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.
| Builder | What it does | Shape inside the flash loan |
|---|---|---|
makeSwapCollateralTx / makeBridgedSwapCollateralTx | Replace collateral A with collateral B | withdraw A → swap A→B → deposit B |
makeSwapDebtTx / makeBridgedSwapDebtTx | Replace debt A with debt B | borrow B → swap B→A → repay A |
makeRepayWithCollatTx | Pay down debt using collateral | withdraw A → swap A→B → repay B |
makeRollPtTx | Roll a matured Exponent PT into the next maturity | withdraw 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:
| Option | Description |
|---|---|
swapOpts | { swapConfig } (or a pinned { swapIxs }) — see Looping |
swapEngineRunner | Override or wrap runSwapEngine to inspect/veto the chosen route — see Controlling the route |
additionalIxs | Extra instructions spliced into the flash loan |
crossbarUrl | Switchboard crossbar endpoint override for oracle cranks |
overrideInferAccounts | { group?, authority? } |
assetShareValueMultiplierByBank | Required 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.
Related
- Looping —
SwapProviderConfig, swap-engine routing and pinned routes - Flash Loans — the primitive these builders are built on
- Error Handling
- Managing Your Account guide
- Examples: 10-swap-collateral.ts, 11-swap-debt.ts, 15-roll-pt.ts
Looping
Build a leveraged position in one flash-loan transaction with makeLoopTx and makeBridgedLoopTx, configure the swap engine, and handle routing failures.
Flash Loans
How marginfi flash loans work, how the SDK uses them under the hood, and how to wrap your own instruction sequence with makeFlashLoanTx.