From c73cbede25d7406f89d0a5227811899557256dc3 Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Wed, 27 May 2026 14:38:03 +0100 Subject: [PATCH] feat: implement subscription smart contract oracle integration for price feeds (#370) --- backend/services/index.ts | 1 + backend/services/oracleMonitorService.ts | 152 +++++++++++++++++++ contracts/subscription/Cargo.toml | 1 + contracts/subscription/src/lib.rs | 185 ++++++++++++++++++++++- contracts/types/src/lib.rs | 20 +++ src/services/oraclePriceService.ts | 77 ++++++++++ src/types/subscription.ts | 7 + 7 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 backend/services/oracleMonitorService.ts create mode 100644 src/services/oraclePriceService.ts diff --git a/backend/services/index.ts b/backend/services/index.ts index 1f5c4a1..4b29373 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -1,5 +1,6 @@ export { AuditService } from './auditService'; export { PricingService } from './pricingService'; +export { OracleMonitorService, oracleMonitorService } from './oracleMonitorService'; export type { AuditAction, AuditEvent, diff --git a/backend/services/oracleMonitorService.ts b/backend/services/oracleMonitorService.ts new file mode 100644 index 0000000..643c72e --- /dev/null +++ b/backend/services/oracleMonitorService.ts @@ -0,0 +1,152 @@ +interface OracleFeedHealth { + feedId: string; + token: string; + quote: string; + lastPrice: number; + lastTimestamp: number; + stalenessSecs: number; + maxStalenessSecs: number; + circuitTripped: boolean; + consecutiveFaults: number; + healthy: boolean; +} + +interface OracleAlert { + type: 'stale_price' | 'circuit_open' | 'deviation' | 'no_price'; + feedId: string; + token: string; + quote: string; + timestamp: number; + message: string; +} + +export class OracleMonitorService { + private feedHealth = new Map(); + private alerts: OracleAlert[] = []; + private checkIntervalMs: number; + private intervalHandle: ReturnType | null = null; + private readonly maxAlerts = 1000; + + constructor(checkIntervalMs = 60_000) { + this.checkIntervalMs = checkIntervalMs; + } + + start(): void { + if (this.intervalHandle) return; + this.intervalHandle = setInterval(() => this.runHealthCheck(), this.checkIntervalMs); + } + + stop(): void { + if (this.intervalHandle) { + clearInterval(this.intervalHandle); + this.intervalHandle = null; + } + } + + registerFeed( + feedId: string, + token: string, + quote: string, + maxStalenessSecs: number, + ): void { + this.feedHealth.set(feedId, { + feedId, + token, + quote, + lastPrice: 0, + lastTimestamp: 0, + stalenessSecs: 0, + maxStalenessSecs, + circuitTripped: false, + consecutiveFaults: 0, + healthy: true, + }); + } + + unregisterFeed(feedId: string): void { + this.feedHealth.delete(feedId); + } + + reportPrice(feedId: string, price: number, timestamp: number): void { + const feed = this.feedHealth.get(feedId); + if (!feed) return; + + const now = Date.now() / 1000; + feed.lastPrice = price; + feed.lastTimestamp = timestamp; + feed.stalenessSecs = Math.max(0, now - timestamp); + feed.healthy = feed.stalenessSecs <= feed.maxStalenessSecs && !feed.circuitTripped; + } + + reportCircuitBreaker(feedId: string, tripped: boolean, faults: number): void { + const feed = this.feedHealth.get(feedId); + if (!feed) return; + + if (tripped && !feed.circuitTripped) { + this.addAlert({ + type: 'circuit_open', + feedId, + token: feed.token, + quote: feed.quote, + timestamp: Date.now(), + message: `Circuit breaker tripped for ${feed.token}/${feed.quote} after ${faults} consecutive faults`, + }); + } + + feed.circuitTripped = tripped; + feed.consecutiveFaults = faults; + feed.healthy = feed.stalenessSecs <= feed.maxStalenessSecs && !feed.circuitTripped; + } + + getHealth(): OracleFeedHealth[] { + return Array.from(this.feedHealth.values()); + } + + getAlerts(): OracleAlert[] { + return this.alerts; + } + + getUnhealthyFeeds(): OracleFeedHealth[] { + return Array.from(this.feedHealth.values()).filter((f) => !f.healthy); + } + + private addAlert(alert: OracleAlert): void { + this.alerts.push(alert); + if (this.alerts.length > this.maxAlerts) { + this.alerts = this.alerts.slice(-this.maxAlerts); + } + } + + private runHealthCheck(): void { + const now = Date.now() / 1000; + for (const feed of this.feedHealth.values()) { + feed.stalenessSecs = Math.max(0, now - feed.lastTimestamp); + + if (feed.lastPrice === 0 && feed.lastTimestamp === 0) { + this.addAlert({ + type: 'no_price', + feedId: feed.feedId, + token: feed.token, + quote: feed.quote, + timestamp: Date.now(), + message: `No price reported yet for ${feed.token}/${feed.quote}`, + }); + } + + if (feed.stalenessSecs > feed.maxStalenessSecs) { + this.addAlert({ + type: 'stale_price', + feedId: feed.feedId, + token: feed.token, + quote: feed.quote, + timestamp: Date.now(), + message: `Stale price for ${feed.token}/${feed.quote}: ${feed.stalenessSecs}s old (max ${feed.maxStalenessSecs}s)`, + }); + } + + feed.healthy = feed.stalenessSecs <= feed.maxStalenessSecs && !feed.circuitTripped; + } + } +} + +export const oracleMonitorService = new OracleMonitorService(); diff --git a/contracts/subscription/Cargo.toml b/contracts/subscription/Cargo.toml index 237afd3..53584cf 100644 --- a/contracts/subscription/Cargo.toml +++ b/contracts/subscription/Cargo.toml @@ -15,6 +15,7 @@ serde = "1.0" [dependencies] soroban-sdk = "21.0.0" subtrackr-types = { path = "../types" } +subtrackr-oracle = { path = "../oracle" } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils"] } diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 75ccad9..de3b336 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -2,9 +2,10 @@ mod gas_profiler; mod gas_storage; mod gas_optimization; -use soroban_sdk::{token, Address, Env, IntoVal, String, TryFromVal, Val, Vec}; +use soroban_sdk::{token, Address, Env, IntoVal, String, Symbol, TryFromVal, Val, Vec}; +use subtrackr_oracle::{SubTrackrOracleClient, OracleError}; use subtrackr_types::{ - Interval, Invoice, Plan, StorageKey, Subscription, SubscriptionStatus, TimeRange, + Interval, Invoice, Plan, PriceBounds, StorageKey, Subscription, SubscriptionStatus, TimeRange, }; /// Billing interval in seconds. @@ -185,6 +186,65 @@ fn invoice_contract(env: &Env, storage: &Address) -> Option
{ storage_instance_get(env, storage, StorageKey::InvoiceContract) } +fn resolve_charge_price(env: &Env, storage: &Address, plan: &Plan) -> i128 { + let oracle_opt: Option
= + storage_instance_get(env, storage, StorageKey::OracleContract); + let bounds_opt: Option = + storage_persistent_get(env, storage, StorageKey::PriceBounds(plan.id)); + + if oracle_opt.is_none() || bounds_opt.is_none() { + return plan.price; + } + + let oracle = oracle_opt.unwrap(); + let bounds = bounds_opt.unwrap(); + + let token_sym_opt: Option = + storage_instance_get(env, storage, StorageKey::TokenSymbol(plan.token.clone())); + + if token_sym_opt.is_none() { + return plan.price; + } + + let token_sym = token_sym_opt.unwrap(); + let quote_sym = Symbol::new(env, &string_to_symbol_str(env, &bounds.quote)); + + let client = SubTrackrOracleClient::new(env, &oracle); + + if let Ok(price) = client.try_get_price_with_cache(&token_sym, "e_sym, &600) { + let oracle_value = price.value; + if oracle_value <= 0 { + return plan.price; + } + + let max_price = (plan.price as u128) + .saturating_mul(bounds.max_price_bps as u128) + / 10_000; + let min_price = (plan.price as u128) + .saturating_mul(bounds.min_price_bps as u128) + / 10_000; + + if oracle_value > max_price as i128 { + max_price as i128 + } else if oracle_value < min_price as i128 { + min_price as i128 + } else { + oracle_value + } + } else { + plan.price + } +} + +fn string_to_symbol_str(env: &Env, s: &String) -> soroban_sdk::Vec { + let bytes = s.as_bytes(); + let mut result: soroban_sdk::Vec = soroban_sdk::Vec::new(env); + for i in 0..bytes.len() { + result.push_back(bytes.get(i).unwrap()); + } + result +} + // ───────────────────────────────────────────────────────────────────────────── // Implementation Contract // ───────────────────────────────────────────────────────────────────────────── @@ -273,6 +333,115 @@ impl SubTrackrSubscription { storage_instance_remove(&env, &storage, StorageKey::InvoiceContract); } + // ── Oracle Integration ── + + pub fn set_oracle_contract(env: Env, proxy: Address, storage: Address, oracle: Address) { + proxy.require_auth(); + let admin = get_admin(&env, &storage); + admin.require_auth(); + storage_instance_set(&env, &storage, StorageKey::OracleContract, oracle); + } + + pub fn clear_oracle_contract(env: Env, proxy: Address, storage: Address) { + proxy.require_auth(); + let admin = get_admin(&env, &storage); + admin.require_auth(); + storage_instance_remove(&env, &storage, StorageKey::OracleContract); + } + + pub fn get_oracle_contract(env: Env, proxy: Address, storage: Address) -> Option
{ + proxy.require_auth(); + storage_instance_get(&env, &storage, StorageKey::OracleContract) + } + + /// Set slippage protection bounds for a plan. When set, `charge_subscription` + /// will verify the oracle price against these bounds before executing payment. + pub fn set_price_bounds( + env: Env, + proxy: Address, + storage: Address, + merchant: Address, + plan_id: u64, + bounds: PriceBounds, + ) { + proxy.require_auth(); + merchant.require_auth(); + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(plan_id)) + .expect("Plan not found"); + assert!(plan.merchant == merchant, "Only plan owner can set bounds"); + assert!( + bounds.max_price_bps >= bounds.min_price_bps, + "Max must be >= min" + ); + assert!(bounds.max_price_bps > 0, "Max must be positive"); + storage_persistent_set( + &env, + &storage, + StorageKey::PriceBounds(plan_id), + bounds, + ); + } + + pub fn clear_price_bounds(env: Env, proxy: Address, storage: Address, merchant: Address, plan_id: u64) { + proxy.require_auth(); + merchant.require_auth(); + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(plan_id)) + .expect("Plan not found"); + assert!(plan.merchant == merchant, "Only plan owner can clear bounds"); + storage_persistent_remove(&env, &storage, StorageKey::PriceBounds(plan_id)); + } + + pub fn get_price_bounds(env: Env, proxy: Address, storage: Address, plan_id: u64) -> Option { + proxy.require_auth(); + storage_persistent_get(&env, &storage, StorageKey::PriceBounds(plan_id)) + } + + /// Look up the current oracle price for a token/quote pair, using cached read. + pub fn get_oracle_price( + env: Env, + proxy: Address, + storage: Address, + token: Symbol, + quote: Symbol, + ttl: u64, + ) -> Result { + proxy.require_auth(); + let oracle: Address = storage_instance_get(&env, &storage, StorageKey::OracleContract) + .expect("Oracle contract not set"); + let client = SubTrackrOracleClient::new(&env, &oracle); + let price = client.get_price_with_cache(&token, "e, &ttl); + Ok(price.value) + } + + /// Register the symbol name for a token address so the oracle can look it up. + pub fn set_token_symbol( + env: Env, + proxy: Address, + storage: Address, + admin: Address, + token: Address, + symbol: Symbol, + ) { + proxy.require_auth(); + admin.require_auth(); + let stored_admin = get_admin(&env, &storage); + assert!(admin == stored_admin, "Only admin can set token symbols"); + storage_instance_set(&env, &storage, StorageKey::TokenSymbol(token), symbol); + } + + pub fn remove_token_symbol(env: Env, proxy: Address, storage: Address, admin: Address, token: Address) { + proxy.require_auth(); + admin.require_auth(); + let stored_admin = get_admin(&env, &storage); + assert!(admin == stored_admin, "Only admin can remove token symbols"); + storage_instance_remove(&env, &storage, StorageKey::TokenSymbol(token)); + } + + pub fn get_token_symbol(env: Env, proxy: Address, storage: Address, token: Address) -> Option { + proxy.require_auth(); + storage_instance_get(&env, &storage, StorageKey::TokenSymbol(token)) + } + // ── Rate Limiting Admin ── pub fn set_rate_limit( @@ -646,15 +815,17 @@ impl SubTrackrSubscription { let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) .expect("Plan not found"); + let charge_price = Self::resolve_charge_price(&env, &storage, &plan); + token::Client::new(&env, &plan.token).transfer( &sub.subscriber, &plan.merchant, - &plan.price, + &charge_price, ); sub.last_charged_at = now; sub.next_charge_at = now + plan.interval.seconds(); - sub.total_paid += plan.price; + sub.total_paid += charge_price; sub.total_gas_spent += 100_000; sub.charge_count += 1; @@ -671,11 +842,11 @@ impl SubTrackrSubscription { &storage, subscription_id, sub.plan_id, - plan.price, + charge_price, now, plan.interval.seconds(), ); - revenue::update_merchant_revenue_balances(&env, &storage, &plan.merchant, 0, plan.price); + revenue::update_merchant_revenue_balances(&env, &storage, &plan.merchant, 0, charge_price); revenue::track_merchant_subscription(&env, &storage, &plan.merchant, subscription_id); env.events().publish( @@ -683,7 +854,7 @@ impl SubTrackrSubscription { String::from_str(&env, "subscription_charged"), subscription_id, ), - (sub.subscriber.clone(), plan.price, 100_000u64, now), + (sub.subscriber.clone(), charge_price, 100_000u64, now), ); if let Some(invoice_addr) = invoice_contract(&env, &storage) { diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 86daa83..9a324e2 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -360,4 +360,24 @@ pub enum StorageKey { PlanQuotas(u64), /// Usage record for a subscription and metric (sub_id, metric -> UsageRecord) SubscriptionUsage(u64, QuotaMetric), + + // ── Added in storage version 5 (Oracle Integration) ── + /// Address of the oracle contract for price feeds. + OracleContract, + /// Price bounds for slippage protection, keyed by plan_id. + PriceBounds(u64), + /// Mapping from token address to symbol name (for oracle lookups). + TokenSymbol(Address), +} + +/// Slippage protection bounds for oracle-based pricing. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PriceBounds { + /// Maximum allowed price as basis points of the stored plan price (e.g. 10500 = +5%). + pub max_price_bps: u32, + /// Minimum allowed price as basis points of the stored plan price (e.g. 9500 = -5%). + pub min_price_bps: u32, + /// Quote currency symbol used for price lookup (e.g. "USD"). + pub quote: String, } diff --git a/src/services/oraclePriceService.ts b/src/services/oraclePriceService.ts new file mode 100644 index 0000000..4bf2f3b --- /dev/null +++ b/src/services/oraclePriceService.ts @@ -0,0 +1,77 @@ +import { Subscription } from '../types/subscription'; + +interface OraclePrice { + token: string; + quote: string; + price: number; + decimals: number; + timestamp: number; + source: 'primary' | 'fallback'; +} + +interface CachedPrice { + price: number; + fetchedAt: number; + ttl: number; +} + +export class OraclePriceService { + private cache = new Map(); + private defaultTtlMs = 600_000; + private baseUrl: string; + + constructor(baseUrl = '/api/oracle') { + this.baseUrl = baseUrl; + } + + async getFiatPrice(token: string, quote = 'USD'): Promise { + const cacheKey = `${token}:${quote}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < cached.ttl) { + return { token, quote, price: cached.price, decimals: 7, timestamp: cached.fetchedAt, source: 'primary' }; + } + + try { + const response = await fetch(`${this.baseUrl}/price/${token}/${quote}`); + if (!response.ok) return null; + const data: OraclePrice = await response.json(); + this.cache.set(cacheKey, { price: data.price, fetchedAt: Date.now(), ttl: this.defaultTtlMs }); + return data; + } catch { + return null; + } + } + + async enrichSubscriptionWithFiat(subscription: Subscription): Promise { + if (!subscription.isCryptoEnabled || !subscription.cryptoToken) { + return subscription; + } + + const oraclePrice = await this.getFiatPrice(subscription.cryptoToken); + if (!oraclePrice) return subscription; + + const cryptoAmount = subscription.cryptoAmount ?? subscription.price; + const fiatPrice = (cryptoAmount * oraclePrice.price) / 10 ** oraclePrice.decimals; + const deviationBps = subscription.price > 0 + ? Math.abs(Math.round(((fiatPrice - subscription.price) / subscription.price) * 10000)) + : 0; + + return { + ...subscription, + fiatPrice, + fiatCurrency: oraclePrice.quote, + fiatPriceUpdatedAt: new Date(oraclePrice.timestamp * 1000), + oraclePriceDeviationBps: deviationBps, + }; + } + + async enrichSubscriptionsWithFiat(subscriptions: Subscription[]): Promise { + return Promise.all(subscriptions.map((sub) => this.enrichSubscriptionWithFiat(sub))); + } + + clearCache(): void { + this.cache.clear(); + } +} + +export const oraclePriceService = new OraclePriceService(); diff --git a/src/types/subscription.ts b/src/types/subscription.ts index bb5862c..bce477f 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -18,6 +18,11 @@ export interface Subscription { totalGasSpent?: number; chargeCount?: number; lastGasCost?: number; + /** Oracle-sourced fiat equivalent price for display purposes */ + fiatPrice?: number; + fiatCurrency?: string; + fiatPriceUpdatedAt?: Date; + oraclePriceDeviationBps?: number; createdAt: Date; updatedAt: Date; } @@ -80,4 +85,6 @@ export interface SubscriptionStats { totalYearlySpend: number; categoryBreakdown: Record; totalGasSpent?: number; + totalFiatMonthlySpend?: number; + fiatCurrency?: string; }