Skip to content

Commit ac0c654

Browse files
Le-CaigneczguesmigfournierPro
authored
feat: Update workflow to be compatible with Stargate UI (#53)
Co-authored-by: Zied Guesmi <[email protected]> Co-authored-by: gfournieriExec <[email protected]>
1 parent 01051e3 commit ac0c654

File tree

11 files changed

+302
-104
lines changed

11 files changed

+302
-104
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ generate-coverage:
3030
FOUNDRY_PROFILE=test forge coverage \
3131
--ir-minimum \
3232
--report lcov \
33-
--no-match-coverage "script|src/mocks|test"
33+
--no-match-coverage "script|src/mocks|src/interfaces|test"
3434
@if [ "$$CI" != "true" ]; then \
3535
genhtml lcov.info --branch-coverage --output-dir coverage; \
3636
fi

script/bridges/layerZero/IexecLayerZeroBridge.s.sol

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ contract Deploy is Script {
2020

2121
vm.startBroadcast();
2222
address iexecLayerZeroBridgeProxy = deploy(
23+
params.approvalRequired,
2324
params.approvalRequired ? params.rlcLiquidityUnifierAddress : params.rlcCrosschainTokenAddress,
2425
params.lzEndpoint,
2526
params.initialAdmin,
@@ -35,6 +36,7 @@ contract Deploy is Script {
3536
}
3637

3738
function deploy(
39+
bool approvalRequired,
3840
address bridgeableToken,
3941
address lzEndpoint,
4042
address initialAdmin,
@@ -43,7 +45,7 @@ contract Deploy is Script {
4345
address createxFactory,
4446
bytes32 createxSalt
4547
) public returns (address) {
46-
bytes memory constructorData = abi.encode(bridgeableToken, lzEndpoint);
48+
bytes memory constructorData = abi.encode(approvalRequired, bridgeableToken, lzEndpoint);
4749
bytes memory initializeData = abi.encodeWithSelector(
4850
IexecLayerZeroBridge.initialize.selector, initialAdmin, initialUpgrader, initialPauser
4951
);
@@ -82,7 +84,7 @@ contract Upgrade is Script {
8284
vm.startBroadcast();
8385
UpgradeUtils.UpgradeParams memory params = UpgradeUtils.UpgradeParams({
8486
proxyAddress: commonParams.iexecLayerZeroBridgeAddress,
85-
constructorData: abi.encode(bridgeableToken, commonParams.lzEndpoint),
87+
constructorData: abi.encode(commonParams.approvalRequired, bridgeableToken, commonParams.lzEndpoint),
8688
contractName: "IexecLayerZeroBridgeV2Mock.sol:IexecLayerZeroBridgeV2", // Would be production contract in real deployment
8789
newStateVariable: newStateVariable
8890
});

src/RLCLiquidityUnifier.sol

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import {AccessControlDefaultAdminRulesUpgradeable} from
99
"@openzeppelin/contracts-upgradeable/access/extensions/AccessControlDefaultAdminRulesUpgradeable.sol";
1010
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
1111
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
12-
import {IERC7802} from "@openzeppelin/contracts/interfaces/draft-IERC7802.sol";
1312
import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol";
13+
import {IERC7802} from "@openzeppelin/contracts/interfaces/draft-IERC7802.sol";
14+
import {IRLCLiquidityUnifier} from "./interfaces/IRLCLiquidityUnifier.sol";
1415

1516
/**
1617
* @dev This contract facilitates cross-chain liquidity unification by allowing
@@ -21,12 +22,14 @@ import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol";
2122
* without being an ERC20 token itself. Functions are overridden to lock/unlock
2223
* tokens on an external ERC20 contract.
2324
*/
24-
contract RLCLiquidityUnifier is UUPSUpgradeable, AccessControlDefaultAdminRulesUpgradeable, IERC7802 {
25+
contract RLCLiquidityUnifier is
26+
UUPSUpgradeable,
27+
AccessControlDefaultAdminRulesUpgradeable,
28+
IRLCLiquidityUnifier,
29+
IERC7802
30+
{
2531
using SafeERC20 for IERC20Metadata;
2632

27-
error ERC7802InvalidToAddress(address addr);
28-
error ERC7802InvalidFromAddress(address addr);
29-
3033
bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");
3134
bytes32 public constant TOKEN_BRIDGE_ROLE = keccak256("TOKEN_BRIDGE_ROLE");
3235

src/bridges/layerZero/IexecLayerZeroBridge.sol

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ pragma solidity ^0.8.22;
55

66
import {OFTCoreUpgradeable} from "@layerzerolabs/oft-evm-upgradeable/contracts/oft/OFTCoreUpgradeable.sol";
77
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
8-
import {IERC7802} from "@openzeppelin/contracts/interfaces/draft-IERC7802.sol";
98
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
109
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
1110
import {AccessControlDefaultAdminRulesUpgradeable} from
1211
"@openzeppelin/contracts-upgradeable/access/extensions/AccessControlDefaultAdminRulesUpgradeable.sol";
12+
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
1313
import {DualPausableUpgradeable} from "../utils/DualPausableUpgradeable.sol";
1414
import {IIexecLayerZeroBridge} from "../../interfaces/IIexecLayerZeroBridge.sol";
15+
import {IERC7802} from "@openzeppelin/contracts/interfaces/draft-IERC7802.sol";
16+
import {IRLCLiquidityUnifier} from "../../interfaces/IRLCLiquidityUnifier.sol";
1517

1618
/**
1719
* @title IexecLayerZeroBridge
@@ -31,6 +33,22 @@ import {IIexecLayerZeroBridge} from "../../interfaces/IIexecLayerZeroBridge.sol"
3133
* Dual-Pause Emergency System:
3234
* 1. Complete Pause: Blocks all bridge operations (incoming and outgoing transfers)
3335
* 2. Send Pause: Blocks only outgoing transfers, allows users to receive/withdraw funds
36+
*
37+
* Architecture Overview:
38+
* This bridge supports two distinct deployment scenarios:
39+
*
40+
* 1. Non-Mainnet Chains (L2s, sidechains, etc.):
41+
* - BRIDGEABLE_TOKEN: Points to RLCCrosschain contract (mintable/burnable tokens)
42+
* - APPROVAL_REQUIRED: false (bridge can mint/burn directly)
43+
* - Mechanism: Mint tokens on transfer-in, burn tokens on transfer-out
44+
*
45+
* 2. Ethereum Mainnet:
46+
* - BRIDGEABLE_TOKEN: Points to LiquidityUnifier contract (manages original RLC tokens)
47+
* - APPROVAL_REQUIRED: true (requires user approval for token transfers)
48+
* - Mechanism: Lock tokens on transfer-out, unlock tokens on transfer-in
49+
* The LiquidityUnifier contract acts as a relayer, implementing ERC-7802 interface
50+
* to provide consistent lock/unlock operations for the original RLC token contract
51+
* that may not natively support the crosschain standard.
3452
*/
3553
contract IexecLayerZeroBridge is
3654
UUPSUpgradeable,
@@ -39,20 +57,32 @@ contract IexecLayerZeroBridge is
3957
DualPausableUpgradeable,
4058
IIexecLayerZeroBridge
4159
{
60+
using SafeERC20 for IERC20Metadata;
61+
4262
/// @dev Role identifier for accounts authorized to upgrade the contract
4363
bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");
4464

4565
/// @dev Role identifier for accounts authorized to pause/unpause the contract
4666
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
4767

4868
/**
49-
* @dev The RLC token contract that this bridge operates on
50-
* Must implement the [ERC-7802](https://eips.ethereum.org/EIPS/eip-7802) interface.
69+
* @custom:oz-upgrades-unsafe-allow state-variable-immutable
70+
*/
71+
// slither-disable-next-line naming-convention
72+
address public immutable BRIDGEABLE_TOKEN;
73+
74+
/**
75+
* @dev Indicates the token transfer mechanism required for this deployment.
76+
*
77+
* - true: Ethereum Mainnet deployment requiring user approval (lock/unlock mechanism)
78+
* - false: Non Ethereum Mainnet deployment with direct mint/burn capabilities
79+
*
80+
* This flag indicates on which chain the bridge is deployed.
5181
*
5282
* @custom:oz-upgrades-unsafe-allow state-variable-immutable
5383
*/
5484
// slither-disable-next-line naming-convention
55-
IERC7802 public immutable BRIDGEABLE_TOKEN;
85+
bool private immutable APPROVAL_REQUIRED;
5686

5787
/**
5888
* @dev Constructor for the LayerZero bridge contract
@@ -61,11 +91,12 @@ contract IexecLayerZeroBridge is
6191
*
6292
* @custom:oz-upgrades-unsafe-allow constructor
6393
*/
64-
constructor(address bridgeableToken, address lzEndpoint)
94+
constructor(bool approvalRequired_, address bridgeableToken, address lzEndpoint)
6595
OFTCoreUpgradeable(IERC20Metadata(bridgeableToken).decimals(), lzEndpoint)
6696
{
6797
_disableInitializers();
68-
BRIDGEABLE_TOKEN = IERC7802(bridgeableToken);
98+
BRIDGEABLE_TOKEN = bridgeableToken;
99+
APPROVAL_REQUIRED = approvalRequired_;
69100
}
70101

71102
// ============ INITIALIZATION ============
@@ -143,15 +174,15 @@ contract IexecLayerZeroBridge is
143174
* @return requiresApproval Returns true if deployed on Ethereum Mainnet, false otherwise
144175
*/
145176
function approvalRequired() external view virtual returns (bool) {
146-
return block.chainid == 1;
177+
return APPROVAL_REQUIRED;
147178
}
148179

149180
/**
150181
* @notice Returns the address of the underlying token being bridged
151182
* @return The address of the RLC token contract
152183
*/
153184
function token() external view returns (address) {
154-
return address(BRIDGEABLE_TOKEN);
185+
return APPROVAL_REQUIRED ? address(IRLCLiquidityUnifier(BRIDGEABLE_TOKEN).RLC_TOKEN()) : BRIDGEABLE_TOKEN;
155186
}
156187

157188
// ============ ACCESS CONTROL OVERRIDES ============
@@ -183,6 +214,9 @@ contract IexecLayerZeroBridge is
183214
* It overrides the `_debit` function
184215
* https://github.com/LayerZero-Labs/devtools/blob/a2e444f4c3a6cb7ae88166d785bd7cf2d9609c7f/packages/oft-evm/contracts/OFT.sol#L56-L69
185216
*
217+
* This function behavior is chain specific and works differently
218+
* depending on whether the bridge is deployed on Ethereum Mainnet or a non-mainnet chain.
219+
*
186220
* IMPORTANT ASSUMPTIONS:
187221
* - This implementation assumes LOSSLESS transfers (1 token burned = 1 token minted)
188222
* - If BRIDGEABLE_TOKEN implements transfer fees, burn fees, or any other fee mechanism,
@@ -213,8 +247,14 @@ contract IexecLayerZeroBridge is
213247
// Calculate the amounts using the parent's logic (handles slippage protection)
214248
(amountSentLD, amountReceivedLD) = _debitView(amountLD, minAmountLD, dstEid);
215249

216-
// Burn the tokens from the sender's balance
217-
BRIDGEABLE_TOKEN.crosschainBurn(from, amountSentLD);
250+
if (APPROVAL_REQUIRED) {
251+
// Transfer RLC tokens from the user's account to the LiquidityUnifier contract.
252+
// The normal workflow would be to call `LiquidityUnifier#crosschainBurn()` but this workflow is not compatible with Stargate UI.
253+
// Stargate UI does not support approving a contract other than the bridge itself, so here the LiquidityUnifier will not be able to send the `transferFrom` transaction.
254+
IRLCLiquidityUnifier(BRIDGEABLE_TOKEN).RLC_TOKEN().safeTransferFrom(from, BRIDGEABLE_TOKEN, amountSentLD);
255+
} else {
256+
IERC7802(BRIDGEABLE_TOKEN).crosschainBurn(from, amountSentLD);
257+
}
218258
}
219259

220260
/**
@@ -225,6 +265,7 @@ contract IexecLayerZeroBridge is
225265
* It overrides the `_credit` function
226266
* https://github.com/LayerZero-Labs/devtools/blob/a2e444f4c3a6cb7ae88166d785bd7cf2d9609c7f/packages/oft-evm/contracts/OFT.sol#L78-L88
227267
*
268+
* This function behavior is chain agnostic and works the same for both chains that does or doesn't require approval.
228269
*
229270
* IMPORTANT ASSUMPTIONS:
230271
* - This implementation assumes LOSSLESS transfers (1 token received = 1 token minted)
@@ -257,7 +298,7 @@ contract IexecLayerZeroBridge is
257298

258299
// Mint the tokens to the recipient
259300
// This assumes crosschainMint doesn't apply any fees
260-
BRIDGEABLE_TOKEN.crosschainMint(to, amountLD);
301+
IERC7802(BRIDGEABLE_TOKEN).crosschainMint(to, amountLD);
261302

262303
// Return the amount minted (assuming no fees)
263304
return amountLD;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// SPDX-FileCopyrightText: 2025 IEXEC BLOCKCHAIN TECH <[email protected]>
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
pragma solidity ^0.8.22;
5+
6+
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
7+
8+
/**
9+
* @title IRLCLiquidityUnifier
10+
* @dev Interface for the RLC Liquidity Unifier contract.
11+
*
12+
* This interface defines the contract that is used to centralize the RLC liquidity
13+
* across different bridges.
14+
*/
15+
interface IRLCLiquidityUnifier {
16+
/**
17+
* @dev Error indicating that the provided 'to' address is invalid for ERC-7802 operations.
18+
* @param addr The invalid address.
19+
*/
20+
error ERC7802InvalidToAddress(address addr);
21+
22+
/**
23+
* @dev Error indicating that the provided 'from' address is invalid for ERC-7802 operations.
24+
* @param addr The invalid address.
25+
*/
26+
error ERC7802InvalidFromAddress(address addr);
27+
28+
/**
29+
* @dev Returns the address of the RLC token contract
30+
* @return The contract address of the RLC token
31+
*/
32+
function RLC_TOKEN() external view returns (IERC20Metadata);
33+
34+
/**
35+
* @dev Returns the number of decimal places used by the token
36+
* @return The number of decimal places (typically 9 for RLC)
37+
*/
38+
function decimals() external pure returns (uint8);
39+
}

src/mocks/IexecLayerZeroBridgeV2Mock.sol

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ contract IexecLayerZeroBridgeV2 is IexecLayerZeroBridge {
1616
uint256 public newStateVariable;
1717

1818
/// @custom:oz-upgrades-unsafe-allow constructor
19-
constructor(address _token, address _lzEndpoint) IexecLayerZeroBridge(_token, _lzEndpoint) {}
19+
constructor(bool approvalRequired, address token, address lzEndpoint)
20+
IexecLayerZeroBridge(approvalRequired, token, lzEndpoint)
21+
{}
2022

2123
/**
2224
* @notice Initializes V2 features (called after upgrade)

test/e2e/IexecLayerZeroBridgeScript.t.sol

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,78 @@ pragma solidity ^0.8.22;
66
import {Test} from "forge-std/Test.sol";
77
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
88
import {Deploy as IexecLayerZeroBridgeDeploy} from "../../script/bridges/layerZero/IexecLayerZeroBridge.s.sol";
9-
import {IexecLayerZeroBridge} from "../../src/bridges/layerZero/IexecLayerZeroBridge.sol";
9+
import {Deploy as RLCLiquidityUnifierDeployScript} from "../../script/RLCLiquidityUnifier.s.sol";
1010
import {Deploy as RLCCrosschainTokenDeployScript} from "../../script/RLCCrosschainToken.s.sol";
11+
import {IexecLayerZeroBridge} from "../../src/bridges/layerZero/IexecLayerZeroBridge.sol";
12+
import {RLCLiquidityUnifier} from "../../src/RLCLiquidityUnifier.sol";
13+
import {RLCCrosschainToken} from "../../src/RLCCrosschainToken.sol";
14+
import {ConfigLib} from "../../script/lib/ConfigLib.sol";
1115

1216
contract IexecLayerZeroBridgeScriptTest is Test {
13-
// TODO read value from config.json file.
14-
address LAYERZERO_ENDPOINT = 0x6EDCE65403992e310A62460808c4b910D972f10f; // LayerZero Arbitrum Sepolia endpoint
15-
address CREATEX = 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed;
17+
// The chain does not matter here as the LAYERZERO_ENDPOINT address is the same for both networks (Sepolia & Arbitrum Sepolia)
18+
ConfigLib.CommonConfigParams params = ConfigLib.readCommonConfig("sepolia");
1619

1720
address admin = makeAddr("admin");
1821
address upgrader = makeAddr("upgrader");
1922
address pauser = makeAddr("pauser");
20-
address rlcAddress; // This will be set to a mock token address for testing
2123
bytes32 salt = keccak256("salt");
2224

2325
IexecLayerZeroBridgeDeploy public deployer;
26+
address private liquidityUnifier;
27+
address private rlcCrosschainToken;
28+
29+
// Forks ID
30+
uint256 private sepoliaFork;
31+
uint256 private arbitrumSepoliaFork;
2432

2533
function setUp() public {
26-
vm.createSelectFork(vm.envString("ARBITRUM_SEPOLIA_RPC_URL"));
2734
deployer = new IexecLayerZeroBridgeDeploy();
28-
rlcAddress =
29-
new RLCCrosschainTokenDeployScript().deploy("iEx.ec Network Token", "RLC", admin, admin, CREATEX, salt);
30-
vm.setEnv("CREATE_X_FACTORY", vm.toString(CREATEX));
35+
36+
// Create a forks
37+
sepoliaFork = vm.createFork(vm.envString("SEPOLIA_RPC_URL"));
38+
arbitrumSepoliaFork = vm.createFork(vm.envString("ARBITRUM_SEPOLIA_RPC_URL"));
39+
40+
// Setup Ethereum Mainnet fork
41+
vm.selectFork(sepoliaFork);
42+
liquidityUnifier = new RLCLiquidityUnifierDeployScript().deploy(
43+
params.rlcToken, admin, upgrader, params.createxFactory, keccak256("salt")
44+
);
45+
46+
// Setup Arbitrum Sepolia fork
47+
vm.selectFork(arbitrumSepoliaFork);
48+
rlcCrosschainToken = new RLCCrosschainTokenDeployScript().deploy(
49+
"iEx.ec Network Token", "RLC", admin, admin, params.createxFactory, salt
50+
);
51+
}
52+
53+
function testFork_Deployment_WithApproval() public {
54+
vm.selectFork(sepoliaFork);
55+
_test_Deployment(true, liquidityUnifier);
3156
}
3257

33-
function testFork_Deployment() public {
58+
function testFork_Deployment_WithoutApproval() public {
59+
vm.selectFork(arbitrumSepoliaFork);
60+
_test_Deployment(false, rlcCrosschainToken);
61+
}
62+
63+
function _test_Deployment(bool requireApproval, address bridgeableToken) internal {
3464
IexecLayerZeroBridge iexecLayerZeroBridge = IexecLayerZeroBridge(
35-
deployer.deploy(rlcAddress, LAYERZERO_ENDPOINT, admin, upgrader, pauser, CREATEX, salt)
65+
deployer.deploy(
66+
requireApproval,
67+
bridgeableToken,
68+
params.lzEndpoint,
69+
admin,
70+
upgrader,
71+
pauser,
72+
params.createxFactory,
73+
salt
74+
)
3675
);
3776

3877
assertEq(iexecLayerZeroBridge.owner(), admin);
39-
assertEq(iexecLayerZeroBridge.token(), address(rlcAddress));
78+
assertEq(iexecLayerZeroBridge.token(), requireApproval ? params.rlcToken : rlcCrosschainToken);
79+
// Check ApprovalRequired value
80+
assertEq(iexecLayerZeroBridge.approvalRequired(), requireApproval, "Incorrect ApprovalRequired value");
4081
// Check all roles.
4182
assertTrue(iexecLayerZeroBridge.hasRole(iexecLayerZeroBridge.DEFAULT_ADMIN_ROLE(), admin));
4283
assertTrue(iexecLayerZeroBridge.hasRole(iexecLayerZeroBridge.UPGRADER_ROLE(), upgrader));
@@ -51,15 +92,19 @@ contract IexecLayerZeroBridgeScriptTest is Test {
5192
}
5293

5394
function testFork_RevertWhen_TwoDeploymentsWithTheSameSalt() public {
54-
deployer.deploy(rlcAddress, LAYERZERO_ENDPOINT, admin, upgrader, pauser, CREATEX, salt);
55-
vm.expectRevert(abi.encodeWithSignature("FailedContractCreation(address)", CREATEX));
56-
deployer.deploy(rlcAddress, LAYERZERO_ENDPOINT, admin, upgrader, pauser, CREATEX, salt);
95+
deployer.deploy(
96+
false, address(rlcCrosschainToken), params.lzEndpoint, admin, upgrader, pauser, params.createxFactory, salt
97+
);
98+
vm.expectRevert(abi.encodeWithSignature("FailedContractCreation(address)", params.createxFactory));
99+
deployer.deploy(
100+
false, address(rlcCrosschainToken), params.lzEndpoint, admin, upgrader, pauser, params.createxFactory, salt
101+
);
57102
}
58103

59-
// TODO add tests for the configuration script.
104+
// TODO: add tests for the configuration script.
60105

61106
function testFork_ConfigureContractCorrectly() public {
62-
// TODO check that the peer has been set with the correct config.
107+
// TODO: check that the peer has been set with the correct config.
63108
}
64109

65110
function testFork_RevertWhenPeerIsAlreadySet() public {}

0 commit comments

Comments
 (0)