@bitsocial/pubsub-voting 0.0.6
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 +674 -0
- package/README.md +223 -0
- package/dist/chain/bucket.d.ts +13 -0
- package/dist/chain/bucket.js +24 -0
- package/dist/chain/coalescer.d.ts +56 -0
- package/dist/chain/coalescer.js +217 -0
- package/dist/chain/ticker.d.ts +15 -0
- package/dist/chain/ticker.js +25 -0
- package/dist/chain/types.d.ts +90 -0
- package/dist/chain/types.js +1 -0
- package/dist/checkpoint/codec.d.ts +54 -0
- package/dist/checkpoint/codec.js +99 -0
- package/dist/client/root-puller.d.ts +49 -0
- package/dist/client/root-puller.js +140 -0
- package/dist/client/voter.d.ts +239 -0
- package/dist/client/voter.js +1343 -0
- package/dist/crdt/codec.d.ts +41 -0
- package/dist/crdt/codec.js +137 -0
- package/dist/crdt/crdt.d.ts +22 -0
- package/dist/crdt/crdt.js +127 -0
- package/dist/crdt/store.d.ts +8 -0
- package/dist/crdt/store.js +23 -0
- package/dist/crdt/types.d.ts +87 -0
- package/dist/crdt/types.js +1 -0
- package/dist/encoding/canonical.d.ts +22 -0
- package/dist/encoding/canonical.js +26 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.js +126 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/rules/constant.d.ts +14 -0
- package/dist/rules/constant.js +18 -0
- package/dist/rules/erc20-balance.d.ts +30 -0
- package/dist/rules/erc20-balance.js +44 -0
- package/dist/rules/erc721-min-balance.d.ts +18 -0
- package/dist/rules/erc721-min-balance.js +100 -0
- package/dist/rules/registry.d.ts +42 -0
- package/dist/rules/registry.js +61 -0
- package/dist/rules/types.d.ts +74 -0
- package/dist/rules/types.js +1 -0
- package/dist/schema/common.d.ts +25 -0
- package/dist/schema/common.js +24 -0
- package/dist/schema/criteria.d.ts +78 -0
- package/dist/schema/criteria.js +76 -0
- package/dist/schema/directory.d.ts +42 -0
- package/dist/schema/directory.js +52 -0
- package/dist/schema/votes.d.ts +55 -0
- package/dist/schema/votes.js +116 -0
- package/dist/signer/eip712.d.ts +103 -0
- package/dist/signer/eip712.js +85 -0
- package/dist/signer/types.d.ts +31 -0
- package/dist/signer/types.js +1 -0
- package/dist/storage/browser.d.ts +3 -0
- package/dist/storage/browser.js +110 -0
- package/dist/storage/memory.d.ts +10 -0
- package/dist/storage/memory.js +56 -0
- package/dist/storage/node.d.ts +5 -0
- package/dist/storage/node.js +106 -0
- package/dist/storage/types.d.ts +46 -0
- package/dist/storage/types.js +1 -0
- package/dist/store/indexeddb.d.ts +9 -0
- package/dist/store/indexeddb.js +72 -0
- package/dist/store/memory.d.ts +15 -0
- package/dist/store/memory.js +22 -0
- package/dist/store/select.d.ts +15 -0
- package/dist/store/select.js +64 -0
- package/dist/store/sqlite.d.ts +11 -0
- package/dist/store/sqlite.js +68 -0
- package/dist/store/types.d.ts +57 -0
- package/dist/store/types.js +1 -0
- package/dist/tally/tally.d.ts +44 -0
- package/dist/tally/tally.js +89 -0
- package/dist/tally/types.d.ts +51 -0
- package/dist/tally/types.js +13 -0
- package/dist/topic.d.ts +20 -0
- package/dist/topic.js +28 -0
- package/dist/transport/accepted-dedup.d.ts +30 -0
- package/dist/transport/accepted-dedup.js +34 -0
- package/dist/transport/announce/browser.d.ts +9 -0
- package/dist/transport/announce/browser.js +14 -0
- package/dist/transport/announce/node.d.ts +38 -0
- package/dist/transport/announce/node.js +162 -0
- package/dist/transport/announce/types.d.ts +74 -0
- package/dist/transport/announce/types.js +16 -0
- package/dist/transport/bundle-store.d.ts +11 -0
- package/dist/transport/bundle-store.js +34 -0
- package/dist/transport/chase.d.ts +125 -0
- package/dist/transport/chase.js +182 -0
- package/dist/transport/gossip-validator.d.ts +107 -0
- package/dist/transport/gossip-validator.js +99 -0
- package/dist/transport/helia.d.ts +55 -0
- package/dist/transport/helia.js +114 -0
- package/dist/transport/integration/harness.d.ts +65 -0
- package/dist/transport/integration/harness.js +278 -0
- package/dist/transport/messages.d.ts +97 -0
- package/dist/transport/messages.js +117 -0
- package/dist/transport/rate-limit.d.ts +11 -0
- package/dist/transport/rate-limit.js +20 -0
- package/dist/transport/transport.d.ts +20 -0
- package/dist/transport/transport.js +35 -0
- package/dist/transport/types.d.ts +189 -0
- package/dist/transport/types.js +1 -0
- package/dist/verify/background.d.ts +81 -0
- package/dist/verify/background.js +236 -0
- package/dist/verify/bundle.d.ts +58 -0
- package/dist/verify/bundle.js +84 -0
- package/dist/verify/cache.d.ts +48 -0
- package/dist/verify/cache.js +62 -0
- package/dist/verify/constraints.d.ts +16 -0
- package/dist/verify/constraints.js +35 -0
- package/dist/verify/gate-result-cache.d.ts +65 -0
- package/dist/verify/gate-result-cache.js +91 -0
- package/dist/verify/name-resolution-cache.d.ts +59 -0
- package/dist/verify/name-resolution-cache.js +64 -0
- package/dist/verify/signature.d.ts +9 -0
- package/dist/verify/signature.js +55 -0
- package/dist/verify/types.d.ts +101 -0
- package/dist/verify/types.js +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-record announcer types (see DESIGN.md "Deferred pkc-js work", provider-record
|
|
3
|
+
* announces). The announcer is the write side of the delegated-routing seam: querying rides the
|
|
4
|
+
* injected node's `libp2p.contentRouting` (the host wires its routers into `libp2p.services`), but
|
|
5
|
+
* the pinned js stack has NO working announce path — `@helia/delegated-routing-v1-http-api-client`'s
|
|
6
|
+
* `provide()` is a literal noop — so provider records only get written by a direct
|
|
7
|
+
* `PUT /routing/v1/providers` to each router. This library is the node that actually holds the
|
|
8
|
+
* state (topic membership, fetch responder, blockstore), so its records are always truthful and
|
|
9
|
+
* current; `PubsubVoterOptions.httpRouterUrls` exists ONLY because announcing cannot ride the
|
|
10
|
+
* `contentRouting` seam.
|
|
11
|
+
*
|
|
12
|
+
* The implementation is Node-only: the browser build swaps `node.js` for the inert `browser.js`
|
|
13
|
+
* stub via the package.json `browser` field (the same remap as `src/storage/`), because a browser
|
|
14
|
+
* peer is not dialable and must never announce.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The slice of the injected node's libp2p the announcer reads, declared structurally (a running
|
|
18
|
+
* `Libp2p` satisfies it). `self:peer:update` fires when the node's address set changes — an
|
|
19
|
+
* AutoTLS certificate landing, a WebRTC-Direct certhash rotation — and triggers a re-announce so
|
|
20
|
+
* records never carry stale addresses for more than a debounce window.
|
|
21
|
+
*/
|
|
22
|
+
export interface AnnouncerLibp2p {
|
|
23
|
+
peerId: {
|
|
24
|
+
toString(): string;
|
|
25
|
+
};
|
|
26
|
+
getMultiaddrs(): Array<{
|
|
27
|
+
toString(): string;
|
|
28
|
+
}>;
|
|
29
|
+
addEventListener(type: "self:peer:update", listener: () => void): void;
|
|
30
|
+
removeEventListener(type: "self:peer:update", listener: () => void): void;
|
|
31
|
+
}
|
|
32
|
+
export interface AnnouncerOptions {
|
|
33
|
+
/** Delegated Routing V1 base URLs to PUT provider records to. Empty means never announce. */
|
|
34
|
+
routerUrls: readonly string[];
|
|
35
|
+
/** The injected node's libp2p (peer id, current addresses, address-change events). */
|
|
36
|
+
libp2p: AnnouncerLibp2p;
|
|
37
|
+
/**
|
|
38
|
+
* The CIDs to announce, collected fresh per tick: every joined contest's criteria CID plus its
|
|
39
|
+
* current checkpoint root + chunk CIDs, batched into ONE record (`Keys` is an array) so a
|
|
40
|
+
* directory host costs one request per router per tick. An empty list skips the tick.
|
|
41
|
+
*/
|
|
42
|
+
keys(): Promise<string[]>;
|
|
43
|
+
/** Re-announce cadence (ms). Test seam; defaults to hourly (see `ANNOUNCE_INTERVAL_MS`). */
|
|
44
|
+
intervalMs?: number;
|
|
45
|
+
/** Change-coalescing window (ms). Test seam; defaults to `ANNOUNCE_DEBOUNCE_MS`. */
|
|
46
|
+
debounceMs?: number;
|
|
47
|
+
/** Per-router PUT deadline (ms). Test seam; defaults to `ANNOUNCE_ROUTER_TIMEOUT_MS`. */
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Per-router failure hook (timeout, non-2xx, network error). Purely observational — a failing
|
|
51
|
+
* router never throws into the voter and is not retried; the next tick covers it.
|
|
52
|
+
*/
|
|
53
|
+
onError?(url: string, error: unknown): void;
|
|
54
|
+
}
|
|
55
|
+
/** The voter-facing announcer handle. All methods are synchronous and idempotent. */
|
|
56
|
+
export interface Announcer {
|
|
57
|
+
/**
|
|
58
|
+
* Begin announcing: subscribe to address changes and arm the periodic re-announce. Called when
|
|
59
|
+
* the voter's first topic joins (symmetric with the lazy fetch-responder lifecycle). Does not
|
|
60
|
+
* announce by itself — the join that triggered it also calls {@link notifyChange}.
|
|
61
|
+
*/
|
|
62
|
+
start(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Stop announcing and drop timers/listeners. Called when the last topic is left. Records
|
|
65
|
+
* already written age out by the router's TTL; there is no un-announce.
|
|
66
|
+
*/
|
|
67
|
+
stop(): void;
|
|
68
|
+
/**
|
|
69
|
+
* Coalesced re-announce trigger: a contest joined, a checkpoint root changed, or the address
|
|
70
|
+
* set changed. The first call arms one debounce timer; further calls within the window ride
|
|
71
|
+
* it, so a gossip burst (or a directory-wide join) costs one announce.
|
|
72
|
+
*/
|
|
73
|
+
notifyChange(): void;
|
|
74
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-record announcer types (see DESIGN.md "Deferred pkc-js work", provider-record
|
|
3
|
+
* announces). The announcer is the write side of the delegated-routing seam: querying rides the
|
|
4
|
+
* injected node's `libp2p.contentRouting` (the host wires its routers into `libp2p.services`), but
|
|
5
|
+
* the pinned js stack has NO working announce path — `@helia/delegated-routing-v1-http-api-client`'s
|
|
6
|
+
* `provide()` is a literal noop — so provider records only get written by a direct
|
|
7
|
+
* `PUT /routing/v1/providers` to each router. This library is the node that actually holds the
|
|
8
|
+
* state (topic membership, fetch responder, blockstore), so its records are always truthful and
|
|
9
|
+
* current; `PubsubVoterOptions.httpRouterUrls` exists ONLY because announcing cannot ride the
|
|
10
|
+
* `contentRouting` seam.
|
|
11
|
+
*
|
|
12
|
+
* The implementation is Node-only: the browser build swaps `node.js` for the inert `browser.js`
|
|
13
|
+
* stub via the package.json `browser` field (the same remap as `src/storage/`), because a browser
|
|
14
|
+
* peer is not dialable and must never announce.
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BundleStore } from "../crdt/types.js";
|
|
2
|
+
import type { BlockstoreLike } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* A {@link BundleStore} backed by the host's Helia blockstore. `put` writes a bundle as a
|
|
5
|
+
* dag-cbor block locally; `get` reads by CID — and because bitswap retrieves through the
|
|
6
|
+
* blockstore, an unknown CID is fetched from peers. A fetch that fails or times out surfaces
|
|
7
|
+
* as `undefined` (not a throw), which the forward-gate reads as "unfetchable -> ignore"
|
|
8
|
+
* rather than a provable fault. Lives in transport/ (not crdt/) because it touches the
|
|
9
|
+
* blockstore; the CRDT stays libp2p-free.
|
|
10
|
+
*/
|
|
11
|
+
export declare function makeBlockstoreBundleStore(blockstore: BlockstoreLike): BundleStore;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { encodeBundle, decodeBundle, bundleCid } from "../crdt/codec.js";
|
|
2
|
+
/**
|
|
3
|
+
* A {@link BundleStore} backed by the host's Helia blockstore. `put` writes a bundle as a
|
|
4
|
+
* dag-cbor block locally; `get` reads by CID — and because bitswap retrieves through the
|
|
5
|
+
* blockstore, an unknown CID is fetched from peers. A fetch that fails or times out surfaces
|
|
6
|
+
* as `undefined` (not a throw), which the forward-gate reads as "unfetchable -> ignore"
|
|
7
|
+
* rather than a provable fault. Lives in transport/ (not crdt/) because it touches the
|
|
8
|
+
* blockstore; the CRDT stays libp2p-free.
|
|
9
|
+
*/
|
|
10
|
+
export function makeBlockstoreBundleStore(blockstore) {
|
|
11
|
+
return {
|
|
12
|
+
async put(bundle) {
|
|
13
|
+
const cid = await bundleCid(bundle);
|
|
14
|
+
await blockstore.put(cid, encodeBundle(bundle));
|
|
15
|
+
return cid;
|
|
16
|
+
},
|
|
17
|
+
async get(cid, options) {
|
|
18
|
+
try {
|
|
19
|
+
return decodeBundle(await blockstore.get(cid, options));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined; // unfetchable / undecodable / aborted — gate treats as ignore
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
async has(cid) {
|
|
26
|
+
try {
|
|
27
|
+
return await blockstore.has(cid);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { CID } from "multiformats/cid";
|
|
2
|
+
import type { PeerId } from "@libp2p/interface";
|
|
3
|
+
import type { BlockSessionLike } from "./types.js";
|
|
4
|
+
import type { VotesBundle } from "../schema/votes.js";
|
|
5
|
+
import type { VerdictCache } from "../verify/cache.js";
|
|
6
|
+
import type { VerifyResult } from "../verify/types.js";
|
|
7
|
+
import type { PendingBundle } from "../verify/background.js";
|
|
8
|
+
/**
|
|
9
|
+
* The divergence chase: act on an advertised checkpoint root that differs from our own (a
|
|
10
|
+
* heartbeat or fetch-protocol hint — see DESIGN.md "Checkpoints", "Block pull"). Pull the
|
|
11
|
+
* blocks behind the root by CID — through a bitswap session seeded with the root's advertisers
|
|
12
|
+
* when the `openSession` seam is present (targeted wants, one provider lookup per root), the
|
|
13
|
+
* broadcast `getBlock` otherwise or on session failure — and admit each inlined bundle in two
|
|
14
|
+
* stages:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Offline, synchronous, before admit** (µs each): signature + criteria constraints via
|
|
17
|
+
* `verifyOffline` — a forged or malformed bundle dies here and is never admitted.
|
|
18
|
+
* 2. **Network, deferred**: the on-chain gate read and name resolution run in the background
|
|
19
|
+
* chain verifier (`deferVerify`, one batch per chased root so the gate reads batch into
|
|
20
|
+
* as few RPC round trips as the rule allows), which confirms or evicts each bundle after
|
|
21
|
+
* the fact. This is what keeps a cold join non-blocking: a 100-vote checkpoint admits in
|
|
22
|
+
* milliseconds instead of serializing 100 chain reads (see DESIGN.md "Background chain
|
|
23
|
+
* verification").
|
|
24
|
+
*
|
|
25
|
+
* So a single liar cannot inject a forged vote (offline check) nor hide an honest one
|
|
26
|
+
* (union-only merge); an *ineligible-wallet* bundle it injects is admitted provisionally,
|
|
27
|
+
* surfaces only as a `chainVerified: false` tally row, is never re-served in our checkpoint,
|
|
28
|
+
* and is evicted as soon as its batched gate read lands.
|
|
29
|
+
*
|
|
30
|
+
* Bounded like everything at the gate: a shared concurrency cap (`limit`), a per-root
|
|
31
|
+
* deadline whose `AbortSignal` cancels in-flight block wants, and in-flight dedup so a spray
|
|
32
|
+
* of the same root queues one chase, not many. A failed chase (unfetchable blocks, malformed
|
|
33
|
+
* chunks, all-invalid bundles) contributes nothing and throws nothing — the hint was never
|
|
34
|
+
* trusted. No libp2p import; pure seams, unit-testable offline.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* A directed block-pull session for one chased root, seeded with the peers that advertised it
|
|
38
|
+
* (see DESIGN.md "Block pull"). The chase tries it before the broadcast `getBlock` and falls
|
|
39
|
+
* back on any failure, so a session is an optimisation, never a correctness dependency.
|
|
40
|
+
*/
|
|
41
|
+
export interface ChaseSession {
|
|
42
|
+
/** Fetch one block through the session; `undefined` or a throw falls back to `getBlock`. */
|
|
43
|
+
get(cid: CID, signal: AbortSignal): Promise<Uint8Array | undefined>;
|
|
44
|
+
/** Seed a late advertiser of the same root into the running pull. MUST NOT throw. */
|
|
45
|
+
addPeer(peer: PeerId): void;
|
|
46
|
+
/** Abort the session's in-flight wants and release it. MUST NOT throw. */
|
|
47
|
+
close(): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Wrap a raw block session into a {@link ChaseSession}, enforcing the contracts above at the one
|
|
51
|
+
* boundary where they can be violated: `get` maps any failure to `undefined` (the chase falls
|
|
52
|
+
* back to the broadcast want), and `addPeer`/`close` swallow synchronous throws as well as
|
|
53
|
+
* rejections — a late hint can race the chase deadline and land on an already-closed session,
|
|
54
|
+
* and nothing on that path guards again (`addProviders` relies on the MUST NOT throw contract,
|
|
55
|
+
* and `chase()` is a fire-and-forget API). Shared by the voter and the integration harness so
|
|
56
|
+
* the harness stays faithful to the production seam it mirrors.
|
|
57
|
+
*/
|
|
58
|
+
export declare function toChaseSession(session: BlockSessionLike): ChaseSession;
|
|
59
|
+
export interface RootChaserDeps {
|
|
60
|
+
/**
|
|
61
|
+
* Fetch one checkpoint block by CID (blockstore + directed bitswap); `undefined` or a
|
|
62
|
+
* throw means unavailable. `signal` aborts the want when the per-root deadline fires.
|
|
63
|
+
*/
|
|
64
|
+
getBlock: (cid: CID, signal: AbortSignal) => Promise<Uint8Array | undefined>;
|
|
65
|
+
/**
|
|
66
|
+
* Open a bitswap session for one chased root, seeded with `providers` — the advertisers of
|
|
67
|
+
* that exact root, who provably hold its blocks. Optional twice over: absent when the host
|
|
68
|
+
* blockstore cannot make sessions (plain blockstores, unit-test mocks), and a call may
|
|
69
|
+
* return `undefined` to decline. NEVER invoked with zero providers — nothing announces
|
|
70
|
+
* these CIDs to routing yet, so an unseeded session would fail-fast where the broadcast
|
|
71
|
+
* `getBlock` succeeds via any connected topic peer.
|
|
72
|
+
*/
|
|
73
|
+
openSession?: (root: CID, providers: PeerId[]) => ChaseSession | undefined;
|
|
74
|
+
/** Stage 1 only — signature + constraints, local and synchronous (the gate's same stage). */
|
|
75
|
+
verifyOffline: (bundle: VotesBundle) => Promise<VerifyResult>;
|
|
76
|
+
/** The gate's per-CID verdict cache, shared so chased bundles reuse (and feed) it. */
|
|
77
|
+
cache: VerdictCache;
|
|
78
|
+
/** The gate's freshness guard (see gossip-validator.ts); omitted ⇒ no check. */
|
|
79
|
+
isEvaluableNow?: (bundle: VotesBundle) => Promise<boolean>;
|
|
80
|
+
/** Skip bundles we already hold (their CID is in the store) without re-verifying. */
|
|
81
|
+
hasBundle: (cid: CID) => Promise<boolean>;
|
|
82
|
+
/**
|
|
83
|
+
* Store an offline-valid bundle's block bytes and admit its CID into the CRDT (idempotent).
|
|
84
|
+
* `verified: true` means a cached terminal verdict already covers the FULL pipeline (the
|
|
85
|
+
* bundle was verified before, e.g. by the forward-gate); `verified: false` is a provisional
|
|
86
|
+
* admit whose deferred checks ride `deferVerify`.
|
|
87
|
+
*/
|
|
88
|
+
admit: (args: {
|
|
89
|
+
cid: CID;
|
|
90
|
+
bytes: Uint8Array;
|
|
91
|
+
bundle: VotesBundle;
|
|
92
|
+
verified: boolean;
|
|
93
|
+
}) => Promise<void>;
|
|
94
|
+
/** Hand one chased root's provisionally admitted bundles to the background chain verifier. */
|
|
95
|
+
deferVerify: (entries: PendingBundle[]) => void;
|
|
96
|
+
/** Called once per chase that admitted at least one bundle (drives tally updates). */
|
|
97
|
+
onMerged?: () => void;
|
|
98
|
+
/** Concurrency limiter shared across chases (a root spray queues, never floods). */
|
|
99
|
+
limit: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
100
|
+
/** Per-root deadline (ms); on expiry the abort signal fires and the chase yields nothing. */
|
|
101
|
+
timeoutMs: number;
|
|
102
|
+
}
|
|
103
|
+
export interface RootChaser {
|
|
104
|
+
/**
|
|
105
|
+
* Chase one advertised root, fire-and-forget: never throws, never blocks the caller
|
|
106
|
+
* (the validator hands hints here without awaiting). A root already being chased is
|
|
107
|
+
* dropped — the in-flight run covers it.
|
|
108
|
+
*
|
|
109
|
+
* `chunks` is the optional piggybacked chunk-CID index from a fetch-protocol root record
|
|
110
|
+
* (see DESIGN.md "Block pull"): when supplied and it re-derives to `root`, the chase skips
|
|
111
|
+
* the root-manifest bitswap round-trip and pulls the chunks directly. A heartbeat hint (no
|
|
112
|
+
* index) omits it and takes the manifest-fetch path. The index is verified against `root`,
|
|
113
|
+
* so a bad one simply falls back — never a trust vector.
|
|
114
|
+
*
|
|
115
|
+
* `providers` are the peers known to have advertised this exact root (the triggering
|
|
116
|
+
* sender plus any prior advertisers still connected — see the voter's peer-root map); the
|
|
117
|
+
* chase pulls through a bitswap session seeded with them when the `openSession` seam is
|
|
118
|
+
* present, broadcasting only as fallback. A provider hint for a root already in flight is
|
|
119
|
+
* added to the running session instead of dropped.
|
|
120
|
+
*/
|
|
121
|
+
chase(root: CID, chunks?: CID[], providers?: readonly PeerId[]): void;
|
|
122
|
+
/** Roots currently being chased (for tests/introspection). */
|
|
123
|
+
inFlight(): number;
|
|
124
|
+
}
|
|
125
|
+
export declare function makeRootChaser(deps: RootChaserDeps): RootChaser;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { decodeCheckpoint } from "../checkpoint/codec.js";
|
|
2
|
+
import { encodeBundle, bundleCidForBytes } from "../crdt/codec.js";
|
|
3
|
+
/**
|
|
4
|
+
* Wrap a raw block session into a {@link ChaseSession}, enforcing the contracts above at the one
|
|
5
|
+
* boundary where they can be violated: `get` maps any failure to `undefined` (the chase falls
|
|
6
|
+
* back to the broadcast want), and `addPeer`/`close` swallow synchronous throws as well as
|
|
7
|
+
* rejections — a late hint can race the chase deadline and land on an already-closed session,
|
|
8
|
+
* and nothing on that path guards again (`addProviders` relies on the MUST NOT throw contract,
|
|
9
|
+
* and `chase()` is a fire-and-forget API). Shared by the voter and the integration harness so
|
|
10
|
+
* the harness stays faithful to the production seam it mirrors.
|
|
11
|
+
*/
|
|
12
|
+
export function toChaseSession(session) {
|
|
13
|
+
return {
|
|
14
|
+
get: async (cid, signal) => {
|
|
15
|
+
try {
|
|
16
|
+
return await session.get(cid, { signal });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined; // the chase falls back to the broadcast want
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
addPeer: (peer) => {
|
|
23
|
+
// A bad late hint (undialable, session at capacity, session already closed) must
|
|
24
|
+
// never surface. `Promise.resolve(...)` alone would miss a synchronous throw — it
|
|
25
|
+
// happens while evaluating the argument — so the call itself needs the try.
|
|
26
|
+
try {
|
|
27
|
+
void Promise.resolve(session.addPeer(peer)).catch(() => { });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// same no-op as a rejection
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
close: () => {
|
|
34
|
+
try {
|
|
35
|
+
session.close();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// releasing a finished session must not fail the chase
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function makeRootChaser(deps) {
|
|
44
|
+
const { getBlock, openSession, verifyOffline, cache, isEvaluableNow, hasBundle, admit, deferVerify, onMerged, limit, timeoutMs } = deps;
|
|
45
|
+
const inFlight = new Map();
|
|
46
|
+
function addProviders(flight, providers) {
|
|
47
|
+
for (const peer of providers) {
|
|
48
|
+
const id = peer.toString();
|
|
49
|
+
if (flight.seeded.has(id))
|
|
50
|
+
continue;
|
|
51
|
+
flight.seeded.add(id);
|
|
52
|
+
if (flight.session !== undefined)
|
|
53
|
+
flight.session.addPeer(peer);
|
|
54
|
+
else if (!flight.started)
|
|
55
|
+
flight.queued.push(peer);
|
|
56
|
+
// started with no session (seam absent/declined): a late hint cannot help — drop it.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function runChase(flight, root, chunks) {
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
62
|
+
// Open the session only now (not at enqueue): hints that arrived while queued behind the
|
|
63
|
+
// concurrency cap are all in `queued`, so they seed the session instead of dialing addPeer.
|
|
64
|
+
// A throwing factory degrades to the broadcast path exactly like an absent seam — a
|
|
65
|
+
// session is an optimisation, never something a chase may die on.
|
|
66
|
+
if (flight.queued.length > 0) {
|
|
67
|
+
try {
|
|
68
|
+
flight.session = openSession?.(root, flight.queued.splice(0));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
flight.session = undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
flight.started = true;
|
|
75
|
+
// One failed session want marks the whole session dead (a converged advertiser holds all
|
|
76
|
+
// blocks or none) — the remaining blocks skip straight to the broadcast fallback instead
|
|
77
|
+
// of paying a doomed session attempt each.
|
|
78
|
+
let sessionDead = false;
|
|
79
|
+
const fetchBlock = async (cid) => {
|
|
80
|
+
if (controller.signal.aborted)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (flight.session !== undefined && !sessionDead) {
|
|
83
|
+
try {
|
|
84
|
+
const bytes = await flight.session.get(cid, controller.signal);
|
|
85
|
+
if (bytes !== undefined)
|
|
86
|
+
return bytes;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// fall through to the broadcast path
|
|
90
|
+
}
|
|
91
|
+
sessionDead = true;
|
|
92
|
+
if (controller.signal.aborted)
|
|
93
|
+
return undefined; // deadline, not a session miss
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return await getBlock(cid, controller.signal);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined; // unfetchable/aborted — decode throws "unavailable", chase yields nothing
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
// Race the decode against the deadline so even a `getBlock` that ignores its abort
|
|
104
|
+
// signal cannot pin this chase slot past `timeoutMs` — the slot is always freed.
|
|
105
|
+
const winners = await Promise.race([
|
|
106
|
+
decodeCheckpoint(root, fetchBlock, chunks),
|
|
107
|
+
new Promise((resolve) => {
|
|
108
|
+
controller.signal.addEventListener("abort", () => resolve(undefined), { once: true });
|
|
109
|
+
})
|
|
110
|
+
]);
|
|
111
|
+
if (winners === undefined)
|
|
112
|
+
return; // deadline hit — the hint contributed nothing
|
|
113
|
+
let merged = false;
|
|
114
|
+
const pending = [];
|
|
115
|
+
for (const bundle of winners) {
|
|
116
|
+
if (controller.signal.aborted)
|
|
117
|
+
break;
|
|
118
|
+
// Re-encoding the decoded bundle reproduces the exact block bytes (the codec is
|
|
119
|
+
// canonical), so the CID matches the advertiser's block and dedups everywhere.
|
|
120
|
+
const bytes = encodeBundle(bundle);
|
|
121
|
+
const cid = await bundleCidForBytes(bytes);
|
|
122
|
+
if (await hasBundle(cid))
|
|
123
|
+
continue; // already held — nothing to verify
|
|
124
|
+
const cached = cache.get(cid);
|
|
125
|
+
if (cached) {
|
|
126
|
+
if (!cached.valid)
|
|
127
|
+
continue; // known bad — skip
|
|
128
|
+
await admit({ cid, bytes, bundle, verified: true }); // full pipeline already passed
|
|
129
|
+
merged = true;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (isEvaluableNow && !(await isEvaluableNow(bundle)))
|
|
133
|
+
continue; // transient, uncached
|
|
134
|
+
const offline = await verifyOffline(bundle);
|
|
135
|
+
if (!offline.valid) {
|
|
136
|
+
// A liar's forged/malformed bundle dies here, before admit. A provable
|
|
137
|
+
// offline reject (bad signature, constraints) is terminal — cache it so a
|
|
138
|
+
// re-served copy short-circuits; a transient `ignore` stays uncached.
|
|
139
|
+
if (offline.disposition === "reject")
|
|
140
|
+
cache.set(cid, offline);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
await admit({ cid, bytes, bundle, verified: false });
|
|
144
|
+
pending.push({ cid, bundle });
|
|
145
|
+
merged = true;
|
|
146
|
+
}
|
|
147
|
+
// One batch per chased root: the background verifier groups these by sample block
|
|
148
|
+
// and batches the gate reads (see verify/background.ts).
|
|
149
|
+
if (pending.length > 0)
|
|
150
|
+
deferVerify(pending);
|
|
151
|
+
if (merged)
|
|
152
|
+
onMerged?.();
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
flight.session?.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
chase(root, chunks, providers) {
|
|
161
|
+
const key = root.toString();
|
|
162
|
+
const existing = inFlight.get(key);
|
|
163
|
+
if (existing !== undefined) {
|
|
164
|
+
// The in-flight run covers this hint (chunks derive from root) — but a new
|
|
165
|
+
// advertiser joins its session rather than being dropped on the floor.
|
|
166
|
+
if (providers !== undefined)
|
|
167
|
+
addProviders(existing, providers);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const flight = { seeded: new Set(), queued: [], session: undefined, started: false };
|
|
171
|
+
if (providers !== undefined)
|
|
172
|
+
addProviders(flight, providers);
|
|
173
|
+
inFlight.set(key, flight);
|
|
174
|
+
void limit(() => runChase(flight, root, chunks))
|
|
175
|
+
.catch(() => { }) // a failed chase contributes nothing; the hint was never trusted
|
|
176
|
+
.finally(() => inFlight.delete(key));
|
|
177
|
+
},
|
|
178
|
+
inFlight() {
|
|
179
|
+
return inFlight.size;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { CID } from "multiformats/cid";
|
|
2
|
+
import type { VotesBundle } from "../schema/votes.js";
|
|
3
|
+
import { type VerdictCache } from "../verify/cache.js";
|
|
4
|
+
import type { BundleVerifier } from "../verify/types.js";
|
|
5
|
+
import type { AcceptedDedup } from "./accepted-dedup.js";
|
|
6
|
+
import type { RootRecord, VoteMessage } from "./messages.js";
|
|
7
|
+
/**
|
|
8
|
+
* The forward-gate: the async gossipsub topic validator's decision core, written as a pure
|
|
9
|
+
* function over injected seams (no libp2p import) so it is fully unit-testable. It runs the
|
|
10
|
+
* FULL validity pipeline on the bundle INLINED in a received message BEFORE the message is
|
|
11
|
+
* re-forwarded, so an invalid bundle never crosses an honest hop and gossipsub's `reject`
|
|
12
|
+
* scores the sender for semantic — not just byte-level — badness. See DESIGN.md "Transport".
|
|
13
|
+
*
|
|
14
|
+
* The payload is a two-kind discriminated union (see transport/messages.ts):
|
|
15
|
+
* - a **bundle delta** — the wallet's own bundle as exact block bytes, validated straight
|
|
16
|
+
* from the message (no fetch toward the publisher exists on this path);
|
|
17
|
+
* - a **root record** — the checkpoint heartbeat, a fixed-shape unverifiable *hint* that
|
|
18
|
+
* short-circuits at layer 1: forwarded within its own per-peer rate and handed to the
|
|
19
|
+
* chase logic, never verified here (only equality with our own root is checkable) and
|
|
20
|
+
* never trusted.
|
|
21
|
+
*
|
|
22
|
+
* Verdicts (who gets blamed):
|
|
23
|
+
* - "accept": the inlined bundle verified (or is a known-valid re-publish), or a
|
|
24
|
+
* well-formed within-rate root record — deliver, merge (bundles), forward.
|
|
25
|
+
* - "reject": something is PROVABLY invalid (malformed message, over the derived size cap,
|
|
26
|
+
* a bundle that fails verification) — drop, do not forward, penalize the sender.
|
|
27
|
+
* - "ignore": no verdict reachable through no provable fault of the sender — either a
|
|
28
|
+
* transient/local condition (over the per-peer rate, internal error, deadline) or a
|
|
29
|
+
* view-/clock-dependent verdict two honest peers can disagree on right now: a bundle
|
|
30
|
+
* bucketed ahead of our chain head (`isEvaluableNow`), or a `community.name` resolved at
|
|
31
|
+
* head during a re-point window. Drop, do not forward, do NOT penalize, and do NOT cache
|
|
32
|
+
* (it can change) — using `reject` here would punish honest relayers for transient
|
|
33
|
+
* conditions and hand attackers a grief vector.
|
|
34
|
+
*
|
|
35
|
+
* Cheap-to-expensive with early exit: size/decode/rate first, then hash + caches, then the
|
|
36
|
+
* verify pipeline — so a re-published known bundle costs one hash and zero chain work.
|
|
37
|
+
*/
|
|
38
|
+
export type MessageVerdict = "accept" | "reject" | "ignore";
|
|
39
|
+
export interface GossipGateDeps {
|
|
40
|
+
/** Decode a payload to its message kind; throws on malformed (see transport/messages.ts). */
|
|
41
|
+
decodeMessage: (data: Uint8Array) => VoteMessage;
|
|
42
|
+
/**
|
|
43
|
+
* Decode inlined bundle-block bytes and hash them to the bundle CID (the verdict-cache
|
|
44
|
+
* key). Throws on malformed block bytes — provable layer-1 badness.
|
|
45
|
+
*/
|
|
46
|
+
parseBundle: (blockBytes: Uint8Array) => Promise<{
|
|
47
|
+
cid: CID;
|
|
48
|
+
bundle: VotesBundle;
|
|
49
|
+
}>;
|
|
50
|
+
/** The full validity pipeline for one bundle (see verify/bundle.ts). */
|
|
51
|
+
verifier: BundleVerifier;
|
|
52
|
+
/**
|
|
53
|
+
* Clock-aware freshness guard, kept OUT of the pure (cacheable) verifier: is this bundle's
|
|
54
|
+
* bucket sample block already reachable from our chain head? A bundle dated to a future
|
|
55
|
+
* bucket (the voter's head ahead of ours, clock skew, or an absurd `blockNumber`) is not
|
|
56
|
+
* yet evaluable — transient, so the gate `ignore`s it (no penalty, uncached) until our head
|
|
57
|
+
* catches up. Omitted ⇒ no freshness check. Steady-state votes resolve `true` with no chain
|
|
58
|
+
* read; only a look-ahead bundle costs a (memoized) head read.
|
|
59
|
+
*/
|
|
60
|
+
isEvaluableNow?: (bundle: VotesBundle) => Promise<boolean>;
|
|
61
|
+
/** Per-CID verdict cache — dedups re-published bundles and known-bad blocks. */
|
|
62
|
+
cache: VerdictCache;
|
|
63
|
+
/**
|
|
64
|
+
* Optional dedup of already-accepted votes by `(wallet, bucket, votes)`. A same-bucket,
|
|
65
|
+
* same-choice re-sign under fresh bytes is dropped — not verified, not merged, not
|
|
66
|
+
* forwarded — so a re-sign flood costs no gate read and does not amplify. Omitted ⇒ every
|
|
67
|
+
* fresh bundle is verified on its own.
|
|
68
|
+
*/
|
|
69
|
+
acceptedDedup?: AcceptedDedup;
|
|
70
|
+
/**
|
|
71
|
+
* Store the verified bundle's exact block bytes and admit its CID into the CRDT
|
|
72
|
+
* (idempotent). Exact bytes preserve byte-identity with the sender's block.
|
|
73
|
+
*/
|
|
74
|
+
admit: (args: {
|
|
75
|
+
cid: CID;
|
|
76
|
+
bytes: Uint8Array;
|
|
77
|
+
bundle: VotesBundle;
|
|
78
|
+
}) => Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Concurrency limiter (p-limit) shared across in-flight verifications — the gate chain
|
|
81
|
+
* read and name resolution are network calls, so concurrent RPC work stays bounded.
|
|
82
|
+
*/
|
|
83
|
+
limit: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
84
|
+
/** Per-peer rate gate for bundle-kind messages; `false` means over-rate this window. */
|
|
85
|
+
allowBundlePeer: (peer: string) => boolean;
|
|
86
|
+
/** Per-peer rate gate for root-kind messages (heartbeats are ~1 per 10 min when honest). */
|
|
87
|
+
allowRootPeer: (peer: string) => boolean;
|
|
88
|
+
/** Called after a fresh bundle is verified and merged (drives tally-update notifications). */
|
|
89
|
+
onAccept?: (cid: CID, bundle: VotesBundle, from: string) => void;
|
|
90
|
+
/**
|
|
91
|
+
* Called with every well-formed, within-rate root record (an unverifiable hint). NOT
|
|
92
|
+
* awaited — acting on a hint (the directed-bitswap chase) is lazy and bounded elsewhere,
|
|
93
|
+
* never on the validator's critical path.
|
|
94
|
+
*/
|
|
95
|
+
onRootRecord?: (record: RootRecord, from: string) => void;
|
|
96
|
+
/** The criteria-derived cap for a bundle-kind message (see messages.ts). Over ⇒ reject. */
|
|
97
|
+
maxBundleMessageBytes: number;
|
|
98
|
+
/** The fixed cap for a root-kind message (~100 B record). Over ⇒ reject. */
|
|
99
|
+
maxRootMessageBytes: number;
|
|
100
|
+
/** Hard per-message validation deadline; on expiry the verdict is `ignore`. */
|
|
101
|
+
timeoutMs: number;
|
|
102
|
+
}
|
|
103
|
+
export interface GossipGate {
|
|
104
|
+
/** Validate one received message; the returned verdict maps to gossipsub accept/reject/ignore. */
|
|
105
|
+
validate(data: Uint8Array, from: string): Promise<MessageVerdict>;
|
|
106
|
+
}
|
|
107
|
+
export declare function makeGossipGate(deps: GossipGateDeps): GossipGate;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isCacheableVerdict } from "../verify/cache.js";
|
|
2
|
+
/** Resolve `p`, or `onTimeout` if it does not settle within `ms`; a rejection also yields `onTimeout`. */
|
|
3
|
+
function withDeadline(p, ms, onTimeout) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
let settled = false;
|
|
6
|
+
const timer = setTimeout(() => {
|
|
7
|
+
if (!settled) {
|
|
8
|
+
settled = true;
|
|
9
|
+
resolve(onTimeout);
|
|
10
|
+
}
|
|
11
|
+
}, ms);
|
|
12
|
+
p.then((v) => {
|
|
13
|
+
if (!settled) {
|
|
14
|
+
settled = true;
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
resolve(v);
|
|
17
|
+
}
|
|
18
|
+
}, () => {
|
|
19
|
+
if (!settled) {
|
|
20
|
+
settled = true;
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
resolve(onTimeout);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function makeGossipGate(deps) {
|
|
28
|
+
const { decodeMessage, parseBundle, verifier, isEvaluableNow, cache, acceptedDedup, admit, limit, allowBundlePeer, allowRootPeer, onAccept, onRootRecord, maxBundleMessageBytes, maxRootMessageBytes, timeoutMs } = deps;
|
|
29
|
+
async function doValidate(data, from) {
|
|
30
|
+
// Layer 1: size and decode — the cheapest, pre-verify checks.
|
|
31
|
+
if (data.length > Math.max(maxBundleMessageBytes, maxRootMessageBytes))
|
|
32
|
+
return "reject";
|
|
33
|
+
let message;
|
|
34
|
+
try {
|
|
35
|
+
message = decodeMessage(data);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return "reject"; // malformed bytes / not one of the two kinds — provable layer-1 badness
|
|
39
|
+
}
|
|
40
|
+
if (message.kind === "root") {
|
|
41
|
+
if (data.length > maxRootMessageBytes)
|
|
42
|
+
return "reject";
|
|
43
|
+
if (!allowRootPeer(from))
|
|
44
|
+
return "ignore";
|
|
45
|
+
// An unverifiable hint: forward within rate, hand to the (lazy, bounded) chase.
|
|
46
|
+
// Never `reject`ed on content — only equality with our own root is checkable.
|
|
47
|
+
onRootRecord?.(message.record, from);
|
|
48
|
+
return "accept";
|
|
49
|
+
}
|
|
50
|
+
if (data.length > maxBundleMessageBytes)
|
|
51
|
+
return "reject";
|
|
52
|
+
if (!allowBundlePeer(from))
|
|
53
|
+
return "ignore";
|
|
54
|
+
let cid;
|
|
55
|
+
let bundle;
|
|
56
|
+
try {
|
|
57
|
+
({ cid, bundle } = await parseBundle(message.bundle));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return "reject"; // malformed bundle block bytes — provable
|
|
61
|
+
}
|
|
62
|
+
// A known bundle short-circuits on the verdict cache for the cost of one hash: a
|
|
63
|
+
// re-published valid bundle (e.g. a client refreshing its vote or re-announcing a
|
|
64
|
+
// withdrawal) is forwarded so late peers converge, without re-verifying or re-merging;
|
|
65
|
+
// a known-bad one rejects without work.
|
|
66
|
+
const cached = cache.get(cid);
|
|
67
|
+
if (cached)
|
|
68
|
+
return cached.valid ? "accept" : cached.disposition;
|
|
69
|
+
// Freshness guard (transient, so it runs here — not in the cacheable verifier — and
|
|
70
|
+
// its verdict is never cached): a bundle bucketed ahead of our head is not yet
|
|
71
|
+
// evaluable, so `ignore` it without penalty until our head advances. Also defuses an
|
|
72
|
+
// absurd-future `blockNumber` (e.g. uint256.max) — it never merges into the set.
|
|
73
|
+
if (isEvaluableNow && !(await isEvaluableNow(bundle)))
|
|
74
|
+
return "ignore";
|
|
75
|
+
// Re-sign flood guard: a same-bucket, same-choice re-sign of a vote we already accepted
|
|
76
|
+
// is inert under LWW, so drop it WITHOUT verifying (no gate read), merging, or
|
|
77
|
+
// forwarding — the anti-amplification win. Safe on the untrusted `address` because a
|
|
78
|
+
// hit only ever suppresses something an honest peer already forwarded.
|
|
79
|
+
if (acceptedDedup?.isResignDuplicate(bundle))
|
|
80
|
+
return "ignore";
|
|
81
|
+
const verdict = await limit(() => verifier.verify(bundle));
|
|
82
|
+
if (isCacheableVerdict(verdict))
|
|
83
|
+
cache.set(cid, verdict); // only terminal verdicts (accept / provable reject)
|
|
84
|
+
if (!verdict.valid)
|
|
85
|
+
return verdict.disposition;
|
|
86
|
+
await admit({ cid, bytes: message.bundle, bundle });
|
|
87
|
+
acceptedDedup?.record(bundle);
|
|
88
|
+
onAccept?.(cid, bundle, from);
|
|
89
|
+
return "accept";
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
validate(data, from) {
|
|
93
|
+
// Whole-message deadline: an internal hang or slow RPC/name resolution yields
|
|
94
|
+
// `ignore`, never a stuck validator (which would strand the message in gossipsub's
|
|
95
|
+
// mcache).
|
|
96
|
+
return withDeadline(doValidate(data, from), timeoutMs, "ignore");
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|