TypeScript SDK#

The TypeScript SDK (@coalescefi/sdk) for building integrations with the Coalesce Finance permissioned lending protocol on Solana.

Installation#

npm install @coalescefi/sdk @solana/web3.js @solana/spl-token
pnpm add @coalescefi/sdk @solana/web3.js @solana/spl-token

For Squads multisig integration, also install:

npm install @sqds/multisig

Requirements#

  • Node.js >= 22
  • TypeScript >= 5.0 with ES2020 target or later (for BigInt support)
  • @solana/web3.js ^1.91.0

Verifying Installation#

import { findProtocolConfigPda, configureSdk } from '@coalescefi/sdk';
import { PublicKey } from '@solana/web3.js';

configureSdk({ network: 'mainnet' });
const [configPda] = findProtocolConfigPda();

console.log('SDK installed successfully');
console.log('Config PDA:', configPda.toBase58());

Quick Start — CoalesceClient#

The recommended way to interact with Coalesce Finance is via the CoalesceClient class. It handles PDA derivation, account resolution, blacklist checks, and caching internally, so you only need to provide the essential parameters.

import { Connection } from '@solana/web3.js';
import { CoalesceClient } from '@coalescefi/sdk';

const connection = new Connection('https://your-rpc.com', 'confirmed');
const client = CoalesceClient.mainnet(connection);

// Deposit 100,000 USDC (6 decimals) — returns TransactionInstruction[]
const ixs = await client.deposit(lender, marketPda, 100_000_000_000n);

Most methods return TransactionInstruction[]. The exception is createMarket(), which returns CreateMarketResult (containing .instructions and .marketPda). The caller controls signing and sending:

import { Transaction, sendAndConfirmTransaction } from '@solana/web3.js';

const tx = new Transaction().add(...ixs);
const signature = await sendAndConfirmTransaction(connection, tx, [lenderKeypair]);

Client Methods#

MethodSignerDescription
deposit(lender, market, amount)LenderDeposit tokens into a market
withdraw(lender, market, scaledAmount)LenderWithdraw after maturity (pass 0n for full balance)
withdrawAndClose(lender, market)LenderWithdraw full balance and close position in one tx
closeLenderPosition(lender, market)LenderClose an empty position to reclaim rent
claimHaircut(lender, market)LenderClaim owed haircut recovery tokens
claimHaircutAndClose(lender, market)LenderClaim haircut and close position in one tx
createMarket(borrower, mint, args)BorrowerCreate a new lending market
borrow(borrower, market, amount)BorrowerBorrow deposited funds
repay(payer, market, totalAmount, interestAmount)AnyoneWaterfall repay (interest first, then principal)
withdrawExcess(borrower, market)BorrowerWithdraw excess vault balance
forceClosePosition(borrower, market, lender, escrow)BorrowerForce-close an abandoned lender position post-maturity
forceClaimHaircut(borrower, market, lender, escrow)BorrowerForce-claim haircut on behalf of a lender
reSettle(market)AnyoneUpdate settlement factor after late repayments
collectFees(feeAuthority, market)Fee AuthorityCollect accrued protocol fees

Admin operations are under client.admin:

MethodSignerDescription
admin.initializeProtocol(admin, args)AdminOne-time protocol initialization
admin.setFeeConfig(admin, feeRateBps, newFeeAuthority)AdminUpdate fee configuration
admin.whitelistBorrower(whitelistManager, borrower, args)Whitelist ManagerSet borrower whitelist status
admin.setPause(admin, paused)AdminPause/unpause the protocol
admin.setBlacklistMode(admin, failClosed)AdminSet blacklist enforcement mode
admin.setAdmin(currentAdmin, newAdmin)AdminTransfer admin role
admin.setWhitelistManager(admin, newManager)AdminUpdate whitelist manager

Reading State#

// Fetch a decoded Market account (cached)
const market = await client.getMarket(marketPda);

// Fetch a lender's position
const position = await client.getPosition(marketPda, lenderPubkey);

// Scan for all markets by a borrower (tries nonces 0..N)
const markets = await client.scanMarkets(borrowerPubkey, { maxNonce: 10 });

// Scan for a lender's positions across multiple borrowers
const positions = await client.scanPositions(lenderPubkey, [borrower1, borrower2]);

Named Constructors#

const client = CoalesceClient.mainnet(connection); // Mainnet program ID
const client = CoalesceClient.devnet(connection); // Devnet program ID
const client = CoalesceClient.localnet(connection); // Localnet program ID

// Custom program ID
const client = new CoalesceClient(connection, { programId: myProgramId });

// With cache TTL (default 30s)
const client = CoalesceClient.mainnet(connection, 60_000); // 60s cache

Note: The low-level instruction builders and PDA functions documented below remain fully supported. CoalesceClient is a convenience layer built on top of them.


Configuration#

Program ID#

The SDK resolves the program ID using a priority chain:

  1. Explicit programId via configureSdk()
  2. COALESCEFI_PROGRAM_ID environment variable
  3. Network name via configureSdk({ network }) or COALESCEFI_NETWORK env var
  4. Defaults to localnet

Call configureSdk() once at application startup:

import { configureSdk } from '@coalescefi/sdk';
import { PublicKey } from '@solana/web3.js';

// Option 1: Network-based (recommended)
configureSdk({ network: 'mainnet' });

// Option 2: Explicit program ID
configureSdk({
  programId: new PublicKey('GooseA4bSoxitTMPa4ppe2zUQ9fu4139u8pEk6x65SR'),
});

After calling configureSdk(), all PDA and instruction functions use the configured program ID automatically — no need to pass programId to every call.

Per-Call Override#

All PDA and instruction functions accept an optional programId parameter that overrides the global config:

const [marketPda] = findMarketPda(borrower, nonce, customProgramId);

Mainnet Program ID#

GooseA4bSoxitTMPa4ppe2zUQ9fu4139u8pEk6x65SR

Connection Setup#

import { Connection } from '@solana/web3.js';

const connection = new Connection('https://your-rpc-endpoint.com', {
  commitment: 'confirmed',
});
Use CaseCommitment
Reading market dataconfirmed
Checking balancesconfirmed
After transactionconfirmed
Critical operationsfinalized

USDC Mint#

import { PublicKey } from '@solana/web3.js';

// Mainnet USDC
const USDC_MINT = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');

Wallet Configuration#

With Wallet Adapter (Browser)#

import { useWallet, useConnection } from '@solana/wallet-adapter-react';

function MyComponent() {
  const { connection } = useConnection();
  const { publicKey, signTransaction } = useWallet();
  // Use publicKey and signTransaction with SDK
}

With Keypair (Server/Script)#

import { Keypair } from '@solana/web3.js';

const keypair = Keypair.fromSecretKey(Uint8Array.from(require('./keypair.json')));

Quick Start#

