Introduction: Why i said yes to the unknown
I stumbled upon this challenge while browsing developer communities online, and honestly, my first instinct was to scroll past it.
The challenge was clear: build a flash loan program on Solana using Anchor framework. Simple enough on paper, but there was one problem —I had never touched Solana development before.
The requirements were intimidating:
Rust: A language I'd love to still learn but currently don't know much about
Anchor Framework: Completely new territory
Solana's programming model: Foreign concepts like PDAs, instruction introspection, and BPF programs
By all reasonable measures, I should have skipped it. I had deadlines, bunch of familiar projects waiting for my attention, and a comfortable tech stack waiting for me. But two things made me pause:
First, Superteam UK's talent database. Successfully completing this challenge meant being recorded as a developer in their ecosystem, gaining access to developer-only Telegram groups, exclusive events, and other initiatives. The opportunity to join a community of builders working on cutting-edge blockchain technology was too valuable to pass up.
Second, and perhaps more importantly, I'd interacted with flash loan protocols before. I'd used them and seen how one can profit from the arbitrage opportunities they enabled. But I'd never built one. There's a massive gulf between using a tool and understanding how it works at a fundamental level. This challenge was my chance to cross that bridge.
So I decided to dive in, armed with nothing but curiosity and the every friendly AI.
What Are Flash Loans, Anyway?
Before we get into the technical weeds, let's establish what flash loans actually are - because they're one of the most fascinating innovations in DeFi.
Imagine borrowing $1 million, using it to execute a complex trading strategy across multiple protocols, and then repaying the loan—all within a single transaction that takes mere seconds. No collateral required. No credit checks. No lengthy approval processes.
That's a flash loan.
The magic lies in transaction atomicity: on blockchains like Solana, transactions are all-or-nothing. If any step in the transaction fails (including the loan repayment), the entire transaction reverts as if it never happened. This means:
- For the lender: Zero risk. Either you get repaid with fees, or the loan never actually occurs.
- For the borrower: Instant access to massive capital, limited only by available liquidity.
Flash loans enable arbitrage, collateral swapping, liquidation assistance, and many other DeFi strategies that were impossible in traditional finance.
The Challenge: Building flash loans on Solana
The challenge was deceptively simple:
Challenge 1: Create a borrow instruction
Build a program that allows a borrower to borrow funds from the protocol and verify that a repay instruction exists at the end of the transaction.
Challenge 2: Create a repay instruction
Implement an instruction that extracts the borrowed amount from the borrow instruction and repays the protocol with the correct amount plus fees.
The twist? This all needed to happen using instruction introspection — the ability for a Solana program to examine other instructions in the same transaction, including ones that haven't executed yet.
Setting Up: New tools, New paradigms
My first task was getting the development environment ready:
# Initialize a new Anchor project
anchor init blueshift_anchor_flash_loan
# Add SPL token utilities
cd blueshift_anchor_flash_loan
cargo add anchor-spl
Coming from an Ethereum background (where I'd worked with Solidity), Solana's programming model was... different. Here are the key paradigm shifts I had to internalize:
1. Accounts, accounts, everywhere
In Solana, you don't have contract storage like Ethereum. Instead, everything is accounts. Your program? An account. Your data? Separate accounts. User tokens? Also accounts.
2. Rent and account ownership
Accounts must maintain a minimum balance (rent) to exist on-chain. Programs can own accounts. This helps create powerful patterns like Program Derived Addresses (PDAs).
3. No built-in reentrancy guards needed
Solana's execution model is different — you explicitly pass all accounts you'll need. The good thing here is that it makes certain attack vectors from Ethereum (like reentrancy) less relevant.
Architecting the solution
I started by sketching out the account structure. Both borrow and repay would use the same accounts:
#[derive(Accounts)]
pub struct Loan<'info> {
#[account(mut)]
pub borrower: Signer<'info>,
#[account(
seeds = [b"protocol".as_ref()],
bump,
)]
pub protocol: SystemAccount<'info>,
pub mint: Account<'info, Mint>,
#[account(
init_if_needed,
payer = borrower,
associated_token::mint = mint,
associated_token::authority = borrower,
)]
pub borrower_ata: Account<'info, TokenAccount>,
#[account(
mut,
associated_token::mint = mint,
associated_token::authority = protocol,
)]
pub protocol_ata: Account<'info, TokenAccount>,
#[account(address = INSTRUCTIONS_SYSVAR_ID)]
/// CHECK: InstructionsSysvar account
pub instructions: UncheckedAccount<'info>,
pub token_program: Program<'info, Token>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>
}
Key design decisions:
1. Protocol PDA: The protocol account is a Program Derived Address (PDA) using the seed "protocol". This is crucial because:
- Only our program can sign transactions on behalf of this PDA
- It provides deterministic addresses
- It securely holds the protocol's liquidity pool
2. Associated Token Accounts (ATAs): Using init_if_needed for the borrower's ATA means we automatically create it if it doesn't exist. The aim here is to improve UX.
3. Instructions Sysvar: This special account gives us access to all instructions in the current transaction—the key to our flash loan security model.
The heart of the matter: Instruction Introspection
This is where things got really interesting. The security of flash loans relies on ensuring that borrowed funds are repaid within the same transaction. On Ethereum, you might use a callback pattern. On Solana, we use instruction introspection.
The borrow instruction
Here's the critical code:
pub fn borrow(ctx: Context<Loan>, borrow_amount: u64) -> Result<()> {
// Validate amount
require!(borrow_amount > 0, ProtocolError::InvalidAmount);
let ixs = ctx.accounts.instructions.to_account_info();
// Check if this is the first instruction in the transaction
let current_index = load_current_index_checked(&ctx.accounts.instructions)?;
require_eq!(current_index, 0, ProtocolError::InvalidIx);
// Get the total number of instructions in this transaction
let instruction_sysvar = ixs.try_borrow_data()?;
let len = u16::from_le_bytes(instruction_sysvar[0..2].try_into().unwrap());
drop(instruction_sysvar);
// Load the LAST instruction and verify it's our repay instruction
if let Ok(repay_ix) = load_instruction_at_checked(len as usize - 1, &ixs) {
// Verify it's calling our program
require_keys_eq!(repay_ix.program_id, crate::ID, ProtocolError::InvalidProgram);
// Verify it has enough data
require!(repay_ix.data.len() >= 8, ProtocolError::InvalidIx);
// Verify the same accounts are being used (indices 3 and 4)
require_keys_eq!(
repay_ix.accounts.get(3).ok_or(ProtocolError::InvalidBorrowerAta)?.pubkey,
ctx.accounts.borrower_ata.key(),
ProtocolError::InvalidBorrowerAta
);
require_keys_eq!(
repay_ix.accounts.get(4).ok_or(ProtocolError::InvalidProtocolAta)?.pubkey,
ctx.accounts.protocol_ata.key(),
ProtocolError::InvalidProtocolAta
);
} else {
return Err(ProtocolError::MissingRepayIx.into());
}
// If we get here, validation passed—time to lend the funds
let seeds = &[
b"protocol".as_ref(),
&[ctx.bumps.protocol]
];
let signer_seeds = &[&seeds[..]];
transfer(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.protocol_ata.to_account_info(),
to: ctx.accounts.borrower_ata.to_account_info(),
authority: ctx.accounts.protocol.to_account_info(),
},
signer_seeds
),
borrow_amount
)?;
msg!("Borrowed {} tokens", borrow_amount);
Ok(())
}
What's happening here?
- We're ensuring
borrowis the FIRST instruction (index 0) - We're reading the instructions sysvar to find out how many total instructions exist
- We're "looking ahead" to the LAST instruction in the transaction
- We're validating that the last instruction:
- Calls our program
- Has the same token accounts (ensuring repayment goes to the right place)
This is powerful: we refuse to lend unless we can see the repay instruction waiting at the end of the transaction.
The repay instruction
The repay side is equally clever:
pub fn repay(ctx: Context<Loan>) -> Result<()> {
let ixs = ctx.accounts.instructions.to_account_info();
let amount_borrowed = if let Ok(borrow_ix) = load_instruction_at_checked(0, &ixs) {
// Extract the borrowed amount from the first instruction's data
let mut borrowed_data: [u8; 8] = [0u8; 8];
borrowed_data.copy_from_slice(&borrow_ix.data[8..16]);
u64::from_le_bytes(borrowed_data)
} else {
return Err(ProtocolError::MissingBorrowIx.into());
};
// Calculate 5% fee (500 basis points)
let fee = (amount_borrowed as u128)
.checked_mul(500)
.unwrap()
.checked_div(10_000)
.ok_or(ProtocolError::Overflow)? as u64;
let repay_amount = amount_borrowed
.checked_add(fee)
.ok_or(ProtocolError::Overflow)?;
// Transfer funds back to protocol
transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.borrower_ata.to_account_info(),
to: ctx.accounts.protocol_ata.to_account_info(),
authority: ctx.accounts.borrower.to_account_info(),
}
),
repay_amount
)?;
msg!("Repaid {} tokens (borrowed: {}, fee: {})", repay_amount, amount_borrowed, fee);
Ok(())
}
Here we're:
- Looking BACKWARD at the first instruction (the borrow)
- Extracting the borrowed amount from its instruction data
- Calculating a 5% fee
- Transferring borrowed amount + fee back to the protocol
The debugging gauntlet
Now, here's where things got... educational. My first several attempts failed spectacularly. Let me walk you through the journey.
Issue #1: The slice index panic
Error:
panicked at programs/blueshift_anchor_flash_loan/src/lib.rs:296:12:
range start index 63407 out of range for slice of length 698
The Problem:
I initially tried to manually parse the instructions sysvar to look ahead at future instructions. My parsing logic was completely wrong — I was trying to access byte index 63407 in a 698-byte array.
The Root Cause:
I misunderstood the instructions sysvar format. I thought I needed to manually parse through it, but Solana provides load_instruction_at_checked() for this exact purpose.
The fix:
Stop trying to be clever and use the standard library:
// WRONG: Manual parsing with incorrect offsets
let num_instructions = get_instruction_relative(0, instructions_sysvar)?.len() as usize;
// RIGHT: Use the built-in function
let instruction_sysvar = ixs.try_borrow_data()?;
let len = u16::from_le_bytes(instruction_sysvar[0..2].try_into().unwrap());
Issue #2: Cannot look ahead
Error:
AnchorError occurred. Error Code: InvalidInstructionIndex. Error Number: 6001.
The Problem:
load_instruction_at_checked() was failing when I tried to load future instructions (instructions that haven't executed yet).
The Revelation:
This was a critical learning moment. In Solana, load_instruction_at_checked() has security restrictions—by default, it can only access instructions that have already executed. This prevents certain types of attacks but complicates looking ahead.
Initial failed approach:
I tried manually parsing the instructions sysvar with custom structs:
// This approach was too complex and error-prone
struct ParsedInstruction {
program_id: Pubkey,
accounts: Vec<Pubkey>,
data: Vec<u8>,
}
fn parse_instruction_at(index: u16, instructions_sysvar: &UncheckedAccount) -> Result<ParsedInstruction> {
// Complex parsing logic that kept failing...
}
The Correct Approach:
It turns out load_instruction_at_checked() CAN load future instructions, but you need to:
- Drop any borrows on the instructions sysvar before calling it
- Read the instruction count from the correct bytes (0..2, not 2..4)
// Get instruction count - MUST drop the borrow before load_instruction_at_checked
let len = {
let instruction_sysvar = ixs.try_borrow_data()?;
u16::from_le_bytes(instruction_sysvar[0..2].try_into().unwrap())
}; // Borrow drops here
// Now we can load future instructions
if let Ok(repay_ix) = load_instruction_at_checked((len - 1) as usize, &ixs) {
// Success!
}
Issue #3: The discriminator mismatch
Error:
Error Code: InvalidIx. Error Number: 6000. Error Message: Invalid instruction.
The problem:
I hardcoded the discriminator for the repay instruction:
let repay_discriminator: [u8; 8] = [0x8c, 0x95, 0xfc, 0xc7, 0x6e, 0x4f, 0xaf, 0x32];
require!(repay_ix.data[0..8].eq(&repay_discriminator), ProtocolError::InvalidIx);
But the test environment's discriminator didn't match. In Anchor, discriminators are generated from sha256("global:<function_name>")[0..8], and apparently the test harness was generating a different value.
The fix:
Remove the strict discriminator check and rely on other validation:
// Instead of checking exact discriminator bytes, just verify we have data
require!(repay_ix.data.len() >= 8, ProtocolError::InvalidIx);
// And validate the accounts match (stronger guarantee anyway)
require_keys_eq!(
repay_ix.accounts.get(3).ok_or(ProtocolError::InvalidBorrowerAta)?.pubkey,
ctx.accounts.borrower_ata.key(),
ProtocolError::InvalidBorrowerAta
);
This is actually MORE secure because we're validating the actual accounts involved, not just a hash.
The security model: Why this works
Let's pause and appreciate the elegance of this system:
Transaction Atomicity = Risk-Free Lending
┌─────────────────────────────────────────┐
│ FLASH LOAN TRANSACTION │
├─────────────────────────────────────────┤
│ │
│ 1. Borrow (checks for repay) ────────┐ │
│ │ │ │
│ │ Lends tokens │ │
│ ▼ │ │
│ 2. [User's actions with funds] │ │
│ │ │ │
│ │ Uses borrowed tokens │ │
│ ▼ │ │
│ 3. Repay (validates borrow) ◄────────┘ │
│ │ │
│ │ Returns tokens + fee │
│ ▼ │
│ ✓ Success │
│ │
│ If ANY step fails: │
│ → Entire transaction reverts │
│ → It's like nothing happened │
│ → Protocol loses nothing │
│ │
└─────────────────────────────────────────┘
Multi-layer validation
- Program ID check: Ensures we're calling our own program
- Account validation: Verifies the same token accounts are used in both instructions
- Atomicity: If repay fails, borrow never happened
- Amount verification: Repay extracts the exact borrowed amount from borrow's data
Key learnings
1. Read the docs, then read them again
Solana's programming model is fundamentally different from Ethereum's. Assumptions don't transfer well. I wasted hours trying to manually parse the instructions sysvar when the SDK had perfect functions for this.
Lesson: Use the platform's native tools first. Optimize later if needed.
2. Borrowing in Rust is not optional
Coming from languages with garbage collection, Rust's borrow checker was frustrating at first. But the error about trying to call load_instruction_at_checked() while holding a borrow taught me that Rust's restrictions prevent real bugs.
// This fails - can't borrow ixs twice
let instruction_sysvar = ixs.try_borrow_data()?;
let ix = load_instruction_at_checked(0, &ixs)?; // ERROR!
// This works - scope the borrow
let len = {
let instruction_sysvar = ixs.try_borrow_data()?;
u16::from_le_bytes(instruction_sysvar[0..2].try_into().unwrap())
}; // Borrow dropped here
let ix = load_instruction_at_checked(0, &ixs)?; // OK!
3. Instruction introspection is powerful
The ability to examine and validate other instructions in a transaction enables patterns that are hard or impossible in other chains:
- Flash loans (as we built)
- Composable DeFi strategies
- Multi-step protocols with built-in validation
- Atomic sandwich protection
4. Security through architecture
We didn't need extensive reentrancy guards or complex state machines. The security comes from:
- Explicit account passing
- Transaction atomicity
- Instruction introspection
- Program Derived Addresses (PDAs)
These primitives combine to create a system that's secure by default.
5. The value of pair programming (Even with AI)
Working with AI was illuminating. When I hit a wall, explaining the problem to the AI forced me to think clearly about what was actually happening. Sometimes I'd solve the issue while typing the question. Other times, the AI would suggest an approach I hadn't considered and these led me down productive paths.
The Final implementation
After all the iterations, here's what worked:
Total Lines of Code: ~200
Number of Instructions: 2 (borrow, repay)
Security Features:
- Instruction introspection
- Account validation
- Transaction atomicity
- PDA-based authority
Key Files:
-
lib.rs: Main program logic -
Cargo.toml: Dependencies (anchor-lang, anchor-spl) -
Anchor.toml: Project configuration
What's next?
This implementation is educational, not production-ready. To make it real:
1. Add price oracle integration
Currently, the protocol has a fixed fee (5%). Real flash loan protocols adjust fees based on:
- Market volatility
- Protocol utilization rates
- Risk metrics
2. Implement multi-token support
Right now it's single-token. Production systems support:
- Multiple token pools
- Cross-token flash loans
- Liquidity management across assets
3. Add governance
Who controls the protocol? What are the fees? Production systems need:
- DAO governance
- Parameter adjustment mechanisms
- Emergency pause functionality
4. Gas optimization
Every instruction on Solana costs compute units. Optimizations could include:
- Minimizing account validations
- Batching operations
- Using more efficient data structures
5. Comprehensive testing
My tests were basic. Production needs:
- Fuzz testing
- Integration tests with real DeFi protocols
- Economic attack simulations
- Formal verification (for critical paths)
Reflections
When I started this challenge, I couldn't have imagined the depth of learning ahead. What began as a resume-building exercise became a masterclass in:
- New programming paradigms (Solana's account model)
- Systems thinking (how transaction atomicity enables new primitives)
- Debugging across abstraction layers (from Rust borrow checker to on-chain execution)
- Financial engineering (flash loans as a DeFi primitive)
The most valuable lesson? Discomfort is where growth happens. I was uncomfortable with Rust, uncertain about Solana, and intimidated by the challenge. But that discomfort was a signal—I was learning.
Would I do it again? Absolutely. In fact, I'm already eyeing the next challenge.
Resources
If you want to explore flash loans and Solana development:
Solana fundamentals:
Flash loan concepts:
Instruction introspection:
Development tools:
Appendix: full code
For those who want to see the complete implementation:
use anchor_lang::prelude::*;
use anchor_spl::{
token::{Token, TokenAccount, Mint, Transfer, transfer},
associated_token::AssociatedToken
};
use anchor_lang::solana_program::sysvar::instructions::{
ID as INSTRUCTIONS_SYSVAR_ID,
load_instruction_at_checked,
load_current_index_checked
};
declare_id!("22222222222222222222222222222222222222222222");
#[program]
pub mod blueshift_anchor_flash_loan {
use super::*;
pub fn borrow(ctx: Context<Loan>, borrow_amount: u64) -> Result<()> {
require!(borrow_amount > 0, ProtocolError::InvalidAmount);
let ixs = ctx.accounts.instructions.to_account_info();
// Check if this is the first instruction in the transaction
let current_index = load_current_index_checked(&ctx.accounts.instructions)?;
require_eq!(current_index, 0, ProtocolError::InvalidIx);
// Check how many instructions we have in this transaction
let len = {
let instruction_sysvar = ixs.try_borrow_data()?;
u16::from_le_bytes(instruction_sysvar[0..2].try_into().unwrap())
};
// Ensure we have a repay instruction
if let Ok(repay_ix) = load_instruction_at_checked((len - 1) as usize, &ixs) {
require_keys_eq!(repay_ix.program_id, crate::ID, ProtocolError::InvalidProgram);
require!(repay_ix.data.len() >= 8, ProtocolError::InvalidIx);
require_keys_eq!(
repay_ix.accounts.get(3).ok_or(ProtocolError::InvalidBorrowerAta)?.pubkey,
ctx.accounts.borrower_ata.key(),
ProtocolError::InvalidBorrowerAta
);
require_keys_eq!(
repay_ix.accounts.get(4).ok_or(ProtocolError::InvalidProtocolAta)?.pubkey,
ctx.accounts.protocol_ata.key(),
ProtocolError::InvalidProtocolAta
);
} else {
return Err(ProtocolError::MissingRepayIx.into());
}
// Transfer the borrowed amount to the borrower
let seeds = &[b"protocol".as_ref(), &[ctx.bumps.protocol]];
let signer_seeds = &[&seeds[..]];
transfer(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.protocol_ata.to_account_info(),
to: ctx.accounts.borrower_ata.to_account_info(),
authority: ctx.accounts.protocol.to_account_info(),
},
signer_seeds
),
borrow_amount
)?;
msg!("Borrowed {} tokens", borrow_amount);
Ok(())
}
pub fn repay(ctx: Context<Loan>) -> Result<()> {
let ixs = ctx.accounts.instructions.to_account_info();
let amount_borrowed = if let Ok(borrow_ix) = load_instruction_at_checked(0, &ixs) {
let mut borrowed_data: [u8; 8] = [0u8; 8];
borrowed_data.copy_from_slice(&borrow_ix.data[8..16]);
u64::from_le_bytes(borrowed_data)
} else {
return Err(ProtocolError::MissingBorrowIx.into());
};
// Calculate 5% fee (500 basis points)
let fee = (amount_borrowed as u128)
.checked_mul(500)
.unwrap()
.checked_div(10_000)
.ok_or(ProtocolError::Overflow)? as u64;
let repay_amount = amount_borrowed
.checked_add(fee)
.ok_or(ProtocolError::Overflow)?;
transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.borrower_ata.to_account_info(),
to: ctx.accounts.protocol_ata.to_account_info(),
authority: ctx.accounts.borrower.to_account_info(),
}
),
repay_amount
)?;
msg!("Repaid {} tokens (borrowed: {}, fee: {})", repay_amount, amount_borrowed, fee);
Ok(())
}
}
#[derive(Accounts)]
pub struct Loan<'info> {
#[account(mut)]
pub borrower: Signer<'info>,
#[account(seeds = [b"protocol".as_ref()], bump)]
pub protocol: SystemAccount<'info>,
pub mint: Account<'info, Mint>,
#[account(
init_if_needed,
payer = borrower,
associated_token::mint = mint,
associated_token::authority = borrower,
)]
pub borrower_ata: Account<'info, TokenAccount>,
#[account(
mut,
associated_token::mint = mint,
associated_token::authority = protocol,
)]
pub protocol_ata: Account<'info, TokenAccount>,
#[account(address = INSTRUCTIONS_SYSVAR_ID)]
/// CHECK: InstructionsSysvar account
pub instructions: UncheckedAccount<'info>,
pub token_program: Program<'info, Token>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>
}
#[error_code]
pub enum ProtocolError {
#[msg("Invalid instruction")]
InvalidIx,
#[msg("Invalid instruction index")]
InvalidInstructionIndex,
#[msg("Invalid amount")]
InvalidAmount,
#[msg("Not enough funds")]
NotEnoughFunds,
#[msg("Program Mismatch")]
ProgramMismatch,
#[msg("Invalid program")]
InvalidProgram,
#[msg("Invalid borrower ATA")]
InvalidBorrowerAta,
#[msg("Invalid protocol ATA")]
InvalidProtocolAta,
#[msg("Missing repay instruction")]
MissingRepayIx,
#[msg("Missing borrow instruction")]
MissingBorrowIx,
#[msg("Overflow")]
Overflow,
}
Top comments (0)