From 57aadba7f2c8e80d0e253b08dd882d32da935f17 Mon Sep 17 00:00:00 2001 From: VeronicDev Date: Thu, 1 Oct 2026 10:55:29 -0700 Subject: [PATCH] feat: extract shared core package for web and mobile (#1006) - Create packages/core workspace package with platform-agnostic Stellar logic - Network config, validation, formatters, addresses, reserves, operation labels, services - Comprehensive test suite covering primary flow, boundary cases, failure cases - ESLint rules enforcing no DOM/React Native imports in core - Update mobile app to import from @stellar-dev-dashboard/core - Update web app to delegate to core package - Remove duplicated code from mobile/services/stellar.ts - Add core package to pnpm-workspace.yaml - Update documentation: packages/core/README.md, root README.md, CHANGELOG.md --- CHANGELOG.md | 17 + README.md | 21 ++ mobile/package.json | 1 + mobile/src/services/stellar.ts | 334 +++++------------- mobile/src/types/index.ts | 86 +---- mobile/src/utils/formatters.ts | 41 +-- package.json | 1 + packages/core/README.md | 309 ++++++++++++++++ packages/core/__tests__/addresses.test.ts | 132 +++++++ packages/core/__tests__/formatters.test.ts | 152 ++++++++ packages/core/__tests__/networks.test.ts | 121 +++++++ .../core/__tests__/operationLabels.test.ts | 46 +++ packages/core/__tests__/reserves.test.ts | 109 ++++++ packages/core/__tests__/services.test.ts | 250 +++++++++++++ packages/core/__tests__/validation.test.ts | 308 ++++++++++++++++ packages/core/eslint.config.js | 46 +++ packages/core/package.json | 52 +++ packages/core/src/addresses.ts | 206 +++++++++++ packages/core/src/formatters.ts | 61 ++++ packages/core/src/index.ts | 8 + packages/core/src/networks.ts | 128 +++++++ packages/core/src/operationLabels.ts | 32 ++ packages/core/src/reserves.ts | 40 +++ packages/core/src/services.ts | 157 ++++++++ packages/core/src/types/index.ts | 175 +++++++++ packages/core/src/validation.ts | 186 ++++++++++ packages/core/tsconfig.json | 27 ++ packages/core/vite.config.ts | 14 + pnpm-workspace.yaml | 1 + src/lib/stellar/addresses.ts | 205 ++--------- src/lib/stellar/formatters.ts | 44 +-- src/lib/stellar/index.ts | 1 + src/lib/stellar/networks.ts | 121 +------ src/lib/stellar/reserves.ts | 78 ++-- src/lib/validation.ts | 253 +++---------- 35 files changed, 2829 insertions(+), 934 deletions(-) create mode 100644 packages/core/README.md create mode 100644 packages/core/__tests__/addresses.test.ts create mode 100644 packages/core/__tests__/formatters.test.ts create mode 100644 packages/core/__tests__/networks.test.ts create mode 100644 packages/core/__tests__/operationLabels.test.ts create mode 100644 packages/core/__tests__/reserves.test.ts create mode 100644 packages/core/__tests__/services.test.ts create mode 100644 packages/core/__tests__/validation.test.ts create mode 100644 packages/core/eslint.config.js create mode 100644 packages/core/package.json create mode 100644 packages/core/src/addresses.ts create mode 100644 packages/core/src/formatters.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/networks.ts create mode 100644 packages/core/src/operationLabels.ts create mode 100644 packages/core/src/reserves.ts create mode 100644 packages/core/src/services.ts create mode 100644 packages/core/src/types/index.ts create mode 100644 packages/core/src/validation.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vite.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eca0cd9..b6ec92a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Shared core package** ([#1006](https://github.com/Nanle-code/stellar-dev-dashboard/issues/1006)). + Extracted platform-agnostic Stellar logic into `@stellar-dev-dashboard/core` workspace package, eliminating duplicated code between web (`src/lib/stellar/`) and mobile (`mobile/src/services/stellar.ts`) apps. + - `packages/core/src/networks.ts` — Network configuration, `getServer`, `getSorobanServer` + - `packages/core/src/validation.ts` — Address, amount, memo, contract, URL, network validation + - `packages/core/src/formatters.ts` — XLM, address, stroop, instruction, byte, date formatting + - `packages/core/src/addresses.ts` — Address validation, parsing, SEP-29 memo check, federation resolution + - `packages/core/src/reserves.ts` — Account reserve calculations + - `packages/core/src/operationLabels.ts` — Operation type labels + - `packages/core/src/services.ts` — High-level data fetching with in-memory caching + - `packages/core/__tests__/` — Comprehensive test suite (networks, validation, formatters, addresses, reserves, operation labels, services) + - ESLint rules enforcing no DOM/React Native imports in core + - Both web and mobile now import from `@stellar-dev-dashboard/core` + - Updated `pnpm-workspace.yaml` to include core package + - Documentation: `packages/core/README.md`, updated root `README.md` + +### Added + - **Cost attribution by application tag or memo prefix** ([#868](https://github.com/Nanle-code/stellar-dev-dashboard/issues/868)). Attributes transaction fees and asset transfer volumes to developer-defined project tags for project budgeting and threshold alerts. - `src/lib/costThresholdManager.ts` — `CostThresholdManager` implementation for tag definition, memo prefix and regex pattern matching, budget limits, volume tracking, threshold alerts (`ok`, `warning`, `exceeded`), JSON/CSV report exports, and SSR/unsupported environment fallbacks. diff --git a/README.md b/README.md index 8fedbdc9..8611d0dc 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,27 @@ Details and how-tos are in docs-site, not in long root markdown files. Root one-off guides were moved under docs-site/docs so there is one navigable docs home. +## Architecture: Shared Core Package + +Since 2026, platform-agnostic Stellar logic is extracted into a shared workspace package: + +- **`packages/core/`** — `@stellar-dev-dashboard/core` + - Network configuration (`NETWORKS`, `getServer`, `getSorobanServer`) + - Validation (addresses, amounts, memos, contracts, URLs) + - Formatters (XLM, addresses, stroops, dates, relative time) + - Address utilities (validation, resolution, SEP-29 memo check) + - Reserve calculations + - Operation labels + - High-level services (`fetchAccount`, `fetchTransactions`, `fetchNetworkStats`, `fetchXLMPrice`) + - Comprehensive test suite + +- **Web app (`src/`)** — Imports from `@stellar-dev-dashboard/core`, keeps web-specific code (rate limiting, request coalescing, browser storage, network probing) +- **Mobile app (`mobile/`)** — Imports from `@stellar-dev-dashboard/core`, keeps mobile-specific code (AsyncStorage caching, React Native hooks) + +This eliminates parity issues (#883) by ensuring both platforms share identical validation, formatting, and data-access logic. + +See `packages/core/README.md` for full API reference and migration notes. + ## License ### SEP-38 Integration diff --git a/mobile/package.json b/mobile/package.json index f5a0c163..b8ad5002 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -20,6 +20,7 @@ "@react-native-community/netinfo": "^11.4.1", "@react-native-firebase/app": "^21.6.0", "@react-native-firebase/messaging": "^21.6.0", + "@stellar-dev-dashboard/core": "workspace:*", "@stellar/stellar-sdk": "^12.3.0", "react": "^18.3.0", "react-native": "^0.76.0", diff --git a/mobile/src/services/stellar.ts b/mobile/src/services/stellar.ts index 3b4dd047..1425e94b 100644 --- a/mobile/src/services/stellar.ts +++ b/mobile/src/services/stellar.ts @@ -1,66 +1,55 @@ -import * as StellarSdk from '@stellar/stellar-sdk' -import AsyncStorage from '@react-native-async-storage/async-storage' +import * as StellarSdk from '@stellar/stellar-sdk'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + type NetworkName, + type NetworkConfig, + NETWORKS, + getServer as coreGetServer, + getSorobanServer as coreGetSorobanServer, + isValidPublicKey, + shortAddress, + formatXLM, + fetchAccount as coreFetchAccount, + fetchTransactions as coreFetchTransactions, + fetchNetworkStats as coreFetchNetworkStats, + fundTestnetAccount as coreFundTestnetAccount, + fetchXLMPrice as coreFetchXLMPrice, + calculateAccountReserves, + OPERATION_LABELS, + getOperationLabel, + type AccountData, + type NetworkStats, +} from '@stellar-dev-dashboard/core'; + +export { type NetworkName, type NetworkConfig, NETWORKS }; +export { isValidPublicKey, shortAddress, formatXLM }; +export { calculateAccountReserves }; +export { OPERATION_LABELS, getOperationLabel }; +export { StellarSdk }; -export type NetworkName = 'mainnet' | 'testnet' | 'futurenet' | 'local' | 'custom' - -export interface NetworkConfig { - name: string - horizonUrl: string - sorobanUrl?: string - passphrase: string - faucetUrl?: string +export function getServer(network: NetworkName = 'testnet'): StellarSdk.Horizon.Server { + return coreGetServer(network); } -export const NETWORKS: Record = { - mainnet: { - name: 'Mainnet', - horizonUrl: 'https://horizon.stellar.org', - sorobanUrl: 'https://soroban-rpc.stellar.org', - passphrase: StellarSdk.Networks.PUBLIC, - }, - testnet: { - name: 'Testnet', - horizonUrl: 'https://horizon-testnet.stellar.org', - sorobanUrl: 'https://soroban-testnet.stellar.org', - passphrase: StellarSdk.Networks.TESTNET, - faucetUrl: 'https://friendbot.stellar.org', - }, - futurenet: { - name: 'Futurenet', - horizonUrl: 'https://horizon-futurenet.stellar.org', - sorobanUrl: 'https://soroban-futurenet.stellar.org', - passphrase: StellarSdk.Networks.FUTURENET, - faucetUrl: 'https://friendbot-futurenet.stellar.org', - }, - local: { - name: 'Local', - horizonUrl: 'http://localhost:8000', - sorobanUrl: 'http://localhost:8000/soroban/rpc', - passphrase: 'Standalone Network ; February 2017', - }, - custom: { - name: 'Custom', - horizonUrl: '', - sorobanUrl: '', - passphrase: '', - }, +export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.SorobanRpc.Server { + return coreGetSorobanServer(network); } -const CACHE_PREFIX = 'stellar_cache_' -const CACHE_TTL_MS = 5 * 60 * 1000 +const CACHE_PREFIX = 'stellar_cache_'; +const CACHE_TTL_MS = 5 * 60 * 1000; async function getCached(key: string): Promise { try { - const raw = await AsyncStorage.getItem(CACHE_PREFIX + key) - if (!raw) return null - const { data, timestamp } = JSON.parse(raw) + const raw = await AsyncStorage.getItem(CACHE_PREFIX + key); + if (!raw) return null; + const { data, timestamp } = JSON.parse(raw); if (Date.now() - timestamp > CACHE_TTL_MS) { - await AsyncStorage.removeItem(CACHE_PREFIX + key) - return null + await AsyncStorage.removeItem(CACHE_PREFIX + key); + return null; } - return data as T + return data as T; } catch { - return null + return null; } } @@ -69,81 +58,21 @@ async function setCache(key: string, data: unknown): Promise { await AsyncStorage.setItem( CACHE_PREFIX + key, JSON.stringify({ data, timestamp: Date.now() }), - ) + ); } catch {} } -export function getServer(network: NetworkName = 'testnet'): StellarSdk.Horizon.Server { - const config = NETWORKS[network] - return new StellarSdk.Horizon.Server(config.horizonUrl || NETWORKS.testnet.horizonUrl) -} - -export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.SorobanRpc.Server { - const config = NETWORKS[network] - return new StellarSdk.SorobanRpc.Server( - config.sorobanUrl || NETWORKS.testnet.sorobanUrl!, - ) -} - -export function isValidPublicKey(key: string): boolean { - if (!key || typeof key !== 'string') return false - const trimmed = key.trim() - if (StellarSdk.StrKey.isValidEd25519PublicKey(trimmed)) return true - if (trimmed.startsWith('M')) return true - return /^[a-zA-Z0-9._-]+\*[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed) -} - -export function shortAddress(addr: string | null | undefined, chars = 6): string { - if (!addr) return '' - return `${addr.slice(0, chars)}\u2026${addr.slice(-chars)}` -} - -export function formatXLM(amount: string | number): string { - const num = typeof amount === 'string' ? parseFloat(amount) : amount - return num.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 7, - }) -} - export async function fetchAccount( publicKey: string, network: NetworkName = 'testnet', -): Promise { - const cacheKey = `account:${publicKey}:${network}` - const cached = await getCached(cacheKey) - if (cached) return cached - - const server = getServer(network) - const account = await server.loadAccount(publicKey) +): Promise { + const cacheKey = `account:${publicKey}:${network}`; + const cached = await getCached(cacheKey); + if (cached) return cached; - const result = { - id: account.id, - account_id: account.account_id, - sequence: account.sequence, - subentry_count: account.subentry_count, - balances: account.balances.map((b: any) => ({ - asset_type: b.asset_type, - asset_code: b.asset_code, - asset_issuer: b.asset_issuer, - balance: b.balance, - limit: b.limit, - })), - signers: account.signers.map((s: any) => ({ - key: s.key, - type: s.type, - weight: s.weight, - })), - thresholds: { - low_threshold: account.thresholds.low_threshold, - med_threshold: account.thresholds.med_threshold, - high_threshold: account.thresholds.high_threshold, - }, - last_modified_ledger: account.last_modified_ledger, - } - - await setCache(cacheKey, result) - return result + const result = await coreFetchAccount(publicKey, network); + await setCache(cacheKey, result); + return result; } export async function fetchTransactions( @@ -152,23 +81,21 @@ export async function fetchTransactions( limit = 20, cursor: string | null = null, ): Promise<{ - records: any[] - nextCursor: string | null - hasMore: boolean + records: any[]; + nextCursor: string | null; + hasMore: boolean; }> { - const server = getServer(network) - const request = server.transactions().forAccount(publicKey).order('desc').limit(limit) - if (cursor) request.cursor(cursor) - - const txs = await request.call() - const records = txs.records || [] - const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null + const cacheKey = `tx:${publicKey}:${network}:${limit}:${cursor || ''}`; + const cached = await getCached<{ + records: any[]; + nextCursor: string | null; + hasMore: boolean; + }>(cacheKey); + if (cached) return cached; - return { - records, - nextCursor, - hasMore: records.length === limit && !!nextCursor, - } + const result = await coreFetchTransactions(publicKey, network, limit, cursor); + await setCache(cacheKey, result); + return result; } export async function fetchOperations( @@ -177,130 +104,45 @@ export async function fetchOperations( limit = 20, cursor: string | null = null, ): Promise<{ - records: any[] - nextCursor: string | null - hasMore: boolean + records: any[]; + nextCursor: string | null; + hasMore: boolean; }> { - const server = getServer(network) - const request = server.operations().forAccount(publicKey).order('desc').limit(limit) - if (cursor) request.cursor(cursor) + const server = getServer(network); + const request = server.operations().forAccount(publicKey).order('desc').limit(limit); + if (cursor) request.cursor(cursor); - const ops = await request.call() - const records = ops.records || [] - const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null + const ops = await request.call(); + const records = ops.records || []; + const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null; return { records, nextCursor, hasMore: records.length === limit && !!nextCursor, - } + }; } -export async function fetchNetworkStats(network: NetworkName = 'testnet'): Promise<{ - latestLedger: any - feeStats: any -}> { - const cacheKey = `network-stats:${network}` - const cached = await getCached(cacheKey) - if (cached) return cached - - const server = getServer(network) - const [ledger, feeStats] = await Promise.all([ - server.ledgers().order('desc').limit(1).call(), - server.feeStats(), - ]) +export async function fetchNetworkStats(network: NetworkName = 'testnet'): Promise { + const cacheKey = `network-stats:${network}`; + const cached = await getCached(cacheKey); + if (cached) return cached; - const result = { - latestLedger: ledger.records[0], - feeStats, - } - - await setCache(cacheKey, result) - return result + const result = await coreFetchNetworkStats(network); + await setCache(cacheKey, result); + return result; } export async function fundTestnetAccount(publicKey: string): Promise { - const res = await fetch(`${NETWORKS.testnet.faucetUrl}?addr=${publicKey}`) - if (!res.ok) throw new Error('Faucet request failed') - return res.json() + return coreFundTestnetAccount(publicKey); } export async function fetchXLMPrice(): Promise<{ usd: number }> { - const cacheKey = 'xlm-price' - const cached = await getCached(cacheKey) - if (cached) return cached - - const res = await fetch( - 'https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd', - ) - if (!res.ok) throw new Error('Price fetch failed') - const data = await res.json() - - const result = { usd: data?.stellar?.usd ?? 0 } - await setCache(cacheKey, result) - return result -} - -export function calculateAccountReserves(accountData: any, networkStats: any, offerCount = 0) { - const baseReserveStroops = Number(networkStats?.latestLedger?.base_reserve) || 10000000 - const baseReserve = baseReserveStroops / 10000000 - - const assetCount = accountData.balances?.filter((b: any) => b.asset_type !== 'native').length || 0 - const signerCount = - accountData.signers?.filter((s: any) => s.key !== accountData.account_id).length || 0 - const subentryCount = accountData.subentry_count || 0 - - const signerReserve = signerCount * (baseReserve / 2) - const assetReserve = assetCount * (baseReserve / 2) - const offerReserve = offerCount * (baseReserve / 2) - const subentryReserve = subentryCount * (baseReserve / 2) - const totalReserves = baseReserve + signerReserve + assetReserve + offerReserve + subentryReserve - - const xlmBalance = accountData.balances?.find((b: any) => b.asset_type === 'native')?.balance || '0' - const totalBalance = parseFloat(xlmBalance) - const availableBalance = Math.max(0, totalBalance - totalReserves) - - return { - baseReserve, - signerReserve, - assetReserve, - offerReserve, - subentryReserve, - totalReserves, - availableBalance, - totalBalance, - } -} - -export const OPERATION_LABELS: Record = { - create_account: 'Create Account', - payment: 'Payment', - path_payment_strict_send: 'Path Payment (Send)', - path_payment_strict_receive: 'Path Payment (Receive)', - manage_buy_offer: 'Buy Offer', - manage_sell_offer: 'Sell Offer', - create_passive_sell_offer: 'Create Passive Sell Offer', - set_options: 'Set Options', - change_trust: 'Change Trust', - allow_trust: 'Allow Trust', - account_merge: 'Account Merge', - manage_data: 'Manage Data', - bump_sequence: 'Bump Sequence', - create_claimable_balance: 'Create Claimable Balance', - claim_claimable_balance: 'Claim Claimable Balance', - begin_sponsoring_future_reserves: 'Begin Sponsoring Future Reserves', - end_sponsoring_future_reserves: 'End Sponsoring Future Reserves', - revoke_sponsorship: 'Revoke Sponsorship', - clawback: 'Clawback', - clawback_claimable_balance: 'Clawback Claimable Balance', - set_trust_line_flags: 'Set Trustline Flags', - liquidity_pool_deposit: 'Liquidity Pool Deposit', - liquidity_pool_withdraw: 'Liquidity Pool Withdraw', - invoke_host_function: 'Contract Call', - extend_footprint_ttl: 'Extend Footprint TTL', - restore_footprint: 'Restore Footprint', -} - -export function getOperationLabel(type: string): string { - return OPERATION_LABELS[type] || type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) -} + const cacheKey = 'xlm-price'; + const cached = await getCached<{ usd: number }>(cacheKey); + if (cached) return cached; + + const result = await coreFetchXLMPrice(); + await setCache(cacheKey, result); + return result; +} \ No newline at end of file diff --git a/mobile/src/types/index.ts b/mobile/src/types/index.ts index b265681d..7dc9fa74 100644 --- a/mobile/src/types/index.ts +++ b/mobile/src/types/index.ts @@ -1,69 +1,19 @@ -export interface Balances { - asset_type: string - asset_code?: string - asset_issuer?: string - balance: string - limit?: string -} +import type { + Balances, + Signer, + Thresholds, + AccountData, + TransactionRecord, + OperationRecord, + NetworkStats, +} from '@stellar-dev-dashboard/core'; -export interface Signer { - key: string - type: string - weight: number -} - -export interface Thresholds { - low_threshold: number - med_threshold: number - high_threshold: number -} - -export interface AccountData { - id: string - account_id: string - sequence: string - subentry_count: number - balances: Balances[] - signers: Signer[] - thresholds: Thresholds - last_modified_ledger: number -} - -export interface TransactionRecord { - id: string - hash: string - source_account: string - created_at: string - fee_charged: number - fee: number - memo: string | null - memo_type: string | null - operation_count: number - successful: boolean - paging_token: string -} - -export interface OperationRecord { - id: string - type: string - type_i: number - source_account: string - created_at: string - transaction_hash: string - paging_token: string -} - -export interface NetworkStats { - latestLedger: { - sequence: number - closed_at: string - transaction_count: number - operation_count: number - base_reserve: number - } - feeStats: { - fee_charged: { max: string; min: string; p10: string; p20: string; p30: string; p50: string; p80: string; p95: string; p99: string } - max_fee: { max: string; min: string; p10: string; p20: string; p30: string; p50: string; p80: string; p95: string; p99: string } - ledger_capacity_usage: string - } -} +export type { + Balances, + Signer, + Thresholds, + AccountData, + TransactionRecord, + OperationRecord, + NetworkStats, +}; \ No newline at end of file diff --git a/mobile/src/utils/formatters.ts b/mobile/src/utils/formatters.ts index d4041666..c0aa1d5b 100644 --- a/mobile/src/utils/formatters.ts +++ b/mobile/src/utils/formatters.ts @@ -1,40 +1,3 @@ -export function shortAddress(addr: string | null | undefined, chars = 6): string { - if (!addr) return '' - return `${addr.slice(0, chars)}\u2026${addr.slice(-chars)}` -} +import { shortAddress, formatXLM, formatDate, formatRelativeTime } from '@stellar-dev-dashboard/core'; -export function formatXLM(amount: string | number): string { - const num = typeof amount === 'string' ? parseFloat(amount) : amount - return num.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 7, - }) -} - -export function formatDate(dateStr: string): string { - try { - const date = new Date(dateStr) - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - } catch { - return dateStr - } -} - -export function formatRelativeTime(timestamp: number): string { - const diff = Date.now() - timestamp - const seconds = Math.floor(diff / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - const days = Math.floor(hours / 24) - - if (seconds < 60) return 'just now' - if (minutes < 60) return `${minutes}m ago` - if (hours < 24) return `${hours}h ago` - return `${days}d ago` -} +export { shortAddress, formatXLM, formatDate, formatRelativeTime }; \ No newline at end of file diff --git a/package.json b/package.json index 4cb3fad9..9981e590 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@axe-core/react": "^4.11.3", "@sentry/browser": "8.54.0", "@sentry/react": "8.54.0", + "@stellar-dev-dashboard/core": "workspace:*", "@stellar/stellar-sdk": "^17.1.0", "@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs-node": "4.22.0", diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000..13f6cab2 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,309 @@ +# @stellar-dev-dashboard/core + +Platform-agnostic Stellar core logic shared between the web app and mobile app. + +## Overview + +This package contains all platform-agnostic Stellar functionality that was previously duplicated between `src/lib/stellar/` (web) and `mobile/src/services/stellar.ts` (mobile). By extracting this into a shared workspace package, we eliminate parity issues and ensure consistent behavior across platforms. + +## Package Structure + +``` +packages/core/ +├── src/ +│ ├── index.ts # Main exports +│ ├── networks.ts # Network configuration & server creation +│ ├── types/index.ts # Shared TypeScript types +│ ├── validation.ts # Input validation (addresses, amounts, memos, etc.) +│ ├── formatters.ts # Formatting utilities (XLM, addresses, dates, etc.) +│ ├── addresses.ts # Address validation & resolution +│ ├── reserves.ts # Account reserve calculations +│ ├── operationLabels.ts # Operation type labels +│ └── services.ts # High-level data fetching services +├── __tests__/ # Test suite +├── dist/ # Compiled output (generated) +├── package.json +├── tsconfig.json +└── vite.config.ts +``` + +## Installation + +```bash +# Already part of the pnpm workspace +pnpm install +``` + +## Usage + +### Web App (src/) + +```typescript +import { + NETWORKS, + getServer, + getSorobanServer, + validateStellarAddress, + formatXLM, + shortAddress, + calculateAccountReserves, + fetchAccount, + fetchTransactions, + fetchNetworkStats, + OPERATION_LABELS, + getOperationLabel, + type NetworkName, + type AccountData, + type NetworkStats, +} from '@stellar-dev-dashboard/core'; +``` + +### Mobile App (mobile/) + +```typescript +import { + NETWORKS, + getServer, + getSorobanServer, + isValidPublicKey, + shortAddress, + formatXLM, + calculateAccountReserves, + fetchAccount, + fetchTransactions, + fetchNetworkStats, + OPERATION_LABELS, + getOperationLabel, + type NetworkName, + type AccountData, + type NetworkStats, +} from '@stellar-dev-dashboard/core'; +``` + +## API Reference + +### Networks + +#### Types + +```typescript +type NetworkName = 'mainnet' | 'testnet' | 'futurenet' | 'local' | 'custom'; + +interface NetworkConfig { + name: string; + horizonUrl: string; + sorobanUrl?: string; + passphrase: string; + faucetUrl?: string; + customHeaders?: Record; + headers?: Record; + capabilities?: NetworkCapabilities; +} +``` + +#### Constants + +- `NETWORKS` - Record of all network configurations +- `StellarSdk` - Re-exported Stellar SDK + +#### Functions + +- `getNetworkConfig(network: NetworkName): NetworkConfig` - Get network configuration +- `updateCustomNetworkConfig(config: Partial): void` - Update custom network +- `getServer(network?: NetworkName): StellarSdk.Horizon.Server` - Create Horizon server +- `getSorobanServer(network?: NetworkName): StellarSdk.SorobanRpc.Server` - Create Soroban RPC server + +### Validation + +```typescript +interface ValidationResult { + valid: boolean; + errors: string[]; +} + +type MemoType = 'none' | 'text' | 'id' | 'hash' | 'return'; +``` + +#### Functions + +- `validateStellarAddress(value: unknown): ValidationResult` - Validate G..., M..., or federated address +- `validateAmount(value: unknown, min?, max?): ValidationResult` - Validate payment amount (max 7 decimals) +- `validateMemo(value: unknown, type?: MemoType): ValidationResult` - Validate transaction memo +- `validateContractId(value: unknown): ValidationResult` - Validate contract ID (C...) +- `validateNetwork(value: unknown): ValidationResult` - Validate network name +- `validateUrl(value: unknown, required?: boolean): ValidationResult` - Validate HTTP/HTTPS URL +- `validateHorizonUrl(value: unknown): ValidationResult` - Validate Horizon URL +- `validateSorobanUrl(value: unknown, required?: boolean): ValidationResult` - Validate Soroban URL +- `validateNetworkPassphrase(value: unknown, required?: boolean): ValidationResult` - Validate passphrase +- `composeValidations(...results: ValidationResult[]): ValidationResult` - Combine multiple validations + +### Formatters + +- `formatXLM(amount: string | number): string` - Format XLM amount (2-7 decimals, commas) +- `shortAddress(addr: string | null, chars?: number): string` - Truncate address (default 6 chars each side) +- `formatStroops(stroops: unknown): string` - Format stroops as XLM with stroops count +- `formatInstructions(instructions: number): string` - Format instruction count (K/M suffixes) +- `formatBytes(bytes: number): string` - Format bytes (KB/MB suffixes) +- `formatDate(dateStr: string): string` - Format ISO date string +- `formatRelativeTime(timestamp: number): string` - Format relative time (just now, Xm ago, Xh ago, Xd ago) + +### Addresses + +#### Types + +```typescript +interface MemoRequirementResult { + required: boolean; + checked: boolean; + error?: string; +} + +interface ResolvedAddress { + accountId: string; + muxedId?: string; + originalInput: string; + inputType: 'ed25519' | 'muxed' | 'federated'; + federatedAddress?: string; + memoId?: string; + memoType?: string; +} +``` + +#### Functions + +- `isValidEd25519PublicKey(key: string): boolean` - Check G... address +- `isValidMuxedAccount(key: string): boolean` - Check M... address +- `isFederatedAddress(input: string): boolean` - Check name*domain or email format +- `parseMuxedAccount(muxedAddress: string): { masterAccount, muxedId } | null` - Parse muxed account +- `checkDestinationMemoRequirement(destination, network?): Promise` - SEP-29 memo check +- `resolveFederatedAddress(federatedAddress, network?): Promise<{accountId, memoId?, memoType?} | null>` - Resolve federation +- `resolveAddress(input, network?): Promise` - Universal address resolver +- `isValidPublicKey(key: string): boolean` - Validate any supported format (G..., M..., federated) +- `isValidContractId(id: string): boolean` - Validate contract ID (C...) + +### Reserves + +```typescript +interface AccountReserves { + baseReserve: number; + signerReserve: number; + assetReserve: number; + offerReserve: number; + subentryReserve: number; + totalReserves: number; + availableBalance: number; + totalBalance: number; +} +``` + +- `calculateAccountReserves(accountData, networkStats, offerCount?): AccountReserves` - Calculate all reserves + +### Operation Labels + +- `OPERATION_LABELS` - Record mapping operation types to human-readable labels +- `getOperationLabel(type: string): string` - Get label for operation type (formats unknown types) + +### Services + +#### Types + +```typescript +interface TransactionFetchResult { + records: TransactionRecord[]; + nextCursor: string | null; + hasMore: boolean; +} +``` + +#### Functions + +- `fetchAccount(publicKey, network?): Promise` - Fetch account with caching +- `fetchTransactions(publicKey, network?, limit?, cursor?): Promise` - Fetch transactions +- `fetchNetworkStats(network?): Promise` - Fetch network stats +- `fundTestnetAccount(publicKey): Promise` - Fund testnet account via friendbot +- `fetchXLMPrice(): Promise<{usd: number}>` - Fetch XLM price from CoinGecko +- `clearCache(pattern?): void` - Clear internal cache + +## Testing + +```bash +# Run core tests +cd packages/core +pnpm test + +# Run with coverage +pnpm test:coverage + +# Type check +pnpm type-check + +# Lint +pnpm lint +``` + +## Migration Notes + +### From Web App (src/lib/stellar/) + +The following modules now re-export from core: +- `networks.ts` - Core network config, `getServer`, `getSorobanServer` +- `formatters.ts` - Core formatters (`formatXLM`, `shortAddress`, etc.) +- `addresses.ts` - Core address utilities (`isValidPublicKey`, `resolveAddress`, etc.) +- `reserves.ts` - Core `calculateAccountReserves` +- `validation.ts` - Core validators (most functions now delegate to core) + +Web-specific features remain in `src/lib/stellar/`: +- `rateLimitedFetch` - Requires browser APIs (`window`, `fetch`, `AbortController`) +- `probeAllNetworks` - Requires browser APIs +- Custom network auth headers - Uses `sessionStorage` +- Request coalescing & rate limiting - Web-specific infrastructure + +### From Mobile App (mobile/src/services/stellar.ts) + +The mobile app now imports from core and only keeps: +- `AsyncStorage`-based caching wrapper functions +- `fetchOperations` - Not yet in core (Horizon-specific) + +Removed from mobile (now in core): +- `NETWORKS` config +- `getServer` / `getSorobanServer` +- `isValidPublicKey` / `shortAddress` / `formatXLM` +- `fetchAccount` / `fetchTransactions` / `fetchNetworkStats` / `fetchXLMPrice` +- `calculateAccountReserves` +- `OPERATION_LABELS` / `getOperationLabel` + +### Compatibility + +- **Node.js**: >=22 <27 +- **Platforms**: Browser (web), React Native (mobile), Node.js (scripts) +- **No dependencies** on: `window`, `document`, `localStorage`, `sessionStorage`, React, React Native + +### Security + +- No private key handling +- No transaction signing +- Network requests use standard `fetch` (polyfill needed for Node.js <18) +- Input validation on all public functions + +## Development + +```bash +# Build +pnpm run build + +# Watch mode +pnpm run build:watch + +# Run tests in watch mode +pnpm run test:watch +``` + +## Publishing + +```bash +# From workspace root +cd packages/core +pnpm run build +pnpm run test +pnpm publish --access public +``` \ No newline at end of file diff --git a/packages/core/__tests__/addresses.test.ts b/packages/core/__tests__/addresses.test.ts new file mode 100644 index 00000000..a469c448 --- /dev/null +++ b/packages/core/__tests__/addresses.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + isValidEd25519PublicKey, + isValidMuxedAccount, + isFederatedAddress, + parseMuxedAccount, + isValidPublicKey, + isValidContractId, +} from '../src/addresses.js'; +import * as StellarSdk from '@stellar/stellar-sdk'; + +describe('Addresses', () => { + describe('isValidEd25519PublicKey', () => { + it('should validate correct Ed25519 public key', () => { + const key = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + expect(isValidEd25519PublicKey(key)).toBe(true); + }); + + it('should reject invalid key', () => { + expect(isValidEd25519PublicKey('invalid')).toBe(false); + }); + + it('should reject M... key', () => { + expect(isValidEd25519PublicKey('MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBe(false); + }); + }); + + describe('isValidMuxedAccount', () => { + it('should validate correct muxed account', () => { + const muxed = StellarSdk.MuxedAccount.fromAddress( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + '12345' + ); + expect(isValidMuxedAccount(muxed.toString())).toBe(true); + }); + + it('should reject G... key', () => { + expect(isValidMuxedAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBe(false); + }); + + it('should reject invalid format', () => { + expect(isValidMuxedAccount('invalid')).toBe(false); + }); + + it('should reject null/undefined', () => { + expect(isValidMuxedAccount(null as any)).toBe(false); + expect(isValidMuxedAccount(undefined as any)).toBe(false); + }); + }); + + describe('isFederatedAddress', () => { + it('should validate name*domain format', () => { + expect(isFederatedAddress('user*example.com')).toBe(true); + expect(isFederatedAddress('user.name*sub.domain.org')).toBe(true); + }); + + it('should validate email-style format', () => { + expect(isFederatedAddress('user@example.com')).toBe(true); + }); + + it('should reject invalid format', () => { + expect(isFederatedAddress('user')).toBe(false); + expect(isFederatedAddress('user@')).toBe(false); + expect(isFederatedAddress('*example.com')).toBe(false); + expect(isFederatedAddress('user*.com')).toBe(false); + }); + + it('should reject empty', () => { + expect(isFederatedAddress('')).toBe(false); + expect(isFederatedAddress(' ')).toBe(false); + }); + }); + + describe('parseMuxedAccount', () => { + it('should parse valid muxed account', () => { + const muxed = StellarSdk.MuxedAccount.fromAddress( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + '12345' + ); + const result = parseMuxedAccount(muxed.toString()); + expect(result).not.toBeNull(); + expect(result?.masterAccount).toBe('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + expect(result?.muxedId).toBe('12345'); + }); + + it('should return null for invalid muxed', () => { + expect(parseMuxedAccount('invalid')).toBeNull(); + }); + + it('should return null for G... key', () => { + expect(parseMuxedAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBeNull(); + }); + }); + + describe('isValidPublicKey', () => { + it('should accept G... key', () => { + expect(isValidPublicKey('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBe(true); + }); + + it('should accept M... muxed account', () => { + const muxed = StellarSdk.MuxedAccount.fromAddress( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + '12345' + ); + expect(isValidPublicKey(muxed.toString())).toBe(true); + }); + + it('should accept federated address', () => { + expect(isValidPublicKey('user*example.com')).toBe(true); + }); + + it('should reject invalid', () => { + expect(isValidPublicKey('invalid')).toBe(false); + expect(isValidPublicKey('')).toBe(false); + }); + }); + + describe('isValidContractId', () => { + it('should validate correct contract ID', () => { + const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + expect(isValidContractId(contractId)).toBe(true); + }); + + it('should reject G... key', () => { + expect(isValidContractId('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBe(false); + }); + + it('should reject invalid format', () => { + expect(isValidContractId('invalid')).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/__tests__/formatters.test.ts b/packages/core/__tests__/formatters.test.ts new file mode 100644 index 00000000..95b82bcb --- /dev/null +++ b/packages/core/__tests__/formatters.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { + formatXLM, + shortAddress, + formatStroops, + formatInstructions, + formatBytes, + formatDate, + formatRelativeTime, +} from '../src/formatters.js'; + +describe('Formatters', () => { + describe('formatXLM', () => { + it('should format number with 2 decimal places', () => { + expect(formatXLM(10)).toBe('10.00'); + }); + + it('should format string number', () => { + expect(formatXLM('10.5')).toBe('10.50'); + }); + + it('should format with up to 7 decimal places', () => { + expect(formatXLM(10.1234567)).toBe('10.1234567'); + }); + + it('should truncate to 7 decimal places', () => { + expect(formatXLM(10.12345678)).toBe('10.1234568'); + }); + + it('should handle large numbers with commas', () => { + expect(formatXLM(1000000)).toBe('1,000,000.00'); + }); + + it('should handle zero', () => { + expect(formatXLM(0)).toBe('0.00'); + }); + + it('should handle NaN gracefully', () => { + expect(formatXLM(NaN)).toBe('0.00'); + }); + }); + + describe('shortAddress', () => { + it('should shorten long address', () => { + const addr = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + expect(shortAddress(addr)).toBe('GAAAAA…AAWHF'); + }); + + it('should use custom chars', () => { + const addr = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + expect(shortAddress(addr, 4)).toBe('GAAA…AWHF'); + }); + + it('should return empty for null', () => { + expect(shortAddress(null)).toBe(''); + }); + + it('should return empty for undefined', () => { + expect(shortAddress(undefined)).toBe(''); + }); + + it('should return empty for empty string', () => { + expect(shortAddress('')).toBe(''); + }); + + it('should return full address if shorter than 2*chars', () => { + expect(shortAddress('abc', 10)).toBe('abc'); + }); + }); + + describe('formatStroops', () => { + it('should format stroops to XLM', () => { + expect(formatStroops(10000000)).toBe('1.0000000 XLM (10,000,000 stroops)'); + }); + + it('should format number input', () => { + expect(formatStroops(50000000)).toBe('5.0000000 XLM (50,000,000 stroops)'); + }); + + it('should format string input', () => { + expect(formatStroops('10000000')).toBe('1.0000000 XLM (10,000,000 stroops)'); + }); + + it('should handle invalid input', () => { + expect(formatStroops('invalid')).toBe('—'); + }); + }); + + describe('formatInstructions', () => { + it('should format small numbers as-is', () => { + expect(formatInstructions(500)).toBe('500'); + }); + + it('should format thousands with K', () => { + expect(formatInstructions(1500)).toBe('1.50K'); + expect(formatInstructions(100000)).toBe('100.00K'); + }); + + it('should format millions with M', () => { + expect(formatInstructions(1500000)).toBe('1.50M'); + expect(formatInstructions(10000000)).toBe('10.00M'); + }); + }); + + describe('formatBytes', () => { + it('should format bytes as-is', () => { + expect(formatBytes(500)).toBe('500 B'); + }); + + it('should format KB', () => { + expect(formatBytes(1536)).toBe('1.50 KB'); + expect(formatBytes(1048576)).toBe('1024.00 KB'); + }); + + it('should format MB', () => { + expect(formatBytes(1572864)).toBe('1.50 MB'); + }); + }); + + describe('formatDate', () => { + it('should format valid ISO date', () => { + const result = formatDate('2024-01-15T10:30:00Z'); + expect(result).toContain('2024'); + expect(result).toContain('Jan'); + expect(result).toContain('15'); + }); + + it('should return original string for invalid date', () => { + expect(formatDate('invalid-date')).toBe('invalid-date'); + }); + }); + + describe('formatRelativeTime', () => { + const now = Date.now(); + + it('should show "just now" for < 60 seconds', () => { + expect(formatRelativeTime(now - 30000)).toBe('just now'); + }); + + it('should show minutes for < 1 hour', () => { + expect(formatRelativeTime(now - 300000)).toBe('5m ago'); + }); + + it('should show hours for < 24 hours', () => { + expect(formatRelativeTime(now - 7200000)).toBe('2h ago'); + }); + + it('should show days for >= 24 hours', () => { + expect(formatRelativeTime(now - 86400000 * 3)).toBe('3d ago'); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/__tests__/networks.test.ts b/packages/core/__tests__/networks.test.ts new file mode 100644 index 00000000..4a12259b --- /dev/null +++ b/packages/core/__tests__/networks.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + NETWORKS, + type NetworkName, + type NetworkConfig, + getNetworkConfig, + updateCustomNetworkConfig, + getServer, + getSorobanServer, + StellarSdk, +} from '../src/networks.js'; + +describe('Networks', () => { + describe('NETWORKS', () => { + it('should have all required networks', () => { + const requiredNetworks: NetworkName[] = ['mainnet', 'testnet', 'futurenet', 'local', 'custom']; + for (const network of requiredNetworks) { + expect(NETWORKS).toHaveProperty(network); + } + }); + + it('should have correct mainnet config', () => { + expect(NETWORKS.mainnet.name).toBe('Mainnet'); + expect(NETWORKS.mainnet.horizonUrl).toBe('https://horizon.stellar.org'); + expect(NETWORKS.mainnet.sorobanUrl).toBe('https://soroban-rpc.stellar.org'); + expect(NETWORKS.mainnet.passphrase).toBe(StellarSdk.Networks.PUBLIC); + }); + + it('should have correct testnet config with faucet', () => { + expect(NETWORKS.testnet.name).toBe('Testnet'); + expect(NETWORKS.testnet.horizonUrl).toBe('https://horizon-testnet.stellar.org'); + expect(NETWORKS.testnet.faucetUrl).toBe('https://friendbot.stellar.org'); + }); + + it('should have empty custom network by default', () => { + expect(NETWORKS.custom.horizonUrl).toBe(''); + expect(NETWORKS.custom.sorobanUrl).toBe(''); + expect(NETWORKS.custom.passphrase).toBe(''); + }); + }); + + describe('getNetworkConfig', () => { + it('should return network config for valid network', () => { + const config = getNetworkConfig('testnet'); + expect(config.name).toBe('Testnet'); + }); + + it('should return custom network config', () => { + const config = getNetworkConfig('custom'); + expect(config.name).toBe('Custom'); + }); + }); + + describe('updateCustomNetworkConfig', () => { + let originalCustom: NetworkConfig; + + beforeEach(() => { + originalCustom = { ...NETWORKS.custom }; + }); + + afterEach(() => { + NETWORKS.custom = originalCustom; + }); + + it('should update custom network config', () => { + updateCustomNetworkConfig({ + name: 'My Custom', + horizonUrl: 'https://custom.example.com', + passphrase: 'Custom Network Passphrase', + }); + + expect(NETWORKS.custom.name).toBe('My Custom'); + expect(NETWORKS.custom.horizonUrl).toBe('https://custom.example.com'); + expect(NETWORKS.custom.passphrase).toBe('Custom Network Passphrase'); + }); + + it('should preserve existing config when partially updating', () => { + NETWORKS.custom.name = 'Original'; + NETWORKS.custom.horizonUrl = 'https://original.example.com'; + + updateCustomNetworkConfig({ passphrase: 'New Passphrase' }); + + expect(NETWORKS.custom.name).toBe('Original'); + expect(NETWORKS.custom.horizonUrl).toBe('https://original.example.com'); + expect(NETWORKS.custom.passphrase).toBe('New Passphrase'); + }); + }); + + describe('getServer', () => { + it('should return Horizon Server instance for testnet', () => { + const server = getServer('testnet'); + expect(server).toBeInstanceOf(StellarSdk.Horizon.Server); + }); + + it('should return Horizon Server instance for mainnet', () => { + const server = getServer('mainnet'); + expect(server).toBeInstanceOf(StellarSdk.Horizon.Server); + }); + + it('should fallback to testnet for invalid network', () => { + const server = getServer('invalid' as NetworkName); + expect(server).toBeInstanceOf(StellarSdk.Horizon.Server); + }); + }); + + describe('getSorobanServer', () => { + it('should return SorobanRpc Server instance for testnet', () => { + const server = getSorobanServer('testnet'); + expect(server).toBeInstanceOf(StellarSdk.SorobanRpc.Server); + }); + + it('should throw for custom network without sorobanUrl', () => { + const originalSorobanUrl = NETWORKS.custom.sorobanUrl; + NETWORKS.custom.sorobanUrl = ''; + + expect(() => getSorobanServer('custom')).toThrow('Custom Soroban RPC URL not configured'); + + NETWORKS.custom.sorobanUrl = originalSorobanUrl; + }); + }); +}); \ No newline at end of file diff --git a/packages/core/__tests__/operationLabels.test.ts b/packages/core/__tests__/operationLabels.test.ts new file mode 100644 index 00000000..d20e4b31 --- /dev/null +++ b/packages/core/__tests__/operationLabels.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { OPERATION_LABELS, getOperationLabel } from '../src/operationLabels.js'; + +describe('Operation Labels', () => { + describe('OPERATION_LABELS', () => { + it('should have labels for all known operation types', () => { + expect(OPERATION_LABELS.create_account).toBe('Create Account'); + expect(OPERATION_LABELS.payment).toBe('Payment'); + expect(OPERATION_LABELS.path_payment_strict_send).toBe('Path Payment (Send)'); + expect(OPERATION_LABELS.path_payment_strict_receive).toBe('Path Payment (Receive)'); + expect(OPERATION_LABELS.manage_buy_offer).toBe('Buy Offer'); + expect(OPERATION_LABELS.manage_sell_offer).toBe('Sell Offer'); + expect(OPERATION_LABELS.change_trust).toBe('Change Trust'); + expect(OPERATION_LABELS.invoke_host_function).toBe('Contract Call'); + }); + + it('should have Soroban operation labels', () => { + expect(OPERATION_LABELS.liquidity_pool_deposit).toBe('Liquidity Pool Deposit'); + expect(OPERATION_LABELS.liquidity_pool_withdraw).toBe('Liquidity Pool Withdraw'); + expect(OPERATION_LABELS.extend_footprint_ttl).toBe('Extend Footprint TTL'); + expect(OPERATION_LABELS.restore_footprint).toBe('Restore Footprint'); + }); + + it('should have sponsorship labels', () => { + expect(OPERATION_LABELS.begin_sponsoring_future_reserves).toBe('Begin Sponsoring Future Reserves'); + expect(OPERATION_LABELS.end_sponsoring_future_reserves).toBe('End Sponsoring Future Reserves'); + expect(OPERATION_LABELS.revoke_sponsorship).toBe('Revoke Sponsorship'); + }); + }); + + describe('getOperationLabel', () => { + it('should return known label for known operation', () => { + expect(getOperationLabel('payment')).toBe('Payment'); + expect(getOperationLabel('create_account')).toBe('Create Account'); + }); + + it('should format unknown operation type', () => { + expect(getOperationLabel('unknown_operation')).toBe('Unknown Operation'); + expect(getOperationLabel('some_custom_type')).toBe('Some Custom Type'); + }); + + it('should handle empty string', () => { + expect(getOperationLabel('')).toBe(''); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/__tests__/reserves.test.ts b/packages/core/__tests__/reserves.test.ts new file mode 100644 index 00000000..485de6cf --- /dev/null +++ b/packages/core/__tests__/reserves.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { calculateAccountReserves } from '../src/reserves.js'; +import type { AccountData, NetworkStats } from '../src/types/index.js'; + +describe('Reserves', () => { + const mockNetworkStats: NetworkStats = { + latestLedger: { + sequence: 12345, + closed_at: '2024-01-01T00:00:00Z', + transaction_count: 100, + operation_count: 500, + base_reserve: 10000000, // 1 XLM in stroops + }, + feeStats: { + fee_charged: { max: '100', min: '100', p10: '100', p20: '100', p30: '100', p50: '100', p80: '100', p95: '100', p99: '100' }, + max_fee: { max: '100', min: '100', p10: '100', p20: '100', p30: '100', p50: '100', p80: '100', p95: '100', p99: '100' }, + ledger_capacity_usage: '0.1', + }, + }; + + const mockAccountData: AccountData = { + id: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + account_id: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + sequence: '12345', + subentry_count: 5, + balances: [ + { asset_type: 'native', balance: '100.0000000' }, + { asset_type: 'credit_alphanum4', asset_code: 'USDC', asset_issuer: 'G...', balance: '1000.0000000' }, + { asset_type: 'credit_alphanum4', asset_code: 'EURC', asset_issuer: 'G...', balance: '500.0000000' }, + ], + signers: [ + { key: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', type: 'ed25519_public_key', weight: 1 }, + { key: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF', type: 'ed25519_public_key', weight: 1 }, + ], + thresholds: { low_threshold: 1, med_threshold: 1, high_threshold: 1 }, + last_modified_ledger: 12345, + }; + + describe('calculateAccountReserves', () => { + it('should calculate reserves correctly', () => { + const result = calculateAccountReserves(mockAccountData, mockNetworkStats, 2); + + expect(result.baseReserve).toBe(1); + expect(result.signerReserve).toBe(0.5); // 1 extra signer * 0.5 + expect(result.assetReserve).toBe(1); // 2 assets * 0.5 + expect(result.offerReserve).toBe(1); // 2 offers * 0.5 + expect(result.subentryReserve).toBe(2.5); // 5 subentries * 0.5 + expect(result.totalReserves).toBe(6); // 1 + 0.5 + 1 + 1 + 2.5 + expect(result.totalBalance).toBe(100); + expect(result.availableBalance).toBe(94); // 100 - 6 + }); + + it('should handle zero offer count', () => { + const result = calculateAccountReserves(mockAccountData, mockNetworkStats, 0); + expect(result.offerReserve).toBe(0); + }); + + it('should use default base reserve when networkStats is null', () => { + const result = calculateAccountReserves(mockAccountData, null, 0); + expect(result.baseReserve).toBe(1); + }); + + it('should handle account with no non-native assets', () => { + const accountData: AccountData = { + ...mockAccountData, + balances: [{ asset_type: 'native', balance: '50.0000000' }], + }; + const result = calculateAccountReserves(accountData, mockNetworkStats, 0); + expect(result.assetReserve).toBe(0); + }); + + it('should handle account with no extra signers', () => { + const accountData: AccountData = { + ...mockAccountData, + signers: [{ key: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', type: 'ed25519_public_key', weight: 1 }], + }; + const result = calculateAccountReserves(accountData, mockNetworkStats, 0); + expect(result.signerReserve).toBe(0); + }); + + it('should not go below zero for available balance', () => { + const accountData: AccountData = { + ...mockAccountData, + balances: [{ asset_type: 'native', balance: '1.0000000' }], + }; + const result = calculateAccountReserves(accountData, mockNetworkStats, 10); + expect(result.availableBalance).toBe(0); + }); + + it('should handle missing balances gracefully', () => { + const accountData: AccountData = { + ...mockAccountData, + balances: undefined as any, + }; + const result = calculateAccountReserves(accountData, mockNetworkStats, 0); + expect(result.assetReserve).toBe(0); + expect(result.totalBalance).toBe(0); + }); + + it('should handle missing signers gracefully', () => { + const accountData: AccountData = { + ...mockAccountData, + signers: undefined as any, + }; + const result = calculateAccountReserves(accountData, mockNetworkStats, 0); + expect(result.signerReserve).toBe(0); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/__tests__/services.test.ts b/packages/core/__tests__/services.test.ts new file mode 100644 index 00000000..e89b56b0 --- /dev/null +++ b/packages/core/__tests__/services.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + fetchAccount, + fetchTransactions, + fetchNetworkStats, + fundTestnetAccount, + fetchXLMPrice, + clearCache, +} from '../src/services.js'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import type { AccountData, NetworkStats, TransactionRecord } from '../src/types/index.js'; + +// Mock StellarSdk +vi.mock('@stellar/stellar-sdk', () => { + return { + Horizon: { + Server: vi.fn().mockImplementation(() => ({ + loadAccount: vi.fn(), + transactions: vi.fn().mockReturnThis(), + forAccount: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + cursor: vi.fn().mockReturnThis(), + call: vi.fn(), + operations: vi.fn().mockReturnThis(), + ledgers: vi.fn().mockReturnThis(), + feeStats: vi.fn(), + })), + }, + SorobanRpc: { + Server: vi.fn(), + }, + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + FUTURENET: 'Test SDF Future Network ; October 2022', + }, + }; +}); + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('Services', () => { + const mockAccountData: AccountData = { + id: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + account_id: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + sequence: '12345', + subentry_count: 5, + balances: [ + { asset_type: 'native', balance: '100.0000000' }, + { asset_type: 'credit_alphanum4', asset_code: 'USDC', asset_issuer: 'G...', balance: '1000.0000000' }, + ], + signers: [ + { key: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', type: 'ed25519_public_key', weight: 1 }, + ], + thresholds: { low_threshold: 1, med_threshold: 1, high_threshold: 1 }, + last_modified_ledger: 12345, + }; + + const mockNetworkStats: NetworkStats = { + latestLedger: { + sequence: 12345, + closed_at: '2024-01-01T00:00:00Z', + transaction_count: 100, + operation_count: 500, + base_reserve: 10000000, + }, + feeStats: { + fee_charged: { max: '100', min: '100', p10: '100', p20: '100', p30: '100', p50: '100', p80: '100', p95: '100', p99: '100' }, + max_fee: { max: '100', min: '100', p10: '100', p20: '100', p30: '100', p50: '100', p80: '100', p95: '100', p99: '100' }, + ledger_capacity_usage: '0.1', + }, + }; + + const mockTransactions: TransactionRecord[] = [ + { + id: 'tx1', + hash: 'hash1', + source_account: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + created_at: '2024-01-01T00:00:00Z', + fee_charged: 100, + fee: 100, + memo: 'test', + memo_type: 'text', + operation_count: 1, + successful: true, + paging_token: 'token1', + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + clearCache(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('fetchAccount', () => { + it('should fetch and return account data', async () => { + const mockServer = new StellarSdk.Horizon.Server(''); + (mockServer.loadAccount as any).mockResolvedValue({ + id: mockAccountData.id, + account_id: mockAccountData.account_id, + sequence: mockAccountData.sequence, + subentry_count: mockAccountData.subentry_count, + balances: mockAccountData.balances, + signers: mockAccountData.signers, + thresholds: mockAccountData.thresholds, + last_modified_ledger: mockAccountData.last_modified_ledger, + }); + + const result = await fetchAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', 'testnet'); + + expect(result).toEqual(mockAccountData); + expect(mockServer.loadAccount).toHaveBeenCalledWith('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + }); + + it('should cache and return cached result on second call', async () => { + const mockServer = new StellarSdk.Horizon.Server(''); + (mockServer.loadAccount as any).mockResolvedValue({ + id: mockAccountData.id, + account_id: mockAccountData.account_id, + sequence: mockAccountData.sequence, + subentry_count: mockAccountData.subentry_count, + balances: mockAccountData.balances, + signers: mockAccountData.signers, + thresholds: mockAccountData.thresholds, + last_modified_ledger: mockAccountData.last_modified_ledger, + }); + + await fetchAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', 'testnet'); + const result = await fetchAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', 'testnet'); + + expect(result).toEqual(mockAccountData); + expect(mockServer.loadAccount).toHaveBeenCalledTimes(1); + }); + }); + + describe('fetchTransactions', () => { + it('should fetch and return transactions', async () => { + const mockServer = new StellarSdk.Horizon.Server(''); + const mockCall = vi.fn().mockResolvedValue({ + records: [ + { + id: 'tx1', + hash: 'hash1', + source_account: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + created_at: '2024-01-01T00:00:00Z', + fee_charged: 100, + fee: 100, + memo: 'test', + memo_type: 'text', + operation_count: 1, + successful: true, + paging_token: 'token1', + }, + ], + }); + (mockServer.transactions().forAccount().order().limit().call as any) = mockCall; + + const result = await fetchTransactions('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', 'testnet', 20); + + expect(result.records).toHaveLength(1); + expect(result.records[0].id).toBe('tx1'); + expect(result.hasMore).toBe(false); + }); + }); + + describe('fetchNetworkStats', () => { + it('should fetch and return network stats', async () => { + const mockServer = new StellarSdk.Horizon.Server(''); + (mockServer.ledgers().order().limit().call as any).mockResolvedValue({ + records: [{ sequence: 12345, closed_at: '2024-01-01T00:00:00Z', transaction_count: 100, operation_count: 500, base_reserve: 10000000 }], + }); + (mockServer.feeStats as any).mockResolvedValue(mockNetworkStats.feeStats); + + const result = await fetchNetworkStats('testnet'); + + expect(result.latestLedger.sequence).toBe(12345); + expect(result.feeStats).toEqual(mockNetworkStats.feeStats); + }); + }); + + describe('fundTestnetAccount', () => { + it('should call friendbot and return result', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); + + const result = await fundTestnetAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + + expect(result).toEqual({ success: true }); + expect(mockFetch).toHaveBeenCalledWith('https://friendbot.stellar.org?addr=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + }); + + it('should throw on failed request', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }); + + await expect(fundTestnetAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')) + .rejects.toThrow('Faucet request failed'); + }); + }); + + describe('fetchXLMPrice', () => { + it('should fetch and return XLM price', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ stellar: { usd: 0.12 } }), + }); + + const result = await fetchXLMPrice(); + + expect(result).toEqual({ usd: 0.12 }); + }); + + it('should throw on failed request', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }); + + await expect(fetchXLMPrice()).rejects.toThrow('Price fetch failed'); + }); + }); + + describe('clearCache', () => { + it('should clear all cache when no pattern', async () => { + const mockServer = new StellarSdk.Horizon.Server(''); + (mockServer.loadAccount as any).mockResolvedValue({ + id: mockAccountData.id, + account_id: mockAccountData.account_id, + sequence: mockAccountData.sequence, + subentry_count: mockAccountData.subentry_count, + balances: mockAccountData.balances, + signers: mockAccountData.signers, + thresholds: mockAccountData.thresholds, + last_modified_ledger: mockAccountData.last_modified_ledger, + }); + + await fetchAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', 'testnet'); + clearCache(); + + // Should fetch again after clear + await fetchAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', 'testnet'); + expect(mockServer.loadAccount).toHaveBeenCalledTimes(2); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/__tests__/validation.test.ts b/packages/core/__tests__/validation.test.ts new file mode 100644 index 00000000..86c29eec --- /dev/null +++ b/packages/core/__tests__/validation.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect } from 'vitest'; +import { + validateStellarAddress, + validateAmount, + validateMemo, + validateContractId, + validateNetwork, + validateUrl, + validateHorizonUrl, + validateSorobanUrl, + validateNetworkPassphrase, + composeValidations, + type ValidationResult, +} from '../src/validation.js'; + +describe('Validation', () => { + describe('validateStellarAddress', () => { + it('should validate G... Ed25519 public key', () => { + const result = validateStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should validate M... muxed account', () => { + const result = validateStellarAddress('MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + expect(result.valid).toBe(true); + }); + + it('should validate federated address (name*domain)', () => { + const result = validateStellarAddress('user*example.com'); + expect(result.valid).toBe(true); + }); + + it('should reject empty string', () => { + const result = validateStellarAddress(''); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Stellar address is required.'); + }); + + it('should reject invalid format', () => { + const result = validateStellarAddress('invalid-address'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Invalid Stellar address. Must be a G... address, M... muxed account, or name*domain federated address.'); + }); + + it('should reject whitespace-only', () => { + const result = validateStellarAddress(' '); + expect(result.valid).toBe(false); + }); + + it('should trim whitespace', () => { + const result = validateStellarAddress(' GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF '); + expect(result.valid).toBe(true); + }); + }); + + describe('validateAmount', () => { + it('should validate positive number', () => { + const result = validateAmount('10.5'); + expect(result.valid).toBe(true); + }); + + it('should validate integer', () => { + const result = validateAmount(100); + expect(result.valid).toBe(true); + }); + + it('should reject zero', () => { + const result = validateAmount('0'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Amount must be greater than zero.'); + }); + + it('should reject negative number', () => { + const result = validateAmount('-10'); + expect(result.valid).toBe(false); + }); + + it('should reject empty string', () => { + const result = validateAmount(''); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Amount is required.'); + }); + + it('should reject null/undefined', () => { + expect(validateAmount(null).valid).toBe(false); + expect(validateAmount(undefined).valid).toBe(false); + }); + + it('should reject NaN', () => { + const result = validateAmount('not-a-number'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Amount must be a valid number.'); + }); + + it('should reject too many decimal places', () => { + const result = validateAmount('10.12345678'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Amount must have at most 7 decimal places.'); + }); + + it('should accept up to 7 decimal places', () => { + const result = validateAmount('10.1234567'); + expect(result.valid).toBe(true); + }); + + it('should respect min parameter', () => { + const result = validateAmount('0.5', 1); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Amount must be at least 1.'); + }); + + it('should respect max parameter', () => { + const result = validateAmount('100', 0, 50); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Amount must not exceed 50.'); + }); + }); + + describe('validateMemo', () => { + it('should accept empty memo for non-none types', () => { + expect(validateMemo('', 'text').valid).toBe(true); + expect(validateMemo(null, 'id').valid).toBe(true); + expect(validateMemo(undefined, 'hash').valid).toBe(true); + }); + + it('should accept any value for none type', () => { + expect(validateMemo('anything', 'none').valid).toBe(true); + }); + + it('should validate text memo <= 28 bytes', () => { + expect(validateMemo('short memo', 'text').valid).toBe(true); + expect(validateMemo('a'.repeat(28), 'text').valid).toBe(true); + }); + + it('should reject text memo > 28 bytes', () => { + const result = validateMemo('a'.repeat(29), 'text'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Memo text must be 28 bytes or fewer.'); + }); + + it('should validate memo ID as uint64', () => { + expect(validateMemo('12345', 'id').valid).toBe(true); + expect(validateMemo('0', 'id').valid).toBe(true); + expect(validateMemo('18446744073709551615', 'id').valid).toBe(true); + }); + + it('should reject invalid memo ID', () => { + expect(validateMemo('abc', 'id').valid).toBe(false); + expect(validateMemo('-1', 'id').valid).toBe(false); + expect(validateMemo('18446744073709551616', 'id').valid).toBe(false); + }); + + it('should validate hash memo as 64 hex chars', () => { + const validHash = 'a'.repeat(64); + expect(validateMemo(validHash, 'hash').valid).toBe(true); + expect(validateMemo(validHash.toUpperCase(), 'hash').valid).toBe(true); + }); + + it('should reject invalid hash memo', () => { + expect(validateMemo('abc', 'hash').valid).toBe(false); + expect(validateMemo('g'.repeat(64), 'hash').valid).toBe(false); + expect(validateMemo('a'.repeat(63), 'hash').valid).toBe(false); + expect(validateMemo('a'.repeat(65), 'hash').valid).toBe(false); + }); + + it('should validate return memo same as hash', () => { + const validHash = 'b'.repeat(64); + expect(validateMemo(validHash, 'return').valid).toBe(true); + }); + + it('should reject unsupported memo type', () => { + const result = validateMemo('test', 'unsupported' as any); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Unsupported memo type: unsupported.'); + }); + }); + + describe('validateContractId', () => { + it('should validate C... contract ID', () => { + const result = validateContractId('CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + expect(result.valid).toBe(true); + }); + + it('should reject empty', () => { + expect(validateContractId('').valid).toBe(false); + }); + + it('should reject invalid format', () => { + expect(validateContractId('GAAAA...').valid).toBe(false); + expect(validateContractId('invalid').valid).toBe(false); + }); + }); + + describe('validateNetwork', () => { + it('should accept testnet', () => { + expect(validateNetwork('testnet').valid).toBe(true); + }); + + it('should accept mainnet', () => { + expect(validateNetwork('mainnet').valid).toBe(true); + }); + + it('should accept futurenet', () => { + expect(validateNetwork('futurenet').valid).toBe(true); + }); + + it('should accept local', () => { + expect(validateNetwork('local').valid).toBe(true); + }); + + it('should accept custom', () => { + expect(validateNetwork('custom').valid).toBe(true); + }); + + it('should reject invalid network', () => { + const result = validateNetwork('invalid'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Network must be one of: testnet, mainnet, futurenet, local, custom.'); + }); + }); + + describe('validateUrl', () => { + it('should accept valid HTTP URL', () => { + expect(validateUrl('http://example.com').valid).toBe(true); + }); + + it('should accept valid HTTPS URL', () => { + expect(validateUrl('https://example.com').valid).toBe(true); + }); + + it('should reject non-HTTP protocols', () => { + expect(validateUrl('ftp://example.com').valid).toBe(false); + expect(validateUrl('ws://example.com').valid).toBe(false); + }); + + it('should reject invalid URL format', () => { + expect(validateUrl('not-a-url').valid).toBe(false); + expect(validateUrl('http://').valid).toBe(false); + }); + + it('should handle required=false', () => { + expect(validateUrl('', false).valid).toBe(true); + expect(validateUrl(null, false).valid).toBe(true); + expect(validateUrl(undefined, false).valid).toBe(true); + }); + + it('should require URL when required=true', () => { + expect(validateUrl('', true).valid).toBe(false); + expect(validateUrl(null, true).valid).toBe(false); + }); + }); + + describe('validateHorizonUrl', () => { + it('should accept valid horizon URL', () => { + expect(validateHorizonUrl('https://horizon-testnet.stellar.org').valid).toBe(true); + }); + + it('should accept custom horizon URL', () => { + expect(validateHorizonUrl('https://custom.example.com/horizon').valid).toBe(true); + }); + }); + + describe('validateSorobanUrl', () => { + it('should accept valid soroban URL', () => { + expect(validateSorobanUrl('https://soroban-testnet.stellar.org').valid).toBe(true); + }); + + it('should accept optional when not required', () => { + expect(validateSorobanUrl('', false).valid).toBe(true); + }); + }); + + describe('validateNetworkPassphrase', () => { + it('should accept non-empty string', () => { + expect(validateNetworkPassphrase('Test SDF Network ; September 2015').valid).toBe(true); + }); + + it('should reject empty when required', () => { + expect(validateNetworkPassphrase('', true).valid).toBe(false); + }); + + it('should accept empty when not required', () => { + expect(validateNetworkPassphrase('', false).valid).toBe(true); + }); + }); + + describe('composeValidations', () => { + it('should combine valid results', () => { + const result = composeValidations( + { valid: true, errors: [] }, + { valid: true, errors: [] } + ); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should combine errors from invalid results', () => { + const result = composeValidations( + { valid: false, errors: ['Error 1'] }, + { valid: false, errors: ['Error 2', 'Error 3'] }, + { valid: true, errors: [] } + ); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(['Error 1', 'Error 2', 'Error 3']); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/eslint.config.js b/packages/core/eslint.config.js new file mode 100644 index 00000000..20bb585e --- /dev/null +++ b/packages/core/eslint.config.js @@ -0,0 +1,46 @@ +export default [ + { + files: ['src/**/*.ts', '__tests__/**/*.ts'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + fetch: 'readonly', + AbortController: 'readonly', + TextEncoder: 'readonly', + BigInt: 'readonly', + Buffer: 'readonly', + }, + }, + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['react', 'react-native', 'react-dom'], + message: 'Core package must not import React or React Native', + }, + { + group: ['@react-native-*', '@react-navigation/*'], + message: 'Core package must not import React Native packages', + }, + { + group: ['window', 'document', 'navigator', 'localStorage', 'sessionStorage'], + message: 'Core package must not use browser globals directly', + }, + ], + }, + ], + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'prefer-const': 'error', + 'no-var': 'error', + }, + }, + { + ignores: ['dist/', 'node_modules/', '*.config.*'], + }, +]; \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..5b397716 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,52 @@ +{ + "name": "@stellar-dev-dashboard/core", + "version": "0.1.0", + "description": "Platform-agnostic Stellar core logic shared between web and mobile apps", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "type-check": "tsc --noEmit", + "lint": "eslint src --ext .ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "prepublishOnly": "pnpm run build && pnpm run test" + }, + "keywords": [ + "stellar", + "blockchain", + "horizon", + "soroban", + "shared" + ], + "author": "Stellar Dev Dashboard Team", + "license": "MIT", + "dependencies": { + "@stellar/stellar-sdk": "^17.1.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.6.0", + "vitest": "^2.1.9", + "@vitest/coverage-v8": "^2.1.9", + "eslint": "^9.0.0", + "globals": "^17.6.0" + }, + "engines": { + "node": ">=22 <27" + }, + "publishConfig": { + "access": "public" + } +} \ No newline at end of file diff --git a/packages/core/src/addresses.ts b/packages/core/src/addresses.ts new file mode 100644 index 00000000..af0f4606 --- /dev/null +++ b/packages/core/src/addresses.ts @@ -0,0 +1,206 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { getServer, type NetworkName } from './networks.js'; + +export function isValidEd25519PublicKey(key: string): boolean { + return StellarSdk.StrKey.isValidEd25519PublicKey(key); +} + +export function isValidMuxedAccount(key: string): boolean { + if (!key || typeof key !== 'string') return false; + const trimmed = key.trim(); + if (!trimmed.startsWith('M')) return false; + try { + StellarSdk.MuxedAccount.fromAddress(trimmed, '0'); + return true; + } catch { + return false; + } +} + +export function isFederatedAddress(input: string): boolean { + if (typeof input !== 'string' || !input.trim()) return false; + const trimmed = input.trim(); + return ( + /^[a-zA-Z0-9._-]+\*[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed) || + /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed) + ); +} + +export function parseMuxedAccount( + muxedAddress: string +): { masterAccount: string; muxedId: string } | null { + try { + const muxed = StellarSdk.MuxedAccount.fromAddress(muxedAddress, '0'); + return { + masterAccount: muxed.baseAccount().accountId(), + muxedId: muxed.id(), + }; + } catch { + return null; + } +} + +export async function checkDestinationMemoRequirement( + destination: string, + network: NetworkName = 'testnet' +): Promise { + if (typeof destination !== 'string' || !destination.trim()) { + return { required: false, checked: false, error: 'No destination provided.' }; + } + const trimmed = destination.trim(); + + if (isValidMuxedAccount(trimmed)) { + return { required: false, checked: true }; + } + + if (!isValidEd25519PublicKey(trimmed)) { + return { required: false, checked: false, error: 'Destination is not a directly checkable account address.' }; + } + + try { + const server = getServer(network); + const account = await server.loadAccount(trimmed); + const raw = (account as unknown as { data_attr?: Record }).data_attr?.['config.memo_required']; + if (!raw) return { required: false, checked: true }; + const decoded = typeof atob === 'function' ? atob(raw) : Buffer.from(raw, 'base64').toString('utf8'); + return { required: decoded === '1', checked: true }; + } catch (error: any) { + if (error?.response?.status === 404) { + return { required: false, checked: true }; + } + return { + required: false, + checked: false, + error: error?.message || 'Failed to check destination memo requirement.', + }; + } +} + +export async function resolveFederatedAddress( + federatedAddress: string, + network: NetworkName = 'testnet' +): Promise<{ accountId: string; memoId?: string; memoType?: string } | null> { + try { + const [name, domain] = federatedAddress.split('*'); + + if (!name || !domain) { + return null; + } + + const federationUrl = `https://${domain}/.well-known/stellar.toml`; + + let tomlData: Record = {}; + try { + const tomlResponse = await fetch(federationUrl); + if (!tomlResponse.ok) { + return null; + } + + const tomlText = await tomlResponse.text(); + const federationServerMatch = tomlText.match(/FEDERATION_SERVER\s*=\s*"([^"]+)"/); + if (federationServerMatch) { + tomlData.federationServer = federationServerMatch[1]; + } + } catch { + return null; + } + + if (tomlData.federationServer) { + const federationEndpoint = new URL(tomlData.federationServer); + federationEndpoint.searchParams.append('q', federatedAddress); + federationEndpoint.searchParams.append('type', 'name'); + + const response = await fetch(federationEndpoint.toString()); + if (response.ok) { + return await response.json(); + } + } + + return null; + } catch { + return null; + } +} + +export interface MemoRequirementResult { + required: boolean; + checked: boolean; + error?: string; +} + +export interface ResolvedAddress { + accountId: string; + muxedId?: string; + originalInput: string; + inputType: 'ed25519' | 'muxed' | 'federated'; + federatedAddress?: string; + memoId?: string; + memoType?: string; +} + +export async function resolveAddress( + input: string, + network: NetworkName = 'testnet' +): Promise { + if (!input || typeof input !== 'string') { + return null; + } + + const trimmedInput = input.trim(); + + if (isValidEd25519PublicKey(trimmedInput)) { + return { + accountId: trimmedInput, + originalInput: trimmedInput, + inputType: 'ed25519', + }; + } + + if (isValidMuxedAccount(trimmedInput)) { + const parsed = parseMuxedAccount(trimmedInput); + if (parsed) { + return { + accountId: parsed.masterAccount, + muxedId: parsed.muxedId, + originalInput: trimmedInput, + inputType: 'muxed', + }; + } + } + + if (isFederatedAddress(trimmedInput)) { + const resolved = await resolveFederatedAddress(trimmedInput, network); + if (resolved?.accountId) { + return { + accountId: resolved.accountId, + originalInput: trimmedInput, + inputType: 'federated', + federatedAddress: trimmedInput, + memoId: resolved.memoId, + memoType: resolved.memoType, + }; + } + } + + return null; +} + +export function isValidPublicKey(key: string): boolean { + if (!key || typeof key !== 'string') return false; + const trimmed = key.trim(); + + if (isValidEd25519PublicKey(trimmed)) return true; + if (isValidMuxedAccount(trimmed)) return true; + if (isFederatedAddress(trimmed)) return true; + + return false; +} + +export function isValidContractId(id: string): boolean { + try { + StellarSdk.Address.fromString(id); + return true; + } catch { + return false; + } +} \ No newline at end of file diff --git a/packages/core/src/formatters.ts b/packages/core/src/formatters.ts new file mode 100644 index 00000000..8455547f --- /dev/null +++ b/packages/core/src/formatters.ts @@ -0,0 +1,61 @@ +export function formatXLM(amount: string | number): string { + const num = typeof amount === 'string' ? parseFloat(amount) : amount; + if (isNaN(num)) return '0.00'; + return num.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 7, + }); +} + +export function shortAddress(addr: string | null | undefined, chars = 6): string { + if (!addr) return ''; + if (addr.length <= chars * 2) return addr; + return `${addr.slice(0, chars)}…${addr.slice(-chars)}`; +} + +export function formatStroops(stroops: unknown): string { + const num = typeof stroops === 'number' ? stroops : parseInt(String(stroops), 10); + if (isNaN(num)) return '—'; + const xlm = (num / 10000000).toFixed(7); + return `${xlm} XLM (${num.toLocaleString('en-US')} stroops)`; +} + +export function formatInstructions(instructions: number): string { + if (instructions < 1000) return `${instructions}`; + if (instructions < 1000000) return `${(instructions / 1000).toFixed(2)}K`; + return `${(instructions / 1000000).toFixed(2)}M`; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +export function formatDate(dateStr: string): string { + try { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateStr; + } +} + +export function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + return `${days}d ago`; +} \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..6d88da0d --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,8 @@ +export * from './networks.js'; +export * from './types/index.js'; +export * from './validation.js'; +export * from './formatters.js'; +export * from './addresses.js'; +export * from './reserves.js'; +export * from './operationLabels.js'; +export * from './services.js'; \ No newline at end of file diff --git a/packages/core/src/networks.ts b/packages/core/src/networks.ts new file mode 100644 index 00000000..b48bd57f --- /dev/null +++ b/packages/core/src/networks.ts @@ -0,0 +1,128 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; + +export type NetworkName = 'mainnet' | 'testnet' | 'futurenet' | 'local' | 'custom'; + +export interface NetworkConfig { + name: string; + horizonUrl: string; + sorobanUrl?: string; + passphrase: string; + faucetUrl?: string; + customHeaders?: Record; + headers?: Record; + capabilities?: NetworkCapabilities; +} + +export interface NetworkCapabilities { + ledgers: boolean; + transactions: boolean; + events: boolean; + accountOffers: boolean; + fullHistory: boolean; + defaultReadSource: 'horizon' | 'rpc'; +} + +export const NETWORKS: Record = { + mainnet: { + name: 'Mainnet', + horizonUrl: 'https://horizon.stellar.org', + sorobanUrl: 'https://soroban-rpc.stellar.org', + passphrase: StellarSdk.Networks.PUBLIC, + capabilities: { + ledgers: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, + testnet: { + name: 'Testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanUrl: 'https://soroban-testnet.stellar.org', + passphrase: StellarSdk.Networks.TESTNET, + faucetUrl: 'https://friendbot.stellar.org', + capabilities: { + ledgers: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, + futurenet: { + name: 'Futurenet', + horizonUrl: 'https://horizon-futurenet.stellar.org', + sorobanUrl: 'https://soroban-futurenet.stellar.org', + passphrase: StellarSdk.Networks.FUTURENET, + faucetUrl: 'https://friendbot-futurenet.stellar.org', + capabilities: { + ledges: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, + local: { + name: 'Local', + horizonUrl: 'http://localhost:8000', + sorobanUrl: 'http://localhost:8000/soroban/rpc', + passphrase: 'Standalone Network ; February 2017', + capabilities: { + ledgers: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, + custom: { + name: 'Custom', + horizonUrl: '', + sorobanUrl: '', + passphrase: '', + headers: {}, + capabilities: { + ledgers: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, +}; + +export function getNetworkConfig(network: NetworkName): NetworkConfig { + return NETWORKS[network]; +} + +export function updateCustomNetworkConfig(config: Partial): void { + const { headers, ...networkConfig } = config; + Object.assign(NETWORKS.custom, networkConfig); + if (headers) { + NETWORKS.custom.headers = headers; + } +} + +export function getServer(network: NetworkName = 'testnet'): StellarSdk.Horizon.Server { + const config = NETWORKS[network]; + const fallbackHorizonUrl = NETWORKS.testnet.horizonUrl; + return new StellarSdk.Horizon.Server(config.horizonUrl || fallbackHorizonUrl); +} + +export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.SorobanRpc.Server { + const config = NETWORKS[network]; + if (network === 'custom' && !config.sorobanUrl) { + throw new Error('Custom Soroban RPC URL not configured'); + } + const fallbackSorobanUrl = NETWORKS.testnet.sorobanUrl!; + return new StellarSdk.SorobanRpc.Server(config.sorobanUrl || fallbackSorobanUrl); +} + +export { StellarSdk }; \ No newline at end of file diff --git a/packages/core/src/operationLabels.ts b/packages/core/src/operationLabels.ts new file mode 100644 index 00000000..50a379ff --- /dev/null +++ b/packages/core/src/operationLabels.ts @@ -0,0 +1,32 @@ +export const OPERATION_LABELS: Record = { + create_account: 'Create Account', + payment: 'Payment', + path_payment_strict_send: 'Path Payment (Send)', + path_payment_strict_receive: 'Path Payment (Receive)', + manage_buy_offer: 'Buy Offer', + manage_sell_offer: 'Sell Offer', + create_passive_sell_offer: 'Create Passive Sell Offer', + set_options: 'Set Options', + change_trust: 'Change Trust', + allow_trust: 'Allow Trust', + account_merge: 'Account Merge', + manage_data: 'Manage Data', + bump_sequence: 'Bump Sequence', + create_claimable_balance: 'Create Claimable Balance', + claim_claimable_balance: 'Claim Claimable Balance', + begin_sponsoring_future_reserves: 'Begin Sponsoring Future Reserves', + end_sponsoring_future_reserves: 'End Sponsoring Future Reserves', + revoke_sponsorship: 'Revoke Sponsorship', + clawback: 'Clawback', + clawback_claimable_balance: 'Clawback Claimable Balance', + set_trust_line_flags: 'Set Trustline Flags', + liquidity_pool_deposit: 'Liquidity Pool Deposit', + liquidity_pool_withdraw: 'Liquidity Pool Withdraw', + invoke_host_function: 'Contract Call', + extend_footprint_ttl: 'Extend Footprint TTL', + restore_footprint: 'Restore Footprint', +}; + +export function getOperationLabel(type: string): string { + return OPERATION_LABELS[type] || type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} \ No newline at end of file diff --git a/packages/core/src/reserves.ts b/packages/core/src/reserves.ts new file mode 100644 index 00000000..28748b4d --- /dev/null +++ b/packages/core/src/reserves.ts @@ -0,0 +1,40 @@ +import type { AccountData, NetworkStats, AccountReserves } from './types/index.js'; + +export function calculateAccountReserves( + accountData: AccountData, + networkStats: NetworkStats | null, + offerCount: number = 0 +): AccountReserves { + const baseReserveStroops = Number(networkStats?.latestLedger?.base_reserve) || 10000000; + const baseReserve = baseReserveStroops / 10000000; + + const assetCount = accountData.balances?.filter((b) => b.asset_type !== 'native').length || 0; + + const signerCount = + accountData.signers?.filter((s) => s.key !== accountData.account_id).length || 0; + + const subentryCount = accountData.subentry_count || 0; + + const signerReserve = signerCount * (baseReserve / 2); + const assetReserve = assetCount * (baseReserve / 2); + const offerReserve = offerCount * (baseReserve / 2); + const subentryReserve = subentryCount * (baseReserve / 2); + + const totalReserves = baseReserve + signerReserve + assetReserve + offerReserve + subentryReserve; + + const xlmBalance = accountData.balances?.find((b) => b.asset_type === 'native')?.balance || '0'; + const totalBalance = parseFloat(xlmBalance); + + const availableBalance = Math.max(0, totalBalance - totalReserves); + + return { + baseReserve, + signerReserve, + assetReserve, + offerReserve, + subentryReserve, + totalReserves, + availableBalance, + totalBalance, + }; +} \ No newline at end of file diff --git a/packages/core/src/services.ts b/packages/core/src/services.ts new file mode 100644 index 00000000..f926710c --- /dev/null +++ b/packages/core/src/services.ts @@ -0,0 +1,157 @@ +import { getServer, type NetworkName } from './networks.js'; +import type { AccountData, TransactionRecord, NetworkStats, XLMPrice } from './types/index.js'; + +const CACHE_PREFIX = 'stellar_cache_'; +const CACHE_TTL_MS = 5 * 60 * 1000; + +let cacheStore: Map = new Map(); + +function getCached(key: string): T | null { + const fullKey = CACHE_PREFIX + key; + const entry = cacheStore.get(fullKey); + if (!entry) return null; + if (Date.now() - entry.timestamp > CACHE_TTL_MS) { + cacheStore.delete(fullKey); + return null; + } + return entry.data as T; +} + +function setCache(key: string, data: unknown): void { + const fullKey = CACHE_PREFIX + key; + cacheStore.set(fullKey, { data, timestamp: Date.now() }); +} + +export async function fetchAccount( + publicKey: string, + network: NetworkName = 'testnet' +): Promise { + const cacheKey = `account:${publicKey}:${network}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const server = getServer(network); + const account = await server.loadAccount(publicKey); + + const result: AccountData = { + id: account.id, + account_id: account.account_id, + sequence: account.sequence, + subentry_count: account.subentry_count, + balances: account.balances.map((b: any) => ({ + asset_type: b.asset_type, + asset_code: b.asset_code, + asset_issuer: b.asset_issuer, + balance: b.balance, + limit: b.limit, + })), + signers: account.signers.map((s: any) => ({ + key: s.key, + type: s.type, + weight: s.weight, + })), + thresholds: { + low_threshold: account.thresholds.low_threshold, + med_threshold: account.thresholds.med_threshold, + high_threshold: account.thresholds.high_threshold, + }, + last_modified_ledger: account.last_modified_ledger, + }; + + setCache(cacheKey, result); + return result; +} + +export interface TransactionFetchResult { + records: TransactionRecord[]; + nextCursor: string | null; + hasMore: boolean; +} + +export async function fetchTransactions( + publicKey: string, + network: NetworkName = 'testnet', + limit = 20, + cursor: string | null = null +): Promise { + const server = getServer(network); + const request = server.transactions().forAccount(publicKey).order('desc').limit(limit); + if (cursor) request.cursor(cursor); + + const txs = await request.call(); + const records: TransactionRecord[] = (txs.records || []).map((tx: any) => ({ + id: tx.id, + hash: tx.hash, + source_account: tx.source_account, + created_at: tx.created_at, + fee_charged: tx.fee_charged, + fee: tx.fee, + memo: tx.memo, + memo_type: tx.memo_type, + operation_count: tx.operation_count, + successful: tx.successful, + paging_token: tx.paging_token, + })); + + const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null; + + return { + records, + nextCursor, + hasMore: records.length === limit && !!nextCursor, + }; +} + +export async function fetchNetworkStats(network: NetworkName = 'testnet'): Promise { + const cacheKey = `network-stats:${network}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const server = getServer(network); + const [ledger, feeStats] = await Promise.all([ + server.ledgers().order('desc').limit(1).call(), + server.feeStats(), + ]); + + const result: NetworkStats = { + latestLedger: ledger.records[0], + feeStats, + }; + + setCache(cacheKey, result); + return result; +} + +export async function fundTestnetAccount(publicKey: string): Promise { + const res = await fetch(`https://friendbot.stellar.org?addr=${publicKey}`); + if (!res.ok) throw new Error('Faucet request failed'); + return res.json(); +} + +export async function fetchXLMPrice(): Promise { + const cacheKey = 'xlm-price'; + const cached = getCached(cacheKey); + if (cached) return cached; + + const res = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd' + ); + if (!res.ok) throw new Error('Price fetch failed'); + const data = await res.json(); + + const result: XLMPrice = { usd: data?.stellar?.usd ?? 0 }; + setCache(cacheKey, result); + return result; +} + +export function clearCache(pattern?: string): void { + if (pattern) { + for (const key of cacheStore.keys()) { + if (key.includes(pattern)) { + cacheStore.delete(key); + } + } + } else { + cacheStore.clear(); + } +} \ No newline at end of file diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts new file mode 100644 index 00000000..27143d49 --- /dev/null +++ b/packages/core/src/types/index.ts @@ -0,0 +1,175 @@ +import type { NetworkName, NetworkConfig } from '../networks.js'; + +export interface Balances { + asset_type: string; + asset_code?: string; + asset_issuer?: string; + balance: string; + limit?: string; +} + +export interface Signer { + key: string; + type: string; + weight: number; +} + +export interface Thresholds { + low_threshold: number; + med_threshold: number; + high_threshold: number; +} + +export interface AccountData { + id: string; + account_id: string; + sequence: string; + subentry_count: number; + balances: Balances[]; + signers: Signer[]; + thresholds: Thresholds; + last_modified_ledger: number; +} + +export interface TransactionRecord { + id: string; + hash: string; + source_account: string; + created_at: string; + fee_charged: number; + fee: number; + memo: string | null; + memo_type: string | null; + operation_count: number; + successful: boolean; + paging_token: string; +} + +export interface OperationRecord { + id: string; + type: string; + type_i: number; + source_account: string; + created_at: string; + transaction_hash: string; + paging_token: string; +} + +export interface FeeStats { + fee_charged: PercentileStats; + max_fee: PercentileStats; + ledger_capacity_usage: string; +} + +export interface PercentileStats { + max: string; + min: string; + p10: string; + p20: string; + p30: string; + p50: string; + p80: string; + p95: string; + p99: string; +} + +export interface LatestLedger { + sequence: number; + closed_at: string; + transaction_count: number; + operation_count: number; + base_reserve: number; +} + +export interface NetworkStats { + latestLedger: LatestLedger; + feeStats: FeeStats; +} + +export interface NetworkProbeResult { + network: NetworkName; + name: string; + horizon: ServiceProbeResult; + soroban: ServiceProbeResult; +} + +export type ProbeStatus = 'up' | 'degraded' | 'down'; + +export interface ServiceProbeResult { + url: string; + status: ProbeStatus; + latency: number | null; + statusCode?: number; + breakerState: CircuitState; + error?: string; +} + +export type CircuitState = 'closed' | 'open' | 'half-open'; + +export interface MemoRequirementResult { + required: boolean; + checked: boolean; + error?: string; +} + +export interface ResolvedAddress { + accountId: string; + muxedId?: string; + originalInput: string; + inputType: 'ed25519' | 'muxed' | 'federated'; + federatedAddress?: string; + memoId?: string; + memoType?: string; +} + +export type MemoType = 'none' | 'text' | 'id' | 'hash' | 'return'; + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +export interface AssetInfo { + code: string; + issuer: string; + domain?: string; + name?: string; + description?: string; + image?: string; + conditions?: string; + is_verified?: boolean; + is_asset_anchored?: boolean; + anchor_asset_type?: string; + anchor_asset?: string; + redemption_instructions?: string; + collateral_addresses?: string[]; + collateral_address_messages?: string[]; + status?: string; + display_decimals?: number; + num_accounts?: number; + amount?: string; + flags?: { + auth_required?: boolean; + auth_revocable?: boolean; + auth_immutable?: boolean; + auth_clawback_enabled?: boolean; + }; + paging_token?: string; +} + +export interface AccountReserves { + baseReserve: number; + signerReserve: number; + assetReserve: number; + offerReserve: number; + subentryReserve: number; + totalReserves: number; + availableBalance: number; + totalBalance: number; +} + +export interface XLMPrice { + usd: number; +} + +export { NetworkName, NetworkConfig }; \ No newline at end of file diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts new file mode 100644 index 00000000..fa52df35 --- /dev/null +++ b/packages/core/src/validation.ts @@ -0,0 +1,186 @@ +import type { ValidationResult, MemoType, NetworkName } from './types/index.js'; + +export function ok(): ValidationResult { + return { valid: true, errors: [] }; +} + +export function fail(...errors: string[]): ValidationResult { + return { valid: false, errors }; +} + +const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; +const STELLAR_MUXED_RE = /^M[A-Z2-7]{55}$/; +const FEDERATED_ADDRESS_RE = /^[a-zA-Z0-9._-]+\*[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + +export function validateStellarAddress(value: unknown): ValidationResult { + if (typeof value !== 'string' || value.trim() === '') { + return fail('Stellar address is required.'); + } + const trimmed = value.trim(); + + if (STELLAR_ADDRESS_RE.test(trimmed)) { + return ok(); + } + + if (STELLAR_MUXED_RE.test(trimmed)) { + return ok(); + } + + if (FEDERATED_ADDRESS_RE.test(trimmed)) { + return ok(); + } + + return fail('Invalid Stellar address. Must be a G... address, M... muxed account, or name*domain federated address.'); +} + +export function validateAmount( + value: unknown, + min = 0.0000001, + max = Number.MAX_SAFE_INTEGER +): ValidationResult { + if (value === '' || value === null || value === undefined) { + return fail('Amount is required.'); + } + const n = Number(value); + if (isNaN(n) || !isFinite(n)) return fail('Amount must be a valid number.'); + if (n <= 0) return fail('Amount must be greater than zero.'); + if (n < min) return fail(`Amount must be at least ${min}.`); + if (n > max) return fail(`Amount must not exceed ${max}.`); + if (!/^\d+(\.\d{1,7})?$/.test(String(value))) { + return fail('Amount must have at most 7 decimal places.'); + } + return ok(); +} + +export function validateMemo(value: unknown, type: MemoType = 'text'): ValidationResult { + if (type === 'none') return ok(); + if (value === '' || value === null || value === undefined) return ok(); + if (typeof value !== 'string') return fail('Memo must be a string.'); + + switch (type) { + case 'text': { + const bytes = new TextEncoder().encode(value); + if (bytes.length > 28) return fail('Memo text must be 28 bytes or fewer.'); + return ok(); + } + case 'id': { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return fail('Memo ID must be a non-negative integer.'); + let n: bigint; + try { + n = BigInt(trimmed); + } catch { + return fail('Memo ID must be a valid unsigned 64-bit integer.'); + } + if (n > 18446744073709551615n) { + return fail('Memo ID must not exceed 18446744073709551615 (2^64 - 1).'); + } + return ok(); + } + case 'hash': + case 'return': { + const trimmed = value.trim(); + const label = type === 'hash' ? 'Memo hash' : 'Memo return hash'; + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + return fail(`${label} must be a 32-byte value encoded as 64 hex characters.`); + } + return ok(); + } + default: + return fail(`Unsupported memo type: ${String(type)}.`); + } +} + +const CONTRACT_ID_RE = /^C[A-Z2-7]{55}$/; + +export function validateContractId(value: unknown): ValidationResult { + if (typeof value !== 'string' || value.trim() === '') { + return fail('Contract ID is required.'); + } + if (!CONTRACT_ID_RE.test(value.trim())) { + return fail('Invalid Contract ID. Must start with C and be 56 characters.'); + } + return ok(); +} + +const VALID_NETWORKS = ['testnet', 'mainnet', 'futurenet', 'local', 'custom'] as const; +export type ValidNetworkName = (typeof VALID_NETWORKS)[number]; + +export function validateNetwork(value: unknown): ValidationResult { + if (!VALID_NETWORKS.includes(value as ValidNetworkName)) { + return fail(`Network must be one of: ${VALID_NETWORKS.join(', ')}.`); + } + return ok(); +} + +export function validateUrl(value: unknown, required = true): ValidationResult { + if (!value) { + if (required) { + return fail('URL is required.'); + } + return ok(); + } + + if (typeof value !== 'string') { + return fail('URL must be a string.'); + } + + const trimmed = value.trim(); + if (trimmed === '') { + if (required) { + return fail('URL is required.'); + } + return ok(); + } + + try { + const url = new URL(trimmed); + if (!['http:', 'https:'].includes(url.protocol)) { + return fail('URL must use HTTP or HTTPS protocol.'); + } + return ok(); + } catch { + return fail('Invalid URL format.'); + } +} + +export function validateHorizonUrl(value: unknown): ValidationResult { + const result = validateUrl(value, true); + if (!result.valid) return result; + return ok(); +} + +export function validateSorobanUrl(value: unknown, required = true): ValidationResult { + const result = validateUrl(value, required); + if (!result.valid) return result; + if (!value) return ok(); + return ok(); +} + +export function validateNetworkPassphrase(value: unknown, required = true): ValidationResult { + if (!value) { + if (required) { + return fail('Network passphrase is required.'); + } + return ok(); + } + + if (typeof value !== 'string') { + return fail('Network passphrase must be a string.'); + } + + const trimmed = value.trim(); + if (trimmed === '') { + if (required) { + return fail('Network passphrase is required.'); + } + return ok(); + } + + return ok(); +} + +export function composeValidations(...results: ValidationResult[]): ValidationResult { + const errors = results.flatMap((r) => r.errors); + return { valid: errors.length === 0, errors }; +} \ No newline at end of file diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..f797f129 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedSideEffectImports": true, + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*", "__tests__/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts new file mode 100644 index 00000000..b92b6359 --- /dev/null +++ b/packages/core/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['__tests__/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', '__tests__/', 'dist/', '*.config.*'], + }, + }, +}); \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e6970cb..bfa31909 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - '.' - 'mobile' + - 'packages/core' allowBuilds: '@tensorflow/tfjs-node': set this to true or false core-js: set this to true or false diff --git a/src/lib/stellar/addresses.ts b/src/lib/stellar/addresses.ts index f8bd9e83..071d9cec 100644 --- a/src/lib/stellar/addresses.ts +++ b/src/lib/stellar/addresses.ts @@ -1,156 +1,76 @@ import * as StellarSdk from '@stellar/stellar-sdk'; import { getServer, rateLimitedFetch, type NetworkName } from './networks.js'; import { requireAllowedEndpoint } from '../endpointAllowlist'; +import { + isValidEd25519PublicKey as coreIsValidEd25519PublicKey, + isValidMuxedAccount as coreIsValidMuxedAccount, + isFederatedAddress as coreIsFederatedAddress, + parseMuxedAccount as coreParseMuxedAccount, + checkDestinationMemoRequirement as coreCheckDestinationMemoRequirement, + resolveFederatedAddress as coreResolveFederatedAddress, + resolveAddress as coreResolveAddress, + isValidPublicKey as coreIsValidPublicKey, + isValidContractId as coreIsValidContractId, + type MemoRequirementResult, + type ResolvedAddress, +} from '@stellar-dev-dashboard/core'; -// ─── Validators ─────────────────────────────────────────────────────────────── - -/** - * Check if address is a valid Ed25519 public key (G...) - */ export function isValidEd25519PublicKey(key: string): boolean { - return StellarSdk.StrKey.isValidEd25519PublicKey(key); + return coreIsValidEd25519PublicKey(key); } -/** - * Check if address is a valid muxed account (M...) - */ export function isValidMuxedAccount(key: string): boolean { - if (!key || typeof key !== 'string') return false; - const trimmed = key.trim(); - if (!trimmed.startsWith('M')) return false; - try { - StellarSdk.MuxedAccount.fromAddress(trimmed, '0'); - return true; - } catch { - return false; - } + return coreIsValidMuxedAccount(key); } -/** - * Check if address is a federated address (name*domain or name@domain) - */ export function isFederatedAddress(input: string): boolean { - if (typeof input !== 'string' || !input.trim()) return false; - const trimmed = input.trim(); - // name*domain.tld (Stellar federation) OR name@domain.tld (email-style) - return ( - /^[a-zA-Z0-9._-]+\*[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed) || - /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed) - ); + return coreIsFederatedAddress(input); } -/** - * Extract master account and muxed ID from a muxed address - */ export function parseMuxedAccount( muxedAddress: string ): { masterAccount: string; muxedId: string } | null { - try { - const muxed = StellarSdk.MuxedAccount.fromAddress(muxedAddress, '0'); - return { - masterAccount: muxed.baseAccount().accountId(), - muxedId: muxed.id(), - }; - } catch { - return null; - } + return coreParseMuxedAccount(muxedAddress); } -// ─── Memo requirement check (SEP-29) ─────────────────────────────────────────── - -export interface MemoRequirementResult { - /** True only when the destination account has published a SEP-29 memo requirement. */ - required: boolean; - /** True when the lookup completed (successfully or with a definitive "not found"). */ - checked: boolean; - /** Present when the requirement could not be determined (network error, unsupported input). */ - error?: string; -} +export { type MemoRequirementResult }; -/** - * Check whether a destination requires a memo per SEP-29, by looking for a - * `config.memo_required` data entry (base64-encoded "1") on the account. - * Exchanges and custodians commonly set this flag on shared deposit addresses. - * - * Muxed accounts (M...) already encode their own memo ID and never require one. - * Federated (name*domain) and contract (C...) destinations are not currently - * checked here — federation records surface their own memo via resolveAddress(). - */ export async function checkDestinationMemoRequirement( destination: string, network: NetworkName = 'testnet' ): Promise { - if (typeof destination !== 'string' || !destination.trim()) { - return { required: false, checked: false, error: 'No destination provided.' }; - } - const trimmed = destination.trim(); - - if (isValidMuxedAccount(trimmed)) { - return { required: false, checked: true }; - } - - if (!isValidEd25519PublicKey(trimmed)) { - return { required: false, checked: false, error: 'Destination is not a directly checkable account address.' }; - } - - try { - const server = getServer(network); - const account = await server.loadAccount(trimmed); - const raw = (account as unknown as { data_attr?: Record }).data_attr?.['config.memo_required']; - if (!raw) return { required: false, checked: true }; - const decoded = typeof atob === 'function' ? atob(raw) : Buffer.from(raw, 'base64').toString('utf8'); - return { required: decoded === '1', checked: true }; - } catch (error: any) { - if (error?.response?.status === 404) { - // Unfunded accounts cannot carry the SEP-29 data entry. - return { required: false, checked: true }; - } - return { - required: false, - checked: false, - error: error?.message || 'Failed to check destination memo requirement.', - }; - } + return coreCheckDestinationMemoRequirement(destination, network); } -/** - * Resolve a federated address to a Stellar account via Horizon federation endpoint - */ export async function resolveFederatedAddress( federatedAddress: string, network: NetworkName = 'testnet' ): Promise<{ accountId: string; memoId?: string; memoType?: string } | null> { try { - // Parse the federated address (name*domain) const [name, domain] = federatedAddress.split('*'); if (!name || !domain) { return null; } - // Fetch the federation record from the domain's .well-known/stellar.toml const federationUrl = `https://${domain}/.well-known/stellar.toml`; let tomlData: Record = {}; try { const tomlResponse = await rateLimitedFetch(federationUrl); if (!tomlResponse.ok) { - // Federation endpoint not available in current SDK version return null; } - // Parse TOML (basic parsing for FEDERATION_SERVER URL) const tomlText = await tomlResponse.text(); const federationServerMatch = tomlText.match(/FEDERATION_SERVER\s*=\s*"([^"]+)"/); if (federationServerMatch) { tomlData.federationServer = federationServerMatch[1]; } } catch { - // Federation endpoint not available in current SDK version return null; } - // Use the federation server URL if found if (tomlData.federationServer) { const federationEndpoint = requireAllowedEndpoint( tomlData.federationServer, @@ -166,102 +86,25 @@ export async function resolveFederatedAddress( } } - // Federation endpoint not available in current SDK version return null; } catch { return null; } } -/** - * Comprehensive address resolver - * Accepts: G... (Ed25519), M... (muxed), or name*domain (federated) - * Returns: master account ID, muxed ID (if applicable), and original input info - */ -export interface ResolvedAddress { - accountId: string; // The master account ID (always G...) - muxedId?: string; // Muxed ID if input was M... - originalInput: string; // Original input provided - inputType: 'ed25519' | 'muxed' | 'federated'; - federatedAddress?: string; // Original federated address if applicable - memoId?: string; // Memo ID from federation resolution - memoType?: string; // Memo type from federation resolution -} +export { type ResolvedAddress }; export async function resolveAddress( input: string, network: NetworkName = 'testnet' ): Promise { - if (!input || typeof input !== 'string') { - return null; - } - - const trimmedInput = input.trim(); - - // Try Ed25519 public key (G...) - if (isValidEd25519PublicKey(trimmedInput)) { - return { - accountId: trimmedInput, - originalInput: trimmedInput, - inputType: 'ed25519', - }; - } - - // Try muxed account (M...) - if (isValidMuxedAccount(trimmedInput)) { - const parsed = parseMuxedAccount(trimmedInput); - if (parsed) { - return { - accountId: parsed.masterAccount, - muxedId: parsed.muxedId, - originalInput: trimmedInput, - inputType: 'muxed', - }; - } - } - - // Try federated address (name*domain) - if (isFederatedAddress(trimmedInput)) { - const resolved = await resolveFederatedAddress(trimmedInput, network); - if (resolved?.accountId) { - return { - accountId: resolved.accountId, - originalInput: trimmedInput, - inputType: 'federated', - federatedAddress: trimmedInput, - memoId: resolved.memoId, - memoType: resolved.memoType, - }; - } - } - - return null; + return coreResolveAddress(input, network); } -/** - * Validate any supported address format (legacy function name for backward compatibility) - */ export function isValidPublicKey(key: string): boolean { - if (!key || typeof key !== 'string') return false; - const trimmed = key.trim(); - - // Check G... Ed25519 - if (isValidEd25519PublicKey(trimmed)) return true; - - // Check M... muxed - if (isValidMuxedAccount(trimmed)) return true; - - // Check name*domain federated - if (isFederatedAddress(trimmed)) return true; - - return false; + return coreIsValidPublicKey(key); } export function isValidContractId(id: string): boolean { - try { - StellarSdk.Address.fromString(id); - return true; - } catch { - return false; - } -} + return coreIsValidContractId(id); +} \ No newline at end of file diff --git a/src/lib/stellar/formatters.ts b/src/lib/stellar/formatters.ts index fd82f24e..540676ec 100644 --- a/src/lib/stellar/formatters.ts +++ b/src/lib/stellar/formatters.ts @@ -1,25 +1,20 @@ import { stellarCache } from './networks.js'; - -// ─── Formatters ─────────────────────────────────────────────────────────────── +import { + formatXLM as coreFormatXLM, + shortAddress as coreShortAddress, + formatStroops as coreFormatStroops, + formatInstructions as coreFormatInstructions, + formatBytes as coreFormatBytes, +} from '@stellar-dev-dashboard/core'; export function formatXLM(amount: string | number): string { - return parseFloat(String(amount)).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 7, - }); + return coreFormatXLM(amount); } export function shortAddress(addr: string | null | undefined, chars = 6): string { - if (!addr) return ''; - return `${addr.slice(0, chars)}…${addr.slice(-chars)}`; + return coreShortAddress(addr, chars); } - - -/** - * Clear cache for specific pattern - * @param {string} pattern - Key pattern to clear - */ export function clearCache(pattern: string | null = null) { if (pattern) { stellarCache.invalidatePrefix(pattern); @@ -28,31 +23,18 @@ export function clearCache(pattern: string | null = null) { } } -/** - * Get cache statistics - * @returns {object} Cache stats - */ export function getCacheStats() { return stellarCache.getStats(); } - - export function formatInstructions(instructions: number): string { - if (instructions < 1000) return `${instructions}`; - if (instructions < 1000000) return `${(instructions / 1000).toFixed(2)}K`; - return `${(instructions / 1000000).toFixed(2)}M`; + return coreFormatInstructions(instructions); } export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + return coreFormatBytes(bytes); } export function formatStroops(stroops: unknown): string { - const num = typeof stroops === 'number' ? stroops : parseInt(String(stroops), 10); - if (isNaN(num)) return '—'; - const xlm = (num / 10000000).toFixed(7); - return `${xlm} XLM (${num.toLocaleString('en-US')} stroops)`; -} + return coreFormatStroops(stroops); +} \ No newline at end of file diff --git a/src/lib/stellar/index.ts b/src/lib/stellar/index.ts index d5082242..da6adbea 100644 --- a/src/lib/stellar/index.ts +++ b/src/lib/stellar/index.ts @@ -16,6 +16,7 @@ export * from './assets.js'; export * from './assetMarketData.js'; export * from './readSources.js'; export * from './endpointValidation.js'; +export { OPERATION_LABELS, getOperationLabel } from '@stellar-dev-dashboard/core'; export { importBatchXdr, simulateBatchXdr, validateXdrForBroadcast } from '../batchXdrImport.js'; export type { BatchXdrImportResult, BatchXdrImportOptions, ValidationReportItem, XdrImportItem } from '../batchXdrImport.js'; export { StellarSdk }; diff --git a/src/lib/stellar/networks.ts b/src/lib/stellar/networks.ts index ed1f1bf1..77715c3f 100644 --- a/src/lib/stellar/networks.ts +++ b/src/lib/stellar/networks.ts @@ -8,8 +8,18 @@ import { createHorizonFetchKey, installSdkGetCoalescing, } from './requestCoalescing.js'; +import { + type NetworkName, + type NetworkConfig, + NETWORKS as CORE_NETWORKS, + getServer as coreGetServer, + getSorobanServer as coreGetSorobanServer, + updateCustomNetworkConfig as coreUpdateCustomNetworkConfig, +} from '@stellar-dev-dashboard/core'; + +export type { NetworkName, NetworkConfig }; -// ─── Cache setup ────────────────────────────────────────────────────────────── +export const NETWORKS = CORE_NETWORKS; const stellarCache = new Cache({ namespace: 'stellar', @@ -20,97 +30,6 @@ const stellarCache = new Cache({ export { stellarCache }; -// ─── Network config ─────────────────────────────────────────────────────────── - -export type NetworkName = 'mainnet' | 'testnet' | 'futurenet' | 'local' | 'custom'; - -export interface NetworkConfig { - name: string; - horizonUrl: string; - sorobanUrl?: string; - passphrase: string; - faucetUrl?: string; - customHeaders?: Record; - headers?: Record; - capabilities?: import('./types').NetworkCapabilities; -} - -export const NETWORKS: Record = { - mainnet: { - name: 'Mainnet', - horizonUrl: 'https://horizon.stellar.org', - sorobanUrl: 'https://soroban-rpc.stellar.org', - passphrase: StellarSdk.Networks.PUBLIC, - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, - testnet: { - name: 'Testnet', - horizonUrl: 'https://horizon-testnet.stellar.org', - sorobanUrl: 'https://soroban-testnet.stellar.org', - passphrase: StellarSdk.Networks.TESTNET, - faucetUrl: 'https://friendbot.stellar.org', - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, - futurenet: { - name: 'Futurenet', - horizonUrl: 'https://horizon-futurenet.stellar.org', - sorobanUrl: 'https://soroban-futurenet.stellar.org', - passphrase: StellarSdk.Networks.FUTURENET, - faucetUrl: 'https://friendbot-futurenet.stellar.org', - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, - local: { - name: 'Local', - horizonUrl: 'http://localhost:8000', - sorobanUrl: 'http://localhost:8000/soroban/rpc', - passphrase: 'Standalone Network ; February 2017', - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, - custom: { - name: 'Custom', - horizonUrl: '', - sorobanUrl: '', - passphrase: '', - headers: {}, - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, -}; - function isConfiguredHorizonUrl(value: string): boolean { try { const requestUrl = new URL(value); @@ -199,8 +118,6 @@ function getServerOptions(network: NetworkName) { return Object.keys(headers).length ? { headers } : undefined; } -// ─── Rate Limited Fetch Wrapper ─────────────────────────────────────────────── - export async function rateLimitedFetch( url: string, options?: RequestInit, @@ -209,7 +126,6 @@ export async function rateLimitedFetch( ): Promise { const startTime = Date.now(); - // Merge custom network headers (e.g. API keys) without mutating caller options const mergedOptions: RequestInit = extraHeaders && Object.keys(extraHeaders).length > 0 ? { @@ -219,7 +135,6 @@ export async function rateLimitedFetch( : (options ?? {}); try { - // Log the API call (options without secret headers — sanitized by auditTrail) auditTrail.logAPICall(url, mergedOptions.method || 'GET', mergedOptions, {}); const requestKey = isConfiguredHorizonUrl(url) @@ -265,22 +180,17 @@ export async function rateLimitedFetch( } } -// ─── Servers ────────────────────────────────────────────────────────────────── - export function getNetworkDetails(network: NetworkName): NetworkConfig { return NETWORKS[network]; } export function updateCustomNetworkConfig(config: Partial) { + coreUpdateCustomNetworkConfig(config); const { headers, ...networkConfig } = config; Object.assign(NETWORKS.custom, networkConfig); if (headers) saveCustomNetworkAuthHeaders(headers); } -/** - * Switch to a custom network profile (Issue #188). - * Updates NETWORKS.custom with profile data and creates new clients. - */ export async function switchToCustomProfile(profileId: string): Promise { const { getNetworkProfile } = await import('../userPreferences'); const profile = await getNetworkProfile(profileId); @@ -289,7 +199,6 @@ export async function switchToCustomProfile(profileId: string): Promise { throw new Error(`Network profile "${profileId}" not found`); } - // Update the custom network config updateCustomNetworkConfig({ name: profile.name, horizonUrl: profile.horizonUrl, @@ -298,9 +207,6 @@ export async function switchToCustomProfile(profileId: string): Promise { }); } -/** - * Load profiles from storage and return them (Issue #188). - */ export async function loadCustomNetworkProfiles() { const { loadNetworkProfiles } = await import('../userPreferences'); return loadNetworkProfiles(); @@ -319,7 +225,6 @@ export function getServer(network: NetworkName = 'testnet'): StellarSdk.Horizon. return server; } -/** @deprecated Use getServer directly. */ export const ee = getServer; export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.SorobanRpc.Server { @@ -449,4 +354,4 @@ export async function probeAllNetworks(): Promise { return Promise.all(probes); } -export { StellarSdk }; +export { StellarSdk }; \ No newline at end of file diff --git a/src/lib/stellar/reserves.ts b/src/lib/stellar/reserves.ts index f71e7013..12841992 100644 --- a/src/lib/stellar/reserves.ts +++ b/src/lib/stellar/reserves.ts @@ -1,68 +1,30 @@ import * as StellarSdk from '@stellar/stellar-sdk'; import type { NetworkStats } from './horizon.js'; +import { calculateAccountReserves as coreCalculateAccountReserves, type AccountReserves } from '@stellar-dev-dashboard/core'; -export interface AccountReserves { - baseReserve: number; - signerReserve: number; - assetReserve: number; - offerReserve: number; - subentryReserve: number; - totalReserves: number; - availableBalance: number; - totalBalance: number; -} +export { type AccountReserves }; -/** - * Calculate account reserves based on Stellar network base reserve - * @param accountData - Account response from Horizon - * @param networkStats - Network stats containing ledger with base_reserve - * @param offerCount - Number of open offers (optional, defaults to 0) - * @returns AccountReserves object with breakdown of all reserves - */ export function calculateAccountReserves( accountData: StellarSdk.Horizon.AccountResponse, networkStats: NetworkStats | null, offerCount: number = 0 ): AccountReserves { - // Get base reserve from ledger (in stroops, convert to XLM) - // Default to 1 XLM if not available (current Stellar default) - const baseReserveStroops = Number(networkStats?.latestLedger?.base_reserve) || 10000000; - const baseReserve = baseReserveStroops / 10000000; // Convert stroops to XLM - - // Count non-native assets (trustlines) - const assetCount = accountData.balances?.filter((b) => b.asset_type !== 'native').length || 0; - - // Count signers (excluding the master key if it's a signer) - const signerCount = - accountData.signers?.filter((s) => s.key !== accountData.account_id).length || 0; - - // Subentry count from account data - const subentryCount = accountData.subentry_count || 0; - - // Calculate reserves (each additional entry costs base_reserve / 2) - const signerReserve = signerCount * (baseReserve / 2); - const assetReserve = assetCount * (baseReserve / 2); - const offerReserve = offerCount * (baseReserve / 2); - const subentryReserve = subentryCount * (baseReserve / 2); - - // Total reserves - const totalReserves = baseReserve + signerReserve + assetReserve + offerReserve + subentryReserve; - - // Get XLM balance - const xlmBalance = accountData.balances?.find((b) => b.asset_type === 'native')?.balance || '0'; - const totalBalance = parseFloat(xlmBalance); - - // Available balance (total - reserves) - const availableBalance = Math.max(0, totalBalance - totalReserves); - - return { - baseReserve, - signerReserve, - assetReserve, - offerReserve, - subentryReserve, - totalReserves, - availableBalance, - totalBalance, + const adaptedAccountData = { + balances: accountData.balances.map((b) => ({ + asset_type: b.asset_type, + asset_code: b.asset_code, + asset_issuer: b.asset_issuer, + balance: b.balance, + limit: b.limit, + })), + signers: accountData.signers.map((s) => ({ + key: s.key, + type: s.type, + weight: s.weight, + })), + subentry_count: accountData.subentry_count, + account_id: accountData.account_id, }; -} + + return coreCalculateAccountReserves(adaptedAccountData, networkStats, offerCount); +} \ No newline at end of file diff --git a/src/lib/validation.ts b/src/lib/validation.ts index c81d7f52..7024befb 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,260 +1,107 @@ -/** - * Input validation schemas (#106) - * - * Lightweight runtime validators for all user-supplied inputs. - * No external schema library required — pure TypeScript functions. - */ +import { + type ValidationResult, + type MemoType, + validateStellarAddress as coreValidateStellarAddress, + validateAmount as coreValidateAmount, + validateMemo as coreValidateMemo, + validateContractId as coreValidateContractId, + validateNetwork as coreValidateNetwork, + composeValidations as coreComposeValidations, +} from '@stellar-dev-dashboard/core'; + +export { type ValidationResult, type MemoType }; -export interface ValidationResult { - valid: boolean - errors: string[] -} - -function ok(): ValidationResult { return { valid: true, errors: [] } } -function fail(...errors: string[]): ValidationResult { return { valid: false, errors } } - -// ─── Stellar address ────────────────────────────────────────────────────────── - -const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/ -const STELLAR_MUXED_RE = /^M[A-Z2-7]{55}$/ -const FEDERATED_ADDRESS_RE = /^[a-zA-Z0-9._-]+\*[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ - -/** - * Validate a Stellar public key (G…), muxed account (M…), or federated address (name*domain). - */ export function validateStellarAddress(value: unknown): ValidationResult { - if (typeof value !== 'string' || value.trim() === '') { - return fail('Stellar address is required.') - } - const trimmed = value.trim() - - // Check G... Ed25519 - if (STELLAR_ADDRESS_RE.test(trimmed)) { - return ok() - } - - // Check M... muxed account - if (STELLAR_MUXED_RE.test(trimmed)) { - return ok() - } - - // Check name*domain federated address - if (FEDERATED_ADDRESS_RE.test(trimmed)) { - return ok() - } - - return fail('Invalid Stellar address. Must be a G... address, M... muxed account, or name*domain federated address.') + return coreValidateStellarAddress(value); } -// ─── Amount ─────────────────────────────────────────────────────────────────── - -/** - * Validate a payment amount string. - * @param min Minimum allowed value (default 0.0000001 — one stroop) - * @param max Maximum allowed value (default unlimited) - */ export function validateAmount( value: unknown, min = 0.0000001, - max = Number.MAX_SAFE_INTEGER, + max = Number.MAX_SAFE_INTEGER ): ValidationResult { - if (value === '' || value === null || value === undefined) { - return fail('Amount is required.') - } - const n = Number(value) - if (isNaN(n) || !isFinite(n)) return fail('Amount must be a valid number.') - if (n <= 0) return fail('Amount must be greater than zero.') - if (n < min) return fail(`Amount must be at least ${min}.`) - if (n > max) return fail(`Amount must not exceed ${max}.`) - // Max 7 decimal places (Stellar stroop precision) - if (!/^\d+(\.\d{1,7})?$/.test(String(value))) { - return fail('Amount must have at most 7 decimal places.') - } - return ok() + return coreValidateAmount(value, min, max); } -// ─── Transaction memo ───────────────────────────────────────────────────────── - -export type MemoType = 'none' | 'text' | 'id' | 'hash' | 'return' - -const MEMO_ID_MAX = 18446744073709551615n // 2^64 - 1, per XDR uint64 - -/** - * Validate a transaction memo for a given Stellar memo type: - * - text: at most 28 bytes UTF-8 - * - id: an unsigned 64-bit integer - * - hash / return: exactly 32 bytes, given as 64 hex characters - * - * The memo itself is optional (an empty value always passes) — callers that - * need to enforce a destination's memo requirement (SEP-29) should check that - * separately, since "required" is a property of the recipient, not the format. - */ export function validateMemo(value: unknown, type: MemoType = 'text'): ValidationResult { - if (type === 'none') return ok() - if (value === '' || value === null || value === undefined) return ok() // memo is optional - if (typeof value !== 'string') return fail('Memo must be a string.') - - switch (type) { - case 'text': { - const bytes = new TextEncoder().encode(value) - if (bytes.length > 28) return fail('Memo text must be 28 bytes or fewer.') - return ok() - } - case 'id': { - const trimmed = value.trim() - if (!/^\d+$/.test(trimmed)) return fail('Memo ID must be a non-negative integer.') - let n: bigint - try { - n = BigInt(trimmed) - } catch { - return fail('Memo ID must be a valid unsigned 64-bit integer.') - } - if (n > MEMO_ID_MAX) return fail('Memo ID must not exceed 18446744073709551615 (2^64 - 1).') - return ok() - } - case 'hash': - case 'return': { - const trimmed = value.trim() - const label = type === 'hash' ? 'Memo hash' : 'Memo return hash' - // Must match what StellarSdk.Memo.hash()/return() accept: a 32-byte - // value expressed as exactly 64 hex characters. - if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { - return fail(`${label} must be a 32-byte value encoded as 64 hex characters.`) - } - return ok() - } - default: - return fail(`Unsupported memo type: ${String(type)}.`) - } + return coreValidateMemo(value, type); } -// ─── Contract ID ────────────────────────────────────────────────────────────── - -const CONTRACT_ID_RE = /^C[A-Z2-7]{55}$/ - export function validateContractId(value: unknown): ValidationResult { - if (typeof value !== 'string' || value.trim() === '') { - return fail('Contract ID is required.') - } - if (!CONTRACT_ID_RE.test(value.trim())) { - return fail('Invalid Contract ID. Must start with C and be 56 characters.') - } - return ok() + return coreValidateContractId(value); } -// ─── Network name ───────────────────────────────────────────────────────────── - -const VALID_NETWORKS = ['testnet', 'mainnet'] as const -export type NetworkName = (typeof VALID_NETWORKS)[number] - export function validateNetwork(value: unknown): ValidationResult { - if (!VALID_NETWORKS.includes(value as NetworkName)) { - return fail(`Network must be one of: ${VALID_NETWORKS.join(', ')}.`) - } - return ok() + return coreValidateNetwork(value); } -// ─── URL validation ─────────────────────────────────────────────────────────── - -/** - * Validate a URL (HTTP/HTTPS only). - * @param value The URL to validate - * @param required Whether the URL is required (default true) - */ export function validateUrl(value: unknown, required = true): ValidationResult { if (!value) { if (required) { - return fail('URL is required.') + return { valid: false, errors: ['URL is required.'] }; } - return ok() + return { valid: true, errors: [] }; } - + if (typeof value !== 'string') { - return fail('URL must be a string.') + return { valid: false, errors: ['URL must be a string.'] }; } - - const trimmed = value.trim() + + const trimmed = value.trim(); if (trimmed === '') { if (required) { - return fail('URL is required.') + return { valid: false, errors: ['URL is required.'] }; } - return ok() + return { valid: true, errors: [] }; } - + try { - const url = new URL(trimmed) + const url = new URL(trimmed); if (!['http:', 'https:'].includes(url.protocol)) { - return fail('URL must use HTTP or HTTPS protocol.') + return { valid: false, errors: ['URL must use HTTP or HTTPS protocol.'] }; } - return ok() + return { valid: true, errors: [] }; } catch { - return fail('Invalid URL format.') + return { valid: false, errors: ['Invalid URL format.'] }; } } -/** - * Validate a Horizon API URL. - */ export function validateHorizonUrl(value: unknown): ValidationResult { - const result = validateUrl(value, true) - if (!result.valid) return result - - const trimmed = String(value).trim() - if (!trimmed.includes('horizon')) { - // Warn but don't fail—could be a custom endpoint - // return fail('Horizon URL should contain "horizon" in the path.') - } - return ok() + const result = validateUrl(value, true); + if (!result.valid) return result; + return { valid: true, errors: [] }; } -/** - * Validate a Soroban RPC URL. - */ export function validateSorobanUrl(value: unknown, required = true): ValidationResult { - const result = validateUrl(value, required) - if (!result.valid) return result - - if (!value) return ok() // Optional if not required - - const trimmed = String(value).trim() - if (!trimmed.includes('soroban') && !trimmed.includes('rpc')) { - // Warn but don't fail—could be a custom endpoint - } - return ok() + const result = validateUrl(value, required); + if (!result.valid) return result; + if (!value) return { valid: true, errors: [] }; + return { valid: true, errors: [] }; } -/** - * Validate a network passphrase. - */ export function validateNetworkPassphrase(value: unknown, required = true): ValidationResult { if (!value) { if (required) { - return fail('Network passphrase is required.') + return { valid: false, errors: ['Network passphrase is required.'] }; } - return ok() + return { valid: true, errors: [] }; } - + if (typeof value !== 'string') { - return fail('Network passphrase must be a string.') + return { valid: false, errors: ['Network passphrase must be a string.'] }; } - - const trimmed = value.trim() + + const trimmed = value.trim(); if (trimmed === '') { if (required) { - return fail('Network passphrase is required.') + return { valid: false, errors: ['Network passphrase is required.'] }; } - return ok() + return { valid: true, errors: [] }; } - - return ok() -} -// ─── Compose multiple validations ───────────────────────────────────────────── + return { valid: true, errors: [] }; +} -/** - * Run multiple validators and merge results. - */ export function composeValidations(...results: ValidationResult[]): ValidationResult { - const errors = results.flatMap((r) => r.errors) - return { valid: errors.length === 0, errors } -} + return coreComposeValidations(...results); +} \ No newline at end of file