Project 0
TypeScript SDK

Advanced Usage

Deep dive into the SDK's layered architecture and low-level APIs

Advanced Usage

This guide explains the different levels of abstraction in the p0-ts-sdk, from high-level convenience methods down to raw program instructions. Understanding this architecture helps you choose the right level of integration for your use case.

SDK Architecture Layers

The SDK is built in layers, each providing different trade-offs between convenience and control:

┌─────────────────────────────────────────────────────────┐
│  Layer 4: MarginfiAccountWrapper (Highest Level)        │
│  - Methods: makeDepositTx(), makeBorrowTx(), etc.       │
│  - ✅ Recommended for most developers                   │
│  - Convenience: Auto-fills params from account/client   │
│  - Calls Layer 3 transaction builders internally        │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  Layer 3: Transaction Builders (Core Logic)             │
│  - Functions: makeBorrowTx(), makeDepositTx(), etc.     │
│  - Does ALL optimizations: oracle cranks, LUTs, Kamino  │
│  - Returns complete transactions, multi-tx handling     │
│  - This is where the magic happens                      │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  Layer 2: Instruction Builders                          │
│  - Functions: makeBorrowIx(), makeDepositIx(), etc.     │
│  - Returns raw instructions                             │
│  - Auto-derives accounts, easier params                 │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  Layer 1: Raw Instructions (Lowest Level)               │
│  - instructions.ts (Anchor-based)                       │
│  - sync-instructions.ts (Hardcoded discriminators)      │
│  - ⚠️ Proceed with caution, more maintenance needed on  │
│    program upgrades                                     │
└─────────────────────────────────────────────────────────┘

When to use: Default choice for 95% of use cases.

This is the highest-level API and provides the cleanest developer experience. The wrapper is a convenience layer that:

  1. Auto-fills parameters from the account and client context
  2. Calls Layer 3 transaction builders with those params
  3. Returns the fully optimized transactions
import { MarginfiAccountWrapper } from "p0-ts-sdk";

// Simple API - you just provide amount and bank
const borrowResult = await wrappedAccount.makeBorrowTx("100", bankAddress);

// Behind the scenes, it calls makeBorrowTx() from Layer 3 with:
// - marginfiAccount (from wrapper)
// - authority (from account)
// - bankMap (from client)
// - oraclePrices (from client)
// - program (from client)
// - connection (from client)
// - luts (from client)
// - bankMetadataMap (from client)
// ... and all the other params you'd need to provide manually

// Just sign and send the optimized transactions
for (const tx of borrowResult.transactions) {
  tx.feePayer = wallet.publicKey;
  const sig = await wallet.sendTransaction(tx, connection);
  await connection.confirmTransaction(sig);
}

Benefits:

  • Minimal code - You only specify amount and bank
  • Auto-filled params - Pulls data from account/client context
  • Same optimizations - Gets all Layer 3 benefits (oracle cranks, LUTs, etc.)
  • Type-safe - Full TypeScript support
  • Clean API - No need to pass around maps and metadata

All available methods:

  • makeDepositTx() - Deposit assets
  • makeBorrowTx() - Borrow assets
  • makeWithdrawTx() - Withdraw collateral
  • makeRepayTx() - Repay borrows
  • makeLoopTx() - Leverage positions
  • makeFlashLoanTx() - Execute flash loans
  • makeSwapCollateralTx() - Swap collateral

Layer 3: Transaction Builders (The Core Logic)

When to use: Building custom transaction flows or composing multiple operations.

This is where all the heavy lifting happens. Transaction builders are the core of the SDK - they handle oracle cranking, Kamino refresh, LUTs, multi-transaction optimization, and more. Layer 4 (wrapper) simply calls these functions with auto-filled parameters.

When you use the wrapper or call these directly, you get the same fully-optimized transactions:

import { makeBorrowTx } from "p0-ts-sdk";

const borrowResult = await makeBorrowTx({
  marginfiAccount: accountData,
  authority: wallet.publicKey,
  bank: usdcBank,
  amount: BigInt(100_000_000), // 100 USDC (with decimals)
  bankMap: new Map(client.banks.map((b) => [b.address.toBase58(), b])),
  oraclePrices: client.oraclePriceByBank,
  program: client.program,
  connection: connection,
  luts: client.addressLookupTables,
  bankMetadataMap: client.bankIntegrationMap,
  crossbarUrl: "https://crossbar.switchboard.xyz", // optional
});

// borrowResult contains:
// - transactions: ExtendedV0Transaction[]
// - actionTxIndex: number (which tx has the main action)

What transaction builders handle:

1. Oracle Cranking

Oracle prices need to be refreshed before certain operations. The SDK automatically:

