feat: bridges, PMM, flash workflow, token-aggregation, and deployment docs
- CCIP/trustless bridge contracts, GRU tokens, DEX/PMM tests, reserve vault. - Token-aggregation service routes, planner, chain config, relay env templates. - Config snapshots and multi-chain deployment markdown updates. - gitignore services/btc-intake/dist/ (tsc output); do not track dist. Run forge build && forge test before deploy (large solc graph). Made-with: Cursor
This commit is contained in:
115
test/flash/AaveQuotePushFlashReceiverMainnetFork.t.sol
Normal file
115
test/flash/AaveQuotePushFlashReceiverMainnetFork.t.sol
Normal file
@@ -0,0 +1,115 @@
|
||||
// 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 {
|
||||
AaveQuotePushFlashReceiver,
|
||||
IAaveExternalUnwinder
|
||||
} from "../../contracts/flash/AaveQuotePushFlashReceiver.sol";
|
||||
|
||||
contract AaveForkMockExternalUnwinder is IAaveExternalUnwinder {
|
||||
IERC20 public immutable base;
|
||||
IERC20 public immutable quote;
|
||||
uint256 public immutable numerator;
|
||||
uint256 public immutable denominator;
|
||||
|
||||
constructor(IERC20 base_, IERC20 quote_, uint256 numerator_, uint256 denominator_) {
|
||||
base = base_;
|
||||
quote = quote_;
|
||||
numerator = numerator_;
|
||||
denominator = denominator_;
|
||||
}
|
||||
|
||||
function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata)
|
||||
external
|
||||
override
|
||||
returns (uint256 amountOut)
|
||||
{
|
||||
require(tokenIn == address(base), "base only");
|
||||
require(tokenOut == address(quote), "quote only");
|
||||
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
|
||||
amountOut = amountIn * numerator / denominator;
|
||||
require(amountOut >= minAmountOut, "min unwind");
|
||||
IERC20(address(quote)).transfer(msg.sender, amountOut);
|
||||
}
|
||||
}
|
||||
|
||||
contract AaveQuotePushFlashReceiverMainnetForkTest is Test {
|
||||
address constant AAVE_POOL_MAINNET = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2;
|
||||
address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84;
|
||||
address constant POOL_CWUSDC_USDC = 0x69776fc607e9edA8042e320e7e43f54d06c68f0E;
|
||||
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
|
||||
address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a;
|
||||
|
||||
bool public forkAvailable;
|
||||
AaveQuotePushFlashReceiver internal receiver;
|
||||
AaveForkMockExternalUnwinder 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;
|
||||
}
|
||||
|
||||
receiver = new AaveQuotePushFlashReceiver(AAVE_POOL_MAINNET);
|
||||
unwinder = new AaveForkMockExternalUnwinder(IERC20(CWUSDC), IERC20(USDC), 112, 100);
|
||||
deal(USDC, address(unwinder), 100_000_000); // 100 USDC quote inventory for unwind payouts
|
||||
}
|
||||
|
||||
function testFork_aaveQuotePush_usesRealAaveAndRealMainnetPmm() public skipIfNoFork {
|
||||
uint256 amount = 2_964_298; // current safe tranche at 120/120
|
||||
uint256 receiverQuoteBefore = IERC20(USDC).balanceOf(address(receiver));
|
||||
uint256 poolBaseBefore = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC);
|
||||
uint256 poolQuoteBefore = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC);
|
||||
|
||||
AaveQuotePushFlashReceiver.QuotePushParams memory p = AaveQuotePushFlashReceiver.QuotePushParams({
|
||||
integration: DODO_PMM_INTEGRATION_MAINNET,
|
||||
pmmPool: POOL_CWUSDC_USDC,
|
||||
baseToken: CWUSDC,
|
||||
externalUnwinder: address(unwinder),
|
||||
minOutPmm: 2_800_000,
|
||||
minOutUnwind: amount + 1_483, // 5 bps Aave premium
|
||||
unwindData: bytes(""),
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
receiver.flashQuotePush(USDC, amount, p);
|
||||
|
||||
uint256 receiverQuoteAfter = IERC20(USDC).balanceOf(address(receiver));
|
||||
uint256 poolBaseAfter = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC);
|
||||
uint256 poolQuoteAfter = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC);
|
||||
|
||||
assertGt(receiverQuoteAfter, receiverQuoteBefore, "receiver retains surplus");
|
||||
assertEq(IERC20(CWUSDC).balanceOf(address(receiver)), 0, "base fully unwound");
|
||||
assertLt(poolBaseAfter, poolBaseBefore, "pool base decreased via quote push");
|
||||
assertGt(poolQuoteAfter, poolQuoteBefore, "pool quote increased via quote push");
|
||||
}
|
||||
}
|
||||
79
test/flash/CrossChainFlashBorrower.t.sol
Normal file
79
test/flash/CrossChainFlashBorrower.t.sol
Normal file
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "forge-std/Test.sol";
|
||||
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
|
||||
import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol";
|
||||
import {CrossChainFlashBorrower} from "../../contracts/flash/CrossChainFlashBorrower.sol";
|
||||
import {ICrossChainFlashBridge} from "../../contracts/flash/interfaces/ICrossChainFlashBridge.sol";
|
||||
|
||||
contract MockCrossChainBridge is ICrossChainFlashBridge {
|
||||
event BridgeCalled(address token, uint256 amount, uint64 dest, address recipient, bytes extra, uint256 value);
|
||||
|
||||
function bridgeTokensFrom(
|
||||
address token,
|
||||
uint256 amount,
|
||||
uint64 destinationChainSelector,
|
||||
address recipientOnDestination,
|
||||
bytes calldata extraData
|
||||
) external payable override returns (bytes32 messageId) {
|
||||
IERC20(token).transferFrom(msg.sender, address(this), amount);
|
||||
emit BridgeCalled(token, amount, destinationChainSelector, recipientOnDestination, extraData, msg.value);
|
||||
messageId = keccak256(abi.encodePacked(block.number, token, amount));
|
||||
}
|
||||
}
|
||||
|
||||
contract MockERC20Mint is ERC20 {
|
||||
constructor() ERC20("T", "T") {}
|
||||
|
||||
function mint(address to, uint256 v) external {
|
||||
_mint(to, v);
|
||||
}
|
||||
}
|
||||
|
||||
contract CrossChainFlashBorrowerTest is Test {
|
||||
SimpleERC3156FlashVault internal vault;
|
||||
MockERC20Mint internal token;
|
||||
MockCrossChainBridge internal bridge;
|
||||
CrossChainFlashBorrower internal borrower;
|
||||
|
||||
address internal owner = address(0xA11);
|
||||
address internal user = address(0xB22);
|
||||
|
||||
function setUp() public {
|
||||
vm.startPrank(owner);
|
||||
vault = new SimpleERC3156FlashVault(owner, 5);
|
||||
token = new MockERC20Mint();
|
||||
token.mint(address(vault), 1_000_000e18);
|
||||
vault.setTokenSupported(address(token), true);
|
||||
vm.stopPrank();
|
||||
|
||||
bridge = new MockCrossChainBridge();
|
||||
borrower = new CrossChainFlashBorrower(address(vault));
|
||||
}
|
||||
|
||||
function test_flashBridge_out_repaysFromPrefund() public {
|
||||
uint256 amount = 40_000e18;
|
||||
uint256 fee = vault.flashFee(address(token), amount);
|
||||
uint256 bridgeAmount = amount;
|
||||
|
||||
token.mint(address(borrower), bridgeAmount + fee);
|
||||
|
||||
CrossChainFlashBorrower.CrossChainFlashParams memory p = CrossChainFlashBorrower.CrossChainFlashParams({
|
||||
bridge: address(bridge),
|
||||
bridgeAmount: bridgeAmount,
|
||||
destinationChainSelector: 123,
|
||||
recipientOnDestination: address(0xbeef),
|
||||
bridgeExtraData: hex"abcd",
|
||||
nativeBridgeFee: 0
|
||||
});
|
||||
|
||||
vm.prank(user);
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), amount, abi.encode(p));
|
||||
|
||||
assertEq(token.balanceOf(address(bridge)), bridgeAmount);
|
||||
assertEq(vault.totalFeesCollected(address(token)), fee);
|
||||
}
|
||||
}
|
||||
63
test/flash/CrossChainFlashRepayReceiver.t.sol
Normal file
63
test/flash/CrossChainFlashRepayReceiver.t.sol
Normal file
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "forge-std/Test.sol";
|
||||
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
import {CrossChainFlashRepayReceiver} from "../../contracts/flash/CrossChainFlashRepayReceiver.sol";
|
||||
import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol";
|
||||
|
||||
contract MockToken is ERC20 {
|
||||
constructor() ERC20("X", "X") {}
|
||||
|
||||
function mint(address to, uint256 v) external {
|
||||
_mint(to, v);
|
||||
}
|
||||
}
|
||||
|
||||
contract CrossChainFlashRepayReceiverTest is Test {
|
||||
CrossChainFlashRepayReceiver internal recv;
|
||||
MockToken internal token;
|
||||
address internal router;
|
||||
|
||||
function setUp() public {
|
||||
router = makeAddr("ccipRouter");
|
||||
recv = new CrossChainFlashRepayReceiver(router);
|
||||
token = new MockToken();
|
||||
}
|
||||
|
||||
function _msg(address beneficiary, bytes32 obligation, uint256 amt, address tok)
|
||||
internal
|
||||
pure
|
||||
returns (IRouterClient.Any2EVMMessage memory m)
|
||||
{
|
||||
IRouterClient.TokenAmount[] memory amounts = new IRouterClient.TokenAmount[](1);
|
||||
amounts[0] = IRouterClient.TokenAmount({token: tok, amount: amt, amountType: IRouterClient.TokenAmountType.Fiat});
|
||||
m = IRouterClient.Any2EVMMessage({
|
||||
messageId: keccak256("mid"),
|
||||
sourceChainSelector: 138,
|
||||
sender: abi.encode(address(0x111)),
|
||||
data: abi.encode(beneficiary, obligation),
|
||||
tokenAmounts: amounts
|
||||
});
|
||||
}
|
||||
|
||||
function test_ccipReceive_forwardsToRecipient() public {
|
||||
address beneficiary = address(0xB0B);
|
||||
bytes32 obligation = keccak256("obligation-1");
|
||||
uint256 amt = 777e18;
|
||||
token.mint(address(recv), amt);
|
||||
|
||||
IRouterClient.Any2EVMMessage memory message = _msg(beneficiary, obligation, amt, address(token));
|
||||
|
||||
vm.prank(router);
|
||||
recv.ccipReceive(message);
|
||||
|
||||
assertEq(token.balanceOf(beneficiary), amt);
|
||||
}
|
||||
|
||||
function test_ccipReceive_revert_notRouter() public {
|
||||
IRouterClient.Any2EVMMessage memory message = _msg(address(0x1), bytes32(0), 1, address(token));
|
||||
vm.expectRevert(CrossChainFlashRepayReceiver.OnlyRouter.selector);
|
||||
recv.ccipReceive(message);
|
||||
}
|
||||
}
|
||||
58
test/flash/CrossChainFlashVaultCreditReceiver.t.sol
Normal file
58
test/flash/CrossChainFlashVaultCreditReceiver.t.sol
Normal file
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "forge-std/Test.sol";
|
||||
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
import {CrossChainFlashVaultCreditReceiver} from "../../contracts/flash/CrossChainFlashVaultCreditReceiver.sol";
|
||||
import {IRouterClient} from "../../contracts/ccip/IRouterClient.sol";
|
||||
|
||||
contract CreditMockToken is ERC20 {
|
||||
constructor() ERC20("Y", "Y") {}
|
||||
|
||||
function mint(address to, uint256 v) external {
|
||||
_mint(to, v);
|
||||
}
|
||||
}
|
||||
|
||||
contract CrossChainFlashVaultCreditReceiverTest is Test {
|
||||
CrossChainFlashVaultCreditReceiver internal recv;
|
||||
CreditMockToken internal token;
|
||||
address internal router;
|
||||
|
||||
function setUp() public {
|
||||
router = makeAddr("ccipRouterCredit");
|
||||
recv = new CrossChainFlashVaultCreditReceiver(router);
|
||||
token = new CreditMockToken();
|
||||
}
|
||||
|
||||
function _msg(address vault, uint256 amt, address tok) internal pure returns (IRouterClient.Any2EVMMessage memory m) {
|
||||
IRouterClient.TokenAmount[] memory amounts = new IRouterClient.TokenAmount[](1);
|
||||
amounts[0] = IRouterClient.TokenAmount({token: tok, amount: amt, amountType: IRouterClient.TokenAmountType.Fiat});
|
||||
m = IRouterClient.Any2EVMMessage({
|
||||
messageId: keccak256("credit-mid"),
|
||||
sourceChainSelector: 50,
|
||||
sender: abi.encode(address(0x222)),
|
||||
data: abi.encode(vault),
|
||||
tokenAmounts: amounts
|
||||
});
|
||||
}
|
||||
|
||||
function test_ccipReceive_creditsVault() public {
|
||||
address vaultAddr = address(0xF1A5);
|
||||
uint256 amt = 333e18;
|
||||
token.mint(address(recv), amt);
|
||||
|
||||
IRouterClient.Any2EVMMessage memory message = _msg(vaultAddr, amt, address(token));
|
||||
|
||||
vm.prank(router);
|
||||
recv.ccipReceive(message);
|
||||
|
||||
assertEq(token.balanceOf(vaultAddr), amt);
|
||||
}
|
||||
|
||||
function test_ccipReceive_revert_notRouter() public {
|
||||
IRouterClient.Any2EVMMessage memory message = _msg(address(0x1), 1, address(token));
|
||||
vm.expectRevert(CrossChainFlashVaultCreditReceiver.OnlyRouter.selector);
|
||||
recv.ccipReceive(message);
|
||||
}
|
||||
}
|
||||
51
test/flash/DODOIntegrationExternalUnwinderMainnetFork.t.sol
Normal file
51
test/flash/DODOIntegrationExternalUnwinderMainnetFork.t.sol
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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 {DODOIntegrationExternalUnwinder} from "../../contracts/flash/DODOIntegrationExternalUnwinder.sol";
|
||||
|
||||
contract DODOIntegrationExternalUnwinderMainnetForkTest is Test {
|
||||
address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84;
|
||||
address constant POOL_CWUSDC_USDC = 0x69776fc607e9edA8042e320e7e43f54d06c68f0E;
|
||||
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
|
||||
address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a;
|
||||
|
||||
bool public forkAvailable;
|
||||
DODOIntegrationExternalUnwinder 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 DODOIntegrationExternalUnwinder(DODO_PMM_INTEGRATION_MAINNET);
|
||||
}
|
||||
|
||||
function testFork_cWUSDCToUSDC_unwindsThroughMainnetDodoIntegration() public skipIfNoFork {
|
||||
uint256 amountIn = 1_000_000; // 1 cWUSDC
|
||||
deal(CWUSDC, address(unwinder), amountIn);
|
||||
|
||||
uint256 before = IERC20(USDC).balanceOf(address(this));
|
||||
uint256 amountOut = unwinder.unwind(CWUSDC, USDC, amountIn, 1, abi.encode(POOL_CWUSDC_USDC));
|
||||
uint256 afterBal = IERC20(USDC).balanceOf(address(this));
|
||||
|
||||
assertGt(amountOut, 0, "amountOut > 0");
|
||||
assertEq(afterBal - before, amountOut, "USDC received");
|
||||
}
|
||||
}
|
||||
54
test/flash/MinimalERC3156FlashBorrower.t.sol
Normal file
54
test/flash/MinimalERC3156FlashBorrower.t.sol
Normal file
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "forge-std/Test.sol";
|
||||
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
|
||||
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol";
|
||||
import {MinimalERC3156FlashBorrower} from "../../contracts/flash/MinimalERC3156FlashBorrower.sol";
|
||||
|
||||
contract MockERC20Mint is ERC20 {
|
||||
constructor() ERC20("Mock", "MCK") {}
|
||||
|
||||
function mint(address to, uint256 v) external {
|
||||
_mint(to, v);
|
||||
}
|
||||
}
|
||||
|
||||
contract MinimalERC3156FlashBorrowerTest is Test {
|
||||
SimpleERC3156FlashVault internal vault;
|
||||
MockERC20Mint internal token;
|
||||
MinimalERC3156FlashBorrower internal borrower;
|
||||
|
||||
address internal owner = address(0xA11);
|
||||
address internal user = address(0xB22);
|
||||
|
||||
function setUp() public {
|
||||
vm.startPrank(owner);
|
||||
vault = new SimpleERC3156FlashVault(owner, 5);
|
||||
token = new MockERC20Mint();
|
||||
token.mint(address(vault), 1_000_000e18);
|
||||
vault.setTokenSupported(address(token), true);
|
||||
vm.stopPrank();
|
||||
|
||||
borrower = new MinimalERC3156FlashBorrower(address(vault));
|
||||
}
|
||||
|
||||
function test_oneUnitFlash_repayWithPrefundedFee() public {
|
||||
uint256 amount = 1000e18;
|
||||
uint256 fee = vault.flashFee(address(token), amount);
|
||||
token.mint(address(borrower), fee);
|
||||
|
||||
uint256 vaultBefore = token.balanceOf(address(vault));
|
||||
vm.prank(user);
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), amount, "");
|
||||
|
||||
assertEq(token.balanceOf(address(vault)), vaultBefore + fee);
|
||||
}
|
||||
|
||||
function test_onFlashLoan_revert_wrongLender() public {
|
||||
vm.expectRevert(MinimalERC3156FlashBorrower.UntrustedLender.selector);
|
||||
borrower.onFlashLoan(user, address(token), 1, 0, "");
|
||||
}
|
||||
}
|
||||
155
test/flash/QuotePushFlashWorkflowBorrower.t.sol
Normal file
155
test/flash/QuotePushFlashWorkflowBorrower.t.sol
Normal file
@@ -0,0 +1,155 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "forge-std/Test.sol";
|
||||
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
|
||||
import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol";
|
||||
import {
|
||||
QuotePushFlashWorkflowBorrower,
|
||||
IDODOQuotePushSwapExactIn,
|
||||
IExternalUnwinder
|
||||
} from "../../contracts/flash/QuotePushFlashWorkflowBorrower.sol";
|
||||
|
||||
contract MockQuoteToken is ERC20 {
|
||||
constructor(string memory n, string memory s) ERC20(n, s) {}
|
||||
|
||||
function mint(address to, uint256 v) external {
|
||||
_mint(to, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// @notice Simple PMM-like mock: quote in, base out at a fixed ratio.
|
||||
contract MockQuotePushIntegration is IDODOQuotePushSwapExactIn {
|
||||
IERC20 public immutable quote;
|
||||
IERC20 public immutable base;
|
||||
uint256 public immutable numerator;
|
||||
uint256 public immutable denominator;
|
||||
|
||||
constructor(IERC20 quote_, IERC20 base_, uint256 numerator_, uint256 denominator_) {
|
||||
quote = quote_;
|
||||
base = base_;
|
||||
numerator = numerator_;
|
||||
denominator = denominator_;
|
||||
}
|
||||
|
||||
function swapExactIn(address, address tokenIn, uint256 amountIn, uint256 minAmountOut)
|
||||
external
|
||||
override
|
||||
returns (uint256 amountOut)
|
||||
{
|
||||
require(tokenIn == address(quote), "quote only");
|
||||
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
|
||||
amountOut = amountIn * numerator / denominator;
|
||||
require(amountOut >= minAmountOut, "min pmm");
|
||||
IERC20(address(base)).transfer(msg.sender, amountOut);
|
||||
}
|
||||
}
|
||||
|
||||
/// @notice Simple external unwinder: base in, quote out at a fixed ratio.
|
||||
contract MockExternalUnwinder is IExternalUnwinder {
|
||||
IERC20 public immutable quote;
|
||||
IERC20 public immutable base;
|
||||
uint256 public immutable numerator;
|
||||
uint256 public immutable denominator;
|
||||
|
||||
constructor(IERC20 base_, IERC20 quote_, uint256 numerator_, uint256 denominator_) {
|
||||
base = base_;
|
||||
quote = quote_;
|
||||
numerator = numerator_;
|
||||
denominator = denominator_;
|
||||
}
|
||||
|
||||
function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata)
|
||||
external
|
||||
override
|
||||
returns (uint256 amountOut)
|
||||
{
|
||||
require(tokenIn == address(base), "base only");
|
||||
require(tokenOut == address(quote), "quote only");
|
||||
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
|
||||
amountOut = amountIn * numerator / denominator;
|
||||
require(amountOut >= minAmountOut, "min unwind");
|
||||
IERC20(address(quote)).transfer(msg.sender, amountOut);
|
||||
}
|
||||
}
|
||||
|
||||
contract QuotePushFlashWorkflowBorrowerTest is Test {
|
||||
SimpleERC3156FlashVault internal vault;
|
||||
MockQuoteToken internal usdc;
|
||||
MockQuoteToken internal cwusdc;
|
||||
MockQuotePushIntegration internal pmm;
|
||||
MockExternalUnwinder internal unwinder;
|
||||
QuotePushFlashWorkflowBorrower internal borrower;
|
||||
|
||||
address internal owner = address(0xA11);
|
||||
address internal user = address(0xB22);
|
||||
|
||||
function setUp() public {
|
||||
vm.startPrank(owner);
|
||||
vault = new SimpleERC3156FlashVault(owner, 5);
|
||||
usdc = new MockQuoteToken("USDC", "USDC");
|
||||
cwusdc = new MockQuoteToken("cWUSDC", "cWUSDC");
|
||||
usdc.mint(address(vault), 1_000_000e6);
|
||||
vault.setTokenSupported(address(usdc), true);
|
||||
vm.stopPrank();
|
||||
|
||||
// PMM leg: slightly under 1:1, like a fee-bearing quote->base purchase.
|
||||
pmm = new MockQuotePushIntegration(usdc, cwusdc, 9997, 10000);
|
||||
// External unwind: profitable 1.12x unwind.
|
||||
unwinder = new MockExternalUnwinder(cwusdc, usdc, 112, 100);
|
||||
borrower = new QuotePushFlashWorkflowBorrower(address(vault));
|
||||
|
||||
cwusdc.mint(address(pmm), 10_000_000e6);
|
||||
usdc.mint(address(unwinder), 10_000_000e6);
|
||||
}
|
||||
|
||||
function test_quotePushRoundTrip_repayAndRetainSurplus() public {
|
||||
uint256 amount = 4_145_894; // 4.145894 USDC
|
||||
uint256 fee = vault.flashFee(address(usdc), amount);
|
||||
|
||||
QuotePushFlashWorkflowBorrower.QuotePushParams memory p = QuotePushFlashWorkflowBorrower.QuotePushParams({
|
||||
integration: address(pmm),
|
||||
pool: address(0x69776fc607e9edA8042e320e7e43f54d06c68f0E),
|
||||
baseToken: address(cwusdc),
|
||||
externalUnwinder: address(unwinder),
|
||||
minOutPmm: 4_100_000,
|
||||
minOutUnwind: amount + fee,
|
||||
unwindData: bytes("")
|
||||
});
|
||||
|
||||
uint256 feesBefore = vault.totalFeesCollected(address(usdc));
|
||||
vm.prank(user);
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(usdc), amount, abi.encode(p));
|
||||
|
||||
uint256 feesAfter = vault.totalFeesCollected(address(usdc));
|
||||
uint256 borrowerSurplus = usdc.balanceOf(address(borrower));
|
||||
|
||||
assertEq(feesAfter - feesBefore, fee, "flash fee collected");
|
||||
assertGt(borrowerSurplus, 0, "surplus retained");
|
||||
assertEq(cwusdc.balanceOf(address(borrower)), 0, "base fully unwound");
|
||||
}
|
||||
|
||||
function test_quotePushRoundTrip_revert_whenUnwindCannotRepay() public {
|
||||
uint256 amount = 4_145_894; // 4.145894 USDC
|
||||
|
||||
// Replace profitable unwinder with a losing one.
|
||||
MockExternalUnwinder badUnwinder = new MockExternalUnwinder(cwusdc, usdc, 95, 100);
|
||||
usdc.mint(address(badUnwinder), 10_000_000e6);
|
||||
|
||||
QuotePushFlashWorkflowBorrower.QuotePushParams memory p = QuotePushFlashWorkflowBorrower.QuotePushParams({
|
||||
integration: address(pmm),
|
||||
pool: address(0x69776fc607e9edA8042e320e7e43f54d06c68f0E),
|
||||
baseToken: address(cwusdc),
|
||||
externalUnwinder: address(badUnwinder),
|
||||
minOutPmm: 4_100_000,
|
||||
minOutUnwind: 1,
|
||||
unwindData: bytes("")
|
||||
});
|
||||
|
||||
vm.expectRevert(QuotePushFlashWorkflowBorrower.InsufficientToRepay.selector);
|
||||
vm.prank(user);
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(usdc), amount, abi.encode(p));
|
||||
}
|
||||
}
|
||||
114
test/flash/QuotePushFlashWorkflowBorrowerMainnetFork.t.sol
Normal file
114
test/flash/QuotePushFlashWorkflowBorrowerMainnetFork.t.sol
Normal file
@@ -0,0 +1,114 @@
|
||||
// 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 {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
|
||||
import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol";
|
||||
import {
|
||||
QuotePushFlashWorkflowBorrower,
|
||||
IExternalUnwinder
|
||||
} from "../../contracts/flash/QuotePushFlashWorkflowBorrower.sol";
|
||||
|
||||
contract ForkMockExternalUnwinder is IExternalUnwinder {
|
||||
IERC20 public immutable base;
|
||||
IERC20 public immutable quote;
|
||||
uint256 public immutable numerator;
|
||||
uint256 public immutable denominator;
|
||||
|
||||
constructor(IERC20 base_, IERC20 quote_, uint256 numerator_, uint256 denominator_) {
|
||||
base = base_;
|
||||
quote = quote_;
|
||||
numerator = numerator_;
|
||||
denominator = denominator_;
|
||||
}
|
||||
|
||||
function unwind(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, bytes calldata)
|
||||
external
|
||||
override
|
||||
returns (uint256 amountOut)
|
||||
{
|
||||
require(tokenIn == address(base), "base only");
|
||||
require(tokenOut == address(quote), "quote only");
|
||||
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
|
||||
amountOut = amountIn * numerator / denominator;
|
||||
require(amountOut >= minAmountOut, "min unwind");
|
||||
IERC20(address(quote)).transfer(msg.sender, amountOut);
|
||||
}
|
||||
}
|
||||
|
||||
contract QuotePushFlashWorkflowBorrowerMainnetForkTest is Test {
|
||||
address constant DODO_PMM_INTEGRATION_MAINNET = 0xa9F284eD010f4F7d7F8F201742b49b9f58e29b84;
|
||||
address constant POOL_CWUSDC_USDC = 0x69776fc607e9edA8042e320e7e43f54d06c68f0E;
|
||||
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
|
||||
address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a;
|
||||
|
||||
bool public forkAvailable;
|
||||
SimpleERC3156FlashVault internal vault;
|
||||
QuotePushFlashWorkflowBorrower internal borrower;
|
||||
ForkMockExternalUnwinder 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;
|
||||
}
|
||||
|
||||
vault = new SimpleERC3156FlashVault(address(this), 5);
|
||||
vault.setTokenSupported(USDC, true);
|
||||
|
||||
borrower = new QuotePushFlashWorkflowBorrower(address(vault));
|
||||
unwinder = new ForkMockExternalUnwinder(IERC20(CWUSDC), IERC20(USDC), 112, 100);
|
||||
|
||||
// Seed the local lender and unwind venue with enough quote on the fork.
|
||||
deal(USDC, address(vault), 100_000_000); // 100 USDC
|
||||
deal(USDC, address(unwinder), 100_000_000); // 100 USDC
|
||||
}
|
||||
|
||||
function testFork_quotePush_usesLiveMainnetPmmLegAndRepays() public skipIfNoFork {
|
||||
uint256 amount = 2_964_298; // live safe tranche from 120/120 under 500 bps cap
|
||||
uint256 fee = vault.flashFee(USDC, amount);
|
||||
|
||||
QuotePushFlashWorkflowBorrower.QuotePushParams memory p = QuotePushFlashWorkflowBorrower.QuotePushParams({
|
||||
integration: DODO_PMM_INTEGRATION_MAINNET,
|
||||
pool: POOL_CWUSDC_USDC,
|
||||
baseToken: CWUSDC,
|
||||
externalUnwinder: address(unwinder),
|
||||
minOutPmm: 2_800_000,
|
||||
minOutUnwind: amount + fee,
|
||||
unwindData: bytes("")
|
||||
});
|
||||
|
||||
uint256 vaultBefore = IERC20(USDC).balanceOf(address(vault));
|
||||
uint256 poolBaseBefore = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC);
|
||||
uint256 poolQuoteBefore = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC);
|
||||
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), USDC, amount, abi.encode(p));
|
||||
|
||||
uint256 vaultAfter = IERC20(USDC).balanceOf(address(vault));
|
||||
uint256 borrowerSurplus = IERC20(USDC).balanceOf(address(borrower));
|
||||
uint256 poolBaseAfter = IERC20(CWUSDC).balanceOf(POOL_CWUSDC_USDC);
|
||||
uint256 poolQuoteAfter = IERC20(USDC).balanceOf(POOL_CWUSDC_USDC);
|
||||
|
||||
assertEq(vaultAfter, vaultBefore + fee, "vault repaid plus fee");
|
||||
assertGt(borrowerSurplus, 0, "borrower retains quote surplus");
|
||||
assertEq(IERC20(CWUSDC).balanceOf(address(borrower)), 0, "all base unwound");
|
||||
assertLt(poolBaseAfter, poolBaseBefore, "pool base decreased via quote push");
|
||||
assertGt(poolQuoteAfter, poolQuoteBefore, "pool quote increased via quote push");
|
||||
}
|
||||
}
|
||||
120
test/flash/SimpleERC3156FlashVault.t.sol
Normal file
120
test/flash/SimpleERC3156FlashVault.t.sol
Normal file
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "forge-std/Test.sol";
|
||||
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
|
||||
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol";
|
||||
|
||||
contract MockBorrower is IERC3156FlashBorrower {
|
||||
bytes32 private constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan");
|
||||
|
||||
function onFlashLoan(address, address token, uint256 amount, uint256 fee, bytes calldata)
|
||||
external
|
||||
override
|
||||
returns (bytes32)
|
||||
{
|
||||
IERC20(token).transfer(msg.sender, amount + fee);
|
||||
return _RETURN_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
contract MockERC20Mint is ERC20 {
|
||||
constructor() ERC20("Mock", "MCK") {}
|
||||
|
||||
function mint(address to, uint256 v) external {
|
||||
_mint(to, v);
|
||||
}
|
||||
}
|
||||
|
||||
contract SimpleERC3156FlashVaultTest is Test {
|
||||
SimpleERC3156FlashVault internal vault;
|
||||
MockERC20Mint internal token;
|
||||
MockBorrower internal borrower;
|
||||
|
||||
address internal owner = address(0xA11);
|
||||
address internal user = address(0xB22);
|
||||
|
||||
function setUp() public {
|
||||
vm.startPrank(owner);
|
||||
vault = new SimpleERC3156FlashVault(owner, 5); // 0.05%
|
||||
token = new MockERC20Mint();
|
||||
token.mint(address(vault), 1_000_000e18);
|
||||
vault.setTokenSupported(address(token), true);
|
||||
vm.stopPrank();
|
||||
|
||||
borrower = new MockBorrower();
|
||||
token.mint(address(borrower), 100e18);
|
||||
vm.prank(address(borrower));
|
||||
token.approve(address(vault), type(uint256).max);
|
||||
}
|
||||
|
||||
function test_maxFlashLoan() public view {
|
||||
assertEq(vault.maxFlashLoan(address(token)), 1_000_000e18);
|
||||
}
|
||||
|
||||
function test_flashFee() public view {
|
||||
assertEq(vault.flashFee(address(token), 100_000e18), (100_000e18 * 5) / 10_000);
|
||||
}
|
||||
|
||||
function test_previewFlashFee_matches_flashFee() public view {
|
||||
uint256 a = 123_456e18;
|
||||
assertEq(vault.previewFlashFee(address(token), a), vault.flashFee(address(token), a));
|
||||
}
|
||||
|
||||
function test_flashLoan_happyPath() public {
|
||||
uint256 amount = 100_000e18;
|
||||
uint256 fee = vault.flashFee(address(token), amount);
|
||||
uint256 beforeBal = token.balanceOf(address(vault));
|
||||
|
||||
vm.prank(user);
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), amount, "");
|
||||
|
||||
assertEq(token.balanceOf(address(vault)), beforeBal + fee);
|
||||
assertEq(vault.totalFeesCollected(address(token)), fee);
|
||||
}
|
||||
|
||||
function test_flashLoan_revert_unsupported() public {
|
||||
MockERC20Mint other = new MockERC20Mint();
|
||||
vm.expectRevert(SimpleERC3156FlashVault.UnsupportedToken.selector);
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(other), 1, "");
|
||||
}
|
||||
|
||||
function test_setFeeBps_revert_above_max() public {
|
||||
vm.prank(owner);
|
||||
vm.expectRevert(SimpleERC3156FlashVault.FeeTooHigh.selector);
|
||||
vault.setFeeBps(1001);
|
||||
}
|
||||
|
||||
function test_rescueTokens() public {
|
||||
vm.prank(owner);
|
||||
vault.rescueTokens(address(token), 10e18, owner);
|
||||
assertEq(token.balanceOf(owner), 10e18);
|
||||
}
|
||||
|
||||
function test_borrowerAllowlist_revert_unapproved() public {
|
||||
vm.prank(owner);
|
||||
vault.setBorrowerAllowlistEnabled(true);
|
||||
|
||||
vm.expectRevert(SimpleERC3156FlashVault.BorrowerNotApproved.selector);
|
||||
vm.prank(user);
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), 1e18, "");
|
||||
}
|
||||
|
||||
function test_borrowerAllowlist_allows_approved() public {
|
||||
vm.startPrank(owner);
|
||||
vault.setBorrowerAllowlistEnabled(true);
|
||||
vault.setBorrowerApproved(address(borrower), true);
|
||||
vm.stopPrank();
|
||||
|
||||
uint256 amount = 1000e18;
|
||||
uint256 fee = vault.flashFee(address(token), amount);
|
||||
uint256 beforeBal = token.balanceOf(address(vault));
|
||||
|
||||
vm.prank(user);
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(token), amount, "");
|
||||
|
||||
assertEq(token.balanceOf(address(vault)), beforeBal + fee);
|
||||
}
|
||||
}
|
||||
87
test/flash/SwapFlashWorkflowBorrower.t.sol
Normal file
87
test/flash/SwapFlashWorkflowBorrower.t.sol
Normal file
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "forge-std/Test.sol";
|
||||
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
|
||||
import {SimpleERC3156FlashVault} from "../../contracts/flash/SimpleERC3156FlashVault.sol";
|
||||
import {SwapFlashWorkflowBorrower, IDODOStyleSwapExactIn} from "../../contracts/flash/SwapFlashWorkflowBorrower.sol";
|
||||
|
||||
/// @notice 1:1 swap router for tests (ignores pool).
|
||||
contract MockSwapRouter1to1 is IDODOStyleSwapExactIn {
|
||||
ERC20 public immutable tokenA;
|
||||
ERC20 public immutable tokenB;
|
||||
|
||||
constructor(ERC20 a, ERC20 b) {
|
||||
tokenA = a;
|
||||
tokenB = b;
|
||||
}
|
||||
|
||||
function swapExactIn(address, address tokenIn, uint256 amountIn, uint256 minAmountOut)
|
||||
external
|
||||
override
|
||||
returns (uint256 amountOut)
|
||||
{
|
||||
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
|
||||
address tokenOut = tokenIn == address(tokenA) ? address(tokenB) : address(tokenA);
|
||||
amountOut = amountIn;
|
||||
require(amountOut >= minAmountOut, "min");
|
||||
IERC20(tokenOut).transfer(msg.sender, amountOut);
|
||||
}
|
||||
}
|
||||
|
||||
contract MockERC20Mint is ERC20 {
|
||||
constructor(string memory n, string memory s) ERC20(n, s) {}
|
||||
|
||||
function mint(address to, uint256 v) external {
|
||||
_mint(to, v);
|
||||
}
|
||||
}
|
||||
|
||||
contract SwapFlashWorkflowBorrowerTest is Test {
|
||||
SimpleERC3156FlashVault internal vault;
|
||||
MockERC20Mint internal tokenA;
|
||||
MockERC20Mint internal tokenB;
|
||||
MockSwapRouter1to1 internal router;
|
||||
SwapFlashWorkflowBorrower internal borrower;
|
||||
|
||||
address internal owner = address(0xA11);
|
||||
address internal user = address(0xB22);
|
||||
|
||||
function setUp() public {
|
||||
vm.startPrank(owner);
|
||||
vault = new SimpleERC3156FlashVault(owner, 5);
|
||||
tokenA = new MockERC20Mint("A", "A");
|
||||
tokenB = new MockERC20Mint("B", "B");
|
||||
tokenA.mint(address(vault), 1_000_000e18);
|
||||
vault.setTokenSupported(address(tokenA), true);
|
||||
vm.stopPrank();
|
||||
|
||||
router = new MockSwapRouter1to1(tokenA, tokenB);
|
||||
tokenA.mint(address(router), 10_000_000e18);
|
||||
tokenB.mint(address(router), 10_000_000e18);
|
||||
|
||||
borrower = new SwapFlashWorkflowBorrower(address(vault));
|
||||
}
|
||||
|
||||
function test_roundTripSwap_prefundFeeInBorrowedToken() public {
|
||||
uint256 amount = 50_000e18;
|
||||
uint256 fee = vault.flashFee(address(tokenA), amount);
|
||||
tokenA.mint(address(borrower), fee);
|
||||
|
||||
SwapFlashWorkflowBorrower.SwapFlashParams memory p = SwapFlashWorkflowBorrower.SwapFlashParams({
|
||||
integration: address(router),
|
||||
pool: address(0xdead),
|
||||
midToken: address(tokenB),
|
||||
minOutFirst: amount,
|
||||
minOutSecond: amount
|
||||
});
|
||||
bytes memory data = abi.encode(p);
|
||||
|
||||
vm.prank(user);
|
||||
vault.flashLoan(IERC3156FlashBorrower(address(borrower)), address(tokenA), amount, data);
|
||||
|
||||
assertEq(vault.totalFeesCollected(address(tokenA)), fee);
|
||||
}
|
||||
}
|
||||
57
test/flash/UniswapV3ExternalUnwinderFork.t.sol
Normal file
57
test/flash/UniswapV3ExternalUnwinderFork.t.sol
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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 {UniswapV3ExternalUnwinder} from "../../contracts/flash/UniswapV3ExternalUnwinder.sol";
|
||||
|
||||
contract UniswapV3ExternalUnwinderForkTest is Test {
|
||||
address constant UNISWAP_V3_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
|
||||
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
|
||||
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
|
||||
address constant CWUSDC = 0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a;
|
||||
|
||||
bool public forkAvailable;
|
||||
UniswapV3ExternalUnwinder internal unwinder;
|
||||
|
||||
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 UniswapV3ExternalUnwinder(UNISWAP_V3_ROUTER);
|
||||
}
|
||||
|
||||
modifier skipIfNoFork() {
|
||||
if (!forkAvailable) {
|
||||
return;
|
||||
}
|
||||
_;
|
||||
}
|
||||
|
||||
function testFork_knownRoute_WETHToUSDC_singleHopWorks() public skipIfNoFork {
|
||||
deal(WETH, address(unwinder), 1 ether);
|
||||
uint256 before = IERC20(USDC).balanceOf(address(this));
|
||||
|
||||
uint256 amountOut = unwinder.unwind(WETH, USDC, 1 ether, 1, abi.encode(uint24(3000)));
|
||||
|
||||
uint256 afterBal = IERC20(USDC).balanceOf(address(this));
|
||||
assertGt(amountOut, 0, "amountOut > 0");
|
||||
assertEq(afterBal - before, amountOut, "USDC received");
|
||||
}
|
||||
|
||||
function testFork_cWUSDCToUSDC_routeUnavailableOnUniswapV3() public skipIfNoFork {
|
||||
deal(CWUSDC, address(unwinder), 1_000_000);
|
||||
|
||||
vm.expectRevert();
|
||||
unwinder.unwind(CWUSDC, USDC, 1_000_000, 1, abi.encode(uint24(3000)));
|
||||
}
|
||||
}
|
||||
67
test/flash/UniversalCCIPFlashBridgeAdapter.t.sol
Normal file
67
test/flash/UniversalCCIPFlashBridgeAdapter.t.sol
Normal file
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "forge-std/Test.sol";
|
||||
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
import {UniversalCCIPBridge} from "../../contracts/bridge/UniversalCCIPBridge.sol";
|
||||
import {UniversalCCIPFlashBridgeAdapter} from "../../contracts/flash/UniversalCCIPFlashBridgeAdapter.sol";
|
||||
|
||||
/// @dev Minimal stand-in for `UniversalCCIPBridge.bridge` (pulls token, records op).
|
||||
contract FlashBridgeAdapterTestMock {
|
||||
event BridgeCalled(address token, uint256 amount, uint64 dest, address recipient, bytes32 assetType, bool pmm, bool vault);
|
||||
|
||||
function bridge(UniversalCCIPBridge.BridgeOperation calldata op) external payable returns (bytes32) {
|
||||
IERC20(op.token).transferFrom(msg.sender, address(this), op.amount);
|
||||
emit BridgeCalled(
|
||||
op.token, op.amount, op.destinationChain, op.recipient, op.assetType, op.usePMM, op.useVault
|
||||
);
|
||||
return keccak256(abi.encodePacked("mock", op.token, op.amount));
|
||||
}
|
||||
|
||||
receive() external payable {}
|
||||
}
|
||||
|
||||
contract MockMintERC20 is ERC20 {
|
||||
constructor() ERC20("A", "A") {}
|
||||
|
||||
function mint(address to, uint256 v) external {
|
||||
_mint(to, v);
|
||||
}
|
||||
}
|
||||
|
||||
contract UniversalCCIPFlashBridgeAdapterTest is Test {
|
||||
FlashBridgeAdapterTestMock internal uni;
|
||||
UniversalCCIPFlashBridgeAdapter internal adapter;
|
||||
MockMintERC20 internal token;
|
||||
|
||||
address internal alice = address(0xA11CE);
|
||||
|
||||
function setUp() public {
|
||||
uni = new FlashBridgeAdapterTestMock();
|
||||
adapter = new UniversalCCIPFlashBridgeAdapter(address(uni));
|
||||
token = new MockMintERC20();
|
||||
token.mint(alice, 500e18);
|
||||
vm.deal(alice, 10 ether);
|
||||
}
|
||||
|
||||
function test_adapter_pullsAndCallsBridge_emptyExtraData() public {
|
||||
vm.startPrank(alice);
|
||||
token.approve(address(adapter), 100e18);
|
||||
bytes32 mid = adapter.bridgeTokensFrom{value: 1 wei}(address(token), 100e18, 7, address(0xBEEF), "");
|
||||
vm.stopPrank();
|
||||
|
||||
assertTrue(mid != bytes32(0));
|
||||
assertEq(token.balanceOf(address(uni)), 100e18);
|
||||
}
|
||||
|
||||
function test_adapter_decodesExtraData() public {
|
||||
bytes memory extra = abi.encode(bytes32(uint256(1)), true, false, bytes("p"), bytes("v"));
|
||||
vm.startPrank(alice);
|
||||
token.approve(address(adapter), 50e18);
|
||||
adapter.bridgeTokensFrom(address(token), 50e18, 99, address(0xCAFE), extra);
|
||||
vm.stopPrank();
|
||||
|
||||
assertEq(token.balanceOf(address(uni)), 50e18);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user