Welcome to Arch Network
What is Arch Network?
Arch Network is a computation environment that enhances Bitcoin's capabilities by enabling complex operations on Bitcoin UTXOs through its specialized virtual machine. Unlike Layer 2 solutions, Arch Network provides a native computation layer that works directly with Bitcoin's security model.
Key Features
Bitcoin-Native
Direct integration with Bitcoin through UTXO management
Computation Environment
Execute complex programs within the Arch VM
Program Development
Write programs in Rust to interact with Bitcoin UTXOs
Security
Leverages Bitcoin's proven security guarantees through multi-signature validation
Developer Tools
Complete development environment with CLI tools and explorer
Choose Your Path
🚀 Quick Start (15 minutes)
🎓 Learning Path
-
Network Architecture - Understand how nodes work together
-
Bitcoin Integration - Learn Bitcoin interaction
-
Program Development - Write programs
🛠 Reference
Core Architecture
How Arch Works
- Network Layer
- Network Architecture
- Bootnode: Network discovery and peer management
- Leader Node: Transaction coordination
- Validator Nodes: Program execution
- Bitcoin Integration
- UTXO Management
- Transaction tracking
- State anchoring
- Ownership validation
- RPC Integration
- Bitcoin node communication
- Transaction submission
- Network synchronization
- Computation Layer
- Programs
- Transaction Processing
- Message validation
- State updates
- UTXO management
Prerequisites
- Node.js v19+ (installation guide)
- Rust (latest stable)
- Docker for local development
- Basic understanding of Bitcoin UTXOs
Next Steps
Need Help?
Setting up the environment
Here you will find instructions on how to run a local Arch Network development environment.
The core components are outlined within this section to help paint a model for how applications interact with Arch and the Bitcoin network.
System Requirements
Welcome to the Arch Network development guide. This page will walk you through setting up your development environment with all necessary dependencies. Please follow each section carefully to ensure a smooth setup process.
Overview
Before you begin development with Arch Network, you'll need to install and configure the following tools:
Requirement | Minimum Version | Description |
---|---|---|
Rust | Latest stable | Core development language |
Docker | Latest | Container runtime for local node infrastructure |
C++ Compiler | gcc/clang | Required for native builds |
Node.js | v19+ | JavaScript runtime for SDK |
Solana CLI | v1.18.18 | Solana development tools |
[Arch CLI] | Latest | Arch Network development toolkit |
Detailed Installation Guide
1. Install Rust
Rust is the primary development language for Arch Network programs.
# Install Rust using rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Verify installation
rustc --version
cargo --version
💡 Note: Make sure you're using the stable channel throughout this book.
2. Install Docker
Docker is essential for running Arch's local node infrastructure.
- Download Docker Desktop from the Docker website
- Follow the installation wizard for your operating system
- Start Docker Desktop
- Verify installation:
docker --version
3. C++ Compiler Setup
MacOS Users
The C++ compiler comes pre-installed with Xcode Command Line Tools. Verify with:
gcc --version
If not installed, run:
xcode-select --install
Linux Users (Debian/Ubuntu)
Install the required compiler tools:
sudo apt-get update
sudo apt-get install gcc-multilib build-essential
4. Install Node.js
Node.js is required for working with the arch-typescript-sdk.
# Using nvm (recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install 19
nvm use 19
# Verify installation
node --version # Should show v19.x.x or higher
npm --version
5. Install Solana CLI
The Solana CLI is required for program compilation and deployment.
sh -c "$(curl -sSfL https://release.solana.com/v1.18.18/install)"
⚠️ Important Notes:
- Solana v2.x is not supported
- You can use stable, beta, or edge channels instead of v1.18.18
- Add Solana to your PATH as instructed after installation
Troubleshooting Solana Installation
If you installed Rust through Homebrew and encounter cargo-build-sbf
issues:
- Remove existing Rust installation:
rustup self uninstall
- Verify removal:
rustup --version # Should show "command not found"
- Perform clean Rust installation:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
- Reinstall Solana:
sh -c "$(curl -sSfL https://release.solana.com/v1.18.18/install)"
6. Install Arch CLI
The Arch CLI provides essential development tools and a local development environment.
# Clone the repository
git clone https://github.com/arch-Network/arch-cli
cd arch-cli
# Install the CLI
cargo install --path .
# Verify installation
arch-cli --version
Features
The Arch CLI provides:
- Local Arch Network development environment
- Project setup and deployment tools
- Example dapp with Arch functionality
- Mini block explorer for transaction monitoring
Need Help?
- Check our Troubleshooting Guide
- Join our Discord dev-chat for community support
- Review the arch-cli repo documentation
- Ensure all version requirements are met
Setting up an Arch Network Project
This guide will walk you through setting up a new Arch Network project using the arch-cli
tool.
Prerequisites
Before starting, ensure you have the following dependencies installed:
- Docker (v27.3.1 or later)
- Docker Compose (v2.29.7 or later)
- Node.js (v22.9.0 or later)
- Solana CLI (v1.18.22 or later)
- Cargo (v1.81.0 or later)
Project Setup Process
1. Initialize the CLI Environment
First, run the initialization command:
arch-cli init
This command will:
- Create the necessary folder structure
- Set up boilerplate code
- Configure Docker settings
- Create a default configuration file
2. Configuration Setup
The CLI uses a config.toml
file for managing settings. This file will be automatically created during initialization.
Configuration File Location
The default locations for config.toml
are:
- Linux:
~/.config/arch-cli/config.toml
- macOS:
~/Library/Application Support/arch-cli/config.toml
- Windows:
C:\Users\<User>\AppData\Roaming\arch-cli\config.toml
You can specify a custom location using:
export ARCH_CLI_CONFIG=/path/to/your/config.toml
Key Configuration Settings
The config.toml
file contains important settings for:
- Network configuration
- Bitcoin node settings
- Program keys
- Service ports
- Arch network parameters
For reference, here's the default configuration structure:
[network]
type = "development"
[bitcoin]
docker_compose_file = "./bitcoin-docker-compose.yml"
network = "regtest"
rpc_endpoint = "http://localhost:18443"
rpc_port = "18443"
rpc_user = "bitcoin"
rpc_password = "password"
rpc_wallet = "devwallet"
services = ["bitcoin", "electrs", "btc-rpc-explorer"]
# ... Additional configuration sections ...
# See full example in documentation
3. Create a New Project
Once the CLI is initialized, you can create a new project using:
arch-cli project create --name my_app
This will:
- Create a new project directory in your configured location
- Set up the necessary project structure
- Configure project-specific settings
Project Structure
Your project will be organized as follows:
sample/
├─ app/ # Frontend application
│ ├─ frontend/
│ │ ├─ node_modules/ # Frontend dependencies
│ │ ├─ public/ # Static assets
│ │ ├─ src/ # Frontend source code
│ │ ├─ eslint.config.js
│ │ ├─ index.html
│ │ ├─ package-lock.json
│ │ ├─ package.json
│ │ ├─ README.md
│ │ └─ vite.config.js
├─ program/ # Backend program
│ ├─ src/
│ │ └─ lib.rs # Rust source code
│ ├─ Cargo.lock
│ └─ Cargo.toml
Next Steps
After setting up your project:
- Review the generated
config.toml
file - Familiarize yourself with the project structure
- Begin development in your project directory
For more detailed information about specific components, refer to their respective documentation sections.
Starting the stack
Configure
Docker
First, ensure that your Docker client is up-to-date and that the DOCKER_DEFAULT_PLATFORM
environment variable is properly set (within your ~/.bashrc
or shell of choice) to your machine's architecture.
# Eg, for Apple-Silicon users:
export DOCKER_DEFAULT_PLATFORM=linux/amd64
Start the validator
This spins up a lightweight validator that effectively serves the purpose of testing program deployment and functionality by simulating a single-node blockchain environment locally.
This setup is much less resource intensive than running the Self-contained Arch Network and includes only the VM component needed to test business logic.
Note: If you are looking to work on core components of Arch Network or would like to understand how Arch validators communicate with one another, we recommend looking into the Self-contained Arch Network setup.
The following commands will assist you in provisioning the local validator. Simply start
the validator to begin testing your program logic.
arch-cli validator start [options]
If everything pulls and builds correctly, you should see something resembling the following in your logs:
Welcome to the Arch Network CLI
→ Loading configuration from /Users/jr/Library/Application Support/arch-cli/config.toml
Starting the local validator...
Local validator started successfully!
To stop the validator, simply issue the corresponding stop
command.
arch-cli validator stop
If everything stops correctly, you should something resembling the following in your logs:
Welcome to the Arch Network CLI
→ Loading configuration from /Users/jr/Library/Application Support/arch-cli/config.toml
Stopping the local validator...
Local validator stopped successfully!
Now that everything is configured and the local validator is up and running, it's time learn how to build, deploy and interact with a program.
Nodes
Let's introduce the nodes that comprise the Arch Network stack in greater detail.
The bootnode works similarly to DNS seeds in Bitcoin whereby the server handles the first connection to nodes joining the Arch Network.
All signing is coordinated by the leader. Ultimately, the leader submits signed Bitcoin transactions to the Bitcoin network following program execution.
This node represents a generic node operated by another party. It performs the validator role and has a share in the network's distributed signing key. The leader node passes transactions to validator nodes to validate and sign. After enough signatures have been collected (a threshold has been met), the leader can then submit a fully signed Bitcoin transaction to the Bitcoin network.
The validator node also runs the eBPF virtual machine and executes the transactions asynchronously alongside the other validator nodes in the network.
This validator is a lightweight server that only serves as an RPC for developers to get up and running quickly with the least amount of overhead. It simulates a single-node blockchain environment that is meant for efficient, rapid development.
Note: the Lightweight Validator node uses the same image as the Validator node though operates singularly for maximum efficiency.
More can be read about the Arch Network architecture in our docs.
Resources
Bitcoin mempool and blockchain explorer
- mempool.space - Arch Regtest
- Bitcoin mempool and block explorer. This mempool.space instance monitors the regtest Bitcoin blockchain being used to run and validate all examples in this repo.
- Solana CLI
- Solana Local Development Guide
Architecture Overview
Core Components
graph TB subgraph "Arch Network" VM[Arch VM<br/>eBPF-based] BTC[Bitcoin Integration] subgraph "Validator Network" L[Leader Node] V1[Validator Node 1] V2[Validator Node 2] V3[Validator Node ...] B[Bootnode] end VM --> BTC L --> V1 L --> V2 L --> V3 B --> V1 B --> V2 B --> V3 end
Arch VM
The Arch Virtual Machine (VM) is built on eBPF technology, providing a secure and efficient environment for executing programs.
Key features:
- 🔄 Manages program execution
- ⚡ Handles state transitions
- 🎯 Ensures deterministic computation
- 🔗 Provides syscalls for Bitcoin UTXO operations
Bitcoin Integration
Arch Network interacts directly with Bitcoin through:
- 💼 Native UTXO management
- ✅ Transaction validation
- 🔐 Multi-signature coordination
- 📝 State commitment to Bitcoin
Validator Network
The validator network consists of multiple node types that work together:
Node Types
Node Type | Primary Responsibilities |
---|---|
Leader Node | • Coordinates transaction signing • Submits signed transactions to Bitcoin • Manages validator communication |
Validator Nodes | • Execute programs in the Arch VM • Validate transactions • Participate in multi-signature operations • Maintain network state |
Bootnode | • Handles initial network discovery • Similar to Bitcoin DNS seeds • Helps new nodes join the network |
Transaction Flow
sequenceDiagram participant C as Client participant L as Leader participant V as Validators participant B as Bitcoin Network C->>L: 1. Submit Transaction L->>V: 2. Distribute to Validators V->>V: 3. Execute in Arch VM V->>L: 4. Sign Results L->>B: 5. Submit to Bitcoin
Security Model
Arch Network implements a robust multi-layered security model that directly leverages Bitcoin's security guarantees:
1. UTXO Security
-
🔒 Ownership Verification
- Public key cryptography using secp256k1
- BIP322 message signing for secure ownership proofs
- Double-spend prevention through UTXO consumption tracking
-
🔗 State Management
- State anchoring to Bitcoin transactions
- Atomic state transitions with rollback capability
- Cross-validator state consistency checks
2. Transaction Security
pub struct SecurityParams {
pub min_confirmations: u32, // Required Bitcoin confirmations
pub signature_threshold: u32, // Multi-sig threshold
pub timelock_blocks: u32, // Timelock requirement
pub max_witness_size: usize // Maximum witness data size
}
- 📝 Multi-signature Validation
- ROAST protocol for distributed signing
- Threshold signature scheme (t-of-n)
- Malicious signer detection and removal
- Binding factor verification for signature shares
3. Network Security
-
🌐 Validator Selection
pub struct ValidatorSet { pub validators: Vec<ValidatorInfo>, pub threshold: u32 }
- Stake-weighted validator participation
- Dynamic threshold adjustment
- Automatic malicious node detection
-
🛡️ State Protection
- Multi-stage transaction verification
- Bitcoin-based finality guarantees
- State root commitment to Bitcoin
- Mandatory signature verification for all state changes
4. Best Practices
-
✅ UTXO Management
- Minimum 6 confirmations for finality
- Comprehensive UTXO validation
- Double-spend monitoring
- Reorg handling for UTXO invalidation
-
🔍 Transaction Processing
- Full signature verification
- Input/output validation
- Proper error handling
- Network partition handling
Network Architecture
Arch Network operates as a distributed system with different types of nodes working together to provide secure and efficient program execution on Bitcoin. This document details the network's architecture and how different components interact.
Network Overview
flowchart TB subgraph Core["Core Components"] direction TB BN[Bitcoin Network] Boot[Bootnode] end subgraph Leader["Leader Node Services"] direction LR TC[Transaction\nCoordination] MS[MultiSig\nAggregation] end subgraph Validators["Validator Network"] direction TB V1[Validator 1] V2[Validator 2] V3[Validator 3] VN[Validator N] end BN --> LN[Leader Node] Boot --> LN LN --> TC LN --> MS LN --> V1 LN --> V2 LN --> V3 LN --> VN %% Styling classDef core fill:#e1f5fe,stroke:#01579b classDef leader fill:#fff3e0,stroke:#e65100 classDef validators fill:#f3e5f5,stroke:#4a148c class BN,Boot core class TC,MS leader class V1,V2,V3,VN validators
Node Types
1. Bootnode
The bootnode serves as the network's entry point, similar to DNS seeds in Bitcoin:
- Handles initial network discovery
- Maintains whitelist of valid validators
- Coordinates peer connections
- Manages network topology
flowchart LR subgraph Bootnode["Bootnode Services"] direction TB PR[Peer Registry] WL[Validator Whitelist] end NN[New Node] VN[Validator Network] %% Connections NN <--> PR PR <--> VN WL -.-> PR %% Styling classDef bootnode fill:#e1f5fe,stroke:#01579b classDef external fill:#f5f5f5,stroke:#333 classDef connection stroke-width:2px class PR,WL bootnode class NN,VN external
Configuration:
cargo run -p bootnode -- \
--network-mode localnet \
--p2p-bind-port 19001 \
--leader-peer-id "<LEADER_ID>" \
--validator-whitelist "<VALIDATOR_IDS>"
2. Leader Node
The leader node coordinates transaction processing and Bitcoin integration:
flowchart TB %% Main Components BN[Bitcoin Network] LN[Leader Node] VN[Validator Network] PE[Program Execution] %% Leader Node Services subgraph Leader["Leader Node Services"] direction LR TC[Transaction\nCoordination] MS[Multi-sig\nAggregation] end %% Connections BN <--> LN LN --> Leader TC --> VN MS --> VN VN --> PE %% Styling classDef bitcoin fill:#f7931a,stroke:#c16c07,color:white classDef leader fill:#fff3e0,stroke:#e65100 classDef validator fill:#f3e5f5,stroke:#4a148c classDef execution fill:#e8f5e9,stroke:#1b5e20 class BN bitcoin class LN,TC,MS leader class VN validator class PE execution
Key responsibilities:
- Transaction coordination
- Multi-signature aggregation
- Bitcoin transaction submission
- Network state management
3. Validator Nodes
Validator nodes form the core of the network's computation and validation:
flowchart TB subgraph ValidatorNode["Validator Node"] direction TB subgraph Execution["Execution Layer"] direction LR VM["Arch VM\nExecution"] SV["State\nValidation"] end NP["Network Protocol"] P2P["P2P Network"] %% Connections within validator VM --> NP SV --> NP NP --> P2P end %% Styling classDef validator fill:#f3e5f5,stroke:#4a148c,stroke-width:2px classDef execution fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px classDef network fill:#e3f2fd,stroke:#0d47a1,stroke-width:2px class ValidatorNode validator class VM,SV execution class NP,P2P network
Types:
-
Full Validator
- Participates in consensus
- Executes programs
- Maintains full state
-
Lightweight Validator
- Local development use
- Single-node operation
- Simulated environment
Network Communication
P2P Protocol
The network uses libp2p for peer-to-peer communication:
pub const ENABLED_PROTOCOLS: [&str; 2] = [
ArchNetworkProtocol::STREAM_PROTOCOL,
ArchNetworkProtocol::VALIDATOR_PROTOCOL,
];
// Protocol versions
pub const PROTOCOL_VERSION: &str = "/arch/1.0.0";
pub const VALIDATOR_VERSION: &str = "/arch/validator/1.0.0";
Message Types
-
Network Messages
pub enum NetworkMessage { Discovery(DiscoveryMessage), State(StateMessage), Transaction(TransactionMessage), }
-
ROAST Protocol Messages
pub enum RoastMessage { KeyGeneration(KeyGenMessage), Signing(SigningMessage), Aggregation(AggregationMessage), }
Network Modes
1. Devnet
- Local development environment
- Single validator setup
- Simulated Bitcoin interactions
- Fast block confirmation
2. Testnet
- Test environment with multiple validators
- Bitcoin testnet integration
- Real network conditions
- Test transaction processing
3. Mainnet
- Production network
- Full security model
- Bitcoin mainnet integration
- Live transaction processing
Security Model
1. Validator Selection
pub struct ValidatorInfo {
pub peer_id: PeerId,
pub pubkey: Pubkey,
pub stake: u64,
}
pub struct ValidatorSet {
pub validators: Vec<ValidatorInfo>,
pub threshold: u32,
}
2. Transaction Security
- Multi-signature validation using ROAST protocol
- Threshold signing (t-of-n)
- Bitcoin-based finality
- Double-spend prevention
3. State Protection
pub struct StateUpdate {
pub block_height: u64,
pub state_root: Hash,
pub bitcoin_height: u64,
pub signatures: Vec<Signature>,
}
Monitoring and Telemetry
1. Node Metrics
pub struct NodeMetrics {
pub peer_id: PeerId,
pub network_mode: ArchNetworkMode,
pub bitcoin_block_height: u64,
pub arch_block_height: u64,
pub peers_connected: u32,
pub transactions_processed: u64,
pub program_count: u32,
}
2. Network Health
pub struct NetworkHealth {
pub validator_count: u32,
pub active_validators: u32,
pub network_tps: f64,
pub average_block_time: Duration,
pub fork_count: u32,
}
3. Monitoring Endpoints
/metrics
- Prometheus metrics/health
- Node health check/peers
- Connected peers/status
- Network status
Best Practices
1. Node Operation
- Secure key management
- Regular state verification
- Proper shutdown procedures
- Log management
2. Network Participation
- Maintain node availability
- Monitor Bitcoin integration
- Handle network upgrades
- Backup critical data
3. Development Setup
- Use lightweight validator for testing
- Monitor resource usage
- Handle network modes properly
- Implement proper error handling
Bitcoin Integration
Arch Network provides direct integration with Bitcoin, enabling programs to interact with Bitcoin's UTXO model while maintaining Bitcoin's security guarantees. This document details how Arch Network integrates with Bitcoin.
Architecture Overview
flowchart TB subgraph BN[Bitcoin Network] BNode[Bitcoin Node] end subgraph AN[Arch Network] LN[Leader Node\nBitcoin Integration] subgraph VN[Validator Network] P1[Program 1] PN[Program N] end end BNode <--> LN LN <--> VN P1 --- PN
Core Components
1. UTXO Management
Arch Network manages Bitcoin UTXOs through a specialized system:
flowchart LR subgraph UTXO[Bitcoin UTXO] TxID[Transaction\nID] OutIdx[Output\nIndex] end subgraph Account[Arch Account] Meta[UTXO\nMeta] State[Program\nState] end TxID --> Meta OutIdx --> State
// UTXO Metadata Structure
pub struct UtxoMeta {
pub txid: [u8; 32], // Transaction ID
pub vout: u32, // Output index
pub amount: u64, // Amount in satoshis
pub script_pubkey: Vec<u8>, // Output script
pub confirmation_height: Option<u32>, // Block height of confirmation
}
// UTXO Account State
pub struct UtxoAccount {
pub meta: UtxoMeta,
pub owner: Pubkey,
pub delegate: Option<Pubkey>,
pub state: Vec<u8>,
pub is_frozen: bool,
}
Key operations:
// UTXO Operations
pub trait UtxoOperations {
fn create_utxo(meta: UtxoMeta, owner: &Pubkey) -> Result<()>;
fn spend_utxo(utxo: &UtxoMeta, signature: &Signature) -> Result<()>;
fn freeze_utxo(utxo: &UtxoMeta, authority: &Pubkey) -> Result<()>;
fn delegate_utxo(utxo: &UtxoMeta, delegate: &Pubkey) -> Result<()>;
}
2. Bitcoin RPC Integration
flowchart LR AP[Arch\nProgram] RPC[Bitcoin RPC\nInterface] BN[Bitcoin\nNode] Config[Configuration] Network[Bitcoin\nNetwork] AP --> RPC RPC --> BN AP --> Config Config --> RPC BN --> Network style AP fill:#f9f9f9,stroke:#333,stroke-width:2px style RPC fill:#f9f9f9,stroke:#333,stroke-width:2px style BN fill:#f9f9f9,stroke:#333,stroke-width:2px style Config fill:#f9f9f9,stroke:#333,stroke-width:2px style Network fill:#f9f9f9,stroke:#333,stroke-width:2px
Programs can interact with Bitcoin through RPC calls:
// Bitcoin RPC Configuration
pub struct BitcoinRpcConfig {
pub endpoint: String,
pub port: u16,
pub username: String,
pub password: String,
pub wallet: Option<String>,
pub network: BitcoinNetwork,
pub timeout: Duration,
}
// RPC Interface
pub trait BitcoinRpc {
fn get_block_count(&self) -> Result<u64>;
fn get_block_hash(&self, height: u64) -> Result<BlockHash>;
fn get_transaction(&self, txid: &Txid) -> Result<Transaction>;
fn send_raw_transaction(&self, tx: &[u8]) -> Result<Txid>;
fn verify_utxo(&self, utxo: &UtxoMeta) -> Result<bool>;
}
Transaction Flow
sequenceDiagram participant Program participant Leader participant Validator participant Bitcoin Program->>Leader: Create UTXO Leader->>Validator: Validate Validator->>Leader: Sign Leader->>Bitcoin: Submit TX Bitcoin-->>Program: Confirmation
1. Transaction Creation
// Create new UTXO transaction
pub struct UtxoCreation {
pub amount: u64,
pub owner: Pubkey,
pub metadata: Option<Vec<u8>>,
}
impl UtxoCreation {
pub fn new(amount: u64, owner: Pubkey) -> Self {
Self {
amount,
owner,
metadata: None,
}
}
pub fn with_metadata(mut self, metadata: Vec<u8>) -> Self {
self.metadata = Some(metadata);
self
}
}
2. Transaction Validation
// Validation rules
pub trait TransactionValidation {
fn validate_inputs(&self, tx: &Transaction) -> Result<()>;
fn validate_outputs(&self, tx: &Transaction) -> Result<()>;
fn validate_signatures(&self, tx: &Transaction) -> Result<()>;
fn validate_script(&self, tx: &Transaction) -> Result<()>;
}
3. State Management
// State transition
pub struct StateTransition {
pub previous_state: Hash,
pub next_state: Hash,
pub utxos_created: Vec<UtxoMeta>,
pub utxos_spent: Vec<UtxoMeta>,
pub bitcoin_height: u64,
}
Security Model
1. UTXO Security
- Ownership verification through public key cryptography
- Double-spend prevention through UTXO consumption
- State anchoring to Bitcoin transactions
- Threshold signature requirements
2. Transaction Security
// Transaction security parameters
pub struct SecurityParams {
pub min_confirmations: u32,
pub signature_threshold: u32,
pub timelock_blocks: u32,
pub max_witness_size: usize,
}
3. Network Security
- Multi-signature validation
- Threshold signing (t-of-n)
- Bitcoin-based finality
- Cross-validator consistency
Error Handling
1. Bitcoin Errors
pub enum BitcoinError {
ConnectionFailed(String),
InvalidTransaction(String),
InsufficientFunds(u64),
InvalidUtxo(UtxoMeta),
RpcError(String),
}
2. UTXO Errors
pub enum UtxoError {
NotFound(UtxoMeta),
AlreadySpent(UtxoMeta),
InvalidOwner(Pubkey),
InvalidSignature(Signature),
InvalidState(Hash),
}
Best Practices
1. UTXO Management
- Always verify UTXO ownership
- Wait for sufficient confirmations
- Handle reorganizations gracefully
- Implement proper error handling
2. Transaction Processing
- Validate all inputs and outputs
- Check signature thresholds
- Maintain proper state transitions
- Monitor Bitcoin network status
3. Security Considerations
- Protect private keys
- Validate all signatures
- Monitor for double-spend attempts
- Handle network partitions
Nodes
Let's introduce the nodes that comprise the Arch Network stack in greater detail.
The bootnode works similarly to DNS seeds in Bitcoin whereby the server handles the first connection to nodes joining the Arch Network.
All signing is coordinated by the leader. Ultimately, the leader submits signed Bitcoin transactions to the Bitcoin network following program execution.
This node represents a generic node operated by another party. It performs the validator role and has a share in the network's distributed signing key. The leader node passes transactions to validator nodes to validate and sign. After enough signatures have been collected (a threshold has been met), the leader can then submit a fully signed Bitcoin transaction to the Bitcoin network.
The validator node also runs the eBPF virtual machine and executes the transactions asynchronously alongside the other validator nodes in the network.
This validator is a lightweight server that only serves as an RPC for developers to get up and running quickly with the least amount of overhead. It simulates a single-node blockchain environment that is meant for efficient, rapid development.
Note: the Lightweight Validator node uses the same image as the Validator node though operates singularly for maximum efficiency.
More can be read about the Arch Network architecture in our docs.
Program
A program is a special kind of account that contains executable eBPF bytecode, denoted by the Account.is_executable: true
field. This allows an account to receive arbitrary instruction data via a transaction to be processed by the runtime.
Every program is stateless, meaning that it can only read/write data to other accounts and that it cannot write to its own account; this, in-part, is how parallelized execution is made possible (see State for more info).
💡 Additionally, programs can send instructions to other programs which, in turn, receive instructions and thus extend program composability further. This is known as cross-program invocation (CPI) and will be detailed in future sections.
Components:
1. Entrypoint
Every Arch program includes a single entrypoint used to invoke the program. A handler function, often named process_instruction
, is then used to handle the data passed into the entrypoint.
These parameters are required for every instruction to be processed._
use arch_program::entrypoint;
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> Result<(), ProgramError> {
// Program logic here
}
2. Instruction
The instruction_data
is deserialized after being passed into the entrypoint. From there, if there are multiple instructions, a match
statement can be utilized to point the logic flow to the appropriate handler function previously defined within the program which can continue processing the instruction.
3. Process Instruction
If a program has multiple instructions, a corresponding handler function should be defined to include the specific logic unique to the instruction.
4. State
Since programs are stateless, a "data" account is needed to hold state for a user. This is a non-executable account that holds program data.
If a program receives instruction that results in a user's state being altered, the program would manage this user's state via a mapping within the program's logic. This mapping would link the user's pubkey with a data account where the state would live for that specific program.
The program will likely include a struct to define the structure of its state and make it easier to work with. The deserialization of account data occurs during program invocation. After an update is made, state data gets re-serialized into a byte array and stored within the data
field of the account.
UTXO (Unspent Transaction Output)
UTXOs (Unspent Transaction Outputs) are fundamental to Bitcoin's transaction model and serve as the foundation for state management in Arch Network. Unlike account-based systems that track balances, UTXOs represent discrete "coins" that must be consumed entirely in transactions.
Core Concepts
What is a UTXO?
- A UTXO represents an unspent output from a previous transaction
- Each UTXO is uniquely identified by a transaction ID (txid) and output index (vout)
- UTXOs are immutable - they can only be created or spent, never modified
- Once spent, a UTXO cannot be reused (prevents double-spending)
Role in Arch Network
- UTXOs anchor program state to Bitcoin's security model
- They provide deterministic state transitions
- Enable atomic operations across the network
- Allow for provable ownership and state validation
UTXO Structure
The UtxoMeta
struct encapsulates the core UTXO identification data:
use arch_program::utxo::UtxoMeta;
use bitcoin::Txid;
#[derive(Debug, Clone, PartialEq)]
pub struct UtxoMeta {
pub txid: [u8; 32], // Bitcoin transaction ID (32 bytes)
pub vout: u32, // Output index in the transaction
}
impl UtxoMeta {
/// Creates a new UTXO metadata instance
pub fn new(txid: [u8; 32], vout: u32) -> Self {
Self { txid, vout }
}
/// Deserializes UTXO metadata from a byte slice
/// Format: [txid(32 bytes)][vout(4 bytes)]
pub fn from_slice(data: &[u8]) -> Self {
let mut txid = [0u8; 32];
txid.copy_from_slice(&data[0..32]);
let vout = u32::from_le_bytes([
data[32], data[33], data[34], data[35]
]);
Self { txid, vout }
}
}
UTXO Lifecycle
1. Creation Process
Creating a UTXO with Bitcoin RPC
use bitcoincore_rpc::{Auth, Client as RpcClient, RpcApi};
use bitcoin::{Amount, Address};
use arch_program::pubkey::Pubkey;
// Initialize Bitcoin RPC client
let rpc = RpcClient::new(
"http://localhost:18443", // Bitcoin node RPC endpoint
Auth::UserPass(
"user".to_string(),
"pass".to_string()
)
).expect("Failed to create RPC client");
// Generate a new account address
let account_address = Pubkey::new_unique();
let btc_address = Address::from_pubkey(&account_address);
// Create UTXO by sending Bitcoin
// Parameters explained:
// - address: Destination Bitcoin address
// - amount: Amount in satoshis (3000 sats = 0.00003 BTC)
// - comment: Optional transaction comment
// - replaceable: Whether the tx can be replaced (RBF)
let txid = rpc.send_to_address(
&btc_address,
Amount::from_sat(3000),
Some("Create Arch UTXO"), // Comment
None, // Comment_to
Some(true), // Replaceable
None, // Fee rate
None, // Fee estimate mode
None // Avoid reuse
)?;
// Wait for confirmation (recommended)
rpc.wait_for_confirmation(&txid, 1)?;
Creating an Arch Account with UTXO
use arch_program::{
system_instruction::SystemInstruction,
pubkey::Pubkey,
transaction::Transaction,
};
// Create new program account backed by UTXO
let account_pubkey = Pubkey::new_unique();
let instruction = SystemInstruction::new_create_account_instruction(
txid.try_into().unwrap(),
0, // vout index
account_pubkey,
// Additional parameters like:
// - space: Amount of space to allocate
// - owner: Program that owns the account
);
// Build and sign transaction
let transaction = Transaction::new_signed_with_payer(
&[instruction],
Some(&payer.pubkey()),
&[&payer],
recent_blockhash
);
2. Validation & Usage
Programs must implement proper UTXO validation:
fn validate_utxo(utxo: &UtxoMeta) -> Result<(), ProgramError> {
// 1. Verify UTXO exists on Bitcoin
let btc_tx = rpc.get_transaction(&utxo.txid)?;
// 2. Check confirmation count
if btc_tx.confirmations < MIN_CONFIRMATIONS {
return Err(ProgramError::InsufficientConfirmations);
}
// 3. Verify output index exists
if utxo.vout as usize >= btc_tx.vout.len() {
return Err(ProgramError::InvalidVout);
}
// 4. Verify UTXO is unspent
if is_spent(utxo) {
return Err(ProgramError::UtxoAlreadySpent);
}
Ok(())
}
3. State Management
// Example UTXO state tracking
#[derive(Debug)]
pub struct UtxoState {
pub meta: UtxoMeta,
pub status: UtxoStatus,
pub owner: Pubkey,
pub created_at: i64,
pub spent_at: Option<i64>,
}
#[derive(Debug)]
pub enum UtxoStatus {
Pending, // Waiting for confirmations
Active, // Confirmed and spendable
Spent, // UTXO has been consumed
Invalid, // UTXO was invalidated (e.g., by reorg)
}
Best Practices
-
Validation
- Always verify UTXO existence on Bitcoin
- Check for sufficient confirmations (recommended: 6+)
- Validate ownership and spending conditions
- Handle Bitcoin reorgs that might invalidate UTXOs
-
State Management
- Implement robust UTXO tracking
- Handle edge cases (reorgs, conflicting txs)
- Consider implementing UTXO caching for performance
- Maintain accurate UTXO sets for your program
-
Security
- Never trust client-provided UTXO data without verification
- Implement proper access controls
- Consider timelock constraints for sensitive operations
- Monitor for suspicious UTXO patterns
-
Performance
- Batch UTXO operations when possible
- Implement efficient UTXO lookup mechanisms
- Consider UTXO consolidation strategies
- Cache frequently accessed UTXO data
Error Handling
Common UTXO-related errors to handle:
pub enum UtxoError {
NotFound, // UTXO doesn't exist
AlreadySpent, // UTXO was already consumed
InsufficientConfirmations, // Not enough confirmations
InvalidOwner, // Unauthorized attempt to spend
Reorged, // UTXO invalidated by reorg
InvalidVout, // Output index doesn't exist
SerializationError, // Data serialization failed
}
Related Topics
- Account Model - How UTXOs relate to Arch accounts
- Program State - Using UTXOs for program state
- System Program - Core UTXO operations
Basics
To illustrate the basic process involved in developing an Arch Program, we're going to build, deploy and interact with our demo app: GraffitiWall.
Building, deploying and interfacing
Now that all of the dependencies are installed and we have successfully started our development stack, we can finally discuss program development, including compiling, deploying and interacting with it.
The arch-cli
comes with a demo dapp called GraffitiWall; each message written to the wall contains a timestamp, name and note.
Find the program's logic within the app/program/src/lib.rs
file.
Build
In order to compile the program, we'll make use of the cargo-build-sbf
a binary, a tool that comes with the Solana-CLI that installs the toolchain needed to produce Executable and Linkable Format (ELF) files which consist of eBPF bytecode.
Access the app/program/src
folder:
cd app/program/src
Build the program
cargo-build-sbf
You will find the generated shared object file at: ./target/deploy/arch_network_app.so
If you are experiencing issues with this step, we recommend returning to review the requirements page or hopping in our Discord dev-chat channel for support.
Deploy
In this step, we will be submitting a transaction to store our program's logic on the Arch Network.
Note: make sure you have arch validator running before deploying the program, if you don't run
arch-cli validator start
arch-cli deploy
Output:
Welcome to the Arch Network CLI
→ Loading configuration from /Users/jr/Library/Application Support/arch-cli/config.toml
Deploying your Arch Network app...
ℹ Building program...
✓ Program built successfully
ℹ Program ID: 3688ef8de06d56e32a765243e900875c4fefc6aa9c83dfbc2643f661c5b4982e
ℹ Failed to load wallet 'devwallet'. Error: JSON-RPC error: RPC error response: RpcError { code: -18, message: "Wallet file verification failed. Failed to load database path '/home/bitcoin/.bitcoin/regtest/wallets/devwallet'. Path does not exist.", data: None }
Attempting to resolve the issue...
✓ Wallet 'devwallet' created successfully.
→ Generating initial blocks for mining rewards...
✓ Initial blocks generated
✓ Transaction sent: db69c08c4ff7df6081499d20c7f544f91f167fd184cbf3487f1ced8f1e75c848
✓ Transaction confirmed with 1 confirmations
Creating program account...
Program account created successfully
Deploying program transactions...
Starting program deployment
ℹ ELF file size: 175640 bytes
→ Deploying program with 210 transactions
Transactions sent successfully ✓ Successfully sent 210 transactions for program deployment
[00:00:11] [########################################] 210/210 (100%) Program transactions deployed successfully
Making program executable...
Transaction sent: 23de0a1d6fe6ec1f7304488aa223d092bcaa64ea63a23c61b765a248063e6e9c
Program made executable successfully
✓ Program deployed successfully
✓ Wallet 'devwallet' unloaded successfully.
Your app has been deployed successfully!
ℹ Program ID: 3688ef8de06d56e32a765243e900875c4fefc6aa9c83dfbc2643f661c5b4982e
Copy the Program ID from the output as you will need this again later.
The Program ID can be thought of as a uniform resource locator (URL) for your deployed program on the Arch Network.
Create an Account
An account is used to store the state for your dapp.
Obtain the Program ID from the deployment step output and use it within this command.
arch-cli account create --name graffiti --program-id <program-id>
Start the demo application
arch-cli demo start
Output:
Welcome to the Arch Network CLI
→ Loading configuration from /Users/jr/Library/Application Support/arch-cli/config.toml
Starting the frontend application...
→ Copying .env.example to .env...
✓ .env file created
→ Installing npm packages...
npm warn deprecated noble-secp256k1@1.2.14: Switch to namespaced @noble/secp256k1 for security and feature updates
npm warn deprecated @types/tailwindcss@3.1.0: This is a stub types definition. tailwindcss provides its own type definitions, so you do not need this installed.
added 393 packages, and audited 394 packages in 3s
131 packages are looking for funding
run `npm fund` for details
2 vulnerabilities (1 moderate, 1 high)
To address all issues, run:
npm audit fix
Run `npm audit` for details.
npm notice
npm notice New patch version of npm available! 10.8.2 -> 10.8.3
npm notice Changelog: https://github.com/npm/cli/releases/tag/v10.8.3
npm notice To update run: npm install -g npm@10.8.3
npm notice
✓ npm packages installed
→ Building and starting the Vite server...
✓ Vite server started
→ Opening application in default browser...
✓ Application opened in default browser
Frontend application started successfully!
Press Ctrl+C to stop the server and exit.
If the window doesn't pop up, navigate to: http://localhost:5173 to interface with your deployed program via the web frontend.
🎨
Now you're ready to tag the wall!
Program interaction
Table of Contents:
Description
Continuing with our example program, GraffitiWall, we find an implementation example of how to communicate with a deployed Program by looking at the frontend code; specifically, we'll look at the GrafittiWallComponent.tsx file.
Logic
const client = new RpcConnection((import.meta as any).env.VITE_RPC_URL || 'http://localhost:9002');
const PROGRAM_PUBKEY = (import.meta as any).env.VITE_PROGRAM_PUBKEY;
const WALL_ACCOUNT_PUBKEY = (import.meta as any).env.VITE_WALL_ACCOUNT_PUBKEY;
Here we initialize a new RPC connection and pass in the RPC URL that we wish to connect to; in this case, the URL is pulled from the environment variables or defaults to our locally running validator.
We then import the Pubkeys of the Graffiti Wall Program as well as the Wall state Account. The Wall state Account stores the state of the Graffiti Wall and the Program's Pubkey serves as the owner of the Graffiti Wall.
class GraffitiMessage {
constructor(
public timestamp: number,
public name: string,
public message: string
) {}
static schema = new Map([
[
GraffitiMessage,
{
kind: 'struct',
fields: [
['timestamp', 'i64'],
['name', ['u8', 16]],
['message', ['u8', 64]]
]
}
]
]);
}
// Define the schema for the wall containing messages
class GraffitiWall {
constructor(public messages: GraffitiMessage[]) {}
static schema = new Map([
[
GraffitiWall,
{
kind: 'struct',
fields: [
['messages', [GraffitiMessage]]
]
}
]
]);
}
We then define the schemas for handling the Wall's message data- these schemas mirror the data structure that are found within the GraffitiWall program which ensures data uniformity between the application frontend and program backend during serialization/deserialization.
#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)]
pub struct GraffitiMessage {
pub timestamp: i64,
pub name: [u8; 16],
pub message: [u8; 64],
}
Above is the data structure as defined in src/app/program/src/lib.rs
, our Program.
const accountPubkey = PubkeyUtil.fromHex(WALL_ACCOUNT_PUBKEY);
const schema = {
struct: {
messages: {
seq: {
struct: {
timestamp: 'i64',
name: { array: { type: 'u8', len: 16 } },
message: { array: { type: 'u8', len: 64 } }
}
}
}
}
};
Moving along, we set the accountPubkey
variable with our previously imported Wall account Pubkey converted from hexidecimal and then set a new schema for to handle the Graffiti Wall message data.
const checkProgramDeployed = useCallback(async () => {
try {
const pubkeyBytes = PubkeyUtil.fromHex(PROGRAM_PUBKEY);
const accountInfo = await client.readAccountInfo(pubkeyBytes);
if (accountInfo) {
setIsProgramDeployed(true);
setError(null);
}
} catch (error) {
console.error('Error checking program:', error);
setError('The Arch Graffiti program has not been deployed to the network yet. Please run `arch-cli deploy`.');
}
}, []);
We then submit our first request to the Arch RPC service: readAccountInfo
.
We pass the pubkeyBytes
- which represents the program_id
, indicating the unique resource location of the Program within the Arch Network- as the argument to readAccountInfo
in order to obtain the AccountInfo.
If we are able to read the accountInfo
successfully, then we can be sure the program was deployed.
const checkAccountCreated = useCallback(async () => {
try {
const pubkeyBytes = PubkeyUtil.fromHex(WALL_ACCOUNT_PUBKEY);
const accountInfo = await client.readAccountInfo(pubkeyBytes);
if (accountInfo) {
setIsAccountCreated(true);
setError(null);
}
} catch (error) {
console.error('Error checking account:', error);
setIsAccountCreated(false);
setError('The wall account has not been created yet. Please run the account creation command.');
}
}, []);
Similarly, we perform the same check against the Wall account, ensuring that the Graffiti wall has an account provisioned to manage the program's state. Without this state account the program would not have any data to work with; as a reminder, every Arch Program is stateless.
const fetchWallData = useCallback(async () => {
try {
const userAccount = await client.readAccountInfo(accountPubkey);
if (!userAccount) {
setError('Account not found.');
return;
}
const wallData = userAccount.data;
console.log(`Wall data: ${wallData}`);
...
We then begin to retrieve the Graffiti Wall data from the Wall account.
We make an RPC call to read in the account info just as we did in the previous step, only this time we access the data stored within the AccountInfo. As of now, this data is not yet parsed, so it comes back as bytes which will need to be handled.
// If data is empty or invalid length, just set empty messages without error
if (!wallData || wallData.length < 4) {
setWallData([]);
setError(null); // Clear any existing errors
return;
}
We perform a check against the length of this bytedata to ensure that it is not empty, meaning there is at least some data stored within the Wall account.
// Deserialize the wall data using borsh
// Read data directly from the buffer
const messages = [];
let offset = 0;
// First 4 bytes are the array length
const messageCount = new DataView(wallData.buffer).getUint32(offset, true);
offset += 4;
for (let i = 0; i < messageCount; i++) {
// Read timestamp (8 bytes)
const timestamp = new DataView(wallData.buffer).getBigInt64(offset, true);
offset += 8;
// Read name (16 bytes)
const nameBytes = wallData.slice(offset, offset + 16);
const name = new TextDecoder().decode(nameBytes.filter(x => x !== 0));
offset += 16;
// Read message (64 bytes)
const messageBytes = wallData.slice(offset, offset + 64);
const message = new TextDecoder().decode(messageBytes.filter(x => x !== 0));
offset += 64;
messages.push(new GraffitiMessage(
Number(timestamp),
name,
message
));
}
messages.sort((a, b) => b.timestamp - a.timestamp);
setWallData(messages);
} catch (error) {
console.error('Error fetching wall data:', error);
setError(`Failed to fetch wall data: ${error instanceof Error ? error.message : String(error)}`);
}
...
We now need to deserialize the bytedata into a structure that is more manageable, in this case, we'll make use of the GraffitiMessage
schema we set earlier.
const handleAddToWall = async () => {
if (!message.trim() || !name.trim() || !isAccountCreated || !wallet.isConnected) {
setError("Name and message are required, account must be created, and wallet must be connected.");
return;
}
We'll again skip over some React state management.
handleAddToWall
contains the lion's share of the logic for serializing data and submitting this data to the Program.
const serializeGraffitiData = (name: string, message: string): number[] => {
// Create fixed-size arrays
const nameArray = new Uint8Array(16).fill(0);
const messageArray = new Uint8Array(64).fill(0);
// Convert strings to bytes
const nameBytes = new TextEncoder().encode(name);
const messageBytes = new TextEncoder().encode(message);
// Copy bytes into fixed-size arrays (will truncate if too long)
nameArray.set(nameBytes.slice(0, 16));
messageArray.set(messageBytes.slice(0, 64));
// Create the params object matching the Rust struct
const params = {
name: Array.from(nameArray),
message: Array.from(messageArray)
};
// Define the schema for borsh serialization
const schema = {
struct: {
name: { array: { type: 'u8', len: 16 } },
message: { array: { type: 'u8', len: 64 } }
}
};
return Array.from(borsh.serialize(schema, params));
...
In this anonymous function we pass in our dapp data, name
and message
in order to prepare it for submission to the Program.
We create two new Uint8
byte arrays and initialize their appropriate lengths with placeholder zeros, eventually copying the encoded name
and message
data into into fixed-size arrays and storing them within the params
object.
We define the schema for the data and serialize the scheme alongside the params
object which we will use within the following try
block.
try {
const data = serializeGraffitiData(name, message);
const instruction: Instruction = {
program_id: PubkeyUtil.fromHex(PROGRAM_PUBKEY),
accounts: [
{
pubkey: PubkeyUtil.fromHex(wallet.publicKey!),
is_signer: true,
is_writable: false
},
{
pubkey: accountPubkey,
is_signer: false,
is_writable: true
},
],
data: new Uint8Array(data),
};
...
Stepping into our try
block, we serialize our post data, in this case including the name of the author as well as the message they wish to post.
We construct an Instruction object, containing our program_id
, serialized data, as well as the accounts involved, in this case, the signing Pubkey of our user's wallet as well as the accountPubkey
, the Pubkey of the Wall state account.
const messageObj : Message = {
signers: [PubkeyUtil.fromHex(wallet.publicKey!)],
instructions: [instruction],
};
We then construct our Message object to hold the needed signers (our user) as well as the previously formed Instruction.
const messageBytes = MessageUtil.serialize(messageObj);
...
const signature = await wallet.signMessage(Buffer.from(MessageUtil.hash(messageObj)).toString('hex'));
We then serialize our Message and then craft our Signature.
const signatureBytes = new Uint8Array(Buffer.from(signature, 'base64')).slice(2);
console.log(`Signature bytes: ${signatureBytes}`);
const result = await client.sendTransaction({
version: 0,
signatures: [signatureBytes],
message: messageObj,
});
We then store our Signature within a new Uint8
array and create a slice from it in order to segregate the last 64-bytes of the base64 decoded Signature.
const result = await client.sendTransaction({
version: 0,
signatures: [signatureBytes],
message: messageObj,
});
We then craft our Transaction object within the RPC call to sendTransaction
, passing in our sliced Signature and serialized Message, along with the correct Transaction version (0
), successfully submitting our state change to the Arch Network for processing.
🎨
This concludes the logic walkthrough of the Program interaction component of our GraffitiWall.
Guides
In this section, we'll provide a few guides that can step through constructing an Arch program, as well as deploying and interacting with your program.
How to write an Arch program
Table of Contents:
The Arch Book can serve as a reference for concepts introduced here as well as our docs for high-level architecture diagrams and comparisons to other similar projects building on Bitcoin.
For this guide, we will be walking through an example program: helloworld.
Logic
A smart contract on Arch is known as a Program.
use arch_program::{
account::AccountInfo,
entrypoint, msg,
helper::add_state_transition,
input_to_sign::InputToSign,
program::{
get_account_script_pubkey, get_bitcoin_block_height,
next_account_info, set_transaction_to_sign, invoke
},
program_error::ProgramError,
pubkey::Pubkey, utxo::UtxoMeta,
transaction_to_sign::TransactionToSign,
system_instruction::SystemInstruction,
};
use bitcoin::{self, Transaction, transaction::Version, absolute::LockTime};
use borsh::{BorshDeserialize, BorshSerialize};
entrypoint!(process_instruction);
pub fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> Result<(), ProgramError> {
if accounts.len() != 2 {
return Err(ProgramError::Custom(501));
}
let bitcoin_block_height = get_bitcoin_block_height();
msg!("bitcoin_block_height {:?}", bitcoin_block_height);
let account_iter = &mut accounts.iter();
let account = next_account_info(account_iter)?;
let account2 = next_account_info(account_iter)?;
msg!("account {:?}", account);
msg!("account2 {:?}", account2);
if account2.utxo.clone() != UtxoMeta::from_slice(&[0; 36]) {
msg!("UTXO {:?}", account2.utxo.clone());
return Err(ProgramError::Custom(502));
}
let params: HelloWorldParams = borsh::from_slice(instruction_data).unwrap();
let fees_tx: Transaction = bitcoin::consensus::deserialize(¶ms.tx_hex).unwrap();
let new_data = format!("Hello {}", params.name);
// Extend the account data to fit the new data
let data_len = account.data.try_borrow().unwrap().len();
if new_data.as_bytes().len() > data_len {
account.realloc(new_data.len(), true)?;
}
let script_pubkey = get_account_script_pubkey(account.key);
msg!("script_pubkey {:?}", script_pubkey);
account
.data
.try_borrow_mut()
.unwrap()
.copy_from_slice(new_data.as_bytes());
if account2.is_writable {
invoke(
&SystemInstruction::new_create_account_instruction(
params.utxo.txid().try_into().unwrap(),
params.utxo.vout(), account2.key.clone()
),
&[account2.clone()]
).expect("failed");
}
let mut tx = Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: vec![],
output: vec![],
};
add_state_transition(&mut tx, account);
tx.input.push(fees_tx.input[0].clone());
let tx_to_sign = TransactionToSign {
tx_bytes: &bitcoin::consensus::serialize(&tx),
inputs_to_sign: &[InputToSign {
index: 0,
signer: account.key.clone(),
}],
};
msg!("tx_to_sign{:?}", tx_to_sign);
set_transaction_to_sign(accounts, tx_to_sign)
}
#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)]
pub struct HelloWorldParams {
pub name: String,
pub tx_hex: Vec<u8>,
pub utxo: UtxoMeta,
}
Imports
First, let's bring our arch_program
, borsh
and bitcoin
crates into local namespace.
use arch_program::{
account::AccountInfo,
entrypoint, msg,
helper::add_state_transition,
input_to_sign::InputToSign,
program::{
get_account_script_pubkey, get_bitcoin_block_height,
next_account_info, set_transaction_to_sign, invoke
},
program_error::ProgramError,
pubkey::Pubkey, utxo::UtxoMeta,
transaction_to_sign::TransactionToSign,
system_instruction::SystemInstruction,
};
use bitcoin::{self, Transaction, transaction::Version, absolute::LockTime};
use borsh::{BorshDeserialize, BorshSerialize};
Before we continue, let's quickly introduce some helpful resources that we are importing:
entrypoint
: a macro used for invoking our program.msg
: a macro used for logging messages; these are visible within the node logs of your local validator.borsh
: a crate for serialization/deserialization of data passed to/from our program.bitcoin
: a crate for working with the Bitcoin blockchain.
Entrypoint
Every Arch program includes a single entrypoint used to invoke the program.
This tells Arch that the entrypoint to this program is the the process_instruction
function, our handler.
entrypoint!(process_instruction);
Handler
Each handler function's parameters must match what is required for a transaction Instruction.
program_id
- Unique identifier of the currently executing program.accounts
- Slice reference containing accounts needed to execute an instruction.instruction_data
- Serialized data containing program instructions.
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> Result<(), ProgramError> {
...
}
Now that we're inside the function scope, first, we check that there are a sufficient number of accounts are passed into our program.
We perform a Syscall to retrieve the latest Bitcoin block height (this can be omitted though is helpful for debugging) and then iterate over the accounts passed in to the program and retrieve the first one.
if accounts.len() != 2 {
return Err(ProgramError::Custom(501));
}
let bitcoin_block_height = get_bitcoin_block_height();
msg!("bitcoin_block_height {:?}", bitcoin_block_height);
let account_iter = &mut accounts.iter();
let account = next_account_info(account_iter)?;
let account2 = next_account_info(account_iter)?;
msg!("account {:?}", account);
msg!("account2 {:?}", account2);
Next, we perform a check to ensure that the UTXO passed into the Account is not the default value of a 36-zero byte slice. This step is done to ensure that the UTXO is properly unititialized before continuing.
if account2.utxo.clone() != UtxoMeta::from_slice(&[0; 36]) {
msg!("UTXO {:?}", account2.utxo.clone());
return Err(ProgramError::Custom(502));
}
Next, we deserialize our instruction_data
into a newly initialized instance of HelloWorldParams
to hold our program state and more easily manage it within our program logic.
let params: HelloWorldParams = borsh::from_slice(instruction_data).unwrap();
Now that our instruction_data
has been deserialized, we can access the fields, such as params.tx_hex
.
In this step, we will use the Bitcoin crate to further deserialize a reference to the tx_hex
field into an instance of a Bitcoin transaction; this represents the fees that need to be paid to execute the program instruction.
let fees_tx: Transaction = bitcoin::consensus::deserialize(¶ms.tx_hex).unwrap();
Note:
tx_hex
represents a serialized Bitcoin UTXO that is used to pay the fee for updating state/executing a transaction; it is a fully-signed Bitcoin UTXO that gets sent directly to Arch. The Arch leader node then submits this fee UTXO alongside the other state/asset UTXOs as a result of the program execution.Including
tx_hex
is a convention, not a requirement.Program invocation can be paid for by another source, although in the majority of cases it is most practical to have the caller be prepared to pay this.
We'll create our new message with the name we wish to store.
let new_data = format!("Hello {}", params.name);
Next, we'll extend the account data to fit the new data that we wish to store from the previous step.
We check whether the length of the new data that we wish to pass to the program exceeds the length that is stored within the existing account's data (ie, the program state) by comparing byte lengths.
If the new data exceeds the length of what was currently stored in the account's data field, then we re-allocate the account's data as well as zero-initialize the new memory. This is done to ensure that no stale data remains in the account.
Read more about memory reallocation and zero-initialization.
let data_len = account.data.try_borrow().unwrap().len();
if new_data.as_bytes().len() > data_len {
account.realloc(new_data.len(), true)?;
}
Next, we retrieve the script_pubkey
from the key
field of the Account. This tells us how the Bitcoin can be spent; we log this out for debugging.
let script_pubkey = get_account_script_pubkey(account.key);
msg!("script_pubkey {:?}", script_pubkey);
Next, we attempt a mutated borrow of the account data in order to copy contents in from the data passed into our program.
account.data.try_borrow_mut().unwrap().copy_from_slice(new_data.as_bytes());
We then perform a check to ensure that the account is writable, if it is, we invoke a SystemInstruction to create a new account instruction.
To create a new account instruction, we provide the txid
and vout
(the output index for identification) of our UTXO for the instruction data, and include a copy of the account's Pubkey.
if account2.is_writable {
invoke(
&SystemInstruction::new_create_account_instruction(
params.utxo.txid().try_into().unwrap(),
params.utxo.vout(),
account2.key.clone(),
),
&[account2.clone()]
).expect("failed");
}
Next, we initialize a new instance of a Bitcoin transaction that we'll fill in over the next few steps.
let mut tx = Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: vec![],
output: vec![],
};
We then modify the previously initialized Bitcoin transaction by updating input
and output
fields with the UTXO data of the account, including the fees_tx
that is needed to pay for the Bitcoin transaction.
add_state_transition(&mut tx, account);
tx.input.push(fees_tx.input[0].clone());
Now, we're ready to sign and submit the transaction to Bitcoin which will cement our state alteration.
Here, we construct a new Arch transaction that includes our serialized Bitcoin transaction alongside our program's Pubkey serving as the signer.
let tx_to_sign = TransactionToSign {
tx_bytes: &bitcoin::consensus::serialize(&tx),
inputs_to_sign: &[InputToSign {
index: 0,
signer: account.key.clone()
}]
};
Finally, we pass in the list of accounts our program received initially alongside the previously constructed transaction (tx_to_sign
) into a helper function that will serialize it and set the UTXOs to the account.
set_transaction_to_sign(accounts, tx_to_sign);
🎉🎉🎉
Congratulations, you've walked through constructing the our helloworld program. In a future guide, we'll walk you through how to test the logic of your program.
How to write an oracle program
This guide walks through the innerworkings of an oracle program as well as details how oracle data can be utilized by other programs on Arch Network.
Table of Contents:
Description
Two important aspects of understanding how this oracle example is implemented within Arch:
- The oracle is a program that updates an account which holds the data
- No cross-program invocation occurs since only the account is updated and read from versus this being another program that gets interacted with from another program
The source code can be found within the arch-examples repo.
Flow
- Project deploys oracle program
- Project creates state account that the oracle program will control in order to write state to it
- Projects submit data to the oracle state account by submitting instructions to the oracle program
- Programs include oracle state account alongside their program instructions in order to use this referenced data stored in the oracle state account within their program
- Projects submit instructions to oracle program periodically to update oracle state account with fresh data
Logic
If you haven't already read How to write an Arch program, we recommend starting there to get a basic understanding of the program anatomy before going further.
We'll look closely at the logic block contained within the update_data
handler.
pub fn update_data(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> Result<(), ProgramError> {
let account_iter = &mut accounts.iter();
let oracle_account = next_account_info(account_iter)?;
assert!(oracle_account.is_signer);
assert_eq!(instruction_data.len(), 8);
...
}
First, we'll iterate over the accounts that get passed into the function, which includes the newly created state account that will be responsible for managing the oracle's data.
We then assert that the oracle state account has the appropriate authority to be written to and update what it stores within its data field. Additionally, we assert that the data we wish to update the account with is at least a certain number of bytes.
let data_len = oracle_account.data.try_borrow().unwrap().len();
if instruction_data.len() > data_len {
oracle_account.realloc(instruction_data.len(), true)?;
}
Next, we calculate the length of the new data that we are looking to store in the account and reallocate memory to the account if the new data is larger than the data currently existing within the account. This step is important for ensuring that there is no remaining, stale data stored in the account before adding new data to it.
oracle_account
.data
.try_borrow_mut()
.unwrap()
.copy_from_slice(instruction_data);
msg!("updated");
Ok(())
Lastly, we store the new data that is passed into the program via the instruction to the state account for management, thus marking the end of the oracle update process.
Implementation
Let's look at an example implementation of this oracle program. This includes:
- Create oracle project
- Deploy program
- Create a state account
- Update the state account
- Read from the state account
Create oracle project
First, we'll need to create a new project using the arch-cli to hold our oracle logic.
arch-cli project create --name oracle
Example output:
Welcome to the Arch Network CLI
Loading config for network: development
→ Loading configuration from /Users/jr/Library/Application Support/arch-cli/config.toml
✓ Loaded network-specific configuration for development
Creating a new project...
✓ Updated configuration with project directory
✓ Created project directory at "/Users/jr/Documents/ArchNetwork/oracle"
Creating Vite application...
✓ Created Vite application
✓ Installed base dependencies
✓ Installed additional packages
New project created successfully! 🎉
ℹ Project location: "/Users/jr/Documents/ArchNetwork/oracle"
Next steps:
1. Navigate to /Users/jr/Documents/ArchNetwork/oracle/app/program to find the Rust program template
2. Edit the source code to implement your program logic
3. When ready, run arch-cli deploy to compile and deploy your program to the network
Need help? Check out our documentation at https://arch-network.github.io/docs/
We can then proceed to replace the logic in oracle/app/program/lib.rs
with our example oracle code as well as update the dependencies (oracle/app/program/Cargo.toml
), both found within the arch-examples repo.
Deploy program
After the project is created, the program is written and the Cargo.toml
is set with the proper dependencies, we can use the arch-cli to deploy the program.
arch-cli deploy
Example output:
Welcome to the Arch Network CLI
Loading config for network: development
→ Loading configuration from /Users/jr/Library/Application Support/arch-cli/config.toml
✓ Loaded network-specific configuration for development
Deploying your Arch Network app...
Available folders to deploy:
1. demo
2. helloworld
3. oracle
4. my_app
Enter the number of the folder you want to deploy (or 'q' to quit): 3
Deploying from folder: "/Users/jr/Documents/ArchNetwork/oracle"
ℹ Building program...
ℹ Cargo.toml found at: /Users/jr/Documents/ArchNetwork/oracle
ℹ Current working directory: /Users/jr/Documents/ArchNetwork/oracle
✓ Program built successfully
Select a key to use as the program key: oracle
ℹ Program ID: e46ed1e7441ac5d583961122bc1b63a46a84ec5d33a1d8967d2a827e65297531
Wallet RPC URI: http://bitcoin-node.dev.aws.archnetwork.xyz:18443/wallet/testwallet
Client connected: 03a06383512c806931d88f55013670454cd95c73611c54ce917552ce9843b50e
✓ Wallet 'testwallet' loaded successfully.
✓ Transaction sent: f3695398563199274125d69e04769c303167f1de599158c3627e83f7493c448d
✓ Transaction confirmed with 1 confirmations
Creating program account...
Program account created successfully
Deploying program transactions...
[00:00:01] Successfully Processed Deployment Transactions : [####################################################################################################] 10/10 (0s) Program transactions deployed successfully
Making program executable...
Transaction sent: 11488915c4535479023ec264e2c65519748c655531ddf4b6f0516d36c4740a41
Program made executable successfully
✓ Program deployed successfully
✓ Wallet 'testwallet' unloaded successfully.
Your app has been deployed successfully!
ℹ Program ID: e46ed1e7441ac5d583961122bc1b63a46a84ec5d33a1d8967d2a827e65297531
During the deployment step, the arch-cli creates an account for the deployed program logic and sets the account to be executable, making the distinction that the account is to be considered a [Program] rather than a data [Account].
Create state account
From the above output, we should obtain the program_id
. We can use this program_id
in order to create a state account that is owned and updated by the program.
The oracle state account can then be read from by any program in order to retrieve the associated oracle data.
arch-cli account create --name oracle-state-account --program-id e46ed1e7441ac5d583961122bc1b63a46a84ec5d33a1d8967d2a827e65297531
Example output:
Welcome to the Arch Network CLI
Loading config for network: development
→ Loading configuration from /Users/jr/Library/Application Support/arch-cli/config.toml
✓ Loaded network-specific configuration for development
Creating account for dApp...
ℹ Account address: bcrt1pz853jlekzq2c9rvx5lz644qc9c3qx6n28g48jv3hyyknzvhm93rsg7r04f
Wallet RPC URI: http://bitcoin-node.dev.aws.archnetwork.xyz:18443/wallet/testwallet
Client connected: 79d37c5aa2b9216b1f4d66cfdfd1e125f9b241536de3ca81ab1a6887881e3e53
✓ Wallet 'testwallet' loaded successfully.
Please send funds to the following address:
→ Bitcoin address: bcrt1pz853jlekzq2c9rvx5lz644qc9c3qx6n28g48jv3hyyknzvhm93rsg7r04f
ℹ Minimum required: 3000 satoshis
⏳ Waiting for funds...
✓ Transaction sent: fb4f176a0f1a6ed355987c4bfa24491a1e01484b624ffaa00e62d9554e411db1
✓ Transaction confirmed with 1 confirmations
✓ Account created with Arch Network transaction ID: c6033bc2acfb12f9f330a7b79c25287e1126dcb1ee42f64d2ebf206dd3fc55cb
ℹ Account public key: "50130456b1bae1cb7ec5b8d2c4afaf08301e899423d1c5908995bc198b6a3326"
Account created and ownership transferred successfully!
IMPORTANT: Please save your private key securely. It will not be displayed again.
🔑 Private Key: ...
🔑 Public Key: 50130456b1bae1cb7ec5b8d2c4afaf08301e899423d1c5908995bc198b6a3326
✓ Wallet 'testwallet' unloaded successfully.
In this step, the account is created and ownership is transferred to the program. This allows the program to update the account's data field which holds state for the program.
Update the state account
Now that we have created an account and the oracle program has authority to update it, we now want to update the data that the account holds.
In order to update the data stored in the account, we simply need to make a transaction that includes the data that we wish to update the oracle state account to hold, and submit this within the context of an instruction.
As an example, below we have a sample rust program that we'll use to fetch the Bitcoin fees from the mempool.space API and store this fee data in our oracle state account that was created during deployment.
Note: The below is a rust program and is not an Arch program.
The call to update the oracle state account can be written in any programming language as it is simply an RPC call. For sake of continuity, we're using rust along with methods from both the
program
andsdk
crates.
use bitcoincore_rpc::{Auth, Client};
let mut old_feerate = 0;
let body: Value = reqwest::blocking::get("https://mempool.space/api/v1/fees/recommended").unwrap().json().unwrap();
let feerate = body.get("fastestFee").unwrap().as_u64().unwrap();
if old_feerate != feerate {
let (txid, instruction_hash) = sign_and_send_instruction(
Instruction {
program_id: program_pubkey.clone(),
accounts: vec![AccountMeta {
pubkey: caller_pubkey.clone(),
is_signer: true,
is_writable: true
}],
data: feerate.to_le_bytes().to_vec()
},
vec![caller_keypair],
).expect("signing and sending a transaction should not fail");
let processed_tx = get_processed_transaction(NODE1_ADDRESS, txid.clone()).expect("get processed transaction should not fail");
println!("processed_tx {:?}", processed_tx);
println!("{:?}", read_account_info(NODE1_ADDRESS, caller_pubkey.clone()));
old_feerate = feerate;
}
Read from the state account
Below is an example of a different program (we'll call this app-program) that would like to access the oracle data.
Essentially, what happens here is that when we pass an instruction into our app-program, we must also include the oracle state account alongside any other account that we need for the app-program. In this way, the oracle state account is now in-scope and its data can be read from.
pub fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> Result<(), ProgramError> {
let account_iter = &mut accounts.iter();
// our app-program's state account
let app_program_account = next_account_info(account_iter)?;
// our oracle data account
let oracle_account = next_account_info(account_iter)?;
// our oracle data that can now be used within the context of
// app-program's business logic
let oracle_data = oracle_account.data.try_borrow().unwrap();
let msg_str = format!("Oracle data: {}", oracle_data);
msg!(msg_str);
...
}
How to create a fungible token
This guide walks through how to implement the Fungible Token Standard program, part of the Arch Program Library, or APL.
Table of Contents:
Description
The Fungible Token Standard program provides a consistent interface for implementing fungible tokens on Arch. As with all programs within the APL, this program is predeployed and is tested against the Arch runtime.
The source code can be found within the arch-examples repo.
Logic
If you haven't already read How to write an Arch program, we recommend starting there to get a basic understanding of the program anatomy before going further.
Implementation
Deploy
Although the Fungible Token Standard program is part of the APL, and is there predeployed by the validators, for local testing, we can deploy it ourselves. Move to Mint if you'd like to skip this step.
To demonstrate a deploy, we'll reference: deploy.rs
We make use of try_deploy_program
, a helper function from the ebpf-counter example to deploy our program.
pub const ELF_PATH: &str = "./program/target/sbf-solana-solana/release/fungible-token-standard-program.so";
fn deploy_standard_program() {
let program_pubkey =
try_deploy_program(ELF_PATH, PROGRAM_FILE_PATH, "Fungible-Token-Standard").unwrap();
println!(
"Deployed Fungible token standard program account id {:?}!",
program_pubkey.serialize()
);
...
}
Mint
To mint tokens, we must supply a few pieces of information:
- Owner
- Supply
- Ticker
- Decimals
This data gets stored in the InitializeMintInput
struct, which will be used to generate a new instance of the Fungible Token Standard.
#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)]
pub struct InitializeMintInput {
owner: [u8; 32],
supply: u64, // in lowest denomination
ticker: String,
decimals: u8,
}
To demonstrate a mint, we'll reference: tests_mint.rs
We initialize a new instance of InitializeMintInput
and pass in the necessary data. In the below case, our owner account will create the token "SPONK," with a total supply of 1,000,000, which will have only a single decimal, meaning it is divisible by 1.
// deploy.rs
let mint_input = InitializeMintInput::new(
mint_account_pubkey.serialize(),
1000000,
"SPONK".to_string(),
1,
);
We then serialize mint_input
so that we can pass it as instruction_data
within an Instruction which then gets submitted to the deployed Fungible Token Standard program.
let mut instruction_data = vec![0u8];
mint_input
.serialize(&mut instruction_data)
.expect("Couldnt serialize mint input");
let initialize_mint_instruction = Instruction {
program_id: program_pubkey.clone(),
accounts: vec![AccountMeta {
pubkey: mint_account_pubkey,
is_signer: true,
is_writable: true,
}],
data: instruction_data,
};
Next, we build a transaction using build_transaction
and then submit the transaction with build_and_send_block
, both helper function from the ebpf-counter example.
let transaction = build_transaction(
vec![mint_account_keypair],
vec![initialize_mint_instruction],
);
let block_transactions = build_and_send_block(vec![transaction]);
We fetch the result of the transaction with fetch_processed_transactions
helper function (ebpf-counter) and then obtain the mint details by passing the Pubkey of the token owner.
let processed_transactions = fetch_processed_transactions(block_transactions).unwrap();
assert!(matches!(
processed_transactions[0].status,
Status::Processed
));
let mint_details = get_mint_info(&mint_account_pubkey).expect("Couldnt deserialize mint info");
println!("Mint account {:?}", mint_account_pubkey.serialize());
Transfer
To demonstrate a transfer, we'll reference: tests_transfer.rs
We obtain a mint_account_pubkey
, made possible by using the try_create_mint_account
helper function. We pass true
as this is a one-time mint event and this will generate a new keypair and Pubkey.
This step will actually create a new token with the following details:
- Supply: 1,000,000
- Ticker: "ARCH"
- Decimals: 2
- Mint Price: 1000 sats
let mint_account_pubkey = try_create_mint_account(true).unwrap();
We then fetch the token mint details with get_mint_info
.
let previous_mint_details = get_mint_info(&mint_account_pubkey).unwrap();
Now, let's provision our two accounts: the sender and the receiver.
// sending account
let (first_account_owner_key_pair, first_account_owner_pubkey, _first_account_owner_address) =
generate_new_keypair();
let first_balance_account_pubkey = create_balance_account(
&first_account_owner_pubkey,
first_account_owner_key_pair,
&mint_account_pubkey,
&program_pubkey,
)
.unwrap();
// receiving account
let (second_account_owner_key_pair, second_account_owner_pubkey, _second_account_owner_address) =
generate_new_keypair();
let second_balance_account_pubkey = create_balance_account(
&second_account_owner_pubkey,
second_account_owner_key_pair,
&mint_account_pubkey,
&program_pubkey,
)
.unwrap();
We then procure funds for the sending account. In this case, we'll mint 10 tokens.
let mint_amount = 10u64;
let mint_instruction = mint_request_instruction(
&mint_account_pubkey,
&program_pubkey,
&first_balance_account_pubkey,
&first_account_owner_pubkey,
mint_amount,
)
.unwrap();
We utilize the transfer_request_instruction
helper function to generate a transfer Instruction.
let transfer_instruction = transfer_request_instruction(
&mint_account_pubkey,
&program_pubkey,
&first_balance_account_pubkey,
&first_account_owner_pubkey,
&second_balance_account_pubkey,
mint_amount,
)
.unwrap();
We build the transaction by passing in the newly created transfer Instruction as well as the keypair of the sending account, necessary for authorizing the fund transfer.
let transfer_transaction = build_transaction(
vec![first_account_owner_key_pair],
vec![transfer_instruction],
);
Next, we then submit the transaction with build_and_send_block
and then fetch the processed transaction to get the result.
let block_transactions = build_and_send_block(vec![transfer_transaction]);
let processed_transactions = fetch_processed_transactions(block_transactions).unwrap();
assert!(matches!(
processed_transactions[0].status,
Status::Processed
));
Balance check
In order to check the token balance of an account, we'll make use of the get_balance_account
function and pass in the account we are looking to query the balance of; in the below example, we'll fetch the balances of both the sending and receiving accounts.
let resulting_sender_balance = get_balance_account(&first_balance_account_pubkey).unwrap();
let resulting_receiver_balance = get_balance_account(&second_balance_account_pubkey).unwrap();
assert_eq!(resulting_receiver_balance.current_balance, mint_amount);
assert_eq!(resulting_sender_balance.current_balance, 0);
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!
How to configure the local validator with Bitcoin Testnet4
This guide is intended for those wishing to view logs from their programs while benefitting from being connected to Bitcoin testnet4 and therefore gaining access to ordinals/runes helper tools.
Table of Contents:
Config
First, edit the arch-cli configuration file and insert the following details into the testnet
section.
arch-cli config edit
Note: We have redacted our Bitcoin node password to prevent abuse; contact us if you need this, otherwise provide your own node credentials and use the below as a reference.
Your arch-cli configuration file should resemble something like the following, with the leader_rpc_endpoint
being the endpoint for reaching your Local validator, which defaults to port 9002
.
[networks.testnet]
type = "testnet"
bitcoin_rpc_endpoint = "bitcoin-node.test.aws.archnetwork.xyz"
bitcoin_rpc_port = "49332"
bitcoin_rpc_user = "bitcoin"
bitcoin_rpc_password = "redacted"
bitcoin_rpc_wallet = "testwallet"
leader_rpc_endpoint = "http://localhost:9002"
Local validator
Note: the arch-cli (and Docker) can be used to run the local validator.
Additionally, if you do not already have the local validator installed, please pull it from the arch-node releases page.
Be sure to download the local variant, not the regular validator.
Run the local validator
Use the arch-cli command to run the local validator. You'll need to have Docker installed and running.
arch-cli validator start
The validator logs can be viewed easily within the Docker desktop dashboard.
Note: You can also run the standalone local validator binary where the logs will be streamed to
stdout
unless otherwise redirected.
Steps for running standalone validator binary:
-
Download the appropriate binary as well as the
system_program.so
file from arch-node releases page. -
Store the
system_program.so
file within a new directory called/ebpf
.Your directory structure should resemble the following:
tmp/ ├─ ebpf/ │ ├─ system_program.so ├─ local_validator
-
Run the binary and pass the relevant flags dependening on your target network.
RUST_LOG=info \ ./local_validator \ --network-mode testnet \ --rpc-bind-ip 127.0.0.1 \ --rpc-bind-port 9002 \ --bitcoin-rpc-endpoint bitcoin-node.test.aws.archnetwork.xyz \ --bitcoin-rpc-port 49332 \ --bitcoin-rpc-username bitcoin \ --bitcoin-rpc-password redacted
Help commands
This section includes some helpful material when needing to restart the node state or better ensure our infrastructure is operational before proceeding.
Arch node
The below commands can be used to assist with running the Local validator.
Start fresh
By removing the /.arch_data
directory, we can wipe the state and effective start the node again from genesis (block: 0).
rm -rf .arch_data && RUST_LOG=info \
./local_validator \
...
Pulse check
This cURL
command will allow us to ensure that our Local validator is up and running correctly. We can use this to effective get a pulse check on the node which is helpful for debugging.
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"is_node_ready",
"params":[]
}' \
http://localhost:9002/
Log assistance
Ordinarily, the arch-node logs will flood your terminal screen (or the Docker logs). This is less than idea when needing to review them carefully, so you can also direct the stdout
to a file for later reading.
Here's an example of how to do this:
rm -rf .arch_data && RUST_LOG=info \
./local_validator \
--network-mode testnet \
--rpc-bind-ip 127.0.0.1 \
--rpc-bind-port 9002 \
--bitcoin-rpc-endpoint bitcoin-node.test.aws.archnetwork.xyz \
--bitcoin-rpc-port 49332 \
--bitcoin-rpc-username bitcoin \
--bitcoin-rpc-password redacted \
> node-logs.txt
Then you can tail
the output and view the logs as they stream in.
tail -f node-logs.txt
Deploy + interact
Now that everything is setup correctly, we can now deploy our program and begin interacting with it. The deploy step will prove everything works correctly.
arch-cli deploy --network testnet
And if you are running the local validator binary directly from the command-line, set the --rpc-endpoint
flag so it overwrites the leader_rpc_endpoint
in the arch-cli config if this information was not changed:
arch-cli deploy --network testnet --rpc-url http://localhost:9002
We hope this guide has been helpful, but as always, feel free to ask question within our Discord dev-chat or submit issues within out public-issues repo.
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)
}
Program
A program is a special kind of account that contains executable eBPF bytecode, denoted by the Account.is_executable: true
field. This allows an account to receive arbitrary instruction data via a transaction to be processed by the runtime.
Every program is stateless, meaning that it can only read/write data to other accounts and that it cannot write to its own account; this, in-part, is how parallelized execution is made possible (see State for more info).
💡 Additionally, programs can send instructions to other programs which, in turn, receive instructions and thus extend program composability further. This is known as cross-program invocation (CPI) and will be detailed in future sections.
Components:
1. Entrypoint
Every Arch program includes a single entrypoint used to invoke the program. A handler function, often named process_instruction
, is then used to handle the data passed into the entrypoint.
These parameters are required for every instruction to be processed._
use arch_program::entrypoint;
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> Result<(), ProgramError> {
// Program logic here
}
2. Instruction
The instruction_data
is deserialized after being passed into the entrypoint. From there, if there are multiple instructions, a match
statement can be utilized to point the logic flow to the appropriate handler function previously defined within the program which can continue processing the instruction.
3. Process Instruction
If a program has multiple instructions, a corresponding handler function should be defined to include the specific logic unique to the instruction.
4. State
Since programs are stateless, a "data" account is needed to hold state for a user. This is a non-executable account that holds program data.
If a program receives instruction that results in a user's state being altered, the program would manage this user's state via a mapping within the program's logic. This mapping would link the user's pubkey with a data account where the state would live for that specific program.
The program will likely include a struct to define the structure of its state and make it easier to work with. The deserialization of account data occurs during program invocation. After an update is made, state data gets re-serialized into a byte array and stored within the data
field of the account.
Account Structure
Navigation: Reference → Program → Account Structure
For a comprehensive guide on working with accounts, see the Account Guide.
Accounts are a fundamental data structure in Arch that store state and are owned by [programs]. Each account has a unique address ([pubkey]) and contains data that can be modified by its owner program.
Account Structure
#[derive(Clone)]
#[repr(C)]
pub struct AccountInfo<'a> {
pub key: &'a Pubkey, // address of the account
pub utxo: &'a UtxoMeta, // utxo has this account key in script_pubkey
pub data: Rc<RefCell<&'a mut [u8]>>,
pub owner: &'a Pubkey,
pub is_signer: bool,
pub is_writable: bool,
pub is_executable: bool, // true: program; false: data account
}
AccountMeta
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
#[repr(C)]
pub struct AccountMeta {
pub pubkey: Pubkey,
pub is_signer: bool,
pub is_writable: bool,
}
Account Guide
Navigation: Reference → Program → Account Guide
For the core account structure and data types, see Account Structure.
Accounts are the fundamental building blocks for state management and program interaction in Arch Network. They serve as containers for both program code and state data, bridging the gap between Bitcoin's UTXO model and modern programmable state machines.
Note: For detailed documentation on core system functions used to interact with accounts (like
invoke
,new_create_account_instruction
,add_state_transition
, andset_transaction_to_sign
), see System Functions.
flowchart TD A[Account] --> B[Program Account] A --> C[Data Account] A --> D[Native Account] B --> E[Executable Code] C --> F[Program State] C --> G[UTXOs] D --> H[System Operations] style A fill:#f9f,stroke:#333,stroke-width:2px style B fill:#f5f5f5,stroke:#666 style C fill:#f5f5f5,stroke:#666 style D fill:#f5f5f5,stroke:#666
Core Concepts
Account Fundamentals
Every account in Arch Network is uniquely identified by a public key (pubkey) and contains four essential components:
pub struct Account {
/// The program that owns this account
pub owner: Pubkey,
/// Number of lamports assigned to this account
pub lamports: u64,
/// Data held in this account
pub data: Vec<u8>,
/// Whether this account can process instructions
pub executable: bool,
}
Component Details:
-
Owner (Pubkey)
- Controls account modifications
- Determines which program can modify data
- Can be transferred to new programs
- Required for all accounts
-
Lamports (u64)
- Native token balance
- Used for:
- Transaction fees
- Rent payments
- State storage costs
- Program execution fees
-
Data (Vec
) - Flexible byte array for state storage
- Common uses:
- Program code (if executable)
- Program state
- UTXO metadata
- Configuration data
- Size determined at creation
-
Executable Flag (bool)
- Determines if account contains program code
- Immutable after deployment
- Controls instruction processing capability
flowchart LR A[Account Creation] --> B[Initial State] B --> C[Runtime Operations] C --> D[State Updates] D --> E[Account Closure] subgraph Lifecycle A -. Initialize .-> B B -. Process Instructions .-> C C -. Modify State .-> D D -. Cleanup .-> E end style A fill:#f9f,stroke:#333,stroke-width:2px style E fill:#9ff,stroke:#333,stroke-width:2px style Lifecycle fill:#f5f5f5,stroke:#666,stroke-width:1px
Account Types & Use Cases
1. Program Accounts
Program accounts contain executable code and form the backbone of Arch Network's programmable functionality.
// Example program account creation
let program_account = SystemInstruction::CreateAccount {
lamports: rent.minimum_balance(program_data.len()),
space: program_data.len() as u64,
owner: bpf_loader::id(), // BPF Loader owns program accounts
executable: true,
data: program_data,
};
Key characteristics:
- Immutable after deployment
- Owned by BPF loader
- Contains verified program code
- Processes instructions
2. Data Accounts
Data accounts store program state and user data. They're highly flexible and can be structured to meet various needs.
// Example data structure for a game account
#[derive(BorshSerialize, BorshDeserialize)]
pub struct GameAccount {
pub player: Pubkey,
pub score: u64,
pub level: u8,
pub achievements: Vec<Achievement>,
pub last_played: i64,
}
// Creating a data account
let game_account = SystemInstruction::CreateAccount {
lamports: rent.minimum_balance(size_of::<GameAccount>()),
space: size_of::<GameAccount>() as u64,
owner: game_program::id(),
executable: false,
data: Vec::new(), // Will be initialized by program
};
Common use cases:
- Player profiles
- Game state
- DeFi positions
- NFT metadata
- Configuration settings
3. UTXO Accounts
Special data accounts that bridge Bitcoin UTXOs with Arch Network state.
#[derive(BorshSerialize, BorshDeserialize)]
pub struct UtxoAccount {
pub meta: UtxoMeta,
pub owner: Pubkey,
pub delegate: Option<Pubkey>,
pub state: UtxoState,
pub created_at: i64,
pub last_updated: i64,
pub constraints: Vec<UtxoConstraint>,
}
// Example UTXO account creation
let utxo_account = SystemInstruction::CreateAccount {
lamports: rent.minimum_balance(size_of::<UtxoAccount>()),
space: size_of::<UtxoAccount>() as u64,
owner: utxo_program::id(),
executable: false,
data: Vec::new(),
};
Account Interactions
Account interactions in Arch Network are facilitated through a set of core system functions. These functions handle everything from account creation to state transitions and are documented in detail in System Functions. Below are common patterns for account interactions:
1. Creation Patterns
// 1. Basic account creation
pub fn create_basic_account(
payer: &Keypair,
space: u64,
owner: &Pubkey,
) -> Result<Keypair, Error> {
let account = Keypair::new();
let rent = banks_client.get_rent().await?;
let lamports = rent.minimum_balance(space as usize);
let ix = system_instruction::create_account(
&payer.pubkey(),
&account.pubkey(),
lamports,
space,
owner,
);
let tx = Transaction::new_signed_with_payer(
&[ix],
Some(&payer.pubkey()),
&[payer, &account],
recent_blockhash,
);
banks_client.process_transaction(tx).await?;
Ok(account)
}
// 2. PDA (Program Derived Address) creation
pub fn create_pda_account(
program_id: &Pubkey,
seeds: &[&[u8]],
space: u64,
) -> Result<Pubkey, Error> {
let (pda, bump) = Pubkey::find_program_address(seeds, program_id);
let ix = system_instruction::create_account(
&payer.pubkey(),
&pda,
lamports,
space,
program_id,
);
// Include the bump seed for deterministic address
let seeds_with_bump = &[&seeds[..], &[&[bump]]].concat();
let signer_seeds = &[&seeds_with_bump[..]];
invoke_signed(&ix, &[payer, pda], signer_seeds)?;
Ok(pda)
}
2. State Management
// Example of managing account state
pub trait AccountState: Sized {
fn try_from_slice(data: &[u8]) -> Result<Self, Error>;
fn try_serialize(&self) -> Result<Vec<u8>, Error>;
fn load(account: &AccountInfo) -> Result<Self, Error> {
Self::try_from_slice(&account.data.borrow())
}
fn save(&self, account: &AccountInfo) -> Result<(), Error> {
let data = self.try_serialize()?;
let mut account_data = account.data.borrow_mut();
account_data[..data.len()].copy_from_slice(&data);
Ok(())
}
}
// Implementation example
impl AccountState for GameAccount {
fn update_score(&mut self, new_score: u64) -> Result<(), Error> {
self.score = new_score;
self.last_played = Clock::get()?.unix_timestamp;
Ok(())
}
}
3. Cross-Program Invocation (CPI)
// Example of one program calling another
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
// Deserialize accounts
let account_info_iter = &mut accounts.iter();
let source_info = next_account_info(account_info_iter)?;
let dest_info = next_account_info(account_info_iter)?;
let system_program = next_account_info(account_info_iter)?;
// Create CPI context
let cpi_accounts = Transfer {
from: source_info.clone(),
to: dest_info.clone(),
};
let cpi_program = system_program.clone();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
// Perform cross-program invocation
transfer(cpi_ctx, amount)?;
Ok(())
}
Security Considerations
1. Access Control
fn verify_account_access(
account: &AccountInfo,
expected_owner: &Pubkey,
writable: bool,
) -> ProgramResult {
// Check account ownership
if account.owner != expected_owner {
return Err(ProgramError::IncorrectProgramId);
}
// Verify write permission if needed
if writable && !account.is_writable {
return Err(ProgramError::InvalidAccountData);
}
// Additional checks...
Ok(())
}
2. Data Validation
fn validate_account_data<T: AccountState>(
account: &AccountInfo,
validate_fn: impl Fn(&T) -> bool,
) -> ProgramResult {
// Load and validate account data
let data = T::load(account)?;
if !validate_fn(&data) {
return Err(ProgramError::InvalidAccountData);
}
Ok(())
}
Best Practices
1. Account Management
- Always validate account ownership before modifications
- Use PDAs for deterministic addresses
- Implement proper error handling
- Close unused accounts to reclaim rent
2. Data Safety
- Validate all input data
- Use proper serialization
- Handle account size limits
- Implement atomic operations
3. Performance
- Minimize account creations
- Batch operations when possible
- Use appropriate data structures
- Cache frequently accessed data
4. Upgrades
- Plan for version management
- Implement migration strategies
- Use flexible data structures
- Document state changes
Common Patterns
1. Account Initialization
pub fn initialize_account<T: AccountState>(
program_id: &Pubkey,
account: &AccountInfo,
initial_state: T,
) -> ProgramResult {
// Verify account is uninitialized
if !account.data_is_empty() {
return Err(ProgramError::AccountAlreadyInitialized);
}
// Set account owner
account.set_owner(program_id)?;
// Initialize state
initial_state.save(account)?;
Ok(())
}
2. Account Updates
pub fn update_account<T: AccountState>(
account: &AccountInfo,
update_fn: impl FnOnce(&mut T) -> ProgramResult,
) -> ProgramResult {
// Load current state
let mut state = T::load(account)?;
// Apply update
update_fn(&mut state)?;
// Save updated state
state.save(account)?;
Ok(())
}
3. Account Closure
pub fn close_account(
account: &AccountInfo,
destination: &AccountInfo,
) -> ProgramResult {
// Transfer lamports
let dest_starting_lamports = destination.lamports();
**destination.lamports.borrow_mut() = dest_starting_lamports
.checked_add(account.lamports())
.ok_or(ProgramError::Overflow)?;
**account.lamports.borrow_mut() = 0;
// Clear data
account.data.borrow_mut().fill(0);
Ok(())
}
Related Topics
- UTXOs - How UTXOs integrate with accounts
- Programs - Programs that own and modify accounts
- Instructions - How to interact with accounts
System Functions
Core system functions that enable program interactions, account management, and state transitions in Arch Network.
Overview
These functions form the foundation for program-to-program communication, account management, and state transitions in Arch Network:
invoke
- Cross-program invocationnew_create_account_instruction
- Account creationadd_state_transition
- State managementset_transaction_to_sign
- Transaction preparation
Detailed Function Documentation
1. invoke
pub fn invoke(instruction: &Instruction, account_infos: &[AccountInfo]) -> ProgramResult
The invoke
function enables cross-program communication and execution:
- Purpose: Allows one program to call another program securely
- Key Features:
- Validates account permissions
- Manages account borrowing
- Handles cross-program context
- Provides error handling
Example Usage:
// Invoke system program to create account
invoke(
&SystemInstruction::new_create_account_instruction(
txid.try_into().unwrap(),
vout,
account_pubkey
),
&[account_info.clone()]
)?;
2. new_create_account_instruction
pub fn new_create_account_instruction(
txid: [u8; 32],
vout: u32,
pubkey: Pubkey,
) -> Instruction
Creates instructions for new account initialization:
- Purpose: Creates new accounts with UTXO backing
- Key Features:
- Sets up UTXO metadata
- Configures permissions
- Associates with system program
- Prepares initialization
Example Usage:
let instruction = SystemInstruction::new_create_account_instruction(
txid.try_into().unwrap(),
0, // vout index
account_pubkey,
);
3. add_state_transition
pub fn add_state_transition(transaction: &mut Transaction, account: &AccountInfo)
Manages state transitions for accounts:
- Purpose: Updates Bitcoin transactions with account changes
- Key Features:
- Adds UTXO inputs
- Sets up script signatures
- Configures outputs
- Manages state
Example Usage:
let mut tx = Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: vec![],
output: vec![],
};
add_state_transition(&mut tx, account);
4. set_transaction_to_sign
pub fn set_transaction_to_sign(
accounts: &[AccountInfo],
transaction_to_sign: TransactionToSign,
) -> ProgramResult
Prepares transactions for signing:
- Purpose: Sets up transaction metadata and permissions
- Key Features:
- Validates size limits
- Checks signer permissions
- Sets up metadata
- Manages requirements
Example Usage:
let transaction_to_sign = TransactionToSign {
tx_bytes: serialized_tx,
inputs_to_sign: vec![
InputToSign {
signer: account.key,
..Default::default()
}
],
};
set_transaction_to_sign(accounts, transaction_to_sign)?;
graph LR A[Program Request] ==> B[new_create_account_instruction] B ==> C[invoke] C ==> D[add_state_transition] D ==> E[set_transaction_to_sign] E ==> F[Bitcoin Transaction] style A fill:#4a9eff,stroke:#3182ce,stroke-width:2px,color:#fff style B fill:#ffffff,stroke:#ccd7e0,stroke-width:2px style C fill:#ffffff,stroke:#ccd7e0,stroke-width:2px style D fill:#ffffff,stroke:#ccd7e0,stroke-width:2px style E fill:#ffffff,stroke:#ccd7e0,stroke-width:2px style F fill:#f687b3,stroke:#d53f8c,stroke-width:2px,color:#fff
Best Practices
-
Validation
- Always check account permissions
- Verify transaction limits
- Validate UTXO states
- Handle errors properly
-
State Management
- Use atomic operations
- Maintain state consistency
- Handle failures gracefully
- Implement rollbacks
-
Security
- Validate all signatures
- Check account ownership
- Verify transaction data
- Handle edge cases
Related Topics
Entrypoint and Handler Functions
Entrypoint
Every Arch program includes a single entrypoint used to invoke the program. A handler function is then used to process the data passed into the entrypoint.
entrypoint!(process_instruction);
Initialization and Data Reading:
The entrypoint begins by initializing and reading serialized data that is passed in, which includes everything needed for program execution. It then deserializes this data to obtain the instruction, an object which contains all necessary details like the program_id
and associated UTXO information.
It passes in all deserialized data in to the handler function for processing of the program's business logic; this could involve transactions, state updates, or other program-specific operations.
Handler function
Here we'll discuss the dispatcher function, in our case, named: process_instruction
.
This dispatcher function requires the following parameters:
program_id
- Unique identifier of the currently executing program.accounts
- Slice reference containing accounts needed to execute an instruction.instruction_data
- Serialized data containing program instructions.
This returns a Result
representing success (Ok
) or failture (ProgramError
).
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> Result<(), ProgramError> {
...
}
This function is responsible for parsing and directing the execution flow based on the type of transaction or method specified. It is a critical component that developers must implement to ensure that their programs can appropriately respond to different operational requests.
The handler function is defined as part of the program and is facilitated via the entrypoint!
. This macro binds the process_instruction
function to be the first receiver of any execution call made by the Arch virtual machine, effectively making it the gatekeeper for all incoming instructions.
Deserialize Input Data
The function starts by deserializing the input data (instruction_data
) into a known format, typically a custom struct that represents different methods or commands the program can execute.
Method Dispatch
Based on the deserialized data, the function determines which specific method to execute. This is often handled through a match statement that routes to different functions or modules within the program.
Execute Business Logic
Each routed function performs specific business logic related to the program's purpose, such as managing assets, updating state, or interacting with other program or tokens.
Result Commitment:
Upon successful execution, the new UTXO authorities, new UTXO Data and a Bitcoin transaction are committed back to the network.
Instructions and Messages
Instructions and messages are fundamental components of Arch's transaction processing system that enable communication between clients and programs. They form the basis for all state changes and interactions within the Arch network.
Instructions
An instruction is the basic unit of program execution in Arch. It contains all the information needed for a program to execute a specific operation. Instructions are processed atomically, meaning they either complete entirely or have no effect.
Structure
pub struct Instruction {
/// Program ID that executes this instruction
pub program_id: Pubkey,
/// Accounts required for this instruction
pub accounts: Vec<AccountMeta>,
/// Instruction data
pub data: Vec<u8>,
}
Components:
- Program ID: The pubkey of the program that will process the instruction
- Accounts: List of accounts required for the instruction, with their metadata
- Instruction Data: Custom data specific to the instruction, typically serialized using Borsh or another format
Account Metadata
pub struct AccountMeta {
pub pubkey: Pubkey,
pub is_signer: bool,
pub is_writable: bool,
}
pubkey
: The account's public keyis_signer
: Whether the account must sign the transactionis_writable
: Whether the account's data can be modified
Messages
A message is a collection of instructions that form a transaction. Messages ensure atomic execution of multiple instructions, meaning either all instructions succeed or none take effect.
Structure
pub struct Message {
/// List of account keys referenced by the instructions
pub account_keys: Vec<Pubkey>,
/// Recent blockhash
pub recent_blockhash: Hash,
/// List of instructions to execute
pub instructions: Vec<CompiledInstruction>,
}
Components:
- Account Keys: All unique accounts referenced across instructions
- Recent Blockhash: Used for transaction uniqueness and timeout
- Instructions: List of instructions to execute in sequence
Instruction Processing Flow:
-
Client creates an instruction with:
- Program ID to execute the instruction
- Required accounts with appropriate permissions
- Instruction-specific data (serialized parameters)
-
Instruction(s) are bundled into a message:
- Multiple instructions can be atomic
- Account permissions are consolidated
- Blockhash is included for uniqueness
-
Message is signed to create a transaction:
- All required signers must sign
- Transaction size limits apply
- Fees are calculated
-
Transaction is sent to the network:
- Validated by validators
- Processed in parallel when possible
- Results are confirmed
-
Program processes the instruction:
- Deserializes instruction data
- Validates accounts and permissions
- Executes operation
- Updates account state
Best Practices:
-
Account Validation
- Always verify account ownership
- Check account permissions
- Validate account relationships
-
Data Serialization
- Use consistent serialization format (preferably Borsh)
- Include version information
- Handle errors gracefully
- Validate data lengths
-
Error Handling
- Return specific error types
- Provide clear error messages
- Handle all edge cases
- Implement proper cleanup
Cross-Program Invocation (CPI)
Instructions can invoke other programs through CPI, enabling composability:
-
Create new instruction for target program:
- Specify program ID
- Include required accounts
- Prepare instruction data
-
Pass required accounts:
- Include all necessary accounts
- Set proper permissions
- Handle PDA derivation
-
Invoke using
invoke
orinvoke_signed
:- For regular accounts:
invoke
- For PDAs:
invoke_signed
- Handle return values
- For regular accounts:
-
Handle results:
- Check return status
- Process any returned data
- Handle errors appropriately
Security Considerations:
-
Account Verification
- Verify all account permissions
- Check ownership and signatures
- Validate account relationships
- Prevent privilege escalation
-
Data Validation
- Sanitize all input data
- Check buffer lengths
- Validate numerical ranges
- Prevent integer overflow
-
State Management
- Maintain atomic operations
- Handle partial failures
- Prevent race conditions
- Ensure consistent state
Common Patterns:
-
Initialization
- Create necessary accounts
- Set initial state
- Assign proper ownership
-
State Updates
- Validate permissions
- Update account data
- Maintain invariants
-
Account Management
- Close accounts when done
- Manage PDAs properly
Pubkey
A pubkey, or public key, is a custom type that contains a 256-bit (32 bytes) integer derived from the private key.
#[derive(Clone, Debug, Eq, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
pub struct Pubkey([u8; 32]);
Syscalls
A syscall is a function that can be used to obtain information from the underlying virtual machine.
// Used for cross-program invocation (CPI)
// Invokes a cross-program call
define_syscall!(fn sol_invoke_signed_rust(instruction_addr: *const u8, account_infos_addr: *const u8, account_infos_len: u64) -> u64);
// Sets the data to be returned for the cross-program invocation
define_syscall!(fn sol_set_return_data(data: *const u8, length: u64));
// Returns the cross-program invocation data
define_syscall!(fn sol_get_return_data(data: *mut u8, length: u64, program_id: *mut Pubkey) -> u64);
// Arch
// Validates and sets up transaction for being signed
define_syscall!(fn arch_set_transaction_to_sign(transaction_to_sign: *const TransactionToSign));
// Retrieves raw Bitcoin transaction from RPC and copies into memory buffer
define_syscall!(fn arch_get_bitcoin_tx(data: *mut u8, length: u64, txid: &[u8; 32]) -> u64);
// Retrieves the multi-sig public key and copies into memory buffer
define_syscall!(fn arch_get_network_xonly_pubkey(data: *mut u8) -> u64);
// Validates ownership of a Bitcoin UTXO against a public key
define_syscall!(fn arch_validate_utxo_ownership(utxo: *const UtxoMeta, owner: *const Pubkey) -> u64);
// Generates a Bitcoin script public key and copies into memory buffer
define_syscall!(fn arch_get_account_script_pubkey(script: *mut u8, pubkey: *const Pubkey) -> u64);
// Retrieves the latest Bitcoin block height
define_syscall!(fn arch_get_bitcoin_block_height() -> u64);
// logs
// Prints the hexidecimal representation of a string slice to stdout
define_syscall!(fn sol_log_(message: *const u8, len: u64));
// Prints 64-bit values represented as hexadecimal to stdout
define_syscall!(fn sol_log_64_(arg1: u64, arg2: u64, arg3: u64, arg4: u64, arg5: u64));
// Prints the hexidecimal representation of a public key to stdout
define_syscall!(fn sol_log_pubkey(pubkey_addr: *const u8));
// Prints the base64 representation of a data array to stdout
define_syscall!(fn sol_log_data(data: *const u8, data_len: u64));
System Instruction
By default, every account is owned by the System Program (Pubkey). Only the System Program can create a new account.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum SystemInstruction {
CreateAccount(UtxoMeta),
ExtendBytes(Vec<u8>),
}
...
pub fn new_create_account_instruction(
txid: [u8; 32],
vout: u32,
pubkey: Pubkey,
) -> Instruction {
Instruction {
program_id: Pubkey::system_program(),
accounts: vec![AccountMeta {
pubkey,
is_signer: true,
is_writable: true,
}],
data: SystemInstruction::CreateAccount(UtxoMeta::from(txid, vout)).serialise(),
}
}
UTXO (Unspent Transaction Output)
UTXOs (Unspent Transaction Outputs) are fundamental to Bitcoin's transaction model and serve as the foundation for state management in Arch Network. Unlike account-based systems that track balances, UTXOs represent discrete "coins" that must be consumed entirely in transactions.
Core Concepts
What is a UTXO?
- A UTXO represents an unspent output from a previous transaction
- Each UTXO is uniquely identified by a transaction ID (txid) and output index (vout)
- UTXOs are immutable - they can only be created or spent, never modified
- Once spent, a UTXO cannot be reused (prevents double-spending)
Role in Arch Network
- UTXOs anchor program state to Bitcoin's security model
- They provide deterministic state transitions
- Enable atomic operations across the network
- Allow for provable ownership and state validation
UTXO Structure
The UtxoMeta
struct encapsulates the core UTXO identification data:
use arch_program::utxo::UtxoMeta;
use bitcoin::Txid;
#[derive(Debug, Clone, PartialEq)]
pub struct UtxoMeta {
pub txid: [u8; 32], // Bitcoin transaction ID (32 bytes)
pub vout: u32, // Output index in the transaction
}
impl UtxoMeta {
/// Creates a new UTXO metadata instance
pub fn new(txid: [u8; 32], vout: u32) -> Self {
Self { txid, vout }
}
/// Deserializes UTXO metadata from a byte slice
/// Format: [txid(32 bytes)][vout(4 bytes)]
pub fn from_slice(data: &[u8]) -> Self {
let mut txid = [0u8; 32];
txid.copy_from_slice(&data[0..32]);
let vout = u32::from_le_bytes([
data[32], data[33], data[34], data[35]
]);
Self { txid, vout }
}
}
UTXO Lifecycle
1. Creation Process
Creating a UTXO with Bitcoin RPC
use bitcoincore_rpc::{Auth, Client as RpcClient, RpcApi};
use bitcoin::{Amount, Address};
use arch_program::pubkey::Pubkey;
// Initialize Bitcoin RPC client
let rpc = RpcClient::new(
"http://localhost:18443", // Bitcoin node RPC endpoint
Auth::UserPass(
"user".to_string(),
"pass".to_string()
)
).expect("Failed to create RPC client");
// Generate a new account address
let account_address = Pubkey::new_unique();
let btc_address = Address::from_pubkey(&account_address);
// Create UTXO by sending Bitcoin
// Parameters explained:
// - address: Destination Bitcoin address
// - amount: Amount in satoshis (3000 sats = 0.00003 BTC)
// - comment: Optional transaction comment
// - replaceable: Whether the tx can be replaced (RBF)
let txid = rpc.send_to_address(
&btc_address,
Amount::from_sat(3000),
Some("Create Arch UTXO"), // Comment
None, // Comment_to
Some(true), // Replaceable
None, // Fee rate
None, // Fee estimate mode
None // Avoid reuse
)?;
// Wait for confirmation (recommended)
rpc.wait_for_confirmation(&txid, 1)?;
Creating an Arch Account with UTXO
use arch_program::{
system_instruction::SystemInstruction,
pubkey::Pubkey,
transaction::Transaction,
};
// Create new program account backed by UTXO
let account_pubkey = Pubkey::new_unique();
let instruction = SystemInstruction::new_create_account_instruction(
txid.try_into().unwrap(),
0, // vout index
account_pubkey,
// Additional parameters like:
// - space: Amount of space to allocate
// - owner: Program that owns the account
);
// Build and sign transaction
let transaction = Transaction::new_signed_with_payer(
&[instruction],
Some(&payer.pubkey()),
&[&payer],
recent_blockhash
);
2. Validation & Usage
Programs must implement proper UTXO validation:
fn validate_utxo(utxo: &UtxoMeta) -> Result<(), ProgramError> {
// 1. Verify UTXO exists on Bitcoin
let btc_tx = rpc.get_transaction(&utxo.txid)?;
// 2. Check confirmation count
if btc_tx.confirmations < MIN_CONFIRMATIONS {
return Err(ProgramError::InsufficientConfirmations);
}
// 3. Verify output index exists
if utxo.vout as usize >= btc_tx.vout.len() {
return Err(ProgramError::InvalidVout);
}
// 4. Verify UTXO is unspent
if is_spent(utxo) {
return Err(ProgramError::UtxoAlreadySpent);
}
Ok(())
}
3. State Management
// Example UTXO state tracking
#[derive(Debug)]
pub struct UtxoState {
pub meta: UtxoMeta,
pub status: UtxoStatus,
pub owner: Pubkey,
pub created_at: i64,
pub spent_at: Option<i64>,
}
#[derive(Debug)]
pub enum UtxoStatus {
Pending, // Waiting for confirmations
Active, // Confirmed and spendable
Spent, // UTXO has been consumed
Invalid, // UTXO was invalidated (e.g., by reorg)
}
Best Practices
-
Validation
- Always verify UTXO existence on Bitcoin
- Check for sufficient confirmations (recommended: 6+)
- Validate ownership and spending conditions
- Handle Bitcoin reorgs that might invalidate UTXOs
-
State Management
- Implement robust UTXO tracking
- Handle edge cases (reorgs, conflicting txs)
- Consider implementing UTXO caching for performance
- Maintain accurate UTXO sets for your program
-
Security
- Never trust client-provided UTXO data without verification
- Implement proper access controls
- Consider timelock constraints for sensitive operations
- Monitor for suspicious UTXO patterns
-
Performance
- Batch UTXO operations when possible
- Implement efficient UTXO lookup mechanisms
- Consider UTXO consolidation strategies
- Cache frequently accessed UTXO data
Error Handling
Common UTXO-related errors to handle:
pub enum UtxoError {
NotFound, // UTXO doesn't exist
AlreadySpent, // UTXO was already consumed
InsufficientConfirmations, // Not enough confirmations
InvalidOwner, // Unauthorized attempt to spend
Reorged, // UTXO invalidated by reorg
InvalidVout, // Output index doesn't exist
SerializationError, // Data serialization failed
}
Related Topics
- Account Model - How UTXOs relate to Arch accounts
- Program State - Using UTXOs for program state
- System Program - Core UTXO operations
SDK
This section includes reference documentation for our SDK.
Processed Transaction
A processed transaction is a custom data type that contains a runtime transaction, a status, denoting the result of executing this runtime transaction, as well as a collection of Bitcoin transaction IDs.
#[derive(Clone, Debug, Deserialize, Serialize, BorshDeserialize, BorshSerialize)]
pub enum Status {
Processing,
Processed,
}
#[derive(Clone, Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct ProcessedTransaction {
pub runtime_transaction: RuntimeTransaction,
pub status: Status,
pub bitcoin_txids: Vec<String>,
}
Runtime Transaction
A runtime transaction includes a version number, a slice of signatures included on the transaction and a message field, which details a list of instructions to be processed atomically.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshDeserialize, BorshSerialize)]
pub struct RuntimeTransaction {
pub version: u32,
pub signatures: Vec<Signature>,
pub message: Message,
}
Signature
A signature is a custom data type that holds a slice of 64 bytes.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct Signature(pub Vec<u8>);
System Program
The Arch System Program is Arch's core program. This program contains a set of variants that can be thought-of as native functionality that can be used within any Arch program.
The System Program creates new accounts, assigns accounts to owning programs, marks accounts as executable, and writes data to the accounts.
In order to make calls to the System Program, the following mapping can help you point to the correct functionality.
index | method |
---|---|
0 | CreateAccount |
1 | WriteBytes |
2 | MakeExecutable |
3 | AssignOwnership |
CreateAccount
Index: 0
Create a new account.
Below, within the Instruction data
field, we find a local variable instruction_data
that contains vec![0]
, the correct index for making a call to SystemProgram::CreateAccount
.
let instruction_data = vec![0];
let instruction = Instruction {
program_id: Pubkey::system_program(),
accounts: vec![AccountMeta {
pubkey,
is_signer: true,
is_writable: true,
}],
data: instruction_data,
}
WriteBytes
Index: 1
Writes bytes to an array and serializes it.
Below, within the Instruction data
field, we find a local variable instruction_data
that contains vec![1]
, the correct index for making a call to SystemProgram::WriteBytes
.
let offset = 4u32.to_le_bytes();
let mut instruction_data = vec![1];
instruction_data.extend_from_slice(&offset);
let instruction = Instruction {
program_id: Pubkey::system_program(),
accounts: vec![AccountMeta {
pubkey,
is_signer: true,
is_writable: true,
}],
data: instruction_data,
}
MakeExecutable
Index: 2
Sets the account as executable, marking it as a program.
Below, within the Instruction data
field, we find a local variable instruction_data
that contains vec![2]
, the correct index for making a call to SystemProgram::MakeExecutable
.
let instruction_data = vec![2];
let instruction = Instruction {
program_id: Pubkey::system_program(),
accounts: vec![AccountMeta {
pubkey,
is_signer: true,
is_writable: true,
}],
data: instruction_data,
}
We can proceed to confirm that the program is executable with read_account_info which returns an AccountInfoResult that gets parsed to obtain the is_executable
value.
assert!(
read_account_info("node_url", program_pubkey)
.unwrap()
.is_executable
);
AssignOwnership
Index: 3
Sets a Pubkey to be the owner of an account.
Below, within the Instruction data
field, we find a local variable instruction_data
that contains vec![3]
, the correct index for making a call to SystemProgram::AssignOwnership
.
The instruction_data
also contains the serialized Pubkey of the owner account.
let mut instruction_data = vec![3];
instruction_data.extend(program_pubkey.serialize());
let instruction = Instruction {
program_id: Pubkey::system_program(),
accounts: vec![AccountMeta {
pubkey,
is_signer: true,
is_writable: true,
}],
data: instruction_data,
}
RPC
Interact with Arch nodes directly with the JSON RPC API via the HTTP methods.
HTTP Methods
Interact with Arch nodes directly with the JSON RPC API via this list of available HTTP methods.
Note: For client-side needs, use the @saturnbtcio/arch-sdk library as an interface for the RPC methods to interact with an Arch node.
Endpoint
Default port: 9002
- http://localhost:9002
Request Format:
To make a JSON-RPC request, send an HTTP POST
request with a Content-Type: application/json
header.
The JSON request data should contain 4 fields:
jsonrpc: <string>
- set to "2.0."id: <number>
- a unique client-generated identifying integer.method: <string>
- a string containing the method to be invoked.params: <array>
- a JSON array of ordered parameter values.
Response Format:
The response output will be a JSON object with the following fields:
jsonrpc: <string>
- matching the value set in the request.id: <number>
- matching the value set in the request.result: <array|boolean|number|object|string>
- requested data, success confirmation or boolean flag.
sendTransaction
Description: Relays a single transaction to the nodes for execution.
The following pre-flight checks are performed:
- It verifies the transaction size limit.
- It verifies the transaction signatures.
- It verifies that the UTXOs are not already spent.
The same checks are performed on sendTransactions.
If these checks pass, the transaction is forwarded to the rest of the nodes for processing.
Method: POST
Parameters:
params: <serialized_object>
- A serialized Runtime Transaction object representing the transaction to be sent.
Returns: A string containing the transaction IDs (txid
) of the submitted transaction.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"send_transaction",
"params": [
[1,2,3,4,...]
]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"id": "1"
}
sendTransactions
Description: Sends multiple transactions in a single batch request.
The following pre-flight checks are performed:
- It verifies the transaction size limit.
- It verifies the transaction signatures.
- It verifies that the UTXOs are not already spent.
The same checks are performed on sendTransaction.
If these checks pass, the transaction is forwarded to the rest of the nodes for processing.
Method: POST
Parameters:
params: <array[serialized_object]>
- An array of serialized Runtime Transaction objects to be sent.
Returns: An array of strings containing the transaction IDs (txids
) of the submitted transactions.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"send_transactions",
"params": [
[
[1,2,3,4,...],
[5,6,7,8,...]
]
]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": [
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
],
"id": "1"
}
getAccountAddress
Description: Fetches the account address associated with a public key.
Method: POST
Parameters:
pubkey: <byte_array>
- The public key (Pubkey) of the account.
Returns: The account address as a string.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"get_account_address",
"params":[
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32]
]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"id": "1"
}
getProgramAccounts
⚠️ Note: This endpoint is not available for local validators.
Description: Fetches all accounts owned by the specified program ID.
Parameters: program_id: <byte_array>
- Pubkey of the program to query, as an array of 32 bytes.
filters
(optional) - Array of filter objects, each filter should be either:
{ "DataSize": <size> }
where<size>
is the required size of the account data{ "DataContent": { "offset": <offset>, "bytes": <byte_array> } }
where<offset>
is the offset into the account data, and<byte_array>
is an array of bytes to match
Returns: An array of account objects, each containing:
pubkey: <byte_array>
: The account's public key.account: <object>
: An object containing the account's data and metadata.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method": "get_program_accounts",
"params": [
[80,82,242,228,43,246,248,133,88,238,139,124,88,96,107,32,71,40,52,251,90,42,66,176,66,32,147,203,137,211,253,40],
[
{
"DataSize": 165
},
{
"DataContent": {
"offset": 0,
"bytes": [1, 2, 3, 4]
}
}
]
]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": [
{
"pubkey": [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],
"account": {
"data": [1,2,3,4,...],
"owner": [80,82,242,228,43,246,248,133,88,238,139,124,88,96,107,32,71,40,52,251,90,42,66,176,66,32,147,203,137,211,253,40],
"utxo": "txid:vout",
"is_executable": false
}
}
],
"id": "1"
}
getBlock
Description: Retrieves block data based on a block hash.
Method: POST
Parameters:
blockHash: <string>
- A string representing the block hash.
Returns: A Block
object or undefined
if the block is not found.
Error Handling: Returns undefined
if the block is not found (404).
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"get_block",
"params":[
"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": {
/* Block object */
},
"id": "1"
}
getBlockCount
Description: Retrieves the current block count.
Method: POST
Parameters: None.
Returns: The current block count as a number.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"get_block_count",
"params":[]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": 680000,
"id": "1"
}
getBlockHash
Description: Retrieves the block hash at a specific block height.
Method: POST
Parameters:
blockHeight: <number>
- The block height for which to retrieve the block hash.
Returns: A string representing the block hash.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"get_block_hash",
"params":["680000"]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
"id": "1"
}
getCurrentState
️️⚠️ Note: This endpoint is not available for local validators.
Description: Retrieves the current state of the node, including information about the blockchain and network status.
Method: POST
Parameters: None.
Returns: A CurrentState
object.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"get_current_state",
"params":[]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": {
"state": "Running",
"last_state_transition": "2023-10-01T12:00:00Z"
},
"id": 1
}
getPeers
️️⚠️ Note: This endpoint is not available for local validators.
Description: Retrieves a list of peers currently connected to the node.
Method: POST
Parameters: None.
Returns: An array of PeerStats
.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"get_peers",
"params":[]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": [
{
"peer_id": "12D3KooW...",
"address": "/ip4/192.168.1.1/tcp/30303",
"status": "connected"
}
],
"id": 1
}
getProcessedTransaction
Description: Fetches details of a processed transaction using a transaction ID.
Method: POST
Parameters:
txid: <string>
- A string representing the transaction ID.
Returns: A ProcessedTransaction
object or undefined if the transaction is not found.
Error Handling: Returns undefined
if the transaction is not found (404).
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"get_processed_transaction",
"params":[
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": {
"runtime_transaction": { /* RuntimeTransaction object */ },
"status": "Confirmed",
"bitcoin_txids": ["txid1", "txid2"]
},
"id": "1"
}
readAccountInfo
Description: Retrieves detailed information for the specified account.
Method: POST
Parameters:
pubkey: <byte_array>
- The public key (Pubkey) of the account to query, as an array of 32 bytes.
Returns: An object containing the account's information:
data
: The account's data as a byte array.owner
: The account's owner as a byte array (program_id
).utxo
: The UTXO associated with this account.is_executable
: A boolean indicating if the account contains executable code.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"read_account_info",
"params":[
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32]
]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": {
"data": [1,2,3,4,...],
"owner": [80,82,242,228,43,246,248,133,88,238,139,124,88,96,107,32,71,40,52,251,90,42,66,176,66,32,147,203,137,211,253,40],
"utxo": "txid:vout",
"is_executable": false
},
"id": "1"
}
startDkg
️️⚠️ Note: This endpoint is not available for local validators.
Description: Initiates the Distributed Key Generation (DKG) process.
Method: POST
Parameters: None.
Returns: A success message if the DKG process is initiated.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"start_dkg",
"params":[]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": "DKG process initiated",
"id": "1"
}
isNodeReady
Description: Checks if the node is ready to process requests.
Parameters: None.
Returns: A boolean indicating whether the node is ready.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"is_node_ready",
"params":[]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": true,
"id": "1"
}
resetNetwork
⚠️ Note:
This method is only callable by the Leader node, which for the time being will be Arch. This method is used for internal debugging purposes as we get the Testnet operational.
This endpoint is also not available for local validators.
Description: Resets the network state.
Parameters: None.
Returns: A success message if the network reset is successful.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"reset_network",
"params":[]
}' \
http://localhost:9002/
Response:
{
"jsonrpc": "2.0",
"result": "Success!",
"id": "1"
}
Deprecated Methods
The following includes a list of RPC methods which are no longer supported.
getAccountInfo
Description: Retrieves detailed information for the specified account.
Method: POST
Parameters:
pubkey: <byte_array>
- The public key (Pubkey) of the account to query, as an array of 32 bytes.
Returns: An object containing the account's information:
data
: The account's data as a byte array.owner
: The account's owner as a byte array (program_id
).utxo
: The UTXO associated with this account.is_executable
: A boolean indicating if the account contains executable code.
Request:
curl -vL POST -H 'Content-Type: application/json' -d '
{
"jsonrpc":"2.0",
"id":1,
"method":"get_account_info",
"params":[
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32]
]
}' \
http://localhost:9001/
Response:
{
"jsonrpc": "2.0",
"result": {
"data": [1,2,3,4,...],
"owner": [80,82,242,228,43,246,248,133,88,238,139,124,88,96,107,32,71,40,52,251,90,42,66,176,66,32,147,203,137,211,253,40],
"utxo": "txid:vout",
"is_executable": false
},
"id": "1"
}