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
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 | Count | Themes |
|---|---|---|
| Critical | 0 | — |
| High | 4 | Centralization/freeze cluster (lockPool re-lock, lockPool arbitrary blacklist, Ownable 1-step transfer, renounce-after-lockPool permanent DoS) |
| Medium | 8 | Permissionless 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 |
| Low | 12 | Constructor 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. |
| Info | 10 | Documentation, leap-year, ERC-1271 SCW UX, etc. |
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.
lockPool is repeatable post-unlock — owner can permanently freeze pool / blacklist any addressSeverity: 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:
PoolLocked() until the owner unlocks again. Sells/adds bricked.lockPool(victim) makes every transfer(_, victim, _) revert. The "pool" semantic is misleading — the gate is just to == storedAddress.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:
unlockPool() called; trading enabled.lockPool(uniswapV4PoolAddress) → isPoolUnlocked = false.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).
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.
renounceOwnership permanently bricks pool gating, inflation, burnSeverity: High · Confluence: ethskills [AC-2] [DOS-5] + pashov [P1-F4] [P3-L6] [P8-L6].
Description: Once renounced (owner = address(0)):
lockPool/unlockPool/updateMintRate/updateTokenURI/burn are unreachable.lockPool(activePool), all transfers to the pool revert forever — no one can call unlockPool. Realizes [H-1] in permanent form.mintInflation() is still callable by anyone but reaches _mint(address(0), x) → reverts ERC20InvalidReceiver → inflation accrual permanently dead.Recommendation: Override renounceOwnership to revert. Document operator runbook: never renounce while pool is locked.
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.
mintInflation is permissionless — precision-loss griefing and snapshot manipulationSeverity: 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:185 — function mintInflation() public { ... }
Description:
lastMintTimestamp = block.timestamp. Because integer division floors, floor(Σ x_i) ≥ Σ floor(x_i) — calling every Base block (~2s) cumulatively reduces the owner's total annual mint._totalSupplyCheckpoints immediately before a Governor's snapshot, raising the quorum bar.getPastVotes lookups pay log2(N) per query; unbounded checkpoint accumulation possible.Recommendation: Restrict to onlyOwner. updateMintRate continues to call it internally.
updateMintRate reverts when accrued inflation rounds to zeroSeverity: 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;
_update pool-lock check collides with burn destinationSeverity: 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:
burn() reverts with misleading PoolLocked() until the owner first calls lockPool(non-zero) or unlockPool().address(0) (a common "burn-by-send" UX) reverts.lockPool(address(0)), the trap reactivates.Recommendation: if (to != address(0) && to == pool && !isPoolUnlocked) revert PoolLocked();
unlockPool resets inflation clock — silently destroys accrued mintableSeverity: 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.
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.
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.
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.
block.number clock on BaseSeverity: 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"; }
| # | Title | Source |
|---|---|---|
| L-1 | vestingDuration_ == 0 allows instant 100% vesting bypass | ERC20-4, P2-L2, P5-L4, P6-L2 |
| L-2 | Constructor accepts recipients_[i] == address(0) — tokens stuck in contract | AC-5, P1-F7, P3-L5, P5-L4, P7-F2 |
| L-3 | Constructor accepts recipients_[i] == address(this) — allocation unrecoverable | P8-L4 |
| L-4 | Unbounded recipients_ loop; duplicate-handling silent footgun | GEN-10, AC/DOS, P5-L4 |
| L-5 | mintInflation triple-product overflows at near-uint208 supply (~1.83e53 wei) | PREC-5, P1-L1, P2-F2 |
| L-6 | Allowance/Approval-event/transferFrom inconsistency for Permit2 | GEN-7, ERC20-9 |
| L-7 | lockPool can target address(this) — breaks future inbound transfers | ERC20-5 |
| L-8 | Compound-vs-simple inflation cap (~0.02%/yr extra at high call frequency) | ERC20-6, PREC-9, P2-F5, P6-L3 |
| L-9 | Soft proposal veto via lockPool(proposalRecipient) | GOV-6 |
| L-10 | Flash-loan governance attack surface (mitigated by Governor snapshot voting) | GOV-4 |
| L-11 | mintInflation year-rollover loop relies on undocumented invariant | PREC-4 |
| L-12 | No timelock on parameter changes | AC-4 |
| L-13 | MAX_PRE_MINT_PER_ADDRESS_WAD == MAX_TOTAL_PRE_MINT_WAD == 10% — per-address cap meaningless | P8-L1 |
| L-14 | Adjacent address parameters recipient / owner_ — silent argument-swap risk | P1-L5 |
| L-15 | updateMintRate settles multi-year accrual at OLD rate (semantics worth confirming) | P5-L5, P6-L9 |
| # | Title | Source |
|---|---|---|
| I-1 | release() allows zero-amount transfer; declared ReleaseAmountInvalid is dead code | GEN-5, ERC20-11, PREC-7, P1-F8, P7-L6 |
| I-2 | hasVestingStarted modifier is vacuous (vestingStart always > 0) | GEN-6, GOV-8, P1-F9, P3-L2, P4-L3, P5-L2, P6-L5, P8-L2 |
| I-3 | No events emitted on lockPool/unlockPool/updateMintRate/updateTokenURI/burn | GEN-12, P1-L3, P7-F3 |
| I-4 | Pragma ^0.8.24, PUSH0 default — fragile on non-Shanghai chains | GEN-11 |
| I-5 | 365-day fixed year (no leap-year handling) | PREC-6 |
| I-6 | ERC-1271 smart-contract wallets cannot use permit() or delegateBySig() | SIG-2 |
| I-7 | Checkpoint downcasting (uint208/uint48) safe at all realistic scales | GOV-7 |
| I-8 | Standard 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-10 | mintInflation while-loop is bounded by elapsed years (no realistic DoS) | DOS-2, P2-L1 |
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.
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.
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.
Ownable2Step + override renounceOwnership to revert.lockPool (require(pool == address(0))); validate pool_ != address(this) && pool_ != address(0)._update guard: if (to != address(0) && to == pool && !isPoolUnlocked).mintInflation → onlyOwner.updateMintRate — handle zero pending without revert.unlockPool — settle inflation first, or one-shot.clock() and CLOCK_MODE() for timestamp mode._mint calls.recipients_[i] and vestingDuration_.