[Audit M-02] TimeCurveBuyRouter: net-only WETH/stable refund + ETH/ERC20 rescue #117

Closed
opened 2026-05-04 04:15:31 +00:00 by PlasticDigits · 12 comments
PlasticDigits commented 2026-05-04 04:15:31 +00:00 (Migrated from gitlab.com)

Source

Tracked from moderate severity finding M-02 in repository file audits/audit_smartcontract_1777813071.md (TimeCurveBuyRouter refunds full WETH/stable balance to the buyer).

Summary

After a Kumbaya exactOutput swap, TimeCurveBuyRouter.buyViaKumbaya refunds the entire post-swap balance of WETH (PAY_ETH) or stableToken (PAY_STABLE) held by the router to msg.sender. That balance can include tokens that sat on the router before the current transaction—not only unused input from msg.value / safeTransferFrom for 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

  • Does not let an attacker drain core TimeCurve reserve accounting by itself.
  • Does let a participant extract accidentally or maliciously pre-seeded WETH/stable from the router when they are msg.sender on a qualifying buyViaKumbaya call.
  • Couples correctness to an implicit “router must never retain balances” assumption that on-chain code does not guarantee.

Code reference (today)

Refund paths use balanceOf(address(this)) after the swap (TimeCurveBuyRouter.sol ETH branch ~withdraw + payable(msg.sender).call, stable branch ~safeTransfer full balance), not net-of-opening-balance:

// PAY_ETH — refunds all WETH left on router, not Δ since start of tx
uint256 wethLeft = weth.balanceOf(address(this));

// PAY_STABLE — refunds all stable left on router
uint256 stLeft = stableToken.balanceOf(address(this));

1. Refund only net input-token balance attributable to this call

  • Stable path (PAY_STABLE): Before stableToken.safeTransferFrom(msg.sender, address(this), amountInMaximum), record stableBefore = stableToken.balanceOf(address(this)). After exactOutput, compute delta = stableToken.balanceOf(address(this)) - stableBefore and refund msg.sender with delta only (when delta > 0). This equals unused pulled allowance regardless of stranded pre-seeded stable.
  • ETH / WETH path (PAY_ETH): Before weth.deposit{value: msg.value}(), record wethBefore = IERC20(address(weth)).balanceOf(address(this)). After swap, compute refundWeth = weth.balanceOf(address(this)) - wethBefore and only unwrap + send that ETH to msg.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 on TimeCurveBuyRouter or reuse an existing Yieldomega multisig primitive consistent with TimeCurve owner:

  • rescueETH(address payable to, uint256 amount) — forward native balance (covers accidental receive() / donations). Optional: allow amount == type(uint256).max to mean “full balance”.
  • rescueERC20(address token, address to, uint256 amount) — SafeERC20.safeTransfer for 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 TimeCurve admin; consider Ownable2Step transfer pattern; emit EthRescued / Erc20Rescued (names illustrative) for monitoring.

3. Operational note

Ensure staging / production deploy scripts pass the explicit rescue owner/treasury argument if constructors change—Anvil DeployKumbayaAnvilFixtures currently deploys the router with deployer as cl8yProtocolTreasury; rescue owner should match intended ops wallet.


Regression tests (Forge)

Add to contracts/test/TimeCurveBuyRouter.t.sol (and snapshot VerifyTimeCurveBuyRouterAnvil.t.sol if behavior is observable live):

# Scenario Assertion
1 Pre-seeded WETH on router before PAY_ETH buyViaKumbaya Buyer receives msg.value − spent refund only (or equivalent zero net subsidy); stranded WETH remains on router until rescue
2 Pre-seeded stable on router before PAY_STABLE buy Unused refund equals pulled margin only; stranded stable not sent to buyer
3 No stranded balance baseline Behavioral parity with current happy path — buyer refund unchanged vs today on clean router
4 Rescue ERC20: non-owner revert; owner pulls stranded WETH and/or stable to to Access control + balance checks
5 Rescue ETH: native balance on router (e.g. vm.deal(router)), owner rescues Native ETH leaves router
6 Rescue CL8Y (optional) If rescue allows protocol token—document intent; align with treasury policy—or assert rescue denies CL8Y if policy forbids

