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:
loopMode | What 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 decimalsBoth 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
| Field | Meaning |
|---|---|
transactions | Ordered ExtendedV0Transaction[]. Setup transactions (oracle cranks, integration refreshes) come before actionTxIndex; the flash loan is at actionTxIndex. |
actionTxIndex | Index of the transaction that completes the loop. For a bridged bundle it is the last leg. |
quoteResponse | The 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). |
mustBeAtomicBundle | true means the transactions must land as one atomic Jito bundle (bridged legs, integration refreshes); false means sequential sends are safe. |
bridgeMint | Bridged 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 (recommended)
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:
quoteResponseis required. ItsotherAmountThreshold(guaranteed min-out, native units) sizes the deposit, and itsinAmountmust 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 othermakeBridged*Txbuilders) never attempt the bridged fallback whenswapIxsis 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.
Related
- Collateral Swaps, Debt Swaps & Repay with Collateral — the other swap-backed flash-loan builders
- Flash Loans — what the loop is built on, and how to compose your own
- Transactions — bundles,
simulateBundle, priority fees - Looping & Strategies guide
- Examples: 16a-loop.ts, 16b-loop-bridged.ts, 16c-loop-pinned-route.ts
Health, Max Amounts & E-mode
Read account health from the simulated health cache, size actions with computeMax*ForBank, and inspect e-mode state.
Collateral Swaps, Debt Swaps & Repay with Collateral
Rebalance an open position atomically — swap one collateral for another, move debt between tokens, repay debt from collateral, or roll a matured Exponent PT — without touching account health mid-way.