Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
faa0b67
fix(EcoFacet): route Tron via non-EVM receiver convention (EXSC-755)
gvladika Aug 11, 2026
6137ee1
docs(EcoFacet): document Tron non-EVM receiver convention (EXSC-755)
gvladika Aug 12, 2026
dde37ed
Merge remote-tracking branch 'origin/main' into feature/exsc-755-fix-…
gvladika Aug 12, 2026
7e94783
fix(EcoFacet): validate full Tron nonEVMReceiver via checked cast
gvladika Aug 13, 2026
3c6e73c
refactor(EcoFacet): hoist Tron guards inline to match Solana branch
gvladika Aug 13, 2026
52d0489
fix(EcoFacet): reject zero Tron receiver and non-transfer route calls
gvladika Aug 13, 2026
f97beb2
fix(EcoFacet): guard empty route calls and mask decoded receiver
gvladika Aug 13, 2026
f53e968
chore(deploy): register EcoFacet v1.2.0 on base staging
gvladika Aug 13, 2026
06e4c88
test(EcoFacet): cover short final-call length guard for Tron
gvladika Aug 17, 2026
c86df08
Merge branch 'main' into feature/exsc-755-fix-ecofacet-tron-destinati…
gvladika Aug 17, 2026
b0db29d
feat(EcoFacet): gate opaque route and prover behind backend EIP-712 s…
gvladika Aug 20, 2026
c7445f0
chore(deploy): register EcoFacet v2.0.0 on arbitrum + base staging
gvladika Aug 20, 2026
fbcfccd
Merge remote-tracking branch 'origin/main' into merge-main-into-2191
gvladika Sep 3, 2026
55d0ecb
docs(audit): add EcoFacet v1.2.0 audit report (EXSC-755)
gvladika Sep 3, 2026
72972a9
chore: regenerate clearSigningProposal for EcoFacet v2.0.0 (EXSC-755)
gvladika Sep 3, 2026
0ef9714
fix(audit): map EcoFacet audit to v2.0.0 (EXSC-755)
gvladika Sep 3, 2026
e209897
chore(audit): rename EcoFacet report to v2.0.0 to match log (EXSC-755)
gvladika Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion audit/auditLog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"]
Expand Down
Binary file added audit/reports/2026.09.02_EcoFacet(v2.0.0).pdf
Binary file not shown.
4 changes: 2 additions & 2 deletions config/clearSigningProposal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
4 changes: 4 additions & 0 deletions deployments/arbitrum.diamond.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@
"Name": "LiFiIntentEscrowFacetV2",
"Version": "1.0.0"
},
"0x4024f93ac23e00EfC85371fe0a69922C82EbdF88": {
"Name": "EcoFacet",
"Version": "2.0.0"
},
"0x401Aaf6A650A17677821E665838367EBD5bE77BD": {
"Name": "FraxFacet",
"Version": "1.0.0"
Expand Down
1 change: 1 addition & 0 deletions deployments/arbitrum.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"AcrossV4SwapFacet": "0x2B8F8C0Cf809EdbbD1257B4c4f27FdAe318062b1",
"ReceiverOIF": "0x6c75016288d838ED1a34BDc9bf84f24d5dd88363",
"WhitelistRecoveryFacet": "0xd35fC069dcBD50780c706750A41AfC418f25f5D6",
"EcoFacet": "0x4024f93ac23e00EfC85371fe0a69922C82EbdF88",
"LayerSwapFacet": "0x6FB56233842489D772b9Bb004390abF317Efa390",
"FeeForwarder": "0x7C1923FbD89154156DE2220870C834B141bA8c49",
"SupersetFacet": "0xD04A7f8EdB8F4C1dB851bCc919e71dB5E5093fc8",
Expand Down
8 changes: 8 additions & 0 deletions deployments/base.diamond.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions deployments/base.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@
"ReceiverOIF": "0x6c75016288d838ED1a34BDc9bf84f24d5dd88363",
"SupersetFacet": "0x54AEC552Acc3218b0B3991FD61cA12b3A4fD222D",
"LiFiIntentEscrowFacetV2": "0x1aAADa7ab2BD56de2482d46213AB0994a0C7ff9b",
"EcoFacet": "0x4024f93ac23e00EfC85371fe0a69922C82EbdF88",
"FraxFacet": "0x401Aaf6A650A17677821E665838367EBD5bE77BD"
}
23 changes: 22 additions & 1 deletion docs/EcoFacet.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,17 @@ 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;
uint64 rewardDeadline;
bytes encodedRoute;
bytes32 solanaATA;
address refundRecipient;
uint256 deadline;
bytes signature;
}
```

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
66 changes: 65 additions & 1 deletion script/demoScripts/demoEco.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -547,13 +556,68 @@ 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,
rewardDeadline: BigInt(quote.data.quoteResponse.deadline),
encodedRoute: encodedRoute,
solanaATA: solanaATA,
refundRecipient: signerAddress,
deadline: signatureDeadline,
signature: backendSignature,
}

// === Ensure allowance ===
Expand Down
17 changes: 16 additions & 1 deletion script/deploy/facets/DeployEcoFacet.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
14 changes: 14 additions & 0 deletions script/deploy/resources/deployRequirements.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,20 @@
}
}
},
"EcoFacet": {
"configData": {
"_portal": {
"configFileName": "eco.json",
"keyInConfigFile": ".<NETWORK>.portal",
"allowToDeployWithZeroAddress": "false"
},
"_backendSigner": {
"configFileName": "global.json",
"keyInConfigFile": ".backendSigner.<ENVIRONMENT>",
"allowToDeployWithZeroAddress": "false"
}
}
},
"FraxFacet": {
"configData": {
"_hop": {
Expand Down
14 changes: 13 additions & 1 deletion script/deploy/tron/deploy-and-register-eco-facet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down Expand Up @@ -136,7 +148,7 @@ async function deployAndRegisterEcoFacet(options: { dryRun?: boolean }) {
})
} else
try {
const constructorArgs = [portal]
const constructorArgs = [portal, backendSigner]

const result = await deployContractWithLogging(
deployer,
Expand Down
Loading
Loading