diff --git a/audit/auditLog.json b/audit/auditLog.json index 4d298d6e7..98e53659a 100644 --- a/audit/auditLog.json +++ b/audit/auditLog.json @@ -720,6 +720,13 @@ "auditorGitHandle": "sujithsomraaj", "auditReportPath": "./audit/reports/2026.08.20_FraxFacet(v1.0.0).pdf", "auditCommitHash": "3ae7e5f8180d97db785b0cc93947d3740f209bf5" + }, + "audit20260902": { + "auditCompletedOn": "02.09.2026", + "auditedBy": "Sujith Somraaj (individual security researcher)", + "auditorGitHandle": "sujithsomraaj", + "auditReportPath": "./audit/reports/2026.09.02_EcoFacet(v2.0.0).pdf", + "auditCommitHash": "c86df08214e4de854a3c20ad3e7aeffc607441d9" } }, "auditedContracts": { @@ -802,7 +809,8 @@ }, "EcoFacet": { "1.0.0": ["audit20251001"], - "1.1.0": ["audit20251020"] + "1.1.0": ["audit20251020"], + "2.0.0": ["audit20260902"] }, "DiamondCutFacet": { "1.0.0": ["audit20250508"] diff --git a/audit/reports/2026.09.02_EcoFacet(v2.0.0).pdf b/audit/reports/2026.09.02_EcoFacet(v2.0.0).pdf new file mode 100644 index 000000000..93621f488 Binary files /dev/null and b/audit/reports/2026.09.02_EcoFacet(v2.0.0).pdf differ diff --git a/config/clearSigningProposal.json b/config/clearSigningProposal.json index 36d481519..2781bcebf 100644 --- a/config/clearSigningProposal.json +++ b/config/clearSigningProposal.json @@ -578,7 +578,7 @@ } ] }, - "startBridgeTokensViaEco((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (bytes nonEVMReceiver, address prover, uint64 rewardDeadline, bytes encodedRoute, bytes32 solanaATA, address refundRecipient) _ecoData)": { + "startBridgeTokensViaEco((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (bytes nonEVMReceiver, address prover, uint64 rewardDeadline, bytes encodedRoute, bytes32 solanaATA, address refundRecipient, uint256 deadline, bytes signature) _ecoData)": { "intent": "Bridge via Eco", "interpolatedIntent": "Bridge {_bridgeData.minAmount} via Eco to chain {_bridgeData.destinationChainId} for {_bridgeData.receiver}", "fields": [ @@ -2965,7 +2965,7 @@ } ] }, - "swapAndStartBridgeTokensViaEco((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, (bytes nonEVMReceiver, address prover, uint64 rewardDeadline, bytes encodedRoute, bytes32 solanaATA, address refundRecipient) _ecoData)": { + "swapAndStartBridgeTokensViaEco((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, (bytes nonEVMReceiver, address prover, uint64 rewardDeadline, bytes encodedRoute, bytes32 solanaATA, address refundRecipient, uint256 deadline, bytes signature) _ecoData)": { "intent": "Swap & Bridge via Eco", "interpolatedIntent": "Swap then bridge {_bridgeData.minAmount} via Eco to chain {_bridgeData.destinationChainId} for {_bridgeData.receiver}", "fields": [ diff --git a/deployments/arbitrum.diamond.staging.json b/deployments/arbitrum.diamond.staging.json index 7b5ae93f2..2fed0d4d7 100644 --- a/deployments/arbitrum.diamond.staging.json +++ b/deployments/arbitrum.diamond.staging.json @@ -225,6 +225,10 @@ "Name": "LiFiIntentEscrowFacetV2", "Version": "1.0.0" }, + "0x4024f93ac23e00EfC85371fe0a69922C82EbdF88": { + "Name": "EcoFacet", + "Version": "2.0.0" + }, "0x401Aaf6A650A17677821E665838367EBD5bE77BD": { "Name": "FraxFacet", "Version": "1.0.0" diff --git a/deployments/arbitrum.staging.json b/deployments/arbitrum.staging.json index 92bd9e0bd..23c0412c0 100644 --- a/deployments/arbitrum.staging.json +++ b/deployments/arbitrum.staging.json @@ -58,6 +58,7 @@ "AcrossV4SwapFacet": "0x2B8F8C0Cf809EdbbD1257B4c4f27FdAe318062b1", "ReceiverOIF": "0x6c75016288d838ED1a34BDc9bf84f24d5dd88363", "WhitelistRecoveryFacet": "0xd35fC069dcBD50780c706750A41AfC418f25f5D6", + "EcoFacet": "0x4024f93ac23e00EfC85371fe0a69922C82EbdF88", "LayerSwapFacet": "0x6FB56233842489D772b9Bb004390abF317Efa390", "FeeForwarder": "0x7C1923FbD89154156DE2220870C834B141bA8c49", "SupersetFacet": "0xD04A7f8EdB8F4C1dB851bCc919e71dB5E5093fc8", diff --git a/deployments/base.diamond.staging.json b/deployments/base.diamond.staging.json index 304ad07e6..a6971108a 100644 --- a/deployments/base.diamond.staging.json +++ b/deployments/base.diamond.staging.json @@ -81,9 +81,17 @@ "Name": "LiFiIntentEscrowFacetV2", "Version": "1.0.0" }, + "0x5C811dE2E64aD6660a464dAD65FF17669C175989": { + "Name": "EcoFacet", + "Version": "1.2.0" + }, "0x401Aaf6A650A17677821E665838367EBD5bE77BD": { "Name": "FraxFacet", "Version": "1.0.0" + }, + "0x4024f93ac23e00EfC85371fe0a69922C82EbdF88": { + "Name": "EcoFacet", + "Version": "2.0.0" } }, "Periphery": { diff --git a/deployments/base.staging.json b/deployments/base.staging.json index 7d9ff990e..b56a2fc16 100644 --- a/deployments/base.staging.json +++ b/deployments/base.staging.json @@ -26,5 +26,6 @@ "ReceiverOIF": "0x6c75016288d838ED1a34BDc9bf84f24d5dd88363", "SupersetFacet": "0x54AEC552Acc3218b0B3991FD61cA12b3A4fD222D", "LiFiIntentEscrowFacetV2": "0x1aAADa7ab2BD56de2482d46213AB0994a0C7ff9b", + "EcoFacet": "0x4024f93ac23e00EfC85371fe0a69922C82EbdF88", "FraxFacet": "0x401Aaf6A650A17677821E665838367EBD5bE77BD" } diff --git a/docs/EcoFacet.md b/docs/EcoFacet.md index 1820c9510..d751392f1 100644 --- a/docs/EcoFacet.md +++ b/docs/EcoFacet.md @@ -29,6 +29,8 @@ The methods listed above take a variable labeled `_ecoData`. This data is specif /// @param encodedRoute Encoded route data containing destination chain routing information /// @param solanaATA Associated Token Account address for Solana bridging (bytes32) /// @param refundRecipient Address that will receive refunds if the intent expires unfulfilled +/// @param deadline Timestamp after which the backend signature is no longer valid +/// @param signature Backend EIP-712 signature over the EcoPayload authorizing this bridge struct EcoData { bytes nonEVMReceiver; address prover; @@ -36,6 +38,8 @@ struct EcoData { bytes encodedRoute; bytes32 solanaATA; address refundRecipient; + uint256 deadline; + bytes signature; } ``` @@ -58,6 +62,13 @@ The receiver address is specified differently depending on the destination chain - Set `refundRecipient` to the address that should receive refunds (typically the user's address) - The contract validates that `solanaATA` matches the ATA encoded in the route +- **For Tron destination chain**: + - Set `bridgeData.receiver` to `NON_EVM_ADDRESS` constant (`0x11f111f111f111F111f111f111F111f111f111F1`) + - Provide the Tron address in `nonEVMReceiver` as a 32-byte left-padded address (`abi.encode(address)`) + - Leave `solanaATA` as `bytes32(0)` + - Set `refundRecipient` to the address that should receive refunds (typically the user's address) + - The contract validates that `nonEVMReceiver` matches the receiver decoded from the encoded route + Examples: ```solidity @@ -72,6 +83,12 @@ bridgeData.receiver = NON_EVM_ADDRESS; // Special constant ecoData.nonEVMReceiver = solanaAddressBytes; // Solana address as bytes ecoData.solanaATA = 0x8f37c499ccbb92...; // Solana ATA as bytes32 ecoData.refundRecipient = msg.sender; // User address for refunds + +// EVM to Tron bridge +bridgeData.receiver = NON_EVM_ADDRESS; // Special constant +ecoData.nonEVMReceiver = abi.encode(tronAddr); // Tron address, 32-byte left-padded +ecoData.solanaATA = bytes32(0); // Zero for Tron +ecoData.refundRecipient = msg.sender; // User address for refunds ``` ### Important Notes @@ -104,9 +121,13 @@ ecoData.refundRecipient = msg.sender; // User address for refunds - **Encoded Route**: The `encodedRoute` parameter is provided by the Eco API and contains all necessary routing information for the destination chain. It is used as-is by the facet and is required for all bridge operations. The contract validates that the receiver address in the encoded route matches the specified receiver. +- **Backend Signature (trust assumption)**: `encodedRoute` and `prover` are opaque, backend-supplied values whose full contents cannot be reconstructed and validated on-chain — the route encodes destination calls the facet does not interpret, and a malicious prover could mark an intent fulfilled without paying the destination. Both are therefore gated by a backend EIP-712 signature. The LI.FI backend signs an `EcoPayload` that commits to `transactionId`, `sendingAssetId`, `minAmount`, `destinationChainId`, `receiver`, `keccak256(nonEVMReceiver)`, `keccak256(encodedRoute)`, `prover`, `refundRecipient`, `rewardDeadline`, `solanaATA`, and `deadline`; the facet recovers the signer and requires it to equal the configured `BACKEND_SIGNER`. `deadline` bounds the signature's validity window. Integrators must obtain `signature` and `deadline` from the LI.FI backend per call; the destination receiver is not purely enforced on-chain for these flows. + + - EIP-712 domain: `name = "LI.FI Eco Facet"`, `version = "1"`, `chainId` = the source chain, `verifyingContract` = the LiFiDiamond address. + - **Chain ID Mapping**: The facet automatically maps LiFi chain IDs to Eco protocol chain IDs for non-EVM chains (Tron: 728126428, Solana: 1399811149). -- **TRON Compatibility**: TRON is treated as EVM-compatible in the smart contract validation logic since it uses the same Route struct encoding as EVM chains. Only Solana requires special non-EVM handling with `nonEVMReceiver` and `solanaATA` parameters. +- **TRON Handling**: Tron follows the non-EVM receiver convention (`bridgeData.receiver` set to `NON_EVM_ADDRESS`, real recipient in `nonEVMReceiver`), matching the backend's generic non-EVM bridge-data builder and the other non-EVM facets. Because Tron uses the same Route struct encoding as EVM chains, its recipient is decoded from the route's final `transfer` call and cross-checked against `nonEVMReceiver`; a `BridgeToNonEVMChainBytes32` event is emitted. Solana keeps its own handling via `nonEVMReceiver` (base58 bytes) and `solanaATA`. - **Solana ATA Validation**: For Solana bridges, the contract validates that the Associated Token Account (ATA) specified in `solanaATA` matches the ATA encoded in bytes 251-283 of the route. The ATA is derived from the user's wallet address and the SPL token mint address, not the user's wallet address directly. diff --git a/script/demoScripts/demoEco.ts b/script/demoScripts/demoEco.ts index fc9ded244..eecb6e975 100644 --- a/script/demoScripts/demoEco.ts +++ b/script/demoScripts/demoEco.ts @@ -16,13 +16,22 @@ import { getAssociatedTokenAddressSync } from '@solana/spl-token' import { Keypair, PublicKey } from '@solana/web3.js' import { defineCommand, runMain } from 'citty' import { config } from 'dotenv' -import { parseUnits, zeroAddress, type Narrow, toHex } from 'viem' +import { + parseUnits, + zeroAddress, + type Narrow, + type Hex, + toHex, + keccak256, +} from 'viem' import { erc20Abi } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' import ecoFacetArtifact from '../../out/EcoFacet.sol/EcoFacet.json' import type { ILiFi } from '../../typechain' import type { EcoFacet, LibSwap } from '../../typechain/EcoFacet' import type { SupportedChain } from '../common/types' +import { getEnvVar } from '../utils/utils' import { ADDRESS_USDC_OPT, @@ -547,6 +556,59 @@ async function main(args: { console.log(' For token mint:', usdcMint) } + // === Backend EIP-712 signature over the EcoPayload === + const backendSignerKey = getEnvVar('PRIVATE_KEY_BACKEND_SIGNER_STAGING') + const normalizedBackendKey: Hex = backendSignerKey.startsWith('0x') + ? (backendSignerKey as Hex) + : (`0x${backendSignerKey}` as Hex) + const backendSignerAccount = privateKeyToAccount(normalizedBackendKey) + + const sourceChainId = await publicClient.getChainId() + const signatureDeadline = BigInt(Math.floor(Date.now() / 1000) + 3600) + + const backendSignature = await backendSignerAccount.signTypedData({ + domain: { + name: 'LI.FI Eco Facet', + version: '1', + chainId: sourceChainId, + verifyingContract: lifiDiamondContract.address, + }, + types: { + EcoPayload: [ + { name: 'transactionId', type: 'bytes32' }, + { name: 'sendingAssetId', type: 'address' }, + { name: 'minAmount', type: 'uint256' }, + { name: 'destinationChainId', type: 'uint256' }, + { name: 'receiver', type: 'address' }, + { name: 'nonEVMReceiverHash', type: 'bytes32' }, + { name: 'encodedRouteHash', type: 'bytes32' }, + { name: 'prover', type: 'address' }, + { name: 'refundRecipient', type: 'address' }, + { name: 'rewardDeadline', type: 'uint64' }, + { name: 'solanaATA', type: 'bytes32' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + primaryType: 'EcoPayload', + message: { + transactionId: bridgeData.transactionId as Hex, + sendingAssetId: SRC_TOKEN_ADDRESS as Hex, + minAmount: BigInt(bridgeMinAmount), + destinationChainId, + receiver: receiverAddress as Hex, + nonEVMReceiverHash: keccak256(nonEVMReceiverBytes), + encodedRouteHash: keccak256(encodedRoute as Hex), + prover: quote.data.contracts.prover as Hex, + refundRecipient: signerAddress as Hex, + rewardDeadline: BigInt(quote.data.quoteResponse.deadline), + solanaATA, + deadline: signatureDeadline, + }, + }) + + console.log(' Backend signer:', backendSignerAccount.address) + console.log(' Signature deadline:', signatureDeadline.toString()) + const ecoData: EcoFacet.EcoDataStruct = { nonEVMReceiver: nonEVMReceiverBytes, prover: quote.data.contracts.prover, @@ -554,6 +616,8 @@ async function main(args: { encodedRoute: encodedRoute, solanaATA: solanaATA, refundRecipient: signerAddress, + deadline: signatureDeadline, + signature: backendSignature, } // === Ensure allowance === diff --git a/script/deploy/facets/DeployEcoFacet.s.sol b/script/deploy/facets/DeployEcoFacet.s.sol index 9ee0aeac1..1442edfd1 100644 --- a/script/deploy/facets/DeployEcoFacet.s.sol +++ b/script/deploy/facets/DeployEcoFacet.s.sol @@ -27,6 +27,21 @@ contract DeployScript is DeployScriptBase { string.concat(".", network, ".portal") ); - return abi.encode(portal); + string memory globalPath = string.concat(root, "/config/global.json"); + string memory globalJson = vm.readFile(globalPath); + + address backendSigner; + if ( + keccak256(abi.encodePacked(fileSuffix)) == + keccak256(abi.encodePacked("staging.")) + ) { + backendSigner = globalJson.readAddress(".backendSigner.staging"); + } else { + backendSigner = globalJson.readAddress( + ".backendSigner.production" + ); + } + + return abi.encode(portal, backendSigner); } } diff --git a/script/deploy/resources/deployRequirements.json b/script/deploy/resources/deployRequirements.json index 489f26d21..116b2f33f 100644 --- a/script/deploy/resources/deployRequirements.json +++ b/script/deploy/resources/deployRequirements.json @@ -181,6 +181,20 @@ } } }, + "EcoFacet": { + "configData": { + "_portal": { + "configFileName": "eco.json", + "keyInConfigFile": "..portal", + "allowToDeployWithZeroAddress": "false" + }, + "_backendSigner": { + "configFileName": "global.json", + "keyInConfigFile": ".backendSigner.", + "allowToDeployWithZeroAddress": "false" + } + } + }, "FraxFacet": { "configData": { "_hop": { diff --git a/script/deploy/tron/deploy-and-register-eco-facet.ts b/script/deploy/tron/deploy-and-register-eco-facet.ts index c3d798173..ddb80771f 100644 --- a/script/deploy/tron/deploy-and-register-eco-facet.ts +++ b/script/deploy/tron/deploy-and-register-eco-facet.ts @@ -105,8 +105,20 @@ async function deployAndRegisterEcoFacet(options: { dryRun?: boolean }) { const portal = tronAddressToHex(tronWeb, portalTron) + const globalConfig = await Bun.file('config/global.json').json() + const backendSigner = + environment === EnvironmentEnum.production + ? globalConfig.backendSigner?.production + : globalConfig.backendSigner?.staging + + if (!backendSigner) + throw new Error( + 'Backend signer not found in config/global.json for this environment' + ) + consola.info('\nEco Configuration:') consola.info(`Portal: ${portalTron} (hex: ${portal})`) + consola.info(`Backend signer: ${backendSigner}`) const contracts = ['EcoFacet'] @@ -136,7 +148,7 @@ async function deployAndRegisterEcoFacet(options: { dryRun?: boolean }) { }) } else try { - const constructorArgs = [portal] + const constructorArgs = [portal, backendSigner] const result = await deployContractWithLogging( deployer, diff --git a/src/Facets/EcoFacet.sol b/src/Facets/EcoFacet.sol index 1cb7fcb85..dd138b9f3 100644 --- a/src/Facets/EcoFacet.sol +++ b/src/Facets/EcoFacet.sol @@ -1,32 +1,51 @@ // SPDX-License-Identifier: LGPL-3.0-only pragma solidity ^0.8.17; +import { ECDSA } from "solady/utils/ECDSA.sol"; import { ILiFi } from "../Interfaces/ILiFi.sol"; import { IEcoPortal } from "../Interfaces/IEcoPortal.sol"; import { LibAsset } from "../Libraries/LibAsset.sol"; import { LibSwap } from "../Libraries/LibSwap.sol"; +import { LibBytes } from "../Libraries/LibBytes.sol"; import { ReentrancyGuard } from "../Helpers/ReentrancyGuard.sol"; import { SwapperV2 } from "../Helpers/SwapperV2.sol"; import { Validatable } from "../Helpers/Validatable.sol"; import { LiFiData } from "../Helpers/LiFiData.sol"; import { IERC20 } from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; -import { InvalidConfig, InvalidReceiver } from "../Errors/GenericErrors.sol"; +import { InvalidConfig, InvalidReceiver, InvalidNonEVMReceiver, InvalidSignature } from "../Errors/GenericErrors.sol"; /// @title EcoFacet /// @author LI.FI (https://li.fi) /// @notice Provides functionality for bridging through Eco Protocol -/// @custom:version 1.1.0 +/// @dev The `encodedRoute` and `prover` are opaque, backend-supplied parameters +/// whose full contents cannot be reconstructed and validated on-chain (the +/// route encodes destination calls the facet does not interpret, and a +/// malicious prover could mark an intent fulfilled without paying out). Both +/// are therefore gated by a backend EIP-712 signature (see `_verifySignature`) +/// that commits to the bridge parameters, the prover, and a hash of the +/// encoded route. The on-chain receiver cross-checks in `_validateEcoData` +/// are retained as defense in depth; integrators must understand that the +/// destination receiver is not purely enforced on-chain for these flows. +/// @custom:version 2.0.0 contract EcoFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable, LiFiData { /// Errors /// error IntentAlreadyFunded(); + /// @notice Thrown when the backend signature has expired + error SignatureExpired(); /// Constants and Immutables /// IEcoPortal public immutable PORTAL; + /// @notice Backend signer authorized to sign the EcoPayload + address internal immutable BACKEND_SIGNER; uint64 private constant ECO_CHAIN_ID_TRON = 728126428; uint64 private constant ECO_CHAIN_ID_SOLANA = 1399811149; + // EIP-712 typehash: keccak256("EcoPayload(bytes32 transactionId,address sendingAssetId,uint256 minAmount,uint256 destinationChainId,address receiver,bytes32 nonEVMReceiverHash,bytes32 encodedRouteHash,address prover,address refundRecipient,uint64 rewardDeadline,bytes32 solanaATA,uint256 deadline)") + bytes32 private constant ECO_PAYLOAD_TYPEHASH = + 0xa3243df568679887ffddc8c7d34cf0bd57b0a8d9430c7044d28def7369fd7881; + /// Constants /// uint256 private constant NATIVE_REWARD_AMOUNT = 0; @@ -72,6 +91,8 @@ contract EcoFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable, LiFiData { /// @param encodedRoute Encoded route data containing destination chain routing information /// @param solanaATA Associated Token Account address for Solana bridging (bytes32) /// @param refundRecipient Address that will receive refunds if the intent expires unfulfilled + /// @param deadline Timestamp after which the backend signature is no longer valid + /// @param signature Backend EIP-712 signature over the EcoPayload authorizing this bridge struct EcoData { bytes nonEVMReceiver; address prover; @@ -79,17 +100,21 @@ contract EcoFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable, LiFiData { bytes encodedRoute; bytes32 solanaATA; address refundRecipient; + uint256 deadline; + bytes signature; } /// Constructor /// /// @notice Initializes the EcoFacet with the Eco Portal contract /// @param _portal Address of the Eco Portal contract - constructor(IEcoPortal _portal) { - if (address(_portal) == address(0)) { + /// @param _backendSigner Address of the backend signer authorized to sign the EcoPayload + constructor(IEcoPortal _portal, address _backendSigner) { + if (address(_portal) == address(0) || _backendSigner == address(0)) { revert InvalidConfig(); } PORTAL = _portal; + BACKEND_SIGNER = _backendSigner; } /// External Methods /// @@ -108,6 +133,7 @@ contract EcoFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable, LiFiData { doesNotContainDestinationCalls(_bridgeData) noNativeAsset(_bridgeData) { + _verifySignature(_bridgeData, _ecoData); _validateEcoData(_bridgeData, _ecoData); LibAsset.depositAsset( @@ -140,6 +166,9 @@ contract EcoFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable, LiFiData { doesNotContainDestinationCalls(_bridgeData) noNativeAsset(_bridgeData) { + // The signature is intentionally verified with the pre-swap `minAmount`, + // which is also the amount funded into the reward in `_startBridge`. + _verifySignature(_bridgeData, _ecoData); _validateEcoData(_bridgeData, _ecoData); uint256 actualAmountAfterSwap = _depositAndSwap( @@ -239,6 +268,12 @@ contract EcoFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable, LiFiData { _bridgeData.destinationChainId, _ecoData.nonEVMReceiver ); + } else if (_bridgeData.destinationChainId == LIFI_CHAIN_ID_TRON) { + emit BridgeToNonEVMChainBytes32( + _bridgeData.transactionId, + _bridgeData.destinationChainId, + bytes32(_ecoData.nonEVMReceiver[0:32]) + ); } emit LiFiTransferStarted(_bridgeData); @@ -254,54 +289,89 @@ contract EcoFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable, LiFiData { revert InvalidConfig(); } - address receiver = _bridgeData.receiver; bool isSolanaDestination = _bridgeData.destinationChainId == LIFI_CHAIN_ID_SOLANA; + bool isTronDestination = _bridgeData.destinationChainId == + LIFI_CHAIN_ID_TRON; - if (receiver == NON_EVM_ADDRESS) { - if (!isSolanaDestination) { + if (_bridgeData.receiver == NON_EVM_ADDRESS) { + if (isSolanaDestination) { + if (_ecoData.nonEVMReceiver.length == 0) + revert InvalidReceiver(); + if (_ecoData.solanaATA == bytes32(0)) revert InvalidConfig(); + if ( + _ecoData.encodedRoute.length != SOLANA_ENCODED_ROUTE_LENGTH + ) revert InvalidReceiver(); + _validateSolanaReceiver(_ecoData); + } else if (isTronDestination) { + if (_ecoData.encodedRoute.length == 0) revert InvalidConfig(); + if (_ecoData.nonEVMReceiver.length != 32) + revert InvalidReceiver(); + _validateTronReceiver(_ecoData); + } else { revert InvalidConfig(); } - - if (_ecoData.nonEVMReceiver.length == 0) revert InvalidReceiver(); - if (_ecoData.solanaATA == bytes32(0)) revert InvalidConfig(); - if (_ecoData.encodedRoute.length != SOLANA_ENCODED_ROUTE_LENGTH) - revert InvalidReceiver(); - _validateSolanaReceiver(_ecoData); } else { if (_ecoData.encodedRoute.length == 0) revert InvalidConfig(); - // If receiver is not NON_EVM_ADDRESS but destination is Solana, reject - if (isSolanaDestination) { + // A concrete receiver is only valid for EVM destinations; non-EVM + // chains must use the NON_EVM_ADDRESS sentinel path above. + if (isSolanaDestination || isTronDestination) { revert InvalidReceiver(); } - // For EVM-compatible chains (includes TRON), decode the Route struct to get the last call - // Note: TRON is considered EVM-compatible here as it uses the same Route struct encoding - Route memory route = abi.decode(_ecoData.encodedRoute, (Route)); - - // The last call should be the transfer to the receiver - // For ERC20 transfer, the calldata follows the pattern: transfer(address,uint256) - // We need to skip the function selector (4 bytes) and decode the address parameter - bytes memory lastCallData = route - .calls[route.calls.length - 1] - .callData; - - // Extract the receiver address from the calldata - // Skip the 4-byte function selector and decode the address (first parameter) - // The address parameter starts at byte 4 (after the selector) - address routeReceiver; - assembly { - // Load the address from offset 36 (32 bytes length + 4 bytes selector) - routeReceiver := mload(add(lastCallData, 36)) - } - - if (routeReceiver != _bridgeData.receiver) { + if ( + _decodeRouteReceiver(_ecoData.encodedRoute) != + _bridgeData.receiver + ) { revert InvalidReceiver(); } } } + /// @dev Decodes the Route struct and returns the recipient of its final + /// ERC20/TRC20 `transfer` call, the address the destination tokens are + /// sent to. Used to cross-check the caller-supplied receiver. + function _decodeRouteReceiver( + bytes calldata encodedRoute + ) private pure returns (address routeReceiver) { + Route memory route = abi.decode(encodedRoute, (Route)); + if (route.calls.length == 0) revert InvalidReceiver(); + + // The last call must be a well-formed transfer(address,uint256): a + // 4-byte selector + two 32-byte words. Enforcing the length and selector + // before reading the address prevents a shorter or unrelated final call + // from yielding a bogus receiver that still satisfies the cross-check. + bytes memory lastCallData = route + .calls[route.calls.length - 1] + .callData; + if (lastCallData.length < 68) revert InvalidReceiver(); + + bytes4 selector; + bytes32 receiverWord; + assembly { + selector := mload(add(lastCallData, 32)) + // Load the address word from offset 36 (32-byte length + 4-byte selector) + receiverWord := mload(add(lastCallData, 36)) + } + if (selector != IERC20.transfer.selector) revert InvalidReceiver(); + + routeReceiver = LibBytes.toAddressUnchecked(receiverWord); + } + + /// @dev Tron uses the same Route struct encoding as EVM chains, so the real + /// recipient lives in the route. nonEVMReceiver carries that recipient + /// as a 32-byte left-padded address and is cross-checked against it. + function _validateTronReceiver(EcoData calldata _ecoData) private pure { + address nonEVMReceiver = LibBytes.toAddress( + bytes32(_ecoData.nonEVMReceiver[0:32]) + ); + if (nonEVMReceiver == address(0)) revert InvalidNonEVMReceiver(); + if (nonEVMReceiver != _decodeRouteReceiver(_ecoData.encodedRoute)) { + revert InvalidReceiver(); + } + } + function _validateSolanaReceiver(EcoData calldata _ecoData) private pure { // Validate the nonEVMReceiver length for Solana addresses // Solana addresses are base58-encoded and should be between 32-44 characters @@ -341,4 +411,67 @@ contract EcoFacet is ILiFi, ReentrancyGuard, SwapperV2, Validatable, LiFiData { bytes32 rewardHash = keccak256(abi.encode(reward)); return keccak256(abi.encodePacked(destination, routeHash, rewardHash)); } + + /// @dev Verifies the backend EIP-712 signature over the EcoPayload. The + /// opaque `encodedRoute` and `nonEVMReceiver` are committed via their + /// keccak256 hashes. + /// @param _bridgeData The core information needed for bridging + /// @param _ecoData Eco-specific parameters for the bridge + function _verifySignature( + ILiFi.BridgeData memory _bridgeData, + EcoData calldata _ecoData + ) internal view { + if (block.timestamp > _ecoData.deadline) { + revert SignatureExpired(); + } + + bytes32 structHash = keccak256( + abi.encode( + ECO_PAYLOAD_TYPEHASH, + _bridgeData.transactionId, + _bridgeData.sendingAssetId, + _bridgeData.minAmount, + _bridgeData.destinationChainId, + _bridgeData.receiver, + keccak256(_ecoData.nonEVMReceiver), + keccak256(_ecoData.encodedRoute), + _ecoData.prover, + _ecoData.refundRecipient, + _ecoData.rewardDeadline, + _ecoData.solanaATA, + _ecoData.deadline + ) + ); + + bytes32 digest = keccak256( + abi.encodePacked("\x19\x01", _domainSeparator(), structHash) + ); + + address recoveredSigner = ECDSA.recoverCalldata( + digest, + _ecoData.signature + ); + + if (recoveredSigner != BACKEND_SIGNER) { + revert InvalidSignature(); + } + } + + /// @notice Returns the EIP-712 domain separator + /// @dev Computed on the fly so `address(this)` resolves to the + /// diamond's address when called via delegatecall + function _domainSeparator() internal view returns (bytes32) { + return + keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + keccak256(bytes("LI.FI Eco Facet")), + keccak256(bytes("1")), + block.chainid, + address(this) + ) + ); + } } diff --git a/test/solidity/Facets/EcoFacet.t.sol b/test/solidity/Facets/EcoFacet.t.sol index 44604a7a7..31e720210 100644 --- a/test/solidity/Facets/EcoFacet.t.sol +++ b/test/solidity/Facets/EcoFacet.t.sol @@ -8,14 +8,19 @@ import { LibSwap } from "../../../src/Libraries/LibSwap.sol"; import { EcoFacet } from "../../../src/Facets/EcoFacet.sol"; import { IEcoPortal } from "../../../src/Interfaces/IEcoPortal.sol"; import { ILiFi } from "../../../src/Interfaces/ILiFi.sol"; -import { InvalidConfig, InvalidReceiver, NativeAssetNotSupported } from "../../../src/Errors/GenericErrors.sol"; +import { InvalidConfig, InvalidReceiver, InvalidNonEVMReceiver, InvalidSignature, NativeAssetNotSupported } from "../../../src/Errors/GenericErrors.sol"; +import { LibBytes } from "../../../src/Libraries/LibBytes.sol"; import { TestWhitelistManagerBase } from "../utils/TestWhitelistManagerBase.sol"; +import { TestEcoBackendSig } from "../utils/TestEcoBackendSig.sol"; contract TestEcoFacet is EcoFacet, TestWhitelistManagerBase { - constructor(IEcoPortal _portal) EcoFacet(_portal) {} + constructor( + IEcoPortal _portal, + address _backendSigner + ) EcoFacet(_portal, _backendSigner) {} } -contract EcoFacetTest is TestBaseFacet { +contract EcoFacetTest is TestBaseFacet, TestEcoBackendSig { TestEcoFacet internal ecoFacet; address internal constant PORTAL = 0xB5e58A8206473Df3Ab9b8DDd3B0F84c0ba68F8b5; @@ -38,7 +43,10 @@ contract EcoFacetTest is TestBaseFacet { 1000000 * 10 ** ERC20(ADDRESS_USDC).decimals() ); - ecoFacet = new TestEcoFacet(IEcoPortal(PORTAL)); + backendSignerPrivateKey = 0xB0B; + backendSignerAddress = vm.addr(backendSignerPrivateKey); + + ecoFacet = new TestEcoFacet(IEcoPortal(PORTAL), backendSignerAddress); bytes4[] memory functionSelectors = new bytes4[](3); functionSelectors[0] = ecoFacet.startBridgeTokensViaEco.selector; @@ -49,6 +57,7 @@ contract EcoFacetTest is TestBaseFacet { addFacet(diamond, address(ecoFacet), functionSelectors); ecoFacet = TestEcoFacet(address(diamond)); + ecoVerifyingContract = address(diamond); ecoFacet.addAllowedContractSelector( ADDRESS_UNISWAP, uniswap.swapExactTokensForTokens.selector @@ -72,17 +81,46 @@ contract EcoFacetTest is TestBaseFacet { function initiateBridgeTxWithFacet(bool) internal override { EcoFacet.EcoData memory ecoData = _getValidEcoData(); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); } function initiateSwapAndBridgeTxWithFacet(bool) internal override { EcoFacet.EcoData memory ecoData = _getValidEcoData(); - ecoFacet.swapAndStartBridgeTokensViaEco(bridgeData, swapData, ecoData); + _swapAndStartEco(bridgeData, swapData, ecoData); + } + + /// @dev Signs the eco data with the backend key and calls the bridge + /// entrypoint. Signing is a cheatcode call, so it does not interfere + /// with a preceding `vm.expectRevert`/`vm.expectEmit`. + function _startEco( + ILiFi.BridgeData memory _bridgeData, + EcoFacet.EcoData memory _ecoData + ) internal { + _ecoData.signature = _signEcoData(_bridgeData, _ecoData); + ecoFacet.startBridgeTokensViaEco(_bridgeData, _ecoData); + } + + function _swapAndStartEco( + ILiFi.BridgeData memory _bridgeData, + LibSwap.SwapData[] memory _swapData, + EcoFacet.EcoData memory _ecoData + ) internal { + _ecoData.signature = _signEcoData(_bridgeData, _ecoData); + ecoFacet.swapAndStartBridgeTokensViaEco( + _bridgeData, + _swapData, + _ecoData + ); } function testRevert_WhenUsingInvalidConfig() public { vm.expectRevert(InvalidConfig.selector); - new EcoFacet(IEcoPortal(address(0))); + new EcoFacet(IEcoPortal(address(0)), backendSignerAddress); + } + + function testRevert_WhenBackendSignerIsZero() public { + vm.expectRevert(InvalidConfig.selector); + new EcoFacet(IEcoPortal(PORTAL), address(0)); } function testRevert_NativeTokenNotSupported() public { @@ -104,12 +142,14 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); // Should revert when trying to bridge native tokens vm.expectRevert(NativeAssetNotSupported.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -159,7 +199,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); dai.approve(_facetTestContractAddress, swapData[0].fromAmount); @@ -329,7 +371,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: solanaEncodedRoute, solanaATA: 0x8f37c499ccbb92cefe5acc2f7aa22edf71d4237d4817e55671c7962b449e79f2, - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -348,7 +392,7 @@ contract EcoFacetTest is TestBaseFacet { emit LiFiTransferStarted(bridgeData); // Execute bridge - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -356,11 +400,12 @@ contract EcoFacetTest is TestBaseFacet { function test_BridgeToTron() public { vm.startPrank(USER_SENDER); - // Set up bridge data for Tron + // Tron follows the non-EVM convention: sentinel receiver + the real + // recipient carried in nonEVMReceiver and cross-checked against the route bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; - bridgeData.receiver = USER_RECEIVER; // Can use regular address for Tron + bridgeData.receiver = NON_EVM_ADDRESS; - // Tron is EVM-compatible, so use the same Route struct encoding + // Tron uses the same Route struct encoding as EVM chains bytes memory tronEncodedRoute = _createEncodedRoute( USER_RECEIVER, bridgeData.sendingAssetId, @@ -368,24 +413,372 @@ contract EcoFacetTest is TestBaseFacet { ); EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ - nonEVMReceiver: "", + nonEVMReceiver: abi.encode(USER_RECEIVER), prover: address(0x1234), rewardDeadline: uint64(block.timestamp + 2 days), - encodedRoute: tronEncodedRoute, // Properly encoded Route struct + encodedRoute: tronEncodedRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; usdc.approve(_facetTestContractAddress, bridgeData.minAmount); - // Expect event + vm.expectEmit(true, true, true, true, _facetTestContractAddress); + emit BridgeToNonEVMChainBytes32( + bridgeData.transactionId, + bridgeData.destinationChainId, + bytes32(uint256(uint160(USER_RECEIVER))) + ); + vm.expectEmit(true, true, true, true, _facetTestContractAddress); emit LiFiTransferStarted(bridgeData); - // Execute bridge - route validation will check the transfer - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_TronReceiverMismatch() public { + vm.startPrank(USER_SENDER); + + bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; + bridgeData.receiver = NON_EVM_ADDRESS; + + // Route pays USER_RECEIVER but nonEVMReceiver points at a different address + bytes memory tronEncodedRoute = _createEncodedRoute( + USER_RECEIVER, + bridgeData.sendingAssetId, + bridgeData.minAmount + ); + + EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ + nonEVMReceiver: abi.encode(address(0x9999)), + prover: address(0x1234), + rewardDeadline: uint64(block.timestamp + 2 days), + encodedRoute: tronEncodedRoute, + solanaATA: bytes32(0), + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" + }); + + bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; + + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidReceiver.selector); + _startEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_TronWithInvalidNonEVMReceiverLength() public { + vm.startPrank(USER_SENDER); + + bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; + bridgeData.receiver = NON_EVM_ADDRESS; + + bytes memory tronEncodedRoute = _createEncodedRoute( + USER_RECEIVER, + bridgeData.sendingAssetId, + bridgeData.minAmount + ); + + EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ + nonEVMReceiver: abi.encodePacked(USER_RECEIVER), // 20 bytes, not 32 + prover: address(0x1234), + rewardDeadline: uint64(block.timestamp + 2 days), + encodedRoute: tronEncodedRoute, + solanaATA: bytes32(0), + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" + }); + + bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; + + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidReceiver.selector); + _startEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_TronWithNonLeftPaddedNonEVMReceiver() public { + vm.startPrank(USER_SENDER); + + bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; + bridgeData.receiver = NON_EVM_ADDRESS; + + // Route pays USER_RECEIVER; nonEVMReceiver carries the same address in its + // low 20 bytes but with dirty high bytes. The receiver emitted on-chain + // must equal the validated one, so a non-left-padded value is rejected. + bytes memory tronEncodedRoute = _createEncodedRoute( + USER_RECEIVER, + bridgeData.sendingAssetId, + bridgeData.minAmount + ); + + bytes32 dirtyReceiver = bytes32( + (uint256(1) << 160) | uint256(uint160(USER_RECEIVER)) + ); + + EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ + nonEVMReceiver: abi.encodePacked(dirtyReceiver), + prover: address(0x1234), + rewardDeadline: uint64(block.timestamp + 2 days), + encodedRoute: tronEncodedRoute, + solanaATA: bytes32(0), + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" + }); + + bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; + + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert( + abi.encodeWithSelector( + LibBytes.NotAnAddress.selector, + dirtyReceiver + ) + ); + _startEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_TronWithZeroReceiver() public { + vm.startPrank(USER_SENDER); + + bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; + bridgeData.receiver = NON_EVM_ADDRESS; + + // A zero non-EVM receiver must be rejected, mirroring the EVM path where + // validateBridgeData already rejects a zero bridgeData.receiver. + bytes memory tronEncodedRoute = _createEncodedRoute( + address(0), + bridgeData.sendingAssetId, + bridgeData.minAmount + ); + + EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ + nonEVMReceiver: abi.encode(address(0)), + prover: address(0x1234), + rewardDeadline: uint64(block.timestamp + 2 days), + encodedRoute: tronEncodedRoute, + solanaATA: bytes32(0), + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" + }); + + bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; + + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidNonEVMReceiver.selector); + _startEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_TronWithNonTransferFinalCall() public { + vm.startPrank(USER_SENDER); + + bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; + bridgeData.receiver = NON_EVM_ADDRESS; + + // Route whose final call is not a transfer(address,uint256); the receiver + // decode must reject it instead of reading a bogus address. + IEcoPortal.TokenAmount[] memory tokens = new IEcoPortal.TokenAmount[]( + 1 + ); + tokens[0] = IEcoPortal.TokenAmount({ + token: bridgeData.sendingAssetId, + amount: bridgeData.minAmount + }); + + EcoFacet.Call[] memory calls = new EcoFacet.Call[](1); + calls[0] = EcoFacet.Call({ + target: bridgeData.sendingAssetId, + callData: abi.encodeWithSelector( + IERC20.approve.selector, + USER_RECEIVER, + bridgeData.minAmount + ) + }); + + EcoFacet.Route memory route = EcoFacet.Route({ + salt: keccak256("eco.route.badselector"), + deadline: uint64(block.timestamp + 1 days), + portal: PORTAL, + nativeAmount: 0, + tokens: tokens, + calls: calls + }); + + EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ + nonEVMReceiver: abi.encode(USER_RECEIVER), + prover: address(0x1234), + rewardDeadline: uint64(block.timestamp + 2 days), + encodedRoute: abi.encode(route), + solanaATA: bytes32(0), + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" + }); + + bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; + + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidReceiver.selector); + _startEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_TronWithShortFinalCallData() public { + vm.startPrank(USER_SENDER); + + bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; + bridgeData.receiver = NON_EVM_ADDRESS; + + // Final call carries the transfer selector but only 36 bytes of calldata + // (selector + recipient word, missing the amount word); the receiver + // decode must reject it via the length guard before reading the address. + IEcoPortal.TokenAmount[] memory tokens = new IEcoPortal.TokenAmount[]( + 1 + ); + tokens[0] = IEcoPortal.TokenAmount({ + token: bridgeData.sendingAssetId, + amount: bridgeData.minAmount + }); + + EcoFacet.Call[] memory calls = new EcoFacet.Call[](1); + calls[0] = EcoFacet.Call({ + target: bridgeData.sendingAssetId, + callData: abi.encodeWithSelector( + IERC20.transfer.selector, + USER_RECEIVER + ) + }); + + EcoFacet.Route memory route = EcoFacet.Route({ + salt: keccak256("eco.route.shortcalldata"), + deadline: uint64(block.timestamp + 1 days), + portal: PORTAL, + nativeAmount: 0, + tokens: tokens, + calls: calls + }); + + EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ + nonEVMReceiver: abi.encode(USER_RECEIVER), + prover: address(0x1234), + rewardDeadline: uint64(block.timestamp + 2 days), + encodedRoute: abi.encode(route), + solanaATA: bytes32(0), + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" + }); + + bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; + + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidReceiver.selector); + _startEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_TronWithEmptyRouteCalls() public { + vm.startPrank(USER_SENDER); + + bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; + bridgeData.receiver = NON_EVM_ADDRESS; + + // Route decodes successfully but has no calls; the receiver decode must + // reject it with InvalidReceiver rather than underflowing calls.length. + IEcoPortal.TokenAmount[] memory tokens = new IEcoPortal.TokenAmount[]( + 1 + ); + tokens[0] = IEcoPortal.TokenAmount({ + token: bridgeData.sendingAssetId, + amount: bridgeData.minAmount + }); + + EcoFacet.Call[] memory calls = new EcoFacet.Call[](0); + + EcoFacet.Route memory route = EcoFacet.Route({ + salt: keccak256("eco.route.empty"), + deadline: uint64(block.timestamp + 1 days), + portal: PORTAL, + nativeAmount: 0, + tokens: tokens, + calls: calls + }); + + EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ + nonEVMReceiver: abi.encode(USER_RECEIVER), + prover: address(0x1234), + rewardDeadline: uint64(block.timestamp + 2 days), + encodedRoute: abi.encode(route), + solanaATA: bytes32(0), + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" + }); + + bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; + + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidReceiver.selector); + _startEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_TronWithEVMReceiver() public { + vm.startPrank(USER_SENDER); + + // A concrete receiver is not allowed for Tron; it must use the sentinel + bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; + bridgeData.receiver = USER_RECEIVER; + + bytes memory tronEncodedRoute = _createEncodedRoute( + USER_RECEIVER, + bridgeData.sendingAssetId, + bridgeData.minAmount + ); + + EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ + nonEVMReceiver: "", + prover: address(0x1234), + rewardDeadline: uint64(block.timestamp + 2 days), + encodedRoute: tronEncodedRoute, + solanaATA: bytes32(0), + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" + }); + + bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; + + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + vm.expectRevert(InvalidReceiver.selector); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -403,7 +796,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: "", // Missing encodedRoute (now required for all chains) solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -411,7 +806,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidConfig.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -433,7 +828,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: hex"0102030405060708090a0b0c0d0e0f10", solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -443,7 +840,7 @@ contract EcoFacetTest is TestBaseFacet { // Expect InvalidReceiver revert vm.expectRevert(InvalidReceiver.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -471,7 +868,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: routeWithWrongReceiver, // Route has different receiver solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -481,7 +880,7 @@ contract EcoFacetTest is TestBaseFacet { // Expect InvalidReceiver revert from line 291 vm.expectRevert(InvalidReceiver.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -505,7 +904,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); overflowBridgeData.minAmount = @@ -515,7 +916,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, overflowBridgeData.minAmount); vm.expectRevert(InvalidConfig.selector); - ecoFacet.startBridgeTokensViaEco(overflowBridgeData, ecoData); + _startEco(overflowBridgeData, ecoData); vm.stopPrank(); } @@ -540,7 +941,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); boundaryBridgeData.minAmount = @@ -549,7 +952,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, boundaryBridgeData.minAmount); - ecoFacet.startBridgeTokensViaEco(boundaryBridgeData, ecoData); + _startEco(boundaryBridgeData, ecoData); vm.stopPrank(); } @@ -570,7 +973,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: invalidRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -579,7 +984,7 @@ contract EcoFacetTest is TestBaseFacet { // Will revert during ABI decode attempt vm.expectRevert(); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -598,7 +1003,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: tooShortRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -607,7 +1014,7 @@ contract EcoFacetTest is TestBaseFacet { // Will revert during ABI decode attempt vm.expectRevert(); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -615,21 +1022,22 @@ contract EcoFacetTest is TestBaseFacet { function testRevert_TronWithInvalidRoute() public { vm.startPrank(USER_SENDER); - // Set up bridge data for Tron (which is an EVM-compatible chain in this context) bridgeData.destinationChainId = LIFI_CHAIN_ID_TRON; - bridgeData.receiver = USER_RECEIVER; + bridgeData.receiver = NON_EVM_ADDRESS; // Create data that cannot be ABI decoded as a Route struct bytes memory invalidTronRoute = hex"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; // [pre-commit-checker: not a secret] EcoFacet.EcoData memory ecoData = EcoFacet.EcoData({ - nonEVMReceiver: "", + nonEVMReceiver: abi.encode(USER_RECEIVER), prover: address(0x1234), rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: invalidTronRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -638,7 +1046,7 @@ contract EcoFacetTest is TestBaseFacet { // Will revert during ABI decode attempt vm.expectRevert(); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -661,7 +1069,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -671,6 +1081,86 @@ contract EcoFacetTest is TestBaseFacet { vm.expectEmit(true, true, true, true, _facetTestContractAddress); emit LiFiTransferStarted(bridgeData); + _startEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_SignatureFromUnauthorizedSigner() public { + vm.startPrank(USER_SENDER); + + bridgeData.minAmount = defaultUSDCAmount + TOKEN_SOLVER_REWARD; + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + EcoFacet.EcoData memory ecoData = _getValidEcoData(); + uint256 unauthorizedKey = 0xBAD5169; + ecoData.signature = _signEcoDataWith( + unauthorizedKey, + bridgeData, + ecoData + ); + + vm.expectRevert(InvalidSignature.selector); + ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + function testRevert_SignatureExpired() public { + vm.startPrank(USER_SENDER); + + bridgeData.minAmount = defaultUSDCAmount + TOKEN_SOLVER_REWARD; + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + EcoFacet.EcoData memory ecoData = _getValidEcoData(); + ecoData.deadline = block.timestamp - 1; + ecoData.signature = _signEcoData(bridgeData, ecoData); + + vm.expectRevert(EcoFacet.SignatureExpired.selector); + ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + // The signature commits to keccak256(encodedRoute), so a caller cannot swap + // in a different route after signing - even one that still pays the correct + // receiver and would pass the on-chain receiver cross-check (audit finding: + // unverified route contents). + function testRevert_TamperedEncodedRoute() public { + vm.startPrank(USER_SENDER); + + bridgeData.minAmount = defaultUSDCAmount + TOKEN_SOLVER_REWARD; + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + EcoFacet.EcoData memory ecoData = _getValidEcoData(); + ecoData.signature = _signEcoData(bridgeData, ecoData); + + ecoData.encodedRoute = _createEncodedRoute( + USER_RECEIVER, + bridgeData.sendingAssetId, + bridgeData.minAmount + 1 + ); + + vm.expectRevert(InvalidSignature.selector); + ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + + vm.stopPrank(); + } + + // The signature commits to the prover, so a caller cannot swap in a + // malicious prover after signing (audit finding: caller-supplied prover). + function testRevert_TamperedProver() public { + vm.startPrank(USER_SENDER); + + bridgeData.minAmount = defaultUSDCAmount + TOKEN_SOLVER_REWARD; + usdc.approve(_facetTestContractAddress, bridgeData.minAmount); + + EcoFacet.EcoData memory ecoData = _getValidEcoData(); + ecoData.signature = _signEcoData(bridgeData, ecoData); + + ecoData.prover = address(0xDEAD); + + vm.expectRevert(InvalidSignature.selector); ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); vm.stopPrank(); @@ -696,7 +1186,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -704,7 +1196,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidReceiver.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -724,7 +1216,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: solanaRoute, solanaATA: bytes32(uint256(1)), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -732,7 +1226,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidReceiver.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -757,7 +1251,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: solanaRoute, solanaATA: bytes32(uint256(1)), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -765,7 +1261,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidReceiver.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -787,7 +1283,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: tooShortRoute, solanaATA: bytes32(uint256(1)), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -795,7 +1293,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidReceiver.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -821,7 +1319,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: solanaEncodedRoute, solanaATA: bytes32(0), // Set to zero - should revert - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -832,7 +1332,7 @@ contract EcoFacetTest is TestBaseFacet { vm.expectRevert(InvalidConfig.selector); // Execute bridge - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -858,7 +1358,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: solanaEncodedRoute, solanaATA: bytes32(uint256(0x123456789abcdef)), // Different ATA that doesn't match the route - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -869,7 +1371,7 @@ contract EcoFacetTest is TestBaseFacet { vm.expectRevert(InvalidReceiver.selector); // Execute bridge - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -889,7 +1391,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: address(0) + refundRecipient: address(0), + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -897,7 +1401,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidConfig.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -919,7 +1423,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: customRefundRecipient + refundRecipient: customRefundRecipient, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -955,7 +1461,7 @@ contract EcoFacetTest is TestBaseFacet { vm.expectEmit(true, true, true, true, _facetTestContractAddress); emit LiFiTransferStarted(bridgeData); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -1014,7 +1520,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: encodedRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); } @@ -1091,10 +1599,12 @@ contract EcoFacetTest is TestBaseFacet { bridgeData.minAmount ), solanaATA: bytes32(0), - refundRecipient: refundRecipient + refundRecipient: refundRecipient, + deadline: block.timestamp + 1 hours, + signature: "" }); - ecoFacet.swapAndStartBridgeTokensViaEco(bridgeData, swapData, ecoData); + _swapAndStartEco(bridgeData, swapData, ecoData); uint256 refundRecipientBalanceAfter = usdc.balanceOf(refundRecipient); @@ -1122,7 +1632,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -1130,7 +1642,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidConfig.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -1150,7 +1662,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp - 1), encodedRoute: validRoute, solanaATA: bytes32(0), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -1158,7 +1672,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidConfig.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -1181,7 +1695,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: validRoute, solanaATA: bytes32(uint256(1)), - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -1189,7 +1705,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidConfig.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } @@ -1211,7 +1727,9 @@ contract EcoFacetTest is TestBaseFacet { rewardDeadline: uint64(block.timestamp + 2 days), encodedRoute: solanaEncodedRoute, solanaATA: 0x8f37c499ccbb92cefe5acc2f7aa22edf71d4237d4817e55671c7962b449e79f2, - refundRecipient: USER_SENDER + refundRecipient: USER_SENDER, + deadline: block.timestamp + 1 hours, + signature: "" }); bridgeData.minAmount = bridgeData.minAmount + TOKEN_SOLVER_REWARD; @@ -1219,7 +1737,7 @@ contract EcoFacetTest is TestBaseFacet { usdc.approve(_facetTestContractAddress, bridgeData.minAmount); vm.expectRevert(InvalidReceiver.selector); - ecoFacet.startBridgeTokensViaEco(bridgeData, ecoData); + _startEco(bridgeData, ecoData); vm.stopPrank(); } diff --git a/test/solidity/utils/TestEcoBackendSig.sol b/test/solidity/utils/TestEcoBackendSig.sol new file mode 100644 index 000000000..8181311e5 --- /dev/null +++ b/test/solidity/utils/TestEcoBackendSig.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; + +import { ILiFi } from "lifi/Interfaces/ILiFi.sol"; +import { EcoFacet } from "lifi/Facets/EcoFacet.sol"; +import { TestEIP712 } from "./TestEIP712.sol"; + +/// @title TestEcoBackendSig +/// @notice Payload-specific backend EIP-712 signature helpers for `EcoFacet` tests. +abstract contract TestEcoBackendSig is TestEIP712 { + // EIP-712 typehash for EcoPayload: + // keccak256("EcoPayload(bytes32 transactionId,address sendingAssetId,uint256 minAmount,uint256 destinationChainId,address receiver,bytes32 nonEVMReceiverHash,bytes32 encodedRouteHash,address prover,address refundRecipient,uint64 rewardDeadline,bytes32 solanaATA,uint256 deadline)") + bytes32 internal constant ECO_PAYLOAD_TYPEHASH = + 0xa3243df568679887ffddc8c7d34cf0bd57b0a8d9430c7044d28def7369fd7881; + + string internal constant ECO_DOMAIN_NAME = "LI.FI Eco Facet"; + string internal constant ECO_EIP712_VERSION = "1"; + + /// @dev Diamond address (the verifyingContract used by the facet via delegatecall). + address internal ecoVerifyingContract; + + /// @dev Backend signer private key and derived address (configured in `setUp()`). + uint256 internal backendSignerPrivateKey; + address internal backendSignerAddress; + + function _buildEcoStructHash( + ILiFi.BridgeData memory _bridgeData, + EcoFacet.EcoData memory _ecoData + ) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + ECO_PAYLOAD_TYPEHASH, + _bridgeData.transactionId, + _bridgeData.sendingAssetId, + _bridgeData.minAmount, + _bridgeData.destinationChainId, + _bridgeData.receiver, + keccak256(_ecoData.nonEVMReceiver), + keccak256(_ecoData.encodedRoute), + _ecoData.prover, + _ecoData.refundRecipient, + _ecoData.rewardDeadline, + _ecoData.solanaATA, + _ecoData.deadline + ) + ); + } + + /// @dev Signs the EcoPayload derived from the given bridge and eco data with + /// the configured backend signer key. Reads `_ecoData.deadline`, so set + /// it before calling. + function _signEcoData( + ILiFi.BridgeData memory _bridgeData, + EcoFacet.EcoData memory _ecoData + ) internal view returns (bytes memory) { + return + _signEcoDataWith(backendSignerPrivateKey, _bridgeData, _ecoData); + } + + /// @dev Same as `_signEcoData` but signs with an arbitrary key (used to test + /// signatures from an unauthorized signer). + function _signEcoDataWith( + uint256 _privateKey, + ILiFi.BridgeData memory _bridgeData, + EcoFacet.EcoData memory _ecoData + ) internal view returns (bytes memory) { + bytes32 domainSeparatorHash = _domainSeparator( + ECO_DOMAIN_NAME, + ECO_EIP712_VERSION, + block.chainid, + ecoVerifyingContract + ); + bytes32 structHash = _buildEcoStructHash(_bridgeData, _ecoData); + bytes32 digestHash = _digest(domainSeparatorHash, structHash); + return _signDigest(_privateKey, digestHash); + } +}