TypeScript SDK

Transactions

Result types, ordering and atomicity, simulation, priority fees, oracle cranking, integration refreshes, lookup tables and size limits.

Every make*Tx builder — whether you call it on MarginfiAccountWrapper or as a free Layer 3 function — returns fully built transactions and leaves signing and sending to you. This page is the single reference for what those results contain, why there is sometimes more than one transaction, and the helpers the builders use under the hood in case you need them yourself. It assumes you have a client and wrappedAccount from Getting Started; for the canonical sign-and-send loop see Deposit, Withdraw, Borrow & Repay.

Result types

Transactions with metadata

Builders return plain web3.js transactions with a few extra fields attached (addTransactionMetadata does Object.assign):

type ExtendedTransactionProperties = {
  type: TransactionType; // DEPOSIT, BORROW, CRANK, LOOP, FLASHLOAN, ...
  signers?: Signer[]; // extra keypairs the SDK generated (e.g. temp wSOL account)
  addressLookupTables?: AddressLookupTableAccount[];
  unitsConsumed?: number;
};

type ExtendedTransaction = Transaction & ExtendedTransactionProperties; // legacy
type ExtendedV0Transaction = VersionedTransaction & ExtendedTransactionProperties;
type SolanaTransaction = ExtendedTransaction | ExtendedV0Transaction;

makeDepositTx and makeRepayTx return an ExtendedTransaction (legacy, feePayer set, no blockhash — set recentBlockhash before signing). makeCreateMarginfiAccountTx is legacy too; every other builder returns ExtendedV0Transactions that already carry a blockhash and are compiled against lookup tables. Always pass tx.signers along when you sign.

TransactionType is an enum (DEPOSIT, WITHDRAW, BORROW, REPAY, CRANK, FLASHLOAN, LOOP, REPAY_COLLAT, DEPOSIT_STAKE, ...) and TransactionConfigMap[type].label(info?) gives a human-readable label if you want to show progress in a UI.

Builder results

Prop

Type

TransactionBuilderResult (above) is what makeBorrowTx, makeWithdrawTx, makeKaminoWithdrawTx, makeDriftWithdrawTx and makeJuplendWithdrawTx return. Flash-loan based flows add fields:

Result typeReturned byExtra fields
TransactionBuilderResultmakeBorrowTx, makeWithdrawTx, integration withdraws
FlashloanActionResulttype only; declared for flash-loan flowstxOverflown: boolean — in practice current builders throw TransactionBuildingError (SWAP_SIZE_EXCEEDED_*) instead of returning an oversized transaction, so handle the error path (Error Handling)
Loop / swap resultsmakeLoopTx, makeSwapCollateralTx, makeSwapDebtTx, makeRollPtTxquoteResponse?: SwapQuoteResult, mustBeAtomicBundle: boolean
makeRepayWithCollatTx resultmakeRepayWithCollatTxswapQuote, amountToRepay, mustBeAtomicBundle (no actionTxIndex)
BridgedTxResultmakeBridgedLoopTx, makeBridgedSwapCollateralTx, makeBridgedSwapDebtTxquoteResponse, bridgeMint?: PublicKey (set only when the double-hop path was used), mustBeAtomicBundle
TransferPositionsResultmakeTransferPositionsTxdestinationAccount: MarginfiAccountType, mustBeAtomicBundle
BulkLendTxsResultmakeBulkWithdrawTx, makeBulkRepayTxmustBeAtomicBundle

Ordering and atomicity

Send result.transactions in order and confirm each one before the next. The transaction at actionTxIndex is the one whose signature you should surface to the user; a crank transaction that lands without the action is harmless (it only refreshes prices).

When a result carries mustBeAtomicBundle, use it to decide how to send:

  • false — sequential sends are safe. Cranked Switchboard prices stay valid for at least about a minute, so a short gap between crank and action is fine.
  • true — the transactions must land together as one atomic Jito bundle. This is set when the action transaction contains integration refreshes (Kamino/Drift/Jupiter Lend state goes stale within a slot) or when a bridged double-hop swap has to land as one operation. Sending these sequentially can leave you with a half-executed position.
import { VersionedTransaction } from "@solana/web3.js";

const result = await wrappedAccount.makeBorrowTx(bankAddress, "100");

for (const tx of result.transactions) {
  if (!(tx instanceof VersionedTransaction)) {
    tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
  }
  const sig = await wallet.sendTransaction(tx, connection, { signers: tx.signers });
  await connection.confirmTransaction(sig, "confirmed");
}

Simulation

connection.simulateTransaction(tx) only sees one transaction at a time, so a borrow that depends on a preceding oracle crank will fail simulation in isolation. Use simulateBundle to simulate the whole set against an RPC that supports the simulateBundle method (Jito block engines and Triton-backed endpoints do; plain public RPCs usually do not):

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

const results = await simulateBundle(
  connection.rpcEndpoint,
  result.transactions,
  [wrappedAccount.address] // optional: accounts to return post-execution (last tx only)
);

