diff --git a/src/application/services/PortfolioAggregationService.ts b/src/application/services/PortfolioAggregationService.ts index a79a1c3..a2aaed7 100644 --- a/src/application/services/PortfolioAggregationService.ts +++ b/src/application/services/PortfolioAggregationService.ts @@ -5,6 +5,7 @@ import type { IPortfolioRepository } from '../../contracts/repositories/IPortfol import type { IAssetValuatorRepository } from '../../contracts/repositories/IAssetValuatorRepository'; import { IntegrationSource } from '../../shared/types'; import type { DeFiPosition } from '../../shared/types'; +import { ChainFamilyRouter } from '../../domain/services/ChainFamilyRouter'; export interface AggregationOptions { sources?: IntegrationSource[]; @@ -152,27 +153,21 @@ export class PortfolioAggregationService { source: IntegrationSource, addresses: Map ): string[] { + // Robinhood doesn't use blockchain addresses + if (source === IntegrationSource.ROBINHOOD) { + return ['default']; + } + + // Route by chain family: find which family maps to this integration source + const groupedByFamily = ChainFamilyRouter.groupAddressesByFamily(addresses); + const result: string[] = []; - - switch (source) { - case IntegrationSource.EVM: - // Get all EVM-compatible chain addresses - for (const [chain, addrs] of addresses) { - if (['ethereum', 'polygon', 'arbitrum', 'optimism', 'binance'].includes(chain)) { - result.push(...addrs); - } - } - break; - case IntegrationSource.SOLANA: - result.push(...(addresses.get('solana') || [])); - break; - case IntegrationSource.ROBINHOOD: - // Robinhood doesn't use addresses - result.push('default'); - break; + for (const [family, addrs] of groupedByFamily) { + if (ChainFamilyRouter.familyToIntegrationSource(family) === source) { + result.push(...addrs); + } } - - // Remove duplicates + return [...new Set(result)]; } diff --git a/src/domain/services/ChainFamilyRouter.ts b/src/domain/services/ChainFamilyRouter.ts new file mode 100644 index 0000000..ee99e77 --- /dev/null +++ b/src/domain/services/ChainFamilyRouter.ts @@ -0,0 +1,151 @@ +import { Chain, IntegrationSource } from '../../shared/types'; + +/** + * Chain family classification per enterprise directive en-o8w. + * A closed enum — adding new families requires Enterprise Arch approval. + */ +export const ChainFamily = { + EVM: 'evm', + SOLANA: 'solana', + SUI: 'sui', + BITCOIN: 'bitcoin', + COSMOS: 'cosmos', + APTOS: 'aptos', +} as const; + +export type ChainFamily = (typeof ChainFamily)[keyof typeof ChainFamily]; + +/** + * Maps individual chains to their chain family. + */ +const CHAIN_TO_FAMILY: Record = { + [Chain.ETHEREUM]: ChainFamily.EVM, + [Chain.POLYGON]: ChainFamily.EVM, + [Chain.ARBITRUM]: ChainFamily.EVM, + [Chain.OPTIMISM]: ChainFamily.EVM, + [Chain.BINANCE]: ChainFamily.EVM, + [Chain.SOLANA]: ChainFamily.SOLANA, + [Chain.BITCOIN]: ChainFamily.BITCOIN, +}; + +/** + * Reverse mapping: chain family → chains belonging to it. + */ +export const CHAIN_FAMILY_CHAINS: Record = { + [ChainFamily.EVM]: [ + Chain.ETHEREUM, + Chain.POLYGON, + Chain.ARBITRUM, + Chain.OPTIMISM, + Chain.BINANCE, + ], + [ChainFamily.SOLANA]: [Chain.SOLANA], + [ChainFamily.BITCOIN]: [Chain.BITCOIN], + [ChainFamily.SUI]: [], + [ChainFamily.COSMOS]: [], + [ChainFamily.APTOS]: [], +}; + +/** + * Maps chain families to their integration source. + * Undefined means no integration bounded context exists yet. + */ +const FAMILY_TO_INTEGRATION: Partial> = { + [ChainFamily.EVM]: IntegrationSource.EVM, + [ChainFamily.SOLANA]: IntegrationSource.SOLANA, +}; + +/** + * Single-chain families where one address = one chain (mainnet only). + * EVM and Cosmos are multi-chain; the rest are single-chain per en-o8w. + */ +const MULTI_CHAIN_FAMILIES: Set = new Set([ + ChainFamily.EVM, + ChainFamily.COSMOS, +]); + +/** + * Resolve a chain to its chain family. + */ +export function chainToFamily(chain: Chain): ChainFamily | undefined { + return CHAIN_TO_FAMILY[chain]; +} + +/** + * Domain service for routing addresses by chain family. + * + * Per en-o8w: PortfolioAggregation groups TrackedAddress[] by chainFamily + * and dispatches to the correct integration bounded context. + */ +export class ChainFamilyRouter { + /** + * Group addresses by their chain family. + * Deduplicates addresses within the same family. + * Skips chains with no known family mapping. + */ + static groupAddressesByFamily( + addresses: Map + ): Map { + const grouped = new Map>(); + + for (const [chain, addrs] of addresses) { + const family = chainToFamily(chain as Chain); + if (!family) continue; + + if (!grouped.has(family)) { + grouped.set(family, new Set()); + } + const familySet = grouped.get(family)!; + for (const addr of addrs) { + familySet.add(addr); + } + } + + const result = new Map(); + for (const [family, addrSet] of grouped) { + result.set(family, Array.from(addrSet)); + } + return result; + } + + /** + * Get addresses relevant to a specific chain family. + * Collects addresses from all chains belonging to the family and deduplicates. + */ + static getRelevantAddressesForFamily( + family: ChainFamily, + addresses: Map + ): string[] { + const chains = CHAIN_FAMILY_CHAINS[family]; + if (!chains || chains.length === 0) return []; + + const result = new Set(); + for (const chain of chains) { + const addrs = addresses.get(chain); + if (addrs) { + for (const addr of addrs) { + result.add(addr); + } + } + } + return Array.from(result); + } + + /** + * Whether a chain family is single-chain (one address = one mainnet chain). + * EVM and Cosmos are multi-chain; everything else is single-chain per en-o8w. + */ + static isSingleChainFamily(family: ChainFamily): boolean { + return !MULTI_CHAIN_FAMILIES.has(family); + } + + /** + * Map a chain family to its integration source. + * Returns undefined if no integration BC exists for that family yet. + */ + static familyToIntegrationSource( + family: ChainFamily + ): IntegrationSource | undefined { + return FAMILY_TO_INTEGRATION[family]; + } +} diff --git a/src/index.ts b/src/index.ts index a2579f5..1c07662 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,10 +21,16 @@ export { Address } from './domain/value-objects/Address'; // Domain Services export { AssetReconciliationService } from './domain/services/AssetReconciliationService'; -export { +export { PortfolioValuationService, - type PortfolioMetrics + type PortfolioMetrics } from './domain/services/PortfolioValuationService'; +export { + ChainFamilyRouter, + ChainFamily, + chainToFamily, + CHAIN_FAMILY_CHAINS, +} from './domain/services/ChainFamilyRouter'; // Domain Events export type { DomainEvent } from './domain/events/DomainEvent'; diff --git a/src/tests/e2e/chain-family-routing.e2e.test.ts b/src/tests/e2e/chain-family-routing.e2e.test.ts new file mode 100644 index 0000000..58fd812 --- /dev/null +++ b/src/tests/e2e/chain-family-routing.e2e.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { PortfolioAggregationService } from '../../application/services/PortfolioAggregationService'; +import { IntegrationSource, Chain } from '../../shared/types'; +import { + E2EMockIntegrationRepository, + createEVMAssets, + createSolanaAssets, +} from './mocks/E2EMockIntegrationRepository'; +import { E2EMockAssetValuator } from './mocks/E2EMockAssetValuator'; +import { InMemoryPortfolioRepository } from '../mocks/InMemoryPortfolioRepository'; + +describe('E2E: Chain-Family Routing', () => { + let evmIntegration: E2EMockIntegrationRepository; + let solanaIntegration: E2EMockIntegrationRepository; + let portfolioRepo: InMemoryPortfolioRepository; + let valuator: E2EMockAssetValuator; + let service: PortfolioAggregationService; + + beforeEach(() => { + evmIntegration = new E2EMockIntegrationRepository({ + source: IntegrationSource.EVM, + }); + solanaIntegration = new E2EMockIntegrationRepository({ + source: IntegrationSource.SOLANA, + }); + portfolioRepo = new InMemoryPortfolioRepository(); + valuator = new E2EMockAssetValuator(); + + const integrations = new Map(); + integrations.set(IntegrationSource.EVM, evmIntegration); + integrations.set(IntegrationSource.SOLANA, solanaIntegration); + + service = new PortfolioAggregationService( + integrations, + portfolioRepo, + valuator + ); + }); + + it('routes multi-chain EVM addresses to EVM integration only', async () => { + const ethAddr = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb4'; + const polyAddr = '0x5aAeb6053f3E94C9b9A09f33669435E7Ef1BeAed'; + + evmIntegration.addAssetsForAddress(ethAddr, createEVMAssets(ethAddr)); + evmIntegration.addAssetsForAddress(polyAddr, createEVMAssets(polyAddr)); + + const addresses = new Map(); + addresses.set('ethereum', [ethAddr]); + addresses.set('polygon', [polyAddr]); + + const portfolio = await service.aggregatePortfolio({ + addresses, + forceRefresh: true, + }); + + // EVM integration should have been called + expect(evmIntegration.fetchCallCount).toBe(1); + // Solana integration should NOT have been called (no solana addresses) + expect(solanaIntegration.fetchCallCount).toBe(0); + // Portfolio should have assets + expect(portfolio.assets.length).toBeGreaterThan(0); + }); + + it('routes addresses to correct chain-family integration in parallel', async () => { + const ethAddr = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb4'; + const solAddr = '5UtaXPD7yKFdwZcNh5qZRf8kY3Zv7HaGpP9K9S5dFN4X'; + + evmIntegration.addAssetsForAddress(ethAddr, createEVMAssets(ethAddr)); + solanaIntegration.addAssetsForAddress(solAddr, createSolanaAssets(solAddr)); + + const addresses = new Map(); + addresses.set('ethereum', [ethAddr]); + addresses.set('solana', [solAddr]); + + const portfolio = await service.aggregatePortfolio({ + addresses, + forceRefresh: true, + }); + + // Both integrations should have been called + expect(evmIntegration.fetchCallCount).toBe(1); + expect(solanaIntegration.fetchCallCount).toBe(1); + + // Portfolio should contain assets from both chains + const ethAssets = portfolio.assets.filter(a => a.chain === Chain.ETHEREUM); + const solAssets = portfolio.assets.filter(a => a.chain === Chain.SOLANA); + expect(ethAssets.length).toBeGreaterThan(0); + expect(solAssets.length).toBeGreaterThan(0); + }); + + it('preserves cross-chain-family USDC as separate assets', async () => { + const ethAddr = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb4'; + const solAddr = '5UtaXPD7yKFdwZcNh5qZRf8kY3Zv7HaGpP9K9S5dFN4X'; + + evmIntegration.addAssetsForAddress(ethAddr, createEVMAssets(ethAddr)); + solanaIntegration.addAssetsForAddress(solAddr, createSolanaAssets(solAddr)); + + const addresses = new Map(); + addresses.set('ethereum', [ethAddr]); + addresses.set('solana', [solAddr]); + + const portfolio = await service.aggregatePortfolio({ + addresses, + forceRefresh: true, + }); + + // USDC on Ethereum and USDC on Solana must remain separate + const usdcAssets = portfolio.assets.filter(a => a.symbol === 'USDC'); + expect(usdcAssets.length).toBe(2); + + const usdcChains = usdcAssets.map(a => a.chain); + expect(usdcChains).toContain(Chain.ETHEREUM); + expect(usdcChains).toContain(Chain.SOLANA); + + // Balances should NOT be merged + const ethUsdc = usdcAssets.find(a => a.chain === Chain.ETHEREUM)!; + const solUsdc = usdcAssets.find(a => a.chain === Chain.SOLANA)!; + expect(ethUsdc.balance.amount).toBe(5000); + expect(solUsdc.balance.amount).toBe(3000); + }); + + it('handles addresses with no matching integration gracefully', async () => { + const addresses = new Map(); + // Bitcoin has no integration yet + addresses.set('bitcoin', ['bc1qtest']); + + const portfolio = await service.aggregatePortfolio({ + addresses, + forceRefresh: true, + }); + + // Should return empty portfolio without error + expect(portfolio.assets.length).toBe(0); + }); +}); diff --git a/src/tests/unit/application/services/ChainFamilyRouting.test.ts b/src/tests/unit/application/services/ChainFamilyRouting.test.ts new file mode 100644 index 0000000..b85d24f --- /dev/null +++ b/src/tests/unit/application/services/ChainFamilyRouting.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PortfolioAggregationService } from '../../../../application/services/PortfolioAggregationService'; +import { InMemoryPortfolioRepository } from '../../../mocks/InMemoryPortfolioRepository'; +import { MockAssetValuator } from '../../../mocks/MockAssetValuator'; +import type { IIntegrationRepository } from '../../../../contracts/repositories/IIntegrationRepository'; +import { IntegrationSource, AssetType, Chain } from '../../../../shared/types'; +import type { Asset } from '../../../../shared/types'; + +function createMockIntegration( + source: IntegrationSource, + assetsFactory?: (addresses: string[]) => Asset[] +): IIntegrationRepository { + let connected = false; + return { + source, + connect: vi.fn(async () => { connected = true; }), + disconnect: vi.fn(async () => { connected = false; }), + isConnected: vi.fn(() => connected), + fetchPortfolio: vi.fn(async () => ({ + id: 'p1', assets: [], totalValue: { value: 0, currency: 'USD', timestamp: new Date() }, + lastUpdated: new Date(), sources: [source], + })), + fetchAssets: vi.fn(async (addresses: string[]) => { + if (assetsFactory) return assetsFactory(addresses); + return []; + }), + fetchTransactions: vi.fn(async () => [] as never[]), + }; +} + +describe('Chain-family routing in PortfolioAggregationService', () => { + let evmIntegration: IIntegrationRepository; + let solanaIntegration: IIntegrationRepository; + let portfolioRepo: InMemoryPortfolioRepository; + let valuator: MockAssetValuator; + + beforeEach(() => { + evmIntegration = createMockIntegration(IntegrationSource.EVM, (addrs) => + addrs.map((_addr, i) => ({ + id: `evm-asset-${i}`, + symbol: 'ETH', + type: AssetType.TOKEN, + chain: Chain.ETHEREUM, + balance: { amount: 1, decimals: 18, formatted: '1.0' }, + })) + ); + + solanaIntegration = createMockIntegration(IntegrationSource.SOLANA, (addrs) => + addrs.map((_addr, i) => ({ + id: `sol-asset-${i}`, + symbol: 'SOL', + type: AssetType.TOKEN, + chain: Chain.SOLANA, + balance: { amount: 10, decimals: 9, formatted: '10.0' }, + })) + ); + + portfolioRepo = new InMemoryPortfolioRepository(); + valuator = new MockAssetValuator(); + }); + + it('routes EVM chain addresses only to EVM integration', async () => { + const integrations = new Map(); + integrations.set(IntegrationSource.EVM, evmIntegration); + integrations.set(IntegrationSource.SOLANA, solanaIntegration); + + const service = new PortfolioAggregationService( + integrations, + portfolioRepo, + valuator + ); + + const addresses = new Map(); + addresses.set('ethereum', ['0xAAA']); + addresses.set('polygon', ['0xBBB']); + + await service.aggregatePortfolio({ + addresses, + forceRefresh: true, + }); + + // EVM integration should have been called with EVM addresses + expect(evmIntegration.fetchAssets).toHaveBeenCalled(); + const evmCallArgs = (evmIntegration.fetchAssets as ReturnType).mock.calls[0][0]; + expect(evmCallArgs).toContain('0xAAA'); + expect(evmCallArgs).toContain('0xBBB'); + + // Solana integration should NOT have been called with EVM addresses + // (it should only get solana addresses, which are empty here) + const solCalls = (solanaIntegration.fetchAssets as ReturnType).mock.calls; + if (solCalls.length > 0) { + expect(solCalls[0][0]).not.toContain('0xAAA'); + expect(solCalls[0][0]).not.toContain('0xBBB'); + } + }); + + it('routes solana addresses only to Solana integration', async () => { + const integrations = new Map(); + integrations.set(IntegrationSource.EVM, evmIntegration); + integrations.set(IntegrationSource.SOLANA, solanaIntegration); + + const service = new PortfolioAggregationService( + integrations, + portfolioRepo, + valuator + ); + + const addresses = new Map(); + addresses.set('solana', ['SolAddr1']); + + await service.aggregatePortfolio({ + addresses, + forceRefresh: true, + }); + + // Solana integration should get solana addresses + expect(solanaIntegration.fetchAssets).toHaveBeenCalled(); + const solCallArgs = (solanaIntegration.fetchAssets as ReturnType).mock.calls[0][0]; + expect(solCallArgs).toContain('SolAddr1'); + + // EVM integration should not get solana addresses + const evmCalls = (evmIntegration.fetchAssets as ReturnType).mock.calls; + if (evmCalls.length > 0) { + expect(evmCalls[0][0]).not.toContain('SolAddr1'); + } + }); + + it('never deduplicates assets across different chain families', async () => { + // Same USDC symbol on EVM and Solana — must NOT be merged + const evmWithUsdc = createMockIntegration(IntegrationSource.EVM, () => [{ + id: 'evm-usdc', + symbol: 'USDC', + type: AssetType.TOKEN, + chain: Chain.ETHEREUM, + contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + balance: { amount: 1000, decimals: 6, formatted: '1000.0' }, + }]); + + const solWithUsdc = createMockIntegration(IntegrationSource.SOLANA, () => [{ + id: 'sol-usdc', + symbol: 'USDC', + type: AssetType.TOKEN, + chain: Chain.SOLANA, + contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + balance: { amount: 500, decimals: 6, formatted: '500.0' }, + }]); + + const integrations = new Map(); + integrations.set(IntegrationSource.EVM, evmWithUsdc); + integrations.set(IntegrationSource.SOLANA, solWithUsdc); + + const service = new PortfolioAggregationService( + integrations, + portfolioRepo, + valuator + ); + + const addresses = new Map(); + addresses.set('ethereum', ['0xAAA']); + addresses.set('solana', ['SolAddr1']); + + const portfolio = await service.aggregatePortfolio({ + addresses, + forceRefresh: true, + }); + + // Both USDC instances should exist as separate assets (different chains/families) + const usdcAssets = portfolio.assets.filter(a => a.symbol === 'USDC'); + expect(usdcAssets.length).toBe(2); + + // Verify they have different chains + const chains = usdcAssets.map(a => a.chain); + expect(chains).toContain(Chain.ETHEREUM); + expect(chains).toContain(Chain.SOLANA); + }); + + it('deduplicates assets within same chain family', async () => { + // Same address on ethereum and polygon — same EVM family, same USDC contract + const evmIntWithDups = createMockIntegration(IntegrationSource.EVM, () => [ + { + id: 'eth-usdc', + symbol: 'USDC', + type: AssetType.TOKEN, + chain: Chain.ETHEREUM, + contractAddress: '0xA0b86991', + balance: { amount: 1000, decimals: 6, formatted: '1000.0' }, + }, + { + id: 'eth-usdc-dup', + symbol: 'USDC', + type: AssetType.TOKEN, + chain: Chain.ETHEREUM, + contractAddress: '0xA0b86991', + balance: { amount: 500, decimals: 6, formatted: '500.0' }, + }, + ]); + + const integrations = new Map(); + integrations.set(IntegrationSource.EVM, evmIntWithDups); + + const service = new PortfolioAggregationService( + integrations, + portfolioRepo, + valuator + ); + + const addresses = new Map(); + addresses.set('ethereum', ['0xAAA']); + + const portfolio = await service.aggregatePortfolio({ + addresses, + forceRefresh: true, + }); + + // Same chain + same contract = merged (balance summed) + const usdcAssets = portfolio.assets.filter(a => a.symbol === 'USDC'); + expect(usdcAssets.length).toBe(1); + expect(usdcAssets[0].balance.amount).toBe(1500); + }); +}); diff --git a/src/tests/unit/domain/services/ChainFamilyRouter.test.ts b/src/tests/unit/domain/services/ChainFamilyRouter.test.ts new file mode 100644 index 0000000..cae8a9e --- /dev/null +++ b/src/tests/unit/domain/services/ChainFamilyRouter.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from 'vitest'; +import { + ChainFamily, + chainToFamily, + CHAIN_FAMILY_CHAINS, + ChainFamilyRouter, +} from '../../../../domain/services/ChainFamilyRouter'; +import { Chain } from '../../../../shared/types'; + +describe('ChainFamily type', () => { + it('defines all six chain families', () => { + expect(ChainFamily.EVM).toBe('evm'); + expect(ChainFamily.SOLANA).toBe('solana'); + expect(ChainFamily.SUI).toBe('sui'); + expect(ChainFamily.BITCOIN).toBe('bitcoin'); + expect(ChainFamily.COSMOS).toBe('cosmos'); + expect(ChainFamily.APTOS).toBe('aptos'); + }); +}); + +describe('chainToFamily mapping', () => { + it('maps EVM chains to evm family', () => { + expect(chainToFamily(Chain.ETHEREUM)).toBe(ChainFamily.EVM); + expect(chainToFamily(Chain.POLYGON)).toBe(ChainFamily.EVM); + expect(chainToFamily(Chain.ARBITRUM)).toBe(ChainFamily.EVM); + expect(chainToFamily(Chain.OPTIMISM)).toBe(ChainFamily.EVM); + expect(chainToFamily(Chain.BINANCE)).toBe(ChainFamily.EVM); + }); + + it('maps solana chain to solana family', () => { + expect(chainToFamily(Chain.SOLANA)).toBe(ChainFamily.SOLANA); + }); + + it('maps bitcoin chain to bitcoin family', () => { + expect(chainToFamily(Chain.BITCOIN)).toBe(ChainFamily.BITCOIN); + }); + + it('returns undefined for unknown chain', () => { + expect(chainToFamily('unknown-chain' as Chain)).toBeUndefined(); + }); +}); + +describe('CHAIN_FAMILY_CHAINS registry', () => { + it('maps evm family to all EVM chains', () => { + const evmChains = CHAIN_FAMILY_CHAINS[ChainFamily.EVM]; + expect(evmChains).toContain(Chain.ETHEREUM); + expect(evmChains).toContain(Chain.POLYGON); + expect(evmChains).toContain(Chain.ARBITRUM); + expect(evmChains).toContain(Chain.OPTIMISM); + expect(evmChains).toContain(Chain.BINANCE); + }); + + it('maps solana family to solana chain only', () => { + expect(CHAIN_FAMILY_CHAINS[ChainFamily.SOLANA]).toEqual([Chain.SOLANA]); + }); + + it('maps bitcoin family to bitcoin chain only', () => { + expect(CHAIN_FAMILY_CHAINS[ChainFamily.BITCOIN]).toEqual([Chain.BITCOIN]); + }); + + it('has empty arrays for chain families with no chains yet', () => { + expect(CHAIN_FAMILY_CHAINS[ChainFamily.SUI]).toEqual([]); + expect(CHAIN_FAMILY_CHAINS[ChainFamily.COSMOS]).toEqual([]); + expect(CHAIN_FAMILY_CHAINS[ChainFamily.APTOS]).toEqual([]); + }); +}); + +describe('ChainFamilyRouter', () => { + describe('groupAddressesByFamily', () => { + it('groups EVM chain addresses under evm family', () => { + const addresses = new Map(); + addresses.set('ethereum', ['0xAAA']); + addresses.set('polygon', ['0xBBB']); + addresses.set('arbitrum', ['0xCCC']); + + const grouped = ChainFamilyRouter.groupAddressesByFamily(addresses); + + expect(grouped.has(ChainFamily.EVM)).toBe(true); + const evmAddrs = grouped.get(ChainFamily.EVM)!; + expect(evmAddrs).toContain('0xAAA'); + expect(evmAddrs).toContain('0xBBB'); + expect(evmAddrs).toContain('0xCCC'); + }); + + it('groups solana addresses under solana family', () => { + const addresses = new Map(); + addresses.set('solana', ['SolAddr1']); + + const grouped = ChainFamilyRouter.groupAddressesByFamily(addresses); + + expect(grouped.has(ChainFamily.SOLANA)).toBe(true); + expect(grouped.get(ChainFamily.SOLANA)).toEqual(['SolAddr1']); + }); + + it('separates EVM and Solana into different families', () => { + const addresses = new Map(); + addresses.set('ethereum', ['0xAAA']); + addresses.set('solana', ['SolAddr1']); + + const grouped = ChainFamilyRouter.groupAddressesByFamily(addresses); + + expect(grouped.size).toBe(2); + expect(grouped.has(ChainFamily.EVM)).toBe(true); + expect(grouped.has(ChainFamily.SOLANA)).toBe(true); + }); + + it('deduplicates addresses within same chain family', () => { + const addresses = new Map(); + // Same address on ethereum and polygon (both EVM) + addresses.set('ethereum', ['0xAAA']); + addresses.set('polygon', ['0xAAA']); + + const grouped = ChainFamilyRouter.groupAddressesByFamily(addresses); + + const evmAddrs = grouped.get(ChainFamily.EVM)!; + expect(evmAddrs).toEqual(['0xAAA']); + }); + + it('does NOT deduplicate same address across different chain families', () => { + const addresses = new Map(); + // Same hex string on ethereum and bitcoin — different families + addresses.set('ethereum', ['0xAAA']); + addresses.set('bitcoin', ['0xAAA']); + + const grouped = ChainFamilyRouter.groupAddressesByFamily(addresses); + + expect(grouped.get(ChainFamily.EVM)).toEqual(['0xAAA']); + expect(grouped.get(ChainFamily.BITCOIN)).toEqual(['0xAAA']); + }); + + it('handles empty addresses map', () => { + const addresses = new Map(); + const grouped = ChainFamilyRouter.groupAddressesByFamily(addresses); + expect(grouped.size).toBe(0); + }); + + it('skips unknown chains gracefully', () => { + const addresses = new Map(); + addresses.set('unknown-chain', ['addr1']); + addresses.set('ethereum', ['0xAAA']); + + const grouped = ChainFamilyRouter.groupAddressesByFamily(addresses); + + expect(grouped.size).toBe(1); + expect(grouped.has(ChainFamily.EVM)).toBe(true); + }); + }); + + describe('getRelevantAddressesForFamily', () => { + const addresses = new Map(); + addresses.set('ethereum', ['0xAAA', '0xBBB']); + addresses.set('polygon', ['0xCCC']); + addresses.set('solana', ['SolAddr1']); + addresses.set('bitcoin', ['bc1addr']); + + it('returns all EVM chain addresses for evm family', () => { + const result = ChainFamilyRouter.getRelevantAddressesForFamily( + ChainFamily.EVM, + addresses + ); + expect(result).toContain('0xAAA'); + expect(result).toContain('0xBBB'); + expect(result).toContain('0xCCC'); + expect(result).not.toContain('SolAddr1'); + }); + + it('returns only solana addresses for solana family', () => { + const result = ChainFamilyRouter.getRelevantAddressesForFamily( + ChainFamily.SOLANA, + addresses + ); + expect(result).toEqual(['SolAddr1']); + }); + + it('returns only bitcoin addresses for bitcoin family', () => { + const result = ChainFamilyRouter.getRelevantAddressesForFamily( + ChainFamily.BITCOIN, + addresses + ); + expect(result).toEqual(['bc1addr']); + }); + + it('returns empty array for family with no matching chains', () => { + const result = ChainFamilyRouter.getRelevantAddressesForFamily( + ChainFamily.SUI, + addresses + ); + expect(result).toEqual([]); + }); + + it('deduplicates within the family', () => { + const addrs = new Map(); + addrs.set('ethereum', ['0xAAA']); + addrs.set('polygon', ['0xAAA']); + + const result = ChainFamilyRouter.getRelevantAddressesForFamily( + ChainFamily.EVM, + addrs + ); + expect(result).toEqual(['0xAAA']); + }); + }); + + describe('isSingleChainFamily', () => { + it('returns false for EVM (multi-chain)', () => { + expect(ChainFamilyRouter.isSingleChainFamily(ChainFamily.EVM)).toBe(false); + }); + + it('returns true for Solana (single-chain)', () => { + expect(ChainFamilyRouter.isSingleChainFamily(ChainFamily.SOLANA)).toBe(true); + }); + + it('returns true for Bitcoin (single-chain)', () => { + expect(ChainFamilyRouter.isSingleChainFamily(ChainFamily.BITCOIN)).toBe(true); + }); + + it('returns true for Sui (single-chain)', () => { + expect(ChainFamilyRouter.isSingleChainFamily(ChainFamily.SUI)).toBe(true); + }); + + it('returns true for Aptos (single-chain)', () => { + expect(ChainFamilyRouter.isSingleChainFamily(ChainFamily.APTOS)).toBe(true); + }); + + it('returns false for Cosmos (user-selectable, treated as multi-chain)', () => { + expect(ChainFamilyRouter.isSingleChainFamily(ChainFamily.COSMOS)).toBe(false); + }); + }); + + describe('familyToIntegrationSource', () => { + it('maps evm family to EVM integration source', () => { + expect(ChainFamilyRouter.familyToIntegrationSource(ChainFamily.EVM)).toBe('evm'); + }); + + it('maps solana family to SOLANA integration source', () => { + expect(ChainFamilyRouter.familyToIntegrationSource(ChainFamily.SOLANA)).toBe('solana'); + }); + + it('returns undefined for chain families without integration yet', () => { + expect(ChainFamilyRouter.familyToIntegrationSource(ChainFamily.SUI)).toBeUndefined(); + expect(ChainFamilyRouter.familyToIntegrationSource(ChainFamily.BITCOIN)).toBeUndefined(); + expect(ChainFamilyRouter.familyToIntegrationSource(ChainFamily.COSMOS)).toBeUndefined(); + expect(ChainFamilyRouter.familyToIntegrationSource(ChainFamily.APTOS)).toBeUndefined(); + }); + }); +});