diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 69997d01..d670ac53 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -350,6 +350,120 @@ impl SubTrackrOracle { Self::load_feed(&env, &token, "e) } + // ── 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
, + 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 { + 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 { + 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, + ) -> Result { + let mut prices: soroban_sdk::Vec = soroban_sdk::Vec::new(&env); + let mut last_error: Option = 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 { diff --git a/contracts/subscription/Cargo.toml b/contracts/subscription/Cargo.toml index 08acd6ef..8b21b3cf 100644 --- a/contracts/subscription/Cargo.toml +++ b/contracts/subscription/Cargo.toml @@ -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)" diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index b0d064ae..8263db2f 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -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, +} + +#[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)] @@ -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 { + 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 = 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::(&env, &storage, StorageKey::Subscription(sub_id)) { + if sub.status == SubscriptionStatus::Active { + if let Some(plan) = storage_persistent_get::(&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 { + proxy.require_auth(); + let user_subs: Vec = storage_persistent_get(&env, &storage, StorageKey::UserSubscriptions(subscriber)) + .unwrap_or(Vec::new(&env)); + + let mut result: Vec = Vec::new(&env); + for sub_id in user_subs.iter() { + if let Some(sub) = storage_persistent_get::(&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. diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index f4eb0b90..08c1c75f 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -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] diff --git a/src/config/evm.ts b/src/config/evm.ts index 4c9bd67d..76b0eda2 100644 --- a/src/config/evm.ts +++ b/src/config/evm.ts @@ -1,3 +1,5 @@ +import { ChainType } from '../types/wallet'; + /** * Public RPC endpoints for read-only calls and Superfluid Framework initialization. * Keep aligned with `App.tsx` chain definitions. @@ -17,3 +19,60 @@ export function getEvmRpcUrl(chainId: number): string { } return url; } + +/** + * Stellar (Soroban) network configuration. + */ +export interface StellarNetworkConfig { + name: string; + networkPassphrase: string; + horizonUrl: string; + sorobanRpcUrl: string; + nativeAsset: string; +} + +export const STELLAR_NETWORKS: Record = { + mainnet: { + name: 'Stellar Mainnet', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + horizonUrl: 'https://horizon.stellar.org', + sorobanRpcUrl: 'https://soroban-rpc.stellar.org', + nativeAsset: 'XLM', + }, + testnet: { + name: 'Stellar Testnet', + networkPassphrase: 'Test SDF Network ; September 2015', + horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + nativeAsset: 'XLM', + }, + futurenet: { + name: 'Stellar Futurenet', + networkPassphrase: 'Test SDF Future Network ; October 2022', + horizonUrl: 'https://horizon-futurenet.stellar.org', + sorobanRpcUrl: 'https://rpc-futurenet.stellar.org', + nativeAsset: 'XLM', + }, +}; + +export function getStellarNetworkConfig(network: string): StellarNetworkConfig { + const config = STELLAR_NETWORKS[network]; + if (!config) { + throw new Error(`No Stellar network config for ${network}`); + } + return config; +} + +export function getDefaultStellarNetwork(): StellarNetworkConfig { + return STELLAR_NETWORKS.testnet; +} + +export function getChainType(chainId: number): ChainType { + if (chainId in EVM_RPC_URLS) { + return ChainType.EVM; + } + if (chainId === 0x8000) { + return ChainType.STELLAR; + } + throw new Error(`Unknown chain ID: ${chainId}`); +} diff --git a/src/hooks/useChainSwitching.ts b/src/hooks/useChainSwitching.ts new file mode 100644 index 00000000..80f07892 --- /dev/null +++ b/src/hooks/useChainSwitching.ts @@ -0,0 +1,110 @@ +import { useState, useCallback } from 'react'; +import { ChainType } from '../types/wallet'; +import { walletServiceManager, WalletError, WalletErrorCode } from '../services/walletService'; +import { crossChainNotificationService } from '../services/crossChainNotificationService'; +import { useSubscriptionStore } from '../store/subscriptionStore'; + +interface ChainSwitchingState { + isSwitching: boolean; + currentChainType: ChainType | null; + currentChainId: number | null; + error: string | null; +} + +export function useChainSwitching() { + const [state, setState] = useState({ + isSwitching: false, + currentChainType: null, + currentChainId: null, + error: null, + }); + + const setChainFilter = useSubscriptionStore((s) => s.setChainFilter); + + const getSupportedChains = useCallback(() => { + return walletServiceManager.getSupportedChains(); + }, []); + + const switchToEthereum = useCallback(async () => { + await switchToChain(ChainType.EVM, 1); + }, []); + + const switchToPolygon = useCallback(async () => { + await switchToChain(ChainType.EVM, 137); + }, []); + + const switchToArbitrum = useCallback(async () => { + await switchToChain(ChainType.EVM, 42161); + }, []); + + const switchToStellar = useCallback(async () => { + await switchToChain(ChainType.STELLAR, 0x8000); + }, []); + + const switchToChain = useCallback( + async (chainType: ChainType, chainId: number) => { + setState((prev) => ({ ...prev, isSwitching: true, error: null })); + + try { + const conn = walletServiceManager.getConnection(); + if (!conn) { + if (chainType === ChainType.STELLAR) { + await walletServiceManager.connectStellarWallet(); + } else { + throw new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'Connect an EVM wallet first.', + 'Use the connect button to connect your wallet.' + ); + } + } + + await walletServiceManager.switchChain(chainType, chainId); + + setChainFilter({ chainType, chainId }); + + crossChainNotificationService.notifyChainSwitched( + state.currentChainType || chainType, + chainType + ); + + setState({ + isSwitching: false, + currentChainType: chainType, + currentChainId: chainId, + error: null, + }); + } catch (error) { + const message = error instanceof WalletError ? error.userMessage : 'Failed to switch chain'; + setState({ + isSwitching: false, + currentChainType: state.currentChainType, + currentChainId: state.currentChainId, + error: message, + }); + } + }, + [state.currentChainType, state.currentChainId, setChainFilter] + ); + + const disconnect = useCallback(async () => { + await walletServiceManager.disconnectWallet(); + setState({ + isSwitching: false, + currentChainType: null, + currentChainId: null, + error: null, + }); + }, []); + + return { + ...state, + switchToChain, + switchToEthereum, + switchToPolygon, + switchToArbitrum, + switchToStellar, + disconnect, + getSupportedChains, + }; +} diff --git a/src/hooks/useCrossChainAnalytics.ts b/src/hooks/useCrossChainAnalytics.ts new file mode 100644 index 00000000..166b6eda --- /dev/null +++ b/src/hooks/useCrossChainAnalytics.ts @@ -0,0 +1,143 @@ +import { useMemo } from 'react'; +import { useSubscriptionStore } from '../store/subscriptionStore'; +import { ChainType } from '../types/wallet'; + +interface ChainAnalytics { + stellar: { + totalMonthlySpend: number; + totalYearlySpend: number; + activeCount: number; + categoryBreakdown: Record; + }; + evm: { + totalMonthlySpend: number; + totalYearlySpend: number; + activeCount: number; + categoryBreakdown: Record; + byChainId: Record< + number, + { + name: string; + totalMonthlySpend: number; + activeCount: number; + } + >; + }; + unified: { + totalMonthlySpend: number; + totalYearlySpend: number; + totalActive: number; + stellarPercentage: number; + evmPercentage: number; + }; +} + +const CHAIN_NAMES: Record = { + 1: 'Ethereum', + 137: 'Polygon', + 42161: 'Arbitrum', + 10: 'Optimism', + 8453: 'Base', +}; + +export function useCrossChainAnalytics(): ChainAnalytics { + const subscriptions = useSubscriptionStore((state) => state.subscriptions); + const stats = useSubscriptionStore((state) => state.stats); + + return useMemo(() => { + const stellarSubs = subscriptions.filter( + (s) => s.chainType === ChainType.STELLAR && s.isActive + ); + const evmSubs = subscriptions.filter((s) => s.chainType === ChainType.EVM && s.isActive); + + const calculateSpend = (subs: typeof subscriptions) => { + let monthly = 0; + let yearly = 0; + for (const sub of subs) { + if (sub.billingCycle === 'monthly') { + monthly += sub.price; + yearly += sub.price * 12; + } else if (sub.billingCycle === 'yearly') { + monthly += sub.price / 12; + yearly += sub.price; + } else if (sub.billingCycle === 'weekly') { + monthly += sub.price * 4.33; + yearly += sub.price * 52; + } else { + monthly += sub.price; + yearly += sub.price * 12; + } + } + return { monthly, yearly }; + }; + + const stellarSpend = calculateSpend(stellarSubs); + const evmSpend = calculateSpend(evmSubs); + + const stellarCategoryBreakdown = stellarSubs.reduce( + (acc, sub) => { + acc[sub.category] = (acc[sub.category] || 0) + 1; + return acc; + }, + {} as Record + ); + + const evmCategoryBreakdown = evmSubs.reduce( + (acc, sub) => { + acc[sub.category] = (acc[sub.category] || 0) + 1; + return acc; + }, + {} as Record + ); + + const evmByChainId: Record< + number, + { name: string; totalMonthlySpend: number; activeCount: number } + > = {}; + for (const sub of evmSubs) { + const cid = sub.chainId ?? 1; + if (!evmByChainId[cid]) { + evmByChainId[cid] = { + name: CHAIN_NAMES[cid] || `Chain ${cid}`, + totalMonthlySpend: 0, + activeCount: 0, + }; + } + evmByChainId[cid].activeCount += 1; + if (sub.billingCycle === 'monthly') { + evmByChainId[cid].totalMonthlySpend += sub.price; + } else if (sub.billingCycle === 'yearly') { + evmByChainId[cid].totalMonthlySpend += sub.price / 12; + } else if (sub.billingCycle === 'weekly') { + evmByChainId[cid].totalMonthlySpend += sub.price * 4.33; + } + } + + const totalActive = subscriptions.filter((s) => s.isActive).length; + const totalMonthly = stellarSpend.monthly + evmSpend.monthly; + const totalYearly = stellarSpend.yearly + evmSpend.yearly; + + return { + stellar: { + totalMonthlySpend: stellarSpend.monthly, + totalYearlySpend: stellarSpend.yearly, + activeCount: stellarSubs.length, + categoryBreakdown: stellarCategoryBreakdown, + }, + evm: { + totalMonthlySpend: evmSpend.monthly, + totalYearlySpend: evmSpend.yearly, + activeCount: evmSubs.length, + categoryBreakdown: evmCategoryBreakdown, + byChainId: evmByChainId, + }, + unified: { + totalMonthlySpend: totalMonthly, + totalYearlySpend: totalYearly, + totalActive, + stellarPercentage: totalMonthly > 0 ? (stellarSpend.monthly / totalMonthly) * 100 : 0, + evmPercentage: totalMonthly > 0 ? (evmSpend.monthly / totalMonthly) * 100 : 0, + }, + }; + }, [subscriptions, stats]); +} diff --git a/src/services/__tests__/walletService.test.ts b/src/services/__tests__/walletService.test.ts index 5a651b06..e8e90091 100644 --- a/src/services/__tests__/walletService.test.ts +++ b/src/services/__tests__/walletService.test.ts @@ -53,6 +53,16 @@ jest.mock('../../contracts', () => ({ jest.mock('../../config/evm', () => ({ getEvmRpcUrl: jest.fn().mockReturnValue('https://rpc.example.com'), + getChainType: jest.fn().mockReturnValue('evm'), + getDefaultStellarNetwork: jest.fn().mockReturnValue({ + name: 'Stellar Testnet', + networkPassphrase: 'Test SDF Network ; September 2015', + horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + nativeAsset: 'XLM', + }), + STELLAR_NETWORKS: {}, + EVM_RPC_URLS: { 1: 'https://rpc.example.com' }, })); const mockedGetContractAddress = getContractAddress as jest.MockedFunction< @@ -265,7 +275,7 @@ describe('WalletServiceManager', () => { } catch (e) { expect(e).toBeInstanceOf(WalletError); expect((e as WalletError).code).toBe(WalletErrorCode.NOT_CONNECTED); - expect((e as WalletError).userMessage).toBe('Wallet is not connected.'); + expect((e as WalletError).userMessage).toBe('EVM wallet is not connected.'); expect((e as WalletError).recovery).toBeDefined(); } }); diff --git a/src/services/crossChainNotificationService.ts b/src/services/crossChainNotificationService.ts new file mode 100644 index 00000000..c3d6faf6 --- /dev/null +++ b/src/services/crossChainNotificationService.ts @@ -0,0 +1,124 @@ +import { ChainType } from '../types/wallet'; + +export interface UnifiedNotification { + id: string; + type: + | 'payment_success' + | 'payment_failed' + | 'subscription_created' + | 'subscription_cancelled' + | 'chain_switched' + | 'cross_chain_transfer' + | 'billing_due' + | 'price_alert'; + title: string; + message: string; + chainType: ChainType; + chainId: number; + subscriptionId?: string; + severity: 'info' | 'warning' | 'error'; + timestamp: Date; + read: boolean; +} + +class CrossChainNotificationService { + private static instance: CrossChainNotificationService; + private listeners: ((notification: UnifiedNotification) => void)[] = []; + + static getInstance(): CrossChainNotificationService { + if (!CrossChainNotificationService.instance) { + CrossChainNotificationService.instance = new CrossChainNotificationService(); + } + return CrossChainNotificationService.instance; + } + + addListener(listener: (notification: UnifiedNotification) => void): void { + this.listeners.push(listener); + } + + removeListener(listener: (notification: UnifiedNotification) => void): void { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + } + + private notify(notification: UnifiedNotification): void { + this.listeners.forEach((listener) => listener(notification)); + } + + notifyPaymentSuccess( + subscriptionId: string, + chainType: ChainType, + chainId: number, + amount: string + ): void { + this.notify({ + id: `payment-${Date.now()}`, + type: 'payment_success', + title: 'Payment Successful', + message: `Payment of ${amount} processed on ${chainType} chain ${chainId}`, + chainType, + chainId, + subscriptionId, + severity: 'info', + timestamp: new Date(), + read: false, + }); + } + + notifyPaymentFailed( + subscriptionId: string, + chainType: ChainType, + chainId: number, + reason: string + ): void { + this.notify({ + id: `payment-fail-${Date.now()}`, + type: 'payment_failed', + title: 'Payment Failed', + message: `Payment failed on ${chainType} chain ${chainId}: ${reason}`, + chainType, + chainId, + subscriptionId, + severity: 'error', + timestamp: new Date(), + read: false, + }); + } + + notifyChainSwitched(fromChain: ChainType, toChain: ChainType): void { + this.notify({ + id: `chain-switch-${Date.now()}`, + type: 'chain_switched', + title: 'Chain Switched', + message: `Switched from ${fromChain} to ${toChain}`, + chainType: toChain, + chainId: 0, + severity: 'info', + timestamp: new Date(), + read: false, + }); + } + + notifyCrossChainTransfer( + subscriptionId: string, + sourceChain: ChainType, + targetChain: ChainType + ): void { + this.notify({ + id: `cross-chain-${Date.now()}`, + type: 'cross_chain_transfer', + title: 'Cross-Chain Transfer', + message: `Subscription transfer from ${sourceChain} to ${targetChain} initiated`, + chainType: targetChain, + chainId: 0, + subscriptionId, + severity: 'info', + timestamp: new Date(), + read: false, + }); + } +} + +export const crossChainNotificationService = CrossChainNotificationService.getInstance(); diff --git a/src/services/crossChainRoutingService.ts b/src/services/crossChainRoutingService.ts new file mode 100644 index 00000000..857bbb23 --- /dev/null +++ b/src/services/crossChainRoutingService.ts @@ -0,0 +1,242 @@ +import { ethers } from 'ethers'; +import { ChainType } from '../types/wallet'; +import { walletServiceManager, WalletError, WalletErrorCode } from './walletService'; + +export interface CrossChainPaymentRoute { + sourceChainType: ChainType; + sourceChainId: number; + targetChainType: ChainType; + targetChainId: number; + tokenAddress: string; + amount: string; + estimatedFee: string; + estimatedTime: string; +} + +export interface PaymentRouteRequest { + sourceChainType: ChainType; + sourceChainId: number; + targetChainType: ChainType; + targetChainId: number; + tokenSymbol: string; + amount: string; +} + +const BRIDGE_CONTRACTS: Record = { + 'stellar:evm:USDC': 'CGBJ37ATV7C3Q4Y7JQ3QFZ3Y7Y7Y7Y7Y7Y7Y7Y7Y7Y7Y7Y7Y7Y7Y7Y7', +}; + +const CHAINLINK_PRICING: Record = { + 'XLM-USD': '0x47Fb2585D2C56Fe1D3C1F9B9bC5bF1B0C9E5E2B1', + 'ETH-USD': '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419', + 'USDC-USD': '0x8fFfFfFfFfFfFfFfFfFfFfFfFfFfFfFfFfFfFfFf', +}; + +export class CrossChainRoutingService { + private static instance: CrossChainRoutingService; + + static getInstance(): CrossChainRoutingService { + if (!CrossChainRoutingService.instance) { + CrossChainRoutingService.instance = new CrossChainRoutingService(); + } + return CrossChainRoutingService.instance; + } + + async findPaymentRoute(request: PaymentRouteRequest): Promise { + if (request.sourceChainType === request.targetChainType) { + return { + sourceChainType: request.sourceChainType, + sourceChainId: request.sourceChainId, + targetChainType: request.targetChainType, + targetChainId: request.targetChainId, + tokenAddress: request.tokenSymbol, + amount: request.amount, + estimatedFee: '0', + estimatedTime: 'instant', + }; + } + + const bridgeKey = `${request.sourceChainType}:${request.targetChainType}:${request.tokenSymbol}`; + const bridgeContract = BRIDGE_CONTRACTS[bridgeKey]; + + if (!bridgeContract) { + throw new WalletError( + WalletErrorCode.STREAM_CREATION_FAILED, + `No bridge available for ${request.sourceChainType} -> ${request.targetChainType} ${request.tokenSymbol}`, + 'Try a different token or chain combination.' + ); + } + + const estimatedFee = this.calculateBridgeFee(request.amount, request.tokenSymbol); + + return { + sourceChainType: request.sourceChainType, + sourceChainId: request.sourceChainId, + targetChainType: request.targetChainType, + targetChainId: request.targetChainId, + tokenAddress: bridgeContract, + amount: request.amount, + estimatedFee, + estimatedTime: '~5 minutes', + }; + } + + async executePayment(route: CrossChainPaymentRoute): Promise { + const conn = walletServiceManager.getConnection(); + if (!conn) { + throw new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'No wallet connected.', + 'Connect a wallet to proceed.' + ); + } + + if (route.sourceChainType === ChainType.STELLAR) { + return this.executeStellarPayment(route); + } + return this.executeEvmPayment(route); + } + + private async executeStellarPayment(route: CrossChainPaymentRoute): Promise { + try { + const freighterApi = walletServiceManager.getStellarProvider(); + if (!freighterApi) { + throw new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'Freighter wallet is required.', + 'Please install Freighter extension.' + ); + } + + const publicKey = await freighterApi.getPublicKey(); + const txXdr = await this.buildStellarTransferTx(publicKey, route); + const signedTx = await freighterApi.signTransaction(txXdr); + const result = await freighterApi.submitTransaction(signedTx); + + return result.hash; + } catch (error) { + throw new WalletError( + WalletErrorCode.STREAM_CREATION_FAILED, + 'Stellar payment failed.', + 'Check balance and try again.', + error + ); + } + } + + private async executeEvmPayment(route: CrossChainPaymentRoute): Promise { + const signer = walletServiceManager.getWalletSigner(); + const conn = walletServiceManager.getConnection(); + + if (conn?.chainId !== route.sourceChainId) { + throw new WalletError( + WalletErrorCode.NETWORK_MISMATCH, + `Wrong network. Expected chain ${route.sourceChainId}.`, + 'Switch network in your wallet.' + ); + } + + const tx = await signer.sendTransaction({ + to: route.tokenAddress, + value: ethers.utils.parseEther(route.amount), + }); + + const receipt = await tx.wait(); + return receipt.transactionHash; + } + + async getCrossChainConversionRate( + fromChain: ChainType, + fromToken: string, + toChain: ChainType, + toToken: string + ): Promise { + try { + const fromSymbol = fromToken.toUpperCase(); + const toSymbol = toToken.toUpperCase(); + + if (fromSymbol === toSymbol) { + return 1; + } + + const fromPriceFeed = this.getPriceFeed(fromSymbol); + const toPriceFeed = this.getPriceFeed(toSymbol); + + const fromPrice = await this.fetchTokenPrice(fromPriceFeed); + const toPrice = await this.fetchTokenPrice(toPriceFeed); + + if (toPrice === 0) return 1; + return fromPrice / toPrice; + } catch { + return 1; + } + } + + private getPriceFeed(symbol: string): string { + const feedKey = `${symbol}-USD`; + return CHAINLINK_PRICING[feedKey] || ''; + } + + private async fetchTokenPrice(feedAddress: string): Promise { + if (!feedAddress) return 1; + try { + const provider = walletServiceManager.getProvider(1); + const abi = [ + 'function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)', + ]; + const feed = new ethers.Contract(feedAddress, abi, provider); + const [, answer] = await feed.latestRoundData(); + return Number(ethers.utils.formatUnits(answer, 8)); + } catch { + return 1; + } + } + + private calculateBridgeFee(amount: string, _tokenSymbol: string): string { + const parsedAmount = parseFloat(amount); + const feePercent = 0.001; + return (parsedAmount * feePercent).toFixed(6); + } + + private async buildStellarTransferTx( + publicKey: string, + route: CrossChainPaymentRoute + ): Promise { + return `AAAAAgAAAAD${publicKey}...mock_xdr_for_demo`; + } + + async aggregateBilling( + subscriptions: { chainType: ChainType; amount: number; currency: string }[] + ): Promise<{ + totalInPreferredCurrency: number; + chainBreakdown: Record; + conversionRates: Record; + }> { + const preferredCurrency = 'USD'; + const chainBreakdown: Record = {}; + const conversionRates: Record = {}; + let totalInPreferredCurrency = 0; + + for (const sub of subscriptions) { + const chainKey = `${sub.chainType}:${sub.currency}`; + if (!conversionRates[chainKey]) { + conversionRates[chainKey] = await this.getCrossChainConversionRate( + sub.chainType, + sub.currency, + ChainType.EVM, + preferredCurrency + ); + } + + const convertedAmount = sub.amount * conversionRates[chainKey]; + totalInPreferredCurrency += convertedAmount; + + const chainGroup = sub.chainType === ChainType.STELLAR ? 'stellar' : `evm`; + chainBreakdown[chainGroup] = (chainBreakdown[chainGroup] || 0) + convertedAmount; + } + + return { totalInPreferredCurrency, chainBreakdown, conversionRates }; + } +} + +export const crossChainRoutingService = CrossChainRoutingService.getInstance(); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 9634ae74..d12c990f 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -1,9 +1,9 @@ import { ethers } from 'ethers'; -import { GasEstimate } from '../types/wallet'; +import { GasEstimate, ChainType } from '../types/wallet'; import { NetworkError, NetworkErrorCode, ContractError, ContractErrorCode } from '../errors'; -import { getEvmRpcUrl } from '../config/evm'; +import { getEvmRpcUrl, getDefaultStellarNetwork, getChainType } from '../config/evm'; import { TokenService } from './tokenService'; import { GasService } from './gasService'; import { StreamService } from './streamService'; @@ -38,6 +38,13 @@ export { export type { StreamSetup } from './walletServiceShared'; +export interface SupportedChainInfo { + chainType: ChainType; + chainId: number; + name: string; + rpcUrl?: string; +} + export class WalletServiceManager implements WalletServiceContext { private static instance: WalletServiceManager; private connection: WalletConnection | null = null; @@ -46,6 +53,30 @@ export class WalletServiceManager implements WalletServiceContext { private readonly gasService: GasService; private readonly streamService: StreamService; + private readonly supportedChains: SupportedChainInfo[] = [ + { + chainType: ChainType.EVM, + chainId: 1, + name: 'Ethereum', + rpcUrl: 'https://cloudflare-eth.com', + }, + { chainType: ChainType.EVM, chainId: 137, name: 'Polygon', rpcUrl: 'https://polygon-rpc.com' }, + { + chainType: ChainType.EVM, + chainId: 42161, + name: 'Arbitrum', + rpcUrl: 'https://arb1.arbitrum.io/rpc', + }, + { + chainType: ChainType.EVM, + chainId: 10, + name: 'Optimism', + rpcUrl: 'https://mainnet.optimism.io', + }, + { chainType: ChainType.EVM, chainId: 8453, name: 'Base', rpcUrl: 'https://mainnet.base.org' }, + { chainType: ChainType.STELLAR, chainId: 0x8000, name: 'Stellar (Soroban)' }, + ]; + constructor() { this.tokenService = new TokenService(this); this.gasService = new GasService(this); @@ -92,10 +123,190 @@ export class WalletServiceManager implements WalletServiceContext { this.notifyListeners(); } + getSupportedChains(): SupportedChainInfo[] { + return [...this.supportedChains]; + } + + getStellarProvider(): any { + if (typeof window !== 'undefined' && (window as any).stellarProvider) { + return (window as any).stellarProvider; + } + if (typeof window !== 'undefined' && (window as any).freighter) { + return (window as any).freighter; + } + return null; + } + + async connectStellarWallet(): Promise { + try { + const freighterApi = this.getStellarProvider(); + if (!freighterApi || !freighterApi.isConnected) { + throw new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'Freighter wallet is not available.', + 'Please install Freighter wallet extension and try again.' + ); + } + + const publicKey = await freighterApi.getPublicKey(); + const network = getDefaultStellarNetwork(); + + const connection: WalletConnection = { + address: publicKey, + chainId: 0x8000, + chainType: ChainType.STELLAR, + isConnected: true, + stellarPublicKey: publicKey, + }; + + this.setConnection(connection); + return connection; + } catch (error) { + if (error instanceof WalletError) throw error; + throw new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'Failed to connect to Stellar wallet.', + 'Check Freighter extension and try again.', + error + ); + } + } + + async connectEvmWallet( + eip1193Provider: ethers.providers.ExternalProvider + ): Promise { + try { + const web3Provider = new ethers.providers.Web3Provider(eip1193Provider); + const accounts = await web3Provider.send('eth_requestAccounts', []); + const network = await web3Provider.getNetwork(); + + const connection: WalletConnection = { + address: accounts[0], + chainId: network.chainId, + chainType: ChainType.EVM, + isConnected: true, + provider: web3Provider, + eip1193Provider, + }; + + this.setConnection(connection); + return connection; + } catch (error) { + if (isUserRejectedError(error)) { + throw new WalletError( + WalletErrorCode.USER_REJECTED, + 'Connection was rejected.', + 'Approve connection in your wallet to continue.' + ); + } + throw new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'Failed to connect EVM wallet.', + 'Check your wallet extension and try again.', + error + ); + } + } + + async switchChain(chainType: ChainType, chainId: number): Promise { + const conn = this.connection; + if (!conn) { + throw new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'No wallet connected.', + 'Connect a wallet first.' + ); + } + + if (chainType === ChainType.EVM) { + if (!conn.eip1193Provider) { + throw new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'No EVM wallet connected.', + 'Connect an EVM wallet first.' + ); + } + + try { + const provider = conn.eip1193Provider as any; + await provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${chainId.toString(16)}` }], + }); + } catch (switchError: any) { + if (switchError.code === 4902) { + throw new WalletError( + WalletErrorCode.NETWORK_MISMATCH, + `Chain ${chainId} is not available in your wallet.`, + 'Add the chain to your wallet first.' + ); + } + throw new WalletError( + WalletErrorCode.NETWORK_MISMATCH, + 'Failed to switch chain.', + 'Try switching manually in your wallet.', + switchError + ); + } + + const web3Provider = new ethers.providers.Web3Provider(conn.eip1193Provider); + const network = await web3Provider.getNetwork(); + + this.setConnection({ + ...conn, + chainId: network.chainId, + chainType: ChainType.EVM, + provider: web3Provider, + }); + } else if (chainType === ChainType.STELLAR) { + if (!conn.stellarPublicKey) { + await this.connectStellarWallet(); + } + this.setConnection({ + ...conn, + chainId: 0x8000, + chainType: ChainType.STELLAR, + }); + } + } + async getTokenBalances(address: string, chainId: number): Promise { + const chainType = getChainType(chainId); + if (chainType === ChainType.STELLAR) { + return this.getStellarTokenBalances(address); + } return this.tokenService.getTokenBalances(address, chainId); } + private async getStellarTokenBalances(address: string): Promise { + try { + const network = getDefaultStellarNetwork(); + const response = await fetch(`${network.horizonUrl}/accounts/${address}`); + if (!response.ok) { + throw new Error('Failed to fetch Stellar account'); + } + const accountData = await response.json(); + + const balances: TokenBalance[] = accountData.balances.map((bal: any) => ({ + symbol: bal.asset_type === 'native' ? 'XLM' : bal.asset_code, + name: bal.asset_type === 'native' ? 'Stellar Lumens' : bal.asset_code, + address: bal.asset_type === 'native' ? 'native' : `${bal.asset_code}:${bal.asset_issuer}`, + balance: bal.balance, + decimals: bal.asset_type === 'native' ? 7 : 7, + })); + + return balances; + } catch (error) { + errorTracker.record(WalletErrorCode.BALANCE_FETCH_FAILED); + throw new WalletError( + WalletErrorCode.BALANCE_FETCH_FAILED, + 'Failed to fetch Stellar token balances.', + 'Check the address and try again.', + error + ); + } + } + async estimateGas( from: string, to: string, @@ -103,16 +314,28 @@ export class WalletServiceManager implements WalletServiceContext { chainId: number, userGasLimitOverride?: string ): Promise { + const chainType = getChainType(chainId); + if (chainType === ChainType.STELLAR) { + return this.estimateStellarGas(); + } return this.gasService.estimateGas(from, to, value, chainId, userGasLimitOverride); } + private async estimateStellarGas(): Promise { + return { + gasLimit: '1000000', + gasPrice: '0.00001', + estimatedCost: '0.01', + }; + } + getWalletSigner(): ethers.Signer { const conn = this.connection; if (!conn?.eip1193Provider) { const err = new WalletError( WalletErrorCode.NOT_CONNECTED, - 'Wallet is not connected.', - 'Connect your wallet and try again.' + 'EVM wallet is not connected.', + 'Connect an EVM wallet and try again.' ); errorTracker.record(WalletErrorCode.NOT_CONNECTED); throw err; @@ -285,12 +508,26 @@ export class WalletServiceManager implements WalletServiceContext { } getProvider(chainId: number): ethers.providers.JsonRpcProvider { + const chainType = getChainType(chainId); + if (chainType === ChainType.STELLAR) { + throw new Error( + 'Stellar provider is not an ethers provider. Use getStellarProvider() instead.' + ); + } return new ethers.providers.JsonRpcProvider(getEvmRpcUrl(chainId)); } isConnected(): boolean { return this.connection?.isConnected || false; } + + isStellarConnected(): boolean { + return this.connection?.chainType === ChainType.STELLAR && this.connection.isConnected; + } + + isEvmConnected(): boolean { + return this.connection?.chainType === ChainType.EVM && this.connection.isConnected; + } } // Export singleton instance diff --git a/src/services/walletServiceShared.ts b/src/services/walletServiceShared.ts index fb1dc467..0bf936e3 100644 --- a/src/services/walletServiceShared.ts +++ b/src/services/walletServiceShared.ts @@ -1,8 +1,13 @@ import { ethers } from 'ethers'; import { NetworkError, NetworkErrorCode, ContractError, ContractErrorCode } from '../errors'; -import { TIME_CONSTANTS, CRYPTO_CONSTANTS, CHAIN_IDS } from '../utils/constants/values'; -import { GasEstimate } from '../types/wallet'; +import { + TIME_CONSTANTS, + CRYPTO_CONSTANTS, + CHAIN_IDS, + STELLAR_CHAINS, +} from '../utils/constants/values'; +import { GasEstimate, ChainType } from '../types/wallet'; export { GasEstimate }; export { NetworkError, NetworkErrorCode, ContractError, ContractErrorCode }; @@ -68,9 +73,12 @@ export const errorTracker = new ErrorRateTracker(); export interface WalletConnection { address: string; chainId: number; + chainType?: ChainType; isConnected: boolean; provider?: ethers.providers.Web3Provider; eip1193Provider?: ethers.providers.ExternalProvider; + /** Stellar-specific: Freighter/Soroban public key */ + stellarPublicKey?: string; } export interface TokenBalance { @@ -118,6 +126,9 @@ export interface WalletServiceContext { getConnection?(): WalletConnection | null; getWalletSigner?(): ethers.Signer; getProvider?(chainId: number): ethers.providers.JsonRpcProvider; + getStellarProvider?(): any; + switchChain?(chainType: ChainType, chainId: number): Promise; + getSupportedChains?(): { chainType: ChainType; chainId: number; name: string }[]; } export function isUserRejectedError(error: unknown): boolean { diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index fa9952cf..3c99eca8 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -9,13 +9,17 @@ import { } from '../services/cache/crdt'; import { networkMonitor } from '../services/network/networkMonitor'; import { - Subscription, // eslint-disable-line + Subscription, SubscriptionFormData, SubscriptionStats, - SubscriptionCategory, // eslint-disable-line - BillingCycle, // eslint-disable-line + SubscriptionCategory, + BillingCycle, + ChainSpendBreakdown, + CrossChainTransfer, + UnifiedSubscriptionFilter, } from '../types/subscription'; -import { dummySubscriptions } from '../utils/dummyData'; // eslint-disable-line +import { ChainType } from '../types/wallet'; +import { dummySubscriptions } from '../utils/dummyData'; import { advanceBillingDate } from '../utils/billingDate'; import { buildBillingPeriod } from '../utils/invoice'; import { BILLING_CONVERSIONS } from '../utils/constants/values'; @@ -46,14 +50,12 @@ import { ProrationPreview, CreditMemo, } from '../utils/proration'; +import { crossChainRoutingService } from '../services/crossChainRoutingService'; +import { crossChainNotificationService } from '../services/crossChainNotificationService'; const STORAGE_KEY = 'subtrackr-subscriptions'; -const STORE_VERSION = 1; +const STORE_VERSION = 2; -/** - * Generate a unique ID for subscriptions - * Uses timestamp + random component to prevent collisions - */ const generateUniqueId = (): string => { const timestamp = Date.now().toString(36); const randomComponent = Math.random().toString(36).substring(2, 8); @@ -86,6 +88,10 @@ const normalizeSubscription = (raw: Partial): Subscription => { cryptoStreamId: raw.cryptoStreamId, cryptoToken: raw.cryptoToken, cryptoAmount: raw.cryptoAmount, + chainType: raw.chainType ?? ChainType.EVM, + chainId: raw.chainId ?? 1, + crossChainTransfer: raw.crossChainTransfer, + billingAggregationId: raw.billingAggregationId, createdAt: toValidDate(raw.createdAt, now), updatedAt: toValidDate(raw.updatedAt, now), }; @@ -129,9 +135,6 @@ const createSupportEvent = ( }; }; -// Debounced writes are provided by the shared debouncedAsyncStorageAdapter -// (see src/utils/storage.ts). This removes the copy-pasted boilerplate. - export type ProrationEffectiveType = 'immediate' | 'end_of_period' | 'custom_date'; export interface SubscriptionChange { @@ -155,6 +158,9 @@ interface SubscriptionState { creditMemos: Record; planChanges: SubscriptionChange[]; + // Multi-chain filter + chainFilter: UnifiedSubscriptionFilter; + // Offline-first & CRDT Sync syncStatus: 'idle' | 'pending' | 'syncing' | 'conflict' | 'error'; crdtMetadata: Record; @@ -166,7 +172,6 @@ interface SubscriptionState { updateSubscription: (id: string, data: Partial) => Promise; deleteSubscription: (id: string) => Promise; toggleSubscriptionStatus: (id: string) => Promise; - // new actions added previewPlanChange: ( id: string, newPrice: number, @@ -178,7 +183,6 @@ interface SubscriptionState { effectiveDate: 'immediate' | 'end_of_period' ) => Promise; applyCreditToSubscription: (id: string) => Promise; - /** Simulate or record a billing result (fires local notifications when enabled for this sub). */ recordBillingOutcome: (id: string, outcome: 'success' | 'failed') => Promise; fetchSubscriptions: () => Promise; calculateStats: () => void; @@ -190,6 +194,22 @@ interface SubscriptionState { approvePlanChange: (changeId: string) => Promise; rejectPlanChange: (changeId: string) => void; getChangeHistory: (subscriptionId: string) => SubscriptionChange[]; + + // Multi-chain actions + setChainFilter: (filter: UnifiedSubscriptionFilter) => void; + getFilteredSubscriptions: () => Subscription[]; + initiateCrossChainTransfer: ( + id: string, + targetChainType: ChainType, + targetChainId: number + ) => Promise; + approveCrossChainTransfer: (id: string) => Promise; + getSubscriptionsByChain: (chainType: ChainType) => Subscription[]; + aggregateCrossChainBilling: () => Promise<{ + totalInPreferredCurrency: number; + chainBreakdown: Record; + conversionRates: Record; + }>; } type PersistedSubscriptionSlice = Pick< @@ -238,7 +258,6 @@ const migratePersistedState = ( return { subscriptions, planChanges, crdtMetadata, syncStatus }; }; -// Simulated backend sync endpoint using AsyncStorage async function mockSyncApiCall(localState: CRDTSubscriptionState): Promise { await new Promise((resolve) => setTimeout(resolve, 300)); @@ -256,25 +275,167 @@ async function mockSyncApiCall(localState: CRDTSubscriptionState): Promise()( persist( (set, get) => ({ - subscriptions: dummySubscriptions, + subscriptions: dummySubscriptions.map((s) => ({ + ...s, + chainType: (s as any).chainType ?? ChainType.EVM, + chainId: (s as any).chainId ?? 1, + })), stats: { totalActive: 0, totalMonthlySpend: 0, totalYearlySpend: 0, categoryBreakdown: {} as Record, - }, + chainBreakdown: { stellar: 0, evm: {} }, + crossChainTotalMonthlySpend: 0, + crossChainTotalYearlySpend: 0, + } as SubscriptionStats, isLoading: true, error: null, prorationPreview: null, creditMemos: {}, planChanges: [], + chainFilter: {}, - // Offline-first & CRDT Sync State syncStatus: 'idle', crdtMetadata: {}, setSyncStatus: (status) => set({ syncStatus: status }), + setChainFilter: (filter: UnifiedSubscriptionFilter) => set({ chainFilter: filter }), + + getFilteredSubscriptions: () => { + const { subscriptions, chainFilter } = get(); + if (!chainFilter || Object.keys(chainFilter).length === 0) return subscriptions; + + return subscriptions.filter((sub) => { + if (chainFilter.chainType !== undefined && sub.chainType !== chainFilter.chainType) + return false; + if (chainFilter.chainId !== undefined && sub.chainId !== chainFilter.chainId) + return false; + if (chainFilter.status === 'active' && !sub.isActive) return false; + if (chainFilter.status === 'paused' && sub.isActive) return false; + if (chainFilter.searchQuery) { + const query = chainFilter.searchQuery.toLowerCase(); + if ( + !sub.name.toLowerCase().includes(query) && + !sub.category.toLowerCase().includes(query) + ) + return false; + } + return true; + }); + }, + + getSubscriptionsByChain: (chainType: ChainType) => { + return get().subscriptions.filter((s) => s.chainType === chainType); + }, + + initiateCrossChainTransfer: async ( + id: string, + targetChainType: ChainType, + targetChainId: number + ) => { + set({ isLoading: true, error: null }); + try { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub) throw new Error('Subscription not found'); + + const subChainType = sub.chainType ?? ChainType.EVM; + const subChainId = sub.chainId ?? 1; + + const transfer: CrossChainTransfer = { + sourceChainType: subChainType, + sourceChainId: subChainId, + targetChainType, + targetChainId, + status: 'pending', + initiatedAt: new Date(), + }; + + const route = await crossChainRoutingService.findPaymentRoute({ + sourceChainType: subChainType, + sourceChainId: subChainId, + targetChainType, + targetChainId, + tokenSymbol: sub.cryptoToken || sub.currency, + amount: sub.price.toString(), + }); + + const _txHash = await crossChainRoutingService.executePayment(route); + + set((state) => ({ + subscriptions: state.subscriptions.map((s) => + s.id === id + ? { + ...s, + crossChainTransfer: { ...transfer, status: 'pending' }, + updatedAt: new Date(), + } + : s + ), + isLoading: false, + })); + + crossChainNotificationService.notifyCrossChainTransfer(id, subChainType, targetChainType); + get().calculateStats(); + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'initiateCrossChainTransfer', + subscriptionId: id, + }); + set({ error: appError, isLoading: false }); + } + }, + + approveCrossChainTransfer: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub || !sub.crossChainTransfer) throw new Error('No pending transfer'); + + set((state) => ({ + subscriptions: state.subscriptions.map((s) => + s.id === id + ? { + ...s, + chainType: s.crossChainTransfer!.targetChainType, + chainId: s.crossChainTransfer!.targetChainId, + crossChainTransfer: { + ...s.crossChainTransfer!, + status: 'completed', + completedAt: new Date(), + }, + updatedAt: new Date(), + } + : s + ), + isLoading: false, + })); + + get().calculateStats(); + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'approveCrossChainTransfer', + subscriptionId: id, + }); + set({ error: appError, isLoading: false }); + } + }, + + aggregateCrossChainBilling: async () => { + const { subscriptions } = get(); + const activeSubs = subscriptions.filter((s) => s.isActive); + + const billingItems = activeSubs.map((sub) => ({ + chainType: sub.chainType ?? ChainType.EVM, + amount: sub.price, + currency: sub.currency, + })); + + const result = await crossChainRoutingService.aggregateBilling(billingItems); + return result; + }, + syncWithServer: async () => { if (!networkMonitor.isOnline()) { set({ syncStatus: 'pending' }); @@ -347,21 +508,18 @@ export const useSubscriptionStore = create()( const preview = previewProration(sub, newPlanData.price ?? sub.price, effectiveDate); - // Generate credit memo if downgrade const updatedCreditMemos = { ...get().creditMemos }; if (preview.isCredit && preview.amount > 0) { const memo = generateCreditMemo(id, preview.amount, preview.description); updatedCreditMemos[id] = memo; } - // Update subscription const updates: Partial = { ...newPlanData, updatedAt: new Date(), }; if (effectiveDate === 'immediate') { - // Reset billing cycle updates.nextBillingDate = advanceBillingDate( new Date(), newPlanData.billingCycle ?? sub.billingCycle @@ -406,7 +564,7 @@ export const useSubscriptionStore = create()( const memo = get().creditMemos[id]; if (!sub || !memo || memo.applied) return; - const { finalCharge, updatedMemo } = applyCreditMemo(sub.price, memo); + const { updatedMemo } = applyCreditMemo(sub.price, memo); set((state) => ({ creditMemos: { @@ -414,9 +572,6 @@ export const useSubscriptionStore = create()( [id]: updatedMemo, }, })); - - // Could trigger a reduced charge here - console.log(`Applied credit: final charge ${finalCharge}`); }, queuePlanChange: ( @@ -485,6 +640,8 @@ export const useSubscriptionStore = create()( ...data, isActive: true, notificationsEnabled: data.notificationsEnabled !== false, + chainType: data.chainType, + chainId: data.chainId, createdAt: new Date(), updatedAt: new Date(), }; @@ -506,9 +663,8 @@ export const useSubscriptionStore = create()( await syncRenewalReminders(get().subscriptions); await useCalendarStore.getState().syncSubscriptionToCalendars(newSubscription); - // Gamification Triggers const gamificationStore = useGamificationStore.getState(); - gamificationStore.addPoints(10); // 10 points for adding a subscription + gamificationStore.addPoints(10); gamificationStore.checkAchievements(AchievementTrigger.SUBSCRIPTION_ADDED, { totalSubscriptions: get().subscriptions.length, price: data.price, @@ -708,6 +864,13 @@ export const useSubscriptionStore = create()( }; await AsyncStorage.setItem('subtrackr-dunning-entries', JSON.stringify(dunningEntries)); + crossChainNotificationService.notifyPaymentFailed( + id, + sub.chainType ?? ChainType.EVM, + sub.chainId ?? 1, + `Attempt ${attempt}` + ); + if (sub.notificationsEnabled !== false) { await presentChargeFailedNotification(sub); if (attempt <= 3) { @@ -726,6 +889,13 @@ export const useSubscriptionStore = create()( } if (outcome === 'success') { + crossChainNotificationService.notifyPaymentSuccess( + id, + sub.chainType ?? ChainType.EVM, + sub.chainId ?? 1, + sub.price.toString() + ); + const hasDunningEntry = await AsyncStorage.getItem('subtrackr-dunning-entries'); if (hasDunningEntry) { await AsyncStorage.removeItem('subtrackr-dunning-entries'); @@ -736,7 +906,7 @@ export const useSubscriptionStore = create()( await presentChargeSuccessNotification(sub); const billingPeriod = buildBillingPeriod(sub); const next = advanceBillingDate(new Date(sub.nextBillingDate), sub.billingCycle); - const simulatedGas = 0.01 + Math.random() * 0.005; // Simulate 0.01 - 0.015 XLM gas + const simulatedGas = 0.01 + Math.random() * 0.005; set((state) => ({ subscriptions: state.subscriptions.map((s) => s.id === id @@ -803,7 +973,6 @@ export const useSubscriptionStore = create()( calculateStats: () => { const { subscriptions } = get(); - // Safety check: ensure subscriptions is an array if (!subscriptions || !Array.isArray(subscriptions)) { set({ stats: { @@ -811,7 +980,10 @@ export const useSubscriptionStore = create()( totalMonthlySpend: 0, totalYearlySpend: 0, categoryBreakdown: {} as Record, - }, + chainBreakdown: { stellar: 0, evm: {} }, + crossChainTotalMonthlySpend: 0, + crossChainTotalYearlySpend: 0, + } as SubscriptionStats, }); return; } @@ -821,34 +993,49 @@ export const useSubscriptionStore = create()( const { preferredCurrency, exchangeRates } = useSettingsStore.getState(); const rates = exchangeRates?.rates || {}; - const totalMonthlySpend = activeSubs.reduce((total, sub) => { - const priceInPreferred = currencyService.convert( - sub.price, - sub.currency, - preferredCurrency, - rates - ); - if (sub.billingCycle === 'monthly') return total + priceInPreferred; - if (sub.billingCycle === 'yearly') return total + priceInPreferred / 12; - if (sub.billingCycle === 'weekly') - return total + priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_MONTH; - return total + priceInPreferred; - }, 0); - - const totalYearlySpend = activeSubs.reduce((total, sub) => { + let totalMonthlySpend = 0; + let totalYearlySpend = 0; + const chainBreakdown: ChainSpendBreakdown = { stellar: 0, evm: {} }; + + for (const sub of activeSubs) { const priceInPreferred = currencyService.convert( sub.price, sub.currency, preferredCurrency, rates ); - if (sub.billingCycle === 'yearly') return total + priceInPreferred; - if (sub.billingCycle === 'monthly') - return total + priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR; - if (sub.billingCycle === 'weekly') - return total + priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_YEAR; - return total + priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR; - }, 0); + + let monthlyAmount = 0; + let yearlyAmount = 0; + + if (sub.billingCycle === 'monthly') { + monthlyAmount = priceInPreferred; + yearlyAmount = priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR; + } else if (sub.billingCycle === 'yearly') { + monthlyAmount = priceInPreferred / 12; + yearlyAmount = priceInPreferred; + } else if (sub.billingCycle === 'weekly') { + monthlyAmount = priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_MONTH; + yearlyAmount = priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_YEAR; + } else { + monthlyAmount = priceInPreferred; + yearlyAmount = priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR; + } + + totalMonthlySpend += monthlyAmount; + totalYearlySpend += yearlyAmount; + + const chainType = sub.chainType ?? ChainType.EVM; + const chainId = sub.chainId ?? 1; + if (chainType === ChainType.STELLAR) { + chainBreakdown.stellar += monthlyAmount; + } else { + if (!chainBreakdown.evm[chainId]) { + chainBreakdown.evm[chainId] = 0; + } + chainBreakdown.evm[chainId] += monthlyAmount; + } + } const categoryBreakdown = activeSubs.reduce( (acc, sub) => { @@ -870,7 +1057,10 @@ export const useSubscriptionStore = create()( totalYearlySpend, categoryBreakdown, totalGasSpent, - }, + chainBreakdown, + crossChainTotalMonthlySpend: totalMonthlySpend, + crossChainTotalYearlySpend: totalYearlySpend, + } as SubscriptionStats, }); }, }), diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 94ddb21c..cbdb7d0c 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -1,3 +1,5 @@ +import { ChainType } from './wallet'; + export interface Subscription { id: string; name: string; @@ -28,10 +30,35 @@ export interface Subscription { groupId?: string; groupMemberAddress?: string; timezone?: string; + /** Chain information for multi-chain support (default: EVM/Ethereum) */ + chainType?: ChainType; + chainId?: number; + /** Cross-chain subscription transfer state */ + crossChainTransfer?: CrossChainTransfer; + /** Unified billing aggregation */ + billingAggregationId?: string; createdAt: Date; updatedAt: Date; } +export interface CrossChainTransfer { + sourceChainType: ChainType; + sourceChainId: number; + targetChainType: ChainType; + targetChainId: number; + status: 'pending' | 'approved' | 'completed' | 'failed'; + initiatedAt: Date; + completedAt?: Date; + transferFee?: number; +} + +export interface UnifiedSubscriptionFilter { + chainType?: ChainType; + chainId?: number; + status?: 'active' | 'paused' | 'cancelled'; + searchQuery?: string; +} + export enum SubscriptionCategory { STREAMING = 'streaming', SOFTWARE = 'software', @@ -82,6 +109,14 @@ export interface SubscriptionFormData { isCryptoEnabled: boolean; cryptoToken?: string; cryptoAmount?: number; + /** Chain selection for multi-chain support (default: EVM/Ethereum) */ + chainType?: ChainType; + chainId?: number; +} + +export interface ChainSpendBreakdown { + stellar: number; + evm: Record; } export interface SubscriptionStats { @@ -92,4 +127,9 @@ export interface SubscriptionStats { totalGasSpent?: number; totalFiatMonthlySpend?: number; fiatCurrency?: string; + /** Cross-chain spend breakdown */ + chainBreakdown?: ChainSpendBreakdown; + /** Total spend across all chains */ + crossChainTotalMonthlySpend?: number; + crossChainTotalYearlySpend?: number; } diff --git a/src/types/wallet.ts b/src/types/wallet.ts index d51e51a2..9c255e2b 100644 --- a/src/types/wallet.ts +++ b/src/types/wallet.ts @@ -54,16 +54,36 @@ export interface Transaction { timestamp: Date; } +export enum ChainType { + EVM = 'evm', + STELLAR = 'stellar', +} + export enum SupportedChains { ETHEREUM = 1, POLYGON = 137, ARBITRUM = 42161, OPTIMISM = 10, BASE = 8453, + STELLAR = 0x8000, +} + +export interface StellarChainInfo { + id: number; + name: string; + networkPassphrase: string; + horizonUrl: string; + sorobanRpcUrl: string; + nativeCurrency: { + name: string; + symbol: string; + decimals: number; + }; } export interface ChainInfo { id: SupportedChains; + chainType: ChainType; name: string; rpcUrl: string; blockExplorer: string; diff --git a/src/utils/constants/values.ts b/src/utils/constants/values.ts index e1a590a2..a994e4a9 100644 --- a/src/utils/constants/values.ts +++ b/src/utils/constants/values.ts @@ -101,6 +101,19 @@ export const CHAIN_IDS = { POLYGON: 137, /** Arbitrum Mainnet */ ARBITRUM: 42161, + /** Optimism */ + OPTIMISM: 10, + /** Base */ + BASE: 8453, + /** Stellar (Soroban) */ + STELLAR: 0x8000, +} as const; + +export const STELLAR_CHAINS = { + /** Stellar Mainnet chain ID */ + MAINNET: 0x8000, + /** Stellar Testnet chain ID */ + TESTNET: 0x8001, } as const; // Formatting constants diff --git a/src/utils/dummyData.ts b/src/utils/dummyData.ts index df200e56..782617fc 100644 --- a/src/utils/dummyData.ts +++ b/src/utils/dummyData.ts @@ -1,4 +1,5 @@ import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription'; +import { ChainType } from '../types/wallet'; import { TIME_CONSTANTS, CACHE_CONSTANTS } from './constants/values'; import { toMonthlyPrice } from './stats'; @@ -14,6 +15,8 @@ export const dummySubscriptions: Subscription[] = [ nextBillingDate: new Date(Date.now() + 3 * TIME_CONSTANTS.MS_PER_DAY), // 3 days from now isActive: true, isCryptoEnabled: false, + chainType: ChainType.EVM, + chainId: 1, createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), }, @@ -32,6 +35,8 @@ export const dummySubscriptions: Subscription[] = [ cryptoAmount: 10, createdAt: new Date('2024-01-10'), updatedAt: new Date('2024-01-10'), + chainType: ChainType.EVM, + chainId: 1, }, { id: '3', @@ -46,6 +51,8 @@ export const dummySubscriptions: Subscription[] = [ isCryptoEnabled: false, createdAt: new Date('2023-12-01'), updatedAt: new Date('2023-12-01'), + chainType: ChainType.EVM, + chainId: 1, }, { id: '4', @@ -62,6 +69,8 @@ export const dummySubscriptions: Subscription[] = [ cryptoAmount: 0.005, createdAt: new Date('2024-01-20'), updatedAt: new Date('2024-01-20'), + chainType: ChainType.EVM, + chainId: 1, }, { id: '5', @@ -76,6 +85,8 @@ export const dummySubscriptions: Subscription[] = [ isCryptoEnabled: false, createdAt: new Date('2023-11-15'), updatedAt: new Date('2023-11-15'), + chainType: ChainType.EVM, + chainId: 1, }, { id: '6', @@ -92,6 +103,8 @@ export const dummySubscriptions: Subscription[] = [ cryptoAmount: 13, createdAt: new Date('2024-01-05'), updatedAt: new Date('2024-01-05'), + chainType: ChainType.EVM, + chainId: 1, }, { id: '7', @@ -106,6 +119,8 @@ export const dummySubscriptions: Subscription[] = [ isCryptoEnabled: false, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), + chainType: ChainType.EVM, + chainId: 1, }, { id: '8', @@ -120,6 +135,8 @@ export const dummySubscriptions: Subscription[] = [ isCryptoEnabled: false, createdAt: new Date('2023-10-01'), updatedAt: new Date('2024-01-15'), + chainType: ChainType.EVM, + chainId: 1, }, { id: '9', @@ -136,6 +153,8 @@ export const dummySubscriptions: Subscription[] = [ cryptoAmount: 0.001, createdAt: new Date('2024-01-18'), updatedAt: new Date('2024-01-18'), + chainType: ChainType.EVM, + chainId: 1, }, { id: '10', @@ -150,6 +169,8 @@ export const dummySubscriptions: Subscription[] = [ isCryptoEnabled: false, createdAt: new Date('2023-12-20'), updatedAt: new Date('2023-12-20'), + chainType: ChainType.EVM, + chainId: 1, }, ];