TimeCurve: launchedToken from unredeemed CHARM weight gets permanently locked (no sweep, mirrors M-01 PodiumPool gap) #128

Closed
opened 2026-05-04 07:24:19 +00:00 by Brouie · 8 comments
Brouie commented 2026-05-04 07:24:19 +00:00 (Migrated from gitlab.com)

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:

function redeemCharms() external nonReentrant {
    require(ended, "TimeCurve: not ended");
    require(charmRedemptionEnabled, "TimeCurve: charm redemptions disabled");
    require(charmWeight[msg.sender] > 0, "TimeCurve: no charm weight");
    require(totalCharmWeight > 0, "TimeCurve: zero charm supply");
    charmsRedeemed[msg.sender] = true;
    uint256 tokenOut = (totalTokensForSale * charmWeight[msg.sender]) / totalCharmWeight;
    require(tokenOut > 0, "TimeCurve: nothing to redeem");
    launchedToken.safeTransfer(msg.sender, tokenOut);
    emit CharmsRedeemed(msg.sender, tokenOut);
}

grep launchedToken across 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

  1. Lost-key wallets — at any TGE-scale launch, ~1-3% of buyers lose access to their wallet between buy and redeem. their share locks permanently.
  2. Wallets below tx-cost threshold — buyers whose redemption value < gas cost have no economic incentive to redeem. share locks.
  3. Forgotten participants — long sales (up to 300 days now per #124), buyers may forget. share locks.
  4. Integer division dust — 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)

  1. sweepUnredeemedLaunchedToken(address recipient) with delay guard — admin can sweep after block.timestamp >= ended_at + UNREDEEMED_GRACE_SEC (e.g., 1 year). recipient is governance-set, audited (PodiumResidualRecipient style).
  2. Permanent burn after grace — same shape but recipient is hardcoded burn sink. removes admin trust dimension.
  3. Document explicitly — add to product docs that unredeemed tokens are permanently locked, no sweep. acceptable design choice if you genuinely want hard one-shot semantics.

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

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`: ```solidity function redeemCharms() external nonReentrant { require(ended, "TimeCurve: not ended"); require(charmRedemptionEnabled, "TimeCurve: charm redemptions disabled"); require(charmWeight[msg.sender] > 0, "TimeCurve: no charm weight"); require(totalCharmWeight > 0, "TimeCurve: zero charm supply"); charmsRedeemed[msg.sender] = true; uint256 tokenOut = (totalTokensForSale * charmWeight[msg.sender]) / totalCharmWeight; require(tokenOut > 0, "TimeCurve: nothing to redeem"); launchedToken.safeTransfer(msg.sender, tokenOut); emit CharmsRedeemed(msg.sender, tokenOut); } ``` `grep launchedToken` across 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 1. **Lost-key wallets** — at any TGE-scale launch, ~1-3% of buyers lose access to their wallet between buy and redeem. their share locks permanently. 2. **Wallets below tx-cost threshold** — buyers whose redemption value < gas cost have no economic incentive to redeem. share locks. 3. **Forgotten participants** — long sales (up to 300 days now per #124), buyers may forget. share locks. 4. **Integer division dust** — `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) 1. **`sweepUnredeemedLaunchedToken(address recipient)` with delay guard** — admin can sweep after `block.timestamp >= ended_at + UNREDEEMED_GRACE_SEC` (e.g., 1 year). recipient is governance-set, audited (`PodiumResidualRecipient` style). 2. **Permanent burn after grace** — same shape but recipient is hardcoded burn sink. removes admin trust dimension. 3. **Document explicitly** — add to product docs that unredeemed tokens are permanently locked, no sweep. acceptable design choice if you genuinely want hard one-shot semantics. 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
Brouie commented 2026-05-04 08:14:03 +00:00 (Migrated from gitlab.com)

mentioned in issue #131

