diff --git a/.gas-snapshot b/.gas-snapshot index 848ff41..c9ba02d 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,26 +1,45 @@ -AquaStorageTest:testDock1Token() (gas: 50947) -AquaStorageTest:testDock2Tokens() (gas: 80108) -AquaStorageTest:testDock3Tokens() (gas: 109363) -AquaStorageTest:testPullSingleSloadSstore() (gas: 67696) -AquaStorageTest:testPushSingleSloadSstore() (gas: 70616) -AquaStorageTest:testShip1Token() (gas: 46337) -AquaStorageTest:testShip2Tokens() (gas: 74317) -AquaStorageTest:testShip3Tokens() (gas: 102276) -AquaTest:testBalancesReturnsCorrectAmounts() (gas: 105159) -AquaTest:testBalancesReturnsZeroForNonExistentStrategy() (gas: 14848) -AquaTest:testBalancesReturnsZeroForTokenNotInStrategy() (gas: 50741) -AquaTest:testDockRequiresAllTokensFromShip() (gas: 76825) -AquaTest:testDockRequiresCorrectTokenCount() (gas: 79476) -AquaTest:testDockRequiresExactTokensFromShip() (gas: 81174) -AquaTest:testFullLifecycle() (gas: 144932) -AquaTest:testMultipleStrategiesSameTokens() (gas: 169969) -AquaTest:testPushFailsAfterDock() (gas: 54897) -AquaTest:testPushOnlyForShippedTokens() (gas: 54277) -AquaTest:testPushRequiresActiveStrategy() (gas: 21078) -AquaTest:testSafeBalancesReturnsCorrectAmountsForActiveStrategy() (gas: 75692) -AquaTest:testSafeBalancesRevertsAfterDock() (gas: 54832) -AquaTest:testSafeBalancesRevertsForNonExistentStrategy() (gas: 20785) -AquaTest:testSafeBalancesRevertsIfAnyTokenNotInStrategy() (gas: 79974) -AquaTest:testSafeBalancesTracksChangesFromPushPull() (gas: 138495) -AquaTest:testShipCannotBeCalledTwiceForSameStrategy() (gas: 52784) -AquaTest:testShipCannotHaveDuplicateTokens() (gas: 46793) \ No newline at end of file +AquaHooksTest:testAfterShipRevertsPropagatesToAqua() (gas: 72625) +AquaHooksTest:testBeforeShipReturnsFalseReverts() (gas: 45643) +AquaHooksTest:testBeforeShipRevertsPropagatesToAqua() (gas: 44349) +AquaHooksTest:testBeforeShipWrapsETH() (gas: 199696) +AquaHooksTest:testETHSentRequiresBeforeHook() (gas: 24263) +AquaHooksTest:testETHSentWithAfterHookOnlyFails() (gas: 24043) +AquaHooksTest:testETHWithBothHooksSucceeds() (gas: 202975) +AquaHooksTest:testETHWithMultipleTokens() (gas: 230487) +AquaHooksTest:testHookConstants() (gas: 7841) +AquaHooksTest:testHooksReceiveCorrectStrategyHash() (gas: 106159) +AquaHooksTest:testNonHookAppWithHooksFails() (gas: 20950) +AquaHooksTest:testNonHookAppWithNoHooksSucceeds() (gas: 46710) +AquaHooksTest:testReentrancyFromAfterShipIsBlocked() (gas: 58456) +AquaHooksTest:testReentrancyFromBeforeShipIsBlocked() (gas: 58458) +AquaHooksTest:testReentrancyFromBothHooksIsBlocked() (gas: 62047) +AquaHooksTest:testShipWithAfterHookOnly() (gas: 103558) +AquaHooksTest:testShipWithBeforeHookOnly() (gas: 105031) +AquaHooksTest:testShipWithBothHooks() (gas: 107501) +AquaHooksTest:testShipWithNoHooks() (gas: 50752) +AquaStorageTest:testDock1Token() (gas: 51407) +AquaStorageTest:testDock2Tokens() (gas: 80579) +AquaStorageTest:testDock3Tokens() (gas: 109845) +AquaStorageTest:testPullSingleSloadSstore() (gas: 68210) +AquaStorageTest:testPushSingleSloadSstore() (gas: 71095) +AquaStorageTest:testShip1Token() (gas: 46745) +AquaStorageTest:testShip2Tokens() (gas: 74731) +AquaStorageTest:testShip3Tokens() (gas: 102696) +AquaTest:testBalancesReturnsCorrectAmounts() (gas: 105840) +AquaTest:testBalancesReturnsZeroForNonExistentStrategy() (gas: 14934) +AquaTest:testBalancesReturnsZeroForTokenNotInStrategy() (gas: 51238) +AquaTest:testDockRequiresAllTokensFromShip() (gas: 77287) +AquaTest:testDockRequiresCorrectTokenCount() (gas: 79938) +AquaTest:testDockRequiresExactTokensFromShip() (gas: 81641) +AquaTest:testFullLifecycle() (gas: 145978) +AquaTest:testMultipleStrategiesSameTokens() (gas: 171442) +AquaTest:testPushFailsAfterDock() (gas: 55409) +AquaTest:testPushOnlyForShippedTokens() (gas: 54738) +AquaTest:testPushRequiresActiveStrategy() (gas: 21131) +AquaTest:testSafeBalancesReturnsCorrectAmountsForActiveStrategy() (gas: 76204) +AquaTest:testSafeBalancesRevertsAfterDock() (gas: 55389) +AquaTest:testSafeBalancesRevertsForNonExistentStrategy() (gas: 20883) +AquaTest:testSafeBalancesRevertsIfAnyTokenNotInStrategy() (gas: 80484) +AquaTest:testSafeBalancesTracksChangesFromPushPull() (gas: 139186) +AquaTest:testShipCannotBeCalledTwiceForSameStrategy() (gas: 53461) +AquaTest:testShipCannotHaveDuplicateTokens() (gas: 47071) \ No newline at end of file diff --git a/src/Aqua.sol b/src/Aqua.sol index d4c95ce..9f1faa9 100644 --- a/src/Aqua.sol +++ b/src/Aqua.sol @@ -6,18 +6,26 @@ pragma solidity 0.8.30; import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { SafeERC20, IERC20 } from "@1inch/solidity-utils/contracts/libraries/SafeERC20.sol"; +import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import { IAqua } from "./interfaces/IAqua.sol"; +import { IShipHook } from "./interfaces/IShipHook.sol"; import { Balance, BalanceLib } from "./libs/Balance.sol"; /// @title Aqua - Shared Liquidity Layer -contract Aqua is IAqua { +contract Aqua is IAqua, ReentrancyGuardTransient { using SafeERC20 for IERC20; using SafeCast for uint256; using BalanceLib for Balance; uint8 private constant _DOCKED = 0xff; + /// @notice Hook flags for optional ship lifecycle hooks + uint8 public constant HOOK_NONE = 0x00; + uint8 public constant HOOK_BEFORE = 0x01; + uint8 public constant HOOK_AFTER = 0x02; + uint8 public constant HOOK_BOTH = 0x03; + mapping(address maker => mapping(address app => mapping(bytes32 strategyHash => @@ -37,11 +45,40 @@ contract Aqua is IAqua { balance1 = amount1; } - function ship(address app, bytes calldata strategy, address[] calldata tokens, uint256[] calldata amounts) external returns(bytes32 strategyHash) { + function ship( + address app, + bytes calldata strategy, + address[] calldata tokens, + uint256[] calldata amounts, + uint8 hooks + ) external payable nonReentrant returns(bytes32 strategyHash) { strategyHash = keccak256(strategy); uint8 tokensCount = tokens.length.toUint8(); require(tokensCount != _DOCKED, MaxNumberOfTokensExceeded(tokensCount, _DOCKED)); + // If ETH is sent, HOOK_BEFORE must be set + if (msg.value > 0) { + require((hooks & HOOK_BEFORE) != 0, ETHSentWithoutBeforeHook()); + } + + // beforeShip hook: called BEFORE balance storage (if HOOK_BEFORE flag set) + // Use for: ETH wrapping, pre-validation, setup + // Note: If app doesn't implement IShipHook, this will revert. This is intentional - + // apps should only set HOOK_BEFORE if they implement the hook. No ERC-165 check + // is performed to save gas (~2600 gas saved per call). + if ((hooks & HOOK_BEFORE) != 0) { + bool success = IShipHook(app).beforeShip{value: msg.value}( + msg.sender, + strategyHash, + tokens, + amounts + ); + // beforeShip returns bool to allow graceful failure signaling. + // Returning false triggers ShipHookFailed; reverting propagates the error. + require(success, ShipHookFailed(app, HOOK_BEFORE)); + } + + // Core ship logic: store balances emit Shipped(msg.sender, app, strategyHash, strategy); for (uint256 i = 0; i < tokens.length; i++) { Balance storage balance = _balances[msg.sender][app][strategyHash][tokens[i]]; @@ -49,6 +86,21 @@ contract Aqua is IAqua { balance.store(amounts[i].toUint248(), tokensCount); emit Pushed(msg.sender, app, strategyHash, tokens[i], amounts[i]); } + + // afterShip hook: called AFTER balance storage (if HOOK_AFTER flag set) + // Use for: notifications, additional state setup, external calls + // Note: Unlike beforeShip, afterShip has no return value. This is intentional: + // - afterShip is for side effects, not validation + // - If it fails, it should revert (consistent with callback patterns) + // - Any revert here will roll back the entire transaction including balance storage + if ((hooks & HOOK_AFTER) != 0) { + IShipHook(app).afterShip( + msg.sender, + strategyHash, + tokens, + amounts + ); + } } function dock(address app, bytes32 strategyHash, address[] calldata tokens) external { diff --git a/src/interfaces/IAqua.sol b/src/interfaces/IAqua.sol index b29f6f6..151daeb 100644 --- a/src/interfaces/IAqua.sol +++ b/src/interfaces/IAqua.sol @@ -37,6 +37,14 @@ interface IAqua { /// @param token The token being queried error SafeBalancesForTokenNotInActiveStrategy(address maker, address app, bytes32 strategyHash, address token); + /// @notice Thrown when a ship hook fails + /// @param app The app address that failed the hook + /// @param hookType 1 = beforeShip, 2 = afterShip + error ShipHookFailed(address app, uint8 hookType); + + /// @notice Thrown when ETH is sent but HOOK_BEFORE flag is not set + error ETHSentWithoutBeforeHook(); + /// @notice Emitted when a new strategy is shipped (deployed) and initialized with balances /// @param maker The address of the maker shipping the strategy /// @param app The app address associated with the strategy @@ -89,16 +97,22 @@ interface IAqua { /// @notice Ships a new strategy as of an app and sets initial balances /// @dev Parameter `strategy` is presented fully instead of being pre-hashed for data availability + /// Hooks are optional and controlled via the `hooks` flags parameter: + /// - HOOK_BEFORE (0x01): Call beforeShip before balance storage (required if ETH sent) + /// - HOOK_AFTER (0x02): Call afterShip after balance storage + /// - HOOK_BOTH (0x03): Call both hooks /// @param app The implementation contract /// @param strategy Initialization data passed to the strategy /// @param tokens Array of token addresses to approve /// @param amounts Array of balance amounts for each token + /// @param hooks Bitmask of hooks to call (0x00=none, 0x01=before, 0x02=after, 0x03=both) function ship( address app, bytes calldata strategy, address[] calldata tokens, - uint256[] calldata amounts - ) external returns(bytes32 strategyHash); + uint256[] calldata amounts, + uint8 hooks + ) external payable returns(bytes32 strategyHash); /// @notice Docks (deactivates) a strategy by clearing balances for specified tokens /// @dev Sets balances to 0 for all specified tokens diff --git a/src/interfaces/IShipHook.sol b/src/interfaces/IShipHook.sol new file mode 100644 index 0000000..a5192d7 --- /dev/null +++ b/src/interfaces/IShipHook.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: LicenseRef-Degensoft-Aqua-Source-1.1 +pragma solidity ^0.8.0; + +/// @custom:license-url https://github.com/1inch/aqua/blob/main/LICENSES/Aqua-Source-1.1.txt +/// @custom:copyright © 2025 Degensoft Ltd + +/// @title IShipHook +/// @notice Hook interface for apps to handle ship lifecycle operations +/// @dev Apps can implement these hooks to handle: +/// - beforeShip: Native ETH wrapping, pre-validation, setup +/// - afterShip: Notifications, additional state setup, external calls +/// +/// ## Hook Flags +/// Hooks are optional and controlled via the `hooks` flags parameter in ship(): +/// - HOOK_NONE (0x00): No hooks called +/// - HOOK_BEFORE (0x01): Call beforeShip before balance storage +/// - HOOK_AFTER (0x02): Call afterShip after balance storage +/// - HOOK_BOTH (0x03): Call both hooks +/// +/// ## Interface Validation +/// Aqua does NOT verify that the app implements IShipHook before calling hooks. +/// If an app address is used with hooks enabled but doesn't implement IShipHook, +/// the call will revert. This is intentional to avoid gas overhead of ERC-165 checks. +/// Apps should only set hook flags if they implement the corresponding hook methods. +/// +/// ## Error Handling +/// - beforeShip: MUST return true on success. Returning false or reverting will +/// cause the entire ship() transaction to revert. Use this for critical validation. +/// - afterShip: Any revert will propagate and cause ship() to fail. Since afterShip +/// is called AFTER balances are stored, apps should handle errors gracefully +/// if the hook failure shouldn't block the ship operation. +/// +/// ## When to Return False vs Revert in beforeShip +/// - Return false: For expected failures that should block ship with a clear error +/// - Revert with custom error: For unexpected failures with detailed error info +/// Both will cause ship() to revert, but returning false uses ShipHookFailed error. +interface IShipHook { + /// @notice Called by Aqua BEFORE processing ship (when HOOK_BEFORE flag is set) + /// @dev Use for: ETH wrapping, pre-validation, setup + /// + /// ## ETH Handling + /// The hook receives ETH via msg.value if sent with ship(). For ETH wrapping: + /// 1. Wrap ETH to WETH: WETH.deposit{value: msg.value}() + /// 2. Transfer WETH to maker: WETH.transfer(maker, msg.value) + /// This allows the maker to have WETH for the strategy without pre-wrapping. + /// + /// ## Return Value + /// - Return true: Hook succeeded, continue with ship + /// - Return false: Hook failed, revert ship with ShipHookFailed error + /// - Revert: Propagates to ship() caller + /// + /// ## Security + /// This hook is called BEFORE balances are stored. Any state changes made here + /// will be reverted if the ship fails. The hook is protected by reentrancy guard. + /// + /// @param maker The maker address who is shipping the strategy + /// @param strategyHash The hash of the strategy being shipped (keccak256 of strategy bytes) + /// @param tokens Array of token addresses in the strategy + /// @param amounts Array of amounts for each token + /// @return success True if hook processed successfully, false to revert ship + function beforeShip( + address maker, + bytes32 strategyHash, + address[] calldata tokens, + uint256[] calldata amounts + ) external payable returns (bool success); + + /// @notice Called by Aqua AFTER ship is completed (when HOOK_AFTER flag is set) + /// @dev Use for: notifications, additional state setup, external calls + /// + /// ## Execution Context + /// Called AFTER balances are stored, so strategy is already active in Aqua. + /// If this hook reverts, the entire ship() transaction reverts, including + /// the balance storage. Apps should handle non-critical failures gracefully. + /// + /// ## No Return Value + /// Unlike beforeShip, afterShip has no return value. This is intentional: + /// - afterShip is for side effects (notifications, external calls) + /// - If it fails, it should revert (no silent failures) + /// - The pattern matches common callback patterns (no boolean dance) + /// + /// ## Security + /// This hook is called AFTER state changes but still within the reentrancy guard. + /// Reentering ship() from this hook will revert. + /// + /// @param maker The maker address who shipped the strategy + /// @param strategyHash The hash of the strategy that was shipped + /// @param tokens Array of token addresses in the strategy + /// @param amounts Array of amounts for each token + function afterShip( + address maker, + bytes32 strategyHash, + address[] calldata tokens, + uint256[] calldata amounts + ) external; +} diff --git a/test/Aqua.t.sol b/test/Aqua.t.sol index e0ba2c1..f4285f1 100644 --- a/test/Aqua.t.sol +++ b/test/Aqua.t.sol @@ -58,7 +58,8 @@ contract AquaTest is Test { app, "strategy1", dynamic([address(token1)]), - dynamic([uint256(100e18)]) + dynamic([uint256(100e18)]), + 0 ); // Try to ship again with same strategy @@ -68,7 +69,8 @@ contract AquaTest is Test { app, "strategy1", dynamic([address(token1)]), - dynamic([uint256(50e18)]) + dynamic([uint256(50e18)]), + 0 ); } @@ -81,7 +83,8 @@ contract AquaTest is Test { app, "strategy_dup", dynamic([address(token1), address(token1)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); } @@ -94,7 +97,8 @@ contract AquaTest is Test { app, "strategy2", dynamic([address(token1), address(token2)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); // Try to dock with only 1 token @@ -114,7 +118,8 @@ contract AquaTest is Test { app, "strategy3", dynamic([address(token1), address(token2)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); // Try to dock with different token @@ -134,7 +139,8 @@ contract AquaTest is Test { app, "strategy4", dynamic([address(token1), address(token2)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); // Try to dock with 3 tokens @@ -163,7 +169,8 @@ contract AquaTest is Test { app, "strategy5", dynamic([address(token1)]), - dynamic([uint256(100e18)]) + dynamic([uint256(100e18)]), + 0 ); vm.prank(maker); @@ -186,7 +193,8 @@ contract AquaTest is Test { app, "strategy6", dynamic([address(token1)]), - dynamic([uint256(100e18)]) + dynamic([uint256(100e18)]), + 0 ); // Try to push token2 (not shipped) @@ -207,7 +215,8 @@ contract AquaTest is Test { app, "lifecycle", dynamic([address(token1), address(token2)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); // 2. Push to token1 @@ -251,7 +260,8 @@ contract AquaTest is Test { app, "multi1", dynamic([address(token1), address(token2)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); // Ship strategy 2 with same tokens but different salt @@ -260,7 +270,8 @@ contract AquaTest is Test { app, "multi2", dynamic([address(token1), address(token2)]), - dynamic([uint256(300e18), uint256(400e18)]) + dynamic([uint256(300e18), uint256(400e18)]), + 0 ); // Verify both strategies work independently @@ -307,7 +318,8 @@ contract AquaTest is Test { app, "balances_test", dynamic([address(token1)]), - dynamic([uint256(100e18)]) + dynamic([uint256(100e18)]), + 0 ); // Query balance for token2 (not in strategy) - should return 0 @@ -322,7 +334,8 @@ contract AquaTest is Test { app, "balances_multi", dynamic([address(token1), address(token2), address(token3)]), - dynamic([uint256(100e18), uint256(200e18), uint256(300e18)]) + dynamic([uint256(100e18), uint256(200e18), uint256(300e18)]), + 0 ); bytes32 strategyHash = keccak256("balances_multi"); @@ -343,7 +356,8 @@ contract AquaTest is Test { app, "safe_balances", dynamic([address(token1), address(token2)]), - dynamic([uint256(150e18), uint256(250e18)]) + dynamic([uint256(150e18), uint256(250e18)]), + 0 ); bytes32 strategyHash = keccak256("safe_balances"); @@ -388,7 +402,8 @@ contract AquaTest is Test { app, "safe_partial", dynamic([address(token1), address(token2)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); bytes32 strategyHash = keccak256("safe_partial"); @@ -419,7 +434,8 @@ contract AquaTest is Test { app, "safe_docked", dynamic([address(token1)]), - dynamic([uint256(100e18)]) + dynamic([uint256(100e18)]), + 0 ); bytes32 strategyHash = keccak256("safe_docked"); @@ -458,7 +474,8 @@ contract AquaTest is Test { app, "safe_changes", dynamic([address(token1), address(token2)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); bytes32 strategyHash = keccak256("safe_changes"); diff --git a/test/AquaHooks.t.sol b/test/AquaHooks.t.sol new file mode 100644 index 0000000..df10063 --- /dev/null +++ b/test/AquaHooks.t.sol @@ -0,0 +1,673 @@ +// SPDX-License-Identifier: LicenseRef-Degensoft-Aqua-Source-1.1 +pragma solidity ^0.8.13; + +/// @custom:license-url https://github.com/1inch/aqua/blob/main/LICENSES/Aqua-Source-1.1.txt +/// @custom:copyright © 2025 Degensoft Ltd + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Aqua} from "src/Aqua.sol"; +import {IAqua} from "src/interfaces/IAqua.sol"; +import {IShipHook} from "src/interfaces/IShipHook.sol"; + +contract MockToken is ERC20 { + constructor(string memory name) ERC20(name, "MOCK") { + _mint(msg.sender, 1000000e18); + } +} + +/// @title MockWETH - Minimal WETH for testing +contract MockWETH is ERC20 { + constructor() ERC20("Wrapped Ether", "WETH") {} + + function deposit() external payable { + _mint(msg.sender, msg.value); + } + + function withdraw(uint256 amount) external { + _burn(msg.sender, amount); + payable(msg.sender).transfer(amount); + } + + receive() external payable { + _mint(msg.sender, msg.value); + } +} + +/// @title MockHookApp - App that implements IShipHook for testing +contract MockHookApp is IShipHook { + MockWETH public weth; + + // Tracking for tests + bool public beforeShipCalled; + bool public afterShipCalled; + address public lastMaker; + bytes32 public lastStrategyHash; + uint256 public lastEthReceived; + + // Control flags for testing + bool public shouldRevertBeforeShip; + bool public shouldRevertAfterShip; + bool public shouldReturnFalse; + + constructor(address _weth) { + weth = MockWETH(payable(_weth)); + } + + function beforeShip( + address maker, + bytes32 strategyHash, + address[] calldata tokens, + uint256[] calldata amounts + ) external payable override returns (bool success) { + if (shouldRevertBeforeShip) revert("beforeShip reverted"); + if (shouldReturnFalse) return false; + + beforeShipCalled = true; + lastMaker = maker; + lastStrategyHash = strategyHash; + lastEthReceived = msg.value; + + // If ETH received, wrap to WETH and send to maker + if (msg.value > 0) { + // Find WETH in tokens and verify amount matches + for (uint256 i = 0; i < tokens.length; i++) { + if (tokens[i] == address(weth) && amounts[i] == msg.value) { + weth.deposit{value: msg.value}(); + weth.transfer(maker, msg.value); + break; + } + } + } + + return true; + } + + function afterShip( + address maker, + bytes32 strategyHash, + address[] calldata, + uint256[] calldata + ) external override { + if (shouldRevertAfterShip) revert("afterShip reverted"); + + afterShipCalled = true; + lastMaker = maker; + lastStrategyHash = strategyHash; + } + + function reset() external { + beforeShipCalled = false; + afterShipCalled = false; + lastMaker = address(0); + lastStrategyHash = bytes32(0); + lastEthReceived = 0; + shouldRevertBeforeShip = false; + shouldRevertAfterShip = false; + shouldReturnFalse = false; + } + + function setRevertBeforeShip(bool _revert) external { + shouldRevertBeforeShip = _revert; + } + + function setRevertAfterShip(bool _revert) external { + shouldRevertAfterShip = _revert; + } + + function setReturnFalse(bool _returnFalse) external { + shouldReturnFalse = _returnFalse; + } + + receive() external payable {} +} + +/// @title MockNonHookApp - App that does NOT implement IShipHook +contract MockNonHookApp { + // This app doesn't implement IShipHook +} + +/// @title ReentrantHookApp - App that tries to re-enter ship() from hooks +contract ReentrantHookApp is IShipHook { + Aqua public aqua; + bool public attemptReentry; + bool public reentrancyAttempted; + bool public reentrancySucceeded; + + constructor(address _aqua) { + aqua = Aqua(_aqua); + } + + function setAttemptReentry(bool _attempt) external { + attemptReentry = _attempt; + } + + function beforeShip( + address, + bytes32, + address[] calldata tokens, + uint256[] calldata amounts + ) external payable override returns (bool success) { + if (attemptReentry) { + reentrancyAttempted = true; + // Try to re-enter ship + try aqua.ship( + address(this), + "reentrant_strategy", + tokens, + amounts, + 0 + ) { + reentrancySucceeded = true; + } catch { + reentrancySucceeded = false; + } + } + return true; + } + + function afterShip( + address, + bytes32, + address[] calldata tokens, + uint256[] calldata amounts + ) external override { + if (attemptReentry) { + reentrancyAttempted = true; + // Try to re-enter ship + try aqua.ship( + address(this), + "reentrant_strategy_after", + tokens, + amounts, + 0 + ) { + reentrancySucceeded = true; + } catch { + reentrancySucceeded = false; + } + } + } +} + +contract AquaHooksTest is Test { + Aqua public aqua; + MockToken public token1; + MockWETH public weth; + MockHookApp public hookApp; + MockNonHookApp public nonHookApp; + ReentrantHookApp public reentrantApp; + + address public maker = address(0x1111); + + function setUp() public { + aqua = new Aqua(); + token1 = new MockToken("Token1"); + weth = new MockWETH(); + hookApp = new MockHookApp(address(weth)); + nonHookApp = new MockNonHookApp(); + reentrantApp = new ReentrantHookApp(address(aqua)); + + // Setup tokens and approvals + token1.transfer(maker, 10000e18); + vm.deal(maker, 100 ether); + + vm.prank(maker); + token1.approve(address(aqua), type(uint256).max); + vm.prank(maker); + weth.approve(address(aqua), type(uint256).max); + } + + // ========== HOOK FLAGS TESTS ========== + + function testShipWithNoHooks() public { + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + aqua.ship( + address(hookApp), + "strategy_no_hooks", + tokens, + amounts, + 0 // HOOK_NONE + ); + + // Hooks should NOT be called + assertFalse(hookApp.beforeShipCalled()); + assertFalse(hookApp.afterShipCalled()); + } + + function testShipWithBeforeHookOnly() public { + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + aqua.ship( + address(hookApp), + "strategy_before_only", + tokens, + amounts, + 1 // HOOK_BEFORE + ); + + // Only beforeShip should be called + assertTrue(hookApp.beforeShipCalled()); + assertFalse(hookApp.afterShipCalled()); + assertEq(hookApp.lastMaker(), maker); + } + + function testShipWithAfterHookOnly() public { + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + aqua.ship( + address(hookApp), + "strategy_after_only", + tokens, + amounts, + 2 // HOOK_AFTER + ); + + // Only afterShip should be called + assertFalse(hookApp.beforeShipCalled()); + assertTrue(hookApp.afterShipCalled()); + assertEq(hookApp.lastMaker(), maker); + } + + function testShipWithBothHooks() public { + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + aqua.ship( + address(hookApp), + "strategy_both_hooks", + tokens, + amounts, + 3 // HOOK_BOTH + ); + + // Both hooks should be called + assertTrue(hookApp.beforeShipCalled()); + assertTrue(hookApp.afterShipCalled()); + assertEq(hookApp.lastMaker(), maker); + } + + // ========== ETH WRAPPING TESTS ========== + + function testBeforeShipWrapsETH() public { + uint256 ethAmount = 10 ether; + + address[] memory tokens = new address[](1); + tokens[0] = address(weth); + uint256[] memory amounts = new uint256[](1); + amounts[0] = ethAmount; + + vm.prank(maker); + aqua.ship{value: ethAmount}( + address(hookApp), + "strategy_eth_wrap", + tokens, + amounts, + 1 // HOOK_BEFORE + ); + + // Maker should have WETH + assertEq(weth.balanceOf(maker), ethAmount); + assertEq(hookApp.lastEthReceived(), ethAmount); + } + + function testETHSentRequiresBeforeHook() public { + address[] memory tokens = new address[](1); + tokens[0] = address(weth); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1 ether; + + // Try to send ETH without HOOK_BEFORE flag + vm.prank(maker); + vm.expectRevert(IAqua.ETHSentWithoutBeforeHook.selector); + aqua.ship{value: 1 ether}( + address(hookApp), + "strategy_eth_no_hook", + tokens, + amounts, + 0 // HOOK_NONE - should fail! + ); + } + + function testETHSentWithAfterHookOnlyFails() public { + address[] memory tokens = new address[](1); + tokens[0] = address(weth); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1 ether; + + // Try to send ETH with only HOOK_AFTER flag + vm.prank(maker); + vm.expectRevert(IAqua.ETHSentWithoutBeforeHook.selector); + aqua.ship{value: 1 ether}( + address(hookApp), + "strategy_eth_after_only", + tokens, + amounts, + 2 // HOOK_AFTER only - should fail! + ); + } + + function testETHWithBothHooksSucceeds() public { + uint256 ethAmount = 5 ether; + + address[] memory tokens = new address[](1); + tokens[0] = address(weth); + uint256[] memory amounts = new uint256[](1); + amounts[0] = ethAmount; + + vm.prank(maker); + aqua.ship{value: ethAmount}( + address(hookApp), + "strategy_eth_both", + tokens, + amounts, + 3 // HOOK_BOTH + ); + + assertTrue(hookApp.beforeShipCalled()); + assertTrue(hookApp.afterShipCalled()); + assertEq(weth.balanceOf(maker), ethAmount); + } + + // ========== HOOK FAILURE TESTS ========== + + function testBeforeShipReturnsFalseReverts() public { + hookApp.setReturnFalse(true); + + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + vm.expectRevert(abi.encodeWithSelector(IAqua.ShipHookFailed.selector, address(hookApp), 1)); + aqua.ship( + address(hookApp), + "strategy_false", + tokens, + amounts, + 1 // HOOK_BEFORE + ); + } + + function testBeforeShipRevertsPropagatesToAqua() public { + hookApp.setRevertBeforeShip(true); + + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + vm.expectRevert("beforeShip reverted"); + aqua.ship( + address(hookApp), + "strategy_revert_before", + tokens, + amounts, + 1 // HOOK_BEFORE + ); + } + + function testAfterShipRevertsPropagatesToAqua() public { + hookApp.setRevertAfterShip(true); + + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + vm.expectRevert("afterShip reverted"); + aqua.ship( + address(hookApp), + "strategy_revert_after", + tokens, + amounts, + 2 // HOOK_AFTER + ); + } + + // ========== NON-HOOK APP TESTS ========== + + function testNonHookAppWithNoHooksSucceeds() public { + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + // Non-hook app with no hooks flag should work + vm.prank(maker); + aqua.ship( + address(nonHookApp), + "strategy_non_hook", + tokens, + amounts, + 0 // HOOK_NONE + ); + + // Verify strategy was created + (uint248 balance,) = aqua.rawBalances(maker, address(nonHookApp), keccak256("strategy_non_hook"), address(token1)); + assertEq(balance, 100e18); + } + + function testNonHookAppWithHooksFails() public { + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + // Non-hook app with hooks flag should fail (app doesn't implement IShipHook) + vm.prank(maker); + vm.expectRevert(); + aqua.ship( + address(nonHookApp), + "strategy_non_hook_fail", + tokens, + amounts, + 1 // HOOK_BEFORE - but app doesn't implement it! + ); + } + + // ========== STRATEGY HASH VERIFICATION ========== + + function testHooksReceiveCorrectStrategyHash() public { + bytes memory strategy = "unique_strategy_123"; + bytes32 expectedHash = keccak256(strategy); + + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + aqua.ship( + address(hookApp), + strategy, + tokens, + amounts, + 3 // HOOK_BOTH + ); + + assertEq(hookApp.lastStrategyHash(), expectedHash); + } + + // ========== MULTIPLE TOKENS WITH ETH ========== + + function testETHWithMultipleTokens() public { + uint256 ethAmount = 2 ether; + + address[] memory tokens = new address[](2); + tokens[0] = address(token1); + tokens[1] = address(weth); + uint256[] memory amounts = new uint256[](2); + amounts[0] = 100e18; + amounts[1] = ethAmount; + + vm.prank(maker); + aqua.ship{value: ethAmount}( + address(hookApp), + "strategy_multi_eth", + tokens, + amounts, + 1 // HOOK_BEFORE + ); + + // Verify both tokens are tracked + bytes32 strategyHash = keccak256("strategy_multi_eth"); + (uint248 balance1,) = aqua.rawBalances(maker, address(hookApp), strategyHash, address(token1)); + (uint248 balance2,) = aqua.rawBalances(maker, address(hookApp), strategyHash, address(weth)); + + assertEq(balance1, 100e18); + assertEq(balance2, ethAmount); + assertEq(weth.balanceOf(maker), ethAmount); + } + + // ========== HOOK CONSTANTS TESTS ========== + + function testHookConstants() public view { + assertEq(aqua.HOOK_NONE(), 0x00); + assertEq(aqua.HOOK_BEFORE(), 0x01); + assertEq(aqua.HOOK_AFTER(), 0x02); + assertEq(aqua.HOOK_BOTH(), 0x03); + } + + // ========== FUZZ TESTS ========== + + function testFuzz_ShipWithHooksFlags(uint8 hooks) public { + // Bound to valid flags (0-3) + hooks = uint8(bound(hooks, 0, 3)); + + hookApp.reset(); + + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + aqua.ship( + address(hookApp), + abi.encodePacked("fuzz_strategy_", hooks), + tokens, + amounts, + hooks + ); + + // Verify correct hooks were called + bool expectBefore = (hooks & 1) != 0; + bool expectAfter = (hooks & 2) != 0; + + assertEq(hookApp.beforeShipCalled(), expectBefore); + assertEq(hookApp.afterShipCalled(), expectAfter); + } + + function testFuzz_ETHAmount(uint256 ethAmount) public { + // Bound to reasonable range + ethAmount = bound(ethAmount, 1, 1000 ether); + vm.deal(maker, ethAmount); + + hookApp.reset(); + + address[] memory tokens = new address[](1); + tokens[0] = address(weth); + uint256[] memory amounts = new uint256[](1); + amounts[0] = ethAmount; + + vm.prank(maker); + aqua.ship{value: ethAmount}( + address(hookApp), + abi.encodePacked("fuzz_eth_", ethAmount), + tokens, + amounts, + 1 // HOOK_BEFORE + ); + + assertEq(weth.balanceOf(maker), ethAmount); + assertEq(hookApp.lastEthReceived(), ethAmount); + } + + // ========== REENTRANCY PROTECTION TESTS ========== + + function testReentrancyFromBeforeShipIsBlocked() public { + reentrantApp.setAttemptReentry(true); + + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + aqua.ship( + address(reentrantApp), + "strategy_reentry_before", + tokens, + amounts, + 1 // HOOK_BEFORE + ); + + // Reentrancy was attempted but blocked + assertTrue(reentrantApp.reentrancyAttempted()); + assertFalse(reentrantApp.reentrancySucceeded()); + } + + function testReentrancyFromAfterShipIsBlocked() public { + reentrantApp.setAttemptReentry(true); + + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + aqua.ship( + address(reentrantApp), + "strategy_reentry_after", + tokens, + amounts, + 2 // HOOK_AFTER + ); + + // Reentrancy was attempted but blocked + assertTrue(reentrantApp.reentrancyAttempted()); + assertFalse(reentrantApp.reentrancySucceeded()); + } + + function testReentrancyFromBothHooksIsBlocked() public { + reentrantApp.setAttemptReentry(true); + + address[] memory tokens = new address[](1); + tokens[0] = address(token1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100e18; + + vm.prank(maker); + aqua.ship( + address(reentrantApp), + "strategy_reentry_both", + tokens, + amounts, + 3 // HOOK_BOTH + ); + + // Reentrancy was attempted but blocked + assertTrue(reentrantApp.reentrancyAttempted()); + assertFalse(reentrantApp.reentrancySucceeded()); + } +} diff --git a/test/AquaStorageTest.t.sol b/test/AquaStorageTest.t.sol index 5714d61..3881ec5 100644 --- a/test/AquaStorageTest.t.sol +++ b/test/AquaStorageTest.t.sol @@ -58,7 +58,8 @@ contract AquaStorageTest is Test { address(this), "strategy", dynamic([address(token1)]), - dynamic([uint256(1000e18)]) + dynamic([uint256(1000e18)]), + 0 ); // Test push storage operations @@ -77,7 +78,8 @@ contract AquaStorageTest is Test { address(this), "strategy", dynamic([address(token1)]), - dynamic([uint256(1000e18)]) + dynamic([uint256(1000e18)]), + 0 ); // Test pull storage operations (called directly from test contract acting as app) @@ -97,7 +99,8 @@ contract AquaStorageTest is Test { address(this), "ship1", dynamic([address(token1)]), - dynamic([uint256(100e18)]) + dynamic([uint256(100e18)]), + 0 ); (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(address(aqua)); @@ -111,7 +114,8 @@ contract AquaStorageTest is Test { address(this), "ship2", dynamic([address(token1), address(token2)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(address(aqua)); @@ -125,7 +129,8 @@ contract AquaStorageTest is Test { address(this), "ship3", dynamic([address(token1), address(token2), address(token3)]), - dynamic([uint256(100e18), uint256(200e18), uint256(300e18)]) + dynamic([uint256(100e18), uint256(200e18), uint256(300e18)]), + 0 ); (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(address(aqua)); @@ -141,7 +146,8 @@ contract AquaStorageTest is Test { address(this), "dock1", dynamic([address(token1)]), - dynamic([uint256(100e18)]) + dynamic([uint256(100e18)]), + 0 ); // Test dock storage operations @@ -164,7 +170,8 @@ contract AquaStorageTest is Test { address(this), "dock2", dynamic([address(token1), address(token2)]), - dynamic([uint256(100e18), uint256(200e18)]) + dynamic([uint256(100e18), uint256(200e18)]), + 0 ); // Test dock storage operations @@ -187,7 +194,8 @@ contract AquaStorageTest is Test { address(this), "dock3", dynamic([address(token1), address(token2), address(token3)]), - dynamic([uint256(100e18), uint256(200e18), uint256(300e18)]) + dynamic([uint256(100e18), uint256(200e18), uint256(300e18)]), + 0 ); // Test dock storage operations