Skip to content
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
193 changes: 193 additions & 0 deletions contracts/chain-adapters/Solana_Adapter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import { IMessageTransmitter, ITokenMessenger } from "../external/interfaces/CCTPInterfaces.sol";
import { SpokePoolInterface } from "../interfaces/SpokePoolInterface.sol";
import { AdapterInterface } from "./interfaces/AdapterInterface.sol";
import { CircleCCTPAdapter, CircleDomainIds } from "../libraries/CircleCCTPAdapter.sol";

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
* @notice Contract containing logic to send messages from L1 to Solana via CCTP.
* @dev Public functions calling external contracts do not guard against reentrancy because they are expected to be
* called via delegatecall, which will execute this contract's logic within the context of the originating contract.
* For example, the HubPool will delegatecall these functions, therefore it's only necessary that the HubPool's methods
* that call this contract's logic guard against reentrancy.
* @custom:security-contact bugs@across.to
*/

// solhint-disable-next-line contract-name-camelcase
contract Solana_Adapter is AdapterInterface, CircleCCTPAdapter {
Copy link
Member

@chrismaree chrismaree Oct 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is this a "Solana_Adapter" or it is better called "Cctp_Adapter"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This includes logic specific to Solana, e.g. how setEnableRoute is translated. I was though considering to move cctpMessageTransmitter to base CircleCCTPAdapter, but that would have required adding it to constructor for all other adapters that don't use CCTP for general message transmission.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's fair enough. given we already have the CircleCCTPAdapter within this repo I agree this naming structure makes the most sence.

/**
* @notice The official Circle CCTP MessageTransmitter contract endpoint.
* @dev Posted officially here: https://developers.circle.com/stablecoins/docs/evm-smart-contracts
*/
// solhint-disable-next-line immutable-vars-naming
IMessageTransmitter public immutable cctpMessageTransmitter;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This more belongs to CircleCCTPAdapter, though that would require passing it in constructor for all other adapters that don't use CCTP for general message transmission.


// Solana spoke pool address, decoded from Base58 to bytes32.
bytes32 public immutable SOLANA_SPOKE_POOL_BYTES32;

// 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;

// USDC token address on Solana for the spoke pool (vault ATA), decoded from Base58 to bytes32.
bytes32 public immutable SOLANA_SPOKE_POOL_USDC_VAULT;

// Custom errors for constructor argument validation.
error InvalidCctpTokenMessenger(address tokenMessenger);
error InvalidCctpMessageTransmitter(address messageTransmitter);

// 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);
error InvalidL2Token(address l2Token);
error InvalidAmount(uint256 amount);
error InvalidTokenRecipient(address to);
Comment on lines +49 to +57
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used these for debugging, though HubPool does not bubble up revert data on delegatecall. Shall we still keep custom errors or revert with empty data at the adapter?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think they are ok to have adapter specific errors? if there are other errors that other adapters re-use we should cleary use them but things like InvalidCctpTokenMessenger only apply in this context.


/**
* @notice Constructs new Adapter.
* @param _l1Usdc USDC address on L1.
* @param _cctpTokenMessenger TokenMessenger contract to bridge tokens via CCTP.
* @param _cctpMessageTransmitter MessageTransmitter contract to bridge messages via CCTP.
* @param solanaSpokePool Solana spoke pool address, decoded from Base58 to bytes32.
* @param solanaUsdc USDC mint address on Solana, decoded from Base58 to bytes32.
* @param solanaSpokePoolUsdcVault USDC token address on Solana for the spoke pool, decoded from Base58 to bytes32.
*/
constructor(
IERC20 _l1Usdc,
ITokenMessenger _cctpTokenMessenger,
IMessageTransmitter _cctpMessageTransmitter,
bytes32 solanaSpokePool,
bytes32 solanaUsdc,
bytes32 solanaSpokePoolUsdcVault
Comment on lines +72 to +74
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it might be easier/cleaner/simpler/more consistant to just have these hard coded as constants, as done in some of the other adapters.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left them as immutables so that its easier to test integration flow on testnets that would have different USDC and we don't have a proper authority for the spoke yet.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right. I dont think we're going to be doing too much testnet work (mainly better for a lot of these flows to test directly on mainnet) but fair enough that it makes it easier to change.

) CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, CircleDomainIds.Solana) {
// Solana adapter requires CCTP TokenMessenger and MessageTransmitter contracts to be set.
if (address(_cctpTokenMessenger) == address(0)) {
revert InvalidCctpTokenMessenger(address(_cctpTokenMessenger));
}
if (address(_cctpMessageTransmitter) == address(0)) {
revert InvalidCctpMessageTransmitter(address(_cctpMessageTransmitter));
}

cctpMessageTransmitter = _cctpMessageTransmitter;

SOLANA_SPOKE_POOL_BYTES32 = solanaSpokePool;
SOLANA_SPOKE_POOL_ADDRESS = _trimSolanaAddress(solanaSpokePool);

SOLANA_USDC_BYTES32 = solanaUsdc;
SOLANA_USDC_ADDRESS = _trimSolanaAddress(solanaUsdc);

SOLANA_SPOKE_POOL_USDC_VAULT = solanaSpokePoolUsdcVault;
}

