Add managed quote-push treasury workflows

This commit is contained in:
defiQUG
2026-04-13 21:37:33 -07:00
parent 2b52cc6e32
commit 7517869ea6
9 changed files with 429 additions and 39 deletions

View File

@@ -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);
}
}

View File

@@ -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");
}
}