diff --git a/.github/account-abstraction.instructions.md b/.github/account-abstraction.instructions.md new file mode 100644 index 00000000..730f316c --- /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 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. + +- 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..7bc4496b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,5 +24,8 @@ - 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. +- 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/.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/.openzeppelin/sonic.json b/.openzeppelin/sonic.json index cc268a37..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": { @@ -154011,6 +154021,699 @@ ] } } + }, + "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" + } + ] + } + } + }, + "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/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); } diff --git a/contracts/Session/GameSubsidisationRegistry.sol b/contracts/Session/GameSubsidisationRegistry.sol new file mode 100644 index 00000000..2188fb68 --- /dev/null +++ b/contracts/Session/GameSubsidisationRegistry.sol @@ -0,0 +1,62 @@ +// 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 {IGameSubsidisationRegistry} from "../interfaces/IGameSubsidisationRegistry.sol"; + +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; + + /// @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 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 new file mode 100644 index 00000000..dfce8135 --- /dev/null +++ b/contracts/Session/UsageBasedSessionModule.sol @@ -0,0 +1,291 @@ +// 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 {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"; +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, EIP712Upgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable { + error ExistingSessionActive(); + error NoSessionKey(); + error ActionNotPermitted(); + error GroupLimitReached(); + error InvalidSignature(); + error SessionExpired(); + error InvalidSessionDuration(); + error ZeroSessionKey(); + error InvalidCallData(); + error ModuleCallFailed(); + error UnauthorizedSigner(); + 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 MAX_BATCH_SIZE = 50; + 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)" + ); + + struct GroupUsage { + uint40 day; // day number (UTC) + uint40 count; // usage count for that day + } + + struct UserUsage { + mapping(uint256 => GroupUsage) groupUsage; // GroupID => usage for current day + uint256 nonce; + } + + struct Session { + 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 { + 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 + mapping(address => bool) private _whitelistedSigners; + uint256 private _gasOverhead; + uint16 private _sessionOpsPerDay; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + 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; + } + + /** + * @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()); + + Session storage session = _sessions[msg.sender]; + require(session.deadline < block.timestamp, ExistingSessionActive()); + + 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 nonReentrant whenNotPaused { + uint256 startGas = gasleft(); + require(_whitelistedSigners[msg.sender], UnauthorizedSigner()); + require(params.length > 0, NoBatchItems()); + require(params.length <= MAX_BATCH_SIZE, BatchTooLarge()); + + uint256 successCount; + for (uint256 i = 0; i < params.length; i++) { + try this.executeSingle(params[i]) { + ++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); + } + } + + // 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); + } + } + } + } + + /** + * @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 + 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) — single registry call (M2 optimisation) + bytes4 selector = bytes4(data[0:4]); + (uint256 groupId, uint256 limit) = _registry.getGroupAndLimit(target, selector); + require(groupId > 0, ActionNotPermitted()); + + uint256 currentDay; + unchecked { + currentDay = block.timestamp / 1 days; + } + UserUsage storage user = _usage[safe]; + GroupUsage storage group = user.groupUsage[groupId]; + if (group.day != uint40(currentDay)) { + group.day = uint40(currentDay); + group.count = 0; + } + uint256 currentUsage = group.count; + + require(currentUsage < limit, GroupLimitReached()); + + uint256 currentNonce = user.nonce; + bytes32 digest = _hashTypedDataV4( + keccak256( + abi.encode( + SESSION_TYPEHASH, + safe, + target, + keccak256(data), + currentNonce, + session.deadline + ) + ) + ); + require(ECDSA.recover(digest, signature) == session.sessionKey, InvalidSignature()); + + // 3. Increment for TODAY + user.nonce = currentNonce + 1; + group.count = uint40(currentUsage + 1); + + // 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 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]; + } + + receive() external payable {} + fallback() external payable {} + + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/interfaces/IGameSubsidisationRegistry.sol b/contracts/interfaces/IGameSubsidisationRegistry.sol new file mode 100644 index 00000000..539001de --- /dev/null +++ b/contracts/interfaces/IGameSubsidisationRegistry.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +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/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/contracts/test/Session/TestSessionHelpers.sol b/contracts/test/Session/TestSessionHelpers.sol new file mode 100644 index 00000000..8b44abf7 --- /dev/null +++ b/contracts/test/Session/TestSessionHelpers.sol @@ -0,0 +1,58 @@ +// 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 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) + { + 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/GameSubsidisationRegistry.json b/data/abi/GameSubsidisationRegistry.json new file mode 100644 index 00000000..f16d205e --- /dev/null +++ b/data/abi/GameSubsidisationRegistry.json @@ -0,0 +1,381 @@ +[ + { + "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": "LengthMismatch", + "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": "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": [ + { + "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": "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": [ + { + "internalType": "uint256", + "name": "_groupId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_limit", + "type": "uint256" + } + ], + "name": "setGroupLimit", + "outputs": [], + "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": [ + { + "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..e6ae983e --- /dev/null +++ b/data/abi/UsageBasedSessionModule.json @@ -0,0 +1,913 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "ActionNotPermitted", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [], + "name": "BatchTooLarge", + "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": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "EnforcedPause", + "type": "error" + }, + { + "inputs": [], + "name": "ExistingSessionActive", + "type": "error" + }, + { + "inputs": [], + "name": "ExpectedPause", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "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": "NoBatchItems", + "type": "error" + }, + { + "inputs": [], + "name": "NoSessionKey", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyInternal", + "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": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [], + "name": "SessionExpired", + "type": "error" + }, + { + "inputs": [], + "name": "SessionOpsPerDayLimitReached", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "inputs": [], + "name": "UnauthorizedSigner", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "inputs": [], + "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": [], + "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": [ + { + "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": 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": [ + { + "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": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newNonce", + "type": "uint256" + } + ], + "name": "SessionNonceIncremented", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLimit", + "type": "uint16" + } + ], + "name": "SessionOpsPerDayUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + } + ], + "name": "SessionRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "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": "DEFAULT_SESSION_OPS_PER_DAY", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_BATCH_SIZE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_SESSION_DURATION", + "outputs": [ + { + "internalType": "uint48", + "name": "", + "type": "uint48" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "UPGRADE_INTERFACE_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "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": [ + { + "internalType": "address", + "name": "_sessionKey", + "type": "address" + }, + { + "internalType": "uint48", + "name": "_duration", + "type": "uint48" + } + ], + "name": "enableSession", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "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": [ + { + "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": "executeSingle", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getGasOverhead", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "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": "uint32", + "name": "opDay", + "type": "uint32" + }, + { + "internalType": "uint16", + "name": "opCount", + "type": "uint16" + } + ], + "internalType": "struct UsageBasedSessionModule.Session", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getSessionOpsPerDay", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "contract IGameSubsidisationRegistry", + "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": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "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": [], + "name": "revokeSession", + "outputs": [], + "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": [ + { + "internalType": "address[]", + "name": "signers", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "whitelisted", + "type": "bool" + } + ], + "name": "setWhitelistedSigner", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpause", + "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" + }, + { + "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..8a134800 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 = "0x71f7f7c98477de38e2f1a0217af0e1dc0fbf19e4"; + gameSubsidisationRegistry = "0xe42d998ec0ec2c5d217c8b54c9522b4224d1bdb0"; + subsidySigners = [ + "0xd774bf717A0AfC12F511728Abe06a37e437923D2", + "0x2047f1aaEb79CbDC51c730D3dc121EE76E5e1F14", + "0x5B6283015D5eFCca3f268f4D805F961209BaCa70", + "0x1C88Ba0C339a87d7cd9826065A93079cA47D0e15", + "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/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/deploy.ts b/scripts/deploy.ts index 6c86e4ac..e8228554 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -43,6 +43,8 @@ import { ActivityPoints, Cosmetics, GlobalEvents, + GameSubsidisationRegistry, + UsageBasedSessionModule, BlackMarketTrader, } from "../typechain-types"; import { @@ -924,6 +926,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/deployAccountAbstraction.ts b/scripts/deployAccountAbstraction.ts new file mode 100644 index 00000000..d8d555fb --- /dev/null +++ b/scripts/deployAccountAbstraction.ts @@ -0,0 +1,86 @@ +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 account abstraction contracts 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.map((s) => ethers.getAddress(s)), + true, + ]), + operation: OperationType.Call, + }); + + 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)); + + 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/scripts/verifyContracts.ts b/scripts/verifyContracts.ts index d037a2ce..a4868935 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"; @@ -105,7 +107,13 @@ async function main() { // await verifyContracts(["0x9f76DE2260CF0E2c08CDF0628E7f00b03c37b861"] /* addresses */, [[VRF_ADDRESS]]); await verifyContracts([PET_NFT_LIBRARY_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]); await verifyContracts([PLAYERS_ADDRESS]); await verifyContracts([PLAYERS_IMPL_MISC1_ADDRESS]); await verifyContracts([PLAYERS_IMPL_MISC_ADDRESS]); 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" + } + } +} diff --git a/test/Players/PlayersFixture.ts b/test/Players/PlayersFixture.ts index ecd3fd1f..6f60afe7 100644 --- a/test/Players/PlayersFixture.ts +++ b/test/Players/PlayersFixture.ts @@ -39,6 +39,8 @@ import { Bridge, ActivityPoints, GlobalEvents, + GameSubsidisationRegistry, + UsageBasedSessionModule, BlackMarketTrader, } from "../../typechain-types"; import {MAX_TIME} from "../utils"; @@ -452,6 +454,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 = [ @@ -863,6 +878,8 @@ export const playersFixture = async function () { cosmeticId, cosmeticInfo, globalEvents, + gameSubsidisationRegistry, + usageBasedSessionModule, blackMarketTrader, }; }; 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..a227c0d5 --- /dev/null +++ b/test/Session/UsageBasedSessionModule.ts @@ -0,0 +1,969 @@ +import {loadFixture, setNextBlockBaseFeePerGas} 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 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()); + + 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"}, + ], + }; + const message = { + safe: await safe.getAddress(), + target: await target.getAddress(), + data, + nonce, + sessionDeadline, + }; + + 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.executeBatch([{safe: await safe.getAddress(), target: 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); + // ZeroSessionKey is checked before ExistingSessionActive / SessionOpsPerDayLimitReached + 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); + // InvalidSessionDuration is checked before ExistingSessionActive / SessionOpsPerDayLimitReached + 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); + // 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) + ).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); + // 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, + "SessionRevoked" + ); + + const session = await module.getSession(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); + 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} = await setupSession(2); + await safe.execTransactionFromModule( + await module.getAddress(), + 0, + module.interface.encodeFunctionData("revokeSession"), + 0 + ); + + 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, selector} = 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()); + + 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, sessionKey, sessionDeadline} = 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() + ); + + 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} = 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() + ); + + 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, selector} = 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() + ); + + 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, 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()); + + 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, selector} = 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() + ); + + 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, 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.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.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()); + 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 () => { + 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) + .executeBatch([{safe: await safe.getAddress(), target: 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) + .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); + + // 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("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); + 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" + ); + }); + + 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 () { + 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 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()); + + 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.executeBatch([ + {safe: await safe.getAddress(), target: 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.executeBatch([ + {safe: await safe.getAddress(), target: await playerNFT.getAddress(), data: data1, signature: 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.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", [ + avatarId, + "ThirdHero" + 3, + "", + "", + "", + false, + true, + ]); + const sig3 = await signCall(sessionKey, safe, playerNFT, data3, 2n, sessionDeadline, await module.getAddress()); + 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 () => { + 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() + ); + + 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", []) + ); + }); + }); +});