@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,78 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The criteria document.
4
+ *
5
+ * Shipped static in the client bundle and used to derive the pubsub topic:
6
+ * topic = "bitsocial-votes/" + CID(dag-cbor(criteria))
7
+ *
8
+ * One criteria document describes exactly one contest (one directory slot), so there
9
+ * is one topic per contest. The differing `contestId` value makes each contest's bytes
10
+ * distinct, which forks the topic automatically. A client joins only the contests it
11
+ * cares about, which is what keeps cold start cheap. See DESIGN.md "Criteria document".
12
+ *
13
+ * Because the topic is the CID of the canonical encoding of this object, two peers
14
+ * on the same topic provably ran identical rules. Therefore this object MUST be
15
+ * canonically encodable (no `undefined`, deterministic key order under dag-cbor);
16
+ * a non-canonical change silently changes the topic.
17
+ */
18
+ /** Inclusive numeric bounds for a single `vote` value. v1 is { min: 1, max: 1 }. */
19
+ export declare const VoteRangeSchema: z.ZodObject<{
20
+ min: z.ZodNumber;
21
+ max: z.ZodNumber;
22
+ }, z.core.$strip>;
23
+ /**
24
+ * A reference to a rule by `type`, plus rule-specific options.
25
+ * Kept loose on purpose: each rule owns and validates its own option schema
26
+ * (see rules/types.ts), so custom rules can be referenced without
27
+ * changing CriteriaSchema. This mirrors pkc-js challenge settings
28
+ * ({ name, options }) where the named challenge validates its own options.
29
+ */
30
+ export declare const RuleRefSchema: z.ZodObject<{
31
+ type: z.ZodString;
32
+ }, z.core.$loose>;
33
+ /** RPC configuration for one chain ticker. Part of the dependency manifest. */
34
+ export declare const ChainConfigSchema: z.ZodObject<{
35
+ chainId: z.ZodNumber;
36
+ rpcUrls: z.ZodArray<z.ZodString>;
37
+ }, z.core.$strip>;
38
+ /**
39
+ * The dependency manifest. A client reads this on join and checks that it
40
+ * implements every named rule; if not, it is too old and must recuse
41
+ * itself rather than miscount. This is how criteria upgrades fork cleanly.
42
+ */
43
+ export declare const RequiresSchema: z.ZodObject<{
44
+ rules: z.ZodArray<z.ZodString>;
45
+ chains: z.ZodRecord<z.ZodString, z.ZodObject<{
46
+ chainId: z.ZodNumber;
47
+ rpcUrls: z.ZodArray<z.ZodString>;
48
+ }, z.core.$strip>>;
49
+ }, z.core.$strip>;
50
+ export declare const CriteriaSchema: z.ZodObject<{
51
+ name: z.ZodString;
52
+ contestId: z.ZodString;
53
+ voteSchema: z.ZodObject<{
54
+ min: z.ZodNumber;
55
+ max: z.ZodNumber;
56
+ }, z.core.$strip>;
57
+ maxVotesPerAddress: z.ZodNumber;
58
+ blocksPerBucket: z.ZodNumber;
59
+ voteExpiryBuckets: z.ZodNumber;
60
+ rule: z.ZodObject<{
61
+ type: z.ZodString;
62
+ }, z.core.$loose>;
63
+ weight: z.ZodObject<{
64
+ type: z.ZodString;
65
+ }, z.core.$loose>;
66
+ requires: z.ZodObject<{
67
+ rules: z.ZodArray<z.ZodString>;
68
+ chains: z.ZodRecord<z.ZodString, z.ZodObject<{
69
+ chainId: z.ZodNumber;
70
+ rpcUrls: z.ZodArray<z.ZodString>;
71
+ }, z.core.$strip>>;
72
+ }, z.core.$strip>;
73
+ }, z.core.$strict>;
74
+ export type VoteRange = z.infer<typeof VoteRangeSchema>;
75
+ export type RuleRef = z.infer<typeof RuleRefSchema>;
76
+ export type ChainConfig = z.infer<typeof ChainConfigSchema>;
77
+ export type Requires = z.infer<typeof RequiresSchema>;
78
+ export type Criteria = z.infer<typeof CriteriaSchema>;
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { ChainTickerSchema } from "./common.js";
3
+ /**
4
+ * The criteria document.
5
+ *
6
+ * Shipped static in the client bundle and used to derive the pubsub topic:
7
+ * topic = "bitsocial-votes/" + CID(dag-cbor(criteria))
8
+ *
9
+ * One criteria document describes exactly one contest (one directory slot), so there
10
+ * is one topic per contest. The differing `contestId` value makes each contest's bytes
11
+ * distinct, which forks the topic automatically. A client joins only the contests it
12
+ * cares about, which is what keeps cold start cheap. See DESIGN.md "Criteria document".
13
+ *
14
+ * Because the topic is the CID of the canonical encoding of this object, two peers
15
+ * on the same topic provably ran identical rules. Therefore this object MUST be
16
+ * canonically encodable (no `undefined`, deterministic key order under dag-cbor);
17
+ * a non-canonical change silently changes the topic.
18
+ */
19
+ /** Inclusive numeric bounds for a single `vote` value. v1 is { min: 1, max: 1 }. */
20
+ export const VoteRangeSchema = z.object({
21
+ min: z.number().int(),
22
+ max: z.number().int()
23
+ });
24
+ /**
25
+ * A reference to a rule by `type`, plus rule-specific options.
26
+ * Kept loose on purpose: each rule owns and validates its own option schema
27
+ * (see rules/types.ts), so custom rules can be referenced without
28
+ * changing CriteriaSchema. This mirrors pkc-js challenge settings
29
+ * ({ name, options }) where the named challenge validates its own options.
30
+ */
31
+ export const RuleRefSchema = z.looseObject({
32
+ type: z.string().min(1)
33
+ });
34
+ /** RPC configuration for one chain ticker. Part of the dependency manifest. */
35
+ export const ChainConfigSchema = z.object({
36
+ chainId: z.number().int().positive(),
37
+ rpcUrls: z.array(z.string().min(1)).nonempty()
38
+ });
39
+ /**
40
+ * The dependency manifest. A client reads this on join and checks that it
41
+ * implements every named rule; if not, it is too old and must recuse
42
+ * itself rather than miscount. This is how criteria upgrades fork cleanly.
43
+ */
44
+ export const RequiresSchema = z.object({
45
+ rules: z.array(z.string().min(1)).nonempty(),
46
+ chains: z.record(ChainTickerSchema, ChainConfigSchema)
47
+ });
48
+ export const CriteriaSchema = z
49
+ .object({
50
+ /** Human-readable label, not consensus-critical beyond changing the CID. */
51
+ name: z.string().min(1),
52
+ /**
53
+ * The directory-slot code this topic decides. One contest per topic: a distinct
54
+ * `contestId` makes the document's bytes distinct, which forks the topic automatically.
55
+ */
56
+ contestId: z.string().min(1),
57
+ /** Allowed range for each `vote` value. v1: { min: 1, max: 1 }. */
58
+ voteSchema: VoteRangeSchema,
59
+ /**
60
+ * Max community selections per wallet in this contest (anti-spam). v1 = 1, the
61
+ * one-vote-per-topic rule: a wallet picks one community. An empty `votes` array is
62
+ * always allowed as withdrawal/abstention regardless of this cap.
63
+ */
64
+ maxVotesPerAddress: z.number().int().positive(),
65
+ /** Block bucket size; all verifiers price the same block per bucket. */
66
+ blocksPerBucket: z.number().int().positive(),
67
+ /** How many buckets a bundle stays valid after its blockNumber. */
68
+ voteExpiryBuckets: z.number().int().positive(),
69
+ /** Who may vote (gates a wallet in or out). */
70
+ rule: RuleRefSchema,
71
+ /** How much an eligible vote counts. */
72
+ weight: RuleRefSchema,
73
+ /** Dependency manifest + version negotiation. */
74
+ requires: RequiresSchema
75
+ })
76
+ .strict();
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import { type Criteria } from "./criteria.js";
3
+ /**
4
+ * Directory authoring manifest → criteria documents.
5
+ *
6
+ * A directory (e.g. 5chan's 63 boards) is authored as ONE manifest of shared `defaults`
7
+ * plus one entry per slot, and each contest's standalone criteria document is derived by
8
+ * shallow merge: criteria = { ...defaults, ...entry }. The manifest itself is NOT a
9
+ * protocol object — it is never encoded or published, and `defaults` is flattened away at
10
+ * derivation. Only the derived documents matter: two clients must end up with
11
+ * byte-identical documents to share a topic (topic = CID(dag-cbor(criteria))), which is
12
+ * exactly why this derivation is library API rather than per-app code — two consumers
13
+ * (e.g. the 5chan web client and a standalone seeder) re-implementing the merge is a
14
+ * silent topic fork waiting to happen.
15
+ *
16
+ * The merge is shallow ON PURPOSE: an entry override replaces that whole top-level field
17
+ * (a `rule` override brings its own complete rule object), no deep merge. Editing
18
+ * `defaults` re-forks every inheriting contest in lockstep; editing one entry forks only
19
+ * that contest. See the authoring manifest example (5chan-directory-criteria.jsonc) and
20
+ * DESIGN.md "Criteria document".
21
+ *
22
+ * Manifest files are conventionally JSONC (commented for human readers); strip comments
23
+ * (e.g. with `strip-json-comments`) and JSON.parse before calling — this helper is pure
24
+ * and takes the parsed value.
25
+ */
26
+ /**
27
+ * The manifest shape: `contests` is required, `defaults` optional, and everything else
28
+ * (name, description, ...) is ignored authoring metadata. Entries are kept loose here —
29
+ * each derived document is what gets validated, against the real CriteriaSchema.
30
+ */
31
+ export declare const DirectoryManifestSchema: z.ZodObject<{
32
+ defaults: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
33
+ contests: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
34
+ }, z.core.$loose>;
35
+ export type DirectoryManifest = z.infer<typeof DirectoryManifestSchema>;
36
+ /**
37
+ * Derive one complete, validated criteria document per manifest entry
38
+ * (`{ ...defaults, ...entry }`, shallow). Throws if the manifest is malformed, if any
39
+ * derived document fails CriteriaSchema, or if two entries claim the same `contestId`
40
+ * ({@link DuplicateContestIdError} — same slot decided by two topics is an authoring bug).
41
+ */
42
+ export declare function deriveDirectoryCriteria(manifest: unknown): Criteria[];
@@ -0,0 +1,52 @@
1
+ import { z } from "zod";
2
+ import { CriteriaSchema } from "./criteria.js";
3
+ import { DuplicateContestIdError } from "../errors.js";
4
+ /**
5
+ * Directory authoring manifest → criteria documents.
6
+ *
7
+ * A directory (e.g. 5chan's 63 boards) is authored as ONE manifest of shared `defaults`
8
+ * plus one entry per slot, and each contest's standalone criteria document is derived by
9
+ * shallow merge: criteria = { ...defaults, ...entry }. The manifest itself is NOT a
10
+ * protocol object — it is never encoded or published, and `defaults` is flattened away at
11
+ * derivation. Only the derived documents matter: two clients must end up with
12
+ * byte-identical documents to share a topic (topic = CID(dag-cbor(criteria))), which is
13
+ * exactly why this derivation is library API rather than per-app code — two consumers
14
+ * (e.g. the 5chan web client and a standalone seeder) re-implementing the merge is a
15
+ * silent topic fork waiting to happen.
16
+ *
17
+ * The merge is shallow ON PURPOSE: an entry override replaces that whole top-level field
18
+ * (a `rule` override brings its own complete rule object), no deep merge. Editing
19
+ * `defaults` re-forks every inheriting contest in lockstep; editing one entry forks only
20
+ * that contest. See the authoring manifest example (5chan-directory-criteria.jsonc) and
21
+ * DESIGN.md "Criteria document".
22
+ *
23
+ * Manifest files are conventionally JSONC (commented for human readers); strip comments
24
+ * (e.g. with `strip-json-comments`) and JSON.parse before calling — this helper is pure
25
+ * and takes the parsed value.
26
+ */
27
+ /**
28
+ * The manifest shape: `contests` is required, `defaults` optional, and everything else
29
+ * (name, description, ...) is ignored authoring metadata. Entries are kept loose here —
30
+ * each derived document is what gets validated, against the real CriteriaSchema.
31
+ */
32
+ export const DirectoryManifestSchema = z.looseObject({
33
+ defaults: z.record(z.string(), z.unknown()).optional(),
34
+ contests: z.array(z.record(z.string(), z.unknown())).nonempty()
35
+ });
36
+ /**
37
+ * Derive one complete, validated criteria document per manifest entry
38
+ * (`{ ...defaults, ...entry }`, shallow). Throws if the manifest is malformed, if any
39
+ * derived document fails CriteriaSchema, or if two entries claim the same `contestId`
40
+ * ({@link DuplicateContestIdError} — same slot decided by two topics is an authoring bug).
41
+ */
42
+ export function deriveDirectoryCriteria(manifest) {
43
+ const { defaults = {}, contests } = DirectoryManifestSchema.parse(manifest);
44
+ const seen = new Set();
45
+ return contests.map((entry) => {
46
+ const criteria = CriteriaSchema.parse({ ...defaults, ...entry });
47
+ if (seen.has(criteria.contestId))
48
+ throw new DuplicateContestIdError(criteria.contestId);
49
+ seen.add(criteria.contestId);
50
+ return criteria;
51
+ });
52
+ }
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The community being voted for: identified by its `publicKey`, with an
4
+ * optional resolvable domain `name` (e.g. "memes.bso"). Community identity — the key the
5
+ * tally aggregates on — is `publicKey` alone, but a carried `name` is a *verified
6
+ * claim*, not a free label: names are unique (the naming registry maps one name to one
7
+ * community), and at tally time a bundle whose `name` does not resolve to the claimed
8
+ * `publicKey` is dropped. A vote that omits `name` still folds into the same
9
+ * `publicKey` row. See DESIGN.md "Votes wire" and "Tally".
10
+ */
11
+ export declare const CommunitySchema: z.ZodObject<{
12
+ name: z.ZodOptional<z.ZodString>;
13
+ publicKey: z.ZodString;
14
+ }, z.core.$strip>;
15
+ /**
16
+ * A single vote inside a bundle. The contest is implied by the topic (one contest
17
+ * per topic), so it is not repeated on the wire.
18
+ * - `community` is the community being voted for (`{ name?, publicKey }`).
19
+ * - `vote` is numeric; its allowed range comes from `criteria.voteSchema`
20
+ * (v1 is upvote-only, so exactly 1). Range is enforced at verify time, not here,
21
+ * because the bounds live in the criteria, not in the wire type.
22
+ */
23
+ export declare const VoteSchema: z.ZodObject<{
24
+ community: z.ZodObject<{
25
+ name: z.ZodOptional<z.ZodString>;
26
+ publicKey: z.ZodString;
27
+ }, z.core.$strip>;
28
+ vote: z.ZodNumber;
29
+ }, z.core.$strip>;
30
+ /**
31
+ * The full wire node: the voting wallet, its votes, the bucket block, and the wallet's
32
+ * EIP-712 signature over the ballot. `address` is carried so the LWW key and chain reads
33
+ * are available without re-recovering; it MUST equal `recoverTypedDataAddress(signature)`
34
+ * (a forged `address` fails the recovery check at verify time). The signed payload is
35
+ * the EIP-712 ballot built from the contest CID + chainId + `votes` + `blockNumber`
36
+ * (see signer/eip712.ts), not these wire bytes directly.
37
+ */
38
+ export declare const VotesBundleSchema: z.ZodObject<{
39
+ address: z.ZodString;
40
+ votes: z.ZodArray<z.ZodObject<{
41
+ community: z.ZodObject<{
42
+ name: z.ZodOptional<z.ZodString>;
43
+ publicKey: z.ZodString;
44
+ }, z.core.$strip>;
45
+ vote: z.ZodNumber;
46
+ }, z.core.$strip>>;
47
+ blockNumber: z.ZodNumber;
48
+ signature: z.ZodObject<{
49
+ signature: z.ZodString;
50
+ type: z.ZodString;
51
+ }, z.core.$strip>;
52
+ }, z.core.$strip>;
53
+ export type Community = z.infer<typeof CommunitySchema>;
54
+ export type Vote = z.infer<typeof VoteSchema>;
55
+ export type VotesBundle = z.infer<typeof VotesBundleSchema>;
@@ -0,0 +1,116 @@
1
+ import { z } from "zod";
2
+ import { base58btc } from "multiformats/bases/base58";
3
+ import * as Digest from "multiformats/hashes/digest";
4
+ import { SignatureSchema } from "./common.js";
5
+ /**
6
+ * True when `x` is a base58btc IPNS name (a `12D3KooW…` key), i.e. it decodes to a
7
+ * multihash digest. This is the same check pkc-js uses (`isIpns`): a community identity is
8
+ * either a resolvable domain (contains a dot) or this self-certifying B58 key. A community's
9
+ * `publicKey` must be the B58 key — the stable identity the tally aggregates on — never a
10
+ * domain, which is mutable/re-pointable. The leading `z` is base58btc's multibase prefix.
11
+ */
12
+ function isB58IpnsKey(x) {
13
+ try {
14
+ Digest.decode(base58btc.decode(`z${x}`));
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ /**
22
+ * The Votes wire format.
23
+ *
24
+ * One bundle per voting wallet per heartbeat, scoped to the topic's single contest.
25
+ * The bundle is the value stored at each Merkle-CRDT node. An empty `votes` array is
26
+ * the withdrawal/abstention form: a newer bundle (higher blockNumber) with no votes
27
+ * supersedes an earlier one under LWW, removing the vote from the tally without
28
+ * breaking the monotonic union. See DESIGN.md "Votes wire", "Cancelling a vote", and
29
+ * "CRDT".
30
+ *
31
+ * The bundle is signed directly by the gating-chain wallet as EIP-712 typed data
32
+ * (see signer/eip712.ts); `address` is that wallet and MUST equal the address recovered
33
+ * from `signature`. There is no pkc-js author and no author->wallet binding.
34
+ */
35
+ /**
36
+ * True when `x` is a dotted domain name — at least two non-empty, whitespace-free labels
37
+ * (e.g. "memes.bso"). Community names must carry a TLD so they are resolvable through a
38
+ * name resolver; the TLD itself is deliberately not pinned (`.bso` today, possibly other
39
+ * naming systems later) — which names actually resolve is decided by the host-injected
40
+ * `nameResolvers` at tally time, not by this schema.
41
+ */
42
+ function isDottedDomain(x) {
43
+ const labels = x.split(".");
44
+ return labels.length >= 2 && labels.every((label) => label.length > 0 && !/\s/.test(label));
45
+ }
46
+ /**
47
+ * The community being voted for: identified by its `publicKey`, with an
48
+ * optional resolvable domain `name` (e.g. "memes.bso"). Community identity — the key the
49
+ * tally aggregates on — is `publicKey` alone, but a carried `name` is a *verified
50
+ * claim*, not a free label: names are unique (the naming registry maps one name to one
51
+ * community), and at tally time a bundle whose `name` does not resolve to the claimed
52
+ * `publicKey` is dropped. A vote that omits `name` still folds into the same
53
+ * `publicKey` row. See DESIGN.md "Votes wire" and "Tally".
54
+ */
55
+ export const CommunitySchema = z.object({
56
+ // A pkc-js community identity: `name` is an optional resolvable domain (must be
57
+ // dotted — carry a TLD — e.g. "memes.bso" via @bitsocial/bso-resolver), `publicKey`
58
+ // is the B58 IPNS name (e.g. "12D3KooW..."): the self-certifying key the tally
59
+ // aggregates on. Not an EVM address; the gating-chain wallet lives on the
60
+ // enclosing bundle's `address`, not here. We validate the key strictly
61
+ // (`isB58IpnsKey`) — stricter than pkc-js's loose `z.string().min(1)` field —
62
+ // because this is the vote-identity boundary: a domain or garbage in the identity
63
+ // slot must not reach the tally.
64
+ name: z
65
+ .string()
66
+ .min(1)
67
+ // The DNS hostname ceiling. A wire-shape hygiene bound, not a criteria knob: it makes a
68
+ // vote entry's size finite so the per-message cap is derivable from the criteria alone
69
+ // (see DESIGN.md "Message size cap").
70
+ .max(253)
71
+ .refine(isDottedDomain, "name must be a resolvable domain with a TLD (e.g. memes.bso)")
72
+ .optional(),
73
+ publicKey: z
74
+ .string()
75
+ .min(1)
76
+ .refine(isB58IpnsKey, "publicKey must be a base58btc IPNS key (e.g. 12D3KooW…), not a domain")
77
+ });
78
+ /**
79
+ * A single vote inside a bundle. The contest is implied by the topic (one contest
80
+ * per topic), so it is not repeated on the wire.
81
+ * - `community` is the community being voted for (`{ name?, publicKey }`).
82
+ * - `vote` is numeric; its allowed range comes from `criteria.voteSchema`
83
+ * (v1 is upvote-only, so exactly 1). Range is enforced at verify time, not here,
84
+ * because the bounds live in the criteria, not in the wire type.
85
+ */
86
+ export const VoteSchema = z.object({
87
+ community: CommunitySchema,
88
+ vote: z.number().int()
89
+ });
90
+ /**
91
+ * The full wire node: the voting wallet, its votes, the bucket block, and the wallet's
92
+ * EIP-712 signature over the ballot. `address` is carried so the LWW key and chain reads
93
+ * are available without re-recovering; it MUST equal `recoverTypedDataAddress(signature)`
94
+ * (a forged `address` fails the recovery check at verify time). The signed payload is
95
+ * the EIP-712 ballot built from the contest CID + chainId + `votes` + `blockNumber`
96
+ * (see signer/eip712.ts), not these wire bytes directly.
97
+ */
98
+ export const VotesBundleSchema = z
99
+ .object({
100
+ address: z.string().min(1),
101
+ votes: z.array(VoteSchema),
102
+ blockNumber: z.number().int().nonnegative(),
103
+ signature: SignatureSchema
104
+ })
105
+ // The votes must name pairwise-distinct communities. `maxVotesPerAddress` caps how many
106
+ // entries a bundle carries, but only distinctness makes that a cap on *communities*:
107
+ // without it a wallet allowed N approval votes could list one community N times and
108
+ // stack N× weight on it. Unlike the criteria-dependent constraints (length cap,
109
+ // vote range — enforced at verify time because their bounds live in the criteria),
110
+ // distinctness is pure wire shape, so the schema owns it. Duplicate *names* need no
111
+ // rule: same name + different keys dies at name resolution, same name + same key is
112
+ // caught here. See DESIGN.md "Votes wire".
113
+ .refine((bundle) => new Set(bundle.votes.map((v) => v.community.publicKey)).size === bundle.votes.length, {
114
+ message: "votes must name pairwise-distinct community.publicKeys (one entry per community)",
115
+ path: ["votes"]
116
+ });
@@ -0,0 +1,103 @@
1
+ import type { Vote } from "../schema/votes.js";
2
+ /**
3
+ * EIP-712 ballot typed data.
4
+ *
5
+ * The voting wallet signs this structure, not the raw wire bytes: it is the single
6
+ * canonical artifact the signer produces and the verifier recovers against (via viem's
7
+ * `signTypedData` / `recoverTypedDataAddress`). It replaces the old cbor "signed
8
+ * property names" scheme. See DESIGN.md "Votes wire".
9
+ *
10
+ * The signature binds three things so a vote cannot be replayed onto another contest or
11
+ * app, and cannot be re-stamped to a different block:
12
+ * - `criteria`: the criteria CID (equivalently the topic), carried as the raw binary
13
+ * CID bytes (`cid.bytes`) so there is no multibase/version representation ambiguity —
14
+ * every client hashes the same bytes. Different rules -> different CID -> a signature
15
+ * gathered for one contest does not validate on another. (This is the CID of the whole
16
+ * criteria document, distinct from the `criteria.contestId` slot-code field inside it.)
17
+ * - `votes`: each community (`{ name, publicKey }`) + numeric vote.
18
+ * - `blockNumber`: the LWW key and the bucketized block every verifier reads at.
19
+ *
20
+ * The `domain.chainId` is the gating (`rule`) chain, giving cross-chain/cross-app domain
21
+ * separation for free. This module is pure: no key material, no network, no viem import
22
+ * — it only shapes the object both sides feed to viem.
23
+ */
24
+ /** EIP-712 domain name shared by every contest. */
25
+ export declare const EIP712_DOMAIN_NAME = "bitsocial-votes";
26
+ /** The `type` tag carried in a bundle's {@link Signature} for an EIP-712 ballot signature. */
27
+ export declare const EIP712_SIGNATURE_TYPE = "eip712";
28
+ /**
29
+ * The EIP-712 `types` struct — frozen for v1 (see DESIGN.md "Wire freeze (v1)"). Field
30
+ * names and order are part of the type hash, so any change here is a breaking wire change
31
+ * that must re-freeze the conformance vector in eip712.test.ts. The domain deliberately
32
+ * carries no `version` field — nothing ever version-negotiates the domain, so a layout
33
+ * change is just a new frozen vector, not a version bump.
34
+ * - `criteria` is `bytes` (the raw binary CID, `cid.bytes`), not a string: hashing the
35
+ * canonical bytes avoids the multibase/version ambiguity a stringified CID would bake
36
+ * in, so independent clients recover identical signers.
37
+ * - `vote` is `int256` (signed) so a future criteria can widen the range to downvotes
38
+ * without a layout change; v1 is upvote-only.
39
+ * - `community` is a `Community` struct (`{ name, publicKey }`). EIP-712 has no optional
40
+ * fields, so `name` is always signed as a string — the empty string when the wire
41
+ * vote carries no name. Community identity is `publicKey`; `name` is a display label and
42
+ * does not affect the tally, so signing `""` vs. a name for the same `publicKey` is
43
+ * the same community.
44
+ */
45
+ export declare const BALLOT_TYPES: {
46
+ readonly Community: readonly [{
47
+ readonly name: "name";
48
+ readonly type: "string";
49
+ }, {
50
+ readonly name: "publicKey";
51
+ readonly type: "string";
52
+ }];
53
+ readonly Vote: readonly [{
54
+ readonly name: "community";
55
+ readonly type: "Community";
56
+ }, {
57
+ readonly name: "vote";
58
+ readonly type: "int256";
59
+ }];
60
+ readonly Ballot: readonly [{
61
+ readonly name: "criteria";
62
+ readonly type: "bytes";
63
+ }, {
64
+ readonly name: "votes";
65
+ readonly type: "Vote[]";
66
+ }, {
67
+ readonly name: "blockNumber";
68
+ readonly type: "uint256";
69
+ }];
70
+ };
71
+ /** The EIP-712 typed-data object handed to viem to sign or to recover a signer from. */
72
+ export interface BallotTypedData {
73
+ domain: {
74
+ name: string;
75
+ chainId: number;
76
+ };
77
+ types: typeof BALLOT_TYPES;
78
+ primaryType: "Ballot";
79
+ message: {
80
+ criteria: `0x${string}`;
81
+ votes: {
82
+ community: {
83
+ name: string;
84
+ publicKey: string;
85
+ };
86
+ vote: bigint;
87
+ }[];
88
+ blockNumber: bigint;
89
+ };
90
+ }
91
+ /**
92
+ * Build the EIP-712 ballot typed data for a bundle. Both the signer (to sign) and the
93
+ * verifier (to recover) call this with identical inputs, so the hashed structure matches.
94
+ * Integer fields are emitted as `bigint` (EIP-712 256-bit ints).
95
+ */
96
+ export declare function ballotTypedData(args: {
97
+ /** The criteria CID's raw binary bytes (`(await criteriaCid(criteria)).bytes`). */
98
+ criteriaCid: Uint8Array;
99
+ /** The gating (`rule`) chain's numeric chainId (`criteria.requires.chains[chain].chainId`). */
100
+ chainId: number;
101
+ votes: Vote[];
102
+ blockNumber: number;
103
+ }): BallotTypedData;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * EIP-712 ballot typed data.
3
+ *
4
+ * The voting wallet signs this structure, not the raw wire bytes: it is the single
5
+ * canonical artifact the signer produces and the verifier recovers against (via viem's
6
+ * `signTypedData` / `recoverTypedDataAddress`). It replaces the old cbor "signed
7
+ * property names" scheme. See DESIGN.md "Votes wire".
8
+ *
9
+ * The signature binds three things so a vote cannot be replayed onto another contest or
10
+ * app, and cannot be re-stamped to a different block:
11
+ * - `criteria`: the criteria CID (equivalently the topic), carried as the raw binary
12
+ * CID bytes (`cid.bytes`) so there is no multibase/version representation ambiguity —
13
+ * every client hashes the same bytes. Different rules -> different CID -> a signature
14
+ * gathered for one contest does not validate on another. (This is the CID of the whole
15
+ * criteria document, distinct from the `criteria.contestId` slot-code field inside it.)
16
+ * - `votes`: each community (`{ name, publicKey }`) + numeric vote.
17
+ * - `blockNumber`: the LWW key and the bucketized block every verifier reads at.
18
+ *
19
+ * The `domain.chainId` is the gating (`rule`) chain, giving cross-chain/cross-app domain
20
+ * separation for free. This module is pure: no key material, no network, no viem import
21
+ * — it only shapes the object both sides feed to viem.
22
+ */
23
+ /** EIP-712 domain name shared by every contest. */
24
+ export const EIP712_DOMAIN_NAME = "bitsocial-votes";
25
+ /** The `type` tag carried in a bundle's {@link Signature} for an EIP-712 ballot signature. */
26
+ export const EIP712_SIGNATURE_TYPE = "eip712";
27
+ /**
28
+ * The EIP-712 `types` struct — frozen for v1 (see DESIGN.md "Wire freeze (v1)"). Field
29
+ * names and order are part of the type hash, so any change here is a breaking wire change
30
+ * that must re-freeze the conformance vector in eip712.test.ts. The domain deliberately
31
+ * carries no `version` field — nothing ever version-negotiates the domain, so a layout
32
+ * change is just a new frozen vector, not a version bump.
33
+ * - `criteria` is `bytes` (the raw binary CID, `cid.bytes`), not a string: hashing the
34
+ * canonical bytes avoids the multibase/version ambiguity a stringified CID would bake
35
+ * in, so independent clients recover identical signers.
36
+ * - `vote` is `int256` (signed) so a future criteria can widen the range to downvotes
37
+ * without a layout change; v1 is upvote-only.
38
+ * - `community` is a `Community` struct (`{ name, publicKey }`). EIP-712 has no optional
39
+ * fields, so `name` is always signed as a string — the empty string when the wire
40
+ * vote carries no name. Community identity is `publicKey`; `name` is a display label and
41
+ * does not affect the tally, so signing `""` vs. a name for the same `publicKey` is
42
+ * the same community.
43
+ */
44
+ export const BALLOT_TYPES = {
45
+ Community: [
46
+ { name: "name", type: "string" },
47
+ { name: "publicKey", type: "string" }
48
+ ],
49
+ Vote: [
50
+ { name: "community", type: "Community" },
51
+ { name: "vote", type: "int256" }
52
+ ],
53
+ Ballot: [
54
+ { name: "criteria", type: "bytes" },
55
+ { name: "votes", type: "Vote[]" },
56
+ { name: "blockNumber", type: "uint256" }
57
+ ]
58
+ };
59
+ /** Lowercase `0x`-hex encoding of raw bytes, for the EIP-712 `bytes` message field. */
60
+ function bytesToHex(bytes) {
61
+ let hex = "";
62
+ for (const b of bytes)
63
+ hex += b.toString(16).padStart(2, "0");
64
+ return `0x${hex}`;
65
+ }
66
+ /**
67
+ * Build the EIP-712 ballot typed data for a bundle. Both the signer (to sign) and the
68
+ * verifier (to recover) call this with identical inputs, so the hashed structure matches.
69
+ * Integer fields are emitted as `bigint` (EIP-712 256-bit ints).
70
+ */
71
+ export function ballotTypedData(args) {
72
+ return {
73
+ domain: { name: EIP712_DOMAIN_NAME, chainId: args.chainId },
74
+ types: BALLOT_TYPES,
75
+ primaryType: "Ballot",
76
+ message: {
77
+ criteria: bytesToHex(args.criteriaCid),
78
+ votes: args.votes.map((v) => ({
79
+ community: { name: v.community.name ?? "", publicKey: v.community.publicKey },
80
+ vote: BigInt(v.vote)
81
+ })),
82
+ blockNumber: BigInt(args.blockNumber)
83
+ }
84
+ };
85
+ }
@@ -0,0 +1,31 @@
1
+ import type { Signature } from "../schema/common.js";
2
+ import type { BallotTypedData } from "./eip712.js";
3
+ /**
4
+ * Identity seam (net-new).
5
+ *
6
+ * This is the third host-injected dependency, alongside the `HeliaInstance` (network +
7
+ * blockstore) and `ChainClientFactory` (chain). It lets a host-agnostic core be driven by
8
+ * pkc-js, plebbit, or a raw key without the library ever holding key material: the
9
+ * library builds the EIP-712 ballot typed data and asks the host to sign it.
10
+ *
11
+ * The signer wraps the gating-chain wallet — the account that holds the Pass/ERC-20.
12
+ * There is no pkc-js author and no author->wallet binding: the address recovered from the
13
+ * ballot signature IS the voter (see DESIGN.md "Identity: the voting wallet, nothing
14
+ * else"). A voter constructed without a signer is read-only (renders tallies, cannot
15
+ * cast). See DESIGN.md "Votes wire".
16
+ */
17
+ export interface VoteSigner {
18
+ /**
19
+ * The voting wallet's address (the gating-chain account holding the Pass/ERC-20).
20
+ * Embedded as `bundle.address`; the verifier independently recovers it from the
21
+ * signature, so a wrong value here fails verification rather than forging a vote.
22
+ */
23
+ address(): Promise<string> | string;
24
+ /**
25
+ * Sign the EIP-712 ballot typed data with the wallet key (e.g. viem
26
+ * `account.signTypedData(typedData)`). The library builds the typed data; the private
27
+ * key stays in the host. Returns the detached signature with its scheme tag
28
+ * ({@link EIP712_SIGNATURE_TYPE}).
29
+ */
30
+ signBallot(typedData: BallotTypedData): Promise<Signature> | Signature;
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { StorageOptions, VoteStorage } from "./types.js";
2
+ /** Build the browser {@link VoteStorage}: IndexedDB via localforage, or in-memory for `false`. */
3
+ export declare function makeStorage(options: StorageOptions): VoteStorage;