✔ 📖 Understand what an Oracle is and why it matters
✔ 🛠️ Build your own decentralized price Oracle smart contract
✔ 🚀 Deploy it locally with Anvil and test it with a real frontend
✔ 🔗 Run Oracle nodes that fetch real crypto prices from CoinGecko
An Oracle is a bridge between the blockchain and the outside world. Smart contracts are powerful, but they have one fundamental limitation: they cannot access external data on their own.
Think about it:
- How can a DeFi protocol know the current price of ETH?
- How can an insurance smart contract know if a flight was delayed?
- How can a betting contract know who won a sports match?
Oracles solve this problem by bringing off-chain data on-chain in a trustworthy way.
The challenge is: how do we trust the data?
If only one source provides the data, that source becomes a central point of failure. If it's hacked or malicious, the entire system is compromised.
Instead of trusting a single source, we use multiple independent nodes that:
- Each fetch data from external sources
- Submit their data to the smart contract
- The contract aggregates the submissions (average, median, etc.)
- Only publishes the result when a quorum is reached
┌─────────────────────────────────────────────────────────────────────┐
│ ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Node 1 │ │ Node 2 │ │ Node 3 │ ... │
│ │ (Go App) │ │ (Go App) │ │ (Go App) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ │ CoinGecko │ │ │
│ │ API │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ORACLE CONTRACT │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ submitPrice │ │ getQuorum │ │currentPrices│ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ When quorum reached: │ │
│ │ emit PriceUpdated(coin, avgPrice, roundId) │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ FRONTEND │ │
│ │ (Next.js App) │ │
│ │ │ │
│ │ Listens for │ │
│ │ PriceUpdated │ │
│ │ events │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
| Oracle | Use Case |
|---|---|
| Chainlink | DeFi price feeds, VRF (randomness), Any API |
| Pyth Network | High-frequency trading data |
| Band Protocol | Cross-chain data oracle |
| API3 | First-party oracles |
Our workshop will help you understand how these systems work under the hood!
Please refer to the SETUP.md file to install Foundry.
If you need a Solidity refresher, check out Solidity.md.
Navigate to the oracle folder:
cd oracle📂 Your project structure should look like:
7.create_an_oracle/
├── oracle/
│ ├── foundry.toml
│ ├── lib/
│ │ └── forge-std/
│ ├── script/ # Deployment script will be copied here
│ ├── src/
│ │ └── Oracle.sol # Your implementation (start here!)
│ └── test/ # Test files will be copied here
└── utils/
├── Oracle.script.sol # Deployment script to copy
└── tests/ # Test files to copy step by step
├── Oracle.Step1.t.sol
├── Oracle.Step2.t.sol
└── Oracle.Step3.t.sol
💡 You'll create the
Oracle.solfile from scratch and implement it step by step!
❓ If you're stuck or have questions, ask the workshop supervisor.
In this first step, you will create the foundation of your Oracle contract. This includes:
- The basic contract structure
- State variables to track nodes and prices
- Functions for nodes to register and unregister themselves
- A dynamic quorum calculation system
Open src/Oracle.sol and create a basic Solidity contract named Oracle.
💡 Don't forget the SPDX license identifier and the pragma statement for Solidity version
^0.8.13.
Your contract needs to store important information. Add the following public state variables in this exact order:
| Variable | Type | Purpose |
|---|---|---|
owner |
address |
Stores who deployed the contract |
nodes |
address[] |
A dynamic array containing all registered node addresses |
isNode |
mapping(address => bool) |
Quick lookup to check if an address is a registered node |
currentPrices |
mapping(string => uint256) |
Stores the finalized price for each coin (key = coin name like "ethereum") |
📚 Learn about: State Variables, Mappings, Arrays
Add a constructor that initializes the owner variable to the address that deploys the contract.
💡 The deployer's address is available via
msg.sender.
The quorum determines how many nodes must submit prices before a price is finalized. This ensures decentralization.
Create a function with these specifications:
| Function | getQuorum |
|---|---|
| Visibility | public view |
| Parameters | none |
| Returns | uint256 |
Logic to implement:
- If there are fewer than 3 registered nodes, always return
3(minimum security threshold) - Otherwise, calculate 66% of the total nodes using ceiling division
- Formula hint:
(nodeCount * 2 + 2) / 3gives you the ceiling of 2/3
Create a function that allows anyone to register as an Oracle node.
| Function | addNode |
|---|---|
| Visibility | public |
| Parameters | none |
| Returns | nothing |
What it should do:
- Verify the caller is not already a node (revert with
"Node already exists"if they are) - Mark the caller as a node in the
isNodemapping - Add the caller's address to the
nodesarray
📚 Learn about: Require Statements
Create a function that allows nodes to unregister themselves.
| Function | removeNode |
|---|---|
| Visibility | public |
| Parameters | none |
| Returns | nothing |
What it should do:
- Verify the caller IS a registered node (revert with
"Node does not exist"if not) - Remove the node from the
isNodemapping - Remove the caller's address from the
nodesarray
💡 Tip: Removing from an array in Solidity is tricky. The efficient pattern is:
- Find the element's index
- Swap it with the last element
- Pop the last element This avoids shifting all elements, saving gas!
Copy the test file to your test folder:
cp ../utils/tests/Oracle.Step1.t.sol test/Oracle.Step1.t.solRun the tests:
forge test --match-contract OracleStep1Test -vvvAll 12 tests should pass! ✅
An oracle needs to organize price submissions into rounds. Each round:
- Collects submissions from multiple nodes
- Tracks how many nodes have submitted
- Records when the price was last updated
This prevents nodes from voting multiple times and organizes the consensus process.
Create a struct named Round with three uint256 fields:
id: The current round number (starts at 0)totalSubmissionCount: How many nodes have submitted in this roundlastUpdatedAt: Timestamp of the last price finalization
📚 Learn about: Structs
Add three new mappings to track rounds and submissions. Important: Add these variables after isNode but before currentPrices to maintain proper storage layout.
| Variable | Type | Purpose |
|---|---|---|
rounds |
mapping(string => Round) |
Stores round info for each coin |
nodePrices |
mapping(string => mapping(uint256 => mapping(address => uint256))) |
Nested mapping: coin → roundId → nodeAddress → submittedPrice |
hasSubmitted |
mapping(string => mapping(uint256 => mapping(address => bool))) |
Nested mapping: coin → roundId → nodeAddress → hasVoted |
💡 The nested mappings allow us to track which node submitted what price for which coin in which round.
📚 Learn about: Nested Mappings
Events allow external applications (like our frontend) to be notified when something happens on-chain.
Declare an event named PriceUpdated with:
coin(string, indexed): The cryptocurrency nameprice(uint256): The finalized priceroundId(uint256): Which round this price was finalized in
💡 The
indexedkeyword allows filtering events by that parameter.
📚 Learn about: Events
Copy the test file to your test folder:
cp ../utils/tests/Oracle.Step2.t.sol test/Oracle.Step2.t.solRun the tests:
forge test --match-contract OracleStep2Test -vvvAll 6 tests should pass! ✅
This is the core logic of the Oracle! Nodes submit prices, and when enough nodes agree (quorum is reached), the contract:
- Calculates the average price from all submissions
- Emits an event to notify listeners
- Moves to the next round
Create the main function that nodes call to submit their price data.
| Function | submitPrice |
|---|---|
| Visibility | public |
| Parameters | string memory coin, uint256 price |
| Returns | nothing |
Logic to implement (in order):
-
Access Control: Verify the caller is a registered node. If not, revert with message
"Not a node". -
Get Round Info: Retrieve the current round ID for this coin from your rounds mapping.
-
Prevent Double Voting: Check if this node has already submitted for this coin in this round. If yes, revert with message
"Already submitted for this round". -
Store the Price: Save the submitted price in the
nodePricesmapping using the coin, current round, and sender's address as keys. -
Mark as Submitted: Update the
hasSubmittedmapping to prevent this node from voting again this round. -
Increment Counter: Increase the
totalSubmissionCountfor this coin's current round. -
Check Quorum: If the submission count is now greater than or equal to
getQuorum(), call the internal_finalizePricefunction.
Create an internal function that calculates the average price and finalizes the round.
| Function | _finalizePrice |
|---|---|
| Visibility | internal |
| Parameters | string memory coin, uint256 roundId |
| Returns | nothing |
Logic to implement:
-
Initialize Counters: Create two local variables to track the total price sum and the count of valid submissions.
-
Aggregate Prices: Loop through all nodes in the
nodesarray:- For each node, check if they submitted for this coin/round
- If yes, add their submitted price to your running total
- Increment your valid submission counter
-
Calculate & Store Average: If at least one valid submission exists:
- Calculate the average price (total divided by count)
- Store it in
currentPricesfor this coin
-
Emit Event: Emit the
PriceUpdatedevent with the coin name, calculated average, and round ID. -
Prepare Next Round:
- Increment the round ID in the rounds mapping
- Reset the submission count to 0
- Update
lastUpdatedAtto the current block timestamp
💡 Use
block.timestampto get the current time.
📚 Learn about: Loops, Block Properties
Copy the test file to your test folder:
cp ../utils/tests/Oracle.Step3.t.sol test/Oracle.Step3.t.solRun all tests:
forge test -vvvAll 30 tests should pass! 🎉
Congratulations! Your Oracle contract is complete!
Now that your contract is complete, let's deploy it locally using Anvil (Foundry's local Ethereum node) and interact with it!
Open a new terminal and start Anvil:
anvilYou should see output like:
Available Accounts
==================
(0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
(1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000 ETH)
...
Private Keys
==================
(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
...
Listening on 127.0.0.1:8545
⚠️ Keep this terminal open! Anvil needs to run continuously.
Before deploying, copy the deployment script to your project:
cp ../utils/Oracle.script.sol script/Oracle.solIn a new terminal, navigate to the oracle folder and deploy:
cd oracle
forge script script/Oracle.sol:OracleScript \
--rpc-url http://localhost:8545 \
--broadcast \
--private-key ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80You should see:
== Return ==
oracle: contract Oracle 0x5FbDB2315678afecb367f032d93F642f64180aa3
📝 Save this contract address! You'll need it for the frontend and nodes.
Test that your contract is working:
# Get the current quorum (should return 3 with 0 nodes)
cast call 0x5FbDB2315678afecb367f032d93F642f64180aa3 "getQuorum()" --rpc-url http://localhost:8545The frontend is a Next.js application that displays real-time prices from your Oracle. It listens for PriceUpdated events and updates automatically.
Navigate to the frontend folder:
cd ../frontendCreate a .env.local file:
echo "NEXT_PUBLIC_ORACLE_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3" > .env.local
⚠️ Replace the address if your deployed contract has a different address!
pnpm install
pnpm devOpen http://localhost:3000 in your browser.
You should see the Oracle dashboard, but prices will be $0.00 since no nodes have submitted prices yet.
🖥️ Your Oracle Dashboard is running!
The Go application runs multiple Oracle nodes that:
- Register themselves with the contract
- Fetch real prices from CoinGecko API
- Submit prices to the contract periodically
- When quorum is reached, the contract emits
PriceUpdatedand the frontend updates!
- Go to CoinGecko API
- Sign up for a free account
- Create a Demo API Key
- Copy your API key
Navigate to the Node folder:
cd ../NodeCreate a .env file:
cat > .env << EOF
RPC_URL=http://127.0.0.1:8545
CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3
COINGECKO_API_KEY=your_api_key_here
EOF
⚠️ Replaceyour_api_key_herewith your actual CoinGecko API key!
go mod downloadThe oracle_contract.go file is already generated. If you need to regenerate it:
# Install abigen
go install github.com/ethereum/go-ethereum/cmd/abigen@latest
# Generate bindings
abigen --abi ../oracle/out/Oracle.sol/Oracle.json --pkg main --out oracle_contract.gogo run .You should see output like:
========================================
Starting 4 Oracle Nodes (Sharing 3 API Keys)
========================================
[Node 0] Oracle Node initialized
[Node 0] Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
[Node 0] Contract: 0x5FbDB2315678afecb367f032d93F642f64180aa3
[Node 0] ⚠ Not registered. Requesting to join Oracle...
[Node 0] ✓ Successfully registered! Block: 2, Gas: 96547
[Node 0] Fetched ethereum: $3456.78
[Node 0] Submitting ethereum tx: 0x...
[Node 0] ✓ ethereum submitted! Block: 3, Gas: 89234
Go back to your browser at http://localhost:3000.
When the quorum (3 nodes by default) has submitted prices:
- The contract calculates the average price
- Emits a
PriceUpdatedevent - The frontend receives the event and updates the displayed price!
You should see toast notifications appearing when prices are updated.
┌─────────────────────────────────────────────────────────────────────────┐
│ COMPLETE FLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Node starts │
│ └─→ Checks if registered → If not, calls addNode() │
│ │
│ 2. Node fetches price from CoinGecko │
│ └─→ GET https://api.coingecko.com/api/v3/simple/price │
│ │
│ 3. Node submits price to contract │
│ └─→ submitPrice("ethereum", 345678000000) │
│ │
│ 4. Contract checks quorum │
│ └─→ With 4 nodes, quorum = 3 (66% of 4 rounded up) │
│ │
│ 5. When quorum reached │
│ └─→ Calculate average │
│ └─→ Emit PriceUpdated event │
│ └─→ Move to next round │
│ │
│ 6. Frontend receives event │
│ └─→ Update displayed price │
│ └─→ Show toast notification │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Congratulations! 🎉 You've successfully built a decentralized price Oracle that:
- ✅ Allows nodes to register and unregister
- ✅ Collects price submissions from multiple sources
- ✅ Uses a dynamic quorum system (66% consensus)
- ✅ Aggregates prices into a trusted average
- ✅ Emits events for real-time frontend updates
- Oracle Design Patterns - How to aggregate off-chain data on-chain
- Quorum Mechanisms - Ensuring decentralized consensus
- Event-Driven Architecture - Smart contract to frontend communication
Now that you understand the basics, here are some advanced topics to explore:
- 🔐 Access Control: Add admin functions to manage nodes
- 📈 Median vs Average: Implement median calculation for outlier resistance
- ⏱️ Stale Data Protection: Add checks for price freshness
- 💎 Economic Incentives: Require nodes to stake tokens
- 🔗 Chainlink Integration: Compare with Chainlink Price Feeds
- 🎲 VRF (Verifiable Random Function): Build a random number oracle
![]() Jules Lordet |
|---|
🚀 Don't hesitate to follow us on our different platforms, and give a star 🌟 to PoC's repositories.
