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.

-
-
- - {touched.startEpoch ? ( - - ) : null} -
+ -
- - {touched.endEpoch ? ( - - ) : null} -
-
+ ); } -// 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 ( +
+ Voting window +

+ Loading live superblock timing… +

+
+ ); + } + if (statsError || !isAnchorLive) { + return ( +
+ Voting window +

+ 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 }) {
{form.paymentAddress}
Amount per payment
{form.paymentAmount} SYS
-
Number of payments
-
{form.paymentCount}
+
Duration
+
+ {form.paymentCount} month + {Number(form.paymentCount) === 1 ? '' : 's'} +
{totalBudgetSys ? ( <>
Total budget
{totalBudgetSys} SYS
) : null} -
Voting window
-
- {new Date(Number(form.startEpoch) * 1000).toUTCString()} -
→{' '} - {new Date(Number(form.endEpoch) * 1000).toUTCString()} -
- {paymentCountNum >= 2 ? ( + + + + + {schedule.length > 0 ? (

- Approximate payment schedule + Projected payment schedule

- 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 ? ( -
    - {schedule.map((p) => ( -
  1. - - #{p.index} - - - ~ {formatUtcDate(p.epochSec)} - - - {form.paymentAmount} SYS - -
  2. - ))} -
- ) : null} - {schedule.length < paymentCountNum ? ( -

- {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} +
    + {schedule.map((p) => ( +
  1. + + #{p.index} + + + ~ {formatUtcDate(p.epochSec)} + + + {form.paymentAmount} SYS + +
  2. + ))} +
) : 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(); + } + } + ); });