@kokuin/controller 0.1.0 → 0.2.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/lib/epoch-events.d.ts +139 -0
- package/lib/epoch-events.js +177 -0
- package/lib/epoch.d.ts +90 -0
- package/lib/epoch.js +505 -0
- package/lib/events.d.ts +97 -143
- package/lib/events.js +94 -143
- package/lib/fold.d.ts +78 -100
- package/lib/fold.js +200 -225
- package/lib/history.d.ts +102 -51
- package/lib/history.js +160 -71
- package/lib/identity.d.ts +13 -36
- package/lib/identity.js +27 -63
- package/lib/index.d.ts +11 -6
- package/lib/index.js +10 -5
- package/lib/keystore-identity.d.ts +1 -4
- package/lib/keystore-identity.js +11 -8
- package/lib/limits.d.ts +29 -0
- package/lib/limits.js +27 -0
- package/lib/log.d.ts +124 -0
- package/lib/log.js +268 -0
- package/lib/members.d.ts +103 -0
- package/lib/members.js +152 -0
- package/lib/profiles.js +7 -1
- package/lib/resolver.d.ts +34 -29
- package/lib/resolver.js +64 -68
- package/lib/state-resolver.d.ts +63 -27
- package/lib/state-resolver.js +130 -53
- package/lib/state.d.ts +10 -26
- package/lib/state.js +23 -28
- package/lib/supersede.d.ts +22 -63
- package/lib/supersede.js +100 -186
- package/package.json +12 -12
package/lib/epoch.js
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { digestOf } from './canonical.js';
|
|
2
|
+
import { isEpochEventShape, verifyEpochSignature } from './epoch-events.js';
|
|
3
|
+
import { keyFromTarget } from './events.js';
|
|
4
|
+
import { MAX_EPOCH_EVENTS, MAX_FAILED_SIGNATURES } from './limits.js';
|
|
5
|
+
import { createDenialMatcher, isRootKey, memberCarriesKey } from './members.js';
|
|
6
|
+
// Merging an epoch. The epoch's events are a set, not a sequence: nothing here may depend on the order
|
|
7
|
+
// they were presented in, how often, or which copy of a duplicate arrived first. The three places that
|
|
8
|
+
// could leak order are closed deliberately -- duplicates collapse by digest, ready events are taken
|
|
9
|
+
// by ascending digest, and an event's causal state is a function of its parents alone.
|
|
10
|
+
// Why a member-lifecycle event is refused. Each lands in `rejected` like any other refusal and never
|
|
11
|
+
// fails the fold; named so a test can tell which rule refused an event.
|
|
12
|
+
/** A member-signed add. Only root admits members, so sock-puppets cannot renew each other forever. */ export const ADD_NOT_ROOT = 'only root may add a member';
|
|
13
|
+
/** An add pinning a key on, or committed by, the generation's root chain, or the recovery key. */ export const ADD_KEY_IS_ROOT = 'member key is a root key';
|
|
14
|
+
/** An add whose DID is not a `did:key` or long-form `did:peer:4` carrying exactly its pin. */ export const ADD_DID_MISMATCH = 'member DID does not carry its key';
|
|
15
|
+
/** An add of a DID or key that a denial in its causal past targets. */ export const ADD_KEY_DENIED = 'member is denied in the causal state';
|
|
16
|
+
/** An add of a key an effective member of its causal past already pins. */ export const ADD_KEY_ALREADY_MEMBER = 'member key is already an effective member';
|
|
17
|
+
/** A member renewing an incarnation pinning its own key. */ export const SELF_RENEW = 'a member may not renew itself';
|
|
18
|
+
/** A member removing an incarnation pinning its own key. */ export const SELF_REMOVE = 'a member may not remove itself';
|
|
19
|
+
/** A renew or remove naming an add its causal past does not hold. */ export const UNKNOWN_MEMBER = 'target is not a member of the causal state';
|
|
20
|
+
/** A member revoke targeting an effective member of its causal past: members leave through remove. */ export const MEMBER_REVOKES_MEMBER = 'a member may not revoke a member';
|
|
21
|
+
/** A revoke denying a key the chain head publishes in `k` or `ka`. */ export const REVOKE_NAMES_ROOT_KEY = 'revoke targets a key the chain publishes';
|
|
22
|
+
/**
|
|
23
|
+
* What a presentation fails with, as a whole, when the signatures on its copies that do not verify
|
|
24
|
+
* exceed {@link MAX_FAILED_SIGNATURES}. A request limit, so it shares `LOG_TOO_LARGE`'s prefix.
|
|
25
|
+
*/ export const TOO_MANY_FAILED_SIGNATURES = 'controller log exceeds request limits: too many signatures fail to verify';
|
|
26
|
+
/** What {@link foldEpoch} uses when a caller lowers nothing: pinned so a test can see it. */ export const DEFAULT_EPOCH_LIMITS = {
|
|
27
|
+
MAX_EPOCH_EVENTS
|
|
28
|
+
};
|
|
29
|
+
function emptyState() {
|
|
30
|
+
return {
|
|
31
|
+
added: new Map(),
|
|
32
|
+
removed: new Set(),
|
|
33
|
+
leases: new Map(),
|
|
34
|
+
deny: new Set()
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function copyState(state) {
|
|
38
|
+
return {
|
|
39
|
+
added: new Map(state.added),
|
|
40
|
+
removed: new Set(state.removed),
|
|
41
|
+
leases: new Map(state.leases),
|
|
42
|
+
deny: new Set(state.deny)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** `into` becomes the join of itself and `other`. */ function joinInto(into, other) {
|
|
46
|
+
for (const [add, entry] of other.added)into.added.set(add, entry);
|
|
47
|
+
for (const add of other.removed)into.removed.add(add);
|
|
48
|
+
for (const [add, lease] of other.leases){
|
|
49
|
+
const current = into.leases.get(add);
|
|
50
|
+
if (current === undefined || lease > current) into.leases.set(add, lease);
|
|
51
|
+
}
|
|
52
|
+
for (const entry of other.deny)into.deny.add(entry);
|
|
53
|
+
}
|
|
54
|
+
function anchorState(anchor) {
|
|
55
|
+
const state = emptyState();
|
|
56
|
+
for (const member of anchor.members.values()){
|
|
57
|
+
state.added.set(member.add, {
|
|
58
|
+
did: member.did,
|
|
59
|
+
key: member.key,
|
|
60
|
+
add: member.add,
|
|
61
|
+
lease: member.lease
|
|
62
|
+
});
|
|
63
|
+
if (member.removed) state.removed.add(member.add);
|
|
64
|
+
}
|
|
65
|
+
for (const entry of anchor.deny)state.deny.add(entry);
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether any denial in `deny` targets `member`: the one predicate, `denialTargetsMember`, through the
|
|
70
|
+
* fold's memoised `matches`.
|
|
71
|
+
*/ function isDenied(deny, member, matches) {
|
|
72
|
+
for (const entry of deny){
|
|
73
|
+
if (matches(entry, member)) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Added, not removed, not denied: a member whose events the state authorises. The same test the head
|
|
79
|
+
* applies to `MemberState.effective`, here asked of a causal state.
|
|
80
|
+
*/ function isEffective(state, member, matches) {
|
|
81
|
+
return !state.removed.has(member.add) && !isDenied(state.deny, member, matches);
|
|
82
|
+
}
|
|
83
|
+
function exhausted(budget) {
|
|
84
|
+
return budget.failed > MAX_FAILED_SIGNATURES;
|
|
85
|
+
}
|
|
86
|
+
/** Why an event is refused outright, or `null` when it is a candidate to evaluate. */ function intrinsicRefusal(event, did, anchor) {
|
|
87
|
+
if (event.i !== did) return 'event names a different controller';
|
|
88
|
+
if (event.g !== anchor.gen) return 'event names a different generation';
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Among the copies that verify, the one whose signature list sorts first, so two parties that saw the
|
|
93
|
+
* same event under different valid signatures remember the same bytes, whatever order each saw them
|
|
94
|
+
* in. Each distinct signature list is checked once, in ascending order, stopping at the first that
|
|
95
|
+
* verifies: repeating a copy costs no extra verification, and neither does any list sorting after a
|
|
96
|
+
* genuine one. Every list that fails is charged to `budget`; the walk stops once it is spent.
|
|
97
|
+
*/ function canonicalCopy(copies, verifies, budget) {
|
|
98
|
+
const distinct = new Map();
|
|
99
|
+
for (const copy of copies){
|
|
100
|
+
// JSON, not a join: signatures are untrusted strings and may hold any separator, and a lossy key
|
|
101
|
+
// would let a forgery spelling the genuine list as one string shadow it, checked first.
|
|
102
|
+
const key = JSON.stringify(copy.sigs);
|
|
103
|
+
if (!distinct.has(key)) distinct.set(key, copy);
|
|
104
|
+
}
|
|
105
|
+
for (const key of [
|
|
106
|
+
...distinct.keys()
|
|
107
|
+
].sort()){
|
|
108
|
+
const copy = distinct.get(key);
|
|
109
|
+
if (verifies(copy)) return copy;
|
|
110
|
+
budget.failed += copy.sigs.length;
|
|
111
|
+
if (exhausted(budget)) return undefined;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Judge `event` against the state its parents established, and say what it adds. `null` reason means
|
|
117
|
+
* it applies; the returned `effect` is then the event's own contribution.
|
|
118
|
+
*/ function evaluate(candidate, causal, anchor, roots, matches, budget) {
|
|
119
|
+
const { event, digest } = candidate;
|
|
120
|
+
// A copy of the same body under a different signature is not a different event, so the event is
|
|
121
|
+
// authentic if any presented copy is. Judging only the first would let a forged copy shadow the
|
|
122
|
+
// genuine one depending on arrival order. A trusted copy was judged so when it was kept.
|
|
123
|
+
const authenticate = (verifies)=>candidate.trusted ?? canonicalCopy(candidate.copies, verifies, budget);
|
|
124
|
+
let signer = null;
|
|
125
|
+
if (event.by === undefined) {
|
|
126
|
+
candidate.authentic = authenticate((c)=>verifyEpochSignature(c, {
|
|
127
|
+
rootKeys: anchor.keys
|
|
128
|
+
}));
|
|
129
|
+
if (candidate.authentic === undefined) {
|
|
130
|
+
return {
|
|
131
|
+
reason: 'invalid root signature'
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
const member = causal.added.get(event.by);
|
|
136
|
+
if (member === undefined) return {
|
|
137
|
+
reason: 'signer is not a member of the causal state'
|
|
138
|
+
};
|
|
139
|
+
if (causal.removed.has(event.by)) return {
|
|
140
|
+
reason: 'signer was removed in the causal state'
|
|
141
|
+
};
|
|
142
|
+
if (isDenied(causal.deny, member, matches)) return {
|
|
143
|
+
reason: 'signer is denied in the causal state'
|
|
144
|
+
};
|
|
145
|
+
candidate.authentic = authenticate((c)=>verifyEpochSignature(c, {
|
|
146
|
+
memberKey: member.key
|
|
147
|
+
}));
|
|
148
|
+
if (candidate.authentic === undefined) {
|
|
149
|
+
return {
|
|
150
|
+
reason: 'invalid member signature'
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
signer = member;
|
|
154
|
+
}
|
|
155
|
+
const effect = emptyState();
|
|
156
|
+
switch(event.t){
|
|
157
|
+
case 'rev':
|
|
158
|
+
{
|
|
159
|
+
// A member removes members; it does not deny them. Denial dominates membership and a removal
|
|
160
|
+
// does not, so a revoke would be a removal that skips the self-remove rule and that root's own
|
|
161
|
+
// add could never undo short of a settlement: a denied key or DID refuses every later add of it.
|
|
162
|
+
// Every member of the causal past is shielded, removed or denied ones included -- otherwise one
|
|
163
|
+
// member removes another and a second denies it on top, and root can no longer re-admit that
|
|
164
|
+
// key under any DID. Root alone may deny a member it can see. The shield is the causal past
|
|
165
|
+
// only: a member root adds concurrently is not in it, so a member on a branch that has not seen
|
|
166
|
+
// the add can deny its key or DID, and root's re-add is refused (`ADD_KEY_DENIED`) until a
|
|
167
|
+
// settlement drops the denial. An accepted stolen-member risk; see the security reference.
|
|
168
|
+
if (signer !== null) {
|
|
169
|
+
for (const member of causal.added.values()){
|
|
170
|
+
if (matches(event.x, member)) {
|
|
171
|
+
return {
|
|
172
|
+
reason: MEMBER_REVOKES_MEMBER
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Any signer, root included: a denied published key would leave a head whose `k` or `ka`
|
|
178
|
+
// resolves to nothing (the `KeyState.deny` invariant). Retired keys may be denied.
|
|
179
|
+
const key = keyFromTarget(event.x);
|
|
180
|
+
if (key != null && (anchor.keys.includes(key) || anchor.agreement.includes(key))) {
|
|
181
|
+
return {
|
|
182
|
+
reason: REVOKE_NAMES_ROOT_KEY
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
effect.deny.add(event.x);
|
|
186
|
+
return {
|
|
187
|
+
effect
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
case 'add':
|
|
191
|
+
{
|
|
192
|
+
if (signer !== null) return {
|
|
193
|
+
reason: ADD_NOT_ROOT
|
|
194
|
+
};
|
|
195
|
+
const entry = {
|
|
196
|
+
did: event.did,
|
|
197
|
+
key: event.key,
|
|
198
|
+
add: digest,
|
|
199
|
+
lease: event.lease
|
|
200
|
+
};
|
|
201
|
+
// Ed25519 only, and the DID carries exactly the pin: a grant verifier that reads the DID and
|
|
202
|
+
// one that reads the pin must agree on the key. A non-Ed25519 pin fails here too.
|
|
203
|
+
if (!memberCarriesKey(entry.did, entry.key)) return {
|
|
204
|
+
reason: ADD_DID_MISMATCH
|
|
205
|
+
};
|
|
206
|
+
// The same disjointness a snapshot obeys, by the same predicate: no management device holds a
|
|
207
|
+
// root key, so member authority and root authority are never mistaken for each other.
|
|
208
|
+
if (isRootKey(entry.key, roots)) return {
|
|
209
|
+
reason: ADD_KEY_IS_ROOT
|
|
210
|
+
};
|
|
211
|
+
// Judged against the causal past only. A denial concurrent with the add does not refuse it --
|
|
212
|
+
// neither may be dropped for arriving second -- it makes the merged member non-effective.
|
|
213
|
+
if (isDenied(causal.deny, entry, matches)) return {
|
|
214
|
+
reason: ADD_KEY_DENIED
|
|
215
|
+
};
|
|
216
|
+
// One key, one live incarnation along any causal line: a second effective add of the same key
|
|
217
|
+
// would be an alias whose renewals the self-renew rule could only catch by key anyway, and
|
|
218
|
+
// root has no reason to issue one. Concurrent adds (a retry) cannot see each other, so both
|
|
219
|
+
// stand; pinned-key equality is what keeps them from renewing each other.
|
|
220
|
+
for (const member of causal.added.values()){
|
|
221
|
+
if (member.key === entry.key && isEffective(causal, member, matches)) {
|
|
222
|
+
return {
|
|
223
|
+
reason: ADD_KEY_ALREADY_MEMBER
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
effect.added.set(digest, entry);
|
|
228
|
+
return {
|
|
229
|
+
effect
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
case 'ren':
|
|
233
|
+
case 'rmv':
|
|
234
|
+
{
|
|
235
|
+
// An incarnation is its add digest: a renew or remove of an add the causal past never held
|
|
236
|
+
// has no target, and a delayed one of an old incarnation never reaches a re-add of its key.
|
|
237
|
+
const target = causal.added.get(event.add);
|
|
238
|
+
if (target === undefined) return {
|
|
239
|
+
reason: UNKNOWN_MEMBER
|
|
240
|
+
};
|
|
241
|
+
// "Another member" is another pinned key, whatever the DID or incarnation: one key added
|
|
242
|
+
// twice (as a `did:key` and as a peer DID, or by a retried add) is still one device.
|
|
243
|
+
if (signer !== null && signer.key === target.key) {
|
|
244
|
+
return {
|
|
245
|
+
reason: event.t === 'ren' ? SELF_RENEW : SELF_REMOVE
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// Neither looks at whether the target is removed: removal dominates renewal at the join, so a
|
|
249
|
+
// renewal of a removed member is harmless and refusing it would make the outcome depend on
|
|
250
|
+
// which branch the renewer happened to see.
|
|
251
|
+
if (event.t === 'ren') effect.leases.set(event.add, event.lease);
|
|
252
|
+
else effect.removed.add(event.add);
|
|
253
|
+
return {
|
|
254
|
+
effect
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
default:
|
|
258
|
+
return {
|
|
259
|
+
reason: 'unknown epoch event type'
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Insert `digest` into `sorted` keeping ascending order. */ function insertSorted(sorted, digest) {
|
|
264
|
+
let lo = 0;
|
|
265
|
+
let hi = sorted.length;
|
|
266
|
+
while(lo < hi){
|
|
267
|
+
const mid = lo + hi >>> 1;
|
|
268
|
+
if (sorted[mid] < digest) lo = mid + 1;
|
|
269
|
+
else hi = mid;
|
|
270
|
+
}
|
|
271
|
+
sorted.splice(lo, 0, digest);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Merge the current epoch into the chain's last state. Total: whatever `events` holds, the result is
|
|
275
|
+
* a head or a refusal, never a throw -- an entry that is not a valid epoch event is counted and
|
|
276
|
+
* dropped. The one refusal is the request limit on failed signatures ({@link TOO_MANY_FAILED_SIGNATURES}),
|
|
277
|
+
* which `trusted` copies never count towards.
|
|
278
|
+
*/ export function foldEpochDetailed(params) {
|
|
279
|
+
const { did, chainStates, events } = params;
|
|
280
|
+
const maxEvents = params.limits?.MAX_EPOCH_EVENTS ?? DEFAULT_EPOCH_LIMITS.MAX_EPOCH_EVENTS;
|
|
281
|
+
const anchor = chainStates[chainStates.length - 1];
|
|
282
|
+
const reasons = new Map();
|
|
283
|
+
let rejected = 0;
|
|
284
|
+
// Digests of chain events: a parent naming one is a cross-epoch parent, not a missing one.
|
|
285
|
+
const chainDigests = new Set(chainStates.map((state)=>state.digest));
|
|
286
|
+
// The root keys an add's pin must avoid: every `k`/`ka`/`n` on the anchor's generation, read off
|
|
287
|
+
// the chain alone as the rotate's snapshot check reads them, so an add and a snapshot refuse the
|
|
288
|
+
// same pins. A reset opens a generation, so earlier generations' keys are not counted -- they
|
|
289
|
+
// cannot sign for this one. Skipped states repeat their prior, which the sets absorb.
|
|
290
|
+
const rootKeys = new Set();
|
|
291
|
+
const commitments = new Set();
|
|
292
|
+
for (const state of chainStates){
|
|
293
|
+
if (state.gen !== anchor.gen) continue;
|
|
294
|
+
for (const key of [
|
|
295
|
+
...state.keys,
|
|
296
|
+
...state.agreement
|
|
297
|
+
])rootKeys.add(key);
|
|
298
|
+
for (const commitment of state.next)commitments.add(commitment);
|
|
299
|
+
}
|
|
300
|
+
const roots = {
|
|
301
|
+
keys: rootKeys,
|
|
302
|
+
commitments,
|
|
303
|
+
recovery: anchor.recovery
|
|
304
|
+
};
|
|
305
|
+
// One canonicalisation per distinct DID for the whole fold: see `createDenialMatcher`.
|
|
306
|
+
const matches = createDenialMatcher();
|
|
307
|
+
// 1. Triage. Dedupe by digest; an event of another epoch is ignored silently (its effect is in the
|
|
308
|
+
// newer snapshot or was dropped by it), but it is remembered by digest so that naming it as a
|
|
309
|
+
// parent reads as a cross-epoch parent rather than a missing one.
|
|
310
|
+
const candidates = new Map();
|
|
311
|
+
const otherEpochs = new Set();
|
|
312
|
+
const counted = new Set();
|
|
313
|
+
// Memory first, so every presented copy of a held body finds its trusted copy already there. Held
|
|
314
|
+
// copies are this verifier's own, so one that is not of this epoch, or not an epoch event at all
|
|
315
|
+
// (a corrupted store), is simply not used: it is no refusal of anything presented.
|
|
316
|
+
for (const entry of params.trusted ?? []){
|
|
317
|
+
if (!isEpochEventShape(entry) || entry.event.e !== anchor.epoch) continue;
|
|
318
|
+
if (intrinsicRefusal(entry.event, did, anchor) !== null) continue;
|
|
319
|
+
const digest = digestOf(entry.event);
|
|
320
|
+
if (!candidates.has(digest)) {
|
|
321
|
+
candidates.set(digest, {
|
|
322
|
+
digest,
|
|
323
|
+
event: entry.event,
|
|
324
|
+
copies: [
|
|
325
|
+
entry
|
|
326
|
+
],
|
|
327
|
+
trusted: entry
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const entry of events){
|
|
332
|
+
if (!isEpochEventShape(entry)) {
|
|
333
|
+
rejected++;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const digest = digestOf(entry.event);
|
|
337
|
+
if (entry.event.e !== anchor.epoch) {
|
|
338
|
+
otherEpochs.add(digest);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const refusal = intrinsicRefusal(entry.event, did, anchor);
|
|
342
|
+
if (refusal !== null) {
|
|
343
|
+
reasons.set(digest, refusal);
|
|
344
|
+
if (!counted.has(digest)) {
|
|
345
|
+
counted.add(digest);
|
|
346
|
+
rejected++;
|
|
347
|
+
}
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const existing = candidates.get(digest);
|
|
351
|
+
if (existing === undefined) {
|
|
352
|
+
candidates.set(digest, {
|
|
353
|
+
digest,
|
|
354
|
+
event: entry.event,
|
|
355
|
+
copies: [
|
|
356
|
+
entry
|
|
357
|
+
]
|
|
358
|
+
});
|
|
359
|
+
} else if (existing.trusted === undefined) {
|
|
360
|
+
existing.copies.push(entry);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Parents. A parent that is neither the anchor nor a candidate is judged by what it is: another
|
|
364
|
+
// epoch's event or a chain event refuses the child; a refused same-epoch event leaves it pending;
|
|
365
|
+
// anything else is simply not here yet, which is also pending.
|
|
366
|
+
//
|
|
367
|
+
// Two passes: every candidate's parents are classified before any edge is registered, so an event
|
|
368
|
+
// refused for a cross-epoch parent never leaves an edge behind for a surviving parent to follow.
|
|
369
|
+
const crossEpoch = [];
|
|
370
|
+
for (const candidate of candidates.values()){
|
|
371
|
+
if (candidate.event.p.some((parent)=>parent !== anchor.epoch && !candidates.has(parent) && (otherEpochs.has(parent) || chainDigests.has(parent)))) {
|
|
372
|
+
crossEpoch.push(candidate.digest);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const digest of crossEpoch){
|
|
376
|
+
candidates.delete(digest);
|
|
377
|
+
reasons.set(digest, 'event lists a parent outside its epoch');
|
|
378
|
+
rejected++;
|
|
379
|
+
}
|
|
380
|
+
const children = new Map();
|
|
381
|
+
const waiting = new Map();
|
|
382
|
+
const ready = [];
|
|
383
|
+
for (const candidate of candidates.values()){
|
|
384
|
+
let waits = 0;
|
|
385
|
+
for (const parent of candidate.event.p){
|
|
386
|
+
if (parent === anchor.epoch) continue;
|
|
387
|
+
waits++;
|
|
388
|
+
if (candidates.has(parent)) {
|
|
389
|
+
const list = children.get(parent);
|
|
390
|
+
if (list === undefined) children.set(parent, [
|
|
391
|
+
candidate.digest
|
|
392
|
+
]);
|
|
393
|
+
else list.push(candidate.digest);
|
|
394
|
+
}
|
|
395
|
+
// Anything else -- a refused event, an absent one -- never applies, so this edge never clears.
|
|
396
|
+
}
|
|
397
|
+
waiting.set(candidate.digest, waits);
|
|
398
|
+
if (waits === 0) insertSorted(ready, candidate.digest);
|
|
399
|
+
}
|
|
400
|
+
// 2-4. Kahn's algorithm, smallest ready digest first. `after` is memoised per digest and dropped
|
|
401
|
+
// once its last child has read it. That frees a plain chain, but not every shape: a chain e1..en
|
|
402
|
+
// with a side event per ei that also names en keeps about n/2 states alive until en applies. Work
|
|
403
|
+
// and memory are bounded by the request limit (MAX_LOG_EVENTS events of at most MAX_EVENT_PARENTS
|
|
404
|
+
// parents each), roughly events x parents x state size, not by this release.
|
|
405
|
+
const anchorAfter = anchorState(anchor);
|
|
406
|
+
const after = new Map();
|
|
407
|
+
const unread = new Map();
|
|
408
|
+
const applied = [];
|
|
409
|
+
const authentic = new Map();
|
|
410
|
+
const union = copyState(anchorAfter);
|
|
411
|
+
const budget = {
|
|
412
|
+
failed: 0
|
|
413
|
+
};
|
|
414
|
+
let evaluated = 0;
|
|
415
|
+
while(ready.length > 0){
|
|
416
|
+
const digest = ready.shift();
|
|
417
|
+
// `ready` only ever holds candidates, and none is deleted once edges are registered.
|
|
418
|
+
const candidate = candidates.get(digest);
|
|
419
|
+
evaluated++;
|
|
420
|
+
const causal = emptyState();
|
|
421
|
+
let seenAnchor = false;
|
|
422
|
+
for (const parent of candidate.event.p){
|
|
423
|
+
if (parent === anchor.epoch) {
|
|
424
|
+
if (!seenAnchor) joinInto(causal, anchorAfter);
|
|
425
|
+
seenAnchor = true;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const parentAfter = after.get(parent);
|
|
429
|
+
if (parentAfter !== undefined) joinInto(causal, parentAfter);
|
|
430
|
+
const left = (unread.get(parent) ?? 1) - 1;
|
|
431
|
+
if (left <= 0) {
|
|
432
|
+
unread.delete(parent);
|
|
433
|
+
after.delete(parent);
|
|
434
|
+
} else {
|
|
435
|
+
unread.set(parent, left);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const outcome = evaluate(candidate, causal, anchor, roots, matches, budget);
|
|
439
|
+
if (exhausted(budget)) {
|
|
440
|
+
return {
|
|
441
|
+
ok: false,
|
|
442
|
+
reason: TOO_MANY_FAILED_SIGNATURES
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if ('reason' in outcome) {
|
|
446
|
+
rejected++;
|
|
447
|
+
reasons.set(digest, outcome.reason);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
applied.push(digest);
|
|
451
|
+
if (candidate.authentic !== undefined) authentic.set(digest, candidate.authentic);
|
|
452
|
+
joinInto(union, outcome.effect);
|
|
453
|
+
const kids = children.get(digest);
|
|
454
|
+
if (kids !== undefined && kids.length > 0) {
|
|
455
|
+
joinInto(causal, outcome.effect);
|
|
456
|
+
after.set(digest, causal);
|
|
457
|
+
unread.set(digest, kids.length);
|
|
458
|
+
for (const kid of kids){
|
|
459
|
+
// Edges are registered only between candidates, each with its own count.
|
|
460
|
+
const left = waiting.get(kid);
|
|
461
|
+
waiting.set(kid, left - 1);
|
|
462
|
+
if (left === 1) insertSorted(ready, kid);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const pending = candidates.size - evaluated;
|
|
467
|
+
const appliedSet = new Set(applied);
|
|
468
|
+
for (const digest of candidates.keys()){
|
|
469
|
+
if (!appliedSet.has(digest) && !reasons.has(digest)) {
|
|
470
|
+
reasons.set(digest, 'a parent is missing or was not applied');
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const members = new Map();
|
|
474
|
+
for (const [add, entry] of union.added){
|
|
475
|
+
const lease = Math.max(entry.lease, union.leases.get(add) ?? 0);
|
|
476
|
+
const removed = union.removed.has(add);
|
|
477
|
+
const effective = !removed && !isDenied(union.deny, entry, matches);
|
|
478
|
+
members.set(add, {
|
|
479
|
+
...entry,
|
|
480
|
+
lease,
|
|
481
|
+
removed,
|
|
482
|
+
effective
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
ok: true,
|
|
487
|
+
head: {
|
|
488
|
+
...anchor,
|
|
489
|
+
deny: union.deny,
|
|
490
|
+
members,
|
|
491
|
+
saturated: applied.length > maxEvents
|
|
492
|
+
},
|
|
493
|
+
applied,
|
|
494
|
+
pending,
|
|
495
|
+
rejected,
|
|
496
|
+
reasons,
|
|
497
|
+
authentic
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/** {@link foldEpochDetailed} without the per-event reasons and copies. */ export function foldEpoch(params) {
|
|
501
|
+
const detail = foldEpochDetailed(params);
|
|
502
|
+
if (!detail.ok) return detail;
|
|
503
|
+
const { reasons: _reasons, authentic: _authentic, ...result } = detail;
|
|
504
|
+
return result;
|
|
505
|
+
}
|