TimeCurve audit I-01: cap sale at 300 days; bound elapsed for TimeMath / LinearCharmPrice #124
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:open_issues
agents
agent:security_audit
agent:verify
area
timecurve
arena
arena-v2
audit
block:feature_review
block:security
bug
contracts
design
devops
docs
documentation
enhancement
epic
feature
frontend
indexer
low priority
megaeth
operations
ops
performance
QA
qa checklist
referrals
removal
security
smart-contracts
tech-debt
testing
time-arena
timecurve
visual-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
code/yieldomega#124
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Track smart contract audit finding I-01 (Long-running TimeCurve math can eventually revert for extreme elapsed/growth values) and implement an explicit 300-day wall-clock cap on TimeCurve sale “pricing time” so envelope + linear price math cannot hit PRB
expdomain failures oruint256overflows from unbounded elapsed.Source:
audits/audit_smartcontract_1777813071.md— § I-01 (TimeMath, LinearCharmPrice, TimeCurve).Problem statement
Audit I-01 (verbatim scope)
TimeMath.currentMinBuyuses PRB-Mathexp. Very largegrowthRateWad * elapsed(scaled by seconds-per-day) can overflow or exceed the exponent domain, causing view calls andbuyto revert (latent DoS).LinearCharmPrice.priceWadusesbasePriceWad + dailyIncrementWad * elapsed / 1 day, which can overflowuint256for extremeelapsedordailyIncrementWadcombinations.The report notes this is not expected under current sale timing assumptions, but becomes a real liveness risk if a sale is extended or left open far beyond intended duration.
Product / protocol requirement (this issue)
Cap sales at 300 days of elapsed sale time (from onchain
saleStart): after this bound, pricing inputs for the CHARM envelope andICharmPricemust plateau (usemin(elapsed, 300 days)) and interactive sale paths must not remain playable past that boundary unless explicitly redesigned.Clarify in implementation PR whether hard stop means:
buy/buyFor/ WarBow CL8Y spend paths afterblock.timestamp >= saleStart + 300 days(and timer extension logic never pushesdeadlinebeyondsaleStart + 300 days); orDefault interpretation for this issue: (1) strong cap — “sales” end by 300 × 86 400 seconds after
saleStart.Recommended fix (onchain)
Introduce an immutable or constant maximum sale calendar duration
MAX_SALE_ELAPSED_SEC=300 * 86_400.constantinTimeCurveif no storage migration is required; if governance needs flexibility later, use immutable set once ininitializewithrequire(_max <= reasonableUpperBound).Single source of truth for “pricing elapsed”
block.timestamp - saleStartin_buy/_charmBoundspaths with a_elapsedCappedForSale()(or reuse_elapsedForCharmPricingwith the cap applied consistently) so views,buy, and anycharmPrice.priceWad(elapsed)callers share the same elapsed value.Enforce the 300-day wall-clock sale end
_buy(and any parallel entrypoints:buyFor→_buy, WarBow paths that depend on sale liveness if applicable):require(block.timestamp < saleStart + MAX_SALE_ELAPSED_SEC, "TimeCurve: sale max duration")(exact revert string to be chosen for consistency with existing"TimeCurve: *"messages).TimeMath.extendDeadline/extendDeadlineOrResetBelowThresholdresults sodeadlinenever exceedssaleStart + MAX_SALE_ELAPSED_SEC(otherwise buys could remain valid after the cap while pricing was plateaued — inconsistent UX and economics).Defense in depth in
TimeMath(optional but valuable)currentMinBuy: document maximum safeelapsedfor a givengrowthRateWador add an explicitrequire/saturation branch if you keepexp(team decision: revert with clear error vs saturate minBuy — saturation changes economics and must be documented).LinearCharmPricedailyIncrementWadis governance-upgradable, add initializer / setter bounds sobase + increment * cappedElapsedcannot wrapuint256under theMAX_SALE_ELAPSED_SECcap (cheaprequireondailyIncrementWadvstype(uint256).maxwithmulDiv).Deploy-time alignment
timerCapSec,initialTimerSec, and extension math cannot encode a sale that logically outlives the 300-day product cap (document inPARAMETERS.md).Files likely to change
Solidity (authoritative)
contracts/src/TimeCurve.solelapsedfor_charmBounds/priceWad;requiresale end bysaleStart + 300d; clampdeadlineupdates tosaleStart + 300d.contracts/src/libraries/TimeMath.solmin(elapsed, cap)helper if shared; optional explicit revert on excessive exponent beforeexp.contracts/src/pricing/LinearCharmPrice.soldailyIncrementWad(or safety comment +requireinpriceWadif uncapped governance is possible).contracts/script/DeployDev.s.sol/contracts/script/UUPSDeployLib.solinitializesignature gains amaxSaleDuration(immutable pattern), thread argument through deploy scripts.contracts/PARAMETERS.mdtimerCapSec.Tests
contracts/test/TimeCurve.t.soldeadlineclamp; views plateau / remain callable; extension near boundary.contracts/test/TimeMath.t.solelapsedwith canonicalgrowthRateWad; optional overflowdailyIncrementtest ifLinearCharmPriceis bounded.contracts/test/LinearCharmPrice.t.soldailyIncrementWadupper bound: reject path.TimeCurveSaleDurationCap.t.solfocused fuzz:elapsed→ cap, timer edge cases.Documentation
docs/product/primitives.mdsaleStart; link toPARAMETERS.md.docs/testing/invariants-and-business-logic.mddeadlineclamp, compat with #114 / #115 hero/timer copy.docs/frontend/timecurve-views.mdended+ timestamps).docs/qa/YO-TimeCurve-QA-Checklist.mdsaleStart + 300d→ buys forbidden; charts use capped duration.contracts/README.mdTimeMath/ sale cap if architecture section lists risks mitigated.skills/play-timecurve-doubloon/SKILL.mdFrontend / indexer (if behavior surfaces)
frontend/src/lib/timeCurveMath.tsand call siteselapsedfor previews — mirrormin(elapsed, 300d)to match chain (avoid misleading bands when locally extrapolating).frontend/src/pages/timecurve/TimeCurveLiveCharts.tsxsaleDurationSecalready derived fromdeadline - saleStart; confirm charts whendeadlineis clamped tosaleStart + 300d.indexer/src/chain_timer.rsRegression tests (concrete)
TimeCurve— buy blocked after capsaleStart, warp tosaleStart + 300 * 86400, expectbuyreverts (andbuyFor/ router path if covered).buystill succeeds under existing timer rules.TimeCurve— deadline clampdeadlinepastsaleStart + 300d; assertdeadline == saleStart + 300d(ormin(..., cap)).TimeCurve— CHARM bounds / price views at boundarysaleStart + 299dvssaleStart + 301d: cappedelapsedyields identicalcurrentMinBuy/ envelope (pricing plateau).TimeMath— no revert under canonical growth forelapsed == 300dcurrentMinBuy(1e18, GROWTH_RATE_20PCT, 300 * 86400)returns finiteminBuy(andminBuy <= type(uint256).max / WADstyle sanity).(If implemented)
TimeMathexcessive exponentelapsedfar beyond cap with maliciousgrowthRateWadin a unit test expecting clear revert (deploy-time bound test) vs satiation.LinearCharmPriceoverflow regressiondailyIncrementWad = type(uint256).max / 2,priceWad(300d)either reverts at setter or reverts inpriceWadwith documented bound.Acceptance criteria
MAX_SALE_ELAPSED_SEC(or equivalent) is 300 days and documented incontracts/PARAMETERS.mdanddocs/product/primitives.md.buy/ fee-routing sale interactions that must end with the sale respectblock.timestamp < saleStart + 300 * 86400(per chosen strong-cap interpretation).deadlineupdates fromTimeMathnever exceedsaleStart + 300 * 86400.elapsedfed into_charmBoundsandcharmPrice.priceWadis capped consistently (_elapsedForCharmPricing/ shared helper).TimeMath.currentMinBuycannot brickTimeCurveviews atelapsed == 300dfor **canonicalgrowthRateWad(DeployDev / production default).LinearCharmPricecannotuint256overflow onpriceWadunderelapsed ≤ 300dwith allowed parameter bounds.forge testpasses for affected packages.docs/testing/invariants-and-business-logic.mdmaps the new invariant; cross-links from audit I-01.Multistep verification checklist
A. Local Forge
cd contracts && forge test --match-contract TimeCurve -vvforge test --match-contract TimeMath -vvforge test --match-contract LinearCharmPrice -vvforge testB. Anvil / dev stack (optional but recommended)
./scripts/start-local-anvil-stack.sh(or repo-documented minimal deploy).cast callCHARM bound / price view functions atsaleStart + 299dvs301d(capped plateau + buy revert).scripts/verify-*.shtouch TimeCurve timing, extend or add a small script that warps and assertsbuyreverts post-cap.C. Frontend / indexer smoke
GET /v1/timecurve/chain-timerstill consistent (deadline_sec ≤ sale_start_sec + 300d in seconds).D. Documentation / governance
stage3docs mention 300-day sale ceiling next totimerCapSec/initialTimerSec.References
audits/audit_smartcontract_1777813071.md(I-01)TimeMath.currentMinBuy:contracts/src/libraries/TimeMath.solLinearCharmPrice.priceWad:contracts/src/pricing/LinearCharmPrice.solTimeCurveelapsed + buy:contracts/src/TimeCurve.solLabels / relationships (for maintainers)
Suggested labels:
security,contracts,timecurve,audit,tech-debtRelated docs/issues: GitLab #114 (
startSaleAt), #115 (pre-open countdown), #48 (hero timer / phase derivation).mentioned in commit
0f6c4672damentioned in issue #117
@PlasticDigits — pulled commit
a574c63(post your #123 push). 4 prior FoT failures all clear, but 2 new failures remain. one is your I-01 fix needing a small ordering tweak, one is a fuzz-caught warbow edge case.Failure 1 —
test_gitlab124_buy_reverts_after_wall_clock_cap(your I-01 test)scenario: warp to
saleStart + MAX_SALE_ELAPSED_SEC + 1, attemptt.buy(1e18). expected the cap-exceeded revert. actual:TimeCurve: below min charmsbecause by 300 days deep, dynamic charm minimum has grown well above 1e18, and the bounds check fires before the wall-clock cap check.your test is correct — the spec said strong cap, and a buy attempt past the cap should revert with the cap message regardless of charm amount. fix is to move the
MAX_SALE_ELAPSED_SECguard earlier in_buy, beforecurrentCharmBoundsWadis consulted.Failure 2 —
testFuzz_warbow_steal_burn_exact_to_sink(fuzz catch)decoded counterexample:
stealer = address(57005) = 0x000000000000000000000000000000000000dead— the burn sink address itself. when stealer == BURN_SINK, the warbow steal burn is a self-transfer and the test'ssinkAfter - sinkBefore == WARBOW_STEAL_BURN_WADinvariant breaks. unlikely in practice (no real wallet uses 0xdead) but the contract semantically allows it.cheap mitigation:
require(msg.sender != BURN_SINK)on warbow paths, orvm.assumeto filter the burn address from fuzz seeds (kicks the can but matches spec).prior 4 FoT parity failures from #117/#123 batch all PASS now. 261 tests succeed total.
@PlasticDigits — dug into the failure trace, the failure is more nuanced than I first thought.
trace shows:
_warpPastBuyCooldownwarps tonextBuyAllowedAt[alice] = 28920000which equalssaleStart + MAX_SALE_ELAPSED_SECexactly. thent.buy(1e18)runs at that timestamp.contract guard at line 414:
require(block.timestamp <= saleStart + MAX_SALE_ELAPSED_SEC, ...)— boundary inclusive, so 28920000 == 28920000 passes the cap check. then bounds check fires with elapsed = maxSec, dynamic minCharm has grown past 1e18 → "below min charms".so the bug is one of:
test bug:
_warpPastBuyCooldownis clamping to exactly the cooldown timestamp which equals the cap boundary. should warp tomax(nextBuyAllowedAt[alice], saleStart + MAX_SALE_ELAPSED_SEC) + 1to land 1 second strictly past the cap.contract bug: line 414 should be strict
<instead of<=if the spec intent is "sales end at exactly maxSec". buttest_gitlab124_warbow_guard_reverts_after_wallpasses with the same<=semantics, suggesting<=is the intended boundary.most likely option 1.
_warpPastBuyCooldownis the problem — it warps to exactly the cooldown end which collides with the cap when those two timestamps align. quick fix is to update the test scenario, not the contract.still flagging since it's currently a CI-red test on
main.mentioned in issue #128
mentioned in commit
dadc16b3cdWe want to allow last buys to come in on last second so <= is correct
ack —
<=boundary is correct intent. so the failure is squarely in_warpPastBuyCooldownclamping to exactlysaleStart + MAX_SALE_ELAPSED_SEC. test fix: warp tomax(nextBuyAllowedAt[alice], saleStart + MAX_SALE_ELAPSED_SEC) + 1so the buy attempt lands strictly past the cap. will retest after the test patch lands.mentioned in issue #136
mentioned in commit
f5d5c1d482#124 follow-up (inclusive wall `\<=` at `saleStart + MAX_SALE_ELAPSED_SEC`)
Summary: Issue notes called out `_buy` ~L414 using `\<=` so last-second buys can succeed when the countdown still allows `block.timestamp \< deadline()` (typically after deadline clamps to `saleStart + MAX + 1`). Docs/spec wording that implied a strict wall was updated; no change from `\<=` to `\<` (per maintainer note on the issue).
Merged (already on `main`):
Verification checklist for @Brouie:
Issue left open as requested.
mentioned in commit
6a4f054fe8@PlasticDigits — verification PASS on the inclusive-wall fix. walked your checklist:
forge test --match-path test/TimeCurve.t.solgreen.test_gitlab124_buy_succeeds_at_hard_wall_second_when_deadline_clampedclean. full suite 310/310 ona421ba1.docs/testing/invariants-and-business-logic.mdreads cleanly — wall is<=, timer interaction spelled out, deadline clamp atsaleStart + MAX + 1documented.contracts/PARAMETERS.md'Sale wall-clock cap' row matches: 300 days, inclusive boundary, cap-message precedence._buyordering verified —require(block.timestamp <= saleStart + MAX_SALE_ELAPSED_SEC, ...)at L460 fires beforerequire(charmWad >= minCharm, ...)at L466. post-cap buy now reverts with cap message instead of the misleading 'below min charms'. WarBow paths share the same pattern (L606/621/692/721) — consistent.<=semantics with deadline clamped toMAX + 1lands the 'last buys can come in on last second' product intent without leaking elapsed past the 300d boundary into pricing math.ready for close on your side.
mentioned in issue #207