CPI — When Programs Call Programs

A general contractor doesn't lay bricks, pull wire, or sweat pipe. A general contractor hires an electrician, a plumber, and an HVAC installer — then coordinates them. The general contractor says "wire the kitchen," and the electrician wires the kitchen. The general contractor says "run the drain line," and the plumber runs the drain line. Each subcontractor executes their specialty. The general contractor ties it all together into a finished house.

This is exactly how programs work on Solana. A program doesn't need to contain the logic for every operation it wants to perform. It calls other programs. The token program knows how to transfer tokens. The DEX program knows how to execute a swap. My custom program knows how to chain those calls together in the right order, check the result, and revert if the outcome is unfavorable. Each program does what it does best. The calling program orchestrates.

The mechanism that makes this possible is Cross-Program Invocation. CPI. One program calling another program, mid-execution, within a single transaction.

Functions Calling Functions, Programs Calling Programs

Every programmer understands function calls. main() calls calculatePrice(), which calls applyDiscount(), which calls roundToNearestCent(). Each function does a specific job and returns control to the caller. The call stack grows deeper with each nested call. When the deepest function finishes, control unwinds back up through the chain.

CPI is the same concept, lifted from functions to programs. Program A is executing. It reaches a point where it needs work done that another program specializes in. It constructs an instruction — specifying the target program, the accounts involved, and the instruction data — and hands it to the Solana runtime. The runtime suspends Program A, begins executing Program B with the provided accounts and data, and when Program B finishes, returns control to Program A. Program A continues executing from where it left off, now with the side effects of Program B's execution reflected in the account states.

The analogy to function calls is so direct that it's almost misleading in its simplicity. When main() calls calculatePrice(), the runtime mechanics — stack frame allocation, register saving, program counter manipulation — are invisible to the programmer. The same is true for CPI. When Program A invokes Program B, the Solana runtime handles the context switch, the account permission propagation, the compute unit accounting, and the error propagation. From the program's perspective, it makes a call and gets a result.

But unlike a simple function call within a single program, CPI crosses a trust boundary. Program A and Program B are separate programs, potentially written by different teams, deployed at different times, with different upgrade authorities. Program A doesn't have access to Program B's internal state. It can only interact through Program B's public instruction interface — just like a general contractor can't tell the electrician how to wire a junction box, only what outcome is needed.

How CPI Actually Works

The mechanics are concrete. A program that wants to invoke another program constructs an Instruction struct: the target program's public key, a list of AccountMeta entries specifying which accounts are involved and whether each is a signer or writable, and a byte array containing the serialized instruction data.

Then it calls one of two functions: invoke or invoke_signed.

invoke is the straightforward case. Program A says: "Execute this instruction on Program B, using these accounts, with the same signer authorities that were passed into my instruction." The signers from the original transaction — the wallet that signed and submitted the transaction — carry through. If the original transaction signer has authority over an account, that authority extends into the CPI call.

Think of it like call forwarding. Someone calls your office line. You pick up, listen to the request, and realize your colleague handles this specific issue. You conference in your colleague, introducing the caller: "This is the customer, they have authority to make changes to their account." Your colleague serves the customer with the same authority verification that would have applied if the customer called directly. The authorization passes through the intermediary transparently.

invoke_signed is where things get interesting. This is the case where the calling program itself needs to act as a signer — specifically, where a Program Derived Address (PDA) owned by the calling program needs to sign an instruction.

PDA Signing: The Franchise Model

A PDA is an account whose address is derived deterministically from a program ID and a set of seeds. No private key exists for a PDA. Nobody can sign a transaction with a PDA's private key because there is no private key. The PDA's authority comes entirely from the program that derived it.

This is like a franchise operation. McDonald's corporate doesn't physically stand behind the counter at each location. Each franchise location operates under McDonald's authority, but the individual store manager makes the day-to-day decisions. The store manager can sign a contract to hire an employee or order supplies because they act under the franchise's authority. They don't need Ray Kroc's personal signature on every purchase order. The franchise agreement itself is the authorization mechanism.

