From 5f01f90a6701611e800d1f69c88a058c237a47ae Mon Sep 17 00:00:00 2001 From: "yilkimezakka@gmail.com" Date: Fri, 28 Aug 2026 12:37:05 +0000 Subject: [PATCH] feat(#904): implement multi-chain subscription management with unified billing - Add MultiChainSubscriptionService: chain bindings per subscription, unified billing statements that convert every chain into one currency while keeping native token subtotals, and settlement planning with health-aware cross-chain failover - walletService: add getBalancesAcrossChains() (parallel, per-chain error isolation) and static totalsBySymbol() keeping holdings separated by chain - Add comprehensive tests for MultiChainSubscriptionService and wallet multi-chain balance fetching - Add docs/MULTI_CHAIN_SUBSCRIPTIONS.md with full API reference Closes #904 --- docs/MULTI_CHAIN_SUBSCRIPTIONS.md | 134 ++++++ .../multiChainSubscriptionService.test.ts | 313 ++++++++++++++ .../__tests__/walletMultiChain.test.ts | 113 +++++ src/services/multiChainSubscriptionService.ts | 396 ++++++++++++++++++ src/services/walletService.ts | 70 ++++ 5 files changed, 1026 insertions(+) create mode 100644 docs/MULTI_CHAIN_SUBSCRIPTIONS.md create mode 100644 src/services/__tests__/multiChainSubscriptionService.test.ts create mode 100644 src/services/__tests__/walletMultiChain.test.ts create mode 100644 src/services/multiChainSubscriptionService.ts diff --git a/docs/MULTI_CHAIN_SUBSCRIPTIONS.md b/docs/MULTI_CHAIN_SUBSCRIPTIONS.md new file mode 100644 index 00000000..172a3023 --- /dev/null +++ b/docs/MULTI_CHAIN_SUBSCRIPTIONS.md @@ -0,0 +1,134 @@ +# Multi-Chain Subscriptions and Unified Billing + +A payer's subscriptions do not all live on one chain: one is funded from USDC on +Polygon, another settles in XLM on Stellar. Left alone that produces one bill per +chain, each denominated in its own asset — which is not a bill anyone can read. + +`src/services/multiChainSubscriptionService.ts` keeps the chain binding of every +subscription and answers the two questions the rest of the app needs. + +## Chain bindings + +Each subscription carries a `ChainBinding`: chain type, chain id, network id, +the token it is denominated in, and the wallet that funds it. + +```ts +multiChainSubscriptionService.register({ + subscriptionId: 'sub_1', + subscriberId: 'payer_1', + name: 'Pro Plan', + amount: 10, // denominated in binding.tokenSymbol + binding: { + chainType: ChainType.EVM, + chainId: 137, + networkId: 'polygon', + tokenSymbol: 'USDC', + walletAddress: '0x…', + }, + nextBillingDate: new Date('2026-02-01'), + isActive: true, +}); +``` + +`rebind()` moves a subscription to another chain when a payer switches funding +wallets. The billed amount does not change; only where it settles does. + +## Unified billing + +`buildUnifiedStatement()` converts every chain's charges into one currency: + +```ts +const statement = multiChainSubscriptionService.buildUnifiedStatement('payer_1', { + currency: 'USD', + rates: [ + { tokenSymbol: 'USDC', rate: 1, asOf: new Date() }, + { tokenSymbol: 'XLM', rate: 0.25, asOf: new Date() }, + ], + dueBefore: endOfMonth, +}); +``` + +The statement carries three views of the same charges: + +- `lines` — one per subscription, with the rate applied and both amounts. +- `chainSubtotals` — per chain, keeping **native token totals** alongside the + converted total. Native amounts are what a payer checks against their wallet; + the converted figure is what they owe. +- `total` — one number, in `currency`. + +### Unpriced subscriptions + +A subscription whose token has no rate is listed in `unpricedSubscriptionIds` +and **excluded** from `total` — never silently treated as zero. A bill that +quietly under-reports is worse than one that says what it could not price. The +chain subtotal still shows the native amount, so nothing disappears from view. + +A token already denominated in the statement currency needs no rate. + +Rates are **injected**, not fetched here, so aggregation stays deterministic and +testable; production wires in `oraclePriceService`. + +## Settlement planning + +`planSettlement()` decides how each due charge actually pays: + +| Action | When | +|---|---| +| `direct` | The subscription's own chain is healthy. | +| `bridge` | That chain is down, but another chain the payer already uses holds enough of the same token. | +| `blocked` | That chain is down and no funded alternative exists. | + +```ts +const plan = multiChainSubscriptionService.planSettlement('payer_1', { + health: [{ networkId: 'polygon', healthy: false }], + balances: { 'arbitrum::USDC': 100 }, // see balanceKey() +}); +``` + +Three rules keep a plan honest: + +1. Fallback candidates are only chains the payer **already uses** — a plan never + invents a chain they have no wallet on. +2. The fallback must hold enough of the **same token**; USDC on Arbitrum cannot + cover an XLM charge. +3. The fallback must itself be healthy. + +When none holds, the step is `blocked` with a reason rather than dropped. +Silently skipping an unpayable charge is how subscriptions lapse without anyone +noticing. + +Chains absent from the `health` list are assumed healthy, so a caller can pass +only the failures it knows about. + +Once a plan has `bridge` steps, `crossChainRoutingService.findPaymentRoute()` +turns each one into an actual bridge route. + +## Cross-chain balances + +`WalletServiceManager.getBalancesAcrossChains()` fetches balances from several +chains at once for the unified view: + +```ts +const balances = await walletServiceManager.getBalancesAcrossChains('0x…', [1, 137, 42161]); +balances.failedChainIds; // chains that could not be read +WalletServiceManager.totalsBySymbol(balances, 'USDC'); // { 137: 50, 42161: 25 } +``` + +Two deliberate choices: + +- **Failures are per chain, not fatal.** One unreachable RPC must not blank the + whole balances screen, so errors are reported alongside the chains that did + respond. +- **`totalsBySymbol` returns a per-chain map, not a sum.** The same symbol on two + chains is not fungible; a single figure would imply it is, and a payer would + think a charge is covered when the funds sit on the wrong chain. + +Requests run in parallel — a serial walk over a handful of RPCs is the slowest +thing on that screen. + +## Testing + +- `src/services/__tests__/multiChainSubscriptionService.test.ts` +- `src/services/__tests__/walletMultiChain.test.ts` + +`MultiChainSubscriptionService` is a singleton; call `reset()` in `beforeEach`. diff --git a/src/services/__tests__/multiChainSubscriptionService.test.ts b/src/services/__tests__/multiChainSubscriptionService.test.ts new file mode 100644 index 00000000..1a12a49c --- /dev/null +++ b/src/services/__tests__/multiChainSubscriptionService.test.ts @@ -0,0 +1,313 @@ +import { + MultiChainSubscriptionService, + balanceKey, + type ChainBinding, + type ConversionRate, + type MultiChainSubscription, +} from '../multiChainSubscriptionService'; +import { ChainType } from '../../types/wallet'; + +const POLYGON: ChainBinding = { + chainType: ChainType.EVM, + chainId: 137, + networkId: 'polygon', + tokenSymbol: 'USDC', + walletAddress: '0xpayer', +}; + +const STELLAR: ChainBinding = { + chainType: ChainType.STELLAR, + chainId: 0x8000, + networkId: 'stellar-mainnet', + tokenSymbol: 'XLM', + walletAddress: 'GPAYER', +}; + +const ARBITRUM: ChainBinding = { + chainType: ChainType.EVM, + chainId: 42161, + networkId: 'arbitrum', + tokenSymbol: 'USDC', + walletAddress: '0xpayer', +}; + +const RATES: ConversionRate[] = [ + { tokenSymbol: 'USDC', rate: 1, asOf: new Date('2026-01-01') }, + { tokenSymbol: 'XLM', rate: 0.25, asOf: new Date('2026-01-01') }, +]; + +const sub = (overrides: Partial = {}): MultiChainSubscription => ({ + subscriptionId: 'sub-1', + subscriberId: 'payer-1', + name: 'Pro Plan', + amount: 10, + binding: POLYGON, + nextBillingDate: new Date('2026-02-01T00:00:00.000Z'), + isActive: true, + ...overrides, +}); + +let service: MultiChainSubscriptionService; + +beforeEach(() => { + service = MultiChainSubscriptionService.getInstance(); + service.reset(); +}); + +describe('registration', () => { + it('registers and reads back a subscription', () => { + service.register(sub()); + expect(service.get('sub-1')?.name).toBe('Pro Plan'); + expect(service.list('payer-1')).toHaveLength(1); + }); + + it('rejects a subscription with no id', () => { + expect(() => service.register(sub({ subscriptionId: '' }))).toThrow(/requires a subscriptionId/); + }); + + it('rejects a negative or non-finite amount', () => { + expect(() => service.register(sub({ amount: -1 }))).toThrow(/negative or non-finite/); + expect(() => service.register(sub({ amount: Number.NaN }))).toThrow(/negative or non-finite/); + }); + + it('rejects a subscription with no network binding', () => { + expect(() => + service.register(sub({ binding: { ...POLYGON, networkId: '' } })) + ).toThrow(/not bound to a network/); + }); + + it('scopes listings by subscriber', () => { + service.register(sub()); + service.register(sub({ subscriptionId: 'sub-2', subscriberId: 'payer-2' })); + expect(service.list('payer-1')).toHaveLength(1); + expect(service.list()).toHaveLength(2); + }); + + it('lists the distinct networks a payer uses', () => { + service.register(sub()); + service.register(sub({ subscriptionId: 'sub-2', binding: STELLAR })); + service.register(sub({ subscriptionId: 'sub-3', binding: POLYGON })); + expect(service.listNetworks('payer-1').sort()).toEqual(['polygon', 'stellar-mainnet']); + }); + + it('unregisters a subscription', () => { + service.register(sub()); + expect(service.unregister('sub-1')).toBe(true); + expect(service.unregister('sub-1')).toBe(false); + expect(service.get('sub-1')).toBeUndefined(); + }); + + it('rebinds a subscription to another chain without changing the amount', () => { + service.register(sub()); + const rebound = service.rebind('sub-1', STELLAR); + expect(rebound?.binding.networkId).toBe('stellar-mainnet'); + expect(rebound?.amount).toBe(10); + expect(service.get('sub-1')?.binding.tokenSymbol).toBe('XLM'); + }); + + it('returns null when rebinding an unknown subscription', () => { + expect(service.rebind('nope', STELLAR)).toBeNull(); + }); +}); + +describe('buildUnifiedStatement', () => { + it('converts every chain into one currency and totals them', () => { + service.register(sub({ amount: 10, binding: POLYGON })); + service.register(sub({ subscriptionId: 'sub-2', amount: 40, binding: STELLAR })); + + const statement = service.buildUnifiedStatement('payer-1', { rates: RATES }); + expect(statement.currency).toBe('USD'); + expect(statement.lines).toHaveLength(2); + // 10 USDC @ 1 + 40 XLM @ 0.25 = 20 + expect(statement.total).toBe(20); + }); + + it('breaks the total down per chain, keeping native token amounts', () => { + service.register(sub({ amount: 10, binding: POLYGON })); + service.register(sub({ subscriptionId: 'sub-2', amount: 40, binding: STELLAR })); + + const { chainSubtotals } = service.buildUnifiedStatement('payer-1', { rates: RATES }); + const polygon = chainSubtotals.find((c) => c.networkId === 'polygon')!; + const stellar = chainSubtotals.find((c) => c.networkId === 'stellar-mainnet')!; + + expect(polygon.nativeTotals).toEqual({ USDC: 10 }); + expect(polygon.convertedTotal).toBe(10); + expect(stellar.nativeTotals).toEqual({ XLM: 40 }); + expect(stellar.convertedTotal).toBe(10); + expect(stellar.chainType).toBe(ChainType.STELLAR); + }); + + it('sums several subscriptions on the same chain into one subtotal', () => { + service.register(sub({ subscriptionId: 'sub-1', amount: 10 })); + service.register(sub({ subscriptionId: 'sub-2', amount: 15 })); + + const { chainSubtotals } = service.buildUnifiedStatement('payer-1', { rates: RATES }); + expect(chainSubtotals).toHaveLength(1); + expect(chainSubtotals[0].subscriptionCount).toBe(2); + expect(chainSubtotals[0].nativeTotals.USDC).toBe(25); + }); + + it('needs no rate for a token already in the statement currency', () => { + service.register(sub({ amount: 10, binding: { ...POLYGON, tokenSymbol: 'USD' } })); + const statement = service.buildUnifiedStatement('payer-1', { rates: [] }); + expect(statement.total).toBe(10); + expect(statement.unpricedSubscriptionIds).toEqual([]); + }); + + it('reports unpriced subscriptions instead of counting them as zero', () => { + service.register(sub({ amount: 10, binding: POLYGON })); + service.register(sub({ subscriptionId: 'sub-2', amount: 40, binding: STELLAR })); + + const statement = service.buildUnifiedStatement('payer-1', { + rates: [{ tokenSymbol: 'USDC', rate: 1, asOf: new Date() }], + }); + expect(statement.total).toBe(10); + expect(statement.unpricedSubscriptionIds).toEqual(['sub-2']); + expect(statement.lines).toHaveLength(1); + // The chain still appears with its native amount, so nothing goes missing. + const stellar = statement.chainSubtotals.find((c) => c.networkId === 'stellar-mainnet')!; + expect(stellar.nativeTotals.XLM).toBe(40); + expect(stellar.convertedTotal).toBe(0); + }); + + it('matches rates case-insensitively', () => { + service.register(sub({ amount: 10, binding: { ...POLYGON, tokenSymbol: 'usdc' } })); + const statement = service.buildUnifiedStatement('payer-1', { rates: RATES }); + expect(statement.total).toBe(10); + }); + + it('excludes inactive subscriptions by default', () => { + service.register(sub({ isActive: false })); + expect(service.buildUnifiedStatement('payer-1', { rates: RATES }).lines).toHaveLength(0); + expect( + service.buildUnifiedStatement('payer-1', { rates: RATES, includeInactive: true }).lines + ).toHaveLength(1); + }); + + it('filters to charges due before a cutoff', () => { + service.register(sub({ nextBillingDate: new Date('2026-01-15T00:00:00.000Z') })); + service.register( + sub({ subscriptionId: 'sub-2', nextBillingDate: new Date('2026-03-01T00:00:00.000Z') }) + ); + + const statement = service.buildUnifiedStatement('payer-1', { + rates: RATES, + dueBefore: new Date('2026-02-01T00:00:00.000Z'), + }); + expect(statement.lines.map((l) => l.subscriptionId)).toEqual(['sub-1']); + }); + + it('honours a non-USD statement currency', () => { + service.register(sub({ amount: 8, binding: STELLAR })); + const statement = service.buildUnifiedStatement('payer-1', { + currency: 'EUR', + rates: [{ tokenSymbol: 'XLM', rate: 0.5, asOf: new Date() }], + }); + expect(statement.currency).toBe('EUR'); + expect(statement.total).toBe(4); + }); + + it('returns an empty statement for a payer with nothing registered', () => { + const statement = service.buildUnifiedStatement('nobody', { rates: RATES }); + expect(statement.total).toBe(0); + expect(statement.lines).toEqual([]); + expect(statement.chainSubtotals).toEqual([]); + }); +}); + +describe('planSettlement', () => { + it('settles directly when every chain is healthy', () => { + service.register(sub()); + service.register(sub({ subscriptionId: 'sub-2', binding: STELLAR })); + + const plan = service.planSettlement('payer-1'); + expect(plan.steps.every((s) => s.action === 'direct')).toBe(true); + expect(plan.bridgedCount).toBe(0); + expect(plan.blockedCount).toBe(0); + }); + + it('treats a chain absent from the health list as healthy', () => { + service.register(sub()); + const plan = service.planSettlement('payer-1', { + health: [{ networkId: 'some-other-chain', healthy: false }], + }); + expect(plan.steps[0].action).toBe('direct'); + }); + + it('bridges from another funded chain when the target is down', () => { + service.register(sub({ binding: POLYGON, amount: 10 })); + service.register(sub({ subscriptionId: 'sub-2', binding: ARBITRUM, amount: 5 })); + + const plan = service.planSettlement('payer-1', { + health: [{ networkId: 'polygon', healthy: false }], + balances: { [balanceKey('arbitrum', 'USDC')]: 100 }, + }); + + const bridged = plan.steps.find((s) => s.subscriptionId === 'sub-1')!; + expect(bridged.action).toBe('bridge'); + expect(bridged.sourceNetworkId).toBe('arbitrum'); + expect(bridged.targetNetworkId).toBe('polygon'); + expect(bridged.reason).toContain('polygon is unavailable'); + expect(plan.bridgedCount).toBe(1); + }); + + it('blocks when the fallback chain lacks enough of the token', () => { + service.register(sub({ binding: POLYGON, amount: 10 })); + service.register(sub({ subscriptionId: 'sub-2', binding: ARBITRUM, amount: 5 })); + + const plan = service.planSettlement('payer-1', { + health: [{ networkId: 'polygon', healthy: false }], + balances: { [balanceKey('arbitrum', 'USDC')]: 3 }, + }); + + const blocked = plan.steps.find((s) => s.subscriptionId === 'sub-1')!; + expect(blocked.action).toBe('blocked'); + expect(blocked.reason).toContain('no other chain holds enough USDC'); + expect(plan.blockedCount).toBe(1); + }); + + it('blocks when the payer uses only the failed chain', () => { + service.register(sub({ binding: POLYGON })); + const plan = service.planSettlement('payer-1', { + health: [{ networkId: 'polygon', healthy: false }], + }); + expect(plan.steps[0].action).toBe('blocked'); + }); + + it('will not route through another unhealthy chain', () => { + service.register(sub({ binding: POLYGON, amount: 10 })); + service.register(sub({ subscriptionId: 'sub-2', binding: ARBITRUM, amount: 5 })); + + const plan = service.planSettlement('payer-1', { + health: [ + { networkId: 'polygon', healthy: false }, + { networkId: 'arbitrum', healthy: false }, + ], + balances: { [balanceKey('arbitrum', 'USDC')]: 100 }, + }); + expect(plan.blockedCount).toBe(2); + }); + + it('will not route a token the fallback chain does not hold', () => { + // The fallback holds USDC, but the failed charge is denominated in XLM. + service.register(sub({ binding: STELLAR, amount: 10 })); + service.register(sub({ subscriptionId: 'sub-2', binding: ARBITRUM, amount: 5 })); + + const plan = service.planSettlement('payer-1', { + health: [{ networkId: 'stellar-mainnet', healthy: false }], + balances: { [balanceKey('arbitrum', 'USDC')]: 100 }, + }); + expect(plan.steps.find((s) => s.subscriptionId === 'sub-1')?.action).toBe('blocked'); + }); + + it('skips inactive subscriptions', () => { + service.register(sub({ isActive: false })); + expect(service.planSettlement('payer-1').steps).toHaveLength(0); + }); +}); + +describe('balanceKey', () => { + it('namespaces a token by its chain', () => { + expect(balanceKey('polygon', 'USDC')).toBe('polygon::USDC'); + }); +}); diff --git a/src/services/__tests__/walletMultiChain.test.ts b/src/services/__tests__/walletMultiChain.test.ts new file mode 100644 index 00000000..652f6b13 --- /dev/null +++ b/src/services/__tests__/walletMultiChain.test.ts @@ -0,0 +1,113 @@ +import { WalletServiceManager, type MultiChainBalances } from '../walletService'; + +const manager = WalletServiceManager.getInstance(); + +const balance = (symbol: string, amount: string) => ({ + symbol, + name: symbol, + address: `0x${symbol}`, + balance: amount, + decimals: 6, +}); + +describe('getBalancesAcrossChains', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('collects balances from every requested chain', async () => { + jest + .spyOn(manager, 'getTokenBalances') + .mockImplementation(async (_address, chainId) => + chainId === 137 ? [balance('USDC', '50')] : [balance('USDC', '25')] + ); + + const result = await manager.getBalancesAcrossChains('0xpayer', [137, 42161]); + expect(result.address).toBe('0xpayer'); + expect(result.results.map((r) => r.chainId)).toEqual([137, 42161]); + expect(result.failedChainIds).toEqual([]); + }); + + it('reports a failing chain without losing the others', async () => { + jest.spyOn(manager, 'getTokenBalances').mockImplementation(async (_address, chainId) => { + if (chainId === 42161) throw new Error('RPC unreachable'); + return [balance('USDC', '50')]; + }); + + const result = await manager.getBalancesAcrossChains('0xpayer', [137, 42161]); + expect(result.failedChainIds).toEqual([42161]); + + const failed = result.results.find((r) => r.chainId === 42161)!; + expect(failed.error).toBe('RPC unreachable'); + expect(failed.balances).toEqual([]); + + // The healthy chain still returned its data. + expect(result.results.find((r) => r.chainId === 137)?.balances).toHaveLength(1); + }); + + it('queries the chains in parallel', async () => { + let inFlight = 0; + let peak = 0; + jest.spyOn(manager, 'getTokenBalances').mockImplementation(async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return [balance('USDC', '1')]; + }); + + await manager.getBalancesAcrossChains('0xpayer', [1, 137, 42161]); + expect(peak).toBe(3); + }); + + it('returns an empty result for an empty chain list', async () => { + const result = await manager.getBalancesAcrossChains('0xpayer', []); + expect(result.results).toEqual([]); + expect(result.failedChainIds).toEqual([]); + }); +}); + +describe('totalsBySymbol', () => { + const balances: MultiChainBalances = { + address: '0xpayer', + results: [ + { chainId: 137, balances: [balance('USDC', '50'), balance('MATIC', '3')] }, + { chainId: 42161, balances: [balance('USDC', '25')] }, + { chainId: 1, balances: [], error: 'down' }, + ], + failedChainIds: [1], + }; + + it('keeps holdings separated by chain rather than summing them', () => { + // The same symbol on two chains is not fungible, so it must not collapse + // into one figure. + expect(WalletServiceManager.totalsBySymbol(balances, 'USDC')).toEqual({ + 137: 50, + 42161: 25, + }); + }); + + it('matches the symbol case-insensitively', () => { + expect(WalletServiceManager.totalsBySymbol(balances, 'usdc')).toEqual({ + 137: 50, + 42161: 25, + }); + }); + + it('omits chains that do not hold the token', () => { + expect(WalletServiceManager.totalsBySymbol(balances, 'MATIC')).toEqual({ 137: 3 }); + }); + + it('returns nothing for an unheld token', () => { + expect(WalletServiceManager.totalsBySymbol(balances, 'DAI')).toEqual({}); + }); + + it('reads an unparseable balance as zero rather than NaN', () => { + const broken: MultiChainBalances = { + address: '0xpayer', + results: [{ chainId: 137, balances: [balance('USDC', 'not-a-number')] }], + failedChainIds: [], + }; + expect(WalletServiceManager.totalsBySymbol(broken, 'USDC')).toEqual({ 137: 0 }); + }); +}); diff --git a/src/services/multiChainSubscriptionService.ts b/src/services/multiChainSubscriptionService.ts new file mode 100644 index 00000000..7217bdf4 --- /dev/null +++ b/src/services/multiChainSubscriptionService.ts @@ -0,0 +1,396 @@ +/** + * Multi-chain subscription management with unified billing. + * + * A payer's subscriptions do not all live on one chain: one is funded from + * USDC on Polygon, another settles in XLM on Stellar. Left alone that produces + * one bill per chain, each in its own asset, which is not a bill anyone can + * read. + * + * This service keeps the chain binding of every subscription, then answers two + * questions the rest of the app needs: + * + * 1. *Unified billing* — what does this payer owe in total, in one currency, + * across every chain? (`buildUnifiedStatement`) + * 2. *Settlement* — which wallet on which chain actually pays each charge, and + * what has to bridge? (`planSettlement`) + * + * Conversion rates and chain health are injected rather than fetched here, so + * the aggregation logic stays deterministic and testable; production wires in + * `oraclePriceService` and `networkService` respectively. + */ + +import { ChainType } from '../types/wallet'; + +/** Where a subscription is billed, and from which wallet. */ +export interface ChainBinding { + chainType: ChainType; + /** EVM chain id, or the Stellar network id from `src/config/networks.ts`. */ + chainId: number; + networkId: string; + /** Asset the subscription is denominated in, e.g. `USDC`, `XLM`. */ + tokenSymbol: string; + /** Wallet that funds this subscription on that chain. */ + walletAddress: string; +} + +export interface MultiChainSubscription { + subscriptionId: string; + subscriberId: string; + name: string; + /** Charge amount per period, denominated in `binding.tokenSymbol`. */ + amount: number; + binding: ChainBinding; + nextBillingDate: Date; + isActive: boolean; +} + +/** Rate of one token against the statement currency. */ +export interface ConversionRate { + tokenSymbol: string; + /** How many units of the statement currency one token is worth. */ + rate: number; + asOf: Date; +} + +export interface ChainHealth { + networkId: string; + healthy: boolean; + latencyMs?: number; +} + +export interface UnifiedStatementLine { + subscriptionId: string; + name: string; + networkId: string; + chainType: ChainType; + tokenSymbol: string; + /** Amount in the subscription's own token. */ + nativeAmount: number; + /** Rate applied to reach `convertedAmount`. */ + rate: number; + /** Amount in the statement currency. */ + convertedAmount: number; + nextBillingDate: Date; +} + +export interface ChainSubtotal { + networkId: string; + chainType: ChainType; + subscriptionCount: number; + /** Per-token totals in each token's own units. */ + nativeTotals: Record; + convertedTotal: number; +} + +export interface UnifiedStatement { + subscriberId: string; + currency: string; + generatedAt: Date; + lines: UnifiedStatementLine[]; + /** One entry per chain the payer has active subscriptions on. */ + chainSubtotals: ChainSubtotal[]; + total: number; + /** + * Subscriptions left out because no conversion rate was available. Their + * amounts are excluded from `total` rather than silently counted as zero. + */ + unpricedSubscriptionIds: string[]; +} + +export type SettlementAction = 'direct' | 'bridge' | 'blocked'; + +export interface SettlementStep { + subscriptionId: string; + action: SettlementAction; + /** Chain the funds come from. */ + sourceNetworkId: string; + /** Chain the charge settles on. */ + targetNetworkId: string; + tokenSymbol: string; + amount: number; + reason?: string; +} + +export interface SettlementPlan { + subscriberId: string; + steps: SettlementStep[]; + /** Steps needing a cross-chain transfer before they can settle. */ + bridgedCount: number; + /** Steps that cannot proceed — unhealthy chain with no funded alternative. */ + blockedCount: number; +} + +export interface UnifiedStatementOptions { + currency?: string; + rates?: ConversionRate[]; + /** Only include subscriptions due on or before this instant. */ + dueBefore?: Date; + includeInactive?: boolean; +} + +export interface SettlementOptions { + /** Chain health snapshot; chains absent from the list are assumed healthy. */ + health?: ChainHealth[]; + /** + * Spendable balance per `networkId::tokenSymbol`. A charge on an unhealthy + * chain reroutes to another chain holding enough of the same token. + */ + balances?: Record; +} + +const DEFAULT_CURRENCY = 'USD'; + +export const balanceKey = (networkId: string, tokenSymbol: string): string => + `${networkId}::${tokenSymbol}`; + +export class MultiChainSubscriptionService { + private static instance: MultiChainSubscriptionService; + + private subscriptions = new Map(); + + static getInstance(): MultiChainSubscriptionService { + if (!MultiChainSubscriptionService.instance) { + MultiChainSubscriptionService.instance = new MultiChainSubscriptionService(); + } + return MultiChainSubscriptionService.instance; + } + + register(subscription: MultiChainSubscription): MultiChainSubscription { + if (!subscription.subscriptionId) { + throw new Error('A multi-chain subscription requires a subscriptionId'); + } + if (!Number.isFinite(subscription.amount) || subscription.amount < 0) { + throw new Error( + `Subscription ${subscription.subscriptionId} has a negative or non-finite amount` + ); + } + if (!subscription.binding?.networkId) { + throw new Error( + `Subscription ${subscription.subscriptionId} is not bound to a network` + ); + } + this.subscriptions.set(subscription.subscriptionId, subscription); + return subscription; + } + + unregister(subscriptionId: string): boolean { + return this.subscriptions.delete(subscriptionId); + } + + get(subscriptionId: string): MultiChainSubscription | undefined { + return this.subscriptions.get(subscriptionId); + } + + list(subscriberId?: string): MultiChainSubscription[] { + const all = Array.from(this.subscriptions.values()); + return subscriberId ? all.filter((s) => s.subscriberId === subscriberId) : all; + } + + /** Distinct networks a payer currently holds subscriptions on. */ + listNetworks(subscriberId: string): string[] { + return Array.from(new Set(this.list(subscriberId).map((s) => s.binding.networkId))); + } + + /** + * Moves a subscription to a different chain — the migration path when a payer + * switches funding wallets. The billed amount is unchanged; only where it + * settles moves. + */ + rebind(subscriptionId: string, binding: ChainBinding): MultiChainSubscription | null { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) return null; + const updated = { ...subscription, binding }; + this.subscriptions.set(subscriptionId, updated); + return updated; + } + + /** + * Aggregates every chain's charges into one statement in a single currency. + * + * A subscription whose token has no rate is reported in + * `unpricedSubscriptionIds` and excluded from the total — a bill that quietly + * under-reports is worse than one that says what it could not price. + */ + buildUnifiedStatement( + subscriberId: string, + options: UnifiedStatementOptions = {} + ): UnifiedStatement { + const currency = options.currency ?? DEFAULT_CURRENCY; + const rateBySymbol = new Map( + (options.rates ?? []).map((r) => [r.tokenSymbol.toUpperCase(), r.rate]) + ); + + const candidates = this.list(subscriberId).filter((s) => { + if (!options.includeInactive && !s.isActive) return false; + if (options.dueBefore && s.nextBillingDate.getTime() > options.dueBefore.getTime()) { + return false; + } + return true; + }); + + const lines: UnifiedStatementLine[] = []; + const unpricedSubscriptionIds: string[] = []; + const subtotals = new Map(); + let total = 0; + + for (const subscription of candidates) { + const { binding } = subscription; + const symbol = binding.tokenSymbol.toUpperCase(); + // A subscription already denominated in the statement currency needs no + // rate; anything else does. + const rate = symbol === currency.toUpperCase() ? 1 : rateBySymbol.get(symbol); + + let subtotal = subtotals.get(binding.networkId); + if (!subtotal) { + subtotal = { + networkId: binding.networkId, + chainType: binding.chainType, + subscriptionCount: 0, + nativeTotals: {}, + convertedTotal: 0, + }; + subtotals.set(binding.networkId, subtotal); + } + subtotal.subscriptionCount += 1; + subtotal.nativeTotals[binding.tokenSymbol] = + (subtotal.nativeTotals[binding.tokenSymbol] ?? 0) + subscription.amount; + + if (rate === undefined) { + unpricedSubscriptionIds.push(subscription.subscriptionId); + continue; + } + + const convertedAmount = subscription.amount * rate; + subtotal.convertedTotal += convertedAmount; + total += convertedAmount; + + lines.push({ + subscriptionId: subscription.subscriptionId, + name: subscription.name, + networkId: binding.networkId, + chainType: binding.chainType, + tokenSymbol: binding.tokenSymbol, + nativeAmount: subscription.amount, + rate, + convertedAmount, + nextBillingDate: subscription.nextBillingDate, + }); + } + + return { + subscriberId, + currency, + generatedAt: new Date(), + lines, + chainSubtotals: Array.from(subtotals.values()), + total, + unpricedSubscriptionIds, + }; + } + + /** + * Decides how each due charge settles. + * + * A charge settles directly when its own chain is healthy. When that chain is + * down, the plan looks for another chain where the payer holds enough of the + * same token and routes through a bridge; with no such chain the step is + * blocked rather than silently dropped. + */ + planSettlement( + subscriberId: string, + options: SettlementOptions = {} + ): SettlementPlan { + const healthByNetwork = new Map( + (options.health ?? []).map((h) => [h.networkId, h.healthy]) + ); + const balances = options.balances ?? {}; + const isHealthy = (networkId: string): boolean => + healthByNetwork.get(networkId) ?? true; + + const steps: SettlementStep[] = []; + + for (const subscription of this.list(subscriberId)) { + if (!subscription.isActive) continue; + + const { binding } = subscription; + const target = binding.networkId; + + if (isHealthy(target)) { + steps.push({ + subscriptionId: subscription.subscriptionId, + action: 'direct', + sourceNetworkId: target, + targetNetworkId: target, + tokenSymbol: binding.tokenSymbol, + amount: subscription.amount, + }); + continue; + } + + const fallback = this.findFundedAlternative( + subscriberId, + binding, + subscription.amount, + balances, + isHealthy + ); + + if (fallback) { + steps.push({ + subscriptionId: subscription.subscriptionId, + action: 'bridge', + sourceNetworkId: fallback, + targetNetworkId: target, + tokenSymbol: binding.tokenSymbol, + amount: subscription.amount, + reason: `${target} is unavailable; routing ${binding.tokenSymbol} from ${fallback}`, + }); + } else { + steps.push({ + subscriptionId: subscription.subscriptionId, + action: 'blocked', + sourceNetworkId: target, + targetNetworkId: target, + tokenSymbol: binding.tokenSymbol, + amount: subscription.amount, + reason: `${target} is unavailable and no other chain holds enough ${binding.tokenSymbol}`, + }); + } + } + + return { + subscriberId, + steps, + bridgedCount: steps.filter((s) => s.action === 'bridge').length, + blockedCount: steps.filter((s) => s.action === 'blocked').length, + }; + } + + /** + * Finds a healthy chain, other than the failed one, where the payer holds + * enough of the same token. Candidates are the chains the payer already uses, + * so a plan never invents a chain the payer has no wallet on. + */ + private findFundedAlternative( + subscriberId: string, + binding: ChainBinding, + amount: number, + balances: Record, + isHealthy: (networkId: string) => boolean + ): string | null { + for (const networkId of this.listNetworks(subscriberId)) { + if (networkId === binding.networkId) continue; + if (!isHealthy(networkId)) continue; + const available = balances[balanceKey(networkId, binding.tokenSymbol)] ?? 0; + if (available >= amount) return networkId; + } + return null; + } + + /** Clears registered subscriptions. Intended for tests and sign-out. */ + reset(): void { + this.subscriptions.clear(); + } +} + +export const multiChainSubscriptionService = MultiChainSubscriptionService.getInstance(); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 3aad1b41..f5870445 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -116,6 +116,20 @@ export interface GasEstimate { estimatedCost: string; } +/** Balances for one chain within a multi-chain fetch, or why that chain failed. */ +export interface ChainBalanceResult { + chainId: number; + balances: TokenBalance[]; + error?: string; +} + +export interface MultiChainBalances { + address: string; + results: ChainBalanceResult[]; + /** Chains whose balances could not be read; their results are empty. */ + failedChainIds: number[]; +} + /** Result after an on-chain Superfluid CFA stream is created */ export interface SuperfluidStreamResult { txHash: string; @@ -717,6 +731,62 @@ export class WalletServiceManager { isConnected(): boolean { return this.connection?.isConnected || false; } + + /** + * Fetches balances across several chains at once, for the unified + * multi-chain view (see `multiChainSubscriptionService`). + * + * One unreachable chain must not blank the whole view, so failures are + * reported per chain instead of rejecting the call. Requests run in parallel + * because a serial walk over a handful of RPCs is the slowest thing on the + * balances screen. + */ + async getBalancesAcrossChains( + address: string, + chainIds: number[] + ): Promise { + const settled = await Promise.all( + chainIds.map(async (chainId): Promise => { + try { + return { chainId, balances: await this.getTokenBalances(address, chainId) }; + } catch (error) { + return { + chainId, + balances: [], + error: error instanceof Error ? error.message : String(error), + }; + } + }) + ); + + return { + address, + results: settled, + failedChainIds: settled.filter((r) => r.error !== undefined).map((r) => r.chainId), + }; + } + + /** + * Total holdings of one token across chains, keyed by chain id. + * + * 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 { + const wanted = symbol.toUpperCase(); + const totals: Record = {}; + for (const result of balances.results) { + const match = result.balances.find((b) => b.symbol.toUpperCase() === wanted); + if (match) { + const parsed = Number(match.balance); + totals[result.chainId] = Number.isFinite(parsed) ? parsed : 0; + } + } + return totals; + } } // ── Payment method management ───────────────────────────────────────