Smart Contract Audit — DERC20 (cyb3rwr3n)

Target: 0x26E6e2E7a9289B6485c53Cd498dE510d3a8c8ba3 (Base mainnet, chain 8453)
Token: cyb3rwr3n (Doppler protocol DERC20 fork)
Compiler: Solidity 0.8.26 (optimizer enabled, runs=0)
Inheritance: OpenZeppelin v5.0.0 ERC20 + ERC20Permit + ERC20Votes + Ownable
Source: Verified via Sourcify (partial match)
Total supply: 1e29 (100 billion, 18 decimals)
Owner: 0x660eAaEdEBc968f8f3694354FA8EC0b4c5Ba8D12

Methodology — two parallel multi-agent audit pipelines:
  1. ethskills.com/audit — 6 specialist Opus subagents (general, precision-math, erc20, governance, signatures, access-control + dos)
  2. pashov solidity-auditor — 8 specialist Opus subagents (vector-scan, math-precision, access-control, economic-security, execution-trace, invariant, periphery, first-principles)

Total: 14 parallel Opus subagents running domain-specific checklists against the verified source. Findings deduplicated by group_key and synthesized into a single severity-ranked report below. The full per-agent findings are preserved in Appendix A (ethskills) and Appendix B (pashov).

Severity Counts (deduplicated)

SeverityCountThemes
Critical0
High4Centralization/freeze cluster (lockPool re-lock, lockPool arbitrary blacklist, Ownable 1-step transfer, renounce-after-lockPool permanent DoS)
Medium8Permissionless mintInflation precision-grief, updateMintRate truncation DoS, default _update collides with burn, unlockPool clobbers inflation accrual, irrevocable Permit2 allowance, owner super-voter via 2%/yr inflation, undelegated voting power on vested + retail balances, default block.number clock on Base
Low12Constructor input validation gaps, missing events, dead modifier/error, lockPool(address(this)) self-DoS, compound vs simple inflation, soft-veto vector, mintInflation overflow at extreme supply, etc.
Info10Documentation, leap-year, ERC-1271 SCW UX, etc.

Executive Summary

DERC20 is a small (~330 lines) OZ v5 ERC20 token with three non-standard behaviors layered on top: (1) per-recipient vesting unlocked by release(), (2) owner-controlled annual inflation capped at 2%/yr, (3) a "pool lock" gate that blocks transfers into a configurable address while locked. The overall architecture is conventional and the Solidity-level cryptography (EIP-712 permit, ERC20Votes delegation, ECDSA recovery) is unmodified OZ v5.0.0 — those surfaces are clean.

The risk is concentrated in the privileged-control surface. The "pool lock" mechanism is implemented as a powerful and under-restricted primitive: the owner can re-target it at any address, re-engage it after unlock, and combine it with Ownable.renounceOwnership to permanently brick the live AMM pool with no recovery path. The default pool == address(0) value collides with the burn destination, breaking burn() until the owner first calls a setup function. The owner also mints up to 2%/year directly to themselves with no timelock or DAO oversight, making them a permanent super-voter for any downstream Governor consuming this token.

There are no Critical findings (no third-party direct fund loss without preconditions). Several High findings concentrate around the centralization/freeze primitive cluster and a renounce-after-lock permanent DoS. Multiple Medium findings cover precision/accounting issues, irrevocable Permit2 allowance, dilution dynamics, and missing governance UX hooks.


High-Severity Findings

[H-1] lockPool is repeatable post-unlock — owner can permanently freeze pool / blacklist any address

Severity: High · Confluence: ethskills [ERC20-1] [ERC20-2] [AC-3] + pashov [P1-F2] [P4-F1] [P5-F5] [P6-F1] [P8-F2] — flagged independently by 8 of 14 agents.
Location: src/DERC20.sol:169-174

function lockPool(address pool_) external onlyOwner {
    pool = pool_;
    isPoolUnlocked = false;
}

Description: lockPool has no state guard — it is callable repeatedly with any address, including after unlockPool() has graduated the token. The function:

  1. Re-locks the live Uniswap v4 pool: every swap whose token-in routes to the pool reverts with PoolLocked() until the owner unlocks again. Sells/adds bricked.
  2. Acts as a per-address blacklist primitive: lockPool(victim) makes every transfer(_, victim, _) revert. The "pool" semantic is misleading — the gate is just to == storedAddress.
  3. Blocks vesting release() for any recipient whose address is set as pool, since release() routes its _transfer(address(this), msg.sender, amount) through the same _update guard.

