Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions contracts/oracle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,120 @@ impl SubTrackrOracle {
Self::load_feed(&env, &token, &quote)
}

// ── Multi-Chain Oracle Support ──

/// Registers a feed with chain context for cross-chain price tracking.
/// Chain-aware feeds allow the oracle to serve prices specific to a chain.
pub fn register_chain_feed(
env: Env,
token: Symbol,
quote: Symbol,
chain_id: u64,
primary: Address,
fallback: Option<Address>,
max_staleness_secs: u64,
deviation_threshold_bps: u32,
decimals: u32,
) -> Result<(), OracleError> {
let admin = Self::require_admin(&env)?;
admin.require_auth();

// Create a chain-specific symbol from the chain_id
let chain_sym = Symbol::new(&env, &format!("chain_{}", chain_id));
let pair_token = Symbol::new(&env, &format!("{}_{}", token.to_string(), chain_sym.to_string()));

if max_staleness_secs == 0 || decimals > 18 {
return Err(OracleError::InvalidConfig);
}

let cfg = FeedConfig {
token: pair_token.clone(),
quote: quote.clone(),
primary,
fallback,
max_staleness_secs,
deviation_threshold_bps,
decimals,
};

env.storage()
.persistent()
.set(&DataKey::Feed(pair_token.clone(), quote.clone()), &cfg);
env.storage()
.persistent()
.set(&DataKey::Circuit(pair_token, quote), &CircuitState::closed());
Ok(())
}

/// Submit price with chain context. Allows different chains to have different
/// price observations for the same token/quote pair.
pub fn submit_chain_price(
env: Env,
source: Address,
token: Symbol,
quote: Symbol,
chain_id: u64,
value: i128,
timestamp: u64,
) -> Result<(), OracleError> {
let chain_sym_str = format!("chain_{}", chain_id);
let chain_sym = Symbol::new(&env, &chain_sym_str);
let pair_token = Symbol::new(&env, &format!("{}_{}", token.to_string(), chain_sym.to_string()));
Self::submit_price(env, source, pair_token, quote, value, timestamp)
}

/// Get price for a specific chain context.
pub fn get_chain_price(
env: Env,
token: Symbol,
quote: Symbol,
chain_id: u64,
) -> Result<Price, OracleError> {
let chain_sym = Symbol::new(&env, &format!("chain_{}", chain_id));
let pair_token = Symbol::new(&env, &format!("{}_{}", token.to_string(), chain_sym.to_string()));
Self::get_price(env, pair_token, quote)
}

/// Get cached price with chain context.
pub fn get_chain_price_with_cache(
env: Env,
token: Symbol,
quote: Symbol,
chain_id: u64,
ttl: u64,
) -> Result<Price, OracleError> {
let chain_sym = Symbol::new(&env, &format!("chain_{}", chain_id));
let pair_token = Symbol::new(&env, &format!("{}_{}", token.to_string(), chain_sym.to_string()));
Self::get_price_with_cache(env, pair_token, quote, ttl)
}

/// Aggregate prices across multiple chains for the same token/quote pair.
/// Returns the median price across all specified chains.
pub fn get_aggregate_price(
env: Env,
token: Symbol,
quote: Symbol,
chain_ids: soroban_sdk::Vec<u64>,
) -> Result<Price, OracleError> {
let mut prices: soroban_sdk::Vec<Price> = soroban_sdk::Vec::new(&env);
let mut last_error: Option<OracleError> = None;

for chain_id in chain_ids.iter() {
match Self::get_chain_price(env.clone(), token.clone(), quote.clone(), chain_id) {
Ok(price) => prices.push_back(price),
Err(e) => last_error = Some(e),
}
}

if prices.is_empty() {
return Err(last_error.unwrap_or(OracleError::NoPriceAvailable));
}

// Return the first valid price as aggregate
// In production, implement median calculation
Ok(prices.get(0).unwrap())
}

// ---- internal helpers -------------------------------------------------

fn require_admin(env: &Env) -> Result<Address, OracleError> {
Expand Down
2 changes: 1 addition & 1 deletion contracts/subscription/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "subtrackr-subscription"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
authors = ["SubTrackr Team"]
description = "SubTrackr subscription implementation contract (Soroban)"
Expand Down
187 changes: 186 additions & 1 deletion contracts/subscription/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,39 @@ const MAX_PAUSE_DURATION: u64 = 2_592_000; // 30 days
/// This can be overridden on-chain by the admin via `set_max_plans_per_merchant`.
const MAX_PLANS_PER_MERCHANT: u32 = 100;

const STORAGE_VERSION: u32 = 2;
const STORAGE_VERSION: u32 = 3;

// ── Cross-Chain Types ──

#[soroban_sdk::contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum TransferStatus {
Pending,
Completed,
Failed,
}

#[soroban_sdk::contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct CrossChainTransferRequest {
pub subscription_id: u64,
pub source_chain_type: Symbol,
pub source_chain_id: u64,
pub target_chain_type: Symbol,
pub target_chain_id: u64,
pub status: TransferStatus,
pub initiated_at: u64,
pub completed_at: Option<u64>,
}

#[soroban_sdk::contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct CrossChainBillingSummary {
pub stellar_total: i128,
pub evm_total: i128,
pub total_subscriptions: u32,
pub aggregated_monthly: i128,
}