// Automatically included in borrowResult.transactions[0]
const { instructions: updateFeedIxs, luts: feedLuts } =
  await makeSmartCrankSwbFeedIx({
    marginfiAccount: params.marginfiAccount,
    bankMap: params.bankMap,
    oraclePrices: params.oraclePrices,
    instructions: borrowIxs.instructions,
    program: params.program,
    connection: params.connection,
    crossbarUrl: params.crossbarUrl,
  });
  • Detects stale oracles - Only cranks when necessary
  • Separate transaction - Oracle cranks go in transaction 0
  • Main action - Your borrow/deposit goes in transaction 1
  • Supports Switchboard and Pyth - Automatic detection

2. Kamino Refresh Instructions

When interacting with Kamino-integrated banks, the SDK automatically adds refresh instructions:

const refreshIxs = makeRefreshKaminoBanksIxs(
  params.marginfiAccount,
  params.bankMap,
  [borrowParams.bank.address], // Banks to refresh
  params.bankMetadataMap
);

// Prepended to the transaction automatically

This ensures Kamino reserves are up-to-date before your operation.

3. Address Lookup Tables (LUTs)

Transactions use LUTs to compress account references and fit more operations:

// Automatically merged from multiple sources:
// - Client-level LUTs (protocol accounts)
// - Oracle feed LUTs (Switchboard/Pyth)
// - Bank-specific LUTs
const allLuts = [...luts, ...feedLuts];

// Applied to all transactions
const tx = new VersionedTransaction(
  new TransactionMessage({
    instructions: [...],
    payerKey: authority,
    recentBlockhash: blockhash,
  }).compileToV0Message(allLuts)
);

4. Multi-Transaction Optimization

Some operations require multiple transactions. The SDK handles this intelligently:

// Example: Borrow might need 2 transactions
// Transaction 0: Oracle crank
// Transaction 1: Actual borrow

console.log(`Transactions: ${borrowResult.transactions.length}`);
console.log(`Main action at index: ${borrowResult.actionTxIndex}`);

// You send them in order
for (const tx of borrowResult.transactions) {
  const sig = await wallet.sendTransaction(tx, connection);
  await connection.confirmTransaction(sig);
}

Available transaction builders:

// All located in ~/services/account/actions/
import {
  makeDepositTx,
  makeBorrowTx,
  makeWithdrawTx,
  makeRepayTx,
  makeRepayWithCollateralTx,
  makeLoopTx,
  makeFlashLoanTx,
  makeSwapCollateralTx,
} from "p0-ts-sdk";

Layer 2: Instruction Builders

When to use: Building highly custom transactions or composing instructions manually.

Instruction builders return raw Solana instructions without packing them into transactions. You're responsible for:

  • Adding oracle cranks
  • Managing LUTs
  • Splitting into multiple transactions if needed
import { makeBorrowIx, makeDepositIx } from "p0-ts-sdk";

// Returns InstructionsWrapper with instructions array
const borrowIxs = await makeBorrowIx({
  marginfiAccount: accountData,
  authority: wallet.publicKey,
  bank: usdcBank,
  amount: BigInt(100_000_000),
  bankMap: new Map(client.banks.map((b) => [b.address.toBase58(), b])),
  program: client.program,
  // Note: No connection, luts, or oracle handling
});

// borrowIxs.instructions = TransactionInstruction[]
// borrowIxs.keys = Signer[] (if any additional signers needed)

// You manually build the transaction
const tx = new Transaction().add(...borrowIxs.instructions);
// You must handle: oracle cranks, LUTs, Kamino refresh, etc.

Benefits of instruction builders:

  • Auto-derives accounts - No manual PDA derivation needed
  • Simpler params - Takes Bank objects instead of addresses
  • Type-safe - Proper TypeScript types
  • Remaining accounts - Automatically includes all required accounts

Example: Kamino-specific deposit

import { makeKaminoDepositIx } from "p0-ts-sdk";

// Special instruction for depositing into Kamino vaults
const kaminoDepositIxs = await makeKaminoDepositIx({
  program: client.program,
  marginfiAccount: accountData,
  marginfiGroup: client.group,
  bank: kaminoBank,
  authority: wallet.publicKey,
  amount: BigInt(50_000_000),
  bankIntegrationMetadata: client.bankIntegrationMap,
});

// Returns instructions ready to be added to a transaction

Available instruction builders:

// Standard operations
import {
  makeBorrowIx,
  makeDepositIx,
  makeWithdrawIx,
  makeRepayIx,
} from "p0-ts-sdk";

// Integration-specific
import { makeKaminoDepositIx, makeKaminoWithdrawIx } from "p0-ts-sdk";

When you might use this layer:

  1. Custom transaction composition - Combining multiple protocol interactions
  2. Cross-program composability - Integrating with other Solana programs
  3. Batch operations - Packing multiple account operations
  4. Special transaction requirements - Custom fee payers, priority fees, etc.

When to use: Almost never. Only for very specialized low-level work.

