DEV Community

Cover image for Euro Guesser: A Stellar Oracle Tutorial Using Reflector
Matej Plavevski
Matej Plavevski

Posted on

Euro Guesser: A Stellar Oracle Tutorial Using Reflector

Our Main Tool: The Reflector Oracle

Reflector is the go-to Stellar oracle. It provides reliable, tamper-proof oracle price feeds to smart contracts written in Soroban, Stellar's Rust-based smart contract language, by aggregating information from multiple on- and off-chain data sources. Reflector is used by leading Stellar ecosystem projects including Blend.

Reflector currently supports:

  • Real-time cryptocurrency prices across multiple blockchains
  • Price feeds for assets traded on Stellar Mainnet
  • Exchange rates for fiat currencies

What We're Building Today

We're going to build something fun - a Stellar smart contract that lets users bet on whether the Euro (EUR) will go up or down over a 5-minute window. It's like a mini prediction market! The contract will tap into Reflector to get real-time EUR price data, making it actually useful instead of just guessing randomly.

By the time you're done, you'll have:

  • Built and deployed your very own Stellar smart contract on testnet
  • Connected to Reflector as your data oracle (fancy!)
  • Created functions that let users make predictions and check if they were right

The contract will have two main functions that do all the heavy lifting:

  • make_guess: Users put down some XLM and make their prediction, while the contract grabs current price data from Reflector
  • verify_guess: Checks if predictions were correct by comparing prices at two different times, and rewards the winners with double their bet

This is a perfect example of how oracles can supercharge your smart contracts with real-world information, opening doors to DeFi apps, prediction markets, and dynamic NFTs that actually respond to what's happening in the world.

Requirements

  • Stellar CLI (Setup Instructions)
  • Funded Stellar account on Testnet
  • A warm cup of hot chocolate (totally optional but highly recommended ☕)

⚠️ Important Disclaimer: The following tutorial has not been audited by a pentester. You have the authors permission to audit it. Please use any of the published code with caution, and nobody is responsible for any loss of funds.

Step 1: Initialize a New Contract

First things first - let's create our project structure:

stellar contract init euro_guesser
cd euro_guesser
Enter fullscreen mode Exit fullscreen mode

We'll be doing all our coding magic in the lib.rs file. Feel free to delete all those default comments and focus on the impl Contract code block - that's where the fun happens!

Step 2: Download the Necessary Contract Interface

Time to head over to the Reflector documentation and find the "Use Public Feed" section. Make sure you select Testnet as your network and Foreign Exchange Rates as your data source - we want those sweet EUR prices!

Scroll down and copy the interface code from Reflector and save it as reflector.rs. This file will simplify interactions with the oracle.

Step 3: Connect to Reflector

At the top of your lib.rs file, import the Reflector interface:

mod reflector;
use crate::reflector::{ReflectorClient, Asset as ReflectorAsset};
Enter fullscreen mode Exit fullscreen mode

