FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: high

[M] Factory Accepts `minStake=1e18` Against 6-Decimal ERC20, Permanently Bricking Staking

Author Revealed upon completion

Description


Sponsors create confidence pools via `ConfidencePoolFactory.createPool`, passing `minStake` as a raw token amount that the pool enforces on every `stake()` call. README scopes standard ERC20 stake tokens, which includes both 18-decimal tokens (WETH/DAI-style) and 6-decimal stablecoins (USDC-style). The in-repo fork integration test uses `minStake = 1e18`, which correctly means “one full token” for 18-decimal mocks.

The protocol never reads `stakeToken.decimals()` at pool creation or stake time. When a sponsor copies the fork template `minStake = 1e18` for a 6-decimal USDC pool, the human minimum becomes `1e18 / 1e6 = 1e12` USDC (one trillion USDC). The factory accepts the configuration, but every staker including a wallet holding 1,000,000 USDC reverts `BelowMinStake`. The pool clone exists on-chain with `totalEligibleStake == 0` forever; there is no post-init migration path to fix `minStake`.

// ConfidencePoolFactory.createPool forwards minStake unchanged, no decimals() check
IConfidencePool(pool).initialize(
agreement, stakeToken, ..., minStake, recoveryAddress, msg.sender, accounts
);
// ConfidencePool.stake — raw comparison only
// @> amount compared to minStake without scaling for token decimals
if (amount < minStake) revert BelowMinStake();
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
// ...
// @> received also gated by same raw minStake
if (received < minStake) revert BelowMinStake();
```
// @> valid for 18-dec MockERC20; fatal when copied for 6-dec USDC
factory.createPool(DEMO_AGREEMENT, address(stakeToken), expiry, 1e18, recovery, accounts);
```
| Field | Value |
|-------|--------|
| `minStake` (raw) | `1e18` |
| Token decimals | `6` |
| Minimum human USDC | `1e12` USDC |
| PoC whale attempt | **1,000,000 USDC** (`1e12` raw) |
| Whale % of minimum | **0.0001%** → `BelowMinStake` |

Risk

Likelihood:

- Sponsors deploy USDC (6-dec) pools using the in-repo fork example value `1e18` — the only `minStake` constant shown in `BattleChainFactoryIntegration.fork.t.sol`.

- README advertises “ERC20 (standard only)” without documenting that `minStake` is raw units with no decimal validation.

- The opposite misconfiguration (`minStake = 1e6` on an 18-dec token) sets a dust floor, same missing guard, opposite direction.

Impact:

- Permanent staking liveness DoS for the affected pool clone: `createPool` succeeds but no account can become the first staker.

- Third-party stakers who discover the pool via `PoolCreated` or a UI lose gas on every failed `stake()` attempt.

- The confidence mechanism for that clone is non-functional; the product promise of standard ERC20 staking breaks for the most common L2 stake token class.

Proof of Concept

