Warp Route rate limiter hook & ISM (#3527)

### Description
Adds rate limiting hook and ism to limit warp route transfers

### Drive-by changes

<!--
Are there any minor or drive-by changes also included?
-->

### Related issues
- Fixes #2697 

### Backward compatibility
Yes

### Testing
Unit Tests

---------

Signed-off-by: Paul Balaji <paul@hyperlane.xyz>
Co-authored-by: Noah Bayindirli 🥂 <noah@primeprotocol.xyz>
Co-authored-by: Kunal Arora <55632507+aroralanuk@users.noreply.github.com>
Co-authored-by: Paul Balaji <paul@hyperlane.xyz>
Co-authored-by: J M Rossy <jm.rossy@gmail.com>
Co-authored-by: Trevor Porter <trkporter@ucdavis.edu>
Co-authored-by: Daniel Savu <23065004+daniel-savu@users.noreply.github.com>
pull/3561/head
Lee 8 months ago committed by GitHub
parent 37d49ec581
commit 24e1f86d25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      solidity/contracts/client/MailboxClient.sol
  2. 58
      solidity/contracts/hooks/warp-route/RateLimitedHook.sol
  3. 3
      solidity/contracts/interfaces/hooks/IPostDispatchHook.sol
  4. 53
      solidity/contracts/isms/warp-route/RateLimitedIsm.sol
  5. 107
      solidity/contracts/libs/RateLimited.sol
  6. 159
      solidity/test/hooks/RateLimitedHook.t.sol
  7. 79
      solidity/test/isms/RateLimitedIsm.t.sol
  8. 107
      solidity/test/lib/RateLimited.t.sol

@ -92,6 +92,10 @@ abstract contract MailboxClient is OwnableUpgradeable {
return mailbox.latestDispatchedId() == id;
}
function _isDelivered(bytes32 id) internal view returns (bool) {
return mailbox.delivered(id);
}
function _metadata(
uint32 /*_destinationDomain*/
) internal view virtual returns (bytes memory) {

@ -0,0 +1,58 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import {MailboxClient} from "contracts/client/MailboxClient.sol";
import {IPostDispatchHook} from "contracts/interfaces/hooks/IPostDispatchHook.sol";
import {Message} from "contracts/libs/Message.sol";
import {TokenMessage} from "contracts/token/libs/TokenMessage.sol";
import {RateLimited} from "contracts/libs/RateLimited.sol";
contract RateLimitedHook is IPostDispatchHook, MailboxClient, RateLimited {
using Message for bytes;
using TokenMessage for bytes;
mapping(bytes32 messageId => bool validated) public messageValidated;
modifier validateMessageOnce(bytes calldata _message) {
bytes32 messageId = _message.id();
require(!messageValidated[messageId], "MessageAlreadyValidated");
_;
messageValidated[messageId] = true;
}
constructor(
address _mailbox,
uint256 _maxCapacity
) MailboxClient(_mailbox) RateLimited(_maxCapacity) {}
/// @inheritdoc IPostDispatchHook
function hookType() external pure returns (uint8) {
return uint8(IPostDispatchHook.Types.Rate_Limited_Hook);
}
/// @inheritdoc IPostDispatchHook
function supportsMetadata(bytes calldata) external pure returns (bool) {
return false;
}
/**
* Verify a message, rate limit, and increment the sender's limit.
* @dev ensures that this gets called by the Mailbox
*/
function postDispatch(
bytes calldata,
bytes calldata _message
) external payable validateMessageOnce(_message) {
require(_isLatestDispatched(_message.id()), "InvalidDispatchedMessage");
uint256 newAmount = _message.body().amount();
validateAndConsumeFilledLevel(newAmount);
}
/// @inheritdoc IPostDispatchHook
function quoteDispatch(
bytes calldata,
bytes calldata
) external pure returns (uint256) {
return 0;
}
}

@ -24,7 +24,8 @@ interface IPostDispatchHook {
ID_AUTH_ISM,
PAUSABLE,
PROTOCOL_FEE,
LAYER_ZERO_V1
LAYER_ZERO_V1,
Rate_Limited_Hook
}
/**

@ -0,0 +1,53 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
import {IMailbox} from "contracts/interfaces/IMailbox.sol";
import {MailboxClient} from "contracts/client/MailboxClient.sol";
import {RateLimited} from "contracts/libs/RateLimited.sol";
import {IInterchainSecurityModule} from "contracts/interfaces/IInterchainSecurityModule.sol";
import {Message} from "contracts/libs/Message.sol";
import {TokenMessage} from "contracts/token/libs/TokenMessage.sol";
contract RateLimitedIsm is
MailboxClient,
RateLimited,
IInterchainSecurityModule
{
using Message for bytes;
using TokenMessage for bytes;
mapping(bytes32 messageId => bool validated) public messageValidated;
modifier validateMessageOnce(bytes calldata _message) {
bytes32 messageId = _message.id();
require(!messageValidated[messageId], "MessageAlreadyValidated");
messageValidated[messageId] = true;
_;
}
constructor(
address _mailbox,
uint256 _maxCapacity
) MailboxClient(_mailbox) RateLimited(_maxCapacity) {}
/// @inheritdoc IInterchainSecurityModule
function moduleType() external pure returns (uint8) {
return uint8(IInterchainSecurityModule.Types.UNUSED);
}
/**
* Verify a message, rate limit, and increment the sender's limit.
* @dev ensures that this gets called by the Mailbox
*/
function verify(
bytes calldata,
bytes calldata _message
) external validateMessageOnce(_message) returns (bool) {
require(_isDelivered(_message.id()), "InvalidDeliveredMessage");
uint256 newAmount = _message.body().amount();
validateAndConsumeFilledLevel(newAmount);
return true;
}
}

@ -0,0 +1,107 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
/**
* @title RateLimited
* @notice A contract used to keep track of an address sender's token amount limits.
* @dev Implements a modified token bucket algorithm where the bucket is full in the beginning and gradually refills
* See: https://dev.to/satrobit/rate-limiting-using-the-token-bucket-algorithm-3cjh
**/
contract RateLimited is OwnableUpgradeable {
uint256 public constant DURATION = 1 days; // 86400
uint256 public filledLevel; /// @notice Current filled level
uint256 public refillRate; /// @notice Tokens per second refill rate
uint256 public lastUpdated; /// @notice Timestamp of the last time an action has been taken TODO prob can be uint40
event RateLimitSet(uint256 _oldCapacity, uint256 _newCapacity);
constructor(uint256 _capacity) {
_transferOwnership(msg.sender);
setRefillRate(_capacity);
filledLevel = _capacity;
}
error RateLimitExceeded(uint256 newLimit, uint256 targetLimit);
/**
* @return The max capacity where the bucket will no longer refill
*/
function maxCapacity() public view returns (uint256) {
return refillRate * DURATION;
}
/**
* Calculates the refilled amount based on time and refill rate
*
* Consider an example where there is a 1e18 max token limit per day (86400s)
* If half of the tokens has been used, and half a day (43200s) has passed,
* then there should be a refill of 0.5e18
*
* To calculate:
* Refilled = Elapsed * RefilledRate
* Elapsed = timestamp - Limit.lastUpdated
* RefilledRate = Capacity / DURATION
*
* If half of the day (43200) has passed, then
* (86400 - 43200) * (1e18 / 86400) = 0.5e18
*/
function calculateRefilledAmount() internal view returns (uint256) {
uint256 elapsed = block.timestamp - lastUpdated;
return elapsed * refillRate;
}
/**
* Calculates the adjusted fill level based on time
*/
function calculateCurrentLevel() public view returns (uint256) {
uint256 _capacity = maxCapacity();
require(_capacity > 0, "RateLimitNotSet");
if (block.timestamp > lastUpdated + DURATION) {
// If last update is in the previous window, return the max capacity
return _capacity;
} else {
// If within the window, refill the capacity
uint256 replenishedLevel = filledLevel + calculateRefilledAmount();
// Only return _capacity, in the case where newCurrentCapcacity overflows
return replenishedLevel > _capacity ? _capacity : replenishedLevel;
}
}
/**
* Sets the refill rate by giving a capacity
* @param _capacity new maxiumum capacity to set
*/
function setRefillRate(
uint256 _capacity
) public onlyOwner returns (uint256) {
uint256 _oldRefillRate = refillRate;
uint256 _newRefillRate = _capacity / DURATION;
refillRate = _newRefillRate;
emit RateLimitSet(_oldRefillRate, _newRefillRate);
return _newRefillRate;
}
/**
* Validate an amount and decreases the currentCapacity
* @param _newAmount The amount to consume the fill level
* @return The new filled level
*/
function validateAndConsumeFilledLevel(
uint256 _newAmount
) public returns (uint256) {
uint256 adjustedFilledLevel = calculateCurrentLevel();
require(_newAmount <= adjustedFilledLevel, "RateLimitExceeded");
// Reduce the filledLevel and update lastUpdated
uint256 _filledLevel = adjustedFilledLevel - _newAmount;
filledLevel = _filledLevel;
lastUpdated = block.timestamp;
return _filledLevel;
}
}

@ -0,0 +1,159 @@
// SPDX-License-Identifier: MIT or Apache-2.0
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Message} from "contracts/libs/Message.sol";
import {TokenMessage} from "contracts/token/libs/TokenMessage.sol";
import {TypeCasts} from "contracts/libs/TypeCasts.sol";
import {RateLimited} from "contracts/libs/RateLimited.sol";
import {RateLimitedHook} from "contracts/hooks/warp-route/RateLimitedHook.sol";
import {HypERC20Collateral} from "contracts/token/HypERC20Collateral.sol";
import {HypERC20} from "contracts/token/HypERC20.sol";
import {TestMailbox} from "contracts/test/TestMailbox.sol";
import {ERC20Test} from "contracts/test/ERC20Test.sol";
import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.sol";
contract RateLimitedHookTest is Test {
using Message for bytes;
using TypeCasts for address;
uint32 constant ORIGIN = 11;
uint32 constant DESTINATION = 12;
uint256 constant MAX_CAPACITY = 1 ether;
uint256 constant ONE_PERCENT = 0.01 ether;
uint8 internal constant DECIMALS = 18;
address constant BOB = address(0x2);
TestMailbox localMailbox;
TestMailbox remoteMailbox;
ERC20Test token;
TestPostDispatchHook internal noopHook;
RateLimitedHook rateLimitedHook;
HypERC20Collateral warpRouteLocal;
HypERC20 warpRouteRemote;
function _mintAndApprove(uint256 amount) internal {
token.mint(amount);
token.approve(address(warpRouteLocal), amount);
}
function setUp() external {
localMailbox = new TestMailbox(ORIGIN);
remoteMailbox = new TestMailbox(DESTINATION);
token = new ERC20Test("Test", "Test", 100 ether, 18);
noopHook = new TestPostDispatchHook();
rateLimitedHook = new RateLimitedHook(
address(localMailbox),
MAX_CAPACITY
);
localMailbox.setDefaultHook(address(noopHook));
localMailbox.setRequiredHook(address(noopHook));
warpRouteLocal = new HypERC20Collateral(
address(token),
address(localMailbox)
);
warpRouteLocal.initialize(
address(rateLimitedHook),
address(0),
address(this)
);
warpRouteRemote = new HypERC20(DECIMALS, address(remoteMailbox));
warpRouteLocal.enrollRemoteRouter(
DESTINATION,
address(warpRouteRemote).addressToBytes32()
);
warpRouteRemote.enrollRemoteRouter(
ORIGIN,
address(warpRouteLocal).addressToBytes32()
);
}
function testRateLimitedHook_revertsIfCalledByNonMailbox(
bytes calldata _message
) external {
vm.expectRevert("InvalidDispatchedMessage");
rateLimitedHook.postDispatch(bytes(""), _message);
}
function testRateLimitedHook_revertsTransfer_ifExceedsFilledLevel(
uint128 _amount,
uint128 _time
) external {
// Warp to a random time, get it's filled level, and try to transfer more than the target max
vm.warp(_time);
uint256 filledLevelBefore = rateLimitedHook.calculateCurrentLevel();
vm.assume(_amount > filledLevelBefore);
_mintAndApprove(_amount);
vm.expectRevert("RateLimitExceeded");
warpRouteLocal.transferRemote{value: 1}(
DESTINATION,
BOB.addressToBytes32(),
_amount
);
}
function testRateLimitedHook_allowsTransfer_ifUnderLimit(
uint128 _amount,
uint128 _time
) external {
// Warp to a random time, get it's filled level, and try to transfer less than the target max
vm.warp(_time);
uint256 filledLevelBefore = rateLimitedHook.calculateCurrentLevel();
vm.assume(_amount <= filledLevelBefore);
_mintAndApprove(_amount);
warpRouteLocal.transferRemote(
DESTINATION,
BOB.addressToBytes32(),
_amount
);
uint256 limitAfter = rateLimitedHook.calculateCurrentLevel();
assertApproxEqRel(limitAfter, filledLevelBefore - _amount, ONE_PERCENT);
}
function testRateLimitedHook_preventsDuplicateMessageFromValidating(
uint128 _amount
) public {
// Warp to a random time, get it's filled level, and try to transfer less than the target max
vm.warp(1 days);
uint256 filledLevelBefore = rateLimitedHook.calculateCurrentLevel();
vm.assume(_amount <= filledLevelBefore);
_mintAndApprove(_amount);
// Generate an outbound message that will be the same as the one created in transferRemote
bytes memory tokenMessage = TokenMessage.format(
BOB.addressToBytes32(),
_amount,
bytes("")
);
vm.prank(address(warpRouteLocal));
bytes memory message = localMailbox.buildOutboundMessage(
DESTINATION,
address(warpRouteRemote).addressToBytes32(),
tokenMessage
);
bytes32 messageId = warpRouteLocal.transferRemote(
DESTINATION,
BOB.addressToBytes32(),
_amount
);
assertEq(message.id(), messageId);
vm.expectRevert("MessageAlreadyValidated");
rateLimitedHook.postDispatch(bytes(""), message);
}
}

