From 994d919b95db288f2d40f2d85bf7fdfddf4b7760 Mon Sep 17 00:00:00 2001 From: deif Date: Wed, 21 Jan 2026 14:05:44 +0000 Subject: [PATCH 01/13] add session module --- contracts/Session/GameRegistry.sol | 39 ++ contracts/Session/UsageBasedSessionModule.sol | 100 +++++ contracts/interfaces/IGameRegistry.sol | 11 + contracts/interfaces/external/Enum.sol | 13 + contracts/interfaces/external/ISafe.sol | 26 ++ data/abi/GameRegistry.json | 306 +++++++++++++++ data/abi/UsageBasedSessionModule.json | 365 ++++++++++++++++++ pnpm-lock.yaml | 13 + 8 files changed, 873 insertions(+) create mode 100644 contracts/Session/GameRegistry.sol create mode 100644 contracts/Session/UsageBasedSessionModule.sol create mode 100644 contracts/interfaces/IGameRegistry.sol create mode 100644 contracts/interfaces/external/Enum.sol create mode 100644 contracts/interfaces/external/ISafe.sol create mode 100644 data/abi/GameRegistry.json create mode 100644 data/abi/UsageBasedSessionModule.json diff --git a/contracts/Session/GameRegistry.sol b/contracts/Session/GameRegistry.sol new file mode 100644 index 00000000..f6522fa1 --- /dev/null +++ b/contracts/Session/GameRegistry.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {IGameRegistry} from "../interfaces/IGameRegistry.sol"; + +contract GameRegistry is UUPSUpgradeable, OwnableUpgradeable, IGameRegistry { + // Group 0 = Disabled, Group 1 = Basic, Group 2 = Combat, etc. + mapping(address => mapping(bytes4 => uint256)) private _functionToLimitGroup; + mapping(uint256 => uint256) private _groupDailyLimits; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address owner) public initializer { + __Ownable_init(owner); + } + + function functionToLimitGroup(address _contract, bytes4 _selector) external view override returns (uint256) { + return _functionToLimitGroup[_contract][_selector]; + } + + function groupDailyLimits(uint256 _groupId) external view override returns (uint256) { + return _groupDailyLimits[_groupId]; + } + + function setFunctionGroup(address _contract, bytes4 _selector, uint256 _groupId) external override onlyOwner { + _functionToLimitGroup[_contract][_selector] = _groupId; + } + + function setGroupLimit(uint256 _groupId, uint256 _limit) external override onlyOwner { + _groupDailyLimits[_groupId] = _limit; + } + + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/Session/UsageBasedSessionModule.sol b/contracts/Session/UsageBasedSessionModule.sol new file mode 100644 index 00000000..a6401df2 --- /dev/null +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Enum} from "../interfaces/external/Enum.sol"; +import {ISafe} from "../interfaces/external/ISafe.sol"; +import {IGameRegistry} from "../interfaces/IGameRegistry.sol"; + +/// @title UsageBasedSessionModule +/// @notice A module for Gnosis Safe that allows for session keys with rate-limited actions +contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable { + error ExistingSessionActive(); + error NoSessionKey(); + error ActionNotPermitted(); + error GroupLimitReached(); + error InvalidSignature(); + error SessionExpired(); + + event SessionEnabled(address indexed safe, address indexed sessionKey, uint48 deadline); + + struct UserUsage { + // Day => GroupID => Count + mapping(uint256 => mapping(uint256 => uint256)) epochGroupCounts; + } + + struct Session { + address sessionKey; + uint48 deadline; + } + + IGameRegistry public registry; + mapping(address => Session) public sessions; // Safe => Session + mapping(address => UserUsage) private usage; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address owner, IGameRegistry _registry) public initializer { + __Ownable_init(owner); + registry = _registry; + } + + /** + * @notice Enables a session. Must be called BY THE SAFE + */ + function enableSession(address _sessionKey, uint48 _duration) external { + require(sessions[msg.sender].deadline < block.timestamp, ExistingSessionActive()); + + sessions[msg.sender] = Session({sessionKey: _sessionKey, deadline: uint48(block.timestamp) + _duration}); + + emit SessionEnabled(msg.sender, _sessionKey, sessions[msg.sender].deadline); + } + + function execute(address safe, address target, bytes calldata data, bytes calldata signature) external { + // 1. Basic Session Check + require(sessions[safe].sessionKey != address(0), NoSessionKey()); + require(sessions[safe].deadline >= block.timestamp, SessionExpired()); + + // 2. Identify the action (extract selector from data) + bytes4 selector = bytes4(data[0:4]); + uint256 groupId = registry.functionToLimitGroup(target, selector); + require(groupId > 0, ActionNotPermitted()); + + uint256 currentDay = block.timestamp / 1 days; + UserUsage storage user = usage[safe]; + uint256 currentUsage = user.epochGroupCounts[currentDay][groupId]; + + uint256 limit = registry.groupDailyLimits(groupId); + require(currentUsage < limit, GroupLimitReached()); + + // 3. Increment for TODAY + user.epochGroupCounts[currentDay][groupId] = currentUsage + 1; + + // 4. Verify Signature & Execute via Safe + bytes32 msgHash = keccak256(abi.encodePacked(safe, target, data)); + require(recoverSigner(msgHash, signature) == sessions[safe].sessionKey, InvalidSignature()); + + ISafe(safe).execTransactionFromModule(target, 0, data, Enum.Operation.Call); + } + + // Standard ECDSA recovery helper + function recoverSigner(bytes32 _hash, bytes memory _sig) internal pure returns (address) { + bytes32 r; + bytes32 s; + uint8 v; + if (_sig.length != 65) return address(0); + assembly { + r := mload(add(_sig, 32)) + s := mload(add(_sig, 64)) + v := byte(0, mload(add(_sig, 96))) + } + if (v < 27) v += 27; + return ecrecover(_hash, v, r, s); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/interfaces/IGameRegistry.sol b/contracts/interfaces/IGameRegistry.sol new file mode 100644 index 00000000..f3fdb176 --- /dev/null +++ b/contracts/interfaces/IGameRegistry.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IGameRegistry { + + function functionToLimitGroup(address _contract, bytes4 _selector) external view returns (uint256); + function groupDailyLimits(uint256 _groupId) external view returns (uint256); + + function setFunctionGroup(address _contract, bytes4 _selector, uint256 _groupId) external; + function setGroupLimit(uint256 _groupId, uint256 _limit) external; +} diff --git a/contracts/interfaces/external/Enum.sol b/contracts/interfaces/external/Enum.sol new file mode 100644 index 00000000..defd8927 --- /dev/null +++ b/contracts/interfaces/external/Enum.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity >=0.7.0 <0.9.0; + +/** + * @title Enum - Collection of enums used in Safe contracts. + * @author Richard Meissner - @rmeissner + */ +abstract contract Enum { + enum Operation { + Call, + DelegateCall + } +} \ No newline at end of file diff --git a/contracts/interfaces/external/ISafe.sol b/contracts/interfaces/external/ISafe.sol new file mode 100644 index 00000000..2bab50a8 --- /dev/null +++ b/contracts/interfaces/external/ISafe.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity >=0.7.0 <0.9.0; +import {Enum} from "./Enum.sol"; + +/** + * @title Safe Interface + * @notice A multi-signature wallet with support for confirmations using signed messages based on EIP-712. + * @dev This is a Solidity interface definition to the Safe account. + * @author @safe-global/safe-protocol + */ +interface ISafe { + /** + * @notice Execute `operation` to `to` with native token `value`. + * @param to Destination address of the module transaction. + * @param value Native token value of the module transaction. + * @param data Data payload of the module transaction. + * @param operation Operation type of the module transaction: 0 for `CALL` and 1 for `DELEGATECALL`. + * @return success Boolean flag indicating if the call succeeded. + */ + function execTransactionFromModule( + address to, + uint256 value, + bytes memory data, + Enum.Operation operation + ) external returns (bool success); +} \ No newline at end of file diff --git a/data/abi/GameRegistry.json b/data/abi/GameRegistry.json new file mode 100644 index 00000000..209dc86b --- /dev/null +++ b/data/abi/GameRegistry.json @@ -0,0 +1,306 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "UPGRADE_INTERFACE_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_contract", + "type": "address" + }, + { + "internalType": "bytes4", + "name": "_selector", + "type": "bytes4" + } + ], + "name": "functionToLimitGroup", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_groupId", + "type": "uint256" + } + ], + "name": "groupDailyLimits", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_contract", + "type": "address" + }, + { + "internalType": "bytes4", + "name": "_selector", + "type": "bytes4" + }, + { + "internalType": "uint256", + "name": "_groupId", + "type": "uint256" + } + ], + "name": "setFunctionGroup", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_groupId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_limit", + "type": "uint256" + } + ], + "name": "setGroupLimit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + } +] diff --git a/data/abi/UsageBasedSessionModule.json b/data/abi/UsageBasedSessionModule.json new file mode 100644 index 00000000..4673c113 --- /dev/null +++ b/data/abi/UsageBasedSessionModule.json @@ -0,0 +1,365 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "ActionNotPermitted", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "ExistingSessionActive", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "GroupLimitReached", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidSignature", + "type": "error" + }, + { + "inputs": [], + "name": "NoSessionKey", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "SessionExpired", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sessionKey", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint48", + "name": "deadline", + "type": "uint48" + } + ], + "name": "SessionEnabled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "UPGRADE_INTERFACE_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_sessionKey", + "type": "address" + }, + { + "internalType": "uint48", + "name": "_duration", + "type": "uint48" + } + ], + "name": "enableSession", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "name": "execute", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "contract IGameRegistry", + "name": "_registry", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "registry", + "outputs": [ + { + "internalType": "contract IGameRegistry", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "sessions", + "outputs": [ + { + "internalType": "address", + "name": "sessionKey", + "type": "address" + }, + { + "internalType": "uint48", + "name": "deadline", + "type": "uint48" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + } +] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9dde980..a48b7d12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: "@safe-global/protocol-kit": specifier: ^6.1.2 version: 6.1.2(typescript@5.9.3)(zod@3.25.76) + "@safe-global/safe-contracts": + specifier: 1.4.1-2 + version: 1.4.1-2(ethers@6.15.0) "@safe-global/types-kit": specifier: ^3.0.0 version: 3.0.0(typescript@5.9.3)(zod@3.25.76) @@ -1030,6 +1033,12 @@ packages: resolution: {integrity: sha512-cTpPdUAS2AMfGCkD1T601rQNjT0rtMQLA2TH7L/C+iFPAC6WrrDFop2B9lzeHjczlnVzrRpfFe4cL1bLrJ9NZw==} + "@safe-global/safe-contracts@1.4.1-2": + resolution: + {integrity: sha512-UFiqZOamt1lDbyQ16lpzBE8mDdzfnQ3OBftll1erLlwIrdfmhePicQqfLquvyXA8AlqPWQ3ktz1DzpC9UcN+JQ==} + peerDependencies: + ethers: 5.4.0 + "@safe-global/safe-deployments@1.37.49": resolution: {integrity: sha512-132QgqMY1/HktXqmda/uPp5b+73UXTgKRB00Xgc1kduFqceSw/ZyF1Q9jJjbND9q91hhapnXhYKWN2/HiWkRcg==} @@ -5922,6 +5931,10 @@ snapshots: - utf-8-validate - zod + "@safe-global/safe-contracts@1.4.1-2(ethers@6.15.0)": + dependencies: + ethers: 6.15.0 + "@safe-global/safe-deployments@1.37.49": dependencies: semver: 7.7.3 From ba17dd6a20c0997eaa835e85c2f0166ae8282a5f Mon Sep 17 00:00:00 2001 From: deif Date: Wed, 21 Jan 2026 20:41:29 +0000 Subject: [PATCH 02/13] add unit tests for session module --- .github/account-abstraction.instructions.md | 16 + .github/copilot-instructions.md | 1 + ...stry.sol => GameSubsidisationRegistry.sol} | 4 +- contracts/Session/UsageBasedSessionModule.sol | 97 ++-- ...try.sol => IGameSubsidisationRegistry.sol} | 2 +- contracts/test/Session/TestSessionHelpers.sol | 53 ++ ...ry.json => GameSubsidisationRegistry.json} | 0 data/abi/UsageBasedSessionModule.json | 133 ++++- scripts/deploy.ts | 23 + scripts/verifyContracts.ts | 12 +- test/Players/PlayersFixture.ts | 17 + test/Session/GameSubsidisationRegistry.ts | 68 +++ test/Session/UsageBasedSessionModule.ts | 470 ++++++++++++++++++ 13 files changed, 860 insertions(+), 36 deletions(-) create mode 100644 .github/account-abstraction.instructions.md rename contracts/Session/{GameRegistry.sol => GameSubsidisationRegistry.sol} (87%) rename contracts/interfaces/{IGameRegistry.sol => IGameSubsidisationRegistry.sol} (91%) create mode 100644 contracts/test/Session/TestSessionHelpers.sol rename data/abi/{GameRegistry.json => GameSubsidisationRegistry.json} (100%) create mode 100644 test/Session/GameSubsidisationRegistry.ts create mode 100644 test/Session/UsageBasedSessionModule.ts diff --git a/.github/account-abstraction.instructions.md b/.github/account-abstraction.instructions.md new file mode 100644 index 00000000..f4ccac48 --- /dev/null +++ b/.github/account-abstraction.instructions.md @@ -0,0 +1,16 @@ +--- +applyTo: "contracts/Session/*.sol" +--- + +# Estfor Account Abstraction AI Guide + +- Aim of the Session module is to abstract Web3 accounts away so that any user with an email and a passkey can sign transactions without paying for gas or require signing multiple transactions. The architecture for achieving this is as follows: + +1. User registers with an email and passkey +2. A 2 of 3 multi-sig Safe is created where 1 signer is the user passkey, 1 signer is a recovery DAO owned multi-sig Safe, 1 signer is a hot DAO owned EOA that exists to execute and sign transactions on behalf of the user to subsidise the gas cost. +3. User authenticates with their passkey to create a new session - `UsageBasedSessionModule.enableSession` ([contracts/Session/UsageBasedSessionModule.sol](contracts/Session/UsageBasedSessionModule.sol)). The session key passed is a temporary throwaway private key stored in the users browser/device for the duration set. +4. User uses their session private key to sign game transactions, then passes the arguments via an api to the hot DAO EOA signer that will call `UsageBasedSessionModule.execute`, and thus the designated game action. + +- The `UsageBasedSessionModule` contains the logic to restrict overuse and needless gas expense via the subsidised mechanism. +- `GameRegistry` ([contracts/Session/GameRegistry.sol](contracts/Session/GameRegistry.sol)) contract contains all valid game actions that can be subsidised by the session module. +- Safe module documentation can be found at https://docs.safe.global/advanced/smart-account-modules diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 60a9b90e..e946ff4c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,5 +24,6 @@ - Do NOT add getter functions for mappings or arrays. Instead, emit events when data is added/updated and read data off-chain via events or direct storage reads. Contract size is more important than on-chain convenience functions. - Always follow Checks-Effects-Interactions pattern to prevent reentrancy issues. Update contract state before making any external calls. - Contract owners are expected to be Gnosis Safe multisigs. There are some scripts that may still use single EOA accounts as they haven't been updated yet. For new scripts, prefer the proposal pattern using `prepareUpgrade` and use the util function `sendTransactionSetToSafe` in `scripts/utils.ts`. +- Use openzeppelin libraries for common functionalities like ERC standards, access control, upgradeability, and security features. Avoid reinventing the wheel. If anything here feels off or incomplete, tell me what to clarify or expand. diff --git a/contracts/Session/GameRegistry.sol b/contracts/Session/GameSubsidisationRegistry.sol similarity index 87% rename from contracts/Session/GameRegistry.sol rename to contracts/Session/GameSubsidisationRegistry.sol index f6522fa1..ccab605a 100644 --- a/contracts/Session/GameRegistry.sol +++ b/contracts/Session/GameSubsidisationRegistry.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.28; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import {IGameRegistry} from "../interfaces/IGameRegistry.sol"; +import {IGameSubsidisationRegistry} from "../interfaces/IGameSubsidisationRegistry.sol"; -contract GameRegistry is UUPSUpgradeable, OwnableUpgradeable, IGameRegistry { +contract GameSubsidisationRegistry is UUPSUpgradeable, OwnableUpgradeable, IGameSubsidisationRegistry { // Group 0 = Disabled, Group 1 = Basic, Group 2 = Combat, etc. mapping(address => mapping(bytes4 => uint256)) private _functionToLimitGroup; mapping(uint256 => uint256) private _groupDailyLimits; diff --git a/contracts/Session/UsageBasedSessionModule.sol b/contracts/Session/UsageBasedSessionModule.sol index a6401df2..a478f680 100644 --- a/contracts/Session/UsageBasedSessionModule.sol +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -3,25 +3,42 @@ pragma solidity ^0.8.28; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {EIP712Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {Enum} from "../interfaces/external/Enum.sol"; import {ISafe} from "../interfaces/external/ISafe.sol"; -import {IGameRegistry} from "../interfaces/IGameRegistry.sol"; +import {IGameSubsidisationRegistry} from "../interfaces/IGameSubsidisationRegistry.sol"; /// @title UsageBasedSessionModule /// @notice A module for Gnosis Safe that allows for session keys with rate-limited actions -contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable { +contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712Upgradeable { error ExistingSessionActive(); error NoSessionKey(); error ActionNotPermitted(); error GroupLimitReached(); error InvalidSignature(); error SessionExpired(); + error InvalidSessionDuration(); + error ZeroSessionKey(); + error InvalidCallData(); + error ModuleCallFailed(); event SessionEnabled(address indexed safe, address indexed sessionKey, uint48 deadline); + event SessionRevoked(address indexed safe); + + uint48 public constant MAX_SESSION_DURATION = 30 days; + bytes32 private constant SESSION_TYPEHASH = keccak256( + "UsageBasedSession(address safe,address target,bytes data,uint256 nonce,uint48 sessionDeadline,address module,uint256 chainId)" + ); + + struct GroupUsage { + uint40 day; // day number (UTC) + uint40 count; // usage count for that day + } struct UserUsage { - // Day => GroupID => Count - mapping(uint256 => mapping(uint256 => uint256)) epochGroupCounts; + mapping(uint256 => GroupUsage) groupUsage; // GroupID => usage for current day + uint256 nonce; } struct Session { @@ -29,17 +46,18 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable { uint48 deadline; } - IGameRegistry public registry; + IGameSubsidisationRegistry public registry; mapping(address => Session) public sessions; // Safe => Session - mapping(address => UserUsage) private usage; + mapping(address => UserUsage) private usage; // Safe => Usage /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } - function initialize(address owner, IGameRegistry _registry) public initializer { + function initialize(address owner, IGameSubsidisationRegistry _registry) public initializer { __Ownable_init(owner); + __EIP712_init("UsageBasedSessionModule", "1"); registry = _registry; } @@ -47,6 +65,8 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable { * @notice Enables a session. Must be called BY THE SAFE */ function enableSession(address _sessionKey, uint48 _duration) external { + require(_sessionKey != address(0), ZeroSessionKey()); + require(_duration > 0 && _duration <= MAX_SESSION_DURATION, InvalidSessionDuration()); require(sessions[msg.sender].deadline < block.timestamp, ExistingSessionActive()); sessions[msg.sender] = Session({sessionKey: _sessionKey, deadline: uint48(block.timestamp) + _duration}); @@ -54,10 +74,21 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable { emit SessionEnabled(msg.sender, _sessionKey, sessions[msg.sender].deadline); } + /** + * @notice Explicitly revoke the current session early. Must be called BY THE SAFE + */ + function revokeSession() external { + delete sessions[msg.sender]; + emit SessionRevoked(msg.sender); + } + function execute(address safe, address target, bytes calldata data, bytes calldata signature) external { + require(data.length >= 4, InvalidCallData()); + // 1. Basic Session Check - require(sessions[safe].sessionKey != address(0), NoSessionKey()); - require(sessions[safe].deadline >= block.timestamp, SessionExpired()); + Session memory session = sessions[safe]; + require(session.sessionKey != address(0), NoSessionKey()); + require(session.deadline >= block.timestamp, SessionExpired()); // 2. Identify the action (extract selector from data) bytes4 selector = bytes4(data[0:4]); @@ -66,34 +97,40 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable { uint256 currentDay = block.timestamp / 1 days; UserUsage storage user = usage[safe]; - uint256 currentUsage = user.epochGroupCounts[currentDay][groupId]; + GroupUsage storage group = user.groupUsage[groupId]; + if (group.day != uint40(currentDay)) { + group.day = uint40(currentDay); + group.count = 0; + } + uint256 currentUsage = group.count; uint256 limit = registry.groupDailyLimits(groupId); require(currentUsage < limit, GroupLimitReached()); + uint256 currentNonce = user.nonce; + bytes32 digest = _hashTypedDataV4( + keccak256( + abi.encode( + SESSION_TYPEHASH, + safe, + target, + keccak256(data), + currentNonce, + session.deadline, + address(this), + block.chainid + ) + ) + ); + require(ECDSA.recover(digest, signature) == session.sessionKey, InvalidSignature()); + // 3. Increment for TODAY - user.epochGroupCounts[currentDay][groupId] = currentUsage + 1; + user.nonce = currentNonce + 1; + group.count = uint40(currentUsage + 1); // 4. Verify Signature & Execute via Safe - bytes32 msgHash = keccak256(abi.encodePacked(safe, target, data)); - require(recoverSigner(msgHash, signature) == sessions[safe].sessionKey, InvalidSignature()); - - ISafe(safe).execTransactionFromModule(target, 0, data, Enum.Operation.Call); - } - - // Standard ECDSA recovery helper - function recoverSigner(bytes32 _hash, bytes memory _sig) internal pure returns (address) { - bytes32 r; - bytes32 s; - uint8 v; - if (_sig.length != 65) return address(0); - assembly { - r := mload(add(_sig, 32)) - s := mload(add(_sig, 64)) - v := byte(0, mload(add(_sig, 96))) - } - if (v < 27) v += 27; - return ecrecover(_hash, v, r, s); + bool success = ISafe(safe).execTransactionFromModule(target, 0, data, Enum.Operation.Call); + require(success, ModuleCallFailed()); } function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} diff --git a/contracts/interfaces/IGameRegistry.sol b/contracts/interfaces/IGameSubsidisationRegistry.sol similarity index 91% rename from contracts/interfaces/IGameRegistry.sol rename to contracts/interfaces/IGameSubsidisationRegistry.sol index f3fdb176..af82e52b 100644 --- a/contracts/interfaces/IGameRegistry.sol +++ b/contracts/interfaces/IGameSubsidisationRegistry.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -interface IGameRegistry { +interface IGameSubsidisationRegistry { function functionToLimitGroup(address _contract, bytes4 _selector) external view returns (uint256); function groupDailyLimits(uint256 _groupId) external view returns (uint256); diff --git a/contracts/test/Session/TestSessionHelpers.sol b/contracts/test/Session/TestSessionHelpers.sol new file mode 100644 index 00000000..e13e23a6 --- /dev/null +++ b/contracts/test/Session/TestSessionHelpers.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Enum} from "../../interfaces/external/Enum.sol"; +import {UsageBasedSessionModule} from "../../Session/UsageBasedSessionModule.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + +/// @notice Minimal Safe-compatible mock that can enable sessions and forward calls +contract TestSessionSafe is ERC1155Holder { + address public immutable owner; + + constructor(address _owner) { + owner = _owner; + } + + function callEnableSession(UsageBasedSessionModule module, address sessionKey, uint48 duration) external { + require(msg.sender == owner, "Not owner"); + module.enableSession(sessionKey, duration); + } + + function execTransactionFromModule(address to, uint256 value, bytes calldata data, Enum.Operation operation) + external + returns (bool success) + { + require(operation == Enum.Operation.Call, "Unsupported operation"); + bytes memory returnData; + (success, returnData) = to.call{value: value}(data); + // Bubble up revert reason for debugging + // if (!success) { + // assembly { + // revert(add(returnData, 32), mload(returnData)) + // } + // } + } +} + +/// @notice Simple target used to test session execution +contract TestSessionTarget { + uint256 public calls; + + event Called(address indexed caller, uint256 newCount); + + function doAction() external { + calls += 1; + emit Called(msg.sender, calls); + } +} + +contract TestSessionRevertingTarget { + function revertAction() external pure { + revert("TargetReverted"); + } +} \ No newline at end of file diff --git a/data/abi/GameRegistry.json b/data/abi/GameSubsidisationRegistry.json similarity index 100% rename from data/abi/GameRegistry.json rename to data/abi/GameSubsidisationRegistry.json diff --git a/data/abi/UsageBasedSessionModule.json b/data/abi/UsageBasedSessionModule.json index 4673c113..717ff255 100644 --- a/data/abi/UsageBasedSessionModule.json +++ b/data/abi/UsageBasedSessionModule.json @@ -20,6 +20,33 @@ "name": "AddressEmptyCode", "type": "error" }, + { + "inputs": [], + "name": "ECDSAInvalidSignature", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "length", + "type": "uint256" + } + ], + "name": "ECDSAInvalidSignatureLength", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "ECDSAInvalidSignatureS", + "type": "error" + }, { "inputs": [ { @@ -51,16 +78,31 @@ "name": "GroupLimitReached", "type": "error" }, + { + "inputs": [], + "name": "InvalidCallData", + "type": "error" + }, { "inputs": [], "name": "InvalidInitialization", "type": "error" }, + { + "inputs": [], + "name": "InvalidSessionDuration", + "type": "error" + }, { "inputs": [], "name": "InvalidSignature", "type": "error" }, + { + "inputs": [], + "name": "ModuleCallFailed", + "type": "error" + }, { "inputs": [], "name": "NoSessionKey", @@ -114,6 +156,17 @@ "name": "UUPSUnsupportedProxiableUUID", "type": "error" }, + { + "inputs": [], + "name": "ZeroSessionKey", + "type": "error" + }, + { + "anonymous": false, + "inputs": [], + "name": "EIP712DomainChanged", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -171,6 +224,19 @@ "name": "SessionEnabled", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + } + ], + "name": "SessionRevoked", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -184,6 +250,19 @@ "name": "Upgraded", "type": "event" }, + { + "inputs": [], + "name": "MAX_SESSION_DURATION", + "outputs": [ + { + "internalType": "uint48", + "name": "", + "type": "uint48" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "UPGRADE_INTERFACE_VERSION", @@ -197,6 +276,49 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "eip712Domain", + "outputs": [ + { + "internalType": "bytes1", + "name": "fields", + "type": "bytes1" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "version", + "type": "string" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "verifyingContract", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + }, + { + "internalType": "uint256[]", + "name": "extensions", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -251,7 +373,7 @@ "type": "address" }, { - "internalType": "contract IGameRegistry", + "internalType": "contract IGameSubsidisationRegistry", "name": "_registry", "type": "address" } @@ -292,7 +414,7 @@ "name": "registry", "outputs": [ { - "internalType": "contract IGameRegistry", + "internalType": "contract IGameSubsidisationRegistry", "name": "", "type": "address" } @@ -307,6 +429,13 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "revokeSession", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 59157223..b6cdf2ab 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -43,6 +43,8 @@ import { ActivityPoints, Cosmetics, GlobalEvents, + GameSubsidisationRegistry, + UsageBasedSessionModule, } from "../typechain-types"; import { deployMockPaintSwapContracts, @@ -914,6 +916,27 @@ async function main() { await combatantsHelper.waitForDeployment(); console.log(`combatantsHelper = "${(await combatantsHelper.getAddress()).toLowerCase()}"`); + // add Safe session module + const GameSubsidisationRegistry = await ethers.getContractFactory("GameSubsidisationRegistry"); + const gameSubsidisationRegistry = (await upgrades.deployProxy(GameSubsidisationRegistry, [owner.address], { + kind: "uups", + timeout, + })) as unknown as GameSubsidisationRegistry; + await gameSubsidisationRegistry.waitForDeployment(); + console.log(`gameSubsidisationRegistry = "${(await gameSubsidisationRegistry.getAddress()).toLowerCase()}"`); + + const UsageBasedSessionModule = await ethers.getContractFactory("UsageBasedSessionModule"); + const usageBasedSessionModule = (await upgrades.deployProxy( + UsageBasedSessionModule, + [owner.address, await gameSubsidisationRegistry.getAddress()], + { + kind: "uups", + timeout, + } + )) as unknown as UsageBasedSessionModule; + await usageBasedSessionModule.waitForDeployment(); + console.log(`usageBasedSessionModule = "${(await usageBasedSessionModule.getAddress()).toLowerCase()}"`); + await upgrades.upgradeProxy(await clans.getAddress(), Clans, { call: {fn: "initializeV2", args: [await combatantsHelper.getAddress()]}, unsafeAllow: ["external-library-linking"], diff --git a/scripts/verifyContracts.ts b/scripts/verifyContracts.ts index 90f75bcf..02922ac5 100644 --- a/scripts/verifyContracts.ts +++ b/scripts/verifyContracts.ts @@ -44,6 +44,8 @@ import { WISHING_WELL_ADDRESS, VRF_ADDRESS, CLAN_BATTLE_LIBRARY_ADDRESS, + GLOBAL_EVENT_ADDRESS, + COSMETICS_ADDRESS, } from "./contractAddresses"; import {verifyContract, verifyContracts} from "./utils"; @@ -104,8 +106,16 @@ async function main() { */ // await verifyContracts(["0x9f76DE2260CF0E2c08CDF0628E7f00b03c37b861"] /* addresses */, [[VRF_ADDRESS]]); + // await verifyContracts([PLAYER_NFT_ADDRESS]); + await verifyContracts([PLAYERS_ADDRESS]); await verifyContracts([PLAYER_NFT_ADDRESS]); - await verifyContracts([PET_NFT_ADDRESS]); + await verifyContracts([COSMETICS_ADDRESS]); + await verifyContracts([PLAYERS_IMPL_MISC1_ADDRESS]); + await verifyContracts([PLAYERS_IMPL_MISC_ADDRESS]); + await verifyContracts([PLAYERS_IMPL_PROCESS_ACTIONS_ADDRESS]); + await verifyContracts([PLAYERS_IMPL_QUEUE_ACTIONS_ADDRESS]); + await verifyContracts([PLAYERS_IMPL_REWARDS_ADDRESS]); + // await verifyContracts([GLOBAL_EVENT_ADDRESS]); } main().catch((error) => { diff --git a/test/Players/PlayersFixture.ts b/test/Players/PlayersFixture.ts index 6b6ad4a0..4dd6f6a8 100644 --- a/test/Players/PlayersFixture.ts +++ b/test/Players/PlayersFixture.ts @@ -39,6 +39,8 @@ import { Bridge, ActivityPoints, GlobalEvents, + GameSubsidisationRegistry, + UsageBasedSessionModule, } from "../../typechain-types"; import {MAX_TIME} from "../utils"; import {allTerritories, allBattleSkills} from "../../scripts/data/territories"; @@ -442,6 +444,19 @@ export const playersFixture = async function () { } )) as unknown as PVPBattleground; + const GameSubsidisationRegistry = await ethers.getContractFactory("GameSubsidisationRegistry"); + const gameSubsidisationRegistry = (await upgrades.deployProxy(GameSubsidisationRegistry, [owner.address], { + kind: "uups", + })) as unknown as GameSubsidisationRegistry; + const UsageBasedSessionModule = await ethers.getContractFactory("UsageBasedSessionModule"); + const usageBasedSessionModule = (await upgrades.deployProxy( + UsageBasedSessionModule, + [owner.address, await gameSubsidisationRegistry.getAddress()], + { + kind: "uups", + } + )) as unknown as UsageBasedSessionModule; + const spawnRaidCooldown = 8 * 3600; // 8 hours const maxRaidCombatants = 20; const raidCombatActionIds = [ @@ -852,5 +867,7 @@ export const playersFixture = async function () { cosmeticId, cosmeticInfo, globalEvents, + gameSubsidisationRegistry, + usageBasedSessionModule, }; }; diff --git a/test/Session/GameSubsidisationRegistry.ts b/test/Session/GameSubsidisationRegistry.ts new file mode 100644 index 00000000..f00e034b --- /dev/null +++ b/test/Session/GameSubsidisationRegistry.ts @@ -0,0 +1,68 @@ +import {loadFixture} from "@nomicfoundation/hardhat-network-helpers"; +import {expect} from "chai"; +import {ethers} from "hardhat"; +import {GameSubsidisationRegistry} from "../../typechain-types"; +import {playersFixture} from "../Players/PlayersFixture"; + +describe("GameSubsidisationRegistry", function () { + async function deployContracts() { + const baseFixture = await loadFixture(playersFixture); + return {...baseFixture}; + } + + it("allows the owner to set and read function groups", async () => { + const {gameSubsidisationRegistry, owner}: {gameSubsidisationRegistry: GameSubsidisationRegistry; owner: any} = + await loadFixture(deployContracts); + + const selector = ethers.id("doThing()").slice(0, 10); + await gameSubsidisationRegistry.setFunctionGroup(owner.address, selector, 2); + + expect(await gameSubsidisationRegistry.functionToLimitGroup(owner.address, selector)).to.eq(2); + }); + + it("blocks non-owners from setting function groups", async () => { + const {gameSubsidisationRegistry, alice}: {gameSubsidisationRegistry: GameSubsidisationRegistry; alice: any} = + await loadFixture(deployContracts); + + const selector = ethers.id("doThing()").slice(0, 10); + + await expect( + gameSubsidisationRegistry.connect(alice).setFunctionGroup(alice.address, selector, 1) + ).to.be.revertedWithCustomError(gameSubsidisationRegistry, "OwnableUnauthorizedAccount"); + }); + + it("allows the owner to set and read group limits", async () => { + const {gameSubsidisationRegistry, owner}: {gameSubsidisationRegistry: GameSubsidisationRegistry; owner: any} = + await loadFixture(deployContracts); + + await gameSubsidisationRegistry.setGroupLimit(1, 5); + + expect(await gameSubsidisationRegistry.groupDailyLimits(1)).to.eq(5); + }); + + it("blocks non-owners from setting group limits", async () => { + const {gameSubsidisationRegistry, alice}: {gameSubsidisationRegistry: GameSubsidisationRegistry; alice: any} = + await loadFixture(deployContracts); + + await expect(gameSubsidisationRegistry.connect(alice).setGroupLimit(1, 5)).to.be.revertedWithCustomError( + gameSubsidisationRegistry, + "OwnableUnauthorizedAccount" + ); + }); + + it("can update existing mappings", async () => { + const {gameSubsidisationRegistry, owner}: {gameSubsidisationRegistry: GameSubsidisationRegistry; owner: any} = + await loadFixture(deployContracts); + + const selector = ethers.id("doThing()").slice(0, 10); + + await gameSubsidisationRegistry.setFunctionGroup(owner.address, selector, 1); + await gameSubsidisationRegistry.setGroupLimit(1, 5); + + await gameSubsidisationRegistry.setFunctionGroup(owner.address, selector, 3); + await gameSubsidisationRegistry.setGroupLimit(1, 9); + + expect(await gameSubsidisationRegistry.functionToLimitGroup(owner.address, selector)).to.eq(3); + expect(await gameSubsidisationRegistry.groupDailyLimits(1)).to.eq(9); + }); +}); diff --git a/test/Session/UsageBasedSessionModule.ts b/test/Session/UsageBasedSessionModule.ts new file mode 100644 index 00000000..3154b552 --- /dev/null +++ b/test/Session/UsageBasedSessionModule.ts @@ -0,0 +1,470 @@ +import {loadFixture} from "@nomicfoundation/hardhat-network-helpers"; +import {expect} from "chai"; +import {ethers} from "hardhat"; +import {GameSubsidisationRegistry, UsageBasedSessionModule, PlayerNFT} from "../../typechain-types"; +import {playersFixture} from "../Players/PlayersFixture"; + +describe("UsageBasedSessionModule", function () { + async function deployContracts() { + const baseFixture = await loadFixture(playersFixture); + return {...baseFixture}; + } + + async function setupSession(groupLimit: number = 2) { + const { + gameSubsidisationRegistry, + usageBasedSessionModule, + owner, + }: { + gameSubsidisationRegistry: GameSubsidisationRegistry; + usageBasedSessionModule: UsageBasedSessionModule; + owner: any; + } = await deployContracts(); + + const Safe = await ethers.getContractFactory("TestSessionSafe"); + const safe = (await Safe.deploy(owner.address)) as any; + + const Target = await ethers.getContractFactory("TestSessionTarget"); + const target = (await Target.deploy()) as any; + + const selector = target.interface.getFunction("doAction")!.selector; + await gameSubsidisationRegistry.setFunctionGroup(await target.getAddress(), selector, 1); + await gameSubsidisationRegistry.setGroupLimit(1, groupLimit); + const sessionKey = ethers.Wallet.createRandom(); + + await safe.callEnableSession(usageBasedSessionModule, sessionKey.address, 3600); + const session = await usageBasedSessionModule.sessions(await safe.getAddress()); + + return { + sessionKey, + safe, + target, + selector, + sessionDeadline: session.deadline, + module: usageBasedSessionModule, + gameSubsidisationRegistry, + owner, + }; + } + + async function signCall( + sessionKey: any, + safe: any, + target: any, + data: string, + nonce: bigint, + sessionDeadline: bigint, + moduleAddress: string + ) { + const network = await ethers.provider.getNetwork(); + const domain = { + name: "UsageBasedSessionModule", + version: "1", + chainId: network.chainId, + verifyingContract: moduleAddress, + }; + const types = { + UsageBasedSession: [ + {name: "safe", type: "address"}, + {name: "target", type: "address"}, + {name: "data", type: "bytes"}, + {name: "nonce", type: "uint256"}, + {name: "sessionDeadline", type: "uint48"}, + {name: "module", type: "address"}, + {name: "chainId", type: "uint256"}, + ], + }; + const message = { + safe: await safe.getAddress(), + target: await target.getAddress(), + data, + nonce, + sessionDeadline, + module: moduleAddress, + chainId: network.chainId, + }; + + return sessionKey.signTypedData(domain, types, message); + } + + it("executes an allowed action and consumes daily quota", async () => { + const {sessionKey, safe, target, module, sessionDeadline} = await setupSession(2); + + const data = target.interface.encodeFunctionData("doAction"); + const signature = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); + + await module.execute(await safe.getAddress(), await target.getAddress(), data, signature); + + expect(await target.calls()).to.eq(1); + }); + + describe("enableSession & revokeSession", function () { + it("fails to enable a session with zero address session key", async () => { + const {module, safe} = await setupSession(2); + // Revoke first + await safe.execTransactionFromModule( + await module.getAddress(), + 0, + module.interface.encodeFunctionData("revokeSession"), + 0 + ); + + await expect( + safe.callEnableSession(await module.getAddress(), ethers.ZeroAddress, 3600) + ).to.be.revertedWithCustomError(module, "ZeroSessionKey"); + }); + + it("fails to enable a session with zero duration", async () => { + const {module, safe} = await setupSession(2); + await safe.execTransactionFromModule( + await module.getAddress(), + 0, + module.interface.encodeFunctionData("revokeSession"), + 0 + ); + + await expect( + safe.callEnableSession(await module.getAddress(), ethers.Wallet.createRandom().address, 0) + ).to.be.revertedWithCustomError(module, "InvalidSessionDuration"); + }); + + it("fails to enable a session with duration exceeding max", async () => { + const {module, safe} = await setupSession(2); + await safe.execTransactionFromModule( + await module.getAddress(), + 0, + module.interface.encodeFunctionData("revokeSession"), + 0 + ); + + const maxDuration = await module.MAX_SESSION_DURATION(); + await expect( + safe.callEnableSession(await module.getAddress(), ethers.Wallet.createRandom().address, Number(maxDuration) + 1) + ).to.be.revertedWithCustomError(module, "InvalidSessionDuration"); + }); + + it("fails to enable a session if one is already active", async () => { + const {module, safe} = await setupSession(2); + await expect( + safe.callEnableSession(await module.getAddress(), ethers.Wallet.createRandom().address, 3600) + ).to.be.revertedWithCustomError(module, "ExistingSessionActive"); + }); + + it("revokes an active session", async () => { + const {module, safe} = await setupSession(2); + const revokeData = module.interface.encodeFunctionData("revokeSession"); + await expect(safe.execTransactionFromModule(await module.getAddress(), 0, revokeData, 0)).to.emit( + module, + "SessionRevoked" + ); + + const session = await module.sessions(await safe.getAddress()); + expect(session.sessionKey).to.eq(ethers.ZeroAddress); + }); + }); + + describe("execute requirements", function () { + it("fails if data is too short", async () => { + const {module, safe} = await setupSession(2); + await expect( + module.execute(await safe.getAddress(), ethers.ZeroAddress, "0x123456", "0x") + ).to.be.revertedWithCustomError(module, "InvalidCallData"); + }); + + it("fails if no session is active", async () => { + const {module, safe, target, selector} = await setupSession(2); + await safe.execTransactionFromModule( + await module.getAddress(), + 0, + module.interface.encodeFunctionData("revokeSession"), + 0 + ); + + await expect( + module.execute(await safe.getAddress(), await target.getAddress(), "0x12345678", "0x") + ).to.be.revertedWithCustomError(module, "NoSessionKey"); + }); + + it("fails if session has expired", async () => { + const {module, safe, target, sessionKey, sessionDeadline} = await setupSession(2); + + // Fast forward time + await ethers.provider.send("evm_increaseTime", [3601]); + await ethers.provider.send("evm_mine", []); + + const data = target.interface.encodeFunctionData("doAction"); + const signature = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); + + await expect( + module.execute(await safe.getAddress(), await target.getAddress(), data, signature) + ).to.be.revertedWithCustomError(module, "SessionExpired"); + }); + + it("fails if action is not permitted (groupId 0)", async () => { + const {module, safe, target, sessionKey, sessionDeadline, gameSubsidisationRegistry} = await setupSession(2); + + // Use a DIFFERENT target or different selector + const Target = await ethers.getContractFactory("TestSessionTarget"); + const unmappedTarget = await Target.deploy(); + + const data = unmappedTarget.interface.encodeFunctionData("doAction"); + const signature = await signCall( + sessionKey, + safe, + unmappedTarget, + data, + 0n, + sessionDeadline, + await module.getAddress() + ); + + await expect( + module.execute(await safe.getAddress(), await unmappedTarget.getAddress(), data, signature) + ).to.be.revertedWithCustomError(module, "ActionNotPermitted"); + }); + + it("fails if target call reverts", async () => { + const {module, safe, sessionKey, sessionDeadline, gameSubsidisationRegistry, owner} = await setupSession(2); + + // Deploy a reverting target + const RevertingTarget = await ethers.getContractFactory("TestSessionRevertingTarget"); + const revertingTarget = await RevertingTarget.deploy(); + + const selector = revertingTarget.interface.getFunction("revertAction")!.selector; + await gameSubsidisationRegistry.setFunctionGroup(await revertingTarget.getAddress(), selector, 1); + + const data = revertingTarget.interface.encodeFunctionData("revertAction"); + const signature = await signCall( + sessionKey, + safe, + revertingTarget, + data, + 0n, + sessionDeadline, + await module.getAddress() + ); + + await expect( + module.execute(await safe.getAddress(), await revertingTarget.getAddress(), data, signature) + ).to.be.revertedWithCustomError(module, "ModuleCallFailed"); + }); + + it("rejects calls signed by the wrong key", async () => { + const {safe, target, module, sessionDeadline} = await setupSession(2); + const badSessionKey = ethers.Wallet.createRandom(); + + const data = target.interface.encodeFunctionData("doAction"); + const signature = await signCall( + badSessionKey, + safe, + target, + data, + 0n, + sessionDeadline, + await module.getAddress() + ); + + await expect( + module.execute(await safe.getAddress(), await target.getAddress(), data, signature) + ).to.be.revertedWithCustomError(module, "InvalidSignature"); + }); + + it("rejects calls with wrong nonce in signature", async () => { + const {safe, target, module, sessionDeadline, sessionKey} = await setupSession(2); + const data = target.interface.encodeFunctionData("doAction"); + // Use nonce 1 instead of 0 + const signature = await signCall(sessionKey, safe, target, data, 1n, sessionDeadline, await module.getAddress()); + + await expect( + module.execute(await safe.getAddress(), await target.getAddress(), data, signature) + ).to.be.revertedWithCustomError(module, "InvalidSignature"); + }); + + it("rejects calls with wrong target in signature", async () => { + const {safe, target, module, sessionDeadline, sessionKey} = await setupSession(2); + const data = target.interface.encodeFunctionData("doAction"); + + const OtherTarget = await ethers.getContractFactory("TestSessionTarget"); + const otherTarget = await OtherTarget.deploy(); + + // Sign for otherTarget but execute for target + const signature = await signCall( + sessionKey, + safe, + otherTarget, + data, + 0n, + sessionDeadline, + await module.getAddress() + ); + + await expect( + module.execute(await safe.getAddress(), await target.getAddress(), data, signature) + ).to.be.revertedWithCustomError(module, "InvalidSignature"); + }); + + it("enforces group daily limits", async () => { + const {sessionKey, safe, target, module, sessionDeadline} = await setupSession(2); + + const data = target.interface.encodeFunctionData("doAction"); + + const sig0 = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); + await module.execute(await safe.getAddress(), await target.getAddress(), data, sig0); + + const sig1 = await signCall(sessionKey, safe, target, data, 1n, sessionDeadline, await module.getAddress()); + await module.execute(await safe.getAddress(), await target.getAddress(), data, sig1); + + const sig2 = await signCall(sessionKey, safe, target, data, 2n, sessionDeadline, await module.getAddress()); + await expect( + module.execute(await safe.getAddress(), await target.getAddress(), data, sig2) + ).to.be.revertedWithCustomError(module, "GroupLimitReached"); + }); + }); + + describe("PlayerNFT integration", function () { + async function setupPlayerNFTSession(groupLimit: number = 5) { + const { + gameSubsidisationRegistry, + usageBasedSessionModule, + owner, + playerNFT, + avatarId, + }: { + gameSubsidisationRegistry: GameSubsidisationRegistry; + usageBasedSessionModule: UsageBasedSessionModule; + owner: any; + playerNFT: PlayerNFT; + avatarId: number; + } = await deployContracts(); + + const Safe = await ethers.getContractFactory("TestSessionSafe"); + const safe = (await Safe.deploy(owner.address)) as any; + + const selector = playerNFT.interface.getFunction("mint")!.selector; + await gameSubsidisationRegistry.setFunctionGroup(await playerNFT.getAddress(), selector, 1); + await gameSubsidisationRegistry.setGroupLimit(1, groupLimit); + const sessionKey = ethers.Wallet.createRandom(); + + await safe.callEnableSession(usageBasedSessionModule, sessionKey.address, 3600); + const session = await usageBasedSessionModule.sessions(await safe.getAddress()); + + return { + sessionKey, + safe, + playerNFT, + avatarId, + sessionDeadline: session.deadline, + module: usageBasedSessionModule, + gameSubsidisationRegistry, + }; + } + + it("mints a player NFT via session module", async () => { + const {sessionKey, safe, playerNFT, avatarId, module, sessionDeadline} = await setupPlayerNFTSession(5); + + const heroName = "SessionHero" + 1; + const data = playerNFT.interface.encodeFunctionData("mint", [avatarId, heroName, "", "", "", false, true]); + const signature = await signCall( + sessionKey, + safe, + playerNFT, + data, + 0n, + sessionDeadline, + await module.getAddress() + ); + + const tx = await module.execute(await safe.getAddress(), await playerNFT.getAddress(), data, signature); + const receipt = await tx.wait(); + + // Parse NewPlayer event from logs + const newPlayerLogs = receipt!.logs + .map((log) => { + try { + return playerNFT.interface.parseLog(log); + } catch { + return null; + } + }) + .filter((x) => x && x.name === "NewPlayer"); + + expect(newPlayerLogs.length).to.eq(1); + const parsed = newPlayerLogs[0]!; + expect(parsed.args?.from).to.eq(await safe.getAddress()); + expect(parsed.args?.avatarId).to.eq(avatarId); + }); + + it("mints multiple players respecting daily limits", async () => { + const {sessionKey, safe, playerNFT, avatarId, module, sessionDeadline} = await setupPlayerNFTSession(2); + + // First mint + const data1 = playerNFT.interface.encodeFunctionData("mint", [ + avatarId, + "FirstHero" + 1, + "", + "", + "", + false, + true, + ]); + const sig1 = await signCall(sessionKey, safe, playerNFT, data1, 0n, sessionDeadline, await module.getAddress()); + await module.execute(await safe.getAddress(), await playerNFT.getAddress(), data1, sig1); + + // Second mint with different name + const data2 = playerNFT.interface.encodeFunctionData("mint", [ + avatarId, + "SecondHero" + 2, + "", + "", + "", + false, + true, + ]); + const sig2 = await signCall(sessionKey, safe, playerNFT, data2, 1n, sessionDeadline, await module.getAddress()); + await module.execute(await safe.getAddress(), await playerNFT.getAddress(), data2, sig2); + + // Third mint should fail due to group limit + const data3 = playerNFT.interface.encodeFunctionData("mint", [ + avatarId, + "ThirdHero" + 3, + "", + "", + "", + false, + true, + ]); + const sig3 = await signCall(sessionKey, safe, playerNFT, data3, 2n, sessionDeadline, await module.getAddress()); + await expect( + module.execute(await safe.getAddress(), await playerNFT.getAddress(), data3, sig3) + ).to.be.revertedWithCustomError(module, "GroupLimitReached"); + }); + + it("rejects PlayerNFT mint with invalid session key signature", async () => { + const {safe, playerNFT, avatarId, module, sessionDeadline} = await setupPlayerNFTSession(5); + const badSessionKey = ethers.Wallet.createRandom(); + + const data = playerNFT.interface.encodeFunctionData("mint", [ + avatarId, + "BadKeyHero" + Date.now(), + "", + "", + "", + false, + true, + ]); + const signature = await signCall( + badSessionKey, + safe, + playerNFT, + data, + 0n, + sessionDeadline, + await module.getAddress() + ); + + await expect( + module.execute(await safe.getAddress(), await playerNFT.getAddress(), data, signature) + ).to.be.revertedWithCustomError(module, "InvalidSignature"); + }); + }); +}); From 0fd314296a381a7fb046d52cc973454b14e45856 Mon Sep 17 00:00:00 2001 From: deif Date: Wed, 21 Jan 2026 20:45:54 +0000 Subject: [PATCH 03/13] emit event on session module execution for indexer to pick up current nonce --- contracts/Session/UsageBasedSessionModule.sol | 3 +++ data/abi/UsageBasedSessionModule.json | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/contracts/Session/UsageBasedSessionModule.sol b/contracts/Session/UsageBasedSessionModule.sol index a478f680..79c1ef41 100644 --- a/contracts/Session/UsageBasedSessionModule.sol +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -25,6 +25,7 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U event SessionEnabled(address indexed safe, address indexed sessionKey, uint48 deadline); event SessionRevoked(address indexed safe); + event SessionNonceIncremented(address indexed safe, uint256 newNonce); uint48 public constant MAX_SESSION_DURATION = 30 days; bytes32 private constant SESSION_TYPEHASH = keccak256( @@ -131,6 +132,8 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U // 4. Verify Signature & Execute via Safe bool success = ISafe(safe).execTransactionFromModule(target, 0, data, Enum.Operation.Call); require(success, ModuleCallFailed()); + + emit SessionNonceIncremented(safe, user.nonce); } function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} diff --git a/data/abi/UsageBasedSessionModule.json b/data/abi/UsageBasedSessionModule.json index 717ff255..983f3e75 100644 --- a/data/abi/UsageBasedSessionModule.json +++ b/data/abi/UsageBasedSessionModule.json @@ -224,6 +224,25 @@ "name": "SessionEnabled", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newNonce", + "type": "uint256" + } + ], + "name": "SessionNonceIncremented", + "type": "event" + }, { "anonymous": false, "inputs": [ From ec4663a92261d0c5a9dff5153dcce5afab3719a9 Mon Sep 17 00:00:00 2001 From: deif Date: Wed, 21 Jan 2026 22:23:45 +0000 Subject: [PATCH 04/13] remove redundant has signature params --- contracts/Session/UsageBasedSessionModule.sol | 6 ++---- test/Session/UsageBasedSessionModule.ts | 4 ---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/contracts/Session/UsageBasedSessionModule.sol b/contracts/Session/UsageBasedSessionModule.sol index 79c1ef41..52fea9f7 100644 --- a/contracts/Session/UsageBasedSessionModule.sol +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -29,7 +29,7 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U uint48 public constant MAX_SESSION_DURATION = 30 days; bytes32 private constant SESSION_TYPEHASH = keccak256( - "UsageBasedSession(address safe,address target,bytes data,uint256 nonce,uint48 sessionDeadline,address module,uint256 chainId)" + "UsageBasedSession(address safe,address target,bytes data,uint256 nonce,uint48 sessionDeadline)" ); struct GroupUsage { @@ -117,9 +117,7 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U target, keccak256(data), currentNonce, - session.deadline, - address(this), - block.chainid + session.deadline ) ) ); diff --git a/test/Session/UsageBasedSessionModule.ts b/test/Session/UsageBasedSessionModule.ts index 3154b552..4353710b 100644 --- a/test/Session/UsageBasedSessionModule.ts +++ b/test/Session/UsageBasedSessionModule.ts @@ -70,8 +70,6 @@ describe("UsageBasedSessionModule", function () { {name: "data", type: "bytes"}, {name: "nonce", type: "uint256"}, {name: "sessionDeadline", type: "uint48"}, - {name: "module", type: "address"}, - {name: "chainId", type: "uint256"}, ], }; const message = { @@ -80,8 +78,6 @@ describe("UsageBasedSessionModule", function () { data, nonce, sessionDeadline, - module: moduleAddress, - chainId: network.chainId, }; return sessionKey.signTypedData(domain, types, message); From a4bbb5991b9bfcc099993ee15e514558d1eb68ab Mon Sep 17 00:00:00 2001 From: deif Date: Thu, 22 Jan 2026 17:53:38 +0000 Subject: [PATCH 05/13] align coding style --- contracts/Session/UsageBasedSessionModule.sol | 30 ++++---- data/abi/UsageBasedSessionModule.json | 70 +++++++++---------- test/Session/UsageBasedSessionModule.ts | 6 +- 3 files changed, 52 insertions(+), 54 deletions(-) diff --git a/contracts/Session/UsageBasedSessionModule.sol b/contracts/Session/UsageBasedSessionModule.sol index 52fea9f7..c16c506b 100644 --- a/contracts/Session/UsageBasedSessionModule.sol +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -47,19 +47,19 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U uint48 deadline; } - IGameSubsidisationRegistry public registry; - mapping(address => Session) public sessions; // Safe => Session - mapping(address => UserUsage) private usage; // Safe => Usage + IGameSubsidisationRegistry private _registry; + mapping(address => Session) private _sessions; // Safe => Session + mapping(address => UserUsage) private _usage; // Safe => Usage /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } - function initialize(address owner, IGameSubsidisationRegistry _registry) public initializer { + function initialize(address owner, IGameSubsidisationRegistry registry) public initializer { __Ownable_init(owner); __EIP712_init("UsageBasedSessionModule", "1"); - registry = _registry; + _registry = registry; } /** @@ -68,18 +68,18 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U function enableSession(address _sessionKey, uint48 _duration) external { require(_sessionKey != address(0), ZeroSessionKey()); require(_duration > 0 && _duration <= MAX_SESSION_DURATION, InvalidSessionDuration()); - require(sessions[msg.sender].deadline < block.timestamp, ExistingSessionActive()); + require(_sessions[msg.sender].deadline < block.timestamp, ExistingSessionActive()); - sessions[msg.sender] = Session({sessionKey: _sessionKey, deadline: uint48(block.timestamp) + _duration}); + _sessions[msg.sender] = Session({sessionKey: _sessionKey, deadline: uint48(block.timestamp) + _duration}); - emit SessionEnabled(msg.sender, _sessionKey, sessions[msg.sender].deadline); + emit SessionEnabled(msg.sender, _sessionKey, _sessions[msg.sender].deadline); } /** * @notice Explicitly revoke the current session early. Must be called BY THE SAFE */ function revokeSession() external { - delete sessions[msg.sender]; + delete _sessions[msg.sender]; emit SessionRevoked(msg.sender); } @@ -87,17 +87,17 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U require(data.length >= 4, InvalidCallData()); // 1. Basic Session Check - Session memory session = sessions[safe]; + Session memory session = _sessions[safe]; require(session.sessionKey != address(0), NoSessionKey()); require(session.deadline >= block.timestamp, SessionExpired()); // 2. Identify the action (extract selector from data) bytes4 selector = bytes4(data[0:4]); - uint256 groupId = registry.functionToLimitGroup(target, selector); + uint256 groupId = _registry.functionToLimitGroup(target, selector); require(groupId > 0, ActionNotPermitted()); uint256 currentDay = block.timestamp / 1 days; - UserUsage storage user = usage[safe]; + UserUsage storage user = _usage[safe]; GroupUsage storage group = user.groupUsage[groupId]; if (group.day != uint40(currentDay)) { group.day = uint40(currentDay); @@ -105,7 +105,7 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U } uint256 currentUsage = group.count; - uint256 limit = registry.groupDailyLimits(groupId); + uint256 limit = _registry.groupDailyLimits(groupId); require(currentUsage < limit, GroupLimitReached()); uint256 currentNonce = user.nonce; @@ -134,5 +134,9 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U emit SessionNonceIncremented(safe, user.nonce); } + function getSession(address safe) external view returns (Session memory) { + return _sessions[safe]; + } + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} } diff --git a/data/abi/UsageBasedSessionModule.json b/data/abi/UsageBasedSessionModule.json index 983f3e75..832f2d36 100644 --- a/data/abi/UsageBasedSessionModule.json +++ b/data/abi/UsageBasedSessionModule.json @@ -384,6 +384,37 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "safe", + "type": "address" + } + ], + "name": "getSession", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "sessionKey", + "type": "address" + }, + { + "internalType": "uint48", + "name": "deadline", + "type": "uint48" + } + ], + "internalType": "struct UsageBasedSessionModule.Session", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -393,7 +424,7 @@ }, { "internalType": "contract IGameSubsidisationRegistry", - "name": "_registry", + "name": "registry", "type": "address" } ], @@ -428,19 +459,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "registry", - "outputs": [ - { - "internalType": "contract IGameSubsidisationRegistry", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "renounceOwnership", @@ -455,30 +473,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "sessions", - "outputs": [ - { - "internalType": "address", - "name": "sessionKey", - "type": "address" - }, - { - "internalType": "uint48", - "name": "deadline", - "type": "uint48" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { diff --git a/test/Session/UsageBasedSessionModule.ts b/test/Session/UsageBasedSessionModule.ts index 4353710b..1df2fc8b 100644 --- a/test/Session/UsageBasedSessionModule.ts +++ b/test/Session/UsageBasedSessionModule.ts @@ -33,7 +33,7 @@ describe("UsageBasedSessionModule", function () { const sessionKey = ethers.Wallet.createRandom(); await safe.callEnableSession(usageBasedSessionModule, sessionKey.address, 3600); - const session = await usageBasedSessionModule.sessions(await safe.getAddress()); + const session = await usageBasedSessionModule.getSession(await safe.getAddress()); return { sessionKey, @@ -154,7 +154,7 @@ describe("UsageBasedSessionModule", function () { "SessionRevoked" ); - const session = await module.sessions(await safe.getAddress()); + const session = await module.getSession(await safe.getAddress()); expect(session.sessionKey).to.eq(ethers.ZeroAddress); }); }); @@ -342,7 +342,7 @@ describe("UsageBasedSessionModule", function () { const sessionKey = ethers.Wallet.createRandom(); await safe.callEnableSession(usageBasedSessionModule, sessionKey.address, 3600); - const session = await usageBasedSessionModule.sessions(await safe.getAddress()); + const session = await usageBasedSessionModule.getSession(await safe.getAddress()); return { sessionKey, From 1325fb74b19c36df71261407ef5962b1421666b6 Mon Sep 17 00:00:00 2001 From: deif Date: Thu, 22 Jan 2026 22:41:10 +0000 Subject: [PATCH 06/13] add whitelisted executors and gas refunds --- contracts/Session/UsageBasedSessionModule.sol | 24 +++++++ data/abi/UsageBasedSessionModule.json | 68 ++++++++++++++++++ test/Session/UsageBasedSessionModule.ts | 70 ++++++++++++++++++- 3 files changed, 161 insertions(+), 1 deletion(-) diff --git a/contracts/Session/UsageBasedSessionModule.sol b/contracts/Session/UsageBasedSessionModule.sol index c16c506b..d4884971 100644 --- a/contracts/Session/UsageBasedSessionModule.sol +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -22,12 +22,16 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U error ZeroSessionKey(); error InvalidCallData(); error ModuleCallFailed(); + error UnauthorizedSigner(); + error RefundFailed(); event SessionEnabled(address indexed safe, address indexed sessionKey, uint48 deadline); event SessionRevoked(address indexed safe); event SessionNonceIncremented(address indexed safe, uint256 newNonce); + event WhitelistedSignersUpdated(address[] signers, bool whitelisted); uint48 public constant MAX_SESSION_DURATION = 30 days; + uint256 public constant GAS_OVERHEAD = 30000; // 21000 base tx + 9k transfer bytes32 private constant SESSION_TYPEHASH = keccak256( "UsageBasedSession(address safe,address target,bytes data,uint256 nonce,uint48 sessionDeadline)" ); @@ -50,6 +54,7 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U IGameSubsidisationRegistry private _registry; mapping(address => Session) private _sessions; // Safe => Session mapping(address => UserUsage) private _usage; // Safe => Usage + mapping(address => bool) private _whitelistedSigners; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -84,6 +89,8 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U } function execute(address safe, address target, bytes calldata data, bytes calldata signature) external { + uint256 startGas = gasleft(); + require(_whitelistedSigners[msg.sender], UnauthorizedSigner()); require(data.length >= 4, InvalidCallData()); // 1. Basic Session Check @@ -132,11 +139,28 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U require(success, ModuleCallFailed()); emit SessionNonceIncremented(safe, user.nonce); + + uint256 gasUsed = startGas - gasleft() + GAS_OVERHEAD + (data.length * 16) + (signature.length * 16); + uint256 refundAmount = gasUsed * tx.gasprice; + if (refundAmount > 0) { + (bool refundSuccess, ) = msg.sender.call{value: refundAmount}(""); // Refund the relayer directly + require(refundSuccess, RefundFailed()); + } + } + + function setWhitelistedSigner(address[] calldata signers, bool whitelisted) external onlyOwner { + for (uint256 i = 0; i < signers.length; i++) { + _whitelistedSigners[signers[i]] = whitelisted; + } + emit WhitelistedSignersUpdated(signers, whitelisted); } function getSession(address safe) external view returns (Session memory) { return _sessions[safe]; } + receive() external payable {} + fallback() external payable {} + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} } diff --git a/data/abi/UsageBasedSessionModule.json b/data/abi/UsageBasedSessionModule.json index 832f2d36..b9d5f13c 100644 --- a/data/abi/UsageBasedSessionModule.json +++ b/data/abi/UsageBasedSessionModule.json @@ -135,6 +135,11 @@ "name": "OwnableUnauthorizedAccount", "type": "error" }, + { + "inputs": [], + "name": "RefundFailed", + "type": "error" + }, { "inputs": [], "name": "SessionExpired", @@ -156,6 +161,11 @@ "name": "UUPSUnsupportedProxiableUUID", "type": "error" }, + { + "inputs": [], + "name": "UnauthorizedSigner", + "type": "error" + }, { "inputs": [], "name": "ZeroSessionKey", @@ -269,6 +279,42 @@ "name": "Upgraded", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address[]", + "name": "signers", + "type": "address[]" + }, + { + "indexed": false, + "internalType": "bool", + "name": "whitelisted", + "type": "bool" + } + ], + "name": "WhitelistedSignersUpdated", + "type": "event" + }, + { + "stateMutability": "payable", + "type": "fallback" + }, + { + "inputs": [], + "name": "GAS_OVERHEAD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "MAX_SESSION_DURATION", @@ -473,6 +519,24 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "signers", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "whitelisted", + "type": "bool" + } + ], + "name": "setWhitelistedSigner", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -503,5 +567,9 @@ "outputs": [], "stateMutability": "payable", "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" } ] diff --git a/test/Session/UsageBasedSessionModule.ts b/test/Session/UsageBasedSessionModule.ts index 1df2fc8b..3c27ef32 100644 --- a/test/Session/UsageBasedSessionModule.ts +++ b/test/Session/UsageBasedSessionModule.ts @@ -1,4 +1,4 @@ -import {loadFixture} from "@nomicfoundation/hardhat-network-helpers"; +import {loadFixture, setNextBlockBaseFeePerGas} from "@nomicfoundation/hardhat-network-helpers"; import {expect} from "chai"; import {ethers} from "hardhat"; import {GameSubsidisationRegistry, UsageBasedSessionModule, PlayerNFT} from "../../typechain-types"; @@ -32,6 +32,9 @@ describe("UsageBasedSessionModule", function () { await gameSubsidisationRegistry.setGroupLimit(1, groupLimit); const sessionKey = ethers.Wallet.createRandom(); + await usageBasedSessionModule.setWhitelistedSigner([owner.address], true); + await owner.sendTransaction({to: await usageBasedSessionModule.getAddress(), value: ethers.parseEther("1")}); + await safe.callEnableSession(usageBasedSessionModule, sessionKey.address, 3600); const session = await usageBasedSessionModule.getSession(await safe.getAddress()); @@ -315,6 +318,68 @@ describe("UsageBasedSessionModule", function () { module.execute(await safe.getAddress(), await target.getAddress(), data, sig2) ).to.be.revertedWithCustomError(module, "GroupLimitReached"); }); + + it("fails if signer is not whitelisted", async () => { + const {sessionKey, safe, target, module, sessionDeadline} = await setupSession(2); + const [, other] = await ethers.getSigners(); + + const data = target.interface.encodeFunctionData("doAction"); + const signature = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); + + // Attempt to execute with 'other' (not whitelisted) + await expect( + module.connect(other).execute(await safe.getAddress(), await target.getAddress(), data, signature) + ).to.be.revertedWithCustomError(module, "UnauthorizedSigner"); + }); + + it("refunds gas to the whitelisted signer", async () => { + const {sessionKey, safe, target, module, sessionDeadline} = await setupSession(2); + const [, otherWhitelisted] = await ethers.getSigners(); + + await module.setWhitelistedSigner([otherWhitelisted.address], true); + + const data = target.interface.encodeFunctionData("doAction"); + const signature = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); + + const gasPrice = ethers.parseUnits("20", "gwei"); + // set network gas price + await setNextBlockBaseFeePerGas(gasPrice); + + const balanceBefore = await ethers.provider.getBalance(otherWhitelisted.address); + const tx = await module + .connect(otherWhitelisted) + .execute(await safe.getAddress(), await target.getAddress(), data, signature, {gasPrice}); + const receipt = await tx.wait(); + const balanceAfter = await ethers.provider.getBalance(otherWhitelisted.address); + + // balanceAfter = balanceBefore - gasUsedInTx + refund + // There's still a tiny cost because gasUsedInTx > gasUsedInModule (some overhead not covered) + // but it should be very close. + + expect(balanceAfter).to.be.closeTo(balanceBefore, ethers.parseEther("0.001")); + expect(balanceAfter).to.be.gte(balanceBefore - receipt!.gasUsed * receipt!.gasPrice); + }); + }); + + describe("Whitelisting", function () { + it("allows owner to whitelist signers", async () => { + const {module} = await setupSession(2); + const [, other] = await ethers.getSigners(); + + await expect(module.setWhitelistedSigner([other.address], true)) + .to.emit(module, "WhitelistedSignersUpdated") + .withArgs([other.address], true); + }); + + it("prevents non-owner from whitelisting signers", async () => { + const {module} = await setupSession(2); + const [, other] = await ethers.getSigners(); + + await expect(module.connect(other).setWhitelistedSigner([other.address], true)).to.be.revertedWithCustomError( + module, + "OwnableUnauthorizedAccount" + ); + }); }); describe("PlayerNFT integration", function () { @@ -341,6 +406,9 @@ describe("UsageBasedSessionModule", function () { await gameSubsidisationRegistry.setGroupLimit(1, groupLimit); const sessionKey = ethers.Wallet.createRandom(); + await usageBasedSessionModule.setWhitelistedSigner([owner.address], true); + await owner.sendTransaction({to: await usageBasedSessionModule.getAddress(), value: ethers.parseEther("1")}); + await safe.callEnableSession(usageBasedSessionModule, sessionKey.address, 3600); const session = await usageBasedSessionModule.getSession(await safe.getAddress()); From 8819bd97f10f1fe877e45857a421c580abffbd22 Mon Sep 17 00:00:00 2001 From: deif Date: Thu, 22 Jan 2026 23:00:09 +0000 Subject: [PATCH 07/13] update lock file --- pnpm-lock.yaml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a48b7d12..c9dde980 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,9 +67,6 @@ importers: "@safe-global/protocol-kit": specifier: ^6.1.2 version: 6.1.2(typescript@5.9.3)(zod@3.25.76) - "@safe-global/safe-contracts": - specifier: 1.4.1-2 - version: 1.4.1-2(ethers@6.15.0) "@safe-global/types-kit": specifier: ^3.0.0 version: 3.0.0(typescript@5.9.3)(zod@3.25.76) @@ -1033,12 +1030,6 @@ packages: resolution: {integrity: sha512-cTpPdUAS2AMfGCkD1T601rQNjT0rtMQLA2TH7L/C+iFPAC6WrrDFop2B9lzeHjczlnVzrRpfFe4cL1bLrJ9NZw==} - "@safe-global/safe-contracts@1.4.1-2": - resolution: - {integrity: sha512-UFiqZOamt1lDbyQ16lpzBE8mDdzfnQ3OBftll1erLlwIrdfmhePicQqfLquvyXA8AlqPWQ3ktz1DzpC9UcN+JQ==} - peerDependencies: - ethers: 5.4.0 - "@safe-global/safe-deployments@1.37.49": resolution: {integrity: sha512-132QgqMY1/HktXqmda/uPp5b+73UXTgKRB00Xgc1kduFqceSw/ZyF1Q9jJjbND9q91hhapnXhYKWN2/HiWkRcg==} @@ -5931,10 +5922,6 @@ snapshots: - utf-8-validate - zod - "@safe-global/safe-contracts@1.4.1-2(ethers@6.15.0)": - dependencies: - ethers: 6.15.0 - "@safe-global/safe-deployments@1.37.49": dependencies: semver: 7.7.3 From 87c93d53469e40055a4abb86d88a94fb2eacff2e Mon Sep 17 00:00:00 2001 From: deif Date: Fri, 23 Jan 2026 01:18:37 +0000 Subject: [PATCH 08/13] implement batching for executing signatures with graceful failures --- .github/copilot-instructions.md | 2 + contracts/Session/UsageBasedSessionModule.sol | 49 ++- data/abi/UsageBasedSessionModule.json | 113 +++++- test/Session/UsageBasedSessionModule.ts | 366 +++++++++++++++--- 4 files changed, 454 insertions(+), 76 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e946ff4c..7bc4496b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -25,5 +25,7 @@ - Always follow Checks-Effects-Interactions pattern to prevent reentrancy issues. Update contract state before making any external calls. - Contract owners are expected to be Gnosis Safe multisigs. There are some scripts that may still use single EOA accounts as they haven't been updated yet. For new scripts, prefer the proposal pattern using `prepareUpgrade` and use the util function `sendTransactionSetToSafe` in `scripts/utils.ts`. - Use openzeppelin libraries for common functionalities like ERC standards, access control, upgradeability, and security features. Avoid reinventing the wheel. +- Use `pnpm test ` to run specific test files during development for faster feedback. +- Do not use `pnpm hardhat test ` as it forces a full recompile which slows down the workflow. If anything here feels off or incomplete, tell me what to clarify or expand. diff --git a/contracts/Session/UsageBasedSessionModule.sol b/contracts/Session/UsageBasedSessionModule.sol index d4884971..39a19068 100644 --- a/contracts/Session/UsageBasedSessionModule.sol +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -24,11 +24,14 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U error ModuleCallFailed(); error UnauthorizedSigner(); error RefundFailed(); + error OnlyInternal(); + error NoBatchItems(); event SessionEnabled(address indexed safe, address indexed sessionKey, uint48 deadline); event SessionRevoked(address indexed safe); event SessionNonceIncremented(address indexed safe, uint256 newNonce); event WhitelistedSignersUpdated(address[] signers, bool whitelisted); + event BatchItemFailed(address indexed safe, bytes4 selector, bytes errorData); uint48 public constant MAX_SESSION_DURATION = 30 days; uint256 public constant GAS_OVERHEAD = 30000; // 21000 base tx + 9k transfer @@ -51,6 +54,13 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U uint48 deadline; } + struct ExecuteParams { + address safe; + address target; + bytes data; + bytes signature; + } + IGameSubsidisationRegistry private _registry; mapping(address => Session) private _sessions; // Safe => Session mapping(address => UserUsage) private _usage; // Safe => Usage @@ -88,9 +98,37 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U emit SessionRevoked(msg.sender); } - function execute(address safe, address target, bytes calldata data, bytes calldata signature) external { - uint256 startGas = gasleft(); + function executeBatch(ExecuteParams[] calldata params) external { require(_whitelistedSigners[msg.sender], UnauthorizedSigner()); + require(params.length > 0, NoBatchItems()); + uint256 startGas = gasleft(); + + for (uint256 i = 0; i < params.length; i++) { + try this.executeSingle(params[i]) { + // Success + } catch (bytes memory reason) { + bytes4 selector = params[i].data.length >= 4 ? bytes4(params[i].data[0:4]) : bytes4(0); + emit BatchItemFailed(params[i].safe, selector, reason); + } + } + + uint256 gasUsed = startGas - gasleft() + GAS_OVERHEAD + msg.data.length * 16; + uint256 refundAmount = gasUsed * tx.gasprice; + if (refundAmount > 0) { + (bool refundSuccess, ) = msg.sender.call{value: refundAmount}(""); // Refund the relayer directly + require(refundSuccess, RefundFailed()); + } + } + + /** + * @notice Helper to allow try/catch within executeBatch via an external call + */ + function executeSingle(ExecuteParams calldata params) external { + require(msg.sender == address(this), OnlyInternal()); + _execute(params.safe, params.target, params.data, params.signature); + } + + function _execute(address safe, address target, bytes calldata data, bytes calldata signature) internal { require(data.length >= 4, InvalidCallData()); // 1. Basic Session Check @@ -139,13 +177,6 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U require(success, ModuleCallFailed()); emit SessionNonceIncremented(safe, user.nonce); - - uint256 gasUsed = startGas - gasleft() + GAS_OVERHEAD + (data.length * 16) + (signature.length * 16); - uint256 refundAmount = gasUsed * tx.gasprice; - if (refundAmount > 0) { - (bool refundSuccess, ) = msg.sender.call{value: refundAmount}(""); // Refund the relayer directly - require(refundSuccess, RefundFailed()); - } } function setWhitelistedSigner(address[] calldata signers, bool whitelisted) external onlyOwner { diff --git a/data/abi/UsageBasedSessionModule.json b/data/abi/UsageBasedSessionModule.json index b9d5f13c..18dc8deb 100644 --- a/data/abi/UsageBasedSessionModule.json +++ b/data/abi/UsageBasedSessionModule.json @@ -103,6 +103,11 @@ "name": "ModuleCallFailed", "type": "error" }, + { + "inputs": [], + "name": "NoBatchItems", + "type": "error" + }, { "inputs": [], "name": "NoSessionKey", @@ -113,6 +118,11 @@ "name": "NotInitializing", "type": "error" }, + { + "inputs": [], + "name": "OnlyInternal", + "type": "error" + }, { "inputs": [ { @@ -171,6 +181,31 @@ "name": "ZeroSessionKey", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "errorData", + "type": "bytes" + } + ], + "name": "BatchItemFailed", + "type": "event" + }, { "anonymous": false, "inputs": [], @@ -405,27 +440,69 @@ { "inputs": [ { - "internalType": "address", - "name": "safe", - "type": "address" - }, - { - "internalType": "address", - "name": "target", - "type": "address" - }, - { - "internalType": "bytes", - "name": "data", - "type": "bytes" - }, + "components": [ + { + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "internalType": "struct UsageBasedSessionModule.ExecuteParams[]", + "name": "params", + "type": "tuple[]" + } + ], + "name": "executeBatch", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ { - "internalType": "bytes", - "name": "signature", - "type": "bytes" + "components": [ + { + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "internalType": "struct UsageBasedSessionModule.ExecuteParams", + "name": "params", + "type": "tuple" } ], - "name": "execute", + "name": "executeSingle", "outputs": [], "stateMutability": "nonpayable", "type": "function" diff --git a/test/Session/UsageBasedSessionModule.ts b/test/Session/UsageBasedSessionModule.ts index 3c27ef32..c7934ff4 100644 --- a/test/Session/UsageBasedSessionModule.ts +++ b/test/Session/UsageBasedSessionModule.ts @@ -92,7 +92,7 @@ describe("UsageBasedSessionModule", function () { const data = target.interface.encodeFunctionData("doAction"); const signature = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); - await module.execute(await safe.getAddress(), await target.getAddress(), data, signature); + await module.executeBatch([{safe: await safe.getAddress(), target: await target.getAddress(), data, signature}]); expect(await target.calls()).to.eq(1); }); @@ -165,13 +165,17 @@ describe("UsageBasedSessionModule", function () { describe("execute requirements", function () { it("fails if data is too short", async () => { const {module, safe} = await setupSession(2); - await expect( - module.execute(await safe.getAddress(), ethers.ZeroAddress, "0x123456", "0x") - ).to.be.revertedWithCustomError(module, "InvalidCallData"); + const data = "0x123456"; + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: ethers.ZeroAddress, data, signature: "0x"}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), "0x00000000", module.interface.encodeErrorResult("InvalidCallData", [])); }); it("fails if no session is active", async () => { - const {module, safe, target, selector} = await setupSession(2); + const {module, safe, target} = await setupSession(2); await safe.execTransactionFromModule( await module.getAddress(), 0, @@ -179,13 +183,17 @@ describe("UsageBasedSessionModule", function () { 0 ); - await expect( - module.execute(await safe.getAddress(), await target.getAddress(), "0x12345678", "0x") - ).to.be.revertedWithCustomError(module, "NoSessionKey"); + const data = "0x12345678"; + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature: "0x"}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), "0x12345678", module.interface.encodeErrorResult("NoSessionKey", [])); }); it("fails if session has expired", async () => { - const {module, safe, target, sessionKey, sessionDeadline} = await setupSession(2); + const {module, safe, target, sessionKey, sessionDeadline, selector} = await setupSession(2); // Fast forward time await ethers.provider.send("evm_increaseTime", [3601]); @@ -194,13 +202,16 @@ describe("UsageBasedSessionModule", function () { const data = target.interface.encodeFunctionData("doAction"); const signature = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); - await expect( - module.execute(await safe.getAddress(), await target.getAddress(), data, signature) - ).to.be.revertedWithCustomError(module, "SessionExpired"); + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), selector, module.interface.encodeErrorResult("SessionExpired", [])); }); it("fails if action is not permitted (groupId 0)", async () => { - const {module, safe, target, sessionKey, sessionDeadline, gameSubsidisationRegistry} = await setupSession(2); + const {module, safe, sessionKey, sessionDeadline} = await setupSession(2); // Use a DIFFERENT target or different selector const Target = await ethers.getContractFactory("TestSessionTarget"); @@ -217,13 +228,20 @@ describe("UsageBasedSessionModule", function () { await module.getAddress() ); - await expect( - module.execute(await safe.getAddress(), await unmappedTarget.getAddress(), data, signature) - ).to.be.revertedWithCustomError(module, "ActionNotPermitted"); + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await unmappedTarget.getAddress(), data, signature}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs( + await safe.getAddress(), + unmappedTarget.interface.getFunction("doAction")!.selector, + module.interface.encodeErrorResult("ActionNotPermitted", []) + ); }); it("fails if target call reverts", async () => { - const {module, safe, sessionKey, sessionDeadline, gameSubsidisationRegistry, owner} = await setupSession(2); + const {module, safe, sessionKey, sessionDeadline, gameSubsidisationRegistry} = await setupSession(2); // Deploy a reverting target const RevertingTarget = await ethers.getContractFactory("TestSessionRevertingTarget"); @@ -243,13 +261,16 @@ describe("UsageBasedSessionModule", function () { await module.getAddress() ); - await expect( - module.execute(await safe.getAddress(), await revertingTarget.getAddress(), data, signature) - ).to.be.revertedWithCustomError(module, "ModuleCallFailed"); + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await revertingTarget.getAddress(), data, signature}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), selector, module.interface.encodeErrorResult("ModuleCallFailed", [])); }); it("rejects calls signed by the wrong key", async () => { - const {safe, target, module, sessionDeadline} = await setupSession(2); + const {safe, target, module, sessionDeadline, selector} = await setupSession(2); const badSessionKey = ethers.Wallet.createRandom(); const data = target.interface.encodeFunctionData("doAction"); @@ -263,24 +284,30 @@ describe("UsageBasedSessionModule", function () { await module.getAddress() ); - await expect( - module.execute(await safe.getAddress(), await target.getAddress(), data, signature) - ).to.be.revertedWithCustomError(module, "InvalidSignature"); + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), selector, module.interface.encodeErrorResult("InvalidSignature", [])); }); it("rejects calls with wrong nonce in signature", async () => { - const {safe, target, module, sessionDeadline, sessionKey} = await setupSession(2); + const {safe, target, module, sessionDeadline, sessionKey, selector} = await setupSession(2); const data = target.interface.encodeFunctionData("doAction"); // Use nonce 1 instead of 0 const signature = await signCall(sessionKey, safe, target, data, 1n, sessionDeadline, await module.getAddress()); - await expect( - module.execute(await safe.getAddress(), await target.getAddress(), data, signature) - ).to.be.revertedWithCustomError(module, "InvalidSignature"); + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), selector, module.interface.encodeErrorResult("InvalidSignature", [])); }); it("rejects calls with wrong target in signature", async () => { - const {safe, target, module, sessionDeadline, sessionKey} = await setupSession(2); + const {safe, target, module, sessionDeadline, sessionKey, selector} = await setupSession(2); const data = target.interface.encodeFunctionData("doAction"); const OtherTarget = await ethers.getContractFactory("TestSessionTarget"); @@ -297,26 +324,233 @@ describe("UsageBasedSessionModule", function () { await module.getAddress() ); - await expect( - module.execute(await safe.getAddress(), await target.getAddress(), data, signature) - ).to.be.revertedWithCustomError(module, "InvalidSignature"); + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), selector, module.interface.encodeErrorResult("InvalidSignature", [])); }); it("enforces group daily limits", async () => { - const {sessionKey, safe, target, module, sessionDeadline} = await setupSession(2); + const {sessionKey, safe, target, module, sessionDeadline, selector} = await setupSession(2); const data = target.interface.encodeFunctionData("doAction"); const sig0 = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); - await module.execute(await safe.getAddress(), await target.getAddress(), data, sig0); + await module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature: sig0}, + ]); const sig1 = await signCall(sessionKey, safe, target, data, 1n, sessionDeadline, await module.getAddress()); - await module.execute(await safe.getAddress(), await target.getAddress(), data, sig1); + await module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature: sig1}, + ]); const sig2 = await signCall(sessionKey, safe, target, data, 2n, sessionDeadline, await module.getAddress()); - await expect( - module.execute(await safe.getAddress(), await target.getAddress(), data, sig2) - ).to.be.revertedWithCustomError(module, "GroupLimitReached"); + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature: sig2}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), selector, module.interface.encodeErrorResult("GroupLimitReached", [])); + }); + + it("executes a batch with mixed success and failure (same safe)", async () => { + const {sessionKey, safe, target, module, sessionDeadline, gameSubsidisationRegistry, selector} = + await setupSession(5); + + // Setup a reverting target + const RevertingTarget = await ethers.getContractFactory("TestSessionRevertingTarget"); + const revertingTarget = await RevertingTarget.deploy(); + const revertSelector = revertingTarget.interface.getFunction("revertAction")!.selector; + await gameSubsidisationRegistry.setFunctionGroup(await revertingTarget.getAddress(), revertSelector, 1); + + const dataSuccess = target.interface.encodeFunctionData("doAction"); + const sig0 = await signCall( + sessionKey, + safe, + target, + dataSuccess, + 0n, + sessionDeadline, + await module.getAddress() + ); + + const dataFail = revertingTarget.interface.encodeFunctionData("revertAction"); + const sig1 = await signCall( + sessionKey, + safe, + revertingTarget, + dataFail, + 1n, + sessionDeadline, + await module.getAddress() + ); + + // Use nonce 1n again because sig1 will fail and revert state + const sig2 = await signCall( + sessionKey, + safe, + target, + dataSuccess, + 1n, + sessionDeadline, + await module.getAddress() + ); + + const params = [ + {safe: await safe.getAddress(), target: await target.getAddress(), data: dataSuccess, signature: sig0}, + {safe: await safe.getAddress(), target: await revertingTarget.getAddress(), data: dataFail, signature: sig1}, + {safe: await safe.getAddress(), target: await target.getAddress(), data: dataSuccess, signature: sig2}, + ]; + + const tx = await module.executeBatch(params); + + // Verify successes + expect(await target.calls()).to.eq(2); + + // Verify failure event + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), revertSelector, module.interface.encodeErrorResult("ModuleCallFailed", [])); + + // Verify nonces incremented (total 2 successful increments) + await expect(tx) + .to.emit(module, "SessionNonceIncremented") + .withArgs(await safe.getAddress(), 1n); + await expect(tx) + .to.emit(module, "SessionNonceIncremented") + .withArgs(await safe.getAddress(), 2n); + }); + + it("executes a batch with mixed success and failure (different safes)", async () => { + const setup1 = await setupSession(5); + const {module, gameSubsidisationRegistry, owner} = setup1; + + // Setup safe2 + const Safe = await ethers.getContractFactory("TestSessionSafe"); + const safe2 = (await Safe.deploy(owner.address)) as any; + const Target = await ethers.getContractFactory("TestSessionTarget"); + const target2 = (await Target.deploy()) as any; + const selector2 = target2.interface.getFunction("doAction")!.selector; + await gameSubsidisationRegistry.setFunctionGroup(await target2.getAddress(), selector2, 1); + const sessionKey2 = ethers.Wallet.createRandom(); + await safe2.callEnableSession(module, sessionKey2.address, 3600); + const session2 = await module.getSession(await safe2.getAddress()); + + const data1 = setup1.target.interface.encodeFunctionData("doAction"); + const data2 = target2.interface.encodeFunctionData("doAction"); + + // Success for safe1 + const sig1 = await signCall( + setup1.sessionKey, + setup1.safe, + setup1.target, + data1, + 0n, + setup1.sessionDeadline, + await module.getAddress() + ); + + // Failure for safe2 (using wrong nonce) + const sig2 = await signCall( + sessionKey2, + safe2, + target2, + data2, + 999n, // Wrong nonce + session2.deadline, + await module.getAddress() + ); + + const params = [ + {safe: await setup1.safe.getAddress(), target: await setup1.target.getAddress(), data: data1, signature: sig1}, + {safe: await safe2.getAddress(), target: await target2.getAddress(), data: data2, signature: sig2}, + ]; + + const tx = await module.executeBatch(params); + + // Verify safe1 succeeded + expect(await setup1.target.calls()).to.eq(1); + // Verify safe2 failed + expect(await target2.calls()).to.eq(0); + + // Verify failure event for safe2 + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe2.getAddress(), selector2, module.interface.encodeErrorResult("InvalidSignature", [])); + + // Verify success for safe1 + await expect(tx) + .to.emit(module, "SessionNonceIncremented") + .withArgs(await setup1.safe.getAddress(), 1n); + }); + + it("handles duplicate items in a single batch (replay protection)", async () => { + const {sessionKey, safe, target, module, sessionDeadline, selector} = await setupSession(5); + const data = target.interface.encodeFunctionData("doAction"); + const signature = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); + + const params = [ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature}, + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature}, + ]; + + const tx = await module.executeBatch(params); + + // First item succeeds + expect(await target.calls()).to.eq(1); + // Second item fails because nonce was already incremented during the first item's execution + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs(await safe.getAddress(), selector, module.interface.encodeErrorResult("InvalidSignature", [])); + }); + + it("handles empty batch arrays", async () => { + const {module} = await setupSession(2); + await expect(module.executeBatch([])).to.be.revertedWithCustomError(module, "NoBatchItems"); + }); + + it("enforces separate daily limits for different groups in the same batch", async () => { + const {sessionKey, safe, target, module, sessionDeadline, gameSubsidisationRegistry} = await setupSession(1); + + const groupId1 = 1n; + const groupId2 = 2n; + const limit = 1n; + + // Set limit of 1 for both groups + await gameSubsidisationRegistry.setGroupLimit(groupId1, limit); + await gameSubsidisationRegistry.setGroupLimit(groupId2, limit); + + const selector = target.interface.getFunction("doAction").selector; + await gameSubsidisationRegistry.setFunctionGroup(await target.getAddress(), selector, groupId1); + const data = target.interface.encodeFunctionData("doAction"); + const sig1 = await signCall(sessionKey, safe, target, data, 0n, sessionDeadline, await module.getAddress()); + + // Submit first call (consumes group 1 quota) + await module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature: sig1}, + ]); + expect(await target.calls()).to.eq(1); + + // Now change function to groupId2 and submit again for same safe + await gameSubsidisationRegistry.setFunctionGroup(await target.getAddress(), selector, groupId2); + const sig2 = await signCall(sessionKey, safe, target, data, 1n, sessionDeadline, await module.getAddress()); + + const tx = await module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature: sig2}, + ]); + await expect(tx).to.not.emit(module, "BatchItemFailed"); + expect(await target.calls()).to.eq(2); + + // Group 3 (unset, defaults to 0) should fail because group 0 has no limit/quota initialized or exceeds 0 + const sig3 = await signCall(sessionKey, safe, target, data, 2n, sessionDeadline, await module.getAddress()); + await gameSubsidisationRegistry.setFunctionGroup(await target.getAddress(), selector, 0n); + const tx2 = await module.executeBatch([ + {safe: await safe.getAddress(), target: await target.getAddress(), data, signature: sig3}, + ]); + await expect(tx2).to.emit(module, "BatchItemFailed"); }); it("fails if signer is not whitelisted", async () => { @@ -328,7 +562,9 @@ describe("UsageBasedSessionModule", function () { // Attempt to execute with 'other' (not whitelisted) await expect( - module.connect(other).execute(await safe.getAddress(), await target.getAddress(), data, signature) + module + .connect(other) + .executeBatch([{safe: await safe.getAddress(), target: await target.getAddress(), data, signature}]) ).to.be.revertedWithCustomError(module, "UnauthorizedSigner"); }); @@ -348,7 +584,9 @@ describe("UsageBasedSessionModule", function () { const balanceBefore = await ethers.provider.getBalance(otherWhitelisted.address); const tx = await module .connect(otherWhitelisted) - .execute(await safe.getAddress(), await target.getAddress(), data, signature, {gasPrice}); + .executeBatch([{safe: await safe.getAddress(), target: await target.getAddress(), data, signature}], { + gasPrice, + }); const receipt = await tx.wait(); const balanceAfter = await ethers.provider.getBalance(otherWhitelisted.address); @@ -380,6 +618,16 @@ describe("UsageBasedSessionModule", function () { "OwnableUnauthorizedAccount" ); }); + + it("can remove a signer from whitelist", async () => { + const {module} = await setupSession(2); + const [, other] = await ethers.getSigners(); + + await module.setWhitelistedSigner([other.address], true); + await module.setWhitelistedSigner([other.address], false); + const tx = module.connect(other).executeBatch([]); // Empty batch to trigger access check first + await expect(tx).to.be.revertedWithCustomError(module, "UnauthorizedSigner"); + }); }); describe("PlayerNFT integration", function () { @@ -438,7 +686,9 @@ describe("UsageBasedSessionModule", function () { await module.getAddress() ); - const tx = await module.execute(await safe.getAddress(), await playerNFT.getAddress(), data, signature); + const tx = await module.executeBatch([ + {safe: await safe.getAddress(), target: await playerNFT.getAddress(), data, signature}, + ]); const receipt = await tx.wait(); // Parse NewPlayer event from logs @@ -472,7 +722,9 @@ describe("UsageBasedSessionModule", function () { true, ]); const sig1 = await signCall(sessionKey, safe, playerNFT, data1, 0n, sessionDeadline, await module.getAddress()); - await module.execute(await safe.getAddress(), await playerNFT.getAddress(), data1, sig1); + await module.executeBatch([ + {safe: await safe.getAddress(), target: await playerNFT.getAddress(), data: data1, signature: sig1}, + ]); // Second mint with different name const data2 = playerNFT.interface.encodeFunctionData("mint", [ @@ -485,7 +737,9 @@ describe("UsageBasedSessionModule", function () { true, ]); const sig2 = await signCall(sessionKey, safe, playerNFT, data2, 1n, sessionDeadline, await module.getAddress()); - await module.execute(await safe.getAddress(), await playerNFT.getAddress(), data2, sig2); + await module.executeBatch([ + {safe: await safe.getAddress(), target: await playerNFT.getAddress(), data: data2, signature: sig2}, + ]); // Third mint should fail due to group limit const data3 = playerNFT.interface.encodeFunctionData("mint", [ @@ -498,9 +752,16 @@ describe("UsageBasedSessionModule", function () { true, ]); const sig3 = await signCall(sessionKey, safe, playerNFT, data3, 2n, sessionDeadline, await module.getAddress()); - await expect( - module.execute(await safe.getAddress(), await playerNFT.getAddress(), data3, sig3) - ).to.be.revertedWithCustomError(module, "GroupLimitReached"); + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await playerNFT.getAddress(), data: data3, signature: sig3}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs( + await safe.getAddress(), + playerNFT.interface.getFunction("mint")!.selector, + module.interface.encodeErrorResult("GroupLimitReached", []) + ); }); it("rejects PlayerNFT mint with invalid session key signature", async () => { @@ -526,9 +787,16 @@ describe("UsageBasedSessionModule", function () { await module.getAddress() ); - await expect( - module.execute(await safe.getAddress(), await playerNFT.getAddress(), data, signature) - ).to.be.revertedWithCustomError(module, "InvalidSignature"); + const tx = module.executeBatch([ + {safe: await safe.getAddress(), target: await playerNFT.getAddress(), data, signature}, + ]); + await expect(tx) + .to.emit(module, "BatchItemFailed") + .withArgs( + await safe.getAddress(), + playerNFT.interface.getFunction("mint")!.selector, + module.interface.encodeErrorResult("InvalidSignature", []) + ); }); }); }); From 688696da27dda6011f1c288141a5d9a74de50ec1 Mon Sep 17 00:00:00 2001 From: deif Date: Sat, 21 Feb 2026 11:33:31 +0000 Subject: [PATCH 09/13] update account abstraction architecture helper --- .github/account-abstraction.instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/account-abstraction.instructions.md b/.github/account-abstraction.instructions.md index f4ccac48..730f316c 100644 --- a/.github/account-abstraction.instructions.md +++ b/.github/account-abstraction.instructions.md @@ -7,7 +7,7 @@ applyTo: "contracts/Session/*.sol" - Aim of the Session module is to abstract Web3 accounts away so that any user with an email and a passkey can sign transactions without paying for gas or require signing multiple transactions. The architecture for achieving this is as follows: 1. User registers with an email and passkey -2. A 2 of 3 multi-sig Safe is created where 1 signer is the user passkey, 1 signer is a recovery DAO owned multi-sig Safe, 1 signer is a hot DAO owned EOA that exists to execute and sign transactions on behalf of the user to subsidise the gas cost. +2. A 1 of 1 multi-sig Safe is created where the signer is the user passkey. 3. User authenticates with their passkey to create a new session - `UsageBasedSessionModule.enableSession` ([contracts/Session/UsageBasedSessionModule.sol](contracts/Session/UsageBasedSessionModule.sol)). The session key passed is a temporary throwaway private key stored in the users browser/device for the duration set. 4. User uses their session private key to sign game transactions, then passes the arguments via an api to the hot DAO EOA signer that will call `UsageBasedSessionModule.execute`, and thus the designated game action. From 8e3a249aa5e322f1f8133400cb9dbd56711a6919 Mon Sep 17 00:00:00 2001 From: deif Date: Sat, 21 Feb 2026 13:11:18 +0000 Subject: [PATCH 10/13] upgrade black market trader for correct stock counts --- .openzeppelin/sonic.json | 221 +++++++++++++++++++++++++ contracts/Events/BlackMarketTrader.sol | 2 + 2 files changed, 223 insertions(+) diff --git a/.openzeppelin/sonic.json b/.openzeppelin/sonic.json index cc268a37..76a9e88f 100644 --- a/.openzeppelin/sonic.json +++ b/.openzeppelin/sonic.json @@ -154011,6 +154011,227 @@ ] } } + }, + "4622888741798a46ada3f869803d3339134f06bf7e9e6613199279b2c3c8ade4": { + "address": "0x906E7F986523D9ea521a3cD45791977124955Eb3", + "txHash": "0x424f17979790787b9314279c5c9367cacb28152082df018a4b73b20852f15fad", + "layout": { + "solcVersion": "0.8.28", + "storage": [ + { + "label": "_itemNFT", + "offset": 0, + "slot": "0", + "type": "t_contract(ItemNFT)12345", + "contract": "BlackMarketTrader", + "src": "contracts\\Events\\BlackMarketTrader.sol:64" + }, + { + "label": "_shopCollections", + "offset": 0, + "slot": "1", + "type": "t_mapping(t_uint256,t_struct(ShopCollection)9629_storage)", + "contract": "BlackMarketTrader", + "src": "contracts\\Events\\BlackMarketTrader.sol:66" + }, + { + "label": "_requestIdToGlobalEventId", + "offset": 0, + "slot": "2", + "type": "t_mapping(t_uint256,t_uint256)", + "contract": "BlackMarketTrader", + "src": "contracts\\Events\\BlackMarketTrader.sol:67" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_struct(InitializableStorage)73_storage": { + "label": "struct Initializable.InitializableStorage", + "members": [ + { + "label": "_initialized", + "type": "t_uint64", + "offset": 0, + "slot": "0" + }, + { + "label": "_initializing", + "type": "t_bool", + "offset": 8, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(OwnableStorage)13_storage": { + "label": "struct OwnableUpgradeable.OwnableStorage", + "members": [ + { + "label": "_owner", + "type": "t_address", + "offset": 0, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_uint64": { + "label": "uint64", + "numberOfBytes": "8" + }, + "t_array(t_uint16)dyn_storage": { + "label": "uint16[]", + "numberOfBytes": "32" + }, + "t_contract(ItemNFT)12345": { + "label": "contract ItemNFT", + "numberOfBytes": "20" + }, + "t_mapping(t_uint16,t_struct(ShopItem)9614_storage)": { + "label": "mapping(uint16 => struct BlackMarketTrader.ShopItem)", + "numberOfBytes": "32" + }, + "t_mapping(t_uint256,t_struct(ShopCollection)9629_storage)": { + "label": "mapping(uint256 => struct BlackMarketTrader.ShopCollection)", + "numberOfBytes": "32" + }, + "t_mapping(t_uint256,t_uint256)": { + "label": "mapping(uint256 => uint256)", + "numberOfBytes": "32" + }, + "t_struct(ShopCollection)9629_storage": { + "label": "struct BlackMarketTrader.ShopCollection", + "members": [ + { + "label": "acceptedItemId", + "type": "t_uint16", + "offset": 0, + "slot": "0" + }, + { + "label": "lastRequestDay", + "type": "t_uint40", + "offset": 2, + "slot": "0" + }, + { + "label": "lastFulfillmentDay", + "type": "t_uint40", + "offset": 7, + "slot": "0" + }, + { + "label": "itemTokenIds", + "type": "t_array(t_uint16)dyn_storage", + "offset": 0, + "slot": "1" + }, + { + "label": "shopItems", + "type": "t_mapping(t_uint16,t_struct(ShopItem)9614_storage)", + "offset": 0, + "slot": "2" + } + ], + "numberOfBytes": "96" + }, + "t_struct(ShopItem)9614_storage": { + "label": "struct BlackMarketTrader.ShopItem", + "members": [ + { + "label": "price", + "type": "t_uint128", + "offset": 0, + "slot": "0" + }, + { + "label": "tokenId", + "type": "t_uint16", + "offset": 16, + "slot": "0" + }, + { + "label": "amountPerPurchase", + "type": "t_uint16", + "offset": 18, + "slot": "0" + }, + { + "label": "currentStock", + "type": "t_uint16", + "offset": 20, + "slot": "0" + }, + { + "label": "stock", + "type": "t_uint16", + "offset": 22, + "slot": "0" + }, + { + "label": "isActive", + "type": "t_bool", + "offset": 24, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_uint128": { + "label": "uint128", + "numberOfBytes": "16" + }, + "t_uint16": { + "label": "uint16", + "numberOfBytes": "2" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint40": { + "label": "uint40", + "numberOfBytes": "5" + } + }, + "namespaces": { + "erc7201:openzeppelin.storage.Ownable": [ + { + "contract": "OwnableUpgradeable", + "label": "_owner", + "type": "t_address", + "src": "@openzeppelin\\contracts-upgradeable\\access\\OwnableUpgradeable.sol:24", + "offset": 0, + "slot": "0" + } + ], + "erc7201:openzeppelin.storage.Initializable": [ + { + "contract": "Initializable", + "label": "_initialized", + "type": "t_uint64", + "src": "@openzeppelin\\contracts-upgradeable\\proxy\\utils\\Initializable.sol:69", + "offset": 0, + "slot": "0" + }, + { + "contract": "Initializable", + "label": "_initializing", + "type": "t_bool", + "src": "@openzeppelin\\contracts-upgradeable\\proxy\\utils\\Initializable.sol:73", + "offset": 8, + "slot": "0" + } + ] + } + } } } } diff --git a/contracts/Events/BlackMarketTrader.sol b/contracts/Events/BlackMarketTrader.sol index 303d4fe5..26b5ca73 100644 --- a/contracts/Events/BlackMarketTrader.sol +++ b/contracts/Events/BlackMarketTrader.sol @@ -221,6 +221,8 @@ contract BlackMarketTrader is require(itemsToEdit[i].price != 0, PriceCannotBeZero()); item.price = itemsToEdit[i].price; item.stock = itemsToEdit[i].stock; + item.currentStock = itemsToEdit[i].stock; // Reset stock to new stock amount when edited + item.amountPerPurchase = itemsToEdit[i].amountPerPurchase; } emit EditShopItems(itemsToEdit, globalEventId); } From 2f8cfdd0c66c32de417a53912eeeca92ed5b153b Mon Sep 17 00:00:00 2001 From: deif Date: Sun, 22 Feb 2026 20:10:30 +0000 Subject: [PATCH 11/13] add additional protections to session module --- .../Session/GameSubsidisationRegistry.sol | 23 ++ contracts/Session/UsageBasedSessionModule.sol | 136 +++++++-- .../interfaces/IGameSubsidisationRegistry.sol | 4 + contracts/test/Session/TestSessionHelpers.sol | 5 + data/abi/GameSubsidisationRegistry.json | 75 +++++ data/abi/UsageBasedSessionModule.json | 265 +++++++++++++++++- package.json | 2 + scripts/contractAddresses.ts | 18 ++ test/Session/UsageBasedSessionModule.ts | 211 ++++++++++++-- 9 files changed, 694 insertions(+), 45 deletions(-) diff --git a/contracts/Session/GameSubsidisationRegistry.sol b/contracts/Session/GameSubsidisationRegistry.sol index ccab605a..2188fb68 100644 --- a/contracts/Session/GameSubsidisationRegistry.sol +++ b/contracts/Session/GameSubsidisationRegistry.sol @@ -27,13 +27,36 @@ contract GameSubsidisationRegistry is UUPSUpgradeable, OwnableUpgradeable, IGame return _groupDailyLimits[_groupId]; } + function getGroupAndLimit(address _contract, bytes4 _selector) external view override returns (uint256 groupId, uint256 limit) { + groupId = _functionToLimitGroup[_contract][_selector]; + limit = _groupDailyLimits[groupId]; + } + function setFunctionGroup(address _contract, bytes4 _selector, uint256 _groupId) external override onlyOwner { _functionToLimitGroup[_contract][_selector] = _groupId; } + function setFunctionGroups( + address[] calldata _contracts, + bytes4[] calldata _selectors, + uint256[] calldata _groupIds + ) external override onlyOwner { + require(_contracts.length == _selectors.length && _selectors.length == _groupIds.length, LengthMismatch()); + for (uint256 i = 0; i < _contracts.length; ++i) { + _functionToLimitGroup[_contracts[i]][_selectors[i]] = _groupIds[i]; + } + } + function setGroupLimit(uint256 _groupId, uint256 _limit) external override onlyOwner { _groupDailyLimits[_groupId] = _limit; } + function setGroupLimits(uint256[] calldata _groupIds, uint256[] calldata _limits) external override onlyOwner { + require(_groupIds.length == _limits.length, LengthMismatch()); + for (uint256 i = 0; i < _groupIds.length; ++i) { + _groupDailyLimits[_groupIds[i]] = _limits[i]; + } + } + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} } diff --git a/contracts/Session/UsageBasedSessionModule.sol b/contracts/Session/UsageBasedSessionModule.sol index 39a19068..802150ff 100644 --- a/contracts/Session/UsageBasedSessionModule.sol +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.28; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {EIP712Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {Enum} from "../interfaces/external/Enum.sol"; import {ISafe} from "../interfaces/external/ISafe.sol"; @@ -11,7 +13,7 @@ import {IGameSubsidisationRegistry} from "../interfaces/IGameSubsidisationRegist /// @title UsageBasedSessionModule /// @notice A module for Gnosis Safe that allows for session keys with rate-limited actions -contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712Upgradeable { +contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712Upgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable { error ExistingSessionActive(); error NoSessionKey(); error ActionNotPermitted(); @@ -23,18 +25,26 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U error InvalidCallData(); error ModuleCallFailed(); error UnauthorizedSigner(); - error RefundFailed(); error OnlyInternal(); error NoBatchItems(); + error BatchTooLarge(); + error ZeroAddress(); + error SessionOpsPerDayLimitReached(); event SessionEnabled(address indexed safe, address indexed sessionKey, uint48 deadline); event SessionRevoked(address indexed safe); event SessionNonceIncremented(address indexed safe, uint256 newNonce); event WhitelistedSignersUpdated(address[] signers, bool whitelisted); event BatchItemFailed(address indexed safe, bytes4 selector, bytes errorData); + event RelayerRefundFailed(address indexed relayer, uint256 amount); + event GasOverheadUpdated(uint256 newOverhead); + event RegistryUpdated(address indexed newRegistry); + event ETHWithdrawn(address indexed to, uint256 amount); + event SessionOpsPerDayUpdated(uint16 newLimit); uint48 public constant MAX_SESSION_DURATION = 30 days; - uint256 public constant GAS_OVERHEAD = 30000; // 21000 base tx + 9k transfer + uint256 public constant MAX_BATCH_SIZE = 50; + uint16 public constant DEFAULT_SESSION_OPS_PER_DAY = 3; bytes32 private constant SESSION_TYPEHASH = keccak256( "UsageBasedSession(address safe,address target,bytes data,uint256 nonce,uint48 sessionDeadline)" ); @@ -50,8 +60,10 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U } struct Session { - address sessionKey; - uint48 deadline; + address sessionKey; // 20 bytes \ + uint48 deadline; // 6 bytes } packed into one 32-byte slot + uint32 opDay; // 4 bytes — UTC day number of last session op + uint16 opCount; // 2 bytes — number of session ops performed today } struct ExecuteParams { @@ -65,6 +77,8 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U mapping(address => Session) private _sessions; // Safe => Session mapping(address => UserUsage) private _usage; // Safe => Usage mapping(address => bool) private _whitelistedSigners; + uint256 private _gasOverhead; + uint16 private _sessionOpsPerDay; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -74,7 +88,11 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U function initialize(address owner, IGameSubsidisationRegistry registry) public initializer { __Ownable_init(owner); __EIP712_init("UsageBasedSessionModule", "1"); + __ReentrancyGuard_init(); + __Pausable_init(); _registry = registry; + _gasOverhead = 30000; // 21000 base tx + 9k transfer + _sessionOpsPerDay = DEFAULT_SESSION_OPS_PER_DAY; } /** @@ -83,40 +101,73 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U function enableSession(address _sessionKey, uint48 _duration) external { require(_sessionKey != address(0), ZeroSessionKey()); require(_duration > 0 && _duration <= MAX_SESSION_DURATION, InvalidSessionDuration()); - require(_sessions[msg.sender].deadline < block.timestamp, ExistingSessionActive()); - _sessions[msg.sender] = Session({sessionKey: _sessionKey, deadline: uint48(block.timestamp) + _duration}); + Session storage session = _sessions[msg.sender]; + require(session.deadline < block.timestamp, ExistingSessionActive()); - emit SessionEnabled(msg.sender, _sessionKey, _sessions[msg.sender].deadline); + uint32 today = uint32(block.timestamp / 1 days); + if (session.opDay == today) { + require(session.opCount < _sessionOpsPerDay, SessionOpsPerDayLimitReached()); + session.opCount += 1; + } else { + session.opDay = today; + session.opCount = 1; + } + + session.sessionKey = _sessionKey; + session.deadline = uint48(block.timestamp) + _duration; + + emit SessionEnabled(msg.sender, _sessionKey, session.deadline); } /** * @notice Explicitly revoke the current session early. Must be called BY THE SAFE */ function revokeSession() external { + uint32 today = uint32(block.timestamp / 1 days); + Session storage session = _sessions[msg.sender]; + uint16 newOpCount; + if (session.opDay == today) { + require(session.opCount < _sessionOpsPerDay, SessionOpsPerDayLimitReached()); + newOpCount = session.opCount + 1; + } else { + newOpCount = 1; + } + delete _sessions[msg.sender]; + // Preserve daily op tracking so the delete doesn't reset the protection + _sessions[msg.sender].opDay = today; + _sessions[msg.sender].opCount = newOpCount; + emit SessionRevoked(msg.sender); } - function executeBatch(ExecuteParams[] calldata params) external { + function executeBatch(ExecuteParams[] calldata params) external nonReentrant whenNotPaused { + uint256 startGas = gasleft(); require(_whitelistedSigners[msg.sender], UnauthorizedSigner()); require(params.length > 0, NoBatchItems()); - uint256 startGas = gasleft(); + require(params.length <= MAX_BATCH_SIZE, BatchTooLarge()); + uint256 successCount; for (uint256 i = 0; i < params.length; i++) { try this.executeSingle(params[i]) { - // Success + ++successCount; } catch (bytes memory reason) { bytes4 selector = params[i].data.length >= 4 ? bytes4(params[i].data[0:4]) : bytes4(0); emit BatchItemFailed(params[i].safe, selector, reason); } } - uint256 gasUsed = startGas - gasleft() + GAS_OVERHEAD + msg.data.length * 16; - uint256 refundAmount = gasUsed * tx.gasprice; - if (refundAmount > 0) { - (bool refundSuccess, ) = msg.sender.call{value: refundAmount}(""); // Refund the relayer directly - require(refundSuccess, RefundFailed()); + // Only refund if at least one item succeeded (prevents drain via all-failing batches) + if (successCount > 0) { + uint256 gasUsed = startGas - gasleft() + _gasOverhead + msg.data.length * 16; + uint256 refundAmount = gasUsed * tx.gasprice; + if (refundAmount > 0) { + (bool refundSuccess, ) = msg.sender.call{value: refundAmount}(""); // Refund the relayer directly + if (!refundSuccess) { + emit RelayerRefundFailed(msg.sender, refundAmount); + } + } } } @@ -128,7 +179,7 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U _execute(params.safe, params.target, params.data, params.signature); } - function _execute(address safe, address target, bytes calldata data, bytes calldata signature) internal { + function _execute(address safe, address target, bytes calldata data, bytes calldata signature) internal { require(data.length >= 4, InvalidCallData()); // 1. Basic Session Check @@ -136,12 +187,15 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U require(session.sessionKey != address(0), NoSessionKey()); require(session.deadline >= block.timestamp, SessionExpired()); - // 2. Identify the action (extract selector from data) + // 2. Identify the action (extract selector from data) — single registry call (M2 optimisation) bytes4 selector = bytes4(data[0:4]); - uint256 groupId = _registry.functionToLimitGroup(target, selector); + (uint256 groupId, uint256 limit) = _registry.getGroupAndLimit(target, selector); require(groupId > 0, ActionNotPermitted()); - uint256 currentDay = block.timestamp / 1 days; + uint256 currentDay; + unchecked { + currentDay = block.timestamp / 1 days; + } UserUsage storage user = _usage[safe]; GroupUsage storage group = user.groupUsage[groupId]; if (group.day != uint40(currentDay)) { @@ -150,7 +204,6 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U } uint256 currentUsage = group.count; - uint256 limit = _registry.groupDailyLimits(groupId); require(currentUsage < limit, GroupLimitReached()); uint256 currentNonce = user.nonce; @@ -181,11 +234,52 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U function setWhitelistedSigner(address[] calldata signers, bool whitelisted) external onlyOwner { for (uint256 i = 0; i < signers.length; i++) { + require(signers[i] != address(0), ZeroAddress()); _whitelistedSigners[signers[i]] = whitelisted; } emit WhitelistedSignersUpdated(signers, whitelisted); } + function withdrawETH(address to, uint256 amount) external onlyOwner { + require(to != address(0), ZeroAddress()); + emit ETHWithdrawn(to, amount); + (bool success, ) = to.call{value: amount}(""); + require(success, ModuleCallFailed()); + } + + function setRegistry(IGameSubsidisationRegistry registry) external onlyOwner { + require(address(registry) != address(0), ZeroAddress()); + _registry = registry; + emit RegistryUpdated(address(registry)); + } + + function setGasOverhead(uint256 overhead) external onlyOwner { + _gasOverhead = overhead; + emit GasOverheadUpdated(overhead); + } + + function setSessionOpsPerDay(uint16 limit) external onlyOwner { + require(limit > 0, InvalidSessionDuration()); + _sessionOpsPerDay = limit; + emit SessionOpsPerDayUpdated(limit); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function getGasOverhead() external view returns (uint256) { + return _gasOverhead; + } + + function getSessionOpsPerDay() external view returns (uint16) { + return _sessionOpsPerDay; + } + function getSession(address safe) external view returns (Session memory) { return _sessions[safe]; } diff --git a/contracts/interfaces/IGameSubsidisationRegistry.sol b/contracts/interfaces/IGameSubsidisationRegistry.sol index af82e52b..539001de 100644 --- a/contracts/interfaces/IGameSubsidisationRegistry.sol +++ b/contracts/interfaces/IGameSubsidisationRegistry.sol @@ -2,10 +2,14 @@ pragma solidity ^0.8.28; interface IGameSubsidisationRegistry { + error LengthMismatch(); function functionToLimitGroup(address _contract, bytes4 _selector) external view returns (uint256); function groupDailyLimits(uint256 _groupId) external view returns (uint256); + function getGroupAndLimit(address _contract, bytes4 _selector) external view returns (uint256 groupId, uint256 limit); function setFunctionGroup(address _contract, bytes4 _selector, uint256 _groupId) external; + function setFunctionGroups(address[] calldata _contracts, bytes4[] calldata _selectors, uint256[] calldata _groupIds) external; function setGroupLimit(uint256 _groupId, uint256 _limit) external; + function setGroupLimits(uint256[] calldata _groupIds, uint256[] calldata _limits) external; } diff --git a/contracts/test/Session/TestSessionHelpers.sol b/contracts/test/Session/TestSessionHelpers.sol index e13e23a6..8b44abf7 100644 --- a/contracts/test/Session/TestSessionHelpers.sol +++ b/contracts/test/Session/TestSessionHelpers.sol @@ -18,6 +18,11 @@ contract TestSessionSafe is ERC1155Holder { module.enableSession(sessionKey, duration); } + function callRevokeSession(UsageBasedSessionModule module) external { + require(msg.sender == owner, "Not owner"); + module.revokeSession(); + } + function execTransactionFromModule(address to, uint256 value, bytes calldata data, Enum.Operation operation) external returns (bool success) diff --git a/data/abi/GameSubsidisationRegistry.json b/data/abi/GameSubsidisationRegistry.json index 209dc86b..f16d205e 100644 --- a/data/abi/GameSubsidisationRegistry.json +++ b/data/abi/GameSubsidisationRegistry.json @@ -41,6 +41,11 @@ "name": "InvalidInitialization", "type": "error" }, + { + "inputs": [], + "name": "LengthMismatch", + "type": "error" + }, { "inputs": [], "name": "NotInitializing", @@ -166,6 +171,35 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_contract", + "type": "address" + }, + { + "internalType": "bytes4", + "name": "_selector", + "type": "bytes4" + } + ], + "name": "getGroupAndLimit", + "outputs": [ + { + "internalType": "uint256", + "name": "groupId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "limit", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -254,6 +288,29 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_contracts", + "type": "address[]" + }, + { + "internalType": "bytes4[]", + "name": "_selectors", + "type": "bytes4[]" + }, + { + "internalType": "uint256[]", + "name": "_groupIds", + "type": "uint256[]" + } + ], + "name": "setFunctionGroups", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -272,6 +329,24 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256[]", + "name": "_groupIds", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "_limits", + "type": "uint256[]" + } + ], + "name": "setGroupLimits", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/data/abi/UsageBasedSessionModule.json b/data/abi/UsageBasedSessionModule.json index 18dc8deb..e6ae983e 100644 --- a/data/abi/UsageBasedSessionModule.json +++ b/data/abi/UsageBasedSessionModule.json @@ -20,6 +20,11 @@ "name": "AddressEmptyCode", "type": "error" }, + { + "inputs": [], + "name": "BatchTooLarge", + "type": "error" + }, { "inputs": [], "name": "ECDSAInvalidSignature", @@ -63,11 +68,21 @@ "name": "ERC1967NonPayable", "type": "error" }, + { + "inputs": [], + "name": "EnforcedPause", + "type": "error" + }, { "inputs": [], "name": "ExistingSessionActive", "type": "error" }, + { + "inputs": [], + "name": "ExpectedPause", + "type": "error" + }, { "inputs": [], "name": "FailedCall", @@ -147,7 +162,7 @@ }, { "inputs": [], - "name": "RefundFailed", + "name": "ReentrancyGuardReentrantCall", "type": "error" }, { @@ -155,6 +170,11 @@ "name": "SessionExpired", "type": "error" }, + { + "inputs": [], + "name": "SessionOpsPerDayLimitReached", + "type": "error" + }, { "inputs": [], "name": "UUPSUnauthorizedCallContext", @@ -176,6 +196,11 @@ "name": "UnauthorizedSigner", "type": "error" }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, { "inputs": [], "name": "ZeroSessionKey", @@ -212,6 +237,38 @@ "name": "EIP712DomainChanged", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ETHWithdrawn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newOverhead", + "type": "uint256" + } + ], + "name": "GasOverheadUpdated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -244,6 +301,51 @@ "name": "OwnershipTransferred", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "newRegistry", + "type": "address" + } + ], + "name": "RegistryUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "relayer", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "RelayerRefundFailed", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -288,6 +390,19 @@ "name": "SessionNonceIncremented", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLimit", + "type": "uint16" + } + ], + "name": "SessionOpsPerDayUpdated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -301,6 +416,19 @@ "name": "SessionRevoked", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -339,7 +467,20 @@ }, { "inputs": [], - "name": "GAS_OVERHEAD", + "name": "DEFAULT_SESSION_OPS_PER_DAY", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_BATCH_SIZE", "outputs": [ { "internalType": "uint256", @@ -507,6 +648,19 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "getGasOverhead", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -528,6 +682,16 @@ "internalType": "uint48", "name": "deadline", "type": "uint48" + }, + { + "internalType": "uint32", + "name": "opDay", + "type": "uint32" + }, + { + "internalType": "uint16", + "name": "opCount", + "type": "uint16" } ], "internalType": "struct UsageBasedSessionModule.Session", @@ -538,6 +702,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "getSessionOpsPerDay", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -569,6 +746,26 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "proxiableUUID", @@ -596,6 +793,45 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "overhead", + "type": "uint256" + } + ], + "name": "setGasOverhead", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IGameSubsidisationRegistry", + "name": "registry", + "type": "address" + } + ], + "name": "setRegistry", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "limit", + "type": "uint16" + } + ], + "name": "setSessionOpsPerDay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -627,6 +863,13 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -645,6 +888,24 @@ "stateMutability": "payable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "withdrawETH", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "stateMutability": "payable", "type": "receive" diff --git a/package.json b/package.json index a6432361..385da142 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,8 @@ "deployInstantVRFActionTestData": "npx hardhat run scripts/deployInstantVRFActionTestData.ts", "deployWinter26Release": "npx hardhat run scripts/deployWinter26Release.ts", "deployBlackMarketTrader": "npx hardhat run scripts/deployBlackMarketTrader.ts", + "deployAccountAbstraction": "npx hardhat run scripts/deployAccountAbstraction.ts", + "setGameSubsidyLimits": "npx hardhat run scripts/setGameSubsidyLimits.ts", "upgradeVRF": "npx hardhat run scripts/integratePaintswapVRF.ts", "addCosmetics": "npx hardhat run scripts/addCosmetics.ts", "addItems": "npx hardhat run scripts/addItems.ts", diff --git a/scripts/contractAddresses.ts b/scripts/contractAddresses.ts index c9be5fe5..83020e3d 100644 --- a/scripts/contractAddresses.ts +++ b/scripts/contractAddresses.ts @@ -55,6 +55,9 @@ let cosmetics; let usdc; let globalEvent; let blackMarketTrader; +let usageBasedSessionModule; +let gameSubsidisationRegistry; +let subsidySigners: string[] = []; // Third party stuff chain specific addresses const chainId = process.env.CHAIN_ID; @@ -125,6 +128,9 @@ if (!isBeta) { cosmetics = "0xb30b177b6c8c21370a72d7cada5f627519c91432"; globalEvent = "0x6aca0ec5ad8158ab112f0fdf76e2c3ed6bfa11e2"; blackMarketTrader = "0x4f9911214d811b5acdc4d1911067f614e81c808e"; + usageBasedSessionModule = ""; + gameSubsidisationRegistry = ""; + subsidySigners = []; } else { bridge = "0x4a4988daecaad326aec386e70fb0e6e6af5bda1a"; worldActions = "0x3a965bf890e5ac353603420cc8d4c821d1f8a765"; @@ -175,6 +181,15 @@ if (!isBeta) { cosmetics = "0x9ac94b923333406d1c8b390ab606f90d6526c187"; globalEvent = "0x8d61f3135a9f39b685b9765976e6a0f0572aeca5"; blackMarketTrader = "0xac619719cdcf1fc03438c7b9aff737993feae851"; + usageBasedSessionModule = ""; + gameSubsidisationRegistry = ""; + subsidySigners = [ + "0xd774bf717A0AfC12F511728Abe06a37e437923D2", + "0x2047f1aaEb79CbDC51c730D3dc121EE76E5e1F14", + "0x5B6283015D5eFCca3f268f4D805F961209BaCa70", + "0x1Bf3c9b8e7C1a5A4D2b9B1c3E7e5F8a2A1d3E4f5", + "0x85A05274359dAAF8615b0362dcde9f1F2bf57f28", + ]; } export const BRIDGE_ADDRESS = bridge; @@ -237,6 +252,9 @@ export const COSMETICS_ADDRESS = cosmetics; export const USDC_ADDRESS = usdc; export const GLOBAL_EVENT_ADDRESS = globalEvent; export const BLACK_MARKET_TRADER_ADDRESS = blackMarketTrader; +export const USAGE_BASED_SESSION_MODULE_ADDRESS = usageBasedSessionModule; +export const GAME_SUBSIDISATION_REGISTRY_ADDRESS = gameSubsidisationRegistry; +export const SUBSIDY_SIGNERS = subsidySigners; // VRF export const VRF_ADDRESS = vrf; diff --git a/test/Session/UsageBasedSessionModule.ts b/test/Session/UsageBasedSessionModule.ts index c7934ff4..a227c0d5 100644 --- a/test/Session/UsageBasedSessionModule.ts +++ b/test/Session/UsageBasedSessionModule.ts @@ -100,14 +100,7 @@ describe("UsageBasedSessionModule", function () { describe("enableSession & revokeSession", function () { it("fails to enable a session with zero address session key", async () => { const {module, safe} = await setupSession(2); - // Revoke first - await safe.execTransactionFromModule( - await module.getAddress(), - 0, - module.interface.encodeFunctionData("revokeSession"), - 0 - ); - + // ZeroSessionKey is checked before ExistingSessionActive / SessionOpsPerDayLimitReached await expect( safe.callEnableSession(await module.getAddress(), ethers.ZeroAddress, 3600) ).to.be.revertedWithCustomError(module, "ZeroSessionKey"); @@ -115,13 +108,7 @@ describe("UsageBasedSessionModule", function () { it("fails to enable a session with zero duration", async () => { const {module, safe} = await setupSession(2); - await safe.execTransactionFromModule( - await module.getAddress(), - 0, - module.interface.encodeFunctionData("revokeSession"), - 0 - ); - + // InvalidSessionDuration is checked before ExistingSessionActive / SessionOpsPerDayLimitReached await expect( safe.callEnableSession(await module.getAddress(), ethers.Wallet.createRandom().address, 0) ).to.be.revertedWithCustomError(module, "InvalidSessionDuration"); @@ -129,13 +116,7 @@ describe("UsageBasedSessionModule", function () { it("fails to enable a session with duration exceeding max", async () => { const {module, safe} = await setupSession(2); - await safe.execTransactionFromModule( - await module.getAddress(), - 0, - module.interface.encodeFunctionData("revokeSession"), - 0 - ); - + // InvalidSessionDuration is checked before ExistingSessionActive / SessionOpsPerDayLimitReached const maxDuration = await module.MAX_SESSION_DURATION(); await expect( safe.callEnableSession(await module.getAddress(), ethers.Wallet.createRandom().address, Number(maxDuration) + 1) @@ -151,6 +132,7 @@ describe("UsageBasedSessionModule", function () { it("revokes an active session", async () => { const {module, safe} = await setupSession(2); + // No time advance needed — daily op limit allows this revoke const revokeData = module.interface.encodeFunctionData("revokeSession"); await expect(safe.execTransactionFromModule(await module.getAddress(), 0, revokeData, 0)).to.emit( module, @@ -599,6 +581,191 @@ describe("UsageBasedSessionModule", function () { }); }); + describe("Session operation daily limit", function () { + it("revokeSession reverts with SessionOpsPerDayLimitReached after exhausting daily limit", async () => { + const {usageBasedSessionModule, owner, gameSubsidisationRegistry} = await deployContracts(); + const module = usageBasedSessionModule; + // Set limit to 1 op per day so the single enableSession in setup exhausts it + await module.setSessionOpsPerDay(1); + + const Safe = await ethers.getContractFactory("TestSessionSafe"); + const safe = (await Safe.deploy(owner.address)) as any; + const Target = await ethers.getContractFactory("TestSessionTarget"); + const target = await Target.deploy(); + const sel = target.interface.getFunction("doAction")!.selector; + await gameSubsidisationRegistry.setFunctionGroup(await target.getAddress(), sel, 1); + await gameSubsidisationRegistry.setGroupLimit(1, 5); + await module.setWhitelistedSigner([owner.address], true); + await owner.sendTransaction({to: await module.getAddress(), value: ethers.parseEther("1")}); + + // enableSession consumes the 1 allowed op + await safe.callEnableSession(module, ethers.Wallet.createRandom().address, 3600); + + // revokeSession should now revert — daily limit exhausted + await expect(safe.callRevokeSession(module)).to.be.revertedWithCustomError( + module, + "SessionOpsPerDayLimitReached" + ); + }); + + it("revokeSession succeeds within daily limit", async () => { + const {module, safe} = await setupSession(2); // default 3 ops/day — enable used 1 + const revokeData = module.interface.encodeFunctionData("revokeSession"); + await expect(safe.execTransactionFromModule(await module.getAddress(), 0, revokeData, 0)).to.emit( + module, + "SessionRevoked" + ); + + const session = await module.getSession(await safe.getAddress()); + expect(session.sessionKey).to.eq(ethers.ZeroAddress); + }); + + it("enableSession reverts with SessionOpsPerDayLimitReached after exhausting daily ops", async () => { + const {module, safe} = await setupSession(2); // default 3 ops/day — enable used op 1 + + // Revoke (op 2) + await safe.execTransactionFromModule( + await module.getAddress(), + 0, + module.interface.encodeFunctionData("revokeSession"), + 0 + ); + + // Re-enable (op 3 — uses last allowed op) + const newKey = ethers.Wallet.createRandom(); + await safe.callEnableSession(await module.getAddress(), newKey.address, 3600); + + // Revoke again would need op 4 — should fail + await expect(safe.callRevokeSession(module)).to.be.revertedWithCustomError( + module, + "SessionOpsPerDayLimitReached" + ); + }); + + it("enableSession reverts with SessionOpsPerDayLimitReached when daily limit exhausted", async () => { + const {usageBasedSessionModule, owner, gameSubsidisationRegistry} = await deployContracts(); + const module = usageBasedSessionModule; + await module.setSessionOpsPerDay(2); // 2 ops/day + + const Safe = await ethers.getContractFactory("TestSessionSafe"); + const safe = (await Safe.deploy(owner.address)) as any; + const Target = await ethers.getContractFactory("TestSessionTarget"); + const target = await Target.deploy(); + const sel = target.interface.getFunction("doAction")!.selector; + await gameSubsidisationRegistry.setFunctionGroup(await target.getAddress(), sel, 1); + await module.setWhitelistedSigner([owner.address], true); + await owner.sendTransaction({to: await module.getAddress(), value: ethers.parseEther("1")}); + + // Enable (op 1) + await safe.callEnableSession(module, ethers.Wallet.createRandom().address, 3600); + + // Revoke (op 2 — exhausts limit) + await safe.execTransactionFromModule( + await module.getAddress(), + 0, + module.interface.encodeFunctionData("revokeSession"), + 0 + ); + + // Enable again should fail — limit of 2 reached + await expect( + safe.callEnableSession(module, ethers.Wallet.createRandom().address, 3600) + ).to.be.revertedWithCustomError(module, "SessionOpsPerDayLimitReached"); + }); + + it("enableSession succeeds on a new day after exhausting previous day's limit", async () => { + const {usageBasedSessionModule, owner, gameSubsidisationRegistry} = await deployContracts(); + const module = usageBasedSessionModule; + await module.setSessionOpsPerDay(2); // 2 ops/day + + const Safe = await ethers.getContractFactory("TestSessionSafe"); + const safe = (await Safe.deploy(owner.address)) as any; + const Target = await ethers.getContractFactory("TestSessionTarget"); + const target = await Target.deploy(); + const sel = target.interface.getFunction("doAction")!.selector; + await gameSubsidisationRegistry.setFunctionGroup(await target.getAddress(), sel, 1); + await module.setWhitelistedSigner([owner.address], true); + await owner.sendTransaction({to: await module.getAddress(), value: ethers.parseEther("1")}); + + // Exhaust today's 2 ops: enable (op 1) + revoke (op 2) + await safe.callEnableSession(module, ethers.Wallet.createRandom().address, 86400 * 2); // 2-day session + await safe.execTransactionFromModule( + await module.getAddress(), + 0, + module.interface.encodeFunctionData("revokeSession"), + 0 + ); + + // Further ops blocked today + await expect( + safe.callEnableSession(module, ethers.Wallet.createRandom().address, 3600) + ).to.be.revertedWithCustomError(module, "SessionOpsPerDayLimitReached"); + + // Advance to the next day + await ethers.provider.send("evm_increaseTime", [24 * 3600]); + await ethers.provider.send("evm_mine", []); + + // Should succeed on new day + const newKey = ethers.Wallet.createRandom(); + await expect(safe.callEnableSession(module, newKey.address, 3600)).to.emit(module, "SessionEnabled"); + const session = await module.getSession(await safe.getAddress()); + expect(session.sessionKey).to.eq(newKey.address); + }); + + it("opDay and opCount are preserved after revokeSession", async () => { + const {module, safe} = await setupSession(2); // default 3 ops/day — enable used op 1 + + // Revoke (op 2) + await safe.execTransactionFromModule( + await module.getAddress(), + 0, + module.interface.encodeFunctionData("revokeSession"), + 0 + ); + + // Session is deleted but opDay/opCount must be preserved + const session = await module.getSession(await safe.getAddress()); + expect(session.sessionKey).to.eq(ethers.ZeroAddress); + expect(session.opDay).to.be.gt(0n); + expect(session.opCount).to.eq(2n); + + // Immediately re-enabling must succeed (op 3 is still within the 3-op limit) + const newKey = ethers.Wallet.createRandom(); + await expect(safe.callEnableSession(await module.getAddress(), newKey.address, 3600)).to.emit( + module, + "SessionEnabled" + ); + }); + + it("owner can update sessionOpsPerDay", async () => { + const {module} = await setupSession(2); + const newLimit = 5; + await expect(module.setSessionOpsPerDay(newLimit)).to.emit(module, "SessionOpsPerDayUpdated").withArgs(newLimit); + expect(await module.getSessionOpsPerDay()).to.eq(newLimit); + }); + + it("non-owner cannot update sessionOpsPerDay", async () => { + const {module} = await setupSession(2); + const [, other] = await ethers.getSigners(); + await expect(module.connect(other).setSessionOpsPerDay(5)).to.be.revertedWithCustomError( + module, + "OwnableUnauthorizedAccount" + ); + }); + + it("setSessionOpsPerDay reverts if zero", async () => { + const {module} = await setupSession(2); + await expect(module.setSessionOpsPerDay(0)).to.be.revertedWithCustomError(module, "InvalidSessionDuration"); + }); + + it("getSessionOpsPerDay returns the configured value", async () => { + const {module} = await setupSession(2); + expect(await module.getSessionOpsPerDay()).to.eq(3n); // default 3 + await module.setSessionOpsPerDay(5); + expect(await module.getSessionOpsPerDay()).to.eq(5n); + }); + }); + describe("Whitelisting", function () { it("allows owner to whitelist signers", async () => { const {module} = await setupSession(2); From 1f1aecf5d66b883bf356ff8f8e9c1b50a84126b6 Mon Sep 17 00:00:00 2001 From: deif Date: Sun, 22 Feb 2026 20:12:52 +0000 Subject: [PATCH 12/13] add deploy scripts --- .github/skills/solidity-security/SKILL.md | 522 ++++++++++++++++++++++ scripts/data/groupSubsidyLimits.ts | 19 + scripts/deployAccountAbstraction.ts | 83 ++++ scripts/setGameSubsidyLimits.ts | 47 ++ skills-lock.json | 10 + 5 files changed, 681 insertions(+) create mode 100644 .github/skills/solidity-security/SKILL.md create mode 100644 scripts/data/groupSubsidyLimits.ts create mode 100644 scripts/deployAccountAbstraction.ts create mode 100644 scripts/setGameSubsidyLimits.ts create mode 100644 skills-lock.json diff --git a/.github/skills/solidity-security/SKILL.md b/.github/skills/solidity-security/SKILL.md new file mode 100644 index 00000000..89a756e6 --- /dev/null +++ b/.github/skills/solidity-security/SKILL.md @@ -0,0 +1,522 @@ +--- +name: solidity-security +description: Master smart contract security best practices to prevent common vulnerabilities and implement secure Solidity patterns. Use when writing smart contracts, auditing existing contracts, or implementing security measures for blockchain applications. +--- + +# Solidity Security + +Master smart contract security best practices, vulnerability prevention, and secure Solidity development patterns. + +## When to Use This Skill + +- Writing secure smart contracts +- Auditing existing contracts for vulnerabilities +- Implementing secure DeFi protocols +- Preventing reentrancy, overflow, and access control issues +- Optimizing gas usage while maintaining security +- Preparing contracts for professional audits +- Understanding common attack vectors + +## Critical Vulnerabilities + +### 1. Reentrancy + +Attacker calls back into your contract before state is updated. + +**Vulnerable Code:** + +```solidity +// VULNERABLE TO REENTRANCY +contract VulnerableBank { + mapping(address => uint256) public balances; + + function withdraw() public { + uint256 amount = balances[msg.sender]; + + // DANGER: External call before state update + (bool success, ) = msg.sender.call{value: amount}(""); + require(success); + + balances[msg.sender] = 0; // Too late! + } +} +``` + +**Secure Pattern (Checks-Effects-Interactions):** + +```solidity +contract SecureBank { + mapping(address => uint256) public balances; + + function withdraw() public { + uint256 amount = balances[msg.sender]; + require(amount > 0, "Insufficient balance"); + + // EFFECTS: Update state BEFORE external call + balances[msg.sender] = 0; + + // INTERACTIONS: External call last + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); + } +} +``` + +**Alternative: ReentrancyGuard** + +```solidity +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +contract SecureBank is ReentrancyGuard { + mapping(address => uint256) public balances; + + function withdraw() public nonReentrant { + uint256 amount = balances[msg.sender]; + require(amount > 0, "Insufficient balance"); + + balances[msg.sender] = 0; + + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); + } +} +``` + +### 2. Integer Overflow/Underflow + +**Vulnerable Code (Solidity < 0.8.0):** + +```solidity +// VULNERABLE +contract VulnerableToken { + mapping(address => uint256) public balances; + + function transfer(address to, uint256 amount) public { + // No overflow check - can wrap around + balances[msg.sender] -= amount; // Can underflow! + balances[to] += amount; // Can overflow! + } +} +``` + +**Secure Pattern (Solidity >= 0.8.0):** + +```solidity +// Solidity 0.8+ has built-in overflow/underflow checks +contract SecureToken { + mapping(address => uint256) public balances; + + function transfer(address to, uint256 amount) public { + // Automatically reverts on overflow/underflow + balances[msg.sender] -= amount; + balances[to] += amount; + } +} +``` + +**For Solidity < 0.8.0, use SafeMath:** + +```solidity +import "@openzeppelin/contracts/utils/math/SafeMath.sol"; + +contract SecureToken { + using SafeMath for uint256; + mapping(address => uint256) public balances; + + function transfer(address to, uint256 amount) public { + balances[msg.sender] = balances[msg.sender].sub(amount); + balances[to] = balances[to].add(amount); + } +} +``` + +### 3. Access Control + +**Vulnerable Code:** + +```solidity +// VULNERABLE: Anyone can call critical functions +contract VulnerableContract { + address public owner; + + function withdraw(uint256 amount) public { + // No access control! + payable(msg.sender).transfer(amount); + } +} +``` + +**Secure Pattern:** + +```solidity +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract SecureContract is Ownable { + function withdraw(uint256 amount) public onlyOwner { + payable(owner()).transfer(amount); + } +} + +// Or implement custom role-based access +contract RoleBasedContract { + mapping(address => bool) public admins; + + modifier onlyAdmin() { + require(admins[msg.sender], "Not an admin"); + _; + } + + function criticalFunction() public onlyAdmin { + // Protected function + } +} +``` + +### 4. Front-Running + +**Vulnerable:** + +```solidity +// VULNERABLE TO FRONT-RUNNING +contract VulnerableDEX { + function swap(uint256 amount, uint256 minOutput) public { + // Attacker sees this in mempool and front-runs + uint256 output = calculateOutput(amount); + require(output >= minOutput, "Slippage too high"); + // Perform swap + } +} +``` + +**Mitigation:** + +```solidity +contract SecureDEX { + mapping(bytes32 => bool) public usedCommitments; + + // Step 1: Commit to trade + function commitTrade(bytes32 commitment) public { + usedCommitments[commitment] = true; + } + + // Step 2: Reveal trade (next block) + function revealTrade( + uint256 amount, + uint256 minOutput, + bytes32 secret + ) public { + bytes32 commitment = keccak256(abi.encodePacked( + msg.sender, amount, minOutput, secret + )); + require(usedCommitments[commitment], "Invalid commitment"); + // Perform swap + } +} +``` + +## Security Best Practices + +### Checks-Effects-Interactions Pattern + +```solidity +contract SecurePattern { + mapping(address => uint256) public balances; + + function withdraw(uint256 amount) public { + // 1. CHECKS: Validate conditions + require(amount <= balances[msg.sender], "Insufficient balance"); + require(amount > 0, "Amount must be positive"); + + // 2. EFFECTS: Update state + balances[msg.sender] -= amount; + + // 3. INTERACTIONS: External calls last + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); + } +} +``` + +### Pull Over Push Pattern + +```solidity +// Prefer this (pull) +contract SecurePayment { + mapping(address => uint256) public pendingWithdrawals; + + function recordPayment(address recipient, uint256 amount) internal { + pendingWithdrawals[recipient] += amount; + } + + function withdraw() public { + uint256 amount = pendingWithdrawals[msg.sender]; + require(amount > 0, "Nothing to withdraw"); + + pendingWithdrawals[msg.sender] = 0; + payable(msg.sender).transfer(amount); + } +} + +// Over this (push) +contract RiskyPayment { + function distributePayments(address[] memory recipients, uint256[] memory amounts) public { + for (uint i = 0; i < recipients.length; i++) { + // If any transfer fails, entire batch fails + payable(recipients[i]).transfer(amounts[i]); + } + } +} +``` + +### Input Validation + +```solidity +contract SecureContract { + function transfer(address to, uint256 amount) public { + // Validate inputs + require(to != address(0), "Invalid recipient"); + require(to != address(this), "Cannot send to contract"); + require(amount > 0, "Amount must be positive"); + require(amount <= balances[msg.sender], "Insufficient balance"); + + // Proceed with transfer + balances[msg.sender] -= amount; + balances[to] += amount; + } +} +``` + +### Emergency Stop (Circuit Breaker) + +```solidity +import "@openzeppelin/contracts/security/Pausable.sol"; + +contract EmergencyStop is Pausable, Ownable { + function criticalFunction() public whenNotPaused { + // Function logic + } + + function emergencyStop() public onlyOwner { + _pause(); + } + + function resume() public onlyOwner { + _unpause(); + } +} +``` + +## Gas Optimization + +### Use `uint256` Instead of Smaller Types + +```solidity +// More gas efficient +contract GasEfficient { + uint256 public value; // Optimal + + function set(uint256 _value) public { + value = _value; + } +} + +// Less efficient +contract GasInefficient { + uint8 public value; // Still uses 256-bit slot + + function set(uint8 _value) public { + value = _value; // Extra gas for type conversion + } +} +``` + +### Pack Storage Variables + +```solidity +// Gas efficient (3 variables in 1 slot) +contract PackedStorage { + uint128 public a; // Slot 0 + uint64 public b; // Slot 0 + uint64 public c; // Slot 0 + uint256 public d; // Slot 1 +} + +// Gas inefficient (each variable in separate slot) +contract UnpackedStorage { + uint256 public a; // Slot 0 + uint256 public b; // Slot 1 + uint256 public c; // Slot 2 + uint256 public d; // Slot 3 +} +``` + +### Use `calldata` Instead of `memory` for Function Arguments + +```solidity +contract GasOptimized { + // More gas efficient + function processData(uint256[] calldata data) public pure returns (uint256) { + return data[0]; + } + + // Less efficient + function processDataMemory(uint256[] memory data) public pure returns (uint256) { + return data[0]; + } +} +``` + +### Use Events for Data Storage (When Appropriate) + +```solidity +contract EventStorage { + // Emitting events is cheaper than storage + event DataStored(address indexed user, uint256 indexed id, bytes data); + + function storeData(uint256 id, bytes calldata data) public { + emit DataStored(msg.sender, id, data); + // Don't store in contract storage unless needed + } +} +``` + +## Common Vulnerabilities Checklist + +```solidity +// Security Checklist Contract +contract SecurityChecklist { + /** + * [ ] Reentrancy protection (ReentrancyGuard or CEI pattern) + * [ ] Integer overflow/underflow (Solidity 0.8+ or SafeMath) + * [ ] Access control (Ownable, roles, modifiers) + * [ ] Input validation (require statements) + * [ ] Front-running mitigation (commit-reveal if applicable) + * [ ] Gas optimization (packed storage, calldata) + * [ ] Emergency stop mechanism (Pausable) + * [ ] Pull over push pattern for payments + * [ ] No delegatecall to untrusted contracts + * [ ] No tx.origin for authentication (use msg.sender) + * [ ] Proper event emission + * [ ] External calls at end of function + * [ ] Check return values of external calls + * [ ] No hardcoded addresses + * [ ] Upgrade mechanism (if proxy pattern) + */ +} +``` + +## Testing for Security + +```javascript +// Hardhat test example +const {expect} = require("chai"); +const {ethers} = require("hardhat"); + +describe("Security Tests", function () { + it("Should prevent reentrancy attack", async function () { + const [attacker] = await ethers.getSigners(); + + const VictimBank = await ethers.getContractFactory("SecureBank"); + const bank = await VictimBank.deploy(); + + const Attacker = await ethers.getContractFactory("ReentrancyAttacker"); + const attackerContract = await Attacker.deploy(bank.address); + + // Deposit funds + await bank.deposit({value: ethers.utils.parseEther("10")}); + + // Attempt reentrancy attack + await expect(attackerContract.attack({value: ethers.utils.parseEther("1")})).to.be.revertedWith( + "ReentrancyGuard: reentrant call" + ); + }); + + it("Should prevent integer overflow", async function () { + const Token = await ethers.getContractFactory("SecureToken"); + const token = await Token.deploy(); + + // Attempt overflow + await expect(token.transfer(attacker.address, ethers.constants.MaxUint256)).to.be.reverted; + }); + + it("Should enforce access control", async function () { + const [owner, attacker] = await ethers.getSigners(); + + const Contract = await ethers.getContractFactory("SecureContract"); + const contract = await Contract.deploy(); + + // Attempt unauthorized withdrawal + await expect(contract.connect(attacker).withdraw(100)).to.be.revertedWith("Ownable: caller is not the owner"); + }); +}); +``` + +## Audit Preparation + +```solidity +contract WellDocumentedContract { + /** + * @title Well Documented Contract + * @dev Example of proper documentation for audits + * @notice This contract handles user deposits and withdrawals + */ + + /// @notice Mapping of user balances + mapping(address => uint256) public balances; + + /** + * @dev Deposits ETH into the contract + * @notice Anyone can deposit funds + */ + function deposit() public payable { + require(msg.value > 0, "Must send ETH"); + balances[msg.sender] += msg.value; + } + + /** + * @dev Withdraws user's balance + * @notice Follows CEI pattern to prevent reentrancy + * @param amount Amount to withdraw in wei + */ + function withdraw(uint256 amount) public { + // CHECKS + require(amount <= balances[msg.sender], "Insufficient balance"); + + // EFFECTS + balances[msg.sender] -= amount; + + // INTERACTIONS + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); + } +} +``` + +## Resources + +- **references/reentrancy.md**: Comprehensive reentrancy prevention +- **references/access-control.md**: Role-based access patterns +- **references/overflow-underflow.md**: SafeMath and integer safety +- **references/gas-optimization.md**: Gas saving techniques +- **references/vulnerability-patterns.md**: Common vulnerability catalog +- **assets/solidity-contracts-templates.sol**: Secure contract templates +- **assets/security-checklist.md**: Pre-audit checklist +- **scripts/analyze-contract.sh**: Static analysis tools + +## Tools for Security Analysis + +- **Slither**: Static analysis tool +- **Mythril**: Security analysis tool +- **Echidna**: Fuzzing tool +- **Manticore**: Symbolic execution +- **Securify**: Automated security scanner + +## Common Pitfalls + +1. **Using `tx.origin` for Authentication**: Use `msg.sender` instead +2. **Unchecked External Calls**: Always check return values +3. **Delegatecall to Untrusted Contracts**: Can hijack your contract +4. **Floating Pragma**: Pin to specific Solidity version +5. **Missing Events**: Emit events for state changes +6. **Excessive Gas in Loops**: Can hit block gas limit +7. **No Upgrade Path**: Consider proxy patterns if upgrades needed diff --git a/scripts/data/groupSubsidyLimits.ts b/scripts/data/groupSubsidyLimits.ts new file mode 100644 index 00000000..ac0e6841 --- /dev/null +++ b/scripts/data/groupSubsidyLimits.ts @@ -0,0 +1,19 @@ +import {PLAYER_NFT_ADDRESS} from "../contractAddresses"; +import {PlayerNFT__factory} from "../../typechain-types"; + +const playerNFTIface = PlayerNFT__factory.createInterface(); +const mintSelector = playerNFTIface.getFunction("mint").selector; + +export const groups = [ + { + groupId: 1, + limit: 2, + selectors: [ + { + groupId: 1, + contract: PLAYER_NFT_ADDRESS, + selector: mintSelector, + }, + ], + }, +]; diff --git a/scripts/deployAccountAbstraction.ts b/scripts/deployAccountAbstraction.ts new file mode 100644 index 00000000..d7a46e6f --- /dev/null +++ b/scripts/deployAccountAbstraction.ts @@ -0,0 +1,83 @@ +import {ethers, upgrades} from "hardhat"; +import {initialiseSafe, sendTransactionSetToSafe, getSafeUpgradeTransaction, verifyContracts} from "./utils"; +import {OperationType, MetaTransactionData} from "@safe-global/types-kit"; +import { + UsageBasedSessionModule, + GameSubsidisationRegistry, + UsageBasedSessionModule__factory, + GameSubsidisationRegistry__factory, +} from "../typechain-types"; +import {SUBSIDY_SIGNERS} from "./contractAddresses"; +import {groups} from "./data/groupSubsidyLimits"; + +async function main() { + const [owner, , proposer] = await ethers.getSigners(); // 0 is old deployer, 2 is proposer for Safe (new deployer) + const network = await ethers.provider.getNetwork(); + const {useSafe, apiKit, protocolKit} = await initialiseSafe(network); + console.log( + `Deploy blackMarketTrader using account: ${proposer.address} on chain id ${network.chainId}, useSafe: ${useSafe}` + ); + + const timeout = 60 * 1000; // 1 minute + + const usageBasedSessionModuleIface = UsageBasedSessionModule__factory.createInterface(); + const gameRegistryIface = GameSubsidisationRegistry__factory.createInterface(); + + if (useSafe) { + const GameSubsidisationRegistry = await ethers.getContractFactory("GameSubsidisationRegistry", proposer); + const gameSubsidisationRegistry = (await upgrades.deployProxy(GameSubsidisationRegistry, [ + process.env.SAFE_ADDRESS, + ])) as unknown as GameSubsidisationRegistry; + await gameSubsidisationRegistry.waitForDeployment(); + console.log(`gameSubsidisationRegistry = "${(await gameSubsidisationRegistry.getAddress()).toLowerCase()}"`); + + const UsageBasedSessionModule = await ethers.getContractFactory("UsageBasedSessionModule", proposer); + const usageBasedSessionModule = (await upgrades.deployProxy(UsageBasedSessionModule, [ + process.env.SAFE_ADDRESS, + await gameSubsidisationRegistry.getAddress(), + ])) as unknown as UsageBasedSessionModule; + await usageBasedSessionModule.waitForDeployment(); + console.log(`usageBasedSessionModule = "${(await usageBasedSessionModule.getAddress()).toLowerCase()}"`); + + // can verify this immediately + if (network.chainId == 146n) { + await verifyContracts([await usageBasedSessionModule.getAddress()]); + await verifyContracts([await gameSubsidisationRegistry.getAddress()]); + } + + const transactionSet: MetaTransactionData[] = []; + // Set addresses and approvals + transactionSet.push({ + to: await usageBasedSessionModule.getAddress(), + value: "0", + data: usageBasedSessionModuleIface.encodeFunctionData("setWhitelistedSigner", [SUBSIDY_SIGNERS, true]), + operation: OperationType.Call, + }); + + const contractAddresses = groups.flatMap((g) => g.selectors.map((s) => s.contract)); + const selectors = groups.flatMap((g) => g.selectors.map((s) => s.selector)); + const groupIds = groups.flatMap((g) => g.selectors.map((s) => s.groupId)); + + const limitGroupIds = groups.map((g) => g.groupId); + const limits = groups.map((g) => g.limit); + + transactionSet.push({ + to: await gameSubsidisationRegistry.getAddress(), + value: "0", + data: gameRegistryIface.encodeFunctionData("setFunctionGroups", [contractAddresses, selectors, groupIds]), + operation: OperationType.Call, + }); + transactionSet.push({ + to: await gameSubsidisationRegistry.getAddress(), + value: "0", + data: gameRegistryIface.encodeFunctionData("setGroupLimits", [limitGroupIds, limits]), + operation: OperationType.Call, + }); + await sendTransactionSetToSafe(network, protocolKit, apiKit, transactionSet, proposer); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/setGameSubsidyLimits.ts b/scripts/setGameSubsidyLimits.ts new file mode 100644 index 00000000..23dc9cd7 --- /dev/null +++ b/scripts/setGameSubsidyLimits.ts @@ -0,0 +1,47 @@ +import {ethers} from "hardhat"; +import {GAME_SUBSIDISATION_REGISTRY_ADDRESS, GLOBAL_EVENT_ADDRESS} from "./contractAddresses"; +import {EstforConstants} from "@paintswap/estfor-definitions"; +import {getSafeUpgradeTransaction, initialiseSafe, sendTransactionSetToSafe} from "./utils"; +import {OperationType, MetaTransactionData} from "@safe-global/types-kit"; +import {GameSubsidisationRegistry__factory} from "../typechain-types"; +import {groups} from "./data/groupSubsidyLimits"; + +async function main() { + const [owner, , proposer] = await ethers.getSigners(); // 0 is old deployer, 2 is proposer for Safe (new deployer) + const network = await ethers.provider.getNetwork(); + const {useSafe, apiKit, protocolKit} = await initialiseSafe(network); + console.log( + `Set game subsidy limits using account: ${proposer.address} on chain id ${network.chainId}, useSafe: ${useSafe}` + ); + + if (useSafe) { + const transactionSet: MetaTransactionData[] = []; + const iface = GameSubsidisationRegistry__factory.createInterface(); + + const contractAddresses = groups.flatMap((g) => g.selectors.map((s) => s.contract)); + const selectors = groups.flatMap((g) => g.selectors.map((s) => s.selector)); + const groupIds = groups.flatMap((g) => g.selectors.map((s) => s.groupId)); + + const limitGroupIds = groups.map((g) => g.groupId); + const limits = groups.map((g) => g.limit); + + transactionSet.push({ + to: GAME_SUBSIDISATION_REGISTRY_ADDRESS, + value: "0", + data: iface.encodeFunctionData("setFunctionGroups", [contractAddresses, selectors, groupIds]), + operation: OperationType.Call, + }); + transactionSet.push({ + to: GAME_SUBSIDISATION_REGISTRY_ADDRESS, + value: "0", + data: iface.encodeFunctionData("setGroupLimits", [limitGroupIds, limits]), + operation: OperationType.Call, + }); + await sendTransactionSetToSafe(network, protocolKit, apiKit, transactionSet, proposer); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..67ba1e27 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "solidity-security": { + "source": "wshobson/agents", + "sourceType": "github", + "computedHash": "080c10be84baecfb396906abb085f16ec3e70b027a9355becb4a394c47da0b80" + } + } +} From 9404e8521a6b22b23e00b4327ef0cf513644b5e4 Mon Sep 17 00:00:00 2001 From: deif Date: Sun, 22 Feb 2026 22:39:59 +0000 Subject: [PATCH 13/13] deploy first pass at account abstraction --- .openzeppelin/sonic.json | 482 ++++++++++++++++++ README.md | 20 +- contracts/Session/UsageBasedSessionModule.sol | 2 +- scripts/contractAddresses.ts | 6 +- scripts/deployAccountAbstraction.ts | 9 +- 5 files changed, 503 insertions(+), 16 deletions(-) diff --git a/.openzeppelin/sonic.json b/.openzeppelin/sonic.json index 76a9e88f..32dff434 100644 --- a/.openzeppelin/sonic.json +++ b/.openzeppelin/sonic.json @@ -1183,6 +1183,16 @@ "address": "0x4f9911214d811b5aCdC4d1911067F614e81c808E", "txHash": "0x852bae20212f28a295a5eed20af2e03e36ab11e7b56ade732a492d642886ad7b", "kind": "uups" + }, + { + "address": "0xe42d998ec0ec2c5D217c8B54C9522b4224D1Bdb0", + "txHash": "0xd5b8acb27f9e547659e308bbd8ef085b49093153c6a87340cdaf6e14d796df94", + "kind": "uups" + }, + { + "address": "0x71f7f7c98477de38e2f1A0217AF0E1Dc0fbf19e4", + "txHash": "0xef8f7c496714ff020de3dce285b63856920aeb81b4c1484bfbd9738d8c5c686a", + "kind": "uups" } ], "impls": { @@ -154232,6 +154242,478 @@ ] } } + }, + "2417df5b0f610e43936ece8c98d43ab7c886826029aa0e1ebfb2805e32ed0a64": { + "address": "0xB552f2F14E7c6014A4C81C3B5bdCFDDbeDF88e10", + "txHash": "0x759a71112be9c7757361a3df3b6dbcd71cb4d578c9d984984b72999f2a09b210", + "layout": { + "solcVersion": "0.8.28", + "storage": [ + { + "label": "_functionToLimitGroup", + "offset": 0, + "slot": "0", + "type": "t_mapping(t_address,t_mapping(t_bytes4,t_uint256))", + "contract": "GameSubsidisationRegistry", + "src": "contracts\\Session\\GameSubsidisationRegistry.sol:10" + }, + { + "label": "_groupDailyLimits", + "offset": 0, + "slot": "1", + "type": "t_mapping(t_uint256,t_uint256)", + "contract": "GameSubsidisationRegistry", + "src": "contracts\\Session\\GameSubsidisationRegistry.sol:11" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_struct(InitializableStorage)1126_storage": { + "label": "struct Initializable.InitializableStorage", + "members": [ + { + "label": "_initialized", + "type": "t_uint64", + "offset": 0, + "slot": "0" + }, + { + "label": "_initializing", + "type": "t_bool", + "offset": 8, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(OwnableStorage)1066_storage": { + "label": "struct OwnableUpgradeable.OwnableStorage", + "members": [ + { + "label": "_owner", + "type": "t_address", + "offset": 0, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_uint64": { + "label": "uint64", + "numberOfBytes": "8" + }, + "t_bytes4": { + "label": "bytes4", + "numberOfBytes": "4" + }, + "t_mapping(t_address,t_mapping(t_bytes4,t_uint256))": { + "label": "mapping(address => mapping(bytes4 => uint256))", + "numberOfBytes": "32" + }, + "t_mapping(t_bytes4,t_uint256)": { + "label": "mapping(bytes4 => uint256)", + "numberOfBytes": "32" + }, + "t_mapping(t_uint256,t_uint256)": { + "label": "mapping(uint256 => uint256)", + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + } + }, + "namespaces": { + "erc7201:openzeppelin.storage.Ownable": [ + { + "contract": "OwnableUpgradeable", + "label": "_owner", + "type": "t_address", + "src": "@openzeppelin\\contracts-upgradeable\\access\\OwnableUpgradeable.sol:24", + "offset": 0, + "slot": "0" + } + ], + "erc7201:openzeppelin.storage.Initializable": [ + { + "contract": "Initializable", + "label": "_initialized", + "type": "t_uint64", + "src": "@openzeppelin\\contracts-upgradeable\\proxy\\utils\\Initializable.sol:69", + "offset": 0, + "slot": "0" + }, + { + "contract": "Initializable", + "label": "_initializing", + "type": "t_bool", + "src": "@openzeppelin\\contracts-upgradeable\\proxy\\utils\\Initializable.sol:73", + "offset": 8, + "slot": "0" + } + ] + } + } + }, + "efe7f0f0616da09ed758f5fc306ad4044ed89e4bd53009c41d1db2d06dd26821": { + "address": "0x78EF88953601904211506BB4b8Faa318EE741637", + "txHash": "0x6cdc66f319e7eb37d9cfab333dde63145e3e93aa57c07494c9afa9657cdda6de", + "layout": { + "solcVersion": "0.8.28", + "storage": [ + { + "label": "_registry", + "offset": 0, + "slot": "0", + "type": "t_contract(IGameSubsidisationRegistry)7706", + "contract": "UsageBasedSessionModule", + "src": "contracts\\Session\\UsageBasedSessionModule.sol:76" + }, + { + "label": "_sessions", + "offset": 0, + "slot": "1", + "type": "t_mapping(t_address,t_struct(Session)6714_storage)", + "contract": "UsageBasedSessionModule", + "src": "contracts\\Session\\UsageBasedSessionModule.sol:77" + }, + { + "label": "_usage", + "offset": 0, + "slot": "2", + "type": "t_mapping(t_address,t_struct(UserUsage)6705_storage)", + "contract": "UsageBasedSessionModule", + "src": "contracts\\Session\\UsageBasedSessionModule.sol:78" + }, + { + "label": "_whitelistedSigners", + "offset": 0, + "slot": "3", + "type": "t_mapping(t_address,t_bool)", + "contract": "UsageBasedSessionModule", + "src": "contracts\\Session\\UsageBasedSessionModule.sol:79" + }, + { + "label": "_gasOverhead", + "offset": 0, + "slot": "4", + "type": "t_uint256", + "contract": "UsageBasedSessionModule", + "src": "contracts\\Session\\UsageBasedSessionModule.sol:80" + }, + { + "label": "_sessionOpsPerDay", + "offset": 0, + "slot": "5", + "type": "t_uint16", + "contract": "UsageBasedSessionModule", + "src": "contracts\\Session\\UsageBasedSessionModule.sol:81" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_bytes32": { + "label": "bytes32", + "numberOfBytes": "32" + }, + "t_string_storage": { + "label": "string", + "numberOfBytes": "32" + }, + "t_struct(EIP712Storage)346_storage": { + "label": "struct EIP712Upgradeable.EIP712Storage", + "members": [ + { + "label": "_hashedName", + "type": "t_bytes32", + "offset": 0, + "slot": "0" + }, + { + "label": "_hashedVersion", + "type": "t_bytes32", + "offset": 0, + "slot": "1" + }, + { + "label": "_name", + "type": "t_string_storage", + "offset": 0, + "slot": "2" + }, + { + "label": "_version", + "type": "t_string_storage", + "offset": 0, + "slot": "3" + } + ], + "numberOfBytes": "128" + }, + "t_struct(InitializableStorage)73_storage": { + "label": "struct Initializable.InitializableStorage", + "members": [ + { + "label": "_initialized", + "type": "t_uint64", + "offset": 0, + "slot": "0" + }, + { + "label": "_initializing", + "type": "t_bool", + "offset": 8, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(OwnableStorage)13_storage": { + "label": "struct OwnableUpgradeable.OwnableStorage", + "members": [ + { + "label": "_owner", + "type": "t_address", + "offset": 0, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(PausableStorage)219_storage": { + "label": "struct PausableUpgradeable.PausableStorage", + "members": [ + { + "label": "_paused", + "type": "t_bool", + "offset": 0, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(ReentrancyGuardStorage)283_storage": { + "label": "struct ReentrancyGuardUpgradeable.ReentrancyGuardStorage", + "members": [ + { + "label": "_status", + "type": "t_uint256", + "offset": 0, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint64": { + "label": "uint64", + "numberOfBytes": "8" + }, + "t_contract(IGameSubsidisationRegistry)7706": { + "label": "contract IGameSubsidisationRegistry", + "numberOfBytes": "20" + }, + "t_mapping(t_address,t_bool)": { + "label": "mapping(address => bool)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_struct(Session)6714_storage)": { + "label": "mapping(address => struct UsageBasedSessionModule.Session)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_struct(UserUsage)6705_storage)": { + "label": "mapping(address => struct UsageBasedSessionModule.UserUsage)", + "numberOfBytes": "32" + }, + "t_mapping(t_uint256,t_struct(GroupUsage)6697_storage)": { + "label": "mapping(uint256 => struct UsageBasedSessionModule.GroupUsage)", + "numberOfBytes": "32" + }, + "t_struct(GroupUsage)6697_storage": { + "label": "struct UsageBasedSessionModule.GroupUsage", + "members": [ + { + "label": "day", + "type": "t_uint40", + "offset": 0, + "slot": "0" + }, + { + "label": "count", + "type": "t_uint40", + "offset": 5, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(Session)6714_storage": { + "label": "struct UsageBasedSessionModule.Session", + "members": [ + { + "label": "sessionKey", + "type": "t_address", + "offset": 0, + "slot": "0" + }, + { + "label": "deadline", + "type": "t_uint48", + "offset": 20, + "slot": "0" + }, + { + "label": "opDay", + "type": "t_uint32", + "offset": 26, + "slot": "0" + }, + { + "label": "opCount", + "type": "t_uint16", + "offset": 30, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(UserUsage)6705_storage": { + "label": "struct UsageBasedSessionModule.UserUsage", + "members": [ + { + "label": "groupUsage", + "type": "t_mapping(t_uint256,t_struct(GroupUsage)6697_storage)", + "offset": 0, + "slot": "0" + }, + { + "label": "nonce", + "type": "t_uint256", + "offset": 0, + "slot": "1" + } + ], + "numberOfBytes": "64" + }, + "t_uint16": { + "label": "uint16", + "numberOfBytes": "2" + }, + "t_uint32": { + "label": "uint32", + "numberOfBytes": "4" + }, + "t_uint40": { + "label": "uint40", + "numberOfBytes": "5" + }, + "t_uint48": { + "label": "uint48", + "numberOfBytes": "6" + } + }, + "namespaces": { + "erc7201:openzeppelin.storage.Pausable": [ + { + "contract": "PausableUpgradeable", + "label": "_paused", + "type": "t_bool", + "src": "@openzeppelin\\contracts-upgradeable\\utils\\PausableUpgradeable.sol:21", + "offset": 0, + "slot": "0" + } + ], + "erc7201:openzeppelin.storage.ReentrancyGuard": [ + { + "contract": "ReentrancyGuardUpgradeable", + "label": "_status", + "type": "t_uint256", + "src": "@openzeppelin\\contracts-upgradeable\\utils\\ReentrancyGuardUpgradeable.sol:43", + "offset": 0, + "slot": "0" + } + ], + "erc7201:openzeppelin.storage.EIP712": [ + { + "contract": "EIP712Upgradeable", + "label": "_hashedName", + "type": "t_bytes32", + "src": "@openzeppelin\\contracts-upgradeable\\utils\\cryptography\\EIP712Upgradeable.sol:39", + "offset": 0, + "slot": "0" + }, + { + "contract": "EIP712Upgradeable", + "label": "_hashedVersion", + "type": "t_bytes32", + "src": "@openzeppelin\\contracts-upgradeable\\utils\\cryptography\\EIP712Upgradeable.sol:41", + "offset": 0, + "slot": "1" + }, + { + "contract": "EIP712Upgradeable", + "label": "_name", + "type": "t_string_storage", + "src": "@openzeppelin\\contracts-upgradeable\\utils\\cryptography\\EIP712Upgradeable.sol:43", + "offset": 0, + "slot": "2" + }, + { + "contract": "EIP712Upgradeable", + "label": "_version", + "type": "t_string_storage", + "src": "@openzeppelin\\contracts-upgradeable\\utils\\cryptography\\EIP712Upgradeable.sol:44", + "offset": 0, + "slot": "3" + } + ], + "erc7201:openzeppelin.storage.Ownable": [ + { + "contract": "OwnableUpgradeable", + "label": "_owner", + "type": "t_address", + "src": "@openzeppelin\\contracts-upgradeable\\access\\OwnableUpgradeable.sol:24", + "offset": 0, + "slot": "0" + } + ], + "erc7201:openzeppelin.storage.Initializable": [ + { + "contract": "Initializable", + "label": "_initialized", + "type": "t_uint64", + "src": "@openzeppelin\\contracts-upgradeable\\proxy\\utils\\Initializable.sol:69", + "offset": 0, + "slot": "0" + }, + { + "contract": "Initializable", + "label": "_initializing", + "type": "t_bool", + "src": "@openzeppelin\\contracts-upgradeable\\proxy\\utils\\Initializable.sol:73", + "offset": 8, + "slot": "0" + } + ] + } + } } } } diff --git a/README.md b/README.md index 3e4fddb0..174f580e 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,10 @@ CombatantsHelper [0xc754d621239b5830264f8c8e302c21ffe48625fc](https://sonicscan. TerritoryTreasury [0x4b1da5984c89312f852c798154a171a5ddc07d43](https://sonicscan.org/address/0x4b1da5984c89312f852c798154a171a5ddc07d43) BankRegistry [0xf213febd3889c5bf18086356e7eff79e2a9fe391](https://sonicscan.org/address/0xf213febd3889c5bf18086356e7eff79e2a9fe391) BankFactory [0x76af5869f1b902f7a16c128a1daa7734819ec327](https://sonicscan.org/address/0x76af5869f1b902f7a16c128a1daa7734819ec327) -ActivityPoints [0x84527c02bb28ce7c32ca4182ad0541a2a9a561d2](https://sonicscan.org/address/0x84527c02bb28ce7c32ca4182ad0541a2a9a561d2) -Marketplace [0x7ba7b9193883e944645fc41d4a16c9516c6c5dc1](https://sonicscan.org/address/0x7ba7b9193883e944645fc41d4a16c9516c6c5dc1) -Cosmetics [0xb30b177b6c8c21370a72d7cada5f627519c91432](https://sonicscan.org/address/0xb30b177b6c8c21370a72d7cada5f627519c91432) -Global Events [0x6aca0ec5ad8158ab112f0fdf76e2c3ed6bfa11e2](https://sonicscan.org/address/0x6aca0ec5ad8158ab112f0fdf76e2c3ed6bfa11e2) +ActivityPoints [0x84527c02bb28ce7c32ca4182ad0541a2a9a561d2](https://sonicscan.org/address/0x84527c02bb28ce7c32ca4182ad0541a2a9a561d2) +Marketplace [0x7ba7b9193883e944645fc41d4a16c9516c6c5dc1](https://sonicscan.org/address/0x7ba7b9193883e944645fc41d4a16c9516c6c5dc1) +Cosmetics [0xb30b177b6c8c21370a72d7cada5f627519c91432](https://sonicscan.org/address/0xb30b177b6c8c21370a72d7cada5f627519c91432) +Global Events [0x6aca0ec5ad8158ab112f0fdf76e2c3ed6bfa11e2](https://sonicscan.org/address/0x6aca0ec5ad8158ab112f0fdf76e2c3ed6bfa11e2) Black Market Trader [0x4f9911214d811b5acdc4d1911067f614e81c808e](https://sonicscan.org/address/0x4f9911214d811b5acdc4d1911067f614e81c808e) ### Sonic mainnet beta deployed contract addresses: @@ -144,11 +144,13 @@ CombatantsHelper [0x7fa2b4c19093e0777d72235ea28d302f53227fa0](https://sonicscan. TerritoryTreasury [0x5d1429f842891ea0ed80e856762b48bc117ac2a8](https://sonicscan.org/address/0x5d1429f842891ea0ed80e856762b48bc117ac2a8) BankRegistry [0x7e7664ff2717889841c758ddfa7a1c6473a8a4d6](https://sonicscan.org/address/0x7e7664ff2717889841c758ddfa7a1c6473a8a4d6) BankFactory [0x5497f4b12092d2a8bff8a9e1640ef68e44613f8c](https://sonicscan.org/address/0x5497f4b12092d2a8bff8a9e1640ef68e44613f8c) -ActivityPoints [0x7fdf947ada5b8979e8aa05c373e1a6ed7457348a](https://sonicscan.org/address/0x7fdf947ada5b8979e8aa05c373e1a6ed7457348a) -Marketplace [0x3935866043766b86f30593bd17a787cc0105f7e0](https://sonicscan.org/address/0x3935866043766b86f30593bd17a787cc0105f7e0) -Cosmetics [0x9ac94b923333406d1c8b390ab606f90d6526c187](https://sonicscan.org/address/0x9ac94b923333406d1c8b390ab606f90d6526c187) -Global Events [0x8d61f3135a9f39b685b9765976e6a0f0572aeca5](https://sonicscan.org/address/0x8d61f3135a9f39b685b9765976e6a0f0572aeca5) -Black Market Trader [0xac619719cdcf1fc03438c7b9aff737993feae851](https://sonicscan.org/address/0xac619719cdcf1fc03438c7b9aff737993feae851) +ActivityPoints [0x7fdf947ada5b8979e8aa05c373e1a6ed7457348a](https://sonicscan.org/address/0x7fdf947ada5b8979e8aa05c373e1a6ed7457348a) +Marketplace [0x3935866043766b86f30593bd17a787cc0105f7e0](https://sonicscan.org/address/0x3935866043766b86f30593bd17a787cc0105f7e0) +Cosmetics [0x9ac94b923333406d1c8b390ab606f90d6526c187](https://sonicscan.org/address/0x9ac94b923333406d1c8b390ab606f90d6526c187) +Global Events [0x8d61f3135a9f39b685b9765976e6a0f0572aeca5](https://sonicscan.org/address/0x8d61f3135a9f39b685b9765976e6a0f0572aeca5) +Black Market Trader [0xac619719cdcf1fc03438c7b9aff737993feae851](https://sonicscan.org/address/0xac619719cdcf1fc03438c7b9aff737993feae851) +Game Subsidisation Registry [0xe42d998ec0ec2c5d217c8b54c9522b4224d1bdb0](https://sonicscan.org/address/0xe42d998ec0ec2c5d217c8b54c9522b4224d1bdb0) +Usage Based Session Module [00x71f7f7c98477de38e2f1a0217af0e1dc0fbf19e41](https://sonicscan.org/address/00x71f7f7c98477de38e2f1a0217af0e1dc0fbf19e41) ### Other addresses: diff --git a/contracts/Session/UsageBasedSessionModule.sol b/contracts/Session/UsageBasedSessionModule.sol index 802150ff..dfce8135 100644 --- a/contracts/Session/UsageBasedSessionModule.sol +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -44,7 +44,7 @@ contract UsageBasedSessionModule is UUPSUpgradeable, OwnableUpgradeable, EIP712U uint48 public constant MAX_SESSION_DURATION = 30 days; uint256 public constant MAX_BATCH_SIZE = 50; - uint16 public constant DEFAULT_SESSION_OPS_PER_DAY = 3; + uint16 public constant DEFAULT_SESSION_OPS_PER_DAY = 5; bytes32 private constant SESSION_TYPEHASH = keccak256( "UsageBasedSession(address safe,address target,bytes data,uint256 nonce,uint48 sessionDeadline)" ); diff --git a/scripts/contractAddresses.ts b/scripts/contractAddresses.ts index 83020e3d..8a134800 100644 --- a/scripts/contractAddresses.ts +++ b/scripts/contractAddresses.ts @@ -181,13 +181,13 @@ if (!isBeta) { cosmetics = "0x9ac94b923333406d1c8b390ab606f90d6526c187"; globalEvent = "0x8d61f3135a9f39b685b9765976e6a0f0572aeca5"; blackMarketTrader = "0xac619719cdcf1fc03438c7b9aff737993feae851"; - usageBasedSessionModule = ""; - gameSubsidisationRegistry = ""; + usageBasedSessionModule = "0x71f7f7c98477de38e2f1a0217af0e1dc0fbf19e4"; + gameSubsidisationRegistry = "0xe42d998ec0ec2c5d217c8b54c9522b4224d1bdb0"; subsidySigners = [ "0xd774bf717A0AfC12F511728Abe06a37e437923D2", "0x2047f1aaEb79CbDC51c730D3dc121EE76E5e1F14", "0x5B6283015D5eFCca3f268f4D805F961209BaCa70", - "0x1Bf3c9b8e7C1a5A4D2b9B1c3E7e5F8a2A1d3E4f5", + "0x1C88Ba0C339a87d7cd9826065A93079cA47D0e15", "0x85A05274359dAAF8615b0362dcde9f1F2bf57f28", ]; } diff --git a/scripts/deployAccountAbstraction.ts b/scripts/deployAccountAbstraction.ts index d7a46e6f..d8d555fb 100644 --- a/scripts/deployAccountAbstraction.ts +++ b/scripts/deployAccountAbstraction.ts @@ -15,7 +15,7 @@ async function main() { const network = await ethers.provider.getNetwork(); const {useSafe, apiKit, protocolKit} = await initialiseSafe(network); console.log( - `Deploy blackMarketTrader using account: ${proposer.address} on chain id ${network.chainId}, useSafe: ${useSafe}` + `Deploy account abstraction contracts using account: ${proposer.address} on chain id ${network.chainId}, useSafe: ${useSafe}` ); const timeout = 60 * 1000; // 1 minute @@ -50,11 +50,14 @@ async function main() { transactionSet.push({ to: await usageBasedSessionModule.getAddress(), value: "0", - data: usageBasedSessionModuleIface.encodeFunctionData("setWhitelistedSigner", [SUBSIDY_SIGNERS, true]), + data: usageBasedSessionModuleIface.encodeFunctionData("setWhitelistedSigner", [ + SUBSIDY_SIGNERS.map((s) => ethers.getAddress(s)), + true, + ]), operation: OperationType.Call, }); - const contractAddresses = groups.flatMap((g) => g.selectors.map((s) => s.contract)); + const contractAddresses = groups.flatMap((g) => g.selectors.map((s) => ethers.getAddress(s.contract))); const selectors = groups.flatMap((g) => g.selectors.map((s) => s.selector)); const groupIds = groups.flatMap((g) => g.selectors.map((s) => s.groupId));