diff --git a/docs/Asset-Model.md b/docs/Asset-Model.md new file mode 100644 index 0000000..bedc787 --- /dev/null +++ b/docs/Asset-Model.md @@ -0,0 +1,25 @@ +# Typed Asset Model Architecture + +The PocketPay SDK uses a discriminated union for asset representations across all SDK functions (balances, payments, transaction summaries, and validation logic). + +This model ensures type safety, prevents invalid asset configurations at compile time, and supports both native XLM and Stellar issued assets (AlphaNum4 & AlphaNum12). + +--- + +## 1. Asset Type Hierarchy + +The `Asset` type is a discriminated union of `NativeAsset` and `IssuedAsset`: + +```typescript +import { Asset, NativeAsset, IssuedAsset, NATIVE_ASSET } from '@axionvera/pocketpay-sdk'; + +// Native XLM Asset +const xlm: NativeAsset = NATIVE_ASSET; +// { type: 'native', code: 'XLM' } + +// Issued Credit Asset (AlphaNum4 or AlphaNum12) +const usdc: IssuedAsset = { + type: 'issued', + code: 'USDC', + issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335WF2CCX3THRDU2C2FYM235AC2', +}; diff --git a/src/index.ts b/src/index.ts index 2033242..4db6a17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,11 @@ export type { TrustlineStatus, TrustlineCheckResult, TrustlineCheckOptions, + // ─── Typed Asset Model ───────────────────────────────────────────────────── + Asset, + NativeAsset, + IssuedAsset, + AssetValidationResult, // ─── Multi-Asset Balance Model ────────────────────────────────────────────── AssetBalanceState, AccountBalanceState, @@ -68,7 +73,17 @@ export type { RetryPolicyExhaustedResult, } from './types'; -export { PocketPayError, TransactionDirection, TransactionStatus } from './types'; +export { + PocketPayError, + TransactionDirection, + TransactionStatus, + // ─── Typed Asset Model Exports ───────────────────────────────────────────── + NATIVE_ASSET, + isNativeAsset, + isIssuedAsset, + validateAsset, + assertValidAsset, +} from './types'; // ─── Error Enrichment Types ──────────────────────────────────────────────── export type { ResultWarning, RecoveryHint } from './errors'; @@ -224,6 +239,9 @@ export { toEnhancedResult, // Asset helpers findAssetBalance, + formatAsset, + parseAssetString, + areAssetsEqual, // Security helpers redactSensitive, } from './utils'; diff --git a/src/types/asset.ts b/src/types/asset.ts new file mode 100644 index 0000000..f66b5a3 --- /dev/null +++ b/src/types/asset.ts @@ -0,0 +1,84 @@ +export interface NativeAsset { + type: 'native'; + code: 'XLM'; +} + +export interface IssuedAsset { + type: 'issued'; + /** 1-12 alphanumeric characters (AlphaNum4 or AlphaNum12) */ + code: string; + /** Valid 56-character Stellar G-address public key */ + issuer: string; +} + +export type Asset = NativeAsset | IssuedAsset; + +/** Immutable constant for native XLM */ +export const NATIVE_ASSET: Readonly = Object.freeze({ + type: 'native', + code: 'XLM', +}); + +// ========================================== +// Type Guards & Validation +// ========================================== + +export function isNativeAsset(asset: Asset): asset is NativeAsset { + return asset.type === 'native'; +} + +export function isIssuedAsset(asset: Asset): asset is IssuedAsset { + return asset.type === 'issued'; +} + +const STELLAR_PUBKEY_REGEX = /^G[A-Z2-7]{55}$/; +const ASSET_CODE_REGEX = /^[a-zA-Z0-9]{1,12}$/; + +export interface AssetValidationResult { + valid: boolean; + error?: string; +} + +/** + * Validates native and issued assets against Stellar network rules. + */ +export function validateAsset(asset: Asset): AssetValidationResult { + if (!asset || typeof asset !== 'object') { + return { valid: false, error: 'Asset must be an object' }; + } + + if (asset.type === 'native') { + if (asset.code !== 'XLM') { + return { valid: false, error: 'Native asset code must be "XLM"' }; + } + return { valid: true }; + } + + if (asset.type === 'issued') { + if (!asset.code || typeof asset.code !== 'string' || !ASSET_CODE_REGEX.test(asset.code)) { + return { valid: false, error: 'Issued asset code must be 1-12 alphanumeric characters' }; + } + + if (asset.code.toUpperCase() === 'XLM') { + return { valid: false, error: 'Issued asset code cannot be XLM' }; + } + + if (!asset.issuer || typeof asset.issuer !== 'string' || !STELLAR_PUBKEY_REGEX.test(asset.issuer)) { + return { valid: false, error: 'Issued asset issuer must be a valid Stellar public key (starting with G)' }; + } + + return { valid: true }; + } + + return { valid: false, error: 'Invalid asset type. Expected "native" or "issued"' }; +} + +/** + * Throws an Error if the asset fails validation. + */ +export function assertValidAsset(asset: Asset): void { + const result = validateAsset(asset); + if (!result.valid) { + throw new Error(`Invalid Asset: ${result.error}`); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index e443191..0be06e1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -905,3 +905,4 @@ export interface RetryPolicyExhaustedResult { /** Number of attempts made. */ attempts: number; } +export * from './asset'; diff --git a/src/utils/assetHelpers.ts b/src/utils/assetHelpers.ts new file mode 100644 index 0000000..20f2ff2 --- /dev/null +++ b/src/utils/assetHelpers.ts @@ -0,0 +1,65 @@ +import { + Asset, + NATIVE_ASSET, + isNativeAsset, + isIssuedAsset, + assertValidAsset, +} from '../types/asset'; + +/** + * Formats an Asset into its standard string representation. + * - Native: "XLM" + * - Issued: "CODE:ISSUER" (e.g. "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335WF2CCX3THRDU2C2FYM235AC2") + */ +export function formatAsset(asset: Asset): string { + assertValidAsset(asset); + if (isNativeAsset(asset)) { + return 'XLM'; + } + return `${asset.code}:${asset.issuer}`; +} + +/** + * Parses a string representation into a typed Asset object. + * Accepts: + * - "XLM" or "native" -> NativeAsset + * - "CODE:ISSUER" -> IssuedAsset + */ +export function parseAssetString(assetStr: string): Asset { + if (!assetStr || typeof assetStr !== 'string') { + throw new Error('Asset string must be a non-empty string'); + } + + const trimmed = assetStr.trim(); + + if (trimmed.toUpperCase() === 'XLM' || trimmed.toLowerCase() === 'native') { + return NATIVE_ASSET; + } + + const parts = trimmed.split(':'); + if (parts.length === 2 && parts[0] && parts[1]) { + const asset: Asset = { + type: 'issued', + code: parts[0].trim(), + issuer: parts[1].trim(), + }; + assertValidAsset(asset); + return asset; + } + + throw new Error( + `Cannot parse asset string: "${assetStr}". Expected "XLM" or "CODE:ISSUER"` + ); +} + +/** + * Checks strict equality between two Asset objects. + */ +export function areAssetsEqual(a: Asset, b: Asset): boolean { + if (a.type !== b.type) return false; + if (isNativeAsset(a) && isNativeAsset(b)) return true; + if (isIssuedAsset(a) && isIssuedAsset(b)) { + return a.code === b.code && a.issuer === b.issuer; + } + return false; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 911b0eb..3a08dae 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -480,5 +480,5 @@ export { getTransactionExplorerLink, getOperationExplorerLink, } from './explorer'; - +export * from './assetHelpers'; diff --git a/tests/types/asset.test.ts b/tests/types/asset.test.ts new file mode 100644 index 0000000..33e20fe --- /dev/null +++ b/tests/types/asset.test.ts @@ -0,0 +1,151 @@ +import { + NATIVE_ASSET, + isNativeAsset, + isIssuedAsset, + validateAsset, + assertValidAsset, + formatAsset, + parseAssetString, + areAssetsEqual, + Asset, + IssuedAsset, +} from '../../src'; + +const VALID_ISSUER = 'GBAU2A3P3VBAJ2R6IUZTL735PVD4OHRRL3AOHXWTYLOH765P6I34J4GH'; +const SECOND_ISSUER = 'GCX2233633644556677889900AABBCCDDEEFFGGHHIIJJKKLLMMNNOOPP'; + +describe('Typed Asset Model', () => { + describe('Native XLM Asset', () => { + it('correctly identifies native XLM asset', () => { + expect(NATIVE_ASSET.type).toBe('native'); + expect(NATIVE_ASSET.code).toBe('XLM'); + expect(isNativeAsset(NATIVE_ASSET)).toBe(true); + expect(isIssuedAsset(NATIVE_ASSET)).toBe(false); + }); + + it('passes validation for native XLM', () => { + const res = validateAsset(NATIVE_ASSET); + expect(res.valid).toBe(true); + expect(res.error).toBeUndefined(); + expect(() => assertValidAsset(NATIVE_ASSET)).not.toThrow(); + }); + + it('fails validation if native asset code is not XLM', () => { + const badNative = { type: 'native', code: 'USD' } as unknown as Asset; + const res = validateAsset(badNative); + expect(res.valid).toBe(false); + expect(res.error).toContain('Native asset code must be "XLM"'); + }); + }); + + describe('Issued Asset Validation', () => { + it('validates AlphaNum4 issued asset', () => { + const usdAsset: IssuedAsset = { + type: 'issued', + code: 'USD', + issuer: VALID_ISSUER, + }; + expect(isIssuedAsset(usdAsset)).toBe(true); + expect(validateAsset(usdAsset).valid).toBe(true); + }); + + it('validates AlphaNum12 issued asset', () => { + const longAsset: IssuedAsset = { + type: 'issued', + code: 'STELLARCODE12', + issuer: VALID_ISSUER, + }; + expect(validateAsset(longAsset).valid).toBe(true); + }); + + it('fails validation if issued asset code is empty or > 12 characters', () => { + const emptyCode = { type: 'issued', code: '', issuer: VALID_ISSUER } as Asset; + expect(validateAsset(emptyCode).valid).toBe(false); + + const tooLongCode = { + type: 'issued', + code: 'VERYLONGASSETNAME', + issuer: VALID_ISSUER, + } as Asset; + expect(validateAsset(tooLongCode).valid).toBe(false); + }); + + it('fails validation if issued asset code is XLM', () => { + const xlmIssued = { type: 'issued', code: 'XLM', issuer: VALID_ISSUER } as Asset; + const res = validateAsset(xlmIssued); + expect(res.valid).toBe(false); + expect(res.error).toContain('Issued asset code cannot be XLM'); + }); + + it('fails validation if issuer public key is invalid', () => { + const badIssuer = { type: 'issued', code: 'USD', issuer: 'INVALID_G_KEY' } as Asset; + const res = validateAsset(badIssuer); + expect(res.valid).toBe(false); + expect(res.error).toContain('valid Stellar public key'); + }); + + it('throws when assertValidAsset receives an invalid asset', () => { + const invalidAsset = { type: 'issued', code: '', issuer: '' } as Asset; + expect(() => assertValidAsset(invalidAsset)).toThrow('Invalid Asset:'); + }); + }); + + describe('Formatting and Parsing', () => { + it('formats native asset as XLM', () => { + expect(formatAsset(NATIVE_ASSET)).toBe('XLM'); + }); + + it('formats issued asset as CODE:ISSUER', () => { + const asset: IssuedAsset = { type: 'issued', code: 'USDC', issuer: VALID_ISSUER }; + expect(formatAsset(asset)).toBe(`USDC:${VALID_ISSUER}`); + }); + + it('parses XLM and native string representations', () => { + expect(parseAssetString('XLM')).toEqual(NATIVE_ASSET); + expect(parseAssetString('xlm')).toEqual(NATIVE_ASSET); + expect(parseAssetString('native')).toEqual(NATIVE_ASSET); + }); + + it('parses CODE:ISSUER string representation', () => { + const str = `USDC:${VALID_ISSUER}`; + const parsed = parseAssetString(str); + expect(parsed).toEqual({ + type: 'issued', + code: 'USDC', + issuer: VALID_ISSUER, + }); + }); + + it('throws error when parsing malformed strings', () => { + expect(() => parseAssetString('')).toThrow(); + expect(() => parseAssetString('USDC')).toThrow('Cannot parse asset string'); + expect(() => parseAssetString('USDC:INVALID_ISSUER')).toThrow(); + }); + }); + + describe('Asset Equality', () => { + it('returns true for two native assets', () => { + expect(areAssetsEqual(NATIVE_ASSET, { type: 'native', code: 'XLM' })).toBe(true); + }); + + it('returns true for matching issued assets', () => { + const a: IssuedAsset = { type: 'issued', code: 'USD', issuer: VALID_ISSUER }; + const b: IssuedAsset = { type: 'issued', code: 'USD', issuer: VALID_ISSUER }; + expect(areAssetsEqual(a, b)).toBe(true); + }); + + it('returns false when code or issuer differs', () => { + const a: IssuedAsset = { type: 'issued', code: 'USD', issuer: VALID_ISSUER }; + const diffCode: IssuedAsset = { type: 'issued', code: 'EUR', issuer: VALID_ISSUER }; + const diffIssuer: IssuedAsset = { type: 'issued', code: 'USD', issuer: SECOND_ISSUER }; + + expect(areAssetsEqual(a, diffCode)).toBe(false); + expect(areAssetsEqual(a, diffIssuer)).toBe(false); + }); + + it('returns false when comparing native vs issued asset', () => { + const issued: IssuedAsset = { type: 'issued', code: 'XLM', issuer: VALID_ISSUER }; + expect(areAssetsEqual(NATIVE_ASSET, issued)).toBe(false); + }); + }); +});