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,
},
];