We strongly advise against using raw instructions. They interact directly with the program IDL and use hardcoded discriminators. When the program updates, these might break with no backward compatibility guarantee.

Anchor-Based Instructions (instructions.ts)

Uses the Anchor client to build instructions:

import { instructions } from "p0-ts-sdk";

// Requires manual account derivation and exact program structure knowledge
const borrowIx = await instructions.makeLendingAccountBorrowIx(
  program,
  {
    marginfiAccount: accountAddress,
    marginfiGroup: groupAddress,
    signer: wallet.publicKey,
    bank: bankAddress,
    // ... many more accounts you must derive manually
  },
  {
    amount: new BN(100_000),
  }
);

Problems with this approach:

  • Manual account derivation - You must derive all PDAs yourself
  • No remaining accounts helper - Must manually add oracle accounts, etc.
  • Breaks on updates - Program changes require code updates
  • No optimizations - No oracle cranks, LUTs, or Kamino refresh
  • Easy to get wrong - Missing accounts cause cryptic errors

Sync Instructions (sync-instructions.ts)

Even lower level - doesn't use Anchor, uses hardcoded discriminators:

import { makeLendingAccountBorrowIxSync } from "p0-ts-sdk";

// Hardcoded discriminators - will break on any program update
const borrowIx = makeLendingAccountBorrowIxSync(
  programId,
  {
    marginfiAccount: accountAddress,
    marginfiGroup: groupAddress,
    signer: wallet.publicKey,
    bank: bankAddress,
    // ... all accounts
  },
  {
    amount: new BN(100_000_000),
  }
);

Additional problems:

  • Hardcoded discriminators - Breaks if instruction discriminators change (IDL reordering, structure changes)
  • No IDL dependency - Can't verify against program structure
  • Zero safety - No compile-time or runtime checks

Why these exist:

  • Used internally by the SDK for specific edge cases
  • Some advanced integrators need direct program access
  • Useful for testing program interactions

If you must use them:

  1. Pin your SDK version exactly
  2. Test thoroughly before every program upgrade
  3. Have a migration plan ready
  4. Monitor program deployments
  5. Consider using Layer 2 (instruction builders) instead

Choosing the Right Layer

Use this decision tree:

┌─────────────────────────────────────────────┐
│ Are you building a standard DeFi interface? │
└──────────────┬──────────────────────────────┘

          YES  │  NO
               │  │
               ↓  ↓
         ┌─────────────────────────────────┐
         │ Use Layer 4: AccountWrapper     │
         │ (makeDepositTx, makeBorrowTx)   │
         └─────────────────────────────────┘

                  NO   │

         ┌──────────────────────────────────────┐
         │ Do you need custom tx composition?   │
         └──────────┬───────────────────────────┘

               YES  │  NO
                    │  │
                    ↓  ↓
         ┌──────────────────────────────────┐
         │ Use Layer 3: Transaction Builders│
         │ (makeBorrowTx from services)     │
         └──────────────────────────────────┘

                      NO   │

         ┌────────────────────────────────────────┐
         │ Are you composing with other protocols?│
         └──────────┬─────────────────────────────┘

               YES  │  NO
                    │  │
                    ↓  ↓
         ┌─────────────────────────────────┐
         │ Use Layer 2: Instruction Builders│
         │ (makeBorrowIx from services)     │
         └─────────────────────────────────┘

                      NO   │

         ┌──────────────────────────────────────┐
         │ DON'T use Layer 1: Raw Instructions  │
         │ (Unless you have a very specific     │
         │  reason and understand the risks)    │
         └──────────────────────────────────────┘

Advanced Topics

Oracle Cranking Deep Dive

The SDK handles oracle price updates automatically in Layer 3+, but understanding how it works helps you optimize your integration.

Oracle Types:

Pyth Oracles:

  • Automatically maintained and sponsored externally
  • Updated every few seconds
  • No cranking instructions required

Switchboard Oracles (On-Demand):

  • Most of our assets use on-demand Switchboard feeds
  • Require cranking when performing actions that negatively impact account health
  • Actions that require cranking: borrow, withdraw, liquidate

Smart Cranking Rules:

The SDK uses intelligent logic to minimize transaction overhead:

  1. Liabilities always need to be cranked - Any asset being borrowed must have its oracle updated
  2. Only enough collateral needs to be cranked - The SDK cranks just enough collateral to cover all liabilities, not every deposited asset
  3. Prevents over-cranking - Smart detection ensures you never crank more oracles than necessary

How the SDK implements this:

// Automatically detects which oracles need cranking based on:
// - The operation type (borrow/withdraw/liquidate)
// - Current account health
// - Which banks are liabilities vs collateral
const { instructions: updateFeedIxs, luts: feedLuts } =
  await makeSmartCrankSwbFeedIx({
    marginfiAccount,
    bankMap,
    oraclePrices,
    instructions: yourOperationIxs,
    program,
    connection,
    crossbarUrl, // Optional Switchboard Crossbar service for faster updates
  });

