diff --git a/README.md b/README.md
index ac7c36e8..4e3a493f 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses,
REACT_APP_API_BASE=https://your-backend.example npm run build
```
-The value is read at build time by `src/lib/apiClient.js`; without it, development builds use `http://localhost:3001` and production builds use `https://syscoin.dev`.
+The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats); without it, development builds use `http://localhost:3001` and production builds use `https://syscoin.dev`. Keeping both clients on the same override means `REACT_APP_API_BASE` retargets the entire app in a single build.
## Getting Started
diff --git a/src/App.css b/src/App.css
index 722219a0..ee520402 100644
--- a/src/App.css
+++ b/src/App.css
@@ -4031,8 +4031,82 @@
font-weight: 700;
}
-.proposal-wizard__help--warn {
- color: #b45309;
+.proposal-wizard__window-preview {
+ border: 1px solid var(--panel-outline);
+ border-radius: 14px;
+ padding: 12px 14px;
+ background: var(--panel-strong);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.proposal-wizard__window-preview > strong {
+ font-size: 0.85rem;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.proposal-wizard__window-preview p {
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+.proposal-wizard__window-preview--error {
+ background: rgba(229, 107, 85, 0.08);
+ border-color: rgba(229, 107, 85, 0.35);
+}
+
+/* Tight-voting-window warning. Fires when the next superblock is
+ within SUPERBLOCK_VOTE_DEADLINE_WARN_SEC (4 days) so the user
+ has a chance to rethink a last-minute submission that would
+ likely miss the payout. Uses the amber "warning" palette
+ (status-chip.is-warning / notice--warning / vote-modal__error--warn)
+ that's already used elsewhere to signal caution rather than
+ hard error. Larger padding + border-left accent makes the
+ banner harder to skim past than the plain window-preview card. */
+.proposal-wizard__tight-warning {
+ background: linear-gradient(
+ 180deg,
+ rgba(255, 248, 237, 0.96) 0%,
+ rgba(255, 240, 214, 0.98) 100%
+ );
+ border: 1px solid rgba(243, 179, 86, 0.45);
+ border-left: 4px solid #d89a3c;
+ border-radius: 14px;
+ padding: 14px 18px;
+ color: #6b4500;
+ margin-bottom: 12px;
+}
+
+.proposal-wizard__tight-warning-title {
+ display: block;
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: #8a5c0f;
+ margin-bottom: 6px;
+}
+
+.proposal-wizard__tight-warning p {
+ margin: 0 0 8px;
+ font-size: 0.9rem;
+ line-height: 1.45;
+}
+
+.proposal-wizard__tight-warning p:last-child {
+ margin-bottom: 0;
+}
+
+/* Inline emphasis inside the notice (numeric month counts, the
+ "will likely miss" clause) should stay inline so the sentence
+ reads as a sentence, not a bulleted stack. Without this the
+ generic `strong` rule from the title selector above would
+ bleed in on nested strongs — same pattern as
+ .proposal-wizard__burn-warning > strong. */
+.proposal-wizard__tight-warning p strong {
+ font-weight: 700;
+ display: inline;
}
.proposal-wizard__schedule {
diff --git a/src/lib/api.js b/src/lib/api.js
index 71035fc6..81248631 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -1,7 +1,31 @@
import axios from 'axios';
+// Base URL for the anonymous public sysnode-backend endpoints
+// (`/mnStats`, `/mnCount`, `/govlist`). Kept in lockstep with the
+// authenticated client in `./apiClient.js` so a single build-time
+// override (`REACT_APP_API_BASE`) retargets BOTH surfaces at once.
+//
+// Why this matters for governance: the proposal wizard now gates
+// `Prepare` on `fetchNetworkStats().superblock_stats.superblock_next_epoch_sec`,
+// so a hardcoded mainnet URL here means any non-default deployment
+// (local dev, self-hosted backend, testnet/regtest) either pulls
+// the wrong network's superblock cadence or fails CORS entirely —
+// which permanently disables Prepare because `isAnchorLive` never
+// flips true. Codex PR20 round 7 P1.
+//
+// Priority (mirrors apiClient.js):
+// 1. REACT_APP_API_BASE (build-time override for bespoke deployments)
+// 2. Production builds → https://syscoin.dev (the default
+// hosted backend, same host the authenticated client picks up)
+// 3. Development builds → http://localhost:3001 (backend dev server)
+const DEFAULT_BASE =
+ process.env.REACT_APP_API_BASE ||
+ (process.env.NODE_ENV === 'production'
+ ? 'https://syscoin.dev'
+ : 'http://localhost:3001');
+
const client = axios.create({
- baseURL: 'https://syscoin.dev',
+ baseURL: DEFAULT_BASE,
headers: {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json;charset=UTF-8',
diff --git a/src/lib/governanceWindow.js b/src/lib/governanceWindow.js
new file mode 100644
index 00000000..b13e46f6
--- /dev/null
+++ b/src/lib/governanceWindow.js
@@ -0,0 +1,321 @@
+// Governance proposal window math.
+//
+// Syscoin governance proposals pay out on superblocks. Core gates
+// payouts by a pair of timestamps on the proposal payload:
+//
+// payEligible = start_epoch - FUDGE <= SB_time <= end_epoch + FUDGE
+//
+// where FUDGE = 2 hours (see GOVERNANCE_FUDGE_WINDOW in
+// src/governance/governanceobject.h) and SB_time is the projected
+// epoch of the superblock being evaluated (computed live at validate
+// time as now + (sb_height - current_height) * 150s; see
+// CGovernanceManager::CreateSuperblockCandidate).
+//
+// `payment_count` is NOT stored on-chain. The "N months" label voters
+// see in the UI is derived purely from the (end_epoch - start_epoch)
+// delta; see formatters.getProposalDurationMonths.
+//
+// Consequence: pay cadence is controlled entirely by the window. We
+// compute a canonical window from a single user-facing input — the
+// number of months the proposal runs — so the wizard never asks for
+// raw epoch timestamps. The math below is pure + deterministic; it
+// takes an authoritative "next superblock" anchor (either from the
+// backend stats feed or a conservative fallback) and produces a
+// window that:
+//
+// * guarantees the first N superblocks starting at `anchor` are
+// inside the [windowStart, windowEnd] range Core checks,
+// * guarantees superblock N+1 (one cycle past the Nth) is outside
+// that range, so an approved proposal never silently extends
+// past its declared duration,
+// * renders as exactly N months via getProposalDurationMonths for
+// every N in [1, MAX_PAYMENT_COUNT].
+//
+// Pruning note: Core deletes an expired proposal
+// GOVERNANCE_DELETION_DELAY (10 min) after `end_epoch`. Our window
+// therefore prunes cleanly at end_epoch + ~10 min with no extra month
+// allocation.
+
+// Syscoin's superblock cadence in wall-clock seconds for the
+// active network. The value is nSuperblockCycle (blocks) *
+// nPowTargetSpacing (sec/block) as configured in Core's
+// kernel/chainparams.cpp; mainnet values are 17520 * 150 ≈ 30.4
+// days, but testnet uses 60 blocks and regtest uses 10, so this
+// MUST NOT be hardcoded to a single network's values — doing so
+// made "1 month" on a testnet build span ~290 real superblocks
+// instead of one. The active network is resolved at build time
+// from REACT_APP_NETWORK (see lib/networkParams.js). Codex PR20
+// round 3 P1.
+import { getNetworkParams } from './networkParams';
+const NETWORK = getNetworkParams();
+export const SUPERBLOCK_CYCLE_SEC =
+ NETWORK.superblockCycleBlocks * NETWORK.targetBlockTimeSec;
+
+// Core's fudge tolerance (governanceobject.h GOVERNANCE_FUDGE_WINDOW).
+// A superblock at SB_time is considered payable by Core iff
+// start_epoch - FUDGE <= SB_time <= end_epoch + FUDGE
+// so the wizard's symmetric anchor padding must be > FUDGE to
+// guarantee that SB_{N+1} (at anchor + N*cycle) stays strictly
+// outside the eligibility window on every network. On mainnet
+// FUDGE is dwarfed by cycle/2 (~15 days vs 2 hours), but on
+// testnet / regtest cycle/2 can be comparable to or smaller than
+// FUDGE — computeProposalWindow clamps accordingly.
+export const SUPERBLOCK_FUDGE_SEC = 2 * 60 * 60;
+
+// Additional seconds of slack beyond FUDGE that we require between
+// the window boundary and the nearest excluded superblock. Keeps
+// the window strictly outside the eligibility region under
+// worst-case block-time drift at the boundary — 60 s is about
+// 24 testnet blocks or 60 regtest blocks of margin, enough to
+// absorb re-orgs and stale clocks without getting close to FUDGE.
+export const SUPERBLOCK_FUDGE_SAFETY_SEC = 60;
+
+// Core's pre-superblock maturity window during which masternodes
+// build the payment-list candidate and lock in their YES-FUNDING
+// trigger vote. On mainnet this is 1728 blocks at ~2.5 min/block,
+// i.e. ~3 days (see kernel/chainparams.cpp:154 and
+// governance.cpp:569); testnet uses 20 blocks, regtest uses 5.
+// Value is taken from the active network's params — see
+// lib/networkParams.js.
+//
+// The important property: once a masternode has voted YES-FUNDING
+// on a trigger during this window it cannot vote YES on another
+// trigger for the same cycle (governance.cpp:727 hard-asserts
+// this). So the effective vote cutoff for a new proposal to be
+// included in the next superblock is not a cliff — it's a
+// gradient spread across the ~3-day window as individual
+// masternodes commit block-by-block inside `UpdatedBlockTip ->
+// CreateSuperblockCandidate -> VoteGovernanceTriggers`.
+//
+// Any proposal that has not accumulated quorum YES votes before
+// a supermajority of masternodes have committed will miss the
+// upcoming superblock and pay out N-1 months instead of N (since
+// our window intentionally excludes SB_{N+1}).
+export const SUPERBLOCK_MATURITY_WINDOW_SEC =
+ NETWORK.superblockMaturityWindowBlocks * NETWORK.targetBlockTimeSec;
+
+// Wizard warning threshold — slightly wider than Core's maturity
+// window to give masternodes headroom between proposal submission
+// and the earliest MN commit. That headroom covers collateral
+// confirmation (~15 min for 6 blocks), gobject relay, operator
+// review, and vote propagation. Proposals submitted inside this
+// window will likely miss the next superblock and pay out N-1
+// months instead of N, so the wizard surfaces a prominent notice.
+//
+// Derivation: `MATURITY * 4/3`. On mainnet this is exactly 4 days
+// (3-day maturity + 1-day headroom), matching the original UX
+// copy. On testnet (50-min maturity) it becomes ~67 min and on
+// regtest (12.5-min maturity) ~17 min — the "1/3 of the maturity
+// window as operator headroom" ratio is preserved across networks.
+// Codex PR20 round 4 P2: prior to this derivation the constant
+// was hardcoded to 4 days, so `isTightVotingWindow` was a
+// permanent true on testnet/regtest (cycle < 4 days) and the
+// warning banner became useless noise on every non-mainnet build.
+export const SUPERBLOCK_VOTE_DEADLINE_WARN_SEC = Math.floor(
+ (SUPERBLOCK_MATURITY_WINDOW_SEC * 4) / 3
+);
+
+// Tolerance used by the wizard's prepare-time anchor-drift check.
+// The backend recomputes `superblock_next_epoch_sec` every
+// sysMain.js tick (20 s) as `now + diffBlock * avgBlockTime`, so
+// the value drifts by seconds/minutes between fetches even when
+// the actual next superblock hasn't rotated. A strict equality
+// check treated every such drift as a rotation and popped an
+// anchor_drift error — users could get stuck looping through
+// re-review prompts without ever reaching `proposalService.prepare`
+// (Codex PR20 round 4 P1). Any legitimate rotation advances the
+// anchor by ≈ one full cycle, which is ≥ `cycle/2` regardless of
+// network, so `cycle/2` cleanly separates drift from rotation.
+export const ANCHOR_SAME_SB_TOLERANCE_SEC = Math.floor(
+ SUPERBLOCK_CYCLE_SEC / 2
+);
+
+// True when `freshAnchor` and `cachedAnchor` refer to the same
+// upcoming superblock (differences are estimate drift, not a
+// rotation). Both arguments must be positive integers or the
+// helper returns false (fail-closed: the caller should treat
+// "can't tell" the same as "different SB" and force a re-review).
+export function anchorsAreSameSuperblock(freshAnchor, cachedAnchor) {
+ const fresh = Math.floor(Number(freshAnchor));
+ const cached = Math.floor(Number(cachedAnchor));
+ if (!Number.isFinite(fresh) || fresh <= 0) return false;
+ if (!Number.isFinite(cached) || cached <= 0) return false;
+ return Math.abs(fresh - cached) < ANCHOR_SAME_SB_TOLERANCE_SEC;
+}
+
+// Returns true when the next superblock is close enough that
+// masternodes are unlikely to have finished voting + committing
+// before the trigger locks in. Caller supplies the wall clock
+// (nowSec) and the live next-superblock anchor so this stays
+// pure + deterministic — same contract as the other helpers here.
+// A null / zero / stale anchor returns false (the wizard already
+// refuses to submit in that state via the missing-stats banner;
+// overlaying a tight-window warning on top would be noise).
+export function isTightVotingWindow(nowSec, nextSuperblockSec) {
+ const now = Math.floor(Number(nowSec));
+ const nextSb = Math.floor(Number(nextSuperblockSec));
+ if (!Number.isFinite(now) || now <= 0) return false;
+ if (!Number.isFinite(nextSb) || nextSb <= now) return false;
+ return nextSb - now < SUPERBLOCK_VOTE_DEADLINE_WARN_SEC;
+}
+
+// Build the window for a proposal that should pay out for
+// `durationMonths` consecutive superblocks starting at the next
+// superblock after `nowSec`.
+//
+// Inputs:
+// durationMonths : integer >= 1, the user-declared month count.
+// Also becomes the on-chain `payment_count`
+// display field (not stored by Core).
+// nowSec : current wall-clock in UNIX seconds.
+// nextSuperblockSec : the projected epoch (seconds) of the next
+// superblock. Pulled from the backend
+// superblock_stats feed. If stale (<= now)
+// we fall back to (now + one full cycle)
+// which is the worst-case conservative
+// anchor — ensures the first real SB still
+// lands inside [windowStart, windowEnd].
+//
+// Returns { startEpoch, endEpoch } as integer UNIX seconds.
+//
+// Formula:
+// anchor = nextSuperblockSec (stale-anchor fallback: now + cycle)
+// padding = min(cycle/2, cycle - FUDGE - SAFETY)
+// startEpoch = anchor - padding
+// endEpoch = anchor + (N - 1) * cycle + padding
+//
+// Why symmetric padding:
+// * Start: places windowStart cleanly before the first SB so
+// Core's `start_epoch - FUDGE <= SB_time` check passes for
+// SB_1 and fails for SB_0 (the SB before the anchor).
+// * End: places windowEnd between SB_N and SB_{N+1}, so SB_N
+// lands comfortably inside the window and SB_{N+1} is excluded.
+// * On mainnet cycle/2 (~15 days) is orders of magnitude larger
+// than Core's 2-hour FUDGE, so block-time drift cannot push an
+// extra payment inside the window and the end - start = N*cycle
+// invariant holds exactly (renders as N months via
+// getProposalDurationMonths).
+//
+// Why the padding clamp:
+// On testnet (cycle 9000 s) and regtest (cycle 1500 s), raw
+// cycle/2 padding is smaller than FUDGE (7200 s), so SB_{N+1}
+// would satisfy `SB_time <= end_epoch + FUDGE` and Core would
+// pay it — a proposal silently pays N+1 superblocks instead of
+// the requested N. Clamping padding to `cycle - FUDGE - SAFETY`
+// keeps SB_{N+1} strictly outside the eligibility window on any
+// network where `cycle > FUDGE + SAFETY`. On such networks
+// `end - start < N*cycle`, so getProposalDurationMonths won't
+// round to N there — but the 30.4375-day month math is
+// mainnet-specific and already didn't render "N months" on
+// testnet/regtest regardless of the padding choice (a testnet
+// "1 month" window spans ~9000 s ≈ 0.1 days). Correctness of the
+// on-chain payout count takes priority over the displayed label
+// on non-mainnet builds. Codex PR20 round 5 P1.
+//
+// Fundamental limit:
+// If `cycle <= FUDGE + SAFETY`, no symmetric padding exists that
+// includes SB_N and excludes SB_{N±1}. We throw rather than
+// silently pad wrong — the wizard UI gates its error surface on
+// this throw. Regtest (cycle 1500 s < FUDGE 7200 s) falls here
+// and is an unsupported target for the duration-derived flow.
+//
+// Past start_epoch note:
+// When the next superblock is sooner than cycle/2 (up to ~15 days
+// away), startEpoch is nominally in the past. That is perfectly
+// fine for Core — src/governance/governancevalidators.cpp only
+// rejects `end_epoch` in the past (fCheckExpiration) and requires
+// end > start; it places no "must be in the future" constraint on
+// `start_epoch`. Backend validateStructural matches Core's rules.
+// The legacy wizard validator *did* flag "start in the past" as a
+// user error, but that guard was aimed at catching date-picker
+// typos; the wizard no longer exposes start_epoch as an input, so
+// the validator path is never invoked for a derived window.
+export function computeProposalWindow({
+ durationMonths,
+ nowSec,
+ nextSuperblockSec,
+} = {}) {
+ const n = Math.floor(Number(durationMonths));
+ const now = Math.floor(Number(nowSec));
+ const nextSb = Math.floor(Number(nextSuperblockSec));
+ if (!Number.isFinite(n) || n < 1) {
+ throw new Error('computeProposalWindow: durationMonths must be >= 1');
+ }
+ if (!Number.isFinite(now) || now <= 0) {
+ throw new Error('computeProposalWindow: nowSec must be a positive integer');
+ }
+ // A stale/invalid anchor falls back to now + one cycle — a
+ // conservative worst-case that still keeps the real first SB
+ // inside the window (the real SB is always <= one cycle from
+ // now, so windowStart = (now + cycle) - cycle/2 = now + cycle/2
+ // is comfortably before the real SB).
+ const anchor =
+ Number.isFinite(nextSb) && nextSb > now ? nextSb : now + SUPERBLOCK_CYCLE_SEC;
+ const desiredPadding = Math.floor(SUPERBLOCK_CYCLE_SEC / 2);
+ const maxPadding =
+ SUPERBLOCK_CYCLE_SEC - SUPERBLOCK_FUDGE_SEC - SUPERBLOCK_FUDGE_SAFETY_SEC;
+ if (maxPadding <= 0) {
+ throw new Error(
+ `computeProposalWindow: network superblock cycle (${SUPERBLOCK_CYCLE_SEC}s) ` +
+ `is too short to derive a safe proposal window — needs to exceed ` +
+ `GOVERNANCE_FUDGE_WINDOW (${SUPERBLOCK_FUDGE_SEC}s) + safety ` +
+ `(${SUPERBLOCK_FUDGE_SAFETY_SEC}s) so SB_{N+1} can be excluded`
+ );
+ }
+ const padding = Math.min(desiredPadding, maxPadding);
+ const startEpoch = anchor - padding;
+ const endEpoch = anchor + (n - 1) * SUPERBLOCK_CYCLE_SEC + padding;
+ // Return `anchor` (the SB_1 epoch used in the math) alongside
+ // the window boundaries so downstream schedule rendering never
+ // has to reconstruct it from `startEpoch + cycle/2`. On networks
+ // where padding is clamped below cycle/2 (testnet: padding
+ // 1740s vs cycle/2 4500s), the naive reconstruction shifts
+ // every projected payout forward by `cycle/2 - padding` and
+ // pushes row #N past `endEpoch`, producing a Review-vs-Prepare
+ // divergence (Codex PR20 round 7 P2). Keep `padding` in the
+ // payload too so consumers that genuinely need the boundary-to-
+ // anchor distance can read it directly.
+ return { startEpoch, endEpoch, anchor, padding };
+}
+
+// Extract the next-superblock epoch (seconds) from the /mnStats
+// response. The backend exposes a numeric `superblock_next_epoch_sec`
+// field (see sysnode-backend services/calculations.js). We never
+// parse the human-readable `superblock_date` string — it's formatted
+// for display only and its shape can drift.
+//
+// `nowSec` is required and is compared against the extracted anchor:
+// any value at or before `nowSec` is treated the same as a missing
+// field (returns null). This matters because the backend's stats
+// feed can lag — if the real next superblock has just passed and
+// sysMain.js hasn't refreshed, `superblock_next_epoch_sec` will be
+// in the past. Without this guard the wizard would cache a stale
+// anchor (truthy, so the Prepare button stays enabled), and the
+// two schedule sources diverge:
+//
+// * buildProjectedSchedule consumes nextSuperblockSec verbatim,
+// so Review shows payouts starting in the past.
+// * computeProposalWindow's internal fallback rewrites the anchor
+// to `now + cycle`, so the window actually submitted on-chain
+// is shifted by up to a full cycle from what the user reviewed.
+//
+// Rejecting stale values here forces refreshStats to surface a
+// "live-chain data unavailable" banner and keep the Prepare button
+// disabled until a fresh anchor arrives.
+export function nextSuperblockEpochSecFromStats(stats, nowSec) {
+ const now = Math.floor(Number(nowSec));
+ if (!Number.isFinite(now) || now <= 0) {
+ throw new Error(
+ 'nextSuperblockEpochSecFromStats: nowSec must be a positive integer'
+ );
+ }
+ if (!stats || typeof stats !== 'object') return null;
+ const root = stats.stats && typeof stats.stats === 'object' ? stats.stats : stats;
+ const sb = root.superblock_stats;
+ if (!sb || typeof sb !== 'object') return null;
+ const value = Number(sb.superblock_next_epoch_sec);
+ if (!Number.isFinite(value) || value <= 0) return null;
+ const floored = Math.floor(value);
+ if (floored <= now) return null;
+ return floored;
+}
diff --git a/src/lib/governanceWindow.test.js b/src/lib/governanceWindow.test.js
new file mode 100644
index 00000000..987ee916
--- /dev/null
+++ b/src/lib/governanceWindow.test.js
@@ -0,0 +1,561 @@
+import {
+ ANCHOR_SAME_SB_TOLERANCE_SEC,
+ SUPERBLOCK_CYCLE_SEC,
+ SUPERBLOCK_FUDGE_SEC,
+ SUPERBLOCK_MATURITY_WINDOW_SEC,
+ SUPERBLOCK_VOTE_DEADLINE_WARN_SEC,
+ anchorsAreSameSuperblock,
+ computeProposalWindow,
+ isTightVotingWindow,
+ nextSuperblockEpochSecFromStats,
+} from './governanceWindow';
+import { getProposalDurationMonths } from './formatters';
+
+describe('SUPERBLOCK_CYCLE_SEC', () => {
+ test('matches Core consensus (17520 blocks * 150s) on the default (mainnet) build', () => {
+ expect(SUPERBLOCK_CYCLE_SEC).toBe(17520 * 150);
+ // Sanity: roughly 30.4 days.
+ expect(SUPERBLOCK_CYCLE_SEC / 86400).toBeCloseTo(30.4166, 3);
+ });
+ test('SUPERBLOCK_FUDGE_SEC is 2 hours, matching GOVERNANCE_FUDGE_WINDOW', () => {
+ expect(SUPERBLOCK_FUDGE_SEC).toBe(2 * 3600);
+ });
+ test('SUPERBLOCK_MATURITY_WINDOW_SEC matches Core (1728 blocks * 150s) on mainnet', () => {
+ expect(SUPERBLOCK_MATURITY_WINDOW_SEC).toBe(1728 * 150);
+ // Sanity: ~3 days.
+ expect(SUPERBLOCK_MATURITY_WINDOW_SEC / 86400).toBeCloseTo(3.0, 1);
+ });
+ test('SUPERBLOCK_VOTE_DEADLINE_WARN_SEC on mainnet is exactly 4 days (maturity * 4/3)', () => {
+ // 3-day maturity * 4/3 = 4 days. Derived (not hardcoded) so
+ // testnet/regtest builds scale the warning proportionally.
+ // Codex PR20 round 4 P2.
+ expect(SUPERBLOCK_VOTE_DEADLINE_WARN_SEC).toBe(4 * 86400);
+ expect(SUPERBLOCK_VOTE_DEADLINE_WARN_SEC).toBe(
+ Math.floor((SUPERBLOCK_MATURITY_WINDOW_SEC * 4) / 3)
+ );
+ expect(SUPERBLOCK_VOTE_DEADLINE_WARN_SEC).toBeGreaterThan(
+ SUPERBLOCK_MATURITY_WINDOW_SEC
+ );
+ });
+
+ test('ANCHOR_SAME_SB_TOLERANCE_SEC is cycle/2 on mainnet (~15 days)', () => {
+ expect(ANCHOR_SAME_SB_TOLERANCE_SEC).toBe(
+ Math.floor(SUPERBLOCK_CYCLE_SEC / 2)
+ );
+ // Sanity check: tolerance is strictly less than one full cycle
+ // so a legitimate rotation (anchor jumps by ~cycle) is never
+ // mis-classified as drift.
+ expect(ANCHOR_SAME_SB_TOLERANCE_SEC).toBeLessThan(SUPERBLOCK_CYCLE_SEC);
+ });
+});
+
+// Codex PR20 round 4 P1: the prepare-time drift check must not
+// treat sub-SB /mnStats re-estimates as a superblock rotation.
+describe('anchorsAreSameSuperblock', () => {
+ const NOW = 1_800_000_000;
+ const ANCHOR = NOW + 10 * 86400;
+
+ test('exact equality → same SB', () => {
+ expect(anchorsAreSameSuperblock(ANCHOR, ANCHOR)).toBe(true);
+ });
+
+ test('sub-cycle drift (seconds / minutes) → same SB', () => {
+ // sysMain.js recomputes estimate every 20s as `now +
+ // diffBlock * avgBlockTime`. Typical drift between two
+ // fetches is seconds to a few minutes depending on new
+ // blocks arriving. None of those should ever surface as
+ // anchor_drift to the user.
+ expect(anchorsAreSameSuperblock(ANCHOR + 20, ANCHOR)).toBe(true);
+ expect(anchorsAreSameSuperblock(ANCHOR - 20, ANCHOR)).toBe(true);
+ expect(anchorsAreSameSuperblock(ANCHOR + 300, ANCHOR)).toBe(true);
+ expect(anchorsAreSameSuperblock(ANCHOR - 300, ANCHOR)).toBe(true);
+ // Hours of drift — still within cycle/2 on mainnet.
+ expect(anchorsAreSameSuperblock(ANCHOR + 86400, ANCHOR)).toBe(true);
+ });
+
+ test('drift approaching cycle/2 is still same SB (boundary)', () => {
+ // Any value strictly < cycle/2 is same SB.
+ expect(
+ anchorsAreSameSuperblock(ANCHOR + ANCHOR_SAME_SB_TOLERANCE_SEC - 1, ANCHOR)
+ ).toBe(true);
+ });
+
+ test('drift at cycle/2 is NOT same SB (strict less-than)', () => {
+ expect(
+ anchorsAreSameSuperblock(ANCHOR + ANCHOR_SAME_SB_TOLERANCE_SEC, ANCHOR)
+ ).toBe(false);
+ });
+
+ test('full-cycle jump (real rotation) → different SB', () => {
+ expect(
+ anchorsAreSameSuperblock(ANCHOR + SUPERBLOCK_CYCLE_SEC, ANCHOR)
+ ).toBe(false);
+ expect(
+ anchorsAreSameSuperblock(ANCHOR + 30 * 86400, ANCHOR)
+ ).toBe(false);
+ });
+
+ test('fail-closed on bogus inputs (treated as different SB)', () => {
+ // If we cannot tell whether the anchors refer to the same
+ // SB, the caller MUST force a re-review rather than silently
+ // submit — same semantics as a legitimate rotation.
+ expect(anchorsAreSameSuperblock(null, ANCHOR)).toBe(false);
+ expect(anchorsAreSameSuperblock(ANCHOR, null)).toBe(false);
+ expect(anchorsAreSameSuperblock(undefined, ANCHOR)).toBe(false);
+ expect(anchorsAreSameSuperblock(0, ANCHOR)).toBe(false);
+ expect(anchorsAreSameSuperblock(ANCHOR, 0)).toBe(false);
+ expect(anchorsAreSameSuperblock(-1, ANCHOR)).toBe(false);
+ expect(anchorsAreSameSuperblock('soon', ANCHOR)).toBe(false);
+ });
+});
+
+// Codex PR20 round 3 P1: ensure the window constants track the
+// per-network consensus params, not a hardcoded mainnet value.
+// Build a testnet/regtest copy of the module with REACT_APP_NETWORK
+// set and verify both cycle + maturity come from the right network.
+describe('per-network consensus params (Codex PR20 round 3 P1)', () => {
+ // jest.isolateModules lets us re-import governanceWindow.js with
+ // a different process.env.REACT_APP_NETWORK, which networkParams
+ // reads at module load. We restore the original value after each
+ // case so the outer describe's mainnet expectations continue to
+ // hold.
+ const originalNetwork = process.env.REACT_APP_NETWORK;
+ afterEach(() => {
+ if (originalNetwork === undefined) {
+ delete process.env.REACT_APP_NETWORK;
+ } else {
+ process.env.REACT_APP_NETWORK = originalNetwork;
+ }
+ });
+
+ function requireWithNetwork(value) {
+ if (value === undefined) {
+ delete process.env.REACT_APP_NETWORK;
+ } else {
+ process.env.REACT_APP_NETWORK = value;
+ }
+ let mod;
+ jest.isolateModules(() => {
+ // eslint-disable-next-line global-require
+ mod = require('./governanceWindow');
+ });
+ return mod;
+ }
+
+ test('testnet: cycle = 60 blocks, maturity = 20 blocks (Core chainparams.cpp:314)', () => {
+ const mod = requireWithNetwork('testnet');
+ expect(mod.SUPERBLOCK_CYCLE_SEC).toBe(60 * 150);
+ expect(mod.SUPERBLOCK_MATURITY_WINDOW_SEC).toBe(20 * 150);
+ // Sanity: testnet SB cadence is 2.5 hours, not 30 days.
+ expect(mod.SUPERBLOCK_CYCLE_SEC).toBe(9000);
+ });
+
+ test('regtest: cycle = 10 blocks, maturity = 5 blocks (Core chainparams.cpp:570)', () => {
+ const mod = requireWithNetwork('regtest');
+ expect(mod.SUPERBLOCK_CYCLE_SEC).toBe(10 * 150);
+ expect(mod.SUPERBLOCK_MATURITY_WINDOW_SEC).toBe(5 * 150);
+ expect(mod.SUPERBLOCK_CYCLE_SEC).toBe(1500);
+ });
+
+ test('explicit mainnet matches the default', () => {
+ const explicit = requireWithNetwork('mainnet');
+ const implicit = requireWithNetwork(undefined);
+ expect(explicit.SUPERBLOCK_CYCLE_SEC).toBe(implicit.SUPERBLOCK_CYCLE_SEC);
+ expect(explicit.SUPERBLOCK_MATURITY_WINDOW_SEC).toBe(
+ implicit.SUPERBLOCK_MATURITY_WINDOW_SEC
+ );
+ });
+
+ test('unknown label falls back to mainnet so typos cannot produce 0-filled windows', () => {
+ const mod = requireWithNetwork('not-a-real-network');
+ expect(mod.SUPERBLOCK_CYCLE_SEC).toBe(17520 * 150);
+ expect(mod.SUPERBLOCK_MATURITY_WINDOW_SEC).toBe(1728 * 150);
+ });
+
+ test(
+ 'testnet: computeProposalWindow excludes SB_{N+1} despite a cycle < 2*FUDGE (Codex round 5 P1)',
+ () => {
+ // Testnet cycle = 9000s, half = 4500s. The raw cycle/2
+ // padding is smaller than Core's GOVERNANCE_FUDGE_WINDOW
+ // (7200s), so without the clamp SB_{N+1} would satisfy
+ // `SB_time <= end_epoch + FUDGE` and Core would pay out one
+ // extra superblock beyond the declared duration. This is
+ // the regression Codex flagged; the fix clamps padding to
+ // `cycle - FUDGE - SAFETY` so SB_{N+1} stays strictly
+ // outside the eligibility window by at least SAFETY seconds.
+ const mod = requireWithNetwork('testnet');
+ const NOW = 1_800_000_000;
+ for (const N of [1, 2, 6, 12]) {
+ const anchor = NOW + mod.SUPERBLOCK_CYCLE_SEC + 60;
+ const win = mod.computeProposalWindow({
+ durationMonths: N,
+ nowSec: NOW,
+ nextSuperblockSec: anchor,
+ });
+ const { startEpoch, endEpoch } = win;
+ const windowStart = startEpoch - mod.SUPERBLOCK_FUDGE_SEC;
+ const windowEnd = endEpoch + mod.SUPERBLOCK_FUDGE_SEC;
+ // anchor + clamped padding are surfaced on the return
+ // value so the schedule renderer can position rows
+ // without guessing (Codex PR20 round 7 P2).
+ expect(win.anchor).toBe(anchor);
+ expect(win.padding).toBe(
+ mod.SUPERBLOCK_CYCLE_SEC -
+ mod.SUPERBLOCK_FUDGE_SEC -
+ mod.SUPERBLOCK_FUDGE_SAFETY_SEC
+ );
+ // Requested SB 1..N are payable.
+ for (let i = 1; i <= N; i += 1) {
+ const sb = anchor + (i - 1) * mod.SUPERBLOCK_CYCLE_SEC;
+ expect(sb).toBeGreaterThanOrEqual(windowStart);
+ expect(sb).toBeLessThanOrEqual(windowEnd);
+ }
+ // SB_{N+1} must be strictly excluded.
+ const sbNext = anchor + N * mod.SUPERBLOCK_CYCLE_SEC;
+ expect(sbNext).toBeGreaterThan(windowEnd);
+ // Concrete slack: cycle - padding - FUDGE = at least SAFETY.
+ expect(sbNext - windowEnd).toBeGreaterThanOrEqual(
+ mod.SUPERBLOCK_FUDGE_SAFETY_SEC
+ );
+ // Critical: every projected row must land at or before
+ // `endEpoch` (not just windowEnd). Previously the Review
+ // schedule reconstructed row positions as
+ // `startEpoch + cycle/2 + i*cycle` which pushed row N
+ // past `endEpoch` on testnet — the regression Codex round
+ // 7 P2 flagged. With `anchor` surfaced directly, the last
+ // row sits exactly at `anchor + (N-1)*cycle` which is
+ // `endEpoch - padding`, always <= endEpoch.
+ const lastRow = win.anchor + (N - 1) * mod.SUPERBLOCK_CYCLE_SEC;
+ expect(lastRow).toBeLessThanOrEqual(endEpoch);
+ }
+ }
+ );
+
+ test(
+ 'regtest: cycle is shorter than FUDGE so computeProposalWindow fails closed',
+ () => {
+ // Regtest cycle = 1500s is less than Core's 7200s fudge
+ // tolerance, so no symmetric padding exists that includes
+ // SB_N and excludes SB_{N+1} simultaneously — the
+ // eligibility windows of adjacent superblocks overlap.
+ // The helper must throw rather than silently submit a
+ // window that pays out the wrong number of superblocks.
+ // Regtest is therefore an unsupported target for the
+ // duration-derived governance wizard; the wizard's
+ // derivedWindow memo catches this throw and surfaces it as
+ // an empty window, which the WindowPreview component
+ // already renders as a neutral error state.
+ const mod = requireWithNetwork('regtest');
+ expect(() =>
+ mod.computeProposalWindow({
+ durationMonths: 1,
+ nowSec: 1_800_000_000,
+ nextSuperblockSec: 1_800_000_000 + mod.SUPERBLOCK_CYCLE_SEC,
+ })
+ ).toThrow(/too short to derive a safe proposal window/);
+ }
+ );
+});
+
+describe('isTightVotingWindow', () => {
+ const NOW = 1_800_000_000;
+
+ test('false when anchor is comfortably in the future (> 4 days)', () => {
+ expect(isTightVotingWindow(NOW, NOW + 5 * 86400)).toBe(false);
+ expect(isTightVotingWindow(NOW, NOW + 7 * 86400)).toBe(false);
+ expect(isTightVotingWindow(NOW, NOW + SUPERBLOCK_CYCLE_SEC)).toBe(false);
+ });
+
+ test('false at the 4-day boundary (strict less-than)', () => {
+ // Exactly at the threshold: not tight (avoids flapping on
+ // integer rounding the moment we cross the mark).
+ expect(isTightVotingWindow(NOW, NOW + 4 * 86400)).toBe(false);
+ });
+
+ test('true when anchor is inside the 4-day window', () => {
+ expect(isTightVotingWindow(NOW, NOW + 4 * 86400 - 1)).toBe(true);
+ expect(isTightVotingWindow(NOW, NOW + 3 * 86400)).toBe(true);
+ expect(isTightVotingWindow(NOW, NOW + 3600)).toBe(true);
+ expect(isTightVotingWindow(NOW, NOW + 60)).toBe(true);
+ });
+
+ test('false when anchor is missing, stale, or invalid', () => {
+ expect(isTightVotingWindow(NOW, NOW)).toBe(false); // equal to now: stale
+ expect(isTightVotingWindow(NOW, NOW - 1)).toBe(false); // past: stale
+ expect(isTightVotingWindow(NOW, 0)).toBe(false);
+ expect(isTightVotingWindow(NOW, null)).toBe(false);
+ expect(isTightVotingWindow(NOW, undefined)).toBe(false);
+ expect(isTightVotingWindow(NOW, 'soon')).toBe(false);
+ });
+
+ test('false when nowSec is invalid (fail-closed)', () => {
+ // Defensive: never claim "tight" on a bogus clock — the
+ // wizard would render a scary warning for no reason.
+ expect(isTightVotingWindow(0, NOW + 60)).toBe(false);
+ expect(isTightVotingWindow(-1, NOW + 60)).toBe(false);
+ expect(isTightVotingWindow(null, NOW + 60)).toBe(false);
+ expect(isTightVotingWindow('now', NOW + 60)).toBe(false);
+ });
+});
+
+describe('computeProposalWindow', () => {
+ const NOW = 1_800_000_000; // arbitrary fixed epoch for determinism
+
+ test('throws on missing / invalid durationMonths', () => {
+ expect(() =>
+ computeProposalWindow({ durationMonths: 0, nowSec: NOW, nextSuperblockSec: NOW + 1000 })
+ ).toThrow();
+ expect(() =>
+ computeProposalWindow({ nowSec: NOW, nextSuperblockSec: NOW + 1000 })
+ ).toThrow();
+ expect(() =>
+ computeProposalWindow({ durationMonths: -1, nowSec: NOW, nextSuperblockSec: NOW + 1000 })
+ ).toThrow();
+ });
+
+ test('falls back to now + cycle when anchor is missing or stale', () => {
+ // Fallback anchor = NOW + cycle. Start is unclamped (NOW + cycle/2
+ // is in the future), end = anchor + (N-1)*cycle + cycle/2.
+ const half = Math.floor(SUPERBLOCK_CYCLE_SEC / 2);
+ const a = computeProposalWindow({ durationMonths: 1, nowSec: NOW });
+ expect(a.startEpoch).toBe(NOW + half);
+ expect(a.endEpoch).toBe(NOW + SUPERBLOCK_CYCLE_SEC + half);
+ expect(a.anchor).toBe(NOW + SUPERBLOCK_CYCLE_SEC);
+ expect(a.padding).toBe(half);
+
+ const b = computeProposalWindow({
+ durationMonths: 1,
+ nowSec: NOW,
+ nextSuperblockSec: NOW - 100,
+ });
+ expect(b).toEqual(a);
+ });
+
+ test(
+ 'returns the computed anchor and padding so schedule rendering can read them directly (Codex round 6 P2)',
+ () => {
+ // Regression guard for Codex PR20 round 6 P2: the Review
+ // schedule used to reconstruct the SB_1 anchor as
+ // `startEpoch + cycle/2`, but on networks where padding is
+ // clamped below cycle/2 that reconstruction drifts every
+ // row forward. Surfacing `anchor` + `padding` directly on
+ // the return value keeps the projected schedule in lockstep
+ // with the window actually submitted to /prepare.
+ const anchor = NOW + 10 * 86400;
+ const w = computeProposalWindow({
+ durationMonths: 6,
+ nowSec: NOW,
+ nextSuperblockSec: anchor,
+ });
+ expect(w.anchor).toBe(anchor);
+ expect(w.padding).toBe(Math.floor(SUPERBLOCK_CYCLE_SEC / 2));
+ // SB_i lands at anchor + (i-1)*cycle and all rows land
+ // inside [startEpoch, endEpoch] by construction.
+ for (let i = 1; i <= 6; i += 1) {
+ const sb = w.anchor + (i - 1) * SUPERBLOCK_CYCLE_SEC;
+ expect(sb).toBeGreaterThanOrEqual(w.startEpoch);
+ expect(sb).toBeLessThanOrEqual(w.endEpoch);
+ }
+ }
+ );
+
+ test.each([1, 2, 3, 6, 12, 24, 60])(
+ 'N=%i: first N superblocks are inside the window, N+1 is excluded',
+ (N) => {
+ // Pick a mid-range anchor 10 days from now — exercises the
+ // unclamped-start branch.
+ const anchor = NOW + 10 * 86400;
+ const { startEpoch, endEpoch } = computeProposalWindow({
+ durationMonths: N,
+ nowSec: NOW,
+ nextSuperblockSec: anchor,
+ });
+ const windowStart = startEpoch - SUPERBLOCK_FUDGE_SEC;
+ const windowEnd = endEpoch + SUPERBLOCK_FUDGE_SEC;
+
+ // Core eligibility: SB_i time = anchor + (i-1)*cycle
+ for (let i = 1; i <= N; i += 1) {
+ const sb = anchor + (i - 1) * SUPERBLOCK_CYCLE_SEC;
+ expect(sb).toBeGreaterThanOrEqual(windowStart);
+ expect(sb).toBeLessThanOrEqual(windowEnd);
+ }
+ // SB_{N+1} must be excluded
+ const sbNext = anchor + N * SUPERBLOCK_CYCLE_SEC;
+ expect(sbNext).toBeGreaterThan(windowEnd);
+ }
+ );
+
+ test.each([1, 2, 3, 6, 12, 60])(
+ 'N=%i: getProposalDurationMonths renders exactly N months for mid-range anchors',
+ (N) => {
+ const anchor = NOW + 10 * 86400;
+ const { startEpoch, endEpoch } = computeProposalWindow({
+ durationMonths: N,
+ nowSec: NOW,
+ nextSuperblockSec: anchor,
+ });
+ expect(getProposalDurationMonths(startEpoch, endEpoch)).toBe(N);
+ }
+ );
+
+ test.each([1, 2, 3, 6, 12, 60])(
+ 'N=%i: display rounds to N when anchor is very near (start nominally in past)',
+ (N) => {
+ // anchor = now + 1h : startEpoch lands ~15d before now. Core
+ // accepts past start_epoch, and (end - start) = N * cycle
+ // exactly, so the displayed month count must still be N.
+ const anchor = NOW + 3600;
+ const { startEpoch, endEpoch } = computeProposalWindow({
+ durationMonths: N,
+ nowSec: NOW,
+ nextSuperblockSec: anchor,
+ });
+ expect(endEpoch - startEpoch).toBe(N * SUPERBLOCK_CYCLE_SEC);
+ expect(startEpoch).toBeLessThan(NOW); // nominally in the past — OK per Core
+ expect(startEpoch).toBeGreaterThan(0);
+ expect(getProposalDurationMonths(startEpoch, endEpoch)).toBe(N);
+ }
+ );
+
+ test.each([1, 2, 3, 6, 12, 60])(
+ 'N=%i: display rounds to N when anchor is one full cycle away (max anchor)',
+ (N) => {
+ // anchor = now + cycle (first SB is a full cycle away, the
+ // worst-case "just missed" scenario). Clamp inactive.
+ const anchor = NOW + SUPERBLOCK_CYCLE_SEC;
+ const { startEpoch, endEpoch } = computeProposalWindow({
+ durationMonths: N,
+ nowSec: NOW,
+ nextSuperblockSec: anchor,
+ });
+ expect(getProposalDurationMonths(startEpoch, endEpoch)).toBe(N);
+ }
+ );
+
+ test('safety slack to SB_{N+1} is ~half a cycle (>> 2h fudge)', () => {
+ const N = 12;
+ const anchor = NOW + 10 * 86400;
+ const { endEpoch } = computeProposalWindow({
+ durationMonths: N,
+ nowSec: NOW,
+ nextSuperblockSec: anchor,
+ });
+ const sbNextPlusOne = anchor + N * SUPERBLOCK_CYCLE_SEC;
+ const slack = sbNextPlusOne - (endEpoch + SUPERBLOCK_FUDGE_SEC);
+ // Slack ~ cycle/2 - 2h ≈ 14.92 days
+ expect(slack).toBeGreaterThan(14 * 86400);
+ expect(slack).toBeLessThan(16 * 86400);
+ });
+
+ test('end_epoch is always > now (so Core fCheckExpiration passes)', () => {
+ for (const nextOffsetDays of [0.01, 1, 15, 29, 30]) {
+ for (const N of [1, 2, 12, 60]) {
+ const { endEpoch } = computeProposalWindow({
+ durationMonths: N,
+ nowSec: NOW,
+ nextSuperblockSec: NOW + nextOffsetDays * 86400,
+ });
+ expect(endEpoch).toBeGreaterThan(NOW);
+ }
+ }
+ });
+
+ test('end_epoch > start_epoch for every valid input', () => {
+ for (const nextOffsetDays of [0.01, 1, 15, 29, 30]) {
+ for (const N of [1, 2, 3, 12, 60]) {
+ const { startEpoch, endEpoch } = computeProposalWindow({
+ durationMonths: N,
+ nowSec: NOW,
+ nextSuperblockSec: NOW + nextOffsetDays * 86400,
+ });
+ expect(endEpoch).toBeGreaterThan(startEpoch);
+ }
+ }
+ });
+});
+
+describe('nextSuperblockEpochSecFromStats', () => {
+ const NOW = 1_800_000_000;
+
+ test('returns the numeric field when strictly in the future', () => {
+ expect(
+ nextSuperblockEpochSecFromStats(
+ { stats: { superblock_stats: { superblock_next_epoch_sec: NOW + 60 } } },
+ NOW
+ )
+ ).toBe(NOW + 60);
+ });
+
+ test('handles an un-wrapped payload (stats at root)', () => {
+ expect(
+ nextSuperblockEpochSecFromStats(
+ { superblock_stats: { superblock_next_epoch_sec: NOW + 3600 } },
+ NOW
+ )
+ ).toBe(NOW + 3600);
+ });
+
+ test('returns null for missing / non-positive / malformed inputs', () => {
+ expect(nextSuperblockEpochSecFromStats(null, NOW)).toBeNull();
+ expect(nextSuperblockEpochSecFromStats({}, NOW)).toBeNull();
+ expect(nextSuperblockEpochSecFromStats({ stats: {} }, NOW)).toBeNull();
+ expect(
+ nextSuperblockEpochSecFromStats({ stats: { superblock_stats: {} } }, NOW)
+ ).toBeNull();
+ expect(
+ nextSuperblockEpochSecFromStats(
+ { stats: { superblock_stats: { superblock_next_epoch_sec: 0 } } },
+ NOW
+ )
+ ).toBeNull();
+ expect(
+ nextSuperblockEpochSecFromStats(
+ { stats: { superblock_stats: { superblock_next_epoch_sec: 'soon' } } },
+ NOW
+ )
+ ).toBeNull();
+ });
+
+ // Codex PR20 P1: the backend's /mnStats feed can lag for a
+ // window between the real next superblock landing and
+ // sysMain.js refreshing its cache. If we were to accept that
+ // stale (past) timestamp as a valid anchor, the wizard would
+ // enable Prepare, render a Review schedule starting from a past
+ // date, and then silently submit a different window (the
+ // computeProposalWindow stale-anchor fallback). Regression
+ // tests: any anchor <= nowSec must be rejected the same way a
+ // missing field is.
+ test('rejects an anchor equal to nowSec as stale', () => {
+ expect(
+ nextSuperblockEpochSecFromStats(
+ { stats: { superblock_stats: { superblock_next_epoch_sec: NOW } } },
+ NOW
+ )
+ ).toBeNull();
+ });
+
+ test('rejects an anchor in the past as stale', () => {
+ expect(
+ nextSuperblockEpochSecFromStats(
+ { stats: { superblock_stats: { superblock_next_epoch_sec: NOW - 1 } } },
+ NOW
+ )
+ ).toBeNull();
+ expect(
+ nextSuperblockEpochSecFromStats(
+ { stats: { superblock_stats: { superblock_next_epoch_sec: NOW - 86400 } } },
+ NOW
+ )
+ ).toBeNull();
+ });
+
+ test('throws when nowSec is missing or invalid', () => {
+ const stats = {
+ stats: { superblock_stats: { superblock_next_epoch_sec: NOW + 60 } },
+ };
+ expect(() => nextSuperblockEpochSecFromStats(stats)).toThrow();
+ expect(() => nextSuperblockEpochSecFromStats(stats, 0)).toThrow();
+ expect(() => nextSuperblockEpochSecFromStats(stats, -1)).toThrow();
+ expect(() => nextSuperblockEpochSecFromStats(stats, 'now')).toThrow();
+ });
+});
diff --git a/src/lib/networkParams.js b/src/lib/networkParams.js
new file mode 100644
index 00000000..3b875a5b
--- /dev/null
+++ b/src/lib/networkParams.js
@@ -0,0 +1,88 @@
+// Per-network Syscoin consensus params the governance wizard needs.
+//
+// Prior versions hardcoded mainnet values (17520 / 1728 blocks,
+// 150 s spacing) directly in governanceWindow.js. That broke every
+// non-mainnet deployment — testnet's superblock cycle is 60 blocks
+// (not 17520), so "1 month" on a testnet build meant
+// 17520 * 150 = 2,628,000 s = ~30.4 days, which spans roughly 290
+// testnet superblocks rather than one. A UI that claims "1 month"
+// but pays out 290 times is a P1 correctness regression for anyone
+// running a fork or private / testnet build. See Codex PR20 round
+// 3 P1 ("Derive cadence from active network, not mainnet
+// constant").
+//
+// Values mirror Syscoin Core's kernel/chainparams.cpp:
+//
+// mainnet : nSuperblockCycle = 17520, nSuperblockMaturityWindow = 1728,
+// nPowTargetSpacing = 150 s
+// testnet : nSuperblockCycle = 60, nSuperblockMaturityWindow = 20,
+// nPowTargetSpacing = 150 s
+// regtest : nSuperblockCycle = 10, nSuperblockMaturityWindow = 5,
+// nPowTargetSpacing = 150 s
+//
+// Any change to Core's chainparams must be mirrored here or the
+// wizard's payout-window math will silently drift from what
+// consensus enforces.
+//
+// The active network is selected by the build-time env var
+// `REACT_APP_NETWORK`. Create-React-App substitutes REACT_APP_*
+// vars into the bundle at build time, so setting this when
+// producing a testnet/regtest build is symmetric with the existing
+// REACT_APP_API_BASE override documented in the repo README.
+// Missing / unrecognised values fall back to mainnet (the
+// production default at https://sysnode.info).
+
+const NETWORK_PARAMS = Object.freeze({
+ mainnet: Object.freeze({
+ id: 'mainnet',
+ superblockCycleBlocks: 17520,
+ superblockMaturityWindowBlocks: 1728,
+ targetBlockTimeSec: 150,
+ }),
+ testnet: Object.freeze({
+ id: 'testnet',
+ superblockCycleBlocks: 60,
+ superblockMaturityWindowBlocks: 20,
+ targetBlockTimeSec: 150,
+ }),
+ regtest: Object.freeze({
+ id: 'regtest',
+ superblockCycleBlocks: 10,
+ superblockMaturityWindowBlocks: 5,
+ targetBlockTimeSec: 150,
+ }),
+});
+
+// Normalise a raw env-var value to one of the supported network
+// ids. Empty / unknown values return 'mainnet' (production
+// default). Exported for test coverage of the resolution rules.
+export function resolveNetworkId(raw) {
+ if (raw == null) return 'mainnet';
+ const s = String(raw).trim().toLowerCase();
+ if (s === '') return 'mainnet';
+ if (s === 'main' || s === 'mainnet') return 'mainnet';
+ if (s === 'test' || s === 'testnet') return 'testnet';
+ if (s === 'reg' || s === 'regtest') return 'regtest';
+ // Unknown label: fall back to mainnet so a misspelled env var
+ // doesn't silently break the window math with zero-filled
+ // defaults. A warning here would be noisy in tests and CRA
+ // stripes the console, so callers that care can inspect
+ // getNetworkParams().id and compare to their build target.
+ return 'mainnet';
+}
+
+// Returns a frozen params record for the active network. Safe to
+// call repeatedly — the resolution is purely a lookup.
+export function getNetworkParams() {
+ const id = resolveNetworkId(
+ typeof process !== 'undefined' && process.env
+ ? process.env.REACT_APP_NETWORK
+ : undefined
+ );
+ return NETWORK_PARAMS[id];
+}
+
+// Map of supported ids → params, exported for tests that want to
+// spot-check every network in one pass rather than re-importing
+// with different env vars.
+export const SUPPORTED_NETWORKS = NETWORK_PARAMS;
diff --git a/src/lib/networkParams.test.js b/src/lib/networkParams.test.js
new file mode 100644
index 00000000..3f012029
--- /dev/null
+++ b/src/lib/networkParams.test.js
@@ -0,0 +1,110 @@
+// Per-network consensus param resolution. Covers the env-var
+// normalisation rules and a spot-check that every supported
+// network's values match Syscoin Core's kernel/chainparams.cpp.
+// Codex PR20 round 3 P1.
+
+import {
+ resolveNetworkId,
+ getNetworkParams,
+ SUPPORTED_NETWORKS,
+} from './networkParams';
+
+describe('resolveNetworkId', () => {
+ test('returns mainnet for missing / empty / null inputs', () => {
+ expect(resolveNetworkId(undefined)).toBe('mainnet');
+ expect(resolveNetworkId(null)).toBe('mainnet');
+ expect(resolveNetworkId('')).toBe('mainnet');
+ expect(resolveNetworkId(' ')).toBe('mainnet');
+ });
+
+ test('accepts canonical and short forms, case-insensitive', () => {
+ expect(resolveNetworkId('mainnet')).toBe('mainnet');
+ expect(resolveNetworkId('main')).toBe('mainnet');
+ expect(resolveNetworkId('MAIN')).toBe('mainnet');
+ expect(resolveNetworkId('testnet')).toBe('testnet');
+ expect(resolveNetworkId('test')).toBe('testnet');
+ expect(resolveNetworkId('TestNet')).toBe('testnet');
+ expect(resolveNetworkId('regtest')).toBe('regtest');
+ expect(resolveNetworkId('reg')).toBe('regtest');
+ });
+
+ test('strips surrounding whitespace', () => {
+ expect(resolveNetworkId(' testnet ')).toBe('testnet');
+ expect(resolveNetworkId('\tregtest\n')).toBe('regtest');
+ });
+
+ test('falls back to mainnet for unknown labels (typo guard)', () => {
+ // Silent fallback is deliberate: a misspelled env var would
+ // otherwise leave SUPERBLOCK_CYCLE_SEC multiplied by zero and
+ // the wizard would ship zero-width windows that Core rejects
+ // outright. Mainnet is the production default and the safest
+ // failure mode — worst-case the user's testnet build renders
+ // mainnet-sized windows, which is obviously wrong on the UI
+ // but still gets submitted as valid epochs.
+ expect(resolveNetworkId('signet')).toBe('mainnet');
+ expect(resolveNetworkId('foobar')).toBe('mainnet');
+ expect(resolveNetworkId('42')).toBe('mainnet');
+ });
+});
+
+describe('SUPPORTED_NETWORKS', () => {
+ test('mainnet matches Core kernel/chainparams.cpp:152', () => {
+ const p = SUPPORTED_NETWORKS.mainnet;
+ expect(p.id).toBe('mainnet');
+ expect(p.superblockCycleBlocks).toBe(17520);
+ expect(p.superblockMaturityWindowBlocks).toBe(1728);
+ expect(p.targetBlockTimeSec).toBe(150);
+ });
+
+ test('testnet matches Core kernel/chainparams.cpp:314', () => {
+ const p = SUPPORTED_NETWORKS.testnet;
+ expect(p.id).toBe('testnet');
+ expect(p.superblockCycleBlocks).toBe(60);
+ expect(p.superblockMaturityWindowBlocks).toBe(20);
+ expect(p.targetBlockTimeSec).toBe(150);
+ });
+
+ test('regtest matches Core kernel/chainparams.cpp:570', () => {
+ const p = SUPPORTED_NETWORKS.regtest;
+ expect(p.id).toBe('regtest');
+ expect(p.superblockCycleBlocks).toBe(10);
+ expect(p.superblockMaturityWindowBlocks).toBe(5);
+ expect(p.targetBlockTimeSec).toBe(150);
+ });
+
+ test('records are frozen so callers cannot mutate consensus values', () => {
+ expect(Object.isFrozen(SUPPORTED_NETWORKS)).toBe(true);
+ expect(Object.isFrozen(SUPPORTED_NETWORKS.mainnet)).toBe(true);
+ expect(Object.isFrozen(SUPPORTED_NETWORKS.testnet)).toBe(true);
+ expect(Object.isFrozen(SUPPORTED_NETWORKS.regtest)).toBe(true);
+ });
+});
+
+describe('getNetworkParams (reads REACT_APP_NETWORK)', () => {
+ const originalNetwork = process.env.REACT_APP_NETWORK;
+ afterEach(() => {
+ if (originalNetwork === undefined) {
+ delete process.env.REACT_APP_NETWORK;
+ } else {
+ process.env.REACT_APP_NETWORK = originalNetwork;
+ }
+ });
+
+ test('returns mainnet params when REACT_APP_NETWORK is unset', () => {
+ delete process.env.REACT_APP_NETWORK;
+ expect(getNetworkParams().id).toBe('mainnet');
+ expect(getNetworkParams().superblockCycleBlocks).toBe(17520);
+ });
+
+ test('returns testnet params when REACT_APP_NETWORK=testnet', () => {
+ process.env.REACT_APP_NETWORK = 'testnet';
+ expect(getNetworkParams().id).toBe('testnet');
+ expect(getNetworkParams().superblockCycleBlocks).toBe(60);
+ });
+
+ test('returns regtest params when REACT_APP_NETWORK=regtest', () => {
+ process.env.REACT_APP_NETWORK = 'regtest';
+ expect(getNetworkParams().id).toBe('regtest');
+ expect(getNetworkParams().superblockCycleBlocks).toBe(10);
+ });
+});
diff --git a/src/lib/proposalForm.js b/src/lib/proposalForm.js
index 014a9f67..8f00d909 100644
--- a/src/lib/proposalForm.js
+++ b/src/lib/proposalForm.js
@@ -34,14 +34,19 @@ const SYS_ADDRESS_RE = /^(sys1|tsys1|[LS3])[A-Za-z0-9]{10,}$/;
// needs to accept exactly what the user can type.
const AMOUNT_RE = /^(0|[1-9]\d*)(\.\d{1,8})?$/;
+// `paymentCount` is both the number of monthly superblock payouts the
+// proposal asks for AND, post-redesign, the SOLE user-facing knob
+// that controls the voting window. start_epoch / end_epoch are now
+// derived at submit time from `paymentCount` plus a live
+// next-superblock anchor (see lib/governanceWindow.computeProposalWindow).
+// Drafts therefore persist `paymentCount` but not epochs; the /prepare
+// body adds the derived epochs inline.
const EMPTY = () => ({
name: '',
url: '',
paymentAddress: '',
paymentAmount: '',
paymentCount: '1',
- startEpoch: '',
- endEpoch: '',
});
// Build a blank form. Exported so tests + the wizard can start from a
@@ -71,14 +76,11 @@ export function fromDraft(draft) {
typeof draft.paymentCount === 'number'
? String(draft.paymentCount)
: draft.paymentCount || '1',
- startEpoch:
- draft.startEpoch != null && Number.isFinite(Number(draft.startEpoch))
- ? String(Math.trunc(Number(draft.startEpoch)))
- : '',
- endEpoch:
- draft.endEpoch != null && Number.isFinite(Number(draft.endEpoch))
- ? String(Math.trunc(Number(draft.endEpoch)))
- : '',
+ // Legacy drafts may still have start_epoch / end_epoch populated
+ // from the pre-redesign wizard. We intentionally drop them here —
+ // they're no longer user-facing, and recomputed fresh at /prepare
+ // time from the current `nextSuperblockEpochSec` anchor so a
+ // resumed draft doesn't inherit stale epochs.
};
}
@@ -145,7 +147,13 @@ export function validateBasics(form) {
}
// Validation step #2 — Payment details.
-export function validatePayment(form, { nowSec } = {}) {
+//
+// `nowSec` is accepted for API-compat with the previous signature and
+// for tests that want deterministic clock control; the redesigned
+// wizard derives start/end epochs programmatically (see
+// lib/governanceWindow) so there's no "start in the past" typo risk
+// to guard against here anymore.
+export function validatePayment(form, { nowSec: _nowSec } = {}) {
const errors = {};
const addr = (form.paymentAddress || '').trim();
if (!addr) {
@@ -176,31 +184,7 @@ export function validatePayment(form, { nowSec } = {}) {
}
const count = Number(form.paymentCount);
if (!Number.isInteger(count) || count < 1 || count > MAX_PAYMENT_COUNT) {
- errors.paymentCount = `Number of payments must be between 1 and ${MAX_PAYMENT_COUNT}.`;
- }
- const start = Number(form.startEpoch);
- const end = Number(form.endEpoch);
- if (!Number.isInteger(start) || start <= 0) {
- errors.startEpoch = 'Start time is required.';
- }
- if (!Number.isInteger(end) || end <= 0) {
- errors.endEpoch = 'End time is required.';
- }
- if (
- Number.isInteger(start) &&
- Number.isInteger(end) &&
- start > 0 &&
- end > 0 &&
- end <= start
- ) {
- errors.endEpoch = 'End time must be after start time.';
- }
- // Consensus-ish: start cannot be in the past. Core rejects end in
- // the past outright; start in the past is rarely useful so we
- // flag it as a warning-level error to catch Date picker typos.
- const floor = Number.isFinite(nowSec) ? nowSec : Math.floor(Date.now() / 1000);
- if (Number.isInteger(start) && start > 0 && start < floor - 60) {
- errors.startEpoch = 'Start time is in the past.';
+ errors.paymentCount = `Duration must be between 1 and ${MAX_PAYMENT_COUNT} months.`;
}
return errors;
}
@@ -227,11 +211,18 @@ export function estimatePayloadBytes(form) {
// satsStringToSys() mirrors that exactly, so using it here makes
// this size estimate faithful to what the backend will actually
// serialize.
+ //
+ // start_epoch / end_epoch are now derived at submit time rather
+ // than being user inputs. Their serialized form in the canonical
+ // JSON is a 10-digit UNIX seconds integer (Core currently, ~year
+ // 2001..2286), so a constant 10-digit placeholder gives a faithful
+ // upper bound on the wire width without requiring a live anchor.
+ const EPOCH_PLACEHOLDER = 1_900_000_000; // 10-digit UNIX seconds
const payload = {
type: 1,
name: (form.name || '').trim(),
- start_epoch: Math.trunc(Number(form.startEpoch) || 0),
- end_epoch: Math.trunc(Number(form.endEpoch) || 0),
+ start_epoch: EPOCH_PLACEHOLDER,
+ end_epoch: EPOCH_PLACEHOLDER,
payment_address: (form.paymentAddress || '').trim(),
payment_amount: sats ? satsStringToSys(sats) : '0',
url: (form.url || '').trim(),
@@ -253,8 +244,6 @@ export function formsEqual(a, b) {
'paymentAddress',
'paymentAmount',
'paymentCount',
- 'startEpoch',
- 'endEpoch',
];
for (const k of keys) {
if ((a[k] || '').trim() !== (b[k] || '').trim()) return false;
@@ -296,22 +285,13 @@ export function draftBodyFromForm(form, { forUpdate = false } = {}) {
if (sats !== null) out.paymentAmountSats = sats;
const count = Number(form.paymentCount);
if (Number.isInteger(count) && count >= 1) out.paymentCount = count;
- const startRaw = (form.startEpoch || '').toString().trim();
- const start = Number(startRaw);
- if (Number.isInteger(start) && start > 0) {
- out.startEpoch = start;
- } else if (forUpdate && !startRaw) {
- // Explicit clear: only send when the string is empty on an
- // update. A non-empty-but-invalid value (`"abc"`, `"-5"`) is
- // ambiguous — we leave it out so the backend doesn't misread
- // garbage typing-in-progress as "clear this column".
+ // Epochs are intentionally NOT persisted on drafts anymore — they
+ // are derived at /prepare time from the live next-superblock
+ // anchor (see prepareBodyFromForm). Clearing them on an update
+ // keeps legacy rows from holding stale timestamps that could leak
+ // into a future /prepare call if the derivation pipeline broke.
+ if (forUpdate) {
out.startEpoch = null;
- }
- const endRaw = (form.endEpoch || '').toString().trim();
- const end = Number(endRaw);
- if (Number.isInteger(end) && end > 0) {
- out.endEpoch = end;
- } else if (forUpdate && !endRaw) {
out.endEpoch = null;
}
return out;
@@ -321,8 +301,29 @@ export function draftBodyFromForm(form, { forUpdate = false } = {}) {
// every field because the backend canonicalises and hashes the whole
// record — missing fields become hash-busting validation errors, not
// "use the default" helpers.
-export function prepareBodyFromForm(form, { draftId, consumeDraft } = {}) {
+//
+// `startEpoch` / `endEpoch` are derived from `paymentCount` plus a
+// live next-superblock anchor; the wizard computes them via
+// computeProposalWindow and passes the result in via the `window`
+// option. Callers that already have raw epoch values (e.g. an
+// external API client, integration tests) can pass them verbatim in
+// `window` and bypass the helper's derivation.
+export function prepareBodyFromForm(
+ form,
+ { draftId, consumeDraft, window } = {}
+) {
const body = draftBodyFromForm(form);
+ // Strip the null-epoch placeholders that draftBodyFromForm would
+ // otherwise emit (forUpdate path); /prepare demands populated
+ // epochs.
+ delete body.startEpoch;
+ delete body.endEpoch;
+ if (window && typeof window === 'object') {
+ const start = Math.trunc(Number(window.startEpoch));
+ const end = Math.trunc(Number(window.endEpoch));
+ if (Number.isFinite(start) && start > 0) body.startEpoch = start;
+ if (Number.isFinite(end) && end > 0) body.endEpoch = end;
+ }
if (Number.isInteger(draftId) && draftId > 0) {
body.draftId = draftId;
body.consumeDraft = consumeDraft !== false;
diff --git a/src/lib/proposalForm.test.js b/src/lib/proposalForm.test.js
index 16833b74..02008fe2 100644
--- a/src/lib/proposalForm.test.js
+++ b/src/lib/proposalForm.test.js
@@ -92,13 +92,16 @@ describe('fromDraft', () => {
paymentAddress: 'sys1qabc',
paymentAmountSats: '150000000', // 1.5 SYS
paymentCount: 12,
+ // Legacy draft epochs — intentionally dropped by fromDraft since
+ // the wizard re-derives them at /prepare time from a live
+ // next-superblock anchor. Asserted below.
startEpoch: 1800000000,
endEpoch: 1802592000,
});
expect(form.paymentAmount).toBe('1.5');
expect(form.paymentCount).toBe('12');
- expect(form.startEpoch).toBe('1800000000');
- expect(form.endEpoch).toBe('1802592000');
+ expect(form).not.toHaveProperty('startEpoch');
+ expect(form).not.toHaveProperty('endEpoch');
});
test('uses explicit paymentAmount string when backend already formatted it', () => {
@@ -155,74 +158,35 @@ describe('validatePayment', () => {
paymentAddress: 'sys1qabcdefghij1234567890',
paymentAmount: '150',
paymentCount: '1',
- startEpoch: '1900000000',
- endEpoch: '1902592000',
};
test('happy path', () => {
- expect(validatePayment(base, { nowSec: 1800000000 })).toEqual({});
+ expect(validatePayment(base)).toEqual({});
});
test('flags non-address payment string', () => {
expect(
- validatePayment({ ...base, paymentAddress: 'not-an-address' }, { nowSec: 1 })
+ validatePayment({ ...base, paymentAddress: 'not-an-address' })
).toHaveProperty('paymentAddress');
});
test('flags zero / negative / malformed amounts', () => {
- expect(validatePayment({ ...base, paymentAmount: '0' }, { nowSec: 1 }))
+ expect(validatePayment({ ...base, paymentAmount: '0' }))
.toMatchObject({ paymentAmount: expect.stringMatching(/greater than zero/i) });
- expect(validatePayment({ ...base, paymentAmount: '-5' }, { nowSec: 1 }))
+ expect(validatePayment({ ...base, paymentAmount: '-5' }))
.toHaveProperty('paymentAmount');
- expect(validatePayment({ ...base, paymentAmount: 'foo' }, { nowSec: 1 }))
+ expect(validatePayment({ ...base, paymentAmount: 'foo' }))
.toHaveProperty('paymentAmount');
});
- test('flags payment count outside [1, MAX]', () => {
- expect(validatePayment({ ...base, paymentCount: '0' }, { nowSec: 1 }))
+ test('flags duration (paymentCount) outside [1, MAX]', () => {
+ expect(validatePayment({ ...base, paymentCount: '0' }))
.toHaveProperty('paymentCount');
- expect(validatePayment({ ...base, paymentCount: String(MAX_PAYMENT_COUNT + 1) }, { nowSec: 1 }))
+ expect(validatePayment({ ...base, paymentCount: String(MAX_PAYMENT_COUNT + 1) }))
.toHaveProperty('paymentCount');
- expect(validatePayment({ ...base, paymentCount: '1.5' }, { nowSec: 1 }))
+ expect(validatePayment({ ...base, paymentCount: '1.5' }))
.toHaveProperty('paymentCount');
});
-
- test('flags missing epochs and end <= start', () => {
- expect(validatePayment({ ...base, startEpoch: '', endEpoch: '' }, { nowSec: 1 }))
- .toMatchObject({
- startEpoch: expect.any(String),
- endEpoch: expect.any(String),
- });
- expect(
- validatePayment(
- { ...base, startEpoch: '1800000000', endEpoch: '1800000000' },
- { nowSec: 1 }
- )
- ).toHaveProperty('endEpoch');
- expect(
- validatePayment(
- { ...base, startEpoch: '1800000000', endEpoch: '1700000000' },
- { nowSec: 1 }
- )
- ).toHaveProperty('endEpoch');
- });
-
- test('flags start in the past (beyond a 60s grace)', () => {
- const nowSec = 1_800_000_000;
- expect(
- validatePayment(
- { ...base, startEpoch: String(nowSec - 3600), endEpoch: String(nowSec + 3600) },
- { nowSec }
- )
- ).toHaveProperty('startEpoch');
- // Within grace → ok
- expect(
- validatePayment(
- { ...base, startEpoch: String(nowSec - 30), endEpoch: String(nowSec + 3600) },
- { nowSec }
- )
- ).not.toHaveProperty('startEpoch');
- });
});
describe('estimatePayloadBytes', () => {
@@ -232,8 +196,6 @@ describe('estimatePayloadBytes', () => {
url: 'https://syscoin.org/proposals/2026-01/fund-docs.md',
paymentAddress: 'sys1qxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
paymentAmount: '150',
- startEpoch: '1900000000',
- endEpoch: '1902592000',
});
expect(bytes).toBeLessThan(512);
expect(bytes).toBeGreaterThan(100);
@@ -245,16 +207,12 @@ describe('estimatePayloadBytes', () => {
url: 'https://a.test',
paymentAddress: 'sys1abc',
paymentAmount: '1',
- startEpoch: '1',
- endEpoch: '2',
});
const large = estimatePayloadBytes({
name: 'x',
url: 'https://a.test/' + 'a'.repeat(200),
paymentAddress: 'sys1abc',
paymentAmount: '1',
- startEpoch: '1',
- endEpoch: '2',
});
expect(large).toBeGreaterThan(small + 150);
});
@@ -274,37 +232,30 @@ describe('estimatePayloadBytes', () => {
url: 'https://syscoin.org/p/huge',
paymentAddress: 'sys1qexample',
paymentAmount: amountDecimal,
- startEpoch: '1900000000',
- endEpoch: '1902592000',
});
- // Full decimal (22 chars) is noticeably larger than sci-form
- // "1.2345678901231234e+21" (22 chars — similar) so we also
- // assert on a smaller precision case that clearly diverges:
- // a huge integer coerces to "1.23456789012e+21"-ish whereas
- // the real decimal "1234567890123" keeps all 13 digits.
const bytesSmallFrac = estimatePayloadBytes({
name: 'huge-ask',
url: 'https://syscoin.org/p/huge',
paymentAddress: 'sys1qexample',
paymentAmount: '1234567890123',
- startEpoch: '1900000000',
- endEpoch: '1902592000',
});
// Sanity bound: the fractional variant is at least as large
// as the integer variant (more chars in payment_amount).
expect(bytes).toBeGreaterThanOrEqual(bytesSmallFrac);
// The critical invariant: the byte-count must reflect the
- // FULL canonical decimal. We reconstruct what the backend's
- // canonical emitter produces and compare via a crude
- // string-inclusion check (the JSON stringify passes through
- // the same text). Because we use TextEncoder for bytes, we
- // validate the underlying JSON contains the literal string.
+ // FULL canonical decimal. We reconstruct what the estimator
+ // produces (constant 10-digit epoch placeholders; see
+ // proposalForm.estimatePayloadBytes) and compare via a
+ // TextEncoder byte count. Because the estimator's JSON
+ // contains the literal amount string, scientific notation
+ // must be absent.
+ const EPOCH_PLACEHOLDER = 1_900_000_000;
const probe = JSON.stringify({
type: 1,
name: 'huge-ask',
- start_epoch: 1900000000,
- end_epoch: 1902592000,
+ start_epoch: EPOCH_PLACEHOLDER,
+ end_epoch: EPOCH_PLACEHOLDER,
payment_address: 'sys1qexample',
payment_amount: amountDecimal,
url: 'https://syscoin.org/p/huge',
@@ -369,17 +320,15 @@ describe('draftBodyFromForm + prepareBodyFromForm', () => {
url: '', // user cleared this
paymentAddress: '', // and this
paymentAmount: '1',
- startEpoch: '',
- endEpoch: '',
},
{ forUpdate: true }
);
expect(body.name).toBe('still has name');
expect(body.url).toBe('');
expect(body.paymentAddress).toBe('');
- // Epochs use explicit null (backend accepts null as clear;
- // `Math.trunc(Number(''))` would be 0, which fails the
- // start/end validators as a garbage value).
+ // Epochs are always cleared on update now — they are no longer
+ // user-editable and are re-derived at /prepare time from a live
+ // next-superblock anchor.
expect(body.startEpoch).toBeNull();
expect(body.endEpoch).toBeNull();
}
@@ -443,4 +392,23 @@ describe('draftBodyFromForm + prepareBodyFromForm', () => {
expect(body).not.toHaveProperty('draftId');
expect(body).not.toHaveProperty('consumeDraft');
});
+
+ test('prepareBodyFromForm injects the derived window when provided', () => {
+ const body = prepareBodyFromForm(
+ { ...emptyForm(), name: 'x' },
+ { window: { startEpoch: 1900000000, endEpoch: 1902628000 } }
+ );
+ expect(body.startEpoch).toBe(1900000000);
+ expect(body.endEpoch).toBe(1902628000);
+ });
+
+ test('prepareBodyFromForm omits epochs when no window is provided', () => {
+ // Callers are expected to always supply `window` for the wizard
+ // flow; the backend will reject /prepare without epochs. Keeping
+ // the helper lenient here so tests can probe the bare body shape
+ // without hand-rolling every field.
+ const body = prepareBodyFromForm({ ...emptyForm(), name: 'x' });
+ expect(body).not.toHaveProperty('startEpoch');
+ expect(body).not.toHaveProperty('endEpoch');
+ });
});
diff --git a/src/pages/NewProposal.js b/src/pages/NewProposal.js
index de0e6b66..b43f1b8b 100644
--- a/src/pages/NewProposal.js
+++ b/src/pages/NewProposal.js
@@ -14,6 +14,15 @@ import PayWithPaliPanel, {
} from '../components/PayWithPaliPanel';
import UnsavedChangesModal from '../components/UnsavedChangesModal';
import { useAuth } from '../context/AuthContext';
+import { fetchNetworkStats } from '../lib/api';
+import {
+ SUPERBLOCK_CYCLE_SEC,
+ SUPERBLOCK_MATURITY_WINDOW_SEC,
+ anchorsAreSameSuperblock,
+ computeProposalWindow,
+ isTightVotingWindow,
+ nextSuperblockEpochSecFromStats,
+} from '../lib/governanceWindow';
import { proposalService } from '../lib/proposalService';
import {
COLLATERAL_FEE_SATS,
@@ -148,6 +157,11 @@ function describePrepareError(err) {
return 'Network hiccup. Check your connection and try again.';
case 'http_error':
return 'The server returned an error. Try again in a moment.';
+ case 'stats_unavailable':
+ case 'anchor_drift':
+ // Messages for these two are authored in onPrepare so they can
+ // quote the exact recovery action inline. Codex PR20 round 2 P2.
+ return err.message || 'Live superblock timing unavailable. Please retry.';
default:
return err.code || 'Unknown error.';
}
@@ -196,6 +210,107 @@ export default function NewProposal() {
// toast-like feedback.
const [draftSavedAt, setDraftSavedAt] = useState(0);
+ // Live next-superblock anchor. We fetch the backend /mnStats feed
+ // on mount and extract `superblock_stats.superblock_next_epoch_sec`
+ // so that `computeProposalWindow` can align the derived start/end
+ // epochs to the real chain. Loading / error states gate the
+ // "Prepare proposal" button — submitting without a live anchor
+ // risks a window that doesn't cover the first superblock or
+ // accidentally fits an extra one.
+ const [nextSuperblockSec, setNextSuperblockSec] = useState(null);
+ const [statsError, setStatsError] = useState(null);
+ const [statsLoading, setStatsLoading] = useState(true);
+ // Monotonic counter tracking the "latest issued /mnStats request".
+ // Each refreshStats invocation takes a snapshot of its own id and
+ // only mutates anchor state if that id is still current when the
+ // response resolves. Prevents out-of-order responses from clobbering
+ // fresher state: e.g. the mount fetch can finish after a manual
+ // Retry click, and without a guard its (potentially stale) response
+ // would overwrite the Retry's successful anchor and re-disable
+ // Prepare. useRef instead of state because we want synchronous
+ // sequencing without triggering re-renders (Codex PR20 round 5 P2).
+ const statsReqIdRef = useRef(0);
+ const refreshStats = useCallback(() => {
+ const reqId = ++statsReqIdRef.current;
+ setStatsLoading(true);
+ setStatsError(null);
+ const isCurrent = () => reqId === statsReqIdRef.current;
+ fetchNetworkStats()
+ .then((stats) => {
+ if (!isCurrent()) return;
+ // `nowSec` is captured at response time, not at effect
+ // mount, so we reject anchors that are already in the past
+ // relative to the clock the user will also see in the
+ // Review schedule.
+ const anchor = nextSuperblockEpochSecFromStats(
+ stats,
+ Math.floor(Date.now() / 1000)
+ );
+ if (!anchor) {
+ setStatsError(new Error('missing_next_superblock_epoch'));
+ setNextSuperblockSec(null);
+ return;
+ }
+ setNextSuperblockSec(anchor);
+ })
+ .catch((err) => {
+ if (!isCurrent()) return;
+ setStatsError(err);
+ setNextSuperblockSec(null);
+ })
+ .finally(() => {
+ if (isCurrent()) setStatsLoading(false);
+ });
+ return () => {
+ // Invalidate this specific request by advancing past it iff
+ // we're still the latest. Any newer refreshStats call has
+ // already bumped the counter on its own, so the no-op branch
+ // here is correct.
+ if (isCurrent()) statsReqIdRef.current += 1;
+ };
+ }, []);
+ useEffect(() => {
+ const cancel = refreshStats();
+ return cancel;
+ }, [refreshStats]);
+
+ // Periodic wall-clock tick so the render-time `isAnchorLive`
+ // predicate below (and all date displays derived from it) stay
+ // consistent with the wall clock while the wizard sits idle.
+ // Without a tick, a user who reaches Review and then leaves the
+ // tab open past `superblock_next_epoch_sec` would keep seeing
+ // the stale anchor as "live" (WindowPreview card, schedule
+ // dates, Prepare button), while `computeProposalWindow` has
+ // already switched to its internal `now + cycle` fallback —
+ // the displayed and submitted windows can then diverge. The
+ // tick does NOT refetch network stats; it just forces a
+ // re-render so the gates re-evaluate against a fresh clock.
+ // 30 s cadence is fine-grained enough for testnet's 2.5 h
+ // cycle without meaningful render cost (Codex PR20 round 6 P2).
+ const [, forceTick] = useReducer((x) => x + 1, 0);
+ useEffect(() => {
+ const id = setInterval(forceTick, 30_000);
+ return () => clearInterval(id);
+ }, []);
+
+ // True only when /mnStats gave us a future superblock anchor.
+ // Used consistently by WindowPreview, the Prepare-button gate,
+ // and the ReviewStep schedule so all three flip together the
+ // instant the cached anchor goes stale. Mere truthiness is
+ // insufficient — a cached anchor that has already passed is
+ // still finite and > 0 but no longer describes the chain's
+ // next payout slot, and letting it drive the UI would print a
+ // schedule that doesn't match the window Prepare would submit
+ // (Codex PR20 round 6 P2). The stale-to-live recovery path is
+ // user-initiated: Prepare's pre-submit refresh + the Retry
+ // button on the stats-error banner; we don't auto-refetch
+ // here because a setTimeout large enough to span mainnet's
+ // ~30-day horizon overflows Node's int32 delay (silently
+ // collapses to 1 ms and fires immediately).
+ const nowSecForAnchor = Math.floor(Date.now() / 1000);
+ const isAnchorLive =
+ Number.isFinite(nextSuperblockSec) && nextSuperblockSec > nowSecForAnchor;
+
const dirty = !formsEqual(form, baseline) && prepared == null;
// ---- Draft load -------------------------------------------------------
@@ -375,6 +490,31 @@ export default function NewProposal() {
);
const payloadBytes = useMemo(() => estimatePayloadBytes(form), [form]);
+ // Derive the proposal's on-chain payment window purely from the
+ // user's duration input + the live next-superblock anchor. We
+ // show this to the user in both the Payment step (as a preview
+ // under the duration input) and the Review step (as the
+ // authoritative "Voting window" the backend will submit). The
+ // same formula runs again inside onPrepare with a freshly-
+ // fetched anchor, so any drift between this preview and the
+ // actual submission is bounded by how much wall-clock time
+ // passes between render and Prepare — essentially zero for the
+ // 0-second-old cached anchor and at most one `fetchNetworkStats`
+ // round-trip otherwise.
+ const derivedWindow = useMemo(() => {
+ const n = Math.floor(Number(form.paymentCount));
+ if (!Number.isInteger(n) || n < 1) return null;
+ try {
+ return computeProposalWindow({
+ durationMonths: n,
+ nowSec: Math.floor(Date.now() / 1000),
+ nextSuperblockSec,
+ });
+ } catch (_e) {
+ return null;
+ }
+ }, [form.paymentCount, nextSuperblockSec]);
+
// Which form fields belong to each step. Used by the "Next"
// handler to mark every field on the current step as touched
// when validation fails, so inline red borders + hint text
@@ -387,13 +527,7 @@ export default function NewProposal() {
const STEP_FIELDS = useMemo(
() => ({
basics: ['name', 'url'],
- payment: [
- 'paymentAddress',
- 'paymentAmount',
- 'paymentCount',
- 'startEpoch',
- 'endEpoch',
- ],
+ payment: ['paymentAddress', 'paymentAmount', 'paymentCount'],
}),
[]
);
@@ -583,9 +717,125 @@ export default function NewProposal() {
setPreparing(true);
setPrepareError(null);
try {
+ // Derive the on-chain payment window at submit time from the
+ // user's duration input (months). We anchor to the current
+ // next-superblock epoch rather than freezing this at draft
+ // time so a draft resumed days later still points at the
+ // correct cycle. See lib/governanceWindow.js for the full
+ // derivation.
+ const durationMonths = Math.floor(Number(form.paymentCount));
+
+ // Re-fetch the live next-SB anchor right before submission
+ // and FAIL CLOSED on anything that would let the submitted
+ // window diverge from what the user saw on Review:
+ //
+ // (a) fetch throws (transport error, 5xx, timeout) → surface
+ // the stats-unavailable banner, clear the cached anchor
+ // so Prepare stays disabled until refreshStats() recovers.
+ // (b) fetch returns a stale or missing anchor
+ // (next_SB epoch <= now) → same as (a). The /mnStats
+ // source occasionally lags a few blocks behind the tip
+ // and we refuse to submit against a backward-pointing
+ // anchor for the same reason.
+ // (c) fetch returns a DIFFERENT future anchor than the one
+ // Review rendered from (a superblock passed while the
+ // wizard was open) → update state so the preview card
+ // rerenders with the new schedule, and surface an
+ // "anchor drift" message asking the user to re-review
+ // before clicking Prepare again. We do NOT submit the
+ // new window under the user — they reviewed the old one.
+ //
+ // Codex PR20 round 2 P2: the previous implementation caught
+ // (a) silently, kept the cached anchor, and let
+ // computeProposalWindow fall back to `now + cycle` if the
+ // cached anchor had also gone stale. That path could ship a
+ // window a full cycle off from the reviewed schedule and
+ // burn collateral on a proposal whose effective payout
+ // window had shifted. All three branches now short-circuit
+ // before prepareBodyFromForm / proposalService.prepare.
+ //
+ // Codex PR20 round 3 P2: the wall-clock cutoff used to validate
+ // the refreshed anchor must be read AFTER the fetch resolves,
+ // not before. /mnStats is a real network RTT (plus jsdom /
+ // proxy / slow-node delays in practice) and can straddle the
+ // actual superblock transition; in that window an anchor that
+ // was strictly future at pre-await time can already be in the
+ // past by the time we use it. Capturing `nowSec` pre-await
+ // allowed `nextSuperblockEpochSecFromStats` to validate a
+ // just-passed anchor as live and silently ship payouts off
+ // by one cycle (N → N-1 months). Reading the clock post-await
+ // closes that window; the same post-await `nowSec` is also
+ // passed into `computeProposalWindow` so its stale-fallback
+ // branch sees a consistent view of the clock.
+ let liveAnchor = null;
+ let refreshErr = null;
+ let nowSec;
+ try {
+ const freshStats = await fetchNetworkStats();
+ nowSec = Math.floor(Date.now() / 1000);
+ liveAnchor = nextSuperblockEpochSecFromStats(freshStats, nowSec);
+ if (!liveAnchor) {
+ refreshErr = new Error('stale_superblock_anchor');
+ }
+ } catch (err) {
+ refreshErr = err;
+ }
+ if (refreshErr) {
+ setStatsError(refreshErr);
+ setNextSuperblockSec(null);
+ setPrepareError(
+ Object.assign(
+ new Error(
+ 'Could not confirm live superblock timing from the node. ' +
+ 'Please wait a moment and Prepare again — the wizard will ' +
+ 'not submit with a potentially stale voting window.'
+ ),
+ { code: 'stats_unavailable' }
+ )
+ );
+ return;
+ }
+ // Compare the refreshed anchor against the cached one the
+ // user just reviewed. We CANNOT use strict equality — the
+ // backend's /mnStats recomputes `superblock_next_epoch_sec`
+ // every sysMain tick (20 s) as `now + diffBlock *
+ // avgBlockTime`, so the value drifts by seconds/minutes
+ // between fetches even when the same upcoming superblock is
+ // still the target. Under strict equality users could get
+ // stuck looping through re-review prompts on every attempted
+ // submit (Codex PR20 round 4 P1). `anchorsAreSameSuperblock`
+ // applies a `cycle/2` tolerance — any legitimate rotation
+ // advances the anchor by ≈ cycle, well above the threshold.
+ if (!anchorsAreSameSuperblock(liveAnchor, nextSuperblockSec)) {
+ // Chain advanced a cycle while the wizard was open. The
+ // fresh anchor is fine, but the user reviewed a schedule
+ // built from the previous anchor. Sync state so the
+ // WindowPreview + schedule re-render, and force a second
+ // Prepare click so they commit collateral to a window
+ // they actually saw.
+ setNextSuperblockSec(liveAnchor);
+ setPrepareError(
+ Object.assign(
+ new Error(
+ 'Chain timing updated while this wizard was open — the ' +
+ 'voting window has been refreshed to match the next ' +
+ 'superblock. Please re-check the updated window above, ' +
+ 'then click Prepare again to submit.'
+ ),
+ { code: 'anchor_drift' }
+ )
+ );
+ return;
+ }
+ const windowSpec = computeProposalWindow({
+ durationMonths,
+ nowSec,
+ nextSuperblockSec: liveAnchor,
+ });
const body = prepareBodyFromForm(form, {
draftId: draftId || undefined,
consumeDraft: true,
+ window: windowSpec,
});
const envelope = await proposalService.prepare(body);
setPrepared(envelope);
@@ -780,10 +1030,25 @@ export default function NewProposal() {
onField={setField}
onBlur={markTouched}
payloadBytes={payloadBytes}
+ derivedWindow={derivedWindow}
+ nextSuperblockSec={nextSuperblockSec}
+ isAnchorLive={isAnchorLive}
+ statsLoading={statsLoading}
+ statsError={statsError}
+ onRetryStats={refreshStats}
/>
) : null}
{currentStep === 'review' ? (
-
+
) : null}
{currentStep === 'submit' ? (
MAX_DATA_SIZE ||
Object.keys(basicsErrors).length > 0 ||
- Object.keys(paymentErrors).length > 0
+ Object.keys(paymentErrors).length > 0 ||
+ // Gate Prepare on having a live next-superblock
+ // anchor. Without one we would fall back to the
+ // "now + cycle" worst-case branch of
+ // computeProposalWindow, which is safe for Core
+ // (window is still long enough) but can mis-align
+ // the displayed voting dates by up to a cycle. Ask
+ // the user to retry rather than submit an
+ // unanchored window silently.
+ !derivedWindow ||
+ !isAnchorLive
}
data-testid="wizard-prepare"
+ title={
+ !isAnchorLive
+ ? 'Waiting for live superblock timing…'
+ : undefined
+ }
>
{preparing ? 'Preparing…' : 'Prepare proposal'}
@@ -984,14 +1264,29 @@ function BasicsStep({ form, errors, touched, onField, onBlur, payloadBytes }) {
);
}
-function PaymentStep({ form, errors, touched, onField, onBlur, payloadBytes }) {
+function PaymentStep({
+ form,
+ errors,
+ touched,
+ onField,
+ onBlur,
+ payloadBytes,
+ derivedWindow,
+ nextSuperblockSec,
+ isAnchorLive,
+ statsLoading,
+ statsError,
+ onRetryStats,
+}) {
return (
Payment details
Specify the Syscoin address that will receive the monthly
- superblock payment, the amount per month, and the voting
- window. Start and end times are UTC epoch seconds.
+ superblock payment, the amount per month, and how many
+ months the proposal should run. The on-chain voting window
+ is derived automatically from the duration so it aligns
+ with the next superblock and prunes cleanly.
+
);
}
-// Approximate cadence of Syscoin governance superblocks — 30 days,
-// roughly. Mainnet consensus sets `nSuperblockCycle = 17520` blocks
-// at a 150-second (2.5-minute) block target, which works out to
-// 17520 * 150 = 2,628,000 s ≈ 30.42 days. The REAL gap between any
-// two superblocks still drifts a few hours either way depending on
-// network hash-rate, so we surface this number as a planning-grade
-// estimate ONLY; pairing every rendered date with a "~" prefix and
-// a caveat in the copy keeps us honest.
+// Compact "Xd Yh Zm" formatter for how-long-until-next-superblock
+// copy in the tight-voting-window notice. Sub-minute granularity
+// would imply precision we don't have (the backend's anchor is
+// itself a projection of `(nNextSuperblock - nHeight) * 150s`),
+// so we stop at whole minutes. Null guard returns an empty string
+// so the caller can concatenate safely.
+function humanizeDurationShort(totalSec) {
+ if (!Number.isFinite(totalSec) || totalSec <= 0) return '';
+ const days = Math.floor(totalSec / 86400);
+ const hours = Math.floor((totalSec % 86400) / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const parts = [];
+ if (days > 0) parts.push(`${days}d`);
+ if (hours > 0) parts.push(`${hours}h`);
+ if (days === 0 && mins > 0) parts.push(`${mins}m`);
+ if (parts.length === 0) parts.push('< 1m');
+ return parts.join(' ');
+}
+
+// Prominent banner shown on Payment + Review whenever the next
+// superblock is inside the wizard's warning buffer
+// (SUPERBLOCK_VOTE_DEADLINE_WARN_SEC, currently 4 days).
+//
+// Why it matters: Core forms the superblock payment-list
+// candidate in the last ~3 days before the superblock
+// (`nSuperblockMaturityWindow` = 1728 blocks). Each masternode
+// independently picks a candidate, votes YES-FUNDING on it,
+// and is then locked out of voting YES on any other trigger for
+// this cycle (governance.cpp:727 asserts this). So a proposal
+// submitted inside that 3-day window is racing masternode
+// commits, and any MN that has already committed cannot
+// retroactively include it. Our window intentionally excludes
+// SB_{N+1} to prevent silent over-payment, so missing SB_1
+// means the proposal pays out N-1 months instead of N.
//
-// Kept as a module constant (not a backend-fed value) because the
-// wizard is a pre-submission planning surface — the user is
-// estimating what their proposal would look like, not querying the
-// live chain. Once the proposal is actually on-chain, the
-// ProposalStatus page shows the live `start_epoch`/`end_epoch`
-// from Core instead.
-const SUPERBLOCK_INTERVAL_DAYS = 30;
-const DAY_SECONDS = 86400;
+// We fire the warning a day earlier than Core's 3-day maturity
+// threshold to give MN operators at least ~24h of headroom
+// between proposal submission and the earliest MN commit
+// (covers collateral confirmation, relay, operator review,
+// and vote propagation).
+//
+// Non-blocking by design: Prepare stays enabled. Some proposers
+// are fine with N-1 (e.g. emergency funding), and we don't want
+// to override their intent — we just make sure they see the
+// trade-off before submitting.
+function TightVotingWindowNotice({ nextSuperblockSec, paymentCount }) {
+ const nowSec = Math.floor(Date.now() / 1000);
+ if (!isTightVotingWindow(nowSec, nextSuperblockSec)) return null;
+ const secondsToSb = nextSuperblockSec - nowSec;
+ const n = Math.floor(Number(paymentCount));
+ const hasValidCount = Number.isInteger(n) && n >= 1;
+ // If the user only asked for 1 month, there's nothing to
+ // demote to (N-1 = 0), so the honest message is just "will
+ // likely miss that superblock".
+ const willLoseOnePayment = hasValidCount && n >= 2;
+ // Network-derived copy. On mainnet this renders "~3d" + "~30d"
+ // matching the original hardcoded UX; on testnet/regtest the
+ // same computation gives the actual, much shorter maturity +
+ // cycle values so the guidance stays numerically correct
+ // across every supported network. Codex PR20 round 4 P3:
+ // hardcoding "~3 days" / "~30 days" made the copy mislead
+ // non-mainnet deployments by orders of magnitude and could
+ // drive incorrect operator decisions.
+ const maturityLabel = humanizeDurationShort(SUPERBLOCK_MATURITY_WINDOW_SEC);
+ const cycleLabel = humanizeDurationShort(SUPERBLOCK_CYCLE_SEC);
+ return (
+
+
+ Tight voting window — masternodes may not have time to vote
+
+
+ The next superblock is in{' '}
+ {humanizeDurationShort(secondsToSb)}. Syscoin
+ Core forms the superblock payment list during the last{' '}
+ ~{maturityLabel} before each superblock, and once a masternode
+ has voted on that payment list it cannot change its vote
+ for this cycle. Submitting now means your proposal{' '}
+ will likely miss that superblock
+ {willLoseOnePayment ? (
+ <>
+ {' '}
+ and pay out{' '}
+
+ {n - 1} month{n - 1 === 1 ? '' : 's'}
+ {' '}
+ instead of {n}
+ >
+ ) : null}
+ .
+
+
+ If you need the full duration you requested, consider
+ waiting for the next superblock cycle (~{cycleLabel}) so
+ masternodes have time to see and vote on the proposal
+ before the next payment list locks in. You can also
+ proceed anyway — Prepare is still enabled — if you've
+ coordinated with operators or are OK with one fewer
+ payment.
+
+
+ );
+}
+
+// Shared renderer for the derived voting window. Used under the
+// duration input on the Payment step AND as the authoritative
+// "Voting window" entry on the Review step. Keeping both call
+// sites on the same component guarantees they render identical
+// dates — which is exactly what the user will see on the live
+// chain once the proposal goes through.
+function WindowPreview({
+ derivedWindow,
+ nextSuperblockSec,
+ isAnchorLive,
+ statsLoading,
+ statsError,
+ onRetryStats,
+}) {
+ // Treat `!isAnchorLive` as equivalent to "no anchor" — a cached
+ // anchor whose timestamp has already elapsed is not usable as a
+ // live reference (Codex PR20 round 6 P2). The wall-clock tick
+ // in NewProposal forces a re-render every 30 s so this branch
+ // flips the moment `superblock_next_epoch_sec` passes without
+ // relying on an external state update.
+ if (statsLoading && !isAnchorLive) {
+ return (
+
+ Couldn't fetch the next-superblock time from the backend.
+ We need it to anchor the proposal's payment window.{' '}
+ {onRetryStats ? (
+
+ ) : null}
+
+
+ );
+ }
+ if (!derivedWindow) {
+ return (
+
+ Voting window
+
+ Enter a valid duration to see the derived voting window.
+
+
+ );
+ }
+ return (
+
+ Voting window (auto)
+
+ Start:{' '}
+
+
+ End:{' '}
+
+
+
+ Anchored to the next superblock (
+ {new Date(nextSuperblockSec * 1000).toUTCString()}). The start
+ is placed ~{humanizeDurationShort(Math.floor(SUPERBLOCK_CYCLE_SEC / 2))}{' '}
+ before the first payout and the end ~{humanizeDurationShort(Math.floor(SUPERBLOCK_CYCLE_SEC / 2))}{' '}
+ after the last so each payment lands safely inside the window
+ and the proposal prunes cleanly afterwards.
+
+
+ );
+}
// Format a UNIX-seconds timestamp as a short UTC calendar date. We
// intentionally drop the time portion in the schedule breakdown
-// because the approximation error (~several hours) is larger than
-// the clock portion would imply precision for.
+// because the superblock-to-wall-clock projection drifts by a few
+// hours and the clock portion would imply false precision.
function formatUtcDate(epochSec) {
if (!Number.isFinite(epochSec) || epochSec <= 0) return '';
try {
@@ -1167,52 +1607,44 @@ function formatUtcDate(epochSec) {
}
}
-// Build an APPROXIMATE schedule of payment dates for a proposal's
-// Review step. Returns one entry per payment, each projected to
-// the NEXT superblock after `startEpoch`. Capped by the voting
-// window: we never project a payment date past `endEpoch` because
-// the proposal's window says Core won't pay it beyond that point.
-// Negative / invalid inputs return an empty array so callers can
-// just gate on `.length`.
+// Build the projected payment schedule for the Review step.
//
-// Why we step by (i + 1), not i:
+// The schedule is derived from the same `derivedWindow` the user
+// sees in the WindowPreview card (and that onPrepare submits on-
+// chain), not from the raw `nextSuperblockSec` state. This matters:
+// computeProposalWindow has an internal fallback to `now + cycle`
+// when the anchor is missing / stale, while a raw-anchor projection
+// would happily run off a stale timestamp. Sourcing both UI
+// elements from the same canonical window means the Review
+// schedule can never diverge from the submitted window, even if
+// state were to drift stale between renders.
//
-// Syscoin governance payouts are executed on superblocks — not
-// at `startEpoch` itself. The first payable point is the next
-// superblock that lands at or after `startEpoch`, not the raw
-// start timestamp. The frontend has no network anchor for
-// superblock timing, so we use the conservative worst-case
-// model: assume the next superblock is exactly
-// `SUPERBLOCK_INTERVAL_DAYS` after startEpoch. That matches
-// real consensus behavior when startEpoch happens to be right
-// after a superblock (the most common case when a user has
-// just dragged a date picker forward), and it AVOIDS the
-// bug Codex flagged on round 3: treating payment #1 as if it
-// lands on `startEpoch` overestimates how many payments fit
-// inside the voting window, which in turn hides the
-// truncation warning for proposals that can't actually pay
-// out every requested installment before `endEpoch`.
+// The anchor for the first payment comes directly from
+// computeProposalWindow's returned `anchor` field — the same
+// value it uses to place `startEpoch = anchor - padding`. The
+// loop then walks forward by whole cycles for superblocks #1..#N.
+// Unlike the legacy approximator this never needs a truncation
+// warning — if derivedWindow is truthy the window fits N payments
+// by construction (see lib/governanceWindow.js).
//
-// The tradeoff: for the rare case where `startEpoch` is
-// moments before a superblock, we show payment #1 one
-// interval later than reality — erring safely toward a user
-// seeing more truncation warnings, not fewer. A false
-// "won't fit, extend the window" warning costs one picker
-// drag; a silent omission costs a proposal that can't
-// actually pay all installments.
-function buildApproximateSchedule({ startEpoch, endEpoch, paymentCount }) {
- const start = Number(startEpoch);
- const end = Number(endEpoch);
+// We used to reconstruct the anchor as `startEpoch + cycle/2`,
+// which was correct on mainnet where padding IS cycle/2 but
+// broke on networks where computeProposalWindow clamps padding
+// below cycle/2 (testnet: padding 1740 s vs cycle/2 4500 s). The
+// reconstruction shifted every projected row forward by
+// `cycle/2 - padding`, pushing row #N past `endEpoch` and making
+// the Review schedule disagree with the window actually sent to
+// `/prepare`. Reading `derivedWindow.anchor` directly keeps the
+// two in lockstep regardless of network (Codex PR20 round 7 P2).
+function buildProjectedSchedule({ derivedWindow, paymentCount }) {
+ if (!derivedWindow) return [];
+ const anchor = Number(derivedWindow.anchor);
const count = Math.floor(Number(paymentCount));
- if (!Number.isFinite(start) || start <= 0) return [];
- if (!Number.isFinite(end) || end <= start) return [];
+ if (!Number.isFinite(anchor) || anchor <= 0) return [];
if (!Number.isFinite(count) || count <= 0) return [];
- const step = SUPERBLOCK_INTERVAL_DAYS * DAY_SECONDS;
const out = [];
for (let i = 0; i < count; i += 1) {
- const at = start + (i + 1) * step;
- if (at > end) break;
- out.push({ index: i + 1, epochSec: at });
+ out.push({ index: i + 1, epochSec: anchor + i * SUPERBLOCK_CYCLE_SEC });
}
return out;
}
@@ -1244,17 +1676,38 @@ function computeTotalBudgetSys(amount, count) {
}
}
-function ReviewStep({ form, payloadBytes }) {
+function ReviewStep({
+ form,
+ payloadBytes,
+ derivedWindow,
+ nextSuperblockSec,
+ isAnchorLive,
+ statsLoading,
+ statsError,
+ onRetryStats,
+}) {
const paymentCountNum = Number(form.paymentCount);
const totalBudgetSys = computeTotalBudgetSys(
form.paymentAmount,
form.paymentCount
);
+ // Only render the projected schedule when we have a live next-SB
+ // anchor from /mnStats. `derivedWindow` alone is insufficient:
+ // computeProposalWindow falls back to `now + cycle` when the anchor
+ // is missing/stale, which would paint a plausible-looking list of
+ // payout dates that don't match the chain. WindowPreview already
+ // suppresses itself on the same `isAnchorLive` predicate; the
+ // schedule row must stay in lockstep or Review shows synthetic
+ // timing alongside a "live data unavailable" banner (Codex PR20
+ // round 4 P2 + round 6 P2). The predicate is a boolean prop
+ // computed once in NewProposal against the wall clock (with a
+ // 30 s periodic re-render tick), so a cached anchor that has
+ // already elapsed is treated as not-live even if it's still a
+ // positive finite number.
const schedule =
- paymentCountNum >= 2
- ? buildApproximateSchedule({
- startEpoch: form.startEpoch,
- endEpoch: form.endEpoch,
+ paymentCountNum >= 2 && derivedWindow && isAnchorLive
+ ? buildProjectedSchedule({
+ derivedWindow,
paymentCount: paymentCountNum,
})
: [];
@@ -1279,75 +1732,70 @@ function ReviewStep({ form, payloadBytes }) {
- Governance payouts are paid on Syscoin superblocks —
- roughly every {SUPERBLOCK_INTERVAL_DAYS} days — and
- the first payable superblock is the one that lands{' '}
- after your start date. The dates below are
- worst-case (one full cycle past start) so your voting
- window has room even when start lands right after a
- superblock. Actual payout dates may run a bit earlier
- depending on when your proposal opens relative to the
- next superblock.
+ One payment per Syscoin superblock, starting at the
+ next superblock. Actual payout timing drifts a few hours
+ either way depending on network hash-rate; the voting
+ window leaves ~{humanizeDurationShort(Math.floor(SUPERBLOCK_CYCLE_SEC / 2))}{' '}
+ of margin on each side.
- {schedule.length === 0
- ? `Your voting window is too short to land any of the ${paymentCountNum} requested payments on a superblock. Extend the voting end date so each payment has a superblock to land in.`
- : `Your voting window only fits ${schedule.length} of the ${paymentCountNum} requested payments. Extend the voting end date, or reduce the payment count, so every payment has a superblock to land in.`}
-
) : null}
diff --git a/src/pages/NewProposal.test.js b/src/pages/NewProposal.test.js
index 3cad1073..e062b388 100644
--- a/src/pages/NewProposal.test.js
+++ b/src/pages/NewProposal.test.js
@@ -18,6 +18,30 @@ jest.mock('../lib/crypto/kdf', () => ({
deriveVaultKey: jest.fn(),
}));
+// Mock the network-stats fetcher that the wizard uses to anchor its
+// derived voting window. Every test that reaches the Payment or
+// Review step needs a stable next-superblock epoch; we default to
+// 30 days in the future so the derived window falls roughly in the
+// same shape the wizard would produce on mainnet (~15-day fudge
+// before start, ~15-day fudge after the last payment). Individual
+// tests can still override via `fetchNetworkStats.mockResolvedValueOnce`.
+//
+// Factory helpers MUST start with `mock` or Jest refuses to reference
+// out-of-scope identifiers (hoisting guard against stale capture).
+jest.mock('../lib/api', () => ({
+ __esModule: true,
+ fetchNetworkStats: jest.fn(() =>
+ Promise.resolve({
+ stats: {
+ superblock_stats: {
+ superblock_next_epoch_sec:
+ Math.floor(Date.now() / 1000) + 30 * 86400,
+ },
+ },
+ })
+ ),
+}));
+
jest.mock('../lib/proposalService', () => {
// Keep the named export of HEX64_RE live so the wizard's own client
// validation behaves as in production.
@@ -42,9 +66,29 @@ jest.mock('../lib/proposalService', () => {
/* eslint-disable import/first */
import NewProposal from './NewProposal';
import { AuthProvider } from '../context/AuthContext';
+import { fetchNetworkStats } from '../lib/api';
import { proposalService } from '../lib/proposalService';
/* eslint-enable import/first */
+// Stable next-superblock anchor captured fresh per-test in beforeEach
+// (see below). The wizard now fetches /mnStats BOTH on mount AND at
+// Prepare time and compares the two — if they differ it assumes the
+// chain advanced a cycle while the wizard was open and forces a
+// re-review instead of submitting. A mock that recomputes
+// `Date.now() + 30 days` on every call would cross the whole-second
+// boundary between mount and prepare and spuriously trigger the
+// drift branch, so we freeze a single value per test.
+let currentStableNextSb = 0;
+function defaultNetworkStatsResolver() {
+ return Promise.resolve({
+ stats: {
+ superblock_stats: {
+ superblock_next_epoch_sec: currentStableNextSb,
+ },
+ },
+ });
+}
+
function makeAuthService(user = { id: 42, email: 'alice@example.com' }) {
return {
me: jest.fn().mockResolvedValue({ user }),
@@ -107,7 +151,6 @@ function validBasics() {
}
function validPayment({ count = '1' } = {}) {
- const now = Math.floor(Date.now() / 1000);
fireEvent.change(screen.getByTestId('wizard-field-address'), {
target: { value: 'sys1qexampleexampleexampleexampleexampleaaaa' },
});
@@ -117,17 +160,20 @@ function validPayment({ count = '1' } = {}) {
fireEvent.change(screen.getByTestId('wizard-field-count'), {
target: { value: count },
});
- fireEvent.change(screen.getByTestId('wizard-field-start'), {
- target: { value: String(now + 3600) },
- });
- fireEvent.change(screen.getByTestId('wizard-field-end'), {
- target: { value: String(now + 7200) },
- });
+ // Voting window is now derived at /prepare time from the live
+ // next-superblock anchor (mocked above). No user-facing start/end
+ // inputs to fill in anymore.
}
describe('NewProposal wizard', () => {
beforeEach(() => {
jest.clearAllMocks();
+ // Snapshot a stable next-SB anchor at test start so both the
+ // mount-time and prepare-time fetchNetworkStats calls return
+ // the same value (same rationale as in production: /mnStats
+ // reports the same pre-computed SB epoch across rapid calls).
+ currentStableNextSb = Math.floor(Date.now() / 1000) + 30 * 86400;
+ fetchNetworkStats.mockImplementation(defaultNetworkStatsResolver);
// Default to no pre-existing draft fetch.
proposalService.getDraft.mockRejectedValue(
Object.assign(new Error('not_found'), { code: 'not_found' })
@@ -242,11 +288,16 @@ describe('NewProposal wizard', () => {
expect(screen.getByTestId('wizard-panel-payment')).toBeInTheDocument();
validPayment();
+ // Wait for the mocked next-superblock anchor to resolve —
+ // until it lands the Prepare button is disabled because we
+ // refuse to submit an unanchored voting window.
+ await screen.findByTestId('window-preview');
fireEvent.click(screen.getByTestId('wizard-next'));
expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
expect(screen.getByTestId('review-name')).toHaveTextContent('my-grant');
const prepareBtn = screen.getByTestId('wizard-prepare');
+ await waitFor(() => expect(prepareBtn).not.toBeDisabled());
await act(async () => {
fireEvent.click(prepareBtn);
});
@@ -260,6 +311,12 @@ describe('NewProposal wizard', () => {
paymentAmountSats: '100000000000',
paymentCount: 1,
});
+ // Epochs are derived at prepare time, never from user input.
+ // Sanity: start < end, end > now, span is roughly
+ // paymentCount * cycle.
+ expect(body.startEpoch).toBeGreaterThan(0);
+ expect(body.endEpoch).toBeGreaterThan(body.startEpoch);
+ expect(body.endEpoch).toBeGreaterThan(Math.floor(Date.now() / 1000));
// Critical invariant: on successful prepare the wizard hands
// off to the dedicated status page. The old in-wizard Submit
@@ -503,11 +560,17 @@ describe('NewProposal wizard', () => {
// Walk the wizard to Review.
fireEvent.click(screen.getByTestId('wizard-next'));
expect(screen.getByTestId('wizard-panel-payment')).toBeInTheDocument();
+ // Wait for the mocked next-superblock anchor before advancing
+ // — Prepare is gated on a live window and tapping it too early
+ // would no-op.
+ await screen.findByTestId('window-preview');
fireEvent.click(screen.getByTestId('wizard-next'));
expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
+ const prepareBtnA = screen.getByTestId('wizard-prepare');
+ await waitFor(() => expect(prepareBtnA).not.toBeDisabled());
await act(async () => {
- fireEvent.click(screen.getByTestId('wizard-prepare'));
+ fireEvent.click(prepareBtnA);
});
expect(proposalService.prepare).toHaveBeenCalledTimes(1);
@@ -556,11 +619,14 @@ describe('NewProposal wizard', () => {
validBasics();
fireEvent.click(screen.getByTestId('wizard-next'));
validPayment();
+ await screen.findByTestId('window-preview');
fireEvent.click(screen.getByTestId('wizard-next'));
expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
+ const prepareBtnB = screen.getByTestId('wizard-prepare');
+ await waitFor(() => expect(prepareBtnB).not.toBeDisabled());
await act(async () => {
- fireEvent.click(screen.getByTestId('wizard-prepare'));
+ fireEvent.click(prepareBtnB);
});
await waitFor(() => {
@@ -572,23 +638,25 @@ describe('NewProposal wizard', () => {
);
test(
- 'Review step surfaces an approximate payment schedule and total budget for paymentCount >= 2 (QA v3-ER03)',
+ 'Review step surfaces the projected payment schedule + total budget for paymentCount >= 2',
async () => {
- // QA v3-ER03: when a proposal requests multiple monthly
- // payments, the Review step must break down approximately
- // WHEN each payment would be paid and what the total budget
- // works out to. Shipping just "Number of payments: 3" left
- // users to mentally multiply and to guess at superblock
- // cadence. Added as a planning-grade estimate (~30d cadence)
- // with a caveat.
+ // With the derived-window redesign the voting window is
+ // computed from duration alone, so the Review step can be
+ // positive about the schedule instead of warning about
+ // truncation: the window is always wide enough to fit every
+ // requested payment by construction (see
+ // lib/governanceWindow.js). Assert the schedule lists N rows,
+ // each carrying an index + a SYS amount, and that the total
+ // budget is rendered.
await renderWizard();
await screen.findByTestId('wizard-panel-basics');
validBasics();
fireEvent.click(screen.getByTestId('wizard-next'));
- // Fill a window wide enough to fit 3 monthly payments:
- // start now+1h, end now+120 days.
- const now = Math.floor(Date.now() / 1000);
+ // Wait for the mocked next-SB anchor to land so the
+ // derived window renders.
+ await screen.findByTestId('window-preview');
+
fireEvent.change(screen.getByTestId('wizard-field-address'), {
target: { value: 'sys1qexampleexampleexampleexampleexampleaaaa' },
});
@@ -598,122 +666,435 @@ describe('NewProposal wizard', () => {
fireEvent.change(screen.getByTestId('wizard-field-count'), {
target: { value: '3' },
});
- fireEvent.change(screen.getByTestId('wizard-field-start'), {
- target: { value: String(now + 3600) },
- });
- fireEvent.change(screen.getByTestId('wizard-field-end'), {
- target: { value: String(now + 120 * 86400) },
- });
fireEvent.click(screen.getByTestId('wizard-next'));
expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
expect(screen.getByTestId('review-count')).toHaveTextContent('3');
- // Total budget = 3 × 500 = 1,500 SYS (with locale-aware
- // grouping separators, so we tolerate either a comma or a
- // space as the thousands separator).
+ // Total budget = 3 × 500 = 1,500 SYS (locale-tolerant).
expect(screen.getByTestId('review-total')).toHaveTextContent(
/1[,\s]?500 SYS/
);
const rows = screen.getAllByTestId('review-schedule-row');
expect(rows).toHaveLength(3);
- // All three rows carry an index and a date. We don't assert
- // the exact date strings (they depend on system clock) but
- // they must be non-empty.
for (const row of rows) {
expect(row.textContent).toMatch(/#\d+/);
expect(row.textContent).toMatch(/SYS/);
}
- // Truncation warning absent when every payment fits.
- expect(screen.queryByTestId('review-schedule-trunc')).toBeNull();
}
);
test(
- 'Review step warns when the voting window cannot fit every requested payment',
+ 'tight-voting-window notice hidden when the next superblock is comfortably far',
async () => {
- // If user sets paymentCount=3 but picks a 45-day window,
- // only 1 of the 3 requested monthly superblocks will fall
- // inside (the first payable superblock is one full cycle
- // after start under worst-case alignment; see
- // `buildApproximateSchedule`) and the remaining 2 payments
- // will never be paid. Surface this explicitly — silently
- // omitting rows from the schedule would let the user
- // prepare a misconfigured proposal and burn their 150 SYS
- // collateral for nothing.
+ // Default mock returns an anchor 30 days out — outside the
+ // 4-day warning threshold. Warning must stay hidden on both
+ // Payment and Review.
await renderWizard();
await screen.findByTestId('wizard-panel-basics');
validBasics();
fireEvent.click(screen.getByTestId('wizard-next'));
- const now = Math.floor(Date.now() / 1000);
- fireEvent.change(screen.getByTestId('wizard-field-address'), {
- target: { value: 'sys1qexampleexampleexampleexampleexampleaaaa' },
- });
- fireEvent.change(screen.getByTestId('wizard-field-amount'), {
- target: { value: '500' },
- });
- fireEvent.change(screen.getByTestId('wizard-field-count'), {
- target: { value: '3' },
- });
- fireEvent.change(screen.getByTestId('wizard-field-start'), {
- target: { value: String(now + 3600) },
- });
- fireEvent.change(screen.getByTestId('wizard-field-end'), {
- target: { value: String(now + 45 * 86400) },
- });
+ // Payment step loaded, anchor resolved.
+ await screen.findByTestId('window-preview');
+ expect(
+ screen.queryByTestId('tight-voting-window-warning')
+ ).toBeNull();
+
+ validPayment({ count: '3' });
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
+ // Review step — same expectation.
+ expect(
+ screen.queryByTestId('tight-voting-window-warning')
+ ).toBeNull();
+ }
+ );
+
+ test(
+ 'tight-voting-window notice fires on both Payment and Review when the next superblock is within 4 days',
+ async () => {
+ // Override the default 30-day mock: put the next superblock
+ // only 2 days out. This is inside Core's 3-day maturity
+ // window + the wizard's extra 1-day headroom — masternodes
+ // likely won't have time to finish voting, so the proposal
+ // would probably miss that superblock and pay out N-1
+ // months instead of N. The warning must fire on BOTH steps
+ // so the user doesn't skip past it. Anchor stable across
+ // both fetchNetworkStats calls (mount + prepare) — see the
+ // comment above `currentStableNextSb`.
+ currentStableNextSb = Math.floor(Date.now() / 1000) + 2 * 86400;
+
+ await renderWizard();
+ await screen.findByTestId('wizard-panel-basics');
+ validBasics();
fireEvent.click(screen.getByTestId('wizard-next'));
+ // Payment step loaded, anchor resolved.
+ await screen.findByTestId('window-preview');
+ const paymentWarn = screen.getByTestId('tight-voting-window-warning');
+ expect(paymentWarn).toBeInTheDocument();
+ expect(paymentWarn).toHaveAttribute('role', 'alert');
+ expect(paymentWarn.textContent).toMatch(/next superblock/i);
+ expect(paymentWarn.textContent).toMatch(/likely miss/i);
+
+ // Fill duration=6 so the notice can quote "5 instead of 6".
+ validPayment({ count: '6' });
+ const paidChip = screen.getByTestId(
+ 'tight-voting-window-warning-paid'
+ );
+ expect(paidChip.textContent).toBe('5 months');
+
+ fireEvent.click(screen.getByTestId('wizard-next'));
expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
- const rows = screen.queryAllByTestId('review-schedule-row');
- expect(rows.length).toBeLessThan(3);
+ const reviewWarn = screen.getByTestId('tight-voting-window-warning');
+ expect(reviewWarn).toBeInTheDocument();
+ // Prepare stays enabled — notice is informational, not a blocker.
+ const prepareBtn = screen.getByTestId('wizard-prepare');
+ await waitFor(() => expect(prepareBtn).not.toBeDisabled());
+ }
+ );
+
+ test(
+ 'tight-voting-window notice omits the "N-1 months" clause for 1-month proposals',
+ async () => {
+ // Edge case: user asked for 1 month. "Will pay 0 months
+ // instead of 1" is unhelpful — the honest message is just
+ // "will likely miss that superblock" without the demotion
+ // clause. Same stable-anchor pattern as above.
+ currentStableNextSb = Math.floor(Date.now() / 1000) + 2 * 86400;
+
+ await renderWizard();
+ await screen.findByTestId('wizard-panel-basics');
+ validBasics();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ await screen.findByTestId('window-preview');
+ validPayment({ count: '1' });
+
+ const warn = screen.getByTestId('tight-voting-window-warning');
+ expect(warn).toBeInTheDocument();
expect(
- screen.getByTestId('review-schedule-trunc')
- ).toHaveTextContent(/of the 3/i);
+ screen.queryByTestId('tight-voting-window-warning-paid')
+ ).toBeNull();
+ expect(warn.textContent).toMatch(/likely miss/i);
}
);
test(
- 'Review step warns when the voting window cannot fit any payment at all (Codex PR9 R3)',
+ 'Prepare fails closed when the pre-submit /mnStats refresh throws (Codex round 2 P2)',
async () => {
- // Regression guard for the Codex-flagged schedule alignment
- // fix: under worst-case superblock alignment the first
- // payable superblock is a full cycle AFTER startEpoch, so a
- // multi-payment proposal with a window shorter than one
- // cycle yields zero payable rows. The schedule panel must
- // still render the truncation warning instead of being
- // hidden — otherwise the user would think the schedule is
- // fine when in reality no payment can land at all.
+ // The wizard refreshes the next-SB anchor right before
+ // submitting. If that fetch errors out, we must NOT fall
+ // through to the cached (possibly-now-stale) anchor or to
+ // computeProposalWindow's `now + cycle` fallback — either
+ // path could ship a window that diverges from the reviewed
+ // schedule and burn collateral. Fail closed: show the
+ // stats-unavailable banner, drop the cached anchor so
+ // Prepare stays disabled, and do NOT call prepare().
await renderWizard();
await screen.findByTestId('wizard-panel-basics');
validBasics();
fireEvent.click(screen.getByTestId('wizard-next'));
+ await screen.findByTestId('window-preview');
+ validPayment();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
- const now = Math.floor(Date.now() / 1000);
- fireEvent.change(screen.getByTestId('wizard-field-address'), {
- target: { value: 'sys1qexampleexampleexampleexampleexampleaaaa' },
- });
- fireEvent.change(screen.getByTestId('wizard-field-amount'), {
- target: { value: '500' },
+ // Now break the /mnStats endpoint for the prepare-time
+ // refresh. The mount-time fetch already succeeded with the
+ // stable anchor from beforeEach.
+ fetchNetworkStats.mockRejectedValueOnce(
+ new Error('transient_network_failure')
+ );
+
+ const prepareBtn = screen.getByTestId('wizard-prepare');
+ await waitFor(() => expect(prepareBtn).not.toBeDisabled());
+ await act(async () => {
+ fireEvent.click(prepareBtn);
});
- fireEvent.change(screen.getByTestId('wizard-field-count'), {
- target: { value: '3' },
+
+ // prepare() must NOT have been called — we short-circuited.
+ expect(proposalService.prepare).not.toHaveBeenCalled();
+ // The inline error banner surfaces the retry guidance.
+ const alerts = screen.getAllByRole('alert');
+ const errBanner = alerts.find((el) =>
+ /could not confirm live superblock timing/i.test(el.textContent)
+ );
+ expect(errBanner).toBeDefined();
+ // Cached anchor dropped → Prepare button goes back to
+ // disabled until refreshStats recovers.
+ await waitFor(() =>
+ expect(screen.getByTestId('wizard-prepare')).toBeDisabled()
+ );
+ }
+ );
+
+ test(
+ 'Prepare fails closed when the pre-submit /mnStats refresh returns a stale (past) anchor',
+ async () => {
+ // Same fail-closed behaviour as the throw case: a lagging
+ // /mnStats feed that still returns a positive but past
+ // timestamp must not let us submit. The mount fetch used
+ // the stable future anchor from beforeEach; we corrupt only
+ // the prepare-time refresh.
+ await renderWizard();
+ await screen.findByTestId('wizard-panel-basics');
+ validBasics();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ await screen.findByTestId('window-preview');
+ validPayment();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
+
+ fetchNetworkStats.mockResolvedValueOnce({
+ stats: {
+ superblock_stats: {
+ superblock_next_epoch_sec: Math.floor(Date.now() / 1000) - 60,
+ },
+ },
});
- fireEvent.change(screen.getByTestId('wizard-field-start'), {
- target: { value: String(now + 3600) },
+
+ const prepareBtn = screen.getByTestId('wizard-prepare');
+ await waitFor(() => expect(prepareBtn).not.toBeDisabled());
+ await act(async () => {
+ fireEvent.click(prepareBtn);
});
- fireEvent.change(screen.getByTestId('wizard-field-end'), {
- target: { value: String(now + 15 * 86400) },
+
+ expect(proposalService.prepare).not.toHaveBeenCalled();
+ const alerts = screen.getAllByRole('alert');
+ const errBanner = alerts.find((el) =>
+ /could not confirm live superblock timing/i.test(el.textContent)
+ );
+ expect(errBanner).toBeDefined();
+ }
+ );
+
+ test(
+ 'Prepare fails closed when /mnStats resolves across the SB boundary (anchor future at pre-await, past at post-await) (Codex round 3 P2)',
+ async () => {
+ // Codex PR20 round 3 P2: the previous implementation captured
+ // `nowSec = Math.floor(Date.now() / 1000)` BEFORE awaiting
+ // fetchNetworkStats() and reused it to validate the refreshed
+ // anchor. /mnStats is a real network RTT and can straddle
+ // wall-clock boundaries — including, at a SB transition, the
+ // actual superblock. In that case an anchor that was strictly
+ // future at pre-await time is already in the past by the time
+ // we use it, so passing the pre-await clock to
+ // nextSuperblockEpochSecFromStats would green-light a
+ // just-passed anchor and ship a window anchored to a SB that
+ // already happened (effective payouts shift N -> N-1).
+ //
+ // Simulate this by installing a Date.now() spy that advances
+ // time forward WHILE fetchNetworkStats is in flight. The
+ // anchor returned is +10s relative to the pre-await clock
+ // but the clock moves +20s during the await, so the same
+ // anchor is -10s relative to the post-await clock. The fix
+ // captures nowSec AFTER the await, so the anchor is rejected
+ // as stale and we fail closed with stats_unavailable.
+ await renderWizard();
+ await screen.findByTestId('wizard-panel-basics');
+ validBasics();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ await screen.findByTestId('window-preview');
+ validPayment();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
+
+ const baseNowMs = Date.now();
+ const baseNowSec = Math.floor(baseNowMs / 1000);
+
+ // Install the Date.now spy AFTER all prior setup has run on
+ // the real clock. The spy advances time on every read so that
+ // once the fetchNetworkStats mock resolver fires we've already
+ // moved past the anchor it's about to return.
+ let dateNowSpy;
+ try {
+ fetchNetworkStats.mockImplementationOnce(async () => {
+ // Bump the clock forward by 20s BEFORE resolving.
+ // Consumers of Date.now() after the await will see the
+ // advanced time; consumers before the await already
+ // captured the un-advanced time. Also pin every subsequent
+ // Date.now() read for the rest of the prepare call to
+ // this value so the assertion is deterministic.
+ dateNowSpy = jest
+ .spyOn(Date, 'now')
+ .mockReturnValue((baseNowSec + 20) * 1000);
+ return {
+ stats: {
+ superblock_stats: {
+ superblock_next_epoch_sec: baseNowSec + 10,
+ },
+ },
+ };
+ });
+
+ const prepareBtn = screen.getByTestId('wizard-prepare');
+ await waitFor(() => expect(prepareBtn).not.toBeDisabled());
+ await act(async () => {
+ fireEvent.click(prepareBtn);
+ });
+
+ // prepare() must NOT have been called — the post-await
+ // clock read sees the anchor as already-past and fails
+ // closed. Without the fix, nextSuperblockEpochSecFromStats
+ // sees nowSec=baseNowSec and anchor=baseNowSec+10 → validates
+ // as live and the submission proceeds.
+ expect(proposalService.prepare).not.toHaveBeenCalled();
+ const alerts = screen.getAllByRole('alert');
+ const errBanner = alerts.find((el) =>
+ /could not confirm live superblock timing/i.test(el.textContent)
+ );
+ expect(errBanner).toBeDefined();
+ } finally {
+ if (dateNowSpy) dateNowSpy.mockRestore();
+ }
+ }
+ );
+
+ test(
+ 'Prepare surfaces anchor_drift when the refreshed anchor differs from the cached one, and submits on the retry click',
+ async () => {
+ // Chain advanced a cycle while the wizard was open. The
+ // fresh anchor is valid, but the user reviewed a schedule
+ // built from the previous anchor. We must update state so
+ // the WindowPreview rerenders with the new schedule, then
+ // let the user commit by clicking Prepare again — this
+ // ensures they only ever burn collateral on a window they
+ // actually saw.
+ proposalService.prepare.mockResolvedValue({
+ submission: {
+ id: 77,
+ proposalHash: 'aa'.repeat(32),
+ parentHash: '0',
+ revision: 1,
+ timeUnix: 1700000000,
+ dataHex: 'deadbeef',
+ name: 'my-grant',
+ url: 'https://forum.syscoin.org/t/my-grant',
+ paymentAddress: 'sys1qexampleexampleexampleexampleexampleaaaa',
+ paymentAmountSats: '100000000000',
+ paymentCount: 1,
+ startEpoch: 1700000000,
+ endEpoch: 1701000000,
+ },
+ opReturnHex: 'aa'.repeat(32),
+ canonicalJson: '{}',
+ payloadBytes: 2,
+ collateralFeeSats: '15000000000',
+ requiredConfirmations: 6,
});
+
+ await renderWizard();
+ await screen.findByTestId('wizard-panel-basics');
+ validBasics();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ await screen.findByTestId('window-preview');
+ validPayment();
fireEvent.click(screen.getByTestId('wizard-next'));
+ expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
+
+ // Next /mnStats call returns a DIFFERENT future anchor (one
+ // superblock past the cached one). Subsequent calls return
+ // the same drifted value so the retry click sees a stable
+ // state.
+ const driftedAnchor = currentStableNextSb + 30 * 86400;
+ currentStableNextSb = driftedAnchor;
+
+ const prepareBtn = screen.getByTestId('wizard-prepare');
+ await waitFor(() => expect(prepareBtn).not.toBeDisabled());
+ await act(async () => {
+ fireEvent.click(prepareBtn);
+ });
+
+ // First click: detected drift, updated state, did NOT submit.
+ expect(proposalService.prepare).not.toHaveBeenCalled();
+ const alerts = screen.getAllByRole('alert');
+ const errBanner = alerts.find((el) =>
+ /chain timing updated while this wizard was open/i.test(
+ el.textContent
+ )
+ );
+ expect(errBanner).toBeDefined();
+ // Prepare button is still enabled for the retry click
+ // (cached anchor was updated, not cleared).
+ expect(screen.getByTestId('wizard-prepare')).not.toBeDisabled();
+
+ // Second click: cached anchor now matches the refreshed
+ // one, so we proceed to prepare.
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('wizard-prepare'));
+ });
+ expect(proposalService.prepare).toHaveBeenCalledTimes(1);
+ }
+ );
+ test(
+ 'Prepare proceeds without anchor_drift when refreshed anchor differs only by estimate drift (sub-SB, Codex round 4 P1)',
+ async () => {
+ // sysMain.js on the backend recomputes
+ // `superblock_next_epoch_sec` every 20 s as `now +
+ // diffBlock * avgBlockTime`, so the value drifts by
+ // seconds between fetches without the next superblock
+ // actually rotating. The previous strict-equality check
+ // in onPrepare treated every such drift as a rotation
+ // and popped the "Chain timing updated" banner, which
+ // meant users could get stuck never reaching
+ // proposalService.prepare — clicking Prepare kept
+ // bouncing them back to re-review. Regression: a 60 s
+ // drift (well under the cycle/2 tolerance) must submit
+ // cleanly on the first click.
+ proposalService.prepare.mockResolvedValue({
+ submission: {
+ id: 88,
+ proposalHash: 'bb'.repeat(32),
+ parentHash: '0',
+ revision: 1,
+ timeUnix: 1700000000,
+ dataHex: 'deadbeef',
+ name: 'my-grant',
+ url: 'https://forum.syscoin.org/t/my-grant',
+ paymentAddress: 'sys1qexampleexampleexampleexampleexampleaaaa',
+ paymentAmountSats: '100000000000',
+ paymentCount: 1,
+ startEpoch: 1700000000,
+ endEpoch: 1701000000,
+ },
+ opReturnHex: 'bb'.repeat(32),
+ canonicalJson: '{}',
+ payloadBytes: 2,
+ collateralFeeSats: '15000000000',
+ requiredConfirmations: 6,
+ });
+
+ await renderWizard();
+ await screen.findByTestId('wizard-panel-basics');
+ validBasics();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ await screen.findByTestId('window-preview');
+ validPayment();
+ fireEvent.click(screen.getByTestId('wizard-next'));
expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
- expect(screen.getByTestId('review-schedule')).toBeInTheDocument();
- expect(
- screen.queryAllByTestId('review-schedule-row')
- ).toHaveLength(0);
- expect(
- screen.getByTestId('review-schedule-trunc')
- ).toHaveTextContent(/too short to land any/i);
+
+ // Next /mnStats call returns a slightly drifted anchor
+ // (60 s forward). Well within the cycle/2 tolerance, so
+ // the wizard must treat it as "same SB, just a fresher
+ // estimate" and proceed to prepare.
+ currentStableNextSb += 60;
+
+ const prepareBtn = screen.getByTestId('wizard-prepare');
+ await waitFor(() => expect(prepareBtn).not.toBeDisabled());
+ await act(async () => {
+ fireEvent.click(prepareBtn);
+ });
+
+ // Submitted on the first click — no anchor_drift banner.
+ expect(proposalService.prepare).toHaveBeenCalledTimes(1);
+ const alerts = screen.queryAllByRole('alert');
+ const spuriousDrift = alerts.find((el) =>
+ /chain timing updated while this wizard was open/i.test(
+ el.textContent
+ )
+ );
+ expect(spuriousDrift).toBeUndefined();
}
);
@@ -735,6 +1116,49 @@ describe('NewProposal wizard', () => {
}
);
+ test(
+ 'Review step suppresses the projected schedule when /mnStats anchor is unavailable (Codex round 5 P2)',
+ async () => {
+ // Regression: computeProposalWindow has an internal
+ // "stale anchor" fallback (anchor = now + cycle) so that
+ // the duration preview can still render something sensible
+ // while stats load. That fallback makes `derivedWindow`
+ // truthy even when `nextSuperblockSec` is null, which in
+ // turn made the Review step paint a convincing-looking
+ // projected payment schedule from entirely synthetic
+ // timestamps — alongside the "live chain data unavailable"
+ // WindowPreview banner that was (correctly) hiding the
+ // voting window itself. Post-fix: the schedule must stay
+ // in lockstep with WindowPreview and only render when we
+ // have a real live anchor.
+ //
+ // Setup: stub /mnStats to return a response that
+ // nextSuperblockEpochSecFromStats rejects (missing
+ // superblock_next_epoch_sec field). Wizard state lands at
+ // `nextSuperblockSec = null, statsError != null`. 3-month
+ // proposal so paymentCount >= 2 is satisfied — the only
+ // remaining gate on the schedule is the live-anchor check.
+ fetchNetworkStats.mockImplementation(() =>
+ Promise.resolve({ stats: { superblock_stats: {} } })
+ );
+
+ await renderWizard();
+ await screen.findByTestId('wizard-panel-basics');
+ validBasics();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ validPayment({ count: '3' });
+ fireEvent.click(screen.getByTestId('wizard-next'));
+
+ expect(screen.getByTestId('wizard-panel-review')).toBeInTheDocument();
+
+ // Review must not fabricate a schedule when the anchor is
+ // gone. WindowPreview's error banner is the only source
+ // of truth about timing in this state.
+ expect(screen.queryByTestId('review-schedule')).toBeNull();
+ expect(screen.queryAllByTestId('review-schedule-row')).toHaveLength(0);
+ }
+ );
+
test(
'edits typed while save is in flight remain dirty (baseline is the saved snapshot, not the live form) (Codex round 7 P1)',
async () => {
@@ -1393,4 +1817,74 @@ describe('NewProposal wizard', () => {
expect(body.paymentAddress).toBe('sys1qresumed1234567890');
}
);
+
+ test(
+ 'WindowPreview + Prepare flip to error when the cached next-SB anchor becomes stale while the wizard is open (Codex round 6 P2)',
+ async () => {
+ // Regression for the round-6 P2 ask: the live-anchor check
+ // used to be "nextSuperblockSec is finite and > 0", which
+ // meant a cached anchor whose timestamp had already passed
+ // was still treated as live. That let WindowPreview keep
+ // showing the stale SB date and Prepare stay enabled while
+ // computeProposalWindow had already switched to its
+ // stale-anchor fallback — a divergence between the displayed
+ // and prepare-able windows. Fix: derive isAnchorLive from a
+ // `> nowSec` comparison and force a periodic re-render so
+ // all gates flip in lockstep when the wall clock passes.
+ //
+ // We simulate time passing by (a) fetching a short-horizon
+ // anchor (6 s in the future) and then (b) advancing the fake
+ // clock past it and flushing the 30 s tick interval. The
+ // post-advance refresh mock returns the same stale value so
+ // refreshStats rejects it via nextSuperblockEpochSecFromStats
+ // and the Retry-in-error-banner path is exercised too.
+ jest.useFakeTimers({ doNotFake: ['performance'] });
+ try {
+ const baseNow = Date.now();
+ jest.setSystemTime(baseNow);
+ // Anchor 6 seconds in the future relative to mount. The
+ // wizard will accept this as a live anchor, but we'll
+ // advance wall-clock past it before Prepare.
+ currentStableNextSb = Math.floor(baseNow / 1000) + 6;
+
+ await renderWizard();
+ await screen.findByTestId('wizard-panel-basics');
+ validBasics();
+
+ // Run the pending microtask queue so the mount fetch
+ // resolves inside act().
+ await act(async () => {
+ jest.advanceTimersByTime(0);
+ });
+
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ await screen.findByTestId('window-preview');
+ validPayment();
+ fireEvent.click(screen.getByTestId('wizard-next'));
+ await screen.findByTestId('wizard-panel-review');
+
+ // Prepare is currently enabled (live anchor, 6 s in future).
+ expect(screen.getByTestId('wizard-prepare')).not.toBeDisabled();
+
+ // Advance wall clock past the anchor. The 30 s render-tick
+ // interval plus React flushes pick up the change.
+ jest.setSystemTime(baseNow + 60_000);
+ await act(async () => {
+ jest.advanceTimersByTime(30_000);
+ });
+
+ // isAnchorLive flips false → Prepare is gated and the
+ // WindowPreview card swaps into its stats-unavailable
+ // branch. Both are driven by the same predicate, so they
+ // must flip together.
+ await waitFor(() =>
+ expect(screen.getByTestId('wizard-prepare')).toBeDisabled()
+ );
+ expect(screen.queryByTestId('window-preview')).toBeNull();
+ expect(screen.queryByTestId('review-schedule')).toBeNull();
+ } finally {
+ jest.useRealTimers();
+ }
+ }
+ );
});