Documentation Index
Fetch the complete documentation index at: https://docs.basepixel.io/llms.txt
Use this file to discover all available pages before exploring further.
BattleFacet is the Diamond facet that handles on-chain settlement of battles. It does not run combat — that’s the off-chain Battle Engine. What this facet does is: lock the two pixels, verify the Engine’s signed result, and atomically transfer NFT + ETH to the winner.
This page is the contract-level spec — function signatures, storage layout, events, errors, and security considerations. It’s the input for the Solidity engineer, not the implementation.
Architecture
BattleFacet is one of ~10 facets behind the Diamond proxy. It depends on:
- PixelCoreFacet — reads
layoutHash,isInAction, NFT ownership. - TreasuryFacet — moves insurance pool ETH on resolve.
- RedeemFacet — notified on resolve to start the 24h redeem window for the loser.
- OwnershipFacet — manages the
ENGINE_SIGNERauthorized address.
keccak256("basepixel.battle.v1")) so upgrades don’t collide with other facets.
Storage layout
Why
lockBlockHash is stored: The Engine uses it as the random seed for the simulator (keccak256(lockBlockHash, battleId)). Storing it on-chain at lock time means anyone can later rerun the simulator with the same seed and verify the replay. This is the linchpin of public verifiability.External functions
lockBattle
Called by the attacker to commit a battle on-chain and pay the attack fee. The Engine has already validated and signed off on the intent in Phase 2; here the attacker just submits that signature alongside msg.value == attackFee.
msg.sender == ownerOf(intent.attackerPixelId)(attacker submits their own lock).msg.value == intent.attackFee(fee is paid in the same call — no escrow).engineSignaturerecovers toengineSignerover the EIP-712AttackIntentdigest (proves the Engine validated the intent in Phase 2).intent.deadline >= block.timestamp(intent not expired).intent.nonce == intentNonces[attacker]then increments it (anti-replay).- Both pixels are in Action mode (read from PixelCoreFacet).
- Neither pixel is already locked (
activeBattleOf[pixelId] == 0). intent.attackerLayoutHashmatches the on-chain hash (sanity check; defender’s hash is read fresh from PixelCoreFacet, not from the intent).
- Allocates new
battleId = ++nextBattleId. - Stores the
Battlestruct with both layout hashes, currentblockhash(block.number - 1)aslockBlockHash, andlockedAt = block.timestamp. - Sets
activeBattleOf[attackerPixelId] = battleIdandactiveBattleOf[defenderPixelId] = battleId. - Emits
BattleLocked.
nonReentrant. State updates happen before any external call (CEI pattern).
resolveBattle
Called by the Engine after off-chain simulation completes. The Engine submits the canonical replay hash + outcome + a signed attestation.
battleIdexists andbattles[battleId].outcome == Unresolved.block.timestamp - lockedAt <= settlementDeadline(otherwise this path is closed; onlyforceResolveExpiredworks).engineSignatureis a valid signature byengineSignerover:outcome != Unresolved(Engine must commit to a real outcome).
- Records
replayHash,outcome,resolvedAt = block.timestamp. - Clears
activeBattleOffor both pixels. - Performs asset transfer:
- AttackerWin / Forfeit →
_transferToWinner(attacker, defender): defender’s NFT and insurance pool ETH transfer to attacker; the attack fee paid at lock joins the (now attacker-owned) pixel’s insurance pool. - DefenderWin / Draw →
_transferToWinner(defender, attacker): attacker’s NFT and insurance pool ETH transfer to defender; the attack fee accrues to the defender’s insurance pool.
- AttackerWin / Forfeit →
- Calls
IRedeemFacet(diamond).openRedeemWindow(loserPixelId, formerOwner, block.timestamp). - Emits
BattleResolvedwith the fullreplayUri(so indexers can pin / fetch the log without an extra round-trip).
.transfer() to attacker-controlled addresses on the hot path). NFT transfer uses _transfer (internal), which doesn’t trigger onERC721Received — wallets cannot reject the transfer, and contracts that own pixels must implement that handler at mint time.
forceResolveExpired
Fallback when the Engine fails to resolve within settlementDeadline. Called by the guardian multisig.
- Same effect as
resolveBattlebut bypasses Engine signature. - Only callable after
settlementDeadlinehas passed since lock. - Guardian (3/5 multisig from OwnershipFacet) is the authorized caller.
- Emits
BattleForceResolved(battleId, outcome, msg.sender)so the use of guardian power is publicly visible.
View functions
Events
Indexing strategy: Indexers (Ponder / The Graph) should listen to
BattleLocked and BattleResolved to maintain a real-time view of active battles. replayUri is in the event, not storage — fetch the canonical log from there for replay UI.Latency optimization: The frontend doesn’t wait for the indexer’s BattleLocked poll. Once the lock tx confirms in the wallet, it POSTs the receipt’s BattleLocked log to POST /api/battles/notify-locked. The Engine decodes the log locally (no eth_getTransactionReceipt round-trip), runs the eager simulation, and broadcasts the replay over SSE. Steady-state lock-to-first-frame is ~30ms.Errors
revert("...")) for cheaper gas and easier indexer parsing.
EIP-712 typehashes
Fee handling
The 0.0005 ETH attack fee is paid asmsg.value directly to lockBattle by the attacker. There is no per-pixel attack escrow, no permit2 allowance, and no Engine-managed pool — the attacker’s wallet pays per battle and the fee sits in the battle’s storage until resolveBattle settles it.
On resolve, the fee:
- AttackerWin / Forfeit → joins the captured pixel’s insurance pool (now under the attacker).
- DefenderWin / Draw → joins the defender’s insurance pool.
Security considerations
Engine signer key compromise
IfengineSigner’s private key is stolen, the attacker can fabricate resolveBattle calls and steal NFTs.
Mitigations:
engineSigneris updatable via OwnershipFacet (timelocked 48h, multisig 3/5). If compromise is detected, signer can be rotated.- Engine private key must live in HSM / KMS, not in environment variables.
- Per-battle signature includes both layout hashes, so a stolen key can’t fabricate battles for arbitrary pixels — it still has to produce a syntactically valid replay.
- Future: V1.1 multi-signer setup where the
replayHashrequires N-of-M signatures from independent Engine instances.
Front-running lockBattle
A malicious Engine could see the attack intent in the mempool and front-run it (e.g. by reordering battles to influence seeds). The attacker can’t profit directly because they don’t control outcomes, but ordering manipulation is theoretically possible.
Mitigation: Engine submits via Flashbots / private mempool on Base. The seed is keccak256(lockBlockHash, battleId) — lockBlockHash is unpredictable until the tx is mined, so even ordering doesn’t change the seed in a way Engine can exploit.
Reentrancy through TreasuryFacet
resolveBattle calls TreasuryFacet to move ETH. TreasuryFacet uses pull-payment for any ETH that goes to a non-EOA address. The hot path (NFT transfer + state update) finishes before any external ETH transfer. NonReentrant guard on resolveBattle for belt-and-suspenders.
Layout hash collision
The layout hash includespixelId to prevent the same (layout, nonce) pair from being valid for multiple pixels:
pixelId, an attacker could pre-compute a strong layout, get its hash committed for one cheap pixel, then claim the same hash on a more valuable pixel. Including pixelId defeats this.
Storage upgradeability
bytes32 STORAGE_SLOT = keccak256("basepixel.battle.v1"). Future versions (v2) get a separate slot. Migration is explicit, never a silent overlay.
The Battle struct itself cannot be modified in-place in a future facet — Solidity layouts changes shift everything. If the struct needs new fields, the migration path is:
- Deploy
BattleFacetV2writing to a new storage namespacekeccak256("basepixel.battle.v2"). - Run a migration function to copy active battles forward.
- Replace the facet via DiamondCutFacet.
Test surface
A minimum viable test suite covers:- Lock: valid intent, invalid signature, expired intent, replayed nonce, attacker not owner, attacker pixel locked, defender pixel locked, defender in Unaction, self-attack, insufficient fee.
- Resolve: valid Engine signature, invalid signature, wrong battleId, already resolved, after settlement deadline, all four outcomes (AttackerWin, DefenderWin, Draw, Forfeit).
- ForceResolve: before deadline (must revert), after deadline (must succeed), non-guardian caller (must revert).
- Asset transfers: NFT ownership flips, insurance pools move, attack fee re-funds correctly, redeem window opens.
- Reentrancy: malicious receiver tries to call back into
resolveBattle,lockBattle, or any TreasuryFacet method — all must revert. - Diamond integration: storage isolation, function selector routing, upgrade replacing only
BattleFacetdoesn’t break stored battles.
forge test --fuzz-runs 10000) for the signature verification paths in particular.
Open implementation decisions
These are intentionally left for the implementing engineer to choose, since they don’t affect the interface:- OpenZeppelin vs. Solady for ECDSA / EIP-712 helpers (Solady is gas-cheaper but less audited; OZ is the safe default for MVP).
- Block hash availability:
blockhash(block.number - 1)is used; if the lock + simulation cycle could span > 256 blocks (unlikely on Base), the Engine must record the hash itself. - Whether
replayUrishould bebytes32IPFS CID + ipfs:// prefix instead ofstring(saves gas). Recommended once IPFS-only is locked in. - Gas griefing on
forceResolveExpiredif guardian is forced to resolve thousands of stuck battles after a long Engine outage. Add batch variant if real-world usage demands.