In Solana terms, the program is corporate headquarters. The PDA is the franchise location. When the program calls invoke_signed, it provides the "signer seeds" — the same seeds used to derive the PDA's address, plus a bump seed. The runtime verifies that these seeds, combined with the calling program's ID, produce the PDA's address. If they match, the runtime grants signer authority to the PDA for this specific CPI call. The PDA "signs" the instruction, not because it has a private key, but because the program that owns it vouched for it using the derivation seeds.

This mechanism is essential for arbitrage programs. The custom program needs to hold tokens in escrow, approve token transfers, and interact with DEX programs on behalf of its own accounts. All of these actions require signer authority. Without PDA signing, the program would need an externally-owned account to co-sign every operation — which would defeat the purpose of autonomous, atomic execution. With PDA signing, the program is self-sovereign. It manages its own accounts, approves its own transfers, and executes its own strategy, all within a single transaction.

The signing flow in a three-hop arbitrage looks like this: the program's PDA holds the input SOL. The program calls invoke_signed to execute swap 1, with the PDA signing as the authority that approves the SOL transfer to the DEX. The DEX executes the swap, depositing Token A into a token account controlled by the PDA. The program calls invoke_signed again for swap 2, with the PDA signing as the authority for the Token A transfer. And again for swap 3. At each hop, the PDA signs because the program provides the correct signer seeds. The runtime validates the derivation at each step. No external wallet needs to be involved in the swap mechanics.

The Compute Budget: CPI Isn't Free

Every instruction on Solana consumes compute units (CU). A simple token transfer costs a few thousand CU. A DEX swap costs tens of thousands. And when programs call programs, each layer of invocation adds overhead.

A Solana transaction starts with a default compute budget of 200,000 CU. For a simple single-instruction transaction — send SOL from one wallet to another — 200,000 CU is lavish. For a three-hop cyclic arbitrage executing via CPI, 200,000 CU is a constraint that demands attention.

The CU consumption of a CPI chain is not simply the sum of each individual program's consumption. There's overhead at each invocation boundary: the runtime validates accounts, propagates signer permissions, sets up the execution context for the callee, and tears it down when the callee returns. Each CPI call adds a tax on top of the callee's actual execution cost.

Consider the arithmetic. A Raydium AMM V4 swap consumes roughly 30,000-40,000 CU. An Orca Whirlpool swap, with its tick-crossing logic, consumes 50,000-80,000 CU depending on the number of ticks crossed. A Meteora DLMM swap sits somewhere in between. Chain three swaps together via CPI, add the CPI overhead at each boundary, add the custom program's own logic for constructing each instruction and performing the profit check, and the total easily reaches 150,000-250,000 CU.

With the 200,000 CU default, a three-hop cycle through three expensive DEXes can exceed the budget. When a transaction exceeds its CU budget, it fails. Not "runs slowly." Fails. The runtime stops execution, reverts all state changes, and returns an error. The transaction is dead.

The solution is the SetComputeUnitLimit instruction. Solana allows a transaction to request up to 1,400,000 CU by including a compute budget instruction at the beginning of the transaction. This raises the ceiling. But it comes with a trade-off: the requested CU amount is used by validators to schedule transactions and by Jito to evaluate bundle efficiency. Requesting 1,400,000 CU when you only use 180,000 makes the transaction look more expensive than it is, potentially reducing priority in scheduling.

The balancing act is to request enough CU to cover the worst-case execution path — the most CU-expensive combination of DEXes, with the most tick crossings, with the most complex pool state — while not requesting so much that the transaction appears wasteful. In practice, this means profiling the actual CU consumption of each DEX combination and setting the budget with a reasonable margin.

This is not unlike budgeting for a construction project. The general contractor estimates costs based on the specific subcontractors and scope of work. Overestimate too much, and the project looks expensive on paper — the bank might deprioritize the loan, or the client might balk. Underestimate, and the project runs out of money mid-construction and stops dead. The right estimate covers the real costs plus a contingency for unknowns.

The Depth Limit: Four Levels Deep, No More

