Error Handling
Recognise and recover from SDK build errors, bundle simulation failures and the program errors you will actually meet.
Errors show up at three points: while the SDK builds a transaction (typed TransactionBuildingError), while you simulate it (BundleSimulationError or a failed simulation result), and when the program rejects it on-chain (Anchor error codes in the logs). This page covers how to tell them apart and what to do next.
This page assumes you have a client and wrappedAccount from Getting Started.
TransactionBuildingError
Every make*Tx builder throws TransactionBuildingError when it cannot produce a valid transaction for a reason it understands. The class carries a machine-readable code and typed details:
class TransactionBuildingError<T extends TransactionBuildingErrorCode> extends Error {
readonly name: "TransactionBuildingError";
readonly code: T; // e.g. "ORACLE_CRANK_FAILED"
readonly details: TransactionBuildingErrorDetails[T]; // shape depends on code
}error.message is diagnostic text for logs, not user-facing copy. Map
error.code to your own strings and use details to fill in bank symbols
or amounts.
Error codes
| Code | When it happens | details |
|---|---|---|
SWAP_SIZE_EXCEEDED_LOOP | makeLoopTx: the swap route does not fit the flash-loan transaction (bytes or account keys). | { bytes, accountKeys, provider? } |
SWAP_SIZE_EXCEEDED_REPAY | makeRepayWithCollatTx: same, for the repay-with-collateral flash loan. | { bytes, accountKeys, provider? } |
SWAP_SIZE_EXCEEDED_POSITION_SWAP | makeSwapCollateralTx / makeSwapDebtTx / makeRollPtTx: same, for a position swap. | { bytes, accountKeys, provider? } |
SWAP_QUOTE_FAILED | No swap provider (including fallbackProviders) returned a usable quote for the pair. | { provider, inputMint, outputMint, reason } |
ORACLE_CRANK_FAILED | A borrow/withdraw/flash-loan needs fresh Switchboard prices for one of the account's banks and the SDK could not build a crank for them. | { uncrankableLiabilities: [{ bankAddress, mint, symbol?, reason }], uncrankableAssets: [...] } |
SWITCHBOARD_FEED_UPDATE_FAILED | Reserved for Switchboard feed-update failures (not thrown by the current builders; cranks surface as ORACLE_CRANK_FAILED). | { oracleKeys, reason } |
KAMINO_RESERVE_NOT_FOUND | An action touches a Kamino bank but client.bankIntegrationMap has no kaminoStates.reserveState for it (client built without integration data, or stale). | { bankAddress, bankMint, bankSymbol? } |
DRIFT_STATE_NOT_FOUND | Same for a Drift bank (driftStates missing). | { bankAddress, bankMint, bankSymbol? } |
JUPLEND_STATE_NOT_FOUND | Same for a Jupiter Lend bank (jupLendStates missing). | { bankAddress, bankMint, bankSymbol? } |
TRANSFER_POSITIONS_INVALID_SELECTION | makeTransferPositionsTx: the selected banks are not valid to move (inactive on the source, overlap/slot conflict on the destination, group or authority mismatch). | { reason, bankAddresses } |
TRANSFER_POSITIONS_UNSUPPORTED_BANK | makeTransferPositionsTx: a selected bank's asset tag is not supported (v1 moves DEFAULT and STAKED only). | { bankAddress, assetTag, bankSymbol? } |
TRANSFER_POSITIONS_UNSPLITTABLE | makeTransferPositionsTx: even at the position cap the transfer does not fit one v0 transaction. Retry with fewer positions. | { reason, sizeBytes?, accountCount? } |
BRIDGE_CONFLICT | A makeBridged*Tx builder could not pick a bridge token: every candidate bank already holds an opposite-side position on the account. | { conflictingBanks: [{ bankAddress, mint, symbol? }], bridgeTokenSide: "deposit" | "borrow" } |
Predicates
Two helpers classify the errors you can recover from automatically:
isDecomposableSwapError(e)— true forSWAP_SIZE_EXCEEDED_*andSWAP_QUOTE_FAILED. The direct single-route swap failed, but the same action can usually succeed as a two-leg swap through a bridge token. Retry with the bridged builder (makeBridgedLoopTx,makeBridgedSwapCollateralTx,makeBridgedSwapDebtTx).isBridgeConflictError(e)— true forBRIDGE_CONFLICT, narrowed to its typed details. The bridged builder also failed because every bridge candidate conflicts with an existing position. Either exclude those banks by passing your ownbridgeOpts.bridgeCandidateMints, or tell the user to close the conflicting position / pick a different pair.
import { isDecomposableSwapError, isBridgeConflictError } from "@0dotxyz/p0-ts-sdk";
try {
result = await wrappedAccount.makeLoopTx(params);
} catch (e) {
if (!isDecomposableSwapError(e)) throw e;
try {
result = await wrappedAccount.makeBridgedLoopTx(params);
} catch (bridged) {
if (isBridgeConflictError(bridged)) {
const blocked = bridged.details.conflictingBanks.map((b) => b.symbol ?? b.mint);
throw new Error(`Close your ${bridged.details.bridgeTokenSide} position in ${blocked.join(", ")} first`);
}
throw bridged;
}
}The makeBridged*Tx builders already attempt the direct route first and only fall back to a bridge when the direct build throws a decomposable error, so calling them up front gives you the same behaviour in one call. The full flow is on Looping.
A catch-and-switch pattern
import {
TransactionBuildingError,
TransactionBuildingErrorCode,
type TransactionBuildingErrorDetails,
} from "@0dotxyz/p0-ts-sdk";
function describeBuildError(e: unknown): string {
if (!(e instanceof TransactionBuildingError)) {
return "Something went wrong while preparing the transaction.";
}
switch (e.code) {
case TransactionBuildingErrorCode.ORACLE_CRANK_FAILED: {
const d = e.details as TransactionBuildingErrorDetails[TransactionBuildingErrorCode.ORACLE_CRANK_FAILED];
const banks = [...d.uncrankableLiabilities, ...d.uncrankableAssets].map((b) => b.symbol ?? b.mint);
return `Price feeds for ${banks.join(", ")} could not be refreshed. Try again in a moment.`;
}
case TransactionBuildingErrorCode.SWAP_QUOTE_FAILED:
return "No swap route is available for this pair right now.";
case TransactionBuildingErrorCode.SWAP_SIZE_EXCEEDED_LOOP:
case TransactionBuildingErrorCode.SWAP_SIZE_EXCEEDED_REPAY:
case TransactionBuildingErrorCode.SWAP_SIZE_EXCEEDED_POSITION_SWAP:
return "The swap route is too large for a single transaction. Try a smaller amount.";
case TransactionBuildingErrorCode.KAMINO_RESERVE_NOT_FOUND:
case TransactionBuildingErrorCode.DRIFT_STATE_NOT_FOUND:
case TransactionBuildingErrorCode.JUPLEND_STATE_NOT_FOUND:
return "Integration data for this market is not loaded. Refresh and try again.";
case TransactionBuildingErrorCode.BRIDGE_CONFLICT:
return "This swap needs a bridge asset you already hold on the other side of your account.";
case TransactionBuildingErrorCode.TRANSFER_POSITIONS_INVALID_SELECTION:
case TransactionBuildingErrorCode.TRANSFER_POSITIONS_UNSUPPORTED_BANK:
case TransactionBuildingErrorCode.TRANSFER_POSITIONS_UNSPLITTABLE:
return "These positions cannot be transferred together. Select fewer positions.";
default:
return "Could not prepare the transaction.";
}
}TransactionBuildingErrorDetails is the exported map from code to details type. e.details is a union across all codes, so narrow it with a cast (as above) or use the predicates, which return the narrowed generic.
BundleSimulationError and HealthCacheSimulationError
simulateBundle(rpcEndpoint, transactions, includeAccounts?) throws when the request itself fails — empty input, a transaction that cannot be encoded, an HTTP error, or an RPC that does not support simulateBundle (the underlying SolanaJSONRPCError is rethrown as-is). The error has name === "BundleSimulationError", plus optional logs and cause. The class itself is not exported, so match on name:
try {
const results = await simulateBundle(connection.rpcEndpoint, result.transactions);
results.forEach((r, i) => {
if (r.err) console.error(`tx ${i} failed`, r.err, r.logs);
});
} catch (e) {
if (e instanceof Error && e.name === "BundleSimulationError") {
console.error("Bundle simulation unavailable:", e.message);
}
throw e;
}A successful call can still return entries whose err is set — that is the program rejecting the transaction in simulation, and the logs contain the Anchor error (see below).
HealthCacheSimulationError (exported) is what simulateHealthCache() / simulateAccountHealthCacheWithFallback return in their error field when the on-chain health simulation failed and the SDK fell back to a local computation. It carries mrgnErr and internalErr (program error numbers or null). It is returned, not thrown — see Health, Max Amounts & E-mode.
Program errors worth recognizing
When a transaction fails in simulation or on-chain, the logs contain Error Code: <Name>. Error Number: <code>. The SDK exports the IDL, so you can resolve a number to a name with MARGINFI_IDL.errors.find((e) => e.code === n). The ones you will see most:
| Code | Name | Meaning and fix |
|---|---|---|
6009 | RiskEngineInitRejected | The action would leave Initial health negative, or an oracle used in the health check is stale. Re-check computeMaxBorrowForBank / computeMaxWithdrawForBank and retry. |
6003 / 6027 | BankAssetCapacityExceeded / BankLiabilityCapacityExceeded | Deposit or borrow cap hit. computeMaxDepositForBank / computeMaxBorrowForBank already clamp to the caps. |
6016 | BankPaused | Bank is paused. Check bank.config.operationalState. |
6017 | BankReduceOnly | Bank is in reduce-only mode: deposits and borrows are rejected, withdrawals and repays still work. Filter with isStandardDepositable / isStandardBorrowable. |
6029 | IsolatedAccountIllegalState | An account with isolated-tier debt can hold only that one liability. computeMaxBorrowForBank returns 0 in this case. |
6115–6118 | Bank/GroupHourly/DailyRateLimitExceeded | Net-outflow rate limiter hit. computeMax*ForBank clamp to it unless ignoreBankLimits was passed; see Caps & Rate Limits. |
6200 | WrongAssetTagForStandardInstructions | A plain deposit/withdraw/borrow/repay was sent to a Kamino, Drift or Jupiter Lend bank. Use the integration builders from Integrations. |
6600 | BankCircuitBreakerHalted | The bank is halted by its oracle circuit breaker (OperationalState.CircuitBroken); user actions are rejected until it is cleared. See Banks & Prices. |
| — | AccountNotEnoughKeys (Anchor, on group) | An instruction built for program 0.1.9 hit the 0.1.10 program (or vice versa), typically end_flashloan or pulse_health. Upgrade to SDK >= 2.7.0; see Program Upgrade troubleshooting. |
import { MARGINFI_IDL } from "@0dotxyz/p0-ts-sdk";
function programErrorName(logs: string[]): string | undefined {
const match = logs.join("\n").match(/Error Number: (\d+)/);
if (!match) return undefined;
const code = Number(match[1]);
return MARGINFI_IDL.errors.find((e) => e.code === code)?.name;
}Simulate before you send
Build errors are cheap; on-chain failures cost a fee and a confirmation round-trip. Simulate every result before signing — connection.simulateTransaction for a single transaction, simulateBundle for a TransactionBuilderResult with several — and treat a non-null err as a program error using the table above. Simulation, bundles and fee settings are covered on Transactions.
Related
- Transactions —
simulateBundle, oracle cranking, bundles - Looping — the direct-then-bridged retry that uses
isDecomposableSwapError - Health, Max Amounts & E-mode — avoid
RiskEngineInitRejectedbefore it happens - Program Upgrade —
AccountNotEnoughKeysand the 0.1.10 flip - Examples:
16a-loop.ts(isDecomposableSwapError),10-swap-collateral.ts(isBridgeConflictError),02-borrow.ts(bundle simulation with error logs)