From 4adf062814a8692347730adcf1c8871372691934 Mon Sep 17 00:00:00 2001 From: devhenryno Date: Thu, 23 Jul 2026 13:46:01 +0100 Subject: [PATCH] feat(wallet): design and implement multi-asset balance model (Closes #166) --- README.md | 1 + docs/multi-asset-balance-model.md | 206 +++++++++++++++++++ src/index.ts | 19 +- src/transactions/filter.ts | 10 +- src/types/balance.ts | 145 +++++++++++++ src/types/index.ts | 35 +--- src/types/transaction.ts | 46 +++-- src/wallet/index.ts | 11 + src/wallet/multi-asset.ts | 327 ++++++++++++++++++++++++++++++ tests/multi-asset-balance.test.ts | 317 +++++++++++++++++++++++++++++ 10 files changed, 1066 insertions(+), 51 deletions(-) create mode 100644 docs/multi-asset-balance-model.md create mode 100644 src/types/balance.ts create mode 100644 src/wallet/multi-asset.ts create mode 100644 tests/multi-asset-balance.test.ts diff --git a/README.md b/README.md index 4545349..fa46f73 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ npm install @axionvera/pocketpay-sdk - [Soroban Vault](./docs/soroban-vault.md) - Savings vault helpers, configuration, and limitations - [Trustline Validation](./docs/trustline-validation.md) - Pre-flight trustline verification and issued asset payment safety - [Issued Asset Payments](./docs/issued-asset-payments.md) - Full guide to sending issued assets: asset identifiers, trustline setup, `sendAsset`, validation rules, and error reference +- [Multi-Asset Balance Model](./docs/multi-asset-balance-model.md) - Rich balance model for native XLM and issued credit assets with reserves and status taxonomy - [Release Checklist](./docs/release-checklist.md) - Pre-release verification steps for maintainers - [Architecture Decision Records](./docs/adr/) - Records of significant SDK design decisions and their rationale - [Support Policy](./docs/support-policy.md) - Supported runtimes, versions, network status, and maintenance expectations diff --git a/docs/multi-asset-balance-model.md b/docs/multi-asset-balance-model.md new file mode 100644 index 0000000..7577547 --- /dev/null +++ b/docs/multi-asset-balance-model.md @@ -0,0 +1,206 @@ +# Multi-Asset Balance Model Guide + +Resolves [Issue #166](https://github.com/Axionvera/pocketpay-sdk/issues/166). + +This guide documents the PocketPay SDK **Multi-Asset Balance Model** designed for mobile and web UI consumers. It provides a rich, strongly typed representation of account balances supporting native XLM, issued credit assets (e.g. USDC, EURT), protocol reserve calculations, issuer authorization states, and unavailable/unknown balance handling. + +--- + +## 1. Overview & Problem Statement + +On the Stellar network, a single account can hold: +1. **Native XLM**: The native network currency required for account creation, transaction fees, and base reserves. +2. **Issued Credit Assets**: Custom tokens (such as USDC, EURT, or project tokens) issued by third-party Stellar accounts via trustlines (`ChangeTrust`). + +A simple `nativeBalance` string does not scale for multi-asset wallets or mobile interfaces. Furthermore, Stellar accounts encumber balances for: +* **Base Reserve**: Minimum XLM reserve required to maintain the account and its subentries (`1.0 XLM + 0.5 XLM * subentry_count`). +* **Selling Liabilities**: Balances committed to open DEX sell offers. +* **Issuer Authorization**: Issued assets whose trustlines have been revoked or not yet authorized by the asset issuer. + +The PocketPay SDK Multi-Asset Balance Model exposes these details cleanly to prevent accidental spend attempts and give UI consumers exact, display-ready data. + +--- + +## 2. Model Structure + +### `MultiAssetBalance` + +```ts +export interface MultiAssetBalance { + /** Stellar public key (G...) of the account */ + publicKey: string; + /** Overall account status ('funded' | 'unfunded' | 'unavailable' | 'unknown') */ + accountState: AccountBalanceState; + /** Native XLM balance entry (undefined if account is unfunded or unavailable) */ + native?: NativeAssetBalanceItem; + /** Array of issued asset balance entries */ + issuedAssets: IssuedAssetBalanceItem[]; + /** Array of unknown/unparseable balance entries (if any) */ + unknownAssets: UnknownAssetBalanceItem[]; + /** Total number of asset entries held by the account */ + totalAssetCount: number; + /** ISO 8601 timestamp when the balance snapshot was taken */ + updatedAt: string; +} +``` + +--- + +## 3. Native vs. Issued Assets + +### Native XLM Balance (`NativeAssetBalanceItem`) + +Native XLM includes Stellar protocol reserve calculations and DEX liabilities: + +```ts +export interface NativeAssetBalanceItem { + type: 'native'; + assetCode: 'XLM'; + totalBalance: string; // e.g. "100.0000000" + availableBalance: string; // totalBalance - reservedBalance (e.g. "97.5000000") + reservedBalance: string; // minBalance + sellingLiabilities (e.g. "2.5000000") + sellingLiabilities: string; // DEX sell offer commitments + buyingLiabilities: string; // DEX buy offer commitments + subentryCount: number; // Number of trustlines, offers, signers, data entries + state: AssetBalanceState; // 'available' | 'reserved' | 'unavailable' | 'unknown' + formattedDisplay: string; // e.g. "97.50 XLM" +} +``` + +#### XLM Base Reserve Formula +$$\text{minBalance} = (2 + \text{subentryCount}) \times 0.5\text{ XLM} = 1.0\text{ XLM} + (\text{subentryCount} \times 0.5\text{ XLM})$$ + +* **Base Reserve**: $1.0\text{ XLM}$ for account maintenance. +* **Per Subentry**: $0.5\text{ XLM}$ per trustline, open offer, additional signer, or data entry. + +--- + +### Issued Asset Balance (`IssuedAssetBalanceItem`) + +Issued assets represent credit assets established via trustlines: + +```ts +export interface IssuedAssetBalanceItem { + type: 'issued'; + assetCode: string; // e.g. "USDC", "EURT" + issuer: string; // Issuer public key (G...) + totalBalance: string; // e.g. "100.5000000" + availableBalance: string; // totalBalance - sellingLiabilities + reservedBalance: string; // sellingLiabilities + sellingLiabilities: string; // Open sell offer commitments + buyingLiabilities: string; // Open buy offer commitments + limit: string; // Maximum trustline capacity limit + isAuthorized: boolean; // Issuer authorization state (is_authorized !== false) + state: AssetBalanceState; // 'available' | 'reserved' | 'unauthorized' | 'unavailable' | 'unknown' + formattedDisplay: string; // e.g. "100.50 USDC" +} +``` + +--- + +## 4. State & Availability Taxonomy + +### Account States (`AccountBalanceState`) + +| State | Meaning | UI Action | +| :--- | :--- | :--- | +| `funded` | Account exists on-chain with native or issued balances | Display wallet balances | +| `unfunded` | Account 404 (has never received native XLM funding) | Show "Fund Account" button | +| `unavailable` | Network/Horizon endpoint unreachable or degraded | Show retry / offline warning | +| `unknown` | Account state unverified | Show loading / pending indicator | + +### Asset Balance States (`AssetBalanceState`) + +| State | Meaning | Spendable? | +| :--- | :--- | :--- | +| `available` | Asset is active and ready for transfers | Yes (`availableBalance`) | +| `reserved` | Balance encumbered by base reserve or DEX liabilities | No | +| `unauthorized` | Trustline exists but issuer revoked or hasn't granted authorization | No | +| `unavailable` | Balance data unavailable from Horizon | No | +| `unknown` | Unparseable asset entry | No | + +--- + +## 5. Usage Code Examples + +### Querying Multi-Asset Balances + +```ts +import { getMultiAssetBalance } from 'stellar-pocketpay-sdk'; + +const balance = await getMultiAssetBalance('GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'); + +if (balance.accountState === 'funded') { + console.log('Native XLM Available:', balance.native?.availableBalance); + console.log('Native XLM Reserved:', balance.native?.reservedBalance); + + for (const asset of balance.issuedAssets) { + console.log(`${asset.assetCode}: ${asset.formattedDisplay} (Authorized: ${asset.isAuthorized})`); + } +} else if (balance.accountState === 'unfunded') { + console.log('Wallet is unfunded on Testnet. Fund with friendbot first.'); +} +``` + +### Safe Non-Throwing Query + +```ts +import { safeGetMultiAssetBalance } from 'stellar-pocketpay-sdk'; + +const result = await safeGetMultiAssetBalance(publicKey); + +if (result.ok) { + const balance = result.value; + console.log(`Total Assets Held: ${balance.totalAssetCount}`); +} else { + console.error('Balance Query Failed:', result.error.message); +} +``` + +### Display Formatting & Asset Lookup Helpers + +```ts +import { + formatAssetBalanceDisplay, + findAssetInMultiBalance, + calculateNativeReserves, +} from 'stellar-pocketpay-sdk'; + +// Calculate reserves for 3 subentries (e.g., 2 trustlines + 1 offer) +const reserves = calculateNativeReserves(3); +console.log('Minimum XLM Required:', reserves.minBalance); // "2.5000000" + +// Find USDC asset in multi-asset balance object +const usdcAsset = findAssetInMultiBalance(multiBalance, 'USDC', usdcIssuerPublicKey); + +if (usdcAsset) { + console.log(formatAssetBalanceDisplay(usdcAsset, 2)); // "50.00 USDC" +} +``` + +--- + +## 6. Summary of Exports + +All multi-asset balance types and functions are exported from the package root (`stellar-pocketpay-sdk`): + +```ts +import { + // Types + AssetBalanceState, + AccountBalanceState, + NativeAssetBalanceItem, + IssuedAssetBalanceItem, + UnknownAssetBalanceItem, + AssetBalanceItem, + MultiAssetBalance, + MultiAssetBalanceResult, + // Functions + calculateNativeReserves, + parseMultiAssetBalance, + getMultiAssetBalance, + safeGetMultiAssetBalance, + formatAssetBalanceDisplay, + findAssetInMultiBalance, +} from 'stellar-pocketpay-sdk'; +``` diff --git a/src/index.ts b/src/index.ts index a69ac97..3f3f91b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,6 @@ export type { TransactionSummary, TransactionRecord, TransactionList, - TransactionDirection, FilterableTransaction, FilterTransactionsOptions, SortableTransaction, @@ -46,13 +45,22 @@ export type { TrustlineStatus, TrustlineCheckResult, TrustlineCheckOptions, + // ─── Multi-Asset Balance Model ────────────────────────────────────────────── + AssetBalanceState, + AccountBalanceState, + NativeAssetBalanceItem, + IssuedAssetBalanceItem, + UnknownAssetBalanceItem, + AssetBalanceItem, + MultiAssetBalance, + MultiAssetBalanceResult, // ─── Retry Policy ────────────────────────────────────────────────────────── SubmissionOutcome, RetryPolicy, RetryPolicyExhaustedResult, } from './types'; -export { PocketPayError } from './types'; +export { PocketPayError, TransactionDirection, TransactionStatus } from './types'; // ─── Error Enrichment Types ──────────────────────────────────────────────── export type { ResultWarning, RecoveryHint } from './errors'; @@ -69,6 +77,13 @@ export { safeFundTestnetAccount, enhancedGetBalance, safeEnhancedGetBalance, + // Multi-Asset Balance + calculateNativeReserves, + parseMultiAssetBalance, + getMultiAssetBalance, + safeGetMultiAssetBalance, + formatAssetBalanceDisplay, + findAssetInMultiBalance, } from './wallet'; // ─── Payments ─────────────────────────────────────────────────────────────── diff --git a/src/transactions/filter.ts b/src/transactions/filter.ts index 7432564..1550d62 100644 --- a/src/transactions/filter.ts +++ b/src/transactions/filter.ts @@ -29,16 +29,16 @@ function resolveDirection( if (isPayment) { const from = record.from; const to = record.to; - if (from !== undefined && to !== undefined && from === to) return 'self'; - if (from === account) return 'outgoing'; - if (to === account) return 'incoming'; + if (from !== undefined && to !== undefined && from === to) return TransactionDirection.SELF; + if (from === account) return TransactionDirection.OUTGOING; + if (to === account) return TransactionDirection.INCOMING; return null; } // Transaction record: the source account is the originating party. if (record.sourceAccount === undefined) return null; - if (record.sourceAccount === account) return 'outgoing'; - return 'incoming'; + if (record.sourceAccount === account) return TransactionDirection.OUTGOING; + return TransactionDirection.INCOMING; } /** diff --git a/src/types/balance.ts b/src/types/balance.ts new file mode 100644 index 0000000..e19f3fa --- /dev/null +++ b/src/types/balance.ts @@ -0,0 +1,145 @@ +/** + * Stellar PocketPay SDK — Multi-Asset Balance Model Types + * + * Types representing native XLM and issued credit assets in a rich, multi-asset + * account balance model with availability, reserve breakdown, and display metadata. + */ + +/** + * State / status of a specific asset balance entry for an account. + */ +export type AssetBalanceState = + | 'available' // Asset balance is active and ready to spend/transfer + | 'reserved' // Portion or all of balance is encumbered by reserves or liabilities + | 'unauthorized' // Issued asset trustline exists but issuer authorization is lacking + | 'unavailable' // Asset data is currently unavailable from network/RPC + | 'unknown'; // Asset state cannot be determined or parsed + +/** + * State / status of the overall account balance query. + */ +export type AccountBalanceState = + | 'funded' // Account exists on-chain with funded native/issued balances + | 'unfunded' // Account does not exist on-chain (404 / never funded) + | 'unavailable' // Network/Horizon call failed or endpoint degraded + | 'unknown'; // Account status is unknown or unverified + +/** + * Represents native XLM asset balance with protocol reserve breakdown. + */ +export interface NativeAssetBalanceItem { + /** Discriminant for native XLM */ + type: 'native'; + /** Always "XLM" */ + assetCode: 'XLM'; + /** Total XLM balance held (as decimal string, e.g. "100.0000000") */ + totalBalance: string; + /** Available XLM balance that can be transferred (total - reservedBalance) */ + availableBalance: string; + /** Reserved XLM balance required for account base reserve, subentries, and selling liabilities */ + reservedBalance: string; + /** Selling liabilities in XLM from open DEX offers */ + sellingLiabilities: string; + /** Buying liabilities in XLM from open DEX offers */ + buyingLiabilities: string; + /** Number of subentries (trustlines, offers, signers, data entries) */ + subentryCount: number; + /** State of this balance entry */ + state: AssetBalanceState; + /** Formatted UI display string (e.g. "97.50 XLM") */ + formattedDisplay: string; +} + +/** + * Represents an issued credit asset balance (e.g. USDC, EURT). + */ +export interface IssuedAssetBalanceItem { + /** Discriminant for issued credit asset */ + type: 'issued'; + /** Asset code (1-12 alphanumeric characters, e.g. "USDC") */ + assetCode: string; + /** Stellar public key (G...) of the asset issuer */ + issuer: string; + /** Total asset balance held (as decimal string, e.g. "50.0000000") */ + totalBalance: string; + /** Available asset balance to transfer (total - sellingLiabilities) */ + availableBalance: string; + /** Reserved balance (selling liabilities) */ + reservedBalance: string; + /** Selling liabilities from open DEX offers */ + sellingLiabilities: string; + /** Buying liabilities from open DEX offers */ + buyingLiabilities: string; + /** Maximum trustline limit configured by the account holder */ + limit: string; + /** Whether the trustline is authorized by the asset issuer */ + isAuthorized: boolean; + /** State of this balance entry */ + state: AssetBalanceState; + /** Formatted UI display string (e.g. "50.00 USDC") */ + formattedDisplay: string; +} + +/** + * Represents an asset balance entry whose details or issuer are unparseable or unavailable. + */ +export interface UnknownAssetBalanceItem { + /** Discriminant for unknown or unverified asset */ + type: 'unknown'; + /** Asset code if available, or "UNKNOWN" */ + assetCode: string; + /** Issuer public key if available */ + issuer?: string; + /** Raw balance string if available */ + totalBalance: string; + /** Available balance (default "0") */ + availableBalance: string; + /** Reserved balance (default "0") */ + reservedBalance: string; + /** State is always 'unknown' or 'unavailable' */ + state: 'unknown' | 'unavailable'; + /** Formatted UI display string */ + formattedDisplay: string; +} + +/** + * Union of all asset balance item variants. + */ +export type AssetBalanceItem = + | NativeAssetBalanceItem + | IssuedAssetBalanceItem + | UnknownAssetBalanceItem; + +/** + * Comprehensive multi-asset account balance model. + */ +export interface MultiAssetBalance { + /** Stellar public key (G...) of the account */ + publicKey: string; + /** Overall account status ('funded' | 'unfunded' | 'unavailable' | 'unknown') */ + accountState: AccountBalanceState; + /** Native XLM balance entry (undefined if account is unfunded or unavailable) */ + native?: NativeAssetBalanceItem; + /** Array of issued asset balance entries */ + issuedAssets: IssuedAssetBalanceItem[]; + /** Array of unknown/unparseable balance entries (if any) */ + unknownAssets: UnknownAssetBalanceItem[]; + /** Total number of asset entries held by the account */ + totalAssetCount: number; + /** ISO 8601 timestamp when the balance snapshot was taken */ + updatedAt: string; +} + +/** + * Result of a multi-asset balance query. + */ +export interface MultiAssetBalanceResult { + /** Whether the balance query succeeded */ + success: boolean; + /** The detailed multi-asset balance object */ + balance: MultiAssetBalance; + /** Optional warning messages (e.g. low XLM reserve, many assets) */ + warnings?: string[]; + /** Optional human-readable error message on failure */ + error?: string; +} diff --git a/src/types/index.ts b/src/types/index.ts index 897cda4..30ddcc8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,6 @@ +import { TransactionSummary, TransactionDirection, TransactionStatus } from './transaction'; export * from './transaction'; +export * from './balance'; /** * Stellar PocketPay SDK — Type Definitions @@ -191,29 +193,6 @@ export interface PaymentResult { // ─── Transactions ─────────────────────────────────────────────────────────── -/** A single transaction summary — the SDK's stable typed model for one transaction. */ -export interface TransactionSummary { - /** Transaction hash */ - hash: string; - /** Ledger number */ - ledger: number; - /** ISO 8601 timestamp */ - createdAt: string; - /** Source account public key */ - sourceAccount: string; - /** Fee paid in stroops */ - fee: string; - /** Number of operations in the transaction */ - operationCount: number; - /** Whether the transaction was successful */ - successful: boolean; - /** Optional memo */ - memo?: string; - /** Memo type */ - memoType: string; - /** Horizon paging token (cursor) for this record */ - pagingToken: string; -} /** * @deprecated Use {@link TransactionSummary}. Retained as an alias for * backward compatibility with existing consumers. @@ -269,15 +248,7 @@ export interface PaymentList { // ─── Transaction Filtering ─────────────────────────────────────────────────── /** - * The relative direction of a transaction or payment with respect to a - * reference Stellar account. - * - * - `"incoming"` — value or activity flowed *to* the reference account. - * - `"outgoing"` — value or activity originated *from* the reference account. - * - `"self"` — the reference account is both sender and receiver (e.g. a - * payment where `from === to`). - */ -export type TransactionDirection = 'incoming' | 'outgoing' | 'self'; + /** * Structural shape shared by {@link TransactionSummary} and diff --git a/src/types/transaction.ts b/src/types/transaction.ts index 0f2e5a4..3c914d7 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -4,6 +4,7 @@ export enum TransactionDirection { INCOMING = 'incoming', OUTGOING = 'outgoing', + SELF = 'self', } /** @@ -17,41 +18,62 @@ export enum TransactionStatus { } /** - * Transaction summary for mobile UI + * Transaction summary for mobile UI and Horizon transaction queries */ export interface TransactionSummary { /** Unique transaction identifier */ - id: string; + id?: string; /** Stellar transaction hash */ - txHash: string; + hash?: string; + + /** Stellar transaction hash (alias for txHash) */ + txHash?: string; - /** Transaction direction (incoming/outgoing) */ - direction: TransactionDirection; + /** Ledger sequence number */ + ledger?: number; + + /** Source account public key */ + sourceAccount?: string; + + /** Number of operations in transaction */ + operationCount?: number; + + /** Whether the transaction was successful */ + successful?: boolean; + + /** Memo type */ + memoType?: string; + + /** Horizon paging token (cursor) */ + pagingToken?: string; + + /** Transaction direction (incoming/outgoing/self) */ + direction?: TransactionDirection | 'incoming' | 'outgoing' | 'self'; - /** Amount in the asset's smallest unit (e.g., stroops for XLM) */ - amount: string; + /** Amount in the asset's smallest unit */ + amount?: string; /** Human-readable amount (formatted with proper decimals) */ - amountDisplay: string; + amountDisplay?: string; /** Asset code (XLM, USDC, etc.) */ - asset: string; + asset?: string; /** Counterparty address (sender for incoming, recipient for outgoing) */ - counterparty: string; + counterparty?: string; /** Transaction memo (if any) */ memo?: string; /** Transaction status */ - status: TransactionStatus; + status?: TransactionStatus | 'pending' | 'completed' | 'failed' | 'unknown'; /** ISO timestamp of the transaction */ createdAt: string; /** Human-readable relative time (e.g., "2 hours ago") */ - timeAgo: string; + timeAgo?: string; /** Fee paid for the transaction */ fee?: string; diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 5c66f98..41116a7 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -370,3 +370,14 @@ export async function safeEnhancedGetBalance( }); } +// ─── Multi-Asset Balance Model ────────────────────────────────────────────── +export { + calculateNativeReserves, + parseMultiAssetBalance, + getMultiAssetBalance, + safeGetMultiAssetBalance, + formatAssetBalanceDisplay, + findAssetInMultiBalance, +} from './multi-asset'; + + diff --git a/src/wallet/multi-asset.ts b/src/wallet/multi-asset.ts new file mode 100644 index 0000000..66bea1d --- /dev/null +++ b/src/wallet/multi-asset.ts @@ -0,0 +1,327 @@ +/** + * Stellar PocketPay SDK — Multi-Asset Balance Model Implementation + * + * Provides functions to query, parse, format, and evaluate multi-asset balances + * representing native XLM and issued credit assets distinctively with support for + * available, reserved, unauthorized, unavailable, and unknown states. + */ + +import { + MultiAssetBalance, + MultiAssetBalanceResult, + AssetBalanceItem, + NativeAssetBalanceItem, + IssuedAssetBalanceItem, + UnknownAssetBalanceItem, + AccountBalanceState, + AssetBalanceState, + SDKConfig, + PocketPayResult, +} from '../types'; +import { PocketPayError } from '../types'; +import { validatePublicKey, wrapError, toSuccessResult, toFailureResult } from '../utils'; +import { getHorizonServer, resolveConfig } from '../config'; +import { withTimeout } from '../network'; + +/** + * Calculates Stellar protocol XLM reserves based on subentry count. + * + * Stellar Protocol Base Reserve Formula: + * - Base reserve per entry = 0.5 XLM + * - Minimum account balance = (2 + subentryCount) * 0.5 XLM = 1.0 XLM + subentryCount * 0.5 XLM + * + * @param subentryCount - Number of subentries (trustlines, offers, signers, data entries) + * @returns Object with baseReserve and minimum required XLM balance string + */ +export function calculateNativeReserves(subentryCount: number = 0): { + baseReserve: string; + minBalance: string; +} { + const count = Math.max(0, subentryCount); + const minReserveNum = (2 + count) * 0.5; + return { + baseReserve: '0.5000000', + minBalance: minReserveNum.toFixed(7), + }; +} + +/** + * Pure helper function to parse raw Horizon account data into the typed MultiAssetBalance model. + * Handles funded, unfunded, unavailable, and unknown account and asset states cleanly. + * + * @param publicKey - Stellar public key (G...) + * @param horizonAccountData - Raw Horizon account response object (or null if unfunded/unavailable) + * @param accountState - Account status override ('funded' | 'unfunded' | 'unavailable' | 'unknown') + * @returns Typed {@link MultiAssetBalance} object + */ +export function parseMultiAssetBalance( + publicKey: string, + horizonAccountData?: any, + accountState: AccountBalanceState = 'funded', +): MultiAssetBalance { + const updatedAt = new Date().toISOString(); + + if (accountState === 'unfunded' || !horizonAccountData) { + return { + publicKey, + accountState: accountState === 'funded' ? 'unfunded' : accountState, + issuedAssets: [], + unknownAssets: [], + totalAssetCount: 0, + updatedAt, + }; + } + + if (accountState === 'unavailable') { + return { + publicKey, + accountState: 'unavailable', + issuedAssets: [], + unknownAssets: [], + totalAssetCount: 0, + updatedAt, + }; + } + + const subentryCount = typeof horizonAccountData.subentry_count === 'number' + ? horizonAccountData.subentry_count + : 0; + const reserves = calculateNativeReserves(subentryCount); + const rawBalances: any[] = Array.isArray(horizonAccountData.balances) + ? horizonAccountData.balances + : []; + + let nativeItem: NativeAssetBalanceItem | undefined; + const issuedAssets: IssuedAssetBalanceItem[] = []; + const unknownAssets: UnknownAssetBalanceItem[] = []; + + for (const bal of rawBalances) { + const assetType = bal.asset_type; + + if (assetType === 'native') { + const totalBalance = typeof bal.balance === 'string' ? bal.balance : '0.0000000'; + const sellingLiabilities = typeof bal.selling_liabilities === 'string' ? bal.selling_liabilities : '0.0000000'; + const buyingLiabilities = typeof bal.buying_liabilities === 'string' ? bal.buying_liabilities : '0.0000000'; + + const totalNum = parseFloat(totalBalance); + const minReserveNum = parseFloat(reserves.minBalance); + const sellingNum = parseFloat(sellingLiabilities); + const reservedNum = minReserveNum + sellingNum; + const availableNum = Math.max(0, totalNum - reservedNum); + + const availableBalance = availableNum.toFixed(7); + const reservedBalance = reservedNum.toFixed(7); + + let state: AssetBalanceState = 'available'; + if (availableNum <= 0 && totalNum > 0) { + state = 'reserved'; + } else if (totalNum === 0) { + state = 'unavailable'; + } + + const formattedDisplay = `${availableNum.toFixed(2)} XLM`; + + nativeItem = { + type: 'native', + assetCode: 'XLM', + totalBalance, + availableBalance, + reservedBalance, + sellingLiabilities, + buyingLiabilities, + subentryCount, + state, + formattedDisplay, + }; + } else if (assetType === 'credit_alphanum4' || assetType === 'credit_alphanum12') { + const assetCode = typeof bal.asset_code === 'string' ? bal.asset_code : 'UNKNOWN'; + const issuer = typeof bal.asset_issuer === 'string' ? bal.asset_issuer : ''; + const totalBalance = typeof bal.balance === 'string' ? bal.balance : '0.0000000'; + const sellingLiabilities = typeof bal.selling_liabilities === 'string' ? bal.selling_liabilities : '0.0000000'; + const buyingLiabilities = typeof bal.buying_liabilities === 'string' ? bal.buying_liabilities : '0.0000000'; + const limit = typeof bal.limit === 'string' ? bal.limit : '0.0000000'; + + const isAuthorized = bal.is_authorized !== false && bal.is_authorized_to_maintain_liabilities !== false; + + const totalNum = parseFloat(totalBalance); + const sellingNum = parseFloat(sellingLiabilities); + const availableNum = Math.max(0, totalNum - sellingNum); + + const availableBalance = availableNum.toFixed(7); + const reservedBalance = sellingNum.toFixed(7); + + let state: AssetBalanceState = 'available'; + if (!isAuthorized) { + state = 'unauthorized'; + } else if (availableNum <= 0 && totalNum > 0) { + state = 'reserved'; + } else if (totalNum === 0) { + state = 'available'; + } + + const statusTag = !isAuthorized ? ' (Unauthorized)' : ''; + const formattedDisplay = `${availableNum.toFixed(2)} ${assetCode}${statusTag}`; + + issuedAssets.push({ + type: 'issued', + assetCode, + issuer, + totalBalance, + availableBalance, + reservedBalance, + sellingLiabilities, + buyingLiabilities, + limit, + isAuthorized, + state, + formattedDisplay, + }); + } else { + const assetCode = typeof bal.asset_code === 'string' ? bal.asset_code : 'UNKNOWN'; + const issuer = typeof bal.asset_issuer === 'string' ? bal.asset_issuer : undefined; + const totalBalance = typeof bal.balance === 'string' ? bal.balance : '0.0000000'; + const availableBalance = '0.0000000'; + const reservedBalance = totalBalance; + const state: AssetBalanceState = 'unknown'; + const formattedDisplay = `${totalBalance} ${assetCode} (Unknown)`; + + unknownAssets.push({ + type: 'unknown', + assetCode, + issuer, + totalBalance, + availableBalance, + reservedBalance, + state, + formattedDisplay, + }); + } + } + + const totalAssetCount = (nativeItem ? 1 : 0) + issuedAssets.length + unknownAssets.length; + + return { + publicKey, + accountState: 'funded', + native: nativeItem, + issuedAssets, + unknownAssets, + totalAssetCount, + updatedAt, + }; +} + +/** + * Fetches the comprehensive multi-asset balance model for a Stellar public key. + * + * Represents native XLM (with reserve breakdowns), issued credit assets (with trustlines + * and authorization status), and handles unfunded, unavailable, or unknown states cleanly. + * + * @param publicKey - Stellar public key (G...) to query + * @param config - Optional SDK config overrides + * @returns Promise resolving to {@link MultiAssetBalance} + * @throws {PocketPayError} with code `INVALID_PUBLIC_KEY` or `BALANCE_ERROR` + */ +export async function getMultiAssetBalance( + publicKey: string, + config?: Partial, +): Promise { + validatePublicKey(publicKey); + const server = getHorizonServer(config); + const cfg = resolveConfig(config); + + try { + const accountData = await withTimeout( + 'Horizon account lookup for multi-asset balance', + cfg.timeout, + server.loadAccount(publicKey), + ); + return parseMultiAssetBalance(publicKey, accountData, 'funded'); + } catch (error) { + if (error instanceof Error && (error as any).response?.status === 404) { + return parseMultiAssetBalance(publicKey, null, 'unfunded'); + } + throw wrapError(error, 'Failed to fetch multi-asset balance', 'BALANCE_ERROR'); + } +} + +/** + * Non-throwing safe wrapper for {@link getMultiAssetBalance}. + * + * @param publicKey - Stellar public key (G...) to query + * @param config - Optional SDK config overrides + * @returns Promise resolving to {@link PocketPayResult} containing {@link MultiAssetBalance} + */ +export async function safeGetMultiAssetBalance( + publicKey: string, + config?: Partial, +): Promise> { + try { + const balance = await getMultiAssetBalance(publicKey, config); + return toSuccessResult(balance); + } catch (error) { + const err = error instanceof PocketPayError + ? error + : wrapError(error, 'Failed to fetch multi-asset balance', 'BALANCE_ERROR'); + return toFailureResult(err); + } +} + +/** + * Utility helper to format any asset balance item for mobile/web UI consumers. + * + * @param item - The {@link AssetBalanceItem} to format + * @param decimals - Number of decimal places for display (default: 2) + * @returns Human-readable formatted string (e.g. "97.50 XLM", "50.00 USDC (Unauthorized)") + */ +export function formatAssetBalanceDisplay( + item: AssetBalanceItem, + decimals: number = 2, +): string { + if (item.type === 'native') { + const amount = parseFloat(item.availableBalance); + return `${amount.toFixed(decimals)} XLM`; + } + + if (item.type === 'issued') { + const amount = parseFloat(item.availableBalance); + const auth = !item.isAuthorized ? ' (Unauthorized)' : ''; + return `${amount.toFixed(decimals)} ${item.assetCode}${auth}`; + } + + return `${item.totalBalance} ${item.assetCode} (Unknown)`; +} + +/** + * Searches a {@link MultiAssetBalance} object for a specific asset entry. + * + * @param multiBalance - The {@link MultiAssetBalance} object to search + * @param assetCode - The asset code to find (e.g. "XLM", "USDC") + * @param issuer - Optional asset issuer public key for issued credit assets + * @returns The matching {@link AssetBalanceItem} or `undefined` + */ +export function findAssetInMultiBalance( + multiBalance: MultiAssetBalance, + assetCode: string, + issuer?: string, +): AssetBalanceItem | undefined { + if (assetCode.toUpperCase() === 'XLM' || assetCode === 'native') { + return multiBalance.native; + } + + const issued = multiBalance.issuedAssets.find((item) => { + if (issuer) { + return item.assetCode === assetCode && item.issuer === issuer; + } + return item.assetCode === assetCode; + }); + + if (issued) return issued; + + return multiBalance.unknownAssets.find((item) => { + if (issuer && item.issuer) { + return item.assetCode === assetCode && item.issuer === issuer; + } + return item.assetCode === assetCode; + }); +} diff --git a/tests/multi-asset-balance.test.ts b/tests/multi-asset-balance.test.ts new file mode 100644 index 0000000..05582eb --- /dev/null +++ b/tests/multi-asset-balance.test.ts @@ -0,0 +1,317 @@ +/** + * Tests for Multi-Asset Balance Model + * + * Verifies representation of native XLM, issued assets, reserve calculations, + * status states (available, reserved, unauthorized, unavailable, unknown), + * unfunded account handling, display formatting, and asset search helpers. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + calculateNativeReserves, + parseMultiAssetBalance, + getMultiAssetBalance, + safeGetMultiAssetBalance, + formatAssetBalanceDisplay, + findAssetInMultiBalance, + createWallet, + PocketPayError, +} from '../src'; + +const mockLoadAccount = vi.fn(); + +vi.mock('@stellar/stellar-sdk', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + Horizon: { + ...actual.Horizon, + Server: vi.fn().mockImplementation(() => ({ + loadAccount: mockLoadAccount, + })), + }, + }; +}); + +function makeHorizon404Error(publicKey: string) { + const err = new Error(`Account not found: ${publicKey}`) as any; + err.response = { status: 404 }; + return err; +} + +describe('Multi-Asset Balance Model', () => { + let publicKey: string; + let issuerPublicKey: string; + + beforeEach(() => { + mockLoadAccount.mockReset(); + publicKey = createWallet().publicKey; + issuerPublicKey = createWallet().publicKey; + }); + + describe('calculateNativeReserves', () => { + it('calculates minimum reserve for 0 subentries (1.0 XLM)', () => { + const reserves = calculateNativeReserves(0); + expect(reserves.baseReserve).toBe('0.5000000'); + expect(reserves.minBalance).toBe('1.0000000'); + }); + + it('calculates minimum reserve for 3 subentries (2.5 XLM)', () => { + const reserves = calculateNativeReserves(3); + expect(reserves.minBalance).toBe('2.5000000'); + }); + + it('handles negative or undefined subentries gracefully', () => { + expect(calculateNativeReserves(-1).minBalance).toBe('1.0000000'); + expect(calculateNativeReserves().minBalance).toBe('1.0000000'); + }); + }); + + describe('parseMultiAssetBalance', () => { + it('parses native XLM balance with subentry reserve deduction', () => { + const horizonData = { + subentry_count: 2, + balances: [ + { + asset_type: 'native', + balance: '100.0000000', + selling_liabilities: '5.0000000', + buying_liabilities: '0.0000000', + }, + ], + }; + + const result = parseMultiAssetBalance(publicKey, horizonData, 'funded'); + + expect(result.publicKey).toBe(publicKey); + expect(result.accountState).toBe('funded'); + expect(result.totalAssetCount).toBe(1); + expect(result.native).toBeDefined(); + + if (result.native) { + expect(result.native.type).toBe('native'); + expect(result.native.assetCode).toBe('XLM'); + expect(result.native.totalBalance).toBe('100.0000000'); + // Min reserve for 2 subentries = 2.0 XLM. Total reserved = 2.0 + 5.0 = 7.0 XLM. + expect(result.native.reservedBalance).toBe('7.0000000'); + expect(result.native.availableBalance).toBe('93.0000000'); + expect(result.native.subentryCount).toBe(2); + expect(result.native.state).toBe('available'); + expect(result.native.formattedDisplay).toBe('93.00 XLM'); + } + }); + + it('parses issued assets with authorized and unauthorized states', () => { + const horizonData = { + subentry_count: 2, + balances: [ + { + asset_type: 'native', + balance: '50.0000000', + }, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: issuerPublicKey, + balance: '100.5000000', + limit: '10000.0000000', + is_authorized: true, + is_authorized_to_maintain_liabilities: true, + selling_liabilities: '10.0000000', + }, + { + asset_type: 'credit_alphanum4', + asset_code: 'EURT', + asset_issuer: issuerPublicKey, + balance: '25.0000000', + limit: '500.0000000', + is_authorized: false, + }, + ], + }; + + const result = parseMultiAssetBalance(publicKey, horizonData, 'funded'); + + expect(result.totalAssetCount).toBe(3); + expect(result.issuedAssets).toHaveLength(2); + + const usdc = result.issuedAssets.find((a) => a.assetCode === 'USDC'); + expect(usdc).toBeDefined(); + if (usdc) { + expect(usdc.type).toBe('issued'); + expect(usdc.issuer).toBe(issuerPublicKey); + expect(usdc.totalBalance).toBe('100.5000000'); + expect(usdc.availableBalance).toBe('90.5000000'); // 100.5 - 10.0 + expect(usdc.reservedBalance).toBe('10.0000000'); + expect(usdc.isAuthorized).toBe(true); + expect(usdc.state).toBe('available'); + expect(usdc.formattedDisplay).toBe('90.50 USDC'); + } + + const eurt = result.issuedAssets.find((a) => a.assetCode === 'EURT'); + expect(eurt).toBeDefined(); + if (eurt) { + expect(eurt.isAuthorized).toBe(false); + expect(eurt.state).toBe('unauthorized'); + expect(eurt.formattedDisplay).toBe('25.00 EURT (Unauthorized)'); + } + }); + + it('handles unknown or unparseable asset types gracefully', () => { + const horizonData = { + balances: [ + { + asset_type: 'custom_unknown_type', + asset_code: 'TOKEN', + balance: '12.34', + }, + ], + }; + + const result = parseMultiAssetBalance(publicKey, horizonData, 'funded'); + + expect(result.unknownAssets).toHaveLength(1); + const unknownItem = result.unknownAssets[0]; + expect(unknownItem.type).toBe('unknown'); + expect(unknownItem.assetCode).toBe('TOKEN'); + expect(unknownItem.state).toBe('unknown'); + expect(unknownItem.formattedDisplay).toContain('(Unknown)'); + }); + + it('returns clean unfunded model when account data is missing (404)', () => { + const result = parseMultiAssetBalance(publicKey, null, 'unfunded'); + + expect(result.accountState).toBe('unfunded'); + expect(result.native).toBeUndefined(); + expect(result.issuedAssets).toHaveLength(0); + expect(result.totalAssetCount).toBe(0); + }); + + it('returns unavailable model when account state is unavailable', () => { + const result = parseMultiAssetBalance(publicKey, null, 'unavailable'); + + expect(result.accountState).toBe('unavailable'); + expect(result.native).toBeUndefined(); + expect(result.issuedAssets).toHaveLength(0); + expect(result.totalAssetCount).toBe(0); + }); + }); + + describe('getMultiAssetBalance & safeGetMultiAssetBalance', () => { + it('fetches and parses multi-asset balance from Horizon', async () => { + mockLoadAccount.mockResolvedValue({ + subentry_count: 1, + balances: [ + { asset_type: 'native', balance: '200.0000000' }, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: issuerPublicKey, + balance: '50.0000000', + limit: '1000.0000000', + is_authorized: true, + }, + ], + }); + + const balance = await getMultiAssetBalance(publicKey); + + expect(balance.accountState).toBe('funded'); + expect(balance.native?.availableBalance).toBe('198.5000000'); // 200 - 1.5 min reserve + expect(balance.issuedAssets).toHaveLength(1); + expect(balance.issuedAssets[0].assetCode).toBe('USDC'); + }); + + it('returns unfunded status for 404 response without throwing', async () => { + mockLoadAccount.mockRejectedValue(makeHorizon404Error(publicKey)); + + const balance = await getMultiAssetBalance(publicKey); + expect(balance.accountState).toBe('unfunded'); + expect(balance.totalAssetCount).toBe(0); + }); + + it('safeGetMultiAssetBalance returns SuccessResult when successful', async () => { + mockLoadAccount.mockResolvedValue({ + balances: [{ asset_type: 'native', balance: '50.0000000' }], + }); + + const res = await safeGetMultiAssetBalance(publicKey); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.accountState).toBe('funded'); + expect(res.value.native?.totalBalance).toBe('50.0000000'); + } + }); + + it('safeGetMultiAssetBalance returns FailureResult for invalid public key', async () => { + const res = await safeGetMultiAssetBalance('INVALID_KEY'); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.code).toBe('INVALID_PUBLIC_KEY'); + } + }); + }); + + describe('formatAssetBalanceDisplay & findAssetInMultiBalance', () => { + it('formats native and issued assets properly with custom decimals', () => { + const nativeItem: NativeAssetBalanceItem = { + type: 'native', + assetCode: 'XLM', + totalBalance: '100.0000000', + availableBalance: '97.5000000', + reservedBalance: '2.5000000', + sellingLiabilities: '0.0000000', + buyingLiabilities: '0.0000000', + subentryCount: 3, + state: 'available', + formattedDisplay: '97.50 XLM', + }; + + const issuedItem: IssuedAssetBalanceItem = { + type: 'issued', + assetCode: 'USDC', + issuer: issuerPublicKey, + totalBalance: '50.1234567', + availableBalance: '50.1234567', + reservedBalance: '0.0000000', + sellingLiabilities: '0.0000000', + buyingLiabilities: '0.0000000', + limit: '1000.0000000', + isAuthorized: true, + state: 'available', + formattedDisplay: '50.12 USDC', + }; + + expect(formatAssetBalanceDisplay(nativeItem, 2)).toBe('97.50 XLM'); + expect(formatAssetBalanceDisplay(issuedItem, 4)).toBe('50.1235 USDC'); + }); + + it('finds XLM and issued assets accurately within MultiAssetBalance', () => { + const horizonData = { + subentry_count: 1, + balances: [ + { asset_type: 'native', balance: '100.0000000' }, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: issuerPublicKey, + balance: '50.0000000', + }, + ], + }; + + const multiBalance = parseMultiAssetBalance(publicKey, horizonData, 'funded'); + + const xlm = findAssetInMultiBalance(multiBalance, 'XLM'); + expect(xlm).toBeDefined(); + expect(xlm?.type).toBe('native'); + + const usdc = findAssetInMultiBalance(multiBalance, 'USDC', issuerPublicKey); + expect(usdc).toBeDefined(); + expect(usdc?.type).toBe('issued'); + + const missing = findAssetInMultiBalance(multiBalance, 'NONEXISTENT'); + expect(missing).toBeUndefined(); + }); + }); +});