Bitcoin Price Oracle Contract on Soroban Stellar (Futurenet)
Introduction:
In the dynamic landscape of blockchain technology, decentralized applications often require access to real-world data. However, integrating external data sources while maintaining the blockchain's decentralized nature can be challenging. This is where oracle contracts step in. In this article, we'll delve into the world of oracles and explore how to construct a robust decentralized Oracle contract using the Soroban blockchain platform.
The Role of Oracles in Decentralization:
Oracles play a crucial role in bridging the gap between blockchain applications and real-world data. These smart contracts act as trustworthy data sources that relay external information onto the blockchain. Whether it's stock prices, weather data, or any other off-chain information, oracles enable decentralized applications to make informed decisions without compromising the blockchain's security and decentralization.
Introducing the Soroban Oracle Contract:
Our journey in this article centers around building an Oracle contract on the Soroban blockchain. The Soroban blockchain offers a powerful ecosystem for crafting decentralized applications, and creating an Oracle contract showcases its potential to address real-world data integration.
Built With
- Soroban smart contracts - https://soroban.stellar.org
- React
- IPFS Storage - https://thirdweb.com/dashboard/infrastructure/storage
- Chakra UI - https://chakra-ui.com/
- API Ninjas - https://api-ninjas.com/api/cryptoprice
Prerequisites
Node v18 - Install here: https://nodejs.org/en/download
Rust - How to install Rust:
https://soroban.stellar.org/docs/getting-started/setup#install-rustSoroban CLI - How to install Soroban CLI:
https://soroban.stellar.org/docs/getting-started/setup#install-the-soroban-cliStellar Account with test tokens on Futurenet - How to create new wallet using soroban-cli & receive test tokens:
https://soroban.stellar.org/docs/getting-started/deploy-to-futurenet#configure-an-identityFreighter Wallet - Wallet extension for interact with the app. Link: https://www.freighter.app
Explanation of the smart contract code
- This project consists of 3 smart contracts. The most important of these is the Bitcoin price Oracle contract, located at
contracts/oracle/
. - The other two are the contract of new created token (BTC) and the Donation Contract.
Bitcoin price Oracle contract
Defining Custom Types (Data Structures)
The heart of our Oracle contract lies within the Rust code provided. This code outlines the contract's architecture, data structures, and core functionalities. It defines two main data structures:
-
PairInfo
for storing information about the data pair, such as its name, relayer address, epoch interval, creation time, and the number of the last epoch.
#[derive(Clone, Debug)]
#[contracttype]
pub struct PairInfo {
pub pair_name: String,
pub relayer: Address,
pub epoch_interval: u32,
pub create_time: u64,
pub last_epoch: u32,
}
-
EpochData
to represent data at specific epochs. It includes an ID, timestamp, and a value associated with that epoch.
#[derive(Clone, Debug)]
#[contracttype]
pub struct EpochData {
pub id: u32,
pub time: u64,
pub value: u32,
}
Enum Definition and Data Keys
-
DataKey
: An enum that represents keys used for storing and retrieving data in the contract's storage. It includes variants for different purposes, like marking initialization, identifying the contract owner, storing pair information, and epoch data using an epoch number.
These data structures define the core components of the oracle contract's state and the types of data it handles.
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Initialized,
ContractOwner,
PairInfo,
EpochData(u32),
}
Helper Functions for Data Retrieval
- This part introduces a series of helper functions used to retrieve data from the contract's storage.
-
get_timestamp
returns the current timestamp from the ledger. -
get_pair_info
retrieves the information of the oracle pair from the contract's storage. -
get_last_data_epoch
retrieves the last recorded epoch from the contract's storage. - These functions abstract the process of retrieving various types of data from the contract's storage and will be used within the contract methods to access relevant information.
fn get_timestamp(e: &Env) -> u64 {
e.ledger().timestamp()
}
fn get_pair_info(e: &Env) -> PairInfo {
// ...
// (Implementation details)
// ...
}
fn get_last_data_epoch(e: &Env) -> u32 {
// ...
// (Implementation details)
// ...
}
// ... (Other helper functions for data retrieval)
Retrieving Contract Owner and Relayer Addresses
- In the
get_contract_owner function
, we use thee.storage().instance().get
method to retrieve the contract owner's address from the contract's storage. We specify theDataKey::ContractOwner
as the key to access this information. If the contract has not been initialized (i.e., the Initialized flag is not set), an error message is displayed. - In the get_relayer function, we retrieve the relayer's address by accessing the
PairInfo
from the contract's storage. We use the samee.storage().instance().get
method, but this time we use theDataKey::PairInfo
as the key. This retrieves the entirePairInfo
struct. We then access the relayer field from this struct to obtain the relayer's address. - These functions enable the contract to fetch the contract owner's address and the relayer's address, which are critical for verifying authorization and managing interactions within the contract.
fn get_contract_owner(e: &Env) -> Address {
e.storage()
.instance()
.get::<_, Address>(&DataKey::ContractOwner)
.expect("Contract not initialized")
}
fn get_relayer(e: &Env) -> Address {
let pair_info = e
.storage()
.instance()
.get::<_, PairInfo>(&DataKey::PairInfo)
.expect("Contract not initialized");
pair_info.relayer
}
Retrieving Pair Data at Specific Epoch
- The
get_pair_data_at_epoch
function is designed to retrieve epoch data for a specific epoch number. It performs validity checks on the provided epoch number to ensure it's within a valid range (greater than 0 and less than or equal to the last recorded epoch). If the epoch number is out of bounds, an error message is generated. - The function then uses the
e.storage().instance().get
method to retrieve theEpochData
associated with the provided epoch number (epoch_nr
) using theDataKey::EpochData
variant. If the data is not found (indicating an inexistent epoch), an error message is displayed. - This function enables the contract to fetch epoch-specific data, which can be critical for various use cases such as fetching historical data for analysis.
fn get_pair_data_at_epoch(e: &Env, epoch_nr: &u32) -> EpochData {
assert!(
epoch_nr > &0u32 && epoch_nr <= &get_last_data_epoch(&e.clone()),
"Inexistent epoch"
);
e.storage()
.instance()
.get::<_, EpochData>(&DataKey::EpochData(epoch_nr.clone()))
.expect("Inexistent Epoch")
}
fn set_epoch_data(e: &Env, epoch_nr: &u32, epoch_data: &EpochData) {
// ...
// (Implementation details)
// ...
}
Contract Methods for Initialization and Configuration
The initialization function initialize is used to set up the Oracle contract. It takes parameters such as the caller's address, relayer's address, pair name, and epoch interval. This function ensures that the contract is initialized only once and sets the initial data values, including the contract owner, pair information, and an initialization flag.
Here's the code for the initialize function:
impl OracleContract {
pub fn initialize(
e: Env,
caller: Address,
relayer: Address,
pair_name: String,
epoch_interval: u32,
) {
// Check if the contract is already initialized
// Create a new PairInfo instance
// Set contract owner, pair info, and initialization flag
// Bump the storage version
}
// Other contract functions
}
Step 1: Check Initialization
The first step is to ensure that the contract is not already initialized. We achieve this by checking if the contract storage contains a flag indicating initialization. If the flag is present, we throw an error to prevent re-initialization.
assert!(
!e.storage().instance().has(&DataKey::Initialized),
"Contract already initialized"
);
Step 2: Create PairInfo
Next, we create a new PairInfo instance using the provided parameters and other information. We set the pair_name, relayer, epoch_interval, creation time (timestamp() from the ledger), and initialize last_epoch to 0.
let pair_info = PairInfo {
pair_name: pair_name.clone(),
relayer: relayer.clone(),
epoch_interval: epoch_interval.clone(),
create_time: e.ledger().timestamp(),
last_epoch: 0,
};
Step 3: Set Initial Values
We set up initial data values in the contract storage. This includes:
Setting the contract owner to the caller's address.
Storing the pair_info
.
Setting the initialization flag to indicate that the contract has been initialized.
e.storage().instance().set(&DataKey::ContractOwner, &caller);
e.storage().instance().set(&DataKey::PairInfo, &pair_info);
e.storage().instance().set(&DataKey::Initialized, &true);
Step 4: Bump Storage Version
We bump the contract instance storage for 30 days using the bump method. Read more about State Expiration here: https://soroban.stellar.org/docs/fundamentals-and-concepts/state-expiration
e.storage().instance().bump(BUMP_AMOUNT);
-
update_pair_epoch_interval
is a contract method that allows the contract owner to update the epoch interval of the oracle pair. It verifies the caller's authorization, updates the epoch interval in thePairInfo
struct, and updates the storage accordingly.
pub fn update_pair_epoch_interval(e: Env, caller: Address, epoch_interval: u32) -> PairInfo {
// ...
// (Implementation details)
// ...
}
// ... (Other contract methods)
}
-
update_relayer_address
is a contract method that allows the contract owner to update the relayer's address. Similar to previous methods, it checks the caller's authorization, updates the relayer's address in the PairInfo struct, and updates the storage accordingly. -
set_epoch_data
is a contract method that allows the relayer to set new epoch data. It verifies the relayer's authorization, increments the epoch number, updates the last epoch in the PairInfo struct, and sets the epoch data in the storage. - These methods enable the contract owner to manage key addresses and settings, while the relayer has the ability to add new epoch data to the contract's storage. This configuration and data management functionality is central to the operation of the oracle contract.
pub fn update_relayer_address(
e: Env,
caller: Address,
new_relayer_address: Address,
) -> PairInfo {
// ...
// (Implementation details)
// ...
}
pub fn set_epoch_data(e: Env, caller: Address, value: u32) -> EpochData {
// ...
// (Implementation details)
// ...
}
Contract Methods for Data Retrieval
- This section focuses on the contract methods designed for data retrieval.
- Each of these methods corresponds to a previously introduced helper function, and they act as wrappers to provide an interface for users to access specific information from the contract's storage.
- For instance,
get_timestamp
,get_contract_owner
,get_relayer
,get_pair_info
,get_last_data_epoch
, andget_pair_data_at_epoch
are all methods that facilitate data retrieval. - By encapsulating these retrieval processes within contract methods, users can easily access important data points stored in the contract's storage without dealing with low-level storage operations.
#[contractimpl]
impl OracleContract {
// ...
pub fn get_timestamp(e: Env) -> u64 {
get_timestamp(&e)
}
pub fn get_contract_owner(e: Env) -> Address {
get_contract_owner(&e)
}
pub fn get_relayer(e: Env) -> Address {
get_relayer(&e)
}
pub fn get_pair_info(e: Env) -> PairInfo {
get_pair_info(&e)
}
pub fn get_last_data_epoch(e: Env) -> u32 {
get_last_data_epoch(&e)
}
pub fn get_pair_data_at_epoch(e: Env, epoch_nr: u32) -> EpochData {
get_pair_data_at_epoch(&e, &epoch_nr)
}
// ... (Other contract methods)
}
Conclusion and Summary
This code is written in the Soroban smart contract language and follows best practices for contract design and security. The combination of enums, data structures, helper functions, and contract methods creates a comprehensive contract that can serve as a reliable oracle for timestamped data.
CRON task
- You will need to run a CRON task at every 5 minutes that will check if there is need to fetch the BTC price from external API and set it to contract.
The function is ready, you need only to put:
- Secret key of wallet (relayer) that will fetch BTC price from API and set it to smart contract;
- Contract address of deployed Oracle Contract;
-
API_KEY
from https://api-ninjas.com/api/cryptoprice (for free).
const cron = require('node-cron');
const SorobanClient = require('soroban-client');
const xdr = SorobanClient.xdr;
// ... (API keys, keypairs, contract ID, and server setup)
const getTimestamp = async () => {
// ... (Get timestamp from contract)
}
const getPairInfo = async () => {
// ... (Get pair info from contract)
}
const getEpochData = async (epochNr) => {
// ... (Get epoch data from contract)
}
const getPairPrice = async (pairName) => {
// ... (Fetch pair price from external API)
}
const updatePairPrice = async (price) => {
// ... (Update pair price data in contract)
}
const main = async () => {
// ... (Main logic to update data in contract)
}
cron.schedule("0 */5 * * * *", async () => {
// ... (Schedule task to run every 5 minutes)
});
Explanation:
- This code showcases an automated script that periodically updates data in the Oracle contract.
- The script uses the
node-cron
library to schedule the execution of the main function every 5 minutes. - Within the main function, the script performs the following steps:
a) Calculates the time difference between the current timestamp and the last epoch timestamp to determine if an update is needed.
If an update is needed, fetches the latest pair price from an external API using the
getPairPrice
function. b) Calls theupdatePairPrice
function to update the pair price data in the contract.
To run the CRON task, go to cron
dir and run:
npm install
node cron-script.js
Summary:
The provided JavaScript code demonstrates an automated script that fetches and updates data from an Oracle contract at regular intervals. It leverages the Soroban client library to interact with the Soroban blockchain and the contract. The script ensures that the contract's data is up-to-date by fetching the latest price from an external API and updating the contract with the new value.
How to run this dapp on your PC:
- Clone this repository:
git clone https://github.com/snowstorm134/SorobanCryptoOracle.git
Build & deploy smart contracts
- Run
npm run setup
It will execute the initialize.sh
bash script. *
- - If you are using Linux or Ubuntu OS, you may get the following error:
./initialize.sh: Permission denied
This error occurs when the shell script you’re trying to run doesn’t have the permissions to execute. To fix that, use this command:
chmod +x initialize.sh
and try again to run
npm run setup
The initialize.sh
script is designed to streamline the deployment and setup of a Soroban smart contracts. It covers network configuration, contracts deployment and initialization, as well as setting up necessary configurations for example wallet identities.
Let's break down the script step by step:
Variables and Network Setup:
This section sets up the script to accept command line arguments for the network type (standalone or futurenet) and the Soroban RPC host.
NETWORK="$1"
SOROBAN_RPC_HOST="$2"
PATH=./target/bin:$PATH
Soroban RPC Host Configuration:
This section determines the Soroban RPC URL based on the provided host or network type.
if [[ "$SOROBAN_RPC_HOST" == "" ]]; then
# ...
else
SOROBAN_RPC_URL="$SOROBAN_RPC_HOST"
fi
Depending on the network type specified as the first argument, this block sets the Soroban network passphrase, Friendbot URL, and other network-specific configurations.
case "$1" in
standalone)
# ...
futurenet)
# ...
*)
# ...
esac
Network Setup Logging:
This section logs the chosen network, its associated RPC URL, and Friendbot URL for informational purposes.
echo "Using $NETWORK network"
echo " RPC URL: $SOROBAN_RPC_URL"
echo " Friendbot URL: $FRIENDBOT_URL"
Configure Soroban Client:
The script configures the Soroban client to connect to the chosen network.
echo Add the $NETWORK network to cli client
soroban config network add \
--rpc-url "$SOROBAN_RPC_URL" \
--network-passphrase "$SOROBAN_NETWORK_PASSPHRASE" "$NETWORK"
Creation of a new wallet (identity):
This section creates an identity and an associated address if it doesn't already exist, and retrieves the admin's address.
if !(soroban config identity ls | grep token-admin 2>&1 >/dev/null); then
# ...
fi
ADMIN_ADDRESS="$(soroban config identity address token-admin)"
Fund Admin Account:
It uses the Friendbot service to fund the admin's account.
echo Fund token-admin account from friendbot
curl --silent -X POST "$FRIENDBOT_URL?addr=$ADMIN_ADDRESS" >/dev/null
Build Contracts:
This block sets up arguments and deploy all contracts and saves their contract IDs.
ARGS="--network $NETWORK --source token-admin"
echo Build contracts
make build
echo Deploy the BTC TOKEN contract
BTC_TOKEN_ID="$(
soroban contract deploy $ARGS \
--wasm target/wasm32-unknown-unknown/release/btc_token.wasm
)"
echo "Contract deployed succesfully with ID: $BTC_TOKEN_ID"
echo -n "$BTC_TOKEN_ID" > .soroban-example-dapp/btc_token_id
echo Deploy the DONATION contract
DONATION_ID="$(
soroban contract deploy $ARGS \
--wasm target/wasm32-unknown-unknown/release/donation_contract.wasm
)"
echo "Contract deployed succesfully with ID: $DONATION_ID"
echo -n "$DONATION_ID" > .soroban-example-dapp/donation_id
echo Deploy the ORACLE contract
ORACLE_ID="$(
soroban contract deploy $ARGS \
--wasm target/wasm32-unknown-unknown/release/soroban_oracle_contract.wasm
)"
echo "Contract deployed succesfully with ID: $ORACLE_ID"
echo "$ORACLE_ID" > .soroban-example-dapp/oracle_id
Initialize the BTC Token, Donation and Oracle contracts:
This initializes the deployed contracts using the initialize method.
echo "Initialize the BTC TOKEN contract"
soroban contract invoke \
$ARGS \
--id "$BTC_TOKEN_ID" \
-- \
initialize \
--symbol BTC \
--decimal 8 \
--name Bitcoin \
--admin "$ADMIN_ADDRESS"
echo "Done"
echo "Initialize the DONATION contract"
soroban contract invoke \
$ARGS \
--id "$DONATION_ID" \
-- \
initialize \
--recipient RECIPIENT_WALLET_ADDRESS \
--token "$BTC_TOKEN_ID"
echo "Done"
echo "Initialize the ORACLE contract"
soroban contract invoke \
$ARGS \
--id "$ORACLE_ID" \
-- \
initialize \
--caller "$ADMIN_ADDRESS" \
--pair_name BTC_USDT \
--epoch_interval 600 \
--relayer RELAYER_WALLET_ADDRESS
echo "Done"
Correction of errors in typescript binding files
The npm run setup
command from the previous steps also executed a script that creates typescript binding files for the smart contract.
Soroban-tooling is still in development, and the team is working to improve generated bindings that may not fully integrate with some frontends at this time.
In this project we will fix this by following these steps:
- Go to:
.soroban/oracle-contract/dist/esm/
; - Open
index.js
file; - Find all
export async function
and in each of them replace this part:
parseResultXdr: (xdr) => {
THIS_ROW_NEEDS_TO_BE_REPLACED
}
with this one:
parseResultXdr: (xdr) => {
return scValStrToJs(xdr);
}
Run the app frontend:
- Run
npm run dev
- This command will start the app frontend and make it accessible on port 3000 by default
- Open your web browser and navigate to the address where the app is running (e.g., http://localhost:3000).
- Start using the dApp;
Top comments (1)
Hey, great article !!