Solana CPI Depth Limits and Why Routes Fail

The nested invocation ceiling, why every account a nested program touches must be passed from the top, and how aggregator routes hit both.

BoltTx Team··9 min read
solanacpicross-program-invocationcomputetransaction-landingswap

A cross-program invocation is one program calling another inside the same transaction. It is how a swap program moves tokens, how an aggregator routes through pools, and how almost every non-trivial Solana action gets done.

★It also carries two limits that most people meet as a confusing revert rather than as a documented constraint.★

The Depth Ceiling

Solana allows an instruction stack five frames deep: your transaction's instruction, plus four levels of nested invocation beneath it.

your instruction           ← frame 1  (not a CPI)
  └─ aggregator            ← nested 1
      └─ pool program      ← nested 2
          └─ token program ← nested 3
              └─ hook      ← ★nested 4 — the last one allowed★
                  └─ anything ← ★rejected: CallDepth★

Past that, the runtime returns CallDepth — sometimes surfacing as ProgramFailedToComplete depending on how the caller handles it.

The ceiling is a runtime constant, not a per-program setting:

// Agave, program-runtime/src/execution_budget.rs
pub const MAX_INSTRUCTION_STACK_DEPTH: usize = 5;

The stack counts your transaction's instruction as the first frame, which is why five frames means four CPIs. ★A separate feature raises the nesting limit to eight★, so treat four as the number to build against today rather than a permanent property of the chain.

★The ceiling is easy to reach without writing any nested code yourself.★ A single swap instruction often spends three of those four nested levels before your own logic contributes anything, because the programs you call also call programs.

When it bites in practice: aggregator routes that hop through several pool programs, programs that wrap other programs for accounting, and anything calling a program that itself calls the token program through a helper layer.

Every Account Must Come From the Top

This is the limit that produces the more confusing errors.

★A program cannot invent accounts for a CPI. Every account any nested program touches must be present in the original transaction's account list.★

// ★These are not just for your instruction.★
const keys = [
  { pubkey: user,        isSigner: true,  isWritable: true  },
  { pubkey: poolAccount, isSigner: false, isWritable: true  },
  { pubkey: poolVaultA,  isSigner: false, isWritable: true  },  // ★used by the pool program★
  { pubkey: poolVaultB,  isSigner: false, isWritable: true  },  // ★used by the pool program★
  { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },  // ★invoked at level 3★
];

Leave one out and you get NotEnoughAccountKeys or AccountNotFound — errors that name the account rather than telling you a nested call needed it. The account belongs to a program you never mentioned in your code, which is why the message rarely points anywhere useful.

This is also why account lists for swaps are so long, and why they push against the 1232-byte limit. ★You are carrying the accounts for every program in the call chain, not just the one you invoked.★

If your instruction assembly is right and transactions still miss, a free BoltTx key is one line to test the submission path.

Signer Privileges Propagate Downward

A subtlety worth knowing when a CPI fails on authority:

★Signer and writable privileges pass down through the call chain.★ If your transaction signs for an account, a program you call can act on that account as a signer — and so can a program that program calls.

// The pool program signs for its own vault using a PDA.
invoke_signed(
    &transfer_ix,
    &accounts,
    &[&[b"vault", mint.as_ref(), &[bump]]],   // ★PDA signs here★
)?;

A program can only sign for PDAs derived from its own program ID. That is the boundary — a program cannot forge a signature for your wallet, and it cannot sign for another program's PDA. When a CPI fails with a missing-signature error, the question is which level was supposed to provide it.

Compute Is Consumed Across the Whole Chain

Every level costs compute units, and they all come from the same per-transaction budget.

// ★Simulate the real route, not a single hop.★
const sim = await connection.simulateTransaction(tx, {
  replaceRecentBlockhash: true,
  sigVerify: false,
});
console.log("units:", sim.value.unitsConsumed);

★A two-hop route does not cost twice a one-hop route — it costs more, because each additional program carries its own account validation and deserialization overhead.★ Extrapolating from a single hop under-estimates, and under-estimating the limit fails a transaction that would otherwise have succeeded.

This interacts with address lookup tables in a way that surprises people: resolving a lookup table costs compute too. A route that fits comfortably without ALTs may need a higher setComputeUnitLimit with them.

Reading a Failure in the Logs

CPI failures are legible if you read the log stack rather than only the top-level error:

Program AGGREGATOR invoke [1]
  Program POOL_A invoke [2]
    Program TOKEN invoke [3]
    Program TOKEN success
  Program POOL_A success
  Program POOL_B invoke [2]
    Program TOKEN invoke [3]
    ★Program TOKEN failed: insufficient funds★
  Program POOL_B failed
