diff --git a/etc/data-models.api.md b/etc/data-models.api.md index 2819882..6a35161 100644 --- a/etc/data-models.api.md +++ b/etc/data-models.api.md @@ -127,6 +127,51 @@ export enum Chain { SUI = "SUI" } +// @public +export enum DeFiDiscoverySource { + CONTRACT_QUERY = "CONTRACT_QUERY", + WALLET_TOKEN_SCAN = "WALLET_TOKEN_SCAN" +} + +// @public +export interface DeFiPosition { + apy?: number; + chain: Chain; + discoverySource?: DeFiDiscoverySource; + id: string; + metadata?: Metadata; + protocol: DeFiProtocol; + rewards: Balance[]; + type: DeFiPositionType; + underlyingAssets: Balance[]; + value?: Price; +} + +// @public +export enum DeFiPositionType { + FARMING = "FARMING", + LENDING_BORROW = "LENDING_BORROW", + LENDING_SUPPLY = "LENDING_SUPPLY", + LIQUIDITY_POOL = "LIQUIDITY_POOL", + PERP_POSITION = "PERP_POSITION", + STAKING = "STAKING", + VAULT = "VAULT" +} + +// @public +export enum DeFiProtocol { + AAVE = "AAVE", + BEEFY = "BEEFY", + COMPOUND = "COMPOUND", + JUPITER = "JUPITER", + LIDO = "LIDO", + MARINADE = "MARINADE", + ORCA = "ORCA", + OTHER = "OTHER", + RAYDIUM = "RAYDIUM", + UNISWAP = "UNISWAP" +} + // @public export interface EnvironmentConfig { chain: Chain; @@ -184,6 +229,7 @@ export interface LendingPosition { apy?: number; asset: Asset; chain: Chain; + discoverySource?: DeFiDiscoverySource; healthFactor?: number; id: string; liquidationThreshold?: number; @@ -202,6 +248,7 @@ export enum LendingPositionType { // @public export interface LiquidityPosition { chain: Chain; + discoverySource?: DeFiDiscoverySource; feesEarned?: number; id: string; impermanentLoss?: number; @@ -320,6 +367,7 @@ export interface StakedPosition { apr?: number; asset: Asset; chain: Chain; + discoverySource?: DeFiDiscoverySource; id: string; lockupPeriod?: number; metadata?: Metadata; @@ -409,6 +457,7 @@ export interface VaultPosition { chain: Chain; depositAsset: Asset; depositedAmount: string; + discoverySource?: DeFiDiscoverySource; id: string; metadata?: Metadata; pricePerShare?: number; diff --git a/package-lock.json b/package-lock.json index 6a5e248..1d8efac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cygnus-wealth/data-models", - "version": "1.1.2", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cygnus-wealth/data-models", - "version": "1.1.2", + "version": "1.2.0", "license": "ISC", "devDependencies": { "@eslint/js": "^9.32.0", diff --git a/package.json b/package.json index 7bf31cf..12a1540 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,26 @@ "import": "./dist/enums/LendingPositionType.js", "require": "./dist/cjs/enums/LendingPositionType.js" }, + "./enums/VaultStrategyType": { + "types": "./dist/enums/VaultStrategyType.d.ts", + "import": "./dist/enums/VaultStrategyType.js", + "require": "./dist/cjs/enums/VaultStrategyType.js" + }, + "./enums/DeFiPositionType": { + "types": "./dist/enums/DeFiPositionType.d.ts", + "import": "./dist/enums/DeFiPositionType.js", + "require": "./dist/cjs/enums/DeFiPositionType.js" + }, + "./enums/DeFiProtocol": { + "types": "./dist/enums/DeFiProtocol.d.ts", + "import": "./dist/enums/DeFiProtocol.js", + "require": "./dist/cjs/enums/DeFiProtocol.js" + }, + "./enums/DeFiDiscoverySource": { + "types": "./dist/enums/DeFiDiscoverySource.d.ts", + "import": "./dist/enums/DeFiDiscoverySource.js", + "require": "./dist/cjs/enums/DeFiDiscoverySource.js" + }, "./interfaces/*": { "types": "./dist/interfaces/*.d.ts", "import": "./dist/interfaces/*.js", diff --git a/src/enums/DeFiDiscoverySource.ts b/src/enums/DeFiDiscoverySource.ts new file mode 100644 index 0000000..8be7f42 --- /dev/null +++ b/src/enums/DeFiDiscoverySource.ts @@ -0,0 +1,34 @@ +/** + * How a DeFi position was discovered during portfolio scanning. + * + * Distinguishes between positions found by scanning wallet token balances + * (receipt tokens like aTokens, cTokens, LP tokens) versus positions found + * by querying protocol smart contract state directly. + * + * This distinction matters for reconciliation: wallet-scanned positions are + * inferred from token holdings, while contract-queried positions come from + * authoritative on-chain state. + * + * @example + * ```typescript + * import { DeFiDiscoverySource } from '@cygnus-wealth/data-models'; + * + * // Found aUSDC in wallet — infer Aave supply position + * const walletDiscovered = DeFiDiscoverySource.WALLET_TOKEN_SCAN; + * + * // Queried Aave LendingPool.getUserAccountData() directly + * const contractDiscovered = DeFiDiscoverySource.CONTRACT_QUERY; + * ``` + * + * @since 1.3.0 + * @stability extended + * + * @see {@link DeFiPosition} for usage in position interfaces + */ +export enum DeFiDiscoverySource { + /** Position inferred from receipt/derivative tokens found in wallet balance scan */ + WALLET_TOKEN_SCAN = 'WALLET_TOKEN_SCAN', + + /** Position discovered by querying protocol smart contract state directly */ + CONTRACT_QUERY = 'CONTRACT_QUERY' +} diff --git a/src/enums/DeFiPositionType.ts b/src/enums/DeFiPositionType.ts new file mode 100644 index 0000000..c04fe2f --- /dev/null +++ b/src/enums/DeFiPositionType.ts @@ -0,0 +1,41 @@ +/** + * Classification of DeFi position types across protocols. + * + * Provides a unified taxonomy for all DeFi position categories, enabling + * consistent categorization regardless of the specific protocol. Used as + * a discriminator on the base {@link DeFiPosition} interface. + * + * @example + * ```typescript + * import { DeFiPositionType } from '@cygnus-wealth/data-models'; + * + * const positionType = DeFiPositionType.VAULT; + * ``` + * + * @since 1.3.0 + * @stability extended + * + * @see {@link DeFiPosition} for usage in the base position interface + */ +export enum DeFiPositionType { + /** Yield vault deposit (Yearn, Beefy, Harvest, Sommelier) */ + VAULT = 'VAULT', + + /** Lending protocol supply position (Aave, Compound — depositing to earn interest) */ + LENDING_SUPPLY = 'LENDING_SUPPLY', + + /** Lending protocol borrow position (Aave, Compound — borrowing against collateral) */ + LENDING_BORROW = 'LENDING_BORROW', + + /** AMM liquidity pool position (Uniswap, Curve, Balancer) */ + LIQUIDITY_POOL = 'LIQUIDITY_POOL', + + /** Proof-of-stake or liquid staking position (Lido, Rocket Pool, native staking) */ + STAKING = 'STAKING', + + /** Yield farming / incentive mining position (LP rewards, token emissions) */ + FARMING = 'FARMING', + + /** Perpetual futures or leveraged position (Jupiter, GMX, dYdX) */ + PERP_POSITION = 'PERP_POSITION' +} diff --git a/src/enums/DeFiProtocol.ts b/src/enums/DeFiProtocol.ts new file mode 100644 index 0000000..eb21da5 --- /dev/null +++ b/src/enums/DeFiProtocol.ts @@ -0,0 +1,51 @@ +/** + * Well-known DeFi protocol identifiers for position attribution. + * + * Provides standardized identifiers for major DeFi protocols across chains. + * Extensible via OTHER for protocols not yet explicitly enumerated. + * Protocol-specific version info (e.g., "Aave V3") should be stored in + * position metadata rather than the enum. + * + * @example + * ```typescript + * import { DeFiProtocol } from '@cygnus-wealth/data-models'; + * + * const protocol = DeFiProtocol.AAVE; + * ``` + * + * @since 1.3.0 + * @stability extended + * + * @see {@link DeFiPosition} for usage in position interfaces + */ +export enum DeFiProtocol { + /** Beefy Finance — multi-chain yield optimizer / auto-compounder */ + BEEFY = 'BEEFY', + + /** Aave — decentralized lending and borrowing protocol */ + AAVE = 'AAVE', + + /** Uniswap — Ethereum-native AMM / DEX */ + UNISWAP = 'UNISWAP', + + /** Compound — algorithmic money market protocol */ + COMPOUND = 'COMPOUND', + + /** Lido — liquid staking for Ethereum and other PoS chains */ + LIDO = 'LIDO', + + /** Marinade Finance — liquid staking for Solana */ + MARINADE = 'MARINADE', + + /** Raydium — Solana AMM and liquidity provider */ + RAYDIUM = 'RAYDIUM', + + /** Jupiter — Solana DEX aggregator and perps platform */ + JUPITER = 'JUPITER', + + /** Orca — Solana concentrated liquidity DEX */ + ORCA = 'ORCA', + + /** Protocol not covered by other values; store name in metadata */ + OTHER = 'OTHER' +} diff --git a/src/index.ts b/src/index.ts index 26f79ee..b152fc9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,9 @@ export { TransactionType } from './enums/TransactionType'; export { AccountType } from './enums/AccountType'; export { LendingPositionType } from './enums/LendingPositionType'; export { VaultStrategyType } from './enums/VaultStrategyType'; +export { DeFiPositionType } from './enums/DeFiPositionType'; +export { DeFiProtocol } from './enums/DeFiProtocol'; +export { DeFiDiscoverySource } from './enums/DeFiDiscoverySource'; // Base Interfaces export { BaseEntity } from './interfaces/BaseEntity'; @@ -25,6 +28,7 @@ export { LiquidityPosition } from './interfaces/LiquidityPosition'; export { StakedPosition } from './interfaces/StakedPosition'; export { LendingPosition } from './interfaces/LendingPosition'; export { VaultPosition } from './interfaces/VaultPosition'; +export { DeFiPosition } from './interfaces/DeFiPosition'; // Account and Portfolio Models export { Account } from './interfaces/Account'; diff --git a/src/interfaces/DeFiPosition.ts b/src/interfaces/DeFiPosition.ts new file mode 100644 index 0000000..44beb89 --- /dev/null +++ b/src/interfaces/DeFiPosition.ts @@ -0,0 +1,86 @@ +import { Chain } from '../enums/Chain'; +import { DeFiPositionType } from '../enums/DeFiPositionType'; +import { DeFiProtocol } from '../enums/DeFiProtocol'; +import { DeFiDiscoverySource } from '../enums/DeFiDiscoverySource'; +import { Balance } from './Balance'; +import { Price } from './Price'; +import { Metadata } from './Metadata'; + +/** + * Base interface for all DeFi positions across protocols and chains. + * + * Provides a unified shape for any DeFi position — vaults, lending, liquidity + * pools, staking, farming, and perp positions. Concrete subtypes + * ({@link VaultPosition}, {@link LendingPosition}, {@link LiquidityPosition}, + * {@link StakedPosition}) add protocol-specific fields. + * + * **Discovery Paths:** + * - `WALLET_TOKEN_SCAN` — position inferred from receipt tokens in wallet + * - `CONTRACT_QUERY` — position read from protocol contract state + * + * @example + * ```typescript + * import { + * DeFiPosition, + * DeFiPositionType, + * DeFiProtocol, + * DeFiDiscoverySource, + * Chain + * } from '@cygnus-wealth/data-models'; + * + * const position: DeFiPosition = { + * id: 'aave-supply-usdc-1', + * type: DeFiPositionType.LENDING_SUPPLY, + * protocol: DeFiProtocol.AAVE, + * chain: Chain.ETHEREUM, + * underlyingAssets: [{ + * assetId: 'ethereum-usdc', + * asset: { id: 'ethereum-usdc', symbol: 'USDC', name: 'USD Coin', type: 'CRYPTOCURRENCY', decimals: 6 }, + * amount: '50000' + * }], + * value: { value: 50125.50, currency: 'USD', timestamp: new Date() }, + * apy: 3.5, + * rewards: [], + * discoverySource: DeFiDiscoverySource.CONTRACT_QUERY + * }; + * ``` + * + * @since 1.3.0 + * @stability extended + * + * @see {@link VaultPosition} for vault-specific fields + * @see {@link LendingPosition} for lending-specific fields + * @see {@link LiquidityPosition} for LP-specific fields + * @see {@link StakedPosition} for staking-specific fields + */ +export interface DeFiPosition { + /** Unique identifier for this position */ + id: string; + + /** Classification of the DeFi position */ + type: DeFiPositionType; + + /** Protocol where the position exists */ + protocol: DeFiProtocol; + + /** Blockchain network where the position exists */ + chain: Chain; + + /** Underlying assets in this position (tokens deposited, staked, or provided) */ + underlyingAssets: Balance[]; + + /** Current total value of the position */ + value?: Price; + + /** Annual Percentage Yield or Rate (e.g., 8.5 = 8.5%) */ + apy?: number; + + /** Earned rewards (claimable or accrued incentive tokens) */ + rewards: Balance[]; + + /** How this position was discovered during portfolio scanning */ + discoverySource?: DeFiDiscoverySource; + + /** Protocol-specific metadata (version, contract addresses, TVL, etc.) */ + metadata?: Metadata; +} diff --git a/src/interfaces/LendingPosition.ts b/src/interfaces/LendingPosition.ts index 437111c..1f3a948 100644 --- a/src/interfaces/LendingPosition.ts +++ b/src/interfaces/LendingPosition.ts @@ -1,5 +1,6 @@ import { Chain } from '../enums/Chain'; import { LendingPositionType } from '../enums/LendingPositionType'; +import { DeFiDiscoverySource } from '../enums/DeFiDiscoverySource'; import { Asset } from './Asset'; import { Price } from './Price'; import { Metadata } from './Metadata'; @@ -110,6 +111,9 @@ export interface LendingPosition { /** Current total value of position (positive for supply, negative for borrow debt) */ value?: Price; + /** How this position was discovered during portfolio scanning */ + discoverySource?: DeFiDiscoverySource; + /** Protocol-specific metadata (collateral assets, liquidation price, etc.) */ metadata?: Metadata; } diff --git a/src/interfaces/LiquidityPosition.ts b/src/interfaces/LiquidityPosition.ts index 9d401f0..5ecb89b 100644 --- a/src/interfaces/LiquidityPosition.ts +++ b/src/interfaces/LiquidityPosition.ts @@ -1,4 +1,5 @@ import { Chain } from '../enums/Chain'; +import { DeFiDiscoverySource } from '../enums/DeFiDiscoverySource'; import { Balance } from './Balance'; import { Price } from './Price'; import { Metadata } from './Metadata'; @@ -89,6 +90,9 @@ export interface LiquidityPosition { /** Impermanent loss compared to holding tokens (negative = loss, positive = gain) */ impermanentLoss?: number; + /** How this position was discovered during portfolio scanning */ + discoverySource?: DeFiDiscoverySource; + /** Protocol-specific metadata (pool version, fee tier, range bounds, etc.) */ metadata?: Metadata; } diff --git a/src/interfaces/StakedPosition.ts b/src/interfaces/StakedPosition.ts index 202f7b0..0f4e073 100644 --- a/src/interfaces/StakedPosition.ts +++ b/src/interfaces/StakedPosition.ts @@ -1,4 +1,5 @@ import { Chain } from '../enums/Chain'; +import { DeFiDiscoverySource } from '../enums/DeFiDiscoverySource'; import { Asset } from './Asset'; import { Balance } from './Balance'; import { Price } from './Price'; @@ -103,6 +104,9 @@ export interface StakedPosition { /** Current total value of staked position plus rewards */ value?: Price; + /** How this position was discovered during portfolio scanning */ + discoverySource?: DeFiDiscoverySource; + /** Protocol-specific metadata (validator performance, slashing history, etc.) */ metadata?: Metadata; } diff --git a/src/interfaces/VaultPosition.ts b/src/interfaces/VaultPosition.ts index 8595618..a90e521 100644 --- a/src/interfaces/VaultPosition.ts +++ b/src/interfaces/VaultPosition.ts @@ -1,5 +1,6 @@ import { Chain } from '../enums/Chain'; import { VaultStrategyType } from '../enums/VaultStrategyType'; +import { DeFiDiscoverySource } from '../enums/DeFiDiscoverySource'; import { Asset } from './Asset'; import { Price } from './Price'; import { Metadata } from './Metadata'; @@ -118,6 +119,9 @@ export interface VaultPosition { /** Current total value of the vault position */ value?: Price; + /** How this position was discovered during portfolio scanning */ + discoverySource?: DeFiDiscoverySource; + /** Protocol-specific metadata (strategy details, harvest frequency, TVL, etc.) */ metadata?: Metadata; } diff --git a/tests/unit/defi-enums.test.ts b/tests/unit/defi-enums.test.ts new file mode 100644 index 0000000..9016af2 --- /dev/null +++ b/tests/unit/defi-enums.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from 'vitest'; +import { + DeFiPositionType, + DeFiProtocol, + DeFiDiscoverySource +} from '../../src/index'; + +/** + * Unit tests for DeFi enum types. + * Coverage target: 100% + */ + +describe('DeFi Enum Types', () => { + describe('DeFiPositionType', () => { + it('should have all expected position types', () => { + expect(DeFiPositionType.VAULT).toBe('VAULT'); + expect(DeFiPositionType.LENDING_SUPPLY).toBe('LENDING_SUPPLY'); + expect(DeFiPositionType.LENDING_BORROW).toBe('LENDING_BORROW'); + expect(DeFiPositionType.LIQUIDITY_POOL).toBe('LIQUIDITY_POOL'); + expect(DeFiPositionType.STAKING).toBe('STAKING'); + expect(DeFiPositionType.FARMING).toBe('FARMING'); + expect(DeFiPositionType.PERP_POSITION).toBe('PERP_POSITION'); + }); + + it('should have unique values (no duplicates)', () => { + const values = Object.values(DeFiPositionType); + const uniqueValues = new Set(values); + expect(values.length).toBe(uniqueValues.size); + }); + + it('should have exactly 7 types', () => { + const values = Object.values(DeFiPositionType); + expect(values).toHaveLength(7); + }); + + it('should cover all DeFi position categories', () => { + const yieldTypes = [ + DeFiPositionType.VAULT, + DeFiPositionType.FARMING + ]; + const lendingTypes = [ + DeFiPositionType.LENDING_SUPPLY, + DeFiPositionType.LENDING_BORROW + ]; + const lpTypes = [DeFiPositionType.LIQUIDITY_POOL]; + const stakingTypes = [DeFiPositionType.STAKING]; + const tradingTypes = [DeFiPositionType.PERP_POSITION]; + + const allTypes = [...yieldTypes, ...lendingTypes, ...lpTypes, ...stakingTypes, ...tradingTypes]; + const values = Object.values(DeFiPositionType); + allTypes.forEach(type => { + expect(values).toContain(type); + }); + }); + }); + + describe('DeFiProtocol', () => { + it('should have all expected protocols', () => { + expect(DeFiProtocol.BEEFY).toBe('BEEFY'); + expect(DeFiProtocol.AAVE).toBe('AAVE'); + expect(DeFiProtocol.UNISWAP).toBe('UNISWAP'); + expect(DeFiProtocol.COMPOUND).toBe('COMPOUND'); + expect(DeFiProtocol.LIDO).toBe('LIDO'); + expect(DeFiProtocol.MARINADE).toBe('MARINADE'); + expect(DeFiProtocol.RAYDIUM).toBe('RAYDIUM'); + expect(DeFiProtocol.JUPITER).toBe('JUPITER'); + expect(DeFiProtocol.ORCA).toBe('ORCA'); + expect(DeFiProtocol.OTHER).toBe('OTHER'); + }); + + it('should have unique values (no duplicates)', () => { + const values = Object.values(DeFiProtocol); + const uniqueValues = new Set(values); + expect(values.length).toBe(uniqueValues.size); + }); + + it('should have exactly 10 protocols', () => { + const values = Object.values(DeFiProtocol); + expect(values).toHaveLength(10); + }); + + it('should include EVM protocols', () => { + const evmProtocols = [ + DeFiProtocol.AAVE, + DeFiProtocol.UNISWAP, + DeFiProtocol.COMPOUND, + DeFiProtocol.LIDO, + DeFiProtocol.BEEFY + ]; + const values = Object.values(DeFiProtocol); + evmProtocols.forEach(p => { + expect(values).toContain(p); + }); + }); + + it('should include Solana protocols', () => { + const solanaProtocols = [ + DeFiProtocol.MARINADE, + DeFiProtocol.RAYDIUM, + DeFiProtocol.JUPITER, + DeFiProtocol.ORCA + ]; + const values = Object.values(DeFiProtocol); + solanaProtocols.forEach(p => { + expect(values).toContain(p); + }); + }); + + it('should be extensible via OTHER', () => { + expect(DeFiProtocol.OTHER).toBe('OTHER'); + }); + }); + + describe('DeFiDiscoverySource', () => { + it('should have both discovery sources', () => { + expect(DeFiDiscoverySource.WALLET_TOKEN_SCAN).toBe('WALLET_TOKEN_SCAN'); + expect(DeFiDiscoverySource.CONTRACT_QUERY).toBe('CONTRACT_QUERY'); + }); + + it('should have unique values (no duplicates)', () => { + const values = Object.values(DeFiDiscoverySource); + const uniqueValues = new Set(values); + expect(values.length).toBe(uniqueValues.size); + }); + + it('should have exactly 2 sources', () => { + const values = Object.values(DeFiDiscoverySource); + expect(values).toHaveLength(2); + }); + + it('should distinguish discovery paths', () => { + // Wallet scan: found aUSDC in wallet -> infer Aave position + const walletPath = DeFiDiscoverySource.WALLET_TOKEN_SCAN; + // Contract query: called Aave getUserAccountData() directly + const contractPath = DeFiDiscoverySource.CONTRACT_QUERY; + + expect(walletPath).not.toBe(contractPath); + }); + }); + + describe('Contract Tests (Breaking Change Detection)', () => { + it('should not remove DeFiPositionType values', () => { + const coreTypes = [ + 'VAULT', + 'LENDING_SUPPLY', + 'LENDING_BORROW', + 'LIQUIDITY_POOL', + 'STAKING', + 'FARMING', + 'PERP_POSITION' + ]; + const values = Object.values(DeFiPositionType); + coreTypes.forEach(type => { + expect(values).toContain(type); + }); + }); + + it('should not remove DeFiProtocol values', () => { + const coreProtocols = [ + 'BEEFY', + 'AAVE', + 'UNISWAP', + 'COMPOUND', + 'LIDO', + 'OTHER' + ]; + const values = Object.values(DeFiProtocol); + coreProtocols.forEach(p => { + expect(values).toContain(p); + }); + }); + + it('should not remove DeFiDiscoverySource values', () => { + const values = Object.values(DeFiDiscoverySource); + expect(values).toContain('WALLET_TOKEN_SCAN'); + expect(values).toContain('CONTRACT_QUERY'); + }); + }); +}); diff --git a/tests/unit/defi-position.test.ts b/tests/unit/defi-position.test.ts new file mode 100644 index 0000000..be003cd --- /dev/null +++ b/tests/unit/defi-position.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from 'vitest'; +import { + DeFiPosition, + DeFiPositionType, + DeFiProtocol, + DeFiDiscoverySource, + Chain, + VaultPosition, + LendingPosition, + LiquidityPosition, + StakedPosition +} from '../../src/index'; +import { ethAsset, usdcAsset } from '../fixtures/assets'; + +/** + * Unit tests for DeFiPosition base interface and discoverySource on subtypes. + * Coverage target: 95% + */ + +describe('DeFiPosition Interface', () => { + it('should create a base DeFiPosition with all required fields', () => { + const position: DeFiPosition = { + id: 'defi-pos-1', + type: DeFiPositionType.LENDING_SUPPLY, + protocol: DeFiProtocol.AAVE, + chain: Chain.ETHEREUM, + underlyingAssets: [ + { + assetId: 'ethereum-usdc', + asset: usdcAsset, + amount: '50000' + } + ], + rewards: [] + }; + + expect(position.id).toBe('defi-pos-1'); + expect(position.type).toBe(DeFiPositionType.LENDING_SUPPLY); + expect(position.protocol).toBe(DeFiProtocol.AAVE); + expect(position.chain).toBe(Chain.ETHEREUM); + expect(position.underlyingAssets).toHaveLength(1); + expect(position.rewards).toHaveLength(0); + }); + + it('should support all optional fields', () => { + const position: DeFiPosition = { + id: 'defi-pos-2', + type: DeFiPositionType.VAULT, + protocol: DeFiProtocol.BEEFY, + chain: Chain.ARBITRUM, + underlyingAssets: [ + { + assetId: 'ethereum-eth', + asset: ethAsset, + amount: '10.0' + } + ], + value: { + value: 20000, + currency: 'USD', + timestamp: new Date() + }, + apy: 12.3, + rewards: [ + { + assetId: 'ethereum-eth', + asset: ethAsset, + amount: '0.1' + } + ], + discoverySource: DeFiDiscoverySource.CONTRACT_QUERY, + metadata: { + 'beefy:vaultId': 'arb-eth-usdc' + } + }; + + expect(position.value?.value).toBe(20000); + expect(position.apy).toBe(12.3); + expect(position.rewards).toHaveLength(1); + expect(position.discoverySource).toBe(DeFiDiscoverySource.CONTRACT_QUERY); + expect(position.metadata?.['beefy:vaultId']).toBe('arb-eth-usdc'); + }); + + it('should support WALLET_TOKEN_SCAN discovery source', () => { + const position: DeFiPosition = { + id: 'wallet-discovered-1', + type: DeFiPositionType.LENDING_SUPPLY, + protocol: DeFiProtocol.AAVE, + chain: Chain.ETHEREUM, + underlyingAssets: [{ + assetId: 'ethereum-usdc', + asset: usdcAsset, + amount: '50000' + }], + rewards: [], + discoverySource: DeFiDiscoverySource.WALLET_TOKEN_SCAN + }; + + expect(position.discoverySource).toBe(DeFiDiscoverySource.WALLET_TOKEN_SCAN); + }); + + it('should support all DeFiPositionType values as type discriminator', () => { + const types = [ + DeFiPositionType.VAULT, + DeFiPositionType.LENDING_SUPPLY, + DeFiPositionType.LENDING_BORROW, + DeFiPositionType.LIQUIDITY_POOL, + DeFiPositionType.STAKING, + DeFiPositionType.FARMING, + DeFiPositionType.PERP_POSITION + ]; + + types.forEach(posType => { + const position: DeFiPosition = { + id: `test-${posType}`, + type: posType, + protocol: DeFiProtocol.OTHER, + chain: Chain.ETHEREUM, + underlyingAssets: [], + rewards: [] + }; + expect(position.type).toBe(posType); + }); + }); + + it('should support all DeFiProtocol values', () => { + const protocols = [ + DeFiProtocol.BEEFY, + DeFiProtocol.AAVE, + DeFiProtocol.UNISWAP, + DeFiProtocol.COMPOUND, + DeFiProtocol.LIDO, + DeFiProtocol.MARINADE, + DeFiProtocol.RAYDIUM, + DeFiProtocol.JUPITER, + DeFiProtocol.ORCA, + DeFiProtocol.OTHER + ]; + + protocols.forEach(proto => { + const position: DeFiPosition = { + id: `test-${proto}`, + type: DeFiPositionType.VAULT, + protocol: proto, + chain: Chain.ETHEREUM, + underlyingAssets: [], + rewards: [] + }; + expect(position.protocol).toBe(proto); + }); + }); + + it('should support multiple underlying assets', () => { + const lpPosition: DeFiPosition = { + id: 'lp-multi-asset-1', + type: DeFiPositionType.LIQUIDITY_POOL, + protocol: DeFiProtocol.UNISWAP, + chain: Chain.ETHEREUM, + underlyingAssets: [ + { assetId: 'ethereum-eth', asset: ethAsset, amount: '5.0' }, + { assetId: 'ethereum-usdc', asset: usdcAsset, amount: '10000' } + ], + rewards: [] + }; + + expect(lpPosition.underlyingAssets).toHaveLength(2); + expect(lpPosition.underlyingAssets[0].asset.symbol).toBe('ETH'); + expect(lpPosition.underlyingAssets[1].asset.symbol).toBe('USDC'); + }); + + it('should support Solana-based positions', () => { + const solPosition: DeFiPosition = { + id: 'sol-staking-1', + type: DeFiPositionType.STAKING, + protocol: DeFiProtocol.MARINADE, + chain: Chain.SOLANA, + underlyingAssets: [], + rewards: [], + discoverySource: DeFiDiscoverySource.CONTRACT_QUERY + }; + + expect(solPosition.chain).toBe(Chain.SOLANA); + expect(solPosition.protocol).toBe(DeFiProtocol.MARINADE); + }); +}); + +describe('Discovery Source on Position Subtypes', () => { + it('should support discoverySource on VaultPosition', () => { + const vault: VaultPosition = { + id: 'vault-ds-1', + protocol: 'Beefy', + vaultAddress: '0xabc', + vaultName: 'Test Vault', + chain: Chain.ARBITRUM, + strategyType: 'YIELD_AGGREGATOR' as const, + depositAsset: ethAsset, + depositedAmount: '1.0', + discoverySource: DeFiDiscoverySource.WALLET_TOKEN_SCAN + }; + + expect(vault.discoverySource).toBe(DeFiDiscoverySource.WALLET_TOKEN_SCAN); + }); + + it('should support discoverySource on LendingPosition', () => { + const lending: LendingPosition = { + id: 'lending-ds-1', + protocol: 'Aave V3', + chain: Chain.ETHEREUM, + type: 'SUPPLY' as const, + asset: usdcAsset, + amount: '50000', + discoverySource: DeFiDiscoverySource.CONTRACT_QUERY + }; + + expect(lending.discoverySource).toBe(DeFiDiscoverySource.CONTRACT_QUERY); + }); + + it('should support discoverySource on LiquidityPosition', () => { + const lp: LiquidityPosition = { + id: 'lp-ds-1', + protocol: 'Uniswap V3', + poolAddress: '0xabc', + poolName: 'ETH/USDC', + chain: Chain.ETHEREUM, + tokens: [], + discoverySource: DeFiDiscoverySource.WALLET_TOKEN_SCAN + }; + + expect(lp.discoverySource).toBe(DeFiDiscoverySource.WALLET_TOKEN_SCAN); + }); + + it('should support discoverySource on StakedPosition', () => { + const staked: StakedPosition = { + id: 'staked-ds-1', + protocol: 'Lido', + chain: Chain.ETHEREUM, + asset: ethAsset, + stakedAmount: '32.0', + rewards: [], + discoverySource: DeFiDiscoverySource.CONTRACT_QUERY + }; + + expect(staked.discoverySource).toBe(DeFiDiscoverySource.CONTRACT_QUERY); + }); + + it('should leave discoverySource undefined when not set', () => { + const vault: VaultPosition = { + id: 'vault-no-ds', + protocol: 'Test', + vaultAddress: '0x123', + vaultName: 'Test', + chain: Chain.ETHEREUM, + strategyType: 'OTHER' as const, + depositAsset: ethAsset, + depositedAmount: '1.0' + }; + + expect(vault.discoverySource).toBeUndefined(); + }); +});