@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.
- package/README.md +89 -26
- package/dist/chain/types.d.ts +10 -9
- package/dist/client/voter.d.ts +175 -34
- package/dist/client/voter.js +368 -106
- package/dist/errors.d.ts +6 -12
- package/dist/errors.js +8 -19
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/rules/erc20-balance.d.ts +0 -1
- package/dist/rules/erc20-balance.js +0 -2
- package/dist/rules/erc5192-min-balance.d.ts +0 -1
- package/dist/rules/erc5192-min-balance.js +0 -2
- package/dist/rules/erc721-min-balance.d.ts +0 -1
- package/dist/rules/erc721-min-balance.js +0 -2
- package/dist/rules/gate.d.ts +164 -0
- package/dist/rules/gate.js +209 -0
- package/dist/rules/registry.d.ts +11 -5
- package/dist/rules/registry.js +18 -9
- package/dist/schema/common.d.ts +5 -5
- package/dist/schema/common.js +5 -5
- package/dist/schema/criteria.d.ts +58 -29
- package/dist/schema/criteria.js +123 -23
- package/dist/signer/eip712.d.ts +2 -2
- package/dist/signer/eip712.js +1 -1
- package/dist/tally/tally.d.ts +2 -1
- package/dist/tally/tally.js +10 -14
- package/dist/tally/types.d.ts +1 -1
- package/dist/transport/chase.d.ts +8 -0
- package/dist/transport/chase.js +5 -1
- package/dist/transport/integration/harness.js +3 -3
- package/dist/verify/background.d.ts +8 -6
- package/dist/verify/background.js +57 -42
- package/dist/verify/bundle.d.ts +12 -10
- package/dist/verify/bundle.js +69 -38
- package/dist/verify/types.d.ts +37 -16
- package/package.json +1 -1
- package/dist/chain/ticker.d.ts +0 -15
- package/dist/chain/ticker.js +0 -25
package/dist/client/voter.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
543
|
+
/** The contest's chain id (`criteria.bucketChainId`), bound into every ballot signature. */
|
|
540
544
|
#chainId;
|
|
541
|
-
/** The
|
|
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
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const
|
|
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
|
-
//
|
|
636
|
-
// and the background chain verifier so neither re-reads what the other settled —
|
|
637
|
-
// over the voter's persistent store and namespaced by the hash of
|
|
638
|
-
// reference + chainId. That hash is exactly the sharing boundary: two
|
|
639
|
-
// gate (a directory of boards on the same Pass) share each other's
|
|
640
|
-
// different
|
|
641
|
-
// under it, and for how long, is the rule's business, not the engine's.
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
//
|
|
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:
|
|
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
|
-
|
|
658
|
+
chain: this.#ruleChain,
|
|
666
659
|
bucketMath: this.#bucketMath,
|
|
667
660
|
nameResolvers: deps.nameResolvers,
|
|
668
|
-
|
|
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
|
-
|
|
674
|
+
chain: this.#ruleChain,
|
|
682
675
|
bucketMath: this.#bucketMath,
|
|
683
676
|
nameResolvers: deps.nameResolvers,
|
|
684
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
709
|
-
#
|
|
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
|
-
|
|
771
|
-
|
|
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.#
|
|
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
|
|
877
|
-
*
|
|
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
|
|
880
|
-
* block, hands it over, and translates
|
|
881
|
-
*
|
|
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
|
|
887
|
-
const
|
|
888
|
-
|
|
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
|
-
/**
|
|
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.#
|
|
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
|
|
1331
|
-
* {@link
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
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
|
-
|
|
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"`/`"
|
|
1974
|
+
* with `"publishing"`/`"published"`. Reset by the next `publish()` call.
|
|
1751
1975
|
*/
|
|
1752
1976
|
#evicted = false;
|
|
1753
|
-
|
|
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
|
-
//
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
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
|
-
|
|
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,
|