Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions sdk/packages/lz-endpoint/contracts/HyperbridgeLzEndpoint.sol
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ contract HyperbridgeLzEndpoint is HyperApp, Ownable, Pausable, ILayerZeroEndpoin
/// @notice Thrown when the destination eid has no configured state machine mapping
error UnknownEid(uint32 eid);

/// @notice Thrown when setEidMapping is called with the zero eid
error InvalidEid();

/// @notice Thrown when an incoming message has an unexpected source
error UnknownSource();

Expand Down Expand Up @@ -143,6 +146,7 @@ contract HyperbridgeLzEndpoint is HyperApp, Ownable, Pausable, ILayerZeroEndpoin
* @param stateMachineId The ISMP state machine identifier (e.g., StateMachine.evm(1))
*/
function setEidMapping(uint32 lzEid, bytes calldata stateMachineId) external onlyOwner {
if (lzEid == 0) revert InvalidEid();
bytes memory previous = _eidToStateMachine[lzEid];
if (previous.length != 0) {
delete _stateMachineToEid[keccak256(previous)];
Expand Down Expand Up @@ -312,9 +316,9 @@ contract HyperbridgeLzEndpoint is HyperApp, Ownable, Pausable, ILayerZeroEndpoin
bytes memory message
) = abi.decode(request.body, (bytes32, uint32, bytes32, uint64, bytes32, bytes));

// Validate source eid matches the ISMP source chain
// Reject `expectedEid == 0` so unconfigured sources don't collide with `srcEid = 0`.
uint32 expectedEid = _stateMachineToEid[keccak256(request.source)];
if (expectedEid != srcEid) revert UnknownSource();
if (expectedEid == 0 || expectedEid != srcEid) revert UnknownSource();

// Validate and increment nonce
address receiverAddr = address(uint160(uint256(receiver)));
Expand Down
100 changes: 99 additions & 1 deletion sdk/packages/lz-endpoint/test/HyperbridgeLzEndpointConfigTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,35 @@ import "forge-std/Test.sol";

import {HyperbridgeLzEndpoint} from "../contracts/HyperbridgeLzEndpoint.sol";

/// @dev Fork-free unit tests for `setEidMapping` cleanup semantics (HYPERBR-427).
import {PostRequest} from "@hyperbridge/core/libraries/Message.sol";
import {IncomingPostRequest} from "@hyperbridge/core/interfaces/IApp.sol";

/// @dev Stub host: HyperbridgeLzEndpoint.setHost queries feeToken().decimals(), so the
/// mock host returns a stub feeToken whose `decimals()` returns 18. No other methods
/// are needed for the onAccept-path tests.
contract MockFeeToken {
function decimals() external pure returns (uint8) {
return 18;
}
}

contract MockHost {
address internal _feeToken;
constructor() {
_feeToken = address(new MockFeeToken());
}
function feeToken() external view returns (address) {
return _feeToken;
}
}

/// @dev Fork-free unit tests for `setEidMapping` cleanup semantics and the
/// `onAccept` source-eid gate against the `srcEid = 0` collision.
contract HyperbridgeLzEndpointConfigTest is Test {
HyperbridgeLzEndpoint internal endpoint;
MockHost internal mockHost;

uint32 internal constant LOCAL_EID = 200;
uint32 internal constant EID_A = 101;
uint32 internal constant EID_B = 102;

Expand All @@ -17,6 +42,8 @@ contract HyperbridgeLzEndpointConfigTest is Test {

function setUp() public {
endpoint = new HyperbridgeLzEndpoint(address(this));
mockHost = new MockHost();
endpoint.setHost(address(mockHost), LOCAL_EID);
}

function testRemapClearsPriorReverseEntry() public {
Expand Down Expand Up @@ -57,4 +84,75 @@ contract HyperbridgeLzEndpointConfigTest is Test {
assertEq(keccak256(endpoint.eidMapping(EID_A)), keccak256(SM_A));
assertEq(keccak256(endpoint.eidMapping(EID_B)), keccak256(SM_A));
}

function testRejectZeroEid() public {
vm.expectRevert(HyperbridgeLzEndpoint.InvalidEid.selector);
endpoint.setEidMapping(0, SM_A);

vm.expectRevert(HyperbridgeLzEndpoint.InvalidEid.selector);
endpoint.setEidMapping(0, hex"");
}

/// @dev Attacker submits an ISMP message from an UNREGISTERED source (the
/// zero-default reverse-map regime) with `srcEid = 0` in the body. The pre-fix
/// gate `expectedEid != srcEid` collapses to `0 != 0`, admitting the forgery.
/// The fixed gate explicitly rejects `expectedEid == 0`.
function testOnAcceptRejectsSrcEidZeroFromUnregisteredSource() public {
bytes memory unregisteredSource = SM_A; // never passed to setEidMapping

bytes memory body = abi.encode(
bytes32(0), // guid
uint32(0), // srcEid — the collision value
bytes32(uint256(uint160(address(0xBEEF)))), // sender
uint64(1), // nonce
bytes32(uint256(uint160(address(0xC0DE)))), // receiver
bytes("") // message
);

PostRequest memory request = PostRequest({
source: unregisteredSource,
dest: hex"00",
nonce: 0,
from: abi.encodePacked(address(endpoint)), // CREATE2 self-check passes
to: abi.encodePacked(address(endpoint)),
timeoutTimestamp: 0,
body: body
});

vm.prank(address(mockHost));
vm.expectRevert(HyperbridgeLzEndpoint.UnknownSource.selector);
endpoint.onAccept(IncomingPostRequest({request: request, relayer: address(0)}));
}

/// @dev Same scenario but the source was previously registered and then disabled
/// via `setEidMapping(eid, "")` — reverse map is now zero. The gate must still
/// reject `srcEid = 0` rather than collide with the cleared mapping.
function testOnAcceptRejectsSrcEidZeroFromDisabledSource() public {
endpoint.setEidMapping(EID_A, SM_A);
endpoint.setEidMapping(EID_A, hex""); // disable; clears reverse for SM_A
assertEq(endpoint.eidFor(SM_A), 0, "precondition: reverse cleared");

bytes memory body = abi.encode(
bytes32(0),
uint32(0),
bytes32(uint256(uint160(address(0xBEEF)))),
uint64(1),
bytes32(uint256(uint160(address(0xC0DE)))),
bytes("")
);

PostRequest memory request = PostRequest({
source: SM_A,
dest: hex"00",
nonce: 0,
from: abi.encodePacked(address(endpoint)),
to: abi.encodePacked(address(endpoint)),
timeoutTimestamp: 0,
body: body
});

vm.prank(address(mockHost));
vm.expectRevert(HyperbridgeLzEndpoint.UnknownSource.selector);
endpoint.onAccept(IncomingPostRequest({request: request, relayer: address(0)}));
}
}
Loading