Add managed quote-push treasury workflows
This commit is contained in:
@@ -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