Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion audit/auditLog.json
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,13 @@
"auditorGitHandle": "sujithsomraaj",
"auditReportPath": "./audit/reports/2025.12.31_TokenWrapper(v1.2.1).pdf",
"auditCommitHash": "a0f5a83d5f42b304474fa040526ce98ea3cea5e6"
},
"audit20260130": {
"auditCompletedOn": "30.01.2026",
"auditedBy": "Sujith Somraaj (individual security researcher)",
"auditorGitHandle": "sujithsomraaj",
"auditReportPath": "./audit/reports/2026.01.30_LiFiIntentEscrowFacet(v1.1.0).pdf",
"auditCommitHash": "151a2a3308ae544a493a5a778502b6a7b7bbf2aa"
}
},
"auditedContracts": {
Expand Down Expand Up @@ -748,7 +755,8 @@
"1.12.0": ["audit20250728"]
},
"LiFiIntentEscrowFacet": {
"1.0.0": ["audit20251119"]
"1.0.0": ["audit20251119"],
"1.1.0": ["audit20260130"]
},
"LiFiTimelockController": {
"1.0.0": ["audit20250110_2", "audit20250508"],
Expand Down
Binary file not shown.
36 changes: 21 additions & 15 deletions docs/LiFiIntentEscrowFacet.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## How it works

LI.FI Intent Escrow uses a built in escrow as a deposit mechanism for its intents. The LI.FI Intent Escrow Facet deposits into the Escrow Input Settler, which will release the deposited funds to the solver when the fill has been proven. The system is highly self serve, with the facet wrapping the deposit logic to ensure the appropriate parameters are called for the user to receive their output.
LI.FI Intent Escrow uses a built-in escrow as a deposit mechanism for its intents. The LI.FI Intent Escrow Facet deposits into the Escrow Input Settler, which will release the deposited funds to the solver when the fill has been proven. The system is self-serve, with the facet wrapping the deposit logic to ensure the appropriate parameters are called for the user to receive their output.

```mermaid
graph LR;
Expand Down Expand Up @@ -31,26 +31,32 @@ graph LR;

## Destination Calls

The facet supports destination calls through the `outputCall` parameter in `LiFiIntentEscrowData`. When `outputCall` contains calldata (length > 0), it will be executed on the destination chain after token delivery to the `receiverAddress`. The `BridgeData.hasDestinationCall` flag must be set to `true` when providing `outputCall` data, and `false` when no destination call is intended.
The LI.FI intent facet supports destination swaps using the periphery contract `ReceiverOIF`.
Destination swaps require configuring `.dstCallReceiver` to an instance of `ReceiverOIF` and `dstCallSwapData` as a list of SwapData. When `dstCallSwapData.length` > 0, the recipient will be replaced with `.dstCallReceiver` and instead encoded in data to be executed by `ReceiverOIF`. The `BridgeData.hasDestinationCall` flag must be set to `true`.

## LIFIIntent Specific Parameters

The methods listed above take a variable labeled `_lifiIntentData`. This data is specific to LIFIIntent and is represented as the following struct type:

```solidity
/// @param receiverAddress The destination account for the delivered assets and calldata.
/// @param depositAndRefundAddress The deposit and claim registration will be made for. If any refund is made, it will be sent to this address.
/// @param expires If the proof for the fill does not arrive before this time, the claim expires.
/// @param fillDeadline The fill has to happen before this time.
/// @param inputOracle Address of the validation layer used on the input chain.
/// @param outputOracle Address of the validation layer used on the output chain.
/// @param outputSettler Address of the output settlement contract containing the fill logic.
/// @param outputToken The desired destination token.
/// @param outputAmount The amount of the desired token.
/// @param outputCall Calldata to be executed after the token has been delivered. Is called on receiverAddress. if set to 0x / hex"" no call is made.
/// @param outputContext Context for the outputSettler to identify the order type.
/// @param dstCallReceiver If dstCallSwapData.length > 0, has to be provided as a deployment of `ReceiverOIF`. Otherwise ignored.
/// @param recipient The end recipient of the swap. If no calldata is included, will be a simple recipient, otherwise it will be encoded as the end destination for the swaps.
/// @param depositAndRefundAddress The deposit and claim registration will be made for. If any refund is made, it will be sent to this address
/// @param nonce OrderId mixer. Used within the intent system to generate unique orderIds for each user. Should not be reused for `depositAndRefundAddress`
/// @param expires If the proof for the fill does not arrive before this time, the claim expires
/// @param fillDeadline The fill has to happen before this time
/// @param inputOracle Address of the validation layer used on the input chain
/// @param outputOracle Address of the validation layer used on the output chain
/// @param outputSettler Address of the output settlement contract containing the fill logic
/// @param outputToken The desired destination token
/// @param outputAmount The amount of the desired token
/// @param dstCallSwapData List of swaps to be executed on the destination chain. Is called on dstCallReceiver. If empty no call is made.
/// @param outputContext Context for the outputSettler to identify the order type
struct LiFiIntentEscrowData {
bytes32 receiverAddress; // StandardOrder.outputs.recipient
// Goes into StandardOrder.outputs.recipient if .dstCallSwapData.length > 0
bytes32 dstCallReceiver;
// Goes into StandardOrder.outputs.recipient if .dstCallSwapData.length == 0
bytes32 recipient;
/// BatchClaim
address depositAndRefundAddress; // StandardOrder.user
uint256 nonce; // StandardOrder.nonce
Expand All @@ -61,7 +67,7 @@ struct LiFiIntentEscrowData {
bytes32 outputSettler; // StandardOrder.outputs.settler
bytes32 outputToken; // StandardOrder.outputs.token
uint256 outputAmount; // StandardOrder.outputs.amount
bytes outputCall; // StandardOrder.outputs.call
LibSwap.SwapData[] dstCallSwapData; // Goes into StandardOrder.outputs.callbackData
bytes outputContext; // StandardOrder.outputs.context
}
```
Expand Down
89 changes: 60 additions & 29 deletions src/Facets/LiFiIntentEscrowFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,35 +19,43 @@ import { IOriginSettler } from "../Interfaces/IOriginSettler.sol";
/// @title LiFiIntentEscrowFacet
/// @author LI.FI (https://li.fi)
/// @notice Deposits and registers claims directly on a OIF Input Settler
/// @custom:version 1.0.0
/// @custom:version 1.1.0
contract LiFiIntentEscrowFacet is
ILiFi,
ReentrancyGuard,
SwapperV2,
Validatable,
LiFiData
{
/// Errors ///

error InvalidDepositAndRefundAddress();

/// Storage ///

/// @dev LIFI Intent Escrow Input Settler
address public immutable LIFI_INTENT_ESCROW_SETTLER;

/// Types ///

/// @param receiverAddress The destination account for the delivered assets and calldata
/// @param dstCallReceiver If dstCallSwapData.length > 0, has to be provided as a deployment of `ReceiverOIF`. Otherwise ignored.
/// @param recipient The end recipient of the swap. If no calldata is included, will be a simple recipient, otherwise it will be encoded as the end destination for the swaps.
/// @param depositAndRefundAddress The deposit and claim registration will be made for. If any refund is made, it will be sent to this address
/// @param nonce OrderId mixer. Used within the intent system to generate unqiue orderIds for each user. Should not be reused for `depositAndRefundAddress`
/// @param nonce OrderId mixer. Used within the intent system to generate unique orderIds for each user. Should not be reused for `depositAndRefundAddress`
/// @param expires If the proof for the fill does not arrive before this time, the claim expires
/// @param fillDeadline The fill has to happen before this time
/// @param inputOracle Address of the validation layer used on the input chain
/// @param outputOracle Address of the validation layer used on the output chain
/// @param outputSettler Address of the output settlement contract containing the fill logic
/// @param outputToken The desired destination token
/// @param outputAmount The amount of the desired token
/// @param outputCall Calldata to be executed after the token has been delivered. Is called on receiverAddress. if set to 0x / hex"" no call is made
/// @param dstCallSwapData List of swaps to be executed on the destination chain. Is called on dstCallReceiver. If empty no call is made.
/// @param outputContext Context for the outputSettler to identify the order type
struct LiFiIntentEscrowData {
bytes32 receiverAddress; // StandardOrder.outputs.recipient
// Goes into StandardOrder.outputs.recipient if .dstCallSwapData.length > 0
bytes32 dstCallReceiver;
// Goes into StandardOrder.outputs.recipient if .dstCallSwapData.length == 0
bytes32 recipient;
/// BatchClaim
address depositAndRefundAddress; // StandardOrder.user
uint256 nonce; // StandardOrder.nonce
Expand All @@ -58,7 +66,7 @@ contract LiFiIntentEscrowFacet is
bytes32 outputSettler; // StandardOrder.outputs.settler
bytes32 outputToken; // StandardOrder.outputs.token
uint256 outputAmount; // StandardOrder.outputs.amount
bytes outputCall; // StandardOrder.outputs.callbackData
LibSwap.SwapData[] dstCallSwapData; // Goes into StandardOrder.outputs.callbackData
bytes outputContext; // StandardOrder.outputs.context
}

Expand All @@ -85,6 +93,8 @@ contract LiFiIntentEscrowFacet is
validateBridgeData(_bridgeData)
doesNotContainSourceSwaps(_bridgeData)
{
if (_lifiIntentData.depositAndRefundAddress == address(0))
revert InvalidDepositAndRefundAddress();
LibAsset.depositAsset(
_bridgeData.sendingAssetId,
_bridgeData.minAmount
Expand All @@ -109,18 +119,23 @@ contract LiFiIntentEscrowFacet is
containsSourceSwaps(_bridgeData)
validateBridgeData(_bridgeData)
{
address depositAndRefundAddress = _lifiIntentData
.depositAndRefundAddress;
if (depositAndRefundAddress == address(0))
revert InvalidDepositAndRefundAddress();

uint256 swapOutcome = _depositAndSwap(
_bridgeData.transactionId,
_bridgeData.minAmount,
_swapData,
payable(msg.sender)
payable(depositAndRefundAddress)
);

// Return positive slippage to user if any
if (swapOutcome > _bridgeData.minAmount) {
LibAsset.transferAsset(
_bridgeData.sendingAssetId,
payable(msg.sender),
payable(depositAndRefundAddress),
swapOutcome - _bridgeData.minAmount
);
}
Expand All @@ -137,30 +152,31 @@ contract LiFiIntentEscrowFacet is
ILiFi.BridgeData memory _bridgeData,
LiFiIntentEscrowData calldata _lifiIntentData
) internal {
uint256 dstCallSwapDataLength = _lifiIntentData.dstCallSwapData.length;
// Validate destination call flag matches actual behavior
if (
(_lifiIntentData.outputCall.length > 0) !=
_bridgeData.hasDestinationCall
) {
if ((dstCallSwapDataLength > 0) != _bridgeData.hasDestinationCall) {
revert InformationMismatch();
}
if (_lifiIntentData.outputAmount == 0) revert InvalidAmount();

// Check if the receiver is the same according to bridgeData and LIFIIntentData
address bridgeDataReceiver = _bridgeData.receiver;
if (bridgeDataReceiver != NON_EVM_ADDRESS) {
if (
_lifiIntentData.receiverAddress !=
bytes32(uint256(uint160(bridgeDataReceiver)))
) {
// We wanna create a "canonical" recipient so we don't have to argue for which one (bridgeData/LIFIIntentData) to use.
bytes32 recipient = _lifiIntentData.recipient;
if (recipient == bytes32(0)) revert InvalidReceiver();
if (_bridgeData.receiver == NON_EVM_ADDRESS) {
// In this case, _bridgeData.receiver is not useful.
emit BridgeToNonEVMChainBytes32(
_bridgeData.transactionId,
_bridgeData.destinationChainId,
recipient
);
} else {
// Check if the receiver is the same according to bridgeData and LIFIIntentData
// Note: We already know 0 <= _bridgeData.receiver < recipient != 0 thus _bridgeData.receiver != 0.
if (recipient != bytes32(uint256(uint160(_bridgeData.receiver)))) {
revert InvalidReceiver();
}
}
if (_lifiIntentData.depositAndRefundAddress == address(0)) revert InvalidReceiver();
if (_lifiIntentData.receiverAddress == bytes32(0)) revert InvalidReceiver();


// Check outputAmount
if (_lifiIntentData.outputAmount == 0) revert InvalidAmount();
address sendingAsset = _bridgeData.sendingAssetId;
// Set approval
uint256 amount = _bridgeData.minAmount;
Expand All @@ -170,9 +186,20 @@ contract LiFiIntentEscrowFacet is
amount
);

// Convert given token and amount into a idsAndAmount array
uint256[2][] memory inputs = new uint256[2][](1);
inputs[0] = [uint256(uint160(sendingAsset)), amount];
bytes memory outputCall = hex"";
if (dstCallSwapDataLength != 0) {
// If we have external calldata, we need to swap out our recipient to the remote caller. We won't be using the recipient anymore so this is without side effects.
recipient = _lifiIntentData.dstCallReceiver;
// Check that _lifiIntentData.dstCallReceiver != 0.
if (recipient == bytes32(0)) revert InvalidReceiver();

// Add swap data to the output call.
outputCall = abi.encode(
_bridgeData.transactionId,
_lifiIntentData.dstCallSwapData,
_lifiIntentData.recipient
);
}

MandateOutput[] memory outputs = new MandateOutput[](1);
outputs[0] = MandateOutput({
Expand All @@ -181,11 +208,15 @@ contract LiFiIntentEscrowFacet is
chainId: _bridgeData.destinationChainId,
token: _lifiIntentData.outputToken,
amount: _lifiIntentData.outputAmount,
recipient: _lifiIntentData.receiverAddress,
callbackData: _lifiIntentData.outputCall,
recipient: recipient,
callbackData: outputCall,
context: _lifiIntentData.outputContext
});

// Convert given token and amount into a idsAndAmount array
uint256[2][] memory inputs = new uint256[2][](1);
inputs[0] = [uint256(uint160(sendingAsset)), amount];

// Make the deposit on behalf of the user
IOriginSettler(LIFI_INTENT_ESCROW_SETTLER).open(
StandardOrder({
Expand Down
Loading
Loading