Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions docs/Asset-Model.md
Original file line number Diff line number Diff line change
@@ -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',
};
20 changes: 19 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export type {
TrustlineStatus,
TrustlineCheckResult,
TrustlineCheckOptions,
// ─── Typed Asset Model ─────────────────────────────────────────────────────
Asset,
NativeAsset,
IssuedAsset,
AssetValidationResult,
// ─── Multi-Asset Balance Model ──────────────────────────────────────────────
AssetBalanceState,
AccountBalanceState,
Expand All @@ -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';
Expand Down Expand Up @@ -224,6 +239,9 @@ export {
toEnhancedResult,
// Asset helpers
findAssetBalance,
formatAsset,
parseAssetString,
areAssetsEqual,
// Security helpers
redactSensitive,
} from './utils';
Expand Down
84 changes: 84 additions & 0 deletions src/types/asset.ts
Original file line number Diff line number Diff line change
@@ -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<NativeAsset> = 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}`);
}
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,3 +905,4 @@ export interface RetryPolicyExhaustedResult {
/** Number of attempts made. */
attempts: number;
}
export * from './asset';
65 changes: 65 additions & 0 deletions src/utils/assetHelpers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,5 +480,5 @@ export {
getTransactionExplorerLink,
getOperationExplorerLink,
} from './explorer';

export * from './assetHelpers';

151 changes: 151 additions & 0 deletions tests/types/asset.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});