diff --git a/ECONOMIC_INVARIANTS.md b/ECONOMIC_INVARIANTS.md index 865163f..719c7c9 100644 --- a/ECONOMIC_INVARIANTS.md +++ b/ECONOMIC_INVARIANTS.md @@ -10,6 +10,18 @@ These invariants are intended to be **testable** (unit/fuzz/property tests) and --- +## 0. Supply Model (one-shot genesis) + +Genesis mints the entire allocation once (patch_oneshot_genesis). Thereafter, +total supply changes ONLY through StakeEngine accrual: mint and burn via +VSPToken, authorized by Authority (StakeEngine is exempt from the time-based +supply cap in VSPToken — an engine-side accrual error is therefore not bounded +by the token, which is why the accrual math and the mint path must be reviewed +together). There is NO scheduled emission and no other minter. ScheduledEmitter, +the superseded price-independent schedule design (never deployed, never wired), +was removed from the tree on 2026-08-21; the reference implementation is +preserved at git tag `scheduled-emitter-ref`. + ## I. Token Conservation & Accounting Invariants (StakeEngine) ### I.1 Contract balance matches staked totals diff --git a/SECURITY.md b/SECURITY.md index 783bc33..cd32b20 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,49 +1,95 @@ # Security Policy -## Supported Versions -The VeriSphere protocol is under active development. Only the latest commit on -the `main` branch is considered supported. +Verisphere is pre-launch, unaudited software. We take security reports +seriously and would rather hear about a problem than not. -Security fixes will be applied immediately upon discovery. +## Reporting a vulnerability -## Reporting a Vulnerability -If you discover a vulnerability in any VeriSphere smart contract, script, -repository, or deployment process: +**Email: security@verisphere.co** — or, if you prefer GitHub, use the +repository's **Report a vulnerability** button (Security tab → Advisories), +which opens a private channel visible only to maintainers. -1. Do **not** create a GitHub issue. -2. Instead, email: +Please include: - security@verisphere.co +- what the issue is and where (file/function/contract), +- how to reproduce it — a failing test, a transaction trace, or a script is + ideal, but a clear written walkthrough is fine, +- what an attacker gains, and roughly how hard it is, +- your assessment of severity, if you have one. -3. Include: - - A clear description of the issue - - Steps to reproduce (if applicable) - - Contract addresses or code locations - - Whether the vulnerability is theoretical or exploitable - - Your public wallet address (for possible bounty eligibility) +Please **do not** open a public issue or PR for a security problem, and please +don't post it on social media before we've had a chance to respond. -We will acknowledge reports within 48 hours. +## What you can expect from us -## Smart Contract Security Expectations -The core contracts follow these principles: +| Stage | Target | +|---|---| +| Acknowledgement that a human has read it | 3 business days | +| Initial assessment (confirmed / not reproduced / need more info) | 10 business days | +| Fix or documented mitigation for confirmed issues | as fast as the severity warrants — we'll tell you the plan | +| Public disclosure | coordinated with you, normally after a fix ships | -- Minimal trusted roles -- No upgradeable proxies in MVP -- Deterministic authorization via Authority.sol -- No unbounded loops over user data -- No external calls inside state-mutating logic except VSP token transfer calls +We will tell you honestly whether we agree with your severity rating and why. +If we disagree, we'll explain our reasoning rather than quietly downgrading it. +If we can't reproduce it, we'll say what we tried. -Formal verification and third-party audits are planned before mainnet launch. +## Safe harbour -## Bounty Program -VeriSphere will reward security disclosures that materially improve protocol -safety. +We will not pursue legal action against anyone who, in good faith: -Qualifying classes include: -- Critical loss of funds -- Unauthorized mint/burn -- Post or stake manipulation -- Incorrect access control on any contract -- Economic attacks that bypass intended staking dynamics +- tests only against **testnet deployments** or their own local fork, +- does not access, modify, or exfiltrate other users' data or funds, +- does not degrade the service for others (no sustained load testing, no spam + against the relay or public endpoints), +- gives us a reasonable opportunity to fix the issue before disclosing it. -Thank you for helping secure VeriSphere. +If you're unsure whether something is in scope, ask first — that also counts +as good faith. + +## Scope + +Upgradeability, so you know what you are testing: the core protocol contracts +(StakeEngine, ScoreEngine, PostRegistry, LinkGraph, ProtocolViews) are +UUPS-upgradeable via GovernedUpgradeable, authorized through Authority, with +upgrades running through script/Upgrade.s.sol and the storage-layout check. +VSPToken and Authority themselves are not proxied. + + +**In scope** +- Protocol contracts in `VeriSphereVSP/core` (`src/`) — token, registry, + staking, scoring, governance, and the deployment scripts under `script/`. +- The application backend in `VeriSphereVSP/app` — the API, relay/forwarder + path, content moderation, indexer, and rate limiting. +- The frontend in `VeriSphereVSP/frontend` and the Verity browser extension. + +**Out of scope** +- `src/mock/` and anything documented as rehearsal-only infrastructure. +- Issues that require compromising a user's own device, wallet, or browser. +- Findings from automated scanners without a demonstrated impact. +- The economics of losing a stake: staking against a claim that later loses is + the protocol working as designed, not a vulnerability. +- Third-party services (RPC providers, AMM venues, the chain itself). + +## Known and accepted + +- **The contracts are unaudited.** This is disclosed to users and is not itself + a finding. Reports of specific exploitable bugs in unaudited code are very + much welcome. +- **Governance is currently a single key** on testnet. Findings that assume a + compromised governance key are understood; findings that let a non-governance + actor reach governance-only behaviour are in scope and serious. + +## Rewards + +We do not currently run a paid bug bounty; the project is pre-revenue and we'd +rather promise nothing than promise what we can't pay. What we do offer: +public credit in the advisory and this repository (or anonymity, your choice), +and a direct line to the maintainers. If a report prevents material loss, we +will discuss a discretionary reward case by case. + +## Disclosure history + +| ID | Date | Summary | Status | +|---|---|---|---| +| VSP-SEC-001 | 2026-08-19 | Mid-window accrual asymmetry in `StakeEngine`: settlement scales rewards by elapsed epochs and applies them to the lot set present at settlement, so with `snapshotPeriod > EPOCH_LENGTH` a late joiner could capture a full window's rewards (and an early leaver could dodge a full window's decay). Not reachable at the deployed 1-day period. Reported by [@ibnu76](https://github.com/ibnu76). | Fixed — `MAX_SNAPSHOT_PERIOD` capped to `EPOCH_LENGTH`; regression tests added | diff --git a/script/slither/slither-baseline.json b/script/slither/slither-baseline.json index 980cf3d..fa76274 100644 --- a/script/slither/slither-baseline.json +++ b/script/slither/slither-baseline.json @@ -1,8 +1,8 @@ { "by_impact": { "High": 0, - "Informational": 42, - "Low": 35, + "Informational": 50, + "Low": 37, "Medium": 30, "Optimization": 2 }, @@ -29,7 +29,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-no-eth", @@ -37,7 +37,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-no-eth", @@ -45,7 +45,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-no-eth", @@ -53,7 +53,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-no-eth", @@ -61,7 +61,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-no-eth", @@ -69,7 +69,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-no-eth", @@ -77,7 +77,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-no-eth", @@ -85,7 +85,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-no-eth", @@ -93,7 +93,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "divide-before-multiply", @@ -101,7 +101,7 @@ "element": "_projectTotals", "fingerprint": "1176ec9c82beef04", "impact": "Medium", - "location": "src/StakeEngine.sol:781" + "location": "src/StakeEngine.sol:796" }, { "check": "divide-before-multiply", @@ -117,7 +117,7 @@ "element": "_settleBucket", "fingerprint": "136d4d02ff0927b2", "impact": "Medium", - "location": "src/StakeEngine.sol:1249" + "location": "src/StakeEngine.sol:1264" }, { "check": "incorrect-equality", @@ -125,7 +125,7 @@ "element": "_projectLotValue", "fingerprint": "160f822661d43b1c", "impact": "Medium", - "location": "src/StakeEngine.sol:850" + "location": "src/StakeEngine.sol:865" }, { "check": "divide-before-multiply", @@ -133,7 +133,7 @@ "element": "_settleBucket", "fingerprint": "16e0738556de802c", "impact": "Medium", - "location": "src/StakeEngine.sol:1249" + "location": "src/StakeEngine.sol:1264" }, { "check": "reentrancy-events", @@ -141,7 +141,7 @@ "element": "_chargeFee", "fingerprint": "1da534071601ff15", "impact": "Low", - "location": "src/PostRegistry.sol:341" + "location": "src/PostRegistry.sol:365" }, { "check": "timestamp", @@ -149,7 +149,7 @@ "element": "_projectLotValue", "fingerprint": "232dac1e92878286", "impact": "Low", - "location": "src/StakeEngine.sol:850" + "location": "src/StakeEngine.sol:865" }, { "check": "reentrancy-events", @@ -157,7 +157,7 @@ "element": "createClaim", "fingerprint": "28d689470fc42fac", "impact": "Low", - "location": "src/PostRegistry.sol:164" + "location": "src/PostRegistry.sol:170" }, { "check": "timestamp", @@ -165,7 +165,7 @@ "element": "_maybeSnapshot", "fingerprint": "295a69aafc4220d2", "impact": "Low", - "location": "src/StakeEngine.sol:576" + "location": "src/StakeEngine.sol:591" }, { "check": "incorrect-equality", @@ -173,7 +173,7 @@ "element": "_projectBucket", "fingerprint": "29f8e8529df820c6", "impact": "Medium", - "location": "src/StakeEngine.sol:1224" + "location": "src/StakeEngine.sol:1239" }, { "check": "calls-loop", @@ -197,7 +197,7 @@ "element": "stake", "fingerprint": "2bc8456d5b7492a2", "impact": "Medium", - "location": "src/StakeEngine.sol:432" + "location": "src/StakeEngine.sol:447" }, { "check": "reentrancy-no-eth", @@ -205,7 +205,7 @@ "element": "stake", "fingerprint": "2bc8456d5b7492a2", "impact": "Medium", - "location": "src/StakeEngine.sol:432" + "location": "src/StakeEngine.sol:447" }, { "check": "naming-convention", @@ -237,7 +237,7 @@ "element": "_applySMaxDecay", "fingerprint": "38b7a222c16b58ec", "impact": "Informational", - "location": "src/StakeEngine.sol:969" + "location": "src/StakeEngine.sol:984" }, { "check": "costly-loop", @@ -245,7 +245,7 @@ "element": "_applySMaxDecay", "fingerprint": "38b7a222c16b58ec", "impact": "Informational", - "location": "src/StakeEngine.sol:969" + "location": "src/StakeEngine.sol:984" }, { "check": "costly-loop", @@ -253,7 +253,7 @@ "element": "_applySMaxDecay", "fingerprint": "38b7a222c16b58ec", "impact": "Informational", - "location": "src/StakeEngine.sol:969" + "location": "src/StakeEngine.sol:984" }, { "check": "timestamp", @@ -261,7 +261,7 @@ "element": "_applyEpoch", "fingerprint": "3c84c785fc3a4a9e", "impact": "Low", - "location": "src/StakeEngine.sol:711" + "location": "src/StakeEngine.sol:726" }, { "check": "divide-before-multiply", @@ -269,7 +269,7 @@ "element": "_forceSnapshot", "fingerprint": "409b648f0a57d48f", "impact": "Medium", - "location": "src/StakeEngine.sol:592" + "location": "src/StakeEngine.sol:607" }, { "check": "missing-zero-check", @@ -285,7 +285,7 @@ "element": "withdraw", "fingerprint": "47ec8394947a0821", "impact": "Medium", - "location": "src/StakeEngine.sol:463" + "location": "src/StakeEngine.sol:478" }, { "check": "naming-convention", @@ -301,7 +301,7 @@ "element": "_forceSnapshot", "fingerprint": "4c1f738ab81c6ecc", "impact": "Medium", - "location": "src/StakeEngine.sol:592" + "location": "src/StakeEngine.sol:607" }, { "check": "reentrancy-benign", @@ -309,7 +309,7 @@ "element": "withdraw", "fingerprint": "54b347d868f44e5d", "impact": "Low", - "location": "src/StakeEngine.sol:463" + "location": "src/StakeEngine.sol:478" }, { "check": "reentrancy-benign", @@ -317,7 +317,7 @@ "element": "_forceSnapshot", "fingerprint": "558c6d1b47de3929", "impact": "Low", - "location": "src/StakeEngine.sol:592" + "location": "src/StakeEngine.sol:607" }, { "check": "timestamp", @@ -325,7 +325,7 @@ "element": "_applySMaxDecay", "fingerprint": "55a3a4425ec9ad04", "impact": "Low", - "location": "src/StakeEngine.sol:969" + "location": "src/StakeEngine.sol:984" }, { "check": "unused-state", @@ -341,7 +341,7 @@ "element": "getPostTotals", "fingerprint": "5781a6a7a7e41f03", "impact": "Low", - "location": "src/StakeEngine.sol:346" + "location": "src/StakeEngine.sol:361" }, { "check": "constable-states", @@ -357,7 +357,7 @@ "element": "createClaim", "fingerprint": "5eb0826aa11aa721", "impact": "Low", - "location": "src/PostRegistry.sol:164" + "location": "src/PostRegistry.sol:170" }, { "check": "naming-convention", @@ -389,7 +389,7 @@ "element": "setStake", "fingerprint": "63ab651bac26de93", "impact": "Low", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-benign", @@ -397,7 +397,7 @@ "element": "setStake", "fingerprint": "63ab651bac26de93", "impact": "Low", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "reentrancy-benign", @@ -405,7 +405,7 @@ "element": "setStake", "fingerprint": "63ab651bac26de93", "impact": "Low", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "timestamp", @@ -413,7 +413,7 @@ "element": "_projectSMaxDecay", "fingerprint": "66754b062ff1f03d", "impact": "Low", - "location": "src/StakeEngine.sol:1021" + "location": "src/StakeEngine.sol:1036" }, { "check": "constable-states", @@ -429,7 +429,7 @@ "element": "_projectBucket", "fingerprint": "691309b6162d9328", "impact": "Low", - "location": "src/StakeEngine.sol:1224" + "location": "src/StakeEngine.sol:1239" }, { "check": "reentrancy-events", @@ -437,7 +437,7 @@ "element": "createLink", "fingerprint": "76e3214f3fe65575", "impact": "Low", - "location": "src/PostRegistry.sol:203" + "location": "src/PostRegistry.sol:227" }, { "check": "naming-convention", @@ -447,13 +447,21 @@ "impact": "Informational", "location": "src/VSPToken.sol:51" }, + { + "check": "missing-inheritance", + "confidence": "High", + "element": "VSPToken", + "fingerprint": "7cee711575888e72", + "impact": "Informational", + "location": "src/VSPToken.sol:15" + }, { "check": "naming-convention", "confidence": "High", "element": "__gap", "fingerprint": "7d2b7acea2f7071e", "impact": "Informational", - "location": "src/StakeEngine.sol:1442" + "location": "src/StakeEngine.sol:1457" }, { "check": "missing-zero-check", @@ -461,7 +469,7 @@ "element": "newGuardian", "fingerprint": "7d61eb90ea93be95", "impact": "Low", - "location": "src/PostRegistry.sol:156" + "location": "src/PostRegistry.sol:162" }, { "check": "incorrect-equality", @@ -469,7 +477,7 @@ "element": "_projectSideTotal", "fingerprint": "81bb502f4ce4f0a8", "impact": "Medium", - "location": "src/StakeEngine.sol:817" + "location": "src/StakeEngine.sol:832" }, { "check": "naming-convention", @@ -493,7 +501,7 @@ "element": "createLink", "fingerprint": "8b81582945b7bb61", "impact": "Low", - "location": "src/PostRegistry.sol:203" + "location": "src/PostRegistry.sol:227" }, { "check": "dead-code", @@ -501,7 +509,7 @@ "element": "_addOrMergeLot", "fingerprint": "8d43dcb8be3d68a6", "impact": "Informational", - "location": "src/StakeEngine.sol:758" + "location": "src/StakeEngine.sol:773" }, { "check": "costly-loop", @@ -509,7 +517,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:913" + "location": "src/StakeEngine.sol:928" }, { "check": "costly-loop", @@ -517,7 +525,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:913" + "location": "src/StakeEngine.sol:928" }, { "check": "costly-loop", @@ -525,7 +533,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:913" + "location": "src/StakeEngine.sol:928" }, { "check": "costly-loop", @@ -533,7 +541,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:913" + "location": "src/StakeEngine.sol:928" }, { "check": "costly-loop", @@ -541,7 +549,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:913" + "location": "src/StakeEngine.sol:928" }, { "check": "costly-loop", @@ -549,7 +557,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:913" + "location": "src/StakeEngine.sol:928" }, { "check": "calls-loop", @@ -573,7 +581,7 @@ "element": "createLink", "fingerprint": "956d516f24679d7a", "impact": "Medium", - "location": "src/PostRegistry.sol:203" + "location": "src/PostRegistry.sol:227" }, { "check": "calls-loop", @@ -589,7 +597,7 @@ "element": "__gap", "fingerprint": "989231729e0f9bd4", "impact": "Informational", - "location": "src/StakeEngine.sol:1442" + "location": "src/StakeEngine.sol:1457" }, { "check": "divide-before-multiply", @@ -597,7 +605,7 @@ "element": "_applyEpoch", "fingerprint": "98ff1344e4051ab1", "impact": "Medium", - "location": "src/StakeEngine.sol:711" + "location": "src/StakeEngine.sol:726" }, { "check": "missing-zero-check", @@ -613,7 +621,7 @@ "element": "guardian_", "fingerprint": "9b2ea80b89bd3dc8", "impact": "Low", - "location": "src/StakeEngine.sol:269" + "location": "src/StakeEngine.sol:284" }, { "check": "naming-convention", @@ -629,7 +637,7 @@ "element": "_forceSnapshot", "fingerprint": "a8b99e3f7d4cd22a", "impact": "Low", - "location": "src/StakeEngine.sol:592" + "location": "src/StakeEngine.sol:607" }, { "check": "unused-state", @@ -637,7 +645,7 @@ "element": "__gap", "fingerprint": "af15571b0bcabf08", "impact": "Informational", - "location": "src/PostRegistry.sol:360" + "location": "src/PostRegistry.sol:384" }, { "check": "naming-convention", @@ -645,7 +653,7 @@ "element": "__gap", "fingerprint": "b3824838aa2866a7", "impact": "Informational", - "location": "src/PostRegistry.sol:360" + "location": "src/PostRegistry.sol:384" }, { "check": "timestamp", @@ -653,7 +661,7 @@ "element": "getUserLotInfo", "fingerprint": "b6ca2733f200d8fe", "impact": "Low", - "location": "src/StakeEngine.sol:384" + "location": "src/StakeEngine.sol:399" }, { "check": "timestamp", @@ -661,7 +669,7 @@ "element": "getUserStake", "fingerprint": "b83ace379b416a73", "impact": "Low", - "location": "src/StakeEngine.sol:358" + "location": "src/StakeEngine.sol:373" }, { "check": "naming-convention", @@ -685,7 +693,7 @@ "element": "newGuardian", "fingerprint": "c0af0203d581f6e1", "impact": "Low", - "location": "src/StakeEngine.sol:297" + "location": "src/StakeEngine.sol:312" }, { "check": "unused-state", @@ -701,7 +709,7 @@ "element": "createClaim", "fingerprint": "c356649d7e743b26", "impact": "Medium", - "location": "src/PostRegistry.sol:164" + "location": "src/PostRegistry.sol:170" }, { "check": "naming-convention", @@ -717,7 +725,7 @@ "element": "_applyEpoch", "fingerprint": "d22932c07ff62734", "impact": "Medium", - "location": "src/StakeEngine.sol:711" + "location": "src/StakeEngine.sol:726" }, { "check": "incorrect-equality", @@ -725,7 +733,7 @@ "element": "_applyEpoch", "fingerprint": "d22932c07ff62734", "impact": "Medium", - "location": "src/StakeEngine.sol:711" + "location": "src/StakeEngine.sol:726" }, { "check": "naming-convention", @@ -749,7 +757,7 @@ "element": "_projectSideTotal", "fingerprint": "d3d7859eec6e6003", "impact": "Low", - "location": "src/StakeEngine.sol:817" + "location": "src/StakeEngine.sol:832" }, { "check": "unused-state", @@ -765,7 +773,7 @@ "element": "setStake", "fingerprint": "dc1768351561c2fc", "impact": "Informational", - "location": "src/StakeEngine.sol:509" + "location": "src/StakeEngine.sol:524" }, { "check": "unused-state", @@ -781,7 +789,7 @@ "element": "_bucketRemove", "fingerprint": "e099e66a54a4b768", "impact": "Medium", - "location": "src/StakeEngine.sol:1107" + "location": "src/StakeEngine.sol:1122" }, { "check": "naming-convention", @@ -797,7 +805,7 @@ "element": "_updateSMax", "fingerprint": "e8f294563e30e6d4", "impact": "Informational", - "location": "src/StakeEngine.sol:913" + "location": "src/StakeEngine.sol:928" }, { "check": "reentrancy-benign", @@ -805,7 +813,7 @@ "element": "stake", "fingerprint": "e9daed0a65e92ae4", "impact": "Low", - "location": "src/StakeEngine.sol:432" + "location": "src/StakeEngine.sol:447" }, { "check": "divide-before-multiply", @@ -813,7 +821,7 @@ "element": "_projectLotValue", "fingerprint": "ea7d30779fe16e19", "impact": "Medium", - "location": "src/StakeEngine.sol:850" + "location": "src/StakeEngine.sol:865" }, { "check": "divide-before-multiply", @@ -821,7 +829,7 @@ "element": "_projectLotValue", "fingerprint": "ea7d30779fe16e19", "impact": "Medium", - "location": "src/StakeEngine.sol:850" + "location": "src/StakeEngine.sol:865" }, { "check": "unused-state", @@ -853,7 +861,7 @@ "element": "_projectSideTotal", "fingerprint": "fa189611f576492b", "impact": "Medium", - "location": "src/StakeEngine.sol:817" + "location": "src/StakeEngine.sol:832" }, { "check": "timestamp", @@ -861,7 +869,7 @@ "element": "_settleBucket", "fingerprint": "fabc5d90b8bd34ae", "impact": "Low", - "location": "src/StakeEngine.sol:1249" + "location": "src/StakeEngine.sol:1264" }, { "check": "missing-zero-check", @@ -869,7 +877,7 @@ "element": "guardian_", "fingerprint": "fce7e81d60c787ed", "impact": "Low", - "location": "src/PostRegistry.sol:129" + "location": "src/PostRegistry.sol:135" }, { "check": "naming-convention", @@ -880,7 +888,7 @@ "location": "src/VSPToken.sol:193" } ], - "generated_utc": "2026-07-02T10:32:51.868941+00:00", + "generated_utc": "2026-08-20T15:44:20.239425+00:00", "scope": "src (excludes lib,test,script)", - "total": 109 + "total": 119 } diff --git a/src/ScheduledEmitter.sol b/src/ScheduledEmitter.sol deleted file mode 100644 index 9e6a32a..0000000 --- a/src/ScheduledEmitter.sol +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @dev Minimal interface the emitter needs: totalSupply (for the cap terminating -/// check) + mint. VSPToken's own IVSPToken interface declares mint/burn/ -/// burnFrom but NOT totalSupply, so we declare a local interface that -/// includes both. totalSupply is the standard ERC20 selector; VSPToken is an -/// ERC20 and exposes it. -interface IEmitterToken { - function totalSupply() external view returns (uint256); - function mint(address to, uint256 amount) external; -} - -/// ############################ SUPERSEDED — NOT DEPLOYED ################# -/// # 2026-07-29: the one-shot genesis supply model (patch_oneshot_genesis) # -/// # supersedes scheduled emission before this contract ever deployed. # -/// # Retained in-tree as the reference implementation of the # -/// # price-independent schedule design (see patches/log/oneshot-genesis.md # -/// # and corporate/legal-memo-2026-07.md C1). Do not wire into Deploy. # -/// ########################################################################### -/// @title ScheduledEmitter -/// @notice Immutable, nudge-driven, PRICE-INDEPENDENT VSP issuance. Replaces the -/// treasury worker's discretionary MM-funding mint with a fixed on-chain -/// schedule. A worker (or anyone, if permissionless) "nudges" emit(); the -/// contract mints AMOUNT to RECIPIENT at most once per INTERVAL, until -/// total supply reaches CAP, then latches finished forever. -/// -/// @dev DESIGN INVARIANT (legal + safety): every input the contract reads to -/// decide whether/how much to emit is PRICE-INDEPENDENT — meaningful even if -/// VSP had no market price. The only inputs are: -/// - block.timestamp vs INTERVAL (time) -/// - IVSPToken.totalSupply() vs CAP (a terminating/cap condition ONLY) -/// Supply is read ONLY as a terminating condition ("are we done?"), NEVER as -/// a demand/price signal ("does the market need more?"). In an AMM, -/// supply-responsive emission IS price-responsive emission (reserve ratio -/// links them) — which is issuer price-management. This contract must never -/// gain any input that would read differently if VSP had no price. -/// -/// Non-discretion (supply governance option "A"): this emitter and the -/// protocol-bound StakeEngine are the only minters. No standing EOA can mint. -/// The immutables below cannot be changed; the schedule is fixed at deploy. -/// -/// Worker unreliability is SAFE: a missed nudge only makes an emission LATE, -/// never early, never wrong-sized. Correctness is fully on-chain; only -/// liveness depends on the nudge. -contract ScheduledEmitter { - /// @notice The VSP token this emitter mints. Immutable. - IEmitterToken public immutable TOKEN; - /// @notice Where emitted VSP is minted to (treasury, or a pool/seed address). - address public immutable RECIPIENT; - /// @notice Minimum seconds between emissions. - uint256 public immutable INTERVAL; - /// @notice VSP minted per emission (in wei). - uint256 public immutable AMOUNT; - /// @notice Total-supply ceiling. Emission stops permanently once totalSupply - /// reaches CAP. Set very high / unreachable for effectively-uncapped - /// ongoing emission; set CAP == AMOUNT for a single premint tranche. - /// A terminating condition, NOT a discretionary control — immutable. - uint256 public immutable CAP; - /// @notice If true, emit() is caller-gated to WORKER; if false, permissionless - /// (anyone may nudge — the contract gates on time+cap regardless). - bool public immutable WORKER_ONLY; - /// @notice Authorized nudger when WORKER_ONLY is true. address(0) otherwise. - address public immutable WORKER; - - /// @notice Timestamp of the last successful emission (0 until first emit). - uint256 public lastEmission; - /// @notice Latches true once CAP is reached; emit() then always reverts. - bool public finished; - - /// @notice Emitted on every successful emission. - event Emitted(uint256 amount, uint256 at, uint256 totalSupplyAfter); - /// @notice Emitted once when CAP is reached and the emitter latches finished. - event Finished(uint256 at, uint256 totalSupplyFinal); - - error EmitterFinished(); - error TooSoon(uint256 nowTs, uint256 earliest); - error CapReached(uint256 supply, uint256 cap); - error NotWorker(); - error ZeroToken(); - error ZeroRecipient(); - error ZeroAmount(); - error ZeroInterval(); - error CapBelowAmount(); - - /// @param token_ VSP token (must be nonzero). - /// @param recipient_ mint destination (must be nonzero). - /// @param interval_ min seconds between emissions (must be > 0). - /// @param amount_ VSP per emission (must be > 0). - /// @param cap_ total-supply ceiling (must be >= amount_). - /// @param workerOnly_ if true, only worker_ may nudge emit(). - /// @param worker_ authorized nudger (required nonzero iff workerOnly_). - /// @param startAt_ lastEmission seed. Pass 0 to allow the first emission - /// immediately; pass a future ts to delay the first window. - constructor( - address token_, - address recipient_, - uint256 interval_, - uint256 amount_, - uint256 cap_, - bool workerOnly_, - address worker_, - uint256 startAt_ - ) { - if (token_ == address(0)) { - revert ZeroToken(); - } - if (recipient_ == address(0)) { - revert ZeroRecipient(); - } - if (interval_ == 0) { - revert ZeroInterval(); - } - if (amount_ == 0) { - revert ZeroAmount(); - } - if (cap_ < amount_) { - revert CapBelowAmount(); - } - if (workerOnly_ && worker_ == address(0)) { - revert NotWorker(); - } - - TOKEN = IEmitterToken(token_); - RECIPIENT = recipient_; - INTERVAL = interval_; - AMOUNT = amount_; - CAP = cap_; - WORKER_ONLY = workerOnly_; - WORKER = workerOnly_ ? worker_ : address(0); - // startAt_ seeds lastEmission. With startAt_==0 the first emit() is allowed - // as soon as block.timestamp >= INTERVAL (i.e. essentially immediately in - // practice, since 0 + INTERVAL is far in the past). To delay the first - // emission to time T, pass startAt_ = T - INTERVAL. - lastEmission = startAt_; - } - - /// @notice Nudge the schedule. Mints AMOUNT (or the final partial tranche) to - /// RECIPIENT iff INTERVAL has elapsed and CAP is not yet reached. - /// Reverts otherwise. Idempotent-safe to call as often as desired. - /// @return minted the amount actually minted this call. - function emit_() external returns (uint256 minted) { - if (finished) { - revert EmitterFinished(); - } - if (WORKER_ONLY && msg.sender != WORKER) { - revert NotWorker(); - } - - uint256 earliest = lastEmission + INTERVAL; - if (block.timestamp < earliest) { - revert TooSoon(block.timestamp, earliest); - } - - uint256 supply = TOKEN.totalSupply(); - if (supply >= CAP) { - // defensive: should have latched already, but never emit over cap - finished = true; - emit Finished(block.timestamp, supply); - revert CapReached(supply, CAP); - } - - // clamp the final tranche so total supply never exceeds CAP - uint256 toMint = AMOUNT; - unchecked { - uint256 room = CAP - supply; // supply < CAP guaranteed above - if (toMint > room) { - toMint = room; - } - } - - TOKEN.mint(RECIPIENT, toMint); - lastEmission = block.timestamp; - minted = toMint; - - uint256 supplyAfter = TOKEN.totalSupply(); - emit Emitted(toMint, block.timestamp, supplyAfter); - - if (supplyAfter >= CAP) { - finished = true; - emit Finished(block.timestamp, supplyAfter); - } - } - - /// @notice View: is an emission currently due (time elapsed, not finished, - /// under cap)? Lets a worker/keeper cheaply decide whether to nudge. - function emissionDue() external view returns (bool) { - if (finished) { - return false; - } - if (block.timestamp < lastEmission + INTERVAL) { - return false; - } - return TOKEN.totalSupply() < CAP; - } - - /// @notice View: earliest timestamp the next emission can occur. - function nextEmissionTime() external view returns (uint256) { - return lastEmission + INTERVAL; - } -} diff --git a/src/StakeEngine.sol b/src/StakeEngine.sol index 4cd5342..ee234d9 100644 --- a/src/StakeEngine.sol +++ b/src/StakeEngine.sol @@ -116,8 +116,23 @@ contract StakeEngine is GovernedUpgradeable { /// @notice Hard floor on snapshotPeriod. Prevents gas-grief at sub-hour periods. uint256 public constant MIN_SNAPSHOT_PERIOD = 1 hours; - /// @notice Hard cap on snapshotPeriod. Prevents yield freeze at multi-year periods. - uint256 public constant MAX_SNAPSHOT_PERIOD = 365 days; + /// @notice Hard cap on snapshotPeriod. Prevents yield freeze at multi-year + /// periods AND closes the mid-window accrual asymmetry. + /// @dev patch_sec_jit_window (2026-08-19, external report VSP-SEC-001): + /// settlement scales the rate by `epochsElapsed` and applies the result + /// to whatever lots exist at settlement time -- `StakeLot.entryEpoch` is + /// stored but never read. Whenever snapshotPeriod > EPOCH_LENGTH the + /// snapshot is SUPPRESSED mid-window, so (a) a lot joining late in the + /// window collects the whole window's accrual, and (b) a lot leaving + /// before the window closes escapes the whole window's decay. + /// Capping the period at one epoch makes `periodInEpochs == 1`, so any + /// interaction settles every elapsed epoch BEFORE mutating the lot set + /// (stake() and withdraw() both call _maybeSnapshot first) -- which + /// closes both directions. + /// Prorating by entryEpoch was the reporter's suggestion; it fixes only + /// direction (a), and cannot fix it for the pooled tail bucket at all, + /// since _settleBucket is an O(1) index rebase with no per-entry epochs. + uint256 public constant MAX_SNAPSHOT_PERIOD = EPOCH_LENGTH; /// @notice Hard cap on sMaxDecayMaxEpochs. Prevents OOG in _projectSMaxDecay. uint256 public constant MAX_SMAX_DECAY_EPOCHS = 10000; // bundle05_a: G-9/G-10 bounds (10M VSP cap on stake amount and setStake target). diff --git a/test/GovernanceTransfer.t.sol b/test/GovernanceTransfer.t.sol index cdf7a5a..75612cf 100644 --- a/test/GovernanceTransfer.t.sol +++ b/test/GovernanceTransfer.t.sol @@ -147,7 +147,7 @@ contract GovernanceTransferTest is Test { vm.prank(newGov); engine.acceptGovernance(); - uint256 newPeriod = 7 days; + uint256 newPeriod = 6 hours; // patch_sec_jit_window: must be <= MAX_SNAPSHOT_PERIOD vm.prank(newGov); engine.setSnapshotPeriod(newPeriod); assertEq(engine.snapshotPeriod(), newPeriod, "newGov should be able to set period"); @@ -255,7 +255,7 @@ contract GovernanceTransferTest is Test { // From here on, every onlyGovernance call must be initiated by // the timelock (in production, scheduled by the Safe). vm.prank(timelock); - engine.setSnapshotPeriod(2 days); - assertEq(engine.snapshotPeriod(), 2 days); + engine.setSnapshotPeriod(6 hours); // patch_sec_jit_window: <= MAX_SNAPSHOT_PERIOD + assertEq(engine.snapshotPeriod(), 6 hours); } } diff --git a/test/JitWindowRegression.t.sol b/test/JitWindowRegression.t.sol new file mode 100644 index 0000000..a33c9a3 --- /dev/null +++ b/test/JitWindowRegression.t.sol @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {StakeEngine} from "../src/StakeEngine.sol"; +import {MockVSP} from "./mocks/MockVSP.sol"; +import {MockProtocolPolicy} from "./mocks/MockProtocolPolicy.sol"; + +/// Regression suite for VSP-SEC-001 (external report, 2026-08-19): +/// "StakeLot.entryEpoch is stored but never read in accrual." +/// +/// Mechanism: _forceSnapshot scales the interest rate by +/// `epochsElapsed = currentEpoch - lastSnapshotEpoch` and _applyEpoch hands that +/// rate to every lot present AT SETTLEMENT TIME, with no reference to when the +/// lot entered. _maybeSnapshot only fires once `snapshotPeriod` has elapsed, so +/// whenever snapshotPeriod > EPOCH_LENGTH there is a window during which the +/// snapshot is suppressed and the lot set can be mutated: +/// +/// J1 a lot joining late in the window collects the WHOLE window's accrual +/// J2 a lot leaving before the window closes escapes the WHOLE window's decay +/// +/// Fix under test (patch_sec_jit_window): MAX_SNAPSHOT_PERIOD == EPOCH_LENGTH, +/// so periodInEpochs is always 1 and every interaction settles all elapsed +/// epochs BEFORE mutating the lot set (stake() and withdraw() both call +/// _maybeSnapshot first). Both directions close. +/// +/// NOTE on the alternative fix: prorating each lot by entryEpoch addresses J1 +/// only, and cannot address it for the pooled tail bucket at all — _settleBucket +/// is an O(1) index rebase with no per-entry epochs. That is why the cap was +/// chosen over proration. +contract JitWindowRegressionTest is Test { + StakeEngine engine; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant POST = 1; + uint8 constant SUPPORT = 0; + uint8 constant CHALLENGE = 1; + uint256 constant STAKE = 1_000e18; + + address honest = makeAddr("honest"); + address jit = makeAddr("jit"); + address loser = makeAddr("loser"); + + function setUp() public { + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + engine = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + + address[3] memory actors = [honest, jit, loser]; + for (uint256 i = 0; i < actors.length; i++) { + vsp.mint(actors[i], 1e36); + vm.prank(actors[i]); + vsp.approve(address(engine), type(uint256).max); + } + vm.warp(30 days); // land well past epoch 0 + } + + // ── The guard itself ─────────────────────────────────────────────── + + /// The cap is what makes the window unreachable: with the period pinned to + /// one epoch, _maybeSnapshot can never be suppressed across epochs. + function test_maxSnapshotPeriod_is_one_epoch() public view { + assertEq(engine.MAX_SNAPSHOT_PERIOD(), engine.EPOCH_LENGTH(), "cap must equal one epoch"); + assertLe(engine.snapshotPeriod(), engine.MAX_SNAPSHOT_PERIOD(), "live period within cap"); + } + + /// Governance cannot re-open the window. THIS is the regression that would + /// have failed before the fix (setSnapshotPeriod(7 days) used to succeed). + function test_governance_cannot_open_a_multi_epoch_window() public { + vm.expectRevert(StakeEngine.PeriodOutOfBounds.selector); + engine.setSnapshotPeriod(2 days); + vm.expectRevert(StakeEngine.PeriodOutOfBounds.selector); + engine.setSnapshotPeriod(7 days); + vm.expectRevert(StakeEngine.PeriodOutOfBounds.selector); + engine.setSnapshotPeriod(365 days); + // sub-epoch periods remain settable (they already behave as one epoch) + engine.setSnapshotPeriod(6 hours); + assertEq(engine.snapshotPeriod(), 6 hours); + } + + // ── J1: late joiner must not capture the window ──────────────────── + + /// An honest staker holds a winning position for 7 epochs. A JIT staker + /// joins in the 7th. Under the vulnerability the JIT lot collected ~the full + /// 7-epoch rate (reported: 99.78%, ~6x its fair share). With the cap, the + /// JIT staker's own stake() settles the elapsed epochs BEFORE its lot is + /// added, so it earns nothing for time it was not present. + function test_J1_lateJoiner_earns_nothing_for_the_elapsed_window() public { + vm.prank(honest); + engine.stake(POST, SUPPORT, STAKE); + vm.prank(loser); + engine.stake(POST, CHALLENGE, STAKE / 4); // make SUPPORT the winning side + + skip(7 days); // no interaction: accrual accumulates unsettled + + vm.prank(jit); + engine.stake(POST, SUPPORT, STAKE); // settles first, then joins + + // settle once more so any pending epoch is materialised for both + skip(1 days); + vm.prank(loser); + engine.stake(POST, CHALLENGE, 1e18); + + uint256 honestAmt = _amountOf(honest, SUPPORT); + uint256 jitAmt = _amountOf(jit, SUPPORT); + + assertGt(honestAmt, STAKE, "honest staker should have accrued"); + // JIT was present for ~1 epoch vs honest's 8; its gain must be a small + // fraction of honest's, not parity. + uint256 honestGain = honestAmt - STAKE; + uint256 jitGain = jitAmt > STAKE ? jitAmt - STAKE : 0; + assertLt(jitGain * 2, honestGain, "JIT gain must be far below the long-held lot's"); + } + + // ── J2: early leaver must not dodge the decay ────────────────────── + + /// The mirror image. A losing lot tries to exit mid-window to escape the + /// burn. With the cap, withdraw() settles the elapsed epochs first, so the + /// loss is materialised before the exit. + function test_J2_earlyLeaver_cannot_dodge_the_decay() public { + vm.prank(honest); + engine.stake(POST, SUPPORT, STAKE); // winning side + vm.prank(loser); + engine.stake(POST, CHALLENGE, STAKE); // losing side + + // tilt support decisively so the challenge side decays + vm.prank(honest); + engine.stake(POST, SUPPORT, STAKE * 3); + + uint256 bookedBefore = _amountOf(loser, CHALLENGE); + skip(7 days); // unsettled decay accumulates against the loser + + // The dodge attempt: withdraw the pre-decay balance. It MUST fail, + // because withdraw() settles the elapsed epochs before touching the + // lot -- the decay is materialised first, so that balance is gone. + vm.prank(loser); + vm.expectRevert(StakeEngine.NotEnoughStake.selector); + engine.withdraw(POST, CHALLENGE, bookedBefore, false); + + // What actually remains is strictly less than the principal. + uint256 remaining = _amountOf(loser, CHALLENGE); + assertLt(remaining, STAKE, "decay must have been applied before any exit"); + + uint256 balBefore = vsp.balanceOf(loser); + vm.prank(loser); + engine.withdraw(POST, CHALLENGE, remaining, false); + uint256 received = vsp.balanceOf(loser) - balBefore; + assertLe(received, remaining, "cannot receive more than the settled balance"); + assertLt(received, STAKE, "loser must not exit whole"); + } + + function _amountOf(address who, uint8 side) internal view returns (uint256) { + return engine.getUserStake(who, POST, side); + } +} diff --git a/test/ScheduledEmitter.t.sol b/test/ScheduledEmitter.t.sol deleted file mode 100644 index 243f40f..0000000 --- a/test/ScheduledEmitter.t.sol +++ /dev/null @@ -1,235 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "forge-std/Test.sol"; -import "../src/ScheduledEmitter.sol"; -import "../src/interfaces/IVSPToken.sol"; - -/// @dev Minimal mock token: tracks totalSupply, mints unconditionally when called -/// by an allowed minter. Enough to exercise the emitter's logic in isolation -/// (the real VSPToken time-window cap is tested separately in its own suite). -contract MockVSP is IVSPToken { - uint256 public totalSupply; - mapping(address => bool) public minter; - mapping(address => uint256) public balanceOf; - - function setMinter(address who, bool ok) external { - minter[who] = ok; - } - - function mint(address to, uint256 amount) external override { - require(minter[msg.sender], "not minter"); - totalSupply += amount; - balanceOf[to] += amount; - } - function burn(uint256) external override {} - function burnFrom(address, uint256) external override {} -} - -contract ScheduledEmitterTest is Test { - MockVSP token; - address recipient = makeAddr("recipient"); - address worker = makeAddr("worker"); - address stranger = makeAddr("stranger"); - - uint256 constant INTERVAL = 30 days; - uint256 constant AMOUNT = 1000e18; - uint256 constant CAP = 10000e18; - - function setUp() public { - token = new MockVSP(); - vm.warp(1_700_000_000); // a realistic start ts, not 0 - } - - function _deploy(bool workerOnly, uint256 cap, uint256 startAt) internal returns (ScheduledEmitter e) { - e = new ScheduledEmitter( - address(token), recipient, INTERVAL, AMOUNT, cap, workerOnly, workerOnly ? worker : address(0), startAt - ); - token.setMinter(address(e), true); - } - - // ---- constructor guards ---- - function test_ctor_rejects_zero_token() public { - vm.expectRevert(ScheduledEmitter.ZeroToken.selector); - new ScheduledEmitter(address(0), recipient, INTERVAL, AMOUNT, CAP, false, address(0), 0); - } - - function test_ctor_rejects_zero_recipient() public { - vm.expectRevert(ScheduledEmitter.ZeroRecipient.selector); - new ScheduledEmitter(address(token), address(0), INTERVAL, AMOUNT, CAP, false, address(0), 0); - } - - function test_ctor_rejects_zero_interval() public { - vm.expectRevert(ScheduledEmitter.ZeroInterval.selector); - new ScheduledEmitter(address(token), recipient, 0, AMOUNT, CAP, false, address(0), 0); - } - - function test_ctor_rejects_zero_amount() public { - vm.expectRevert(ScheduledEmitter.ZeroAmount.selector); - new ScheduledEmitter(address(token), recipient, INTERVAL, 0, CAP, false, address(0), 0); - } - - function test_ctor_rejects_cap_below_amount() public { - vm.expectRevert(ScheduledEmitter.CapBelowAmount.selector); - new ScheduledEmitter(address(token), recipient, INTERVAL, AMOUNT, AMOUNT - 1, false, address(0), 0); - } - - function test_ctor_rejects_workeronly_zero_worker() public { - vm.expectRevert(ScheduledEmitter.NotWorker.selector); - new ScheduledEmitter(address(token), recipient, INTERVAL, AMOUNT, CAP, true, address(0), 0); - } - - // ---- time gate ---- - function test_first_emit_allowed_when_startAt_zero() public { - ScheduledEmitter e = _deploy(false, CAP, 0); - uint256 minted = e.emit_(); - assertEq(minted, AMOUNT); - assertEq(token.totalSupply(), AMOUNT); - assertEq(token.balanceOf(recipient), AMOUNT); - } - - function test_emit_reverts_before_interval() public { - ScheduledEmitter e = _deploy(false, CAP, block.timestamp); - // startAt = now, so earliest = now + INTERVAL; immediate emit too soon - vm.expectRevert( - abi.encodeWithSelector(ScheduledEmitter.TooSoon.selector, block.timestamp, block.timestamp + INTERVAL) - ); - e.emit_(); - } - - function test_emit_allowed_after_interval() public { - ScheduledEmitter e = _deploy(false, CAP, block.timestamp); - vm.warp(block.timestamp + INTERVAL); - e.emit_(); - assertEq(token.totalSupply(), AMOUNT); - } - - function test_second_emit_reverts_until_next_interval() public { - ScheduledEmitter e = _deploy(false, CAP, 0); - e.emit_(); // t0 - uint256 t0 = block.timestamp; - vm.warp(t0 + INTERVAL - 1); - vm.expectRevert(abi.encodeWithSelector(ScheduledEmitter.TooSoon.selector, block.timestamp, t0 + INTERVAL)); - e.emit_(); - vm.warp(t0 + INTERVAL); - e.emit_(); // now allowed - assertEq(token.totalSupply(), 2 * AMOUNT); - } - - // ---- worker unreliability: late nudge emits once, not catch-up ---- - function test_late_nudge_emits_once_not_catchup() public { - ScheduledEmitter e = _deploy(false, CAP, 0); - e.emit_(); // t0 - // wait 5 intervals, then nudge once - vm.warp(block.timestamp + 5 * INTERVAL); - e.emit_(); - // only ONE emission happened despite 5 intervals elapsing (no catch-up) - assertEq(token.totalSupply(), 2 * AMOUNT); - } - - // ---- cap: premint (cap == amount) ---- - function test_premint_single_tranche_then_finished() public { - ScheduledEmitter e = _deploy(false, AMOUNT, 0); // cap == amount - e.emit_(); - assertEq(token.totalSupply(), AMOUNT); - assertTrue(e.finished()); - // any further nudge reverts finished - vm.warp(block.timestamp + INTERVAL); - vm.expectRevert(ScheduledEmitter.EmitterFinished.selector); - e.emit_(); - } - - // ---- cap: final partial tranche is clamped ---- - function test_final_tranche_clamped_to_cap() public { - // cap = 2500, amount = 1000 -> emissions 1000,1000, then 500 (clamped) - ScheduledEmitter e = _deploy(false, 2500e18, 0); - e.emit_(); // 1000 at t=start - // advance a FULL interval past each emission explicitly (avoid reusing a - // stale block.timestamp base, which would land two warps on the same ts) - vm.warp(e.nextEmissionTime()); - e.emit_(); // 2000 - vm.warp(e.nextEmissionTime()); - uint256 minted = e.emit_(); // clamp to 500 - assertEq(minted, 500e18); - assertEq(token.totalSupply(), 2500e18); - assertTrue(e.finished()); - } - - // ---- cap: pre-existing supply near cap ---- - function test_respects_existing_supply_toward_cap() public { - // simulate other minters already produced supply - token.setMinter(address(this), true); - token.mint(address(0xdead), 9500e18); - ScheduledEmitter e = _deploy(false, 10000e18, 0); // cap 10000 - uint256 minted = e.emit_(); // room = 500 - assertEq(minted, 500e18); - assertTrue(e.finished()); - } - - // ---- access control ---- - function test_workeronly_stranger_reverts() public { - ScheduledEmitter e = _deploy(true, CAP, 0); - vm.prank(stranger); - vm.expectRevert(ScheduledEmitter.NotWorker.selector); - e.emit_(); - } - - function test_workeronly_worker_ok() public { - ScheduledEmitter e = _deploy(true, CAP, 0); - vm.prank(worker); - e.emit_(); - assertEq(token.totalSupply(), AMOUNT); - } - - function test_permissionless_stranger_ok() public { - ScheduledEmitter e = _deploy(false, CAP, 0); - vm.prank(stranger); - e.emit_(); // contract gates on time+cap, not caller - assertEq(token.totalSupply(), AMOUNT); - } - - // ---- views ---- - function test_emissionDue_and_nextTime() public { - ScheduledEmitter e = _deploy(false, CAP, block.timestamp); - assertFalse(e.emissionDue()); // too soon - assertEq(e.nextEmissionTime(), block.timestamp + INTERVAL); - vm.warp(block.timestamp + INTERVAL); - assertTrue(e.emissionDue()); - e.emit_(); - assertFalse(e.emissionDue()); // just emitted - } - - // ---- INVARIANT: emission depends ONLY on time + cap, never on price ---- - // Encoded as: given identical (time, supply) state, emit() behaves - // identically regardless of any external "market" — there is no price - // input to vary. This test documents the property; the structural proof - // is that the contract has no oracle/price/reserve reads at all. - function test_invariant_no_price_dependence_behaves_purely_on_time_and_cap() public { - ScheduledEmitter a = _deploy(false, CAP, 0); - // Two identical emitters in identical time/supply state must behave - // identically — there is no other input that could differ. - a.emit_(); - uint256 supplyAfterA = token.totalSupply(); - - MockVSP token2 = new MockVSP(); - vm.warp(block.timestamp); // same ts - ScheduledEmitter b = - new ScheduledEmitter(address(token2), recipient, INTERVAL, AMOUNT, CAP, false, address(0), 0); - token2.setMinter(address(b), true); - b.emit_(); - assertEq(token2.totalSupply(), supplyAfterA, "emission must depend only on time+cap"); - } - - // ---- fuzz: never exceeds cap, never emits early ---- - function testFuzz_never_exceeds_cap(uint96 warpBy, uint8 nudges) public { - ScheduledEmitter e = _deploy(false, CAP, 0); - uint256 n = uint256(nudges) % 30; - for (uint256 i = 0; i < n; i++) { - vm.warp(block.timestamp + (uint256(warpBy) % (2 * INTERVAL)) + 1); - if (e.emissionDue()) { - try e.emit_() {} catch {} - } - } - assertLe(token.totalSupply(), CAP, "cap must never be exceeded"); - } -}