mentioned in issue #131
PlasticDigits commented 2026-05-04 08:43:48 +00:00 (Migrated from gitlab.com)

mentioned in commit 7eaa27014b

mentioned in commit 7eaa27014b6021085f7cff06781249beba0e36e9
PlasticDigits commented 2026-05-04 08:45:32 +00:00 (Migrated from gitlab.com)

GitLab #128 – verify sweep (merged)

Merged to main: feat(contracts): sweep unredeemed launched DOUB after grace (GitLab #128) (commit 7eaa270), plus indexer + docs/skills.

What shipped

  • TimeCurve: UNREDEEMED_LAUNCHED_TOKEN_GRACE_SEC = 7 days; saleEndedAt set in endSale(); sweepUnredeemedLaunchedToken() (owner, after grace, full launchedToken balance); setUnredeemedLaunchedTokenRecipient; repairSaleEndedAt for upgrades when ended && saleEndedAt == 0.
  • Index: events and migration 20260504180000_timecurve_unredeemed_launched_token.
  • DeployDev: setUnredeemedLaunchedTokenRecipient(ecoTreasury) after podium wiring.
  • Docs: 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 launchedToken still in TimeCurve 7 days after endSale may be swept to unredeemedLaunchedTokenRecipient (governance/treasury path). Remainder is not automatically sent elsewhere before that.


@brouie — please verify on staging/mainnet-readiness:

  • After redeemCharms / podium + full allocation, residual launchedToken in TimeCurve is expected; sweepUnredeemedLaunchedToken succeeds only after grace elapsed and recipient is set.
  • saleEndedAt matches endSale tx time; repairSaleEndedAt works once for legacy deployments with ended but saleEndedAt == 0.
  • Indexer picks up UnredeemedLaunchedTokenRecipientSet / UnredeemedLaunchedTokenSwept; migration applied on deploy.
  • Deploy script sets recipient; 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).

## GitLab #128 – verify sweep (merged) **Merged to `main`:** `feat(contracts): sweep unredeemed launched DOUB after grace (GitLab #128)` (commit `7eaa270`), plus indexer + docs/skills. **What shipped** - **TimeCurve:** `UNREDEEMED_LAUNCHED_TOKEN_GRACE_SEC = 7 days`; `saleEndedAt` set in `endSale()`; `sweepUnredeemedLaunchedToken()` (owner, after grace, full `launchedToken` balance); `setUnredeemedLaunchedTokenRecipient`; `repairSaleEndedAt` for upgrades when `ended && saleEndedAt == 0`. - **Index:** events and migration `20260504180000_timecurve_unredeemed_launched_token`. - **DeployDev:** `setUnredeemedLaunchedTokenRecipient(ecoTreasury)` after podium wiring. - **Docs:** `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 `launchedToken` still in TimeCurve **7 days after** `endSale` may be swept to `unredeemedLaunchedTokenRecipient` (governance/treasury path). Remainder is **not** automatically sent elsewhere before that. --- @brouie — please verify on staging/mainnet-readiness: - [ ] After `redeemCharms` / podium + full allocation, residual `launchedToken` in TimeCurve is expected; `sweepUnredeemedLaunchedToken` succeeds only after **grace elapsed** and recipient is set. - [ ] `saleEndedAt` matches `endSale` tx time; `repairSaleEndedAt` works once for legacy deployments with `ended` but `saleEndedAt == 0`. - [ ] Indexer picks up `UnredeemedLaunchedTokenRecipientSet` / `UnredeemedLaunchedTokenSwept`; migration applied on deploy. - [ ] Deploy script sets recipient; `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).
Brouie commented 2026-05-05 01:51:54 +00:00 (Migrated from gitlab.com)