Now let's start building our make_guess function. It needs three things from users:

  • Their address (so we know who's betting)
  • A boolean for their prediction (true = EUR goes up, false = EUR goes down)
  • How much they want to bet

Let's begin with some basic validation:

pub fn make_guess(env: Env, user: Address, will_rise: bool, amount: i128) -> Result<bool, Error> {
    user.require_auth();

    if amount <= 0 {
        return Err(Error::LowBalance);
    }
Enter fullscreen mode Exit fullscreen mode

Step 4: Interact with the Oracle

Here's where things get interesting! To chat with an oracle, we first need to connect to it using its contract address. You can find all the available addresses in the /Oracles tab on the ~Reflector website~.

// 1. Fetch Price
// Oracles on-chain are Smart Contracts, therefore we interface them via a contract address.
// Here we convert the address from String to an Address object
// Each Dataset has it's own smart contract address, in our case, for testnet it's the following address
let oracle_address = Address::from_str(&env, "CCYOZJCOPG34LLQQ7N24YXBM7LL62R7ONMZ3G6WZAAYPB5OYKOMJRN63");
// Create client for working with oracle
let reflector_client = ReflectorClient::new(&env, &oracle_address);
Enter fullscreen mode Exit fullscreen mode

Reflector supports two asset types:

  • Stellar: Assets that live on the Stellar Network
  • Other: External assets like USD, EUR, GBP Since EUR is not a Stellar asset, we create a Symbol with the currency name:
// Set Asset
let ticker = ReflectorAsset::Other(Symbol::new(&env, &("EUR")));

// Fetch the most recent price record for it
let recent = reflector_client.lastprice(&ticker);

// Check the result
if recent.is_none() {
      return Err(Error::LimitReached)
}

// Retrieve the price itself
let price = recent.unwrap().price;
Enter fullscreen mode Exit fullscreen mode

The lastprice function returns an Option containing a struct with:

  • Price: Latest price as an i128 value
  • Timestamp: When the data was fetched as a u64 value

Using Option lets us gracefully handle cases where Reflector might not have data available.

Quick Detour: Error Handling in Soroban

You probably noticed our function returns Result - this is Rust's way of saying "I'll either give you a boolean if everything goes well, or an error if something goes wrong."

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
    NoPrice = 1,
    LowBalance = 2,
    Broke = 3,
    NoGuesses = 4,
    TimeNotPassed = 5,
    WrongAnswer = 6,
}
Enter fullscreen mode Exit fullscreen mode

In Soroban, errors are just u32 enums - each error type gets a simple integer. When we write return Err(Error::NoPrice), we're telling the user exactly what went wrong.
A few things to remember when creating error enums:

  • Must have the #[repr(u32)] attribute
  • Must have the #[derive(Copy)] attribute
  • Every error needs an explicit integer value

⠀Our contract has these error scenarios:

  • Oracle didn't return a price
  • User doesn't have enough balance
  • Contract is out of liquidity
  • User trying to verify but hasn't made any guesses
  • 5 minutes haven't passed yet
  • User's prediction was wrong

Getting Cozy with Soroban Storage:

Soroban is pretty cool when it comes to storing data. It handles simple stuff like integers and strings, but it also lets you store complex objects that you define as structs. One key thing to remember is that Soroban uses key-value storage - think of it like a giant dictionary where you need a key to find your value.

For simple storage, you start by creating a Symbol:

const NUMBER: Symbol = symbol_short!("NUMBER");

Then storing data is straightforward:

env.storage()
    .instance()
    .set(&COUNTER, 4); // Chosen by Fair Dice Roll
                       // Guaranteed to be Random
                       // https://xkcd.com/221/
Enter fullscreen mode Exit fullscreen mode

You probably noticed that instance() function - let's talk about Soroban's three storage personalities:

The Three Types of Storage

Soroban gives you three different storage options, each with its own personality:

  • Temporary Storage - The cheapest option that's perfect for short-term data. You can use unlimited keys, but here's the catch: when temporary storage expires, your data is gone forever. No take-backs, no "oops I need that back" - it's deleted permanently.
  • Instance Storage - This is more expensive than temporary but has a safety net. When instance storage expires, your data gets "archived" instead of deleted. Think of it like putting something in a storage unit - it's not immediately accessible, but you can get it back if you need it. Instance storage is limited by entry size, so you can't go completely crazy with the amount of data.
  • Persistent Storage - The most expensive option, but also the most reliable. Like instance storage, when persistent storage expires, it gets archived rather than deleted. The big advantage? Unlimited keys, so you can store as much as you want (budget permitting).

Time to Live (TTL) - The Ticking Clock

Here's where things get interesting. Every piece of data in Soroban has something called a Time to Live (TTL) that needs to be periodically extended - kind of like renewing a library book. When a TTL hits zero:

  • Temporary storage: Your data vanishes into the digital void
  • Instance and Persistent storage: Your data gets moved to off-chain archived storage

When data is archived, your Stellar smart contract can't access it anymore, but it's not gone forever. You can bring it back using the RestoreFootprintOp operation - think of it as retrieving something from that storage unit we mentioned earlier.

How It All Works

Every storage type works like a completely separate map (or dictionary) from arbitrary keys to arbitrary values. The cool thing is that these maps are totally independent - you could store different data for the same key in each storage type. For example:

  • In temporary storage: key number might have a value of 543
  • In persistent storage: the same number key could have a completely different value like 412

Here's how you'd retrieve data:

let mut number: u32 = env
    .storage()
    .instance()
    .get(&NUMBER)
    .unwrap_or(0); // If no value set, assume 0.
Enter fullscreen mode Exit fullscreen mode

All three storage types work almost identically, with the main differences being cost, what happens when TTL expires, and limits on the number of keys. Choose based on your needs: temporary for short-term data you don't mind losing, instance for moderate amounts of important data, and persistent for large amounts of critical data.

Storing Complex Data with Structs

One of Soroban's superpowers is that you're not limited to storing simple numbers or strings. You can create custom data structures using Rust structs and store those directly! This is incredibly useful when you need to keep track of multiple related pieces of information together.
To make a struct work with Soroban storage, you need to add some special attributes:

#[contracttype]
#[derive(Clone)]
pub struct Guess {
    pub user: Address,
    pub will_rise: bool,
    pub amount: i128,
    pub time: u64,
}
Enter fullscreen mode Exit fullscreen mode

The #[contracttype] attribute tells Soroban "hey, this struct can be stored on the blockchain," while#[derive(Clone)] gives the struct the ability to be copied when needed.

In our euro guesser example, each Guess struct bundles together everything we need to know about a user's prediction:

  • user: Who made the guess
  • will_rise: Whether they think EUR will go up or down
  • amount: How much XLM they're betting
  • time: When they made the guess (crucial for our 5-minute verification)

⠀You can then store and retrieve these structs just like any other data:

// Store a guess
env.storage().instance().set(&user, &guess);

// Retrieve it later
let stored_guess: Guess = env.storage().instance().get(&user).unwrap();
Enter fullscreen mode Exit fullscreen mode

Step 5: Complete the Guess Function

Here's the remaining logic for storing data and transferring XLM:

// 2. Check Users Balance
let native_asset_address = Address::from_str(&env, "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC");
let native_client = token::Client::new(&env, &native_asset_address);
let balance = native_client.balance(&user);

if amount > balance {
    return Err(Error::LowBalance);
}


// 3. Create the guess
let guess = Guess {
    user: user.clone(),
    will_rise,
    amount,
    time: env.ledger().timestamp(),
};


// 4. Store user's guesses in a Vec, one can do multiple guesses why not
let mut user_guesses: Vec<Guess> = env.storage().instance().get(&user).unwrap_or(vec![&env]);
user_guesses.push_back(guess);
env.storage().instance().set(&user, &user_guesses);


// 5. Transfer tokens
native_client.transfer(&user, &env.current_contract_address(), &amount);
Ok(true)
Enter fullscreen mode Exit fullscreen mode

Interacting with Smart Contracts & Performing Payments

Now that we've got the basic structure down, let's dive into one of the most important aspects of our contract - handling payments and user authentication. Since our euro guesser involves real money (XLM), we need to understand how funds move around safely in Soroban.

You probably noticed this line at the beginning of our function:

user.require_auth();
Enter fullscreen mode Exit fullscreen mode

This simple line is doing some serious heavy lifting behind the scenes. It's Soroban's way of saying "prove you own this account before we let you spend money." When this function runs, it checks that the transaction was properly signed by the user's private key. No signature = no dice!
Think of it like requiring a PIN at an ATM - we need proof that you're really you before we let you touch your money.

Working with Token Contracts

Here's something cool about Stellar/Soroban - everything is a contract, even XLM (the native Stellar token) is handled through a contract interface. To work with any token, we need to:

  1. Connect to the token contract
  2. Check balances
  3. Transfer funds

Here's how it works for XLM:

// Connect to the native XLM token contract
let native_asset_address = Address::from_str(&env, "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC");
let native_client = token::Client::new(&env, &native_asset_address);
Enter fullscreen mode Exit fullscreen mode

That long address string is the contract address for XLM on the Stellar network on testnet. Every token has its own contract address - think of it as XLM's "home address" on the blockchain.

Checking Balances - Making Sure Users Have Enough to Play

Before accepting any bets, we should probably check if users actually have the XLM they're trying to bet:

let balance = native_client.balance(&user);

if amount > balance {
    return Err(Error::LowBalance);
}
Enter fullscreen mode Exit fullscreen mode

This prevents the super awkward situation where someone tries to bet 1000 XLM when they only have 5 XLM in their account. The balance() function queries the token contract to get the user's current balance.

Moving Money Around - The Transfer

Once we've verified everything checks out (user is authenticated, has enough balance), we can actually move their XLM to our contract:

native_client.transfer(&user, &env.current_contract_address(), &amount);
Enter fullscreen mode Exit fullscreen mode

The Complete Payment Flow
Putting it all together, here's what happens when someone makes a bet:

  1. Authentication: user.require_auth() - prove you own the account
  2. Balance Check: Make sure they have enough XLM
  3. Transfer**: Move their XLM to our contract for safekeeping
  4. Record: Store their guess for later verification

⠀This pattern of auth → validate → transfer is super common in DeFi applications, so understanding it well will serve you in building all kinds of financial smart contracts.

Step 6: Verify Guesses with Historical Data

Here's where we get sneaky to prevent cheating! We don't want users waiting around for favorable price movements before claiming their winnings. Instead, we use Reflector's historical data feature to check prices at specific timestamps:

// Fetch the price 5 minutes after the time stored, prevents an attack where one could wait for the price to actually increase when voting
// Fetch the data from exactly the timestamp. Since we know the time, we do not have to story an entry and pay fees
let newPrice = reflector_client.price(&ticker, &(&guess_data.time + 300));
let priceBefore = reflector_client.price(&ticker, &guess_data.time);
Enter fullscreen mode Exit fullscreen mode

This approach ensures fair verification by comparing prices exactly 5 minutes apart. No waiting for the perfect moment to cash in!

You Did It! 🎉

Congratulations! You've successfully connected real-world data to your Stellar smart contract using Reflector. This opens up a whole universe of possibilities for building sophisticated dapps, prediction markets, and other data-driven smart contracts that actually know what's happening in the real world.

View the complete contract code on SoroPg: contract.rs

Now go build something amazing! 🚀

Top comments (0)