Invariant tests: Prefer property-style checks: refund + spent + stranded_opening == marginal_user_input (with opening measured at snapshot).


Files likely to change

Solidity (authoritative behavior)

Path Purpose
contracts/src/TimeCurveBuyRouter.sol Net refund snapshots; rescue functions + access control + events/NatSpec
contracts/test/TimeCurveBuyRouter.t.sol New regression vectors for pre-seed WETH/USDM + rescue
contracts/test/VerifyTimeCurveBuyRouterAnvil.t.sol Adjust expectations if fork script exercises router balances
contracts/script/DeployKumbayaAnvilFixtures.s.sol Router constructor/deploy args if owner/rescue added
contracts/test/TimeCurve.t.sol If mock router instantiation needs new constructor arity

(Search for new TimeCurveBuyRouter in contracts/ for any additional deploy/tests.)

Documentation

Path Purpose
docs/integrations/kumbaya.md Describe net refund vs CL8Y-to-treasury and optional rescues (issue #70 cross-link remains)
docs/testing/invariants-and-business-logic.md New invariant row (WETH/stable net refund, rescue ops); tie to Forge test names
docs/onchain/cl8y-flow-audit.md Update recommendations if they still imply CL8Y-only sweep risk; clarify non-CL8Y router custody
docs/testing/strategy.md If new scope requires CI / forge test matrix note
audits/audit_smartcontract_1777813071.md (optional housekeeping) Add “Addressed by #<this issue>” under M-02 when merged

Scripts / ergonomics (if constructor or ownership surface changes)

Path Purpose
scripts/verify-timecurve-buy-router-anvil.sh Only if forge interface or deploy flow changes

Frontend / indexer

  • frontend/: No ABI change anticipated for buyViaKumbaya; rescues are admin-only—no UX unless ops tooling reads new events off-chain.
  • indexer/: Optional EthRescued / Erc20Rescued decoding if indexer must index admin maintenance events (non-blocking for correctness).

Acceptance criteria

  1. Net refund correctness: After exactOutput, both PAY_ETH and PAY_STABLE refund at most \Delta\text{balanceSinceSnapshot}; pre-existing router balances are never implicitly transferred to the buyer as part of the refund branch.
  2. Parity: On a router whose opening WETH/stable balance is zero, user-visible refunds match the prior intended economics within rounding/slippage (no regression vs current happy-path expectations).
  3. Rescue surface: Authorized account can rescueETH and rescueERC20 to a configurable recipient without invoking buyViaKumbaya; unauthorized callers revert.
  4. Governance ergonomics: Owner transfer follows project standards (e.g. Ownable2Step if adopted here); deploy scripts wire explicit owner matching ops/governance assumptions.
  5. Tests: Forge tests reproduce pre-seeded WETH/USDM stranded funds and prove buyer neither gains nor absorbs unintended subsidy; rescue tests demonstrate recovery.
  6. Docs/invariants: 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)

  1. forge test --match-contract TimeCurveBuyRouterTest passes including new preseed/net-refund/rescue cases.
  2. If touched: VerifyTimeCurveBuyRouterAnvil / scripts/verify-timecurve-buy-router-anvil.sh (with documented env) succeeds on main parity branch.
  3. forge snapshot (if snapshots used in repo workflow) unchanged or consciously updated—no unintended gas drift masking logic bugs.

