Skip to content

Commit 347c6ce

Browse files
authored
Merge pull request #537 from moonwell-fi/feat/bridge-validation
add bridge validation hook
2 parents 7f6278e + c792c08 commit 347c6ce

File tree

6 files changed

+1130
-13
lines changed

6 files changed

+1130
-13
lines changed

.github/workflows/hook-tests.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Hook Tests
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
workflow_dispatch:
9+
10+
env:
11+
MOONBEAM_RPC_URL: ${{secrets.MOONBEAM_RPC_URL}}
12+
FOUNDRY_PROFILE: ci
13+
14+
jobs:
15+
hook-tests:
16+
name: Hook Tests (Unit + Integration)
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Checkout code
21+
uses: actions/checkout@v2
22+
with:
23+
submodules: recursive
24+
25+
- name: Setup Environment
26+
uses: ./.github/actions
27+
28+
- name: Run Hook Tests
29+
uses: nick-fields/retry@v3
30+
with:
31+
polling_interval_seconds: 30
32+
retry_wait_seconds: 60
33+
timeout_minutes: 20
34+
max_attempts: 3
35+
command: time forge test --match-contract Hook --fork-url moonbeam -vvv
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
pragma solidity 0.8.19;
3+
4+
import {xWELLRouter} from "@protocol/xWELL/xWELLRouter.sol";
5+
import {ProposalAction} from "@proposals/proposalTypes/IProposal.sol";
6+
7+
/// @title BridgeValidationHook
8+
/// @notice Hook to validate bridgeToRecipient calls in proposals
9+
/// @dev Ensures that the native value sent with bridge calls is between 5x and 10x
10+
/// the actual bridge cost returned by router.bridgeCost(destinationChain)
11+
abstract contract BridgeValidationHook {
12+
/// @notice Function selector for bridgeToRecipient(address,uint256,uint16)
13+
bytes4 private constant BRIDGE_TO_RECIPIENT_SELECTOR =
14+
xWELLRouter.bridgeToRecipient.selector;
15+
16+
/// @notice Minimum multiplier for bridge cost (5x)
17+
uint256 private constant MIN_BRIDGE_COST_MULTIPLIER = 5;
18+
19+
/// @notice Maximum multiplier for bridge cost (10x)
20+
uint256 private constant MAX_BRIDGE_COST_MULTIPLIER = 10;
21+
22+
/// @notice Verify bridge-related proposal actions before execution
23+
/// @dev Called by inheriting contracts to validate bridge cost parameters
24+
/// @param proposal Array of proposal actions to validate
25+
function _verifyBridgeActions(
26+
ProposalAction[] memory proposal
27+
) internal view {
28+
uint256 proposalLength = proposal.length;
29+
30+
for (uint256 i = 0; i < proposalLength; i++) {
31+
bytes4 selector = bytesToBytes4(proposal[i].data);
32+
33+
// Check if this action is a bridgeToRecipient call
34+
if (selector == BRIDGE_TO_RECIPIENT_SELECTOR) {
35+
address router = proposal[i].target;
36+
uint256 actionValue = proposal[i].value;
37+
38+
// Validate router is a contract
39+
_validateRouterIsContract(router);
40+
41+
// Extract wormholeChainId from calldata
42+
// Calldata structure:
43+
// 0-3: function selector
44+
// 4-35: address to (32 bytes)
45+
// 36-67: uint256 amount (32 bytes)
46+
// 68-99: uint16 wormholeChainId (32 bytes, right-padded)
47+
uint16 wormholeChainId = extractUint16FromCalldata(
48+
proposal[i].data
49+
);
50+
51+
// Get the actual bridge cost from the router with validation
52+
uint256 bridgeCost = _getBridgeCost(router, wormholeChainId);
53+
54+
// Validate that action value is between 5x and 10x the bridge cost
55+
uint256 minValue = bridgeCost * MIN_BRIDGE_COST_MULTIPLIER;
56+
uint256 maxValue = bridgeCost * MAX_BRIDGE_COST_MULTIPLIER;
57+
58+
require(
59+
actionValue >= minValue,
60+
string.concat(
61+
"BridgeValidationHook: bridge value too low. Expected >= ",
62+
_toString(minValue),
63+
", got ",
64+
_toString(actionValue)
65+
)
66+
);
67+
68+
require(
69+
actionValue <= maxValue,
70+
string.concat(
71+
"BridgeValidationHook: bridge value too high. Expected <= ",
72+
_toString(maxValue),
73+
", got ",
74+
_toString(actionValue)
75+
)
76+
);
77+
}
78+
}
79+
}
80+
81+
/// @notice Validates that the router address is a contract
82+
/// @param router The router address to validate
83+
function _validateRouterIsContract(address router) private view {
84+
require(
85+
router.code.length > 0,
86+
"BridgeValidationHook: router must be a contract"
87+
);
88+
}
89+
90+
/// @notice Gets bridge cost from router and validates it's non-zero
91+
/// @param router The router contract address
92+
/// @param wormholeChainId The destination chain ID
93+
/// @return bridgeCost The validated bridge cost
94+
function _getBridgeCost(
95+
address router,
96+
uint16 wormholeChainId
97+
) private view returns (uint256 bridgeCost) {
98+
bridgeCost = xWELLRouter(router).bridgeCost(wormholeChainId);
99+
100+
require(
101+
bridgeCost > 0,
102+
"BridgeValidationHook: bridge cost must be greater than zero"
103+
);
104+
}
105+
106+
/// @notice Extract uint16 value from calldata at the third parameter position
107+
/// @param input The calldata to extract from
108+
/// @return result The extracted uint16 value
109+
function extractUint16FromCalldata(
110+
bytes memory input
111+
) public pure returns (uint16 result) {
112+
require(
113+
input.length >= 100,
114+
"BridgeValidationHook: invalid calldata length"
115+
);
116+
117+
// The uint16 wormholeChainId is the third parameter, starting at byte 68
118+
// It's stored in the last 2 bytes of a 32-byte word
119+
bytes32 rawBytes;
120+
assembly {
121+
// Skip 32 bytes (array length) + 4 bytes (selector) + 64 bytes (first two params)
122+
// = 100 bytes total, so we load from position 68 after the length prefix
123+
let dataPointer := add(add(input, 0x20), 0x44) // 0x20 (32) + 0x44 (68) = 100
124+
rawBytes := mload(dataPointer) // Load 32 bytes
125+
}
126+
127+
// Extract the uint16 from the rightmost 2 bytes
128+
result = uint16(uint256(rawBytes));
129+
}
130+
131+
/// @notice Extract the first 4 bytes (function selector) from calldata
132+
/// @dev This function must be implemented by inheriting contracts
133+
/// @param toSlice The bytes to extract from
134+
/// @return functionSignature The extracted function selector
135+
function bytesToBytes4(
136+
bytes memory toSlice
137+
) public pure virtual returns (bytes4 functionSignature);
138+
139+
/// @notice Convert uint256 to string
140+
/// @param value The uint256 value to convert
141+
/// @return str The string representation
142+
function _toString(
143+
uint256 value
144+
) internal pure returns (string memory str) {
145+
if (value == 0) {
146+
return "0";
147+
}
148+
149+
uint256 temp = value;
150+
uint256 digits;
151+
152+
while (temp != 0) {
153+
digits++;
154+
temp /= 10;
155+
}
156+
157+
bytes memory buffer = new bytes(digits);
158+
159+
while (value != 0) {
160+
digits -= 1;
161+
buffer[digits] = bytes1(uint8(48 + uint256(value % 10)));
162+
value /= 10;
163+
}
164+
165+
return string(buffer);
166+
}
167+
}

