From ddee0b29397c474c3465717ef65838e171579629 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Tue, 23 Jun 2026 10:12:04 +0200 Subject: [PATCH 1/3] Add tests --- test/DromosEscrow.t.sol | 333 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 test/DromosEscrow.t.sol diff --git a/test/DromosEscrow.t.sol b/test/DromosEscrow.t.sol new file mode 100644 index 0000000..6cdba9b --- /dev/null +++ b/test/DromosEscrow.t.sol @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {DromosEscrow, IDromosGauge} from "src/escrows/DromosEscrow.sol"; + +contract MockDromosToken is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address receiver, uint256 amount) external { + _mint(receiver, amount); + } +} + +contract MockDromosGauge is IDromosGauge { + using SafeERC20 for IERC20; + + address public immutable stakingToken; + address public immutable rewardToken; + + mapping(address account => uint256 amount) public balanceOf; + mapping(address account => uint256 amount) public claimable; + + constructor(address _stakingToken, address _rewardToken) { + stakingToken = _stakingToken; + rewardToken = _rewardToken; + } + + function deposit(uint256 amount) external { + IERC20(stakingToken).safeTransferFrom(msg.sender, address(this), amount); + balanceOf[msg.sender] += amount; + } + + function withdraw(uint256 amount) external { + balanceOf[msg.sender] -= amount; + IERC20(stakingToken).safeTransfer(msg.sender, amount); + } + + function getReward(address account) external { + uint256 amount = claimable[account]; + claimable[account] = 0; + if (amount != 0) IERC20(rewardToken).safeTransfer(account, amount); + } + + function setReward(address account, uint256 amount) external { + claimable[account] = amount; + } +} + +contract DromosEscrowTest is Test { + address internal constant MARKET = address(0xA); + address internal constant BENEFICIARY = address(0xB); + address internal constant CLAIMER = address(0xC); + address internal constant RECEIVER = address(0xD); + address internal constant OTHER = address(0xE); + + MockDromosToken internal collateral; + MockDromosToken internal reward; + MockDromosGauge internal gauge; + DromosEscrow internal escrow; + + function setUp() public { + collateral = new MockDromosToken("LP Token", "LP"); + reward = new MockDromosToken("Emission Token", "EMIT"); + gauge = new MockDromosGauge(address(collateral), address(reward)); + escrow = new DromosEscrow(address(gauge)); + + vm.prank(MARKET); + escrow.initialize(IERC20(address(collateral)), BENEFICIARY); + } + + function testConstructorRejectsNonContractGauge() public { + vm.expectRevert(DromosEscrow.InvalidGauge.selector); + new DromosEscrow(address(0x1234)); + } + + function testConstructorRejectsZeroToken() public { + MockDromosGauge invalidGauge = new MockDromosGauge(address(0), address(reward)); + + vm.expectRevert(DromosEscrow.InvalidGauge.selector); + new DromosEscrow(address(invalidGauge)); + } + + function testConstructorRejectsCollateralAsRewardToken() public { + MockDromosGauge unsafeGauge = new MockDromosGauge(address(collateral), address(collateral)); + + vm.expectRevert(DromosEscrow.UnsafeRewardToken.selector); + new DromosEscrow(address(unsafeGauge)); + } + + function testInitializeSetsConfigurationAndApproval() public view { + assertEq(address(escrow.gauge()), address(gauge)); + assertEq(address(escrow.stakingToken()), address(collateral)); + assertEq(address(escrow.rewardToken()), address(reward)); + assertEq(address(escrow.token()), address(collateral)); + assertEq(escrow.market(), MARKET); + assertEq(escrow.beneficiary(), BENEFICIARY); + assertEq(collateral.allowance(address(escrow), address(gauge)), type(uint256).max); + } + + function testInitializeRejectsSecondInitialization() public { + vm.expectRevert(DromosEscrow.AlreadyInitialized.selector); + escrow.initialize(IERC20(address(collateral)), BENEFICIARY); + } + + function testInitializeRejectsWrongCollateral() public { + DromosEscrow freshEscrow = new DromosEscrow(address(gauge)); + MockDromosToken wrongToken = new MockDromosToken("Wrong", "WRONG"); + + vm.expectRevert(DromosEscrow.WrongCollateral.selector); + freshEscrow.initialize(IERC20(address(wrongToken)), BENEFICIARY); + } + + function testOnDepositStakesEntireBalancePermissionlessly() public { + collateral.mint(address(escrow), 10 ether); + + vm.prank(OTHER); + escrow.onDeposit(); + + assertEq(collateral.balanceOf(address(escrow)), 0); + assertEq(gauge.balanceOf(address(escrow)), 10 ether); + assertEq(escrow.balance(), 10 ether); + } + + function testOnDepositWithZeroBalanceIsNoOp() public { + escrow.onDeposit(); + + assertEq(gauge.balanceOf(address(escrow)), 0); + assertEq(escrow.balance(), 0); + } + + function testOnDepositSupportsRepeatedDeposits() public { + _deposit(4 ether); + _deposit(6 ether); + + assertEq(gauge.balanceOf(address(escrow)), 10 ether); + assertEq(escrow.balance(), 10 ether); + } + + function testBalanceIncludesStakedAndUnstakedCollateral() public { + _deposit(7 ether); + collateral.mint(address(escrow), 3 ether); + + assertEq(escrow.balance(), 10 ether); + } + + function testPayRejectsNonMarket() public { + collateral.mint(address(escrow), 1 ether); + + vm.expectRevert(DromosEscrow.OnlyMarket.selector); + escrow.pay(RECEIVER, 1 ether); + } + + function testPayUsesUnstakedCollateralFirst() public { + collateral.mint(address(escrow), 10 ether); + + vm.prank(MARKET); + escrow.pay(RECEIVER, 6 ether); + + assertEq(collateral.balanceOf(RECEIVER), 6 ether); + assertEq(collateral.balanceOf(address(escrow)), 4 ether); + assertEq(gauge.balanceOf(address(escrow)), 0); + } + + function testPayWithdrawsMissingCollateralFromGauge() public { + _deposit(10 ether); + + vm.prank(MARKET); + escrow.pay(RECEIVER, 6 ether); + + assertEq(collateral.balanceOf(RECEIVER), 6 ether); + assertEq(gauge.balanceOf(address(escrow)), 4 ether); + assertEq(escrow.balance(), 4 ether); + } + + function testPayUsesMixedUnstakedAndStakedCollateral() public { + _deposit(7 ether); + collateral.mint(address(escrow), 3 ether); + + vm.prank(MARKET); + escrow.pay(RECEIVER, 5 ether); + + assertEq(collateral.balanceOf(RECEIVER), 5 ether); + assertEq(collateral.balanceOf(address(escrow)), 0); + assertEq(gauge.balanceOf(address(escrow)), 5 ether); + } + + function testPayCanWithdrawFullBalance() public { + _deposit(10 ether); + + vm.prank(MARKET); + escrow.pay(RECEIVER, 10 ether); + + assertEq(collateral.balanceOf(RECEIVER), 10 ether); + assertEq(escrow.balance(), 0); + } + + function testPayRevertsWhenAmountExceedsBalance() public { + _deposit(10 ether); + + vm.prank(MARKET); + vm.expectRevert(); + escrow.pay(RECEIVER, 11 ether); + } + + function testPayDoesNotClaimEmissions() public { + _deposit(10 ether); + _setReward(2 ether); + + vm.prank(MARKET); + escrow.pay(RECEIVER, 5 ether); + + assertEq(gauge.claimable(address(escrow)), 2 ether); + assertEq(reward.balanceOf(address(escrow)), 0); + assertEq(reward.balanceOf(BENEFICIARY), 0); + } + + function testBeneficiaryCanClaimToSelf() public { + _deposit(10 ether); + _setReward(2 ether); + + vm.prank(BENEFICIARY); + escrow.claim(); + + assertEq(reward.balanceOf(BENEFICIARY), 2 ether); + assertEq(reward.balanceOf(address(escrow)), 0); + assertEq(gauge.claimable(address(escrow)), 0); + } + + function testBeneficiaryCanClaimToReceiver() public { + _deposit(10 ether); + _setReward(2 ether); + + vm.prank(BENEFICIARY); + escrow.claimTo(RECEIVER); + + assertEq(reward.balanceOf(RECEIVER), 2 ether); + } + + function testAllowlistedClaimerCanClaimToReceiver() public { + _deposit(10 ether); + _setReward(2 ether); + + vm.prank(BENEFICIARY); + escrow.setClaimer(CLAIMER, true); + + vm.prank(CLAIMER); + escrow.claimTo(RECEIVER); + + assertEq(reward.balanceOf(RECEIVER), 2 ether); + } + + function testRevokedClaimerCannotClaim() public { + vm.startPrank(BENEFICIARY); + escrow.setClaimer(CLAIMER, true); + escrow.setClaimer(CLAIMER, false); + vm.stopPrank(); + + vm.prank(CLAIMER); + vm.expectRevert(DromosEscrow.OnlyBeneficiaryOrAllowlist.selector); + escrow.claimTo(RECEIVER); + } + + function testUnauthorizedAddressCannotClaim() public { + vm.prank(OTHER); + vm.expectRevert(DromosEscrow.OnlyBeneficiaryOrAllowlist.selector); + escrow.claimTo(RECEIVER); + } + + function testOnlyBeneficiaryCanSetClaimer() public { + vm.prank(OTHER); + vm.expectRevert(DromosEscrow.OnlyBeneficiary.selector); + escrow.setClaimer(CLAIMER, true); + } + + function testClaimRejectsZeroReceiver() public { + vm.prank(BENEFICIARY); + vm.expectRevert(DromosEscrow.InvalidReceiver.selector); + escrow.claimTo(address(0)); + } + + function testClaimRejectsEscrowReceiver() public { + vm.prank(BENEFICIARY); + vm.expectRevert(DromosEscrow.InvalidReceiver.selector); + escrow.claimTo(address(escrow)); + } + + function testClaimWithNoRewardsIsNoOp() public { + vm.prank(BENEFICIARY); + escrow.claim(); + + assertEq(reward.balanceOf(BENEFICIARY), 0); + } + + function testRewardsRemainClaimableAfterFullWithdrawal() public { + _deposit(10 ether); + _setReward(2 ether); + + vm.prank(MARKET); + escrow.pay(RECEIVER, 10 ether); + + vm.prank(BENEFICIARY); + escrow.claim(); + + assertEq(escrow.balance(), 0); + assertEq(reward.balanceOf(BENEFICIARY), 2 ether); + } + + function testClaimDoesNotReduceCollateralBalance() public { + _deposit(10 ether); + _setReward(2 ether); + uint256 collateralBalance = escrow.balance(); + + vm.prank(BENEFICIARY); + escrow.claim(); + + assertEq(escrow.balance(), collateralBalance); + assertEq(reward.balanceOf(BENEFICIARY), 2 ether); + } + + function _deposit(uint256 amount) internal { + collateral.mint(address(escrow), amount); + escrow.onDeposit(); + } + + function _setReward(uint256 amount) internal { + reward.mint(address(gauge), amount); + gauge.setReward(address(escrow), amount); + } +} From ef82ff4556d9add6585fce0ff8e351b0c0140ccb Mon Sep 17 00:00:00 2001 From: 08xmt Date: Tue, 23 Jun 2026 10:12:17 +0200 Subject: [PATCH 2/3] Add logic --- src/escrows/DromosEscrow.sol | 160 +++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/escrows/DromosEscrow.sol diff --git a/src/escrows/DromosEscrow.sol b/src/escrows/DromosEscrow.sol new file mode 100644 index 0000000..4a5da5f --- /dev/null +++ b/src/escrows/DromosEscrow.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @dev Interface reference: Aerodrome vAMM-WETH/USDC Pool Gauge on Base. +/// https://basescan.org/address/0x519BBD1Dd8C6A94C46080E24f316c14Ee758C025#code +interface IDromosGauge { + function stakingToken() external view returns (address); + function rewardToken() external view returns (address); + function balanceOf(address account) external view returns (uint256); + function deposit(uint256 amount) external; + function withdraw(uint256 amount) external; + function getReward(address account) external; +} + +/** + * @title Dromos Escrow + * @notice Stakes a borrower's Solidly-like LP collateral in a Dromos-compatible gauge. + * @dev This contract is used as a proxy implementation. Each implementation supports one immutable gauge. + */ +contract DromosEscrow { + using SafeERC20 for IERC20; + + error AlreadyInitialized(); + error InvalidGauge(); + error InvalidReceiver(); + error OnlyBeneficiary(); + error OnlyBeneficiaryOrAllowlist(); + error OnlyMarket(); + error UnsafeRewardToken(); + error WrongCollateral(); + + IDromosGauge public immutable gauge; + IERC20 public immutable stakingToken; + IERC20 public immutable rewardToken; + + address public market; + IERC20 public token; + address public beneficiary; + + mapping(address claimer => bool isAllowed) public allowlist; + + event Claim(address indexed caller, address indexed receiver, uint256 amount); + event SetClaimer(address indexed claimer, bool isAllowed); + + modifier onlyBeneficiary() { + if (msg.sender != beneficiary) revert OnlyBeneficiary(); + _; + } + + modifier onlyBeneficiaryOrAllowlist() { + if (msg.sender != beneficiary && !allowlist[msg.sender]) { + revert OnlyBeneficiaryOrAllowlist(); + } + _; + } + + constructor(address _gauge) { + if (_gauge.code.length == 0) revert InvalidGauge(); + + gauge = IDromosGauge(_gauge); + + address _stakingToken = gauge.stakingToken(); + address _rewardToken = gauge.rewardToken(); + if (_stakingToken == address(0) || _rewardToken == address(0)) { + revert InvalidGauge(); + } + if (_stakingToken == _rewardToken) revert UnsafeRewardToken(); + + stakingToken = IERC20(_stakingToken); + rewardToken = IERC20(_rewardToken); + } + + /** + * @notice Initializes an escrow clone. + * @param _token The market's collateral token. + * @param _beneficiary The borrower entitled to the gauge emissions. + */ + function initialize(IERC20 _token, address _beneficiary) external { + if (market != address(0)) revert AlreadyInitialized(); + if (address(_token) != address(stakingToken)) revert WrongCollateral(); + + market = msg.sender; + token = _token; + beneficiary = _beneficiary; + + _token.forceApprove(address(gauge), type(uint256).max); + } + + /** + * @notice Transfers collateral to a recipient, unstaking only the amount required. + * @dev Only the market may call this function. Gauge emissions are not claimed. + */ + function pay(address recipient, uint256 amount) external { + if (msg.sender != market) revert OnlyMarket(); + + uint256 tokenBalance = token.balanceOf(address(this)); + if (tokenBalance < amount) { + gauge.withdraw(amount - tokenBalance); + } + + token.safeTransfer(recipient, amount); + } + + /** + * @notice Returns the escrow's total unstaked and gauge-staked collateral. + */ + function balance() external view returns (uint256) { + return token.balanceOf(address(this)) + gauge.balanceOf(address(this)); + } + + /** + * @notice Stakes all collateral currently held by the escrow. + * @dev Permissionless so direct collateral transfers can subsequently be staked. + */ + function onDeposit() external { + uint256 tokenBalance = token.balanceOf(address(this)); + if (tokenBalance == 0) return; + + gauge.deposit(tokenBalance); + } + + /** + * @notice Claims gauge emissions to the beneficiary. + */ + function claim() external onlyBeneficiary { + _claim(beneficiary); + } + + /** + * @notice Claims gauge emissions to a specified receiver. + * @dev Callable by the beneficiary or an allowlisted claimer. + */ + function claimTo(address receiver) external onlyBeneficiaryOrAllowlist { + _claim(receiver); + } + + /** + * @notice Allows or disallows an address to claim emissions on the beneficiary's behalf. + */ + function setClaimer(address claimer, bool isAllowed) external onlyBeneficiary { + allowlist[claimer] = isAllowed; + emit SetClaimer(claimer, isAllowed); + } + + function _claim(address receiver) internal { + if (receiver == address(0) || receiver == address(this)) { + revert InvalidReceiver(); + } + + gauge.getReward(address(this)); + + uint256 amount = rewardToken.balanceOf(address(this)); + if (amount != 0) rewardToken.safeTransfer(receiver, amount); + + emit Claim(msg.sender, receiver, amount); + } +} From dbf8398fef649f932ebecb764bcb6b955d68649f Mon Sep 17 00:00:00 2001 From: 08xmt Date: Mon, 29 Jun 2026 08:12:20 +0200 Subject: [PATCH 3/3] Make token immutatable and remove stakingToken --- src/escrows/DromosEscrow.sol | 16 +++++++--------- test/DromosEscrow.t.sol | 1 - 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/escrows/DromosEscrow.sol b/src/escrows/DromosEscrow.sol index 4a5da5f..43a4814 100644 --- a/src/escrows/DromosEscrow.sol +++ b/src/escrows/DromosEscrow.sol @@ -33,11 +33,10 @@ contract DromosEscrow { error WrongCollateral(); IDromosGauge public immutable gauge; - IERC20 public immutable stakingToken; + IERC20 public immutable token; IERC20 public immutable rewardToken; address public market; - IERC20 public token; address public beneficiary; mapping(address claimer => bool isAllowed) public allowlist; @@ -62,14 +61,14 @@ contract DromosEscrow { gauge = IDromosGauge(_gauge); - address _stakingToken = gauge.stakingToken(); + address _token = gauge.stakingToken(); address _rewardToken = gauge.rewardToken(); - if (_stakingToken == address(0) || _rewardToken == address(0)) { + if (_token == address(0) || _rewardToken == address(0)) { revert InvalidGauge(); } - if (_stakingToken == _rewardToken) revert UnsafeRewardToken(); + if (_token == _rewardToken) revert UnsafeRewardToken(); - stakingToken = IERC20(_stakingToken); + token = IERC20(_token); rewardToken = IERC20(_rewardToken); } @@ -80,13 +79,12 @@ contract DromosEscrow { */ function initialize(IERC20 _token, address _beneficiary) external { if (market != address(0)) revert AlreadyInitialized(); - if (address(_token) != address(stakingToken)) revert WrongCollateral(); + if (address(_token) != address(token)) revert WrongCollateral(); market = msg.sender; - token = _token; beneficiary = _beneficiary; - _token.forceApprove(address(gauge), type(uint256).max); + token.forceApprove(address(gauge), type(uint256).max); } /** diff --git a/test/DromosEscrow.t.sol b/test/DromosEscrow.t.sol index 6cdba9b..6368cd5 100644 --- a/test/DromosEscrow.t.sol +++ b/test/DromosEscrow.t.sol @@ -93,7 +93,6 @@ contract DromosEscrowTest is Test { function testInitializeSetsConfigurationAndApproval() public view { assertEq(address(escrow.gauge()), address(gauge)); - assertEq(address(escrow.stakingToken()), address(collateral)); assertEq(address(escrow.rewardToken()), address(reward)); assertEq(address(escrow.token()), address(collateral)); assertEq(escrow.market(), MARKET);