From 24fe8b6b6f765393419b395e19dd988b76fa642d Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Tue, 14 Jul 2026 19:04:21 -0700 Subject: [PATCH] Fix perpetual dispute-window reset: compare against pending, not just cache hasDataChanged() only compared fresh data against CachedStakingDetails - which stays empty until a submission matures and gets finalized. For any account whose real stake was genuinely stable, every 5-minute scan still saw "changed from empty cache", resubmitted, and reset submitted_at to the current block. Confirmed live: Serok's and QaziM's real, correctly-computed stake (490,000 / 100,000 HEZ) never once reached CachedStakingDetails/ TrustScores despite multiple successful submissions - each new scan arrived before the previous one's 300-block dispute window elapsed and overwrote it with a fresh timer, a self-perpetuating loop. getPendingData() now reads PendingStakingDetails first; hasDataChanged() compares against that when present, only falling back to the cache once nothing is pending. Applied to both the AssetHub and legacy RelayChain-clear comparisons. Also reordered main()'s cycle to finalizeMaturedPending() before fullScan(), so anything that matured this cycle gets promoted before a new scan pass runs at all. --- noter/bot.js | 64 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/noter/bot.js b/noter/bot.js index e0d1806..14e4254 100644 --- a/noter/bot.js +++ b/noter/bot.js @@ -219,13 +219,42 @@ async function getCachedData(peopleApi, address, source) { } /** - * Check if staking data has changed compared to cached values. + * Get an already-pending (not yet finalized) submission for comparison. Without this, every + * scan compares fresh data only against CachedStakingDetails - which stays empty until a + * submission actually matures and gets finalized - so a stable account with genuinely unchanged + * stake looked "changed" on every single scan, and each resubmission reset submitted_at to the + * current block. That kept the dispute window perpetually restarting: real accounts (Serok, + * QaziM) never matured because a fresh scan always arrived and re-submitted before finalize ever + * got a chance. */ -function hasDataChanged(fresh, cached) { - if (!cached) return true; // No cache → need to submit - return fresh.stakedAmount !== cached.stakedAmount || - fresh.nominationsCount !== cached.nominationsCount || - fresh.unlockingChunksCount !== cached.unlockingChunksCount; +async function getPendingData(peopleApi, address, source) { + try { + const result = await peopleApi.query.stakingScore.pendingStakingDetails(address, source); + if (result.isNone || result.isEmpty) { + return null; + } + const json = result.unwrap().toJSON().details; + return { + stakedAmount: BigInt(json.stakedAmount ?? json.staked_amount ?? '0'), + nominationsCount: json.nominationsCount ?? json.nominations_count ?? 0, + unlockingChunksCount: json.unlockingChunksCount ?? json.unlocking_chunks_count ?? 0, + }; + } catch { + return null; + } +} + +/** + * Check if staking data has changed compared to the most recent known value for this account - + * whichever is more recent, an unmatured pending submission or (once nothing is pending) the + * finalized cache. Resubmitting identical data to what's already pending would only reset its + * dispute-window clock for no reason. + */ +function hasDataChanged(fresh, baseline) { + if (!baseline) return true; // Nothing recorded yet at all → need to submit + return fresh.stakedAmount !== baseline.stakedAmount || + fresh.nominationsCount !== baseline.nominationsCount || + fresh.unlockingChunksCount !== baseline.unlockingChunksCount; } // ======================================== @@ -352,8 +381,11 @@ async function processAccount(relayApi, assetHubApi, peopleApi, noterKeypair, ad unlockingChunksCount: ahStakingData.unlockingChunksCount + poolData.unlockingChunksCount, }; - // 3. Only compare the COMBINED total against cache — never submit partial data - const ahStakingCached = await getCachedData(peopleApi, address, 'AssetHub'); + // 3. Compare the COMBINED total against whichever is more recent - an already-pending + // (not yet matured) submission, or failing that, the finalized cache. Never submit partial + // data, and never resubmit unchanged data just because the cache itself is still empty. + const ahStakingPending = await getPendingData(peopleApi, address, 'AssetHub'); + const ahStakingCached = ahStakingPending ?? await getCachedData(peopleApi, address, 'AssetHub'); if (hasDataChanged(combinedData, ahStakingCached)) { // Skip update if pool query failed and we'd be downgrading a known stake to 0 @@ -375,8 +407,10 @@ async function processAccount(relayApi, assetHubApi, peopleApi, noterKeypair, ad } } - // 4. Clear old RelayChain cache if it exists (staking moved to AH) - const relayCached = await getCachedData(peopleApi, address, 'RelayChain'); + // 4. Clear old RelayChain cache if it exists (staking moved to AH) - same pending-first check + // as step 3, so an already-submitted (unmatured) clear isn't resubmitted every cycle either. + const relayPending = await getPendingData(peopleApi, address, 'RelayChain'); + const relayCached = relayPending ?? await getCachedData(peopleApi, address, 'RelayChain'); if (relayCached !== null && relayCached.stakedAmount > 0n) { updates.push({ address, @@ -524,9 +558,11 @@ async function main() { log('WARN', 'Could not verify noter tiki', { error: err.message }); } - // Run initial full scan - await fullScan(relayApi, assetHubApi, peopleApi, noterKeypair); + // Finalize any already-matured entries BEFORE scanning - so a fresh scan's comparison (now + // pending-aware, see getPendingData) never has a chance to touch something that should have + // already been promoted to CachedStakingDetails this cycle. await finalizeMaturedPending(peopleApi, noterKeypair); + await fullScan(relayApi, assetHubApi, peopleApi, noterKeypair); // Start event listener for real-time processing await startEventListener(relayApi, assetHubApi, peopleApi, noterKeypair); @@ -534,8 +570,8 @@ async function main() { // Schedule periodic full scans log('INFO', `Periodic scan scheduled every ${SCAN_INTERVAL / 1000}s`); setInterval(() => { - fullScan(relayApi, assetHubApi, peopleApi, noterKeypair) - .then(() => finalizeMaturedPending(peopleApi, noterKeypair)) + finalizeMaturedPending(peopleApi, noterKeypair) + .then(() => fullScan(relayApi, assetHubApi, peopleApi, noterKeypair)) .catch(err => { log('ERROR', 'Periodic scan failed', { error: err.message }); });