Across 跨链桥合约解析

什么是 Across

以太坊跨链协议 Across 是一种新颖的跨链方法,它结合了乐观预言机(Optimistic Oracle)、绑定中继者和单边流动性池,可以提供从 Rollup 链到以太坊主网的去中心化即时交易。目前,Across 协议通过集成以太坊二层扩容方案Optimism、Arbitrum和Boba Network支持双向桥接,即可将资产从L1发送至L2,亦可从L2发送至L1。

存款跨链流程

process

来源于:https://docs.across.to/bridge/how-does-across-work-1/architecture-process-walkthrough

Across 协议中,存款跨链有几种可能的流程,最重要的是,存款人在任何这些情况下都不会损失资金。在每一种情况下,在 L2 上存入的任何代币都会通过 Optimism 或 Arbitrum 的原生桥转移到 L1 上的流动池,用以偿还给流动性提供者。

从上面的流程中,我们可以看到 Across 协议流程包括以下几种:

  • 即时中继,无争议;
  • 即时中继,有争议;
  • 慢速中继,无争议;
  • 慢速中继,有争议;
  • 慢速中继,加速为即时中继。

Across 协议中主要包括几类角色:

  • 存款者(Depositor):需要将资产从二层链转移到L1的用户;
  • 中继者(Relayer):负责将L1层资产转移给用户,以及L2层资产跨链的节点;
  • 流动性提供者(LP):为流动性池提供资产;
  • 争议者(Disputor):对中继过程有争议的人,可以向 Optimistic Oracle 提交争议;

项目总览

Across 的合约源码地址为 https://github.com/across-protocol/contracts-v1,目前 Across Protocol 正在进行 v2 版本合约的开发,我们这一篇文章主要分析 v1 版本的合约源码。首先我们下载源码:

git clone https://github.com/across-protocol/contracts-v1
cd contracts-v1

合约源码的主要的目录结构为:

contract-v1
├── contracts // Across protocol 的合约源码
├── deploy // 部署脚本
├── hardhat.config.js // hardhat 配置
├── helpers // 辅助函数
├── networks // 合约在不同链上的部署地址
└── package.json // 依赖包

在这篇解析中,我们主要关注 contractsdeploy 目录下的文件。

合约总览

合约目录 contracts 的目录结构为:

contracts/
├── common
│   ├── implementation
│   └── interfaces
├── external
│   ├── README.md
│   ├── avm
│   ├── chainbridge
│   ├── ovm
│   └── polygon
├── insured-bridge
│   ├── BridgeAdmin.sol
│   ├── BridgeDepositBox.sol
│   ├── BridgePool.sol
│   ├── RateModelStore.sol
│   ├── avm
│   ├── interfaces
│   ├── ovm
│   └── test
└── oracle
    ├── implementation
    └── interfaces

其中,各个目录包含的内容为:

  • common:一些通用功能的库方法等,包括:
  • external:外部合约,主要用于实现在管理员合约中对不同 L2 的消息发送;
  • insured-bridge 合约主要功能,我们会在接下来的章节章节中重点分析;
  • oracle:主要是 Optimistic Oracle 提供功能的方法接口,在这篇文章中我们不对 Optimistic Oracle 的原理实现进行介绍,主要会介绍 Across 协议会在何处使用 Optimistic Oracle。

接下来我们会重点分析 insured-bridge 中的合约的功能,这是 Across 主要功能的合约所在。

insured-bridge 目录中:

  • BridgeAdmin.sol :管理合约,负责管理和生成生成 L2 上的 DepositBox 合约和 L1 上的 BridgePool 合约;
  • BridgeDepositBox.sol :L2 层上负责存款的抽象合约,Arbitrum,Optimism 和 Boba 网络的合约都是继承自这个合约;
  • BridgePool.sol :桥接池合约,管理 L1 层资金池。

BridgeAdmin

这个合约是管理员合约,部署在L1层,并有权限管理 L1 层上的流动性池和 L2 上的存款箱(DepositBoxes)。可以注意的是,这个合约的管理帐号是一个多钱钱包,避免了一些安全问题。

首先我们看到合约中的几个状态变量:

