@@ -1411,4 +1411,93 @@ TEST(DashDashboardWiring, LocalStatsIdleWarningWhenNoWorkers)
14111411 EXPECT_TRUE (idle);
14121412}
14131413
1414+ // ═════════════════════════════════════════════════════════════════════════════
1415+ // coin-P2P tip event closes the stale-payee window EXPLICITLY (robust under a
1416+ // future io-decouple-extended config where refresh_executor_ is SET).
1417+ // ═════════════════════════════════════════════════════════════════════════════
1418+
1419+ namespace {
1420+ // A DEFERRED refresh executor (models the rpc_pool background thread): jobs are
1421+ // queued, not run inline, so cached_work() takes the serve-stale-while-refreshing
1422+ // path — exactly the config where a bump-only tip event would REOPEN the window.
1423+ struct DeferredExecutor {
1424+ std::vector<std::function<void ()>> jobs;
1425+ void operator ()(std::function<void ()> j) { jobs.push_back (std::move (j)); }
1426+ void run () { auto q = std::move (jobs); jobs.clear (); for (auto & j : q) j (); }
1427+ };
1428+ } // namespace
1429+
1430+ // THE FIX: invalidate_template_cache() on the coin-P2P tip event guarantees the
1431+ // next served job is re-sourced (fresh payee), never the stale cached one — even
1432+ // with a background refresh_executor_ set.
1433+ TEST (DashStratumCoinP2pTipInvalidate, InvalidateClosesStalePayeeWindowUnderRefreshExecutor)
1434+ {
1435+ auto current = std::make_shared<dash::coin::DashWorkData>(rich_work ()); // tip A
1436+ auto fallback = [current]() -> dash::coin::DashWorkData { return *current; };
1437+ auto submit = [](const std::vector<unsigned char >&, uint32_t ) { return true ; };
1438+ dash::coin::NodeCoinState cs; // unpopulated -> fallback arm
1439+
1440+ dash::stratum::DASHWorkSource ws (cs, fallback, submit,
1441+ core::stratum::StratumConfig{}, /* is_testnet=*/ false );
1442+ auto exec = std::make_shared<DeferredExecutor>();
1443+ ws.set_refresh_executor ([exec](std::function<void ()> j) { (*exec)(std::move (j)); });
1444+
1445+ // Prime the cache with tip A (dispatch the bg refresh, then run it).
1446+ ws.get_current_work_template (); // dispatches; returns set-gap
1447+ exec->run (); // bg refresh sources tip A -> cache = A
1448+ ASSERT_EQ (ws.get_current_work_template ().value (" previousblockhash" , " " ),
1449+ std::string (kPrevHashHex ));
1450+
1451+ // The coin-P2P feed advances to tip B (a DIFFERENT masternode payee).
1452+ *current = rotated_work ();
1453+
1454+ // The coin-P2P on_tip_changed handler fires: invalidate + bump (+ notify).
1455+ ws.invalidate_template_cache (" coin-P2P tip changed: fresh-payee re-source" );
1456+ ws.bump_work_generation ();
1457+
1458+ // Under refresh_executor_ the served job must NEVER be the stale tip-A
1459+ // template: the invalidate dropped the cache -> honest set-gap until the bg
1460+ // refresh lands tip B.
1461+ auto served = ws.get_current_work_template ();
1462+ if (!served.empty ())
1463+ EXPECT_NE (served.value (" previousblockhash" , " " ), std::string (kPrevHashHex ))
1464+ << " stale tip-A template must not be served after a coin-P2P tip event" ;
1465+ exec->run (); // the deferred bg refresh completes
1466+ auto fresh = ws.get_current_work_template ();
1467+ ASSERT_FALSE (fresh.empty ());
1468+ EXPECT_EQ (fresh.value (" previousblockhash" , " " ), std::string (kRotatedPrevHashHex ))
1469+ << " the served job must carry the FRESH-tip (B) template/payee" ;
1470+ }
1471+
1472+ // CONTRAST (the window this fix closes): bump_work_generation() ALONE, under a
1473+ // refresh_executor_, serves the STALE cached tip-A template while refreshing
1474+ // async — the implicit-only path that reopens the stale-payee window.
1475+ TEST (DashStratumCoinP2pTipInvalidate, BumpAloneServesStaleUnderRefreshExecutor)
1476+ {
1477+ auto current = std::make_shared<dash::coin::DashWorkData>(rich_work ());
1478+ auto fallback = [current]() -> dash::coin::DashWorkData { return *current; };
1479+ auto submit = [](const std::vector<unsigned char >&, uint32_t ) { return true ; };
1480+ dash::coin::NodeCoinState cs;
1481+
1482+ dash::stratum::DASHWorkSource ws (cs, fallback, submit,
1483+ core::stratum::StratumConfig{}, /* is_testnet=*/ false );
1484+ auto exec = std::make_shared<DeferredExecutor>();
1485+ ws.set_refresh_executor ([exec](std::function<void ()> j) { (*exec)(std::move (j)); });
1486+
1487+ ws.get_current_work_template ();
1488+ exec->run ();
1489+ ASSERT_EQ (ws.get_current_work_template ().value (" previousblockhash" , " " ),
1490+ std::string (kPrevHashHex ));
1491+
1492+ *current = rotated_work ();
1493+ ws.bump_work_generation (); // NO invalidate — the pre-fix implicit path
1494+
1495+ // Serves the STALE tip-A template (cache still held; async refresh not landed).
1496+ auto served = ws.get_current_work_template ();
1497+ ASSERT_FALSE (served.empty ());
1498+ EXPECT_EQ (served.value (" previousblockhash" , " " ), std::string (kPrevHashHex ))
1499+ << " bump-only under refresh_executor_ serves the STALE tip-A payee — the "
1500+ " window invalidate_template_cache() closes" ;
1501+ }
1502+
14141503} // namespace
0 commit comments