B. Logic review

  1. Re-read buyViaKumbaya with two mental models: (a) clean router, (b) router with stranded WETH and stranded stable simultaneously.
  2. Confirm nonReentrant still covers rescue if any external call ordering could recurse (usually no user callback on rescue to if to is EOA/multisig only—document multisig-safe usage).
  3. Confirm WETH unwrap + ETH send refund path cannot leave dust trapped in unintended ways—or document dust policy.
  1. Deploy patched router fixture on Anvil via DeployKumbayaAnvilFixtures flow; cast call balance probes before/after buy.
  2. Manually rescue 1 wei test dust to multisig sink; verify event in block explorer/indexer tool.
  3. Cross-check TimeCurve.timeCurveBuyRouter() unchanged reset procedure (setTimeCurveBuyRouter(0)) still behaves per issue #78.

D. Closure / regression guard

  1. Paste Forge test names into MR description linking to docs/testing/invariants-and-business-logic.md new bullet.
  2. Link MR with Closes / Related to this issue ID when opened.
## Source Tracked from **moderate severity** finding **M-02** in repository file [`audits/audit_smartcontract_1777813071.md`](/PlasticDigits/yieldomega/-/blob/main/audits/audit_smartcontract_1777813071.md) (**`TimeCurveBuyRouter` refunds full WETH/stable balance to the buyer**). ## Summary After a Kumbaya `exactOutput` swap, `TimeCurveBuyRouter.buyViaKumbaya` refunds the **entire** post-swap balance of **WETH** (`PAY_ETH`) or **`stableToken`** (`PAY_STABLE`) held by the router to `msg.sender`. That balance can include tokens that sat on the router **before** the current transaction—not only unused input from `msg.value` / `safeTransferFrom` for 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 - Does **not** let an attacker drain core **TimeCurve** reserve accounting by itself. - **Does** let a participant extract **accidentally or maliciously pre-seeded** WETH/stable from the router when they are `msg.sender` on a qualifying `buyViaKumbaya` call. - Couples correctness to an implicit “router must never retain balances” assumption that **on-chain code does not guarantee**. ### Code reference (today) Refund paths use **`balanceOf(address(this))`** after the swap (`TimeCurveBuyRouter.sol` ETH branch ~withdraw + `payable(msg.sender).call`, stable branch ~`safeTransfer` full balance), not **net-of-opening-balance**: ```solidity // PAY_ETH — refunds all WETH left on router, not Δ since start of tx uint256 wethLeft = weth.balanceOf(address(this)); // PAY_STABLE — refunds all stable left on router uint256 stLeft = stableToken.balanceOf(address(this)); ``` ## Recommended remediation ### 1. Refund **only net** input-token balance attributable to this call - **Stable path (`PAY_STABLE`):** Before `stableToken.safeTransferFrom(msg.sender, address(this), amountInMaximum)`, record `stableBefore = stableToken.balanceOf(address(this))`. After `exactOutput`, compute `delta = stableToken.balanceOf(address(this)) - stableBefore` and refund `msg.sender` with **`delta`** only (when `delta > 0`). This equals unused **pulled allowance** regardless of stranded pre-seeded stable. - **ETH / WETH path (`PAY_ETH`):** Before `weth.deposit{value: msg.value}()`, record `wethBefore = IERC20(address(weth)).balanceOf(address(this))`. After swap, compute `refundWeth = weth.balanceOf(address(this)) - wethBefore` and only unwrap + send **that** ETH to `msg.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** on `TimeCurveBuyRouter` **or** reuse an existing Yieldomega multisig primitive consistent with **`TimeCurve` owner**: - **`rescueETH(address payable to, uint256 amount)`** — forward native balance (covers accidental `receive()` / donations). Optional: allow `amount == type(uint256).max` to mean “full balance”. - **`rescueERC20(address token, address to, uint256 amount)`** — **`SafeERC20.safeTransfer`** for 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 `TimeCurve` admin**; consider **`Ownable2Step`** transfer pattern; emit **`EthRescued` / `Erc20Rescued`** (names illustrative) for monitoring. ### 3. Operational note Ensure **staging / production deploy scripts** pass the **explicit rescue owner/treasury** argument if constructors change—Anvil **`DeployKumbayaAnvilFixtures`** currently deploys the router with `deployer` as `cl8yProtocolTreasury`; rescue owner should match intended ops wallet. --- ## Regression tests (Forge) Add to **`contracts/test/TimeCurveBuyRouter.t.sol`** (and snapshot **`VerifyTimeCurveBuyRouterAnvil.t.sol`** if behavior is observable live): | # | Scenario | Assertion | |---|----------|-----------| | 1 | **Pre-seeded WETH** on router before **`PAY_ETH` `buyViaKumbaya`** | Buyer receives **`msg.value` − spent** refund only (or equivalent zero net subsidy); stranded WETH remains on router until **rescue** | | 2 | **Pre-seeded stable** on router before **`PAY_STABLE`** buy | Unused refund equals pulled margin only; stranded stable not sent to buyer | | 3 | **No stranded balance** baseline | Behavioral parity with current happy path — buyer refund unchanged vs today on clean router | | 4 | **Rescue ERC20**: non-owner revert; owner pulls stranded WETH and/or stable to **`to`** | Access control + balance checks | | 5 | **Rescue ETH**: native balance on router (e.g. `vm.deal(router)`), owner rescues | Native ETH leaves router | | 6 | **Rescue CL8Y (optional)** | If rescue allows protocol token—document intent; align with treasury policy—or assert rescue denies CL8Y if policy forbids | **Invariant tests:** Prefer property-style checks: **`refund + spent + stranded_opening == marginal_user_input`** (with opening measured at snapshot). --- ## Files likely to change ### Solidity (authoritative behavior) | Path | Purpose | |------|---------| | [`contracts/src/TimeCurveBuyRouter.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/TimeCurveBuyRouter.sol) | Net refund snapshots; rescue functions + access control + events/NatSpec | | [`contracts/test/TimeCurveBuyRouter.t.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/test/TimeCurveBuyRouter.t.sol) | New regression vectors for pre-seed WETH/USDM + rescue | | [`contracts/test/VerifyTimeCurveBuyRouterAnvil.t.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/test/VerifyTimeCurveBuyRouterAnvil.t.sol) | Adjust expectations if fork script exercises router balances | | [`contracts/script/DeployKumbayaAnvilFixtures.s.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/script/DeployKumbayaAnvilFixtures.s.sol) | Router constructor/deploy args if **`owner`/rescue** added | | [`contracts/test/TimeCurve.t.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/test/TimeCurve.t.sol) | If mock router instantiation needs new constructor arity | *(Search for **`new TimeCurveBuyRouter`** in `contracts/` for any additional deploy/tests.)* ### Documentation | Path | Purpose | |------|---------| | [`docs/integrations/kumbaya.md`](/PlasticDigits/yieldomega/-/blob/main/docs/integrations/kumbaya.md) | Describe **net refund vs CL8Y-to-treasury** and optional **rescues** ([issue #70](https://gitlab.com/PlasticDigits/yieldomega/-/issues/70) cross-link remains) | | [`docs/testing/invariants-and-business-logic.md`](/PlasticDigits/yieldomega/-/blob/main/docs/testing/invariants-and-business-logic.md) | New invariant row (**WETH/stable net refund**, rescue ops); tie to Forge test names | | [`docs/onchain/cl8y-flow-audit.md`](/PlasticDigits/yieldomega/-/blob/main/docs/onchain/cl8y-flow-audit.md) | Update recommendations if they still imply CL8Y-only sweep risk; clarify **non-CL8Y** router custody | | [`docs/testing/strategy.md`](/PlasticDigits/yieldomega/-/blob/main/docs/testing/strategy.md) | If new scope requires CI / `forge test` matrix note | | **[`audits/audit_smartcontract_1777813071.md`](/PlasticDigits/yieldomega/-/blob/main/audits/audit_smartcontract_1777813071.md)** *(optional housekeeping)* | Add “Addressed by #&lt;this issue&gt;” under M-02 when merged | ### Scripts / ergonomics *(if constructor or ownership surface changes)* | Path | Purpose | |------|---------| | [`scripts/verify-timecurve-buy-router-anvil.sh`](/PlasticDigits/yieldomega/-/blob/main/scripts/verify-timecurve-buy-router-anvil.sh) | Only if forge interface or deploy flow changes | ### Frontend / indexer - **`frontend/`**: No ABI change anticipated for **`buyViaKumbaya`**; **rescues are admin-only**—no UX unless ops tooling reads new events off-chain. - **`indexer/`**: Optional **`EthRescued` / `Erc20Rescued`** decoding if indexer must index admin maintenance events (non-blocking for correctness). --- ## Acceptance criteria 1. **Net refund correctness:** After `exactOutput`, **both** **`PAY_ETH`** and **`PAY_STABLE`** refund **at most** \(\Delta\text{balanceSinceSnapshot}\); pre-existing router balances are **never** implicitly transferred to the buyer as part of the refund branch. 2. **Parity:** On a router whose **opening WETH/stable balance is zero**, user-visible refunds match the **prior intended economics** within rounding/slippage (no regression vs current happy-path expectations). 3. **Rescue surface:** Authorized account can **`rescueETH`** and **`rescueERC20`** to a configurable recipient **without** invoking `buyViaKumbaya`; unauthorized callers revert. 4. **Governance ergonomics:** Owner transfer follows project standards (**e.g. `Ownable2Step`** if adopted here); deploy scripts wire **explicit** owner matching ops/governance assumptions. 5. **Tests:** Forge tests reproduce **pre-seeded** WETH/USDM stranded funds and prove **buyer neither gains nor absorbs** unintended subsidy; rescue tests demonstrate recovery. 6. **Docs/invariants:** `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) 1. **`forge test --match-contract TimeCurveBuyRouterTest`** passes including new **`preseed`/net-refund**/rescue cases. 2. If touched: **`VerifyTimeCurveBuyRouterAnvil`** / **`scripts/verify-timecurve-buy-router-anvil.sh`** (with documented env) succeeds on **`main`** parity branch. 3. **`forge snapshot`** *(if snapshots used in repo workflow)* unchanged or consciously updated—no unintended gas drift masking logic bugs. ### B. Logic review 1. Re-read **`buyViaKumbaya`** with two mental models: (a) **clean router**, (b) **router with stranded WETH and stranded stable** simultaneously. 2. Confirm **`nonReentrant`** still covers **`rescue`** if any external call ordering could recurse (usually no user callback on rescue **`to`** if **`to`** is EOA/multisig only—document multisig-safe usage). 3. Confirm **WETH unwrap + ETH send** refund path cannot leave **dust** trapped in unintended ways—or document dust policy. ### C. Operational / staging (optional but recommended before prod) 1. Deploy **patched router** fixture on Anvil via **`DeployKumbayaAnvilFixtures`** flow; **`cast call`** balance probes before/after buy. 2. Manually **`rescue`** 1 wei test dust to multisig sink; verify event in block explorer/indexer tool. 3. Cross-check **`TimeCurve.timeCurveBuyRouter()`** unchanged reset procedure (**`setTimeCurveBuyRouter(0)`**) still behaves per [issue #78](https://gitlab.com/PlasticDigits/yieldomega/-/issues/78). ### D. Closure / regression guard 1. Paste **Forge test names** into MR description linking to **`docs/testing/invariants-and-business-logic.md`** new bullet. 2. Link MR with **`Closes`** / **`Related`** to this issue ID when opened.
PlasticDigits commented 2026-05-04 05:01:41 +00:00 (Migrated from gitlab.com)