CPI has a hard constraint that many developers discover only when they hit it: the maximum call depth is 4. The top-level program is at depth 0. It calls a program via CPI — depth 1. That program calls another — depth 2. That program calls another — depth 3. That program calls another — depth 4. At depth 4, the callee cannot make further CPI calls. If it tries, the runtime rejects the call with a CallDepthExceeded error.

In the relay race analogy, think of it as a four-leg relay. Runner 1 hands the baton to Runner 2. Runner 2 to Runner 3. Runner 3 to Runner 4. Runner 4 must cross the finish line — there is no Runner 5. If the race requires five legs, it cannot be run as a single relay.

For cyclic arbitrage, this depth limit matters in the design of the on-chain program. The custom program sits at depth 0. Each CPI call to a DEX program is at depth 1. If the DEX program itself makes a CPI call — say, to the SPL Token program to execute a transfer — that's depth 2. If the Token program makes a further CPI call for some reason, that's depth 3.

With a maximum depth of 4, this leaves room for each DEX swap to be two CPI levels deep internally (DEX program at depth 1, Token program at depth 2, and one more layer if needed at depth 3). Most DEX programs fit within this budget, because they follow a common pattern: receive the swap instruction, compute the output amount, call the Token program to transfer tokens in, call the Token program to transfer tokens out. That's two CPI calls from the DEX program's perspective, putting the Token program at depth 2. The custom program's CPI to the DEX is depth 1. Everything fits.

But the depth limit means the custom program cannot delegate its orchestration to an intermediate program that then calls DEXes. If there were a "swap router" program at depth 1 that calls DEX programs at depth 2, and those DEX programs call the Token program at depth 3, the total depth is 3 — still under the limit. But if any DEX program has an additional internal CPI layer, the depth hits 4, and any further nesting fails. The design must account for the entire call tree, not just the immediate calls.

This is why the custom program calls DEX programs directly, rather than going through a generic routing layer. Each layer of indirection consumes one level of the depth budget. In a system where the budget is 4, indirection is a luxury that cannot be afforded casually.

remaining_accounts: The Flexibility Mechanism

Different DEX programs require different accounts. A Raydium AMM V4 swap needs the AMM program ID, the pool's state accounts, the AMM authority PDA, the pool's token vaults, the serum market accounts, and the token program. An Orca Whirlpool swap needs the Whirlpool program ID, the pool state, the token vaults, the tick arrays, the oracle PDA, and the token program. A Meteora DLMM swap needs the DLMM program ID, the pool state, the bin arrays, the token vaults, the oracle, and the token program.

These account sets are different in number, in type, and in ordering. A three-hop cycle through Raydium, Orca, and Meteora requires a different set of accounts than a cycle through Orca, Meteora, and Raydium. And a three-hop cycle requires a different count than a two-hop cycle.

If the custom program tried to define a fixed account layout in its instruction definition — "accounts 1-10 are for swap 1, accounts 11-20 are for swap 2, accounts 21-30 are for swap 3" — it would need a separate instruction for every possible DEX combination and hop count. The number of combinations is large and grows as new DEXes are integrated.

The solution is remaining_accounts. In Solana's Anchor framework, a program can define a set of named, typed accounts (the fixed accounts that are always required) and then accept an arbitrary number of additional accounts via the remaining_accounts field. These extra accounts are passed through in the transaction but are not typed or validated by the framework — the program itself is responsible for parsing them.

For the arbitrage program, the fixed accounts include the program's own PDA, the authority, and the system-level programs (Token program, etc.). Everything else — the pool accounts, vaults, tick arrays, oracles, and other DEX-specific accounts for each hop — goes into remaining_accounts. The program uses instruction data to determine how to route each swap through the appropriate slice of accounts.

This is analogous to a catering company that has a standard setup — tables, chairs, linens — and then a variable list of add-ons depending on the event. A wedding reception needs a dance floor and a DJ booth. A corporate dinner needs a podium and a projector screen. The catering company doesn't have a different menu item for every possible event configuration. It has a base package plus a flexible add-on system. The event planner specifies what's needed, and the catering company sets it up accordingly.

