Fix noter bot: serialize signAndSend to stop concurrent-nonce tx rejection

fullScan() processes accounts in concurrent batches of 10, and each account's
own data-gathering takes variable time, so multiple accounts' submitStakingDetails
calls landed close enough together to race on nonce assignment - the pool then
rejected most of them as duplicate-priority ("Priority is too low ... already in
the pool"). Observed live: 37 of 50 tracked accounts silently dropped in a single
scan cycle, including real accounts with genuine stake (Serok, QaziM) whose trust
scores stayed stuck at 0 as a result even after the separate NotRegisteredNoter
bond issue was fixed.

Routes every submission (both fullScan's batches and the live event listener)
through a single queue so exactly one signAndSend is ever in flight at a time,
in call order - data-gathering stays concurrent (unaffected), only the actual
chain submission is serialized.
This commit is contained in:
2026-07-14 17:51:08 -07:00
parent 1f6a81de25
commit 92022d79c1
+21 -2
View File
@@ -232,13 +232,32 @@ function hasDataChanged(fresh, cached) {
// TRANSACTION SUBMISSION
// ========================================
/**
* Serializes every real submission through this queue. fullScan() fires up to BATCH_SIZE
* processAccount() calls concurrently (by design, for the read-only data-gathering they start
* with), and the live event listener can also submit at any time - without this, multiple
* signAndSend() calls from the same noter keypair can land close enough together that the API's
* nonce bookkeeping races, producing duplicate-nonce transactions the pool then rejects as
* "Priority is too low ... already in the pool" (observed: 37 of 50 accounts silently dropped in
* a single scan). One in-flight submission at a time, in call order, removes the race entirely
* regardless of which caller is submitting.
*/
let submitQueue = Promise.resolve();
/**
* Submit receive_staking_details for one or more (address, source) pairs.
* Batches multiple calls into a single utility.batchAll transaction.
*/
async function submitStakingDetails(peopleApi, noterKeypair, updates) {
if (updates.length === 0) return;
function submitStakingDetails(peopleApi, noterKeypair, updates) {
if (updates.length === 0) return Promise.resolve();
const task = submitQueue.then(() => doSubmitStakingDetails(peopleApi, noterKeypair, updates));
// Never let one caller's failure poison the queue for the next caller's submission.
submitQueue = task.catch(() => {});
return task;
}
function doSubmitStakingDetails(peopleApi, noterKeypair, updates) {
const calls = updates.map(({ address, source, data }) =>
peopleApi.tx.stakingScore.receiveStakingDetails(
address,