From eac1e78119e6e3ea9dbe0853d0c275a618391781 Mon Sep 17 00:00:00 2001 From: Yurii3721 <202633286+Yurii3721@users.noreply.github.com> Date: Mon, 4 May 2026 12:16:13 +0300 Subject: [PATCH] fix(lz-bridge): emit actual LZ native fee in Bridged event [audit L-02] burnAndSend now returns the MessagingReceipt from the LZ endpoint, and LZCrossChainBridge.sendOhm surfaces receipt.fee.nativeFee in the Bridged event alongside the supplied msg.value. Previously the event recorded msg.value, which overstates the paid fee when callers overpay and the LZ endpoint refunds the excess to msg.sender. Extend tests: BurnAndSend verifies receipt.fee.nativeFee matches the estimate and is strictly less than totalSent on overpayment; SendOhm adds test_sendOhm_emitsActualNativeFeeOnOverpayment to assert the Bridged event reports the real nativeFee and the supplied msgValue. --- src/periphery/bridge/LZCrossChainBridge.sol | 6 +-- .../interfaces/ILZCrossChainBridge.sol | 13 +++++- src/policies/bridge/LZBridgeGateway.sol | 11 ++++- src/policies/interfaces/ILZBridgeGateway.sol | 7 ++- .../LZCrossChainBridge_SendOhm.t.sol | 44 ++++++++++++++++++- .../LZBridgeGateway_BurnAndSend.t.sol | 22 ++++++++-- 6 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/periphery/bridge/LZCrossChainBridge.sol b/src/periphery/bridge/LZCrossChainBridge.sol index 11a10c40a..c70b7b472 100644 --- a/src/periphery/bridge/LZCrossChainBridge.sol +++ b/src/periphery/bridge/LZCrossChainBridge.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.30; // Interfaces import {IERC20} from "@openzeppelin-5.3.0/token/ERC20/IERC20.sol"; -import {MessagingFee} from "@lz-evm-protocol-v2-3.0.162/interfaces/ILayerZeroEndpointV2.sol"; +import {MessagingFee, MessagingReceipt} from "@lz-evm-protocol-v2-3.0.162/interfaces/ILayerZeroEndpointV2.sol"; import {IVersioned} from "src/interfaces/IVersioned.sol"; import {ILZCrossChainBridge} from "src/periphery/interfaces/ILZCrossChainBridge.sol"; import {ILZBridgeGateway} from "src/policies/interfaces/ILZBridgeGateway.sol"; @@ -57,7 +57,7 @@ contract LZCrossChainBridge is Owned, PeripheryEnabler, IVersioned, ILZCrossChai IERC20(OHM).safeTransferFrom(msg.sender, gateway, amount_); // Gateway burns and sends via LayerZero - ILZBridgeGateway(gateway).burnAndSend{value: msg.value}( + MessagingReceipt memory receipt = ILZBridgeGateway(gateway).burnAndSend{value: msg.value}( dstEid_, to_, amount_, @@ -65,7 +65,7 @@ contract LZCrossChainBridge is Owned, PeripheryEnabler, IVersioned, ILZCrossChai bytes("") ); - emit Bridged(msg.sender, amount_, dstEid_, msg.value); + emit Bridged(msg.sender, amount_, dstEid_, receipt.fee.nativeFee, msg.value); } /// @inheritdoc ILZCrossChainBridge diff --git a/src/periphery/interfaces/ILZCrossChainBridge.sol b/src/periphery/interfaces/ILZCrossChainBridge.sol index 63361fbaa..76bd0d653 100644 --- a/src/periphery/interfaces/ILZCrossChainBridge.sol +++ b/src/periphery/interfaces/ILZCrossChainBridge.sol @@ -22,8 +22,17 @@ interface ILZCrossChainBridge is IVersioned { /// @param sender The address that initiated the bridge transfer. /// @param amount The amount of OHM bridged. /// @param dstEid The LayerZero destination endpoint ID. - /// @param fees The native token fee paid for the bridge transfer. - event Bridged(address indexed sender, uint256 amount, uint32 indexed dstEid, uint256 fees); + /// @param nativeFee The native token fee actually charged by LayerZero, read from + /// `MessagingReceipt`. May be less than `msgValue` when the caller overpays; + /// the excess is refunded to the sender by the LayerZero endpoint. + /// @param msgValue The native value the caller supplied with the transaction. + event Bridged( + address indexed sender, + uint256 amount, + uint32 indexed dstEid, + uint256 nativeFee, + uint256 msgValue + ); /// @notice Emitted when the gateway address is updated. /// @param gateway The new gateway address. diff --git a/src/policies/bridge/LZBridgeGateway.sol b/src/policies/bridge/LZBridgeGateway.sol index 10843605b..f8d0932e5 100644 --- a/src/policies/bridge/LZBridgeGateway.sol +++ b/src/policies/bridge/LZBridgeGateway.sol @@ -204,7 +204,14 @@ contract LZBridgeGateway is uint256 amount_, address payable refundAddress_, bytes calldata extraOptions_ - ) external payable override onlyEnabled onlyRole(_BRIDGE_FACILITATOR_ROLE) { + ) + external + payable + override + onlyEnabled + onlyRole(_BRIDGE_FACILITATOR_ROLE) + returns (MessagingReceipt memory receipt) + { // Note: zero-amount validation is the facilitator's responsibility _requireNonzeroAddress(to_, "to"); @@ -231,7 +238,7 @@ contract LZBridgeGateway is bytes memory payload = abi.encode(MSG_BRIDGE_OHM, abi.encode(to_, amount_)); bytes memory options = _combineOptions(dstEid_, MSG_BRIDGE_OHM, extraOptions_); - MessagingReceipt memory receipt = ILayerZeroEndpointV2(LZ_ENDPOINT).send{value: msg.value}( + receipt = ILayerZeroEndpointV2(LZ_ENDPOINT).send{value: msg.value}( MessagingParams({ dstEid: dstEid_, receiver: peer, diff --git a/src/policies/interfaces/ILZBridgeGateway.sol b/src/policies/interfaces/ILZBridgeGateway.sol index e493f87a8..ff6a9827c 100644 --- a/src/policies/interfaces/ILZBridgeGateway.sol +++ b/src/policies/interfaces/ILZBridgeGateway.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.18; import {EnforcedOptionParam} from "@lz-oapp-evm-0.4.1/oapp/interfaces/IOAppOptionsType3.sol"; import {RateLimiter} from "@lz-oapp-evm-0.4.1/oapp/utils/RateLimiter.sol"; -import {MessagingFee} from "@lz-evm-protocol-v2-3.0.162/interfaces/ILayerZeroEndpointV2.sol"; +import {MessagingFee, MessagingReceipt} from "@lz-evm-protocol-v2-3.0.162/interfaces/ILayerZeroEndpointV2.sol"; import {IVersioned} from "src/interfaces/IVersioned.sol"; import {ILZEndpointV2Admin} from "src/policies/interfaces/ILZEndpointV2Admin.sol"; @@ -121,13 +121,16 @@ interface ILZBridgeGateway is IVersioned, ILZEndpointV2Admin { /// @param amount_ The amount of OHM to burn and send. /// @param refundAddress_ The address to receive excess native token refund. /// @param extraOptions_ Additional Type 3 options to combine with enforced options. + /// @return receipt The LayerZero messaging receipt. `receipt.fee.nativeFee` is the + /// actual native amount charged by the endpoint (may be less than msg.value; + /// excess is refunded to `refundAddress_`). function burnAndSend( uint32 dstEid_, address to_, uint256 amount_, address payable refundAddress_, bytes calldata extraOptions_ - ) external payable; + ) external payable returns (MessagingReceipt memory receipt); // ========= FEE ESTIMATION ========= // diff --git a/src/test/periphery/bridge/LZCrossChainBridge/LZCrossChainBridge_SendOhm.t.sol b/src/test/periphery/bridge/LZCrossChainBridge/LZCrossChainBridge_SendOhm.t.sol index fd5851721..bf4ca4d7e 100644 --- a/src/test/periphery/bridge/LZCrossChainBridge/LZCrossChainBridge_SendOhm.t.sol +++ b/src/test/periphery/bridge/LZCrossChainBridge/LZCrossChainBridge_SendOhm.t.sol @@ -21,7 +21,13 @@ contract LZCrossChainBridgeTests_SendOhm is LZCrossChainBridgeTestBase { MessagingFee memory fee = bridge.estimateSendFee(NONCANONICAL_EID, recipient, amount); vm.expectEmit(true, true, true, true); - emit ILZCrossChainBridge.Bridged(user, amount, NONCANONICAL_EID, fee.nativeFee); + emit ILZCrossChainBridge.Bridged( + user, + amount, + NONCANONICAL_EID, + fee.nativeFee, + fee.nativeFee + ); vm.prank(user); bridge.sendOhm{value: fee.nativeFee}(NONCANONICAL_EID, recipient, amount); @@ -46,6 +52,42 @@ contract LZCrossChainBridgeTests_SendOhm is LZCrossChainBridgeTestBase { ); } + /// @notice When the caller overpays, the Bridged event reports the actual nativeFee + /// charged by LayerZero, and the excess is refunded to msg.sender. + function test_sendOhm_emitsActualNativeFeeOnOverpayment() external { + uint256 amount = 1000e9; + MessagingFee memory fee = bridge.estimateSendFee(NONCANONICAL_EID, recipient, amount); + + uint256 excess = 3 ether; + uint256 totalSent = fee.nativeFee + excess; + uint256 userEthBefore = user.balance; + + vm.expectEmit(true, true, true, true); + emit ILZCrossChainBridge.Bridged(user, amount, NONCANONICAL_EID, fee.nativeFee, totalSent); + + vm.prank(user); + bridge.sendOhm{value: totalSent}(NONCANONICAL_EID, recipient, amount); + + // Deliver packet so the OHM is credited on destination + verifyPackets(NONCANONICAL_EID, LZConfigLib.addressToBytes32(address(gateway2))); + + // User should only be debited the actual fee (excess is refunded to msg.sender). + uint256 userEthAfter = user.balance; + assertGe( + userEthAfter, + userEthBefore - fee.nativeFee - 0.01 ether, + "User should be charged only the actual fee (plus small refund tolerance)" + ); + assertLe( + userEthAfter, + userEthBefore - fee.nativeFee, + "User cannot be charged less than the actual fee" + ); + + assertEq(address(bridge).balance, 0, "Bridge should hold no ETH after send"); + assertEq(address(gateway).balance, 0, "Gateway should hold no ETH after send"); + } + function test_sendOhm_revertsIfNotEnabled() external { // Disable bridge bridge.disable(bytes("")); diff --git a/src/test/policies/bridge/LZBridgeGateway/LZBridgeGateway_BurnAndSend.t.sol b/src/test/policies/bridge/LZBridgeGateway/LZBridgeGateway_BurnAndSend.t.sol index 4e742be39..2f3758e33 100644 --- a/src/test/policies/bridge/LZBridgeGateway/LZBridgeGateway_BurnAndSend.t.sol +++ b/src/test/policies/bridge/LZBridgeGateway/LZBridgeGateway_BurnAndSend.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.30; import {LZBridgeGatewayTestBase} from "src/test/policies/bridge/LZBridgeGateway/LZBridgeGatewayTestBase.sol"; // Interfaces -import {MessagingFee} from "@lz-evm-protocol-v2-3.0.162/interfaces/ILayerZeroEndpointV2.sol"; +import {MessagingFee, MessagingReceipt} from "@lz-evm-protocol-v2-3.0.162/interfaces/ILayerZeroEndpointV2.sol"; import {EnforcedOptionParam} from "@lz-oapp-evm-0.4.1/oapp/interfaces/IOAppOptionsType3.sol"; import {IEnabler} from "src/periphery/interfaces/IEnabler.sol"; import {ILZBridgeGateway} from "src/policies/interfaces/ILZBridgeGateway.sol"; @@ -41,7 +41,7 @@ contract LZBridgeGatewayTests_BurnAndSend is LZBridgeGatewayTestBase { vm.expectEmit(true, true, true, false); emit ILZBridgeGateway.Sent(facilitator, amount, NONCANONICAL_EID, bytes32(0)); - gateway.burnAndSend{value: fee.nativeFee}( + MessagingReceipt memory receipt = gateway.burnAndSend{value: fee.nativeFee}( NONCANONICAL_EID, recipient, amount, @@ -50,6 +50,14 @@ contract LZBridgeGatewayTests_BurnAndSend is LZBridgeGatewayTestBase { ); vm.stopPrank(); + assertEq( + receipt.fee.nativeFee, + fee.nativeFee, + "Receipt native fee should match estimated fee" + ); + assertEq(receipt.fee.lzTokenFee, 0, "Receipt should have no lzToken fee"); + assertTrue(receipt.guid != bytes32(0), "Receipt guid should be non-zero"); + assertEq( ohm.balanceOf(facilitator), facilitatorBalanceBefore - amount, @@ -139,7 +147,7 @@ contract LZBridgeGatewayTests_BurnAndSend is LZBridgeGatewayTestBase { vm.startPrank(facilitator); ohm.transfer(address(gateway), amount); - gateway.burnAndSend{value: totalSent}( + MessagingReceipt memory receipt = gateway.burnAndSend{value: totalSent}( NONCANONICAL_EID, recipient, amount, @@ -148,6 +156,14 @@ contract LZBridgeGatewayTests_BurnAndSend is LZBridgeGatewayTestBase { ); vm.stopPrank(); + // Receipt records the actual fee charged, not the excess msg.value. + assertEq(receipt.fee.nativeFee, fee.nativeFee, "Receipt should report actual fee charged"); + assertLt( + receipt.fee.nativeFee, + totalSent, + "Receipt fee must be less than totalSent when overpaying" + ); + uint256 refundReceived = refundAddr.balance - refundBalanceBefore; // Refund should be approximately the excess (minus any rounding) assertGe(refundReceived, excess - 0.01 ether, "Refund should return most of the excess");