diff --git a/docs/portfolio-normalizer.md b/docs/portfolio-normalizer.md new file mode 100644 index 0000000..b302dd0 --- /dev/null +++ b/docs/portfolio-normalizer.md @@ -0,0 +1,77 @@ +# Investor portfolio normalizer + +The SDK already exposes `InvestorModule.getPortfolio()` for its native RPC-backed +portfolio read model. The normalizer is a separate, pure compatibility layer for +holdings that arrive from other asset reads, cached API responses, or dashboard +payloads with inconsistent field names. + +## Usage + +```ts +import { + normalizeInvestorPortfolio, + normalizePortfolioHolding, +} from '@aegis/sdk'; + +const portfolio = normalizeInvestorPortfolio([ + { + asset_id: 'ASSET-42', + raw_balance: '125000000', + asset_symbol: 'RWA', + asset_decimals: 7, + }, + { + contractId: 'C...', + amount: '50000000', + metadata: { symbol: 'BOND', decimals: 7 }, + }, +]); + +// portfolio.state === 'ready' +// portfolio.holdings[0].decimalBalance === '12.5' +``` + +## Supported source aliases + +The normalizer accepts decoded objects and recognizes these compatibility fields: + +| Meaning | Accepted fields | +| --- | --- | +| Asset identity | `assetId`, `asset_id`, `contractId`, `contract_id`, `id` | +| Raw base-unit balance | `balance`, `rawBalance`, `raw_balance`, `amount`, `quantity` | +| Precision | `decimals`, `assetDecimals`, `asset_decimals` | +| Metadata | top-level fields or nested `metadata` / `asset` objects | +| Unsupported marker | `supported: false`, `isSupported: false`, `unsupported: true`, or `status: "unsupported"` | + +Input order is preserved. The normalizer does not deduplicate, fetch, price, or reorder +holdings. + +## Assumptions and failure states + +- Balances are integer base units. Decimal strings such as `"1.25"` are marked + `unsupported` rather than guessed into a scale. +- When a supported row omits precision, the default is 7 decimals to match the + SDK's existing Soroban portfolio model. Consumers can pass `defaultDecimals` + when adapting another source. +- A generic `assetId` is not treated as a contract ID. `metadata.contractId` + is populated only when the source actually provides `contractId` or + `contract_id`. +- Missing asset identity produces an `unknown` row that remains in the result. + Unknown rows are not silently discarded. +- Explicitly unsupported assets or malformed balance/precision data produce an + `unsupported` row with a stable reason code. +- The aggregate state is `empty`, `ready`, `partial`, or `unusable` + depending on whether zero, all, some, or none of the source rows are supported. + +These statuses describe normalization quality only. They do not imply KYC status, +transfer eligibility, on-chain authorization, market value, or legal ownership. + +## Display helpers + +`formatPortfolioBalance(rawBalance, decimals)` converts integer base units to an +exact, locale-neutral decimal string without rounding. Apply locale/currency +formatting in the application layer. + +`getPortfolioHoldingDisplayLabel(holding)` returns the best stable identifier in +this order: symbol, name, asset ID, then `UNKNOWN`. It intentionally does not add +localized UI copy. diff --git a/src/index.ts b/src/index.ts index b498dcf..bbc1cbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,13 @@ export { export { resolveClientConfig } from './config/validate'; export { AEGIS_ENVIRONMENTS, getEnvironmentPreset } from './config/environments'; export * from './types/portfolio'; +export { + normalizePortfolioHolding, + normalizeInvestorPortfolio, + formatPortfolioBalance, + getPortfolioHoldingDisplayLabel, +} from './investor/normalizer'; +export * from './types/portfolio-normalizer'; export * from './errors/portfolio'; export * from './types/role'; export * from './errors/role'; diff --git a/src/investor/normalizer.ts b/src/investor/normalizer.ts new file mode 100644 index 0000000..d1a4065 --- /dev/null +++ b/src/investor/normalizer.ts @@ -0,0 +1,242 @@ +import { + NormalizedInvestorPortfolio, + NormalizedPortfolioAssetMetadata, + NormalizedPortfolioHolding, + PortfolioNormalizerOptions, +} from '../types/portfolio-normalizer'; + +type Row = Record; +const DEFAULT_DECIMALS = 7; +const MAX_DECIMALS = 30; + +const row = (value: unknown): Row | null => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Row) + : null; + +interface ParsedAlias { + present: boolean; + value: T | null; +} + +function firstParsed( + source: Row | null, + keys: readonly string[], + parse: (value: unknown) => T | null, +): ParsedAlias { + if (!source) return { present: false, value: null }; + + let present = false; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue; + const raw = source[key]; + if (raw === null || raw === undefined) continue; + + present = true; + const parsed = parse(raw); + if (parsed !== null) { + return { present: true, value: parsed }; + } + } + + return { present, value: null }; +} + +function firstParsedFrom( + sources: readonly (Row | null)[], + keys: readonly string[], + parse: (value: unknown) => T | null, +): ParsedAlias { + let present = false; + for (const source of sources) { + const parsed = firstParsed(source, keys, parse); + present = present || parsed.present; + if (parsed.value !== null) { + return { present: true, value: parsed.value }; + } + } + + return { present, value: null }; +} + +function text(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function stringFrom(sources: readonly (Row | null)[], keys: readonly string[]) { + return firstParsedFrom(sources, keys, text).value; +} + +function integer(value: unknown): string | null { + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'number') return Number.isSafeInteger(value) ? String(value) : null; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return /^-?\d+$/.test(trimmed) + ? trimmed.replace(/^(-?)0+(?=\d)/, '$1') + : null; +} + +function precision(value: unknown): number | null { + const parsed = + typeof value === 'number' + ? value + : typeof value === 'string' && /^\d+$/.test(value.trim()) + ? Number(value.trim()) + : NaN; + return Number.isSafeInteger(parsed) && parsed >= 0 && parsed <= MAX_DECIMALS + ? parsed + : null; +} + +function unsupported(source: Row | null): boolean { + if (!source) return false; + return ( + source.supported === false || + source.isSupported === false || + source.unsupported === true || + (typeof source.status === 'string' && + source.status.trim().toLowerCase() === 'unsupported') + ); +} + +function metadataFor( + sources: readonly (Row | null)[], + decimals: number | null, + assetId: string | null, +): NormalizedPortfolioAssetMetadata { + return { + symbol: stringFrom(sources, ['symbol', 'assetSymbol', 'asset_symbol']), + name: stringFrom(sources, ['name', 'assetName', 'asset_name']), + decimals, + category: stringFrom(sources, ['category', 'assetCategory', 'asset_category']), + contractId: stringFrom(sources, ['contractId', 'contract_id']), + }; +} + +export function formatPortfolioBalance(rawBalance: string, decimals: number): string { + const normalized = integer(rawBalance); + const scale = precision(decimals); + if (normalized === null) { + throw new TypeError('Portfolio balance must be an integer base-unit string.'); + } + if (scale === null) { + throw new RangeError(`Portfolio decimals must be between 0 and ${MAX_DECIMALS}.`); + } + + const negative = normalized.startsWith('-'); + const absolute = negative ? normalized.slice(1) : normalized; + const digits = absolute.replace(/^0+(?=\d)/, '') || '0'; + if (digits === '0') return '0'; + if (scale === 0) return `${negative ? '-' : ''}${digits}`; + + const padded = digits.padStart(scale + 1, '0'); + const whole = padded.slice(0, -scale); + const fraction = padded.slice(-scale).replace(/0+$/, ''); + return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`; +} + +export function getPortfolioHoldingDisplayLabel( + holding: NormalizedPortfolioHolding, +): string { + return holding.metadata.symbol ?? holding.metadata.name ?? holding.assetId ?? 'UNKNOWN'; +} + +export function normalizePortfolioHolding( + input: unknown, + options: PortfolioNormalizerOptions = {}, +): NormalizedPortfolioHolding { + const source = row(input); + const nested = [ + source ? row(source.metadata) : null, + source ? row(source.asset) : null, + ] as const; + const sources = [source, ...nested] as const; + const assetId = stringFrom(sources, [ + 'assetId', + 'asset_id', + 'contractId', + 'contract_id', + 'id', + ]); + + const decimalKeys = ['decimals', 'assetDecimals', 'asset_decimals'] as const; + const parsedDecimals = firstParsedFrom(sources, decimalKeys, precision); + const precisionAliasExists = sources.some( + (candidate) => + candidate !== null && + decimalKeys.some((key) => Object.prototype.hasOwnProperty.call(candidate, key)), + ); + const defaultDecimals = precision(options.defaultDecimals ?? DEFAULT_DECIMALS); + if (defaultDecimals === null) { + throw new RangeError(`defaultDecimals must be between 0 and ${MAX_DECIMALS}.`); + } + const decimals = + parsedDecimals.value ?? (precisionAliasExists ? null : defaultDecimals); + + const balance = firstParsed( + source, + ['balance', 'rawBalance', 'raw_balance', 'amount', 'quantity'], + integer, + ).value; + const metadata = metadataFor(sources, decimals, assetId); + + let status: NormalizedPortfolioHolding['status'] = 'supported'; + let code: NormalizedPortfolioHolding['code']; + + if (sources.some((candidate) => unsupported(candidate))) { + status = 'unsupported'; + code = 'UNSUPPORTED_ASSET'; + } else if (assetId === null) { + status = 'unknown'; + code = 'MISSING_ASSET_ID'; + } else if (balance === null) { + status = 'unsupported'; + code = 'INVALID_BALANCE'; + } else if (decimals === null) { + status = 'unsupported'; + code = 'INVALID_DECIMALS'; + } + + return { + status, + ...(code ? { code } : {}), + assetId, + balance, + decimalBalance: + balance !== null && decimals !== null + ? formatPortfolioBalance(balance, decimals) + : null, + metadata, + }; +} + +export function normalizeInvestorPortfolio( + inputs: readonly unknown[], + options: PortfolioNormalizerOptions = {}, +): NormalizedInvestorPortfolio { + const holdings = inputs.map((input) => normalizePortfolioHolding(input, options)); + const supportedHoldingsCount = holdings.filter((h) => h.status === 'supported').length; + const unknownHoldingsCount = holdings.filter((h) => h.status === 'unknown').length; + const unsupportedHoldingsCount = holdings.filter((h) => h.status === 'unsupported').length; + + const state = + holdings.length === 0 + ? 'empty' + : supportedHoldingsCount === holdings.length + ? 'ready' + : supportedHoldingsCount === 0 + ? 'unusable' + : 'partial'; + + return { + state, + holdings, + totalHoldingsCount: holdings.length, + supportedHoldingsCount, + unknownHoldingsCount, + unsupportedHoldingsCount, + }; +} diff --git a/src/types/portfolio-normalizer.ts b/src/types/portfolio-normalizer.ts new file mode 100644 index 0000000..9f9ae9b --- /dev/null +++ b/src/types/portfolio-normalizer.ts @@ -0,0 +1,115 @@ +/** + * Row-level outcome produced by the investor portfolio normalizer. + * + * - `supported`: the row has a stable asset identity, integer base-unit balance, + * and usable decimal precision. + * - `unknown`: no stable asset identity was present; the row is preserved rather + * than silently discarded. + * - `unsupported`: the asset is explicitly marked unsupported or its balance / + * decimal shape cannot be represented safely by the SDK normalizer. + */ +export type PortfolioHoldingNormalizationStatus = + | 'supported' + | 'unknown' + | 'unsupported'; + +/** Stable reason codes for non-supported holding rows. */ +export type PortfolioHoldingNormalizationCode = + | 'MISSING_ASSET_ID' + | 'UNSUPPORTED_ASSET' + | 'INVALID_BALANCE' + | 'INVALID_DECIMALS'; + +/** + * Normalized metadata intentionally contains only fields observable in raw + * portfolio/asset reads. Missing metadata stays null instead of being invented. + */ +export interface NormalizedPortfolioAssetMetadata { + symbol: string | null; + name: string | null; + decimals: number | null; + category: string | null; + contractId: string | null; +} + +/** + * One normalized holding row suitable for dashboards and other SDK consumers. + * + * `balance` is the canonical integer base-unit representation. `decimalBalance` + * is the exact decimal expansion using metadata.decimals. Both are null when the + * source row cannot be represented without guessing. + */ +export interface NormalizedPortfolioHolding { + status: PortfolioHoldingNormalizationStatus; + code?: PortfolioHoldingNormalizationCode; + assetId: string | null; + balance: string | null; + decimalBalance: string | null; + metadata: NormalizedPortfolioAssetMetadata; +} + +/** Aggregate state of a normalized portfolio snapshot. */ +export type PortfolioNormalizationState = + | 'empty' + | 'ready' + | 'partial' + | 'unusable'; + +/** + * Typed, source-agnostic portfolio snapshot produced from heterogeneous holdings. + */ +export interface NormalizedInvestorPortfolio { + state: PortfolioNormalizationState; + holdings: NormalizedPortfolioHolding[]; + totalHoldingsCount: number; + supportedHoldingsCount: number; + unknownHoldingsCount: number; + unsupportedHoldingsCount: number; +} + +/** + * Compatibility aliases accepted by the normalizer. + * + * The public function still accepts `unknown` so callers can pass decoded JSON + * or contract/dashboard payloads without first asserting a shape; this interface + * documents the supported field vocabulary. + */ +export interface PortfolioHoldingInput { + assetId?: unknown; + asset_id?: unknown; + contractId?: unknown; + contract_id?: unknown; + id?: unknown; + balance?: unknown; + rawBalance?: unknown; + raw_balance?: unknown; + amount?: unknown; + quantity?: unknown; + symbol?: unknown; + assetSymbol?: unknown; + asset_symbol?: unknown; + name?: unknown; + assetName?: unknown; + asset_name?: unknown; + decimals?: unknown; + assetDecimals?: unknown; + asset_decimals?: unknown; + category?: unknown; + assetCategory?: unknown; + asset_category?: unknown; + supported?: unknown; + isSupported?: unknown; + unsupported?: unknown; + status?: unknown; + metadata?: unknown; + asset?: unknown; +} + +/** Optional compatibility assumptions for normalization. */ +export interface PortfolioNormalizerOptions { + /** + * Decimal precision used only when a supported row omits precision entirely. + * Defaults to 7 to match the SDK's existing Soroban holding model. + */ + defaultDecimals?: number; +} diff --git a/tests/portfolio-normalizer.test.ts b/tests/portfolio-normalizer.test.ts new file mode 100644 index 0000000..ed851cf --- /dev/null +++ b/tests/portfolio-normalizer.test.ts @@ -0,0 +1,217 @@ +import { + formatPortfolioBalance, + getPortfolioHoldingDisplayLabel, + normalizeInvestorPortfolio, + normalizePortfolioHolding, +} from '../src/investor/normalizer'; + +describe('portfolio normalizer', () => { + it('normalizes camelCase and snake_case contract ID aliases into the same shape', () => { + const canonical = normalizePortfolioHolding({ + contractId: 'C_ASSET', + balance: '123450000', + metadata: { + symbol: 'RWA', + name: 'Real Asset', + decimals: 7, + category: 'real-estate', + }, + }); + + const legacy = normalizePortfolioHolding({ + contract_id: 'C_ASSET', + raw_balance: '123450000', + asset_symbol: 'RWA', + asset_name: 'Real Asset', + asset_decimals: '7', + asset_category: 'real-estate', + }); + + expect(canonical).toEqual(legacy); + expect(canonical).toMatchObject({ + status: 'supported', + assetId: 'C_ASSET', + balance: '123450000', + decimalBalance: '12.345', + metadata: { + symbol: 'RWA', + name: 'Real Asset', + decimals: 7, + category: 'real-estate', + contractId: 'C_ASSET', + }, + }); + }); + + it('uses a later alias when an earlier alias is nullish', () => { + const holding = normalizePortfolioHolding({ + assetId: null, + contract_id: 'C_FALLBACK', + balance: null, + raw_balance: '250', + asset: { symbol: 'FALL', decimals: 2 }, + }); + + expect(holding.status).toBe('supported'); + expect(holding.assetId).toBe('C_FALLBACK'); + expect(holding.balance).toBe('250'); + expect(holding.decimalBalance).toBe('2.5'); + }); + + it('falls through malformed aliases when a later source alias is valid', () => { + const holding = normalizePortfolioHolding({ + assetId: 42, + asset_id: 'C_RECOVERED', + balance: '1.25', + raw_balance: '250', + decimals: 'not-a-precision', + asset_decimals: '2', + metadata: 'not-an-object', + asset: { symbol: 'RECOVERED', name: 'Recovered Asset' }, + }); + + expect(holding).toMatchObject({ + status: 'supported', + assetId: 'C_RECOVERED', + balance: '250', + decimalBalance: '2.5', + metadata: { + symbol: 'RECOVERED', + name: 'Recovered Asset', + decimals: 2, + }, + }); + }); + + it('falls through partial metadata to later asset aliases while preserving earlier values', () => { + const holding = normalizePortfolioHolding({ + assetId: 'C_DUAL', + balance: '250', + metadata: { name: 'Primary Name' }, + asset: { + name: 'Fallback Name', + symbol: 'DUAL', + decimals: 2, + unsupported: true, + }, + }); + + expect(holding).toMatchObject({ + status: 'unsupported', + code: 'UNSUPPORTED_ASSET', + assetId: 'C_DUAL', + decimalBalance: '2.5', + metadata: { + name: 'Primary Name', + symbol: 'DUAL', + decimals: 2, + }, + }); + }); + + it.each([null, undefined])( + 'fails closed when a precision alias is explicitly %s', + (decimals) => { + const holding = normalizePortfolioHolding({ + assetId: 'C_UNKNOWN_PRECISION', + balance: '10000000', + decimals, + }); + + expect(holding).toMatchObject({ + status: 'unsupported', + code: 'INVALID_DECIMALS', + decimalBalance: null, + metadata: { decimals: null }, + }); + }, + ); + + it('preserves unknown rows instead of silently dropping them', () => { + const holding = normalizePortfolioHolding({ + balance: '50000000', + metadata: { symbol: 'MYSTERY', decimals: 7 }, + }); + + expect(holding.status).toBe('unknown'); + expect(holding.code).toBe('MISSING_ASSET_ID'); + expect(holding.assetId).toBeNull(); + expect(holding.balance).toBe('50000000'); + expect(holding.decimalBalance).toBe('5'); + expect(getPortfolioHoldingDisplayLabel(holding)).toBe('MYSTERY'); + }); + + it.each([ + [ + { balance: '1', supported: false }, + 'UNSUPPORTED_ASSET', + ], + [ + { assetId: 'C_DISABLED', balance: '1', supported: false }, + 'UNSUPPORTED_ASSET', + ], + [ + { assetId: 'C_STATUS', balance: '1', status: ' Unsupported ' }, + 'UNSUPPORTED_ASSET', + ], + [ + { assetId: 'C_BAD_BALANCE', balance: '1.25', decimals: 7 }, + 'INVALID_BALANCE', + ], + [ + { assetId: 'C_BAD_DECIMALS', balance: '10', decimals: 100 }, + 'INVALID_DECIMALS', + ], + ])('marks unsupported holding shapes explicitly', (input, code) => { + expect(normalizePortfolioHolding(input)).toMatchObject({ + status: 'unsupported', + code, + }); + }); + + it('formats signed base-unit balances exactly and locale-neutrally', () => { + expect(formatPortfolioBalance('0', 7)).toBe('0'); + expect(formatPortfolioBalance('123450000', 7)).toBe('12.345'); + expect(formatPortfolioBalance('-5000000', 7)).toBe('-0.5'); + expect(formatPortfolioBalance('42', 0)).toBe('42'); + }); + + it('reports ready, partial, unusable, and empty portfolio states', () => { + const supported = { assetId: 'C_OK', balance: '10000000', decimals: 7 }; + const unknown = { balance: '10000000', decimals: 7 }; + const unsupported = { assetId: 'C_BAD', balance: 'not-a-number', decimals: 7 }; + + expect(normalizeInvestorPortfolio([]).state).toBe('empty'); + expect(normalizeInvestorPortfolio([supported]).state).toBe('ready'); + + const partial = normalizeInvestorPortfolio([supported, unknown, unsupported]); + expect(partial).toMatchObject({ + state: 'partial', + totalHoldingsCount: 3, + supportedHoldingsCount: 1, + unknownHoldingsCount: 1, + unsupportedHoldingsCount: 1, + }); + + expect(normalizeInvestorPortfolio([unknown, unsupported]).state).toBe('unusable'); + }); + + it('preserves source order and supports an explicit default precision', () => { + const normalized = normalizeInvestorPortfolio( + [ + { asset_id: 'C_SECOND', amount: '250' }, + { asset_id: 'C_FIRST', amount: '100' }, + ], + { defaultDecimals: 2 }, + ); + + expect(normalized.holdings.map((holding) => holding.assetId)).toEqual([ + 'C_SECOND', + 'C_FIRST', + ]); + expect(normalized.holdings.map((holding) => holding.decimalBalance)).toEqual([ + '2.5', + '1', + ]); + }); +});