Skip to content

Cross-Program Invocation and Signed PDAs

The last three pages built a wall around a single program. The sandbox denies it everything by default; the only reachable state is the account slice it was handed, and the only way back to the runtime is a small set of audited syscalls. Compute units guarantee it halts. So far, so isolated — but a chain where programs cannot call each other is a chain of islands. A DEX cannot ask the token program to move tokens; a lending market cannot ask an oracle for a price. Isolation without composition is useless.

This page is where the wall gets a door. Cross-Program Invocation (CPI) lets one program call another mid-execution, passing along accounts and instruction data — and it does so without weakening a single guarantee from the previous pages. The trick is that the door is not a hole in the wall; it is another audited syscall, and the runtime re-checks every privilege on the way through. Then we answer the question the account model left open: when a program calls the token program to move its own tokens, who signs? The answer is a Program Derived Address and the invoke_signed syscall — deterministic, keyless authority a program has over the accounts it owns.

Recall the shape of a program from Stateless Programs Act on Accounts: it is a pure function process(accounts, data). A transaction names a top-level program and hands it a list of accounts. CPI is what happens when that program, mid-process, decides it needs another program to do part of the work.

It does not link the other program’s code, import it, or share memory with it. It issues a syscall — in real Solana, invoke (or the lower-level sol_invoke_signed) — that says: “Runtime, please run program X, on this instruction, with this subset of my accounts.” The runtime then loads X, sets up a fresh sandbox for it, runs its process, and returns control.

top-level transaction
─────────────────────
names program A, accounts [payer, vault, token_acct, token_program, ...]
A.process(accounts, data)
│ A needs tokens moved. It does NOT touch token_acct directly
│ (it does not own it). It asks the token program to:
└──► invoke( TokenProgram, ix=Transfer{amount},
accounts=[vault, token_acct, authority] )
runtime loads TokenProgram, new sandbox, runs:
TokenProgram.process([vault, token_acct, authority], Transfer)
▼ returns Ok/Err back up into A.process
A continues, or aborts the whole transaction on Err

The caller A passes along accounts it already holds; it cannot conjure new ones. The callee TokenProgram receives its own ordered slice — the same process(accounts, data) contract as any top-level call. From the callee’s point of view there is no difference between being invoked by a user’s transaction and being invoked by another program. That uniformity is what makes Solana composable: any program is callable by any other, over the same account-passing interface, with no special “I am being called by a program” mode.

This is the on-chain equivalent of a function call, but across a security boundary. And because every program speaks the same (accounts, data) language, programs written by strangers, at different times, compose without prior agreement — the token program was written years before your DEX, and your DEX calls it without either side knowing the other exists.

The re-check: privileges do not expand across the boundary

Section titled “The re-check: privileges do not expand across the boundary”

Here is the property that makes CPI safe rather than a giant hole. When A invokes TokenProgram, the runtime does not trust A to have set up the call honestly. It re-verifies, from scratch, that every account A is passing to TokenProgram carries privileges A itself was granted.

Recall the account flags from The Instruction and Account Flags: every account in a transaction is tagged is_signer and is_writable. Those flags are not decoration — they are capabilities. The rule at the CPI boundary is simple and absolute:

A callee can only receive an account as writable if the caller had it writable, and can only receive it as a signer if the caller had it as a signer. Privileges can be narrowed on the way down, never widened.

privileges the runtime granted A (from the transaction):
vault : writable, NOT signer
token_acct : writable, NOT signer
authority : signer, NOT writable
A tries to invoke TokenProgram passing:
vault : writable ✔ A had it writable
token_acct : writable ✔ A had it writable
authority : signer ✔ A had it as signer ── privilege PASSES through
some_account : writable --> X A had it read-only ── runtime REJECTS the CPI

Think of it as the borrow checker’s shared XOR mutable, but for privileges instead of memory, and enforced across the call boundary. A cannot forge write access to an account it was only allowed to read, and it cannot forge a signature it never received. If A tries, the runtime aborts the CPI — and because a failed instruction fails the whole transaction, nothing commits.

Why does this matter so much? Because without it, composition would be an escalation attack. If calling a program let you hand it more power than you had, then every program would be a potential laundering step: pass a read-only account through some intermediary that “upgrades” it to writable, and the sandbox is meaningless. The re-check is what lets you call untrusted programs safely — the worst a malicious callee can do is misuse the exact privileges you already held, never more.

Under the hood — how a signature survives a CPI

Section titled “Under the hood — how a signature survives a CPI”