results.forEach((r, i) => {
  if (r.err) console.error(`tx ${i} failed`, r.err, r.logs);
  else console.log(`tx ${i} ok, CU: ${r.unitsConsumed}`);
});

simulateBundle(rpcEndpoint, transactions, includeAccounts?) sends a simulateBundle JSON-RPC call with skipSigVerify and replaceRecentBlockhash enabled, so you can simulate unsigned transactions. It returns one { err?, logs, unitsConsumed?, postExecutionAccounts? } entry per transaction. If the bundle as a whole fails, or the RPC rejects the call, it throws a BundleSimulationError (name === "BundleSimulationError", with a logs array when available); the class itself is not exported, so match on error.name.

Priority fees

Simple builders (makeDepositTx, makeBorrowTx, makeWithdrawTx, makeRepayTx) do not add compute-budget instructions. The flash-loan flows (loop, swaps, repay-with-collateral, roll PT, transfer positions) already prepend setComputeUnitLimit(1_200_000) and a 1 micro-lamport setComputeUnitPrice; you should not add a second pair.

The helpers live in src/services/transaction/helpers/priority.ts:

HelperUse
makePriorityFeeMicroIx(priorityFeeMicro?)Preferred. setComputeUnitPrice in micro-lamports per CU (default 1).
makePriorityFeeIx(priorityFeeUi?, computeUnitsLimit?)Deprecated. Takes a fee in SOL and converts it to micro-lamports; fees above 0.1 SOL are discarded.
makeTxPriorityIx(feePayer, feeUi, "BUNDLE" | "RPC" | "DYNAMIC")Deprecated. Returns { bundleTipIx?, priorityFeeIx }.
makeBundleTipIx(feePayer, bundleTip = 100_000)Jito tip transfer (lamports) for bundles.

Because builder transactions are already compiled, the clean place to add a priority fee is when you build the transaction yourself from Layer 2 instructions:

import { makePriorityFeeMicroIx } from "@0dotxyz/p0-ts-sdk";
import { ComputeBudgetProgram } from "@solana/web3.js";

const ixs = await wrappedAccount.makeDepositIx(bankAddress, "250");
const tx = new Transaction().add(
  ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }),
  makePriorityFeeMicroIx(50_000), // 50k micro-lamports per CU
  ...ixs.instructions
);

For a compiled ExtendedV0Transaction you can decompileV0Transaction(tx, tx.addressLookupTables ?? []), prepend the fee instruction to message.instructions, and recompile with compileToV0Message(tx.addressLookupTables). The constants PRIORITY_TX_SIZE (44 bytes) and BUNDLE_TX_SIZE (81 bytes) are the size budgets to reserve for a priority-fee or tip instruction appended at send time.

Oracle cranking

Pyth feeds are pushed externally and need no cranking. Most Project 0 banks use Switchboard on-demand pull feeds, which only update when someone submits a feed-update instruction. Actions that can reduce account health — borrow, withdraw, liquidate — run the on-chain health check against the feed's last price, so a stale feed makes them fail with RiskEngineInitRejected.

The builders handle this with makeSmartCrankSwbFeedIx. It projects the account's balances after your instructions, then picks the minimum set of feeds to update:

  1. every liability bank's oracle is always cranked;
  2. collateral oracles are cranked only until the projected health covers the liabilities — not every deposit;
  3. when the group rate limiter is on, every withdrawn bank's oracle is cranked because the program requires a fresh price for any withdraw.

If a required feed cannot be updated (no Crossbar response, feed misconfigured) the builder throws TransactionBuildingError with code ORACLE_CRANK_FAILED listing the uncrankable banks.

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

const { instructions: crankIxs, luts: feedLuts } = await makeSmartCrankSwbFeedIx({
  marginfiAccount: wrappedAccount.getUnderlyingAccount(),
  bankMap: client.bankMap,
  oraclePrices: client.oraclePriceByBank,
  assetShareValueMultiplierByBank: client.assetShareValueMultiplierByBank,
  instructions: borrowIxs.instructions, // the action you are about to send
  program: client.program,
  connection,
  crossbarUrl: "https://crossbar.0.xyz", // optional
  groupRateLimiterEnabled: isGroupRateLimiterEnabled(client.group.rateLimiter),
});

The crank instructions go into their own transaction (type: TransactionType.CRANK, compiled with feedLuts) that precedes the action transaction; when no feed is stale crankIxs is empty and the result has a single transaction. makeSmartCrankSwbFeedIxForAccounts({ marginfiAccounts, ... }) is the multi-account variant used by transfer-positions flows.

