Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 14 additions & 19 deletions src/application/services/PortfolioAggregationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -152,27 +153,21 @@ export class PortfolioAggregationService {
source: IntegrationSource,
addresses: Map<string, string[]>
): 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)];
}

Expand Down
151 changes: 151 additions & 0 deletions src/domain/services/ChainFamilyRouter.ts
Original file line number Diff line number Diff line change
@@ -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<string, ChainFamily> = {
[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, Chain[]> = {
[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<Record<ChainFamily, IntegrationSource>> = {
[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<ChainFamily> = 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<string, string[]>
): Map<ChainFamily, string[]> {
const grouped = new Map<ChainFamily, Set<string>>();

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<ChainFamily, string[]>();
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, string[]>
): string[] {
const chains = CHAIN_FAMILY_CHAINS[family];
if (!chains || chains.length === 0) return [];

const result = new Set<string>();
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];
}
}
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
135 changes: 135 additions & 0 deletions src/tests/e2e/chain-family-routing.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>();
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<string, string[]>();
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<string, string[]>();
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<string, string[]>();
// 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);
});
});
Loading