diff --git a/package.json b/package.json index 94e92dd..91ffc13 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,11 @@ "import": "./dist/enums/Chain.js", "require": "./dist/cjs/enums/Chain.js" }, + "./enums/ChainFamily": { + "types": "./dist/enums/ChainFamily.d.ts", + "import": "./dist/enums/ChainFamily.js", + "require": "./dist/cjs/enums/ChainFamily.js" + }, "./enums/IntegrationSource": { "types": "./dist/enums/IntegrationSource.d.ts", "import": "./dist/enums/IntegrationSource.js", diff --git a/src/enums/ChainFamily.ts b/src/enums/ChainFamily.ts new file mode 100644 index 0000000..e2c4148 --- /dev/null +++ b/src/enums/ChainFamily.ts @@ -0,0 +1,55 @@ +/** + * Classification of blockchain protocol families. + * + * Groups blockchain networks by their underlying protocol family rather than + * individual chain identity. Used for routing (which integration handles which + * addresses), discovery (which standard detects which providers), and display + * (chain family badges in UI). + * + * `ChainFamily` is distinct from `Chain` — a chain family contains multiple + * chains (e.g., EVM family includes Ethereum, Polygon, Arbitrum, etc.). + * + * This is a closed enum. Adding new families requires Enterprise Architecture + * approval, a new integration bounded context, and correlation registry entries. + * + * @example + * ```typescript + * import { ChainFamily } from '@cygnus-wealth/data-models'; + * + * // Route address to correct integration + * function getIntegration(family: ChainFamily) { + * switch (family) { + * case ChainFamily.EVM: + * return evmIntegration; + * case ChainFamily.SOLANA: + * return solIntegration; + * default: + * throw new Error(`No integration for ${family}`); + * } + * } + * ``` + * + * @since 1.5.0 + * @stability extended + * + * @see {@link Chain} for specific chain identification within a family + */ +export enum ChainFamily { + /** EVM-compatible chains (Ethereum, Polygon, Arbitrum, Optimism, Avalanche, BSC, Base) */ + EVM = 'evm', + + /** Solana mainnet */ + SOLANA = 'solana', + + /** SUI mainnet (Move-based L1) */ + SUI = 'sui', + + /** Bitcoin mainnet (UTXO model) */ + BITCOIN = 'bitcoin', + + /** Cosmos ecosystem chains (Cosmos Hub, Osmosis, etc.) */ + COSMOS = 'cosmos', + + /** Aptos mainnet (Move-based L1) */ + APTOS = 'aptos', +} diff --git a/src/enums/IntegrationSource.ts b/src/enums/IntegrationSource.ts index 86a0890..7d16ce9 100644 --- a/src/enums/IntegrationSource.ts +++ b/src/enums/IntegrationSource.ts @@ -76,6 +76,18 @@ export enum IntegrationSource { /** Data fetched directly from blockchain via RPC */ BLOCKCHAIN_DIRECT = 'BLOCKCHAIN_DIRECT', + /** SUI blockchain integration */ + SUI = 'SUI', + + /** Bitcoin blockchain integration */ + BITCOIN = 'BITCOIN', + + /** Cosmos ecosystem integration */ + COSMOS = 'COSMOS', + + /** Aptos blockchain integration */ + APTOS = 'APTOS', + /** Other or unclassified data source */ OTHER = 'OTHER' } diff --git a/src/index.ts b/src/index.ts index 18a09a6..165d7e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ // Enums export { AssetType } from './enums/AssetType'; export { Chain } from './enums/Chain'; +export { ChainFamily } from './enums/ChainFamily'; export { IntegrationSource } from './enums/IntegrationSource'; export { TransactionType } from './enums/TransactionType'; export { AccountType } from './enums/AccountType'; @@ -38,6 +39,7 @@ export { PortfolioAsset } from './interfaces/PortfolioAsset'; export type { WalletProviderId } from './types/WalletProviderId'; export type { WalletConnectionId } from './types/WalletConnectionId'; export type { AccountId } from './types/AccountId'; +export type { Caip2ChainId } from './types/Caip2ChainId'; // Multi-Wallet Connection Models export type { WalletConnection } from './interfaces/WalletConnection'; diff --git a/src/interfaces/AddressRequest.ts b/src/interfaces/AddressRequest.ts index b443215..b5a0034 100644 --- a/src/interfaces/AddressRequest.ts +++ b/src/interfaces/AddressRequest.ts @@ -1,5 +1,6 @@ import { AccountId } from '../types/AccountId'; import { Chain } from '../enums/Chain'; +import { ChainFamily } from '../enums/ChainFamily'; /** * Request to query data for a specific account address. @@ -31,6 +32,9 @@ export interface AddressRequest { /** Checksummed address to query */ address: string; + /** Chain family for routing to the correct integration */ + chainFamily: ChainFamily; + /** Chains to query for this address */ chainScope: Chain[]; } diff --git a/src/interfaces/ConnectedAccount.ts b/src/interfaces/ConnectedAccount.ts index 3ca57e0..bcebd47 100644 --- a/src/interfaces/ConnectedAccount.ts +++ b/src/interfaces/ConnectedAccount.ts @@ -1,5 +1,6 @@ import { AccountId } from '../types/AccountId'; import { Chain } from '../enums/Chain'; +import { ChainFamily } from '../enums/ChainFamily'; /** * A single account within a wallet connection. @@ -40,6 +41,9 @@ export interface ConnectedAccount { /** User-assigned label (default: truncated address) */ accountLabel: string; + /** Chain family this account belongs to */ + chainFamily: ChainFamily; + /** Chains to track this account on (default: all supported by the connection) */ chainScope: Chain[]; diff --git a/src/interfaces/TrackedAddress.ts b/src/interfaces/TrackedAddress.ts index 0eb82e1..68e05d7 100644 --- a/src/interfaces/TrackedAddress.ts +++ b/src/interfaces/TrackedAddress.ts @@ -2,6 +2,7 @@ import { AccountId } from '../types/AccountId'; import { WalletConnectionId } from '../types/WalletConnectionId'; import { WalletProviderId } from '../types/WalletProviderId'; import { Chain } from '../enums/Chain'; +import { ChainFamily } from '../enums/ChainFamily'; /** * An address being tracked for portfolio purposes with full account context. @@ -49,6 +50,9 @@ export interface TrackedAddress { /** Label of the parent wallet connection */ connectionLabel: string; + /** Chain family of this tracked address, used for integration routing */ + chainFamily: ChainFamily; + /** Chains to query for this address */ chainScope: Chain[]; } diff --git a/src/interfaces/WalletConnection.ts b/src/interfaces/WalletConnection.ts index 93f2e3e..865835d 100644 --- a/src/interfaces/WalletConnection.ts +++ b/src/interfaces/WalletConnection.ts @@ -1,6 +1,7 @@ import { WalletConnectionId } from '../types/WalletConnectionId'; import { WalletProviderId } from '../types/WalletProviderId'; import { Chain } from '../enums/Chain'; +import { ChainFamily } from '../enums/ChainFamily'; import { ConnectedAccount } from './ConnectedAccount'; /** @@ -64,6 +65,9 @@ export interface WalletConnection { /** Chains this wallet connection supports */ supportedChains: Chain[]; + /** Chain families this wallet connection supports (e.g., EVM, Solana, SUI) */ + supportedChainFamilies: ChainFamily[]; + /** ISO 8601 timestamp of initial connection */ connectedAt: string; diff --git a/src/interfaces/WatchAddress.ts b/src/interfaces/WatchAddress.ts index c2c04b1..b24bb7a 100644 --- a/src/interfaces/WatchAddress.ts +++ b/src/interfaces/WatchAddress.ts @@ -1,5 +1,6 @@ import { AccountId } from '../types/AccountId'; import { Chain } from '../enums/Chain'; +import { ChainFamily } from '../enums/ChainFamily'; /** * An address tracked independently of any wallet connection. @@ -36,6 +37,9 @@ export interface WatchAddress { /** User-assigned label */ addressLabel: string; + /** Chain family of this watched address */ + chainFamily: ChainFamily; + /** Chains to track this address on */ chainScope: Chain[]; diff --git a/src/types/AccountId.ts b/src/types/AccountId.ts index cfd1457..d199211 100644 --- a/src/types/AccountId.ts +++ b/src/types/AccountId.ts @@ -1,10 +1,13 @@ /** * Unique identifier for a specific account across the system. * - * Format: `{walletConnectionId}:{checksummedAddress}` for connected accounts - * (e.g., `metamask:a1b2c3d4:0xAbC...123`), or `watch:{checksummedAddress}` + * Format: `{walletConnectionId}:{chainFamily}:{address}` for connected accounts + * (e.g., `metamask:a1b2c3d4:evm:0xAbC...123`), or `watch:{address}` * for watch addresses. * + * The `chainFamily` segment (introduced in en-o8w) disambiguates addresses + * from the same wallet connection that span different chain families. + * * This is the primary key used throughout the system to reference a specific * account. It disambiguates the same address appearing in different wallet * connections (e.g., imported into both MetaMask and Rabby). @@ -13,7 +16,8 @@ * ```typescript * import { AccountId } from '@cygnus-wealth/data-models'; * - * const connectedAccountId: AccountId = 'metamask:a1b2c3d4:0xAbCdEf1234567890'; + * const evmAccountId: AccountId = 'metamask:a1b2c3d4:evm:0xAbCdEf1234567890'; + * const solanaAccountId: AccountId = 'phantom:abc123:solana:7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU'; * const watchAccountId: AccountId = 'watch:0xAbCdEf1234567890'; * ``` * diff --git a/src/types/Caip2ChainId.ts b/src/types/Caip2ChainId.ts new file mode 100644 index 0000000..97c16d8 --- /dev/null +++ b/src/types/Caip2ChainId.ts @@ -0,0 +1,27 @@ +/** + * CAIP-2 chain identifier string type. + * + * Format: `{namespace}:{reference}` as defined by the Chain Agnostic + * Improvement Proposal 2. Used for WalletConnect v2 interoperability. + * + * Common namespace prefixes map to ChainFamily values: + * - `eip155` → ChainFamily.EVM (e.g., `eip155:1` for Ethereum mainnet) + * - `solana` → ChainFamily.SOLANA (e.g., `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) + * - `bip122` → ChainFamily.BITCOIN + * - `cosmos` → ChainFamily.COSMOS + * + * @example + * ```typescript + * import { Caip2ChainId } from '@cygnus-wealth/data-models'; + * + * const ethereumMainnet: Caip2ChainId = 'eip155:1'; + * const polygon: Caip2ChainId = 'eip155:137'; + * const solanaMainnet: Caip2ChainId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + * ``` + * + * @since 1.5.0 + * @stability extended + * + * @see {@link ChainFamily} for chain family classification + */ +export type Caip2ChainId = string; diff --git a/tests/unit/chain-family.test.ts b/tests/unit/chain-family.test.ts new file mode 100644 index 0000000..e49aba4 --- /dev/null +++ b/tests/unit/chain-family.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect } from 'vitest'; +import { + ChainFamily, + IntegrationSource, +} from '../../src/index'; +import type { + Caip2ChainId, + WalletConnection, + ConnectedAccount, + AccountId, + WatchAddress, + TrackedAddress, + AddressRequest, +} from '../../src/index'; +import { Chain } from '../../src/enums/Chain'; + +describe('ChainFamily enum (en-o8w)', () => { + it('should have all required chain family values', () => { + expect(ChainFamily.EVM).toBe('evm'); + expect(ChainFamily.SOLANA).toBe('solana'); + expect(ChainFamily.SUI).toBe('sui'); + expect(ChainFamily.BITCOIN).toBe('bitcoin'); + expect(ChainFamily.COSMOS).toBe('cosmos'); + expect(ChainFamily.APTOS).toBe('aptos'); + }); + + it('should have exactly 6 members', () => { + const values = Object.values(ChainFamily); + expect(values).toHaveLength(6); + }); + + it('should use lowercase string values', () => { + for (const value of Object.values(ChainFamily)) { + expect(value).toBe(value.toLowerCase()); + } + }); +}); + +describe('Caip2ChainId type (en-o8w)', () => { + it('should accept valid CAIP-2 format strings', () => { + const ethereumMainnet: Caip2ChainId = 'eip155:1'; + const solanaMainnet: Caip2ChainId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const bitcoinMainnet: Caip2ChainId = 'bip122:000000000019d6689c085ae165831e93'; + + expect(ethereumMainnet).toBe('eip155:1'); + expect(solanaMainnet).toContain('solana:'); + expect(bitcoinMainnet).toContain('bip122:'); + }); +}); + +describe('IntegrationSource extensions (en-o8w)', () => { + it('should have new chain-family integration sources', () => { + expect(IntegrationSource.SUI).toBe('SUI'); + expect(IntegrationSource.BITCOIN).toBe('BITCOIN'); + expect(IntegrationSource.COSMOS).toBe('COSMOS'); + expect(IntegrationSource.APTOS).toBe('APTOS'); + }); + + it('should retain all existing integration sources', () => { + expect(IntegrationSource.ROBINHOOD).toBe('ROBINHOOD'); + expect(IntegrationSource.KRAKEN).toBe('KRAKEN'); + expect(IntegrationSource.COINBASE).toBe('COINBASE'); + expect(IntegrationSource.METAMASK).toBe('METAMASK'); + expect(IntegrationSource.PHANTOM).toBe('PHANTOM'); + expect(IntegrationSource.SLUSH).toBe('SLUSH'); + expect(IntegrationSource.SUIET).toBe('SUIET'); + expect(IntegrationSource.BLOCKCHAIN_DIRECT).toBe('BLOCKCHAIN_DIRECT'); + expect(IntegrationSource.MANUAL_ENTRY).toBe('MANUAL_ENTRY'); + }); +}); + +describe('WalletConnection extensions (en-o8w)', () => { + it('should support supportedChainFamilies field', () => { + const connection: WalletConnection = { + connectionId: 'phantom:abc123', + providerId: 'phantom', + providerName: 'Phantom', + providerIcon: 'https://phantom.app/icon.svg', + connectionLabel: 'My Phantom', + accounts: [], + activeAccountAddress: null, + supportedChains: [Chain.ETHEREUM, Chain.SOLANA], + supportedChainFamilies: [ChainFamily.EVM, ChainFamily.SOLANA], + connectedAt: '2026-02-25T10:00:00Z', + lastActiveAt: '2026-02-25T10:00:00Z', + sessionStatus: 'active', + }; + + expect(connection.supportedChainFamilies).toEqual([ChainFamily.EVM, ChainFamily.SOLANA]); + expect(connection.supportedChainFamilies).toHaveLength(2); + }); + + it('should support single chain family wallet', () => { + const connection: WalletConnection = { + connectionId: 'metamask:xyz789', + providerId: 'metamask', + providerName: 'MetaMask', + providerIcon: 'https://metamask.io/icon.svg', + connectionLabel: 'My MetaMask', + accounts: [], + activeAccountAddress: '0xAbCdEf1234567890', + supportedChains: [Chain.ETHEREUM], + supportedChainFamilies: [ChainFamily.EVM], + connectedAt: '2026-02-25T10:00:00Z', + lastActiveAt: '2026-02-25T10:00:00Z', + sessionStatus: 'active', + }; + + expect(connection.supportedChainFamilies).toEqual([ChainFamily.EVM]); + }); + + it('should support multi-chain wallet with many families', () => { + const connection: WalletConnection = { + connectionId: 'trust:def456', + providerId: 'trust-wallet', + providerName: 'Trust Wallet', + providerIcon: 'https://trustwallet.com/icon.svg', + connectionLabel: 'Trust Wallet', + accounts: [], + activeAccountAddress: null, + supportedChains: [Chain.ETHEREUM, Chain.SOLANA, Chain.SUI], + supportedChainFamilies: [ChainFamily.EVM, ChainFamily.SOLANA, ChainFamily.SUI, ChainFamily.COSMOS, ChainFamily.APTOS], + connectedAt: '2026-02-25T10:00:00Z', + lastActiveAt: '2026-02-25T10:00:00Z', + sessionStatus: 'active', + }; + + expect(connection.supportedChainFamilies).toHaveLength(5); + }); +}); + +describe('ConnectedAccount extensions (en-o8w)', () => { + it('should have chainFamily field for EVM account', () => { + const account: ConnectedAccount = { + accountId: 'metamask:abc123:evm:0xAbCdEf1234567890', + address: '0xAbCdEf1234567890', + accountLabel: 'Main DeFi', + chainScope: [Chain.ETHEREUM, Chain.POLYGON], + chainFamily: ChainFamily.EVM, + source: 'provider', + discoveredAt: '2026-02-25T10:00:00Z', + isStale: false, + isActive: true, + }; + + expect(account.chainFamily).toBe(ChainFamily.EVM); + }); + + it('should have chainFamily field for Solana account', () => { + const account: ConnectedAccount = { + accountId: 'phantom:abc123:solana:7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + address: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + accountLabel: 'Solana Main', + chainScope: [Chain.SOLANA], + chainFamily: ChainFamily.SOLANA, + source: 'provider', + discoveredAt: '2026-02-25T10:00:00Z', + isStale: false, + isActive: true, + }; + + expect(account.chainFamily).toBe(ChainFamily.SOLANA); + }); + + it('should have chainFamily field for Bitcoin account', () => { + const account: ConnectedAccount = { + accountId: 'trust:def456:bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + accountLabel: 'BTC Savings', + chainScope: [Chain.BITCOIN], + chainFamily: ChainFamily.BITCOIN, + source: 'provider', + discoveredAt: '2026-02-25T10:00:00Z', + isStale: false, + isActive: true, + }; + + expect(account.chainFamily).toBe(ChainFamily.BITCOIN); + }); +}); + +describe('AccountId format (en-o8w)', () => { + it('should accept new format with chain family segment', () => { + const connectedId: AccountId = 'metamask:abc123:evm:0xAbCdEf1234567890'; + expect(connectedId).toContain(':evm:'); + }); + + it('should accept watch address format', () => { + const watchId: AccountId = 'watch:0xAbCdEf1234567890'; + expect(watchId).toContain('watch:'); + }); + + it('should accept Solana account format', () => { + const solanaId: AccountId = 'phantom:abc123:solana:7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU'; + expect(solanaId).toContain(':solana:'); + }); +}); + +describe('WatchAddress extensions (en-o8w)', () => { + it('should have chainFamily field', () => { + const watched: WatchAddress = { + accountId: 'watch:0xAbCdEf1234567890', + address: '0xAbCdEf1234567890', + addressLabel: 'Vitalik.eth', + chainScope: [Chain.ETHEREUM], + chainFamily: ChainFamily.EVM, + addedAt: '2026-02-01T12:00:00Z', + }; + + expect(watched.chainFamily).toBe(ChainFamily.EVM); + }); + + it('should support Solana watch address', () => { + const watched: WatchAddress = { + accountId: 'watch:7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + address: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + addressLabel: 'SOL Whale', + chainScope: [Chain.SOLANA], + chainFamily: ChainFamily.SOLANA, + addedAt: '2026-02-01T12:00:00Z', + }; + + expect(watched.chainFamily).toBe(ChainFamily.SOLANA); + }); + + it('should support Bitcoin watch address', () => { + const watched: WatchAddress = { + accountId: 'watch:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + addressLabel: 'BTC Watch', + chainScope: [Chain.BITCOIN], + chainFamily: ChainFamily.BITCOIN, + addedAt: '2026-02-01T12:00:00Z', + }; + + expect(watched.chainFamily).toBe(ChainFamily.BITCOIN); + }); +}); + +describe('TrackedAddress extensions (en-o8w)', () => { + it('should have chainFamily field', () => { + const tracked: TrackedAddress = { + accountId: 'metamask:abc123:evm:0xAbCdEf1234567890', + address: '0xAbCdEf1234567890', + walletConnectionId: 'metamask:abc123', + providerId: 'metamask', + accountLabel: 'Main DeFi', + connectionLabel: 'My MetaMask', + chainScope: [Chain.ETHEREUM, Chain.POLYGON], + chainFamily: ChainFamily.EVM, + }; + + expect(tracked.chainFamily).toBe(ChainFamily.EVM); + }); + + it('should have chainFamily for Solana tracked address', () => { + const tracked: TrackedAddress = { + accountId: 'phantom:abc123:solana:7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + address: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + walletConnectionId: 'phantom:abc123', + providerId: 'phantom', + accountLabel: 'Solana Main', + connectionLabel: 'My Phantom', + chainScope: [Chain.SOLANA], + chainFamily: ChainFamily.SOLANA, + }; + + expect(tracked.chainFamily).toBe(ChainFamily.SOLANA); + }); +}); + +describe('AddressRequest extensions (en-o8w)', () => { + it('should have chainFamily field', () => { + const request: AddressRequest = { + accountId: 'metamask:abc123:evm:0xAbCdEf1234567890', + address: '0xAbCdEf1234567890', + chainScope: [Chain.ETHEREUM, Chain.POLYGON], + chainFamily: ChainFamily.EVM, + }; + + expect(request.chainFamily).toBe(ChainFamily.EVM); + }); + + it('should have chainFamily for Solana request', () => { + const request: AddressRequest = { + accountId: 'phantom:abc123:solana:7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + address: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + chainScope: [Chain.SOLANA], + chainFamily: ChainFamily.SOLANA, + }; + + expect(request.chainFamily).toBe(ChainFamily.SOLANA); + }); + + it('should have chainFamily for Bitcoin request', () => { + const request: AddressRequest = { + accountId: 'trust:def456:bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + chainScope: [Chain.BITCOIN], + chainFamily: ChainFamily.BITCOIN, + }; + + expect(request.chainFamily).toBe(ChainFamily.BITCOIN); + }); +});