Skip to content

feat: Add EVM Universal Adapter and remove deposit whitelist checks #974

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 18 commits into from
May 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 1 addition & 23 deletions contracts/SpokePool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ abstract contract SpokePool is
RootBundle[] public rootBundles;

// Origin token to destination token routings can be turned on or off, which can enable or disable deposits.
mapping(address => mapping(uint256 => bool)) public enabledDepositRoutes;
mapping(address => mapping(uint256 => bool)) private DEPRECATED_enabledDepositRoutes;

// Each relay is associated with the hash of parameters that uniquely identify the original deposit and a relay
// attempt for that deposit. The relay itself is just represented as the amount filled so far. The total amount to
Expand Down Expand Up @@ -309,21 +309,6 @@ abstract contract SpokePool is
_setWithdrawalRecipient(newWithdrawalRecipient);
}

/**
* @notice Enable/Disable an origin token => destination chain ID route for deposits. Callable by admin only.
* @param originToken Token that depositor can deposit to this contract.
* @param destinationChainId Chain ID for where depositor wants to receive funds.
* @param enabled True to enable deposits, False otherwise.
*/
function setEnableRoute(
address originToken,
uint256 destinationChainId,
bool enabled
) public override onlyAdmin nonReentrant {
enabledDepositRoutes[originToken][destinationChainId] = enabled;
emit EnabledDepositRoute(originToken, destinationChainId, enabled);
}

/**
* @notice This method stores a new root bundle in this contract that can be executed to refund relayers, fulfill
* slow relays, and send funds back to the HubPool on L1. This method can only be called by the admin and is
Expand Down Expand Up @@ -1309,10 +1294,6 @@ abstract contract SpokePool is
// Verify depositor is a valid EVM address.
params.depositor.checkAddress();

// Check that deposit route is enabled for the input token. There are no checks required for the output token
// which is pulled from the relayer at fill time and passed through this contract atomically to the recipient.
if (!enabledDepositRoutes[params.inputToken.toAddress()][params.destinationChainId]) revert DisabledRoute();

// Require that quoteTimestamp has a maximum age so that depositors pay an LP fee based on recent HubPool usage.
// It is assumed that cross-chain timestamps are normally loosely in-sync, but clock drift can occur. If the
// SpokePool time stalls or lags significantly, it is still possible to make deposits by setting quoteTimestamp
Expand Down Expand Up @@ -1398,9 +1379,6 @@ abstract contract SpokePool is
uint32 quoteTimestamp,
bytes memory message
) internal {
// Check that deposit route is enabled.
if (!enabledDepositRoutes[originToken][destinationChainId]) revert DisabledRoute();

// We limit the relay fees to prevent the user spending all their funds on fees.
if (SignedMath.abs(relayerFeePct) >= 0.5e18) revert InvalidRelayerFeePct();
if (amount > MAX_TRANSFER_SIZE) revert MaxTransferSizeExceeded();
Expand Down
194 changes: 194 additions & 0 deletions contracts/Universal_SpokePool.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// 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.
* @custom:security-contact bugs@across.to
*/
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 nonces of calldata stored in HubPoolStore that gets executed via executeMessage()
/// to prevent replay attacks.
mapping(uint256 => bool) public executedMessages;

// 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 executeMessage 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's relayMessageCallData mapping. Compared against raw value
* in Helios 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);
// The expected slot value corresponds to the hash of the L2 calldata and its target,
// as originally stored in the HubPoolStore's relayMessageCallData mapping.
bytes32 expectedSlotValue = keccak256(_message);

// Verify Helios light client has expected slot value.
bytes32 slotValue = IHelios(helios).getStorageSlot(_blockNumber, hubPoolStore, slotKey);
if (expectedSlotValue != slotValue) {
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 (executedMessages[_messageNonce]) {
revert AlreadyExecuted();
}
executedMessages[_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 executeMessage() call.
function _requireAdminSender() internal view override {
if (!_adminCallValidated) {
revert AdminCallNotValidated();
}
}
}
46 changes: 1 addition & 45 deletions contracts/chain-adapters/Solana_Adapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ contract Solana_Adapter is AdapterInterface, CircleCCTPAdapter {
// Solana spoke pool address, mapped to its EVM address representation.
address public immutable SOLANA_SPOKE_POOL_ADDRESS;

// USDC mint address on Solana, decoded from Base58 to bytes32.
bytes32 public immutable SOLANA_USDC_BYTES32;

// USDC mint address on Solana, mapped to its EVM address representation.
address public immutable SOLANA_USDC_ADDRESS;

Expand All @@ -56,8 +53,6 @@ contract Solana_Adapter is AdapterInterface, CircleCCTPAdapter {

// Custom errors for relayMessage validation.
error InvalidRelayMessageTarget(address target);
error InvalidOriginToken(address originToken);
error InvalidDestinationChainId(uint256 destinationChainId);

// Custom errors for relayTokens validation.
error InvalidL1Token(address l1Token);
Expand Down Expand Up @@ -95,7 +90,6 @@ contract Solana_Adapter is AdapterInterface, CircleCCTPAdapter {
SOLANA_SPOKE_POOL_BYTES32 = solanaSpokePool;
SOLANA_SPOKE_POOL_ADDRESS = solanaSpokePool.toAddressUnchecked();

SOLANA_USDC_BYTES32 = solanaUsdc;
SOLANA_USDC_ADDRESS = solanaUsdc.toAddressUnchecked();

SOLANA_SPOKE_POOL_USDC_VAULT = solanaSpokePoolUsdcVault;
Expand All @@ -111,17 +105,7 @@ contract Solana_Adapter is AdapterInterface, CircleCCTPAdapter {
if (target != SOLANA_SPOKE_POOL_ADDRESS) {
revert InvalidRelayMessageTarget(target);
}

bytes4 selector = bytes4(message[:4]);
if (selector == SpokePoolInterface.setEnableRoute.selector) {
cctpMessageTransmitter.sendMessage(
CircleDomainIds.Solana,
SOLANA_SPOKE_POOL_BYTES32,
_translateSetEnableRoute(message)
);
} else {
cctpMessageTransmitter.sendMessage(CircleDomainIds.Solana, SOLANA_SPOKE_POOL_BYTES32, message);
}
cctpMessageTransmitter.sendMessage(CircleDomainIds.Solana, SOLANA_SPOKE_POOL_BYTES32, message);

// TODO: consider if we need also to emit the translated message.
emit MessageRelayed(target, message);
Expand Down Expand Up @@ -159,32 +143,4 @@ contract Solana_Adapter is AdapterInterface, CircleCCTPAdapter {
// TODO: consider if we need also to emit the translated addresses.
emit TokensRelayed(l1Token, l2Token, amount, to);
}

/**
* @notice Translates a message to enable/disable a route on Solana spoke pool.
* @param message Message to translate, expecting setEnableRoute(address,uint256,bool).
* @return Translated message, using setEnableRoute(bytes32,uint64,bool).
*/
function _translateSetEnableRoute(bytes calldata message) internal view returns (bytes memory) {
(address originToken, uint256 destinationChainId, bool enable) = abi.decode(
message[4:],
(address, uint256, bool)
);

if (originToken != SOLANA_USDC_ADDRESS) {
revert InvalidOriginToken(originToken);
}

if (destinationChainId > type(uint64).max) {
revert InvalidDestinationChainId(destinationChainId);
}

return
abi.encodeWithSignature(
"setEnableRoute(bytes32,uint64,bool)",
SOLANA_USDC_BYTES32,
uint64(destinationChainId),
enable
);
}
}
Loading
Loading