Market Limits & Isolated Assets
Read the protocol-level caps that gate every supply/borrow op, independent of obligation health.
Overview
Scallop enforces two layers of constraints before a state-changing op succeeds:
- Per-obligation health — collateral factor × USD value of collateral must cover the new debt. Documented in obligation-manager.
- Per-asset market caps — global ceilings the protocol places on each pool, plus the "isolated asset" flag on certain collaterals. This is the layer this skill covers.
If a supply_quick/borrow_quick reverts even though the obligation looks healthy and there's plenty of liquidity, the cause is almost always one of these caps.
Borrow & Supply Limits
Each asset has independent borrow_limit and supply_limit values stored on-chain. The TS SDK (verified against @scallop-io/sui-scallop-sdk v4.3.0) exposes them two ways:
- Normalized, on
MarketPool(preferred):getMarketPool(coinName)returnsmaxSupplyCoin/maxBorrowCoinalongside the currentsupplyCoin/borrowCoin— allnumbers already shifted to human coin units, safe for plain arithmetic. See src/repositories/market/utils.ts. - Raw, as strings:
getPoolSupplyLimit/getPoolBorrowLimitreturn the cap as a decimal string in the asset's base units. These can exceedNumber.MAX_SAFE_INTEGER— useBigNumber(orBigInt, the values are integers) if you do math on them; don't cast tonumber.
Imports: v4.3.0 exports the package root plus the
./client,./query,./builder,./types,./errors, and./loggersubpaths. Internal deep paths (e.g..../src/repositories/...) are not exported — go throughscallop.client.query.*or the public subpath entries.
const scallopSDK = new Scallop({ networkType: 'mainnet' });
await scallopSDK.init();
const { query, builder } = scallopSDK.client;
// Preferred: normalized coin-unit fields on the market pool
const pool = await query.getMarketPool('usdc');
// pool.maxSupplyCoin / pool.maxBorrowCoin -> caps in coin units (0 = no cap set)
// pool.supplyCoin / pool.borrowCoin -> current usage in coin units
// Raw base-unit strings (never null — '0' when the field is missing/unparseable)
const borrowCapRaw = await query.getPoolBorrowLimit('usdc'); // Promise<string>
const supplyCapRaw = await query.getPoolSupplyLimit('usdc'); // Promise<string>
Both raw wrappers read the on-chain BorrowLimitKey / SupplyLimitKey dynamic field on the market object — see getSupplyLimit / getBorrowLimit in src/repositories/market/helpers.ts, surfaced through src/models/scallopQuery/index.ts.
The Python SDK (sui-scallop-sdk >= 0.3.0a1) exposes the same reads: query.get_supply_limit(coin_name) / query.get_borrow_limit(coin_name) return Decimal in human-readable coin units (0 when unavailable), plus batched get_supply_limits() / get_borrow_limits().
Pattern: Cap-Aware Supply
async function safeSupply(
scallopSDK: Scallop,
coinName: string,
amountInBaseUnits: number
) {
const { query, builder } = scallopSDK.client;
// suiKit is a getter on ScallopClient (client.suiKit), not on Scallop itself.
const sender = scallopSDK.client.suiKit.currentAddress;
// Compare in normalized coin units — maxSupplyCoin / supplyCoin are already
// decimal-shifted numbers, so no BigInt/precision pitfalls.
const pool = await query.getMarketPool(coinName);
if (!pool) throw new Error(`Unknown pool ${coinName}`);
const cap = pool.maxSupplyCoin; // 0 = no cap configured
const headroom = Math.max(cap - pool.supplyCoin, 0);
const amountInCoin = amountInBaseUnits / 10 ** pool.coinDecimal;
if (cap > 0 && amountInCoin > headroom) {
throw new Error(
`Supply ${amountInCoin} ${coinName} exceeds cap headroom ${headroom}`
);
}
const tx = builder.createTxBlock();
// supplyQuick returns an sCoin by default (returnSCoin = true).
const sCoin = await tx.supplyQuick(amountInBaseUnits, coinName);
tx.transferObjects([sCoin], sender);
return builder.signAndSendTxBlock(tx);
}
Pattern: Cap-Aware Borrow
async function safeBorrow(
scallopSDK: Scallop,
obligationId: string,
obligationKey: string,
coinName: string,
amountInBaseUnits: number
) {
const { query, builder } = scallopSDK.client;
const sender = scallopSDK.client.suiKit.currentAddress;
const pool = await query.getMarketPool(coinName);
if (!pool) throw new Error(`Unknown pool ${coinName}`);
const cap = pool.maxBorrowCoin; // coin units; 0 = no cap
const headroom = Math.max(cap - pool.borrowCoin, 0);
const amountInCoin = amountInBaseUnits / 10 ** pool.coinDecimal;
if (cap > 0 && amountInCoin > headroom) {
throw new Error(
`Borrow ${amountInCoin} ${coinName} exceeds cap headroom ${headroom}`
);
}
const tx = builder.createTxBlock();
const coin = await tx.borrowQuick(amountInBaseUnits, coinName, obligationId, obligationKey);
tx.transferObjects([coin], sender);
return builder.signAndSendTxBlock(tx);
}
Isolated Assets
An isolated asset can only be used as collateral in an obligation that has no other collateral. This protects the protocol from cross-correlated risk on long-tail assets — but it means a strategy that tries to top up an existing multi-collateral obligation with an isolated coin will revert.
const allIsolated = await query.getIsolatedAssets();
const flag = await query.isIsolatedAsset('musd');
The SDK-side implementation lives in src/repositories/isolatedAssets/; the on-chain flag is the IsolatedAssetKey dynamic field on the market object. Python equivalents (sui-scallop-sdk >= 0.3.0a1): query.get_isolated_assets() / query.is_isolated_asset(coin_name). The MarketPool object also carries an isIsolated boolean.
Pattern: Validate Before Depositing Collateral
async function addCollateralChecked(
scallopSDK: Scallop,
obligationId: string,
coinName: string,
amount: number
) {
const { query, builder } = scallopSDK.client;
const sender = scallopSDK.client.suiKit.currentAddress;
if (await query.isIsolatedAsset(coinName)) {
const account = await query.getObligationAccount(obligationId);
const collaterals = Object.values(account?.collaterals ?? {}).filter(Boolean);
if (collaterals.length > 0) {
throw new Error(
`${coinName} is an isolated asset; obligation already has collateral`
);
}
}
const tx = builder.createTxBlock();
await tx.depositCollateralQuick(amount, coinName, obligationId);
return builder.signAndSendTxBlock(tx);
}
The TS builder method for adding collateral is
depositCollateralQuick— it mirrors the protocol'sdeposit_collateralMove call. The pre-v4 namesdepositQuick/addCollateralQuickwere removed in SDK v4; usesupplyQuick/depositCollateralQuick.
When to Open a New Obligation
If you want exposure to an isolated asset alongside existing positions, open a separate obligation for it:
// v4.3.0 returns the new SuiTransactionBlockResponse union — there is no
// `objectChanges` field. Pull created ids from effects.changedObjects and
// resolve their types via objectTypes:
const result = await scallopSDK.client.openObligation();
if (result.$kind !== 'Transaction') throw new Error('tx failed');
const created = (result.Transaction.effects?.changedObjects ?? []).filter(
(o) => o.idOperation === 'Created'
);
const types = result.Transaction.objectTypes;
const obligationId = created.find((o) =>
types?.[o.objectId]?.endsWith('::obligation::Obligation')
)?.objectId;
const obligationKeyId = created.find((o) =>
types?.[o.objectId]?.endsWith('::obligation::ObligationKey')
)?.objectId;
// ...then call depositCollateralQuick against the new obligation.
See obligation-manager for the obligation lifecycle.
Composite Health Check
Combine cap + isolation + health into a single pre-flight check used by liquidator/strategist code:
type Preflight =
| { ok: true }
| { ok: false; reason: 'cap' | 'isolated' | 'health'; detail: string };
async function preflightBorrow(
scallopSDK: Scallop,
obligationId: string,
coinName: string,
amountInCoin: number // human coin units
): Promise<Preflight> {
const { query } = scallopSDK.client;
const pool = await query.getMarketPool(coinName);
if (!pool) return { ok: false, reason: 'cap', detail: 'unknown pool' };
const headroom = Math.max(pool.maxBorrowCoin - pool.borrowCoin, 0);
if (pool.maxBorrowCoin > 0 && amountInCoin > headroom) {
return { ok: false, reason: 'cap', detail: `headroom ${headroom}` };
}
// Health: see obligation-manager for the full collateral-factor formula.
// Use query.getObligationAccount(obligationId) to drive the math.
return { ok: true };
}
Error Handling
| Error | Code | Likely Cause |
|---|---|---|
BorrowCapReached (borrow_limit_reached_error) |
104 / 0x0014005 |
New borrow would push the pool's total borrow above its cap |
SupplyCapReached (supply_limit_reached) |
103 / 0x0014002 |
New supply would push the pool's total supply above its cap |
unable_to_borrow_other_coin_with_isolated_asset |
0x0000505 |
Borrowing another coin while an isolated asset is used as collateral (or vice versa) |
See error-codes.md for the full table.
These are protocol-level reverts — they fire regardless of obligation health.
References
- query-data Skill - Other on-chain reads
- obligation-manager Skill - Per-obligation health
- lend-integration Skill - Supply/borrow ops
- supported-coins.md - Asset list