TypeScript SDK

Looping

Build a leveraged position in one flash-loan transaction with makeLoopTx and makeBridgedLoopTx, configure the swap engine, and handle routing failures.

A loop borrows, swaps the borrowed token into the collateral token, and deposits it again, all inside one flash loan. The result is a leveraged position opened in a single transaction instead of repeated deposit/borrow round-trips. This page assumes you have a client and wrappedAccount from Getting Started; for the product-level explanation see the Looping & Strategies guide.

What a loop is

The SDK builds one flash-loan transaction in the shape begin_flashloan → borrow X → swap X to the deposit asset → deposit → end_flashloan. Health is only checked at end_flashloan, so the intermediate borrow never has to be collateralized on its own.

depositOpts.loopMode decides where the principal comes from:

loopModeWhat happens
"DEPOSIT"inputDepositAmount is pulled from the wallet and deposited as new principal, then the borrow→swap→deposit leg adds leverage on top.
"BORROW"No new principal is deposited. Only the borrow→swap→deposit leg runs, levering up collateral the account already holds. Pass inputDepositAmount: 0 and size borrowAmount from the existing position.

Two builders exist:

  • wrappedAccount.makeLoopTx(...) — the direct loop. One flash-loan transaction with a single borrow→deposit swap route. If that route does not fit one transaction (bytes or account locks) or cannot be quoted, the build throws.
  • wrappedAccount.makeBridgedLoopTx(...) — the same call with a transparent bridged fallback. It tries the direct loop first; if that fails for a routing reason it loops against a value-equivalent borrow of a bridge token (USDC, wSOL, USDT by default), then debt-swaps that bridge debt into the requested borrow asset, as one atomic Jito bundle.

Building a loop

Pick the banks

Only standard, operational banks accept deposits and borrows in a loop. Use the bank predicates rather than filtering on asset tags yourself, and read each bank's token program through getMintDataFromBank:

import { isStandardDepositable, isStandardBorrowable } from "@0dotxyz/p0-ts-sdk";
import { PublicKey } from "@solana/web3.js";

const SOL_MINT = new PublicKey("So11111111111111111111111111111111111111112");
const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");

const depositBank = client.banks.find(
  (bank) => bank.mint.equals(SOL_MINT) && isStandardDepositable(bank)
);
const borrowBank = client.banks.find(
  (bank) => bank.mint.equals(USDC_MINT) && isStandardBorrowable(bank)
);
if (!depositBank || !borrowBank) throw new Error("No loopable bank pair found");

const depositMintData = await wrappedAccount.getMintDataFromBank(depositBank);
const borrowMintData = await wrappedAccount.getMintDataFromBank(borrowBank);

isStandardDepositable excludes reduce-only banks and Kamino/Drift/JupLend wrapper banks (depositing into those reverts). isStandardBorrowable keeps only standard banks with a non-zero borrow limit. See Banks & Prices for the full set of predicates.

Size the position

Leverage L on principal P means borrowing (L - 1) × P worth of the borrow asset. Price both sides in USD — the examples use the client's realtime oracle prices; the app seeds this with a tradeable market price and falls back to the oracle:

const DEPOSIT_AMOUNT = 0.5; // principal, UI units
const LEVERAGE = 2;

const priceOf = (bankAddress: PublicKey): number => {
  const price = client.oraclePriceByBank
    .get(bankAddress.toBase58())
    ?.priceRealtime.price.toNumber();
  if (!price || price <= 0) throw new Error(`Missing oracle price for ${bankAddress.toBase58()}`);
  return price;
};

const depositPrice = priceOf(depositBank.address);
const borrowPrice = priceOf(borrowBank.address);

const borrowAmount = (DEPOSIT_AMOUNT * (LEVERAGE - 1) * depositPrice) / borrowPrice;

To check how far you can go, computeMaxLeverage(depositBank, borrowBank) returns { maxLeverage, ltv } from the banks' initial weights, and computeLoopingParams(principal, targetLeverage, depositBank, borrowBank, depositPriceUsd, borrowPriceUsd) returns { totalDepositAmount, totalBorrowAmount } with the target leverage clamped to the valid range:

import { computeMaxLeverage, computeLoopingParams } from "@0dotxyz/p0-ts-sdk";

const { maxLeverage } = computeMaxLeverage(depositBank, borrowBank);
const { totalBorrowAmount } = computeLoopingParams(
  DEPOSIT_AMOUNT,
  Math.min(LEVERAGE, maxLeverage),
  depositBank,
  borrowBank,
  depositPrice,
  borrowPrice
);
// totalBorrowAmount is a BigNumber equal to the borrowAmount formula above, rounded to mint decimals

