TimeCurve: launchedToken from unredeemed CHARM weight gets permanently locked (no sweep, mirrors M-01 PodiumPool gap) #128
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#128
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?
found during the post-audit security review pass (followup to #126).
Summary
TimeCurve.redeemCharms()is a one-shot per-wallet redemption (line 720-731) that pays out launchedToken proportional to charm weight. there is no admin path to sweep unredeemed launchedToken after the sale ends.participants who never redeem (lost keys, forgotten wallets, dead users, dust below tx-cost threshold) leave their proportional launchedToken share permanently locked in the TimeCurve contract.
this is the same shape as audit M-01 (PodiumPool empty-category stranding, fixed in #116 via
forwardPodiumResidual) but on the launchedToken side instead of the CL8Y side.Code
TimeCurve.sol:719-731:grep launchedTokenacross TimeCurve.sol returns 2 hits — line 347 (balance check on startSaleAt) and line 729 (transfer in redeemCharms). no sweep, no rescue, no admin recovery path.Loss vectors
tokenOut = (totalTokensForSale * charmWeight[msg.sender]) / totalCharmWeight. each redemption rounds down up to 1 wei. across N redemptions, max N-1 wei dust stranded. small but cumulative.Severity
Medium. not unprivileged extraction, but real value permanently locked. exactly the shape audit M-01 flagged on the CL8Y side. matches the audit's bug-class #11 "Locked funds".
Mitigations (mirror the M-01 fix pattern)
sweepUnredeemedLaunchedToken(address recipient)with delay guard — admin can sweep afterblock.timestamp >= ended_at + UNREDEEMED_GRACE_SEC(e.g., 1 year). recipient is governance-set, audited (PodiumResidualRecipientstyle).mitigation #1 is the cleanest analog to your M-01 / #116 fix. mitigation #3 is the do-nothing path with explicit disclosure.
Pre-deploy relevance
TimeCurve ships tonight. worth a decision before mainnet — add sweep, or document that unredeemed = forfeit. fix is small (~30 LoC + test), follows your existing #116 pattern.
cc @PlasticDigits
mentioned in issue #131
mentioned in commit
7eaa27014bGitLab #128 – verify sweep (merged)
Merged to
main:feat(contracts): sweep unredeemed launched DOUB after grace (GitLab #128)(commit7eaa270), plus indexer + docs/skills.What shipped
UNREDEEMED_LAUNCHED_TOKEN_GRACE_SEC = 7 days;saleEndedAtset inendSale();sweepUnredeemedLaunchedToken()(owner, after grace, fulllaunchedTokenbalance);setUnredeemedLaunchedTokenRecipient;repairSaleEndedAtfor upgrades whenended && saleEndedAt == 0.20260504180000_timecurve_unredeemed_launched_token.setUnredeemedLaunchedTokenRecipient(ecoTreasury)after podium wiring.docs/onchain/fee-routing-and-governance.md(#timecurve-unredeemed-launch-allocation-gitlab-128),docs/testing/invariants-and-business-logic.md,contracts/PARAMETERS.md,skills/play-timecurve-doubloon/SKILL.md.Sweep: any unredeemed
launchedTokenstill in TimeCurve 7 days afterendSalemay be swept tounredeemedLaunchedTokenRecipient(governance/treasury path). Remainder is not automatically sent elsewhere before that.@brouie — please verify on staging/mainnet-readiness:
redeemCharms/ podium + full allocation, residuallaunchedTokenin TimeCurve is expected;sweepUnredeemedLaunchedTokensucceeds only after grace elapsed and recipient is set.saleEndedAtmatchesendSaletx time;repairSaleEndedAtworks once for legacy deployments withendedbutsaleEndedAt == 0.UnredeemedLaunchedTokenRecipientSet/UnredeemedLaunchedTokenSwept; migration applied on deploy.forge test/ targeted*128*/ sweep tests pass in CI.Issue stays open until you sign off. Remainder / long-dust paths per product: sweep to governance if unclaimed after policy window (this implementation: 7-day grace to recipient).
@PlasticDigits — verification PASS on
7eaa270. walked your checklist:sweepUnredeemedLaunchedTokengates correctly —ended(L728),saleEndedAt > 0(L729),block.timestamp >= saleEndedAt + 7 days(L730), recipient!= 0(L735),bal > 0(L737),onlyOwner,nonReentrant,safeTransfer.endSale()setssaleEndedAt = block.timestampimmediately afterended = trueso no edge case whereended && saleEndedAt == 0in same block.repairSaleEndedAtone-shot lock checksended,saleEndedAt == 0,endedAtTs > 0 && endedAtTs <= block.timestamp. UUPS-safe for legacy deployments.decoder.rsL147-148sol!defs, L451/454DecodedEventvariants, L929/937 dispatch arms withSIGNATURE_HASHmatch. migration20260504180000_timecurve_unredeemed_launched_tokenapplied.reorg.rsincludes the new table in rollback.DeployDev.s.sol:158wiressetUnredeemedLaunchedTokenRecipient(ecoTreasury)after podium wiring as documented.forge test --match-test 'sweepUnredeemedLaunchedToken|repairSaleEndedAt|test_endSale' -vvgreen: 8/8 PASS including the after-grace happy path, before-grace revert, zero-recipient revert, nothing-to-sweep revert, andendSalesaleEndedAt assertion. full suite 310/310 ona421ba1.__gapshrunk from[46]to[44]for the two new slots. UUPS layout safe.small note for completeness:
repairSaleEndedAtaccepts anyendedAtTsup toblock.timestampso an owner repairing late could effectively shift the 7d grace clock forward. governance trust thing, fine for the upgrade-only use case, worth a doc line if not already there.ready for close on your side.
mentioned in issue #151
mentioned in issue #152
marked this issue as related to #152
mentioned in issue #207