@bitsocial/pubsub-votes 0.0.2

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.
Files changed (117) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +212 -0
  3. package/dist/chain/bucket.d.ts +13 -0
  4. package/dist/chain/bucket.js +24 -0
  5. package/dist/chain/ticker.d.ts +15 -0
  6. package/dist/chain/ticker.js +25 -0
  7. package/dist/chain/types.d.ts +90 -0
  8. package/dist/chain/types.js +1 -0
  9. package/dist/checkpoint/codec.d.ts +54 -0
  10. package/dist/checkpoint/codec.js +99 -0
  11. package/dist/client/root-puller.d.ts +49 -0
  12. package/dist/client/root-puller.js +140 -0
  13. package/dist/client/voter.d.ts +225 -0
  14. package/dist/client/voter.js +1195 -0
  15. package/dist/crdt/codec.d.ts +41 -0
  16. package/dist/crdt/codec.js +137 -0
  17. package/dist/crdt/crdt.d.ts +22 -0
  18. package/dist/crdt/crdt.js +127 -0
  19. package/dist/crdt/store.d.ts +8 -0
  20. package/dist/crdt/store.js +23 -0
  21. package/dist/crdt/types.d.ts +87 -0
  22. package/dist/crdt/types.js +1 -0
  23. package/dist/encoding/canonical.d.ts +22 -0
  24. package/dist/encoding/canonical.js +26 -0
  25. package/dist/errors.d.ts +72 -0
  26. package/dist/errors.js +111 -0
  27. package/dist/index.d.ts +31 -0
  28. package/dist/index.js +39 -0
  29. package/dist/rules/constant.d.ts +14 -0
  30. package/dist/rules/constant.js +18 -0
  31. package/dist/rules/erc20-balance.d.ts +30 -0
  32. package/dist/rules/erc20-balance.js +44 -0
  33. package/dist/rules/erc721-min-balance.d.ts +18 -0
  34. package/dist/rules/erc721-min-balance.js +56 -0
  35. package/dist/rules/registry.d.ts +42 -0
  36. package/dist/rules/registry.js +61 -0
  37. package/dist/rules/types.d.ts +74 -0
  38. package/dist/rules/types.js +1 -0
  39. package/dist/schema/common.d.ts +25 -0
  40. package/dist/schema/common.js +24 -0
  41. package/dist/schema/criteria.d.ts +78 -0
  42. package/dist/schema/criteria.js +76 -0
  43. package/dist/schema/directory.d.ts +42 -0
  44. package/dist/schema/directory.js +52 -0
  45. package/dist/schema/votes.d.ts +55 -0
  46. package/dist/schema/votes.js +116 -0
  47. package/dist/signer/eip712.d.ts +103 -0
  48. package/dist/signer/eip712.js +85 -0
  49. package/dist/signer/types.d.ts +31 -0
  50. package/dist/signer/types.js +1 -0
  51. package/dist/storage/browser.d.ts +3 -0
  52. package/dist/storage/browser.js +110 -0
  53. package/dist/storage/memory.d.ts +10 -0
  54. package/dist/storage/memory.js +56 -0
  55. package/dist/storage/node.d.ts +5 -0
  56. package/dist/storage/node.js +106 -0
  57. package/dist/storage/types.d.ts +46 -0
  58. package/dist/storage/types.js +1 -0
  59. package/dist/store/indexeddb.d.ts +9 -0
  60. package/dist/store/indexeddb.js +72 -0
  61. package/dist/store/memory.d.ts +15 -0
  62. package/dist/store/memory.js +22 -0
  63. package/dist/store/select.d.ts +15 -0
  64. package/dist/store/select.js +64 -0
  65. package/dist/store/sqlite.d.ts +11 -0
  66. package/dist/store/sqlite.js +68 -0
  67. package/dist/store/types.d.ts +57 -0
  68. package/dist/store/types.js +1 -0
  69. package/dist/tally/tally.d.ts +44 -0
  70. package/dist/tally/tally.js +89 -0
  71. package/dist/tally/types.d.ts +51 -0
  72. package/dist/tally/types.js +13 -0
  73. package/dist/topic.d.ts +20 -0
  74. package/dist/topic.js +28 -0
  75. package/dist/transport/accepted-dedup.d.ts +30 -0
  76. package/dist/transport/accepted-dedup.js +34 -0
  77. package/dist/transport/announce/browser.d.ts +9 -0
  78. package/dist/transport/announce/browser.js +14 -0
  79. package/dist/transport/announce/node.d.ts +38 -0
  80. package/dist/transport/announce/node.js +162 -0
  81. package/dist/transport/announce/types.d.ts +74 -0
  82. package/dist/transport/announce/types.js +16 -0
  83. package/dist/transport/bundle-store.d.ts +11 -0
  84. package/dist/transport/bundle-store.js +34 -0
  85. package/dist/transport/chase.d.ts +83 -0
  86. package/dist/transport/chase.js +88 -0
  87. package/dist/transport/gossip-validator.d.ts +107 -0
  88. package/dist/transport/gossip-validator.js +99 -0
  89. package/dist/transport/helia.d.ts +41 -0
  90. package/dist/transport/helia.js +98 -0
  91. package/dist/transport/integration/harness.d.ts +60 -0
  92. package/dist/transport/integration/harness.js +262 -0
  93. package/dist/transport/messages.d.ts +97 -0
  94. package/dist/transport/messages.js +117 -0
  95. package/dist/transport/rate-limit.d.ts +11 -0
  96. package/dist/transport/rate-limit.js +20 -0
  97. package/dist/transport/transport.d.ts +20 -0
  98. package/dist/transport/transport.js +35 -0
  99. package/dist/transport/types.d.ts +165 -0
  100. package/dist/transport/types.js +1 -0
  101. package/dist/verify/background.d.ts +81 -0
  102. package/dist/verify/background.js +236 -0
  103. package/dist/verify/bundle.d.ts +58 -0
  104. package/dist/verify/bundle.js +84 -0
  105. package/dist/verify/cache.d.ts +48 -0
  106. package/dist/verify/cache.js +62 -0
  107. package/dist/verify/constraints.d.ts +16 -0
  108. package/dist/verify/constraints.js +35 -0
  109. package/dist/verify/gate-result-cache.d.ts +65 -0
  110. package/dist/verify/gate-result-cache.js +91 -0
  111. package/dist/verify/name-resolution-cache.d.ts +59 -0
  112. package/dist/verify/name-resolution-cache.js +64 -0
  113. package/dist/verify/signature.d.ts +9 -0
  114. package/dist/verify/signature.js +55 -0
  115. package/dist/verify/types.d.ts +101 -0
  116. package/dist/verify/types.js +1 -0
  117. package/package.json +77 -0
