diff --git a/.github/workflows/account-modules-ci.yml b/.github/workflows/account-modules-ci.yml new file mode 100644 index 0000000..ad4b175 --- /dev/null +++ b/.github/workflows/account-modules-ci.yml @@ -0,0 +1,49 @@ +name: Account Modules CI + +on: + pull_request: + paths: + - 'solidity/account-modules/**' + - '.github/workflows/account-modules-ci.yml' + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + name: Account Modules Foundry project + runs-on: ubuntu-latest + defaults: + run: + working-directory: solidity/account-modules + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: | + forge --version + + - name: Install Node dependencies + run: | + cd lib/modulekit && npm install + + - name: Run Forge fmt + run: | + forge fmt --check + id: fmt + + - name: Run Forge build + run: | + forge build --sizes src/ + id: build + + - name: Run Forge tests + run: | + ACCOUNT_TYPE=SAFE forge test -vvv + id: test diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..53c9cf2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,15 @@ +[submodule "solidity/account-modules/lib/forge-std"] + path = solidity/account-modules/lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "solidity/account-modules/lib/modulekit"] + path = solidity/account-modules/lib/modulekit + url = https://github.com/pcarranzav/modulekit +[submodule "solidity/account-modules/lib/openzeppelin-contracts"] + path = solidity/account-modules/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "solidity/account-modules/lib/BokkyPooBahsDateTimeLibrary"] + path = solidity/account-modules/lib/BokkyPooBahsDateTimeLibrary + url = https://github.com/bokkypoobah/BokkyPooBahsDateTimeLibrary +[submodule "solidity/account-modules/lib/safe-singleton-deployer-sol"] + path = solidity/account-modules/lib/safe-singleton-deployer-sol + url = https://github.com/wilsoncusack/safe-singleton-deployer-sol diff --git a/.prettierignore b/.prettierignore index f7ebd59..3ce4a66 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,3 +18,6 @@ tmp/ # Lock files pnpm-lock.yaml uv.lock + +# Solidity submodules (have their own prettier configs) +solidity/**/lib/ diff --git a/solidity/account-modules/.gitignore b/solidity/account-modules/.gitignore new file mode 100644 index 0000000..3269660 --- /dev/null +++ b/solidity/account-modules/.gitignore @@ -0,0 +1,11 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Dotenv file +.env diff --git a/solidity/account-modules/README.md b/solidity/account-modules/README.md new file mode 100644 index 0000000..07118cc --- /dev/null +++ b/solidity/account-modules/README.md @@ -0,0 +1,74 @@ +# Account Modules + +ERC-7579 compliant executor modules for smart account automation in the x402 payments system. + +## Modules + +### AutoTopUpModule + +- Automatically tops up agent accounts when balance falls below threshold +- Configurable per-agent limits (daily, monthly) +- Permissionless triggering with proper checks +- Used by: Main accounts to fund agent accounts + +### AutoCollectModule + +- Automatically collects payments from service accounts +- Configurable collection thresholds (amount and time) +- Batch collection optimization +- Used by: Service accounts to collect to main accounts + +## Architecture + +All modules implement the ERC-7579 executor module interface: + +- `onInstall(bytes calldata data)` - Module installation +- `onUninstall(bytes calldata data)` - Module removal +- `isModuleType(uint256 typeID)` - Returns true for executor type (0x01) +- `isInitialized(address account)` - Check if module is initialized for account + +## Deployment + +The modules are deployed as immutable singletons using Safe's Singleton Factory, ensuring the same addresses across all +chains: + +- **AutoTopUpExecutor**: `0x16f13052FbFFfcE34E5752b7F4CFF881a030F40B` +- **AutoCollectExecutor**: `0x29864bd91370886c38dE9Fe95F5589E7EbE15130` + +These addresses are deterministic and will remain consistent on all supported chains (Base, Base Sepolia, etc.). + +## Development + +### Prerequisites + +- [Foundry](https://book.getfoundry.sh/getting-started/installation) +- [pnpm](https://pnpm.io/installation) (for ModuleKit dependencies) + +### Setup + +```bash +# Install Forge dependencies +forge install + +# Install ModuleKit node dependencies (required for compilation) +cd lib/modulekit && npm install && cd ../.. + +# Build contracts +forge build + +# Run tests +forge test + +# Deploy +forge script script/Deploy.s.sol --rpc-url $RPC_URL --broadcast +``` + +**Important**: ModuleKit requires its node modules to be installed for proper compilation. This step (`pnpm install` in +the modulekit directory) must be performed after cloning the repository and before building. + +## Security + +- Non-custodial design - modules only execute based on user-defined rules +- Access control via ERC-7579 standards +- Immutable singleton contracts - no upgradeability, no owner +- Comprehensive event logging diff --git a/solidity/account-modules/docs/auto-collect/architecture.md b/solidity/account-modules/docs/auto-collect/architecture.md new file mode 100644 index 0000000..f7d1d78 --- /dev/null +++ b/solidity/account-modules/docs/auto-collect/architecture.md @@ -0,0 +1,263 @@ +# AutoCollectExecutor Architecture + +## Overview + +AutoCollectExecutor is an ERC-7579 executor module that automatically collects funds from service accounts to a main +seller account. It mirrors the AutoTopUpExecutor pattern but reverses the transfer direction - collecting funds FROM the +account where the module is installed TO a configured target account. + +## Key Design Principles + +1. **Installation Location**: Module is installed ON each service account (not on main account) +2. **Transfer Direction**: FROM service account (where module is installed) TO main account +3. **Permissionless Execution**: Anyone can trigger collections (gas incentives for automation) +4. **Calendar-Based Limits**: Collections limited to once per calendar day (UTC) +5. **Per-Asset Configuration**: Each token has its own collection configuration + +## Architecture Components + +### Storage Structure + +```solidity +// Configuration per asset +struct CollectConfig { + address target; // Main account to collect funds to + address asset; // ERC-20 token address + uint256 threshold; // Minimum balance to trigger collection (0 = no threshold) + uint256 minimumRemaining; // Minimum amount to leave in account (0 = collect all) + bool enabled; // Whether collection is enabled +} + +// State tracking per configuration +struct CollectState { + uint256 lastCollectDate; // Last collection date (YYYYMMDD format) +} + +// Storage mappings +mapping(uint256 => CollectConfig) configs; // configId => config +mapping(uint256 => CollectState) states; // configId => state +mapping(address => EnumerableSet.Bytes32Set) accountConfigs; // service account => configIds (using EnumerableSet) + +// Config ID generation (deterministic) +configId = keccak256(abi.encode("CollectConfig", serviceAccount, asset)) +``` + +### Core Functions + +#### Module Lifecycle + +- `onInstall(bytes calldata data)` - Install with optional initial configurations +- `onUninstall(bytes calldata)` - Clean up all configurations and state +- `isModuleType(uint256 typeID)` - Returns MODULE_TYPE_EXECUTOR constant +- `isInitialized(address account)` - Check if module is initialized for account + +#### Configuration Management + +- `configureCollection(address asset, CollectConfig config)` - Create/update config for msg.sender +- `enableCollection(bytes32 configId)` - Enable collection for a config +- `disableCollection(bytes32 configId)` - Disable collection for a config + +#### Collection Execution + +- `triggerCollection(address account, address asset)` - Trigger collection for specific asset on account +- `triggerAllCollections(address account)` - Trigger all enabled collections for specific account +- `canExecuteCollection(address account, address asset)` - Check if collection can execute + +#### View Functions + +- `getCollectionConfig(address account, address asset)` - Get specific config +- `getCollectionConfigs(address account)` - Get all configs for an account +- `getCollectionState(address account, address asset)` - Get collection state + +## Execution Flow + +### Collection Trigger Flow + +``` +1. External caller → triggerCollection(account, asset) +2. Module validates: + - Configuration exists and is enabled for account + - Calendar day has changed since last collection + - Balance meets threshold (if threshold > 0) + - Balance >= minimumRemaining (can leave the required amount) + - collectAmount = balance - minimumRemaining > 0 +3. Module executes: + - Get current balance of asset in account + - Calculate collect amount: balance - minimumRemaining + - Transfer collect amount to configured target + - Leave minimumRemaining in account + - If transfer fails, emit CollectionSkipped event and continue + - Update lastCollectDate only on successful transfer +4. Emit CollectionExecuted or CollectionSkipped event +``` + +### Calendar Day Tracking + +- Uses BokkyPooBah's DateTime Library (same as AutoTopUpExecutor) +- Dates encoded as YYYYMMDD (e.g., 20240829) +- All timestamps are UTC-based +- Prevents multiple collections per calendar day + +## Security Considerations + +1. **Access Control** + - Only service account (msg.sender) can configure its own collections + - Anyone can trigger collections (permissionless) + - Module can only transfer FROM the account it's installed on + +2. **Reentrancy Protection** + - CEI (Checks-Effects-Interactions) pattern + - State updates before external calls + - ReentrancyGuard on execution functions + +3. **Token Safety** + - Execute transfers via Safe's \_execute() (like AutoTopUpExecutor) + - Handle non-standard token returns by checking returndata length + - Decode and validate return value if present + - Zero-value transfer protection + +## Events + +```solidity +event CollectionConfigured( + address indexed account, + address indexed asset, + address indexed target, + uint256 threshold +); + +event CollectionExecuted( + address indexed account, + address indexed asset, + address indexed target, + uint256 amount +); + +event CollectionSkipped( + address indexed account, + address indexed asset, + bytes32 indexed configId, + uint256 balance, + uint256 threshold, + uint256 minimumRemaining, + uint256 collectAmount +); + +event CollectionEnabled(address indexed account, address indexed asset); +event CollectionDisabled(address indexed account, address indexed asset); +``` + +## Edge Cases & Validation + +1. **Collection Validation** + - If balance < threshold: Skip collection, emit CollectionSkipped event + - If balance = 0: Skip collection, emit CollectionSkipped event + - If balance < minimumRemaining: Skip collection, emit CollectionSkipped event + - If balance - minimumRemaining = 0: Skip collection, emit CollectionSkipped event + - Threshold = 0 means trigger collection on any balance + - minimumRemaining = 0 means collect full balance (default behavior) + +2. **Valid Configurations** + - minimumRemaining > threshold: Valid (effective threshold is minimumRemaining) + - minimumRemaining = threshold: Valid (collect when balance exactly at or above threshold) + - minimumRemaining = 0, threshold = 0: Valid (collect any non-zero balance) + - minimumRemaining > 0, threshold = 0: Valid (collect when balance > minimumRemaining) + +3. **Invalid Configuration** + - Target cannot be zero address + - Asset must be valid ERC-20 contract + - Threshold and minimumRemaining can both be 0 + +4. **Date Boundaries** + - Month transitions handled correctly + - Year transitions handled correctly + - Leap years considered + +5. **Token Edge Cases** + - Non-standard return values (USDT) + - Tokens with transfer fees + - Balance changes between check and transfer (handled gracefully by skipping collection) + +## Gas Optimization + +1. **Storage Patterns** + - Separate config and state structs for efficient updates + - Pack struct fields for optimal storage slots + - Use mappings instead of arrays where possible + +2. **Execution Optimization** + - Calculate date once per execution + - Batch operations where possible + - Skip zero-balance collections early + +## Differences from AutoTopUpExecutor + +| Aspect | AutoTopUpExecutor | AutoCollectExecutor | +| ------------------ | --------------------- | --------------------------- | +| Installation | On main account | On service accounts | +| Transfer Direction | Main → Agent accounts | Service account → Main | +| Config Storage | Per (agent, asset) | Per (asset) on each account | +| Limits | Daily/Monthly amounts | Once per calendar day | +| Threshold | Top-up when below | Collect when above | +| Target | Multiple agents | Single main account | + +## Integration with Safe Accounts + +Service accounts will have AutoCollectExecutor installed as an ERC-7579 module: + +```solidity +Safe Service Account +├── Owner: User's Privy Wallet +├── Modules: +│ └── AutoCollectExecutor +│ ├── Collection configs per asset +│ ├── Target: Main account (also owned by Privy wallet) +│ └── Thresholds and state +└── Holdings: USDC, other tokens (to be collected) +``` + +## Testing Strategy + +1. **Unit Tests** + - All configuration functions + - Collection execution logic + - Calendar day calculations + - Edge cases and error conditions + +2. **Integration Tests (ModuleKit)** + - Module installation/uninstallation + - Cross-account interactions + - Real Safe account testing + - Gas usage validation + +3. **Fuzz Testing** + - Date arithmetic edge cases + - Random threshold values + - Multiple asset scenarios + +## Implementation Notes + +### Upgradeability + +- Use UUPS proxy pattern (same as AutoTopUpExecutor) +- OwnableUpgradeable for upgrade admin control +- ERC-7201 namespaced storage for upgrade safety + +### Dependencies + +- BokkyPooBah's DateTime Library (already in lib/) +- OpenZeppelin contracts upgradeable (including EnumerableSet) +- ModuleKit for ERC-7579 compliance +- ERC7579ExecutorBase for \_execute() functionality + +## Implementation Checklist + +- [ ] Core contract implementation (UUPS upgradeable) +- [ ] Interface extraction (IAutoCollectExecutor) +- [ ] Token transfer via \_execute() with returndata validation +- [ ] Calendar-based date tracking with DateTime library +- [ ] Event and error definitions +- [ ] Unit tests following AutoTopUpExecutor.t.sol patterns +- [ ] Integration tests with ModuleKit +- [ ] Gas optimization and forge fmt +- [ ] Security review diff --git a/solidity/account-modules/docs/auto-top-up/architecture.md b/solidity/account-modules/docs/auto-top-up/architecture.md new file mode 100644 index 0000000..db65f70 --- /dev/null +++ b/solidity/account-modules/docs/auto-top-up/architecture.md @@ -0,0 +1,306 @@ +# AutoTopUpExecutor Module Architecture + +## Overview + +The AutoTopUpExecutor is an ERC-7579 executor module that enables automatic balance management for Safe smart accounts. +It allows anyone to trigger pre-configured ERC-20 token transfers from a main Safe account to designated agent accounts +when their balances fall below specified thresholds. + +## Core Design Principles + +### 1. Non-Custodial Architecture + +- Module operates as part of the Safe's execution context +- Never holds funds directly +- User maintains full control over configuration and can disable at any time +- Executions happen through Safe's module system + +### 2. Permissionless Triggering + +- Anyone can call the trigger function to execute configured top-ups +- Security enforced through on-chain configuration and limits +- Primary automation through our keeper infrastructure (with option for users to self-host) + +### 3. Multi-Agent Support + +- Single module instance manages multiple agent configurations +- Each agent has independent thresholds, amounts, and limits +- Efficient batch operations for checking and executing multiple top-ups + +## Technical Architecture + +### Module Type + +- **Type**: Executor Module (0x01) +- **Standard**: ERC-7579 compliant +- **Base**: Rhinestone ModuleKit's `ERC7579ExecutorBase` +- **Upgradeable**: Yes, using UUPS pattern with OpenZeppelin + +### Storage Pattern + +- **ERC-7201**: Namespaced storage for upgrade safety +- **Storage Layout**: + + ```solidity + // User-configurable parameters + struct TopUpConfig { + uint256 dailyLimit; // Target balance to maintain (max top-up per day) + uint256 monthlyLimit; // Maximum total top-ups per month + bool enabled; // Configuration active status + } + + // Immutable identity and internal state (not user-editable) + struct TopUpState { + address agent; // Target agent account (immutable, part of config ID) + address asset; // ERC-20 token address (immutable, part of config ID) + uint256 lastTopUpTime; // Last top-up timestamp (enforces once-per-day) + uint256 monthlySpent; // Amount topped up this month + uint256 lastResetMonth; // Last monthly reset timestamp + } + + struct AutoTopUpStorage { + // Config ID => user configuration + mapping(bytes32 => TopUpConfig) configs; + + // Config ID => internal state + mapping(bytes32 => TopUpState) states; + + // Account => set of config IDs + mapping(address => EnumerableSet.Bytes32Set) accountConfigs; + } + ``` + +- **Config ID Generation**: `keccak256(abi.encode("TopUpConfig", account, agent, asset))` returns bytes32 for unique + configs +- **Namespaced IDs**: "TopUpConfig" prefix prevents collision with other system hashes +- **Per-Account Execution**: Each account's configs are triggered separately to avoid gas limits +- **Gas Optimization**: EnumerableSet allows O(1) add/remove and efficient iteration within an account + +### Key Functions + +#### Module Interface (ERC-7579 via ModuleKit) + +```solidity +// Inherited from ERC7579ExecutorBase +function onInstall(bytes calldata data) external +function onUninstall(bytes calldata data) external +function isModuleType(uint256 typeID) external view returns (bool) +function isInitialized(address smartAccount) external view returns (bool) + +// Internal execution helpers from base (used within our functions) +// _execute(address account, address to, uint256 value, bytes memory data) - single tx +// _execute(address account, Execution[] memory execs) - batch tx +// (Plus msg.sender variants and delegatecall versions we won't use) +``` + +#### Configuration Management + +```solidity +function configureTopUp( + address agent, + address asset, + TopUpConfig calldata config +) external returns (bytes32 configId) + +function configureTopUpById( + bytes32 configId, + TopUpConfig calldata config +) external + +function disableTopUp(bytes32 configId) external +function enableTopUp(bytes32 configId) external +``` + +#### Execution + +```solidity +function triggerTopUps(address account) external returns (bytes32[] memory executed) +function triggerTopUp(bytes32 configId) external returns (bool executed) +``` + +#### View Functions + +```solidity +function generateConfigId(address account, address agent, address asset) external pure returns (bytes32) +function getTopUpConfigs(address account) external view returns (TopUpConfig[] memory, TopUpState[] memory) +function canExecuteTopUp(bytes32 configId) external view returns (bool, string memory reason) +function getTopUpById(bytes32 configId) external view returns (TopUpConfig memory config, TopUpState memory state) +function getTopUp(address account, address agent, address asset) external view returns (TopUpConfig memory config, TopUpState memory state) +``` + +## Execution Flow + +### 1. Installation + +1. Safe owner calls module management to install AutoTopUpExecutor +2. Module's `onInstall` is called with initial configurations (optional) +3. Module registers the Safe account as initialized + +### 2. Configuration + +1. Safe owner sends transaction to module to configure top-ups +2. Config ID generated: `keccak256(abi.encode("TopUpConfig", account, agent, asset))` +3. `configureTopUp()` creates new or updates existing config for agent/asset pair +4. `configureTopUpById()` updates existing config by its ID +5. Agent and asset are immutable once set (part of the config ID) +6. Only dailyLimit, monthlyLimit, and enabled status can be updated +7. Multiple configurations can exist for different agents and different tokens +8. Frontend/keeper can precompute config IDs using `generateConfigId()` + +### 3. Triggering + +1. Keeper calls `triggerTopUps(account)` for each account (separate transactions) +2. Module iterates through all enabled configurations for that specific account +3. For each configuration: + - Check if already topped up today (via `lastTopUpTime`) + - Calculate top-up amount: `min(dailyLimit - agentBalance, monthlyLimit - monthlySpent)` + - Skip if amount ≤ 0 (agent already funded or monthly limit reached) + - Execute transfer via internal `_execute()` helper (triggers Safe's module execution) + - Update `lastTopUpTime` and `monthlySpent` + +### 4. Limit Management + +- **Daily**: Enforced by once-per-day check using `lastTopUpTime` +- **Monthly**: Reset on the 1st of each month (tracked via `lastResetMonth`) +- **Top-up calculation**: Always maintains balance up to `dailyLimit`, respecting `monthlyLimit` +- **No partial days**: If a top-up happens, it's for the full daily amount (up to monthly remaining) + +## Security Considerations + +### Access Control + +- Only Safe account can configure its own top-ups +- Configuration updates require Safe transaction +- No admin keys or external control + +### Limit Enforcement + +- Once-per-day enforcement prevents abuse +- Monthly budget caps provide spending control +- Simple calculation: top up to daily limit, not exceeding monthly budget + +### Reentrancy Protection + +- OpenZeppelin's ReentrancyGuard on all state-changing functions +- Checks-effects-interactions pattern +- State updates before external calls + +### Validation + +- Balance checks before transfers +- Sufficient Safe balance validation +- Asset address validation (ensure valid ERC-20 contract) +- Agent address validation (prevent self-transfers) + +## Gas Optimization + +### Batch Operations + +- Per-account batch execution (all configs for one account in one tx) +- Bounded gas usage per transaction +- Keeper maintains off-chain list of accounts to service + +### Storage Efficiency + +- EnumerableSet for per-account config tracking +- O(1) config lookups via mapping +- Config IDs prevent duplicate account/agent/asset combinations +- No global account list needed (keeper tracks off-chain) + +## Integration Points + +### ERC-7579 Module System + +- Registered as executor module (type 0x01) +- Uses ModuleKit's `_execute()` helpers for execution +- Works across all ERC-7579 compliant accounts (Safe, Kernel, Biconomy) + +### Token Contracts + +- ERC20 interface for balance checks +- OpenZeppelin's SafeERC20 for safe token transfers +- Transfers executed via Safe module system (using \_execute) +- No direct token approvals needed + +### Monitoring & Events + +```solidity +event TopUpConfigured(address indexed account, address indexed agent, address asset, bytes32 indexed configId, TopUpConfig config) +event TopUpExecuted(address indexed account, address indexed agent, address asset, bytes32 indexed configId, uint256 amount) +event TopUpFailed(address indexed account, address indexed agent, address asset, bytes32 indexed configId, string reason) +event TopUpEnabled(address indexed account, address indexed agent, address asset, bytes32 indexed configId) +event TopUpDisabled(address indexed account, address indexed agent, address asset, bytes32 indexed configId) +``` + +- **TopUpConfigured**: Emitted on both initial configuration and updates +- **TopUpEnabled/Disabled**: Emitted whenever enabled status changes (via any function) + +## Future Enhancements + +### Post-MVP Improvements + +1. **Native Token Support**: ETH/MATIC/etc. alongside ERC-20s +2. **Off-chain Signatures**: Gasless configuration updates via EIP-712 signed messages + +## Testing Strategy + +### Unit Tests + +- Configuration CRUD operations +- Daily limit enforcement (once per day) +- Monthly limit tracking and resets +- Balance calculation logic (top up to daily limit) + +### Integration Tests + +- Safe module installation/uninstallation using ModuleKit test utilities +- Token transfer execution through Safe +- Multi-account scenarios using ModuleKit's `makeAccountInstance` +- Cross-implementation tests (Safe, Kernel, Biconomy) - lower priority + +### Security Tests + +- Reentrancy attempts +- Limit bypass attempts (trying to trigger multiple times per day) +- Unauthorized configuration changes +- Edge cases around month transitions + +## Implementation Stack + +### Dependencies + +- **ModuleKit** (v0.5.9+): Rhinestone's development kit for ERC-7579 modules + - Provides `ERC7579ExecutorBase` for executor functionality + - Built-in test utilities for multi-account testing (Safe, Kernel, Biconomy) + - Helper functions for module deployment + - Integration with Module Registry + - Main import: `import "modulekit/Modules.sol"` + - **Important**: Requires `npm install` in lib/modulekit directory for ERC4337 dependencies +- **OpenZeppelin Contracts Upgradeable**: For UUPS proxy pattern and utilities +- **OpenZeppelin Contracts**: For ERC20 interface and security utilities + +### Inheritance Chain + +``` +AutoTopUpExecutor + ├── ERC7579ExecutorBase (from ModuleKit) + │ └── IERC7579Module + ├── UUPSUpgradeable (from OpenZeppelin) + ├── OwnableUpgradeable (from OpenZeppelin) + └── ReentrancyGuardUpgradeable (from OpenZeppelin) +``` + +## Deployment Considerations + +### Deployment Steps + +1. Deploy implementation contract +2. Deploy proxy with initializer +3. Register in Rhinestone module registry +4. Integrate with Safe deployment flow +5. Utilize ModuleKit's deployment helpers + +### Monitoring + +- Subgraph for indexing all contract events +- Track and alert on failed top-ups (especially during initial rollout) diff --git a/solidity/account-modules/foundry.toml b/solidity/account-modules/foundry.toml new file mode 100644 index 0000000..41e39e9 --- /dev/null +++ b/solidity/account-modules/foundry.toml @@ -0,0 +1,28 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +remappings = [ + "modulekit/=lib/modulekit/", + "@openzeppelin/=lib/openzeppelin-contracts/", + "forge-std/=lib/forge-std/src/", + "BokkyPooBahsDateTimeLibrary/=lib/BokkyPooBahsDateTimeLibrary/", + "safe-singleton-deployer/=lib/safe-singleton-deployer-sol/src/", + # ModuleKit dependencies + "erc4337-validation/=lib/modulekit/node_modules/@rhinestone/erc4337-validation/src/", + "@ERC4337/=lib/modulekit/node_modules/@ERC4337/", + "account-abstraction/=lib/modulekit/node_modules/@ERC4337/account-abstraction/contracts/", + "account-abstraction-v0.6/=lib/modulekit/node_modules/@ERC4337/account-abstraction-v0.6/contracts/", + "@rhinestone/=lib/modulekit/node_modules/@rhinestone/", + "ds-test/=lib/modulekit/node_modules/ds-test/src/", + "solady/=lib/modulekit/node_modules/solady/src/", + "solarray/=lib/modulekit/node_modules/solarray/src/", + "@prb/math/=lib/modulekit/node_modules/@prb/math/src/", + "ExcessivelySafeCall/=lib/modulekit/node_modules/excessively-safe-call/src/" +] +solc = "0.8.30" +optimizer = true +optimizer_runs = 200 + +[lint] +exclude_lints = ["asm-keccak256"] diff --git a/solidity/account-modules/lib/BokkyPooBahsDateTimeLibrary b/solidity/account-modules/lib/BokkyPooBahsDateTimeLibrary new file mode 160000 index 0000000..1dc26f9 --- /dev/null +++ b/solidity/account-modules/lib/BokkyPooBahsDateTimeLibrary @@ -0,0 +1 @@ +Subproject commit 1dc26f977c57a6ba3ed6d7c53cafdb191e7e59ae diff --git a/solidity/account-modules/lib/forge-std b/solidity/account-modules/lib/forge-std new file mode 160000 index 0000000..8bbcf6e --- /dev/null +++ b/solidity/account-modules/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 8bbcf6e3f8f62f419e5429a0bd89331c85c37824 diff --git a/solidity/account-modules/lib/modulekit b/solidity/account-modules/lib/modulekit new file mode 160000 index 0000000..676bb65 --- /dev/null +++ b/solidity/account-modules/lib/modulekit @@ -0,0 +1 @@ +Subproject commit 676bb65032c17b13ecd15ee89f435f0afeffca9b diff --git a/solidity/account-modules/lib/openzeppelin-contracts b/solidity/account-modules/lib/openzeppelin-contracts new file mode 160000 index 0000000..c64a1ed --- /dev/null +++ b/solidity/account-modules/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit c64a1edb67b6e3f4a15cca8909c9482ad33a02b0 diff --git a/solidity/account-modules/lib/safe-singleton-deployer-sol b/solidity/account-modules/lib/safe-singleton-deployer-sol new file mode 160000 index 0000000..cf2b89c --- /dev/null +++ b/solidity/account-modules/lib/safe-singleton-deployer-sol @@ -0,0 +1 @@ +Subproject commit cf2b89c33fed536c4dd6fef2fb84f39053068868 diff --git a/solidity/account-modules/script/CalculateStorageSlot.s.sol b/solidity/account-modules/script/CalculateStorageSlot.s.sol new file mode 100644 index 0000000..7fbacdd --- /dev/null +++ b/solidity/account-modules/script/CalculateStorageSlot.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {Script, console} from "forge-std/Script.sol"; +import {SlotDerivation} from "@openzeppelin/contracts/utils/SlotDerivation.sol"; + +contract CalculateStorageSlot is Script { + using SlotDerivation for string; + + function run() public { + // AutoTopUpExecutor storage slot + string memory topUpNamespace = "autotopup.storage.AutoTopUpExecutor"; + bytes32 topUpSlot = topUpNamespace.erc7201Slot(); + console.log("AutoTopUpExecutor Namespace:", topUpNamespace); + console.log("AutoTopUpExecutor Storage slot:"); + console.logBytes32(topUpSlot); + + console.log(""); + + // AutoCollectExecutor storage slot + string memory collectNamespace = "autocollect.storage.AutoCollectExecutor"; + bytes32 collectSlot = collectNamespace.erc7201Slot(); + console.log("AutoCollectExecutor Namespace:", collectNamespace); + console.log("AutoCollectExecutor Storage slot:"); + console.logBytes32(collectSlot); + } +} diff --git a/solidity/account-modules/script/Deploy.s.sol b/solidity/account-modules/script/Deploy.s.sol new file mode 100644 index 0000000..931afa5 --- /dev/null +++ b/solidity/account-modules/script/Deploy.s.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {Script, console} from "forge-std/Script.sol"; +import {SafeSingletonDeployer} from "safe-singleton-deployer/SafeSingletonDeployer.sol"; +import {AutoTopUpExecutor} from "../src/AutoTopUpExecutor.sol"; +import {AutoCollectExecutor} from "../src/AutoCollectExecutor.sol"; + +contract Deploy is Script { + // Use meaningful salts for deterministic addresses across chains + bytes32 constant AUTO_TOPUP_SALT = keccak256("AutoTopUpExecutor.v1"); + bytes32 constant AUTO_COLLECT_SALT = keccak256("AutoCollectExecutor.v1"); + + function run() external returns (address autoTopUp, address autoCollect) { + // Get deployer from environment + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + + console.log("=== Deploying Account Modules ==="); + console.log("Deployer:", vm.addr(deployerPrivateKey)); + console.log(""); + + vm.startBroadcast(deployerPrivateKey); + + // Deploy AutoTopUpExecutor using Safe Singleton Factory + console.log("Deploying AutoTopUpExecutor..."); + autoTopUp = _deploySingleton( + type(AutoTopUpExecutor).creationCode, + "", // No constructor args + AUTO_TOPUP_SALT, + "AutoTopUpExecutor" + ); + + // Deploy AutoCollectExecutor using Safe Singleton Factory + console.log("Deploying AutoCollectExecutor..."); + autoCollect = _deploySingleton( + type(AutoCollectExecutor).creationCode, + "", // No constructor args + AUTO_COLLECT_SALT, + "AutoCollectExecutor" + ); + + vm.stopBroadcast(); + + // Log deployment summary + console.log(""); + console.log("=== Deployment Summary ==="); + console.log("AutoTopUpExecutor:", autoTopUp); + console.log("AutoCollectExecutor:", autoCollect); + console.log(""); + console.log("These addresses will be consistent across all chains!"); + console.log("========================="); + } + + function _deploySingleton( + bytes memory creationCode, + bytes memory constructorArgs, + bytes32 salt, + string memory contractName + ) internal returns (address deployed) { + // Use SafeSingletonDeployer.deploy (not broadcastDeploy) since we're already broadcasting + deployed = SafeSingletonDeployer.deploy(creationCode, constructorArgs, salt); + + console.log(string.concat(contractName, " deployed at:"), deployed); + + // Verify deployment + require(deployed.code.length > 0, string.concat(contractName, " deployment failed")); + } + + // Helper function to predict addresses without deploying + function predict() external view { + console.log("=== Predicted Addresses ==="); + + // Predict AutoTopUpExecutor address + address predictedAutoTopUp = + SafeSingletonDeployer.computeAddress(type(AutoTopUpExecutor).creationCode, AUTO_TOPUP_SALT); + console.log("AutoTopUpExecutor:", predictedAutoTopUp); + + // Predict AutoCollectExecutor address + address predictedAutoCollect = + SafeSingletonDeployer.computeAddress(type(AutoCollectExecutor).creationCode, AUTO_COLLECT_SALT); + console.log("AutoCollectExecutor:", predictedAutoCollect); + + console.log("==========================="); + } +} diff --git a/solidity/account-modules/script/README.md b/solidity/account-modules/script/README.md new file mode 100644 index 0000000..ac456f5 --- /dev/null +++ b/solidity/account-modules/script/README.md @@ -0,0 +1,46 @@ +# Account Modules Deployment Scripts + +## Deploy.s.sol + +Deploys the AutoTopUpExecutor and AutoCollectExecutor modules using Safe's Singleton Factory for deterministic addresses +across all chains. + +### Usage + +#### Predict Addresses (without deploying) + +```bash +forge script script/Deploy.s.sol:Deploy --sig "predict()" +``` + +#### Deploy to a Network + +```bash +# Set required environment variables +export PRIVATE_KEY= + +# Deploy to Base Sepolia +forge script script/Deploy.s.sol:Deploy \ + --rpc-url \ + --broadcast \ + --verify + +# Deploy to Base Mainnet +forge script script/Deploy.s.sol:Deploy \ + --rpc-url \ + --broadcast \ + --verify +``` + +### Deployed Addresses + +The modules will be deployed to the same addresses on all chains: + +- **AutoTopUpExecutor**: `0x92Be8FA04bF1d9Ee311F4B2754Ca22252ccA18D4` +- **AutoCollectExecutor**: `0x6647fA97ff1f04614A0A960dcF499545c4DcC431` + +### Notes + +- The contracts are deployed as immutable singletons (no upgradeability, no owner) +- Addresses are deterministic using Safe Singleton Factory +- The factory is deployed on 250+ chains at: `0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7` diff --git a/solidity/account-modules/script/verify.sh b/solidity/account-modules/script/verify.sh new file mode 100755 index 0000000..d33727c --- /dev/null +++ b/solidity/account-modules/script/verify.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Script to verify deployed contracts on Basescan/Etherscan +# Usage: ./verify.sh [network] +# Example: ./verify.sh base-sepolia + +NETWORK=${1:-base-sepolia} + +# Contract addresses (same on all chains) +AUTO_TOPUP_ADDRESS="0x92Be8FA04bF1d9Ee311F4B2754Ca22252ccA18D4" +AUTO_COLLECT_ADDRESS="0x6647fA97ff1f04614A0A960dcF499545c4DcC431" + +echo "Verifying contracts on $NETWORK..." +echo "" + +# Verify AutoTopUpExecutor +echo "Verifying AutoTopUpExecutor at $AUTO_TOPUP_ADDRESS..." +forge verify-contract \ + --chain $NETWORK \ + --num-of-optimizations 200 \ + --compiler-version v0.8.30 \ + $AUTO_TOPUP_ADDRESS \ + src/AutoTopUpExecutor.sol:AutoTopUpExecutor + +echo "" + +# Verify AutoCollectExecutor +echo "Verifying AutoCollectExecutor at $AUTO_COLLECT_ADDRESS..." +forge verify-contract \ + --chain $NETWORK \ + --num-of-optimizations 200 \ + --compiler-version v0.8.30 \ + $AUTO_COLLECT_ADDRESS \ + src/AutoCollectExecutor.sol:AutoCollectExecutor + +echo "" +echo "Verification complete!" +echo "" +echo "View verified contracts:" +echo "- AutoTopUpExecutor: https://sepolia.basescan.org/address/$AUTO_TOPUP_ADDRESS#code" +echo "- AutoCollectExecutor: https://sepolia.basescan.org/address/$AUTO_COLLECT_ADDRESS#code" diff --git a/solidity/account-modules/src/AutoCollectExecutor.sol b/solidity/account-modules/src/AutoCollectExecutor.sol new file mode 100644 index 0000000..cef3065 --- /dev/null +++ b/solidity/account-modules/src/AutoCollectExecutor.sol @@ -0,0 +1,558 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {IAutoCollectExecutor} from "./IAutoCollectExecutor.sol"; +import {ERC7579ExecutorBase} from "modulekit/src/module-bases/ERC7579ExecutorBase.sol"; +import {IModule} from "modulekit/src/accounts/common/interfaces/IERC7579Module.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {BokkyPooBahsDateTimeLibrary} from "BokkyPooBahsDateTimeLibrary/contracts/BokkyPooBahsDateTimeLibrary.sol"; + +/** + * @title AutoCollectExecutor + * @notice ERC-7579 executor module for automatic ERC-20 token collection from service accounts + * @dev Enables permissionless triggering of pre-configured collections from service accounts to main accounts + */ +contract AutoCollectExecutor is IAutoCollectExecutor, ERC7579ExecutorBase, ReentrancyGuard { + using EnumerableSet for EnumerableSet.Bytes32Set; + + // ============ Storage ============ + + /// @dev ERC-7201 namespace for storage + /// @custom:storage-location erc7201:autocollect.storage.AutoCollectExecutor + // keccak256(abi.encode(uint256(keccak256("autocollect.storage.AutoCollectExecutor")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant STORAGE_NAMESPACE = 0x6ede4012bbf78b7156310dfe77e035f310125e160497f34e20b746b314ff2200; + + /// @custom:storage-namespace AutoCollectStorage + struct AutoCollectStorage { + // Config ID => user configuration + mapping(bytes32 => CollectConfig) configs; + // Config ID => internal state + mapping(bytes32 => CollectState) states; + // Account => set of config IDs + mapping(address => EnumerableSet.Bytes32Set) accountConfigs; + // Account => initialized status + mapping(address => bool) initializedAccounts; + } + + // ============ Constructor ============ + + constructor() {} + + // ============ Storage Access ============ + + function _getStorage() private pure returns (AutoCollectStorage storage $) { + assembly { + $.slot := STORAGE_NAMESPACE + } + } + + // ============ Module Management (ERC-7579) ============ + + /** + * @notice Called when module is installed for an account + * @param data Encoded initial configurations (optional) + * @dev Format: abi.encode(address[], CollectConfig[]) + * @dev Parallel arrays: assets and configs at same indices + */ + function onInstall(bytes calldata data) external override(IAutoCollectExecutor, IModule) { + AutoCollectStorage storage $ = _getStorage(); + address account = msg.sender; + + // Prevent double initialization + require(!$.initializedAccounts[account], "Already initialized"); + + // Mark account as initialized + $.initializedAccounts[account] = true; + + uint256 configCount = 0; + + // Optional: decode and set initial configurations + if (data.length > 0) { + // Decode as parallel arrays: assets and configs + (address[] memory assets, CollectConfig[] memory configs) = abi.decode(data, (address[], CollectConfig[])); + + // Validate array lengths match + require(assets.length == configs.length, InvalidConfigurationArrays()); + + configCount = assets.length; + + // Process each configuration using the public function + // This handles all validation, state initialization, and event emission + for (uint256 i = 0; i < assets.length; i++) { + configureCollection(assets[i], configs[i]); + } + } + + emit ModuleInstalled(account, configCount); + } + + /** + * @notice Called when module is uninstalled for an account + * @dev Additional data parameter is unused + */ + function onUninstall(bytes calldata) external override(IAutoCollectExecutor, IModule) { + AutoCollectStorage storage $ = _getStorage(); + + // Clean up all configs for this account + address account = msg.sender; + bytes32[] memory configIds = $.accountConfigs[account].values(); + + // Iterate in reverse to efficiently remove from set while deleting configs + for (uint256 i = configIds.length; i > 0; i--) { + bytes32 configId = configIds[i - 1]; + delete $.configs[configId]; + delete $.states[configId]; + $.accountConfigs[account].remove(configId); + } + + // Mark account as uninitialized + delete $.initializedAccounts[account]; + + emit ModuleUninstalled(account, configIds.length); + } + + /** + * @notice Returns whether this module is of a certain type + * @param _typeId The type ID to check + * @return True if this module is an executor (type 0x01) + */ + function isModuleType(uint256 _typeId) external pure override(IAutoCollectExecutor, IModule) returns (bool) { + return _typeId == TYPE_EXECUTOR; + } + + // ============ Configuration Management ============ + + /** + * @notice Configure a new collection or update an existing one + * @param asset The ERC-20 token address + * @param config The collection configuration + * @return configId The generated config ID + */ + function configureCollection(address asset, CollectConfig memory config) public returns (bytes32 configId) { + require(asset != address(0), InvalidAsset()); + require(config.target != address(0), InvalidTarget()); + + AutoCollectStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[msg.sender], ModuleNotInitialized(msg.sender)); + + configId = generateConfigId(msg.sender, asset); + + // Check if this is a new config + if ($.states[configId].asset == address(0)) { + // Initialize state for new config + $.states[configId] = CollectState({asset: asset, lastCollectDate: 0}); + + // Add to account's config set + $.accountConfigs[msg.sender].add(configId); + } + + // Update the configuration + _configureCollection(configId, config); + } + + /** + * @notice Update an existing collection configuration by ID + * @param configId The config ID to update + * @param config The new configuration + */ + function configureCollectionById(bytes32 configId, CollectConfig memory config) external { + AutoCollectStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[msg.sender], ModuleNotInitialized(msg.sender)); + + // Verify config exists and caller owns it + require($.states[configId].asset != address(0), ConfigNotFound()); + require($.accountConfigs[msg.sender].contains(configId), Unauthorized(configId, msg.sender)); + + // Update the configuration + _configureCollection(configId, config); + } + + /** + * @dev Internal function to configure a collection + * @dev IMPORTANT: This function does NOT validate that the config exists or belongs to the caller. + * @dev Caller must perform these checks before calling this function. + * @param configId The config ID to configure + * @param config The configuration to apply + */ + function _configureCollection(bytes32 configId, CollectConfig memory config) internal { + require(config.target != address(0), InvalidTarget()); + + AutoCollectStorage storage $ = _getStorage(); + CollectState memory state = $.states[configId]; + + bool wasEnabled = $.configs[configId].enabled; + $.configs[configId] = config; + + emit CollectionConfigured(msg.sender, state.asset, config.target, configId, config); + + // Emit enable/disable events if status changed + if (config.enabled && !wasEnabled) { + emit CollectionEnabled(msg.sender, state.asset, configId); + } else if (!config.enabled && wasEnabled) { + emit CollectionDisabled(msg.sender, state.asset, configId); + } + } + + /** + * @notice Enable a collection configuration by asset address + * @param asset The asset address + */ + function enableCollection(address asset) external { + bytes32 configId = generateConfigId(msg.sender, asset); + enableCollection(configId); + } + + /** + * @notice Enable a collection configuration by config ID + * @param configId The config ID to enable + */ + function enableCollection(bytes32 configId) public { + AutoCollectStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[msg.sender], ModuleNotInitialized(msg.sender)); + + CollectState memory state = $.states[configId]; + require(state.asset != address(0), ConfigNotFound()); + + // Verify caller owns this config + address account = msg.sender; + require($.accountConfigs[account].contains(configId), Unauthorized(configId, account)); + + if (!$.configs[configId].enabled) { + $.configs[configId].enabled = true; + emit CollectionEnabled(account, state.asset, configId); + } + } + + /** + * @notice Disable a collection configuration by asset address + * @param asset The asset address + */ + function disableCollection(address asset) external { + bytes32 configId = generateConfigId(msg.sender, asset); + disableCollection(configId); + } + + /** + * @notice Disable a collection configuration by config ID + * @param configId The config ID to disable + */ + function disableCollection(bytes32 configId) public { + AutoCollectStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[msg.sender], ModuleNotInitialized(msg.sender)); + + CollectState memory state = $.states[configId]; + require(state.asset != address(0), ConfigNotFound()); + + // Verify caller owns this config + address account = msg.sender; + require($.accountConfigs[account].contains(configId), Unauthorized(configId, account)); + + if ($.configs[configId].enabled) { + $.configs[configId].enabled = false; + emit CollectionDisabled(account, state.asset, configId); + } + } + + // ============ Execution ============ + + /** + * @notice Trigger all collections for a specific account + * @param account The service account to trigger collections for + * @dev Emits CollectionExecuted for each successful collection + */ + function triggerAllCollections(address account) external nonReentrant { + AutoCollectStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[account], ModuleNotInitialized(account)); + + bytes32[] memory configIds = $.accountConfigs[account].values(); + + // Calculate date once for all configs + (uint256 year, uint256 month, uint256 day) = BokkyPooBahsDateTimeLibrary.timestampToDate(block.timestamp); + + for (uint256 i = 0; i < configIds.length; i++) { + _tryExecuteCollection(configIds[i], account, year, month, day); + } + } + + /** + * @notice Trigger a specific collection by asset + * @param account The service account to collect from + * @param asset The asset to collect + * @dev Emits CollectionExecuted if successful, CollectionSkipped if conditions not met + */ + function triggerCollection(address account, address asset) external nonReentrant { + AutoCollectStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[account], ModuleNotInitialized(account)); + + bytes32 configId = generateConfigId(account, asset); + + // Verify config exists and belongs to the account + require($.states[configId].asset != address(0), ConfigNotFound()); + require($.accountConfigs[account].contains(configId), Unauthorized(configId, account)); + + // Calculate current date + (uint256 year, uint256 month, uint256 day) = BokkyPooBahsDateTimeLibrary.timestampToDate(block.timestamp); + + _tryExecuteCollection(configId, account, year, month, day); + } + + /** + * @notice Execute a collection transfer (external for try-catch) + * @param account The service account to transfer from + * @param asset The token address + * @param target The target address to transfer to + * @param amount The amount to transfer + * @dev Must be external/public for try-catch. Only callable by this contract. + */ + function _executeCollectionTransfer(address account, address asset, address target, uint256 amount) external { + require(msg.sender == address(this), "Only self"); + + bytes memory result = + _execute(account, asset, 0, abi.encodeWithSelector(IERC20.transfer.selector, target, amount)); + + // Check return value (handle non-standard tokens like USDT that don't return bool) + if (result.length > 0) { + require(result.length == 32, InvalidTransferReturn(result)); + require(abi.decode(result, (bool)), TransferReturnedFalse(result)); + } + } + + /** + * @dev Internal function to attempt a collection execution + * @param configId The config ID to execute + * @param account The service account that owns the configuration + * @param year Current year + * @param month Current month (1-12) + * @param day Current day (1-31) + * @dev Emits CollectionExecuted on success, CollectionSkipped for conditions not met + * @dev Uses graceful failure handling - does not revert on collection failures + */ + function _tryExecuteCollection(bytes32 configId, address account, uint256 year, uint256 month, uint256 day) + private + { + AutoCollectStorage storage $ = _getStorage(); + + CollectConfig memory config = $.configs[configId]; + CollectState storage state = $.states[configId]; + + // Validate and calculate collection + (bool canExecute, uint256 currentBalance, uint256 collectAmount, uint256 currentDate,) = + _validateAndCalculateCollection(config, state, account, year, month, day); + + if (!canExecute) { + // Always emit skip event for complete visibility + emit CollectionSkipped( + account, state.asset, configId, currentBalance, config.threshold, config.minimumRemaining, collectAmount + ); + return; + } + + // Try to execute transfer - wrapped in try-catch to prevent batch operation failure + // If this fails, other collections in the batch can still succeed + try this._executeCollectionTransfer(account, state.asset, config.target, collectAmount) { + // Update state AFTER successful transfer + state.lastCollectDate = currentDate; + emit CollectionExecuted(account, state.asset, config.target, configId, collectAmount); + } catch Error(string memory reason) { + emit CollectionFailed(account, state.asset, configId, reason); + } catch (bytes memory) { + emit CollectionFailed(account, state.asset, configId, "Transfer failed"); + } + } + + /** + * @dev Validates if a collection can be executed and calculates the amount + * @param config The collection configuration + * @param state The collection state (storage pointer for reading) + * @param account The service account + * @param year Current year + * @param month Current month (1-12) + * @param day Current day (1-31) + * @return canExecute Whether the collection can be executed + * @return currentBalance The current balance of the asset in the account + * @return collectAmount The amount that would be collected (balance - minimumRemaining) + * @return currentDate The encoded current date (YYYYMMDD) + * @return reason Error message if cannot execute + */ + function _validateAndCalculateCollection( + CollectConfig memory config, + CollectState memory state, + address account, + uint256 year, + uint256 month, + uint256 day + ) + private + view + returns ( + bool canExecute, + uint256 currentBalance, + uint256 collectAmount, + uint256 currentDate, + string memory reason + ) + { + // Check if enabled + if (!config.enabled) { + return (false, 0, 0, 0, "Collection not enabled"); + } + + // Check once per day limit using calendar days + currentDate = year * 10000 + month * 100 + day; + if (currentDate <= state.lastCollectDate) { + return (false, 0, 0, currentDate, "Already collected today"); + } + + // Get current balance + currentBalance = IERC20(state.asset).balanceOf(account); + + // Calculate collect amount (balance minus what we want to keep) + collectAmount = currentBalance >= config.minimumRemaining ? currentBalance - config.minimumRemaining : 0; + + // Check for zero balance (skip collection) + if (currentBalance == 0) { + return (false, currentBalance, collectAmount, currentDate, "Balance is zero"); + } + + // Check threshold requirement + if (currentBalance < config.threshold) { + return (false, currentBalance, collectAmount, currentDate, "Balance below threshold"); + } + + // Skip if nothing to collect + if (collectAmount == 0) { + return (false, currentBalance, collectAmount, currentDate, "Collect amount would be zero"); + } + + return (true, currentBalance, collectAmount, currentDate, ""); + } + + // ============ View Functions ============ + + /// @inheritdoc IAutoCollectExecutor + function isInitialized(address smartAccount) external view override(IAutoCollectExecutor, IModule) returns (bool) { + AutoCollectStorage storage $ = _getStorage(); + return $.initializedAccounts[smartAccount]; + } + + /** + * @notice Generate a config ID for given parameters + * @param account The service account + * @param asset The token address + * @return The generated config ID + */ + function generateConfigId(address account, address asset) public pure returns (bytes32) { + return keccak256(abi.encode("CollectConfig", account, asset)); + } + + /** + * @notice Get all collection configurations for an account + * @param account The account to query + * @return configs Array of configurations + * @return states Array of states + */ + function getCollectionConfigs(address account) + external + view + returns (CollectConfig[] memory configs, CollectState[] memory states) + { + AutoCollectStorage storage $ = _getStorage(); + + bytes32[] memory configIds = $.accountConfigs[account].values(); + configs = new CollectConfig[](configIds.length); + states = new CollectState[](configIds.length); + + for (uint256 i = 0; i < configIds.length; i++) { + configs[i] = $.configs[configIds[i]]; + states[i] = $.states[configIds[i]]; + } + } + + /** + * @notice Check if a collection can be executed + * @param account The account that owns the config + * @param asset The asset to check + * @return canExecute Whether the collection can be executed + * @return reason Reason if cannot execute + */ + function canExecuteCollection(address account, address asset) + external + view + returns (bool canExecute, string memory reason) + { + AutoCollectStorage storage $ = _getStorage(); + + bytes32 configId = generateConfigId(account, asset); + CollectConfig memory config = $.configs[configId]; + CollectState memory state = $.states[configId]; + + if (state.asset == address(0)) { + return (false, "Config not found"); + } + + if (!$.accountConfigs[account].contains(configId)) { + return (false, "Account doesn't own config"); + } + + if (!config.enabled) { + return (false, "Collection disabled"); + } + + // Get current date + (uint256 year, uint256 month, uint256 day) = BokkyPooBahsDateTimeLibrary.timestampToDate(block.timestamp); + + // Use shared validation logic + (bool canExec,,,, string memory errorReason) = + _validateAndCalculateCollection(config, state, account, year, month, day); + + return (canExec, errorReason); + } + + /** + * @notice Get collection config by asset + * @param account The service account + * @param asset The token address + * @return config The configuration + * @return state The state + */ + function getCollectionConfig(address account, address asset) + external + view + returns (CollectConfig memory config, CollectState memory state) + { + bytes32 configId = generateConfigId(account, asset); + AutoCollectStorage storage $ = _getStorage(); + config = $.configs[configId]; + state = $.states[configId]; + } + + /** + * @notice Get collection config by config ID + * @param configId The config ID + * @return config The configuration + * @return state The state + */ + function getCollectionConfigById(bytes32 configId) + external + view + returns (CollectConfig memory config, CollectState memory state) + { + AutoCollectStorage storage $ = _getStorage(); + config = $.configs[configId]; + state = $.states[configId]; + } +} diff --git a/solidity/account-modules/src/AutoTopUpExecutor.sol b/solidity/account-modules/src/AutoTopUpExecutor.sol new file mode 100644 index 0000000..effb355 --- /dev/null +++ b/solidity/account-modules/src/AutoTopUpExecutor.sol @@ -0,0 +1,551 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {IAutoTopUpExecutor} from "./IAutoTopUpExecutor.sol"; +import {ERC7579ExecutorBase} from "modulekit/src/module-bases/ERC7579ExecutorBase.sol"; +import {IModule} from "modulekit/src/accounts/common/interfaces/IERC7579Module.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {BokkyPooBahsDateTimeLibrary} from "BokkyPooBahsDateTimeLibrary/contracts/BokkyPooBahsDateTimeLibrary.sol"; + +/** + * @title AutoTopUpExecutor + * @notice ERC-7579 executor module for automatic ERC-20 token balance management + * @dev Enables permissionless triggering of pre-configured top-ups from Safe accounts to agent accounts + */ +contract AutoTopUpExecutor is IAutoTopUpExecutor, ERC7579ExecutorBase, ReentrancyGuard { + using EnumerableSet for EnumerableSet.Bytes32Set; + using SafeERC20 for IERC20; + + // ============ Storage ============ + + /// @dev ERC-7201 namespace for storage + /// @custom:storage-location erc7201:autotopup.storage.AutoTopUpExecutor + // keccak256(abi.encode(uint256(keccak256("autotopup.storage.AutoTopUpExecutor")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant STORAGE_NAMESPACE = 0xf1fdef444005737c1eaec423fc423d55d53d0a09b4618f0f50ba7fb8df2b0400; + + /// @custom:storage-namespace AutoTopUpStorage + struct AutoTopUpStorage { + // Config ID => user configuration + mapping(bytes32 => TopUpConfig) configs; + // Config ID => internal state + mapping(bytes32 => TopUpState) states; + // Account => set of config IDs + mapping(address => EnumerableSet.Bytes32Set) accountConfigs; + // Account => initialized status + mapping(address => bool) initializedAccounts; + } + + // ============ Constructor ============ + + constructor() {} + + // ============ Storage Access ============ + + function _getStorage() private pure returns (AutoTopUpStorage storage $) { + assembly { + $.slot := STORAGE_NAMESPACE + } + } + + // ============ Module Management (ERC-7579) ============ + + /** + * @notice Called when module is installed for an account + * @param data Encoded initial configurations (optional) + * @dev Format: abi.encode(address[], address[], TopUpConfig[]) + * @dev Parallel arrays: agents, assets, and configs at same indices + */ + function onInstall(bytes calldata data) external override(IAutoTopUpExecutor, IModule) { + AutoTopUpStorage storage $ = _getStorage(); + address account = msg.sender; + + // Prevent double initialization + require(!$.initializedAccounts[account], "Already initialized"); + + // Mark account as initialized + $.initializedAccounts[account] = true; + + uint256 configCount = 0; + + // Optional: decode and set initial configurations + if (data.length > 0) { + // Decode as parallel arrays: agents, assets, and configs + (address[] memory agents, address[] memory assets, TopUpConfig[] memory configs) = + abi.decode(data, (address[], address[], TopUpConfig[])); + + // Validate array lengths match + require(agents.length == assets.length && agents.length == configs.length, InvalidConfigurationArrays()); + + configCount = agents.length; + + // Process each configuration using the public function + // This handles all validation, state initialization, and event emission + for (uint256 i = 0; i < agents.length; i++) { + configureTopUp(agents[i], assets[i], configs[i]); + } + } + + emit ModuleInstalled(account, configCount); + } + + /** + * @notice Called when module is uninstalled for an account + * @dev Additional data parameter is unused + */ + function onUninstall(bytes calldata) external override(IAutoTopUpExecutor, IModule) { + AutoTopUpStorage storage $ = _getStorage(); + + // Clean up all configs for this account + address account = msg.sender; + bytes32[] memory configIds = $.accountConfigs[account].values(); + + // Iterate in reverse to efficiently remove from set while deleting configs + for (uint256 i = configIds.length; i > 0; i--) { + bytes32 configId = configIds[i - 1]; + delete $.configs[configId]; + delete $.states[configId]; + $.accountConfigs[account].remove(configId); + } + + // Mark account as uninitialized + delete $.initializedAccounts[account]; + + emit ModuleUninstalled(account, configIds.length); + } + + /** + * @notice Returns whether this module is of a certain type + * @param _typeId The type ID to check + * @return True if this module is an executor (type 0x01) + */ + function isModuleType(uint256 _typeId) external pure override(IAutoTopUpExecutor, IModule) returns (bool) { + return _typeId == TYPE_EXECUTOR; + } + + // ============ Configuration Management ============ + + /** + * @notice Configure a new top-up or update an existing one + * @param agent The agent account to top up + * @param asset The ERC-20 token address + * @param config The top-up configuration + * @return configId The generated config ID + */ + function configureTopUp(address agent, address asset, TopUpConfig memory config) public returns (bytes32 configId) { + require(agent != address(0) && agent != msg.sender, InvalidAgent()); + require(asset != address(0), InvalidAsset()); + + AutoTopUpStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[msg.sender], ModuleNotInitialized(msg.sender)); + + configId = generateConfigId(msg.sender, agent, asset); + + // Check if this is a new config + if ($.states[configId].agent == address(0)) { + // Initialize state for new config + $.states[configId] = + TopUpState({agent: agent, asset: asset, lastTopUpDay: 0, monthlySpent: 0, lastResetMonth: 0}); + + // Add to account's config set + $.accountConfigs[msg.sender].add(configId); + } + + // Update the configuration + _configureTopUp(configId, config); + } + + /** + * @notice Update an existing top-up configuration by ID + * @param configId The config ID to update + * @param config The new configuration + */ + function configureTopUpById(bytes32 configId, TopUpConfig memory config) external { + AutoTopUpStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[msg.sender], ModuleNotInitialized(msg.sender)); + + // Verify config exists and caller owns it + require($.states[configId].agent != address(0), ConfigNotFound()); + require($.accountConfigs[msg.sender].contains(configId), Unauthorized(configId, msg.sender)); + + // Update the configuration + _configureTopUp(configId, config); + } + + /** + * @dev Internal function to configure a top-up + * @dev IMPORTANT: This function does NOT validate that the config exists or belongs to the caller. + * @dev Caller must perform these checks before calling this function. + * @param configId The config ID to configure + * @param config The configuration to apply + */ + function _configureTopUp(bytes32 configId, TopUpConfig memory config) internal { + // Note: dailyLimit can be greater than monthlyLimit for use cases where users want + // monthly budget constraints but no daily restrictions + require(config.dailyLimit > 0 && config.monthlyLimit > 0, InvalidConfiguration()); + + AutoTopUpStorage storage $ = _getStorage(); + TopUpState memory state = $.states[configId]; + + bool wasEnabled = $.configs[configId].enabled; + $.configs[configId] = config; + + emit TopUpConfigured(msg.sender, state.agent, state.asset, configId, config); + + // Emit enable/disable events if status changed + if (config.enabled && !wasEnabled) { + emit TopUpEnabled(msg.sender, state.agent, state.asset, configId); + } else if (!config.enabled && wasEnabled) { + emit TopUpDisabled(msg.sender, state.agent, state.asset, configId); + } + } + + /** + * @notice Enable a top-up configuration + * @param configId The config ID to enable + */ + function enableTopUp(bytes32 configId) external { + AutoTopUpStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[msg.sender], ModuleNotInitialized(msg.sender)); + + TopUpState memory state = $.states[configId]; + require(state.agent != address(0), ConfigNotFound()); + + // Verify caller owns this config + address account = msg.sender; + require($.accountConfigs[account].contains(configId), Unauthorized(configId, account)); + + if (!$.configs[configId].enabled) { + $.configs[configId].enabled = true; + emit TopUpEnabled(account, state.agent, state.asset, configId); + } + } + + /** + * @notice Disable a top-up configuration + * @param configId The config ID to disable + */ + function disableTopUp(bytes32 configId) external { + AutoTopUpStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[msg.sender], ModuleNotInitialized(msg.sender)); + + TopUpState memory state = $.states[configId]; + require(state.agent != address(0), ConfigNotFound()); + + // Verify caller owns this config + address account = msg.sender; + require($.accountConfigs[account].contains(configId), Unauthorized(configId, account)); + + if ($.configs[configId].enabled) { + $.configs[configId].enabled = false; + emit TopUpDisabled(account, state.agent, state.asset, configId); + } + } + + // ============ Execution ============ + + /** + * @notice Trigger all top-ups for a specific account + * @param account The account to trigger top-ups for + * @dev Emits TopUpExecuted for each successful top-up + */ + function triggerTopUps(address account) external nonReentrant { + AutoTopUpStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[account], ModuleNotInitialized(account)); + + bytes32[] memory configIds = $.accountConfigs[account].values(); + + // Calculate date once for all configs + (uint256 year, uint256 month, uint256 day) = BokkyPooBahsDateTimeLibrary.timestampToDate(block.timestamp); + + for (uint256 i = 0; i < configIds.length; i++) { + _tryExecuteTopUp(configIds[i], account, year, month, day); + } + } + + /** + * @notice Trigger a specific top-up by config ID + * @param account The account that owns the config + * @param configId The config ID to trigger + * @dev Emits TopUpExecuted if successful, reverts on transfer failure + */ + function triggerTopUp(address account, bytes32 configId) external nonReentrant { + AutoTopUpStorage storage $ = _getStorage(); + + // Check if module is initialized for this account + require($.initializedAccounts[account], ModuleNotInitialized(account)); + + // Verify config exists and belongs to the account + require($.states[configId].agent != address(0), ConfigNotFound()); + require($.accountConfigs[account].contains(configId), Unauthorized(configId, account)); + + // Calculate current date + (uint256 year, uint256 month, uint256 day) = BokkyPooBahsDateTimeLibrary.timestampToDate(block.timestamp); + + _tryExecuteTopUp(configId, account, year, month, day); + } + + /** + * @notice Execute a top-up transfer (external for try-catch) + * @param account The main account to transfer from + * @param asset The token address + * @param agent The agent account to transfer to + * @param amount The amount to transfer + * @dev Must be external/public for try-catch. Only callable by this contract. + */ + function _executeTopUpTransfer(address account, address asset, address agent, uint256 amount) external { + require(msg.sender == address(this), "Only self"); + + bytes memory result = + _execute(account, asset, 0, abi.encodeWithSelector(IERC20.transfer.selector, agent, amount)); + + // Check return value (handle non-standard tokens like USDT that don't return bool) + if (result.length > 0) { + require(result.length == 32, InvalidTransferReturn(result)); + require(abi.decode(result, (bool)), TransferReturnedFalse(result)); + } + } + + /** + * @dev Internal function to attempt a top-up execution + * @param configId The config ID to execute + * @param account The Safe account that owns the configuration + * @param year Current year + * @param month Current month (1-12) + * @param day Current day (1-31) + * @dev Emits TopUpExecuted on success, TopUpFailed for insufficient balance + * @dev Reverts on transfer failure (indicates configuration error) + */ + /** + * @dev Validates if a top-up can be executed and calculates the amount + * @param config The top-up configuration + * @param state The top-up state (storage pointer for reading) + * @param account The Safe account + * @param year Current year + * @param month Current month (1-12) + * @param day Current day (1-31) + * @return canExecute Whether the top-up can be executed + * @return topUpAmount The amount to top up (0 if cannot execute) + * @return currentDay The encoded current day (YYYYMMDD) + * @return currentMonth The encoded current month (year * 12 + month) + * @return reason Error message if cannot execute + */ + function _validateAndCalculateTopUp( + TopUpConfig memory config, + TopUpState memory state, + address account, + uint256 year, + uint256 month, + uint256 day + ) + private + view + returns (bool canExecute, uint256 topUpAmount, uint256 currentDay, uint256 currentMonth, string memory reason) + { + // Check if enabled + if (!config.enabled) { + return (false, 0, 0, 0, "Top-up not enabled"); + } + + // Check once per day limit using calendar days + currentDay = year * 10000 + month * 100 + day; + if (currentDay <= state.lastTopUpDay) { + return (false, 0, currentDay, 0, "Already topped up today"); + } + + // Check monthly limit using calendar months + currentMonth = year * 12 + month; + uint256 monthlySpent = state.monthlySpent; + if (currentMonth > state.lastResetMonth) { + monthlySpent = 0; // Will be reset in actual execution + } + + // Check if agent needs top-up + uint256 agentBalance = IERC20(state.asset).balanceOf(state.agent); + if (agentBalance >= config.dailyLimit) { + return (false, 0, currentDay, currentMonth, "Agent balance sufficient"); + } + + // Calculate top-up amount + topUpAmount = config.dailyLimit - agentBalance; + + // Apply monthly limit + if (monthlySpent + topUpAmount > config.monthlyLimit) { + topUpAmount = config.monthlyLimit - monthlySpent; + if (topUpAmount == 0) { + return (false, 0, currentDay, currentMonth, "Monthly limit reached"); + } + } + + // Check account has sufficient balance + if (IERC20(state.asset).balanceOf(account) < topUpAmount) { + return (false, 0, currentDay, currentMonth, "Insufficient account balance"); + } + + return (true, topUpAmount, currentDay, currentMonth, ""); + } + + function _tryExecuteTopUp(bytes32 configId, address account, uint256 year, uint256 month, uint256 day) private { + AutoTopUpStorage storage $ = _getStorage(); + + TopUpConfig memory config = $.configs[configId]; + TopUpState storage state = $.states[configId]; + + // Validate and calculate top-up + (bool canExecute, uint256 topUpAmount, uint256 currentDay, uint256 currentMonth, string memory reason) = + _validateAndCalculateTopUp(config, state, account, year, month, day); + + if (!canExecute) { + // Only emit failure for insufficient balance (not for normal conditions like already topped up) + if (keccak256(bytes(reason)) == keccak256(bytes("Insufficient account balance"))) { + emit TopUpFailed(account, state.agent, state.asset, configId, reason); + } + return; + } + + // Try to execute transfer - wrapped in try-catch to prevent batch operation failure + // If this fails, other top-ups in the batch can still succeed + try this._executeTopUpTransfer(account, state.asset, state.agent, topUpAmount) { + // Update state AFTER successful transfer + + // Reset monthly counter if needed + if (currentMonth > state.lastResetMonth) { + state.monthlySpent = 0; + state.lastResetMonth = currentMonth; + } + + state.lastTopUpDay = currentDay; + state.monthlySpent += topUpAmount; + + emit TopUpExecuted(account, state.agent, state.asset, configId, topUpAmount); + } catch Error(string memory errorReason) { + emit TopUpFailed(account, state.agent, state.asset, configId, errorReason); + } catch (bytes memory) { + emit TopUpFailed(account, state.agent, state.asset, configId, "Transfer failed"); + } + } + + // ============ View Functions ============ + + /// @inheritdoc IAutoTopUpExecutor + function isInitialized(address smartAccount) external view override(IAutoTopUpExecutor, IModule) returns (bool) { + AutoTopUpStorage storage $ = _getStorage(); + return $.initializedAccounts[smartAccount]; + } + + /** + * @notice Generate a config ID for given parameters + * @param account The Safe account + * @param agent The agent account + * @param asset The token address + * @return The generated config ID + */ + function generateConfigId(address account, address agent, address asset) public pure returns (bytes32) { + return keccak256(abi.encode("TopUpConfig", account, agent, asset)); + } + + /** + * @notice Get all top-up configurations for an account + * @param account The account to query + * @return configs Array of configurations + * @return states Array of states + */ + function getTopUpConfigs(address account) + external + view + returns (TopUpConfig[] memory configs, TopUpState[] memory states) + { + AutoTopUpStorage storage $ = _getStorage(); + + bytes32[] memory configIds = $.accountConfigs[account].values(); + configs = new TopUpConfig[](configIds.length); + states = new TopUpState[](configIds.length); + + for (uint256 i = 0; i < configIds.length; i++) { + configs[i] = $.configs[configIds[i]]; + states[i] = $.states[configIds[i]]; + } + } + + /** + * @notice Check if a top-up can be executed + * @param account The account that owns the config + * @param configId The config ID to check + * @return canExecute Whether the top-up can be executed + * @return reason Reason if cannot execute + */ + function canExecuteTopUp(address account, bytes32 configId) + external + view + returns (bool canExecute, string memory reason) + { + AutoTopUpStorage storage $ = _getStorage(); + + TopUpConfig memory config = $.configs[configId]; + TopUpState memory state = $.states[configId]; + + if (state.agent == address(0)) { + return (false, "Config not found"); + } + + if (!$.accountConfigs[account].contains(configId)) { + return (false, "Account doesn't own config"); + } + + if (!config.enabled) { + return (false, "Top-up disabled"); + } + + // Get current date + (uint256 year, uint256 month, uint256 day) = BokkyPooBahsDateTimeLibrary.timestampToDate(block.timestamp); + + // Use shared validation logic + (bool canExec,,,, string memory errorReason) = + _validateAndCalculateTopUp(config, state, account, year, month, day); + + return (canExec, errorReason); + } + + /** + * @notice Get top-up by config ID + * @param configId The config ID + * @return config The configuration + * @return state The state + */ + function getTopUpById(bytes32 configId) external view returns (TopUpConfig memory config, TopUpState memory state) { + AutoTopUpStorage storage $ = _getStorage(); + config = $.configs[configId]; + state = $.states[configId]; + } + + /** + * @notice Get top-up by account, agent, and asset + * @param account The Safe account + * @param agent The agent account + * @param asset The token address + * @return config The configuration + * @return state The state + */ + function getTopUp(address account, address agent, address asset) + external + view + returns (TopUpConfig memory config, TopUpState memory state) + { + bytes32 configId = generateConfigId(account, agent, asset); + AutoTopUpStorage storage $ = _getStorage(); + config = $.configs[configId]; + state = $.states[configId]; + } +} diff --git a/solidity/account-modules/src/IAutoCollectExecutor.sol b/solidity/account-modules/src/IAutoCollectExecutor.sol new file mode 100644 index 0000000..caeb7b9 --- /dev/null +++ b/solidity/account-modules/src/IAutoCollectExecutor.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +/** + * @title IAutoCollectExecutor + * @notice Interface for the ERC-7579 executor module that manages automatic ERC-20 token collection + * @dev Enables permissionless triggering of pre-configured collections from service accounts to main accounts + */ +interface IAutoCollectExecutor { + // ============ Structs ============ + + /// @notice User-configurable parameters + struct CollectConfig { + address target; // Main account to collect funds to + uint256 threshold; // Minimum balance to trigger collection (0 = no threshold) + uint256 minimumRemaining; // Minimum amount to leave in account after collection (0 = collect all) + bool enabled; // Configuration active status + } + + /// @notice Immutable identity and internal state (not user-editable) + struct CollectState { + address asset; // ERC-20 token address (immutable, part of config ID) + uint256 lastCollectDate; // Last collection date (YYYYMMDD format) + } + + // ============ Events ============ + + event ModuleInstalled(address indexed account, uint256 initialConfigs); + + event ModuleUninstalled(address indexed account, uint256 removedConfigs); + + event CollectionConfigured( + address indexed account, address indexed asset, address target, bytes32 indexed configId, CollectConfig config + ); + + event CollectionExecuted( + address indexed account, address indexed asset, address target, bytes32 indexed configId, uint256 amount + ); + + event CollectionSkipped( + address indexed account, + address indexed asset, + bytes32 indexed configId, + uint256 balance, + uint256 threshold, + uint256 minimumRemaining, + uint256 collectAmount + ); + + event CollectionFailed(address indexed account, address indexed asset, bytes32 indexed configId, string reason); + + event CollectionEnabled(address indexed account, address indexed asset, bytes32 indexed configId); + + event CollectionDisabled(address indexed account, address indexed asset, bytes32 indexed configId); + + // ============ Errors ============ + + error ModuleNotInitialized(address account); + error InvalidConfiguration(); + error ConfigNotFound(); + error Unauthorized(bytes32 configId, address caller); + error InvalidAsset(); + error InvalidTarget(); + error AlreadyCollectedToday(); + error BalanceBelowThreshold(); + error InvalidConfigurationArrays(); + error TransferFailed(); + error InvalidTransferReturn(bytes result); + error TransferReturnedFalse(bytes result); + + // ============ Module Management (ERC-7579) ============ + + /** + * @notice Called when module is installed for an account + * @param data Encoded initial configurations (optional) + * @dev Format: abi.encode(address[], CollectConfig[]) + * @dev Parallel arrays: assets and configs at same indices + */ + function onInstall(bytes calldata data) external; + + /** + * @notice Called when module is uninstalled for an account + * @dev Additional data parameter is unused + */ + function onUninstall(bytes calldata) external; + + /** + * @notice Returns whether this module is of a certain type + * @param _typeId The type ID to check + * @return True if this module is an executor (type 0x01) + */ + function isModuleType(uint256 _typeId) external pure returns (bool); + + /** + * @notice Check if module is initialized for an account + * @param smartAccount The account to check + * @return Whether the module is initialized + */ + function isInitialized(address smartAccount) external view returns (bool); + + // ============ Configuration Management ============ + + /** + * @notice Configure a new collection or update an existing one + * @param asset The ERC-20 token address + * @param config The collection configuration + * @return configId The generated config ID + */ + function configureCollection(address asset, CollectConfig memory config) external returns (bytes32 configId); + + /** + * @notice Update an existing collection configuration by ID + * @param configId The config ID to update + * @param config The new configuration + */ + function configureCollectionById(bytes32 configId, CollectConfig memory config) external; + + /** + * @notice Enable a collection configuration by asset address + * @param asset The asset address + */ + function enableCollection(address asset) external; + + /** + * @notice Enable a collection configuration by config ID + * @param configId The config ID to enable + */ + function enableCollection(bytes32 configId) external; + + /** + * @notice Disable a collection configuration by asset address + * @param asset The asset address + */ + function disableCollection(address asset) external; + + /** + * @notice Disable a collection configuration by config ID + * @param configId The config ID to disable + */ + function disableCollection(bytes32 configId) external; + + // ============ Execution ============ + + /** + * @notice Trigger all collections for a specific account + * @param account The service account to trigger collections for + * @dev Emits CollectionExecuted for each successful collection + */ + function triggerAllCollections(address account) external; + + /** + * @notice Trigger a specific collection by asset + * @param account The service account to collect from + * @param asset The asset to collect + * @dev Emits CollectionExecuted if successful, CollectionSkipped if conditions not met + */ + function triggerCollection(address account, address asset) external; + + // ============ View Functions ============ + + /** + * @notice Generate a config ID for given parameters + * @param account The service account + * @param asset The token address + * @return The generated config ID + */ + function generateConfigId(address account, address asset) external pure returns (bytes32); + + /** + * @notice Get all collection configurations for an account + * @param account The account to query + * @return configs Array of configurations + * @return states Array of states + */ + function getCollectionConfigs(address account) + external + view + returns (CollectConfig[] memory configs, CollectState[] memory states); + + /** + * @notice Check if a collection can be executed + * @param account The account that owns the config + * @param asset The asset to check + * @return canExecute Whether the collection can be executed + * @return reason Reason if cannot execute + */ + function canExecuteCollection(address account, address asset) + external + view + returns (bool canExecute, string memory reason); + + /** + * @notice Get collection config by asset + * @param account The service account + * @param asset The token address + * @return config The configuration + * @return state The state + */ + function getCollectionConfig(address account, address asset) + external + view + returns (CollectConfig memory config, CollectState memory state); + + /** + * @notice Get collection config by config ID + * @param configId The config ID + * @return config The configuration + * @return state The state + */ + function getCollectionConfigById(bytes32 configId) + external + view + returns (CollectConfig memory config, CollectState memory state); +} diff --git a/solidity/account-modules/src/IAutoTopUpExecutor.sol b/solidity/account-modules/src/IAutoTopUpExecutor.sol new file mode 100644 index 0000000..1f27799 --- /dev/null +++ b/solidity/account-modules/src/IAutoTopUpExecutor.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +/** + * @title IAutoTopUpExecutor + * @notice Interface for the ERC-7579 executor module that manages automatic ERC-20 token balance top-ups + * @dev Enables permissionless triggering of pre-configured top-ups from Safe accounts to agent accounts + */ +interface IAutoTopUpExecutor { + // ============ Structs ============ + + /// @notice User-configurable parameters + struct TopUpConfig { + uint256 dailyLimit; // Target balance to maintain (max top-up per day) + uint256 monthlyLimit; // Maximum total top-ups per month + bool enabled; // Configuration active status + } + + /// @notice Immutable identity and internal state (not user-editable) + struct TopUpState { + address agent; // Target agent account (immutable, part of config ID) + address asset; // ERC-20 token address (immutable, part of config ID) + uint256 lastTopUpDay; // Last top-up day (year * 10000 + month * 100 + day) + uint256 monthlySpent; // Amount topped up this month + uint256 lastResetMonth; // Last reset month (year * 12 + month) + } + + // ============ Events ============ + + event ModuleInstalled(address indexed account, uint256 initialConfigs); + + event ModuleUninstalled(address indexed account, uint256 removedConfigs); + + event TopUpConfigured( + address indexed account, address indexed agent, address asset, bytes32 indexed configId, TopUpConfig config + ); + + event TopUpExecuted( + address indexed account, address indexed agent, address asset, bytes32 indexed configId, uint256 amount + ); + + event TopUpFailed( + address indexed account, address indexed agent, address asset, bytes32 indexed configId, string reason + ); + + event TopUpEnabled(address indexed account, address indexed agent, address asset, bytes32 indexed configId); + + event TopUpDisabled(address indexed account, address indexed agent, address asset, bytes32 indexed configId); + + // ============ Errors ============ + + error ModuleNotInitialized(address account); + error InvalidConfiguration(); + error ConfigNotFound(); + error Unauthorized(bytes32 configId, address caller); + error InvalidAgent(); + error InvalidAsset(); + error AlreadyToppedUpToday(); + error MonthlyLimitExceeded(); + error InsufficientBalance(); + error TopUpNotEnabled(); + error TransferFailed(); + error InvalidTransferReturn(bytes result); + error TransferReturnedFalse(bytes result); + error InvalidConfigurationArrays(); + + // ============ Module Management (ERC-7579) ============ + + /** + * @notice Called when module is installed for an account + * @param data Encoded initial configurations (optional) + * @dev Format: abi.encode(address[], address[], TopUpConfig[]) + * @dev Parallel arrays: agents, assets, and configs at same indices + */ + function onInstall(bytes calldata data) external; + + /** + * @notice Called when module is uninstalled for an account + * @dev Additional data parameter is unused + */ + function onUninstall(bytes calldata) external; + + /** + * @notice Returns whether this module is of a certain type + * @param _typeId The type ID to check + * @return True if this module is an executor (type 0x01) + */ + function isModuleType(uint256 _typeId) external pure returns (bool); + + /** + * @notice Check if module is initialized for an account + * @param smartAccount The account to check + * @return Whether the module is initialized + */ + function isInitialized(address smartAccount) external view returns (bool); + + // ============ Configuration Management ============ + + /** + * @notice Configure a new top-up or update an existing one + * @param agent The agent account to top up + * @param asset The ERC-20 token address + * @param config The top-up configuration + * @return configId The generated config ID + */ + function configureTopUp(address agent, address asset, TopUpConfig memory config) external returns (bytes32 configId); + + /** + * @notice Update an existing top-up configuration by ID + * @param configId The config ID to update + * @param config The new configuration + */ + function configureTopUpById(bytes32 configId, TopUpConfig memory config) external; + + /** + * @notice Enable a top-up configuration + * @param configId The config ID to enable + */ + function enableTopUp(bytes32 configId) external; + + /** + * @notice Disable a top-up configuration + * @param configId The config ID to disable + */ + function disableTopUp(bytes32 configId) external; + + // ============ Execution ============ + + /** + * @notice Trigger all top-ups for a specific account + * @param account The account to trigger top-ups for + * @dev Emits TopUpExecuted for each successful top-up + */ + function triggerTopUps(address account) external; + + /** + * @notice Trigger a specific top-up by config ID + * @param account The account that owns the config + * @param configId The config ID to trigger + * @dev Emits TopUpExecuted if successful, reverts on transfer failure + */ + function triggerTopUp(address account, bytes32 configId) external; + + // ============ View Functions ============ + + /** + * @notice Generate a config ID for given parameters + * @param account The Safe account + * @param agent The agent account + * @param asset The token address + * @return The generated config ID + */ + function generateConfigId(address account, address agent, address asset) external pure returns (bytes32); + + /** + * @notice Get all top-up configurations for an account + * @param account The account to query + * @return configs Array of configurations + * @return states Array of states + */ + function getTopUpConfigs(address account) + external + view + returns (TopUpConfig[] memory configs, TopUpState[] memory states); + + /** + * @notice Check if a top-up can be executed + * @param account The account that owns the config + * @param configId The config ID to check + * @return canExecute Whether the top-up can be executed + * @return reason Reason if cannot execute + */ + function canExecuteTopUp(address account, bytes32 configId) + external + view + returns (bool canExecute, string memory reason); + + /** + * @notice Get top-up by config ID + * @param configId The config ID + * @return config The configuration + * @return state The state + */ + function getTopUpById(bytes32 configId) external view returns (TopUpConfig memory config, TopUpState memory state); + + /** + * @notice Get top-up by account, agent, and asset + * @param account The Safe account + * @param agent The agent account + * @param asset The token address + * @return config The configuration + * @return state The state + */ + function getTopUp(address account, address agent, address asset) + external + view + returns (TopUpConfig memory config, TopUpState memory state); +} diff --git a/solidity/account-modules/test/AutoCollectExecutor.integration.t.sol b/solidity/account-modules/test/AutoCollectExecutor.integration.t.sol new file mode 100644 index 0000000..d03be8b --- /dev/null +++ b/solidity/account-modules/test/AutoCollectExecutor.integration.t.sol @@ -0,0 +1,675 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {Test, Vm, console2} from "forge-std/Test.sol"; +import { + RhinestoneModuleKit, + AccountType, + AccountInstance, + UserOpData +} from "modulekit/src/test/RhinestoneModuleKit.sol"; +import {ModuleKitHelpers} from "modulekit/src/test/ModuleKitHelpers.sol"; +import {AutoCollectExecutor} from "../src/AutoCollectExecutor.sol"; +import {IAutoCollectExecutor} from "../src/IAutoCollectExecutor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { + IModule, + MODULE_TYPE_VALIDATOR, + MODULE_TYPE_EXECUTOR, + MODULE_TYPE_FALLBACK, + MODULE_TYPE_HOOK +} from "modulekit/src/accounts/common/interfaces/IERC7579Module.sol"; +import {BokkyPooBahsDateTimeLibrary} from "BokkyPooBahsDateTimeLibrary/contracts/BokkyPooBahsDateTimeLibrary.sol"; + +// Import mocks +import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockUSDT} from "./mocks/MockUSDT.sol"; +import {MaliciousCollectToken} from "./mocks/MaliciousCollectToken.sol"; + +// Integration tests using RhinestoneModuleKit for proper Safe interaction +contract AutoCollectExecutorIntegrationTest is Test, RhinestoneModuleKit { + using SafeERC20 for IERC20; + using ModuleKitHelpers for AccountInstance; + + // Main contracts + AutoCollectExecutor public executor; + MockERC20 public token; + MockUSDT public usdt; + + // Test accounts + AccountInstance public serviceAccount; + address public mainAccount; + address public stranger; + + // Test constants + uint256 constant THRESHOLD = 10 ether; + uint256 constant INITIAL_SERVICE_BALANCE = 100 ether; + uint256 constant INITIAL_MAIN_BALANCE = 1000 ether; + + // Events from the module + event ModuleInstalled(address indexed account, uint256 initialConfigs); + event ModuleUninstalled(address indexed account, uint256 removedConfigs); + event CollectionConfigured( + address indexed account, + address indexed asset, + address target, + bytes32 indexed configId, + IAutoCollectExecutor.CollectConfig config + ); + event CollectionExecuted( + address indexed account, address indexed asset, address target, bytes32 indexed configId, uint256 amount + ); + event CollectionSkipped( + address indexed account, + address indexed asset, + bytes32 indexed configId, + uint256 balance, + uint256 threshold, + uint256 minimumRemaining, + uint256 collectAmount + ); + event CollectionFailed(address indexed account, address indexed asset, bytes32 indexed configId, string reason); + + function setUp() public { + // Initialize ModuleKit with Safe account type + super.init(); + + // Create test addresses + mainAccount = makeAddr("mainAccount"); + stranger = makeAddr("stranger"); + + // Deploy tokens + token = new MockERC20("Test Token", "TEST"); + usdt = new MockUSDT(); + + // Deploy AutoCollectExecutor module as singleton + executor = new AutoCollectExecutor(); + + // Create a Safe service account instance + serviceAccount = makeAccountInstance("service-account"); + + // Fund service account with tokens + token.mint(serviceAccount.account, INITIAL_SERVICE_BALANCE); + usdt.mint(serviceAccount.account, INITIAL_SERVICE_BALANCE); + + // Give main account some initial balance + token.mint(mainAccount, INITIAL_MAIN_BALANCE); + usdt.mint(mainAccount, INITIAL_MAIN_BALANCE); + } + + // ============ Module Installation with Safe ============ + + function test_Integration_InstallModule() public { + // Install the module on the service account using ModuleKit + // Note: Event emissions during UserOp execution cannot be tested with expectEmit + // due to ModuleKit's internal use of recordLogs(). Events are tested in unit tests. + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + // Verify module is properly installed and initialized + assertTrue(executor.isInitialized(serviceAccount.account)); + assertTrue(serviceAccount.isModuleInstalled(MODULE_TYPE_EXECUTOR, address(executor))); + } + + function test_Integration_InstallWithInitialConfigs() public { + // Prepare installation data with initial configurations + address[] memory assets = new address[](2); + IAutoCollectExecutor.CollectConfig[] memory configs = new IAutoCollectExecutor.CollectConfig[](2); + + assets[0] = address(token); + assets[1] = address(usdt); + configs[0] = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + configs[1] = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD * 2, minimumRemaining: 0, enabled: true + }); + + bytes memory installData = abi.encode(assets, configs); + + // Install with initial configs using ModuleKit + // Events during UserOp execution are tested in unit tests + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), installData); + + // Verify configs were created + (IAutoCollectExecutor.CollectConfig[] memory retrievedConfigs,) = + executor.getCollectionConfigs(serviceAccount.account); + assertEq(retrievedConfigs.length, 2); + assertTrue(serviceAccount.isModuleInstalled(MODULE_TYPE_EXECUTOR, address(executor))); + } + + // ============ Execution Tests with Safe ============ + + function test_Integration_TriggerCollection_Success() public { + // Install module using ModuleKit + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection(address(token), config); + + // Check balances before + uint256 serviceBalanceBefore = token.balanceOf(serviceAccount.account); + uint256 mainBalanceBefore = token.balanceOf(mainAccount); + assertEq(serviceBalanceBefore, INITIAL_SERVICE_BALANCE); + + // Expect the CollectionExecuted event + vm.expectEmit(true, true, true, true); + emit CollectionExecuted(serviceAccount.account, address(token), mainAccount, configId, serviceBalanceBefore); + + // Anyone can trigger the collection (permissionless) + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + + // Check balances after + assertEq(token.balanceOf(serviceAccount.account), 0); // All collected + assertEq(token.balanceOf(mainAccount), mainBalanceBefore + serviceBalanceBefore); + } + + function test_Integration_TriggerCollection_NonStandardToken() public { + // Test with USDT-style token that has non-standard transfer + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection(address(usdt), config); + + // Check initial state + uint256 serviceBalanceBefore = usdt.balanceOf(serviceAccount.account); + uint256 mainBalanceBefore = usdt.balanceOf(mainAccount); + + // Trigger collection with non-standard token + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(usdt)); + + // Verify transfer succeeded despite non-standard return + assertEq(usdt.balanceOf(serviceAccount.account), 0); + assertEq(usdt.balanceOf(mainAccount), mainBalanceBefore + serviceBalanceBefore); + } + + function test_Integration_TriggerAllCollections_BatchExecution() public { + // Setup multiple configs for the same account + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + // Configure collections for different tokens + vm.startPrank(serviceAccount.account); + IAutoCollectExecutor.CollectConfig memory tokenConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + IAutoCollectExecutor.CollectConfig memory usdtConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + executor.configureCollection(address(token), tokenConfig); + executor.configureCollection(address(usdt), usdtConfig); + vm.stopPrank(); + + // Record balances before + uint256 serviceTokenBefore = token.balanceOf(serviceAccount.account); + uint256 serviceUsdtBefore = usdt.balanceOf(serviceAccount.account); + uint256 mainTokenBefore = token.balanceOf(mainAccount); + uint256 mainUsdtBefore = usdt.balanceOf(mainAccount); + + // Trigger all collections for the service account + vm.prank(stranger); + executor.triggerAllCollections(serviceAccount.account); + + // Should have executed 2 collections (verify by checking balances) + assertEq(token.balanceOf(serviceAccount.account), 0); + assertEq(usdt.balanceOf(serviceAccount.account), 0); + assertEq(token.balanceOf(mainAccount), mainTokenBefore + serviceTokenBefore); + assertEq(usdt.balanceOf(mainAccount), mainUsdtBefore + serviceUsdtBefore); + } + + // ============ Daily Limit Tests ============ + + function test_Integration_DailyLimitReset() public { + // Setup config + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection(address(token), config); + + // First collection (service has 100 ether, above threshold) + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + assertEq(token.balanceOf(serviceAccount.account), 0); + + // Add some tokens back + token.mint(serviceAccount.account, 50 ether); + + // Try to collect again same day - should fail (already collected today) + (bool canExecute, string memory reason) = executor.canExecuteCollection(serviceAccount.account, address(token)); + assertFalse(canExecute); + assertEq(reason, "Already collected today"); + + // Move to next day + vm.warp(block.timestamp + 1 days); + + // Now can execute again (new day, service has 50 ether above threshold) + (canExecute, reason) = executor.canExecuteCollection(serviceAccount.account, address(token)); + assertTrue(canExecute); + assertEq(reason, ""); + + // Execute the collection + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + assertEq(token.balanceOf(serviceAccount.account), 0); + } + + function test_Integration_ThresholdEnforcement() public { + // Setup config with high threshold + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + uint256 highThreshold = INITIAL_SERVICE_BALANCE + 50 ether; + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: highThreshold, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection(address(token), config); + + uint256 currentBalance = token.balanceOf(serviceAccount.account); + + // Should not collect - balance below threshold + vm.expectEmit(true, true, true, true); + emit CollectionSkipped( + serviceAccount.account, + address(token), + configId, + currentBalance, + highThreshold, + 0, // minimumRemaining + currentBalance // would collect (balance - 0) + ); + + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + + // Balance should not change + assertEq(token.balanceOf(serviceAccount.account), INITIAL_SERVICE_BALANCE); + + // Add more tokens to exceed threshold + token.mint(serviceAccount.account, 60 ether); + + // Now should be able to collect + (bool canExecute, string memory reason) = executor.canExecuteCollection(serviceAccount.account, address(token)); + assertTrue(canExecute); + assertEq(reason, ""); + } + + // ============ Module Uninstall Tests ============ + + function test_Integration_UninstallModule_CleansState() + public + withModuleStorageClearValidation(serviceAccount, address(executor)) + { + // Install and configure using ModuleKit + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + vm.startPrank(serviceAccount.account); + IAutoCollectExecutor.CollectConfig memory tokenConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + IAutoCollectExecutor.CollectConfig memory usdtConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD * 2, minimumRemaining: 0, enabled: true + }); + executor.configureCollection(address(token), tokenConfig); + executor.configureCollection(address(usdt), usdtConfig); + vm.stopPrank(); + + // Verify configs exist + (IAutoCollectExecutor.CollectConfig[] memory configs,) = executor.getCollectionConfigs(serviceAccount.account); + assertEq(configs.length, 2); + + // Uninstall module using ModuleKit + // Events during UserOp execution are tested in unit tests + serviceAccount.uninstallModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + // Verify state is cleaned + assertFalse(executor.isInitialized(serviceAccount.account)); + assertFalse(serviceAccount.isModuleInstalled(MODULE_TYPE_EXECUTOR, address(executor))); + (configs,) = executor.getCollectionConfigs(serviceAccount.account); + assertEq(configs.length, 0); + } + + // ============ Reentrancy Protection Tests ============ + + function test_Integration_ReentrancyProtection() public { + // Deploy malicious token + MaliciousCollectToken malToken = new MaliciousCollectToken(); + + // Setup module with malicious token using ModuleKit + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + executor.configureCollection(address(malToken), config); + + // Set reentrancy target - malicious token will try to call back + malToken.setTarget(executor, serviceAccount.account, address(malToken)); + + // Fund service account with malicious tokens + malToken.mint(serviceAccount.account, INITIAL_SERVICE_BALANCE); + + // Try to trigger - reentrancy guard should prevent issues + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(malToken)); + + // Should have completed successfully despite reentrancy attempt + // Full balance should be collected + assertEq(malToken.balanceOf(serviceAccount.account), 0); + assertEq(malToken.balanceOf(mainAccount), INITIAL_SERVICE_BALANCE); + } + + // ============ Access Control Tests ============ + + function test_Integration_ModuleNotInitialized() public { + // Don't install module - test that uninitialized accounts can't configure + // Stranger cannot configure without module being installed for their account + vm.prank(stranger); + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.expectRevert(abi.encodeWithSelector(IAutoCollectExecutor.ModuleNotInitialized.selector, stranger)); + executor.configureCollection(address(token), config); + } + + function test_Integration_CrossAccountConfigurationBlocked() public { + // Install module for serviceAccount only + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + // Service account can configure for itself + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection(address(token), config); + + // Stranger cannot modify service account's configs (module not initialized for stranger) + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAutoCollectExecutor.ModuleNotInitialized.selector, stranger)); + executor.disableCollection(configId); + } + + // ============ Edge Case Tests ============ + + function test_Integration_ZeroBalanceSkipped() public { + // Setup module using ModuleKit + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, + threshold: 0, // Zero threshold + minimumRemaining: 0, + enabled: true + }); + + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection(address(token), config); + + // Drain service account balance + vm.prank(serviceAccount.account); + token.transfer(mainAccount, INITIAL_SERVICE_BALANCE); + + uint256 currentBalance = token.balanceOf(serviceAccount.account); + assertEq(currentBalance, 0); + + // Try to trigger - should skip due to zero balance + vm.expectEmit(true, true, true, true); + emit CollectionSkipped( + serviceAccount.account, + address(token), + configId, + 0, // balance + 0, // threshold + 0, // minimumRemaining + 0 // would collect + ); + + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + + // Balance should remain zero + assertEq(token.balanceOf(serviceAccount.account), 0); + } + + function test_Integration_DisabledConfigSkipped() public { + // Setup module using ModuleKit + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection(address(token), config); + + // Disable the configuration + vm.prank(serviceAccount.account); + executor.disableCollection(configId); + + uint256 serviceBalanceBefore = token.balanceOf(serviceAccount.account); + uint256 mainBalanceBefore = token.balanceOf(mainAccount); + + // Try to trigger - should not execute (disabled) + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + + // Balances should not change + assertEq(token.balanceOf(serviceAccount.account), serviceBalanceBefore); + assertEq(token.balanceOf(mainAccount), mainBalanceBefore); + } + + function test_Integration_PartialCollectionAfterThresholdChange() public { + // Setup with low threshold initially + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection(address(token), config); + + // First collection should work (100 ether > 10 ether threshold) + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + assertEq(token.balanceOf(serviceAccount.account), 0); + + // Add some tokens back + token.mint(serviceAccount.account, 5 ether); + + // Update threshold to be higher than current balance + vm.prank(serviceAccount.account); + IAutoCollectExecutor.CollectConfig memory newConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: 20 ether, minimumRemaining: 0, enabled: true + }); + executor.configureCollectionById(configId, newConfig); + + // Move to next day + vm.warp(block.timestamp + 1 days); + + // Should not collect due to threshold (5 ether < 20 ether) + (bool canExecute, string memory reason) = executor.canExecuteCollection(serviceAccount.account, address(token)); + assertFalse(canExecute); + assertEq(reason, "Balance below threshold"); + } + + // ============ Multi-Day Collection Tests ============ + + function test_Integration_CollectionAcrossDays() public { + // Setup config with zero threshold (collect any amount) + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + vm.prank(serviceAccount.account); + IAutoCollectExecutor.CollectConfig memory config = + IAutoCollectExecutor.CollectConfig({target: mainAccount, threshold: 0, minimumRemaining: 0, enabled: true}); + executor.configureCollection(address(token), config); + + // Day 1: Collect all balance + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + assertEq(token.balanceOf(serviceAccount.account), 0); + + // Add some tokens back same day + token.mint(serviceAccount.account, 30 ether); + + // Should not collect again same day + (bool canExecute, string memory reason) = executor.canExecuteCollection(serviceAccount.account, address(token)); + assertFalse(canExecute); + assertEq(reason, "Already collected today"); + + // Move to next day + vm.warp(block.timestamp + 1 days); + + // Day 2: Should be able to collect again + uint256 mainBalanceBeforeDay2 = token.balanceOf(mainAccount); + + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + + // Should have collected the 30 ether + assertEq(token.balanceOf(serviceAccount.account), 0); + assertEq(token.balanceOf(mainAccount), mainBalanceBeforeDay2 + 30 ether); + } + + // ============ Module Type Tests ============ + + function test_Integration_ModuleType() public { + // Verify module type - should only be true for EXECUTOR type + assertTrue(executor.isModuleType(MODULE_TYPE_EXECUTOR)); + assertFalse(executor.isModuleType(MODULE_TYPE_VALIDATOR)); + assertFalse(executor.isModuleType(MODULE_TYPE_FALLBACK)); + assertFalse(executor.isModuleType(MODULE_TYPE_HOOK)); + } + + // ============ Complex Scenarios ============ + + function test_Integration_MultipleServiceAccounts() public { + // Create second service account + AccountInstance memory serviceAccount2 = makeAccountInstance("service-account-2"); + + // Fund second service account + token.mint(serviceAccount2.account, INITIAL_SERVICE_BALANCE); + + // Install module on both accounts + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + serviceAccount2.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + // Configure collections on both accounts to same main account + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + executor.configureCollection(address(token), config); + + vm.prank(serviceAccount2.account); + executor.configureCollection(address(token), config); + + uint256 mainBalanceBefore = token.balanceOf(mainAccount); + + // Trigger collections from both accounts + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + + vm.prank(stranger); + executor.triggerCollection(serviceAccount2.account, address(token)); + + // Both service accounts should be drained, main should receive both balances + assertEq(token.balanceOf(serviceAccount.account), 0); + assertEq(token.balanceOf(serviceAccount2.account), 0); + assertEq(token.balanceOf(mainAccount), mainBalanceBefore + (INITIAL_SERVICE_BALANCE * 2)); + } + + function test_Integration_ConfigurationUpdatesAfterExecution() public { + // Setup and execute first collection + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection(address(token), config); + + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + + // Add tokens back + token.mint(serviceAccount.account, 40 ether); + + // Update configuration to new target + address newMainAccount = makeAddr("newMainAccount"); + vm.prank(serviceAccount.account); + IAutoCollectExecutor.CollectConfig memory newConfig = IAutoCollectExecutor.CollectConfig({ + target: newMainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + executor.configureCollectionById(configId, newConfig); + + // Move to next day and collect again + vm.warp(block.timestamp + 1 days); + + uint256 newMainBalanceBefore = token.balanceOf(newMainAccount); + + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + + // Should collect to new target + assertEq(token.balanceOf(serviceAccount.account), 0); + assertEq(token.balanceOf(newMainAccount), newMainBalanceBefore + 40 ether); + } + + // ============ Integration Tests for MinimumRemaining ============ + + function test_Integration_PartialCollectionWithMinimumRemaining() public { + // Setup: Service account with 100 tokens, collect with 30 minimumRemaining + // Expected: Collect 70, leave 30 + uint256 minimumRemaining = 30 ether; + uint256 threshold = 50 ether; + + // Install module + serviceAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + // Configure collection with minimumRemaining + vm.prank(serviceAccount.account); + bytes32 configId = executor.configureCollection( + address(token), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: threshold, minimumRemaining: minimumRemaining, enabled: true + }) + ); + + // Verify initial balances + assertEq(token.balanceOf(serviceAccount.account), INITIAL_SERVICE_BALANCE); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE); + + // Trigger collection + vm.prank(stranger); + executor.triggerCollection(serviceAccount.account, address(token)); + + // Verify partial collection - should leave minimumRemaining + uint256 expectedCollected = INITIAL_SERVICE_BALANCE - minimumRemaining; + assertEq(token.balanceOf(serviceAccount.account), minimumRemaining); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE + expectedCollected); + + // Verify config state persists + (IAutoCollectExecutor.CollectConfig memory config,) = executor.getCollectionConfigById(configId); + assertEq(config.minimumRemaining, minimumRemaining); + } +} diff --git a/solidity/account-modules/test/AutoCollectExecutor.t.sol b/solidity/account-modules/test/AutoCollectExecutor.t.sol new file mode 100644 index 0000000..6080146 --- /dev/null +++ b/solidity/account-modules/test/AutoCollectExecutor.t.sol @@ -0,0 +1,1192 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {Test, console2} from "forge-std/Test.sol"; +import {AutoCollectExecutor} from "../src/AutoCollectExecutor.sol"; +import {IAutoCollectExecutor} from "../src/IAutoCollectExecutor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {BokkyPooBahsDateTimeLibrary} from "BokkyPooBahsDateTimeLibrary/contracts/BokkyPooBahsDateTimeLibrary.sol"; +import { + MODULE_TYPE_VALIDATOR, + MODULE_TYPE_EXECUTOR, + MODULE_TYPE_FALLBACK, + MODULE_TYPE_HOOK +} from "modulekit/src/accounts/common/interfaces/IERC7579Module.sol"; + +// Import mocks +import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockUSDT} from "./mocks/MockUSDT.sol"; +import {MockSafe} from "./mocks/MockSafe.sol"; +import {MockTokenReturnsFalse} from "./mocks/MockTokenReturnsFalse.sol"; + +// Test contract with proper inheritance for module testing +contract AutoCollectExecutorTest is Test { + // Main contracts + AutoCollectExecutor public executor; + MockERC20 public token; + MockUSDT public usdt; + MockSafe public serviceAccount; + + // Test addresses + address public owner; + address public mainAccount; + address public stranger; + + // Test constants + uint256 constant THRESHOLD = 10 ether; + uint256 constant INITIAL_SERVICE_BALANCE = 100 ether; + uint256 constant INITIAL_MAIN_BALANCE = 1000 ether; + + // Events to test + event ModuleInstalled(address indexed account, uint256 initialConfigs); + event ModuleUninstalled(address indexed account, uint256 removedConfigs); + event CollectionConfigured( + address indexed account, + address indexed asset, + address target, + bytes32 indexed configId, + IAutoCollectExecutor.CollectConfig config + ); + event CollectionExecuted( + address indexed account, address indexed asset, address target, bytes32 indexed configId, uint256 amount + ); + event CollectionSkipped( + address indexed account, + address indexed asset, + bytes32 indexed configId, + uint256 balance, + uint256 threshold, + uint256 minimumRemaining, + uint256 collectAmount + ); + event CollectionFailed(address indexed account, address indexed asset, bytes32 indexed configId, string reason); + event CollectionEnabled(address indexed account, address indexed asset, bytes32 indexed configId); + event CollectionDisabled(address indexed account, address indexed asset, bytes32 indexed configId); + + function setUp() public { + // Set up test accounts + owner = makeAddr("owner"); + mainAccount = makeAddr("mainAccount"); + stranger = makeAddr("stranger"); + + // Deploy test tokens + token = new MockERC20("Test Token", "TEST"); + usdt = new MockUSDT(); + + // Deploy mock service account (Safe) + serviceAccount = new MockSafe(owner); + + // Deploy AutoCollectExecutor as singleton + executor = new AutoCollectExecutor(); + + // Enable executor as module on service account + vm.prank(owner); + serviceAccount.enableModule(address(executor)); + + // Fund service account with tokens + token.mint(address(serviceAccount), INITIAL_SERVICE_BALANCE); + usdt.mint(address(serviceAccount), INITIAL_SERVICE_BALANCE); + + // Give main account some initial balance + token.mint(mainAccount, INITIAL_MAIN_BALANCE); + usdt.mint(mainAccount, INITIAL_MAIN_BALANCE); + } + + // ============ Module Installation Tests ============ + + function test_OnInstall_EmptyData() public { + // Install module without initial configs + vm.expectEmit(true, false, false, true); + emit ModuleInstalled(address(serviceAccount), 0); + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + assertTrue(executor.isInitialized(address(serviceAccount))); + } + + function test_OnInstall_WithSingleConfig() public { + // Prepare installation data + address[] memory assets = new address[](1); + IAutoCollectExecutor.CollectConfig[] memory configs = new IAutoCollectExecutor.CollectConfig[](1); + + assets[0] = address(token); + configs[0] = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + bytes memory installData = abi.encode(assets, configs); + + // Calculate expected config ID + bytes32 configId = executor.generateConfigId(address(serviceAccount), address(token)); + + // Expect events + vm.expectEmit(true, true, true, true); + emit CollectionConfigured(address(serviceAccount), address(token), mainAccount, configId, configs[0]); + + vm.expectEmit(true, true, true, true); + emit CollectionEnabled(address(serviceAccount), address(token), configId); + + vm.expectEmit(true, false, false, true); + emit ModuleInstalled(address(serviceAccount), 1); + + vm.prank(address(serviceAccount)); + executor.onInstall(installData); + + assertTrue(executor.isInitialized(address(serviceAccount))); + + // Verify config was created + (IAutoCollectExecutor.CollectConfig memory config,) = executor.getCollectionConfigById(configId); + assertEq(config.target, mainAccount); + assertEq(config.threshold, THRESHOLD); + assertEq(config.minimumRemaining, 0); + assertTrue(config.enabled); + } + + function test_OnInstall_RevertInvalidAsset() public { + // Try to install with zero address asset + address[] memory assets = new address[](1); + IAutoCollectExecutor.CollectConfig[] memory configs = new IAutoCollectExecutor.CollectConfig[](1); + + assets[0] = address(0); // Invalid + configs[0] = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + bytes memory installData = abi.encode(assets, configs); + + vm.prank(address(serviceAccount)); + vm.expectRevert(IAutoCollectExecutor.InvalidAsset.selector); + executor.onInstall(installData); + } + + function test_OnInstall_RevertInvalidTarget() public { + // Try to install with zero address target + address[] memory assets = new address[](1); + IAutoCollectExecutor.CollectConfig[] memory configs = new IAutoCollectExecutor.CollectConfig[](1); + + assets[0] = address(token); + configs[0] = IAutoCollectExecutor.CollectConfig({ + target: address(0), threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); // Invalid target + + bytes memory installData = abi.encode(assets, configs); + + vm.prank(address(serviceAccount)); + vm.expectRevert(IAutoCollectExecutor.InvalidTarget.selector); + executor.onInstall(installData); + } + + // ============ Module Uninstallation Tests ============ + + function test_OnUninstall_CleansUpAllState() public { + // First install with configs + address[] memory assets = new address[](2); + IAutoCollectExecutor.CollectConfig[] memory configs = new IAutoCollectExecutor.CollectConfig[](2); + + assets[0] = address(token); + assets[1] = address(usdt); + configs[0] = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + configs[1] = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + executor.onInstall(abi.encode(assets, configs)); + + // Verify installation + assertTrue(executor.isInitialized(address(serviceAccount))); + (IAutoCollectExecutor.CollectConfig[] memory retrievedConfigs,) = + executor.getCollectionConfigs(address(serviceAccount)); + assertEq(retrievedConfigs.length, 2); + + // Now uninstall + vm.expectEmit(true, false, false, true); + emit ModuleUninstalled(address(serviceAccount), 2); + + vm.prank(address(serviceAccount)); + executor.onUninstall(""); + + // Verify all state is cleaned + assertFalse(executor.isInitialized(address(serviceAccount))); + (retrievedConfigs,) = executor.getCollectionConfigs(address(serviceAccount)); + assertEq(retrievedConfigs.length, 0); + } + + // ============ Configuration Management Tests ============ + + function test_ConfigureCollection_NewConfig() public { + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + bytes32 expectedConfigId = executor.generateConfigId(address(serviceAccount), address(token)); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + assertEq(configId, expectedConfigId); + + // Verify config was created + (IAutoCollectExecutor.CollectConfig memory retrievedConfig, IAutoCollectExecutor.CollectState memory state) = + executor.getCollectionConfigById(configId); + assertEq(retrievedConfig.target, mainAccount); + assertEq(retrievedConfig.threshold, THRESHOLD); + assertTrue(retrievedConfig.enabled); + assertEq(state.asset, address(token)); + assertEq(state.lastCollectDate, 0); + } + + function test_ConfigureCollection_RevertInvalidAsset() public { + // Setup: Install module + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + // Try to configure with zero address asset + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + vm.expectRevert(abi.encodeWithSelector(IAutoCollectExecutor.InvalidAsset.selector)); + executor.configureCollection(address(0), config); + } + + function test_ConfigureCollection_RevertInvalidTarget() public { + // Setup: Install module + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + // Try to configure with zero address target + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: address(0), threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + vm.expectRevert(abi.encodeWithSelector(IAutoCollectExecutor.InvalidTarget.selector)); + executor.configureCollection(address(token), config); + } + + function test_ConfigureCollection_ZeroThreshold() public { + // Setup: Install module + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + // Test with zero threshold (should be valid - collect any balance) + IAutoCollectExecutor.CollectConfig memory config = + IAutoCollectExecutor.CollectConfig({target: mainAccount, threshold: 0, minimumRemaining: 0, enabled: true}); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + // Verify config was created successfully + (IAutoCollectExecutor.CollectConfig memory retrievedConfig,) = executor.getCollectionConfigById(configId); + assertEq(retrievedConfig.target, mainAccount); + assertEq(retrievedConfig.threshold, 0); + assertTrue(retrievedConfig.enabled); + } + + function test_ConfigureCollection_RevertNotInitialized() public { + // Don't install module - try to configure + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + vm.expectRevert( + abi.encodeWithSelector(IAutoCollectExecutor.ModuleNotInitialized.selector, address(serviceAccount)) + ); + executor.configureCollection(address(token), config); + } + + // ============ Enable/Disable Tests ============ + + function test_EnableDisableCollection() public { + // Setup config (enabled by default) + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + // Disable it + vm.expectEmit(true, true, true, true); + emit CollectionDisabled(address(serviceAccount), address(token), configId); + + vm.prank(address(serviceAccount)); + executor.disableCollection(configId); + + // Verify it's disabled + (IAutoCollectExecutor.CollectConfig memory retrievedConfig,) = executor.getCollectionConfigById(configId); + assertFalse(retrievedConfig.enabled); + + // Enable it again + vm.expectEmit(true, true, true, true); + emit CollectionEnabled(address(serviceAccount), address(token), configId); + + vm.prank(address(serviceAccount)); + executor.enableCollection(configId); + + // Verify it's enabled + (retrievedConfig,) = executor.getCollectionConfigById(configId); + assertTrue(retrievedConfig.enabled); + } + + function test_EnableDisable_RevertUnauthorized() public { + // Setup config as service account + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + // Try to disable as stranger (should fail - module not initialized) + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAutoCollectExecutor.ModuleNotInitialized.selector, stranger)); + executor.disableCollection(configId); + + // Try to enable as stranger (should fail - module not initialized) + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAutoCollectExecutor.ModuleNotInitialized.selector, stranger)); + executor.enableCollection(configId); + } + + // ============ Configuration By ID Tests ============ + + function test_ConfigureCollectionById_Success() public { + // Setup: Install module and create initial config + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + // Update config by ID + address newTarget = makeAddr("newTarget"); + uint256 newThreshold = THRESHOLD * 2; + IAutoCollectExecutor.CollectConfig memory newConfig = IAutoCollectExecutor.CollectConfig({ + target: newTarget, threshold: newThreshold, minimumRemaining: 0, enabled: true + }); + + vm.expectEmit(true, true, true, true); + emit CollectionConfigured(address(serviceAccount), address(token), newTarget, configId, newConfig); + + vm.prank(address(serviceAccount)); + executor.configureCollectionById(configId, newConfig); + + // Verify config was updated + (IAutoCollectExecutor.CollectConfig memory retrievedConfig,) = executor.getCollectionConfigById(configId); + assertEq(retrievedConfig.target, newTarget); + assertEq(retrievedConfig.threshold, newThreshold); + assertTrue(retrievedConfig.enabled); + } + + function test_ConfigureCollectionById_RevertConfigNotFound() public { + // Install module but use non-existent configId + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + bytes32 nonExistentConfigId = keccak256("nonexistent"); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + vm.expectRevert(abi.encodeWithSelector(IAutoCollectExecutor.ConfigNotFound.selector)); + executor.configureCollectionById(nonExistentConfigId, config); + } + + function test_ConfigureCollectionById_RevertUnauthorized() public { + // Setup: Service account creates a config + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + // Stranger tries to update service account's config + IAutoCollectExecutor.CollectConfig memory newConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD * 2, minimumRemaining: 0, enabled: true + }); + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAutoCollectExecutor.ModuleNotInitialized.selector, stranger)); + executor.configureCollectionById(configId, newConfig); + } + + // ============ Execution Tests ============ + + function test_TriggerCollection_Success() public { + // Setup: Install module and configure collection + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + // Service account has balance above threshold + uint256 serviceBalanceBefore = token.balanceOf(address(serviceAccount)); + uint256 mainBalanceBefore = token.balanceOf(mainAccount); + assertEq(serviceBalanceBefore, INITIAL_SERVICE_BALANCE); + + // Expect the CollectionExecuted event + vm.expectEmit(true, true, true, true); + emit CollectionExecuted(address(serviceAccount), address(token), mainAccount, configId, serviceBalanceBefore); + + // Trigger the collection + executor.triggerCollection(address(serviceAccount), address(token)); + + // Verify the collection was executed (full balance transferred) + assertEq(token.balanceOf(address(serviceAccount)), 0); + assertEq(token.balanceOf(mainAccount), mainBalanceBefore + serviceBalanceBefore); + } + + function test_TriggerCollection_SkippedBelowThreshold() public { + // Setup with high threshold + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + uint256 highThreshold = INITIAL_SERVICE_BALANCE + 1 ether; + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: highThreshold, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + uint256 currentBalance = token.balanceOf(address(serviceAccount)); + + // Expect CollectionSkipped event + vm.expectEmit(true, true, true, true); + emit CollectionSkipped( + address(serviceAccount), + address(token), + configId, + currentBalance, + highThreshold, + 0, // minimumRemaining + currentBalance // would collect + ); + + // Trigger collection - should be skipped + executor.triggerCollection(address(serviceAccount), address(token)); + + // Balance should not change + assertEq(token.balanceOf(address(serviceAccount)), INITIAL_SERVICE_BALANCE); + } + + function test_TriggerAllCollections_MultipleAssets() public { + // Setup multiple configs for the same account + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + // Configure collections for both tokens + IAutoCollectExecutor.CollectConfig memory tokenConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + IAutoCollectExecutor.CollectConfig memory usdtConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.startPrank(address(serviceAccount)); + executor.configureCollection(address(token), tokenConfig); + executor.configureCollection(address(usdt), usdtConfig); + vm.stopPrank(); + + // Record balances before + uint256 serviceTokenBefore = token.balanceOf(address(serviceAccount)); + uint256 serviceUsdtBefore = usdt.balanceOf(address(serviceAccount)); + uint256 mainTokenBefore = token.balanceOf(mainAccount); + uint256 mainUsdtBefore = usdt.balanceOf(mainAccount); + + // Trigger all collections + vm.prank(stranger); + executor.triggerAllCollections(address(serviceAccount)); + + // Should have executed 2 collections + assertEq(token.balanceOf(address(serviceAccount)), 0); + assertEq(usdt.balanceOf(address(serviceAccount)), 0); + assertEq(token.balanceOf(mainAccount), mainTokenBefore + serviceTokenBefore); + assertEq(usdt.balanceOf(mainAccount), mainUsdtBefore + serviceUsdtBefore); + } + + // ============ Daily Limit Tests ============ + + function test_DailyLimitReset() public { + // Setup config + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + // First collection should work + (bool canExecute, string memory reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertTrue(canExecute); + assertEq(reason, ""); + + // Actually execute the collection + executor.triggerCollection(address(serviceAccount), address(token)); + + // Add some tokens back to service account + token.mint(address(serviceAccount), 50 ether); + + // Should not be able to collect again same day + (canExecute, reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertFalse(canExecute); + assertEq(reason, "Already collected today"); + + // Move to next day + vm.warp(block.timestamp + 1 days); + + // Now should be able to collect again + (canExecute, reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertTrue(canExecute); + assertEq(reason, ""); + } + + // ============ View Function Tests ============ + + function test_CanExecuteCollection_Conditions() public { + // Setup + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + executor.configureCollection(address(token), config); + + // Should be able to execute (service balance is above threshold) + (bool canExecute, string memory reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertTrue(canExecute); + assertEq(reason, ""); + + // Test with disabled config + bytes32 configId = executor.generateConfigId(address(serviceAccount), address(token)); + vm.prank(address(serviceAccount)); + executor.disableCollection(configId); + + (canExecute, reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertFalse(canExecute); + assertEq(reason, "Collection disabled"); + } + + function test_GetCollectionConfig_Success() public { + // Setup: Install module and create config + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + // Test getCollectionConfig function + ( + IAutoCollectExecutor.CollectConfig memory retrievedConfig, + IAutoCollectExecutor.CollectState memory retrievedState + ) = executor.getCollectionConfig(address(serviceAccount), address(token)); + + assertEq(retrievedConfig.target, mainAccount); + assertEq(retrievedConfig.threshold, THRESHOLD); + assertTrue(retrievedConfig.enabled); + assertEq(retrievedState.asset, address(token)); + assertEq(retrievedState.lastCollectDate, 0); + } + + function test_GetCollectionConfig_NonExistentConfig() public { + // Test getCollectionConfig with non-existent config + (IAutoCollectExecutor.CollectConfig memory config, IAutoCollectExecutor.CollectState memory state) = + executor.getCollectionConfig(address(serviceAccount), address(token)); + + // Should return zero values for non-existent config + assertEq(config.target, address(0)); + assertEq(config.threshold, 0); + assertEq(config.minimumRemaining, 0); + assertFalse(config.enabled); + assertEq(state.asset, address(0)); + assertEq(state.lastCollectDate, 0); + } + + function test_CanExecuteCollection_ConfigNotFound() public { + bytes32 configId = executor.generateConfigId(address(serviceAccount), address(token)); + + (bool canExecute, string memory reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + + assertFalse(canExecute); + assertEq(reason, "Config not found"); + } + + function test_GetCollectionConfigs() public { + // Install module and create multiple configs + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory tokenConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + IAutoCollectExecutor.CollectConfig memory usdtConfig = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD * 2, minimumRemaining: 0, enabled: true + }); + + vm.startPrank(address(serviceAccount)); + executor.configureCollection(address(token), tokenConfig); + executor.configureCollection(address(usdt), usdtConfig); + vm.stopPrank(); + + // Get all configs + (IAutoCollectExecutor.CollectConfig[] memory configs, IAutoCollectExecutor.CollectState[] memory states) = + executor.getCollectionConfigs(address(serviceAccount)); + + assertEq(configs.length, 2); + assertEq(states.length, 2); + + // Verify configs (order might vary due to EnumerableSet) + bool foundToken = false; + bool foundUsdt = false; + + for (uint256 i = 0; i < configs.length; i++) { + if (states[i].asset == address(token)) { + foundToken = true; + assertEq(configs[i].target, mainAccount); + assertEq(configs[i].threshold, THRESHOLD); + assertTrue(configs[i].enabled); + } else if (states[i].asset == address(usdt)) { + foundUsdt = true; + assertEq(configs[i].target, mainAccount); + assertEq(configs[i].threshold, THRESHOLD * 2); + assertTrue(configs[i].enabled); + } + } + + assertTrue(foundToken); + assertTrue(foundUsdt); + } + + // ============ Date Boundary Tests ============ + + function test_YearTransition() public { + // Setup config + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + executor.configureCollection(address(token), config); + + // Set time to Dec 31, 2023 + vm.warp(1704067199); // Dec 31, 2023 23:59:59 UTC + + // Execute collection on last day of year + executor.triggerCollection(address(serviceAccount), address(token)); + + // Add tokens back + token.mint(address(serviceAccount), 50 ether); + + // Should not execute again same day + (bool canExecute, string memory reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertFalse(canExecute); + assertEq(reason, "Already collected today"); + + // Move to Jan 1, 2024 (next year) + vm.warp(1704067200); // Jan 1, 2024 00:00:00 UTC + + // Should be able to execute (new day and new year) + (canExecute, reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertTrue(canExecute); + assertEq(reason, ""); + } + + function test_LeapYearFebruary() public { + // Setup config + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + executor.configureCollection(address(token), config); + + // Set time to Feb 28, 2024 (leap year) + vm.warp(1709078400); // Feb 28, 2024 00:00:00 UTC + + // Execute collection on Feb 28 + executor.triggerCollection(address(serviceAccount), address(token)); + + // Add tokens back + token.mint(address(serviceAccount), 50 ether); + + // Move to Feb 29 (leap day) + vm.warp(1709164800); // Feb 29, 2024 00:00:00 UTC + + // Should be able to execute on leap day + (bool canExecute, string memory reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertTrue(canExecute); + assertEq(reason, ""); + + executor.triggerCollection(address(serviceAccount), address(token)); + + // Add tokens back again + token.mint(address(serviceAccount), 50 ether); + + // Move to March 1 + vm.warp(1709251200); // March 1, 2024 00:00:00 UTC + + // Should be able to execute on March 1 + (canExecute, reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertTrue(canExecute); + assertEq(reason, ""); + } + + // ============ Fuzz Tests ============ + + function testFuzz_DailyExecutionLimit(uint32 startTimestamp, uint32 timeDelta) public { + // Test that daily execution limit is enforced across various start times and deltas + // Bound start timestamp to reasonable range (year 2020-2030) + uint256 startTime = bound(uint256(startTimestamp), 1577836800, 1893456000); // 2020-2030 + vm.warp(startTime); + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + executor.configureCollection(address(token), config); + + // Execute first collection + executor.triggerCollection(address(serviceAccount), address(token)); + + // Add tokens back + token.mint(address(serviceAccount), 50 ether); + + // Calculate seconds remaining in current calendar day + (uint256 year, uint256 month, uint256 day) = BokkyPooBahsDateTimeLibrary.timestampToDate(block.timestamp); + uint256 endOfDay = BokkyPooBahsDateTimeLibrary.timestampFromDate(year, month, day) + 86400 - 1; + uint256 secondsLeftInDay = endOfDay - block.timestamp; + + // Bound time delta to stay within current calendar day + uint256 deltaSeconds = bound(uint256(timeDelta), 0, secondsLeftInDay); + vm.warp(block.timestamp + deltaSeconds); + + // Should not be able to execute again same calendar day + (bool canExecute, string memory reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertFalse(canExecute); + assertEq(reason, "Already collected today"); + + // Move to next calendar day + vm.warp(block.timestamp + (secondsLeftInDay - deltaSeconds) + 1); + + // Now should be able to execute + (canExecute, reason) = executor.canExecuteCollection(address(serviceAccount), address(token)); + assertTrue(canExecute); + assertEq(reason, ""); + } + + function testFuzz_ConfigurationThresholds(uint256 threshold) public { + // Bound to reasonable values + threshold = bound(threshold, 0, type(uint128).max); + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + // Should succeed with any threshold + IAutoCollectExecutor.CollectConfig memory config = IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: threshold, minimumRemaining: 0, enabled: true + }); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection(address(token), config); + + // Verify config was stored correctly + (IAutoCollectExecutor.CollectConfig memory retrieved,) = executor.getCollectionConfigById(configId); + assertEq(retrieved.target, mainAccount); + assertEq(retrieved.threshold, threshold); + assertTrue(retrieved.enabled); + } + + // ============ Module Type Tests ============ + + function test_IsModuleType() public { + assertTrue(executor.isModuleType(MODULE_TYPE_EXECUTOR)); + assertFalse(executor.isModuleType(MODULE_TYPE_VALIDATOR)); + assertFalse(executor.isModuleType(MODULE_TYPE_FALLBACK)); + assertFalse(executor.isModuleType(MODULE_TYPE_HOOK)); + } + + // ============ Error Cases Tests ============ + + function test_TriggerCollection_RevertModuleNotInitialized() public { + // Don't install module - try to trigger + vm.expectRevert( + abi.encodeWithSelector(IAutoCollectExecutor.ModuleNotInitialized.selector, address(serviceAccount)) + ); + executor.triggerCollection(address(serviceAccount), address(token)); + } + + function test_TriggerCollection_RevertConfigNotFound() public { + // Install module but don't create config + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + vm.expectRevert(abi.encodeWithSelector(IAutoCollectExecutor.ConfigNotFound.selector)); + executor.triggerCollection(address(serviceAccount), address(token)); + } + + // ============ MinimumRemaining Tests ============ + + function test_CollectWithMinimumRemaining() public { + // Setup: 100 USDC balance, threshold 50, minimumRemaining 20 + // Expected: Collect 80, leave 20 + uint256 minimumRemaining = 20 ether; + uint256 threshold = 50 ether; + + // Install and configure + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection( + address(token), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: threshold, minimumRemaining: minimumRemaining, enabled: true + }) + ); + + // Verify initial balance + assertEq(token.balanceOf(address(serviceAccount)), INITIAL_SERVICE_BALANCE); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE); + + // Trigger collection + uint256 expectedCollectAmount = INITIAL_SERVICE_BALANCE - minimumRemaining; + + vm.expectEmit(true, true, true, true); + emit CollectionExecuted(address(serviceAccount), address(token), mainAccount, configId, expectedCollectAmount); + + executor.triggerCollection(address(serviceAccount), address(token)); + + // Verify balances + assertEq(token.balanceOf(address(serviceAccount)), minimumRemaining); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE + expectedCollectAmount); + } + + function test_CollectWithZeroThresholdAndMinimumRemaining() public { + // Setup: 100 USDC balance, threshold 0, minimumRemaining 30 + // Expected: Collect 70, leave 30 + uint256 minimumRemaining = 30 ether; + uint256 threshold = 0; + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection( + address(token), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: threshold, minimumRemaining: minimumRemaining, enabled: true + }) + ); + + // Trigger collection + uint256 expectedCollectAmount = INITIAL_SERVICE_BALANCE - minimumRemaining; + + vm.expectEmit(true, true, true, true); + emit CollectionExecuted(address(serviceAccount), address(token), mainAccount, configId, expectedCollectAmount); + + executor.triggerCollection(address(serviceAccount), address(token)); + + // Verify balances + assertEq(token.balanceOf(address(serviceAccount)), minimumRemaining); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE + expectedCollectAmount); + } + + function test_SkipCollectionWhenMinimumRemainingEqualsBalance() public { + // Setup: 50 USDC balance, threshold 20, minimumRemaining 50 + // Expected: Skip (collect amount = 0) + uint256 balance = 50 ether; + uint256 minimumRemaining = 50 ether; + uint256 threshold = 20 ether; + + // Set service account balance to exactly minimumRemaining + token.burn(address(serviceAccount), INITIAL_SERVICE_BALANCE - balance); + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection( + address(token), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: threshold, minimumRemaining: minimumRemaining, enabled: true + }) + ); + + // Expect CollectionSkipped event + vm.expectEmit(true, true, true, true); + emit CollectionSkipped( + address(serviceAccount), + address(token), + configId, + balance, // current balance + threshold, + minimumRemaining, + 0 // would collect amount + ); + + executor.triggerCollection(address(serviceAccount), address(token)); + + // Verify balances unchanged + assertEq(token.balanceOf(address(serviceAccount)), balance); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE); + } + + function test_SkipCollectionWhenMinimumRemainingExceedsBalance() public { + // Setup: 40 USDC balance, threshold 20, minimumRemaining 50 + // Expected: Skip (can't leave 50 when we only have 40) + uint256 balance = 40 ether; + uint256 minimumRemaining = 50 ether; + uint256 threshold = 20 ether; + + // Set service account balance + token.burn(address(serviceAccount), INITIAL_SERVICE_BALANCE - balance); + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection( + address(token), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: threshold, minimumRemaining: minimumRemaining, enabled: true + }) + ); + + // Expect CollectionSkipped event + vm.expectEmit(true, true, true, true); + emit CollectionSkipped( + address(serviceAccount), + address(token), + configId, + balance, // current balance + threshold, + minimumRemaining, + 0 // would collect amount (balance < minimumRemaining, so 0) + ); + + executor.triggerCollection(address(serviceAccount), address(token)); + + // Verify balances unchanged + assertEq(token.balanceOf(address(serviceAccount)), balance); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE); + } + + function test_MinimumRemainingGreaterThanThreshold() public { + // Setup: 100 USDC balance, threshold 10, minimumRemaining 80 + // Expected: Collect 20, leave 80 (triggers at 10 but keeps 80) + uint256 minimumRemaining = 80 ether; + uint256 threshold = 10 ether; + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection( + address(token), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: threshold, minimumRemaining: minimumRemaining, enabled: true + }) + ); + + // Trigger collection + uint256 expectedCollectAmount = INITIAL_SERVICE_BALANCE - minimumRemaining; + + vm.expectEmit(true, true, true, true); + emit CollectionExecuted(address(serviceAccount), address(token), mainAccount, configId, expectedCollectAmount); + + executor.triggerCollection(address(serviceAccount), address(token)); + + // Verify balances + assertEq(token.balanceOf(address(serviceAccount)), minimumRemaining); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE + expectedCollectAmount); + } + + function test_MinimumRemainingEqualsThreshold() public { + // Setup: 100 USDC balance, threshold 50, minimumRemaining 50 + // Expected: Collect 50, leave 50 + uint256 minimumRemaining = 50 ether; + uint256 threshold = 50 ether; + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection( + address(token), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: threshold, minimumRemaining: minimumRemaining, enabled: true + }) + ); + + // Trigger collection + uint256 expectedCollectAmount = INITIAL_SERVICE_BALANCE - minimumRemaining; + + vm.expectEmit(true, true, true, true); + emit CollectionExecuted(address(serviceAccount), address(token), mainAccount, configId, expectedCollectAmount); + + executor.triggerCollection(address(serviceAccount), address(token)); + + // Verify balances + assertEq(token.balanceOf(address(serviceAccount)), minimumRemaining); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE + expectedCollectAmount); + } + + function test_CollectionSkippedEventIncludesMinimumRemaining() public { + // Verify new event parameters are emitted correctly when skipping + uint256 threshold = 200 ether; // Higher than balance + uint256 minimumRemaining = 10 ether; + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection( + address(token), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: threshold, minimumRemaining: minimumRemaining, enabled: true + }) + ); + + uint256 currentBalance = INITIAL_SERVICE_BALANCE; + uint256 wouldCollect = currentBalance - minimumRemaining; + + // Expect CollectionSkipped with all parameters + vm.expectEmit(true, true, true, true); + emit CollectionSkipped( + address(serviceAccount), address(token), configId, currentBalance, threshold, minimumRemaining, wouldCollect + ); + + executor.triggerCollection(address(serviceAccount), address(token)); + } + + // ============ Batch Failure Tests ============ + + function test_TriggerAllCollections_PartialFailure() public { + // Test that if one collection fails, others still succeed + // This demonstrates resilience of batch operations with try-catch + + // Import the bad token mock + MockTokenReturnsFalse badToken = new MockTokenReturnsFalse(); + badToken.mint(address(serviceAccount), INITIAL_SERVICE_BALANCE); + + // Install module + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + // Configure 3 collections: good token, bad token, good USDT + vm.prank(address(serviceAccount)); + bytes32 goodConfigId1 = executor.configureCollection( + address(token), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }) + ); + + vm.prank(address(serviceAccount)); + bytes32 badConfigId = executor.configureCollection( + address(badToken), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }) + ); + + vm.prank(address(serviceAccount)); + bytes32 goodConfigId2 = executor.configureCollection( + address(usdt), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }) + ); + + // Trigger all collections - should not revert despite badToken failure + executor.triggerAllCollections(address(serviceAccount)); + + // Verify: good tokens collected, bad token remained + assertEq(token.balanceOf(address(serviceAccount)), 0); + assertEq(token.balanceOf(mainAccount), INITIAL_MAIN_BALANCE + INITIAL_SERVICE_BALANCE); + + assertEq(badToken.balanceOf(address(serviceAccount)), INITIAL_SERVICE_BALANCE); // Failed - balance unchanged + assertEq(badToken.balanceOf(mainAccount), 0); + + assertEq(usdt.balanceOf(address(serviceAccount)), 0); + assertEq(usdt.balanceOf(mainAccount), INITIAL_MAIN_BALANCE + INITIAL_SERVICE_BALANCE); + } + + function test_CollectionFailed_EmitsCorrectReason() public { + // Test that CollectionFailed event contains meaningful error message + MockTokenReturnsFalse badToken = new MockTokenReturnsFalse(); + badToken.mint(address(serviceAccount), INITIAL_SERVICE_BALANCE); + + vm.prank(address(serviceAccount)); + executor.onInstall(""); + + vm.prank(address(serviceAccount)); + bytes32 configId = executor.configureCollection( + address(badToken), + IAutoCollectExecutor.CollectConfig({ + target: mainAccount, threshold: THRESHOLD, minimumRemaining: 0, enabled: true + }) + ); + + // Should emit CollectionFailed with generic reason (custom errors fall to catch(bytes)) + vm.expectEmit(true, true, true, true); + emit CollectionFailed(address(serviceAccount), address(badToken), configId, "Transfer failed"); + + executor.triggerCollection(address(serviceAccount), address(badToken)); + + // Verify state was NOT updated (no collection happened) + (, IAutoCollectExecutor.CollectState memory state) = executor.getCollectionConfigById(configId); + assertEq(state.lastCollectDate, 0); // Should remain 0 since transfer failed + } +} diff --git a/solidity/account-modules/test/AutoTopUpExecutor.integration.t.sol b/solidity/account-modules/test/AutoTopUpExecutor.integration.t.sol new file mode 100644 index 0000000..77e78a5 --- /dev/null +++ b/solidity/account-modules/test/AutoTopUpExecutor.integration.t.sol @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {Test, Vm, console2} from "forge-std/Test.sol"; +import { + RhinestoneModuleKit, + AccountType, + AccountInstance, + UserOpData +} from "modulekit/src/test/RhinestoneModuleKit.sol"; +import {ModuleKitHelpers} from "modulekit/src/test/ModuleKitHelpers.sol"; +import {AutoTopUpExecutor} from "../src/AutoTopUpExecutor.sol"; +import {IAutoTopUpExecutor} from "../src/IAutoTopUpExecutor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { + IModule, + MODULE_TYPE_VALIDATOR, + MODULE_TYPE_EXECUTOR, + MODULE_TYPE_FALLBACK, + MODULE_TYPE_HOOK +} from "modulekit/src/accounts/common/interfaces/IERC7579Module.sol"; +import {BokkyPooBahsDateTimeLibrary} from "BokkyPooBahsDateTimeLibrary/contracts/BokkyPooBahsDateTimeLibrary.sol"; + +// Import mocks +import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockUSDT} from "./mocks/MockUSDT.sol"; +import {MaliciousToken} from "./mocks/MaliciousToken.sol"; + +// Integration tests using RhinestoneModuleKit for proper Safe interaction +contract AutoTopUpExecutorIntegrationTest is Test, RhinestoneModuleKit { + using SafeERC20 for IERC20; + using ModuleKitHelpers for AccountInstance; + + // Main contracts + AutoTopUpExecutor public executor; + MockERC20 public token; + MockUSDT public usdt; + + // Test accounts + AccountInstance public safeAccount; + address public agent1; + address public agent2; + address public stranger; + + // Test constants + uint256 constant DAILY_LIMIT = 100 ether; + uint256 constant MONTHLY_LIMIT = 1000 ether; + uint256 constant INITIAL_SAFE_BALANCE = 10000 ether; + uint256 constant INITIAL_AGENT_BALANCE = 50 ether; + + // Events from the module + event ModuleInstalled(address indexed account, uint256 initialConfigs); + event ModuleUninstalled(address indexed account, uint256 removedConfigs); + event TopUpConfigured( + address indexed account, + address indexed agent, + address asset, + bytes32 indexed configId, + IAutoTopUpExecutor.TopUpConfig config + ); + event TopUpExecuted( + address indexed account, address indexed agent, address asset, bytes32 indexed configId, uint256 amount + ); + event TopUpFailed( + address indexed account, address indexed agent, address asset, bytes32 indexed configId, string reason + ); + + function setUp() public { + // Initialize ModuleKit with Safe account type + super.init(); + + // Create test addresses + agent1 = makeAddr("agent1"); + agent2 = makeAddr("agent2"); + stranger = makeAddr("stranger"); + + // Deploy tokens + token = new MockERC20("Test Token", "TEST"); + usdt = new MockUSDT(); + + // Deploy AutoTopUpExecutor module as singleton + executor = new AutoTopUpExecutor(); + + // Create a Safe account instance + safeAccount = makeAccountInstance("safe-account"); + + // Fund Safe account with tokens + token.mint(safeAccount.account, INITIAL_SAFE_BALANCE); + usdt.mint(safeAccount.account, INITIAL_SAFE_BALANCE); + + // Give agents some initial balance (below daily limit) + token.mint(agent1, INITIAL_AGENT_BALANCE); + token.mint(agent2, INITIAL_AGENT_BALANCE); + usdt.mint(agent1, INITIAL_AGENT_BALANCE); + usdt.mint(agent2, INITIAL_AGENT_BALANCE); + } + + // ============ Module Installation with Safe ============ + + function test_Integration_InstallModule() public { + // Install the module on the Safe account using ModuleKit + // Note: Event emissions during UserOp execution cannot be tested with expectEmit + // due to ModuleKit's internal use of recordLogs(). Events are tested in unit tests. + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + // Verify module is properly installed and initialized + assertTrue(executor.isInitialized(safeAccount.account)); + assertTrue(safeAccount.isModuleInstalled(MODULE_TYPE_EXECUTOR, address(executor))); + } + + function test_Integration_InstallWithInitialConfigs() public { + // Prepare installation data with initial configurations + address[] memory agents = new address[](2); + address[] memory assets = new address[](2); + IAutoTopUpExecutor.TopUpConfig[] memory configs = new IAutoTopUpExecutor.TopUpConfig[](2); + + agents[0] = agent1; + agents[1] = agent2; + assets[0] = address(token); + assets[1] = address(token); + + configs[0] = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + configs[1] = IAutoTopUpExecutor.TopUpConfig({ + dailyLimit: DAILY_LIMIT / 2, monthlyLimit: MONTHLY_LIMIT / 2, enabled: false + }); + + bytes memory installData = abi.encode(agents, assets, configs); + + // Install with initial configs using ModuleKit + // Events during UserOp execution are tested in unit tests + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), installData); + + // Verify configs were created + (IAutoTopUpExecutor.TopUpConfig[] memory retrievedConfigs,) = executor.getTopUpConfigs(safeAccount.account); + assertEq(retrievedConfigs.length, 2); + assertTrue(safeAccount.isModuleInstalled(MODULE_TYPE_EXECUTOR, address(executor))); + } + + // ============ Execution Tests with Safe ============ + + function test_Integration_TriggerTopUp_Success() public { + // Install module using ModuleKit + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(safeAccount.account); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Check agent balance before + uint256 agentBalanceBefore = token.balanceOf(agent1); + assertEq(agentBalanceBefore, INITIAL_AGENT_BALANCE); + + // Calculate expected top-up amount + uint256 expectedTopUp = DAILY_LIMIT - agentBalanceBefore; + + // Expect the TopUpExecuted event + vm.expectEmit(true, true, true, true); + emit TopUpExecuted(safeAccount.account, agent1, address(token), configId, expectedTopUp); + + // Anyone can trigger the top-up (permissionless) + vm.prank(stranger); + executor.triggerTopUp(safeAccount.account, configId); + + // Check agent balance after + uint256 agentBalanceAfter = token.balanceOf(agent1); + assertEq(agentBalanceAfter, DAILY_LIMIT); + + // Check Safe balance decreased + assertEq(token.balanceOf(safeAccount.account), INITIAL_SAFE_BALANCE - expectedTopUp); + } + + function test_Integration_TriggerTopUp_NonStandardToken() public { + // Test with USDT-style token that has non-standard transfer + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(safeAccount.account); + bytes32 configId = executor.configureTopUp(agent1, address(usdt), config); + + // Check initial state + uint256 agentBalanceBefore = usdt.balanceOf(agent1); + uint256 expectedTopUp = DAILY_LIMIT - agentBalanceBefore; + + // Trigger top-up with non-standard token + vm.prank(stranger); + executor.triggerTopUp(safeAccount.account, configId); + + // Verify transfer succeeded despite non-standard return + assertEq(usdt.balanceOf(agent1), DAILY_LIMIT); + assertEq(usdt.balanceOf(safeAccount.account), INITIAL_SAFE_BALANCE - expectedTopUp); + } + + function test_Integration_TriggerTopUps_BatchExecution() public { + // Setup multiple configs for the same account + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + // Configure multiple agents with different tokens + vm.startPrank(safeAccount.account); + executor.configureTopUp(agent1, address(token), config); + executor.configureTopUp(agent2, address(token), config); + executor.configureTopUp(agent1, address(usdt), config); + vm.stopPrank(); + + // Record balances before + uint256 agent1TokenBefore = token.balanceOf(agent1); + uint256 agent2TokenBefore = token.balanceOf(agent2); + uint256 agent1UsdtBefore = usdt.balanceOf(agent1); + + // Trigger all top-ups for the Safe account + vm.prank(stranger); + executor.triggerTopUps(safeAccount.account); + + // Should have executed 3 top-ups (verify by checking balances) + + // Verify all balances were topped up + assertEq(token.balanceOf(agent1), DAILY_LIMIT); + assertEq(token.balanceOf(agent2), DAILY_LIMIT); + assertEq(usdt.balanceOf(agent1), DAILY_LIMIT); + } + + // ============ Daily/Monthly Limit Tests ============ + + function test_Integration_DailyLimitReset() public { + // Setup config with low daily limit + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: 60 ether, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(safeAccount.account); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // First top-up (agent1 has 50 ether, needs 10 ether to reach 60) + vm.prank(stranger); + executor.triggerTopUp(safeAccount.account, configId); + assertEq(token.balanceOf(agent1), 60 ether); + + // Spend some tokens + vm.prank(agent1); + token.transfer(agent2, 20 ether); + assertEq(token.balanceOf(agent1), 40 ether); + + // Try to top-up again same day - should fail (already topped up today) + (bool canExecute, string memory reason) = executor.canExecuteTopUp(safeAccount.account, configId); + assertFalse(canExecute); + assertEq(reason, "Already topped up today"); + + // Move to next day + vm.warp(block.timestamp + 1 days); + + // Now can execute again (new day, agent balance is 40, needs 20 to reach 60) + (canExecute, reason) = executor.canExecuteTopUp(safeAccount.account, configId); + assertTrue(canExecute); + assertEq(reason, ""); + + // Execute the top-up + vm.prank(stranger); + executor.triggerTopUp(safeAccount.account, configId); + assertEq(token.balanceOf(agent1), 60 ether); + } + + function test_Integration_MonthlyLimitEnforcement() public { + // Setup config with low monthly limit + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoTopUpExecutor.TopUpConfig memory config = IAutoTopUpExecutor.TopUpConfig({ + dailyLimit: 100 ether, + monthlyLimit: 120 ether, // Just above one top-up (50 ether needed initially) + enabled: true + }); + + vm.prank(safeAccount.account); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // First top-up should work (agent has 50 ether, needs 50 ether to reach 100) + vm.prank(stranger); + executor.triggerTopUp(safeAccount.account, configId); + assertEq(token.balanceOf(agent1), 100 ether); + + // Spend most tokens + vm.prank(agent1); + token.transfer(agent2, 90 ether); + assertEq(token.balanceOf(agent1), 10 ether); + + // Move to next day to allow another top-up + vm.warp(block.timestamp + 1 days); + + // Second top-up should be partial (would need 90 ether, but only 70 left in monthly limit) + // Monthly spent: 50 ether, limit: 120 ether, remaining: 70 ether + vm.prank(stranger); + executor.triggerTopUp(safeAccount.account, configId); + + // Balance should be: 10 (current) + 70 (remaining monthly limit) = 80 ether + assertEq(token.balanceOf(agent1), 80 ether); + + // Move to next day and try again - should fail (monthly limit reached) + vm.warp(block.timestamp + 1 days); + vm.prank(agent1); + token.transfer(agent2, 10 ether); // Spend some to be below daily limit + + (bool canExecute, string memory reason) = executor.canExecuteTopUp(safeAccount.account, configId); + assertFalse(canExecute); + assertEq(reason, "Monthly limit reached"); + } + + // ============ Module Uninstall Tests ============ + + function test_Integration_UninstallModule_CleansState() + public + withModuleStorageClearValidation(safeAccount, address(executor)) + { + // Install and configure using ModuleKit + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.startPrank(safeAccount.account); + executor.configureTopUp(agent1, address(token), config); + executor.configureTopUp(agent2, address(usdt), config); + vm.stopPrank(); + + // Verify configs exist + (IAutoTopUpExecutor.TopUpConfig[] memory configs,) = executor.getTopUpConfigs(safeAccount.account); + assertEq(configs.length, 2); + + // Uninstall module using ModuleKit + // Events during UserOp execution are tested in unit tests + safeAccount.uninstallModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + // Verify state is cleaned + assertFalse(executor.isInitialized(safeAccount.account)); + assertFalse(safeAccount.isModuleInstalled(MODULE_TYPE_EXECUTOR, address(executor))); + (configs,) = executor.getTopUpConfigs(safeAccount.account); + assertEq(configs.length, 0); + } + + // ============ Reentrancy Protection Tests ============ + + function test_Integration_ReentrancyProtection() public { + // Deploy malicious token + MaliciousToken malToken = new MaliciousToken(); + + // Setup module with malicious token using ModuleKit + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(safeAccount.account); + bytes32 configId = executor.configureTopUp(agent1, address(malToken), config); + + // Set reentrancy target + malToken.setTarget(executor, configId); + + // Fund Safe with malicious tokens + malToken.mint(safeAccount.account, INITIAL_SAFE_BALANCE); + + // Try to trigger - reentrancy guard should prevent issues + vm.prank(stranger); + executor.triggerTopUp(safeAccount.account, configId); + + // Should have completed successfully despite reentrancy attempt + assertEq(malToken.balanceOf(agent1), DAILY_LIMIT); + } + + // ============ Access Control Tests ============ + + function test_Integration_ModuleNotInitialized() public { + // Don't install module - test that uninitialised accounts can't configure + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + // Stranger cannot configure without module being installed for their account + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.ModuleNotInitialized.selector, stranger)); + executor.configureTopUp(agent1, address(token), config); + } + + function test_Integration_CrossAccountConfigurationBlocked() public { + // Install module for safeAccount only + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + // Safe account can configure for itself + vm.prank(safeAccount.account); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Stranger cannot modify safe account's configs (module not initialized for stranger) + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.ModuleNotInitialized.selector, stranger)); + executor.disableTopUp(configId); + } + + // ============ Edge Case Tests ============ + + function test_Integration_InsufficientSafeBalance() public { + // Setup module using ModuleKit + safeAccount.installModule(MODULE_TYPE_EXECUTOR, address(executor), ""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(safeAccount.account); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Drain Safe balance + vm.prank(safeAccount.account); + token.transfer(agent2, INITIAL_SAFE_BALANCE); + + // Try to trigger - should fail due to insufficient balance + vm.expectEmit(true, true, true, true); + emit TopUpFailed(safeAccount.account, agent1, address(token), configId, "Insufficient account balance"); + + vm.prank(stranger); + executor.triggerTopUp(safeAccount.account, configId); + + // Agent balance should not change + assertEq(token.balanceOf(agent1), INITIAL_AGENT_BALANCE); + } + + function test_Integration_ModuleType() public { + // Verify module type - should only be true for EXECUTOR type + assertTrue(executor.isModuleType(MODULE_TYPE_EXECUTOR)); + assertFalse(executor.isModuleType(MODULE_TYPE_VALIDATOR)); + assertFalse(executor.isModuleType(MODULE_TYPE_FALLBACK)); + assertFalse(executor.isModuleType(MODULE_TYPE_HOOK)); + } +} diff --git a/solidity/account-modules/test/AutoTopUpExecutor.t.sol b/solidity/account-modules/test/AutoTopUpExecutor.t.sol new file mode 100644 index 0000000..4a865b9 --- /dev/null +++ b/solidity/account-modules/test/AutoTopUpExecutor.t.sol @@ -0,0 +1,941 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {Test, console2} from "forge-std/Test.sol"; +import {AutoTopUpExecutor} from "../src/AutoTopUpExecutor.sol"; +import {IAutoTopUpExecutor} from "../src/IAutoTopUpExecutor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {BokkyPooBahsDateTimeLibrary} from "BokkyPooBahsDateTimeLibrary/contracts/BokkyPooBahsDateTimeLibrary.sol"; + +// Import mocks +import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockUSDT} from "./mocks/MockUSDT.sol"; +import {MockSafe} from "./mocks/MockSafe.sol"; +import {MockTokenBadReturn} from "./mocks/MockTokenBadReturn.sol"; +import {MockTokenReturnsFalse} from "./mocks/MockTokenReturnsFalse.sol"; + +// Test contract with proper inheritance for module testing +contract AutoTopUpExecutorTest is Test { + // Main contracts + AutoTopUpExecutor public executor; + MockERC20 public token; + MockUSDT public usdt; + MockSafe public safe; + + // Test addresses + address public owner; + address public agent1; + address public agent2; + address public stranger; + + // Test constants + uint256 constant DAILY_LIMIT = 100 ether; + uint256 constant MONTHLY_LIMIT = 1000 ether; + uint256 constant INITIAL_SAFE_BALANCE = 10000 ether; + uint256 constant INITIAL_AGENT_BALANCE = 50 ether; // Below daily limit + + // Events to test + event ModuleInstalled(address indexed account, uint256 initialConfigs); + event ModuleUninstalled(address indexed account, uint256 removedConfigs); + event TopUpConfigured( + address indexed account, + address indexed agent, + address asset, + bytes32 indexed configId, + IAutoTopUpExecutor.TopUpConfig config + ); + event TopUpExecuted( + address indexed account, address indexed agent, address asset, bytes32 indexed configId, uint256 amount + ); + event TopUpFailed( + address indexed account, address indexed agent, address asset, bytes32 indexed configId, string reason + ); + event TopUpEnabled(address indexed account, address indexed agent, address asset, bytes32 indexed configId); + event TopUpDisabled(address indexed account, address indexed agent, address asset, bytes32 indexed configId); + + function setUp() public { + // Set up test accounts + owner = makeAddr("owner"); + stranger = makeAddr("stranger"); + agent1 = makeAddr("agent1"); + agent2 = makeAddr("agent2"); + + // Deploy test tokens + token = new MockERC20("Test Token", "TEST"); + usdt = new MockUSDT(); + + // Deploy mock Safe + safe = new MockSafe(owner); + + // Deploy AutoTopUpExecutor as singleton + executor = new AutoTopUpExecutor(); + + // Enable executor as module on Safe + vm.prank(owner); + safe.enableModule(address(executor)); + + // Fund Safe with tokens + token.mint(address(safe), INITIAL_SAFE_BALANCE); + usdt.mint(address(safe), INITIAL_SAFE_BALANCE); + + // Give agents some initial balance (below daily limit) + token.mint(agent1, INITIAL_AGENT_BALANCE); + token.mint(agent2, INITIAL_AGENT_BALANCE); + usdt.mint(agent1, INITIAL_AGENT_BALANCE); + usdt.mint(agent2, INITIAL_AGENT_BALANCE); + } + + // ============ Module Installation Tests ============ + + function test_OnInstall_EmptyData() public { + // Install module without initial configs + vm.expectEmit(true, false, false, true); + emit ModuleInstalled(address(safe), 0); + + vm.prank(address(safe)); + executor.onInstall(""); + + assertTrue(executor.isInitialized(address(safe))); + } + + function test_OnInstall_WithSingleConfig() public { + // Prepare installation data + address[] memory agents = new address[](1); + address[] memory assets = new address[](1); + IAutoTopUpExecutor.TopUpConfig[] memory configs = new IAutoTopUpExecutor.TopUpConfig[](1); + + agents[0] = agent1; + assets[0] = address(token); + configs[0] = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + bytes memory installData = abi.encode(agents, assets, configs); + + // Calculate expected config ID + bytes32 configId = executor.generateConfigId(address(safe), agent1, address(token)); + + // Expect events + vm.expectEmit(true, true, true, true); + emit TopUpConfigured(address(safe), agent1, address(token), configId, configs[0]); + + vm.expectEmit(true, true, true, true); + emit TopUpEnabled(address(safe), agent1, address(token), configId); + + vm.expectEmit(true, false, false, true); + emit ModuleInstalled(address(safe), 1); + + vm.prank(address(safe)); + executor.onInstall(installData); + + assertTrue(executor.isInitialized(address(safe))); + + // Verify config was created + (IAutoTopUpExecutor.TopUpConfig memory config,) = executor.getTopUpById(configId); + assertEq(config.dailyLimit, DAILY_LIMIT); + assertEq(config.monthlyLimit, MONTHLY_LIMIT); + assertTrue(config.enabled); + } + + function test_OnInstall_RevertInvalidAgent() public { + // Try to install with zero address agent + address[] memory agents = new address[](1); + address[] memory assets = new address[](1); + IAutoTopUpExecutor.TopUpConfig[] memory configs = new IAutoTopUpExecutor.TopUpConfig[](1); + + agents[0] = address(0); // Invalid + assets[0] = address(token); + configs[0] = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + bytes memory installData = abi.encode(agents, assets, configs); + + vm.prank(address(safe)); + vm.expectRevert(IAutoTopUpExecutor.InvalidAgent.selector); + executor.onInstall(installData); + } + + // ============ Module Uninstallation Tests ============ + + function test_OnUninstall_CleansUpAllState() public { + // First install with configs + address[] memory agents = new address[](2); + address[] memory assets = new address[](2); + IAutoTopUpExecutor.TopUpConfig[] memory configs = new IAutoTopUpExecutor.TopUpConfig[](2); + + agents[0] = agent1; + agents[1] = agent2; + assets[0] = address(token); + assets[1] = address(token); + + for (uint256 i = 0; i < 2; i++) { + configs[i] = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + } + + vm.prank(address(safe)); + executor.onInstall(abi.encode(agents, assets, configs)); + + // Verify installation + assertTrue(executor.isInitialized(address(safe))); + (IAutoTopUpExecutor.TopUpConfig[] memory retrievedConfigs,) = executor.getTopUpConfigs(address(safe)); + assertEq(retrievedConfigs.length, 2); + + // Now uninstall + vm.expectEmit(true, false, false, true); + emit ModuleUninstalled(address(safe), 2); + + vm.prank(address(safe)); + executor.onUninstall(""); + + // Verify all state is cleaned + assertFalse(executor.isInitialized(address(safe))); + (retrievedConfigs,) = executor.getTopUpConfigs(address(safe)); + assertEq(retrievedConfigs.length, 0); + } + + // ============ Configuration Management Tests ============ + + function test_ConfigureTopUp_NewConfig() public { + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + bytes32 expectedConfigId = executor.generateConfigId(address(safe), agent1, address(token)); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + assertEq(configId, expectedConfigId); + + // Verify config was created + (IAutoTopUpExecutor.TopUpConfig memory retrievedConfig, IAutoTopUpExecutor.TopUpState memory state) = + executor.getTopUpById(configId); + assertEq(retrievedConfig.dailyLimit, DAILY_LIMIT); + assertEq(retrievedConfig.monthlyLimit, MONTHLY_LIMIT); + assertTrue(retrievedConfig.enabled); + assertEq(state.agent, agent1); + assertEq(state.asset, address(token)); + } + + function test_ConfigureTopUp_RevertInvalidAgent_ZeroAddress() public { + // Setup: Install module + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + // Try to configure with zero address agent + vm.prank(address(safe)); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.InvalidAgent.selector)); + executor.configureTopUp(address(0), address(token), config); + } + + function test_ConfigureTopUp_RevertInvalidAgent_SameAsAccount() public { + // Setup: Install module + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + // Try to configure with agent same as account (msg.sender) + vm.prank(address(safe)); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.InvalidAgent.selector)); + executor.configureTopUp(address(safe), address(token), config); + } + + function test_ConfigureTopUp_RevertInvalidAsset() public { + // Setup: Install module + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + // Try to configure with zero address asset + vm.prank(address(safe)); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.InvalidAsset.selector)); + executor.configureTopUp(agent1, address(0), config); + } + + function test_ConfigureTopUp_MaxValues() public { + // Setup: Install module + vm.prank(address(safe)); + executor.onInstall(""); + + // Test with maximum uint256 values + IAutoTopUpExecutor.TopUpConfig memory config = IAutoTopUpExecutor.TopUpConfig({ + dailyLimit: type(uint256).max, monthlyLimit: type(uint256).max, enabled: true + }); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Verify config was created successfully + (IAutoTopUpExecutor.TopUpConfig memory retrievedConfig,) = executor.getTopUpById(configId); + assertEq(retrievedConfig.dailyLimit, type(uint256).max); + assertEq(retrievedConfig.monthlyLimit, type(uint256).max); + assertTrue(retrievedConfig.enabled); + } + + function test_ConfigureTopUp_RevertInvalidConfiguration() public { + vm.prank(address(safe)); + executor.onInstall(""); + + // Zero daily limit + IAutoTopUpExecutor.TopUpConfig memory config1 = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: 0, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + vm.expectRevert(IAutoTopUpExecutor.InvalidConfiguration.selector); + executor.configureTopUp(agent1, address(token), config1); + + // Zero monthly limit + IAutoTopUpExecutor.TopUpConfig memory config2 = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: 0, enabled: true}); + + vm.prank(address(safe)); + vm.expectRevert(IAutoTopUpExecutor.InvalidConfiguration.selector); + executor.configureTopUp(agent1, address(token), config2); + } + + // ============ Enable/Disable Tests ============ + + function test_EnableDisableTopUp() public { + // Setup disabled config + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: false}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Enable it + vm.expectEmit(true, true, true, true); + emit TopUpEnabled(address(safe), agent1, address(token), configId); + + vm.prank(address(safe)); + executor.enableTopUp(configId); + + // Verify it's enabled + (IAutoTopUpExecutor.TopUpConfig memory retrievedConfig,) = executor.getTopUpById(configId); + assertTrue(retrievedConfig.enabled); + + // Disable it + vm.expectEmit(true, true, true, true); + emit TopUpDisabled(address(safe), agent1, address(token), configId); + + vm.prank(address(safe)); + executor.disableTopUp(configId); + + // Verify it's disabled + (retrievedConfig,) = executor.getTopUpById(configId); + assertFalse(retrievedConfig.enabled); + } + + function test_EnableDisable_RevertUnauthorized() public { + // Setup config as safe account + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: false}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Try to enable as stranger (should fail - module not initialized) + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.ModuleNotInitialized.selector, stranger)); + executor.enableTopUp(configId); + + // Try to disable as stranger (should fail - module not initialized) + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.ModuleNotInitialized.selector, stranger)); + executor.disableTopUp(configId); + + // Now test with an initialized but unauthorized account + address otherAccount = makeAddr("otherAccount"); + vm.prank(otherAccount); + executor.onInstall(""); + + // Try to enable config owned by safe account (should fail - unauthorized) + vm.prank(otherAccount); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.Unauthorized.selector, configId, otherAccount)); + executor.enableTopUp(configId); + } + + // ============ View Function Tests ============ + + function test_ConfigureTopUpById_Success() public { + // Setup: Install module and create initial config + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory initialConfig = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: false}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), initialConfig); + + // Update config by ID + IAutoTopUpExecutor.TopUpConfig memory newConfig = IAutoTopUpExecutor.TopUpConfig({ + dailyLimit: DAILY_LIMIT * 2, monthlyLimit: MONTHLY_LIMIT * 3, enabled: true + }); + + vm.expectEmit(true, true, true, true); + emit TopUpConfigured(address(safe), agent1, address(token), configId, newConfig); + vm.expectEmit(true, true, true, true); + emit TopUpEnabled(address(safe), agent1, address(token), configId); + + vm.prank(address(safe)); + executor.configureTopUpById(configId, newConfig); + + // Verify config was updated + (IAutoTopUpExecutor.TopUpConfig memory retrievedConfig,) = executor.getTopUpById(configId); + assertEq(retrievedConfig.dailyLimit, DAILY_LIMIT * 2); + assertEq(retrievedConfig.monthlyLimit, MONTHLY_LIMIT * 3); + assertTrue(retrievedConfig.enabled); + } + + function test_ConfigureTopUpById_RevertModuleNotInitialized() public { + // Don't install module for safe + bytes32 configId = keccak256("dummy"); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.ModuleNotInitialized.selector, address(safe))); + executor.configureTopUpById(configId, config); + } + + function test_ConfigureTopUpById_RevertConfigNotFound() public { + // Install module but use non-existent configId + vm.prank(address(safe)); + executor.onInstall(""); + + bytes32 nonExistentConfigId = keccak256("nonexistent"); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.ConfigNotFound.selector)); + executor.configureTopUpById(nonExistentConfigId, config); + } + + function test_ConfigureTopUpById_RevertUnauthorized() public { + // Setup: Safe creates a config + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Stranger tries to update safe's config + IAutoTopUpExecutor.TopUpConfig memory newConfig = IAutoTopUpExecutor.TopUpConfig({ + dailyLimit: DAILY_LIMIT * 2, monthlyLimit: MONTHLY_LIMIT * 2, enabled: false + }); + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.ModuleNotInitialized.selector, stranger)); + executor.configureTopUpById(configId, newConfig); + } + + function test_ConfigureTopUpById_RevertInvalidConfiguration() public { + // Setup: Install module and create initial config + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory initialConfig = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), initialConfig); + + // Try to update with invalid config (zero daily limit) + IAutoTopUpExecutor.TopUpConfig memory invalidConfig = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: 0, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + vm.expectRevert(abi.encodeWithSelector(IAutoTopUpExecutor.InvalidConfiguration.selector)); + executor.configureTopUpById(configId, invalidConfig); + } + + function test_TriggerTopUp_EmitsEvent() public { + // Setup: Install module and configure top-up + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Agent has low balance, needs top-up + uint256 agentBalanceBefore = token.balanceOf(agent1); + uint256 expectedTopUp = DAILY_LIMIT - agentBalanceBefore; + + // Expect the TopUpExecuted event + vm.expectEmit(true, true, true, true); + emit TopUpExecuted(address(safe), agent1, address(token), configId, expectedTopUp); + + // Trigger the top-up + executor.triggerTopUp(address(safe), configId); + + // Verify the top-up was executed + assertEq(token.balanceOf(agent1), DAILY_LIMIT); + } + + function test_CanExecuteTopUp_Conditions() public { + // Setup + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Should be able to execute (agent balance is below daily limit) + (bool canExecute, string memory reason) = executor.canExecuteTopUp(address(safe), configId); + assertTrue(canExecute); + assertEq(reason, ""); + + // Fund agent to be above daily limit + token.mint(agent1, DAILY_LIMIT); + + // Should not be able to execute now + (canExecute, reason) = executor.canExecuteTopUp(address(safe), configId); + assertFalse(canExecute); + assertEq(reason, "Agent balance sufficient"); + + // Test with disabled config + vm.prank(address(safe)); + executor.disableTopUp(configId); + + (canExecute, reason) = executor.canExecuteTopUp(address(safe), configId); + assertFalse(canExecute); + assertEq(reason, "Top-up disabled"); + } + + function test_TransferValidation_InvalidReturn() public { + // Setup: Install module and configure top-up + vm.prank(address(safe)); + executor.onInstall(""); + + // Create a token that returns malformed data + MockTokenBadReturn badToken = new MockTokenBadReturn(); + badToken.mint(address(safe), INITIAL_SAFE_BALANCE); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(badToken), config); + + // Should emit TopUpFailed when transfer returns malformed data (now caught by try-catch) + vm.prank(stranger); + vm.expectEmit(true, true, true, true); + emit TopUpFailed(address(safe), agent1, address(badToken), configId, "Transfer failed"); + executor.triggerTopUp(address(safe), configId); + + // Verify state was NOT updated (no top-up happened) + (, IAutoTopUpExecutor.TopUpState memory state) = executor.getTopUpById(configId); + assertEq(state.lastTopUpDay, 0); // Should remain 0 since transfer failed + assertEq(state.monthlySpent, 0); // Should remain 0 since transfer failed + } + + function test_TransferValidation_ReturnsFalse() public { + // Setup: Install module and configure top-up + vm.prank(address(safe)); + executor.onInstall(""); + + // Create a token that returns false on transfer + MockTokenReturnsFalse falseToken = new MockTokenReturnsFalse(); + falseToken.mint(address(safe), INITIAL_SAFE_BALANCE); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(falseToken), config); + + // Should emit TopUpFailed when transfer returns false (now caught by try-catch) + vm.prank(stranger); + vm.expectEmit(true, true, true, true); + emit TopUpFailed(address(safe), agent1, address(falseToken), configId, "Transfer failed"); + executor.triggerTopUp(address(safe), configId); + + // Verify state was NOT updated (no top-up happened) + (, IAutoTopUpExecutor.TopUpState memory state) = executor.getTopUpById(configId); + assertEq(state.lastTopUpDay, 0); // Should remain 0 since transfer failed + assertEq(state.monthlySpent, 0); // Should remain 0 since transfer failed + } + + function test_GetTopUp_Success() public { + // Setup: Install module and create config + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Test getTopUp function + (IAutoTopUpExecutor.TopUpConfig memory retrievedConfig, IAutoTopUpExecutor.TopUpState memory retrievedState) = + executor.getTopUp(address(safe), agent1, address(token)); + + assertEq(retrievedConfig.dailyLimit, DAILY_LIMIT); + assertEq(retrievedConfig.monthlyLimit, MONTHLY_LIMIT); + assertTrue(retrievedConfig.enabled); + assertEq(retrievedState.agent, agent1); + assertEq(retrievedState.asset, address(token)); + } + + function test_GetTopUp_NonExistentConfig() public { + // Test getTopUp with non-existent config + (IAutoTopUpExecutor.TopUpConfig memory config, IAutoTopUpExecutor.TopUpState memory state) = + executor.getTopUp(address(safe), agent1, address(token)); + + // Should return zero values for non-existent config + assertEq(config.dailyLimit, 0); + assertEq(config.monthlyLimit, 0); + assertFalse(config.enabled); + assertEq(state.agent, address(0)); + assertEq(state.asset, address(0)); + } + + function test_CanExecuteTopUp_ConfigNotFound() public { + bytes32 nonExistentConfigId = keccak256("nonexistent"); + + (bool canExecute, string memory reason) = executor.canExecuteTopUp(address(safe), nonExistentConfigId); + + assertFalse(canExecute); + assertEq(reason, "Config not found"); + } + + function test_CanExecuteTopUp_AccountDoesntOwnConfig() public { + // Setup: Safe creates a config + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Try to check canExecute with different account (stranger) + (bool canExecute, string memory reason) = executor.canExecuteTopUp(stranger, configId); + + assertFalse(canExecute); + assertEq(reason, "Account doesn't own config"); + } + + function test_GetTopUpConfigs() public { + // Install module and create multiple configs + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config1 = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + IAutoTopUpExecutor.TopUpConfig memory config2 = IAutoTopUpExecutor.TopUpConfig({ + dailyLimit: DAILY_LIMIT / 2, monthlyLimit: MONTHLY_LIMIT / 2, enabled: false + }); + + vm.startPrank(address(safe)); + executor.configureTopUp(agent1, address(token), config1); + executor.configureTopUp(agent2, address(usdt), config2); + vm.stopPrank(); + + // Get all configs + (IAutoTopUpExecutor.TopUpConfig[] memory configs, IAutoTopUpExecutor.TopUpState[] memory states) = + executor.getTopUpConfigs(address(safe)); + + assertEq(configs.length, 2); + assertEq(states.length, 2); + + // Verify first config + assertEq(configs[0].dailyLimit, DAILY_LIMIT); + assertEq(configs[0].monthlyLimit, MONTHLY_LIMIT); + assertTrue(configs[0].enabled); + + // Verify second config + assertEq(configs[1].dailyLimit, DAILY_LIMIT / 2); + assertEq(configs[1].monthlyLimit, MONTHLY_LIMIT / 2); + assertFalse(configs[1].enabled); + } + + // ============ Date Boundary Tests ============ + + function test_DailyLimitReset() public { + // Setup config with daily limit + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // First execution should work + (bool canExecute, string memory reason) = executor.canExecuteTopUp(address(safe), configId); + assertTrue(canExecute); + assertEq(reason, ""); + + // Actually execute the top-up + vm.prank(address(safe)); + executor.triggerTopUp(address(safe), configId); + + // Should not be able to execute again same day (daily limit enforced) + (canExecute, reason) = executor.canExecuteTopUp(address(safe), configId); + assertFalse(canExecute); + assertEq(reason, "Already topped up today"); + + // Move to next day + vm.warp(block.timestamp + 1 days); + + // Now check - should not execute because agent balance is sufficient + (canExecute, reason) = executor.canExecuteTopUp(address(safe), configId); + assertFalse(canExecute); + assertEq(reason, "Agent balance sufficient"); + + // Reduce agent balance to need another top-up + vm.prank(agent1); + token.transfer(agent2, 60 ether); + + // Now should be able to execute + (canExecute, reason) = executor.canExecuteTopUp(address(safe), configId); + assertTrue(canExecute); + assertEq(reason, ""); + } + + function test_MonthlyLimitReset() public { + // Setup config with low monthly limit (just enough for 2 top-ups) + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = IAutoTopUpExecutor.TopUpConfig({ + dailyLimit: DAILY_LIMIT, + monthlyLimit: 110 ether, // Allows ~2 top-ups of 50 ether each + enabled: true + }); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // First top-up should work + vm.prank(address(safe)); + executor.triggerTopUp(address(safe), configId); + assertEq(token.balanceOf(agent1), DAILY_LIMIT); + + // Spend tokens and move to next day + vm.prank(agent1); + token.transfer(agent2, 90 ether); + vm.warp(block.timestamp + 1 days); + + // Second top-up should work (still within monthly limit) + vm.prank(address(safe)); + executor.triggerTopUp(address(safe), configId); + + // After second top-up, agent should have less than daily limit due to monthly limit + // Monthly limit is 110 ether, already spent 50 ether, so can only top up 60 ether more + // Agent had 10 ether, topped up by 60 ether to reach 70 ether + assertEq(token.balanceOf(agent1), 70 ether); + + // Spend tokens and move to next day (transfer less than balance) + vm.prank(agent1); + token.transfer(agent2, 60 ether); + vm.warp(block.timestamp + 1 days); + + // Third top-up should fail (monthly limit reached) + (bool canExecute, string memory reason) = executor.canExecuteTopUp(address(safe), configId); + assertFalse(canExecute); + assertEq(reason, "Monthly limit reached"); + + // Move to next month (30 days forward to ensure month change) + vm.warp(block.timestamp + 30 days); + + // Should be able to execute again (new month) + (canExecute, reason) = executor.canExecuteTopUp(address(safe), configId); + assertTrue(canExecute); + assertEq(reason, ""); + + // Execute to verify it actually works + vm.prank(address(safe)); + executor.triggerTopUp(address(safe), configId); + assertEq(token.balanceOf(agent1), DAILY_LIMIT); + } + + function test_YearTransition() public { + // Setup config + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Set time to Dec 31, 2023 + vm.warp(1704067199); // Dec 31, 2023 23:59:59 UTC + + // Execute top-up on last day of year + vm.prank(address(safe)); + executor.triggerTopUp(address(safe), configId); + + // Reduce balance to need another top-up + vm.prank(agent1); + token.transfer(agent2, 60 ether); + + // Should not execute again same day + (bool canExecute, string memory reason) = executor.canExecuteTopUp(address(safe), configId); + assertFalse(canExecute); + assertEq(reason, "Already topped up today"); + + // Move to Jan 1, 2024 (next year) + vm.warp(1704067200); // Jan 1, 2024 00:00:00 UTC + + // Should be able to execute (new day and new year) + (canExecute, reason) = executor.canExecuteTopUp(address(safe), configId); + assertTrue(canExecute); + assertEq(reason, ""); + + // Verify execution works + vm.prank(address(safe)); + executor.triggerTopUp(address(safe), configId); + assertEq(token.balanceOf(agent1), DAILY_LIMIT); + } + + function test_LeapYearFebruary() public { + // Setup config + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Set time to Feb 28, 2024 (leap year) + vm.warp(1709078400); // Feb 28, 2024 00:00:00 UTC + + // Execute top-up on Feb 28 + vm.prank(address(safe)); + executor.triggerTopUp(address(safe), configId); + + // Reduce balance + vm.prank(agent1); + token.transfer(agent2, 60 ether); + + // Move to Feb 29 (leap day) + vm.warp(1709164800); // Feb 29, 2024 00:00:00 UTC + + // Should be able to execute on leap day + (bool canExecute, string memory reason) = executor.canExecuteTopUp(address(safe), configId); + assertTrue(canExecute); + assertEq(reason, ""); + + vm.prank(address(safe)); + executor.triggerTopUp(address(safe), configId); + + // Reduce balance again + vm.prank(agent1); + token.transfer(agent2, 60 ether); + + // Move to March 1 + vm.warp(1709251200); // March 1, 2024 00:00:00 UTC + + // Should be able to execute on March 1 + (canExecute, reason) = executor.canExecuteTopUp(address(safe), configId); + assertTrue(canExecute); + assertEq(reason, ""); + } + + // ============ Fuzz Tests ============ + + function testFuzz_DailyExecutionLimit(uint32 startTimestamp, uint32 timeDelta) public { + // Test that daily execution limit is enforced across various start times and deltas + // Bound start timestamp to reasonable range (year 2020-2030) + uint256 startTime = bound(uint256(startTimestamp), 1577836800, 1893456000); // 2020-2030 + vm.warp(startTime); + + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: DAILY_LIMIT, monthlyLimit: MONTHLY_LIMIT, enabled: true}); + + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Execute first top-up + vm.prank(address(safe)); + executor.triggerTopUp(address(safe), configId); + + // Reduce balance to need another top-up + vm.prank(agent1); + token.transfer(agent2, 60 ether); + + // Calculate seconds remaining in current calendar day + (uint256 year, uint256 month, uint256 day) = BokkyPooBahsDateTimeLibrary.timestampToDate(block.timestamp); + uint256 endOfDay = BokkyPooBahsDateTimeLibrary.timestampFromDate(year, month, day) + 86400 - 1; + uint256 secondsLeftInDay = endOfDay - block.timestamp; + + // Bound time delta to stay within current calendar day + uint256 deltaSeconds = bound(uint256(timeDelta), 0, secondsLeftInDay); + vm.warp(block.timestamp + deltaSeconds); + + // Should not be able to execute again same calendar day + (bool canExecute, string memory reason) = executor.canExecuteTopUp(address(safe), configId); + assertFalse(canExecute); + assertEq(reason, "Already topped up today"); + + // Move to next calendar day + vm.warp(block.timestamp + (secondsLeftInDay - deltaSeconds) + 1); + + // Now should be able to execute + (canExecute, reason) = executor.canExecuteTopUp(address(safe), configId); + assertTrue(canExecute); + assertEq(reason, ""); + } + + function testFuzz_ConfigurationLimits(uint256 dailyLimit, uint256 monthlyLimit) public { + // Bound to reasonable values + dailyLimit = bound(dailyLimit, 1, type(uint128).max); + monthlyLimit = bound(monthlyLimit, 1, type(uint128).max); + + vm.prank(address(safe)); + executor.onInstall(""); + + IAutoTopUpExecutor.TopUpConfig memory config = + IAutoTopUpExecutor.TopUpConfig({dailyLimit: dailyLimit, monthlyLimit: monthlyLimit, enabled: true}); + + // Should succeed with valid limits + vm.prank(address(safe)); + bytes32 configId = executor.configureTopUp(agent1, address(token), config); + + // Verify config was stored correctly + (IAutoTopUpExecutor.TopUpConfig memory retrieved,) = executor.getTopUpById(configId); + assertEq(retrieved.dailyLimit, dailyLimit); + assertEq(retrieved.monthlyLimit, monthlyLimit); + } +} diff --git a/solidity/account-modules/test/mocks/MaliciousCollectToken.sol b/solidity/account-modules/test/mocks/MaliciousCollectToken.sol new file mode 100644 index 0000000..fffb1df --- /dev/null +++ b/solidity/account-modules/test/mocks/MaliciousCollectToken.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {AutoCollectExecutor} from "../../src/AutoCollectExecutor.sol"; + +// Malicious token that tries to re-enter during transfer for collection tests +contract MaliciousCollectToken is ERC20 { + AutoCollectExecutor public target; + address public targetAccount; + address public targetAsset; + + constructor() ERC20("MaliciousCollect", "MALCOL") {} + + function setTarget(AutoCollectExecutor _target, address _account, address _asset) external { + target = _target; + targetAccount = _account; + targetAsset = _asset; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function transfer(address to, uint256 amount) public override returns (bool) { + // Try to re-enter during transfer + if (address(target) != address(0)) { + try target.triggerCollection(targetAccount, targetAsset) {} catch {} + } + return super.transfer(to, amount); + } +} diff --git a/solidity/account-modules/test/mocks/MaliciousToken.sol b/solidity/account-modules/test/mocks/MaliciousToken.sol new file mode 100644 index 0000000..e88e6b3 --- /dev/null +++ b/solidity/account-modules/test/mocks/MaliciousToken.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {AutoTopUpExecutor} from "../../src/AutoTopUpExecutor.sol"; + +// Malicious token that tries to re-enter during transfer +contract MaliciousToken is ERC20 { + AutoTopUpExecutor public target; + bytes32 public targetConfigId; + + constructor() ERC20("Malicious", "MAL") {} + + function setTarget(AutoTopUpExecutor _target, bytes32 _configId) external { + target = _target; + targetConfigId = _configId; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function transfer(address to, uint256 amount) public override returns (bool) { + // Try to re-enter during transfer + if (address(target) != address(0)) { + try target.triggerTopUp(address(this), targetConfigId) {} catch {} + } + return super.transfer(to, amount); + } +} diff --git a/solidity/account-modules/test/mocks/MockERC20.sol b/solidity/account-modules/test/mocks/MockERC20.sol new file mode 100644 index 0000000..96c6a65 --- /dev/null +++ b/solidity/account-modules/test/mocks/MockERC20.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function burn(address from, uint256 amount) external { + _burn(from, amount); + } +} diff --git a/solidity/account-modules/test/mocks/MockSafe.sol b/solidity/account-modules/test/mocks/MockSafe.sol new file mode 100644 index 0000000..18d90ab --- /dev/null +++ b/solidity/account-modules/test/mocks/MockSafe.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {ModeCode} from "modulekit/src/accounts/common/lib/ModeLib.sol"; +import {ExecutionLib} from "modulekit/src/accounts/erc7579/lib/ExecutionLib.sol"; + +// Mock Safe contract for testing ERC-7579 module execution +contract MockSafe { + mapping(address => bool) public isModuleEnabled; + + // Storage for execution results + bool public lastExecutionSuccess; + bytes public lastExecutionResult; + + // Mock owner for testing + address public owner; + + constructor(address _owner) { + owner = _owner; + } + + function enableModule(address module) external { + require(msg.sender == owner, "Only owner"); + isModuleEnabled[module] = true; + } + + // ERC-7579 compatible execution function + // This is called by the module's _execute function via ERC7579ExecutorBase + function executeFromExecutor( + ModeCode, // mode - we ignore this in the mock + bytes calldata executionCalldata + ) + external + payable + returns (bytes[] memory returnData) + { + require(isModuleEnabled[msg.sender], "Module not enabled"); + + // Decode the execution calldata + // For single execution mode, it's encoded as (target, value, callData) + (address target, uint256 value, bytes memory data) = ExecutionLib.decodeSingle(executionCalldata); + + // Execute the call + bool success; + bytes memory result; + (success, result) = target.call{value: value}(data); + lastExecutionSuccess = success; + lastExecutionResult = result; + + if (!success) { + // Bubble up the revert reason + assembly { + revert(add(result, 32), mload(result)) + } + } + + // Return as array (ERC-7579 expects array of results) + returnData = new bytes[](1); + returnData[0] = result; + + return returnData; + } +} diff --git a/solidity/account-modules/test/mocks/MockTokenBadReturn.sol b/solidity/account-modules/test/mocks/MockTokenBadReturn.sol new file mode 100644 index 0000000..28a2b79 --- /dev/null +++ b/solidity/account-modules/test/mocks/MockTokenBadReturn.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +// Mock ERC20 token that returns malformed data (not 32 bytes) on transfer +contract MockTokenBadReturn { + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalSupply; + + string public name = "Bad Return Token"; + string public symbol = "BAD"; + uint8 public decimals = 18; + + function totalSupply() public view returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) public view returns (uint256) { + return _balances[account]; + } + + function allowance(address owner, address spender) public view returns (uint256) { + return _allowances[owner][spender]; + } + + function approve(address spender, uint256 amount) external returns (bool) { + _allowances[msg.sender][spender] = amount; + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + require(_balances[msg.sender] >= amount, "Insufficient balance"); + _balances[msg.sender] -= amount; + _balances[to] += amount; + + // Return malformed data (64 bytes instead of 32) + assembly { + let ptr := mload(0x40) + mstore(ptr, 0x0000000000000000000000000000000000000000000000000000000000000001) + mstore(add(ptr, 0x20), 0x0000000000000000000000000000000000000000000000000000000000000001) + return(ptr, 0x40) + } + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + require(_balances[from] >= amount, "Insufficient balance"); + require(_allowances[from][msg.sender] >= amount, "Insufficient allowance"); + + _balances[from] -= amount; + _balances[to] += amount; + _allowances[from][msg.sender] -= amount; + + return true; + } + + function mint(address to, uint256 amount) external { + _balances[to] += amount; + _totalSupply += amount; + } +} diff --git a/solidity/account-modules/test/mocks/MockTokenReturnsFalse.sol b/solidity/account-modules/test/mocks/MockTokenReturnsFalse.sol new file mode 100644 index 0000000..1310406 --- /dev/null +++ b/solidity/account-modules/test/mocks/MockTokenReturnsFalse.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +// Mock ERC20 token that returns false on transfer +contract MockTokenReturnsFalse { + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalSupply; + + string public name = "False Return Token"; + string public symbol = "FALSE"; + uint8 public decimals = 18; + + function totalSupply() public view returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) public view returns (uint256) { + return _balances[account]; + } + + function allowance(address owner, address spender) public view returns (uint256) { + return _allowances[owner][spender]; + } + + function approve(address spender, uint256 amount) external returns (bool) { + _allowances[msg.sender][spender] = amount; + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + require(_balances[msg.sender] >= amount, "Insufficient balance"); + _balances[msg.sender] -= amount; + _balances[to] += amount; + + // Always return false even on successful transfer + return false; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + require(_balances[from] >= amount, "Insufficient balance"); + require(_allowances[from][msg.sender] >= amount, "Insufficient allowance"); + + _balances[from] -= amount; + _balances[to] += amount; + _allowances[from][msg.sender] -= amount; + + return true; + } + + function mint(address to, uint256 amount) external { + _balances[to] += amount; + _totalSupply += amount; + } +} diff --git a/solidity/account-modules/test/mocks/MockUSDT.sol b/solidity/account-modules/test/mocks/MockUSDT.sol new file mode 100644 index 0000000..39ccb57 --- /dev/null +++ b/solidity/account-modules/test/mocks/MockUSDT.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +// Mock ERC20 token that returns nothing on transfer (like USDT) +contract MockUSDT { + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalSupply; + + string public name = "Mock USDT"; + string public symbol = "mUSDT"; + uint8 public decimals = 6; + + function totalSupply() public view returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) public view returns (uint256) { + return _balances[account]; + } + + function allowance(address owner, address spender) public view returns (uint256) { + return _allowances[owner][spender]; + } + + // USDT-style transfer that returns nothing + function transfer(address to, uint256 amount) external { + require(_balances[msg.sender] >= amount, "Insufficient balance"); + _balances[msg.sender] -= amount; + _balances[to] += amount; + // No return value - mimics USDT behavior + } + + function approve(address spender, uint256 amount) external returns (bool) { + _allowances[msg.sender][spender] = amount; + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + require(_balances[from] >= amount, "Insufficient balance"); + require(_allowances[from][msg.sender] >= amount, "Insufficient allowance"); + + _balances[from] -= amount; + _balances[to] += amount; + _allowances[from][msg.sender] -= amount; + + return true; + } + + function mint(address to, uint256 amount) external { + _balances[to] += amount; + _totalSupply += amount; + } +}