qiwihui

Posted on Mar 18, 2022Read on Mirror.xyz

Across 跨链桥合约解析

Mar 18, 202242 min. read

什么是 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 的接口来出和解决争议,对应的逻辑有空之后详说。