Skip to content

TreasureProject/L2-eigenlayer-restaking

 
 

Repository files navigation

L2 Eigenlayer Restaking via ERC-6551 accounts

This repo routes contract calls through user-owned 6551 accounts to deposit into Eigenlayer from L2.

Eigenlayer does not allow ThirdParty withdrawals, users must use their wallets to deposit and withdraw funds. So we cannot withdraw on behalf of our users via L1 bridge contracts. Using 6551 proxies addresses this restriction.

This also keeps custody of funds with the user (who owns the 6551 NFT) and gives them an escape option to withdraw funds on L1.

Table of Contents

The following test will bridge from L2 to L1, deposit in Eigenlayer, queueWithdrawals, completeWithdrawal, then bridge back to the original user on L2.

forge test --match-test test_FullFlow_CompleteWithdrawal -vvvv

The following test will test delegating, undelegating, and re-depositing:

forge test --match-test test_FullFlow_Undelegate_Delegate_Redeposit -vvvv

See test coverage

forge coverage

You can generate a lcov.info file to see line-by-line test coverage in code editors, run this with:

forge coverage --report lcov

Frontend clients will make contract calls similar to the scripts in the scripts folder. These scripts run on Base Sepolia and dispatches CCIP calls to Eth Sepolia, bridging CCIP-BnM ERC20 tokens and interacting with mock Eigenlayer Strategy Vaults setup for the CCIP-BnM token.

To run the Scripts see the scripts folder:

  • 5_depositAndMintEigenAgent.sh: makes a cross-chain deposit into Eigenlayer from L2, minting an EigenAgent if the user does not yet have one.
  • 7_queueWithdrawal.sh: queues a withdrawal from L2.
  • 8_completeWithdrawal.sh: completes the withdrawal and bridges the deposit back from L1 into the original staker's wallet on L2.
  • 9_submitRewards.sh: sets up token emission rewards for the week, however can only be done once per epoch (weekly)
  • 9b_processClaimRewards.sh: claims token emissions rewards for the user and bridges tokens back to L2 user.

Scripts 2_deploySenderOnL2.s.sol, 3_deployReceiverOnL1.s.sol and 4_whitelistCCIPContracts.sh deploy the CCIP bridge contracts, and 6551 and Eigenlayer Restaking handler contracts.

There are 2b and 3b upgrade scripts which need to be run when changes made to either the SenderCCIP, ReceiverCCIP,RestakingConnector, SenderUtils, AgentFactory or EigenAgentOwner721 contracts.

The following scripts will test delegation:

  • 6_delegateTo.sh delegates to an Operator
  • 6b_undelegate.sh undelegates from an Operator and begins a cooldown period (7 days on mainnet)
  • 6c_redeposit.sh re-deposits shares into Eigenlayer and allows the user to re-delegate to another Operator. Everytime a user undelegates, there is a cooldown timer.

Bridging times depend on the finality times of source and destination chains. It currently takes +20 minutes to bridge a message Base and ZkSync has finality times of 20min, ETH is 15min

We first bridge 0.0619 tokens from L2 to L1 with a message to mint an ERC-6551 EigenAgent and forward a DepositIntoStrategy message to Eigenlayer, resulting in Deposit and 6551 EigenAgent minting events.

We can see tokens routing through the 6551 EigenAgent contract here.

Cost: 0.014686 ETH on Sepolia at 20.27 GWEI (724,221 gas)
approx. $38

Assuming 10 GWEI on mainnet: 0.00724 ETH
Assuming 30 GWEI on mainnet: 0.0217 ETH

See Tenderly transaction for an execution trace.

Deposit Flow

Users can send their EigenAgent a QueueWithdrawal message to withdraw, producing WithdrawalQueued events on L1.

After waiting for the unstaking period (7 days), users can complete withdrawals.

  • Queued withdrawals information are stored in script/withdrawals-queued/<user_address>/
  • Completed withdrawals information are stored in script/withdrawals-completed/<user_address>/.

Queue Withdrawals Flow

Sending a CompleteWithdrawal message from L2 executes on L1 with the following Eigenlayer WithdrawalCompleted events.

The L2 Bridge contract will automatically bridge the withdrawal back to L2 to the EigenAgent's owner. You can see the messageId here in topic[1] of the MessageSent event which we can track in the CCIP explorer to see the withdrawal bridging back from L1 to L2.

When the funds arrive on L2, the original 0.0619 tokens are transferred to the EigenAgent owner's address.

Note: As EigenAgentOwner NFTs are transferrable, a user may try call completeWithdrawal then attempt to sell the NFT while the withdrawal is in flight. If users are trading these NFTs they need to be careful about this.

Complete Withdrawals Flow

You can also claim staking rewards by sending a processClaim message from L2. The rewards are bridged back to L2 to the EigenAgent owner. Only the bridge token will be bridged back to L2. Other ERC20 reward tokens will be sent to the EigenAgent owner's wallet on L1.

Claim Rewards Flow

You can delegate to Operators, by sending a delegateTo message, resulting in the following delegation events.

DelegateTo Flow

If a user wants to switch Operators to delegate to, they can send a undelegate message which results in the following undelegate events.

Undelegating queues the staker for withdrawal and produces withdrawalRoots. Front-end clients should keep track of the withdrawal information and withdrawalRoots as they will be needed to re-deposit later.

Undelegate Flow

After undelegating, users wait 7 days then delegate to another Operator. Then can re-deposit back into Eigenlayer with a redeposit Message, which results in the following re-deposit (WithdrawalCompleted) events.

