@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.
- package/LICENSE +674 -0
- package/README.md +212 -0
- package/dist/chain/bucket.d.ts +13 -0
- package/dist/chain/bucket.js +24 -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 +225 -0
- package/dist/client/voter.js +1195 -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 +72 -0
- package/dist/errors.js +111 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +39 -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 +56 -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 +83 -0
- package/dist/transport/chase.js +88 -0
- package/dist/transport/gossip-validator.d.ts +107 -0
- package/dist/transport/gossip-validator.js +99 -0
- package/dist/transport/helia.d.ts +41 -0
- package/dist/transport/helia.js +98 -0
- package/dist/transport/integration/harness.d.ts +60 -0
- package/dist/transport/integration/harness.js +262 -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 +165 -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 +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 {};
|
package/dist/topic.d.ts
ADDED
|
@@ -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
|
+
}
|