Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions snapshots/EngineGasTest.json
Original file line number Diff line number Diff line change
@@ -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"
}
25 changes: 23 additions & 2 deletions src/DefaultCommitManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
69 changes: 60 additions & 9 deletions src/DefaultValidator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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:

Expand Down
34 changes: 32 additions & 2 deletions src/Engine.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
}
175 changes: 175 additions & 0 deletions src/FastCommitManager.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions src/IEngine.sol
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,5 @@ interface IEngine {
external
view
returns (DamageCalcContext memory);
function getValidationContext(bytes32 battleKey) external view returns (ValidationContext memory);
}
Loading