Re-depositing uses the same function calls as completeWithdrawals. The receiveAsTokens flag in completeWithdrawals call determines whether user will:

  1. Receive withdrawals as tokens (which are bridged back to L2), or
  2. Receive withdrawals as shares in the vault (which can be re-delegated).

There is no way to directly re-delegate to another operator, a staker must undelegate + withdraw, wait 7 days, then restake and re-delegate to a new operator.

Redelegate Flow

EigenAgent accounts will only execute calls if the signature came from the user who owns the associated EigenAgentOwner 721 NFT. See: https://eips.ethereum.org/EIPS/eip-6551

Each user can only have 1 EigenAgentOwner NFT at the moment. We can make them tradeable or soulbound.

EigenAgent accounts are ERC1967 Proxies and can be upgraded. We can also look at BeaconProxy implementation if we want upgradeability for all accounts (Agents just route contract calls, so upgradeability is not strictly needed).

EigenAgentOwner NFTs are minted via the AgentFactory (which talks to a 6551 Registry and keeps track of EigenAgent 6551 accounts and ownership).

Cost of deploying EigenAgent should be manageable (can swap for ERC1167 minimal proxies):

forge test --match-test test_step5c_MintEigenAgent -vvvv --gas-report

Note: at the moment you cannot have more than 1 cross-chain message in-flight at a time because the execution nonce will be stale, causing the EigenAgent execution signature to fail for the 2nd message.

  • CCIP bridging takes ~20min (Ethereum finality takes ~12.8 min)
  • A solution is to track in-flight txs and increment nonces on the client-side for subsequent messages (at least until the messages successfully execute on L1). Note this assumes CCIP messages land on L1 in the correct order.

When sending a TX to SenderCCIP bridge, if the CCIP message:

Contains Message Sends Funds Outcome
true false The TX reverts early on L2 if the Message is not a depositIntoStrategy function call. This makes it harder for frontend clients to make mistakes, e.g. frontend clients accidentally sending funds to L1 for a queueWithdrawal call.
true false Tries to match an Eigenlayer function selector and execute that function (queueWithdrawal, claim rewards, delegate, etc). If no function selectors match, nothing happens.
true false Simply bridges funds

These choices are made because this repo is intended just for Eigenlayer function calls, not general purpose function calls.

Every message sent from L2 to L1 abi.encodes the message to send to Eigenlayer, then appends a user signature that signs the message digest of that Eigenlayer message to the end of it.

Please see the signMessageForEigenAgentExecution function here.

An example of an Eigenlayer message would be the deposit message:

function encodeDepositIntoStrategyMsg(
    address strategy,
    address token,
    uint256 amount
) public pure returns (bytes memory) {
    return abi.encodeWithSelector(
        IStrategyManager.depositIntoStrategy.selector,
        strategy,
        token,
        amount
    );
}

See it being used in a script here.

These messageWithSignatures arrive on L1 and are decoded in the RestakingConnector.sol contract. In general, they follow the following format:

0000000000000000000000000000000000000000000000000000000000000020 [32]  string offset CCIP
00000000000000000000000000000000000000000000000000000000000005a5 [64]  string length CCIP
3ccc861d                                                         [96]  Eigenlayer function selector
0000000000000000000000000000000000000000000000000000000000000000 [100] Eigenlayer calldata
0000000000000000000000008454d149beb26e3e3fc5ed1c87fb0b2a1b7b6c2c [132] ...
0000000000000000000000000000000000000000000000000000000000000054 [164] ...
0000000000000000000000000000000000000000000000000000000000010252 [196] ...
0000000000000000000000008454d149beb26e3e3fc5ed1c87fb0b2a1b7b6c2c [-124] signer
0000000000000000000000000000000000000000000000000000000000015195 [-92]  expiry
03814b471f1beef18326b0d63c4a0f4431fdb72be167ee8aeb6212c8bd14d8e5 [-60]  signature r
74fa9f4f34373bef152fdcba912a10b0a5c77be53c00d04c4c6c77ae407136e7 [-28]  signature s
1b000000000000000000000000000000000000000000000000000000         [-0]   signature v

The signature starts 124 bytes from the end of the message.

The decoding functions can be found in src/utils/EigenlayerMsgDecoders.sol with correspoding tests in test/UnitTests_MsgEncodingDecoding.t.sol.

  • Cross-chain messages for EigenAgent to execute Eigenlayer actions:

    • depositIntoStrategy
      • Catches deposit reverts, and allow manual re-execution to trigger refund after expiry (in case target Operator goes offline while deposits are in-flight from L2).
    • queueWithdrawals
    • completeQueuedWithdrawals
      • Transfer withdrawn tokens back to L2.
      • Transfer L1 tokens to AgentOwner address on L1.
    • delegateTo
    • undelegate
    • re-deposit into Eigenlayer (and re-delegate).
    • processClaim staking rewards, and bridge rewards back to owner on L2.
      • Transfer bridgeable rewards tokens back to L2.
      • Transfer L1 rewards tokens to AgentOwner address on L1.
  • Gas optimization

    • Estimate gas limit for each of the previous operations
    • Reduce gas costs associated with 6551 accounts creation + delegate calls
      • Remove proxies if we don't need upgradeability.
  • Chainlink to setup a "lane" for CCIP bridges:

    • Setup Chainlink lanes on Holesky and target L2.
    • Adapt differences in bridging model (mint/burn vs lock/mint) for target chain.

About

L2 Restaking via ERC6551 Agents

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Solidity 97.9%
  • Python 1.2%
  • Shell 0.9%