URUOIdocs

How it works

This page follows one position through its life and shows the exact rule the program applies at each step. Code references point at programs/uruoi/src.

Values are in SOL, read from the pool

Every collateral has a rate source, set once when the collateral is added:

Collateral kindRate source accountRate the program reads
SplStakePool (JitoSOL)the SPL stake pool accounttotal_lamports / pool_token_supply
Marinade (mSOL)the Marinade state accountmsol_price, lamports per mSOL in 32.32 fixed point

The program keeps rates as Q64.64 fixed point numbers (math.rs, Rate). The SOL value of a position is shares x rate, rounded down. The rate source is pinned to the collateral account, and the program checks that the source belongs to the right program, has the expected layout, and prices the right mint (InvalidRateSource, RateSourceMintMismatch).

Borrowing, withdrawing while a debt is open, repaying with collateral, redeeming and unwinding need a fresh rate: an SPL stake pool that has not been updated for the current epoch is refused with StaleRate, so nobody can borrow against a rate the pool has not confirmed yet. Settling (and so sync) works on any rate; a stale one simply sweeps what it shows.

Settle: the sweep runs first, every time

Every position instruction starts with settle (instructions/position.rs). It reads today's pool rate and applies the sweep:

// math.rs
pub fn sweep(shares: u64, debt: u64, snapshot: Rate, now: Rate) -> Option<Sweep> {
    if debt == 0 || shares == 0 || now <= snapshot {
        return Some(Sweep::default());
    }
    let earned = u128::from(shares).checked_mul(now.0 - snapshot.0)? >> 64;
    let swept = u64::try_from(earned.min(u128::from(debt))).ok()?;
    if swept == 0 {
        return Some(Sweep::default());
    }
    let shares_out = now.shares_ceil(swept)?.min(shares);
    Some(Sweep { swept, shares_out })
}

In words:

  1. earned is what your shares gained since the last snapshot: shares times the rate rise, in lamports.
  2. swept is that gain, capped at your debt. Once the debt is gone, the gain stays with you.
  3. shares_out is how many LST shares are worth swept lamports at today's rate, rounded up, so the buffer always holds at least the SOL value credited to your debt.
  4. Your debt falls by swept, your shares fall by shares_out, and those shares move to the collateral's redemption buffer (buffer_shares).
  5. Your snapshot moves to today's rate. A falling rate sweeps nothing.

Because the swept shares are worth exactly the gain, your position keeps the SOL value it started with, and the debt goes down by the yield on that value.

Sync: anyone can push it along

sync is permissionless. It runs settle on any position and nothing else. Anyone may call it, for any position, at any time; a keeper that syncs every position once an epoch keeps every debt current. You never need to sync your own position: any instruction you send settles first.

If nobody syncs for a while, nothing is lost. The next settle uses the shares and the snapshot from the last one, so the gain covers the whole stretch at once.

Borrow

borrow(amount) settles, needs a fresh rate, then checks:

  • borrowing is not paused for this collateral (MintingPaused),
  • (debt + amount) / value <= max_ltv (ExceedsMaxLtv), computed as debt x 10,000 <= value x max_ltv_bps,
  • the collateral's total debt stays under its debt ceiling (DebtCeilingReached).

Then it mints amount uSOL to your token account and emits Borrowed.

Repay

There are two ways to pay early:

  • repay(amount): anyone burns up to amount uSOL against any position's debt. It can only lower a debt. Anything above the remaining debt stays in the payer's account.
  • repay_with_collateral(amount): the owner pays with the position's own LST at the pool rate. The LST moves to the redemption buffer, where it backs the uSOL this debt once created.

Withdraw and close

withdraw(amount) settles, then, if the position still has debt, needs a fresh rate and releases LST only if the remaining shares keep the debt within the borrow limit. With no debt, it releases any amount up to the shares held. close_position closes an empty position (no shares, no debt) and returns its rent.

Redeem

redeem(amount) burns amount uSOL and pays exactly amount lamports of LST, taken from every collateral's buffer in proportion to that buffer's SOL value (math::pro_rata). Every redeemer receives the same mix, so nobody can pick the best LST and leave a weaker one behind for the last holders. If the buffers together hold less than amount, the call fails with InsufficientBuffer.

Unwind

unwind(amount) is the only forced path. It opens only when a position's loan to value is above its collateral's unwind threshold, which can only happen if the pool rate itself has fallen. The unwinder burns uSOL against the debt and takes the same SOL value of the position's LST at the pool rate, never more than it takes to bring the position back down to the borrow limit (math::unwind_limit). A healthy position refuses with PositionHealthy.

Diagram, in MermaidsequenceDiagram
    participant Owner
    participant Program
    participant Pool as Stake pool
    participant Buffer as Redemption buffer
    Owner->>Program: deposit(LST)
    Owner->>Program: borrow(uSOL)
    Program->>Owner: mint uSOL
    loop every epoch
        Pool-->>Pool: rate rises
        Note over Program: anyone calls sync
        Program->>Buffer: shares worth the rise
        Program->>Program: debt -= rise
    end
    Owner->>Program: withdraw(all LST) at zero debt