How to Build a Bitcoin Lending Protocol
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
flowchart TD subgraph Depositing A[User wants to lend] -->|1. Deposits BTC| B[Lending Pool] B -->|2. Receives interest| A end subgraph Borrowing C[User needs loan] -->|3. Provides collateral| B B -->|4. Lends BTC| C C -->|5. Repays loan + interest| B end style A fill:#b3e0ff style B fill:#98FB98 style C fill:#b3e0ff
Safety System
flowchart LR subgraph "Price Monitoring" direction TB A[Price Oracle] -->|1. Updates prices| B[Health Checker] end subgraph "Health Check" direction TB B -->|2. Monitors positions| C[User Position] C -->|3. If position unsafe| D[Liquidator] end style A fill:#FFB6C1 style B fill:#FFB6C1 style C fill:#b3e0ff style D fill:#FFB6C1
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
- Outstanding borrows and accrued interest
- Collateral positions and health factors
- Liquidation thresholds and warnings
Each user can have multiple positions across different pools, and the protocol tracks:
- Position health through real-time monitoring
- Collateralization ratios
- Interest accrual
- Liquidation risks
Core Data Structures
#[derive(BorshSerialize, BorshDeserialize)]
pub struct LendingPool {
pub pool_pubkey: Pubkey,
pub asset_type: AssetType, // BTC, Runes, Ordinals
pub total_deposits: u64,
pub total_borrows: u64,
pub interest_rate: u64,
pub utilization_rate: u64,
pub liquidation_threshold: u64,
pub collateral_factor: u64,
pub utxos: Vec<UtxoMeta>,
pub validator_signatures: Vec<Signature>,
pub min_signatures_required: u32,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct UserPosition {
pub user_pubkey: Pubkey,
pub pool_pubkey: Pubkey,
pub deposited_amount: u64,
pub borrowed_amount: u64,
pub collateral_amount: u64,
pub last_update: i64,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct InterestRateModel {
pub base_rate: u64,
pub multiplier: u64,
pub jump_multiplier: u64,
pub optimal_utilization: u64,
}
// Additional helper structures for managing positions
#[derive(BorshSerialize, BorshDeserialize)]
pub struct PositionHealth {
pub health_factor: u64,
pub liquidation_price: u64,
pub safe_borrow_limit: u64,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct PoolMetrics {
pub total_value_locked: u64,
pub available_liquidity: u64,
pub utilization_rate: u64,
pub supply_apy: u64,
pub borrow_apy: u64,
}
Custom Scoring and Risk Management
LTV (Loan-to-Value) Scoring System
flowchart TD subgraph Core_Factors[Core Factors] P1[Transaction History] P2[Asset Quality] P3[Market Volatility] P4[Position Size] end subgraph User_Metrics[User Metrics] P5[Account History] P6[Repayment Record] P7[Portfolio Health] end subgraph Market_Context[Market Context] P8[Market Conditions] P9[Price Impact] P10[Network Status] end Core_Factors --> SC[Scoring Engine] User_Metrics --> SC Market_Context --> SC SC --> WF[Weight Calculation] WF --> NM[Risk Normalization] NM --> LTV[Final LTV Ratio] style Core_Factors fill:#e1f3d8 style User_Metrics fill:#fff7e6 style Market_Context fill:#e6f3ff style SC fill:#f9f9f9 style WF fill:#f9f9f9 style NM fill:#f9f9f9 style LTV fill:#d4edda
Health Score Monitoring
sequenceDiagram participant User participant HealthMonitor participant PriceOracle participant LiquidationEngine participant Market loop Every Block PriceOracle->>HealthMonitor: Update Asset Prices HealthMonitor->>HealthMonitor: Calculate Health Score alt Health Score < Threshold HealthMonitor->>LiquidationEngine: Trigger Liquidation LiquidationEngine->>User: Lock Account LiquidationEngine->>Market: List Assets Market-->>LiquidationEngine: Asset Sale Complete LiquidationEngine->>User: Update Position else Health Score >= Threshold HealthMonitor->>User: Position Safe end end
Liquidation Process
stateDiagram-v2 [*] --> Monitoring Monitoring --> Warning: Health Score Declining Warning --> AtRisk: Below Warning Threshold AtRisk --> Liquidation: Below Critical Threshold Liquidation --> Step1: Lock Account Step1 --> Step2: List Assets Step2 --> Recovery: Asset Sale Recovery --> [*]: Position Cleared Warning --> Monitoring: Health Restored AtRisk --> Warning: Health Improved
Custom Scoring Implementation
#[derive(BorshSerialize, BorshDeserialize)]
pub struct UserScore {
pub historical_data_score: u64,
pub asset_quality_score: u64,
pub market_volatility_score: u64,
pub position_size_score: u64,
pub account_age_score: u64,
pub liquidation_history_score: u64,
pub repayment_history_score: u64,
pub cross_margin_score: u64,
pub portfolio_diversity_score: u64,
pub market_condition_score: u64,
pub collateral_quality_score: u64,
pub platform_activity_score: u64,
pub time_weighted_score: u64,
pub price_impact_score: u64,
pub network_status_score: u64,
}
pub fn calculate_ltv_ratio(score: &UserScore) -> Result<u64> {
// Weighted calculation of LTV based on all scoring parameters
let weighted_score = calculate_weighted_score(score)?;
let normalized_score = normalize_score(weighted_score)?;
// Convert normalized score to LTV ratio
let ltv_ratio = convert_score_to_ltv(normalized_score)?;
// Apply market condition adjustments
let adjusted_ltv = apply_market_adjustments(ltv_ratio)?;
Ok(adjusted_ltv)
}
pub fn monitor_health_score(
ctx: Context<HealthCheck>,
position: &UserPosition,
score: &UserScore,
) -> Result<()> {
let health_score = calculate_health_score(position, score)?;
if health_score < CRITICAL_THRESHOLD {
trigger_full_liquidation(ctx, position)?;
lock_account(ctx.accounts.user_account)?;
} else if health_score < WARNING_THRESHOLD {
emit_warning(ctx.accounts.user_account)?;
}
Ok(())
}
pub fn trigger_full_liquidation(
ctx: Context<Liquidation>,
position: &UserPosition,
) -> Result<()> {
// Step 1: Lock the account
lock_account(ctx.accounts.user_account)?;
// Step 2: Calculate current position value
let position_value = calculate_position_value(position)?;
// Step 3: List assets on marketplace
list_assets_for_liquidation(
ctx.accounts.marketplace,
position.assets,
position_value,
)?;
// Step 4: Monitor recovery process
start_recovery_monitoring(ctx.accounts.recovery_manager)?;
Ok(())
}
## Health Score Calculation
The health score is calculated using a combination of factors:
```rust,ignore
pub fn calculate_health_score(
position: &UserPosition,
score: &UserScore,
) -> Result<u64> {
// 1. Calculate base health ratio
let base_health = calculate_base_health_ratio(
position.collateral_value,
position.borrowed_value,
)?;
// 2. Apply user score modifiers
let score_adjusted_health = apply_score_modifiers(
base_health,
score,
)?;
// 3. Apply market condition adjustments
let market_adjusted_health = apply_market_conditions(
score_adjusted_health,
&position.asset_type,
)?;
// 4. Apply time-weighted factors
let final_health_score = apply_time_weights(
market_adjusted_health,
position.last_update,
)?;
Ok(final_health_score)
}
Liquidation Implementation
The two-step liquidation process is implemented as follows:
pub struct LiquidationConfig {
pub warning_threshold: u64,
pub critical_threshold: u64,
pub recovery_timeout: i64,
pub minimum_recovery_value: u64,
}
pub fn handle_liquidation(
ctx: Context<Liquidation>,
config: &LiquidationConfig,
) -> Result<()> {
// Step 1: Asset Recovery
let recovery_listing = create_recovery_listing(
ctx.accounts.marketplace,
ctx.accounts.user_position,
config.minimum_recovery_value,
)?;
// Step 2: Monitor Recovery
start_recovery_monitoring(
recovery_listing,
config.recovery_timeout,
)?;
// Lock account until recovery complete
lock_user_account(ctx.accounts.user_account)?;
Ok(())
}
Implementation Steps
1. Initialize Lending Pool
First, we'll create a function to initialize a new lending pool:
pub fn initialize_lending_pool(
ctx: Context<InitializeLendingPool>,
asset_type: AssetType,
initial_interest_rate: u64,
liquidation_threshold: u64,
collateral_factor: u64,
) -> Result<()> {
let lending_pool = &mut ctx.accounts.lending_pool;
lending_pool.pool_pubkey = ctx.accounts.pool.key();
lending_pool.asset_type = asset_type;
lending_pool.total_deposits = 0;
lending_pool.total_borrows = 0;
lending_pool.interest_rate = initial_interest_rate;
lending_pool.utilization_rate = 0;
lending_pool.liquidation_threshold = liquidation_threshold;
lending_pool.collateral_factor = collateral_factor;
Ok(())
}
// Initialize pool metrics
pub fn initialize_pool_metrics(
ctx: Context<InitializePoolMetrics>,
) -> Result<()> {
let pool_metrics = &mut ctx.accounts.pool_metrics;
pool_metrics.total_value_locked = 0;
pool_metrics.available_liquidity = 0;
pool_metrics.utilization_rate = 0;
pool_metrics.supply_apy = 0;
pool_metrics.borrow_apy = 0;
Ok(())
}
2. Manage User Positions
Functions to handle user position management:
pub fn create_user_position(
ctx: Context<CreateUserPosition>,
pool_pubkey: Pubkey,
) -> Result<()> {
let user_position = &mut ctx.accounts.user_position;
user_position.user_pubkey = ctx.accounts.user.key();
user_position.pool_pubkey = pool_pubkey;
user_position.deposited_amount = 0;
user_position.borrowed_amount = 0;
user_position.collateral_amount = 0;
user_position.last_update = Clock::get()?.unix_timestamp;
Ok(())
}
pub fn update_position_health(
ctx: Context<UpdatePositionHealth>,
) -> Result<()> {
let position = &ctx.accounts.user_position;
let pool = &ctx.accounts.lending_pool;
let health = &mut ctx.accounts.position_health;
// Calculate health factor based on current prices and positions
let collateral_value = calculate_collateral_value(
position.collateral_amount,
pool.asset_type,
)?;
let borrow_value = calculate_borrow_value(
position.borrowed_amount,
pool.asset_type,
)?;
health.health_factor = calculate_health_factor(
collateral_value,
borrow_value,
pool.collateral_factor,
)?;
health.liquidation_price = calculate_liquidation_price(
position.borrowed_amount,
position.collateral_amount,
pool.liquidation_threshold,
)?;
health.safe_borrow_limit = calculate_safe_borrow_limit(
collateral_value,
pool.collateral_factor,
)?;
Ok(())
}
3. Pool and Position Utilities
Helper functions for managing pools and positions:
// Calculate the utilization rate of a pool
pub fn calculate_utilization_rate(pool: &LendingPool) -> Result<u64> {
if pool.total_deposits == 0 {
return Ok(0);
}
Ok((pool.total_borrows * 10000) / pool.total_deposits)
}
// Calculate the health factor of a position
pub fn calculate_health_factor(
collateral_value: u64,
borrow_value: u64,
collateral_factor: u64,
) -> Result<u64> {
if borrow_value == 0 {
return Ok(u64::MAX);
}
Ok((collateral_value * collateral_factor) / (borrow_value * 10000))
}
// Update pool metrics
pub fn update_pool_metrics(
pool: &LendingPool,
metrics: &mut PoolMetrics,
) -> Result<()> {
metrics.total_value_locked = pool.total_deposits;
metrics.available_liquidity = pool.total_deposits.saturating_sub(pool.total_borrows);
metrics.utilization_rate = calculate_utilization_rate(pool)?;
// Update APY rates based on utilization
let (supply_apy, borrow_apy) = calculate_apy_rates(
metrics.utilization_rate,
pool.interest_rate,
)?;
metrics.supply_apy = supply_apy;
metrics.borrow_apy = borrow_apy;
Ok(())
}
4. Deposit Assets
Create a deposit function to allow users to provide liquidity:
pub fn deposit(
ctx: Context<Deposit>,
amount: u64,
btc_txid: [u8; 32],
vout: u32,
) -> Result<()> {
let pool = &mut ctx.accounts.lending_pool;
let user_position = &mut ctx.accounts.user_position;
// Verify the UTXO belongs to the user
require!(
verify_utxo_ownership(
&ctx.accounts.user.key(),
&btc_txid,
vout
)?,
ErrorCode::InvalidUTXO
);
// Create deposit account to hold the UTXO
invoke(
&SystemInstruction::new_create_account_instruction(
btc_txid,
vout,
pool.pool_pubkey,
),
&[ctx.accounts.user.clone(), ctx.accounts.pool.clone()]
)?;
// Update pool state
pool.total_deposits = pool.total_deposits
.checked_add(amount)
.ok_or(ErrorCode::MathOverflow)?;
// Update user position
user_position.deposited_amount = user_position.deposited_amount
.checked_add(amount)
.ok_or(ErrorCode::MathOverflow)?;
// Update utilization metrics
update_utilization_rate(pool)?;
Ok(())
}
5. Borrow Assets
Implement borrowing functionality:
pub fn borrow(
ctx: Context<Borrow>,
amount: u64,
collateral_utxo: UtxoMeta,
) -> Result<()> {
let pool = &mut ctx.accounts.lending_pool;
let borrower_position = &mut ctx.accounts.user_position;
// Verify collateral UTXO ownership
require!(
verify_utxo_ownership(
&ctx.accounts.borrower.key(),
&collateral_utxo.txid,
collateral_utxo.vout,
)?,
ErrorCode::InvalidCollateral
);
// Check collateral requirements
require!(
is_collateral_sufficient(borrower_position, pool, amount)?,
ErrorCode::InsufficientCollateral
);
// Create collateral account
invoke(
&SystemInstruction::new_create_account_instruction(
collateral_utxo.txid,
collateral_utxo.vout,
pool.pool_pubkey,
),
&[ctx.accounts.borrower.clone(), ctx.accounts.pool.clone()]
)?;
// Create borrow UTXO for user
let mut btc_tx = Transaction::new();
add_state_transition(&mut btc_tx, ctx.accounts.pool);
// Set transaction for validator signing
set_transaction_to_sign(
ctx.accounts,
TransactionToSign {
tx_bytes: &bitcoin::consensus::serialize(&btc_tx),
inputs_to_sign: &[InputToSign {
index: 0,
signer: pool.pool_pubkey
}]
}
);
// Update states
pool.total_borrows = pool.total_borrows
.checked_add(amount)
.ok_or(ErrorCode::MathOverflow)?;
borrower_position.borrowed_amount = borrower_position.borrowed_amount
.checked_add(amount)
.ok_or(ErrorCode::MathOverflow)?;
update_utilization_rate(pool)?;
update_interest_rate(pool)?;
Ok(())
}
6. Liquidation Logic
Implement liquidation for underwater positions:
pub fn liquidate(
ctx: Context<Liquidate>,
repay_amount: u64,
) -> Result<()> {
let pool = &mut ctx.accounts.lending_pool;
let liquidated_position = &mut ctx.accounts.liquidated_position;
// Check if position is liquidatable
require!(
is_position_liquidatable(liquidated_position, pool)?,
ErrorCode::PositionNotLiquidatable
);
// Calculate liquidation bonus
let bonus = calculate_liquidation_bonus(repay_amount, pool.liquidation_threshold)?;
// Process liquidation
process_liquidation(
pool,
liquidated_position,
repay_amount,
bonus,
)?;
Ok(())
}
Testing
Create comprehensive tests for your lending protocol:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initialize_lending_pool() {
// Test pool initialization
}
#[test]
fn test_deposit() {
// Test deposit functionality
}
#[test]
fn test_borrow() {
// Test borrowing
}
#[test]
fn test_liquidation() {
// Test liquidation scenarios
}
}
Security Considerations
- Collateral Safety: Implement strict collateral requirements and regular position health checks
- Price Oracle Security: Use reliable price feeds and implement safeguards against price manipulation
- Interest Rate Model: Ensure the model can handle extreme market conditions
- Access Control: Implement proper permission checks for all sensitive operations
- Liquidation Thresholds: Set appropriate thresholds to maintain protocol solvency
Next Steps
-
Implement additional features:
- Flash loans
- Multiple collateral types
- Governance mechanisms
-
Deploy and test on testnet:
- Monitor pool performance
- Test liquidation scenarios
- Validate interest rate model
-
Security audit:
- Contract review
- Economic model analysis
- Risk assessment
Process Descriptions
1. Pool Initialization Process
The pool initialization process involves several steps:
%%{init: { 'theme': 'base', 'themeVariables': { 'fontSize': '16px'}, 'flowchart': { 'curve': 'basis', 'nodeSpacing': 50, 'rankSpacing': 50, 'animation': { 'sequence': true, 'duration': 1000, 'ease': 'linear', 'diagramUpdate': 200 } } }}%% graph LR A[Admin] -->|Create Pool| B[Initialize Pool Account] B -->|Set Parameters| C[Configure Pool] C -->|Initialize Metrics| D[Create Pool Metrics] D -->|Enable Oracle| E[Connect Price Feed] E -->|Activate| F[Pool Active] classDef default fill:#f9f9f9,stroke:#333,stroke-width:2px; classDef active fill:#4a9eff,color:white,stroke:#3182ce,opacity:0; classDef complete fill:#98FB98,stroke:#333;
- Admin creates a new pool account
- Pool parameters are set (interest rates, thresholds)
- Pool metrics are initialized
- Price oracle connection is established
- Pool is activated for user operations
2. Deposit and Borrow Flow
The lending and borrowing process follows this sequence:
graph TD A[User] -->|Deposit Assets| B[Lending Pool] B -->|Create Position| C[User Position] C -->|Calculate Capacity| D[Borrow Limit] D -->|Enable Borrowing| E[Borrow Assets] E -->|Update Metrics| F[Pool Metrics] F -->|Adjust Rates| G[Interest Rates]
Key steps:
- User deposits assets into the pool
- System creates or updates user position
- Calculates borrowing capacity based on collateral
- Enables borrowing up to the limit
- Updates pool metrics and interest rates
3. Health Monitoring System
Continuous health monitoring process:
graph TD A[Price Oracle] -->|Update Prices| B[Position Valuation] B -->|Calculate Ratios| C[Health Check] C -->|Evaluate| D{Health Factor} D -->|>1| E[Healthy] D -->|<1| F[At Risk] F -->|<Threshold| G[Liquidatable] G -->|Notify| H[Liquidators]
The system:
- Continuously monitors asset prices
- Updates position valuations
- Calculates health factors
- Triggers liquidations when necessary
Withdrawal Process
The withdrawal process in our lending protocol involves two key components:
- State management through program accounts
- Actual BTC transfer through UTXOs
rust,ignore
#[derive(BorshSerialize, BorshDeserialize)]
pub struct WithdrawRequest {
pub user_pubkey: Pubkey,
pub pool_pubkey: Pubkey,
pub amount: u64,
pub recipient_btc_address: String,
}
pub fn process_withdrawal(
ctx: Context<ProcessWithdraw>,
request: WithdrawRequest,
) -> Result<()> {
let pool = &mut ctx.accounts.lending_pool;
let user_position = &mut ctx.accounts.user_position;
// 1. Validate user position
require!(
user_position.deposited_amount >= request.amount,
ErrorCode::InsufficientBalance
);
// 2. Check pool liquidity
require!(
pool.available_liquidity() >= request.amount,
ErrorCode::InsufficientLiquidity
);
// 3. Find available UTXOs from pool
let selected_utxos = select_utxos_for_withdrawal(
&pool.utxos,
request.amount
)?;
// 4. Create Bitcoin withdrawal transaction
let mut btc_tx = Transaction::new();
// Add inputs from selected UTXOs
for utxo in selected_utxos {
btc_tx.input.push(TxIn {
previous_output: OutPoint::new(utxo.txid, utxo.vout),
script_sig: Script::new(),
sequence: Sequence::MAX,
witness: Witness::new(),
});
}
// Add withdrawal output to user's address
let recipient_script = Address::from_str(&request.recipient_btc_address)?
.script_pubkey();
btc_tx.output.push(TxOut {
value: request.amount,
script_pubkey: recipient_script,
});
// Add change output back to pool if needed
let total_input = selected_utxos.iter()
.map(|utxo| utxo.amount)
.sum::<u64>();
if total_input > request.amount {
btc_tx.output.push(TxOut {
value: total_input - request.amount,
script_pubkey: get_account_script_pubkey(&pool.pool_pubkey),
});
}
// 5. Set transaction for validator signing
set_transaction_to_sign(
ctx.accounts,
TransactionToSign {
tx_bytes: &bitcoin::consensus::serialize(&btc_tx),
inputs_to_sign: &selected_utxos.iter()
.enumerate()
.map(|(i, _)| InputToSign {
index: i as u32,
signer: pool.pool_pubkey,
})
.collect::<Vec<_>>()
}
);
// 6. Update pool state
pool.total_deposits = pool.total_deposits
.checked_sub(request.amount)
.ok_or(ErrorCode::MathOverflow)?;
// 7. Update user position
user_position.deposited_amount = user_position.deposited_amount
.checked_sub(request.amount)
.ok_or(ErrorCode::MathOverflow)?;
// 8. Remove spent UTXOs from pool
pool.utxos.retain(|utxo| !selected_utxos.contains(utxo));
Ok(())
}
fn select_utxos_for_withdrawal(
pool_utxos: &[UtxoMeta],
amount: u64,
) -> Result<Vec<UtxoMeta>> {
let mut selected = Vec::new();
let mut total_selected = 0;
for utxo in pool_utxos {
if total_selected >= amount {
break;
}
// Verify UTXO is still valid and unspent
validate_utxo(utxo)?;
selected.push(utxo.clone());
total_selected += utxo.amount;
}
require!(
total_selected >= amount,
ErrorCode::InsufficientUtxos
);
Ok(selected)
}