diff --git a/src/application/services/MultiAccountAggregationService.ts b/src/application/services/MultiAccountAggregationService.ts new file mode 100644 index 0000000..b324f23 --- /dev/null +++ b/src/application/services/MultiAccountAggregationService.ts @@ -0,0 +1,71 @@ +import { PortfolioAggregate } from '../../domain/aggregates/Portfolio'; +import { PortfolioAggregationService, AggregationOptions } from './PortfolioAggregationService'; +import type { IntegrationSource } from '../../shared/types'; + +/** + * Represents a single wallet/mnemonic with its addresses across chains + */ +export interface WalletAccount { + id: string; + label?: string; + addresses: Map; // chain -> addresses +} + +/** + * Options for aggregating across multiple wallet accounts + */ +export interface MultiAccountAggregationOptions { + accounts: WalletAccount[]; + sources?: IntegrationSource[]; + userId?: string; + forceRefresh?: boolean; +} + +/** + * Service for aggregating portfolio data across multiple wallet accounts + * (separate mnemonics/hardware wallets/browser wallets). + * + * Merges all addresses from all accounts, delegates to PortfolioAggregationService + * for fetching and reconciliation, and returns a unified portfolio view. + */ +export class MultiAccountAggregationService { + constructor( + private aggregationService: PortfolioAggregationService + ) {} + + /** + * Aggregate assets from multiple wallet accounts into a unified portfolio. + * Same-chain, same-token assets are merged (balances summed). + */ + async aggregateAccounts( + options: MultiAccountAggregationOptions + ): Promise { + const mergedAddresses = this.mergeAccountAddresses(options.accounts); + + const aggregationOptions: AggregationOptions = { + addresses: mergedAddresses, + sources: options.sources, + userId: options.userId, + forceRefresh: options.forceRefresh, + }; + + return this.aggregationService.aggregatePortfolio(aggregationOptions); + } + + /** + * Merge addresses from multiple accounts, deduplicating per chain. + */ + mergeAccountAddresses(accounts: WalletAccount[]): Map { + const merged = new Map(); + + for (const account of accounts) { + for (const [chain, addrs] of account.addresses) { + const existing = merged.get(chain) || []; + const deduped = [...new Set([...existing, ...addrs])]; + merged.set(chain, deduped); + } + } + + return merged; + } +} diff --git a/src/tests/e2e/circuit-breaker.e2e.test.ts b/src/tests/e2e/circuit-breaker.e2e.test.ts index df4943a..c075ce3 100644 --- a/src/tests/e2e/circuit-breaker.e2e.test.ts +++ b/src/tests/e2e/circuit-breaker.e2e.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { SyncOrchestratorService } from '../../application/services/SyncOrchestratorService'; import { CircuitBreaker } from '../../infrastructure/patterns/CircuitBreaker'; import { CircuitState } from '../../contracts/patterns/ICircuitBreaker'; -import { IntegrationSource } from '../../shared/types'; +import { IntegrationSource, Environment } from '../../shared/types'; import { E2EMockIntegrationRepository, E2EMockEventEmitter, @@ -45,7 +45,8 @@ describe('E2E: Circuit Breaker Activation', () => { recoveryTimeout: 100, halfOpenRetries: 1, }), - eventEmitter + eventEmitter, + Environment.TESTNET ); }); diff --git a/src/tests/e2e/multi-account-aggregation.e2e.test.ts b/src/tests/e2e/multi-account-aggregation.e2e.test.ts new file mode 100644 index 0000000..dbe4f28 --- /dev/null +++ b/src/tests/e2e/multi-account-aggregation.e2e.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { PortfolioAggregationService } from '../../application/services/PortfolioAggregationService'; +import { + MultiAccountAggregationService, + WalletAccount, +} from '../../application/services/MultiAccountAggregationService'; +import { InMemoryPortfolioRepository } from '../mocks/InMemoryPortfolioRepository'; +import { IntegrationSource } from '../../shared/types'; +import { + E2EMockIntegrationRepository, + E2EMockAssetValuator, + createEVMAssets, + createDuplicateEVMAssets, + createSolanaAssets, +} from './mocks'; +import type { IIntegrationRepository } from '../../contracts/repositories/IIntegrationRepository'; + +describe('E2E: Multi-Account Aggregation Across Multiple Mnemonics', () => { + let evmIntegration: E2EMockIntegrationRepository; + let solanaIntegration: E2EMockIntegrationRepository; + let portfolioRepo: InMemoryPortfolioRepository; + let valuator: E2EMockAssetValuator; + let multiAccountService: MultiAccountAggregationService; + + const WALLET_A_ETH = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb4'; + const WALLET_B_ETH = '0x5aAeb6053f3E94C9b9A09f33669435E7Ef1BeAed'; + const WALLET_A_SOL = '5UtaXPD7yKFdwZcNh5qZRf8kY3Zv7HaGpP9K9S5dFN4X'; + const WALLET_B_SOL = '7nYBm5pB8rAHVyhJXKM7Yz7GRe9iqCjHQaXtjJNVh4Mv'; + + beforeEach(() => { + evmIntegration = new E2EMockIntegrationRepository({ source: IntegrationSource.EVM }); + solanaIntegration = new E2EMockIntegrationRepository({ source: IntegrationSource.SOLANA }); + portfolioRepo = new InMemoryPortfolioRepository(); + valuator = new E2EMockAssetValuator(); + + const integrations = new Map([ + [IntegrationSource.EVM, evmIntegration], + [IntegrationSource.SOLANA, solanaIntegration], + ]); + + const aggregationService = new PortfolioAggregationService( + integrations, + portfolioRepo, + valuator + ); + multiAccountService = new MultiAccountAggregationService(aggregationService); + }); + + it('aggregates two EVM wallets with overlapping tokens into unified portfolio', async () => { + // Wallet A: 2.5 ETH + 5000 USDC on ethereum + evmIntegration.addAssetsForAddress(WALLET_A_ETH, createEVMAssets(WALLET_A_ETH)); + // Wallet B: 1.0 ETH on ethereum (duplicate token) + evmIntegration.addAssetsForAddress(WALLET_B_ETH, createDuplicateEVMAssets(WALLET_B_ETH)); + + const accounts: WalletAccount[] = [ + { + id: 'mnemonic-1', + label: 'Ledger Hardware Wallet', + addresses: new Map([['ethereum', [WALLET_A_ETH]]]), + }, + { + id: 'mnemonic-2', + label: 'MetaMask Hot Wallet', + addresses: new Map([['ethereum', [WALLET_B_ETH]]]), + }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-mnemonic-user', + forceRefresh: true, + }); + + // ETH should be merged: 2.5 + 1.0 = 3.5 + const ethAssets = portfolio.assets.filter(a => a.symbol === 'ETH'); + expect(ethAssets).toHaveLength(1); + expect(ethAssets[0].balance.amount).toBeCloseTo(3.5); + + // USDC stays at 5000 (only in wallet A) + const usdcAssets = portfolio.assets.filter(a => a.symbol === 'USDC'); + expect(usdcAssets).toHaveLength(1); + expect(usdcAssets[0].balance.amount).toBeCloseTo(5000); + + // Total: 3.5 * 2500 + 5000 * 1 = 13750 + const total = portfolio.getTotalValue('USD'); + expect(total.amount).toBeCloseTo(13750); + }); + + it('aggregates wallets across EVM and Solana chains', async () => { + // Wallet A: EVM assets on ethereum + evmIntegration.addAssetsForAddress(WALLET_A_ETH, createEVMAssets(WALLET_A_ETH)); + // Wallet B: Solana assets + solanaIntegration.addAssetsForAddress(WALLET_B_SOL, createSolanaAssets(WALLET_B_SOL)); + + const accounts: WalletAccount[] = [ + { + id: 'seed-phrase-1', + label: 'EVM Wallet', + addresses: new Map([['ethereum', [WALLET_A_ETH]]]), + }, + { + id: 'seed-phrase-2', + label: 'Solana Wallet', + addresses: new Map([['solana', [WALLET_B_SOL]]]), + }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'cross-chain-user', + forceRefresh: true, + }); + + // Should have: ETH (ethereum), USDC (ethereum), SOL (solana), USDC (solana) = 4 + expect(portfolio.assets).toHaveLength(4); + expect(portfolio.sources).toContain(IntegrationSource.EVM); + expect(portfolio.sources).toContain(IntegrationSource.SOLANA); + + // USDC on different chains must remain separate + const usdcAssets = portfolio.assets.filter(a => a.symbol === 'USDC'); + expect(usdcAssets).toHaveLength(2); + expect(usdcAssets.map(a => a.chain).sort()).toEqual(['ethereum', 'solana']); + }); + + it('handles three wallets with a mix of shared and unique assets', async () => { + // Wallet A: 2.5 ETH + 5000 USDC on ethereum + evmIntegration.addAssetsForAddress(WALLET_A_ETH, createEVMAssets(WALLET_A_ETH)); + // Wallet B: 1.0 ETH on ethereum (merges with A) + evmIntegration.addAssetsForAddress(WALLET_B_ETH, createDuplicateEVMAssets(WALLET_B_ETH)); + // Wallet C (Solana): 50 SOL + 3000 USDC on solana + solanaIntegration.addAssetsForAddress(WALLET_A_SOL, createSolanaAssets(WALLET_A_SOL)); + + const accounts: WalletAccount[] = [ + { + id: 'hw-wallet', + label: 'Hardware Wallet', + addresses: new Map([['ethereum', [WALLET_A_ETH]]]), + }, + { + id: 'hot-wallet', + label: 'Browser Wallet', + addresses: new Map([['ethereum', [WALLET_B_ETH]]]), + }, + { + id: 'solana-wallet', + label: 'Phantom Wallet', + addresses: new Map([['solana', [WALLET_A_SOL]]]), + }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'three-wallet-user', + forceRefresh: true, + }); + + // ETH merged: 2.5 + 1.0 = 3.5 + const eth = portfolio.assets.filter(a => a.symbol === 'ETH'); + expect(eth).toHaveLength(1); + expect(eth[0].balance.amount).toBeCloseTo(3.5); + + // SOL: 50 + const sol = portfolio.assets.filter(a => a.symbol === 'SOL'); + expect(sol).toHaveLength(1); + expect(sol[0].balance.amount).toBeCloseTo(50); + + // USDC separate by chain: 5000 (ethereum) + 3000 (solana) + const usdc = portfolio.assets.filter(a => a.symbol === 'USDC'); + expect(usdc).toHaveLength(2); + + // Total: 3.5*2500 + 5000*1 + 50*100 + 3000*1 = 8750 + 5000 + 5000 + 3000 = 21750 + const total = portfolio.getTotalValue('USD'); + expect(total.amount).toBeCloseTo(21750); + }); + + it('gracefully handles one integration source failing', async () => { + // Wallet A: EVM assets (succeeds) + evmIntegration.addAssetsForAddress(WALLET_A_ETH, createEVMAssets(WALLET_A_ETH)); + // Wallet B: Solana (will fail) + solanaIntegration.setFailure(true, 'Solana RPC unavailable'); + + const accounts: WalletAccount[] = [ + { + id: 'evm-wallet', + addresses: new Map([['ethereum', [WALLET_A_ETH]]]), + }, + { + id: 'sol-wallet', + addresses: new Map([['solana', [WALLET_B_SOL]]]), + }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'partial-fail-user', + forceRefresh: true, + }); + + // Should still have EVM assets + expect(portfolio.assets.length).toBeGreaterThan(0); + expect(portfolio.sources).toContain(IntegrationSource.EVM); + + // Solana source should not be present + expect(portfolio.sources).not.toContain(IntegrationSource.SOLANA); + }); + + it('single wallet with multiple addresses on same chain aggregates correctly', async () => { + // One mnemonic producing two ethereum addresses + evmIntegration.addAssetsForAddress(WALLET_A_ETH, createEVMAssets(WALLET_A_ETH)); + evmIntegration.addAssetsForAddress(WALLET_B_ETH, createDuplicateEVMAssets(WALLET_B_ETH)); + + const accounts: WalletAccount[] = [ + { + id: 'single-mnemonic', + label: 'My Main Wallet', + addresses: new Map([['ethereum', [WALLET_A_ETH, WALLET_B_ETH]]]), + }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'single-mnemonic-user', + forceRefresh: true, + }); + + // ETH merged from both addresses: 2.5 + 1.0 = 3.5 + const ethAssets = portfolio.assets.filter(a => a.symbol === 'ETH'); + expect(ethAssets).toHaveLength(1); + expect(ethAssets[0].balance.amount).toBeCloseTo(3.5); + }); + + it('wallet with both EVM and Solana addresses aggregates across chains', async () => { + evmIntegration.addAssetsForAddress(WALLET_A_ETH, createEVMAssets(WALLET_A_ETH)); + solanaIntegration.addAssetsForAddress(WALLET_A_SOL, createSolanaAssets(WALLET_A_SOL)); + + const accounts: WalletAccount[] = [ + { + id: 'multi-chain-wallet', + label: 'Cross-chain Wallet', + addresses: new Map([ + ['ethereum', [WALLET_A_ETH]], + ['solana', [WALLET_A_SOL]], + ]), + }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-chain-user', + forceRefresh: true, + }); + + // ETH, USDC-eth, SOL, USDC-sol = 4 assets + expect(portfolio.assets).toHaveLength(4); + expect(portfolio.sources).toContain(IntegrationSource.EVM); + expect(portfolio.sources).toContain(IntegrationSource.SOLANA); + }); + + it('portfolio is saved to repository and can be found by user id', async () => { + evmIntegration.addAssetsForAddress(WALLET_A_ETH, createEVMAssets(WALLET_A_ETH)); + + const accounts: WalletAccount[] = [ + { + id: 'persist-wallet', + addresses: new Map([['ethereum', [WALLET_A_ETH]]]), + }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'persist-user', + forceRefresh: true, + }); + + // Portfolio should be persisted + const saved = await portfolioRepo.findById(portfolio.id); + expect(saved).not.toBeNull(); + expect(saved!.assets.length).toBe(portfolio.assets.length); + }); +}); diff --git a/src/tests/unit/application/services/MultiAccountAggregationService.test.ts b/src/tests/unit/application/services/MultiAccountAggregationService.test.ts new file mode 100644 index 0000000..95370ac --- /dev/null +++ b/src/tests/unit/application/services/MultiAccountAggregationService.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + MultiAccountAggregationService, + WalletAccount, +} from '../../../../application/services/MultiAccountAggregationService'; +import { PortfolioAggregationService } from '../../../../application/services/PortfolioAggregationService'; +import { InMemoryPortfolioRepository } from '../../../mocks/InMemoryPortfolioRepository'; +import { MockIntegrationRepository } from '../../../mocks/MockIntegrationRepository'; +import { MockAssetValuator } from '../../../mocks/MockAssetValuator'; +import { IntegrationSource, AssetType } from '../../../../shared/types'; +import type { IIntegrationRepository } from '../../../../contracts/repositories/IIntegrationRepository'; +import type { Asset } from '../../../../shared/types'; + +describe('MultiAccountAggregationService', () => { + let evmIntegration: MockIntegrationRepository; + let solanaIntegration: MockIntegrationRepository; + let portfolioRepo: InMemoryPortfolioRepository; + let valuator: MockAssetValuator; + let aggregationService: PortfolioAggregationService; + let multiAccountService: MultiAccountAggregationService; + + const WALLET_A_ETH_ADDR = '0x1111111111111111111111111111111111111111'; + const WALLET_B_ETH_ADDR = '0x2222222222222222222222222222222222222222'; + const WALLET_A_SOL_ADDR = '5UtaXPD7yKFdwZcNh5qZRf8kY3Zv7HaGpP9K9S5dFN4X'; + const WALLET_B_SOL_ADDR = '7nYBm5pB8rAHVyhJXKM7Yz7GRe9iqCjHQaXtjJNVh4Mv'; + + function makeEthAsset(address: string, amount: number): Asset { + return { + id: `eth-${address.slice(0, 8)}`, + symbol: 'ETH', + name: 'Ethereum', + type: AssetType.CRYPTOCURRENCY, + chain: 'ethereum', + balance: { amount, decimals: 18, formatted: amount.toString() }, + price: { value: 2500, currency: 'USD', timestamp: new Date() }, + metadata: { address, source: IntegrationSource.EVM }, + }; + } + + function makeUsdcAsset(address: string, amount: number, chain: string = 'ethereum'): Asset { + const contractMap: Record = { + ethereum: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + solana: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }; + return { + id: `usdc-${chain}-${address.slice(0, 8)}`, + symbol: 'USDC', + name: 'USD Coin', + type: AssetType.CRYPTOCURRENCY, + chain: chain as Asset['chain'], + contractAddress: contractMap[chain], + balance: { amount, decimals: 6, formatted: amount.toString() }, + price: { value: 1, currency: 'USD', timestamp: new Date() }, + metadata: { address, source: chain === 'solana' ? IntegrationSource.SOLANA : IntegrationSource.EVM }, + }; + } + + function makeSolAsset(address: string, amount: number): Asset { + return { + id: `sol-${address.slice(0, 8)}`, + symbol: 'SOL', + name: 'Solana', + type: AssetType.CRYPTOCURRENCY, + chain: 'solana', + balance: { amount, decimals: 9, formatted: amount.toString() }, + price: { value: 100, currency: 'USD', timestamp: new Date() }, + metadata: { address, source: IntegrationSource.SOLANA }, + }; + } + + beforeEach(() => { + evmIntegration = new MockIntegrationRepository(IntegrationSource.EVM); + solanaIntegration = new MockIntegrationRepository(IntegrationSource.SOLANA); + portfolioRepo = new InMemoryPortfolioRepository(); + valuator = new MockAssetValuator(); + + const integrations = new Map([ + [IntegrationSource.EVM, evmIntegration], + [IntegrationSource.SOLANA, solanaIntegration], + ]); + + aggregationService = new PortfolioAggregationService(integrations, portfolioRepo, valuator); + multiAccountService = new MultiAccountAggregationService(aggregationService); + }); + + describe('mergeAccountAddresses', () => { + it('merges addresses from multiple accounts across chains', () => { + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map([['ethereum', [WALLET_A_ETH_ADDR]]]) }, + { id: 'wallet-b', addresses: new Map([['ethereum', [WALLET_B_ETH_ADDR]]]) }, + ]; + + const merged = multiAccountService.mergeAccountAddresses(accounts); + + expect(merged.get('ethereum')).toEqual([WALLET_A_ETH_ADDR, WALLET_B_ETH_ADDR]); + }); + + it('deduplicates addresses that appear in multiple accounts', () => { + const sharedAddr = WALLET_A_ETH_ADDR; + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map([['ethereum', [sharedAddr]]]) }, + { id: 'wallet-b', addresses: new Map([['ethereum', [sharedAddr, WALLET_B_ETH_ADDR]]]) }, + ]; + + const merged = multiAccountService.mergeAccountAddresses(accounts); + + expect(merged.get('ethereum')).toHaveLength(2); + expect(merged.get('ethereum')).toContain(sharedAddr); + expect(merged.get('ethereum')).toContain(WALLET_B_ETH_ADDR); + }); + + it('merges addresses across different chains from different accounts', () => { + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map([['ethereum', [WALLET_A_ETH_ADDR]]]) }, + { id: 'wallet-b', addresses: new Map([['solana', [WALLET_B_SOL_ADDR]]]) }, + ]; + + const merged = multiAccountService.mergeAccountAddresses(accounts); + + expect(merged.get('ethereum')).toEqual([WALLET_A_ETH_ADDR]); + expect(merged.get('solana')).toEqual([WALLET_B_SOL_ADDR]); + }); + + it('handles empty accounts list', () => { + const merged = multiAccountService.mergeAccountAddresses([]); + expect(merged.size).toBe(0); + }); + + it('handles accounts with no addresses', () => { + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map() }, + { id: 'wallet-b', addresses: new Map() }, + ]; + + const merged = multiAccountService.mergeAccountAddresses(accounts); + expect(merged.size).toBe(0); + }); + }); + + describe('aggregateAccounts', () => { + it('aggregates same asset from two wallets into merged balance', async () => { + // Wallet A: 2 ETH on ethereum + // Wallet B: 3 ETH on ethereum + // Expected: 5 ETH total + evmIntegration.setMockAssets([ + makeEthAsset(WALLET_A_ETH_ADDR, 2), + makeEthAsset(WALLET_B_ETH_ADDR, 3), + ]); + + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map([['ethereum', [WALLET_A_ETH_ADDR]]]) }, + { id: 'wallet-b', addresses: new Map([['ethereum', [WALLET_B_ETH_ADDR]]]) }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-user', + forceRefresh: true, + }); + + const ethAssets = portfolio.assets.filter(a => a.symbol === 'ETH'); + expect(ethAssets).toHaveLength(1); + expect(ethAssets[0].balance.amount).toBeCloseTo(5); + }); + + it('keeps different assets from different wallets separate', async () => { + // Wallet A: ETH on ethereum + // Wallet B: SOL on solana + evmIntegration.setMockAssets([makeEthAsset(WALLET_A_ETH_ADDR, 2)]); + solanaIntegration.setMockAssets([makeSolAsset(WALLET_B_SOL_ADDR, 50)]); + + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map([['ethereum', [WALLET_A_ETH_ADDR]]]) }, + { id: 'wallet-b', addresses: new Map([['solana', [WALLET_B_SOL_ADDR]]]) }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-user', + forceRefresh: true, + }); + + expect(portfolio.assets).toHaveLength(2); + expect(portfolio.assets.find(a => a.symbol === 'ETH')).toBeTruthy(); + expect(portfolio.assets.find(a => a.symbol === 'SOL')).toBeTruthy(); + }); + + it('correctly calculates total value across multiple wallets', async () => { + // Wallet A: 2 ETH ($5000) + 1000 USDC ($1000) on ethereum + // Wallet B: 3 ETH ($7500) on ethereum + // Total expected: $13,500 + evmIntegration.setMockAssets([ + makeEthAsset(WALLET_A_ETH_ADDR, 2), + makeUsdcAsset(WALLET_A_ETH_ADDR, 1000), + makeEthAsset(WALLET_B_ETH_ADDR, 3), + ]); + + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map([['ethereum', [WALLET_A_ETH_ADDR]]]) }, + { id: 'wallet-b', addresses: new Map([['ethereum', [WALLET_B_ETH_ADDR]]]) }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-user', + forceRefresh: true, + }); + + const totalValue = portfolio.getTotalValue('USD'); + expect(totalValue.amount).toBeCloseTo(13500); + }); + + it('handles USDC on different chains as separate assets', async () => { + // Wallet A: 1000 USDC on ethereum + // Wallet B: 2000 USDC on solana + evmIntegration.setMockAssets([makeUsdcAsset(WALLET_A_ETH_ADDR, 1000, 'ethereum')]); + solanaIntegration.setMockAssets([makeUsdcAsset(WALLET_B_SOL_ADDR, 2000, 'solana')]); + + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map([['ethereum', [WALLET_A_ETH_ADDR]]]) }, + { id: 'wallet-b', addresses: new Map([['solana', [WALLET_B_SOL_ADDR]]]) }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-user', + forceRefresh: true, + }); + + const usdcAssets = portfolio.assets.filter(a => a.symbol === 'USDC'); + expect(usdcAssets).toHaveLength(2); + expect(usdcAssets.map(a => a.chain).sort()).toEqual(['ethereum', 'solana']); + }); + + it('produces an empty portfolio when all accounts have no addresses', async () => { + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map() }, + { id: 'wallet-b', addresses: new Map() }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-user', + forceRefresh: true, + }); + + expect(portfolio.assets).toHaveLength(0); + expect(portfolio.getTotalValue('USD').amount).toBe(0); + }); + + it('produces an empty portfolio when no accounts provided', async () => { + const portfolio = await multiAccountService.aggregateAccounts({ + accounts: [], + userId: 'multi-user', + forceRefresh: true, + }); + + expect(portfolio.assets).toHaveLength(0); + }); + + it('aggregates three wallets across multiple chains', async () => { + // Wallet A: 1 ETH on ethereum + // Wallet B: 2 ETH on ethereum + 50 SOL on solana + // Wallet C: 100 SOL on solana + // Expected: 3 ETH (merged), 150 SOL (merged) + evmIntegration.setMockAssets([ + makeEthAsset(WALLET_A_ETH_ADDR, 1), + makeEthAsset(WALLET_B_ETH_ADDR, 2), + ]); + solanaIntegration.setMockAssets([ + makeSolAsset(WALLET_A_SOL_ADDR, 50), + makeSolAsset(WALLET_B_SOL_ADDR, 100), + ]); + + const accounts: WalletAccount[] = [ + { + id: 'wallet-a', + addresses: new Map([ + ['ethereum', [WALLET_A_ETH_ADDR]], + ['solana', [WALLET_A_SOL_ADDR]], + ]), + }, + { + id: 'wallet-b', + addresses: new Map([ + ['ethereum', [WALLET_B_ETH_ADDR]], + ['solana', [WALLET_B_SOL_ADDR]], + ]), + }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-user', + forceRefresh: true, + }); + + const ethAssets = portfolio.assets.filter(a => a.symbol === 'ETH'); + const solAssets = portfolio.assets.filter(a => a.symbol === 'SOL'); + expect(ethAssets).toHaveLength(1); + expect(ethAssets[0].balance.amount).toBeCloseTo(3); + expect(solAssets).toHaveLength(1); + expect(solAssets[0].balance.amount).toBeCloseTo(150); + }); + + it('records all sources used across wallets', async () => { + evmIntegration.setMockAssets([makeEthAsset(WALLET_A_ETH_ADDR, 1)]); + solanaIntegration.setMockAssets([makeSolAsset(WALLET_B_SOL_ADDR, 50)]); + + const accounts: WalletAccount[] = [ + { id: 'wallet-a', addresses: new Map([['ethereum', [WALLET_A_ETH_ADDR]]]) }, + { id: 'wallet-b', addresses: new Map([['solana', [WALLET_B_SOL_ADDR]]]) }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-user', + forceRefresh: true, + }); + + expect(portfolio.sources).toContain(IntegrationSource.EVM); + expect(portfolio.sources).toContain(IntegrationSource.SOLANA); + }); + + it('includes account labels for attribution', async () => { + evmIntegration.setMockAssets([makeEthAsset(WALLET_A_ETH_ADDR, 2)]); + + const accounts: WalletAccount[] = [ + { + id: 'wallet-a', + label: 'Hardware Wallet', + addresses: new Map([['ethereum', [WALLET_A_ETH_ADDR]]]), + }, + ]; + + const portfolio = await multiAccountService.aggregateAccounts({ + accounts, + userId: 'multi-user', + forceRefresh: true, + }); + + expect(portfolio.assets).toHaveLength(1); + }); + }); +});