@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
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* bitgraph-audit causal reconstruction
|
|
5
|
+
*
|
|
6
|
+
* Rebuilds the observed causal record from an ingested bundle:
|
|
7
|
+
*
|
|
8
|
+
* 1. Partition observed proofs by (signer publicKeyB64, epochId,
|
|
9
|
+
* chainId) per G6. Multiple signer lineages are never merged.
|
|
10
|
+
* Chainless proofs (neither counter nor epochId) stay outside
|
|
11
|
+
* partitions as observed-but-unchained; proofs missing a signer key
|
|
12
|
+
* cannot join a lineage and are listed as unpartitioned.
|
|
13
|
+
* 2. Within each partition, reconstruct chain components from prevB64
|
|
14
|
+
* hash links as the PRIMARY evidence: an edge exists exactly when a
|
|
15
|
+
* proof's prevB64 equals the canonical proof hash of another member
|
|
16
|
+
* (the hash computeProofHash returns over the full canonical signed
|
|
17
|
+
* body, which is what the enclave writes into prevB64). Counters are
|
|
18
|
+
* ordering and anomaly evidence, never the reconstruction mechanism.
|
|
19
|
+
* 3. Derive epoch relationships per G1: epochs are independent chains;
|
|
20
|
+
* cross-epoch ordering comes ONLY from verified epochLink lineage
|
|
21
|
+
* (hard edges), with a typed extension point for anchor-derived
|
|
22
|
+
* one-sided bounds (populated by the anchor analysis stage). Epochs
|
|
23
|
+
* with neither are observed-but-unordered, which is never divergence.
|
|
24
|
+
*
|
|
25
|
+
* Verification status and chain topology stay separate dimensions:
|
|
26
|
+
* reconstruction includes verifier-failed proofs as observed members and
|
|
27
|
+
* never reinterprets a verification failure as a chain anomaly (or the
|
|
28
|
+
* reverse). Validity enters exactly once, and conservatively: an epoch
|
|
29
|
+
* lineage edge counts as hard ordering evidence only when both endpoint
|
|
30
|
+
* proofs are intrinsically valid.
|
|
31
|
+
*
|
|
32
|
+
* Run this after verifyObservedProofs. Isolated validity rechecks reset
|
|
33
|
+
* the verify package's module-level epoch link state (see validity.ts).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type {
|
|
37
|
+
ChainComponent,
|
|
38
|
+
ChainPartition,
|
|
39
|
+
EpochLineageEdge,
|
|
40
|
+
EpochLinkFields,
|
|
41
|
+
EpochRecord,
|
|
42
|
+
EpochRelationshipResult,
|
|
43
|
+
IngestResult,
|
|
44
|
+
ObservedProof,
|
|
45
|
+
PartitionKey,
|
|
46
|
+
ReconstructionResult,
|
|
47
|
+
} from "./types.js";
|
|
48
|
+
import { byCounterThenHash, isIntrinsicallyValid, parseCounter, pushMap } from "./validity.js";
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Public API
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reconstruct partitions, chain components, and epoch relationships from
|
|
56
|
+
* an ingested bundle. Read-only over the ingest result. Deterministic:
|
|
57
|
+
* the same bundle always produces the same structures in the same order.
|
|
58
|
+
*/
|
|
59
|
+
export async function reconstructChains(ingest: IngestResult): Promise<ReconstructionResult> {
|
|
60
|
+
// -----------------------------------------------------------------------
|
|
61
|
+
// 1. Partition per G6.
|
|
62
|
+
// -----------------------------------------------------------------------
|
|
63
|
+
const unchained: string[] = [];
|
|
64
|
+
const unpartitioned: string[] = [];
|
|
65
|
+
const partitionsByKey = new Map<string, { key: PartitionKey; members: ObservedProof[] }>();
|
|
66
|
+
|
|
67
|
+
for (const proof of ingest.proofs) {
|
|
68
|
+
if (proof.chainless) {
|
|
69
|
+
unchained.push(proof.proofHash);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (proof.publicKeyB64 === undefined) {
|
|
73
|
+
unpartitioned.push(proof.proofHash);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const key: PartitionKey = {
|
|
77
|
+
publicKeyB64: proof.publicKeyB64,
|
|
78
|
+
...(proof.epochId !== undefined ? { epochId: proof.epochId } : {}),
|
|
79
|
+
chainId: proof.chainId,
|
|
80
|
+
};
|
|
81
|
+
const mapKey = JSON.stringify([key.publicKeyB64, key.epochId ?? null, key.chainId]);
|
|
82
|
+
const existing = partitionsByKey.get(mapKey);
|
|
83
|
+
if (existing === undefined) partitionsByKey.set(mapKey, { key, members: [proof] });
|
|
84
|
+
else existing.members.push(proof);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sortedEntries = [...partitionsByKey.values()].sort((a, b) =>
|
|
88
|
+
comparePartitionKeys(a.key, b.key)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// -----------------------------------------------------------------------
|
|
92
|
+
// 2. Reconstruct components per partition from hash links.
|
|
93
|
+
// -----------------------------------------------------------------------
|
|
94
|
+
const partitions: ChainPartition[] = [];
|
|
95
|
+
/** Member hashes that have an in-partition successor (for terminal checks). */
|
|
96
|
+
const hasInPartitionSuccessor = new Set<string>();
|
|
97
|
+
/** proofHash -> partition map key, for cross-partition lookups. */
|
|
98
|
+
const partitionOf = new Map<string, PartitionKey>();
|
|
99
|
+
|
|
100
|
+
for (const entry of sortedEntries) {
|
|
101
|
+
const partition = buildPartition(entry.key, entry.members, hasInPartitionSuccessor);
|
|
102
|
+
partitions.push(partition);
|
|
103
|
+
for (const hash of partition.memberProofHashes) partitionOf.set(hash, entry.key);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -----------------------------------------------------------------------
|
|
107
|
+
// 3. Epoch relationships per G1.
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
const epochRelationships = await buildEpochRelationships(
|
|
110
|
+
ingest,
|
|
111
|
+
partitions,
|
|
112
|
+
hasInPartitionSuccessor,
|
|
113
|
+
partitionOf
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
partitions,
|
|
118
|
+
unchainedProofHashes: unchained,
|
|
119
|
+
unpartitionedProofHashes: unpartitioned,
|
|
120
|
+
epochRelationships,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Partition and component construction
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function comparePartitionKeys(a: PartitionKey, b: PartitionKey): number {
|
|
129
|
+
if (a.chainId !== b.chainId) return a.chainId < b.chainId ? -1 : 1;
|
|
130
|
+
const ea = a.epochId ?? "";
|
|
131
|
+
const eb = b.epochId ?? "";
|
|
132
|
+
if (ea !== eb) return ea < eb ? -1 : 1;
|
|
133
|
+
if (a.publicKeyB64 !== b.publicKeyB64) return a.publicKeyB64 < b.publicKeyB64 ? -1 : 1;
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildPartition(
|
|
138
|
+
key: PartitionKey,
|
|
139
|
+
members: ObservedProof[],
|
|
140
|
+
hasInPartitionSuccessor: Set<string>
|
|
141
|
+
): ChainPartition {
|
|
142
|
+
const byHash = new Map<string, ObservedProof>(members.map((m) => [m.proofHash, m]));
|
|
143
|
+
|
|
144
|
+
// Hash-link edges: successor's prevB64 equals a member's canonical hash.
|
|
145
|
+
const successors = new Map<string, ObservedProof[]>();
|
|
146
|
+
const resolvedPrev = new Set<string>(); // member hashes whose prevB64 resolved in-partition
|
|
147
|
+
|
|
148
|
+
// Union-find over member hashes.
|
|
149
|
+
const parent = new Map<string, string>();
|
|
150
|
+
for (const m of members) parent.set(m.proofHash, m.proofHash);
|
|
151
|
+
const find = (x: string): string => {
|
|
152
|
+
let root = x;
|
|
153
|
+
while ((parent.get(root) as string) !== root) root = parent.get(root) as string;
|
|
154
|
+
// Path compression.
|
|
155
|
+
let cursor = x;
|
|
156
|
+
while (cursor !== root) {
|
|
157
|
+
const next = parent.get(cursor) as string;
|
|
158
|
+
parent.set(cursor, root);
|
|
159
|
+
cursor = next;
|
|
160
|
+
}
|
|
161
|
+
return root;
|
|
162
|
+
};
|
|
163
|
+
const union = (a: string, b: string): void => {
|
|
164
|
+
const ra = find(a);
|
|
165
|
+
const rb = find(b);
|
|
166
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const m of members) {
|
|
170
|
+
if (m.prevB64 === undefined) continue;
|
|
171
|
+
const pred = byHash.get(m.prevB64);
|
|
172
|
+
if (pred === undefined || pred.proofHash === m.proofHash) continue;
|
|
173
|
+
resolvedPrev.add(m.proofHash);
|
|
174
|
+
pushMap(successors, pred.proofHash, m);
|
|
175
|
+
hasInPartitionSuccessor.add(pred.proofHash);
|
|
176
|
+
union(pred.proofHash, m.proofHash);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Group members into connected components.
|
|
180
|
+
const membersByRoot = new Map<string, ObservedProof[]>();
|
|
181
|
+
for (const m of members) pushMap(membersByRoot, find(m.proofHash), m);
|
|
182
|
+
|
|
183
|
+
const components: ChainComponent[] = [];
|
|
184
|
+
for (const componentMembers of membersByRoot.values()) {
|
|
185
|
+
components.push(buildComponent(componentMembers, successors, resolvedPrev));
|
|
186
|
+
}
|
|
187
|
+
components.sort(compareComponents);
|
|
188
|
+
|
|
189
|
+
const memberProofHashes = [...members].sort(byCounterThenHash).map((m) => m.proofHash);
|
|
190
|
+
return { key, memberProofHashes, components };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildComponent(
|
|
194
|
+
componentMembers: ObservedProof[],
|
|
195
|
+
successors: Map<string, ObservedProof[]>,
|
|
196
|
+
resolvedPrev: Set<string>
|
|
197
|
+
): ChainComponent {
|
|
198
|
+
const sorted = [...componentMembers].sort(byCounterThenHash);
|
|
199
|
+
const genesis = sorted.filter((m) => m.prevB64 === undefined);
|
|
200
|
+
const broken = sorted.filter((m) => m.prevB64 !== undefined && !resolvedPrev.has(m.proofHash));
|
|
201
|
+
const terminals = sorted.filter((m) => (successors.get(m.proofHash) ?? []).length === 0);
|
|
202
|
+
|
|
203
|
+
// Link-order traversal: deterministic iterative DFS from genesis and
|
|
204
|
+
// broken-link entry points, following successor hash links, branches
|
|
205
|
+
// ordered by counter then hash. Iterative so long chains never hit
|
|
206
|
+
// recursion limits.
|
|
207
|
+
const visited = new Set<string>();
|
|
208
|
+
const order: string[] = [];
|
|
209
|
+
const visitFrom = (startHash: string): void => {
|
|
210
|
+
const stack: string[] = [startHash];
|
|
211
|
+
while (stack.length > 0) {
|
|
212
|
+
const hash = stack.pop() as string;
|
|
213
|
+
if (visited.has(hash)) continue;
|
|
214
|
+
visited.add(hash);
|
|
215
|
+
order.push(hash);
|
|
216
|
+
const next = [...(successors.get(hash) ?? [])].sort(byCounterThenHash);
|
|
217
|
+
for (let i = next.length - 1; i >= 0; i--) stack.push((next[i] as ObservedProof).proofHash);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
for (const start of [...genesis, ...broken]) visitFrom(start.proofHash);
|
|
221
|
+
// Defensive completeness: prevB64 links cannot form cycles among
|
|
222
|
+
// observed members (a cycle needs a proof whose canonical SHA-256 hash
|
|
223
|
+
// appears inside its own signed body, a hash fixpoint), so every member
|
|
224
|
+
// is reachable from an entry point. Cover any leftovers anyway.
|
|
225
|
+
for (const m of sorted) {
|
|
226
|
+
if (!visited.has(m.proofHash)) visitFrom(m.proofHash);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Counter evidence over both position kinds (G2: a proof spans two
|
|
230
|
+
// positions, its slotCounter and its commit counter).
|
|
231
|
+
let min: bigint | undefined;
|
|
232
|
+
let max: bigint | undefined;
|
|
233
|
+
for (const m of componentMembers) {
|
|
234
|
+
for (const value of [m.counter, m.slotCounter]) {
|
|
235
|
+
const n = parseCounter(value);
|
|
236
|
+
if (n === undefined) continue;
|
|
237
|
+
if (min === undefined || n < min) min = n;
|
|
238
|
+
if (max === undefined || n > max) max = n;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
memberProofHashes: order,
|
|
244
|
+
genesisProofHashes: genesis.map((m) => m.proofHash),
|
|
245
|
+
brokenLinkProofHashes: broken.map((m) => m.proofHash),
|
|
246
|
+
terminalProofHashes: terminals.map((m) => m.proofHash),
|
|
247
|
+
hasCounterEvidence: min !== undefined,
|
|
248
|
+
...(min !== undefined && max !== undefined
|
|
249
|
+
? { positionRange: { min: String(min), max: String(max) } }
|
|
250
|
+
: {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function compareComponents(a: ChainComponent, b: ChainComponent): number {
|
|
255
|
+
const ma = a.positionRange?.min;
|
|
256
|
+
const mb = b.positionRange?.min;
|
|
257
|
+
if (ma !== undefined && mb !== undefined && ma !== mb) {
|
|
258
|
+
return BigInt(ma) < BigInt(mb) ? -1 : 1;
|
|
259
|
+
}
|
|
260
|
+
if (ma !== undefined && mb === undefined) return -1;
|
|
261
|
+
if (ma === undefined && mb !== undefined) return 1;
|
|
262
|
+
const ha = a.memberProofHashes[0] ?? "";
|
|
263
|
+
const hb = b.memberProofHashes[0] ?? "";
|
|
264
|
+
return ha < hb ? -1 : ha > hb ? 1 : 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Epoch relationships (G1)
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
async function buildEpochRelationships(
|
|
272
|
+
ingest: IngestResult,
|
|
273
|
+
partitions: ChainPartition[],
|
|
274
|
+
hasInPartitionSuccessor: Set<string>,
|
|
275
|
+
partitionOf: Map<string, PartitionKey>
|
|
276
|
+
): Promise<EpochRelationshipResult> {
|
|
277
|
+
// Epoch inventory from partitioned proofs.
|
|
278
|
+
const epochAccum = new Map<
|
|
279
|
+
string,
|
|
280
|
+
{ chainIds: Set<string>; publicKeys: Set<string>; proofCount: number }
|
|
281
|
+
>();
|
|
282
|
+
for (const partition of partitions) {
|
|
283
|
+
const epochId = partition.key.epochId;
|
|
284
|
+
if (epochId === undefined) continue;
|
|
285
|
+
let accum = epochAccum.get(epochId);
|
|
286
|
+
if (accum === undefined) {
|
|
287
|
+
accum = { chainIds: new Set(), publicKeys: new Set(), proofCount: 0 };
|
|
288
|
+
epochAccum.set(epochId, accum);
|
|
289
|
+
}
|
|
290
|
+
accum.chainIds.add(partition.key.chainId);
|
|
291
|
+
accum.publicKeys.add(partition.key.publicKeyB64);
|
|
292
|
+
accum.proofCount += partition.memberProofHashes.length;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const byHash = new Map<string, ObservedProof>(ingest.proofs.map((p) => [p.proofHash, p]));
|
|
296
|
+
|
|
297
|
+
// Analyze every observed epochLink, in observation order.
|
|
298
|
+
const edges: EpochLineageEdge[] = [];
|
|
299
|
+
for (const proof of ingest.proofs) {
|
|
300
|
+
if (!proof.hasEpochLink) continue;
|
|
301
|
+
const link = extractEpochLink(proof);
|
|
302
|
+
if (link === null) continue; // malformed shape; the verification dimension reports it
|
|
303
|
+
edges.push(
|
|
304
|
+
await analyzeEdge(link, proof, byHash, epochAccum, hasInPartitionSuccessor, partitionOf)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Transitive ordering over hard edges only.
|
|
309
|
+
const orderedPairs = deriveOrderedPairs(edges);
|
|
310
|
+
|
|
311
|
+
// Epoch records.
|
|
312
|
+
const linkedEpochs = new Set<string>();
|
|
313
|
+
const directPredecessors = new Map<string, Set<string>>();
|
|
314
|
+
const directSuccessors = new Map<string, Set<string>>();
|
|
315
|
+
for (const edge of edges) {
|
|
316
|
+
if (!edge.hardEdge) continue;
|
|
317
|
+
linkedEpochs.add(edge.fromEpochId);
|
|
318
|
+
linkedEpochs.add(edge.toEpochId);
|
|
319
|
+
addToSetMap(directPredecessors, edge.toEpochId, edge.fromEpochId);
|
|
320
|
+
addToSetMap(directSuccessors, edge.fromEpochId, edge.toEpochId);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const epochs: EpochRecord[] = [...epochAccum.entries()]
|
|
324
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
325
|
+
.map(([epochId, accum]) => ({
|
|
326
|
+
epochId,
|
|
327
|
+
chainIds: [...accum.chainIds].sort(),
|
|
328
|
+
publicKeysB64: [...accum.publicKeys].sort(),
|
|
329
|
+
proofCount: accum.proofCount,
|
|
330
|
+
ordering: linkedEpochs.has(epochId) ? ("linked" as const) : ("observed-but-unordered" as const),
|
|
331
|
+
linkedPredecessorEpochIds: [...(directPredecessors.get(epochId) ?? [])].sort(),
|
|
332
|
+
linkedSuccessorEpochIds: [...(directSuccessors.get(epochId) ?? [])].sort(),
|
|
333
|
+
}));
|
|
334
|
+
|
|
335
|
+
return { epochs, edges, orderedPairs };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Extract the six declared epochLink fields; null when any is not a string. */
|
|
339
|
+
function extractEpochLink(proof: ObservedProof): EpochLinkFields | null {
|
|
340
|
+
const commit = (proof.proof as unknown as Record<string, unknown>)["commit"];
|
|
341
|
+
if (commit === null || typeof commit !== "object") return null;
|
|
342
|
+
const raw = (commit as Record<string, unknown>)["epochLink"];
|
|
343
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
344
|
+
const link = raw as Record<string, unknown>;
|
|
345
|
+
const fields = [
|
|
346
|
+
"prevEpochId",
|
|
347
|
+
"prevPublicKeyB64",
|
|
348
|
+
"prevCounter",
|
|
349
|
+
"prevProofHashB64",
|
|
350
|
+
"toEpochId",
|
|
351
|
+
"toPublicKeyB64",
|
|
352
|
+
] as const;
|
|
353
|
+
const out: Record<string, string> = {};
|
|
354
|
+
for (const field of fields) {
|
|
355
|
+
const value = link[field];
|
|
356
|
+
if (typeof value !== "string" || value.length === 0) return null;
|
|
357
|
+
out[field] = value;
|
|
358
|
+
}
|
|
359
|
+
return out as unknown as EpochLinkFields;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function analyzeEdge(
|
|
363
|
+
link: EpochLinkFields,
|
|
364
|
+
viaProof: ObservedProof,
|
|
365
|
+
byHash: Map<string, ObservedProof>,
|
|
366
|
+
epochAccum: Map<string, { chainIds: Set<string>; publicKeys: Set<string>; proofCount: number }>,
|
|
367
|
+
hasInPartitionSuccessor: Set<string>,
|
|
368
|
+
partitionOf: Map<string, PartitionKey>
|
|
369
|
+
): Promise<EpochLineageEdge> {
|
|
370
|
+
const viaProofValid = await isIntrinsicallyValid(viaProof);
|
|
371
|
+
const predecessor = byHash.get(link.prevProofHashB64);
|
|
372
|
+
|
|
373
|
+
if (predecessor === undefined) {
|
|
374
|
+
const priorEpochObserved = epochAccum.has(link.prevEpochId);
|
|
375
|
+
return {
|
|
376
|
+
fromEpochId: link.prevEpochId,
|
|
377
|
+
toEpochId: link.toEpochId,
|
|
378
|
+
viaProofHash: viaProof.proofHash,
|
|
379
|
+
link,
|
|
380
|
+
resolution: priorEpochObserved ? "terminal-missing" : "dangling",
|
|
381
|
+
viaProofValid,
|
|
382
|
+
hardEdge: false,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const inconsistencies: string[] = [];
|
|
387
|
+
if (predecessor.epochId !== link.prevEpochId) {
|
|
388
|
+
inconsistencies.push(
|
|
389
|
+
`the referenced proof's epochId (${predecessor.epochId ?? "absent"}) does not match epochLink.prevEpochId (${link.prevEpochId})`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (predecessor.publicKeyB64 !== link.prevPublicKeyB64) {
|
|
393
|
+
inconsistencies.push(
|
|
394
|
+
"the referenced proof's signer key does not match epochLink.prevPublicKeyB64"
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (!countersEqual(predecessor.counter, link.prevCounter)) {
|
|
398
|
+
inconsistencies.push(
|
|
399
|
+
`the referenced proof's counter (${predecessor.counter ?? "absent"}) does not match epochLink.prevCounter (${link.prevCounter})`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const predecessorValid = await isIntrinsicallyValid(predecessor);
|
|
404
|
+
const metadataConsistent = inconsistencies.length === 0;
|
|
405
|
+
const predecessorPartitioned = partitionOf.has(predecessor.proofHash);
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
fromEpochId: link.prevEpochId,
|
|
409
|
+
toEpochId: link.toEpochId,
|
|
410
|
+
viaProofHash: viaProof.proofHash,
|
|
411
|
+
link,
|
|
412
|
+
resolution: "matched",
|
|
413
|
+
predecessorProofHash: predecessor.proofHash,
|
|
414
|
+
metadataConsistent,
|
|
415
|
+
...(metadataConsistent ? {} : { inconsistencies }),
|
|
416
|
+
...(predecessorPartitioned
|
|
417
|
+
? { referencedProofIsTerminal: !hasInPartitionSuccessor.has(predecessor.proofHash) }
|
|
418
|
+
: {}),
|
|
419
|
+
viaProofValid,
|
|
420
|
+
predecessorValid,
|
|
421
|
+
hardEdge: metadataConsistent && viaProofValid && predecessorValid,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function countersEqual(a: string | undefined, b: string): boolean {
|
|
426
|
+
if (a === undefined) return false;
|
|
427
|
+
if (a === b) return true;
|
|
428
|
+
const na = parseCounter(a);
|
|
429
|
+
const nb = parseCounter(b);
|
|
430
|
+
return na !== undefined && nb !== undefined && na === nb;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Transitive closure over hard edges. Pairs reachable in both directions
|
|
435
|
+
* (only a lineage cycle produces this) are removed: contradictory
|
|
436
|
+
* ordering evidence is never asserted either way. The cycle itself is
|
|
437
|
+
* classified by the anomaly stage.
|
|
438
|
+
*/
|
|
439
|
+
function deriveOrderedPairs(
|
|
440
|
+
edges: EpochLineageEdge[]
|
|
441
|
+
): Array<{ beforeEpochId: string; afterEpochId: string }> {
|
|
442
|
+
const adjacency = new Map<string, Set<string>>();
|
|
443
|
+
const nodes = new Set<string>();
|
|
444
|
+
for (const edge of edges) {
|
|
445
|
+
if (!edge.hardEdge) continue;
|
|
446
|
+
addToSetMap(adjacency, edge.fromEpochId, edge.toEpochId);
|
|
447
|
+
nodes.add(edge.fromEpochId);
|
|
448
|
+
nodes.add(edge.toEpochId);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const pairs = new Set<string>();
|
|
452
|
+
for (const start of nodes) {
|
|
453
|
+
// Iterative DFS reachability.
|
|
454
|
+
const reached = new Set<string>();
|
|
455
|
+
const stack = [...(adjacency.get(start) ?? [])];
|
|
456
|
+
while (stack.length > 0) {
|
|
457
|
+
const node = stack.pop() as string;
|
|
458
|
+
if (reached.has(node)) continue;
|
|
459
|
+
reached.add(node);
|
|
460
|
+
for (const next of adjacency.get(node) ?? []) stack.push(next);
|
|
461
|
+
}
|
|
462
|
+
for (const node of reached) {
|
|
463
|
+
if (node !== start) pairs.add(`${start}${node}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const result: Array<{ beforeEpochId: string; afterEpochId: string }> = [];
|
|
468
|
+
for (const pair of pairs) {
|
|
469
|
+
const [before, after] = pair.split("") as [string, string];
|
|
470
|
+
if (pairs.has(`${after}${before}`)) continue; // contradictory: assert neither
|
|
471
|
+
result.push({ beforeEpochId: before, afterEpochId: after });
|
|
472
|
+
}
|
|
473
|
+
result.sort((a, b) =>
|
|
474
|
+
a.beforeEpochId !== b.beforeEpochId
|
|
475
|
+
? a.beforeEpochId < b.beforeEpochId
|
|
476
|
+
? -1
|
|
477
|
+
: 1
|
|
478
|
+
: a.afterEpochId < b.afterEpochId
|
|
479
|
+
? -1
|
|
480
|
+
: a.afterEpochId > b.afterEpochId
|
|
481
|
+
? 1
|
|
482
|
+
: 0
|
|
483
|
+
);
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function addToSetMap(map: Map<string, Set<string>>, key: string, value: string): void {
|
|
488
|
+
const set = map.get(key);
|
|
489
|
+
if (set === undefined) map.set(key, new Set([value]));
|
|
490
|
+
else set.add(value);
|
|
491
|
+
}
|