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 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 Arch project
arch-cli project create --name runes-swap
cd runes-swap
# 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(())
}
Final Steps: Building and Deploying
Now that we've implemented our swap program, let's build and deploy it:
# Build the program
cargo build-bpf
# Deploy to your local test validator
arch-cli program deploy target/deploy/runes_swap.so
Testing the Deployed Program
Here's a simple script to test our deployed program:
import { Connection, PublicKey, Transaction } from '@archway/web3.js';
import { RunesSwapProgram } from './program';
async function testSwap() {
// Connect to local test validator
const connection = new Connection('http://localhost:8899', 'confirmed');
// Create a new swap offer
const offer = await RunesSwapProgram.createOffer({
runeIdGive: 'RUNE1',
amountGive: 100,
runeIdWant: 'RUNE2',
amountWant: 200,
expiry: Date.now() + 3600000, // 1 hour from now
});
console.log('Created offer:', offer);
}
testSwap().catch(console.error);
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!