@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT or Apache-2.0
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {TokenMessage} from "contracts/token/libs/TokenMessage.sol";
import {TypeCasts} from "contracts/libs/TypeCasts.sol";
import {RateLimitedIsm} from "contracts/isms/warp-route/RateLimitedIsm.sol";
import {TestMailbox} from "contracts/test/TestMailbox.sol";
import {MessageUtils} from "../isms/IsmTestUtils.sol";
import {TestRecipient} from "contracts/test/TestRecipient.sol";
contract RateLimitedIsmTest is Test {
using TypeCasts for address;
uint256 MAX_CAPACITY = 1 ether;
uint32 constant ORIGIN = 11;
uint32 constant DESTINATION = 12;
address WARP_ROUTE_ADDR = makeAddr("warpRoute");
TestMailbox localMailbox;
TestRecipient testRecipient;
RateLimitedIsm rateLimitedIsm;
function setUp() external {
localMailbox = new TestMailbox(ORIGIN);
rateLimitedIsm = new RateLimitedIsm(
address(localMailbox),
MAX_CAPACITY
);
testRecipient = new TestRecipient();
testRecipient.setInterchainSecurityModule(address(rateLimitedIsm));
}
function testRateLimitedIsm_revertsIDeliveredFalse(
bytes calldata _message
) external {
vm.prank(address(localMailbox));
vm.expectRevert("InvalidDeliveredMessage");
rateLimitedIsm.verify(bytes(""), _message);
}
function testRateLimitedIsm_verify(uint128 _amount) external {
vm.assume(_amount <= rateLimitedIsm.calculateCurrentLevel());
vm.prank(address(localMailbox));
localMailbox.process(bytes(""), _encodeTestMessage(_amount));
}
function testRateLimitedIsm_preventsDuplicateMessageFromValidating(
uint128 _amount
) public {
vm.assume(_amount <= rateLimitedIsm.calculateCurrentLevel());
bytes memory encodedMessage = _encodeTestMessage(_amount);
vm.prank(address(localMailbox));
localMailbox.process(bytes(""), encodedMessage);
vm.expectRevert("MessageAlreadyValidated");
rateLimitedIsm.verify(bytes(""), encodedMessage);
}
function _encodeTestMessage(
uint256 _amount
) internal view returns (bytes memory) {
return
MessageUtils.formatMessage(
uint8(3),
uint32(1),
ORIGIN,
WARP_ROUTE_ADDR.addressToBytes32(),
ORIGIN,
address(testRecipient).addressToBytes32(),
TokenMessage.format(bytes32(""), _amount, bytes(""))
);
}
}