Proof of concept:

  1. Token launches; unlockPool() called; trading enabled.
  2. Owner observes a sell in mempool, front-runs with lockPool(uniswapV4PoolAddress)isPoolUnlocked = false.
  3. The sell reverts. All future sells revert until owner relents.

Recommendation:

function lockPool(address pool_) external onlyOwner {
    require(pool == address(0), "pool already set");
    require(pool_ != address(0) && pool_ != address(this), "invalid pool");
    pool = pool_;
}

Plus make unlockPool one-way (no re-engagement).

[H-2] One-step ownership transfer (Ownable, not Ownable2Step)

Severity: High · Confluence: ethskills [AC-1].
Location: Inheritance line 66.

Description: A typo or compromised UI passing a wrong/dead address to transferOwnership immediately and irrevocably hands control to that address. The new owner controls every onlyOwner function and becomes the sole recipient of all future mintInflation() calls. Combined with [H-1] and [H-3], the blast radius is the entire trading surface.

Recommendation: Inherit Ownable2Step; require acceptOwnership confirmation on transfer.

[H-3] renounceOwnership permanently bricks pool gating, inflation, burn

Severity: High · Confluence: ethskills [AC-2] [DOS-5] + pashov [P1-F4] [P3-L6] [P8-L6].

Description: Once renounced (owner = address(0)):

Recommendation: Override renounceOwnership to revert. Document operator runbook: never renounce while pool is locked.

[H-4] Renounce-after-lockPool permanently DoSes the AMM

Severity: High (specific concrete realization of H-3) · Confluence: ethskills [DOS-5] + pashov [P1-F4].
Sequence: owner calls lockPool(activePool)renounceOwnership() → every transfer to activePool reverts forever, no one can unlock. LPs can withdraw tokens from the pool but no buys/adds work. Major economic harm if pool is the primary venue.

Recommendation: Same as H-3.


Medium-Severity Findings

[M-1] mintInflation is permissionless — precision-loss griefing and snapshot manipulation

Severity: Medium · Confluence: ethskills [GEN-4] [PREC-2] [GOV-5] [AC-6] + pashov [P3-F2] [P4-L1] [P6-F4] [P7-L3] [P8-L3]9 of 14 agents.
Location: src/DERC20.sol:185function mintInflation() public { ... }

Description:

Recommendation: Restrict to onlyOwner. updateMintRate continues to call it internally.

[M-2] updateMintRate reverts when accrued inflation rounds to zero

Severity: Medium · Confluence: ethskills [PREC-1] + pashov [P2-F1] [P5-F2] [P6-L6] [P8-F4].
Location: src/DERC20.sol:241-243 (revert at line 212 of mintInflation)

Description: updateMintRate calls mintInflation() whenever currentYearStart != 0 && (block.timestamp - lastMintTimestamp) != 0. mintInflation() reverts NoMintableAmount() if (supply * rate * dt) / (1 ether * 365 days) truncates to 0. For low supply, low rate, or small dt (especially right after unlockPool()), this is a deterministic precision-loss DoS that blocks legitimate rate updates. Also: when yearlyMintRate == 0, the inner mintInflation() always reverts → updateMintRate becomes permanently stuck at zero rate.

Recommendation:

if (currentYearStart != 0 && block.timestamp > lastMintTimestamp) {
    uint256 pending = (totalSupply() * yearlyMintRate * (block.timestamp - lastMintTimestamp))
                       / (1 ether * 365 days);
    if (pending > 0) mintInflation();
    else lastMintTimestamp = block.timestamp;
}
yearlyMintRate = newMintRate;

[M-3] _update pool-lock check collides with burn destination

Severity: Medium · Confluence: ethskills [GEN-9] [DOS-4] + pashov [P1-F5] [P4-F2] [P5-F1] [P6-F2] [P7-F1] [P8-F1]8 of 14 agents.
Location: _update()src/DERC20.sol:300-304

Description: Default pool == address(0) and isPoolUnlocked == false. The check if (to == pool && !isPoolUnlocked) evaluates true for to == address(0). Consequences:

  1. burn() reverts with misleading PoolLocked() until the owner first calls lockPool(non-zero) or unlockPool().
  2. Any user transfer to address(0) (a common "burn-by-send" UX) reverts.
  3. If owner later calls lockPool(address(0)), the trap reactivates.

Recommendation: if (to != address(0) && to == pool && !isPoolUnlocked) revert PoolLocked();

[M-4] unlockPool resets inflation clock — silently destroys accrued mintable

