Building Your First Bitcoin Runes Swap Application
Welcome to this hands-on tutorial! Today, we're going to build a decentralized application that enables users to swap Bitcoin Runes tokens on the Arch Network. By the end of this lesson, you'll understand how to create a secure, trustless swap mechanism for Runes tokens.
Class Prerequisites
Before we dive in, please 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 Network CLI installed
Lesson 1: Understanding the Basics
What are Runes?
Before we write any code, let's understand what we're working with. Runes is a Bitcoin protocol for fungible tokens, similar to how BRC-20 works. Each Rune token has a unique identifier and can be transferred between Bitcoin addresses.
What are we building?
We're creating a swap program that will:
- Allow users to create swap offers ("I want to trade X amount of Rune A for Y amount of Rune B")
- Enable other users to accept these offers
- Let users cancel their offers if they change their mind
- Ensure all swaps are atomic (they either complete fully or not at all)
Lesson 2: Setting Up Our Project
Let's start by creating our project structure. Open your terminal and run:
# Create a new directory for your project
mkdir runes-swap
cd runes-swap
# Initialize a new Rust project
cargo init --lib
# Your project structure should look like this:
# runes-swap/
# ├── Cargo.toml
# ├── src/
# │ └── lib.rs
Lesson 3: Defining Our Data Structures
Now, let's define the building blocks of our swap program. In programming, it's crucial to plan our data structures before implementing functionality.
use arch_program::{
account::AccountInfo,
entrypoint,
msg,
program_error::ProgramError,
pubkey::Pubkey,
utxo::UtxoMeta,
borsh::{BorshDeserialize, BorshSerialize},
};
/// This structure represents a single swap offer in our system
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct SwapOffer {
// Unique identifier for the offer
pub offer_id: u64,
// The public key of the person creating the offer
pub maker: Pubkey,
// The Rune ID they want to give
pub rune_id_give: String,
// Amount of Runes they want to give
pub amount_give: u64,
// The Rune ID they want to receive
pub rune_id_want: String,
// Amount of Runes they want to receive
pub amount_want: u64,
// When this offer expires (in block height)
pub expiry: u64,
// Current status of the offer
pub status: OfferStatus,
}
Let's break down why we chose each field:
offer_id
: Every offer needs a unique identifier so we can reference it latermaker
: We store who created the offer to ensure only they can cancel itrune_id_give/want
: These identify which Runes are being swappedamount_give/want
: The quantities of each Rune in the swapexpiry
: Offers shouldn't live forever, so we add an expiration
Lesson 4: Implementing the Swap Logic
Now that we understand our data structures, let's implement the core swap functionality. We'll start with creating an offer:
fn process_create_offer(
accounts: &[AccountInfo],
instruction: SwapInstruction,
) -> Result<(), ProgramError> {
// Step 1: Get all the accounts we need
let account_iter = &mut accounts.iter();
let maker = next_account_info(account_iter)?;
let offer_account = next_account_info(account_iter)?;
// Step 2: Verify the maker has the Runes they want to swap
if let SwapInstruction::CreateOffer {
rune_id_give,
amount_give,
rune_id_want,
amount_want,
expiry
} = instruction {
// Security check: Ensure the maker owns enough Runes
verify_rune_ownership(maker, &rune_id_give, amount_give)?;
// Step 3: Create and store the offer
let offer = SwapOffer {
offer_id: get_next_offer_id(offer_account)?,
maker: *maker.key,
rune_id_give,
amount_give,
rune_id_want,
amount_want,
expiry,
status: OfferStatus::Active,
};
store_offer(offer_account, &offer)?;
}
Ok(())
}
Understanding the Create Offer Process
- First, we extract the accounts passed to our program
- We verify that the maker actually owns the Runes they want to trade
- We create a new
SwapOffer
with an Active status - Finally, we store this offer in the program's state
Lesson 5: Testing Our Program
Testing is crucial in blockchain development because once deployed, your program can't be easily changed. Let's write comprehensive tests for our swap program.
#[cfg(test)]
mod tests {
use super::*;
use arch_program::test_utils::{create_test_account, create_test_pubkey};
/// Helper function to create a test offer
fn create_test_offer() -> SwapOffer {
SwapOffer {
offer_id: 1,
maker: create_test_pubkey(),
rune_id_give: "RUNE1".to_string(),
amount_give: 100,
rune_id_want: "RUNE2".to_string(),
amount_want: 200,
expiry: 1000,
status: OfferStatus::Active,
}
}
#[test]
fn test_create_offer() {
// Arrange: Set up our test accounts
let maker = create_test_account();
let offer_account = create_test_account();
// Act: Create an offer
let result = process_create_offer(
&[maker.clone(), offer_account.clone()],
SwapInstruction::CreateOffer {
rune_id_give: "RUNE1".to_string(),
amount_give: 100,
rune_id_want: "RUNE2".to_string(),
amount_want: 200,
expiry: 1000,
},
);
// Assert: Check the result
assert!(result.is_ok());
// Add more assertions here to verify the offer was stored correctly
}
}
Understanding Our Test Structure
We follow the "Arrange-Act-Assert" pattern:
- Arrange: Set up the test environment and data
- Act: Execute the functionality we're testing
- Assert: Verify the results match our expectations
Lesson 6: Implementing Offer Acceptance
Now let's implement the logic for accepting an offer. This is where atomic swaps become crucial:
fn process_accept_offer(
accounts: &[AccountInfo],
instruction: SwapInstruction,
) -> Result<(), ProgramError> {
// Step 1: Get all required accounts
let account_iter = &mut accounts.iter();
let taker = next_account_info(account_iter)?;
let maker = next_account_info(account_iter)?;
let offer_account = next_account_info(account_iter)?;
if let SwapInstruction::AcceptOffer { offer_id } = instruction {
// Step 2: Load and validate the offer
let mut offer = load_offer(offer_account)?;
require!(
offer.status == OfferStatus::Active,
ProgramError::InvalidAccountData
);
require!(
offer.offer_id == offer_id,
ProgramError::InvalidArgument
);
// Step 3: Verify the taker has the required Runes
verify_rune_ownership(taker, &offer.rune_id_want, offer.amount_want)?;
// Step 4: Perform the atomic swap
// Transfer Runes from maker to taker
transfer_runes(
maker,
taker,
&offer.rune_id_give,
offer.amount_give,
)?;
// Transfer Runes from taker to maker
transfer_runes(
taker,
maker,
&offer.rune_id_want,
offer.amount_want,
)?;
// Step 5: Update offer status
offer.status = OfferStatus::Completed;
store_offer(offer_account, &offer)?;
}
Ok(())
}
Understanding Atomic Swaps
An atomic swap ensures that either:
- Both transfers complete successfully, or
- Neither transfer happens at all
This is crucial for preventing partial swaps where one party could lose their tokens.
Lesson 7: Implementing Offer Cancellation
Finally, let's implement the ability to cancel offers:
fn process_cancel_offer(
accounts: &[AccountInfo],
instruction: SwapInstruction,
) -> Result<(), ProgramError> {
let account_iter = &mut accounts.iter();
let maker = next_account_info(account_iter)?;
let offer_account = next_account_info(account_iter)?;
if let SwapInstruction::CancelOffer { offer_id } = instruction {
// Load the offer
let mut offer = load_offer(offer_account)?;
// Security checks
require!(
offer.maker == *maker.key,
ProgramError::InvalidAccountData
);
require!(
offer.status == OfferStatus::Active,
ProgramError::InvalidAccountData
);
require!(
offer.offer_id == offer_id,
ProgramError::InvalidArgument
);
// Update offer status
offer.status = OfferStatus::Cancelled;
store_offer(offer_account, &offer)?;
}
Ok(())
}
Deploying Your Runes Swap Program
After you've written and tested your program, it's time to deploy it to the Arch Network:
# Build the program
cargo build-sbf
# Deploy the program to the Arch Network
cli deploy target/deploy/runes_swap.so
Make sure you have a validator node running before deployment:
# Start a local validator
cli validator-start
Conclusion
Congratulations! You've built a complete Runes swap program. This program demonstrates several important blockchain concepts:
- Atomic transactions
- State management
- Security checks
- Program testing
Remember to always:
- Test thoroughly before deployment
- Consider edge cases
- Implement proper error handling
- Add detailed documentation
Next Steps
To further improve your program, consider adding:
- A UI for interacting with the swap program
- More sophisticated offer matching
- Order book functionality
- Price oracle integration
- Additional security features
Questions? Feel free to ask in the comments below!
Implementation Details
Runes Transfer Implementation
Let's look at the implementation of the transfer_runes
function used in our swap program:
/// Transfers Runes tokens from one account to another
///
/// # Arguments
/// * `from` - The account sending the Runes
/// * `to` - The account receiving the Runes
/// * `rune_id` - The identifier of the Rune to transfer
/// * `amount` - The amount of Runes to transfer
///
/// # Returns
/// * `Result<(), ProgramError>` - Success or error code
fn transfer_runes(
from: &AccountInfo,
to: &AccountInfo,
rune_id: &str,
amount: u64,
) -> Result<(), ProgramError> {
// Step 1: Get Bitcoin script pubkey for both accounts
let from_script = get_account_script_pubkey(from.key)?;
let to_script = get_account_script_pubkey(to.key)?;
// Step 2: Create a Bitcoin transaction for the Rune transfer
let mut tx = Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: vec![],
output: vec![],
};
// Step 3: Get UTXOs associated with the sender
let utxos = get_account_utxos(from)?;
// Step 4: Find UTXOs with the specified Rune
let rune_utxos = utxos.iter()
.filter(|utxo| has_rune(utxo, rune_id))
.collect::<Vec<_>>();
// Step 5: Verify sender has enough of the rune
let total_runes = rune_utxos.iter()
.map(|utxo| get_rune_amount(utxo, rune_id))
.sum::<u64>();
require!(
total_runes >= amount,
ProgramError::InsufficientRuneBalance
);
// Step 6: Select UTXOs for the transfer
let selected_utxos = select_utxos_for_transfer(
&rune_utxos,
rune_id,
amount
)?;
// Step 7: Add inputs from selected UTXOs
for utxo in &selected_utxos {
tx.input.push(TxIn {
previous_output: OutPoint::new(utxo.txid.into(), utxo.vout),
script_sig: Script::new(),
sequence: Sequence::MAX,
witness: Witness::new(),
});
}
// Step 8: Calculate total input amount
let total_input_amount = selected_utxos.iter()
.map(|utxo| utxo.amount)
.sum::<u64>();
// Step 9: Create output with rune transfer
let runes_data = create_runes_data(rune_id, amount);
tx.output.push(TxOut {
value: DUST_LIMIT, // Minimum amount for a valid output
script_pubkey: to_script.clone(),
});
// Step 10: Add change output if needed
if total_input_amount > DUST_LIMIT {
// Return change to sender
let change_amount = total_input_amount - DUST_LIMIT;
let change_runes = total_runes - amount;
// Create change output with remaining runes
if change_amount > 0 {
let change_data = create_runes_data(rune_id, change_runes);
tx.output.push(TxOut {
value: change_amount,
script_pubkey: from_script.clone(),
});
}
}
// Step 11: Create transaction signing request
let tx_to_sign = TransactionToSign {
tx_bytes: &bitcoin::consensus::serialize(&tx),
inputs_to_sign: &selected_utxos.iter()
.enumerate()
.map(|(i, utxo)| InputToSign {
index: i as u32,
signer: *from.key,
})
.collect::<Vec<_>>(),
};
// Step 12: Submit transaction for signing by the Arch runtime
set_transaction_to_sign(&[from.clone(), to.clone()], tx_to_sign)?;
Ok(())
}
/// Gets UTXOs associated with an account
fn get_account_utxos(account: &AccountInfo) -> Result<Vec<UtxoMeta>, ProgramError> {
// In a real implementation, this would query the Arch state
// to get UTXOs associated with the account
// This is a simplified placeholder implementation
// For tutorial purposes, we simulate fetching UTXOs
Ok(vec![])
}
/// Checks if a UTXO contains a specific Rune
fn has_rune(utxo: &UtxoMeta, rune_id: &str) -> bool {
// In a real implementation, this would parse the Bitcoin
// transaction data to check for Rune presence
// This is a simplified placeholder for the tutorial
true // For tutorial purposes
}
/// Gets the amount of a specific Rune in a UTXO
fn get_rune_amount(utxo: &UtxoMeta, rune_id: &str) -> u64 {
// In a real implementation, this would parse the Bitcoin
// transaction data to get the Rune amount
// This is a simplified placeholder for the tutorial
1000 // For tutorial purposes
}
/// Creates Rune-specific data for transaction outputs
fn create_runes_data(rune_id: &str, amount: u64) -> Vec<u8> {
// In a real implementation, this would create the proper
// script or OP_RETURN data to encode Rune information
// This is a simplified placeholder for the tutorial
vec![] // For tutorial purposes
}
/// Selects appropriate UTXOs for a Rune transfer
fn select_utxos_for_transfer(
utxos: &[&UtxoMeta],
rune_id: &str,
amount: u64,
) -> Result<Vec<UtxoMeta>, ProgramError> {
// In a real implementation, this would implement a UTXO
// selection algorithm optimized for Rune transfers
// This is a simplified placeholder for the tutorial
// Simply clone the first UTXO for the tutorial
if let Some(utxo) = utxos.first() {
Ok(vec![(*utxo).clone()])
} else {
Err(ProgramError::InsufficientFunds)
}
}
The transfer_runes
function implements the core logic for transferring Runes tokens between accounts. It:
- Gets the Bitcoin script pubkeys for the sender and receiver
- Creates a new Bitcoin transaction
- Finds UTXOs containing the desired Rune
- Selects appropriate UTXOs for the transfer
- Creates outputs with proper Rune encoding
- Handles change output for remaining Runes
- Sets up the transaction for signing by the Arch runtime
Rune Ownership Verification
Let's also look at the implementation of the verify_rune_ownership
function:
/// Verifies that an account owns sufficient Runes
///
/// # Arguments
/// * `account` - The account to check
/// * `rune_id` - The identifier of the Rune to verify
/// * `required_amount` - The amount of Runes required
///
/// # Returns
/// * `Result<(), ProgramError>` - Success or error code
fn verify_rune_ownership(
account: &AccountInfo,
rune_id: &str,
required_amount: u64,
) -> Result<(), ProgramError> {
// Step 1: Get UTXOs associated with the account
let utxos = get_account_utxos(account)?;
// Step 2: Filter UTXOs that contain the specified Rune
let rune_utxos = utxos.iter()
.filter(|utxo| has_rune(utxo, rune_id))
.collect::<Vec<_>>();
// Step 3: Calculate total Runes owned
let total_owned = rune_utxos.iter()
.map(|utxo| get_rune_amount(utxo, rune_id))
.sum::<u64>();
// Step 4: Verify the account has enough Runes
if total_owned < required_amount {
msg!(
"Insufficient Rune balance. Required: {}, Available: {}",
required_amount,
total_owned
);
return Err(ProgramError::InsufficientRuneBalance);
}
Ok(())
}
This function validates that an account owns a sufficient amount of a specific Rune by:
- Getting the account's UTXOs
- Filtering those containing the specified Rune
- Calculating the total Rune amount owned
- Verifying the account has enough to meet the required amount