@ -0,0 +1,107 @@
// SPDX-License-Identifier: MIT or Apache-2.0
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {RateLimited} from "../../contracts/libs/RateLimited.sol";
contract RateLimitLibTest is Test {
RateLimited rateLimited;
uint256 constant MAX_CAPACITY = 1 ether;
uint256 constant ONE_PERCENT = 0.01 ether; // Used for assertApproxEqRel
address HOOK = makeAddr("HOOK");
function setUp() public {
rateLimited = new RateLimited(MAX_CAPACITY);
}
function testRateLimited_setsNewLimit() external {
rateLimited.setRefillRate(2 ether);
assertApproxEqRel(rateLimited.maxCapacity(), 2 ether, ONE_PERCENT);
assertEq(rateLimited.refillRate(), uint256(2 ether) / 1 days); // 2 ether / 1 day
}
function testRateLimited_revertsIfMaxNotSet() external {
rateLimited.setRefillRate(0);
vm.expectRevert();
rateLimited.calculateCurrentLevel();
}
function testRateLimited_returnsCurrentFilledLevel_anyDay(
uint40 time
) external {
bound(time, 1 days, 2 days);
vm.warp(time);
// Using approx because division won't be exact
assertApproxEqRel(
rateLimited.calculateCurrentLevel(),
MAX_CAPACITY,
ONE_PERCENT
);
}
function testRateLimited_onlyOwnerCanSetTargetLimit() external {
vm.prank(address(0));
vm.expectRevert();
rateLimited.setRefillRate(1 ether);
}
function testRateLimited_neverReturnsGtMaxLimit(
uint256 _newAmount,
uint40 _newTime
) external {
vm.warp(_newTime);
vm.assume(_newAmount <= rateLimited.calculateCurrentLevel());
rateLimited.validateAndConsumeFilledLevel(_newAmount);
assertLe(
rateLimited.calculateCurrentLevel(),
rateLimited.maxCapacity()
);
}
function testRateLimited_decreasesLimitWithinSameDay() external {
vm.warp(1 days);
uint256 currentTargetLimit = rateLimited.calculateCurrentLevel();
uint256 amount = 0.4 ether;
uint256 newLimit = rateLimited.validateAndConsumeFilledLevel(amount);
assertEq(newLimit, currentTargetLimit - amount);
// Consume the same amount
currentTargetLimit = rateLimited.calculateCurrentLevel();
newLimit = rateLimited.validateAndConsumeFilledLevel(amount);
assertEq(newLimit, currentTargetLimit - amount);
// One more to exceed limit
vm.expectRevert();
rateLimited.validateAndConsumeFilledLevel(amount);
}
function testRateLimited_replinishesWithinSameDay() external {
vm.warp(1 days);
uint256 amount = 0.95 ether;
uint256 newLimit = rateLimited.validateAndConsumeFilledLevel(amount);
uint256 currentTargetLimit = rateLimited.calculateCurrentLevel();
assertApproxEqRel(currentTargetLimit, 0.05 ether, ONE_PERCENT);
// Warp to near end-of-day
vm.warp(block.timestamp + 0.99 days);
newLimit = rateLimited.validateAndConsumeFilledLevel(amount);
assertApproxEqRel(newLimit, 0.05 ether, ONE_PERCENT);
}
function testRateLimited_shouldResetLimit_ifDurationExceeds(
uint256 _amount
) external {
// Transfer less than the limit
vm.warp(0.5 days);
uint256 currentTargetLimit = rateLimited.calculateCurrentLevel();
vm.assume(_amount < currentTargetLimit);
uint256 newLimit = rateLimited.validateAndConsumeFilledLevel(_amount);
assertApproxEqRel(newLimit, currentTargetLimit - _amount, ONE_PERCENT);
// Warp to a new cycle
vm.warp(10 days);
currentTargetLimit = rateLimited.calculateCurrentLevel();
assertApproxEqRel(currentTargetLimit, MAX_CAPACITY, ONE_PERCENT);
}
}
Loading…
Cancel
Save