Severity: Medium · Confluence: ethskills [GEN-8] [ERC20-3] + pashov [P1-F1] [P4-F4] [P5-F3] [P6-F3] [P8-F3].
Location: src/DERC20.sol:177-180

Description: Each unlockPool() call unconditionally overwrites currentYearStart = lastMintTimestamp = block.timestamp without first calling mintInflation(). Combined with the re-lockability of lockPool ([H-1]), the owner can call lockPool then unlockPool to reset the per-year inflation accumulator, destroying up to a year of accrued mintable inflation that was never minted. The function violates its own contract's internal pattern — updateMintRate correctly settles before changing state.

Recommendation: Settle inflation first, or make unlockPool one-shot.

[M-5] Irrevocable infinite Permit2 allowance

Severity: Medium · Confluence: ethskills [GEN-7] [ERC20-8] [ERC20-9] [SIG-1] + pashov [P3-F1] [P4-F3] [P5-F4] [P6-F5] [P7-F4] [P8-F5]10 of 14 agents.
Location: allowance() override — src/DERC20.sol:294-297

Description: Every holder grants type(uint256).max to PERMIT_2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3 from genesis with no signature, no approve() call, and no Approval event. The allowance is non-revocable at the token layer: approve(PERMIT_2, 0) writes storage but the override returns max regardless, and OZ v5 _spendAllowance reads the public override. Off-chain indexers (revoke.cash, Etherscan) see MAX_UINT for every holder with zero Approval events backing it — invariant violation.

This is a known Doppler design choice but materially deviates from EIP-20/EIP-2612 user expectations.

Recommendation: Minimum: prominent NatSpec warning. Better: opt-out flag (mapping(address => bool) public permit2Disabled). Alternative: consult storage first.

[M-6] Owner mints up to 2%/yr directly — permanent super-voter

Severity: Medium · Confluence: ethskills [GOV-1].

Description: Owner unilaterally controls yearlyMintRate (capped at 2%) with no timelock. mintInflation() mints to owner(). If owner self-delegates, both _totalSupplyCheckpoints and _delegateCheckpoints[owner] grow simultaneously. Over 10 years at 2%, owner gains ~21.9% of original supply; over 35 years, ~100%. Holders cannot opt out.

Recommendation: One-way yearlyMintRate ratchet (only adjustable downward), or behind timelock. Mint to a treasury/DAO multisig, not owner(). Cumulative cap.

[M-7] No auto-self-delegation — silent voting-power loss + dormant vested supply distorts quorum

Severity: Medium · Confluence: ethskills [GOV-2] [GOV-3] + pashov [P4-L5].

Description: ERC20Votes requires delegate(self) for vote-counting. The constructor mints to recipients, vested supply to address(this), and never delegates anywhere. Most retail recipients don't know to self-delegate → effective voting share concentrates with sophisticated users (notably the owner). getPastTotalSupply includes the dormant vested balance held by the contract. release() then creates voting power at the recipient with no source decrement (correct ERC20Votes semantics, non-obvious). Quorums computed against getPastTotalSupply see an inflated denominator (up to 10% pre-mint cap dormant + all undelegated retail).

Recommendation: Add _delegate(recipient, recipient) after each constructor _mint. Optional: auto-self-delegate in release() for first-time recipients.

[M-8] ERC20Votes uses default block.number clock on Base

Severity: Medium · Confluence: ethskills [GEN-3].

Description: OZ v5 Votes.clock() defaults to Time.blockNumber(). On Base (~2s blocks), a DAO computing block.number - 7200 expecting "1 day" actually gets ~4 hours. EIP-6372 explicitly recommends overriding clock() to block.timestamp on L2s.

Recommendation:

function clock() public view override returns (uint48) { return uint48(block.timestamp); }
function CLOCK_MODE() public pure override returns (string memory) { return "mode=timestamp"; }

Low-Severity Findings

