@bitsocial/pubsub-voting 0.4.1 → 0.6.0

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.
@@ -3,7 +3,6 @@ import { CriteriaSchema } from "../schema/criteria.js";
3
3
  import { VotesBundleSchema } from "../schema/votes.js";
4
4
  import { coalescingChainFactory } from "../chain/coalescer.js";
5
5
  import { makeBucketMath } from "../chain/bucket.js";
6
- import { tickerForRef } from "../chain/ticker.js";
7
6
  import { requireHeliaServices } from "../transport/helia.js";
8
7
  import { makeBlockstoreBundleStore } from "../transport/bundle-store.js";
9
8
  import { makeRateLimiter } from "../transport/rate-limit.js";
@@ -15,7 +14,7 @@ import { encodeBundle, decodeBundle, bundleCidForBytes } from "../crdt/codec.js"
15
14
  import { resolveRegistry, validateCriteriaRules } from "../rules/registry.js";
16
15
  import { makeVoteCrdt } from "../crdt/crdt.js";
17
16
  import { makePersistentRuleCache } from "../rules/cache.js";
18
- import { gateFailure, scoreOrZero } from "../rules/result.js";
17
+ import { gateBlame, gateLeaves, gateReason, gateScore } from "../rules/gate.js";
19
18
  import { makeBundleVerifier } from "../verify/bundle.js";
20
19
  import { makeVerdictCache } from "../verify/cache.js";
21
20
  import { makeNameResolutionCache } from "../verify/name-resolution-cache.js";
@@ -32,7 +31,7 @@ import { CID } from "multiformats/cid";
32
31
  import { makeTally } from "../tally/tally.js";
33
32
  import { ballotTypedData } from "../signer/eip712.js";
34
33
  import { criteriaCid, TOPIC_PREFIX } from "../topic.js";
35
- import { InvalidCommunityNameError, MissingChainClientError, ReadOnlyError, UnknownRuleError, VoteEvictedError, VoterDestroyedError } from "../errors.js";
34
+ import { InvalidCommunityNameError, MissingChainClientError, UnknownRuleError, VoteEvictedError, VoterDestroyedError } from "../errors.js";
36
35
  /**
37
36
  * The recommended cadence, in buckets, at which a client should re-publish a live vote to keep
38
37
  * it alive: half its expiry window, rounded up. A bundle is valid for `voteExpiryBuckets` after
@@ -45,7 +44,7 @@ import { InvalidCommunityNameError, MissingChainClientError, ReadOnlyError, Unkn
45
44
  * `blockNumber` on a published `VotesBundle`, and `criteria.voteExpiryBuckets` /
46
45
  * `criteria.blocksPerBucket` are what a client uses to schedule its own refreshes: a vote sampled
47
46
  * at bucket `b` expires once the current bucket exceeds `b + voteExpiryBuckets`; refresh by
48
- * calling `createContestVote({ criteria, votes }).publish()` again before then.
47
+ * calling `createContestVote({ criteria, votes, signer }).publish()` again before then.
49
48
  */
