After building a Hello World program, the next step is managing real state on-chain. A price oracle is a great exercise for this: it needs to create an account, store data, and enforce that only the owner can update it.
This is where Anchor starts showing its strengths. Account validation, ownership checks, and state management — all handled through Rust macros and compile-time constraints.
What’s an Oracle?
In blockchain, an oracle is a bridge between on-chain and off-chain data. Smart contracts can’t call external APIs, so oracles push external data (prices, weather, scores) on-chain where programs can read it.
Production oracles like Pyth Network are complex distributed systems. This exercise builds a simplified version: one owner, one price, basic access control. But it teaches the core patterns.
Program Architecture
Unlike Hello World’s single file, this program is organized into modules:
src/
├── lib.rs # Entry point
├── errors.rs # Custom error codes
├── state/
│ └── oracle.rs # Account structure
└── instructions/
├── initialize.rs # Create oracle
└── update.rs # Update price
This structure scales. As programs grow, keeping state definitions separate from instruction logic keeps things manageable.
The Oracle Account
#[account]
#[derive(InitSpace)]
pub struct Oracle {
pub owner: Pubkey, // 32 bytes
pub price: u64, // 8 bytes
}
Two fields: who owns this oracle, and what’s the current price. The #[derive(InitSpace)] macro calculates the storage needed automatically — no manual byte counting.
In Rails terms, this is your migration and model combined. But instead of rows in a database, each oracle is its own account on-chain with its own address.
Initialize: Creating the Oracle
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let oracle = &mut ctx.accounts.oracle;
oracle.owner = ctx.accounts.owner.key();
oracle.price = 0;
Ok(())
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = owner,
space = 8 + Oracle::INIT_SPACE
)]
pub oracle: Account<'info, Oracle>,
#[account(mut)]
pub owner: Signer<'info>,
pub system_program: Program<'info, System>,
}
The init constraint tells Anchor to create the account. payer = owner means the caller pays the rent (Solana charges rent for storing data on-chain). space = 8 + Oracle::INIT_SPACE allocates 8 bytes for Anchor’s discriminator plus the struct size.
The Signer<'info> type is important — Anchor cryptographically verifies that this account actually signed the transaction. No impersonation possible.
Update: Access Control
pub fn update(ctx: Context<Update>, price: u64) -> Result<()> {
require_keys_eq!(
ctx.accounts.oracle.owner,
ctx.accounts.owner.key(),
OracleError::Unauthorized
);
ctx.accounts.oracle.price = price;
Ok(())
}
require_keys_eq! checks that the signer matches the stored owner. If someone else tries to update the price, they get an Unauthorized error. This is the Solana equivalent of a before_action :authenticate! in Rails — except it’s enforced cryptographically, not through sessions.
Custom Errors
#[error_code]
pub enum OracleError {
#[msg("You are not authorized to update this oracle")]
Unauthorized,
}
Anchor turns these into proper error codes that clients can parse. The #[msg] attribute provides human-readable messages for debugging.
Testing the Access Control
The most interesting test is the unauthorized access attempt:
it("Fails when non-owner tries to update", async () => {
const fakeOwner = Keypair.generate();
try {
await program.methods
.update(new anchor.BN(99999))
.accounts({
oracle: oracleKeypair.publicKey,
owner: fakeOwner.publicKey,
})
.signers([fakeOwner])
.rpc();
expect.fail("Should have thrown an error");
} catch (err) {
expect(err.message).to.include("Unauthorized");
}
});
A random keypair tries to update the oracle and gets rejected. The access control works at the protocol level — no middleware, no JWT tokens, just cryptographic signatures.
Key Takeaways
Ownership is fundamental. Almost every Solana program needs some form of “who can modify this data?” The pattern of storing an owner pubkey and validating the signer against it is used everywhere.
Space must be pre-allocated. Unlike a database that grows dynamically, Solana accounts have a fixed size set at creation. You need to calculate exactly how many bytes your data needs. Anchor’s InitSpace derive macro helps, but you still need to think about it.
Module organization matters early. Even a two-instruction program benefits from separating state, errors, and instructions into their own files. Programs grow fast once you start adding features.
What’s Next
The oracle handles ownership and state. The next exercise adds time-based logic: a piggy bank that locks SOL until a future date. That introduces CPI (cross-program invocations), the Clock sysvar, and direct lamport manipulation.
The code is on GitHub: anchor-price-oracle