p2p-fret 1.0.2 → 1.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.
@@ -81,7 +81,7 @@ function windowGaps(store, m, selfCoord, selfId, filter) {
81
81
  * into a tiny, evenly-spaced arc — an eclipse, or a very young ring — scores high here while
82
82
  * `n` is wildly wrong, because no statistic over the sampled arc can see the arc that was never
83
83
  * sampled. The defense is corroboration from peer-reported estimates (`reportNetworkSize` /
84
- * `calibrateSizeFromSnapshot`), not a better local formula.
84
+ * `calibrateSizeFromPeer`), not a better local formula.
85
85
  */
86
86
  function dispersionFactor(gaps) {
87
87
  const count = gaps.length;
@@ -18,6 +18,36 @@ export interface FretConfig {
18
18
  * spacing window count once, so the run is genuinely spread over time.
19
19
  */
20
20
  deadAfterFailures?: number;
21
+ /**
22
+ * Whole-dial budget, in milliseconds, for a maintenance **redial** — the connection FRET opens
23
+ * on its own to a routing-table peer it is not connected to (default 10 000 ms).
24
+ *
25
+ * A maintenance ping assumes an open connection and is bounded by a 2 s exchange budget; opening
26
+ * one is a separate step with this budget, run outside the stabilization tick. The default
27
+ * restates libp2p's own `dialTimeout` default (10 s), because libp2p applies that only to a dial
28
+ * that carries *no* abort signal — FRET always passes one, so this is the only whole-dial limit
29
+ * in force (libp2p's per-address `addressDialTimeout` still applies underneath). A host on slow
30
+ * relayed links sets it from its link budget: a circuit-relay dial that completes in 9 s is
31
+ * ordinary there. Must be a positive finite number; the constructor throws otherwise.
32
+ */
33
+ dialTimeoutMs?: number;
34
+ /**
35
+ * The ceiling, in milliseconds, the passive stabilization interval may grow to while nothing in
36
+ * the network changes (default 60 000 ms).
37
+ *
38
+ * Passive stabilization runs every 1.5 s while things are changing and doubles that interval
39
+ * after every pass that finds nothing to act on — 1.5 → 3 → 6 → 12 → 24 → 48 s, then this
40
+ * ceiling — snapping back to 1.5 s the moment anything changes (a connection forms or drops, a
41
+ * peer is discovered, a label or liveness verdict moves, an address becomes known). At the
42
+ * default an idle node runs about one pass a minute instead of forty. The cost of a higher
43
+ * ceiling is detection latency for what only a pass can see: a peer that went silent with no
44
+ * disconnect event is first struck on the next pass (up to one interval away) and dead a few
45
+ * seconds after, a neighbour's content is re-checked once per interval, and an off-ring peer
46
+ * whose backoff window expired is re-probed up to one interval late. A host that wants faster
47
+ * detection sets this lower; a value at or below 1.5 s disables backoff altogether.
48
+ * Must be a positive finite number; the constructor throws otherwise.
49
+ */
50
+ maxPassiveIntervalMs?: number;
21
51
  /**
22
52
  * This node's libp2p private key — the key `node.peerId` was derived from.
23
53
  *
@@ -62,6 +92,13 @@ export interface NeighborSnapshotV1 {
62
92
  * an id-keyed object so it truncates like the other lists and has no `__proto__`-key hazard.
63
93
  */
64
94
  hints?: AddressHintV1[];
95
+ /**
96
+ * Fingerprint of the content a receiver acts on — the id lists, the sample's ids, `metadata`,
97
+ * `hints` (see `snapshotDigest` in `service/near-exchange.ts`). A receiver that merged this
98
+ * snapshot caches the digest and skips its next fetch while the sender's ping replies carry the
99
+ * same one. Optional and unversioned like `hints`: absent means "unknown, fetch as before".
100
+ */
101
+ digest?: string;
65
102
  }
66
103
  export interface RouteAndMaybeActV1 {
67
104
  v: 1;
@@ -1,5 +1,6 @@
1
1
  import type { Libp2p } from 'libp2p';
2
2
  import type { StreamCaps } from './protocols.js';
3
+ import { type RpcRequestOptions } from './request.js';
3
4
  import type { RpcOutcome } from './outcome.js';
4
5
  import type { BusyResponseV1 } from '../index.js';
5
6
  export interface PingResponseV1 {
@@ -7,26 +8,33 @@ export interface PingResponseV1 {
7
8
  ts: number;
8
9
  size_estimate?: number;
9
10
  confidence?: number;
11
+ /**
12
+ * The responder's current `NeighborSnapshotV1.digest`, so a neighbour that already merged that
13
+ * content can skip its snapshot fetch. Optional, no version bump: an older responder omits it
14
+ * and the sender fetches as before.
15
+ */
16
+ snapshot_digest?: string;
10
17
  }
11
- export type SizeEstimateProvider = () => {
12
- size_estimate?: number;
13
- confidence?: number;
14
- } | BusyResponseV1 | Promise<{
15
- size_estimate?: number;
16
- confidence?: number;
17
- } | BusyResponseV1>;
18
- export declare function registerPing(node: Libp2p, protocol?: string, getSizeEstimate?: SizeEstimateProvider, onInbound?: (from: string) => void, streamCaps?: StreamCaps): Promise<void>;
18
+ /** The optional fields a responder adds to its pong — see {@link PingResponseV1}. */
19
+ export type PingReplyExtras = Pick<PingResponseV1, 'size_estimate' | 'confidence' | 'snapshot_digest'>;
20
+ /** Supplies {@link PingReplyExtras} per inbound ping, or a busy refusal in place of the pong. */
21
+ export type PingReplyProvider = () => PingReplyExtras | BusyResponseV1 | Promise<PingReplyExtras | BusyResponseV1>;
22
+ export declare function registerPing(node: Libp2p, protocol?: string, getReplyExtras?: PingReplyProvider, onInbound?: (from: string) => void, streamCaps?: StreamCaps): Promise<void>;
19
23
  /**
20
24
  * Ping `peer` over this network's namespaced protocol. Read-only request (the handler replies
21
- * without reading); dial 'always'. `ok.value.ok` is the peer's own pong flag; rttMs on the
22
- * outcome. Truncation/undecodable replies are `decode-error` — the service scores them as
23
- * proof of life.
25
+ * without reading). `dial` defaults to `'always'`; the service's maintenance passes pass
26
+ * `'never'`, because opening a connection is the redial seam's job under its own budget and a
27
+ * 2 s ping budget cannot cover a dial on a slow link. `ok.value.ok` is the peer's own pong flag;
28
+ * rttMs on the outcome. Truncation/undecodable replies are `decode-error` — the service scores
29
+ * them as proof of life.
24
30
  */
25
31
  export declare function sendPing(node: Libp2p, peer: string, protocol?: string, opts?: {
26
32
  signal?: AbortSignal;
27
33
  timeoutMs?: number;
34
+ dial?: RpcRequestOptions<unknown>['dial'];
28
35
  }): Promise<RpcOutcome<{
29
36
  ok: boolean;
30
37
  size_estimate?: number;
31
38
  confidence?: number;
39
+ snapshot_digest?: string;
32
40
  }>>;
@@ -3,7 +3,7 @@ import { rpcRequest } from './request.js';
3
3
  import { parseOrThrow, parsePingResponse } from './validate.js';
4
4
  import { createLogger } from '../logger.js';
5
5
  const log = createLogger('rpc:ping');
6
- export async function registerPing(node, protocol = PROTOCOL_PING, getSizeEstimate, onInbound,
6
+ export async function registerPing(node, protocol = PROTOCOL_PING, getReplyExtras, onInbound,
7
7
  // Trailing and optional so existing positional callers keep compiling. Forwarded verbatim to
8
8
  // `node.handle`; omitting it leaves libp2p's own per-connection defaults.
9
9
  streamCaps = {}) {
@@ -17,51 +17,57 @@ streamCaps = {}) {
17
17
  ...streamCaps,
18
18
  serve: (connection) => {
19
19
  onInbound?.(connection.remotePeer.toString());
20
- return pingReply(getSizeEstimate);
20
+ return pingReply(getReplyExtras);
21
21
  },
22
22
  });
23
23
  }
24
24
  /**
25
- * The body of a ping answer. Split out so the estimate's `try` covers the *provider* alone: while
25
+ * The body of a ping answer. Split out so the provider's `try` covers the *provider* alone: while
26
26
  * it also wrapped the send-and-close tail, a stream that failed mid-reply was logged as a failed
27
- * estimate and then answered a second time on the same broken stream.
27
+ * provider and then answered a second time on the same broken stream.
28
28
  *
29
- * A failed estimate is not a failed ping — it degrades to a plain pong.
29
+ * A failed provider is not a failed ping — it degrades to a plain pong.
30
30
  */
31
- async function pingReply(getSizeEstimate) {
32
- // Busy test on the size-estimate *provider's return* — an in-process value, not a wire reply,
33
- // so this is not a copy of `rpcRequest`'s busy classification.
31
+ async function pingReply(getReplyExtras) {
32
+ // Busy test on the *provider's return* — an in-process value, not a wire reply, so this is not
33
+ // a copy of `rpcRequest`'s busy classification.
34
34
  const isBusy = (res) => typeof res === 'object' && res !== null && 'busy' in res && res.busy === true;
35
35
  const pong = () => ({ ok: true, ts: Date.now() });
36
- if (!getSizeEstimate)
36
+ if (!getReplyExtras)
37
37
  return pong();
38
- let sizeInfo;
38
+ let extras;
39
39
  try {
40
- sizeInfo = await getSizeEstimate();
40
+ extras = await getReplyExtras();
41
41
  }
42
42
  catch (err) {
43
- log.error('getSizeEstimate failed - %e', err);
43
+ log.error('ping reply provider failed - %e', err);
44
44
  return pong();
45
45
  }
46
- if (isBusy(sizeInfo))
47
- return sizeInfo;
46
+ if (isBusy(extras))
47
+ return extras;
48
48
  const response = pong();
49
- if (sizeInfo.size_estimate !== undefined) {
50
- response.size_estimate = sizeInfo.size_estimate;
51
- response.confidence = sizeInfo.confidence;
49
+ if (extras.size_estimate !== undefined) {
50
+ response.size_estimate = extras.size_estimate;
51
+ response.confidence = extras.confidence;
52
52
  }
53
+ if (extras.snapshot_digest !== undefined)
54
+ response.snapshot_digest = extras.snapshot_digest;
53
55
  return response;
54
56
  }
55
57
  /**
56
58
  * Ping `peer` over this network's namespaced protocol. Read-only request (the handler replies
57
- * without reading); dial 'always'. `ok.value.ok` is the peer's own pong flag; rttMs on the
58
- * outcome. Truncation/undecodable replies are `decode-error` — the service scores them as
59
- * proof of life.
59
+ * without reading). `dial` defaults to `'always'`; the service's maintenance passes pass
60
+ * `'never'`, because opening a connection is the redial seam's job under its own budget and a
61
+ * 2 s ping budget cannot cover a dial on a slow link. `ok.value.ok` is the peer's own pong flag;
62
+ * rttMs on the outcome. Truncation/undecodable replies are `decode-error` — the service scores
63
+ * them as proof of life.
60
64
  */
61
65
  export async function sendPing(node, peer, protocol = PROTOCOL_PING, opts = {}) {
62
66
  return rpcRequest(node, peer, protocol, {
63
67
  ...opts,
64
- maxBytes: 1024, // ping reply is tiny, fixed-shape ({ok, size_estimate?, confidence?}); ample headroom
68
+ // A ping reply is tiny and fixed-shape ({ok, ts, size_estimate?, confidence?,
69
+ // snapshot_digest?}) — ~140 bytes at its worst against this cap.
70
+ maxBytes: 1024,
65
71
  // The parser replaces the old `Boolean(r.ok)` coercion: a legal value always encodes as a
66
72
  // boolean, so the coercion could only ever have hidden a malformed peer. A rejection
67
73
  // throws (see `parseOrThrow`) and surfaces as `decode-error` — proof of life, never a
@@ -48,16 +48,17 @@ export declare const MAYBE_ACT_OVERHEAD_BYTES: number;
48
48
  * `FretService.snapshot` produces (53-char peer ids, a 32-byte base64url coordinate per sample
49
49
  * entry, `metadata` grown until `JSON.stringify(metadata)` hits
50
50
  * {@link MAX_SNAPSHOT_METADATA_BYTES_CORE} exactly, 256 bytes reserved for the still-empty `sig`
51
- * field, and a `hints` array packed to {@link MAX_SNAPSHOT_HINT_BYTES_CORE}):
51
+ * field, a `digest` at the parser's 64-character cap, and a `hints` array packed to
52
+ * {@link MAX_SNAPSHOT_HINT_BYTES_CORE}):
52
53
  *
53
54
  * 16 successors + 16 predecessors + 8 sample entries (the *merge* caps, which are wider than
54
- * today's emission caps of 12/12/8) .................................... 3,371 bytes
55
+ * today's emission caps of 12/12/8), plus the 64-char digest ............. 3,447 bytes
55
56
  * + 8 KiB metadata (including the `,"metadata":` key) .................. 8,204 bytes
56
- * = 11,575 bytes of fixed fields
57
+ * = 11,651 bytes of fixed fields
57
58
  * + 48 KiB of hints (the whole array, plus its `,"hints":[]` framing) .... 49,163 bytes
58
- * = 60,738 bytes
59
+ * = 60,814 bytes
59
60
  *
60
- * 64 KiB leaves ~4.7 KiB of headroom over that. Sereus used the same 64 KiB for the same payload.
61
+ * 64 KiB leaves ~4.6 KiB of headroom over that. Sereus used the same 64 KiB for the same payload.
61
62
  *
62
63
  * The invariant this constant carries, pinned by `test/rpc.codec-properties.spec.ts`: **the
63
64
  * largest legal emission of *any* profile — fixed fields, metadata allowance and hint budget
@@ -69,12 +70,12 @@ export declare const MAX_NEIGHBORS_BYTES: number;
69
70
  * application metadata it will carry — not an acceptance limit; acceptance is
70
71
  * {@link MAX_NEIGHBORS_BYTES}, one network-wide number. The invariant the emission budgets must
71
72
  * keep is `largest emission of any profile <= MAX_NEIGHBORS_BYTES`: at the merge caps the Core
72
- * fixed fields cost 3,371 bytes, so this allowance plus them lands at 11,575, and the hint budget
73
- * ({@link MAX_SNAPSHOT_HINT_BYTES_CORE}) on top of that at 60,738 — under. Over-cap metadata is
73
+ * fixed fields cost 3,447 bytes, so this allowance plus them lands at 11,651, and the hint budget
74
+ * ({@link MAX_SNAPSHOT_HINT_BYTES_CORE}) on top of that at 60,814 — under. Over-cap metadata is
74
75
  * omitted from the snapshot rather than truncated — see `FretService.snapshot`. */
75
76
  export declare const MAX_SNAPSHOT_METADATA_BYTES_CORE: number;
76
77
  /** Same, Edge profile: an emission budget, half of Core's because an edge node carries less.
77
- * Its worst legal emission is 6,301 bytes of fixed fields plus {@link MAX_SNAPSHOT_HINT_BYTES_EDGE}
78
+ * Its worst legal emission is 6,377 bytes of fixed fields plus {@link MAX_SNAPSHOT_HINT_BYTES_EDGE}
78
79
  * of hints, also under {@link MAX_NEIGHBORS_BYTES} — which is the only bound any budget has to
79
80
  * satisfy. Deliberately *not* sized against a per-profile acceptance cap; there is no such thing. */
80
81
  export declare const MAX_SNAPSHOT_METADATA_BYTES_EDGE: number;
@@ -86,15 +87,22 @@ export declare const MAX_SNAPSHOT_METADATA_BYTES_EDGE: number;
86
87
  * `SelfAddressRecord` omits its own record rather than emit one the receiver would drop.
87
88
  */
88
89
  export declare const MAX_ADDRESS_RECORD_CHARS = 2048;
90
+ /**
91
+ * Longest snapshot digest a parser keeps (`NeighborSnapshotV1.digest`,
92
+ * `PingResponseV1.snapshot_digest`). Wider than the 22 characters `snapshotDigest`
93
+ * (`service/near-exchange.ts`) emits so a longer digest can be introduced later without a parser
94
+ * change; a receiver compares digests for equality only, so the width never matters to it.
95
+ */
96
+ export declare const MAX_SNAPSHOT_DIGEST_CHARS = 64;
89
97
  /**
90
98
  * Emission budget for the whole `hints` array of a Core snapshot — the sum over hints of
91
99
  * `JSON.stringify(hint).length + 1` (the `+ 1` is the separating comma), which is what
92
100
  * `FretService.snapshot` counts. Profile-split like the metadata allowance, and for the same
93
101
  * reason: it is this node's own choice of how much it forwards, not a limit on what it accepts.
94
- * Worst case: 11,575 (fixed fields) + 49,152 + the field's own framing < {@link MAX_NEIGHBORS_BYTES}.
102
+ * Worst case: 11,651 (fixed fields) + 49,152 + the field's own framing < {@link MAX_NEIGHBORS_BYTES}.
95
103
  */
96
104
  export declare const MAX_SNAPSHOT_HINT_BYTES_CORE: number;
97
- /** Same, Edge profile: an edge node forwards less. 6,301 + 16,384 + framing is far under the cap. */
105
+ /** Same, Edge profile: an edge node forwards less. 6,377 + 16,384 + framing is far under the cap. */
98
106
  export declare const MAX_SNAPSHOT_HINT_BYTES_EDGE: number;
99
107
  /**
100
108
  * Structural validity of an inbound `RouteAndMaybeAct` — everything downstream code touches
@@ -182,6 +190,7 @@ export declare const parsePingResponse: Parser<{
182
190
  ok: boolean;
183
191
  size_estimate?: number;
184
192
  confidence?: number;
193
+ snapshot_digest?: string;
185
194
  }>;
186
195
  /**
187
196
  * A NearAnchor reply. The two id lists are hints, so they truncate and drop bad entries; missing
@@ -78,16 +78,17 @@ export const MAYBE_ACT_OVERHEAD_BYTES = 16 * 1024;
78
78
  * `FretService.snapshot` produces (53-char peer ids, a 32-byte base64url coordinate per sample
79
79
  * entry, `metadata` grown until `JSON.stringify(metadata)` hits
80
80
  * {@link MAX_SNAPSHOT_METADATA_BYTES_CORE} exactly, 256 bytes reserved for the still-empty `sig`
81
- * field, and a `hints` array packed to {@link MAX_SNAPSHOT_HINT_BYTES_CORE}):
81
+ * field, a `digest` at the parser's 64-character cap, and a `hints` array packed to
82
+ * {@link MAX_SNAPSHOT_HINT_BYTES_CORE}):
82
83
  *
83
84
  * 16 successors + 16 predecessors + 8 sample entries (the *merge* caps, which are wider than
84
- * today's emission caps of 12/12/8) .................................... 3,371 bytes
85
+ * today's emission caps of 12/12/8), plus the 64-char digest ............. 3,447 bytes
85
86
  * + 8 KiB metadata (including the `,"metadata":` key) .................. 8,204 bytes
86
- * = 11,575 bytes of fixed fields
87
+ * = 11,651 bytes of fixed fields
87
88
  * + 48 KiB of hints (the whole array, plus its `,"hints":[]` framing) .... 49,163 bytes
88
- * = 60,738 bytes
89
+ * = 60,814 bytes
89
90
  *
90
- * 64 KiB leaves ~4.7 KiB of headroom over that. Sereus used the same 64 KiB for the same payload.
91
+ * 64 KiB leaves ~4.6 KiB of headroom over that. Sereus used the same 64 KiB for the same payload.
91
92
  *
92
93
  * The invariant this constant carries, pinned by `test/rpc.codec-properties.spec.ts`: **the
93
94
  * largest legal emission of *any* profile — fixed fields, metadata allowance and hint budget
@@ -99,12 +100,12 @@ export const MAX_NEIGHBORS_BYTES = 64 * 1024;
99
100
  * application metadata it will carry — not an acceptance limit; acceptance is
100
101
  * {@link MAX_NEIGHBORS_BYTES}, one network-wide number. The invariant the emission budgets must
101
102
  * keep is `largest emission of any profile <= MAX_NEIGHBORS_BYTES`: at the merge caps the Core
102
- * fixed fields cost 3,371 bytes, so this allowance plus them lands at 11,575, and the hint budget
103
- * ({@link MAX_SNAPSHOT_HINT_BYTES_CORE}) on top of that at 60,738 — under. Over-cap metadata is
103
+ * fixed fields cost 3,447 bytes, so this allowance plus them lands at 11,651, and the hint budget
104
+ * ({@link MAX_SNAPSHOT_HINT_BYTES_CORE}) on top of that at 60,814 — under. Over-cap metadata is
104
105
  * omitted from the snapshot rather than truncated — see `FretService.snapshot`. */
105
106
  export const MAX_SNAPSHOT_METADATA_BYTES_CORE = 8 * 1024;
106
107
  /** Same, Edge profile: an emission budget, half of Core's because an edge node carries less.
107
- * Its worst legal emission is 6,301 bytes of fixed fields plus {@link MAX_SNAPSHOT_HINT_BYTES_EDGE}
108
+ * Its worst legal emission is 6,377 bytes of fixed fields plus {@link MAX_SNAPSHOT_HINT_BYTES_EDGE}
108
109
  * of hints, also under {@link MAX_NEIGHBORS_BYTES} — which is the only bound any budget has to
109
110
  * satisfy. Deliberately *not* sized against a per-profile acceptance cap; there is no such thing. */
110
111
  export const MAX_SNAPSHOT_METADATA_BYTES_EDGE = 4 * 1024;
@@ -116,15 +117,22 @@ export const MAX_SNAPSHOT_METADATA_BYTES_EDGE = 4 * 1024;
116
117
  * `SelfAddressRecord` omits its own record rather than emit one the receiver would drop.
117
118
  */
118
119
  export const MAX_ADDRESS_RECORD_CHARS = 2048;
120
+ /**
121
+ * Longest snapshot digest a parser keeps (`NeighborSnapshotV1.digest`,
122
+ * `PingResponseV1.snapshot_digest`). Wider than the 22 characters `snapshotDigest`
123
+ * (`service/near-exchange.ts`) emits so a longer digest can be introduced later without a parser
124
+ * change; a receiver compares digests for equality only, so the width never matters to it.
125
+ */
126
+ export const MAX_SNAPSHOT_DIGEST_CHARS = 64;
119
127
  /**
120
128
  * Emission budget for the whole `hints` array of a Core snapshot — the sum over hints of
121
129
  * `JSON.stringify(hint).length + 1` (the `+ 1` is the separating comma), which is what
122
130
  * `FretService.snapshot` counts. Profile-split like the metadata allowance, and for the same
123
131
  * reason: it is this node's own choice of how much it forwards, not a limit on what it accepts.
124
- * Worst case: 11,575 (fixed fields) + 49,152 + the field's own framing < {@link MAX_NEIGHBORS_BYTES}.
132
+ * Worst case: 11,651 (fixed fields) + 49,152 + the field's own framing < {@link MAX_NEIGHBORS_BYTES}.
125
133
  */
126
134
  export const MAX_SNAPSHOT_HINT_BYTES_CORE = 48 * 1024;
127
- /** Same, Edge profile: an edge node forwards less. 6,301 + 16,384 + framing is far under the cap. */
135
+ /** Same, Edge profile: an edge node forwards less. 6,377 + 16,384 + framing is far under the cap. */
128
136
  export const MAX_SNAPSHOT_HINT_BYTES_EDGE = 16 * 1024;
129
137
  /**
130
138
  * Structural validity of an inbound `RouteAndMaybeAct` — everything downstream code touches
@@ -275,10 +283,25 @@ export function makeSnapshotParser(caps) {
275
283
  delete out.hints;
276
284
  else
277
285
  out.hints = hints;
286
+ // Advisory like the numerics: a usable digest lets the receiver skip its next fetch, an
287
+ // unusable one costs it nothing but that.
288
+ const digest = snapshotDigestOr(msg.digest, undefined);
289
+ if (digest === undefined)
290
+ delete out.digest;
291
+ else
292
+ out.digest = digest;
278
293
  // `sig` is deliberately unchecked: message signing is unimplemented, so nothing reads it.
279
294
  return out;
280
295
  };
281
296
  }
297
+ /**
298
+ * The value when it is a snapshot digest a receiver can compare — a non-empty string of at most
299
+ * {@link MAX_SNAPSHOT_DIGEST_CHARS} characters — else `fallback`. Width-checked only: the receiver
300
+ * tests equality against a digest it cached from the same peer, so no encoding rule is enforced.
301
+ */
302
+ function snapshotDigestOr(value, fallback) {
303
+ return typeof value === 'string' && value.length > 0 && value.length <= MAX_SNAPSHOT_DIGEST_CHARS ? value : fallback;
304
+ }
282
305
  /**
283
306
  * The address hints, vetted per entry against the snapshot's named set — see
284
307
  * {@link makeSnapshotParser}. An entry survives when it is an object whose `id` is a named id not
@@ -416,6 +439,10 @@ export const parsePingResponse = (msg) => {
416
439
  const confidence = finiteNumberInRangeOr(msg.confidence, 0, 1, undefined);
417
440
  if (confidence !== undefined)
418
441
  out.confidence = confidence;
442
+ // Same drop-not-reject rule as the snapshot's `digest`, which this fingerprints.
443
+ const snapshotDigest = snapshotDigestOr(msg.snapshot_digest, undefined);
444
+ if (snapshotDigest !== undefined)
445
+ out.snapshot_digest = snapshotDigest;
419
446
  return out;
420
447
  };
421
448
  /**