/**
* @notice Send cross-chain message to target on Solana.
* @dev Only allows sending messages to the Solana spoke pool.
* @param target Program on Solana (translated as EVM address) that will receive message.
* @param message Data to send to target.
*/
function relayMessage(address target, bytes calldata message) external payable override {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need permission checks here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is delegatecalled, so we check CCTP sender on Solana side matches HubPool

if (target != SOLANA_SPOKE_POOL_ADDRESS) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the downside with letting this target non spoke pool address?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having it hardcoded potentially limits flexibility for future updates or use cases. Maybe we can add a setter or remove the check if not strictly required as @chrismaree is exploring.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This accepts target address that must be translated to bytes32, but since we don't have translation mapping for any other target then obviously the caller has been misconfigured.

As for updates, I understand the general pattern for these adapters is to have them immutable and upgrade by redeploying and updating the instance in HubPool.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Reinis-FRP that's a good point. this is not a generic adapter as a result of the encoding so there is no situation where you should ever be passing in anything other than a known target address. if you are, you're doing something wrong and it should error. I agree with leaving it like this.

revert InvalidRelayMessageTarget(target);
}

bytes4 selector = bytes4(message[:4]);
if (selector == SpokePoolInterface.setEnableRoute.selector) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is great. it's nice that we only need to worry about one selector as the others are bool.

cctpMessageTransmitter.sendMessage(
CircleDomainIds.Solana,
SOLANA_SPOKE_POOL_BYTES32,
_translateSetEnableRoute(message)
);
} else {
cctpMessageTransmitter.sendMessage(CircleDomainIds.Solana, SOLANA_SPOKE_POOL_BYTES32, message);
}

// TODO: consider if we need also to emit the translated message.
emit MessageRelayed(target, message);
}

/**
* @notice Bridge tokens to Solana.
* @dev Only allows bridging USDC to Solana spoke pool.
* @param l1Token L1 token to deposit.
* @param l2Token L2 token to receive.
* @param amount Amount of L1 tokens to deposit and L2 tokens to receive.
* @param to Bridge recipient.
*/
function relayTokens(
address l1Token,
address l2Token,
uint256 amount,
address to
) external payable override {
if (l1Token != address(usdcToken)) {
revert InvalidL1Token(l1Token);
}
if (l2Token != SOLANA_USDC_ADDRESS) {
revert InvalidL2Token(l2Token);
}
if (amount > type(uint64).max) {
revert InvalidAmount(amount);
}
if (to != SOLANA_SPOKE_POOL_ADDRESS) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar comment to before: if the to is not the spoke address, it in theory is ok?

I agree constraining the execution flow is properly better but in theory there is no issue not having this require, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have recipient ATA for any other than spoke. If this does not match then the caller has been misconfigured, so better to block it here.

revert InvalidTokenRecipient(to);
}

_transferUsdc(SOLANA_SPOKE_POOL_USDC_VAULT, amount);

// TODO: consider if we need also to emit the translated addresses.
emit TokensRelayed(l1Token, l2Token, amount, to);
}

/**
* @notice Helper to map a Solana address to an Ethereum address representation.
* @dev The Ethereum address is derived from the Solana address by truncating it to its lowest 20 bytes. This same
* conversion must be done by the HubPool owner when adding Solana spoke pool and setting the corresponding pool
* rebalance and deposit routes.
* @param solanaAddress Solana address (Base58 decoded to bytes32) to map to its Ethereum address representation.
* @return Ethereum address representation of the Solana address.
*/
function _trimSolanaAddress(bytes32 solanaAddress) internal pure returns (address) {
return address(uint160(uint256(solanaAddress)));
}

