|
| 1 | +# Multi-Chain Subscriptions and Unified Billing |
| 2 | + |
| 3 | +A payer's subscriptions do not all live on one chain: one is funded from USDC on |
| 4 | +Polygon, another settles in XLM on Stellar. Left alone that produces one bill per |
| 5 | +chain, each denominated in its own asset — which is not a bill anyone can read. |
| 6 | + |
| 7 | +`src/services/multiChainSubscriptionService.ts` keeps the chain binding of every |
| 8 | +subscription and answers the two questions the rest of the app needs. |
| 9 | + |
| 10 | +## Chain bindings |
| 11 | + |
| 12 | +Each subscription carries a `ChainBinding`: chain type, chain id, network id, |
| 13 | +the token it is denominated in, and the wallet that funds it. |
| 14 | + |
| 15 | +```ts |
| 16 | +multiChainSubscriptionService.register({ |
| 17 | + subscriptionId: 'sub_1', |
| 18 | + subscriberId: 'payer_1', |
| 19 | + name: 'Pro Plan', |
| 20 | + amount: 10, // denominated in binding.tokenSymbol |
| 21 | + binding: { |
| 22 | + chainType: ChainType.EVM, |
| 23 | + chainId: 137, |
| 24 | + networkId: 'polygon', |
| 25 | + tokenSymbol: 'USDC', |
| 26 | + walletAddress: '0x…', |
| 27 | + }, |
| 28 | + nextBillingDate: new Date('2026-02-01'), |
| 29 | + isActive: true, |
| 30 | +}); |
| 31 | +``` |
| 32 | + |
| 33 | +`rebind()` moves a subscription to another chain when a payer switches funding |
| 34 | +wallets. The billed amount does not change; only where it settles does. |
| 35 | + |
| 36 | +## Unified billing |
| 37 | + |
| 38 | +`buildUnifiedStatement()` converts every chain's charges into one currency: |
| 39 | + |
| 40 | +```ts |
| 41 | +const statement = multiChainSubscriptionService.buildUnifiedStatement('payer_1', { |
| 42 | + currency: 'USD', |
| 43 | + rates: [ |
| 44 | + { tokenSymbol: 'USDC', rate: 1, asOf: new Date() }, |
| 45 | + { tokenSymbol: 'XLM', rate: 0.25, asOf: new Date() }, |
| 46 | + ], |
| 47 | + dueBefore: endOfMonth, |
| 48 | +}); |
| 49 | +``` |
| 50 | + |
| 51 | +The statement carries three views of the same charges: |
| 52 | + |
| 53 | +- `lines` — one per subscription, with the rate applied and both amounts. |
| 54 | +- `chainSubtotals` — per chain, keeping **native token totals** alongside the |
| 55 | + converted total. Native amounts are what a payer checks against their wallet; |
| 56 | + the converted figure is what they owe. |
| 57 | +- `total` — one number, in `currency`. |
| 58 | + |
| 59 | +### Unpriced subscriptions |
| 60 | + |
| 61 | +A subscription whose token has no rate is listed in `unpricedSubscriptionIds` |
| 62 | +and **excluded** from `total` — never silently treated as zero. A bill that |
| 63 | +quietly under-reports is worse than one that says what it could not price. The |
| 64 | +chain subtotal still shows the native amount, so nothing disappears from view. |
| 65 | + |
| 66 | +A token already denominated in the statement currency needs no rate. |
| 67 | + |
| 68 | +Rates are **injected**, not fetched here, so aggregation stays deterministic and |
| 69 | +testable; production wires in `oraclePriceService`. |
| 70 | + |
| 71 | +## Settlement planning |
| 72 | + |
| 73 | +`planSettlement()` decides how each due charge actually pays: |
| 74 | + |
| 75 | +| Action | When | |
| 76 | +|---|---| |
| 77 | +| `direct` | The subscription's own chain is healthy. | |
| 78 | +| `bridge` | That chain is down, but another chain the payer already uses holds enough of the same token. | |
| 79 | +| `blocked` | That chain is down and no funded alternative exists. | |
| 80 | + |
| 81 | +```ts |
| 82 | +const plan = multiChainSubscriptionService.planSettlement('payer_1', { |
| 83 | + health: [{ networkId: 'polygon', healthy: false }], |
| 84 | + balances: { 'arbitrum::USDC': 100 }, // see balanceKey() |
| 85 | +}); |
| 86 | +``` |
| 87 | + |
| 88 | +Three rules keep a plan honest: |
| 89 | + |
| 90 | +1. Fallback candidates are only chains the payer **already uses** — a plan never |
| 91 | + invents a chain they have no wallet on. |
| 92 | +2. The fallback must hold enough of the **same token**; USDC on Arbitrum cannot |
| 93 | + cover an XLM charge. |
| 94 | +3. The fallback must itself be healthy. |
| 95 | + |
| 96 | +When none holds, the step is `blocked` with a reason rather than dropped. |
| 97 | +Silently skipping an unpayable charge is how subscriptions lapse without anyone |
| 98 | +noticing. |
| 99 | + |
| 100 | +Chains absent from the `health` list are assumed healthy, so a caller can pass |
| 101 | +only the failures it knows about. |
| 102 | + |
| 103 | +Once a plan has `bridge` steps, `crossChainRoutingService.findPaymentRoute()` |
| 104 | +turns each one into an actual bridge route. |
| 105 | + |
| 106 | +## Cross-chain balances |
| 107 | + |
| 108 | +`WalletServiceManager.getBalancesAcrossChains()` fetches balances from several |
| 109 | +chains at once for the unified view: |
| 110 | + |
| 111 | +```ts |
| 112 | +const balances = await walletServiceManager.getBalancesAcrossChains('0x…', [1, 137, 42161]); |
| 113 | +balances.failedChainIds; // chains that could not be read |
| 114 | +WalletServiceManager.totalsBySymbol(balances, 'USDC'); // { 137: 50, 42161: 25 } |
| 115 | +``` |
| 116 | + |
| 117 | +Two deliberate choices: |
| 118 | + |
| 119 | +- **Failures are per chain, not fatal.** One unreachable RPC must not blank the |
| 120 | + whole balances screen, so errors are reported alongside the chains that did |
| 121 | + respond. |
| 122 | +- **`totalsBySymbol` returns a per-chain map, not a sum.** The same symbol on two |
| 123 | + chains is not fungible; a single figure would imply it is, and a payer would |
| 124 | + think a charge is covered when the funds sit on the wrong chain. |
| 125 | + |
| 126 | +Requests run in parallel — a serial walk over a handful of RPCs is the slowest |
| 127 | +thing on that screen. |
| 128 | + |
| 129 | +## Testing |
| 130 | + |
| 131 | +- `src/services/__tests__/multiChainSubscriptionService.test.ts` |
| 132 | +- `src/services/__tests__/walletMultiChain.test.ts` |
| 133 | + |
| 134 | +`MultiChainSubscriptionService` is a singleton; call `reset()` in `beforeEach`. |
0 commit comments