crossbarUrl selects the Switchboard Crossbar gateway that serves the signed price updates; the Layer 3 builders accept it as an optional parameter and fall back to DEFAULT_CROSSBAR_URL (https://crossbar.0.xyz) and then DEFAULT_FALLBACK_CROSSBAR_URL (https://crossbar.switchboard.xyz). Lower-level pieces — makeUpdateSwbFeedIx({ swbPullOracles, feePayer, connection, crossbarUrl? }) and computeSmartCrank(...) — are exported too. See Oracles for the protocol side.

Integration refreshes

Banks backed by an external venue need that venue's state to be current in the same transaction as your action, which is why these instructions are prepended to the action transaction rather than sent separately (and why such results set mustBeAtomicBundle). The builders call:

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

const refreshIxs = makeRefreshIntegrationBanksIxs(
  wrappedAccount.getUnderlyingAccount(),
  client.bankMap,
  [bank.address], // banks to exclude from Drift/JupLend updates (their CPI self-updates) and to include in the Kamino refresh
  client.bankIntegrationMap
);
// refreshIxs.instructions, ordered kamino -> drift -> juplend

It composes three helpers you can also call individually, all with the signature (marginfiAccount, bankMap, banks: PublicKey[], bankMetadataMap) and returning an InstructionsWrapper:

HelperWhat it emitsNote
makeRefreshKaminoBanksIxsKamino refresh_reserve + refresh_obligation for every Kamino bank the account holds plus the banks you passKamino has no self-refreshing CPI, so the action bank must be included.
makeUpdateDriftMarketIxsDrift spot-market update for held Drift banks, minus the banks you passDrift deposit/withdraw update their own market via CPI.
makeUpdateJupLendRateIxsJupiter Lend update_rate for held JupLend banks, minus the banks you passjuplend_deposit / juplend_withdraw call updateRate internally.

The per-bank states these read (kaminoStates, driftStates, jupLendStates) come from client.bankIntegrationMap; see Integration Banks & Native Stake.

Address lookup tables

Without LUTs every account costs 32 bytes and a transaction fits roughly 35 accounts; with LUTs accounts are 1-byte indexes, which is what makes health checks over many banks fit. client.addressLookupTables is the combined set the client loaded at initialize(): the general group tables from ADDRESS_LOOKUP_TABLE_FOR_GROUP[groupPk] followed by the native-stake tables from ADDRESS_LOOKUP_TABLE_FOR_GROUP_NATIVE_STAKE[groupPk] (both exported from src/constants/luts.consts.ts). Switchboard feed LUTs are fetched per crank and returned as feedLuts; swap builders add the aggregator's tables from the quote.

The builders pass the whole array and let two helpers pick the subset to compile with:

  • selectLutsForBanks(luts, banks) — returns only the native-stake tables when every bank is STAKED or SOL, otherwise the general tables.
  • selectLutsForAccountAction(luts, targetBank, balances, bankMap, extraBankAddresses?) — same, but collects the target bank, the account's active-position banks and any extra health-check banks first.

To build your own v0 transaction from instructions:

import {
  makeVersionedTransaction,
  splitInstructionsToFitTransactions,
  selectLutsForAccountAction,
  getTxSize,
} from "@0dotxyz/p0-ts-sdk";

const luts = selectLutsForAccountAction(
  client.addressLookupTables,
  bank,
  wrappedAccount.balances,
  client.bankMap
);
const { blockhash } = await connection.getLatestBlockhash();

// One transaction from a legacy Transaction's instructions
const v0 = await makeVersionedTransaction(blockhash, legacyTx, wallet.publicKey, luts);
console.log(getTxSize(v0)); // bytes

// Or split a long instruction list across as many transactions as needed
const txs = splitInstructionsToFitTransactions(
  [computeBudgetIx], // mandatory ixs repeated in every transaction
  actionIxs,
  { blockhash, payerKey: wallet.publicKey, luts, sizeMargin: 44, maxAccountLocks: 64 }
);

splitInstructionsToFitTransactions throws "Single instruction too large to fit in a transaction" if one instruction alone exceeds the limit. isV0Tx(tx), isFlashloan(tx), decompileV0Transaction(tx, luts) and addTransactionMetadata(tx, props) are exported alongside.

Transaction size limits

From src/constants/transaction.consts.ts:

ConstantValueMeaning
MAX_TX_SIZE1232Maximum serialized transaction size in bytes.
MAX_ACCOUNT_LOCKS64Maximum distinct accounts (static + LUT-resolved) a transaction may lock.
PRIORITY_TX_SIZE44Bytes to reserve for a priority-fee instruction added at send time.
BUNDLE_TX_SIZE81Bytes to reserve for a Jito tip instruction added at send time.

getTxSize(tx) returns the byte size of a legacy or versioned transaction (and logs a warning with a breakdown when a v0 transaction exceeds 1232); getTotalAccountKeys(tx) and getWritableAccountKeys(tx) count locks. Flash-loan flows size their swap leg against these limits before building; when a swap route cannot fit they throw TransactionBuildingError (SWAP_SIZE_EXCEEDED_LOOP / _REPAY / _POSITION_SWAP) rather than returning a transaction with txOverflown set — try a bridged variant or a route with fewer hops (see Looping and Error Handling).

On this page