diff --git a/contracts/games/session-activity/README.md b/contracts/games/session-activity/README.md new file mode 100644 index 00000000..e69de29b diff --git a/contracts/games/session-activity/SessionActivity.sol b/contracts/games/session-activity/SessionActivity.sol new file mode 100644 index 00000000..96453d46 --- /dev/null +++ b/contracts/games/session-activity/SessionActivity.sol @@ -0,0 +1,67 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2 +// solhint-disable not-rely-on-time + +pragma solidity ^0.8.19; + +import {AccessControlEnumerable} from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; + +error Unauthorized(); +error ContractPaused(); + +/** + * @title SessionActivity - A simple contract that emits an event for the purpose of recording session activity on-chain + * @author Immutable + * @dev The SessionActivity contract is not designed to be upgradeable or extended. + */ +contract SessionActivity is AccessControlEnumerable, Pausable { + /// @notice Indicates that session activity has been recorded for an account + event SessionActivityRecorded(address indexed account, uint256 timestamp); + + /// @notice The name of the contract + string public name; + + /// @notice Role to allow pausing the contract + bytes32 private constant _PAUSE = keccak256("PAUSE"); + + /// @notice Role to allow unpausing the contract + bytes32 private constant _UNPAUSE = keccak256("UNPAUSE"); + + /** + * @notice Sets the DEFAULT_ADMIN, PAUSE and UNPAUSE roles + * @param _admin The address for the admin role + * @param _pauser The address for the pauser role + * @param _unpauser The address for the unpauser role + */ + constructor(address _admin, address _pauser, address _unpauser, string memory _name) { + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(_PAUSE, _pauser); + _grantRole(_UNPAUSE, _unpauser); + name = _name; + } + + /** + * @notice Pauses the contract + */ + function pause() external { + if (!hasRole(_PAUSE, msg.sender)) revert Unauthorized(); + _pause(); + } + + /** + * @notice Unpauses the contract + */ + function unpause() external { + if (!hasRole(_UNPAUSE, msg.sender)) revert Unauthorized(); + _unpause(); + } + + /** + * @notice Function that emits a `SessionActivityRecorded` event + */ + function recordSessionActivity() external { + if (paused()) revert ContractPaused(); + emit SessionActivityRecorded(msg.sender, block.timestamp); + } +} diff --git a/contracts/games/session-activity/SessionActivityDeployer.sol b/contracts/games/session-activity/SessionActivityDeployer.sol new file mode 100644 index 00000000..ff0d1e40 --- /dev/null +++ b/contracts/games/session-activity/SessionActivityDeployer.sol @@ -0,0 +1,63 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2 +// solhint-disable not-rely-on-time + +pragma solidity ^0.8.19; + +import {AccessControlEnumerable} from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import {SessionActivity} from "./SessionActivity.sol"; + +error Unauthorized(); +error NameAlreadyRegistered(); + +/** + * @title SessionActivityDeployer - A factory contract that deploys SessionActivity contracts + * @author Immutable + * @dev The SessionActivityDeployer contract is not designed to be upgradeable or extended. + */ +contract SessionActivityDeployer is AccessControlEnumerable { + /// @notice Indicates that an account has registered session activity + event SessionActivityDeployed(address indexed account, address indexed deployedContract, string indexed name); + + /// @notice Role to allow deploying SessionActivity contracts + bytes32 private constant _DEPLOYER_ROLE = keccak256("DEPLOYER"); + + /// @notice The address for the pauser role on the SessionActivity contract + address private _pauser; + + /// @notice The address for the unpauser role on the SessionActivity contract + address private _unpauser; + + /** + * @notice Sets the DEFAULT_ADMIN, PAUSE and UNPAUSE roles + * @param admin The address for the admin role + * @param deployer The address for the deployer role + * @param pauser The address for the pauser role on the SessionActivity contract + * @param unpauser The address for the unpauser role on the SessionActivity contract + */ + constructor(address admin, address deployer, address pauser, address unpauser) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(_DEPLOYER_ROLE, deployer); + _pauser = pauser; + _unpauser = unpauser; + } + + /** + * @notice Deploys a new SessionActivity contract + * @param name The name of the SessionActivity contract + * @dev Only accounts granted the _DEPLOYER_ROLE can call this function + */ + function deploy(string memory name) public returns (SessionActivity) { + // Ensure the caller has the deployer role + if (!hasRole(_DEPLOYER_ROLE, msg.sender)) revert Unauthorized(); + + // Get the existing admin role + address admin = getRoleMember(DEFAULT_ADMIN_ROLE, 0); + + // Deploy the session activity contract + SessionActivity sessionActivityContract = new SessionActivity(admin, _pauser, _unpauser, name); + emit SessionActivityDeployed(msg.sender, address(sessionActivityContract), name); + + return sessionActivityContract; + } +} diff --git a/script/games/session-activity/DeploySessionActivityDeployer.sol b/script/games/session-activity/DeploySessionActivityDeployer.sol new file mode 100644 index 00000000..be30b63b --- /dev/null +++ b/script/games/session-activity/DeploySessionActivityDeployer.sol @@ -0,0 +1,169 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import {SessionActivity} from "../../../contracts/games/session-activity/SessionActivity.sol"; +import { + SessionActivityDeployer, + Unauthorized +} from "../../../contracts/games/session-activity/SessionActivityDeployer.sol"; + +/** + * @title IDeployer Interface + * @notice This interface defines the contract responsible for deploying and optionally initializing new contracts + * via a specified deployment method. + * @dev Credit to axelarnetwork https://github.com/axelarnetwork/axelar-gmp-sdk-solidity/blob/main/contracts/interfaces/IDeployer.sol + */ +interface IDeployer { + function deploy(bytes memory bytecode, bytes32 salt) external payable returns (address deployedAddress_); + function deployAndInit(bytes memory bytecode, bytes32 salt, bytes calldata init) + external + payable + returns (address deployedAddress_); + function deployedAddress(bytes calldata bytecode, address sender, bytes32 salt) + external + view + returns (address deployedAddress_); +} + +interface IAccessControlledDeployer { + function deploy(IDeployer deployer, bytes memory bytecode, bytes32 salt) external payable returns (address); +} + +struct DeploymentArgs { + address signer; + address create3Factory; + address accessControlledDeployer; + string salt; +} + +struct SessionActivityDeployerArgs { + address admin; + address deployer; + address pauser; + address unpauser; +} + +contract DeploySessionActivityDeployer is Test { + event SessionActivityRecorded(address indexed account, uint256 timestamp); + event SessionActivityDeployed(address indexed account, address indexed deployedContract, string indexed name); + + function testDeploy() external { + /// @dev Fork the Immutable zkEVM testnet for this test + string memory rpcURL = "https://rpc.testnet.immutable.com"; + vm.createSelectFork(rpcURL); + + /// @dev These are Immutable zkEVM testnet values where necessary + DeploymentArgs memory deploymentArgs = DeploymentArgs({ + signer: 0xE4D45C0277762CaD4EC40bE69406068DAE74E17d, + create3Factory: 0xFB1Ecc73c3f3F505d66C055A3571362DE001D9C0, + accessControlledDeployer: 0x0B5B1d92259b13D516cCd5a6E63d7D94Ea2A4836, + salt: "salty" + }); + + SessionActivityDeployerArgs memory sessionActivityDeployerArgs = SessionActivityDeployerArgs({ + pauser: makeAddr("pause"), + unpauser: makeAddr("unpause"), + admin: makeAddr("admin"), + deployer: makeAddr("deployer") + }); + + // Run deployment against forked testnet + SessionActivityDeployer deployerContract = _deploy(deploymentArgs, sessionActivityDeployerArgs); + + // Assert roles are assigned correctly + assertEq(true, deployerContract.hasRole(keccak256("DEPLOYER"), sessionActivityDeployerArgs.deployer)); + assertEq( + true, deployerContract.hasRole(deployerContract.DEFAULT_ADMIN_ROLE(), sessionActivityDeployerArgs.admin) + ); + + // The DEFAULT_ADMIN_ROLE should be revoked from the deployer account and the factory contract address + assertEq(false, deployerContract.hasRole(deployerContract.DEFAULT_ADMIN_ROLE(), deploymentArgs.signer)); + assertEq(false, deployerContract.hasRole(deployerContract.DEFAULT_ADMIN_ROLE(), deploymentArgs.create3Factory)); + + // Try to deploy a contract without the deployer role expecting a revert + vm.prank(makeAddr("notdeployer")); + vm.expectRevert(Unauthorized.selector); + deployerContract.deploy("atestname"); + + // Deploy a contract with the deployer role + vm.prank(sessionActivityDeployerArgs.deployer); + vm.expectEmit(true, false, true, false); + emit SessionActivityDeployed(sessionActivityDeployerArgs.deployer, address(0), "atestname"); + SessionActivity deployedSessionActivityContract = deployerContract.deploy("atestname"); + + // Asset roles are assigned correctly on the child contract + assertEq(true, deployedSessionActivityContract.hasRole(keccak256("PAUSE"), sessionActivityDeployerArgs.pauser)); + assertEq( + true, deployedSessionActivityContract.hasRole(keccak256("UNPAUSE"), sessionActivityDeployerArgs.unpauser) + ); + assertEq( + true, + deployedSessionActivityContract.hasRole( + deployedSessionActivityContract.DEFAULT_ADMIN_ROLE(), sessionActivityDeployerArgs.admin + ) + ); + + // Record a session activity + vm.expectEmit(true, true, true, false); + emit SessionActivityRecorded(address(this), block.timestamp); + deployedSessionActivityContract.recordSessionActivity(); + } + + function deploy() external { + address signer = vm.envAddress("SIGNER_ADDRESS"); + address create3Factory = vm.envAddress("OWNABLE_CREATE3_FACTORY_ADDRESS"); + address accessControlledDeployer = vm.envAddress("ACCESS_CONTROLLED_DEPLOYER_ADDRESS"); + string memory salt = vm.envString("SESSION_ACTIVITY_DEPLOYER_SALT"); + + DeploymentArgs memory deploymentArgs = DeploymentArgs({ + signer: signer, + create3Factory: create3Factory, + salt: salt, + accessControlledDeployer: accessControlledDeployer + }); + + address defaultAdmin = vm.envAddress("DEFAULT_ADMIN"); + address deployer = vm.envAddress("DEPLOYER"); + address pauser = vm.envAddress("PAUSER"); + address unpauser = vm.envAddress("UNPAUSER"); + + SessionActivityDeployerArgs memory sessionActivityDeployerArgs = + SessionActivityDeployerArgs({admin: defaultAdmin, deployer: deployer, pauser: pauser, unpauser: unpauser}); + + _deploy(deploymentArgs, sessionActivityDeployerArgs); + } + + function _deploy( + DeploymentArgs memory deploymentArgs, + SessionActivityDeployerArgs memory sessionActivityDeployerArgs + ) internal returns (SessionActivityDeployer sessionActivityDeployerContract) { + IAccessControlledDeployer accessControlledDeployer = + IAccessControlledDeployer(deploymentArgs.accessControlledDeployer); + IDeployer create3Factory = IDeployer(deploymentArgs.create3Factory); + + // Create deployment bytecode and encode constructor args + bytes memory deploymentBytecode = abi.encodePacked( + type(SessionActivityDeployer).creationCode, + abi.encode( + sessionActivityDeployerArgs.admin, + sessionActivityDeployerArgs.deployer, + sessionActivityDeployerArgs.pauser, + sessionActivityDeployerArgs.unpauser + ) + ); + + bytes32 saltBytes = keccak256(abi.encode(deploymentArgs.salt)); + + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + + address sessionActivityDeployerAddress = + accessControlledDeployer.deploy(create3Factory, deploymentBytecode, saltBytes); + sessionActivityDeployerContract = SessionActivityDeployer(sessionActivityDeployerAddress); + + vm.stopBroadcast(); + } +}