```bash
forge build
forge test --match-test test_PoC_factory6DecUSDC_forkTemplateMinStake_bricks1MWhale -vvvv
```
| Path | Role |
|------|------|
| `test/audit/PoC_MDecimals_MinStakeBrick.t.sol` | Standalone PoC (factory path) |
| `test/mocks/MockDecimalERC20.sol` | 6-dec USDC mock |
| `test/fork/BattleChainFactoryIntegration.fork.t.sol` | In-repo source of `minStake=1e18` template |
---
### Reviewer copy-paste setup (clean official contest repo)
All paths relative to **`repo/`**.
**Step 1 — Prerequisites**
```bash
cd repo
git submodule update --init --recursive
forge build
```
**Step 2 — Create the mock file (`MockDecimalERC20.sol`, lines 122)**
Create **`test/mocks/MockDecimalERC20.sol`** and paste the **entire file**:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/// @notice ERC20 with configurable decimals (simulates USDC 6 vs WETH-style 18 on the same chain).
contract MockDecimalERC20 is ERC20 {
uint8 private immutable _decimals;
constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) {
_decimals = decimals_;
}
function decimals() public view override returns (uint8) {
return _decimals;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
```
**Step 3 — Create the PoC test file**
Create **`test/audit/PoC_MDecimals_MinStakeBrick.t.sol`** and paste:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockDecimalERC20} from "test/mocks/MockDecimalERC20.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PoC_MDecimals_MinStakeBrickTest is BaseConfidencePoolTest {
uint256 internal constant FORK_TEMPLATE_MIN_STAKE = 1e18;
uint256 internal constant USDC_ONE = 1e6;
uint256 internal constant WHALE_USDC = 1_000_000 * USDC_ONE; // 1M USDC
MockDecimalERC20 internal usdc6;
MockAgreement internal sponsorAgreement;
ConfidencePoolFactory internal factory;
address internal agreementOwner = makeAddr("agreementOwner");
function setUp() public override {
super.setUp();
usdc6 = new MockDecimalERC20("USD Coin", "USDC", 6);
sponsorAgreement = new MockAgreement(agreementOwner);
sponsorAgreement.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAgreementValid(address(sponsorAgreement), true);
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(new ConfidencePool()), moderator)
)
)
)
);
factory.setStakeTokenAllowed(address(usdc6), true);
}
function _scope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = DEFAULT_SCOPE_ACCOUNT;
}
/// @dev Factory path: createPool succeeds with fork-template minStake on 6-dec USDC,
/// but stake(1M USDC) reverts BelowMinStake — pool permanently unpopulated.
function test_PoC_factory6DecUSDC_forkTemplateMinStake_bricks1MWhale() external {
vm.prank(agreementOwner);
ConfidencePool pool = ConfidencePool(
factory.createPool(
address(sponsorAgreement),
address(usdc6),
block.timestamp + 31 days,
FORK_TEMPLATE_MIN_STAKE,
recovery,
_scope()
)
);
assertEq(pool.minStake(), FORK_TEMPLATE_MIN_STAKE);
assertEq(usdc6.decimals(), 6);
usdc6.mint(alice, WHALE_USDC);
vm.startPrank(alice);
usdc6.approve(address(pool), WHALE_USDC);
vm.expectRevert(IConfidencePool.BelowMinStake.selector);
pool.stake(WHALE_USDC);
vm.stopPrank();
assertEq(pool.totalEligibleStake(), 0);
assertEq(usdc6.balanceOf(address(pool)), 0);
}
}
```
**Step 4 — Run**
```bash
forge test --match-test test_PoC_factory6DecUSDC_forkTemplateMinStake_bricks1MWhale -vvvv
```
**Expected result:** `1 passed` — factory `createPool` succeeds with 6-dec token + `minStake=1e18`; 1M USDC `stake` reverts `BelowMinStake`; `totalEligibleStake == 0`.
---
### Full test output and trace
```
[PASS] test_PoC_factory6DecUSDC_forkTemplateMinStake_bricks1MWhale() (gas: 532350)
Traces:
[532350] PoC_MDecimals_MinStakeBrickTest::test_PoC_factory6DecUSDC_forkTemplateMinStake_bricks1MWhale()
├─ [402056] ERC1967Proxy::fallback(..., minStake: 1000000000000000000 [1e18], ...)
│ ├─ [397207] ConfidencePoolFactory::createPool(..., minStake: 1000000000000000000 [1e18], ...) [delegatecall]
│ │ ├─ emit PoolCreated(..., stakeToken: MockDecimalERC20, minStake: 1000000000000000000 [1e18], ...)
│ │ └─ ← [Return] 0x6E50791B4d179fF380Aa28fA9fE90706541873e0
├─ [612] ConfidencePool::minStake() → 1000000000000000000 [1e18]
├─ MockDecimalERC20::decimals() → 6
├─ MockDecimalERC20::mint(alice, 1000000000000 [1e12]) // 1,000,000 USDC
├─ pool.stake(1000000000000 [1e12])
│ ├─ ConfidencePool::stake(1000000000000 [1e12]) [delegatecall]
│ │ └─ ← [Revert] BelowMinStake()
│ └─ ← [Revert] BelowMinStake()
├─ ConfidencePool::totalEligibleStake() → 0
└─ MockDecimalERC20::balanceOf(pool) → 0
Suite result: ok. 1 passed; 0 failed; 0 skipped
```

Recommended Mitigation

Validate `minStake` against `IERC20Metadata(stakeToken).decimals()` at `createPool` reject configs where the implied human minimum is absurd (e.g. `minStake > 10 ** (decimals + 12)`).

Support

FAQs

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

Give us feedback!