Overview
This report covers the security review for EYWA. This audit covered some Solidity smart contracts of the CrossCurve OFT protocol. Our security assessment was a full review of the code, spanning a total of 1 week. During our review, we did not identify any major security vulnerabilities. We did identify several Medium severity vulnerabilities and other minor issues and optimizations. All reported issues were fixed by the development team and subsequently verified by us. We can confidently say that the overall security and code quality have increased after completion of our audit.
Scope
The analyzed resources are located on:
The issues described in this report were fixed in the following commit:
Summary
Weaknesses
This section contains the list of discovered weaknesses.
XCRV1-2 | SEND EXECUTION CHARGES PROTOCOL TREASURY FOR USER MESSAGING COSTS
Severity:
Status:
Fixed
Path:
contracts/CrossCurveCore.sol
Description:
When crossCurveEnabled[dstEid] is enabled, the CrossCurve send path no longer uses the standard LayerZero payment flow. quoteSend() still computes a messaging fee, but _send() does not enforce payment from the caller and instead forwards the message to GateKeeper.sendData(). As a result, a user can submit valid CrossCurve sends while the associated bridge cost is paid by the protocol treasury.
function quoteSend(
SendParam calldata _sendParam,
bool _payInLzToken
) external view virtual override returns (MessagingFee memory msgFee) {
...
uint256 gasLimit = OptionsReader.getGaslimit(_sendParam.extraOptions);
return _quote(_sendParam.dstEid, message, options, _payInLzToken, gasLimit);
}
function _send(
SendParam calldata _sendParam,
MessagingFee calldata _fee,
address _refundAddress
) internal virtual override returns (MessagingReceipt memory msgReceipt, OFTReceipt memory oftReceipt) {
...
if (crossCurveEnabled[_sendParam.dstEid] == false) {
msgReceipt = _lzSend(_sendParam.dstEid, message, options, _fee, _refundAddress);
} else {
...
uint256 gasLimit = OptionsReader.getGaslimit(_sendParam.extraOptions);
bytes[] memory ccOptions = gateKeeper.buildOptions(
chainIdTo,
gasLimit,
nonce
);
...
gateKeeper.sendData(data, receiver, chainIdTo, ccOptions);
}
}
GateKeeper calculates the bridge cost and withdraws the corresponding amount from treasuries[protocol] before sending the message. This makes the protocol treasury the payer for user-initiated CrossCurve sends.
// https://github.com/eywa-protocol/eywa-cdp/blob/main/contracts/bridge/GateKeeper.sol
function estimateGasFee(
bytes calldata data,
bytes32 to,
uint64 chainIdTo,
bytes[] memory currentOptions
) public view returns (uint256, uint256) {
...
sendFee += _quoteCustomBridge(
selectedBridges[i],
...,
currentOptions[i],
discounts[msg.sender]
);
...
}
function _sendCustomBridge(
address bridge_,
IBridge.SendParams memory params,
uint256 nonce,
address protocol,
bytes memory options,
uint256 discountPersentage
) internal returns(uint256, uint256) {
...
INativeTreasury(treasuries[protocol]).getValue(totalFee);
IBridge(bridge_).sendV3{value: bridge_ == bridgeEywa ? 0 : gasFee}(
params,
protocol,
nonce,
options
);
}
The treasury-funded amount is also influenced by user-provided execution input. CrossCurveCore derives gasLimit from _sendParam.extraOptions through OptionsReader.getGaslimit(...) and passes that value into GateKeeper.buildOptions(...). GateKeeper then uses the resulting bridge options during fee estimation, and on the BridgeV3 route those options are decoded into gasExecute and forwarded to IOracle.estimateFeeByChain(...). As a result, the amount charged to the protocol treasury depends in part on a gas value derived from caller-controlled input.
// https://github.com/eywa-protocol/eywa-cdp/blob/main/contracts/bridge/BridgeV3.sol
function estimateGasFee(
SendParams calldata params,
address sender,
bytes memory options
) public view returns (uint256) {
uint32 gasExecute = abi.decode(options, (uint32));
(uint256 fee,) = IOracle(priceOracle).estimateFeeByChain(
params.chainIdTo,
params.data.length,
gasExecute
);
return fee;
}
Remediation:
Require the caller to pay the actual messaging cost for ordinary CrossCurve sends, instead of charging that cost to the protocol treasury.
Ensure the fee collected during execution is based on the same effective parameters that are ultimately used to construct the CrossCurve bridge request.
Validate any user-supplied gas or option values that affect messaging cost against protocol-defined bounds before using them in option construction or fee estimation.
XCRV1-1 | NO REFUND FOR EXCESS MSG.VALUE IN CROSSCURVE SEND PATH
Severity:
Status:
Fixed
Path:
contracts/CrossCurveCore.sol:_send#L282-L334
Description:
The send() function is payable, so users may send native tokens as messaging fees. In the LayerZero path, any excess ETH is refunded via _refundAddress. However, the CrossCurve path has no equivalent refund mechanism.
As a result, any excess msg.value sent through the CrossCurve branch can remain trapped in the contract with no recovery path.
The LayerZero path explicitly supports excess-fee refunds, while the CrossCurve path neither forwards ETH to gateKeeper.sendData() nor refunds unused ETH to the caller.
LayerZero path — refund supported
if (crossCurveEnabled[_sendParam.dstEid] == false) {
msgReceipt = _lzSend(
_sendParam.dstEid,
message,
options,
_fee,
_refundAddress // refunds excess ETH
);
emit OFTSent(msgReceipt.guid, _sendParam.dstEid, msg.sender, amountSentLD, amountReceivedLD);
}
CrossCurve path — no refund handling
} else {
if (_sendParam.dstEid == 0) revert EidIncorrect(_sendParam.dstEid);
uint64 chainIdTo = IChainIdAdapter(chainIdAdapter).dstEidToChainId(_sendParam.dstEid);
bytes32 receiver = _getPeerOrRevert(_sendParam.dstEid);
uint64 nonce = _outbound(address(this), _sendParam.dstEid, receiver);
uint256 gasLimit = OptionsReader.getGaslimit(_sendParam.extraOptions);
IGateKeeper gateKeeper = IGateKeeper(addressBook.gateKeeper());
bytes[] memory ccOptions = gateKeeper.buildOptions(chainIdTo, gasLimit, nonce);
...
gateKeeper.sendData(data, receiver, chainIdTo, ccOptions);
// no {value: ...}
// _refundAddress unused
// no refund logic
}
Remediation:
Consider forwarding the required ETH fee to gateKeeper.sendData() and refunding any unused msg.value to _refundAddress.
XCRV1-3 | GUID COLLISION FOR NON-EVM SENDERS
Severity:
Status:
Fixed
Path:
contracts/CrossCurveCore.sol:L63, contracts/CrossCurveCore.sol#L63, contracts/CrossCurveCore.sol#L158
Description:
GUID.generate() converts the sender identifier from bytes32 to address using AddressCast.toAddress(), which truncates the upper 12 bytes. While safe for EVM addresses (which are always 20 bytes), this causes potential collisions for non-EVM chains (e.g., Aptos, Solana) where full 32-byte addresses are used.
Different 32-byte sender addresses that share the same lower 20 bytes can produce identical GUIDs, breaking message uniqueness guarantees.
The GUID is derived using a truncated sender value:
AddressCast.toAddress(_sender) // bytes32 → address (lower 20 bytes only)
function toAddress(bytes32 _address) internal pure returns (address) {
return address(uint160(uint256(_address)));
// upper 12 bytes are discarded
}
XCRV1-4 | CROSSCURVE SEND PATH OMITS MESSAGINGRECEIPT AND OFTSENT EVENT
Severity:
Status:
Fixed
Path:
contracts/CrossCurveCore.sol:_send#L282-L334
Description:
When a user sends tokens through the CrossCurve path (crossCurveEnabled[dstEid] == true), the _send function successfully debits tokens and dispatches the message via gateKeeper.sendData, but never populates the msgReceipt return value and never emits the OFTSent event. The LayerZero path (the if branch) correctly assigns msgReceipt from _lzSend and emits OFTSent on line 304, but the CrossCurve else branch on lines 305-330 skips both entirely.
The public send() inherited from OFTCoreUpgradeable returns _send()'s result directly to the caller, so any contract reading the returned MessagingReceipt gets a zeroed struct.
On the event side, OFTSent is the standard event LayerZero tooling uses to track outbound transfers. Without it, CrossCurve sends are invisible to block explorers like LayerZero Scan, monitoring dashboards, and any off-chain indexer that relies on this event.
function _send(
SendParam calldata _sendParam,
MessagingFee calldata _fee,
address _refundAddress
) internal virtual override returns (MessagingReceipt memory msgReceipt, OFTReceipt memory oftReceipt) {
// @dev Applies the token transfers regarding this send() operation.
// - amountSentLD is the amount in local decimals that was ACTUALLY sent/debited from the sender.
// - amountReceivedLD is the amount in local decimals that will be received/credited to the recipient on the remote OFT instance.
(uint256 amountSentLD, uint256 amountReceivedLD) = _debit(
msg.sender,
_sendParam.amountLD,
_sendParam.minAmountLD,
_sendParam.dstEid
);
// @dev Builds the options and OFT message to quote in the endpoint.
(bytes memory message, bytes memory options) = _buildMsgAndOptions(_sendParam, amountReceivedLD);
if (crossCurveEnabled[_sendParam.dstEid] == false) {
// @dev Sends the message to the LayerZero endpoint and returns the LayerZero msg receipt.
msgReceipt = _lzSend(_sendParam.dstEid, message, options, _fee, _refundAddress);
emit OFTSent(msgReceipt.guid, _sendParam.dstEid, msg.sender, amountSentLD, amountReceivedLD);
} else {
if (_sendParam.dstEid == 0) revert EidIncorrect(_sendParam.dstEid);
uint64 chainIdTo = IChainIdAdapter(chainIdAdapter).dstEidToChainId(_sendParam.dstEid);
bytes32 receiver = _getPeerOrRevert(_sendParam.dstEid);
uint64 nonce = _outbound(address(this), _sendParam.dstEid, receiver);
uint256 gasLimit = OptionsReader.getGaslimit(_sendParam.extraOptions);
IGateKeeper gateKeeper = IGateKeeper(addressBook.gateKeeper());
bytes[] memory ccOptions = gateKeeper.buildOptions(
chainIdTo,
gasLimit,
nonce
);
Origin memory origin = Origin(
IChainIdAdapter(chainIdAdapter).chainIdToDstEid(uint64(block.chainid)),
AddressCast.toBytes32(address(this)),
nonce
);
bytes memory data = abi.encodeWithSelector(
ICrossCurveOFT.crossCurveReceive.selector,
origin,
message,
bytes32(0),
""
);
gateKeeper.sendData(data, receiver, chainIdTo, ccOptions);
}
// @dev Formulate the OFT receipt.
oftReceipt = OFTReceipt(amountSentLD, amountReceivedLD);
}
Remediation:
Populate msgReceipt and emit OFTSent in the CrossCurve branch, using the nonce and origin data already available in that scope.
XCRV1-5 | UNUSED SELECTOR PARAMETER IN RECEIVEVALIDATEDDATA
Severity:
Status:
Fixed
Path:
contracts/CrossCurveCore.sol:receiveValidatedData#L143-L150
Description:
The function receiveValidatedData is used by the Receiver contract to set the current srcEid and the sender. It validates both the from and chainIdFrom parameter, but it does not check or use the selector parameter.
We assume that this parameter could be used to limit the functionality to-be-called from the message, but it is currently unused. Because the Receiver is permissioned, the impact is low, but it does create more attack surface.
function receiveValidatedData(bytes4 selector, bytes32 from, uint64 chainIdFrom) external onlyReceiver returns (bool) {
uint32 srcEid = IChainIdAdapter(chainIdAdapter).chainIdToDstEid(chainIdFrom);
if (srcEid == 0) revert EidIncorrect(srcEid);
if (_getPeerOrRevert(srcEid) != from) revert OnlyPeer(srcEid, from);
CrossCurveOFTStorage.setCurrentSrcEid(srcEid);
CrossCurveOFTStorage.setCurrentSender(from);
return true;
}
Remediation:
We recommend implementing some selector checks in favor of defense-in-depth.
XCRV1-6 | UNUSED VARIABLES
Severity:
Status:
Fixed
Path:
contracts/lib/CrossCurveOFTStorage.sol:GASLIMIT_POS#L16, contracts/lib/CrossCurveOFTStorageS.sol:GASLIMIT_POS#L17
Description:
In the CrossCurve storage library, the constant variable GASLIMIT_POS is declared but never used.
/// @dev keccak256("crosscurve.oft.gasLimit");
bytes32 private constant GASLIMIT_POS = 0xdbc2516235d15f257691ce9942eeea638e7d4e7cad4c616797c3bad02f322f75;
Remediation:
We recommend to either use or remove unused variables.