@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/anomalies.ts
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* bitgraph-audit anomaly classification
|
|
5
|
+
*
|
|
6
|
+
* Classifies the reconstructed record with stable machine-readable codes
|
|
7
|
+
* and builds divergence records for every conflict between valid proofs.
|
|
8
|
+
*
|
|
9
|
+
* The G2 two-position rule governs the gap logic: every proof consumes TWO
|
|
10
|
+
* counter positions, its slotCounter N and its commit counter M with
|
|
11
|
+
* N < M (usually M = N + 1, but the enclave guarantees only the strict
|
|
12
|
+
* inequality, and concurrent slot allocation can interleave). A position
|
|
13
|
+
* within a partition's observed [min, max] range is EXPLAINED when it
|
|
14
|
+
* appears as any observed proof's commit counter OR any observed proof's
|
|
15
|
+
* slotCounter. Only unexplained positions are reported, always as
|
|
16
|
+
* unexplained from the supplied evidence (a proof absent from the bundle
|
|
17
|
+
* OR a slot that was allocated but never committed, indistinguishable
|
|
18
|
+
* offline), never as asserted authority failure. Anchor proofs are
|
|
19
|
+
* ordinary chain members; their positions count normally.
|
|
20
|
+
*
|
|
21
|
+
* Dimension discipline: verification status and chain topology stay
|
|
22
|
+
* separate throughout. Topology anomalies are computed over all observed
|
|
23
|
+
* members regardless of verification outcome (with statuses reported
|
|
24
|
+
* alongside), a verifier failure is never reinterpreted as a chain
|
|
25
|
+
* anomaly, and a chain observation never changes a verification result.
|
|
26
|
+
* Divergence parties, however, must be valid: conflicts are declared
|
|
27
|
+
* between valid proofs only (per their definitions), and cryptographically
|
|
28
|
+
* invalid proofs appear as observed context, never as competing branches.
|
|
29
|
+
*
|
|
30
|
+
* Run after verifyObservedProofs and reconstructChains. Isolated validity
|
|
31
|
+
* rechecks reset the verify package's module-level epoch link state (see
|
|
32
|
+
* validity.ts).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type {
|
|
36
|
+
AnomalyReport,
|
|
37
|
+
ChainAnomaly,
|
|
38
|
+
ChainPartition,
|
|
39
|
+
DivergenceParty,
|
|
40
|
+
DivergenceRecord,
|
|
41
|
+
EpochLineageEdge,
|
|
42
|
+
IngestResult,
|
|
43
|
+
ObservedProof,
|
|
44
|
+
PartitionKey,
|
|
45
|
+
ReconstructionResult,
|
|
46
|
+
UnexplainedPositionsDetail,
|
|
47
|
+
} from "./types.js";
|
|
48
|
+
import { byCounterThenHash, isIntrinsicallyValid, parseCounter, pushMap } from "./validity.js";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cap on the flat unexplained-position list. Ranges and the total count
|
|
52
|
+
* are always complete; only the flat list truncates, with the truncated
|
|
53
|
+
* flag set.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_LISTED_POSITIONS = 10_000;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Public API
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classify anomalies and build divergence records over a reconstructed
|
|
63
|
+
* bundle. Read-only over its inputs. Deterministic: anomalies follow
|
|
64
|
+
* partition order, then a fixed per-partition analysis order (gaps,
|
|
65
|
+
* counter collisions, slot collisions, cross-kind position reuse,
|
|
66
|
+
* predecessor reuse, chain breaks, multiple genesis, slot order), then
|
|
67
|
+
* epoch link anomalies.
|
|
68
|
+
*/
|
|
69
|
+
export async function classifyAnomalies(
|
|
70
|
+
ingest: IngestResult,
|
|
71
|
+
reconstruction: ReconstructionResult
|
|
72
|
+
): Promise<AnomalyReport> {
|
|
73
|
+
const anomalies: ChainAnomaly[] = [];
|
|
74
|
+
const divergences: DivergenceRecord[] = [];
|
|
75
|
+
|
|
76
|
+
const byHash = new Map<string, ObservedProof>(ingest.proofs.map((p) => [p.proofHash, p]));
|
|
77
|
+
const partitionOf = new Map<string, PartitionKey>();
|
|
78
|
+
for (const partition of reconstruction.partitions) {
|
|
79
|
+
for (const hash of partition.memberProofHashes) partitionOf.set(hash, partition.key);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const partition of reconstruction.partitions) {
|
|
83
|
+
const members = partition.memberProofHashes.map((h) => byHash.get(h) as ObservedProof);
|
|
84
|
+
analyzeGaps(partition, members, anomalies);
|
|
85
|
+
await analyzeCollisions(partition, members, "counter", anomalies, divergences);
|
|
86
|
+
await analyzeCollisions(partition, members, "slot", anomalies, divergences);
|
|
87
|
+
await analyzeCrossKindPositionReuse(partition, members, anomalies, divergences);
|
|
88
|
+
await analyzePredecessorReuse(partition, members, byHash, anomalies, divergences);
|
|
89
|
+
analyzeChainBreaks(partition, members, byHash, partitionOf, anomalies);
|
|
90
|
+
await analyzeMultipleGenesis(partition, members, anomalies, divergences);
|
|
91
|
+
analyzeSlotOrder(partition, members, anomalies);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await analyzeEpochLinks(reconstruction.epochRelationships.edges, byHash, anomalies, divergences);
|
|
95
|
+
|
|
96
|
+
return { anomalies, divergences };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// G2 gap logic
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function analyzeGaps(
|
|
104
|
+
partition: ChainPartition,
|
|
105
|
+
members: ObservedProof[],
|
|
106
|
+
anomalies: ChainAnomaly[]
|
|
107
|
+
): void {
|
|
108
|
+
// Explained positions: every observed commit counter and every observed
|
|
109
|
+
// slot counter (two positions per proof, never deduplicated against
|
|
110
|
+
// each other).
|
|
111
|
+
const explained = new Set<bigint>();
|
|
112
|
+
for (const m of members) {
|
|
113
|
+
const commit = parseCounter(m.counter);
|
|
114
|
+
if (commit !== undefined) explained.add(commit);
|
|
115
|
+
const slot = parseCounter(m.slotCounter);
|
|
116
|
+
if (slot !== undefined) explained.add(slot);
|
|
117
|
+
}
|
|
118
|
+
if (explained.size < 2) return; // no interior positions can exist
|
|
119
|
+
|
|
120
|
+
const sorted = [...explained].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
121
|
+
const ranges: Array<{ start: string; end: string }> = [];
|
|
122
|
+
const positions: string[] = [];
|
|
123
|
+
let count = 0n;
|
|
124
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
125
|
+
const low = (sorted[i - 1] as bigint) + 1n;
|
|
126
|
+
const high = (sorted[i] as bigint) - 1n;
|
|
127
|
+
if (low > high) continue;
|
|
128
|
+
ranges.push({ start: String(low), end: String(high) });
|
|
129
|
+
count += high - low + 1n;
|
|
130
|
+
for (let p = low; p <= high && positions.length < MAX_LISTED_POSITIONS; p++) {
|
|
131
|
+
positions.push(String(p));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (ranges.length === 0) return;
|
|
135
|
+
|
|
136
|
+
const detail: UnexplainedPositionsDetail = {
|
|
137
|
+
count: String(count),
|
|
138
|
+
ranges,
|
|
139
|
+
positions,
|
|
140
|
+
truncated: count > BigInt(positions.length),
|
|
141
|
+
};
|
|
142
|
+
const min = String(sorted[0] as bigint);
|
|
143
|
+
const max = String(sorted[sorted.length - 1] as bigint);
|
|
144
|
+
anomalies.push({
|
|
145
|
+
code: "unexplained-counter-positions",
|
|
146
|
+
partition: partition.key,
|
|
147
|
+
proofHashes: [],
|
|
148
|
+
message:
|
|
149
|
+
`${count} counter position${count === 1n ? "" : "s"} within the observed range [${min}, ${max}] ` +
|
|
150
|
+
`of this partition ${count === 1n ? "is" : "are"} neither a commit position nor a referenced slot position ` +
|
|
151
|
+
`in the supplied bundle. Such ${count === 1n ? "a position" : "positions"} may mean a proof is absent ` +
|
|
152
|
+
`from the bundle, or a slot that was allocated but never committed (a routine, benign occurrence); ` +
|
|
153
|
+
`this offline audit cannot distinguish the two. It does not, by itself, establish that the authority ` +
|
|
154
|
+
`failed to create or withheld any proof.`,
|
|
155
|
+
details: detail as unknown as Record<string, unknown>,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Collisions
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
async function analyzeCollisions(
|
|
164
|
+
partition: ChainPartition,
|
|
165
|
+
members: ObservedProof[],
|
|
166
|
+
kind: "counter" | "slot",
|
|
167
|
+
anomalies: ChainAnomaly[],
|
|
168
|
+
divergences: DivergenceRecord[]
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
const groups = new Map<string, ObservedProof[]>();
|
|
171
|
+
for (const m of members) {
|
|
172
|
+
const raw = kind === "counter" ? m.counter : m.slotCounter;
|
|
173
|
+
if (raw === undefined) continue;
|
|
174
|
+
const parsed = parseCounter(raw);
|
|
175
|
+
pushMap(groups, parsed !== undefined ? String(parsed) : `raw:${raw}`, m);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const [groupKey, group] of sortGroupKeys(groups)) {
|
|
179
|
+
if (group.length < 2) continue;
|
|
180
|
+
const { parties, context } = await splitByValidity(group);
|
|
181
|
+
// Collisions are defined between valid non-identical proofs. Invalid
|
|
182
|
+
// proofs sharing a position stay on the verification dimension.
|
|
183
|
+
if (parties.length < 2) continue;
|
|
184
|
+
|
|
185
|
+
const position = groupKey.startsWith("raw:") ? groupKey.slice(4) : groupKey;
|
|
186
|
+
const code = kind === "counter" ? "counter-collision" : "slot-collision";
|
|
187
|
+
anomalies.push({
|
|
188
|
+
code,
|
|
189
|
+
partition: partition.key,
|
|
190
|
+
proofHashes: group.map((m) => m.proofHash),
|
|
191
|
+
message:
|
|
192
|
+
kind === "counter"
|
|
193
|
+
? `${parties.length} distinct valid proofs claim commit counter ${position} in one partition. ` +
|
|
194
|
+
`The audit does not choose between them; all parties are preserved for adjudication.`
|
|
195
|
+
: `${parties.length} distinct valid proofs reference slot counter ${position} in one partition. ` +
|
|
196
|
+
`A slot position is consumed exactly once; all parties are preserved for adjudication.`,
|
|
197
|
+
details: {
|
|
198
|
+
position,
|
|
199
|
+
validParties: parties.map((m) => m.proofHash),
|
|
200
|
+
invalidObserved: context.map((m) => m.proofHash),
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
divergences.push({
|
|
204
|
+
kind: code,
|
|
205
|
+
partition: partition.key,
|
|
206
|
+
contested: kind === "counter" ? { counter: position } : { slotCounter: position },
|
|
207
|
+
parties: parties.map(toParty),
|
|
208
|
+
invalidContext: context.map(toParty),
|
|
209
|
+
explanation:
|
|
210
|
+
kind === "counter"
|
|
211
|
+
? `Two or more independently valid proof objects claim commit counter ${position} in the same ` +
|
|
212
|
+
`signer, epoch, and chain partition. A commit counter position can be consumed only once, so at ` +
|
|
213
|
+
`most one of these can belong to the authoritative sequence. The audit tool does not choose ` +
|
|
214
|
+
`between them; every party and its predecessor relationship is preserved for adjudication.`
|
|
215
|
+
: `Two or more independently valid proof objects reference slot counter ${position} in the same ` +
|
|
216
|
+
`signer, epoch, and chain partition. A slot position is allocated once and consumed by one ` +
|
|
217
|
+
`commit, so at most one of these can belong to the authoritative sequence. The audit tool does ` +
|
|
218
|
+
`not choose between them.`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Cross-kind position reuse (one causal position double-allocated)
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A single causal position is consumed exactly once: reserved as a slot and
|
|
229
|
+
* later consumed by exactly one commit. The same-kind collision checks
|
|
230
|
+
* above catch two commits at one counter or two slots at one counter. This
|
|
231
|
+
* catches the cross-kind case the collision checks miss: one proof's commit
|
|
232
|
+
* counter equal to a DIFFERENT proof's slotCounter in the same partition,
|
|
233
|
+
* a double-allocation of one position that only enclave malfunction,
|
|
234
|
+
* replay, or compromise can produce. A single proof whose own commit and
|
|
235
|
+
* slot share a position is slot-order-violation, handled separately, and is
|
|
236
|
+
* never flagged here. Only intrinsically valid, distinct proofs are
|
|
237
|
+
* declared parties, consistent with the module's other divergences.
|
|
238
|
+
*/
|
|
239
|
+
async function analyzeCrossKindPositionReuse(
|
|
240
|
+
partition: ChainPartition,
|
|
241
|
+
members: ObservedProof[],
|
|
242
|
+
anomalies: ChainAnomaly[],
|
|
243
|
+
divergences: DivergenceRecord[]
|
|
244
|
+
): Promise<void> {
|
|
245
|
+
const commitAt = new Map<string, ObservedProof[]>();
|
|
246
|
+
const slotAt = new Map<string, ObservedProof[]>();
|
|
247
|
+
for (const m of members) {
|
|
248
|
+
const commit = parseCounter(m.counter);
|
|
249
|
+
if (commit !== undefined) pushMap(commitAt, String(commit), m);
|
|
250
|
+
const slot = parseCounter(m.slotCounter);
|
|
251
|
+
if (slot !== undefined) pushMap(slotAt, String(slot), m);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const positions = [...commitAt.keys()]
|
|
255
|
+
.filter((p) => slotAt.has(p))
|
|
256
|
+
.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0));
|
|
257
|
+
|
|
258
|
+
for (const position of positions) {
|
|
259
|
+
const committers = commitAt.get(position) as ObservedProof[];
|
|
260
|
+
const slotters = slotAt.get(position) as ObservedProof[];
|
|
261
|
+
|
|
262
|
+
// Divergence parties must be intrinsically valid, as everywhere in this
|
|
263
|
+
// module. Invalid proofs sharing the position stay on the verification
|
|
264
|
+
// dimension and appear only as context.
|
|
265
|
+
const validCommitters: ObservedProof[] = [];
|
|
266
|
+
for (const m of committers) if (await isIntrinsicallyValid(m)) validCommitters.push(m);
|
|
267
|
+
const validSlotters: ObservedProof[] = [];
|
|
268
|
+
for (const m of slotters) if (await isIntrinsicallyValid(m)) validSlotters.push(m);
|
|
269
|
+
|
|
270
|
+
// A double-allocation requires a valid commit-side proof and a valid
|
|
271
|
+
// slot-side proof that are DIFFERENT objects. One proof committing and
|
|
272
|
+
// reserving the same position is slot-order-violation, not this.
|
|
273
|
+
const distinctCrossPair = validCommitters.some((c) =>
|
|
274
|
+
validSlotters.some((s) => s.proofHash !== c.proofHash)
|
|
275
|
+
);
|
|
276
|
+
if (!distinctCrossPair) continue;
|
|
277
|
+
|
|
278
|
+
const partyMap = new Map<string, ObservedProof>();
|
|
279
|
+
for (const m of [...validCommitters, ...validSlotters]) partyMap.set(m.proofHash, m);
|
|
280
|
+
const parties = [...partyMap.values()];
|
|
281
|
+
|
|
282
|
+
const involvedMap = new Map<string, ObservedProof>();
|
|
283
|
+
for (const m of [...committers, ...slotters]) involvedMap.set(m.proofHash, m);
|
|
284
|
+
const context = [...involvedMap.values()].filter((m) => !partyMap.has(m.proofHash));
|
|
285
|
+
|
|
286
|
+
anomalies.push({
|
|
287
|
+
code: "cross-kind-position-reuse",
|
|
288
|
+
partition: partition.key,
|
|
289
|
+
proofHashes: [...involvedMap.keys()],
|
|
290
|
+
message:
|
|
291
|
+
`Counter position ${position} is committed by one proof and reserved as a slot by a ` +
|
|
292
|
+
`different proof in the same partition. A single causal position is consumed once, as a ` +
|
|
293
|
+
`slot or as a commit, never both across distinct proofs. The audit does not choose between ` +
|
|
294
|
+
`them; all parties are preserved for adjudication.`,
|
|
295
|
+
details: {
|
|
296
|
+
position,
|
|
297
|
+
commitParties: validCommitters.map((m) => m.proofHash),
|
|
298
|
+
slotParties: validSlotters.map((m) => m.proofHash),
|
|
299
|
+
validParties: parties.map((m) => m.proofHash),
|
|
300
|
+
invalidObserved: context.map((m) => m.proofHash),
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
divergences.push({
|
|
304
|
+
kind: "cross-kind-position-reuse",
|
|
305
|
+
partition: partition.key,
|
|
306
|
+
contested: { position },
|
|
307
|
+
parties: parties.map(toParty),
|
|
308
|
+
invalidContext: context.map(toParty),
|
|
309
|
+
explanation:
|
|
310
|
+
`Two or more independently valid proof objects allocate counter position ${position} in the ` +
|
|
311
|
+
`same signer, epoch, and chain partition through different roles: at least one commits at it ` +
|
|
312
|
+
`and at least one reserves it as a slot. Each position in a chain is consumed exactly once, so ` +
|
|
313
|
+
`at most one of these allocations can belong to the authoritative sequence. This pattern only ` +
|
|
314
|
+
`arises through enclave malfunction, replay, or compromise. The audit tool does not choose ` +
|
|
315
|
+
`between them; every party is preserved for adjudication.`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Predecessor reuse (forks)
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
async function analyzePredecessorReuse(
|
|
325
|
+
partition: ChainPartition,
|
|
326
|
+
members: ObservedProof[],
|
|
327
|
+
byHash: Map<string, ObservedProof>,
|
|
328
|
+
anomalies: ChainAnomaly[],
|
|
329
|
+
divergences: DivergenceRecord[]
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
const byPrev = new Map<string, ObservedProof[]>();
|
|
332
|
+
for (const m of members) {
|
|
333
|
+
if (m.prevB64 === undefined) continue;
|
|
334
|
+
pushMap(byPrev, m.prevB64, m);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
for (const [prevB64, claimants] of sortGroupKeys(byPrev)) {
|
|
338
|
+
if (claimants.length < 2) continue;
|
|
339
|
+
const { parties, context } = await splitByValidity(claimants);
|
|
340
|
+
if (parties.length < 2) continue;
|
|
341
|
+
|
|
342
|
+
const predecessorObserved = byHash.has(prevB64);
|
|
343
|
+
anomalies.push({
|
|
344
|
+
code: "predecessor-reuse",
|
|
345
|
+
partition: partition.key,
|
|
346
|
+
proofHashes: claimants.map((m) => m.proofHash),
|
|
347
|
+
message:
|
|
348
|
+
`${parties.length} distinct valid proofs claim the same predecessor hash: a detectable fork. ` +
|
|
349
|
+
`All branches are preserved; the audit does not choose between them.`,
|
|
350
|
+
details: {
|
|
351
|
+
prevB64,
|
|
352
|
+
predecessorObserved,
|
|
353
|
+
validParties: parties.map((m) => m.proofHash),
|
|
354
|
+
invalidObserved: context.map((m) => m.proofHash),
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
divergences.push({
|
|
358
|
+
kind: "predecessor-reuse",
|
|
359
|
+
partition: partition.key,
|
|
360
|
+
contested: { prevB64 },
|
|
361
|
+
parties: parties.map(toParty),
|
|
362
|
+
invalidContext: context.map(toParty),
|
|
363
|
+
explanation:
|
|
364
|
+
`Two or more independently valid proof objects name the same predecessor proof in their prevB64 ` +
|
|
365
|
+
`hash link. Each proof has exactly one successor in an intact chain, so this is a fork: the ` +
|
|
366
|
+
`history diverges into multiple branches at that predecessor` +
|
|
367
|
+
`${predecessorObserved ? ", which is itself observed in the bundle" : ", which is absent from the bundle"}. ` +
|
|
368
|
+
`All branches are shown; the audit tool does not select one.`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Chain breaks
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
function analyzeChainBreaks(
|
|
378
|
+
partition: ChainPartition,
|
|
379
|
+
members: ObservedProof[],
|
|
380
|
+
byHash: Map<string, ObservedProof>,
|
|
381
|
+
partitionOf: Map<string, PartitionKey>,
|
|
382
|
+
anomalies: ChainAnomaly[]
|
|
383
|
+
): void {
|
|
384
|
+
const memberHashes = new Set(members.map((m) => m.proofHash));
|
|
385
|
+
for (const m of [...members].sort(byCounterThenHash)) {
|
|
386
|
+
if (m.prevB64 === undefined) continue;
|
|
387
|
+
if (memberHashes.has(m.prevB64)) continue; // resolved in-partition
|
|
388
|
+
|
|
389
|
+
if (!isStrict32ByteBase64(m.prevB64)) {
|
|
390
|
+
anomalies.push({
|
|
391
|
+
code: "chain-break-malformed",
|
|
392
|
+
partition: partition.key,
|
|
393
|
+
proofHashes: [m.proofHash],
|
|
394
|
+
message:
|
|
395
|
+
"commit.prevB64 is not standard base64 of 32 bytes, so it can never match a canonical proof " +
|
|
396
|
+
"hash. The predecessor link is unusable as reconstruction evidence.",
|
|
397
|
+
details: { prevB64: m.prevB64 },
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const elsewhere = byHash.get(m.prevB64);
|
|
403
|
+
if (elsewhere !== undefined) {
|
|
404
|
+
const otherKey = partitionOf.get(elsewhere.proofHash);
|
|
405
|
+
anomalies.push({
|
|
406
|
+
code: "chain-break-cross-partition",
|
|
407
|
+
partition: partition.key,
|
|
408
|
+
proofHashes: [m.proofHash, elsewhere.proofHash],
|
|
409
|
+
message:
|
|
410
|
+
"commit.prevB64 resolves to an observed proof outside this proof's own signer, epoch, and " +
|
|
411
|
+
"chain partition. prevB64 never bridges partitions (each epoch and chain keeps its own hash " +
|
|
412
|
+
"chain), so this link does not join the two.",
|
|
413
|
+
details: {
|
|
414
|
+
prevB64: m.prevB64,
|
|
415
|
+
predecessorProofHash: elsewhere.proofHash,
|
|
416
|
+
predecessorPartition:
|
|
417
|
+
otherKey !== undefined
|
|
418
|
+
? {
|
|
419
|
+
publicKeyB64: otherKey.publicKeyB64,
|
|
420
|
+
...(otherKey.epochId !== undefined ? { epochId: otherKey.epochId } : {}),
|
|
421
|
+
chainId: otherKey.chainId,
|
|
422
|
+
}
|
|
423
|
+
: "unchained-or-unpartitioned",
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
anomalies.push({
|
|
430
|
+
code: "chain-break-missing",
|
|
431
|
+
partition: partition.key,
|
|
432
|
+
proofHashes: [m.proofHash],
|
|
433
|
+
message:
|
|
434
|
+
"commit.prevB64 references a predecessor proof that is absent from the supplied bundle. The " +
|
|
435
|
+
"chain cannot be reconstructed across this link from the supplied evidence; this does not, by " +
|
|
436
|
+
"itself, establish that the predecessor never existed.",
|
|
437
|
+
details: { prevB64: m.prevB64 },
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isStrict32ByteBase64(value: string): boolean {
|
|
443
|
+
if (value.length === 0) return false;
|
|
444
|
+
const decoded = Buffer.from(value, "base64");
|
|
445
|
+
return decoded.length === 32 && decoded.toString("base64") === value;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Multiple genesis
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
async function analyzeMultipleGenesis(
|
|
453
|
+
partition: ChainPartition,
|
|
454
|
+
members: ObservedProof[],
|
|
455
|
+
anomalies: ChainAnomaly[],
|
|
456
|
+
divergences: DivergenceRecord[]
|
|
457
|
+
): Promise<void> {
|
|
458
|
+
// A single proof without prevB64 is the epoch genesis: normal per G1,
|
|
459
|
+
// never an anomaly.
|
|
460
|
+
const genesis = [...members.filter((m) => m.prevB64 === undefined)].sort(byCounterThenHash);
|
|
461
|
+
if (genesis.length < 2) return;
|
|
462
|
+
|
|
463
|
+
const { parties, context } = await splitByValidity(genesis);
|
|
464
|
+
anomalies.push({
|
|
465
|
+
code: "multiple-genesis",
|
|
466
|
+
partition: partition.key,
|
|
467
|
+
proofHashes: genesis.map((m) => m.proofHash),
|
|
468
|
+
message:
|
|
469
|
+
`${genesis.length} proofs in one partition carry no prevB64 link. An epoch has exactly one first ` +
|
|
470
|
+
`proof, so at most one of these can be the genesis of this partition's chain.`,
|
|
471
|
+
details: {
|
|
472
|
+
validParties: parties.map((m) => m.proofHash),
|
|
473
|
+
invalidObserved: context.map((m) => m.proofHash),
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (parties.length < 2) return;
|
|
478
|
+
divergences.push({
|
|
479
|
+
kind: "multiple-genesis",
|
|
480
|
+
partition: partition.key,
|
|
481
|
+
contested: {
|
|
482
|
+
...(partition.key.epochId !== undefined ? { epochId: partition.key.epochId } : {}),
|
|
483
|
+
chainId: partition.key.chainId,
|
|
484
|
+
},
|
|
485
|
+
parties: parties.map(toParty),
|
|
486
|
+
invalidContext: context.map(toParty),
|
|
487
|
+
explanation:
|
|
488
|
+
"Two or more independently valid proof objects in the same signer, epoch, and chain partition " +
|
|
489
|
+
"carry no predecessor link, each presenting itself as the first proof of the chain. An epoch has " +
|
|
490
|
+
"exactly one first proof, so at most one of these can be the genesis. The audit tool does not " +
|
|
491
|
+
"choose between them.",
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
// Slot ordering
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
function analyzeSlotOrder(
|
|
500
|
+
partition: ChainPartition,
|
|
501
|
+
members: ObservedProof[],
|
|
502
|
+
anomalies: ChainAnomaly[]
|
|
503
|
+
): void {
|
|
504
|
+
for (const m of [...members].sort(byCounterThenHash)) {
|
|
505
|
+
const slot = parseCounter(m.slotCounter);
|
|
506
|
+
const commit = parseCounter(m.counter);
|
|
507
|
+
if (slot === undefined || commit === undefined) continue;
|
|
508
|
+
if (slot < commit) continue;
|
|
509
|
+
anomalies.push({
|
|
510
|
+
code: "slot-order-violation",
|
|
511
|
+
partition: partition.key,
|
|
512
|
+
proofHashes: [m.proofHash],
|
|
513
|
+
message:
|
|
514
|
+
"commit.slotCounter is not strictly less than commit.counter. The slot must be allocated " +
|
|
515
|
+
"before the commit that consumes it, so this proof's declared positions contradict the " +
|
|
516
|
+
"nonce-first construction order.",
|
|
517
|
+
details: {
|
|
518
|
+
slotCounter: m.slotCounter as string,
|
|
519
|
+
counter: m.counter as string,
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// Epoch link anomalies
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
async function analyzeEpochLinks(
|
|
530
|
+
edges: EpochLineageEdge[],
|
|
531
|
+
byHash: Map<string, ObservedProof>,
|
|
532
|
+
anomalies: ChainAnomaly[],
|
|
533
|
+
divergences: DivergenceRecord[]
|
|
534
|
+
): Promise<void> {
|
|
535
|
+
// Per-edge resolutions, in edge (observation) order.
|
|
536
|
+
for (const edge of edges) {
|
|
537
|
+
if (edge.resolution === "terminal-missing") {
|
|
538
|
+
anomalies.push({
|
|
539
|
+
code: "epochlink-terminal-missing",
|
|
540
|
+
proofHashes: [edge.viaProofHash],
|
|
541
|
+
message:
|
|
542
|
+
"commit.epochLink references a prior epoch that is observed in the bundle, but the referenced " +
|
|
543
|
+
"terminal proof is absent from the bundle. The lineage claim cannot be confirmed from the " +
|
|
544
|
+
"supplied evidence; this does not, by itself, establish that the terminal never existed.",
|
|
545
|
+
details: { link: { ...edge.link } },
|
|
546
|
+
});
|
|
547
|
+
} else if (edge.resolution === "dangling") {
|
|
548
|
+
anomalies.push({
|
|
549
|
+
code: "epochlink-dangling",
|
|
550
|
+
proofHashes: [edge.viaProofHash],
|
|
551
|
+
message:
|
|
552
|
+
"commit.epochLink references a prior epoch and terminal proof, neither of which is observed " +
|
|
553
|
+
"in the bundle. The lineage claim dangles into unobserved history; it cannot be confirmed from " +
|
|
554
|
+
"the supplied evidence.",
|
|
555
|
+
details: { link: { ...edge.link } },
|
|
556
|
+
});
|
|
557
|
+
} else if (edge.metadataConsistent === false) {
|
|
558
|
+
anomalies.push({
|
|
559
|
+
code: "epochlink-mismatch",
|
|
560
|
+
proofHashes: [
|
|
561
|
+
edge.viaProofHash,
|
|
562
|
+
...(edge.predecessorProofHash !== undefined ? [edge.predecessorProofHash] : []),
|
|
563
|
+
],
|
|
564
|
+
message:
|
|
565
|
+
"commit.epochLink's prevProofHashB64 matches an observed proof, but the link's declared " +
|
|
566
|
+
"epoch, signer key, or counter disagrees with that proof. The hash reference and the declared " +
|
|
567
|
+
"metadata cannot both be right.",
|
|
568
|
+
details: {
|
|
569
|
+
link: { ...edge.link },
|
|
570
|
+
inconsistencies: edge.inconsistencies ?? [],
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Forks: one predecessor tuple consumed by two or more distinct
|
|
577
|
+
// successor epochs (the canonical verifier's single-successor
|
|
578
|
+
// invariant, applied to the observed record).
|
|
579
|
+
const byTuple = new Map<string, EpochLineageEdge[]>();
|
|
580
|
+
for (const edge of edges) {
|
|
581
|
+
pushMap(
|
|
582
|
+
byTuple,
|
|
583
|
+
`${edge.link.prevEpochId}|${edge.link.prevCounter}|${edge.link.prevProofHashB64}`,
|
|
584
|
+
edge
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
for (const [, group] of sortGroupKeys(byTuple)) {
|
|
588
|
+
const successorEpochs = new Set(group.map((e) => e.link.toEpochId));
|
|
589
|
+
if (successorEpochs.size < 2) continue;
|
|
590
|
+
|
|
591
|
+
const viaProofs = group.map((e) => byHash.get(e.viaProofHash) as ObservedProof);
|
|
592
|
+
const { parties, context } = await splitByValidity(viaProofs);
|
|
593
|
+
const sample = (group[0] as EpochLineageEdge).link;
|
|
594
|
+
anomalies.push({
|
|
595
|
+
code: "epochlink-fork",
|
|
596
|
+
proofHashes: group.map((e) => e.viaProofHash),
|
|
597
|
+
message:
|
|
598
|
+
`The same predecessor terminal (epoch ${sample.prevEpochId}, counter ${sample.prevCounter}) is ` +
|
|
599
|
+
`consumed by genesis proofs of ${successorEpochs.size} distinct successor epochs. A predecessor ` +
|
|
600
|
+
`may be consumed by at most one successor epoch, so this is a detectable fork at the epoch ` +
|
|
601
|
+
`boundary. All branches are preserved.`,
|
|
602
|
+
details: {
|
|
603
|
+
prevEpochId: sample.prevEpochId,
|
|
604
|
+
prevCounter: sample.prevCounter,
|
|
605
|
+
prevProofHashB64: sample.prevProofHashB64,
|
|
606
|
+
successorEpochIds: [...successorEpochs].sort(),
|
|
607
|
+
validParties: parties.map((m) => m.proofHash),
|
|
608
|
+
invalidObserved: context.map((m) => m.proofHash),
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
if (parties.length < 2) continue;
|
|
613
|
+
divergences.push({
|
|
614
|
+
kind: "epochlink-fork",
|
|
615
|
+
contested: {
|
|
616
|
+
prevEpochId: sample.prevEpochId,
|
|
617
|
+
prevCounter: sample.prevCounter,
|
|
618
|
+
prevProofHashB64: sample.prevProofHashB64,
|
|
619
|
+
},
|
|
620
|
+
parties: parties.map(toParty),
|
|
621
|
+
invalidContext: context.map(toParty),
|
|
622
|
+
explanation:
|
|
623
|
+
"Two or more independently valid epoch-genesis proofs consume the same terminal proof of a " +
|
|
624
|
+
"prior epoch through their epochLink. The single-successor rule allows a terminal to be " +
|
|
625
|
+
"consumed by exactly one successor epoch, so at most one of these lineages can be the " +
|
|
626
|
+
"authoritative continuation. Note that whichever party verified second in this run carries a " +
|
|
627
|
+
"fork-detection failure from the canonical verifier; that ordering is an artifact of " +
|
|
628
|
+
"verification order, not evidence of which branch is authoritative. The audit tool does not " +
|
|
629
|
+
"choose between them.",
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Cycles: the claimed lineage graph must be acyclic; a cycle means the
|
|
634
|
+
// epochs' ordering claims contradict each other. Detected over all
|
|
635
|
+
// matched edges regardless of validity, because the contradiction is a
|
|
636
|
+
// property of the observed claims.
|
|
637
|
+
const adjacency = new Map<string, Set<string>>();
|
|
638
|
+
for (const edge of edges) {
|
|
639
|
+
if (edge.resolution !== "matched") continue;
|
|
640
|
+
const set = adjacency.get(edge.fromEpochId);
|
|
641
|
+
if (set === undefined) adjacency.set(edge.fromEpochId, new Set([edge.toEpochId]));
|
|
642
|
+
else set.add(edge.toEpochId);
|
|
643
|
+
}
|
|
644
|
+
for (const cycle of findCycles(adjacency)) {
|
|
645
|
+
const involved = edges
|
|
646
|
+
.filter(
|
|
647
|
+
(e) =>
|
|
648
|
+
e.resolution === "matched" &&
|
|
649
|
+
cycle.includes(e.fromEpochId) &&
|
|
650
|
+
cycle.includes(e.toEpochId)
|
|
651
|
+
)
|
|
652
|
+
.map((e) => e.viaProofHash);
|
|
653
|
+
anomalies.push({
|
|
654
|
+
code: "epochlink-cycle",
|
|
655
|
+
proofHashes: involved,
|
|
656
|
+
message:
|
|
657
|
+
`Epoch lineage links form a cycle among ${cycle.length} epochs. Each link claims its successor ` +
|
|
658
|
+
`epoch came after its predecessor, so a cycle means these ordering claims contradict each ` +
|
|
659
|
+
`other; no ordering is asserted for the epochs involved.`,
|
|
660
|
+
details: { epochIds: cycle },
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Find simple cycles in the epoch lineage graph via iterative DFS with
|
|
667
|
+
* an explicit path stack. Returns each cycle once, rotated so the
|
|
668
|
+
* lexicographically smallest epochId leads, sorted deterministically.
|
|
669
|
+
*/
|
|
670
|
+
function findCycles(adjacency: Map<string, Set<string>>): string[][] {
|
|
671
|
+
const cycles = new Map<string, string[]>();
|
|
672
|
+
const visited = new Set<string>();
|
|
673
|
+
|
|
674
|
+
const nodes = [...adjacency.keys()].sort();
|
|
675
|
+
for (const start of nodes) {
|
|
676
|
+
if (visited.has(start)) continue;
|
|
677
|
+
// Iterative DFS tracking the current path.
|
|
678
|
+
const path: string[] = [];
|
|
679
|
+
const onPath = new Set<string>();
|
|
680
|
+
const iterators = new Map<string, string[]>();
|
|
681
|
+
const stack: string[] = [start];
|
|
682
|
+
while (stack.length > 0) {
|
|
683
|
+
const node = stack[stack.length - 1] as string;
|
|
684
|
+
if (!onPath.has(node)) {
|
|
685
|
+
onPath.add(node);
|
|
686
|
+
path.push(node);
|
|
687
|
+
iterators.set(node, [...(adjacency.get(node) ?? [])].sort());
|
|
688
|
+
}
|
|
689
|
+
const pending = iterators.get(node) as string[];
|
|
690
|
+
const next = pending.shift();
|
|
691
|
+
if (next === undefined) {
|
|
692
|
+
stack.pop();
|
|
693
|
+
onPath.delete(node);
|
|
694
|
+
path.pop();
|
|
695
|
+
visited.add(node);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (onPath.has(next)) {
|
|
699
|
+
// Back edge: extract the cycle from the path.
|
|
700
|
+
const fromIndex = path.indexOf(next);
|
|
701
|
+
const cycle = path.slice(fromIndex);
|
|
702
|
+
const canonical = canonicalizeCycle(cycle);
|
|
703
|
+
cycles.set(canonical.join("|"), canonical);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
if (!visited.has(next)) stack.push(next);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return [...cycles.values()].sort((a, b) => {
|
|
711
|
+
const ja = a.join("|");
|
|
712
|
+
const jb = b.join("|");
|
|
713
|
+
return ja < jb ? -1 : ja > jb ? 1 : 0;
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function canonicalizeCycle(cycle: string[]): string[] {
|
|
718
|
+
let minIndex = 0;
|
|
719
|
+
for (let i = 1; i < cycle.length; i++) {
|
|
720
|
+
if ((cycle[i] as string) < (cycle[minIndex] as string)) minIndex = i;
|
|
721
|
+
}
|
|
722
|
+
return [...cycle.slice(minIndex), ...cycle.slice(0, minIndex)];
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
// Shared helpers
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
async function splitByValidity(
|
|
730
|
+
group: ObservedProof[]
|
|
731
|
+
): Promise<{ parties: ObservedProof[]; context: ObservedProof[] }> {
|
|
732
|
+
const parties: ObservedProof[] = [];
|
|
733
|
+
const context: ObservedProof[] = [];
|
|
734
|
+
for (const m of group) {
|
|
735
|
+
if (await isIntrinsicallyValid(m)) parties.push(m);
|
|
736
|
+
else context.push(m);
|
|
737
|
+
}
|
|
738
|
+
return { parties, context };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function toParty(m: ObservedProof): DivergenceParty {
|
|
742
|
+
const verification = m.verification;
|
|
743
|
+
return {
|
|
744
|
+
proofHash: m.proofHash,
|
|
745
|
+
sourcePaths: m.sources.map((s) => s.path),
|
|
746
|
+
...(verification !== undefined
|
|
747
|
+
? {
|
|
748
|
+
verificationTier: verification.tier,
|
|
749
|
+
verificationStatus: verification.status,
|
|
750
|
+
...(verification.reason !== undefined ? { verificationReason: verification.reason } : {}),
|
|
751
|
+
}
|
|
752
|
+
: {}),
|
|
753
|
+
...(m.counter !== undefined ? { counter: m.counter } : {}),
|
|
754
|
+
...(m.slotCounter !== undefined ? { slotCounter: m.slotCounter } : {}),
|
|
755
|
+
...(m.prevB64 !== undefined ? { prevB64: m.prevB64 } : {}),
|
|
756
|
+
...(m.publicKeyB64 !== undefined ? { publicKeyB64: m.publicKeyB64 } : {}),
|
|
757
|
+
...(m.epochId !== undefined ? { epochId: m.epochId } : {}),
|
|
758
|
+
chainId: m.chainId,
|
|
759
|
+
...(m.measurement !== undefined ? { measurement: m.measurement } : {}),
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Deterministic group iteration: numeric keys ascending, then raw keys. */
|
|
764
|
+
function sortGroupKeys<V>(groups: Map<string, V>): Array<[string, V]> {
|
|
765
|
+
return [...groups.entries()].sort(([a], [b]) => {
|
|
766
|
+
const na = /^[0-9]+$/.test(a) ? BigInt(a) : undefined;
|
|
767
|
+
const nb = /^[0-9]+$/.test(b) ? BigInt(b) : undefined;
|
|
768
|
+
if (na !== undefined && nb !== undefined && na !== nb) return na < nb ? -1 : 1;
|
|
769
|
+
if (na !== undefined && nb === undefined) return -1;
|
|
770
|
+
if (na === undefined && nb !== undefined) return 1;
|
|
771
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
772
|
+
});
|
|
773
|
+
}
|