contract BridgeAdmin is BridgeAdminInterface, Ownable, Lockable {

    address public override finder;

    mapping(uint256 => DepositUtilityContracts) private _depositContracts;

    mapping(address => L1TokenRelationships) private _whitelistedTokens;

    // Set upon construction and can be reset by Owner.
    uint32 public override optimisticOracleLiveness;
    uint64 public override proposerBondPct;
    bytes32 public override identifier;

    constructor(
        address _finder,
        uint32 _optimisticOracleLiveness,
        uint64 _proposerBondPct,
        bytes32 _identifier
    ) {
        finder = _finder;
        require(address(_getCollateralWhitelist()) != address(0), "Invalid finder");
        _setOptimisticOracleLiveness(_optimisticOracleLiveness);
        _setProposerBondPct(_proposerBondPct);
        _setIdentifier(_identifier);
    }

...

其中:

  • finder 用来记录查询最新 OptimisticOracle 和 UMA 生态中其他合约的合约地址;
  • _depositContracts 该合约可以将消息中继到任意数量的 L2 存款箱,每个 L2 网络一个,每个都由唯一的网络 ID 标识。 要中继消息,需要存储存款箱合约地址和信使(messenger)合约地址。 每个 L2 的信使实现不同,因为 L1 –> L2 消息传递是非标准的;
  • _whitelistedTokens 记录了 L1 代币地址与对应 L2 代币地址以及桥接池的映射;
  • optimisticOracleLiveness 中继存款的争议时长;
  • proposerBondPct Optimistic Oracle 中 proposer 的绑定费率

管理员可以设置以上这些变量的内容,以及可以设置每秒的 LP 费率,转移桥接池的管理员权限等。

同时,管理员还可以通过信使设置 L2 层合约的参数,包括;

  • setCrossDomainAdmin :设置 L2 存款合约的管理员地址;
  • setMinimumBridgingDelay :设置 L2 存款合约的最小桥接延迟;
  • setEnableDepositsAndRelays:开启或者暂停代币 L2 存款,这个方法会同时暂停 L1 层桥接池;
  • whitelistToken:关联 L2 代币地址,这样这个代币就可以开始存款和中继;

对于消息发送,管理员合约通过调用不同的信使的 relayMessage 方法来完成,将 msg.value == l1CallValue 发送给信使,然后它可以以任何方式使用它来执行跨域消息。

    function _relayMessage(
        address messengerContract,
        uint256 l1CallValue,
        address target,
        address user,
        uint256 l2Gas,
        uint256 l2GasPrice,
        uint256 maxSubmissionCost,
        bytes memory message
    ) private {
        require(l1CallValue == msg.value, "Wrong number of ETH sent");
        MessengerInterface(messengerContract).relayMessage{ value: l1CallValue }(
            target,
            user,
            l1CallValue,
            l2Gas,
            l2GasPrice,
            maxSubmissionCost,
            message
        );
    }

不同L2的消息方法分别在对应链的 CrossDomainEnabled.sol 合约中,比如:

  • Arbitrum: contracts/insured-bridge/avm/Arbitrum_CrossDomainEnabled.sol
  • Optimism,Boba: contracts/insured-bridge/ovm/OVM_CrossDomainEnabled.sol

BridgeDepositBox

接下来我们看到 BridgeDepositBox.sol,抽象合约 BridgeDepositBox 合约中主要有两个功能。

bridgeTokens

第一个是 bridgeTokens 方法,用于将 L2 层代币通过原生代币桥转移到 L1 上,这个方法需要在不同的 L2 层合约上实现,目前支持的 L2 层包括 Arbitrum,Optimism 和 Boba,分别对应的文件为:

  • Arbitrum: contracts/insured-bridge/avm/AVM_BridgeDepositBox.sol
  • Optimism: contracts/insured-bridge/ovm/OVM_BridgeDepositBox.sol
  • Boba: contracts/insured-bridge/ovm/OVM_OETH_BridgeDepositBox.sol

以 Arbitrum 链上的 bridgeToken 为例:

    // BridgeDepositBox.sol 文件中
    function canBridge(address l2Token) public view returns (bool) {
        return isWhitelistToken(l2Token) && _hasEnoughTimeElapsedToBridge(l2Token);
    }

		// AVM_BridgeDepositBox.sol文件中
    function bridgeTokens(address l2Token, uint32 l1Gas) public override nonReentrant() {
        uint256 bridgeDepositBoxBalance = TokenLike(l2Token).balanceOf(address(this));
        require(bridgeDepositBoxBalance > 0, "can't bridge zero tokens");
        require(canBridge(l2Token), "non-whitelisted token or last bridge too recent");

        whitelistedTokens[l2Token].lastBridgeTime = uint64(getCurrentTime());

        StandardBridgeLike(l2GatewayRouter).outboundTransfer(
            whitelistedTokens[l2Token].l1Token, // _l1Token. Address of the L1 token to bridge over.
            whitelistedTokens[l2Token].l1BridgePool, // _to. Withdraw, over the bridge, to the l1 withdraw contract.
            bridgeDepositBoxBalance, // _amount. Send the full balance of the deposit box to bridge.
            "" // _data. We don't need to send any data for the bridging action.
        );

        emit TokensBridged(l2Token, bridgeDepositBoxBalance, l1Gas, msg.sender);
    }

bridgeTokens 上有一个装饰器 canBridge 包含两个判断, isWhitelistToken 用于判断对应 L2 层代币是否已经在 L1 层上添加了桥接池, _hasEnoughTimeElapsedToBridge 用来减少频繁跨连导致的费用消耗问题,因此设置了最小的跨链接时间。

bridgeTokens 主要就是调用了 L2 层原生的跨链方法,比如 outboundTransfer

deposit

第二个是 deposit 方法用于将 L2 层资产转移到以太坊 L1 层上,对应与前端页面 Deposit 操作。对应代码为:

    function bridgeTokens(address l2Token, uint32 l2Gas) public virtual;

    function deposit(
        address l1Recipient,
        address l2Token,
        uint256 amount,
        uint64 slowRelayFeePct,
        uint64 instantRelayFeePct,
        uint64 quoteTimestamp
    ) public payable onlyIfDepositsEnabled(l2Token) nonReentrant() {
        require(isWhitelistToken(l2Token), "deposit token not whitelisted");

        require(slowRelayFeePct <= 0.25e18, "slowRelayFeePct must be <= 25%");
        require(instantRelayFeePct <= 0.25e18, "instantRelayFeePct must be <= 25%");

        require(
            getCurrentTime() >= quoteTimestamp - 10 minutes && getCurrentTime() <= quoteTimestamp + 10 minutes,
            "deposit mined after deadline"
        );
        
        if (whitelistedTokens[l2Token].l1Token == l1Weth && msg.value > 0) {
            require(msg.value == amount, "msg.value must match amount");
            WETH9Like(address(l2Token)).deposit{ value: msg.value }();
        }
        else IERC20(l2Token).safeTransferFrom(msg.sender, address(this), amount);

        emit FundsDeposited(
            chainId,
            numberOfDeposits, // depositId: the current number of deposits acts as a deposit ID (nonce).
            l1Recipient,
            msg.sender,
            whitelistedTokens[l2Token].l1Token,
            l2Token,
            amount,
            slowRelayFeePct,
            instantRelayFeePct,
            quoteTimestamp
        );

        numberOfDeposits += 1;
    }

其中,合约区分了 ETH 和 ERC20 代币的存入方式。

存入资产后,合约产生了一个事件 FundsDeposited,用于中继者程序捕获并进行资产跨链,事件信息包含合约部署的 L2 链ID,存款ID numberOfDeposits,L1层接收者,存款者,L1和L2层代币地址,数量和费率,以及时间戳。

BridgePool

BridgePool 合约部署在 Layer 1 上,提供了给中继者完成 Layer2 上存款订单的函数。主要包含以下功能:

  1. 流动性提供者添加和删除流动性的方法 addLiquidityremoveLiquidity
  2. 慢速中继: relayDeposit
  3. 即时中继: relayAndSpeedUpspeedUpRelay
  4. 争议: disputeRelay
  5. 解决中继: settleRelay

构造器

在合约初始时,合约设置了对应的桥管理员地址,L1代币地址,每秒的 LP 费率,以及标识是否为 WETH 池。同时,通过 syncUmaEcosystemParamssyncWithBridgeAdminParams 两个方法同步了 Optimistic Oracle 地址信息,Store 的地址信息,以及对应的 ProposerBondPctOptimisticOracleLiveness 等参数。

    function syncUmaEcosystemParams() public nonReentrant() {
        FinderInterface finder = FinderInterface(bridgeAdmin.finder());
        optimisticOracle = SkinnyOptimisticOracleInterface(
            finder.getImplementationAddress(OracleInterfaces.SkinnyOptimisticOracle)
        );

        store = StoreInterface(finder.getImplementationAddress(OracleInterfaces.Store));
        l1TokenFinalFee = store.computeFinalFee(address(l1Token)).rawValue;
    }

		function syncWithBridgeAdminParams() public nonReentrant() {
        proposerBondPct = bridgeAdmin.proposerBondPct();
        optimisticOracleLiveness = bridgeAdmin.optimisticOracleLiveness();
        identifier = bridgeAdmin.identifier();
    }

		constructor(
        string memory _lpTokenName,
        string memory _lpTokenSymbol,
        address _bridgeAdmin,
        address _l1Token,
        uint64 _lpFeeRatePerSecond,
        bool _isWethPool,
        address _timer
    ) Testable(_timer) ERC20(_lpTokenName, _lpTokenSymbol) {
        require(bytes(_lpTokenName).length != 0 && bytes(_lpTokenSymbol).length != 0, "Bad LP token name or symbol");
        bridgeAdmin = BridgeAdminInterface(_bridgeAdmin);
        l1Token = IERC20(_l1Token);
        lastLpFeeUpdate = uint32(getCurrentTime());
        lpFeeRatePerSecond = _lpFeeRatePerSecond;
        isWethPool = _isWethPool;

        syncUmaEcosystemParams(); // Fetch OptimisticOracle and Store addresses and L1Token finalFee.
        syncWithBridgeAdminParams(); // Fetch ProposerBondPct OptimisticOracleLiveness, Identifier from the BridgeAdmin.

        emit LpFeeRateSet(lpFeeRatePerSecond);
    }

添加和删除流动性

我们首先看到添加和删除流动性,添加流动性即流动性提供者向连接池中提供 L1 代币,并获取相应数量的 LP 代币作为证明,LP 代币数量根据现行汇率计算。

    function addLiquidity(uint256 l1TokenAmount) public payable nonReentrant() {
				// 如果是 weth 池,调用发送 msg.value,msg.value 与 l1TokenAmount 相同
				// 否则,msg.value 必需为 0
        require((isWethPool && msg.value == l1TokenAmount) || msg.value == 0, "Bad add liquidity Eth value");

			  // 由于 `_exchangeRateCurrent()` 读取合约的余额并使用它更新合约状态,
				// 因此我们必需在转入任何代币之前调用
        uint256 lpTokensToMint = (l1TokenAmount * 1e18) / _exchangeRateCurrent();
        _mint(msg.sender, lpTokensToMint);
        liquidReserves += l1TokenAmount;

        if (msg.value > 0 && isWethPool) WETH9Like(address(l1Token)).deposit{ value: msg.value }();
        else l1Token.safeTransferFrom(msg.sender, address(this), l1TokenAmount);

        emit LiquidityAdded(l1TokenAmount, lpTokensToMint, msg.sender);
    }

由于合约支持 WETH 作为流动性池,因此添加流动性区分了 WETH 和其他 ERC20 代币的添加方法。

此处的难点在于 LP 代币和 L1 代币之间的汇率换算 _exchangeRateCurrent 的实现,我们从合约中提取出了 _exchangeRateCurrent 所使用的函数,包括 _updateAccumulatedLpFees_sync

	
		function _getAccumulatedFees() internal view returns (uint256) {
        uint256 possibleUnpaidFees =
            (undistributedLpFees * lpFeeRatePerSecond * (getCurrentTime() - lastLpFeeUpdate)) / (1e18);
        return possibleUnpaidFees < undistributedLpFees ? possibleUnpaidFees : undistributedLpFees;
    }

    function _updateAccumulatedLpFees() internal {
        uint256 unallocatedAccumulatedFees = _getAccumulatedFees();

        undistributedLpFees = undistributedLpFees - unallocatedAccumulatedFees;

        lastLpFeeUpdate = uint32(getCurrentTime());
    }

		function _sync() internal {
        uint256 l1TokenBalance = l1Token.balanceOf(address(this)) - bonds;
        if (l1TokenBalance > liquidReserves) {
            
            utilizedReserves -= int256(l1TokenBalance - liquidReserves);
            liquidReserves = l1TokenBalance;
        }
    }
    
		function _exchangeRateCurrent() internal returns (uint256) {
        if (totalSupply() == 0) return 1e18; // initial rate is 1 pre any mint action.

        _updateAccumulatedLpFees();
        _sync();

        int256 numerator = int256(liquidReserves) + utilizedReserves - int256(undistributedLpFees);
        return (uint256(numerator) * 1e18) / totalSupply();
    }

换算汇率等于当前合约中代币的储备与总 LP 供应量的比值,计算步骤如下:

  1. 更新自上次方法调用以来的累积LP费用 _updateAccumulatedLpFees
    1. 计算可能未付的费用 possibleUnpaidFees ,等于未分配的 Lp 费用 undistributedLpFees * 每秒 LP 费率 *(当前时间-上次更新时间),目前 WETH 桥接池中每秒LP费率为 0.0000015。
    2. 计算累积费用 unallocatedAccumulatedFees ,如果 possibleUnpaidFees 小于未分配的 Lp 费用,则所有未分配的 LP 费用都将用于累积费用;
    3. 当前未分配 LP 费用 = 原先未分配 LP 费用 - 累积费用;
  2. 计算由于代币桥接产生的余额变化
    1. 当前合约中的代币储备=当前合约中的代币数量 - 被绑定在中继过程中的代币数量;
    2. 如果当前合约中的代币储备大于流动储备 liquidReserves,则被使用的储备 utilizedReserves = 原先被使用的储备 -(当前合约中的代币储备 - 流动储备);
    3. 当前流动性储备 = 当前合约中的代币储备;
  3. 计算汇率:
    1. 经过更新之后,汇率计算的分子:流动储备 + 被使用的储备 - 未被分配 LP 费用;
    2. 分子与LP 代币总供应量的比值即为换算汇率。

利用换算汇率,可以计算得到添加 l1TokenAmount 数量的代币时所能得到的 LP 代币的数量。

对于移除流动性,过程与添加流动性相反,这里不再赘述。

    function removeLiquidity(uint256 lpTokenAmount, bool sendEth) public nonReentrant() {
        // 如果是 WETH 池,则只能通过发送 ETH 来取出流动性
        require(!sendEth || isWethPool, "Cant send eth");
        uint256 l1TokensToReturn = (lpTokenAmount * _exchangeRateCurrent()) / 1e18;

        // 检查是否有足够的流储备来支持取款金额
        require(liquidReserves >= (pendingReserves + l1TokensToReturn), "Utilization too high to remove");

        _burn(msg.sender, lpTokenAmount);
        liquidReserves -= l1TokensToReturn;

        if (sendEth) _unwrapWETHTo(payable(msg.sender), l1TokensToReturn);
        else l1Token.safeTransfer(msg.sender, l1TokensToReturn);

        emit LiquidityRemoved(l1TokensToReturn, lpTokenAmount, msg.sender);
    }

慢速中继

慢速中继,以及之后要讨论的即时中继,都会用到 DepositDataRelayData 这两个数据,前者表示存框交易的数据,后者表示中继交易的信息。

		// 来自 L2 存款交易的数据。
    struct DepositData {
        uint256 chainId;
        uint64 depositId;
        address payable l1Recipient;
        address l2Sender;
        uint256 amount;
        uint64 slowRelayFeePct;
        uint64 instantRelayFeePct;
        uint32 quoteTimestamp;
    }

		// 每个 L2 存款在任何时候都可以进行一次中继尝试。 中继尝试的特征在于其 RelayData。
    struct RelayData {
        RelayState relayState;
        address slowRelayer;
        uint32 relayId;
        uint64 realizedLpFeePct;
        uint32 priceRequestTime;
        uint256 proposerBond;
        uint256 finalFee;
    }

下面我们看到 relayDeposit 方法,这个方法由中继者调用,执行从 L2 到 L1 的慢速中继。对于每一个存款而言,只能有一个待处理的中继,这个待处理的中继不包括有争议的中继。

    function relayDeposit(DepositData memory depositData, uint64 realizedLpFeePct)
        public
        onlyIfRelaysEnabld()
        nonReentrant()
    {
				// realizedLPFeePct 不超过 50%,慢速和即时中继费用不超过25%,费用合计不超过100%
        require(
            depositData.slowRelayFeePct <= 0.25e18 &&
                depositData.instantRelayFeePct <= 0.25e18 &&
                realizedLpFeePct <= 0.5e18,
            "Invalid fees"
        );

        // 查看是否已经有待处理的中继
        bytes32 depositHash = _getDepositHash(depositData);

				// 对于有争议的中继,relays 中对应的 hash 会被删除,这个条件可以通过
        require(relays[depositHash] == bytes32(0), "Pending relay exists");

				// 如果存款没有正在执行的中继,则关联调用者的中继尝试
        uint32 priceRequestTime = uint32(getCurrentTime());

        uint256 proposerBond = _getProposerBond(depositData.amount);

        // 保存新中继尝试参数的哈希值。
        // 注意:这个中继的活跃时间(liveness)可以在 BridgeAdmin 中更改,这意味着每个中继都有一个潜在的可变活跃时间。
				// 这不应该提供任何被利用机会,特别是因为 BridgeAdmin 状态(包括 liveness 值)被许可给跨域所有者。
				RelayData memory relayData =
            RelayData({
                relayState: RelayState.Pending,
                slowRelayer: msg.sender,
                relayId: numberOfRelays++, // 注意:在将 relayId 设置为其当前值的同时增加 numberOfRelays。
                realizedLpFeePct: realizedLpFeePct,
                priceRequestTime: priceRequestTime,
                proposerBond: proposerBond,
                finalFee: l1TokenFinalFee
            });
        relays[depositHash] = _getRelayDataHash(relayData);

        bytes32 relayHash = _getRelayHash(depositData, relayData);

				// 健全性检查池是否有足够的余额来支付中继金额 + 提议者奖励。 OptimisticOracle 价格请求经过挑战期后,将在结算时支付奖励金额。
        // 注意:liquidReserves 应该总是 <= balance - bonds。
        require(liquidReserves - pendingReserves >= depositData.amount, "Insufficient pool balance");

				// 计算总提议保证金并从调用者那里拉取,以便 OptimisticOracle 可以从这里拉取它。
        uint256 totalBond = proposerBond + l1TokenFinalFee;
        pendingReserves += depositData.amount; // 在正在处理的准备中预订此中继使用的最大流动性。
        bonds += totalBond;

        l1Token.safeTransferFrom(msg.sender, address(this), totalBond);
        emit DepositRelayed(depositHash, depositData, relayData, relayHash);
    }

可以看到,存款哈希与 depositData 有关,中继哈希与 depositDatarelayData 都有关。最后我们可以看到, relayDeposit 还未实际付款给用户的 L1 地址,需要等待中继者处理,或者通过加速处理中继。

加速中继

speedUpRelay 方法立即将存款金额减去费用后转发给 l1Recipient,即时中继者在待处理的中继挑战期后获得奖励。

    // 我们假设调用者已经执行了链外检查,以确保他们尝试中继的存款数据是有效的。
		// 如果存款数据无效,则即时中继者在无效存款数据发生争议后无权收回其资金。
		// 此外,没有人能够重新提交无效存款数据的中继,因为他们知道这将再次引起争议。
		// 另一方面,如果存款数据是有效的,那么即使它被错误地争议,即时中继者最终也会得到补偿,
		// 因为会激励其他人重新提交中继,以获得慢中继者的奖励。
		// 一旦有效中继最终确定,即时中继将得到补偿。因此,调用者在验证中继数据方面与争议者具有相同的责任。
		function speedUpRelay(DepositData memory depositData, RelayData memory relayData) public nonReentrant() {
        bytes32 depositHash = _getDepositHash(depositData);
        _validateRelayDataHash(depositHash, relayData);
        bytes32 instantRelayHash = _getInstantRelayHash(depositHash, relayData);
        require(
            // 只能在没有与之关联的现有即时中继的情况下加速待处理的中继。
            getCurrentTime() < relayData.priceRequestTime + optimisticOracleLiveness &&
                relayData.relayState == RelayState.Pending &&
                instantRelays[instantRelayHash] == address(0),
            "Relay cannot be sped up"
        );
        instantRelays[instantRelayHash] = msg.sender;

        // 从调用者那里提取中继金额减去费用并发送存款到 l1Recipient。
				// 支付的总费用是 LP 费用、中继费用和即时中继费用的总和。
        uint256 feesTotal =
            _getAmountFromPct(
                relayData.realizedLpFeePct + depositData.slowRelayFeePct + depositData.instantRelayFeePct,
                depositData.amount
            );
        // 如果 L1 代币是 WETH,那么:a) 从即时中继者提取 WETH b) 解包 WETH 为 ETH c) 将 ETH 发送给接收者。
        uint256 recipientAmount = depositData.amount - feesTotal;
        if (isWethPool) {
            l1Token.safeTransferFrom(msg.sender, address(this), recipientAmount);
            _unwrapWETHTo(depositData.l1Recipient, recipientAmount);
            // 否则,这是一个普通的 ERC20 代币。 发送给收件人。
        } else l1Token.safeTransferFrom(msg.sender, depositData.l1Recipient, recipientAmount);

        emit RelaySpedUp(depositHash, msg.sender, relayData);
    }

即时中继

relayAndSpeedUp 执行即时中继。这个方法的函数内容与 relayDepositspeedUpRelay 方法是一致的,这里就不具体注释了,可以参考前文中的注释。这个函数的代码几乎是直接将 relayDepositspeedUpRelay 的代码进行了合并,代码冗余。

    // 由 Relayer 调用以执行从 L2 到 L1 的慢 + 快中继,完成相应的存款订单。
    // 存款只能有一个待处理的中继。此方法实际上是串联的 relayDeposit 和 speedUpRelay 方法。
		// 这可以重构为只调用每个方法,但是结合传输和哈希计算可以节省一些 gas。
		function relayAndSpeedUp(DepositData memory depositData, uint64 realizedLpFeePct)
        public
        onlyIfRelaysEnabld()
        nonReentrant()
    {
        uint32 priceRequestTime = uint32(getCurrentTime());

        require(
            depositData.slowRelayFeePct <= 0.25e18 &&
                depositData.instantRelayFeePct <= 0.25e18 &&
                realizedLpFeePct <= 0.5e18,
            "Invalid fees"
        );

        bytes32 depositHash = _getDepositHash(depositData);

        require(relays[depositHash] == bytes32(0), "Pending relay exists");

        uint256 proposerBond = _getProposerBond(depositData.amount);

        RelayData memory relayData =
            RelayData({
                relayState: RelayState.Pending,
                slowRelayer: msg.sender,
                relayId: numberOfRelays++, // Note: Increment numberOfRelays at the same time as setting relayId to its current value.
                realizedLpFeePct: realizedLpFeePct,
                priceRequestTime: priceRequestTime,
                proposerBond: proposerBond,
                finalFee: l1TokenFinalFee
            });
        bytes32 relayHash = _getRelayHash(depositData, relayData);
        relays[depositHash] = _getRelayDataHash(relayData);

        bytes32 instantRelayHash = _getInstantRelayHash(depositHash, relayData);
        require(
            instantRelays[instantRelayHash] == address(0),
            "Relay cannot be sped up"
        );

        require(liquidReserves - pendingReserves >= depositData.amount, "Insufficient pool balance");

        uint256 totalBond = proposerBond + l1TokenFinalFee;

        uint256 feesTotal =
            _getAmountFromPct(
                relayData.realizedLpFeePct + depositData.slowRelayFeePct + depositData.instantRelayFeePct,
                depositData.amount
            );
        uint256 recipientAmount = depositData.amount - feesTotal;

        bonds += totalBond;
        pendingReserves += depositData.amount;

        instantRelays[instantRelayHash] = msg.sender;

        l1Token.safeTransferFrom(msg.sender, address(this), recipientAmount + totalBond);

        if (isWethPool) {
            _unwrapWETHTo(depositData.l1Recipient, recipientAmount);
        } else l1Token.safeTransfer(depositData.l1Recipient, recipientAmount);

        emit DepositRelayed(depositHash, depositData, relayData, relayHash);
        emit RelaySpedUp(depositHash, msg.sender, relayData);
    }

争议

当对待处理的中继提出争议时,争议者需要想 Optimistic Oracle 提交提案,并等待争议解决。

    // 由 Disputer 调用以对待处理的中继提出争议。
		// 这个方法的结果是总是抛出中继,为另一个中继者提供处理相同存款的机会。
		// 在争议者和提议者之间,谁不正确,谁就失去了他们的质押。谁是正确的,谁就拿回来并获得一笔钱。
		function disputeRelay(DepositData memory depositData, RelayData memory relayData) public nonReentrant() {
        require(relayData.priceRequestTime + optimisticOracleLiveness > getCurrentTime(), "Past liveness");
        require(relayData.relayState == RelayState.Pending, "Not disputable");
        // 检验输入数据
        bytes32 depositHash = _getDepositHash(depositData);
        _validateRelayDataHash(depositHash, relayData);

        // 将提案和争议提交给 Optimistic Oracle。
        bytes32 relayHash = _getRelayHash(depositData, relayData);

        // 注意:在某些情况下,这会由于 Optimistic Oracle 的变化而失败,并且该方法将退还中继者。
        bool success =
            _requestProposeDispute(
                relayData.slowRelayer,
                msg.sender,
                relayData.proposerBond,
                relayData.finalFee,
                _getRelayAncillaryData(relayHash)
            );

				// 放弃中继并从跟踪的保证金中移除中继的保证金。
        bonds -= relayData.finalFee + relayData.proposerBond;
        pendingReserves -= depositData.amount;
        delete relays[depositHash];
        if (success) emit RelayDisputed(depositHash, _getRelayDataHash(relayData), msg.sender);
        else emit RelayCanceled(depositHash, _getRelayDataHash(relayData), msg.sender);
    }

其中, _requestProposeDispute 的函数内容如下:

    // 向 optimistic oracle 提议与 `customAncillaryData` 相关的中继事件的新价格为真。
		// 如果有人不同意中继参数,不管他们是否映射到 L2 存款,他们可以与预言机争议。
    function _requestProposeDispute(
        address proposer,
        address disputer,
        uint256 proposerBond,
        uint256 finalFee,
        bytes memory customAncillaryData
    ) private returns (bool) {
        uint256 totalBond = finalFee + proposerBond;
        l1Token.safeApprove(address(optimisticOracle), totalBond);
        try
            optimisticOracle.requestAndProposePriceFor(
                identifier,
                uint32(getCurrentTime()),
                customAncillaryData,
                IERC20(l1Token),
                // 将奖励设置为 0,因为在中继提案经过挑战期后,我们将直接从该合约中结算提案人奖励支出。
                0,
                // 为价格请求设置 Optimistic oracle 提议者保证金。
                proposerBond,
                // 为价格请求设置 Optimistic oracle 活跃时间。
                optimisticOracleLiveness,
                proposer,
                // 表示 "True"; 及提议的中继是合法的
                int256(1e18)
            )
        returns (uint256 bondSpent) {
            if (bondSpent < totalBond) {
                // 如果 Optimistic oracle 拉取得更少(由于最终费用的变化),则退还提议者。
                uint256 refund = totalBond - bondSpent;
                l1Token.safeTransfer(proposer, refund);
                l1Token.safeApprove(address(optimisticOracle), 0);
                totalBond = bondSpent;
            }
        } catch {
            // 如果 Optimistic oracle 中出现错误,这意味着已经更改了某些内容以使该请求无可争议。
						// 为确保请求不会默认通过,退款提议者并提前返回,允许调用方法删除请求,但 Optimistic oracle 没有额外的追索权。
            l1Token.safeTransfer(proposer, totalBond);
            l1Token.safeApprove(address(optimisticOracle), 0);

            // 提早返回,注意到提案+争议的尝试没有成功。
            return false;
        }

        SkinnyOptimisticOracleInterface.Request memory request =
            SkinnyOptimisticOracleInterface.Request({
                proposer: proposer,
                disputer: address(0),
                currency: IERC20(l1Token),
                settled: false,
                proposedPrice: int256(1e18),
                resolvedPrice: 0,
                expirationTime: getCurrentTime() + optimisticOracleLiveness,
                reward: 0,
                finalFee: totalBond - proposerBond,
                bond: proposerBond,
                customLiveness: uint256(optimisticOracleLiveness)
            });

        // 注意:在此之前不要提取资金,以避免任何不需要的转账。
        l1Token.safeTransferFrom(msg.sender, address(this), totalBond);
        l1Token.safeApprove(address(optimisticOracle), totalBond);
        // 对我们刚刚发送的请求提出争议。
        optimisticOracle.disputePriceFor(
            identifier,
            uint32(getCurrentTime()),
            customAncillaryData,
            request,
            disputer,
            address(this)
        );

        // 返回 true 表示提案 + 争议调用成功。
        return true;
    }

最后,我们来看看 settleRelay

    // 如果待处理中继价格请求在 OptimisticOracle 上有可用的价格,则奖励中继者,并将中继标记为完成。
	  // 我们使用 relayData 和 depositData 来计算中继价格请求在 OptimisticOracle 上唯一关联的辅助数据。
		// 如果传入的价格请求与待处理的中继价格请求不匹配,那么这将恢复(revert)。
		function settleRelay(DepositData memory depositData, RelayData memory relayData) public nonReentrant() {
        bytes32 depositHash = _getDepositHash(depositData);
        _validateRelayDataHash(depositHash, relayData);
        require(relayData.relayState == RelayState.Pending, "Already settled");
        uint32 expirationTime = relayData.priceRequestTime + optimisticOracleLiveness;
        require(expirationTime <= getCurrentTime(), "Not settleable yet");

        // 注意:此检查是为了给中继者一小段但合理的时间来完成中继,然后再被其他人“偷走”。
				// 这是为了确保有动力快速解决中继。
        require(
            msg.sender == relayData.slowRelayer || getCurrentTime() > expirationTime + 15 minutes,
            "Not slow relayer"
        );

        // 将中继状态更新为已完成。 这可以防止中继的任何重新设处理。
        relays[depositHash] = _getRelayDataHash(
            RelayData({
                relayState: RelayState.Finalized,
                slowRelayer: relayData.slowRelayer,
                relayId: relayData.relayId,
                realizedLpFeePct: relayData.realizedLpFeePct,
                priceRequestTime: relayData.priceRequestTime,
                proposerBond: relayData.proposerBond,
                finalFee: relayData.finalFee
            })
        );

        // 奖励中继者并支付 l1Recipient。
         // 此时有两种可能的情况:
         // - 这是一个慢速中继:在这种情况下,a) 向慢速中继者支付奖励 b) 向 l1Recipient 支付
         //   金额减去已实现的 LP 费用和慢速中继费用。 转账没有加快,所以没有即时费用。
         // - 这是一个即时中继:在这种情况下,a) 向慢速中继者支付奖励 b) 向即时中继者支付
         //   全部桥接金额,减去已实现的 LP 费用并减去慢速中继费用。
				//    当即时中继者调用 speedUpRelay 时,它们存入的金额相同,减去即时中继者费用。
				//    结果,他们实际上得到了加速中继时所花费的费用 + InstantRelayFee。

        uint256 instantRelayerOrRecipientAmount =
            depositData.amount -
                _getAmountFromPct(relayData.realizedLpFeePct + depositData.slowRelayFeePct, depositData.amount);

        // 如果即时中继参数与批准的中继相匹配,则退款给即时中继者。
        bytes32 instantRelayHash = _getInstantRelayHash(depositHash, relayData);
        address instantRelayer = instantRelays[instantRelayHash];

        // 如果这是 WETH 池并且即时中继者是地址 0x0(即中继没有加速),那么:
        // a) 将 WETH 提取到 ETH 和 b) 将 ETH 发送给接收者。
        if (isWethPool && instantRelayer == address(0)) {
            _unwrapWETHTo(depositData.l1Recipient, instantRelayerOrRecipientAmount);
            // 否则,这是一个正常的慢速中继正在完成,合约将 ERC20 发送给接收者,
						// 或者这是一个即时中继的最终完成,我们需要用 WETH 偿还即时中继者。
        } else
            l1Token.safeTransfer(
                instantRelayer != address(0) ? instantRelayer : depositData.l1Recipient,
                instantRelayerOrRecipientAmount
            );

        // 需要支付费用和保证金。费用归解决者。保证金总是归到慢速中继者。
        // 注意:为了 gas 效率,我们使用 `if`,所以如果它们是相同的地址,我们可以合并这些转账。
        uint256 slowRelayerReward = _getAmountFromPct(depositData.slowRelayFeePct, depositData.amount);
        uint256 totalBond = relayData.finalFee + relayData.proposerBond;
        if (relayData.slowRelayer == msg.sender)
            l1Token.safeTransfer(relayData.slowRelayer, slowRelayerReward + totalBond);
        else {
            l1Token.safeTransfer(relayData.slowRelayer, totalBond);
            l1Token.safeTransfer(msg.sender, slowRelayerReward);
        }

        uint256 totalReservesSent = instantRelayerOrRecipientAmount + slowRelayerReward;

        // 按更改的金额和分配的 LP 费用更新储备。
        pendingReserves -= depositData.amount;
        liquidReserves -= totalReservesSent;
        utilizedReserves += int256(totalReservesSent);
        bonds -= totalBond;
        _updateAccumulatedLpFees();
        _allocateLpFees(_getAmountFromPct(relayData.realizedLpFeePct, depositData.amount));

        emit RelaySettled(depositHash, msg.sender, relayData);

        // 清理状态存储并获得gas退款。
				// 这也可以防止 `priceDisputed()` 重置这个新的 Finalized 中继状态。
        delete instantRelays[instantRelayHash];
    }

    function _allocateLpFees(uint256 allocatedLpFees) internal {
        undistributedLpFees += allocatedLpFees;
        utilizedReserves += int256(allocatedLpFees);
    }

至此,我们分析完了 Across 合约的主要功能的代码。

合约部署

部署合约目录 deploy 下包含 8 脚本,依次部署了管理合约,WETH 桥接池,Optimism,Arbitrum和Boba的信使,以及 Arbitrum,Optimism 和 Boba 的存款合约。由于过程比较简单,这里就不仔细分析了。

deploy/
├── 001_deploy_across_bridge_admin.js
├── 002_deploy_across_weth_bridge_pool.js
├── 003_deploy_across_optimism_wrapper.js
├── 004_deploy_across_optimism_messenger.js
├── 005_deploy_across_arbitrum_messenger.js
├── 006_deploy_across_boba_messenger.js
├── 007_deploy_across_ovm_bridge_deposit_box.js
└── 008_deploy_across_avm_deposit_box.js

总结

Across 协议整体结构简单,流程清晰,支持了 Across 协议安全,快速的从 L2 向 L1 的资金转移。

代码中调用了 Optimistic Oracle 的接口来出和解决争议,对应的逻辑有空之后详说。

View on GitHub