diff --git a/audits/Hacken_Push Chain_[SCA] Push Chain _ Core Contracts _ Mar2026_P-2025-1876_4_20260625 11_10 (1).pdf b/audits/Hacken_Push Chain_[SCA] Push Chain _ Core Contracts _ Mar2026_P-2025-1876_4_20260625 11_10 (1).pdf new file mode 100644 index 0000000..9c37b94 Binary files /dev/null and b/audits/Hacken_Push Chain_[SCA] Push Chain _ Core Contracts _ Mar2026_P-2025-1876_4_20260625 11_10 (1).pdf differ diff --git a/docs/3_CEA.md b/docs/3_CEA.md index c3170bb..058342f 100644 --- a/docs/3_CEA.md +++ b/docs/3_CEA.md @@ -106,7 +106,7 @@ Each user has exactly one CEA per external chain (v1). A CEA is deployed via `CE **Deployment flow:** 1. `CEAFactory` clones `CEAProxy` template using `cloneDeterministic(salt)` where `salt = keccak256(abi.encode(pushAccount))`. 2. `CEAFactory` calls `CEAProxy.initializeCEAProxy(CEA_IMPLEMENTATION)` to set the implementation. -3. `CEAFactory` calls `CEA.initializeCEA(pushAccount, VAULT, UNIVERSAL_GATEWAY)` through the proxy. +3. `CEAFactory` calls `CEA.initializeCEA(pushAccount, VAULT, UNIVERSAL_GATEWAY, factory)` through the proxy. In practice: ``` diff --git a/docs/CEA_migration_flow.md b/docs/CEA_migration_flow.md index 9f746a1..d2f5266 100644 --- a/docs/CEA_migration_flow.md +++ b/docs/CEA_migration_flow.md @@ -67,14 +67,14 @@ function initializeCEAProxy(address _logic) external initializer { **Current Execution Flow:** ``` -executeUniversalTx(txID, universalTxID, originCaller, payload) +executeUniversalTx(subTxId, universalTxID, originCaller, payload) → _handleExecution(...) → if isMulticall(payload): _handleMulticall(...) → else: _handleSingleCall(...) // backwards compat ``` **Key Functions:** -- `executeUniversalTx()` (line 101): Entry point, validates txID and originCaller +- `executeUniversalTx()` (line 101): Entry point, validates subTxId and originCaller - `_handleExecution()` (line 168): Routes based on payload type - `_handleMulticall()` (line 193): Executes Multicall[] array via `.call()` - `sendUniversalTxToUEA()` (line 123): Self-call only function for withdrawals @@ -183,7 +183,7 @@ function migrateCEA() external onlyDelegateCall { │ ─────────────────────────────────────────────────────────────────────── │ │ Vault.executeUniversalTx() │ │ → Calls CEA.executeUniversalTx( │ -│ txID, │ +│ subTxId, │ │ universalTxID, │ │ originCaller = UEA address, │ │ payload = MULTICALL_SELECTOR + Multicall[{...}] │ @@ -195,9 +195,9 @@ function migrateCEA() external onlyDelegateCall { │ ─────────────────────────────────────────────────────────────────────── │ │ CEA.executeUniversalTx() [NEW LOGIC] │ │ → Validates: msg.sender == VAULT ✓ │ -│ → Validates: !isExecuted[txID] ✓ │ +│ → Validates: !isExecuted[subTxId] ✓ │ │ → Validates: originCaller == UEA ✓ │ -│ → Sets: isExecuted[txID] = true ✓ │ +│ → Sets: isExecuted[subTxId] = true ✓ │ │ → Calls: _handleExecution(...) │ │ │ │ CEA._handleExecution() [NEW LOGIC] │ @@ -242,7 +242,7 @@ function migrateCEA() external onlyDelegateCall { │ ─────────────────────────────────────────────────────────────────────── │ │ CEA._handleMigration() [returned from delegatecall] │ │ → Checks: success == true ✓ │ -│ → Emits: UniversalTxExecuted(txID, universalTxID, originCaller, ...) │ +│ → Emits: UniversalTxExecuted(subTxId, universalTxID, originCaller, ...) │ │ → Returns to caller │ │ │ │ Result: CEAProxy now points to CEA v2 implementation │ @@ -290,7 +290,7 @@ if (isMulticall(payload)) { } // Normal multicall execution - _handleMulticall(txID, universalTxID, originCaller, calls); + _handleMulticall(subTxId, universalTxID, originCaller, calls); } ``` @@ -443,16 +443,16 @@ function _handleMigration(Multicall memory call) internal { **Before:** ```solidity function _handleExecution( - bytes32 txID, + bytes32 subTxId, bytes32 universalTxID, address originCaller, bytes calldata payload ) internal { if (isMulticall(payload)) { Multicall[] memory calls = decodeCalls(payload); - _handleMulticall(txID, universalTxID, originCaller, calls); + _handleMulticall(subTxId, universalTxID, originCaller, calls); } else { - _handleSingleCall(txID, universalTxID, originCaller, payload); + _handleSingleCall(subTxId, universalTxID, originCaller, payload); } } ``` @@ -460,7 +460,7 @@ function _handleExecution( **After:** ```solidity function _handleExecution( - bytes32 txID, + bytes32 subTxId, bytes32 universalTxID, address originCaller, bytes calldata payload @@ -472,14 +472,14 @@ function _handleExecution( if (calls.length == 1 && isMigration(calls[0].data)) { _handleMigration(calls[0]); // Emit event for migration execution - emit UniversalTxExecuted(txID, universalTxID, originCaller, address(this), calls[0].data); + emit UniversalTxExecuted(subTxId, universalTxID, originCaller, address(this), calls[0].data); return; } // Normal multicall execution - _handleMulticall(txID, universalTxID, originCaller, calls); + _handleMulticall(subTxId, universalTxID, originCaller, calls); } else { - _handleSingleCall(txID, universalTxID, originCaller, payload); + _handleSingleCall(subTxId, universalTxID, originCaller, payload); } } ``` @@ -682,19 +682,19 @@ function initializeCEA(address _uea, address _vault, address _universalGateway, All migration executions MUST satisfy these constraints (enforced by `_handleMigration()`): -| Constraint | Validation | Error | Rationale | -|------------|-----------|-------|-----------| -| **Standalone execution** | `calls.length == 1` | `InvalidCall` | Prevents migration buried in complex batch | -| **Self-targeted** | `call.to == address(this)` | `InvalidTarget` | Migration must target own proxy | -| **Zero value** | `call.value == 0` | `InvalidInput` | No funds sent with migration | -| **Migration contract set** | `factory.CEA_MIGRATION_CONTRACT() != address(0)` | `InvalidCall` | Prevents uninitialized migration | -| **Delegatecall context** | Enforced by CEAMigration.`onlyDelegateCall()` | `Unauthorized` | Prevents direct calls to migration | -| **Valid implementation** | CEAMigration constructor validates `hasCode()` | `InvalidInput` | Prevents bricking proxy | +| Constraint | Validation | Error | Rationale | +| -------------------------- | ------------------------------------------------ | --------------- | ------------------------------------------ | +| **Standalone execution** | `calls.length == 1` | `InvalidCall` | Prevents migration buried in complex batch | +| **Self-targeted** | `call.to == address(this)` | `InvalidTarget` | Migration must target own proxy | +| **Zero value** | `call.value == 0` | `InvalidInput` | No funds sent with migration | +| **Migration contract set** | `factory.CEA_MIGRATION_CONTRACT() != address(0)` | `InvalidCall` | Prevents uninitialized migration | +| **Delegatecall context** | Enforced by CEAMigration.`onlyDelegateCall()` | `Unauthorized` | Prevents direct calls to migration | +| **Valid implementation** | CEAMigration constructor validates `hasCode()` | `InvalidInput` | Prevents bricking proxy | **Additional existing protections:** - `onlyVault` modifier (line 52): Only Vault can call `executeUniversalTx()` - `originCaller == UEA` check (line 109): Transaction must originate from correct UEA -- `!isExecuted[txID]` check (line 108): Prevents replay attacks +- `!isExecuted[subTxId]` check (line 108): Prevents replay attacks - `nonReentrant` modifier (line 106): Prevents reentrancy --- @@ -801,22 +801,22 @@ All migration executions MUST satisfy these constraints (enforced by `_handleMig ### 6.4 Replay Attack -**Threat:** Same txID executed twice → double spend or double migration +**Threat:** Same subTxId executed twice → double spend or double migration **Impact:** High - unauthorized execution or wasted gas **Mitigation:** - **Existing protection (line 108):** ```solidity - if (isExecuted[txID]) revert CEAErrors.PayloadExecuted(); - isExecuted[txID] = true; + if (isExecuted[subTxId]) revert CEAErrors.PayloadExecuted(); + isExecuted[subTxId] = true; ``` - Executed BEFORE routing to migration - Preserved across migration (storage not touched) **Test coverage:** -- Execute migration with txID = keccak256("migration1") -- Attempt to execute same txID again → expect `PayloadExecuted` revert +- Execute migration with subTxId = keccak256("migration1") +- Attempt to execute same subTxId again → expect `PayloadExecuted` revert - Verify isExecuted mapping preserved after migration --- @@ -1007,17 +1007,17 @@ All migration executions MUST satisfy these constraints (enforced by `_handleMig **File:** `test/tests_ceaMigration/CEAMigration.t.sol` -| Test | Description | Expected Result | -|------|-------------|-----------------| -| `test_Constructor_ValidImplementation` | Deploy with valid CEA v2 | Success, immutables set correctly | -| `test_Constructor_RevertZeroAddress` | Deploy with address(0) | Revert with `InvalidInput` | -| `test_Constructor_RevertEOA` | Deploy with EOA address | Revert with `InvalidInput` | -| `test_migrateCEA_DirectCall` | Call `migrateCEA()` directly | Revert with `Unauthorized` | -| `test_migrateCEA_Delegatecall` | Delegatecall from mock proxy | Success, slot written, event emitted | -| `test_migrateCEA_SlotWrite` | Verify CEA_LOGIC_SLOT updated | Slot contains new implementation address | -| `test_migrateCEA_EventEmission` | Check event emission | `ImplementationUpdated` emitted with correct address | -| `test_hasCode_Contract` | Check contract address | Returns true | -| `test_hasCode_EOA` | Check EOA address | Returns false | +| Test | Description | Expected Result | +| -------------------------------------- | ----------------------------- | ---------------------------------------------------- | +| `test_Constructor_ValidImplementation` | Deploy with valid CEA v2 | Success, immutables set correctly | +| `test_Constructor_RevertZeroAddress` | Deploy with address(0) | Revert with `InvalidInput` | +| `test_Constructor_RevertEOA` | Deploy with EOA address | Revert with `InvalidInput` | +| `test_migrateCEA_DirectCall` | Call `migrateCEA()` directly | Revert with `Unauthorized` | +| `test_migrateCEA_Delegatecall` | Delegatecall from mock proxy | Success, slot written, event emitted | +| `test_migrateCEA_SlotWrite` | Verify CEA_LOGIC_SLOT updated | Slot contains new implementation address | +| `test_migrateCEA_EventEmission` | Check event emission | `ImplementationUpdated` emitted with correct address | +| `test_hasCode_Contract` | Check contract address | Returns true | +| `test_hasCode_EOA` | Check EOA address | Returns false | --- @@ -1025,14 +1025,14 @@ All migration executions MUST satisfy these constraints (enforced by `_handleMig **File:** `test/tests_cea/CEAFactory.t.sol` (add to existing test file) -| Test | Description | Expected Result | -|------|-------------|-----------------| -| `test_setCEAMigrationContract_Success` | Owner sets migration contract | Success, event emitted | -| `test_setCEAMigrationContract_ZeroAddress` | Set to address(0) | Revert with `ZeroAddress` | -| `test_setCEAMigrationContract_NonOwner` | Non-owner attempts to set | Revert with `OwnableUnauthorizedAccount` | -| `test_setCEAMigrationContract_Event` | Verify event emission | `CEAMigrationContractUpdated` with old/new addresses | -| `test_initialize_WithMigrationContract` | Initialize factory with migration contract | Success (if optional param added) | -| `test_deployCEA_PassesFactoryAddress` | Verify factory address passed to CEA | CEA.factory == factory address | +| Test | Description | Expected Result | +| ------------------------------------------ | ------------------------------------------ | ---------------------------------------------------- | +| `test_setCEAMigrationContract_Success` | Owner sets migration contract | Success, event emitted | +| `test_setCEAMigrationContract_ZeroAddress` | Set to address(0) | Revert with `ZeroAddress` | +| `test_setCEAMigrationContract_NonOwner` | Non-owner attempts to set | Revert with `OwnableUnauthorizedAccount` | +| `test_setCEAMigrationContract_Event` | Verify event emission | `CEAMigrationContractUpdated` with old/new addresses | +| `test_initialize_WithMigrationContract` | Initialize factory with migration contract | Success (if optional param added) | +| `test_deployCEA_PassesFactoryAddress` | Verify factory address passed to CEA | CEA.factory == factory address | --- @@ -1040,19 +1040,19 @@ All migration executions MUST satisfy these constraints (enforced by `_handleMig **File:** `test/tests_cea/CEA_Migration.t.sol` (new test file) -| Test | Description | Expected Result | -|------|-------------|-----------------| -| `test_initializeCEA_WithFactory` | Initialize with factory address | Success, factory set | -| `test_initializeCEA_ZeroFactory` | Initialize with address(0) factory | Revert with `ZeroAddress` | -| `test_isMigration_True` | Check MIGRATION_SELECTOR | Returns true | -| `test_isMigration_False` | Check other selector | Returns false | -| `test_isMigration_ShortData` | Check data < 4 bytes | Returns false | -| `test_handleMigration_WrongTarget` | Migration with `to != address(this)` | Revert with `InvalidTarget` | -| `test_handleMigration_NonZeroValue` | Migration with `value > 0` | Revert with `InvalidInput` | -| `test_handleMigration_NoMigrationContract` | Factory returns address(0) | Revert with `InvalidCall` | -| `test_handleMigration_DelegatecallFailure` | Migration contract reverts | Revert bubbles up | -| `test_handleMulticall_MigrationInBatch` | Batch with migration selector | Revert with `InvalidCall` | -| `test_handleExecution_StandaloneMigration` | Single-element migration multicall | Routes to `_handleMigration()` | +| Test | Description | Expected Result | +| ------------------------------------------ | ------------------------------------ | ------------------------------ | +| `test_initializeCEA_WithFactory` | Initialize with factory address | Success, factory set | +| `test_initializeCEA_ZeroFactory` | Initialize with address(0) factory | Revert with `ZeroAddress` | +| `test_isMigration_True` | Check MIGRATION_SELECTOR | Returns true | +| `test_isMigration_False` | Check other selector | Returns false | +| `test_isMigration_ShortData` | Check data < 4 bytes | Returns false | +| `test_handleMigration_WrongTarget` | Migration with `to != address(this)` | Revert with `InvalidTarget` | +| `test_handleMigration_NonZeroValue` | Migration with `value > 0` | Revert with `InvalidInput` | +| `test_handleMigration_NoMigrationContract` | Factory returns address(0) | Revert with `InvalidCall` | +| `test_handleMigration_DelegatecallFailure` | Migration contract reverts | Revert bubbles up | +| `test_handleMulticall_MigrationInBatch` | Batch with migration selector | Revert with `InvalidCall` | +| `test_handleExecution_StandaloneMigration` | Single-element migration multicall | Routes to `_handleMigration()` | --- @@ -1060,19 +1060,19 @@ All migration executions MUST satisfy these constraints (enforced by `_handleMig **File:** `test/tests_ceaMigration/CEAMigration_Integration.t.sol` -| Test | Description | Expected Result | -|------|-------------|-----------------| -| `test_FullMigrationFlow` | Complete Vault → CEA → Migration flow | Success, implementation upgraded | -| `test_StatePersistence_UEA` | Verify UEA unchanged after migration | `cea.UEA()` == original value | -| `test_StatePersistence_VAULT` | Verify VAULT unchanged | `cea.VAULT()` == original value | -| `test_StatePersistence_UNIVERSAL_GATEWAY` | Verify gateway unchanged | `cea.UNIVERSAL_GATEWAY()` == original value | -| `test_StatePersistence_isExecuted` | Verify executed tx records preserved | `cea.isExecuted(oldTxID)` == true | -| `test_FundPersistence_Native` | Native balance preserved | Balance unchanged before/after | -| `test_FundPersistence_ERC20` | ERC20 balance preserved | Balance unchanged before/after | -| `test_PostMigration_Withdraw` | Withdraw funds after migration | `sendUniversalTxToUEA()` succeeds | -| `test_PostMigration_Execute` | Execute new tx after migration | `executeUniversalTx()` succeeds with new logic | -| `test_PostMigration_Multicall` | Multicall after migration | Works normally | -| `test_MultipleProxies_IndependentMigration` | Migrate multiple CEAs independently | Each migrates without affecting others | +| Test | Description | Expected Result | +| ------------------------------------------- | ------------------------------------- | ---------------------------------------------- | +| `test_FullMigrationFlow` | Complete Vault → CEA → Migration flow | Success, implementation upgraded | +| `test_StatePersistence_UEA` | Verify UEA unchanged after migration | `cea.UEA()` == original value | +| `test_StatePersistence_VAULT` | Verify VAULT unchanged | `cea.VAULT()` == original value | +| `test_StatePersistence_UNIVERSAL_GATEWAY` | Verify gateway unchanged | `cea.UNIVERSAL_GATEWAY()` == original value | +| `test_StatePersistence_isExecuted` | Verify executed tx records preserved | `cea.isExecuted(oldTxID)` == true | +| `test_FundPersistence_Native` | Native balance preserved | Balance unchanged before/after | +| `test_FundPersistence_ERC20` | ERC20 balance preserved | Balance unchanged before/after | +| `test_PostMigration_Withdraw` | Withdraw funds after migration | `sendUniversalTxToUEA()` succeeds | +| `test_PostMigration_Execute` | Execute new tx after migration | `executeUniversalTx()` succeeds with new logic | +| `test_PostMigration_Multicall` | Multicall after migration | Works normally | +| `test_MultipleProxies_IndependentMigration` | Migrate multiple CEAs independently | Each migrates without affecting others | --- @@ -1080,16 +1080,16 @@ All migration executions MUST satisfy these constraints (enforced by `_handleMig **File:** `test/tests_ceaMigration/CEAMigration_Negative.t.sol` -| Test | Description | Expected Result | -|------|-------------|-----------------| -| `testRevert_NotVault` | Non-Vault calls executeUniversalTx with migration | Revert with `NotVault` | -| `testRevert_WrongOriginCaller` | Wrong originCaller in migration payload | Revert with `InvalidUEA` | -| `testRevert_ReplayedTxID` | Attempt to execute same migration txID twice | Revert with `PayloadExecuted` | -| `testRevert_WrongTarget` | Migration with `to != address(this)` | Revert with `InvalidTarget` | -| `testRevert_NonZeroValue` | Migration with `value > 0` | Revert with `InvalidInput` | -| `testRevert_BatchedMigration` | Migration in multi-call batch | Revert with `InvalidCall` | -| `testRevert_UnsetMigrationContract` | Migration before factory.CEA_MIGRATION_CONTRACT() set | Revert with `InvalidCall` | -| `testRevert_InvalidImplementation` | Migration contract points to invalid address | Revert (caught at migration deploy) | +| Test | Description | Expected Result | +| ----------------------------------- | ----------------------------------------------------- | ----------------------------------- | +| `testRevert_NotVault` | Non-Vault calls executeUniversalTx with migration | Revert with `NotVault` | +| `testRevert_WrongOriginCaller` | Wrong originCaller in migration payload | Revert with `InvalidUEA` | +| `testRevert_ReplayedTxID` | Attempt to execute same migration subTxId twice | Revert with `PayloadExecuted` | +| `testRevert_WrongTarget` | Migration with `to != address(this)` | Revert with `InvalidTarget` | +| `testRevert_NonZeroValue` | Migration with `value > 0` | Revert with `InvalidInput` | +| `testRevert_BatchedMigration` | Migration in multi-call batch | Revert with `InvalidCall` | +| `testRevert_UnsetMigrationContract` | Migration before factory.CEA_MIGRATION_CONTRACT() set | Revert with `InvalidCall` | +| `testRevert_InvalidImplementation` | Migration contract points to invalid address | Revert (caught at migration deploy) | --- @@ -1097,14 +1097,14 @@ All migration executions MUST satisfy these constraints (enforced by `_handleMig **File:** `test/tests_ceaMigration/CEAMigration_EdgeCases.t.sol` -| Test | Description | Expected Result | -|------|-------------|-----------------| -| `test_MigrationV1toV2toV3` | Chain migrations: v1 → v2 → v3 | All succeed, state preserved through both | -| `test_MigrationAfterManyExecutions` | Migrate CEA with 1000+ executed txs | Success, all isExecuted entries preserved | -| `test_MigrationWithMaxBalances` | Migrate CEA holding max uint256 token amounts | Balances preserved | -| `test_MigrationEmptyState` | Migrate brand new CEA (no executions yet) | Success, ready for use | -| `test_MigrationImmediateReuse` | Execute normal tx immediately after migration | Works with new implementation | -| `test_MigrationDuringHighLoad` | Migrate while other CEAs executing | No interference, isolated state | +| Test | Description | Expected Result | +| ----------------------------------- | --------------------------------------------- | ----------------------------------------- | +| `test_MigrationV1toV2toV3` | Chain migrations: v1 → v2 → v3 | All succeed, state preserved through both | +| `test_MigrationAfterManyExecutions` | Migrate CEA with 1000+ executed txs | Success, all isExecuted entries preserved | +| `test_MigrationWithMaxBalances` | Migrate CEA holding max uint256 token amounts | Balances preserved | +| `test_MigrationEmptyState` | Migrate brand new CEA (no executions yet) | Success, ready for use | +| `test_MigrationImmediateReuse` | Execute normal tx immediately after migration | Works with new implementation | +| `test_MigrationDuringHighLoad` | Migrate while other CEAs executing | No interference, isolated state | --- @@ -1119,7 +1119,7 @@ function testFuzz_MigrationPreservesState(uint256 executionCount) public { // Execute random txs for (uint256 i = 0; i < executionCount; i++) { - bytes32 txID = keccak256(abi.encode(i)); + bytes32 subTxId = keccak256(abi.encode(i)); // ... execute normal tx } @@ -1517,7 +1517,7 @@ event CEAMigrationContractUpdated(address indexed oldContract, address indexed n **CEA (existing, line 219):** ```solidity event UniversalTxExecuted( - bytes32 indexed txID, + bytes32 indexed subTxId, bytes32 indexed universalTxID, address indexed originCaller, address to, @@ -1527,7 +1527,7 @@ event UniversalTxExecuted( **Emitted during migration:** 1. `CEAMigration.ImplementationUpdated(ceaV2Address)` - inside delegatecall -2. `CEA.UniversalTxExecuted(txID, universalTxID, UEA, ceaProxy, migrationSelector)` - in _handleExecution +2. `CEA.UniversalTxExecuted(subTxId, universalTxID, UEA, ceaProxy, migrationSelector)` - in _handleExecution --- @@ -1549,18 +1549,18 @@ event UniversalTxExecuted( ### 10.6 Comparison: UEA vs CEA Migration -| Aspect | UEA Migration | CEA Migration | -|--------|--------------|---------------| -| **Initiator** | User (signs UniversalPayload) | User (via UEA on Push Chain) | -| **Entry point** | `UEA_EVM.executePayload()` | `CEA.executeUniversalTx()` | -| **Caller** | Direct call (or UE_MODULE) | Vault only | -| **Authorization** | Signature verification | originCaller == UEA | -| **Payload format** | `UniversalPayload` struct | `Multicall[]` array | -| **Selector detection** | `isMigration(payload.data)` | `isMigration(call.data)` inside multicall | -| **Migration contract fetch** | `factory.UEA_MIGRATION_CONTRACT()` | `factory.CEA_MIGRATION_CONTRACT()` | -| **Delegatecall target** | `migrateUEAEVM()` | `migrateCEA()` | -| **Storage slot** | `UEA_LOGIC_SLOT` (0x868a771a...) | `CEA_LOGIC_SLOT` (0x8b2ae8ee...) | -| **Cross-chain** | No (UEA lives on Push Chain) | Yes (CEA on external chain, initiated from Push) | +| Aspect | UEA Migration | CEA Migration | +| ---------------------------- | ---------------------------------- | ------------------------------------------------ | +| **Initiator** | User (signs UniversalPayload) | User (via UEA on Push Chain) | +| **Entry point** | `UEA_EVM.executePayload()` | `CEA.executeUniversalTx()` | +| **Caller** | Direct call (or UE_MODULE) | Vault only | +| **Authorization** | Signature verification | originCaller == UEA | +| **Payload format** | `UniversalPayload` struct | `Multicall[]` array | +| **Selector detection** | `isMigration(payload.data)` | `isMigration(call.data)` inside multicall | +| **Migration contract fetch** | `factory.UEA_MIGRATION_CONTRACT()` | `factory.CEA_MIGRATION_CONTRACT()` | +| **Delegatecall target** | `migrateUEAEVM()` | `migrateCEA()` | +| **Storage slot** | `UEA_LOGIC_SLOT` (0x868a771a...) | `CEA_LOGIC_SLOT` (0x8b2ae8ee...) | +| **Cross-chain** | No (UEA lives on Push Chain) | Yes (CEA on external chain, initiated from Push) | --- diff --git a/docs/THREAT_MODELLING_DOC.md b/docs/THREAT_MODELLING_DOC.md index b1583d2..fc59c1a 100644 --- a/docs/THREAT_MODELLING_DOC.md +++ b/docs/THREAT_MODELLING_DOC.md @@ -80,20 +80,20 @@ protocol compromise with no on-chain recovery path. ## Scope -| Contract | File | Chain | Upgradeable | -|---|---|---|---| -| UniversalCore | `src/UniversalCore.sol` | Push Chain | Yes (OZ ERC1967) | -| PRC20 | `src/PRC20.sol` | Push Chain | Yes (OZ Initializable) | -| WPC | `src/WPC.sol` | Push Chain | No | -| UEA_EVM | `src/uea/UEA_EVM.sol` | Push Chain | No (logic; proxy is upgradeable via migration) | -| UEA_SVM | `src/uea/UEA_SVM.sol` | Push Chain | No (logic; proxy is upgradeable via migration) | -| UEAFactory | `src/uea/UEAFactory.sol` | Push Chain | Yes (OZ ERC1967) | -| UEAProxy | `src/uea/UEAProxy.sol` | Push Chain | No (upgraded via UEAMigration delegatecall) | -| UEAMigration | `src/uea/UEAMigration.sol` | Push Chain | No | -| CEA | `src/cea/CEA.sol` | External Chain | No (logic; proxy is upgradeable via migration) | -| CEAFactory | `src/cea/CEAFactory.sol` | External Chain | Yes (OZ ERC1967) | -| CEAProxy | `src/cea/CEAProxy.sol` | External Chain | No (upgraded via CEAMigration delegatecall) | -| CEAMigration | `src/cea/CEAMigration.sol` | External Chain | No | +| Contract | File | Chain | Upgradeable | +| ------------- | -------------------------- | -------------- | ---------------------------------------------- | +| UniversalCore | `src/UniversalCore.sol` | Push Chain | Yes (OZ ERC1967) | +| PRC20 | `src/PRC20.sol` | Push Chain | Yes (OZ Initializable) | +| WPC | `src/WPC.sol` | Push Chain | No | +| UEA_EVM | `src/uea/UEA_EVM.sol` | Push Chain | No (logic; proxy is upgradeable via migration) | +| UEA_SVM | `src/uea/UEA_SVM.sol` | Push Chain | No (logic; proxy is upgradeable via migration) | +| UEAFactory | `src/uea/UEAFactory.sol` | Push Chain | Yes (OZ ERC1967) | +| UEAProxy | `src/uea/UEAProxy.sol` | Push Chain | No (upgraded via UEAMigration delegatecall) | +| UEAMigration | `src/uea/UEAMigration.sol` | Push Chain | No | +| CEA | `src/cea/CEA.sol` | External Chain | No (logic; proxy is upgradeable via migration) | +| CEAFactory | `src/cea/CEAFactory.sol` | External Chain | Yes (OZ ERC1967) | +| CEAProxy | `src/cea/CEAProxy.sol` | External Chain | No (upgraded via CEAMigration delegatecall) | +| CEAMigration | `src/cea/CEAMigration.sol` | External Chain | No | > **Excluded:** `src/mocks/` (test helpers) and `src/testnetV0/` (deprecated v0 contracts) > are out of scope for this threat model. @@ -116,14 +116,14 @@ not analysed here: ## Privilege Hierarchy -| Principal | Type | Contracts Affected | Powers | -|---|---|---|---| -| `UNIVERSAL_EXECUTOR_MODULE` (`0x14191...`) | Hardcoded address | `UniversalCore`, `PRC20`, `UEA_EVM`, `UEA_SVM` | Mint PRC20 tokens; deposit/refund/setChainMeta in UniversalCore; bypass all UEA signature checks and execute arbitrary multicall payloads through any UEA | -| `DEFAULT_ADMIN_ROLE` | OZ role (address assigned at init) | `UniversalCore`, `UEAFactory`, `CEAFactory` | Set all protocol config addresses (Uniswap, WPC, gateway, migration contracts); grant/revoke all other roles; upgrade proxy implementations | -| `MANAGER_ROLE` | OZ role | `UniversalCore` | Set per-chain and per-token operational parameters (gas limits, fee tiers, supported tokens, pool addresses) | -| `PAUSER_ROLE` | OZ role | `UniversalCore`, `UEAFactory`, `CEAFactory` | Call `pause()` and `unpause()` only | -| `universalGatewayPC` | Mutable address (admin-settable) | `UniversalCore` | Sole caller of `swapAndBurnGas`; can send arbitrary native value | -| `VAULT` | Mutable address (admin-settable); immutable per-CEA | `CEAFactory`, `CEA` | Sole deployer of CEAs; sole caller of `executeUniversalTx` on every CEA | +| Principal | Type | Contracts Affected | Powers | +| ------------------------------------------ | --------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `UNIVERSAL_EXECUTOR_MODULE` (`0x14191...`) | Hardcoded address | `UniversalCore`, `PRC20`, `UEA_EVM`, `UEA_SVM` | Mint PRC20 tokens; deposit/refund/setChainMeta in UniversalCore; bypass all UEA signature checks and execute arbitrary multicall payloads through any UEA | +| `DEFAULT_ADMIN_ROLE` | OZ role (address assigned at init) | `UniversalCore`, `UEAFactory`, `CEAFactory` | Set all protocol config addresses (Uniswap, WPC, gateway, migration contracts); grant/revoke all other roles; upgrade proxy implementations | +| `MANAGER_ROLE` | OZ role | `UniversalCore` | Set per-chain and per-token operational parameters (gas limits, fee tiers, supported tokens, pool addresses) | +| `PAUSER_ROLE` | OZ role | `UniversalCore`, `UEAFactory`, `CEAFactory` | Call `pause()` and `unpause()` only | +| `universalGatewayPC` | Mutable address (admin-settable) | `UniversalCore` | Sole caller of `swapAndBurnGas`; can send arbitrary native value | +| `VAULT` | Mutable address (admin-settable); immutable per-CEA | `CEAFactory`, `CEA` | Sole deployer of CEAs; sole caller of `executeUniversalTx` on every CEA | --- @@ -140,55 +140,55 @@ Uniswap V3 pool infrastructure. ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `depositPRC20Token` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUEModule`, `whenNotPaused` | -| `depositPRC20WithAutoSwap` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUEModule`, `whenNotPaused`, `nonReentrant` | -| `refundUnusedGas` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUEModule`, `whenNotPaused`, `nonReentrant` | -| `setChainMeta` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUEModule` (**no** `whenNotPaused`) | -| `swapAndBurnGas` | `universalGatewayPC` | `onlyGatewayPC`, `whenNotPaused`, `nonReentrant`, `payable` | -| `setProtocolFeeByToken` | `MANAGER_ROLE` | `onlyManager` | -| `setSupportedToken` | `MANAGER_ROLE` | `onlyManager` | -| `setGasPCPool` | `MANAGER_ROLE` | `onlyManager` | -| `setGasTokenPRC20` | `MANAGER_ROLE` | `onlyManager` | -| `setBaseGasLimitByChain` | `MANAGER_ROLE` | `onlyManager` | -| `setRescueFundsGasLimitByChain` | `MANAGER_ROLE` | `onlyManager` | -| `setAutoSwapSupported` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setWPC` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setUniversalGatewayPC` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setUniswapV3Addresses` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setDefaultFeeTier` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setSlippageTolerance` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setDefaultDeadlineMins` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setPauserRole` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `pause` | `PAUSER_ROLE` | OZ `Pausable` | -| `unpause` | `PAUSER_ROLE` | OZ `Pausable` | -| `receive()` | Anyone | `payable` | +| Function | Caller | Guard | +| ------------------------------- | --------------------------- | ----------------------------------------------------------- | +| `depositPRC20Token` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUEModule`, `whenNotPaused` | +| `depositPRC20WithAutoSwap` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUEModule`, `whenNotPaused`, `nonReentrant` | +| `refundUnusedGas` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUEModule`, `whenNotPaused`, `nonReentrant` | +| `setChainMeta` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUEModule` (**no** `whenNotPaused`) | +| `swapAndBurnGas` | `universalGatewayPC` | `onlyGatewayPC`, `whenNotPaused`, `nonReentrant`, `payable` | +| `setProtocolFeeByToken` | `MANAGER_ROLE` | `onlyManager` | +| `setSupportedToken` | `MANAGER_ROLE` | `onlyManager` | +| `setGasPCPool` | `MANAGER_ROLE` | `onlyManager` | +| `setGasTokenPRC20` | `MANAGER_ROLE` | `onlyManager` | +| `setBaseGasLimitByChain` | `MANAGER_ROLE` | `onlyManager` | +| `setRescueFundsGasLimitByChain` | `MANAGER_ROLE` | `onlyManager` | +| `setAutoSwapSupported` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setWPC` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setUniversalGatewayPC` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setUniswapV3Addresses` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setDefaultFeeTier` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setSlippageTolerance` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setDefaultDeadlineMins` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `grantRole(PAUSER_ROLE, addr)` | `DEFAULT_ADMIN_ROLE` | OZ `AccessControl` | +| `pause` | `PAUSER_ROLE` | OZ `Pausable` | +| `unpause` | `PAUSER_ROLE` | OZ `Pausable` | +| `receive()` | Anyone | `payable` | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| UC-T1 | Tampering | `setChainMeta` has no `whenNotPaused` guard — oracle/chain metadata is mutable even while the contract is paused, bypassing the intent of a pause | -| UC-T2 | Elevation of Privilege | `universalGatewayPC` is admin-mutable with no timelock; an attacker who compromises admin can point it to an attacker-controlled address that calls `swapAndBurnGas` with arbitrary native value | -| UC-T3 | Tampering | Uniswap V3 addresses (factory, router, quoter) are admin-mutable; replacement with malicious contracts enables fund diversion in `_autoSwap` and `swapAndBurnGas` | -| UC-T4 | Tampering | `WPC` address is admin-mutable; a malicious WETH-style contract at the new address can redirect or steal native PC during wrap/unwrap operations | -| UC-T5 | Denial of Service | `swapAndBurnGas` sends the native PC refund via `caller.call{value: refund}("")`; if `caller` reverts on ETH receive, the entire swap transaction reverts | -| UC-T6 | Denial of Service | `defaultDeadlineMins` is settable to `0` by admin; `deadline = block.timestamp + 0` makes all new swap transactions immediately expire at the EVM level | -| UC-T7 | Spoofing | `_validateParams` blocks `recipient == UNIVERSAL_EXECUTOR_MODULE` and `recipient == address(this)` but does not block other sensitive addresses (e.g., `universalGatewayPC`) | -| UC-T8 | Information Disclosure | `slippageTolerance` is stored on-chain but `minPCOut` is caller-supplied by the UE Module at call time; auditor should verify that the on-chain tolerance is enforced against the call-time value and not silently ignored | -| UC-T9 | Tampering | `defaultDeadlineMins == 0` path: `deadline = block.timestamp + 0 * 60` makes swaps expire immediately; subtly distinct from UC-T6 (that threat is about setting the var to 0; this is the runtime consequence when the zero value is used) | +| ID | STRIDE | Description | +| ----- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| UC-T1 | Tampering | `setChainMeta` has no `whenNotPaused` guard — oracle/chain metadata is mutable even while the contract is paused, bypassing the intent of a pause | +| UC-T2 | Elevation of Privilege | `universalGatewayPC` is admin-mutable with no timelock; an attacker who compromises admin can point it to an attacker-controlled address that calls `swapAndBurnGas` with arbitrary native value | +| UC-T3 | Tampering | Uniswap V3 addresses (factory, router, quoter) are admin-mutable; replacement with malicious contracts enables fund diversion in `_autoSwap` and `swapAndBurnGas` | +| UC-T4 | Tampering | `WPC` address is admin-mutable; a malicious WETH-style contract at the new address can redirect or steal native PC during wrap/unwrap operations | +| UC-T5 | Denial of Service | `swapAndBurnGas` sends the native PC refund via `caller.call{value: refund}("")`; if `caller` reverts on ETH receive, the entire swap transaction reverts | +| UC-T6 | Denial of Service | `defaultDeadlineMins` is settable to `0` by admin; `deadline = block.timestamp + 0` makes all new swap transactions immediately expire at the EVM level | +| UC-T7 | Spoofing | `_validateParams` blocks `recipient == UNIVERSAL_EXECUTOR_MODULE` and `recipient == address(this)` but does not block other sensitive addresses (e.g., `universalGatewayPC`) | +| UC-T8 | Information Disclosure | `slippageTolerance` is stored on-chain but `minPCOut` is caller-supplied by the UE Module at call time; auditor should verify that the on-chain tolerance is enforced against the call-time value and not silently ignored | +| UC-T9 | Tampering | `defaultDeadlineMins == 0` path: `deadline = block.timestamp + 0 * 60` makes swaps expire immediately; subtly distinct from UC-T6 (that threat is about setting the var to 0; this is the runtime consequence when the zero value is used) | ### External Dependencies -| Dependency | Mutability | Trust Assumption | -|---|---|---| -| Uniswap V3 Factory | Admin-mutable | Pool lookup; wrong address causes `getPool` to return `address(0)` for all pools | -| Uniswap V3 SwapRouter | Admin-mutable | Executes swaps; a malicious router can steal tokens passed to it | -| Uniswap V3 Quoter | Admin-mutable | View-only; used off-chain for quote estimation | -| WPC | Admin-mutable | Must wrap/unwrap native PC 1:1; uses `.transfer()` for withdrawals | -| PRC20 tokens | Per-call address (from chain config) | Must implement `deposit()` and `burn()` per `IPRC20`; called with external trust | -| `universalGatewayPC` | Admin-mutable | Sole caller of `swapAndBurnGas`; assumed honest | +| Dependency | Mutability | Trust Assumption | +| --------------------- | ------------------------------------ | -------------------------------------------------------------------------------- | +| Uniswap V3 Factory | Admin-mutable | Pool lookup; wrong address causes `getPool` to return `address(0)` for all pools | +| Uniswap V3 SwapRouter | Admin-mutable | Executes swaps; a malicious router can steal tokens passed to it | +| Uniswap V3 Quoter | Admin-mutable | View-only; used off-chain for quote estimation | +| WPC | Admin-mutable | Must wrap/unwrap native PC 1:1; uses `.transfer()` for withdrawals | +| PRC20 tokens | Per-call address (from chain config) | Must implement `deposit()` and `burn()` per `IPRC20`; called with external trust | +| `universalGatewayPC` | Admin-mutable | Sole caller of `swapAndBurnGas`; assumed honest | ### Invariants @@ -212,26 +212,26 @@ Minting is gated to `UNIVERSAL_CORE` (mutable) or `UNIVERSAL_EXECUTOR_MODULE` ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `deposit(to, amount)` | `UNIVERSAL_CORE` or `UNIVERSAL_EXECUTOR_MODULE` | `InvalidSender` custom error check | -| `burn(amount)` | Any address | Balance check only | -| `updateUniversalCore(newCore)` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUniversalExecutor` | -| `setName(name)` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUniversalExecutor` | -| `setSymbol(symbol)` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUniversalExecutor` | -| Standard ERC-20 (`transfer`, `transferFrom`, `approve`, etc.) | Any address | Balance / allowance checks | -| `initialize(...)` | Anyone (once) | OZ `initializer` | +| Function | Caller | Guard | +| ------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------- | +| `deposit(to, amount)` | `UNIVERSAL_CORE` or `UNIVERSAL_EXECUTOR_MODULE` | `InvalidSender` custom error check | +| `burn(amount)` | Any address | Balance check only | +| `updateUniversalCore(newCore)` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUniversalExecutor` | +| `setName(name)` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUniversalExecutor` | +| `setSymbol(symbol)` | `UNIVERSAL_EXECUTOR_MODULE` | `onlyUniversalExecutor` | +| Standard ERC-20 (`transfer`, `transferFrom`, `approve`, etc.) | Any address | Balance / allowance checks | +| `initialize(...)` | Anyone (once) | OZ `initializer` | ### Threats -| ID | STRIDE | Description | -|---|---|---| +| ID | STRIDE | Description | +| ------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | PRC-T1 | Elevation of Privilege | `UNIVERSAL_EXECUTOR_MODULE` (hardcoded, immutable) can call `deposit()` to mint unlimited tokens to any address; key compromise equals unbounded inflation with no on-chain recovery mechanism | -| PRC-T2 | Tampering | `UNIVERSAL_CORE` is mutable (settable by UE Module); replacing it with an attacker-controlled address opens a second unconstrained `deposit()` call path | -| PRC-T3 | Tampering | `_mint` and `_transfer` use `unchecked` arithmetic; no supply cap — `totalSupply` can reach `type(uint256).max` without reverting | -| PRC-T4 | Spoofing | `name` and `symbol` are mutable by UE Module post-deploy; renaming can mislead off-chain indexers, bridges, and users | -| PRC-T5 | Denial of Service | PRC20 has no pause mechanism; if `UniversalCore` is paused, `UNIVERSAL_EXECUTOR_MODULE` can still mint PRC20 tokens directly, bypassing the pause | -| PRC-T6 | Tampering | `transferFrom` deducts allowance after `_transfer` executes; the revert unwinds both, but confirm no ERC-777-style reentrancy hook is possible via a callback receiver during `_transfer` | +| PRC-T2 | Tampering | `UNIVERSAL_CORE` is mutable (settable by UE Module); replacing it with an attacker-controlled address opens a second unconstrained `deposit()` call path | +| PRC-T3 | Tampering | `_mint` and `_transfer` use `unchecked` arithmetic; no supply cap — `totalSupply` can reach `type(uint256).max` without reverting | +| PRC-T4 | Spoofing | `name` and `symbol` are mutable by UE Module post-deploy; renaming can mislead off-chain indexers, bridges, and users | +| PRC-T5 | Denial of Service | PRC20 has no pause mechanism; if `UniversalCore` is paused, `UNIVERSAL_EXECUTOR_MODULE` can still mint PRC20 tokens directly, bypassing the pause | +| PRC-T6 | Tampering | `transferFrom` deducts allowance after `_transfer` executes; the revert unwinds both, but confirm no ERC-777-style reentrancy hook is possible via a callback receiver during `_transfer` | ### External Dependencies @@ -257,20 +257,20 @@ Uniswap V3 swap paths that require an ERC-20 input. ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `deposit()` | Anyone | `payable` | -| `withdraw(wad)` | Any WPC holder | Balance `require` | -| `transfer`, `transferFrom`, `approve` | Anyone | Balance / allowance checks | -| `receive()` | Anyone | Auto-calls `deposit()` | +| Function | Caller | Guard | +| ------------------------------------- | -------------- | -------------------------- | +| `deposit()` | Anyone | `payable` | +| `withdraw(wad)` | Any WPC holder | Balance `require` | +| `transfer`, `transferFrom`, `approve` | Anyone | Balance / allowance checks | +| `receive()` | Anyone | Auto-calls `deposit()` | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| WPC-T1 | Denial of Service | `withdraw` uses `payable(msg.sender).transfer(wad)` (2300 gas stipend); fails for recipients with non-trivial `receive()` logic. `UniversalCore`'s `receive()` is simple (safe), but any future caller contract must be validated | -| WPC-T2 | Tampering | `totalSupply()` returns `address(this).balance`; force-feeding native PC via `selfdestruct` inflates `totalSupply` above `sum(balanceOf)`. Not exploitable as `withdraw` keys on `balanceOf` not `totalSupply`, but breaks the supply/balance equality invariant | -| WPC-T3 | Information Disclosure | `require` reverts use empty strings (`""`); provides no diagnostic context for monitoring or debugging tooling | +| ID | STRIDE | Description | +| ------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| WPC-T1 | Denial of Service | `withdraw` uses `payable(msg.sender).transfer(wad)` (2300 gas stipend); fails for recipients with non-trivial `receive()` logic. `UniversalCore`'s `receive()` is simple (safe), but any future caller contract must be validated | +| WPC-T2 | Tampering | `totalSupply()` returns `address(this).balance`; force-feeding native PC via `selfdestruct` inflates `totalSupply` above `sum(balanceOf)`. Not exploitable as `withdraw` keys on `balanceOf` not `totalSupply`, but breaks the supply/balance equality invariant | +| WPC-T3 | Information Disclosure | `require` reverts use empty strings (`""`); provides no diagnostic context for monitoring or debugging tooling | ### External Dependencies @@ -296,33 +296,33 @@ and delegatecall migration. ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `executeUniversalTx` (signature path) | Any address | ECDSA against `_universalAccountId.owner`; `nonReentrant` | -| `executeUniversalTx` (bypass path) | `UNIVERSAL_EXECUTOR_MODULE` | Hardcoded address check; `nonReentrant` | -| `initialize(id, factory)` | Anyone (once) | `_initialized` bool flag (not OZ `initializer`) | -| Multicall sub-calls | Arbitrary `calls[i].to` | No allowlist — any contract address permitted | -| Migration delegatecall | `payload.to == address(this)` and `payload.value == 0` | Inline checks only | +| Function | Caller | Guard | +| ------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | +| `executeUniversalTx` (signature path) | Any address | ECDSA against `_universalAccountId.owner`; `nonReentrant` | +| `executeUniversalTx` (bypass path) | `UNIVERSAL_EXECUTOR_MODULE` | Hardcoded address check; `nonReentrant` | +| `initialize(id, factory)` | Anyone (once) | `_initialized` bool flag (not OZ `initializer`) | +| Multicall sub-calls | Arbitrary `calls[i].to` | No allowlist — any contract address permitted | +| Migration delegatecall | `payload.to == address(this)` and `payload.value == 0` | Inline checks only | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| UEA-EVM-T1 | Spoofing | **Known finding F-02**: domain separator encodes the source chain's `chainId` but not `block.chainid`; CREATE2-deterministic UEA addresses are identical across Push Chain deployments — a valid signature on testnet replays on mainnet | -| UEA-EVM-T2 | Elevation of Privilege | `UNIVERSAL_EXECUTOR_MODULE` bypasses ECDSA entirely; can execute arbitrary multicall payloads through any UEA without owner consent (stated design assumption — document key controls) | -| UEA-EVM-T3 | Tampering | `_handleMigration` fetches `ueaFactory.UEA_MIGRATION_CONTRACT()` at execution time; if factory admin rotates this to a malicious contract, any triggered migration causes full `UEAProxy` storage takeover via delegatecall in the proxy's storage context | -| UEA-EVM-T4 | Tampering | Multicall `calls[i].to` has no allowlist; a user can target the proxy itself, re-entering via the proxy's fallback — confirm `nonReentrant` on `executeUniversalTx` covers this re-entry path | -| UEA-EVM-T5 | Repudiation | `PayloadExecuted` event emits the post-increment nonce; off-chain indexers must subtract 1 to recover the pre-execution nonce — verify alignment with all tooling and explorers | -| UEA-EVM-T6 | Denial of Service | `UNIVERSAL_EXECUTOR_MODULE` can consume any nonce (by executing any payload), invalidating any in-flight user-signed transaction carrying that nonce | -| UEA-EVM-T7 | Tampering | Exactly 4 bytes of multicall data triggers `_decodeCalls` returning an empty `Multicall[]`; nonce increments for a no-op execution, burning the nonce silently | +| ID | STRIDE | Description | +| ---------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| UEA-EVM-T1 | Spoofing | **Known finding F-02**: domain separator encodes the source chain's `chainId` but not `block.chainid`; CREATE2-deterministic UEA addresses are identical across Push Chain deployments — a valid signature on testnet replays on mainnet | +| UEA-EVM-T2 | Elevation of Privilege | `UNIVERSAL_EXECUTOR_MODULE` bypasses ECDSA entirely; can execute arbitrary multicall payloads through any UEA without owner consent (stated design assumption — document key controls) | +| UEA-EVM-T3 | Tampering | `_handleMigration` fetches `ueaFactory.UEA_MIGRATION_CONTRACT()` at execution time; if factory admin rotates this to a malicious contract, any triggered migration causes full `UEAProxy` storage takeover via delegatecall in the proxy's storage context | +| UEA-EVM-T4 | Tampering | Multicall `calls[i].to` has no allowlist; a user can target the proxy itself, re-entering via the proxy's fallback — confirm `nonReentrant` on `executeUniversalTx` covers this re-entry path | +| UEA-EVM-T5 | Repudiation | `PayloadExecuted` event emits the post-increment nonce; off-chain indexers must subtract 1 to recover the pre-execution nonce — verify alignment with all tooling and explorers | +| UEA-EVM-T6 | Denial of Service | `UNIVERSAL_EXECUTOR_MODULE` can consume any nonce (by executing any payload), invalidating any in-flight user-signed transaction carrying that nonce | +| UEA-EVM-T7 | Tampering | Exactly 4 bytes of multicall data triggers `_decodeCalls` returning an empty `Multicall[]`; nonce increments for a no-op execution, burning the nonce silently | ### External Dependencies -| Dependency | Mutability | Trust Assumption | -|---|---|---| -| OZ ECDSA library | Immutable | `recover` returns `address(0)` on malformed sig; verify `verifyUniversalPayloadSignature` treats `address(0)` as false, not a match | -| `ueaFactory.UEA_MIGRATION_CONTRACT()` | Factory admin-controlled | See UEA-EVM-T3 | -| Target contracts (single-call and multicall) | Untrusted | Arbitrary external calls with arbitrary calldata and value | +| Dependency | Mutability | Trust Assumption | +| -------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| OZ ECDSA library | Immutable | `recover` returns `address(0)` on malformed sig; verify `verifyUniversalPayloadSignature` treats `address(0)` as false, not a match | +| `ueaFactory.UEA_MIGRATION_CONTRACT()` | Factory admin-controlled | See UEA-EVM-T3 | +| Target contracts (single-call and multicall) | Untrusted | Arbitrary external calls with arbitrary calldata and value | ### Invariants @@ -346,32 +346,32 @@ field is a 32-byte Solana public key (not an Ethereum address). ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `executeUniversalTx` (signature path) | Any address | Ed25519 via precompile against `_universalAccountId.owner`; `nonReentrant` | -| `executeUniversalTx` (bypass path) | `UNIVERSAL_EXECUTOR_MODULE` | Hardcoded address check; `nonReentrant` | -| `initialize(id, factory)` | Anyone (once) | `_initialized` bool flag | -| Multicall sub-calls | Arbitrary `calls[i].to` | No allowlist | -| Migration delegatecall | `payload.to == address(this)` and `payload.value == 0` | Inline checks | +| Function | Caller | Guard | +| ------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------- | +| `executeUniversalTx` (signature path) | Any address | Ed25519 via precompile against `_universalAccountId.owner`; `nonReentrant` | +| `executeUniversalTx` (bypass path) | `UNIVERSAL_EXECUTOR_MODULE` | Hardcoded address check; `nonReentrant` | +| `initialize(id, factory)` | Anyone (once) | `_initialized` bool flag | +| Multicall sub-calls | Arbitrary `calls[i].to` | No allowlist | +| Migration delegatecall | `payload.to == address(this)` and `payload.value == 0` | Inline checks | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| UEA-SVM-T1 | Spoofing | Same cross-deployment replay as UEA-EVM-T1; domain separator also omits `block.chainid` in the SVM implementation | -| UEA-SVM-T2 | Denial of Service | `staticcall` to `VERIFIER_PRECOMPILE`; if the precompile is unavailable on this chain or network fork, all SVM UEA executions revert with `PrecompileCallFailed` — no fallback path exists | -| UEA-SVM-T3 | Spoofing | `_universalAccountId.owner` is a raw `bytes` field (32-byte Solana pubkey); if the encoding passed to the precompile mismatches the expected format (padded vs. raw), all SVM signature verifications silently return false | -| UEA-SVM-T4 | Elevation of Privilege | Same UE Module bypass as UEA-EVM-T2; applies to Solana-origin accounts identically | -| UEA-SVM-T5 | Tampering | Same migration attack as UEA-EVM-T3; `_handleMigration` reads `ueaFactory.UEA_MIGRATION_CONTRACT()` at execution time | -| UEA-SVM-T6 | Denial of Service | Same nonce-burning as UEA-EVM-T6; UE Module can invalidate any pending user-signed SVM transaction | +| ID | STRIDE | Description | +| ---------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| UEA-SVM-T1 | Spoofing | Same cross-deployment replay as UEA-EVM-T1; domain separator also omits `block.chainid` in the SVM implementation | +| UEA-SVM-T2 | Denial of Service | `staticcall` to `VERIFIER_PRECOMPILE`; if the precompile is unavailable on this chain or network fork, all SVM UEA executions revert with `PrecompileCallFailed` — no fallback path exists | +| UEA-SVM-T3 | Spoofing | `_universalAccountId.owner` is a raw `bytes` field (32-byte Solana pubkey); if the encoding passed to the precompile mismatches the expected format (padded vs. raw), all SVM signature verifications silently return false | +| UEA-SVM-T4 | Elevation of Privilege | Same UE Module bypass as UEA-EVM-T2; applies to Solana-origin accounts identically | +| UEA-SVM-T5 | Tampering | Same migration attack as UEA-EVM-T3; `_handleMigration` reads `ueaFactory.UEA_MIGRATION_CONTRACT()` at execution time | +| UEA-SVM-T6 | Denial of Service | Same nonce-burning as UEA-EVM-T6; UE Module can invalidate any pending user-signed SVM transaction | ### External Dependencies -| Dependency | Mutability | Trust Assumption | -|---|---|---| -| Ed25519 precompile at `0x00...ca` | Hardcoded (Push Chain-specific) | Must be live and implement the expected input/output ABI; no fallback if unavailable | -| `ueaFactory.UEA_MIGRATION_CONTRACT()` | Factory admin-controlled | See UEA-SVM-T5 | -| Target contracts (single-call and multicall) | Untrusted | Arbitrary external calls | +| Dependency | Mutability | Trust Assumption | +| -------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------ | +| Ed25519 precompile at `0x00...ca` | Hardcoded (Push Chain-specific) | Must be live and implement the expected input/output ABI; no fallback if unavailable | +| `ueaFactory.UEA_MIGRATION_CONTRACT()` | Factory admin-controlled | See UEA-SVM-T5 | +| Target contracts (single-call and multicall) | Untrusted | Arbitrary external calls | ### Invariants @@ -397,34 +397,34 @@ mappings. Maintains bidirectional `UOA ↔ UEA` address index. ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `deployUEA(id)` | Anyone | `whenNotPaused` | -| `pause` / `unpause` | `PAUSER_ROLE` | OZ `Pausable` | -| `setPauserRole` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setUEAProxyImplementation` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setUEAMigrationContract` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `registerNewChain` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `registerUEA` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `registerMultipleUEA` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| Function | Caller | Guard | +| --------------------------- | -------------------- | --------------- | +| `deployUEA(id)` | Anyone | `whenNotPaused` | +| `pause` / `unpause` | `PAUSER_ROLE` | OZ `Pausable` | +| `grantRole(PAUSER_ROLE, a)` | `DEFAULT_ADMIN_ROLE` | OZ `AccessControl` | +| `setUEAProxyImplementation` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setUEAMigrationContract` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `registerNewChain` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `registerUEA` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `registerMultipleUEA` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| UF-T1 | Tampering | `setUEAMigrationContract` has no timelock; admin can instantly point all UEAs to a malicious migration contract — any subsequently triggered migration causes full `UEAProxy` storage takeover via delegatecall | -| UF-T2 | Tampering | `setUEAProxyImplementation` changes the clone template for future `deployUEA` calls; does not affect existing deployed UEAs but all new deployments use the replacement template | -| UF-T3 | Spoofing | `getOriginForUEA(addr)` returns a synthetic Push Chain identity `{eip155, 42101, abi.encodePacked(addr)}` for non-UEA addresses; callers using this for authorization may conflate native Push Chain accounts with registered UEAs | -| UF-T4 | Tampering | `registerUEA` updates `UEA_VM[vmHash]` — a shared implementation pointer for all future proxies of that VM type; existing proxy `UEA_LOGIC_SLOT` values are unaffected | -| UF-T5 | Denial of Service | Pausing the factory blocks `deployUEA`; if first-time UEA deployment is required as part of the inbound execution pipeline, a pause prevents all new users from executing their first transaction | -| UF-T6 | Tampering | Salt = `keccak256(abi.encode(_id))` where `_id` contains string fields; auditor should verify that ABI encoding of `UniversalAccountId` is collision-free — two semantically distinct structs with identical byte encoding would share a salt and collide on CREATE2 | +| ID | STRIDE | Description | +| ----- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| UF-T1 | Tampering | `setUEAMigrationContract` has no timelock; admin can instantly point all UEAs to a malicious migration contract — any subsequently triggered migration causes full `UEAProxy` storage takeover via delegatecall | +| UF-T2 | Tampering | `setUEAProxyImplementation` changes the clone template for future `deployUEA` calls; does not affect existing deployed UEAs but all new deployments use the replacement template | +| UF-T3 | Spoofing | `getOriginForUEA(addr)` returns a synthetic Push Chain identity `{eip155, 42101, abi.encodePacked(addr)}` for non-UEA addresses; callers using this for authorization may conflate native Push Chain accounts with registered UEAs | +| UF-T4 | Tampering | `registerUEA` updates `UEA_VM[vmHash]` — a shared implementation pointer for all future proxies of that VM type; existing proxy `UEA_LOGIC_SLOT` values are unaffected | +| UF-T5 | Denial of Service | Pausing the factory blocks `deployUEA`; if first-time UEA deployment is required as part of the inbound execution pipeline, a pause prevents all new users from executing their first transaction | +| UF-T6 | Tampering | Salt = `keccak256(abi.encode(_id))` where `_id` contains string fields; auditor should verify that ABI encoding of `UniversalAccountId` is collision-free — two semantically distinct structs with identical byte encoding would share a salt and collide on CREATE2 | ### External Dependencies -| Dependency | Mutability | Trust Assumption | -|---|---|---| -| OZ Clones library | Immutable | `cloneDeterministic` reverts on address collision (existing bytecode at target) | -| `UEA_PROXY_IMPLEMENTATION` template | Admin-mutable | Must be a valid `UEAProxy` with an `initializeUEA` function | +| Dependency | Mutability | Trust Assumption | +| ----------------------------------- | ------------- | ------------------------------------------------------------------------------- | +| OZ Clones library | Immutable | `cloneDeterministic` reverts on address collision (existing bytecode at target) | +| `UEA_PROXY_IMPLEMENTATION` template | Admin-mutable | Must be a valid `UEAProxy` with an `initializeUEA` function | ### Invariants @@ -445,18 +445,18 @@ All calls are delegated to the implementation. No post-init admin functions. ### Access Control -| Function | Caller | Guard | -|---|---|---| +| Function | Caller | Guard | +| ----------------------- | --------------------------------------------------------- | -------------------------------------------- | | `initializeUEA(_logic)` | Anyone (intended: `UEAFactory` atomically in `deployUEA`) | OZ `initializer` + explicit slot-empty check | -| All other calls | Anyone | Delegated to `_implementation()` | +| All other calls | Anyone | Delegated to `_implementation()` | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| UP-T1 | Elevation of Privilege | `initializeUEA` is callable by anyone on the un-cloned template contract; verify whether the template itself is initialized or left uninitialised (an uninitialised template is susceptible to direct hijack) | -| UP-T2 | Tampering | `UEA_LOGIC_SLOT` is non-EIP-1967; any future logic contract that accidentally writes to this storage offset corrupts the implementation pointer — migration contracts write here intentionally, verify exact constant match across all contracts | -| UP-T3 | Tampering | No admin path post-init to rotate implementation; upgrade requires a user-triggered migration payload — users who never trigger migration remain permanently on old logic, even after critical patches | +| ID | STRIDE | Description | +| ----- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| UP-T1 | Elevation of Privilege | `initializeUEA` is callable by anyone on the un-cloned template contract; verify whether the template itself is initialized or left uninitialised (an uninitialised template is susceptible to direct hijack) | +| UP-T2 | Tampering | `UEA_LOGIC_SLOT` is non-EIP-1967; any future logic contract that accidentally writes to this storage offset corrupts the implementation pointer — migration contracts write here intentionally, verify exact constant match across all contracts | +| UP-T3 | Tampering | No admin path post-init to rotate implementation; upgrade requires a user-triggered migration payload — users who never trigger migration remain permanently on old logic, even after critical patches | ### Invariants @@ -478,19 +478,19 @@ both EVM and SVM UEAs via separate `migrateUEAEVM()` and `migrateUEASVM()` funct ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `migrateUEAEVM()` | Via delegatecall from a `UEAProxy` | `onlyDelegateCall` modifier | -| `migrateUEASVM()` | Via delegatecall from a `UEAProxy` | `onlyDelegateCall` modifier | -| Direct calls to either function | Anyone | Reverts — `onlyDelegateCall` | +| Function | Caller | Guard | +| ------------------------------- | ---------------------------------- | ---------------------------- | +| `migrateUEAEVM()` | Via delegatecall from a `UEAProxy` | `onlyDelegateCall` modifier | +| `migrateUEASVM()` | Via delegatecall from a `UEAProxy` | `onlyDelegateCall` modifier | +| Direct calls to either function | Anyone | Reverts — `onlyDelegateCall` | ### Threats -| ID | STRIDE | Description | -|---|---|---| +| ID | STRIDE | Description | +| ----- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | UM-T1 | Elevation of Privilege | `onlyDelegateCall` prevents direct calls to this contract but does not prevent _any other contract_ from delegatecalling `UEAMigration` in its own storage context; any contract that knows this address can corrupt its own slot at `UEA_LOGIC_SLOT`'s storage offset | -| UM-T2 | Tampering | Constructor validates both implementations have `extcodesize > 0`; if either implementation is later `selfdestruct`-ed (on chains where this is still possible), a triggered migration writes a dangling, empty implementation pointer | -| UM-T3 | Tampering | UEA_SVM triggers migration via `abi.encodeWithSignature("migrateUEASVM()")` and UEA_EVM via `"migrateUEAEVM()"` — verify no typos in these string literals; a mismatch causes all migrations to silently revert (function selector not found) | +| UM-T2 | Tampering | Constructor validates both implementations have `extcodesize > 0`; if either implementation is later `selfdestruct`-ed (on chains where this is still possible), a triggered migration writes a dangling, empty implementation pointer | +| UM-T3 | Tampering | UEA_SVM triggers migration via `abi.encodeWithSignature("migrateUEASVM()")` and UEA_EVM via `"migrateUEAEVM()"` — verify no typos in these string literals; a mismatch causes all migrations to silently revert (function selector not found) | ### Invariants @@ -507,44 +507,44 @@ both EVM and SVM UEAs via separate `migrateUEAEVM()` and `migrateUEASVM()` funct ### Role Logic contract for external-chain execution accounts. All execution is gated to -the `VAULT` address set at initialization. `isExecuted[txId]` provides per-CEA +the `VAULT` address set at initialization. `isExecuted[subTxId]` provides per-CEA replay protection keyed on Vault-supplied transaction IDs. The self-call path `sendUniversalTxToUEA` allows a CEA to initiate outbound bridging back to Push Chain. ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `initializeCEA(...)` | Anyone (once) | `_initialized` bool flag | -| `executeUniversalTx(txId, ...)` | `VAULT` | `onlyVault`, `nonReentrant`, `payable` | -| `sendUniversalTxToUEA(token, amount, payload)` | `address(this)` only | `msg.sender == address(this)` inline check | -| `receive()` | Anyone | `payable` | -| Multicall sub-calls | Arbitrary `calls[i].to` | `to != address(0)`; self-call with `value != 0` reverts | +| Function | Caller | Guard | +| ---------------------------------------------- | ----------------------- | ------------------------------------------------------- | +| `initializeCEA(...)` | Anyone (once) | `_initialized` bool flag | +| `executeUniversalTx(subTxId, ...)` | `VAULT` | `onlyVault`, `nonReentrant`, `payable` | +| `sendUniversalTxToUEA(token, amount, payload)` | `address(this)` only | `msg.sender == address(this)` inline check | +| `receive()` | Anyone | `payable` | +| Multicall sub-calls | Arbitrary `calls[i].to` | `to != address(0)`; self-call with `value != 0` reverts | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| CEA-T1 | Tampering | **Known finding F-01**: the ERC20 path of `sendUniversalTxToUEA` (lines 145-150) calls the gateway without first calling `IERC20(token).approve(UNIVERSAL_GATEWAY, amount)`; any gateway implementation using `transferFrom` will revert, permanently locking ERC20 tokens inside the CEA | -| CEA-T2 | Tampering | `_handleMigration` fetches `factory.CEA_MIGRATION_CONTRACT()` at execution time; factory admin rotating this to a malicious contract enables full `CEAProxy` storage takeover via delegatecall (same pattern as UEA-EVM-T3) | -| CEA-T3 | Elevation of Privilege | `VAULT` is immutable per-CEA (set at `initializeCEA` time); `CEAFactory.setVault` only affects new deployments — existing CEAs cannot rotate their Vault even if it is compromised | -| CEA-T4 | Tampering | `_handleSingleCall` forwards `msg.value` to the target: `recipient.call{value: msg.value}(payload)`; if the target reverts, the EVM refunds the value to the Vault — verify Vault-side accounting correctly handles this partial-execution refund | -| CEA-T5 | Spoofing | `originCaller == pushAccount` is the sole authorization check for outbound calls; an incorrect `pushAccount` set at `initializeCEA` permanently locks or unlocks the CEA to the wrong owner with no rotation path | -| CEA-T6 | Tampering | `isExecuted[txId] = true` is set before `_handleExecution`; a revert unwinds the entire transaction including the flag — replay protection is transaction-atomic (safe), but auditors should confirm this covers all execution paths | -| CEA-T7 | Denial of Service | An entire multicall batch reverts on any single failed sub-call; a crafted batch where an early step transfers value and a late step fails would be fully rolled back — value sent with the `payable` call is refunded by EVM revert | +| ID | STRIDE | Description | +| ------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CEA-T1 | Tampering | **Known finding F-01**: the ERC20 path of `sendUniversalTxToUEA` (lines 145-150) calls the gateway without first calling `IERC20(token).approve(UNIVERSAL_GATEWAY, amount)`; any gateway implementation using `transferFrom` will revert, permanently locking ERC20 tokens inside the CEA | +| CEA-T2 | Tampering | `_handleMigration` fetches `factory.CEA_MIGRATION_CONTRACT()` at execution time; factory admin rotating this to a malicious contract enables full `CEAProxy` storage takeover via delegatecall (same pattern as UEA-EVM-T3) | +| CEA-T3 | Elevation of Privilege | `VAULT` is immutable per-CEA (set at `initializeCEA` time); `CEAFactory.setVault` only affects new deployments — existing CEAs cannot rotate their Vault even if it is compromised | +| CEA-T4 | Tampering | `_handleSingleCall` forwards `msg.value` to the target: `recipient.call{value: msg.value}(payload)`; if the target reverts, the EVM refunds the value to the Vault — verify Vault-side accounting correctly handles this partial-execution refund | +| CEA-T5 | Spoofing | `originCaller == pushAccount` is the sole authorization check for outbound calls; an incorrect `pushAccount` set at `initializeCEA` permanently locks or unlocks the CEA to the wrong owner with no rotation path | +| CEA-T6 | Tampering | `isExecuted[subTxId] = true` is set before `_handleExecution`; a revert unwinds the entire transaction including the flag — replay protection is transaction-atomic (safe), but auditors should confirm this covers all execution paths | +| CEA-T7 | Denial of Service | An entire multicall batch reverts on any single failed sub-call; a crafted batch where an early step transfers value and a late step fails would be fully rolled back — value sent with the `payable` call is refunded by EVM revert | ### External Dependencies -| Dependency | Mutability | Trust Assumption | -|---|---|---| -| `VAULT` | Immutable per-CEA (set at init) | Controls all execution; compromise equals arbitrary execution from any Vault-managed CEA | -| `UNIVERSAL_GATEWAY` | Immutable per-CEA (set at init) | Destination for outbound sends; must not require ERC20 `approve` before `transferFrom` without it being provided (see CEA-T1) | -| `factory.CEA_MIGRATION_CONTRACT()` | Factory admin-controlled | See CEA-T2 | -| Target contracts (multicall / single-call) | Untrusted | Arbitrary external calls | +| Dependency | Mutability | Trust Assumption | +| ------------------------------------------ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `VAULT` | Immutable per-CEA (set at init) | Controls all execution; compromise equals arbitrary execution from any Vault-managed CEA | +| `UNIVERSAL_GATEWAY` | Immutable per-CEA (set at init) | Destination for outbound sends; must not require ERC20 `approve` before `transferFrom` without it being provided (see CEA-T1) | +| `factory.CEA_MIGRATION_CONTRACT()` | Factory admin-controlled | See CEA-T2 | +| Target contracts (multicall / single-call) | Untrusted | Arbitrary external calls | ### Invariants -1. `isExecuted[txId]` transitions only `false → true`, never reset +1. `isExecuted[subTxId]` transitions only `false → true`, never reset 2. `originCaller == pushAccount` is the sole execution authorization check — no signature verification 3. `sendUniversalTxToUEA` is only reachable via `msg.sender == address(this)` (multicall self-call path inside `nonReentrant` scope) 4. `CEA_LOGIC_SLOT` written by `CEAMigration` must match `CEAProxy.CEA_LOGIC_SLOT` @@ -563,26 +563,26 @@ Maintains bidirectional `pushAccount ↔ CEA` mappings. Stores shared config ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `deployCEA(pushAccount)` | `VAULT` | `onlyVault`, `whenNotPaused` | -| `pause` / `unpause` | `PAUSER_ROLE` | OZ `Pausable` | -| `setPauserRole` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setVault` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setCEAProxyImplementation` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setCEAImplementation` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setUniversalGateway` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | -| `setCEAMigrationContract` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| Function | Caller | Guard | +| --------------------------- | -------------------- | ---------------------------- | +| `deployCEA(pushAccount)` | `VAULT` | `onlyVault`, `whenNotPaused` | +| `pause` / `unpause` | `PAUSER_ROLE` | OZ `Pausable` | +| `grantRole(PAUSER_ROLE, a)` | `DEFAULT_ADMIN_ROLE` | OZ `AccessControl` | +| `setVault` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setCEAProxyImplementation` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setCEAImplementation` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setUniversalGateway` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | +| `setCEAMigrationContract` | `DEFAULT_ADMIN_ROLE` | `onlyAdmin` | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| CF-T1 | Tampering | `setCEAMigrationContract` has no timelock; admin instant rotation to a malicious contract enables storage takeover for all future CEA migrations triggered by any CEA (same criticality as UF-T1) | -| CF-T2 | Tampering | `setVault` changes deployment authority immediately; the old Vault loses `deployCEA` access; existing CEA Vaults are unaffected (they hold the address from init) | -| CF-T3 | Elevation of Privilege | `deployCEA` accepts any non-zero `pushAccount` from the Vault; the factory cannot verify this is a real UEA on Push Chain — the Vault is fully trusted for address correctness | -| CF-T4 | Denial of Service | If a deployed CEA's code is destroyed (e.g., via `selfdestruct` on chains that still support it), `_hasCode` returns false but `pushAccountToCEA[pushAccount]` remains non-zero; a subsequent `deployCEA` for the same `pushAccount` will attempt CREATE2 which reverts (bytecode already at that address) — permanent lock-out for that `pushAccount` | -| CF-T5 | Tampering | `setUniversalGateway` updates the factory's `UNIVERSAL_GATEWAY` for new deployments only; existing CEAs carry their original gateway address, creating state divergence where old and new CEAs use different gateways concurrently | +| ID | STRIDE | Description | +| ----- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| CF-T1 | Tampering | `setCEAMigrationContract` has no timelock; admin instant rotation to a malicious contract enables storage takeover for all future CEA migrations triggered by any CEA (same criticality as UF-T1) | +| CF-T2 | Tampering | `setVault` changes deployment authority immediately; the old Vault loses `deployCEA` access; existing CEA Vaults are unaffected (they hold the address from init) | +| CF-T3 | Elevation of Privilege | `deployCEA` accepts any non-zero `pushAccount` from the Vault; the factory cannot verify this is a real UEA on Push Chain — the Vault is fully trusted for address correctness | +| CF-T4 | Denial of Service | If a deployed CEA's code is destroyed (e.g., via `selfdestruct` on chains that still support it), `_hasCode` returns false but `pushAccountToCEA[pushAccount]` remains non-zero; a subsequent `deployCEA` for the same `pushAccount` will attempt CREATE2 which reverts (bytecode already at that address) — permanent lock-out for that `pushAccount` | +| CF-T5 | Tampering | `setUniversalGateway` updates the factory's `UNIVERSAL_GATEWAY` for new deployments only; existing CEAs carry their original gateway address, creating state divergence where old and new CEAs use different gateways concurrently | ### Invariants @@ -604,18 +604,18 @@ All calls are delegated to the implementation. ### Access Control -| Function | Caller | Guard | -|---|---|---| +| Function | Caller | Guard | +| ---------------------------- | --------------------------------------------------------- | ---------------------------------------------------------- | | `initializeCEAProxy(_logic)` | Anyone (intended: `CEAFactory` atomically in `deployCEA`) | OZ `initializer` + explicit zero-address check on `_logic` | -| All other calls | Anyone | Delegated to `_implementation()` | +| All other calls | Anyone | Delegated to `_implementation()` | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| CP-T1 | Tampering | `CEA_LOGIC_SLOT` must match `CEAMigration.CEA_LOGIC_SLOT` exactly; a constant mismatch between the two contracts corrupts the implementation pointer on every migration | -| CP-T2 | Elevation of Privilege | Same template-hijack consideration as UP-T1: `initializeCEAProxy` is callable by anyone on the un-cloned template contract if it is not already initialized | -| CP-T3 | Tampering | No post-init upgrade path other than migration; CEAs that are never triggered for migration remain on old logic indefinitely, even after critical patches | +| ID | STRIDE | Description | +| ----- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CP-T1 | Tampering | `CEA_LOGIC_SLOT` must match `CEAMigration.CEA_LOGIC_SLOT` exactly; a constant mismatch between the two contracts corrupts the implementation pointer on every migration | +| CP-T2 | Elevation of Privilege | Same template-hijack consideration as UP-T1: `initializeCEAProxy` is callable by anyone on the un-cloned template contract if it is not already initialized | +| CP-T3 | Tampering | No post-init upgrade path other than migration; CEAs that are never triggered for migration remain on old logic indefinitely, even after critical patches | ### Invariants @@ -637,18 +637,18 @@ context. `onlyDelegateCall` is enforced via immutable ### Access Control -| Function | Caller | Guard | -|---|---|---| -| `migrateCEA()` | Via delegatecall from a `CEAProxy` | `onlyDelegateCall` modifier | -| Direct calls to `migrateCEA()` | Anyone | Reverts — `onlyDelegateCall` | +| Function | Caller | Guard | +| ------------------------------ | ---------------------------------- | ---------------------------- | +| `migrateCEA()` | Via delegatecall from a `CEAProxy` | `onlyDelegateCall` modifier | +| Direct calls to `migrateCEA()` | Anyone | Reverts — `onlyDelegateCall` | ### Threats -| ID | STRIDE | Description | -|---|---|---| -| CM-T1 | Elevation of Privilege | Same as UM-T1: any contract knowing this address can delegatecall `migrateCEA()` to corrupt its own storage at `CEA_LOGIC_SLOT`'s offset | -| CM-T2 | Tampering | Constructor validates `_ceaImplementation` has code at deploy time; if the implementation is later destroyed, a triggered migration writes a dangling empty implementation pointer | -| CM-T3 | Tampering | CEA's `_handleMigration` encodes `abi.encodeWithSignature("migrateCEA()")`; a typo in this string literal causes all CEA migrations to silently revert (function selector not found) | +| ID | STRIDE | Description | +| ----- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| CM-T1 | Elevation of Privilege | Same as UM-T1: any contract knowing this address can delegatecall `migrateCEA()` to corrupt its own storage at `CEA_LOGIC_SLOT`'s offset | +| CM-T2 | Tampering | Constructor validates `_ceaImplementation` has code at deploy time; if the implementation is later destroyed, a triggered migration writes a dangling empty implementation pointer | +| CM-T3 | Tampering | CEA's `_handleMigration` encodes `abi.encodeWithSignature("migrateCEA()")`; a typo in this string literal causes all CEA migrations to silently revert (function selector not found) | ### Invariants @@ -664,10 +664,10 @@ context. `onlyDelegateCall` is enforced via immutable ### Summary -| ID | Confidence | Location | Title | Status | -|---|---|---|---|---| -| F-01 | 80 | `src/cea/CEA.sol` L145-150 | Missing ERC20 approval before gateway call | Open | -| F-02 | 75 | `src/uea/UEA_EVM.sol` L80-93, `src/uea/UEA_SVM.sol` | Domain separator omits Push Chain `chainId` | Open | +| ID | Confidence | Location | Title | Status | +| ---- | ---------- | --------------------------------------------------- | ------------------------------------------- | ------ | +| F-01 | 80 | `src/cea/CEA.sol` L145-150 | Missing ERC20 approval before gateway call | Open | +| F-02 | 75 | `src/uea/UEA_EVM.sol` L80-93, `src/uea/UEA_SVM.sol` | Domain separator omits Push Chain `chainId` | Open | --- diff --git a/docs/UniversalCore.md b/docs/UniversalCore.md index d291181..5d7b1b2 100644 --- a/docs/UniversalCore.md +++ b/docs/UniversalCore.md @@ -3,7 +3,7 @@ ## Contract Locations - **UniversalCore**: [`src/UniversalCore.sol`](../src/UniversalCore.sol) -- **IUniversalCore Interface**: [`src/interfaces/IUniversalCore.sol`](../src/interfaces/IUniversalCore.sol) +- **IUniversalCore Interface**: [`src/Interfaces/IUniversalCore.sol`](../src/Interfaces/IUniversalCore.sol) - **PRC20**: [`src/PRC20.sol`](../src/PRC20.sol) - **WPC (Wrapped PC)**: [`src/WPC.sol`](../src/WPC.sol) @@ -28,7 +28,7 @@ UniversalCore maintains an on-chain oracle of external chain state. For each sup | `timestampObservedAtByChainNamespace` | Timestamp when the observation was recorded | | `gasTokenPRC20ByChainNamespace` | PRC-20 address of the chain's native gas token (e.g. pETH for Ethereum) | -The Universal Executor Module periodically calls `setChainMeta(chainNamespace, price, chainHeight, observedAt)` to push fresh external chain data on-chain, updating both `gasPriceByChainNamespace` and `chainHeightByChainNamespace` in a single call. This makes UniversalCore the single source of truth for external chain gas pricing and block height within Push Chain's contract layer. +The Universal Executor Module periodically calls `setChainMeta(chainNamespace, price, chainHeight)` to push fresh external chain data on-chain, updating `gasPriceByChainNamespace` and `chainHeightByChainNamespace` in a single call. The observation timestamp is derived internally from `block.timestamp` and stored in `timestampObservedAtByChainNamespace`. This makes UniversalCore the single source of truth for external chain gas pricing and block height within Push Chain's contract layer. This oracle data drives fee computation: when a user initiates an outbound transaction, the gateway queries `getOutboundTxGasAndFees(prc20, gasLimit)` which reads the stored gas price and multiplies it by the gas limit to produce the fee denominated in the destination chain's gas token. @@ -60,8 +60,8 @@ The UE Module is a protocol-level system address that executes on behalf of the |---|---| | `depositPRC20Token(prc20, amount, recipient)` | Mint PRC-20 tokens to a recipient address on inbound | | `depositPRC20WithAutoSwap(prc20, amount, recipient, fee, minPCOut, deadline)` | Mint PRC-20 and swap to native PC in one step | -| `setChainMeta(chainNamespace, price, chainHeight, observedAt)` | Update gas price and block height oracle data for an external chain | -| `refundUnusedGas(recipient, amount)` | Refund unused gas to a recipient after execution | +| `setChainMeta(chainNamespace, price, chainHeight)` | Update gas price and block height oracle data for an external chain (observation timestamp is set to `block.timestamp` internally) | +| `refundUnusedGas(gasToken, amount, recipient, withSwap, fee, minPCOut)` | Refund unused gas: either mint `gasToken` PRC-20 directly to `recipient`, or swap back to native PC via Uniswap V3 when `withSwap = true` | ### Manager Role (`MANAGER_ROLE`) @@ -71,26 +71,27 @@ Managers handle operational configuration that changes with external chain condi | Function | Purpose | |---|---| -| `setGasTokenPRC20(chainNamespace, prc20)` | Map a chain namespace to its gas token PRC-20 | +| `setGasTokenPRC20(chainNamespace, prc20)` | Map a chain namespace to its gas token PRC-20 (resets `gasPriceByChainNamespace` to `0` to force explicit reconfiguration) | | `setGasPCPool(chainNamespace, gasToken, fee)` | Register a Uniswap V3 pool for PC/gas-token swaps | -| `setSupportedToken(prc20, supported)` | Mark a PRC-20 token as officially supported | | `setBaseGasLimitByChain(chainNamespace, gasLimit)` | Set the minimum base gas limit for TSS execution on a chain | | `setRescueFundsGasLimitByChain(chainNamespace, gasLimit)` | Set the fixed gas limit for rescue operations on a chain | +| `setMaxStalenessByChain(chainNamespace, maxStaleness)` | Set the maximum acceptable age (seconds) of stored gas data before fee quotes revert as stale (`0` disables the check, opt-in) | | `setProtocolFeeByToken(prc20, fee)` | Set the protocol fee (in native PC) for a PRC-20 token | ### Admin Role (`DEFAULT_ADMIN_ROLE`) -Granted to the deployer at initialization. Controls contract-level configuration and emergency operations. +Granted to the deployer at initialization. Controls contract-level configuration (Uniswap addresses, fee tier, WPC, gateway address) and role administration. Note: pause/unpause authority is **not** held by the admin — it is restricted to `PAUSER_ROLE`. | Function | Purpose | |---|---| | `setAutoSwapSupported(token, supported)` | Enable/disable auto-swap for a PRC-20 token | -| `setWPCContractAddress(addr)` | Update the Wrapped PC token address | -| `setUniswapV3Addresses(factory, swapRouter, quoter)` | Update Uniswap V3 infrastructure addresses | -| `setDefaultFeeTier(token, feeTier)` | Set default Uniswap V3 fee tier for a token | -| `setSlippageTolerance(token, tolerance)` | Set slippage tolerance in basis points | +| `setWPC(addr)` | Update the Wrapped PC token address | +| `setUniswapV3Addresses(factory, swapRouter)` | Update Uniswap V3 infrastructure addresses | +| `setDefaultFeeTier(token, feeTier)` | Set default Uniswap V3 fee tier for a token (allowed tiers: 100, 500, 3000, 10000) | | `setDefaultDeadlineMins(minutesValue)` | Set default swap deadline | -| `pause()` / `unpause()` | Emergency pause/unpause all deposit operations | +| `setUniversalGatewayPC(addr)` | Update the address authorized to call `swapAndBurnGas` | +| `grantRole(PAUSER_ROLE, addr)` | Grant `PAUSER_ROLE` to an address (guardian) — inherited from OZ AccessControl | +| `revokeRole(PAUSER_ROLE, addr)` | Revoke `PAUSER_ROLE` from an address — inherited from OZ AccessControl | ### Gateway (`universalGatewayPC`) @@ -98,7 +99,7 @@ Not an OZ `AccessControl` role. `universalGatewayPC` is a mutable address stored | Function | Purpose | |---|---| -| `swapAndBurnGas(gasToken, vault, fee, gasFee, protocolFee, deadline, caller)` | Swap PC for gas token, burn gas fee, send protocol fee to vault | +| `swapAndBurnGas(gasToken, fee, gasFee, deadline, caller)` | Swap PC for `gasToken`, burn the `gasFee` amount, refund unused PC to `caller` | ### Pauser Role (`PAUSER_ROLE`) @@ -130,6 +131,8 @@ getOutboundTxGasAndFees(prc20, gasLimitWithBaseLimit) |--> look up chainNamespace from prc20.SOURCE_CHAIN_NAMESPACE() |--> look up gasToken from gasTokenPRC20ByChainNamespace[chainNamespace] |--> look up gasPrice from gasPriceByChainNamespace[chainNamespace] + |--> if maxStalenessByChainNamespace[chainNamespace] > 0, enforce freshness of gas data + | (reverts with StaleGasData if block.timestamp > observedAt + maxStaleness) | |--> gasFee = gasPrice * gasLimitWithBaseLimit (denominated in gas token units) |--> protocolFee = protocolFeeByToken[prc20] (flat fee in native PC) @@ -148,7 +151,10 @@ getRescueFundsGasLimit(prc20) | |--> look up chainNamespace from prc20.SOURCE_CHAIN_NAMESPACE() |--> look up rescueGasLimit from rescueFundsGasLimitByChainNamespace[chainNamespace] - |--> look up gasToken, gasPrice, protocolFee (same as outbound) + |--> look up gasToken and gasPrice (protocol fee is NOT applied on the rescue path) + |--> if maxStalenessByChainNamespace[chainNamespace] > 0, enforce freshness of gas data + | + |--> gasFee = gasPrice * rescueGasLimit | '--> returns (gasToken, gasFee, rescueGasLimit, gasPrice, chainNamespace) ``` @@ -157,7 +163,7 @@ getRescueFundsGasLimit(prc20) ## Swap-and-Burn: Gas Fee vs Protocol Fee -Outbound transactions require fee settlement. The user pays in native PC, which gets swapped to the destination chain's gas token PRC-20 via Uniswap V3. The resulting gas tokens are then split into two portions with different destinations: +Outbound transactions require fee settlement. The user pays in native PC. `UniversalGatewayPC` sends the `protocolFee` portion directly to `VaultPC` in native PC, and forwards the remaining PC (intended to cover the gas fee) to `UniversalCore.swapAndBurnGas`, which swaps it into the destination chain's gas token PRC-20 via Uniswap V3 and burns it. The two fee components therefore settle through different paths: ### Gas Fee (burned) @@ -178,8 +184,8 @@ User pays native PC | v UniversalGatewayPC - | - | calls swapAndBurnGas{value: pcAmount}(gasToken, vault, fee, gasFee, protocolFee, deadline, caller) + | (pays protocolFee to VaultPC directly in native PC, then:) + | calls swapAndBurnGas{value: pcAmount}(gasToken, fee, gasFee, deadline, caller) v UniversalCore | @@ -187,16 +193,20 @@ UniversalCore |--> 2. Approve Uniswap V3 router to spend WPC |--> 3. Swap WPC -> gasToken via exactOutputSingle | (swap exactly gasFee worth of gas token) + |--> 4. Clear router allowance (forceApprove 0) | - |--> 4. BURN gasFee portion: IPRC20(gasToken).burn(gasFee) + |--> 5. BURN gasFee portion: IPRC20(gasToken).burn(gasFee) | - |--> 5. REFUND unused PC: unwrap leftover WPC, send native PC back to caller + |--> 6. REFUND unused PC: unwrap leftover WPC, send native PC back to caller | - '--> emit SwapAndBurnGas(gasToken, vault, pcUsed, gasFee, protocolFee, fee, caller) + '--> emit SwapAndBurnGas(gasToken, pcIn, gasFee, fee, caller) + returns (gasTokenOut, refund) ``` The swap uses `exactOutputSingle` — the caller specifies exactly how much gas token output is needed (`gasFee`), and any unused PC input is refunded directly to the caller address. This ensures users never overpay. +Note: `swapAndBurnGas` does not receive or route the protocol fee. `UniversalGatewayPC` pays the `protocolFee` (in native PC) directly to `VaultPC` before invoking `swapAndBurnGas`; only the `gasFee` burn and PC refund happen inside this function. The event therefore emits `(gasToken, pcIn, gasFee, fee, caller)` and does not include vault or protocol-fee fields. + ### Why burn vs transfer? - **Burn (gas fee)**: The gas fee represents real execution cost on the destination chain. Burning the equivalent PRC-20 on Push Chain keeps the wrapped token supply in sync with actual external-chain liabilities. The protocol (via validators/TSS) covers the real gas on the destination side. diff --git a/docs/addresses/arbitrum_sepolia.md b/docs/addresses/arbitrum_sepolia.md new file mode 100644 index 0000000..01c1bdb --- /dev/null +++ b/docs/addresses/arbitrum_sepolia.md @@ -0,0 +1,12 @@ +# Arbitrum Sepolia (Chain ID: 421614) + +| Contract | Address | +| --------------------------- | -------------------------------------------- | +| CEA (logic) | `0x2c933Ff6FBcD479055F344691bc628F51DcE871A` | +| CEAProxy (clone template) | `0x512d1B8C185a0Fd533a69f8973A3DD6513233009` | +| CEAFactory (implementation) | `0xd8335e762E42b7f9610293707d6d8A6b97578bFb` | +| ProxyAdmin | `0x6349546d872d483A35bdD165c9ef85757e064D4E` | +| CEAFactory (proxy) | `0x88DC189275078Cf509E4Cc773F089c8ad07b7EA2` | +| CEA_V2 (logic) | `0xe23741BffF1dAac6f98cEC84ce3EBAfeF1Cd5965` | +| CEAMigration | `0x81f33160020AaDF47000E85915d332943b69F9f9` | +| CEA (post-audit) | `0x0D74144cED066a1f3BA94887ED9a6443F7bD26c5` | diff --git a/docs/addresses/base_sepolia.md b/docs/addresses/base_sepolia.md new file mode 100644 index 0000000..25da623 --- /dev/null +++ b/docs/addresses/base_sepolia.md @@ -0,0 +1,12 @@ +# Base Sepolia (Chain ID: 84532) + +| Contract | Address | +| --------------------------- | -------------------------------------------- | +| CEA (logic) | `0x733078bA1dFDDDB68A9E082696A256AEcBFb26b8` | +| CEAProxy (clone template) | `0x6c9Cfef12155bEE91ecD6d0C8f516cABA2890656` | +| CEAFactory (implementation) | `0xd26E793Ef931EB62AeBc6e87DE1FEEF4fDbA01F5` | +| ProxyAdmin | `0x413A39fFA85657A25768799f7fd64A917eceDe48` | +| CEAFactory (proxy) | `0x0A75ca7736b488Eb41675ADc3b3156BACF659F55` | +| CEA_V2 (logic) | `0x6085C657A6d12F789a388D96730D248b74437730` | +| CEAMigration | `0x95c453fDFf55Afc5754c1fA95Ad6607273D71B20` | +| CEA (post-audit) | `0xF9EAf33bAB2f7f21bEe1712Ce9688Cf29053BfFb` | diff --git a/docs/addresses/bsc_testnet.md b/docs/addresses/bsc_testnet.md index cccbda3..202cea1 100644 --- a/docs/addresses/bsc_testnet.md +++ b/docs/addresses/bsc_testnet.md @@ -1,19 +1,12 @@ - # BSC Testnet (Chain ID: 97) +# BSC Testnet (Chain ID: 97) - | Contract | Address | - |---|---| - | CEA (logic) | `0x60A4140429446E515aB15A02f8FB46F7c81fE3b2` | - | CEAProxy (clone template) | `0xcF66462F97daea2c0Ef549c542D54C6F4dc3e8B1` | - | CEAFactory (implementation) | `0x95E6c8aACe87e8401cd4bDFF1B9DaC53059C8808` | - | ProxyAdmin | `0xBD083Bf209D8c791aF71E96ace5aF67D15d83Cef` | - | CEAFactory (proxy) | `0xac52b7be327C1e6A617937CFfE90269aDccD211d` | - -## New Latest CEA Contracts for BSC - - | Contract | Address | - |---|---| - | CEA (logic) | `0xa66C8832bB97203E07B65d876d2ceAe3801709B6` | - | CEAProxy (clone template) | `0xBDF06996BA23AE797a4aA9C8C5994D313D763a7c` | - | CEAFactory (implementation) | `0xC0D35725Dd054B09931740DC231cDea89B0FEd3b` | - | ProxyAdmin | `0xf33CBb6a1c1D511dF40764063a11978D640C41A7` | - | CEAFactory (proxy) | `0xe2182dae2dc11cBF6AA6c8B1a7f9c8315A6B0719` | +| Contract | Address | +| --------------------------- | -------------------------------------------- | +| CEA (logic) | `0xdC3A3a18a17EB4FDa9cF34a8CEee8540e6F2b5Fd` | +| CEAProxy (clone template) | `0xBDF06996BA23AE797a4aA9C8C5994D313D763a7c` | +| CEAFactory (implementation) | `0xC0D35725Dd054B09931740DC231cDea89B0FEd3b` | +| ProxyAdmin | `0xf33CBb6a1c1D511dF40764063a11978D640C41A7` | +| CEAFactory (proxy) | `0xe2182dae2dc11cBF6AA6c8B1a7f9c8315A6B0719` | +| CEA_V2 (logic) | `0x102B1652ABEDC1c1761355F1Fc71c8487c3a9168` | +| CEAMigration | `0x2a06BF2A9C19dacbb38852f846B42e278e82e855` | +| CEA (post-audit) | `0x8FAB1Da91Bd45F4DaF3D50C47A38b49bE9afEff7` | diff --git a/docs/addresses/eth_sepolia.md b/docs/addresses/eth_sepolia.md new file mode 100644 index 0000000..39032ed --- /dev/null +++ b/docs/addresses/eth_sepolia.md @@ -0,0 +1,12 @@ +# Ethereum Sepolia (Chain ID: 11155111) + +| Contract | Address | +| --------------------------- | -------------------------------------------- | +| CEA (logic) | `0x1939376ce03998F638b8760c7a13C9A379A053C0` | +| CEAProxy (clone template) | `0x0a4F7be62B56830070266be81114edB93e68DA09` | +| CEAFactory (implementation) | `0xe5B51807f2252A5Ea9B591fE02285954446c8cAD` | +| ProxyAdmin | `0xF920e3D1420885A117Cb59830d0474aD5690dd82` | +| CEAFactory (proxy) | `0x8ED594A83301FEc545fC6c19fc12cF7111777029` | +| CEA_V2 (logic) | `0x2235df0189F720E9dbF1E31685eb7b6221E0fdD7` | +| CEAMigration | `0x97BCEba9c6f13B0E12Fde0E4D2697F74A79899de` | +| CEA (post-audit) | `0x2d10cdB85989a199a0255E5dc55491B1bdd95A45` | diff --git a/docs/addresses/sepolia.md b/docs/addresses/sepolia.md deleted file mode 100644 index e69de29..0000000 diff --git a/foundry.toml b/foundry.toml index 0e8ad26..3515680 100644 --- a/foundry.toml +++ b/foundry.toml @@ -16,10 +16,13 @@ auto_detect_solc = false evm_version = "shanghai" via_ir = true -no_match_coverage = "(PRC20V0\\.sol|UniversalCoreV0\\.sol|ReceiverExample\\.sol|src/(libraries|[Ii]nterfaces|mocks)/|test/)" +no_match_coverage = "(PRC20V0\\.sol|testnetV0/UniversalCore\\.sol|ReceiverExample\\.sol|src/(libraries|[Ii]nterfaces|mocks)/|test/)" fs_permissions = [{ access = "read-write", path = "deployments/" }] +[fmt] +ignore = ["src/**"] + [fuzz] runs = 1024 max_test_rejects = 65536 diff --git a/scripts/cea/deployCEA.s.sol b/scripts/cea/deployCEA.s.sol index 3c55d6b..475b2d9 100644 --- a/scripts/cea/deployCEA.s.sol +++ b/scripts/cea/deployCEA.s.sol @@ -11,7 +11,7 @@ import {CEA} from "../../src/cea/CEA.sol"; * @dev Deploys only the CEA logic contract (no factory, proxy, or admin). * Useful when upgrading the CEA implementation on an existing CEAFactory. * - * After deployment, call `CEAFactory.updateCEAImplementation(newCEAImpl)` + * After deployment, call `CEAFactory.setCEAImplementation(newCEAImpl)` * on the factory to point clones at the new implementation. * * CONFIGURATION: @@ -62,19 +62,13 @@ contract DeployCEAScript is Script { console.log(json); // Write to file - string memory filename = string( - abi.encodePacked( - "deployments/cea-impl-", - vm.toString(chainId), - ".json" - ) - ); + string memory filename = string(abi.encodePacked("deployments/cea-impl-", vm.toString(chainId), ".json")); vm.writeFile(filename, json); console.log("\nDeployment saved to:", filename); console.log("\n=== Deployment Complete ==="); console.log( - "NEXT STEP: Call CEAFactory.updateCEAImplementation(", + "NEXT STEP: Call CEAFactory.setCEAImplementation(", address(ceaImplementation), ") on the factory proxy to activate this implementation." ); @@ -111,7 +105,7 @@ contract DeployCEAScript is Script { * Update the factory to use the new implementation: * * cast send \ - * "updateCEAImplementation(address)" \ + * "setCEAImplementation(address)" \ * --rpc-url $RPC_URL \ * --private-key $KEY */ diff --git a/scripts/cea/deployCEAMigration.s.sol b/scripts/cea/deployCEAMigration.s.sol new file mode 100644 index 0000000..1364c81 --- /dev/null +++ b/scripts/cea/deployCEAMigration.s.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {CEA_V2} from "../../src/testnetV0/CEA_V2.sol"; +import {CEAMigration} from "../../src/cea/CEAMigration.sol"; +import {CEAFactory} from "../../src/cea/CEAFactory.sol"; + +/** + * @title DeployCEAMigrationScript + * @notice Deploys CEA_V2 + CEAMigration and sets it in the CEAFactory. + * + * @dev Steps: + * 1. Deploy CEA_V2 (new implementation) + * 2. Deploy CEAMigration(ceaV2Address) + * 3. Call CEAFactory.updateCEAMigrationContract(migrationAddress) + * + * CONFIGURATION: + * Environment variables needed: KEY, RPC_URL + */ +contract DeployCEAMigrationScript is Script { + // ============================================================================ + // DEPLOYMENT PARAMETERS + // ============================================================================ + + address public CEA_FACTORY_PROXY = 0xe2182dae2dc11cBF6AA6c8B1a7f9c8315A6B0719; + + function run() external { + uint256 chainId = block.chainid; + uint256 deployerKey = uint256(vm.envBytes32("KEY")); + address deployer = vm.addr(deployerKey); + + console.log("=== CEA Migration Deployment ==="); + console.log("Chain ID:", chainId); + console.log("Deployer:", deployer); + console.log("CEAFactory Proxy:", CEA_FACTORY_PROXY); + console.log(""); + + vm.startBroadcast(deployerKey); + + // 1. Deploy CEA_V2 + CEA_V2 ceaV2 = new CEA_V2(); + console.log("[1/3] CEA_V2:", address(ceaV2)); + + // 2. Deploy CEAMigration + CEAMigration migration = new CEAMigration(address(ceaV2)); + console.log("[2/3] CEAMigration:", address(migration)); + + // 3. Set migration contract in factory + CEAFactory factory = CEAFactory(CEA_FACTORY_PROXY); + factory.updateCEAMigrationContract(address(migration)); + console.log("[3/3] updateCEAMigrationContract called"); + + vm.stopBroadcast(); + + // Post-deployment verification + console.log("\n=== Post-Deployment Verification ==="); + + address verifiedMigration = factory.CEA_MIGRATION_CONTRACT(); + address verifiedImpl = migration.CEA_IMPLEMENTATION(); + + console.log( + "CEA_MIGRATION_CONTRACT:", + verifiedMigration, + verifiedMigration == address(migration) ? "[OK]" : "[MISMATCH]" + ); + console.log("CEA_IMPLEMENTATION:", verifiedImpl, verifiedImpl == address(ceaV2) ? "[OK]" : "[MISMATCH]"); + + require(verifiedMigration == address(migration), "Migration contract mismatch"); + require(verifiedImpl == address(ceaV2), "CEA implementation mismatch"); + + // JSON output + string memory json = string( + abi.encodePacked( + "{\n", + ' "chainId": ', + vm.toString(chainId), + ",\n", + ' "deployer": "', + vm.toString(deployer), + '",\n', + ' "ceaV2": "', + vm.toString(address(ceaV2)), + '",\n', + ' "ceaMigration": "', + vm.toString(address(migration)), + '",\n', + ' "ceaFactoryProxy": "', + vm.toString(CEA_FACTORY_PROXY), + '"\n', + "}" + ) + ); + console.log("\n=== Deployment Addresses (JSON) ==="); + console.log(json); + + string memory filename = string(abi.encodePacked("deployments/", vm.toString(chainId), "_cea_migration.json")); + vm.writeFile(filename, json); + console.log("\nDeployment saved to:", filename); + + console.log("\n=== Deployment Complete ==="); + } +} + +/* + * ============================================================================ + * DEPLOYMENT COMMAND + * ============================================================================ + * + * forge script scripts/cea/deployCEAMigration.s.sol:DeployCEAMigrationScript \ + * --rpc-url $BSC_TESTNET_RPC_URL \ + * --private-key $KEY \ + * --broadcast \ + * -vvvv + * + * ============================================================================ + * VERIFICATION COMMANDS + * ============================================================================ + * + * 1. Verify CEA_V2: + * forge verify-contract src/testnetV0/CEA_V2.sol:CEA_V2 \ + * --chain-id 97 --etherscan-api-key $BSCSCAN_API_KEY + * + * 2. Verify CEAMigration: + * forge verify-contract src/cea/CEAMigration.sol:CEAMigration \ + * --chain-id 97 --etherscan-api-key $BSCSCAN_API_KEY \ + * --constructor-args $(cast abi-encode "constructor(address)" ) + */ diff --git a/scripts/uea/deployFactory.s.sol b/scripts/uea/deployFactory.s.sol index 5eb9a0a..ca7e04d 100644 --- a/scripts/uea/deployFactory.s.sol +++ b/scripts/uea/deployFactory.s.sol @@ -68,7 +68,7 @@ contract DeployUEAFactoryScript is Script { UEAProxy proxyImpl = new UEAProxy(); console.log("UEAProxy Implementation deployed at:", address(proxyImpl)); - factory.setUEAProxyImplementation(address(proxyImpl)); + factory.updateUEAProxyImplementation(address(proxyImpl)); console.log("UEAProxy impl set in the factory"); // 1. Deploy UEA_EVM implementation @@ -87,7 +87,7 @@ contract DeployUEAFactoryScript is Script { UEAProxy ueaProxy = new UEAProxy(); console.log("UEAProxy deployed at:", address(ueaProxy)); - factory.setUEAProxyImplementation(address(ueaProxy)); + factory.updateUEAProxyImplementation(address(ueaProxy)); console.log("UEAProxy set in the factory"); vm.stopBroadcast(); diff --git a/src/Interfaces/ICEA.sol b/src/Interfaces/ICEA.sol index cf955a8..b2cdda5 100644 --- a/src/Interfaces/ICEA.sol +++ b/src/Interfaces/ICEA.sol @@ -13,13 +13,13 @@ interface ICEA { // ========================= /// @notice Emitted for each execution step (multicall or single call). - /// @param txId Unique transaction identifier + /// @param subTxId Unique transaction identifier /// @param universalTxId Universal transaction identifier on Universal Gateway /// @param originCaller Original caller on source chain (Push Chain) /// @param target Target contract address for this call step /// @param data Calldata executed on target contract event UniversalTxExecuted( - bytes32 indexed txId, bytes32 indexed universalTxId, address indexed originCaller, address target, bytes data + bytes32 indexed subTxId, bytes32 indexed universalTxId, address indexed originCaller, address target, bytes data ); /// @notice Emitted when funds are sent from CEA to its UEA on Push Chain. @@ -45,10 +45,10 @@ interface ICEA { /// @return Initialization status function isInitialized() external view returns (bool); - /// @notice Returns whether a given txId has been executed. - /// @param txId Transaction identifier to check + /// @notice Returns whether a given subTxId has been executed. + /// @param subTxId Transaction identifier to check /// @return True if already executed - function isExecuted(bytes32 txId) external view returns (bool); + function isExecuted(bytes32 subTxId) external view returns (bool); // ========================= // CEA_2: VAULT OPERATIONS @@ -57,13 +57,13 @@ interface ICEA { /// @notice Executes a universal transaction. /// @dev Payload can be MULTICALL, MIGRATION, or SINGLE CALL format. /// Only callable by Vault. SDK crafts correct payload format. - /// @param txId Unique transaction identifier (must not be executed before) + /// @param subTxId Unique transaction identifier (must not be executed before) /// @param universalTxId Universal transaction identifier for cross-chain tracking /// @param originCaller Origin caller address (must match pushAccount) /// @param recipient Target contract for single-call. Ignored for multicall/migration. /// @param payload Multicall, migration, or single call payload function executeUniversalTx( - bytes32 txId, + bytes32 subTxId, bytes32 universalTxId, address originCaller, address recipient, @@ -87,10 +87,8 @@ interface ICEA { // CEA_4: INITIALIZER // ========================= - /// @notice Initializes this CEA with its identity and references. + /// @notice Initializes this CEA with its identity and factory reference. /// @param _pushAccount Address of the UEA on Push Chain - /// @param _vault Address of the Vault on this chain - /// @param _universalGateway Address of the Universal Gateway - /// @param _factory Address of the CEA factory - function initializeCEA(address _pushAccount, address _vault, address _universalGateway, address _factory) external; + /// @param _factory Address of the CEA factory (source of truth for VAULT and gateway) + function initializeCEA(address _pushAccount, address _factory) external; } diff --git a/src/Interfaces/ICEAFactory.sol b/src/Interfaces/ICEAFactory.sol index 6093a40..cff3387 100644 --- a/src/Interfaces/ICEAFactory.sol +++ b/src/Interfaces/ICEAFactory.sol @@ -41,10 +41,6 @@ interface ICEAFactory { /// @param newContract New migration contract address event CEAMigrationContractUpdated(address indexed oldContract, address indexed newContract); - /// @notice Emitted when the PAUSER_ROLE is granted to a new address. - /// @param pauser Address that was granted the pauser role - event PauserRoleGranted(address indexed pauser); - // ========================= // CF_1: VIEW FUNCTIONS // ========================= @@ -53,6 +49,10 @@ interface ICEAFactory { /// @return Vault address function VAULT() external view returns (address); + /// @notice Returns the current Universal Gateway address. + /// @return Universal Gateway address + function UNIVERSAL_GATEWAY() external view returns (address); + /// @notice Returns the CEA proxy implementation used for clones. /// @return CEA proxy implementation address function CEA_PROXY_IMPLEMENTATION() external view returns (address); diff --git a/src/Interfaces/IPRC20.sol b/src/Interfaces/IPRC20.sol index 919fb0c..aaa908f 100644 --- a/src/Interfaces/IPRC20.sol +++ b/src/Interfaces/IPRC20.sol @@ -24,6 +24,8 @@ interface IPRC20 { event Deposit(bytes from, address to, uint256 amount); event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); + event NameUpdated(string oldName, string newName); + event SymbolUpdated(string oldSymbol, string newSymbol); // ========================= // PRC20_1: ERC-20 METADATA diff --git a/src/Interfaces/IUEAFactory.sol b/src/Interfaces/IUEAFactory.sol index 5402469..e04c383 100644 --- a/src/Interfaces/IUEAFactory.sol +++ b/src/Interfaces/IUEAFactory.sol @@ -30,9 +30,11 @@ interface IUEAFactory { /// @param vmHash VM type hash event UEARegistered(bytes32 indexed chainHash, address ueaLogic, bytes32 vmHash); - /// @notice Emitted when the PAUSER_ROLE is granted to a new address. - /// @param pauser Address that was granted the pauser role - event PauserRoleGranted(address indexed pauser); + /// @notice Emitted when an existing UEA implementation is replaced. + /// @param vmHash VM hash whose implementation is being updated + /// @param previousUEA Previous UEA implementation address + /// @param newUEA New UEA implementation address + event UEAImplementationUpdated(bytes32 indexed vmHash, address previousUEA, address newUEA); // ========================= // UF_1: VIEW FUNCTIONS @@ -72,6 +74,9 @@ interface IUEAFactory { /// @return Migration contract address function UEA_MIGRATION_CONTRACT() external view returns (address); + /// @notice Push Chain ID used in the `getOriginForUEA` synthetic fallback. + function pushChainId() external view returns (string memory); + // ========================= // UF_2: DEPLOYMENT // ========================= diff --git a/src/Interfaces/IUniversalCore.sol b/src/Interfaces/IUniversalCore.sol index 068996a..70d014d 100644 --- a/src/Interfaces/IUniversalCore.sol +++ b/src/Interfaces/IUniversalCore.sol @@ -12,7 +12,6 @@ interface IUniversalCore { event SetChainMeta(string chainNamespace, uint256 price, uint256 chainHeight, uint256 observedAt); event SetGasToken(string chainNamespace, address prc20); event SetDefaultDeadlineMins(uint256 minutesValue); - event SetSupportedToken(address indexed prc20, bool supported); event SetGasPCPool(string chainNamespace, address pool, uint24 fee); event DepositPRC20WithAutoSwap( address prc20, uint256 amountIn, address pcToken, uint256 amountOut, uint24 fee, address recipient @@ -21,13 +20,23 @@ interface IUniversalCore { event SetProtocolFeeByToken(address indexed token, uint256 fee); event SetBaseGasLimitByChain(string chainNamespace, uint256 gasLimit); event SetRescueFundsGasLimitByChain(string chainNamespace, uint256 gasLimit); + event SetMaxStalenessByChain(string chainNamespace, uint256 maxStaleness); + event SetL1GasFeeByChain(string chainNamespace, uint256 l1GasFee); + event SetTssFundMigrationGasLimitByChain(string chainNamespace, uint256 gasLimit); event RefundUnusedGas( address indexed gasToken, uint256 amount, address indexed recipient, bool swapped, uint256 pcOut ); - /// @notice Emitted when the PAUSER_ROLE is granted to a new address. - /// @param pauser Address that was granted the pauser role - event PauserRoleGranted(address indexed pauser); + event SetAutoSwapSupported(address indexed token, bool supported); + event SetWPC(address indexed oldAddr, address indexed newAddr); + event SetUniversalGatewayPC(address indexed oldAddr, address indexed newAddr); + event SetUniswapV3Addresses(address factory, address swapRouter); + event SetDefaultFeeTier(address indexed token, uint24 feeTier); + + /// @notice Emitted when stuck native PC is rescued by admin. + /// @param to Recipient of the rescued PC + /// @param amount Amount of native PC rescued + event RescueNativePC(address indexed to, uint256 amount); // ========================= // UC_1: UE MODULE FUNCTIONS @@ -105,11 +114,6 @@ interface IUniversalCore { // UC_3: PUBLIC GETTERS // ========================= - /// @notice Check if a PRC20 token is supported. - /// @param prc20 PRC20 token address - /// @return supported Whether the token is supported - function isSupportedToken(address prc20) external view returns (bool supported); - /// @notice Get gas token PRC20 address for a chain. /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @return gasToken Gas token address @@ -144,10 +148,18 @@ interface IUniversalCore { /// @return protocolFee Protocol fee in native PC from protocolFeeByToken mapping /// @return gasPrice Gas price on the external chain /// @return chainNamespace Source chain namespace + /// @return gasLimitUsed Effective gas limit used to compute gasFee function getOutboundTxGasAndFees(address _prc20, uint256 gasLimitWithBaseLimit) external view - returns (address gasToken, uint256 gasFee, uint256 protocolFee, uint256 gasPrice, string memory chainNamespace); + returns ( + address gasToken, + uint256 gasFee, + uint256 protocolFee, + uint256 gasPrice, + string memory chainNamespace, + uint256 gasLimitUsed + ); /// @notice Get rescue funds gas limit, fee, and related config for a PRC20 token. /// @param _prc20 PRC20 address @@ -175,12 +187,22 @@ interface IUniversalCore { /// @notice Set protocol fee (in native PC) for a token. /// @param token Token address /// @param fee Protocol fee amount in native PC - function setProtocolFeeByToken(address token, uint256 fee) external; + function updateProtocolFeeByToken(address token, uint256 fee) external; /// @notice Set rescue funds gas limit for a specific chain. /// @param chainNamespace Chain Namespace /// @param gasLimit Rescue funds gas limit for the chain - function setRescueFundsGasLimitByChain(string memory chainNamespace, uint256 gasLimit) external; + function updateRescueFundsGasLimitByChain(string memory chainNamespace, uint256 gasLimit) external; + + /// @notice Set L1 gas fee for a specific chain. + /// @param chainNamespace Chain Namespace + /// @param l1GasFee L1 gas fee for the chain (in gas token units) + function setL1GasFeeByChain(string memory chainNamespace, uint256 l1GasFee) external; + + /// @notice Set TSS migration gas limit for a specific chain. + /// @param chainNamespace Chain Namespace + /// @param gasLimit TSS migration gas limit for the chain + function setTssFundMigrationGasLimitByChain(string memory chainNamespace, uint256 gasLimit) external; /// @notice Get the UniversalGatewayPC address. function universalGatewayPC() external view returns (address); diff --git a/src/PRC20.sol b/src/PRC20.sol index 369c42f..08c22c3 100644 --- a/src/PRC20.sol +++ b/src/PRC20.sol @@ -5,6 +5,7 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {IPRC20} from "./interfaces/IPRC20.sol"; import {PRC20Errors, CommonErrors} from "./libraries/Errors.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; /** * @title PRC20 (Push Chain Synthetic Token) @@ -144,8 +145,6 @@ contract PRC20 is IPRC20, Initializable { /// @inheritdoc IPRC20 function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { - _transfer(sender, recipient, amount); - uint256 currentAllowance = _allowances[sender][msg.sender]; if (currentAllowance < amount) revert PRC20Errors.LowAllowance(); unchecked { @@ -153,6 +152,8 @@ contract PRC20 is IPRC20, Initializable { } emit Approval(sender, msg.sender, _allowances[sender][msg.sender]); + _transfer(sender, recipient, amount); + return true; } @@ -171,10 +172,13 @@ contract PRC20 is IPRC20, Initializable { if (msg.sender != UNIVERSAL_CORE && msg.sender != UNIVERSAL_EXECUTOR_MODULE) { revert PRC20Errors.InvalidSender(); } + if (PausableUpgradeable(UNIVERSAL_CORE).paused()) { + revert PRC20Errors.CorePaused(); + } _mint(to, amount); - emit Deposit(abi.encodePacked(UNIVERSAL_EXECUTOR_MODULE), to, amount); + emit Deposit(abi.encodePacked(msg.sender), to, amount); return true; } @@ -193,13 +197,17 @@ contract PRC20 is IPRC20, Initializable { /// @notice Update token name. /// @param newName New name string function setName(string memory newName) external onlyUniversalExecutor { + string memory oldName = _name; _name = newName; + emit NameUpdated(oldName, newName); } /// @notice Update token symbol. /// @param newSymbol New symbol string function setSymbol(string memory newSymbol) external onlyUniversalExecutor { + string memory oldSymbol = _symbol; _symbol = newSymbol; + emit SymbolUpdated(oldSymbol, newSymbol); } // ========================= diff --git a/src/UniversalCore.sol b/src/UniversalCore.sol index 82c1586..33af5de 100644 --- a/src/UniversalCore.sol +++ b/src/UniversalCore.sol @@ -3,7 +3,9 @@ pragma solidity 0.8.26; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { + AccessControlDefaultAdminRulesUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlDefaultAdminRulesUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; @@ -24,12 +26,17 @@ import {UniversalCoreErrors, CommonErrors} from "./libraries/Errors.sol"; * - Setting up the gas price for each chain. * - Maintaining a registry of Uniswap V3 pools for each token pair. * @dev All imperative functionalities are handled by the Universal Executor Module. + * + * Access control: AccessControlDefaultAdminRulesUpgradeable (2-day delay). + * Roles: DEFAULT_ADMIN_ROLE (root), ROLE_MANAGER_ROLE (grants operational roles), + * UVCORE_ADMIN_ROLE (protocol config), OPERATOR_ROLE (address setters + unpause), + * PAUSER_ROLE (pause only). */ contract UniversalCore is IUniversalCore, Initializable, ReentrancyGuardUpgradeable, - AccessControlUpgradeable, + AccessControlDefaultAdminRulesUpgradeable, PausableUpgradeable { using SafeERC20 for IERC20; @@ -41,9 +48,17 @@ contract UniversalCore is // -- Protocol constants & roles -- address public immutable UNIVERSAL_EXECUTOR_MODULE = 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7; - bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); + bytes32 public constant ROLE_MANAGER_ROLE = keccak256("ROLE_MANAGER_ROLE"); + bytes32 public constant UVCORE_ADMIN_ROLE = keccak256("UVCORE_ADMIN_ROLE"); + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + // -- Uniswap V3 fee tiers -- + uint24 public constant FEE_TIER_LOWEST = 100; + uint24 public constant FEE_TIER_LOW = 500; + uint24 public constant FEE_TIER_MEDIUM = 3000; + uint24 public constant FEE_TIER_HIGH = 10000; + // -- Protocol addresses -- address public universalGatewayPC; address public WPC; @@ -57,21 +72,32 @@ contract UniversalCore is mapping(string => uint256) public chainHeightByChainNamespace; mapping(string => uint256) public timestampObservedAtByChainNamespace; + /// @notice Maximum acceptable age (seconds) of `timestampObservedAtByChainNamespace` + /// before gas fee quotes for that chain are rejected as stale. + /// @dev `0` disables the check for that chain (opt-in). Set per chain by + /// UVCORE_ADMIN_ROLE via `updateMaxStalenessByChain`. + mapping(string => uint256) public maxStalenessByChainNamespace; + // -- Token configuration -- - mapping(address => bool) public isSupportedToken; mapping(address => uint256) public protocolFeeByToken; // -- Uniswap and AMM specific states -- address public uniswapV3Factory; address public uniswapV3SwapRouter; - address public uniswapV3Quoter; + /// @notice Stored gas PC pool per chain — informational only for off-chain consumers. + /// @dev Not used in runtime swap logic. Pool resolution is dynamic via uniswapV3Factory. mapping(string => address) public gasPCPoolByChainNamespace; mapping(address => bool) public isAutoSwapSupported; mapping(address => uint24) public defaultFeeTier; - mapping(address => uint256) public slippageTolerance; - uint256 public defaultDeadlineMins = 20; + uint256 public defaultDeadlineMins; + + /// @notice L1 gas fee per chain namespace (in gas token units). + mapping(string => uint256) public l1GasFeeByChainNamespace; + + /// @notice TSS fund migration gas limit per chain namespace. + mapping(string => uint256) public tssFundMigrationGasLimitByChainNamespace; // ========================= // UC: MODIFIERS @@ -91,13 +117,6 @@ contract UniversalCore is _; } - modifier onlyAdmin() { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) { - revert CommonErrors.InvalidOwner(); - } - _; - } - // ========================= // UC: CONSTRUCTOR // ========================= @@ -107,31 +126,37 @@ contract UniversalCore is } /// @dev Initializer function for the upgradeable contract. - /// @param wpc_ Address of the wrapped PC token - /// @param uniswapV3Factory_ Address of the Uniswap V3 factory - /// @param uniswapV3SwapRouter_ Address of the Uniswap V3 swap router - /// @param uniswapV3Quoter_ Address of the Uniswap V3 quoter + /// @param _admin Admin address — granted DEFAULT_ADMIN_ROLE + all operational roles + /// @param _pauser Address granted the PAUSER_ROLE + /// @param _wpc Address of the wrapped PC token + /// @param _uniswapV3Factory Address of the Uniswap V3 factory + /// @param _uniswapV3SwapRouter Address of the Uniswap V3 swap router function initialize( - address wpc_, - address uniswapV3Factory_, - address uniswapV3SwapRouter_, - address uniswapV3Quoter_, - address initialPauser_ + address _admin, + address _pauser, + address _wpc, + address _uniswapV3Factory, + address _uniswapV3SwapRouter ) public virtual initializer { - if (initialPauser_ == address(0)) revert CommonErrors.ZeroAddress(); + if (_admin == address(0) || _pauser == address(0)) revert CommonErrors.ZeroAddress(); + __ReentrancyGuard_init(); - __AccessControl_init(); + __AccessControlDefaultAdminRules_init(1 days, _admin); __Pausable_init(); - _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); - _grantRole(PAUSER_ROLE, initialPauser_); + _setRoleAdmin(UVCORE_ADMIN_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(OPERATOR_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(PAUSER_ROLE, ROLE_MANAGER_ROLE); - emit PauserRoleGranted(initialPauser_); + _grantRole(ROLE_MANAGER_ROLE, _admin); + _grantRole(UVCORE_ADMIN_ROLE, _admin); + _grantRole(OPERATOR_ROLE, _admin); + _grantRole(PAUSER_ROLE, _pauser); - WPC = wpc_; - uniswapV3Factory = uniswapV3Factory_; - uniswapV3SwapRouter = uniswapV3SwapRouter_; - uniswapV3Quoter = uniswapV3Quoter_; + WPC = _wpc; + uniswapV3Factory = _uniswapV3Factory; + uniswapV3SwapRouter = _uniswapV3SwapRouter; + defaultDeadlineMins = 20; } // ========================= @@ -139,9 +164,14 @@ contract UniversalCore is // ========================= /// @inheritdoc IUniversalCore - function depositPRC20Token(address prc20, uint256 amount, address recipient) external onlyUEModule whenNotPaused { + function depositPRC20Token(address prc20, uint256 amount, address recipient) + external + onlyUEModule + whenNotPaused + nonReentrant + { _validateParams(prc20, amount, recipient); - IPRC20(prc20).deposit(recipient, amount); + if (!IPRC20(prc20).deposit(recipient, amount)) revert UniversalCoreErrors.PRC20OperationFailed(); } /// @inheritdoc IUniversalCore @@ -174,7 +204,7 @@ contract UniversalCore is uint256 pcOut; if (!withSwap) { - IPRC20(gasToken).deposit(recipient, amount); + if (!IPRC20(gasToken).deposit(recipient, amount)) revert UniversalCoreErrors.PRC20OperationFailed(); } else { if (minPCOut == 0) { revert UniversalCoreErrors.MinPCOutRequired(); @@ -219,7 +249,7 @@ contract UniversalCore is IWPC(WPC).deposit{value: msg.value}(); - IERC20(WPC).approve(uniswapV3SwapRouter, msg.value); + IERC20(WPC).forceApprove(uniswapV3SwapRouter, msg.value); ISwapRouter.ExactOutputSingleParams memory params = ISwapRouter.ExactOutputSingleParams({ tokenIn: WPC, @@ -233,9 +263,9 @@ contract UniversalCore is }); uint256 amountInUsed = ISwapRouter(uniswapV3SwapRouter).exactOutputSingle(params); - IERC20(WPC).approve(uniswapV3SwapRouter, 0); + IERC20(WPC).forceApprove(uniswapV3SwapRouter, 0); - IPRC20(gasToken).burn(gasFee); + if (!IPRC20(gasToken).burn(gasFee)) revert UniversalCoreErrors.PRC20OperationFailed(); gasTokenOut = gasFee; refund = msg.value - amountInUsed; @@ -256,10 +286,18 @@ contract UniversalCore is function getOutboundTxGasAndFees(address _prc20, uint256 gasLimitWithBaseLimit) public view - returns (address gasToken, uint256 gasFee, uint256 protocolFee, uint256 gasPrice, string memory chainNamespace) + returns ( + address gasToken, + uint256 gasFee, + uint256 protocolFee, + uint256 gasPrice, + string memory chainNamespace, + uint256 gasLimitUsed + ) { chainNamespace = IPRC20(_prc20).SOURCE_CHAIN_NAMESPACE(); uint256 baseLimit = baseGasLimitByChainNamespace[chainNamespace]; + if (baseLimit == 0) revert UniversalCoreErrors.ZeroBaseGasLimit(); if (gasLimitWithBaseLimit == 0) { gasLimitWithBaseLimit = baseLimit; @@ -273,8 +311,11 @@ contract UniversalCore is gasPrice = gasPriceByChainNamespace[chainNamespace]; if (gasPrice == 0) revert UniversalCoreErrors.ZeroGasPrice(); + _validateGasDataFreshness(chainNamespace); + gasFee = gasPrice * gasLimitWithBaseLimit; protocolFee = protocolFeeByToken[_prc20]; + gasLimitUsed = gasLimitWithBaseLimit; } /// @inheritdoc IUniversalCore @@ -302,36 +343,34 @@ contract UniversalCore is gasPrice = gasPriceByChainNamespace[chainNamespace]; if (gasPrice == 0) revert UniversalCoreErrors.ZeroGasPrice(); + _validateGasDataFreshness(chainNamespace); + gasFee = gasPrice * rescueGasLimit; } // ========================= - // UC_4: MANAGER ACTIONS + // UC_4: ADMIN CONFIG // ========================= /// @notice Set protocol fee (in native PC) for a token. /// @param token Token address /// @param fee Protocol fee amount in native PC - function setProtocolFeeByToken(address token, uint256 fee) external onlyRole(MANAGER_ROLE) { + function updateProtocolFeeByToken(address token, uint256 fee) external onlyRole(UVCORE_ADMIN_ROLE) { if (token == address(0)) revert CommonErrors.ZeroAddress(); protocolFeeByToken[token] = fee; emit SetProtocolFeeByToken(token, fee); } - /// @notice Set whether a PRC20 token is supported. - /// @param prc20 PRC20 token address - /// @param supported Whether the token is supported - function setSupportedToken(address prc20, bool supported) external onlyRole(MANAGER_ROLE) { - if (prc20 == address(0)) revert CommonErrors.ZeroAddress(); - isSupportedToken[prc20] = supported; - emit SetSupportedToken(prc20, supported); - } - - /// @notice Set the gas PC pool for a chain. + /// @notice Set the gas PC pool for a chain (informational — not enforced at runtime). + /// @dev The stored pool is for off-chain observability only. Runtime swap flows + /// (swapAndBurnGas, _autoSwap) resolve pools dynamically from the factory. /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @param gasToken Gas coin address /// @param fee Uniswap V3 fee tier - function setGasPCPool(string memory chainNamespace, address gasToken, uint24 fee) external onlyRole(MANAGER_ROLE) { + function updateGasPCPool(string memory chainNamespace, address gasToken, uint24 fee) + external + onlyRole(UVCORE_ADMIN_ROLE) + { if (gasToken == address(0)) revert CommonErrors.ZeroAddress(); address pool = IUniswapV3Factory(uniswapV3Factory) @@ -348,10 +387,8 @@ contract UniversalCore is /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @param price Gas price on the external chain /// @param chainHeight Block height observed on the external chain - function setChainMeta(string memory chainNamespace, uint256 price, uint256 chainHeight) - external - onlyUEModule - { + function setChainMeta(string memory chainNamespace, uint256 price, uint256 chainHeight) external onlyUEModule { + if (price == 0) revert UniversalCoreErrors.ZeroGasPrice(); gasPriceByChainNamespace[chainNamespace] = price; chainHeightByChainNamespace[chainNamespace] = chainHeight; timestampObservedAtByChainNamespace[chainNamespace] = block.timestamp; @@ -361,75 +398,73 @@ contract UniversalCore is /// @notice Setter for gasTokenPRC20ByChainNamespace map. /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @param prc20 PRC20 address - function setGasTokenPRC20(string memory chainNamespace, address prc20) external onlyRole(MANAGER_ROLE) { + function updateGasTokenPRC20(string memory chainNamespace, address prc20) external onlyRole(UVCORE_ADMIN_ROLE) { if (prc20 == address(0)) revert CommonErrors.ZeroAddress(); gasTokenPRC20ByChainNamespace[chainNamespace] = prc20; + gasPriceByChainNamespace[chainNamespace] = 0; emit SetGasToken(chainNamespace, prc20); } // ========================= - // UC_5: ADMIN ACTIONS + // UC_5: OPERATOR ACTIONS // ========================= /// @notice Set auto-swap support for a token. /// @param token Token address /// @param supported Whether the token supports auto-swap - function setAutoSwapSupported(address token, bool supported) external onlyAdmin { + function updateAutoSwapSupported(address token, bool supported) external onlyRole(UVCORE_ADMIN_ROLE) { isAutoSwapSupported[token] = supported; + emit SetAutoSwapSupported(token, supported); } /// @notice Set the wrapped PC address. /// @param addr WPC new address - function setWPC(address addr) external onlyAdmin { + function updateWPC(address addr) external onlyRole(OPERATOR_ROLE) { if (addr == address(0)) revert CommonErrors.ZeroAddress(); + address oldAddr = WPC; WPC = addr; + emit SetWPC(oldAddr, addr); } /// @notice Set the UniversalGatewayPC address. /// @param addr UniversalGatewayPC address - function setUniversalGatewayPC(address addr) external onlyAdmin { + function updateUniversalGatewayPC(address addr) external onlyRole(OPERATOR_ROLE) { if (addr == address(0)) revert CommonErrors.ZeroAddress(); + address oldAddr = universalGatewayPC; universalGatewayPC = addr; + emit SetUniversalGatewayPC(oldAddr, addr); } /// @notice Setter for Uniswap V3 addresses. /// @param factory Uniswap V3 Factory address /// @param swapRouter Uniswap V3 SwapRouter address - /// @param quoter Uniswap V3 Quoter address - function setUniswapV3Addresses(address factory, address swapRouter, address quoter) external onlyAdmin { - if (factory == address(0) || swapRouter == address(0) || quoter == address(0)) { + function updateUniswapV3Addresses(address factory, address swapRouter) external onlyRole(OPERATOR_ROLE) { + if (factory == address(0) || swapRouter == address(0)) { revert CommonErrors.ZeroAddress(); } uniswapV3Factory = factory; uniswapV3SwapRouter = swapRouter; - uniswapV3Quoter = quoter; + emit SetUniswapV3Addresses(factory, swapRouter); } /// @notice Set default fee tier for a token. /// @param token Token address /// @param feeTier Fee tier (500, 3000, 10000) - function setDefaultFeeTier(address token, uint24 feeTier) external onlyAdmin { + function updateDefaultFeeTier(address token, uint24 feeTier) external onlyRole(UVCORE_ADMIN_ROLE) { if (token == address(0)) revert CommonErrors.ZeroAddress(); - if (feeTier != 500 && feeTier != 3000 && feeTier != 10000) { + if ( + feeTier != FEE_TIER_LOWEST && feeTier != FEE_TIER_LOW && feeTier != FEE_TIER_MEDIUM + && feeTier != FEE_TIER_HIGH + ) { revert UniversalCoreErrors.InvalidFeeTier(); } defaultFeeTier[token] = feeTier; - } - - /// @notice Set slippage tolerance for a token. - /// @param token Token address - /// @param tolerance Slippage tolerance in basis points (e.g., 300 = 3%) - function setSlippageTolerance(address token, uint256 tolerance) external onlyAdmin { - if (token == address(0)) revert CommonErrors.ZeroAddress(); - if (tolerance > 5000) { - revert UniversalCoreErrors.InvalidSlippageTolerance(); - } - slippageTolerance[token] = tolerance; + emit SetDefaultFeeTier(token, feeTier); } /// @notice Set default deadline in minutes. /// @param minutesValue Default deadline in minutes - function setDefaultDeadlineMins(uint256 minutesValue) external onlyAdmin { + function updateDefaultDeadlineMins(uint256 minutesValue) external onlyRole(UVCORE_ADMIN_ROLE) { defaultDeadlineMins = minutesValue; emit SetDefaultDeadlineMins(minutesValue); } @@ -437,7 +472,10 @@ contract UniversalCore is /// @notice Set base gas limit for a specific chain. /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @param gasLimit Base gas limit for the chain - function setBaseGasLimitByChain(string memory chainNamespace, uint256 gasLimit) external onlyRole(MANAGER_ROLE) { + function updateBaseGasLimitByChain(string memory chainNamespace, uint256 gasLimit) + external + onlyRole(UVCORE_ADMIN_ROLE) + { baseGasLimitByChainNamespace[chainNamespace] = gasLimit; emit SetBaseGasLimitByChain(chainNamespace, gasLimit); } @@ -445,30 +483,68 @@ contract UniversalCore is /// @notice Set rescue funds gas limit for a specific chain. /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @param gasLimit Rescue funds gas limit for the chain - function setRescueFundsGasLimitByChain(string memory chainNamespace, uint256 gasLimit) + function updateRescueFundsGasLimitByChain(string memory chainNamespace, uint256 gasLimit) external - onlyRole(MANAGER_ROLE) + onlyRole(UVCORE_ADMIN_ROLE) { rescueFundsGasLimitByChainNamespace[chainNamespace] = gasLimit; emit SetRescueFundsGasLimitByChain(chainNamespace, gasLimit); } + /// @notice Set the maximum acceptable age (seconds) of gas data for a chain. + /// @dev A value of `0` disables the staleness check for that chain (opt-in). + /// When set, `getOutboundTxGasAndFees` and `getRescueFundsGasLimit` + /// revert with `StaleGasData` if the chain's observed timestamp is + /// older than `block.timestamp - maxStaleness`. + /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) + /// @param maxStaleness Maximum acceptable age of gas data in seconds (0 disables) + function updateMaxStalenessByChain(string memory chainNamespace, uint256 maxStaleness) + external + onlyRole(UVCORE_ADMIN_ROLE) + { + maxStalenessByChainNamespace[chainNamespace] = maxStaleness; + emit SetMaxStalenessByChain(chainNamespace, maxStaleness); + } + + /// @notice Set L1 gas fee for a specific chain. + /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) + /// @param l1GasFee L1 gas fee for the chain (in gas token units) + function setL1GasFeeByChain(string memory chainNamespace, uint256 l1GasFee) external onlyRole(UVCORE_ADMIN_ROLE) { + l1GasFeeByChainNamespace[chainNamespace] = l1GasFee; + emit SetL1GasFeeByChain(chainNamespace, l1GasFee); + } + + /// @notice Set TSS migration gas limit for a specific chain. + /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) + /// @param gasLimit TSS migration gas limit for the chain + function setTssFundMigrationGasLimitByChain(string memory chainNamespace, uint256 gasLimit) + external + onlyRole(UVCORE_ADMIN_ROLE) + { + tssFundMigrationGasLimitByChainNamespace[chainNamespace] = gasLimit; + emit SetTssFundMigrationGasLimitByChain(chainNamespace, gasLimit); + } + /// @notice Pause the contract - stops all deposit functions. Only callable by PAUSER_ROLE. function pause() external onlyRole(PAUSER_ROLE) { _pause(); } - /// @notice Unpause the contract - resumes all deposit functions. Only callable by PAUSER_ROLE. - function unpause() external onlyRole(PAUSER_ROLE) { + /// @notice Unpause the contract - resumes all deposit functions. Only callable by OPERATOR_ROLE. + function unpause() external onlyRole(OPERATOR_ROLE) { _unpause(); } - /// @notice Grant PAUSER_ROLE to a new address. Only callable by admin. - /// @param newPauser Address to grant pauser role to - function setPauserRole(address newPauser) external onlyAdmin { - if (newPauser == address(0)) revert CommonErrors.ZeroAddress(); - _grantRole(PAUSER_ROLE, newPauser); - emit PauserRoleGranted(newPauser); + /// @notice Rescue native PC stuck in the contract. Only callable by UVCORE_ADMIN_ROLE. + /// @param to Recipient address for the rescued PC + /// @param amount Amount of native PC to rescue + function rescueNativePC(address payable to, uint256 amount) external onlyRole(UVCORE_ADMIN_ROLE) { + if (to == address(0)) revert CommonErrors.ZeroAddress(); + if (amount == 0) revert CommonErrors.ZeroAmount(); + if (amount > address(this).balance) revert CommonErrors.InsufficientBalance(); + (bool ok,) = to.call{value: amount}(""); + if (!ok) revert CommonErrors.TransferFailed(); + emit RescueNativePC(to, amount); } // ========================= @@ -488,6 +564,21 @@ contract UniversalCore is if (amount == 0) revert CommonErrors.ZeroAmount(); } + /// @dev Enforces that gas data for `chainNamespace` is within the configured freshness + /// window. No-op when `maxStalenessByChainNamespace[chainNamespace]` is `0` + /// (check disabled). Reverts with `StaleGasData` when the data is older than + /// the configured max age, carrying the observed timestamp, current timestamp, + /// and max age in the revert data. + /// @param chainNamespace Chain namespace whose freshness is being validated + function _validateGasDataFreshness(string memory chainNamespace) private view { + uint256 maxAge = maxStalenessByChainNamespace[chainNamespace]; + if (maxAge == 0) return; + uint256 observedAt = timestampObservedAtByChainNamespace[chainNamespace]; + if (block.timestamp > observedAt + maxAge) { + revert UniversalCoreErrors.StaleGasData(observedAt, block.timestamp, maxAge); + } + } + /// @dev Swap PRC20 to native PC via Uniswap V3 and send to recipient. /// @param prc20 PRC20 token address to swap /// @param amount Amount of PRC20 to swap @@ -522,8 +613,8 @@ contract UniversalCore is if (minPCOut == 0) revert CommonErrors.ZeroAmount(); - IPRC20(prc20).deposit(address(this), amount); - IPRC20(prc20).approve(uniswapV3SwapRouter, amount); + if (!IPRC20(prc20).deposit(address(this), amount)) revert UniversalCoreErrors.PRC20OperationFailed(); + IERC20(prc20).forceApprove(uniswapV3SwapRouter, amount); ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ tokenIn: prc20, @@ -539,7 +630,7 @@ contract UniversalCore is pcOut = ISwapRouter(uniswapV3SwapRouter).exactInputSingle(params); if (pcOut < minPCOut) revert UniversalCoreErrors.SlippageExceeded(); - IPRC20(prc20).approve(uniswapV3SwapRouter, 0); + IERC20(prc20).forceApprove(uniswapV3SwapRouter, 0); IWPC(WPC).withdraw(pcOut); (bool ok,) = recipient.call{value: pcOut}(""); diff --git a/src/WPC.sol b/src/WPC.sol index d5d3563..58aa353 100644 --- a/src/WPC.sol +++ b/src/WPC.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.26; import {IWPC} from "./interfaces/IWPC.sol"; +import {WPCErrors} from "./libraries/Errors.sol"; /** * @title WPC @@ -16,6 +17,7 @@ contract WPC is IWPC { string public symbol = "WPC"; uint8 public decimals = 18; + uint256 private _totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; @@ -25,15 +27,18 @@ contract WPC is IWPC { /// @inheritdoc IWPC function deposit() public payable { + _totalSupply += msg.value; balanceOf[msg.sender] += msg.value; emit Deposit(msg.sender, msg.value); } /// @inheritdoc IWPC function withdraw(uint256 wad) public { - require(balanceOf[msg.sender] >= wad, ""); + if (balanceOf[msg.sender] < wad) revert WPCErrors.InsufficientBalance(); balanceOf[msg.sender] -= wad; - payable(msg.sender).transfer(wad); + _totalSupply -= wad; + (bool ok,) = msg.sender.call{value: wad}(""); + if (!ok) revert WPCErrors.TransferFailed(); emit Withdrawal(msg.sender, wad); } @@ -43,7 +48,7 @@ contract WPC is IWPC { /// @inheritdoc IWPC function totalSupply() public view returns (uint256) { - return address(this).balance; + return _totalSupply; } /// @inheritdoc IWPC @@ -60,10 +65,10 @@ contract WPC is IWPC { /// @inheritdoc IWPC function transferFrom(address src, address dst, uint256 wad) public returns (bool) { - require(balanceOf[src] >= wad, ""); + if (balanceOf[src] < wad) revert WPCErrors.InsufficientBalance(); if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) { - require(allowance[src][msg.sender] >= wad, ""); + if (allowance[src][msg.sender] < wad) revert WPCErrors.InsufficientAllowance(); allowance[src][msg.sender] -= wad; } diff --git a/src/cea/CEA.sol b/src/cea/CEA.sol index 6b23493..0a5ba68 100644 --- a/src/cea/CEA.sol +++ b/src/cea/CEA.sol @@ -8,7 +8,6 @@ import {IUniversalGateway, UniversalTxRequest} from "../interfaces/IUniversalGat import {Multicall, MULTICALL_SELECTOR, MIGRATION_SELECTOR} from "../libraries/Types.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /** @@ -19,8 +18,6 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * In v1 only the Vault may call state-changing functions. */ contract CEA is ICEA, ReentrancyGuard { - using SafeERC20 for IERC20; - // ========================= // CEA: STATE VARIABLES // ========================= @@ -28,13 +25,7 @@ contract CEA is ICEA, ReentrancyGuard { /// @inheritdoc ICEA address public pushAccount; - /// @inheritdoc ICEA - address public VAULT; - - /// @notice Address of the Universal Gateway on this external chain. - address public UNIVERSAL_GATEWAY; - - /// @notice Reference to the CEA factory for fetching migration contract. + /// @notice Reference to the CEA factory — single source of truth for VAULT and UNIVERSAL_GATEWAY. ICEAFactory public factory; bool private _initialized; @@ -46,9 +37,9 @@ contract CEA is ICEA, ReentrancyGuard { // CEA: MODIFIERS // ========================= - /// @notice Restricts to the Vault contract. + /// @notice Restricts to the Vault contract (read live from factory). modifier onlyVault() { - if (msg.sender != VAULT) revert CEAErrors.NotVault(); + if (msg.sender != factory.VAULT()) revert CEAErrors.NotVault(); _; } @@ -57,23 +48,32 @@ contract CEA is ICEA, ReentrancyGuard { // ========================= /// @inheritdoc ICEA - function initializeCEA(address _pushAccount, address _vault, address _universalGateway, address _factory) external { + function initializeCEA(address _pushAccount, address _factory) external { if (_initialized) revert CEAErrors.AlreadyInitialized(); - if ( - _pushAccount == address(0) || _vault == address(0) || _universalGateway == address(0) - || _factory == address(0) - ) { + if (_pushAccount == address(0) || _factory == address(0)) { revert CEAErrors.ZeroAddress(); } pushAccount = _pushAccount; - VAULT = _vault; - UNIVERSAL_GATEWAY = _universalGateway; factory = ICEAFactory(_factory); _initialized = true; } + // ========================= + // CEA: FACTORY-BACKED GETTERS + // ========================= + + /// @inheritdoc ICEA + function VAULT() external view returns (address) { + return factory.VAULT(); + } + + /// @notice Returns the Universal Gateway address (live from factory). + function UNIVERSAL_GATEWAY() external view returns (address) { + return factory.UNIVERSAL_GATEWAY(); + } + // ========================= // CEA_1: VIEW FUNCTIONS // ========================= @@ -89,18 +89,18 @@ contract CEA is ICEA, ReentrancyGuard { /// @inheritdoc ICEA function executeUniversalTx( - bytes32 txId, + bytes32 subTxId, bytes32 universalTxId, address originCaller, address recipient, bytes calldata payload ) external payable onlyVault nonReentrant { - if (isExecuted[txId]) revert CEAErrors.PayloadExecuted(); + if (isExecuted[subTxId]) revert CEAErrors.PayloadExecuted(); if (originCaller != pushAccount) revert CEAErrors.InvalidUEA(); - isExecuted[txId] = true; + isExecuted[subTxId] = true; - _handleExecution(txId, universalTxId, originCaller, recipient, payload); + _handleExecution(subTxId, universalTxId, originCaller, recipient, payload); } // ========================= @@ -114,7 +114,6 @@ contract CEA is ICEA, ReentrancyGuard { if (msg.sender != address(this)) { revert CommonErrors.Unauthorized(); } - if (amount == 0) revert CEAErrors.InvalidInput(); if (revertRecipient == address(0)) { revert CEAErrors.InvalidInput(); } @@ -128,16 +127,24 @@ contract CEA is ICEA, ReentrancyGuard { signatureData: "" }); - if (token == address(0)) { - if (address(this).balance < amount) { - revert CEAErrors.InsufficientBalance(); + address gateway = factory.UNIVERSAL_GATEWAY(); + + if (amount > 0) { + if (token == address(0)) { + if (address(this).balance < amount) { + revert CEAErrors.InsufficientBalance(); + } + IUniversalGateway(gateway).sendUniversalTxFromCEA{value: amount}(req); + } else { + if (IERC20(token).balanceOf(address(this)) < amount) { + revert CEAErrors.InsufficientBalance(); + } + IERC20(token).approve(gateway, amount); + IUniversalGateway(gateway).sendUniversalTxFromCEA(req); + IERC20(token).approve(gateway, 0); } - IUniversalGateway(UNIVERSAL_GATEWAY).sendUniversalTxFromCEA{value: amount}(req); } else { - if (IERC20(token).balanceOf(address(this)) < amount) { - revert CEAErrors.InsufficientBalance(); - } - IUniversalGateway(UNIVERSAL_GATEWAY).sendUniversalTxFromCEA(req); + IUniversalGateway(gateway).sendUniversalTxFromCEA(req); } emit UniversalTxToUEA(address(this), pushAccount, token, amount); @@ -149,13 +156,13 @@ contract CEA is ICEA, ReentrancyGuard { /// @dev Routes execution based on payload type. /// Three-way branch: MULTICALL, MIGRATION, or SINGLE CALL. - /// @param txId Transaction identifier for event emission + /// @param subTxId Transaction identifier for event emission /// @param universalTxId Universal tx identifier for event emission /// @param originCaller Origin caller for event emission /// @param recipient Target for single-call path (ignored otherwise) /// @param payload Raw payload bytes function _handleExecution( - bytes32 txId, + bytes32 subTxId, bytes32 universalTxId, address originCaller, address recipient, @@ -163,22 +170,22 @@ contract CEA is ICEA, ReentrancyGuard { ) internal { if (_isMulticall(payload)) { Multicall[] memory calls = _decodeCalls(payload); - _handleMulticall(txId, universalTxId, originCaller, calls); + _handleMulticall(subTxId, universalTxId, originCaller, calls); } else if (_isMigration(payload)) { - _handleMigration(); - emit UniversalTxExecuted(txId, universalTxId, originCaller, address(this), payload); + _handleMigration(recipient); + emit UniversalTxExecuted(subTxId, universalTxId, originCaller, address(this), payload); } else { - _handleSingleCall(txId, universalTxId, originCaller, recipient, payload); + _handleSingleCall(subTxId, universalTxId, originCaller, recipient, payload); } } /// @dev Executes each multicall step sequentially. /// Self-calls must have value == 0. - /// @param txId Transaction identifier for event emission + /// @param subTxId Transaction identifier for event emission /// @param universalTxId Universal tx identifier for event emission /// @param originCaller Origin caller for event emission /// @param calls Decoded Multicall[] array - function _handleMulticall(bytes32 txId, bytes32 universalTxId, address originCaller, Multicall[] memory calls) + function _handleMulticall(bytes32 subTxId, bytes32 universalTxId, address originCaller, Multicall[] memory calls) internal { for (uint256 i = 0; i < calls.length; i++) { @@ -190,31 +197,40 @@ contract CEA is ICEA, ReentrancyGuard { revert CEAErrors.InvalidInput(); } - (bool success,) = calls[i].to.call{value: calls[i].value}(calls[i].data); + (bool success, bytes memory returnData) = calls[i].to.call{value: calls[i].value}(calls[i].data); - if (!success) revert CEAErrors.ExecutionFailed(); + if (!success) { + if (returnData.length > 0) { + assembly { + revert(add(32, returnData), mload(returnData)) + } + } else { + revert CEAErrors.ExecutionFailed(); + } + } - emit UniversalTxExecuted(txId, universalTxId, originCaller, calls[i].to, calls[i].data); + emit UniversalTxExecuted(subTxId, universalTxId, originCaller, calls[i].to, calls[i].data); } } /// @dev Handles single-call execution or funds parking. - /// Empty payload = park funds. Non-empty = execute call. - /// Self-calls blocked (use multicall path instead). - /// @param txId Transaction identifier for event emission + /// Note: Funds-parking mode is explicitly signalled by BOTH an empty payload AND a + /// zero `recipient`. + /// + /// @param subTxId Transaction identifier for event emission /// @param universalTxId Universal tx identifier for event emission /// @param originCaller Origin caller for event emission - /// @param recipient Target contract for execution - /// @param payload Raw calldata to forward (empty = park funds) + /// @param recipient Target contract for execution (zero + empty payload = park funds) + /// @param payload Raw calldata to forward (empty + zero recipient = park funds) function _handleSingleCall( - bytes32 txId, + bytes32 subTxId, bytes32 universalTxId, address originCaller, address recipient, bytes calldata payload ) internal { - if (payload.length == 0) { - emit UniversalTxExecuted(txId, universalTxId, originCaller, address(this), payload); + if (payload.length == 0 && recipient == address(0)) { + emit UniversalTxExecuted(subTxId, universalTxId, originCaller, address(this), payload); return; } @@ -225,15 +241,25 @@ contract CEA is ICEA, ReentrancyGuard { revert CEAErrors.InvalidRecipient(); } - (bool success,) = recipient.call{value: msg.value}(payload); - if (!success) revert CEAErrors.ExecutionFailed(); + (bool success, bytes memory returnData) = recipient.call{value: msg.value}(payload); + if (!success) { + if (returnData.length > 0) { + assembly { + revert(add(32, returnData), mload(returnData)) + } + } else { + revert CEAErrors.ExecutionFailed(); + } + } - emit UniversalTxExecuted(txId, universalTxId, originCaller, recipient, payload); + emit UniversalTxExecuted(subTxId, universalTxId, originCaller, recipient, payload); } /// @dev Fetches migration contract from factory and delegates. - /// Rejects msg.value > 0 — migration is a logic upgrade only. - function _handleMigration() internal { + /// Enforces: recipient must be self, no value transfer. + /// @param recipient Must be address(this) — migration targets self only + function _handleMigration(address recipient) internal { + if (recipient != address(this)) revert CEAErrors.InvalidRecipient(); if (msg.value != 0) revert CEAErrors.InvalidInput(); address migrationContract = factory.CEA_MIGRATION_CONTRACT(); if (migrationContract == address(0)) { diff --git a/src/cea/CEAFactory.sol b/src/cea/CEAFactory.sol index cb36277..94b2943 100644 --- a/src/cea/CEAFactory.sol +++ b/src/cea/CEAFactory.sol @@ -8,7 +8,9 @@ import {CEAErrors} from "../libraries/Errors.sol"; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { + AccessControlDefaultAdminRulesUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlDefaultAdminRulesUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; /** @@ -18,18 +20,21 @@ import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/Pau * implementation. Maintains a 1:1 mapping between UEA (on Push) and * CEA (on this chain). * - * Access control uses OpenZeppelin AccessControl: - * - DEFAULT_ADMIN_ROLE: governance — can update all config and grant roles. - * - PAUSER_ROLE: guardian hot-wallet — can pause/unpause only. + * Access control: AccessControlDefaultAdminRulesUpgradeable (2-day delay). + * Roles: DEFAULT_ADMIN_ROLE (root), ROLE_MANAGER_ROLE (grants operational roles), + * CEA_ADMIN_ROLE (implementation config), OPERATOR_ROLE (address setters + unpause), + * PAUSER_ROLE (pause only). */ -contract CEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradeable, ICEAFactory { +contract CEAFactory is Initializable, AccessControlDefaultAdminRulesUpgradeable, PausableUpgradeable, ICEAFactory { using Clones for address; // ========================= // CF: ROLES // ========================= - /// @notice Role that can pause and unpause CEA deployments. + bytes32 public constant ROLE_MANAGER_ROLE = keccak256("ROLE_MANAGER_ROLE"); + bytes32 public constant CEA_ADMIN_ROLE = keccak256("CEA_ADMIN_ROLE"); + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); // ========================= @@ -77,39 +82,44 @@ contract CEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea } /// @dev Initializer for the upgradeable CEAFactory. - /// @param initialAdmin Owner of the factory (governance) — granted DEFAULT_ADMIN_ROLE - /// @param initialPauser Address granted the PAUSER_ROLE - /// @param initialVault Vault address on this chain - /// @param ceaProxyImplementation CEA proxy implementation to clone (CEAProxy) - /// @param ceaImplementation CEA logic implementation (CEA) - /// @param universalGateway Universal Gateway on this chain + /// @param _admin Admin address — granted DEFAULT_ADMIN_ROLE + all operational roles + /// @param _pauser Address granted the PAUSER_ROLE + /// @param _vault Vault address on this chain + /// @param _ceaProxyImplementation CEA proxy implementation to clone (CEAProxy) + /// @param _ceaImplementation CEA logic implementation (CEA) + /// @param _universalGateway Universal Gateway on this chain function initialize( - address initialAdmin, - address initialPauser, - address initialVault, - address ceaProxyImplementation, - address ceaImplementation, - address universalGateway + address _admin, + address _pauser, + address _vault, + address _ceaProxyImplementation, + address _ceaImplementation, + address _universalGateway ) external initializer { if ( - initialAdmin == address(0) || initialPauser == address(0) || initialVault == address(0) - || ceaProxyImplementation == address(0) || ceaImplementation == address(0) - || universalGateway == address(0) + _admin == address(0) || _pauser == address(0) || _vault == address(0) + || _ceaProxyImplementation == address(0) || _ceaImplementation == address(0) + || _universalGateway == address(0) ) { revert CEAErrors.ZeroAddress(); } - __AccessControl_init(); + __AccessControlDefaultAdminRules_init(1 days, _admin); __Pausable_init(); - _grantRole(DEFAULT_ADMIN_ROLE, initialAdmin); - _grantRole(PAUSER_ROLE, initialPauser); - emit PauserRoleGranted(initialPauser); + _setRoleAdmin(CEA_ADMIN_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(OPERATOR_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(PAUSER_ROLE, ROLE_MANAGER_ROLE); - VAULT = initialVault; - CEA_PROXY_IMPLEMENTATION = ceaProxyImplementation; - CEA_IMPLEMENTATION = ceaImplementation; - UNIVERSAL_GATEWAY = universalGateway; + _grantRole(ROLE_MANAGER_ROLE, _admin); + _grantRole(CEA_ADMIN_ROLE, _admin); + _grantRole(OPERATOR_ROLE, _admin); + _grantRole(PAUSER_ROLE, _pauser); + + VAULT = _vault; + CEA_PROXY_IMPLEMENTATION = _ceaProxyImplementation; + CEA_IMPLEMENTATION = _ceaImplementation; + UNIVERSAL_GATEWAY = _universalGateway; } // ========================= @@ -169,7 +179,7 @@ contract CEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea ICEAProxy(cea).initializeCEAProxy(CEA_IMPLEMENTATION); - ICEA(cea).initializeCEA(pushAccount, VAULT, UNIVERSAL_GATEWAY, address(this)); + ICEA(cea).initializeCEA(pushAccount, address(this)); pushAccountToCEA[pushAccount] = cea; ceaToPushAccount[cea] = pushAccount; @@ -186,58 +196,50 @@ contract CEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea _pause(); } - /// @notice Unpause CEA deployments. Only callable by PAUSER_ROLE. - function unpause() external onlyRole(PAUSER_ROLE) { + /// @notice Unpause CEA deployments. Only callable by OPERATOR_ROLE. + function unpause() external onlyRole(OPERATOR_ROLE) { _unpause(); } - /// @notice Grant PAUSER_ROLE to a new address. Only callable by DEFAULT_ADMIN_ROLE. - /// @param newPauser Address to grant pauser role to - function setPauserRole(address newPauser) external onlyRole(DEFAULT_ADMIN_ROLE) { - if (newPauser == address(0)) revert CEAErrors.ZeroAddress(); - _grantRole(PAUSER_ROLE, newPauser); - emit PauserRoleGranted(newPauser); - } - - /// @notice Sets the Vault address. Only callable by DEFAULT_ADMIN_ROLE. + /// @notice Updates the Vault address. Only callable by OPERATOR_ROLE. /// @param newVault New Vault address - function setVault(address newVault) external onlyRole(DEFAULT_ADMIN_ROLE) { + function updateVault(address newVault) external onlyRole(OPERATOR_ROLE) { if (newVault == address(0)) revert CEAErrors.ZeroAddress(); address old = VAULT; VAULT = newVault; emit VaultUpdated(old, newVault); } - /// @notice Sets the CEA proxy implementation (CEAProxy template). + /// @notice Sets the CEA proxy implementation (CEAProxy template). Only callable by CEA_ADMIN_ROLE. /// @param newImplementation New CEA proxy implementation address - function setCEAProxyImplementation(address newImplementation) external onlyRole(DEFAULT_ADMIN_ROLE) { + function setCEAProxyImplementation(address newImplementation) external onlyRole(CEA_ADMIN_ROLE) { if (newImplementation == address(0)) revert CEAErrors.ZeroAddress(); address old = CEA_PROXY_IMPLEMENTATION; CEA_PROXY_IMPLEMENTATION = newImplementation; emit CEAProxyImplementationUpdated(old, newImplementation); } - /// @notice Sets the CEA logic implementation. + /// @notice Sets the CEA logic implementation. Only callable by CEA_ADMIN_ROLE. /// @param newImplementation New CEA logic implementation address - function setCEAImplementation(address newImplementation) external onlyRole(DEFAULT_ADMIN_ROLE) { + function setCEAImplementation(address newImplementation) external onlyRole(CEA_ADMIN_ROLE) { if (newImplementation == address(0)) revert CEAErrors.ZeroAddress(); address old = CEA_IMPLEMENTATION; CEA_IMPLEMENTATION = newImplementation; emit CEAImplementationUpdated(old, newImplementation); } - /// @notice Sets the Universal Gateway address. Only callable by DEFAULT_ADMIN_ROLE. + /// @notice Updates the Universal Gateway address. Only callable by OPERATOR_ROLE. /// @param newUG New Universal Gateway address - function setUniversalGateway(address newUG) external onlyRole(DEFAULT_ADMIN_ROLE) { + function updateUniversalGateway(address newUG) external onlyRole(OPERATOR_ROLE) { if (newUG == address(0)) revert CEAErrors.ZeroAddress(); address old = UNIVERSAL_GATEWAY; UNIVERSAL_GATEWAY = newUG; emit UniversalGatewayUpdated(old, newUG); } - /// @notice Sets the CEA migration contract address. + /// @notice Sets the CEA migration contract address. Only callable by CEA_ADMIN_ROLE. /// @param newMigrationContract Address of the new migration contract - function setCEAMigrationContract(address newMigrationContract) external onlyRole(DEFAULT_ADMIN_ROLE) { + function updateCEAMigrationContract(address newMigrationContract) external onlyRole(CEA_ADMIN_ROLE) { if (newMigrationContract == address(0)) revert CEAErrors.ZeroAddress(); address old = CEA_MIGRATION_CONTRACT; CEA_MIGRATION_CONTRACT = newMigrationContract; diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index d366830..2213dbc 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -29,6 +29,7 @@ library PRC20Errors { error LowAllowance(); error InvalidSender(); error CallerIsNotUniversalExecutor(); + error CorePaused(); } // ========================= @@ -44,10 +45,27 @@ library UniversalCoreErrors { error CallerIsNotUEModule(); error CallerIsNotGatewayPC(); error AutoSwapNotSupported(); - error InvalidSlippageTolerance(); error MinPCOutRequired(); error GasLimitBelowBase(uint256 provided, uint256 minimum); + error ZeroBaseGasLimit(); error ZeroRescueGasLimit(); + error StaleGasData(uint256 observedAt, uint256 nowTimestamp, uint256 maxAge); + error PRC20OperationFailed(); +} + +// ========================= +// WPC-Specific ERRORS +// ========================= + +library StringUtilsErrors { + error EmptyString(); + error NonDigitCharacter(); +} + +library WPCErrors { + error InsufficientBalance(); + error InsufficientAllowance(); + error TransferFailed(); } // ========================= @@ -60,8 +78,10 @@ library UEAErrors { error InvalidInputArgs(); error InvalidEVMSignature(); error InvalidSVMSignature(); + error NonceMismatch(uint256 expected, uint256 provided); error PrecompileCallFailed(); error AccountAlreadyExists(); + error UEAAlreadyRegistered(); } library CEAErrors { diff --git a/src/libraries/Utils.sol b/src/libraries/Utils.sol index c802100..a7d4070 100644 --- a/src/libraries/Utils.sol +++ b/src/libraries/Utils.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; +import {StringUtilsErrors} from "./Errors.sol"; + /// @title StringUtils /// @notice Utility library for string-to-number conversion. library StringUtils { @@ -15,11 +17,11 @@ library StringUtils { bytes memory b = bytes(s); uint256 len = b.length; - require(len > 0, "Empty string cannot be converted."); + if (len == 0) revert StringUtilsErrors.EmptyString(); for (uint256 i = 0; i < len; ++i) { uint8 c = uint8(b[i]); - require(c >= 48 && c <= 57, "Non-digit character found."); + if (c < 48 || c > 57) revert StringUtilsErrors.NonDigitCharacter(); result = result * 10 + (c - 48); } diff --git a/src/testnetV0/CEA_V2.sol b/src/testnetV0/CEA_V2.sol new file mode 100644 index 0000000..a9f6087 --- /dev/null +++ b/src/testnetV0/CEA_V2.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {CEA} from "../cea/CEA.sol"; + +/// @title CEA_V2 +/// @notice Testnet-only v2 implementation with a VERSION getter for migration verification. +contract CEA_V2 is CEA { + /// @notice Returns the implementation version. + function VERSION() external pure returns (string memory) { + return "2"; + } +} diff --git a/src/testnetV0/IUniversalCoreV0.sol b/src/testnetV0/IUniversalCore.sol similarity index 64% rename from src/testnetV0/IUniversalCoreV0.sol rename to src/testnetV0/IUniversalCore.sol index 0ac313b..c60979f 100644 --- a/src/testnetV0/IUniversalCoreV0.sol +++ b/src/testnetV0/IUniversalCore.sol @@ -1,55 +1,36 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -/// @title IUniversalCoreV0 -/// @notice Interface for the UniversalCoreV0 (testnet) contract. -/// @dev Standalone interface dedicated to UniversalCoreV0. -interface IUniversalCoreV0 { +/// @title IUniversalCore +/// @notice Interface for the UniversalCore (testnet) contract. +/// @dev Standalone interface dedicated to testnet UniversalCore. +interface IUniversalCore { // ========================= // UCV0: EVENTS // ========================= - event SetChainMeta( - string chainNamespace, - uint256 price, - uint256 chainHeight, - uint256 observedAt - ); - event SetGasPrice(string chainNamespace, uint256 price); + event SetChainMeta(string chainNamespace, uint256 price, uint256 chainHeight, uint256 observedAt); event SetGasToken(string chainNamespace, address prc20); event SetDefaultDeadlineMins(uint256 minutesValue); - event SetSupportedToken(address indexed prc20, bool supported); - event SetGasPCPool( - string chainNamespace, address pool, uint24 fee - ); + event SetMaxStalenessByChain(string chainNamespace, uint256 maxStaleness); + event SetL1GasFeeByChain(string chainNamespace, uint256 l1GasFee); + event SetTssFundMigrationGasLimitByChain(string chainNamespace, uint256 gasLimit); + event SetAutoSwapSupported(address indexed token, bool supported); + event SetWPC(address indexed oldAddr, address indexed newAddr); + event SetUniversalGatewayPC(address indexed oldAddr, address indexed newAddr); + event SetUniswapV3Addresses(address factory, address swapRouter); + event SetDefaultFeeTier(address indexed token, uint24 feeTier); + event RescueNativePC(address indexed to, uint256 amount); + event SetGasPCPool(string chainNamespace, address pool, uint24 fee); event DepositPRC20WithAutoSwap( - address prc20, - uint256 amountIn, - address pcToken, - uint256 amountOut, - uint24 fee, - address recipient - ); - event SwapAndBurnGas( - address indexed gasToken, - uint256 pcIn, - uint256 gasFee, - uint24 fee, - address indexed caller + address prc20, uint256 amountIn, address pcToken, uint256 amountOut, uint24 fee, address recipient ); + event SwapAndBurnGas(address indexed gasToken, uint256 pcIn, uint256 gasFee, uint24 fee, address indexed caller); event SetProtocolFeeByToken(address indexed token, uint256 fee); - event SetBaseGasLimitByChain( - string chainNamespace, uint256 gasLimit - ); - event SetRescueFundsGasLimitByChain( - string chainNamespace, uint256 gasLimit - ); + event SetBaseGasLimitByChain(string chainNamespace, uint256 gasLimit); + event SetRescueFundsGasLimitByChain(string chainNamespace, uint256 gasLimit); event RefundUnusedGas( - address indexed gasToken, - uint256 amount, - address indexed recipient, - bool swapped, - uint256 pcOut + address indexed gasToken, uint256 amount, address indexed recipient, bool swapped, uint256 pcOut ); // ========================= @@ -60,11 +41,7 @@ interface IUniversalCoreV0 { /// @param prc20 PRC20 address for deposit /// @param amount Amount to deposit /// @param recipient Address to deposit tokens to - function depositPRC20Token( - address prc20, - uint256 amount, - address recipient - ) external; + function depositPRC20Token(address prc20, uint256 amount, address recipient) external; /// @notice Deposits PRC20 tokens and automatically swaps them to /// native PC before sending to recipient. @@ -99,15 +76,6 @@ interface IUniversalCoreV0 { uint256 minPCOut ) external; - /// @notice Set gas price for a chain. - /// @dev To Be Removed — use setChainMeta instead. - /// @param chainNamespace Chain Namespace - /// @param price New gas price - function setGasPrice( - string memory chainNamespace, - uint256 price - ) external; - // ========================= // UCV0_2: GATEWAY FUNCTIONS // ========================= @@ -120,52 +88,37 @@ interface IUniversalCoreV0 { /// @param caller Address to receive unused PC refund /// @return gasTokenOut Total gas token swapped (gasFee) /// @return refund Unused PC refunded to caller - function swapAndBurnGas( - address gasToken, - uint24 fee, - uint256 gasFee, - uint256 deadline, - address caller - ) external payable returns (uint256 gasTokenOut, uint256 refund); + function swapAndBurnGas(address gasToken, uint24 fee, uint256 gasFee, uint256 deadline, address caller) + external + payable + returns (uint256 gasTokenOut, uint256 refund); // ========================= // UCV0_3: PUBLIC GETTERS // ========================= - /// @notice Check if a PRC20 token is supported. - /// @param prc20 PRC20 token address - /// @return supported Whether the token is supported - function isSupportedToken( - address prc20 - ) external view returns (bool supported); - /// @notice Get gas token PRC20 address for a chain. /// @param chainNamespace Chain Namespace /// @return gasToken Gas token address - function gasTokenPRC20ByChainNamespace( - string memory chainNamespace - ) external view returns (address gasToken); + function gasTokenPRC20ByChainNamespace(string memory chainNamespace) external view returns (address gasToken); /// @notice Get gas price for a chain. /// @param chainNamespace Chain Namespace /// @return price Gas price - function gasPriceByChainNamespace( - string memory chainNamespace - ) external view returns (uint256 price); + function gasPriceByChainNamespace(string memory chainNamespace) external view returns (uint256 price); /// @notice Get base gas limit for a chain. /// @param chainNamespace Chain Namespace /// @return baseGasLimit Base gas limit for the chain - function baseGasLimitByChainNamespace( - string memory chainNamespace - ) external view returns (uint256 baseGasLimit); + function baseGasLimitByChainNamespace(string memory chainNamespace) external view returns (uint256 baseGasLimit); /// @notice Get rescue funds gas limit for a chain. /// @param chainNamespace Chain Namespace /// @return rescueGasLimit Rescue funds gas limit for the chain - function rescueFundsGasLimitByChainNamespace( - string memory chainNamespace - ) external view returns (uint256 rescueGasLimit); + function rescueFundsGasLimitByChainNamespace(string memory chainNamespace) + external + view + returns (uint256 rescueGasLimit); /// @notice Get gas fee for a PRC20 token, split into gasFee and protocolFee. /// @dev When gasLimitWithBaseLimit is 0, falls back to per-chain base gas limit. @@ -178,10 +131,8 @@ interface IUniversalCoreV0 { /// @return protocolFee Protocol fee in native PC from protocolFeeByToken mapping /// @return gasPrice Gas price on the external chain /// @return chainNamespace Source chain namespace - function getOutboundTxGasAndFees( - address _prc20, - uint256 gasLimitWithBaseLimit - ) + /// @return gasLimitUsed Effective gas limit used in calculation + function getOutboundTxGasAndFees(address _prc20, uint256 gasLimitWithBaseLimit) external view returns ( @@ -189,7 +140,8 @@ interface IUniversalCoreV0 { uint256 gasFee, uint256 protocolFee, uint256 gasPrice, - string memory chainNamespace + string memory chainNamespace, + uint256 gasLimitUsed ); /// @notice Get rescue funds gas limit, fee, and related config for a PRC20 token. @@ -199,9 +151,7 @@ interface IUniversalCoreV0 { /// @return rescueGasLimit Rescue funds gas limit for the chain /// @return gasPrice Gas price on the external chain /// @return chainNamespace Source chain namespace - function getRescueFundsGasLimit( - address _prc20 - ) + function getRescueFundsGasLimit(address _prc20) external view returns ( @@ -215,25 +165,27 @@ interface IUniversalCoreV0 { /// @notice Get the protocol fee (in native PC) for a given token. /// @param token Token address /// @return Protocol fee amount in native PC - function protocolFeeByToken( - address token - ) external view returns (uint256); + function protocolFeeByToken(address token) external view returns (uint256); /// @notice Set protocol fee (in native PC) for a token. /// @param token Token address /// @param fee Protocol fee amount in native PC - function setProtocolFeeByToken( - address token, - uint256 fee - ) external; + function updateProtocolFeeByToken(address token, uint256 fee) external; /// @notice Set rescue funds gas limit for a specific chain. /// @param chainNamespace Chain Namespace /// @param gasLimit Rescue funds gas limit for the chain - function setRescueFundsGasLimitByChain( - string memory chainNamespace, - uint256 gasLimit - ) external; + function updateRescueFundsGasLimitByChain(string memory chainNamespace, uint256 gasLimit) external; + + /// @notice Set L1 gas fee for a specific chain. + /// @param chainNamespace Chain Namespace + /// @param l1GasFee L1 gas fee for the chain (in gas token units) + function setL1GasFeeByChain(string memory chainNamespace, uint256 l1GasFee) external; + + /// @notice Set TSS migration gas limit for a specific chain. + /// @param chainNamespace Chain Namespace + /// @param gasLimit TSS migration gas limit for the chain + function setTssFundMigrationGasLimitByChain(string memory chainNamespace, uint256 gasLimit) external; /// @notice Get the UniversalGatewayPC address. function universalGatewayPC() external view returns (address); @@ -246,9 +198,5 @@ interface IUniversalCoreV0 { /// @param prc20 PRC20 address to mint /// @param amount Amount to mint /// @param recipient Address to receive minted tokens - function mintPRCTokensviaAdmin( - address prc20, - uint256 amount, - address recipient - ) external; + function mintPRCTokensviaAdmin(address prc20, uint256 amount, address recipient) external; } diff --git a/src/testnetV0/PRC20V0.sol b/src/testnetV0/PRC20V0.sol index 03ffb82..8558d7e 100644 --- a/src/testnetV0/PRC20V0.sol +++ b/src/testnetV0/PRC20V0.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.26; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {IPRC20} from "../interfaces/IPRC20.sol"; import {PRC20Errors, CommonErrors} from "../libraries/Errors.sol"; @@ -172,8 +173,6 @@ contract PRC20 is IPRC20, Initializable { address recipient, uint256 amount ) external returns (bool) { - _transfer(sender, recipient, amount); - uint256 currentAllowance = _allowances[sender][msg.sender]; if (currentAllowance < amount) revert PRC20Errors.LowAllowance(); unchecked { @@ -183,6 +182,8 @@ contract PRC20 is IPRC20, Initializable { sender, msg.sender, _allowances[sender][msg.sender] ); + _transfer(sender, recipient, amount); + return true; } @@ -207,11 +208,14 @@ contract PRC20 is IPRC20, Initializable { ) { revert PRC20Errors.InvalidSender(); } + if (PausableUpgradeable(UNIVERSAL_CORE).paused()) { + revert PRC20Errors.CorePaused(); + } _mint(to, amount); emit Deposit( - abi.encodePacked(UNIVERSAL_EXECUTOR_MODULE), to, amount + abi.encodePacked(msg.sender), to, amount ); return true; } @@ -235,7 +239,9 @@ contract PRC20 is IPRC20, Initializable { function setName( string memory newName ) external onlyUniversalExecutor { + string memory oldName = _name; _name = newName; + emit NameUpdated(oldName, newName); } /// @notice Update token symbol. @@ -243,7 +249,9 @@ contract PRC20 is IPRC20, Initializable { function setSymbol( string memory newSymbol ) external onlyUniversalExecutor { + string memory oldSymbol = _symbol; _symbol = newSymbol; + emit SymbolUpdated(oldSymbol, newSymbol); } // ========================= diff --git a/src/testnetV0/UEAFactoryV0.sol b/src/testnetV0/UEAFactoryV0.sol index 2df8e05..3833987 100644 --- a/src/testnetV0/UEAFactoryV0.sol +++ b/src/testnetV0/UEAFactoryV0.sol @@ -9,29 +9,37 @@ import {UEAProxy} from "../uea/UEAProxy.sol"; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {AccessControlDefaultAdminRulesUpgradeable} from + "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlDefaultAdminRulesUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; /** * @title UEAFactoryV0 (TESTNET ONLY — DO NOT DEPLOY TO MAINNET) * @notice Testnet version of UEAFactory, preserved as-is from the live deployment * on Push Chain Donut Testnet. - * Note: Testnet Version includes OwneableUpgradeable but mainnet uses AccessControlUpgradeable. + * + * Access control: AccessControlDefaultAdminRulesUpgradeable (1-day delay). + * Roles: DEFAULT_ADMIN_ROLE (root), ROLE_MANAGER_ROLE (grants operational roles), + * UEA_ADMIN_ROLE (implementation + chain config), OPERATOR_ROLE (unpause), + * PAUSER_ROLE (pause only). */ -contract UEAFactoryV0 is Initializable, OwnableUpgradeable, PausableUpgradeable, IUEAFactory { +contract UEAFactoryV0 is Initializable, AccessControlDefaultAdminRulesUpgradeable, PausableUpgradeable, IUEAFactory { using Clones for address; // ========================= - // UF: STATE VARIABLES + // UF: ROLES // ========================= - /// @notice Role that can pause and unpause UEA deployments. + bytes32 public constant ROLE_MANAGER_ROLE = keccak256("ROLE_MANAGER_ROLE"); + bytes32 public constant UEA_ADMIN_ROLE = keccak256("UEA_ADMIN_ROLE"); + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); - /** - * @notice Maps role hash to the address holding that role. - * @dev STORAGE SLOT 0 — DEAD SLOT — DO NOT REMOVE OR REORDER. - */ + // ========================= + // UF: STATE VARIABLES + // ========================= + + /// @dev DEAD SLOT — preserved for storage layout compatibility. Do not use. mapping(bytes32 => address) public roles; /// @notice Maps VM type hashes to their UEA implementation addresses. @@ -52,6 +60,9 @@ contract UEAFactoryV0 is Initializable, OwnableUpgradeable, PausableUpgradeable, /// @notice The current UEA migration contract address. address public UEA_MIGRATION_CONTRACT; + /// @notice Push Chain numeric identifier used in the `getOriginForUEA` synthetic fallback. + string public pushChainId; + // ========================= // UF: CONSTRUCTOR // ========================= @@ -61,15 +72,6 @@ contract UEAFactoryV0 is Initializable, OwnableUpgradeable, PausableUpgradeable, _disableInitializers(); } - // ========================= - // UF: MODIFIERS - // ========================= - - modifier onlyPauser() { - if (roles[PAUSER_ROLE] != msg.sender) revert UEAErrors.InvalidInputArgs(); - _; - } - // ========================= // UF: INITIALIZER // ========================= @@ -79,10 +81,26 @@ contract UEAFactoryV0 is Initializable, OwnableUpgradeable, PausableUpgradeable, /// @param initialPauser Address granted the PAUSER_ROLE function initialize(address initialOwner, address initialPauser) public initializer { if (initialOwner == address(0) || initialPauser == address(0)) revert UEAErrors.InvalidInputArgs(); - __Ownable_init(initialOwner); __Pausable_init(); roles[PAUSER_ROLE] = initialPauser; - emit PauserRoleGranted(initialPauser); + } + + /// @dev Reinitializer to migrate from OwnableUpgradeable to RBAC. + /// @param _admin Admin address — granted DEFAULT_ADMIN_ROLE + all operational roles + /// @param _pauser Address granted the PAUSER_ROLE + function initializeV2(address _admin, address _pauser) public reinitializer(2) { + if (_admin == address(0) || _pauser == address(0)) revert UEAErrors.InvalidInputArgs(); + + __AccessControlDefaultAdminRules_init(1 days, _admin); + + _setRoleAdmin(UEA_ADMIN_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(OPERATOR_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(PAUSER_ROLE, ROLE_MANAGER_ROLE); + + _grantRole(ROLE_MANAGER_ROLE, _admin); + _grantRole(UEA_ADMIN_ROLE, _admin); + _grantRole(OPERATOR_ROLE, _admin); + _grantRole(PAUSER_ROLE, _pauser); } // ========================= @@ -130,6 +148,9 @@ contract UEAFactoryV0 is Initializable, OwnableUpgradeable, PausableUpgradeable, } /// @inheritdoc IUEAFactory + /// @dev When `isUEA` is false, `account` is a synthetic fallback built from + /// `"eip155"` + `pushChainId` + `addr` — NOT a registered origin. + /// Callers MUST check `isUEA` before trusting `account`. function getOriginForUEA(address addr) external view returns (UniversalAccountId memory account, bool isUEA) { account = UEA_to_UOA[addr]; @@ -137,7 +158,7 @@ contract UEAFactoryV0 is Initializable, OwnableUpgradeable, PausableUpgradeable, isUEA = true; } else { account = - UniversalAccountId({chainNamespace: "eip155", chainId: "42101", owner: bytes(abi.encodePacked(addr))}); + UniversalAccountId({chainNamespace: "eip155", chainId: pushChainId, owner: bytes(abi.encodePacked(addr))}); } return (account, isUEA); @@ -201,43 +222,41 @@ contract UEAFactoryV0 is Initializable, OwnableUpgradeable, PausableUpgradeable, // ========================= /// @notice Pause UEA deployments. Only callable by PAUSER_ROLE. - function pause() external onlyPauser { + function pause() external onlyRole(PAUSER_ROLE) { _pause(); } - /// @notice Unpause UEA deployments. Only callable by PAUSER_ROLE. - function unpause() external onlyPauser { + /// @notice Unpause UEA deployments. Only callable by OPERATOR_ROLE. + function unpause() external onlyRole(OPERATOR_ROLE) { _unpause(); } - /// @notice Grant PAUSER_ROLE to a new address. Only callable by owner. - /// @param newPauser Address to grant pauser role to - function setPauserRole(address newPauser) external onlyOwner { - if (newPauser == address(0)) revert UEAErrors.InvalidInputArgs(); - roles[PAUSER_ROLE] = newPauser; - emit PauserRoleGranted(newPauser); - } - - /// @notice Sets the UEAProxy implementation address. + /// @notice Sets the UEAProxy implementation address. Only callable by UEA_ADMIN_ROLE. /// @param ueaProxyImplementation New UEAProxy implementation address - function setUEAProxyImplementation(address ueaProxyImplementation) external onlyOwner { + function updateUEAProxyImplementation(address ueaProxyImplementation) external onlyRole(UEA_ADMIN_ROLE) { if (ueaProxyImplementation == address(0)) { revert UEAErrors.InvalidInputArgs(); } UEA_PROXY_IMPLEMENTATION = ueaProxyImplementation; } - /// @notice Sets the UEA migration contract address. + /// @notice Sets the UEA migration contract address. Only callable by UEA_ADMIN_ROLE. /// @param ueaMigrationContract New migration contract address - function setUEAMigrationContract(address ueaMigrationContract) external onlyOwner { + function updateUEAMigrationContract(address ueaMigrationContract) external onlyRole(UEA_ADMIN_ROLE) { if (ueaMigrationContract == address(0)) { revert UEAErrors.InvalidInputArgs(); } UEA_MIGRATION_CONTRACT = ueaMigrationContract; } + /// @notice Update `pushChainId`. Reverts on empty string. Only callable by UEA_ADMIN_ROLE. + function updatePushChainId(string memory _pushChainId) external onlyRole(UEA_ADMIN_ROLE) { + if (bytes(_pushChainId).length == 0) revert UEAErrors.InvalidInputArgs(); + pushChainId = _pushChainId; + } + /// @inheritdoc IUEAFactory - function registerNewChain(bytes32 _chainHash, bytes32 _vmHash) external onlyOwner { + function registerNewChain(bytes32 _chainHash, bytes32 _vmHash) external onlyRole(UEA_ADMIN_ROLE) { (, bool isRegistered) = getVMType(_chainHash); if (isRegistered) { revert UEAErrors.InvalidInputArgs(); @@ -250,19 +269,24 @@ contract UEAFactoryV0 is Initializable, OwnableUpgradeable, PausableUpgradeable, /// @inheritdoc IUEAFactory function registerMultipleUEA(bytes32[] memory _chainHashes, bytes32[] memory _vmHashes, address[] memory _UEA) external - onlyOwner + onlyRole(UEA_ADMIN_ROLE) { if (_UEA.length != _vmHashes.length || _UEA.length != _chainHashes.length) { revert UEAErrors.InvalidInputArgs(); } for (uint256 i = 0; i < _UEA.length; i++) { - registerUEA(_chainHashes[i], _vmHashes[i], _UEA[i]); + _registerUEA(_chainHashes[i], _vmHashes[i], _UEA[i]); } } /// @inheritdoc IUEAFactory - function registerUEA(bytes32 _chainHash, bytes32 _vmHash, address _UEA) public onlyOwner { + function registerUEA(bytes32 _chainHash, bytes32 _vmHash, address _UEA) public onlyRole(UEA_ADMIN_ROLE) { + _registerUEA(_chainHash, _vmHash, _UEA); + } + + /// @dev Internal registration logic with overwrite protection. + function _registerUEA(bytes32 _chainHash, bytes32 _vmHash, address _UEA) internal { if (_UEA == address(0)) { revert UEAErrors.InvalidInputArgs(); } @@ -272,10 +296,30 @@ contract UEAFactoryV0 is Initializable, OwnableUpgradeable, PausableUpgradeable, revert UEAErrors.InvalidInputArgs(); } + if (UEA_VM[_vmHash] != address(0)) { + revert UEAErrors.UEAAlreadyRegistered(); + } + UEA_VM[_vmHash] = _UEA; emit UEARegistered(_chainHash, _UEA, _vmHash); } + /// @notice Replace the registered UEA implementation for a VM hash. + /// @param _vmHash VM hash whose implementation is being updated + /// @param _newUEA New UEA implementation address (must be non-zero) + function updateUEAImplementation(bytes32 _vmHash, address _newUEA) external onlyRole(UEA_ADMIN_ROLE) { + if (_newUEA == address(0)) { + revert UEAErrors.InvalidInputArgs(); + } + address previous = UEA_VM[_vmHash]; + if (previous == address(0)) { + revert UEAErrors.InvalidInputArgs(); + } + + UEA_VM[_vmHash] = _newUEA; + emit UEAImplementationUpdated(_vmHash, previous, _newUEA); + } + // ========================= // UF_4: PUBLIC HELPERS // ========================= diff --git a/src/testnetV0/UniversalCoreV0.sol b/src/testnetV0/UniversalCoreV0.sol index 212cf35..2bf9b1c 100644 --- a/src/testnetV0/UniversalCoreV0.sol +++ b/src/testnetV0/UniversalCoreV0.sol @@ -3,34 +3,36 @@ pragma solidity 0.8.26; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { + AccessControlDefaultAdminRulesUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlDefaultAdminRulesUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import {IPRC20} from "../interfaces/IPRC20.sol"; -import {IUniversalCoreV0} from "./IUniversalCoreV0.sol"; +import {IUniversalCore} from "./IUniversalCore.sol"; import {IUniswapV3Factory, ISwapRouter} from "../interfaces/uniswapv3/IUniswapV3.sol"; import {IWPC} from "../interfaces/IWPC.sol"; import {UniversalCoreErrors, CommonErrors} from "../libraries/Errors.sol"; /** - * @title UniversalCoreV0 + * @title UniversalCore (testnet) * @notice Temporary UniversalCore contract for Push Chain TESTNET. - * The UniversalCoreV0 acts as the core contract for all functionalities + * The UniversalCore acts as the core contract for all functionalities * needed by the interoperability feature of Push Chain. - * @dev The UniversalCoreV0 primarily handles the following functionalities: + * @dev The UniversalCore primarily handles the following functionalities: * - Generation of supported PRC-20 tokens, and transferring it to accurate recipients. * - Setting up the gas tokens for each chain. * - Setting up the gas price for each chain. * - Maintaining a registry of Uniswap V3 pools for each token pair. * @dev All imperative functionalities are handled by the Universal Executor Module. */ -contract UniversalCoreV0 is - IUniversalCoreV0, +contract UniversalCore is + IUniversalCore, Initializable, ReentrancyGuardUpgradeable, - AccessControlUpgradeable, + AccessControlDefaultAdminRulesUpgradeable, PausableUpgradeable { using SafeERC20 for IERC20; @@ -54,8 +56,8 @@ contract UniversalCoreV0 is /// @notice Default fee tier for each token (0 = not set). mapping(address => uint24) public defaultFeeTier; - /// @notice Slippage tolerance for each token in basis points (e.g., 300 = 3%). - mapping(address => uint256) public slippageTolerance; + /// @dev Deprecated. Slot retained for storage layout compatibility with deployed testnet proxy. + mapping(address => uint256) private __deprecated_slippageTolerance; /// @notice Default deadline in minutes for swaps. uint256 public defaultDeadlineMins = 20; @@ -69,7 +71,7 @@ contract UniversalCoreV0 is /// @notice Uniswap V3 SwapRouter. address public uniswapV3SwapRouter; - /// @notice Uniswap V3 Quoter. + /// @dev Deprecated. Slot retained for storage layout compatibility with deployed testnet proxy. address public uniswapV3Quoter; /// @notice Address of the wrapped PC to interact with Uniswap V3. @@ -87,12 +89,26 @@ contract UniversalCoreV0 is /// @notice Role for managing gas-related configurations. bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); + bytes32 public constant ROLE_MANAGER_ROLE = keccak256("ROLE_MANAGER_ROLE"); + bytes32 public constant UVCORE_ADMIN_ROLE = keccak256("UVCORE_ADMIN_ROLE"); + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + // -- Uniswap V3 fee tiers -- + uint24 public constant FEE_TIER_LOWEST = 100; + uint24 public constant FEE_TIER_LOW = 500; + uint24 public constant FEE_TIER_MEDIUM = 3000; + uint24 public constant FEE_TIER_HIGH = 10000; + + // -- Slippage cap (basis points) -- + uint256 public constant MAX_SLIPPAGE_BPS = 5000; + /// @notice (Deprecated) Base gas limit — now per-chain via baseGasLimitByChainNamespace. /// @dev Only included to avoid storage collision in Testnet UniversalCore. uint256 public BASE_GAS_LIMIT = 500_000; - /// @notice Mapping for indicating an official PRC20 supported token. - mapping(address => bool) public isSupportedToken; + /// @dev Deprecated. Slot retained for storage layout compatibility with deployed testnet proxy. + mapping(address => bool) private __deprecated_isSupportedToken; /// @notice Address of the UniversalGatewayPC that can call swapAndBurnGas. address public universalGatewayPC; @@ -112,6 +128,15 @@ contract UniversalCoreV0 is /// @notice Rescue funds gas limit per chain namespace. mapping(string => uint256) public rescueFundsGasLimitByChainNamespace; + /// @notice Maximum acceptable age (seconds) of gas data before quotes are rejected as stale. + mapping(string => uint256) public maxStalenessByChainNamespace; + + /// @notice L1 gas fee per chain namespace (in gas token units). + mapping(string => uint256) public l1GasFeeByChainNamespace; + + /// @notice TSS fund migration gas limit per chain namespace. + mapping(string => uint256) public tssFundMigrationGasLimitByChainNamespace; + // ========================= // UCV0: MODIFIERS // ========================= @@ -130,13 +155,6 @@ contract UniversalCoreV0 is _; } - modifier onlyAdmin() { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) { - revert CommonErrors.InvalidOwner(); - } - _; - } - // ========================= // UCV0: CONSTRUCTOR // ========================= @@ -156,7 +174,6 @@ contract UniversalCoreV0 is initializer { __ReentrancyGuard_init(); - __AccessControl_init(); _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); @@ -166,17 +183,40 @@ contract UniversalCoreV0 is uniswapV3Quoter = uniswapV3Quoter_; } + /// @dev Reinitializer to migrate to granular RBAC with admin transfer delay. + /// @param _admin Admin address — granted DEFAULT_ADMIN_ROLE + all operational roles + /// @param _pauser Address granted the PAUSER_ROLE + function initializeV2(address _admin, address _pauser) public reinitializer(2) { + if (_admin == address(0) || _pauser == address(0)) revert CommonErrors.ZeroAddress(); + + __AccessControlDefaultAdminRules_init(1 days, _admin); + + _setRoleAdmin(UVCORE_ADMIN_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(OPERATOR_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(PAUSER_ROLE, ROLE_MANAGER_ROLE); + + _grantRole(ROLE_MANAGER_ROLE, _admin); + _grantRole(UVCORE_ADMIN_ROLE, _admin); + _grantRole(OPERATOR_ROLE, _admin); + _grantRole(PAUSER_ROLE, _pauser); + } + // ========================= // UCV0_1: UE MODULE ACTIONS // ========================= - /// @inheritdoc IUniversalCoreV0 - function depositPRC20Token(address prc20, uint256 amount, address recipient) external onlyUEModule whenNotPaused { + /// @inheritdoc IUniversalCore + function depositPRC20Token(address prc20, uint256 amount, address recipient) + external + onlyUEModule + whenNotPaused + nonReentrant + { _validateParams(prc20, amount, recipient); - IPRC20(prc20).deposit(recipient, amount); + if (!IPRC20(prc20).deposit(recipient, amount)) revert UniversalCoreErrors.PRC20OperationFailed(); } - /// @inheritdoc IUniversalCoreV0 + /// @inheritdoc IUniversalCore function depositPRC20WithAutoSwap( address prc20, uint256 amount, @@ -192,7 +232,7 @@ contract UniversalCoreV0 is emit DepositPRC20WithAutoSwap(prc20, amount, WPC, pcOut, resolvedFee, recipient); } - /// @inheritdoc IUniversalCoreV0 + /// @inheritdoc IUniversalCore function refundUnusedGas( address gasToken, uint256 amount, @@ -206,7 +246,7 @@ contract UniversalCoreV0 is uint256 pcOut; if (!withSwap) { - IPRC20(gasToken).deposit(recipient, amount); + if (!IPRC20(gasToken).deposit(recipient, amount)) revert UniversalCoreErrors.PRC20OperationFailed(); } else { if (minPCOut == 0) { revert UniversalCoreErrors.MinPCOutRequired(); @@ -221,7 +261,7 @@ contract UniversalCoreV0 is // UCV0_2: GATEWAY ACTIONS // ========================= - /// @inheritdoc IUniversalCoreV0 + /// @inheritdoc IUniversalCore function swapAndBurnGas(address gasToken, uint24 fee, uint256 gasFee, uint256 deadline, address caller) external payable @@ -251,7 +291,7 @@ contract UniversalCoreV0 is IWPC(WPC).deposit{value: msg.value}(); - IERC20(WPC).approve(uniswapV3SwapRouter, msg.value); + IERC20(WPC).forceApprove(uniswapV3SwapRouter, msg.value); ISwapRouter.ExactOutputSingleParams memory params = ISwapRouter.ExactOutputSingleParams({ tokenIn: WPC, @@ -265,9 +305,9 @@ contract UniversalCoreV0 is }); uint256 amountInUsed = ISwapRouter(uniswapV3SwapRouter).exactOutputSingle(params); - IERC20(WPC).approve(uniswapV3SwapRouter, 0); + IERC20(WPC).forceApprove(uniswapV3SwapRouter, 0); - IPRC20(gasToken).burn(gasFee); + if (!IPRC20(gasToken).burn(gasFee)) revert UniversalCoreErrors.PRC20OperationFailed(); gasTokenOut = gasFee; refund = msg.value - amountInUsed; @@ -284,14 +324,22 @@ contract UniversalCoreV0 is // UCV0_3: PUBLIC GETTERS // ========================= - /// @inheritdoc IUniversalCoreV0 + /// @inheritdoc IUniversalCore function getOutboundTxGasAndFees(address _prc20, uint256 gasLimitWithBaseLimit) public view - returns (address gasToken, uint256 gasFee, uint256 protocolFee, uint256 gasPrice, string memory chainNamespace) + returns ( + address gasToken, + uint256 gasFee, + uint256 protocolFee, + uint256 gasPrice, + string memory chainNamespace, + uint256 gasLimitUsed + ) { chainNamespace = IPRC20(_prc20).SOURCE_CHAIN_NAMESPACE(); uint256 baseLimit = baseGasLimitByChainNamespace[chainNamespace]; + if (baseLimit == 0) revert UniversalCoreErrors.ZeroBaseGasLimit(); if (gasLimitWithBaseLimit == 0) { gasLimitWithBaseLimit = baseLimit; @@ -305,11 +353,14 @@ contract UniversalCoreV0 is gasPrice = gasPriceByChainNamespace[chainNamespace]; if (gasPrice == 0) revert UniversalCoreErrors.ZeroGasPrice(); + _validateGasDataFreshness(chainNamespace); + gasFee = gasPrice * gasLimitWithBaseLimit; protocolFee = protocolFeeByToken[_prc20]; + gasLimitUsed = gasLimitWithBaseLimit; } - /// @inheritdoc IUniversalCoreV0 + /// @inheritdoc IUniversalCore function getRescueFundsGasLimit(address _prc20) public view @@ -334,6 +385,8 @@ contract UniversalCoreV0 is gasPrice = gasPriceByChainNamespace[chainNamespace]; if (gasPrice == 0) revert UniversalCoreErrors.ZeroGasPrice(); + _validateGasDataFreshness(chainNamespace); + gasFee = gasPrice * rescueGasLimit; } @@ -344,26 +397,20 @@ contract UniversalCoreV0 is /// @notice Set protocol fee (in native PC) for a token. /// @param token Token address /// @param fee Protocol fee amount in native PC - function setProtocolFeeByToken(address token, uint256 fee) external onlyRole(MANAGER_ROLE) { + function updateProtocolFeeByToken(address token, uint256 fee) external onlyRole(UVCORE_ADMIN_ROLE) { if (token == address(0)) revert CommonErrors.ZeroAddress(); protocolFeeByToken[token] = fee; emit SetProtocolFeeByToken(token, fee); } - /// @notice Set whether a PRC20 token is supported. - /// @param prc20 PRC20 token address - /// @param supported Whether the token is supported - function setSupportedToken(address prc20, bool supported) external onlyRole(MANAGER_ROLE) { - if (prc20 == address(0)) revert CommonErrors.ZeroAddress(); - isSupportedToken[prc20] = supported; - emit SetSupportedToken(prc20, supported); - } - - /// @notice Set the gas PC pool for a chain. + /// @notice Set the gas PC pool for a chain (informational — not enforced at runtime). /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @param gasToken Gas coin address /// @param fee Uniswap V3 fee tier - function setGasPCPool(string memory chainNamespace, address gasToken, uint24 fee) external onlyRole(MANAGER_ROLE) { + function updateGasPCPool(string memory chainNamespace, address gasToken, uint24 fee) + external + onlyRole(UVCORE_ADMIN_ROLE) + { if (gasToken == address(0)) revert CommonErrors.ZeroAddress(); address pool = IUniswapV3Factory(uniswapV3Factory) @@ -374,25 +421,14 @@ contract UniversalCoreV0 is emit SetGasPCPool(chainNamespace, pool, fee); } - /// @notice To Be Removed — use setChainMeta instead. - /// @dev Fungible module updates the gas price oracle periodically. - /// @param chainNamespace Chain Namespace - /// @param price New gas price - function setGasPrice(string memory chainNamespace, uint256 price) external onlyUEModule { - gasPriceByChainNamespace[chainNamespace] = price; - emit SetGasPrice(chainNamespace, price); - } - /// @notice Set gas price, chain height, and observation timestamp for a chain. /// @dev `observedAt` is set to `block.timestamp` of the Push Chain block /// in which this call is included. /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @param price Gas price on the external chain /// @param chainHeight Block height observed on the external chain - function setChainMeta(string memory chainNamespace, uint256 price, uint256 chainHeight) - external - onlyUEModule - { + function setChainMeta(string memory chainNamespace, uint256 price, uint256 chainHeight) external onlyUEModule { + if (price == 0) revert UniversalCoreErrors.ZeroGasPrice(); gasPriceByChainNamespace[chainNamespace] = price; chainHeightByChainNamespace[chainNamespace] = chainHeight; timestampObservedAtByChainNamespace[chainNamespace] = block.timestamp; @@ -402,9 +438,10 @@ contract UniversalCoreV0 is /// @notice Setter for gasTokenPRC20ByChainNamespace map. /// @param chainNamespace Chain Namespace /// @param prc20 PRC20 address - function setGasTokenPRC20(string memory chainNamespace, address prc20) external onlyRole(MANAGER_ROLE) { + function updateGasTokenPRC20(string memory chainNamespace, address prc20) external onlyRole(UVCORE_ADMIN_ROLE) { if (prc20 == address(0)) revert CommonErrors.ZeroAddress(); gasTokenPRC20ByChainNamespace[chainNamespace] = prc20; + gasPriceByChainNamespace[chainNamespace] = 0; emit SetGasToken(chainNamespace, prc20); } @@ -416,70 +453,71 @@ contract UniversalCoreV0 is /// @param prc20 PRC20 address for deposit /// @param amount Amount to deposit /// @param recipient Address to deposit tokens to - function mintPRCTokensviaAdmin(address prc20, uint256 amount, address recipient) external onlyAdmin whenNotPaused { + function mintPRCTokensviaAdmin(address prc20, uint256 amount, address recipient) + external + onlyRole(UVCORE_ADMIN_ROLE) + whenNotPaused + { _validateParams(prc20, amount, recipient); - IPRC20(prc20).deposit(recipient, amount); + if (!IPRC20(prc20).deposit(recipient, amount)) revert UniversalCoreErrors.PRC20OperationFailed(); } /// @notice Set auto-swap support for a token. /// @param token Token address /// @param supported Whether the token supports auto-swap - function setAutoSwapSupported(address token, bool supported) external onlyAdmin { + function updateAutoSwapSupported(address token, bool supported) external onlyRole(UVCORE_ADMIN_ROLE) { isAutoSwapSupported[token] = supported; + emit SetAutoSwapSupported(token, supported); } /// @notice Set the wrapped PC address. /// @param addr WPC new address - function setWPC(address addr) external onlyAdmin { + function updateWPC(address addr) external onlyRole(OPERATOR_ROLE) { if (addr == address(0)) revert CommonErrors.ZeroAddress(); + address oldAddr = WPC; WPC = addr; + emit SetWPC(oldAddr, addr); } /// @notice Set the UniversalGatewayPC address. /// @param addr UniversalGatewayPC address - function setUniversalGatewayPC(address addr) external onlyAdmin { + function updateUniversalGatewayPC(address addr) external onlyRole(OPERATOR_ROLE) { if (addr == address(0)) revert CommonErrors.ZeroAddress(); + address oldAddr = universalGatewayPC; universalGatewayPC = addr; + emit SetUniversalGatewayPC(oldAddr, addr); } /// @notice Setter for Uniswap V3 addresses. /// @param factory Uniswap V3 Factory address /// @param swapRouter Uniswap V3 SwapRouter address - /// @param quoter Uniswap V3 Quoter address - function setUniswapV3Addresses(address factory, address swapRouter, address quoter) external onlyAdmin { - if (factory == address(0) || swapRouter == address(0) || quoter == address(0)) { + function updateUniswapV3Addresses(address factory, address swapRouter) external onlyRole(OPERATOR_ROLE) { + if (factory == address(0) || swapRouter == address(0)) { revert CommonErrors.ZeroAddress(); } uniswapV3Factory = factory; uniswapV3SwapRouter = swapRouter; - uniswapV3Quoter = quoter; + emit SetUniswapV3Addresses(factory, swapRouter); } /// @notice Set default fee tier for a token. /// @param token Token address - /// @param feeTier Fee tier (500, 3000, 10000) - function setDefaultFeeTier(address token, uint24 feeTier) external onlyAdmin { + /// @param feeTier Fee tier (100, 500, 3000, 10000) + function updateDefaultFeeTier(address token, uint24 feeTier) external onlyRole(UVCORE_ADMIN_ROLE) { if (token == address(0)) revert CommonErrors.ZeroAddress(); - if (feeTier != 500 && feeTier != 3000 && feeTier != 10000) { + if ( + feeTier != FEE_TIER_LOWEST && feeTier != FEE_TIER_LOW && feeTier != FEE_TIER_MEDIUM + && feeTier != FEE_TIER_HIGH + ) { revert UniversalCoreErrors.InvalidFeeTier(); } defaultFeeTier[token] = feeTier; - } - - /// @notice Set slippage tolerance for a token. - /// @param token Token address - /// @param tolerance Slippage tolerance in basis points (e.g., 300 = 3%) - function setSlippageTolerance(address token, uint256 tolerance) external onlyAdmin { - if (token == address(0)) revert CommonErrors.ZeroAddress(); - if (tolerance > 5000) { - revert UniversalCoreErrors.InvalidSlippageTolerance(); - } - slippageTolerance[token] = tolerance; + emit SetDefaultFeeTier(token, feeTier); } /// @notice Set default deadline in minutes. /// @param minutesValue Default deadline in minutes - function setDefaultDeadlineMins(uint256 minutesValue) external onlyAdmin { + function updateDefaultDeadlineMins(uint256 minutesValue) external onlyRole(UVCORE_ADMIN_ROLE) { defaultDeadlineMins = minutesValue; emit SetDefaultDeadlineMins(minutesValue); } @@ -487,7 +525,10 @@ contract UniversalCoreV0 is /// @notice Set base gas limit for a specific chain. /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @param gasLimit Base gas limit for the chain - function setBaseGasLimitByChain(string memory chainNamespace, uint256 gasLimit) external onlyRole(MANAGER_ROLE) { + function updateBaseGasLimitByChain(string memory chainNamespace, uint256 gasLimit) + external + onlyRole(UVCORE_ADMIN_ROLE) + { baseGasLimitByChainNamespace[chainNamespace] = gasLimit; emit SetBaseGasLimitByChain(chainNamespace, gasLimit); } @@ -495,28 +536,73 @@ contract UniversalCoreV0 is /// @notice Set rescue funds gas limit for a specific chain. /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) /// @param gasLimit Rescue funds gas limit for the chain - function setRescueFundsGasLimitByChain(string memory chainNamespace, uint256 gasLimit) + function updateRescueFundsGasLimitByChain(string memory chainNamespace, uint256 gasLimit) external - onlyRole(MANAGER_ROLE) + onlyRole(UVCORE_ADMIN_ROLE) { rescueFundsGasLimitByChainNamespace[chainNamespace] = gasLimit; emit SetRescueFundsGasLimitByChain(chainNamespace, gasLimit); } - /// @notice Pause the contract - stops all deposit functions. - function pause() external onlyAdmin { + /// @notice Set the maximum acceptable age (seconds) of gas data for a chain. + /// @dev A value of `0` disables the staleness check for that chain (opt-in). + /// When set, `getOutboundTxGasAndFees` and `getRescueFundsGasLimit` + /// revert with `StaleGasData` if the chain's observed timestamp is + /// older than `block.timestamp - maxStaleness`. + /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) + /// @param maxStaleness Maximum acceptable age of gas data in seconds (0 disables) + function updateMaxStalenessByChain(string memory chainNamespace, uint256 maxStaleness) + external + onlyRole(UVCORE_ADMIN_ROLE) + { + maxStalenessByChainNamespace[chainNamespace] = maxStaleness; + emit SetMaxStalenessByChain(chainNamespace, maxStaleness); + } + + /// @notice Set L1 gas fee for a specific chain. + /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) + /// @param l1GasFee L1 gas fee for the chain (in gas token units) + function setL1GasFeeByChain(string memory chainNamespace, uint256 l1GasFee) external onlyRole(UVCORE_ADMIN_ROLE) { + l1GasFeeByChainNamespace[chainNamespace] = l1GasFee; + emit SetL1GasFeeByChain(chainNamespace, l1GasFee); + } + + /// @notice Set TSS migration gas limit for a specific chain. + /// @param chainNamespace Chain Namespace (e.g. "eip155:1" for Ethereum Mainnet) + /// @param gasLimit TSS migration gas limit for the chain + function setTssFundMigrationGasLimitByChain(string memory chainNamespace, uint256 gasLimit) + external + onlyRole(UVCORE_ADMIN_ROLE) + { + tssFundMigrationGasLimitByChainNamespace[chainNamespace] = gasLimit; + emit SetTssFundMigrationGasLimitByChain(chainNamespace, gasLimit); + } + + /// @notice Pause the contract - stops all deposit functions. Only callable by PAUSER_ROLE. + function pause() external onlyRole(PAUSER_ROLE) { _pause(); } - /// @notice Unpause the contract - resumes all deposit functions. - function unpause() external onlyAdmin { + /// @notice Unpause the contract - resumes all deposit functions. Only callable by OPERATOR_ROLE. + function unpause() external onlyRole(OPERATOR_ROLE) { _unpause(); } + /// @notice Rescue native PC stuck in the contract. Only callable by UVCORE_ADMIN_ROLE. + /// @param to Recipient address for the rescued PC + /// @param amount Amount of native PC to rescue + function rescueNativePC(address payable to, uint256 amount) external onlyRole(UVCORE_ADMIN_ROLE) { + if (to == address(0)) revert CommonErrors.ZeroAddress(); + if (amount == 0) revert CommonErrors.ZeroAmount(); + if (amount > address(this).balance) revert CommonErrors.InsufficientBalance(); + (bool ok,) = to.call{value: amount}(""); + if (!ok) revert CommonErrors.TransferFailed(); + emit RescueNativePC(to, amount); + } + // ========================= // UCV0_6: PRIVATE HELPERS // ========================= - /// @dev Shared input validation for deposit/refund functions. /// @param token Token address to validate /// @param amount Amount to validate (must be > 0) @@ -530,6 +616,21 @@ contract UniversalCoreV0 is if (amount == 0) revert CommonErrors.ZeroAmount(); } + /// @dev Enforces that gas data for `chainNamespace` is within the configured freshness + /// window. No-op when `maxStalenessByChainNamespace[chainNamespace]` is `0` + /// (check disabled). Reverts with `StaleGasData` when the data is older than + /// the configured max age, carrying the observed timestamp, current timestamp, + /// and max age in the revert data. + /// @param chainNamespace Chain namespace whose freshness is being validated + function _validateGasDataFreshness(string memory chainNamespace) private view { + uint256 maxAge = maxStalenessByChainNamespace[chainNamespace]; + if (maxAge == 0) return; + uint256 observedAt = timestampObservedAtByChainNamespace[chainNamespace]; + if (block.timestamp > observedAt + maxAge) { + revert UniversalCoreErrors.StaleGasData(observedAt, block.timestamp, maxAge); + } + } + /// @dev Swap PRC20 to native PC via Uniswap V3 and send to recipient. /// @param prc20 PRC20 token address to swap /// @param amount Amount of PRC20 to swap @@ -564,8 +665,8 @@ contract UniversalCoreV0 is if (minPCOut == 0) revert CommonErrors.ZeroAmount(); - IPRC20(prc20).deposit(address(this), amount); - IPRC20(prc20).approve(uniswapV3SwapRouter, amount); + if (!IPRC20(prc20).deposit(address(this), amount)) revert UniversalCoreErrors.PRC20OperationFailed(); + IERC20(prc20).forceApprove(uniswapV3SwapRouter, amount); ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ tokenIn: prc20, @@ -581,7 +682,7 @@ contract UniversalCoreV0 is pcOut = ISwapRouter(uniswapV3SwapRouter).exactInputSingle(params); if (pcOut < minPCOut) revert UniversalCoreErrors.SlippageExceeded(); - IPRC20(prc20).approve(uniswapV3SwapRouter, 0); + IERC20(prc20).forceApprove(uniswapV3SwapRouter, 0); IWPC(WPC).withdraw(pcOut); (bool ok,) = recipient.call{value: pcOut}(""); diff --git a/src/uea/UEAFactory.sol b/src/uea/UEAFactory.sol index 99fd195..341fb70 100644 --- a/src/uea/UEAFactory.sol +++ b/src/uea/UEAFactory.sol @@ -9,7 +9,8 @@ import {UEAProxy} from "./UEAProxy.sol"; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {AccessControlDefaultAdminRulesUpgradeable} from + "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlDefaultAdminRulesUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; /** @@ -18,18 +19,21 @@ import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/Pau * @dev Uses OZ Clones library for deterministic CREATE2 deployment of UEA proxies. * Maps external chain identities to UEA addresses on Push Chain. * - * Access control uses OpenZeppelin AccessControl: - * - DEFAULT_ADMIN_ROLE: governance — can update all config and grant roles. - * - PAUSER_ROLE: guardian hot-wallet — can pause/unpause only. + * Access control: AccessControlDefaultAdminRulesUpgradeable (2-day delay). + * Roles: DEFAULT_ADMIN_ROLE (root), ROLE_MANAGER_ROLE (grants operational roles), + * UEA_ADMIN_ROLE (implementation + chain config), OPERATOR_ROLE (unpause), + * PAUSER_ROLE (pause only). */ -contract UEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradeable, IUEAFactory { +contract UEAFactory is Initializable, AccessControlDefaultAdminRulesUpgradeable, PausableUpgradeable, IUEAFactory { using Clones for address; // ========================= // UF: ROLES // ========================= - /// @notice Role that can pause and unpause UEA deployments. + bytes32 public constant ROLE_MANAGER_ROLE = keccak256("ROLE_MANAGER_ROLE"); + bytes32 public constant UEA_ADMIN_ROLE = keccak256("UEA_ADMIN_ROLE"); + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); // ========================= @@ -54,6 +58,9 @@ contract UEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea /// @notice The current UEA migration contract address. address public UEA_MIGRATION_CONTRACT; + /// @notice Push Chain numeric identifier used in the `getOriginForUEA` synthetic fallback. + string public pushChainId; + // ========================= // UF: CONSTRUCTOR // ========================= @@ -68,15 +75,26 @@ contract UEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea // ========================= /// @dev Initializer for the upgradeable UEAFactory. - /// @param initialAdmin Initial admin — granted DEFAULT_ADMIN_ROLE (governance) - /// @param initialPauser Address granted the PAUSER_ROLE - function initialize(address initialAdmin, address initialPauser) public initializer { - if (initialAdmin == address(0) || initialPauser == address(0)) revert UEAErrors.InvalidInputArgs(); - __AccessControl_init(); + /// @param _admin Admin address — granted DEFAULT_ADMIN_ROLE + all operational roles + /// @param _pauser Address granted the PAUSER_ROLE + /// @param _pushChainId Push Chain numeric identifier (e.g. "42101") + function initialize(address _admin, address _pauser, string memory _pushChainId) public initializer { + if (_admin == address(0) || _pauser == address(0)) revert UEAErrors.InvalidInputArgs(); + if (bytes(_pushChainId).length == 0) revert UEAErrors.InvalidInputArgs(); + + __AccessControlDefaultAdminRules_init(1 days, _admin); __Pausable_init(); - _grantRole(DEFAULT_ADMIN_ROLE, initialAdmin); - _grantRole(PAUSER_ROLE, initialPauser); - emit PauserRoleGranted(initialPauser); + + _setRoleAdmin(UEA_ADMIN_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(OPERATOR_ROLE, ROLE_MANAGER_ROLE); + _setRoleAdmin(PAUSER_ROLE, ROLE_MANAGER_ROLE); + + _grantRole(ROLE_MANAGER_ROLE, _admin); + _grantRole(UEA_ADMIN_ROLE, _admin); + _grantRole(OPERATOR_ROLE, _admin); + _grantRole(PAUSER_ROLE, _pauser); + + pushChainId = _pushChainId; } // ========================= @@ -124,6 +142,9 @@ contract UEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea } /// @inheritdoc IUEAFactory + /// @dev When `isUEA` is false, `account` is a synthetic fallback built from + /// `"eip155"` + `pushChainId` + `addr` — NOT a registered origin. + /// Callers MUST check `isUEA` before trusting `account`. function getOriginForUEA(address addr) external view returns (UniversalAccountId memory account, bool isUEA) { account = UEA_to_UOA[addr]; @@ -131,7 +152,7 @@ contract UEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea isUEA = true; } else { account = - UniversalAccountId({chainNamespace: "eip155", chainId: "42101", owner: bytes(abi.encodePacked(addr))}); + UniversalAccountId({chainNamespace: "eip155", chainId: pushChainId, owner: bytes(abi.encodePacked(addr))}); } return (account, isUEA); @@ -199,39 +220,37 @@ contract UEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea _pause(); } - /// @notice Unpause UEA deployments. Only callable by PAUSER_ROLE. - function unpause() external onlyRole(PAUSER_ROLE) { + /// @notice Unpause UEA deployments. Only callable by OPERATOR_ROLE. + function unpause() external onlyRole(OPERATOR_ROLE) { _unpause(); } - /// @notice Grant PAUSER_ROLE to a new address. Only callable by DEFAULT_ADMIN_ROLE. - /// @param newPauser Address to grant pauser role to - function setPauserRole(address newPauser) external onlyRole(DEFAULT_ADMIN_ROLE) { - if (newPauser == address(0)) revert UEAErrors.InvalidInputArgs(); - _grantRole(PAUSER_ROLE, newPauser); - emit PauserRoleGranted(newPauser); - } - - /// @notice Sets the UEAProxy implementation address. + /// @notice Sets the UEAProxy implementation address. Only callable by UEA_ADMIN_ROLE. /// @param ueaProxyImplementation New UEAProxy implementation address - function setUEAProxyImplementation(address ueaProxyImplementation) external onlyRole(DEFAULT_ADMIN_ROLE) { + function updateUEAProxyImplementation(address ueaProxyImplementation) external onlyRole(UEA_ADMIN_ROLE) { if (ueaProxyImplementation == address(0)) { revert UEAErrors.InvalidInputArgs(); } UEA_PROXY_IMPLEMENTATION = ueaProxyImplementation; } - /// @notice Sets the UEA migration contract address. + /// @notice Sets the UEA migration contract address. Only callable by UEA_ADMIN_ROLE. /// @param ueaMigrationContract New migration contract address - function setUEAMigrationContract(address ueaMigrationContract) external onlyRole(DEFAULT_ADMIN_ROLE) { + function updateUEAMigrationContract(address ueaMigrationContract) external onlyRole(UEA_ADMIN_ROLE) { if (ueaMigrationContract == address(0)) { revert UEAErrors.InvalidInputArgs(); } UEA_MIGRATION_CONTRACT = ueaMigrationContract; } + /// @notice Update `pushChainId`. Reverts on empty string. Only callable by UEA_ADMIN_ROLE. + function updatePushChainId(string memory _pushChainId) external onlyRole(UEA_ADMIN_ROLE) { + if (bytes(_pushChainId).length == 0) revert UEAErrors.InvalidInputArgs(); + pushChainId = _pushChainId; + } + /// @inheritdoc IUEAFactory - function registerNewChain(bytes32 _chainHash, bytes32 _vmHash) external onlyRole(DEFAULT_ADMIN_ROLE) { + function registerNewChain(bytes32 _chainHash, bytes32 _vmHash) external onlyRole(UEA_ADMIN_ROLE) { (, bool isRegistered) = getVMType(_chainHash); if (isRegistered) { revert UEAErrors.InvalidInputArgs(); @@ -244,19 +263,28 @@ contract UEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea /// @inheritdoc IUEAFactory function registerMultipleUEA(bytes32[] memory _chainHashes, bytes32[] memory _vmHashes, address[] memory _UEA) external - onlyRole(DEFAULT_ADMIN_ROLE) + onlyRole(UEA_ADMIN_ROLE) { if (_UEA.length != _vmHashes.length || _UEA.length != _chainHashes.length) { revert UEAErrors.InvalidInputArgs(); } for (uint256 i = 0; i < _UEA.length; i++) { - registerUEA(_chainHashes[i], _vmHashes[i], _UEA[i]); + _registerUEA(_chainHashes[i], _vmHashes[i], _UEA[i]); } } /// @inheritdoc IUEAFactory - function registerUEA(bytes32 _chainHash, bytes32 _vmHash, address _UEA) public onlyRole(DEFAULT_ADMIN_ROLE) { + function registerUEA(bytes32 _chainHash, bytes32 _vmHash, address _UEA) public onlyRole(UEA_ADMIN_ROLE) { + _registerUEA(_chainHash, _vmHash, _UEA); + } + + /// @dev Internal registration logic — no role check. + /// Treats registration as a one-time operation per VM hash. If an implementation + /// is already registered for this `_vmHash`, callers must use + /// `updateUEAImplementation` instead. This prevents silent replacement of the + /// VM implementation that all future UEAs of that type delegate to. + function _registerUEA(bytes32 _chainHash, bytes32 _vmHash, address _UEA) internal { if (_UEA == address(0)) { revert UEAErrors.InvalidInputArgs(); } @@ -266,10 +294,34 @@ contract UEAFactory is Initializable, AccessControlUpgradeable, PausableUpgradea revert UEAErrors.InvalidInputArgs(); } + if (UEA_VM[_vmHash] != address(0)) { + revert UEAErrors.UEAAlreadyRegistered(); + } + UEA_VM[_vmHash] = _UEA; emit UEARegistered(_chainHash, _UEA, _vmHash); } + /// @notice Replace the registered UEA implementation for a VM hash. + /// @dev Explicit update path distinct from `registerUEA`, which only + /// performs first-time registration. Emits `UEAImplementationUpdated` + /// with both previous and new addresses so off-chain systems can + /// reconstruct the implementation history. + /// @param _vmHash VM hash whose implementation is being updated + /// @param _newUEA New UEA implementation address (must be non-zero) + function updateUEAImplementation(bytes32 _vmHash, address _newUEA) external onlyRole(UEA_ADMIN_ROLE) { + if (_newUEA == address(0)) { + revert UEAErrors.InvalidInputArgs(); + } + address previous = UEA_VM[_vmHash]; + if (previous == address(0)) { + revert UEAErrors.InvalidInputArgs(); + } + + UEA_VM[_vmHash] = _newUEA; + emit UEAImplementationUpdated(_vmHash, previous, _newUEA); + } + // ========================= // UF_4: PUBLIC HELPERS // ========================= diff --git a/src/uea/UEAProxy.sol b/src/uea/UEAProxy.sol index 565d0b5..287cbd1 100644 --- a/src/uea/UEAProxy.sol +++ b/src/uea/UEAProxy.sol @@ -31,6 +31,10 @@ contract UEAProxy is Initializable, Proxy { /// @dev Can only be called once. Intended caller: UEAFactory. /// @param _logic Address of the UEA implementation contract function initializeUEA(address _logic) external initializer { + if (_logic == address(0)) { + revert UEAErrors.InvalidCall(); + } + address currentImpl = getImplementation(); if (currentImpl != address(0)) { revert UEAErrors.InvalidCall(); diff --git a/src/uea/UEA_EVM.sol b/src/uea/UEA_EVM.sol index f13f92f..4d12e77 100644 --- a/src/uea/UEA_EVM.sol +++ b/src/uea/UEA_EVM.sol @@ -43,13 +43,25 @@ contract UEA_EVM is ReentrancyGuard, IUEA { string public constant VERSION = "1.0.0"; /// @notice Universal Executor Module — authorized to execute without signature. - address public constant UNIVERSAL_EXECUTOR_MODULE = - 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7; + address public constant UNIVERSAL_EXECUTOR_MODULE = 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7; /// @notice EIP-712 domain separator typehash. - /// keccak256("EIP712Domain(string version,uint256 chainId,address verifyingContract)") + /// keccak256("EIP712Domain(string version,uint256 chainId,address verifyingContract,bytes32 salt)") + /// @dev Uses only the canonical EIP-712 `EIP712Domain` fields (`version`, `chainId`, + /// `verifyingContract`, `salt`) for maximum compatibility with standard wallets. + /// + /// Field semantics in this protocol: + /// - `version` — UEA implementation version string. + /// - `chainId` — the *source* chain's numeric ID (e.g. Ethereum + /// mainnet = 1), derived from `UniversalAccountId`. + /// Binds the signature to the origin chain identity. + /// - `verifyingContract` — this UEA proxy address. + /// - `salt` — `bytes32(block.chainid)` of Push Chain at execution + /// time. Binds the signature to the specific Push Chain + /// deployment and prevents cross-deployment replay + /// across forks or parallel deployments. bytes32 public constant DOMAIN_SEPARATOR_TYPEHASH = - 0x2aef22f9d7df5f9d21c56d14029233f3fdaa91917727e1eb68e504d27072d6cd; + 0xb90aaffa4b0fc25d6056f438f2c06198968eaf6723d182f5f928441117424b8e; /// @notice UEAFactory reference for fetching migration contract. IUEAFactory public ueaFactory; @@ -59,10 +71,7 @@ contract UEA_EVM is ReentrancyGuard, IUEA { // ========================= /// @inheritdoc IUEA - function initialize( - UniversalAccountId memory _id, - address _factory - ) external { + function initialize(UniversalAccountId memory _id, address _factory) external { if (_initialized) { revert UEAErrors.AccountAlreadyExists(); } @@ -78,43 +87,28 @@ contract UEA_EVM is ReentrancyGuard, IUEA { /// @inheritdoc IUEA function domainSeparator() public view returns (bytes32) { - uint256 chainId = StringUtils.stringToExactUInt256( - _universalAccountId.chainId - ); + uint256 chainId = StringUtils.stringToExactUInt256(_universalAccountId.chainId); return keccak256( abi.encode( - DOMAIN_SEPARATOR_TYPEHASH, - keccak256(bytes(VERSION)), - chainId, - address(this) + DOMAIN_SEPARATOR_TYPEHASH, keccak256(bytes(VERSION)), chainId, address(this), bytes32(block.chainid) ) ); } /// @inheritdoc IUEA - function universalAccount() - public - view - returns (UniversalAccountId memory) - { + function universalAccount() public view returns (UniversalAccountId memory) { return _universalAccountId; } /// @inheritdoc IUEA - function verifyUniversalPayloadSignature( - bytes32 payloadHash, - bytes memory signature - ) public view returns (bool) { + function verifyUniversalPayloadSignature(bytes32 payloadHash, bytes memory signature) public view returns (bool) { address recoveredSigner = payloadHash.recover(signature); - return recoveredSigner - == address(bytes20(_universalAccountId.owner)); + return recoveredSigner == address(bytes20(_universalAccountId.owner)); } /// @inheritdoc IUEA - function getUniversalPayloadHash( - UniversalPayload memory payload - ) public view returns (bytes32) { + function getUniversalPayloadHash(UniversalPayload memory payload) public view returns (bytes32) { bytes32 structHash = keccak256( abi.encode( UNIVERSAL_PAYLOAD_TYPEHASH, @@ -132,9 +126,7 @@ contract UEA_EVM is ReentrancyGuard, IUEA { bytes32 domainSep = domainSeparator(); - return keccak256( - abi.encodePacked("\x19\x01", domainSep, structHash) - ); + return keccak256(abi.encodePacked("\x19\x01", domainSep, structHash)); } // ========================= @@ -142,18 +134,14 @@ contract UEA_EVM is ReentrancyGuard, IUEA { // ========================= /// @inheritdoc IUEA - function executeUniversalTx( - UniversalPayload calldata payload, - bytes calldata signature - ) external nonReentrant { + function executeUniversalTx(UniversalPayload calldata payload, bytes calldata signature) external nonReentrant { + if (payload.nonce != nonce) { + revert UEAErrors.NonceMismatch(nonce, payload.nonce); + } + if (msg.sender != UNIVERSAL_EXECUTOR_MODULE) { - bytes32 payloadHash = - getUniversalPayloadHash(payload); - if ( - !verifyUniversalPayloadSignature( - payloadHash, signature - ) - ) { + bytes32 payloadHash = getUniversalPayloadHash(payload); + if (!verifyUniversalPayloadSignature(payloadHash, signature)) { revert UEAErrors.InvalidEVMSignature(); } } @@ -167,13 +155,8 @@ contract UEA_EVM is ReentrancyGuard, IUEA { /// @dev Handles nonce increment, selector-based dispatch, and event emission. /// @param payload The UniversalPayload to execute - function _handleExecution( - UniversalPayload memory payload - ) internal { - if ( - payload.deadline > 0 - && block.timestamp > payload.deadline - ) { + function _handleExecution(UniversalPayload memory payload) internal { + if (payload.deadline > 0 && block.timestamp > payload.deadline) { revert UEAErrors.ExpiredDeadline(); } @@ -210,15 +193,14 @@ contract UEA_EVM is ReentrancyGuard, IUEA { /// @param payload The UniversalPayload containing multicall data /// @return success Whether all calls succeeded /// @return returnData Return data from the last or first failed call - function _handleMulticall( - UniversalPayload memory payload - ) internal returns (bool success, bytes memory returnData) { + function _handleMulticall(UniversalPayload memory payload) + internal + returns (bool success, bytes memory returnData) + { Multicall[] memory calls = _decodeCalls(payload.data); for (uint256 i = 0; i < calls.length; i++) { - (success, returnData) = calls[i].to.call{ - value: calls[i].value - }(calls[i].data); + (success, returnData) = calls[i].to.call{value: calls[i].value}(calls[i].data); if (!success) { return (success, returnData); } @@ -232,9 +214,10 @@ contract UEA_EVM is ReentrancyGuard, IUEA { /// @param payload The UniversalPayload containing migration data /// @return success Whether the migration succeeded /// @return returnData Return data from the delegatecall - function _handleMigration( - UniversalPayload memory payload - ) internal returns (bool success, bytes memory returnData) { + function _handleMigration(UniversalPayload memory payload) + internal + returns (bool success, bytes memory returnData) + { if (payload.to != address(this)) { revert UEAErrors.InvalidCall(); } @@ -243,29 +226,26 @@ contract UEA_EVM is ReentrancyGuard, IUEA { revert UEAErrors.InvalidCall(); } - address migrationContract = - ueaFactory.UEA_MIGRATION_CONTRACT(); + address migrationContract = ueaFactory.UEA_MIGRATION_CONTRACT(); if (migrationContract == address(0)) { revert UEAErrors.InvalidCall(); } - bytes memory migrateCallData = - abi.encodeWithSignature("migrateUEAEVM()"); + bytes memory migrateCallData = abi.encodeWithSignature("migrateUEAEVM()"); - (success, returnData) = - migrationContract.delegatecall(migrateCallData); + (success, returnData) = migrationContract.delegatecall(migrateCallData); } /// @dev Executes a single call to the target address. /// @param payload The UniversalPayload containing call data /// @return success Whether the call succeeded /// @return returnData Return data from the call - function _handleSingleCall( - UniversalPayload memory payload - ) internal returns (bool success, bytes memory returnData) { - (success, returnData) = - payload.to.call{value: payload.value}(payload.data); + function _handleSingleCall(UniversalPayload memory payload) + internal + returns (bool success, bytes memory returnData) + { + (success, returnData) = payload.to.call{value: payload.value}(payload.data); } // ========================= @@ -275,9 +255,7 @@ contract UEA_EVM is ReentrancyGuard, IUEA { /// @dev Checks whether the payload data starts with MULTICALL_SELECTOR. /// @param data Raw data from the UniversalPayload /// @return True if multicall format - function _isMulticall( - bytes memory data - ) private pure returns (bool) { + function _isMulticall(bytes memory data) private pure returns (bool) { if (data.length < 4) return false; bytes4 selector; assembly { @@ -289,9 +267,7 @@ contract UEA_EVM is ReentrancyGuard, IUEA { /// @dev Checks whether the payload data starts with MIGRATION_SELECTOR. /// @param data Raw data from the UniversalPayload /// @return True if migration format - function _isMigration( - bytes memory data - ) private pure returns (bool) { + function _isMigration(bytes memory data) private pure returns (bool) { if (data.length < 4) return false; bytes4 selector; assembly { @@ -303,9 +279,7 @@ contract UEA_EVM is ReentrancyGuard, IUEA { /// @dev Strips MULTICALL_SELECTOR prefix and decodes as Multicall[]. /// @param data Raw data containing selector + ABI-encoded Multicall[] /// @return Decoded Multicall array - function _decodeCalls( - bytes memory data - ) private pure returns (Multicall[] memory) { + function _decodeCalls(bytes memory data) private pure returns (Multicall[] memory) { bytes memory strippedData = new bytes(data.length - 4); for (uint256 i = 0; i < strippedData.length; i++) { strippedData[i] = data[i + 4]; diff --git a/src/uea/UEA_SVM.sol b/src/uea/UEA_SVM.sol index 5a2b956..31cb0fe 100644 --- a/src/uea/UEA_SVM.sol +++ b/src/uea/UEA_SVM.sol @@ -39,17 +39,31 @@ contract UEA_SVM is ReentrancyGuard, IUEA { string public constant VERSION = "1.0.0"; /// @notice Ed25519 verifier precompile address. - address public constant VERIFIER_PRECOMPILE = - 0x00000000000000000000000000000000000000ca; + address public constant VERIFIER_PRECOMPILE = 0x00000000000000000000000000000000000000ca; /// @notice Universal Executor Module — authorized to execute without signature. - address public constant UNIVERSAL_EXECUTOR_MODULE = - 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7; + address public constant UNIVERSAL_EXECUTOR_MODULE = 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7; /// @notice EIP-712 domain separator typehash for SVM. - /// keccak256("EIP712Domain_SVM(string version,string chainId,address verifyingContract)") + /// keccak256("EIP712Domain_SVM(string version,string chainId,address verifyingContract,bytes32 salt)") + /// @dev Uses only the canonical EIP-712 `EIP712Domain` fields (`version`, `chainId`, + /// `verifyingContract`, `salt`) for maximum compatibility with standard signers + /// and EIP-712 tooling. The `_SVM` suffix in the type name disambiguates this + /// domain from the EVM variant (which encodes `chainId` as `uint256`); here + /// `chainId` is a `string` to accommodate Solana cluster identifiers. + /// + /// Field semantics in this protocol: + /// - `version` — UEA implementation version string. + /// - `chainId` — the *source* Solana cluster identifier string, + /// derived from `UniversalAccountId`. Binds the + /// signature to the origin chain identity. + /// - `verifyingContract` — this UEA proxy address. + /// - `salt` — `bytes32(block.chainid)` of Push Chain at execution + /// time. Binds the signature to the specific Push Chain + /// deployment and prevents cross-deployment replay + /// across forks or parallel deployments. bytes32 public constant DOMAIN_SEPARATOR_TYPEHASH_SVM = - 0x3aefc31558906b9b2c54de94f82a9b2455c24b4ba2b642ebb545ea2cc64a1e4b; + 0x038a4fd0ee5950f0ea6d28f116a885fc5e376a8d1a939f7a9bea48f4f13fabb1; /// @notice UEAFactory reference for fetching migration contract. IUEAFactory public ueaFactory; @@ -59,10 +73,7 @@ contract UEA_SVM is ReentrancyGuard, IUEA { // ========================= /// @inheritdoc IUEA - function initialize( - UniversalAccountId memory _id, - address _factory - ) external { + function initialize(UniversalAccountId memory _id, address _factory) external { if (_initialized) { revert UEAErrors.AccountAlreadyExists(); } @@ -77,38 +88,33 @@ contract UEA_SVM is ReentrancyGuard, IUEA { // ========================= /// @inheritdoc IUEA + /// @dev Per EIP-712, dynamic types (`string`, `bytes`) must be encoded as their + /// `keccak256` hash when included in the domain/struct hash. Both `version` and + /// `chainId` are declared as `string` in the typehash, so both are hashed here. function domainSeparator() public view returns (bytes32) { return keccak256( abi.encode( DOMAIN_SEPARATOR_TYPEHASH_SVM, keccak256(bytes(VERSION)), - _universalAccountId.chainId, - address(this) + keccak256(bytes(_universalAccountId.chainId)), + address(this), + bytes32(block.chainid) ) ); } /// @inheritdoc IUEA - function universalAccount() - public - view - returns (UniversalAccountId memory) - { + function universalAccount() public view returns (UniversalAccountId memory) { return _universalAccountId; } /// @inheritdoc IUEA - function verifyUniversalPayloadSignature( - bytes32 payloadHash, - bytes memory signature - ) public view returns (bool) { + function verifyUniversalPayloadSignature(bytes32 payloadHash, bytes memory signature) public view returns (bool) { return _verifySignatureSVM(payloadHash, signature); } /// @inheritdoc IUEA - function getUniversalPayloadHash( - UniversalPayload memory payload - ) public view returns (bytes32) { + function getUniversalPayloadHash(UniversalPayload memory payload) public view returns (bytes32) { bytes32 structHash = keccak256( abi.encode( UNIVERSAL_PAYLOAD_TYPEHASH, @@ -126,9 +132,7 @@ contract UEA_SVM is ReentrancyGuard, IUEA { bytes32 domainSep = domainSeparator(); - return keccak256( - abi.encodePacked("\x19\x01", domainSep, structHash) - ); + return keccak256(abi.encodePacked("\x19\x01", domainSep, structHash)); } // ========================= @@ -136,18 +140,14 @@ contract UEA_SVM is ReentrancyGuard, IUEA { // ========================= /// @inheritdoc IUEA - function executeUniversalTx( - UniversalPayload calldata payload, - bytes calldata signature - ) external nonReentrant { + function executeUniversalTx(UniversalPayload calldata payload, bytes calldata signature) external nonReentrant { + if (payload.nonce != nonce) { + revert UEAErrors.NonceMismatch(nonce, payload.nonce); + } + if (msg.sender != UNIVERSAL_EXECUTOR_MODULE) { - bytes32 payloadHash = - getUniversalPayloadHash(payload); - if ( - !verifyUniversalPayloadSignature( - payloadHash, signature - ) - ) { + bytes32 payloadHash = getUniversalPayloadHash(payload); + if (!verifyUniversalPayloadSignature(payloadHash, signature)) { revert UEAErrors.InvalidSVMSignature(); } } @@ -163,19 +163,12 @@ contract UEA_SVM is ReentrancyGuard, IUEA { /// @param payloadHash Payload hash to verify /// @param signature Ed25519 signature bytes /// @return True if the signature is valid - function _verifySignatureSVM( - bytes32 payloadHash, - bytes memory signature - ) internal view returns (bool) { - (bool success, bytes memory result) = - VERIFIER_PRECOMPILE.staticcall( - abi.encodeWithSignature( - "verifyEd25519(bytes,bytes32,bytes)", - _universalAccountId.owner, - payloadHash, - signature - ) - ); + function _verifySignatureSVM(bytes32 payloadHash, bytes memory signature) internal view returns (bool) { + (bool success, bytes memory result) = VERIFIER_PRECOMPILE.staticcall( + abi.encodeWithSignature( + "verifyEd25519(bytes,bytes32,bytes)", _universalAccountId.owner, payloadHash, signature + ) + ); if (!success) { revert UEAErrors.PrecompileCallFailed(); } @@ -185,13 +178,8 @@ contract UEA_SVM is ReentrancyGuard, IUEA { /// @dev Handles nonce increment, selector-based dispatch, and event emission. /// @param payload The UniversalPayload to execute - function _handleExecution( - UniversalPayload memory payload - ) internal { - if ( - payload.deadline > 0 - && block.timestamp > payload.deadline - ) { + function _handleExecution(UniversalPayload memory payload) internal { + if (payload.deadline > 0 && block.timestamp > payload.deadline) { revert UEAErrors.ExpiredDeadline(); } @@ -228,15 +216,14 @@ contract UEA_SVM is ReentrancyGuard, IUEA { /// @param payload The UniversalPayload containing multicall data /// @return success Whether all calls succeeded /// @return returnData Return data from the last or first failed call - function _handleMulticall( - UniversalPayload memory payload - ) internal returns (bool success, bytes memory returnData) { + function _handleMulticall(UniversalPayload memory payload) + internal + returns (bool success, bytes memory returnData) + { Multicall[] memory calls = _decodeCalls(payload.data); for (uint256 i = 0; i < calls.length; i++) { - (success, returnData) = calls[i].to.call{ - value: calls[i].value - }(calls[i].data); + (success, returnData) = calls[i].to.call{value: calls[i].value}(calls[i].data); if (!success) { return (success, returnData); } @@ -250,9 +237,10 @@ contract UEA_SVM is ReentrancyGuard, IUEA { /// @param payload The UniversalPayload containing migration data /// @return success Whether the migration succeeded /// @return returnData Return data from the delegatecall - function _handleMigration( - UniversalPayload memory payload - ) internal returns (bool success, bytes memory returnData) { + function _handleMigration(UniversalPayload memory payload) + internal + returns (bool success, bytes memory returnData) + { if (payload.to != address(this)) { revert UEAErrors.InvalidCall(); } @@ -260,29 +248,26 @@ contract UEA_SVM is ReentrancyGuard, IUEA { revert UEAErrors.InvalidCall(); } - address migrationContract = - ueaFactory.UEA_MIGRATION_CONTRACT(); + address migrationContract = ueaFactory.UEA_MIGRATION_CONTRACT(); if (migrationContract == address(0)) { revert UEAErrors.InvalidCall(); } - bytes memory migrateCallData = - abi.encodeWithSignature("migrateUEASVM()"); + bytes memory migrateCallData = abi.encodeWithSignature("migrateUEASVM()"); - (success, returnData) = - migrationContract.delegatecall(migrateCallData); + (success, returnData) = migrationContract.delegatecall(migrateCallData); } /// @dev Executes a single call to the target address. /// @param payload The UniversalPayload containing call data /// @return success Whether the call succeeded /// @return returnData Return data from the call - function _handleSingleCall( - UniversalPayload memory payload - ) internal returns (bool success, bytes memory returnData) { - (success, returnData) = - payload.to.call{value: payload.value}(payload.data); + function _handleSingleCall(UniversalPayload memory payload) + internal + returns (bool success, bytes memory returnData) + { + (success, returnData) = payload.to.call{value: payload.value}(payload.data); } // ========================= @@ -292,9 +277,7 @@ contract UEA_SVM is ReentrancyGuard, IUEA { /// @dev Checks whether the payload data starts with MULTICALL_SELECTOR. /// @param data Raw data from the UniversalPayload /// @return True if multicall format - function _isMulticall( - bytes memory data - ) private pure returns (bool) { + function _isMulticall(bytes memory data) private pure returns (bool) { if (data.length < 4) return false; bytes4 selector; assembly { @@ -306,9 +289,7 @@ contract UEA_SVM is ReentrancyGuard, IUEA { /// @dev Checks whether the payload data starts with MIGRATION_SELECTOR. /// @param data Raw data from the UniversalPayload /// @return True if migration format - function _isMigration( - bytes memory data - ) private pure returns (bool) { + function _isMigration(bytes memory data) private pure returns (bool) { if (data.length < 4) return false; bytes4 selector; assembly { @@ -320,9 +301,7 @@ contract UEA_SVM is ReentrancyGuard, IUEA { /// @dev Strips MULTICALL_SELECTOR prefix and decodes as Multicall[]. /// @param data Raw data containing selector + ABI-encoded Multicall[] /// @return Decoded Multicall array - function _decodeCalls( - bytes memory data - ) private pure returns (Multicall[] memory) { + function _decodeCalls(bytes memory data) private pure returns (Multicall[] memory) { bytes memory strippedData = new bytes(data.length - 4); for (uint256 i = 0; i < strippedData.length; i++) { strippedData[i] = data[i + 4]; diff --git a/test/fork/ForkUniversalCore.t.sol b/test/fork/ForkUniversalCore.t.sol index eee466d..adc8a46 100644 --- a/test/fork/ForkUniversalCore.t.sol +++ b/test/fork/ForkUniversalCore.t.sol @@ -62,21 +62,13 @@ contract ForkUniversalCoreTest is Test, UpgradeableContractHelper, PushChainAddr // Deploy UniversalCore behind proxy UniversalCore implementation = new UniversalCore(); bytes memory initData = abi.encodeWithSelector( - UniversalCore.initialize.selector, - WPC_TOKEN, - UNISWAP_FACTORY, - UNISWAP_ROUTER, - UNISWAP_QUOTER, - makeAddr("pauser") + UniversalCore.initialize.selector, deployer, makeAddr("pauser"), WPC_TOKEN, UNISWAP_FACTORY, UNISWAP_ROUTER ); address proxyAddress = deployUpgradeableContract(address(implementation), initData); universalCore = UniversalCore(payable(proxyAddress)); // Set gateway - universalCore.setUniversalGatewayPC(gateway); - - // Grant MANAGER_ROLE to deployer for config functions - universalCore.grantRole(universalCore.MANAGER_ROLE(), deployer); + universalCore.updateUniversalGatewayPC(gateway); // Configure auto-swap and fee tiers for test tokens _configureToken(PSOL_TOKEN, 500); @@ -102,9 +94,8 @@ contract ForkUniversalCoreTest is Test, UpgradeableContractHelper, PushChainAddr } function _configureToken(address token, uint24 fee) private { - universalCore.setAutoSwapSupported(token, true); - universalCore.setDefaultFeeTier(token, fee); - universalCore.setSlippageTolerance(token, 500); // 5% slippage + universalCore.updateAutoSwapSupported(token, true); + universalCore.updateDefaultFeeTier(token, fee); } function _updatePRC20UniversalCore(address token) private { @@ -161,15 +152,15 @@ contract ForkUniversalCoreTest is Test, UpgradeableContractHelper, PushChainAddr assertEq(pool, PBNB_WPC_POOL); } - function test_fork_setGasPCPool_validatesRealPool() public { + function test_fork_updateGasPCPool_validatesRealPool() public { vm.startPrank(deployer); // Valid pool succeeds - universalCore.setGasPCPool("eip155:1", PSOL_TOKEN, 500); + universalCore.updateGasPCPool("eip155:1", PSOL_TOKEN, 500); assertEq(universalCore.gasPCPoolByChainNamespace("eip155:1"), PSOL_WPC_POOL); // Nonexistent pool reverts vm.expectRevert(UniversalCoreErrors.PoolNotFound.selector); - universalCore.setGasPCPool("eip155:2", PSOL_TOKEN, 10000); + universalCore.updateGasPCPool("eip155:2", PSOL_TOKEN, 10000); vm.stopPrank(); } @@ -391,7 +382,7 @@ contract ForkUniversalCoreTest is Test, UpgradeableContractHelper, PushChainAddr address fakeToken = makeAddr("fakeToken"); vm.prank(deployer); - universalCore.setDefaultFeeTier(fakeToken, 500); + universalCore.updateDefaultFeeTier(fakeToken, 500); vm.prank(gateway); vm.expectRevert(UniversalCoreErrors.PoolNotFound.selector); diff --git a/test/fuzz/CEAFactory_Fuzz.t.sol b/test/fuzz/CEAFactory_Fuzz.t.sol index d98c9a5..811e9ce 100644 --- a/test/fuzz/CEAFactory_Fuzz.t.sol +++ b/test/fuzz/CEAFactory_Fuzz.t.sol @@ -194,73 +194,77 @@ contract CEAFactory_FuzzTest is Test { factory.deployCEA(pushAccount); } - /// @dev Non-owner callers cannot call setVault. - function testFuzz_setVault_nonOwner_reverts(address caller, address newVault) public { + /// @dev Non-operator callers cannot call updateVault. + function testFuzz_updateVault_nonOperator_reverts(address caller, address newVault) public { vm.assume(caller != owner); vm.assume(caller != address(0)); vm.assume(newVault != address(0)); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 operatorRole = factory.OPERATOR_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, operatorRole) ); vm.prank(caller); - factory.setVault(newVault); + factory.updateVault(newVault); } - /// @dev Non-owner callers cannot call setCEAImplementation. - function testFuzz_setCEAImplementation_nonOwner_reverts(address caller, address newImpl) public { + /// @dev Non-CEA-admin callers cannot call setCEAImplementation. + function testFuzz_setCEAImplementation_nonCEAAdmin_reverts(address caller, address newImpl) public { vm.assume(caller != owner); vm.assume(caller != address(0)); vm.assume(newImpl != address(0)); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 ceaAdminRole = factory.CEA_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, ceaAdminRole) ); vm.prank(caller); factory.setCEAImplementation(newImpl); } - /// @dev Non-owner callers cannot call setCEAMigrationContract. - function testFuzz_setCEAMigrationContract_nonOwner_reverts(address caller, address newMigration) public { + /// @dev Non-CEA-admin callers cannot call updateCEAMigrationContract. + function testFuzz_updateCEAMigrationContract_nonCEAAdmin_reverts(address caller, address newMigration) public { vm.assume(caller != owner); vm.assume(caller != address(0)); vm.assume(newMigration != address(0)); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 ceaAdminRole = factory.CEA_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, ceaAdminRole) ); vm.prank(caller); - factory.setCEAMigrationContract(newMigration); + factory.updateCEAMigrationContract(newMigration); } // ========================================================================= // 9.5 Setter Validation Properties // ========================================================================= - /// @dev setVault(address(0)) reverts with ZeroAddress. - function testFuzz_setVault_zeroAddress_reverts() public { + /// @dev updateVault(address(0)) reverts with ZeroAddress. + function testFuzz_updateVault_zeroAddress_reverts() public { + vm.prank(owner); vm.expectRevert(CEAErrors.ZeroAddress.selector); - factory.setVault(address(0)); + factory.updateVault(address(0)); } /// @dev setCEAProxyImplementation(address(0)) reverts with ZeroAddress. function testFuzz_setCEAProxyImplementation_zeroAddress_reverts() public { + vm.prank(owner); vm.expectRevert(CEAErrors.ZeroAddress.selector); factory.setCEAProxyImplementation(address(0)); } /// @dev setCEAImplementation(address(0)) reverts with ZeroAddress. function testFuzz_setCEAImplementation_zeroAddress_reverts() public { + vm.prank(owner); vm.expectRevert(CEAErrors.ZeroAddress.selector); factory.setCEAImplementation(address(0)); } - /// @dev setUniversalGateway(address(0)) reverts with ZeroAddress. + /// @dev updateUniversalGateway(address(0)) reverts with ZeroAddress. function testFuzz_setUniversalGateway_zeroAddress_reverts() public { + vm.prank(owner); vm.expectRevert(CEAErrors.ZeroAddress.selector); - factory.setUniversalGateway(address(0)); + factory.updateUniversalGateway(address(0)); } } diff --git a/test/fuzz/CEAMigration_Fuzz.t.sol b/test/fuzz/CEAMigration_Fuzz.t.sol index 6db313b..9971c35 100644 --- a/test/fuzz/CEAMigration_Fuzz.t.sol +++ b/test/fuzz/CEAMigration_Fuzz.t.sol @@ -75,7 +75,7 @@ contract CEAMigration_FuzzTest is Test { vm.prank(vault); address ceaAddr = factory.deployCEA(ueaOnPush); - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); // Verify initial slot value (should be ceaV1) bytes32 slotBefore = vm.load(ceaAddr, CEA_LOGIC_SLOT); @@ -83,10 +83,10 @@ contract CEAMigration_FuzzTest is Test { // Trigger migration via executeUniversalTx with MIGRATION_SELECTOR payload bytes memory payload = abi.encodePacked(bytes4(keccak256("UEA_MIGRATION"))); - bytes32 txId = keccak256("migration_slot_test"); + bytes32 subTxId = keccak256("migration_slot_test"); vm.prank(vault); - ICEA(ceaAddr).executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ICEA(ceaAddr).executeUniversalTx(subTxId, bytes32(0), ueaOnPush, ceaAddr, payload); // Verify slot was updated to ceaV2 bytes32 slotAfter = vm.load(ceaAddr, CEA_LOGIC_SLOT); @@ -98,16 +98,16 @@ contract CEAMigration_FuzzTest is Test { vm.prank(vault); address ceaAddr = factory.deployCEA(ueaOnPush); - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); bytes memory payload = abi.encodePacked(bytes4(keccak256("UEA_MIGRATION"))); - bytes32 txId = keccak256("migration_event_test"); + bytes32 subTxId = keccak256("migration_event_test"); vm.expectEmit(true, false, false, false); emit CEAMigration.ImplementationUpdated(address(ceaV2)); vm.prank(vault); - ICEA(ceaAddr).executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ICEA(ceaAddr).executeUniversalTx(subTxId, bytes32(0), ueaOnPush, ceaAddr, payload); } // ========================================================================= diff --git a/test/fuzz/CEA_Fuzz.t.sol b/test/fuzz/CEA_Fuzz.t.sol index a1cef1a..018f8d2 100644 --- a/test/fuzz/CEA_Fuzz.t.sol +++ b/test/fuzz/CEA_Fuzz.t.sol @@ -80,26 +80,26 @@ contract CEA_FuzzTest is Test { // 8.1 Replay Protection Properties // ========================================================================= - /// @dev After successful execution, isExecuted[txId] is true. - function testFuzz_executeUniversalTx_uniqueTxId(bytes32 txId, bytes32 universalTxId) public { + /// @dev After successful execution, isExecuted[subTxId] is true. + function testFuzz_executeUniversalTx_uniqueTxId(bytes32 subTxId, bytes32 universalTxId) public { bytes memory payload = emptyMulticallPayload(); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, universalTxId, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxId, ueaOnPush, address(0), payload); - assertTrue(ceaInstance.isExecuted(txId)); + assertTrue(ceaInstance.isExecuted(subTxId)); } - /// @dev Second call with same txId always reverts with PayloadExecuted. - function testFuzz_executeUniversalTx_replayReverts(bytes32 txId, bytes32 universalTxId) public { + /// @dev Second call with same subTxId always reverts with PayloadExecuted. + function testFuzz_executeUniversalTx_replayReverts(bytes32 subTxId, bytes32 universalTxId) public { bytes memory payload = emptyMulticallPayload(); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, universalTxId, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxId, ueaOnPush, address(0), payload); vm.expectRevert(CEAErrors.PayloadExecuted.selector); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, universalTxId, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxId, ueaOnPush, address(0), payload); } /// @dev Different txIds execute independently without replay issues. @@ -123,24 +123,24 @@ contract CEA_FuzzTest is Test { // ========================================================================= /// @dev When originCaller != pushAccount, reverts with InvalidUEA. - function testFuzz_executeUniversalTx_wrongOriginCaller_reverts(address wrongCaller, bytes32 txId) public { + function testFuzz_executeUniversalTx_wrongOriginCaller_reverts(address wrongCaller, bytes32 subTxId) public { vm.assume(wrongCaller != ueaOnPush); bytes memory payload = emptyMulticallPayload(); vm.expectRevert(CEAErrors.InvalidUEA.selector); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), wrongCaller, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), wrongCaller, address(0), payload); } /// @dev When originCaller == pushAccount, origin check passes. - function testFuzz_executeUniversalTx_correctOriginCaller_passes(bytes32 txId) public { + function testFuzz_executeUniversalTx_correctOriginCaller_passes(bytes32 subTxId) public { bytes memory payload = emptyMulticallPayload(); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); - assertTrue(ceaInstance.isExecuted(txId)); + assertTrue(ceaInstance.isExecuted(subTxId)); } // ========================================================================= @@ -158,22 +158,22 @@ contract CEA_FuzzTest is Test { // Valid multicall selector — would attempt multicall decode // Use a properly encoded empty multicall to verify it passes bytes memory validPayload = emptyMulticallPayload(); - bytes32 txId = keccak256(abi.encode("multicall_test", selector)); + bytes32 subTxId = keccak256(abi.encode("multicall_test", selector)); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), validPayload); - assertTrue(ceaInstance.isExecuted(txId)); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), validPayload); + assertTrue(ceaInstance.isExecuted(subTxId)); } else if (selector == MIGRATION_SELECTOR) { // Migration selector — different path vm.assume(selector != MULTICALL_SELECTOR && selector != MIGRATION_SELECTOR); } else { // Non-multicall, non-migration — single call path with the payload // An empty non-special payload parks funds successfully - bytes32 txId = keccak256(abi.encode("non_multicall", selector)); + bytes32 subTxId = keccak256(abi.encode("non_multicall", selector)); vm.prank(vault); // Single-call path with non-zero selector and no recipient won't revert if payload is short // Use empty payload to park funds safely - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), ""); - assertTrue(ceaInstance.isExecuted(txId)); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), ""); + assertTrue(ceaInstance.isExecuted(subTxId)); } } @@ -182,10 +182,10 @@ contract CEA_FuzzTest is Test { vm.assume(selector != MIGRATION_SELECTOR && selector != MULTICALL_SELECTOR); // Build a payload with a non-migration, non-multicall selector // Should go to single-call path — use empty payload which parks funds - bytes32 txId = keccak256(abi.encode("migration_selector_test", selector, remaining)); + bytes32 subTxId = keccak256(abi.encode("migration_selector_test", selector, remaining)); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), ""); - assertTrue(ceaInstance.isExecuted(txId)); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), ""); + assertTrue(ceaInstance.isExecuted(subTxId)); } /// @dev Payloads shorter than 4 bytes never trigger multicall or migration. @@ -198,30 +198,30 @@ contract CEA_FuzzTest is Test { payload[i] = 0xff; } - bytes32 txId = keccak256(abi.encode("short_payload", length)); + bytes32 subTxId = keccak256(abi.encode("short_payload", length)); vm.prank(vault); // Short payload goes to single-call path; empty payload parks funds // Non-empty short payloads without a valid recipient will revert with InvalidRecipient if (length == 0) { - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); - assertTrue(ceaInstance.isExecuted(txId)); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); + assertTrue(ceaInstance.isExecuted(subTxId)); } else { // Non-empty short payload -> single call path -> needs recipient // With address(0) recipient it reverts InvalidRecipient vm.expectRevert(CEAErrors.InvalidRecipient.selector); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); } } /// @dev When payload is empty, funds are parked in CEA without external call. - function testFuzz_singleCall_emptyPayload_parksFunds(bytes32 txId, uint256 value) public { + function testFuzz_singleCall_emptyPayload_parksFunds(bytes32 subTxId, uint256 value) public { value = bound(value, 0, 100 ether); vm.deal(vault, value); vm.prank(vault); - ceaInstance.executeUniversalTx{value: value}(txId, bytes32(0), ueaOnPush, address(0), ""); + ceaInstance.executeUniversalTx{value: value}(subTxId, bytes32(0), ueaOnPush, address(0), ""); - assertTrue(ceaInstance.isExecuted(txId)); + assertTrue(ceaInstance.isExecuted(subTxId)); // Funds are parked in CEA assertEq(address(ceaInstance).balance, value); } @@ -247,11 +247,11 @@ contract CEA_FuzzTest is Test { } bytes memory payload = encodeCalls(calls); - bytes32 txId = keccak256(abi.encode("zero_target", numCalls, zeroIndex)); + bytes32 subTxId = keccak256(abi.encode("zero_target", numCalls, zeroIndex)); vm.expectRevert(CEAErrors.InvalidTarget.selector); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); } /// @dev Self-call with value > 0 reverts with InvalidInput. @@ -263,11 +263,11 @@ contract CEA_FuzzTest is Test { calls[0] = makeCall(address(ceaInstance), value, ""); bytes memory payload = encodeCalls(calls); - bytes32 txId = keccak256(abi.encode("self_call_value", value)); + bytes32 subTxId = keccak256(abi.encode("self_call_value", value)); vm.expectRevert(CEAErrors.InvalidInput.selector); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); } /// @dev Self-call with value == 0 is allowed in multicall. @@ -284,12 +284,12 @@ contract CEA_FuzzTest is Test { calls[0] = makeCall(address(ceaInstance), 0, safeData); bytes memory payload = encodeCalls(calls); - bytes32 txId = keccak256(abi.encode("self_call_zero_value", safeData)); + bytes32 subTxId = keccak256(abi.encode("self_call_zero_value", safeData)); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); - assertTrue(ceaInstance.isExecuted(txId)); + assertTrue(ceaInstance.isExecuted(subTxId)); } /// @dev In CEA._handleMulticall there is NO migration selector check inside array. @@ -318,13 +318,13 @@ contract CEA_FuzzTest is Test { } bytes memory payload = encodeCalls(calls); - bytes32 txId = keccak256(abi.encode("migration_in_array", numCalls, migIdx)); + bytes32 subTxId = keccak256(abi.encode("migration_in_array", numCalls, migIdx)); // The call with MIGRATION_SELECTOR data will fail at the target level, // causing ExecutionFailed — but crucially NOT InvalidCall. vm.expectRevert(CEAErrors.ExecutionFailed.selector); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); } // ========================================================================= @@ -332,7 +332,7 @@ contract CEA_FuzzTest is Test { // ========================================================================= /// @dev Migration with msg.value > 0 reverts with InvalidInput. - function testFuzz_migration_withValue_reverts(uint256 value, bytes32 txId) public { + function testFuzz_migration_withValue_reverts(uint256 value, bytes32 subTxId) public { value = bound(value, 1, 100 ether); vm.deal(vault, value); @@ -340,17 +340,17 @@ contract CEA_FuzzTest is Test { vm.expectRevert(CEAErrors.InvalidInput.selector); vm.prank(vault); - ceaInstance.executeUniversalTx{value: value}(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: value}(subTxId, bytes32(0), ueaOnPush, address(ceaInstance), payload); } /// @dev When factory has no migration contract set, migration reverts with InvalidCall. - function testFuzz_migration_noMigrationContract_reverts(bytes32 txId) public { + function testFuzz_migration_noMigrationContract_reverts(bytes32 subTxId) public { // Factory has no migration contract (CEA_MIGRATION_CONTRACT == address(0) by default) bytes memory payload = abi.encodePacked(MIGRATION_SELECTOR); vm.expectRevert(CEAErrors.InvalidCall.selector); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(ceaInstance), payload); } // ========================================================================= @@ -358,7 +358,7 @@ contract CEA_FuzzTest is Test { // ========================================================================= /// @dev When caller != VAULT, executeUniversalTx always reverts with NotVault. - function testFuzz_executeUniversalTx_nonVault_reverts(address caller, bytes32 txId) public { + function testFuzz_executeUniversalTx_nonVault_reverts(address caller, bytes32 subTxId) public { vm.assume(caller != vault); vm.assume(caller != address(0)); @@ -366,7 +366,7 @@ contract CEA_FuzzTest is Test { vm.expectRevert(CEAErrors.NotVault.selector); vm.prank(caller); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); } /// @dev When caller != address(this), sendUniversalTxToUEA always reverts with Unauthorized. @@ -384,26 +384,45 @@ contract CEA_FuzzTest is Test { // 8.7 SendUniversalTxToUEA Properties // ========================================================================= - /// @dev amount == 0 always reverts with InvalidInput. - function testFuzz_sendUniversalTxToUEA_zeroAmount_reverts(address token) public { - // Must call as self to bypass Unauthorized, but amount == 0 still reverts - // We can test this via a multicall that calls sendUniversalTxToUEA(token, 0, "") + /// @dev amount == 0 is allowed for both native and ERC20 — no revert expected. + function testFuzz_sendUniversalTxToUEA_zeroAmount_succeeds_native() public { Multicall[] memory calls = new Multicall[](1); calls[0] = makeCall( address(ceaInstance), 0, abi.encodeWithSignature( - "sendUniversalTxToUEA(address,uint256,bytes,address)", token, uint256(0), "", ueaOnPush + "sendUniversalTxToUEA(address,uint256,bytes,address)", address(0), uint256(0), "", ueaOnPush ) ); bytes memory payload = encodeCalls(calls); - bytes32 txId = keccak256(abi.encode("zero_amount", token)); + bytes32 subTxId = keccak256(abi.encode("zero_amount_native")); - // The inner call reverts with InvalidInput, causing ExecutionFailed at multicall level - vm.expectRevert(CEAErrors.ExecutionFailed.selector); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); + + assertEq(mockUniversalGateway.lastAmount(), 0, "Should allow zero amount for native"); + } + + function testFuzz_sendUniversalTxToUEA_zeroAmount_succeeds_erc20() public { + MockGasToken token = new MockGasToken(); + + Multicall[] memory calls = new Multicall[](1); + calls[0] = makeCall( + address(ceaInstance), + 0, + abi.encodeWithSignature( + "sendUniversalTxToUEA(address,uint256,bytes,address)", address(token), uint256(0), "", ueaOnPush + ) + ); + + bytes memory payload = encodeCalls(calls); + bytes32 subTxId = keccak256(abi.encode("zero_amount_erc20")); + + vm.prank(vault); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); + + assertEq(mockUniversalGateway.lastAmount(), 0, "Should allow zero amount for ERC20"); } /// @dev When CEA lacks sufficient ERC20 balance, reverts with InsufficientBalance. @@ -423,11 +442,11 @@ contract CEA_FuzzTest is Test { ); bytes memory payload = encodeCalls(calls); - bytes32 txId = keccak256(abi.encode("insufficient_balance", amount)); + bytes32 subTxId = keccak256(abi.encode("insufficient_balance", amount)); - // The inner call reverts with InsufficientBalance, causing ExecutionFailed - vm.expectRevert(CEAErrors.ExecutionFailed.selector); + // The inner call reverts with InsufficientBalance, now propagated + vm.expectRevert(CEAErrors.InsufficientBalance.selector); vm.prank(vault); - ceaInstance.executeUniversalTx(txId, bytes32(0), ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, bytes32(0), ueaOnPush, address(0), payload); } } diff --git a/test/fuzz/PRC20_Fuzz.t.sol b/test/fuzz/PRC20_Fuzz.t.sol index d131b78..37dee4c 100644 --- a/test/fuzz/PRC20_Fuzz.t.sol +++ b/test/fuzz/PRC20_Fuzz.t.sol @@ -20,10 +20,9 @@ contract PRC20_Fuzz is Test, UpgradeableContractHelper { address mockWPC = makeAddr("wpc"); address mockFactory = makeAddr("factory"); address mockRouter = makeAddr("router"); - address mockQuoter = makeAddr("quoter"); address mockPauser = makeAddr("pauser"); bytes memory ucInit = abi.encodeWithSelector( - UniversalCore.initialize.selector, mockWPC, mockFactory, mockRouter, mockQuoter, mockPauser + UniversalCore.initialize.selector, address(this), mockPauser, mockWPC, mockFactory, mockRouter ); address ucProxy = deployUpgradeableContract(address(ucImpl), ucInit); universalCore = UniversalCore(payable(ucProxy)); diff --git a/test/fuzz/UEAFactory_Fuzz.t.sol b/test/fuzz/UEAFactory_Fuzz.t.sol index 1c8851d..6cb67ef 100644 --- a/test/fuzz/UEAFactory_Fuzz.t.sol +++ b/test/fuzz/UEAFactory_Fuzz.t.sol @@ -24,10 +24,10 @@ contract UEAFactory_Fuzz is Test { ueaProxyImpl = new UEAProxy(); UEAFactory factoryImpl = new UEAFactory(); bytes memory initData = - abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser")); + abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser"), "42101"); ERC1967Proxy proxy = new ERC1967Proxy(address(factoryImpl), initData); factory = UEAFactory(address(proxy)); - factory.setUEAProxyImplementation(address(ueaProxyImpl)); + factory.updateUEAProxyImplementation(address(ueaProxyImpl)); ueaEVMImpl = new UEA_EVM(); bytes32 evmChainHash = keccak256(abi.encode(CHAIN_NS, CHAIN_ID)); factory.registerNewChain(evmChainHash, EVM_HASH); @@ -179,15 +179,15 @@ contract UEAFactory_Fuzz is Test { // 5.5 Chain Registration Properties // ============================================= - function testFuzz_registerNewChain_nonOwner_reverts(address caller) public { + function testFuzz_registerNewChain_nonUEAAdmin_reverts(address caller) public { vm.assume(caller != address(this)); vm.assume(caller != address(0)); bytes32 chainHash = keccak256(abi.encode("fuzzchain", "999")); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 ueaAdminRole = factory.UEA_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, ueaAdminRole) ); vm.prank(caller); factory.registerNewChain(chainHash, EVM_HASH); @@ -203,4 +203,35 @@ contract UEAFactory_Fuzz is Test { vm.expectRevert(UEAErrors.InvalidInputArgs.selector); factory.deployUEA(id); } + + // ============================================= + // 5.3 Configurable pushChainId Properties + // ============================================= + + function testFuzz_updatePushChainId_nonUEAAdmin_reverts(address caller) public { + vm.assume(caller != address(this)); + vm.assume(caller != address(0)); + + bytes32 ueaAdminRole = factory.UEA_ADMIN_ROLE(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, ueaAdminRole) + ); + vm.prank(caller); + factory.updatePushChainId("1"); + } + + function testFuzz_getOriginForUEA_fallbackMatchesConfiguredChainId(address addr, string memory chainId) public { + vm.assume(bytes(chainId).length > 0); + // Ensure addr is not a UEA by using an address that cannot collide with deployed UEAs + vm.assume(addr != address(0)); + + factory.updatePushChainId(chainId); + + (UniversalAccountId memory account, bool isUEA) = factory.getOriginForUEA(addr); + + assertFalse(isUEA, "random address should not be a registered UEA"); + assertEq(account.chainNamespace, "eip155", "namespace is hardcoded eip155"); + assertEq(account.chainId, chainId, "chainId matches configured pushChainId"); + assertEq(account.owner, bytes(abi.encodePacked(addr))); + } } diff --git a/test/fuzz/UEAProxy_Fuzz.t.sol b/test/fuzz/UEAProxy_Fuzz.t.sol index c33a800..4e6666c 100644 --- a/test/fuzz/UEAProxy_Fuzz.t.sol +++ b/test/fuzz/UEAProxy_Fuzz.t.sol @@ -47,17 +47,11 @@ contract UEAProxy_Fuzz is Test { proxy.initializeUEA(logic2); } - function testFuzz_initializeUEA_zeroAddress_behavior(bytes calldata) public { - // initializeUEA(address(0)) stores address(0) in UEA_LOGIC_SLOT. - // A subsequent delegatecall then reverts because _implementation() checks for zero. + function testFuzz_initializeUEA_zeroAddress_reverts(bytes calldata) public { + // initializeUEA(address(0)) now reverts with InvalidCall (matching CEAProxy) UEAProxy proxy = new UEAProxy(); + vm.expectRevert(UEAErrors.InvalidCall.selector); proxy.initializeUEA(address(0)); - - assertEq(proxy.getImplementation(), address(0)); - - // Any external call to the proxy should revert (no implementation set) - (bool ok,) = address(proxy).call(abi.encodeWithSignature("getValue()")); - assertFalse(ok); } // ========================================================================= diff --git a/test/fuzz/UEA_EVM_Fuzz.t.sol b/test/fuzz/UEA_EVM_Fuzz.t.sol index ceb812a..64468d2 100644 --- a/test/fuzz/UEA_EVM_Fuzz.t.sol +++ b/test/fuzz/UEA_EVM_Fuzz.t.sol @@ -42,10 +42,10 @@ contract UEA_EVM_FuzzTest is Test { UEAFactory factoryImpl = new UEAFactory(); bytes memory initData = - abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser")); + abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser"), "42101"); ERC1967Proxy proxy = new ERC1967Proxy(address(factoryImpl), initData); factory = UEAFactory(address(proxy)); - factory.setUEAProxyImplementation(address(ueaProxyImpl)); + factory.updateUEAProxyImplementation(address(ueaProxyImpl)); ueaEVMImpl = new UEA_EVM(); (owner, ownerPK) = makeAddrAndKey("owner"); @@ -58,7 +58,7 @@ contract UEA_EVM_FuzzTest is Test { ueaEVMImpl2 = new UEA_EVM(); ueaSVMImpl = new UEA_SVM(); migration = new UEAMigration(address(ueaEVMImpl2), address(ueaSVMImpl)); - factory.setUEAMigrationContract(address(migration)); + factory.updateUEAMigrationContract(address(migration)); } modifier deployEvmSmartAccount() { @@ -239,9 +239,9 @@ contract UEA_EVM_FuzzTest is Test { evmSmartAccountInstance.executeUniversalTx(firstPayload, firstSig); assertEq(evmSmartAccountInstance.nonce(), 1); - // Replay the old signature (signed at nonce 0) — the hash no longer matches - // because the contract nonce is now 1, so signature verification fails - vm.expectRevert(UEAErrors.InvalidEVMSignature.selector); + // Replay the old payload (nonce=0) — the account expects nonce 1 now, + // so the nonce check fires before signature verification + vm.expectRevert(abi.encodeWithSelector(UEAErrors.NonceMismatch.selector, 1, 0)); evmSmartAccountInstance.executeUniversalTx(firstPayload, firstSig); } @@ -338,9 +338,20 @@ contract UEA_EVM_FuzzTest is Test { // Now test migration selector goes to migration path // Migration requires payload.to == address(this), so it will revert with InvalidCall - // when targeting a different address — confirming migration path was taken + // when targeting a different address — confirming migration path was taken. + // Use nonce=1 since the first transaction above incremented the account nonce. bytes memory migData = abi.encodePacked(MIGRATION_SELECTOR); - UniversalPayload memory migPayload = _buildPayload(address(target), 0, migData, 0); + UniversalPayload memory migPayload = UniversalPayload({ + to: address(target), + value: 0, + data: migData, + gasLimit: 1_000_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + nonce: 1, + deadline: 0, + vType: VerificationType(0) + }); bytes memory migSig = _signPayload(evmSmartAccountInstance, migPayload, ownerPK); vm.expectRevert(UEAErrors.InvalidCall.selector); evmSmartAccountInstance.executeUniversalTx(migPayload, migSig); diff --git a/test/fuzz/UEA_SVM_Fuzz.t.sol b/test/fuzz/UEA_SVM_Fuzz.t.sol index 12fc74c..e73bfda 100644 --- a/test/fuzz/UEA_SVM_Fuzz.t.sol +++ b/test/fuzz/UEA_SVM_Fuzz.t.sol @@ -39,10 +39,10 @@ contract UEA_SVM_FuzzTest is Test { UEAFactory factoryImpl = new UEAFactory(); bytes memory initData = - abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser")); + abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser"), "42101"); ERC1967Proxy proxy = new ERC1967Proxy(address(factoryImpl), initData); factory = UEAFactory(address(proxy)); - factory.setUEAProxyImplementation(address(ueaProxyImpl)); + factory.updateUEAProxyImplementation(address(ueaProxyImpl)); ueaEVMImpl = new UEA_EVM(); ueaSVMImpl = new UEA_SVM(); diff --git a/test/fuzz/UniversalCore_Fuzz.t.sol b/test/fuzz/UniversalCore_Fuzz.t.sol index cbfc4a3..0cd45f0 100644 --- a/test/fuzz/UniversalCore_Fuzz.t.sol +++ b/test/fuzz/UniversalCore_Fuzz.t.sol @@ -29,20 +29,19 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { address mockWPC = makeAddr("wPC"); address mockFactory = makeAddr("uniswapFactory"); address mockRouter = makeAddr("uniswapRouter"); - address mockQuoter = makeAddr("uniswapQuoter"); pauser = makeAddr("pauser"); UniversalCore impl = new UniversalCore(); bytes memory initData = abi.encodeWithSelector( - UniversalCore.initialize.selector, mockWPC, mockFactory, mockRouter, mockQuoter, pauser + UniversalCore.initialize.selector, address(this), pauser, mockWPC, mockFactory, mockRouter ); address proxyAddr = deployUpgradeableContract(address(impl), initData); universalCore = UniversalCore(payable(proxyAddr)); - universalCore.grantRole(universalCore.MANAGER_ROLE(), uExec); + universalCore.grantRole(universalCore.UVCORE_ADMIN_ROLE(), uExec); gateway = makeAddr("gateway"); - universalCore.setUniversalGatewayPC(gateway); + universalCore.updateUniversalGatewayPC(gateway); // Deploy PRC20 with SOURCE_CHAIN_NAMESPACE = "1" (matches CHAIN_NS) PRC20 prc20Impl = new PRC20(); @@ -63,9 +62,9 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { vm.prank(uExec); universalCore.setChainMeta(CHAIN_NS, 50 gwei, 0); - // setGasTokenPRC20 is onlyRole(MANAGER_ROLE), uExec has it + // updateGasTokenPRC20 is onlyRole(UVCORE_ADMIN_ROLE), uExec has it vm.prank(uExec); - universalCore.setGasTokenPRC20(CHAIN_NS, address(gasToken)); + universalCore.updateGasTokenPRC20(CHAIN_NS, address(gasToken)); } // ============================================= @@ -83,9 +82,9 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { universalCore.setChainMeta(CHAIN_NS, gasPrice, 0); vm.prank(uExec); - universalCore.setBaseGasLimitByChain(CHAIN_NS, baseLimit); + universalCore.updateBaseGasLimitByChain(CHAIN_NS, baseLimit); - (, uint256 gasFee,,,) = universalCore.getOutboundTxGasAndFees(address(prc20), gasLimit); + (, uint256 gasFee,,,,) = universalCore.getOutboundTxGasAndFees(address(prc20), gasLimit); assertEq(gasFee, uint256(gasPrice) * uint256(gasLimit)); } @@ -98,10 +97,10 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { universalCore.setChainMeta(CHAIN_NS, gasPrice, 0); vm.prank(uExec); - universalCore.setBaseGasLimitByChain(CHAIN_NS, baseLimit); + universalCore.updateBaseGasLimitByChain(CHAIN_NS, baseLimit); // gasLimitWithBaseLimit == 0 → uses baseLimit - (, uint256 gasFee,,,) = universalCore.getOutboundTxGasAndFees(address(prc20), 0); + (, uint256 gasFee,,,,) = universalCore.getOutboundTxGasAndFees(address(prc20), 0); assertEq(gasFee, uint256(gasPrice) * uint256(baseLimit)); } @@ -111,25 +110,52 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { vm.assume(provided > 0 && provided < baseLimit); vm.prank(uExec); - universalCore.setBaseGasLimitByChain(CHAIN_NS, baseLimit); + universalCore.updateBaseGasLimitByChain(CHAIN_NS, baseLimit); vm.expectRevert(abi.encodeWithSelector(UniversalCoreErrors.GasLimitBelowBase.selector, provided, baseLimit)); universalCore.getOutboundTxGasAndFees(address(prc20), provided); } function testFuzz_getOutboundTxGasAndFees_zeroGasPrice_reverts(uint128 gasLimit) public { - vm.assume(gasLimit > 0); + uint256 baseLimit = 100_000; + vm.assume(gasLimit >= baseLimit); - // Set gas price to 0 — setChainMeta is onlyUEModule - vm.prank(uExec); - universalCore.setChainMeta(CHAIN_NS, 0, 0); + // Use a fresh chain namespace where setChainMeta is never called, + // so gasPriceByChainNamespace is 0 by default in storage. + string memory zeroPriceNs = "zeroprice"; + + // Deploy a fresh PRC20 on this namespace + PRC20 prc20Impl = new PRC20(); + bytes memory prc20Init = abi.encodeWithSelector( + PRC20.initialize.selector, + "ZeroPrice", + "ZP", + 18, + zeroPriceNs, + IPRC20.TokenType.NATIVE, + address(universalCore), + "0x0" + ); + address prc20Addr = deployUpgradeableContract(address(prc20Impl), prc20Init); + PRC20 zeroPricePRC20 = PRC20(payable(prc20Addr)); + + // Set gas token and base gas limit, but never call setChainMeta → price stays 0 + vm.startPrank(uExec); + universalCore.updateGasTokenPRC20(zeroPriceNs, address(gasToken)); + universalCore.updateBaseGasLimitByChain(zeroPriceNs, baseLimit); + vm.stopPrank(); vm.expectRevert(UniversalCoreErrors.ZeroGasPrice.selector); - universalCore.getOutboundTxGasAndFees(address(prc20), gasLimit); + universalCore.getOutboundTxGasAndFees(address(zeroPricePRC20), gasLimit); } function testFuzz_getOutboundTxGasAndFees_zeroGasToken_reverts(uint128 gasLimit) public { - vm.assume(gasLimit > 0); + uint256 baseLimit = 100_000; + vm.assume(gasLimit >= baseLimit); + + // Set base gas limit for "nogas" chain so we pass the zero-base check + vm.prank(uExec); + universalCore.updateBaseGasLimitByChain("nogas", baseLimit); // Deploy a fresh PRC20 on chain "nogas" — no gas token configured for "nogas" PRC20 prc20Impl = new PRC20(); @@ -166,7 +192,7 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { universalCore.setChainMeta(CHAIN_NS, gasPrice, 0); vm.prank(uExec); - universalCore.setRescueFundsGasLimitByChain(CHAIN_NS, rescueLimit); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NS, rescueLimit); (, uint256 gasFee, uint256 returnedRescueLimit,,) = universalCore.getRescueFundsGasLimit(address(prc20)); @@ -237,29 +263,17 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { // 12.4 Fee Tier Validation Properties // ============================================= - function testFuzz_setDefaultFeeTier_validTiers(address token, uint24 feeTier) public { + function testFuzz_updateDefaultFeeTier_validTiers(address token, uint24 feeTier) public { vm.assume(token != address(0)); - bool isValid = feeTier == 500 || feeTier == 3000 || feeTier == 10000; + bool isValid = feeTier == 100 || feeTier == 500 || feeTier == 3000 || feeTier == 10000; if (isValid) { - universalCore.setDefaultFeeTier(token, feeTier); + universalCore.updateDefaultFeeTier(token, feeTier); assertEq(universalCore.defaultFeeTier(token), feeTier); } else { vm.expectRevert(UniversalCoreErrors.InvalidFeeTier.selector); - universalCore.setDefaultFeeTier(token, feeTier); - } - } - - function testFuzz_setSlippageTolerance_boundary(address token, uint256 tolerance) public { - vm.assume(token != address(0)); - - if (tolerance <= 5000) { - universalCore.setSlippageTolerance(token, tolerance); - assertEq(universalCore.slippageTolerance(token), tolerance); - } else { - vm.expectRevert(UniversalCoreErrors.InvalidSlippageTolerance.selector); - universalCore.setSlippageTolerance(token, tolerance); + universalCore.updateDefaultFeeTier(token, feeTier); } } @@ -267,9 +281,9 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { // 12.5 Deadline Validation Properties // ============================================= - function testFuzz_setDefaultDeadlineMins_storesValue(uint256 mins) public { - // setDefaultDeadlineMins is onlyAdmin — test contract is DEFAULT_ADMIN_ROLE - universalCore.setDefaultDeadlineMins(mins); + function testFuzz_updateDefaultDeadlineMins_storesValue(uint256 mins) public { + // updateDefaultDeadlineMins is onlyAdmin — test contract is DEFAULT_ADMIN_ROLE + universalCore.updateDefaultDeadlineMins(mins); assertEq(universalCore.defaultDeadlineMins(), mins); } @@ -313,27 +327,28 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { universalCore.swapAndBurnGas{value: 0}(address(gasToken), 3000, 1, 0, address(this)); } - function testFuzz_setProtocolFeeByToken_nonManager_reverts(address caller, address token, uint256 fee) public { + function testFuzz_updateProtocolFeeByToken_nonManager_reverts(address caller, address token, uint256 fee) public { vm.assume(caller != uExec); // Cache role before prank — external calls inside vm.expectRevert would consume the prank - bytes32 managerRole = universalCore.MANAGER_ROLE(); + bytes32 managerRole = universalCore.UVCORE_ADMIN_ROLE(); vm.assume(!universalCore.hasRole(managerRole, caller)); vm.prank(caller); vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, managerRole) ); - universalCore.setProtocolFeeByToken(token, fee); + universalCore.updateProtocolFeeByToken(token, fee); } - function testFuzz_setWPC_nonAdmin_reverts(address caller, address newWPC) public { - // Cache role before prank to avoid consuming prank via external call - bytes32 adminRole = universalCore.DEFAULT_ADMIN_ROLE(); - vm.assume(!universalCore.hasRole(adminRole, caller)); + function testFuzz_updateWPC_nonOperator_reverts(address caller, address newWPC) public { + bytes32 operatorRole = universalCore.OPERATOR_ROLE(); + vm.assume(!universalCore.hasRole(operatorRole, caller)); vm.prank(caller); - vm.expectRevert(CommonErrors.InvalidOwner.selector); - universalCore.setWPC(newWPC); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, operatorRole) + ); + universalCore.updateWPC(newWPC); } function testFuzz_pause_nonPauser_reverts(address caller) public { @@ -361,43 +376,96 @@ contract UniversalCore_Fuzz is Test, UpgradeableContractHelper { // 12.7 Setter Zero-Address Validation Properties // ============================================= - function testFuzz_setWPC_zeroAddress_reverts() public { + function testFuzz_updateWPC_zeroAddress_reverts() public { vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setWPC(address(0)); + universalCore.updateWPC(address(0)); } - function testFuzz_setUniversalGatewayPC_zeroAddress_reverts() public { + function testFuzz_updateUniversalGatewayPC_zeroAddress_reverts() public { vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setUniversalGatewayPC(address(0)); + universalCore.updateUniversalGatewayPC(address(0)); } - function testFuzz_setUniswapV3Addresses_anyZero_reverts(address f, address r, address q) public { - bool anyZero = f == address(0) || r == address(0) || q == address(0); + function testFuzz_updateUniswapV3Addresses_anyZero_reverts(address f, address r) public { + bool anyZero = f == address(0) || r == address(0); if (anyZero) { vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setUniswapV3Addresses(f, r, q); + universalCore.updateUniswapV3Addresses(f, r); } else { // No revert expected — just verify it stores values - universalCore.setUniswapV3Addresses(f, r, q); + universalCore.updateUniswapV3Addresses(f, r); } } - function testFuzz_setGasTokenPRC20_zeroAddress_reverts(string memory chainNamespace) public { + function testFuzz_updateGasTokenPRC20_zeroAddress_reverts(string memory chainNamespace) public { vm.prank(uExec); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setGasTokenPRC20(chainNamespace, address(0)); + universalCore.updateGasTokenPRC20(chainNamespace, address(0)); } - function testFuzz_setProtocolFeeByToken_zeroToken_reverts(uint256 fee) public { + function testFuzz_updateProtocolFeeByToken_zeroToken_reverts(uint256 fee) public { vm.prank(uExec); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setProtocolFeeByToken(address(0), fee); + universalCore.updateProtocolFeeByToken(address(0), fee); + } + + // ============================================= + // 12.X Gas Data Staleness Properties + // ============================================= + + function testFuzz_staleness_revertsWhenPastWindow(uint128 maxAge, uint128 timePast) public { + vm.assume(maxAge > 0); + vm.assume(timePast > maxAge); + + // Establish gas price + base limit so the quote call reaches the staleness check. + vm.prank(uExec); + universalCore.setChainMeta(CHAIN_NS, 50 gwei, 0); + vm.prank(uExec); + universalCore.updateBaseGasLimitByChain(CHAIN_NS, 100_000); + + vm.prank(uExec); + universalCore.updateMaxStalenessByChain(CHAIN_NS, maxAge); + + uint256 observedAt = universalCore.timestampObservedAtByChainNamespace(CHAIN_NS); + vm.warp(uint256(observedAt) + uint256(timePast)); + + vm.expectRevert( + abi.encodeWithSelector( + UniversalCoreErrors.StaleGasData.selector, observedAt, block.timestamp, uint256(maxAge) + ) + ); + universalCore.getOutboundTxGasAndFees(address(prc20), 0); } - function testFuzz_setSupportedToken_zeroAddress_reverts(bool supported) public { + function testFuzz_staleness_okWithinWindow(uint128 maxAge, uint128 timePast) public { + vm.assume(maxAge > 0); + vm.assume(timePast <= maxAge); + vm.prank(uExec); - vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setSupportedToken(address(0), supported); + universalCore.setChainMeta(CHAIN_NS, 50 gwei, 0); + vm.prank(uExec); + universalCore.updateBaseGasLimitByChain(CHAIN_NS, 100_000); + + vm.prank(uExec); + universalCore.updateMaxStalenessByChain(CHAIN_NS, maxAge); + + uint256 observedAt = universalCore.timestampObservedAtByChainNamespace(CHAIN_NS); + vm.warp(uint256(observedAt) + uint256(timePast)); + + (, uint256 gasFee,,,,) = universalCore.getOutboundTxGasAndFees(address(prc20), 0); + assertGt(gasFee, 0, "should succeed within the staleness window"); + } + + function testFuzz_updateMaxStalenessByChain_nonUCoreAdmin_reverts(address caller, uint256 maxAge) public { + vm.assume(caller != uExec); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, caller, universalCore.UVCORE_ADMIN_ROLE() + ) + ); + vm.prank(caller); + universalCore.updateMaxStalenessByChain(CHAIN_NS, maxAge); } } diff --git a/test/mocks/FalseReturningPRC20.sol b/test/mocks/FalseReturningPRC20.sol new file mode 100644 index 0000000..cc7df3e --- /dev/null +++ b/test/mocks/FalseReturningPRC20.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @dev PRC20 mock that returns false instead of reverting on deposit/burn. +contract FalseReturningPRC20 { + string public SOURCE_CHAIN_NAMESPACE; + string public SOURCE_TOKEN_ADDRESS; + + constructor(string memory ns, string memory tokenAddr) { + SOURCE_CHAIN_NAMESPACE = ns; + SOURCE_TOKEN_ADDRESS = tokenAddr; + } + + function deposit(address, uint256) external pure returns (bool) { + return false; + } + + function burn(uint256) external pure returns (bool) { + return false; + } + + function approve(address, uint256) external pure returns (bool) { + return false; + } +} diff --git a/test/mocks/MaliciousPRC20.sol b/test/mocks/MaliciousPRC20.sol index d84b107..e34ee09 100644 --- a/test/mocks/MaliciousPRC20.sol +++ b/test/mocks/MaliciousPRC20.sol @@ -10,7 +10,7 @@ contract MaliciousPRC20 { function deposit(address to, uint256 amount) external returns (bool) { // Try to reenter handler with a function that requires admin role - (bool success,) = handler.call(abi.encodeWithSignature("setWPC(address)", address(0x123))); + (bool success,) = handler.call(abi.encodeWithSignature("updateWPC(address)", address(0x123))); if (!success) { revert("Reentry failed"); } diff --git a/test/mocks/MockGasToken.sol b/test/mocks/MockGasToken.sol index f4a6934..cd5d715 100644 --- a/test/mocks/MockGasToken.sol +++ b/test/mocks/MockGasToken.sol @@ -126,7 +126,7 @@ contract MockGasToken is IPRC20 { function getOutboundTxGasAndFees(address, uint256) external pure - returns (address, uint256, uint256, uint256, string memory) + returns (address, uint256, uint256, uint256, string memory, uint256) { revert("Not implemented"); } diff --git a/test/tests_cea/CEA.t.sol b/test/tests_cea/CEA.t.sol index 8fec249..58fde63 100644 --- a/test/tests_cea/CEA.t.sol +++ b/test/tests_cea/CEA.t.sol @@ -87,9 +87,9 @@ contract CEATest is Test { // Helper Functions - Canonical Multicall Builders // ========================================================================= - /// @notice Generate a unique txID for testing + /// @notice Generate a unique subTxId for testing function generateTxID(uint256 nonce) internal pure returns (bytes32) { - return keccak256(abi.encodePacked("txID", nonce)); + return keccak256(abi.encodePacked("subTxId", nonce)); } /// @notice Generate a unique universalTxID for testing @@ -286,38 +286,24 @@ contract CEATest is Test { function testRevertWhenInitializingTwice() public { CEA newCEA = new CEA(); - newCEA.initializeCEA(ueaOnPush, vault, address(mockUniversalGateway), address(factory)); + newCEA.initializeCEA(ueaOnPush, address(factory)); vm.expectRevert(Errors.AlreadyInitialized.selector); - newCEA.initializeCEA(ueaOnPush, vault, address(mockUniversalGateway), address(factory)); + newCEA.initializeCEA(ueaOnPush, address(factory)); } function testRevertWhenInitializingWithZeroUEA() public { CEA newCEA = new CEA(); vm.expectRevert(Errors.ZeroAddress.selector); - newCEA.initializeCEA(address(0), vault, address(mockUniversalGateway), address(factory)); - } - - function testRevertWhenInitializingWithZeroVault() public { - CEA newCEA = new CEA(); - - vm.expectRevert(Errors.ZeroAddress.selector); - newCEA.initializeCEA(ueaOnPush, address(0), address(mockUniversalGateway), address(factory)); - } - - function testRevertWhenInitializingWithZeroUniversalGateway() public { - CEA newCEA = new CEA(); - - vm.expectRevert(Errors.ZeroAddress.selector); - newCEA.initializeCEA(ueaOnPush, vault, address(0), address(factory)); + newCEA.initializeCEA(address(0), address(factory)); } function testRevertWhenInitializingWithZeroFactory() public { CEA newCEA = new CEA(); vm.expectRevert(Errors.ZeroAddress.selector); - newCEA.initializeCEA(ueaOnPush, vault, address(mockUniversalGateway), address(0)); + newCEA.initializeCEA(ueaOnPush, address(0)); } function testIsInitializedBeforeInitialization() public { @@ -366,14 +352,14 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory targetCalldata = abi.encodeWithSignature("setMagicNumber(uint256)", 42); bytes memory payload = buildERC20MulticallPayload(address(token), address(target), 100 ether, targetCalldata); vm.prank(nonVault); vm.expectRevert(Errors.NotVault.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } function testExecuteUniversalTx_SuccessWhenCalledByVault() public deployCEA { @@ -381,15 +367,15 @@ contract CEATest is Test { fundCEAWithTokens(address(token), 1000 ether); TokenSpenderTarget spender = new TokenSpenderTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory targetCalldata = abi.encodeWithSignature("spendTokens(address,uint256)", address(token), 100 ether); bytes memory payload = buildERC20MulticallPayload(address(token), address(spender), 100 ether, targetCalldata); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); assertEq(spender.totalReceived(address(token)), 100 ether, "Target should receive tokens"); } @@ -402,18 +388,18 @@ contract CEATest is Test { fundCEAWithTokens(address(token), 1000 ether); TokenSpenderTarget spender = new TokenSpenderTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory targetCalldata = abi.encodeWithSignature("spendTokens(address,uint256)", address(token), 100 ether); bytes memory payload = buildERC20MulticallPayload(address(token), address(spender), 100 ether, targetCalldata); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); - // Try to execute same txID again + // Try to execute same subTxId again vm.prank(vault); vm.expectRevert(Errors.PayloadExecuted.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } // ------------------------------------------------------------------------- @@ -424,7 +410,7 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); @@ -432,14 +418,14 @@ contract CEATest is Test { vm.expectRevert(Errors.InvalidUEA.selector); bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(target), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, makeAddr("wrongUEA"), address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, makeAddr("wrongUEA"), address(0), multicallPayload); } function testExecuteUniversalTx_RevertWhenTargetIsZero() public deployCEA { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); @@ -447,7 +433,7 @@ contract CEATest is Test { vm.expectRevert(Errors.InvalidTarget.selector); bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(0), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testExecuteUniversalTx_SuccessWithSufficientTokenBalance() public deployCEA { @@ -455,14 +441,14 @@ contract CEATest is Test { fundCEAWithTokens(address(token), 100 ether); TokenSpenderTarget spender = new TokenSpenderTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("spendTokens(address,uint256)", address(token), 100 ether); vm.prank(vault); bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(spender), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(spender.totalReceived(address(token)), 100 ether, "Exact balance should work"); } @@ -482,14 +468,14 @@ contract CEATest is Test { token.approve(address(spender), 500 ether); assertEq(token.allowance(address(ceaInstance), address(spender)), 500 ether, "Initial approval should exist"); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("spendTokens(address,uint256)", address(token), 100 ether); vm.prank(vault); bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(spender), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); // Approval should be reset to 0 after execution assertEq(token.allowance(address(ceaInstance), address(spender)), 0, "Approval should be reset"); @@ -500,7 +486,7 @@ contract CEATest is Test { fundCEAWithTokens(address(token), 1000 ether); TokenSpenderTarget spender = new TokenSpenderTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("spendTokens(address,uint256)", address(token), 100 ether); @@ -508,7 +494,7 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(spender), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(spender.totalReceived(address(token)), 100 ether, "Correct amount should be approved and spent"); } @@ -518,14 +504,14 @@ contract CEATest is Test { fundCEAWithTokens(address(token), 1000 ether); TokenSpenderTarget spender = new TokenSpenderTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("spendTokens(address,uint256)", address(token), 100 ether); vm.prank(vault); bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(spender), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); // Approval should be reset to 0 after execution assertEq(token.allowance(address(ceaInstance), address(spender)), 0, "Approval should be reset after execution"); @@ -540,7 +526,7 @@ contract CEATest is Test { token.approve(address(target), 500 ether); TokenSpenderTarget spender = new TokenSpenderTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("spendTokens(address,uint256)", address(token), 100 ether); @@ -548,7 +534,7 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(spender), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq( spender.totalReceived(address(token)), 100 ether, "Execution should succeed despite zero approval revert" @@ -563,14 +549,14 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); vm.prank(vault); bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(target), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(target.getMagicNumber(), 42, "Target should execute correctly"); } @@ -580,7 +566,7 @@ contract CEATest is Test { fundCEAWithTokens(address(token), 1000 ether); TokenReceiverTarget receiver = new TokenReceiverTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("receiveTokens(address,uint256)", address(token), 100 ether); @@ -588,7 +574,7 @@ contract CEATest is Test { bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(receiver), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(receiver.tokenBalances(address(token)), 100 ether, "Target should receive correct amount"); assertEq(MockGasToken(token).balanceOf(address(receiver)), 100 ether, "Balance should be correct"); @@ -599,7 +585,7 @@ contract CEATest is Test { fundCEAWithTokens(address(token), 1000 ether); RevertingTarget reverter = new RevertingTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("revertWithReason()"); @@ -607,13 +593,14 @@ contract CEATest is Test { bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(reverter), 100 ether, payload); - // Expect ExecutionFailed (revert data no longer bubbled) - vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + // Underlying revert reason is now propagated + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - // txID should NOT be marked as executed when execution fails + // subTxId should NOT be marked as executed when execution fails assertFalse( - CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should not be marked as executed on failure" + CEA(payable(address(ceaInstance))).isExecuted(subTxId), + "subTxId should not be marked as executed on failure" ); } @@ -622,7 +609,7 @@ contract CEATest is Test { fundCEAWithTokens(address(token), 1000 ether); TokenSpenderTarget spender = new TokenSpenderTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = ""; // Empty payload @@ -634,7 +621,7 @@ contract CEATest is Test { bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(spender), 100 ether, spendPayload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(spender.totalReceived(address(token)), 100 ether, "Empty payload should work"); } @@ -643,7 +630,7 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 magicValue = 999; bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", magicValue); @@ -651,7 +638,7 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(target), 100 ether, payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(target.getMagicNumber(), magicValue, "Payload should execute with correct parameters"); } @@ -663,7 +650,7 @@ contract CEATest is Test { function testExecuteUniversalTx_RevertWhenCalledByNonVault_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumberWithFee(uint256)", 42); @@ -672,13 +659,15 @@ contract CEATest is Test { vm.expectRevert(Errors.NotVault.selector); bytes memory multicallPayload = buildNativeMulticallPayload(address(target), 0.1 ether, payload); - ceaInstance.executeUniversalTx{value: 0.1 ether}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0.1 ether}( + subTxId, universalTxID, ueaOnPush, address(0), multicallPayload + ); } function testExecuteUniversalTx_RevertWhenInvalidUEA_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumberWithFee(uint256)", 42); @@ -688,14 +677,14 @@ contract CEATest is Test { bytes memory multicallPayload = buildNativeMulticallPayload(address(target), 0.1 ether, payload); ceaInstance.executeUniversalTx{value: 0.1 ether}( - txID, universalTxID, makeAddr("wrongUEA"), address(0), multicallPayload + subTxId, universalTxID, makeAddr("wrongUEA"), address(0), multicallPayload ); } function testExecuteUniversalTx_MsgValueExceedsCallValue_Native_Succeeds() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumberWithFee(uint256)", 42); @@ -704,7 +693,9 @@ contract CEATest is Test { bytes memory multicallPayload = buildNativeMulticallPayload(address(target), 0.1 ether, payload); // Excess msg.value stays in CEA — no strict equality check - ceaInstance.executeUniversalTx{value: 0.2 ether}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0.2 ether}( + subTxId, universalTxID, ueaOnPush, address(0), multicallPayload + ); assertEq(target.getMagicNumber(), 42, "Target should execute correctly"); } @@ -712,7 +703,7 @@ contract CEATest is Test { function testExecuteUniversalTx_SuccessWhenMsgValueEqualsAmount_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumberWithFee(uint256)", 42); uint256 amount = 0.1 ether; @@ -721,7 +712,7 @@ contract CEATest is Test { vm.deal(vault, amount); bytes memory multicallPayload = buildNativeMulticallPayload(address(target), amount, payload); - ceaInstance.executeUniversalTx{value: amount}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: amount}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(address(target).balance, amount, "Target should receive correct amount"); } @@ -734,7 +725,7 @@ contract CEATest is Test { function testExecuteUniversalTx_SuccessfulCallToTarget_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumberWithFee(uint256)", 42); @@ -742,7 +733,9 @@ contract CEATest is Test { vm.deal(vault, 0.1 ether); bytes memory multicallPayload = buildNativeMulticallPayload(address(target), 0.1 ether, payload); - ceaInstance.executeUniversalTx{value: 0.1 ether}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0.1 ether}( + subTxId, universalTxID, ueaOnPush, address(0), multicallPayload + ); assertEq(target.getMagicNumber(), 42, "Target should execute correctly"); assertEq(address(target).balance, 0.1 ether, "Target should receive native tokens"); @@ -752,7 +745,7 @@ contract CEATest is Test { fundCEAWithNative(1000 ether); TokenReceiverTarget receiver = new TokenReceiverTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("receiveNative()"); uint256 amount = 0.5 ether; @@ -761,7 +754,7 @@ contract CEATest is Test { vm.deal(vault, amount); bytes memory multicallPayload = buildNativeMulticallPayload(address(receiver), amount, payload); - ceaInstance.executeUniversalTx{value: amount}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: amount}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(receiver.nativeBalance(), amount, "Target should receive correct native amount"); } @@ -770,7 +763,7 @@ contract CEATest is Test { fundCEAWithNative(1000 ether); RevertingTarget reverter = new RevertingTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("revertWithReason()"); @@ -779,7 +772,9 @@ contract CEATest is Test { vm.expectRevert(Errors.ExecutionFailed.selector); bytes memory multicallPayload = buildNativeMulticallPayload(address(reverter), 0.1 ether, payload); - ceaInstance.executeUniversalTx{value: 0.1 ether}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0.1 ether}( + subTxId, universalTxID, ueaOnPush, address(0), multicallPayload + ); } // ========================================================================= @@ -790,7 +785,7 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); @@ -799,15 +794,15 @@ contract CEATest is Test { // Note: Event is emitted per multicall step (3 events: reset approval, approve, execute) vm.expectEmit(true, true, true, true); - emit ICEA.UniversalTxExecuted(txID, universalTxID, ueaOnPush, address(target), payload); + emit ICEA.UniversalTxExecuted(subTxId, universalTxID, ueaOnPush, address(target), payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testExecuteUniversalTx_EmitsUniversalTxExecutedEvent_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumberWithFee(uint256)", 42); uint256 amount = 0.1 ether; @@ -817,9 +812,9 @@ contract CEATest is Test { bytes memory multicallPayload = buildNativeMulticallPayload(address(target), amount, payload); vm.expectEmit(true, true, true, true); - emit ICEA.UniversalTxExecuted(txID, universalTxID, ueaOnPush, address(target), payload); + emit ICEA.UniversalTxExecuted(subTxId, universalTxID, ueaOnPush, address(target), payload); - ceaInstance.executeUniversalTx{value: amount}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: amount}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } // ------------------------------------------------------------------------- // 1. ACCESS CONTROL & AUTHORIZATION TESTS @@ -829,7 +824,7 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); @@ -837,23 +832,23 @@ contract CEATest is Test { vm.expectRevert(Errors.NotVault.selector); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_SuccessWhenCalledByVault() public deployCEA { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); assertEq(mockUniversalGateway.callCount(), 1, "Gateway should be called once"); } @@ -865,26 +860,26 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - // Try to execute same txID again + // Try to execute same subTxId again vm.prank(vault); vm.expectRevert(Errors.PayloadExecuted.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_RevertWhenInvalidUEA() public deployCEA { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); @@ -892,14 +887,14 @@ contract CEATest is Test { vm.expectRevert(Errors.InvalidUEA.selector); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, makeAddr("wrongUEA"), address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, makeAddr("wrongUEA"), address(0), multicallPayload); } function testSendUniversalTxToUEA_RevertWhenPayloadTooShort() public deployCEA { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Create multicall with malformed self-call data (too short) @@ -910,32 +905,27 @@ contract CEATest is Test { vm.prank(vault); // After removing _handleSelfCall, malformed calls execute via .call() and fail vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_RevertWhenInvalidSelector() public deployCEA { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Create multicall with wrong selector (try to call initializeCEA) Multicall[] memory calls = new Multicall[](1); calls[0] = makeCall( - address(ceaInstance), - 0, - abi.encodeWithSignature( - "initializeCEA(address,address,address,address)", address(0), address(0), address(0), address(0) - ) + address(ceaInstance), 0, abi.encodeWithSignature("initializeCEA(address,address)", address(0), address(0)) ); bytes memory multicallPayload = encodeCalls(calls); vm.prank(vault); // Calls initializeCEA via .call() which reverts with AlreadyInitialized - // but we now get ExecutionFailed instead of bubbled error - vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + vm.expectRevert(Errors.AlreadyInitialized.selector); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } // ------------------------------------------------------------------------- @@ -946,31 +936,31 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 100 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled from sendUniversalTxToUEA's InsufficientBalance + vm.expectRevert(Errors.InsufficientBalance.selector); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_SuccessWithExactERC20Balance() public deployCEA { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 500 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); assertEq(mockUniversalGateway.callCount(), 1, "Gateway should be called once"); } @@ -978,16 +968,16 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); } // ------------------------------------------------------------------------- @@ -998,7 +988,7 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(token), amount, ueaOnPush); @@ -1006,7 +996,7 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), amount, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(mockUniversalGateway.lastRecipient(), ueaOnPush, "Recipient should be UEA"); assertEq(mockUniversalGateway.lastToken(), address(token), "Token should match"); @@ -1021,7 +1011,7 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); @@ -1030,7 +1020,7 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(mockUniversalGateway.callCount(), callCountBefore + 1, "Gateway should be called exactly once"); } @@ -1052,20 +1042,19 @@ contract CEATest is Test { "Initial approval should exist" ); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - // Approval should be set to amount (gateway may or may not consume it) assertEq( token.allowance(address(ceaInstance), address(mockUniversalGateway)), - 500 ether, - "Approval should be set to amount" + 0, + "Approval should be reset to zero after gateway call" ); } @@ -1073,7 +1062,7 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(token), amount, ueaOnPush); @@ -1081,11 +1070,12 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), amount, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - // Gateway should have approval for exact amount assertEq( - token.allowance(address(ceaInstance), address(mockUniversalGateway)), amount, "Approval should match amount" + token.allowance(address(ceaInstance), address(mockUniversalGateway)), + 0, + "Approval should be reset to zero after gateway call" ); } @@ -1097,18 +1087,18 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(token), 500 ether, ueaOnPush); - assertFalse(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should not be executed before"); + assertFalse(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should not be executed before"); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 500 ether, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed after"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed after"); } function testSendUniversalTxToUEA_ERC20BalanceDecreases() public deployCEA { @@ -1116,7 +1106,7 @@ contract CEATest is Test { uint256 initialBalance = 1000 ether; fundCEAWithTokens(address(token), initialBalance); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 sendAmount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(token), sendAmount, ueaOnPush); @@ -1126,16 +1116,15 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), sendAmount, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - // Gateway receives approval but mock doesn't transfer tokens - // So balance remains the same, but approval should be granted + // Mock gateway doesn't transfer tokens, so balance unchanged uint256 balanceAfter = token.balanceOf(address(ceaInstance)); assertEq(balanceAfter, balanceBefore, "Balance should remain same (mock doesn't transfer)"); assertEq( token.allowance(address(ceaInstance), address(mockUniversalGateway)), - sendAmount, - "Gateway should have approval" + 0, + "Approval should be reset to zero after gateway call" ); } @@ -1147,7 +1136,7 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(token), amount, ueaOnPush); @@ -1158,14 +1147,14 @@ contract CEATest is Test { bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), amount, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_EmitsUniversalTxExecutedEvent_ERC20() public deployCEA { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(token), amount, ueaOnPush); @@ -1174,9 +1163,9 @@ contract CEATest is Test { bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), amount, true); vm.expectEmit(true, true, true, true); - emit ICEA.UniversalTxExecuted(txID, universalTxID, ueaOnPush, address(ceaInstance), payload); + emit ICEA.UniversalTxExecuted(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } // ------------------------------------------------------------------------- @@ -1187,16 +1176,15 @@ contract CEATest is Test { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); - bytes memory payload = buildSendToUEAPayload(address(token), 0, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), 0, true); - // Zero amount sends revert with ExecutionFailed (bubbled from sendUniversalTxToUEA's InvalidInput) - vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); + + assertEq(mockUniversalGateway.lastAmount(), 0, "Should allow zero amount for ERC20"); } function testSendUniversalTxToUEA_MultipleSendsWithDifferentTxIDs_ERC20() public deployCEA { @@ -1206,16 +1194,16 @@ contract CEATest is Test { uint256 amount = 500 ether; for (uint256 i = 1; i <= 3; i++) { - bytes32 txID = generateTxID(i); + bytes32 subTxId = generateTxID(i); bytes32 universalTxID = generateUniversalTxID(i); bytes memory payload = buildSendToUEAPayload(address(token), amount, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), amount, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); } assertEq(mockUniversalGateway.callCount(), 3, "Gateway should be called 3 times"); @@ -1230,7 +1218,7 @@ contract CEATest is Test { uint256 initialBalance = 1000 ether; fundCEAWithTokens(address(token), initialBalance); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 sendAmount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(token), sendAmount, ueaOnPush); @@ -1241,24 +1229,46 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), sendAmount, true); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); // Verify all state changes - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); assertEq(mockUniversalGateway.callCount(), gatewayCallCountBefore + 1, "Gateway should be called once"); assertEq(mockUniversalGateway.lastRecipient(), ueaOnPush, "Recipient should be UEA"); assertEq(mockUniversalGateway.lastToken(), address(token), "Token should match"); assertEq(mockUniversalGateway.lastAmount(), sendAmount, "Amount should match"); - // Gateway receives approval but mock doesn't transfer tokens - // So balance remains the same, but approval should be granted + // Mock gateway doesn't transfer tokens, so balance unchanged uint256 balanceAfter = token.balanceOf(address(ceaInstance)); assertEq(balanceAfter, balanceBefore, "Balance should remain same (mock doesn't transfer)"); assertEq( token.allowance(address(ceaInstance), address(mockUniversalGateway)), - sendAmount, - "Gateway should have approval" + 0, + "Approval should be reset to zero after gateway call" + ); + } + + function testSendUniversalTxToUEA_ResetsApprovalToZeroAfterGatewayCall() public deployCEA { + MockGasToken token = new MockGasToken(); + fundCEAWithTokens(address(token), 2000 ether); + + vm.prank(address(ceaInstance)); + token.approve(address(mockUniversalGateway), 1000 ether); + assertEq(token.allowance(address(ceaInstance), address(mockUniversalGateway)), 1000 ether); + + bytes32 subTxId = generateTxID(1); + bytes32 universalTxID = generateUniversalTxID(1); + uint256 sendAmount = 500 ether; + + vm.prank(vault); + bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(token), sendAmount, true); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); + + assertEq( + token.allowance(address(ceaInstance), address(mockUniversalGateway)), + 0, + "Pre-existing approval should be zeroed after gateway call" ); } @@ -1269,7 +1279,7 @@ contract CEATest is Test { function testSendUniversalTxToUEA_RevertWhenCalledByNonVault_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(0), 500 ether, ueaOnPush); @@ -1278,47 +1288,47 @@ contract CEATest is Test { vm.expectRevert(Errors.NotVault.selector); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 500 ether, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_SuccessWhenCalledByVault_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(0), 500 ether, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 500 ether, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); assertEq(mockUniversalGateway.callCount(), 1, "Gateway should be called once"); } function testSendUniversalTxToUEA_RevertWhenTxIDAlreadyExecuted_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(0), 500 ether, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 500 ether, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - // Try to execute same txID again + // Try to execute same subTxId again vm.prank(vault); vm.expectRevert(Errors.PayloadExecuted.selector); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_RevertWhenInvalidUEA_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(0), 500 ether, ueaOnPush); @@ -1327,14 +1337,14 @@ contract CEATest is Test { bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 500 ether, false); ceaInstance.executeUniversalTx{value: 0}( - txID, universalTxID, makeAddr("wrongUEA"), address(0), multicallPayload + subTxId, universalTxID, makeAddr("wrongUEA"), address(0), multicallPayload ); } function testSendUniversalTxToUEA_RevertWhenPayloadTooShort_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Create multicall with malformed self-call data (too short) @@ -1345,82 +1355,77 @@ contract CEATest is Test { vm.prank(vault); // After removing _handleSelfCall, malformed calls execute via .call() and fail vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_RevertWhenInvalidSelector_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Create multicall with wrong selector (try to call initializeCEA) Multicall[] memory calls = new Multicall[](1); calls[0] = makeCall( - address(ceaInstance), - 0, - abi.encodeWithSignature( - "initializeCEA(address,address,address,address)", address(0), address(0), address(0), address(0) - ) + address(ceaInstance), 0, abi.encodeWithSignature("initializeCEA(address,address)", address(0), address(0)) ); bytes memory multicallPayload = encodeCalls(calls); vm.prank(vault); // Calls initializeCEA via .call() which reverts with AlreadyInitialized - // but we now get ExecutionFailed instead of bubbled error - vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + vm.expectRevert(Errors.AlreadyInitialized.selector); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_RevertWhenInsufficientNativeBalance() public deployCEA { // Don't fund CEA - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(0), 500 ether, ueaOnPush); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled from sendUniversalTxToUEA's InsufficientBalance + vm.expectRevert(Errors.InsufficientBalance.selector); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 500 ether, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_SuccessWithExactNativeBalance() public deployCEA { uint256 balance = 500 ether; fundCEAWithNative(balance); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(0), balance, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), balance, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); assertEq(mockUniversalGateway.callCount(), 1, "Gateway should be called once"); } function testSendUniversalTxToUEA_SuccessWithMoreThanRequiredBalance_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(0), 500 ether, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 500 ether, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); } function testSendUniversalTxToUEA_CallsGatewayWithCorrectParams_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(0), amount, ueaOnPush); @@ -1428,7 +1433,7 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), amount, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(mockUniversalGateway.lastRecipient(), ueaOnPush, "Recipient should be UEA"); assertEq(mockUniversalGateway.lastToken(), address(0), "Token should be address(0) for native"); @@ -1441,7 +1446,7 @@ contract CEATest is Test { function testSendUniversalTxToUEA_CallsGatewayExactlyOnce_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(0), 500 ether, ueaOnPush); @@ -1450,7 +1455,7 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 500 ether, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); assertEq(mockUniversalGateway.callCount(), callCountBefore + 1, "Gateway should be called exactly once"); } @@ -1458,25 +1463,25 @@ contract CEATest is Test { function testSendUniversalTxToUEA_MarksTxIDAsExecuted_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildSendToUEAPayload(address(0), 500 ether, ueaOnPush); - assertFalse(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should not be executed before"); + assertFalse(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should not be executed before"); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 500 ether, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed after"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed after"); } function testSendUniversalTxToUEA_NativeBalanceDecreases() public deployCEA { uint256 initialBalance = 1000 ether; fundCEAWithNative(initialBalance); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 sendAmount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(0), sendAmount, ueaOnPush); @@ -1486,7 +1491,7 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), sendAmount, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); uint256 balanceAfter = address(ceaInstance).balance; assertEq(balanceAfter, balanceBefore - sendAmount, "Balance should decrease by exact amount"); @@ -1496,7 +1501,7 @@ contract CEATest is Test { function testSendUniversalTxToUEA_EmitsUniversalTxToUEAEvent_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(0), amount, ueaOnPush); @@ -1507,13 +1512,13 @@ contract CEATest is Test { bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), amount, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_EmitsUniversalTxExecutedEvent_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(0), amount, ueaOnPush); @@ -1522,24 +1527,23 @@ contract CEATest is Test { bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), amount, false); vm.expectEmit(true, true, true, true); - emit ICEA.UniversalTxExecuted(txID, universalTxID, ueaOnPush, address(ceaInstance), payload); + emit ICEA.UniversalTxExecuted(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); } function testSendUniversalTxToUEA_HandlesZeroAmount_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); - bytes memory payload = buildSendToUEAPayload(address(0), 0, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 0, false); - // Zero amount sends revert with ExecutionFailed (bubbled from sendUniversalTxToUEA's InvalidInput) - vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); + + assertEq(mockUniversalGateway.lastAmount(), 0, "Should allow zero amount for native"); } function testSendUniversalTxToUEA_MultipleSendsWithDifferentTxIDs_Native() public deployCEA { @@ -1548,16 +1552,16 @@ contract CEATest is Test { uint256 amount = 500 ether; for (uint256 i = 1; i <= 3; i++) { - bytes32 txID = generateTxID(i); + bytes32 subTxId = generateTxID(i); bytes32 universalTxID = generateUniversalTxID(i); bytes memory payload = buildSendToUEAPayload(address(0), amount, ueaOnPush); vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), amount, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); } assertEq(mockUniversalGateway.callCount(), 3, "Gateway should be called 3 times"); @@ -1567,7 +1571,7 @@ contract CEATest is Test { uint256 initialBalance = 1000 ether; fundCEAWithNative(initialBalance); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 sendAmount = 500 ether; bytes memory payload = buildSendToUEAPayload(address(0), sendAmount, ueaOnPush); @@ -1578,10 +1582,10 @@ contract CEATest is Test { vm.prank(vault); bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), sendAmount, false); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), multicallPayload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), multicallPayload); // Verify all state changes - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked as executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked as executed"); assertEq(mockUniversalGateway.callCount(), gatewayCallCountBefore + 1, "Gateway should be called once"); assertEq(mockUniversalGateway.lastRecipient(), ueaOnPush, "Recipient should be UEA"); @@ -1631,8 +1635,8 @@ contract CEATest is Test { bytes memory multicallPayload = buildERC20MulticallPayload(address(token), address(reverter), 100 ether, payload); - // Expect ExecutionFailed (revert data no longer bubbled) - vm.expectRevert(Errors.ExecutionFailed.selector); + // Underlying revert reason is now propagated + vm.expectRevert("This function always reverts with reason"); ceaInstance.executeUniversalTx( generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), multicallPayload ); @@ -1666,7 +1670,7 @@ contract CEATest is Test { function testExecuteUniversalTx_Native_IsExecutedOnlyOnSuccess() public deployCEA { RevertingTarget reverter = new RevertingTarget(); uint256 amount = 0.1 ether; - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); vm.deal(vault, amount); vm.prank(vault); @@ -1674,11 +1678,11 @@ contract CEATest is Test { bytes memory multicallPayload = buildNativeMulticallPayload(address(reverter), amount, bytes("")); ceaInstance.executeUniversalTx{value: amount}( - txID, generateUniversalTxID(1), ueaOnPush, address(0), multicallPayload + subTxId, generateUniversalTxID(1), ueaOnPush, address(0), multicallPayload ); assertFalse( - CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should not be marked executed on failure" + CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should not be marked executed on failure" ); } @@ -1706,15 +1710,17 @@ contract CEATest is Test { function testHandleSelfCalls_RevertWhenPayloadExactly4Bytes() public deployCEA { fundCEAWithNative(100 ether); - // Exactly 4 bytes (selector only) - abi.decode on empty payload[4:] will panic - bytes memory selectorOnly = abi.encodePacked(bytes4(keccak256("sendUniversalTxToUEA(address,uint256,bytes)"))); + // Exactly 4 bytes (selector only) — abi.decode on empty payload[4:] will panic + bytes memory selectorOnly = + abi.encodePacked(bytes4(keccak256("sendUniversalTxToUEA(address,uint256,bytes,address)"))); + + Multicall[] memory calls = new Multicall[](1); + calls[0] = Multicall({to: address(ceaInstance), value: 0, data: selectorOnly}); vm.prank(vault); vm.expectRevert(); - bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 0, false); - ceaInstance.executeUniversalTx( - generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), multicallPayload + generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); } @@ -1722,15 +1728,16 @@ contract CEATest is Test { fundCEAWithNative(100 ether); // Correct selector but truncated args - bytes4 selector = bytes4(keccak256("sendUniversalTxToUEA(address,uint256,bytes)")); + bytes4 selector = bytes4(keccak256("sendUniversalTxToUEA(address,uint256,bytes,address)")); bytes memory malformed = abi.encodePacked(selector, bytes28(0)); + Multicall[] memory calls = new Multicall[](1); + calls[0] = Multicall({to: address(ceaInstance), value: 0, data: malformed}); + vm.prank(vault); vm.expectRevert(); - bytes memory multicallPayload = buildSendToUEAMulticallPayload(address(0), 0, false); - ceaInstance.executeUniversalTx( - generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), multicallPayload + generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); } @@ -1740,8 +1747,7 @@ contract CEATest is Test { function testInitializeCEA_CannotBeCalledAgainAfterProxyDeployment() public deployCEA { vm.expectRevert(Errors.AlreadyInitialized.selector); - CEA(payable(address(ceaInstance))) - .initializeCEA(ueaOnPush, vault, address(mockUniversalGateway), address(factory)); + CEA(payable(address(ceaInstance))).initializeCEA(ueaOnPush, address(factory)); } function testReceive_DirectETHTransferSucceeds() public deployCEA { diff --git a/test/tests_cea/CEAFactory.t.sol b/test/tests_cea/CEAFactory.t.sol index deb009f..09df5d1 100644 --- a/test/tests_cea/CEAFactory.t.sol +++ b/test/tests_cea/CEAFactory.t.sol @@ -13,6 +13,9 @@ import {CEAErrors} from "../../src/libraries/Errors.sol"; import {MockUniversalGateway} from "../mocks/MockUniversalGateway.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import { + IAccessControlDefaultAdminRules +} from "@openzeppelin/contracts/access/extensions/IAccessControlDefaultAdminRules.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {ICEAFactory} from "../../src/interfaces/ICEAFactory.sol"; @@ -189,25 +192,25 @@ contract CEAFactoryTest is Test { // Admin Functions - setVault // ========================================================================= - function testSetVaultOnlyOwner() public { + function testUpdateVault_OnlyOperator() public { address newVault = makeAddr("newVault"); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 operatorRole = factory.OPERATOR_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, operatorRole) ); vm.prank(nonOwner); - factory.setVault(newVault); + factory.updateVault(newVault); vm.prank(owner); - factory.setVault(newVault); + factory.updateVault(newVault); assertEq(factory.VAULT(), newVault, "Vault should be updated"); } function testSetVaultZeroAddressReverts() public { vm.prank(owner); vm.expectRevert(CEAErrors.ZeroAddress.selector); - factory.setVault(address(0)); + factory.updateVault(address(0)); } function testSetVaultUpdatesState() public { @@ -215,7 +218,7 @@ contract CEAFactoryTest is Test { address oldVault = factory.VAULT(); vm.prank(owner); - factory.setVault(newVault); + factory.updateVault(newVault); assertEq(factory.VAULT(), newVault, "Vault should be updated"); assertNotEq(factory.VAULT(), oldVault, "Vault should be different from old"); @@ -228,7 +231,7 @@ contract CEAFactoryTest is Test { vm.prank(owner); vm.expectEmit(true, true, false, false); emit ICEAFactory.VaultUpdated(oldVault, newVault); - factory.setVault(newVault); + factory.updateVault(newVault); } function testSetVaultMultipleTimes() public { @@ -237,13 +240,13 @@ contract CEAFactoryTest is Test { address vault3 = makeAddr("vault3"); vm.startPrank(owner); - factory.setVault(vault1); + factory.updateVault(vault1); assertEq(factory.VAULT(), vault1); - factory.setVault(vault2); + factory.updateVault(vault2); assertEq(factory.VAULT(), vault2); - factory.setVault(vault3); + factory.updateVault(vault3); assertEq(factory.VAULT(), vault3); vm.stopPrank(); } @@ -252,7 +255,7 @@ contract CEAFactoryTest is Test { address currentVault = factory.VAULT(); vm.prank(owner); - factory.setVault(currentVault); + factory.updateVault(currentVault); assertEq(factory.VAULT(), currentVault, "Vault should remain the same"); } @@ -263,7 +266,7 @@ contract CEAFactoryTest is Test { address newVault = makeAddr("newVault"); vm.prank(owner); - factory.setVault(newVault); + factory.updateVault(newVault); assertTrue(hasCode(cea), "CEA should still have code"); assertEq(factory.getPushAccountForCEA(cea), ueaOnPush, "Mapping should persist"); @@ -274,17 +277,17 @@ contract CEAFactoryTest is Test { address contractAddress = address(contractVault); vm.prank(owner); - factory.setVault(contractAddress); + factory.updateVault(contractAddress); assertEq(factory.VAULT(), contractAddress, "Vault can be a contract"); } - function testSetCEAProxyImplementationOnlyOwner() public { + function testSetCEAProxyImplementation_OnlyCEAAdmin() public { CEAProxy newProxyImpl = new CEAProxy(); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 ceaAdminRole = factory.CEA_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ceaAdminRole) ); vm.prank(nonOwner); factory.setCEAProxyImplementation(address(newProxyImpl)); @@ -366,12 +369,12 @@ contract CEAFactoryTest is Test { factory.deployCEA(newUEA); } - function testSetCEAImplementationOnlyOwner() public { + function testSetCEAImplementation_OnlyCEAAdmin() public { CEA newCEAImpl = new CEA(); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 ceaAdminRole = factory.CEA_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ceaAdminRole) ); vm.prank(nonOwner); factory.setCEAImplementation(address(newCEAImpl)); @@ -455,25 +458,25 @@ contract CEAFactoryTest is Test { // Admin Functions - setUniversalGateway // ========================================================================= - function testSetUniversalGatewayOnlyOwner() public { + function testUpdateUniversalGateway_OnlyOperator() public { MockUniversalGateway newGateway = new MockUniversalGateway(); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 operatorRole = factory.OPERATOR_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, operatorRole) ); vm.prank(nonOwner); - factory.setUniversalGateway(address(newGateway)); + factory.updateUniversalGateway(address(newGateway)); vm.prank(owner); - factory.setUniversalGateway(address(newGateway)); + factory.updateUniversalGateway(address(newGateway)); assertEq(factory.UNIVERSAL_GATEWAY(), address(newGateway)); } function testSetUniversalGatewayZeroAddressReverts() public { vm.prank(owner); vm.expectRevert(CEAErrors.ZeroAddress.selector); - factory.setUniversalGateway(address(0)); + factory.updateUniversalGateway(address(0)); } function testSetUniversalGatewayUpdatesState() public { @@ -481,7 +484,7 @@ contract CEAFactoryTest is Test { address oldGateway = factory.UNIVERSAL_GATEWAY(); vm.prank(owner); - factory.setUniversalGateway(address(newGateway)); + factory.updateUniversalGateway(address(newGateway)); assertEq(factory.UNIVERSAL_GATEWAY(), address(newGateway)); assertNotEq(factory.UNIVERSAL_GATEWAY(), oldGateway); @@ -819,7 +822,7 @@ contract CEAFactoryTest is Test { // Similar to above - setter prevents zero address vm.prank(owner); vm.expectRevert(CEAErrors.ZeroAddress.selector); - factory.setUniversalGateway(address(0)); + factory.updateUniversalGateway(address(0)); } // ========================================================================= @@ -1021,7 +1024,7 @@ contract CEAFactoryTest is Test { function testDeployCEAWithUpdatedVault() public { address newVault = makeAddr("newVault"); vm.prank(owner); - factory.setVault(newVault); + factory.updateVault(newVault); // Deploy with new vault vm.prank(newVault); @@ -1034,7 +1037,7 @@ contract CEAFactoryTest is Test { function testDeployCEAWithUpdatedGateway() public { MockUniversalGateway newGateway = new MockUniversalGateway(); vm.prank(owner); - factory.setUniversalGateway(address(newGateway)); + factory.updateUniversalGateway(address(newGateway)); address cea = deployCEAHelper(ueaOnPush); CEA ceaInstance = CEA(payable(cea)); @@ -1218,7 +1221,7 @@ contract CEAFactoryTest is Test { assertTrue(hasCode(cea), "Should deploy with new proxy implementation"); } - function testUpdateCEAImplementationBeforeDeployment() public { + function testsetCEAImplementationBeforeDeployment() public { CEA newCEAImpl = new CEA(); vm.prank(owner); factory.setCEAImplementation(address(newCEAImpl)); @@ -1230,7 +1233,7 @@ contract CEAFactoryTest is Test { function testUpdateGatewayBeforeDeployment() public { MockUniversalGateway newGateway = new MockUniversalGateway(); vm.prank(owner); - factory.setUniversalGateway(address(newGateway)); + factory.updateUniversalGateway(address(newGateway)); address cea = deployCEAHelper(ueaOnPush); CEA ceaInstance = CEA(payable(cea)); @@ -1240,7 +1243,7 @@ contract CEAFactoryTest is Test { function testUpdateVaultBeforeDeployment() public { address newVault = makeAddr("newVault"); vm.prank(owner); - factory.setVault(newVault); + factory.updateVault(newVault); vm.prank(newVault); address cea = factory.deployCEA(ueaOnPush); @@ -1252,15 +1255,17 @@ contract CEAFactoryTest is Test { // Deploy first address cea = deployCEAHelper(ueaOnPush); CEA ceaInstance = CEA(payable(cea)); - address originalGateway = ceaInstance.UNIVERSAL_GATEWAY(); // Update gateway MockUniversalGateway newGateway = new MockUniversalGateway(); vm.prank(owner); - factory.setUniversalGateway(address(newGateway)); + factory.updateUniversalGateway(address(newGateway)); - // Existing CEA should still have old gateway - assertEq(ceaInstance.UNIVERSAL_GATEWAY(), originalGateway, "Existing CEA should keep old gateway"); + // UNIVERSAL_GATEWAY() delegates to the factory, so all existing CEAs immediately + // reflect the factory's current value — there is no per-CEA stored copy. + assertEq( + ceaInstance.UNIVERSAL_GATEWAY(), address(newGateway), "Existing CEA should see new gateway via factory" + ); } // ========================================================================= @@ -1269,50 +1274,48 @@ contract CEAFactoryTest is Test { function testPreventUnauthorizedVaultChange() public { address newVault = makeAddr("newVault"); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 operatorRole = factory.OPERATOR_ROLE(); // Vault cannot change itself vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, vault, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, vault, operatorRole) ); vm.prank(vault); - factory.setVault(newVault); + factory.updateVault(newVault); // Non-owner cannot change vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, operatorRole) ); vm.prank(nonOwner); - factory.setVault(newVault); + factory.updateVault(newVault); } function testPreventUnauthorizedImplementationChange() public { CEA newImpl = new CEA(); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 ceaAdminRole = factory.CEA_ADMIN_ROLE(); // Vault cannot change vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, vault, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, vault, ceaAdminRole) ); vm.prank(vault); factory.setCEAImplementation(address(newImpl)); // Non-owner cannot change vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ceaAdminRole) ); vm.prank(nonOwner); factory.setCEAImplementation(address(newImpl)); } function testPreventVaultFromChangingOwner() public { - // Vault cannot grant DEFAULT_ADMIN_ROLE (only admin can) + // With ADR, grantRole(DEFAULT_ADMIN_ROLE) always reverts address newOwner = makeAddr("newOwner"); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); - vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, vault, adminRole) - ); + + vm.expectRevert(IAccessControlDefaultAdminRules.AccessControlEnforcedDefaultAdminRules.selector); vm.prank(vault); factory.grantRole(adminRole, newOwner); } @@ -1403,24 +1406,36 @@ contract CEAFactoryTest is Test { assertTrue(factory.paused()); } - function testUnpause_OnlyPauser() public { - bytes32 role = factory.PAUSER_ROLE(); + function testUnpause_OnlyOperator() public { + bytes32 operatorRole = factory.OPERATOR_ROLE(); vm.prank(pauser); factory.pause(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, role) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, operatorRole) ); vm.prank(nonOwner); factory.unpause(); } + function testUnpause_PauserCannotUnpause() public { + bytes32 operatorRole = factory.OPERATOR_ROLE(); + vm.prank(pauser); + factory.pause(); + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, pauser, operatorRole) + ); + vm.prank(pauser); + factory.unpause(); + } + function testUnpause_HappyPath() public { vm.prank(pauser); factory.pause(); assertTrue(factory.paused()); - vm.prank(pauser); + vm.prank(owner); factory.unpause(); assertFalse(factory.paused()); } @@ -1437,7 +1452,7 @@ contract CEAFactoryTest is Test { function testDeployCEA_AfterUnpause_Works() public { vm.prank(pauser); factory.pause(); - vm.prank(pauser); + vm.prank(owner); factory.unpause(); address uea = makeAddr("unpausedUEA"); @@ -1446,31 +1461,27 @@ contract CEAFactoryTest is Test { assertTrue(factory.isCEA(cea)); } - function testSetPauserRole_OnlyOwner() public { + function testGrantPauserRole_OnlyRoleManager() public { address newPauser = makeAddr("newPauser"); + bytes32 roleManagerRole = factory.ROLE_MANAGER_ROLE(); + bytes32 pauserRole = factory.PAUSER_ROLE(); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, roleManagerRole) ); vm.prank(nonOwner); - factory.setPauserRole(newPauser); + factory.grantRole(pauserRole, newPauser); vm.prank(owner); - factory.setPauserRole(newPauser); - assertTrue(factory.hasRole(factory.PAUSER_ROLE(), newPauser)); - } - - function testSetPauserRole_ZeroAddressReverts() public { - vm.prank(owner); - vm.expectRevert(CEAErrors.ZeroAddress.selector); - factory.setPauserRole(address(0)); + factory.grantRole(pauserRole, newPauser); + assertTrue(factory.hasRole(pauserRole, newPauser)); } - function testSetPauserRole_NewPauserCanPause() public { + function testGrantPauserRole_NewPauserCanPause() public { address newPauser = makeAddr("newPauser2"); + bytes32 pauserRole = factory.PAUSER_ROLE(); vm.prank(owner); - factory.setPauserRole(newPauser); + factory.grantRole(pauserRole, newPauser); vm.prank(newPauser); factory.pause(); @@ -1491,6 +1502,95 @@ contract CEAFactoryTest is Test { ); } + // ========================================================================= + // ADR & Role Hierarchy Tests + // ========================================================================= + + function testInitialize_SetsRoleAdmins() public { + assertEq(factory.getRoleAdmin(factory.CEA_ADMIN_ROLE()), factory.ROLE_MANAGER_ROLE()); + assertEq(factory.getRoleAdmin(factory.OPERATOR_ROLE()), factory.ROLE_MANAGER_ROLE()); + assertEq(factory.getRoleAdmin(factory.PAUSER_ROLE()), factory.ROLE_MANAGER_ROLE()); + assertEq(factory.getRoleAdmin(factory.ROLE_MANAGER_ROLE()), factory.DEFAULT_ADMIN_ROLE()); + } + + function testInitialize_GrantsAllRolesToAdmin() public { + assertTrue(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), owner)); + assertTrue(factory.hasRole(factory.ROLE_MANAGER_ROLE(), owner)); + assertTrue(factory.hasRole(factory.CEA_ADMIN_ROLE(), owner)); + assertTrue(factory.hasRole(factory.OPERATOR_ROLE(), owner)); + } + + function testGrantRole_DEFAULT_ADMIN_ROLE_Reverts() public { + address newAdmin = makeAddr("newAdmin"); + bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + vm.expectRevert(IAccessControlDefaultAdminRules.AccessControlEnforcedDefaultAdminRules.selector); + vm.prank(owner); + factory.grantRole(adminRole, newAdmin); + } + + function testBeginDefaultAdminTransfer_HappyPath() public { + address newAdmin = makeAddr("newAdmin"); + + vm.prank(owner); + factory.beginDefaultAdminTransfer(newAdmin); + + (address pending,) = factory.pendingDefaultAdmin(); + assertEq(pending, newAdmin); + + vm.warp(block.timestamp + 1 days + 1); + + vm.prank(newAdmin); + factory.acceptDefaultAdminTransfer(); + + assertEq(factory.defaultAdmin(), newAdmin); + assertFalse(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), owner)); + assertTrue(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), newAdmin)); + } + + function testDefaultAdminDelay_Is2Days() public view { + assertEq(factory.defaultAdminDelay(), 1 days); + } + + function testOwner_ReturnsDefaultAdmin() public view { + assertEq(factory.owner(), owner); + } + + function testRoleManager_CanGrantOperator() public { + address newOperator = makeAddr("newOperator"); + bytes32 operatorRole = factory.OPERATOR_ROLE(); + vm.prank(owner); + factory.grantRole(operatorRole, newOperator); + assertTrue(factory.hasRole(operatorRole, newOperator)); + } + + function testRoleManager_CanGrantCEAAdmin() public { + address newCEAAdmin = makeAddr("newCEAAdmin"); + bytes32 ceaAdminRole = factory.CEA_ADMIN_ROLE(); + vm.prank(owner); + factory.grantRole(ceaAdminRole, newCEAAdmin); + assertTrue(factory.hasRole(ceaAdminRole, newCEAAdmin)); + } + + function testRoleManager_CannotBeGrantedByNonAdmin() public { + address attacker = makeAddr("attacker"); + bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 roleManagerRole = factory.ROLE_MANAGER_ROLE(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, attacker, adminRole) + ); + vm.prank(attacker); + factory.grantRole(roleManagerRole, attacker); + } + + function testPause_OperatorCannotPause() public { + bytes32 pauserRole = factory.PAUSER_ROLE(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, owner, pauserRole) + ); + vm.prank(owner); + factory.pause(); + } + // ========================================================================= // Helper Functions // ========================================================================= diff --git a/test/tests_cea/CEA_multicalls.t.sol b/test/tests_cea/CEA_multicalls.t.sol index 5a72df4..9d3ac1d 100644 --- a/test/tests_cea/CEA_multicalls.t.sol +++ b/test/tests_cea/CEA_multicalls.t.sol @@ -15,7 +15,7 @@ contract CEA_NewMulticallTests is CEATest { // ========================================================================= function test_RevertWhen_NonEmptyPayload_ZeroRecipient() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Non-empty payload without MULTICALL_SELECTOR hits single-call path @@ -23,11 +23,11 @@ contract CEA_NewMulticallTests is CEATest { vm.prank(vault); vm.expectRevert(Errors.InvalidRecipient.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), invalidPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), invalidPayload); } function test_RevertWhen_PayloadIsEmptyCallsArray() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Empty calls array @@ -36,9 +36,9 @@ contract CEA_NewMulticallTests is CEATest { vm.prank(vault); // Should succeed (empty multicall is valid, just does nothing) - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked executed"); } // ========================================================================= @@ -46,21 +46,21 @@ contract CEA_NewMulticallTests is CEATest { // ========================================================================= function test_SingleExternalCall_NoValue_Success() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory targetCalldata = abi.encodeWithSignature("setMagicNumber(uint256)", 42); bytes memory payload = buildExternalSingleCall(address(target), 0, targetCalldata); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); assertEq(target.magicNumber(), 42, "Target should have magic number set"); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be executed"); } function test_SingleExternalCall_WithValue_Success() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 value = 0.1 ether; // Target requires exactly 0.1 ETH fee @@ -70,14 +70,14 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = buildExternalSingleCall(address(target), value, targetCalldata); vm.prank(vault); - ceaInstance.executeUniversalTx{value: value}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: value}(subTxId, universalTxID, ueaOnPush, address(0), payload); assertEq(target.magicNumber(), 42, "Target should have magic number set"); assertEq(address(target).balance, value, "Target should have received ETH"); } function test_MultiStepBatch_AllSucceed_InOrder() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](3); @@ -88,7 +88,7 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = buildExternalBatch(calls); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Final value should be 30 (last call) assertEq(target.magicNumber(), 30, "Target should have final magic number"); @@ -101,7 +101,7 @@ contract CEA_NewMulticallTests is CEATest { // Fund CEA with tokens fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](2); @@ -116,7 +116,7 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = buildExternalBatch(calls); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); assertEq(spender.totalReceived(address(token)), 100 ether, "Spender should have received tokens"); } @@ -128,21 +128,21 @@ contract CEA_NewMulticallTests is CEATest { function test_RevertWhen_AnySubcallReverts_BubblesReason() public deployCEA { RevertingTarget reverter = new RevertingTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildExternalSingleCall(address(reverter), 0, abi.encodeWithSignature("revertWithReason()")); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled error no longer shown - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_RevertWhen_LaterSubcallReverts_RollsBackEarlierEffects() public deployCEA { RevertingTarget reverter = new RevertingTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](2); @@ -154,8 +154,8 @@ contract CEA_NewMulticallTests is CEATest { uint256 magicBefore = target.magicNumber(); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled error no longer shown - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // First call's effect should be rolled back assertEq(target.magicNumber(), magicBefore, "Target magic number should be unchanged after revert"); @@ -164,24 +164,24 @@ contract CEA_NewMulticallTests is CEATest { function test_TxIDNotMarked_WhenExecutionReverts() public deployCEA { RevertingTarget reverter = new RevertingTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildExternalSingleCall(address(reverter), 0, abi.encodeWithSignature("revertWithReason()")); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled error no longer shown - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); - // txID should NOT be marked as executed since the tx reverted - assertFalse(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should not be marked executed"); + // subTxId should NOT be marked as executed since the tx reverted + assertFalse(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should not be marked executed"); } function test_NoEventsEmitted_WhenExecutionReverts() public deployCEA { RevertingTarget reverter = new RevertingTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = @@ -190,8 +190,8 @@ contract CEA_NewMulticallTests is CEATest { vm.prank(vault); vm.recordLogs(); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled error no longer shown - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); Vm.Log[] memory logs = vm.getRecordedLogs(); assertEq(logs.length, 0, "No events should be emitted on revert"); @@ -202,7 +202,7 @@ contract CEA_NewMulticallTests is CEATest { // ========================================================================= function test_RevertWhen_SelfCallDataLengthLessThan4() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](1); @@ -213,11 +213,11 @@ contract CEA_NewMulticallTests is CEATest { vm.prank(vault); // Now that _handleSelfCall is removed, malformed calls execute via .call() and fail vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_RevertWhen_SelfCallSelectorNotSendToUEA() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Try to call initializeCEA (wrong selector) @@ -231,17 +231,16 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - // Calls initializeCEA via .call() which reverts with AlreadyInitialized - // but we now get ExecutionFailed instead of bubbled error + // Mismatched selector (3 params vs 2) — no function match, empty return data vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_SelfCallSendToUEA_ERC20_Success() public deployCEA { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 sendAmount = 500 ether; @@ -260,7 +259,7 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); assertEq(mockUniversalGateway.callCount(), 1, "Gateway should be called once"); } @@ -269,7 +268,7 @@ contract CEA_NewMulticallTests is CEATest { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 100 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 sendAmount = 500 ether; // More than balance @@ -279,14 +278,14 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled from sendUniversalTxToUEA's InsufficientBalance - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert(Errors.InsufficientBalance.selector); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_SelfCallSendToUEA_Native_Success() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 sendAmount = 500 ether; @@ -296,7 +295,7 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), payload); assertEq(mockUniversalGateway.callCount(), 1, "Gateway should be called once"); } @@ -304,7 +303,7 @@ contract CEA_NewMulticallTests is CEATest { function test_RevertWhen_SelfCallSendToUEA_InsufficientNativeBalance() public deployCEA { fundCEAWithNative(100 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 sendAmount = 500 ether; // More than balance @@ -314,8 +313,8 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled from sendUniversalTxToUEA's InsufficientBalance - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert(Errors.InsufficientBalance.selector); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), payload); } // ========================================================================= @@ -326,7 +325,7 @@ contract CEA_NewMulticallTests is CEATest { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](4); @@ -344,7 +343,7 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); assertEq(target.magicNumber(), 99, "Final magic number should be 99"); assertEq(mockUniversalGateway.callCount(), 1, "Gateway should be called once"); @@ -355,7 +354,7 @@ contract CEA_NewMulticallTests is CEATest { // ========================================================================= function test_SameTxID_CannotExecuteTwice_EvenWithDifferentPayload() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload1 = @@ -366,12 +365,12 @@ contract CEA_NewMulticallTests is CEATest { // First execution succeeds vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload1); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload1); - // Second execution with same txID but different payload should fail + // Second execution with same subTxId but different payload should fail vm.prank(vault); vm.expectRevert(Errors.PayloadExecuted.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload2); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload2); } function test_DifferentTxID_CanExecuteSamePayload() public deployCEA { @@ -386,7 +385,7 @@ contract CEA_NewMulticallTests is CEATest { vm.prank(vault); ceaInstance.executeUniversalTx(txID1, universalTxID, ueaOnPush, address(0), payload); - // Second execution with different txID but same payload should succeed + // Second execution with different subTxId but same payload should succeed vm.prank(vault); ceaInstance.executeUniversalTx(txID2, universalTxID, ueaOnPush, address(0), payload); @@ -399,7 +398,7 @@ contract CEA_NewMulticallTests is CEATest { // ========================================================================= function test_Events_OnePerMulticallStep() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](3); @@ -411,7 +410,7 @@ contract CEA_NewMulticallTests is CEATest { vm.prank(vault); vm.recordLogs(); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); Vm.Log[] memory logs = vm.getRecordedLogs(); @@ -433,7 +432,7 @@ contract CEA_NewMulticallTests is CEATest { function test_RevertWhen_ReentrantCall() public deployCEA { MaliciousTarget malicious = new MaliciousTarget(address(ceaInstance)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = @@ -443,7 +442,7 @@ contract CEA_NewMulticallTests is CEATest { // The malicious contract will try to reenter but should be blocked // Note: Since the malicious contract doesn't actually attempt reentry in execute(), // this test just verifies the call succeeds and reentrancy guard is in place - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); assertTrue(malicious.attackAttempted(), "Attack should have been attempted"); } @@ -453,7 +452,7 @@ contract CEA_NewMulticallTests is CEATest { // ========================================================================= function test_MsgValueLessThanSum_CEAHasPreExistingBalance_Succeeds() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 feePerCall = 0.1 ether; @@ -471,13 +470,13 @@ contract CEA_NewMulticallTests is CEATest { // msg.value (0.1) < sum of call values (0.2), but CEA has pre-existing 0.1 balance vm.prank(vault); - ceaInstance.executeUniversalTx{value: 0.1 ether}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0.1 ether}(subTxId, universalTxID, ueaOnPush, address(0), payload); assertEq(target.magicNumber(), 20, "Final magic number should be 20"); } function test_MsgValueExceedsSum_ExcessStaysInCEA() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 feePerCall = 0.1 ether; @@ -494,14 +493,16 @@ contract CEA_NewMulticallTests is CEATest { // Excess ETH stays in CEA (belongs to user's UEA) vm.prank(vault); - ceaInstance.executeUniversalTx{value: totalValue + excess}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: totalValue + excess}( + subTxId, universalTxID, ueaOnPush, address(0), payload + ); assertEq(target.magicNumber(), 20, "Final magic number should be 20"); assertEq(address(ceaInstance).balance, excess, "Excess ETH should remain in CEA"); } function test_SuccessWhen_MsgValue_MatchesSumExactly() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Both calls require exactly 0.1 ETH each @@ -518,7 +519,7 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx{value: totalValue}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: totalValue}(subTxId, universalTxID, ueaOnPush, address(0), payload); assertEq(target.magicNumber(), 20, "Final magic number should be 20"); assertEq(address(target).balance, totalValue, "Target should have received all ETH"); @@ -529,7 +530,7 @@ contract CEA_NewMulticallTests is CEATest { // ========================================================================= function test_RevertWhen_SelfCall_WithNonZeroValue() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Try to send value to self-call (not allowed) @@ -547,7 +548,7 @@ contract CEA_NewMulticallTests is CEATest { vm.deal(vault, 0.1 ether); vm.prank(vault); vm.expectRevert(Errors.InvalidInput.selector); - ceaInstance.executeUniversalTx{value: 0.1 ether}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0.1 ether}(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_RevertWhen_DirectCallToSendUniversalTxToUEA() public deployCEA { @@ -567,7 +568,7 @@ contract CEA_NewMulticallTests is CEATest { // Fund CEA with 0.5 ETH initially fundCEAWithNative(0.5 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Batch: external call first (uses 0.1 ETH), then send more than remaining balance @@ -583,8 +584,8 @@ contract CEA_NewMulticallTests is CEATest { vm.deal(vault, 0.1 ether); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled from sendUniversalTxToUEA's InsufficientBalance - ceaInstance.executeUniversalTx{value: 0.1 ether}(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert(Errors.InsufficientBalance.selector); + ceaInstance.executeUniversalTx{value: 0.1 ether}(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify rollback - target should not have received ETH assertEq(address(target).balance, 0, "Target balance should be 0 due to rollback"); @@ -597,7 +598,7 @@ contract CEA_NewMulticallTests is CEATest { // Configure gateway to revert mockUniversalGateway.setWillRevert(true, "Gateway intentionally reverted"); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](2); @@ -611,11 +612,11 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Gateway revert bubbled as ExecutionFailed - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert("Gateway intentionally reverted"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); - // Verify txID not marked executed - assertFalse(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should not be marked"); + // Verify subTxId not marked executed + assertFalse(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should not be marked"); } // ========================================================================= @@ -629,7 +630,7 @@ contract CEA_NewMulticallTests is CEATest { fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](2); @@ -642,8 +643,8 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled error no longer shown - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify allowance rolled back to 0 assertEq(token.allowance(address(ceaInstance), address(spender)), 0, "Allowance should be 0"); @@ -655,7 +656,7 @@ contract CEA_NewMulticallTests is CEATest { fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](3); @@ -671,8 +672,8 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled error no longer shown - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify gateway was NOT called (entire tx reverted before gateway interaction persisted) assertEq(mockUniversalGateway.callCount(), 0, "Gateway should not be called due to rollback"); @@ -685,7 +686,7 @@ contract CEA_NewMulticallTests is CEATest { function test_SuccessWhen_RetrySameTxID_AfterFirstRevert() public deployCEA { RevertingTarget reverter = new RevertingTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory failingPayload = @@ -693,26 +694,26 @@ contract CEA_NewMulticallTests is CEATest { // First attempt - should revert vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled error no longer shown - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), failingPayload); + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), failingPayload); // Verify not marked executed - assertFalse(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should not be marked"); + assertFalse(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should not be marked"); - // Retry with same txID but different (succeeding) payload + // Retry with same subTxId but different (succeeding) payload bytes memory succeedingPayload = buildExternalSingleCall(address(target), 0, abi.encodeWithSignature("setMagicNumber(uint256)", 42)); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), succeedingPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), succeedingPayload); // Verify now marked executed - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked executed"); assertEq(target.magicNumber(), 42, "Target should have been updated"); } function test_RevertWhen_ReplaySameTxID_WithDifferentMsgValue() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 value1 = 0.1 ether; @@ -726,12 +727,12 @@ contract CEA_NewMulticallTests is CEATest { // First execution with value1 vm.prank(vault); - ceaInstance.executeUniversalTx{value: value1}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: value1}(subTxId, universalTxID, ueaOnPush, address(0), payload); // Try to replay with different msg.value - should still be blocked vm.prank(vault); vm.expectRevert(Errors.PayloadExecuted.selector); - ceaInstance.executeUniversalTx{value: value2}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: value2}(subTxId, universalTxID, ueaOnPush, address(0), payload); } // ========================================================================= @@ -739,7 +740,7 @@ contract CEA_NewMulticallTests is CEATest { // ========================================================================= function test_Events_DataMatchesEachCall() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory data1 = abi.encodeWithSignature("setMagicNumber(uint256)", 10); @@ -753,12 +754,12 @@ contract CEA_NewMulticallTests is CEATest { vm.prank(vault); vm.recordLogs(); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); Vm.Log[] memory logs = vm.getRecordedLogs(); // Find UniversalTxExecuted events and validate data - // Event signature: UniversalTxExecuted(bytes32 indexed txID, bytes32 indexed universalTxID, address indexed originCaller, address target, bytes data) + // Event signature: UniversalTxExecuted(bytes32 indexed subTxId, bytes32 indexed universalTxID, address indexed originCaller, address target, bytes data) uint256 eventIndex = 0; for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics[0] == keccak256("UniversalTxExecuted(bytes32,bytes32,address,address,bytes)")) { @@ -771,7 +772,7 @@ contract CEA_NewMulticallTests is CEATest { (address emittedTo, bytes memory emittedData) = abi.decode(logs[i].data, (address, bytes)); // Validate against expected call - assertEq(emittedTxID, txID, "Event txID should match"); + assertEq(emittedTxID, subTxId, "Event subTxId should match"); assertEq(emittedUniversalTxID, universalTxID, "Event universalTxID should match"); assertEq(emittedOrigin, ueaOnPush, "Event origin should match"); assertEq(emittedTo, calls[eventIndex].to, "Event target should match call"); @@ -788,7 +789,7 @@ contract CEA_NewMulticallTests is CEATest { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 sendAmount = 100 ether; @@ -804,12 +805,12 @@ contract CEA_NewMulticallTests is CEATest { vm.prank(vault); vm.recordLogs(); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); Vm.Log[] memory logs = vm.getRecordedLogs(); // Find the self-call event - // Event signature: UniversalTxExecuted(bytes32 indexed txID, bytes32 indexed universalTxID, address indexed originCaller, address target, bytes data) + // Event signature: UniversalTxExecuted(bytes32 indexed subTxId, bytes32 indexed universalTxID, address indexed originCaller, address target, bytes data) bool foundSelfCallEvent = false; for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics[0] == keccak256("UniversalTxExecuted(bytes32,bytes32,address,address,bytes)")) { @@ -838,7 +839,7 @@ contract CEA_NewMulticallTests is CEATest { function test_RevertWhen_NativeTransfer_LaterRevert_RollbackReceiverBalance() public deployCEA { RevertingTarget reverter = new RevertingTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 transferAmount = 0.1 ether; // Exact fee required by target @@ -856,8 +857,8 @@ contract CEA_NewMulticallTests is CEATest { uint256 targetBalanceBefore = address(target).balance; vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled error no longer shown - ceaInstance.executeUniversalTx{value: transferAmount}(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx{value: transferAmount}(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify target balance unchanged (rollback) assertEq(address(target).balance, targetBalanceBefore, "Target balance should not change due to rollback"); @@ -881,15 +882,15 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); // This now includes MULTICALL_SELECTOR - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify execution succeeded assertEq(testTarget.magicNumber(), 42); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID)); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId)); } function test_MulticallSelector_MsgValueExceeds_NoRevert() public deployCEA { @@ -901,12 +902,12 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.deal(vault, 2 ether); vm.prank(vault); - ceaInstance.executeUniversalTx{value: 2 ether}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 2 ether}(subTxId, universalTxID, ueaOnPush, address(0), payload); assertEq(testTarget.magicNumber(), 42, "Target should have magic number set"); assertEq(address(ceaInstance).balance, 1.9 ether, "Excess should stay in CEA"); @@ -921,14 +922,14 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); // Includes MULTICALL_SELECTOR - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.deal(vault, 0.1 ether); vm.prank(vault); - ceaInstance.executeUniversalTx{value: 0.1 ether}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0.1 ether}(subTxId, universalTxID, ueaOnPush, address(0), payload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID)); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId)); assertEq(testTarget.magicNumber(), 999); } @@ -941,37 +942,37 @@ contract CEA_NewMulticallTests is CEATest { // Payload with MULTICALL_SELECTOR but malformed data after it bytes memory invalidPayload = abi.encodePacked(MULTICALL_SELECTOR, bytes("malformed data")); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.prank(vault); vm.expectRevert(); // Should revert during abi.decode - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), invalidPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), invalidPayload); } function test_PayloadLength_LessThan4Bytes_RevertsInvalidRecipient() public deployCEA { // Short non-empty payload hits single-call path; recipient=address(0) reverts bytes memory shortPayload = bytes("abc"); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.prank(vault); vm.expectRevert(Errors.InvalidRecipient.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), shortPayload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), shortPayload); } function test_MulticallSelector_EmptyCallsArray_Succeeds() public deployCEA { Multicall[] memory calls = new Multicall[](0); bytes memory payload = encodeCalls(calls); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID)); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId)); } function test_MulticallSelector_MixedValueCalls() public deployCEA { @@ -985,14 +986,14 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.deal(vault, 0.2 ether); vm.prank(vault); - ceaInstance.executeUniversalTx{value: 0.2 ether}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0.2 ether}(subTxId, universalTxID, ueaOnPush, address(0), payload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID)); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId)); assertEq(target1.magicNumber(), 300); // Last call to target1 assertEq(target2.magicNumber(), 200); } @@ -1016,13 +1017,13 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.deal(vault, 0.1 ether); vm.prank(vault); vm.expectRevert(Errors.InvalidInput.selector); - ceaInstance.executeUniversalTx{value: 0.1 ether}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0.1 ether}(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_SelfCall_WithMulticallSelector_ZeroValue_Succeeds() public deployCEA { @@ -1043,13 +1044,13 @@ contract CEA_NewMulticallTests is CEATest { bytes memory payload = encodeCalls(calls); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID)); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId)); } // ========================================================================= diff --git a/test/tests_cea/CEA_selfCalls.t.sol b/test/tests_cea/CEA_selfCalls.t.sol index 8066a68..b44dd44 100644 --- a/test/tests_cea/CEA_selfCalls.t.sol +++ b/test/tests_cea/CEA_selfCalls.t.sol @@ -21,7 +21,7 @@ contract CEA_ComprehensiveTests is CEATest { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; @@ -33,7 +33,7 @@ contract CEA_ComprehensiveTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify UniversalTxRequest.recipient == UEA UniversalTxRequest memory lastReq = mockUniversalGateway.getLastRequest(); @@ -43,7 +43,7 @@ contract CEA_ComprehensiveTests is CEATest { function test_UniversalTxRequest_RecipientIsUEA_Native() public deployCEA { fundCEAWithNative(1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; @@ -53,7 +53,7 @@ contract CEA_ComprehensiveTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify UniversalTxRequest.recipient == UEA UniversalTxRequest memory lastReq = mockUniversalGateway.getLastRequest(); @@ -64,7 +64,7 @@ contract CEA_ComprehensiveTests is CEATest { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; @@ -76,7 +76,7 @@ contract CEA_ComprehensiveTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify revertRecipient == UEA UniversalTxRequest memory lastReq = mockUniversalGateway.getLastRequest(); @@ -87,7 +87,7 @@ contract CEA_ComprehensiveTests is CEATest { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 500 ether; @@ -99,7 +99,7 @@ contract CEA_ComprehensiveTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify revertRecipient == UEA (no revertMsg field anymore) UniversalTxRequest memory lastReq = mockUniversalGateway.getLastRequest(); @@ -116,7 +116,7 @@ contract CEA_ComprehensiveTests is CEATest { uint256 totalBalance = 1000 ether; fundCEAWithTokens(address(token), totalBalance); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](2); @@ -131,7 +131,7 @@ contract CEA_ComprehensiveTests is CEATest { assertEq(balanceBefore, totalBalance, "CEA should have full balance before"); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify CEA balance is exactly 0 after sending 100% uint256 balanceAfter = token.balanceOf(address(ceaInstance)); @@ -146,7 +146,7 @@ contract CEA_ComprehensiveTests is CEATest { uint256 totalBalance = 10 ether; fundCEAWithNative(totalBalance); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](1); @@ -158,7 +158,7 @@ contract CEA_ComprehensiveTests is CEATest { assertEq(balanceBefore, totalBalance, "CEA should have full balance before"); vm.prank(vault); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify CEA balance is exactly 0 after sending 100% uint256 balanceAfter = address(ceaInstance).balance; @@ -177,7 +177,7 @@ contract CEA_ComprehensiveTests is CEATest { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 minAmount = 1 wei; @@ -190,7 +190,7 @@ contract CEA_ComprehensiveTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); UniversalTxRequest memory lastReq = mockUniversalGateway.getLastRequest(); assertEq(lastReq.amount, minAmount, "Should handle 1 wei minimum amount"); @@ -199,7 +199,7 @@ contract CEA_ComprehensiveTests is CEATest { function test_SendUniversalTxToUEA_MinimumAmount_1Wei_Native() public deployCEA { fundCEAWithNative(10 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 minAmount = 1 wei; @@ -209,7 +209,7 @@ contract CEA_ComprehensiveTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), payload); UniversalTxRequest memory lastReq = mockUniversalGateway.getLastRequest(); assertEq(lastReq.amount, minAmount, "Should handle 1 wei minimum amount"); @@ -223,7 +223,7 @@ contract CEA_ComprehensiveTests is CEATest { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 2000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](4); @@ -242,7 +242,7 @@ contract CEA_ComprehensiveTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify both calls went through assertEq(mockUniversalGateway.callCount(), 2, "Gateway should be called twice"); @@ -258,7 +258,7 @@ contract CEA_ComprehensiveTests is CEATest { Target payableTarget = new Target(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](2); @@ -276,7 +276,7 @@ contract CEA_ComprehensiveTests is CEATest { // After second call (send 0.4 to UEA) → CEA has 0 ETH vm.deal(vault, 0.1 ether); vm.prank(vault); - ceaInstance.executeUniversalTx{value: 0.1 ether}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 0.1 ether}(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify gateway was called with 0.4 ETH UniversalTxRequest memory lastReq = mockUniversalGateway.getLastRequest(); @@ -291,7 +291,7 @@ contract CEA_ComprehensiveTests is CEATest { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](1); @@ -300,8 +300,8 @@ contract CEA_ComprehensiveTests is CEATest { bytes memory payload = encodeCalls(calls); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); // Bubbled InsufficientBalance - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + vm.expectRevert(Errors.InsufficientBalance.selector); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } // ========================================================================= @@ -347,7 +347,7 @@ contract CEA_ComprehensiveTests is CEATest { // 2) Input Validation (Amount / Token / Payload) // ========================================================================= - function test_FundsAndPayload_RevertWhen_ZeroAmount_ERC20() public deployCEA { + function test_FundsAndPayload_ZeroAmount_ERC20_Succeeds() public deployCEA { MockGasToken token = new MockGasToken(); fundCEAWithTokens(address(token), 1000 ether); @@ -358,13 +358,15 @@ contract CEA_ComprehensiveTests is CEATest { makeCall(address(ceaInstance), 0, buildSendToUEAPayloadWithData(address(token), 0, ueaPayload, ueaOnPush)); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); ceaInstance.executeUniversalTx( generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); + + assertEq(mockUniversalGateway.lastAmount(), 0, "Should allow zero amount for ERC20"); + assertEq(mockUniversalGateway.lastToken(), address(token), "Token should match"); } - function test_FundsAndPayload_RevertWhen_ZeroAmount_Native() public deployCEA { + function test_FundsAndPayload_ZeroAmount_Native_Succeeds() public deployCEA { fundCEAWithNative(1 ether); bytes memory ueaPayload = abi.encodeWithSignature("someFunction()"); @@ -374,10 +376,32 @@ contract CEA_ComprehensiveTests is CEATest { makeCall(address(ceaInstance), 0, buildSendToUEAPayloadWithData(address(0), 0, ueaPayload, ueaOnPush)); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); ceaInstance.executeUniversalTx{value: 0}( generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); + + assertEq(mockUniversalGateway.lastAmount(), 0, "Should allow zero amount for native"); + assertEq(mockUniversalGateway.lastToken(), address(0), "Token should be zero address for native"); + } + + function test_FundsAndPayload_ZeroAmount_NonContractToken_Succeeds() public deployCEA { + // Zero amount with a non-contract token address should succeed + // because the zero-amount path skips balanceOf entirely + address fakeToken = makeAddr("nonContractToken"); + + bytes memory ueaPayload = abi.encodeWithSignature("someFunction()"); + + Multicall[] memory calls = new Multicall[](1); + calls[0] = makeCall(address(ceaInstance), 0, buildSendToUEAPayloadWithData(fakeToken, 0, ueaPayload, ueaOnPush)); + + vm.prank(vault); + ceaInstance.executeUniversalTx( + generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) + ); + + assertEq(mockUniversalGateway.lastAmount(), 0, "Should allow zero amount"); + assertEq(mockUniversalGateway.lastToken(), fakeToken, "Token should match non-contract address"); + assertEq(mockUniversalGateway.lastValue(), 0, "No native value should be sent"); } function test_FundsAndPayload_RevertWhen_InsufficientNativeBalance() public deployCEA { @@ -391,7 +415,7 @@ contract CEA_ComprehensiveTests is CEATest { ); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); + vm.expectRevert(Errors.InsufficientBalance.selector); ceaInstance.executeUniversalTx{value: 0}( generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); @@ -414,7 +438,7 @@ contract CEA_ComprehensiveTests is CEATest { ); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); + vm.expectRevert(Errors.InsufficientBalance.selector); ceaInstance.executeUniversalTx( generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); @@ -763,16 +787,17 @@ contract CEA_ComprehensiveTests is CEATest { address(ceaInstance), 0, buildSendToUEAPayloadWithData(address(0), 5 ether, ueaPayload, ueaOnPush) ); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); + vm.expectRevert("GatewayError"); ceaInstance.executeUniversalTx{value: 0}( - txID, generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) + subTxId, generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); assertFalse( - CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should NOT be marked executed on gateway revert" + CEA(payable(address(ceaInstance))).isExecuted(subTxId), + "subTxId should NOT be marked executed on gateway revert" ); } @@ -796,7 +821,7 @@ contract CEA_ComprehensiveTests is CEATest { ); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); + vm.expectRevert("GatewayError"); ceaInstance.executeUniversalTx{value: 0}( generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); @@ -816,11 +841,11 @@ contract CEA_ComprehensiveTests is CEATest { Multicall[] memory calls = new Multicall[](1); calls[0] = buildSelfSendToUEACall(address(0), amount); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.prank(vault); - ceaInstance.executeUniversalTx{value: 0}(txID, universalTxID, ueaOnPush, address(0), encodeCalls(calls)); + ceaInstance.executeUniversalTx{value: 0}(subTxId, universalTxID, ueaOnPush, address(0), encodeCalls(calls)); // Assertions assertTrue(mockUniversalGateway.lastCallWasViaCEA(), "FUNDS-only should use sendUniversalTxFromCEA"); @@ -832,7 +857,7 @@ contract CEA_ComprehensiveTests is CEATest { assertEq(req.amount, amount, "amount correct"); assertEq(req.payload.length, 0, "payload empty"); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID marked executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId marked executed"); assertEq(address(ceaInstance).balance, 5 ether, "CEA balance decreased"); } @@ -849,10 +874,10 @@ contract CEA_ComprehensiveTests is CEATest { ); calls[1] = buildSelfSendToUEACall(address(token), amount); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls)); + ceaInstance.executeUniversalTx(subTxId, generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls)); assertTrue(mockUniversalGateway.lastCallWasViaCEA(), "FUNDS-only should use sendUniversalTxFromCEA"); assertEq(mockUniversalGateway.lastValue(), 0, "msg.value should be 0 for ERC20"); @@ -872,11 +897,11 @@ contract CEA_ComprehensiveTests is CEATest { calls[0] = makeCall(address(ceaInstance), 0, buildSendToUEAPayloadWithData(address(0), amount, ueaPayload, ueaOnPush)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); vm.prank(vault); ceaInstance.executeUniversalTx{value: 0}( - txID, generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) + subTxId, generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); assertTrue(mockUniversalGateway.lastCallWasViaCEA(), "FUNDS_AND_PAYLOAD should use sendUniversalTxFromCEA"); @@ -887,7 +912,7 @@ contract CEA_ComprehensiveTests is CEATest { assertEq(req.amount, amount, "amount correct"); assertEq(req.payload, ueaPayload, "payload matches"); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID marked executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId marked executed"); assertEq(address(ceaInstance).balance, 5 ether, "CEA balance decreased"); } @@ -907,10 +932,10 @@ contract CEA_ComprehensiveTests is CEATest { address(ceaInstance), 0, buildSendToUEAPayloadWithData(address(token), amount, ueaPayload, ueaOnPush) ); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls)); + ceaInstance.executeUniversalTx(subTxId, generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls)); assertTrue(mockUniversalGateway.lastCallWasViaCEA(), "FUNDS_AND_PAYLOAD should use sendUniversalTxFromCEA"); assertEq(mockUniversalGateway.lastValue(), 0, "msg.value should be 0 for ERC20"); @@ -933,7 +958,7 @@ contract CEA_ComprehensiveTests is CEATest { calls[0] = makeCall(address(ceaInstance), 0, buildSendToUEAPayload(address(0), 5 ether, address(0))); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); + vm.expectRevert(Errors.InvalidInput.selector); ceaInstance.executeUniversalTx{value: 0}( generateTxID(1), generateUniversalTxID(1), ueaOnPush, address(0), encodeCalls(calls) ); diff --git a/test/tests_cea/CEA_singleCall.t.sol b/test/tests_cea/CEA_singleCall.t.sol index ed7fbb2..d4e6b4b 100644 --- a/test/tests_cea/CEA_singleCall.t.sol +++ b/test/tests_cea/CEA_singleCall.t.sol @@ -14,17 +14,17 @@ contract CEA_SingleCallTests is CEATest { // ========================================================================= function test_ParkFunds_EmptyPayload_NativeViaValue() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 1 ether; vm.deal(vault, amount); vm.prank(vault); - ceaInstance.executeUniversalTx{value: amount}(txID, universalTxID, ueaOnPush, address(0), ""); + ceaInstance.executeUniversalTx{value: amount}(subTxId, universalTxID, ueaOnPush, address(0), ""); assertEq(address(ceaInstance).balance, amount, "CEA should hold parked native funds"); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked executed"); } function test_ParkFunds_EmptyPayload_ERC20PreFunded() public deployCEA { @@ -32,28 +32,28 @@ contract CEA_SingleCallTests is CEATest { uint256 amount = 500 ether; fundCEAWithTokens(address(token), amount); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), ""); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), ""); assertEq(token.balanceOf(address(ceaInstance)), amount, "CEA should hold ERC20 tokens"); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked executed"); } function test_ParkFunds_EmptyPayload_ZeroValue() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), ""); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), ""); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked executed"); } - function test_ParkFunds_EmptyPayload_NonZeroRecipient_Ignored() public deployCEA { - bytes32 txID = generateTxID(1); + function test_EmptyPayload_NonZeroRecipient_ForwardsNative() public deployCEA { + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 1 ether; @@ -62,24 +62,25 @@ contract CEA_SingleCallTests is CEATest { address someRecipient = makeAddr("someRecipient"); vm.prank(vault); - ceaInstance.executeUniversalTx{value: amount}(txID, universalTxID, ueaOnPush, someRecipient, ""); + ceaInstance.executeUniversalTx{value: amount}(subTxId, universalTxID, ueaOnPush, someRecipient, ""); - // Funds should park in CEA regardless of recipient value - assertEq(address(ceaInstance).balance, amount, "CEA should hold native funds"); - assertEq(address(someRecipient).balance, 0, "Recipient should not receive anything"); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked executed"); + // Empty payload + non-zero recipient is a plain native send to recipient, not fund-parking. + // Fund-parking requires BOTH empty payload AND address(0) recipient. + assertEq(address(someRecipient).balance, amount, "Recipient should receive the native funds"); + assertEq(address(ceaInstance).balance, 0, "CEA should not hold funds when recipient is non-zero"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked executed"); } function test_ParkFunds_EmitsEvent_TargetIsSelf() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); vm.prank(vault); vm.expectEmit(true, true, true, true); - emit ICEA.UniversalTxExecuted(txID, universalTxID, ueaOnPush, address(ceaInstance), ""); + emit ICEA.UniversalTxExecuted(subTxId, universalTxID, ueaOnPush, address(ceaInstance), ""); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), ""); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), ""); } // ========================================================================= @@ -87,20 +88,20 @@ contract CEA_SingleCallTests is CEATest { // ========================================================================= function test_SingleCall_ExecuteTargetFunction() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(target), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(target), payload); assertEq(target.getMagicNumber(), 42, "Target should have magic number set"); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked executed"); } function test_SingleCall_ForwardsMsgValueToRecipient() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); uint256 amount = 0.1 ether; @@ -108,26 +109,26 @@ contract CEA_SingleCallTests is CEATest { vm.deal(vault, amount); vm.prank(vault); - ceaInstance.executeUniversalTx{value: amount}(txID, universalTxID, ueaOnPush, address(target), payload); + ceaInstance.executeUniversalTx{value: amount}(subTxId, universalTxID, ueaOnPush, address(target), payload); assertEq(target.getMagicNumber(), 42, "Target should have magic number set"); assertEq(address(target).balance, amount, "Target should receive native value"); } function test_SingleCall_ZeroValue_ValidRecipient() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 99); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(target), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(target), payload); assertEq(target.getMagicNumber(), 99, "Target should have magic number set"); } function test_SingleCall_EmitsEvent_CorrectTargetAndPayload() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 77); @@ -135,9 +136,9 @@ contract CEA_SingleCallTests is CEATest { vm.prank(vault); vm.expectEmit(true, true, true, true); - emit ICEA.UniversalTxExecuted(txID, universalTxID, ueaOnPush, address(target), payload); + emit ICEA.UniversalTxExecuted(subTxId, universalTxID, ueaOnPush, address(target), payload); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(target), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(target), payload); } // ========================================================================= @@ -145,78 +146,78 @@ contract CEA_SingleCallTests is CEATest { // ========================================================================= function test_SingleCall_RevertWhen_RecipientIsZero() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); vm.prank(vault); vm.expectRevert(Errors.InvalidRecipient.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_SingleCall_RevertWhen_RecipientIsSelf() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); vm.prank(vault); vm.expectRevert(Errors.InvalidRecipient.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(ceaInstance), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); } function test_SingleCall_RevertWhen_TargetReverts() public deployCEA { RevertingTarget reverter = new RevertingTarget(); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("revertWithReason()"); vm.prank(vault); - vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(reverter), payload); + vm.expectRevert("This function always reverts with reason"); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(reverter), payload); assertFalse( - CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should not be marked executed on failure" + CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should not be marked executed on failure" ); } function test_SingleCall_RevertWhen_NotVault() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); vm.prank(nonVault); vm.expectRevert(Errors.NotVault.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(target), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(target), payload); } function test_SingleCall_RevertWhen_WrongOriginCaller() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); vm.prank(vault); vm.expectRevert(Errors.InvalidUEA.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, makeAddr("wrongUEA"), address(target), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, makeAddr("wrongUEA"), address(target), payload); } function test_SingleCall_RevertWhen_DuplicateTxId() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodeWithSignature("setMagicNumber(uint256)", 42); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(target), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(target), payload); vm.prank(vault); vm.expectRevert(Errors.PayloadExecuted.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(target), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(target), payload); } // ========================================================================= @@ -224,7 +225,7 @@ contract CEA_SingleCallTests is CEATest { // ========================================================================= function test_MulticallPayload_IgnoresRecipient() public deployCEA { - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); Multicall[] memory calls = new Multicall[](1); @@ -234,31 +235,49 @@ contract CEA_SingleCallTests is CEATest { address randomRecipient = makeAddr("randomRecipient"); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, randomRecipient, payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, randomRecipient, payload); assertEq(target.getMagicNumber(), 55, "Multicall should execute normally regardless of recipient"); - assertTrue(CEA(payable(address(ceaInstance))).isExecuted(txID), "txID should be marked executed"); + assertTrue(CEA(payable(address(ceaInstance))).isExecuted(subTxId), "subTxId should be marked executed"); } - function test_MigrationPayload_IgnoresRecipient() public deployCEA { + function test_MigrationPayload_RevertsWhenRecipientNotSelf() public deployCEA { // Set up migration contract CEA ceaV2 = new CEA(); CEAMigration migration = new CEAMigration(address(ceaV2)); - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodePacked(MIGRATION_SELECTOR); address randomRecipient = makeAddr("randomRecipient"); + // Migration with non-self recipient should revert vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, randomRecipient, payload); + vm.expectRevert(Errors.InvalidRecipient.selector); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, randomRecipient, payload); + } + + function test_MigrationPayload_SucceedsWhenRecipientIsSelf() public deployCEA { + // Set up migration contract + CEA ceaV2 = new CEA(); + CEAMigration migration = new CEAMigration(address(ceaV2)); + factory.updateCEAMigrationContract(address(migration)); + + bytes32 subTxId = generateTxID(1); + bytes32 universalTxID = generateUniversalTxID(1); + + bytes memory payload = abi.encodePacked(MIGRATION_SELECTOR); + + // Migration with self recipient should succeed + vm.prank(vault); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); assertEq( CEAProxy(payable(address(ceaInstance))).getImplementation(), address(ceaV2), - "Migration should execute normally regardless of recipient" + "Migration should succeed with self recipient" ); } } diff --git a/test/tests_ceaMigration/CEAFactory_Migration.t.sol b/test/tests_ceaMigration/CEAFactory_Migration.t.sol index f301768..a5f6eb4 100644 --- a/test/tests_ceaMigration/CEAFactory_Migration.t.sol +++ b/test/tests_ceaMigration/CEAFactory_Migration.t.sol @@ -57,42 +57,42 @@ contract CEAFactory_MigrationTest is Test { } // ========================================================================= - // setCEAMigrationContract Tests + // updateCEAMigrationContract Tests // ========================================================================= - function test_setCEAMigrationContract_Success() public { + function test_updateCEAMigrationContract_Success() public { // Initially should be zero assertEq(factory.CEA_MIGRATION_CONTRACT(), address(0), "Migration contract should be zero initially"); // Set migration contract - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); // Verify set correctly assertEq(factory.CEA_MIGRATION_CONTRACT(), address(migration), "Migration contract should be set"); } - function test_setCEAMigrationContract_ZeroAddress() public { + function test_updateCEAMigrationContract_ZeroAddress() public { vm.expectRevert(abi.encodeWithSelector(CEAErrors.ZeroAddress.selector)); - factory.setCEAMigrationContract(address(0)); + factory.updateCEAMigrationContract(address(0)); } - function test_setCEAMigrationContract_NonOwner() public { + function test_updateCEAMigrationContract_NonOwner() public { vm.prank(nonOwner); vm.expectRevert(); // OwnableUnauthorizedAccount - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); } - function test_setCEAMigrationContract_Event() public { + function test_updateCEAMigrationContract_Event() public { // Expect CEAMigrationContractUpdated event vm.expectEmit(true, true, false, false); emit ICEAFactory.CEAMigrationContractUpdated(address(0), address(migration)); - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); } - function test_setCEAMigrationContract_UpdateExisting() public { + function test_updateCEAMigrationContract_UpdateExisting() public { // Set initial migration contract - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); // Deploy new migration contract CEA ceaV3 = new CEA(); @@ -103,7 +103,7 @@ contract CEAFactory_MigrationTest is Test { emit ICEAFactory.CEAMigrationContractUpdated(address(migration), address(migration2)); // Update migration contract - factory.setCEAMigrationContract(address(migration2)); + factory.updateCEAMigrationContract(address(migration2)); // Verify updated assertEq(factory.CEA_MIGRATION_CONTRACT(), address(migration2), "Migration contract should be updated"); @@ -129,7 +129,7 @@ contract CEAFactory_MigrationTest is Test { address ueaOnPush = makeAddr("ueaOnPush"); // Set migration contract in factory - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); // Deploy CEA vm.prank(vault); diff --git a/test/tests_ceaMigration/CEAMigration_Integration.t.sol b/test/tests_ceaMigration/CEAMigration_Integration.t.sol index 8169a4f..f1ae26e 100644 --- a/test/tests_ceaMigration/CEAMigration_Integration.t.sol +++ b/test/tests_ceaMigration/CEAMigration_Integration.t.sol @@ -72,7 +72,7 @@ contract CEAMigration_IntegrationTest is Test { migration = new CEAMigration(address(ceaV2Implementation)); // Set migration contract in factory - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); } // ========================================================================= @@ -80,7 +80,7 @@ contract CEAMigration_IntegrationTest is Test { // ========================================================================= function generateTxID(uint256 nonce) internal pure returns (bytes32) { - return keccak256(abi.encodePacked("txID", nonce)); + return keccak256(abi.encodePacked("subTxId", nonce)); } function generateUniversalTxID(uint256 nonce) internal pure returns (bytes32) { @@ -92,12 +92,12 @@ contract CEAMigration_IntegrationTest is Test { } function executeMigration() internal { - bytes32 txID = generateTxID(999); + bytes32 subTxId = generateTxID(999); bytes32 universalTxID = generateUniversalTxID(999); bytes memory payload = buildMigrationPayload(address(ceaInstance)); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); } // ========================================================================= @@ -249,7 +249,7 @@ contract CEAMigration_IntegrationTest is Test { executeMigration(); // Execute a new transaction after migration - bytes32 txID = generateTxID(100); + bytes32 subTxId = generateTxID(100); bytes32 universalTxID = generateUniversalTxID(100); Multicall[] memory calls = new Multicall[](1); @@ -257,10 +257,10 @@ contract CEAMigration_IntegrationTest is Test { bytes memory payload = abi.encodePacked(MULTICALL_SELECTOR, abi.encode(calls)); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify executed successfully - assertTrue(ceaInstance.isExecuted(txID), "Post-migration execution should work"); + assertTrue(ceaInstance.isExecuted(subTxId), "Post-migration execution should work"); } function test_PostMigration_Multicall() public { @@ -268,7 +268,7 @@ contract CEAMigration_IntegrationTest is Test { executeMigration(); // Execute a multicall after migration - bytes32 txID = generateTxID(101); + bytes32 subTxId = generateTxID(101); bytes32 universalTxID = generateUniversalTxID(101); Multicall[] memory calls = new Multicall[](3); @@ -278,10 +278,10 @@ contract CEAMigration_IntegrationTest is Test { bytes memory payload = abi.encodePacked(MULTICALL_SELECTOR, abi.encode(calls)); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); // Verify executed successfully - assertTrue(ceaInstance.isExecuted(txID), "Post-migration multicall should work"); + assertTrue(ceaInstance.isExecuted(subTxId), "Post-migration multicall should work"); } // ========================================================================= @@ -289,18 +289,18 @@ contract CEAMigration_IntegrationTest is Test { // ========================================================================= function test_Migration_ReplayProtection() public { - bytes32 txID = generateTxID(999); + bytes32 subTxId = generateTxID(999); bytes32 universalTxID = generateUniversalTxID(999); bytes memory payload = buildMigrationPayload(address(ceaInstance)); // Execute migration vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); // Attempt to replay same migration vm.prank(vault); vm.expectRevert(Errors.PayloadExecuted.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); } // ========================================================================= @@ -308,7 +308,7 @@ contract CEAMigration_IntegrationTest is Test { // ========================================================================= function test_Migration_NotVault() public { - bytes32 txID = generateTxID(999); + bytes32 subTxId = generateTxID(999); bytes32 universalTxID = generateUniversalTxID(999); bytes memory payload = buildMigrationPayload(address(ceaInstance)); @@ -316,11 +316,11 @@ contract CEAMigration_IntegrationTest is Test { vm.prank(nonVault); vm.expectRevert(Errors.NotVault.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_Migration_WrongOriginCaller() public { - bytes32 txID = generateTxID(999); + bytes32 subTxId = generateTxID(999); bytes32 universalTxID = generateUniversalTxID(999); bytes memory payload = buildMigrationPayload(address(ceaInstance)); @@ -328,7 +328,7 @@ contract CEAMigration_IntegrationTest is Test { vm.prank(vault); vm.expectRevert(Errors.InvalidUEA.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, wrongUEA, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, wrongUEA, address(0), payload); } // ========================================================================= @@ -347,15 +347,15 @@ contract CEAMigration_IntegrationTest is Test { // Deploy v3 and new migration contract CEA ceaV3Implementation = new CEA(); CEAMigration migration2 = new CEAMigration(address(ceaV3Implementation)); - factory.setCEAMigrationContract(address(migration2)); + factory.updateCEAMigrationContract(address(migration2)); // Migration 2: v2 → v3 - bytes32 txID = generateTxID(1000); + bytes32 subTxId = generateTxID(1000); bytes32 universalTxID = generateUniversalTxID(1000); bytes memory payload = buildMigrationPayload(address(ceaInstance)); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); assertEq( CEAProxy(payable(address(ceaInstance))).getImplementation(), @@ -375,7 +375,7 @@ contract CEAMigration_IntegrationTest is Test { function test_MigrationAfterManyExecutions() public { // Execute many transactions before migration for (uint256 i = 1; i <= 100; i++) { - bytes32 txID = generateTxID(i); + bytes32 subTxId = generateTxID(i); bytes32 universalTxID = generateUniversalTxID(i); Multicall[] memory calls = new Multicall[](1); @@ -383,7 +383,7 @@ contract CEAMigration_IntegrationTest is Test { bytes memory payload = abi.encodePacked(MULTICALL_SELECTOR, abi.encode(calls)); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } // Verify all executed @@ -409,12 +409,12 @@ contract CEAMigration_IntegrationTest is Test { CEA freshCEAInstance = CEA(payable(freshCEA)); // Execute migration immediately - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildMigrationPayload(freshCEA); vm.prank(vault); - freshCEAInstance.executeUniversalTx(txID, universalTxID, freshUEA, address(0), payload); + freshCEAInstance.executeUniversalTx(subTxId, universalTxID, freshUEA, address(freshCEAInstance), payload); // Verify migration successful assertEq( diff --git a/test/tests_ceaMigration/CEA_Migration.t.sol b/test/tests_ceaMigration/CEA_Migration.t.sol index a6fbf51..04051d5 100644 --- a/test/tests_ceaMigration/CEA_Migration.t.sol +++ b/test/tests_ceaMigration/CEA_Migration.t.sol @@ -69,7 +69,7 @@ contract CEA_MigrationTest is Test { // ========================================================================= function generateTxID(uint256 nonce) internal pure returns (bytes32) { - return keccak256(abi.encodePacked("txID", nonce)); + return keccak256(abi.encodePacked("subTxId", nonce)); } function generateUniversalTxID(uint256 nonce) internal pure returns (bytes32) { @@ -87,7 +87,7 @@ contract CEA_MigrationTest is Test { function test_initializeCEA_WithFactory() public { CEA newCEA = new CEA(); - newCEA.initializeCEA(ueaOnPush, vault, universalGateway, address(factory)); + newCEA.initializeCEA(ueaOnPush, address(factory)); assertTrue(newCEA.isInitialized(), "CEA should be initialized"); assertEq(address(newCEA.factory()), address(factory), "Factory should be set"); @@ -97,7 +97,7 @@ contract CEA_MigrationTest is Test { CEA newCEA = new CEA(); vm.expectRevert(Errors.ZeroAddress.selector); - newCEA.initializeCEA(ueaOnPush, vault, universalGateway, address(0)); + newCEA.initializeCEA(ueaOnPush, address(0)); } // ========================================================================= @@ -106,9 +106,9 @@ contract CEA_MigrationTest is Test { function test_isMigration_True() public { // Set migration contract in factory - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Build migration payload @@ -116,7 +116,7 @@ contract CEA_MigrationTest is Test { // Execute migration (will test isMigration detection internally) vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); // If execution reaches here without reverting, isMigration worked assertTrue(true, "Migration selector detected successfully"); @@ -128,16 +128,16 @@ contract CEA_MigrationTest is Test { function test_handleMigration_TopLevelFormat_Succeeds() public { // Set migration contract - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Top-level MIGRATION_SELECTOR (no Multicall wrapper) bytes memory payload = abi.encodePacked(MIGRATION_SELECTOR); vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); // Verify implementation changed address implAfter = CEAProxy(payable(address(ceaInstance))).getImplementation(); @@ -145,9 +145,9 @@ contract CEA_MigrationTest is Test { } function test_handleMigration_NonZeroMsgValue_Reverts() public { - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = abi.encodePacked(MIGRATION_SELECTOR); @@ -155,14 +155,14 @@ contract CEA_MigrationTest is Test { vm.prank(vault); vm.expectRevert(Errors.InvalidInput.selector); - ceaInstance.executeUniversalTx{value: 1 ether}(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx{value: 1 ether}(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); } function test_handleMigration_MigrationInsideMulticall_Reverts() public { // Set migration contract - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // MIGRATION_SELECTOR wrapped in multicall fails as generic execution failure @@ -173,13 +173,13 @@ contract CEA_MigrationTest is Test { vm.prank(vault); vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_handleMigration_NoMigrationContract() public { // Do NOT set migration contract (remains address(0)) - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Build migration payload @@ -188,7 +188,7 @@ contract CEA_MigrationTest is Test { // Expect InvalidCall revert vm.prank(vault); vm.expectRevert(Errors.InvalidCall.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); } // ========================================================================= @@ -197,9 +197,9 @@ contract CEA_MigrationTest is Test { function test_handleMulticall_MigrationInBatch() public { // Set migration contract - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Build batched payload with migration @@ -215,14 +215,14 @@ contract CEA_MigrationTest is Test { // Migration selector in multicall fails as generic execution failure vm.prank(vault); vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } function test_handleMulticall_MigrationInBatch_FirstPosition() public { // Set migration contract - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Build batched payload with migration in first position @@ -238,7 +238,7 @@ contract CEA_MigrationTest is Test { // Migration selector in multicall fails as generic execution failure vm.prank(vault); vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(0), payload); } // ========================================================================= @@ -247,9 +247,9 @@ contract CEA_MigrationTest is Test { function test_handleExecution_StandaloneMigration() public { // Set migration contract - factory.setCEAMigrationContract(address(migration)); + factory.updateCEAMigrationContract(address(migration)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); // Build standalone migration payload @@ -260,7 +260,7 @@ contract CEA_MigrationTest is Test { // Execute migration vm.prank(vault); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); // Get updated implementation address implAfter = CEAProxy(payable(address(ceaInstance))).getImplementation(); @@ -275,15 +275,15 @@ contract CEA_MigrationTest is Test { // We need a contract whose migrateCEA() will fail when delegatecalled. // Use a mock that reverts on migrateCEA(). FailingMigration failMigration = new FailingMigration(); - factory.setCEAMigrationContract(address(failMigration)); + factory.updateCEAMigrationContract(address(failMigration)); - bytes32 txID = generateTxID(1); + bytes32 subTxId = generateTxID(1); bytes32 universalTxID = generateUniversalTxID(1); bytes memory payload = buildMigrationPayload(address(ceaInstance)); vm.prank(vault); vm.expectRevert(Errors.ExecutionFailed.selector); - ceaInstance.executeUniversalTx(txID, universalTxID, ueaOnPush, address(0), payload); + ceaInstance.executeUniversalTx(subTxId, universalTxID, ueaOnPush, address(ceaInstance), payload); } } diff --git a/test/tests_token_and_core/ForkUniversalCoreAMM.t.sol b/test/tests_token_and_core/ForkUniversalCoreAMM.t.sol index 30cdc39..67a875b 100644 --- a/test/tests_token_and_core/ForkUniversalCoreAMM.t.sol +++ b/test/tests_token_and_core/ForkUniversalCoreAMM.t.sol @@ -125,8 +125,8 @@ // // Setup auto-swap for PSOL // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // universalCore.setSlippageTolerance(PSOL_TOKEN, 300); // 3% // vm.stopPrank(); @@ -193,8 +193,8 @@ // function test_DepositPRC20WithAutoSwap_PETHToWPC() public { // // Setup auto-swap for PETH // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PETH_TOKEN, true); -// universalCore.setDefaultFeeTier(PETH_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PETH_TOKEN, true); +// universalCore.updateDefaultFeeTier(PETH_TOKEN, 500); // universalCore.setSlippageTolerance(PETH_TOKEN, 300); // 3% // vm.stopPrank(); @@ -250,8 +250,8 @@ // function test_DepositPRC20WithAutoSwap_USDTToWPC() public { // // Setup auto-swap for USDT // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(USDT_TOKEN, true); -// universalCore.setDefaultFeeTier(USDT_TOKEN, 500); +// universalCore.updateAutoSwapSupported(USDT_TOKEN, true); +// universalCore.updateDefaultFeeTier(USDT_TOKEN, 500); // universalCore.setSlippageTolerance(USDT_TOKEN, 500); // 5% // vm.stopPrank(); @@ -345,7 +345,7 @@ // // Enable auto-swap but don't set fee tier // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); // vm.stopPrank(); // // Verify auto-swap is enabled but fee tier is not set @@ -379,8 +379,8 @@ // // Setup auto-swap // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // vm.stopPrank(); // // Verify configuration was set @@ -423,8 +423,8 @@ // // Setup auto-swap // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // // Pause the contract // universalCore.pause(); @@ -626,8 +626,8 @@ // function test_DepositPRC20WithAutoSwap_MinPCOutZero_UsesQuoter() public { // // Test when minPCOut=0, should go through quoter route // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // universalCore.setSlippageTolerance(PSOL_TOKEN, 300); // 3% // vm.stopPrank(); @@ -686,8 +686,8 @@ // function test_DepositPRC20WithAutoSwap_MinPCOutProvided_BypassesQuoter() public { // // Test when minPCOut>0, should bypass quoter // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // vm.stopPrank(); // uint256 amount = 1e18; // 1 PSOL @@ -749,8 +749,8 @@ // function test_DepositPRC20WithAutoSwap_FeeZero_UsesDefault() public { // // Test when fee=0, should use default fee tier // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // vm.stopPrank(); // uint256 amount = 1e18; @@ -812,8 +812,8 @@ // function test_DepositPRC20WithAutoSwap_FeeProvided_UsesProvided() public { // // Test when fee>0, should use provided fee (default set to 0.3% but we pass 0.05%) // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 3000); // Set default to 0.3% pool +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 3000); // Set default to 0.3% pool // vm.stopPrank(); // uint256 amount = 1e18; @@ -862,9 +862,9 @@ // function test_DepositPRC20WithAutoSwap_DeadlineZero_UsesDefault() public { // // Test when deadline=0, should use default deadline // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); -// universalCore.setDefaultDeadlineMins(30); // Set default to 30 minutes +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateDefaultDeadlineMins(30); // Set default to 30 minutes // vm.stopPrank(); // uint256 amount = 1e18; @@ -918,8 +918,8 @@ // function test_DepositPRC20WithAutoSwap_DeadlineProvided_UsesProvided() public { // // Test when deadline>0, should use provided deadline // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // vm.stopPrank(); // uint256 amount = 1e18; @@ -979,8 +979,8 @@ // function test_CalculateMinOutput_SlippageZero_UsesDefault() public { // // Test calculateMinOutput when slippage tolerance=0, should use default 3% // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // // Don't set slippage tolerance (should default to 300 = 3%) // vm.stopPrank(); @@ -1035,8 +1035,8 @@ // function test_CalculateMinOutput_SlippageSet_UsesSet() public { // // Test calculateMinOutput when slippage tolerance is set // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // universalCore.setSlippageTolerance(PSOL_TOKEN, 500); // 5% // vm.stopPrank(); @@ -1092,8 +1092,8 @@ // function test_GetSwapQuote_QuoterV2Integration() public { // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // vm.stopPrank(); // uint256 quote = universalCore.getSwapQuote(PSOL_TOKEN, WPC_TOKEN, 500, 1e18); @@ -1106,13 +1106,13 @@ // vm.startPrank(deployer); // vm.expectRevert(CommonErrors.ZeroAddress.selector); -// universalCore.setUniswapV3Addresses(address(0), address(1), address(1)); +// universalCore.updateUniswapV3Addresses(address(0), address(1), address(1)); // vm.expectRevert(CommonErrors.ZeroAddress.selector); -// universalCore.setUniswapV3Addresses(address(1), address(0), address(1)); +// universalCore.updateUniswapV3Addresses(address(1), address(0), address(1)); // vm.expectRevert(CommonErrors.ZeroAddress.selector); -// universalCore.setUniswapV3Addresses(address(1), address(1), address(0)); +// universalCore.updateUniswapV3Addresses(address(1), address(1), address(0)); // vm.stopPrank(); // } @@ -1123,7 +1123,7 @@ // address newQuoter = address(0x789); // vm.prank(deployer); -// universalCore.setUniswapV3Addresses(newFactory, newRouter, newQuoter); +// universalCore.updateUniswapV3Addresses(newFactory, newRouter, newQuoter); // assertEq(universalCore.uniswapV3FactoryAddress(), newFactory); // assertEq(universalCore.uniswapV3SwapRouterAddress(), newRouter); @@ -1133,14 +1133,14 @@ // function test_SetDefaultFeeTier_InvalidFeeTierReverts() public { // vm.prank(deployer); // vm.expectRevert(UniversalCoreErrors.InvalidFeeTier.selector); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 999); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 999); // } // function test_DeadlineExpired_Reverts() public { // // Test when deadline has already passed // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // vm.stopPrank(); // uint256 amount = 1e18; @@ -1163,8 +1163,8 @@ // function test_PoolNotFound_Reverts() public { // // Test when pool doesn't exist for given fee tier // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // vm.stopPrank(); // uint256 amount = 1e18; @@ -1185,8 +1185,8 @@ // function test_GetSwapQuote_QuoterV2ReturnsZero_ReturnsEstimate() public { // vm.startPrank(deployer); -// universalCore.setAutoSwapSupported(PSOL_TOKEN, true); -// universalCore.setDefaultFeeTier(PSOL_TOKEN, 500); +// universalCore.updateAutoSwapSupported(PSOL_TOKEN, true); +// universalCore.updateDefaultFeeTier(PSOL_TOKEN, 500); // vm.stopPrank(); // // Use a tiny amount to produce 0 output due to tick spacing diff --git a/test/tests_token_and_core/PRC20.t.sol b/test/tests_token_and_core/PRC20.t.sol index f24d89d..7a4da00 100644 --- a/test/tests_token_and_core/PRC20.t.sol +++ b/test/tests_token_and_core/PRC20.t.sol @@ -43,6 +43,8 @@ contract PRC20Test is Test, UpgradeableContractHelper { event Approval(address indexed owner, address indexed spender, uint256 value); event Deposit(bytes from, address to, uint256 amount); event UpdatedUniversalCore(address universalCore); + event NameUpdated(string oldName, string newName); + event SymbolUpdated(string oldSymbol, string newSymbol); function setUp() public { // Setup actors @@ -58,7 +60,6 @@ contract PRC20Test is Test, UpgradeableContractHelper { address mockWPC = makeAddr("wPC"); address mockUniswapFactory = makeAddr("uniswapFactory"); address mockUniswapRouter = makeAddr("uniswapRouter"); - address mockUniswapQuoter = makeAddr("uniswapQuoter"); // Deploy universalCore implementation universalCoreImplementation = new UniversalCore(); @@ -66,24 +67,24 @@ contract PRC20Test is Test, UpgradeableContractHelper { // Create initialization data bytes memory initData = abi.encodeWithSelector( UniversalCore.initialize.selector, + address(this), + makeAddr("pauser"), mockWPC, mockUniswapFactory, - mockUniswapRouter, - mockUniswapQuoter, - makeAddr("pauser") + mockUniswapRouter ); // Deploy proxy and initialize address proxyAddress = deployUpgradeableContract(address(universalCoreImplementation), initData); universalCore = UniversalCore(payable(proxyAddress)); - // Grant MANAGER_ROLE to uExec so manager functions (e.g. setGasTokenPRC20) are callable - universalCore.grantRole(universalCore.MANAGER_ROLE(), uExec); + // Grant UVCORE_ADMIN_ROLE to uExec so config functions are callable + universalCore.grantRole(universalCore.UVCORE_ADMIN_ROLE(), uExec); // Configure universalCore vm.startPrank(uExec); universalCore.setChainMeta(SOURCE_CHAIN_NAMESPACE, GAS_PRICE, 0); - universalCore.setGasTokenPRC20(SOURCE_CHAIN_NAMESPACE, address(gasToken)); + universalCore.updateGasTokenPRC20(SOURCE_CHAIN_NAMESPACE, address(gasToken)); vm.stopPrank(); // Deploy PRC20 token implementation @@ -250,10 +251,10 @@ contract PRC20Test is Test, UpgradeableContractHelper { vm.prank(bob); vm.expectEmit(true, true, false, true); - emit Transfer(alice, bob, APPROVAL_AMOUNT); + emit Approval(alice, bob, 0); vm.expectEmit(true, true, false, true); - emit Approval(alice, bob, 0); + emit Transfer(alice, bob, APPROVAL_AMOUNT); bool success = prc20.transferFrom(alice, bob, APPROVAL_AMOUNT); @@ -276,7 +277,7 @@ contract PRC20Test is Test, UpgradeableContractHelper { function testTransferFromRevertZeroAddressSender() public { vm.prank(bob); - vm.expectRevert(CommonErrors.ZeroAddress.selector); + vm.expectRevert(PRC20Errors.LowAllowance.selector); prc20.transferFrom(address(0), bob, APPROVAL_AMOUNT); } @@ -363,9 +364,9 @@ contract PRC20Test is Test, UpgradeableContractHelper { vm.expectEmit(true, true, false, true); emit Transfer(address(0), bob, depositAmount); - // Expect Deposit event with UNIVERSAL_EXECUTOR_MODULE as from (encoded as bytes) + // Expect Deposit event with msg.sender (universalCore) as from (encoded as bytes) vm.expectEmit(false, true, false, true); - emit Deposit(abi.encodePacked(uExec), bob, depositAmount); + emit Deposit(abi.encodePacked(address(universalCore)), bob, depositAmount); bool success = prc20.deposit(bob, depositAmount); @@ -448,9 +449,9 @@ contract PRC20Test is Test, UpgradeableContractHelper { assertEq(to, bob); assertEq(amount, depositAmount); - // Verify the from field is encoded as UNIVERSAL_EXECUTOR_MODULE, not universalCore + // Verify the from field is encoded as msg.sender (universalCore), not UNIVERSAL_EXECUTOR_MODULE assertEq(from.length, 20); // Should be 20 bytes (address length) - assertEq(address(bytes20(from)), uExec); + assertEq(address(bytes20(from)), address(universalCore)); } function testFuzzDeposit(address to, uint96 amount) public { @@ -470,6 +471,45 @@ contract PRC20Test is Test, UpgradeableContractHelper { assertEq(prc20.totalSupply(), initialSupply + amount); } + // ========================================================================= + // PAUSE PROPAGATION TESTS + // ========================================================================= + + function testDepositRevertsWhenCorePaused_ViaModule() public { + // Pause UniversalCore + vm.prank(makeAddr("pauser")); + universalCore.pause(); + + // Module tries to deposit directly to PRC20 — should revert + vm.prank(uExec); + vm.expectRevert(PRC20Errors.CorePaused.selector); + prc20.deposit(bob, 1000 ether); + } + + function testDepositRevertsWhenCorePaused_ViaCore() public { + // Pause UniversalCore + vm.prank(makeAddr("pauser")); + universalCore.pause(); + + // Core tries to deposit — should also revert + vm.prank(address(universalCore)); + vm.expectRevert(PRC20Errors.CorePaused.selector); + prc20.deposit(bob, 1000 ether); + } + + function testDepositSucceedsAfterUnpause() public { + // Pause then unpause: pauser has PAUSER_ROLE (can pause), admin/operator has OPERATOR_ROLE (can unpause) + vm.prank(makeAddr("pauser")); + universalCore.pause(); + // address(this) is the admin and holds OPERATOR_ROLE — only OPERATOR_ROLE can unpause + universalCore.unpause(); + + // Deposit should succeed + vm.prank(uExec); + bool success = prc20.deposit(bob, 1000 ether); + assertTrue(success); + } + // ========================================================================= // ADMIN & GOVERNANCE CONTROLS // ========================================================================= @@ -479,27 +519,23 @@ contract PRC20Test is Test, UpgradeableContractHelper { address mockWPC = makeAddr("newWPC"); address mockUniswapFactory = makeAddr("newUniswapFactory"); address mockUniswapRouter = makeAddr("newUniswapRouter"); - address mockUniswapQuoter = makeAddr("newUniswapQuoter"); vm.prank(uExec); // Deploy new universalCore implementation UniversalCore newHandlerImpl = new UniversalCore(); - // Create initialization data bytes memory initData = abi.encodeWithSelector( UniversalCore.initialize.selector, + address(this), + makeAddr("pauser"), mockWPC, mockUniswapFactory, - mockUniswapRouter, - mockUniswapQuoter, - makeAddr("pauser") + mockUniswapRouter ); - // Deploy proxy and initialize address proxyAddress = deployUpgradeableContract(address(newHandlerImpl), initData); UniversalCore newHandler = UniversalCore(payable(proxyAddress)); - // Update universalCore contract from Universal Executor Module vm.prank(uExec); vm.expectEmit(false, false, false, true); @@ -516,23 +552,20 @@ contract PRC20Test is Test, UpgradeableContractHelper { address mockWPC = makeAddr("newWPC"); address mockUniswapFactory = makeAddr("newUniswapFactory"); address mockUniswapRouter = makeAddr("newUniswapRouter"); - address mockUniswapQuoter = makeAddr("newUniswapQuoter"); vm.prank(uExec); // Deploy new universalCore implementation UniversalCore newHandlerImpl = new UniversalCore(); - // Create initialization data bytes memory initData = abi.encodeWithSelector( UniversalCore.initialize.selector, + address(this), + makeAddr("pauser"), mockWPC, mockUniswapFactory, - mockUniswapRouter, - mockUniswapQuoter, - makeAddr("pauser") + mockUniswapRouter ); - // Deploy proxy and initialize address proxyAddress = deployUpgradeableContract(address(newHandlerImpl), initData); UniversalCore newHandler = UniversalCore(payable(proxyAddress)); @@ -551,12 +584,13 @@ contract PRC20Test is Test, UpgradeableContractHelper { function testSetNameFromUExec() public { string memory newName = "New Push Token"; + string memory oldName = prc20.name(); - // Set name from Universal Executor Module vm.prank(uExec); + vm.expectEmit(false, false, false, true); + emit NameUpdated(oldName, newName); prc20.setName(newName); - // Verify name was updated assertEq(prc20.name(), newName); } @@ -571,12 +605,13 @@ contract PRC20Test is Test, UpgradeableContractHelper { function testSetSymbolFromUExec() public { string memory newSymbol = "NPUSH"; + string memory oldSymbol = prc20.symbol(); - // Set symbol from Universal Executor Module vm.prank(uExec); + vm.expectEmit(false, false, false, true); + emit SymbolUpdated(oldSymbol, newSymbol); prc20.setSymbol(newSymbol); - // Verify symbol was updated assertEq(prc20.symbol(), newSymbol); } diff --git a/test/tests_token_and_core/UniversalCore.t.sol b/test/tests_token_and_core/UniversalCore.t.sol index 3a7fe09..71d0ef1 100644 --- a/test/tests_token_and_core/UniversalCore.t.sol +++ b/test/tests_token_and_core/UniversalCore.t.sol @@ -10,22 +10,24 @@ import {UniversalCoreErrors, PRC20Errors, CommonErrors} from "../../src/librarie import "../../test/helpers/UpgradeableContractHelper.sol"; import "../../test/mocks/MockUniswapV3Factory.sol"; import "../../test/mocks/MockUniswapV3Router.sol"; -import "../../test/mocks/MockUniswapV3Quoter.sol"; import "../../test/mocks/MockWPC.sol"; import "../../test/mocks/MockPRC20.sol"; import "../../test/mocks/MaliciousPRC20.sol"; import "../../test/mocks/RevertingPRC20.sol"; +import "../../test/mocks/FalseReturningPRC20.sol"; +import "../../test/mocks/RevertingTarget.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; -import "@openzeppelin/contracts/access/AccessControl.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import { + IAccessControlDefaultAdminRules +} from "@openzeppelin/contracts/access/extensions/IAccessControlDefaultAdminRules.sol"; contract UniversalCoreTest is Test, UpgradeableContractHelper { UniversalCore public universalCore; PRC20 public prc20Token; MockUniswapV3Factory public mockFactory; MockUniswapV3Router public mockRouter; - MockUniswapV3Quoter public mockQuoter; MockWPC public mockWPC; address public constant UNIVERSAL_EXECUTOR_MODULE = 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7; @@ -46,7 +48,10 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { event SystemContractDeployed(); event SetAutoSwapSupported(address indexed token, bool supported); - event SetWPC(address indexed wpc); + event SetWPC(address indexed oldAddr, address indexed newAddr); + event SetUniversalGatewayPC(address indexed oldAddr, address indexed newAddr); + event SetUniswapV3Addresses(address factory, address swapRouter); + event SetDefaultFeeTier(address indexed token, uint24 feeTier); event SetGasPCPool(string indexed chainId, address indexed pool, uint24 fee); event SetGasToken(string indexed chainId, address indexed prc20); event DepositPRC20WithAutoSwap( @@ -59,10 +64,13 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { ); event Paused(address account); event Unpaused(address account); - event SetSupportedToken(address indexed prc20, bool supported); event SetChainMeta(string chainNamespace, uint256 price, uint256 chainHeight, uint256 observedAt); event SetBaseGasLimitByChain(string chainNamespace, uint256 gasLimit); event SetRescueFundsGasLimitByChain(string chainNamespace, uint256 gasLimit); + event SetMaxStalenessByChain(string chainNamespace, uint256 maxStaleness); + event SetL1GasFeeByChain(string chainNamespace, uint256 l1GasFee); + event SetTssFundMigrationGasLimitByChain(string chainNamespace, uint256 gasLimit); + event RescueNativePC(address indexed to, uint256 amount); function setUp() public { // Setup accounts @@ -75,7 +83,6 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { // Deploy mocks mockFactory = new MockUniswapV3Factory(); mockRouter = new MockUniswapV3Router(); - mockQuoter = new MockUniswapV3Quoter(); mockWPC = new MockWPC(); mockPRC20 = new MockPRC20(); @@ -103,11 +110,11 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { // Deploy proxy and initialize bytes memory initData = abi.encodeWithSelector( UniversalCore.initialize.selector, + deployer, + pauser, address(mockWPC), address(mockFactory), - address(mockRouter), - address(mockQuoter), - pauser + address(mockRouter) ); address proxyAddress = deployUpgradeableContract(address(implementation), initData); @@ -121,15 +128,16 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { address pool = makeAddr("mockPool"); mockFactory.setPool(address(mockWPC), address(prc20Token), FEE_TIER, pool); - // Grant MANAGER_ROLE to UE Module for manager functions - universalCore.grantRole(universalCore.MANAGER_ROLE(), UNIVERSAL_EXECUTOR_MODULE); + // Grant UVCORE_ADMIN_ROLE to UE Module for config functions + universalCore.grantRole(universalCore.UVCORE_ADMIN_ROLE(), UNIVERSAL_EXECUTOR_MODULE); - // Configure gas price, gas token, base gas limit, and protocol fee for testing + // Configure gas token first, then gas price (updateGasTokenPRC20 resets gas price to 0, + // so setChainMeta must come after to preserve the configured gas price). vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateGasTokenPRC20(CHAIN_NAMESPACE, address(mockPRC20)); universalCore.setChainMeta(CHAIN_NAMESPACE, GAS_PRICE, 0); - universalCore.setGasTokenPRC20(CHAIN_NAMESPACE, address(mockPRC20)); - universalCore.setBaseGasLimitByChain(CHAIN_NAMESPACE, BASE_GAS_LIMIT); - universalCore.setProtocolFeeByToken(address(prc20Token), PROTOCOL_FEE); + universalCore.updateBaseGasLimitByChain(CHAIN_NAMESPACE, BASE_GAS_LIMIT); + universalCore.updateProtocolFeeByToken(address(prc20Token), PROTOCOL_FEE); vm.stopPrank(); } @@ -141,45 +149,43 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { UniversalCore newHandler = new UniversalCore(); // Should not be able to call initialize on implementation directly vm.expectRevert(Initializable.InvalidInitialization.selector); - newHandler.initialize(address(mockWPC), address(mockFactory), address(mockRouter), address(mockQuoter), pauser); + newHandler.initialize(deployer, pauser, address(mockWPC), address(mockFactory), address(mockRouter)); } - function test_Initialize_GrantsAdminRoleToDeployer() public { - // Deploy new universalCore with different deployer - address newDeployer = makeAddr("newDeployer"); - vm.startPrank(newDeployer); + function test_Initialize_GrantsAdminRoleToAdmin() public { + address admin = makeAddr("newAdmin"); + address newPauser = makeAddr("newPauser"); UniversalCore newImplementation = new UniversalCore(); - address newPauser = makeAddr("newPauser"); bytes memory initData = abi.encodeWithSelector( UniversalCore.initialize.selector, + admin, + newPauser, address(mockWPC), address(mockFactory), - address(mockRouter), - address(mockQuoter), - newPauser + address(mockRouter) ); address newProxyAddress = deployUpgradeableContract(address(newImplementation), initData); UniversalCore newHandler = UniversalCore(payable(newProxyAddress)); - // Check that deployer has admin role - assertTrue(newHandler.hasRole(newHandler.DEFAULT_ADMIN_ROLE(), newDeployer)); - vm.stopPrank(); + assertTrue(newHandler.hasRole(newHandler.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(newHandler.hasRole(newHandler.ROLE_MANAGER_ROLE(), admin)); + assertTrue(newHandler.hasRole(newHandler.UVCORE_ADMIN_ROLE(), admin)); + assertTrue(newHandler.hasRole(newHandler.OPERATOR_ROLE(), admin)); + assertTrue(newHandler.hasRole(newHandler.PAUSER_ROLE(), newPauser)); + assertFalse(newHandler.hasRole(newHandler.PAUSER_ROLE(), admin)); } function test_Initialize_SetsAddresses() public view { assertEq(universalCore.WPC(), address(mockWPC)); assertEq(universalCore.uniswapV3Factory(), address(mockFactory)); assertEq(universalCore.uniswapV3SwapRouter(), address(mockRouter)); - assertEq(universalCore.uniswapV3Quoter(), address(mockQuoter)); } function test_Initialize_RevertsOnSecondCall() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - universalCore.initialize( - address(mockWPC), address(mockFactory), address(mockRouter), address(mockQuoter), pauser - ); + universalCore.initialize(deployer, pauser, address(mockWPC), address(mockFactory), address(mockRouter)); } function test_UniversalExecutorModule_IsImmutable() public view { @@ -197,17 +203,19 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { // 1) Admin-specific (DEFAULT_ADMIN_ROLE) setters // ======================================== - function test_SetAutoSwapSupported_OnlyOwner() public { + function test_SetAutoSwapSupported_OnlyUCoreAdmin() public { address token = makeAddr("token"); - // Non-owner should revert + bytes32 ucoreAdminRole = universalCore.UVCORE_ADMIN_ROLE(); vm.prank(nonOwner); - vm.expectRevert(CommonErrors.InvalidOwner.selector); - universalCore.setAutoSwapSupported(token, true); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ucoreAdminRole) + ); + universalCore.updateAutoSwapSupported(token, true); - // Deployer (who has admin role) should succeed + // Deployer (who has UVCORE_ADMIN_ROLE) should succeed vm.prank(deployer); - universalCore.setAutoSwapSupported(token, true); + universalCore.updateAutoSwapSupported(token, true); assertTrue(universalCore.isAutoSwapSupported(token)); } @@ -215,41 +223,50 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { address token = makeAddr("token"); vm.prank(deployer); - universalCore.setAutoSwapSupported(token, true); + vm.expectEmit(true, false, false, true); + emit SetAutoSwapSupported(token, true); + universalCore.updateAutoSwapSupported(token, true); assertTrue(universalCore.isAutoSwapSupported(token)); // Test flipping to false vm.prank(deployer); - universalCore.setAutoSwapSupported(token, false); + vm.expectEmit(true, false, false, true); + emit SetAutoSwapSupported(token, false); + universalCore.updateAutoSwapSupported(token, false); assertFalse(universalCore.isAutoSwapSupported(token)); } function test_SetAutoSwapSupported_ZeroAddressAllowed() public { // Current implementation allows zero address vm.prank(deployer); - universalCore.setAutoSwapSupported(address(0), true); + universalCore.updateAutoSwapSupported(address(0), true); assertTrue(universalCore.isAutoSwapSupported(address(0))); } - function test_SetWPCContractAddress_OnlyOwner() public { + function test_SetWPCContractAddress_OnlyOperator() public { address newWPC = makeAddr("newWPC"); - // Non-owner should revert + bytes32 operatorRole = universalCore.OPERATOR_ROLE(); vm.prank(nonOwner); - vm.expectRevert(CommonErrors.InvalidOwner.selector); - universalCore.setWPC(newWPC); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, operatorRole) + ); + universalCore.updateWPC(newWPC); - // Deployer (who has admin role) should succeed + // Deployer (who has OPERATOR_ROLE) should succeed vm.prank(deployer); - universalCore.setWPC(newWPC); + universalCore.updateWPC(newWPC); assertEq(universalCore.WPC(), newWPC); } function test_SetWPCContractAddress_HappyPath() public { address newWPC = makeAddr("newWPC"); + address oldWPC = universalCore.WPC(); vm.prank(deployer); - universalCore.setWPC(newWPC); + vm.expectEmit(true, true, false, true); + emit SetWPC(oldWPC, newWPC); + universalCore.updateWPC(newWPC); assertEq(universalCore.WPC(), newWPC); } @@ -257,7 +274,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { function test_SetWPCContractAddress_ZeroAddressReverts() public { vm.prank(deployer); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setWPC(address(0)); + universalCore.updateWPC(address(0)); } // ======================================== @@ -279,21 +296,21 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { vm.startPrank(nonUEModule); vm.expectRevert( abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, nonUEModule, universalCore.MANAGER_ROLE() + IAccessControl.AccessControlUnauthorizedAccount.selector, nonUEModule, universalCore.UVCORE_ADMIN_ROLE() ) ); - universalCore.setGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); + universalCore.updateGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); vm.stopPrank(); // UEM should succeed vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); + universalCore.updateGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); } function test_SetGasPCPool_ZeroAddressReverts() public { vm.prank(UNIVERSAL_EXECUTOR_MODULE); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setGasPCPool(CHAIN_NAMESPACE, address(0), FEE_TIER); + universalCore.updateGasPCPool(CHAIN_NAMESPACE, address(0), FEE_TIER); } function test_SetGasPCPool_PoolNotFoundReverts() public { @@ -304,7 +321,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { vm.prank(UNIVERSAL_EXECUTOR_MODULE); vm.expectRevert(UniversalCoreErrors.PoolNotFound.selector); - universalCore.setGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); + universalCore.updateGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); } function test_SetGasPCPool_HappyPath() public { @@ -319,7 +336,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { } vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); + universalCore.updateGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); assertEq(universalCore.gasPCPoolByChainNamespace(CHAIN_NAMESPACE), pool); } @@ -336,7 +353,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { } vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); + universalCore.updateGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); assertEq(universalCore.gasPCPoolByChainNamespace(CHAIN_NAMESPACE), pool); } @@ -348,7 +365,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { // Change WPC first vm.prank(deployer); - universalCore.setWPC(newWPC); + universalCore.updateWPC(newWPC); // Setup pool with new WPC (both orderings) if (newWPC < gasToken) { @@ -358,7 +375,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { } vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); + universalCore.updateGasPCPool(CHAIN_NAMESPACE, gasToken, FEE_TIER); assertEq(universalCore.gasPCPoolByChainNamespace(CHAIN_NAMESPACE), pool); } @@ -370,29 +387,29 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { vm.startPrank(nonUEModule); vm.expectRevert( abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, nonUEModule, universalCore.MANAGER_ROLE() + IAccessControl.AccessControlUnauthorizedAccount.selector, nonUEModule, universalCore.UVCORE_ADMIN_ROLE() ) ); - universalCore.setGasTokenPRC20(CHAIN_NAMESPACE, prc20); + universalCore.updateGasTokenPRC20(CHAIN_NAMESPACE, prc20); vm.stopPrank(); // UEM should succeed vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setGasTokenPRC20(CHAIN_NAMESPACE, prc20); + universalCore.updateGasTokenPRC20(CHAIN_NAMESPACE, prc20); assertEq(universalCore.gasTokenPRC20ByChainNamespace(CHAIN_NAMESPACE), prc20); } function test_SetGasTokenPRC20_ZeroAddressReverts() public { vm.prank(UNIVERSAL_EXECUTOR_MODULE); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setGasTokenPRC20(CHAIN_NAMESPACE, address(0)); + universalCore.updateGasTokenPRC20(CHAIN_NAMESPACE, address(0)); } function test_SetGasTokenPRC20_HappyPath() public { address prc20 = makeAddr("prc20"); vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setGasTokenPRC20(CHAIN_NAMESPACE, prc20); + universalCore.updateGasTokenPRC20(CHAIN_NAMESPACE, prc20); assertEq(universalCore.gasTokenPRC20ByChainNamespace(CHAIN_NAMESPACE), prc20); } @@ -518,31 +535,35 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { assertTrue(universalCore.paused()); } - function test_Unpause_OnlyPauser() public { - bytes32 role = universalCore.PAUSER_ROLE(); + function test_Unpause_OnlyOperator() public { + bytes32 operatorRole = universalCore.OPERATOR_ROLE(); - // First pause the contract vm.prank(pauser); universalCore.pause(); - // Non-pauser cannot unpause + // Non-operator cannot unpause vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, role) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, operatorRole) ); vm.prank(nonOwner); universalCore.unpause(); + + // Pauser also cannot unpause (different role) + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, pauser, operatorRole) + ); + vm.prank(pauser); + universalCore.unpause(); } function test_Unpause_HappyPath() public { - // First pause the contract vm.prank(pauser); universalCore.pause(); assertTrue(universalCore.paused()); - // Unpause - vm.prank(pauser); + // Deployer has OPERATOR_ROLE vm.expectEmit(true, true, true, true); - emit Unpaused(pauser); + emit Unpaused(deployer); universalCore.unpause(); assertFalse(universalCore.paused()); @@ -556,31 +577,27 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { assertFalse(universalCore.hasRole(universalCore.PAUSER_ROLE(), deployer)); } - function test_SetPauserRole_OnlyAdmin() public { + function test_GrantPauserRole_OnlyRoleManager() public { address newPauser = makeAddr("newPauser"); + bytes32 pauserRole = universalCore.PAUSER_ROLE(); + bytes32 roleManagerRole = universalCore.ROLE_MANAGER_ROLE(); - // Non-admin cannot set pauser role vm.prank(nonOwner); - vm.expectRevert(CommonErrors.InvalidOwner.selector); - universalCore.setPauserRole(newPauser); - - // Admin can set pauser role - vm.prank(deployer); - universalCore.setPauserRole(newPauser); - assertTrue(universalCore.hasRole(universalCore.PAUSER_ROLE(), newPauser)); - } + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, roleManagerRole) + ); + universalCore.grantRole(pauserRole, newPauser); - function test_SetPauserRole_ZeroAddressReverts() public { - vm.prank(deployer); - vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setPauserRole(address(0)); + // Deployer has ROLE_MANAGER_ROLE + universalCore.grantRole(pauserRole, newPauser); + assertTrue(universalCore.hasRole(pauserRole, newPauser)); } - function test_SetPauserRole_NewPauserCanPause() public { + function test_GrantPauserRole_NewPauserCanPause() public { address newPauser = makeAddr("newPauser"); vm.prank(deployer); - universalCore.setPauserRole(newPauser); + universalCore.grantRole(universalCore.PAUSER_ROLE(), newPauser); vm.prank(newPauser); universalCore.pause(); @@ -601,7 +618,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { function test_DepositPRC20WithAutoSwap_WhenPaused_Reverts() public { // Setup auto-swap support vm.prank(deployer); - universalCore.setAutoSwapSupported(address(mockPRC20), true); + universalCore.updateAutoSwapSupported(address(mockPRC20), true); // Pause the contract vm.prank(pauser); @@ -614,12 +631,10 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { } function test_DepositPRC20Token_AfterUnpause_Works() public { - // Pause the contract vm.prank(pauser); universalCore.pause(); - // Unpause the contract - vm.prank(pauser); + // Deployer has OPERATOR_ROLE universalCore.unpause(); // Now deposit should work @@ -639,7 +654,8 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { uint256 gasFee, uint256 protocolFee, uint256 gasPrice, - string memory chainNamespace + string memory chainNamespace, + uint256 gasLimitUsed ) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); assertEq(returnedGasToken, address(mockPRC20)); @@ -651,6 +667,8 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { assertEq(gasFee, gasPrice * actualBaseGasLimit); assertEq(protocolFee, actualProtocolFee); assertEq(keccak256(bytes(chainNamespace)), keccak256(bytes(CHAIN_NAMESPACE))); + assertEq(gasLimitUsed, actualBaseGasLimit); + assertEq(gasFee, gasPrice * gasLimitUsed); } function testWithdrawGasFeeWithGasLimitHappyPath() public view { @@ -661,7 +679,8 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { uint256 gasFee, uint256 protocolFee, uint256 gasPrice, - string memory chainNamespace + string memory chainNamespace, + uint256 gasLimitUsed ) = universalCore.getOutboundTxGasAndFees(address(prc20Token), customGasLimit); assertEq(returnedGasToken, address(mockPRC20)); @@ -669,17 +688,37 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { assertEq(gasFee, gasPrice * customGasLimit); assertEq(protocolFee, PROTOCOL_FEE); assertEq(keccak256(bytes(chainNamespace)), keccak256(bytes(CHAIN_NAMESPACE))); + assertEq(gasLimitUsed, customGasLimit); + assertEq(gasFee, gasPrice * gasLimitUsed); } function testWithdrawGasFeeZeroGasPrice() public { + // Use a fresh chain namespace with gas token set but no setChainMeta call, + // so gasPriceByChainNamespace is 0 by default in storage. + string memory newNs = "eip155:9999"; + + PRC20 newPrc20Impl = new PRC20(); + bytes memory initData = abi.encodeWithSelector( + PRC20.initialize.selector, + "Zero Price PRC20", + "ZP", + 18, + newNs, + IPRC20.TokenType.ERC20, + address(universalCore), + SOURCE_TOKEN_ADDRESS + ); + address proxyAddr = deployUpgradeableContract(address(newPrc20Impl), initData); + PRC20 newToken = PRC20(payable(proxyAddr)); + + // Set gas token and base gas limit, but never call setChainMeta → price stays 0 vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); - // Set gas price to zero - universalCore.setChainMeta(CHAIN_NAMESPACE, 0, 0); + universalCore.updateGasTokenPRC20(newNs, address(mockPRC20)); + universalCore.updateBaseGasLimitByChain(newNs, BASE_GAS_LIMIT); vm.stopPrank(); - // Expect revert when getting gas fee vm.expectRevert(UniversalCoreErrors.ZeroGasPrice.selector); - universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + universalCore.getOutboundTxGasAndFees(address(newToken), BASE_GAS_LIMIT); } function testWithdrawGasFeeZeroGasToken() public { @@ -701,10 +740,10 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { address proxyAddress = deployUpgradeableContract(address(newPrc20Token), initData); PRC20 newToken = PRC20(payable(proxyAddress)); - // Don't set gas token for this chain ID, so it will be address(0) + // Don't set gas token or base gas limit for this chain ID - // Expect revert when getting gas fee - vm.expectRevert(CommonErrors.ZeroAddress.selector); + // Expect revert due to unconfigured base gas limit + vm.expectRevert(UniversalCoreErrors.ZeroBaseGasLimit.selector); universalCore.getOutboundTxGasAndFees(address(newToken), 0); } @@ -714,7 +753,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { vm.prank(UNIVERSAL_EXECUTOR_MODULE); universalCore.setChainMeta(CHAIN_NAMESPACE, newGasPrice, 0); - (, uint256 gasFee, uint256 protocolFee,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + (, uint256 gasFee, uint256 protocolFee,,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); uint256 actualBaseGasLimit = universalCore.baseGasLimitByChainNamespace(CHAIN_NAMESPACE); uint256 expectedGasFee = newGasPrice * actualBaseGasLimit; @@ -726,9 +765,9 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { uint256 newBaseGasLimit = BASE_GAS_LIMIT * 2; vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setBaseGasLimitByChain(CHAIN_NAMESPACE, newBaseGasLimit); + universalCore.updateBaseGasLimitByChain(CHAIN_NAMESPACE, newBaseGasLimit); - (, uint256 gasFee, uint256 protocolFee,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + (, uint256 gasFee, uint256 protocolFee,,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); uint256 actualGasPrice = universalCore.gasPriceByChainNamespace(CHAIN_NAMESPACE); assertEq(gasFee, actualGasPrice * newBaseGasLimit); @@ -739,9 +778,9 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { uint256 newProtocolFee = PROTOCOL_FEE * 2; vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setProtocolFeeByToken(address(prc20Token), newProtocolFee); + universalCore.updateProtocolFeeByToken(address(prc20Token), newProtocolFee); - (, uint256 gasFee, uint256 protocolFee,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + (, uint256 gasFee, uint256 protocolFee,,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); uint256 actualGasPrice = universalCore.gasPriceByChainNamespace(CHAIN_NAMESPACE); uint256 actualBaseGasLimit = universalCore.baseGasLimitByChainNamespace(CHAIN_NAMESPACE); @@ -759,7 +798,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { vm.prank(UNIVERSAL_EXECUTOR_MODULE); vm.expectEmit(false, false, false, true); emit SetBaseGasLimitByChain(CHAIN_NAMESPACE, newGasLimit); - universalCore.setBaseGasLimitByChain(CHAIN_NAMESPACE, newGasLimit); + universalCore.updateBaseGasLimitByChain(CHAIN_NAMESPACE, newGasLimit); assertEq(universalCore.baseGasLimitByChainNamespace(CHAIN_NAMESPACE), newGasLimit); } @@ -770,16 +809,16 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { // Non-manager should revert vm.expectRevert( abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, universalCore.MANAGER_ROLE() + IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, universalCore.UVCORE_ADMIN_ROLE() ) ); vm.prank(nonOwner); - universalCore.setBaseGasLimitByChain(CHAIN_NAMESPACE, newGasLimit); + universalCore.updateBaseGasLimitByChain(CHAIN_NAMESPACE, newGasLimit); } function test_SetBaseGasLimitByChain_ZeroValueAllowed() public { vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setBaseGasLimitByChain(CHAIN_NAMESPACE, 0); + universalCore.updateBaseGasLimitByChain(CHAIN_NAMESPACE, 0); assertEq(universalCore.baseGasLimitByChainNamespace(CHAIN_NAMESPACE), 0); } @@ -792,97 +831,6 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { universalCore.getOutboundTxGasAndFees(address(prc20Token), belowBase); } - // ======================================== - // 6) Set Supported Token Tests - // ======================================== - - function test_SetSupportedToken_OnlyManagerRole() public { - address token = makeAddr("token"); - - // Non-manager should revert - vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, nonUEModule, universalCore.MANAGER_ROLE() - ) - ); - vm.prank(nonUEModule); - universalCore.setSupportedToken(token, true); - - // MANAGER_ROLE (UNIVERSAL_EXECUTOR_MODULE) should succeed - vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setSupportedToken(token, true); - assertTrue(universalCore.isSupportedToken(token)); - } - - function test_SetSupportedToken_HappyPath_SetTrue() public { - address token = makeAddr("token"); - - vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setSupportedToken(token, true); - assertTrue(universalCore.isSupportedToken(token)); - } - - function test_SetSupportedToken_HappyPath_SetFalse() public { - address token = makeAddr("token"); - - // First set to true - vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setSupportedToken(token, true); - assertTrue(universalCore.isSupportedToken(token)); - - // Then set to false - vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setSupportedToken(token, false); - assertFalse(universalCore.isSupportedToken(token)); - } - - function test_SetSupportedToken_FlipFalseToTrue() public { - address token = makeAddr("token"); - - // Initially false (default) - assertFalse(universalCore.isSupportedToken(token)); - - // Flip to true - vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setSupportedToken(token, true); - assertTrue(universalCore.isSupportedToken(token)); - } - - function test_SetSupportedToken_ZeroAddressReverts() public { - vm.prank(UNIVERSAL_EXECUTOR_MODULE); - vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setSupportedToken(address(0), true); - } - - function test_SetSupportedToken_EmitsEvent() public { - address token = makeAddr("token"); - - // Test event emission when setting to true - vm.prank(UNIVERSAL_EXECUTOR_MODULE); - vm.expectEmit(true, false, false, false); - emit SetSupportedToken(token, true); - universalCore.setSupportedToken(token, true); - - // Test event emission when setting to false - vm.prank(UNIVERSAL_EXECUTOR_MODULE); - vm.expectEmit(true, false, false, false); - emit SetSupportedToken(token, false); - universalCore.setSupportedToken(token, false); - } - - function test_SetSupportedToken_OwnerCannotCall() public { - address token = makeAddr("token"); - - // Owner (deployer) should not be able to call without MANAGER_ROLE - vm.prank(deployer); - vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, deployer, universalCore.MANAGER_ROLE() - ) - ); - universalCore.setSupportedToken(token, true); - } - // ======================================== // 7) setChainMeta Tests // ======================================== @@ -922,7 +870,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { vm.prank(UNIVERSAL_EXECUTOR_MODULE); universalCore.setChainMeta(CHAIN_NAMESPACE, newPrice, 100); - (, uint256 gasFee, uint256 protocolFee,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + (, uint256 gasFee, uint256 protocolFee,,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); assertEq(gasFee, newPrice * universalCore.baseGasLimitByChainNamespace(CHAIN_NAMESPACE)); assertEq(protocolFee, universalCore.protocolFeeByToken(address(prc20Token))); } @@ -962,11 +910,18 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { assertEq(universalCore.timestampObservedAtByChainNamespace(bscChain), block.timestamp); } - function test_SetChainMeta_ZeroValuesAllowed() public { + function test_SetChainMeta_ZeroPriceReverts() public { + vm.expectRevert(UniversalCoreErrors.ZeroGasPrice.selector); vm.prank(UNIVERSAL_EXECUTOR_MODULE); universalCore.setChainMeta(CHAIN_NAMESPACE, 0, 0); + } + + function test_SetChainMeta_ZeroChainHeightAllowed() public { + // price must be non-zero, but chainHeight=0 is valid + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.setChainMeta(CHAIN_NAMESPACE, GAS_PRICE, 0); - assertEq(universalCore.gasPriceByChainNamespace(CHAIN_NAMESPACE), 0); + assertEq(universalCore.gasPriceByChainNamespace(CHAIN_NAMESPACE), GAS_PRICE); assertEq(universalCore.chainHeightByChainNamespace(CHAIN_NAMESPACE), 0); assertEq(universalCore.timestampObservedAtByChainNamespace(CHAIN_NAMESPACE), block.timestamp); } @@ -981,7 +936,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { vm.prank(UNIVERSAL_EXECUTOR_MODULE); vm.expectEmit(false, false, false, true); emit SetRescueFundsGasLimitByChain(CHAIN_NAMESPACE, rescueGasLimit); - universalCore.setRescueFundsGasLimitByChain(CHAIN_NAMESPACE, rescueGasLimit); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NAMESPACE, rescueGasLimit); assertEq(universalCore.rescueFundsGasLimitByChainNamespace(CHAIN_NAMESPACE), rescueGasLimit); } @@ -989,16 +944,16 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { function test_SetRescueFundsGasLimitByChain_OnlyManagerRole() public { vm.expectRevert( abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, universalCore.MANAGER_ROLE() + IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, universalCore.UVCORE_ADMIN_ROLE() ) ); vm.prank(nonOwner); - universalCore.setRescueFundsGasLimitByChain(CHAIN_NAMESPACE, 300_000); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NAMESPACE, 300_000); } function test_SetRescueFundsGasLimitByChain_ZeroValueAllowed() public { vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setRescueFundsGasLimitByChain(CHAIN_NAMESPACE, 0); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NAMESPACE, 0); assertEq(universalCore.rescueFundsGasLimitByChainNamespace(CHAIN_NAMESPACE), 0); } @@ -1006,7 +961,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { uint256 rescueGasLimit = 300_000; vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setRescueFundsGasLimitByChain(CHAIN_NAMESPACE, rescueGasLimit); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NAMESPACE, rescueGasLimit); ( address returnedGasToken, @@ -1046,7 +1001,7 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { // Set rescue gas limit but no gas token for this chain vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setRescueFundsGasLimitByChain("999", 300_000); + universalCore.updateRescueFundsGasLimitByChain("999", 300_000); vm.expectRevert(CommonErrors.ZeroAddress.selector); universalCore.getRescueFundsGasLimit(address(newToken)); @@ -1070,8 +1025,8 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { // Set rescue gas limit and gas token, but no gas price vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setRescueFundsGasLimitByChain("888", 300_000); - universalCore.setGasTokenPRC20("888", address(mockPRC20)); + universalCore.updateRescueFundsGasLimitByChain("888", 300_000); + universalCore.updateGasTokenPRC20("888", address(mockPRC20)); vm.stopPrank(); vm.expectRevert(UniversalCoreErrors.ZeroGasPrice.selector); @@ -1079,71 +1034,75 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { } // ======================================== - // 9) setUniversalGatewayPC Tests + // 9) updateUniversalGatewayPC Tests // ======================================== function test_SetUniversalGatewayPC_HappyPath() public { address gateway = makeAddr("gateway"); + address oldGateway = universalCore.universalGatewayPC(); + vm.prank(deployer); - universalCore.setUniversalGatewayPC(gateway); + vm.expectEmit(true, true, false, true); + emit SetUniversalGatewayPC(oldGateway, gateway); + universalCore.updateUniversalGatewayPC(gateway); assertEq(universalCore.universalGatewayPC(), gateway); } function test_SetUniversalGatewayPC_ZeroAddressReverts() public { vm.prank(deployer); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setUniversalGatewayPC(address(0)); + universalCore.updateUniversalGatewayPC(address(0)); } - function test_SetUniversalGatewayPC_OnlyAdmin() public { + function test_SetUniversalGatewayPC_OnlyOperator() public { + bytes32 operatorRole = universalCore.OPERATOR_ROLE(); vm.prank(nonOwner); - vm.expectRevert(CommonErrors.InvalidOwner.selector); - universalCore.setUniversalGatewayPC(makeAddr("gateway")); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, operatorRole) + ); + universalCore.updateUniversalGatewayPC(makeAddr("gateway")); } // ======================================== - // 10) setUniswapV3Addresses Tests + // 10) updateUniswapV3Addresses Tests // ======================================== function test_SetUniswapV3Addresses_HappyPath() public { address f = makeAddr("factory2"); address r = makeAddr("router2"); - address q = makeAddr("quoter2"); vm.prank(deployer); - universalCore.setUniswapV3Addresses(f, r, q); + vm.expectEmit(false, false, false, true); + emit SetUniswapV3Addresses(f, r); + universalCore.updateUniswapV3Addresses(f, r); assertEq(universalCore.uniswapV3Factory(), f); assertEq(universalCore.uniswapV3SwapRouter(), r); - assertEq(universalCore.uniswapV3Quoter(), q); } function test_SetUniswapV3Addresses_RevertsZeroFactory() public { vm.prank(deployer); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setUniswapV3Addresses(address(0), makeAddr("r"), makeAddr("q")); + universalCore.updateUniswapV3Addresses(address(0), makeAddr("r")); } function test_SetUniswapV3Addresses_RevertsZeroRouter() public { vm.prank(deployer); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setUniswapV3Addresses(makeAddr("f"), address(0), makeAddr("q")); + universalCore.updateUniswapV3Addresses(makeAddr("f"), address(0)); } - function test_SetUniswapV3Addresses_RevertsZeroQuoter() public { - vm.prank(deployer); - vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setUniswapV3Addresses(makeAddr("f"), makeAddr("r"), address(0)); - } - - function test_SetUniswapV3Addresses_OnlyAdmin() public { + function test_SetUniswapV3Addresses_OnlyOperator() public { + bytes32 operatorRole = universalCore.OPERATOR_ROLE(); vm.prank(nonOwner); - vm.expectRevert(CommonErrors.InvalidOwner.selector); - universalCore.setUniswapV3Addresses(makeAddr("f"), makeAddr("r"), makeAddr("q")); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, operatorRole) + ); + universalCore.updateUniswapV3Addresses(makeAddr("f"), makeAddr("r")); } // ======================================== - // 11) setDefaultDeadlineMins Tests + // 11) updateDefaultDeadlineMins Tests // ======================================== event SetDefaultDeadlineMins(uint256 minutesValue); @@ -1152,38 +1111,43 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { vm.prank(deployer); vm.expectEmit(false, false, false, true); emit SetDefaultDeadlineMins(30); - universalCore.setDefaultDeadlineMins(30); + universalCore.updateDefaultDeadlineMins(30); assertEq(universalCore.defaultDeadlineMins(), 30); } - function test_SetDefaultDeadlineMins_OnlyAdmin() public { + function test_SetDefaultDeadlineMins_OnlyUCoreAdmin() public { + bytes32 ucoreAdminRole = universalCore.UVCORE_ADMIN_ROLE(); vm.prank(nonOwner); - vm.expectRevert(CommonErrors.InvalidOwner.selector); - universalCore.setDefaultDeadlineMins(30); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ucoreAdminRole) + ); + universalCore.updateDefaultDeadlineMins(30); } // ======================================== - // 12) setDefaultFeeTier Tests + // 12) updateDefaultFeeTier Tests // ======================================== function test_SetDefaultFeeTier_HappyPath_500() public { address token = makeAddr("token"); vm.prank(deployer); - universalCore.setDefaultFeeTier(token, 500); + vm.expectEmit(true, false, false, true); + emit SetDefaultFeeTier(token, 500); + universalCore.updateDefaultFeeTier(token, 500); assertEq(universalCore.defaultFeeTier(token), 500); } function test_SetDefaultFeeTier_HappyPath_3000() public { address token = makeAddr("token"); vm.prank(deployer); - universalCore.setDefaultFeeTier(token, 3000); + universalCore.updateDefaultFeeTier(token, 3000); assertEq(universalCore.defaultFeeTier(token), 3000); } function test_SetDefaultFeeTier_HappyPath_10000() public { address token = makeAddr("token"); vm.prank(deployer); - universalCore.setDefaultFeeTier(token, 10000); + universalCore.updateDefaultFeeTier(token, 10000); assertEq(universalCore.defaultFeeTier(token), 10000); } @@ -1191,78 +1155,538 @@ contract UniversalCoreTest is Test, UpgradeableContractHelper { address token = makeAddr("token"); vm.prank(deployer); vm.expectRevert(UniversalCoreErrors.InvalidFeeTier.selector); - universalCore.setDefaultFeeTier(token, 100); + universalCore.updateDefaultFeeTier(token, 200); } function test_SetDefaultFeeTier_RevertsZeroAddress() public { vm.prank(deployer); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setDefaultFeeTier(address(0), 3000); + universalCore.updateDefaultFeeTier(address(0), 3000); } - function test_SetDefaultFeeTier_OnlyAdmin() public { + function test_SetDefaultFeeTier_OnlyUCoreAdmin() public { + bytes32 ucoreAdminRole = universalCore.UVCORE_ADMIN_ROLE(); vm.prank(nonOwner); - vm.expectRevert(CommonErrors.InvalidOwner.selector); - universalCore.setDefaultFeeTier(makeAddr("token"), 3000); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ucoreAdminRole) + ); + universalCore.updateDefaultFeeTier(makeAddr("token"), 3000); } // ======================================== - // 13) setSlippageTolerance Tests + // 14) Rescue Funds Gas Limit (continued) // ======================================== - function test_SetSlippageTolerance_HappyPath() public { - address token = makeAddr("token"); - vm.prank(deployer); - universalCore.setSlippageTolerance(token, 300); - assertEq(universalCore.slippageTolerance(token), 300); - } + function test_GetRescueFundsGasLimit_UpdatedAfterSettingNewLimit() public { + uint256 initialLimit = 300_000; + uint256 updatedLimit = 600_000; - function test_SetSlippageTolerance_RevertsExceeds5000() public { - address token = makeAddr("token"); - vm.prank(deployer); - vm.expectRevert(UniversalCoreErrors.InvalidSlippageTolerance.selector); - universalCore.setSlippageTolerance(token, 5001); + vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NAMESPACE, initialLimit); + vm.stopPrank(); + + (, uint256 gasFee1,,,) = universalCore.getRescueFundsGasLimit(address(prc20Token)); + assertEq(gasFee1, GAS_PRICE * initialLimit); + + vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NAMESPACE, updatedLimit); + vm.stopPrank(); + + (, uint256 gasFee2,,,) = universalCore.getRescueFundsGasLimit(address(prc20Token)); + assertEq(gasFee2, GAS_PRICE * updatedLimit); } - function test_SetSlippageTolerance_RevertsZeroAddress() public { - vm.prank(deployer); - vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setSlippageTolerance(address(0), 300); + // ======================================== + // Gas Data Staleness Tests + // ======================================== + + // --- Setter tests --- + + function test_SetMaxStalenessByChain_HappyPath() public { + uint256 maxStaleness = 3600; // 1 hour + + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + vm.expectEmit(false, false, false, true); + emit SetMaxStalenessByChain(CHAIN_NAMESPACE, maxStaleness); + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, maxStaleness); + + assertEq(universalCore.maxStalenessByChainNamespace(CHAIN_NAMESPACE), maxStaleness); } - function test_SetSlippageTolerance_OnlyAdmin() public { + function test_SetMaxStalenessByChain_OnlyManagerRole() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, universalCore.UVCORE_ADMIN_ROLE() + ) + ); vm.prank(nonOwner); - vm.expectRevert(CommonErrors.InvalidOwner.selector); - universalCore.setSlippageTolerance(makeAddr("token"), 300); + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, 3600); } - function test_SetSlippageTolerance_BoundaryAt5000() public { - address token = makeAddr("token"); - vm.prank(deployer); - universalCore.setSlippageTolerance(token, 5000); - assertEq(universalCore.slippageTolerance(token), 5000); + function test_SetMaxStalenessByChain_ZeroDisablesCheck() public { + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, 0); + + assertEq(universalCore.maxStalenessByChainNamespace(CHAIN_NAMESPACE), 0); } - // ======================================== - // 14) Rescue Funds Gas Limit (continued) - // ======================================== + // --- Default-off behaviour --- - function test_GetRescueFundsGasLimit_UpdatedAfterSettingNewLimit() public { - uint256 initialLimit = 300_000; - uint256 updatedLimit = 600_000; + function test_StalenessDisabledByDefault_NoRevertEvenAfterLongWarp() public { + // No updateMaxStalenessByChain call — staleness check is off for this namespace. + // Configure rescue limit so getRescueFundsGasLimit doesn't revert on + // ZeroRescueGasLimit before reaching the (disabled) staleness check. + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NAMESPACE, 300_000); + + vm.warp(block.timestamp + 365 days); + + (, uint256 outboundFee,,,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + assertGt(outboundFee, 0, "outbound fee should quote even after a year when check is disabled"); + + (, uint256 rescueFee,,,) = universalCore.getRescueFundsGasLimit(address(prc20Token)); + assertGt(rescueFee, 0, "rescue fee should quote even after a year when check is disabled"); + } + + // --- getOutboundTxGasAndFees staleness --- + + function test_StalenessCheck_GetOutboundTxGasAndFees_Reverts() public { + uint256 maxStaleness = 300; + + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, maxStaleness); + + uint256 observedAt = universalCore.timestampObservedAtByChainNamespace(CHAIN_NAMESPACE); + vm.warp(observedAt + maxStaleness + 1); + + vm.expectRevert( + abi.encodeWithSelector(UniversalCoreErrors.StaleGasData.selector, observedAt, block.timestamp, maxStaleness) + ); + universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + } + + function test_StalenessCheck_GetOutboundTxGasAndFees_BoundaryAtEdge_OK() public { + uint256 maxStaleness = 300; + + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, maxStaleness); + + // Warp to exactly observedAt + maxStaleness — still within window (strict >). + uint256 observedAt = universalCore.timestampObservedAtByChainNamespace(CHAIN_NAMESPACE); + vm.warp(observedAt + maxStaleness); + + (, uint256 gasFee,,,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + assertGt(gasFee, 0, "should succeed exactly at the boundary"); + } + + function test_StalenessCheck_GetOutboundTxGasAndFees_OneSecondPastEdge_Reverts() public { + uint256 maxStaleness = 300; + + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, maxStaleness); + + uint256 observedAt = universalCore.timestampObservedAtByChainNamespace(CHAIN_NAMESPACE); + vm.warp(observedAt + maxStaleness + 1); + + vm.expectRevert( + abi.encodeWithSelector(UniversalCoreErrors.StaleGasData.selector, observedAt, block.timestamp, maxStaleness) + ); + universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + } + + // --- getRescueFundsGasLimit staleness --- + + function test_StalenessCheck_GetRescueFundsGasLimit_Reverts() public { + uint256 maxStaleness = 300; vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setRescueFundsGasLimitByChain(CHAIN_NAMESPACE, initialLimit); + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, maxStaleness); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NAMESPACE, 300_000); vm.stopPrank(); - (, uint256 gasFee1,,,) = universalCore.getRescueFundsGasLimit(address(prc20Token)); - assertEq(gasFee1, GAS_PRICE * initialLimit); + uint256 observedAt = universalCore.timestampObservedAtByChainNamespace(CHAIN_NAMESPACE); + vm.warp(observedAt + maxStaleness + 1); + + vm.expectRevert( + abi.encodeWithSelector(UniversalCoreErrors.StaleGasData.selector, observedAt, block.timestamp, maxStaleness) + ); + universalCore.getRescueFundsGasLimit(address(prc20Token)); + } + + function test_StalenessCheck_GetRescueFundsGasLimit_BoundaryAtEdge_OK() public { + uint256 maxStaleness = 300; vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setRescueFundsGasLimitByChain(CHAIN_NAMESPACE, updatedLimit); + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, maxStaleness); + universalCore.updateRescueFundsGasLimitByChain(CHAIN_NAMESPACE, 300_000); vm.stopPrank(); - (, uint256 gasFee2,,,) = universalCore.getRescueFundsGasLimit(address(prc20Token)); - assertEq(gasFee2, GAS_PRICE * updatedLimit); + uint256 observedAt = universalCore.timestampObservedAtByChainNamespace(CHAIN_NAMESPACE); + vm.warp(observedAt + maxStaleness); + + (, uint256 gasFee,,,) = universalCore.getRescueFundsGasLimit(address(prc20Token)); + assertGt(gasFee, 0, "rescue should succeed exactly at the boundary"); + } + + // --- Recovery / refresh --- + + function test_StalenessCheck_RefreshingObservedAtClearsStaleness() public { + uint256 maxStaleness = 300; + + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, maxStaleness); + + // Warp past window — call should revert + uint256 firstObservedAt = universalCore.timestampObservedAtByChainNamespace(CHAIN_NAMESPACE); + vm.warp(firstObservedAt + maxStaleness + 100); + + vm.expectRevert( + abi.encodeWithSelector( + UniversalCoreErrors.StaleGasData.selector, firstObservedAt, block.timestamp, maxStaleness + ) + ); + universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + + // Refresh chain meta — observedAt resets to current block.timestamp. + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.setChainMeta(CHAIN_NAMESPACE, GAS_PRICE, 0); + + (, uint256 gasFee,,,,) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + assertGt(gasFee, 0, "should succeed after refresh"); + } + + // --- Chain-halt edge case: observedAt never set --- + + function test_StalenessCheck_RevertsWhenObservedAtIsZero() public { + // Set up a fresh chain namespace with gas token + base limit, but NEVER call setChainMeta. + string memory freshNs = "never-observed"; + + PRC20 freshImpl = new PRC20(); + bytes memory initData = abi.encodeWithSelector( + PRC20.initialize.selector, + "Fresh PRC20", + "FPRC20", + 18, + freshNs, + IPRC20.TokenType.ERC20, + address(universalCore), + SOURCE_TOKEN_ADDRESS + ); + address proxyAddr = deployUpgradeableContract(address(freshImpl), initData); + PRC20 freshPRC20 = PRC20(payable(proxyAddr)); + + vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateGasTokenPRC20(freshNs, address(mockPRC20)); + universalCore.updateBaseGasLimitByChain(freshNs, 100_000); + // Deliberately skip setChainMeta — gasPrice stays 0. + // We need gasPrice > 0 to reach the staleness check. Work around by calling + // setChainMeta once to establish a price, then test the "observedAt is in the + // distant past" case which is the same fail-closed behaviour. + universalCore.setChainMeta(freshNs, GAS_PRICE, 0); + universalCore.updateMaxStalenessByChain(freshNs, 60); + vm.stopPrank(); + + // Warp far past the observed window. observedAt is now in the past relative to block.timestamp. + uint256 observedAt = universalCore.timestampObservedAtByChainNamespace(freshNs); + vm.warp(observedAt + 1 days); + + vm.expectRevert( + abi.encodeWithSelector(UniversalCoreErrors.StaleGasData.selector, observedAt, block.timestamp, uint256(60)) + ); + universalCore.getOutboundTxGasAndFees(address(freshPRC20), 0); + } + + // --- Multi-chain isolation --- + + function test_StalenessCheck_PerChainIsolation() public { + // Chain A is the default CHAIN_NAMESPACE with full config from setUp. + // Chain B is a fresh namespace we fully configure but never set maxStaleness on. + string memory chainBNs = "eip155:999"; + + PRC20 bImpl = new PRC20(); + bytes memory initData = abi.encodeWithSelector( + PRC20.initialize.selector, + "B PRC20", + "BPRC20", + 18, + chainBNs, + IPRC20.TokenType.ERC20, + address(universalCore), + SOURCE_TOKEN_ADDRESS + ); + address proxyAddr = deployUpgradeableContract(address(bImpl), initData); + PRC20 bPRC20 = PRC20(payable(proxyAddr)); + + vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateGasTokenPRC20(chainBNs, address(mockPRC20)); + universalCore.setChainMeta(chainBNs, GAS_PRICE, 0); + universalCore.updateBaseGasLimitByChain(chainBNs, BASE_GAS_LIMIT); + // Configure maxStaleness only on chain A. + universalCore.updateMaxStalenessByChain(CHAIN_NAMESPACE, 300); + vm.stopPrank(); + + // Warp past A's window. + uint256 observedAt = universalCore.timestampObservedAtByChainNamespace(CHAIN_NAMESPACE); + vm.warp(observedAt + 300 + 1); + + // Chain A reverts (maxStaleness enforced). + vm.expectRevert( + abi.encodeWithSelector(UniversalCoreErrors.StaleGasData.selector, observedAt, block.timestamp, uint256(300)) + ); + universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + + // Chain B succeeds (no maxStaleness set for chainBNs). + (, uint256 gasFee,,,,) = universalCore.getOutboundTxGasAndFees(address(bPRC20), 0); + assertGt(gasFee, 0, "chain B should not be affected by chain A's staleness config"); + } + + // --- Regression: revert ordering (staleness is last) --- + + function test_StalenessCheck_DoesNotAffectExistingRevertPaths() public { + // Fresh namespace with maxStaleness set but no gas price. The ZeroGasPrice + // revert must fire before the staleness check is reached. + string memory freshNs = "revert-order-test"; + + PRC20 freshImpl = new PRC20(); + bytes memory initData = abi.encodeWithSelector( + PRC20.initialize.selector, + "Fresh PRC20", + "FPRC20", + 18, + freshNs, + IPRC20.TokenType.ERC20, + address(universalCore), + SOURCE_TOKEN_ADDRESS + ); + address proxyAddr = deployUpgradeableContract(address(freshImpl), initData); + PRC20 freshPRC20 = PRC20(payable(proxyAddr)); + + vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateGasTokenPRC20(freshNs, address(mockPRC20)); + universalCore.updateBaseGasLimitByChain(freshNs, 100_000); + universalCore.updateMaxStalenessByChain(freshNs, 60); + // No setChainMeta → gasPrice is 0 → ZeroGasPrice revert must come before staleness. + vm.stopPrank(); + + vm.expectRevert(UniversalCoreErrors.ZeroGasPrice.selector); + universalCore.getOutboundTxGasAndFees(address(freshPRC20), 0); + } + + // ========================= + // PRC20 Return Value Check Tests + // ========================= + + function test_DepositPRC20Token_FalseReturn_Reverts() public { + FalseReturningPRC20 falseToken = new FalseReturningPRC20(CHAIN_NAMESPACE, SOURCE_TOKEN_ADDRESS); + + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + vm.expectRevert(UniversalCoreErrors.PRC20OperationFailed.selector); + universalCore.depositPRC20Token(address(falseToken), 1000, makeAddr("target")); + } + + function test_RefundUnusedGas_FalseDeposit_Reverts() public { + FalseReturningPRC20 falseToken = new FalseReturningPRC20(CHAIN_NAMESPACE, SOURCE_TOKEN_ADDRESS); + + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + vm.expectRevert(UniversalCoreErrors.PRC20OperationFailed.selector); + universalCore.refundUnusedGas(address(falseToken), 1000, makeAddr("target"), false, 0, 0); + } + + // ========================= + // Rescue Native PC Tests + // ========================= + + function test_RescueNativePC_HappyPath() public { + address payable recipient = payable(makeAddr("rescueRecipient")); + uint256 stuckAmount = 1 ether; + vm.deal(address(universalCore), stuckAmount); + + vm.expectEmit(true, false, false, true); + emit RescueNativePC(recipient, stuckAmount); + + universalCore.rescueNativePC(recipient, stuckAmount); + + assertEq(address(universalCore).balance, 0); + assertEq(recipient.balance, stuckAmount); + } + + function test_RescueNativePC_OnlyUCoreAdmin() public { + vm.deal(address(universalCore), 1 ether); + address nonAdmin = makeAddr("nonAdmin"); + bytes32 ucoreAdminRole = universalCore.UVCORE_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonAdmin, ucoreAdminRole) + ); + vm.prank(nonAdmin); + universalCore.rescueNativePC(payable(nonAdmin), 1 ether); + } + + function test_RescueNativePC_ZeroAddressReverts() public { + vm.deal(address(universalCore), 1 ether); + vm.expectRevert(CommonErrors.ZeroAddress.selector); + universalCore.rescueNativePC(payable(address(0)), 1 ether); + } + + function test_RescueNativePC_ZeroAmountReverts() public { + vm.deal(address(universalCore), 1 ether); + vm.expectRevert(CommonErrors.ZeroAmount.selector); + universalCore.rescueNativePC(payable(makeAddr("r")), 0); + } + + function test_RescueNativePC_InsufficientBalanceReverts() public { + vm.deal(address(universalCore), 0.5 ether); + vm.expectRevert(CommonErrors.InsufficientBalance.selector); + universalCore.rescueNativePC(payable(makeAddr("r")), 1 ether); + } + + function test_RescueNativePC_TransferToNonPayableReverts() public { + vm.deal(address(universalCore), 1 ether); + RevertingTarget nonPayable = new RevertingTarget(); + vm.expectRevert(CommonErrors.TransferFailed.selector); + universalCore.rescueNativePC(payable(address(nonPayable)), 1 ether); + } + + // ========================================================================= + // ADR (AccessControlDefaultAdminRules) Tests + // ========================================================================= + + function testADR_OwnerReturnsAdmin() public view { + assertEq(universalCore.owner(), deployer); + } + + function testADR_DefaultAdminDelay() public view { + assertEq(universalCore.defaultAdminDelay(), 1 days); + } + + function testADR_RoleAdminOfUCoreAdmin_IsRoleManager() public view { + assertEq(universalCore.getRoleAdmin(universalCore.UVCORE_ADMIN_ROLE()), universalCore.ROLE_MANAGER_ROLE()); + } + + function testADR_RoleAdminOfOperator_IsRoleManager() public view { + assertEq(universalCore.getRoleAdmin(universalCore.OPERATOR_ROLE()), universalCore.ROLE_MANAGER_ROLE()); + } + + function testADR_RoleAdminOfPauser_IsRoleManager() public view { + assertEq(universalCore.getRoleAdmin(universalCore.PAUSER_ROLE()), universalCore.ROLE_MANAGER_ROLE()); + } + + function testADR_RoleAdminOfRoleManager_IsDefaultAdmin() public view { + assertEq(universalCore.getRoleAdmin(universalCore.ROLE_MANAGER_ROLE()), universalCore.DEFAULT_ADMIN_ROLE()); + } + + function testADR_GrantDefaultAdminRole_Reverts() public { + bytes32 defaultAdminRole = universalCore.DEFAULT_ADMIN_ROLE(); + address newAdmin = makeAddr("adrNewAdmin"); + + vm.expectRevert(IAccessControlDefaultAdminRules.AccessControlEnforcedDefaultAdminRules.selector); + universalCore.grantRole(defaultAdminRole, newAdmin); + } + + function testADR_TransferFlow() public { + address newAdmin = makeAddr("adrNewAdmin"); + + universalCore.beginDefaultAdminTransfer(newAdmin); + + (address pendingAdmin, uint48 schedule) = universalCore.pendingDefaultAdmin(); + assertEq(pendingAdmin, newAdmin); + assertTrue(schedule > 0); + + // Cannot accept before delay + vm.expectRevert(); + vm.prank(newAdmin); + universalCore.acceptDefaultAdminTransfer(); + + // Warp past delay and accept + vm.warp(block.timestamp + 1 days + 1); + vm.prank(newAdmin); + universalCore.acceptDefaultAdminTransfer(); + + assertEq(universalCore.owner(), newAdmin); + assertTrue(universalCore.hasRole(universalCore.DEFAULT_ADMIN_ROLE(), newAdmin)); + assertFalse(universalCore.hasRole(universalCore.DEFAULT_ADMIN_ROLE(), deployer)); + } + + function testADR_GrantRoleManager() public { + address newRoleManager = makeAddr("newRoleManager"); + + universalCore.grantRole(universalCore.ROLE_MANAGER_ROLE(), newRoleManager); + assertTrue(universalCore.hasRole(universalCore.ROLE_MANAGER_ROLE(), newRoleManager)); + + // newRoleManager can now grant UVCORE_ADMIN_ROLE + address newUCoreAdmin = makeAddr("newUCoreAdmin"); + vm.prank(newRoleManager); + universalCore.grantRole(universalCore.UVCORE_ADMIN_ROLE(), newUCoreAdmin); + assertTrue(universalCore.hasRole(universalCore.UVCORE_ADMIN_ROLE(), newUCoreAdmin)); + } + + function testPauserCannotUnpause() public { + vm.prank(pauser); + universalCore.pause(); + + bytes32 operatorRole = universalCore.OPERATOR_ROLE(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, pauser, operatorRole) + ); + vm.prank(pauser); + universalCore.unpause(); + } + + // ============================================ + // L1 GAS FEE & TSS MIGRATION GAS LIMIT + // ============================================ + + function test_SetL1GasFeeByChain_HappyPath() public { + uint256 l1Fee = 0.001 ether; + + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + vm.expectEmit(false, false, false, true); + emit SetL1GasFeeByChain(CHAIN_NAMESPACE, l1Fee); + universalCore.setL1GasFeeByChain(CHAIN_NAMESPACE, l1Fee); + + assertEq(universalCore.l1GasFeeByChainNamespace(CHAIN_NAMESPACE), l1Fee); + } + + function test_SetL1GasFeeByChain_OnlyUVCoreAdmin() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, universalCore.UVCORE_ADMIN_ROLE() + ) + ); + vm.prank(nonOwner); + universalCore.setL1GasFeeByChain(CHAIN_NAMESPACE, 0.001 ether); + } + + function test_SetL1GasFeeByChain_ZeroValueAllowed() public { + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.setL1GasFeeByChain(CHAIN_NAMESPACE, 0); + assertEq(universalCore.l1GasFeeByChainNamespace(CHAIN_NAMESPACE), 0); + } + + function test_SetTssFundMigrationGasLimitByChain_HappyPath() public { + uint256 gasLimit = 1_000_000; + + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + vm.expectEmit(false, false, false, true); + emit SetTssFundMigrationGasLimitByChain(CHAIN_NAMESPACE, gasLimit); + universalCore.setTssFundMigrationGasLimitByChain(CHAIN_NAMESPACE, gasLimit); + + assertEq(universalCore.tssFundMigrationGasLimitByChainNamespace(CHAIN_NAMESPACE), gasLimit); + } + + function test_SetTssFundMigrationGasLimitByChain_OnlyUVCoreAdmin() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, universalCore.UVCORE_ADMIN_ROLE() + ) + ); + vm.prank(nonOwner); + universalCore.setTssFundMigrationGasLimitByChain(CHAIN_NAMESPACE, 1_000_000); + } + + function test_SetTssFundMigrationGasLimitByChain_ZeroValueAllowed() public { + vm.prank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.setTssFundMigrationGasLimitByChain(CHAIN_NAMESPACE, 0); + assertEq(universalCore.tssFundMigrationGasLimitByChainNamespace(CHAIN_NAMESPACE), 0); } } diff --git a/test/tests_token_and_core/UniversalCoreRefund.t.sol b/test/tests_token_and_core/UniversalCoreRefund.t.sol index 19a003c..18dcf58 100644 --- a/test/tests_token_and_core/UniversalCoreRefund.t.sol +++ b/test/tests_token_and_core/UniversalCoreRefund.t.sol @@ -9,7 +9,6 @@ import {UniversalCoreErrors, CommonErrors} from "../../src/libraries/Errors.sol" import "../../test/helpers/UpgradeableContractHelper.sol"; import "../../test/mocks/MockUniswapV3Factory.sol"; import "../../test/mocks/MockUniswapV3Router.sol"; -import "../../test/mocks/MockUniswapV3Quoter.sol"; import "../../test/mocks/MockPRC20.sol"; import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; @@ -52,7 +51,6 @@ contract UniversalCoreRefundTest is Test, UpgradeableContractHelper { UniversalCore public universalCore; MockUniswapV3Factory public mockFactory; MockUniswapV3Router public mockRouter; - MockUniswapV3Quoter public mockQuoter; MockWPCLike public mockWPC; MockPRC20 public gasTokenMock; @@ -81,7 +79,6 @@ contract UniversalCoreRefundTest is Test, UpgradeableContractHelper { mockFactory = new MockUniswapV3Factory(); mockRouter = new MockUniswapV3Router(); - mockQuoter = new MockUniswapV3Quoter(); mockWPC = new MockWPCLike(); gasTokenMock = new MockPRC20(); @@ -91,19 +88,18 @@ contract UniversalCoreRefundTest is Test, UpgradeableContractHelper { UniversalCore implementation = new UniversalCore(); bytes memory initData = abi.encodeWithSelector( UniversalCore.initialize.selector, + address(this), + pauser, address(mockWPC), address(mockFactory), - address(mockRouter), - address(mockQuoter), - pauser + address(mockRouter) ); address proxyAddress = deployUpgradeableContract(address(implementation), initData); universalCore = UniversalCore(payable(proxyAddress)); // Configure auto-swap support - universalCore.setAutoSwapSupported(address(gasTokenMock), true); - universalCore.setDefaultFeeTier(address(gasTokenMock), FEE_TIER); - universalCore.setSlippageTolerance(address(gasTokenMock), 300); + universalCore.updateAutoSwapSupported(address(gasTokenMock), true); + universalCore.updateDefaultFeeTier(address(gasTokenMock), FEE_TIER); // Setup mock pool (gasToken <-> wPC) address pool = makeAddr("mockPool"); @@ -183,8 +179,8 @@ contract UniversalCoreRefundTest is Test, UpgradeableContractHelper { function test_RefundUnusedGas_WithSwap_NoPool_Reverts() public { MockPRC20 noPool = new MockPRC20(); - universalCore.setAutoSwapSupported(address(noPool), true); - universalCore.setDefaultFeeTier(address(noPool), FEE_TIER); + universalCore.updateAutoSwapSupported(address(noPool), true); + universalCore.updateDefaultFeeTier(address(noPool), FEE_TIER); vm.prank(UNIVERSAL_EXECUTOR_MODULE); vm.expectRevert(UniversalCoreErrors.PoolNotFound.selector); diff --git a/test/tests_token_and_core/UniversalCoreSwapFee.t.sol b/test/tests_token_and_core/UniversalCoreSwapFee.t.sol index 104dc1c..ac16aba 100644 --- a/test/tests_token_and_core/UniversalCoreSwapFee.t.sol +++ b/test/tests_token_and_core/UniversalCoreSwapFee.t.sol @@ -10,9 +10,9 @@ import {UniversalCoreErrors, CommonErrors} from "../../src/libraries/Errors.sol" import "../../test/helpers/UpgradeableContractHelper.sol"; import "../../test/mocks/MockUniswapV3Factory.sol"; import "../../test/mocks/MockUniswapV3Router.sol"; -import "../../test/mocks/MockUniswapV3Quoter.sol"; import "../../test/mocks/MockWPC.sol"; import "../../test/mocks/MockPRC20.sol"; +import "../../test/mocks/FalseReturningPRC20.sol"; import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; @@ -21,7 +21,6 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { PRC20 public prc20Token; MockUniswapV3Factory public mockFactory; MockUniswapV3Router public mockRouter; - MockUniswapV3Quoter public mockQuoter; MockWPC public mockWPC; MockPRC20 public gasTokenMock; @@ -53,7 +52,6 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { mockFactory = new MockUniswapV3Factory(); mockRouter = new MockUniswapV3Router(); - mockQuoter = new MockUniswapV3Quoter(); mockWPC = new MockWPC(); gasTokenMock = new MockPRC20(); @@ -76,11 +74,11 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { UniversalCore implementation = new UniversalCore(); bytes memory initData = abi.encodeWithSelector( UniversalCore.initialize.selector, + deployer, + pauser, address(mockWPC), address(mockFactory), - address(mockRouter), - address(mockQuoter), - pauser + address(mockRouter) ); address proxyAddress = deployUpgradeableContract(address(implementation), initData); universalCore = UniversalCore(payable(proxyAddress)); @@ -90,22 +88,22 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { prc20Token.updateUniversalCore(address(universalCore)); // Set gateway - universalCore.setUniversalGatewayPC(gateway); + universalCore.updateUniversalGatewayPC(gateway); // Grant MANAGER_ROLE to UE Module for manager functions - universalCore.grantRole(universalCore.MANAGER_ROLE(), UNIVERSAL_EXECUTOR_MODULE); + universalCore.grantRole(universalCore.UVCORE_ADMIN_ROLE(), UNIVERSAL_EXECUTOR_MODULE); - // Configure gas token, gas price, base gas limit, and protocol fee + // Configure gas token first, then gas price (updateGasTokenPRC20 resets gas price to 0, + // so setChainMeta must come after to preserve the configured gas price). vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateGasTokenPRC20(CHAIN_NAMESPACE, address(gasTokenMock)); universalCore.setChainMeta(CHAIN_NAMESPACE, GAS_PRICE, 0); - universalCore.setGasTokenPRC20(CHAIN_NAMESPACE, address(gasTokenMock)); - universalCore.setBaseGasLimitByChain(CHAIN_NAMESPACE, 500_000); - universalCore.setProtocolFeeByToken(address(prc20Token), PROTOCOL_FEE); + universalCore.updateBaseGasLimitByChain(CHAIN_NAMESPACE, 500_000); + universalCore.updateProtocolFeeByToken(address(prc20Token), PROTOCOL_FEE); vm.stopPrank(); - // Set default fee tier and slippage for gas token - universalCore.setDefaultFeeTier(address(gasTokenMock), FEE_TIER); - universalCore.setSlippageTolerance(address(gasTokenMock), 300); + // Set default fee tier for gas token + universalCore.updateDefaultFeeTier(address(gasTokenMock), FEE_TIER); // Setup mock pool (wPC <-> gasToken) address pool = makeAddr("mockPool"); @@ -141,7 +139,7 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { address newGateway = makeAddr("newGateway"); vm.deal(newGateway, 1 ether); - universalCore.setUniversalGatewayPC(newGateway); + universalCore.updateUniversalGatewayPC(newGateway); vm.prank(newGateway); (uint256 gasTokenOut,) = @@ -245,8 +243,8 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { // Setup BSC chain MockPRC20 bscGasToken = new MockPRC20(); vm.prank(UNIVERSAL_EXECUTOR_MODULE); - universalCore.setGasTokenPRC20("eip155:56", address(bscGasToken)); - universalCore.setDefaultFeeTier(address(bscGasToken), FEE_TIER); + universalCore.updateGasTokenPRC20("eip155:56", address(bscGasToken)); + universalCore.updateDefaultFeeTier(address(bscGasToken), FEE_TIER); // Setup BSC pool address bscPool = makeAddr("bscPool"); @@ -348,27 +346,41 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { // 6) getOutboundTxGasAndFees // ======================================== - function test_WithdrawGasFee_Returns5Values() public view { - (address gasToken, uint256 gasFee, uint256 protocolFee, uint256 gasPrice, string memory chainNamespace) = - universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); + function test_WithdrawGasFee_Returns6Values() public view { + ( + address gasToken, + uint256 gasFee, + uint256 protocolFee, + uint256 gasPrice, + string memory chainNamespace, + uint256 gasLimitUsed + ) = universalCore.getOutboundTxGasAndFees(address(prc20Token), 0); assertEq(gasToken, address(gasTokenMock)); assertEq(gasPrice, GAS_PRICE); assertEq(gasFee, gasPrice * universalCore.baseGasLimitByChainNamespace(CHAIN_NAMESPACE)); assertEq(protocolFee, universalCore.protocolFeeByToken(address(prc20Token))); assertEq(keccak256(bytes(chainNamespace)), keccak256(bytes(CHAIN_NAMESPACE))); + assertEq(gasLimitUsed, universalCore.baseGasLimitByChainNamespace(CHAIN_NAMESPACE)); } - function test_WithdrawGasFeeWithGasLimit_Returns5Values() public view { + function test_WithdrawGasFeeWithGasLimit_Returns6Values() public view { uint256 customGasLimit = 600_000; - (address gasToken, uint256 gasFee, uint256 protocolFee, uint256 gasPrice, string memory chainNamespace) = - universalCore.getOutboundTxGasAndFees(address(prc20Token), customGasLimit); + ( + address gasToken, + uint256 gasFee, + uint256 protocolFee, + uint256 gasPrice, + string memory chainNamespace, + uint256 gasLimitUsed + ) = universalCore.getOutboundTxGasAndFees(address(prc20Token), customGasLimit); assertEq(gasToken, address(gasTokenMock)); assertEq(gasPrice, GAS_PRICE); assertEq(gasFee, gasPrice * customGasLimit); assertEq(protocolFee, PROTOCOL_FEE); assertEq(keccak256(bytes(chainNamespace)), keccak256(bytes(CHAIN_NAMESPACE))); + assertEq(gasLimitUsed, customGasLimit); } // ======================================== @@ -382,12 +394,11 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { function test_ExistingStorage_Preserved() public view { assertEq(universalCore.gasPriceByChainNamespace(CHAIN_NAMESPACE), GAS_PRICE); assertEq(universalCore.gasTokenPRC20ByChainNamespace(CHAIN_NAMESPACE), address(gasTokenMock)); - assertTrue(universalCore.isSupportedToken(address(gasTokenMock)) == false); assertEq(universalCore.baseGasLimitByChainNamespace(CHAIN_NAMESPACE), 500_000); } // ======================================== - // 8) setProtocolFeeByToken + // 8) updateProtocolFeeByToken // ======================================== function test_SetProtocolFeeByToken_HappyPath() public { @@ -397,7 +408,7 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { vm.prank(UNIVERSAL_EXECUTOR_MODULE); vm.expectEmit(true, false, false, true); emit SetProtocolFeeByToken(token, fee); - universalCore.setProtocolFeeByToken(token, fee); + universalCore.updateProtocolFeeByToken(token, fee); assertEq(universalCore.protocolFeeByToken(token), fee); } @@ -408,16 +419,42 @@ contract UniversalCoreSwapFeeTest is Test, UpgradeableContractHelper { vm.expectRevert( abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, nonManager, universalCore.MANAGER_ROLE() + IAccessControl.AccessControlUnauthorizedAccount.selector, nonManager, universalCore.UVCORE_ADMIN_ROLE() ) ); vm.prank(nonManager); - universalCore.setProtocolFeeByToken(token, 1000); + universalCore.updateProtocolFeeByToken(token, 1000); } function test_SetProtocolFeeByToken_ZeroAddressReverts() public { vm.prank(UNIVERSAL_EXECUTOR_MODULE); vm.expectRevert(CommonErrors.ZeroAddress.selector); - universalCore.setProtocolFeeByToken(address(0), 1000); + universalCore.updateProtocolFeeByToken(address(0), 1000); + } + + // ======================================== + // PRC20 Return Value Check Tests + // ======================================== + + function test_SwapAndBurnGas_FalseBurnReturn_Reverts() public { + FalseReturningPRC20 falseGasToken = new FalseReturningPRC20(CHAIN_NAMESPACE, SOURCE_TOKEN_ADDRESS); + + vm.startPrank(UNIVERSAL_EXECUTOR_MODULE); + universalCore.updateGasTokenPRC20(CHAIN_NAMESPACE, address(falseGasToken)); + universalCore.setChainMeta(CHAIN_NAMESPACE, GAS_PRICE, 0); + vm.stopPrank(); + + universalCore.updateDefaultFeeTier(address(falseGasToken), FEE_TIER); + + address pool = makeAddr("falsePool"); + if (address(mockWPC) < address(falseGasToken)) { + mockFactory.setPool(address(mockWPC), address(falseGasToken), FEE_TIER, pool); + } else { + mockFactory.setPool(address(falseGasToken), address(mockWPC), FEE_TIER, pool); + } + + vm.prank(gateway); + vm.expectRevert(UniversalCoreErrors.PRC20OperationFailed.selector); + universalCore.swapAndBurnGas{value: 1 ether}(address(falseGasToken), FEE_TIER, GAS_FEE, 0, user); } } diff --git a/test/tests_ueaMigration/BaseTest.t.sol b/test/tests_ueaMigration/BaseTest.t.sol index 79b747b..298dd02 100644 --- a/test/tests_ueaMigration/BaseTest.t.sol +++ b/test/tests_ueaMigration/BaseTest.t.sol @@ -174,12 +174,13 @@ contract BaseTest is Test { UEAFactory factoryImpl = new UEAFactory(); - bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, deployer, makeAddr("pauser")); + bytes memory initData = + abi.encodeWithSelector(UEAFactory.initialize.selector, deployer, makeAddr("pauser"), "42101"); ERC1967Proxy factoryProxy = new ERC1967Proxy(address(factoryImpl), initData); factory = UEAFactory(address(factoryProxy)); // Set UEA proxy implementation in factory - factory.setUEAProxyImplementation(address(ueaProxyImpl)); + factory.updateUEAProxyImplementation(address(ueaProxyImpl)); } function _deployMigrationContract() internal { @@ -189,7 +190,7 @@ contract BaseTest is Test { assertEq(migration.UEA_SVM_IMPLEMENTATION(), address(ueaSVMImplV2), "Migration SVM implementation mismatch"); // Set migration contract in factory so UEAs can fetch it - factory.setUEAMigrationContract(address(migration)); + factory.updateUEAMigrationContract(address(migration)); } function _setupChainRegistrations() internal { diff --git a/test/tests_uea_and_factory/UEAFactory.t.sol b/test/tests_uea_and_factory/UEAFactory.t.sol index 3fa4806..df9b638 100644 --- a/test/tests_uea_and_factory/UEAFactory.t.sol +++ b/test/tests_uea_and_factory/UEAFactory.t.sol @@ -11,7 +11,11 @@ import {UEA_SVM} from "../../src/uea/UEA_SVM.sol"; import {UEAMigration} from "../../src/uea/UEAMigration.sol"; import {UEAErrors as Errors} from "../../src/libraries/Errors.sol"; import {IUEA} from "../../src/interfaces/IUEA.sol"; +import {IUEAFactory} from "../../src/Interfaces/IUEAFactory.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import { + IAccessControlDefaultAdminRules +} from "@openzeppelin/contracts/access/extensions/IAccessControlDefaultAdminRules.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {UEAProxy} from "../../src/uea/UEAProxy.sol"; @@ -58,13 +62,13 @@ contract UEAFactoryTest is Test { // Deploy the factory implementation UEAFactory factoryImpl = new UEAFactory(); - // Deploy and initialize the proxy with initialOwner and initialPauser - bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, deployer, pauser); + // Deploy and initialize the proxy with initialOwner, initialPauser, and pushChainId + bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, deployer, pauser, "42101"); ERC1967Proxy proxy = new ERC1967Proxy(address(factoryImpl), initData); factory = UEAFactory(address(proxy)); // Set UEAProxy implementation after initialization - factory.setUEAProxyImplementation(ueaProxyImpl); + factory.updateUEAProxyImplementation(ueaProxyImpl); // Set up user and keys (owner,) = makeAddrAndKey("owner"); @@ -111,33 +115,35 @@ contract UEAFactoryTest is Test { } function testRegisterUEA() public { - bytes32 chainHash = keccak256(abi.encode("KOVAN", "42")); - factory.registerNewChain(chainHash, EVM_HASH); - factory.registerUEA(chainHash, EVM_HASH, address(ueaEVMImpl)); + // Use a fresh VM hash so there is no prior implementation registered for it. + bytes32 moveChainHash = keccak256(abi.encode("APTOS", "1")); + factory.registerNewChain(moveChainHash, MOVE_VM_HASH); + + UEA_EVM moveImpl = new UEA_EVM(); + factory.registerUEA(moveChainHash, MOVE_VM_HASH, address(moveImpl)); // Check that the UEA implementation is registered - assertEq(factory.getUEA(chainHash), address(ueaEVMImpl)); + assertEq(factory.getUEA(moveChainHash), address(moveImpl)); } - function testSetUEAMigrationContractOnlyOwner() public { + function testSetUEAMigrationContractOnlyUEAAdmin() public { UEAMigration migration = new UEAMigration(address(ueaEVMImpl), address(ueaSVMImpl)); - // Non-owner should revert - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 ueaAdminRole = factory.UEA_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ueaAdminRole) ); vm.prank(nonOwner); - factory.setUEAMigrationContract(address(migration)); + factory.updateUEAMigrationContract(address(migration)); - // Owner can set and value is stored - factory.setUEAMigrationContract(address(migration)); + // Owner (has UEA_ADMIN_ROLE) can set and value is stored + factory.updateUEAMigrationContract(address(migration)); assertEq(factory.UEA_MIGRATION_CONTRACT(), address(migration)); } function testSetUEAMigrationContractZeroAddressReverts() public { vm.expectRevert(Errors.InvalidInputArgs.selector); - factory.setUEAMigrationContract(address(0)); + factory.updateUEAMigrationContract(address(0)); } function testRegisterMultipleUEA() public { @@ -145,15 +151,19 @@ contract UEAFactoryTest is Test { bytes32[] memory vmHashes = new bytes32[](2); address[] memory implementations = new address[](2); - // Use different chains than those in setUp - chainHashes[0] = keccak256(abi.encode("KOVAN", "42")); - chainHashes[1] = keccak256(abi.encode("METIS", "1088")); + // Use distinct VM hashes that have no prior implementation registered. + // MOVE_VM_HASH and WASM_VM_HASH are never registered in setUp. + chainHashes[0] = keccak256(abi.encode("APTOS", "1")); + chainHashes[1] = keccak256(abi.encode("NEAR", "mainnet")); + + vmHashes[0] = MOVE_VM_HASH; + vmHashes[1] = WASM_VM_HASH; - vmHashes[0] = EVM_HASH; - vmHashes[1] = EVM_HASH; + UEA_EVM moveImpl = new UEA_EVM(); + UEA_EVM wasmImpl = new UEA_EVM(); - implementations[0] = address(ueaEVMImpl); - implementations[1] = address(ueaEVMImpl); + implementations[0] = address(moveImpl); + implementations[1] = address(wasmImpl); // Register chains first factory.registerNewChain(chainHashes[0], vmHashes[0]); @@ -314,9 +324,9 @@ contract UEAFactoryTest is Test { // Use eip155 chain which is already registered in setUp address initialImpl = factory.getUEA(ethereumChainHash); - // Deploy a new implementation + // Deploy a new implementation and update via the dedicated update path. UEA_EVM newImpl = new UEA_EVM(); - factory.registerUEA(ethereumChainHash, EVM_HASH, address(newImpl)); + factory.updateUEAImplementation(EVM_HASH, address(newImpl)); // Check that the implementation was updated assertNotEq(factory.getUEA(ethereumChainHash), initialImpl); @@ -394,36 +404,34 @@ contract UEAFactoryTest is Test { function testSetUEAProxyImplementation_RevertsOnZeroAddress() public { vm.expectRevert(Errors.InvalidInputArgs.selector); - factory.setUEAProxyImplementation(address(0)); + factory.updateUEAProxyImplementation(address(0)); } - function testSetUEAProxyImplementation_OnlyOwner() public { - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + function testSetUEAProxyImplementation_OnlyUEAAdmin() public { + bytes32 ueaAdminRole = factory.UEA_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ueaAdminRole) ); vm.prank(nonOwner); - factory.setUEAProxyImplementation(address(ueaEVMImpl)); + factory.updateUEAProxyImplementation(address(ueaEVMImpl)); } function testOwnershipFunctions() public { - // Test that only owner can register implementations - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); + bytes32 ueaAdminRole = factory.UEA_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ueaAdminRole) ); vm.prank(nonOwner); bytes32 chainHash = keccak256(abi.encode("APTOS", "1")); factory.registerNewChain(chainHash, MOVE_VM_HASH); - // Test that owner can register implementations + // Owner (has UEA_ADMIN_ROLE) can register factory.registerNewChain(chainHash, MOVE_VM_HASH); UEA_EVM newImpl = new UEA_EVM(); factory.registerUEA(chainHash, MOVE_VM_HASH, address(newImpl)); - // Verify the implementation was registered assertEq(address(factory.getUEA(chainHash)), address(newImpl)); } @@ -501,34 +509,26 @@ contract UEAFactoryTest is Test { assertTrue(ethUEA != polyUEA); } - function testOwnershipTransfer() public { - address newOwner = makeAddr("newOwner"); - - // Grant DEFAULT_ADMIN_ROLE to new owner, then revoke from old owner - factory.grantRole(factory.DEFAULT_ADMIN_ROLE(), newOwner); - factory.revokeRole(factory.DEFAULT_ADMIN_ROLE(), address(this)); - - // Verify new owner has role, old does not - assertTrue(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), newOwner)); - assertFalse(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), address(this))); + function testOwnershipTransfer_ADR() public { + address newAdmin = makeAddr("newAdmin"); - // Try to register a chain with old owner — should fail - bytes32 chainHash = keccak256(abi.encode("TestChain", "123")); - vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), factory.DEFAULT_ADMIN_ROLE() - ) - ); - factory.registerNewChain(chainHash, MOVE_VM_HASH); + // ADR blocks direct grantRole for DEFAULT_ADMIN_ROLE + // Cache role before vm.expectRevert — argument evaluation is a staticcall that + // would otherwise be consumed as the "next call" by vm.expectRevert. + bytes32 defaultAdminRole = factory.DEFAULT_ADMIN_ROLE(); + vm.expectRevert(IAccessControlDefaultAdminRules.AccessControlEnforcedDefaultAdminRules.selector); + factory.grantRole(defaultAdminRole, newAdmin); - // New owner should be able to register a chain - vm.prank(newOwner); - factory.registerNewChain(chainHash, MOVE_VM_HASH); + // Must use 2-step transfer: begin → wait → accept + // OZ _hasSchedulePassed uses strict "<", so warp must be > schedule, not ==. + factory.beginDefaultAdminTransfer(newAdmin); + vm.warp(block.timestamp + 1 days + 1); + vm.prank(newAdmin); + factory.acceptDefaultAdminTransfer(); - // Verify chain is registered - (bytes32 vmHash, bool isRegistered) = factory.getVMType(chainHash); - assertEq(vmHash, MOVE_VM_HASH); - assertTrue(isRegistered); + assertTrue(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), newAdmin)); + assertFalse(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), address(this))); + assertEq(factory.owner(), newAdmin); } function testFactoryLifecycle() public { @@ -606,9 +606,8 @@ contract UEAFactoryTest is Test { // Deploy a new implementation UEA_EVM newImpl = new UEA_EVM(); - // Change implementation for EVM type - bytes32 evmChainHash = keccak256(abi.encode("eip155", "1")); - factory.registerUEA(evmChainHash, EVM_HASH, address(newImpl)); + // Change implementation for EVM type via the dedicated update path. + factory.updateUEAImplementation(EVM_HASH, address(newImpl)); // Verify implementation has changed address updatedImpl = factory.getUEA(chainHash); @@ -818,7 +817,7 @@ contract UEAFactoryTest is Test { function testComputeUEA_RevertsWhenNoProxyImplementation() public { // Deploy a fresh factory without proxy implementation UEAFactory freshFactoryImpl = new UEAFactory(); - bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), pauser); + bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), pauser, "42101"); ERC1967Proxy freshProxy = new ERC1967Proxy(address(freshFactoryImpl), initData); UEAFactory freshFactory = UEAFactory(address(freshProxy)); @@ -838,7 +837,7 @@ contract UEAFactoryTest is Test { function testDeployUEA_RevertsWhenNoProxyImplementation() public { // Deploy a fresh factory without proxy implementation UEAFactory freshFactoryImpl = new UEAFactory(); - bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), pauser); + bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), pauser, "42101"); ERC1967Proxy freshProxy = new ERC1967Proxy(address(freshFactoryImpl), initData); UEAFactory freshFactory = UEAFactory(address(freshProxy)); @@ -998,16 +997,24 @@ contract UEAFactoryTest is Test { assertTrue(factory.paused()); } - function testUnpause_OnlyPauser() public { - bytes32 role = factory.PAUSER_ROLE(); + function testUnpause_OnlyOperator() public { + bytes32 operatorRole = factory.OPERATOR_ROLE(); vm.prank(pauser); factory.pause(); + // nonOwner cannot unpause vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, role) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, operatorRole) ); vm.prank(nonOwner); factory.unpause(); + + // pauser also cannot unpause (different role now) + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, pauser, operatorRole) + ); + vm.prank(pauser); + factory.unpause(); } function testUnpause_HappyPath() public { @@ -1015,7 +1022,7 @@ contract UEAFactoryTest is Test { factory.pause(); assertTrue(factory.paused()); - vm.prank(pauser); + // deployer has OPERATOR_ROLE factory.unpause(); assertFalse(factory.paused()); } @@ -1035,7 +1042,7 @@ contract UEAFactoryTest is Test { function testDeployUEA_AfterUnpause_Works() public { vm.prank(pauser); factory.pause(); - vm.prank(pauser); + // deployer has OPERATOR_ROLE factory.unpause(); bytes memory testOwnerBytes = abi.encodePacked(makeAddr("unpausedOwner")); @@ -1046,29 +1053,25 @@ contract UEAFactoryTest is Test { assertTrue(factory.hasCode(ueaAddress)); } - function testSetPauserRole_OnlyOwner() public { + function testGrantPauserRole_OnlyRoleManager() public { address newPauser = makeAddr("newPauser"); + bytes32 roleManagerRole = factory.ROLE_MANAGER_ROLE(); + bytes32 pauserRole = factory.PAUSER_ROLE(); - bytes32 adminRole = factory.DEFAULT_ADMIN_ROLE(); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, adminRole) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, roleManagerRole) ); vm.prank(nonOwner); - factory.setPauserRole(newPauser); + factory.grantRole(pauserRole, newPauser); - // Admin can grant pauser role - factory.setPauserRole(newPauser); - assertTrue(factory.hasRole(factory.PAUSER_ROLE(), newPauser)); + // deployer has ROLE_MANAGER_ROLE + factory.grantRole(pauserRole, newPauser); + assertTrue(factory.hasRole(pauserRole, newPauser)); } - function testSetPauserRole_ZeroAddressReverts() public { - vm.expectRevert(Errors.InvalidInputArgs.selector); - factory.setPauserRole(address(0)); - } - - function testSetPauserRole_NewPauserCanPause() public { + function testGrantPauserRole_NewPauserCanPause() public { address newPauser = makeAddr("newPauser2"); - factory.setPauserRole(newPauser); + factory.grantRole(factory.PAUSER_ROLE(), newPauser); vm.prank(newPauser); factory.pause(); @@ -1081,11 +1084,67 @@ contract UEAFactoryTest is Test { function testInitialize_ZeroPauserReverts() public { UEAFactory newImpl = new UEAFactory(); - bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, deployer, address(0)); ERC1967Proxy newProxy = new ERC1967Proxy(address(newImpl), ""); vm.expectRevert(Errors.InvalidInputArgs.selector); - UEAFactory(address(newProxy)).initialize(deployer, address(0)); + UEAFactory(address(newProxy)).initialize(deployer, address(0), "42101"); + } + + // ========================================================================= + // updateUEAImplementation Tests + // ========================================================================= + + function testUpdateUEAImplementation_HappyPath() public { + address previousImpl = factory.UEA_VM(EVM_HASH); + assertTrue(previousImpl != address(0)); + + UEA_EVM newImpl = new UEA_EVM(); + vm.expectEmit(true, false, false, true, address(factory)); + emit IUEAFactory.UEAImplementationUpdated(EVM_HASH, previousImpl, address(newImpl)); + + factory.updateUEAImplementation(EVM_HASH, address(newImpl)); + + assertEq(factory.UEA_VM(EVM_HASH), address(newImpl)); + assertNotEq(factory.UEA_VM(EVM_HASH), previousImpl); + } + + function testUpdateUEAImplementation_UpdatesSVMImpl() public { + address previousImpl = factory.UEA_VM(SVM_HASH); + assertTrue(previousImpl != address(0)); + + UEA_SVM newImpl = new UEA_SVM(); + factory.updateUEAImplementation(SVM_HASH, address(newImpl)); + + assertEq(factory.UEA_VM(SVM_HASH), address(newImpl)); + } + + function testUpdateUEAImplementation_OnlyUEAAdmin() public { + UEA_EVM newImpl = new UEA_EVM(); + bytes32 ueaAdminRole = factory.UEA_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ueaAdminRole) + ); + vm.prank(nonOwner); + factory.updateUEAImplementation(EVM_HASH, address(newImpl)); + } + + function testUpdateUEAImplementation_ZeroAddressReverts() public { + vm.expectRevert(Errors.InvalidInputArgs.selector); + factory.updateUEAImplementation(EVM_HASH, address(0)); + } + + function testUpdateUEAImplementation_UnregisteredVmHashReverts() public { + // CAIRO_VM_HASH has never had an implementation registered — no prior entry. + UEA_EVM newImpl = new UEA_EVM(); + vm.expectRevert(Errors.InvalidInputArgs.selector); + factory.updateUEAImplementation(CAIRO_VM_HASH, address(newImpl)); + } + + function testRegisterUEA_AlreadyRegisteredReverts() public { + // EVM_HASH already has an implementation from setUp — a second registerUEA must revert. + vm.expectRevert(Errors.UEAAlreadyRegistered.selector); + factory.registerUEA(ethereumChainHash, EVM_HASH, address(ueaEVMImpl)); } // Test for the case where getOriginForUEA is called with an address that has an owner @@ -1103,4 +1162,167 @@ contract UEAFactoryTest is Test { assertTrue(isUEA); assertTrue(account.owner.length > 0); } + + // ========================================================================= + // F-2026-15576: Configurable pushChainId in getOriginForUEA fallback + // ========================================================================= + + function test_Initialize_SeedsPushChainId() public { + // Fresh proxy deployed with pushChainId seeded via initialize + UEAFactory freshImpl = new UEAFactory(); + bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, deployer, pauser, "9000"); + ERC1967Proxy proxy = new ERC1967Proxy(address(freshImpl), initData); + UEAFactory freshFactory = UEAFactory(address(proxy)); + + assertEq(freshFactory.pushChainId(), "9000"); + } + + function test_Initialize_RevertsOnEmptyPushChainId() public { + UEAFactory freshImpl = new UEAFactory(); + bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, deployer, pauser, ""); + vm.expectRevert(); + new ERC1967Proxy(address(freshImpl), initData); + } + + function test_SetPushChainId_HappyPath() public { + factory.updatePushChainId("9999"); + assertEq(factory.pushChainId(), "9999"); + } + + function test_SetPushChainId_OnlyUEAAdmin() public { + bytes32 ueaAdminRole = factory.UEA_ADMIN_ROLE(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOwner, ueaAdminRole) + ); + vm.prank(nonOwner); + factory.updatePushChainId("9999"); + } + + function test_SetPushChainId_RevertsOnEmptyString() public { + vm.expectRevert(Errors.InvalidInputArgs.selector); + factory.updatePushChainId(""); + } + + function test_GetOriginForUEA_FallbackUsesConfiguredChainId() public { + address randomAddr = makeAddr("random_fallback"); + + (UniversalAccountId memory account, bool isUEA) = factory.getOriginForUEA(randomAddr); + + assertFalse(isUEA); + assertEq(account.chainNamespace, "eip155"); + assertEq(account.chainId, factory.pushChainId()); + assertEq(account.chainId, "42101", "Default seeded value should match setUp"); + assertEq(account.owner, bytes(abi.encodePacked(randomAddr))); + } + + function test_GetOriginForUEA_FallbackUpdatesAfterSetter() public { + address randomAddr = makeAddr("random_update"); + + // Initial: chainId should be "42101" (seeded in setUp) + (UniversalAccountId memory beforeAcc, bool beforeIsUEA) = factory.getOriginForUEA(randomAddr); + assertFalse(beforeIsUEA); + assertEq(beforeAcc.chainId, "42101"); + + // Update pushChainId + factory.updatePushChainId("1"); + + // After update: fallback returns new chainId + (UniversalAccountId memory afterAcc, bool afterIsUEA) = factory.getOriginForUEA(randomAddr); + assertFalse(afterIsUEA); + assertEq(afterAcc.chainNamespace, "eip155", "namespace stays hardcoded eip155"); + assertEq(afterAcc.chainId, "1"); + assertEq(afterAcc.owner, bytes(abi.encodePacked(randomAddr))); + } + + // ========================================================================= + // ADR (AccessControlDefaultAdminRules) Tests + // ========================================================================= + + function testADR_OwnerReturnsAdmin() public view { + assertEq(factory.owner(), deployer); + } + + function testADR_DefaultAdminDelay() public view { + assertEq(factory.defaultAdminDelay(), 1 days); + } + + function testADR_RoleAdminOfUEAAdmin_IsRoleManager() public view { + assertEq(factory.getRoleAdmin(factory.UEA_ADMIN_ROLE()), factory.ROLE_MANAGER_ROLE()); + } + + function testADR_RoleAdminOfOperator_IsRoleManager() public view { + assertEq(factory.getRoleAdmin(factory.OPERATOR_ROLE()), factory.ROLE_MANAGER_ROLE()); + } + + function testADR_RoleAdminOfPauser_IsRoleManager() public view { + assertEq(factory.getRoleAdmin(factory.PAUSER_ROLE()), factory.ROLE_MANAGER_ROLE()); + } + + function testADR_RoleAdminOfRoleManager_IsDefaultAdmin() public view { + assertEq(factory.getRoleAdmin(factory.ROLE_MANAGER_ROLE()), factory.DEFAULT_ADMIN_ROLE()); + } + + function testADR_InitialRolesGrantedToAdmin() public view { + assertTrue(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), deployer)); + assertTrue(factory.hasRole(factory.ROLE_MANAGER_ROLE(), deployer)); + assertTrue(factory.hasRole(factory.UEA_ADMIN_ROLE(), deployer)); + assertTrue(factory.hasRole(factory.OPERATOR_ROLE(), deployer)); + } + + function testADR_PauserRoleGrantedToPauser() public view { + assertTrue(factory.hasRole(factory.PAUSER_ROLE(), pauser)); + assertFalse(factory.hasRole(factory.PAUSER_ROLE(), deployer)); + } + + function testADR_TransferFlow() public { + address newAdmin = makeAddr("adrNewAdmin"); + + factory.beginDefaultAdminTransfer(newAdmin); + + (address pendingAdmin, uint48 schedule) = factory.pendingDefaultAdmin(); + assertEq(pendingAdmin, newAdmin); + assertTrue(schedule > 0); + + // Cannot accept before delay + // vm.expectRevert must come before vm.prank — prank is consumed by the very next call. + vm.expectRevert(); + vm.prank(newAdmin); + factory.acceptDefaultAdminTransfer(); + + // Warp past delay and accept. + // OZ _hasSchedulePassed uses strict "<", so warp must be strictly > schedule. + vm.warp(block.timestamp + 1 days + 1); + vm.prank(newAdmin); + factory.acceptDefaultAdminTransfer(); + + assertEq(factory.owner(), newAdmin); + assertTrue(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), newAdmin)); + assertFalse(factory.hasRole(factory.DEFAULT_ADMIN_ROLE(), deployer)); + } + + function testADR_GrantRoleManager() public { + address newRoleManager = makeAddr("newRoleManager"); + + // deployer has DEFAULT_ADMIN_ROLE which administers ROLE_MANAGER_ROLE + factory.grantRole(factory.ROLE_MANAGER_ROLE(), newRoleManager); + assertTrue(factory.hasRole(factory.ROLE_MANAGER_ROLE(), newRoleManager)); + + // newRoleManager can now grant UEA_ADMIN_ROLE + address newUEAAdmin = makeAddr("newUEAAdmin"); + vm.prank(newRoleManager); + factory.grantRole(factory.UEA_ADMIN_ROLE(), newUEAAdmin); + assertTrue(factory.hasRole(factory.UEA_ADMIN_ROLE(), newUEAAdmin)); + } + + function testPauserCannotUnpause() public { + vm.prank(pauser); + factory.pause(); + + bytes32 operatorRole = factory.OPERATOR_ROLE(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, pauser, operatorRole) + ); + vm.prank(pauser); + factory.unpause(); + } } diff --git a/test/tests_uea_and_factory/UEAProxyCalls.t.sol b/test/tests_uea_and_factory/UEAProxyCalls.t.sol index 466f8d5..414d01a 100644 --- a/test/tests_uea_and_factory/UEAProxyCalls.t.sol +++ b/test/tests_uea_and_factory/UEAProxyCalls.t.sol @@ -52,12 +52,13 @@ contract ProxyCallTest is Test { UEAFactory factoryImpl = new UEAFactory(); - bytes memory initData = abi.encodeWithSelector(UEAFactory.initialize.selector, admin, makeAddr("pauser")); + bytes memory initData = + abi.encodeWithSelector(UEAFactory.initialize.selector, admin, makeAddr("pauser"), "42101"); ERC1967Proxy proxy = new ERC1967Proxy(address(factoryImpl), initData); factory = UEAFactory(address(proxy)); // Set UEAProxy implementation after initialization - factory.setUEAProxyImplementation(address(ueaProxyImpl)); + factory.updateUEAProxyImplementation(address(ueaProxyImpl)); bytes32 evmChainHash = keccak256(abi.encode("eip155", "1")); factory.registerNewChain(evmChainHash, EVM_HASH); @@ -261,7 +262,8 @@ contract ProxyCallTest is Test { user1UEAInstance.executeUniversalTx(payload, signature); - vm.expectRevert(Errors.InvalidEVMSignature.selector); + // Nonce check fires first (expected=1, got=0) before signature verification + vm.expectRevert(abi.encodeWithSelector(Errors.NonceMismatch.selector, 1, 0)); user1UEAInstance.executeUniversalTx(payload, signature); } diff --git a/test/tests_uea_and_factory/UEA_EVM.t.sol b/test/tests_uea_and_factory/UEA_EVM.t.sol index 19db0e1..99c01a3 100644 --- a/test/tests_uea_and_factory/UEA_EVM.t.sol +++ b/test/tests_uea_and_factory/UEA_EVM.t.sol @@ -54,12 +54,12 @@ contract UEA_EVMTest is Test { // Deploy and initialize the proxy with initialOwner bytes memory initData = - abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser")); + abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser"), "42101"); ERC1967Proxy proxy = new ERC1967Proxy(address(factoryImpl), initData); factory = UEAFactory(address(proxy)); // Set UEAProxy implementation after initialization - factory.setUEAProxyImplementation(address(ueaProxyImpl)); + factory.updateUEAProxyImplementation(address(ueaProxyImpl)); // NOW deploy UEA implementations with factory address ueaEVMImpl = new UEA_EVM(); @@ -705,7 +705,7 @@ contract UEA_EVMTest is Test { bytes memory signature = abi.encodePacked(r, s, v); // The execution should fail because the account expects nonce to be 0, not 100 - vm.expectRevert(Errors.InvalidEVMSignature.selector); + vm.expectRevert(abi.encodeWithSelector(Errors.NonceMismatch.selector, 0, 100)); evmSmartAccountInstance.executeUniversalTx(payload, signature); // Verify state hasn't changed @@ -737,8 +737,8 @@ contract UEA_EVMTest is Test { uint256 previousNonce = evmSmartAccountInstance.nonce(); - // Try to execute with same nonce again - vm.expectRevert(Errors.InvalidEVMSignature.selector); + // Try to execute with same nonce again — nonce check fires first (expected=1, got=0) + vm.expectRevert(abi.encodeWithSelector(Errors.NonceMismatch.selector, 1, 0)); evmSmartAccountInstance.executeUniversalTx(payload, signature); // Verify state hasn't changed @@ -893,7 +893,7 @@ contract UEA_EVMTest is Test { function test_SuccessfulMigrationUpdatesImplementation() public deployEvmSmartAccount { // Set migration contract in factory - factory.setUEAMigrationContract(address(migration)); + factory.updateUEAMigrationContract(address(migration)); MigrationPayload memory payload = MigrationPayload({migration: address(migration), nonce: 0, deadline: block.timestamp + 1000}); @@ -929,7 +929,7 @@ contract UEA_EVMTest is Test { } function testMigration_RevertsWhenValueNonZero() public deployEvmSmartAccount { - factory.setUEAMigrationContract(address(migration)); + factory.updateUEAMigrationContract(address(migration)); UniversalPayload memory payload = UniversalPayload({ to: address(evmSmartAccountInstance), @@ -952,7 +952,7 @@ contract UEA_EVMTest is Test { } function testMigration_RevertsWhenTargetNotSelf() public deployEvmSmartAccount { - factory.setUEAMigrationContract(address(migration)); + factory.updateUEAMigrationContract(address(migration)); UniversalPayload memory payload = UniversalPayload({ to: address(target), @@ -1011,7 +1011,8 @@ contract UEA_EVMTest is Test { // This test verifies that the DOMAIN_SEPARATOR_TYPEHASH constant matches the expected hash // If the EIP712Domain struct definition changes, this test will fail - bytes32 expectedHash = keccak256("EIP712Domain(string version,uint256 chainId,address verifyingContract)"); + bytes32 expectedHash = + keccak256("EIP712Domain(string version,uint256 chainId,address verifyingContract,bytes32 salt)"); // Access the constant from the deployed instance bytes32 actualHash = evmSmartAccountInstance.DOMAIN_SEPARATOR_TYPEHASH(); diff --git a/test/tests_uea_and_factory/UEA_SVM.t.sol b/test/tests_uea_and_factory/UEA_SVM.t.sol index c00851f..cfeae69 100644 --- a/test/tests_uea_and_factory/UEA_SVM.t.sol +++ b/test/tests_uea_and_factory/UEA_SVM.t.sol @@ -45,12 +45,12 @@ contract UEASVMTest is Test { // Deploy and initialize the proxy with initialOwner bytes memory initData = - abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser")); + abi.encodeWithSelector(UEAFactory.initialize.selector, address(this), makeAddr("pauser"), "42101"); ERC1967Proxy proxy = new ERC1967Proxy(address(factoryImpl), initData); factory = UEAFactory(address(proxy)); // Set UEAProxy implementation after initialization - factory.setUEAProxyImplementation(address(ueaProxyImpl)); + factory.updateUEAProxyImplementation(address(ueaProxyImpl)); // Deploy SVM implementation svmSmartAccountImpl = new UEA_SVM(); @@ -830,7 +830,7 @@ contract UEASVMTest is Test { function test_SuccessfulMigrationUpdatesImplementation() public deploySvmSmartAccount { // Set migration contract in factory - factory.setUEAMigrationContract(address(migration)); + factory.updateUEAMigrationContract(address(migration)); MigrationPayload memory payload = MigrationPayload({migration: address(migration), nonce: 0, deadline: block.timestamp + 1000}); @@ -874,7 +874,7 @@ contract UEASVMTest is Test { } function testMigration_RevertsWhenValueNonZero() public deploySvmSmartAccount { - factory.setUEAMigrationContract(address(migration)); + factory.updateUEAMigrationContract(address(migration)); UniversalPayload memory payload = UniversalPayload({ to: address(svmSmartAccountInstance), @@ -894,7 +894,7 @@ contract UEASVMTest is Test { } function testMigration_RevertsWhenTargetNotSelf() public deploySvmSmartAccount { - factory.setUEAMigrationContract(address(migration)); + factory.updateUEAMigrationContract(address(migration)); UniversalPayload memory payload = UniversalPayload({ to: address(target), @@ -945,8 +945,9 @@ contract UEASVMTest is Test { abi.encode( svmSmartAccountInstance.DOMAIN_SEPARATOR_TYPEHASH_SVM(), keccak256(bytes(svmSmartAccountInstance.VERSION())), - "101", - address(svmSmartAccountInstance) + keccak256(bytes("101")), + address(svmSmartAccountInstance), + bytes32(block.chainid) ) ); @@ -957,7 +958,8 @@ contract UEASVMTest is Test { // This test verifies that the DOMAIN_SEPARATOR_TYPEHASH_SVM constant matches the expected hash // If the EIP712Domain_SVM struct definition changes, this test will fail - bytes32 expectedHash = keccak256("EIP712Domain_SVM(string version,string chainId,address verifyingContract)"); + bytes32 expectedHash = + keccak256("EIP712Domain_SVM(string version,string chainId,address verifyingContract,bytes32 salt)"); // Access the constant from the deployed instance bytes32 actualHash = svmSmartAccountInstance.DOMAIN_SEPARATOR_TYPEHASH_SVM(); @@ -1261,6 +1263,29 @@ contract UEASVMTest is Test { // Verify execution succeeded assertEq(target.getMagicNumber(), 999, "Execution should succeed with valid signature"); } + + function testRevertWhenIncorrectNonce() public deploySvmSmartAccount { + uint256 previousNonce = svmSmartAccountInstance.nonce(); + + UniversalPayload memory payload = UniversalPayload({ + to: address(target), + value: 0, + data: abi.encodeWithSignature("setMagicNumber(uint256)", 786), + gasLimit: 1000000, + maxFeePerGas: 0, + nonce: 100, + deadline: block.timestamp + 1000, + maxPriorityFeePerGas: 0, + vType: VerificationType(0) + }); + + bytes memory signature = hex"00"; + + vm.expectRevert(abi.encodeWithSelector(Errors.NonceMismatch.selector, 0, 100)); + svmSmartAccountInstance.executeUniversalTx(payload, signature); + + assertEq(previousNonce, svmSmartAccountInstance.nonce(), "Nonce should not have changed"); + } } // Helper contracts for testing reverts