Both ignore e-mode weights unless you pass opts.assetWeightInit / opts.liabilityWeightInit; see Health, Max Amounts & E-mode.

Configure the swap

The swap leg is routed by the SDK's swap engine, driven by a SwapProviderConfig passed as swapOpts.swapConfig. provider plus fallbackProviders are queried as co-equal candidates and the engine picks the best route that fits the flash-loan transaction. Only providers you give credentials for are reachable.

Prop

Type

Jupiter only (the public endpoint is rate-limited; set apiKey for production):

import { SwapProvider, type SwapProviderConfig } from "@0dotxyz/p0-ts-sdk";

const swapConfig: SwapProviderConfig = {
  provider: SwapProvider.JUPITER,
  slippageMode: "DYNAMIC",
  slippageBps: 50,
  platformFeeBps: 0,
  directRoutesOnly: false,
  apiConfig: {
    basePath: "https://api.jup.ag/swap/v1",
    apiKey: process.env.JUPITER_API_KEY,
  },
};

Titan primary with a Jupiter fallback (the Project 0 app default):

const swapConfig: SwapProviderConfig = {
  provider: SwapProvider.TITAN,
  slippageMode: "DYNAMIC",
  slippageBps: 50,
  platformFeeBps: 0,
  apiConfig: {
    basePath: "https://<titan-host>/api/v1",
    wsUrl: "wss://<titan-host>/api/v1/ws",
    apiKey: process.env.TITAN_API_KEY,
  },
  fallbackProviders: [
    {
      provider: SwapProvider.JUPITER,
      apiConfig: { basePath: "https://api.jup.ag/swap/v1", apiKey: process.env.JUPITER_API_KEY },
    },
  ],
};

This swapConfig shape is shared by every swap-backed builder (collateral/debt swaps, repay with collateral); this page is its home.

Build the transaction

Both builders take the same depositOpts / borrowOpts / swapOpts. The one difference to watch: makeLoopTx requires you to pass assetShareValueMultiplierByBank (use client.assetShareValueMultiplierByBank), while makeBridgedLoopTx injects it for you and additionally accepts bridgeOpts.

const result = await wrappedAccount.makeLoopTx({
  connection,
  depositOpts: {
    inputDepositAmount: DEPOSIT_AMOUNT,
    depositBank,
    tokenProgram: depositMintData.tokenProgram,
    loopMode: "DEPOSIT", // "BORROW" levers an existing position without new principal
    marketPrice: depositPrice,
  },
  borrowOpts: {
    borrowAmount,
    borrowBank,
    tokenProgram: borrowMintData.tokenProgram,
    marketPrice: borrowPrice,
  },
  swapOpts: { swapConfig },
  assetShareValueMultiplierByBank: client.assetShareValueMultiplierByBank,
});

Returns { transactions, actionTxIndex, quoteResponse, mustBeAtomicBundle }.

Optional params shared by both: additionalIxs (spliced into the flash loan), crossbarUrl (Switchboard crossbar override for oracle cranks), overrideInferAccounts ({ group?, authority? }), and swapEngineRunner (see below). Lookup tables come from client.addressLookupTables automatically.

Inspect the result

FieldMeaning
transactionsOrdered ExtendedV0Transaction[]. Setup transactions (oracle cranks, integration refreshes) come before actionTxIndex; the flash loan is at actionTxIndex.
actionTxIndexIndex of the transaction that completes the loop. For a bridged bundle it is the last leg.
quoteResponseThe swap engine's SwapQuoteResult (inAmount, outAmount, otherAmountThreshold, slippageBps, priceImpactPct?, provider?, ...). inAmount is the borrow in native units, outAmount the extra collateral. Bridged results carry a merged quote across both legs. undefined when no swap was needed (same mint).
mustBeAtomicBundletrue means the transactions must land as one atomic Jito bundle (bridged legs, integration refreshes); false means sequential sends are safe.
bridgeMintBridged builder only. Set to the bridge token's mint when the fallback path was used, undefined on a direct build.
if (result.quoteResponse) {
  const borrowedUi = Number(result.quoteResponse.inAmount) / 10 ** borrowBank.mintDecimals;
  const depositedUi = Number(result.quoteResponse.outAmount) / 10 ** depositBank.mintDecimals;
  console.log(`borrow ~${borrowedUi} ${borrowBank.tokenSymbol} → +${depositedUi} ${depositBank.tokenSymbol}`);
  console.log(`price impact: ${result.quoteResponse.priceImpactPct ?? "n/a"}%`);
}

