Security Audit
Internal audit by Opus (Claude Opus 4.6)
Scope: All contracts in src/core/, src/governance/, src/identity/, src/oracle/, src/libraries/ · Solidity 0.8.28 · OpenZeppelin v5.x
Trust Model
Non-Upgradeable Contracts
All contracts are immutable by design. Bugs cannot be patched post-deployment, but the system cannot be rugged by a malicious admin upgrade.
3-Oracle Median Consensus
Basket and ETH prices require 3+ oracles to agree via median consensus. A single compromised oracle cannot manipulate pricing.
Biometric Sybil Defense
Citizenship is gated by biometric verification (off-chain ZK proof, on-chain hash attestation). Each biometric identity maps to exactly one citizen.
Ratchet Principle
Floors can only increase, never decrease. Once a UBC minimum is set, it cannot be lowered even by governance. ConstitutionalGuard enforces this on-chain.
2-Day Execution Timelock
All governance proposals require a 2-day delay between passing and execution, giving citizens time to react to malicious proposals.
7-Day Minimum Voting Period
Governor proposals run for a minimum of 7 days, preventing flash loan governance attacks and ensuring broad participation.
Findings by Severity
High Severity
HIGH4 findingsUnverified vote weights
Governor.sol
castVote(proposalId, support, stakeAmount) accepts stakeAmount from the caller without verifying it against any staking/conviction contract. Any address can pass arbitrary vote weight.
Fix: Now verifies on-chain via QuadraticVoting/ConvictionWeighting lookup.
Single-oracle ETH price manipulation
ComputeBasketOracle.sol
submitETHPrice() allowed a single oracle to set the ETH price used by OneWayBridge.depositETH(), unlike basket prices which require 3-oracle median consensus. A compromised oracle could set ETH price to $1M, making ETH deposits mint disproportionate UCU.
Fix: ETH price now uses the same multi-oracle median consensus as basket prices.
Missing reentrancy guard
Bonfire.sol
finalizeEpoch() makes an external call via _burnUCU() (low-level .call) and reportRevenue() calls safeTransferFrom(). Neither had ReentrancyGuard protection. Cross-function reentrancy could manipulate epoch accounting.
Fix: Added ReentrancyGuard inheritance and nonReentrant to reportRevenue() and finalizeEpoch().
Low-level call to non-contract succeeds silently
Bonfire.sol
_burnUCU() used address(ucu).call(...) which succeeds silently when the target is an EOA (not a contract). If ucu were set to a non-contract address, burns would appear successful without actually burning tokens, inflating supply.
Fix: Added extcodesize check and return data validation.
Medium Severity
MEDIUM9 findingsNo proposal threshold
Governor.sol
Anyone can create proposals with no minimum stake or citizen requirement. This enables spam proposals that waste gas and create governance noise.
Mitigation: Add a minimum stake requirement or restrict to verified citizens.
Unbounded paramIds array (DoS vector)
ConstitutionalGuard.sol
validateProposal() iterates all paramIds for each target in a proposal (O(targets * paramIds)). As protections accumulate, gas costs increase, eventually making execution impossible.
Mitigation: Cap paramIds.length or use a mapping-based lookup by (target, selector).
Permissionless enrollment
UBCDistributor.sol
enrollCitizen() can be called by anyone for any verified citizen. A griefer could enroll citizens who don't want UBC, consuming capacity slots or adding them to the waitlist without consent.
Mitigation: Restrict to msg.sender == citizen or add an opt-in mechanism.
Stake lock only tracks last proposal
QuadraticVoting.sol
lockedForProposal stores only the single most recent proposal the user voted on. If a citizen votes on proposal A (ends day 30) then votes on proposal B (ends day 10), after day 10 they can unstake even though proposal A is still active.
Mitigation: Track all active locks or use the latest-ending proposal.
Distribute loop with external calls
SupportClasses.sol
distribute() makes safeTransfer calls inside a loop over recipients. If any recipient is a contract that reverts on receive, the entire distribution fails (one bad recipient blocks all).
Mitigation: Use a pull-based pattern or try/catch around individual transfers.
Oracle staleness affects deposit pricing
OneWayBridge.sol
If oracle reports expire (> MAX_PRICE_AGE), getBasketPriceUSD() reverts, completely blocking deposits rather than gracefully degrading. Coordinated oracle downtime could freeze the bridge.
Mitigation: Consider a cached fallback price with an age warning.
Votes not recorded on-chain for tally
ConvictionWeighting.sol
voteWithConviction() emits events but does not update any on-chain vote tally. The effective votes are only in events, making the contract informational only.
Mitigation: Wire ConvictionWeighting to Governor.castVote or implement its own tally.
netSupplyChange() overflow risk
UCUToken.sol
int256(totalMinted) - int256(totalBurned) will revert if totalMinted exceeds type(int256).max (~5.7e76). Practically unreachable but theoretically possible for an uncapped supply token.
Mitigation: Add a safe cast or document the theoretical limit.
sqrt overflow for large inputs
FixedPointMath.sol
x * WAD in sqrt() overflows for x > type(uint256).max / 1e18 (~1.15e59). While token amounts shouldn't reach this, the function is a public library.
Fix: Added explicit overflow check with require.
Low Severity
LOW7 findingsBirth rate period reset drift
CitizenRegistry.sol
_enforceBirthRate() sets currentPeriodStart = block.timestamp instead of incrementing by agentBirthPeriod. Over time, periods drift forward if called late.
Mitigation: Use currentPeriodStart += agentBirthPeriod to prevent drift.
Waitlist array grows forever
UBCDistributor.sol
The waitlist array only grows via push() and is never compacted. waitlistHead advances but old entries remain. Over years, this wastes storage.
Mitigation: Periodically compact or use a linked-list pattern.
Largest recipient tracking is imprecise
SovereigntyIndex.sol
If a citizen transfers to A (100 UCU) then to B (150 UCU), largestRecipient updates to B. But if A later receives another 200 UCU (total 300), the contract misses that A surpassed B.
Mitigation: Maintain a proper max-heap or accept the approximation.
Daily rate limit uses block.timestamp / 1 days
OneWayBridge.sol
Miners can slightly manipulate timestamps. A deposit near midnight could be placed into a favorable day window. Impact is minimal (max ~15 seconds of manipulation).
Mitigation: Acceptable for current design.
No upper bound on proposal targets
Governor.sol
A proposal can have hundreds of targets/calldatas, consuming excessive gas on execution.
Mitigation: Add a MAX_ACTIONS_PER_PROPOSAL constant.
Grant proposals never expire
CreatorGrantOracle.sol
Once submitted, a ProposalData lives forever. Stale proposals from years ago can still be verified and approved by oracles.
Mitigation: Add an expiry timestamp to proposals.
Supporter refund on expiry triggered by anyone
SupportClasses.sol
When distribute() is called on an expired class, the refund goes to sc.supporter. A third party could front-run the supporter's own call. Not harmful but creates unexpected behavior.
Mitigation: Document this as expected behavior.
Pre-Mainnet Recommendations
- Wire Governor to QuadraticVoting/ConvictionWeighting for on-chain vote weight verification
- Add flash loan protection with minimum stake duration before voting power accrues
- Restrict proposal creation to citizens with minimum stake
- Add MAX_ACTIONS_PER_PROPOSAL to prevent gas DoS on execution
- Implement pull-based distribution for SupportClasses
- Deploy with multisig/DAO as admin for all admin-controlled contracts
- Formal verification of FixedPointMath library (ln/exp/log10)
- Professional external audit before handling real value