Contracts: single-tx CHARM buy with ETH/USDM via configurable router (Kumbaya → TimeCurve buy) #65

Closed
opened 2026-04-26 05:28:27 +00:00 by PlasticDigits · 13 comments
PlasticDigits commented 2026-04-26 05:28:27 +00:00 (Migrated from gitlab.com)

Summary

Expose an onchain entry path so participants can buy CHARM with native ETH or USDM in a single transaction, with the contract (or an approved router module) performing the Kumbaya swap leg + TimeCurve buy atomically. Today the sale’s accepted asset is CL8Y-centric and the app composes separate user txs (wrap/approve/swap then CL8Y approve/buy).

Motivation

  • UX / gas: One signature and one tx reduces failure modes, MEV surface between swap and buy, and support burden.
  • Trust boundary: Path/slippage and “exactly enough CL8Y for this charmWad” can be enforced in one atomic execution instead of relying on the wallet to complete a multi-step flow.
  • Parity with product story: “Pay with ETH or USDM” becomes a first-class protocol surface, not only a frontend-orchestrated Kumbaya flow.

Proposed direction (high level)

  1. Configurable router / module address on TimeCurve (or a dedicated TimeCurveBuyRouter trusted by TimeCurve) that:

    • Accepts ETH (payable) or USDM (ERC20) plus calldata encoding the Kumbaya path (and any integrator-kit–compatible parameters the contracts need).
    • Pulls/transfers input from the user, executes the Kumbaya router/quoter-derived swap into CL8Y, then calls buy / buy(..., referral) with the resulting CL8Y spend so the user receives CHARM in the same tx.
  2. “Data from the frontend” in the sense of user-chosen route + limits (path, min/max input, deadline, charmWad or CL8Y spend target) supplied as opaque but validated calldata to the router—same information the frontend already derives for Kumbaya + TimeCurve, but signed once against a single contract entrypoint.

  3. Safety / invariants (must be explicit in spec):

    • Reuse existing min/max buy, cooldown, referral, and fee routing gates from TimeCurve.
    • Slippage / max input bounds checked onchain; fail closed if routing or amounts are inconsistent with TimeCurve reads (see docs/integrations/kumbaya.md, GitLab #46).
    • Clear trust model for who can set router address (owner/governance) and upgrade path (UUPS / module pattern).

Out of scope / follow-ups

  • Exact ABI shape (payable vs permit2, exactOutput vs exactInput swap style) should be decided with reference to current frontend swap stack (frontend/src/lib/timeCurveKumbayaSwap.ts, sale session / arena models).
  • Indexer / subgraph may need new event surfaces if the router emits different logs than today’s separate swap + buy.

References

  • Kumbaya integration expectations: docs/integrations/kumbaya.md
  • Related multi-asset UX: GitLab #46 (Kumbaya / TimeCurve entry)
## Summary Expose an onchain entry path so participants can **buy CHARM with native ETH or USDM in a single transaction**, with the contract (or an approved router module) performing the **Kumbaya swap leg + TimeCurve `buy`** atomically. Today the sale’s accepted asset is CL8Y-centric and the app composes **separate user txs** (wrap/approve/swap then CL8Y approve/`buy`). ## Motivation - **UX / gas**: One signature and one tx reduces failure modes, MEV surface between swap and buy, and support burden. - **Trust boundary**: Path/slippage and “exactly enough CL8Y for this `charmWad`” can be enforced in one atomic execution instead of relying on the wallet to complete a multi-step flow. - **Parity with product story**: “Pay with ETH or USDM” becomes a first-class protocol surface, not only a frontend-orchestrated Kumbaya flow. ## Proposed direction (high level) 1. **Configurable router / module address** on `TimeCurve` (or a dedicated `TimeCurveBuyRouter` trusted by `TimeCurve`) that: - Accepts **ETH** (payable) or **USDM** (ERC20) plus calldata encoding the **Kumbaya path** (and any integrator-kit–compatible parameters the contracts need). - Pulls/transfers input from the user, executes the **Kumbaya router/quoter-derived swap** into CL8Y, then calls **`buy` / `buy(..., referral)`** with the resulting CL8Y spend so the user receives CHARM in the same tx. 2. **“Data from the frontend”** in the sense of **user-chosen route + limits** (path, min/max input, deadline, `charmWad` or CL8Y spend target) supplied as **opaque but validated calldata** to the router—same information the frontend already derives for Kumbaya + TimeCurve, but signed once against a single contract entrypoint. 3. **Safety / invariants** (must be explicit in spec): - Reuse existing **min/max buy**, **cooldown**, **referral**, and **fee routing** gates from `TimeCurve`. - **Slippage / max input** bounds checked onchain; **fail closed** if routing or amounts are inconsistent with `TimeCurve` reads (see `docs/integrations/kumbaya.md`, GitLab #46). - Clear **trust model** for who can set router address (owner/governance) and upgrade path (UUPS / module pattern). ## Out of scope / follow-ups - Exact ABI shape (payable vs permit2, `exactOutput` vs `exactInput` swap style) should be decided with reference to current frontend swap stack (`frontend/src/lib/timeCurveKumbayaSwap.ts`, sale session / arena models). - Indexer / subgraph may need new event surfaces if the router emits different logs than today’s separate swap + buy. ## References - Kumbaya integration expectations: `docs/integrations/kumbaya.md` - Related multi-asset UX: GitLab #46 (Kumbaya / TimeCurve entry)
PlasticDigits commented 2026-04-26 06:14:17 +00:00 (Migrated from gitlab.com)

mentioned in commit d36cccb6de

mentioned in commit d36cccb6dea3fa968c5d5c2c997fe29188011d18
PlasticDigits commented 2026-04-26 06:14:52 +00:00 (Migrated from gitlab.com)

Implemented (merged to main)

Single-transaction ETH or USDM (configured stable) → Kumbaya exactOutput → TimeCurve purchase:

  • TimeCurve.buyFor(buyer, charmWad[, codeHash]) — only callable by timeCurveBuyRouter (zero = disabled). CL8Y is pulled from the router; all CHARM weight, WarBow, cooldown, and referral semantics use buyer (same as a normal buy for that wallet).
  • TimeCurveBuyRouter (immutable companion): buyViaKumbaya recomputes gross CL8Y from currentPricePerCharmWad × charmWad, validates v3 packed path (CL8Y first, WETH or stable last), runs exactOutput, approves TimeCurve, calls buyFor(msg.sender, …). PAY_ETH / PAY_STABLE (see contract constants).
  • DeployKumbayaAnvilFixtures deploys the buy router and calls setTimeCurveBuyRouter.
  • AnvilKumbayaRouter: multi-hop quoteExactOutput / first pass of exactOutput now walk hops from the CL8Y (output) end toward the spend token so two-hop USDM quotes match execution (fixes local fixture consistency).

Docs / cross-links: docs/integrations/kumbaya.md, docs/testing/invariants-and-business-logic.md, docs/testing/local-swap-testing.md, skills/README.md, .cursor/skills/yieldomega-guardrails/SKILL.md.


@brouie please verify when you can. Checklist:

  1. cd contracts && FOUNDRY_PROFILE=ci forge test — expect 188 tests green (includes TimeCurveBuyRouter.t.sol + buyFor tests).
  2. Fresh Anvil stack: after DeployKumbayaAnvilFixtures, console logs TimeCurveBuyRouter; cast call timeCurveBuyRouter() on the TimeCurve proxy returns that address.
  3. USDM two-hop on Anvil: quoteExactOutput for CL8Y←WETH←USDM is in the same ballpark as before for single-step sanity; full path quote no longer underestimates input vs exactOutput (regression guard for fixture).
  4. On a test deployment, owner can setTimeCurveBuyRouter(address(0)) to disable buyFor; unauthorized buyFor still reverts TimeCurve: not buy router.
  5. MegaETH / prod: wire SwapRouter02-compatible router; confirm path packing matches buildV3PathExactOutput in kumbayaRoutes.ts before encouraging users to call buyViaKumbaya.

Issue left open per maintainer request.

## Implemented (merged to `main`) Single-transaction **ETH** or **USDM** (configured stable) → Kumbaya `exactOutput` → **TimeCurve** purchase: - **`TimeCurve.buyFor(buyer, charmWad[, codeHash])`** — only callable by **`timeCurveBuyRouter`** (zero = disabled). CL8Y is pulled from the **router**; all CHARM weight, WarBow, cooldown, and referral semantics use **`buyer`** (same as a normal `buy` for that wallet). - **`TimeCurveBuyRouter`** (immutable companion): `buyViaKumbaya` recomputes gross CL8Y from **`currentPricePerCharmWad` × `charmWad`**, validates v3 packed path (**CL8Y first**, **WETH** or **stable** last), runs **`exactOutput`**, approves **TimeCurve**, calls **`buyFor(msg.sender, …)`**. **`PAY_ETH`** / **`PAY_STABLE`** (see contract constants). - **`DeployKumbayaAnvilFixtures`** deploys the buy router and calls **`setTimeCurveBuyRouter`**. - **`AnvilKumbayaRouter`**: multi-hop **`quoteExactOutput`** / first pass of **`exactOutput`** now walk hops **from the CL8Y (output) end** toward the spend token so two-hop USDM quotes match execution (fixes local fixture consistency). **Docs / cross-links:** `docs/integrations/kumbaya.md`, `docs/testing/invariants-and-business-logic.md`, `docs/testing/local-swap-testing.md`, `skills/README.md`, `.cursor/skills/yieldomega-guardrails/SKILL.md`. --- @brouie please verify when you can. **Checklist:** 1. [ ] `cd contracts && FOUNDRY_PROFILE=ci forge test` — expect **188** tests green (includes `TimeCurveBuyRouter.t.sol` + `buyFor` tests). 2. [ ] Fresh Anvil stack: after **DeployKumbayaAnvilFixtures**, console logs **TimeCurveBuyRouter**; `cast call` `timeCurveBuyRouter()` on the **TimeCurve proxy** returns that address. 3. [ ] **USDM two-hop** on Anvil: `quoteExactOutput` for CL8Y←WETH←USDM is in the same ballpark as before for single-step sanity; full path quote no longer underestimates input vs `exactOutput` (regression guard for fixture). 4. [ ] On a test deployment, owner can **`setTimeCurveBuyRouter(address(0))`** to disable `buyFor`; unauthorized `buyFor` still reverts **`TimeCurve: not buy router`**. 5. [ ] MegaETH / prod: wire **SwapRouter02**-compatible router; confirm path packing matches `buildV3PathExactOutput` in `kumbayaRoutes.ts` before encouraging users to call `buyViaKumbaya`. Issue left **open** per maintainer request.
PlasticDigits commented 2026-04-26 07:51:34 +00:00 (Migrated from gitlab.com)

mentioned in issue #66

mentioned in issue #66
PlasticDigits commented 2026-04-26 07:51:39 +00:00 (Migrated from gitlab.com)

mentioned in issue #67

mentioned in issue #67
Brouie commented 2026-04-27 02:46:33 +00:00 (Migrated from gitlab.com)

mentioned in issue #72

mentioned in issue #72
Brouie commented 2026-04-27 04:11:38 +00:00 (Migrated from gitlab.com)

mentioned in issue #78

mentioned in issue #78
Brouie commented 2026-04-27 04:12:18 +00:00 (Migrated from gitlab.com)

@PlasticDigits source + selective live verification on the implementation merged to main.

Tests

cd contracts && FOUNDRY_PROFILE=ci forge test --match-contract TimeCurveBuyRouterTest:

  • 3/3 passing (test_buyViaKumbaya_stable_creditsAlice / test_buyViaKumbaya_eth_creditsAlice / test_buyFor_revertsWhenRouterUnset)

cd contracts && FOUNDRY_PROFILE=ci forge test:

  • 191/191 passing (you cited 188 at push, drift from #54/#55/#63 follow-up tests)

Source review

Contract gate (TimeCurve.sol):

  • L334-335 buyFor(buyer, charmWad, plantWarBowFlag) external
  • L339-340 buyFor(buyer, charmWad, codeHash, plantWarBowFlag) external
  • L343-346 _buyForExternal: L344 require(msg.sender == timeCurveBuyRouter && timeCurveBuyRouter != address(0), 'TimeCurve: not buy router')
    • Both arms of && enforce: zero state blocks even self-calls, non-zero state requires exact-match sender
  • L296 setTimeCurveBuyRouter(address(0)) supported (disable path)
  • L868 NatSpec: 'Trusted companion for buyFor (ETH/USDM → Kumbaya → CL8Y buy in one tx). Zero = disabled.'

Fixture quoter (AnvilKumbayaFixture.sol):

  • L103 quoteExactOutput, L121 exactOutput
  • L109 NatSpec: 'Walk hops from CL8Y/output end toward input (hop 0 … n-1) so multi-hop amountIn matches exactOutput.'
  • This is dev's regression-fix scope per the merge note

Live verification (default stack with timeCurveBuyRouter unset)

cast call 0x59b670e9fA9D0A427751Af201D676719a970857b 'timeCurveBuyRouter()(address)' returns 0x0 — proxy responds, default disabled state confirmed.

cast call 0x59b670e9fA9D0A427751Af201D676719a970857b 'buyFor(address,uint256,bool)' 0xc46b15f4B56489a16F561c22D5F0BA8bdCa80650 1000000000000000000 false reverts with: 'TimeCurve: not buy router' — exact match for AC4.

Same revert on the bytes32-codehash 4-arg variant.

Live items deferred to #78

scripts/start-local-anvil-stack.sh and contracts/script/anvil_rich_state.sh don't run DeployKumbayaAnvilFixtures — so the router-set state, USDM two-hop live quote, and full buyViaKumbaya tx receipt evidence need a separate fixtures deploy. Tracked at #78. AC5 (MegaETH/prod SwapRouter02 wire-up) is mainnet readiness, out of scope for local QA.

Closing #65. Live walkthrough proceeds via #78.

@PlasticDigits source + selective live verification on the implementation merged to main. ## Tests cd contracts && FOUNDRY_PROFILE=ci forge test --match-contract TimeCurveBuyRouterTest: - 3/3 passing (test_buyViaKumbaya_stable_creditsAlice / test_buyViaKumbaya_eth_creditsAlice / test_buyFor_revertsWhenRouterUnset) cd contracts && FOUNDRY_PROFILE=ci forge test: - 191/191 passing (you cited 188 at push, drift from #54/#55/#63 follow-up tests) ## Source review Contract gate (TimeCurve.sol): - L334-335 buyFor(buyer, charmWad, plantWarBowFlag) external - L339-340 buyFor(buyer, charmWad, codeHash, plantWarBowFlag) external - L343-346 _buyForExternal: L344 require(msg.sender == timeCurveBuyRouter && timeCurveBuyRouter != address(0), 'TimeCurve: not buy router') - Both arms of && enforce: zero state blocks even self-calls, non-zero state requires exact-match sender - L296 setTimeCurveBuyRouter(address(0)) supported (disable path) - L868 NatSpec: 'Trusted companion for buyFor (ETH/USDM → Kumbaya → CL8Y buy in one tx). Zero = disabled.' Fixture quoter (AnvilKumbayaFixture.sol): - L103 quoteExactOutput, L121 exactOutput - L109 NatSpec: 'Walk hops from CL8Y/output end toward input (hop 0 … n-1) so multi-hop amountIn matches exactOutput.' - This is dev's regression-fix scope per the merge note ## Live verification (default stack with timeCurveBuyRouter unset) cast call 0x59b670e9fA9D0A427751Af201D676719a970857b 'timeCurveBuyRouter()(address)' returns 0x0 — proxy responds, default disabled state confirmed. cast call 0x59b670e9fA9D0A427751Af201D676719a970857b 'buyFor(address,uint256,bool)' 0xc46b15f4B56489a16F561c22D5F0BA8bdCa80650 1000000000000000000 false reverts with: 'TimeCurve: not buy router' — exact match for AC4. Same revert on the bytes32-codehash 4-arg variant. ## Live items deferred to #78 scripts/start-local-anvil-stack.sh and contracts/script/anvil_rich_state.sh don't run DeployKumbayaAnvilFixtures — so the router-set state, USDM two-hop live quote, and full buyViaKumbaya tx receipt evidence need a separate fixtures deploy. Tracked at #78. AC5 (MegaETH/prod SwapRouter02 wire-up) is mainnet readiness, out of scope for local QA. Closing #65. Live walkthrough proceeds via #78.
Brouie (Migrated from gitlab.com) closed this issue 2026-04-27 04:12:19 +00:00
PlasticDigits commented 2026-04-27 04:41:42 +00:00 (Migrated from gitlab.com)

mentioned in commit 54e6ad92b4

mentioned in commit 54e6ad92b43e81d61e6f4b5ab2606396919a60b1
PlasticDigits commented 2026-04-27 07:24:01 +00:00 (Migrated from gitlab.com)

mentioned in issue #83

mentioned in issue #83
PlasticDigits commented 2026-04-30 05:25:10 +00:00 (Migrated from gitlab.com)

mentioned in issue #100

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

mentioned in issue #118

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

mentioned in issue #207

mentioned in issue #207
PlasticDigits commented 2026-05-21 09:32:21 +00:00 (Migrated from gitlab.com)

mentioned in issue #232

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