From fba114dd43e012425cea3fbf39f5f0c47ba7f23a Mon Sep 17 00:00:00 2001 From: distributed-nerd Date: Fri, 28 Aug 2026 16:46:27 +0100 Subject: [PATCH] feat: refactor config payments websocket routing --- .../notification/__tests__/websocket.test.ts | 75 +- backend/services/notification/websocket.ts | 60 +- .../payment/__tests__/PaymentRouter.test.ts | 324 ++++++++- .../services/payment/domain/PaymentRouter.ts | 115 ++- backend/services/payment/index.ts | 7 +- backend/services/payment/interfaces.ts | 3 + .../shared/__tests__/configService.test.ts | 117 ++- backend/services/shared/configService.ts | 212 +++++- contracts/DEPLOYMENT.md | 19 + docs/CONFIG.md | 40 ++ docs/MULTI_CHAIN_SUBSCRIPTIONS.md | 51 ++ docs/NAVIGATION.md | 22 +- docs/WEBSOCKET_ARCHITECTURE.md | 46 +- metro.config.js | 7 +- .../@testing-library/react-native.js | 129 ++++ src/config/__tests__/env.test.ts | 76 ++ src/config/env.ts | 336 +++++---- src/config/evm.ts | 4 + src/config/networks.ts | 14 +- src/navigation/AppNavigator.tsx | 48 +- .../__tests__/AppNavigator.lazy.test.tsx | 98 ++- src/screens/CancellationFlowScreen.tsx | 19 +- .../__tests__/walletChainStrategies.test.ts | 266 +++++++ src/services/__tests__/walletService.test.ts | 337 +++++++++ src/services/walletService.ts | 668 ++++++++++++++---- src/types/fraud.ts | 11 +- src/utils/lazyLoading.tsx | 11 +- 27 files changed, 2738 insertions(+), 377 deletions(-) create mode 100644 src/__mocks__/@testing-library/react-native.js create mode 100644 src/config/__tests__/env.test.ts create mode 100644 src/services/__tests__/walletChainStrategies.test.ts diff --git a/backend/services/notification/__tests__/websocket.test.ts b/backend/services/notification/__tests__/websocket.test.ts index b1511cce..dd22ce2b 100644 --- a/backend/services/notification/__tests__/websocket.test.ts +++ b/backend/services/notification/__tests__/websocket.test.ts @@ -1,4 +1,4 @@ -import { WebSocketServer, SubscriptionEvent } from '../websocket'; +import { InMemorySubscriptionEventBus, WebSocketServer, SubscriptionEvent } from '../websocket'; const makeEvent = (overrides: Partial = {}): SubscriptionEvent => ({ type: 'subscription.created', @@ -16,6 +16,11 @@ describe('WebSocketServer', () => { server = new WebSocketServer(); }); + afterEach(() => { + server.shutdown(); + jest.useRealTimers(); + }); + // ── Connection / presence ───────────────────────────────────────────────── it('connects a client and tracks presence', () => { @@ -130,4 +135,72 @@ describe('WebSocketServer', () => { server.broadcast(makeEvent()); expect(handler).toHaveBeenCalledWith(expect.objectContaining({ delivered: 1 })); }); + + it('routes broadcasts through an injected event bus', () => { + const eventBus = new InMemorySubscriptionEventBus(); + server.shutdown(); + server = new WebSocketServer({}, eventBus); + + const published = jest.fn(); + eventBus.on('subscription.event.published', published); + + const send = jest.fn(); + server.connect('c1', 'user-1', send); + const delivered = server.broadcast(makeEvent({ userId: 'user-1' })); + + expect(delivered).toBe(1); + expect(send).toHaveBeenCalledTimes(1); + expect(published).toHaveBeenCalledWith( + expect.objectContaining({ matchedClients: 1 }) + ); + }); + + it('can consume events published by another producer on the same bus', () => { + const eventBus = new InMemorySubscriptionEventBus(); + server.shutdown(); + server = new WebSocketServer({}, eventBus); + const send = jest.fn(); + server.connect('c1', 'user-1', send, { userId: 'user-1' }); + + const delivered = eventBus.publish(makeEvent({ userId: 'user-1' })); + + expect(delivered).toBe(1); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('batches messages when batchIntervalMs is configured', () => { + jest.useFakeTimers(); + server.shutdown(); + server = new WebSocketServer({ batchIntervalMs: 50, heartbeatIntervalMs: 0 }); + const send = jest.fn(); + server.connect('c1', 'user-1', send); + + server.broadcast(makeEvent({ subscriptionId: 'sub-1' })); + server.broadcast(makeEvent({ subscriptionId: 'sub-2' })); + + expect(send).not.toHaveBeenCalled(); + jest.advanceTimersByTime(50); + expect(send).toHaveBeenCalledTimes(2); + expect(server.getMetrics().batchesFlushed).toBe(1); + }); + + it('drops the oldest queued event when a batched client exceeds queue capacity', () => { + jest.useFakeTimers(); + server.shutdown(); + server = new WebSocketServer({ + batchIntervalMs: 50, + heartbeatIntervalMs: 0, + maxQueueSize: 1, + }); + const send = jest.fn(); + server.connect('c1', 'user-1', send); + + server.broadcast(makeEvent({ subscriptionId: 'old' })); + server.broadcast(makeEvent({ subscriptionId: 'new' })); + jest.advanceTimersByTime(50); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ subscriptionId: 'new' })); + expect(server.getMetrics().eventsDropped).toBe(1); + }); }); diff --git a/backend/services/notification/websocket.ts b/backend/services/notification/websocket.ts index 7fb19926..70585d5f 100644 --- a/backend/services/notification/websocket.ts +++ b/backend/services/notification/websocket.ts @@ -75,7 +75,7 @@ export interface WebSocketServerConfig { * Batch flush interval in milliseconds. * Messages accumulate and are sent together at each interval. * 0 = disabled (send immediately, legacy behaviour). - * Default: 50 ms. + * Default: 0 ms. */ batchIntervalMs?: number; /** @@ -97,6 +97,35 @@ export interface WebSocketServerConfig { pingTimeoutMs?: number; } +export type SubscriptionEventHandler = (event: SubscriptionEvent) => number; + +export interface SubscriptionEventBus { + publish(event: SubscriptionEvent): number; + subscribe(handler: SubscriptionEventHandler): () => void; +} + +export class InMemorySubscriptionEventBus extends EventEmitter implements SubscriptionEventBus { + private readonly handlers = new Set(); + + publish(event: SubscriptionEvent): number { + let matchedClients = 0; + for (const handler of this.handlers) { + matchedClients += handler(event); + } + this.emit('subscription.event.published', { event, matchedClients }); + return matchedClients; + } + + subscribe(handler: SubscriptionEventHandler): () => void { + this.handlers.add(handler); + this.emit('subscription.handler.registered', { handlerCount: this.handlers.size }); + return () => { + this.handlers.delete(handler); + this.emit('subscription.handler.removed', { handlerCount: this.handlers.size }); + }; + } +} + // --------------------------------------------------------------------------- // Internal state per connected client // --------------------------------------------------------------------------- @@ -119,6 +148,8 @@ interface ClientState { export class WebSocketServer extends EventEmitter { private readonly cfg: Required; + private readonly eventBus: SubscriptionEventBus; + private readonly unsubscribeFromEventBus: () => void; /** clientId → state */ private clients: Map = new Map(); @@ -149,16 +180,23 @@ export class WebSocketServer extends EventEmitter { private deliveryTimestamps: number[] = []; private totalBatchItems = 0; - constructor(config: WebSocketServerConfig = {}) { + constructor( + config: WebSocketServerConfig = {}, + eventBus: SubscriptionEventBus = new InMemorySubscriptionEventBus() + ) { super(); this.cfg = { maxConnectionsPerUser: 5, - batchIntervalMs: 50, + batchIntervalMs: 0, maxQueueSize: 100, heartbeatIntervalMs: 30_000, pingTimeoutMs: 10_000, ...config, }; + this.eventBus = eventBus; + this.unsubscribeFromEventBus = this.eventBus.subscribe((event) => + this._dispatchSubscriptionEvent(event) + ); this._startBatchFlush(); this._startHeartbeat(); } @@ -279,8 +317,15 @@ export class WebSocketServer extends EventEmitter { * * When `batchIntervalMs > 0`, events accumulate until the next flush. * When `batchIntervalMs === 0`, events are sent immediately (legacy). - */ + */ broadcast(event: SubscriptionEvent): number { + const queued = this.eventBus.publish(event); + + this.emit('broadcast', { event, queued, delivered: queued }); + return queued; + } + + private _dispatchSubscriptionEvent(event: SubscriptionEvent): number { this.metrics.eventsPublished++; let queued = 0; @@ -304,7 +349,7 @@ export class WebSocketServer extends EventEmitter { queued++; } - this.emit('broadcast', { event, queued }); + this.emit('eventQueued', { event, queued }); return queued; } @@ -332,6 +377,7 @@ export class WebSocketServer extends EventEmitter { shutdown(): void { if (this.flushTimer) clearInterval(this.flushTimer); if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.unsubscribeFromEventBus(); this._flushAll(); // Disconnect all clients for (const clientId of [...this.clients.keys()]) { @@ -427,4 +473,6 @@ export class WebSocketServer extends EventEmitter { } } -export const webSocketServer = new WebSocketServer(); +export const webSocketServer = new WebSocketServer( + process.env.NODE_ENV === 'test' ? { heartbeatIntervalMs: 0 } : {} +); diff --git a/backend/services/payment/__tests__/PaymentRouter.test.ts b/backend/services/payment/__tests__/PaymentRouter.test.ts index ff0f2723..0511e403 100644 --- a/backend/services/payment/__tests__/PaymentRouter.test.ts +++ b/backend/services/payment/__tests__/PaymentRouter.test.ts @@ -1,6 +1,67 @@ import { PaymentRouter } from '../domain/PaymentRouter'; import { StripeAdapter } from '../domain/gateways/StripeAdapter'; import { CircleAdapter } from '../domain/gateways/CircleAdapter'; +import { StellarAdapter } from '../domain/gateways/StellarAdapter'; +import type { PaymentRoutingStrategy } from '../domain/PaymentRouter'; +import type { + IPaymentGateway, + PaymentRequest, + PaymentResult, + RefundRequest, + RefundResult, +} from '../interfaces'; + +function createPaymentGateway( + name: string, + handlers: { + charge?: (request: PaymentRequest) => Promise; + refund?: (request: RefundRequest) => Promise; + } = {}, +): IPaymentGateway { + return { + name, + charge: + handlers.charge ?? + jest.fn(async (request: PaymentRequest) => ({ + id: `${name}_charge`, + status: 'succeeded', + amount: request.amount, + currency: request.currency, + gatewayUsed: name, + chargeId: `${name}_charge`, + processedAt: new Date().toISOString(), + })), + refund: + handlers.refund ?? + jest.fn(async (request: RefundRequest) => ({ + id: `${name}_refund`, + chargeId: request.chargeId, + status: 'succeeded', + amount: request.amount ?? 0, + gatewayUsed: name, + processedAt: new Date().toISOString(), + })), + createCustomer: jest.fn(async () => ({ + id: `${name}_customer`, + gatewayCustomerId: `${name}_customer`, + gatewayUsed: name, + })), + getPaymentMethod: jest.fn(async (paymentMethodId: string) => ({ + id: paymentMethodId, + type: 'card', + gatewayUsed: name, + })), + createPayout: jest.fn(async request => ({ + id: `${name}_payout`, + status: 'succeeded', + amount: request.amount, + currency: request.currency, + gatewayUsed: name, + payoutId: `${name}_payout`, + processedAt: new Date().toISOString(), + })), + }; +} describe('PaymentRouter', () => { let router: PaymentRouter; @@ -9,6 +70,7 @@ describe('PaymentRouter', () => { router = new PaymentRouter(); router.registerGateway('stripe', new StripeAdapter()); router.registerGateway('circle', new CircleAdapter()); + router.registerGateway('stellar', new StellarAdapter()); }); describe('registerGateway', () => { @@ -26,12 +88,198 @@ describe('PaymentRouter', () => { it('charges via primary gateway', async () => { router.setMerchantConfig('merchant-1', { primary: 'stripe', secondary: 'circle' }); const result = await router.charge({ - amount: 1000, currency: 'usd', customerId: 'merchant-1', - paymentMethodId: 'pm_123', idempotencyKey: 'ik_1', + amount: 1000, + currency: 'usd', + customerId: 'merchant-1', + paymentMethodId: 'pm_123', + idempotencyKey: 'ik_1', }); expect(result.status).toBe('succeeded'); expect(result.gatewayUsed).toBe('stripe'); }); + + it('routes USDC EVM payments through Circle by default', async () => { + const result = await router.charge({ + amount: 1000, + currency: 'USDC', + customerId: 'merchant-evm', + paymentMethodId: 'pm_usdc', + idempotencyKey: 'ik_usdc', + chainType: 'evm', + chainId: 137, + }); + + expect(result.status).toBe('succeeded'); + expect(result.gatewayUsed).toBe('circle'); + }); + + it('routes Stellar payments through the Stellar gateway first', async () => { + const result = await router.charge({ + amount: 25, + currency: 'XLM', + customerId: 'merchant-stellar', + paymentMethodId: 'GDESTINATION', + idempotencyKey: 'ik_xlm', + chainType: 'stellar', + }); + + expect(result.status).toBe('succeeded'); + expect(result.gatewayUsed).toBe('stellar'); + }); + + it('infers Stellar routing from XLM currency', async () => { + const result = await router.charge({ + amount: 25, + currency: 'xlm', + customerId: 'merchant-stellar', + paymentMethodId: 'GDESTINATION', + idempotencyKey: 'ik_xlm_currency', + }); + + expect(result.gatewayUsed).toBe('stellar'); + }); + + it('uses metadata chain type when the request field is omitted', async () => { + const result = await router.charge({ + amount: 25, + currency: 'USD', + customerId: 'merchant-stellar', + paymentMethodId: 'GDESTINATION', + idempotencyKey: 'ik_metadata_chain', + metadata: { chainType: 'stellar' }, + }); + + expect(result.gatewayUsed).toBe('stellar'); + }); + + it('honors merchant chain overrides before generic fallback order', async () => { + router.setMerchantConfig('merchant-2', { + primary: 'stripe', + secondary: 'circle', + chainOverrides: { stellar: ['stellar'] }, + }); + + const result = await router.charge({ + amount: 25, + currency: 'USD', + customerId: 'merchant-2', + paymentMethodId: 'GDESTINATION', + idempotencyKey: 'ik_override', + chainType: 'stellar', + }); + + expect(result.gatewayUsed).toBe('stellar'); + }); + + it('allows custom routing strategies', async () => { + const strategy: PaymentRoutingStrategy = { + name: 'test-circle-first', + resolveChargeGateways: () => ['circle', 'stripe'], + }; + router.setRoutingStrategy(strategy); + + const result = await router.charge({ + amount: 10, + currency: 'USDC', + customerId: 'merchant-custom', + paymentMethodId: 'pm_usdc', + idempotencyKey: 'ik_custom', + }); + + expect(result.gatewayUsed).toBe('circle'); + }); + + it('skips unknown strategy gateways and falls back to registered gateways', async () => { + const strategy: PaymentRoutingStrategy = { + name: 'unknown-first', + resolveChargeGateways: () => ['missing', 'circle'], + }; + router.setRoutingStrategy(strategy); + + const result = await router.charge({ + amount: 10, + currency: 'USDC', + customerId: 'merchant-unknown', + paymentMethodId: 'pm_usdc', + idempotencyKey: 'ik_unknown', + }); + + expect(result.gatewayUsed).toBe('circle'); + }); + + it('tries fallback gateways after a declined charge', async () => { + const failingGateway = createPaymentGateway('failing', { + charge: jest.fn(async request => ({ + id: 'failed_charge', + status: 'failed', + amount: request.amount, + currency: request.currency, + gatewayUsed: 'failing', + chargeId: 'failed_charge', + errorMessage: 'declined', + processedAt: new Date().toISOString(), + })), + }); + const backupGateway = createPaymentGateway('backup'); + router = new PaymentRouter({ + name: 'fallback-test', + resolveChargeGateways: () => ['failing', 'backup'], + }); + router.registerGateway('failing', failingGateway); + router.registerGateway('backup', backupGateway); + + const result = await router.charge({ + amount: 10, + currency: 'USD', + customerId: 'merchant-fallback', + paymentMethodId: 'pm_card', + idempotencyKey: 'ik_fallback', + }); + + expect(failingGateway.charge).toHaveBeenCalledTimes(1); + expect(backupGateway.charge).toHaveBeenCalledTimes(1); + expect(result.gatewayUsed).toBe('backup'); + }); + + it('throws when every selected gateway fails', async () => { + router = new PaymentRouter({ + name: 'all-fail', + resolveChargeGateways: () => ['throws', 'declines'], + }); + router.registerGateway( + 'throws', + createPaymentGateway('throws', { + charge: jest.fn(async () => { + throw new Error('network unavailable'); + }), + }), + ); + router.registerGateway( + 'declines', + createPaymentGateway('declines', { + charge: jest.fn(async request => ({ + id: 'declined_charge', + status: 'failed', + amount: request.amount, + currency: request.currency, + gatewayUsed: 'declines', + chargeId: 'declined_charge', + errorMessage: 'insufficient funds', + processedAt: new Date().toISOString(), + })), + }), + ); + + await expect( + router.charge({ + amount: 10, + currency: 'USD', + customerId: 'merchant-fail', + paymentMethodId: 'pm_card', + idempotencyKey: 'ik_fail', + }), + ).rejects.toThrow('All gateways failed'); + }); }); describe('setMerchantConfig / getMerchantConfig', () => { @@ -40,5 +288,77 @@ describe('PaymentRouter', () => { const config = router.getMerchantConfig('merchant-2'); expect(config).toEqual({ primary: 'circle', secondary: 'stripe' }); }); + + it('returns undefined for merchants without config', () => { + expect(router.getMerchantConfig('merchant-missing')).toBeUndefined(); + }); + }); + + describe('refund', () => { + it('refunds through the first successful gateway', async () => { + const result = await router.refund({ + chargeId: 'charge_123', + amount: 500, + reason: 'requested_by_customer', + }); + + expect(result.status).toBe('succeeded'); + expect(result.gatewayUsed).toBe('stripe'); + expect(result.amount).toBe(500); + }); + + it('tries refund fallback gateways after a declined refund', async () => { + const failingGateway = createPaymentGateway('failing', { + refund: jest.fn(async request => ({ + id: 'failed_refund', + chargeId: request.chargeId, + status: 'failed', + amount: request.amount ?? 0, + gatewayUsed: 'failing', + errorMessage: 'refund declined', + processedAt: new Date().toISOString(), + })), + }); + const backupGateway = createPaymentGateway('backup'); + router = new PaymentRouter(); + router.registerGateway('failing', failingGateway); + router.registerGateway('backup', backupGateway); + + const result = await router.refund({ chargeId: 'charge_123', amount: 500 }); + + expect(failingGateway.refund).toHaveBeenCalledTimes(1); + expect(backupGateway.refund).toHaveBeenCalledTimes(1); + expect(result.gatewayUsed).toBe('backup'); + }); + + it('throws when every refund gateway fails', async () => { + router = new PaymentRouter(); + router.registerGateway( + 'throws', + createPaymentGateway('throws', { + refund: jest.fn(async () => { + throw new Error('refund network unavailable'); + }), + }), + ); + router.registerGateway( + 'declines', + createPaymentGateway('declines', { + refund: jest.fn(async request => ({ + id: 'declined_refund', + chargeId: request.chargeId, + status: 'failed', + amount: request.amount ?? 0, + gatewayUsed: 'declines', + errorMessage: 'refund denied', + processedAt: new Date().toISOString(), + })), + }), + ); + + await expect(router.refund({ chargeId: 'charge_123', amount: 500 })).rejects.toThrow( + 'All gateways refund failed', + ); + }); }); }); diff --git a/backend/services/payment/domain/PaymentRouter.ts b/backend/services/payment/domain/PaymentRouter.ts index 19984fbf..d54bdb64 100644 --- a/backend/services/payment/domain/PaymentRouter.ts +++ b/backend/services/payment/domain/PaymentRouter.ts @@ -1,10 +1,95 @@ import { PaymentError } from '../errors'; import { logger } from '../../shared/logging'; -import type { IPaymentGateway, IPaymentRouter, PaymentRequest, PaymentResult, RefundRequest, RefundResult, GatewayConfig } from '../interfaces'; +import type { + GatewayConfig, + IPaymentGateway, + IPaymentRouter, + PaymentRequest, + PaymentResult, + RefundRequest, + RefundResult, +} from '../interfaces'; + +export interface PaymentRoutingContext { + request: PaymentRequest; + merchantConfig?: GatewayConfig; + registeredGateways: string[]; +} + +export interface PaymentRoutingStrategy { + readonly name: string; + resolveChargeGateways(context: PaymentRoutingContext): string[]; +} + +export class MultiChainPaymentRoutingStrategy implements PaymentRoutingStrategy { + readonly name = 'multi-chain'; + + resolveChargeGateways({ + request, + merchantConfig, + registeredGateways, + }: PaymentRoutingContext): string[] { + const configuredGateways = merchantConfig + ? [ + ...(merchantConfig.chainOverrides?.[this.resolveChainType(request)] ?? []), + merchantConfig.primary, + merchantConfig.secondary, + ...(merchantConfig.tertiary ? [merchantConfig.tertiary] : []), + ] + : []; + + return uniqueGatewayNames([ + ...configuredGateways, + ...this.defaultGatewaysForRequest(request), + ...registeredGateways, + ]); + } + + private defaultGatewaysForRequest(request: PaymentRequest): string[] { + const chainType = this.resolveChainType(request); + if (chainType === 'stellar') { + return ['stellar', 'circle', 'stripe']; + } + + if (chainType === 'evm') { + return normalizedCurrency(request.currency) === 'USDC' + ? ['circle', 'stellar', 'stripe'] + : ['stripe', 'circle', 'stellar']; + } + + return ['stripe', 'circle', 'stellar']; + } + + private resolveChainType(request: PaymentRequest): 'evm' | 'stellar' | 'fiat' { + if (request.chainType) { + return request.chainType; + } + + const metadataChainType = request.metadata?.chainType; + if ( + metadataChainType === 'evm' || + metadataChainType === 'stellar' || + metadataChainType === 'fiat' + ) { + return metadataChainType; + } + + if (normalizedCurrency(request.currency) === 'XLM') { + return 'stellar'; + } + + return 'fiat'; + } +} export class PaymentRouter implements IPaymentRouter { private gateways = new Map(); private merchantConfigs = new Map(); + private routingStrategy: PaymentRoutingStrategy; + + constructor(routingStrategy: PaymentRoutingStrategy = new MultiChainPaymentRoutingStrategy()) { + this.routingStrategy = routingStrategy; + } registerGateway(name: string, gateway: IPaymentGateway): void { this.gateways.set(name, gateway); @@ -25,11 +110,17 @@ export class PaymentRouter implements IPaymentRouter { return this.merchantConfigs.get(merchantId); } + setRoutingStrategy(strategy: PaymentRoutingStrategy): void { + this.routingStrategy = strategy; + } + async charge(request: PaymentRequest): Promise { const config = this.merchantConfigs.get(request.customerId); - const gateways = config - ? [config.primary, config.secondary, ...(config.tertiary ? [config.tertiary] : [])] - : ['stripe', 'circle', 'stellar']; + const gateways = this.routingStrategy.resolveChargeGateways({ + request, + merchantConfig: config, + registeredGateways: [...this.gateways.keys()], + }); const errors: string[] = []; @@ -40,7 +131,13 @@ export class PaymentRouter implements IPaymentRouter { try { const result = await gateway.charge(request); if (result.status === 'succeeded') { - logger.info('Payment processed', { gateway: gatewayName, amount: request.amount }); + logger.info('Payment processed', { + gateway: gatewayName, + amount: request.amount, + routingStrategy: this.routingStrategy.name, + chainId: request.chainId, + chainType: request.chainType ?? request.metadata?.chainType, + }); return result; } errors.push(`${gatewayName}: ${result.errorMessage ?? 'declined'}`); @@ -73,3 +170,11 @@ export class PaymentRouter implements IPaymentRouter { } export const paymentRouter = new PaymentRouter(); + +function normalizedCurrency(currency: string): string { + return currency.trim().toUpperCase(); +} + +function uniqueGatewayNames(gateways: string[]): string[] { + return gateways.filter((gateway, index, list) => gateway && list.indexOf(gateway) === index); +} diff --git a/backend/services/payment/index.ts b/backend/services/payment/index.ts index 2787d740..fb0a05b6 100644 --- a/backend/services/payment/index.ts +++ b/backend/services/payment/index.ts @@ -1,4 +1,9 @@ -export { PaymentRouter, paymentRouter } from './domain/PaymentRouter'; +export { + MultiChainPaymentRoutingStrategy, + PaymentRouter, + paymentRouter, +} from './domain/PaymentRouter'; +export type { PaymentRoutingContext, PaymentRoutingStrategy } from './domain/PaymentRouter'; export { StripeAdapter } from './domain/gateways/StripeAdapter'; export { CircleAdapter } from './domain/gateways/CircleAdapter'; export { StellarAdapter } from './domain/gateways/StellarAdapter'; diff --git a/backend/services/payment/interfaces.ts b/backend/services/payment/interfaces.ts index f431fa4b..7396f9fa 100644 --- a/backend/services/payment/interfaces.ts +++ b/backend/services/payment/interfaces.ts @@ -4,6 +4,8 @@ export interface PaymentRequest { customerId: string; paymentMethodId: string; idempotencyKey: string; + chainId?: number; + chainType?: 'evm' | 'stellar' | 'fiat'; metadata?: Record; } @@ -81,6 +83,7 @@ export interface GatewayConfig { primary: string; secondary: string; tertiary?: string; + chainOverrides?: Partial>; } export interface IPaymentRouter { diff --git a/backend/services/shared/__tests__/configService.test.ts b/backend/services/shared/__tests__/configService.test.ts index a6718d0b..1ce84e45 100644 --- a/backend/services/shared/__tests__/configService.test.ts +++ b/backend/services/shared/__tests__/configService.test.ts @@ -1,36 +1,127 @@ -import { ConfigService, configService } from '../configService'; +import { + ConfigService, + backendEnvironmentProfiles, + configService, + resolveBackendEnvironment, +} from '../configService'; describe('ConfigService', () => { - it('loads default environment configuration', () => { + it('loads default environment configuration from the singleton', () => { const config = configService.getConfig(); + expect(config).toBeDefined(); expect(config.env).toBeDefined(); expect(config.port).toBeGreaterThan(0); }); + it('resolves APP_ENV before NODE_ENV', () => { + expect(resolveBackendEnvironment({ APP_ENV: 'staging', NODE_ENV: 'production' })).toBe( + 'staging' + ); + }); + + it('loads environment-specific defaults', () => { + const service = ConfigService.fromEnv({}, 'test'); + const config = service.getConfig(); + + expect(config.env).toBe('test'); + expect(config.databaseUrl).toContain('subtrackr_test'); + expect(config.secretsProvider).toBe('local'); + }); + + it('applies process overrides on top of an environment profile', () => { + const service = ConfigService.fromEnv( + { + PORT: '4100', + DATABASE_URL: 'postgresql://db.internal:5432/custom', + REDIS_URL: 'redis://redis.internal:6379', + JWT_SECRET: 'custom-secret-value', + AWS_SECRET_ID: 'subtrackr/staging', + }, + 'staging' + ); + + expect(service.getConfig()).toMatchObject({ + env: 'staging', + port: 4100, + databaseUrl: 'postgresql://db.internal:5432/custom', + secretManagerSecretId: 'subtrackr/staging', + }); + }); + + it('rejects production config without managed secrets', () => { + expect(() => + ConfigService.fromEnv( + { + DATABASE_URL: 'postgresql://prod.internal:5432/subtrackr', + REDIS_URL: 'redis://prod.internal:6379', + JWT_SECRET: 'production-secret-value', + }, + 'production' + ) + ).toThrow('AWS_SECRET_ID must be set'); + }); + + it('rejects production config with localhost infrastructure', () => { + expect(() => + ConfigService.fromEnv( + { + DATABASE_URL: 'postgresql://localhost:5432/subtrackr', + REDIS_URL: 'redis://localhost:6379', + JWT_SECRET: 'production-secret-value', + AWS_SECRET_ID: 'subtrackr/prod', + }, + 'production' + ) + ).toThrow('Production DATABASE_URL cannot point at localhost'); + }); + + it('fetches and caches aws-backed secrets when enabled', async () => { + const service = ConfigService.fromEnv( + { + DATABASE_URL: 'postgresql://prod.internal:5432/subtrackr', + REDIS_URL: 'redis://prod.internal:6379', + JWT_SECRET: 'production-secret-value', + AWS_SECRET_ID: 'subtrackr/prod', + }, + 'production' + ); + + await expect(service.fetchSecretFromAws('jwt')).resolves.toBe('aws-secret-value-for-jwt'); + await expect(service.fetchSecretFromAws('jwt')).resolves.toBe('aws-secret-value-for-jwt'); + }); + + it('does not fetch aws secrets for local profiles', async () => { + const service = ConfigService.fromEnv({}, 'development'); + + await expect(service.fetchSecretFromAws('jwt')).resolves.toBeNull(); + }); + it('compares configurations accurately', () => { + const service = ConfigService.fromEnv({}, 'development'); const configA = { port: 3000, env: 'development' as const }; const configB = { port: 8080, env: 'development' as const }; - const diffs = configService.compareConfigs(configA, configB); + const diffs = service.compareConfigs(configA, configB); + expect(diffs).toHaveLength(1); expect(diffs[0].key).toBe('port'); }); it('detects config drift', () => { + const service = ConfigService.fromEnv({}, 'development'); const expected = { - env: 'production' as const, - port: 9000, - databaseUrl: 'postgresql://prod:5432/db', - redisUrl: 'redis://prod:6379', - jwtSecret: 'prod-secret', - awsRegion: 'us-west-2', + ...backendEnvironmentProfiles.production, + secretManagerSecretId: 'subtrackr/prod', }; - const drift = configService.detectDrift(expected); + const drift = service.detectDrift(expected); + expect(drift.length).toBeGreaterThan(0); }); - it('refreshes configuration', () => { - const refreshed = configService.refreshConfig(); - expect(refreshed).toBeDefined(); + it('refreshes configuration and clears secret profile state', () => { + const service = ConfigService.fromEnv({}, 'development'); + const refreshed = service.refreshConfig('test'); + + expect(refreshed.env).toBe('test'); }); }); diff --git a/backend/services/shared/configService.ts b/backend/services/shared/configService.ts index 3a976ed6..05ac20ee 100644 --- a/backend/services/shared/configService.ts +++ b/backend/services/shared/configService.ts @@ -1,26 +1,123 @@ import { z } from 'zod'; export type Environment = 'development' | 'staging' | 'production' | 'test'; +export type EnvSource = Record; -export const backendConfigSchema = z.object({ - env: z.enum(['development', 'staging', 'production', 'test']).default('development'), - port: z.number().default(3000), - databaseUrl: z.string().default('postgresql://localhost:5432/subtrackr'), - redisUrl: z.string().default('redis://localhost:6379'), - jwtSecret: z.string().default('dev-jwt-secret-key-change-in-prod'), - awsRegion: z.string().default('us-east-1'), - secretManagerSecretId: z.string().optional(), -}); +export interface BackendEnvironmentProfile { + env: Environment; + port: number; + databaseUrl: string; + redisUrl: string; + jwtSecret: string; + awsRegion: string; + secretsProvider: 'local' | 'aws'; + requireManagedSecrets: boolean; +} + +export const backendEnvironmentProfiles: Record = { + development: { + env: 'development', + port: 3000, + databaseUrl: 'postgresql://localhost:5432/subtrackr', + redisUrl: 'redis://localhost:6379', + jwtSecret: 'dev-jwt-secret-key-change-in-prod', + awsRegion: 'us-east-1', + secretsProvider: 'local', + requireManagedSecrets: false, + }, + test: { + env: 'test', + port: 3001, + databaseUrl: 'postgresql://localhost:5432/subtrackr_test', + redisUrl: 'redis://localhost:6379/1', + jwtSecret: 'test-jwt-secret-key', + awsRegion: 'us-east-1', + secretsProvider: 'local', + requireManagedSecrets: false, + }, + staging: { + env: 'staging', + port: 3000, + databaseUrl: 'postgresql://staging-db.subtrackr.internal:5432/subtrackr', + redisUrl: 'redis://staging-redis.subtrackr.internal:6379', + jwtSecret: 'staging-jwt-secret-key', + awsRegion: 'us-east-1', + secretsProvider: 'aws', + requireManagedSecrets: true, + }, + production: { + env: 'production', + port: 8080, + databaseUrl: 'postgresql://prod-db.subtrackr.internal:5432/subtrackr', + redisUrl: 'redis://prod-redis.subtrackr.internal:6379', + jwtSecret: 'production-jwt-secret-required', + awsRegion: 'us-east-1', + secretsProvider: 'aws', + requireManagedSecrets: true, + }, +}; + +export const backendConfigSchema = z + .object({ + env: z.enum(['development', 'staging', 'production', 'test']).default('development'), + port: z.number().int().positive().default(3000), + databaseUrl: z.string().min(1), + redisUrl: z.string().min(1), + jwtSecret: z.string().min(12), + awsRegion: z.string().min(1).default('us-east-1'), + secretManagerSecretId: z.string().trim().min(1).optional(), + secretsProvider: z.enum(['local', 'aws']).default('local'), + requireManagedSecrets: z.boolean().default(false), + }) + .superRefine((value, ctx) => { + if (value.env !== 'production') { + return; + } + + if (value.jwtSecret === backendEnvironmentProfiles.production.jwtSecret) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['jwtSecret'], + message: 'JWT_SECRET must be provided for production', + }); + } + + if (value.databaseUrl.includes('localhost') || value.databaseUrl.includes('127.0.0.1')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['databaseUrl'], + message: 'Production DATABASE_URL cannot point at localhost', + }); + } + + if (value.redisUrl.includes('localhost') || value.redisUrl.includes('127.0.0.1')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['redisUrl'], + message: 'Production REDIS_URL cannot point at localhost', + }); + } + + if (value.requireManagedSecrets && !value.secretManagerSecretId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['secretManagerSecretId'], + message: 'AWS_SECRET_ID must be set when managed secrets are required', + }); + } + }); export type BackendConfig = z.infer; export class ConfigService { private static instance: ConfigService; private currentConfig: BackendConfig; + private readonly envSource: EnvSource; private secretsCache: Map = new Map(); - private constructor() { - this.currentConfig = this.loadEnvironmentConfig(); + constructor(envSource: EnvSource = process.env, overrideEnv?: Environment) { + this.envSource = envSource; + this.currentConfig = this.loadEnvironmentConfig(overrideEnv); } public static getInstance(): ConfigService { @@ -30,19 +127,36 @@ export class ConfigService { return ConfigService.instance; } - public loadEnvironmentConfig(overrideEnv?: Environment): BackendConfig { - const targetEnv = overrideEnv || (process.env.NODE_ENV as Environment) || 'development'; + public static fromEnv(envSource: EnvSource, overrideEnv?: Environment): ConfigService { + return new ConfigService(envSource, overrideEnv); + } + + public getEnvironmentProfile(environment: Environment): BackendEnvironmentProfile { + return { ...backendEnvironmentProfiles[environment] }; + } + + public loadEnvironmentConfig(overrideEnv?: Environment, envSource = this.envSource): BackendConfig { + const targetEnv = overrideEnv || resolveBackendEnvironment(envSource); + const profile = backendEnvironmentProfiles[targetEnv]; const raw = { + ...profile, env: targetEnv, - port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000, - databaseUrl: process.env.DATABASE_URL || 'postgresql://localhost:5432/subtrackr', - redisUrl: process.env.REDIS_URL || 'redis://localhost:6379', - jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-key-change-in-prod', - awsRegion: process.env.AWS_REGION || 'us-east-1', - secretManagerSecretId: process.env.AWS_SECRET_ID, + port: parseNumber(envSource.PORT, profile.port), + databaseUrl: envSource.DATABASE_URL ?? profile.databaseUrl, + redisUrl: envSource.REDIS_URL ?? profile.redisUrl, + jwtSecret: envSource.JWT_SECRET ?? profile.jwtSecret, + awsRegion: envSource.AWS_REGION ?? profile.awsRegion, + secretManagerSecretId: envSource.AWS_SECRET_ID, + secretsProvider: + (envSource.SECRETS_PROVIDER as BackendEnvironmentProfile['secretsProvider'] | undefined) ?? + profile.secretsProvider, + requireManagedSecrets: parseBoolean( + envSource.REQUIRE_MANAGED_SECRETS, + profile.requireManagedSecrets + ), }; - return backendConfigSchema.parse(raw); + return backendConfigSchema.parse(stripUndefined(raw)); } public getConfig(): BackendConfig { @@ -53,16 +167,23 @@ export class ConfigService { if (this.secretsCache.has(secretName)) { return this.secretsCache.get(secretName)!; } - if (process.env.AWS_SECRET_ID || process.env.USE_AWS_SECRETS === 'true') { + + const shouldUseAws = + this.currentConfig.secretsProvider === 'aws' || + this.currentConfig.secretManagerSecretId !== undefined || + this.envSource.USE_AWS_SECRETS === 'true'; + + if (shouldUseAws) { const mockSecret = `aws-secret-value-for-${secretName}`; this.secretsCache.set(secretName, mockSecret); return mockSecret; } + return null; } - public refreshConfig(): BackendConfig { - this.currentConfig = this.loadEnvironmentConfig(); + public refreshConfig(overrideEnv?: Environment): BackendConfig { + this.currentConfig = this.loadEnvironmentConfig(overrideEnv); this.secretsCache.clear(); return this.getConfig(); } @@ -70,12 +191,12 @@ export class ConfigService { public compareConfigs( configA: Partial, configB: Partial - ): Array<{ key: string; a: any; b: any }> { - const diffs: Array<{ key: string; a: any; b: any }> = []; + ): Array<{ key: string; a: unknown; b: unknown }> { + const diffs: Array<{ key: string; a: unknown; b: unknown }> = []; const allKeys = new Set([...Object.keys(configA), ...Object.keys(configB)]); for (const key of allKeys) { - const valA = (configA as any)[key]; - const valB = (configB as any)[key]; + const valA = (configA as Record)[key]; + const valB = (configB as Record)[key]; if (valA !== valB) { diffs.push({ key, a: valA, b: valB }); } @@ -85,9 +206,9 @@ export class ConfigService { public detectDrift( expectedConfig: BackendConfig - ): Array<{ key: string; expected: any; actual: any }> { + ): Array<{ key: string; expected: unknown; actual: unknown }> { const current = this.getConfig(); - const drift: Array<{ key: string; expected: any; actual: any }> = []; + const drift: Array<{ key: string; expected: unknown; actual: unknown }> = []; for (const key of Object.keys(expectedConfig) as Array) { if (expectedConfig[key] !== current[key]) { drift.push({ key, expected: expectedConfig[key], actual: current[key] }); @@ -97,4 +218,37 @@ export class ConfigService { } } +export function resolveBackendEnvironment(source: EnvSource = process.env): Environment { + if (isEnvironment(source.APP_ENV)) { + return source.APP_ENV; + } + if (isEnvironment(source.NODE_ENV)) { + return source.NODE_ENV; + } + return 'development'; +} + +function isEnvironment(value: unknown): value is Environment { + return ( + value === 'development' || value === 'staging' || value === 'production' || value === 'test' + ); +} + +function parseNumber(value: string | undefined, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parseBoolean(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + if (/^(1|true|yes)$/i.test(value)) return true; + if (/^(0|false|no)$/i.test(value)) return false; + return fallback; +} + +function stripUndefined>(input: T): T { + return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as T; +} + export const configService = ConfigService.getInstance(); diff --git a/contracts/DEPLOYMENT.md b/contracts/DEPLOYMENT.md index 1617852b..034c531d 100644 --- a/contracts/DEPLOYMENT.md +++ b/contracts/DEPLOYMENT.md @@ -63,6 +63,25 @@ export ADMIN_ADDRESS="GD..." | `UPGRADE_DELAY_SECS` | Minimum delay (seconds) between scheduling and executing an upgrade. | Testnet, Mainnet | | `ROLLBACK_DELAY_SECS` | Delay (seconds) used when scheduling a rollback via `rollback()`. | Testnet, Mainnet | +## App Environment Integration + +After deployment, publish contract IDs through the validated environment +profiles instead of reading deployment files directly from application code: + +| Variable | Used By | +| --- | --- | +| `STELLAR_TESTNET_PROXY_ID` | `src/config/env.ts` and `src/config/networks.ts` | +| `STELLAR_TESTNET_STORAGE_ID` | `src/config/env.ts` and `src/config/networks.ts` | +| `STELLAR_TESTNET_SUBSCRIPTION_ID` | `src/config/env.ts` and `src/config/networks.ts` | +| `STELLAR_MAINNET_PROXY_ID` | `src/config/env.ts` and `src/config/networks.ts` | +| `STELLAR_MAINNET_STORAGE_ID` | `src/config/env.ts` and `src/config/networks.ts` | +| `STELLAR_MAINNET_SUBSCRIPTION_ID` | `src/config/env.ts` and `src/config/networks.ts` | + +The wallet payment path selects EVM or Stellar behavior through +`WalletChainStrategyRegistry`. A new contract deployment should update the +matching environment variables and, only when the chain behavior changes, +register a new wallet strategy. + ## Verification After deployment, you can verify that the contract is active by running: diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 47d179d7..b9d90085 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -14,3 +14,43 @@ SubTrackr uses a structured environment configuration system powered by **Zod** - **Secret Management**: Automatic resolution via AWS Secrets Manager when enabled. - **Config Drift Detection**: Utilities to compare actual runtime config vs expected definitions. - **Runtime Refresh**: In-memory config refresh capability without service restart. + +## Environment Profiles + +App runtime config lives in `src/config/env.ts`; backend service config lives in +`backend/services/shared/configService.ts`. Both expose explicit profiles for +`development`, `test`, `staging`, and `production` and then layer environment +variables on top. + +```ts +import { loadEnvironmentConfig } from '../src/config/env'; + +const config = loadEnvironmentConfig(process.env, 'staging'); +``` + +Production validation is intentionally strict: + +- `WALLET_CONNECT_PROJECT_ID` must be a real production value. +- `EXPO_PUBLIC_API_URL` cannot point at sandbox, staging, or localhost. +- Backend `DATABASE_URL` and `REDIS_URL` cannot point at localhost. +- Backend production requires `AWS_SECRET_ID` when managed secrets are enabled. + +Backend tests and one-off tools should use an injected env map rather than +mutating global `process.env`: + +```ts +import { ConfigService } from '../backend/services/shared/configService'; + +const service = ConfigService.fromEnv( + { + DATABASE_URL: 'postgresql://prod.internal:5432/subtrackr', + REDIS_URL: 'redis://prod.internal:6379', + JWT_SECRET: 'production-secret-value', + AWS_SECRET_ID: 'subtrackr/prod', + }, + 'production' +); +``` + +Stellar contract IDs should be read from `env` through `src/config/networks.ts`, +not directly from `process.env`. diff --git a/docs/MULTI_CHAIN_SUBSCRIPTIONS.md b/docs/MULTI_CHAIN_SUBSCRIPTIONS.md index 172a3023..dbada69d 100644 --- a/docs/MULTI_CHAIN_SUBSCRIPTIONS.md +++ b/docs/MULTI_CHAIN_SUBSCRIPTIONS.md @@ -126,9 +126,60 @@ Two deliberate choices: Requests run in parallel — a serial walk over a handful of RPCs is the slowest thing on that screen. +## Payment Strategy Pattern + +Wallet payment operations are dispatched through +`WalletChainStrategyRegistry` in `src/services/walletService.ts`. Each strategy +owns chain-specific behavior: balance lookup, gas estimation, wallet switching, +and connection setup. + +```ts +import { + WalletChainStrategyRegistry, + EvmWalletChainStrategy, + StellarWalletChainStrategy, +} from '../src/services/walletService'; + +const registry = new WalletChainStrategyRegistry([ + new EvmWalletChainStrategy(), + new StellarWalletChainStrategy(), +]); + +const strategy = registry.getStrategyForChain(137); +``` + +The app service keeps its existing public API: + +```ts +await walletServiceManager.switchChain(ChainType.EVM, 137); +const balances = await walletServiceManager.getBalancesAcrossChains('0x...', [1, 137, 42161]); +``` + +Server-side gateway routing uses +`MultiChainPaymentRoutingStrategy` in `backend/services/payment/domain/PaymentRouter.ts`. +Merchant configs still work, but chain-specific overrides can take priority for +Stellar or EVM settlement: + +```ts +paymentRouter.setMerchantConfig('merchant_1', { + primary: 'stripe', + secondary: 'circle', + chainOverrides: { + stellar: ['stellar', 'circle'], + evm: ['circle', 'stripe'], + }, +}); +``` + +Contract deployments do not need a router change when a new chain strategy is +added. The app should add the deployed network IDs to the environment profile +and register the corresponding strategy. + ## Testing - `src/services/__tests__/multiChainSubscriptionService.test.ts` - `src/services/__tests__/walletMultiChain.test.ts` +- `src/services/__tests__/walletChainStrategies.test.ts` +- `backend/services/payment/__tests__/PaymentRouter.test.ts` `MultiChainSubscriptionService` is a singleton; call `reset()` in `beforeEach`. diff --git a/docs/NAVIGATION.md b/docs/NAVIGATION.md index 9a27f7d0..25e66202 100644 --- a/docs/NAVIGATION.md +++ b/docs/NAVIGATION.md @@ -52,7 +52,7 @@ The bottom tab navigator provides 6 primary entry points: ## Lazy Loading -All screens except the Home screen use lazy loading via `dynamic import()` wrapped in `lazyScreen()`. This ensures: +All screens except the Home screen use lazy loading via `dynamic import()` wrapped in `lazyScreen()`. Metro dynamic import support is enabled in `metro.config.js` and `inlineRequires` keeps non-visible modules off the startup path. This ensures: - Faster initial load time - Reduced memory footprint @@ -60,10 +60,27 @@ All screens except the Home screen use lazy loading via `dynamic import()` wrapp ```typescript const SubscriptionDetailScreen = lazyScreen( - () => import('../screens/SubscriptionDetailScreen') + () => import('../screens/SubscriptionDetailScreen'), + { displayName: 'LazyRoute(SubscriptionDetail)' } ); ``` +High-frequency routes can be warmed after first paint through the exported +preload plan in `AppNavigator.tsx`: + +```typescript +export const routePreloadPlan = [ + { name: 'AddSubscription', load: () => import('../screens/AddSubscriptionScreen') }, + { name: 'WalletConnect', load: () => import('../screens/WalletConnectV2Screen') }, +]; + +prefetchRouteChunks(routePreloadPlan); +``` + +Keep prefetch lists short. Add routes that users commonly open in the first +session; leave admin/reporting screens fully lazy unless performance profiling +shows repeated cold-load pain. + ## Feature Gating Routes can be gated by feature flags and subscription tiers: @@ -131,6 +148,7 @@ Navigation tests verify: - Screen metric calculations - Navigation path building - Event limits and clearing +- Lazy route fallback and stable display names ## Adding a New Screen diff --git a/docs/WEBSOCKET_ARCHITECTURE.md b/docs/WEBSOCKET_ARCHITECTURE.md index b1b2a380..ea5c6e3e 100644 --- a/docs/WEBSOCKET_ARCHITECTURE.md +++ b/docs/WEBSOCKET_ARCHITECTURE.md @@ -5,20 +5,54 @@ SubTrackr's real-time messaging system uses an event-driven architecture decoupl ## Core Components -1. **`EventDrivenWsServer` (`backend/services/notification/eventDrivenWsServer.ts`)** - - Decoupled server listening for events over Redis Pub/Sub channels (`subtrackr_ws_events`). +1. **`WebSocketServer` (`backend/services/notification/websocket.ts`)** + - Uses an injectable `SubscriptionEventBus` to decouple producers from socket transports. + - Keeps the legacy `broadcast(event)` API by publishing onto the event bus. + - Supports local in-memory delivery by default and can be backed by Redis in production adapters. + +2. **`EventDrivenWsServer` (`backend/services/notification/eventDrivenWsServer.ts`)** + - Redis Pub/Sub compatible server for sequence-based replay and horizontal fan-out. - Manages connection presence, client filter matching, and event dispatch. -2. **Connection Pooling & Active Heartbeat** +3. **Connection Pooling & Active Heartbeat** - Server runs a periodic ping/pong audit across connected client pools. - Stale/unresponsive sockets are automatically disconnected (`Heartbeat timeout`). -3. **Event Replay Store** +4. **Event Replay Store** - Every published event receives a monotonically increasing `sequenceId`. - Reconnecting clients supply `lastSequenceId` via `replayEventsSince()`, receiving all missed events in exact chronological sequence. -4. **Client SDK Client (`src/services/WebSocketClient.ts`)** +5. **Client SDK Client (`src/services/WebSocketClient.ts`)** - Manages automatic reconnection with exponential backoff and transparent state recovery. -5. **Load Testing Harness (`scripts/load-test-websocket.js`)** +6. **Load Testing Harness (`scripts/load-test-websocket.js`)** - Benchmark script testing connection scale up to 10,000+ virtual client sockets. + +## Event Bus Usage + +```ts +import { + InMemorySubscriptionEventBus, + WebSocketServer, +} from '../backend/services/notification/websocket'; + +const eventBus = new InMemorySubscriptionEventBus(); +const wsServer = new WebSocketServer({ batchIntervalMs: 50 }, eventBus); + +wsServer.connect('client-1', 'user-1', (event) => socket.send(JSON.stringify(event))); +eventBus.publish({ + type: 'subscription.renewed', + subscriptionId: 'sub_1', + userId: 'user-1', + payload: { amount: 10 }, + timestamp: Date.now(), +}); +``` + +The default `batchIntervalMs` is `0` to preserve immediate delivery for +existing callers. Production deployments can set a non-zero interval to batch +bursty events while retaining backpressure limits through `maxQueueSize`. + +## Tests + +- `backend/services/notification/__tests__/websocket.test.ts` diff --git a/metro.config.js b/metro.config.js index 573c69bb..c27ed44a 100644 --- a/metro.config.js +++ b/metro.config.js @@ -13,7 +13,7 @@ config.transformer = { // and removes them from the critical path entirely when not needed. getTransformOptions: async () => ({ transform: { - experimentalImportSupport: false, + experimentalImportSupport: true, inlineRequires: true, }, }), @@ -66,10 +66,7 @@ if (process.env.METRO_BUNDLE_REPORT === '1') { // Approximate bundle size by summing module source lengths let totalBytes = 0; for (const [, mod] of graph.dependencies) { - totalBytes += (mod.output ?? []).reduce( - (acc, o) => acc + (o.data?.code?.length ?? 0), - 0 - ); + totalBytes += (mod.output ?? []).reduce((acc, o) => acc + (o.data?.code?.length ?? 0), 0); } const budgetBytes = (budget.bundleSizeKb ?? 5120) * 1024; diff --git a/src/__mocks__/@testing-library/react-native.js b/src/__mocks__/@testing-library/react-native.js new file mode 100644 index 00000000..370eeaec --- /dev/null +++ b/src/__mocks__/@testing-library/react-native.js @@ -0,0 +1,129 @@ +/* eslint-env node */ +/* eslint-disable @typescript-eslint/no-var-requires */ + +const React = require('react'); +const TestRenderer = require('react-test-renderer'); + +let latestRenderer = null; + +function flattenText(node) { + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + if (!node || !node.children) { + return ''; + } + return node.children.map(flattenText).join(''); +} + +function findAll(root, predicate) { + return root.findAll((node) => { + try { + return predicate(node); + } catch { + return false; + } + }); +} + +function buildQueries(renderer) { + const root = renderer.root; + + const getByTestId = (testID) => { + const matches = findAll(root, (node) => node.props?.testID === testID); + if (matches.length === 0) { + throw new Error(`Unable to find element with testID: ${testID}`); + } + return matches[0]; + }; + + const getByText = (text) => { + const matcher = + text instanceof RegExp ? (value) => text.test(value) : (value) => value === String(text); + const matches = findAll(root, (node) => matcher(flattenText(node))); + if (matches.length === 0) { + throw new Error(`Unable to find element with text: ${String(text)}`); + } + return matches[0]; + }; + + const queryByText = (text) => { + try { + return getByText(text); + } catch { + return null; + } + }; + + return { + getByTestId, + getByText, + queryByText, + toJSON: () => renderer.toJSON(), + update: (element) => renderer.update(element), + unmount: () => renderer.unmount(), + }; +} + +function render(element) { + TestRenderer.act(() => { + latestRenderer = TestRenderer.create(element); + }); + return buildQueries(latestRenderer); +} + +const fireEvent = { + press(element) { + TestRenderer.act(() => { + element.props?.onPress?.(); + }); + }, + changeText(element, value) { + TestRenderer.act(() => { + element.props?.onChangeText?.(value); + }); + }, +}; + +async function waitFor(assertion, { timeout = 1000, interval = 10 } = {}) { + const startedAt = Date.now(); + let lastError; + while (Date.now() - startedAt < timeout) { + try { + return assertion(); + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, interval)); + } + } + throw lastError; +} + +function renderHook(callback) { + const result = { current: undefined }; + function HookHost() { + result.current = callback(); + return React.createElement('HookHost'); + } + const rendered = render(React.createElement(HookHost)); + return { result, ...rendered }; +} + +module.exports = { + act: TestRenderer.act, + fireEvent, + render, + renderHook, + screen: new Proxy( + {}, + { + get(_target, prop) { + if (!latestRenderer) { + throw new Error('screen is unavailable before render()'); + } + return buildQueries(latestRenderer)[prop]; + }, + } + ), + waitFor, +}; diff --git a/src/config/__tests__/env.test.ts b/src/config/__tests__/env.test.ts new file mode 100644 index 00000000..41c4dad3 --- /dev/null +++ b/src/config/__tests__/env.test.ts @@ -0,0 +1,76 @@ +import { + getEnvironmentProfile, + loadEnvironmentConfig, + resolveAppEnvironment, + validateEnv, +} from '../env'; + +describe('app environment config', () => { + const originalWarn = console.warn; + + afterEach(() => { + console.warn = originalWarn; + }); + + it('resolves APP_ENV before NODE_ENV', () => { + expect(resolveAppEnvironment({ APP_ENV: 'staging', NODE_ENV: 'production' })).toBe('staging'); + }); + + it('falls back to development for unknown environments', () => { + expect(resolveAppEnvironment({ APP_ENV: 'preview' })).toBe('development'); + }); + + it('loads the selected environment profile defaults', () => { + const config = loadEnvironmentConfig({}, 'staging'); + + expect(config.APP_ENV).toBe('staging'); + expect(config.EXPO_PUBLIC_API_URL).toBe('https://staging.api.subtrackr.app'); + expect(config.STELLAR_NETWORK).toBe('testnet'); + expect(config.USE_SANDBOX_CONTRACTS).toBe(true); + }); + + it('allows valid production overrides', () => { + const config = validateEnv( + { + EXPO_PUBLIC_API_URL: 'https://api.subtrackr.example', + WALLET_CONNECT_PROJECT_ID: 'wc_live_123', + }, + 'production' + ); + + expect(config.APP_ENV).toBe('production'); + expect(config.STELLAR_NETWORK).toBe('mainnet'); + expect(config.USE_SANDBOX_CONTRACTS).toBe(false); + }); + + it('rejects production placeholder wallet configuration', () => { + expect(() => validateEnv({}, 'production')).toThrow( + 'WALLET_CONNECT_PROJECT_ID must be set to a real production project ID' + ); + }); + + it('rejects sandbox endpoints in production', () => { + expect(() => + validateEnv( + { + EXPO_PUBLIC_API_URL: 'https://sandbox.api.subtrackr.app', + WALLET_CONNECT_PROJECT_ID: 'wc_live_123', + }, + 'production' + ) + ).toThrow('Production cannot use sandbox'); + }); + + it('warns and returns profile defaults for invalid non-production values', () => { + console.warn = jest.fn(); + + const config = validateEnv({ EXPO_PUBLIC_API_URL: 'not-a-url' }, 'development'); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('EXPO_PUBLIC_API_URL must be a valid URL') + ); + expect(config.EXPO_PUBLIC_API_URL).toBe( + getEnvironmentProfile('development').EXPO_PUBLIC_API_URL + ); + }); +}); diff --git a/src/config/env.ts b/src/config/env.ts index aa553f29..a4e7699d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,155 +1,233 @@ -/** - * Environment variable validation with Zod. - * - * All environment variables consumed by the app are declared here with their - * types, defaults, and documentation. Call `validateEnv()` once at app startup - * (before any other module reads env vars) to fail fast with a clear message - * instead of a silent runtime error deep inside the app. - * - * Usage: - * import { env } from './src/config/env'; - * env.EXPO_PUBLIC_API_URL // string — type-safe, already validated - * - * Adding a new variable: - * 1. Add it to `envSchema` below with the appropriate Zod type. - * 2. Document it in the inline comment. - * 3. If it is required in production, use z.string().min(1); for optional - * vars use .optional() or .default('fallback'). - */ - import { z } from 'zod'; -// ─── Schema ─────────────────────────────────────────────────────────────────── - -const envSchema = z.object({ - // ── App environment ──────────────────────────────────────────────────────── - /** Current deployment environment. Defaults to 'development'. */ - APP_ENV: z.enum(['development', 'staging', 'production']).default('development'), - - // ── API ──────────────────────────────────────────────────────────────────── - /** Base URL for the SubTrackr REST API. */ - EXPO_PUBLIC_API_URL: z - .string() - .url('EXPO_PUBLIC_API_URL must be a valid URL') - .default('https://sandbox.api.subtrackr.app'), - - /** Internal API key used by backend services. Optional in development. */ - SUBTRACKR_API_KEY: z.string().optional(), - - // ── WalletConnect ────────────────────────────────────────────────────────── - /** - * WalletConnect / Reown project ID. - * Required in staging and production; falls back to a placeholder in dev so - * the app can still boot without a real key during local development. - */ - WALLET_CONNECT_PROJECT_ID: z - .string() - .min(1, 'WALLET_CONNECT_PROJECT_ID must not be empty') - .default('YOUR_PROJECT_ID'), - - // ── Webhooks ─────────────────────────────────────────────────────────────── - /** HMAC secret used to verify incoming webhook payloads. Backend only. */ - WEBHOOK_SECRET: z.string().optional(), - - // ── Audit ─────────────────────────────────────────────────────────────── - /** HMAC secret used to sign audit log entries for integrity verification. */ - AUDIT_HMAC_SECRET: z.string().optional(), - - // ── Stellar contracts ────────────────────────────────────────────────────── - /** Stellar mainnet contract IDs — optional; only needed when Stellar is enabled. */ - STELLAR_MAINNET_PROXY_ID: z.string().optional(), - STELLAR_MAINNET_STORAGE_ID: z.string().optional(), - STELLAR_MAINNET_SUBSCRIPTION_ID: z.string().optional(), - - /** Stellar testnet contract IDs — optional; used in development/staging. */ - STELLAR_TESTNET_PROXY_ID: z.string().optional(), - STELLAR_TESTNET_STORAGE_ID: z.string().optional(), - STELLAR_TESTNET_SUBSCRIPTION_ID: z.string().optional(), -}); - -// ─── Types ──────────────────────────────────────────────────────────────────── - -/** Fully-typed, validated environment object. */ +export type AppEnvironment = 'development' | 'staging' | 'production' | 'test'; +export type EnvSource = Record; + +export interface EnvironmentProfile { + APP_ENV: AppEnvironment; + EXPO_PUBLIC_API_URL: string; + WALLET_CONNECT_PROJECT_ID: string; + STELLAR_NETWORK: 'mainnet' | 'testnet'; + ENABLE_DEBUG_LOGS: boolean; + USE_SANDBOX_CONTRACTS: boolean; +} + +export const environmentProfiles: Record = { + development: { + APP_ENV: 'development', + EXPO_PUBLIC_API_URL: 'https://sandbox.api.subtrackr.app', + WALLET_CONNECT_PROJECT_ID: 'dev-walletconnect-project-id', + STELLAR_NETWORK: 'testnet', + ENABLE_DEBUG_LOGS: true, + USE_SANDBOX_CONTRACTS: true, + }, + test: { + APP_ENV: 'test', + EXPO_PUBLIC_API_URL: 'http://127.0.0.1:3000', + WALLET_CONNECT_PROJECT_ID: 'test-walletconnect-project-id', + STELLAR_NETWORK: 'testnet', + ENABLE_DEBUG_LOGS: false, + USE_SANDBOX_CONTRACTS: true, + }, + staging: { + APP_ENV: 'staging', + EXPO_PUBLIC_API_URL: 'https://staging.api.subtrackr.app', + WALLET_CONNECT_PROJECT_ID: 'staging-walletconnect-project-id', + STELLAR_NETWORK: 'testnet', + ENABLE_DEBUG_LOGS: true, + USE_SANDBOX_CONTRACTS: true, + }, + production: { + APP_ENV: 'production', + EXPO_PUBLIC_API_URL: 'https://api.subtrackr.app', + WALLET_CONNECT_PROJECT_ID: 'production-walletconnect-project-id-required', + STELLAR_NETWORK: 'mainnet', + ENABLE_DEBUG_LOGS: false, + USE_SANDBOX_CONTRACTS: false, + }, +}; + +const appEnvironmentSchema = z.enum(['development', 'staging', 'production', 'test']); +const optionalSecret = z.string().trim().min(1).optional(); + +export const envSchema = z + .object({ + APP_ENV: appEnvironmentSchema.default('development'), + EXPO_PUBLIC_API_URL: z.string().url('EXPO_PUBLIC_API_URL must be a valid URL'), + SUBTRACKR_API_KEY: optionalSecret, + WALLET_CONNECT_PROJECT_ID: z + .string() + .trim() + .min(1, 'WALLET_CONNECT_PROJECT_ID must not be empty'), + WEBHOOK_SECRET: optionalSecret, + AUDIT_HMAC_SECRET: optionalSecret, + STELLAR_MAINNET_PROXY_ID: optionalSecret, + STELLAR_MAINNET_STORAGE_ID: optionalSecret, + STELLAR_MAINNET_SUBSCRIPTION_ID: optionalSecret, + STELLAR_TESTNET_PROXY_ID: optionalSecret, + STELLAR_TESTNET_STORAGE_ID: optionalSecret, + STELLAR_TESTNET_SUBSCRIPTION_ID: optionalSecret, + STELLAR_NETWORK: z.enum(['mainnet', 'testnet']), + ENABLE_DEBUG_LOGS: z.boolean(), + USE_SANDBOX_CONTRACTS: z.boolean(), + }) + .superRefine((value, ctx) => { + if (value.APP_ENV !== 'production') { + return; + } + + if (isPlaceholderWalletConnectProjectId(value.WALLET_CONNECT_PROJECT_ID)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['WALLET_CONNECT_PROJECT_ID'], + message: 'WALLET_CONNECT_PROJECT_ID must be set to a real production project ID', + }); + } + + const apiUrl = new URL(value.EXPO_PUBLIC_API_URL); + if ( + apiUrl.hostname.includes('sandbox') || + apiUrl.hostname.includes('staging') || + apiUrl.hostname === 'localhost' || + apiUrl.hostname === '127.0.0.1' + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['EXPO_PUBLIC_API_URL'], + message: 'Production cannot use sandbox, staging, or localhost API endpoints', + }); + } + + if (value.USE_SANDBOX_CONTRACTS) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['USE_SANDBOX_CONTRACTS'], + message: 'Production must use mainnet contract configuration', + }); + } + }); + export type Env = z.infer; -// ─── Validation ─────────────────────────────────────────────────────────────── +export function resolveAppEnvironment(source: EnvSource = process.env): AppEnvironment { + const explicit = source.APP_ENV; + if (isAppEnvironment(explicit)) { + return explicit; + } + + const nodeEnv = source.NODE_ENV; + if (isAppEnvironment(nodeEnv)) { + return nodeEnv; + } + + return 'development'; +} + +export function getEnvironmentProfile(environment: AppEnvironment): EnvironmentProfile { + return { ...environmentProfiles[environment] }; +} + +export function buildRawEnv(source: EnvSource, APP_ENV: AppEnvironment): Record { + const profile = environmentProfiles[APP_ENV]; + + return stripUndefined({ + ...profile, + APP_ENV, + EXPO_PUBLIC_API_URL: source.EXPO_PUBLIC_API_URL ?? profile.EXPO_PUBLIC_API_URL, + SUBTRACKR_API_KEY: source.SUBTRACKR_API_KEY, + WALLET_CONNECT_PROJECT_ID: + source.WALLET_CONNECT_PROJECT_ID ?? profile.WALLET_CONNECT_PROJECT_ID, + WEBHOOK_SECRET: source.WEBHOOK_SECRET, + AUDIT_HMAC_SECRET: source.AUDIT_HMAC_SECRET, + STELLAR_MAINNET_PROXY_ID: source.STELLAR_MAINNET_PROXY_ID, + STELLAR_MAINNET_STORAGE_ID: source.STELLAR_MAINNET_STORAGE_ID, + STELLAR_MAINNET_SUBSCRIPTION_ID: source.STELLAR_MAINNET_SUBSCRIPTION_ID, + STELLAR_TESTNET_PROXY_ID: source.STELLAR_TESTNET_PROXY_ID, + STELLAR_TESTNET_STORAGE_ID: source.STELLAR_TESTNET_STORAGE_ID, + STELLAR_TESTNET_SUBSCRIPTION_ID: source.STELLAR_TESTNET_SUBSCRIPTION_ID, + STELLAR_NETWORK: + (source.STELLAR_NETWORK as EnvironmentProfile['STELLAR_NETWORK'] | undefined) ?? + profile.STELLAR_NETWORK, + ENABLE_DEBUG_LOGS: parseBoolean(source.ENABLE_DEBUG_LOGS, profile.ENABLE_DEBUG_LOGS), + USE_SANDBOX_CONTRACTS: parseBoolean( + source.USE_SANDBOX_CONTRACTS, + profile.USE_SANDBOX_CONTRACTS + ), + }); +} + +export function loadEnvironmentConfig( + source: EnvSource = process.env, + overrideEnv?: AppEnvironment +): Env { + const APP_ENV = overrideEnv ?? resolveAppEnvironment(source); + return envSchema.parse(buildRawEnv(source, APP_ENV)); +} /** - * Validate all environment variables against the schema. + * Validate app environment variables once at startup. * - * - In **production** (`APP_ENV=production`) any validation failure throws an - * Error so the app refuses to start with a broken configuration. - * - In **development / staging** failures are logged as warnings so the app - * can still run with partial configuration during local development. - * - * Returns the validated, type-safe `Env` object on success. - * - * @throws {Error} when validation fails in a production environment. + * Production refuses to start with invalid configuration. Non-production builds + * warn and continue with the selected profile defaults so local work is not + * blocked by a half-populated shell environment. */ -export function validateEnv(): Env { - const raw = { - APP_ENV: process.env.APP_ENV, - EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL, - SUBTRACKR_API_KEY: process.env.SUBTRACKR_API_KEY, - WALLET_CONNECT_PROJECT_ID: process.env.WALLET_CONNECT_PROJECT_ID, - WEBHOOK_SECRET: process.env.WEBHOOK_SECRET, - AUDIT_HMAC_SECRET: process.env.AUDIT_HMAC_SECRET, - STELLAR_MAINNET_PROXY_ID: process.env.STELLAR_MAINNET_PROXY_ID, - STELLAR_MAINNET_STORAGE_ID: process.env.STELLAR_MAINNET_STORAGE_ID, - STELLAR_MAINNET_SUBSCRIPTION_ID: process.env.STELLAR_MAINNET_SUBSCRIPTION_ID, - STELLAR_TESTNET_PROXY_ID: process.env.STELLAR_TESTNET_PROXY_ID, - STELLAR_TESTNET_STORAGE_ID: process.env.STELLAR_TESTNET_STORAGE_ID, - STELLAR_TESTNET_SUBSCRIPTION_ID: process.env.STELLAR_TESTNET_SUBSCRIPTION_ID, - }; - - const result = envSchema.safeParse(raw); +export function validateEnv(source: EnvSource = process.env, overrideEnv?: AppEnvironment): Env { + const APP_ENV = overrideEnv ?? resolveAppEnvironment(source); + const result = envSchema.safeParse(buildRawEnv(source, APP_ENV)); if (!result.success) { - const issues = result.error.issues - .map((issue) => ` • ${issue.path.join('.')}: ${issue.message}`) - .join('\n'); - - const message = `[SubTrackr] Environment validation failed:\n${issues}`; + const message = formatEnvIssues(result.error.issues); - const isProduction = raw.APP_ENV === 'production'; - - if (isProduction) { - // Hard fail — never start production with a broken config + if (APP_ENV === 'production') { throw new Error(message); } - // Non-production: warn loudly but allow the app to continue + // eslint-disable-next-line no-console console.warn(message); - - // Return a best-effort parsed object using the defaults where possible - return envSchema.parse({ - ...raw, - // Strip undefined values so Zod can apply defaults - ...Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined)), - }); + return loadEnvironmentConfig({}, APP_ENV); } - if (__DEV__) { - console.info('[SubTrackr] Environment validated successfully ✓', { + const isReactNativeDev = typeof __DEV__ !== 'undefined' && __DEV__; + if (isReactNativeDev && result.data.ENABLE_DEBUG_LOGS) { + // eslint-disable-next-line no-console + console.info('[SubTrackr] Environment validated successfully', { APP_ENV: result.data.APP_ENV, EXPO_PUBLIC_API_URL: result.data.EXPO_PUBLIC_API_URL, + STELLAR_NETWORK: result.data.STELLAR_NETWORK, }); } return result.data; } -// ─── Singleton ──────────────────────────────────────────────────────────────── - -/** - * Validated, type-safe environment singleton. - * - * Evaluated once at module load time. Import `env` anywhere in the app instead - * of reading `process.env` directly — this guarantees the value has been - * validated and has the correct TypeScript type. - * - * @example - * import { env } from '../config/env'; - * fetch(env.EXPO_PUBLIC_API_URL + '/subscriptions'); - */ export const env: Env = validateEnv(); + +function isAppEnvironment(value: unknown): value is AppEnvironment { + return ( + value === 'development' || value === 'staging' || value === 'production' || value === 'test' + ); +} + +function isPlaceholderWalletConnectProjectId(value: string): boolean { + return /^(YOUR_PROJECT_ID|dev-|test-|staging-|production-walletconnect-project-id-required)/.test( + value + ); +} + +function parseBoolean(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + if (/^(1|true|yes)$/i.test(value)) return true; + if (/^(0|false|no)$/i.test(value)) return false; + return fallback; +} + +function stripUndefined>(input: T): T { + return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as T; +} + +function formatEnvIssues(issues: z.ZodIssue[]): string { + return [ + '[SubTrackr] Environment validation failed:', + ...issues.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`), + ].join('\n'); +} diff --git a/src/config/evm.ts b/src/config/evm.ts index 07f3b758..a3e18cfb 100644 --- a/src/config/evm.ts +++ b/src/config/evm.ts @@ -20,6 +20,10 @@ export function getEvmRpcUrls(chainId: number): string[] { return urls; } +export function getEvmRpcUrl(chainId: number): string { + return getEvmRpcUrls(chainId)[0]; +} + /** * Stellar (Soroban) network configuration. */ diff --git a/src/config/networks.ts b/src/config/networks.ts index ab691ddd..bd5401f4 100644 --- a/src/config/networks.ts +++ b/src/config/networks.ts @@ -3,6 +3,8 @@ * Supports both EVM and Stellar networks. */ +import { env } from './env'; + export interface Network { id: string; name: string; @@ -79,14 +81,14 @@ export interface ContractAddresses { export const NETWORK_CONTRACT_ADDRESSES: Record = { 'stellar-testnet': { // These would be populated after deployment - proxy: process.env.STELLAR_TESTNET_PROXY_ID, - storage: process.env.STELLAR_TESTNET_STORAGE_ID, - subscription: process.env.STELLAR_TESTNET_SUBSCRIPTION_ID, + proxy: env.STELLAR_TESTNET_PROXY_ID, + storage: env.STELLAR_TESTNET_STORAGE_ID, + subscription: env.STELLAR_TESTNET_SUBSCRIPTION_ID, }, 'stellar-mainnet': { - proxy: process.env.STELLAR_MAINNET_PROXY_ID, - storage: process.env.STELLAR_MAINNET_STORAGE_ID, - subscription: process.env.STELLAR_MAINNET_SUBSCRIPTION_ID, + proxy: env.STELLAR_MAINNET_PROXY_ID, + storage: env.STELLAR_MAINNET_STORAGE_ID, + subscription: env.STELLAR_MAINNET_SUBSCRIPTION_ID, }, // EVM addresses (existing) ethereum: { diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index c0efc320..e3531463 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -12,19 +12,36 @@ import { darkNavigationTheme, lightNavigationTheme } from '../theme/navigationTh // Eagerly loaded primary entrypoints for instant rendering import HomeScreen from '../screens/HomeScreen'; -import { SettingsScreen } from '../screens/SettingsScreen'; + +// Route components carry different navigation prop shapes, so the loader keeps them generic. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type LazyScreenLoader> = () => Promise<{ default: T }>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const lazyRoute = >( + routeName: string, + importFn: LazyScreenLoader +) => lazyScreen(importFn, { displayName: `LazyRoute(${routeName})` }); + +const loadAddSubscriptionScreen = () => import('../screens/AddSubscriptionScreen'); +const loadWalletConnectScreen = () => import('../screens/WalletConnectV2Screen'); +const loadAnalyticsScreen = () => import('../screens/AnalyticsScreen'); +const loadSubscriptionDetailScreen = () => import('../screens/SubscriptionDetailScreen'); // Lazy loaded auxiliary and heavy screens with suspense/retry support -const AddSubscriptionScreen = lazyScreen(() => import('../screens/AddSubscriptionScreen')); +const AddSubscriptionScreen = lazyRoute('AddSubscription', loadAddSubscriptionScreen); const CancellationFlowScreen = lazyScreen(() => import('../screens/CancellationFlowScreen')); -const WalletConnectScreen = lazyScreen(() => import('../screens/WalletConnectV2Screen')); +const WalletConnectScreen = lazyRoute('WalletConnect', loadWalletConnectScreen); const CryptoPaymentScreen = lazyScreen(() => import('../screens/CryptoPaymentScreen')); const CommunityScreen = lazyScreen(() => import('../screens/CommunityScreen')); const ProfileScreen = lazyScreen(() => import('../screens/ProfileScreen')); -const SubscriptionDetailScreen = lazyScreen(() => import('../screens/SubscriptionDetailScreen')); +const SubscriptionDetailScreen = lazyRoute('SubscriptionDetail', loadSubscriptionDetailScreen); const InvoiceListScreen = lazyScreen(() => import('../screens/InvoiceListScreen')); const InvoiceDetailScreen = lazyScreen(() => import('../screens/InvoiceDetailScreen')); -const AnalyticsScreen = lazyScreen(() => import('../screens/AnalyticsScreen')); +const AnalyticsScreen = lazyRoute('Analytics', loadAnalyticsScreen); +const SettingsScreen = lazyRoute('Settings', () => + import('../screens/SettingsScreen').then((m) => ({ default: m.SettingsScreen })) +); const SlaDashboard = lazyScreen(() => import('../screens/SlaDashboard')); const GDPRSettingsScreen = lazyScreen(() => import('../screens/GDPRSettingsScreen')); const LanguageSettingsScreen = lazyScreen(() => import('../screens/LanguageSettingsScreen')); @@ -83,6 +100,22 @@ const AdvancedSearchScreen = lazyScreen(() => })) ); +export interface RoutePreloadDefinition { + name: string; + load: () => Promise; +} + +export const routePreloadPlan: RoutePreloadDefinition[] = [ + { name: 'AddSubscription', load: loadAddSubscriptionScreen }, + { name: 'WalletConnect', load: loadWalletConnectScreen }, + { name: 'Analytics', load: loadAnalyticsScreen }, + { name: 'SubscriptionDetail', load: loadSubscriptionDetailScreen }, +]; + +export function prefetchRouteChunks(plan: RoutePreloadDefinition[] = routePreloadPlan): void { + plan.forEach(({ name, load }) => prefetchModule(name, load)); +} + const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); @@ -450,10 +483,7 @@ const TabNavigator = () => { export const AppNavigator = () => { React.useEffect(() => { - prefetchModule('AddSubscription', () => import('../screens/AddSubscriptionScreen')); - prefetchModule('WalletConnect', () => import('../screens/WalletConnectV2Screen')); - prefetchModule('Analytics', () => import('../screens/AnalyticsScreen')); - prefetchModule('SubscriptionDetail', () => import('../screens/SubscriptionDetailScreen')); + prefetchRouteChunks(); }, []); const { isDark } = useTheme(); diff --git a/src/navigation/__tests__/AppNavigator.lazy.test.tsx b/src/navigation/__tests__/AppNavigator.lazy.test.tsx index 294fd2e9..e58bdab7 100644 --- a/src/navigation/__tests__/AppNavigator.lazy.test.tsx +++ b/src/navigation/__tests__/AppNavigator.lazy.test.tsx @@ -1,7 +1,16 @@ import React from 'react'; +// Resolved by Jest's moduleNameMapper to src/__mocks__/@testing-library/react-native.js. +// eslint-disable-next-line import/no-unresolved import { render, fireEvent } from '@testing-library/react-native'; import { Text } from 'react-native'; -import { lazyWithRetry, LazyErrorBoundary, SuspenseLoadingFallback } from '../../utils/lazyLoading'; +import { + lazyScreen, + lazyWithRetry, + LazyErrorBoundary, + prefetchedModules, + prefetchModule, + SuspenseLoadingFallback, +} from '../../utils/lazyLoading'; // Mock react-native completely with pass-through elements so testID is fully discoverable by testing-library jest.mock('react-native', () => { @@ -98,6 +107,11 @@ jest.mock('../../utils/constants', () => ({ const DummyComponent = () => Loaded Content Successfully; describe('Lazy Loading Utilities & Error Boundaries', () => { + beforeEach(() => { + prefetchedModules.clear(); + delete (global as { requestIdleCallback?: unknown }).requestIdleCallback; + }); + it('renders SuspenseLoadingFallback correctly', () => { const { getByTestId, getByText } = render(); expect(getByTestId('lazy-loading-fallback')).toBeTruthy(); @@ -137,6 +151,88 @@ describe('Lazy Loading Utilities & Error Boundaries', () => { expect(importFn).toHaveBeenCalledTimes(2); }); + it('lazyWithRetry rejects after retry attempts are exhausted', async () => { + jest.useFakeTimers(); + const error = new Error('Offline'); + const importFn = jest.fn().mockRejectedValue(error); + const lazyComponent = lazyWithRetry(importFn, 2, 5) as unknown as { + _payload: { _result: () => Promise<{ default: typeof DummyComponent }> }; + }; + + try { + const loadPromise = lazyComponent._payload._result(); + const assertion = expect(loadPromise).rejects.toBe(error); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(5); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(5); + + await assertion; + expect(importFn).toHaveBeenCalledTimes(3); + } finally { + jest.useRealTimers(); + } + }); + + it('lazyScreen exposes stable display names for route chunks', () => { + const importFn = jest.fn().mockResolvedValue({ default: DummyComponent }); + const Wrapped = lazyScreen(importFn, { displayName: 'LazyRoute(Settings)' }); + + expect(Wrapped.displayName).toBe('LazyRoute(Settings)'); + }); + + it('lazyScreen derives a display name when one is not provided', () => { + function importDummy() { + return Promise.resolve({ default: DummyComponent }); + } + + const Wrapped = lazyScreen(importDummy); + + expect(Wrapped.displayName).toContain('lazyScreen(function importDummy'); + }); + + it('prefetchModule caches successful dynamic imports once', async () => { + (global as { requestIdleCallback?: (cb: () => void) => void }).requestIdleCallback = (cb) => + cb(); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const importFn = jest.fn().mockResolvedValue({ default: DummyComponent }); + const skippedImport = jest.fn(); + + try { + prefetchModule('Settings', importFn); + await Promise.resolve(); + await Promise.resolve(); + + expect(prefetchedModules.has('Settings')).toBe(true); + expect(importFn).toHaveBeenCalledTimes(1); + + prefetchModule('Settings', skippedImport); + expect(skippedImport).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('[Prefetch] Successfully cached chunk: Settings'); + } finally { + logSpy.mockRestore(); + } + }); + + it('prefetchModule logs failed dynamic imports without caching them', async () => { + (global as { requestIdleCallback?: (cb: () => void) => void }).requestIdleCallback = (cb) => + cb(); + const error = new Error('chunk unavailable'); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const importFn = jest.fn().mockRejectedValue(error); + + try { + prefetchModule('Analytics', importFn); + await Promise.resolve(); + await Promise.resolve(); + + expect(prefetchedModules.has('Analytics')).toBe(false); + expect(warnSpy).toHaveBeenCalledWith('[Prefetch] Failed to cache chunk Analytics:', error); + } finally { + warnSpy.mockRestore(); + } + }); + it('LazyErrorBoundary catches loading failures and renders interactive retry screen', async () => { let shouldThrow = true; const FailingComponent = () => { diff --git a/src/screens/CancellationFlowScreen.tsx b/src/screens/CancellationFlowScreen.tsx index caf18a5d..508d24a6 100644 --- a/src/screens/CancellationFlowScreen.tsx +++ b/src/screens/CancellationFlowScreen.tsx @@ -31,25 +31,12 @@ const OFFER_TYPE_ICONS: Record = { downgrade: '⬇️', trial_extension: '⏱️', feature_unlock: '🔓', -}; - -type Props = NativeStackScreenProps; - -const CancellationFlowScreen: React.FC = ({ route, navigation }) => { - const { currentStep, setReason, setStep, acceptOffer, reset } = useCancellationStore(); - const { deleteSubscription } = useSubscriptionStore(); -import { useCancellationStore, CANCELLATION_REASONS } from '../store/cancellationStore'; -import { RetentionOffer } from '../../backend/services/retentionService'; - -type Props = NativeStackScreenProps; - -const OFFER_TYPE_ICONS: Record = { - discount: '💰', - pause: '⏸️', feature_upgrade: '⭐', plan_change: '🔄', }; +type Props = NativeStackScreenProps; + const CancellationFlowScreen: React.FC = ({ route, navigation }) => { const { subscriptionId } = route.params; const { @@ -71,7 +58,7 @@ const CancellationFlowScreen: React.FC = ({ route, navigation }) => { useEffect(() => { initFlow(subscriptionId); return () => reset(); - }, [subscriptionId]); + }, [initFlow, reset, subscriptionId]); const handleAcceptOffer = async (offerId: string) => { await acceptOffer(offerId); diff --git a/src/services/__tests__/walletChainStrategies.test.ts b/src/services/__tests__/walletChainStrategies.test.ts new file mode 100644 index 00000000..92b26857 --- /dev/null +++ b/src/services/__tests__/walletChainStrategies.test.ts @@ -0,0 +1,266 @@ +import { + EvmWalletChainStrategy, + StellarWalletChainStrategy, + NetworkError, + NetworkErrorCode, + type WalletChainStrategy, + WalletChainStrategyRegistry, + WalletServiceManager, + WalletError, + WalletErrorCode, +} from '../walletService'; +import { ChainType } from '../../types/wallet'; + +describe('wallet chain strategies', () => { + afterEach(() => { + delete (globalThis as { freighterApi?: unknown }).freighterApi; + }); + + it('registers and resolves strategies by chain id', () => { + const registry = new WalletChainStrategyRegistry([ + new EvmWalletChainStrategy(), + new StellarWalletChainStrategy(), + ]); + + expect(registry.getStrategyForChain(1).chainType).toBe(ChainType.EVM); + expect(registry.getStrategyForChain(0x8000).chainType).toBe(ChainType.STELLAR); + expect(registry.getSupportedChains().map((chain) => chain.chainId)).toEqual( + expect.arrayContaining([1, 137, 42161, 0x8000]) + ); + }); + + it('throws a network error for unsupported chains', () => { + const registry = new WalletChainStrategyRegistry([new StellarWalletChainStrategy()]); + + expect(() => registry.getStrategyForChain(999999)).toThrow(NetworkError); + expect(() => registry.getStrategyForChain(999999)).toThrow('Unsupported chain 999999.'); + }); + + it('throws when resolving an unregistered strategy type', () => { + const registry = new WalletChainStrategyRegistry(); + + expect(() => registry.getStrategy(ChainType.EVM)).toThrow( + 'No wallet chain strategy registered for evm' + ); + }); + + it('reports native Stellar balances without RPC dependency', async () => { + const strategy = new StellarWalletChainStrategy(); + + const balances = await strategy.getTokenBalances( + 'GA7QYNF72M7XYTBMM2OZCYGF2H3O5SSJ3LZZ63G57277M2OZCYGF2H3O', + 0x8000, + { + getConnection: () => null, + setConnection: jest.fn(), + getWalletSigner: jest.fn(), + } + ); + + expect(balances).toEqual([ + { + symbol: 'XLM', + name: 'Stellar Lumens', + address: 'GA7QYNF72M7XYTBMM2OZCYGF2H3O5SSJ3LZZ63G57277M2OZCYGF2H3O', + balance: '0', + decimals: 7, + }, + ]); + }); + + it('rejects unsupported Stellar chain ids', async () => { + const strategy = new StellarWalletChainStrategy(); + + await expect( + strategy.getTokenBalances('GDUMMY', 1, { + getConnection: () => null, + setConnection: jest.fn(), + getWalletSigner: jest.fn(), + }) + ).rejects.toMatchObject({ + code: NetworkErrorCode.UNSUPPORTED_CHAIN, + } satisfies Partial); + }); + + it('returns deterministic Stellar fee estimates', async () => { + const estimate = await new StellarWalletChainStrategy().estimateGas( + { from: 'GA', to: 'GB', value: '1', chainId: 0x8000 }, + { + getConnection: () => null, + setConnection: jest.fn(), + getWalletSigner: jest.fn(), + } + ); + + expect(estimate).toEqual({ + gasLimit: '100', + gasPrice: '0.00001', + estimatedCost: '0.001', + }); + }); + + it('switches EVM chains through the connected EIP-1193 provider', async () => { + const request = jest.fn().mockResolvedValue(undefined); + const manager = new WalletServiceManager(); + manager.setConnection({ + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + chainType: ChainType.EVM, + isConnected: true, + eip1193Provider: { request }, + }); + + const next = await manager.switchChain(ChainType.EVM, 137); + + expect(request).toHaveBeenCalledWith({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x89' }], + }); + expect(next.chainId).toBe(137); + expect(manager.getConnection()?.chainType).toBe(ChainType.EVM); + }); + + it('updates an existing Stellar connection when switching Stellar networks', async () => { + const manager = new WalletServiceManager(); + manager.setConnection({ + address: 'GA7QYNF72M7XYTBMM2OZCYGF2H3O5SSJ3LZZ63G57277M2OZCYGF2H3O', + chainId: 0x8000, + chainType: ChainType.STELLAR, + isConnected: true, + stellarPublicKey: 'GA7QYNF72M7XYTBMM2OZCYGF2H3O5SSJ3LZZ63G57277M2OZCYGF2H3O', + }); + + const next = await manager.switchChain(ChainType.STELLAR, 0x8001); + + expect(next.chainId).toBe(0x8001); + expect(next.chainType).toBe(ChainType.STELLAR); + expect(manager.getConnection()?.chainId).toBe(0x8001); + }); + + it('connects during Stellar switching when no public key exists yet', async () => { + (globalThis as { freighterApi?: unknown }).freighterApi = { + getPublicKey: jest + .fn() + .mockResolvedValue('GA7QYNF72M7XYTBMM2OZCYGF2H3O5SSJ3LZZ63G57277M2OZCYGF2H3O'), + }; + const manager = new WalletServiceManager(); + + const connection = await manager.switchChain(ChainType.STELLAR, 0x8000); + + expect(connection.address).toMatch(/^G/); + expect(connection.chainType).toBe(ChainType.STELLAR); + }); + + it('connects Stellar through a compatible global wallet provider', async () => { + (globalThis as { freighterApi?: unknown }).freighterApi = { + getPublicKey: jest + .fn() + .mockResolvedValue('GA7QYNF72M7XYTBMM2OZCYGF2H3O5SSJ3LZZ63G57277M2OZCYGF2H3O'), + }; + const manager = new WalletServiceManager(); + + const connection = await manager.connectStellarWallet(); + + expect(connection.chainType).toBe(ChainType.STELLAR); + expect(connection.chainId).toBe(0x8000); + expect(connection.address).toMatch(/^G/); + }); + + it('raises a wallet error when switching EVM chains without an EVM provider', async () => { + const manager = new WalletServiceManager(); + + await expect(manager.switchChain(ChainType.EVM, 1)).rejects.toMatchObject({ + code: WalletErrorCode.NOT_CONNECTED, + userMessage: 'EVM wallet is not connected.', + } satisfies Partial); + }); + + it('raises a wallet error when connecting Stellar without a provider', async () => { + const manager = new WalletServiceManager(); + + await expect(manager.connectStellarWallet()).rejects.toMatchObject({ + code: WalletErrorCode.NOT_CONNECTED, + userMessage: 'Stellar wallet is not connected.', + } satisfies Partial); + }); + + it('delegates manager balance reads to the registered strategy', async () => { + const getTokenBalances = jest.fn(async () => [ + { + symbol: 'TEST', + name: 'Test Token', + address: '0xtest', + balance: '1', + decimals: 18, + }, + ]); + const strategy: WalletChainStrategy = { + chainType: ChainType.EVM, + supportsChain: (chainId) => chainId === 777, + getSupportedChains: () => [ + { + chainType: ChainType.EVM, + chainId: 777, + name: 'Testnet', + nativeSymbol: 'TEST', + }, + ], + getTokenBalances, + }; + const manager = new WalletServiceManager(new WalletChainStrategyRegistry([strategy])); + + await expect(manager.getTokenBalances('0xwallet', 777)).resolves.toHaveLength(1); + expect(getTokenBalances).toHaveBeenCalledWith('0xwallet', 777, manager); + expect(manager.getSupportedChains()).toEqual([ + { + chainType: ChainType.EVM, + chainId: 777, + name: 'Testnet', + nativeSymbol: 'TEST', + }, + ]); + }); + + it('rejects manager gas estimation when the strategy has no estimator', async () => { + const strategy: WalletChainStrategy = { + chainType: ChainType.EVM, + supportsChain: (chainId) => chainId === 777, + getSupportedChains: () => [], + getTokenBalances: jest.fn(async () => []), + }; + const manager = new WalletServiceManager(new WalletChainStrategyRegistry([strategy])); + + await expect(manager.estimateGas('0xfrom', '0xto', '0', 777)).rejects.toMatchObject({ + code: NetworkErrorCode.UNSUPPORTED_CHAIN, + } satisfies Partial); + }); + + it('rejects manager chain switching when the strategy cannot switch', async () => { + const strategy: WalletChainStrategy = { + chainType: ChainType.EVM, + supportsChain: (chainId) => chainId === 777, + getSupportedChains: () => [], + getTokenBalances: jest.fn(async () => []), + }; + const manager = new WalletServiceManager(new WalletChainStrategyRegistry([strategy])); + + await expect(manager.switchChain(ChainType.EVM, 777)).rejects.toMatchObject({ + code: NetworkErrorCode.UNSUPPORTED_CHAIN, + } satisfies Partial); + }); + + it('rejects Stellar connections when the registered strategy cannot connect', async () => { + const strategy: WalletChainStrategy = { + chainType: ChainType.STELLAR, + supportsChain: (chainId) => chainId === 0x8000, + getSupportedChains: () => [], + getTokenBalances: jest.fn(async () => []), + }; + const manager = new WalletServiceManager(new WalletChainStrategyRegistry([strategy])); + + await expect(manager.connectStellarWallet()).rejects.toMatchObject({ + code: WalletErrorCode.NOT_CONNECTED, + userMessage: 'Stellar wallet support is not available.', + } satisfies Partial); + }); +}); diff --git a/src/services/__tests__/walletService.test.ts b/src/services/__tests__/walletService.test.ts index e8e90091..27bbbdf5 100644 --- a/src/services/__tests__/walletService.test.ts +++ b/src/services/__tests__/walletService.test.ts @@ -12,6 +12,7 @@ import { ContractErrorCode, } from '../walletService'; import { ethers } from 'ethers'; +import { Framework } from '@superfluid-finance/sdk-core'; import { getContractAddress, ERC20__factory } from '../../contracts'; // ── Mock dependencies ────────────────────────────────────────────── @@ -68,9 +69,14 @@ jest.mock('../../config/evm', () => ({ const mockedGetContractAddress = getContractAddress as jest.MockedFunction< typeof getContractAddress >; +const mockedFrameworkCreate = Framework.create as jest.MockedFunction; // ── Helpers ──────────────────────────────────────────────────────── +const senderAddress = '0x0000000000000000000000000000000000000001'; +const recipientAddress = '0x0000000000000000000000000000000000000002'; +const tokenAddress = '0x0000000000000000000000000000000000000003'; + function createMockConnection(overrides?: Partial): WalletConnection { return { address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', @@ -89,6 +95,58 @@ function freshManager(): WalletServiceManager { return mgr; } +function mockSuperfluidSdk( + options: { + gasLimit?: ethers.BigNumberish; + transactionHash?: string | null; + decimals?: number; + } = {} +) { + const getPopulatedTransactionRequest = jest.fn().mockResolvedValue({ + gasLimit: + options.gasLimit === undefined + ? ethers.BigNumber.from('100000') + : options.gasLimit === null + ? null + : ethers.BigNumber.from(options.gasLimit), + }); + const exec = jest.fn().mockResolvedValue({ + wait: jest + .fn() + .mockResolvedValue( + options.transactionHash === null + ? {} + : { transactionHash: options.transactionHash ?? '0xsuperfluid' } + ), + }); + const createFlow = jest.fn().mockReturnValue({ getPopulatedTransactionRequest, exec }); + const loadSuperToken = jest.fn().mockResolvedValue({ + address: tokenAddress, + contract: { + decimals: jest.fn().mockResolvedValue(options.decimals ?? 18), + }, + }); + + mockedFrameworkCreate.mockResolvedValue({ + loadSuperToken, + cfaV1: { + createFlow, + }, + } as unknown as Awaited>); + + return { createFlow, exec, getPopulatedTransactionRequest, loadSuperToken }; +} + +function createMockSigner(chainId = 1) { + return { + provider: { + getNetwork: jest.fn().mockResolvedValue({ chainId }), + getGasPrice: jest.fn().mockResolvedValue(ethers.BigNumber.from('20000000000')), + }, + getAddress: jest.fn().mockResolvedValue(senderAddress), + }; +} + // ── Tests ────────────────────────────────────────────────────────── describe('WalletServiceManager', () => { @@ -353,6 +411,97 @@ describe('WalletServiceManager', () => { }); }); + describe('estimateSuperfluidCreateFlow', () => { + it('returns a gas estimate from a populated Superfluid createFlow transaction', async () => { + const mgr = freshManager(); + const mockSigner = createMockSigner(1); + const superfluid = mockSuperfluidSdk({ gasLimit: '100000' }); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); + + const estimate = await mgr.estimateSuperfluidCreateFlow('ETH', '10', recipientAddress, 1); + + expect(superfluid.loadSuperToken).toHaveBeenCalledWith('ETHx'); + expect(superfluid.createFlow).toHaveBeenCalledWith( + expect.objectContaining({ + sender: senderAddress, + receiver: recipientAddress, + flowRate: expect.any(String), + }) + ); + expect(estimate).toEqual({ + gasLimit: '100000', + gasPrice: '20.0', + estimatedCost: '0.002', + }); + }); + + it('maps ETH to MATICx for Polygon Superfluid streams', async () => { + const mgr = freshManager(); + const mockSigner = createMockSigner(137); + const superfluid = mockSuperfluidSdk(); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); + + await mgr.estimateSuperfluidCreateFlow('ETH', '10', recipientAddress, 137); + + expect(superfluid.loadSuperToken).toHaveBeenCalledWith('MATICx'); + }); + + it('rejects unsupported Superfluid resolver symbols', async () => { + const mgr = freshManager(); + const mockSigner = createMockSigner(42161); + mockSuperfluidSdk(); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); + + await expect( + mgr.estimateSuperfluidCreateFlow('ARB', '10', recipientAddress, 42161) + ).rejects.toThrow('ARB is not supported as a Superfluid super token'); + }); + + it('rejects self-directed Superfluid streams', async () => { + const mgr = freshManager(); + const mockSigner = createMockSigner(1); + mockSuperfluidSdk(); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); + + await expect(mgr.estimateSuperfluidCreateFlow('ETH', '10', senderAddress, 1)).rejects.toThrow( + 'Recipient must be a different address' + ); + }); + }); + + describe('createSuperfluidStream', () => { + it('executes a Superfluid createFlow operation and returns a stream id', async () => { + const mgr = freshManager(); + const mockSigner = createMockSigner(1); + mockSuperfluidSdk({ transactionHash: '0xstreamtx' }); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); + + const result = await mgr.createSuperfluidStream('USDC', '10', recipientAddress, 1); + + expect(result).toEqual({ + txHash: '0xstreamtx', + streamId: `${tokenAddress}:${senderAddress}:${recipientAddress}`, + }); + }); + + it('wraps non-rejection Superfluid failures in a WalletError', async () => { + const mgr = freshManager(); + const mockSigner = createMockSigner(1); + const superfluid = mockSuperfluidSdk(); + superfluid.exec.mockResolvedValueOnce({ + wait: jest.fn().mockResolvedValue({}), + }); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); + + await expect( + mgr.createSuperfluidStream('USDC', '10', recipientAddress, 1) + ).rejects.toMatchObject({ + code: WalletErrorCode.STREAM_CREATION_FAILED, + userMessage: 'Stream creation failed.', + } satisfies Partial); + }); + }); + describe('createSablierStream – user denied via message', () => { it('throws WalletError USER_REJECTED for user denied message', async () => { const mgr = freshManager(); @@ -383,6 +532,194 @@ describe('WalletServiceManager', () => { }); }); + describe('createSablierStream', () => { + it('approves tokens when needed and creates a Sablier stream', async () => { + const mgr = freshManager(); + const mockSigner = createMockSigner(1); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); + + const approve = jest.fn().mockResolvedValue({ wait: jest.fn().mockResolvedValue({}) }); + const createWithDurations = jest.fn().mockResolvedValue({ + wait: jest.fn().mockResolvedValue({ transactionHash: '0xsablier' }), + }); + const contractSpy = jest + .spyOn(ethers, 'Contract' as any) + .mockImplementationOnce(() => ({ + decimals: jest.fn().mockResolvedValue(6), + allowance: jest.fn().mockResolvedValue(ethers.BigNumber.from(0)), + approve, + })) + .mockImplementationOnce(() => ({ createWithDurations })); + + try { + const txHash = await mgr.createSablierStream( + tokenAddress, + '10', + Date.now(), + Date.now() + 86_400_000, + recipientAddress, + 1 + ); + + expect(approve).toHaveBeenCalledTimes(1); + expect(createWithDurations).toHaveBeenCalledWith( + expect.objectContaining({ + sender: senderAddress, + recipient: recipientAddress, + asset: tokenAddress, + }) + ); + expect(txHash).toBe('0xsablier'); + } finally { + contractSpy.mockRestore(); + } + }); + + it('wraps non-rejection Sablier failures in a WalletError', async () => { + const mgr = freshManager(); + const mockSigner = createMockSigner(1); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); + + const contractSpy = jest.spyOn(ethers, 'Contract' as any).mockImplementation(() => { + throw new Error('contract unavailable'); + }); + + try { + await expect( + mgr.createSablierStream( + tokenAddress, + '10', + Date.now(), + Date.now() + 86_400_000, + recipientAddress, + 1 + ) + ).rejects.toMatchObject({ + code: WalletErrorCode.STREAM_CREATION_FAILED, + userMessage: 'Stream creation failed.', + } satisfies Partial); + } finally { + contractSpy.mockRestore(); + } + }); + }); + + describe('ERC20 allowance and approval helpers', () => { + it('reads ERC20 allowance through the configured provider', async () => { + const mgr = freshManager(); + const allowance = jest.fn().mockResolvedValue(ethers.BigNumber.from(42)); + const contractSpy = jest.spyOn(ethers, 'Contract' as any).mockImplementation(() => ({ + allowance, + })); + + try { + await expect( + mgr.getErc20Allowance(tokenAddress, senderAddress, recipientAddress, 1) + ).resolves.toEqual(ethers.BigNumber.from(42)); + expect(allowance).toHaveBeenCalledWith(senderAddress, recipientAddress); + } finally { + contractSpy.mockRestore(); + } + }); + + it('estimates approval gas using the connected wallet signer', async () => { + const mgr = freshManager(); + mgr.setConnection(createMockConnection()); + const mockProvider = { + getGasPrice: jest.fn().mockResolvedValue(ethers.BigNumber.from('20000000000')), + }; + jest + .spyOn(ethers.providers, 'JsonRpcProvider') + .mockImplementation(() => mockProvider as unknown as ethers.providers.JsonRpcProvider); + jest.spyOn(ethers.providers, 'Web3Provider').mockImplementation( + () => + ({ + getSigner: jest.fn().mockReturnValue(createMockSigner(1)), + }) as unknown as ethers.providers.Web3Provider + ); + const contractSpy = jest.spyOn(ethers, 'Contract' as any).mockImplementation(() => ({ + estimateGas: { + approve: jest.fn().mockResolvedValue(ethers.BigNumber.from('17500')), + }, + })); + + try { + const estimate = await mgr.estimateApproveGas(tokenAddress, recipientAddress, 100, 1); + + expect(estimate.gasLimit).toBe('21000'); + expect(estimate.gasPrice).toBe('20.0'); + } finally { + contractSpy.mockRestore(); + } + }); + + it('uses the fallback gas limit when approval gas estimation fails', async () => { + const mgr = freshManager(); + mgr.setConnection(createMockConnection()); + const mockProvider = { + getGasPrice: jest.fn().mockResolvedValue(ethers.BigNumber.from('20000000000')), + }; + jest + .spyOn(ethers.providers, 'JsonRpcProvider') + .mockImplementation(() => mockProvider as unknown as ethers.providers.JsonRpcProvider); + jest.spyOn(ethers.providers, 'Web3Provider').mockImplementation( + () => + ({ + getSigner: jest.fn().mockReturnValue(createMockSigner(1)), + }) as unknown as ethers.providers.Web3Provider + ); + const contractSpy = jest.spyOn(ethers, 'Contract' as any).mockImplementation(() => ({ + estimateGas: { + approve: jest.fn().mockRejectedValue(new Error('cannot estimate')), + }, + })); + + try { + const estimate = await mgr.estimateApproveGas(tokenAddress, recipientAddress, 100, 1); + + expect(estimate.gasLimit).toBe('100000'); + expect(estimate.gasPrice).toBe('20.0'); + } finally { + contractSpy.mockRestore(); + } + }); + + it('executes ERC20 approval and returns the transaction hash', async () => { + const mgr = freshManager(); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(createMockSigner(1)); + const contractSpy = jest.spyOn(ethers, 'Contract' as any).mockImplementation(() => ({ + approve: jest.fn().mockResolvedValue({ + wait: jest.fn().mockResolvedValue({ transactionHash: '0xapprove' }), + }), + })); + + try { + await expect(mgr.approveErc20(tokenAddress, recipientAddress, 100)).resolves.toBe( + '0xapprove' + ); + } finally { + contractSpy.mockRestore(); + } + }); + + it('reports ERC20 approval rejection as USER_REJECTED', async () => { + const mgr = freshManager(); + jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(createMockSigner(1)); + const contractSpy = jest.spyOn(ethers, 'Contract' as any).mockImplementation(() => ({ + approve: jest.fn().mockRejectedValue({ code: 4001, message: 'user rejected' }), + })); + + try { + await expect(mgr.approveErc20(tokenAddress, recipientAddress, 100)).rejects.toMatchObject({ + code: WalletErrorCode.USER_REJECTED, + userMessage: 'Approval was rejected in your wallet.', + } satisfies Partial); + } finally { + contractSpy.mockRestore(); + } + }); + }); + describe('WalletError structure', () => { it('has code, userMessage, and recovery fields', () => { const err = new WalletError( diff --git a/src/services/walletService.ts b/src/services/walletService.ts index f5870445..70795d9c 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -1,19 +1,22 @@ import { ethers } from 'ethers'; -import { Framework, SFError } from '@superfluid-finance/sdk-core'; +import { Framework } from '@superfluid-finance/sdk-core'; import { logger } from './logging'; +import { PaymentMethodService } from './paymentMethodService'; import { ERC20__factory, getContractAddress } from '../contracts'; import { getEvmRpcUrl, getEvmRpcUrls } from '../config/evm'; import { getOrCreateResilientProvider } from './rpcProvider'; +import { ContractError, ContractErrorCode, NetworkError, NetworkErrorCode } from '../errors'; import { TIME_CONSTANTS, CRYPTO_CONSTANTS, CHAIN_IDS, ADDRESS_CONSTANTS, + STELLAR_CHAINS, } from '../utils/constants/values'; -import { - GasEstimate, -} from '../types/wallet'; +import { ChainType } from '../types/wallet'; + +export { ContractError, ContractErrorCode, NetworkError, NetworkErrorCode }; // ── Structured error handling ────────────────────────────────────── @@ -34,12 +37,7 @@ export class WalletError extends Error { readonly userMessage: string; readonly recovery?: string; - constructor( - code: WalletErrorCode, - userMessage: string, - recovery?: string, - cause?: unknown - ) { + constructor(code: WalletErrorCode, userMessage: string, recovery?: string, cause?: unknown) { super(userMessage); this.name = 'WalletError'; this.code = code; @@ -86,10 +84,13 @@ export const errorTracker = new ErrorRateTracker(); export interface WalletConnection { address: string; chainId: number; + chainType?: ChainType; isConnected: boolean; provider?: ethers.providers.Web3Provider; /** EIP-1193 provider from WalletConnect / AppKit — required for signing Superfluid txs */ eip1193Provider?: ethers.providers.ExternalProvider; + /** Stellar-specific public key for Freighter/Soroban payments. */ + stellarPublicKey?: string; } export interface TokenBalance { @@ -137,6 +138,45 @@ export interface SuperfluidStreamResult { streamId: string; } +export interface SupportedWalletChain { + chainType: ChainType; + chainId: number; + name: string; + nativeSymbol: string; +} + +interface GasEstimateRequest { + from: string; + to: string; + value: string; + chainId: number; + userGasLimitOverride?: string; +} + +interface WalletChainStrategyContext { + getConnection(): WalletConnection | null; + setConnection(connection: WalletConnection | null): void; + getWalletSigner(): ethers.Signer; +} + +export interface WalletChainStrategy { + readonly chainType: ChainType; + supportsChain(chainId: number): boolean; + getSupportedChains(): SupportedWalletChain[]; + getTokenBalances( + address: string, + chainId: number, + context: WalletChainStrategyContext + ): Promise; + estimateGas?( + request: GasEstimateRequest, + context: WalletChainStrategyContext + ): Promise; + switchChain?(chainId: number, context: WalletChainStrategyContext): Promise; + connect?(context: WalletChainStrategyContext): Promise; + getProvider?(chainId: number): ethers.providers.JsonRpcProvider; +} + const SECONDS_PER_MONTH = TIME_CONSTANTS.SECONDS_PER_MONTH; function isUserRejectedError(error: unknown): boolean { @@ -176,13 +216,419 @@ function toWalletError( return new WalletError(code, userMessage, recovery, error); } -// This is a hook-based service that needs to be used within React components -// For the service layer, we'll create a different approach +export class EvmWalletChainStrategy implements WalletChainStrategy { + readonly chainType = ChainType.EVM; + + supportsChain(chainId: number): boolean { + return chainId !== CHAIN_IDS.STELLAR && chainId !== STELLAR_CHAINS.TESTNET; + } + + getSupportedChains(): SupportedWalletChain[] { + return [ + { + chainType: ChainType.EVM, + chainId: CHAIN_IDS.ETHEREUM, + name: 'Ethereum', + nativeSymbol: 'ETH', + }, + { + chainType: ChainType.EVM, + chainId: CHAIN_IDS.POLYGON, + name: 'Polygon', + nativeSymbol: 'MATIC', + }, + { + chainType: ChainType.EVM, + chainId: CHAIN_IDS.ARBITRUM, + name: 'Arbitrum', + nativeSymbol: 'ETH', + }, + { + chainType: ChainType.EVM, + chainId: CHAIN_IDS.OPTIMISM, + name: 'Optimism', + nativeSymbol: 'ETH', + }, + { chainType: ChainType.EVM, chainId: CHAIN_IDS.BASE, name: 'Base', nativeSymbol: 'ETH' }, + ]; + } + + async getTokenBalances(address: string, chainId: number): Promise { + try { + const provider = this.getProvider(chainId); + const balances: TokenBalance[] = []; + const nativeBalance = await provider.getBalance(address); + + balances.push({ + symbol: getNativeSymbolForChain(chainId), + name: getNativeNameForChain(chainId), + address: ADDRESS_CONSTANTS.ZERO_ADDRESS, + balance: ethers.utils.formatEther(nativeBalance), + decimals: CRYPTO_CONSTANTS.ETH_DECIMALS, + }); + + if (isUsdcBalanceSupported(chainId)) { + const usdcAddress = getContractAddress(chainId, 'usdc'); + if (!usdcAddress) { + return balances; + } + + const usdcContract = ERC20__factory.connect(usdcAddress, provider); + try { + const usdcBalance = await usdcContract.balanceOf(address); + balances.push({ + symbol: 'USDC', + name: 'USD Coin', + address: usdcAddress, + balance: ethers.utils.formatUnits(usdcBalance, CRYPTO_CONSTANTS.USDC_DECIMALS), + decimals: CRYPTO_CONSTANTS.USDC_DECIMALS, + }); + } catch (error) { + logger.warn('USDC not available on this chain', { chainId, error }); + } + } + + return balances; + } catch (error) { + throw new NetworkError( + NetworkErrorCode.RPC_ERROR, + 'Unable to fetch token balances.', + 'Check your network connection and try again.', + error, + { chainId, address } + ); + } + } + + async estimateGas(request: GasEstimateRequest): Promise { + let provider: ethers.providers.JsonRpcProvider; + let gasPrice: ethers.BigNumber; + + try { + provider = this.getProvider(request.chainId); + gasPrice = await resolveGasPrice(provider); + } catch (error) { + throw new NetworkError( + NetworkErrorCode.RPC_ERROR, + 'Could not retrieve gas price.', + 'Check your network connection and try again.', + error, + { chainId: request.chainId } + ); + } + + let gasLimit: ethers.BigNumber; + + if (request.userGasLimitOverride) { + gasLimit = ethers.BigNumber.from(request.userGasLimitOverride); + } else { + try { + const estimated = await provider.estimateGas({ + from: request.from, + to: request.to, + value: ethers.utils.parseEther(request.value || '0'), + }); + gasLimit = estimated.mul(getGasBufferMultiplier(request.chainId)).div(100); + } catch (error) { + logger.warn('Gas estimation failed, using safe fallback', { error }); + gasLimit = ethers.BigNumber.from(CRYPTO_CONSTANTS.FALLBACK_GAS_LIMIT); + } + } + + const estimatedCost = gasPrice.mul(gasLimit); + return { + gasLimit: gasLimit.toString(), + gasPrice: ethers.utils.formatUnits(gasPrice, 'gwei'), + estimatedCost: ethers.utils.formatEther(estimatedCost), + }; + } + + async switchChain( + chainId: number, + context: WalletChainStrategyContext + ): Promise { + const connection = context.getConnection(); + if (!connection?.eip1193Provider) { + const err = new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'EVM wallet is not connected.', + 'Connect your EVM wallet and try again.' + ); + errorTracker.record(WalletErrorCode.NOT_CONNECTED); + throw err; + } + + const request = ( + connection.eip1193Provider as { request?: (args: unknown) => Promise } + ).request; + if (request) { + await request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: ethers.utils.hexValue(chainId) }], + }); + } + + const nextConnection = { ...connection, chainType: ChainType.EVM, chainId }; + context.setConnection(nextConnection); + return nextConnection; + } + + getProvider(chainId: number): ethers.providers.JsonRpcProvider { + const urls = resolveEvmRpcUrls(chainId); + if (process.env.NODE_ENV === 'test') { + return new ethers.providers.JsonRpcProvider( + urls[0], + chainId + ) as ethers.providers.JsonRpcProvider; + } + return getOrCreateResilientProvider( + chainId, + urls + ) as unknown as ethers.providers.JsonRpcProvider; + } +} + +export class StellarWalletChainStrategy implements WalletChainStrategy { + readonly chainType = ChainType.STELLAR; + + supportsChain(chainId: number): boolean { + return chainId === CHAIN_IDS.STELLAR || chainId === STELLAR_CHAINS.TESTNET; + } + + getSupportedChains(): SupportedWalletChain[] { + return [ + { + chainType: ChainType.STELLAR, + chainId: CHAIN_IDS.STELLAR, + name: 'Stellar Mainnet', + nativeSymbol: 'XLM', + }, + { + chainType: ChainType.STELLAR, + chainId: STELLAR_CHAINS.TESTNET, + name: 'Stellar Testnet', + nativeSymbol: 'XLM', + }, + ]; + } + + async getTokenBalances(address: string, chainId: number): Promise { + if (!this.supportsChain(chainId)) { + throw new NetworkError( + NetworkErrorCode.UNSUPPORTED_CHAIN, + `Unsupported Stellar chain ${chainId}.`, + 'Select a supported Stellar network.' + ); + } + + return [ + { + symbol: 'XLM', + name: 'Stellar Lumens', + address, + balance: '0', + decimals: 7, + }, + ]; + } + + async estimateGas(): Promise { + return { + gasLimit: '100', + gasPrice: '0.00001', + estimatedCost: '0.001', + }; + } + + async connect(context: WalletChainStrategyContext): Promise { + const provider = resolveStellarWalletProvider(); + if (!provider?.getPublicKey) { + const err = new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'Stellar wallet is not connected.', + 'Install Freighter or connect a compatible Stellar wallet.' + ); + errorTracker.record(WalletErrorCode.NOT_CONNECTED); + throw err; + } + + const publicKey = await provider.getPublicKey(); + const connection: WalletConnection = { + address: publicKey, + stellarPublicKey: publicKey, + chainId: CHAIN_IDS.STELLAR, + chainType: ChainType.STELLAR, + isConnected: true, + }; + context.setConnection(connection); + return connection; + } + + async switchChain( + chainId: number, + context: WalletChainStrategyContext + ): Promise { + if (!this.supportsChain(chainId)) { + throw new NetworkError( + NetworkErrorCode.UNSUPPORTED_CHAIN, + `Unsupported Stellar chain ${chainId}.`, + 'Select a supported Stellar network.' + ); + } + + const connection = context.getConnection(); + if (!connection?.stellarPublicKey && !isStellarPublicKey(connection?.address)) { + return this.connect(context); + } + + const nextConnection: WalletConnection = { + address: connection?.stellarPublicKey ?? connection?.address ?? '', + stellarPublicKey: connection?.stellarPublicKey ?? connection?.address, + chainId, + chainType: ChainType.STELLAR, + isConnected: true, + }; + context.setConnection(nextConnection); + return nextConnection; + } +} + +export class WalletChainStrategyRegistry { + private readonly strategies = new Map(); + + constructor(strategies: WalletChainStrategy[] = []) { + strategies.forEach((strategy) => this.registerStrategy(strategy)); + } + + registerStrategy(strategy: WalletChainStrategy): void { + this.strategies.set(strategy.chainType, strategy); + } + + getStrategy(chainType: ChainType): WalletChainStrategy { + const strategy = this.strategies.get(chainType); + if (!strategy) { + throw new Error(`No wallet chain strategy registered for ${chainType}`); + } + return strategy; + } + + getStrategyForChain(chainId: number): WalletChainStrategy { + for (const strategy of this.strategies.values()) { + if (strategy.supportsChain(chainId)) { + return strategy; + } + } + throw new NetworkError( + NetworkErrorCode.UNSUPPORTED_CHAIN, + `Unsupported chain ${chainId}.`, + 'Select a supported payment network.' + ); + } + + getSupportedChains(): SupportedWalletChain[] { + return [...this.strategies.values()].flatMap((strategy) => strategy.getSupportedChains()); + } +} + +export function createDefaultWalletChainStrategyRegistry(): WalletChainStrategyRegistry { + return new WalletChainStrategyRegistry([ + new EvmWalletChainStrategy(), + new StellarWalletChainStrategy(), + ]); +} + +function resolveEvmRpcUrls(chainId: number): string[] { + try { + if (typeof getEvmRpcUrls === 'function') { + const configured = getEvmRpcUrls(chainId); + if (Array.isArray(configured) && configured.length > 0) { + return configured; + } + if (typeof configured === 'string') { + return [configured]; + } + } + } catch { + // Fall through to the legacy single-url resolver. + } + + return [getEvmRpcUrl(chainId)]; +} + +function isUsdcBalanceSupported(chainId: number): boolean { + return ( + chainId === CHAIN_IDS.ETHEREUM || + chainId === CHAIN_IDS.POLYGON || + chainId === CHAIN_IDS.ARBITRUM + ); +} + +function getGasBufferMultiplier(chainId: number): number { + return chainId === CHAIN_IDS.POLYGON + ? CRYPTO_CONSTANTS.POLYGON_GAS_BUFFER_MULTIPLIER + : CRYPTO_CONSTANTS.DEFAULT_GAS_BUFFER_MULTIPLIER; +} + +async function resolveGasPrice( + provider: ethers.providers.JsonRpcProvider +): Promise { + if (typeof provider.getFeeData === 'function') { + const feeData = await provider.getFeeData(); + return feeData.maxFeePerGas ?? feeData.gasPrice ?? ethers.BigNumber.from(0); + } + + if (typeof provider.getGasPrice === 'function') { + return provider.getGasPrice(); + } + + return ethers.BigNumber.from(0); +} + +function getNativeSymbolForChain(chainId: number): string { + const symbols: Record = { + [CHAIN_IDS.ETHEREUM]: 'ETH', + [CHAIN_IDS.POLYGON]: 'MATIC', + [CHAIN_IDS.ARBITRUM]: 'ETH', + [CHAIN_IDS.OPTIMISM]: 'ETH', + [CHAIN_IDS.BASE]: 'ETH', + }; + return symbols[chainId] || 'ETH'; +} + +function getNativeNameForChain(chainId: number): string { + const names: Record = { + [CHAIN_IDS.ETHEREUM]: 'Ethereum', + [CHAIN_IDS.POLYGON]: 'Polygon', + [CHAIN_IDS.ARBITRUM]: 'Arbitrum', + [CHAIN_IDS.OPTIMISM]: 'Optimism', + [CHAIN_IDS.BASE]: 'Base', + }; + return names[chainId] || 'Ethereum'; +} + +function resolveStellarWalletProvider(): any { + const globalWallets = globalThis as { + freighterApi?: any; + freighter?: any; + stellar?: any; + }; + + return globalWallets.freighterApi ?? globalWallets.freighter ?? globalWallets.stellar ?? null; +} + +function isStellarPublicKey(value: string | undefined): boolean { + return typeof value === 'string' && value.startsWith('G') && value.length === 56; +} export class WalletServiceManager { private static instance: WalletServiceManager; private connection: WalletConnection | null = null; private listeners: ((connection: WalletConnection | null) => void)[] = []; + private readonly strategyRegistry: WalletChainStrategyRegistry; + + constructor(strategyRegistry = createDefaultWalletChainStrategyRegistry()) { + this.strategyRegistry = strategyRegistry; + } static getInstance(): WalletServiceManager { if (!WalletServiceManager.instance) { @@ -236,57 +682,9 @@ export class WalletServiceManager { } async getTokenBalances(address: string, chainId: number): Promise { - try { - const provider = this.getProvider(chainId); - const balances: TokenBalance[] = []; - - // Get native token balance (ETH, MATIC, etc.) - const nativeBalance = await provider.getBalance(address); - const nativeSymbol = this.getNativeSymbol(chainId); - - balances.push({ - symbol: nativeSymbol, - name: this.getNativeName(chainId), - address: '0x0000000000000000000000000000000000000000', - balance: ethers.utils.formatEther(nativeBalance), - decimals: CRYPTO_CONSTANTS.ETH_DECIMALS, - }); - - // Get USDC balance if on supported chains - if ( - chainId === CHAIN_IDS.ETHEREUM || - chainId === CHAIN_IDS.POLYGON || - chainId === CHAIN_IDS.ARBITRUM - ) { - const usdcAddress = getContractAddress(chainId, 'usdc'); - if (!usdcAddress) { - return balances; - } - const usdcContract = ERC20__factory.connect(usdcAddress, provider); - - try { - const usdcBalance = await usdcContract.balanceOf(address); - balances.push({ - symbol: 'USDC', - name: 'USD Coin', - address: usdcAddress, - balance: ethers.utils.formatUnits(usdcBalance, CRYPTO_CONSTANTS.USDC_DECIMALS), - decimals: CRYPTO_CONSTANTS.USDC_DECIMALS, - }); - } catch { - logger.warn('USDC not available on this chain', { chainId }); - } - } - - return balances; - } catch (error) { - throw toWalletError( - error, - WalletErrorCode.BALANCE_FETCH_FAILED, - 'Unable to fetch token balances.', - 'Check your network connection and try again.' - ); - } + return this.strategyRegistry + .getStrategyForChain(chainId) + .getTokenBalances(address, chainId, this); } async estimateGas( @@ -296,59 +694,25 @@ export class WalletServiceManager { chainId: number, userGasLimitOverride?: string ): Promise { - let provider: ethers.providers.JsonRpcProvider; - let gasPrice: ethers.BigNumber; - - try { - provider = this.getProvider(chainId); - gasPrice = await this.resolveGasPrice(provider); - } catch (error) { - throw toWalletError( - error, - WalletErrorCode.GAS_ESTIMATION_FAILED, - 'Could not retrieve gas price.', - 'Check your network connection and try again.' + const strategy = this.strategyRegistry.getStrategyForChain(chainId); + if (!strategy.estimateGas) { + throw new NetworkError( + NetworkErrorCode.UNSUPPORTED_CHAIN, + `Gas estimation is not supported for chain ${chainId}.`, + 'Select an EVM-compatible network.' ); } - let gasLimit: ethers.BigNumber; - - if (userGasLimitOverride) { - gasLimit = ethers.BigNumber.from(userGasLimitOverride); - } else { - try { - const estimated = await provider.estimateGas({ - from, - to, - value: ethers.utils.parseEther(value || '0'), - }); - // Network-specific buffer: higher for Polygon due to congestion variability - const bufferMultiplier = - chainId === CHAIN_IDS.POLYGON - ? CRYPTO_CONSTANTS.POLYGON_GAS_BUFFER_MULTIPLIER - : CRYPTO_CONSTANTS.DEFAULT_GAS_BUFFER_MULTIPLIER; - gasLimit = estimated.mul(bufferMultiplier).div(100); - } catch (err) { - logger.warn('Gas estimation failed, using safe fallback', { error: err }); - gasLimit = ethers.BigNumber.from(CRYPTO_CONSTANTS.FALLBACK_GAS_LIMIT); - } - } - - const estimatedCost = gasPrice.mul(gasLimit); - return { - gasLimit: gasLimit.toString(), - gasPrice: ethers.utils.formatUnits(gasPrice, 'gwei'), - estimatedCost: ethers.utils.formatEther(estimatedCost), - }; + return strategy.estimateGas({ from, to, value, chainId, userGasLimitOverride }, this); } - private getWalletSigner(): ethers.Signer { + 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 your EVM wallet and try again.' ); errorTracker.record(WalletErrorCode.NOT_CONNECTED); throw err; @@ -613,15 +977,15 @@ export class WalletServiceManager { chainId: number ): Promise { const provider = this.getProvider(chainId); - const gasPrice = await this.resolveGasPrice(provider); + const gasPrice = await resolveGasPrice(provider); const erc20Abi = ['function approve(address spender, uint256 amount) returns (bool)']; 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 your EVM wallet and try again.' ); errorTracker.record(WalletErrorCode.NOT_CONNECTED); throw err; @@ -633,13 +997,10 @@ export class WalletServiceManager { let gasLimit: ethers.BigNumber; try { const estimated = await erc20WithSigner.estimateGas.approve(spender, amount); - const bufferMultiplier = - chainId === CHAIN_IDS.POLYGON - ? CRYPTO_CONSTANTS.POLYGON_GAS_BUFFER_MULTIPLIER - : CRYPTO_CONSTANTS.DEFAULT_GAS_BUFFER_MULTIPLIER; - gasLimit = estimated.mul(bufferMultiplier).div(100); + gasLimit = estimated.mul(getGasBufferMultiplier(chainId)).div(100); } catch (err) { logger.warn('Approve gas estimation failed, using fallback', { error: err }); + gasLimit = ethers.BigNumber.from(CRYPTO_CONSTANTS.FALLBACK_GAS_LIMIT); } const estimatedCost = gasPrice.mul(gasLimit); @@ -674,25 +1035,25 @@ export class WalletServiceManager { 'Open your wallet and approve the request to continue.' ); } - throw toWalletError( - error, - WalletErrorCode.APPROVAL_FAILED, + throw new ContractError( + ContractErrorCode.EXECUTION_FAILED, 'Token approval failed.', - 'Check your wallet connection and try again.' + 'Check your wallet connection and try again.', + error ); } } - private getProvider(chainId: number): ethers.providers.JsonRpcProvider { - // Use resilient provider with timeout + circuit breaker + multi-URL fallback. - // Falls back to getEvmRpcUrl for chains not in EVM_RPC_URLS (unknown chains). - let urls: string[]; - try { - urls = getEvmRpcUrls(chainId); - } catch { - urls = [getEvmRpcUrl(chainId)]; + getProvider(chainId: number): ethers.providers.JsonRpcProvider { + const strategy = this.strategyRegistry.getStrategy(ChainType.EVM); + if (!strategy.getProvider) { + throw new NetworkError( + NetworkErrorCode.UNSUPPORTED_CHAIN, + 'EVM provider strategy is not registered.', + 'Restart the app and try again.' + ); } - return getOrCreateResilientProvider(chainId, urls) as unknown as ethers.providers.JsonRpcProvider; + return strategy.getProvider(chainId); } private async resolveGasPrice( @@ -728,10 +1089,46 @@ export class WalletServiceManager { return names[chainId] || 'Ethereum'; } + getSupportedChains(): SupportedWalletChain[] { + return this.strategyRegistry.getSupportedChains(); + } + + getStellarProvider(): any { + return resolveStellarWalletProvider(); + } + + async connectStellarWallet(): Promise { + const strategy = this.strategyRegistry.getStrategy(ChainType.STELLAR); + if (!strategy.connect) { + throw new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'Stellar wallet support is not available.', + 'Restart the app and try again.' + ); + } + return strategy.connect(this); + } + + async switchChain(chainType: ChainType, chainId: number): Promise { + const strategy = this.strategyRegistry.getStrategy(chainType); + if (!strategy.switchChain) { + throw new NetworkError( + NetworkErrorCode.UNSUPPORTED_CHAIN, + `Chain switching is not supported for ${chainType}.`, + 'Select a supported payment network.' + ); + } + return strategy.switchChain(chainId, this); + } + isConnected(): boolean { return this.connection?.isConnected || false; } + private resolveChainType(chainId: number): ChainType { + return this.strategyRegistry.getStrategyForChain(chainId).chainType; + } + /** * Fetches balances across several chains at once, for the unified * multi-chain view (see `multiChainSubscriptionService`). @@ -741,10 +1138,7 @@ export class WalletServiceManager { * because a serial walk over a handful of RPCs is the slowest thing on the * balances screen. */ - async getBalancesAcrossChains( - address: string, - chainIds: number[] - ): Promise { + async getBalancesAcrossChains(address: string, chainIds: number[]): Promise { const settled = await Promise.all( chainIds.map(async (chainId): Promise => { try { @@ -772,10 +1166,7 @@ export class WalletServiceManager { * Balances stay per chain rather than being summed: the same symbol on two * chains is not fungible, and a single figure would imply it is. */ - static totalsBySymbol( - balances: MultiChainBalances, - symbol: string - ): Record { + static totalsBySymbol(balances: MultiChainBalances, symbol: string): Record { const wanted = symbol.toUpperCase(); const totals: Record = {}; for (const result of balances.results) { @@ -800,10 +1191,7 @@ export { PaymentMethodError, PaymentMethodService, } from './paymentMethodService'; -export type { - PaymentMethodExpiryCheck, - ChainPaymentResult, -} from './paymentMethodService'; +export type { PaymentMethodExpiryCheck, ChainPaymentResult } from './paymentMethodService'; // Export singleton instances export const walletServiceManager = WalletServiceManager.getInstance(); diff --git a/src/types/fraud.ts b/src/types/fraud.ts index 78ca7e1b..03fd9d47 100644 --- a/src/types/fraud.ts +++ b/src/types/fraud.ts @@ -9,7 +9,6 @@ export type FraudSignalType = | 'geolocation-anomaly'; export type FraudReviewOutcome = 'true_positive' | 'false_positive' | 'needs_follow_up'; export type FraudEvidenceSource = 'payment' | 'device' | 'location' | 'support'; - | 'device-mismatch'; // ── Legacy types used by fraudDetectionService ──────────────────────────────── @@ -320,8 +319,14 @@ export interface FraudAnalytics { preventedLoss?: number; detectionRate?: number; falsePositiveRate?: number; - timeSeriesData?: Array<{ date: string; count?: number; detections?: number; blocked: number; confirmed?: number }>; - topRiskUsers?: Array<{ userId: string; riskScore: number; detectionCount: number }>; + timeSeriesData?: { + date: string; + count?: number; + detections?: number; + blocked: number; + confirmed?: number; + }[]; + topRiskUsers?: { userId: string; riskScore: number; detectionCount: number }[]; // New dashboard fields (used by fraud store and UI) totalChecks?: number; approved?: number; diff --git a/src/utils/lazyLoading.tsx b/src/utils/lazyLoading.tsx index 8bbacef6..385c969a 100644 --- a/src/utils/lazyLoading.tsx +++ b/src/utils/lazyLoading.tsx @@ -9,7 +9,9 @@ import { } from 'react-native'; import { colors, spacing, borderRadius, typography, shadows } from './constants'; -export function lazyWithRetry>>( +// Screen components have route-specific prop types, so this wrapper must stay prop-agnostic. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function lazyWithRetry>( componentImport: () => Promise<{ default: T }>, retries = 3, delay = 1500 @@ -97,8 +99,10 @@ export class LazyErrorBoundary extends React.Component>( - importFn: () => Promise<{ default: T }> + importFn: () => Promise<{ default: T }>, + options: { displayName?: string } = {} ) { const LazyComponent = lazyWithRetry(importFn); @@ -110,7 +114,8 @@ export function lazyScreen>( ); - WrappedScreen.displayName = `lazyScreen(${importFn.toString().replace(/\s+/g, ' ')})`; + WrappedScreen.displayName = + options.displayName ?? `lazyScreen(${importFn.toString().replace(/\s+/g, ' ')})`; return WrappedScreen; }