0

I've been working to adapt the Transfer USDC With Data tutorial from the Chainlink Documentation to my project and this is driving me mad. I initially got the sender contract to send USDC to my receiver contract and the receiver contract sent it to my "staker" equivalent contract, a PrizeVault contract, but it didn't trigger the deposit function selector. So I made a change and now my CCIP transactions fail. They get blessed, committed, and then I look back and I need to do a manual gas transaction. Even looking at my deployed Receiver contract, there are no failed messages so it looks like the destination router contract doesn't get the message to my receiver contract. I don't remember the change I made to regress to this point.

EDIT: now that I recall, I think I changed my function selector to match my deposit function selector in building the CCIP message as it was previously the unchanged selector from the tutorial.

Relevant functions on Sender contract on Avalanche Fuji

function crossChainDepositPayLink( uint64 _destinationChainSelector, address _beneficiary, // beneficiary of the staked tokens on the destination chain uint256 _amount ) external onlyOwner validateDestinationChain(_destinationChainSelector) returns (bytes32 messageId) { address receiver = s_receivers[_destinationChainSelector]; if (receiver == address(0)) revert NoReceiverOnDestinationChain(_destinationChainSelector); if (_amount == 0) revert AmountIsZero(); uint256 gasLimit = s_gasLimits[_destinationChainSelector]; if (gasLimit == 0) revert NoGasLimitOnDestinationChain(_destinationChainSelector); // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage( receiver, abi.encodeWithSelector( IPrizeVault.deposit.selector, _amount, _beneficiary ), // Encode the function selector and the arguments of the stake function), i_payInLinkOrGasToken[true], // paying in Link gasLimit, true, address(i_usdcToken), _amount ); // Get the fee required to send the CCIP message uint256 fees = i_router.getFee( _destinationChainSelector, evm2AnyMessage ); if (fees > i_linkToken.balanceOf(address(this))) revert NotEnoughBalance(i_linkToken.balanceOf(address(this)), fees); // approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK i_linkToken.approve(address(i_router), fees); // approve the Router to spend usdc tokens on contract's behalf. It will spend the amount of the given token i_usdcToken.approve(address(i_router), _amount); // Send the message through the router and store the returned message ID messageId = i_router.ccipSend( _destinationChainSelector, evm2AnyMessage ); // Emit an event with message details emit MessageSent( messageId, _destinationChainSelector, receiver, _beneficiary, address(i_usdcToken), _amount, address(i_linkToken), fees ); // Return the message ID return messageId; } 

^^^^ I put a bool -> address mapping of gas tokens in i_payInLinkOrGasToken. True is Link's token address while false is address(0) to specify using the gas token. I built the CCIP Message in the contract below. In the full contract, I have two named _buildCCIPMessage functions but one has more parameters so the latter is used for token transfers. The signature would be different so I don't anticipate this to cause an error.

 function _buildCCIPMessage( // I can use the same function name with extra parameters and it'll recognize as unique address _receiver, bytes memory _data, address _feeTokenAddress, uint256 _gasLimit, bool _allowOutOfOrderExecution, address _token, uint256 _amount ) private pure returns (Client.EVM2AnyMessage memory){ // setting token amounts: // If not sending any token, create an empty array, if yes, create 1 size array Client.EVMTokenAmount[] memory tokenAmounts = _token == address(0) || _amount == 0 ? new Client.EVMTokenAmount[](0) : new Client.EVMTokenAmount[](1); if (tokenAmounts.length > 0){ tokenAmounts[0] = Client.EVMTokenAmount({ token: _token, amount: _amount }); } // Create EVM2AnyMessage struct in memorry with necessary info for sending cross chain message return Client.EVM2AnyMessage({ receiver: abi.encode(_receiver), // ABI-encoded receiver address data: _data, // abi.encode(_text) -> BI-encoded string tokenAmounts: tokenAmounts, extraArgs: Client._argsToBytes( Client.GenericExtraArgsV2({ gasLimit: _gasLimit, // 200_000 - gas limit for callback on destination chain allowOutOfOrderExecution: _allowOutOfOrderExecution // true }) ), feeToken: _feeTokenAddress }); } 

Relevant functions for Sepolia's Receiver contract

constructor( address _router, address _usdcToken, address _staker ) CCIPReceiver(_router) { if (_usdcToken == address(0)) revert InvalidUsdcToken(); if (_staker == address(0)) revert InvalidStaker(); i_usdcToken = IERC20(_usdcToken); i_staker = _staker; i_usdcToken.safeApprove(_staker, type(uint256).max); //@audit-info dunno if I need this for the main contract } 

^^^^ The constructor is posted ad verbatim from the tutorial. Just adding this to show it's not an ERC-20 approval issue. And I passed the PrizeVault contract to the _staker constructor parameter since the variable is already there.

function ccipReceive( Client.Any2EVMMessage calldata any2EvmMessage ) external override onlyRouter { // validate the sender contract if ( abi.decode(any2EvmMessage.sender, (address)) != s_senders[any2EvmMessage.sourceChainSelector] ) revert WrongSenderForSourceChain(any2EvmMessage.sourceChainSelector); /* solhint-disable no-empty-blocks */ try this.processMessage(any2EvmMessage) { // Intentionally empty in this example; no action needed if processMessage succeeds } catch (bytes memory err) { // Could set different error codes based on the caught error. Each could be // handled differently. s_failedMessages.set( any2EvmMessage.messageId, uint256(ErrorCode.FAILED) ); s_messageContents[any2EvmMessage.messageId] = any2EvmMessage; // Don't revert so CCIP doesn't revert. Emit event instead. // The message can be retried later without having to do manual execution of CCIP. emit MessageFailed(any2EvmMessage.messageId, err); return; } } 

^^^^ No changes that I recall again.

 /// @notice Serves as the entry point for this contract to process incoming messages. /// @param any2EvmMessage Received CCIP message. /// @dev Transfers specified token amounts to the owner of this contract. This function /// must be external because of the try/catch for error handling. /// It uses the `onlySelf`: can only be called from the contract. function processMessage( Client.Any2EVMMessage calldata any2EvmMessage ) external onlySelf { _ccipReceive(any2EvmMessage); // process the message - may revert } function _ccipReceive( Client.Any2EVMMessage memory any2EvmMessage ) internal override { if (any2EvmMessage.destTokenAmounts[0].token != address(i_usdcToken)) revert WrongReceivedToken( address(i_usdcToken), any2EvmMessage.destTokenAmounts[0].token ); (bool success, bytes memory returnData) = i_staker.call( any2EvmMessage.data ); // low level call to the staker contract using the encoded function selector and arguments if (!success) revert CallToStakerFailed(); if (returnData.length > 0) revert NoReturnDataExpected(); emit MessageReceived( any2EvmMessage.messageId, any2EvmMessage.sourceChainSelector, // fetch the source chain identifier (aka selector) abi.decode(any2EvmMessage.sender, (address)), // abi-decoding of the sender address, any2EvmMessage.data, // received data any2EvmMessage.destTokenAmounts[0].token, any2EvmMessage.destTokenAmounts[0].amount ); } 

^^^ No changes that I can recall again from the tutorial.

PrizeVault contract

/// @inheritdoc IERC4626 function deposit(uint256 _assets, address _receiver) external returns (uint256) { uint256 _shares = previewDeposit(_assets); _depositAndMint(msg.sender, _receiver, _assets, _shares); return _shares; } 

^^^^ It works when I call the contract directly on the chain. So I'm only posting it to show the function signature. One thing I did not do that the tutorial did was create an interface *in the same [PrizeVault] contract file.

I am at a loss here so any help would be appreciated. Thank you.

EDIT:

interface IPrizeVault { function deposit(uint256 _assets, address _receiver) external; } 

PrizeVault interface that I use for the selector in the internal function _buildCCIPMessage. It's in my Sender file but it's defined outside of the Sender contract.

1 Answer 1

0

Ok, based on what you described you are using the existing USDC CCIP Transfer tutorial and sending from Avalanche Fuji to a receiver contract on Ethereum Sepolia, which then forwards the tokens to a "PrizeVault" contract (acting as the staker) via a low-level call in ccipReceive. The transaction succeeds on the source chain, gets blessed and committed, but requires manual execution on the destination chain via the CCIP explorer. Even after manual execution, the receiver doesn't show any processed or failed messages, and the tokens don't reach the PrizeVault. I have seen manual execution prompts happen with CCIP before when there is not enough gas, since the destination chain gets paid with gas from the source chain.

Some thoughts/ideas that might help you troubleshoot...

On the destination chain (Sepolia), the CCIP OffRamp contract calls your receiver's ccipReceive function with the gas limit specified in the extraArgs of your Client.EVM2AnyMessage. If extraArgs is empty, it defaults to 200,000 gas. Your _buildCCIPMessage function sets this explicitly via _gasLimit (which you noted as 200,000 in the comment), but that's often insufficient for complex receivers. Your receiver validates the sender, then uses a low-level call to forward the data (likely an encoded function selector for a deposit or similar on the PrizeVault) and tokens to the PrizeVault. PrizeVault contracts (e.g., from protocols like PoolTogether) typically involve multiple storage operations, events, approvals, and possibly interactions with other contracts (like yield sources or ERC-4626 vaults). This can easily exceed 200,000 gas, causing an out-of-gas revert during auto-execution.

The fact that it enters "ready for manual execution" mode is a classic sign of execution failure due to insufficient gas or an unhandled revert in the receiver. Manual execution allows you to provide more gas, but if the original limit is too low, auto-execution fails silently from the receiver's perspective still. =) Since you mentioned when you call the contracts directly it succeeds, probably because those simulations use the full block gas limit or a higher manual setting, bypassing the CCIP-imposed limit.

In your sender's _buildCCIPMessage, you're already passing _gasLimit to extraArgs. Just increase the value you pass when calling this function (e.g., from 200,000 to 500,000 or even 1,000,000). So I would try 500,000 first. If it still fails, bump to 1,000,000. Chainlink caps manual execution at ~3,000,000 gas, so stay under that for safety. I'd suggest running everything in Chainlink Local first to eliminate any testnet high traffic gas issues. https://docs.chain.link/chainlink-local And also, make sure any2EvmMessage.data is correctly abi-encoded too (abi.encodeWithSelector(PrizeVault.deposit.selector, amount, beneficiary). If there is a mismatch it will cause a revert, mimicking a gas issue. Your validation in ccipReceive looks correct, but confirm the source chain selector and sender address match.

Hope that helps!

--Dave

2
  • I bumped up the gas amount to 800,000. So it finally sent the transaction and the token to my Receiver contract. But I got a failed message. So then I read the rest of your answer and bumped the gas limit to 1M and then 3M. Stilled failed. But again, I do have the messageIds logged in my Receiver contract along with the tokens. So Progress! I'll update the question to include the PrizeVault interface I included in my USDCSender.sol solidity file but outside the contract. Surely that won't cause an issue as there are no references beyond the buildCCIPMessage otherwise, right? Commented Jul 11 at 3:00
  • 1
    FOUND THE LAST ERROR. THIS IS NOT A DRILL. FOUND THE LAST ERROR. within the _ccipReceive function there's a line if (returnData.length > 0) revert NoReturnDataExpected(); that reverts the transaction if the function call returns any data. In PrizeVault.deposit's case, it returns a uint256. It took ChatGPT to find that measly line. So much time wasted since I didn't meticulously read every line and copypasted willnilly. I can finally trigger a transaction cross chain, as Chainlink intended. Thank you, Dave. Commented Jul 11 at 3:29

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.