TypeScript SDK

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.

These builders restructure an existing position inside a single flash loan: the withdraw or borrow, the swap and the deposit or repay all land in one transaction, and the risk engine only checks health at the end. The user never has to hold the intermediate tokens or survive a temporarily unhealthy account. This page assumes you have a client and wrappedAccount from Getting Started.

BuilderWhat it doesShape inside the flash loan
makeSwapCollateralTx / makeBridgedSwapCollateralTxReplace collateral A with collateral Bwithdraw A → swap A→B → deposit B
makeSwapDebtTx / makeBridgedSwapDebtTxReplace debt A with debt Bborrow B → swap B→A → repay A
makeRepayWithCollatTxPay down debt using collateralwithdraw A → swap A→B → repay B
makeRollPtTxRoll a matured Exponent PT into the next maturitywithdraw PT_old → redeem → buy PT_new → deposit

The makeBridged* variants try the direct single-route build first and, when the swap does not fit one transaction or cannot be quoted, decompose it through a bridge token (USDC, wSOL, USDT by default) as one atomic Jito bundle. swapOpts is the same { swapConfig } object documented on Looping.

Reading the current position size

Every builder takes totalPositionAmount in UI units so it can detect a full withdraw/repay. Compute it from the balance's shares through the bank:

const sourceBalance = wrappedAccount.activeBalances.find((b) =>
  b.bankPk.equals(sourceBank.address)
);
if (!sourceBalance) throw new Error("No position in this bank");

// Collateral position
const collateralUi = sourceBank
  .getAssetQuantity(sourceBalance.assetShares)
  .div(10 ** sourceBank.mintDecimals)
  .toNumber();

// Debt position
const debtUi = sourceBank
  .getLiabilityQuantity(sourceBalance.liabilityShares)
  .div(10 ** sourceBank.mintDecimals)
  .toNumber();

balance.computeQuantityUi(bank) returns { assets, liabilities } already in UI units if you prefer; see Accounts. Always read the token program of each bank with wrappedAccount.getMintDataFromBank(bank) — the builders need it for both sides.

Collateral swap

The destination bank must pass isStandardDepositable. Omit withdrawAmount to swap the whole position; pass it for a partial swap.

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

const destinationBank = client.banks.find(
  (bank) => bank.mint.equals(MSOL_MINT) && isStandardDepositable(bank)
)!;
const sourceMintData = await wrappedAccount.getMintDataFromBank(sourceBank);
const destinationMintData = await wrappedAccount.getMintDataFromBank(destinationBank);

const result = await wrappedAccount.makeSwapCollateralTx({
  connection,
  withdrawOpts: {
    totalPositionAmount: collateralUi,
    withdrawAmount: collateralUi / 2, // optional, defaults to totalPositionAmount
    withdrawBank: sourceBank,
    tokenProgram: sourceMintData.tokenProgram,
  },
  depositOpts: {
    depositBank: destinationBank,
    tokenProgram: destinationMintData.tokenProgram,
  },
  swapOpts: { swapConfig },
  assetShareValueMultiplierByBank: client.assetShareValueMultiplierByBank,
});
// { transactions, actionTxIndex, quoteResponse, mustBeAtomicBundle }

If source and destination share a mint (moving between two banks of the same token) no swap is run and quoteResponse is undefined. Otherwise quoteResponse.outAmount is the expected destination amount in native units.

Debt swap

The destination bank must pass isStandardBorrowable. There is no ExactOut quote for this flow, so the builder sizes the new borrow from the two marketPrice values (USD per token, UI units) you pass — the examples use the client's realtime oracle prices. Bridge legs price themselves from the oracle map automatically.

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

const destinationBank = client.banks.find(
  (bank) => bank.mint.equals(SOL_MINT) && isStandardBorrowable(bank)
)!;

const priceOf = (bank: typeof sourceBank) =>
  client.oraclePriceByBank.get(bank.address.toBase58())!.priceRealtime.price.toNumber();

const result = await wrappedAccount.makeSwapDebtTx({
  connection,
  repayOpts: {
    totalPositionAmount: debtUi,
    repayAmount: debtUi, // optional, defaults to totalPositionAmount
    repayBank: sourceBank,
    tokenProgram: sourceMintData.tokenProgram,
    marketPrice: priceOf(sourceBank),
  },
  borrowOpts: {
    borrowBank: destinationBank,
    tokenProgram: destinationMintData.tokenProgram,
    marketPrice: priceOf(destinationBank),
  },
  swapOpts: { swapConfig },
  assetShareValueMultiplierByBank: client.assetShareValueMultiplierByBank,
});
// { transactions, actionTxIndex, quoteResponse, mustBeAtomicBundle }