#TitleSource
L-1vestingDuration_ == 0 allows instant 100% vesting bypassERC20-4, P2-L2, P5-L4, P6-L2
L-2Constructor accepts recipients_[i] == address(0) — tokens stuck in contractAC-5, P1-F7, P3-L5, P5-L4, P7-F2
L-3Constructor accepts recipients_[i] == address(this) — allocation unrecoverableP8-L4
L-4Unbounded recipients_ loop; duplicate-handling silent footgunGEN-10, AC/DOS, P5-L4
L-5mintInflation triple-product overflows at near-uint208 supply (~1.83e53 wei)PREC-5, P1-L1, P2-F2
L-6Allowance/Approval-event/transferFrom inconsistency for Permit2GEN-7, ERC20-9
L-7lockPool can target address(this) — breaks future inbound transfersERC20-5
L-8Compound-vs-simple inflation cap (~0.02%/yr extra at high call frequency)ERC20-6, PREC-9, P2-F5, P6-L3
L-9Soft proposal veto via lockPool(proposalRecipient)GOV-6
L-10Flash-loan governance attack surface (mitigated by Governor snapshot voting)GOV-4
L-11mintInflation year-rollover loop relies on undocumented invariantPREC-4
L-12No timelock on parameter changesAC-4
L-13MAX_PRE_MINT_PER_ADDRESS_WAD == MAX_TOTAL_PRE_MINT_WAD == 10% — per-address cap meaninglessP8-L1
L-14Adjacent address parameters recipient / owner_ — silent argument-swap riskP1-L5
L-15updateMintRate settles multi-year accrual at OLD rate (semantics worth confirming)P5-L5, P6-L9

Informational Findings

#TitleSource
I-1release() allows zero-amount transfer; declared ReleaseAmountInvalid is dead codeGEN-5, ERC20-11, PREC-7, P1-F8, P7-L6
I-2hasVestingStarted modifier is vacuous (vestingStart always > 0)GEN-6, GOV-8, P1-F9, P3-L2, P4-L3, P5-L2, P6-L5, P8-L2
I-3No events emitted on lockPool/unlockPool/updateMintRate/updateTokenURI/burnGEN-12, P1-L3, P7-F3
I-4Pragma ^0.8.24, PUSH0 default — fragile on non-Shanghai chainsGEN-11
I-5365-day fixed year (no leap-year handling)PREC-6
I-6ERC-1271 smart-contract wallets cannot use permit() or delegateBySig()SIG-2
I-7Checkpoint downcasting (uint208/uint48) safe at all realistic scalesGOV-7
I-8Standard weird-ERC20 footguns confirmed absent (no fee-on-transfer, rebasing, ERC777 hooks, etc.)ERC20-12
I-9_update lock is one-way (to ==); transfers OUT of locked pool allowed (likely intentional)ERC20-10
I-10mintInflation while-loop is bounded by elapsed years (no realistic DoS)DOS-2, P2-L1

Cross-Cutting Composite Risks

[C-1] Centralization-cluster compounding: H-1 + H-3 + M-6

The owner has, in combination: permanent kill-switch on the AMM ([H-1]); no-rollback via renounceOwnership ([H-3]); compounding voting share at 2%/yr forever ([M-6]); no timelock on any of these ([L-12]). A misbehaving / compromised / adversarial owner can drain economic value from holders without ever stealing tokens directly. The token will still pass a static audit ("no theft path") while having a robust extraction mechanism via slow dilution + AMM censorship.

[C-2] M-3 + L-2 vesting-stuck cascade

Default pool == 0 causes burns to revert ([M-3]). If a deployer sets recipients_[i] == address(0) ([L-2]), tokens are minted to address(this) and become permanently irretrievable — they cannot be released (caller can never be address(0)) AND cannot be burned (owner-only burn() calls _burn(owner(), x), not _burn(address(this), x)). No rescue function exists.

[C-3] Governance UX trap: M-7 + M-8 + I-2

Retail holders never self-delegate ([M-7]) AND any Governor uses block-number clocks miscalibrated for Base ([M-8]) AND the dead hasVestingStarted modifier ([I-2]) suggests vesting was meant to be deferred but isn't. A DAO using cyb3rwr3n governance will have a poorly-calibrated voter base from day one — a problem the token contract cannot fix retroactively.


Recommended Fixes (10-line path to a hardened deployment)

  1. Ownable2Step + override renounceOwnership to revert.
  2. One-shot lockPool (require(pool == address(0))); validate pool_ != address(this) && pool_ != address(0).
  3. Fix the _update guard: if (to != address(0) && to == pool && !isPoolUnlocked).
  4. mintInflationonlyOwner.
  5. updateMintRate — handle zero pending without revert.
  6. unlockPool — settle inflation first, or one-shot.
  7. Override clock() and CLOCK_MODE() for timestamp mode.
  8. Auto-delegate in constructor _mint calls.
  9. Constructor input validation for recipients_[i] and vestingDuration_.
  10. Add events on every state-changing admin function.

Full per-agent findings (all 14 specialist Opus subagents) and the bundled markdown source are available alongside this document at the IPFS URL. See AUDIT-REPORT.md for the same content in plain markdown.