L-05: Balance-delta ERC20 accounting (standard ERC20 covenant)
## Source
Tracked from internal smart-contract audit excerpt **L-05** in [`audits/audit_smartcontract_1777813071.md`](/PlasticDigits/yieldomega/-/blob/main/audits/audit_smartcontract_1777813071.md):
> Standard-ERC20 assumptions are security-critical — contracts use `SafeERC20`, but accounting generally uses **requested amounts** rather than **received balance deltas**. Deployment invariant must remain “standard ERC20 only”; accounting should match tokens actually credited.
---
## Problem statement
`SafeERC20` correctly handles missing/false **`bool` returns** but does **not** make **`transferFrom`** move exactly the caller-supplied **`amount`** in all real ERC20 deployments. Categories of tokens that violate the “canonical ERC20 accounting model” include **fee-on-transfer**, **rebasing**, **rebate-on-transfer hooks** (ERC-777-adjacent), **pausable/blacklist**, **upgradeable/token-with-fees** wrappers, etc.
Across core contracts, **`totalRaised`**, treasury **`totalReserves`**, routed fee splits, burns, vesting allocations, events, and invariants implicitly assume **`balance change == requestedAmount`**.
That assumption is accurate **only** for **standard** ERC-20 implementations (constant balance on transfer excluding mint/burn semantics the token documents). For other assets it becomes a **silent or revert-prone correctness bug**: internal ledgers drift from **`balanceOf`**, permissionless routers can fail mid-flow, payouts can under-transfer, indexer “volume” ghosts misalign with treasury reality, etc.
Operational docs already warn operators; audit L-05 calls out this is **policy + implementation smell**, not purely documentation.
---
## Goals (dual requirement)
1. **Onchain bookkeeping:** Prefer **balance deltas** (before/after `balanceOf` snapshots around each pull/push cohort) wherever value enters or leaves protocol-controlled ERC20 custody, rather than trusting nominated request sizes for **`+=` ledger updates**, **`distributeFees` arguments**, burns, emitted economic fields, etc.
For **canonical standard ERC20**, **`received == requested`** anyway; deltas add **correctness parity** between storage and **`balanceOf`**.
2. **Documentation & deployment posture:** Strengthen explicit, discoverable docs that **`acceptedAsset` / CL8Y / reserve / vested funding tokens are expected to be standard ERC20.** Balance-delta accounting is **not** consent to ship fee-on-transfer or rebasing “supported assets”: it aligns ledgers when possible and makes violations **observeable/testable**.
---
## Recommended implementation approach
### Shared pattern
For each **`safeTransferFrom(payer, this, nominal)`**:
1. **`uint256 b0 = token.balanceOf(address(this));`**
2. **`token.safeTransferFrom(payer, address(this), nominal);`**
3. **`uint256 received = token.balanceOf(address(this)) - b0;`**
4. Use **`received`** (not **`nominal`**) for:
- Stateful counters (**`totalRaised`**, **`totalReserves`**, etc.).
- Downstream **`safeTransfer`** / **`distributeFees`** / vesting allocations driven by “what actually arrived”.
- Event payloads intended to mirror **economically realized** ingress/egress (**unless** intentionally documenting “priced quote”; see below).
**Edge cases:**
- **`received == 0`:** generally **`revert`** (no-op steals gas / breaks invariants).
- **Strict parity (optional complementary guardrail):** if product requires **pricing math ingress == physical ingress**, add **`require(received == nominal, "...")`** *after* snapshot — standard tokens pass; malformed tokens fail **early and explicitly**. Decide per path whether economic rule is **`received` must equal priced `amount`** (preserves participant-facing CHARM/price linkage) vs **best-effort `received`** (usually wrong for auctions with fixed **`charmWad`**). Default recommendation for **`TimeCurve._buy`**:** **`require(received == amount)` + ledger from `received`** so CHARM sizing stays coherent *and* storage uses delta **by construction** even if someone later tweaks code paths.
### `TimeCurve` (priority)
[`contracts/src/TimeCurve.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/TimeCurve.sol): **`_buy`** today does **`acceptedAsset.safeTransferFrom(..., amount)`** then **`totalRaised += amount`** and **`feeRouter.distributeFees(acceptedAsset, amount)`**.
- Migrate to **`received`** from **`acceptedAsset`** balance delta on **`address(this)`** for **`distributeFees`**, **`totalRaised`**, and **`Buy`** / **`ReferralApplied`** payloads (use **`received`** everywhere the audit calls “accounting”; align NatSpec/event comments).
- Apply the same discipline to **`warbowSteal`**, **`warbowRevenge`**, **`warbowGuard`** multi-pull paths ( **`totalBurn`** accounting vs actual aggregate delta before burn sink transfer).
- Any other **`acceptedAsset.safeTransferFrom`** ingress in this file — same checklist.
### `TimeCurveBuyRouter`
[`contracts/src/TimeCurveBuyRouter.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/TimeCurveBuyRouter.sol) already computes **`cl8yGain`** from CL8Y **balance delta** post-swap. Extend **consistency**:
- Stable path: **`amountInMaximum`** pull should record **received** delta on **`address(this)`** (not **`amountInMaximum`**) before approving router; refunds already use leftovers — verify no ledger uses **`amountInMaximum`** as “spent”.
- Document that **`grossCl8y`** emitted is still priced target; **`buyFor`** ultimately depends on **`TimeCurve`** ingress delta after approval.
### `RabbitTreasury`
[`contracts/src/RabbitTreasury.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/RabbitTreasury.sol): deposits that **`safeTransferFrom`** then mutate **`totalReserves`** — switch reserves bookkeeping to **`received`** deltas (+ optional **`require(received == amount)`**).
### `ReferralRegistry`
[`contracts/src/ReferralRegistry.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/ReferralRegistry.sol): burn uses **`transferFrom` → dead address**. Measure **delta at `BURN_ADDRESS`** or **delta leaving caller** consistently; emit/docs should match **actually burned**.
### `FeeRouter`, `FeeSink`, `PodiumPool`
[`contracts/src/FeeRouter.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/FeeRouter.sol), [`contracts/src/sinks/FeeSink.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/sinks/FeeSink.sol), [`contracts/src/sinks/PodiumPool.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/sinks/PodiumPool.sol): callers must pass **`exact spendable balance delta`** (not optimistic request). Confirm **`distributeFees`** and withdrawals never assume **`requested > balanceOf`**.
### `DoubPresaleVesting`
[`contracts/src/vesting/DoubPresaleVesting.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/vesting/DoubPresaleVesting.sol): funding / claim paths using ERC20 — same delta discipline for any accounting fields.
---
## Regression / new tests (`forge`)
| Area | Suggested coverage |
|------|---------------------|
| **Standard ERC20 parity** | Property tests: **`received == nominal`** paths unchanged vs pre-refactor (**`totalRaised`**, fee splits). |
| **`NonStandardERC20.t.sol`** | Extend [`contracts/test/NonStandardERC20.t.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/test/NonStandardERC20.t.sol): fee-on-transfer / rebasing stubs assert **ledger == balance deltas**, not nominal; assert **`require(received == nominal)`** (if adopted) triggers with clear selectors/revert strings. |
| **TimeCurve invariant** | [`contracts/test/TimeCurveInvariant.t.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/test/TimeCurveInvariant.t.sol): ghost buy volume tracks **summed deltas** aligned with **`totalRaised`**. |
| **Router fork / Anvil** | [`contracts/test/TimeCurveBuyRouter.t.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/test/TimeCurveBuyRouter.t.sol), fork tests — regress CL8Y surplus handling + stable refunds. |
| **Treasury / registry** | Add focused tests for **`RabbitTreasury`** deposit deltas; **`ReferralRegistry`** burn measurably matches **`registrationBurnAmount`** only for standard tokens. |
---
## Files likely to touch
**Solidity**
- [`contracts/src/TimeCurve.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/TimeCurve.sol)
- [`contracts/src/TimeCurveBuyRouter.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/TimeCurveBuyRouter.sol)
- [`contracts/src/RabbitTreasury.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/RabbitTreasury.sol)
- [`contracts/src/ReferralRegistry.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/ReferralRegistry.sol)
- [`contracts/src/FeeRouter.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/FeeRouter.sol)
- [`contracts/src/sinks/FeeSink.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/sinks/FeeSink.sol)
- [`contracts/src/sinks/PodiumPool.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/sinks/PodiumPool.sol)
- [`contracts/src/vesting/DoubPresaleVesting.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/src/vesting/DoubPresaleVesting.sol)
**Tests**
- [`contracts/test/NonStandardERC20.t.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/test/NonStandardERC20.t.sol)
- [`contracts/test/TimeCurveInvariant.t.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/test/TimeCurveInvariant.t.sol)
- [`contracts/test/TimeCurve.t.sol`](/PlasticDigits/yieldomega/-/blob/main/contracts/test/TimeCurve.t.sol) and related (**`WarBow`** / **`Fork`** suites as impacted)
- New small helper **`internal pure/view`** wrappers only if duplication is high (keep diff focused).
**Docs**
- [`docs/onchain/security-and-threat-model.md`](/PlasticDigits/yieldomega/-/blob/main/docs/onchain/security-and-threat-model.md) — reconcile “Implementation notes” with **delta-first** wording and reiterated **standard-ERC20-only** deployment covenant.
- [`docs/testing/invariants-and-business-logic.md`](/PlasticDigits/yieldomega/-/blob/main/docs/testing/invariants-and-business-logic.md) — update invariant text: **`totalRaised`** tracks **sum of received deltas** under standard ERC20 (**equals** nominal); link tests.
- Product/rail docs as relevant: **[`docs/product/primitives.md`](/PlasticDigits/yieldomega/-/blob/main/docs/product/primitives.md)**, **[`docs/product/referrals.md`](/PlasticDigits/yieldomega/-/blob/main/docs/product/referrals.md)**, **[`docs/product/rabbit-treasury.md`](/PlasticDigits/yieldomega/-/blob/main/docs/product/rabbit-treasury.md)**, **[`docs/integrations/kumbaya.md`](/PlasticDigits/yieldomega/-/blob/main/docs/integrations/kumbaya.md)** — single paragraph each: **accepted assets must be vanilla ERC20;** delta accounting ≠ multi-asset carte blanche.
**Indexer / frontend (conditional)**
- If event field **semantics** or names change (**`Buy`**, **`ReferralApplied`**, **`BuyViaKumbaya`**): [`indexer/`](/PlasticDigits/yieldomega/-/blob/main/indexer/), [`frontend/src`](/PlasticDigits/yieldomega/-/blob/main/frontend/src/) ABI bindings and aggregates.
- If only **runtime values** converge to same numbers on canonical tokens → likely **no schema change**.
---
## Acceptance criteria
1. **Every production ERC20 ingress** into **`TimeCurve`**, **`RabbitTreasury`**, **`ReferralRegistry`**, **`DoubPresaleVesting`**, **`TimeCurveBuyRouter`** (ingress legs), **`FeeRouter`/sinks callers** reviewed; those that update **`+=` ledger state** derive from **measured deltas**, not uninstrumented **`amount` parameters**.
2. **Fork/invariant regressions**: **`forge test`** green; **`TimeCurveInvariant`** handler ghost vs **`totalRaised`** coherent under fuzz (standard mock ERC20 unchanged numerically vs baseline).
3. **`NonStandardERC20`** mocks exercise **either** corrected accounting **or** explicit **`require(received == nominal)`** failure — tests document which policy each path chooses.
4. **Docs**: clear **deployment covenant** (“**standard ERC20 only** — no FoT/rebasing/777/pausable/third-party wrappers unless explicitly waived”) and clarify **why balance deltas exist** (**ledger–`balanceOf` parity**, not generalized multi-asset).
5. **No silent semantic drift** on canonical deployments: numerical outcomes for mocks with **18-dec standard ERC20** match **pre-change** snapshots (golden assertions or pinned expected values in touched tests).
---
## Verification checklist (implementation)
1. **`git diff` sanity:** only erc20 bookkeeping + tests + documented surfaces; no unrelated refactors.
2. **`forge fmt`** on touched Solidity.
3. **`forge test`** full contracts suite (or document CI-equivalent subset if partial locally).
4. Grep residual pattern: **`totalRaised += amount`** without preceding delta — **must be justified** (`amount` == `received` by construction immediately above).
5. NatSpec / dev comments above **`acceptedAsset`** and **`reserveAsset`** setters emphasize **canonical ERC20** expectations.
6. If events change ABI: regenerate Rust/TS ABI artifacts per repo convention and run **`cargo test`** / **`npm run typecheck`** as applicable.
---
## Verification checklist (reviewer QA)
1. Read updated **security/threat-model** subsection — covenant is unambiguous for operators.
2. Spot-check **`TimeCurve._buy`** trace: **`balanceOf[this]` delta** feeds **`distributeFees`** + **`totalRaised`** + events.
3. Spot-check **`RabbitTreasury`** deposits: **`totalReserves`** vs **`reserveAsset.balanceOf(this)`** after synthetic deposit sequences.
4. Confirm **`BuyViaKumbaya`** analytics still coherent: **`grossCl8y`** meaning documented (priced vs measured if both appear).
5. Confirm **reject-list** narrative (FoT/rebasing) still prominent — **delta math does not read as feature support.**
---
## Out of scope (explicit)
- **General multi-asset safe composition** (`allowlist adapters per asset`) — future work referenced in audit **only**.
- Replacing **`SafeERC20`** or supporting **FoT‑native** treasury logic without governance decision.
issue