Pre-deploy review pass 2: 8 findings on indexer / deploy scripts / frontend (3 medium, 5 low) #138
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#138
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?
second pass on the broader stack — indexer, deploy scripts, frontend, env templates, test coverage. dedup'd against the audit MD, my prior #126-#131, and the #116-#125 audit batch. these are NEW.
ordered by severity descending.
Finding 1 — Indexer: 3 events emitted by audit-batch contracts are undecoded (Medium)
indexer/src/decoder.rshas nosol!definitions and no decode arms for:TimeCurve.PodiumResidualRecipientSet(address indexed recipient)—contracts/src/TimeCurve.sol:187. M-01 fix introduced this setter to direct unallocated podium residuals.TimeCurveBuyRouter.EthRescued(address indexed to, uint256 amount)—contracts/src/TimeCurveBuyRouter.sol:69. M-02/#117 fix.TimeCurveBuyRouter.Erc20Rescued(address indexed token, address indexed to, uint256 amount)—contracts/src/TimeCurveBuyRouter.sol:70. M-02/#117 fix. NB: lowercase casing differs fromFeeRouter.ERC20Rescued(uppercase) — different topic0s, only the FeeRouter variant is decoded.undecoded events fall to
DecodedEvent::Unknown { topic0 }and are dropped bypersist.rs. no idx_ table, no row inreorg.rsrollback list.consequence: M-01 closure note frames residual routing as governed-with-audit-trail; M-02 closure same shape for ETH/ERC20 sweeps. without decoded events, the audit trail does not exist on the indexer side. ops/compliance dashboards waiting on these events get nothing.
PodiumResidualRecipientSetmatters most because owner can re-point the residual sink mid-sale or post-end with no on-chain delay.mitigation: add
sol!defs for the 3 events, add decode arms with the right topic0s, add 3 newidx_tables, add INSERT branches inpersist.rs, add the table names torollback_after's array.Finding 2 — Indexer: per-block ingestion is non-transactional, crash leaves ghost events on reorg (Medium)
indexer/src/ingestion.rs:144-162runs three sqlx calls sequentially without a wrapping transaction:if the indexer crashes (panic, RPC error, OOM, container kill, host reboot) after persisting some/all events for block N but before
save_chain_pointer, the chain_pointer stays at N-1 while events from N are already in DB.race: on restart,
next_block_to_processreturns N. RPC call fetches new canonical block N. if a reorg occurred during the crash window, parent of new N is still N-1's hash (parent doesn't change on a same-height reorg) — reorg detection atingestion.rs:115does NOT fire. new (now-canonical) N's logs persist.ON CONFLICT DO NOTHINGprevents(tx_hash, log_index)duplicates, but orphaned tx_hashes from the old reorged-out N stay in DB indefinitely as ghost rows. since primary keys are(tx_hash, log_index), the rollback logic keyed onblock_number > ancestornever sees them again unless a deeper reorg happens later.at MegaETH ~1s block time the window is narrow but real.
docs/REORG_STRATEGY.mdinvariant 'reorged events must never stick' is violated for the crash-during-block-N case.mitigation: wrap per-block work in a transaction:
restart then sees N fully committed or not at all.
Finding 3 — Deploy script has no chainid guard, can deploy full dev stack to mainnet on RPC misconfig (Medium)
contracts/script/DeployDev.s.soland 3 sibling dev scripts (DeployKumbayaAnvilFixtures.s.sol,AnvilSameBlockDrill.s.sol,SimulateAnvilRichState.s.sol) have norequire(block.chainid == ..., ...)guard.DeployDev.s.sol:run()unconditionally:MockReserveCl8y— has public unauthenticatedmint(address, uint256), anyone can mint unlimited supplytc.startSaleAt(block.timestamp + 300)— sale auto-starts in 5 minutessetCharmRedemptionEnabled(true)andsetReservePodiumPayoutsEnabled(true)— opens issue-#55 final-signoff gates immediatelypresaleVesting.setClaimsEnabled(true)andstartVesting()— auto-enables vestingtc.setDoubPresaleVesting(...)— wires +15% CHARM weightconsequence: an operator running
forge script script/DeployDev.s.sol --broadcast --rpc-url <mainnet_rpc>with a realPRIVATE_KEYenv (forgot to swap to prod script; CI smoke test against wrong RPC; staging job's RPC env points at mainnet by mistake) deploys a complete dev stack to mainnet — publicly-mintable reserve, 5min auto-start sale, claim gates open, vesting started, deployer holding all admin/upgrade keys. recovery = manual cleanup. the OZ pattern around L168-170 (grantRole(MINTER_ROLE, deployer); mint(...); revokeRole) also leaves the deployer with permanent burn-anyone power on Doubloon if broadcast aborts between grant and revoke (compounds with #131 finding 1).mitigation: add at top of every
run():cheap defense, catches the misconfigured-RPC class before any tx broadcasts.
Finding 4 — Indexer .env.example uses a trivial placeholder DB password and ships testnet defaults without warnings (Low)
indexer/.env.exampleships with a trivially-grep-able placeholder string as the postgres password in theDATABASE_URLtemplate. operators using copy-this-and-fill workflows commonly forget to swap weak placeholder credentials. additionallyRPC_URLandCHAIN_IDdefault to MegaETH testnet without comment annotation — increases mis-pointed deploy risk.mitigation:
CHANGE_ME_BEFORE_DEPLOYconfig.rsthat refuses well-known placeholder passwords whenINDEXER_PRODUCTION=1Finding 5 — Frontend issues 9
maxUint256infinite approvals across the user flow, multiplying H-01 blast radius (Low–Medium)inventoried infinite approvals:
useTimeCurveArenaModel.tsx:2227, 2243— WETH/USDM → SwapRouteruseTimeCurveArenaModel.tsx:2278, 2340— CL8Y → TimeCurve (×2 paths)useTimeCurveSaleSession.ts:985, 1001— WETH/USDM → SwapRouteruseTimeCurveSaleSession.ts:1036— CL8Y → TimeCurveReferralRegisterSection.tsx:219— CL8Y → ReferralRegistrytimeCurveKumbayaSingleTx.ts:97— token → router= 9 distinct infinite-approval grants accumulated by a typical user across a sale.
race / MEV exposure: TimeCurve, ReferralRegistry, FeeRouter, RabbitTreasury are UUPS proxies. per audit H-01, a compromised owner can install a new TimeCurve impl whose
buy(charmWad)re-uses the existing CL8Y allowance to drain the wallet (new logic transfers full allowance to attacker before the buy). the frontend'smaxUint256choice multiplies H-01 blast radius from 'amount in flight at moment of compromise' to 'full balance of every previously-connecting wallet'. norevokeApprovalUX, no 'use exact amount' toggle.mitigation: change defaults to
approve(spender, exactAmount)(next call re-approves; user signs once per buy). for the high-volume CL8Y → TimeCurve path, allow opt-in infinite via checkbox with one-line disclosure: 'this gives the contract permission to spend any amount, forever; if the contract is later upgraded by its admin, that authority can be re-used'.Finding 6 — submitBuy multi-step flow has account-switch race (fund-redirection vector) (Low–Medium)
useTimeCurveSaleSession.ts:861-1050(submitBuycallback).addressis captured at click time (L867), reused unchanged across multiplewriteContractAsync+waitForTransactionReceiptawaits.specific reuse points:
args: [address, k.config.swapRouter](WETH allowance read)args: [address, k.config.swapRouter](USDM allowance read)recipient: address(KumbayaexactOutputswap recipient)args: [address, tc](CL8Y allowance read)between awaits, the user can disconnect + reconnect with a different wallet (wallets like Rabby switch accounts mid-tx). subsequent
writeContractAsynccalls are signed by the NEW wallet, but the in-callbackaddressvariable still points to the OLD wallet.concrete consequence: if user switches wallets between the WETH approve step and the swap step:
recipientis hardcoded OLD wallet's addressnot lost funds in the strict sense (each tx is signed by its actual owner) but coherent fund-tracking breaks; refund only via
weth.withdrawfrom the wallet that holds the WETH; error surface is confusing (friendlyRevertFromUnknowndoesn't disambiguate this).mitigation: snapshot
addressonce at top, then on eachawaitre-readuseAccount().addressand abort with a recoverable error if it changed. same forchainId. on disconnect, gate any pending steps behind a re-confirmation modal.Finding 7 — PresaleVestingPage renders raw wagmi error message, can leak RPC API key (Low)
frontend/src/pages/PresaleVestingPage.tsx:269:raw
error.messagefrom wagmi/viem is rendered to DOM. wagmi error messages can include the configured RPC URL, gas estimation details, and revert-data payloads.consequence: on a deployment using a paid RPC with API key in the URL (common pattern for MegaETH partners — Alchemy / QuickNode / Helius style), the URL leaks to:
friendlyRevertFromUnknownexists in this codebase (used by submitBuy) but is not applied here.mitigation: route through
friendlyRevertFromUnknownconsistently. add a redaction pass that stripsimport.meta.env.VITE_RPC_URLsubstring before display. apply same to other write surfaces (referrals register, time curve buy) where errors render to user.Finding 8 — Multiple critical attack scenarios have no negative-test coverage (Low aggregate)
surfaces from #131 + this pass that have no regression-pinning test:
a.
Doubloon.burn(arbitrary_from)from a non-RabbitTreasury MINTER_ROLE holder (#131 finding 1). addtest_burn_drains_arbitrary_holder_via_secondary_minterthat grants MINTER_ROLE to a fresh address, has it burn another wallet's DOUB, asserts success.b. WarBow steal '2x rule' fresh-wallet bypass (#131 finding 4).
test_warbow_steal_drains_ten_percent_and_burns_one_reserveexercises the bypass (bob hasabp=0) but doesn't label it. addtest_warbow_steal_fresh_wallet_drains_high_bp_victim_for_one_cl8ydocumenting the economic vulnerability as a known invariant.c. WarBow revenge collusion shielding (#131 finding 5). add
test_warbow_revenge_overwritten_by_second_stealer_shields_firstexercising A→V→B→V→V.warbowRevenge(B)succeeds while A is unrevengable.d.
setBuyFeeRoutingEnabledmid-sale freeze (#131 finding 2). addtest_buy_fee_routing_freeze_after_buy_blocks_warbow_defensesimulating owner flip during active podium battle, asserts victims cannot guard/revenge until timer expires.e. DoubPresaleVesting excess DOUB lock (#131 finding 7). add
test_excess_doub_above_total_allocated_is_unrecoverabletransferring extra DOUB post-startVesting, asserts no path drains it after all beneficiaries claim.f. Indexer reorg crash-recovery (this finding 2). integration test that persists block N events, kills indexer process or aborts the future, restarts, asserts second run produces no orphan rows.
per-gap severity is low. aggregate of 'critical adversarial behaviors not regression-pinned' is medium-relevance for a contract going to mainnet tonight.
mitigation: add the 6 tests above. they (a) lock current behavior as regression baseline, (b) make future fixes obvious, (c) document attack surface in code that runs in CI.
Pre-deploy relevance
cc @PlasticDigits
mentioned in issue #139
mentioned in issue #140
mentioned in issue #141
mentioned in issue #142
mentioned in issue #143
mentioned in issue #144
mentioned in issue #145
mentioned in issue #146
Spawned 8 detailed follow-ups with references, recommended fixes, acceptance criteria, and verification checklists for each Finding 1–8 from this pre-deploy review pass:
Parent triage can track deploy-tonight scope via #139–#141; #142–#146 align with post-deploy hardening per the pass-2 note. Closing this umbrella now that work is decomposed.
mentioned in issue #148
mentioned in issue #149
mentioned in issue #168