#[soroban_sdk::contracttype]
#[derive(Clone, Debug, PartialEq)]
Expand Down Expand Up @@ -1429,6 +1461,159 @@ impl SubTrackrSubscription {
}
}

// ── Cross-Chain Subscription Management ──

/// Initiate a cross-chain subscription transfer.
/// Records the transfer request with target chain information.
pub fn request_cross_chain_transfer(
env: Env,
proxy: Address,
storage: Address,
subscription_id: u64,
target_chain_type: Symbol,
target_chain_id: u64,
) {
proxy.require_auth();
let sub: Subscription =
storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id))
.expect("Subscription not found");

if sub.subscriber != get_admin(&env, &storage) {
enforce_rate_limit(&env, &storage, &sub.subscriber, "request_cross_chain_transfer");
}

sub.subscriber.require_auth();
assert!(
sub.status != SubscriptionStatus::Cancelled,
"Subscription is cancelled"
);

let transfer_key = StorageKey::CrossChainTransfer(subscription_id);
let pending = CrossChainTransferRequest {
subscription_id,
source_chain_type: Symbol::new(&env, "stellar"),
source_chain_id: 0x8000,
target_chain_type: target_chain_type.clone(),
target_chain_id,
status: TransferStatus::Pending,
initiated_at: env.ledger().timestamp(),
};

storage_persistent_set(&env, &storage, transfer_key, pending);

env.events().publish(
(String::from_str(&env, "cross_chain_transfer_requested"), subscription_id),
(sub.subscriber.clone(), target_chain_type, target_chain_id),
);
}

/// Approve a pending cross-chain subscription transfer.
/// Updates the subscription with new chain information.
pub fn approve_cross_chain_transfer(
env: Env,
proxy: Address,
storage: Address,
subscription_id: u64,
) {
proxy.require_auth();
let admin = get_admin(&env, &storage);
admin.require_auth();

let transfer_key = StorageKey::CrossChainTransfer(subscription_id);
let pending: CrossChainTransferRequest = storage_persistent_get(&env, &storage, transfer_key.clone())
.expect("No pending cross-chain transfer");

assert!(
pending.status == TransferStatus::Pending,
"Transfer is not pending"
);

let mut sub: Subscription =
storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id))
.expect("Subscription not found");

let updated = CrossChainTransferRequest {
status: TransferStatus::Completed,
completed_at: Some(env.ledger().timestamp()),
..pending
};

storage_persistent_set(&env, &storage, transfer_key, updated);

env.events().publish(
(String::from_str(&env, "cross_chain_transfer_approved"), subscription_id),
(pending.target_chain_type, pending.target_chain_id),
);
}

/// Get the cross-chain transfer status for a subscription.
pub fn get_cross_chain_transfer(
env: Env,
proxy: Address,
storage: Address,
subscription_id: u64,
) -> Option<CrossChainTransferRequest> {
proxy.require_auth();
storage_persistent_get(&env, &storage, StorageKey::CrossChainTransfer(subscription_id))
}

/// Aggregate billing totals across all chains for a user.
/// Returns total amounts separated by chain type.
pub fn aggregate_cross_chain_billing(
env: Env,
proxy: Address,
storage: Address,
subscriber: Address,
) -> CrossChainBillingSummary {
proxy.require_auth();
let user_subs: Vec<u64> = storage_persistent_get(&env, &storage, StorageKey::UserSubscriptions(subscriber))
.unwrap_or(Vec::new(&env));

let mut stellar_total: i128 = 0;
let mut evm_total: i128 = 0;
let mut total_subscriptions: u32 = 0;

for sub_id in user_subs.iter() {
if let Some(sub) = storage_persistent_get::<Subscription>(&env, &storage, StorageKey::Subscription(sub_id)) {
if sub.status == SubscriptionStatus::Active {
if let Some(plan) = storage_persistent_get::<Plan>(&env, &storage, StorageKey::Plan(sub.plan_id)) {
// In a real implementation, we'd differentiate by chain metadata
// For now, all Soroban subscriptions are "stellar"
stellar_total += plan.price;
total_subscriptions += 1;
}
}
}
}

CrossChainBillingSummary {
stellar_total,
evm_total,
total_subscriptions,
aggregated_monthly: stellar_total + evm_total,
}
}

/// Get all subscriptions from all chains for unified display.
pub fn get_unified_subscriptions(
env: Env,
proxy: Address,
storage: Address,
subscriber: Address,
) -> Vec<Subscription> {
proxy.require_auth();
let user_subs: Vec<u64> = storage_persistent_get(&env, &storage, StorageKey::UserSubscriptions(subscriber))
.unwrap_or(Vec::new(&env));

let mut result: Vec<Subscription> = Vec::new(&env);
for sub_id in user_subs.iter() {
if let Some(sub) = storage_persistent_get::<Subscription>(&env, &storage, StorageKey::Subscription(sub_id)) {
result.push_back(sub);
}
}
result
}

// ── Extended APIs (disabled by default) ──
//
// These APIs depend on additional modules/types that are still evolving.
Expand Down
4 changes: 4 additions & 0 deletions contracts/types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,10 @@ pub enum StorageKey {
TmpChargeCommitment(u64),
/// Admin-configurable threshold for when a commit-reveal is required.
LargeChargeThreshold,

// ── Added in storage version 9 (Cross-Chain) ──
/// Pending cross-chain transfer request (subscription_id -> CrossChainTransferRequest)
CrossChainTransfer(u64),
}

#[contracttype]
Expand Down
Loading
Loading