Skip to content
Merged
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
16 changes: 14 additions & 2 deletions config/clearSigningProposal.json
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,7 @@
}
]
},
"startBridgeTokensViaFrax((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (address oft, uint32 dstEid, uint256 nativeFee, address refundRecipient) _fraxData)": {
"startBridgeTokensViaFrax((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (address oft, uint32 dstEid, uint256 nativeFee, address refundRecipient, bytes32 nonEVMReceiver) _fraxData)": {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"intent": "Bridge via Frax",
"interpolatedIntent": "Bridge {_bridgeData.minAmount} via Frax to chain {_bridgeData.destinationChainId} for {_bridgeData.receiver}",
"fields": [
Expand Down Expand Up @@ -769,6 +769,12 @@
},
"visible": "always"
},
{
"path": "_fraxData.nonEVMReceiver",
"label": "Non-EVM Recipient",
"format": "raw",
"visible": "always"
},
{
"path": "_bridgeData.transactionId",
"label": "Transaction Id",
Expand Down Expand Up @@ -3747,7 +3753,7 @@
}
]
},
"swapAndStartBridgeTokensViaFrax((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit)[] _swapData, (address oft, uint32 dstEid, uint256 nativeFee, address refundRecipient) _fraxData)": {
"swapAndStartBridgeTokensViaFrax((bytes32 transactionId, string bridge, string integrator, address referrer, address sendingAssetId, address receiver, uint256 minAmount, uint256 destinationChainId, bool hasSourceSwaps, bool hasDestinationCall) _bridgeData, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit)[] _swapData, (address oft, uint32 dstEid, uint256 nativeFee, address refundRecipient, bytes32 nonEVMReceiver) _fraxData)": {
"intent": "Swap & Bridge via Frax",
"interpolatedIntent": "Swap then bridge {_bridgeData.minAmount} via Frax to chain {_bridgeData.destinationChainId} for {_bridgeData.receiver}",
"fields": [
Expand Down Expand Up @@ -3791,6 +3797,12 @@
},
"visible": "always"
},
{
"path": "_fraxData.nonEVMReceiver",
"label": "Non-EVM Recipient",
"format": "raw",
"visible": "always"
},
{
"path": "_bridgeData.transactionId",
"label": "Transaction Id",
Expand Down
13 changes: 3 additions & 10 deletions config/frax.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
{
"DEV NOTICE": "after adding a new mapping, call initFrax (first deploy, wired into UpdateFraxFacet) or setFraxChainIdToEid (incremental update) on every diamond where FraxFacet is deployed. Every destination chain a route targets MUST be seeded here first (unseeded destinations revert UnsupportedChainId). Source for LayerZero EIDs: https://docs.layerzero.network/v2/deployments/deployed-contracts. \"tipFeeManager\"/\"pathUsd\" are intentionally sparse: they list ONLY chains where the value is non-zero (currently just Tempo). Any chain absent from these maps deploys with address(0) — do NOT pad them with zero entries for other networks (see rule 004-config-structure).",
"DEV NOTICE": "after adding a new mapping, call initFrax (first deploy, wired into UpdateFraxFacet) or setFraxChainIdToEid (incremental update) on every diamond where FraxFacet is deployed. Every destination chain a route targets MUST be seeded here first (unseeded destinations revert UnsupportedChainId). Source for LayerZero EIDs: https://docs.layerzero.network/v2/deployments/deployed-contracts. \"tipFeeManager\"/\"pathUsd\" are intentionally sparse: they list ONLY chains where the value is non-zero (currently just Tempo). Any chain absent from these maps deploys with address(0) — do NOT pad them with zero entries for other networks (see rule 004-config-structure). berachain, mode and scroll are intentionally absent from \"hop\" and \"mappings\": Frax is deprecating them from the hop mesh (confirmed by the Frax team on PR #2048) — do NOT re-add them. Removing a chain here does NOT disable it on diamonds already seeded via initFrax: call unsetFraxChainIdToEid(chainIds) on each of them. chainId 1151111081099710 is LI.FI's Solana chain ID (non-EVM, bytes32 recipient) — routes via the Fraxtal hub to LayerZero EID 30168.",
"hop": {
"mainnet": "0x0000006D38568b00B457580b734e0076C62de659",
"abstract": "0x0000006D38568b00B457580b734e0076C62de659",
"arbitrum": "0x0000006D38568b00B457580b734e0076C62de659",
"avalanche": "0x0000006D38568b00B457580b734e0076C62de659",
"base": "0x0000006D38568b00B457580b734e0076C62de659",
"berachain": "0x0000006D38568b00B457580b734e0076C62de659",
"bsc": "0x0000006D38568b00B457580b734e0076C62de659",
"fraxtal": "0x00000000e18aFc20Afe54d4B2C8688bB60c06B36",
"hyperevm": "0x0000006D38568b00B457580b734e0076C62de659",
"ink": "0x0000006D38568b00B457580b734e0076C62de659",
"katana": "0x0000006D38568b00B457580b734e0076C62de659",
"linea": "0x0000006D38568b00B457580b734e0076C62de659",
"mode": "0x0000006D38568b00B457580b734e0076C62de659",
"monad": "0x0000006D38568b00B457580b734e0076C62de659",
"optimism": "0x0000006D38568b00B457580b734e0076C62de659",
"plume": "0x0000006D38568b00B457580b734e0076C62de659",
"polygon": "0x0000006D38568b00B457580b734e0076C62de659",
"scroll": "0x0000006D38568b00B457580b734e0076C62de659",
"sei": "0x0000006D38568b00B457580b734e0076C62de659",
"somnia": "0x0000006D38568b00B457580b734e0076C62de659",
"sonic": "0x0000006D38568b00B457580b734e0076C62de659",
Expand Down Expand Up @@ -96,10 +93,6 @@
"chainId": 8453,
"lzEid": 30184
},
{
"chainId": 34443,
"lzEid": 30260
},
{
"chainId": 42161,
"lzEid": 30110
Expand All @@ -121,8 +114,8 @@
"lzEid": 30370
},
{
"chainId": 534352,
"lzEid": 30214
"chainId": 1151111081099710,
"lzEid": 30168
}
]
}
2 changes: 1 addition & 1 deletion docs/ClearSigningProposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The extra field is `bytes`/`bytes32` (it may hold a non-EVM address such as a So
Two shapes, keyed by `bridgeFacetName()`:

- **TYPE-A** — the field is the on-chain recipient in **every** branch (always populated). `AcrossV4` (`_acrossData.receiverAddress`) and `DeBridgeDln` (`_deBridgeData.receiver`) can differ from `_bridgeData.receiver` even on EVM (destination call → our Receiver contract; DeBridgeDln never cross-checks its `receiver`), so the field adds signal on EVM too. `Glacis`, `AllBridge`, `LiFiIntentEscrow`(+`V2`), `GasZip` enforce equality on plain EVM transfers, so there the field re-shows the recipient and carries the real value on non-EVM. Labelled `"<Bridge> Recipient"`.
- **TYPE-B** — a `nonEVMReceiver`-style field only set on non-EVM transfers (`0x0` on EVM, where `_bridgeData.receiver` is the faithful recipient): `Mayan`, `LayerSwap`, `Chainflip`, `Eco`, `NEARIntents`, `PolymerCCTP`. Labelled `"Non-EVM Recipient"` so the `0x0` on an EVM transfer reads as "N/A".
- **TYPE-B** — a `nonEVMReceiver`-style field only set on non-EVM transfers (`0x0` on EVM, where `_bridgeData.receiver` is the faithful recipient): `Mayan`, `LayerSwap`, `Chainflip`, `Eco`, `NEARIntents`, `PolymerCCTP`, `Frax`. Labelled `"Non-EVM Recipient"` so the `0x0` on an EVM transfer reads as "N/A".

> **`GasZip` caveat:** its `receiverAddress` is a **right-padded** `bytes32` (`bytes32(bytes20(addr))`), unlike the left-padded convention elsewhere; `raw` shows the address in the high bytes.

Expand Down
103 changes: 89 additions & 14 deletions docs/FraxFacet.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,34 @@ specific to Frax HopV2 and is represented as the following struct type:
/// @param refundRecipient Address that receives pre-bridge swap leftovers, the dust
/// remainder that HopV2 does not bridge, and any excess native that HopV2 refunds
/// to the diamond mid-call. Must accept plain native transfers.
/// @param nonEVMReceiver The bytes32 recipient on a non-EVM destination (e.g. a Solana
/// pubkey). Required when bridgeData.receiver is NON_EVM_ADDRESS; must be
/// bytes32(0) for EVM destinations.
struct FraxData {
address oft;
uint32 dstEid;
uint256 nativeFee;
address refundRecipient;
bytes32 nonEVMReceiver;
}
```

Both entrypoints validate `_fraxData` up front (reverting `InvalidCallData` on a
zero `refundRecipient`, `oft`, or `dstEid`) and verify that the OFT's underlying
token matches `bridgeData.sendingAssetId` (reverting `InformationMismatch`
otherwise).
Both entrypoints validate `_fraxData` up front — **before any deposit or swap** — so an
invalid route costs the caller nothing but gas:

- `InvalidCallData` on a zero `refundRecipient`, `oft`, or `dstEid` — or on a non-zero
`nonEVMReceiver` for an EVM destination.
- `InvalidReceiver` when the `NON_EVM_ADDRESS` sentinel and the destination kind disagree
(sentinel on an EVM destination, or a 20-byte receiver on a non-EVM one).
- `InvalidNonEVMReceiver` when a non-EVM destination has a zero `nonEVMReceiver`.
- `UnsupportedChainId` / `InformationMismatch` on the `destinationChainId` ↔ `dstEid`
cross-check.
- `TokenNotSupported` when `HOP.approvedOft(oft)` is `false`.
- `InformationMismatch` when the OFT's underlying `token()` is not
`bridgeData.sendingAssetId`.

The `approvedOft` check runs **before** `oft.token()` deliberately: `oft` is caller-supplied,
so the only external call this facet makes into it happens after the hop has allowlisted it.

- `nativeFee` is the native LayerZero messaging fee. It is `0` on Tempo, where the
fee is charged in an ERC20 gas token instead (see below).
Expand Down Expand Up @@ -92,10 +108,45 @@ otherwise).
every destination a route targets before that route can be used. This is stricter
than the pure analytics-only model of `AcrossFacetV4` / `PaxosTransitFacet`, and
mirrors `SupersetFacet`.
- **HopV2 trust surface.** The HopV2 contract is an **upgradeable proxy** with an
admin `recover()` function. The facet grants it an ERC20 allowance via the
standard LI.FI `maxApproveERC20` pattern (scoped to the bridged amount). This is
a trust surface worth noting.
- **HopV2 trust surface / standing approval (explicit trust assumption).** The HopV2
contract is an **upgradeable proxy** with an admin `recover()` function, and the facet
approves it via the standard LI.FI `LibAsset.maxApproveERC20` pattern. Note what that
pattern actually does: the `amount` argument is only the *required-allowance
threshold* — when the current allowance is insufficient the allowance is set to
**`type(uint256).max`**. So after the first bridge of a given token, the Diamond
carries a **standing unlimited allowance** to HopV2 for that token, and HopV2 can be
upgraded by its admin to arbitrary logic. This is identical to how every other LI.FI
facet approves its bridge, and the residual risk is bounded by the "Diamond holds
nothing between transactions" invariant above (tokens are pulled, forwarded, and any
remainder refunded within the same call), but the trust assumption is explicit:
**an upgraded, malicious HopV2 could spend any balance the Diamond happens to hold in
an approved token at that moment.**
- **OFT support pre-check.** HopV2 only routes OFTs it has been configured with
(`HOP.approvedOft(oft)`). The facet checks this **before** any deposit or swap, so an
unapproved OFT reverts `TokenNotSupported` up front instead of failing deep inside
`sendOFT` after `_depositAndSwap` has already executed the caller's swap. The whole
transaction reverts either way, so this is a gas / error-clarity guarantee rather than a
fund-safety one.
- **Non-EVM destinations (Solana).** HopV2's recipient is a `bytes32`, and Frax routes to
Solana (LayerZero EID `30168`) via the Fraxtal hub, so the facet supports both address
formats:
- **EVM destination** — recipient is the left-padded `bridgeData.receiver`;
`nonEVMReceiver` must be `bytes32(0)`.
- **Non-EVM destination** — `bridgeData.receiver` must be the `NON_EVM_ADDRESS` sentinel
and the real recipient travels as `fraxData.nonEVMReceiver`, which is passed to
`sendOFT` verbatim. The facet emits `BridgeToNonEVMChainBytes32` alongside
`LiFiTransferStarted`.

The two are cross-checked **in both directions**, because each mismatch loses funds: a
20-byte receiver on a non-EVM route would be left-padded into an unspendable Solana
account, and the sentinel on an EVM route would deliver the funds to `NON_EVM_ADDRESS`
itself. A non-EVM `nonEVMReceiver` is only checked for non-zero — a Solana pubkey cannot
be validated further on-chain.

Which destinations are non-EVM is decided by `_isNonEVMDestination`, currently
`LIFI_CHAIN_ID_SOLANA` (Frax's only non-EVM spoke). **Add further LI.FI non-EVM chain IDs
there as Frax adds spokes** — seeding a non-EVM chain ID in `mappings` without listing it
in `_isNonEVMDestination` would let an EVM-shaped receiver through.

## ChainId → LayerZero EID mapping

Expand All @@ -111,6 +162,9 @@ above). This follows the `SupersetFacet` pattern.
it is **not** registered as a diamond method.
- `setFraxChainIdToEid(ChainIdConfig[])` — **owner-only**, add/update entries after the
initial seeding (requires a prior `initFrax`, else reverts `NotInitialized`).
- `unsetFraxChainIdToEid(uint256[])` — **owner-only**, deletes entries so those
destinations revert `UnsupportedChainId` again. Reverts `UnsupportedChainId` on a chain ID
that is not set, so a mistyped removal fails loudly instead of silently doing nothing.
- `getFraxChainIdToEid(uint256 chainId)` — returns the configured EID, reverting
`UnsupportedChainId` when unset. `chainId == 0` and `lzEid == 0` are rejected on
write (EID `0` is the "unset" sentinel).
Expand All @@ -122,6 +176,26 @@ usable — an unseeded destination reverts `UnsupportedChainId`. LayerZero EIDs
sourced from
[the LayerZero deployments list](https://docs.layerzero.network/v2/deployments/deployed-contracts).

**Deprecated spokes.** `berachain`, `mode` and `scroll` are deliberately absent from both
the `hop` address map and `mappings` in `config/frax.json`: Frax is removing them from the
hop mesh (confirmed by the Frax team on PR #2048). Do not re-add them.

> **Removing a chain from config is not enough.** `initFrax` copies `mappings` into diamond
> storage, so any diamond **already seeded** with a chain keeps routing to it regardless of
> what the config file says. Retiring a spoke is a two-step operation: drop it from
> `config/frax.json` (so future deployments never seed it) **and** call
> `unsetFraxChainIdToEid([chainId])` on every diamond that already has it. As of this
> change the Arbitrum staging diamond still has `mode` (34443) and `scroll` (534352) seeded
> from the pre-deprecation config and needs the removal call.

**Source-only chains.** Some networks have a live HopV2 spoke (so FraxFacet can be
deployed there and bridge **out**) but no `mappings` entry, which makes them unusable as a
**destination** — currently `katana`, `somnia`, `stable` and `tempo`. Tempo is the notable
one: the full TIP20 source path is implemented, yet `destinationChainId == 4217` reverts
`UnsupportedChainId` until a mapping is added. This is launch scoping, resolved together
with the BE integration (EXP-514); add the entries with `setFraxChainIdToEid` when those
destinations go live.

## Tempo (EndpointV2Alt) special case

On **Tempo** (chainId `4217`), LayerZero uses **EndpointV2Alt**, which rejects
Expand Down Expand Up @@ -161,12 +235,13 @@ The swap library can be found [here](../src/Libraries/LibSwap.sol).
Some methods accept a `BridgeData _bridgeData` parameter.

This parameter carries both analytics metadata and enforced fields. The metadata
(`transactionId`, `integrator`, `referrer`, and — for FraxFacet — `destinationChainId`)
is used to emit events that we can later track and index in our subgraphs. The
remaining fields are load-bearing: FraxFacet validates and acts on `sendingAssetId`
(the OFT token pulled and bridged), `receiver` (encoded as the destination recipient),
and `minAmount` (the amount deposited and bridged). `BridgeData` and the events we can
emit can be found [here](../src/Interfaces/ILiFi.sol).
(`transactionId`, `integrator`, `referrer`) is used to emit events that we can later
track and index in our subgraphs. The remaining fields are load-bearing: FraxFacet
validates and acts on `sendingAssetId` (the OFT token pulled and bridged), `receiver`
(encoded as the destination recipient), `minAmount` (the amount deposited and bridged),
and `destinationChainId` (cross-checked against `fraxData.dstEid` via the seeded
chainId → EID mapping). `BridgeData` and the events we can emit can be found
[here](../src/Interfaces/ILiFi.sol).

## Getting Sample Calls to interact with the Facet

Expand Down
4 changes: 4 additions & 0 deletions script/demoScripts/demoFrax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
getAddress,
parseUnits,
zeroAddress,
zeroHash,
type Abi,
type Address,
type Hex,
Expand Down Expand Up @@ -334,6 +335,9 @@ async function bridgeViaFrax(routeKey: string): Promise<void> {
dstEid,
nativeFee,
refundRecipient: devWallet,
// both demo routes target EVM chains, so the bytes32 non-EVM recipient must be zero
// (the facet reverts InvalidCallData if it is set on an EVM destination)
nonEVMReceiver: zeroHash,
}

consola.info('BridgeData:', bridgeData)
Expand Down
Loading
Loading