mentioned in commit 2f3d954b50

mentioned in commit 2f3d954b5099b5a43475c412652b46bf948ddc72
PlasticDigits commented 2026-05-04 05:03:30 +00:00 (Migrated from gitlab.com)

Implemented 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:

  • buyViaKumbaya on router with zero pre-open WETH/stable: buyer refund matches marginal unused margin (parity with economics before #117 aside from subsidy fix).
  • Pre-seeded WETH on router then PAY_ETH buy: stranded WETH remains on router; buyer gets ~ msg.value minus swap spend only.
  • Pre-seeded USDM similarly for PAY_STABLE.
  • rescueERC20 / rescueETH: non-owner reverts; owner can sweep stranded balances; EthRescued / Erc20Rescued in logs when used.
  • New constructor: TimeCurveBuyRouter(..., cl8yProtocolTreasury, initialOwner) — DeployKumbayaAnvilFixtures uses deployer for both treasury and owner.
  • Optional: bash scripts/verify-timecurve-buy-router-anvil.sh with live fixtures when YIELDOMEGA_FORK_VERIFY=1.

Leaving issue open until you confirm.

## Implemented 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: - buyViaKumbaya on router with zero pre-open WETH/stable: buyer refund matches marginal unused margin (parity with economics before #117 aside from subsidy fix). - Pre-seeded WETH on router then PAY_ETH buy: stranded WETH remains on router; buyer gets ~ msg.value minus swap spend only. - Pre-seeded USDM similarly for PAY_STABLE. - rescueERC20 / rescueETH: non-owner reverts; owner can sweep stranded balances; EthRescued / Erc20Rescued in logs when used. - New constructor: TimeCurveBuyRouter(..., cl8yProtocolTreasury, initialOwner) — DeployKumbayaAnvilFixtures uses deployer for both treasury and owner. - Optional: bash scripts/verify-timecurve-buy-router-anvil.sh with live fixtures when YIELDOMEGA_FORK_VERIFY=1. Leaving issue open until you confirm.
PlasticDigits commented 2026-05-04 05:11:08 +00:00 (Migrated from gitlab.com)

mentioned in issue #118

mentioned in issue #118
PlasticDigits commented 2026-05-04 05:16:46 +00:00 (Migrated from gitlab.com)

mentioned in commit 9905b25c9c

mentioned in commit 9905b25c9c11f63b798de6a4fc4ab3127fbd8a9e
PlasticDigits commented 2026-05-04 05:38:31 +00:00 (Migrated from gitlab.com)

mentioned in commit ebbf58ed93

mentioned in commit ebbf58ed9379559e94cc4855ca2e2b343ae18c70
Brouie commented 2026-05-04 06:08:20 +00:00 (Migrated from gitlab.com)

@PlasticDigits — pulled the audit batch (commits 2f3d954 thru 68dda7f) and ran forge test with the audit's CI flags. 5 failures on main, 257 pass.

Category A — L-05 / #123 fee-on-transfer parity (4 failures)

[FAIL: next call did not revert as expected] test_feeOnTransfer_rabbitTreasury_deposit_reverts_erc20Parity
[FAIL: next call did not revert as expected] test_feeOnTransfer_rabbitTreasury_receiveFee_reverts_erc20Parity
[FAIL: next call did not revert as expected] test_feeOnTransfer_referralRegistry_register_reverts_erc20Parity
[FAIL: Error != expected error: ERC20InsufficientBalance(0xD6BbDE9174b1CdAa358d2Cf4D57D1a9F7178FBfF, 0, 1e17) != TimeCurve: ERC20 parity] test_feeOnTransfer_timeCurve_buyReverts_erc20Parity

three flows aren't reverting at all on fee-on-transfer assets, fourth reverts with the wrong source (OZ ERC20InsufficientBalance instead of the new TimeCurve: ERC20 parity guard). looks like the ingress balance-delta check from 6144dd3 either 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)

[FAIL: Error != expected error: TimeCurve: sale max elapsed exceeded != TimeCurve: timer expired] test_gitlab124_buy_reverts_timer_expired_at_wall_clock_boundary

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.

@PlasticDigits — pulled the audit batch (commits 2f3d954 thru 68dda7f) and ran `forge test` with the audit's CI flags. **5 failures on main, 257 pass.** ## Category A — L-05 / #123 fee-on-transfer parity (4 failures) ``` [FAIL: next call did not revert as expected] test_feeOnTransfer_rabbitTreasury_deposit_reverts_erc20Parity [FAIL: next call did not revert as expected] test_feeOnTransfer_rabbitTreasury_receiveFee_reverts_erc20Parity [FAIL: next call did not revert as expected] test_feeOnTransfer_referralRegistry_register_reverts_erc20Parity [FAIL: Error != expected error: ERC20InsufficientBalance(0xD6BbDE9174b1CdAa358d2Cf4D57D1a9F7178FBfF, 0, 1e17) != TimeCurve: ERC20 parity] test_feeOnTransfer_timeCurve_buyReverts_erc20Parity ``` three flows aren't reverting at all on fee-on-transfer assets, fourth reverts with the wrong source (OZ `ERC20InsufficientBalance` instead of the new `TimeCurve: ERC20 parity` guard). looks like the ingress balance-delta check from `6144dd3` either 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) ``` [FAIL: Error != expected error: TimeCurve: sale max elapsed exceeded != TimeCurve: timer expired] test_gitlab124_buy_reverts_timer_expired_at_wall_clock_boundary ``` 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.
Brouie commented 2026-05-04 06:35:33 +00:00 (Migrated from gitlab.com)

