Add managed quote-push treasury workflows
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user