Skip to main content
Beta. On-chain CPI composability is newly available. The POST /api/v1/cpi/route-accounts endpoint and the vulcx-aggregator on-chain helpers may change before general availability. If you hit a rough edge, reach out on Telegram.
Most integrations call Vulcx from off-chain: get a quote, build a transaction, sign with a wallet. This guide is for the other case — when another on-chain program needs a swap as one atomic step inside its own instruction, signed by a program-derived address (PDA) rather than a user wallet. Your program calls the Vulcx aggregator’s route instruction via Cross-Program Invocation (CPI), invoke_signed with your PDA seeds. Because it happens inside your transaction, the swap and the logic around it (repay a loan, rebalance a vault, credit a merchant) either all succeed or all revert.

Why call route on-chain

Typical use cases: lending liquidations and collateral swaps, vault auto-compounding and rebalancing, pay-with-any-token settlement, perps cross-collateral conversion, DAO treasury diversification, and zaps (swap-then-LP in one transaction).

When to use CPI vs the HTTP paths

All three start from the same routing engine. Only the CPI path signs with a PDA and returns an instruction shaped to be forwarded from inside your program.

Flow

Step 1 — Get the account list (off-chain)

Your backend calls POST /api/v1/cpi/route-accounts with your program’s PDA as the authority. The response contains the route instruction (with the PDA already wired in as the swap authority) plus the token accounts that PDA must own.
The data payload:
requiredTokenAccounts are ATAs owned by your PDA, ordered [inputMint, …intermediates, outputMint]. This endpoint emits no ATA-create and no SOL wrap/unwrap instructions — your program is responsible for creating and funding them before the CPI runs.
See the full schema in the CPI Route Accounts API reference.

Route corridors — constrain what the router may touch

Open routing gives the best price; a CPI integrator often wants determinism instead — the route must stay inside the ATAs and DEX flows you pre-provisioned and pre-audited. Three optional request fields constrain routing per request:
  • allowedIntermediateMints closes the requiredTokenAccounts set: multi-hop routes may only pass through these mints (input and output are implicitly allowed). Your PDA’s ATA set becomes static.
  • excludeDexes: ["moonit"] also lifts the authority-writable requirement — one less thing for your auditors.
  • Constrained requests bypass the route caches and never split. If no route exists inside the constraints, the endpoint returns 404 with a message telling you to widen them.
The tradeoff is honest: corridors trade price for determinism. Liquidation bots take that trade.

Your integrator fee

Add "referrer": "<wallet>" and "integratorFeeBps": <rate> to charge your own fee on top of Vulcx’s. You keep 100% of it, paid in the output token directly by the aggregator program. It is added to Vulcx’s platformFeeBps rather than carved out of it, and the sum may not exceed 100 bps. See Fees. The response then includes referrerAta — the referrer’s output-mint ATA appended to the account list. It must exist before the CPI runs (create it once per output mint); the fee transfer fails otherwise.

Step 2 — Call route from your program (on-chain)

Add the Vulcx aggregator as a library dependency and use its route_cpi helper to assemble the account list and invoke route, signing with your PDA seeds.
The on-chain helpers ship as the standalone vulcx-cpi crate — typed per-hop builders, arg encoding pinned byte-for-byte to the deployed program by CI parity tests, and (behind the api feature, off-chain only) a from_api_response bridge that parses the Step 1 JSON straight into typed RouteAccounts + RouteArgs and fails loudly on any layout drift:
Cargo.toml
keeper.rs (off-chain, api feature)
The crate’s major version tracks the deployed program’s instruction layout.
The instruction below swaps the vault’s source token → destination token through a single Vortex hop, signed by the vault PDA. It maps 1:1 to the reference program — the only difference from a no-op probe is num_steps = 1 plus a populated token_accounts/hops.
lib.rs
The PDA-as-signer mechanic (&[&[SEED, &[bump]]]) is the whole point: route accepts your program’s PDA as user_transfer_authority, so the swap is authorized by your program, not a wallet.

Account layout reference

route reads a fixed set of accounts followed by remaining_accounts (your token accounts, then one group per hop). RouteAccounts::account_metas() produces this exact order and flags for you — you rarely assemble it by hand — but here it is for reference and debugging. Fixed accounts (always first, in this order): Then, in remaining_accounts: all your token_accounts (writable), then each hop’s account group in route order, then optional protocol_fee_ata / referrer_ata (writable). Per-hop account groups (count and flags depend on the DEX):

Streaming route templates (zero round-trips at fire time)

Polling POST /cpi/route-accounts before every action costs a round-trip you may not have — on Fogo’s ~40 ms blocks, a liquidation won or lost in one block. The WebSocket stream can push the route template to you instead:
The server pushes {"type":"route", "seq":…, "slot":…, "route":{…}} — the same payload as POST /cpi/route-accountsonly when the template actually changes (route plan, account list, or LUTs; price ticks alone never push). For a corridor-constrained pair that’s rare, so your keeper holds the latest template in memory and fires with zero HTTP round-trips when its trigger hits.
  • seq increments by 1 per change; a gap means you missed a push — resubscribe.
  • Amounts inside the template are as-of the last change, not tick-fresh; re-quote (or redeem a firm quote) if you need the current price.
  • Max 32 route subscriptions per connection.

Gotchas

Your PDA must already own funded ATAs. The CPI builder emits no ATA-create or SOL wrap/unwrap instructions. Create and fund the accounts listed in requiredTokenAccounts before the CPI runs.
  • simulate: false for real execution. simulate: true makes route bail early — it’s only for probing the account wiring without touching liquidity.
  • Moonit hops need a writable authority. If your route includes a Moonit hop, the authority must be a writable signer. RouteAccounts detects this and sets the flag; make sure your PDA account is marked mut in the context (harmless for non-Moonit routes).
  • Split routes aren’t supported yet. A split route returns 422 from POST /api/v1/cpi/route-accounts. Retry with a pair that routes through a single path (any corridor constraint also disables splits).
  • Multi-hop ExactOut is rejected with a 400. An exact-output swap can only bound its input on a single hop. For exact-out across hops use the ExactIn-with-buffer pattern: quote ExactOut to size the input, execute ExactIn at input × (1 + buffer), then sweep the surplus output.
  • Keep the layout in sync. If you hand-assemble accounts instead of forwarding what the endpoint returns, a single wrong writable/signer flag surfaces as an opaque privilege-escalation or not-writable failure deep inside the CPI. Prefer forwarding routeInstruction.accounts verbatim.

The liquidation kit

Liquidation kit — worked example

A complete reference integration: a minimal Anchor liquidate instruction that CPIs route via vulcx-cpi (PDA signing, simulate-refusal, balance-delta min-out), plus a TypeScript keeper bot that consumes subscribe_route templates and fires liquidations with zero HTTP round-trips. Every gotcha on this page appears in it as code, not prose. Ask on Telegram for access.
Last modified on August 31, 2026