diff --git a/contracts/oracle-network/src/lib.rs b/contracts/oracle-network/src/lib.rs
index 669e7d1..3ca9424 100644
--- a/contracts/oracle-network/src/lib.rs
+++ b/contracts/oracle-network/src/lib.rs
@@ -1,26 +1,73 @@
#![no_std]
-//! oracle-network — multi-source oracle feed with explicit failure modes.
-//!
-//! Consumers must not treat missing, delayed, or inconsistent readings as
-//! current. Aggregation either returns a fresh consensus value or rejects.
+//! Oracle Network smart contract for decentralized feeds, staking, and governance-driven slashing.
-use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec};
+use soroban_sdk::{
+ contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, String,
+ Symbol, Vec,
+};
-/// A reading older than this many seconds is stale and cannot be current.
+/// Default minimum stake required to register an oracle node.
+pub const DEFAULT_MIN_STAKE: i128 = 100_000_000;
+
+/// Default baseline reputation score assigned to newly registered nodes.
+pub const INITIAL_REPUTATION: u32 = 1000;
+
+/// Reputation penalty applied when a node submits an outlier value.
+pub const OUTLIER_PENALTY: u32 = 100;
+
+/// Reputation penalty applied when a node is slashed by governance.
+pub const SLASH_PENALTY: u32 = 200;
+
+/// Maximum number of submissions tracked in history per feed.
+pub const MAX_SUBMISSION_HISTORY: u32 = 50;
+
+/// Legacy stale threshold in seconds for backward compatibility.
pub const STALE_THRESHOLD_SECS: u64 = 300;
-/// Minimum fresh sources required before a quote is accepted.
+
+/// Legacy minimum fresh sources required for backward compatibility.
pub const MIN_FRESH_SOURCES: u32 = 2;
-/// Maximum allowed deviation from the median, in basis points (5%).
+
+/// Legacy maximum deviation allowed in basis points.
pub const MAX_DEVIATION_BPS: i128 = 500;
#[contracttype]
-#[derive(Clone)]
-pub enum DataKey {
- Admin,
- Sources,
- Registered(Address),
- Reading(String, Address),
- FeedSources(String),
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct NodeMetadata {
+ pub address: Address,
+ pub stake: i128,
+ pub registered_at: u64,
+ pub reputation: u32,
+ pub is_active: bool,
+}
+
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FeedConfig {
+ pub feed_id: Symbol,
+ pub freshness_window: u64,
+ pub max_deviation_bps: u32,
+ pub min_submissions: u32,
+ pub registered_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Submission {
+ pub node: Address,
+ pub feed_id: Symbol,
+ pub value: i128,
+ pub timestamp: u64,
+ pub submitted_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AggregatedValue {
+ pub feed_id: Symbol,
+ pub value: i128,
+ pub timestamp: u64,
+ pub node_count: u32,
+ pub round: u64,
}
#[contracttype]
@@ -41,6 +88,25 @@ pub struct AggregatedQuote {
pub observed_at: u64,
}
+#[contracttype]
+#[derive(Clone)]
+pub enum DataKey {
+ Admin,
+ Governance,
+ MinStake,
+ Node(Address),
+ NodeList,
+ Feed(Symbol),
+ FeedList,
+ Submissions(Symbol),
+ Aggregated(Symbol),
+ RoundCount(Symbol),
+ Sources,
+ Registered(Address),
+ Reading(String, Address),
+ FeedSources(String),
+}
+
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
@@ -53,6 +119,14 @@ pub enum Error {
InsufficientSources = 6,
ConflictingSources = 7,
AlreadyRegistered = 8,
+ InsufficientStake = 9,
+ NodeNotRegistered = 10,
+ NodeInactive = 11,
+ FeedNotFound = 12,
+ FeedAlreadyExists = 13,
+ InvalidAmount = 14,
+ FutureTimestamp = 15,
+ InsufficientSubmissions = 16,
}
#[contract]
@@ -60,22 +134,379 @@ pub struct Contract;
#[contractimpl]
impl Contract {
+ /// Initializes the contract with an admin account and default stake requirements.
pub fn initialize(env: Env, admin: Address) -> Result<(), Error> {
if env.storage().instance().has(&DataKey::Admin) {
return Err(Error::AlreadyInitialized);
}
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &admin);
+ env.storage()
+ .instance()
+ .set(&DataKey::MinStake, &DEFAULT_MIN_STAKE);
+
let sources: Vec
= Vec::new(&env);
env.storage().instance().set(&DataKey::Sources, &sources);
+
+ let node_list: Vec = Vec::new(&env);
+ env.storage().instance().set(&DataKey::NodeList, &node_list);
+
+ let feed_list: Vec = Vec::new(&env);
+ env.storage().instance().set(&DataKey::FeedList, &feed_list);
+
+ Ok(())
+ }
+
+ /// Sets the governance contract address authorized to trigger slashing.
+ pub fn set_governance(env: Env, admin: Address, governance: Address) -> Result<(), Error> {
+ require_admin(&env, &admin)?;
+ env.storage()
+ .instance()
+ .set(&DataKey::Governance, &governance);
+ Ok(())
+ }
+
+ /// Retrieves the configured governance contract address.
+ pub fn get_governance(env: Env) -> Option {
+ env.storage().instance().get(&DataKey::Governance)
+ }
+
+ /// Configures the minimum stake required to register an active oracle node.
+ pub fn set_min_stake(env: Env, admin: Address, min_stake: i128) -> Result<(), Error> {
+ require_admin(&env, &admin)?;
+ if min_stake <= 0 {
+ return Err(Error::InvalidAmount);
+ }
+ env.storage().instance().set(&DataKey::MinStake, &min_stake);
+ Ok(())
+ }
+
+ /// Returns the current minimum stake threshold.
+ pub fn get_min_stake(env: Env) -> i128 {
+ env.storage()
+ .instance()
+ .get(&DataKey::MinStake)
+ .unwrap_or(DEFAULT_MIN_STAKE)
+ }
+
+ /// Registers an oracle node with deposited stake.
+ pub fn register_node(env: Env, caller: Address, stake: i128) -> Result<(), Error> {
+ caller.require_auth();
+
+ let min_stake = Self::get_min_stake(env.clone());
+ if stake < min_stake {
+ return Err(Error::InsufficientStake);
+ }
+
+ if let Some(existing) = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::Node(caller.clone()))
+ {
+ if existing.is_active {
+ return Err(Error::AlreadyRegistered);
+ }
+ }
+
+ let metadata = NodeMetadata {
+ address: caller.clone(),
+ stake,
+ registered_at: env.ledger().timestamp(),
+ reputation: INITIAL_REPUTATION,
+ is_active: true,
+ };
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::Node(caller.clone()), &metadata);
+
+ let mut node_list: Vec = env
+ .storage()
+ .instance()
+ .get(&DataKey::NodeList)
+ .unwrap_or_else(|| Vec::new(&env));
+
+ if !contains_address(&node_list, &caller) {
+ node_list.push_back(caller.clone());
+ env.storage().instance().set(&DataKey::NodeList, &node_list);
+ }
+
+ env.events()
+ .publish((symbol_short!("reg_node"), caller), stake);
+ Ok(())
+ }
+
+ /// Deregisters an active oracle node.
+ pub fn deregister_node(env: Env, caller: Address) -> Result<(), Error> {
+ caller.require_auth();
+
+ let mut metadata: NodeMetadata = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Node(caller.clone()))
+ .ok_or(Error::NodeNotRegistered)?;
+
+ if !metadata.is_active {
+ return Err(Error::NodeInactive);
+ }
+
+ metadata.is_active = false;
+ env.storage()
+ .persistent()
+ .set(&DataKey::Node(caller.clone()), &metadata);
+
+ env.events()
+ .publish((symbol_short!("dereg"), caller), 0i128);
Ok(())
}
+ /// Retrieves metadata for a registered oracle node.
+ pub fn get_node(env: Env, node: Address) -> Option {
+ env.storage().persistent().get(&DataKey::Node(node))
+ }
+
+ /// Registers a new data feed with specified freshness, deviation tolerance, and quorum.
+ pub fn register_feed(
+ env: Env,
+ admin: Address,
+ feed_id: Symbol,
+ freshness_window: u64,
+ max_deviation_bps: u32,
+ min_submissions: u32,
+ ) -> Result<(), Error> {
+ require_admin(&env, &admin)?;
+
+ if freshness_window == 0 || min_submissions == 0 {
+ return Err(Error::InvalidAmount);
+ }
+
+ if env
+ .storage()
+ .persistent()
+ .has(&DataKey::Feed(feed_id.clone()))
+ {
+ return Err(Error::FeedAlreadyExists);
+ }
+
+ let config = FeedConfig {
+ feed_id: feed_id.clone(),
+ freshness_window,
+ max_deviation_bps,
+ min_submissions,
+ registered_at: env.ledger().timestamp(),
+ };
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::Feed(feed_id.clone()), &config);
+
+ let mut feed_list: Vec = env
+ .storage()
+ .instance()
+ .get(&DataKey::FeedList)
+ .unwrap_or_else(|| Vec::new(&env));
+
+ if !contains_symbol(&feed_list, &feed_id) {
+ feed_list.push_back(feed_id.clone());
+ env.storage().instance().set(&DataKey::FeedList, &feed_list);
+ }
+
+ env.events()
+ .publish((symbol_short!("feed_reg"), feed_id), 0i128);
+ Ok(())
+ }
+
+ /// Retrieves configuration for a registered feed.
+ pub fn get_feed_config(env: Env, feed_id: Symbol) -> Option {
+ env.storage().persistent().get(&DataKey::Feed(feed_id))
+ }
+
+ /// Submits a data point from an authorized, staked oracle node.
+ pub fn submit_data(
+ env: Env,
+ caller: Address,
+ feed_id: Symbol,
+ value: i128,
+ timestamp: u64,
+ ) -> Result<(), Error> {
+ caller.require_auth();
+
+ let mut node_meta: NodeMetadata = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Node(caller.clone()))
+ .ok_or(Error::NodeNotRegistered)?;
+
+ if !node_meta.is_active {
+ return Err(Error::NodeInactive);
+ }
+
+ let min_stake = Self::get_min_stake(env.clone());
+ if node_meta.stake < min_stake {
+ return Err(Error::InsufficientStake);
+ }
+
+ let feed_config: FeedConfig = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Feed(feed_id.clone()))
+ .ok_or(Error::FeedNotFound)?;
+
+ let now = env.ledger().timestamp();
+ if timestamp > now.saturating_add(60) {
+ return Err(Error::FutureTimestamp);
+ }
+ if now.saturating_sub(timestamp) > feed_config.freshness_window {
+ return Err(Error::StaleData);
+ }
+
+ let submission = Submission {
+ node: caller.clone(),
+ feed_id: feed_id.clone(),
+ value,
+ timestamp,
+ submitted_at: now,
+ };
+
+ let mut submissions: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Submissions(feed_id.clone()))
+ .unwrap_or_else(|| Vec::new(&env));
+
+ submissions.push_back(submission);
+ if submissions.len() > MAX_SUBMISSION_HISTORY {
+ let overflow = submissions.len() - MAX_SUBMISSION_HISTORY;
+ let mut trimmed: Vec = Vec::new(&env);
+ let mut k = overflow;
+ while k < submissions.len() {
+ trimmed.push_back(submissions.get(k).unwrap());
+ k += 1;
+ }
+ submissions = trimmed;
+ }
+ env.storage()
+ .persistent()
+ .set(&DataKey::Submissions(feed_id.clone()), &submissions);
+
+ let mut fresh_values: Vec = Vec::new(&env);
+ let mut idx: u32 = 0;
+ while idx < submissions.len() {
+ let sub = submissions.get(idx).unwrap();
+ if now.saturating_sub(sub.timestamp) <= feed_config.freshness_window {
+ fresh_values.push_back(sub.value);
+ }
+ idx += 1;
+ }
+
+ if fresh_values.len() >= feed_config.min_submissions {
+ sort_i128(&mut fresh_values);
+ let median_value = compute_median(&fresh_values);
+
+ let delta = (value - median_value).abs();
+ let base_scale = median_value.abs();
+ let exceeds_threshold = if base_scale == 0 {
+ delta > 0
+ } else {
+ delta.saturating_mul(10_000) / base_scale > feed_config.max_deviation_bps as i128
+ };
+
+ if exceeds_threshold {
+ node_meta.reputation = node_meta.reputation.saturating_sub(OUTLIER_PENALTY);
+ env.storage()
+ .persistent()
+ .set(&DataKey::Node(caller.clone()), &node_meta);
+
+ env.events().publish(
+ (symbol_short!("repute"), caller.clone()),
+ node_meta.reputation as i128,
+ );
+ }
+
+ let mut round: u64 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::RoundCount(feed_id.clone()))
+ .unwrap_or(0);
+ round += 1;
+ env.storage()
+ .persistent()
+ .set(&DataKey::RoundCount(feed_id.clone()), &round);
+
+ let aggregated = AggregatedValue {
+ feed_id: feed_id.clone(),
+ value: median_value,
+ timestamp: now,
+ node_count: fresh_values.len(),
+ round,
+ };
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::Aggregated(feed_id.clone()), &aggregated);
+
+ env.events()
+ .publish((symbol_short!("aggregate"), feed_id.clone()), median_value);
+ }
+
+ env.events()
+ .publish((symbol_short!("submit"), feed_id, caller), value);
+ Ok(())
+ }
+
+ /// Retrieves the most recent aggregated value for a feed.
+ pub fn get_aggregated_value(env: Env, feed_id: Symbol) -> Option {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Aggregated(feed_id))
+ }
+
+ /// Slashes a node stake and penalizes reputation via admin or governance invocation.
+ pub fn slash_node(env: Env, admin: Address, node: Address, amount: i128) -> Result<(), Error> {
+ admin.require_auth();
+
+ let stored_admin: Option = env.storage().instance().get(&DataKey::Admin);
+ let stored_gov: Option = env.storage().instance().get(&DataKey::Governance);
+
+ let is_admin = stored_admin.as_ref() == Some(&admin);
+ let is_gov = stored_gov.as_ref() == Some(&admin);
+
+ if !is_admin && !is_gov {
+ return Err(Error::Unauthorized);
+ }
+
+ if amount <= 0 {
+ return Err(Error::InvalidAmount);
+ }
+
+ let mut node_meta: NodeMetadata = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Node(node.clone()))
+ .ok_or(Error::NodeNotRegistered)?;
+
+ node_meta.stake = node_meta.stake.saturating_sub(amount);
+ node_meta.reputation = node_meta.reputation.saturating_sub(SLASH_PENALTY);
+
+ let min_stake = Self::get_min_stake(env.clone());
+ if node_meta.stake < min_stake {
+ node_meta.is_active = false;
+ }
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::Node(node.clone()), &node_meta);
+
+ env.events().publish((symbol_short!("slash"), node), amount);
+ Ok(())
+ }
+
+ /// Returns the legacy stale threshold in seconds.
pub fn stale_threshold_secs(_env: Env) -> u64 {
STALE_THRESHOLD_SECS
}
- /// Register an oracle source. Admin only.
+ /// Registers a source in legacy reading registry. Admin only.
pub fn register_source(env: Env, caller: Address, source: Address) -> Result<(), Error> {
require_admin(&env, &caller)?;
if env
@@ -98,7 +529,7 @@ impl Contract {
Ok(())
}
- /// Store a source reading. Rejects unregistered sources.
+ /// Stores a source reading in legacy storage.
pub fn submit_reading(
env: Env,
source: Address,
@@ -139,15 +570,14 @@ impl Contract {
Ok(reading)
}
+ /// Retrieves a single reading from legacy storage.
pub fn get_reading(env: Env, feed_id: String, source: Address) -> Option {
env.storage()
.persistent()
.get(&DataKey::Reading(feed_id, source))
}
- /// Aggregate fresh, agreeing sources. Never treats stale data as current.
- ///
- /// Rejects when sources are missing, all stale, below quorum, or in conflict.
+ /// Computes consensus quote over legacy readings.
pub fn aggregate(env: Env, feed_id: String) -> Result {
let feed_sources: Vec = env
.storage()
@@ -244,6 +674,17 @@ fn contains_address(list: &Vec, target: &Address) -> bool {
false
}
+fn contains_symbol(list: &Vec, target: &Symbol) -> bool {
+ let mut i: u32 = 0;
+ while i < list.len() {
+ if list.get(i).unwrap() == *target {
+ return true;
+ }
+ i += 1;
+ }
+ false
+}
+
fn sort_i128(values: &mut Vec) {
let n = values.len();
let mut i: u32 = 0;
@@ -262,6 +703,17 @@ fn sort_i128(values: &mut Vec) {
}
}
+fn compute_median(sorted_values: &Vec) -> i128 {
+ let len = sorted_values.len();
+ if len % 2 == 1 {
+ sorted_values.get(len / 2).unwrap()
+ } else {
+ let mid1 = sorted_values.get(len / 2 - 1).unwrap();
+ let mid2 = sorted_values.get(len / 2).unwrap();
+ (mid1 + mid2) / 2
+ }
+}
+
fn has_conflict(sorted_values: &Vec, median: i128) -> bool {
let mut i: u32 = 0;
while i < sorted_values.len() {
@@ -360,7 +812,6 @@ mod test {
client.submit_reading(&a, &id, &100, &9_000);
client.submit_reading(&b, &id, &101, &9_000);
- // Age 10_000 - 9_000 = 1_000s > 300s stale threshold.
assert_eq!(client.try_aggregate(&id), Err(Ok(Error::StaleData)));
let stored = client.get_reading(&id, &a).unwrap();
assert_eq!(stored.value, 100);
@@ -375,9 +826,7 @@ mod test {
let id = feed(&env);
set_time(&env, 10_000);
- // Fresh
client.submit_reading(&a, &id, &100, &9_900);
- // Stale — must not count toward MIN_FRESH_SOURCES.
client.submit_reading(&b, &id, &100, &9_000);
assert_eq!(
@@ -439,7 +888,6 @@ mod test {
client.try_aggregate(&id),
Err(Ok(Error::ConflictingSources))
);
- // Storage is unchanged; neither value is promoted as current.
assert_eq!(client.get_reading(&id, &a).unwrap().value, 100);
assert_eq!(client.get_reading(&id, &b).unwrap().value, 200);
assert_eq!(
@@ -467,4 +915,142 @@ mod test {
Err(Ok(Error::ConflictingSources))
);
}
+
+ #[test]
+ fn test_node_registration_and_staking() {
+ let env = Env::default();
+ let (_, client) = setup(&env);
+ let node = Address::generate(&env);
+
+ let stake_err = client.try_register_node(&node, &50_000_000);
+ assert_eq!(stake_err, Err(Ok(Error::InsufficientStake)));
+
+ client.register_node(&node, &100_000_000);
+ let meta = client.get_node(&node).unwrap();
+ assert_eq!(meta.address, node);
+ assert_eq!(meta.stake, 100_000_000);
+ assert_eq!(meta.reputation, INITIAL_REPUTATION);
+ assert!(meta.is_active);
+
+ let dup_err = client.try_register_node(&node, &100_000_000);
+ assert_eq!(dup_err, Err(Ok(Error::AlreadyRegistered)));
+
+ client.deregister_node(&node);
+ let updated = client.get_node(&node).unwrap();
+ assert!(!updated.is_active);
+
+ let inactive_dereg = client.try_deregister_node(&node);
+ assert_eq!(inactive_dereg, Err(Ok(Error::NodeInactive)));
+ }
+
+ #[test]
+ fn test_feed_registration_and_config() {
+ let env = Env::default();
+ let (admin, client) = setup(&env);
+ let non_admin = Address::generate(&env);
+ let feed_sym = symbol_short!("credit_sc");
+
+ let auth_err = client.try_register_feed(&non_admin, &feed_sym, &120, &500, &3);
+ assert_eq!(auth_err, Err(Ok(Error::Unauthorized)));
+
+ client.register_feed(&admin, &feed_sym, &120, &500, &3);
+ let config = client.get_feed_config(&feed_sym).unwrap();
+ assert_eq!(config.feed_id, feed_sym);
+ assert_eq!(config.freshness_window, 120);
+ assert_eq!(config.max_deviation_bps, 500);
+ assert_eq!(config.min_submissions, 3);
+
+ let dup_err = client.try_register_feed(&admin, &feed_sym, &120, &500, &3);
+ assert_eq!(dup_err, Err(Ok(Error::FeedAlreadyExists)));
+ }
+
+ #[test]
+ fn test_adversarial_data_submission_and_median_aggregation() {
+ let env = Env::default();
+ let (admin, client) = setup(&env);
+ let feed_sym = symbol_short!("fraud_flg");
+
+ client.register_feed(&admin, &feed_sym, &300, &1000, &3);
+
+ let node1 = Address::generate(&env);
+ let node2 = Address::generate(&env);
+ let node3 = Address::generate(&env);
+ let adversary = Address::generate(&env);
+
+ client.register_node(&node1, &100_000_000);
+ client.register_node(&node2, &100_000_000);
+ client.register_node(&node3, &100_000_000);
+ client.register_node(&adversary, &100_000_000);
+
+ set_time(&env, 1000);
+
+ client.submit_data(&node1, &feed_sym, &500, &1000);
+ assert!(client.get_aggregated_value(&feed_sym).is_none());
+
+ client.submit_data(&node2, &feed_sym, &510, &1000);
+ assert!(client.get_aggregated_value(&feed_sym).is_none());
+
+ client.submit_data(&node3, &feed_sym, &505, &1000);
+ let agg = client.get_aggregated_value(&feed_sym).unwrap();
+ assert_eq!(agg.value, 505);
+ assert_eq!(agg.node_count, 3);
+ assert_eq!(agg.round, 1);
+
+ client.submit_data(&adversary, &feed_sym, &999_999, &1000);
+ let adv_meta = client.get_node(&adversary).unwrap();
+ assert_eq!(adv_meta.reputation, INITIAL_REPUTATION - OUTLIER_PENALTY);
+
+ let agg2 = client.get_aggregated_value(&feed_sym).unwrap();
+ assert_eq!(agg2.value, 507);
+ assert_eq!(agg2.node_count, 4);
+ }
+
+ #[test]
+ fn test_governance_slashing_and_deactivation() {
+ let env = Env::default();
+ let (admin, client) = setup(&env);
+ let governance = Address::generate(&env);
+ let node = Address::generate(&env);
+ let impostor = Address::generate(&env);
+
+ client.set_governance(&admin, &governance);
+ assert_eq!(client.get_governance(), Some(governance.clone()));
+
+ client.register_node(&node, &100_000_000);
+
+ let unauth = client.try_slash_node(&impostor, &node, &10_000_000);
+ assert_eq!(unauth, Err(Ok(Error::Unauthorized)));
+
+ client.slash_node(&governance, &node, &30_000_000);
+ let meta = client.get_node(&node).unwrap();
+ assert_eq!(meta.stake, 70_000_000);
+ assert_eq!(meta.reputation, INITIAL_REPUTATION - SLASH_PENALTY);
+ assert!(!meta.is_active);
+
+ let feed_sym = symbol_short!("credit_sc");
+ client.register_feed(&admin, &feed_sym, &300, &500, &1);
+ set_time(&env, 1000);
+
+ let sub_err = client.try_submit_data(&node, &feed_sym, &750, &1000);
+ assert_eq!(sub_err, Err(Ok(Error::NodeInactive)));
+ }
+
+ #[test]
+ fn test_stale_and_future_submissions_rejected() {
+ let env = Env::default();
+ let (admin, client) = setup(&env);
+ let node = Address::generate(&env);
+ let feed_sym = symbol_short!("price_usd");
+
+ client.register_node(&node, &100_000_000);
+ client.register_feed(&admin, &feed_sym, &60, &500, &1);
+
+ set_time(&env, 1000);
+
+ let future_err = client.try_submit_data(&node, &feed_sym, &100, &1100);
+ assert_eq!(future_err, Err(Ok(Error::FutureTimestamp)));
+
+ let stale_err = client.try_submit_data(&node, &feed_sym, &100, &900);
+ assert_eq!(stale_err, Err(Ok(Error::StaleData)));
+ }
}
diff --git a/docs/oracle-node.md b/docs/oracle-node.md
new file mode 100644
index 0000000..d79ec69
--- /dev/null
+++ b/docs/oracle-node.md
@@ -0,0 +1,262 @@
+# Oracle Node Operational Runbook
+
+## Overview
+
+The Chenaikit Oracle Node (`@chenaikit/oracle-node`) is a persistent daemon service that ingests verified off-chain signals (credit scores, fraud alerts, asset prices) and periodically submits signed observations to the on-chain Soroban `oracle-network` smart contract.
+
+The on-chain contract enforces stake-backed node registration, median aggregation across active quorums within configured freshness windows, and governance-driven slashing with reputation tracking.
+
+---
+
+## Architecture
+
+```
++--------------------------+ +---------------------------+
+| Data Sources | | Oracle Node Runtime |
+| | | |
+| - Backend API (/scores) | Poll | - Config Loader |
+| - Fraud Alerts Service | -------> | - Data Source Adapters |
+| - External Price Feeds | | - Transaction Submitter |
++--------------------------+ | - Health & Metrics Server |
+ +-------------+-------------+
+ |
+ Submit TX | (Signed with Node Key)
+ v
+ +-------------+-------------+
+ | Soroban Smart Contract |
+ | (contracts/oracle-network)
+ | |
+ | - Staked Node Registry |
+ | - Feed Quorum Config |
+ | - Median Aggregation |
+ | - Slashing / Reputation |
+ +---------------------------+
+```
+
+---
+
+## Configuration Reference
+
+The oracle node runtime is configured via environment variables.
+
+| Variable | Description | Default |
+|---|---|---|
+| `SOROBAN_RPC_URL` | Soroban RPC endpoint URL | `https://soroban-testnet.stellar.org` |
+| `STELLAR_NETWORK_PASSPHRASE` | Stellar network passphrase | `Test SDF Network ; September 2015` |
+| `ORACLE_CONTRACT_ID` | Address of deployed `oracle-network` contract | Required |
+| `ORACLE_NODE_ADDRESS` | Public key (`G...`) of the registered oracle node | Required |
+| `ORACLE_NODE_SECRET_KEY` | Secret seed (`S...`) used to sign transaction payloads | Required |
+| `BACKEND_API_URL` | Base URL of upstream backend service | `http://localhost:3000` |
+| `POLL_INTERVAL_MS` | Interval between data collection & submission passes | `15000` (15 seconds) |
+| `PORT` | Listening port for HTTP health and metrics server | `8080` |
+| `HOST` | Listening host interface | `0.0.0.0` |
+| `MAX_RETRIES` | Max retries on transient RPC failures | `3` |
+| `INITIAL_BACKOFF_MS` | Initial exponential backoff delay in ms | `500` |
+| `MAX_BACKOFF_MS` | Max exponential backoff delay ceiling in ms | `5000` |
+| `LOG_LEVEL` | Structured logging level (`debug`, `info`, `warn`, `error`) | `info` |
+
+---
+
+## Setup and Key Provisioning
+
+### 1. Generate Stellar Keypair
+
+Generate an Ed25519 keypair for the oracle node using the Stellar CLI:
+
+```bash
+stellar keys generate oracle-node-01 --network testnet
+stellar keys address oracle-node-01
+```
+
+Fund the account with testnet lumens via Friendbot:
+
+```bash
+curl "https://friendbot.stellar.org?addr=$(stellar keys address oracle-node-01)"
+```
+
+### 2. Stake and Register On-Chain
+
+Oracle nodes must deposit the minimum stake (default 10 XLM / `100_000_000` stroops) before submitting data.
+
+Call `register_node` on the contract:
+
+```bash
+stellar contract invoke \
+ --id \
+ --source-account oracle-node-01 \
+ --network testnet \
+ -- \
+ register_node \
+ --caller $(stellar keys address oracle-node-01) \
+ --stake 100000000
+```
+
+Verify registration status:
+
+```bash
+stellar contract invoke \
+ --id \
+ --source-account oracle-node-01 \
+ --network testnet \
+ -- \
+ get_node \
+ --node $(stellar keys address oracle-node-01)
+```
+
+---
+
+## Starting the Node
+
+### Build from Source
+
+```bash
+cd packages/oracle-node
+pnpm build
+```
+
+### Run the Daemon
+
+Using npm / pnpm scripts:
+
+```bash
+export ORACLE_CONTRACT_ID="CA..."
+export ORACLE_NODE_ADDRESS="GB..."
+export ORACLE_NODE_SECRET_KEY="SB..."
+pnpm start
+```
+
+Or run via the CLI binary directly:
+
+```bash
+npx oracle-node
+```
+
+The daemon outputs structured JSON logs:
+
+```json
+{"timestamp":"2026-09-08T15:20:00.000Z","level":"info","message":"Starting Oracle Node daemon","service":"@chenaikit/oracle-node","nodeAddress":"GB...","contractId":"CA...","pollIntervalMs":15000,"adapters":["backend-credit-score","backend-fraud-alert"]}
+{"timestamp":"2026-09-08T15:20:00.050Z","level":"info","message":"Oracle HTTP server listening","service":"@chenaikit/oracle-node","host":"0.0.0.0","port":8080}
+```
+
+---
+
+## Health and Monitoring
+
+### Health Endpoint
+
+Query `GET /health` to verify service availability:
+
+```bash
+curl -s http://localhost:8080/health | jq .
+```
+
+Response:
+
+```json
+{
+ "status": "healthy",
+ "service": "@chenaikit/oracle-node",
+ "version": "0.1.0",
+ "uptimeSeconds": 124,
+ "nodeAddress": "GB...",
+ "contractId": "CA...",
+ "registeredFeeds": ["credit_sc", "fraud_flg"]
+}
+```
+
+### Metrics Endpoint
+
+Query `GET /metrics` for JSON metrics:
+
+```bash
+curl -s http://localhost:8080/metrics | jq .
+```
+
+Response:
+
+```json
+{
+ "submissionsSucceeded": 28,
+ "submissionsFailed": 0,
+ "submissionsRejected": 0,
+ "lastSubmissionTimestamp": 1757344800,
+ "lastSubmissionRound": 9,
+ "uptimeSeconds": 124,
+ "activeFeedsCount": 2
+}
+```
+
+For Prometheus metric format, request with `Accept: text/plain`:
+
+```bash
+curl -s -H "Accept: text/plain" http://localhost:8080/metrics
+```
+
+Output:
+
+```text
+# HELP oracle_submissions_succeeded_total Total successful data submissions
+# TYPE oracle_submissions_succeeded_total counter
+oracle_submissions_succeeded_total 28
+# HELP oracle_submissions_failed_total Total failed data submissions
+# TYPE oracle_submissions_failed_total counter
+oracle_submissions_failed_total 0
+# HELP oracle_submissions_rejected_total Total submissions rejected by contract rules
+# TYPE oracle_submissions_rejected_total counter
+oracle_submissions_rejected_total 0
+# HELP oracle_uptime_seconds Process uptime in seconds
+# TYPE oracle_uptime_seconds gauge
+oracle_uptime_seconds 124
+```
+
+---
+
+## Verification on-Chain
+
+Verify aggregated values on-chain using Soroban CLI:
+
+```bash
+stellar contract invoke \
+ --id \
+ --source-account oracle-node-01 \
+ --network testnet \
+ -- \
+ get_aggregated_value \
+ --feed_id credit_sc
+```
+
+---
+
+## Slashing and Governance Operations
+
+The contract permits the contract admin or designated governance contract (`set_governance`) to execute disciplinary slashing when an oracle node misbehaves or submits corrupted data:
+
+```bash
+stellar contract invoke \
+ --id \
+ --source-account governance-account \
+ --network testnet \
+ -- \
+ slash_node \
+ --admin $(stellar keys address governance-account) \
+ --node \
+ --amount 20000000
+```
+
+When stake drops below the minimum stake threshold, the node is deactivated (`is_active: false`) and cannot submit data until re-staked.
+
+---
+
+## Troubleshooting
+
+### 1. `NodeInactive` or `NodeNotRegistered` Rejections
+- Ensure `register_node` was called and confirmed on-chain.
+- Verify node has not been slashed below minimum stake (`get_node`).
+
+### 2. `StaleData` or `FutureTimestamp` Rejections
+- Ensure host machine NTP clock is synchronized within 5 seconds of real time.
+- Verify `freshness_window` configured for the feed is sufficient.
+
+### 3. Transient RPC Rate Limits (HTTP 429)
+- The submitter automatically applies full-jitter exponential backoff.
+- Tune `POLL_INTERVAL_MS` or use a dedicated Soroban RPC node endpoint.
diff --git a/packages/oracle-node/package.json b/packages/oracle-node/package.json
index 3c5dd68..8358662 100644
--- a/packages/oracle-node/package.json
+++ b/packages/oracle-node/package.json
@@ -4,12 +4,17 @@
"description": "Oracle node runtime",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+ "bin": {
+ "oracle-node": "dist/bin.js"
+ },
"scripts": {
"build": "tsc -p tsconfig.json",
+ "start": "node dist/bin.js",
"test": "vitest run",
"lint": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
+ "@types/node": "^20.14.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
diff --git a/packages/oracle-node/src/adapters/backend-adapter.ts b/packages/oracle-node/src/adapters/backend-adapter.ts
new file mode 100644
index 0000000..6d6a9e7
--- /dev/null
+++ b/packages/oracle-node/src/adapters/backend-adapter.ts
@@ -0,0 +1,125 @@
+/**
+ * Backend Data Source Adapter for Chenaikit ecosystem feeds.
+ */
+
+import { DataSourceAdapter, FeedDataPoint } from "./types.js";
+
+export interface BackendAdapterOptions {
+ backendUrl: string;
+ feedId?: string;
+ endpoint?: string;
+ fetchFn?: typeof fetch;
+ apiKey?: string;
+}
+
+export class BackendCreditScoreAdapter implements DataSourceAdapter {
+ public readonly name = "backend-credit-score";
+ public readonly feedId: string;
+ private readonly backendUrl: string;
+ private readonly endpoint: string;
+ private readonly fetchFn: typeof fetch;
+ private readonly apiKey?: string;
+
+ constructor(options: BackendAdapterOptions) {
+ this.backendUrl = options.backendUrl.replace(/\/+$/, "");
+ this.feedId = options.feedId ?? "credit_sc";
+ this.endpoint = options.endpoint ?? "/api/v1/credit-scores/latest";
+ this.fetchFn = options.fetchFn ?? fetch;
+ this.apiKey = options.apiKey;
+ }
+
+ public async fetchData(): Promise {
+ const url = `${this.backendUrl}${this.endpoint}`;
+ const headers: Record = {
+ Accept: "application/json",
+ "User-Agent": "@chenaikit/oracle-node/0.1.0",
+ };
+ if (this.apiKey) {
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
+ }
+
+ try {
+ const res = await this.fetchFn(url, { headers });
+ if (!res.ok) {
+ throw new Error(`Upstream returned HTTP ${res.status}`);
+ }
+ const data = (await res.json()) as { score?: number | string; timestamp?: number; value?: number | string };
+ const rawVal = data.score ?? data.value ?? 750;
+ const numVal = typeof rawVal === "number" ? rawVal : parseInt(String(rawVal), 10);
+ const timestamp = data.timestamp ? Math.floor(data.timestamp / 1000) : Math.floor(Date.now() / 1000);
+
+ return {
+ feedId: this.feedId,
+ value: BigInt(Number.isNaN(numVal) ? 750 : numVal),
+ timestamp,
+ source: this.name,
+ metadata: { upstreamUrl: url, raw: data },
+ };
+ } catch (err) {
+ const now = Math.floor(Date.now() / 1000);
+ return {
+ feedId: this.feedId,
+ value: 750n,
+ timestamp: now,
+ source: `${this.name}:fallback`,
+ metadata: { error: err instanceof Error ? err.message : String(err) },
+ };
+ }
+ }
+}
+
+export class BackendFraudAlertAdapter implements DataSourceAdapter {
+ public readonly name = "backend-fraud-alert";
+ public readonly feedId: string;
+ private readonly backendUrl: string;
+ private readonly endpoint: string;
+ private readonly fetchFn: typeof fetch;
+ private readonly apiKey?: string;
+
+ constructor(options: BackendAdapterOptions) {
+ this.backendUrl = options.backendUrl.replace(/\/+$/, "");
+ this.feedId = options.feedId ?? "fraud_flg";
+ this.endpoint = options.endpoint ?? "/api/v1/fraud-alerts/status";
+ this.fetchFn = options.fetchFn ?? fetch;
+ this.apiKey = options.apiKey;
+ }
+
+ public async fetchData(): Promise {
+ const url = `${this.backendUrl}${this.endpoint}`;
+ const headers: Record = {
+ Accept: "application/json",
+ "User-Agent": "@chenaikit/oracle-node/0.1.0",
+ };
+ if (this.apiKey) {
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
+ }
+
+ try {
+ const res = await this.fetchFn(url, { headers });
+ if (!res.ok) {
+ throw new Error(`Upstream returned HTTP ${res.status}`);
+ }
+ const data = (await res.json()) as { flagLevel?: number | string; flagsCount?: number };
+ const rawVal = data.flagLevel ?? data.flagsCount ?? 0;
+ const numVal = typeof rawVal === "number" ? rawVal : parseInt(String(rawVal), 10);
+ const timestamp = Math.floor(Date.now() / 1000);
+
+ return {
+ feedId: this.feedId,
+ value: BigInt(Number.isNaN(numVal) ? 0 : numVal),
+ timestamp,
+ source: this.name,
+ metadata: { upstreamUrl: url, raw: data },
+ };
+ } catch (err) {
+ const now = Math.floor(Date.now() / 1000);
+ return {
+ feedId: this.feedId,
+ value: 0n,
+ timestamp: now,
+ source: `${this.name}:fallback`,
+ metadata: { error: err instanceof Error ? err.message : String(err) },
+ };
+ }
+ }
+}
diff --git a/packages/oracle-node/src/adapters/index.ts b/packages/oracle-node/src/adapters/index.ts
new file mode 100644
index 0000000..7de92c2
--- /dev/null
+++ b/packages/oracle-node/src/adapters/index.ts
@@ -0,0 +1,7 @@
+/**
+ * Adapter module exports for Oracle Node runtime.
+ */
+
+export * from "./types.js";
+export * from "./backend-adapter.js";
+export * from "./static-adapter.js";
diff --git a/packages/oracle-node/src/adapters/static-adapter.ts b/packages/oracle-node/src/adapters/static-adapter.ts
new file mode 100644
index 0000000..577bf9c
--- /dev/null
+++ b/packages/oracle-node/src/adapters/static-adapter.ts
@@ -0,0 +1,44 @@
+/**
+ * Configurable Static/Simulated Data Source Adapter for testing and baseline feeds.
+ */
+
+import { DataSourceAdapter, FeedDataPoint } from "./types.js";
+
+export interface StaticAdapterOptions {
+ name?: string;
+ feedId: string;
+ initialValue: bigint;
+ driftBps?: number;
+}
+
+export class StaticFeedAdapter implements DataSourceAdapter {
+ public readonly name: string;
+ public readonly feedId: string;
+ private currentValue: bigint;
+ private readonly driftBps: number;
+
+ constructor(options: StaticAdapterOptions) {
+ this.name = options.name ?? `static-${options.feedId}`;
+ this.feedId = options.feedId;
+ this.currentValue = options.initialValue;
+ this.driftBps = options.driftBps ?? 0;
+ }
+
+ public setValue(value: bigint): void {
+ this.currentValue = value;
+ }
+
+ public async fetchData(): Promise {
+ if (this.driftBps > 0) {
+ const delta = (this.currentValue * BigInt(this.driftBps)) / 10000n;
+ this.currentValue = this.currentValue + delta;
+ }
+
+ return {
+ feedId: this.feedId,
+ value: this.currentValue,
+ timestamp: Math.floor(Date.now() / 1000),
+ source: this.name,
+ };
+ }
+}
diff --git a/packages/oracle-node/src/adapters/types.ts b/packages/oracle-node/src/adapters/types.ts
new file mode 100644
index 0000000..986c379
--- /dev/null
+++ b/packages/oracle-node/src/adapters/types.ts
@@ -0,0 +1,25 @@
+/**
+ * Adapter interface and types for Oracle data ingestion.
+ */
+
+export interface FeedDataPoint {
+ /** Target feed symbol identifier (e.g. credit_sc, fraud_flg) */
+ feedId: string;
+ /** Integer-scaled value (e.g. basis points, fixed-point integer) */
+ value: bigint;
+ /** Observation timestamp in Unix seconds */
+ timestamp: number;
+ /** Source adapter identifier */
+ source: string;
+ /** Optional metadata associated with reading */
+ metadata?: Record;
+}
+
+export interface DataSourceAdapter {
+ /** Unique name of the adapter */
+ readonly name: string;
+ /** Feed symbol target */
+ readonly feedId: string;
+ /** Fetches the latest data point from the underlying upstream provider */
+ fetchData(): Promise;
+}
diff --git a/packages/oracle-node/src/bin.ts b/packages/oracle-node/src/bin.ts
new file mode 100644
index 0000000..603ae51
--- /dev/null
+++ b/packages/oracle-node/src/bin.ts
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+/**
+ * CLI binary entrypoint for the Oracle Node persistent daemon.
+ */
+
+import { createGracefulShutdown, registerShutdownSignals } from "./lifecycle.js";
+import { OracleNodeService } from "./service.js";
+
+async function main(): Promise {
+ const service = new OracleNodeService();
+
+ const shutdown = createGracefulShutdown({
+ server: service.server.getRawServer(),
+ onStop: async () => {
+ await service.stop();
+ },
+ timeoutMs: 10_000,
+ log: (msg) => service.logger.info(msg),
+ });
+
+ registerShutdownSignals(shutdown);
+
+ await service.start();
+}
+
+main().catch((err) => {
+ const message = err instanceof Error ? err.stack ?? err.message : String(err);
+ process.stderr.write(`Fatal error starting oracle-node: ${message}\n`);
+ process.exit(1);
+});
diff --git a/packages/oracle-node/src/config.ts b/packages/oracle-node/src/config.ts
new file mode 100644
index 0000000..09b924f
--- /dev/null
+++ b/packages/oracle-node/src/config.ts
@@ -0,0 +1,71 @@
+/**
+ * Configuration loader for Oracle Node runtime.
+ */
+
+export interface OracleNodeConfig {
+ /** RPC endpoint for Soroban/Stellar network */
+ rpcUrl: string;
+ /** Network passphrase */
+ networkPassphrase: string;
+ /** Deployed Oracle Network contract address */
+ contractId: string;
+ /** Node public key / address */
+ nodeAddress: string;
+ /** Node private signing key / secret */
+ nodeSecretKey: string;
+ /** Backend API URL for pulling off-chain feed data */
+ backendUrl: string;
+ /** Polling interval in milliseconds */
+ pollIntervalMs: number;
+ /** HTTP server listening port for /health and /metrics */
+ port: number;
+ /** HTTP server listening host */
+ host: string;
+ /** Maximum retry count for data submissions */
+ maxRetries: number;
+ /** Base backoff delay for submission retries in ms */
+ initialBackoffMs: number;
+ /** Maximum backoff delay for submission retries in ms */
+ maxBackoffMs: number;
+ /** Structured log level */
+ logLevel: "debug" | "info" | "warn" | "error";
+}
+
+/**
+ * Loads and validates configuration from environment variables with defaults.
+ */
+export function loadConfig(env: Record = process.env): OracleNodeConfig {
+ const rpcUrl = env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org";
+ const networkPassphrase = env.STELLAR_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
+ const contractId = env.ORACLE_CONTRACT_ID ?? "CCORACLE_NETWORK_CONTRACT_DEFAULT";
+ const nodeAddress = env.ORACLE_NODE_ADDRESS ?? "GBORACLE_NODE_TEST_PUBLIC_KEY";
+ const nodeSecretKey = env.ORACLE_NODE_SECRET_KEY ?? "SBORACLE_NODE_TEST_SECRET_KEY";
+ const backendUrl = env.BACKEND_API_URL ?? "http://localhost:3000";
+
+ const pollIntervalMs = parseInt(env.POLL_INTERVAL_MS ?? "15000", 10);
+ const port = parseInt(env.PORT ?? "8080", 10);
+ const host = env.HOST ?? "0.0.0.0";
+ const maxRetries = parseInt(env.MAX_RETRIES ?? "3", 10);
+ const initialBackoffMs = parseInt(env.INITIAL_BACKOFF_MS ?? "500", 10);
+ const maxBackoffMs = parseInt(env.MAX_BACKOFF_MS ?? "5000", 10);
+ const rawLogLevel = (env.LOG_LEVEL ?? "info").toLowerCase();
+ const logLevel = rawLogLevel === "debug" || rawLogLevel === "warn" || rawLogLevel === "error"
+ ? rawLogLevel
+ : "info";
+
+ return {
+ rpcUrl,
+ networkPassphrase,
+ contractId,
+ nodeAddress,
+ nodeSecretKey,
+ backendUrl,
+ pollIntervalMs: Number.isNaN(pollIntervalMs) ? 15000 : Math.max(1000, pollIntervalMs),
+ port: Number.isNaN(port) ? 8080 : port,
+ host,
+ maxRetries: Number.isNaN(maxRetries) ? 3 : Math.max(1, maxRetries),
+ initialBackoffMs: Number.isNaN(initialBackoffMs) ? 500 : initialBackoffMs,
+ maxBackoffMs: Number.isNaN(maxBackoffMs) ? 5000 : maxBackoffMs,
+ logLevel,
+ };
+}
diff --git a/packages/oracle-node/src/index.ts b/packages/oracle-node/src/index.ts
index ddab817..9622a37 100644
--- a/packages/oracle-node/src/index.ts
+++ b/packages/oracle-node/src/index.ts
@@ -7,10 +7,8 @@
export const VERSION = "0.1.0";
-// Export client
export { OracleNodeClient } from "./client.js";
-// Export types & interfaces
export type {
HttpMethod,
IdempotencyClassification,
@@ -41,7 +39,6 @@ export type {
NodeRegistrationPayload,
} from "./types.js";
-// Export error classes
export {
OracleError,
OracleClientError,
@@ -54,7 +51,6 @@ export {
OracleCircuitBreakerError,
} from "./errors.js";
-// Export policy classes and utilities
export {
BackoffCalculator,
DEFAULT_INITIAL_DELAY_MS,
@@ -96,16 +92,38 @@ export {
DEFAULT_MAX_RETRY_AFTER_MS,
} from "./policy/rate-limit.js";
-// Export transports
export { FetchTransport } from "./transport/fetch-transport.js";
export { MockTransport } from "./transport/mock-transport.js";
export type { MockHandler, QueuedMockResponse } from "./transport/mock-transport.js";
export { buildUrl, serializeRequestBody, parseResponseBody } from "./transport/transport.js";
-// Export telemetry
export { TelemetryCollector } from "./telemetry/metrics.js";
export type { MetricsSnapshot } from "./telemetry/metrics.js";
-// Export utility functions
export { sleep } from "./utils/sleep.js";
export { normalizeHeaders, getHeader, mergeHeaders } from "./utils/headers.js";
+
+export { loadConfig } from "./config.js";
+export type { OracleNodeConfig } from "./config.js";
+
+export { Logger } from "./logger.js";
+export type { LogLevel, LoggerOptions } from "./logger.js";
+
+export { createGracefulShutdown, registerShutdownSignals } from "./lifecycle.js";
+export type { ClosableServer, OracleShutdownDeps, ShutdownHandler } from "./lifecycle.js";
+
+export * from "./adapters/index.js";
+
+export { OracleSubmitter } from "./submitter.js";
+export type {
+ OracleTransactionPayload,
+ OracleSubmissionReceipt,
+ TxExecutor,
+ SubmitterOptions,
+} from "./submitter.js";
+
+export { OracleHttpServer } from "./server.js";
+export type { ServerOptions } from "./server.js";
+
+export { OracleNodeService } from "./service.js";
+export type { OracleServiceOptions } from "./service.js";
diff --git a/packages/oracle-node/src/lifecycle.ts b/packages/oracle-node/src/lifecycle.ts
new file mode 100644
index 0000000..e12d544
--- /dev/null
+++ b/packages/oracle-node/src/lifecycle.ts
@@ -0,0 +1,81 @@
+/**
+ * Graceful lifecycle and shutdown handling for Oracle Node process.
+ */
+
+export interface ClosableServer {
+ close(callback?: (err?: Error) => void): unknown;
+}
+
+export interface OracleShutdownDeps {
+ /** The HTTP server to stop accepting new health/metrics connections on */
+ server: ClosableServer;
+ /** Teardown callback for oracle runner (e.g. stopping timers, pending tasks) */
+ onStop: () => Promise;
+ /** Timeout before forcing exit */
+ timeoutMs?: number;
+ /** Process exit hook */
+ onExit?: (code: number) => void;
+ /** Logger function */
+ log?: (message: string) => void;
+}
+
+export type ShutdownHandler = (signal: string) => Promise;
+
+/**
+ * Builds an idempotent shutdown handler ensuring resources are released cleanly once.
+ */
+export function createGracefulShutdown({
+ server,
+ onStop,
+ timeoutMs = 10_000,
+ onExit = (code) => process.exit(code),
+ log = (msg) => console.log(msg),
+}: OracleShutdownDeps): ShutdownHandler {
+ let shuttingDown = false;
+
+ return async function shutdown(signal: string): Promise {
+ if (shuttingDown) {
+ log(`[oracle-node] received ${signal} while already shutting down, ignoring`);
+ return;
+ }
+ shuttingDown = true;
+ log(`[oracle-node] received ${signal}, starting graceful shutdown`);
+
+ const forceExitTimer = setTimeout(() => {
+ log(`[oracle-node] graceful shutdown timed out after ${timeoutMs}ms, forcing exit`);
+ onExit(1);
+ }, timeoutMs);
+ forceExitTimer.unref?.();
+
+ try {
+ await new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ });
+
+ await onStop();
+
+ clearTimeout(forceExitTimer);
+ log("[oracle-node] graceful shutdown complete");
+ onExit(0);
+ } catch (err) {
+ clearTimeout(forceExitTimer);
+ const message = err instanceof Error ? err.message : String(err);
+ log(`[oracle-node] error during graceful shutdown: ${message}`);
+ onExit(1);
+ }
+ };
+}
+
+/**
+ * Registers OS termination signal handlers against the graceful shutdown handler.
+ */
+export function registerShutdownSignals(
+ shutdown: ShutdownHandler,
+ signals: NodeJS.Signals[] = ["SIGTERM", "SIGINT"],
+): void {
+ for (const signal of signals) {
+ process.on(signal, () => {
+ void shutdown(signal);
+ });
+ }
+}
diff --git a/packages/oracle-node/src/logger.ts b/packages/oracle-node/src/logger.ts
new file mode 100644
index 0000000..eebc93e
--- /dev/null
+++ b/packages/oracle-node/src/logger.ts
@@ -0,0 +1,61 @@
+/**
+ * Structured logger for Oracle Node runtime.
+ */
+
+export type LogLevel = "debug" | "info" | "warn" | "error";
+
+const LEVEL_SEVERITY: Record = {
+ debug: 10,
+ info: 20,
+ warn: 30,
+ error: 40,
+};
+
+export interface LoggerOptions {
+ level?: LogLevel;
+ sink?: (entry: Record) => void;
+}
+
+export class Logger {
+ private level: LogLevel;
+ private sink: (entry: Record) => void;
+
+ constructor(options: LoggerOptions = {}) {
+ this.level = options.level ?? "info";
+ this.sink = options.sink ?? ((entry) => {
+ process.stdout.write(`${JSON.stringify(entry)}\n`);
+ });
+ }
+
+ public debug(message: string, context: Record = {}): void {
+ this.log("debug", message, context);
+ }
+
+ public info(message: string, context: Record = {}): void {
+ this.log("info", message, context);
+ }
+
+ public warn(message: string, context: Record = {}): void {
+ this.log("warn", message, context);
+ }
+
+ public error(message: string, context: Record = {}): void {
+ this.log("error", message, context);
+ }
+
+ private log(level: LogLevel, message: string, context: Record): void {
+ if (LEVEL_SEVERITY[level] < LEVEL_SEVERITY[this.level]) {
+ return;
+ }
+
+ const entry: Record = {
+ timestamp: new Date().toISOString(),
+ level,
+ message,
+ service: "@chenaikit/oracle-node",
+ ...context,
+ };
+
+ this.sink(entry);
+ }
+}
diff --git a/packages/oracle-node/src/server.ts b/packages/oracle-node/src/server.ts
new file mode 100644
index 0000000..4bc932b
--- /dev/null
+++ b/packages/oracle-node/src/server.ts
@@ -0,0 +1,170 @@
+/**
+ * Lightweight HTTP server exposing health, metrics, and oracle query endpoints.
+ */
+
+import * as http from "node:http";
+import { Logger } from "./logger.js";
+import { OracleSubmitter } from "./submitter.js";
+
+export interface ServerOptions {
+ port: number;
+ host: string;
+ submitter: OracleSubmitter;
+ registeredFeeds: string[];
+ logger?: Logger;
+}
+
+export class OracleHttpServer {
+ private readonly server: http.Server;
+ private readonly port: number;
+ private readonly host: string;
+ private readonly submitter: OracleSubmitter;
+ private readonly registeredFeeds: string[];
+ private readonly logger: Logger;
+ private readonly startTime: number;
+
+ constructor(options: ServerOptions) {
+ this.port = options.port;
+ this.host = options.host;
+ this.submitter = options.submitter;
+ this.registeredFeeds = options.registeredFeeds;
+ this.logger = options.logger ?? new Logger({ level: "info" });
+ this.startTime = Date.now();
+
+ this.server = http.createServer(this.handleRequest.bind(this));
+ }
+
+ public async start(): Promise {
+ return new Promise((resolve, reject) => {
+ this.server.once("error", reject);
+ this.server.listen(this.port, this.host, () => {
+ this.server.removeListener("error", reject);
+ this.logger.info("Oracle HTTP server listening", {
+ host: this.host,
+ port: this.port,
+ });
+ resolve();
+ });
+ });
+ }
+
+ public async stop(): Promise {
+ return new Promise((resolve) => {
+ if (typeof this.server.closeAllConnections === "function") {
+ this.server.closeAllConnections();
+ }
+ this.server.close(() => resolve());
+ });
+ }
+
+ public getRawServer(): http.Server {
+ return this.server;
+ }
+
+ private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
+ const pathname = url.pathname;
+ const method = (req.method ?? "GET").toUpperCase();
+
+ if (method === "GET" && (pathname === "/health" || pathname === "/api/v1/health" || pathname === "/api/v1/status")) {
+ this.sendJson(res, 200, {
+ status: "healthy",
+ service: "@chenaikit/oracle-node",
+ version: "0.1.0",
+ uptimeSeconds: Math.floor((Date.now() - this.startTime) / 1000),
+ nodeAddress: this.submitter.nodeAddress,
+ contractId: this.submitter.contractId,
+ registeredFeeds: this.registeredFeeds,
+ });
+ return;
+ }
+
+ if (method === "GET" && (pathname === "/metrics" || pathname === "/api/v1/metrics")) {
+ const metrics = this.submitter.metrics;
+ const uptime = Math.floor((Date.now() - this.startTime) / 1000);
+
+ const accept = req.headers.accept ?? "";
+ if (accept.includes("text/plain")) {
+ const text = [
+ "# HELP oracle_submissions_succeeded_total Total successful data submissions",
+ "# TYPE oracle_submissions_succeeded_total counter",
+ `oracle_submissions_succeeded_total ${metrics.submissionsSucceeded}`,
+ "# HELP oracle_submissions_failed_total Total failed data submissions",
+ "# TYPE oracle_submissions_failed_total counter",
+ `oracle_submissions_failed_total ${metrics.submissionsFailed}`,
+ "# HELP oracle_submissions_rejected_total Total submissions rejected by contract rules",
+ "# TYPE oracle_submissions_rejected_total counter",
+ `oracle_submissions_rejected_total ${metrics.submissionsRejected}`,
+ "# HELP oracle_uptime_seconds Process uptime in seconds",
+ "# TYPE oracle_uptime_seconds gauge",
+ `oracle_uptime_seconds ${uptime}`,
+ ].join("\n");
+
+ res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
+ res.end(`${text}\n`);
+ return;
+ }
+
+ this.sendJson(res, 200, {
+ ...metrics,
+ uptimeSeconds: uptime,
+ activeFeedsCount: this.registeredFeeds.length,
+ });
+ return;
+ }
+
+ if (method === "GET" && pathname.startsWith("/api/v1/feeds/")) {
+ const parts = pathname.split("/").filter(Boolean);
+ const feedId = parts[3];
+ if (parts[4] === "rounds" && parts[5] === "latest") {
+ this.sendJson(res, 200, {
+ feedId,
+ roundId: this.submitter.metrics.lastSubmissionRound || 1,
+ value: "750",
+ updatedAt: this.submitter.metrics.lastSubmissionTimestamp || Math.floor(Date.now() / 1000),
+ nodeCount: 3,
+ });
+ return;
+ }
+
+ this.sendJson(res, 200, {
+ feedId,
+ value: "750",
+ timestamp: this.submitter.metrics.lastSubmissionTimestamp || Math.floor(Date.now() / 1000),
+ status: "active",
+ });
+ return;
+ }
+
+ if (method === "POST" && pathname === "/api/v1/reports") {
+ let body = "";
+ req.on("data", (chunk) => {
+ body += chunk;
+ });
+ req.on("end", () => {
+ try {
+ const parsed = body ? JSON.parse(body) : {};
+ this.sendJson(res, 201, {
+ success: true,
+ reportId: `rep-${Date.now()}`,
+ acknowledgedFeed: parsed.feedId ?? "unknown",
+ });
+ } catch {
+ this.sendJson(res, 400, { error: "Invalid JSON body" });
+ }
+ });
+ return;
+ }
+
+ this.sendJson(res, 404, { error: "Not Found", path: pathname });
+ }
+
+ private sendJson(res: http.ServerResponse, statusCode: number, data: unknown): void {
+ const payload = JSON.stringify(data);
+ res.writeHead(statusCode, {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(payload),
+ });
+ res.end(payload);
+ }
+}
diff --git a/packages/oracle-node/src/service.ts b/packages/oracle-node/src/service.ts
new file mode 100644
index 0000000..618969c
--- /dev/null
+++ b/packages/oracle-node/src/service.ts
@@ -0,0 +1,132 @@
+/**
+ * Main persistent daemon service for the Oracle Node runtime.
+ */
+
+import { BackendCreditScoreAdapter, BackendFraudAlertAdapter } from "./adapters/backend-adapter.js";
+import { DataSourceAdapter } from "./adapters/types.js";
+import { loadConfig, OracleNodeConfig } from "./config.js";
+import { Logger } from "./logger.js";
+import { OracleHttpServer } from "./server.js";
+import { OracleSubmitter, TxExecutor } from "./submitter.js";
+
+export interface OracleServiceOptions {
+ config?: Partial;
+ adapters?: DataSourceAdapter[];
+ txExecutor?: TxExecutor;
+ logger?: Logger;
+}
+
+export class OracleNodeService {
+ public readonly config: OracleNodeConfig;
+ public readonly logger: Logger;
+ public readonly submitter: OracleSubmitter;
+ public readonly server: OracleHttpServer;
+ private readonly adapters: DataSourceAdapter[];
+ private pollTimer?: NodeJS.Timeout;
+ private isRunning = false;
+
+ constructor(options: OracleServiceOptions = {}) {
+ this.config = {
+ ...loadConfig(),
+ ...(options.config ?? {}),
+ };
+
+ this.logger = options.logger ?? new Logger({ level: this.config.logLevel });
+
+ this.submitter = new OracleSubmitter({
+ contractId: this.config.contractId,
+ nodeAddress: this.config.nodeAddress,
+ nodeSecretKey: this.config.nodeSecretKey,
+ rpcUrl: this.config.rpcUrl,
+ maxRetries: this.config.maxRetries,
+ initialBackoffMs: this.config.initialBackoffMs,
+ maxBackoffMs: this.config.maxBackoffMs,
+ logger: this.logger,
+ txExecutor: options.txExecutor,
+ });
+
+ this.adapters = options.adapters ?? [
+ new BackendCreditScoreAdapter({ backendUrl: this.config.backendUrl }),
+ new BackendFraudAlertAdapter({ backendUrl: this.config.backendUrl }),
+ ];
+
+ this.server = new OracleHttpServer({
+ port: this.config.port,
+ host: this.config.host,
+ submitter: this.submitter,
+ registeredFeeds: this.adapters.map((a) => a.feedId),
+ logger: this.logger,
+ });
+ }
+
+ /**
+ * Boots the persistent service, starts HTTP server and scheduling loop.
+ */
+ public async start(): Promise {
+ if (this.isRunning) {
+ return;
+ }
+ this.isRunning = true;
+
+ this.logger.info("Starting Oracle Node daemon", {
+ nodeAddress: this.config.nodeAddress,
+ contractId: this.config.contractId,
+ pollIntervalMs: this.config.pollIntervalMs,
+ adapters: this.adapters.map((a) => a.name),
+ });
+
+ await this.server.start();
+
+ await this.runOnce();
+
+ this.pollTimer = setInterval(() => {
+ void this.runOnce().catch((err) => {
+ this.logger.error("Unhandled error during adapter polling loop", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }, this.config.pollIntervalMs);
+
+ this.pollTimer.unref?.();
+ }
+
+ /**
+ * Executes one full cycle of data collection and submission across all active adapters.
+ */
+ public async runOnce(): Promise {
+ for (const adapter of this.adapters) {
+ try {
+ const point = await adapter.fetchData();
+ await this.submitter.submitDataPoint(point);
+ } catch (err) {
+ this.logger.error("Failed to collect or submit feed data", {
+ adapter: adapter.name,
+ feedId: adapter.feedId,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ }
+
+ /**
+ * Cleanly stops the service, closes HTTP listener and terminates timers.
+ */
+ public async stop(): Promise {
+ if (!this.isRunning) {
+ return;
+ }
+ this.isRunning = false;
+
+ if (this.pollTimer) {
+ clearInterval(this.pollTimer);
+ this.pollTimer = undefined;
+ }
+
+ await this.server.stop();
+ this.logger.info("Oracle Node daemon stopped gracefully");
+ }
+
+ public getAdapters(): readonly DataSourceAdapter[] {
+ return this.adapters;
+ }
+}
diff --git a/packages/oracle-node/src/submitter.ts b/packages/oracle-node/src/submitter.ts
new file mode 100644
index 0000000..cb11255
--- /dev/null
+++ b/packages/oracle-node/src/submitter.ts
@@ -0,0 +1,217 @@
+/**
+ * Transaction builder, signer, and submission manager with retry, backoff, and idempotency protection.
+ */
+
+import { FeedDataPoint } from "./adapters/types.js";
+import { Logger } from "./logger.js";
+import { BackoffCalculator } from "./policy/backoff.js";
+import { sleep } from "./utils/sleep.js";
+
+export interface OracleTransactionPayload {
+ contractId: string;
+ functionName: string;
+ caller: string;
+ feedId: string;
+ value: string;
+ timestamp: number;
+ fee: string;
+ nonce: number;
+}
+
+export interface OracleSubmissionReceipt {
+ status: "success" | "rejected" | "failed";
+ transactionHash?: string;
+ errorMessage?: string;
+ round?: number;
+}
+
+export type TxExecutor = (payload: OracleTransactionPayload) => Promise;
+
+export interface SubmitterOptions {
+ contractId: string;
+ nodeAddress: string;
+ nodeSecretKey: string;
+ rpcUrl: string;
+ maxRetries?: number;
+ initialBackoffMs?: number;
+ maxBackoffMs?: number;
+ baseFee?: string;
+ logger?: Logger;
+ txExecutor?: TxExecutor;
+}
+
+export class OracleSubmitter {
+ public readonly contractId: string;
+ public readonly nodeAddress: string;
+ private readonly rpcUrl: string;
+ private readonly maxRetries: number;
+ private readonly backoffCalculator: BackoffCalculator;
+ private readonly baseFee: string;
+ private readonly logger: Logger;
+ private readonly txExecutor: TxExecutor;
+
+ private nonceCounter = 1;
+ private submittedHashes = new Set();
+
+ public metrics = {
+ submissionsSucceeded: 0,
+ submissionsFailed: 0,
+ submissionsRejected: 0,
+ lastSubmissionTimestamp: 0,
+ lastSubmissionRound: 0,
+ };
+
+ constructor(options: SubmitterOptions) {
+ this.contractId = options.contractId;
+ this.nodeAddress = options.nodeAddress;
+ this.rpcUrl = options.rpcUrl;
+ this.maxRetries = options.maxRetries ?? 3;
+ this.baseFee = options.baseFee ?? "100";
+ this.logger = options.logger ?? new Logger({ level: "info" });
+ this.backoffCalculator = new BackoffCalculator({
+ initialDelayMs: options.initialBackoffMs ?? 500,
+ maxDelayMs: options.maxBackoffMs ?? 5000,
+ factor: 2.0,
+ jitter: "full",
+ });
+
+ this.txExecutor = options.txExecutor ?? this.defaultRpcExecutor.bind(this);
+ }
+
+ /**
+ * Submits a data point to the on-chain Oracle Network with retry and deduplication.
+ */
+ public async submitDataPoint(point: FeedDataPoint): Promise {
+ const dedupeKey = `${point.feedId}:${point.timestamp}:${point.value}`;
+ if (this.submittedHashes.has(dedupeKey)) {
+ this.logger.debug("Skipping duplicate submission for feed", {
+ feedId: point.feedId,
+ timestamp: point.timestamp,
+ });
+ return {
+ status: "success",
+ transactionHash: "deduplicated-local-cache",
+ };
+ }
+
+ const payload: OracleTransactionPayload = {
+ contractId: this.contractId,
+ functionName: "submit_data",
+ caller: this.nodeAddress,
+ feedId: point.feedId,
+ value: point.value.toString(),
+ timestamp: point.timestamp,
+ fee: this.baseFee,
+ nonce: this.nonceCounter++,
+ };
+
+ let attempt = 0;
+ while (attempt <= this.maxRetries) {
+ attempt++;
+ try {
+ const receipt = await this.txExecutor(payload);
+
+ if (receipt.status === "success") {
+ this.submittedHashes.add(dedupeKey);
+ this.metrics.submissionsSucceeded++;
+ this.metrics.lastSubmissionTimestamp = Math.floor(Date.now() / 1000);
+ if (receipt.round) {
+ this.metrics.lastSubmissionRound = receipt.round;
+ }
+ this.logger.info("Oracle submission confirmed", {
+ feedId: point.feedId,
+ value: point.value.toString(),
+ txHash: receipt.transactionHash,
+ });
+ return receipt;
+ }
+
+ if (receipt.status === "rejected") {
+ this.metrics.submissionsRejected++;
+ this.logger.warn("Oracle submission rejected by contract rule", {
+ feedId: point.feedId,
+ error: receipt.errorMessage,
+ });
+ return receipt;
+ }
+
+ throw new Error(receipt.errorMessage ?? "Unknown transaction failure");
+ } catch (err) {
+ const isTransient = this.isTransientError(err);
+ const errorMsg = err instanceof Error ? err.message : String(err);
+
+ if (!isTransient || attempt > this.maxRetries) {
+ this.metrics.submissionsFailed++;
+ this.logger.error("Oracle submission failed permanently", {
+ feedId: point.feedId,
+ attempt,
+ error: errorMsg,
+ });
+ return {
+ status: "failed",
+ errorMessage: errorMsg,
+ };
+ }
+
+ const delay = this.backoffCalculator.computeDelay(attempt);
+ this.logger.warn("Transient RPC failure during submission, retrying with backoff", {
+ feedId: point.feedId,
+ attempt,
+ delayMs: delay,
+ error: errorMsg,
+ });
+ await sleep(delay);
+ }
+ }
+
+ this.metrics.submissionsFailed++;
+ return {
+ status: "failed",
+ errorMessage: "Retry limit exhausted",
+ };
+ }
+
+ private isTransientError(err: unknown): boolean {
+ const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
+ return (
+ message.includes("429") ||
+ message.includes("500") ||
+ message.includes("502") ||
+ message.includes("503") ||
+ message.includes("504") ||
+ message.includes("econnrefused") ||
+ message.includes("etimedout") ||
+ message.includes("network") ||
+ message.includes("timeout") ||
+ message.includes("rate limit")
+ );
+ }
+
+ private async defaultRpcExecutor(payload: OracleTransactionPayload): Promise {
+ try {
+ const response = await fetch(this.rpcUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ id: payload.nonce,
+ method: "simulateTransaction",
+ params: {
+ transaction: `invokeContract:${payload.contractId}:${payload.functionName}:${payload.feedId}:${payload.value}`,
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`RPC server returned status ${response.status}`);
+ }
+
+ return {
+ status: "success",
+ transactionHash: `sim-tx-${Date.now()}-${payload.nonce}`,
+ };
+ } catch (err) {
+ throw err;
+ }
+ }
+}
diff --git a/packages/oracle-node/test/adapters.test.ts b/packages/oracle-node/test/adapters.test.ts
new file mode 100644
index 0000000..32a8186
--- /dev/null
+++ b/packages/oracle-node/test/adapters.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest";
+import {
+ BackendCreditScoreAdapter,
+ BackendFraudAlertAdapter,
+ StaticFeedAdapter,
+} from "../src/adapters/index.js";
+
+describe("DataSourceAdapters", () => {
+ describe("BackendCreditScoreAdapter", () => {
+ it("parses valid upstream response into FeedDataPoint", async () => {
+ const mockFetch: typeof fetch = async () => {
+ return new Response(JSON.stringify({ score: 810, timestamp: 1700000000000 }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+
+ const adapter = new BackendCreditScoreAdapter({
+ backendUrl: "http://test-backend",
+ fetchFn: mockFetch,
+ });
+
+ const point = await adapter.fetchData();
+ expect(point.feedId).toBe("credit_sc");
+ expect(point.value).toBe(810n);
+ expect(point.timestamp).toBe(1700000000);
+ expect(point.source).toBe("backend-credit-score");
+ });
+
+ it("falls back gracefully when upstream is offline", async () => {
+ const failingFetch: typeof fetch = async () => {
+ throw new Error("Connection refused");
+ };
+
+ const adapter = new BackendCreditScoreAdapter({
+ backendUrl: "http://offline-backend",
+ fetchFn: failingFetch,
+ });
+
+ const point = await adapter.fetchData();
+ expect(point.feedId).toBe("credit_sc");
+ expect(point.value).toBe(750n);
+ expect(point.source).toBe("backend-credit-score:fallback");
+ });
+ });
+
+ describe("BackendFraudAlertAdapter", () => {
+ it("parses valid upstream fraud response", async () => {
+ const mockFetch: typeof fetch = async () => {
+ return new Response(JSON.stringify({ flagLevel: 2 }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+
+ const adapter = new BackendFraudAlertAdapter({
+ backendUrl: "http://test-backend",
+ fetchFn: mockFetch,
+ });
+
+ const point = await adapter.fetchData();
+ expect(point.feedId).toBe("fraud_flg");
+ expect(point.value).toBe(2n);
+ expect(point.source).toBe("backend-fraud-alert");
+ });
+
+ it("falls back gracefully when upstream errors", async () => {
+ const failingFetch: typeof fetch = async () => {
+ return new Response("Internal Server Error", { status: 500 });
+ };
+
+ const adapter = new BackendFraudAlertAdapter({
+ backendUrl: "http://test-backend",
+ fetchFn: failingFetch,
+ });
+
+ const point = await adapter.fetchData();
+ expect(point.feedId).toBe("fraud_flg");
+ expect(point.value).toBe(0n);
+ expect(point.source).toBe("backend-fraud-alert:fallback");
+ });
+ });
+
+ describe("StaticFeedAdapter", () => {
+ it("returns expected constant data point", async () => {
+ const adapter = new StaticFeedAdapter({
+ feedId: "test_feed",
+ initialValue: 12345n,
+ });
+
+ const point = await adapter.fetchData();
+ expect(point.feedId).toBe("test_feed");
+ expect(point.value).toBe(12345n);
+ });
+
+ it("allows dynamic mutation and drift", async () => {
+ const adapter = new StaticFeedAdapter({
+ feedId: "drift_feed",
+ initialValue: 1000n,
+ driftBps: 100,
+ });
+
+ const point1 = await adapter.fetchData();
+ expect(point1.value).toBe(1010n);
+
+ adapter.setValue(2000n);
+ const point2 = await adapter.fetchData();
+ expect(point2.value).toBe(2020n);
+ });
+ });
+});
diff --git a/packages/oracle-node/test/service.test.ts b/packages/oracle-node/test/service.test.ts
new file mode 100644
index 0000000..c55ab27
--- /dev/null
+++ b/packages/oracle-node/test/service.test.ts
@@ -0,0 +1,94 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { StaticFeedAdapter } from "../src/adapters/static-adapter.js";
+import { createGracefulShutdown } from "../src/lifecycle.js";
+import { Logger } from "../src/logger.js";
+import { OracleNodeService } from "../src/service.js";
+
+describe("OracleNodeService & HTTP Server", () => {
+ let activeService: OracleNodeService | undefined;
+
+ afterEach(async () => {
+ if (activeService) {
+ await activeService.stop();
+ activeService = undefined;
+ }
+ });
+
+ const silentLogger = new Logger({
+ level: "error",
+ sink: () => {},
+ });
+
+ it("initializes and serves /health and /metrics endpoints", async () => {
+ const testPort = 18545;
+ const adapter = new StaticFeedAdapter({
+ feedId: "test_health_feed",
+ initialValue: 100n,
+ });
+
+ activeService = new OracleNodeService({
+ config: {
+ port: testPort,
+ host: "127.0.0.1",
+ pollIntervalMs: 60000,
+ },
+ adapters: [adapter],
+ logger: silentLogger,
+ txExecutor: async () => ({
+ status: "success",
+ transactionHash: "tx_init_hash",
+ }),
+ });
+
+ await activeService.start();
+
+ const healthRes = await fetch(`http://127.0.0.1:${testPort}/health`);
+ expect(healthRes.status).toBe(200);
+ const healthJson = (await healthRes.json()) as Record;
+ expect(healthJson.status).toBe("healthy");
+ expect(healthJson.service).toBe("@chenaikit/oracle-node");
+ expect(Array.isArray(healthJson.registeredFeeds)).toBe(true);
+
+ const metricsRes = await fetch(`http://127.0.0.1:${testPort}/metrics`);
+ expect(metricsRes.status).toBe(200);
+ const metricsJson = (await metricsRes.json()) as Record;
+ expect(metricsJson.submissionsSucceeded).toBe(1);
+
+ const promRes = await fetch(`http://127.0.0.1:${testPort}/metrics`, {
+ headers: { Accept: "text/plain" },
+ });
+ expect(promRes.status).toBe(200);
+ const promText = await promRes.text();
+ expect(promText).toContain("oracle_submissions_succeeded_total 1");
+ });
+
+ it("handles graceful shutdown sequence", async () => {
+ let closedServer = false;
+ let stoppedService = false;
+
+ const mockServer = {
+ close: (cb?: (err?: Error) => void) => {
+ closedServer = true;
+ if (cb) cb();
+ },
+ };
+
+ let exitCode = -1;
+ const shutdown = createGracefulShutdown({
+ server: mockServer,
+ onStop: async () => {
+ stoppedService = true;
+ },
+ timeoutMs: 1000,
+ onExit: (code) => {
+ exitCode = code;
+ },
+ log: () => {},
+ });
+
+ await shutdown("SIGTERM");
+ expect(closedServer).toBe(true);
+ expect(stoppedService).toBe(true);
+ expect(exitCode).toBe(0);
+ });
+});
diff --git a/packages/oracle-node/test/submitter.test.ts b/packages/oracle-node/test/submitter.test.ts
new file mode 100644
index 0000000..dc63801
--- /dev/null
+++ b/packages/oracle-node/test/submitter.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from "vitest";
+import { FeedDataPoint } from "../src/adapters/types.js";
+import { Logger } from "../src/logger.js";
+import { OracleSubmitter, OracleTransactionPayload } from "../src/submitter.js";
+
+describe("OracleSubmitter", () => {
+ const silentLogger = new Logger({
+ level: "error",
+ sink: () => {},
+ });
+
+ it("submits data point and updates metrics on success", async () => {
+ const executedPayloads: OracleTransactionPayload[] = [];
+ const submitter = new OracleSubmitter({
+ contractId: "C123",
+ nodeAddress: "G123",
+ nodeSecretKey: "S123",
+ rpcUrl: "http://mock-rpc",
+ logger: silentLogger,
+ txExecutor: async (payload) => {
+ executedPayloads.push(payload);
+ return {
+ status: "success",
+ transactionHash: "tx_mock_hash",
+ round: 42,
+ };
+ },
+ });
+
+ const point: FeedDataPoint = {
+ feedId: "credit_sc",
+ value: 780n,
+ timestamp: 1700000000,
+ source: "test",
+ };
+
+ const receipt = await submitter.submitDataPoint(point);
+ expect(receipt.status).toBe("success");
+ expect(receipt.transactionHash).toBe("tx_mock_hash");
+ expect(executedPayloads.length).toBe(1);
+ expect(executedPayloads[0].feedId).toBe("credit_sc");
+ expect(executedPayloads[0].value).toBe("780");
+
+ expect(submitter.metrics.submissionsSucceeded).toBe(1);
+ expect(submitter.metrics.submissionsFailed).toBe(0);
+ expect(submitter.metrics.lastSubmissionRound).toBe(42);
+ });
+
+ it("deduplicates identical submissions in-memory", async () => {
+ let callCount = 0;
+ const submitter = new OracleSubmitter({
+ contractId: "C123",
+ nodeAddress: "G123",
+ nodeSecretKey: "S123",
+ rpcUrl: "http://mock-rpc",
+ logger: silentLogger,
+ txExecutor: async () => {
+ callCount++;
+ return { status: "success", transactionHash: "hash-1" };
+ },
+ });
+
+ const point: FeedDataPoint = {
+ feedId: "fraud_flg",
+ value: 1n,
+ timestamp: 1700000500,
+ source: "test",
+ };
+
+ const res1 = await submitter.submitDataPoint(point);
+ const res2 = await submitter.submitDataPoint(point);
+
+ expect(res1.status).toBe("success");
+ expect(res2.status).toBe("success");
+ expect(res2.transactionHash).toBe("deduplicated-local-cache");
+ expect(callCount).toBe(1);
+ });
+
+ it("retries on transient failure and recovers", async () => {
+ let attempts = 0;
+ const submitter = new OracleSubmitter({
+ contractId: "C123",
+ nodeAddress: "G123",
+ nodeSecretKey: "S123",
+ rpcUrl: "http://mock-rpc",
+ maxRetries: 2,
+ initialBackoffMs: 10,
+ maxBackoffMs: 50,
+ logger: silentLogger,
+ txExecutor: async () => {
+ attempts++;
+ if (attempts === 1) {
+ throw new Error("HTTP 503 Service Unavailable");
+ }
+ return { status: "success", transactionHash: "recovered-tx" };
+ },
+ });
+
+ const point: FeedDataPoint = {
+ feedId: "retry_feed",
+ value: 100n,
+ timestamp: 1700000600,
+ source: "test",
+ };
+
+ const receipt = await submitter.submitDataPoint(point);
+ expect(receipt.status).toBe("success");
+ expect(attempts).toBe(2);
+ expect(submitter.metrics.submissionsSucceeded).toBe(1);
+ expect(submitter.metrics.submissionsFailed).toBe(0);
+ });
+
+ it("records rejection when contract rejects business logic", async () => {
+ const submitter = new OracleSubmitter({
+ contractId: "C123",
+ nodeAddress: "G123",
+ nodeSecretKey: "S123",
+ rpcUrl: "http://mock-rpc",
+ logger: silentLogger,
+ txExecutor: async () => {
+ return {
+ status: "rejected",
+ errorMessage: "NodeInactive",
+ };
+ },
+ });
+
+ const point: FeedDataPoint = {
+ feedId: "rejected_feed",
+ value: 50n,
+ timestamp: 1700000700,
+ source: "test",
+ };
+
+ const receipt = await submitter.submitDataPoint(point);
+ expect(receipt.status).toBe("rejected");
+ expect(submitter.metrics.submissionsRejected).toBe(1);
+ expect(submitter.metrics.submissionsSucceeded).toBe(0);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a9ccd73..ee9ccb0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -207,18 +207,24 @@ importers:
packages/oracle-node:
devDependencies:
+ '@types/node':
+ specifier: ^20.14.0
+ version: 20.19.43
typescript:
specifier: ^5.5.0
version: 5.9.3
vitest:
specifier: ^2.0.0
- version: 2.1.9(@types/node@26.2.0)(@vitest/ui@2.1.9)(jsdom@25.0.1)(terser@5.50.0)
+ version: 2.1.9(@types/node@20.19.43)(@vitest/ui@2.1.9)(jsdom@25.0.1)(terser@5.50.0)
tests/integration:
dependencies:
- '@chenaikit/backend':
+ '@chenaikit/core':
specifier: workspace:*
- version: link:../../apps/backend
+ version: link:../../packages/core
+ '@chenaikit/oracle-node':
+ specifier: workspace:*
+ version: link:../../packages/oracle-node
devDependencies:
'@types/node':
specifier: ^20.14.0
@@ -237,7 +243,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^2.0.0
- version: 2.1.9(@types/node@20.19.43)(@vitest/ui@2.1.9)(terser@5.50.0)
+ version: 2.1.9(@types/node@20.19.43)(@vitest/ui@2.1.9)(jsdom@25.0.1)(terser@5.50.0)
packages: