How to Build a Bitcoin Lending Protocol
Complete guide to building a decentralized lending protocol for Bitcoin-based assets on Arch Network
This guide walks through building a lending protocol for Bitcoin-based assets (BTC, Runes, Ordinals) on Arch Network. We'll create a decentralized lending platform similar to Aave, but specifically designed for Bitcoin-based assets.
Prerequisites
Before starting, ensure you have:
- Completed the environment setup
- A basic understanding of Bitcoin Integration
- Familiarity with Rust programming language
- Your development environment ready with the Arch CLI installed
System Overview
Basic User Flow
Safety System
Simple Example
Let's say Alice wants to borrow BTC and Bob wants to earn interest:
-
Bob (Lender)
- Deposits 1 BTC into pool
- Earns 3% APY interest
-
Alice (Borrower)
- Provides 1.5 BTC as collateral
- Borrows 1 BTC
- Pays 5% APY interest
-
Safety System
- Monitors BTC price
- Checks if Alice's collateral stays valuable enough
- If BTC price drops too much, liquidates some collateral to protect Bob's deposit
Architecture Overview
Our lending protocol consists of several key components:
1. Pool Accounts
Pool accounts are the core of our lending protocol. They serve as liquidity pools where users can:
- Deposit Bitcoin-based assets (BTC, Runes, Ordinals)
- Earn interest on deposits
- Borrow against their collateral
- Manage protocol parameters
Each pool account maintains:
- Total deposits and borrows
- Interest rates and utilization metrics
- Collateral factors and liquidation thresholds
- Asset-specific parameters
The pool account manages both state and UTXOs:
- State Management: Tracks deposits, withdrawals, and user positions
- UTXO Management:
- Maintains a collection of UTXOs for the pool's Bitcoin holdings
- Manages UTXO creation for withdrawals
- Handles UTXO consolidation for efficient liquidity management
2. Price Oracle
Track asset prices for liquidation calculations
3. User Positions
User positions track all user interactions with the lending pools:
- Active deposits and their earned interest
- Active borrows and their accrued interest
- Collateral values and health factors
- Liquidation history and penalties
Data Structures
Pool Account
use arch_program::prelude::*;
use borsh::{BorshDeserialize, BorshSerialize};
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct LendingPool {
// Pool identification
pub pool_id: u64,
pub asset_type: AssetType,
// Pool state
pub total_deposits: u64,
pub total_borrows: u64,
pub utilization_rate: u64, // Basis points (0-10000)
// Interest rates
pub deposit_rate: u64, // Basis points
pub borrow_rate: u64, // Basis points
// Risk parameters
pub collateral_factor: u64, // Basis points
pub liquidation_threshold: u64, // Basis points
pub liquidation_penalty: u64, // Basis points
// Oracle integration
pub price_oracle: Pubkey,
pub last_price_update: i64,
// Pool management
pub pool_authority: Pubkey,
pub is_active: bool,
pub created_at: i64,
}
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub enum AssetType {
Bitcoin,
Rune { rune_id: String },
Ordinal { inscription_id: String },
}
User Position
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct UserPosition {
// User identification
pub user: Pubkey,
pub position_id: u64,
// Deposits
pub deposits: Vec<Deposit>,
pub total_deposit_value: u64,
// Borrows
pub borrows: Vec<Borrow>,
pub total_borrow_value: u64,
// Health factor
pub health_factor: u64, // Basis points
pub is_healthy: bool,
// Liquidation
pub liquidation_count: u32,
pub last_liquidation: Option<i64>,
// Timestamps
pub created_at: i64,
pub last_updated: i64,
}
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub struct Deposit {
pub pool_id: u64,
pub amount: u64,
pub interest_earned: u64,
pub deposited_at: i64,
pub last_interest_update: i64,
}
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub struct Borrow {
pub pool_id: u64,
pub amount: u64,
pub interest_accrued: u64,
pub borrowed_at: i64,
pub last_interest_update: i64,
}
Core Instructions
Initialize Pool
pub fn initialize_pool(
program_id: &Pubkey,
accounts: &[AccountInfo],
pool_id: u64,
asset_type: AssetType,
collateral_factor: u64,
liquidation_threshold: u64,
price_oracle: Pubkey,
) -> Result<(), ProgramError> {
let account_iter = &mut accounts.iter();
let pool_account = next_account_info(account_iter)?;
let authority = next_account_info(account_iter)?;
let system_program = next_account_info(account_iter)?;
// Verify authority
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Initialize pool
let pool = LendingPool {
pool_id,
asset_type,
total_deposits: 0,
total_borrows: 0,
utilization_rate: 0,
deposit_rate: 0,
borrow_rate: 0,
collateral_factor,
liquidation_threshold,
liquidation_penalty: 500, // 5% default
price_oracle,
last_price_update: 0,
pool_authority: *authority.key,
is_active: true,
created_at: arch_program::clock::Clock::get()?.unix_timestamp,
};
// Store pool data
let mut pool_data = pool_account.data.try_borrow_mut().unwrap();
let serialized = pool.try_to_vec().unwrap();
pool_data[..serialized.len()].copy_from_slice(&serialized);
Ok(())
}
Deposit Assets
pub fn deposit(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
) -> Result<(), ProgramError> {
let account_iter = &mut accounts.iter();
let pool_account = next_account_info(account_iter)?;
let user_position = next_account_info(account_iter)?;
let user = next_account_info(account_iter)?;
let utxo_account = next_account_info(account_iter)?;
// Verify user is signer
if !user.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Load pool data
let pool_data = pool_account.data.try_borrow().unwrap();
let mut pool: LendingPool = LendingPool::try_from_slice(&pool_data)
.map_err(|_| ProgramError::InvalidAccountData)?;
// Load user position
let position_data = user_position.data.try_borrow().unwrap();
let mut position: UserPosition = UserPosition::try_from_slice(&position_data)
.map_err(|_| ProgramError::InvalidAccountData)?;
// Update pool state
pool.total_deposits += amount;
pool.utilization_rate = (pool.total_borrows * 10000) / pool.total_deposits;
// Update interest rates based on utilization
update_interest_rates(&mut pool);
// Add deposit to user position
let deposit = Deposit {
pool_id: pool.pool_id,
amount,
interest_earned: 0,
deposited_at: arch_program::clock::Clock::get()?.unix_timestamp,
last_interest_update: arch_program::clock::Clock::get()?.unix_timestamp,
};
position.deposits.push(deposit);
position.total_deposit_value += amount;
position.last_updated = arch_program::clock::Clock::get()?.unix_timestamp;
// Update health factor
update_health_factor(&mut position, &pool);
// Store updated data
let mut pool_data_mut = pool_account.data.try_borrow_mut().unwrap();
let serialized_pool = pool.try_to_vec().unwrap();
pool_data_mut[..serialized_pool.len()].copy_from_slice(&serialized_pool);
let mut position_data_mut = user_position.data.try_borrow_mut().unwrap();
let serialized_position = position.try_to_vec().unwrap();
position_data_mut[..serialized_position.len()].copy_from_slice(&serialized_position);
Ok(())
}
Borrow Assets
pub fn borrow(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
) -> Result<(), ProgramError> {
let account_iter = &mut accounts.iter();
let pool_account = next_account_info(account_iter)?;
let user_position = next_account_info(account_iter)?;
let user = next_account_info(account_iter)?;
let destination_utxo = next_account_info(account_iter)?;
// Verify user is signer
if !user.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Load pool data
let pool_data = pool_account.data.try_borrow().unwrap();
let mut pool: LendingPool = LendingPool::try_from_slice(&pool_data)
.map_err(|_| ProgramError::InvalidAccountData)?;
// Load user position
let position_data = user_position.data.try_borrow().unwrap();
let mut position: UserPosition = UserPosition::try_from_slice(&position_data)
.map_err(|_| ProgramError::InvalidAccountData)?;
// Check if pool has sufficient liquidity
if pool.total_deposits - pool.total_borrows < amount {
return Err(ProgramError::InsufficientFunds);
}
// Check health factor after borrow
let new_borrow_value = position.total_borrow_value + amount;
let health_factor = calculate_health_factor(
position.total_deposit_value,
new_borrow_value,
pool.collateral_factor,
);
if health_factor < 10000 { // Less than 100% collateralized
return Err(ProgramError::InvalidAccountData);
}
// Update pool state
pool.total_borrows += amount;
pool.utilization_rate = (pool.total_borrows * 10000) / pool.total_deposits;
update_interest_rates(&mut pool);
// Add borrow to user position
let borrow = Borrow {
pool_id: pool.pool_id,
amount,
interest_accrued: 0,
borrowed_at: arch_program::clock::Clock::get()?.unix_timestamp,
last_interest_update: arch_program::clock::Clock::get()?.unix_timestamp,
};
position.borrows.push(borrow);
position.total_borrow_value += amount;
position.last_updated = arch_program::clock::Clock::get()?.unix_timestamp;
// Update health factor
update_health_factor(&mut position, &pool);
// Store updated data
let mut pool_data_mut = pool_account.data.try_borrow_mut().unwrap();
let serialized_pool = pool.try_to_vec().unwrap();
pool_data_mut[..serialized_pool.len()].copy_from_slice(&serialized_pool);
let mut position_data_mut = user_position.data.try_borrow_mut().unwrap();
let serialized_position = position.try_to_vec().unwrap();
position_data_mut[..serialized_position.len()].copy_from_slice(&serialized_position);
Ok(())
}
Interest Rate Calculation
fn update_interest_rates(pool: &mut LendingPool) {
let utilization = pool.utilization_rate;
// Linear interest rate model
// Base rate + (utilization * slope)
let base_rate = 200; // 2% base rate
let slope = 300; // 3% slope
pool.borrow_rate = base_rate + (utilization * slope / 10000);
// Deposit rate is borrow rate * utilization * (1 - reserve factor)
let reserve_factor = 1000; // 10% reserve factor
pool.deposit_rate = (pool.borrow_rate * utilization * (10000 - reserve_factor)) / (10000 * 10000);
}
Health Factor Calculation
fn calculate_health_factor(
total_deposit_value: u64,
total_borrow_value: u64,
collateral_factor: u64,
) -> u64 {
if total_borrow_value == 0 {
return u64::MAX; // No borrows = healthy
}
let collateral_value = total_deposit_value * collateral_factor / 10000;
(collateral_value * 10000) / total_borrow_value
}
fn update_health_factor(position: &mut UserPosition, pool: &LendingPool) {
position.health_factor = calculate_health_factor(
position.total_deposit_value,
position.total_borrow_value,
pool.collateral_factor,
);
position.is_healthy = position.health_factor >= 10000;
}
Liquidation System
Liquidate Position
pub fn liquidate(
program_id: &Pubkey,
accounts: &[AccountInfo],
liquidate_amount: u64,
) -> Result<(), ProgramError> {
let account_iter = &mut accounts.iter();
let pool_account = next_account_info(account_iter)?;
let user_position = next_account_info(account_iter)?;
let liquidator = next_account_info(account_iter)?;
let liquidator_position = next_account_info(account_iter)?;
// Verify liquidator is signer
if !liquidator.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Load pool data
let pool_data = pool_account.data.try_borrow().unwrap();
let pool: LendingPool = LendingPool::try_from_slice(&pool_data)
.map_err(|_| ProgramError::InvalidAccountData)?;
// Load user position
let position_data = user_position.data.try_borrow().unwrap();
let mut position: UserPosition = UserPosition::try_from_slice(&position_data)
.map_err(|_| ProgramError::InvalidAccountData)?;
// Check if position is unhealthy
if position.is_healthy {
return Err(ProgramError::InvalidAccountData);
}
// Calculate liquidation amount
let max_liquidation = position.total_deposit_value * 5000 / 10000; // Max 50% of deposits
let actual_liquidation = liquidate_amount.min(max_liquidation);
// Apply liquidation penalty
let penalty_amount = actual_liquidation * pool.liquidation_penalty / 10000;
let liquidator_reward = actual_liquidation + penalty_amount;
// Update position
position.total_deposit_value -= actual_liquidation;
position.liquidation_count += 1;
position.last_liquidation = Some(arch_program::clock::Clock::get()?.unix_timestamp);
// Update health factor
update_health_factor(&mut position, &pool);
// Store updated position
let mut position_data_mut = user_position.data.try_borrow_mut().unwrap();
let serialized_position = position.try_to_vec().unwrap();
position_data_mut[..serialized_position.len()].copy_from_slice(&serialized_position);
Ok(())
}
Oracle Integration
Price Update
pub fn update_price(
program_id: &Pubkey,
accounts: &[AccountInfo],
new_price: u64,
) -> Result<(), ProgramError> {
let account_iter = &mut accounts.iter();
let pool_account = next_account_info(account_iter)?;
let oracle = next_account_info(account_iter)?;
// Verify oracle authority
if !oracle.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Load pool data
let pool_data = pool_account.data.try_borrow().unwrap();
let mut pool: LendingPool = LendingPool::try_from_slice(&pool_data)
.map_err(|_| ProgramError::InvalidAccountData)?;
// Verify oracle matches
if *oracle.key != pool.price_oracle {
return Err(ProgramError::InvalidAccountData);
}
// Update price and timestamp
pool.last_price_update = arch_program::clock::Clock::get()?.unix_timestamp;
// Store updated pool
let mut pool_data_mut = pool_account.data.try_borrow_mut().unwrap();
let serialized_pool = pool.try_to_vec().unwrap();
pool_data_mut[..serialized_pool.len()].copy_from_slice(&serialized_pool);
Ok(())
}
Testing
Unit Tests
#[cfg(test)]
mod tests {
use super::*;
use arch_program::test_utils::*;
#[test]
fn test_pool_initialization() {
let program_id = Pubkey::new_unique();
let pool_account = create_test_account(&program_id, 1024);
let authority = create_test_account(&program_id, 0);
let asset_type = AssetType::Bitcoin;
let result = initialize_pool(
&program_id,
&[pool_account, authority],
1,
asset_type,
8000, // 80% collateral factor
8500, // 85% liquidation threshold
Pubkey::new_unique(),
);
assert!(result.is_ok());
}
#[test]
fn test_deposit() {
let program_id = Pubkey::new_unique();
let pool_account = create_test_account(&program_id, 1024);
let user_position = create_test_account(&program_id, 1024);
let user = create_test_account(&program_id, 0);
let result = deposit(
&program_id,
&[pool_account, user_position, user],
1000000, // 1 BTC in satoshis
);
assert!(result.is_ok());
}
#[test]
fn test_health_factor_calculation() {
let health_factor = calculate_health_factor(
1000000, // 1 BTC deposits
500000, // 0.5 BTC borrows
8000, // 80% collateral factor
);
assert_eq!(health_factor, 16000); // 160% collateralized
}
}
Deployment
Build and Deploy
# Build the program
cargo build-sbf
# Deploy to local network
arch-cli program deploy target/deploy/lending_protocol.so
# Deploy to testnet
arch-cli program deploy target/deploy/lending_protocol.so --url testnet
Initialize Pool
# Initialize a Bitcoin lending pool
arch-cli program call <PROGRAM_ID> \
--accounts ~/pool_account.key,~/authority.key \
--instruction-data $(echo '{"InitializePool":{"pool_id":1,"asset_type":"Bitcoin","collateral_factor":8000,"liquidation_threshold":8500,"price_oracle":"<ORACLE_ADDRESS>"}}' | base64) \
--keypair-path ~/authority.key
Security Considerations
1. Oracle Security
- Use multiple price sources
- Implement price deviation checks
- Add circuit breakers for extreme price movements
2. Liquidation Security
- Implement proper liquidation incentives
- Add liquidation caps to prevent over-liquidation
- Monitor liquidation patterns for abuse
3. Interest Rate Security
- Implement rate limits for interest rate changes
- Add emergency pause functionality
- Monitor for interest rate manipulation
4. Access Control
- Implement proper authority management
- Add multi-signature requirements for critical operations
- Regular security audits
Best Practices
1. Risk Management
- Conservative collateral factors
- Regular health factor monitoring
- Automated liquidation systems
2. User Experience
- Clear health factor displays
- Liquidation warnings
- Easy deposit/withdrawal flows
3. Protocol Governance
- Community-driven parameter updates
- Transparent decision making
- Regular protocol upgrades
Next Steps
Oracle Program Guide
Learn to build price oracles for your lending protocol
Runes Swap Guide
Build a token swap protocol
APL Token Program
Use APL tokens in your lending protocol
Arch Examples
Explore more example programs