@PlasticDigits — verification PASS on 7eaa270. walked your checklist:

  • residual sweep flow: sweepUnredeemedLaunchedToken gates correctly — ended (L728), saleEndedAt > 0 (L729), block.timestamp >= saleEndedAt + 7 days (L730), recipient != 0 (L735), bal > 0 (L737), onlyOwner, nonReentrant, safeTransfer. endSale() sets saleEndedAt = block.timestamp immediately after ended = true so no edge case where ended && saleEndedAt == 0 in same block.
  • repairSaleEndedAt one-shot lock checks ended, saleEndedAt == 0, endedAtTs > 0 && endedAtTs <= block.timestamp. UUPS-safe for legacy deployments.
  • indexer pickup: decoder.rs L147-148 sol! defs, L451/454 DecodedEvent variants, L929/937 dispatch arms with SIGNATURE_HASH match. migration 20260504180000_timecurve_unredeemed_launched_token applied. reorg.rs includes the new table in rollback.
  • DeployDev.s.sol:158 wires setUnredeemedLaunchedTokenRecipient(ecoTreasury) after podium wiring as documented.
  • forge test --match-test 'sweepUnredeemedLaunchedToken|repairSaleEndedAt|test_endSale' -vv green: 8/8 PASS including the after-grace happy path, before-grace revert, zero-recipient revert, nothing-to-sweep revert, and endSale saleEndedAt assertion. full suite 310/310 on a421ba1.
  • storage: __gap shrunk from [46] to [44] for the two new slots. UUPS layout safe.
    small note for completeness: repairSaleEndedAt accepts any endedAtTs up to block.timestamp so 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.
@PlasticDigits — verification PASS on `7eaa270`. walked your checklist: - residual sweep flow: `sweepUnredeemedLaunchedToken` gates correctly — `ended` (L728), `saleEndedAt > 0` (L729), `block.timestamp >= saleEndedAt + 7 days` (L730), recipient `!= 0` (L735), `bal > 0` (L737), `onlyOwner`, `nonReentrant`, `safeTransfer`. `endSale()` sets `saleEndedAt = block.timestamp` immediately after `ended = true` so no edge case where `ended && saleEndedAt == 0` in same block. - `repairSaleEndedAt` one-shot lock checks `ended`, `saleEndedAt == 0`, `endedAtTs > 0 && endedAtTs <= block.timestamp`. UUPS-safe for legacy deployments. - indexer pickup: `decoder.rs` L147-148 `sol!` defs, L451/454 `DecodedEvent` variants, L929/937 dispatch arms with `SIGNATURE_HASH` match. migration `20260504180000_timecurve_unredeemed_launched_token` applied. `reorg.rs` includes the new table in rollback. - `DeployDev.s.sol:158` wires `setUnredeemedLaunchedTokenRecipient(ecoTreasury)` after podium wiring as documented. - `forge test --match-test 'sweepUnredeemedLaunchedToken|repairSaleEndedAt|test_endSale' -vv` green: 8/8 PASS including the after-grace happy path, before-grace revert, zero-recipient revert, nothing-to-sweep revert, and `endSale` saleEndedAt assertion. full suite 310/310 on `a421ba1`. - storage: `__gap` shrunk from `[46]` to `[44]` for the two new slots. UUPS layout safe. small note for completeness: `repairSaleEndedAt` accepts any `endedAtTs` up to `block.timestamp` so 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.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-05 02:32:07 +00:00
Brouie commented 2026-05-05 04:59:21 +00:00 (Migrated from gitlab.com)

mentioned in issue #151

mentioned in issue #151
PlasticDigits commented 2026-05-05 05:45:38 +00:00 (Migrated from gitlab.com)

mentioned in issue #152

mentioned in issue #152
PlasticDigits commented 2026-05-05 05:47:55 +00:00 (Migrated from gitlab.com)

marked this issue as related to #152

marked this issue as related to #152
PlasticDigits commented 2026-05-13 16:07:18 +00:00 (Migrated from gitlab.com)

mentioned in issue #207

mentioned in issue #207
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/yieldomega#128
No description provided.