Typescript (64 lines)
import { Connection, PublicKey, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
import { getAssociatedTokenAddress, TOKEN_PROGRAM_ID } from '@solana/spl-token';
import {
  configureSdk,
  findMarketPda,
  findLenderPositionPda,
  findVaultPda,
  findProtocolConfigPda,
  findBlacklistCheckPda,
  createDepositInstruction,
  fetchMarket,
  fetchProtocolConfig,
  configFieldToPublicKey,
} from '@coalescefi/sdk';
import { SystemProgram } from '@solana/web3.js';

// 1. Configure SDK
configureSdk({ network: 'mainnet' });

const connection = new Connection('https://your-rpc.com', 'confirmed');
const lender = yourWallet; // Keypair or wallet adapter
const borrower = new PublicKey('BORROWER_PUBKEY');
const usdcMint = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
const marketNonce = 1n;

// 2. Derive PDAs
const [marketPda] = findMarketPda(borrower, marketNonce);
const [vaultPda] = findVaultPda(marketPda);
const [positionPda] = findLenderPositionPda(marketPda, lender.publicKey);
const [configPda] = findProtocolConfigPda();

// 3. Fetch config to get blacklist program, then derive blacklist check PDA
const config = await fetchProtocolConfig(connection, configPda);
if (!config) throw new Error('Protocol not initialized');
const blacklistProgram = configFieldToPublicKey(config.blacklistProgram);
const [blacklistCheckPda] = findBlacklistCheckPda(lender.publicKey, blacklistProgram);

// 4. Fetch market data
const market = await fetchMarket(connection, marketPda);
if (!market) throw new Error('Market not found');

console.log('Market Interest Rate:', market.annualInterestBps / 100, '%');

// 5. Build and send deposit instruction
const depositIx = createDepositInstruction(
  {
    market: marketPda,
    lender: lender.publicKey,
    lenderTokenAccount: await getAssociatedTokenAddress(usdcMint, lender.publicKey),
    vault: vaultPda,
    lenderPosition: positionPda,
    blacklistCheck: blacklistCheckPda,
    protocolConfig: configPda,
    mint: usdcMint,
    tokenProgram: TOKEN_PROGRAM_ID,
    systemProgram: SystemProgram.programId,
  },
  { amount: 1_000_000n } // 1 USDC (6 decimals)
);

const tx = new Transaction().add(depositIx);
const signature = await sendAndConfirmTransaction(connection, tx, [lender]);
console.log('Deposited:', signature);

PDA Derivation Reference#

All account addresses in Coalesce Finance are Program Derived Addresses (PDAs). When configureSdk() has been called, the programId parameter is optional.

Protocol Config PDA#

Singleton protocol configuration account:

import { findProtocolConfigPda } from '@coalescefi/sdk';

const [configPda, configBump] = findProtocolConfigPda();
// Seeds: ["protocol_config"]

Program Data PDA#

The BPF Loader Upgradeable program data account (used in initializeProtocol):

import { findProgramDataPda } from '@coalescefi/sdk';

const [programDataPda] = findProgramDataPda();

Market PDA#

Each market is uniquely identified by borrower + nonce:

import { findMarketPda } from '@coalescefi/sdk';

const [marketPda, marketBump] = findMarketPda(borrowerPubkey, marketNonce);
// Seeds: ["market", borrower, nonce_le_bytes]

Market Authority PDA#

The authority that signs vault token transfers:

import { findMarketAuthorityPda } from '@coalescefi/sdk';

const [authorityPda, authorityBump] = findMarketAuthorityPda(marketPda);
// Seeds: ["market_authority", market]

Vault PDA#

The token account holding market deposits:

import { findVaultPda } from '@coalescefi/sdk';

const [vaultPda, vaultBump] = findVaultPda(marketPda);
// Seeds: ["vault", market]

Lender Position PDA#

Each lender's position in a specific market:

import { findLenderPositionPda } from '@coalescefi/sdk';

const [positionPda, positionBump] = findLenderPositionPda(marketPda, lenderPubkey);
// Seeds: ["lender", market, lender]

Borrower Whitelist PDA#

A borrower's whitelisted status and capacity:

import { findBorrowerWhitelistPda } from '@coalescefi/sdk';

const [whitelistPda, whitelistBump] = findBorrowerWhitelistPda(borrowerPubkey);
// Seeds: ["borrower_whitelist", borrower]

Blacklist Check PDA#

Derived from the blacklist program (not the Coalesce program):

import { findBlacklistCheckPda } from '@coalescefi/sdk';

// blacklistProgram comes from ProtocolConfig — see "Fetching Blacklist Program" below
const [blacklistCheckPda] = findBlacklistCheckPda(userPubkey, blacklistProgram);
// Seeds: ["blacklist", user] (derived against blacklist program ID)

Haircut State PDA#

Per-market aggregate haircut state, used by the re-settle solver and haircut recovery instructions:

import { findHaircutStatePda } from '@coalescefi/sdk';

const [haircutStatePda, haircutStateBump] = findHaircutStatePda(marketPda);
// Seeds: ["haircut_state", market]

Derive All Market PDAs at Once#

import { deriveMarketPdas } from '@coalescefi/sdk';

const { market, marketAuthority, vault } = deriveMarketPdas(borrowerPubkey, marketNonce);
// market.address, marketAuthority.address, vault.address

Fetching Blacklist Program#

Many instructions require a blacklistCheck PDA. To derive it, you first need the blacklist program address from the protocol config:

import {
  findProtocolConfigPda,
  findBlacklistCheckPda,
  fetchProtocolConfig,
  configFieldToPublicKey,
} from '@coalescefi/sdk';

const [configPda] = findProtocolConfigPda();
const config = await fetchProtocolConfig(connection, configPda);
if (!config) throw new Error('Protocol not initialized');

const blacklistProgram = configFieldToPublicKey(config.blacklistProgram);
const [blacklistCheckPda] = findBlacklistCheckPda(userAddress, blacklistProgram);

Complete Operation Examples#

1. Create Market (Borrower)#

Borrowers create markets to attract lender deposits.

Typescript (100 lines)
import {
  Connection,
  Keypair,
  PublicKey,
  SystemProgram,
  Transaction,
  sendAndConfirmTransaction,
} from '@solana/web3.js';
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
import {
  configureSdk,
  findProtocolConfigPda,
  findMarketPda,
  findMarketAuthorityPda,
  findVaultPda,
  findHaircutStatePda,
  findBorrowerWhitelistPda,
  findBlacklistCheckPda,
  createCreateMarketInstruction,
  fetchProtocolConfig,
  fetchBorrowerWhitelist,
  configFieldToPublicKey,
} from '@coalescefi/sdk';

configureSdk({ network: 'mainnet' });

async function createMarket(
  connection: Connection,
  borrowerKeypair: Keypair,
  usdcMint: PublicKey,
  params: {
    maxTotalSupply: bigint;
    annualInterestBps: number;
    maturityTimestamp: bigint;
  }
) {
  const borrower = borrowerKeypair.publicKey;

  // 1. Verify borrower is whitelisted
  const [whitelistPda] = findBorrowerWhitelistPda(borrower);
  const whitelist = await fetchBorrowerWhitelist(connection, whitelistPda);
  if (!whitelist || !whitelist.isWhitelisted) {
    throw new Error('Borrower is not whitelisted');
  }

  // 2. Fetch config for blacklist program
  const [configPda] = findProtocolConfigPda();
  const config = await fetchProtocolConfig(connection, configPda);
  if (!config) throw new Error('Protocol not initialized');
  const blacklistProgram = configFieldToPublicKey(config.blacklistProgram);

  // 3. Derive all required PDAs
  const marketNonce = BigInt(Date.now());
  const [marketPda] = findMarketPda(borrower, marketNonce);
  const [marketAuthorityPda] = findMarketAuthorityPda(marketPda);
  const [vaultPda] = findVaultPda(marketPda);
  const [blacklistCheckPda] = findBlacklistCheckPda(borrower, blacklistProgram);
  const [haircutStatePda] = findHaircutStatePda(marketPda);

  // 4. Build create market instruction
  const createMarketIx = createCreateMarketInstruction(
    {
      market: marketPda,
      borrower: borrower,
      mint: usdcMint,
      vault: vaultPda,
      marketAuthority: marketAuthorityPda,
      protocolConfig: configPda,
      borrowerWhitelist: whitelistPda,
      blacklistCheck: blacklistCheckPda,
      systemProgram: SystemProgram.programId,
      tokenProgram: TOKEN_PROGRAM_ID,
      haircutState: haircutStatePda,
    },
    {
      marketNonce,
      annualInterestBps: params.annualInterestBps,
      maturityTimestamp: params.maturityTimestamp,
      maxTotalSupply: params.maxTotalSupply,
    }
  );

  // 5. Send transaction
  const tx = new Transaction().add(createMarketIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [borrowerKeypair]);

  console.log('Market created:', marketPda.toBase58());
  console.log('Transaction:', signature);

  return { marketPda, vaultPda, signature };
}

// Usage
const maturityDate = new Date('2025-06-01T00:00:00Z');
await createMarket(connection, borrowerKeypair, USDC_MINT, {
  maxTotalSupply: 100_000_000_000n, // 100,000 USDC (6 decimals)
  annualInterestBps: 800, // 8% APR
  maturityTimestamp: BigInt(Math.floor(maturityDate.getTime() / 1000)),
});

2. Deposit (Lender)#

Lenders deposit funds into a market to earn interest.

Typescript (73 lines)
import {
  findProtocolConfigPda,
  findVaultPda,
  findLenderPositionPda,
  findBlacklistCheckPda,
  createDepositInstruction,
  fetchMarket,
  fetchProtocolConfig,
  configFieldToPublicKey,
} from '@coalescefi/sdk';
import { getAssociatedTokenAddress, TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { SystemProgram } from '@solana/web3.js';

async function deposit(
  connection: Connection,
  lenderKeypair: Keypair,
  marketPda: PublicKey,
  amount: bigint
) {
  const lender = lenderKeypair.publicKey;

  // 1. Fetch market to get mint
  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  // 2. Verify market is active
  const now = BigInt(Math.floor(Date.now() / 1000));
  if (now >= market.maturityTimestamp) {
    throw new Error('Market has matured, deposits not allowed');
  }

  // 3. Fetch config for blacklist program
  const [configPda] = findProtocolConfigPda();
  const config = await fetchProtocolConfig(connection, configPda);
  if (!config) throw new Error('Protocol not initialized');
  const blacklistProgram = configFieldToPublicKey(config.blacklistProgram);

  // 4. Derive required PDAs
  const [vaultPda] = findVaultPda(marketPda);
  const [positionPda] = findLenderPositionPda(marketPda, lender);
  const [blacklistCheckPda] = findBlacklistCheckPda(lender, blacklistProgram);

  // 5. Get lender's token account
  const lenderTokenAccount = await getAssociatedTokenAddress(market.mint, lender);

  // 6. Build deposit instruction
  const depositIx = createDepositInstruction(
    {
      market: marketPda,
      lender: lender,
      lenderTokenAccount: lenderTokenAccount,
      vault: vaultPda,
      lenderPosition: positionPda,
      blacklistCheck: blacklistCheckPda,
      protocolConfig: configPda,
      mint: market.mint,
      tokenProgram: TOKEN_PROGRAM_ID,
      systemProgram: SystemProgram.programId,
    },
    { amount }
  );

  // 7. Send transaction
  const tx = new Transaction().add(depositIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [lenderKeypair]);

  console.log('Deposited:', amount.toString(), 'to market:', marketPda.toBase58());
  return { positionPda, signature };
}

// Usage: Deposit 1,000 USDC
await deposit(connection, lenderKeypair, marketPda, 1_000_000_000n);

3. Borrow (Borrower)#

Borrowers withdraw deposited funds from their markets.

Typescript (73 lines)
import {
  findProtocolConfigPda,
  findMarketAuthorityPda,
  findVaultPda,
  findBorrowerWhitelistPda,
  findBlacklistCheckPda,
  createBorrowInstruction,
  fetchProtocolConfig,
  fetchMarket,
  configFieldToPublicKey,
} from '@coalescefi/sdk';

async function borrow(
  connection: Connection,
  borrowerKeypair: Keypair,
  marketPda: PublicKey,
  amount: bigint
) {
  const borrower = borrowerKeypair.publicKey;

  // 1. Fetch market and verify ownership
  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');
  if (!market.borrower.equals(borrower)) {
    throw new Error('Only the market borrower can borrow');
  }

  // 2. Fetch config for blacklist program
  const [configPda] = findProtocolConfigPda();
  const config = await fetchProtocolConfig(connection, configPda);
  if (!config) throw new Error('Protocol not initialized');
  const blacklistProgram = configFieldToPublicKey(config.blacklistProgram);

  // 3. Derive required PDAs
  const [marketAuthorityPda] = findMarketAuthorityPda(marketPda);
  const [vaultPda] = findVaultPda(marketPda);
  const [whitelistPda] = findBorrowerWhitelistPda(borrower);
  const [blacklistCheckPda] = findBlacklistCheckPda(borrower, blacklistProgram);

  // NOTE: The borrower must have is_whitelisted = 1 on their BorrowerWhitelist
  // account. De-whitelisted borrowers (is_whitelisted = 0) are rejected even if
  // they still have residual borrow capacity (COAL-I02).

  // 4. Get borrower's token account
  const borrowerTokenAccount = await getAssociatedTokenAddress(market.mint, borrower);

  // 5. Build borrow instruction
  const borrowIx = createBorrowInstruction(
    {
      market: marketPda,
      borrower: borrower,
      borrowerTokenAccount: borrowerTokenAccount,
      vault: vaultPda,
      marketAuthority: marketAuthorityPda,
      borrowerWhitelist: whitelistPda,
      blacklistCheck: blacklistCheckPda,
      protocolConfig: configPda,
      tokenProgram: TOKEN_PROGRAM_ID,
    },
    { amount }
  );

  // 6. Send transaction
  const tx = new Transaction().add(borrowIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [borrowerKeypair]);

  console.log('Borrowed:', amount.toString(), 'from market:', marketPda.toBase58());
  return { signature };
}

// Usage: Borrow 50,000 USDC
await borrow(connection, borrowerKeypair, marketPda, 50_000_000_000n);

4. Repay Principal#

Anyone can repay principal to a market. The borrowerWhitelist account must reference the market's borrower, not the payer, because the on-chain program decrements the borrower's outstanding debt.

Typescript (55 lines)
import {
  findProtocolConfigPda,
  findVaultPda,
  findBorrowerWhitelistPda,
  createRepayInstruction,
  fetchMarket,
} from '@coalescefi/sdk';

async function repay(
  connection: Connection,
  payerKeypair: Keypair,
  marketPda: PublicKey,
  amount: bigint
) {
  const payer = payerKeypair.publicKey;

  // 1. Fetch market to get mint and borrower
  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  // 2. Derive required PDAs
  //    IMPORTANT: whitelist PDA uses market.borrower, not payer
  const [configPda] = findProtocolConfigPda();
  const [vaultPda] = findVaultPda(marketPda);
  const [whitelistPda] = findBorrowerWhitelistPda(market.borrower);

  // 3. Get payer's token account
  const payerTokenAccount = await getAssociatedTokenAddress(market.mint, payer);

  // 4. Build repay instruction
  const repayIx = createRepayInstruction(
    {
      market: marketPda,
      payer: payer,
      payerTokenAccount: payerTokenAccount,
      vault: vaultPda,
      protocolConfig: configPda,
      mint: market.mint,
      borrowerWhitelist: whitelistPda,
      tokenProgram: TOKEN_PROGRAM_ID,
    },
    { amount }
  );

  // 5. Send transaction
  const tx = new Transaction().add(repayIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [payerKeypair]);

  console.log('Repaid:', amount.toString(), 'to market:', marketPda.toBase58());
  return { signature };
}

// Usage: Repay 50,000 USDC principal
await repay(connection, payerKeypair, marketPda, 50_000_000_000n);

5. Repay Interest#

Interest is repaid separately from principal via a dedicated instruction.

Typescript (47 lines)
import {
  findProtocolConfigPda,
  findVaultPda,
  createRepayInterestInstruction,
  fetchMarket,
} from '@coalescefi/sdk';

async function repayInterest(
  connection: Connection,
  payerKeypair: Keypair,
  marketPda: PublicKey,
  amount: bigint
) {
  const payer = payerKeypair.publicKey;

  // 1. Fetch market
  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  // 2. Derive required PDAs
  const [configPda] = findProtocolConfigPda();
  const [vaultPda] = findVaultPda(marketPda);

  // 3. Get payer's token account
  const payerTokenAccount = await getAssociatedTokenAddress(market.mint, payer);

  // 4. Build repay interest instruction
  const repayInterestIx = createRepayInterestInstruction(
    {
      market: marketPda,
      payer: payer,
      payerTokenAccount: payerTokenAccount,
      vault: vaultPda,
      protocolConfig: configPda,
      tokenProgram: TOKEN_PROGRAM_ID,
    },
    { amount }
  );

  // 5. Send transaction
  const tx = new Transaction().add(repayInterestIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [payerKeypair]);

  console.log('Repaid interest:', amount.toString());
  return { signature };
}

5a. Waterfall Repay (Interest + Principal)#

The SDK provides createWaterfallRepayInstructions() to repay interest first, then principal, in a single transaction. This is the recommended approach when repaying both.

Typescript (50 lines)
import {
  findProtocolConfigPda,
  findVaultPda,
  findBorrowerWhitelistPda,
  createWaterfallRepayInstructions,
  fetchMarket,
} from '@coalescefi/sdk';

async function waterfallRepay(
  connection: Connection,
  payerKeypair: Keypair,
  marketPda: PublicKey,
  totalAmount: bigint,
  interestAmount: bigint
) {
  const payer = payerKeypair.publicKey;

  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  const [configPda] = findProtocolConfigPda();
  const [vaultPda] = findVaultPda(marketPda);
  const [whitelistPda] = findBorrowerWhitelistPda(market.borrower);
  const payerTokenAccount = await getAssociatedTokenAddress(market.mint, payer);

  // Returns 0-2 instructions: RepayInterest (if interest > 0), then Repay (if principal > 0)
  const instructions = createWaterfallRepayInstructions(
    {
      market: marketPda,
      payer: payer,
      payerTokenAccount: payerTokenAccount,
      vault: vaultPda,
      protocolConfig: configPda,
      mint: market.mint,
      borrowerWhitelist: whitelistPda,
      tokenProgram: TOKEN_PROGRAM_ID,
    },
    { totalAmount, interestAmount }
  );

  const tx = new Transaction().add(...instructions);
  const signature = await sendAndConfirmTransaction(connection, tx, [payerKeypair]);

  console.log('Waterfall repay complete:', signature);
  return { signature };
}

// Usage: Repay 5,000 USDC interest + 50,000 USDC principal
await waterfallRepay(connection, payerKeypair, marketPda, 55_000_000_000n, 5_000_000_000n);

6. Withdraw (Lender)#

Lenders withdraw funds after market maturity. The first settlement-triggering withdrawal must wait for the 5-minute grace period, then it triggers settlement and locks the settlement factor.

Typescript (94 lines)
import {
  findProtocolConfigPda,
  findMarketAuthorityPda,
  findVaultPda,
  findLenderPositionPda,
  findBlacklistCheckPda,
  findHaircutStatePda,
  createWithdrawInstruction,
  fetchProtocolConfig,
  fetchMarket,
  fetchLenderPosition,
  configFieldToPublicKey,
  WAD,
} from '@coalescefi/sdk';

async function withdraw(
  connection: Connection,
  lenderKeypair: Keypair,
  marketPda: PublicKey,
  minPayout?: bigint
) {
  const lender = lenderKeypair.publicKey;

  // 1. Fetch market and position
  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  const [positionPda] = findLenderPositionPda(marketPda, lender);
  const position = await fetchLenderPosition(connection, positionPda);
  if (!position) throw new Error('Position not found');

  // 2. Verify market is mature
  const now = BigInt(Math.floor(Date.now() / 1000));
  if (now < market.maturityTimestamp) {
    throw new Error('Market not yet matured');
  }

  // 3. Estimate expected payout
  const normalizedValue = (position.scaledBalance * market.scaleFactor) / WAD;
  if (market.settlementFactorWad > 0n) {
    const expectedPayout = (normalizedValue * market.settlementFactorWad) / WAD;
    console.log('Expected payout:', expectedPayout.toString());
  } else {
    console.log('Settlement factor not locked yet; first withdraw call will set it.');
  }

  // 4. Fetch config for blacklist program
  const [configPda] = findProtocolConfigPda();
  const config = await fetchProtocolConfig(connection, configPda);
  if (!config) throw new Error('Protocol not initialized');
  const blacklistProgram = configFieldToPublicKey(config.blacklistProgram);

  // 5. Derive required PDAs
  const [marketAuthorityPda] = findMarketAuthorityPda(marketPda);
  const [vaultPda] = findVaultPda(marketPda);
  const [blacklistCheckPda] = findBlacklistCheckPda(lender, blacklistProgram);
  const [haircutStatePda] = findHaircutStatePda(marketPda);

  // 6. Get lender's token account
  const lenderTokenAccount = await getAssociatedTokenAddress(market.mint, lender);

  // 7. Build withdraw instruction
  const withdrawIx = createWithdrawInstruction(
    {
      market: marketPda,
      lender: lender,
      lenderTokenAccount: lenderTokenAccount,
      vault: vaultPda,
      lenderPosition: positionPda,
      marketAuthority: marketAuthorityPda,
      blacklistCheck: blacklistCheckPda,
      protocolConfig: configPda,
      tokenProgram: TOKEN_PROGRAM_ID,
      haircutState: haircutStatePda,
    },
    {
      scaledAmount: 0n, // 0n = withdraw full position
      minPayout: minPayout, // Optional: revert if payout below this
    }
  );

  // 8. Send transaction
  const tx = new Transaction().add(withdrawIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [lenderKeypair]);

  console.log('Withdrawn from market:', marketPda.toBase58());
  return { signature };
}

// Usage: Withdraw with 1% slippage tolerance
const expectedPayout = 1_050_000_000n; // ~1,050 USDC
const minPayout = (expectedPayout * 99n) / 100n;
await withdraw(connection, lenderKeypair, marketPda, minPayout);

7. Re-Settle (Anyone)#

Update settlement factor if borrower makes late repayments. This is permissionless — anyone can call it.

Typescript (34 lines)
import {
  findProtocolConfigPda,
  findVaultPda,
  findHaircutStatePda,
  createReSettleInstruction,
  fetchMarket,
} from '@coalescefi/sdk';

async function reSettle(connection: Connection, payerKeypair: Keypair, marketPda: PublicKey) {
  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  if (market.settlementFactorWad === 0n) {
    throw new Error('Market not yet settled');
  }

  const [configPda] = findProtocolConfigPda();
  const [vaultPda] = findVaultPda(marketPda);
  const [haircutStatePda] = findHaircutStatePda(marketPda);

  const reSettleIx = createReSettleInstruction({
    market: marketPda,
    vault: vaultPda,
    protocolConfig: configPda,
    haircutState: haircutStatePda,
  });

  const tx = new Transaction().add(reSettleIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [payerKeypair]);

  console.log('Re-settled market:', marketPda.toBase58());
  return { signature };
}

8. Collect Fees (Fee Authority)#

Fee authority collects accrued protocol fees from a market.

Typescript (42 lines)
import {
  findProtocolConfigPda,
  findMarketAuthorityPda,
  findVaultPda,
  createCollectFeesInstruction,
  fetchMarket,
} from '@coalescefi/sdk';

async function collectFees(
  connection: Connection,
  feeAuthorityKeypair: Keypair,
  marketPda: PublicKey
) {
  const feeAuthority = feeAuthorityKeypair.publicKey;

  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  const [configPda] = findProtocolConfigPda();
  const [marketAuthorityPda] = findMarketAuthorityPda(marketPda);
  const [vaultPda] = findVaultPda(marketPda);

  // Fee destination token account must be owned by the fee authority
  const feeTokenAccount = await getAssociatedTokenAddress(market.mint, feeAuthority);

  const collectFeesIx = createCollectFeesInstruction({
    market: marketPda,
    protocolConfig: configPda,
    feeAuthority: feeAuthority,
    feeTokenAccount: feeTokenAccount,
    vault: vaultPda,
    marketAuthority: marketAuthorityPda,
    tokenProgram: TOKEN_PROGRAM_ID,
  });

  const tx = new Transaction().add(collectFeesIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [feeAuthorityKeypair]);

  console.log('Collected fees from market:', marketPda.toBase58());
  return { signature };
}

9. Close Lender Position (Lender)#

Close an empty position account to reclaim rent.

Typescript (39 lines)
import {
  findProtocolConfigPda,
  findLenderPositionPda,
  createCloseLenderPositionInstruction,
  fetchLenderPosition,
} from '@coalescefi/sdk';
import { SystemProgram } from '@solana/web3.js';

async function closeLenderPosition(
  connection: Connection,
  lenderKeypair: Keypair,
  marketPda: PublicKey
) {
  const lender = lenderKeypair.publicKey;
  const [positionPda] = findLenderPositionPda(marketPda, lender);
  const [configPda] = findProtocolConfigPda();

  // Verify position is empty
  const position = await fetchLenderPosition(connection, positionPda);
  if (!position) throw new Error('Position not found');
  if (position.scaledBalance > 0n) {
    throw new Error('Position still has balance, withdraw first');
  }

  const closeIx = createCloseLenderPositionInstruction({
    market: marketPda,
    lenderPosition: positionPda,
    lender: lender,
    systemProgram: SystemProgram.programId,
    protocolConfig: configPda,
  });

  const tx = new Transaction().add(closeIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [lenderKeypair]);

  console.log('Closed position, rent returned');
  return { signature };
}

10. Force Close Position (Borrower)#

After maturity plus a grace period, the market's borrower can force-close abandoned or dust lender positions. This is necessary when withdraw_excess is blocked because scaledTotalSupply > 0 due to lost wallets, blacklisted lenders, or dust deposits that will never be voluntarily withdrawn (COAL-M02).

The instruction computes the lender's owed payout, transfers it to a validated escrow token account owned by the lender, zeros the position, and decrements scaledTotalSupply.

Typescript (52 lines)
import {
  findMarketAuthorityPda,
  findVaultPda,
  findProtocolConfigPda,
  findHaircutStatePda,
  createForceClosePositionInstruction,
  fetchMarket,
} from '@coalescefi/sdk';

async function forceClosePosition(
  connection: Connection,
  borrowerKeypair: Keypair,
  marketPda: PublicKey,
  lenderPositionPda: PublicKey,
  escrowTokenAccount: PublicKey // Lender's token account (must be owned by lender)
) {
  const borrower = borrowerKeypair.publicKey;

  // 1. Fetch market and verify borrower
  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');
  if (!market.borrower.equals(borrower)) {
    throw new Error('Only the market borrower can force-close positions');
  }

  // 2. Derive required PDAs
  const [configPda] = findProtocolConfigPda();
  const [marketAuthorityPda] = findMarketAuthorityPda(marketPda);
  const [vaultPda] = findVaultPda(marketPda);
  const [haircutStatePda] = findHaircutStatePda(marketPda);

  // 3. Build force_close_position instruction
  const forceCloseIx = createForceClosePositionInstruction({
    market: marketPda,
    borrower: borrower,
    lenderPosition: lenderPositionPda,
    vault: vaultPda,
    escrowTokenAccount: escrowTokenAccount,
    marketAuthority: marketAuthorityPda,
    protocolConfig: configPda,
    tokenProgram: TOKEN_PROGRAM_ID,
    haircutState: haircutStatePda,
  });

  // 4. Send transaction
  const tx = new Transaction().add(forceCloseIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [borrowerKeypair]);

  console.log('Force-closed position, payout sent to escrow');
  return { signature };
}

Important: The escrowTokenAccount must be an SPL token account owned by the lender (the position holder), not the borrower. The on-chain program validates this to prevent fund redirection. If no settlement has occurred yet (first position closure), the instruction computes the settlement factor inline.

11. Claim Haircut (Lender)#

After a distressed settlement (shortfall), if the borrower later repays additional funds, lenders who were shorted can claim their haircut recovery. The haircutOwed field on the lender position tracks how much each lender is owed.

Typescript (49 lines)
import {
  findProtocolConfigPda,
  findMarketAuthorityPda,
  findVaultPda,
  findLenderPositionPda,
  findHaircutStatePda,
  createClaimHaircutInstruction,
  fetchLenderPosition,
  fetchMarket,
} from '@coalescefi/sdk';

async function claimHaircut(connection: Connection, lenderKeypair: Keypair, marketPda: PublicKey) {
  const lender = lenderKeypair.publicKey;

  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  const [positionPda] = findLenderPositionPda(marketPda, lender);
  const position = await fetchLenderPosition(connection, positionPda);
  if (!position) throw new Error('Position not found');
  if (position.haircutOwed === 0n) {
    throw new Error('No haircut owed to this lender');
  }

  const [configPda] = findProtocolConfigPda();
  const [marketAuthorityPda] = findMarketAuthorityPda(marketPda);
  const [vaultPda] = findVaultPda(marketPda);
  const [haircutStatePda] = findHaircutStatePda(marketPda);
  const lenderTokenAccount = await getAssociatedTokenAddress(market.mint, lender);

  const claimIx = createClaimHaircutInstruction({
    market: marketPda,
    lender,
    lenderPosition: positionPda,
    lenderTokenAccount,
    vault: vaultPda,
    marketAuthority: marketAuthorityPda,
    haircutState: haircutStatePda,
    protocolConfig: configPda,
    tokenProgram: TOKEN_PROGRAM_ID,
  });

  const tx = new Transaction().add(claimIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [lenderKeypair]);

  console.log('Claimed haircut recovery:', signature);
  return { signature };
}

12. Force Claim Haircut (Borrower)#

The borrower can force-claim haircut recovery on behalf of an unresponsive lender, sending the tokens to a validated escrow account owned by the lender.

Typescript (46 lines)
import {
  findProtocolConfigPda,
  findMarketAuthorityPda,
  findVaultPda,
  findLenderPositionPda,
  findHaircutStatePda,
  createForceClaimHaircutInstruction,
  fetchMarket,
} from '@coalescefi/sdk';

async function forceClaimHaircut(
  connection: Connection,
  borrowerKeypair: Keypair,
  marketPda: PublicKey,
  lenderPositionPda: PublicKey,
  escrowTokenAccount: PublicKey
) {
  const borrower = borrowerKeypair.publicKey;

  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  const [configPda] = findProtocolConfigPda();
  const [marketAuthorityPda] = findMarketAuthorityPda(marketPda);
  const [vaultPda] = findVaultPda(marketPda);
  const [haircutStatePda] = findHaircutStatePda(marketPda);

  const forceClaimIx = createForceClaimHaircutInstruction({
    market: marketPda,
    borrower,
    lenderPosition: lenderPositionPda,
    escrowTokenAccount,
    vault: vaultPda,
    marketAuthority: marketAuthorityPda,
    haircutState: haircutStatePda,
    protocolConfig: configPda,
    tokenProgram: TOKEN_PROGRAM_ID,
  });

  const tx = new Transaction().add(forceClaimIx);
  const signature = await sendAndConfirmTransaction(connection, tx, [borrowerKeypair]);

  console.log('Force-claimed haircut for lender:', signature);
  return { signature };
}

Instruction Reference#

All low-level instruction builders exported by the SDK:

DiscriminatorInstructionBuilder Function
0InitializeProtocolcreateInitializeProtocolInstruction
1SetFeeConfigcreateSetFeeConfigInstruction
2CreateMarketcreateCreateMarketInstruction
3DepositcreateDepositInstruction
4BorrowcreateBorrowInstruction
5RepaycreateRepayInstruction
6RepayInterestcreateRepayInterestInstruction
7WithdrawcreateWithdrawInstruction
8CollectFeescreateCollectFeesInstruction
9ReSettlecreateReSettleInstruction
10CloseLenderPositioncreateCloseLenderPositionInstruction
11WithdrawExcesscreateWithdrawExcessInstruction
12SetBorrowerWhitelistcreateSetBorrowerWhitelistInstruction
13SetPausecreateSetPauseInstruction
14SetBlacklistModecreateSetBlacklistModeInstruction
15SetAdmincreateSetAdminInstruction
16SetWhitelistManagercreateSetWhitelistManagerInstruction
18ForceClosePositioncreateForceClosePositionInstruction
19ClaimHaircutcreateClaimHaircutInstruction
20ForceClaimHaircutcreateForceClaimHaircutInstruction

Combined Builders (Multi-Instruction Helpers)#

These helpers return TransactionInstruction[] combining multiple instructions for common workflows:

HelperDescription
createWaterfallRepayInstructions(accounts, args)Returns 0-2 instructions: RepayInterest (if interest > 0) then Repay (if principal > 0). Recommended for repaying both in a single transaction.

The CoalesceClient provides additional combined methods that handle account resolution internally:

Client MethodInstructions Combined
client.withdrawAndClose(lender, market)Withdraw (full balance) + CloseLenderPosition
client.claimHaircutAndClose(lender, market)ClaimHaircut + CloseLenderPosition
client.repay(payer, market, total, interest)Uses createWaterfallRepayInstructions internally

Reading Data#

Fetch Helpers#

The SDK provides fetch helpers with built-in retry logic (exponential backoff for network errors and rate limits):

Typescript (24 lines)
import {
  fetchProtocolConfig,
  fetchMarket,
  fetchLenderPosition,
  fetchBorrowerWhitelist,
  findProtocolConfigPda,
  findMarketPda,
  findLenderPositionPda,
  findBorrowerWhitelistPda,
} from '@coalescefi/sdk';

// Each returns the decoded account or null if not found
const [configPda] = findProtocolConfigPda();
const config = await fetchProtocolConfig(connection, configPda);

const [marketPda] = findMarketPda(borrower, nonce);
const market = await fetchMarket(connection, marketPda);

const [positionPda] = findLenderPositionPda(marketPda, lender);
const position = await fetchLenderPosition(connection, positionPda);

const [whitelistPda] = findBorrowerWhitelistPda(borrower);
const whitelist = await fetchBorrowerWhitelist(connection, whitelistPda);

Optional retry configuration:

const market = await fetchMarket(connection, marketPda, {
  maxRetries: 5,
  baseDelayMs: 500,
  maxDelayMs: 15000,
});

Batch Reading#

Typescript (16 lines)
import { decodeMarket } from '@coalescefi/sdk';

async function getMultipleMarkets(connection: Connection, marketPdas: PublicKey[]) {
  const accountInfos = await connection.getMultipleAccountsInfo(marketPdas);

  return accountInfos
    .map((info, index) => {
      if (!info) return null;
      return {
        address: marketPdas[index],
        data: decodeMarket(info.data),
      };
    })
    .filter(Boolean);
}

Market with Position#

Typescript (20 lines)
import { findLenderPositionPda, decodeMarket, decodeLenderPosition } from '@coalescefi/sdk';

async function getMarketWithPosition(
  connection: Connection,
  marketPda: PublicKey,
  lender: PublicKey
) {
  const [positionPda] = findLenderPositionPda(marketPda, lender);

  const [marketInfo, positionInfo] = await connection.getMultipleAccountsInfo([
    marketPda,
    positionPda,
  ]);

  const market = marketInfo ? decodeMarket(marketInfo.data) : null;
  const position = positionInfo ? decodeLenderPosition(positionInfo.data) : null;

  return { market, position };
}

Market Statistics#

Calculate key metrics using market data and the SDK's WAD constant:

Typescript (39 lines)
import { WAD, fetchMarket } from '@coalescefi/sdk';

async function getMarketStats(connection: Connection, marketPda: PublicKey) {
  const market = await fetchMarket(connection, marketPda);
  if (!market) return null;

  const now = BigInt(Math.floor(Date.now() / 1000));
  const isMatured = now >= market.maturityTimestamp;

  // Current total supply (normalized from scaled)
  const totalSupply = (market.scaledTotalSupply * market.scaleFactor) / WAD;

  // Outstanding borrowed principal (excludes interest repayments)
  const principalRepaid =
    market.totalRepaid > market.totalInterestRepaid
      ? market.totalRepaid - market.totalInterestRepaid
      : 0n;
  const outstandingBorrowed =
    market.totalBorrowed > principalRepaid ? market.totalBorrowed - principalRepaid : 0n;

  // Fill rate as percentage (raw principal vs cap, not interest-inflated supply)
  const fillRate =
    market.maxTotalSupply > 0n
      ? Number((market.totalDeposited * 10000n) / market.maxTotalSupply) / 100
      : 0;

  return {
    capacity: market.maxTotalSupply,
    totalSupply,
    outstandingBorrowed,
    apr: market.annualInterestBps,
    maturity: new Date(Number(market.maturityTimestamp) * 1000),
    isMatured,
    fillRate,
    scaleFactor: market.scaleFactor,
    settlementFactor: market.settlementFactorWad,
  };
}

Subscribing to Account Changes#

Typescript (27 lines)
import { decodeMarket, type Market } from '@coalescefi/sdk';

function subscribeToMarket(
  connection: Connection,
  marketPda: PublicKey,
  callback: (market: Market) => void
) {
  const subscriptionId = connection.onAccountChange(
    marketPda,
    (accountInfo) => {
      const market = decodeMarket(accountInfo.data);
      callback(market);
    },
    'confirmed'
  );

  return () => {
    connection.removeAccountChangeListener(subscriptionId);
  };
}

// Usage
const unsubscribe = subscribeToMarket(connection, marketPda, (market) => {
  console.log('Market updated, scale factor:', market.scaleFactor);
});
// Later: unsubscribe();

Finding All User Positions#

Typescript (24 lines)
import { findLenderPositionPda, decodeLenderPosition } from '@coalescefi/sdk';

async function findAllPositions(
  connection: Connection,
  lender: PublicKey,
  knownMarkets: PublicKey[]
) {
  const positionPdas = knownMarkets.map((market) => findLenderPositionPda(market, lender)[0]);

  const accountInfos = await connection.getMultipleAccountsInfo(positionPdas);

  const positions = [];
  for (let i = 0; i < accountInfos.length; i++) {
    if (accountInfos[i]) {
      positions.push({
        market: knownMarkets[i],
        position: decodeLenderPosition(accountInfos[i]!.data),
      });
    }
  }

  return positions;
}

Account Decoders#

Parse Market#

Typescript (19 lines)
import { decodeMarket } from '@coalescefi/sdk';

const marketAccount = await connection.getAccountInfo(marketPda);
const market = decodeMarket(marketAccount!.data);

console.log({
  borrower: market.borrower.toBase58(),
  mint: market.mint.toBase58(),
  maxTotalSupply: market.maxTotalSupply,
  totalDeposited: market.totalDeposited, // capacity tracker, not a lifetime counter
  totalBorrowed: market.totalBorrowed,
  annualInterestBps: market.annualInterestBps,
  maturityTimestamp: market.maturityTimestamp,
  scaleFactor: market.scaleFactor,
  settlementFactorWad: market.settlementFactorWad,
  haircutAccumulator: market.haircutAccumulator,
  marketNonce: market.marketNonce,
});

Parse Lender Position#

import { decodeLenderPosition } from '@coalescefi/sdk';

const positionAccount = await connection.getAccountInfo(positionPda);
const position = decodeLenderPosition(positionAccount!.data);

console.log({
  market: position.market.toBase58(),
  lender: position.lender.toBase58(),
  scaledBalance: position.scaledBalance,
  bump: position.bump,
  haircutOwed: position.haircutOwed, // Tokens owed from distressed settlement
  withdrawalSf: position.withdrawalSf, // Settlement factor at last withdrawal/claim
});

Parse Borrower Whitelist#

import { decodeBorrowerWhitelist } from '@coalescefi/sdk';

const whitelistAccount = await connection.getAccountInfo(whitelistPda);
const whitelist = decodeBorrowerWhitelist(whitelistAccount!.data);

console.log({
  borrower: whitelist.borrower.toBase58(),
  isWhitelisted: whitelist.isWhitelisted,
  maxBorrowCapacity: whitelist.maxBorrowCapacity,
  currentBorrowed: whitelist.currentBorrowed,
});

Parse Protocol Config#

Protocol config stores pubkey fields as raw Uint8Array. Use configFieldToPublicKey() to convert:

import { decodeProtocolConfig, configFieldToPublicKey } from '@coalescefi/sdk';

const configAccount = await connection.getAccountInfo(configPda);
const config = decodeProtocolConfig(configAccount!.data);

console.log({
  admin: configFieldToPublicKey(config.admin).toBase58(),
  isPaused: config.isPaused,
  feeRateBps: config.feeRateBps,
  feeAuthority: configFieldToPublicKey(config.feeAuthority).toBase58(),
  whitelistManager: configFieldToPublicKey(config.whitelistManager).toBase58(),
  blacklistProgram: configFieldToPublicKey(config.blacklistProgram).toBase58(),
  isBlacklistFailClosed: config.isBlacklistFailClosed,
});

Parse HaircutState#

Per-market aggregate haircut state used by the re-settle solver for haircut recovery calculations:

import { decodeHaircutState, findHaircutStatePda } from '@coalescefi/sdk';

const [haircutStatePda] = findHaircutStatePda(marketPda);
const haircutAccount = await connection.getAccountInfo(haircutStatePda);
const haircutState = decodeHaircutState(haircutAccount!.data);

console.log({
  market: haircutState.market.toBase58(),
  claimWeightSum: haircutState.claimWeightSum, // u128 — sum of per-position weight contributions
  claimOffsetSum: haircutState.claimOffsetSum, // u128 — sum of per-position offset contributions
  bump: haircutState.bump,
});

You can also use the generic decoder to auto-detect account type:

import { fetchHaircutState } from '@coalescefi/sdk';

const haircutState = await fetchHaircutState(connection, haircutStatePda);
if (haircutState) {
  console.log('Claim weight sum:', haircutState.claimWeightSum);
}

Squads Multisig Integration#

For institutional borrowers and DAOs, Coalesce Finance operations can be executed through Squads Protocol multisig wallets. The on-chain program only checks is_signer() and does not require a specific account type — multisig vaults work as borrowers, lenders, or admins.

Setup#

import * as multisig from '@sqds/multisig';
import { Connection, PublicKey, TransactionMessage, VersionedTransaction } from '@solana/web3.js';

// Your Squads multisig address
const MULTISIG_PDA = new PublicKey('YOUR_MULTISIG_PDA');

// Derive the vault PDA (this is the "signer" for your transactions)
const [vaultPda] = multisig.getVaultPda({
  multisigPda: MULTISIG_PDA,
  index: 0, // Usually vault index 0
});

console.log('Multisig Vault (use as borrower/lender):', vaultPda.toBase58());

Create Market via Multisig#

Typescript (135 lines)
import * as multisig from '@sqds/multisig';
import {
  configureSdk,
  findProtocolConfigPda,
  findMarketPda,
  findMarketAuthorityPda,
  findVaultPda as findCoalesceVaultPda,
  findBorrowerWhitelistPda,
  findBlacklistCheckPda,
  createCreateMarketInstruction,
  fetchProtocolConfig,
  fetchBorrowerWhitelist,
  configFieldToPublicKey,
} from '@coalescefi/sdk';
import { SystemProgram } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';

configureSdk({ network: 'mainnet' });

async function createMarketViaMultisig(
  connection: Connection,
  memberKeypair: Keypair,
  multisigPda: PublicKey,
  usdcMint: PublicKey,
  params: {
    maxTotalSupply: bigint;
    annualInterestBps: number;
    maturityTimestamp: bigint;
  }
) {
  // 1. Get multisig vault (this is the "borrower")
  const [vaultPda] = multisig.getVaultPda({
    multisigPda: multisigPda,
    index: 0,
  });

  // 2. Verify borrower whitelist exists
  const [whitelistPda] = findBorrowerWhitelistPda(vaultPda);
  const whitelist = await fetchBorrowerWhitelist(connection, whitelistPda);
  if (!whitelist || !whitelist.isWhitelisted) {
    throw new Error('Multisig vault is not whitelisted as borrower');
  }

  // 3. Fetch config for blacklist program
  const [configPda] = findProtocolConfigPda();
  const config = await fetchProtocolConfig(connection, configPda);
  if (!config) throw new Error('Protocol not initialized');
  const blacklistProgram = configFieldToPublicKey(config.blacklistProgram);

  // 4. Derive Coalesce Finance PDAs
  const marketNonce = BigInt(Date.now());
  const [marketPda] = findMarketPda(vaultPda, marketNonce);
  const [marketAuthorityPda] = findMarketAuthorityPda(marketPda);
  const [coalesceVaultPda] = findCoalesceVaultPda(marketPda);
  const [blacklistCheckPda] = findBlacklistCheckPda(vaultPda, blacklistProgram);

  // 5. Build the Coalesce Finance create market instruction
  const createMarketIx = createCreateMarketInstruction(
    {
      market: marketPda,
      borrower: vaultPda, // Multisig vault is the borrower
      mint: usdcMint,
      vault: coalesceVaultPda,
      marketAuthority: marketAuthorityPda,
      protocolConfig: configPda,
      borrowerWhitelist: whitelistPda,
      blacklistCheck: blacklistCheckPda,
      systemProgram: SystemProgram.programId,
      tokenProgram: TOKEN_PROGRAM_ID,
    },
    {
      marketNonce,
      annualInterestBps: params.annualInterestBps,
      maturityTimestamp: params.maturityTimestamp,
      maxTotalSupply: params.maxTotalSupply,
    }
  );

  // 6. Get current multisig state for transaction index
  const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress(
    connection,
    multisigPda
  );
  const transactionIndex = Number(multisigAccount.transactionIndex) + 1;

  // 7. Create vault transaction message
  const { blockhash } = await connection.getLatestBlockhash();

  const vaultTransactionMessage = new TransactionMessage({
    payerKey: vaultPda,
    recentBlockhash: blockhash,
    instructions: [createMarketIx],
  });

  // 8. Create the vault transaction (proposal)
  const createVaultTxIx = multisig.instructions.vaultTransactionCreate({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    creator: memberKeypair.publicKey,
    vaultIndex: 0,
    ephemeralSigners: 0,
    transactionMessage: vaultTransactionMessage,
  });

  // 9. Create proposal for the vault transaction
  const createProposalIx = multisig.instructions.proposalCreate({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    creator: memberKeypair.publicKey,
  });

  // 10. Auto-approve as creator
  const approveIx = multisig.instructions.proposalApprove({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    member: memberKeypair.publicKey,
  });

  // 11. Send proposal transaction
  const proposalTx = new VersionedTransaction(
    new TransactionMessage({
      payerKey: memberKeypair.publicKey,
      recentBlockhash: blockhash,
      instructions: [createVaultTxIx, createProposalIx, approveIx],
    }).compileToV0Message()
  );

  proposalTx.sign([memberKeypair]);
  const signature = await connection.sendTransaction(proposalTx);
  await connection.confirmTransaction(signature);

  console.log('Proposal created, transaction index:', transactionIndex);
  return { transactionIndex, marketPda, signature };
}

Approve Multisig Proposal#

Typescript (30 lines)
async function approveProposal(
  connection: Connection,
  memberKeypair: Keypair,
  multisigPda: PublicKey,
  transactionIndex: number
) {
  const approveIx = multisig.instructions.proposalApprove({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    member: memberKeypair.publicKey,
  });

  const { blockhash } = await connection.getLatestBlockhash();

  const tx = new VersionedTransaction(
    new TransactionMessage({
      payerKey: memberKeypair.publicKey,
      recentBlockhash: blockhash,
      instructions: [approveIx],
    }).compileToV0Message()
  );

  tx.sign([memberKeypair]);
  const signature = await connection.sendTransaction(tx);
  await connection.confirmTransaction(signature);

  console.log('Approved proposal:', transactionIndex);
  return { signature };
}

Execute Approved Proposal#

Typescript (42 lines)
async function executeProposal(
  connection: Connection,
  payerKeypair: Keypair, // Anyone can execute once approved
  multisigPda: PublicKey,
  transactionIndex: number
) {
  // 1. Check proposal is approved
  const [proposalPda] = multisig.getProposalPda({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
  });
  const proposal = await multisig.accounts.Proposal.fromAccountAddress(connection, proposalPda);

  if (proposal.status.__kind !== 'Approved') {
    throw new Error(`Proposal not approved. Status: ${proposal.status.__kind}`);
  }

  // 2. Execute the vault transaction
  const executeIx = multisig.instructions.vaultTransactionExecute({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    member: payerKeypair.publicKey,
  });

  const { blockhash } = await connection.getLatestBlockhash();

  const tx = new VersionedTransaction(
    new TransactionMessage({
      payerKey: payerKeypair.publicKey,
      recentBlockhash: blockhash,
      instructions: [executeIx],
    }).compileToV0Message()
  );

  tx.sign([payerKeypair]);
  const signature = await connection.sendTransaction(tx);
  await connection.confirmTransaction(signature);

  console.log('Executed proposal:', transactionIndex);
  return { signature };
}

Borrow via Multisig#

Typescript (99 lines)
async function borrowViaMultisig(
  connection: Connection,
  memberKeypair: Keypair,
  multisigPda: PublicKey,
  marketPda: PublicKey,
  amount: bigint
) {
  // 1. Get multisig vault
  const [vaultPda] = multisig.getVaultPda({
    multisigPda: multisigPda,
    index: 0,
  });

  // 2. Fetch market and config
  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  const [configPda] = findProtocolConfigPda();
  const config = await fetchProtocolConfig(connection, configPda);
  if (!config) throw new Error('Protocol not initialized');
  const blacklistProgram = configFieldToPublicKey(config.blacklistProgram);

  // 3. Derive PDAs
  const [marketAuthorityPda] = findMarketAuthorityPda(marketPda);
  const [coalesceVaultPda] = findCoalesceVaultPda(marketPda);
  const [whitelistPda] = findBorrowerWhitelistPda(vaultPda);
  const [blacklistCheckPda] = findBlacklistCheckPda(vaultPda, blacklistProgram);

  // 4. Get vault's token account (allowOwnerOffCurve=true for PDA owners)
  const vaultTokenAccount = await getAssociatedTokenAddress(market.mint, vaultPda, true);

  // 5. Build borrow instruction
  const borrowIx = createBorrowInstruction(
    {
      market: marketPda,
      borrower: vaultPda,
      borrowerTokenAccount: vaultTokenAccount,
      vault: coalesceVaultPda,
      marketAuthority: marketAuthorityPda,
      borrowerWhitelist: whitelistPda,
      blacklistCheck: blacklistCheckPda,
      protocolConfig: configPda,
      tokenProgram: TOKEN_PROGRAM_ID,
    },
    { amount }
  );

  // 6. Create proposal (same pattern as createMarketViaMultisig)
  const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress(
    connection,
    multisigPda
  );
  const transactionIndex = Number(multisigAccount.transactionIndex) + 1;

  const { blockhash } = await connection.getLatestBlockhash();

  const vaultTransactionMessage = new TransactionMessage({
    payerKey: vaultPda,
    recentBlockhash: blockhash,
    instructions: [borrowIx],
  });

  const createVaultTxIx = multisig.instructions.vaultTransactionCreate({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    creator: memberKeypair.publicKey,
    vaultIndex: 0,
    ephemeralSigners: 0,
    transactionMessage: vaultTransactionMessage,
  });

  const createProposalIx = multisig.instructions.proposalCreate({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    creator: memberKeypair.publicKey,
  });

  const approveIx = multisig.instructions.proposalApprove({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    member: memberKeypair.publicKey,
  });

  const proposalTx = new VersionedTransaction(
    new TransactionMessage({
      payerKey: memberKeypair.publicKey,
      recentBlockhash: blockhash,
      instructions: [createVaultTxIx, createProposalIx, approveIx],
    }).compileToV0Message()
  );

  proposalTx.sign([memberKeypair]);
  const signature = await connection.sendTransaction(proposalTx);
  await connection.confirmTransaction(signature);

  console.log('Borrow proposal created, index:', transactionIndex);
  return { transactionIndex, signature };
}

Repay via Multisig#

Typescript (94 lines)
async function repayViaMultisig(
  connection: Connection,
  memberKeypair: Keypair,
  multisigPda: PublicKey,
  marketPda: PublicKey,
  totalAmount: bigint,
  interestAmount: bigint
) {
  // 1. Get multisig vault
  const [vaultPda] = multisig.getVaultPda({
    multisigPda: multisigPda,
    index: 0,
  });

  // 2. Fetch market
  const market = await fetchMarket(connection, marketPda);
  if (!market) throw new Error('Market not found');

  // 3. Derive PDAs
  const [configPda] = findProtocolConfigPda();
  const [coalesceVaultPda] = findCoalesceVaultPda(marketPda);
  // IMPORTANT: whitelist PDA uses market.borrower, not payer
  const [whitelistPda] = findBorrowerWhitelistPda(market.borrower);

  // 4. Get vault's token account
  const vaultTokenAccount = await getAssociatedTokenAddress(market.mint, vaultPda, true);

  // 5. Build repay instructions using waterfall helper
  const instructions = createWaterfallRepayInstructions(
    {
      market: marketPda,
      payer: vaultPda,
      payerTokenAccount: vaultTokenAccount,
      vault: coalesceVaultPda,
      protocolConfig: configPda,
      mint: market.mint,
      borrowerWhitelist: whitelistPda,
      tokenProgram: TOKEN_PROGRAM_ID,
    },
    { totalAmount, interestAmount }
  );

  // 6. Create proposal
  const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress(
    connection,
    multisigPda
  );
  const transactionIndex = Number(multisigAccount.transactionIndex) + 1;

  const { blockhash } = await connection.getLatestBlockhash();

  const vaultTransactionMessage = new TransactionMessage({
    payerKey: vaultPda,
    recentBlockhash: blockhash,
    instructions: instructions,
  });

  const createVaultTxIx = multisig.instructions.vaultTransactionCreate({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    creator: memberKeypair.publicKey,
    vaultIndex: 0,
    ephemeralSigners: 0,
    transactionMessage: vaultTransactionMessage,
  });

  const createProposalIx = multisig.instructions.proposalCreate({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    creator: memberKeypair.publicKey,
  });

  const approveIx = multisig.instructions.proposalApprove({
    multisigPda: multisigPda,
    transactionIndex: BigInt(transactionIndex),
    member: memberKeypair.publicKey,
  });

  const proposalTx = new VersionedTransaction(
    new TransactionMessage({
      payerKey: memberKeypair.publicKey,
      recentBlockhash: blockhash,
      instructions: [createVaultTxIx, createProposalIx, approveIx],
    }).compileToV0Message()
  );

  proposalTx.sign([memberKeypair]);
  const signature = await connection.sendTransaction(proposalTx);
  await connection.confirmTransaction(signature);

  console.log('Repay proposal created, index:', transactionIndex);
  return { transactionIndex, signature };
}

Complete Multisig Workflow Example#

Typescript (67 lines)
// Full workflow for a DAO borrowing via Squads
async function daoLoanWorkflow() {
  const connection = new Connection('https://your-rpc.com');
  configureSdk({ network: 'mainnet' });
  const usdcMint = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');

  // DAO's Squads multisig
  const multisigPda = new PublicKey('YOUR_MULTISIG');

  // Member keypairs (in practice, each member signs independently)
  const member1 = Keypair.fromSecretKey(/* ... */);
  const member2 = Keypair.fromSecretKey(/* ... */);
  const member3 = Keypair.fromSecretKey(/* ... */);

  // Step 1: Create market (member1 proposes + auto-approves)
  const { transactionIndex: createTxIndex, marketPda } = await createMarketViaMultisig(
    connection,
    member1,
    multisigPda,
    usdcMint,
    {
      maxTotalSupply: 500_000_000_000n, // 500k USDC
      annualInterestBps: 850, // 8.5% APR
      maturityTimestamp: BigInt(Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60), // 90 days
    }
  );

  // Step 2: Other members approve
  await approveProposal(connection, member2, multisigPda, createTxIndex);
  await approveProposal(connection, member3, multisigPda, createTxIndex);

  // Step 3: Execute create market
  await executeProposal(connection, member1, multisigPda, createTxIndex);
  console.log('Market created:', marketPda.toBase58());

  // ... Wait for lender deposits ...

  // Step 4: Borrow funds
  const { transactionIndex: borrowTxIndex } = await borrowViaMultisig(
    connection,
    member1,
    multisigPda,
    marketPda,
    250_000_000_000n // 250k USDC
  );
  await approveProposal(connection, member2, multisigPda, borrowTxIndex);
  await approveProposal(connection, member3, multisigPda, borrowTxIndex);
  await executeProposal(connection, member1, multisigPda, borrowTxIndex);

  // ... Use funds, time passes ...

  // Step 5: Repay before maturity (interest-first, then principal)
  const { transactionIndex: repayTxIndex } = await repayViaMultisig(
    connection,
    member1,
    multisigPda,
    marketPda,
    255_312_500_000n, // Total: 250k principal + 5,312.50 interest
    5_312_500_000n // Interest portion
  );
  await approveProposal(connection, member2, multisigPda, repayTxIndex);
  await approveProposal(connection, member3, multisigPda, repayTxIndex);
  await executeProposal(connection, member1, multisigPda, repayTxIndex);

  console.log('Loan complete');
}

Error Handling#

Using the SDK Error Parser#

The SDK provides parseCoalescefiError() to extract structured error information from transaction failures:

Typescript (31 lines)
import {
  parseCoalescefiError,
  getErrorRecoveryAction,
  isUserRecoverableError,
  CoalescefiErrorCode,
} from '@coalescefi/sdk';
import { SendTransactionError } from '@solana/web3.js';

try {
  const sig = await sendAndConfirmTransaction(connection, tx, [signer]);
  console.log('Success:', sig);
} catch (error) {
  const coalesceError = parseCoalescefiError(error);

  if (coalesceError) {
    console.error(`Error ${coalesceError.code}: ${coalesceError.codeName}`);
    console.error('Message:', coalesceError.message);

    // Check if user can recover
    if (isUserRecoverableError(coalesceError.code)) {
      const action = getErrorRecoveryAction(coalesceError.code);
      console.log('Recovery action:', action);
    }
  } else if (error instanceof SendTransactionError) {
    console.error('Transaction failed (non-program error)');
    console.error('Logs:', error.logs);
  } else {
    throw error;
  }
}

Error Codes Reference#

CodeNameDescriptionRecoverable
0AlreadyInitializedProtocol already initializedNo
1InvalidFeeRateFee rate exceeds 10,000 bpsYes
2InvalidCapacityMax total supply is zeroYes
3InvalidMaturityMaturity timestamp in the pastYes
5UnauthorizedSigner doesn't match expected authorityNo
6NotWhitelistedBorrower not whitelistedNo
7BlacklistedAddress is blacklistedNo
8ProtocolPausedProtocol is pausedYes (wait)
17ZeroAmountAmount must be greater than zeroYes
21InsufficientBalanceNot enough tokens in source accountYes
25CapExceededDeposit would exceed market capacityYes
26BorrowAmountTooHighBorrow exceeds available vault fundsYes
27GlobalCapacityExceededBorrow would exceed borrower whitelist capacityYes
28MarketMaturedCannot deposit/borrow after maturityNo
29NotMaturedCannot withdraw before maturityYes (wait)
32SettlementGracePeriodWait 5 minutes after maturity before first settlement-triggering withdrawal or force-closeYes (wait)
34PositionNotEmptyCannot close position with balance > 0Yes
35RepaymentExceedsDebtRepay amount exceeds outstanding debtYes
41MathOverflowArithmetic overflowNo
42PayoutBelowMinimumWithdrawal payout below minPayoutYes

For the complete list of all 43 error codes, see CoalescefiErrorCode in the SDK.

Quick Pattern Matching#

Typescript (22 lines)
try {
  const tx = new Transaction().add(depositIx);
  await sendAndConfirmTransaction(connection, tx, [wallet]);
} catch (error) {
  const parsed = parseCoalescefiError(error);
  if (!parsed) throw error;

  switch (parsed.code) {
    case CoalescefiErrorCode.MarketMatured:
      console.error('Cannot deposit: market has matured');
      break;
    case CoalescefiErrorCode.CapExceeded:
      console.error('Cannot deposit: market capacity reached');
      break;
    case CoalescefiErrorCode.ProtocolPaused:
      console.error('Cannot deposit: protocol is paused');
      break;
    default:
      throw error;
  }
}

Math Reference#

The SDK includes all protocol math functions using WAD precision (10^18) for lossless fixed-point arithmetic. These functions match the on-chain Rust implementation exactly.

Constants#

import { WAD, BPS, SECONDS_PER_YEAR } from '@coalescefi/sdk';

// WAD = 10n ** 18n           (1,000,000,000,000,000,000)
// BPS = 10000n               (basis points denominator)
// SECONDS_PER_YEAR = 31536000n

Converting Between Scaled and Token Amounts#

The protocol tracks lender positions as "scaled shares" rather than raw token amounts. The scale factor grows over time as interest accrues.

import { calculateScaledAmount, calculateNormalizedAmount } from '@coalescefi/sdk';

// Token amount → scaled shares (what you receive when depositing)
// Equivalent to: (tokenAmount * WAD) / scaleFactor
const shares = calculateScaledAmount(depositAmount, market.scaleFactor);

// Scaled shares → token amount (what the position is worth)
// Equivalent to: (scaledBalance * scaleFactor) / WAD
const tokenValue = calculateNormalizedAmount(position.scaledBalance, market.scaleFactor);

Settlement Payout#

After maturity, the settlement factor determines what fraction of their position each lender receives:

import { calculateSettlementPayout } from '@coalescefi/sdk';

// Calculate payout for a lender position after settlement
// Two-step: normalize by scale_factor, then apply settlement_factor
const payout = calculateSettlementPayout(
  position.scaledBalance,
  market.scaleFactor,
  market.settlementFactorWad
);

// settlementFactorWad == WAD means full repayment (100%)
// settlementFactorWad < WAD means partial repayment (shortfall)

Interest Accrual Estimation#

Estimate future interest growth for display purposes. This uses the same daily-compounding + linear sub-day remainder model as the on-chain program:

import { estimateInterestAccrual } from '@coalescefi/sdk';
import type { Market } from '@coalescefi/sdk';

const market: Market = await fetchMarket(connection, marketPda);
const now = BigInt(Math.floor(Date.now() / 1000));

const { newScaleFactor, interestDelta, cappedTimestamp } = estimateInterestAccrual(market, now);

// newScaleFactor: estimated scale factor after accrual
// interestDelta: difference from current scale factor
// cappedTimestamp: accrual stops at maturity

Position Value and Maturity Estimates#

Typescript (18 lines)
import { calculatePositionValue, estimateValueAtMaturity, calculateAPR } from '@coalescefi/sdk';
import type { Market, LenderPosition } from '@coalescefi/sdk';

// Current position value
const currentValue = calculatePositionValue(position.scaledBalance, market.scaleFactor);

// Estimated value at maturity (with compounded interest)
const maturityValue = estimateValueAtMaturity(
  position.scaledBalance,
  market.scaleFactor,
  market.annualInterestBps,
  BigInt(Math.floor(Date.now() / 1000)),
  market.maturityTimestamp
);

// APR as decimal (e.g., 0.10 for 10%)
const apr = calculateAPR(market.annualInterestBps);

Market Utilization#

import { calculateUtilizationRate, calculateTotalSupply } from '@coalescefi/sdk';

// Utilization rate with full BPS precision
const { bps, decimal } = calculateUtilizationRate(market);
console.log(`Utilization: ${(decimal * 100).toFixed(2)}%`);

// Total supply in normalized token terms
const totalSupply = calculateTotalSupply(market.scaledTotalSupply, market.scaleFactor);

// Available vault balance — read from the vault token account via RPC
// (cannot be reliably reconstructed from market counters alone)
const vaultAccount = await connection.getTokenAccountBalance(vaultPda);
const available = BigInt(vaultAccount.value.amount);

Requirements#

  • Node.js >= 22
  • TypeScript >= 5.0 (recommended, with ES2020 target or later)
  • @solana/web3.js ^1.91.0
  • @solana/spl-token (for getAssociatedTokenAddress)
  • @sqds/multisig (for multisig operations, optional)