proposals/hooks/MarketCreationHook.sol

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {MToken} from "@protocol/MToken.sol";
77
import {Comptroller} from "@protocol/Comptroller.sol";
88
import {ProposalAction} from "@proposals/proposalTypes/IProposal.sol";
99

10-
contract MarketCreationHook {
10+
abstract contract MarketCreationHook {
1111
/// private so that contracts that inherit cannot write to functionDetectors
1212
bytes4 private constant detector = Comptroller._supportMarket.selector;
1313

@@ -31,7 +31,12 @@ contract MarketCreationHook {
3131
/// @notice comptroller address as specified by an mToken being created
3232
address private comptroller;
3333

34-
function _verifyActionsPreRun(ProposalAction[] memory proposal) internal {
34+
/// @notice Verify market creation actions in proposals
35+
/// @dev Called by inheriting contracts to validate market listing pattern
36+
/// @param proposal Array of proposal actions to validate
37+
function _verifyMarketCreationActions(
38+
ProposalAction[] memory proposal
39+
) internal {
3540
address[] memory targets = new address[](proposal.length);
3641
uint256[] memory values = new uint256[](proposal.length);
3742
bytes[] memory datas = new bytes[](proposal.length);
@@ -197,18 +202,13 @@ contract MarketCreationHook {
197202
result = address(uint160(uint256(rawBytes)));
198203
}
199204

200-
/// @notice function to grab the first 4 bytes of calldata payload
205+
/// @notice Extract the first 4 bytes (function selector) from calldata
206+
/// @dev This function must be implemented by inheriting contracts
207+
/// @param toSlice The bytes to extract from
208+
/// @return functionSignature The extracted function selector
201209
function bytesToBytes4(
202210
bytes memory toSlice
203-
) public pure returns (bytes4 functionSignature) {
204-
if (toSlice.length < 4) {
205-
return bytes4(0);
206-
}
207-
208-
assembly {
209-
functionSignature := mload(add(toSlice, 0x20))
210-
}
211-
}
211+
) public pure virtual returns (bytes4 functionSignature);
212212

213213
/// Credit ethereum stackexchange https://ethereum.stackexchange.com/a/58341
214214
function toString(bytes memory data) public pure returns (string memory) {

proposals/proposalTypes/HybridProposal.sol

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {ProposalActions} from "@proposals/utils/ProposalActions.sol";
1717
import {ProposalChecker} from "@proposals/utils/ProposalChecker.sol";
1818
import {ITemporalGovernor} from "@protocol/governance/TemporalGovernor.sol";
1919
import {MarketCreationHook} from "@proposals/hooks/MarketCreationHook.sol";
20+
import {BridgeValidationHook} from "@proposals/hooks/BridgeValidationHook.sol";
2021
import {ProposalAction, ActionType} from "@proposals/proposalTypes/IProposal.sol";
2122
import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol";
2223
import {MultichainGovernor, IMultichainGovernor} from "@protocol/governance/multichain/MultichainGovernor.sol";
@@ -31,7 +32,8 @@ import {MultichainGovernor, IMultichainGovernor} from "@protocol/governance/mult
3132
abstract contract HybridProposal is
3233
Proposal,
3334
ProposalChecker,
34-
MarketCreationHook
35+
MarketCreationHook,
36+
BridgeValidationHook
3537
{
3638
using Strings for string;
3739
using Address for address;
@@ -44,6 +46,38 @@ abstract contract HybridProposal is
4446
/// @notice instant finality on moonbeam https://book.wormhole.com/wormhole/3_coreLayerContracts.html?highlight=consiste#consistency-levels
4547
uint8 public constant consistencyLevel = 200;
4648

49+
/// @notice Verify all proposal actions before execution
50+
/// @dev Calls both market creation and bridge validation hooks
51+
/// @param proposal Array of proposal actions to validate
52+
function _verifyActionsPreRun(ProposalAction[] memory proposal) internal {
53+
// Validate market creation actions
54+
_verifyMarketCreationActions(proposal);
55+
56+
// Validate bridge actions
57+
_verifyBridgeActions(proposal);
58+
}
59+
60+
/// @notice Extract the first 4 bytes (function selector) from calldata
61+
/// @dev Provides single implementation for both hooks to avoid duplication
62+
/// @param toSlice The bytes to extract from
63+
/// @return functionSignature The extracted function selector
64+
function bytesToBytes4(
65+
bytes memory toSlice
66+
)
67+
public
68+
pure
69+
override(MarketCreationHook, BridgeValidationHook)
70+
returns (bytes4 functionSignature)
71+
{
72+
if (toSlice.length < 4) {
73+
return bytes4(0);
74+
}
75+
76+
assembly {
77+
functionSignature := mload(add(toSlice, 0x20))
78+
}
79+
}
80+
4781
/// @notice actions to run against contracts
4882
ProposalAction[] public actions;
4983

0 commit comments

Comments
 (0)