@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,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;
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|