@@ -0,0 +1,57 @@
1
+ import type { Vote } from "../schema/votes.js";
2
+ /**
3
+ * Vote-intent persistence.
4
+ *
5
+ * A live vote decays on its own: a bundle is valid for only `voteExpiryBuckets` after its
6
+ * `blockNumber` (see DESIGN.md "Passive expiry"). Keeping a vote alive means periodically
7
+ * re-signing the *same choice* with a fresh `blockNumber` and re-broadcasting it. To do
8
+ * that across a process restart the voter must remember what it chose — but not the signed
9
+ * bundles (those are immutable, content-addressed, and live in the host's Helia blockstore;
10
+ * a stale `blockNumber` makes an old bundle useless). What it persists is the re-signable
11
+ * *intent*: which communities this wallet picked in which contest. On `start()` the voter loads
12
+ * every stored intent and republishes it; the republish scheduler re-signs each on the
13
+ * liveness cadence (`ceil(voteExpiryBuckets / 2)` buckets — see DESIGN.md "Lifecycle").
14
+ *
15
+ * This is a plain key-value contract, keyed by `topic` (one intent per contest per wallet).
16
+ * It is internal: the library picks the backend by environment — IndexedDB in the browser,
17
+ * a SQLite file under the constructor's `dataPath` on Node — with no host-facing store seam.
18
+ * Declared as its own interface (like `BundleStore` in crdt/types.ts) so the voter can be
19
+ * tested against an in-memory store with no I/O.
20
+ */
21
+ /** One contest's re-signable (or, when empty, re-announceable) choice for this wallet. */
22
+ export interface VoteIntent {
23
+ /** The gossipsub topic this intent votes in = "bitsocial-votes/" + CID(dag-cbor(criteria)). */
24
+ topic: string;
25
+ /** The voting wallet address (recovered from the bundle signature). One intent per topic per address. */
26
+ address: string;
27
+ /**
28
+ * The communities this wallet chose. A non-empty intent is an active vote the scheduler re-signs
29
+ * (fresh `blockNumber`) each cadence to keep alive. An **empty array is a withdrawal
30
+ * tombstone**: the empty bundle supersedes the prior vote under LWW, and the scheduler
31
+ * re-announces its existing CID (never re-signing it) each cadence until it expires, then
32
+ * deletes the intent — the bundle decays on its own via expiry + prune. See DESIGN.md
33
+ * "Cancelling a vote".
34
+ */
35
+ votes: Vote[];
36
+ /** The bucket of the last successful republish, so the scheduler knows when the next is due. */
37
+ lastBucket: number;
38
+ }
39
+ /**
40
+ * Where the voter keeps its own vote intents. Only ever holds *this* voter's choices, not
41
+ * the CRDT of everyone's bundles. `list` feeds the republish loop on `start()`; `put` is
42
+ * written on every cast and every successful republish; `delete` drops an intent that has
43
+ * expired or been explicitly cancelled. `destroy` releases backend handles (close the DB
44
+ * or file) and is called by `PubsubVoter.destroy()`.
45
+ */
46
+ export interface VoteStore {
47
+ /** Every intent to keep alive. Called once on `start()` to seed the republish scheduler. */
48
+ list(): Promise<VoteIntent[]>;
49
+ /** The intent for one contest, or `undefined` if this wallet has not voted there. */
50
+ get(topic: string): Promise<VoteIntent | undefined>;
51
+ /** Insert or replace one contest's intent (last write wins, mirroring the CRDT). */
52
+ put(intent: VoteIntent): Promise<void>;
53
+ /** Drop one contest's intent (expiry or explicit cancel). Idempotent. */
54
+ delete(topic: string): Promise<void>;
55
+ /** Release backend handles. Optional: the in-memory store has nothing to release. */
56
+ destroy?(): Promise<void>;
57
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import type { Criteria } from "../schema/criteria.js";
2
+ import type { VotesBundle } from "../schema/votes.js";
3
+ import type { RuleRegistry } from "../rules/types.js";
4
+ import type { ChainClient, BucketMath } from "../chain/types.js";
5
+ import type { BundleChecks } from "../verify/types.js";
6
+ import type { Tally } from "./types.js";
7
+ /**
8
+ * Deterministic per-contest aggregation over the CRDT's current bundles. Every aggregated
9
+ * bundle passed the synchronous offline checks (signature, constraints) at admission; each
10
+ * entry's {@link BundleChecks} says where its two deferred NETWORK checks stand (the on-chain
11
+ * gate read and name resolution — run inline by the forward-gate for live gossip, in the
12
+ * background chain verifier for cold-join admits). The tally never re-does verification: it
13
+ * sums *weight magnitude* per community, folds each entry's check state into the row's
14
+ * `chainVerified` / `nameResolved` flags, and orders the rows. In v1 the weight rule is
15
+ * `constant`, so this does ZERO chain reads (see DESIGN.md "Tally"). The reserved
16
+ * balance-derived weight path (`erc20-balance`) is where the lazy ceiling/floor early-stop
17
+ * would live; v1 has a trivial `1` ceiling per vote, so the common case simply sums.
18
+ *
19
+ * Rows are keyed by `community.publicKey`, so votes carrying different names for the same key
20
+ * fold into one row. Ties are broken by the rolling seed `sha256(bucketBlockHash ‖ publicKey)`
21
+ * — ungrindable because a future block hash cannot be predicted — and the one block-hash read
22
+ * happens only when an actual tie must be broken.
23
+ */
24
+ export interface TallyDeps {
25
+ criteria: Criteria;
26
+ registry: RuleRegistry;
27
+ chainFor: (ticker: string) => ChainClient;
28
+ bucketMath: BucketMath;
29
+ /**
30
+ * The CRDT's current bundles (one per wallet, LWW-resolved; empty-votes bundles are
31
+ * withdrawals), each with its deferred-check state (see verify/types.ts `BundleChecks`).
32
+ */
33
+ current: () => Array<{
34
+ bundle: VotesBundle;
35
+ checks: BundleChecks;
36
+ }>;
37
+ /**
38
+ * Hash of the current bucket boundary block on the criteria's chain, for the rolling tie
39
+ * seed. Invoked at most once per `compute`, and only when a tie must actually be broken —
40
+ * so a tie-free tally still does no extra read.
41
+ */
42
+ bucketBlockHash: () => Promise<Uint8Array>;
43
+ }
44
+ export declare function makeTally(deps: TallyDeps): Tally;
@@ -0,0 +1,89 @@
1
+ import { base58btc } from "multiformats/bases/base58";
2
+ import { sha256 } from "multiformats/hashes/sha2";
3
+ import { tickerForRef } from "../chain/ticker.js";
4
+ import { UnknownRuleError } from "../errors.js";
5
+ /** Byte-lexicographic compare of two byte arrays (returns <0, 0, >0). */
6
+ function compareBytes(x, y) {
7
+ const n = Math.min(x.length, y.length);
8
+ for (let i = 0; i < n; i++) {
9
+ const xi = x[i];
10
+ const yi = y[i];
11
+ if (xi !== yi)
12
+ return xi - yi;
13
+ }
14
+ return x.length - y.length;
15
+ }
16
+ export function makeTally(deps) {
17
+ const { criteria, registry, chainFor, bucketMath, current, bucketBlockHash } = deps;
18
+ // Resolve the weight rule, its options, and its chain once (see verify/bundle.ts).
19
+ const weight = registry[criteria.weight.type];
20
+ if (!weight)
21
+ throw new UnknownRuleError("weight", criteria.weight.type);
22
+ const weightOptions = weight.optionsSchema.parse(criteria.weight);
23
+ const weightChain = chainFor(tickerForRef(criteria, criteria.weight, weightOptions));
24
+ const weightFor = async (wallet, blockNumber) => {
25
+ const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(blockNumber));
26
+ const { score } = await weight.evaluate({
27
+ options: weightOptions,
28
+ walletAddress: wallet,
29
+ ctx: { chain: weightChain, blockNumber: sampleBlock }
30
+ });
31
+ return score;
32
+ };
33
+ /** The rolling tie seed for a community: sha256(bucketBlockHash ‖ publicKey bytes). */
34
+ const tieSeed = async (blockHash, publicKey) => {
35
+ const pkBytes = base58btc.decode(`z${publicKey}`);
36
+ const buf = new Uint8Array(blockHash.length + pkBytes.length);
37
+ buf.set(blockHash, 0);
38
+ buf.set(pkBytes, blockHash.length);
39
+ return (await sha256.digest(buf)).digest;
40
+ };
41
+ return {
42
+ async compute() {
43
+ // Aggregate weight per community.publicKey, folding each contribution's deferred-check
44
+ // state into the row: `chainVerified` only once EVERY contributing bundle's gate read
45
+ // confirmed, and the shown name prefers (and reports) a registry-resolved one.
46
+ const rows = new Map();
47
+ for (const { bundle, checks } of current()) {
48
+ if (bundle.votes.length === 0)
49
+ continue; // withdrawal — expresses no vote
50
+ const w = await weightFor(bundle.address, bundle.blockNumber);
51
+ for (const v of bundle.votes) {
52
+ const pk = v.community.publicKey;
53
+ const row = rows.get(pk) ?? { weight: 0n, chainVerified: true };
54
+ row.weight += w;
55
+ row.chainVerified &&= checks.chainVerified;
56
+ // Prefer a resolved name over a still-pending one; never downgrade to pending.
57
+ if (v.community.name && row.nameResolved !== true) {
58
+ row.name = v.community.name;
59
+ row.nameResolved = checks.nameResolved === true;
60
+ }
61
+ rows.set(pk, row);
62
+ }
63
+ }
64
+ const list = [...rows.entries()].map(([publicKey, row]) => ({
65
+ community: row.name ? { name: row.name, publicKey } : { publicKey },
66
+ weight: row.weight,
67
+ chainVerified: row.chainVerified,
68
+ ...(row.name ? { nameResolved: row.nameResolved } : {})
69
+ }));
70
+ // Order by weight desc. Only if two rows tie on weight do we read the boundary
71
+ // block hash and order those by the ungrindable rolling seed.
72
+ const hasTie = new Set(list.map((r) => r.weight)).size !== list.length;
73
+ if (!hasTie) {
74
+ list.sort((a, b) => (a.weight < b.weight ? 1 : a.weight > b.weight ? -1 : 0));
75
+ return { contestId: criteria.contestId, ranking: list };
76
+ }
77
+ const blockHash = await bucketBlockHash();
78
+ const seeds = new Map();
79
+ for (const r of list)
80
+ seeds.set(r.community.publicKey, await tieSeed(blockHash, r.community.publicKey));
81
+ list.sort((a, b) => {
82
+ if (a.weight !== b.weight)
83
+ return a.weight < b.weight ? 1 : -1;
84
+ return compareBytes(seeds.get(a.community.publicKey), seeds.get(b.community.publicKey));
85
+ });
86
+ return { contestId: criteria.contestId, ranking: list };
87
+ }
88
+ };
89
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tally interfaces.
3
+ *
4
+ * One topic decides one contest, so the tally aggregates the current bundles into a single
5
+ * contest ranking. The synchronous checks (signature, constraints) are admission
6
+ * preconditions — every aggregated bundle has passed them — so the per-row flags below track
7
+ * only the two deferred NETWORK checks, one field per verification operation (mirroring
8
+ * pkc-js's `nameResolved`): the on-chain gate read and community-name resolution. A cold join
9
+ * admits a checkpoint's bundles provisionally and renders immediately; the background chain
10
+ * verifier then confirms (flipping the flags, re-emitting `update`) or evicts. See DESIGN.md
11
+ * "Background chain verification" and "Tally".
12
+ */
13
+ /** One community's standing within a contest. */
14
+ export interface CommunityTally {
15
+ /**
16
+ * The community this row aggregates. Rows are keyed by `community.publicKey`; `name` is the
17
+ * community's resolvable domain carried through from the votes. It never affects which
18
+ * votes fold into this row, and `nameResolved` below says whether it has been checked
19
+ * against the registry yet. See DESIGN.md "Votes wire" and "Tally".
20
+ */
21
+ community: {
22
+ name?: string;
23
+ publicKey: string;
24
+ };
25
+ /** Summed weight of upvotes counted so far, in rule score units (`bigint`). */
26
+ weight: bigint;
27
+ /**
28
+ * True once every bundle contributing to this row has had its gate `rule` confirmed `> 0n`
29
+ * by an on-chain read at its bucket block. `false` means at least one contribution is still
30
+ * awaiting its background gate read — never that one failed (a failed gate evicts the
31
+ * bundle and recounts the row).
32
+ */
33
+ chainVerified: boolean;
34
+ /**
35
+ * Present only when the row carries a `name`: true once the shown name resolved to this
36
+ * row's `publicKey` through the registry (pkc-js's `nameResolved` shape). `false` while the
37
+ * carried name is still awaiting background resolution — a name that resolves to a
38
+ * DIFFERENT key evicts its bundle instead, so a mismatched name is never shown.
39
+ */
40
+ nameResolved?: boolean;
41
+ }
42
+ /** A contest's ranking, highest weight first. Ordering is deterministic. */
43
+ export interface ContestTally {
44
+ /** The `contestId` of the criteria this ranking is for. */
45
+ contestId: string;
46
+ ranking: CommunityTally[];
47
+ }
48
+ /** Computes the contest ranking from the CRDT's current bundles. */
49
+ export interface Tally {
50
+ compute(): Promise<ContestTally>;
51
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Tally interfaces.
3
+ *
4
+ * One topic decides one contest, so the tally aggregates the current bundles into a single
5
+ * contest ranking. The synchronous checks (signature, constraints) are admission
6
+ * preconditions — every aggregated bundle has passed them — so the per-row flags below track
7
+ * only the two deferred NETWORK checks, one field per verification operation (mirroring
8
+ * pkc-js's `nameResolved`): the on-chain gate read and community-name resolution. A cold join
9
+ * admits a checkpoint's bundles provisionally and renders immediately; the background chain
10
+ * verifier then confirms (flipping the flags, re-emitting `update`) or evicts. See DESIGN.md
11
+ * "Background chain verification" and "Tally".
12
+ */
13
+ export {};
@@ -0,0 +1,20 @@
1
+ import { CID } from "multiformats/cid";
2
+ import type { Criteria } from "./schema/criteria.js";
3
+ /**
4
+ * Topic derivation.
5
+ *
6
+ * topic = "bitsocial-votes/" + CID(dag-cbor(criteria))
7
+ *
8
+ * The topic is the content address of the criteria document, so subscribing to a
9
+ * topic is itself proof that two peers ran byte-identical rules. A different value
10
+ * (different contest, gate, expiry, ...) yields different bytes, a different CID, and
11
+ * therefore a different topic — which is how criteria upgrades fork cleanly. Reordering
12
+ * keys does NOT fork the topic, because dag-cbor sorts them. See DESIGN.md
13
+ * "Criteria document". Pure and offline: no libp2p/helia, no network.
14
+ */
15
+ /** Prefix that namespaces every vote topic. */
16
+ export declare const TOPIC_PREFIX = "bitsocial-votes/";
17
+ /** The CIDv1 (dag-cbor, sha2-256) of a criteria document's canonical encoding. */
18
+ export declare function criteriaCid(criteria: Criteria): Promise<CID>;
19
+ /** The gossipsub topic a criteria document maps to. */
20
+ export declare function topicFor(criteria: Criteria): Promise<string>;
package/dist/topic.js ADDED
@@ -0,0 +1,28 @@
1
+ import { CID } from "multiformats/cid";
2
+ import { sha256 } from "multiformats/hashes/sha2";
3
+ import { encodeCriteria, dagCborCode } from "./encoding/canonical.js";
4
+ /**
5
+ * Topic derivation.
6
+ *
7
+ * topic = "bitsocial-votes/" + CID(dag-cbor(criteria))
8
+ *
9
+ * The topic is the content address of the criteria document, so subscribing to a
10
+ * topic is itself proof that two peers ran byte-identical rules. A different value
11
+ * (different contest, gate, expiry, ...) yields different bytes, a different CID, and
12
+ * therefore a different topic — which is how criteria upgrades fork cleanly. Reordering
13
+ * keys does NOT fork the topic, because dag-cbor sorts them. See DESIGN.md
14
+ * "Criteria document". Pure and offline: no libp2p/helia, no network.
15
+ */
16
+ /** Prefix that namespaces every vote topic. */
17
+ export const TOPIC_PREFIX = "bitsocial-votes/";
18
+ /** The CIDv1 (dag-cbor, sha2-256) of a criteria document's canonical encoding. */
19
+ export async function criteriaCid(criteria) {
20
+ const bytes = encodeCriteria(criteria);
21
+ const digest = await sha256.digest(bytes);
22
+ return CID.createV1(dagCborCode, digest);
23
+ }
24
+ /** The gossipsub topic a criteria document maps to. */
25
+ export async function topicFor(criteria) {
26
+ const cid = await criteriaCid(criteria);
27
+ return TOPIC_PREFIX + cid.toString();
28
+ }
@@ -0,0 +1,30 @@
1
+ import type { BucketMath } from "../chain/types.js";
2
+ import type { VotesBundle } from "../schema/votes.js";
3
+ /**
4
+ * A bounded set of already-accepted votes, keyed by `(wallet, bucket, votes)`, used by the forward
5
+ * gate to drop a **re-sign flood**: an eligible wallet re-signing the same choice with fresh
6
+ * signature nonces (or a bumped `blockNumber` inside the same bucket) mints a new bundle CID each
7
+ * time, so the per-CID verdict cache never hits. Each such bundle is inert under LWW (same wallet)
8
+ * and shares the same expiry bucket and the same tally choice as the one already accepted, so
9
+ * re-forwarding it is pure amplification (see DESIGN.md "Transport", resource-exhaustion residual).
10
+ *
11
+ * Keyed on the *bucket* (not the raw `blockNumber`) and the canonical `votes` bytes, so a genuine
12
+ * heartbeat re-sign — which lands in a *later* bucket on the liveness cadence — is NOT a duplicate,
13
+ * and a genuine vote *change* (different `votes`) is NOT a duplicate. Only a same-bucket, same-choice
14
+ * re-sign matches.
15
+ *
16
+ * Safety: `isResignDuplicate` may be consulted on the *untrusted* claimed `bundle.address` (before
17
+ * the signature is verified), because a hit only ever yields "drop, do not forward" — never accept
18
+ * or store. A hit means an equivalent vote was already verified and forwarded, so dropping an
19
+ * attacker's matching-key garbage suppresses nothing an honest peer lacks; a non-hit falls through
20
+ * to the full verifier, where a bad signature is `reject`ed. `record` is therefore only ever called
21
+ * for a bundle that has passed the full gate.
22
+ */
23
+ export interface AcceptedDedup {
24
+ /** Is `bundle` a same-bucket, same-choice re-sign of a vote already accepted from its wallet? */
25
+ isResignDuplicate(bundle: VotesBundle): boolean;
26
+ /** Record a fully-verified, accepted bundle so later re-signs of it are recognised as duplicates. */
27
+ record(bundle: VotesBundle): void;
28
+ }
29
+ /** An in-memory {@link AcceptedDedup} bounded to `maxEntries` with FIFO eviction. */
30
+ export declare function makeAcceptedDedup(bucketMath: BucketMath, maxEntries?: number): AcceptedDedup;
@@ -0,0 +1,34 @@
1
+ import { encodeCanonical } from "../encoding/canonical.js";
2
+ function toHex(bytes) {
3
+ let s = "";
4
+ for (const b of bytes)
5
+ s += b.toString(16).padStart(2, "0");
6
+ return s;
7
+ }
8
+ /** An in-memory {@link AcceptedDedup} bounded to `maxEntries` with FIFO eviction. */
9
+ export function makeAcceptedDedup(bucketMath, maxEntries = 4096) {
10
+ const keys = new Set();
11
+ const order = [];
12
+ const keyFor = (bundle) => {
13
+ const bucket = bucketMath.bucketForBlock(bundle.blockNumber);
14
+ // `votes` is canonically dag-cbor encoded (sorted keys), so the same choice always yields
15
+ // the same key regardless of authoring order. Votes are tiny in v1, so raw hex is a cheap,
16
+ // sync key — no hashing needed.
17
+ return `${bundle.address.toLowerCase()}:${bucket}:${toHex(encodeCanonical(bundle.votes))}`;
18
+ };
19
+ return {
20
+ isResignDuplicate: (bundle) => keys.has(keyFor(bundle)),
21
+ record: (bundle) => {
22
+ const k = keyFor(bundle);
23
+ if (keys.has(k))
24
+ return;
25
+ keys.add(k);
26
+ order.push(k);
27
+ if (order.length > maxEntries) {
28
+ const evicted = order.shift();
29
+ if (evicted !== undefined)
30
+ keys.delete(evicted);
31
+ }
32
+ }
33
+ };
34
+ }
@@ -0,0 +1,9 @@
1
+ import type { Announcer, AnnouncerOptions } from "./types.js";
2
+ /**
3
+ * The browser announcer: inert by design. A browser peer is not dialable — no listener, no
4
+ * public address — so a provider record naming it would only misdirect cold joiners; it must
5
+ * never announce, whatever `httpRouterUrls` says. The package.json `browser` field remaps
6
+ * `./dist/transport/announce/node.js` to this module (the same swap as `src/storage/`), so the
7
+ * voter's wiring is identical on both platforms and only the effect differs.
8
+ */
9
+ export declare function makeAnnouncer(_options: AnnouncerOptions): Announcer;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The browser announcer: inert by design. A browser peer is not dialable — no listener, no
3
+ * public address — so a provider record naming it would only misdirect cold joiners; it must
4
+ * never announce, whatever `httpRouterUrls` says. The package.json `browser` field remaps
5
+ * `./dist/transport/announce/node.js` to this module (the same swap as `src/storage/`), so the
6
+ * voter's wiring is identical on both platforms and only the effect differs.
7
+ */
8
+ export function makeAnnouncer(_options) {
9
+ return {
10
+ start() { },
11
+ stop() { },
12
+ notifyChange() { }
13
+ };
14
+ }
@@ -0,0 +1,38 @@
1
+ import type { Announcer, AnnouncerOptions } from "./types.js";
2
+ /**
3
+ * The Node provider-record announcer (see types.ts for the seam rationale, and DESIGN.md
4
+ * "Deferred pkc-js work", provider-record announces): one unsigned `PUT /routing/v1/providers`
5
+ * per configured router per tick, kubo's body shape —
6
+ * `{ Providers: [{ Schema: "peer", Payload: { ID, Addrs, Keys } }] }` — with `Keys` batched
7
+ * across ALL joined contests. Unsigned is correct against the production router
8
+ * (pkc-http-router reads only `Payload.{ID, Addrs, Keys, AdvisoryTTL}`; no signature field
9
+ * exists), and its anti-spoofing keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's
10
+ * source IP — which a seeder announcing its own addresses passes naturally.
11
+ *
12
+ * Ticks: the debounced change trigger ({@link Announcer.notifyChange} — contest joins, checkpoint
13
+ * root changes, `self:peer:update` address changes) plus an hourly re-announce (pkc-js's
14
+ * `providePubsubTopicRoutingCidsIfNeeded` cadence; the production router's record TTL is 24h, so
15
+ * hourly has a 24× margin). Best-effort per router: a failing or slow router is reported through
16
+ * `onError` and never throws into the voter; there is no retry — the next tick covers it.
17
+ */
18
+ /** Hourly re-announce — pkc-js's `providePubsubTopicRoutingCidsIfNeeded` cadence, ≪ the 24h TTL. */
19
+ export declare const ANNOUNCE_INTERVAL_MS = 3600000;
20
+ /**
21
+ * Change-coalescing window: a gossip burst re-dirties the checkpoint per accepted bundle and a
22
+ * directory join fires once per contest, so the first trigger arms one timer and the burst rides
23
+ * it — bounding announces to one per window while a hot topic churns.
24
+ */
25
+ export declare const ANNOUNCE_DEBOUNCE_MS = 10000;
26
+ /** Per-router PUT deadline — same order as the cold-join router lookup deadline. */
27
+ export declare const ANNOUNCE_ROUTER_TIMEOUT_MS = 10000;
28
+ /**
29
+ * Filter a node's multiaddrs down to what belongs in a public provider record: public `/ip4` /
30
+ * `/ip6` addrs and DNS addrs (`/dns4`/`/dns6`/`/dnsaddr` — the AutoTLS `<peerId>.libp2p.direct`
31
+ * WSS addrs travel this way, and the production router passes DNS through unvalidated). Private,
32
+ * loopback, link-local, CGNAT, and unspecified IPs are dropped CLIENT-side rather than trusting
33
+ * the router to drop them; a `p2p-circuit` addr is judged by its relay's leading component like
34
+ * any other. Exported for the announcer's unit tests.
35
+ */
36
+ export declare function announceableAddrs(addrs: readonly string[]): string[];
37
+ /** Build the Node announcer. The browser build never sees this file (package.json `browser` remap). */
38
+ export declare function makeAnnouncer(options: AnnouncerOptions): Announcer;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The Node provider-record announcer (see types.ts for the seam rationale, and DESIGN.md
3
+ * "Deferred pkc-js work", provider-record announces): one unsigned `PUT /routing/v1/providers`
4
+ * per configured router per tick, kubo's body shape —
5
+ * `{ Providers: [{ Schema: "peer", Payload: { ID, Addrs, Keys } }] }` — with `Keys` batched
6
+ * across ALL joined contests. Unsigned is correct against the production router
7
+ * (pkc-http-router reads only `Payload.{ID, Addrs, Keys, AdvisoryTTL}`; no signature field
8
+ * exists), and its anti-spoofing keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's
9
+ * source IP — which a seeder announcing its own addresses passes naturally.
10
+ *
11
+ * Ticks: the debounced change trigger ({@link Announcer.notifyChange} — contest joins, checkpoint
12
+ * root changes, `self:peer:update` address changes) plus an hourly re-announce (pkc-js's
13
+ * `providePubsubTopicRoutingCidsIfNeeded` cadence; the production router's record TTL is 24h, so
14
+ * hourly has a 24× margin). Best-effort per router: a failing or slow router is reported through
15
+ * `onError` and never throws into the voter; there is no retry — the next tick covers it.
16
+ */
17
+ /** Hourly re-announce — pkc-js's `providePubsubTopicRoutingCidsIfNeeded` cadence, ≪ the 24h TTL. */
18
+ export const ANNOUNCE_INTERVAL_MS = 3_600_000;
19
+ /**
20
+ * Change-coalescing window: a gossip burst re-dirties the checkpoint per accepted bundle and a
21
+ * directory join fires once per contest, so the first trigger arms one timer and the burst rides
22
+ * it — bounding announces to one per window while a hot topic churns.
23
+ */
24
+ export const ANNOUNCE_DEBOUNCE_MS = 10_000;
25
+ /** Per-router PUT deadline — same order as the cold-join router lookup deadline. */
26
+ export const ANNOUNCE_ROUTER_TIMEOUT_MS = 10_000;
27
+ /** RFC1918/loopback/link-local/CGNAT/unspecified IPv4 — never announceable. */
28
+ function isPrivateIp4(ip) {
29
+ const parts = ip.split(".").map(Number);
30
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255))
31
+ return true;
32
+ const [a, b] = parts;
33
+ return (a === 0 || // unspecified / "this network"
34
+ a === 10 ||
35
+ a === 127 || // loopback
36
+ (a === 100 && b >= 64 && b < 128) || // CGNAT 100.64/10
37
+ (a === 169 && b === 254) || // link-local
38
+ (a === 172 && b >= 16 && b < 32) ||
39
+ (a === 192 && b === 168));
40
+ }
41
+ /** Loopback/link-local/ULA/unspecified IPv6 — never announceable. */
42
+ function isPrivateIp6(ip) {
43
+ const lower = ip.toLowerCase();
44
+ if (lower === "::" || lower === "::1")
45
+ return true;
46
+ // fe80::/10 link-local (fe8x..febx), fc00::/7 unique-local (fcxx/fdxx).
47
+ return /^fe[89ab]/.test(lower) || /^f[cd]/.test(lower);
48
+ }
49
+ /**
50
+ * Filter a node's multiaddrs down to what belongs in a public provider record: public `/ip4` /
51
+ * `/ip6` addrs and DNS addrs (`/dns4`/`/dns6`/`/dnsaddr` — the AutoTLS `<peerId>.libp2p.direct`
52
+ * WSS addrs travel this way, and the production router passes DNS through unvalidated). Private,
53
+ * loopback, link-local, CGNAT, and unspecified IPs are dropped CLIENT-side rather than trusting
54
+ * the router to drop them; a `p2p-circuit` addr is judged by its relay's leading component like
55
+ * any other. Exported for the announcer's unit tests.
56
+ */
57
+ export function announceableAddrs(addrs) {
58
+ return addrs.filter((addr) => {
59
+ const [, proto, value] = addr.split("/");
60
+ if (proto === undefined || value === undefined)
61
+ return false;
62
+ if (proto === "dns4" || proto === "dns6" || proto === "dnsaddr" || proto === "dns")
63
+ return true;
64
+ if (proto === "ip4")
65
+ return !isPrivateIp4(value);
66
+ if (proto === "ip6")
67
+ return !isPrivateIp6(value);
68
+ return false;
69
+ });
70
+ }
71
+ /** One unsigned kubo-shape provider PUT; throws on timeout or a non-2xx answer. */
72
+ async function putProviders(baseUrl, body, timeoutMs) {
73
+ const endpoint = `${baseUrl.replace(/\/+$/, "")}/routing/v1/providers`;
74
+ const res = await fetch(endpoint, {
75
+ method: "PUT",
76
+ headers: { "content-type": "application/json" },
77
+ body,
78
+ signal: AbortSignal.timeout(timeoutMs)
79
+ });
80
+ if (!res.ok)
81
+ throw new Error(`router ${endpoint} answered ${res.status}`);
82
+ // Drain so the connection can be reused; the ProvideResults echo carries nothing we act on.
83
+ await res.arrayBuffer().catch(() => { });
84
+ }
85
+ /** Build the Node announcer. The browser build never sees this file (package.json `browser` remap). */
86
+ export function makeAnnouncer(options) {
87
+ const intervalMs = options.intervalMs ?? ANNOUNCE_INTERVAL_MS;
88
+ const debounceMs = options.debounceMs ?? ANNOUNCE_DEBOUNCE_MS;
89
+ const timeoutMs = options.timeoutMs ?? ANNOUNCE_ROUTER_TIMEOUT_MS;
90
+ let started = false;
91
+ let intervalTimer;
92
+ let debounceTimer;
93
+ /** True while one announce runs; a trigger landing mid-run re-runs once after (never overlaps). */
94
+ let running = false;
95
+ let rerun = false;
96
+ const announceNow = async () => {
97
+ if (running) {
98
+ rerun = true;
99
+ return;
100
+ }
101
+ running = true;
102
+ try {
103
+ do {
104
+ rerun = false;
105
+ const keys = await options.keys();
106
+ const addrs = announceableAddrs(options.libp2p.getMultiaddrs().map((a) => a.toString()));
107
+ // Nothing joined, or no publicly dialable address: announce nothing. The production
108
+ // router drops a provider whose addrs come up empty anyway — an undialable node
109
+ // (plain client, NATed box with no configured announce addrs) must not announce.
110
+ if (keys.length === 0 || addrs.length === 0)
111
+ continue;
112
+ const body = JSON.stringify({
113
+ Providers: [{ Schema: "peer", Payload: { ID: options.libp2p.peerId.toString(), Addrs: addrs, Keys: keys } }]
114
+ });
115
+ await Promise.all(options.routerUrls.map(async (url) => {
116
+ try {
117
+ await putProviders(url, body, timeoutMs);
118
+ }
119
+ catch (error) {
120
+ options.onError?.(url, error);
121
+ }
122
+ }));
123
+ } while (rerun);
124
+ }
125
+ finally {
126
+ running = false;
127
+ }
128
+ };
129
+ const notifyChange = () => {
130
+ if (!started || debounceTimer !== undefined)
131
+ return;
132
+ debounceTimer = setTimeout(() => {
133
+ debounceTimer = undefined;
134
+ void announceNow();
135
+ }, debounceMs);
136
+ debounceTimer.unref?.();
137
+ };
138
+ const onAddressChange = () => notifyChange();
139
+ return {
140
+ start() {
141
+ if (started)
142
+ return;
143
+ started = true;
144
+ options.libp2p.addEventListener("self:peer:update", onAddressChange);
145
+ intervalTimer = setInterval(() => void announceNow(), intervalMs);
146
+ intervalTimer.unref?.();
147
+ },
148
+ stop() {
149
+ if (!started)
150
+ return;
151
+ started = false;
152
+ options.libp2p.removeEventListener("self:peer:update", onAddressChange);
153
+ if (intervalTimer !== undefined)
154
+ clearInterval(intervalTimer);
155
+ intervalTimer = undefined;
156
+ if (debounceTimer !== undefined)
157
+ clearTimeout(debounceTimer);
158
+ debounceTimer = undefined;
159
+ },
160
+ notifyChange
161
+ };
162
+ }