Simulate and send

import { simulateBundle } from "@0dotxyz/p0-ts-sdk";

const sims = await simulateBundle(connection.rpcEndpoint, result.transactions);
sims.forEach((sim, i) => {
  if (sim.err) throw new Error(`tx ${i} failed: ${JSON.stringify(sim.err)}`);
});

if (result.mustBeAtomicBundle) {
  // sign every tx and submit them together as one Jito bundle
} else {
  for (const tx of result.transactions) {
    const sig = await wallet.sendTransaction(tx, connection);
    await connection.confirmTransaction(sig);
  }
}

Bundle submission, priority fees and the other send-side details live on Transactions.

Controlling the route

Integrators that must review or whitelist the exact swap they sign have two supported paths.

swapEngineRunner replaces the in-process runSwapEngine. It receives a SwapEngineRequest (mints, exact native input amount, remaining byte and account budget, the providers to query) and returns a SwapEngineResult (swapInstructions, setupInstructions, swapLuts, quoteResponse, outputAmountNative, provider). Wrap the default runner to inspect or veto the route before it is spliced into the flash loan:

import { runSwapEngine } from "@0dotxyz/p0-ts-sdk";

const reviewingRunner: typeof runSwapEngine = async (req) => {
  const route = await runSwapEngine(req);
  const programs = new Set(route.swapInstructions.map((ix) => ix.programId.toBase58()));
  for (const program of programs) {
    if (!ALLOWED_PROGRAMS.has(program)) throw new Error(`route touches disallowed program ${program}`);
  }
  return route;
};

const result = await wrappedAccount.makeLoopTx({
  ...loopParams,
  swapEngineRunner: reviewingRunner,
});

The same option exists on the collateral-swap, debt-swap and repay-with-collateral builders.

Pinned swapOpts.swapIxs

For a fully static, pre-reviewed route, pass the instructions yourself and omit swapConfig:

const result = await wrappedAccount.makeLoopTx({
  ...loopParams,
  swapOpts: {
    swapIxs: {
      instructions: reviewedRoute.instructions,
      lookupTables: reviewedRoute.lookupTables,
      quoteResponse: reviewedRoute.quoteResponse, // required
    },
  },
});

Rules when pinning:

  • quoteResponse is required. Its otherAmountThreshold (guaranteed min-out, native units) sizes the deposit, and its inAmount must equal the loop's borrow amount. The SDK validates both and throws otherwise.
  • The route must pay out to the flow's destination token account (the deposit ATA of the account authority), and any extra ATAs the route needs are yours to create.
  • A pinned route belongs to the direct pair: makeBridgedLoopTx (and the other makeBridged*Tx builders) never attempt the bridged fallback when swapIxs is supplied.

examples/16c-loop-pinned-route.ts shows the full capture → review → pin workflow.

Handling errors

Loop builders throw TransactionBuildingError with a typed code; two helpers cover the routing cases:

import { isDecomposableSwapError, isBridgeConflictError } from "@0dotxyz/p0-ts-sdk";

try {
  result = await wrappedAccount.makeLoopTx(loopParams);
} catch (e) {
  if (isDecomposableSwapError(e)) {
    // SWAP_SIZE_EXCEEDED_* or SWAP_QUOTE_FAILED: the single route did not fit or had no quote.
    // The pair may still be loopable through a bridge token.
    result = await wrappedAccount.makeBridgedLoopTx(loopParams);
  } else {
    throw e;
  }
}

makeBridgedLoopTx already does this retry internally. If the direct build fails and every bridge candidate is blocked, it throws BRIDGE_CONFLICT:

try {
  result = await wrappedAccount.makeBridgedLoopTx(loopParams);
} catch (e) {
  if (isBridgeConflictError(e)) {
    // A token cannot be both collateral and debt on one bank. A loop borrows the bridge,
    // so candidates the account already supplies are skipped (bridgeTokenSide === "borrow").
    const blocked = e.details.conflictingBanks.map((b) => b.symbol ?? b.mint).join(", ");
    throw new Error(`Loop must route through ${blocked}, but the account already supplies them`);
  }
  throw e;
}

e.details for BRIDGE_CONFLICT is { conflictingBanks: Array<{ bankAddress, mint, symbol? }>, bridgeTokenSide: "deposit" | "borrow" }. Any other error is the direct build's own failure (health, oracle crank, Kamino reserve lookup, ...); the full code table is on Error Handling.

On this page