The remaining_accounts pattern makes the program generic across DEX combinations. Adding support for a new DEX doesn't require changing the program's account struct — it requires adding a new DEX type constant and the logic to construct the correct CPI instruction from the appropriate slice of remaining accounts. The program's interface stays stable. The flexibility lives in the data, not the code structure.

Chaining Multiple DEX Calls: The Core of Cyclic Arbitrage

The entire architecture comes together in the CPI chain. The custom program receives a single instruction: execute this cycle. The instruction data specifies the number of hops, the DEX type for each hop, and the minimum profit threshold. The accounts list includes the fixed program accounts followed by all the accounts needed for all hops, packed into remaining_accounts.

The program begins execution. It reads the starting balance of the base token (typically wrapped SOL) from the PDA's token account. It partitions the remaining accounts according to the hop configuration. Then it enters a loop.

For each hop: construct the appropriate swap instruction for the specified DEX type, select the corresponding slice of accounts, and invoke the DEX program via CPI. If the PDA needs to sign (which it does, because the PDA's token account is the source of funds for the first swap, and the PDA's authority is needed for token approvals), the program uses invoke_signed with the PDA's signer seeds.

After all hops complete, the program reads the ending balance of the base token. It computes the difference. If the difference is less than the specified minimum profit threshold, it returns an error — triggering a full revert of all state changes. If the difference meets or exceeds the threshold, the instruction succeeds. The transaction finalizes. The profit sits in the PDA's token account, ready for extraction.

The beauty of this design is its composability. Each DEX swap is a self-contained CPI call. The custom program doesn't need to understand the internal mechanics of Raydium's constant-product formula or Orca's tick-based concentrated liquidity or Meteora's bin-based pricing. It only needs to know how to construct a valid instruction for each DEX — which accounts to pass, in which order, with what data. The DEX programs handle their own math. The custom program handles the orchestration and the bottom-line check.

This is the subcontracting model in its purest form. The general contractor doesn't know how to wire a three-way switch or calculate pipe grades for drainage slope. The general contractor knows which subcontractor to call, what information to give them, and how to verify that the finished work meets spec. The expertise lives in the subcontractors. The coordination lives in the general contractor.

The Constraints Are the Design

CPI is not an unlimited capability. The 200,000 CU default pushes programs toward efficiency. The depth limit of 4 pushes programs toward flat architectures with minimal nesting. The signer propagation rules push programs toward PDA-based account management. The remaining_accounts pattern pushes programs toward data-driven instruction handling.

Each of these constraints shapes the design of the arbitrage program. Not as obstacles to work around, but as guardrails that produce a specific architectural shape. The program is flat (one level of CPI from the custom program to each DEX). It is efficient (each CPI call does the minimum necessary work). It is self-sovereign (PDA signing eliminates external signer dependencies). It is flexible (remaining_accounts accommodates any DEX combination without code changes).

The constraints are the design. A CPI chain for cyclic arbitrage is not a general-purpose framework. It is a specific machine built within specific limits. A machine that takes SOL, passes it through a sequence of DEX swaps via CPI, checks whether the result is profitable, and either commits the profit or reverts the attempt. Every aspect — the PDA authority model, the compute budgeting, the depth-aware architecture, the data-driven account routing — serves this single purpose.

In the same way that a NASCAR pit crew operates within the constraint of four tires, one fuel hose, and a narrow time window — and that constraint produces a specific, optimized choreography that looks effortless but is the result of working within strict limits — the CPI chain operates within Solana's constraints and produces an execution pattern that is tight, predictable, and purpose-built.

Programs calling programs. Subcontractors executing under the general contractor's coordination. Each one doing what it does best, the calling program ensuring that the whole is greater than the sum of the parts — or that nothing happens at all.

Disclaimer

This article is for informational and educational purposes only and does not constitute financial, investment, legal, or professional advice. Content is produced independently and supported by advertising revenue. While we strive for accuracy, this article may contain unintentional errors or outdated information. Readers should independently verify all facts and data before making decisions. Company names and trademarks are referenced for analysis purposes under fair use principles. Always consult qualified professionals before making financial or legal decisions.