Skip to content

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 76 commits into from
Mar 29, 2025
Merged
Show file tree
Hide file tree
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 Mar 10, 2025
334a753
feat: Add SP1_Adapter and SP1_SpokePool
nicholaspai Mar 11, 2025
82d0ae1
Remove sp1 import
nicholaspai Mar 11, 2025
b29d91e
Add simple test
nicholaspai Mar 11, 2025
813b79e
Re-use storage slots in HubPoolStore
nicholaspai Mar 12, 2025
ec25d2d
Update SP1_SpokePool.sol
nicholaspai Mar 12, 2025
492f8cc
Added replay protection
nicholaspai Mar 12, 2025
dcc276b
Updated event
nicholaspai Mar 12, 2025
a41143a
Don't include nonce in data hash, emit data hash
nicholaspai Mar 12, 2025
7d6e76d
Store relayRootBundle calldata with no nonce for gas optimization
nicholaspai Mar 12, 2025
6f7eeaa
Rename contract public values variables to make it clearer how storag…
nicholaspai Mar 12, 2025
46824c4
Add `OFTTransportAdapter` to support cross-chain token transfers of `…
grasphoper Mar 12, 2025
d39572f
In HubPoolStore, store byes rather than a struct, add simple unit tests
nicholaspai Mar 13, 2025
c312abc
Merge branch 'march-25-evm-audit' into sp1-adapter
nicholaspai Mar 13, 2025
3516fe2
Update SP1_SpokePool.sol
nicholaspai Mar 13, 2025
53661d5
Merge branch 'master' into march-25-evm-audit
nicholaspai Mar 13, 2025
b9a298e
feat(SpokePoolPeriphery): Support multiple exchanges (#777)
nicholaspai Mar 13, 2025
3281dd5
Single AddressBook for all adapters (#919)
grasphoper Mar 13, 2025
5bedb25
Merge branch 'master' into march-25-evm-audit
nicholaspai Mar 17, 2025
df6835d
Rename to universal adapter
nicholaspai Mar 17, 2025
7870b37
Update UniversalEventInclusionProof_Adapter.sol
nicholaspai Mar 17, 2025
a7c5405
Add R0_SpokePool capable of receiving events via event inclusion proofs
nicholaspai Mar 18, 2025
f73ba08
Update R0Steel.sol
nicholaspai Mar 18, 2025
92bec66
Steel light client commitments
nicholaspai Mar 18, 2025
590cb8d
Update R0_SpokePool.sol
nicholaspai Mar 18, 2025
140df6f
read storage slots from Helios
nicholaspai Mar 18, 2025
92f1ba8
Change Steel call to more likely interface validateCommitment
nicholaspai Mar 19, 2025
d7ef746
Link Steel to SP1Helios light client
nicholaspai Mar 19, 2025
6250810
Update HeliosSteelValidator.sol
nicholaspai Mar 19, 2025
0e55d1a
rename to IHelios
nicholaspai Mar 19, 2025
74685ce
Pass in Journal instead of bytes; use eventKey as replay protection key.
nicholaspai Mar 24, 2025
31fd0d6
Add OFT adapter to L2 spoke pools
nicholaspai Mar 24, 2025
886a2f8
Merge branch 'march-25-evm-audit' into sp1-adapter
nicholaspai Mar 24, 2025
eb7bebc
Add OFT adapter to Universal adapter
nicholaspai Mar 24, 2025
a258ddc
Replace OFT/HYP with CCTP
nicholaspai Mar 24, 2025
3ee84dc
Update SP1_SpokePool.t.sol
nicholaspai Mar 24, 2025
2ddcd9b
merge
nicholaspai Mar 24, 2025
b7a2b84
Rebase to master
nicholaspai Mar 24, 2025
1f381e0
fix
nicholaspai Mar 24, 2025
d81a433
Add hub pool store deploy script
nicholaspai Mar 24, 2025
6af1a29
Update Journal params
nicholaspai Mar 24, 2025
7c33e97
Update SP1_SpokePool.sol
nicholaspai Mar 24, 2025
64f5d2b
remove verifier from SP1SpokePool
nicholaspai Mar 25, 2025
eda33dd
update logs
nicholaspai Mar 25, 2025
16efaff
Update SP1_SpokePool.sol
nicholaspai Mar 25, 2025
3eb3636
Rename Sp1SpokePool to StorageProofSpokePool, remove R0 Spoke
nicholaspai Mar 25, 2025
e84fdea
use challenge period timestamp as nonce in storage proof adapter
nicholaspai Mar 25, 2025
5d55f68
Update UniversalStorageProof_SpokePool.sol
nicholaspai Mar 25, 2025
ff4723a
Use slotKey as data hash
nicholaspai Mar 25, 2025
0cf31da
Add test to spoke pool about dataHash
nicholaspai Mar 25, 2025
b150beb
Allow admin root bundles in between livenesses
nicholaspai Mar 25, 2025
8dc5a37
Add checks for admin root bundle
nicholaspai Mar 25, 2025
a6558b8
Add fallback function to spoke pool
nicholaspai Mar 25, 2025
92442b8
move HubPoolStore to utilities folder
nicholaspai Mar 25, 2025
0221127
Remove challenge period timestamp check
nicholaspai Mar 26, 2025
4a30881
Adds more robust isAdminRootBundle check, plus unit tests
nicholaspai Mar 26, 2025
f5beb6a
add placeholder unit tests
nicholaspai Mar 26, 2025
ef94793
Update IHelios.sol
nicholaspai Mar 26, 2025
96eb47f
Update HubPoolStore.sol
nicholaspai Mar 27, 2025
b923181
Finish universal adapter tests
nicholaspai Mar 27, 2025
6f3f917
Store bytes32 at relayAdminFunctionCalldata
nicholaspai Mar 28, 2025
5db8b3e
Finish tests
nicholaspai Mar 28, 2025
9175ed5
Use uint256 nonce as key in HubPoolStore
nicholaspai Mar 28, 2025
add3d73
rename deploy scripts
nicholaspai Mar 28, 2025
130bb2f
Change isAdminSender check
nicholaspai Mar 28, 2025
f359788
Add helios.headTimestamp check
nicholaspai Mar 28, 2025
62558c7
Compute slot key in contract to improve UX
nicholaspai Mar 28, 2025
861c005
Update checkStorageLayout.sh
nicholaspai Mar 28, 2025
b97738f
Delete UniversalStorageProof_SpokePool.json
nicholaspai Mar 28, 2025
7de065f
Delete UniversalStorageProof_SpokePool.json
nicholaspai Mar 28, 2025
6afc7c2
Rename shorter
nicholaspai Mar 28, 2025
6441601
Update utils.hre.ts
nicholaspai Mar 28, 2025
c296187
Update admin messaging
nicholaspai Mar 28, 2025
b9045f8
Add storage layout
nicholaspai Mar 28, 2025
0484025
Merge branch 'march-evm-audit-universal-adapter' into sp1-adapter
nicholaspai Mar 29, 2025
dd8dcec
fix
nicholaspai Mar 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions contracts/Universal_SpokePool.sol
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();
}
}
}
83 changes: 83 additions & 0 deletions contracts/chain-adapters/Universal_Adapter.sol
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();
}
}
}
87 changes: 87 additions & 0 deletions contracts/chain-adapters/utilities/HubPoolStore.sol
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);
}

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);
}
}
16 changes: 16 additions & 0 deletions contracts/external/interfaces/IHelios.sol
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);
}
Loading
Loading