Guardian's review of BattleChain for Cyfrin, published May 2026. The report records 19 findings across 2 review rounds, including 1 high and 1 medium.
- Published
- Review window
- March 30 to May 12, 2026
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum, Arbitrum, Polygon, Scroll, ZKsync, Celo, Blast
- Sector
- Infrastructure
- 0 Critical
- 1 High
- 1 Medium
- 5 Low
- 12 Informational
Scope
Findings 19
Main Review
17 findings · March 30 to April 7, 2026-
H-01 High Multiple Deployment Paths In BattleChainDeployer Will Always Fail Logical Error Resolved
Description
BattleChainDeployeroverrides each CreateX deployment function to call_registerDeployment(newContract)after invokingsuper. However, multiple convenience overloads in CreateX are implemented by calling another overload (for example,deployCreateAndInit(init, data, values)callsdeployCreateAndInit(init, data, values, refundAddress), anddeployCreate2(initCode)callsdeployCreate2(salt, initCode)).Because these internal calls are virtual, when
BattleChainDeployerinvokessuper.deployCreate2(initCode), the CreateX implementation internally calls thedeployCreate2(salt, initCode)variant, which is also overridden by theBattleChainDeployer. That override then deploys the contract and registers the contract once. Control then returns to the original one, which proceeds to call_registerDeploymentagain, resulting in duplicate registration and reverts.As a result, most deployment functions in
BattleChainDeployeralways revert, since any overload that internally calls another overload attempts to register the same contract twice and fails with theContractAlreadyRegisterederror.The failing variants are as follows:
deployCreateAndInit(bytes memory initCode, bytes memory data, Values memory values)deployCreate2(bytes memory initCode)deployCreate2AndInit(bytes32 salt, bytes memory initCode, bytes memory data, Values memory values)deployCreate2AndInit(bytes memory initCode, bytes memory data, Values memory values, address refundAddress)deployCreate2AndInit(bytes memory initCode, bytes memory data, Values memory values)deployCreate2Clone(address implementation, bytes memory data)deployCreate3(bytes memory initCode)deployCreate3AndInit(bytes32 salt, bytes memory initCode, bytes memory data, Values memory values)deployCreate3AndInit(bytes memory initCode, bytes memory data, Values memory values, address refundAddress)deployCreate3AndInit(bytes memory initCode, bytes memory data, Values memory values)
Recommendation
Ensure that
_registerDeploymentis called only once per deployment. For overloads that delegate to another overload, consider removing the_registerDeploymentcall from the delegating wrapper to avoid duplicate registration. -
M-01 Medium
diligenceRequirementsCan Be Made Arbitrarily Burdensome During Commitment Window Validation ResolvedDescription
The
BountyTermsstruct contains adiligenceRequirementsstring defining conditions whitehats must meet to claim bounties. The commitment-window checks insetBountyTermsguard five fields (bountyPercentage,bountyCapUsd,aggregateBountyCapUsd,identity,retainable) but notdiligenceRequirements. During the commitment window, the owner can change this from empty to any arbitrary requirements, making bounty collection impossible.As a result, whitehats who began work under no diligence requirements may be subjected to impossible conditions introduced mid-commitment, effectively preventing any bounty claims.
Recommendation
Block changes to
diligenceRequirementsduring the commitment window. -
L-01 Low Stale promotionRequestTimestamp When Marked Corrupted Logical Error Resolved
Description
The
known-issues.mddocument states: “If an attack is discovered during the 3-day promotion delay, the attack moderator should callcancelPromotion(returning toUNDER_ATTACK) and thenmarkCorrupted”. However, this behavior is not enforced in the contract.markCorruptedcan be called in both theUNDER_ATTACKandPROMOTION_REQUESTEDstates. If it is called directly during thePROMOTION_REQUESTEDstate without first cancelling the promotion, the agreement state becomesCORRUPTED, but thepromotionRequestedTimestampis not cleared.getAgreementInfoindicates that a promotion was requested and may be interpreted as still pending, even though the agreement has already been marked as corrupted.Similarly,
promotionRequestedTimestampis not cleared when a terminalPRODUCTIONstate is reached. While this can serve as useful historical information about when the promotion was requested for that state, having a non-zeropromotionRequestedTimestampwhen the terminal state isCORRUPTEDcan be misleading and open to misinterpretation.Recommendation
Consider enforcing a promotion cancellation before marking an agreement as corrupted, or clearing
promotionRequestedTimestampwhen the agreement reaches a terminalCORRUPTEDstate. -
L-02 Low Aggregate Bounty Cap Can Be Introduced In Commitment Window Validation Resolved
Description
The commitment-window guard for
aggregateBountyCapUsdinAgreement.setBountyTermsonly prevents decreasing an existing non-zero cap. When the current value is 0 (no aggregate cap),new_value < 0is always false foruint256, so introducing a brand-new aggregate cap passes the check. Adding a cap where none existed is unfavorable to whitehats as it limits the total payout pool across all participants.Whitehats who evaluated the agreement under no-cap terms face a retroactively imposed payout ceiling. If multiple whitehats are active, the aggregate cap may prevent each from receiving their full individual cap.
Recommendation
Do not allow setting a new cap during the commitment window if the previous cap was zero.
-
L-03 Low Favorable
childContractScopeUpgrade Is Blocked Logical Error AcknowledgedDescription
During the commitment window,
addAccountsis allowed because expanding scope is favorable to whitehats. However, there is no way to update thechildContractScopeof an existing account. For example, upgrading an account fromNonetoAllexpands the attack surface, which is beneficial for whitehats. The only way to achieve this is to remove the account and re-add it with the new scope, butremoveAccountsis blocked during the commitment window as an unfavorable changefunction removeAccounts(string memory caip2ChainId, string[] memory accountAddresses) external onlyOwner { // Cannot remove accounts during commitment window if (block.timestamp < s_cantChangeUntil) { revert Agreement__CannotReduceScopeDuringCommitment(); } // ... }As a result, a favorable scope expansion is unintentionally blocked.
Recommendation
Consider allowing
childContractScopeupgrades (toward more inclusive scopes) for existing accounts during the commitment window. -
L-04 Low Switching From Aggregate Cap To Retainable Is Blocked Despite Being Favorable Logical Error Resolved
Description
Agreement.setBountyTermsenforces thataggregateBountyCapUsdcannot decrease during the commitment window. Separately,_validateBountyTermsrequiresaggregateBountyCapUsd == 0whenretainable = true. These two constraints create a deadlock: an agreement withretainable = falseandaggregateBountyCapUsd > 0can never switch toretainable = trueduring the commitment window, because doing so requires settingaggregateBountyCapUsdto 0 (a decrease).Making the bounty retainable is strictly favorable to whitehats as they keep recovered funds instead of returning them. The commitment window is designed to allow favorable changes, but this particular favorable transition is blocked.
As a result, agreement owners who initially chose an aggregate bounty cap cannot upgrade to retainable bounties during the commitment window despite being favorable to whitehats.
Recommendation
Allow
aggregateBountyCapUsdto decrease to 0 when switching to retainable in the same call. -
I-01 Informational Redundant Zero-address Checks In Initializers Informational Resolved
Description
The
initializefunctions across multiple contracts perform duplicateaddress(0)checks, as outlined below:AttackRegistry.initializechecks that_initialOwnerand_treasuryare notaddress(0)and reverts if they are. However, the subsequent internal calls to__Ownable_initand_setTreasuryalready perform these validations.AgreementFactory.initializedoes the same for_initialOwnerand reverts before calling__Ownable_init(_initialOwner), even though__Ownable_initalready reverts when the owner is zero.BattleChainSafeHarborRegistry.initializechecks whetherowner == address(0)immediately before invoking__Ownable_init_unchained(owner), which already enforces this constraint.
These checks are therefore redundant and can be safely removed to reduce code duplication.
Recommendation
Consider removing redundant
address(0)validation checks where the downstream initializer already enforces the same constraint. While retaining these checks is functionally harmless, removing them can reduce duplication. -
I-02 Informational Misleading Comment On requestUnderAttack Informational Resolved
Description
The NatSpec comment on
requestUnderAttackstates that “the agreement owner must be the deployer of all contracts”. However, the function actually checks the authorized owner for each contract, rather than the deployer.Since deployers can delegate ownership via
authorizeAgreementOwner, the deployer does not necessarily have to match the agreement owner, making the comment inaccurate.Recommendation
Update the comment to state that “the agreement owner must be the authorized owner for all contracts”.
-
I-03 Informational Agreement Mismatches Can Be Used to Deceive Whitehats Documentation Resolved
Description
Protocols adopting the BattleChain Safe Harbor deploy agreements and register them with both the
AttackRegistryand theBattleChainSafeHarborRegistry. However, there is no enforcement or linkage between these two registries.The BattleChain Safe Harbor documentation (https://docs.battlechain.com/battlechain/explanation/safe-harbor#verifying-protection) recommends this three-step check before attacking:
// 1. Agreement is valid bool valid = safeHarborRegistry.isAgreementValid(agreementAddress); // 2. Contract is attackable bool attackable = attackRegistry.isTopLevelContractUnderAttack(contractAddress); // 3. Contract is in scope bool inScope = agreement.isContractInScope(contractAddress);A protocol can satisfy all three booleans while pointing whitehats to an agreement that doesn't actually apply to the contract:
- Deploy two agreements via the factory: Agreement A (generous terms, higher bounty, higher percentage) and Agreement B (stingier, low bounty, more strict requirements). Include the same contract addresses in both scopes.
- Register stingier Agreement B with AttackRegistry (
requestUnderAttack). This makesattackRegistry.isTopLevelContractUnderAttack(contract)return true and legally binds the contract to Agreement B. - Register favourable Agreement A with BattleChainSafeHarborRegistry (
adoptSafeHarbor).
When whitehats fetch the agreement for that protocol via
BattleChainSafeHarborRegistry.getAgreement, they receive Agreement A. When they then run the checklist using Agreement A:safeHarborRegistry.isAgreementValid(A)→true, because Agreement A was also factory-deployed.attackRegistry.isTopLevelContractUnderAttack(contract)→true, because Agreement B registered the contract.AgreementA.isContractInScope(contract)→true, because the early return in the constructor when adding contracts to the scope. While each contract can have only one agreement in the AttackRegistry, different agreements can include the same contract in their scope as long as they are not registered, due to no-op here.
All three checks succeed, yet the contract's actual governing agreement (Agreement B) has different bounty terms. Whitehats performing the recommended checks can still be misled into believing more generous terms apply and could even face legal consequences.
Note that the
adoptSafeHarborfunction includes the following comment:/// @dev A user can add a malicious agreement here, this is ok /// @dev but the attack registry can only use agreements deployed from the factoryThis assumes that the added 'malicious' agreement is not deployed via the factory. However, protocols can deploy an agreement with malicious intent via the factory to pass checks and mislead whitehats.
Recommendation
Consider enforcing that
adoptSafeHarboronly accepts agreements already registered in the AttackRegistry, if an onchain fix is desired. This would prevent inconsistent agreements across registries. However, mismatches may still occur after the commitment window if the protocol updates the AttackRegistry but not the BattleChainSafeHarborRegistry.Additionally, document that whitehats should verify the agreement directly in the AttackRegistry via
attackRegistry.getAgreementForContract(contractAddress)for each contract, and confirm the agreement state usingattackRegistry.getAgreementState(agreementAddress) -
I-04 Informational Warning Regarding In Scope Contracts Informational Acknowledged
Description
Safe Harbor protection applies only to the accounts explicitly listed within an agreement’s scope (including any child contracts covered by
ChildContractScope)Complex exploits often traverse multiple contracts (e.g., entry points, routers, adapters, vaults). If a protocol’s agreement includes only a subset (for example, just the vault), any intermediate contract not listed is technically outside the protection. This can create grey areas for both whitehats and protocols. For example:
- A vault is in scope, but the exploit requires an entry point in another contract that is not in scope. In this case,
isTopLevelContractUnderAttackreturns true for the vault but false for the entry-point contract, making it unclear whether attacking the vault is permitted. - The entry point of an attack is within scope, but the contract holding the funds is not.
- Both the entry-point contract and the vault are in scope, but multiple intermediary contracts involved in the exploit path are not.
Everything is clear for whitehats when all contracts in an exploit path are either entirely in scope or out of scope. However, mixed cases introduce uncertainty and should be documented for both whitehats and protocols, as protocols may assume that including only fund-holding contracts is sufficient and exclude surrounding contracts.
Recommendation
For protocols, document that all relevant contracts should be included in these agreements, rather than only those holding funds.
For whitehats, clarify which contracts must be verified as in scope, whether that includes only the final contract holding funds, the entry-point contract, or the entire exploit chain.
- A vault is in scope, but the exploit requires an entry point in another contract that is not in scope. In this case,
-
I-05 Informational Document Bond Forfeiture When Re-requesting Documentation Resolved
Description
When
_collectFeeAndBondruns, it checks for an existing bond deposit on the agreement and forfeits it if an unclaimed bond exists.This is the intended behavior, as indicated by the
BondForfeitedevent and the comment "Forfeit any existing unclaimed bond to keep s_reservedByToken accurate." However, this should be clearly documented, as agreement owners may not review the codebase directly.Recommendation
Document the forfeiture behavior of unclaimed bonds. Alternatively, consider allowing previous depositors to claim at a later time, or directly transferring the existing bond to them.
-
I-06 Informational Warning For Whitehats Regarding Mid-Attack Scope Changes Warning Acknowledged
Description
After the minimum commitment window, the scope and agreement can be changed. The protocol may still be in the UNDER_ATTACK state while having passed the commitment window. During this period, a scope or agreement change may be recorded on-chain just before an attack transaction, either naturally or by the protocol front-running the attack with a scope change.
This may expose the whitehat to legal risk while performing the attack. To mitigate this, whitehats should perform scope and agreement validation and the attack atomically within the same transaction, ensuring there is no gap between validation and execution. This ensures that the attack either executes under a valid agreement or reverts if a scope change is mined before the attack transaction.
Recommendation
Document this risk and warn users to perform scope validation and attacks atomically.
-
I-07 Informational Old Agreements Break When The Factory Is Changed Logical Error Resolved
Description
registerContractForExistingAgreement/unregisterContractForExistingAgreementonly pass ifs_agreementFactory.isAgreementContract(msg.sender)is true. The Agreement contract ismsg.sender.Old agreements were created by factory A and are only marked in A’s
s_isAgreement. If the registry owner changes to factory B viasetAgreementFactory, those old addresses don’t exist in B’s mapping. TheisAgreementContractcheck fails withInvalidAgreementrevert.Consequently,
addAccounts,removeAccounts,addOrSetChains,removeChains(anything that syncs BattleChain scope) reverts for those agreements. Same forsyncNewContractswhen it hits the factory check on the agreement address.Recommendation
Consider documenting this behavior, so that users are aware of it.
-
I-08 Informational
finalizeStateEmits Stale PRODUCTION Event Informational ResolvedDescription
When an agreement auto-promotes to PRODUCTION (either via the 14-day deadline without DAO approval, or via the 3-day promotion delay), the transition is computed only —
promotedstaysfalsein storage. Because_revertIfLinkedToActiveAgreementreads the computed state, contracts can immediately be re-linked to a new agreement, emittingContractRegistered(X, agreementB).finalizeStateis permissionless and has no expiry. If it is called after contracts have already been re-linked, it emitsAgreementStateChanged(agreementA, PRODUCTION)out of order, afterContractRegistered(X, agreementB)has already appeared in the event stream.Any off-chain system reconstructing "which agreement governed contract X at production time" will compute the wrong answer: it will appear that contract X was still under agreementA's umbrella when it graduated, when in reality X had already been reassigned to agreementB.
Recommendation
Consider requiring
finalizeStateto be called before contracts can be re-linked, or callingfinalizeStatein_revertIfLinkedToActiveAgreement. -
I-09 Informational Duplicate Accounts Desynchronize Scope Cache Validation Resolved
Description
Agreement.addAccountsdoes not check for duplicate BattleChain account addresses (documented in NatSpec). The scope cache deduplicates vias_battleChainScopeExistsin_addToBattleChainScope. When the same address is added twice tos_accountsbut only once to the scope cache, a singleremoveAccountscall removes the cache entry entirely while one copy remains ins_accounts. The result is a state wheregetChainAccountsstill includes the address butgetBattleChainScopeAddressesandisContractInScopedo not. Consequently, off-chain tools relying ongetDetailsshow the contract in scope while on-chain queries show it out of scope.Recommendation
Consider rejecting duplicate BattleChain additions, or document this behavior, as adding the same address twice constitutes an owner error.
-
I-10 Informational
claimBondReverts For Fee-only Deposits Informational ResolvedDescription
When
s_bondToken != address(0)buts_verifiedBondAmount = 0(ors_unverifiedBondAmount = 0),BondManager._collectFeeAndBondcreates aBondDepositwithbondAmount = 0. The depositor pays only a fee. When the agreement later reachesPRODUCTION, the depositor callsclaimBond. The lazy-mark block is gated ondeposit.bondAmount > 0and is skipped._markBondClaimablealso returns early forbondAmount == 0. SobondClaimableis never set totrue, andclaimBondpermanently reverts withBondNotYetClaimable.While claiming a bond with a zero amount is unnecessary and has no impact on funds, off-chain automation tools that call
claimBondafter going into production will fail.Recommendation
Consider handling zero-bond deposits as an early success and marking them as claimable within the lazy-marking block, thereby allowing zero-bond claims when necessary.
-
I-11 Informational Outdated Code Comment In AttackRegistry Informational Resolved
Description
The comment in the
AttackRegistrycontract states that "NEW_DEPLOYMENT is for contracts deployed via BattleChainDeployer, and NOT_DEPLOYED is for external deployments." However, this is incorrect, as theNEW_DEPLOYMENTstate is not tied toBattleChainDeployer._getAgreementStatereturnsNEW_DEPLOYMENTonly wheninfo.isRegistered == trueandattackRequested == false; however, all registration paths setattackRequestedto true.All unregistered agreements are treated as
NOT_DEPLOYED, regardless of whether they are externally deployed or deployed viaBattleChainDeployer.Recommendation
Update the outdated comment and the README to reflect the current behavior.
Remediation Review
2 findings · May 12, 2026-
I-01 Informational Informational Note Regarding promotionRequestedTimestamp Informational Acknowledged
Description
promotionRequestedTimestampis now cleared on explicit terminal paths, so the original L-01 issue has been fixed. Multiple comments in the codebase now state that a non-zero timestamp indicates a promotion is pending.- In known-issues.md: "preserving the invariant that a non-zero timestamp means promotion is currently pending"
- In AttackRegistry.sol: "// Maintain invariant: promotionRequestedTimestamp != 0 iff promotion currently pending"
However, this is not entirely accurate, since
promotionRequestedTimestampcan remain non-zero while the state isPRODUCTIONafterPROMOTION_DELAYhas elapsed but beforefinalizeStatehas been called.Recommendation
Update the comments to reflect that a non-zero
promotionRequestedTimestampdoes not necessarily mean the promotion is still pending, and may also represent the period where the state has effectively transitioned toPRODUCTIONbutfinalizeStatehas not yet been called. -
L-01 Low
claimBondzero-bond bypasses state check Logical Error AcknowledgedDescription
The I-10 fix added a short-circuit at the top of
claimBondso fee-only (zero-bond) deposits no longer permanently revert. As written, the short-circuit runs before the lazy-mark / PRODUCTION check that gates non-zero claims:if (deposit.bondAmount == 0) { emit BondClaimed(agreementAddress, deposit.depositor, 0); deposit.claimed = true; return; } // Lazy-mark for time-based promotions that bypassed explicit marking if (!deposit.bondClaimable) { if (_getAgreementState(agreementAddress) == IAttackRegistry.ContractState.PRODUCTION) { emit BondClaimable(agreementAddress, deposit.bondAmount); deposit.bondClaimable = true; } } if (!deposit.bondClaimable) { revert BondManager__BondNotYetClaimable(agreementAddress); }For non-zero bonds the depositor must wait until
PRODUCTION(or until the agreement is rejected / corrupted) beforeclaimBondsucceeds. For zero-bond deposits there is no such gate. The depositor can callclaimBondimmediately after_collectFeeAndBond, while the agreement is still inATTACK_REQUESTEDorUNDER_ATTACK, and the deposit gets flipped toclaimed = true.Recommendation
Move the short-circuit inside the existing PRODUCTION lazy-mark gate, matching the I-10 recommendation.
No findings match.
More from Cyfrin
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
