L-05: Balance-delta ERC20 accounting (standard ERC20 covenant) #123
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#123
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 internal smart-contract audit excerpt L-05 in
audits/audit_smartcontract_1777813071.md:Problem statement
SafeERC20correctly handles missing/falseboolreturns but does not maketransferFrommove exactly the caller-suppliedamountin 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, treasurytotalReserves, routed fee splits, burns, vesting allocations, events, and invariants implicitly assumebalance 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)
Onchain bookkeeping: Prefer balance deltas (before/after
balanceOfsnapshots around each pull/push cohort) wherever value enters or leaves protocol-controlled ERC20 custody, rather than trusting nominated request sizes for+=ledger updates,distributeFeesarguments, burns, emitted economic fields, etc.For canonical standard ERC20,
received == requestedanyway; deltas add correctness parity between storage andbalanceOf.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):uint256 b0 = token.balanceOf(address(this));token.safeTransferFrom(payer, address(this), nominal);uint256 received = token.balanceOf(address(this)) - b0;received(notnominal) for:totalRaised,totalReserves, etc.).safeTransfer/distributeFees/ vesting allocations driven by “what actually arrived”.Edge cases:
received == 0: generallyrevert(no-op steals gas / breaks invariants).require(received == nominal, "...")after snapshot — standard tokens pass; malformed tokens fail early and explicitly. Decide per path whether economic rule isreceivedmust equal pricedamount(preserves participant-facing CHARM/price linkage) vs best-effortreceived(usually wrong for auctions with fixedcharmWad). Default recommendation forTimeCurve._buy:**require(received == amount)+ ledger fromreceivedso CHARM sizing stays coherent and storage uses delta by construction even if someone later tweaks code paths.TimeCurve(priority)contracts/src/TimeCurve.sol:_buytoday doesacceptedAsset.safeTransferFrom(..., amount)thentotalRaised += amountandfeeRouter.distributeFees(acceptedAsset, amount).receivedfromacceptedAssetbalance delta onaddress(this)fordistributeFees,totalRaised, andBuy/ReferralAppliedpayloads (usereceivedeverywhere the audit calls “accounting”; align NatSpec/event comments).warbowSteal,warbowRevenge,warbowGuardmulti-pull paths (totalBurnaccounting vs actual aggregate delta before burn sink transfer).acceptedAsset.safeTransferFromingress in this file — same checklist.TimeCurveBuyRoutercontracts/src/TimeCurveBuyRouter.solalready computescl8yGainfrom CL8Y balance delta post-swap. Extend consistency:amountInMaximumpull should record received delta onaddress(this)(notamountInMaximum) before approving router; refunds already use leftovers — verify no ledger usesamountInMaximumas “spent”.grossCl8yemitted is still priced target;buyForultimately depends onTimeCurveingress delta after approval.RabbitTreasurycontracts/src/RabbitTreasury.sol: deposits thatsafeTransferFromthen mutatetotalReserves— switch reserves bookkeeping toreceiveddeltas (+ optionalrequire(received == amount)).ReferralRegistrycontracts/src/ReferralRegistry.sol: burn usestransferFrom→ dead address. Measure delta atBURN_ADDRESSor delta leaving caller consistently; emit/docs should match actually burned.FeeRouter,FeeSink,PodiumPoolcontracts/src/FeeRouter.sol,contracts/src/sinks/FeeSink.sol,contracts/src/sinks/PodiumPool.sol: callers must passexact spendable balance delta(not optimistic request). ConfirmdistributeFeesand withdrawals never assumerequested > balanceOf.DoubPresaleVestingcontracts/src/vesting/DoubPresaleVesting.sol: funding / claim paths using ERC20 — same delta discipline for any accounting fields.Regression / new tests (
forge)received == nominalpaths unchanged vs pre-refactor (totalRaised, fee splits).NonStandardERC20.t.solcontracts/test/NonStandardERC20.t.sol: fee-on-transfer / rebasing stubs assert ledger == balance deltas, not nominal; assertrequire(received == nominal)(if adopted) triggers with clear selectors/revert strings.contracts/test/TimeCurveInvariant.t.sol: ghost buy volume tracks summed deltas aligned withtotalRaised.contracts/test/TimeCurveBuyRouter.t.sol, fork tests — regress CL8Y surplus handling + stable refunds.RabbitTreasurydeposit deltas;ReferralRegistryburn measurably matchesregistrationBurnAmountonly for standard tokens.Files likely to touch
Solidity
contracts/src/TimeCurve.solcontracts/src/TimeCurveBuyRouter.solcontracts/src/RabbitTreasury.solcontracts/src/ReferralRegistry.solcontracts/src/FeeRouter.solcontracts/src/sinks/FeeSink.solcontracts/src/sinks/PodiumPool.solcontracts/src/vesting/DoubPresaleVesting.solTests
contracts/test/NonStandardERC20.t.solcontracts/test/TimeCurveInvariant.t.solcontracts/test/TimeCurve.t.soland related (WarBow/Forksuites as impacted)internal pure/viewwrappers only if duplication is high (keep diff focused).Docs
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— update invariant text:totalRaisedtracks sum of received deltas under standard ERC20 (equals nominal); link tests.docs/product/primitives.md,docs/product/referrals.md,docs/product/rabbit-treasury.md,docs/integrations/kumbaya.md— single paragraph each: accepted assets must be vanilla ERC20; delta accounting ≠ multi-asset carte blanche.Indexer / frontend (conditional)
Buy,ReferralApplied,BuyViaKumbaya):indexer/,frontend/srcABI bindings and aggregates.Acceptance criteria
TimeCurve,RabbitTreasury,ReferralRegistry,DoubPresaleVesting,TimeCurveBuyRouter(ingress legs),FeeRouter/sinks callers reviewed; those that update+=ledger state derive from measured deltas, not uninstrumentedamountparameters.forge testgreen;TimeCurveInvarianthandler ghost vstotalRaisedcoherent under fuzz (standard mock ERC20 unchanged numerically vs baseline).NonStandardERC20mocks exercise either corrected accounting or explicitrequire(received == nominal)failure — tests document which policy each path chooses.balanceOfparity, not generalized multi-asset).Verification checklist (implementation)
git diffsanity: only erc20 bookkeeping + tests + documented surfaces; no unrelated refactors.forge fmton touched Solidity.forge testfull contracts suite (or document CI-equivalent subset if partial locally).totalRaised += amountwithout preceding delta — must be justified (amount==receivedby construction immediately above).acceptedAssetandreserveAssetsetters emphasize canonical ERC20 expectations.cargo test/npm run typecheckas applicable.Verification checklist (reviewer QA)
TimeCurve._buytrace:balanceOf[this]delta feedsdistributeFees+totalRaised+ events.RabbitTreasurydeposits:totalReservesvsreserveAsset.balanceOf(this)after synthetic deposit sequences.BuyViaKumbayaanalytics still coherent:grossCl8ymeaning documented (priced vs measured if both appear).Out of scope (explicit)
allowlist adapters per asset) — future work referenced in audit only.SafeERC20or supporting FoT‑native treasury logic without governance decision.mentioned in commit
9905b25c9cmentioned in commit
ebbf58ed93mentioned in commit
a574c6337amentioned in issue #122
mentioned in issue #117
verified on commit
a574c63(the ingress parity work you mentioned on #122). all 4 fee-on-transfer parity tests now pass clean — these were the failures I'd flagged earlier on #117.ingress balance-delta enforcement (your AC):
test_feeOnTransfer_rabbitTreasury_deposit_reverts_erc20Parity— RabbitTreasury.deposit revertsTimeCurve: ERC20 parity(or analogue) on FoTtest_feeOnTransfer_rabbitTreasury_receiveFee_reverts_erc20Parity— receiveFee path sametest_feeOnTransfer_referralRegistry_register_reverts_erc20Parity— registry burn path sametest_feeOnTransfer_timeCurve_buyReverts_erc20Parity— TimeCurve.buy reverts with the new parity message instead of opaque OZERC20InsufficientBalanceexisting non-standard token coverage intact:
test_alwaysRevert_feeRouter_distributeReverts— pre-existing always-revert behavior preservedtest_alwaysRevert_rabbitTreasury_depositRevertstest_blockedSink_feeRouter_distributeRevertstest_rebasing_treasury_balanceCanDesyncFromTotalReserves— rebasing desync warning preservedtotalRaised + FeeRouter.distributeFees now use the measured segment per your spec. accidental FoT/rebasing token deployment now fails at the ingress check rather than producing silently wrong accounting downstream. complements #122's allowlist + governed rescue.
mentioned in issue #124
mentioned in commit
53dab468ff@PlasticDigits —
testFuzz_warbow_steal_burn_exact_to_sinkstill fails after53dab46. counterexample:args=[21118, 57005]. second arg decodes to0x000000000000000000000000000000000000dEaD= BURN_SINK.walks through:
_pullAcceptedExact(BURN_SINK, 1e18)→transferFrom(BURN_SINK, tc, 1e18)← BURN_SINK was pre-funded by test setupsafeTransfer(BURN_SINK, 1e18)→ tc returns 1e18 to BURN_SINKbalanceOf(BURN_SINK) - sinkBefore == 1e18fails because actual delta is 0your
vm.assume(... != address(tc))patch caught the address(tc)==payer round-trip but not the BURN_SINK==msg.sender round-trip. they're the same shape —_pullAcceptedExactpulls into tc, then tc transfers back out to BURN_SINK, net zero on the assertion target.two paths:
test fix (small): add
vm.assume(victim != BURN_SINK && stealer != BURN_SINK)alongside the existing address(tc) checks in the steal test (and also revenge and guard tests for consistency, though those happen to pass currently).contract fix (defensive): add
require(msg.sender != BURN_SINK)inwarbowSteal/warbowRevenge/warbowActivateGuard. closes the no-op-burn edge for any actor that controls 0xdead (theoretical only, no real wallet does).option 1 is the immediate CI green. option 2 is the defense-in-depth. up to you.
Option 2 is the correct choice
mentioned in commit
daed936003mentioned in commit
a4d2a50ccbFix merged (option 2 — onchain guard, GitLab #123)
Pushed to
mainas merge commitdaed936.What changed
TimeCurve: added_requireWarbowParticipantCaller()—require(msg.sender != BURN_SINK, "TimeCurve: burn sink caller")— and call it fromwarbowSteal,warbowRevenge, andwarbowActivateGuard(after sale-interaction gates). Closes the#123/ balance-delta_pullAcceptedExactround-trip wheremsg.sender == 0x…dEaDcould net zero CL8Y into the burn sink while WarBow BP logic could still move.TimeCurveWarBowCl8yBurns.t.sol—test_warbow_steal_reverts_when_caller_is_burn_sink,test_warbow_revenge_reverts_when_caller_is_burn_sink,test_warbow_guard_reverts_when_caller_is_burn_sink; fuzz cases alsoassumeawayBURN_SINKfor the sink-delta invariants.INV-WARBOW-123-BURN-CALLERindocs/testing/invariants-and-business-logic.md, row indocs/onchain/security-and-threat-model.md,skills/README.md,skills/play-timecurve-warbow/SKILL.md, contributor.cursor/skills/yieldomega-guardrails/SKILL.mditem #41.Verification checklist for @brouie
git pulland confirmdaed936onmain;git show --stat daed936.cd contracts && forge test --match-path test/TimeCurveWarBowCl8yBurns.t.sol -vvv— 6 tests green (3 fuzz + 3 explicit revert).forge testfull suite locally or CI green on the pushed commit._requireWarbowParticipantCallercall sites vsBURN_SINKrelay incontracts/src/TimeCurve.sol; confirm no other CL8Y WarBow entrypoint bypasses this pattern.#inv-warbow-123-burn-callerstill reads as defense-in-depth (not “support FoT tokens”).Leaving this issue open for your sign-off.
@PlasticDigits — verification PASS on
daed936. walked your checklist:daed936on main.git show --stat daed936confirms.forge test --match-path test/TimeCurveWarBowCl8yBurns.t.sol -vvvgreen.testFuzz_warbow_steal_burn_exact_to_sinkclean at 256 runs after the BURN_SINK assume — yesterday's red is now green.a421ba1(HEAD post-merge)._requireWarbowParticipantCallercall sites read clean —warbowSteal(L624),warbowRevenge(L695),warbowActivateGuard(L724).claimWarBowFlagcorrectly skipped (no_pullAcceptedExactcall, just flag plant/claim, no CL8Y movement). no other WarBow entrypoint pulls CL8Y.#inv-warbow-123-burn-callerreads as defense-in-depth — frames the round-trip parity as a WarBow-state-mutation gap, not asset support.option 2 was the right call. test-side
vm.assumewould have left a real ingress shape for any future actor that controls 0xdead.ready for close on your side.
mentioned in issue #207
mentioned in issue #217
mentioned in issue #246
mentioned in issue #251
mentioned in issue #261
mentioned in issue #264
mentioned in issue #300
mentioned in issue #309
mentioned in issue #310
mentioned in issue #325
mentioned in issue #341
mentioned in issue #367