Manual Execution
When automatic execution fails or is delayed on the destination chain, you can manually execute the message. The SDK provides two paths depending on what data you have.
Two Approaches
| Approach | What you need | Data source | When to use |
|---|---|---|---|
| By message ID | Message ID + dest RPC | CCIP API | Default — covers most scenarios with minimal setup |
| By transaction hash | Source tx hash + source RPC + dest RPC | On-chain (RPC) | When operating without the API (apiClient: null) |
When Manual Execution Is Needed
- Receiver contract reverts — destination contract throws an error
- Insufficient gas limit —
gasLimitin extraArgs was too low - Rate limiter blocked — token transfer exceeded rate limits
- Execution timeout — automatic execution window expired
Before executing, ensure:
- The message has been committed on the destination chain
- Source chain finality has passed
- You have a funded wallet on the destination chain
By Message ID
The CCIP API handles proof calculation, off-chain token data (USDC attestations, LBTC attestations), and OffRamp discovery. You only need the message ID and a destination chain wallet.
import { EVMChain } from '@chainlink/ccip-sdk'
const dest = await EVMChain.fromUrl('https://rpc.fuji.avax.network')
const execution = await dest.execute({
messageId: '0x1234...abcd', // 32-byte CCIP message ID
wallet: destWallet, // ethers Signer or viemWallet(client)
})
console.log('Execution tx:', execution.log.transactionHash)
console.log('Block:', execution.log.blockNumber)
Requires the CCIP API (enabled by default). Throws CCIPApiClientNotAvailableError if you created the chain with apiClient: null.
Where to Get the Message ID
- From
sendMessagereturn:request.message.messageId - From
getMessagesInTx:requests[0].message.messageId - From the CCIP Explorer
- From the
getMessageByIdAPI call
By Transaction Hash
Use this when operating without the CCIP API, or when you need full control over each step.
Step 1: Get the Message
import { EVMChain, discoverOffRamp } from '@chainlink/ccip-sdk'
const source = await EVMChain.fromUrl('https://rpc.sepolia.org')
const dest = await EVMChain.fromUrl('https://rpc.fuji.avax.network')
// Decode CCIP messages from the source transaction
const requests = await source.getMessagesInTx('0xSourceTxHash...')
const request = requests[0] // first message; use [n] if multiple messages in tx
console.log('Message ID:', request.message.messageId)
If the transaction contains multiple CCIP messages, select the one you want by index.
Step 2: Check Execution Status
import { discoverOffRamp, ExecutionState } from '@chainlink/ccip-sdk'
const offRamp = await discoverOffRamp(source, dest, request.lane.onRamp)
for await (const execution of dest.getExecutionReceipts({
offRamp,
messageId: request.message.messageId,
})) {
if (execution.receipt.state === ExecutionState.Success) {
console.log('Already executed — nothing to do')
process.exit(0)
}
if (execution.receipt.state === ExecutionState.Failed) {
console.log('Previous execution failed — proceeding with manual execution')
}
}
Step 3: Get Verifications
let verifications
try {
verifications = await dest.getVerifications({ offRamp, request })
} catch {
// CCIPCommitNotFoundError (transient) — message not yet committed
console.log('Not yet committed — wait and retry')
process.exit(1)
}
Step 4: Build Execution Input
getExecutionInput runs on the source chain. It fetches all messages in the commit batch, calculates the Merkle proof, and fetches off-chain token data (USDC/LBTC attestations) automatically.
const input = await source.getExecutionInput({ request, verifications })
Step 5: Execute
const execution = await dest.execute({
offRamp,
input,
wallet: destWallet,
})
console.log('Execution tx:', execution.log.transactionHash)
Complete Examples
By Message ID
import { ethers } from 'ethers'
import { EVMChain, CCIPError } from '@chainlink/ccip-sdk'
async function executeByMessageId(
destRpc: string,
messageId: string,
wallet: ethers.Signer
) {
const dest = await EVMChain.fromUrl(destRpc)
try {
const execution = await dest.execute({ messageId, wallet })
console.log('Execution tx:', execution.log.transactionHash)
return execution
} catch (error) {
if (CCIPError.isCCIPError(error)) {
console.error(`${error.code}: ${error.message}`)
if (error.isTransient) console.log('Transient — safe to retry')
if (error.recovery) console.log('Recovery:', error.recovery)
}
throw error
}
}
By Transaction Hash (No API)
import { ethers } from 'ethers'
import {
EVMChain,
discoverOffRamp,
ExecutionState,
CCIPError,
} from '@chainlink/ccip-sdk'
async function executeByTxHash(
sourceRpc: string,
destRpc: string,
sourceTxHash: string,
wallet: ethers.Signer,
messageIndex = 0
) {
const source = await EVMChain.fromUrl(sourceRpc, { apiClient: null })
const dest = await EVMChain.fromUrl(destRpc, { apiClient: null })
// 1. Get the message
const requests = await source.getMessagesInTx(sourceTxHash)
const request = requests[messageIndex]
console.log('Message ID:', request.message.messageId)
// 2. Discover OffRamp (memoized)
const offRamp = await discoverOffRamp(source, dest, request.lane.onRamp)
// 3. Check if already executed
for await (const execution of dest.getExecutionReceipts({
offRamp,
messageId: request.message.messageId,
})) {
if (execution.receipt.state === ExecutionState.Success) {
console.log('Already executed')
return { status: 'already_executed' as const }
}
}
// 4. Get verifications (throws CCIPCommitNotFoundError if not yet committed)
let verifications
try {
verifications = await dest.getVerifications({ offRamp, request })
} catch (error) {
if (CCIPError.isCCIPError(error) && error.isTransient) {
console.log('Not yet committed — retry later')
return { status: 'pending_commit' as const }
}
throw error
}
// 5. Build execution input (Merkle proof + off-chain token data)
const input = await source.getExecutionInput({ request, verifications })
// 6. Execute on destination
const execution = await dest.execute({ offRamp, input, wallet })
console.log('Execution tx:', execution.log.transactionHash)
return { status: 'executed' as const, txHash: execution.log.transactionHash }
}
Gas Limit Overrides
If execution reverts due to insufficient gas, override the gas limit:
const execution = await dest.execute({
offRamp,
input,
wallet: destWallet,
gasLimit: 500_000, // Override gas for ccipReceive call
tokensGasLimit: 100_000, // Override gas for tokenPool.releaseOrMint (EVM only)
})
The gasLimit override applies to the receiver contract's ccipReceive call. The tokensGasLimit override applies to each token pool operation.
Off-Chain Token Data
getExecutionInput automatically handles off-chain token data for all token types:
| Token type | What the SDK does |
|---|---|
| USDC (CCTP) | Fetches Circle attestation from Circle API |
| LBTC | Fetches attestation from Lombard API |
| Standard tokens | No off-chain data needed (empty) |
Both the message ID path (dest.execute({ messageId })) and the transaction hash path (source.getExecutionInput()) handle this automatically.
Unsigned Transactions
Use generateUnsignedExecute to separate transaction construction from signing. This lets you build execution transactions on a backend with reliable RPCs and return them to clients for signing — or feed them into multi-sig wallets, offline signing workflows, or any system where signing happens separately.
By Message ID
const unsignedTx = await dest.generateUnsignedExecute({
messageId: '0x1234...abcd',
payer: walletAddress, // address of the wallet that will sign
})
// Single transaction — send to the OffRamp contract
const tx = unsignedTx.transactions[0]
const hash = await walletClient.sendTransaction(tx)
By Transaction Hash
const input = await source.getExecutionInput({ request, verifications })
const unsignedTx = await dest.generateUnsignedExecute({
offRamp,
input,
payer: walletAddress,
gasLimit: 500_000, // optional override
})
const tx = unsignedTx.transactions[0]
const hash = await walletClient.sendTransaction(tx)
Unlike generateUnsignedSendMessage (which may return multiple transactions for token approvals), generateUnsignedExecute always returns a single transaction.
Using the CLI
# By message ID (needs dest RPC only)
ccip-cli manual-exec 0xMessageId \
--rpc https://rpc.fuji.avax.network \
--wallet $PRIVATE_KEY
# By transaction hash (needs source + dest RPCs)
ccip-cli manual-exec 0xSourceTxHash \
--rpc https://rpc.sepolia.org \
--rpc https://rpc.fuji.avax.network \
--wallet $PRIVATE_KEY
# Override gas limit
ccip-cli manual-exec 0xMessageId \
--rpc https://rpc.fuji.avax.network \
--wallet $PRIVATE_KEY \
--gas-limit 500000
# Select specific message in multi-message transaction
ccip-cli manual-exec 0xSourceTxHash \
--rpc https://rpc.sepolia.org \
--rpc https://rpc.fuji.avax.network \
--wallet $PRIVATE_KEY \
--log-index 1
The CLI auto-detects whether the argument is a message ID or transaction hash.
Method Reference
| Method | Called on | Data source | Purpose |
|---|---|---|---|
execute({ messageId, wallet }) | Destination | CCIP API | Execute by message ID (simplest) |
execute({ offRamp, input, wallet }) | Destination | Pre-computed input | Execute with pre-fetched proof |
generateUnsignedExecute({ messageId, payer }) | Destination | CCIP API | Unsigned execution tx by message ID |
generateUnsignedExecute({ offRamp, input, payer }) | Destination | Pre-computed input | Unsigned execution tx with pre-fetched proof |
getExecutionInput({ request, verifications }) | Source | RPC | Build Merkle proof + fetch off-chain token data |
getVerifications({ offRamp, request }) | Destination | RPC | Get commit report or verifier results |
getExecutionReceipts({ offRamp, messageId }) | Destination | RPC | Check current execution state |
discoverOffRamp(source, dest, onRamp) | — | RPC | Find OffRamp address (memoized) |
Errors
| Error | Transient | Meaning |
|---|---|---|
CCIPCommitNotFoundError | Yes | Message not yet committed — wait and retry |
CCIPMessageBatchIncompleteError | Yes | Not all batch messages found — retry with larger range |
CCIPUsdcAttestationError | Yes | USDC attestation not ready — retry |
CCIPLbtcAttestationError | Yes | LBTC attestation not ready — retry |
CCIPExecTxRevertedError | No | Execution reverted — check gas limit, receiver contract |
CCIPMerkleRootMismatchError | No | Proof mismatch — verify all messages in batch |
CCIPOffRampNotFoundError | No | No OffRamp for this lane — verify lane exists |
CCIPApiClientNotAvailableError | No | API disabled — use transaction hash path instead |
Related
- Tracking Messages — Check message status before executing
- Error Handling — Error recovery patterns
- CLI Manual Exec — CLI reference