diff --git a/package-lock.json b/package-lock.json index 42a1ca1..88e6338 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cygnus-wealth/portfolio-aggregation", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cygnus-wealth/portfolio-aggregation", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@cygnus-wealth/asset-valuator": "^0.2.0", diff --git a/src/application/services/PortfolioAggregationService.ts b/src/application/services/PortfolioAggregationService.ts index b8056c4..a79a1c3 100644 --- a/src/application/services/PortfolioAggregationService.ts +++ b/src/application/services/PortfolioAggregationService.ts @@ -4,6 +4,7 @@ import type { IIntegrationRepository } from '../../contracts/repositories/IInteg import type { IPortfolioRepository } from '../../contracts/repositories/IPortfolioRepository'; import type { IAssetValuatorRepository } from '../../contracts/repositories/IAssetValuatorRepository'; import { IntegrationSource } from '../../shared/types'; +import type { DeFiPosition } from '../../shared/types'; export interface AggregationOptions { sources?: IntegrationSource[]; @@ -12,6 +13,12 @@ export interface AggregationOptions { forceRefresh?: boolean; } +export interface DeFiAggregationResult { + positions: DeFiPosition[]; + failedSources: { source: IntegrationSource; error: string }[]; + receiptTokenAddresses: Set; +} + export class PortfolioAggregationService { private integrations: Map; private portfolioRepository: IPortfolioRepository; @@ -29,7 +36,7 @@ export class PortfolioAggregationService { async aggregatePortfolio(options: AggregationOptions): Promise { const portfolioId = this.generatePortfolioId(options.userId); - + // Check cache if not forcing refresh if (!options.forceRefresh) { const cached = await this.portfolioRepository.findById(portfolioId); @@ -47,36 +54,53 @@ export class PortfolioAggregationService { // Determine which sources to use const sourcesToFetch = options.sources || Array.from(this.integrations.keys()); - // Fetch from all sources in parallel - const fetchPromises = sourcesToFetch.map(source => + // Scatter: Fetch assets and DeFi positions from all sources in parallel + const assetFetchPromises = sourcesToFetch.map(source => this.fetchFromSource(source, options.addresses) ); + const defiFetchPromises = sourcesToFetch.map(source => + this.fetchDeFiFromSource(source, options.addresses) + ); try { - const results = await Promise.allSettled(fetchPromises); - - // Process successful fetches - for (let i = 0; i < results.length; i++) { - const result = results[i]; + const [assetResults, defiResults] = await Promise.all([ + Promise.allSettled(assetFetchPromises), + Promise.allSettled(defiFetchPromises) + ]); + + // Gather: Process asset results + for (let i = 0; i < assetResults.length; i++) { + const result = assetResults[i]; const source = sourcesToFetch[i]; - + if (result.status === 'fulfilled' && result.value) { const assets = result.value; portfolio.addSource(source); - + for (const asset of assets) { portfolio.addAsset(asset); } } else if (result.status === 'rejected') { - console.error(`Failed to fetch from ${source}:`, result.reason); + console.error(`Failed to fetch assets from ${source}:`, result.reason); } } + // Gather: Process DeFi results with deduplication + const defiAggregation = this.processDeFiResults(defiResults, sourcesToFetch); + + for (const position of defiAggregation.positions) { + portfolio.addDeFiPosition(position); + } + + // Coordinate: filter receipt tokens to prevent double-counting + portfolio.filterReceiptTokens(defiAggregation.receiptTokenAddresses); + // Reconcile duplicates portfolio.reconcile(); - // Enrich with prices + // Enrich with prices (assets + DeFi underlying assets) await this.enrichWithPrices(portfolio); + await this.enrichDeFiWithPrices(portfolio); // Save to repository await this.portfolioRepository.save(portfolio); @@ -152,6 +176,105 @@ export class PortfolioAggregationService { return [...new Set(result)]; } + private async fetchDeFiFromSource( + source: IntegrationSource, + addresses: Map + ): Promise { + const integration = this.integrations.get(source); + if (!integration || !integration.getDeFiPositions) { + return []; + } + + if (!integration.isConnected()) { + await integration.connect(); + } + + const relevantAddresses = this.getRelevantAddresses(source, addresses); + if (relevantAddresses.length === 0) { + return []; + } + + return integration.getDeFiPositions(relevantAddresses); + } + + private processDeFiResults( + results: PromiseSettledResult[], + sources: IntegrationSource[] + ): DeFiAggregationResult { + const seen = new Map(); + const failedSources: { source: IntegrationSource; error: string }[] = []; + const receiptTokenAddresses = new Set(); + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const source = sources[i]; + + if (result.status === 'fulfilled' && result.value) { + for (const position of result.value) { + // Deduplicate by deduplicationKey — first-seen wins + if (!seen.has(position.deduplicationKey)) { + seen.set(position.deduplicationKey, position); + } + // Collect receipt token addresses + if (position.receiptTokenAddress) { + receiptTokenAddresses.add(position.receiptTokenAddress.toLowerCase()); + } + } + } else if (result.status === 'rejected') { + console.error(`Failed to fetch DeFi from ${source}:`, result.reason); + failedSources.push({ + source, + error: result.reason instanceof Error ? result.reason.message : String(result.reason) + }); + } + } + + return { + positions: Array.from(seen.values()), + failedSources, + receiptTokenAddresses + }; + } + + private async enrichDeFiWithPrices(portfolio: PortfolioAggregate): Promise { + const positions = portfolio.defiPositions; + const symbols = new Set(); + + for (const position of positions) { + for (const underlying of position.underlyingAssets) { + symbols.add(underlying.symbol); + } + } + + if (symbols.size === 0) return; + + try { + const prices = await this.assetValuator.getBatchPrices([...symbols]); + + for (const position of positions) { + if (position.value) continue; // Already priced + + let totalValue = 0; + for (const underlying of position.underlyingAssets) { + const price = prices.get(underlying.symbol); + if (price) { + totalValue += underlying.amount * price.value; + } + } + + if (totalValue > 0) { + position.value = { + value: totalValue, + currency: 'USD', + timestamp: new Date() + }; + } + } + } catch (error) { + console.error('Failed to enrich DeFi with prices:', error); + } + } + private async enrichWithPrices(portfolio: PortfolioAggregate): Promise { const assets = portfolio.assets; const symbols = [...new Set(assets.map(a => a.symbol))]; diff --git a/src/contracts/repositories/IIntegrationRepository.ts b/src/contracts/repositories/IIntegrationRepository.ts index 09a59c7..500cb70 100644 --- a/src/contracts/repositories/IIntegrationRepository.ts +++ b/src/contracts/repositories/IIntegrationRepository.ts @@ -1,16 +1,17 @@ -import type { Asset, Portfolio, Transaction } from '../../shared/types'; +import type { Asset, Portfolio, Transaction, DeFiPosition } from '../../shared/types'; import { IntegrationSource } from '../../shared/types'; export interface IIntegrationRepository { source: IntegrationSource; - + connect(): Promise; disconnect(): Promise; isConnected(): boolean; - + fetchPortfolio(addresses: string[]): Promise; fetchAssets(addresses: string[]): Promise; fetchTransactions(addresses: string[], limit?: number): Promise; - + getDeFiPositions?(addresses: string[]): Promise; + subscribeToUpdates?(addresses: string[], callback: (update: Portfolio) => void): () => void; } \ No newline at end of file diff --git a/src/domain/aggregates/Portfolio.ts b/src/domain/aggregates/Portfolio.ts index 49cf02f..0980e14 100644 --- a/src/domain/aggregates/Portfolio.ts +++ b/src/domain/aggregates/Portfolio.ts @@ -1,5 +1,5 @@ -import { IntegrationSource } from '../../shared/types'; -import type { Price } from '../../shared/types'; +import { IntegrationSource, DeFiPositionType } from '../../shared/types'; +import type { Price, DeFiPosition } from '../../shared/types'; import { AssetEntity } from '../entities/Asset'; import { Money } from '../value-objects/Money'; @@ -7,6 +7,7 @@ export class PortfolioAggregate { private _id: string; private _userId?: string; private _assets: Map; + private _defiPositions: Map; private _sources: Set; private _lastUpdated: Date; @@ -14,12 +15,14 @@ export class PortfolioAggregate { id: string; userId?: string; assets?: AssetEntity[]; + defiPositions?: DeFiPosition[]; sources?: IntegrationSource[]; lastUpdated?: Date; }) { this._id = params.id; this._userId = params.userId; this._assets = new Map(); + this._defiPositions = new Map(); this._sources = new Set(params.sources || []); this._lastUpdated = params.lastUpdated || new Date(); @@ -28,6 +31,12 @@ export class PortfolioAggregate { this.addAsset(asset); }); } + + if (params.defiPositions) { + params.defiPositions.forEach(position => { + this.addDeFiPosition(position); + }); + } } get id(): string { @@ -69,6 +78,33 @@ export class PortfolioAggregate { } } + get defiPositions(): DeFiPosition[] { + return Array.from(this._defiPositions.values()); + } + + addDeFiPosition(position: DeFiPosition): void { + const existing = this._defiPositions.get(position.deduplicationKey); + if (!existing) { + this._defiPositions.set(position.deduplicationKey, position); + } + // Duplicate by deduplicationKey — first-seen wins + this._lastUpdated = new Date(); + } + + removeDeFiPosition(deduplicationKey: string): void { + if (this._defiPositions.delete(deduplicationKey)) { + this._lastUpdated = new Date(); + } + } + + filterReceiptTokens(receiptTokenAddresses: Set): void { + for (const [id, asset] of this._assets) { + if (asset.contractAddress && receiptTokenAddresses.has(asset.contractAddress.toLowerCase())) { + this._assets.delete(id); + } + } + } + addSource(source: IntegrationSource): void { this._sources.add(source); this._lastUpdated = new Date(); @@ -84,16 +120,31 @@ export class PortfolioAggregate { } getTotalValue(currency: string = 'USD'): Money { - let total = new Money(0, currency); - + let totalAmount = 0; + for (const asset of this._assets.values()) { const value = asset.getValue(); if (value && value.currency === currency) { - total = total.add(value); + totalAmount += value.amount; } } - - return total; + + totalAmount += this.getDeFiNetValue(currency); + + return new Money(Math.max(0, totalAmount), currency); + } + + getDeFiNetValue(currency: string = 'USD'): number { + let net = 0; + for (const position of this._defiPositions.values()) { + if (!position.value || position.value.currency !== currency) continue; + if (position.type === DeFiPositionType.LENDING_BORROW) { + net -= position.value.value; + } else { + net += position.value.value; + } + } + return net; } getAssetsByChain(chain: string): AssetEntity[] { @@ -108,11 +159,15 @@ export class PortfolioAggregate { for (const asset of other.assets) { this.addAsset(asset); } - + + for (const position of other.defiPositions) { + this.addDeFiPosition(position); + } + for (const source of other.sources) { this.addSource(source); } - + this._lastUpdated = new Date(); } @@ -142,22 +197,25 @@ export class PortfolioAggregate { } isEmpty(): boolean { - return this._assets.size === 0; + return this._assets.size === 0 && this._defiPositions.size === 0; } clear(): void { this._assets.clear(); + this._defiPositions.clear(); this._sources.clear(); this._lastUpdated = new Date(); } toJSON() { const totalValue = this.getTotalValue(); - + return { id: this._id, userId: this._userId, assets: this.assets.map(a => a.toJSON()), + defiPositions: this.defiPositions, + defiNetValue: this.getDeFiNetValue(), totalValue: { value: totalValue.amount, currency: totalValue.currency, diff --git a/src/index.ts b/src/index.ts index c8a8e73..19420bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,9 +59,10 @@ export { // ============================================================================ // Application Services -export { +export { PortfolioAggregationService, - type AggregationOptions + type AggregationOptions, + type DeFiAggregationResult } from './application/services/PortfolioAggregationService'; export { @@ -174,11 +175,21 @@ export { IntegrationSource, AssetType, Environment, + DeFiPositionType, + DeFiProtocol, + DeFiDiscoveryPath, type Asset, type Balance, type Chain, type Transaction, - type Price as SharedPrice + type Price as SharedPrice, + type DeFiPosition, + type VaultPosition, + type LendingPosition, + type LiquidityPosition, + type StakingPosition, + type UnderlyingAsset, + type DeFiReward } from './shared/types'; // ============================================================================ diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index a7db069..4bcbe91 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -95,4 +95,102 @@ export interface WalletConnection { connected: boolean; label?: string; type?: string; +} + +// ============================================================================ +// DeFi Types +// ============================================================================ + +export const DeFiPositionType = { + VAULT: 'vault', + LENDING_SUPPLY: 'lending_supply', + LENDING_BORROW: 'lending_borrow', + LIQUIDITY_POOL: 'liquidity_pool', + STAKING: 'staking', + FARMING: 'farming', + PERP_POSITION: 'perp_position' +} as const; + +export type DeFiPositionType = typeof DeFiPositionType[keyof typeof DeFiPositionType]; + +export const DeFiProtocol = { + BEEFY: 'beefy', + AAVE: 'aave', + UNISWAP: 'uniswap', + COMPOUND: 'compound', + LIDO: 'lido', + MARINADE: 'marinade', + RAYDIUM: 'raydium', + JUPITER: 'jupiter', + ORCA: 'orca', + CETUS: 'cetus', + TURBOS: 'turbos', + SCALLOP: 'scallop' +} as const; + +export type DeFiProtocol = typeof DeFiProtocol[keyof typeof DeFiProtocol]; + +export const DeFiDiscoveryPath = { + PASSIVE: 'passive', + ACTIVE: 'active' +} as const; + +export type DeFiDiscoveryPath = typeof DeFiDiscoveryPath[keyof typeof DeFiDiscoveryPath]; + +export interface UnderlyingAsset { + symbol: string; + amount: number; + contractAddress?: string; + chain?: Chain; +} + +export interface DeFiReward { + symbol: string; + amount: number; + value?: Price; +} + +export interface DeFiPosition { + id: string; + type: DeFiPositionType; + protocol: DeFiProtocol; + chain: Chain; + underlyingAssets: UnderlyingAsset[]; + value?: Price; + apy?: number; + rewards?: DeFiReward[]; + deduplicationKey: string; + discoveryPath?: DeFiDiscoveryPath; + receiptTokenAddress?: string; + metadata?: Record; +} + +export interface VaultPosition extends DeFiPosition { + type: typeof DeFiPositionType.VAULT; + vaultAddress: string; + shareBalance: number; + pricePerShare: number; +} + +export interface LendingPosition extends DeFiPosition { + type: typeof DeFiPositionType.LENDING_SUPPLY | typeof DeFiPositionType.LENDING_BORROW; + supplyRate?: number; + borrowRate?: number; + collateralFactor?: number; + healthFactor?: number; +} + +export interface LiquidityPosition extends DeFiPosition { + type: typeof DeFiPositionType.LIQUIDITY_POOL; + tokenPair: [string, string]; + priceRange?: { lower: number; upper: number }; + feeTier?: number; + impermanentLoss?: number; +} + +export interface StakingPosition extends DeFiPosition { + type: typeof DeFiPositionType.STAKING; + lockPeriod?: number; + validator?: string; + rewardsAccrued?: DeFiReward[]; } \ No newline at end of file diff --git a/src/tests/unit/application/services/DeFiAggregation.test.ts b/src/tests/unit/application/services/DeFiAggregation.test.ts new file mode 100644 index 0000000..e3d5dc7 --- /dev/null +++ b/src/tests/unit/application/services/DeFiAggregation.test.ts @@ -0,0 +1,372 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PortfolioAggregationService, AggregationOptions } from '../../../../application/services/PortfolioAggregationService'; +import { IIntegrationRepository } from '../../../../contracts/repositories/IIntegrationRepository'; +import { InMemoryPortfolioRepository } from '../../../mocks/InMemoryPortfolioRepository'; +import { MockAssetValuator } from '../../../mocks/MockAssetValuator'; +import { + IntegrationSource, + DeFiPositionType, + DeFiProtocol, + AssetType, + Chain +} from '../../../../shared/types'; +import type { DeFiPosition, Asset } from '../../../../shared/types'; + +// -- Helpers -- + +function createMockIntegration( + source: IntegrationSource, + options: { + assets?: Asset[]; + defiPositions?: DeFiPosition[]; + shouldFailDeFi?: boolean; + shouldFailAssets?: boolean; + } = {} +): 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: 'p', assets: [], totalValue: { value: 0, currency: 'USD', timestamp: new Date() }, + lastUpdated: new Date(), sources: [source] + })), + fetchAssets: vi.fn(async () => { + if (options.shouldFailAssets) throw new Error(`${source} assets failed`); + return options.assets || []; + }), + fetchTransactions: vi.fn(async () => []), + getDeFiPositions: vi.fn(async () => { + if (options.shouldFailDeFi) throw new Error(`${source} DeFi failed`); + return options.defiPositions || []; + }) + }; +} + +function createTestDeFiPosition(overrides: Partial = {}): DeFiPosition { + return { + id: 'defi-1', + type: DeFiPositionType.LENDING_SUPPLY, + protocol: DeFiProtocol.AAVE, + chain: 'ethereum' as Chain, + underlyingAssets: [{ symbol: 'USDC', amount: 10000 }], + value: { value: 10000, currency: 'USD', timestamp: new Date() }, + deduplicationKey: 'aave:ethereum:0xabc:lending_supply', + ...overrides + }; +} + +function createTestAsset(overrides: Partial = {}): Asset { + return { + id: 'asset-eth-1', + symbol: 'ETH', + name: 'Ethereum', + type: AssetType.TOKEN, + chain: 'ethereum', + balance: { amount: 1, decimals: 18, formatted: '1.0' }, + price: { value: 2500, currency: 'USD', timestamp: new Date() }, + metadata: { address: '0x123', source: IntegrationSource.EVM }, + ...overrides + }; +} + +describe('DeFi Aggregation in PortfolioAggregationService', () => { + let service: PortfolioAggregationService; + let portfolioRepo: InMemoryPortfolioRepository; + let assetValuator: MockAssetValuator; + let integrations: Map; + + const defaultOptions: AggregationOptions = { + addresses: new Map([['ethereum', ['0x123']]]), + forceRefresh: true + }; + + describe('scatter-gather getDeFiPositions', () => { + it('calls getDeFiPositions on all integrations in parallel', async () => { + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + defiPositions: [createTestDeFiPosition()] + }); + const solIntegration = createMockIntegration(IntegrationSource.SOLANA, { + defiPositions: [createTestDeFiPosition({ + id: 'defi-marinade-1', + protocol: DeFiProtocol.MARINADE, + chain: 'solana', + deduplicationKey: 'marinade:solana:0xabc:staking' + })] + }); + + integrations = new Map([ + [IntegrationSource.EVM, evmIntegration], + [IntegrationSource.SOLANA, solIntegration] + ]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio({ + ...defaultOptions, + addresses: new Map([ + ['ethereum', ['0x123']], + ['solana', ['sol123']] + ]) + }); + + expect(evmIntegration.getDeFiPositions).toHaveBeenCalled(); + expect(solIntegration.getDeFiPositions).toHaveBeenCalled(); + expect(portfolio.defiPositions).toHaveLength(2); + }); + + it('handles integrations without getDeFiPositions gracefully', async () => { + const integration: IIntegrationRepository = { + source: IntegrationSource.ROBINHOOD, + connect: vi.fn(async () => {}), + disconnect: vi.fn(async () => {}), + isConnected: vi.fn(() => true), + fetchPortfolio: vi.fn(async () => ({ + id: 'p', assets: [], totalValue: { value: 0, currency: 'USD', timestamp: new Date() }, + lastUpdated: new Date(), sources: [IntegrationSource.ROBINHOOD] + })), + fetchAssets: vi.fn(async () => []), + fetchTransactions: vi.fn(async () => []) + // No getDeFiPositions + }; + + integrations = new Map([[IntegrationSource.ROBINHOOD, integration]]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio(defaultOptions); + expect(portfolio.defiPositions).toHaveLength(0); + }); + }); + + describe('deduplication by deduplicationKey', () => { + it('deduplicates positions with the same deduplicationKey across sources', async () => { + const sameKey = 'aave:ethereum:0xabc:lending_supply'; + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + defiPositions: [createTestDeFiPosition({ deduplicationKey: sameKey, id: 'evm-pos' })] + }); + const solIntegration = createMockIntegration(IntegrationSource.SOLANA, { + defiPositions: [createTestDeFiPosition({ deduplicationKey: sameKey, id: 'sol-pos' })] + }); + + integrations = new Map([ + [IntegrationSource.EVM, evmIntegration], + [IntegrationSource.SOLANA, solIntegration] + ]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio({ + ...defaultOptions, + addresses: new Map([ + ['ethereum', ['0x123']], + ['solana', ['sol123']] + ]) + }); + + expect(portfolio.defiPositions).toHaveLength(1); + }); + + it('keeps positions with different deduplicationKeys', async () => { + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + defiPositions: [ + createTestDeFiPosition({ deduplicationKey: 'aave:ethereum:0xabc:supply' }), + createTestDeFiPosition({ deduplicationKey: 'beefy:ethereum:0xvault:vault', id: 'v1' }) + ] + }); + + integrations = new Map([[IntegrationSource.EVM, evmIntegration]]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio(defaultOptions); + expect(portfolio.defiPositions).toHaveLength(2); + }); + }); + + describe('partial failure strategy', () => { + it('returns DeFi from healthy sources when one source fails', async () => { + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + defiPositions: [createTestDeFiPosition()] + }); + const solIntegration = createMockIntegration(IntegrationSource.SOLANA, { + shouldFailDeFi: true + }); + + integrations = new Map([ + [IntegrationSource.EVM, evmIntegration], + [IntegrationSource.SOLANA, solIntegration] + ]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio({ + ...defaultOptions, + addresses: new Map([ + ['ethereum', ['0x123']], + ['solana', ['sol123']] + ]) + }); + + // EVM DeFi positions still present despite Solana failure + expect(portfolio.defiPositions).toHaveLength(1); + expect(portfolio.defiPositions[0].protocol).toBe(DeFiProtocol.AAVE); + }); + + it('assets still work when DeFi fails for a source', async () => { + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + assets: [createTestAsset()], + shouldFailDeFi: true + }); + + integrations = new Map([[IntegrationSource.EVM, evmIntegration]]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio(defaultOptions); + + expect(portfolio.assets.length).toBeGreaterThan(0); + expect(portfolio.defiPositions).toHaveLength(0); + }); + }); + + describe('receipt token coordination', () => { + it('filters out receipt tokens from assets to prevent double-counting', async () => { + const receiptTokenAddress = '0xReceiptToken'; + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + assets: [ + createTestAsset(), + createTestAsset({ + id: 'receipt-token', + symbol: 'aUSDC', + contractAddress: receiptTokenAddress, + balance: { amount: 10000, decimals: 6, formatted: '10000.0' } + }) + ], + defiPositions: [ + createTestDeFiPosition({ + receiptTokenAddress: receiptTokenAddress + }) + ] + }); + + integrations = new Map([[IntegrationSource.EVM, evmIntegration]]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio(defaultOptions); + + // The receipt token (aUSDC) should be filtered out + const symbols = portfolio.assets.map(a => a.symbol); + expect(symbols).not.toContain('aUSDC'); + expect(symbols).toContain('ETH'); + + // DeFi position should remain + expect(portfolio.defiPositions).toHaveLength(1); + }); + }); + + describe('DeFi value aggregation', () => { + it('supply positions add to portfolio total', async () => { + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + assets: [createTestAsset()], // ETH at $2500 + defiPositions: [createTestDeFiPosition({ + value: { value: 10000, currency: 'USD', timestamp: new Date() } + })] + }); + + integrations = new Map([[IntegrationSource.EVM, evmIntegration]]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio(defaultOptions); + const total = portfolio.getTotalValue(); + + // ETH ($2500) + DeFi supply ($10000) = $12500 + expect(total.amount).toBe(12500); + }); + + it('borrow positions subtract from portfolio total', async () => { + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + assets: [createTestAsset()], // ETH at $2500 + defiPositions: [ + createTestDeFiPosition({ + value: { value: 10000, currency: 'USD', timestamp: new Date() } + }), + createTestDeFiPosition({ + id: 'borrow-1', + type: DeFiPositionType.LENDING_BORROW, + deduplicationKey: 'aave:ethereum:0xabc:lending_borrow', + value: { value: 3000, currency: 'USD', timestamp: new Date() } + }) + ] + }); + + integrations = new Map([[IntegrationSource.EVM, evmIntegration]]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio(defaultOptions); + const total = portfolio.getTotalValue(); + + // ETH ($2500) + DeFi supply ($10000) - DeFi borrow ($3000) = $9500 + expect(total.amount).toBe(9500); + }); + }); + + describe('DeFi price enrichment', () => { + it('prices unpriced DeFi positions from underlying assets', async () => { + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + defiPositions: [createTestDeFiPosition({ + value: undefined, // Not priced yet + underlyingAssets: [ + { symbol: 'ETH', amount: 2 }, + { symbol: 'USDC', amount: 5000 } + ] + })] + }); + + integrations = new Map([[IntegrationSource.EVM, evmIntegration]]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + // MockAssetValuator has ETH=$2500 and USDC=$1 by default + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio(defaultOptions); + + expect(portfolio.defiPositions).toHaveLength(1); + const pos = portfolio.defiPositions[0]; + // 2 ETH * $2500 + 5000 USDC * $1 = $10000 + expect(pos.value).toBeDefined(); + expect(pos.value!.value).toBe(10000); + }); + + it('does not overwrite existing price on DeFi positions', async () => { + const evmIntegration = createMockIntegration(IntegrationSource.EVM, { + defiPositions: [createTestDeFiPosition({ + value: { value: 99999, currency: 'USD', timestamp: new Date() }, + underlyingAssets: [{ symbol: 'ETH', amount: 2 }] + })] + }); + + integrations = new Map([[IntegrationSource.EVM, evmIntegration]]); + portfolioRepo = new InMemoryPortfolioRepository(); + assetValuator = new MockAssetValuator(); + service = new PortfolioAggregationService(integrations, portfolioRepo, assetValuator); + + const portfolio = await service.aggregatePortfolio(defaultOptions); + expect(portfolio.defiPositions[0].value!.value).toBe(99999); + }); + }); +}); diff --git a/src/tests/unit/domain/aggregates/Portfolio.defi.test.ts b/src/tests/unit/domain/aggregates/Portfolio.defi.test.ts new file mode 100644 index 0000000..9554202 --- /dev/null +++ b/src/tests/unit/domain/aggregates/Portfolio.defi.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { PortfolioAggregate } from '../../../../domain/aggregates/Portfolio'; +import { AssetEntity } from '../../../../domain/entities/Asset'; +import { + DeFiPositionType, + DeFiProtocol, + AssetType +} from '../../../../shared/types'; +import type { DeFiPosition } from '../../../../shared/types'; + +function createSupplyPosition(overrides: Partial = {}): DeFiPosition { + return { + id: 'defi-aave-supply-1', + type: DeFiPositionType.LENDING_SUPPLY, + protocol: DeFiProtocol.AAVE, + chain: 'ethereum', + underlyingAssets: [{ symbol: 'USDC', amount: 10000 }], + value: { value: 10000, currency: 'USD', timestamp: new Date() }, + deduplicationKey: 'aave:ethereum:0xabc:lending_supply', + ...overrides + }; +} + +function createBorrowPosition(overrides: Partial = {}): DeFiPosition { + return { + id: 'defi-aave-borrow-1', + type: DeFiPositionType.LENDING_BORROW, + protocol: DeFiProtocol.AAVE, + chain: 'ethereum', + underlyingAssets: [{ symbol: 'ETH', amount: 2 }], + value: { value: 5000, currency: 'USD', timestamp: new Date() }, + deduplicationKey: 'aave:ethereum:0xabc:lending_borrow', + ...overrides + }; +} + +function createVaultPosition(overrides: Partial = {}): DeFiPosition { + return { + id: 'defi-beefy-vault-1', + type: DeFiPositionType.VAULT, + protocol: DeFiProtocol.BEEFY, + chain: 'ethereum', + underlyingAssets: [ + { symbol: 'ETH', amount: 5 }, + { symbol: 'USDC', amount: 5000 } + ], + value: { value: 17500, currency: 'USD', timestamp: new Date() }, + deduplicationKey: 'beefy:ethereum:0xvault1', + receiptTokenAddress: '0xmooETH', + ...overrides + }; +} + +describe('PortfolioAggregate DeFi', () => { + let portfolio: PortfolioAggregate; + + beforeEach(() => { + portfolio = new PortfolioAggregate({ + id: 'test-portfolio', + userId: 'test-user' + }); + }); + + describe('addDeFiPosition', () => { + it('adds a DeFi position to the portfolio', () => { + const position = createSupplyPosition(); + portfolio.addDeFiPosition(position); + + expect(portfolio.defiPositions).toHaveLength(1); + expect(portfolio.defiPositions[0].id).toBe('defi-aave-supply-1'); + }); + + it('deduplicates positions by deduplicationKey', () => { + const pos1 = createSupplyPosition(); + const pos2 = createSupplyPosition({ + id: 'defi-aave-supply-2', + value: { value: 20000, currency: 'USD', timestamp: new Date() } + }); + + portfolio.addDeFiPosition(pos1); + portfolio.addDeFiPosition(pos2); + + expect(portfolio.defiPositions).toHaveLength(1); + // First-seen wins + expect(portfolio.defiPositions[0].value!.value).toBe(10000); + }); + + it('allows positions with different deduplicationKeys', () => { + const pos1 = createSupplyPosition(); + const pos2 = createSupplyPosition({ + id: 'defi-aave-supply-arb', + deduplicationKey: 'aave:arbitrum:0xdef:lending_supply', + chain: 'arbitrum' + }); + + portfolio.addDeFiPosition(pos1); + portfolio.addDeFiPosition(pos2); + + expect(portfolio.defiPositions).toHaveLength(2); + }); + }); + + describe('getDeFiNetValue', () => { + it('returns 0 when no DeFi positions', () => { + expect(portfolio.getDeFiNetValue()).toBe(0); + }); + + it('adds supply position values', () => { + portfolio.addDeFiPosition(createSupplyPosition()); + expect(portfolio.getDeFiNetValue()).toBe(10000); + }); + + it('subtracts borrow position values', () => { + portfolio.addDeFiPosition(createBorrowPosition()); + expect(portfolio.getDeFiNetValue()).toBe(-5000); + }); + + it('computes net value: supply - borrow', () => { + portfolio.addDeFiPosition(createSupplyPosition()); // +10000 + portfolio.addDeFiPosition(createBorrowPosition()); // -5000 + + expect(portfolio.getDeFiNetValue()).toBe(5000); + }); + + it('adds vault positions as positive value', () => { + portfolio.addDeFiPosition(createVaultPosition()); // +17500 + expect(portfolio.getDeFiNetValue()).toBe(17500); + }); + + it('handles mixed position types', () => { + portfolio.addDeFiPosition(createSupplyPosition()); // +10000 + portfolio.addDeFiPosition(createBorrowPosition()); // -5000 + portfolio.addDeFiPosition(createVaultPosition()); // +17500 + + expect(portfolio.getDeFiNetValue()).toBe(22500); + }); + }); + + describe('getTotalValue with DeFi', () => { + it('includes DeFi net value in portfolio total', () => { + // Add a regular asset: 1 ETH at $2500 + portfolio.addAsset(new AssetEntity({ + id: 'eth-1', + symbol: 'ETH', + type: AssetType.TOKEN, + chain: 'ethereum', + balance: { amount: 1, decimals: 18, formatted: '1.0' }, + price: { value: 2500, currency: 'USD', timestamp: new Date() } + })); + + // Add DeFi supply: $10000 + portfolio.addDeFiPosition(createSupplyPosition()); + + const total = portfolio.getTotalValue(); + expect(total.amount).toBe(12500); // 2500 + 10000 + }); + + it('subtracts borrow from total', () => { + portfolio.addAsset(new AssetEntity({ + id: 'eth-1', + symbol: 'ETH', + type: AssetType.TOKEN, + chain: 'ethereum', + balance: { amount: 1, decimals: 18, formatted: '1.0' }, + price: { value: 2500, currency: 'USD', timestamp: new Date() } + })); + + portfolio.addDeFiPosition(createSupplyPosition()); // +10000 + portfolio.addDeFiPosition(createBorrowPosition()); // -5000 + + const total = portfolio.getTotalValue(); + expect(total.amount).toBe(7500); // 2500 + 10000 - 5000 + }); + + it('clamps total to zero if borrow exceeds supply + assets', () => { + portfolio.addDeFiPosition(createBorrowPosition({ + value: { value: 999999, currency: 'USD', timestamp: new Date() } + })); + + const total = portfolio.getTotalValue(); + expect(total.amount).toBe(0); + }); + }); + + describe('filterReceiptTokens', () => { + it('removes assets matching receipt token addresses', () => { + const receiptAsset = new AssetEntity({ + id: 'receipt-1', + symbol: 'mooETH', + type: AssetType.TOKEN, + chain: 'ethereum', + balance: { amount: 5, decimals: 18, formatted: '5.0' }, + contractAddress: '0xmooETH' + }); + const normalAsset = new AssetEntity({ + id: 'eth-1', + symbol: 'ETH', + type: AssetType.TOKEN, + chain: 'ethereum', + balance: { amount: 1, decimals: 18, formatted: '1.0' } + }); + + portfolio.addAsset(receiptAsset); + portfolio.addAsset(normalAsset); + + expect(portfolio.assets).toHaveLength(2); + + portfolio.filterReceiptTokens(new Set(['0xmooeth'])); // lowercase + + expect(portfolio.assets).toHaveLength(1); + expect(portfolio.assets[0].symbol).toBe('ETH'); + }); + + it('does nothing when no receipt tokens match', () => { + portfolio.addAsset(new AssetEntity({ + id: 'eth-1', + symbol: 'ETH', + type: AssetType.TOKEN, + chain: 'ethereum', + balance: { amount: 1, decimals: 18, formatted: '1.0' } + })); + + portfolio.filterReceiptTokens(new Set(['0xunknown'])); + expect(portfolio.assets).toHaveLength(1); + }); + }); + + describe('removeDeFiPosition', () => { + it('removes a position by deduplicationKey', () => { + const pos = createSupplyPosition(); + portfolio.addDeFiPosition(pos); + expect(portfolio.defiPositions).toHaveLength(1); + + portfolio.removeDeFiPosition(pos.deduplicationKey); + expect(portfolio.defiPositions).toHaveLength(0); + }); + }); + + describe('isEmpty', () => { + it('returns true when no assets and no DeFi positions', () => { + expect(portfolio.isEmpty()).toBe(true); + }); + + it('returns false when DeFi positions exist but no assets', () => { + portfolio.addDeFiPosition(createSupplyPosition()); + expect(portfolio.isEmpty()).toBe(false); + }); + }); + + describe('clear', () => { + it('clears DeFi positions along with assets', () => { + portfolio.addDeFiPosition(createSupplyPosition()); + portfolio.addAsset(new AssetEntity({ + id: 'eth-1', + symbol: 'ETH', + type: AssetType.TOKEN, + chain: 'ethereum', + balance: { amount: 1, decimals: 18, formatted: '1.0' } + })); + + portfolio.clear(); + expect(portfolio.defiPositions).toHaveLength(0); + expect(portfolio.assets).toHaveLength(0); + expect(portfolio.isEmpty()).toBe(true); + }); + }); + + describe('mergePortfolio with DeFi', () => { + it('merges DeFi positions from another portfolio', () => { + const other = new PortfolioAggregate({ id: 'other' }); + other.addDeFiPosition(createSupplyPosition()); + other.addDeFiPosition(createVaultPosition()); + + portfolio.mergePortfolio(other); + expect(portfolio.defiPositions).toHaveLength(2); + }); + + it('deduplicates when merging', () => { + portfolio.addDeFiPosition(createSupplyPosition()); + + const other = new PortfolioAggregate({ id: 'other' }); + other.addDeFiPosition(createSupplyPosition()); // Same dedup key + + portfolio.mergePortfolio(other); + expect(portfolio.defiPositions).toHaveLength(1); + }); + }); + + describe('toJSON with DeFi', () => { + it('includes defiPositions and defiNetValue', () => { + portfolio.addDeFiPosition(createSupplyPosition()); + + const json = portfolio.toJSON(); + expect(json.defiPositions).toHaveLength(1); + expect(json.defiNetValue).toBe(10000); + }); + }); + + describe('constructor with DeFi positions', () => { + it('accepts initial DeFi positions', () => { + const positions = [createSupplyPosition(), createVaultPosition()]; + const p = new PortfolioAggregate({ + id: 'test', + defiPositions: positions + }); + + expect(p.defiPositions).toHaveLength(2); + }); + }); +});