FoundrySolidityLayer 2
7.25 ETH
View results
Submission Details
Impact: low
Likelihood: low
Invalid

QA & Low Severity Report: ConfidencePool

# QA & Low Severity Report: ConfidencePool
​
**Auditor's Note:** [L-01] was removed during review. The original claim that `_clampUserSums()` is a guaranteed dead no-op inside `withdraw()` was incorrect. The `ATTACK_REQUESTED` state exception proves user sums can legitimately need clamping at that point.
​
---
​
## Summary of Findings
​
| ID | Title | Severity | Category |
|---|---|---|---|
| [L-02] | `MockConfidencePoolModerator` is located in `src/` instead of `test/`, creating deployment risks | Low | Build / Deployment |
| [L-03] | Re-used `InvalidAmount` error for `hasClaimed` checks is misleading | Low | UX / Error Clarity |
| [L-04] | Factory `createPool()` lacks `minStake > 0` validation, relying on child clone to revert | Low | Input Validation |
| [L-05] | `PoolStates.isTerminal()` is dead library code never called in production | Low | Dead Code |
| [L-06] | Duplicate `_MIN_EXPIRY_LEAD` constants between Factory and Pool create an upgrade divergence hazard | Low | Maintainability |
| [L-07] | `ScopeUpdated` event emits an unindexed dynamic array, breaking off-chain filtering | Low | Indexing / Events |
| [L-08] | `setRecoveryAddress()` has no resolution-state guard, allowing sponsor to redirect CORRUPTED sweep post-outcome | Low | Access Control |
| [G-01] | `stake()` recomputes `received * newEntry` twice instead of caching the intermediate | Gas | Optimization |
| [G-02] | `sweepUnclaimedBonus()` performs two SLOADs of `riskWindowStart` | Gas | Optimization |
| [G-03] | `_clampUserSums()` recomputes `stake_ * start` three times without caching | Gas | Optimization |
​
---
​
## Low Severity Findings
​
---
​
### [L-02] `MockConfidencePoolModerator` is located in `src/` instead of `test/`, creating deployment risks
​
**File:** `src/mocks/MockConfidencePoolModerator.sol`
​
**Description:**
The `MockConfidencePoolModerator.sol` contract carries an explicit NatSpec warning on line 8:
```solidity
/// @dev Do NOT deploy to mainnet. Agreement-state checks still run on the pool itself...
```
Despite this warning, the file lives in `src/mocks/` — not `test/mocks/`. The project's `foundry.toml` sets `src = "src"` as the default build source. This means the mock is compiled into the production artifact set by default.
​
**Cross-Examination:** The `test/mocks/` directory contains six purpose-built test mocks (`MockAttackRegistry`, `MockERC20`, etc.), confirming a clear, established pattern: test helpers belong in `test/`, not `src/`. This mock violates that convention without any documented justification.
​
**Impact:**
An automated deployment script iterating over the `src/` directory, or a developer running `forge build` before a deploy, will silently include this contract. If mistakenly set as the `outcomeModerator` on any pool, any EOA can call `flagSurvived`, `flagCorruptedGoodFaith`, or `flagCorruptedBadFaith` to arbitrarily resolve any pool wired to it, regardless of the actual registry state. The consequence is total theft or misdirection of all staked assets in those pools.
​
**Recommendation:**
Move `src/mocks/MockConfidencePoolModerator.sol` to `test/mocks/MockConfidencePoolModerator.sol`. Update any `import` paths in test files accordingly.
​
---
​
### [L-03] Re-used `InvalidAmount` error for `hasClaimed` checks is misleading
​
**Files:** `src/ConfidencePool.sol` (lines 383, 578), `src/interfaces/IConfidencePool.sol` (line 42)
​
**Description:**
Both `claimSurvived()` and `claimExpired()` use `InvalidAmount()` to signal that a user has already claimed:
​
```solidity
// claimSurvived() – line 383
if (hasClaimed[msg.sender]) revert InvalidAmount();
​
// claimExpired() – line 578
if (hasClaimed[msg.sender]) revert InvalidAmount();
```
​
`InvalidAmount` is also used to guard `stake(amount == 0)` (line 223), `contributeBonus(amount == 0)` (line 267), and `minStake_ == 0` at initialization (line 198). It is semantically a "bad numeric input" error.
​
**Cross-Examination:** There is no `AlreadyClaimed` or equivalent error defined anywhere in the codebase. The interface defines 24 custom errors and none covers duplicate claims. This is a genuine omission, not a documented design choice.
​
**Impact:**
Off-chain integrators, frontends, and monitoring tools that decode revert data will receive `InvalidAmount` on a double-claim attempt and have no way to distinguish it from a genuine zero-amount input error without additional context. This leads to incorrect error messages and harder debugging.
​
**Recommendation:**
Define a dedicated `error AlreadyClaimed();` in `IConfidencePool.sol` and replace both `hasClaimed` guards in `claimSurvived()` and `claimExpired()` with `revert AlreadyClaimed()`.
​
---
​
### [L-04] Factory `createPool()` lacks `minStake > 0` validation, causing wasted gas on revert
​
**File:** `src/ConfidencePoolFactory.sol` (lines 67–113)
​
**Description:**
`createPool()` validates `agreement`, `stakeToken`, `recoveryAddress`, `expiry`, and registry state — but does **not** validate `minStake > 0`. The call proceeds to deploy a deterministic clone via `Clones.cloneDeterministic()` and then call `initialize()`. Inside `initialize()`, line 198 catches the invalid input:
​
```solidity
if (minStake_ == 0) revert InvalidAmount(); // ConfidencePool.sol:198
```
​
**Cross-Examination:** Every other core parameter that the factory delegates to the clone — `agreement`, `stakeToken`, `recoveryAddress` — is validated at the factory level first. `minStake = 0` is the only input that gets a free pass past the factory gate, causing the clone to be deployed (consuming `Clones.cloneDeterministic` execution cost) before reverting.
​
**Impact:**
A caller passing `minStake = 0` pays the gas cost of a `CREATE2` clone deployment before the transaction reverts. While not a security issue, it violates the fail-fast principle and wastes user funds.
​
**Recommendation:**
Add early validation in `createPool()`:
```solidity
if (minStake == 0) revert InvalidAmount(); // or define a new error
```
Place it alongside the existing zero-address checks (line 75–76).
​
---
​
### [L-05] `PoolStates.isTerminal()` is dead library code that increases bytecode size
​
**File:** `src/libraries/PoolStates.sol` (lines 13–16)
​
**Description:**
The `PoolStates` library exports a function `isTerminal(Outcome outcome)`:
​
```solidity
function isTerminal(Outcome outcome) internal pure returns (bool) {
return outcome != Outcome.UNRESOLVED;
}
```
​
A project-wide search of `ConfidencePool.sol`, `ConfidencePoolFactory.sol`, and all test files confirms this function is never called. `ConfidencePool.sol` uses its own private helper `_isTerminalState(IAttackRegistry.ContractState s)` (line 837) which operates on the *registry's* `ContractState` enum — a different type. The library function operates on the pool's *internal* `Outcome` enum and is never invoked anywhere.
​
**Cross-Examination:** Because `isTerminal` is `internal`, the Solidity compiler will only include it if it is referenced. With `via_ir = true` and `optimizer = true` in `foundry.toml`, it is likely dead-stripped. However, the dead code still adds **cognitive overhead**: auditors must verify it is truly unreachable, and developers may be confused about whether they should use it. The dead code misleads about intended invariants.
​
**Impact:**
Unused library code that adds maintenance overhead and potential future confusion. If optimizer settings change, bytecode size impact.
​
**Recommendation:**
Remove the `isTerminal(Outcome outcome)` function from `PoolStates.sol`. If future code needs to check for a resolved outcome, the check `outcome != Outcome.UNRESOLVED` is clear enough inline.
​
---
​
### [L-06] Duplicate `_MIN_EXPIRY_LEAD` constants create an upgrade divergence hazard
​
**Files:** `src/ConfidencePoolFactory.sol` (line 42), `src/ConfidencePool.sol` (line 44)
​
**Description:**
Both contracts independently define the identical private constant:
​
```solidity
// ConfidencePoolFactory.sol:42
uint256 private constant _MIN_EXPIRY_LEAD = 30 days;
​
// ConfidencePool.sol:44
uint256 private constant _MIN_EXPIRY_LEAD = 30 days;
```
​
Both use this constant to gate `expiry` validity. The factory checks it in `createPool()` (line 78), and the pool checks it again in `initialize()` (line 196) and `setExpiry()` (line 624).
​
**Cross-Examination:** `ConfidencePoolFactory` is deployed as a UUPS upgradeable proxy (`UUPSUpgradeable`). If a future upgrade of the factory changes its local `_MIN_EXPIRY_LEAD`, the pool's hardcoded value remains at `30 days`. A new pool could then be created satisfying the factory's updated requirement but failing the pool's initialization, permanently breaking pool creation for any expiry in the gap between the two values.
​
**Impact:**
An upgrade to `ConfidencePoolFactory` that modifies its expiry lead requirement will silently diverge from the pool implementation's enforcement. This creates a maintenance hazard where factory operators may be unaware that the pool still enforces the old constant.
​
**Recommendation:**
Expose `_MIN_EXPIRY_LEAD` as a public constant on `ConfidencePool` so the factory can read it dynamically:
​
```solidity
// In ConfidencePool.sol
uint256 public constant MIN_EXPIRY_LEAD = 30 days;
```
​
Then in `ConfidencePoolFactory.sol`, remove the duplicate constant and read from the implementation:
```solidity
if (expiry < block.timestamp + IConfidencePool(poolImplementation).MIN_EXPIRY_LEAD()) revert ExpiryTooSoon();
```
​
Alternatively, move the constant to a shared library that both contracts import.
​
---
​
### [L-07] `ScopeUpdated` event emits an unindexed dynamic array, breaking off-chain filtering
​
**File:** `src/interfaces/IConfidencePool.sol` (line 25)
​
**Description:**
The `ScopeUpdated` event is defined as:
​
```solidity
/// @dev Emitted whenever the pool owner replaces the scope before lock. Carries the full
/// BattleChain account list so indexers can reconstruct scope without follow-up view calls.
event ScopeUpdated(address[] accounts);
```
​
Dynamic arrays (`address[]`) **cannot** be `indexed` in Solidity events — the EVM encodes them as a `keccak256` hash of their ABI encoding in the topic slot, destroying the original data for topic-based filtering. The NatSpec explicitly states the purpose is to let indexers reconstruct scope, but the current design means a third party trying to find "all pools that include contract address X in their scope" must download and decode the `accounts` payload of every `ScopeUpdated` event ever emitted, globally across all pools.
​
**Cross-Examination:** The NatSpec comment confirms this event is the authoritative data source for scope reconstruction. The limitation is real and undocumented: there is no `indexed` field, and no alternative "per-account" event is emitted.
​
**Impact:**
Off-chain indexers (subgraphs, explorers, monitoring bots) cannot efficiently subscribe to scope changes for a specific account. They must parse all `ScopeUpdated` events and filter in-application logic, significantly increasing indexing cost.
​
**Recommendation:**
Emit individual per-account events in addition to or instead of the array-based event:
```solidity
event ScopeAccountAdded(address indexed account);
event ScopeAccountRemoved(address indexed account);
```
This allows O(1) topic-based filtering while the bulk `ScopeUpdated` event can remain for full-scope snapshots. At minimum, document the filtering limitation in the NatSpec.
​
---
​
### [L-08] `setRecoveryAddress()` has no resolution-state guard, allowing sponsor to redirect CORRUPTED funds post-outcome
​
**File:** `src/ConfidencePool.sol` (lines 609–618)
​
**Description:**
The `setRecoveryAddress()` function has no check on whether the pool has already been resolved:
​
```solidity
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
```
​
This means the pool owner (the sponsor) can change `recoveryAddress` **after `flagOutcome` has set the outcome to `CORRUPTED`**, and **before** the sweep functions are called.
​
The following sweep/claim functions all send tokens to the current value of `recoveryAddress` at call time:
- `claimCorrupted()` — line 423: `stakeToken.safeTransfer(recoveryAddress, toSweep);`
- `sweepUnclaimedCorrupted()` — line 468: `stakeToken.safeTransfer(recoveryAddress, amount);`
- `sweepUnclaimedBonus()` — line 506: `stakeToken.safeTransfer(recoveryAddress, amount);`
​
**Attack scenario:** The sponsor's protocol is hacked and the moderator flags the outcome as bad-faith `CORRUPTED`. Before anyone calls `claimCorrupted()`, the sponsor calls `setRecoveryAddress()` to point to a fresh address they control, then calls `claimCorrupted()` themselves, sweeping the entire pool (stakers' principal + bonus) to their new address instead of the original recovery destination stakers verified before depositing.
​
**Documentation Kill-Switch Check (DESIGN.md §10):**
> `recoveryAddress` — CORRUPTED sweep destination. Receives the full pool (including stakers' principal) under bad-faith CORRUPTED.
> All staker-relevant state is on-chain; stakers should verify pool parameters before depositing.
​
DESIGN.md acknowledges `recoveryAddress` as a sponsor-controlled parameter and notes it is on-chain for stakers to verify. However, it does **not** explicitly state that `recoveryAddress` is mutable post-resolution, nor does it document this as an accepted trust assumption. The `expiry` mutability has an explicit lock (`expiryLocked`) and a documented rationale (§10). `recoveryAddress` has neither.
​
**Impact:**
A malicious or compromised sponsor can redirect all stakers' principal (100% of pool value) from the expected recovery destination to any arbitrary address in the window between `flagOutcome` and the first sweep call. This is a post-resolution fund-redirection vector that stakers cannot protect against by verifying state at deposit time, because the value changes after their commitment is locked.
​
**Severity Justification:** Low (not Medium) because: (1) it requires the pool owner to be actively malicious; (2) DESIGN.md §10 frames the sponsor's `recoveryAddress` control as a known trust surface; (3) in the CORRUPTED path, the sponsor's protocol is already compromised, making the marginal additional attack somewhat bounded.
​
**Recommendation:**
Add a post-resolution guard:
```solidity
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
// ...
}
```
​
Alternatively, explicitly document in DESIGN.md §10 that `recoveryAddress` remains mutable post-resolution as a documented trust assumption — this would provide full transparency to stakers and auditors.
​
---
​
## Gas Optimizations
​
---
​
### [G-01] `stake()` recomputes `received * newEntry` twice without caching
​
**File:** `src/ConfidencePool.sol` (lines 252–253)
​
**Description:**
```solidity
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry; // recomputes `received * newEntry`
```
​
The product `received * newEntry` is computed once to produce `contribTime`, then computed **again** in the next line as part of the `contribTimeSq` expression.
​
**Recommendation:**
```solidity
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = contribTime * newEntry; // reuses already-computed product
```
Saves one `MUL` opcode per `stake()` call.
​
---
​
### [G-02] `sweepUnclaimedBonus()` performs two SLOADs of `riskWindowStart`
​
**File:** `src/ConfidencePool.sol` (lines 485, 499)
​
**Description:**
```solidity
if (riskWindowStart != 0) { // SLOAD 1
reserved += snapshotTotalBonus - claimedBonus;
}
// ...
if (totalEligibleStake == 0 || riskWindowStart == 0) { // SLOAD 2
totalBonus -= ...
}
```
`riskWindowStart` is a `uint32` storage slot read twice in the same function with no mutation between reads.
​
**Recommendation:**
Cache it at the top of the function:
```solidity
uint256 start = riskWindowStart; // single SLOAD
// ...
if (start != 0) { ... }
// ...
if (totalEligibleStake == 0 || start == 0) { ... }
```
Saves ~2,100 gas on the warm SLOAD (post-EIP-2929).
​
---
​
### [G-03] `_clampUserSums()` recomputes `stake_ * start` three times without caching
​
**File:** `src/ConfidencePool.sol` (lines 681–683)
​
**Description:**
```solidity
if (userSumStakeTime[u] < stake_ * start) { // MUL #1
userSumStakeTime[u] = stake_ * start; // MUL #2
userSumStakeTimeSq[u] = stake_ * start * start; // MUL #3 + MUL #4
}
```
​
The product `stake_ * start` is evaluated three times in a single branch. This function is called on every `stake()`, `withdraw()`, and claim, making this a high-frequency inefficiency.
​
**Recommendation:**
```solidity
uint256 targetSum = stake_ * start; // MUL #1 — computed once
if (userSumStakeTime[u] < targetSum) {
userSumStakeTime[u] = targetSum;
userSumStakeTimeSq[u] = targetSum * start; // MUL #2 — one reuse
}
```
Saves two `MUL` opcodes per invocation of the clamp branch.
​
---
​
## Audit Conclusion
​
After conducting a comprehensive security audit of the ConfidencePool smart contract system — including critical analysis of the k=2 bonus formula mathematics, deep investigation of state machine edge cases and race conditions, examination of external integration issues, and cross-function state consistency verification — **we found ZERO high-severity or medium-severity vulnerabilities.**
​
All potential security concerns were determined to be either documented trust assumptions (e.g., `recoveryAddress` sponsor control per DESIGN.md §10), documented design decisions (e.g., the "no observable risk" bonus handling per §5), or fictional state machine paths that do not exist in the code.
​
The authors have done an **exceptional job** designing this contract. The codebase demonstrates mathematical soundness in the k=2 bonus distribution, robust state machine design with thoroughly documented intentional behaviors, comprehensive input validation, and clear trust assumptions. The low-severity findings identified in this report represent minor code quality, event indexing, and gas optimization opportunities that do not pose security risks to protocol funds under the defined trust model.
​
Updates

Lead Judging Commences

inallhonesty Lead Judge
3 months ago
inallhonesty Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

QA / Informational / Gas

inallhonesty Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

QA / Informational / Gas

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!