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
6 changes: 3 additions & 3 deletions src/periphery/bridge/LZCrossChainBridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -57,15 +57,15 @@ 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_,
payable(msg.sender),
bytes("")
);

emit Bridged(msg.sender, amount_, dstEid_, msg.value);
emit Bridged(msg.sender, amount_, dstEid_, receipt.fee.nativeFee, msg.value);
}

/// @inheritdoc ILZCrossChainBridge
Expand Down
13 changes: 11 additions & 2 deletions src/periphery/interfaces/ILZCrossChainBridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions src/policies/bridge/LZBridgeGateway.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/policies/interfaces/ILZBridgeGateway.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 ========= //

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(""));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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");
Expand Down
Loading