Skip to content

Commit c6d7570

Browse files
committed
Add doubles support for SignedCommitManager + optimize SLOADs
- getCommitAuthForDualSigned: drop startTimestamp check to eliminate 2 SLOADs (storageKey lookup + config read). winnerIndex != 2 already catches never-started battles. Return gameMode for free from same slot. - Engine.executeWithMovesForDoubles: new function that sets all 4 slot moves (p0Move, p0Move2, p1Move, p1Move2) and executes in one call. - SignedCommitLib.DualSignedRevealDoubles: new EIP-712 struct covering 2 move indices + 2 extra data fields per revealer for doubles. - SignedCommitManager.executeWithDualSignedMovesForDoubles: doubles variant of the dual-signed flow. Committer hash uses revealMovePair preimage format. Validates gameMode to prevent singles/doubles mismatch. https://claude.ai/code/session_01MdUWjZNL2QrK4utE8Lma7H
1 parent 9917e21 commit c6d7570

4 files changed

Lines changed: 203 additions & 8 deletions

File tree

src/Engine.sol

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,49 @@ contract Engine is IEngine, MappingAllocator {
366366
_executeInternal(battleKey, storageKey);
367367
}
368368

369+
/// @notice Sets all 4 slot moves for doubles and executes in a single call
370+
/// @dev Like executeWithMoves but for doubles battles — sets p0Move, p0Move2, p1Move, p1Move2.
371+
/// Salt is per-player (not per-slot), matching the revealMovePair convention.
372+
function executeWithMovesForDoubles(
373+
bytes32 battleKey,
374+
uint8 p0MoveIndex0,
375+
uint240 p0ExtraData0,
376+
uint8 p0MoveIndex1,
377+
uint240 p0ExtraData1,
378+
bytes32 p0Salt,
379+
uint8 p1MoveIndex0,
380+
uint240 p1ExtraData0,
381+
uint8 p1MoveIndex1,
382+
uint240 p1ExtraData1,
383+
bytes32 p1Salt
384+
) external {
385+
// Cache storage key
386+
bytes32 storageKey = _getStorageKey(battleKey);
387+
storageKeyForWrite = storageKey;
388+
389+
BattleConfig storage config = battleConfig[storageKey];
390+
391+
// Only moveManager can call this
392+
if (msg.sender != config.moveManager) {
393+
revert WrongCaller();
394+
}
395+
396+
// Set p0 slot 0 + slot 1 moves
397+
config.p0Move = _packMoveDecision(p0MoveIndex0, p0ExtraData0);
398+
config.p0Move2 = _packMoveDecision(p0MoveIndex1, p0ExtraData1);
399+
config.p0Salt = p0Salt;
400+
emit P0MoveSet(battleKey, uint256(p0MoveIndex0) | (uint256(p0ExtraData0) << 8));
401+
402+
// Set p1 slot 0 + slot 1 moves
403+
config.p1Move = _packMoveDecision(p1MoveIndex0, p1ExtraData0);
404+
config.p1Move2 = _packMoveDecision(p1MoveIndex1, p1ExtraData1);
405+
config.p1Salt = p1Salt;
406+
emit P1MoveSet(battleKey, uint256(p1MoveIndex0) | (uint256(p1ExtraData0) << 8));
407+
408+
// Execute (skip MovesNotSet check since we just set them)
409+
_executeInternal(battleKey, storageKey);
410+
}
411+
369412
/// @notice Internal execution logic shared by execute() and executeWithMoves()
370413
function _executeInternal(bytes32 battleKey, bytes32 storageKey) internal {
371414
// Load storage vars
@@ -2367,20 +2410,25 @@ contract Engine is IEngine, MappingAllocator {
23672410
}
23682411

23692412
/// @notice Lightweight getter for dual-signed flow that validates state and returns only needed fields
2370-
/// @dev Reverts internally if battle not started, already complete, or not a two-player turn
2413+
/// @dev Reverts if battle already complete or not a two-player turn.
2414+
/// Skips startTimestamp check (saves 2 SLOADs): if battle was never started,
2415+
/// winnerIndex defaults to 0 (not 2), so the GameAlreadyOver check catches it.
2416+
/// Returns gameMode for free since slotSwitchFlagsAndGameMode is in the same
2417+
/// storage slot as winnerIndex/playerSwitchForTurnFlag.
23712418
function getCommitAuthForDualSigned(bytes32 battleKey)
23722419
external
23732420
view
2374-
returns (address committer, address revealer, uint64 turnId)
2421+
returns (address committer, address revealer, uint64 turnId, GameMode gameMode)
23752422
{
2376-
bytes32 storageKey = _getStorageKey(battleKey);
23772423
BattleData storage data = battleData[battleKey];
2378-
BattleConfig storage config = battleConfig[storageKey];
23792424

2380-
if (config.startTimestamp == 0) revert BattleNotStarted();
2425+
// winnerIndex == 0 for never-started battles (all zeros), != 2 catches both cases
23812426
if (data.winnerIndex != 2) revert GameAlreadyOver();
23822427
if (data.playerSwitchForTurnFlag != 2) revert NotTwoPlayerTurn();
23832428

2429+
// gameMode is free: slotSwitchFlagsAndGameMode is in the same slot as winnerIndex
2430+
gameMode = (data.slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0 ? GameMode.Doubles : GameMode.Singles;
2431+
23842432
turnId = data.turnId;
23852433
if (turnId % 2 == 0) {
23862434
committer = data.p0;

src/IEngine.sol

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ interface IEngine {
3636
bytes32 p1Salt,
3737
uint240 p1ExtraData
3838
) external;
39+
function executeWithMovesForDoubles(
40+
bytes32 battleKey,
41+
uint8 p0MoveIndex0,
42+
uint240 p0ExtraData0,
43+
uint8 p0MoveIndex1,
44+
uint240 p0ExtraData1,
45+
bytes32 p0Salt,
46+
uint8 p1MoveIndex0,
47+
uint240 p1ExtraData0,
48+
uint8 p1MoveIndex1,
49+
uint240 p1ExtraData1,
50+
bytes32 p1Salt
51+
) external;
3952
function emitEngineEvent(bytes32 eventType, bytes memory extraData) external;
4053
function setUpstreamCaller(address caller) external;
4154

@@ -96,7 +109,7 @@ interface IEngine {
96109
function getCommitAuthForDualSigned(bytes32 battleKey)
97110
external
98111
view
99-
returns (address committer, address revealer, uint64 turnId);
112+
returns (address committer, address revealer, uint64 turnId, GameMode gameMode);
100113
function getDamageCalcContext(bytes32 battleKey, uint256 attackerPlayerIndex, uint256 defenderPlayerIndex)
101114
external
102115
view

src/commit-manager/SignedCommitLib.sol

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,40 @@ library SignedCommitLib {
6969
)
7070
);
7171
}
72+
73+
/// @notice Struct for the dual-signed reveal flow in doubles battles
74+
/// @dev Like DualSignedReveal but covers 2 slot moves per player.
75+
/// The committer's hash covers both their slot moves (matching revealMovePair preimage).
76+
/// The revealer signs over both their slot moves + the committer's hash.
77+
struct DualSignedRevealDoubles {
78+
bytes32 battleKey;
79+
uint64 turnId;
80+
bytes32 committerMoveHash; // hash(moveIndex0, moveIndex1, salt, extraData0, extraData1)
81+
uint8 revealerMoveIndex0; // Slot 0 move
82+
uint8 revealerMoveIndex1; // Slot 1 move
83+
bytes32 revealerSalt;
84+
uint240 revealerExtraData0; // Slot 0 extra data
85+
uint240 revealerExtraData1; // Slot 1 extra data
86+
}
87+
88+
/// @notice Hashes a DualSignedRevealDoubles struct according to EIP-712
89+
/// @param reveal The DualSignedRevealDoubles struct to hash
90+
/// @return The EIP-712 struct hash
91+
function hashDualSignedRevealDoubles(DualSignedRevealDoubles memory reveal) internal pure returns (bytes32) {
92+
return keccak256(
93+
abi.encode(
94+
keccak256(
95+
"DualSignedRevealDoubles(bytes32 battleKey,uint64 turnId,bytes32 committerMoveHash,uint8 revealerMoveIndex0,uint8 revealerMoveIndex1,bytes32 revealerSalt,uint240 revealerExtraData0,uint240 revealerExtraData1)"
96+
),
97+
reveal.battleKey,
98+
reveal.turnId,
99+
reveal.committerMoveHash,
100+
reveal.revealerMoveIndex0,
101+
reveal.revealerMoveIndex1,
102+
reveal.revealerSalt,
103+
reveal.revealerExtraData0,
104+
reveal.revealerExtraData1
105+
)
106+
);
107+
}
72108
}

src/commit-manager/SignedCommitManager.sol

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {ECDSA} from "../lib/ECDSA.sol";
77
import {SignedCommitLib} from "./SignedCommitLib.sol";
88
import {IEngine} from "../IEngine.sol";
99
import {CommitContext, PlayerDecisionData} from "../Structs.sol";
10+
import {GameMode} from "../Enums.sol";
1011

1112
/// @title SignedCommitManager
1213
/// @notice Extends DefaultCommitManager with optimistic dual-signed commit flow
@@ -41,6 +42,9 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 {
4142
/// @notice Thrown when trying to use dual-signed flow on a single-player turn
4243
error NotTwoPlayerTurn();
4344

45+
/// @notice Thrown when using singles function for a doubles battle or vice versa
46+
error WrongGameMode();
47+
4448
constructor(IEngine engine) DefaultCommitManager(engine) {}
4549

4650
/// @inheritdoc EIP712
@@ -54,7 +58,7 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 {
5458
version = "1";
5559
}
5660

57-
/// @notice Executes a turn using dual-signed moves from both players (gas-optimized)
61+
/// @notice Executes a turn using dual-signed moves from both players (singles only)
5862
/// @dev The committer (A) submits both moves. The revealer (B) has signed over
5963
/// their move and A's move hash, binding both players to their moves.
6064
/// @param battleKey The battle identifier
@@ -77,9 +81,11 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 {
7781
bytes memory revealerSignature
7882
) external {
7983
// Use lightweight getter (validates internally, reverts on bad state)
80-
(address committer, address revealer, uint64 turnId) =
84+
(address committer, address revealer, uint64 turnId, GameMode gameMode) =
8185
ENGINE.getCommitAuthForDualSigned(battleKey);
8286

87+
if (gameMode != GameMode.Singles) revert WrongGameMode();
88+
8389
// Caller must be the committing player
8490
if (msg.sender != committer) {
8591
revert CallerNotCommitter();
@@ -123,10 +129,102 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 {
123129
}
124130
}
125131

132+
/// @notice Executes a turn using dual-signed moves for doubles battles
133+
/// @dev Same security model as executeWithDualSignedMoves but each player has 2 slot moves.
134+
/// The committer's hash covers both slot moves: keccak256(moveIndex0, moveIndex1, salt, extraData0, extraData1)
135+
/// matching the revealMovePair preimage format.
136+
/// @param battleKey The battle identifier
137+
/// @param committerMoveIndex0 The committer's slot 0 move index
138+
/// @param committerExtraData0 The committer's slot 0 extra data
139+
/// @param committerMoveIndex1 The committer's slot 1 move index
140+
/// @param committerExtraData1 The committer's slot 1 extra data
141+
/// @param committerSalt The committer's salt (shared across both slots)
142+
/// @param revealerMoveIndex0 The revealer's slot 0 move index
143+
/// @param revealerExtraData0 The revealer's slot 0 extra data
144+
/// @param revealerMoveIndex1 The revealer's slot 1 move index
145+
/// @param revealerExtraData1 The revealer's slot 1 extra data
146+
/// @param revealerSalt The revealer's salt (shared across both slots)
147+
/// @param revealerSignature EIP-712 signature from the revealer over DualSignedRevealDoubles
148+
function executeWithDualSignedMovesForDoubles(
149+
bytes32 battleKey,
150+
uint8 committerMoveIndex0,
151+
uint240 committerExtraData0,
152+
uint8 committerMoveIndex1,
153+
uint240 committerExtraData1,
154+
bytes32 committerSalt,
155+
uint8 revealerMoveIndex0,
156+
uint240 revealerExtraData0,
157+
uint8 revealerMoveIndex1,
158+
uint240 revealerExtraData1,
159+
bytes32 revealerSalt,
160+
bytes memory revealerSignature
161+
) external {
162+
// Use lightweight getter (validates internally, reverts on bad state)
163+
(address committer, address revealer, uint64 turnId, GameMode gameMode) =
164+
ENGINE.getCommitAuthForDualSigned(battleKey);
165+
166+
if (gameMode != GameMode.Doubles) revert WrongGameMode();
167+
168+
// Caller must be the committing player
169+
if (msg.sender != committer) {
170+
revert CallerNotCommitter();
171+
}
172+
173+
// Compute the committer's move hash (matches revealMovePair preimage format)
174+
bytes32 committerMoveHash = keccak256(
175+
abi.encodePacked(
176+
committerMoveIndex0, committerMoveIndex1, committerSalt, committerExtraData0, committerExtraData1
177+
)
178+
);
179+
180+
// Verify the revealer's signature over DualSignedRevealDoubles
181+
SignedCommitLib.DualSignedRevealDoubles memory reveal = SignedCommitLib.DualSignedRevealDoubles({
182+
battleKey: battleKey,
183+
turnId: turnId,
184+
committerMoveHash: committerMoveHash,
185+
revealerMoveIndex0: revealerMoveIndex0,
186+
revealerMoveIndex1: revealerMoveIndex1,
187+
revealerSalt: revealerSalt,
188+
revealerExtraData0: revealerExtraData0,
189+
revealerExtraData1: revealerExtraData1
190+
});
191+
192+
bytes32 digest = _hashTypedData(SignedCommitLib.hashDualSignedRevealDoubles(reveal));
193+
if (ECDSA.recover(digest, revealerSignature) != revealer) {
194+
revert InvalidSignature();
195+
}
196+
197+
// Execute with all 4 slot moves in a single call
198+
if (turnId % 2 == 0) {
199+
// Committer is p0
200+
ENGINE.executeWithMovesForDoubles(
201+
battleKey,
202+
committerMoveIndex0, committerExtraData0,
203+
committerMoveIndex1, committerExtraData1,
204+
committerSalt,
205+
revealerMoveIndex0, revealerExtraData0,
206+
revealerMoveIndex1, revealerExtraData1,
207+
revealerSalt
208+
);
209+
} else {
210+
// Committer is p1
211+
ENGINE.executeWithMovesForDoubles(
212+
battleKey,
213+
revealerMoveIndex0, revealerExtraData0,
214+
revealerMoveIndex1, revealerExtraData1,
215+
revealerSalt,
216+
committerMoveIndex0, committerExtraData0,
217+
committerMoveIndex1, committerExtraData1,
218+
committerSalt
219+
);
220+
}
221+
}
222+
126223
/// @notice Allows anyone to publish the committer's signed commitment on-chain
127224
/// @dev This is a fallback mechanism if the committer (A) doesn't submit via
128225
/// executeWithDualSignedMoves. The revealer (B) can use this to force A's
129226
/// commitment on-chain, then proceed with the normal reveal flow.
227+
/// Works for both singles and doubles - the moveHash is format-agnostic.
130228
/// @param battleKey The battle identifier
131229
/// @param moveHash The committer's move hash
132230
/// @param committerSignature EIP-712 signature from the committer over

0 commit comments

Comments
 (0)