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:
defiQUG
2026-04-07 23:40:52 -07:00
parent 0fb7bba07b
commit 76aa419320
289 changed files with 28367 additions and 824 deletions

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

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

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

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

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

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

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

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

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

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

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

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