There is a subtle case worth making explicit. When a user signs a transaction, their signature is verified once, at the top, before any program runs (see signing and verification). The account is then marked is_signer = true for the whole transaction. When program A passes that account down to TokenProgram as a signer, no signature is re-checked — the runtime already verified it, and it simply carries the is_signer flag through the boundary, having confirmed A genuinely held it.

So a signer privilege is a capability the runtime tracks, not a cryptographic operation repeated at each hop. This is exactly what makes the next section possible: if “being a signer” is a flag the runtime sets rather than a signature it demands at every call, then the runtime can set that flag for an account that has no key at all — provided the program proves it owns the account. That is a signed PDA.

Signed PDAs: keyless authority via invoke_signed

Section titled “Signed PDAs: keyless authority via invoke_signed”

The cryptography part established the geometry: a Program Derived Address (PDA) is a 32-byte address derived from a program’s id plus some seeds, deliberately pushed off the ed25519 curve so that no private key exists for it. If you have not internalized that, read Program Derived Addresses and Deriving a PDA first — this page assumes the what and teaches the how it acts inside the runtime.

The problem a PDA solves at the runtime level is precise. A program owns accounts (that is what owner means). Often it must call another program — say the token program — to act on those accounts: move tokens out of a vault, mint from a mint. But the token program will only move tokens if the token account’s authority signs. The authority is a PDA. A PDA has no key. So how does the calling program sign?

It does not. It uses invoke_signed, and hands the runtime the seeds instead of a signature:

Program A owns a vault PDA = derive(seeds=["vault", user], program_id=A)
A.process:
invoke_signed(
ix = TokenProgram::Transfer { amount },
accounts= [vault, dest, vault_authority /* = the PDA */],
seeds = [ ["vault", user, bump] ] ← the PDA's seeds, NOT a key
)
▼ runtime, before running TokenProgram:
re-derive addr = hash(seeds ++ A.program_id) , push off-curve with bump
addr == vault_authority ?
├─ yes → mark vault_authority as is_signer = true, for THIS call only
└─ no → reject the CPI
TokenProgram sees vault_authority as a signer → authorizes the transfer

The runtime re-derives the address from the supplied seeds plus the calling program’s own id. If the result equals the account the program is trying to sign for, the runtime marks that account is_signer = true for the duration of the inner call — no key, no cryptography, just a verified re-derivation.

Two facts make this airtight:

  • The program id is baked into the derivation. The seeds are hashed with the caller’s own program id. So program A can only ever produce a valid invoke_signed for a PDA derived under A. A different program B running the same seeds derives a different address, so B can never sign for A’s PDAs. Authority is partitioned by program, automatically, by geometry.
  • No secret is stored anywhere. The “credential” a program presents is the seeds, which are public and computable. There is nothing to leak, because there is no key. Compare this to the alternative — embedding a keypair in a program’s code or config — which would put the program’s identity in plaintext on-chain for anyone to read and impersonate.

This is what promotes a program from “code that edits accounts it was given” to a first-class entity that can hold funds and authorize actions: the escrow that releases deposits only by its own rules, the mint whose authority is a program instruction rather than a human key. The authority is a rule, enforced by re-derivation, not a secret guarded by hope.

Re-entrancy: why CPI does not reopen the Ethereum wound

Section titled “Re-entrancy: why CPI does not reopen the Ethereum wound”

If you know one famous smart-contract bug, it is probably re-entrancy — the class that drained The DAO on Ethereum in 2016. The shape is: contract A calls out to contract B (say, to send it funds); B, before returning, calls back into A while A’s state is mid-update; A, not expecting to be re-entered, lets B withdraw again, and again, against a balance that was never decremented. The vulnerability lives in the gap between “A started an action” and “A finished recording it,” which an attacker re-enters.

Solana’s design narrows this attack surface from two directions at once, and it is worth seeing that both come from mechanisms you already have:

  • Direct re-entrancy is forbidden. A program is not allowed to CPI into itself (nor create a cycle back to a program already in the current call stack). Real Solana rejects self-re-entrant CPI outright. So the classic “call out, get called back into the same half-updated function” loop simply cannot form.
  • State is guarded by ownership and the privilege re-check, not by call order. This is the deeper reason. On Ethereum, a contract holds its own storage, and re-entrancy exploits the timing of when that private storage is written. On Solana, state lives in external accounts, and only the owning program can write an account, and a CPI cannot widen the writable privilege it was handed. So even if program B calls some third program during A’s call, none of them can write A’s accounts unless A explicitly passed them writable — and even then, the runtime re-verifies that privilege at every hop. The attacker cannot manufacture write access mid-call, which is the very thing re-entrancy needs.
