From 9c4bbba768816734ab096be34dfdcbda8e3c9e88 Mon Sep 17 00:00:00 2001 From: Daniel Blaecker Date: Mon, 13 Jul 2026 13:28:18 +0700 Subject: [PATCH 1/5] feat(FraxFacet): add Frax HopV2 (LayerZero OFT) bridge facet + config + deploy scripts (EXSC-382) Co-Authored-By: Claude Opus 4.8 --- config/frax.json | 89 ++++++ script/deploy/facets/DeployFraxFacet.s.sol | 48 +++ script/deploy/facets/UpdateFraxFacet.s.sol | 13 + .../deploy/resources/deployRequirements.json | 19 ++ .../zksync/DeployFraxFacet.zksync.s.sol | 46 +++ .../zksync/UpdateFraxFacet.zksync.s.sol | 13 + src/Facets/FraxFacet.sol | 280 ++++++++++++++++++ src/Interfaces/IFraxHopV2.sol | 92 ++++++ 8 files changed, 600 insertions(+) create mode 100644 config/frax.json create mode 100644 script/deploy/facets/DeployFraxFacet.s.sol create mode 100644 script/deploy/facets/UpdateFraxFacet.s.sol create mode 100644 script/deploy/zksync/DeployFraxFacet.zksync.s.sol create mode 100644 script/deploy/zksync/UpdateFraxFacet.zksync.s.sol create mode 100644 src/Facets/FraxFacet.sol create mode 100644 src/Interfaces/IFraxHopV2.sol diff --git a/config/frax.json b/config/frax.json new file mode 100644 index 000000000..dca395a9d --- /dev/null +++ b/config/frax.json @@ -0,0 +1,89 @@ +{ + "hop": { + "mainnet": "0x0000006D38568b00B457580b734e0076C62de659", + "abstract": "0x0000006D38568b00B457580b734e0076C62de659", + "arbitrum": "0x0000006D38568b00B457580b734e0076C62de659", + "avalanche": "0x0000006D38568b00B457580b734e0076C62de659", + "base": "0x0000006D38568b00B457580b734e0076C62de659", + "berachain": "0x0000006D38568b00B457580b734e0076C62de659", + "bsc": "0x0000006D38568b00B457580b734e0076C62de659", + "fraxtal": "0x00000000e18aFc20Afe54d4B2C8688bB60c06B36", + "hyperevm": "0x0000006D38568b00B457580b734e0076C62de659", + "ink": "0x0000006D38568b00B457580b734e0076C62de659", + "katana": "0x0000006D38568b00B457580b734e0076C62de659", + "linea": "0x0000006D38568b00B457580b734e0076C62de659", + "mode": "0x0000006D38568b00B457580b734e0076C62de659", + "monad": "0x0000006D38568b00B457580b734e0076C62de659", + "optimism": "0x0000006D38568b00B457580b734e0076C62de659", + "plume": "0x0000006D38568b00B457580b734e0076C62de659", + "polygon": "0x0000006D38568b00B457580b734e0076C62de659", + "scroll": "0x0000006D38568b00B457580b734e0076C62de659", + "sei": "0x0000006D38568b00B457580b734e0076C62de659", + "somnia": "0x0000006D38568b00B457580b734e0076C62de659", + "sonic": "0x0000006D38568b00B457580b734e0076C62de659", + "stable": "0x0000006D38568b00B457580b734e0076C62de659", + "tempo": "0x0000006D38568b00B457580b734e0076C62de659", + "unichain": "0x0000006D38568b00B457580b734e0076C62de659", + "worldchain": "0x0000006D38568b00B457580b734e0076C62de659", + "xlayer": "0x0000006D38568b00B457580b734e0076C62de659", + "zksync": "0x0000006D38568b00B457580b734e0076C62de659" + }, + "tipFeeManager": { + "mainnet": "0x0000000000000000000000000000000000000000", + "abstract": "0x0000000000000000000000000000000000000000", + "arbitrum": "0x0000000000000000000000000000000000000000", + "avalanche": "0x0000000000000000000000000000000000000000", + "base": "0x0000000000000000000000000000000000000000", + "berachain": "0x0000000000000000000000000000000000000000", + "bsc": "0x0000000000000000000000000000000000000000", + "fraxtal": "0x0000000000000000000000000000000000000000", + "hyperevm": "0x0000000000000000000000000000000000000000", + "ink": "0x0000000000000000000000000000000000000000", + "katana": "0x0000000000000000000000000000000000000000", + "linea": "0x0000000000000000000000000000000000000000", + "mode": "0x0000000000000000000000000000000000000000", + "monad": "0x0000000000000000000000000000000000000000", + "optimism": "0x0000000000000000000000000000000000000000", + "plume": "0x0000000000000000000000000000000000000000", + "polygon": "0x0000000000000000000000000000000000000000", + "scroll": "0x0000000000000000000000000000000000000000", + "sei": "0x0000000000000000000000000000000000000000", + "somnia": "0x0000000000000000000000000000000000000000", + "sonic": "0x0000000000000000000000000000000000000000", + "stable": "0x0000000000000000000000000000000000000000", + "tempo": "0xfeEC000000000000000000000000000000000000", + "unichain": "0x0000000000000000000000000000000000000000", + "worldchain": "0x0000000000000000000000000000000000000000", + "xlayer": "0x0000000000000000000000000000000000000000", + "zksync": "0x0000000000000000000000000000000000000000" + }, + "pathUsd": { + "mainnet": "0x0000000000000000000000000000000000000000", + "abstract": "0x0000000000000000000000000000000000000000", + "arbitrum": "0x0000000000000000000000000000000000000000", + "avalanche": "0x0000000000000000000000000000000000000000", + "base": "0x0000000000000000000000000000000000000000", + "berachain": "0x0000000000000000000000000000000000000000", + "bsc": "0x0000000000000000000000000000000000000000", + "fraxtal": "0x0000000000000000000000000000000000000000", + "hyperevm": "0x0000000000000000000000000000000000000000", + "ink": "0x0000000000000000000000000000000000000000", + "katana": "0x0000000000000000000000000000000000000000", + "linea": "0x0000000000000000000000000000000000000000", + "mode": "0x0000000000000000000000000000000000000000", + "monad": "0x0000000000000000000000000000000000000000", + "optimism": "0x0000000000000000000000000000000000000000", + "plume": "0x0000000000000000000000000000000000000000", + "polygon": "0x0000000000000000000000000000000000000000", + "scroll": "0x0000000000000000000000000000000000000000", + "sei": "0x0000000000000000000000000000000000000000", + "somnia": "0x0000000000000000000000000000000000000000", + "sonic": "0x0000000000000000000000000000000000000000", + "stable": "0x0000000000000000000000000000000000000000", + "tempo": "0x20C0000000000000000000000000000000000000", + "unichain": "0x0000000000000000000000000000000000000000", + "worldchain": "0x0000000000000000000000000000000000000000", + "xlayer": "0x0000000000000000000000000000000000000000", + "zksync": "0x0000000000000000000000000000000000000000" + } +} diff --git a/script/deploy/facets/DeployFraxFacet.s.sol b/script/deploy/facets/DeployFraxFacet.s.sol new file mode 100644 index 000000000..f432f279f --- /dev/null +++ b/script/deploy/facets/DeployFraxFacet.s.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; + +import { DeployScriptBase } from "./utils/DeployScriptBase.sol"; +import { stdJson } from "forge-std/Script.sol"; +import { FraxFacet } from "lifi/Facets/FraxFacet.sol"; +import { IFraxHopV2 } from "lifi/Interfaces/IFraxHopV2.sol"; + +contract DeployScript is DeployScriptBase { + using stdJson for string; + + constructor() DeployScriptBase("FraxFacet") {} + + function run() + public + returns (FraxFacet deployed, bytes memory constructorArgs) + { + constructorArgs = getConstructorArgs(); + + deployed = FraxFacet(deploy(type(FraxFacet).creationCode)); + } + + function getConstructorArgs() internal override returns (bytes memory) { + string memory path = string.concat(root, "/config/frax.json"); + + address hop = _getConfigContractAddress( + path, + string.concat(".hop.", network) + ); + + // tipFeeManager and pathUsd are zero on every chain except Tempo, where they are + // precompile addresses (no bytecode); allow both zero and non-contract values. + address tipFeeManager = _getConfigContractAddress( + path, + string.concat(".tipFeeManager.", network), + true, + true + ); + address pathUsd = _getConfigContractAddress( + path, + string.concat(".pathUsd.", network), + true, + true + ); + + return abi.encode(IFraxHopV2(hop), tipFeeManager, pathUsd); + } +} diff --git a/script/deploy/facets/UpdateFraxFacet.s.sol b/script/deploy/facets/UpdateFraxFacet.s.sol new file mode 100644 index 000000000..5eb006ae2 --- /dev/null +++ b/script/deploy/facets/UpdateFraxFacet.s.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; + +import { UpdateScriptBase } from "./utils/UpdateScriptBase.sol"; + +contract DeployScript is UpdateScriptBase { + function run() + public + returns (address[] memory facets, bytes memory cutData) + { + return update("FraxFacet"); + } +} diff --git a/script/deploy/resources/deployRequirements.json b/script/deploy/resources/deployRequirements.json index fa5fe546a..dc15c3edf 100644 --- a/script/deploy/resources/deployRequirements.json +++ b/script/deploy/resources/deployRequirements.json @@ -260,6 +260,25 @@ } } }, + "FraxFacet": { + "configData": { + "_hop": { + "configFileName": "frax.json", + "keyInConfigFile": ".hop.", + "allowToDeployWithZeroAddress": "false" + }, + "_tipFeeManager": { + "configFileName": "frax.json", + "keyInConfigFile": ".tipFeeManager.", + "allowToDeployWithZeroAddress": "true" + }, + "_pathUsd": { + "configFileName": "frax.json", + "keyInConfigFile": ".pathUsd.", + "allowToDeployWithZeroAddress": "true" + } + } + }, "GenericSwapFacetV3": { "configData": { "_nativeAddress": { diff --git a/script/deploy/zksync/DeployFraxFacet.zksync.s.sol b/script/deploy/zksync/DeployFraxFacet.zksync.s.sol new file mode 100644 index 000000000..e21f5ea77 --- /dev/null +++ b/script/deploy/zksync/DeployFraxFacet.zksync.s.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; + +import { DeployScriptBase } from "./utils/DeployScriptBase.sol"; +import { stdJson } from "forge-std/Script.sol"; +import { FraxFacet } from "lifi/Facets/FraxFacet.sol"; +import { IFraxHopV2 } from "lifi/Interfaces/IFraxHopV2.sol"; + +contract DeployScript is DeployScriptBase { + using stdJson for string; + + constructor() DeployScriptBase("FraxFacet") {} + + function run() + public + returns (FraxFacet deployed, bytes memory constructorArgs) + { + constructorArgs = getConstructorArgs(); + + deployed = FraxFacet(deploy(type(FraxFacet).creationCode)); + } + + function getConstructorArgs() internal override returns (bytes memory) { + string memory path = string.concat(root, "/config/frax.json"); + + address hop = _getConfigContractAddress( + path, + string.concat(".hop.", network) + ); + + // Tempo (the only chain with non-zero values) is not a zkSync-stack chain, so on every + // network deployed through this script tipFeeManager and pathUsd are zero. + address tipFeeManager = _getConfigContractAddress( + path, + string.concat(".tipFeeManager.", network), + true + ); + address pathUsd = _getConfigContractAddress( + path, + string.concat(".pathUsd.", network), + true + ); + + return abi.encode(IFraxHopV2(hop), tipFeeManager, pathUsd); + } +} diff --git a/script/deploy/zksync/UpdateFraxFacet.zksync.s.sol b/script/deploy/zksync/UpdateFraxFacet.zksync.s.sol new file mode 100644 index 000000000..5eb006ae2 --- /dev/null +++ b/script/deploy/zksync/UpdateFraxFacet.zksync.s.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; + +import { UpdateScriptBase } from "./utils/UpdateScriptBase.sol"; + +contract DeployScript is UpdateScriptBase { + function run() + public + returns (address[] memory facets, bytes memory cutData) + { + return update("FraxFacet"); + } +} diff --git a/src/Facets/FraxFacet.sol b/src/Facets/FraxFacet.sol new file mode 100644 index 000000000..90a6e382f --- /dev/null +++ b/src/Facets/FraxFacet.sol @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; + +import { ILiFi } from "../Interfaces/ILiFi.sol"; +import { IFraxHopV2, IFraxOFT, ITipFeeManager } from "../Interfaces/IFraxHopV2.sol"; +import { LibAsset, IERC20 } from "../Libraries/LibAsset.sol"; +import { LibSwap } from "../Libraries/LibSwap.sol"; +import { ReentrancyGuard } from "../Helpers/ReentrancyGuard.sol"; +import { SwapperV2 } from "../Helpers/SwapperV2.sol"; +import { Validatable } from "../Helpers/Validatable.sol"; +import { InformationMismatch, InvalidCallData, InvalidConfig } from "../Errors/GenericErrors.sol"; + +/// @title FraxFacet +/// @author LI.FI (https://li.fi) +/// @notice Provides functionality for bridging through Frax HopV2, a LayerZero V2 +/// OFT hub-and-spoke bridge (hub on Fraxtal, spokes on every other chain). +/// @dev This facet is not intended to custody user funds. Tokens are pulled, floored to +/// the OFT's dust granularity, forwarded to the HopV2 contract in the same call, and +/// any dust remainder plus excess native fee are returned to the refundRecipient within +/// the same transaction; no balance is meant to persist between calls. +/// @custom:version 1.0.0 +contract FraxFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable { + /// Storage /// + + /// @notice The Frax HopV2 contract on this chain (hub on Fraxtal, spoke elsewhere). + /// This is also the token approval target ("approvalAddress"). + IFraxHopV2 public immutable HOP; + + /// @notice The Tempo TIP20 fee manager. Non-zero ONLY on Tempo, whose LayerZero + /// EndpointV2Alt rejects native msg.value and charges the messaging fee in an + /// ERC20 gas token instead. Zero on every standard chain, which selects the + /// native-fee path. This single immutable is what lets one FraxFacet source + /// serve every chain (see docs/FraxFacet.md). + address public immutable TIP_FEE_MANAGER; + + /// @notice The default Tempo gas token (PATH_USD) used when the diamond has not opted + /// into a specific TIP20 gas token. Set only on Tempo; zero elsewhere. + address public immutable PATH_USD; + + /// Types /// + + /// @param oft The OFT messenger for the token on the source chain (its token() is the + /// ERC20 that HopV2 pulls and that must equal bridgeData.sendingAssetId) + /// @param dstEid The LayerZero endpoint ID of the destination chain + /// @param nativeFee The native LayerZero fee forwarded to HopV2 as msg.value on standard + /// chains; ignored on Tempo (fee is paid in the TIP20 gas token) + /// @param refundRecipient Address that receives pre-bridge swap leftovers, the dust + /// remainder that HopV2 does not bridge, and any excess native that HopV2 refunds + /// to the diamond mid-call. Must accept plain native transfers. + struct FraxData { + address oft; + uint32 dstEid; + uint256 nativeFee; + address refundRecipient; + } + + /// Constructor /// + + /// @notice Initializes the FraxFacet + /// @param _hop The Frax HopV2 contract on this chain + /// @param _tipFeeManager The Tempo TIP20 fee manager (address(0) on non-Tempo chains) + /// @param _pathUsd The default Tempo gas token PATH_USD (address(0) on non-Tempo chains) + constructor(IFraxHopV2 _hop, address _tipFeeManager, address _pathUsd) { + // The Tempo fee-token path needs both the fee manager and a default gas token; they + // are either both set (Tempo) or both zero (every standard chain). A half-configured + // deployment would revert deep inside the Tempo branch at bridge time. + if ( + address(_hop) == address(0) || + ((_tipFeeManager == address(0)) != (_pathUsd == address(0))) + ) { + revert InvalidConfig(); + } + HOP = _hop; + TIP_FEE_MANAGER = _tipFeeManager; + PATH_USD = _pathUsd; + } + + /// External Methods /// + + /// @notice Bridges tokens via Frax HopV2 + /// @param _bridgeData The core information needed for bridging + /// @param _fraxData Data specific to Frax HopV2 + function startBridgeTokensViaFrax( + ILiFi.BridgeData memory _bridgeData, + FraxData calldata _fraxData + ) + external + payable + nonReentrant + refundExcessNative(payable(_fraxData.refundRecipient)) + validateBridgeData(_bridgeData) + doesNotContainSourceSwaps(_bridgeData) + doesNotContainDestinationCalls(_bridgeData) + noNativeAsset(_bridgeData) + { + _validateFraxData(_fraxData); + + // On standard chains the LayerZero fee is the only native outflow and must come from + // msg.value, never from stray diamond balance. On Tempo the fee is an ERC20, so no + // native should be sent at all (EndpointV2Alt would revert on non-zero msg.value). + if (TIP_FEE_MANAGER == address(0)) { + if (_fraxData.nativeFee > msg.value) { + revert InvalidCallData(); + } + } else if (msg.value != 0) { + revert InvalidCallData(); + } + + LibAsset.depositAsset( + _bridgeData.sendingAssetId, + _bridgeData.minAmount + ); + _startBridge(_bridgeData, _fraxData); + } + + /// @notice Performs a swap before bridging via Frax HopV2 + /// @param _bridgeData The core information needed for bridging + /// @param _swapData An array of swap related data for performing swaps before bridging + /// @param _fraxData Data specific to Frax HopV2 + function swapAndStartBridgeTokensViaFrax( + ILiFi.BridgeData memory _bridgeData, + LibSwap.SwapData[] calldata _swapData, + FraxData calldata _fraxData + ) + external + payable + nonReentrant + refundExcessNative(payable(_fraxData.refundRecipient)) + containsSourceSwaps(_bridgeData) + doesNotContainDestinationCalls(_bridgeData) + validateBridgeData(_bridgeData) + noNativeAsset(_bridgeData) + { + _validateFraxData(_fraxData); + + // NOTE: nativeFee is intentionally NOT checked against msg.value here (unlike the + // non-swap path): on standard chains the fee may be funded by an ERC20->native + // pre-swap whose output the nativeReserve below keeps in the diamond. On Tempo the + // fee is an ERC20 and there is no native reserve. + _bridgeData.minAmount = _depositAndSwap( + _bridgeData.transactionId, + _bridgeData.minAmount, + _swapData, + payable(_fraxData.refundRecipient), + TIP_FEE_MANAGER == address(0) ? _fraxData.nativeFee : 0 + ); + + _startBridge(_bridgeData, _fraxData); + } + + /// Internal Methods /// + + /// @dev Validates FraxData fields shared by both entry points + /// @param _fraxData Data specific to Frax HopV2 + function _validateFraxData(FraxData calldata _fraxData) internal pure { + // refundExcessNative forwards excess native to refundRecipient; a zero address would + // only revert late, when fee drift happens to leave an excess. Fail fast instead. + // dstEid == 0 is never a valid LayerZero endpoint and would strand the transfer. + if ( + _fraxData.refundRecipient == address(0) || + _fraxData.oft == address(0) || + _fraxData.dstEid == 0 + ) { + revert InvalidCallData(); + } + } + + /// @dev Contains the business logic for bridging via Frax HopV2 + /// @param _bridgeData The core information needed for bridging + /// @param _fraxData Data specific to Frax HopV2 + function _startBridge( + ILiFi.BridgeData memory _bridgeData, + FraxData calldata _fraxData + ) internal { + // The OFT's underlying token must be exactly what we bridge; otherwise HopV2 would + // pull a different asset than the one deposited/validated here. + if (IFraxOFT(_fraxData.oft).token() != _bridgeData.sendingAssetId) { + revert InformationMismatch(); + } + + // HopV2 floors the amount to the OFT's dust granularity and only pulls the floored + // amount. Compute it up front so we approve and bridge exactly that, and can return + // the un-bridged dust to the user instead of leaving it stranded in the diamond. + uint256 flooredAmount = HOP.removeDust( + _fraxData.oft, + _bridgeData.minAmount + ); + if (flooredAmount == 0) { + revert InvalidCallData(); + } + + LibAsset.maxApproveERC20( + IERC20(_bridgeData.sendingAssetId), + address(HOP), + flooredAmount + ); + + bytes32 recipient = bytes32(uint256(uint160(_bridgeData.receiver))); + + if (TIP_FEE_MANAGER == address(0)) { + HOP.sendOFT{ value: _fraxData.nativeFee }( + _fraxData.oft, + _fraxData.dstEid, + recipient, + flooredAmount, + 0, + "" + ); + } else { + _sendViaTempo(_fraxData, recipient, flooredAmount); + } + + // Return the dust that HopV2 did not bridge to the user (never leave it in the diamond) + uint256 dust = _bridgeData.minAmount - flooredAmount; + if (dust != 0) { + LibAsset.transferAsset( + _bridgeData.sendingAssetId, + payable(_fraxData.refundRecipient), + dust + ); + } + + // Emit the amount actually bridged so downstream accounting matches what arrives on dst + _bridgeData.minAmount = flooredAmount; + + emit LiFiTransferStarted(_bridgeData); + } + + /// @dev Tempo (EndpointV2Alt) send path. Tempo rejects native msg.value and charges the + /// LayerZero fee in a TIP20 ERC20 gas token, which HopV2 pulls from the diamond + /// (msg.sender) via transferFrom. So the diamond must (1) hold that fee token and + /// (2) approve it to HopV2, in addition to the bridged-token approval above. The fee + /// token is the one the diamond opted into via TIP_FEE_MANAGER, else PATH_USD; its + /// amount is quoted in-token by HopV2.quoteStatic. msg.value is 0. + /// @dev BE-integration note (EXP-514): the fee token must be made available to the diamond + /// by the caller for the transferFrom pull to succeed - see docs/FraxFacet.md. + /// @param _fraxData Data specific to Frax HopV2 + /// @param _recipient bytes32-encoded destination recipient + /// @param _amount The dust-floored amount to bridge + function _sendViaTempo( + FraxData calldata _fraxData, + bytes32 _recipient, + uint256 _amount + ) internal { + address feeToken = ITipFeeManager(TIP_FEE_MANAGER).userTokens( + address(this) + ); + if (feeToken == address(0)) { + feeToken = PATH_USD; + } + + uint256 feeAmount = HOP.quoteStatic( + _fraxData.oft, + _fraxData.dstEid, + _recipient, + _amount, + 0, + "", + feeToken + ); + + if (feeAmount != 0) { + LibAsset.depositAsset(feeToken, feeAmount); + LibAsset.maxApproveERC20( + IERC20(feeToken), + address(HOP), + feeAmount + ); + } + + HOP.sendOFT( + _fraxData.oft, + _fraxData.dstEid, + _recipient, + _amount, + 0, + "" + ); + } +} diff --git a/src/Interfaces/IFraxHopV2.sol b/src/Interfaces/IFraxHopV2.sol new file mode 100644 index 000000000..7db841cc1 --- /dev/null +++ b/src/Interfaces/IFraxHopV2.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; + +/// @title IFraxHopV2 +/// @notice Minimal interface for Frax's HopV2 bridge (LayerZero V2 OFT hub-and-spoke) +/// @author LI.FI (https://li.fi) +/// @custom:version 1.0.0 +/// @dev Shadows FraxFinance HopV2/RemoteHopV2 (src/contracts/hop/HopV2.sol) +interface IFraxHopV2 { + /// @notice Bridges an OFT to a destination chain (plain transfer, no compose) + /// @param oft Address of the OFT messenger on the source chain + /// @param dstEid LayerZero endpoint ID of the destination chain + /// @param recipient bytes32-encoded recipient on the destination chain + /// @param amountLD Amount to send in local decimals (floored to dust internally) + /// @param dstGas Gas forwarded to the destination compose (0 for standard transfers) + /// @param data Extra compose payload (empty for standard transfers) + function sendOFT( + address oft, + uint32 dstEid, + bytes32 recipient, + uint256 amountLD, + uint128 dstGas, + bytes calldata data + ) external payable; + + /// @notice Quotes the messaging fee for a hop (native on standard chains, TIP20 on Tempo) + /// @param oft Address of the OFT messenger on the source chain + /// @param dstEid LayerZero endpoint ID of the destination chain + /// @param recipient bytes32-encoded recipient on the destination chain + /// @param amount Amount to send in local decimals (dust removed internally) + /// @param dstGas Gas forwarded to the destination compose + /// @param data Extra compose payload + /// @return fee The total fee required for the send + function quote( + address oft, + uint32 dstEid, + bytes32 recipient, + uint256 amount, + uint128 dstGas, + bytes calldata data + ) external view returns (uint256 fee); + + /// @notice Quotes the fee in a specific ERC20 gas token (Tempo EndpointV2Alt only) + /// @param oft Address of the OFT messenger on the source chain + /// @param dstEid LayerZero endpoint ID of the destination chain + /// @param recipient bytes32-encoded recipient on the destination chain + /// @param amount Amount to send in local decimals (dust removed internally) + /// @param dstGas Gas forwarded to the destination compose + /// @param data Extra compose payload + /// @param userToken The ERC20 gas token the fee will be paid in + /// @return fee The fee denominated in userToken + function quoteStatic( + address oft, + uint32 dstEid, + bytes32 recipient, + uint256 amount, + uint128 dstGas, + bytes calldata data, + address userToken + ) external view returns (uint256 fee); + + /// @notice Floors an amount to the OFT's dust-free granularity + /// @param oft Address of the OFT messenger + /// @param amountLD Amount in local decimals + /// @return flooredAmountLD The amount floored to a decimalConversionRate multiple + function removeDust( + address oft, + uint256 amountLD + ) external view returns (uint256 flooredAmountLD); +} + +/// @title ITipFeeManager +/// @notice Tempo TIP20 fee-manager precompile: resolves a caller's preferred gas token +/// @author LI.FI (https://li.fi) +/// @custom:version 1.0.0 +/// @dev Tempo precompile at 0xfeEC000000000000000000000000000000000000 +interface ITipFeeManager { + /// @notice The gas token a user has opted into (address(0) if unset → PATH_USD default) + /// @param user The account whose preferred gas token to read + /// @return token The preferred TIP20 gas token, or address(0) if none set + function userTokens(address user) external view returns (address token); +} + +/// @title IFraxOFT +/// @notice Minimal OFT surface used by FraxFacet to resolve the underlying ERC20 +/// @author LI.FI (https://li.fi) +/// @custom:version 1.0.0 +interface IFraxOFT { + /// @notice The ERC20 token that the OFT transfers on the local chain + /// @return The underlying ERC20 token address + function token() external view returns (address); +} From d6f5d5879074955219305d749e5b8fe271031909 Mon Sep 17 00:00:00 2001 From: Daniel Blaecker Date: Mon, 13 Jul 2026 13:38:24 +0700 Subject: [PATCH 2/5] feat(FraxFacet): harden swap-path guards + Tempo fee sweep; add docs + clearSigning (EXSC-382) - swap entrypoint: reject stray msg.value on Tempo; require final swap output == sendingAssetId - Tempo: return unpulled fee-token remainder to refundRecipient (invariant holds across hop upgrades) - docs/FraxFacet.md; regenerate clearSigningProposal.json (both Frax entrypoints) Co-Authored-By: Claude Opus 4.8 --- config/clearSigningProposal.json | 165 +++++++++++++++++++++++++- docs/FraxFacet.md | 191 +++++++++++++++++++++++++++++++ src/Facets/FraxFacet.sol | 37 ++++++ 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 docs/FraxFacet.md diff --git a/config/clearSigningProposal.json b/config/clearSigningProposal.json index e1b790587..e436cb799 100644 --- a/config/clearSigningProposal.json +++ b/config/clearSigningProposal.json @@ -1,6 +1,6 @@ { "$note": "ERC-7730 display.formats entries for the LIFI Diamond, generated by tasks/buildClearSigningProposal.ts from Foundry artifacts. Source of truth for clear-signing UX strings. Consumed by tasks/generateLedgerClearSigning.ts during the sync workflow: entries here are merged into the registry payload pushed to ethereum/clear-signing-erc7730-registry.", - "$count": 107, + "$count": 109, "formats": { "startBridgeTokensViaAcross((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (int64 relayerFeePct, uint32 quoteTimestamp, bytes message, uint256 maxCount) _acrossData)": { "intent": "Bridge via Across", @@ -803,6 +803,73 @@ } ] }, + "startBridgeTokensViaFrax((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (address oft, uint32 dstEid, uint256 nativeFee, address refundRecipient) _fraxData)": { + "intent": "Bridge via Frax", + "interpolatedIntent": "Bridge {_bridgeData.minAmount} via Frax to chain {_bridgeData.destinationChainId} for {_bridgeData.receiver}", + "fields": [ + { + "path": "_bridgeData.minAmount", + "label": "Amount to Bridge", + "format": "tokenAmount", + "params": { + "tokenPath": "_bridgeData.sendingAssetId" + }, + "visible": "always" + }, + { + "path": "_bridgeData.destinationChainId", + "label": "Destination Chain", + "format": "raw", + "visible": "always" + }, + { + "path": "_bridgeData.receiver", + "label": "Recipient", + "format": "addressName", + "params": { + "types": [ + "eoa", + "contract" + ], + "sources": [ + "local", + "ens" + ] + }, + "visible": "always" + }, + { + "path": "_bridgeData.transactionId", + "label": "Transaction Id", + "visible": "never" + }, + { + "path": "_bridgeData.bridge", + "label": "Bridge", + "visible": "never" + }, + { + "path": "_bridgeData.integrator", + "label": "Integrator", + "visible": "never" + }, + { + "path": "_bridgeData.referrer", + "label": "Referrer", + "visible": "never" + }, + { + "path": "_bridgeData.hasSourceSwaps", + "label": "Has Source Swaps", + "visible": "never" + }, + { + "path": "_bridgeData.hasDestinationCall", + "label": "Has Destination Call", + "visible": "never" + } + ] + }, "startBridgeTokensViaGarden((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (address redeemer, address refundAddress, uint256 timelock, bytes32 secretHash, bytes32 nonEvmReceiver) _gardenData)": { "intent": "Bridge via Garden", "interpolatedIntent": "Bridge {_bridgeData.minAmount} via Garden to chain {_bridgeData.destinationChainId} for {_bridgeData.receiver}", @@ -3700,6 +3767,102 @@ } ] }, + "swapAndStartBridgeTokensViaFrax((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit)[] _swapData, (address oft, uint32 dstEid, uint256 nativeFee, address refundRecipient) _fraxData)": { + "intent": "Swap & Bridge via Frax", + "interpolatedIntent": "Swap then bridge {_bridgeData.minAmount} via Frax to chain {_bridgeData.destinationChainId} for {_bridgeData.receiver}", + "fields": [ + { + "path": "_swapData.[0].fromAmount", + "label": "Amount to Swap", + "format": "tokenAmount", + "params": { + "tokenPath": "_swapData.[0].sendingAssetId" + }, + "visible": "always" + }, + { + "path": "_bridgeData.minAmount", + "label": "Minimum to Bridge", + "format": "tokenAmount", + "params": { + "tokenPath": "_bridgeData.sendingAssetId" + }, + "visible": "always" + }, + { + "path": "_bridgeData.destinationChainId", + "label": "Destination Chain", + "format": "raw", + "visible": "always" + }, + { + "path": "_bridgeData.receiver", + "label": "Recipient", + "format": "addressName", + "params": { + "types": [ + "eoa", + "contract" + ], + "sources": [ + "local", + "ens" + ] + }, + "visible": "always" + }, + { + "path": "_bridgeData.transactionId", + "label": "Transaction Id", + "visible": "never" + }, + { + "path": "_bridgeData.bridge", + "label": "Bridge", + "visible": "never" + }, + { + "path": "_bridgeData.integrator", + "label": "Integrator", + "visible": "never" + }, + { + "path": "_bridgeData.referrer", + "label": "Referrer", + "visible": "never" + }, + { + "path": "_bridgeData.hasSourceSwaps", + "label": "Has Source Swaps", + "visible": "never" + }, + { + "path": "_bridgeData.hasDestinationCall", + "label": "Has Destination Call", + "visible": "never" + }, + { + "path": "_swapData.[].callData", + "label": "Swap Data Call Data", + "visible": "never" + }, + { + "path": "_swapData.[].callTo", + "label": "Swap Data Call To", + "visible": "never" + }, + { + "path": "_swapData.[].approveTo", + "label": "Swap Data Approve To", + "visible": "never" + }, + { + "path": "_swapData.[].requiresDeposit", + "label": "Swap Data Requires Deposit", + "visible": "never" + } + ] + }, "swapAndStartBridgeTokensViaGarden((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit)[] _swapData, (address redeemer, address refundAddress, uint256 timelock, bytes32 secretHash, bytes32 nonEvmReceiver) _gardenData)": { "intent": "Swap & Bridge via Garden", "interpolatedIntent": "Swap then bridge {_bridgeData.minAmount} via Garden to chain {_bridgeData.destinationChainId} for {_bridgeData.receiver}", diff --git a/docs/FraxFacet.md b/docs/FraxFacet.md new file mode 100644 index 000000000..b86d9e056 --- /dev/null +++ b/docs/FraxFacet.md @@ -0,0 +1,191 @@ +# FraxFacet + +## How it works + +The FraxFacet bridges tokens through **Frax HopV2**, a LayerZero V2 OFT +hub-and-spoke bridge. **Fraxtal** (chainId `252`) is the hub; every other chain +is a spoke, and all spokes share a single CREATE2 address for the HopV2 contract. +The facet pulls the sending asset into the Diamond and calls +`HOP.sendOFT(oft, dstEid, recipient, amountLd, dstGas=0, data="")` on the local +HopV2 contract. Routes are resolved by the hub-and-spoke topology: + +- **spoke ↔ hub** is a single hop. +- **spoke → spoke** is composed as two hops, relayed via Fraxtal. + +Supported tokens are the Frax OFTs — `frxUSD`, `sfrxUSD`, `WFRAX`, `frxETH`, +`sfrxETH`, and `FPI` (all 18 decimals, 6 shared decimals). + +This facet performs **plain transfers only** — it does **not** support +destination calls (`bridgeData.hasDestinationCall` must be `false`, enforced by +the `doesNotContainDestinationCalls` modifier), and `dstGas`/`data` are always +`0`/empty in the `sendOFT` call. + +```mermaid +graph LR; + D{LiFiDiamond}-- DELEGATECALL -->A[FraxFacet] + A -- CALL --> B(Frax HopV2) +``` + +## Public Methods + +- `function startBridgeTokensViaFrax(BridgeData memory _bridgeData, FraxData calldata _fraxData)` + - Simply bridges tokens using Frax HopV2 without performing any swaps +- `function swapAndStartBridgeTokensViaFrax(BridgeData memory _bridgeData, LibSwap.SwapData[] calldata _swapData, FraxData calldata _fraxData)` + - Performs swap(s) before bridging tokens using Frax HopV2 + +## Frax Specific Parameters + +The methods listed above take a variable labeled `_fraxData`. This data is +specific to Frax HopV2 and is represented as the following struct type: + +```solidity +/// @param oft The OFT messenger for the token on the source chain (its token() is the +/// ERC20 that HopV2 pulls and that must equal bridgeData.sendingAssetId) +/// @param dstEid The LayerZero endpoint ID of the destination chain +/// @param nativeFee The native LayerZero fee forwarded to HopV2 as msg.value on standard +/// chains; ignored on Tempo (fee is paid in the TIP20 gas token) +/// @param refundRecipient Address that receives pre-bridge swap leftovers, the dust +/// remainder that HopV2 does not bridge, and any excess native that HopV2 refunds +/// to the diamond mid-call. Must accept plain native transfers. +struct FraxData { + address oft; + uint32 dstEid; + uint256 nativeFee; + address refundRecipient; +} +``` + +Both entrypoints validate `_fraxData` up front (reverting `InvalidCallData` on a +zero `refundRecipient`, `oft`, or `dstEid`) and verify that the OFT's underlying +token matches `bridgeData.sendingAssetId` (reverting `InformationMismatch` +otherwise). + +- `nativeFee` is the native LayerZero messaging fee. It is `0` on Tempo, where the + fee is charged in an ERC20 gas token instead (see below). +- `refundRecipient` receives everything belonging to the user that does not get + bridged: pre-bridge swap leftovers, the dust remainder HopV2 floors off, and any + excess native / fee refund that HopV2 returns to the Diamond mid-call. + +## Fund flow and safety + +- **Fee overage refund.** The LI.FI backend/API + (`https://lz-route-api.ext.frax.com/lifi/quote`) provides the `nativeFee` with a + ~10% buffer. HopV2 refunds the excess synchronously to the caller (the Diamond), + and the `refundExcessNative` modifier then forwards that overage to + `refundRecipient`. +- **Dust.** HopV2 floors the amount to the OFT's `decimalConversionRate` multiple + and only bridges the floored amount. The facet floors the amount up front (via + `HOP.removeDust`), approves and bridges exactly that, and transfers the dust + remainder to `refundRecipient` **in the same transaction** — nothing is stranded + in the Diamond. It reverts `InvalidCallData` if the floored amount is `0`. +- **No positive slippage / intent semantics.** `minAmountLD == amountLD`: HopV2 + delivers destination funds directly to the recipient. No funds ever sit in a + LI.FI contract on the destination chain. +- **Destination-chain trust assumption.** The facet **cannot validate on-chain** + whether the destination chain is a supported/registered Frax spoke. If an + unsupported `dstEid` is used, funds route through Fraxtal and can be **stranded + there** pending Frax-admin recovery. Only backend-generated calldata should be + used. `dstEid` is trusted from calldata; the analytics-only + `bridgeData.destinationChainId` is **not** cross-checked against it — the same + trust model as `AcrossFacetV4` and `PaxosTransitFacet`. +- **HopV2 trust surface.** The HopV2 contract is an **upgradeable proxy** with an + admin `recover()` function. The facet grants it an ERC20 allowance via the + standard LI.FI `maxApproveERC20` pattern (scoped to the bridged amount). This is + a trust surface worth noting. + +## Tempo (EndpointV2Alt) special case + +On **Tempo** (chainId `4217`), LayerZero uses **EndpointV2Alt**, which rejects +native `msg.value`; the messaging fee is paid in a **TIP20 ERC20 gas token**, not +native gas. + +- The facet selects this path via a constructor immutable, `TIP_FEE_MANAGER`, + which is non-zero **only on Tempo** (`PATH_USD` is the default gas token). One + FraxFacet source serves every chain — the fee mode is chosen at **deploy time** + from `config/frax.json`, not by a separate contract. +- The fee token is resolved as `TIP_FEE_MANAGER.userTokens(diamond)`, falling back + to `PATH_USD` when the Diamond has not opted into a specific token. Its amount is + quoted in-token via `HOP.quoteStatic`. The facet pulls the fee token from the + caller, approves it to HopV2, and sends with `msg.value == 0` (the non-swap + entrypoint reverts `InvalidCallData` if any native is sent on Tempo). +- **BE-integration requirement (EXP-514 — to be confirmed).** On Tempo the Diamond + must be **funded with the fee token**: the caller must approve/transfer the TIP20 + fee token to the Diamond so HopV2's `transferFrom` pull succeeds. This differs + from every other chain (where the fee is native `msg.value`) and **MUST be + confirmed with the BE integration**. + +## Swap Data + +Some methods accept a `SwapData _swapData` parameter. + +Swapping is performed by a swap-specific library that expects an array of calldata +that can be run on various DEXs (i.e. Uniswap) to make one or multiple swaps before +performing another action. + +The swap library can be found [here](../src/Libraries/LibSwap.sol). + +## LiFi Data + +Some methods accept a `BridgeData _bridgeData` parameter. + +This parameter is strictly for analytics purposes. It's used to emit events that we +can later track and index in our subgraphs and provide data on how our contracts are +being used. `BridgeData` and the events we can emit can be found +[here](../src/Interfaces/ILiFi.sol). + +## Getting Sample Calls to interact with the Facet + +In the following some sample calls are shown that allow you to retrieve a populated +transaction that can be sent to our contract via your wallet. + +All examples use our [/quote endpoint](https://apidocs.li.fi/reference/get_quote) to +retrieve a quote which contains a `transactionRequest`. This request can directly be +sent to your wallet to trigger the transaction. + +The quote result looks like the following: + +```javascript +const quoteResult = { + id: '0x...', // quote id + type: 'lifi', // the type of the quote (all lifi contract calls have the type "lifi") + tool: 'frax', // the bridge tool used for the transaction + action: {}, // information about what is going to happen + estimate: {}, // information about the estimated outcome of the call + includedSteps: [], // steps that are executed by the contract as part of this transaction, e.g. a swap step and a cross step + transactionRequest: { + // the transaction that can be sent using a wallet + data: '0x...', + to: '0x...', + value: '0x00', + from: '{YOUR_WALLET_ADDRESS}', + chainId: 252, + gasLimit: '0x...', + gasPrice: '0x...', + }, +} +``` + +A detailed explanation on how to use the /quote endpoint and how to trigger the +transaction can be found +[here](https://docs.li.fi/products/more-integration-options/li.fi-api/transferring-tokens-example). + +**Hint**: Don't forget to replace `{YOUR_WALLET_ADDRESS}` with your real wallet +address in the examples. + +### Cross Only + +To get a transaction for a transfer from 100 frxUSD on Ethereum to frxUSD on +Fraxtal you can execute the following request: + +```shell +curl 'https://li.quest/v1/quote?fromChain=ETH&fromAmount=100000000000000000000&fromToken=frxUSD&toChain=FRAX&toToken=frxUSD&slippage=0.03&allowBridges=frax&fromAddress={YOUR_WALLET_ADDRESS}' +``` + +### Swap & Cross + +To get a transaction for a transfer from 100 USDC on Ethereum to frxUSD on Fraxtal +you can execute the following request: + +```shell +curl 'https://li.quest/v1/quote?fromChain=ETH&fromAmount=100000000&fromToken=USDC&toChain=FRAX&toToken=frxUSD&slippage=0.03&allowBridges=frax&fromAddress={YOUR_WALLET_ADDRESS}' +``` diff --git a/src/Facets/FraxFacet.sol b/src/Facets/FraxFacet.sol index 90a6e382f..ff58221a4 100644 --- a/src/Facets/FraxFacet.sol +++ b/src/Facets/FraxFacet.sol @@ -133,6 +133,25 @@ contract FraxFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable { { _validateFraxData(_fraxData); + // On Tempo the fee is an ERC20 and no native is ever consumed; reject stray msg.value + // here too (symmetric with the non-swap path) so it fails fast instead of being + // silently refunded late. + if (TIP_FEE_MANAGER != address(0) && msg.value != 0) { + revert InvalidCallData(); + } + + // The final swap output must be the bridged asset: _depositAndSwap measures the + // slippage floor in the last swap's receivingAssetId, while _startBridge floors, + // approves and bridges _bridgeData.sendingAssetId. A mismatch would validate one token + // and bridge another. An empty array is left to _depositAndSwap (reverts NoSwapData). + if ( + _swapData.length != 0 && + _swapData[_swapData.length - 1].receivingAssetId != + _bridgeData.sendingAssetId + ) { + revert InformationMismatch(); + } + // NOTE: nativeFee is intentionally NOT checked against msg.value here (unlike the // non-swap path): on standard chains the fee may be funded by an ERC20->native // pre-swap whose output the nativeReserve below keeps in the diamond. On Tempo the @@ -259,6 +278,14 @@ contract FraxFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable { feeToken ); + // Snapshot before the deposit so any fee token HopV2 does not pull can be returned to + // the user without touching a pre-existing diamond balance. quoteStatic and sendOFT run + // in the same tx so the pull normally equals feeAmount, but this keeps the "diamond + // retains nothing" invariant even if HopV2's fee logic changes on a proxy upgrade. + uint256 feeTokenBalanceBefore = IERC20(feeToken).balanceOf( + address(this) + ); + if (feeAmount != 0) { LibAsset.depositAsset(feeToken, feeAmount); LibAsset.maxApproveERC20( @@ -276,5 +303,15 @@ contract FraxFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable { 0, "" ); + + uint256 unusedFee = IERC20(feeToken).balanceOf(address(this)) - + feeTokenBalanceBefore; + if (unusedFee != 0) { + LibAsset.transferAsset( + feeToken, + payable(_fraxData.refundRecipient), + unusedFee + ); + } } } From 5204fc3fb5982acc19b7a164604b9f554d45bd76 Mon Sep 17 00:00:00 2001 From: Daniel Blaecker Date: Mon, 13 Jul 2026 14:25:30 +0700 Subject: [PATCH 3/5] test(FraxFacet): fork + mock suite, 100% line coverage incl. Tempo ERC20-fee path (EXSC-382) - 29 Arbitrum-fork tests (real HopV2 + frxUSD self-OFT): dust floor+refund, excess-native refund, diamond-retains-nothing, oft/token mismatch, validation reverts, swap-output guard - 7 local Tempo tests via MockFraxHopV2Tempo (precompile TIP20 can't fork; rule 400): msg.value==0 guard, ERC20 fee pull, opted-in fee token, unused-fee sweep Co-Authored-By: Claude Opus 4.8 --- test/solidity/Facets/FraxFacet.t.sol | 746 +++++++++++++++++++++ test/solidity/utils/MockFraxHopV2Tempo.sol | 144 ++++ 2 files changed, 890 insertions(+) create mode 100644 test/solidity/Facets/FraxFacet.t.sol create mode 100644 test/solidity/utils/MockFraxHopV2Tempo.sol diff --git a/test/solidity/Facets/FraxFacet.t.sol b/test/solidity/Facets/FraxFacet.t.sol new file mode 100644 index 000000000..b82a237e1 --- /dev/null +++ b/test/solidity/Facets/FraxFacet.t.sol @@ -0,0 +1,746 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; + +import { TestBase, ILiFi, LibSwap, ERC20 } from "../utils/TestBase.sol"; +import { TestBaseFacet } from "../utils/TestBaseFacet.sol"; +import { TestWhitelistManagerBase } from "../utils/TestWhitelistManagerBase.sol"; +import { MockUniswapDEX } from "../utils/MockUniswapDEX.sol"; +import { TestToken } from "../utils/TestToken.sol"; +import { MockFraxHopV2Tempo, MockFraxOFT, MockTipFeeManager } from "../utils/MockFraxHopV2Tempo.sol"; +import { FraxFacet } from "lifi/Facets/FraxFacet.sol"; +import { IFraxHopV2 } from "lifi/Interfaces/IFraxHopV2.sol"; +import { InformationMismatch, InvalidCallData, InvalidConfig, TransferFromFailed } from "lifi/Errors/GenericErrors.sol"; + +// Stub FraxFacet: adds the whitelist-manager selectors so pre-bridge swaps can be tested +contract TestFraxFacet is FraxFacet, TestWhitelistManagerBase { + constructor( + IFraxHopV2 _hop, + address _tipFeeManager, + address _pathUsd + ) FraxFacet(_hop, _tipFeeManager, _pathUsd) {} +} + +/// @notice Fork tests for the standard (native-fee) FraxFacet path against the real +/// Frax HopV2 spoke and the frxUSD self-OFT on Arbitrum. frxUSD is a self-OFT +/// (token == oft), 18 decimals, dust granularity 1e12. +contract FraxFacetTest is TestBaseFacet { + // Frax HopV2 spoke on Arbitrum (approvalAddress + call target) + address internal constant HOP = 0x0000006D38568b00B457580b734e0076C62de659; + // frxUSD self-OFT (bridgeData.sendingAssetId == fraxData.oft == this) + address internal constant FRXUSD = + 0x80Eede496655FB9047dd39d9f418d5483ED600df; + uint32 internal constant DST_EID_FRAXTAL = 30255; // hub + uint256 internal constant DUST_RATE = 1e12; // frxUSD decimalConversionRate + + TestFraxFacet internal fraxFacet; + FraxFacet.FraxData internal fraxData; + MockUniswapDEX internal mockDex; + ERC20 internal frxUSD; + + uint256 internal defaultFrxAmount; + + function setUp() public { + // Arbitrum fork pinned to a block where frxUSD is an approvedOft on the hop + customRpcUrlForForking = "ETH_NODE_URI_ARBITRUM"; + customBlockNumberForForking = 483300000; + initTestBase(); + + frxUSD = ERC20(FRXUSD); + defaultFrxAmount = 100 * 1e18; + + // fund the sender with frxUSD (standard TransparentUpgradeableProxy ERC20) + deal(FRXUSD, USER_SENDER, 1_000_000 * 1e18); + + // deploy facet in standard (non-Tempo) configuration + fraxFacet = new TestFraxFacet(IFraxHopV2(HOP), address(0), address(0)); + + bytes4[] memory functionSelectors = new bytes4[](4); + functionSelectors[0] = fraxFacet.startBridgeTokensViaFrax.selector; + functionSelectors[1] = fraxFacet + .swapAndStartBridgeTokensViaFrax + .selector; + functionSelectors[2] = fraxFacet.addAllowedContractSelector.selector; + functionSelectors[3] = fraxFacet + .removeAllowedContractSelector + .selector; + + addFacet(diamond, address(fraxFacet), functionSelectors); + fraxFacet = TestFraxFacet(payable(address(diamond))); + + // mock DEX for pre-bridge swaps whose OUTPUT is frxUSD (the facet requires the + // final swap receivingAssetId == sendingAssetId == frxUSD); no reliable on-fork + // route to frxUSD exists at this block, so a preset-output mock is used (rule 400) + mockDex = new MockUniswapDEX(); + deal(FRXUSD, address(mockDex), 1_000_000 * 1e18); + mockDex.setSwapOutput( + defaultUSDCAmount, + ERC20(FRXUSD), + defaultFrxAmount + ); + + fraxFacet.addAllowedContractSelector( + address(mockDex), + mockDex.swapExactTokensForTokens.selector + ); + + setFacetAddressInTestBase(address(fraxFacet), "FraxFacet"); + + // default bridgeData bridges frxUSD to Fraxtal + bridgeData.bridge = "frax"; + bridgeData.sendingAssetId = FRXUSD; + bridgeData.minAmount = defaultFrxAmount; + + fraxData = FraxFacet.FraxData({ + oft: FRXUSD, + dstEid: DST_EID_FRAXTAL, + nativeFee: _quote(defaultFrxAmount), + refundRecipient: USER_REFUND + }); + + vm.label(HOP, "FraxHopV2"); + vm.label(FRXUSD, "frxUSD"); + vm.label(address(mockDex), "MockUniswapDEX"); + } + + /// @dev Live native LZ fee for bridging `amount` of frxUSD to Fraxtal + function _quote(uint256 amount) internal view returns (uint256) { + return + IFraxHopV2(HOP).quote( + FRXUSD, + DST_EID_FRAXTAL, + bytes32(uint256(uint160(USER_RECEIVER))), + amount, + 0, + "" + ); + } + + /// @dev Build a USDC -> frxUSD swap through the mock DEX (receivingAssetId == frxUSD) + function setDefaultSwapDataSingleDAItoUSDC() internal override { + delete swapData; + address[] memory path = new address[](2); + path[0] = ADDRESS_USDC; + path[1] = FRXUSD; + + swapData.push( + LibSwap.SwapData({ + callTo: address(mockDex), + approveTo: address(mockDex), + sendingAssetId: ADDRESS_USDC, + receivingAssetId: FRXUSD, + fromAmount: defaultUSDCAmount, + callData: abi.encodeWithSelector( + mockDex.swapExactTokensForTokens.selector, + defaultUSDCAmount, + defaultFrxAmount, + path, + _facetTestContractAddress, + block.timestamp + 20 minutes + ), + requiresDeposit: true + }) + ); + } + + function initiateBridgeTxWithFacet(bool isNative) internal override { + if (isNative) { + fraxFacet.startBridgeTokensViaFrax{ value: bridgeData.minAmount }( + bridgeData, + fraxData + ); + } else { + fraxFacet.startBridgeTokensViaFrax{ value: fraxData.nativeFee }( + bridgeData, + fraxData + ); + } + } + + function initiateSwapAndBridgeTxWithFacet( + bool isNative + ) internal override { + if (isNative) { + fraxFacet.swapAndStartBridgeTokensViaFrax{ + value: swapData[0].fromAmount + }(bridgeData, swapData, fraxData); + } else { + fraxFacet.swapAndStartBridgeTokensViaFrax{ + value: fraxData.nativeFee + }(bridgeData, swapData, fraxData); + } + } + + // FraxFacet supports only ERC20 OFTs; no native path + function testBase_CanBridgeNativeTokens() public override {} + + // FraxFacet supports only ERC20 OFTs; no native path + function testBase_CanSwapAndBridgeNativeTokens() public override {} + + function testBase_CanBridgeTokens() public override { + uint256 senderBefore = frxUSD.balanceOf(USER_SENDER); + + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(bridgeData); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + + // diamond custodies nothing after the bridge + assertEq(frxUSD.balanceOf(address(diamond)), 0); + assertEq(address(diamond).balance, 0); + assertEq( + frxUSD.balanceOf(USER_SENDER), + senderBefore - defaultFrxAmount + ); + } + + function testBase_CanBridgeTokens_fuzzed(uint256 amount) public override { + vm.assume(amount > 0 && amount < 100_000); + amount = amount * 1e18; // multiple of the 1e12 dust rate -> no dust + + bridgeData.minAmount = amount; + fraxData.nativeFee = _quote(amount); + + uint256 senderBefore = frxUSD.balanceOf(USER_SENDER); + + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, amount); + + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(bridgeData); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + + assertEq(frxUSD.balanceOf(address(diamond)), 0); + assertEq(address(diamond).balance, 0); + assertEq(frxUSD.balanceOf(USER_SENDER), senderBefore - amount); + } + + function testBase_CanSwapAndBridgeTokens() public override { + vm.startPrank(USER_SENDER); + + bridgeData.hasSourceSwaps = true; + setDefaultSwapDataSingleDAItoUSDC(); + + // output of the swap becomes the bridged amount (clean multiple of dust rate) + bridgeData.minAmount = defaultFrxAmount; + + uint256 senderUsdcBefore = usdc.balanceOf(USER_SENDER); + usdc.approve(_facetTestContractAddress, swapData[0].fromAmount); + + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit AssetSwapped( + bridgeData.transactionId, + address(mockDex), + ADDRESS_USDC, + FRXUSD, + swapData[0].fromAmount, + defaultFrxAmount, + block.timestamp + ); + + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(bridgeData); + + initiateSwapAndBridgeTxWithFacet(false); + vm.stopPrank(); + + // nothing stranded in the diamond (neither the swapped-in nor the bridged asset) + assertEq(frxUSD.balanceOf(address(diamond)), 0); + assertEq(usdc.balanceOf(address(diamond)), 0); + assertEq(address(diamond).balance, 0); + assertEq( + usdc.balanceOf(USER_SENDER), + senderUsdcBefore - defaultUSDCAmount + ); + } + + function testBase_Revert_CallerHasInsufficientFunds() public override { + vm.startPrank(USER_SENDER); + + frxUSD.approve(_facetTestContractAddress, defaultFrxAmount); + // drain the sender's frxUSD so depositAsset's transferFrom fails + frxUSD.transfer(USER_RECEIVER, frxUSD.balanceOf(USER_SENDER)); + + vm.expectRevert(TransferFromFailed.selector); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + /// Additional money-flow and validation tests /// + + function test_DustIsFlooredAndRefundedToRefundRecipient() public { + // minAmount = 100e18 + 1e6; 1e6 < 1e12 dust rate -> floored to 100e18, 1e6 dust + uint256 dust = 1e6; + bridgeData.minAmount = defaultFrxAmount + dust; + fraxData.nativeFee = _quote(defaultFrxAmount); + + uint256 senderBefore = frxUSD.balanceOf(USER_SENDER); + uint256 refundBefore = frxUSD.balanceOf(USER_REFUND); + + // the emitted (and bridged) amount is the floored amount, not minAmount + ILiFi.BridgeData memory emittedData = bridgeData; + emittedData.minAmount = defaultFrxAmount; + + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit LiFiTransferStarted(emittedData); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + + // sender debited the full amount; dust returned to refundRecipient; diamond holds 0 + assertEq( + frxUSD.balanceOf(USER_SENDER), + senderBefore - defaultFrxAmount - dust + ); + assertEq(frxUSD.balanceOf(USER_REFUND), refundBefore + dust); + assertEq(frxUSD.balanceOf(address(diamond)), 0); + assertEq(address(diamond).balance, 0); + } + + function testRevert_WhenFlooredAmountIsZero() public { + // 5e11 < 1e12 -> the whole amount is dust -> flooredAmount == 0 + bridgeData.minAmount = 5e11; + fraxData.nativeFee = _quote(defaultFrxAmount); + + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidCallData.selector); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function test_ExcessNativeIsRefundedToRefundRecipient() public { + uint256 excess = 0.01 ether; + uint256 refundBefore = USER_REFUND.balance; + + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, bridgeData.minAmount); + + fraxFacet.startBridgeTokensViaFrax{ + value: fraxData.nativeFee + excess + }(bridgeData, fraxData); + vm.stopPrank(); + + // the excess (and any LZ overpayment) is forwarded to the refundRecipient + assertGe(USER_REFUND.balance, refundBefore + excess); + assertEq(address(diamond).balance, 0); + } + + function testRevert_WhenNativeFeeExceedsMsgValue() public { + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidCallData.selector); + + fraxFacet.startBridgeTokensViaFrax{ value: fraxData.nativeFee - 1 }( + bridgeData, + fraxData + ); + vm.stopPrank(); + } + + function testRevert_WhenOftTokenMismatchesSendingAsset() public { + // an OFT whose token() != sendingAssetId must be rejected + MockFraxOFT wrongOft = new MockFraxOFT(ADDRESS_USDC); + fraxData.oft = address(wrongOft); + + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InformationMismatch.selector); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function testRevert_WhenBridgeWithZeroRefundRecipient() public { + fraxData.refundRecipient = address(0); + + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidCallData.selector); + + fraxFacet.startBridgeTokensViaFrax{ value: fraxData.nativeFee }( + bridgeData, + fraxData + ); + vm.stopPrank(); + } + + function testRevert_WhenBridgeWithZeroOft() public { + fraxData.oft = address(0); + + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidCallData.selector); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function testRevert_WhenBridgeWithZeroDstEid() public { + fraxData.dstEid = 0; + + vm.startPrank(USER_SENDER); + frxUSD.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidCallData.selector); + + initiateBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function testRevert_WhenSwapAndBridgeWithZeroRefundRecipient() public { + fraxData.refundRecipient = address(0); + + vm.startPrank(USER_SENDER); + bridgeData.hasSourceSwaps = true; + setDefaultSwapDataSingleDAItoUSDC(); + usdc.approve(_facetTestContractAddress, swapData[0].fromAmount); + + vm.expectRevert(InvalidCallData.selector); + + initiateSwapAndBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function testRevert_WhenSwapOutputAssetMismatchesBridgeAsset() public { + // the final swap output must equal the bridged sendingAssetId (frxUSD) + vm.startPrank(USER_SENDER); + bridgeData.hasSourceSwaps = true; + setDefaultSwapDataSingleDAItoUSDC(); + // break the invariant: last swap now outputs USDC, not frxUSD + swapData[0].receivingAssetId = ADDRESS_USDC; + usdc.approve(_facetTestContractAddress, swapData[0].fromAmount); + + vm.expectRevert(InformationMismatch.selector); + + initiateSwapAndBridgeTxWithFacet(false); + vm.stopPrank(); + } + + function test_WillStoreConstructorParametersCorrectly() public { + fraxFacet = new TestFraxFacet(IFraxHopV2(HOP), address(0), address(0)); + assertEq(address(fraxFacet.HOP()), HOP); + assertEq(fraxFacet.TIP_FEE_MANAGER(), address(0)); + assertEq(fraxFacet.PATH_USD(), address(0)); + } + + function testRevert_WhenConstructedWithZeroHop() public { + vm.expectRevert(InvalidConfig.selector); + new TestFraxFacet(IFraxHopV2(address(0)), address(0), address(0)); + } + + function testRevert_WhenConstructedWithHalfTempoConfig() public { + // tipFeeManager set but pathUsd zero -> half-configured Tempo deployment + vm.expectRevert(InvalidConfig.selector); + new TestFraxFacet( + IFraxHopV2(HOP), + 0xfeEC000000000000000000000000000000000000, + address(0) + ); + } + + function testRevert_WhenConstructedWithOtherHalfTempoConfig() public { + // pathUsd set but tipFeeManager zero -> the mirror half-config + vm.expectRevert(InvalidConfig.selector); + new TestFraxFacet( + IFraxHopV2(HOP), + address(0), + 0x20C0000000000000000000000000000000000000 + ); + } +} + +/// @notice Local (non-fork) tests for the Tempo (ERC20-fee) FraxFacet branch. +/// @dev Rationale (rule 400): the real Tempo chain relies on precompile-backed TIP20 +/// tokens and a LayerZero EndpointV2Alt implemented at the node level, which a +/// Foundry fork cannot execute (deal/transferFrom/sendOFT all fail on codesize-1 +/// precompiles). A mock hop + fee manager reproduce the exact Tempo money flow the +/// facet depends on: ERC20 fee quote, msg.value==0 requirement, and a transferFrom +/// pull of both the bridged token and the fee token from the diamond. +contract FraxFacetTempoTest is TestBase { + TestFraxFacet internal fraxFacet; + FraxFacet.FraxData internal fraxData; + + MockFraxHopV2Tempo internal hop; + MockFraxOFT internal oft; + MockTipFeeManager internal tipFeeManager; + TestToken internal bridgedToken; // frxUSD-style, 18 decimals + TestToken internal pathUsd; // default Tempo gas token, 6 decimals + + uint256 internal constant BRIDGE_AMOUNT = 100 * 1e18; + uint256 internal constant FEE_QUOTE = 5 * 1e6; + uint256 internal constant DUST_RATE = 1e12; + + function setUp() public { + initTestBaseLocal(); + + bridgedToken = new TestToken("frxUSD", "frxUSD", 18); + pathUsd = new TestToken("Path USD", "PUSD", 6); + + oft = new MockFraxOFT(address(bridgedToken)); + tipFeeManager = new MockTipFeeManager(); + hop = new MockFraxHopV2Tempo(DUST_RATE); + hop.setFeeConfig(address(pathUsd), FEE_QUOTE, FEE_QUOTE); + + fraxFacet = new TestFraxFacet( + IFraxHopV2(address(hop)), + address(tipFeeManager), + address(pathUsd) + ); + + bytes4[] memory functionSelectors = new bytes4[](4); + functionSelectors[0] = fraxFacet.startBridgeTokensViaFrax.selector; + functionSelectors[1] = fraxFacet + .swapAndStartBridgeTokensViaFrax + .selector; + functionSelectors[2] = fraxFacet.addAllowedContractSelector.selector; + functionSelectors[3] = fraxFacet + .removeAllowedContractSelector + .selector; + + addFacet(diamond, address(fraxFacet), functionSelectors); + fraxFacet = TestFraxFacet(payable(address(diamond))); + + // fund the sender with the bridged token and the fee token + bridgedToken.mint(USER_SENDER, 1_000_000 * 1e18); + pathUsd.mint(USER_SENDER, 1_000_000 * 1e6); + + bridgeData = ILiFi.BridgeData({ + transactionId: "tempoTx", + bridge: "frax", + integrator: "", + referrer: address(0), + sendingAssetId: address(bridgedToken), + receiver: USER_RECEIVER, + minAmount: BRIDGE_AMOUNT, + destinationChainId: 137, + hasSourceSwaps: false, + hasDestinationCall: false + }); + + fraxData = FraxFacet.FraxData({ + oft: address(oft), + dstEid: 30255, + nativeFee: 0, + refundRecipient: USER_REFUND + }); + + vm.label(address(hop), "MockFraxHopV2Tempo"); + vm.label(address(bridgedToken), "bridgedToken"); + vm.label(address(pathUsd), "PATH_USD"); + } + + function test_Tempo_BridgeChargesFeeInErc20AndRetainsNothing() public { + uint256 senderBridgedBefore = bridgedToken.balanceOf(USER_SENDER); + uint256 senderFeeBefore = pathUsd.balanceOf(USER_SENDER); + + vm.startPrank(USER_SENDER); + bridgedToken.approve(address(fraxFacet), BRIDGE_AMOUNT); + pathUsd.approve(address(fraxFacet), FEE_QUOTE); + + vm.expectEmit(true, true, true, true, address(fraxFacet)); + emit LiFiTransferStarted(bridgeData); + + // Tempo path: no native is ever sent + fraxFacet.startBridgeTokensViaFrax{ value: 0 }(bridgeData, fraxData); + vm.stopPrank(); + + // the hop pulled the bridged token and the ERC20 fee + assertEq(bridgedToken.balanceOf(address(hop)), BRIDGE_AMOUNT); + assertEq(pathUsd.balanceOf(address(hop)), FEE_QUOTE); + // sender debited both; diamond retains neither + assertEq( + bridgedToken.balanceOf(USER_SENDER), + senderBridgedBefore - BRIDGE_AMOUNT + ); + assertEq(pathUsd.balanceOf(USER_SENDER), senderFeeBefore - FEE_QUOTE); + assertEq(bridgedToken.balanceOf(address(fraxFacet)), 0); + assertEq(pathUsd.balanceOf(address(fraxFacet)), 0); + } + + function testRevert_Tempo_WhenNativeSent() public { + vm.deal(USER_SENDER, 1 ether); + vm.startPrank(USER_SENDER); + bridgedToken.approve(address(fraxFacet), BRIDGE_AMOUNT); + pathUsd.approve(address(fraxFacet), FEE_QUOTE); + + vm.expectRevert(InvalidCallData.selector); + + fraxFacet.startBridgeTokensViaFrax{ value: 1 }(bridgeData, fraxData); + vm.stopPrank(); + } + + function test_Tempo_UnusedFeeReturnedToRefundRecipient() public { + // hop pulls only 4e6 of the 5e6 quoted fee -> 1e6 unused stays in the diamond + // and must be swept to the refundRecipient + uint256 feePull = 4 * 1e6; + hop.setFeeConfig(address(pathUsd), FEE_QUOTE, feePull); + + uint256 refundBefore = pathUsd.balanceOf(USER_REFUND); + + vm.startPrank(USER_SENDER); + bridgedToken.approve(address(fraxFacet), BRIDGE_AMOUNT); + pathUsd.approve(address(fraxFacet), FEE_QUOTE); + + fraxFacet.startBridgeTokensViaFrax{ value: 0 }(bridgeData, fraxData); + vm.stopPrank(); + + assertEq( + pathUsd.balanceOf(USER_REFUND), + refundBefore + (FEE_QUOTE - feePull) + ); + assertEq(pathUsd.balanceOf(address(fraxFacet)), 0); + } + + function test_Tempo_ZeroFeeQuoteSkipsFeeDeposit() public { + // a zero fee quote must skip the fee deposit/approve entirely + hop.setFeeConfig(address(pathUsd), 0, 0); + + uint256 senderFeeBefore = pathUsd.balanceOf(USER_SENDER); + + vm.startPrank(USER_SENDER); + bridgedToken.approve(address(fraxFacet), BRIDGE_AMOUNT); + + fraxFacet.startBridgeTokensViaFrax{ value: 0 }(bridgeData, fraxData); + vm.stopPrank(); + + // bridged token still moved; no fee token pulled; diamond retains nothing + assertEq(bridgedToken.balanceOf(address(hop)), BRIDGE_AMOUNT); + assertEq(pathUsd.balanceOf(USER_SENDER), senderFeeBefore); + assertEq(pathUsd.balanceOf(address(hop)), 0); + assertEq(bridgedToken.balanceOf(address(fraxFacet)), 0); + assertEq(pathUsd.balanceOf(address(fraxFacet)), 0); + } + + function test_Tempo_UsesOptedInFeeToken() public { + // the diamond has opted into a specific TIP20 gas token via the fee manager + TestToken feeToken2 = new TestToken("Fee Token 2", "FEE2", 6); + feeToken2.mint(USER_SENDER, 1_000_000 * 1e6); + tipFeeManager.setUserToken(address(fraxFacet), address(feeToken2)); + hop.setFeeConfig(address(feeToken2), FEE_QUOTE, FEE_QUOTE); + + vm.startPrank(USER_SENDER); + bridgedToken.approve(address(fraxFacet), BRIDGE_AMOUNT); + feeToken2.approve(address(fraxFacet), FEE_QUOTE); + + fraxFacet.startBridgeTokensViaFrax{ value: 0 }(bridgeData, fraxData); + vm.stopPrank(); + + // fee charged in the opted-in token, not PATH_USD + assertEq(feeToken2.balanceOf(address(hop)), FEE_QUOTE); + assertEq(pathUsd.balanceOf(address(hop)), 0); + assertEq(feeToken2.balanceOf(address(fraxFacet)), 0); + } + + function test_Tempo_SwapAndBridgeChargesErc20FeeAndRetainsNothing() + public + { + // pre-bridge swap (USDC -> bridged token) followed by a Tempo (ERC20-fee) bridge + MockUniswapDEX mockDex = new MockUniswapDEX(); + bridgedToken.mint(address(mockDex), BRIDGE_AMOUNT); + mockDex.setSwapOutput( + defaultUSDCAmount, + ERC20(address(bridgedToken)), + BRIDGE_AMOUNT + ); + fraxFacet.addAllowedContractSelector( + address(mockDex), + mockDex.swapExactTokensForTokens.selector + ); + + address[] memory path = new address[](2); + path[0] = ADDRESS_USDC; + path[1] = address(bridgedToken); + + delete swapData; + swapData.push( + LibSwap.SwapData({ + callTo: address(mockDex), + approveTo: address(mockDex), + sendingAssetId: ADDRESS_USDC, + receivingAssetId: address(bridgedToken), + fromAmount: defaultUSDCAmount, + callData: abi.encodeWithSelector( + mockDex.swapExactTokensForTokens.selector, + defaultUSDCAmount, + BRIDGE_AMOUNT, + path, + address(fraxFacet), + block.timestamp + 20 minutes + ), + requiresDeposit: true + }) + ); + + bridgeData.hasSourceSwaps = true; + + vm.startPrank(USER_SENDER); + usdc.approve(address(fraxFacet), defaultUSDCAmount); + pathUsd.approve(address(fraxFacet), FEE_QUOTE); + + vm.expectEmit(true, true, true, true, address(fraxFacet)); + emit LiFiTransferStarted(bridgeData); + + fraxFacet.swapAndStartBridgeTokensViaFrax{ value: 0 }( + bridgeData, + swapData, + fraxData + ); + vm.stopPrank(); + + // bridged token + ERC20 fee pulled by the hop; diamond keeps nothing + assertEq(bridgedToken.balanceOf(address(hop)), BRIDGE_AMOUNT); + assertEq(pathUsd.balanceOf(address(hop)), FEE_QUOTE); + assertEq(bridgedToken.balanceOf(address(fraxFacet)), 0); + assertEq(usdc.balanceOf(address(fraxFacet)), 0); + assertEq(pathUsd.balanceOf(address(fraxFacet)), 0); + } + + function testRevert_Tempo_SwapAndBridgeWhenNativeSent() public { + // symmetric msg.value guard on the swap entry point + vm.deal(USER_SENDER, 1 ether); + + delete swapData; + address[] memory path = new address[](2); + path[0] = address(pathUsd); + path[1] = address(bridgedToken); + swapData.push( + LibSwap.SwapData({ + callTo: address(hop), + approveTo: address(hop), + sendingAssetId: address(pathUsd), + receivingAssetId: address(bridgedToken), + fromAmount: 1, + callData: abi.encodeWithSelector(bytes4(0x12345678)), + requiresDeposit: true + }) + ); + + vm.startPrank(USER_SENDER); + bridgeData.hasSourceSwaps = true; + + vm.expectRevert(InvalidCallData.selector); + + fraxFacet.swapAndStartBridgeTokensViaFrax{ value: 1 }( + bridgeData, + swapData, + fraxData + ); + vm.stopPrank(); + } +} diff --git a/test/solidity/utils/MockFraxHopV2Tempo.sol b/test/solidity/utils/MockFraxHopV2Tempo.sol new file mode 100644 index 000000000..8741f7c85 --- /dev/null +++ b/test/solidity/utils/MockFraxHopV2Tempo.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; + +import { IFraxHopV2, IFraxOFT, ITipFeeManager } from "lifi/Interfaces/IFraxHopV2.sol"; + +/// @title IERC20Like +/// @notice Minimal ERC20 surface used by the Tempo mock hop to pull tokens +interface IERC20Like { + function transferFrom( + address from, + address to, + uint256 amount + ) external returns (bool); + + function balanceOf(address account) external view returns (uint256); +} + +/// @title MockFraxOFT +/// @notice Mock OFT messenger exposing a configurable underlying token() +/// @dev Used both to simulate a Tempo OFT adapter and to force the +/// FraxFacet oft.token() != sendingAssetId mismatch on a live fork +contract MockFraxOFT is IFraxOFT { + address public token; + + constructor(address _token) { + token = _token; + } +} + +/// @title MockTipFeeManager +/// @notice Mock of the Tempo TIP20 fee manager precompile +contract MockTipFeeManager is ITipFeeManager { + mapping(address => address) internal _userTokens; + + function setUserToken(address user, address token) external { + _userTokens[user] = token; + } + + function userTokens(address user) external view returns (address) { + return _userTokens[user]; + } +} + +/// @title MockFraxHopV2Tempo +/// @notice Local mock of Frax HopV2 exercising the FraxFacet Tempo (ERC20 fee) branch. +/// @dev Rationale (rule 400): the real Tempo chain relies on precompile-backed TIP20 +/// tokens and a LayerZero EndpointV2Alt whose logic is implemented at the node +/// level (codesize 1), so a Foundry fork cannot reproduce sendOFT / deal / the fee +/// pull on those addresses. This mock replicates the exact on-chain behaviour the +/// FraxFacet Tempo path depends on: dust flooring, an ERC20 fee quote via +/// quoteStatic, a msg.value==0 requirement, and a transferFrom pull of both the +/// bridged token and the fee token from the caller (the diamond). +contract MockFraxHopV2Tempo is IFraxHopV2 { + error NativeValueNotAllowed(); + error BridgedTokenPullFailed(); + error FeeTokenPullFailed(); + + uint256 internal immutable DUST_RATE; + + address public feeToken; + uint256 public feeQuote; + uint256 public feePull; + + uint256 public lastAmountPulled; + + constructor(uint256 _dustRate) { + DUST_RATE = _dustRate; + } + + /// @notice Configures the fee token, the amount reported by quoteStatic, and the + /// amount actually pulled by sendOFT (feePull < feeQuote leaves unused fee + /// in the diamond so the facet's refund sweep can be exercised). + function setFeeConfig( + address _feeToken, + uint256 _feeQuote, + uint256 _feePull + ) external { + feeToken = _feeToken; + feeQuote = _feeQuote; + feePull = _feePull; + } + + function removeDust( + address, + uint256 amountLD + ) external view returns (uint256) { + return (amountLD / DUST_RATE) * DUST_RATE; + } + + function quote( + address, + uint32, + bytes32, + uint256, + uint128, + bytes calldata + ) external view returns (uint256) { + return feeQuote; + } + + function quoteStatic( + address, + uint32, + bytes32, + uint256, + uint128, + bytes calldata, + address + ) external view returns (uint256) { + return feeQuote; + } + + function sendOFT( + address oft, + uint32, + bytes32, + uint256 amountLD, + uint128, + bytes calldata + ) external payable { + // Tempo's EndpointV2Alt rejects native msg.value + if (msg.value != 0) revert NativeValueNotAllowed(); + + address token = IFraxOFT(oft).token(); + if ( + !IERC20Like(token).transferFrom( + msg.sender, + address(this), + amountLD + ) + ) revert BridgedTokenPullFailed(); + lastAmountPulled = amountLD; + + if (feePull != 0) { + if ( + !IERC20Like(feeToken).transferFrom( + msg.sender, + address(this), + feePull + ) + ) revert FeeTokenPullFailed(); + } + } +} From 42bfa17abcd06d249d4934e01a05ca420cc4ab29 Mon Sep 17 00:00:00 2001 From: Daniel Blaecker Date: Mon, 13 Jul 2026 14:34:05 +0700 Subject: [PATCH 4/5] feat(FraxFacet): add demo script; flag unverified API slug + fix Tempo guard wording in docs (EXSC-382) Co-Authored-By: Claude Opus 4.8 --- docs/FraxFacet.md | 8 +- script/demoScripts/demoFrax.ts | 371 +++++++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 script/demoScripts/demoFrax.ts diff --git a/docs/FraxFacet.md b/docs/FraxFacet.md index b86d9e056..1ef2c9287 100644 --- a/docs/FraxFacet.md +++ b/docs/FraxFacet.md @@ -106,8 +106,8 @@ native gas. - The fee token is resolved as `TIP_FEE_MANAGER.userTokens(diamond)`, falling back to `PATH_USD` when the Diamond has not opted into a specific token. Its amount is quoted in-token via `HOP.quoteStatic`. The facet pulls the fee token from the - caller, approves it to HopV2, and sends with `msg.value == 0` (the non-swap - entrypoint reverts `InvalidCallData` if any native is sent on Tempo). + caller, approves it to HopV2, and sends with `msg.value == 0` (both entrypoints + revert `InvalidCallData` if any native is sent on Tempo). - **BE-integration requirement (EXP-514 — to be confirmed).** On Tempo the Diamond must be **funded with the fee token**: the caller must approve/transfer the TIP20 fee token to the Diamond so HopV2's `transferFrom` pull succeeds. This differs @@ -169,6 +169,10 @@ A detailed explanation on how to use the /quote endpoint and how to trigger the transaction can be found [here](https://docs.li.fi/products/more-integration-options/li.fi-api/transferring-tokens-example). +> **Note:** the `tool: 'frax'` / `allowBridges=frax` slug and token symbols in the +> examples below are placeholders pending the BE integration (EXP-514) — confirm the +> actual bridge slug and supported symbols with the backend team before relying on them. + **Hint**: Don't forget to replace `{YOUR_WALLET_ADDRESS}` with your real wallet address in the examples. diff --git a/script/demoScripts/demoFrax.ts b/script/demoScripts/demoFrax.ts new file mode 100644 index 000000000..09009f381 --- /dev/null +++ b/script/demoScripts/demoFrax.ts @@ -0,0 +1,371 @@ +/** + * Demo: FraxFacet against the LI.FI staging Diamond (EXSC-382). + * + * Fetches a bridge quote from the public Frax LayerZero-route API, maps the + * response into LI.FI BridgeData + FraxData, and calls + * FraxFacet.startBridgeTokensViaFrax on the arbitrum staging Diamond. It + * demonstrates the two hub-and-spoke shapes from arbitrum (a spoke): + * 1. arbitrum -> fraxtal (1 hop, direct to the Fraxtal hub, dstEid 30255) + * 2. arbitrum -> base (2 hops, composed via Fraxtal, dstEid 30184) + * + * The whole point of the demo is to show, explicitly, how the Frax API response + * maps onto the facet calldata (approvalAddress -> the HopV2 target; + * transactionRequest.value -> FraxData.nativeFee; meta.sendOftCall.args -> + * FraxData.oft/dstEid). + * + * PREREQUISITES (this will only succeed once these hold — it is meant to be run + * AFTER the staging deployment, not against mainnet before funds exist): + * - FraxFacet deployed and registered on the arbitrum staging Diamond + * - the staging dev wallet funded with frxUSD (the bridged amount) and a little + * ETH to cover the native LayerZero messaging fee + * + * NOTE: Tempo-as-source is intentionally NOT demoed here. On Tempo the LayerZero + * fee is paid in a TIP20 ERC20 gas token (not native msg.value) and the flow is + * different (the Diamond must hold/approve the fee token) — see docs/FraxFacet.md + * ("Tempo (EndpointV2Alt) special case"). + * + * Run: bunx tsx script/demoScripts/demoFrax.ts --route arbitrum-fraxtal + * bunx tsx script/demoScripts/demoFrax.ts --route both + */ +import { randomBytes } from 'crypto' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +import { defineCommand, runMain } from 'citty' +import { consola } from 'consola' +import { config } from 'dotenv' +import { + erc20Abi, + getAddress, + parseUnits, + zeroAddress, + type Abi, + type Address, + type Hex, +} from 'viem' + +import type { SupportedChain } from '../common/types' +import { EnvironmentEnum } from '../common/types' +import { fetchWithTimeout } from '../utils/fetchWithTimeout' + +import { + createContractObject, + ensureAllowance, + ensureBalance, + executeTransaction, + setupEnvironment, +} from './utils/demoScriptHelpers' + +config() + +// Read the Forge artifact at runtime rather than statically importing it: the +// validate-scripts CI job runs without a prior `forge build`, so a static +// `import ... from '../../out/...json'` fails TS2307 there even though a full +// local `forge build` produces the file. +interface IForgeArtifact { + abi: Abi +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function readIForgeArtifact( + contractFile: string, + contractName: string +): IForgeArtifact { + const artifactPath = path.join( + __dirname, + '../../out', + contractFile, + `${contractName}.json` + ) + return JSON.parse(fs.readFileSync(artifactPath, 'utf8')) as IForgeArtifact +} + +const FRAX_FACET_ABI = readIForgeArtifact('FraxFacet.sol', 'FraxFacet').abi + +// Public Frax LayerZero-route API (no auth). It already bakes a ~10% buffer into +// transactionRequest.value; HopV2 refunds the overage to the refundRecipient. +const FRAX_API_BASE = 'https://lz-route-api.ext.frax.com' + +// frxUSD is a self-OFT on arbitrum: the OFT messenger address IS the ERC20 token +// (18 decimals, decimalConversionRate 1e12). See docs/FraxFacet.md. +const FRXUSD_ARBITRUM: Address = getAddress( + '0x80Eede496655FB9047dd39d9f418d5483ED600df' +) + +// The HopV2 contract (== the quote's approvalAddress) shares one CREATE2 address +// across all spokes; the Fraxtal hub has its own. Used here only to sanity-check +// the API response, never sent as calldata (the facet targets its own immutable). +const FRAX_HOP_SPOKE: Address = getAddress( + '0x0000006D38568b00B457580b734e0076C62de659' +) +const FRAX_HOP_HUB_FRAXTAL: Address = getAddress( + '0x00000000e18aFc20Afe54d4B2C8688bB60c06B36' +) + +// 100 frxUSD (18 decimals) — small demo amount. +const DEMO_AMOUNT = parseUnits('100', 18) + +interface IFraxRoute { + /** Network key for setupEnvironment / deployments lookup (source chain). */ + srcChain: SupportedChain + /** Chain name the Frax API expects in the fromChain/toChain query params. */ + apiFromChain: string + apiToChain: string + /** LayerZero endpoint ID of the destination chain (goes into FraxData.dstEid). */ + dstEid: number + /** Analytics-only destination chainId for BridgeData.destinationChainId. */ + destinationChainId: number +} + +// LayerZero EIDs: fraxtal 30255, base 30184, arbitrum 30110. +const ROUTES: Record = { + 'arbitrum-fraxtal': { + srcChain: 'arbitrum', + apiFromChain: 'arbitrum', + apiToChain: 'fraxtal', + dstEid: 30255, + destinationChainId: 252, + }, + 'arbitrum-base': { + srcChain: 'arbitrum', + apiFromChain: 'arbitrum', + apiToChain: 'base', + dstEid: 30184, + destinationChainId: 8453, + }, +} + +// Shape of the fields this demo consumes from the Frax /lifi/quote response. +// approvalAddress is the HopV2 token-approval target; transactionRequest.value is +// the native LZ fee (with the API's built-in buffer); meta.sendOftCall.args holds +// the decoded sendOFT parameters. +interface IFraxQuoteResponse { + approvalAddress: string + transactionRequest: { + to: string + value: string + data: string + } + meta: { + sendOftCall: { + args: { + oft: string + dstEid: number + recipient: string + amountLd: string + dstGas: string | number + data: string + } + } + } + feeCosts?: { amount: string }[] +} + +/** + * Fetches a frxUSD->frxUSD bridge quote from the public Frax LayerZero-route API. + * + * @param route - The demo route (source/destination + API chain names) + * @param amount - Amount to bridge, in frxUSD wei (18 decimals) + * @param address - Sender and receiver address (the staging dev wallet) + * @returns The parsed quote response + * @throws If the API returns a non-2xx status or a response missing the fields the demo maps into calldata + */ +async function getFraxQuote( + route: IFraxRoute, + amount: bigint, + address: Address +): Promise { + const url = + `${FRAX_API_BASE}/lifi/quote?fromChain=${route.apiFromChain}` + + `&toChain=${route.apiToChain}&fromToken=frxUSD&toToken=frxUSD` + + `&fromAmount=${amount.toString()}&fromAddress=${address}&toAddress=${address}` + + consola.info(`Fetching Frax quote: ${url}`) + const response = await fetchWithTimeout(url) + if (!response.ok) { + const errorText = await response.text() + throw new Error( + `Frax quote request failed: ${response.status} - ${errorText}` + ) + } + + const data = (await response.json()) as IFraxQuoteResponse + if ( + !data?.transactionRequest?.value || + !data?.meta?.sendOftCall?.args?.oft || + !data?.meta?.sendOftCall?.args?.dstEid + ) + throw new Error( + `Unexpected Frax quote response (missing transactionRequest.value or meta.sendOftCall.args): ${JSON.stringify( + data + )}` + ) + + return data +} + +async function bridgeViaFrax(routeKey: string): Promise { + const route = ROUTES[routeKey] + if (!route) + throw new Error( + `Unknown route '${routeKey}'. Valid routes: ${Object.keys(ROUTES).join( + ', ' + )}` + ) + + // === Set up environment (arbitrum staging Diamond, staging dev wallet) === + const { publicClient, walletClient, walletAccount, lifiDiamondAddress } = + await setupEnvironment( + route.srcChain, + FRAX_FACET_ABI, + EnvironmentEnum.staging + ) + + if (!lifiDiamondAddress) + throw new Error( + `LiFiDiamond not found for ${route.srcChain} (staging). Is FraxFacet deployed there yet?` + ) + + const devWallet = walletAccount.address + const amount = DEMO_AMOUNT + + consola.info( + `Bridge ${amount} frxUSD from ${route.apiFromChain} --> ${route.apiToChain} via Frax` + ) + consola.info(`Connected wallet (staging dev wallet): ${devWallet}`) + consola.info(`LiFiDiamond (staging): ${lifiDiamondAddress}`) + + // === Fetch quote and map the response onto facet calldata === + const quote = await getFraxQuote(route, amount, devWallet) + + const nativeFee = BigInt(quote.transactionRequest.value) + const oft = getAddress(quote.meta.sendOftCall.args.oft) + const dstEid = quote.meta.sendOftCall.args.dstEid + const expectedHop = + route.apiToChain === 'fraxtal' ? FRAX_HOP_HUB_FRAXTAL : FRAX_HOP_SPOKE + + consola.box('Frax API response -> facet calldata mapping') + consola.info(` approvalAddress (HopV2 target): ${quote.approvalAddress}`) + consola.info( + ` transactionRequest.to: ${quote.transactionRequest.to}` + ) + consola.info( + ` transactionRequest.value: ${nativeFee} wei -> FraxData.nativeFee` + ) + consola.info(` sendOftCall.args.oft: ${oft} -> FraxData.oft`) + consola.info(` sendOftCall.args.dstEid: ${dstEid} -> FraxData.dstEid`) + consola.info( + ` sendOftCall.args.recipient: ${quote.meta.sendOftCall.args.recipient}` + ) + consola.info( + ` sendOftCall.args.amountLd: ${quote.meta.sendOftCall.args.amountLd}` + ) + if (quote.feeCosts?.length) + consola.info( + ` feeCosts[0].amount: ${quote.feeCosts[0]?.amount}` + ) + + // Sanity-check the API against our expectations. The source OFT for frxUSD on + // arbitrum is the token itself, and the facet only pulls bridgeData.sendingAssetId. + if (oft.toLowerCase() !== FRXUSD_ARBITRUM.toLowerCase()) + consola.warn( + `API oft ${oft} != expected arbitrum frxUSD ${FRXUSD_ARBITRUM} — using the API value` + ) + if (dstEid !== route.dstEid) + consola.warn( + `API dstEid ${dstEid} != expected ${route.dstEid} for ${route.apiToChain} — using the API value` + ) + if (getAddress(quote.approvalAddress) !== expectedHop) + consola.warn( + `API approvalAddress ${quote.approvalAddress} != expected HopV2 ${expectedHop}` + ) + + // === Ensure balance + allowance (the Diamond pulls frxUSD via depositAsset) === + const token = createContractObject( + FRXUSD_ARBITRUM, + erc20Abi as Abi, + publicClient, + walletClient + ) + await ensureBalance(token, devWallet, amount, publicClient) + await ensureAllowance( + token, + devWallet, + lifiDiamondAddress, + amount, + publicClient + ) + + // === Build BridgeData + FraxData === + const bridgeData = { + transactionId: `0x${randomBytes(32).toString('hex')}` as Hex, + bridge: 'frax', + integrator: 'lifi-demo', + referrer: zeroAddress, + sendingAssetId: FRXUSD_ARBITRUM, + receiver: devWallet, + minAmount: amount, + destinationChainId: BigInt(route.destinationChainId), + hasSourceSwaps: false, + hasDestinationCall: false, + } + + const fraxData = { + oft, + dstEid, + nativeFee, + refundRecipient: devWallet, + } + + consola.info('BridgeData:', bridgeData) + consola.info('FraxData:', fraxData) + + // === Start bridging (native fee forwarded as msg.value) === + const txHash = await executeTransaction( + () => + walletClient.writeContract({ + address: lifiDiamondAddress, + abi: FRAX_FACET_ABI, + functionName: 'startBridgeTokensViaFrax', + args: [bridgeData, fraxData], + value: nativeFee, + }), + `Bridge frxUSD ${route.apiFromChain}->${route.apiToChain} via Frax`, + publicClient, + true + ) + + if (txHash) { + consola.success(`Bridge tx: ${txHash}`) + consola.info( + `Track LayerZero delivery: https://layerzeroscan.com/tx/${txHash}` + ) + consola.info( + `Check Frax bridge status: GET ${FRAX_API_BASE}/lifi/transaction/${txHash}` + ) + } +} + +const command = defineCommand({ + meta: { + name: 'demoFrax', + description: 'Demo bridging frxUSD from arbitrum via FraxFacet (staging)', + }, + args: { + route: { + type: 'string', + default: 'arbitrum-fraxtal', + description: + "Route to demo: 'arbitrum-fraxtal', 'arbitrum-base', or 'both'", + }, + }, + async run({ args }) { + const routeKeys = + args.route === 'both' ? Object.keys(ROUTES) : [args.route as string] + for (const routeKey of routeKeys) await bridgeViaFrax(routeKey) + }, +}) + +runMain(command) From 0c28eda6e140538b8700aafe7888a0d308afeaa7 Mon Sep 17 00:00:00 2001 From: Daniel Blaecker Date: Mon, 13 Jul 2026 14:59:17 +0700 Subject: [PATCH 5/5] =?UTF-8?q?fix(FraxFacet):=20address=20CodeRabbit=20re?= =?UTF-8?q?view=20=E2=80=94=20demo=20aborts=20on=20quote-routing=20mismatc?= =?UTF-8?q?h;=20docs=20clarify=20enforced=20BridgeData=20fields=20(EXSC-38?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - demoFrax: source-chain Hop is always the arbitrum spoke for both routes; validate oft/dstEid/recipient/amount/approvalAddress/to and throw on mismatch instead of warn - docs: BridgeData is not strictly analytics — sendingAssetId/receiver/minAmount are enforced Co-Authored-By: Claude Opus 4.8 --- docs/FraxFacet.md | 11 +++++--- script/demoScripts/demoFrax.ts | 49 +++++++++++++++++++--------------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/docs/FraxFacet.md b/docs/FraxFacet.md index 1ef2c9287..ae6e22091 100644 --- a/docs/FraxFacet.md +++ b/docs/FraxFacet.md @@ -128,10 +128,13 @@ The swap library can be found [here](../src/Libraries/LibSwap.sol). Some methods accept a `BridgeData _bridgeData` parameter. -This parameter is strictly for analytics purposes. It's used to emit events that we -can later track and index in our subgraphs and provide data on how our contracts are -being used. `BridgeData` and the events we can emit can be found -[here](../src/Interfaces/ILiFi.sol). +This parameter carries both analytics metadata and enforced fields. The metadata +(`transactionId`, `integrator`, `referrer`, and — for FraxFacet — `destinationChainId`) +is used to emit events that we can later track and index in our subgraphs. The +remaining fields are load-bearing: FraxFacet validates and acts on `sendingAssetId` +(the OFT token pulled and bridged), `receiver` (encoded as the destination recipient), +and `minAmount` (the amount deposited and bridged). `BridgeData` and the events we can +emit can be found [here](../src/Interfaces/ILiFi.sol). ## Getting Sample Calls to interact with the Facet diff --git a/script/demoScripts/demoFrax.ts b/script/demoScripts/demoFrax.ts index 09009f381..aeadce3c9 100644 --- a/script/demoScripts/demoFrax.ts +++ b/script/demoScripts/demoFrax.ts @@ -94,15 +94,16 @@ const FRXUSD_ARBITRUM: Address = getAddress( '0x80Eede496655FB9047dd39d9f418d5483ED600df' ) -// The HopV2 contract (== the quote's approvalAddress) shares one CREATE2 address -// across all spokes; the Fraxtal hub has its own. Used here only to sanity-check -// the API response, never sent as calldata (the facet targets its own immutable). +// The HopV2 contract (== the quote's approvalAddress / transactionRequest.to) +// shares one CREATE2 address across all spokes. This demo always bridges FROM +// arbitrum (a spoke), so the source-chain Hop is always this spoke address for +// both routes — the destination (hub or another spoke) does not change it. Used +// here only to sanity-check the API response, never sent as calldata (the facet +// targets its own immutable HOP). The Fraxtal hub 0x00000000e18a…B36 is only the +// source Hop when bridging FROM Fraxtal, which this demo does not do. const FRAX_HOP_SPOKE: Address = getAddress( '0x0000006D38568b00B457580b734e0076C62de659' ) -const FRAX_HOP_HUB_FRAXTAL: Address = getAddress( - '0x00000000e18aFc20Afe54d4B2C8688bB60c06B36' -) // 100 frxUSD (18 decimals) — small demo amount. const DEMO_AMOUNT = parseUnits('100', 18) @@ -243,8 +244,9 @@ async function bridgeViaFrax(routeKey: string): Promise { const nativeFee = BigInt(quote.transactionRequest.value) const oft = getAddress(quote.meta.sendOftCall.args.oft) const dstEid = quote.meta.sendOftCall.args.dstEid - const expectedHop = - route.apiToChain === 'fraxtal' ? FRAX_HOP_HUB_FRAXTAL : FRAX_HOP_SPOKE + // arbitrum is the source (a spoke) for both routes, so the source-chain Hop — + // and thus approvalAddress / transactionRequest.to — is always the spoke address. + const expectedHop = FRAX_HOP_SPOKE consola.box('Frax API response -> facet calldata mapping') consola.info(` approvalAddress (HopV2 target): ${quote.approvalAddress}`) @@ -267,19 +269,24 @@ async function bridgeViaFrax(routeKey: string): Promise { ` feeCosts[0].amount: ${quote.feeCosts[0]?.amount}` ) - // Sanity-check the API against our expectations. The source OFT for frxUSD on - // arbitrum is the token itself, and the facet only pulls bridgeData.sendingAssetId. - if (oft.toLowerCase() !== FRXUSD_ARBITRUM.toLowerCase()) - consola.warn( - `API oft ${oft} != expected arbitrum frxUSD ${FRXUSD_ARBITRUM} — using the API value` - ) - if (dstEid !== route.dstEid) - consola.warn( - `API dstEid ${dstEid} != expected ${route.dstEid} for ${route.apiToChain} — using the API value` - ) - if (getAddress(quote.approvalAddress) !== expectedHop) - consola.warn( - `API approvalAddress ${quote.approvalAddress} != expected HopV2 ${expectedHop}` + // The API controls oft/dstEid/recipient/amount and the Hop target, so validate + // them against what we requested and ABORT on any mismatch — an unexpected dstEid + // or Hop could otherwise strand funds. (In production the LI.FI backend enforces + // this; here we fail loudly rather than send against a surprising quote.) + // recipient is a bytes32 (address left-padded) — compare its trailing 20 bytes + const recipientAddr = getAddress( + `0x${quote.meta.sendOftCall.args.recipient.slice(-40)}` + ) + if ( + oft !== FRXUSD_ARBITRUM || + dstEid !== route.dstEid || + recipientAddr !== devWallet || + BigInt(quote.meta.sendOftCall.args.amountLd) !== amount || + getAddress(quote.approvalAddress) !== expectedHop || + getAddress(quote.transactionRequest.to) !== expectedHop + ) + throw new Error( + `Frax quote routing does not match the requested ${route.apiFromChain}->${route.apiToChain} route` ) // === Ensure balance + allowance (the Diamond pulls frxUSD via depositAsset) ===