From 7517869ea6b9394e9fdaecdff0aba2454691e4a5 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Mon, 13 Apr 2026 21:37:33 -0700 Subject: [PATCH] Add managed quote-push treasury workflows --- contracts/flash/QuotePushTreasuryManager.sol | 76 ++++++++-- ...odoToUniswapV3MultiHopExternalUnwinder.sol | 92 ++++++++++++ .../DeployQuotePushTreasuryManager.s.sol | 3 +- ...oToUniswapV3MultiHopExternalUnwinder.s.sol | 36 +++++ .../ManageQuotePushTreasuryManager.s.sol | 6 + ...unMainnetAaveCwusdcUsdcQuotePushOnce.s.sol | 16 ++- ...dMainnetAaveCwusdcUsdcQuotePushCycle.s.sol | 42 +++--- test/flash/QuotePushTreasuryManager.t.sol | 132 +++++++++++++++++- ...3MultiHopExternalUnwinderMainnetFork.t.sol | 65 +++++++++ 9 files changed, 429 insertions(+), 39 deletions(-) create mode 100644 contracts/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinder.sol create mode 100644 script/deploy/DeployTwoHopDodoToUniswapV3MultiHopExternalUnwinder.s.sol create mode 100644 test/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinderMainnetFork.t.sol diff --git a/contracts/flash/QuotePushTreasuryManager.sol b/contracts/flash/QuotePushTreasuryManager.sol index a35327c..fe7bd69 100644 --- a/contracts/flash/QuotePushTreasuryManager.sol +++ b/contracts/flash/QuotePushTreasuryManager.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {AaveQuotePushFlashReceiver} from "./AaveQuotePushFlashReceiver.sol"; interface IQuotePushSweepableReceiver { function owner() external view returns (address); @@ -11,6 +12,7 @@ interface IQuotePushSweepableReceiver { function sweepQuoteSurplus(address quoteToken, address to, uint256 reserveRetained) external returns (uint256 amount); + function transferOwnership(address newOwner) external; } /** @@ -40,6 +42,7 @@ contract QuotePushTreasuryManager is Ownable { error Unauthorized(); error NothingToHarvest(); error InsufficientAvailable(uint256 available, uint256 requested); + error ReceiverNotOwnedByManager(); event OperatorUpdated(address indexed previousOperator, address indexed newOperator); event RecipientsUpdated(address indexed gasRecipient, address indexed recycleRecipient); @@ -47,6 +50,14 @@ contract QuotePushTreasuryManager is Ownable { event ReceiverSurplusHarvested(address indexed token, uint256 amount, uint256 receiverReserveRetained); event QuoteDistributed(address indexed token, address indexed to, uint256 amount, bytes32 purpose); event TokenRescued(address indexed token, address indexed to, uint256 amount); + event ManagedCycleExecuted( + address indexed flashAsset, + uint256 flashAmount, + uint256 harvested, + uint256 gasAmount, + uint256 recycleAmount + ); + event ManagedReceiverOwnershipTransferred(address indexed previousOwner, address indexed newOwner); modifier onlyOwnerOrOperator() { if (msg.sender != owner() && msg.sender != operator) revert Unauthorized(); @@ -124,22 +135,49 @@ contract QuotePushTreasuryManager is Ownable { emit ReceiverSurplusHarvested(address(quoteToken), amount, receiverReserveRetained); } + function runManagedCycle( + address flashAsset, + uint256 flashAmount, + AaveQuotePushFlashReceiver.QuotePushParams calldata params, + bool harvestSurplus, + uint256 gasHoldbackTargetRaw + ) external onlyOwnerOrOperator returns (uint256 harvested, uint256 gasAmount, uint256 recycleAmount) { + if (!isReceiverOwnedByManager()) revert ReceiverNotOwnedByManager(); + + AaveQuotePushFlashReceiver(address(receiver)).flashQuotePush(flashAsset, flashAmount, params); + + if (harvestSurplus) { + uint256 sweepable = receiverSweepableQuote(); + if (sweepable > 0) { + harvested = harvestReceiverSurplus(); + } + } + + uint256 available = availableQuote(); + gasAmount = _min(available, gasHoldbackTargetRaw); + recycleAmount = available - gasAmount; + + if (gasAmount > 0 || recycleAmount > 0) { + _distributeConfigured(gasAmount, recycleAmount); + } + + emit ManagedCycleExecuted(flashAsset, flashAmount, harvested, gasAmount, recycleAmount); + } + function distributeQuote(address to, uint256 amount, bytes32 purpose) external onlyOwnerOrOperator { _distributeQuote(to, amount, purpose); } function distributeToConfiguredRecipients(uint256 gasAmount, uint256 recycleAmount) external onlyOwnerOrOperator { - uint256 requested = gasAmount + recycleAmount; - _requireAvailable(requested); + _distributeConfigured(gasAmount, recycleAmount); + } - if (gasAmount > 0) { - quoteToken.safeTransfer(gasRecipient, gasAmount); - emit QuoteDistributed(address(quoteToken), gasRecipient, gasAmount, bytes32("gas")); - } - if (recycleAmount > 0) { - quoteToken.safeTransfer(recycleRecipient, recycleAmount); - emit QuoteDistributed(address(quoteToken), recycleRecipient, recycleAmount, bytes32("recycle")); - } + function transferManagedReceiverOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert BadConfig(); + if (!isReceiverOwnedByManager()) revert ReceiverNotOwnedByManager(); + address previousOwner = receiver.owner(); + receiver.transferOwnership(newOwner); + emit ManagedReceiverOwnershipTransferred(previousOwner, newOwner); } function rescueToken(address token, address to, uint256 amount) external onlyOwner { @@ -158,8 +196,26 @@ contract QuotePushTreasuryManager is Ownable { emit QuoteDistributed(address(quoteToken), to, amount, purpose); } + function _distributeConfigured(uint256 gasAmount, uint256 recycleAmount) internal { + uint256 requested = gasAmount + recycleAmount; + _requireAvailable(requested); + + if (gasAmount > 0) { + quoteToken.safeTransfer(gasRecipient, gasAmount); + emit QuoteDistributed(address(quoteToken), gasRecipient, gasAmount, bytes32("gas")); + } + if (recycleAmount > 0) { + quoteToken.safeTransfer(recycleRecipient, recycleAmount); + emit QuoteDistributed(address(quoteToken), recycleRecipient, recycleAmount, bytes32("recycle")); + } + } + function _requireAvailable(uint256 requested) internal view { uint256 available = availableQuote(); if (requested > available) revert InsufficientAvailable(available, requested); } + + function _min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } } diff --git a/contracts/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinder.sol b/contracts/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinder.sol new file mode 100644 index 0000000..c4f15b6 --- /dev/null +++ b/contracts/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinder.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ISwapRouter} from "../bridge/trustless/interfaces/ISwapRouter.sol"; + +interface IDODOTwoHopSwapExactIn { + function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) + external + returns (uint256 amountOut); +} + +/** + * @title TwoHopDodoToUniswapV3MultiHopExternalUnwinder + * @notice Unwinds through two DODO PMM hops followed by a final Uniswap V3 exactInput path. + * @dev `data` = + * abi.encode( + * address poolA, + * address poolB, + * address midToken, + * uint256 minMidOut, + * address intermediateToken, + * uint256 minIntermediateOut, + * bytes uniswapPath + * ) + * Route shape: + * tokenIn --(poolA via DODO)--> midToken + * midToken --(poolB via DODO)--> intermediateToken + * intermediateToken --(Uniswap V3 path)--> tokenOut + */ +contract TwoHopDodoToUniswapV3MultiHopExternalUnwinder { + using SafeERC20 for IERC20; + + address public immutable integration; + address public immutable router; + + error BadParams(); + + constructor(address integration_, address router_) { + if (integration_ == address(0) || router_ == address(0)) revert BadParams(); + integration = integration_; + router = router_; + } + + function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata data) + external + returns (uint256 amountOut) + { + if (tokenIn == address(0) || tokenOut == address(0) || tokenIn == tokenOut || amountIn == 0) revert BadParams(); + + ( + address poolA, + address poolB, + address midToken, + uint256 minMidOut, + address intermediateToken, + uint256 minIntermediateOut, + bytes memory uniswapPath + ) = abi.decode(data, (address, address, address, uint256, address, uint256, bytes)); + + if (poolA == address(0) || poolB == address(0) || midToken == address(0) || intermediateToken == address(0)) { + revert BadParams(); + } + if (midToken == tokenIn || midToken == tokenOut || intermediateToken == tokenIn || intermediateToken == midToken) { + revert BadParams(); + } + if (uniswapPath.length < 43) revert BadParams(); + + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + IERC20(tokenIn).forceApprove(integration, amountIn); + + uint256 midOut = IDODOTwoHopSwapExactIn(integration).swapExactIn(poolA, tokenIn, amountIn, minMidOut); + if (midOut == 0) revert BadParams(); + + IERC20(midToken).forceApprove(integration, midOut); + uint256 intermediateOut = + IDODOTwoHopSwapExactIn(integration).swapExactIn(poolB, midToken, midOut, minIntermediateOut); + if (intermediateOut == 0) revert BadParams(); + + IERC20(intermediateToken).forceApprove(router, intermediateOut); + amountOut = ISwapRouter(router).exactInput( + ISwapRouter.ExactInputParams({ + path: uniswapPath, + recipient: msg.sender, + deadline: block.timestamp + 300, + amountIn: intermediateOut, + amountOutMinimum: minAmountOut + }) + ); + } +} diff --git a/script/deploy/DeployQuotePushTreasuryManager.s.sol b/script/deploy/DeployQuotePushTreasuryManager.s.sol index 373543d..217fb89 100644 --- a/script/deploy/DeployQuotePushTreasuryManager.s.sol +++ b/script/deploy/DeployQuotePushTreasuryManager.s.sol @@ -8,7 +8,7 @@ import {QuotePushTreasuryManager} from "../../contracts/flash/QuotePushTreasuryM /** * @title DeployQuotePushTreasuryManager * @notice Deploy a treasury manager for quote-push retained surplus and optionally - * hand receiver ownership to it in the same broadcast. + * hand receiver ownership to it in the same broadcast sequence. * * Env: * PRIVATE_KEY required @@ -63,6 +63,7 @@ contract DeployQuotePushTreasuryManager is Script { if (takeReceiverOwnership) { AaveQuotePushFlashReceiver(receiver).transferOwnership(address(manager)); + require(manager.isReceiverOwnedByManager(), "receiver ownership not finalized"); console.log("Receiver ownership transferred to manager"); } vm.stopBroadcast(); diff --git a/script/deploy/DeployTwoHopDodoToUniswapV3MultiHopExternalUnwinder.s.sol b/script/deploy/DeployTwoHopDodoToUniswapV3MultiHopExternalUnwinder.s.sol new file mode 100644 index 0000000..a1052b7 --- /dev/null +++ b/script/deploy/DeployTwoHopDodoToUniswapV3MultiHopExternalUnwinder.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {TwoHopDodoToUniswapV3MultiHopExternalUnwinder} from "../../contracts/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinder.sol"; + +/** + * @title DeployTwoHopDodoToUniswapV3MultiHopExternalUnwinder + * @notice Deploy three-hop DODO -> DODO -> Uniswap V3 unwinder for quote-push workflows. + * + * Env: + * PRIVATE_KEY required + * DODO_PMM_INTEGRATION_MAINNET required + * UNISWAP_V3_SWAP_ROUTER_MAINNET optional; defaults to legacy SwapRouter `0xE592...` + */ +contract DeployTwoHopDodoToUniswapV3MultiHopExternalUnwinder is Script { + address internal constant DEFAULT_UNISWAP_V3_ROUTER_MAINNET = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address integration = vm.envAddress("DODO_PMM_INTEGRATION_MAINNET"); + address router = vm.envOr("UNISWAP_V3_SWAP_ROUTER_MAINNET", DEFAULT_UNISWAP_V3_ROUTER_MAINNET); + address deployer = vm.addr(pk); + + console.log("Deployer:", deployer); + console.log("DODO PMM integration:", integration); + console.log("Uniswap V3 router:", router); + + vm.startBroadcast(pk); + TwoHopDodoToUniswapV3MultiHopExternalUnwinder unwinder = + new TwoHopDodoToUniswapV3MultiHopExternalUnwinder(integration, router); + vm.stopBroadcast(); + + console.log("TwoHopDodoToUniswapV3MultiHopExternalUnwinder:", address(unwinder)); + } +} diff --git a/script/flash/ManageQuotePushTreasuryManager.s.sol b/script/flash/ManageQuotePushTreasuryManager.s.sol index 7d5d728..81fa8ec 100644 --- a/script/flash/ManageQuotePushTreasuryManager.s.sol +++ b/script/flash/ManageQuotePushTreasuryManager.s.sol @@ -15,6 +15,7 @@ import {QuotePushTreasuryManager} from "../../contracts/flash/QuotePushTreasuryM * QUOTE_PUSH_TREASURY_HARVEST optional; default 1 * QUOTE_PUSH_TREASURY_GAS_DISTRIBUTION_RAW optional; default 0 * QUOTE_PUSH_TREASURY_RECYCLE_DISTRIBUTION_RAW optional; default 0 + * QUOTE_PUSH_TREASURY_TRANSFER_RECEIVER_OWNER_TO optional; when set, transfer receiver ownership */ contract ManageQuotePushTreasuryManager is Script { function run() external { @@ -23,6 +24,7 @@ contract ManageQuotePushTreasuryManager is Script { bool harvest = vm.envOr("QUOTE_PUSH_TREASURY_HARVEST", uint256(1)) == 1; uint256 gasAmount = vm.envOr("QUOTE_PUSH_TREASURY_GAS_DISTRIBUTION_RAW", uint256(0)); uint256 recycleAmount = vm.envOr("QUOTE_PUSH_TREASURY_RECYCLE_DISTRIBUTION_RAW", uint256(0)); + address transferReceiverOwnerTo = vm.envOr("QUOTE_PUSH_TREASURY_TRANSFER_RECEIVER_OWNER_TO", address(0)); QuotePushTreasuryManager manager = QuotePushTreasuryManager(managerAddr); @@ -30,6 +32,7 @@ contract ManageQuotePushTreasuryManager is Script { console.log("harvest", harvest); console.log("gasAmount", gasAmount); console.log("recycleAmount", recycleAmount); + console.log("transferReceiverOwnerTo", transferReceiverOwnerTo); console.log("quoteBalanceBefore", manager.quoteBalance()); console.log("availableBefore", manager.availableQuote()); console.log("receiverSweepableBefore", manager.receiverSweepableQuote()); @@ -42,6 +45,9 @@ contract ManageQuotePushTreasuryManager is Script { if (gasAmount > 0 || recycleAmount > 0) { manager.distributeToConfiguredRecipients(gasAmount, recycleAmount); } + if (transferReceiverOwnerTo != address(0)) { + manager.transferManagedReceiverOwnership(transferReceiverOwnerTo); + } vm.stopBroadcast(); console.log("quoteBalanceAfter", manager.quoteBalance()); diff --git a/script/flash/RunMainnetAaveCwusdcUsdcQuotePushOnce.s.sol b/script/flash/RunMainnetAaveCwusdcUsdcQuotePushOnce.s.sol index 8a3882f..b84014e 100644 --- a/script/flash/RunMainnetAaveCwusdcUsdcQuotePushOnce.s.sol +++ b/script/flash/RunMainnetAaveCwusdcUsdcQuotePushOnce.s.sol @@ -41,6 +41,10 @@ interface IDODOPMMPoolQuote { * UNWIND_MODE=5 DODOToUniswapV3MultiHopExternalUnwinder: * set UNWIND_DODO_POOL, UNWIND_INTERMEDIATE_TOKEN, UNWIND_MIN_INTERMEDIATE_OUT_RAW, * UNWIND_V3_PATH_HEX (path starts at intermediate token and ends at tokenOut) + * UNWIND_MODE=6 TwoHopDodoToUniswapV3MultiHopExternalUnwinder: + * set UNWIND_TWO_HOP_POOL_A, UNWIND_TWO_HOP_POOL_B, UNWIND_TWO_HOP_MID_TOKEN, + * optional UNWIND_MIN_MID_OUT_RAW, then UNWIND_INTERMEDIATE_TOKEN, + * UNWIND_MIN_INTERMEDIATE_OUT_RAW, UNWIND_V3_PATH_HEX */ contract RunMainnetAaveCwusdcUsdcQuotePushOnce is Script { address internal constant DEFAULT_POOL = 0x69776fc607e9edA8042e320e7e43f54d06c68f0E; @@ -99,8 +103,18 @@ contract RunMainnetAaveCwusdcUsdcQuotePushOnce is Script { string memory pathHex = vm.envString("UNWIND_V3_PATH_HEX"); bytes memory path = vm.parseBytes(pathHex); unwindData = abi.encode(dodoPool, intermediateToken, minIntermediateOut, path); + } else if (unwindMode == 6) { + address poolA = vm.envAddress("UNWIND_TWO_HOP_POOL_A"); + address poolB = vm.envAddress("UNWIND_TWO_HOP_POOL_B"); + address midToken = vm.envAddress("UNWIND_TWO_HOP_MID_TOKEN"); + uint256 minMidOut = vm.envOr("UNWIND_MIN_MID_OUT_RAW", uint256(1)); + address intermediateToken = vm.envAddress("UNWIND_INTERMEDIATE_TOKEN"); + uint256 minIntermediateOut = vm.envOr("UNWIND_MIN_INTERMEDIATE_OUT_RAW", uint256(1)); + string memory pathHex = vm.envString("UNWIND_V3_PATH_HEX"); + bytes memory path = vm.parseBytes(pathHex); + unwindData = abi.encode(poolA, poolB, midToken, minMidOut, intermediateToken, minIntermediateOut, path); } else { - revert("UNWIND_MODE must be 0, 1, 2, 4, or 5"); + revert("UNWIND_MODE must be 0, 1, 2, 4, 5, or 6"); } AaveQuotePushFlashReceiver.QuotePushParams memory p = AaveQuotePushFlashReceiver.QuotePushParams({ diff --git a/script/flash/RunManagedMainnetAaveCwusdcUsdcQuotePushCycle.s.sol b/script/flash/RunManagedMainnetAaveCwusdcUsdcQuotePushCycle.s.sol index dfbf5e2..b97103a 100644 --- a/script/flash/RunManagedMainnetAaveCwusdcUsdcQuotePushCycle.s.sol +++ b/script/flash/RunManagedMainnetAaveCwusdcUsdcQuotePushCycle.s.sol @@ -58,29 +58,13 @@ contract RunManagedMainnetAaveCwusdcUsdcQuotePushCycle is Script { console.log("gasHoldbackTargetRaw", gasHoldbackTargetRaw); vm.startBroadcast(pk); - AaveQuotePushFlashReceiver(receiver).flashQuotePush(usdc, amount, p); - - uint256 harvested = 0; - if (harvest) { - uint256 receiverSweepableAfterFlash = manager.receiverSweepableQuote(); - if (receiverSweepableAfterFlash > 0) { - harvested = manager.harvestReceiverSurplus(); - } - console.log("receiverSweepableAfterFlash", receiverSweepableAfterFlash); - } - - uint256 available = manager.availableQuote(); - uint256 gasAmount = _min(available, gasHoldbackTargetRaw); - uint256 recycleAmount = available - gasAmount; - - if (gasAmount > 0 || recycleAmount > 0) { - manager.distributeToConfiguredRecipients(gasAmount, recycleAmount); - } + (uint256 harvested, uint256 gasAmount, uint256 recycleAmount) = + manager.runManagedCycle(usdc, amount, p, harvest, gasHoldbackTargetRaw); vm.stopBroadcast(); - console.log("harvested", harvested); - console.log("gasDistributionRaw", gasAmount); - console.log("recycleDistributionRaw", recycleAmount); + console.log("managedCycleHarvestedRaw", harvested); + console.log("managedCycleGasDistributionRaw", gasAmount); + console.log("managedCycleRecycleDistributionRaw", recycleAmount); console.log("managerQuoteAfter", manager.quoteBalance()); console.log("managerAvailableAfter", manager.availableQuote()); console.log("receiverSweepableAfter", manager.receiverSweepableQuote()); @@ -136,8 +120,18 @@ contract RunManagedMainnetAaveCwusdcUsdcQuotePushCycle is Script { string memory pathHex = vm.envString("UNWIND_V3_PATH_HEX"); bytes memory path = vm.parseBytes(pathHex); unwindData = abi.encode(dodoPool, intermediateToken, minIntermediateOut, path); + } else if (unwindMode == 6) { + address poolA = vm.envAddress("UNWIND_TWO_HOP_POOL_A"); + address poolB = vm.envAddress("UNWIND_TWO_HOP_POOL_B"); + address midToken = vm.envAddress("UNWIND_TWO_HOP_MID_TOKEN"); + uint256 minMidOut = vm.envOr("UNWIND_MIN_MID_OUT_RAW", uint256(1)); + address intermediateToken = vm.envAddress("UNWIND_INTERMEDIATE_TOKEN"); + uint256 minIntermediateOut = vm.envOr("UNWIND_MIN_INTERMEDIATE_OUT_RAW", uint256(1)); + string memory pathHex = vm.envString("UNWIND_V3_PATH_HEX"); + bytes memory path = vm.parseBytes(pathHex); + unwindData = abi.encode(poolA, poolB, midToken, minMidOut, intermediateToken, minIntermediateOut, path); } else { - revert("UNWIND_MODE must be 0, 1, 2, 4, or 5"); + revert("UNWIND_MODE must be 0, 1, 2, 4, 5, or 6"); } p = AaveQuotePushFlashReceiver.QuotePushParams({ @@ -166,8 +160,4 @@ contract RunManagedMainnetAaveCwusdcUsdcQuotePushCycle is Script { console.log("minOutPmm", minOutPmm); console.log("minOutUnwind", minOutUnwind); } - - function _min(uint256 a, uint256 b) internal pure returns (uint256) { - return a < b ? a : b; - } } diff --git a/test/flash/QuotePushTreasuryManager.t.sol b/test/flash/QuotePushTreasuryManager.t.sol index e364350..41bdfe7 100644 --- a/test/flash/QuotePushTreasuryManager.t.sol +++ b/test/flash/QuotePushTreasuryManager.t.sol @@ -7,6 +7,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {QuotePushTreasuryManager} from "../../contracts/flash/QuotePushTreasuryManager.sol"; +import {AaveQuotePushFlashReceiver} from "../../contracts/flash/AaveQuotePushFlashReceiver.sol"; contract MockTreasuryToken is ERC20 { constructor() ERC20("Mock Treasury Token", "MTT") {} @@ -42,7 +43,49 @@ contract MockSweepableReceiver is Ownable { uint256 balance = token.balanceOf(address(this)); require(balance > reserveRetained, "nothing to sweep"); amount = balance - reserveRetained; - token.safeTransfer(to, amount); + IERC20(address(token)).safeTransfer(to, amount); + } +} + +contract MockManagedCycleReceiver is Ownable { + using SafeERC20 for IERC20; + + MockTreasuryToken internal immutable token; + uint256 internal simulatedSurplusRaw; + + constructor(address initialOwner, MockTreasuryToken token_) Ownable(initialOwner) { + token = token_; + } + + function setSimulatedSurplusRaw(uint256 amount) external { + simulatedSurplusRaw = amount; + } + + function flashQuotePush(address asset, uint256, AaveQuotePushFlashReceiver.QuotePushParams calldata) external { + require(asset == address(token), "wrong asset"); + if (simulatedSurplusRaw > 0) { + token.mint(address(this), simulatedSurplusRaw); + } + } + + function quoteSurplusBalance(address quoteToken, uint256 reserveRetained) external view returns (uint256 surplus) { + require(quoteToken == address(token), "wrong token"); + uint256 balance = token.balanceOf(address(this)); + if (balance > reserveRetained) { + surplus = balance - reserveRetained; + } + } + + function sweepQuoteSurplus(address quoteToken, address to, uint256 reserveRetained) + external + onlyOwner + returns (uint256 amount) + { + require(quoteToken == address(token), "wrong token"); + uint256 balance = token.balanceOf(address(this)); + require(balance > reserveRetained, "nothing to sweep"); + amount = balance - reserveRetained; + IERC20(address(token)).safeTransfer(to, amount); } } @@ -50,6 +93,8 @@ contract QuotePushTreasuryManagerTest is Test { MockTreasuryToken internal token; MockSweepableReceiver internal receiver; QuotePushTreasuryManager internal manager; + MockManagedCycleReceiver internal cycleReceiver; + QuotePushTreasuryManager internal cycleManager; address internal constant OPERATOR = address(0xBEEF); address internal constant GAS_RECIPIENT = address(0xCAFE); @@ -64,6 +109,12 @@ contract QuotePushTreasuryManagerTest is Test { receiver.transferOwnership(address(manager)); token.mint(address(receiver), 1_000_000); + + cycleReceiver = new MockManagedCycleReceiver(address(this), token); + cycleManager = new QuotePushTreasuryManager( + address(this), address(cycleReceiver), address(token), OPERATOR, GAS_RECIPIENT, RECYCLE_RECIPIENT, 10_000, 5_000 + ); + cycleReceiver.transferOwnership(address(cycleManager)); } function testHarvestReceiverSurplusKeepsReceiverReserve() public { @@ -106,4 +157,83 @@ contract QuotePushTreasuryManagerTest is Test { assertEq(other.balanceOf(address(this)), 42, "owner should be able to rescue unrelated tokens"); } + + function testRunManagedCycleHarvestsAndDistributesAtomically() public { + cycleReceiver.setSimulatedSurplusRaw(80_000); + + AaveQuotePushFlashReceiver.QuotePushParams memory params = AaveQuotePushFlashReceiver.QuotePushParams({ + integration: address(0x1111), + pmmPool: address(0x2222), + baseToken: address(0x3333), + externalUnwinder: address(0x4444), + minOutPmm: 1, + minOutUnwind: 1, + unwindData: "", + atomicBridge: AaveQuotePushFlashReceiver.AtomicBridgeParams({ + coordinator: address(0), + sourceChain: 0, + destinationChain: 0, + destinationAsset: address(0), + bridgeAmount: 0, + minDestinationAmount: 0, + destinationRecipient: address(0), + destinationDeadline: 0, + routeId: bytes32(0), + settlementMode: bytes32(0), + submitCommitment: false + }) + }); + + vm.prank(OPERATOR); + (uint256 harvested, uint256 gasAmount, uint256 recycleAmount) = + cycleManager.runManagedCycle(address(token), 20_000, params, true, 15_000); + + assertEq(harvested, 70_000, "receiver reserve should stay behind"); + assertEq(gasAmount, 15_000, "gas holdback should consume configured slice"); + assertEq(recycleAmount, 50_000, "remaining available quote should recycle"); + assertEq(token.balanceOf(GAS_RECIPIENT), 15_000, "gas recipient receives holdback"); + assertEq(token.balanceOf(RECYCLE_RECIPIENT), 50_000, "recycle recipient receives remaining quote"); + assertEq(token.balanceOf(address(cycleReceiver)), 10_000, "receiver reserve is retained"); + assertEq(token.balanceOf(address(cycleManager)), 5_000, "manager reserve is retained"); + } + + function testOwnerCanTransferManagedReceiverOwnership() public { + address newOwner = address(0xABCD); + + manager.transferManagedReceiverOwnership(newOwner); + + assertEq(manager.receiverOwner(), newOwner, "receiver ownership should move to requested address"); + assertFalse(manager.isReceiverOwnedByManager(), "manager should no longer own receiver"); + } + + function testRunManagedCycleRevertsWhenManagerDoesNotOwnReceiver() public { + AaveQuotePushFlashReceiver.QuotePushParams memory params = AaveQuotePushFlashReceiver.QuotePushParams({ + integration: address(0x1111), + pmmPool: address(0x2222), + baseToken: address(0x3333), + externalUnwinder: address(0x4444), + minOutPmm: 1, + minOutUnwind: 1, + unwindData: "", + atomicBridge: AaveQuotePushFlashReceiver.AtomicBridgeParams({ + coordinator: address(0), + sourceChain: 0, + destinationChain: 0, + destinationAsset: address(0), + bridgeAmount: 0, + minDestinationAmount: 0, + destinationRecipient: address(0), + destinationDeadline: 0, + routeId: bytes32(0), + settlementMode: bytes32(0), + submitCommitment: false + }) + }); + + cycleManager.transferManagedReceiverOwnership(address(this)); + + vm.prank(OPERATOR); + vm.expectRevert(QuotePushTreasuryManager.ReceiverNotOwnedByManager.selector); + cycleManager.runManagedCycle(address(token), 20_000, params, true, 15_000); + } } diff --git a/test/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinderMainnetFork.t.sol b/test/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinderMainnetFork.t.sol new file mode 100644 index 0000000..f480ca8 --- /dev/null +++ b/test/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinderMainnetFork.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {TwoHopDodoToUniswapV3MultiHopExternalUnwinder} from "../../contracts/flash/TwoHopDodoToUniswapV3MultiHopExternalUnwinder.sol"; + +contract TwoHopDodoToUniswapV3MultiHopExternalUnwinderMainnetForkTest is Test { + address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84; + address constant UNISWAP_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + address constant POOL_CWUSDT_CWUSDC = 0xe944b7Cb012A0820c07f54D51e92f0e1C74168DB; + address constant POOL_CWUSDT_USDT = 0x79156F6B7bf71a1B72D78189B540A89A6C13F6FC; + address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a; + address constant CWUSDT = 0xaF5017d0163ecb99D9B5D94e3b4D7b09Af44D8AE; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + bytes constant USDT_TO_USDC_0_01_PATH = + hex"dac17f958d2ee523a2206206994597c13d831ec7000064a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; + + bool public forkAvailable; + TwoHopDodoToUniswapV3MultiHopExternalUnwinder internal unwinder; + + modifier skipIfNoFork() { + if (!forkAvailable) { + return; + } + _; + } + + function setUp() public { + string memory rpcUrl = vm.envOr("ETHEREUM_MAINNET_RPC", string("")); + if (bytes(rpcUrl).length == 0) { + forkAvailable = false; + return; + } + try vm.createSelectFork(rpcUrl) { + forkAvailable = true; + } catch { + forkAvailable = false; + return; + } + + unwinder = new TwoHopDodoToUniswapV3MultiHopExternalUnwinder(DODO_PMM_INTEGRATION_MAINNET, UNISWAP_V3_ROUTER); + } + + function testFork_cWUSDCToUSDC_unwindsThroughTwoDodoHopsThenV3() public skipIfNoFork { + uint256 amountIn = 100_000; // 0.1 cWUSDC + deal(CWUSDC, address(this), amountIn); + IERC20(CWUSDC).approve(address(unwinder), amountIn); + + uint256 before = IERC20(USDC).balanceOf(address(this)); + uint256 amountOut = unwinder.unwind( + CWUSDC, + USDC, + amountIn, + 1, + abi.encode(POOL_CWUSDT_CWUSDC, POOL_CWUSDT_USDT, CWUSDT, 1, USDT, 1, USDT_TO_USDC_0_01_PATH) + ); + uint256 afterBal = IERC20(USDC).balanceOf(address(this)); + + assertGt(amountOut, 0, "amountOut > 0"); + assertEq(afterBal - before, amountOut, "USDC received"); + } +}