Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
207bce4
feat(portfolio): add normalized holding types
woahwhattheheck Oct 6, 2026
0fc61f7
feat(portfolio): normalize heterogeneous holdings
woahwhattheheck Oct 6, 2026
46eab07
test(portfolio): cover normalization states
woahwhattheheck Oct 6, 2026
ccf3074
fix(portfolio): preserve contract identity provenance
woahwhattheheck Oct 6, 2026
ef045fc
test(portfolio): keep generic asset ids distinct from contracts
woahwhattheheck Oct 6, 2026
9b58eaa
feat(portfolio): export holding normalizer
woahwhattheheck Oct 6, 2026
9557903
docs(portfolio): document nested asset alias
woahwhattheheck Oct 6, 2026
f69b7cf
docs(portfolio): explain normalization assumptions
woahwhattheheck Oct 6, 2026
9eb529d
test: align portfolio contract ID aliases
woahwhattheheck Oct 6, 2026
2297fa5
fix: fall through nullish portfolio aliases
woahwhattheheck Oct 7, 2026
65ab07e
test nullish alias fallback
woahwhattheheck Oct 7, 2026
8e5c912
fix: preserve explicit unsupported portfolio state
woahwhattheheck Oct 7, 2026
8c65e08
test: cover unsupported holding without asset id
woahwhattheheck Oct 7, 2026
358befc
fix(portfolio): fall through malformed aliases
woahwhattheheck Oct 7, 2026
38fa747
test(portfolio): cover malformed alias recovery
woahwhattheheck Oct 7, 2026
3ead068
fix(portfolio): treat explicit missing precision as invalid
woahwhattheheck Oct 8, 2026
12d0c45
test(portfolio): cover explicit null precision aliases (#146)
woahwhattheheck Oct 8, 2026
28e48d2
fix(portfolio): fall through nested holding aliases (#146)
woahwhattheheck Oct 8, 2026
ec358ac
fix(portfolio): normalize unsupported status whitespace (#146)
woahwhattheheck Oct 8, 2026
9a1cbb3
test(portfolio): cover status whitespace (#146)
woahwhattheheck Oct 8, 2026
a652328
fix(test): repair portfolio status fixture (#146)
woahwhattheheck Oct 8, 2026
f31024d
Align portfolio input type with accepted aliases
woahwhattheheck Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/portfolio-normalizer.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
242 changes: 242 additions & 0 deletions src/investor/normalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import {
NormalizedInvestorPortfolio,
NormalizedPortfolioAssetMetadata,
NormalizedPortfolioHolding,
PortfolioNormalizerOptions,
} from '../types/portfolio-normalizer';

type Row = Record<string, unknown>;
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<T> {
present: boolean;
value: T | null;
}

function firstParsed<T>(
source: Row | null,
keys: readonly string[],
parse: (value: unknown) => T | null,
): ParsedAlias<T> {
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<T>(
sources: readonly (Row | null)[],
keys: readonly string[],
parse: (value: unknown) => T | null,
): ParsedAlias<T> {
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,
};
}
Loading