50
49
  export function republishIntervalBuckets(criteria) {
51
50
  return Math.ceil(criteria.voteExpiryBuckets / 2);
@@ -166,6 +165,14 @@ const CHASE_SESSION_PROVIDER_HEADROOM = 1;
166
165
  * cannot grow memory.
167
166
  */
168
167
  const PEER_ROOTS_MAX = 256;
168
+ /**
169
+ * How many distinct chased roots we remember the contents of, for peer-checkpoint attribution
170
+ * ({@link ContestEngine.checkpointPeersFor}). Bounded for the same reason as {@link PEER_ROOTS_MAX}:
171
+ * a busy topic mints a new root on every admitted vote, so an unbounded index would grow with
172
+ * traffic forever. Forgetting a root only costs attribution for peers still advertising it — the
173
+ * next chase of a newer root re-establishes it.
174
+ */
175
+ const DECODED_ROOTS_MAX = 64;
169
176
  /**
170
177
  * How long a gating-chain head read stays fresh (ms) for the gate's freshness guard. Steady-
171
178
  * state votes cost no read (they resolve against the cached bucket); only a look-ahead bundle
@@ -531,14 +538,11 @@ function hexToBytes(hex) {
531
538
  class ContestEngine {
532
539
  criteria;
533
540
  topic;
534
- readOnly;
535
541
  #deps;
536
- /** Chain clients for this contest, built from `criteria.requires.chains` via the factory. */
537
- #chainClients;
538
542
  #criteriaCid;
539
- /** The gating (`rule`) chain's numeric chainId, bound into every ballot signature. */
543
+ /** The contest's chain id (`criteria.bucketChainId`), bound into every ballot signature. */
540
544
  #chainId;
541
- /** The gating (`rule`) chain client, also the seed chain for the tally's tie-break block hash. */
545
+ /** The contest's one chain client, also the seed chain for the tally's tie-break block hash. */
542
546
  #ruleChain;
543
547
  #bucketMath;
544
548
  #crdt;
@@ -601,27 +605,18 @@ class ContestEngine {
601
605
  constructor(criteria, topic, criteriaCidBytes, deps) {
602
606
  this.criteria = criteria;
603
607
  this.topic = topic;
604
- this.readOnly = deps.signer === undefined;
605
608
  this.#deps = deps;
606
609
  this.#criteriaCid = criteriaCidBytes;
607
- // Resolve every chain the manifest requires, eagerly: a client with no RPC configured
608
- // for one of them must find out at the create seam (recuse), not on its first verify.
609
- this.#chainClients = Object.fromEntries(Object.entries(criteria.requires.chains).map(([chain, config]) => {
610
- const client = deps.chains({ chain, chainId: config.chainId });
611
- if (client === undefined)
612
- throw new MissingChainClientError(chain, config.chainId);
613
- return [chain, client];
614
- }));
615
- // The gating (`rule`) chain fixes the ballot's chainId and the tie-break seed chain.
616
- const rule = deps.registry[criteria.rule.type];
617
- if (!rule)
618
- throw new UnknownRuleError("rule", criteria.rule.type);
619
- const ruleTicker = tickerForRef(criteria, criteria.rule, rule.optionsSchema.parse(criteria.rule));
620
- const ruleChain = this.#chainClients[ruleTicker];
621
- if (!ruleChain)
622
- throw new Error(`no chain client for gating (\`rule\`) chain "${ruleTicker}"`);
623
- this.#ruleChain = ruleChain;
624
- this.#chainId = criteria.requires.chains[ruleTicker].chainId;
610
+ // The contest's ONE chain (`bucketChainId`): it fixes the ballot's chainId, the blocks the
611
+ // buckets count, the block every rule is handed, and the tie-break seed. Resolved eagerly,
612
+ // so a client with no RPC configured for it finds out at the create seam (and recuses)
613
+ // rather than on its first verify.
614
+ this.#chainId = criteria.bucketChainId;
615
+ const chain = deps.chains({ chainId: this.#chainId });
616
+ if (chain === undefined)
617
+ throw new MissingChainClientError(this.#chainId);
618
+ this.#ruleChain = chain;
619
+ const gateRefs = gateLeaves(criteria.gate);
625
620
  this.#bucketMath = makeBucketMath(criteria.blocksPerBucket);
626
621
  const store = makeBlockstoreBundleStore(deps.blockstore);
627
622
  // The CRDT keeps a superseded bundle alive while its superseder's deferred checks are
@@ -632,40 +627,38 @@ class ContestEngine {
632
627
  voteExpiryBuckets: criteria.voteExpiryBuckets,
633
628
  isProvisional: (cid) => this.#isPending(cid)
634
629
  });
635
- // The gate rule's memo (rules/cache.ts), shared between the inline forward-gate verifier
636
- // and the background chain verifier so neither re-reads what the other settled — layered
637
- // over the voter's persistent store and namespaced by the hash of the canonical rule
638
- // reference + chainId. That hash is exactly the sharing boundary: two contests over one
639
- // gate (a directory of boards on the same Pass) share each other's reads, while
640
- // different gates, or one gate on different options, can never collide. What is stored
641
- // under it, and for how long, is the rule's business, not the engine's.
642
- this.#ruleHash = sha256(encodeDagCbor({ chainId: this.#chainId, rule: criteria.rule }));
643
- const gateCache = makePersistentRuleCache({ store: deps.gateStore, namespace: this.#ruleHash });
644
- // The weight rule gets its own namespace on the SAME terms — its canonical reference plus
645
- // the id of the chain IT reads, which is not necessarily the gating chain. A ticker is
646
- // just a name local to the criteria document, so two contests can spell the same weight
647
- // ref while `requires.chains` binds that ticker to different chains; keying on the gate's
648
- // chainId would let one serve the other's scores from the wrong chain.
630
+ // One memo per gate leaf (rules/cache.ts), each shared between the inline forward-gate
631
+ // verifier and the background chain verifier so neither re-reads what the other settled —
632
+ // layered over the voter's persistent store and namespaced by the hash of that leaf's
633
+ // canonical rule reference + chainId. That hash is exactly the sharing boundary: two
634
+ // contests over one gate rule (a directory of boards on the same Pass) share each other's
635
+ // reads, while different rules, or one rule on different options, can never collide. What
636
+ // is stored under it, and for how long, is the rule's business, not the engine's. The same
637
+ // hash is each leaf's public `ruleId` in `checkEligibility` — NOT unique within one gate,
638
+ // because a rule may be named in two branches, and two positions of one question SHOULD
639
+ // share a keyspace (see `dedupeLeaves`).
640
+ this.#gateRefs = gateRefs;
641
+ this.#ruleIds = gateRefs.map((ref) => sha256(encodeDagCbor({ chainId: this.#chainId, rule: ref })));
642
+ const gateCaches = this.#ruleIds.map((namespace) => makePersistentRuleCache({ store: deps.gateStore, namespace }));
643
+ // The weight rule gets its own namespace on the same terms: its canonical reference plus
644
+ // this contest's chain id. It reads the same chain as everything else here — a weight rule
645
+ // on a second chain is the same open question as a gate leaf on one.
649
646
  const weight = deps.registry[criteria.weight.type];
650
647
  if (!weight)
651
648
  throw new UnknownRuleError("weight", criteria.weight.type);
652
- const weightTicker = tickerForRef(criteria, criteria.weight, weight.optionsSchema.parse(criteria.weight));
653
- const weightChainId = criteria.requires.chains[weightTicker]?.chainId;
654
- if (weightChainId === undefined)
655
- throw new Error(`no chain client for weight chain "${weightTicker}"`);
656
649
  const weightCache = makePersistentRuleCache({
657
650
  store: deps.gateStore,
658
- namespace: sha256(encodeDagCbor({ chainId: weightChainId, rule: criteria.weight }))
651
+ namespace: sha256(encodeDagCbor({ chainId: this.#chainId, rule: criteria.weight }))
659
652
  });
660
653
  const verifier = makeBundleVerifier({
661
654
  criteria,
662
655
  criteriaCid: criteriaCidBytes,
663
656
  chainId: this.#chainId,
664
657
  registry: deps.registry,
665
- chainFor: (ticker) => this.#chainFor(ticker),
658
+ chain: this.#ruleChain,
666
659
  bucketMath: this.#bucketMath,
667
660
  nameResolvers: deps.nameResolvers,
668
- ruleCache: gateCache,
661
+ ruleCaches: gateCaches,
669
662
  nameResolutionCache: deps.nameResolutionCache,
670
663
  readHead: ({ chain }) => this.#readHead({ chain })
671
664
  });
@@ -678,10 +671,10 @@ class ContestEngine {
678
671
  this.#background = makeBackgroundVerifier({
679
672
  criteria,
680
673
  registry: deps.registry,
681
- chainFor: (ticker) => this.#chainFor(ticker),
674
+ chain: this.#ruleChain,
682
675
  bucketMath: this.#bucketMath,
683
676
  nameResolvers: deps.nameResolvers,
684
- ruleCache: gateCache,
677
+ ruleCaches: gateCaches,
685
678
  nameResolutionCache: deps.nameResolutionCache,
686
679
  readHead: ({ chain }) => this.#readHead({ chain }),
687
680
  cache: this.#cache,
@@ -694,7 +687,7 @@ class ContestEngine {
694
687
  this.#tally = makeTally({
695
688
  criteria,
696
689
  registry: deps.registry,
697
- chainFor: (ticker) => this.#chainFor(ticker),
690
+ chain: this.#ruleChain,
698
691
  bucketMath: this.#bucketMath,
699
692
  readHead: ({ chain }) => this.#readHead({ chain }),
700
693
  ruleCache: weightCache,
@@ -705,8 +698,10 @@ class ContestEngine {
705
698
  });
706
699
  }
707
700
  #store;
708
- /** Hash of the canonical gate rule + chainId — this contest's keyspace in the shared gate store. */
709
- #ruleHash;
701
+ /** Per gate leaf: hash of its canonical rule ref + chainId — its keyspace in the shared gate store. */
702
+ #ruleIds;
703
+ /** The gate's leaf refs in document order, aligned with {@link #ruleIds}. */
704
+ #gateRefs;
710
705
  #cache;
711
706
  #acceptedDedup;
712
707
  #verifier;
@@ -732,6 +727,45 @@ class ContestEngine {
732
727
  #ownPublishes = new Map();
733
728
  /** Per-own-CID eviction callbacks: the publishing `ContestVote` registers one at sign time. */
734
729
  #ownEvictionCbs = new Map();
730
+ /**
731
+ * Per-own-CID verification callbacks — the positive counterpart of {@link #ownEvictionCbs},
732
+ * registered by the publishing `ContestVote` at sign time and fired once every deferred check
733
+ * on that bundle has settled clean.
734
+ */
735
+ #ownVerifiedCbs = new Map();
736
+ /**
737
+ * Per-own-CID first-peer-checkpoint callbacks. Fires once — the state it drives has no
738
+ * degrees, and a second peer holding the vote is not a new fact about the publish.
739
+ */
740
+ #ownCheckpointCbs = new Map();
741
+ /**
742
+ * Every bundle CID this wallet published that is still in the working set. Unlike
743
+ * {@link #ownPublishes} — which exists only to report an eviction and is dropped the moment
744
+ * the checks settle — this outlives verification, because peer-checkpoint attribution keeps
745
+ * mattering afterwards: a bundle stays live for `voteExpiryBuckets` and peers go on
746
+ * re-serving it, so "who else kept my vote" is a longer-lived question than "is it valid".
747
+ * Dropped on eviction and on expiry prune.
748
+ */
749
+ #ownCids = new Set();
750
+ /**
751
+ * Roots we decoded → which of OUR CIDs that checkpoint contained. Kept so a peer that
752
+ * advertises an ALREADY-decoded root is attributed without re-chasing it (the common case:
753
+ * many peers converge on one root). Bounded by {@link DECODED_ROOTS_MAX}, oldest evicted first.
754
+ */
755
+ #decodedRoots = new Map();
756
+ /**
757
+ * Own CID → the peers seen advertising a checkpoint that contained it. Bounded twice over:
758
+ * one entry per own bundle (one per wallet per contest), and the peers can only ever come
759
+ * from {@link #peerRoots}, itself capped at {@link PEER_ROOTS_MAX}.
760
+ */
761
+ #ownCheckpointPeers = new Map();
762
+ /**
763
+ * Cap on {@link #ownCids}. A wallet holds one live bundle per contest and a re-publish
764
+ * supersedes it, so real use sits far below this; the cap exists because
765
+ * {@link ContestEngine.trackOwnBundle} lets a caller add CIDs, and no public entry point
766
+ * should be able to grow a map without bound.
767
+ */
768
+ static #OWN_CIDS_MAX = 64;
735
769
  /** Does any vote in the bundle carry a `community.name` claim (needing resolution)? */
736
770
  #carriesName(bundle) {
737
771
  return bundle.votes.some((v) => v.community.name !== undefined);
@@ -766,9 +800,13 @@ class ContestEngine {
766
800
  return; // evicted or pruned while its check was in flight
767
801
  checks[key] = true;
768
802
  // A fully settled own publish can no longer be evicted — its verdict is terminal — so
769
- // its eviction-reporting entries are done (see #ownPublishes).
770
- if (this.#isFullyVerified(cid))
771
- this.#dropOwnTracking(cid.toString());
803
+ // its eviction-reporting entries are done (see #ownPublishes). Tell the publishing
804
+ // ContestVote first: this is the positive verdict it has no other way to hear.
805
+ if (this.#isFullyVerified(cid)) {
806
+ const key = cid.toString();
807
+ this.#ownVerifiedCbs.get(key)?.(cid);
808
+ this.#dropOwnTracking(key);
809
+ }
772
810
  this.#onStateChanged();
773
811
  }
774
812
  /**
@@ -782,11 +820,13 @@ class ContestEngine {
782
820
  this.#crdt.remove(cid);
783
821
  const key = cid.toString();
784
822
  this.#checks.delete(key);
823
+ this.#ownCids.delete(key);
824
+ this.#ownCheckpointPeers.delete(key);
785
825
  const own = this.#ownPublishes.get(key);
786
826
  if (own) {
787
827
  const error = new VoteEvictedError(own, verdict);
788
828
  const notifyVote = this.#ownEvictionCbs.get(key);
789
- this.#dropOwnTracking(key);
829
+ this.#forgetOwnBundle(key);
790
830
  notifyVote?.(error);
791
831
  this.#emitError(error);
792
832
  }
@@ -796,6 +836,19 @@ class ContestEngine {
796
836
  #dropOwnTracking(key) {
797
837
  this.#ownPublishes.delete(key);
798
838
  this.#ownEvictionCbs.delete(key);
839
+ this.#ownVerifiedCbs.delete(key);
840
+ }
841
+ /**
842
+ * Forget an own bundle entirely — its verdict-reporting entries AND its peer-checkpoint
843
+ * attribution. Only for a bundle that has LEFT the working set (evicted or expired):
844
+ * settlement alone must not reach this, because attribution goes on accruing for a bundle
845
+ * that is verified and live (see {@link #ownCids}).
846
+ */
847
+ #forgetOwnBundle(key) {
848
+ this.#dropOwnTracking(key);
849
+ this.#ownCheckpointCbs.delete(key);
850
+ this.#ownCids.delete(key);
851
+ this.#ownCheckpointPeers.delete(key);
799
852
  }
800
853
  #emitError(error) {
801
854
  for (const cb of [...this.#errorListeners])
@@ -805,12 +858,6 @@ class ContestEngine {
805
858
  emitError(error) {
806
859
  this.#emitError(error);
807
860
  }
808
- #chainFor(ticker) {
809
- const client = this.#chainClients[ticker];
810
- if (!client)
811
- throw new Error(`no chain client configured for chain "${ticker}"`);
812
- return client;
813
- }
814
861
  /**
815
862
  * Read the gating-chain head and update {@link #currentBucketCache}; returns the bucket.
816
863
  *
@@ -873,21 +920,54 @@ class ContestEngine {
873
920
  return sampleBucket <= (await this.#nowBucket());
874
921
  }
875
922
  /**
876
- * Run the gate rule for one wallet, against the ballot block a vote published NOW would carry
877
- * the engine half of {@link Contest.checkEligibility}.
923
+ * Run the gate for one wallet, against the ballot block a vote published NOW would carry
924
+ * the engine half of {@link Contest.checkEligibility}.
878
925
  *
879
- * All the interesting decisions belong to the rule: this resolves the current bucket's sample
880
- * block, hands it over, and translates the rule's own answer. It never looks at what kind of
881
- * rule it is holding, so it stays correct for a head-scoring gate, a pinned one, or anything
882
- * a host registers later.
926
+ * All the interesting decisions belong to the rules: this resolves the current bucket's sample
927
+ * block, hands it over, and translates their own answers. It never looks at what kind of rules
928
+ * it is holding, so it stays correct for a head-scoring gate, a pinned one, a composite of
929
+ * both, or anything a host registers later.
883
930
  */
884
931
  async checkEligibility({ address }) {
885
932
  const sampleBlock = this.#bucketMath.sampleBlockForBucket(await this.#nowBucket());
886
- const result = await this.#verifier.checkGate({ address, sampleBlock });
887
- const failed = gateFailure(result);
888
- return failed ? { eligible: false, error: failed.error } : { eligible: true, score: scoreOrZero(result) };
933
+ const gate = await this.#verifier.checkGates({ address, sampleBlock });
934
+ const checks = new Map();
935
+ const node = this.#toEligibilityNode(gate, checks);
936
+ // Document order, not evaluation order: the leaves as the criteria lists them.
937
+ const ordered = [...checks.entries()].sort(([a], [b]) => a - b).map(([, check]) => check);
938
+ if (gate.satisfied === true)
939
+ return { eligible: true, score: gateScore(gate), checks: ordered, gate: node };
940
+ return {
941
+ eligible: false,
942
+ error: gateReason(gate),
943
+ // The blame set, not every failed leaf: a failure inside a satisfied `any` explains
944
+ // nothing the voter can act on (rules/gate.ts `gateBlame`).
945
+ failures: gateBlame(gate).map((leaf) => checks.get(leaf.leaf)),
946
+ checks: ordered,
947
+ gate: node
948
+ };
889
949
  }
890
- /** Hash of the current bucket boundary block on the gating (`rule`) chain (rolling tie seed). */
950
+ /** Project one evaluated gate node onto the public shape, collecting its leaves by index. */
951
+ #toEligibilityNode(result, checks) {
952
+ if (result.kind !== "leaf") {
953
+ return {
954
+ kind: result.kind,
955
+ satisfied: result.satisfied,
956
+ children: result.children.map((child) => this.#toEligibilityNode(child, checks))
957
+ };
958
+ }
959
+ const check = {
960
+ leaf: result.leaf,
961
+ ruleId: this.#ruleIds[result.leaf],
962
+ type: this.#gateRefs[result.leaf].type,
963
+ satisfied: result.satisfied,
964
+ score: result.score,
965
+ ...(result.error === undefined ? {} : { error: result.error })
966
+ };
967
+ checks.set(result.leaf, check);
968
+ return { kind: "leaf", ...check };
969
+ }
970
+ /** Hash of the current bucket boundary block on the contest's chain (rolling tie seed). */
891
971
  async #bucketBlockHash() {
892
972
  const head = await this.#ruleChain.getBlockNumber();
893
973
  const boundary = this.#bucketMath.sampleBlockForBucket(this.#bucketMath.bucketForBlock(Number(head)));
@@ -928,7 +1008,7 @@ class ContestEngine {
928
1008
  for (const removed of await this.#crdt.prune(this.#currentBucketCache)) {
929
1009
  const key = removed.toString();
930
1010
  this.#checks.delete(key);
931
- this.#dropOwnTracking(key); // expiry is decay, not an eviction — no error
1011
+ this.#forgetOwnBundle(key); // expiry is decay, not an eviction — no error
932
1012
  }
933
1013
  }
934
1014
  return this.#tally.compute();
@@ -1063,6 +1143,9 @@ class ContestEngine {
1063
1143
  this.#markStateChanged();
1064
1144
  },
1065
1145
  deferVerify: (entries) => this.#background.enqueue(entries),
1146
+ // Every CID the checkpoint referenced, admitted or skipped — the skipped ones are
1147
+ // what tier-3 attribution is made of (see #noteCheckpointContents).
1148
+ onCheckpointContents: (root, cids) => this.#noteCheckpointContents(root, cids),
1066
1149
  onMerged: () => this.#onStateChanged(),
1067
1150
  limit: (fn) => chaseLimit(fn),
1068
1151
  timeoutMs: CHASE_TIMEOUT_MS
@@ -1327,15 +1410,13 @@ class ContestEngine {
1327
1410
  /**
1328
1411
  * Sign the votes into a bundle for the current bucket boundary block (the block every verifier
1329
1412
  * reads at), add it to the CRDT, and return the bundle plus its encoded block bytes for
1330
- * broadcast. Throws `ReadOnlyError` with no signer. `namesSettled` carries the
1331
- * {@link preflightNames} outcome (default false: name checks still owed to the background
1332
- * verifier); `onEvicted` is told if a deferred check later evicts THIS bundle — registered
1333
- * here, before the background verifier can possibly settle, so the report cannot be missed.
1413
+ * broadcast with the wallet the caller hands it (identity is the ballot's, see
1414
+ * {@link VoteClient.createContestVote}). `namesSettled` carries the {@link preflightNames}
1415
+ * outcome (default false: name checks still owed to the background verifier); `onEvicted` is
1416
+ * told if a deferred check later evicts THIS bundle registered here, before the background
1417
+ * verifier can possibly settle, so the report cannot be missed.
1334
1418
  */
1335
- async signVote(votes, opts = {}) {
1336
- const signer = this.#deps.signer;
1337
- if (signer === undefined)
1338
- throw new ReadOnlyError();
1419
+ async signVote(votes, signer, opts = {}) {
1339
1420
  const head = await this.#ruleChain.getBlockNumber();
1340
1421
  const bucket = this.#bucketMath.bucketForBlock(Number(head));
1341
1422
  this.#currentBucketCache = bucket;
@@ -1352,11 +1433,16 @@ class ContestEngine {
1352
1433
  // preflight already resolved is recorded settled, so it renders verified immediately.
1353
1434
  this.#recordChecks(cid, bundle, false, opts.namesSettled ?? false);
1354
1435
  this.#ownPublishes.set(cid.toString(), bundle);
1436
+ this.#ownCids.add(cid.toString());
1355
1437
  if (opts.onEvicted)
1356
1438
  this.#ownEvictionCbs.set(cid.toString(), opts.onEvicted);
1439
+ if (opts.onVerified)
1440
+ this.#ownVerifiedCbs.set(cid.toString(), opts.onVerified);
1441
+ if (opts.onCheckpointedByPeer)
1442
+ this.#ownCheckpointCbs.set(cid.toString(), opts.onCheckpointedByPeer);
1357
1443
  this.#background.enqueue([{ cid, bundle }]);
1358
1444
  this.#onStateChanged();
1359
- return { bundle, encoded: encodeBundle(bundle) };
1445
+ return { bundle, encoded: encodeBundle(bundle), cid };
1360
1446
  }
1361
1447
  /**
1362
1448
  * Broadcast an encoded bundle inline as a live delta (this wallet's own delta, never the set).
@@ -1587,6 +1673,12 @@ class ContestEngine {
1587
1673
  const own = await this.rootRecord();
1588
1674
  if (own.root.equals(record.root)) {
1589
1675
  this.#heardMatchingRoot = true;
1676
+ // Their checkpoint is ours byte for byte, so it demonstrably contains every bundle
1677
+ // ours does — including our own. Nothing is chased here (there is no divergence to
1678
+ // chase), so without this the ONE case tier-3 attribution most wants to catch — a
1679
+ // healthy topic where we and the seeder have converged — would be the one case it
1680
+ // never saw.
1681
+ this.#indexRootContents(record.root, this.#ownVerifiedCids());
1590
1682
  return;
1591
1683
  }
1592
1684
  this.#chaser?.chase(record.root, undefined, this.#sessionProvidersFor(record.root));
@@ -1605,7 +1697,112 @@ class ContestEngine {
1605
1697
  if (oldest !== undefined)
1606
1698
  this.#peerRoots.delete(oldest);
1607
1699
  }
1608
- this.#peerRoots.set(peerId, root.toString());
1700
+ const key = root.toString();
1701
+ this.#peerRoots.set(peerId, key);
1702
+ // Many peers converge on one root, and most of them advertise it AFTER we chased it —
1703
+ // so attribution cannot wait for a decode that already happened. #decodedRoots is what
1704
+ // makes that retroactive.
1705
+ this.#attributeRoot(peerId, key);
1706
+ }
1707
+ /**
1708
+ * Record which of OUR bundles a decoded checkpoint contained, and credit every peer already
1709
+ * known to advertise that root. Called once per successful chase decode, with every bundle
1710
+ * CID the checkpoint referenced — INCLUDING ones we already hold, which is the whole point:
1711
+ * our own bundle is always already held, so the chase skips admitting it and would otherwise
1712
+ * never mention it.
1713
+ */
1714
+ #noteCheckpointContents(root, cids) {
1715
+ const mine = new Set();
1716
+ for (const cid of cids) {
1717
+ const key = cid.toString();
1718
+ if (this.#ownCids.has(key))
1719
+ mine.add(key);
1720
+ }
1721
+ this.#indexRootContents(root, mine);
1722
+ }
1723
+ /**
1724
+ * Our own bundles that are in our OWN checkpoint right now — i.e. fully verified ones. A
1725
+ * pending bundle is deliberately excluded: the encoder does not serve it, so a peer holding
1726
+ * our root is not thereby holding it.
1727
+ */
1728
+ #ownVerifiedCids() {
1729
+ const mine = new Set();
1730
+ for (const key of this.#ownCids) {
1731
+ if (this.#isFullyVerified(CID.parse(key)))
1732
+ mine.add(key);
1733
+ }
1734
+ return mine;
1735
+ }
1736
+ /** Index `mine` under `root` (bounded, LRU) and credit every peer already at that root. */
1737
+ #indexRootContents(root, mine) {
1738
+ if (mine.size === 0)
1739
+ return; // nothing of ours in it — not worth an index entry
1740
+ const rootKey = root.toString();
1741
+ if (this.#decodedRoots.has(rootKey))
1742
+ this.#decodedRoots.delete(rootKey); // refresh LRU slot
1743
+ else if (this.#decodedRoots.size >= DECODED_ROOTS_MAX) {
1744
+ const oldest = this.#decodedRoots.keys().next().value;
1745
+ if (oldest !== undefined)
1746
+ this.#decodedRoots.delete(oldest);
1747
+ }
1748
+ this.#decodedRoots.set(rootKey, mine);
1749
+ let credited = false;
1750
+ for (const [peerId, peerRoot] of this.#peerRoots) {
1751
+ if (peerRoot === rootKey)
1752
+ credited = this.#creditPeer(peerId, mine) || credited;
1753
+ }
1754
+ if (credited)
1755
+ this.#onStateChanged();
1756
+ }
1757
+ /** Credit `peerId` with every own CID in a root it advertises, if we decoded that root. */
1758
+ #attributeRoot(peerId, rootKey) {
1759
+ const mine = this.#decodedRoots.get(rootKey);
1760
+ if (mine !== undefined && this.#creditPeer(peerId, mine))
1761
+ this.#onStateChanged();
1762
+ }
1763
+ /** Add `peerId` to each own CID's attribution set; true if anything was new. */
1764
+ #creditPeer(peerId, ownCids) {
1765
+ let added = false;
1766
+ for (const key of ownCids) {
1767
+ if (!this.#ownCids.has(key))
1768
+ continue; // evicted or expired since the decode
1769
+ let peers = this.#ownCheckpointPeers.get(key);
1770
+ if (peers === undefined) {
1771
+ peers = new Set();
1772
+ this.#ownCheckpointPeers.set(key, peers);
1773
+ }
1774
+ if (!peers.has(peerId)) {
1775
+ const first = peers.size === 0;
1776
+ peers.add(peerId);
1777
+ added = true;
1778
+ if (first) {
1779
+ this.#ownCheckpointCbs.get(key)?.(CID.parse(key));
1780
+ this.#ownCheckpointCbs.delete(key);
1781
+ }
1782
+ }
1783
+ }
1784
+ return added;
1785
+ }
1786
+ /** {@link Contest.trackOwnBundle}. */
1787
+ trackOwnBundle(cid) {
1788
+ const key = cid.toString();
1789
+ if (this.#ownCids.has(key))
1790
+ return;
1791
+ if (this.#ownCids.size >= ContestEngine.#OWN_CIDS_MAX) {
1792
+ const oldest = this.#ownCids.values().next().value;
1793
+ if (oldest !== undefined)
1794
+ this.#forgetOwnBundle(oldest);
1795
+ }
1796
+ this.#ownCids.add(key);
1797
+ }
1798
+ /** {@link Contest.checksFor}. */
1799
+ checksFor(cid) {
1800
+ const checks = this.#checks.get(cid.toString());
1801
+ return checks === undefined ? undefined : { ...checks };
1802
+ }
1803
+ /** {@link Contest.checkpointPeersFor}. */
1804
+ checkpointPeersFor(cid) {
1805
+ return [...(this.#ownCheckpointPeers.get(cid.toString()) ?? [])];
1609
1806
  }
1610
1807
  /**
1611
1808
  * The session seeds for chasing `root`: every still-connected peer whose last advertised
@@ -1691,6 +1888,15 @@ class ContestView {
1691
1888
  get tally() {
1692
1889
  return this.#engine.cachedTally;
1693
1890
  }
1891
+ checksFor(cid) {
1892
+ return this.#engine.checksFor(cid);
1893
+ }
1894
+ checkpointPeersFor(cid) {
1895
+ return this.#engine.checkpointPeersFor(cid);
1896
+ }
1897
+ trackOwnBundle(cid) {
1898
+ this.#engine.trackOwnBundle(cid);
1899
+ }
1694
1900
  async update() {
1695
1901
  if (this.#subscribed)
1696
1902
  return;
@@ -1738,22 +1944,62 @@ class ContestView {
1738
1944
  class ContestVotePublication {
1739
1945
  contestId;
1740
1946
  votes;
1947
+ signer;
1741
1948
  #engine;
1742
1949
  #stateCbs = [];
1743
1950
  #errorCbs = [];
1744
1951
  #state = "stopped";
1745
1952
  #bundle;
1953
+ #cid;
1954
+ /**
1955
+ * Which `publish()` call owns the state machine. Incremented synchronously at the top of
1956
+ * `publish()`, BEFORE any await, and captured by every callback that attempt registers.
1957
+ *
1958
+ * A re-publish in a later window signs new bytes, so the previous attempt's bundle keeps its
1959
+ * own CID, stays live in the CRDT (a superseded bundle outlives its superseder's deferred
1960
+ * checks) and can still be verified, served by a peer, or evicted — long after a newer
1961
+ * attempt owns this object. Matching on the CID cannot separate the two: `#cid` still holds
1962
+ * the PREVIOUS attempt's value until the new `signVote()` resolves, so an old verdict landing
1963
+ * in that window passes a CID check and moves the wrong attempt. The token is established
1964
+ * before that window opens, so it does not. It also decouples the check from `#cid` entirely,
1965
+ * which is what lets a verdict that settles BEFORE `#cid` is assigned still be delivered
1966
+ * (the engine delivers each callback once and then drops it — a settlement ignored here is
1967
+ * lost for good).
1968
+ */
1969
+ #attempt = 0;
1746
1970
  /**
1747
1971
  * True once the background verifier evicted the current publish's bundle. The eviction can
1748
1972
  * land WHILE `publish()` is still broadcasting (the deferred checks run concurrently), and
1749
1973
  * its `"failed"` is terminal for this attempt — the in-flight publish must not stomp it
1750
- * with `"publishing"`/`"succeeded"`. Reset by the next `publish()` call.
1974
+ * with `"publishing"`/`"published"`. Reset by the next `publish()` call.
1751
1975
  */
1752
1976
  #evicted = false;
1753
- constructor(engine, votes) {
1977
+ /** Is this callback's attempt still the one that owns the object? (see {@link #attempt}) */
1978
+ #isCurrent(attempt) {
1979
+ return attempt === this.#attempt;
1980
+ }
1981
+ /**
1982
+ * Advance to a settled state, unless this attempt is already finished or further along.
1983
+ * Two orderings have to be tolerated, not just one: the deferred checks race the broadcast,
1984
+ * so a verdict can land before `publish()` resolves, and a peer can serve our bundle back to
1985
+ * us before our OWN gate read returns — peer evidence is strictly stronger (a peer
1986
+ * checkpoints only fully verified bundles), so it must never be overwritten by the weaker
1987
+ * local one arriving late.
1988
+ */
1989
+ #settle(attempt, state) {
1990
+ if (!this.#isCurrent(attempt))
1991
+ return;
1992
+ if (this.#evicted || this.#state === "failed" || this.#state === "verified-by-peer")
1993
+ return;
1994
+ if (state === "verified-locally" && this.#state === "verified-locally")
1995
+ return;
1996
+ this.#setState(state);
1997
+ }
1998
+ constructor(engine, votes, signer) {
1754
1999
  this.#engine = engine;
1755
2000
  this.contestId = engine.criteria.contestId;
1756
2001
  this.votes = votes;
2002
+ this.signer = signer;
1757
2003
  }
1758
2004
  get topic() {
1759
2005
  return this.#engine.topic;
@@ -1764,6 +2010,9 @@ class ContestVotePublication {
1764
2010
  get bundle() {
1765
2011
  return this.#bundle;
1766
2012
  }
2013
+ get checks() {
2014
+ return this.#cid === undefined ? undefined : this.#engine.checksFor(this.#cid);
2015
+ }
1767
2016
  #setState(state) {
1768
2017
  this.#state = state;
1769
2018
  for (const cb of [...this.#stateCbs])
@@ -1775,12 +2024,11 @@ class ContestVotePublication {
1775
2024
  cb(error);
1776
2025
  }
1777
2026
  async publish() {
1778
- // Fail before joining a read-only voter needlessly to the topic.
1779
- if (this.#engine.readOnly) {
1780
- const error = new ReadOnlyError();
1781
- this.#fail(error);
1782
- throw error;
1783
- }
2027
+ // Before the first await: every callback below is bound to THIS attempt, and the
2028
+ // previous attempt's callbacks stop being able to move this object the moment the
2029
+ // counter ticks even though its bundle is still live and still verifiable.
2030
+ const attempt = ++this.#attempt;
2031
+ this.#evicted = false;
1784
2032
  try {
1785
2033
  // Name preflight, also before joining: a vote whose carried community name
1786
2034
  // definitively fails to resolve to its claimed key would be silently dropped by
@@ -1789,30 +2037,48 @@ class ContestVotePublication {
1789
2037
  // the background verifier owns the deferred check).
1790
2038
  const namesSettled = await this.#engine.preflightNames(this.votes);
1791
2039
  await this.#engine.join();
1792
- this.#evicted = false;
1793
2040
  this.#setState("signing");
1794
- const { bundle, encoded } = await this.#engine.signVote([...this.votes], {
2041
+ const { bundle, encoded, cid } = await this.#engine.signVote([...this.votes], this.signer, {
1795
2042
  namesSettled,
2043
+ // The positive counterparts of `onEvicted`: our own deferred checks settling
2044
+ // clean, then a peer serving the bundle back to us in its checkpoint. Both
2045
+ // normally land after publish() has resolved.
2046
+ onVerified: () => this.#settle(attempt, "verified-locally"),
2047
+ onCheckpointedByPeer: () => this.#settle(attempt, "verified-by-peer"),
1796
2048
  // The one rejection a publisher can hear about (peers drop silently): our own
1797
2049
  // node's deferred checks evicting this bundle. Usually post hoc — publish() has
1798
2050
  // already resolved — so it surfaces as `error` + `publishingState: "failed"`.
1799
2051
  onEvicted: (error) => {
2052
+ // Attempt-scoped like the positive verdicts: a superseded bundle is kept
2053
+ // alive while its superseder's checks are pending, so an eviction of the
2054
+ // PREVIOUS attempt's bytes must not fail the attempt that replaced it.
2055
+ if (!this.#isCurrent(attempt))
2056
+ return;
1800
2057
  this.#evicted = true;
1801
2058
  this.#fail(error);
1802
2059
  }
1803
2060
  });
1804
2061
  this.#bundle = bundle;
1805
- if (!this.#evicted)
2062
+ this.#cid = cid;
2063
+ // Only from "signing": a deferred check can settle before this line runs (a cached
2064
+ // verdict needs no round trip), and "publishing" would walk that verdict backwards.
2065
+ if (this.#state === "signing")
1806
2066
  this.#setState("publishing");
1807
2067
  const { recipientCount } = await this.#engine.broadcastBundle(encoded);
1808
2068
  // An eviction that landed mid-broadcast already failed this attempt; the outcome
1809
- // still resolves (the bundle DID hit the wire) but the state stays "failed".
1810
- if (!this.#evicted)
1811
- this.#setState("succeeded");
1812
- return { bundle, recipientCount };
2069
+ // still resolves (the bundle DID hit the wire) but the state stays "failed". A
2070
+ // check that SETTLED mid-broadcast is not stomped either: "published" describes
2071
+ // the broadcast, and a verdict already in hand is further along than that.
2072
+ if (!this.#evicted && this.#state === "publishing")
2073
+ this.#setState("published");
2074
+ return { bundle, recipientCount, cid };
1813
2075
  }
1814
2076
  catch (error) {
1815
- this.#fail(error);
2077
+ // Same scoping: two overlapping `publish()` calls are a misuse, but the loser's
2078
+ // rejection belongs to ITS caller (it is rethrown), not to the state machine a
2079
+ // later attempt now owns.
2080
+ if (this.#isCurrent(attempt))
2081
+ this.#fail(error);
1816
2082
  throw error;
1817
2083
  }
1818
2084
  }
@@ -1878,7 +2144,6 @@ export class PubsubVoter {
1878
2144
  // the background verifier) merge into shared multicall3 round trips under one
1879
2145
  // per-client in-flight budget — see src/chain/coalescer.ts.
1880
2146
  chains: coalescingChainFactory(options.chains),
1881
- signer: options.signer,
1882
2147
  registry: resolveRegistry(options.rules),
1883
2148
  nameResolvers: options.nameResolvers ?? [],
1884
2149
  onTopicJoined: this.#onTopicJoined,
@@ -1958,9 +2223,6 @@ export class PubsubVoter {
1958
2223
  // Contests can share CIDs (e.g. two vote-less contests share the empty-checkpoint root).
1959
2224
  return [...new Set(keys)];
1960
2225
  };
1961
- get readOnly() {
1962
- return this.#deps.signer === undefined;
1963
- }
1964
2226
  /** Guard the create paths after {@link destroy}: a destroyed voter is terminal. */
1965
2227
  #assertLive() {
1966
2228
  if (this.#destroyed)
@@ -1979,7 +2241,7 @@ export class PubsubVoter {
1979
2241
  async createContestVote(args) {
1980
2242
  this.#assertLive();
1981
2243
  const engine = await this.#engineFor(this.#validateCriteria(args.criteria));
1982
- return new ContestVotePublication(engine, args.votes);
2244
+ return new ContestVotePublication(engine, args.votes, args.signer);
1983
2245
  }
1984
2246
  /**
1985
2247
  * Strictly validate one criteria document at the create seam: `CriteriaSchema` (shape,