/**
* @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
);
}
}
21 changes: 21 additions & 0 deletions contracts/external/interfaces/CCTPInterfaces.sol
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,24 @@ interface ITokenMinter {
*/
function burnLimitsPerMessage(address token) external view returns (uint256);
}

/**
* IMessageTransmitter in CCTP inherits IRelayer and IReceiver, but here we only import sendMessage from IRelayer:
* https://github.com/circlefin/evm-cctp-contracts/blob/377c9bd813fb86a42d900ae4003599d82aef635a/src/interfaces/IMessageTransmitter.sol#L25
* https://github.com/circlefin/evm-cctp-contracts/blob/377c9bd813fb86a42d900ae4003599d82aef635a/src/interfaces/IRelayer.sol#L23-L35
*/
interface IMessageTransmitter {
/**
* @notice Sends an outgoing message from the source domain.
* @dev Increment nonce, format the message, and emit `MessageSent` event with message information.
* @param destinationDomain Domain of destination chain
* @param recipient Address of message recipient on destination domain as bytes32
* @param messageBody Raw bytes content of message
* @return nonce reserved by message
*/
function sendMessage(
uint32 destinationDomain,
bytes32 recipient,
bytes calldata messageBody
) external returns (uint64);
}
14 changes: 12 additions & 2 deletions contracts/libraries/CircleCCTPAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ library CircleDomainIds {
uint32 public constant Ethereum = 0;
uint32 public constant Optimism = 2;
uint32 public constant Arbitrum = 3;
uint32 public constant Solana = 5;
uint32 public constant Base = 6;
uint32 public constant Polygon = 7;
// Use this value for placeholder purposes only for adapters that extend this adapter but haven't yet been
Expand Down Expand Up @@ -87,17 +88,26 @@ abstract contract CircleCCTPAdapter {
* @param amount Amount of USDC to transfer.
*/
function _transferUsdc(address to, uint256 amount) internal {
_transferUsdc(_addressToBytes32(to), amount);
}

/**
* @notice Transfers USDC from the current domain to the given address on the new domain.
* @dev This function will revert if the CCTP bridge is disabled. I.e. if the zero address is passed to the constructor for the cctpTokenMessenger.
* @param to Address to receive USDC on the new domain represented as bytes32.
* @param amount Amount of USDC to transfer.
*/
function _transferUsdc(bytes32 to, uint256 amount) internal {
// Only approve the exact amount to be transferred
usdcToken.safeIncreaseAllowance(address(cctpTokenMessenger), amount);
// Submit the amount to be transferred to bridged via the TokenMessenger.
// If the amount to send exceeds the burn limit per message, then split the message into smaller parts.
ITokenMinter cctpMinter = cctpTokenMessenger.localMinter();
uint256 burnLimit = cctpMinter.burnLimitsPerMessage(address(usdcToken));
uint256 remainingAmount = amount;
bytes32 recipient = _addressToBytes32(to);
while (remainingAmount > 0) {
uint256 partAmount = remainingAmount > burnLimit ? burnLimit : remainingAmount;
cctpTokenMessenger.depositForBurn(partAmount, recipientCircleDomainId, recipient, address(usdcToken));
cctpTokenMessenger.depositForBurn(partAmount, recipientCircleDomainId, to, address(usdcToken));
remainingAmount -= partAmount;
}
}
Expand Down
13 changes: 9 additions & 4 deletions test/evm/hardhat/MerkleLib.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
BigNumber,
defaultAbiCoder,
keccak256,
toBNWei,
toBNWeiWithDecimals,
createRandomBytes32,
Contract,
} from "../../../utils/utils";
Expand Down Expand Up @@ -119,9 +119,14 @@ export async function constructSingleRelayerRefundTree(l2Token: Contract | Strin
return { leaves, tree };
}

export async function constructSingleChainTree(token: string, scalingSize = 1, repaymentChain = repaymentChainId) {
const tokensSendToL2 = toBNWei(100 * scalingSize);
const realizedLpFees = toBNWei(10 * scalingSize);
export async function constructSingleChainTree(
token: string,
scalingSize = 1,
repaymentChain = repaymentChainId,
decimals = 18
) {
const tokensSendToL2 = toBNWeiWithDecimals(100 * scalingSize, decimals);
const realizedLpFees = toBNWeiWithDecimals(10 * scalingSize, decimals);
const leaves = buildPoolRebalanceLeaves(
[repaymentChain], // repayment chain. In this test we only want to send one token to one chain.
[[token]], // l1Token. We will only be sending 1 token to one chain.
Expand Down
Loading
Loading