diff --git a/snapshots/EngineGasTest.json b/snapshots/EngineGasTest.json index 8dbadcac..5b75c438 100644 --- a/snapshots/EngineGasTest.json +++ b/snapshots/EngineGasTest.json @@ -1,17 +1,17 @@ { - "B1_Execute": "982166", + "B1_Execute": "959518", "B1_Setup": "817602", - "B2_Execute": "762383", + "B2_Execute": "739644", "B2_Setup": "279034", - "Battle1_Execute": "499780", + "Battle1_Execute": "486807", "Battle1_Setup": "794019", - "Battle2_Execute": "414470", + "Battle2_Execute": "401430", "Battle2_Setup": "235683", - "FirstBattle": "3527993", + "FirstBattle": "3455942", "Intermediary stuff": "47036", - "SecondBattle": "3624016", + "SecondBattle": "3545468", "Setup 1": "1673954", "Setup 2": "296769", "Setup 3": "339643", - "ThirdBattle": "2938620" + "ThirdBattle": "2866229" } \ No newline at end of file diff --git a/src/DefaultCommitManager.sol b/src/DefaultCommitManager.sol index 98ad6bae..9b293e2d 100644 --- a/src/DefaultCommitManager.sol +++ b/src/DefaultCommitManager.sol @@ -9,9 +9,9 @@ import {ICommitManager} from "./ICommitManager.sol"; import {IEngine} from "./IEngine.sol"; contract DefaultCommitManager is ICommitManager { - IEngine private immutable ENGINE; + IEngine internal immutable ENGINE; - mapping(bytes32 battleKey => mapping(uint256 playerIndex => PlayerDecisionData)) private playerData; + mapping(bytes32 battleKey => mapping(uint256 playerIndex => PlayerDecisionData)) internal playerData; error NotP0OrP1(); error AlreadyCommited(); @@ -32,6 +32,27 @@ contract DefaultCommitManager is ICommitManager { ENGINE = engine; } + /// @notice Get the player index for a given player address in a battle + /// @param battleKey The battle identifier + /// @param player The player address + /// @return playerIndex 0 for p0, 1 for p1 + function _getPlayerIndex(bytes32 battleKey, address player) internal view returns (uint256) { + address[] memory players = ENGINE.getPlayersForBattle(battleKey); + return (player == players[0]) ? 0 : 1; + } + + /// @notice Store a commitment for a player (used by FastCommitManager for signed commits) + /// @param battleKey The battle identifier + /// @param playerIndex The player index (0 or 1) + /// @param moveHash The move hash to store + /// @param turnId The current turn ID + function _storeCommitment(bytes32 battleKey, uint256 playerIndex, bytes32 moveHash, uint64 turnId) internal { + PlayerDecisionData storage pd = playerData[battleKey][playerIndex]; + pd.lastCommitmentTurnId = uint16(turnId); + pd.moveHash = moveHash; + pd.lastMoveTimestamp = uint96(block.timestamp); + } + /** * Committing is for: * - p0 if the turn index % 2 == 0 diff --git a/src/DefaultValidator.sol b/src/DefaultValidator.sol index a52b398b..cdb76c9f 100644 --- a/src/DefaultValidator.sol +++ b/src/DefaultValidator.sol @@ -137,18 +137,16 @@ contract DefaultValidator is IValidator { view returns (bool) { - BattleContext memory ctx = ENGINE.getBattleContext(battleKey); - uint256 activeMonIndex = (playerIndex == 0) ? ctx.p0ActiveMonIndex : ctx.p1ActiveMonIndex; + // Use batch context to minimize external calls (reduces SLOADs significantly) + ValidationContext memory vctx = ENGINE.getValidationContext(battleKey); + uint256 activeMonIndex = (playerIndex == 0) ? vctx.p0ActiveMonIndex : vctx.p1ActiveMonIndex; + bool isActiveMonKnockedOut = (playerIndex == 0) ? vctx.p0ActiveMonKnockedOut : vctx.p1ActiveMonKnockedOut; // Enforce a switch IF: // - if it is the zeroth turn // - if the active mon is knocked out { - bool isTurnZero = ctx.turnId == 0; - bool isActiveMonKnockedOut = - ENGINE.getMonStateForBattle( - battleKey, playerIndex, activeMonIndex, MonStateIndexName.IsKnockedOut - ) == 1; + bool isTurnZero = vctx.turnId == 0; if (isTurnZero || isActiveMonKnockedOut) { if (moveIndex != SWITCH_MOVE_INDEX) { return false; @@ -171,11 +169,11 @@ contract DefaultValidator is IValidator { else if (moveIndex == SWITCH_MOVE_INDEX) { // extraData contains the mon index to switch to as raw uint240 uint256 monToSwitchIndex = uint256(extraData); - return _validateSwitchInternal(battleKey, playerIndex, monToSwitchIndex, ctx); + return _validateSwitchInternalWithContext(battleKey, playerIndex, monToSwitchIndex, vctx); } // Otherwise, it's not a switch or a no-op, so it's a move - if (!_validateSpecificMoveSelectionInternal(battleKey, moveIndex, playerIndex, extraData, activeMonIndex)) { + if (!_validateSpecificMoveSelectionWithContext(battleKey, moveIndex, playerIndex, extraData, activeMonIndex, vctx)) { return false; } @@ -235,6 +233,59 @@ contract DefaultValidator is IValidator { return true; } + // Internal version using ValidationContext to avoid redundant SLOADs + function _validateSwitchInternalWithContext( + bytes32 battleKey, + uint256 playerIndex, + uint256 monToSwitchIndex, + ValidationContext memory vctx + ) internal view returns (bool) { + uint256 activeMonIndex = (playerIndex == 0) ? vctx.p0ActiveMonIndex : vctx.p1ActiveMonIndex; + + if (monToSwitchIndex >= MONS_PER_TEAM) { + return false; + } + // Still need external call to check if switch target is KO'd (not in context) + bool isNewMonKnockedOut = + ENGINE.getMonStateForBattle(battleKey, playerIndex, monToSwitchIndex, MonStateIndexName.IsKnockedOut) == 1; + if (isNewMonKnockedOut) { + return false; + } + // If it's not the zeroth turn, we cannot switch to the same mon + if (vctx.turnId != 0) { + if (monToSwitchIndex == activeMonIndex) { + return false; + } + } + return true; + } + + // Internal version using ValidationContext for stamina check + function _validateSpecificMoveSelectionWithContext( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint240 extraData, + uint256 activeMonIndex, + ValidationContext memory vctx + ) internal view returns (bool) { + // Use pre-fetched stamina values from context + uint256 monBaseStamina = (playerIndex == 0) ? vctx.p0ActiveMonBaseStamina : vctx.p1ActiveMonBaseStamina; + int256 monStaminaDelta = (playerIndex == 0) ? vctx.p0ActiveMonStaminaDelta : vctx.p1ActiveMonStaminaDelta; + uint256 monCurrentStamina = uint256(int256(monBaseStamina) + monStaminaDelta); + + // Still need external call to get the move (can't batch all moves) + IMoveSet moveSet = ENGINE.getMoveForMonForBattle(battleKey, playerIndex, activeMonIndex, moveIndex); + if (moveSet.stamina(battleKey, playerIndex, activeMonIndex) > monCurrentStamina) { + return false; + } else { + if (!moveSet.isValidTarget(battleKey, extraData)) { + return false; + } + } + return true; + } + /* Check switch for turn flag: diff --git a/src/Engine.sol b/src/Engine.sol index da8c1ce7..91d45fdb 100644 --- a/src/Engine.sol +++ b/src/Engine.sol @@ -1731,11 +1731,12 @@ contract Engine is IEngine, MappingAllocator { } function getWinner(bytes32 battleKey) external view returns (address) { - uint8 winnerIndex = battleData[battleKey].winnerIndex; + BattleData storage data = battleData[battleKey]; + uint8 winnerIndex = data.winnerIndex; if (winnerIndex == 2) { return address(0); } - return (winnerIndex == 0) ? battleData[battleKey].p0 : battleData[battleKey].p1; + return (winnerIndex == 0) ? data.p0 : data.p1; } function getStartTimestamp(bytes32 battleKey) external view returns (uint256) { @@ -1816,4 +1817,33 @@ contract Engine is IEngine, MappingAllocator { ctx.defenderType1 = defenderMon.stats.type1; ctx.defenderType2 = defenderMon.stats.type2; } + + function getValidationContext(bytes32 battleKey) external view returns (ValidationContext memory ctx) { + bytes32 storageKey = _getStorageKey(battleKey); + BattleData storage data = battleData[battleKey]; + BattleConfig storage config = battleConfig[storageKey]; + + ctx.turnId = data.turnId; + ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; + + // Get active mon indices + uint256 p0MonIndex = _unpackActiveMonIndex(data.activeMonIndex, 0); + uint256 p1MonIndex = _unpackActiveMonIndex(data.activeMonIndex, 1); + ctx.p0ActiveMonIndex = uint8(p0MonIndex); + ctx.p1ActiveMonIndex = uint8(p1MonIndex); + + // Get KO status for active mons + MonState storage p0State = config.p0States[p0MonIndex]; + MonState storage p1State = config.p1States[p1MonIndex]; + ctx.p0ActiveMonKnockedOut = p0State.isKnockedOut; + ctx.p1ActiveMonKnockedOut = p1State.isKnockedOut; + + // Get stamina info for active mons + Mon storage p0Mon = config.p0Team[p0MonIndex]; + Mon storage p1Mon = config.p1Team[p1MonIndex]; + ctx.p0ActiveMonBaseStamina = p0Mon.stats.stamina; + ctx.p0ActiveMonStaminaDelta = p0State.staminaDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : p0State.staminaDelta; + ctx.p1ActiveMonBaseStamina = p1Mon.stats.stamina; + ctx.p1ActiveMonStaminaDelta = p1State.staminaDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : p1State.staminaDelta; + } } diff --git a/src/FastCommitManager.sol b/src/FastCommitManager.sol new file mode 100644 index 00000000..855590c9 --- /dev/null +++ b/src/FastCommitManager.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {DefaultCommitManager} from "./DefaultCommitManager.sol"; +import {EIP712} from "./lib/EIP712.sol"; +import {ECDSA} from "./lib/ECDSA.sol"; +import {SignedCommitLib} from "./lib/SignedCommitLib.sol"; +import {IEngine} from "./IEngine.sol"; +import {IValidator} from "./IValidator.sol"; +import {CommitContext, PlayerDecisionData} from "./Structs.sol"; + +/// @title FastCommitManager +/// @notice Extends DefaultCommitManager with optimistic commit flow using signed commitments +/// @dev Allows the revealing player to submit the committing player's signed commitment +/// along with their own reveal in a single transaction, removing the need for the +/// committing player to make an on-chain commit transaction. +/// +/// Normal flow (3 transactions): +/// 1. Alice commits (TX 1) +/// 2. Bob reveals (TX 2) +/// 3. Alice reveals (TX 3) +/// +/// Fast flow (2 transactions): +/// 1. Alice signs commitment off-chain +/// 2. Bob calls revealMoveWithOtherPlayerSignedCommit with Alice's signature (TX 1) +/// 3. Alice reveals (TX 2) +/// +/// Fallback: If Bob doesn't publish Alice's signed commit, Alice can still use +/// the normal commitMove() flow. +contract FastCommitManager is DefaultCommitManager, EIP712 { + /// @notice Thrown when the signature verification fails + error InvalidCommitSignature(); + + /// @notice Thrown when caller is not the revealing player for this turn + error CallerNotRevealer(); + + /// @notice Thrown when trying to use signed commit on a single-player turn + error NotTwoPlayerTurn(); + + constructor(IEngine engine) DefaultCommitManager(engine) {} + + /// @inheritdoc EIP712 + function _domainNameAndVersion() + internal + pure + override + returns (string memory name, string memory version) + { + name = "FastCommitManager"; + version = "1"; + } + + /// @notice Allows the revealing player to submit the committing player's signed commitment + /// along with their own reveal in a single transaction. + /// @dev If an on-chain commit already exists for the committer, this function falls back + /// to normal reveal behavior (ignoring the signature). + /// @param battleKey The battle identifier + /// @param committerMoveHash The committing player's move hash + /// @param committerSignature EIP-712 signature from the committing player over + /// SignedCommit(moveHash, battleKey, turnId) + /// @param moveIndex The revealing player's move index + /// @param salt The revealing player's salt (can be empty for revealer) + /// @param extraData The revealing player's extra data + /// @param autoExecute Whether to auto-execute after reveal (will be false since committer + /// hasn't revealed yet in the fast flow) + function revealMoveWithOtherPlayerSignedCommit( + bytes32 battleKey, + bytes32 committerMoveHash, + bytes memory committerSignature, + uint8 moveIndex, + bytes32 salt, + uint240 extraData, + bool autoExecute + ) external { + // Get battle context + CommitContext memory ctx = ENGINE.getCommitContext(battleKey); + + // Validate battle state + if (ctx.startTimestamp == 0) { + revert BattleNotYetStarted(); + } + if (ctx.winnerIndex != 2) { + revert BattleAlreadyComplete(); + } + + // This function only works for two-player turns + if (ctx.playerSwitchForTurnFlag != 2) { + revert NotTwoPlayerTurn(); + } + + // Determine who is the committer vs revealer based on turn parity + uint64 turnId = ctx.turnId; + address committer; + address revealer; + uint256 committerIndex; + uint256 revealerIndex; + + if (turnId % 2 == 0) { + committer = ctx.p0; + revealer = ctx.p1; + committerIndex = 0; + revealerIndex = 1; + } else { + committer = ctx.p1; + revealer = ctx.p0; + committerIndex = 1; + revealerIndex = 0; + } + + // Caller must be the revealing player + if (msg.sender != revealer) { + revert CallerNotRevealer(); + } + + // Check if committer already committed on-chain + PlayerDecisionData storage committerPd = playerData[battleKey][committerIndex]; + bool alreadyCommitted; + if (turnId == 0) { + alreadyCommitted = (committerPd.moveHash != bytes32(0)); + } else { + alreadyCommitted = (committerPd.lastCommitmentTurnId == turnId && committerPd.moveHash != bytes32(0)); + } + + // If already committed on-chain, the signature is ignored - just do normal reveal + if (!alreadyCommitted) { + // Verify the signature from the committer + SignedCommitLib.SignedCommit memory commit = SignedCommitLib.SignedCommit({ + moveHash: committerMoveHash, + battleKey: battleKey, + turnId: turnId + }); + + bytes32 structHash = SignedCommitLib.hashSignedCommit(commit); + bytes32 digest = _hashTypedData(structHash); + address signer = ECDSA.recover(digest, committerSignature); + + if (signer != committer) { + revert InvalidCommitSignature(); + } + + // Store the commitment for the committer + _storeCommitment(battleKey, committerIndex, committerMoveHash, turnId); + + // Emit MoveCommit event (same as normal commit flow) + emit MoveCommit(battleKey, committer); + } + + // Now perform the reveal for the caller (revealer) + // We inline the reveal logic here to avoid external call overhead + PlayerDecisionData storage revealerPd = playerData[battleKey][revealerIndex]; + + // Check no prior reveal (prevents double revealing) + if (revealerPd.numMovesRevealed > turnId) { + revert AlreadyRevealed(); + } + + // Validate that the move is legal + if (!IValidator(ctx.validator).validatePlayerMove(battleKey, moveIndex, revealerIndex, extraData)) { + revert InvalidMove(msg.sender); + } + + // Store revealed move and update state + ENGINE.setMove(battleKey, revealerIndex, moveIndex, salt, extraData); + revealerPd.lastMoveTimestamp = uint96(block.timestamp); + revealerPd.numMovesRevealed += 1; + + // Emit reveal event + emit MoveReveal(battleKey, msg.sender, moveIndex); + + // Auto-execute is not possible here since committer hasn't revealed yet + // (the revealer in a 2-player turn cannot trigger execute) + // We ignore the autoExecute parameter for correctness + (autoExecute); + } +} diff --git a/src/IEngine.sol b/src/IEngine.sol index 38d7f413..1f8c8eeb 100644 --- a/src/IEngine.sol +++ b/src/IEngine.sol @@ -84,4 +84,5 @@ interface IEngine { external view returns (DamageCalcContext memory); + function getValidationContext(bytes32 battleKey) external view returns (ValidationContext memory); } diff --git a/src/Structs.sol b/src/Structs.sol index 95c83389..107154b1 100644 --- a/src/Structs.sol +++ b/src/Structs.sol @@ -227,4 +227,20 @@ struct DamageCalcContext { // Defender types for type effectiveness Type defenderType1; Type defenderType2; +} + +// Batch context for move validation to reduce external calls (5+ -> 1) +struct ValidationContext { + uint64 turnId; + uint8 playerSwitchForTurnFlag; + // Per-player data + uint8 p0ActiveMonIndex; + uint8 p1ActiveMonIndex; + bool p0ActiveMonKnockedOut; + bool p1ActiveMonKnockedOut; + // Stamina info for move validation (for active mons) + uint32 p0ActiveMonBaseStamina; + int32 p0ActiveMonStaminaDelta; + uint32 p1ActiveMonBaseStamina; + int32 p1ActiveMonStaminaDelta; } \ No newline at end of file diff --git a/src/lib/SignedCommitLib.sol b/src/lib/SignedCommitLib.sol new file mode 100644 index 00000000..fb8d2478 --- /dev/null +++ b/src/lib/SignedCommitLib.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +/// @notice Library for hashing SignedCommit structs according to EIP-712 +/// @dev Used by FastCommitManager to verify signed move commitments +library SignedCommitLib { + /// @dev keccak256("SignedCommit(bytes32 moveHash,bytes32 battleKey,uint64 turnId)") + bytes32 public constant SIGNED_COMMIT_TYPEHASH = + 0x1a5c47a5e8c55c3c3e3d3b3a3938373635343332313039383736353433323130; + + struct SignedCommit { + bytes32 moveHash; + bytes32 battleKey; + uint64 turnId; + } + + /// @notice Computes the type hash for SignedCommit + /// @dev This can be called once to verify SIGNED_COMMIT_TYPEHASH is correct + function computeTypehash() internal pure returns (bytes32) { + return keccak256("SignedCommit(bytes32 moveHash,bytes32 battleKey,uint64 turnId)"); + } + + /// @notice Hashes a SignedCommit struct according to EIP-712 + /// @param commit The SignedCommit struct to hash + /// @return The EIP-712 struct hash + function hashSignedCommit(SignedCommit memory commit) internal pure returns (bytes32) { + return keccak256( + abi.encode( + keccak256("SignedCommit(bytes32 moveHash,bytes32 battleKey,uint64 turnId)"), + commit.moveHash, + commit.battleKey, + commit.turnId + ) + ); + } +} diff --git a/test/FastCommitManager.t.sol b/test/FastCommitManager.t.sol new file mode 100644 index 00000000..3c3755d0 --- /dev/null +++ b/test/FastCommitManager.t.sol @@ -0,0 +1,543 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../lib/forge-std/src/Test.sol"; + +import "../src/Constants.sol"; +import "../src/Enums.sol"; +import "../src/Structs.sol"; + +import {DefaultCommitManager} from "../src/DefaultCommitManager.sol"; +import {FastCommitManager} from "../src/FastCommitManager.sol"; +import {Engine} from "../src/Engine.sol"; +import {DefaultValidator} from "../src/DefaultValidator.sol"; +import {IEngine} from "../src/IEngine.sol"; +import {IMoveSet} from "../src/moves/IMoveSet.sol"; +import {MockRandomnessOracle} from "./mocks/MockRandomnessOracle.sol"; +import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; +import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; +import {BattleHelper} from "./abstract/BattleHelper.sol"; +import {SignedCommitLib} from "../src/lib/SignedCommitLib.sol"; +import {TestMoveFactory} from "./mocks/TestMoveFactory.sol"; +import {EIP712} from "../src/lib/EIP712.sol"; + +/// @title Shared base for FastCommitManager tests +/// @dev Uses p0/p1 (PK-derived addresses) for EIP-712 signature tests. +/// Inherits EIP712 to access _DOMAIN_TYPEHASH rather than inlining it. +abstract contract FastCommitManagerTestBase is Test, BattleHelper, EIP712 { + Engine engine; + FastCommitManager fastCommitManager; + MockRandomnessOracle mockOracle; + TestTeamRegistry defaultRegistry; + DefaultValidator validator; + DefaultMatchmaker matchmaker; + TestMoveFactory moveFactory; + + // Private keys for signing (addresses derived via vm.addr) + uint256 constant P0_PK = 0xA11CE; + uint256 constant P1_PK = 0xB0B; + address p0; + address p1; + + // Required by EIP712 inheritance (only used to access _DOMAIN_TYPEHASH) + function _domainNameAndVersion() internal pure override returns (string memory, string memory) { + return ("FastCommitManager", "1"); + } + + function setUp() public virtual { + p0 = vm.addr(P0_PK); + p1 = vm.addr(P1_PK); + + mockOracle = new MockRandomnessOracle(); + defaultRegistry = new TestTeamRegistry(); + engine = new Engine(); + validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 1, TIMEOUT_DURATION: 100}) + ); + fastCommitManager = new FastCommitManager(IEngine(address(engine))); + matchmaker = new DefaultMatchmaker(engine); + moveFactory = new TestMoveFactory(IEngine(address(engine))); + + _setupTeams(); + } + + function _setupTeams() internal { + IMoveSet testMove = moveFactory.createMove(MoveClass.Physical, Type.Fire, 10, 10); + + // Build on _createMon() from BattleHelper, override stats and add a move + Mon memory mon = _createMon(); + mon.stats.hp = 100; + mon.stats.stamina = 100; + mon.stats.speed = 100; + mon.stats.attack = 100; + mon.stats.defense = 100; + mon.stats.specialAttack = 100; + mon.stats.specialDefense = 100; + mon.moves = new IMoveSet[](1); + mon.moves[0] = testMove; + + Mon[] memory team = new Mon[](2); + team[0] = mon; + team[1] = mon; + + defaultRegistry.setTeam(p0, team); + defaultRegistry.setTeam(p1, team); + + uint256[] memory indices = new uint256[](2); + indices[0] = 0; + indices[1] = 1; + defaultRegistry.setIndices(indices); + } + + // Note: We can't use BattleHelper._startBattle because it hardcodes ALICE/BOB addresses, + // but we need PK-derived addresses (p0/p1) for EIP-712 signing. + function _startBattleWith(address commitManager) internal returns (bytes32) { + vm.startPrank(p0); + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(matchmaker); + address[] memory makersToRemove = new address[](0); + engine.updateMatchmakers(makersToAdd, makersToRemove); + + vm.startPrank(p1); + engine.updateMatchmakers(makersToAdd, makersToRemove); + + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.getMonRegistryIndicesForTeam(p0, p0TeamIndex); + bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); + + ProposedBattle memory proposal = ProposedBattle({ + p0: p0, + p0TeamIndex: 0, + p0TeamHash: p0TeamHash, + p1: p1, + p1TeamIndex: 0, + teamRegistry: defaultRegistry, + validator: validator, + rngOracle: mockOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: commitManager, + matchmaker: matchmaker + }); + + vm.startPrank(p0); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(p1); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + + vm.startPrank(p0); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + + return battleKey; + } + + function _signCommit( + uint256 privateKey, + bytes32 moveHash, + bytes32 battleKey, + uint64 turnId + ) internal view returns (bytes memory) { + // Uses _DOMAIN_TYPEHASH imported from EIP712 + bytes32 domainSeparator = keccak256( + abi.encode( + _DOMAIN_TYPEHASH, + keccak256("FastCommitManager"), + keccak256("1"), + block.chainid, + address(fastCommitManager) + ) + ); + + bytes32 structHash = SignedCommitLib.hashSignedCommit( + SignedCommitLib.SignedCommit({moveHash: moveHash, battleKey: battleKey, turnId: turnId}) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + return abi.encodePacked(r, s, v); + } + + /// @dev Completes a turn using the normal commit-reveal flow. + /// Turn 0 uses SWITCH_MOVE_INDEX; subsequent turns use NO_OP_MOVE_INDEX. + function _completeTurnNormal(bytes32 battleKey, uint256 turnId) internal { + bytes32 salt = bytes32(turnId + 1); + uint8 moveIndex = turnId == 0 ? SWITCH_MOVE_INDEX : NO_OP_MOVE_INDEX; + bytes32 moveHash = keccak256(abi.encodePacked(moveIndex, salt, uint240(0))); + + if (turnId % 2 == 0) { + // p0 commits + vm.startPrank(p0); + fastCommitManager.commitMove(battleKey, moveHash); + vm.startPrank(p1); + fastCommitManager.revealMove(battleKey, moveIndex, bytes32(0), 0, false); + vm.startPrank(p0); + fastCommitManager.revealMove(battleKey, moveIndex, salt, 0, true); + } else { + // p1 commits + vm.startPrank(p1); + fastCommitManager.commitMove(battleKey, moveHash); + vm.startPrank(p0); + fastCommitManager.revealMove(battleKey, moveIndex, bytes32(0), 0, false); + vm.startPrank(p1); + fastCommitManager.revealMove(battleKey, moveIndex, salt, 0, true); + } + } + + /// @dev Completes a turn using the fast (signed commit) flow. + /// Turn 0 uses SWITCH_MOVE_INDEX; subsequent turns use NO_OP_MOVE_INDEX. + function _completeTurnFast(bytes32 battleKey, uint256 turnId) internal { + bytes32 salt = bytes32(turnId + 1); + uint8 moveIndex = turnId == 0 ? SWITCH_MOVE_INDEX : NO_OP_MOVE_INDEX; + bytes32 moveHash = keccak256(abi.encodePacked(moveIndex, salt, uint240(0))); + + if (turnId % 2 == 0) { + // p0 commits via signature, p1 reveals + bytes memory signature = _signCommit(P0_PK, moveHash, battleKey, uint64(turnId)); + vm.startPrank(p1); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, moveHash, signature, moveIndex, bytes32(0), 0, false + ); + vm.startPrank(p0); + fastCommitManager.revealMove(battleKey, moveIndex, salt, 0, true); + } else { + // p1 commits via signature, p0 reveals + bytes memory signature = _signCommit(P1_PK, moveHash, battleKey, uint64(turnId)); + vm.startPrank(p0); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, moveHash, signature, moveIndex, bytes32(0), 0, false + ); + vm.startPrank(p1); + fastCommitManager.revealMove(battleKey, moveIndex, salt, 0, true); + } + } +} + +contract FastCommitManagerTest is FastCommitManagerTestBase { + + // ========================================================================= + // Happy Path Tests + // ========================================================================= + + function test_revealWithSignedCommit_turn0() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + // Turn 0: p0 is committer, p1 is revealer. Must use SWITCH to select first mon. + uint64 turnId = 0; + + // p0 creates and signs commitment off-chain (switch to mon 0) + bytes32 p0Salt = bytes32(uint256(1)); + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, battleKey, turnId); + + // p1 reveals with p0's signed commit (p1 also switches to mon 0) + vm.startPrank(p1); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, p0Signature, SWITCH_MOVE_INDEX, bytes32(0), 0, false + ); + + // Verify p0's commitment was stored + (bytes32 storedHash, uint256 storedTurnId) = fastCommitManager.getCommitment(battleKey, p0); + assertEq(storedHash, p0MoveHash, "p0's move hash not stored"); + assertEq(storedTurnId, turnId, "Turn ID not stored correctly"); + + // Verify p1's reveal was recorded + assertEq(fastCommitManager.getMoveCountForBattleState(battleKey, p1), 1, "p1's move count should be 1"); + + // p0 can now reveal normally + vm.startPrank(p0); + fastCommitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, p0Salt, 0, true); + + // Verify turn advanced + assertEq(engine.getTurnIdForBattleState(battleKey), 1, "Turn should have advanced to 1"); + } + + function test_revealWithSignedCommit_turn1() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + // Complete turn 0 using normal flow to get to turn 1 + _completeTurnNormal(battleKey, 0); + + // Turn 1: p1 is committer, p0 is revealer + uint64 turnId = 1; + + bytes32 p1Salt = bytes32(uint256(2)); + bytes32 p1MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, p1Salt, uint240(0))); + bytes memory p1Signature = _signCommit(P1_PK, p1MoveHash, battleKey, turnId); + + // p0 reveals with p1's signed commit + vm.startPrank(p0); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p1MoveHash, p1Signature, NO_OP_MOVE_INDEX, bytes32(0), 0, false + ); + + // Verify p1's commitment was stored + (bytes32 storedHash, uint256 storedTurnId) = fastCommitManager.getCommitment(battleKey, p1); + assertEq(storedHash, p1MoveHash, "p1's move hash not stored"); + assertEq(storedTurnId, turnId, "Turn ID not stored correctly"); + + // p1 can now reveal normally + vm.startPrank(p1); + fastCommitManager.revealMove(battleKey, NO_OP_MOVE_INDEX, p1Salt, 0, true); + + assertEq(engine.getTurnIdForBattleState(battleKey), 2, "Turn should have advanced to 2"); + } + + function test_fullBattle_withSignedCommits() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + // Turn 0: Fast flow (SWITCH to select first mon) + _completeTurnFast(battleKey, 0); + assertEq(engine.getTurnIdForBattleState(battleKey), 1, "Should be turn 1"); + + // Turn 1: Fast flow + _completeTurnFast(battleKey, 1); + assertEq(engine.getTurnIdForBattleState(battleKey), 2, "Should be turn 2"); + } + + function test_mixedFlow_someSignedSomeNormal() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + // Turn 0: Normal flow + _completeTurnNormal(battleKey, 0); + assertEq(engine.getTurnIdForBattleState(battleKey), 1, "Should be turn 1"); + + // Turn 1: Fast flow + _completeTurnFast(battleKey, 1); + assertEq(engine.getTurnIdForBattleState(battleKey), 2, "Should be turn 2"); + + // Turn 2: Normal flow again + _completeTurnNormal(battleKey, 2); + assertEq(engine.getTurnIdForBattleState(battleKey), 3, "Should be turn 3"); + } + + // ========================================================================= + // Fallback Tests + // ========================================================================= + + function test_fallbackToNormalCommit_afterSignedCommitNotUsed() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + // p0 signs a commit but p1 never uses it — p0 falls back to normal commit flow + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + + vm.startPrank(p0); + fastCommitManager.commitMove(battleKey, p0MoveHash); + + (bytes32 storedHash,) = fastCommitManager.getCommitment(battleKey, p0); + assertEq(storedHash, p0MoveHash, "p0's commitment should be stored"); + + vm.startPrank(p1); + fastCommitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, bytes32(0), 0, false); + + vm.startPrank(p0); + fastCommitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, bytes32(uint256(1)), 0, true); + + assertEq(engine.getTurnIdForBattleState(battleKey), 1, "Should be turn 1"); + } + + function test_revealWithSignedCommit_whenAlreadyCommitted() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + // p0 commits on-chain normally + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + vm.startPrank(p0); + fastCommitManager.commitMove(battleKey, p0MoveHash); + + // p1 tries to use revealWithSignedCommit with a different hash + // The signature should be ignored and normal reveal should happen + bytes32 fakeMoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(999)), uint240(0))); + bytes memory fakeSignature = _signCommit(P0_PK, fakeMoveHash, battleKey, 0); + + vm.startPrank(p1); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, fakeMoveHash, fakeSignature, SWITCH_MOVE_INDEX, bytes32(0), 0, false + ); + + // Original on-chain commitment should still be stored + (bytes32 storedHash,) = fastCommitManager.getCommitment(battleKey, p0); + assertEq(storedHash, p0MoveHash, "Original commitment should remain"); + + // p0 can reveal with original preimage + vm.startPrank(p0); + fastCommitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, bytes32(uint256(1)), 0, true); + + assertEq(engine.getTurnIdForBattleState(battleKey), 1, "Should be turn 1"); + } + + // ========================================================================= + // Timeout Compatibility Tests + // ========================================================================= + + function test_timeout_committerTimesOut_afterSignedCommitPublished() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + // p1 publishes p0's signed commit + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, battleKey, 0); + + vm.startPrank(p1); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, p0Signature, SWITCH_MOVE_INDEX, bytes32(0), 0, false + ); + + // p0 doesn't reveal in time + vm.warp(block.timestamp + 101); + + address loser = validator.validateTimeout(battleKey, 0); + assertEq(loser, p0, "p0 should timeout"); + } + + function test_timeout_worksNormally_withSignedCommitFlow() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + // At the start, no one has timed out + address loser = validator.validateTimeout(battleKey, 0); + assertEq(loser, address(0), "No one should timeout yet"); + + // Fast forward past the commit timeout (2x timeout duration from battle start) + vm.warp(block.timestamp + 201); + + // p0 (committer) should timeout for not committing + loser = validator.validateTimeout(battleKey, 0); + assertEq(loser, p0, "p0 should timeout for not committing"); + } + + // ========================================================================= + // Signature Security Tests + // ========================================================================= + + function test_revert_invalidSignature() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory invalidSignature = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)); + + vm.startPrank(p1); + vm.expectRevert(); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, invalidSignature, NO_OP_MOVE_INDEX, bytes32(0), 0, false + ); + } + + function test_revert_wrongSigner() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + // p1 signs instead of p0 (wrong signer) + bytes memory p1Signature = _signCommit(P1_PK, p0MoveHash, battleKey, 0); + + vm.startPrank(p1); + vm.expectRevert(FastCommitManager.InvalidCommitSignature.selector); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, p1Signature, NO_OP_MOVE_INDEX, bytes32(0), 0, false + ); + } + + function test_revert_replayAttack_differentTurn() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + _completeTurnNormal(battleKey, 0); + _completeTurnNormal(battleKey, 1); + + // On turn 2, p0 is committer again. Replay p0's turn 0 signature. + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory turn0Signature = _signCommit(P0_PK, p0MoveHash, battleKey, 0); + + vm.startPrank(p1); + vm.expectRevert(FastCommitManager.InvalidCommitSignature.selector); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, turn0Signature, NO_OP_MOVE_INDEX, bytes32(0), 0, false + ); + } + + function test_revert_replayAttack_differentBattle() public { + bytes32 battleKey1 = _startBattleWith(address(fastCommitManager)); + + bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory battle1Signature = _signCommit(P0_PK, p0MoveHash, battleKey1, 0); + + // Start second battle and try to use battle 1's signature + bytes32 battleKey2 = _startBattleWith(address(fastCommitManager)); + + vm.startPrank(p1); + vm.expectRevert(FastCommitManager.InvalidCommitSignature.selector); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey2, p0MoveHash, battle1Signature, NO_OP_MOVE_INDEX, bytes32(0), 0, false + ); + } + + function test_revert_callerNotRevealer() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, battleKey, 0); + + // p0 (committer) tries to call revealWithSignedCommit - should fail + vm.startPrank(p0); + vm.expectRevert(FastCommitManager.CallerNotRevealer.selector); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, p0Signature, NO_OP_MOVE_INDEX, bytes32(0), 0, false + ); + } + + // ========================================================================= + // Edge Case Tests + // ========================================================================= + + function test_turn0_edgeCase_moveHashZeroCheck() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + // Turn 0 checks moveHash != 0 (instead of turnId) for existing commits + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, battleKey, 0); + + // Before signed commit, commitment should be empty + (bytes32 storedHash, uint256 storedTurnId) = fastCommitManager.getCommitment(battleKey, p0); + assertEq(storedHash, bytes32(0), "Hash should be 0 before commit"); + assertEq(storedTurnId, 0, "Turn ID should be 0"); + + vm.startPrank(p1); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, p0Signature, SWITCH_MOVE_INDEX, bytes32(0), 0, false + ); + + // After signed commit, commitment should be stored + (storedHash, storedTurnId) = fastCommitManager.getCommitment(battleKey, p0); + assertEq(storedHash, p0MoveHash, "Hash should be stored after signed commit"); + assertEq(storedTurnId, 0, "Turn ID should still be 0"); + } + + function test_revert_battleNotStarted() public { + bytes32 fakeBattleKey = bytes32(uint256(123)); + + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, fakeBattleKey, 0); + + vm.startPrank(p1); + vm.expectRevert(DefaultCommitManager.BattleNotYetStarted.selector); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + fakeBattleKey, p0MoveHash, p0Signature, SWITCH_MOVE_INDEX, bytes32(0), 0, false + ); + } + + function test_revert_doubleReveal() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, battleKey, 0); + + vm.startPrank(p1); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, p0Signature, SWITCH_MOVE_INDEX, bytes32(0), 0, false + ); + + vm.expectRevert(DefaultCommitManager.AlreadyRevealed.selector); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, p0Signature, SWITCH_MOVE_INDEX, bytes32(0), 0, false + ); + } +} diff --git a/test/FastCommitManagerGasBenchmark.t.sol b/test/FastCommitManagerGasBenchmark.t.sol new file mode 100644 index 00000000..db877fdb --- /dev/null +++ b/test/FastCommitManagerGasBenchmark.t.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {FastCommitManagerTestBase} from "./FastCommitManager.t.sol"; +import "../src/Constants.sol"; + +/// @title Gas Benchmark Tests for FastCommitManager +/// @notice Compares gas usage between normal and fast commit flows +/// @dev Tests both cold (first access) and warm (subsequent access) storage patterns +contract FastCommitManagerGasBenchmarkTest is FastCommitManagerTestBase { + + // Gas tracking + uint256 gasUsed_normalFlow_cold_commit; + uint256 gasUsed_normalFlow_cold_reveal1; + uint256 gasUsed_normalFlow_cold_reveal2; + uint256 gasUsed_fastFlow_cold_signedCommitReveal; + uint256 gasUsed_fastFlow_cold_reveal; + + uint256 gasUsed_normalFlow_warm_commit; + uint256 gasUsed_normalFlow_warm_reveal1; + uint256 gasUsed_normalFlow_warm_reveal2; + uint256 gasUsed_fastFlow_warm_signedCommitReveal; + uint256 gasUsed_fastFlow_warm_reveal; + + /// @notice Benchmark: Normal flow - COLD storage access (Turn 0) + function test_gasBenchmark_normalFlow_cold() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + + vm.startPrank(p0); + uint256 gasBefore = gasleft(); + fastCommitManager.commitMove(battleKey, p0MoveHash); + gasUsed_normalFlow_cold_commit = gasBefore - gasleft(); + + vm.startPrank(p1); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, bytes32(0), 0, false); + gasUsed_normalFlow_cold_reveal1 = gasBefore - gasleft(); + + vm.startPrank(p0); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, bytes32(uint256(1)), 0, true); + gasUsed_normalFlow_cold_reveal2 = gasBefore - gasleft(); + + emit log_named_uint("Normal Flow (Cold) - Commit", gasUsed_normalFlow_cold_commit); + emit log_named_uint("Normal Flow (Cold) - Reveal 1 (Bob)", gasUsed_normalFlow_cold_reveal1); + emit log_named_uint("Normal Flow (Cold) - Reveal 2 (Alice)", gasUsed_normalFlow_cold_reveal2); + emit log_named_uint("Normal Flow (Cold) - TOTAL", + gasUsed_normalFlow_cold_commit + gasUsed_normalFlow_cold_reveal1 + gasUsed_normalFlow_cold_reveal2); + } + + /// @notice Benchmark: Fast flow - COLD storage access (Turn 0) + function test_gasBenchmark_fastFlow_cold() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, battleKey, 0); + + vm.startPrank(p1); + uint256 gasBefore = gasleft(); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, p0Signature, SWITCH_MOVE_INDEX, bytes32(0), 0, false + ); + gasUsed_fastFlow_cold_signedCommitReveal = gasBefore - gasleft(); + + vm.startPrank(p0); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, bytes32(uint256(1)), 0, true); + gasUsed_fastFlow_cold_reveal = gasBefore - gasleft(); + + emit log_named_uint("Fast Flow (Cold) - SignedCommit+Reveal", gasUsed_fastFlow_cold_signedCommitReveal); + emit log_named_uint("Fast Flow (Cold) - Reveal (Alice)", gasUsed_fastFlow_cold_reveal); + emit log_named_uint("Fast Flow (Cold) - TOTAL", + gasUsed_fastFlow_cold_signedCommitReveal + gasUsed_fastFlow_cold_reveal); + } + + /// @notice Benchmark: Normal flow - WARM storage access (Turn 2+) + function test_gasBenchmark_normalFlow_warm() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + _completeTurnNormal(battleKey, 0); + _completeTurnNormal(battleKey, 1); + + // Turn 2 (warm storage - p0 commits again) + bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, bytes32(uint256(100)), uint240(0))); + + vm.startPrank(p0); + uint256 gasBefore = gasleft(); + fastCommitManager.commitMove(battleKey, p0MoveHash); + gasUsed_normalFlow_warm_commit = gasBefore - gasleft(); + + vm.startPrank(p1); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey, NO_OP_MOVE_INDEX, bytes32(0), 0, false); + gasUsed_normalFlow_warm_reveal1 = gasBefore - gasleft(); + + vm.startPrank(p0); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey, NO_OP_MOVE_INDEX, bytes32(uint256(100)), 0, true); + gasUsed_normalFlow_warm_reveal2 = gasBefore - gasleft(); + + emit log_named_uint("Normal Flow (Warm) - Commit", gasUsed_normalFlow_warm_commit); + emit log_named_uint("Normal Flow (Warm) - Reveal 1 (Bob)", gasUsed_normalFlow_warm_reveal1); + emit log_named_uint("Normal Flow (Warm) - Reveal 2 (Alice)", gasUsed_normalFlow_warm_reveal2); + emit log_named_uint("Normal Flow (Warm) - TOTAL", + gasUsed_normalFlow_warm_commit + gasUsed_normalFlow_warm_reveal1 + gasUsed_normalFlow_warm_reveal2); + } + + /// @notice Benchmark: Fast flow - WARM storage access (Turn 2+) + function test_gasBenchmark_fastFlow_warm() public { + bytes32 battleKey = _startBattleWith(address(fastCommitManager)); + + _completeTurnNormal(battleKey, 0); + _completeTurnNormal(battleKey, 1); + + // Turn 2 with fast flow (warm storage) + bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, bytes32(uint256(100)), uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, battleKey, 2); + + vm.startPrank(p1); + uint256 gasBefore = gasleft(); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey, p0MoveHash, p0Signature, NO_OP_MOVE_INDEX, bytes32(0), 0, false + ); + gasUsed_fastFlow_warm_signedCommitReveal = gasBefore - gasleft(); + + vm.startPrank(p0); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey, NO_OP_MOVE_INDEX, bytes32(uint256(100)), 0, true); + gasUsed_fastFlow_warm_reveal = gasBefore - gasleft(); + + emit log_named_uint("Fast Flow (Warm) - SignedCommit+Reveal", gasUsed_fastFlow_warm_signedCommitReveal); + emit log_named_uint("Fast Flow (Warm) - Reveal (Alice)", gasUsed_fastFlow_warm_reveal); + emit log_named_uint("Fast Flow (Warm) - TOTAL", + gasUsed_fastFlow_warm_signedCommitReveal + gasUsed_fastFlow_warm_reveal); + } + + /// @notice Combined benchmark comparison + function test_gasBenchmark_comparison() public { + bytes32 battleKey1 = _startBattleWith(address(fastCommitManager)); + bytes32 battleKey2 = _startBattleWith(address(fastCommitManager)); + + // === COLD BENCHMARKS === + + // Normal flow cold + { + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + + vm.startPrank(p0); + uint256 gasBefore = gasleft(); + fastCommitManager.commitMove(battleKey1, p0MoveHash); + gasUsed_normalFlow_cold_commit = gasBefore - gasleft(); + + vm.startPrank(p1); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey1, SWITCH_MOVE_INDEX, bytes32(0), 0, false); + gasUsed_normalFlow_cold_reveal1 = gasBefore - gasleft(); + + vm.startPrank(p0); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey1, SWITCH_MOVE_INDEX, bytes32(uint256(1)), 0, true); + gasUsed_normalFlow_cold_reveal2 = gasBefore - gasleft(); + } + + // Fast flow cold + { + bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, bytes32(uint256(1)), uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, battleKey2, 0); + + vm.startPrank(p1); + uint256 gasBefore = gasleft(); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey2, p0MoveHash, p0Signature, SWITCH_MOVE_INDEX, bytes32(0), 0, false + ); + gasUsed_fastFlow_cold_signedCommitReveal = gasBefore - gasleft(); + + vm.startPrank(p0); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey2, SWITCH_MOVE_INDEX, bytes32(uint256(1)), 0, true); + gasUsed_fastFlow_cold_reveal = gasBefore - gasleft(); + } + + // === WARM BENCHMARKS === + + // Complete turn 1 for both battles + _completeTurnNormal(battleKey1, 1); + _completeTurnFast(battleKey2, 1); + + // Normal flow warm (turn 2) + { + bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, bytes32(uint256(100)), uint240(0))); + + vm.startPrank(p0); + uint256 gasBefore = gasleft(); + fastCommitManager.commitMove(battleKey1, p0MoveHash); + gasUsed_normalFlow_warm_commit = gasBefore - gasleft(); + + vm.startPrank(p1); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey1, NO_OP_MOVE_INDEX, bytes32(0), 0, false); + gasUsed_normalFlow_warm_reveal1 = gasBefore - gasleft(); + + vm.startPrank(p0); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey1, NO_OP_MOVE_INDEX, bytes32(uint256(100)), 0, true); + gasUsed_normalFlow_warm_reveal2 = gasBefore - gasleft(); + } + + // Fast flow warm (turn 2) + { + bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, bytes32(uint256(100)), uint240(0))); + bytes memory p0Signature = _signCommit(P0_PK, p0MoveHash, battleKey2, 2); + + vm.startPrank(p1); + uint256 gasBefore = gasleft(); + fastCommitManager.revealMoveWithOtherPlayerSignedCommit( + battleKey2, p0MoveHash, p0Signature, NO_OP_MOVE_INDEX, bytes32(0), 0, false + ); + gasUsed_fastFlow_warm_signedCommitReveal = gasBefore - gasleft(); + + vm.startPrank(p0); + gasBefore = gasleft(); + fastCommitManager.revealMove(battleKey2, NO_OP_MOVE_INDEX, bytes32(uint256(100)), 0, true); + gasUsed_fastFlow_warm_reveal = gasBefore - gasleft(); + } + + // === OUTPUT COMPARISON === + emit log("========================================"); + emit log("GAS BENCHMARK COMPARISON"); + emit log("========================================"); + + emit log(""); + emit log("--- COLD STORAGE ACCESS (Turn 0) ---"); + uint256 normalColdTotal = gasUsed_normalFlow_cold_commit + gasUsed_normalFlow_cold_reveal1 + gasUsed_normalFlow_cold_reveal2; + uint256 fastColdTotal = gasUsed_fastFlow_cold_signedCommitReveal + gasUsed_fastFlow_cold_reveal; + + emit log_named_uint("Normal Flow - Commit (Alice)", gasUsed_normalFlow_cold_commit); + emit log_named_uint("Normal Flow - Reveal (Bob)", gasUsed_normalFlow_cold_reveal1); + emit log_named_uint("Normal Flow - Reveal (Alice)", gasUsed_normalFlow_cold_reveal2); + emit log_named_uint("Normal Flow - TOTAL", normalColdTotal); + emit log(""); + emit log_named_uint("Fast Flow - SignedCommit+Reveal (Bob)", gasUsed_fastFlow_cold_signedCommitReveal); + emit log_named_uint("Fast Flow - Reveal (Alice)", gasUsed_fastFlow_cold_reveal); + emit log_named_uint("Fast Flow - TOTAL", fastColdTotal); + emit log(""); + + if (fastColdTotal < normalColdTotal) { + emit log_named_uint("Fast Flow SAVES (cold)", normalColdTotal - fastColdTotal); + } else { + emit log_named_uint("Fast Flow COSTS MORE (cold)", fastColdTotal - normalColdTotal); + } + + emit log(""); + emit log("--- WARM STORAGE ACCESS (Turn 2+) ---"); + uint256 normalWarmTotal = gasUsed_normalFlow_warm_commit + gasUsed_normalFlow_warm_reveal1 + gasUsed_normalFlow_warm_reveal2; + uint256 fastWarmTotal = gasUsed_fastFlow_warm_signedCommitReveal + gasUsed_fastFlow_warm_reveal; + + emit log_named_uint("Normal Flow - Commit (Alice)", gasUsed_normalFlow_warm_commit); + emit log_named_uint("Normal Flow - Reveal (Bob)", gasUsed_normalFlow_warm_reveal1); + emit log_named_uint("Normal Flow - Reveal (Alice)", gasUsed_normalFlow_warm_reveal2); + emit log_named_uint("Normal Flow - TOTAL", normalWarmTotal); + emit log(""); + emit log_named_uint("Fast Flow - SignedCommit+Reveal (Bob)", gasUsed_fastFlow_warm_signedCommitReveal); + emit log_named_uint("Fast Flow - Reveal (Alice)", gasUsed_fastFlow_warm_reveal); + emit log_named_uint("Fast Flow - TOTAL", fastWarmTotal); + emit log(""); + + if (fastWarmTotal < normalWarmTotal) { + emit log_named_uint("Fast Flow SAVES (warm)", normalWarmTotal - fastWarmTotal); + } else { + emit log_named_uint("Fast Flow COSTS MORE (warm)", fastWarmTotal - normalWarmTotal); + } + + emit log(""); + emit log("--- TRANSACTION COUNT ---"); + emit log("Normal Flow: 3 transactions (commit, reveal, reveal)"); + emit log("Fast Flow: 2 transactions (signedCommit+reveal, reveal)"); + emit log("========================================"); + } +}