Reading the quote differs between the two paths: on a direct debt swap the new debt is the borrow, i.e. quoteResponse.inAmount; on a bridged result the merged quote maps it to quoteResponse.outAmount. Check result.bridgeMint !== undefined to tell them apart.

Repay with collateral

Withdraw collateral, swap it into the debt token and repay, in one flash loan. withdrawAmount is the exact collateral to sell; repayOpts.totalPositionAmount caps the repay so a swap that over-delivers closes the debt instead of overpaying.

const result = await wrappedAccount.makeRepayWithCollatTx({
  connection,
  withdrawOpts: {
    totalPositionAmount: collateralUi,
    withdrawAmount: 0.25,
    withdrawBank: collateralBank,
    tokenProgram: collateralMintData.tokenProgram,
  },
  repayOpts: {
    repayBank: debtBank,
    tokenProgram: debtMintData.tokenProgram,
    totalPositionAmount: debtUi,
  },
  swapOpts: { swapConfig },
  assetShareValueMultiplierByBank: client.assetShareValueMultiplierByBank,
});

console.log(`repaying ~${result.amountToRepay} ${debtBank.tokenSymbol}`);

Returns { transactions, swapQuote, amountToRepay, mustBeAtomicBundle }. amountToRepay is derived from the swap's guaranteed min-out (otherAmountThreshold) and clamped to totalPositionAmount; when the expected output exceeds the debt the builder repays the full position instead. If collateral and debt share a mint no swap is run and amountToRepay === withdrawAmount. There is no bridged variant of this builder.

Shared options

All four swap-backed builders accept:

OptionDescription
swapOpts{ swapConfig } (or a pinned { swapIxs }) — see Looping
swapEngineRunnerOverride or wrap runSwapEngine to inspect/veto the chosen route — see Controlling the route
additionalIxsExtra instructions spliced into the flash loan
crossbarUrlSwitchboard crossbar endpoint override for oracle cranks
overrideInferAccounts{ group?, authority? }
assetShareValueMultiplierByBankRequired on the direct builders (client.assetShareValueMultiplierByBank); injected by the makeBridged* builders

Address lookup tables are always taken from client.addressLookupTables. Results are simulated and sent exactly like a loop — see Simulate and send and Transactions for bundles.

Handling errors

The direct builders throw SWAP_SIZE_EXCEEDED_POSITION_SWAP (or SWAP_SIZE_EXCEEDED_REPAY for repay-with-collateral) when the route does not fit the transaction and SWAP_QUOTE_FAILED when it cannot be quoted; isDecomposableSwapError(e) matches both, and retrying with the makeBridged* builder is the usual answer. The bridged builders throw BRIDGE_CONFLICT when every bridge candidate is blocked by an opposite-side position:

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

try {
  result = await wrappedAccount.makeBridgedSwapCollateralTx(params);
} catch (e) {
  if (isBridgeConflictError(e)) {
    const blocked = e.details.conflictingBanks.map((b) => b.symbol ?? b.mint).join(", ");
    throw new Error(`Swap must route through ${blocked}, but the account already borrows them`);
  }
  throw e;
}

The full code table is on Error Handling.

Exponent PT rollover

makeRollPtTx rolls a matured Exponent PT collateral position into its successor maturity so the full deposit ends up as the new PT: withdraw the old PT, redeem it to SY via Exponent merge, buy the new PT on the successor's CLMM pool, deposit. No external aggregator is involved, so there is no swapOpts; the Exponent accounts are resolved internally from rollOpts:

const result = await wrappedAccount.makeRollPtTx({
  connection,
  withdrawOpts: {
    totalPositionAmount: maturedPtUi,
    withdrawAmount: maturedPtUi, // optional, defaults to the full position
    withdrawBank: maturedPtBank,
    tokenProgram: maturedMintData.tokenProgram,
  },
  depositOpts: {
    depositBank: successorPtBank,
    tokenProgram: successorMintData.tokenProgram,
  },
  rollOpts: {
    maturedMarket: MATURED_MARKET, // or maturedVault — one of the two is required
    successorMarket: SUCCESSOR_MARKET, // the successor CLMM (MarketThree) pool
    slippageBps: 100, // default 50
    // lookupTable?: PublicKey — dedicated PT-roll LUT, see examples/create-pt-roll-lut.ts
  },
  assetShareValueMultiplierByBank: client.assetShareValueMultiplierByBank,
});
// { transactions, actionTxIndex, quoteResponse }

RollPtOpts also accepts syTokenProgram (defaults to the classic Token program). The SY→PT price is quoted by simulating a standalone trade_pt through connection.simulateTransaction; pass simulateTx (a RollQuoteSimulator) if your RPC proxy blocks that call. quoteResponse.otherAmountThreshold is the minimum new PT the deposit is sized to. The complete flow is in examples/15-roll-pt.ts.

On this page