Skip to main content
Withdraw collateral out of an account and repay its debt, both through the controller. Read first: Supply and borrow, Health factor. Both calls go to the controller. Withdraw is owner-only and re-checks risk. Repay is permissionless: anyone can repay any account. Confirm state from controller events and views.

Withdraw

withdraw removes one or more supplied assets from an account and sends them to to when provided, else to caller. It returns the actual amount paid per deduped asset.
withdraw(
    caller: Address,
    account_id: u64,
    withdrawals: Vec<(Address, i128)>,
    to: Option<Address>,
) -> Vec<(Address, i128)>
For each asset the controller calls the pool’s withdraw(action: PoolAction, is_liquidation = false, protocol_fee = 0), then re-checks the LTV gate and health factor on cached indexes. The owner stays the auth subject regardless of to; only the token destination changes.
Full-position sentinel. Pass amount = 0 for an asset to withdraw its entire post-accrual position. The controller forwards i128::MAX to the pool, which clamps it to the current actual supply; over-asking with any amount at or above the position value clamps the same way. Preview the largest executable amount with max_withdraw(account_id, asset).

Preconditions

  • caller is the account owner.
  • Each asset has a live supply position in the account.
  • After the withdrawal, if any debt remains, the health factor stays at or above 1e18 and the LTV gate holds.
  • The pool holds enough free liquidity for the requested amount (else #112).
  • Accounts with debt are priced under a strict risk-increasing policy before the final health-factor gate; a debt-free withdrawal uses the risk-decreasing policy.

Inputs

caller
Address
required
Account owner. Authorizes the withdrawal; receives the tokens when to is absent.
account_id
u64
required
The account to withdraw from. Must already exist.
withdrawals
Vec<(Address, i128)>
required
Each entry is (asset_address, amount) in asset-native units. amount = 0 withdraws the full position for that asset.
to
Option<Address>
Optional recipient override. When set, the pool pays the withdrawn tokens directly to this address — useful for contracts (vaults, routers) forwarding funds to an end user without an extra hop.

Build the call

# Withdraw the full USDC position (amount 0) from account 42.
stellar contract invoke \
  --id <CONTROLLER> \
  --source-account caller \
  --network testnet \
  -- withdraw \
  --caller C... \
  --account_id 42 \
  --withdrawals '[["CUSDC...", "0"]]' \
  --to null
import { Contract, nativeToScVal, Address } from "@stellar/stellar-sdk";

const controller = new Contract("C...");

const withdrawals = nativeToScVal(
  [[new Address("CUSDC...").toScVal(), nativeToScVal(0n, { type: "i128" })]], // 0 = full position
  { type: "Vec" },
);

const op = controller.call(
  "withdraw",
  new Address(callerPublicKey).toScVal(),
  nativeToScVal(42n, { type: "u64" }),
  withdrawals,
  nativeToScVal(null, { type: "option" }), // or an Address to redirect the payout
);
// build, prepareTransaction (simulate), sign with caller, send

Authorize

caller.require_auth() runs inside withdraw; only the account owner can call it. The controller transfers the withdrawn assets from the pool to the recipient (to or caller) — pool-internal, no extra signature. The transaction’s only authorization is caller’s top-level call. Simulate first: an account with debt can revert at the post-withdrawal health-factor gate.

Verify

On success the controller emits UpdatePositionBatchEvent (["position", "batch_update"]) and UpdateMarketStateBatchEvent (["market", "batch_state_update"]). The call returns the actual amounts paid per asset (a full close pays the floor rounding of the position value). Confirm with collateral_amount_for_token(account_id, asset) (zero after a full close), total_collateral_in_usd(account_id), and health_factor(account_id). If the withdrawal cleared the last supply and debt position, the account is removed — get_account_attributes(account_id) then raises #24 AccountNotFound, and a later supply must pass account_id = 0 to open a new account.

Failure modes

ErrorCauseFix
#102 HealthFactorTooLowPost-withdrawal health factor below 1e18Withdraw less, or repay debt first.
#112 InsufficientLiquidityPool free liquidity below the requested amountWithdraw less, or wait for repayments.
#100 InsufficientCollateralLTV gate fails after the withdrawalWithdraw less, or repay debt first.
#126 MinBorrowCollateralNotMetLTV-weighted collateral below instance floor while debt remainsRepay debt first or withdraw less.

Repay

repay reduces an account’s debt. It is permissionless: any caller may repay any account_id, and overpayment is refunded to caller.
repay(
    caller: Address,
    account_id: u64,
    payments: Vec<(Address, i128)>,
)
The controller pre-transfers each (asset, amount) from caller into the pool, then calls the pool’s repay(action: PoolAction) per asset. If a payment exceeds the outstanding debt, the pool refunds the excess to caller.

Preconditions

  • caller holds at least amount of every asset in payments. No account ownership is required.
  • Each asset has a live debt position in the account (else #120).
  • Active markets use the permissive RiskDecreasing policy; disabled markets use Repay, which also tolerates a disabled market status.

Inputs

caller
Address
required
Funds the repayment and authorizes the transfers. Need not own the account. Receives any overpayment refund.
account_id
u64
required
The account whose debt is repaid. Must already exist.
payments
Vec<(Address, i128)>
required
Each entry is (asset_address, amount) in asset-native units. Overpayment is refunded.

Build the call

# Repay 200 XLM toward account 42's debt (overpayment is refunded).
stellar contract invoke \
  --id <CONTROLLER> \
  --source-account caller \
  --network testnet \
  -- repay \
  --caller C... \
  --account_id 42 \
  --payments '[["CXLM...", "2000000000"]]'
import { Contract, nativeToScVal, Address } from "@stellar/stellar-sdk";

const controller = new Contract("C...");

const payments = nativeToScVal(
  [[new Address("CXLM...").toScVal(), nativeToScVal(2_000_000_000n, { type: "i128" })]],
  { type: "Vec" },
);

const op = controller.call(
  "repay",
  new Address(callerPublicKey).toScVal(), // any payer
  nativeToScVal(42n, { type: "u64" }),
  payments,
);
// build, prepareTransaction (simulate), sign with caller, send

Authorize

caller.require_auth() runs inside repay. The transaction authorizes one token transfer per asset from caller to the pool. Because repay is permissionless, the only signature required is the payer’s — never the account owner’s.

Verify

On success the controller emits UpdatePositionBatchEvent (["position", "batch_update"]) and UpdateMarketStateBatchEvent (["market", "batch_state_update"]). Confirm with borrow_amount_for_token(account_id, asset), total_borrow_in_usd(account_id), and health_factor(account_id) (rises as debt falls; i128::MAX once debt-free).

Account close behavior

Clearing all positions cleans up the account. Repaying the last debt while collateral remains does not remove the account — the supply positions still exist. The account’s storage is removed only when both its supply and debt maps are empty, which happens when an owner withdrawal (or a close-position strategy) takes out the final position. To fully close, repay all debt, then withdraw all collateral with the full-position sentinel.

Failure modes

ErrorCauseFix
#120 DebtPositionNotFoundNo debt position for the asset in this accountRepay an asset the account actually owes.
#16 InvalidPaymentsEmpty payments vectorPass at least one (asset, amount) entry.
#14 AmountMustBePositiveA payment amount is zero or negativeUse a positive amount per asset.

Next

Liquidations

What happens when health factor drops below 1, and the liquidator’s recipe.

Accounts and risk

Accounts, positions, and how account close-out works.