mentioned in issue #123

mentioned in issue #123
Brouie commented 2026-05-04 06:39:34 +00:00 (Migrated from gitlab.com)

mentioned in issue #124

mentioned in issue #124
Brouie commented 2026-05-04 06:40:29 +00:00 (Migrated from gitlab.com)

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 allowance
  • test_issue117_buyViaKumbaya_preseed_weth_kept_on_router — same for WETH branch
  • test_buyViaKumbaya_preseed_cl8y_surplus_routes_to_protocol_treasury — pre-seeded CL8Y still routes to protocol treasury per existing Cl8ySurplusToProtocol policy

rescue path (Ownable2Step):

  • test_issue117_rescue_eth_and_erc20_only_owner — rescueETH / rescueERC20 reject non-owner
  • test_issue117_rescue_cl8y_routes_via_owner_ops — owner can sweep CL8Y to ops wallet

existing flows preserved:

  • test_buyViaKumbaya_eth_creditsAlice, test_buyViaKumbaya_stable_creditsAlice — happy paths unchanged
  • test_buyFor_revertsWhenRouterUnset — TimeCurve interaction guards intact

router 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).

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 allowance - `test_issue117_buyViaKumbaya_preseed_weth_kept_on_router` — same for WETH branch - `test_buyViaKumbaya_preseed_cl8y_surplus_routes_to_protocol_treasury` — pre-seeded CL8Y still routes to protocol treasury per existing `Cl8ySurplusToProtocol` policy **rescue path (Ownable2Step):** - `test_issue117_rescue_eth_and_erc20_only_owner` — `rescueETH` / `rescueERC20` reject non-owner - `test_issue117_rescue_cl8y_routes_via_owner_ops` — owner can sweep CL8Y to ops wallet **existing flows preserved:** - `test_buyViaKumbaya_eth_creditsAlice`, `test_buyViaKumbaya_stable_creditsAlice` — happy paths unchanged - `test_buyFor_revertsWhenRouterUnset` — TimeCurve interaction guards intact router 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).
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-04 08:19:05 +00:00
Brouie commented 2026-05-04 08:44:28 +00:00 (Migrated from gitlab.com)

mentioned in issue #138

mentioned in issue #138
PlasticDigits commented 2026-05-04 08:51:53 +00:00 (Migrated from gitlab.com)

mentioned in issue #139

mentioned in issue #139
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#117
No description provided.