Liquidate

Running a liquidator

Find credit accounts through the indexer, value them with the SDK exactly as the program does, and send liquidations that simulate first.

A liquidator needs four things: the list of credit accounts, each account's balances, both targets' state at the same moment, and a transaction that unwinds and repays. The indexer API gives the first; the SDK does the rest with the program's own valuation and rounding.

Setup

The registry account holds the Save and Orca addresses every move lists. Read it once.

TypeScript
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
import {
  adapterMetas, associatedTokenAddress, buildV0, composeSession, computeBudget,
  createAssociatedTokenIdempotent, custodyAddress, decodeYokeAccount, health, loadLookupTables,
  poolAddress, quoteLpWithdraw, quotePool, readTargets, registryAddress, saveCash, saveWorth,
  quoteSaveRedeem, simulate, valueCredit, vaultAddress, USDC_MINT, WSOL_MINT,
  type AdapterAction, type AdapterConfig,
} from "@yoke/sdk";

const connection = new Connection(process.env.SOLANA_RPC_URL!, "confirmed");
const programId = new PublicKey(process.env.YOKE_PROGRAM_ID!);
const liquidator = Keypair.fromSecretKey(/* your key */);
const pool = poolAddress(programId)[0];
const registry = registryAddress(pool, programId)[0];

const decoded = decodeYokeAccount((await connection.getAccountInfo(registry))!.data, registry, programId);
if (decoded.kind !== "adapter") throw new Error("Not the registry");
const config = toConfig(decoded.config);
const tables = await loadLookupTables(connection, [new PublicKey(process.env.YOKE_LOOKUP_TABLE!)]);

// The registry decodes to strings; turn addresses and slots back into PublicKey and bigint.
function toConfig(json: Record<string, string | boolean>): AdapterConfig {
  const out: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(json))
    out[k] = typeof v === "boolean" ? v : k.endsWith("Slot") ? BigInt(v) : new PublicKey(v);
  return out as unknown as AdapterConfig;
}

Find accounts

The indexer pages through every credit account, 250 at a time.

TypeScript
async function credits(api: string) {
  const all: { address: string; state: { owner: string; debtUnits: string; nonce: string } }[] = [];
  let after = "";
  for (;;) {
    const res = await fetch(`${api}/v1/accounts?kind=credit&limit=250${after ? `&after=${after}` : ""}`);
    const page = await res.json();
    all.push(...page.accounts);
    if (!page.next) return all;
    after = page.next;
  }
}

The indexer serves finalized snapshots and labels stale ones. Use it to discover accounts, never to value them.

Value them

Read the pool, its vault, both targets and each account's four token accounts from the same RPC, then value with the SDK.

TypeScript
const SCALE = 10n ** 12n;
const amount = (data: Buffer) => data.readBigUInt64LE(64);

async function check(address: string, units: bigint) {
  const credit = new PublicKey(address);
  const [poolInfo, vaultInfo, ...custody] = await connection.getMultipleAccountsInfo([
    pool, vaultAddress(pool, programId)[0], ...[0, 1, 2, 3].map((i) => custodyAddress(credit, i, programId)[0]),
  ]);
  const targets = await readTargets(connection, config);
  const poolState = decodeYokeAccount(poolInfo!.data, pool, programId);
  if (poolState.kind !== "pool") throw new Error("Not the pool");
  const { index } = quotePool(poolState, amount(vaultInfo!.data), targets.unixTimestamp);
  const debt = (units * index + SCALE - 1n) / SCALE;
  const [usdc, wsol, ctoken, lp] = custody.map((c) => amount(c!.data));
  const value = valueCredit(targets, { usdc, wsol, ctoken, lp });
  return { targets, custody: { usdc, wsol, ctoken, lp }, debt, ...health(value.collateral, debt) };
}

liquidatable is true when the account has debt and its collateral is below it.

Build the liquidation

Unwind with the two unwind steps, then let end repay. Save redemptions are capped by what Save can pay out now; redeem what fits and come back for the rest.

TypeScript
const down = (x: bigint) => (x * 99n) / 100n; // 1% slippage

function unwind(t: Awaited<ReturnType<typeof readTargets>>, c: { ctoken: bigint; lp: bigint }) {
  const actions: AdapterAction[] = [];
  if (c.ctoken > 0n) {
    const worth = saveWorth(t, c.ctoken);
    const fits = worth <= saveCash(t) ? c.ctoken : (c.ctoken * saveCash(t)) / worth;
    if (fits > 0n) actions.push({ action: 1, amount: fits, limitA: down(quoteSaveRedeem(t, fits).usdc), limitB: 0n });
  }
  if (c.lp > 0n) {
    const q = quoteLpWithdraw(t, c.lp);
    actions.push({ action: 3, amount: c.lp, limitA: down(q.usdc), limitB: down(q.wsol) });
  }
  return actions;
}

The liquidator's USDC account receives the premium. The owner's SOL account receives any SOL the program returns, so create it if it's missing; these go before the move, and the move's end index counts them.

TypeScript
async function liquidate(owner: PublicKey, nonce: bigint, actions: AdapterAction[]) {
  const prefix = [
    ...computeBudget({ computeUnits: 1_000_000 }),
    createAssociatedTokenIdempotent(liquidator.publicKey, liquidator.publicKey, USDC_MINT),
    createAssociatedTokenIdempotent(liquidator.publicKey, owner, WSOL_MINT),
  ];
  const accounts = {
    actor: liquidator.publicKey,
    owner,
    destinationUsdc: associatedTokenAddress(liquidator.publicKey, USDC_MINT),
    destinationWsol: associatedTokenAddress(owner, WSOL_MINT),
    external: adapterMetas(config),
  };
  const session = composeSession(accounts, nonce, 2, actions, {}, prefix.length, programId);
  const { blockhash } = await connection.getLatestBlockhash();
  const tx = buildV0(liquidator.publicKey, blockhash, [...prefix, ...session], tables);

  const sim = await simulate(connection, tx);
  if (sim.error) return { skipped: sim.error, logs: sim.logs };
  tx.sign([liquidator]);
  return { signature: await connection.sendRawTransaction(tx.serialize()) };
}

nonce is the account's stored nonce plus one. Pass { writeOff: true } instead of {} only when no Save receipts or LP tokens are left and the USDC can't cover the debt; see write-offs.

What to expect

  • A full liquidation of both positions uses about 390,000 compute units; ask for 1,000,000 to leave room.
  • A partial liquidation that would leave collateral ÷ debt lower than before fails with error 14.
  • An account that recovered above 1.00 before your transaction landed fails with error 8. Nothing happens, and you pay only the fee.

Next

Yoke

One credit account across Save and Orca, on Solana.

Follow on X
© 2026 YokeYoke is software on Solana; every transaction is signed by your own wallet. Borrowing, lending and liquidating can lose money. Read the risks first.