Introduction
Welcome to the Arch Network developer documentation.
This book will guide you through installation of Arch's developer stack as well as provide you with the essential knowledge to create, deploy and interact with Arch programs.
Arch Network leverages the security and ubiquity of Bitcoin to enhance decentralized applications by providing a computation environment. Arch facilitates complex operations on Bitcoin UTXOs through programs executed within the Arch VM environment.
Prerequirements
Rust
This book explores Arch Network's programs. It is not a Rust tutorial, and it assumes basic Rust knowledge. It is strongly recommended to understand the language first before proceeding.
Bitcoin
Additionally, it is helpful to understand how Bitcoin works, especially the UTXO model. A good resource is Saylor Academy's Bitcoin for Developers course as well as Mastering Bitcoin by Andreas Antonopoulos.
Contributing
Please create an issue or pull request if you find any mistakes, bugs, or ambiguities. See CONTRIBUTING.md for additional details.
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.
Requirements
The following dependencies are needed to proceed. Install these before moving to the next step.
Install Rust
First, to work with Arch programs you will need Rust installed on your machine. If you don't have it, you can find installation instructions on the Rust website.
It is assumed that you are working with a stable Rust channel throughout this book.
Install Docker
Next, Docker is required to run Arch's containerized node infrastructure locally. The desktop client can be installed from the Docker website.
Install C++ Compiler
For MacOS users, this should already be installed alongside gcc so you can skip this section.
For Linux (Debian/Ubuntu) users, this must be installed if it isn't already. We will manually install the gcc-multilib.
sudo apt-get update
sudo apt-get install gcc-multilib
Install Node.js
Please make sure you have Node.js version 19 or higher installed as npm is required to run the various front-ends within the repository.
Install Solana CLI
To compile the examples, the Solana CLI toolchain must be installed. Execute the following commands to install the toolchain to your local system.
MacOS & Linux
sh -c "$(curl -sSfL https://release.solana.com/v1.18.18/install)"
You can replace v1.18.18 with the release tag matching the software version of your desired release, or use one of the three symbolic channel names: stable, beta, or edge.
Ref: Solana Docs.
⚠️ NOTE: Installing rust through Homebrew likely leads to issues working with
cargo-build-sbf
. Below are some steps to get around this.
Steps:
- Uninstall rust.
rustup uninstall self
- Ensure rust is completely removed.
rustup --version
# should result:
zsh: command not found: rustup
- Reinstall rust.
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)"
If you are still experiencing errors, join our Discord dev-chat channel for more support.
Clone and install the arch-cli
Finally, we'll be using a repository specifically made to demonstrate Arch's capabilities and get you started building quickly: arch-cli
.
The arch-cli
repo provides a local Arch Network development environment, a command-line tool to setup new projects, deploy programs and more, as well as provides an example dapp to showcase Arch functionality that we will touch on later in this book. The arch-cli
also ships with a mini block explorer for additional visibility into transactions and block production.
git clone https://github.com/arch-Network/arch-cli && \
cd arch-cli
# install
cargo install --path .
Setting up a project
Initialize
The init
subcommand initializes an Arch Network project with the necessary folder structure, boilerplate code, and Docker configurations for supporting the example application.
arch-cli init
Note: This step will prompt you to provide a location on your hard drive where you'd like new Arch projects to be created; this location is then stored within the
config.toml
and can be updated accordingly.The default location for new Arch projects is within your
/Documents
directory.
If everything initializes smoothly, you'll be presented with output similar to the following:
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
Initializing new Arch Network app...
Checking required dependencies...
→ Checking docker... ✓
Detected version: Docker version 27.3.1, build ce1223035a
→ Checking docker-compose... ✓
Detected version: Docker Compose version 2.29.7
→ Checking node... ✓
Detected version: v22.9.0
→ Checking solana... ✓
Detected version: solana-cli 1.18.22 (src:b286211c; feat:4215500110, client:SolanaLabs)
→ Checking cargo... ✓
Detected version: cargo 1.81.0 (2dbb1af80 2024-08-20)
All required dependencies are installed.
Where would you like to create your Arch Network project?
Default: /Users/jr/Documents/ArchNetwork
Project directory (press Enter for default):
⚠ Directory is not empty. Do you want to use this existing project folder? (y/N)
y
✓ Using existing project folder
✓ Created arch-data directory at "/Users/jr/Library/Application Support/arch-cli/arch-data"
✓ Copied default configuration to "/Users/jr/Library/Application Support/arch-cli/config.toml"
✓ Updated configuration with project directory
✓ New Arch Network app initialized successfully!
Create a new project
To create a new project, the arch-cli
offers a project create
directive that will setup a new project directory in the location set in the config.toml
.
Simply issue the following command and pass the name of your project in:
arch-cli project create --name my_app
And the corresponding output:
Welcome to the Arch Network CLI
→ Loading configuration from /Users/jr/Library/Application Support/arch-cli/config.toml
Creating a new project...
✓ Updated configuration with project directory
✓ Created project directory at "/Users/jr/Documents/ArchNetwork/projects/my_app"
You will find all of the necessary crates for development (eg, /program
, /sdk
and /bip322
) available at the root of /ArchNetwork
.
In this way, all projects will be able to access the necessary Arch dependencies without needing to manage them within each project.
Example:
ArchNetwork/
├─ bip322/
├─ program/
├─ projects/
│ ├─ my_app/
├─ sdk/
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
Config.toml
Before using arch-cli
, you need to set up a config.toml
file. By default, the CLI will look for this file in the following locations:
- Linux:
~/.config/arch-cli/config.toml
- macOS:
~/Library/Application Support/arch-cli/config.toml
- Windows:
C:\Users\<User>\AppData\Roaming\arch-cli\config.toml
If the configuration file is not found, a default configuration file will be created automatically using the config.default.toml
template which can then be renamed to config.toml
if you don't wish to create your own.
You can also specify a custom configuration file location by setting the ARCH_CLI_CONFIG
environment variable:
export ARCH_CLI_CONFIG=/path/to/your/config.toml
Here's the default configuration:
[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"]
[program]
key_path = "${CONFIG_DIR}/keys/program.json"
[electrs]
rest_api_port = "3003"
electrum_port = "60401"
[btc_rpc_explorer]
port = "3000"
[demo]
frontend_port = "5173"
backend_port = "5174"
[indexer]
port = "5175"
[ord]
port = "3032"
[arch]
docker_compose_file = "./arch-docker-compose.yml"
network_mode = "localnet"
rust_log = "info"
rust_backtrace = "1"
bootnode_ip = "172.30.0.10"
bootnode_p2p_port = "19001"
leader_p2p_port = "19002"
leader_rpc_port = "9002"
leader_rpc_endpoint = "http://localhost:9002"
validator1_p2p_port = "19003"
validator1_rpc_port = "9003"
validator2_p2p_port = "19004"
validator2_rpc_port = "9004"
bitcoin_rpc_endpoint = "bitcoin"
bitcoin_rpc_wallet = "devwallet"
services = ["bootnode", "leader", "validator-1", "validator-2"]
replica_count = 2
By following these steps, you ensure that your CLI can be run from any location and still correctly locate and load its configuration files on Windows, macOS, and Linux.
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: this 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
- Bitcoin mempool and blockchain 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
Basics
Now that we've successfully compiled a program and executed it within the context of our Arch VM, let's dig into the details of how an Arch Program is written and discuss some best practices.
In this section, we'll guide you through the basics of writing functions and handling input parameters effectively within a demo program: GraffitiWall.
Building, deploying and interfacing
Now that all of the dependencies are installed and we have successfully chosen a development track, 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 src/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 src/app/program/src
folder:
cd src/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.
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; you may need to replace this if the Program ID you received from the previous step differs from this one.
arch-cli account create --name graffiti --program-id 3688ef8de06d56e32a765243e900875c4fefc6aa9c83dfbc2643f661c5b4982e
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
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.
Inside this file we initialize a new instance of RPC client, construct, sign and send a few transactions, and then poll the network for the processed transaction results.
After receiving the relayed instructions from the RPC server, the Arch Network validator nodes will execute the program logic within the context of the Arch VM, signing-off on the execution and then pass the results to the leader who will ultimately submit a signed Bitcoin transaction back to the Bitcoin network.
We'll have an updated version of this where we explain line-by-line how things work shortly. For now, familiarize yourself with the data structures.
For example, you'll find the class GraffitiMessage
, which will mirror the data structure that we use within our GraffitiWall program.
// templates/demo/app/frontend/src/components/GraffitiWallComponent.tsx
class GraffitiMessage {
constructor(
public timestamp: number,
public name: string,
public message: string
) {}
}
And here's the data structure found in our src/app/program/src/lib.rs
.
#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)]
pub struct GraffitiMessage {
pub timestamp: i64,
pub name: [u8; 16],
pub message: [u8; 64],
}
More to come.
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.
// update_account.rs
use bitcoincore_rpc::{Auth, Client};
use common::constants::*;
use arch_program::{pubkey::Pubkey, utxo::UtxoMeta, system_instruction::SystemInstruction, instruction::Instruction, account::AccountMeta};
use common::helper::*;
use serial_test::serial;
use common::models::*;
use std::thread;
use std::str::FromStr;
use borsh::{BorshSerialize, BorshDeserialize};
use std::fs;
use serde_json::Value;
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);
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._
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> Result<(), ProgramError> {
...
}
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.
Accounts
An account is a unique 32-bytes address that can store arbitrary data.
Everything on Arch is an account, and anyone can publicly read from any account; however, only an account's owner can modify data within an account.
If an account is_executable: true
, then the account is considered to be a program; conversely, if an account is_executable: false
, then it is considered to be a data account, meaning that it only serves to hold and manage state of a program.
Key Concepts
- Accounts can store up to 10MB of data, which can consist of either executable program code or program state
- Every account has a UTXO. The UTXO is used to anchor the state change to bitcoin.
- New Account Creation: Only the System Program can create a new account
- Space Allocation: Sets the byte capacity for the data field of an account
- Data Modification: Modifies the data field of an account
#[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
}
#[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,
}
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
An instruction specifies the program_id
, which is a unique resource identifier for the program, a collection of accounts needed to execute the instruction, as well as a slice of bytes which, once deserialized, includes the actions for the program to take.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct Instruction {
pub program_id: Pubkey,
pub accounts: Vec<AccountMeta>,
pub data: Vec<u8>,
}
Messages
A message structure contains a slice of signing keys as well as a slice of instruction data.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct Message {
pub signers: Vec<Pubkey>,
pub instructions: Vec<Instruction>,
}
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
A UtxoMeta
structure contains a 36-byte u8
array representing the UTXO.
#[derive(Clone, Debug, PartialEq, Eq)]
#[repr(C)]
pub struct UtxoMeta([u8; 36]);
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.
INFO:
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: 9001
- http://localhost:9001
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:9001/
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:9001/
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:9001/
Response:
{
"jsonrpc": "2.0",
"result": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"id": "1"
}
getProgramAccounts
⚠️ Note: This endpoint is not available for local validators.
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:9001/
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:9001/
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:9001/
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:9001/
Response:
{
"jsonrpc": "2.0",
"result": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
"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:9001/
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: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"
}
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:9001/
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:9001/
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:9001/
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"
}