// Packs cranks into separate transaction (if needed)
if (updateFeedIxs.length > 0) {
  transactions.unshift(crankTx); // Oracle cranks in transaction 0
  transactions.push(mainActionTx); // Main action in transaction 1
}

Result:

  • transactions[0] - Oracle crank (only if Switchboard feeds need updating)
  • transactions[1] - Your main operation (borrow, withdraw, etc.)
  • Automatically optimized to crank only what's necessary

Kamino Integration Refresh

When using Kamino-integrated banks, additional refresh instructions are required. Kamino requires reserves to be refreshed in the same slot as your action, so the SDK compiles these in the same transaction as your main operation (not a separate transaction like oracle cranks).

// Automatically added by Layer 3+ transaction builders
const refreshIxs = makeRefreshKaminoBanksIxs(
  marginfiAccount,
  bankMap,
  [kaminoBank.address], // Banks that need Kamino refresh
  bankMetadataMap
);

// Included in the SAME transaction as your main action
// (before the main instruction, in the same slot)
const tx = new VersionedTransaction(
  new TransactionMessage({
    instructions: [
      ...refreshIxs.instructions, // Kamino refresh first
      ...borrowIx.instructions, // Then your action (e.g., borrow)
    ],
    payerKey: authority,
    recentBlockhash: blockhash,
  }).compileToV0Message(luts)
);

How it works with oracle cranking:

When you have Kamino banks, the transaction flow becomes:

  • transactions[0] - Oracle cranks for Switchboard feeds (if needed)
  • transactions[1] - Kamino refresh + main action (in same transaction, same slot)

What Kamino refresh does:

  • Calls Kamino's refresh instruction to update reserve state
  • Ensures accurate liquidity calculations
  • Must execute in the same slot as your deposit/withdrawal/borrow from Kamino vaults
  • Automatically handled by Layer 3+ - you don't need to worry about it

Address Lookup Tables (LUTs)

LUTs compress account references, allowing larger transactions:

Without LUTs:

// Each account = 32 bytes in transaction
// Max ~35 accounts per transaction
const tx = new Transaction().add(ix); // Limited size

With LUTs:

// Accounts referenced by 1-byte index
// Can include 100+ accounts
const tx = new VersionedTransaction(
  new TransactionMessage({
    instructions: [ix],
    payerKey: wallet.publicKey,
    recentBlockhash: blockhash,
  }).compileToV0Message([...clientLuts, ...feedLuts])
);

SDK LUT sources:

  1. Client LUTs - Protocol accounts (banks, group, etc.)
  2. Oracle LUTs - Price feed accounts
  3. Bank LUTs - Bank-specific lookup tables

All merged automatically by Layer 3+.

Multi-Transaction Operations

Some operations can't fit in one transaction:

Reasons for splits:

  1. Oracle cranks - Separate transaction for price updates
  2. Transaction size - Too many accounts/instructions
  3. Compute limits - Complex calculations exceed limits

How the SDK handles it:

const result = await wrappedAccount.makeBorrowTx("1000", bankAddress);

console.log(`Transactions: ${result.transactions.length}`);
// Output: Transactions: 2

console.log(`Main action index: ${result.actionTxIndex}`);
// Output: Main action index: 1

// result.transactions[0] = Oracle crank
// result.transactions[1] = Borrow operation

// Send in order
for (const tx of result.transactions) {
  const sig = await wallet.sendTransaction(tx, connection);
  await connection.confirmTransaction(sig); // Wait for confirmation
}

Important: Always send transactions sequentially and confirm each one before sending the next.

Atomic Multi-Transaction Execution with Bundles

For operations that require multiple transactions (like oracle crank + main action), you can use Jito bundles to send them atomically. This ensures that either all transactions succeed or all fail together, preventing partial execution.

Why use bundles:

  • Atomicity - All transactions in the bundle execute or none do
  • No partial failures - Prevents situations where oracle crank succeeds but main action fails
  • MEV protection - Reduces front-running risks
  • Better UX - Users see a single atomic operation instead of multiple steps

Summary

For 95% of use cases:

// Use this!
const result = await wrappedAccount.makeBorrowTx("100", bankAddress);

For custom flows:

// Use transaction builders
const result = await makeBorrowTx({ ...params });

For cross-protocol:

// Use instruction builders
const ixs = await makeBorrowIx({ ...params });

Avoid raw instructions unless you:

  • Have a very specific need
  • Understand the risks
  • Can handle program updates
  • Are willing to maintain the code

The SDK is designed to handle complexity so you don't have to. Trust the abstractions, and only drop down to lower levels when you have a clear reason to do so.

On this page