[Audit M-02] TimeCurveBuyRouter: net-only WETH/stable refund + ETH/ERC20 rescue #117
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#117
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?
Source
Tracked from moderate severity finding M-02 in repository file
audits/audit_smartcontract_1777813071.md(TimeCurveBuyRouterrefunds full WETH/stable balance to the buyer).Summary
After a Kumbaya
exactOutputswap,TimeCurveBuyRouter.buyViaKumbayarefunds the entire post-swap balance of WETH (PAY_ETH) orstableToken(PAY_STABLE) held by the router tomsg.sender. That balance can include tokens that sat on the router before the current transaction—not only unused input frommsg.value/safeTransferFromfor this call. Anyone may pre-seed WETH/stable on the router; a subsequent buyer receives those stranded balances as “refund,” creating an unintended subsidy and weakening the invariant that the router “should never hold custody” without enforcement.Contrast: CL8Y residuals are explicitly routed to
cl8yProtocolTreasury(Cl8ySurplusToProtocol), with Forge coverage (test_buyViaKumbaya_preseed_cl8y_surplus_routes_to_protocol_treasury). No equivalent explicit policy exists for WETH/stable.Impact
msg.senderon a qualifyingbuyViaKumbayacall.Code reference (today)
Refund paths use
balanceOf(address(this))after the swap (TimeCurveBuyRouter.solETH branch ~withdraw +payable(msg.sender).call, stable branch ~safeTransferfull balance), not net-of-opening-balance:Recommended remediation
1. Refund only net input-token balance attributable to this call
PAY_STABLE): BeforestableToken.safeTransferFrom(msg.sender, address(this), amountInMaximum), recordstableBefore = stableToken.balanceOf(address(this)). AfterexactOutput, computedelta = stableToken.balanceOf(address(this)) - stableBeforeand refundmsg.senderwithdeltaonly (whendelta > 0). This equals unused pulled allowance regardless of stranded pre-seeded stable.PAY_ETH): Beforeweth.deposit{value: msg.value}(), recordwethBefore = IERC20(address(weth)).balanceOf(address(this)). After swap, computerefundWeth = weth.balanceOf(address(this)) - wethBeforeand only unwrap + send that ETH tomsg.sender. Any positive opening WETH/stray accounting stays on the router until handled by governance rescue (below).Invariant to preserve: Caller receives exactly their unused marginal input for this invocation; stranded third-party deposits are not swept to the buyer.
2. Rescue / sweep for stranded native ETH and ERC20 (governance-owned)
Introduce
onlyOwner-style admin onTimeCurveBuyRouteror reuse an existing Yieldomega multisig primitive consistent withTimeCurveowner:rescueETH(address payable to, uint256 amount)— forward native balance (covers accidentalreceive()/ donations). Optional: allowamount == type(uint256).maxto mean “full balance”.rescueERC20(address token, address to, uint256 amount)—SafeERC20.safeTransferfor arbitrary ERC20 (including WETH, stable, wrongly sent CL8Y if ever needed)—with documented exclusions only if policy forbids sweeping CL8Y (today CL8Y dust is intended for treasury; rescue may still be required for unrelated tokens).Governance expectation: Restrict to multisig / same trust domain as
TimeCurveadmin; considerOwnable2Steptransfer pattern; emitEthRescued/Erc20Rescued(names illustrative) for monitoring.3. Operational note
Ensure staging / production deploy scripts pass the explicit rescue owner/treasury argument if constructors change—Anvil
DeployKumbayaAnvilFixturescurrently deploys the router withdeployerascl8yProtocolTreasury; rescue owner should match intended ops wallet.Regression tests (Forge)
Add to
contracts/test/TimeCurveBuyRouter.t.sol(and snapshotVerifyTimeCurveBuyRouterAnvil.t.solif behavior is observable live):PAY_ETHbuyViaKumbayamsg.value− spent refund only (or equivalent zero net subsidy); stranded WETH remains on router until rescuePAY_STABLEbuytovm.deal(router)), owner rescuesInvariant tests: Prefer property-style checks:
refund + spent + stranded_opening == marginal_user_input(with opening measured at snapshot).Files likely to change
Solidity (authoritative behavior)
contracts/src/TimeCurveBuyRouter.solcontracts/test/TimeCurveBuyRouter.t.solcontracts/test/VerifyTimeCurveBuyRouterAnvil.t.solcontracts/script/DeployKumbayaAnvilFixtures.s.solowner/rescue addedcontracts/test/TimeCurve.t.sol(Search for
new TimeCurveBuyRouterincontracts/for any additional deploy/tests.)Documentation
docs/integrations/kumbaya.mddocs/testing/invariants-and-business-logic.mddocs/onchain/cl8y-flow-audit.mddocs/testing/strategy.mdforge testmatrix noteaudits/audit_smartcontract_1777813071.md(optional housekeeping)Scripts / ergonomics (if constructor or ownership surface changes)
scripts/verify-timecurve-buy-router-anvil.shFrontend / indexer
frontend/: No ABI change anticipated forbuyViaKumbaya; rescues are admin-only—no UX unless ops tooling reads new events off-chain.indexer/: OptionalEthRescued/Erc20Rescueddecoding if indexer must index admin maintenance events (non-blocking for correctness).Acceptance criteria
exactOutput, bothPAY_ETHandPAY_STABLErefund at most\Delta\text{balanceSinceSnapshot}; pre-existing router balances are never implicitly transferred to the buyer as part of the refund branch.rescueETHandrescueERC20to a configurable recipient without invokingbuyViaKumbaya; unauthorized callers revert.Ownable2Stepif adopted here); deploy scripts wire explicit owner matching ops/governance assumptions.kumbaya.md+ invariant map describe behavioral contract: CL8Y →cl8yProtocolTreasury; ETH/stable margin → caller; stranded →rescue*until moved.Multistep verification checklists
A. Automated (CI / local Forge)
forge test --match-contract TimeCurveBuyRouterTestpasses including newpreseed/net-refund/rescue cases.VerifyTimeCurveBuyRouterAnvil/scripts/verify-timecurve-buy-router-anvil.sh(with documented env) succeeds onmainparity branch.forge snapshot(if snapshots used in repo workflow) unchanged or consciously updated—no unintended gas drift masking logic bugs.B. Logic review
buyViaKumbayawith two mental models: (a) clean router, (b) router with stranded WETH and stranded stable simultaneously.nonReentrantstill coversrescueif any external call ordering could recurse (usually no user callback on rescuetoiftois EOA/multisig only—document multisig-safe usage).C. Operational / staging (optional but recommended before prod)
DeployKumbayaAnvilFixturesflow;cast callbalance probes before/after buy.rescue1 wei test dust to multisig sink; verify event in block explorer/indexer tool.TimeCurve.timeCurveBuyRouter()unchanged reset procedure (setTimeCurveBuyRouter(0)) still behaves per issue #78.D. Closure / regression guard
docs/testing/invariants-and-business-logic.mdnew bullet.Closes/Relatedto this issue ID when opened.mentioned in commit
2f3d954b50Implemented on main (
2f3d954)Summary: TimeCurveBuyRouter snapshots WETH / stableToken balance before caller funding; post-exactOutput refunds only the marginal unused amount (buyer cannot scoop pre-seeded WETH/USDM). Ownable2Step owner may rescueETH / rescueERC20 (amount == uint256.max = full sweep). DeployKumbayaAnvilFixtures passes explicit initialOwner (= deployer on Anvil, same row as treasury today).
Documentation: docs/testing/invariants-and-business-logic.md#timecurve-buy-router-net-refund-rescue-issue-117, docs/integrations/kumbaya.md#issue-65-single-tx-router, docs/onchain/cl8y-flow-audit.md (exec findings + reco 3), audits/audit_smartcontract_1777813071.md under M-02 status, skills/README.md, skills/script-with-timecurve-local/SKILL.md, .cursor/skills/yieldomega-guardrails/SKILL.md.
Tests: forge test --match-contract TimeCurveBuyRouterTest (includes test_issue117_buyViaKumbaya_preseed_* and test_issue117_rescue_*).
@brouie verification checklist:
Leaving issue open until you confirm.
mentioned in issue #118
mentioned in commit
9905b25c9cmentioned in commit
ebbf58ed93@PlasticDigits — pulled the audit batch (commits
2f3d954thru68dda7f) and ranforge testwith the audit's CI flags. 5 failures on main, 257 pass.Category A — L-05 / #123 fee-on-transfer parity (4 failures)
three flows aren't reverting at all on fee-on-transfer assets, fourth reverts with the wrong source (OZ
ERC20InsufficientBalanceinstead of the newTimeCurve: ERC20 parityguard). looks like the ingress balance-delta check from6144dd3either isn't firing on the rabbitTreasury / referralRegistry paths, or the FoT mock isn't deducting fee on the path the new code measures.Category B — I-01 / #124 wall-clock cap ordering (1 failure)
your own new boundary test fails — at the boundary the new wall-clock cap fires before the existing timer-expired check, even though the test was written expecting timer-expired to win. ordering bug in
c5f42e0.flagging tonight pre-deploy. happy to dig deeper on either category if helpful.
mentioned in issue #123
mentioned in issue #124
verified clean on commit
2f3d954. all M-02 acceptance criteria pass.net-only refund (no scoop of pre-seeded balances):
test_issue117_buyViaKumbaya_preseed_stable_kept_on_router— pre-seeded stable stays on router, refund only delta from caller's allowancetest_issue117_buyViaKumbaya_preseed_weth_kept_on_router— same for WETH branchtest_buyViaKumbaya_preseed_cl8y_surplus_routes_to_protocol_treasury— pre-seeded CL8Y still routes to protocol treasury per existingCl8ySurplusToProtocolpolicyrescue path (Ownable2Step):
test_issue117_rescue_eth_and_erc20_only_owner—rescueETH/rescueERC20reject non-ownertest_issue117_rescue_cl8y_routes_via_owner_ops— owner can sweep CL8Y to ops walletexisting flows preserved:
test_buyViaKumbaya_eth_creditsAlice,test_buyViaKumbaya_stable_creditsAlice— happy paths unchangedtest_buyFor_revertsWhenRouterUnset— TimeCurve interaction guards intactrouter invariant now matches your spec — caller receives only their unused marginal input for this invocation; stranded balances stay on router until owner rescue. closes both halves of M-02 (refund subsidy + custody assumption).
mentioned in issue #138
mentioned in issue #139
mentioned in issue #207