@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,41 @@
1
+ import { CID } from "multiformats/cid";
2
+ import { z } from "zod";
3
+ import { type VotesBundle } from "../schema/votes.js";
4
+ /** The binary wire shape of one bundle block (dag-cbor map, canonical key order). */
5
+ declare const WireBundleSchema: z.ZodObject<{
6
+ address: z.ZodCustom<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>;
7
+ blockNumber: z.ZodNumber;
8
+ signature: z.ZodObject<{
9
+ signature: z.ZodCustom<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>;
10
+ type: z.ZodString;
11
+ }, z.core.$strip>;
12
+ votes: z.ZodArray<z.ZodObject<{
13
+ community: z.ZodObject<{
14
+ name: z.ZodOptional<z.ZodString>;
15
+ publicKey: z.ZodCustom<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>;
16
+ }, z.core.$strip>;
17
+ vote: z.ZodNumber;
18
+ }, z.core.$strip>>;
19
+ }, z.core.$strip>;
20
+ type WireBundle = z.infer<typeof WireBundleSchema>;
21
+ /**
22
+ * Map a (schema-valid) VotesBundle to its binary wire shape. Exported for the checkpoint
23
+ * codec, which inlines these same wire objects into its chunk blocks so the byte saving
24
+ * multiplies across every inlined winner.
25
+ */
26
+ export declare function toWireBundle(bundle: VotesBundle): WireBundle;
27
+ /**
28
+ * Map a decoded wire value back to a VotesBundle, validating both the wire shape and the
29
+ * full bundle schema (B58 key, distinct communities, name bound). Throws on anything
30
+ * malformed — callers on untrusted paths treat a throw as an invalid block.
31
+ */
32
+ export declare function fromWireBundle(wire: unknown): VotesBundle;
33
+ /** Canonically encode a VotesBundle to its binary dag-cbor block bytes. */
34
+ export declare function encodeBundle(bundle: VotesBundle): Uint8Array;
35
+ /** Decode binary block bytes back to a VotesBundle. Throws on malformed bytes. */
36
+ export declare function decodeBundle(bytes: Uint8Array): VotesBundle;
37
+ /** The CIDv1 (dag-cbor, sha2-256) content address of already-encoded bundle block bytes. */
38
+ export declare function bundleCidForBytes(bytes: Uint8Array): Promise<CID>;
39
+ /** The CIDv1 (dag-cbor, sha2-256) content address of a VotesBundle. */
40
+ export declare function bundleCid(bundle: VotesBundle): Promise<CID>;
41
+ export {};
@@ -0,0 +1,137 @@
1
+ import { CID } from "multiformats/cid";
2
+ import { sha256 } from "multiformats/hashes/sha2";
3
+ import * as dagCbor from "@ipld/dag-cbor";
4
+ import { base58btc } from "multiformats/bases/base58";
5
+ import { z } from "zod";
6
+ import { encodeCanonical, dagCborCode } from "../encoding/canonical.js";
7
+ import { VotesBundleSchema } from "../schema/votes.js";
8
+ /**
9
+ * VotesBundle <-> bytes, using the same canonical dag-cbor encoder as the criteria topic. A
10
+ * bundle is a standalone dag-cbor block with no parent links; content-addressing makes the
11
+ * CID a checksum over the bundle bytes, which is what the CRDT relies on to de-duplicate and
12
+ * LWW-tiebreak (see DESIGN.md "CRDT").
13
+ *
14
+ * The block layout is BINARY (see DESIGN.md "Bundle wire encoding"): crypto material travels
15
+ * as raw bytes, not strings — `address` as its 20 bytes, `signature.signature` as its 65
16
+ * bytes, `community.publicKey` as the raw multihash behind the B58 IPNS name — roughly
17
+ * halving a one-vote bundle. This is a *separate* serialization from the frozen EIP-712
18
+ * signed struct (signer/eip712.ts): changing it re-hashes the bundle CID but never the
19
+ * signature vector. Decoding maps each field back to the string form the schema and EIP-712
20
+ * verification expect; hex round-trips to lowercase (signature recovery compares addresses
21
+ * case-insensitively, so casing is presentation, not identity). The layout is pinned by the
22
+ * fixed test vector in `codec.test.ts` — any change is a breaking wire change that re-freezes
23
+ * the vector (and the checkpoint vector, which inlines these bytes).
24
+ */
25
+ const ADDRESS_BYTES = 20;
26
+ const SIGNATURE_BYTES = 65;
27
+ // `z.instanceof(Uint8Array)` infers `Uint8Array<ArrayBuffer>`, which rejects the
28
+ // `ArrayBufferLike`-backed arrays multiformats/dag-cbor produce — hence the custom check.
29
+ const BytesSchema = z.custom((value) => value instanceof Uint8Array, "expected bytes");
30
+ /** The binary wire shape of one bundle block (dag-cbor map, canonical key order). */
31
+ const WireBundleSchema = z.object({
32
+ address: BytesSchema,
33
+ blockNumber: z.number().int().nonnegative(),
34
+ signature: z.object({
35
+ signature: BytesSchema,
36
+ type: z.string().min(1)
37
+ }),
38
+ votes: z.array(z.object({
39
+ community: z.object({
40
+ name: z.string().optional(),
41
+ publicKey: BytesSchema
42
+ }),
43
+ vote: z.number().int()
44
+ }))
45
+ });
46
+ function hexToBytes(hex, expectedBytes, label) {
47
+ if (!/^0x[0-9a-fA-F]*$/.test(hex) || hex.length !== 2 + expectedBytes * 2) {
48
+ throw new Error(`${label} must be 0x-hex of exactly ${expectedBytes} bytes`);
49
+ }
50
+ const out = new Uint8Array(expectedBytes);
51
+ for (let i = 0; i < expectedBytes; i++)
52
+ out[i] = parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16);
53
+ return out;
54
+ }
55
+ function bytesToHex(bytes) {
56
+ let hex = "0x";
57
+ for (const byte of bytes)
58
+ hex += byte.toString(16).padStart(2, "0");
59
+ return hex;
60
+ }
61
+ /** B58 IPNS name ("12D3KooW…", base58btc without the multibase prefix) -> raw multihash bytes. */
62
+ function publicKeyToBytes(publicKey) {
63
+ return base58btc.decode(`z${publicKey}`);
64
+ }
65
+ /** Raw multihash bytes -> the B58 IPNS name (drop base58btc's leading `z`). */
66
+ function publicKeyFromBytes(bytes) {
67
+ return base58btc.encode(bytes).slice(1);
68
+ }
69
+ /**
70
+ * Map a (schema-valid) VotesBundle to its binary wire shape. Exported for the checkpoint
71
+ * codec, which inlines these same wire objects into its chunk blocks so the byte saving
72
+ * multiplies across every inlined winner.
73
+ */
74
+ export function toWireBundle(bundle) {
75
+ const parsed = VotesBundleSchema.parse(bundle);
76
+ return {
77
+ address: hexToBytes(parsed.address, ADDRESS_BYTES, "address"),
78
+ blockNumber: parsed.blockNumber,
79
+ signature: {
80
+ signature: hexToBytes(parsed.signature.signature, SIGNATURE_BYTES, "signature.signature"),
81
+ type: parsed.signature.type
82
+ },
83
+ votes: parsed.votes.map((vote) => ({
84
+ community: {
85
+ // dag-cbor rejects `undefined`, so an absent name omits the key entirely.
86
+ ...(vote.community.name !== undefined ? { name: vote.community.name } : {}),
87
+ publicKey: publicKeyToBytes(vote.community.publicKey)
88
+ },
89
+ vote: vote.vote
90
+ }))
91
+ };
92
+ }
93
+ /**
94
+ * Map a decoded wire value back to a VotesBundle, validating both the wire shape and the
95
+ * full bundle schema (B58 key, distinct communities, name bound). Throws on anything
96
+ * malformed — callers on untrusted paths treat a throw as an invalid block.
97
+ */
98
+ export function fromWireBundle(wire) {
99
+ const parsed = WireBundleSchema.parse(wire);
100
+ if (parsed.address.length !== ADDRESS_BYTES)
101
+ throw new Error(`address must be ${ADDRESS_BYTES} bytes`);
102
+ if (parsed.signature.signature.length !== SIGNATURE_BYTES) {
103
+ throw new Error(`signature.signature must be ${SIGNATURE_BYTES} bytes`);
104
+ }
105
+ return VotesBundleSchema.parse({
106
+ address: bytesToHex(parsed.address),
107
+ blockNumber: parsed.blockNumber,
108
+ signature: {
109
+ signature: bytesToHex(parsed.signature.signature),
110
+ type: parsed.signature.type
111
+ },
112
+ votes: parsed.votes.map((vote) => ({
113
+ community: {
114
+ ...(vote.community.name !== undefined ? { name: vote.community.name } : {}),
115
+ publicKey: publicKeyFromBytes(vote.community.publicKey)
116
+ },
117
+ vote: vote.vote
118
+ }))
119
+ });
120
+ }
121
+ /** Canonically encode a VotesBundle to its binary dag-cbor block bytes. */
122
+ export function encodeBundle(bundle) {
123
+ return encodeCanonical(toWireBundle(bundle));
124
+ }
125
+ /** Decode binary block bytes back to a VotesBundle. Throws on malformed bytes. */
126
+ export function decodeBundle(bytes) {
127
+ return fromWireBundle(dagCbor.decode(bytes));
128
+ }
129
+ /** The CIDv1 (dag-cbor, sha2-256) content address of already-encoded bundle block bytes. */
130
+ export async function bundleCidForBytes(bytes) {
131
+ const digest = await sha256.digest(bytes);
132
+ return CID.createV1(dagCborCode, digest);
133
+ }
134
+ /** The CIDv1 (dag-cbor, sha2-256) content address of a VotesBundle. */
135
+ export async function bundleCid(bundle) {
136
+ return bundleCidForBytes(encodeBundle(bundle));
137
+ }
@@ -0,0 +1,22 @@
1
+ import type { CID } from "multiformats/cid";
2
+ import type { BucketMath } from "../chain/types.js";
3
+ import type { BundleStore, LwwResolve, VoteCrdt } from "./types.js";
4
+ /** Highest `blockNumber` wins; tie broken by lowest bundle CID. */
5
+ export declare const lwwResolve: LwwResolve;
6
+ export interface VoteCrdtDeps {
7
+ store: BundleStore;
8
+ /** Bucket math for `criteria.blocksPerBucket`, used by `prune` to age out expired bundles. */
9
+ bucketMath: BucketMath;
10
+ /** How many buckets a bundle stays valid after its blockNumber's bucket (`criteria.voteExpiryBuckets`). */
11
+ voteExpiryBuckets: number;
12
+ /** Conflict resolver; defaults to {@link lwwResolve}. */
13
+ resolve?: LwwResolve;
14
+ /**
15
+ * True while a bundle's deferred chain/name checks are still pending (see
16
+ * verify/background.ts). `prune` keeps a superseded bundle whose superseder is provisional —
17
+ * the fallback winner if the provisional bundle is later evicted. Omitted ⇒ nothing is
18
+ * provisional (prior behaviour).
19
+ */
20
+ isProvisional?: (cid: CID) => boolean;
21
+ }
22
+ export declare function makeVoteCrdt(deps: VoteCrdtDeps): VoteCrdt;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The state-based grow-only CRDT: a last-write-wins element-set keyed by the voting wallet.
3
+ * Each bundle is a standalone dag-cbor block; there are no parent links and no DAG to walk.
4
+ * Aggregation is a monotonic union (a CRDT join) — combining two peers' sets can only add
5
+ * bundles, never remove one — so a liar can omit a vote but cannot subtract one an honest
6
+ * peer serves (see DESIGN.md "CRDT", "Can always-online peers drop votes?").
7
+ *
8
+ * Conflict resolution per wallet: highest `blockNumber` wins, tie broken by lowest bundle CID,
9
+ * for determinism across clients. Bundles admitted here are assumed already validity-gated
10
+ * (signature, gate, name) by the transport before merge — the CRDT is trust-neutral
11
+ * storage, not a verifier.
12
+ */
13
+ /** Byte-lexicographic compare of two CIDs (returns <0, 0, >0). */
14
+ function compareCid(a, b) {
15
+ const x = a.bytes;
16
+ const y = b.bytes;
17
+ const n = Math.min(x.length, y.length);
18
+ for (let i = 0; i < n; i++) {
19
+ const xi = x[i];
20
+ const yi = y[i];
21
+ if (xi !== yi)
22
+ return xi - yi;
23
+ }
24
+ return x.length - y.length;
25
+ }
26
+ /** Highest `blockNumber` wins; tie broken by lowest bundle CID. */
27
+ export const lwwResolve = (a, b) => {
28
+ if (a.bundle.blockNumber !== b.bundle.blockNumber) {
29
+ return a.bundle.blockNumber > b.bundle.blockNumber ? a.cid : b.cid;
30
+ }
31
+ return compareCid(a.cid, b.cid) <= 0 ? a.cid : b.cid;
32
+ };
33
+ export function makeVoteCrdt(deps) {
34
+ const { store, bucketMath, voteExpiryBuckets } = deps;
35
+ const resolve = deps.resolve ?? lwwResolve;
36
+ const isProvisional = deps.isProvisional ?? (() => false);
37
+ // The working set of integrated bundles, keyed by CID string, mirrored from the store for
38
+ // fast reduction.
39
+ const bundles = new Map();
40
+ /**
41
+ * A bundle has decayed once the current bucket is past its `blockNumber`'s bucket plus the
42
+ * expiry window (see DESIGN.md "Passive expiry"). This is the read-time filter that keeps an
43
+ * expired vote out of the tally and the checkpoint — the same predicate `prune` uses to
44
+ * bound memory.
45
+ */
46
+ function isExpired(bundle, currentBucket) {
47
+ return currentBucket > bucketMath.bucketForBlock(bundle.blockNumber) + voteExpiryBuckets;
48
+ }
49
+ /** Integrate one bundle CID from the store into the working set (idempotent). */
50
+ async function integrate(cid) {
51
+ const key = cid.toString();
52
+ if (bundles.has(key))
53
+ return;
54
+ const bundle = await store.get(cid);
55
+ if (!bundle) {
56
+ throw new Error(`missing bundle ${key} during merge; fetch it into the store before merging`);
57
+ }
58
+ bundles.set(key, { cid, bundle });
59
+ }
60
+ /** LWW winner per wallet across the working set; `eligible` restricts the reduction. */
61
+ function winnersByWallet(eligible) {
62
+ const winners = new Map();
63
+ for (const entry of bundles.values()) {
64
+ if (eligible && !eligible(entry.cid))
65
+ continue;
66
+ const wallet = entry.bundle.address.toLowerCase();
67
+ const prev = winners.get(wallet);
68
+ if (!prev) {
69
+ winners.set(wallet, entry);
70
+ continue;
71
+ }
72
+ const winnerCid = resolve({ bundle: prev.bundle, cid: prev.cid }, { bundle: entry.bundle, cid: entry.cid });
73
+ winners.set(wallet, winnerCid.equals(prev.cid) ? prev : entry);
74
+ }
75
+ return winners;
76
+ }
77
+ /**
78
+ * One winner per wallet (LWW, optionally restricted to `eligible` bundles), minus expired
79
+ * winners. A winning empty-votes bundle is the withdrawal form and is returned as-is; the
80
+ * tally treats it as "no vote". A winner past its expiry window drops the wallet entirely —
81
+ * the read-time filter that keeps a decayed vote out of the tally and the checkpoint.
82
+ */
83
+ function currentEntries(currentBucket, eligible) {
84
+ return [...winnersByWallet(eligible).values()].filter((e) => !isExpired(e.bundle, currentBucket));
85
+ }
86
+ return {
87
+ async add(bundle) {
88
+ const cid = await store.put(bundle);
89
+ bundles.set(cid.toString(), { cid, bundle });
90
+ return cid;
91
+ },
92
+ async merge(cids) {
93
+ for (const cid of cids)
94
+ await integrate(cid);
95
+ },
96
+ current(currentBucket) {
97
+ return currentEntries(currentBucket).map((e) => e.bundle);
98
+ },
99
+ currentEntries,
100
+ remove(cid) {
101
+ bundles.delete(cid.toString());
102
+ },
103
+ nodeCount() {
104
+ return bundles.size;
105
+ },
106
+ async prune(currentBucket) {
107
+ const winners = winnersByWallet();
108
+ const removed = [];
109
+ for (const [key, entry] of bundles) {
110
+ const wallet = entry.bundle.address.toLowerCase();
111
+ const winner = winners.get(wallet);
112
+ const isWinner = winner === entry;
113
+ const bundleBucket = bucketMath.bucketForBlock(entry.bundle.blockNumber);
114
+ const expired = currentBucket > bundleBucket + voteExpiryBuckets;
115
+ // Drop superseded (non-winning) bundles and any bundle past its expiry window —
116
+ // EXCEPT a superseded bundle whose superseder is still provisional: it is the
117
+ // fallback winner if that bundle's deferred check evicts it.
118
+ const shieldedByProvisionalWinner = !isWinner && !expired && winner !== undefined && isProvisional(winner.cid);
119
+ if ((!isWinner || expired) && !shieldedByProvisionalWinner) {
120
+ bundles.delete(key);
121
+ removed.push(entry.cid);
122
+ }
123
+ }
124
+ return removed;
125
+ }
126
+ };
127
+ }
@@ -0,0 +1,8 @@
1
+ import type { BundleStore } from "./types.js";
2
+ /**
3
+ * In-memory {@link BundleStore}, keyed by content address. Used by the engine's unit
4
+ * tests and as a no-persistence default; the transport backs the same interface with the
5
+ * host's Helia blockstore (put/get by CID, bitswap for unknown CIDs) — see DESIGN.md
6
+ * "Transport". The store computes the CID on `put`, so callers never assign addresses.
7
+ */
8
+ export declare function makeMemoryBundleStore(): BundleStore;
@@ -0,0 +1,23 @@
1
+ import { bundleCid } from "./codec.js";
2
+ /**
3
+ * In-memory {@link BundleStore}, keyed by content address. Used by the engine's unit
4
+ * tests and as a no-persistence default; the transport backs the same interface with the
5
+ * host's Helia blockstore (put/get by CID, bitswap for unknown CIDs) — see DESIGN.md
6
+ * "Transport". The store computes the CID on `put`, so callers never assign addresses.
7
+ */
8
+ export function makeMemoryBundleStore() {
9
+ const byCid = new Map();
10
+ return {
11
+ async put(bundle) {
12
+ const cid = await bundleCid(bundle);
13
+ byCid.set(cid.toString(), bundle);
14
+ return cid;
15
+ },
16
+ async get(cid) {
17
+ return byCid.get(cid.toString());
18
+ },
19
+ async has(cid) {
20
+ return byCid.has(cid.toString());
21
+ }
22
+ };
23
+ }
@@ -0,0 +1,87 @@
1
+ import type { CID } from "multiformats/cid";
2
+ import type { VotesBundle } from "../schema/votes.js";
3
+ /**
4
+ * State-based grow-only LWW winner-set interfaces.
5
+ *
6
+ * State is a last-write-wins element-set keyed by the gating-chain wallet address. Each
7
+ * bundle is a standalone dag-cbor block addressed by its CID — it carries no parent links.
8
+ * The live path gossips individual bundle CIDs; a peer fetches each unknown bundle by CID,
9
+ * verifies it (at the transport gate), and LWW-merges it into its per-wallet winner-set.
10
+ * There is no DAG to walk and no notion of history "heads" — convergence comes from
11
+ * gossipsub flood, the heartbeat re-gossip, and checkpoint reconciliation.
12
+ * See DESIGN.md "CRDT".
13
+ */
14
+ /**
15
+ * The conflict-resolution rule for two bundles claiming the same wallet:
16
+ * higher blockNumber wins; tie broken by lower bundle CID. Returns the winner.
17
+ * Pure and deterministic so all clients agree.
18
+ */
19
+ export type LwwResolve = (a: {
20
+ bundle: VotesBundle;
21
+ cid: CID;
22
+ }, b: {
23
+ bundle: VotesBundle;
24
+ cid: CID;
25
+ }) => CID;
26
+ /**
27
+ * The CRDT store. `add` and `merge` only ever grow knowledge; `current` is the
28
+ * LWW reduction (one bundle per wallet) that the tally consumes.
29
+ */
30
+ export interface VoteCrdt {
31
+ /** Store a locally produced bundle as a standalone dag-cbor block. Returns its CID. */
32
+ add(bundle: VotesBundle): Promise<CID>;
33
+ /**
34
+ * Integrate remote bundle CIDs: fetch any unknown bundle by CID and LWW-merge it into the
35
+ * winner-set. There is no ancestor recursion — each CID is a standalone bundle. Idempotent
36
+ * and order-independent.
37
+ */
38
+ merge(cids: CID[]): Promise<void>;
39
+ /**
40
+ * LWW reduction (at most one bundle per wallet), filtered to the given bucket: a wallet
41
+ * whose winning bundle has expired drops out entirely, so neither the tally nor a
42
+ * checkpoint ever carries a decayed vote. `currentBucket` is `bucketForBlock(currentBlock)`.
43
+ */
44
+ current(currentBucket: number): VotesBundle[];
45
+ /**
46
+ * {@link current} with each winner's CID alongside its bundle — the CID is the key the
47
+ * engine's per-bundle check state ({@link BundleChecks}) is tracked under. The optional
48
+ * `eligible` predicate restricts the LWW reduction to bundles it admits: the checkpoint
49
+ * encoder passes "fully verified", so a provisionally admitted winner does not HIDE its
50
+ * verified predecessor from the served checkpoint — the reduction falls back to the newest
51
+ * bundle that IS eligible. No predicate reduces over everything (the tally's view).
52
+ */
53
+ currentEntries(currentBucket: number, eligible?: (cid: CID) => boolean): Array<{
54
+ cid: CID;
55
+ bundle: VotesBundle;
56
+ }>;
57
+ /**
58
+ * Drop one bundle from the working set — the eviction path for a provisionally admitted
59
+ * bundle whose deferred chain/name check failed (see verify/background.ts). Local
60
+ * housekeeping of an invalid admit, not a protocol subtract: the union/LWW semantics over
61
+ * VALID bundles are untouched, and the block bytes may stay in the blockstore.
62
+ */
63
+ remove(cid: CID): void;
64
+ /**
65
+ * Drop bundles older than `voteExpiryBuckets` and those superseded per wallet from the
66
+ * in-memory working set, bounding memory. Correctness does not depend on it — `current`
67
+ * filters expiry at read time — so this is housekeeping, not the guarantee. A superseded
68
+ * bundle whose superseder is still provisional (per the injected `isProvisional`) is KEPT:
69
+ * it is the fallback winner if the provisional bundle's deferred check evicts it. Returns
70
+ * the removed CIDs so the engine can drop their per-bundle check state.
71
+ */
72
+ prune(currentBucket: number): Promise<CID[]>;
73
+ /** Size of the in-memory working set — lets callers/tests observe that `prune` shrinks it. */
74
+ nodeCount(): number;
75
+ }
76
+ /**
77
+ * Persistence for vote bundles, backed by the host's Helia blockstore (dag-cbor).
78
+ * Declared as its own seam so the CRDT can be tested against an in-memory store.
79
+ */
80
+ export interface BundleStore {
81
+ put(bundle: VotesBundle): Promise<CID>;
82
+ /** `options.signal` cancels an in-flight bitswap fetch (the gate's per-fetch timeout aborts it). */
83
+ get(cid: CID, options?: {
84
+ signal?: AbortSignal;
85
+ }): Promise<VotesBundle | undefined>;
86
+ has(cid: CID): Promise<boolean>;
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import type { Criteria } from "../schema/criteria.js";
2
+ /**
3
+ * Canonical encoding.
4
+ *
5
+ * dag-cbor is itself the canonical form the whole protocol relies on: it sorts map
6
+ * keys deterministically and rejects `undefined`, so identical logical objects encode
7
+ * to identical bytes regardless of authoring key order. That property is what makes
8
+ * `topic = CID(dag-cbor(criteria))` a self-validating binding (see DESIGN.md
9
+ * "Criteria document"). Nothing here re-implements canonicalisation; it just names the
10
+ * single encoder the rest of the library must route through so the bytes never diverge.
11
+ *
12
+ * This module is pure and has no libp2p/helia import, so it is unit-testable offline.
13
+ */
14
+ /** Canonically encode any dag-cbor-safe value to bytes. */
15
+ export declare function encodeCanonical(value: unknown): Uint8Array;
16
+ /**
17
+ * Canonically encode a criteria document. This is the exact byte sequence whose CID
18
+ * becomes the pubsub topic, so it MUST be stable across clients and library versions.
19
+ */
20
+ export declare function encodeCriteria(criteria: Criteria): Uint8Array;
21
+ /** The dag-cbor codec code (0x71), exposed so callers build CIDs with the right codec. */
22
+ export declare const dagCborCode: 113;
@@ -0,0 +1,26 @@
1
+ import * as dagCbor from "@ipld/dag-cbor";
2
+ /**
3
+ * Canonical encoding.
4
+ *
5
+ * dag-cbor is itself the canonical form the whole protocol relies on: it sorts map
6
+ * keys deterministically and rejects `undefined`, so identical logical objects encode
7
+ * to identical bytes regardless of authoring key order. That property is what makes
8
+ * `topic = CID(dag-cbor(criteria))` a self-validating binding (see DESIGN.md
9
+ * "Criteria document"). Nothing here re-implements canonicalisation; it just names the
10
+ * single encoder the rest of the library must route through so the bytes never diverge.
11
+ *
12
+ * This module is pure and has no libp2p/helia import, so it is unit-testable offline.
13
+ */
14
+ /** Canonically encode any dag-cbor-safe value to bytes. */
15
+ export function encodeCanonical(value) {
16
+ return dagCbor.encode(value);
17
+ }
18
+ /**
19
+ * Canonically encode a criteria document. This is the exact byte sequence whose CID
20
+ * becomes the pubsub topic, so it MUST be stable across clients and library versions.
21
+ */
22
+ export function encodeCriteria(criteria) {
23
+ return dagCbor.encode(criteria);
24
+ }
25
+ /** The dag-cbor codec code (0x71), exposed so callers build CIDs with the right codec. */
26
+ export const dagCborCode = dagCbor.code;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Library error types.
3
+ *
4
+ * The engine and client lifecycle are implemented — schemas, encoding, topic derivation,
5
+ * the verify pipeline, the LWW winner-set CRDT, the tally, the transport's
6
+ * validate-before-forward gossip gate, and the reactive `PubsubVoter` / `Contest` / `ContestVote`
7
+ * facade. Republishing a live vote is the client's job (no scheduler, no persistence); see
8
+ * DESIGN.md "Republishing is the client's job".
9
+ */
10
+ /** Thrown by any facade path deferred to a later version (none in v1's shipped surface). */
11
+ export declare class NotImplementedError extends Error {
12
+ constructor(what: string);
13
+ }
14
+ /**
15
+ * Thrown when a criteria document names a rule `type` this client does not
16
+ * implement (in the `rule`/`weight` slot or in `requires.rules`). A
17
+ * client that hits this is too old (or missing a host override) and must recuse
18
+ * itself from the contest rather than miscount. See DESIGN.md "Rules".
19
+ */
20
+ export declare class UnknownRuleError extends Error {
21
+ readonly slot: "rule" | "weight" | "requires";
22
+ readonly type: string;
23
+ constructor(slot: "rule" | "weight" | "requires", type: string);
24
+ }
25
+ /**
26
+ * Thrown at construction when the injected Helia node's libp2p has no usable pubsub
27
+ * (gossipsub) service at `libp2p.services.pubsub`. The library broadcasts and receives
28
+ * winner bundle CIDs over gossipsub, so a node without it cannot participate. Helia's default
29
+ * libp2p services do NOT include pubsub — the host must register a gossipsub service
30
+ * (e.g. `@chainsafe/libp2p-gossipsub`) before passing the node in. We fail fast here
31
+ * rather than letting a later `publish`/`subscribe` fail obscurely. See DESIGN.md
32
+ * "Transport".
33
+ */
34
+ export declare class MissingPubsubError extends Error {
35
+ constructor();
36
+ }
37
+ /**
38
+ * Thrown at construction when the injected Helia node has no usable `blockstore`. Vote
39
+ * bundles are immutable content-addressed blocks fetched by CID through the host's
40
+ * blockstore (bitswap retrieves through it), so the engine cannot resolve a bundle without
41
+ * one. Note "bitswap" is not a separately introspectable property of a Helia node — it
42
+ * is a block broker wired *beneath* `blockstore` — so the checkable guarantee is a
43
+ * well-formed blockstore, the surface bitswap retrieves through. See DESIGN.md
44
+ * "Transport".
45
+ */
46
+ export declare class MissingBlockstoreError extends Error {
47
+ constructor();
48
+ }
49
+ /**
50
+ * Thrown at construction when the injected Helia node's libp2p has no usable **fetch
51
+ * service** at `libp2p.services.fetch`. The root-record pull — cold-start / reconnect
52
+ * checkpoint sync — rides the libp2p fetch protocol (this library registers its own lookup
53
+ * and runs its own requester), so the host must register `@libp2p/fetch` on the shared
54
+ * node. See DESIGN.md "Checkpoints" and "Deferred pkc-js work".
55
+ */
56
+ export declare class MissingFetchError extends Error {
57
+ constructor();
58
+ }
59
+ /**
60
+ * Thrown once a voter has been `destroy()`ed and something tries to keep using it. Unlike `stop()`
61
+ * (which leaves every topic but keeps the client reusable), `destroy()` is terminal: every contest
62
+ * is stopped and can no longer update or publish. Surfaced by `createContest` / `createContestVote`,
63
+ * and by a pre-existing `Contest.update()` / `ContestVote.publish()`. Construct a new
64
+ * `PubsubVoter` to participate again.
65
+ */
66
+ export declare class VoterDestroyedError extends Error {
67
+ constructor();
68
+ }
69
+ /** Thrown when a publish (vote/withdraw) is attempted on a voter constructed without a signer. */
70
+ export declare class ReadOnlyError extends Error {
71
+ constructor();
72
+ }