Skip to content

Commit 5f01f90

Browse files
committed
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
1 parent 28d1bd9 commit 5f01f90

5 files changed

Lines changed: 1026 additions & 0 deletions

File tree

docs/MULTI_CHAIN_SUBSCRIPTIONS.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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

Comments
 (0)