-
Notifications
You must be signed in to change notification settings - Fork 65
feat: Add universal adapters and spoke pools #916
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
76 commits
Select commit
Hold shift + click to select a range
9f7e40c
fix(ZkSync_SpokePool): Add __gap (#907)
nicholaspai 334a753
feat: Add SP1_Adapter and SP1_SpokePool
nicholaspai 82d0ae1
Remove sp1 import
nicholaspai b29d91e
Add simple test
nicholaspai 813b79e
Re-use storage slots in HubPoolStore
nicholaspai ec25d2d
Update SP1_SpokePool.sol
nicholaspai 492f8cc
Added replay protection
nicholaspai dcc276b
Updated event
nicholaspai a41143a
Don't include nonce in data hash, emit data hash
nicholaspai 7d6e76d
Store relayRootBundle calldata with no nonce for gas optimization
nicholaspai 6f7eeaa
Rename contract public values variables to make it clearer how storag…
nicholaspai 46824c4
Add `OFTTransportAdapter` to support cross-chain token transfers of `…
grasphoper d39572f
In HubPoolStore, store byes rather than a struct, add simple unit tests
nicholaspai c312abc
Merge branch 'march-25-evm-audit' into sp1-adapter
nicholaspai 3516fe2
Update SP1_SpokePool.sol
nicholaspai 53661d5
Merge branch 'master' into march-25-evm-audit
nicholaspai b9a298e
feat(SpokePoolPeriphery): Support multiple exchanges (#777)
nicholaspai 3281dd5
Single AddressBook for all adapters (#919)
grasphoper 5bedb25
Merge branch 'master' into march-25-evm-audit
nicholaspai df6835d
Rename to universal adapter
nicholaspai 7870b37
Update UniversalEventInclusionProof_Adapter.sol
nicholaspai a7c5405
Add R0_SpokePool capable of receiving events via event inclusion proofs
nicholaspai f73ba08
Update R0Steel.sol
nicholaspai 92bec66
Steel light client commitments
nicholaspai 590cb8d
Update R0_SpokePool.sol
nicholaspai 140df6f
read storage slots from Helios
nicholaspai 92f1ba8
Change Steel call to more likely interface validateCommitment
nicholaspai d7ef746
Link Steel to SP1Helios light client
nicholaspai 6250810
Update HeliosSteelValidator.sol
nicholaspai 0e55d1a
rename to IHelios
nicholaspai 74685ce
Pass in Journal instead of bytes; use eventKey as replay protection key.
nicholaspai 31fd0d6
Add OFT adapter to L2 spoke pools
nicholaspai 886a2f8
Merge branch 'march-25-evm-audit' into sp1-adapter
nicholaspai eb7bebc
Add OFT adapter to Universal adapter
nicholaspai a258ddc
Replace OFT/HYP with CCTP
nicholaspai 3ee84dc
Update SP1_SpokePool.t.sol
nicholaspai 2ddcd9b
merge
nicholaspai b7a2b84
Rebase to master
nicholaspai 1f381e0
fix
nicholaspai d81a433
Add hub pool store deploy script
nicholaspai 6af1a29
Update Journal params
nicholaspai 7c33e97
Update SP1_SpokePool.sol
nicholaspai 64f5d2b
remove verifier from SP1SpokePool
nicholaspai eda33dd
update logs
nicholaspai 16efaff
Update SP1_SpokePool.sol
nicholaspai 3eb3636
Rename Sp1SpokePool to StorageProofSpokePool, remove R0 Spoke
nicholaspai e84fdea
use challenge period timestamp as nonce in storage proof adapter
nicholaspai 5d55f68
Update UniversalStorageProof_SpokePool.sol
nicholaspai ff4723a
Use slotKey as data hash
nicholaspai 0cf31da
Add test to spoke pool about dataHash
nicholaspai b150beb
Allow admin root bundles in between livenesses
nicholaspai 8dc5a37
Add checks for admin root bundle
nicholaspai a6558b8
Add fallback function to spoke pool
nicholaspai 92442b8
move HubPoolStore to utilities folder
nicholaspai 0221127
Remove challenge period timestamp check
nicholaspai 4a30881
Adds more robust isAdminRootBundle check, plus unit tests
nicholaspai f5beb6a
add placeholder unit tests
nicholaspai ef94793
Update IHelios.sol
nicholaspai 96eb47f
Update HubPoolStore.sol
nicholaspai b923181
Finish universal adapter tests
nicholaspai 6f3f917
Store bytes32 at relayAdminFunctionCalldata
nicholaspai 5db8b3e
Finish tests
nicholaspai 9175ed5
Use uint256 nonce as key in HubPoolStore
nicholaspai add3d73
rename deploy scripts
nicholaspai 130bb2f
Change isAdminSender check
nicholaspai f359788
Add helios.headTimestamp check
nicholaspai 62558c7
Compute slot key in contract to improve UX
nicholaspai 861c005
Update checkStorageLayout.sh
nicholaspai b97738f
Delete UniversalStorageProof_SpokePool.json
nicholaspai 7de065f
Delete UniversalStorageProof_SpokePool.json
nicholaspai 6afc7c2
Rename shorter
nicholaspai 6441601
Update utils.hre.ts
nicholaspai c296187
Update admin messaging
nicholaspai b9045f8
Add storage layout
nicholaspai 0484025
Merge branch 'march-evm-audit-universal-adapter' into sp1-adapter
nicholaspai dd8dcec
fix
nicholaspai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,190 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity ^0.8.0; | ||
|
||
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; | ||
|
||
import { IHelios } from "./external/interfaces/IHelios.sol"; | ||
import "./libraries/CircleCCTPAdapter.sol"; | ||
|
||
import "./SpokePool.sol"; | ||
|
||
/** | ||
* @notice Spoke pool capable of executing calldata stored in L1 state via storage proof + Helios light client. | ||
* @dev This contract has one onlyOwner function to be used as an emergency fallback to execute a message to | ||
* this SpokePool in the case where the light-client is not functioning correctly. The owner is designed to be set | ||
* to a multisig contract on this chain. | ||
*/ | ||
contract Universal_SpokePool is OwnableUpgradeable, SpokePool, CircleCCTPAdapter { | ||
/// @notice The data store contract that only the HubPool can write to. This spoke pool can only act on | ||
/// data that has been written to this store. | ||
address public immutable hubPoolStore; | ||
|
||
/// @notice Slot index of the HubPoolStore's relayMessageCallData mapping. | ||
uint256 public constant HUB_POOL_STORE_CALLDATA_MAPPING_SLOT_INDEX = 0; | ||
|
||
/// @notice The address of the Helios L1 light client contract. | ||
address public immutable helios; | ||
|
||
/// @notice The owner of this contract must wait until this amount of seconds have passed since the latest | ||
/// helios light client update to emergency execute a message. This prevents the owner from executing a message | ||
/// in the happy case where the light client is being regularly updated. Therefore, this value should be | ||
/// set to a very high value, like 24 hours. | ||
uint256 public immutable ADMIN_UPDATE_BUFFER; | ||
|
||
/// @notice Stores all proofs verified to prevent replay attacks. | ||
mapping(uint256 => bool) public verifiedProofs; | ||
|
||
// Warning: this variable should _never_ be touched outside of this contract. It is intentionally set to be | ||
// private. Leaving it set to true can permanently disable admin calls. | ||
bool private _adminCallValidated; | ||
|
||
/// @notice Event emitted after off-chain agent sees HubPoolStore's emitted StoredCallData event and calls | ||
/// executeMessage() on this contract to relay the stored calldata. | ||
event RelayedCallData(uint256 indexed nonce, address caller); | ||
|
||
error NotTarget(); | ||
error AdminCallAlreadySet(); | ||
error SlotValueMismatch(); | ||
error AdminCallNotValidated(); | ||
error DelegateCallFailed(); | ||
error AlreadyExecuted(); | ||
error NotImplemented(); | ||
error AdminUpdateTooCloseToLastHeliosUpdate(); | ||
|
||
// All calls that have admin privileges must be fired from within the receiveL1State method that validates that | ||
// the input data was published on L1 by the HubPool. This input data is then executed on this contract. | ||
// This modifier sets the adminCallValidated variable so this condition can be checked in _requireAdminSender(). | ||
modifier validateInternalCalls() { | ||
// Make sure adminCallValidated is set to True only once at beginning of the function, which prevents | ||
// the function from being re-entered. | ||
if (_adminCallValidated) { | ||
revert AdminCallAlreadySet(); | ||
} | ||
|
||
// This sets a variable indicating that we're now inside a validated call. | ||
// Note: this is used by other methods to ensure that this call has been validated by this method and is not | ||
// spoofed. | ||
_adminCallValidated = true; | ||
|
||
_; | ||
|
||
// Reset adminCallValidated to false to disallow admin calls after this method exits. | ||
_adminCallValidated = false; | ||
} | ||
|
||
/// @custom:oz-upgrades-unsafe-allow constructor | ||
constructor( | ||
uint256 _adminUpdateBufferSeconds, | ||
address _helios, | ||
address _hubPoolStore, | ||
address _wrappedNativeTokenAddress, | ||
uint32 _depositQuoteTimeBuffer, | ||
uint32 _fillDeadlineBuffer, | ||
IERC20 _l2Usdc, | ||
ITokenMessenger _cctpTokenMessenger | ||
) | ||
SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) | ||
CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) | ||
{ | ||
ADMIN_UPDATE_BUFFER = _adminUpdateBufferSeconds; | ||
helios = _helios; | ||
hubPoolStore = _hubPoolStore; | ||
} | ||
|
||
function initialize( | ||
uint32 _initialDepositId, | ||
address _crossDomainAdmin, | ||
address _withdrawalRecipient | ||
) public initializer { | ||
__Ownable_init(); | ||
__SpokePool_init(_initialDepositId, _crossDomainAdmin, _withdrawalRecipient); | ||
} | ||
|
||
/** | ||
* @notice Relays calldata stored by the HubPool on L1 into this contract. | ||
* @dev Replay attacks are possible with this _message if this contract has the same address on another chain. | ||
* @param _messageNonce Nonce of message stored in HubPoolStore. | ||
* @param _message Message stored in HubPoolStore. Compared against hashed value in light client for slot key | ||
* corresponding to _messageNonce at block number. | ||
* @param _blockNumber Block number in light client we use to check slot value of slot key | ||
*/ | ||
function executeMessage( | ||
uint256 _messageNonce, | ||
bytes calldata _message, | ||
uint256 _blockNumber | ||
) external validateInternalCalls { | ||
bytes32 slotKey = getSlotKey(_messageNonce); | ||
bytes32 expectedSlotValueHash = keccak256(_message); | ||
|
||
// Verify Helios light client has expected slot value. | ||
bytes32 slotValueHash = IHelios(helios).getStorageSlot(_blockNumber, hubPoolStore, slotKey); | ||
if (expectedSlotValueHash != slotValueHash) { | ||
revert SlotValueMismatch(); | ||
} | ||
|
||
// Validate state is intended to be sent to this contract. The target could have been set to the zero address | ||
// which is used by the StorageProof_Adapter to denote messages that can be sent to any target. | ||
(address target, bytes memory message) = abi.decode(_message, (address, bytes)); | ||
if (target != address(0) && target != address(this)) { | ||
revert NotTarget(); | ||
} | ||
|
||
// Prevent replay attacks. The slot key should be a hash of the nonce associated with this calldata in the | ||
// HubPoolStore, which maps the nonce to the _value. | ||
if (verifiedProofs[_messageNonce]) { | ||
revert AlreadyExecuted(); | ||
} | ||
verifiedProofs[_messageNonce] = true; | ||
emit RelayedCallData(_messageNonce, msg.sender); | ||
|
||
_executeCalldata(message); | ||
} | ||
|
||
/** | ||
* @notice This function is only callable by the owner and is used as an emergency fallback to execute | ||
* calldata to this SpokePool in the case where the light-client is not able to be updated. | ||
* @dev This function will revert if the last Helios update was less than ADMIN_UPDATE_BUFFER seconds ago. | ||
* @param _message The calldata to execute on this contract. | ||
*/ | ||
function adminExecuteMessage(bytes memory _message) external onlyOwner validateInternalCalls { | ||
uint256 heliosHeadTimestamp = IHelios(helios).headTimestamp(); | ||
if (heliosHeadTimestamp > block.timestamp || block.timestamp - heliosHeadTimestamp < ADMIN_UPDATE_BUFFER) { | ||
revert AdminUpdateTooCloseToLastHeliosUpdate(); | ||
} | ||
_executeCalldata(_message); | ||
} | ||
|
||
/** | ||
* @notice Computes the EVM storage slot key for a message nonce using the formula keccak256(key, slotIndex) | ||
* to find the storage slot for a value within a mapping(key=>value) at a slot index. We already know the | ||
* slot index of the relayMessageCallData mapping in the HubPoolStore. | ||
* @param _nonce The nonce associated with the message. | ||
* @return The computed storage slot key. | ||
*/ | ||
function getSlotKey(uint256 _nonce) public pure returns (bytes32) { | ||
return keccak256(abi.encode(_nonce, HUB_POOL_STORE_CALLDATA_MAPPING_SLOT_INDEX)); | ||
} | ||
|
||
function _executeCalldata(bytes memory _calldata) internal { | ||
/// @custom:oz-upgrades-unsafe-allow delegatecall | ||
(bool success, ) = address(this).delegatecall(_calldata); | ||
if (!success) { | ||
revert DelegateCallFailed(); | ||
} | ||
} | ||
|
||
function _bridgeTokensToHubPool(uint256 amountToReturn, address l2TokenAddress) internal override { | ||
if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { | ||
_transferUsdc(withdrawalRecipient, amountToReturn); | ||
} else { | ||
revert NotImplemented(); | ||
} | ||
} | ||
|
||
// Check that the admin call is only triggered by a receiveL1State() call. | ||
function _requireAdminSender() internal view override { | ||
if (!_adminCallValidated) { | ||
revert AdminCallNotValidated(); | ||
} | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity ^0.8.0; | ||
|
||
import "./interfaces/AdapterInterface.sol"; | ||
|
||
import "../libraries/CircleCCTPAdapter.sol"; | ||
import { SpokePoolInterface } from "../interfaces/SpokePoolInterface.sol"; | ||
import { HubPoolStore } from "./utilities/HubPoolStore.sol"; | ||
|
||
interface IOwnable { | ||
function owner() external view returns (address); | ||
} | ||
|
||
/** | ||
* @notice Stores data that can be relayed to L2 SpokePool using storage proof verification and light client contracts | ||
* on the L2 where the SpokePool is deployed. Designed to be used as a singleton contract that can be used to relay | ||
* messages to multiple SpokePools on different chains. | ||
* @dev This contract should NOT be reused to send messages to SpokePools that have the same address on different L2s. | ||
* @dev This contract can be redeployed to point to a new HubPoolStore if the data store gets corrupted and new data | ||
* can't get written to the store for some reason. The corresponding Universal_SpokePool contract will | ||
* also need to be redeployed to point to the new HubPoolStore. | ||
*/ | ||
contract Universal_Adapter is AdapterInterface, CircleCCTPAdapter { | ||
/// @notice Contract that stores calldata to be relayed to L2 via storage proofs. | ||
HubPoolStore public immutable DATA_STORE; | ||
|
||
error NotImplemented(); | ||
|
||
constructor( | ||
HubPoolStore _store, | ||
IERC20 _l1Usdc, | ||
ITokenMessenger _cctpTokenMessenger, | ||
uint32 _cctpDestinationDomainId | ||
) CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, _cctpDestinationDomainId) { | ||
DATA_STORE = _store; | ||
} | ||
|
||
/** | ||
* @notice Saves calldata in a simple storage contract whose state can be proven and relayed to L2. | ||
* @param target Contract on the destination that will receive the message. Unused if the message is created | ||
* by the HubPool admin. | ||
* @param message Data to send to target. | ||
*/ | ||
function relayMessage(address target, bytes calldata message) external payable override { | ||
// Admin messages are stored differently in the data store than non-admin messages, because admin | ||
// messages must only be sent to a single target on a specific L2 chain. Non-admin messages are sent | ||
// to any target on any L2 chain because the only type of an non-admin message is the result of a | ||
// HubPool.executeRootBundle() call which attempts to relay a relayRootBundle() call to all SpokePools using | ||
// this adapter. Therefore, non-admin messages are stored optimally in the data store | ||
// by only storing the message once and allowing any SpokePool target to read it via storage proofs. | ||
|
||
// We assume that the HubPool is delegatecall-ing into this function, therefore address(this) is the HubPool's | ||
// address. As a result, we can determine whether this message is an admin function based on the msg.sender. | ||
// If an admin sends a message that could have been relayed as a non-admin message (e.g. the admin | ||
// calls executeRootBundle()), then the message won't be stored optimally in the data store, but the | ||
// message can still be delivered to the target. | ||
bool isAdminSender = msg.sender == IOwnable(address(this)).owner(); | ||
DATA_STORE.storeRelayMessageCalldata(target, message, isAdminSender); | ||
emit MessageRelayed(target, message); | ||
} | ||
|
||
/** | ||
* @notice Relays tokens from L1 to L2. | ||
* @dev This function only uses the CircleCCTPAdapter to relay USDC tokens to CCTP enabled L2 chains. | ||
* Relaying other tokens will cause this function to revert. | ||
* @param l1Token Address of the token on L1. | ||
* @param l2Token Address of the token on L2. Unused | ||
* @param amount Amount of tokens to relay. | ||
* @param to Address to receive the tokens on L2. Should be SpokePool address. | ||
*/ | ||
function relayTokens( | ||
address l1Token, | ||
address l2Token, | ||
uint256 amount, | ||
address to | ||
) external payable override { | ||
if (_isCCTPEnabled() && l1Token == address(usdcToken)) { | ||
_transferUsdc(to, amount); | ||
} else { | ||
revert NotImplemented(); | ||
} | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity ^0.8.0; | ||
|
||
import { HubPoolInterface } from "../../interfaces/HubPoolInterface.sol"; | ||
|
||
interface IHubPool { | ||
function rootBundleProposal() external view returns (HubPoolInterface.RootBundle memory); | ||
} | ||
|
||
/** | ||
* @notice Stores data that can be relayed to L2 SpokePool using storage proof verification and light client contracts | ||
* on the L2 where the SpokePool is deployed. Only the HubPool can store data to this contract. Each data to be | ||
* relayed is written to a unique slot key and that slot key's value can never be modified. | ||
* @dev Designed to be used with Universal_Adapter and Universal_SpokePool. | ||
* @dev This contract DOES NOT prevent replay attacks of storage proofs on the L2 spoke pool if the | ||
* UniversalStorageProof_Adapters using this contract are mapped to spokepools with the same address on different | ||
* L2 chains. See comment in storeRelayAdminFunctionCalldata() for more details. | ||
*/ | ||
contract HubPoolStore { | ||
error NotHubPool(); | ||
|
||
/// @notice Maps nonce to hash of calldata. | ||
mapping(uint256 => bytes32) public relayMessageCallData; | ||
|
||
/// @notice Counter to ensure that each relay admin function calldata is unique. | ||
uint256 private dataUuid; | ||
|
||
/// @notice Address of the HubPool contract, the only contract that can store data to this contract. | ||
address public immutable hubPool; | ||
|
||
/// @notice Event designed to be queried off chain and relayed to Universal SpokePool. | ||
event StoredCallData(address indexed target, bytes data, uint256 indexed nonce); | ||
|
||
modifier onlyHubPool() { | ||
if (msg.sender != hubPool) { | ||
revert NotHubPool(); | ||
} | ||
_; | ||
} | ||
|
||
constructor(address _hubPool) { | ||
hubPool = _hubPool; | ||
} | ||
|
||
/** | ||
* @notice To be called by HubPool to store calldata that will be relayed | ||
* to the Universal_SpokePool via storage proofs. | ||
* @dev Only callable by the HubPool contract. | ||
* @param target Address of the contract on the destination that will receive the message. Unused if the | ||
* data is NOT an admin function and can be relayed to any target. | ||
* @param data Data to send to Universal SpokePool. | ||
* @param isAdminSender True if the data is an admin function call, false otherwise. | ||
*/ | ||
function storeRelayMessageCalldata( | ||
address target, | ||
bytes calldata data, | ||
bool isAdminSender | ||
) external onlyHubPool { | ||
if (isAdminSender) { | ||
_storeData(target, dataUuid++, data); | ||
} else { | ||
_storeRelayMessageCalldataForAnyTarget(data); | ||
} | ||
} | ||
|
||
function _storeRelayMessageCalldataForAnyTarget(bytes calldata data) internal { | ||
// When the data can be sent to any target, we assume that the data contains a relayRootBundleCall as | ||
// constructed by an executeRootBundle() call, therefore this data will be identical for all spoke pools | ||
// in this bundle. We can use the current hub pool's challengePeriodEndTimestamp as the nonce for this data | ||
// so that all relayRootBundle calldata for this bundle gets stored to the same slot and we only write to | ||
// this slot once. | ||
_storeData(address(0), IHubPool(hubPool).rootBundleProposal().challengePeriodEndTimestamp, data); | ||
nicholaspai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
function _storeData( | ||
address target, | ||
uint256 nonce, | ||
bytes calldata data | ||
) internal { | ||
if (relayMessageCallData[nonce] != bytes32(0)) { | ||
// Data is already stored, do nothing. | ||
return; | ||
} | ||
relayMessageCallData[nonce] = keccak256(abi.encode(target, data)); | ||
emit StoredCallData(target, data, nonce); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity ^0.8.0; | ||
|
||
/// @notice SP1HeliosLightClient | ||
/// https://github.com/succinctlabs/sp1-helios/blob/776337bf8b63bcf9beebad143e8981020dec2b52/contracts/src/SP1Helios.sol | ||
interface IHelios { | ||
/// @notice Gets the value of a storage slot at a specific block | ||
/// @dev Function added to Helios in https://github.com/across-protocol/sp1-helios/pull/2 | ||
function getStorageSlot( | ||
uint256 blockNumber, | ||
address contractAddress, | ||
bytes32 slot | ||
) external view returns (bytes32); | ||
|
||
function headTimestamp() external view returns (uint256); | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.