Ethereum re-entrancy Solana
──────────────────── ──────
A holds its own storage state in external, owned accounts
A calls B before finishing update A may CPI B, but:
B re-enters A, storage half-written · B cannot re-enter A (cycle forbidden)
B withdraws against stale balance · B cannot write A's accounts unless A
passed them writable — re-checked each hop
· only A's owner may write A's accounts

None of this makes Solana programs magically safe — you can still write logic bugs, and (as the incident below shows) account confusion is a live danger. But the specific, catastrophic re-entrancy class that composition opened on Ethereum is closed by construction here: composability and per-account privilege enforcement are the same mechanism, so composing does not weaken confinement.

  • Why does it exist? Because an isolated program is a useless program. CPI is the door in the sandbox wall that lets one program call another — the mechanism that turns thousands of separate programs into a composable system where a DEX can call the token program and a lending market can call an oracle.
  • What problem does it solve? Composition without privilege escalation. A program can borrow another program’s logic while the runtime guarantees the callee can only touch accounts the caller was already allowed to touch, with exactly the caller’s signer and writable rights — never more.
  • What are the trade-offs? You gain open, permissionless composability, but every call boundary is a place account confusion can hide (Wormhole), each CPI spends stack and compute budget, and depth is capped at 4. The runtime enforces privileges for you; it cannot enforce that you passed the accounts you intended — that validation is yours.
  • When should I avoid it? When the work is genuinely internal to your program, inline it rather than paying a CPI’s setup cost and depth. And never CPI into an unvalidated program id or over unvalidated accounts — if you cannot confirm what you are calling and what you are handing it, do not make the call.
  • What breaks if I remove it? Composability collapses. Every program becomes an island that can only touch its own accounts; there are no on-chain integrations, no token standard shared across apps, no PDAs signing for cross-program actions — and DeFi, which is composition all the way down, cannot exist.
  1. CPI does not link or import the callee’s code. Mechanically, what does a program actually do to invoke another program, and why is the callee’s process(accounts, data) contract identical whether it was called by a user or by another program?
  2. State the CPI privilege rule precisely for both is_signer and is_writable. Why would composition be an escalation attack if this rule did not hold?
  3. A PDA has no private key, yet a program can make the token program treat it as a signer. Walk through what invoke_signed hands the runtime and what the runtime checks before setting is_signer on the PDA.
  4. The seeds passed to invoke_signed are hashed together with the calling program’s own id. What security property does including the program id buy — specifically, why can program B never sign for a PDA that program A derived?
  5. Re-entrancy drained The DAO on Ethereum. Name the two distinct Solana mechanisms that together close that attack class, and explain why “composability and confinement are the same mechanism” is the accurate way to describe it.
Show answers
  1. It issues a syscall (invoke / sol_invoke_signed) that asks the runtime to run program X on a given instruction with a subset of the caller’s accounts. The runtime loads X, sets up a fresh sandbox, and runs its process. The callee receives an ordered account slice and opaque data — the exact same contract as a top-level call — so from the callee’s side there is no difference between a user invocation and a program invocation. That uniformity (every program speaks the same (accounts, data) interface) is what makes any program callable by any other, which is composability.
  2. An account may be passed to a callee as writable only if the caller held it writable, and as a signer only if the caller held it as a signer; privileges can be narrowed on the way down but never widened. Without this, calling a program could launder privileges — pass a read-only account through an intermediary that “upgrades” it to writable, or forge a signer you never had — so composition would become a way to gain power you were never granted, and the sandbox’s confinement would be meaningless.
  3. invoke_signed hands the runtime the seeds the program used to derive the PDA (not a key or signature). The runtime re-derives the address by hashing those seeds together with the calling program’s own id and pushing it off-curve with the bump; if the result equals the account the program is trying to sign for, it marks that account is_signer = true for the inner call only. No cryptography runs — “signing” is a verified re-derivation, not a signature.
  4. Because the derivation mixes in the caller’s program id, only the program whose id is in the hash can produce a matching address. Program B running A’s exact seeds hashes them with B’s id, which yields a different address, so B’s invoke_signed never matches A’s PDA and is rejected. Authority is partitioned per-program automatically — a program can only ever sign for PDAs derived under itself.
  5. (a) A program cannot CPI into itself or form a cycle back into a program already on the call stack, so the “call out, get called back into a half-updated function” loop cannot form. (b) State lives in external accounts that only the owning program can write, and a CPI cannot widen the writable privilege it was handed (re-checked at every hop), so no callee can manufacture write access to A’s accounts mid-call. It is “the same mechanism” because the per-account privilege re-check that enables safe composition (you can call untrusted code without granting it power) is the very same check that confines state writes — so composing never loosens confinement.