Program AGGREGATOR failed

★The bracketed number is the depth, and the innermost failure is the real cause.★ The top-level error only tells you the aggregator failed, which is true and useless.

const tx = await connection.getTransaction(sig, {
  maxSupportedTransactionVersion: 0,
});
tx?.meta?.logMessages?.forEach((l) => console.log(l));

Read from the bottom up. The last failure before the unwinding starts is the one to fix.

Reducing Depth and Accounts

When a route will not fit, the levers in order of how often they work:

Fewer hops. A direct route uses fewer levels and fewer accounts. ★onlyDirectRoutes: true trades a small price improvement for a transaction that actually lands.★

Split into two transactions. Only when the steps do not need atomicity — which rules it out for arbitrage.

Address lookup tables. These help with the size limit and do nothing for the depth limit. Worth being precise about, since they are often suggested for both.

Bound the account count at quote time. maxAccounts in an aggregator request keeps the route inside what a transaction can carry.

What Landing Looks Like

Real transactions through our delivery nodes: median confirmation 336ms — under one slot.

★A CPI failure never gets you that number — it lands and reverts, paying the base fee for a transaction that changed nothing.★ That makes it worth catching in simulation, where it costs nothing.

Where BoltTx Fits

We handle submission. Instruction structure and routing choices are entirely yours.

Submissions route through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so a transaction is not observable in transit before it lands. We never modify transaction contents, which includes never altering your instruction set or account list.

You sign locally. We never hold funds and never sign. The tip travels inside the transaction, paid on chain from your own wallet, and reverts with the transaction if it fails, because that is how Solana handles atomic transactions. You pay only on transactions that reach the chain.

Get a free API key. No monthly fee:

const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

FAQ

What is a CPI on Solana? A cross-program invocation — one program calling another within the same transaction. It is how a swap program moves tokens and how aggregators route through pool programs.

What is the maximum CPI depth on Solana? Five stack frames: your transaction's instruction plus four levels of nested invocation. Beyond that the runtime returns CallDepth.

Why does my aggregator swap fail with call depth exceeded? The route nests too many programs. Your instruction, the aggregator, a pool program, and the token program already fill the stack, so any additional wrapper exceeds it.

Do I need to pass accounts that only nested programs use? Yes. A program cannot invent accounts for a CPI, so every account any program in the chain touches must appear in the original transaction. This is why swap account lists are long.

Why do I get NotEnoughAccountKeys on a swap? An account a nested program needed was not in your list. The error names the account rather than the nested call that wanted it, which is why it rarely points anywhere useful.

How much compute does a CPI cost? More than the sum of the individual programs, since each level adds account validation and deserialization overhead. Simulate the actual route rather than extrapolating from one hop.

Can a program sign for my wallet in a CPI? No. A program can only sign for PDAs derived from its own program ID. Your wallet's signature comes from your transaction and cannot be forged by any program in the chain.

How do signer privileges work across CPI levels? They propagate downward. An account signed for at the top level is available as a signer to programs called beneath it, which is how nested programs act on your behalf.

How do I debug a failed CPI? Read the log messages and follow the bracketed depth numbers. The innermost failure is the real cause, while the top-level error only reports that the outer program failed.

Do address lookup tables help with CPI depth? No. They compress account references and help with the 1232-byte size limit, but they do nothing about invocation depth. The two limits are separate.

Why does adding a lookup table increase my compute usage? Because resolving the table costs compute at runtime. A route that fits comfortably without one may need a higher compute unit limit with it.

How do I reduce the number of accounts in a swap? Fewer routing hops, maxAccounts bounded at quote time, and lookup tables to compress what remains. Direct routes carry noticeably fewer accounts than split routes.

Should I use onlyDirectRoutes to avoid these limits? It is worth considering when you are racing. A direct route uses fewer levels, fewer accounts, and less compute, and a route that lands beats a marginally better price that reverts.

Does a CPI failure cost money? Yes, if the transaction landed. It reverts, changes nothing, and still consumes the base fee — which is why catching it in simulation is worthwhile.

Can I split a route across two transactions? Only when the steps do not need to be atomic. Arbitrage does need atomicity, so this option is unavailable exactly where the routes are most complex.

What is invoke_signed used for? It lets a program sign a CPI using one of its own PDAs, by supplying the seeds. That is how a pool program authorises transfers out of a vault it owns.

Back to all posts