From 4ae75159bdb853785321818a8743a6c2582a8de8 Mon Sep 17 00:00:00 2001 From: emmyoat Date: Thu, 23 Jul 2026 00:39:38 +0100 Subject: [PATCH 1/4] feat: add reusable transaction summary fixtures --- docs/FIXTURES.md | 66 +++++++++++++++++++ src/index.ts | 6 ++ src/transactions/fixtures.ts | 94 ++++++++++++++++++++++++++++ src/transactions/index.ts | 1 + src/types/transaction.ts | 1 + tests/fixtures/index.ts | 7 +++ tests/fixtures/transactionSummary.ts | 7 +++ tests/transactionFixtures.test.ts | 43 +++++++++++++ 8 files changed, 225 insertions(+) create mode 100644 docs/FIXTURES.md create mode 100644 src/transactions/fixtures.ts create mode 100644 tests/fixtures/transactionSummary.ts create mode 100644 tests/transactionFixtures.test.ts diff --git a/docs/FIXTURES.md b/docs/FIXTURES.md new file mode 100644 index 0000000..b2c7873 --- /dev/null +++ b/docs/FIXTURES.md @@ -0,0 +1,66 @@ +# Transaction Summary Fixtures + +The PocketPay SDK provides reusable `TransactionSummary` fixtures representing common transaction scenarios for tests and documentation examples. + +## Transaction Summary Shape + +The `TransactionSummary` object represents a high-level summary of a transaction formatted for mobile UI and SDK consumers: + +```typescript +export interface TransactionSummary { + /** Unique transaction identifier */ + id: string; + /** Stellar transaction hash */ + txHash: string; + /** Transaction direction ('incoming' | 'outgoing') */ + direction: TransactionDirection; + /** Amount in smallest unit (e.g. stroops for XLM) */ + amount: string; + /** Formatted human-readable amount string */ + amountDisplay: string; + /** Asset code (e.g. 'XLM', 'USDC') */ + asset: string; + /** Counterparty address */ + counterparty: string; + /** Optional transaction memo */ + memo?: string; + /** Transaction lifecycle status ('pending' | 'completed' | 'failed' | 'unknown') */ + status: TransactionStatus; + /** ISO 8601 timestamp string */ + createdAt: string; + /** Relative time representation (e.g., "2 hours ago") */ + timeAgo: string; + /** Optional fee charged */ + fee?: string; + /** Optional raw operation type */ + rawType?: string; +} +``` + +## Available Fixture States + +The SDK exports the following fixtures from `stellar-pocketpay-sdk`: + +1. **`successfulPaymentSummary`**: Reusable fixture for a successful completed payment (`TransactionStatus.COMPLETED`). +2. **`failedPaymentSummary`**: Reusable fixture for a failed transaction (`TransactionStatus.FAILED`). +3. **`pendingTransactionSummary`**: Reusable fixture for an in-flight pending payment (`TransactionStatus.PENDING`). +4. **`unknownTransactionSummary`**: Reusable fixture representing an unknown transaction state (`TransactionStatus.UNKNOWN`). +5. **`transactionSummaryFixtures`**: Collection object grouping all summary fixtures for batch test utilities. + +## Usage Example + +```typescript +import { + successfulPaymentSummary, + failedPaymentSummary, + pendingTransactionSummary, + unknownTransactionSummary, + TransactionStatus, +} from 'stellar-pocketpay-sdk'; + +// Use in unit tests or docs +console.log(successfulPaymentSummary.status); // TransactionStatus.COMPLETED ('completed') +console.log(failedPaymentSummary.status); // TransactionStatus.FAILED ('failed') +console.log(pendingTransactionSummary.status); // TransactionStatus.PENDING ('pending') +console.log(unknownTransactionSummary.status); // TransactionStatus.UNKNOWN ('unknown') +``` diff --git a/src/index.ts b/src/index.ts index 6829d9a..a69ac97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,6 +97,12 @@ export { sortTransactionsByDate, safeGetTransactions, safeGetPayments, + // ─── Transaction Fixtures ────────────────────────────────────────────────── + successfulPaymentSummary, + failedPaymentSummary, + pendingTransactionSummary, + unknownTransactionSummary, + transactionSummaryFixtures, } from './transactions'; // ─── Soroban Vault ────────────────────────────────────────────────────────── diff --git a/src/transactions/fixtures.ts b/src/transactions/fixtures.ts new file mode 100644 index 0000000..79c8031 --- /dev/null +++ b/src/transactions/fixtures.ts @@ -0,0 +1,94 @@ +import { + TransactionSummary, + TransactionDirection, + TransactionStatus, +} from '../types'; + +/** + * Mock public addresses for fixtures (non-sensitive mock values) + */ +const MOCK_SENDER = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; +const MOCK_RECIPIENT = 'GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSFGG4HYS'; + +/** + * Reusable fixture representing a successful payment transaction. + */ +export const successfulPaymentSummary: TransactionSummary = { + id: 'tx_succ_1001', + txHash: 'a1b2c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcdef', + direction: TransactionDirection.OUTGOING, + amount: '10000000', + amountDisplay: '1.0000000 XLM', + asset: 'XLM', + counterparty: MOCK_RECIPIENT, + memo: 'Payment for services', + status: TransactionStatus.COMPLETED, + createdAt: '2024-01-15T10:30:00Z', + timeAgo: '2 hours ago', + fee: '100', + rawType: 'payment', +}; + +/** + * Reusable fixture representing a failed payment transaction. + */ +export const failedPaymentSummary: TransactionSummary = { + id: 'tx_fail_1002', + txHash: 'f6e5d4c3b2a109876543210987fedcba0987654321fedcba0987654321fedcba', + direction: TransactionDirection.OUTGOING, + amount: '5000000', + amountDisplay: '0.5000000 XLM', + asset: 'XLM', + counterparty: MOCK_RECIPIENT, + memo: 'Failed transfer', + status: TransactionStatus.FAILED, + createdAt: '2024-01-15T11:00:00Z', + timeAgo: '1 hour ago', + fee: '100', + rawType: 'payment', +}; + +/** + * Reusable fixture representing a pending transaction. + */ +export const pendingTransactionSummary: TransactionSummary = { + id: 'tx_pend_1003', + txHash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + direction: TransactionDirection.INCOMING, + amount: '25000000', + amountDisplay: '2.5000000 XLM', + asset: 'XLM', + counterparty: MOCK_SENDER, + memo: 'Pending deposit', + status: TransactionStatus.PENDING, + createdAt: '2024-01-15T11:25:00Z', + timeAgo: '5 minutes ago', + fee: '100', + rawType: 'payment', +}; + +/** + * Reusable fixture representing an unknown transaction state. + */ +export const unknownTransactionSummary: TransactionSummary = { + id: 'tx_unkn_1004', + txHash: '0000000000000000000000000000000000000000000000000000000000000000', + direction: TransactionDirection.INCOMING, + amount: '0', + amountDisplay: '0 XLM', + asset: 'XLM', + counterparty: 'UNKNOWN_COUNTERPARTY', + status: TransactionStatus.UNKNOWN, + createdAt: '1970-01-01T00:00:00Z', + timeAgo: 'unknown', +}; + +/** + * Collection of common transaction summary fixtures for tests and documentation. + */ +export const transactionSummaryFixtures = { + successfulPayment: successfulPaymentSummary, + failedPayment: failedPaymentSummary, + pendingTransaction: pendingTransactionSummary, + unknownTransaction: unknownTransactionSummary, +}; diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 6752e51..b7bb2de 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -1 +1,2 @@ export * from './mapper'; +export * from './fixtures'; diff --git a/src/types/transaction.ts b/src/types/transaction.ts index 6a8aa2a..0f2e5a4 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -13,6 +13,7 @@ export enum TransactionStatus { PENDING = 'pending', COMPLETED = 'completed', FAILED = 'failed', + UNKNOWN = 'unknown', } /** diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts index 3037534..90035dd 100644 --- a/tests/fixtures/index.ts +++ b/tests/fixtures/index.ts @@ -1,3 +1,10 @@ export { fundedAccount, unfundedAccount, accountNotFound } from './accounts'; export { transactionList, failedTransaction, transactionNotFound } from './transactions'; export { paymentList, paymentNotFound } from './payments'; +export { + successfulPaymentSummary, + failedPaymentSummary, + pendingTransactionSummary, + unknownTransactionSummary, + transactionSummaryFixtures, +} from './transactionSummary'; diff --git a/tests/fixtures/transactionSummary.ts b/tests/fixtures/transactionSummary.ts new file mode 100644 index 0000000..3617efb --- /dev/null +++ b/tests/fixtures/transactionSummary.ts @@ -0,0 +1,7 @@ +export { + successfulPaymentSummary, + failedPaymentSummary, + pendingTransactionSummary, + unknownTransactionSummary, + transactionSummaryFixtures, +} from '../../src/transactions/fixtures'; diff --git a/tests/transactionFixtures.test.ts b/tests/transactionFixtures.test.ts new file mode 100644 index 0000000..b985c20 --- /dev/null +++ b/tests/transactionFixtures.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { + successfulPaymentSummary, + failedPaymentSummary, + pendingTransactionSummary, + unknownTransactionSummary, + transactionSummaryFixtures, + TransactionStatus, +} from '../src'; + +describe('Transaction Summary Fixtures', () => { + it('should provide a valid successful payment summary fixture', () => { + expect(successfulPaymentSummary.status).toBe(TransactionStatus.COMPLETED); + expect(successfulPaymentSummary.id).toBeDefined(); + expect(successfulPaymentSummary.amount).toBe('10000000'); + expect(successfulPaymentSummary.counterparty).toBeDefined(); + }); + + it('should provide a valid failed payment summary fixture', () => { + expect(failedPaymentSummary.status).toBe(TransactionStatus.FAILED); + expect(failedPaymentSummary.id).toBeDefined(); + expect(failedPaymentSummary.amount).toBe('5000000'); + }); + + it('should provide a valid pending transaction summary fixture', () => { + expect(pendingTransactionSummary.status).toBe(TransactionStatus.PENDING); + expect(pendingTransactionSummary.id).toBeDefined(); + expect(pendingTransactionSummary.counterparty).toBeDefined(); + }); + + it('should provide a valid unknown transaction state fixture', () => { + expect(unknownTransactionSummary.status).toBe(TransactionStatus.UNKNOWN); + expect(unknownTransactionSummary.id).toBeDefined(); + expect(unknownTransactionSummary.amount).toBe('0'); + }); + + it('should bundle all fixtures into transactionSummaryFixtures object', () => { + expect(transactionSummaryFixtures.successfulPayment).toEqual(successfulPaymentSummary); + expect(transactionSummaryFixtures.failedPayment).toEqual(failedPaymentSummary); + expect(transactionSummaryFixtures.pendingTransaction).toEqual(pendingTransactionSummary); + expect(transactionSummaryFixtures.unknownTransaction).toEqual(unknownTransactionSummary); + }); +}); From 622db3b06e5fa7737582fce3a84d0de0b8133260 Mon Sep 17 00:00:00 2001 From: emmyoat Date: Thu, 23 Jul 2026 16:52:39 +0100 Subject: [PATCH 2/4] feat: add validatePocketPayConfig helper for early config validation --- docs/configuration.md | 43 ++ src/config/index.ts | 329 +++++++++++- src/index.ts | 4 + src/types/index.ts | 870 ++++++++++++++++++++++++++++++++ tests/config-validation.test.ts | 201 ++++++++ 5 files changed, 1445 insertions(+), 2 deletions(-) create mode 100644 tests/config-validation.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index ea1deb1..9559293 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -61,3 +61,46 @@ config, such as `getBalance`, `getTransactions`, `getPayments`, `sendXLM`, and When a request times out, the SDK throws a `PocketPayError` with code `REQUEST_TIMEOUT` and a message that includes the operation and timeout duration. +## Early Configuration Validation + +Applications can validate SDK configurations early before initiating network or transaction operations using `validatePocketPayConfig`. + +Unlike `resolveConfig`, `validatePocketPayConfig` does not throw exceptions. Instead, it returns a structured `ConfigValidationResult` containing: +- `valid`: `boolean` (`true` when zero errors are found) +- `errors`: List of fatal validation issues (e.g. invalid network, malformed URLs, invalid timeout, bad contract ID format) +- `warnings`: Advisory non-fatal issues (e.g. HTTP/HTTPS protocol mismatches, mainnet/testnet endpoint mismatches, extreme timeout values) +- `issues`: Complete list of all errors and warnings +- `config`: Resolved `SDKConfig` (populated when `valid` is `true`) + +### Usage Example + +```ts +import { validatePocketPayConfig } from 'stellar-pocketpay-sdk'; + +const validationResult = validatePocketPayConfig({ + network: 'testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + timeout: 30000, +}); + +if (!validationResult.valid) { + console.error('Configuration validation failed:'); + for (const error of validationResult.errors) { + console.error(`- [${error.field}] ${error.code}: ${error.message}`); + } +} else { + console.log('SDK Configuration is valid:', validationResult.config); + + if (validationResult.warnings.length > 0) { + for (const warning of validationResult.warnings) { + console.warn(`- [${warning.field}] Warning ${warning.code}: ${warning.message}`); + } + } +} +``` + +### Security & Secret Redaction + +Validation issue outputs never expose sensitive keys (e.g. Stellar secret keys `S...`). Any sensitive value passed in malformed inputs is automatically redacted (masked as `S[REDACTED]`) before being returned in validation issues. + + diff --git a/src/config/index.ts b/src/config/index.ts index 3f7f116..6553642 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,8 +5,14 @@ * Defaults to Stellar Testnet. Override via environment variables or programmatic config. */ import * as StellarSDK from '@stellar/stellar-sdk'; -import { SDKConfig, StellarNetwork } from '../types'; -import { PocketPayError } from '../types'; +import { + SDKConfig, + StellarNetwork, + ConfigValidationIssue, + ConfigValidationResult, + PocketPayError, +} from '../types'; +import { redactSensitive } from '../utils'; // ─── Default URLs ─────────────────────────────────────────────────────────── const HORIZON_URLS: Record = { testnet: 'https://horizon-testnet.stellar.org', @@ -245,6 +251,325 @@ export function resolveConfig(overrides?: Partial): SDKConfig { return { network, horizonUrl, sorobanRpcUrl, timeout, contractId }; } + +/** + * Helper to sanitize potential sensitive values in validation outputs. + * Never exposes secret keys or sensitive strings in issue outputs. + */ +function sanitizeValue(value: unknown): unknown { + if (typeof value === 'string') { + return redactSensitive(value); + } + return value; +} + +/** + * Validates SDK configuration and returns a structured summary of issues and warnings. + * + * Unlike {@link resolveConfig}, this helper does not throw on invalid parameters. + * Instead, it collects all errors and advisory warnings into a structured {@link ConfigValidationResult}. + * + * @param overrides - Optional partial SDK configuration or unvalidated config object to evaluate + * @returns Structured validation result containing `valid`, `issues`, `errors`, `warnings`, and resolved `config` if valid + * + * @example + * ```ts + * const result = validatePocketPayConfig({ + * network: 'testnet', + * horizonUrl: 'https://horizon-testnet.stellar.org', + * }); + * + * if (!result.valid) { + * console.error('Config validation failed:', result.errors); + * } else { + * console.log('Resolved config:', result.config); + * } + * ``` + */ +export function validatePocketPayConfig( + overrides?: Partial | Record +): ConfigValidationResult { + const issues: ConfigValidationIssue[] = []; + + // 1. Network Validation + const rawNetwork: unknown = + overrides?.network !== undefined + ? overrides.network + : process.env.STELLAR_NETWORK ?? 'testnet'; + + let network: StellarNetwork = 'testnet'; + if (rawNetwork !== 'testnet' && rawNetwork !== 'mainnet') { + issues.push({ + severity: 'error', + field: 'network', + code: 'INVALID_NETWORK', + message: `Unsupported network: "${rawNetwork}". Supported networks: testnet, mainnet`, + value: sanitizeValue(rawNetwork), + }); + } else { + network = rawNetwork; + } + + // 2. Horizon URL Validation & Warnings + const rawHorizonUrl: unknown = + overrides?.horizonUrl !== undefined + ? overrides.horizonUrl + : process.env.STELLAR_HORIZON_URL ?? HORIZON_URLS[network]; + + if (typeof rawHorizonUrl !== 'string') { + issues.push({ + severity: 'error', + field: 'horizonUrl', + code: 'INVALID_HORIZON_URL', + message: `Invalid Horizon URL: "${rawHorizonUrl}". Must be a non-empty string.`, + value: sanitizeValue(rawHorizonUrl), + }); + } else { + try { + const parsed = new URL(rawHorizonUrl); + if (!parsed.protocol.startsWith('http')) { + issues.push({ + severity: 'error', + field: 'horizonUrl', + code: 'INVALID_HORIZON_URL', + message: `Invalid Horizon URL: "${rawHorizonUrl}". Protocol must be http or https.`, + value: sanitizeValue(rawHorizonUrl), + }); + } else { + // Advisory Warnings for Horizon URL + if ( + parsed.protocol === 'http:' && + parsed.hostname !== 'localhost' && + parsed.hostname !== '127.0.0.1' + ) { + issues.push({ + severity: 'warning', + field: 'horizonUrl', + code: 'INSECURE_HTTP_URL', + message: `Horizon URL "${rawHorizonUrl}" uses unencrypted HTTP protocol for a non-localhost host.`, + value: sanitizeValue(rawHorizonUrl), + }); + } + if ( + network === 'mainnet' && + rawHorizonUrl.toLowerCase().includes('testnet') + ) { + issues.push({ + severity: 'warning', + field: 'horizonUrl', + code: 'NETWORK_MISMATCH', + message: `Horizon URL "${rawHorizonUrl}" contains "testnet" but network is configured as mainnet.`, + value: sanitizeValue(rawHorizonUrl), + }); + } else if ( + network === 'testnet' && + rawHorizonUrl.toLowerCase().includes('horizon.stellar.org') && + !rawHorizonUrl.toLowerCase().includes('testnet') + ) { + issues.push({ + severity: 'warning', + field: 'horizonUrl', + code: 'NETWORK_MISMATCH', + message: `Horizon URL "${rawHorizonUrl}" points to Stellar public mainnet endpoint but network is configured as testnet.`, + value: sanitizeValue(rawHorizonUrl), + }); + } + } + } catch { + issues.push({ + severity: 'error', + field: 'horizonUrl', + code: 'INVALID_HORIZON_URL', + message: `Invalid Horizon URL: "${rawHorizonUrl}". Must be a valid HTTP(S) URL.`, + value: sanitizeValue(rawHorizonUrl), + }); + } + } + + // 3. Soroban RPC URL Validation & Warnings + const rawSorobanRpcUrl: unknown = + overrides?.sorobanRpcUrl !== undefined + ? overrides.sorobanRpcUrl + : process.env.STELLAR_SOROBAN_RPC_URL ?? SOROBAN_RPC_URLS[network]; + + if (typeof rawSorobanRpcUrl !== 'string') { + issues.push({ + severity: 'error', + field: 'sorobanRpcUrl', + code: 'INVALID_SOROBAN_RPC_URL', + message: `Invalid Soroban RPC URL: "${rawSorobanRpcUrl}". Must be a non-empty string.`, + value: sanitizeValue(rawSorobanRpcUrl), + }); + } else { + try { + const parsed = new URL(rawSorobanRpcUrl); + if (!parsed.protocol.startsWith('http')) { + issues.push({ + severity: 'error', + field: 'sorobanRpcUrl', + code: 'INVALID_SOROBAN_RPC_URL', + message: `Invalid Soroban RPC URL: "${rawSorobanRpcUrl}". Protocol must be http or https.`, + value: sanitizeValue(rawSorobanRpcUrl), + }); + } else { + // Advisory Warnings for Soroban RPC URL + if ( + parsed.protocol === 'http:' && + parsed.hostname !== 'localhost' && + parsed.hostname !== '127.0.0.1' + ) { + issues.push({ + severity: 'warning', + field: 'sorobanRpcUrl', + code: 'INSECURE_HTTP_URL', + message: `Soroban RPC URL "${rawSorobanRpcUrl}" uses unencrypted HTTP protocol for a non-localhost host.`, + value: sanitizeValue(rawSorobanRpcUrl), + }); + } + if ( + network === 'mainnet' && + rawSorobanRpcUrl.toLowerCase().includes('testnet') + ) { + issues.push({ + severity: 'warning', + field: 'sorobanRpcUrl', + code: 'NETWORK_MISMATCH', + message: `Soroban RPC URL "${rawSorobanRpcUrl}" contains "testnet" but network is configured as mainnet.`, + value: sanitizeValue(rawSorobanRpcUrl), + }); + } else if ( + network === 'testnet' && + rawSorobanRpcUrl.toLowerCase().includes('soroban.stellar.org') && + !rawSorobanRpcUrl.toLowerCase().includes('testnet') + ) { + issues.push({ + severity: 'warning', + field: 'sorobanRpcUrl', + code: 'NETWORK_MISMATCH', + message: `Soroban RPC URL "${rawSorobanRpcUrl}" points to Stellar public mainnet endpoint but network is configured as testnet.`, + value: sanitizeValue(rawSorobanRpcUrl), + }); + } + } + } catch { + issues.push({ + severity: 'error', + field: 'sorobanRpcUrl', + code: 'INVALID_SOROBAN_RPC_URL', + message: `Invalid Soroban RPC URL: "${rawSorobanRpcUrl}". Must be a valid HTTP(S) URL.`, + value: sanitizeValue(rawSorobanRpcUrl), + }); + } + } + + // 4. Timeout Validation & Warnings + const rawTimeout: unknown = + overrides?.timeout ?? + (process.env.STELLAR_TIMEOUT + ? parseInt(process.env.STELLAR_TIMEOUT, 10) + : DEFAULT_TIMEOUT_MS); + + if (typeof rawTimeout !== 'number' || Number.isNaN(rawTimeout)) { + issues.push({ + severity: 'error', + field: 'timeout', + code: 'INVALID_TIMEOUT', + message: `Invalid timeout: "${rawTimeout}". Timeout must be a number (milliseconds).`, + value: sanitizeValue(rawTimeout), + }); + } else if (rawTimeout <= 0) { + issues.push({ + severity: 'error', + field: 'timeout', + code: 'INVALID_TIMEOUT', + message: `Invalid timeout: ${rawTimeout}. Timeout must be greater than 0.`, + value: rawTimeout, + }); + } else if (!Number.isFinite(rawTimeout)) { + issues.push({ + severity: 'error', + field: 'timeout', + code: 'INVALID_TIMEOUT', + message: `Invalid timeout: ${rawTimeout}. Timeout must be a finite number.`, + value: sanitizeValue(rawTimeout), + }); + } else { + if (rawTimeout < 1000) { + issues.push({ + severity: 'warning', + field: 'timeout', + code: 'EXTREME_TIMEOUT', + message: `Timeout of ${rawTimeout}ms is unusually low (< 1000ms) and may lead to frequent timeouts.`, + value: rawTimeout, + }); + } else if (rawTimeout > 120000) { + issues.push({ + severity: 'warning', + field: 'timeout', + code: 'EXTREME_TIMEOUT', + message: `Timeout of ${rawTimeout}ms is unusually high (> 120000ms).`, + value: rawTimeout, + }); + } + } + + // 5. Contract ID Validation + const rawContractId: unknown = + overrides?.contractId !== undefined + ? overrides.contractId + : process.env.STELLAR_CONTRACT_ID; + + if ( + rawContractId !== undefined && + rawContractId !== null && + rawContractId !== '' + ) { + if (typeof rawContractId !== 'string') { + issues.push({ + severity: 'error', + field: 'contractId', + code: 'INVALID_CONTRACT_ID', + message: `Invalid contract ID: "${rawContractId}". Contract ID must be a non-empty string.`, + value: sanitizeValue(rawContractId), + }); + } else if ( + !rawContractId.startsWith('C') || + rawContractId.length !== 56 || + !/^C[A-Z2-7]{55}$/.test(rawContractId) + ) { + issues.push({ + severity: 'error', + field: 'contractId', + code: 'INVALID_CONTRACT_ID', + message: `Invalid contract ID: "${rawContractId}". Contract ID must be a 56-character base32 string starting with 'C'.`, + value: sanitizeValue(rawContractId), + }); + } + } + + const errors = issues.filter((i) => i.severity === 'error'); + const warnings = issues.filter((i) => i.severity === 'warning'); + const valid = errors.length === 0; + + let resolvedConfig: SDKConfig | undefined = undefined; + if (valid) { + resolvedConfig = { + network, + horizonUrl: rawHorizonUrl as string, + sorobanRpcUrl: rawSorobanRpcUrl as string, + timeout: rawTimeout as number, + contractId: (rawContractId as string) || undefined, + }; + } + + return { + valid, + issues, + errors, + warnings, + config: resolvedConfig, + }; +} /** * Factory used to construct Horizon server instances. Defaults to a real * Horizon server. Tests can override this via {@link setHorizonServerFactory} diff --git a/src/index.ts b/src/index.ts index a69ac97..bccc718 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,9 @@ dotenv.config(); export type { StellarNetwork, SDKConfig, + ConfigIssueSeverity, + ConfigValidationIssue, + ConfigValidationResult, WalletKeypair, AssetBalance, AccountBalance, @@ -128,6 +131,7 @@ export { // ─── Config ───────────────────────────────────────────────────────────────── export { resolveConfig, + validatePocketPayConfig, getHorizonServer, setHorizonServerFactory, resetHorizonServerFactory, diff --git a/src/types/index.ts b/src/types/index.ts index e8f45da..440d633 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,871 @@ +/** + * Stellar PocketPay SDK — Type Definitions + * + * All shared types, interfaces, and enums used across the SDK. + */ + export * from './transaction'; + +// ─── Network ──────────────────────────────────────────────────────────────── + +/** Supported Stellar networks */ +export type StellarNetwork = 'testnet' | 'mainnet'; + +/** SDK configuration options */ +export interface SDKConfig { + /** Network to connect to (default: "testnet") */ + network: StellarNetwork; + /** Horizon server URL (auto-resolved if omitted) */ + horizonUrl: string; + /** Soroban RPC URL (auto-resolved if omitted) */ + sorobanRpcUrl: string; + /** Request timeout in milliseconds (default: 30000) */ + timeout?: number; + /** Soroban contract ID for vault operations (optional) */ + contractId?: string; +} + +/** Severity level of a configuration validation issue */ +export type ConfigIssueSeverity = 'error' | 'warning'; + +/** A structured issue found during SDK configuration validation */ +export interface ConfigValidationIssue { + /** Severity level: 'error' (fatal issue) or 'warning' (non-fatal advisory) */ + severity: ConfigIssueSeverity; + /** Configuration field associated with the issue */ + field: string; + /** Machine-readable error or warning code */ + code: string; + /** Human-readable description of the issue */ + message: string; + /** Sanitized input value that caused the issue (never exposes secrets) */ + value?: unknown; +} + +/** Structured output returned by `validatePocketPayConfig` */ +export interface ConfigValidationResult { + /** `true` if there are zero errors (warnings may still be present) */ + valid: boolean; + /** All validation issues (combines errors and warnings) */ + issues: ConfigValidationIssue[]; + /** Error-level issues (`severity === 'error'`) */ + errors: ConfigValidationIssue[]; + /** Warning-level issues (`severity === 'warning'`) */ + warnings: ConfigValidationIssue[]; + /** Resolved SDKConfig if `valid` is true; `undefined` if `valid` is false */ + config?: SDKConfig; +} + +// ─── Wallet ───────────────────────────────────────────────────────────────── + +/** A newly created or imported Stellar keypair */ +export interface WalletKeypair { + /** Stellar public key (G...) */ + publicKey: string; + /** Stellar secret key (S...) — handle with extreme care */ + secretKey: string; +} + +/** Balance entry for a single asset */ +export interface AssetBalance { + /** Asset code (e.g. "XLM", "USDC") */ + asset: string; + /** Balance amount as a string (to preserve precision) */ + balance: string; + /** Asset issuer public key (empty for native XLM) */ + issuer: string; +} + +/** Full account balance response */ +export interface AccountBalance { + /** The queried public key */ + publicKey: string; + /** Array of asset balances */ + balances: AssetBalance[]; + /** Native XLM balance (convenience shortcut) */ + nativeBalance: string; +} + +// ─── Balance Result (discriminated union) ─────────────────────────────────── + +/** + * Result of {@link getBalanceOrUnfunded} — a discriminated union on `status`. + * + * Use `result.status` to branch without try/catch: + * - `"funded"` — the account exists on-chain; `balance` is populated. + * - `"unfunded"` — Horizon returned 404; the account has never been funded. + * + * Any unexpected Horizon failure (5xx, network error, etc.) is still thrown + * as a {@link PocketPayError} so genuine errors are never silently swallowed. + * + * @example + * ```ts + * const result = await getBalanceOrUnfunded(wallet.publicKey); + * if (result.status === 'funded') { + * console.log('XLM balance:', result.balance.nativeBalance); + * } else { + * // result.status === 'unfunded' + * console.log('Wallet not yet funded — call fundTestnetAccount()'); + * } + * ``` + */ +export type BalanceResult = + | { + /** Account exists and has been funded. */ + status: 'funded'; + /** The queried public key. */ + publicKey: string; + /** Full account balance detail. */ + balance: AccountBalance; + } + | { + /** Account does not exist on Horizon (never funded). */ + status: 'unfunded'; + /** The queried public key. */ + publicKey: string; + }; + +// ─── Payments ─────────────────────────────────────────────────────────────── + +/** Parameters for sending an XLM payment */ +export interface SendXLMParams { + /** Secret key of the source account (S...) */ + sourceSecret: string; + /** Public key of the destination account (G...) */ + destination: string; + /** Amount of XLM to send (as string for precision, e.g. "10.5") */ + amount: string; + /** Optional memo text (max 28 bytes) */ + memo?: string; +} + +/** + * Parameters for sending an issued asset (or native XLM) payment. + * + * This is the generalized form of {@link SendXLMParams} that adds an `asset` + * field. Passing `{ code: 'XLM' }` (or `{ code: 'native' }`) is equivalent + * to calling {@link sendXLM} — native XLM behaviour is fully preserved. + * + * For issued assets (e.g. USDC, EURT) the `asset.issuer` must be provided + * and the destination account must hold an authorized trustline for the + * asset before the payment can succeed. + * + * @example Native XLM (backwards-compatible path) + * ```ts + * await sendAsset({ + * sourceSecret: wallet.secretKey, + * destination: receiverPublicKey, + * amount: '10', + * asset: { code: 'XLM' }, + * }); + * ``` + * + * @example Issued asset (USDC) + * ```ts + * await sendAsset({ + * sourceSecret: wallet.secretKey, + * destination: receiverPublicKey, + * amount: '50', + * asset: { code: 'USDC', issuer: usdcIssuerPublicKey }, + * memo: 'invoice #42', + * }); + * ``` + */ +export interface SendAssetParams { + /** Secret key of the source account (S...) */ + sourceSecret: string; + /** Public key of the destination account (G...) */ + destination: string; + /** Amount to send (as string for precision, e.g. "10.5") */ + amount: string; + /** + * Asset to send. Pass `{ code: 'XLM' }` for native XLM. + * For issued assets supply both `code` and `issuer`. + */ + asset: StellarAssetSpec; + /** Optional memo text (max 28 bytes) */ + memo?: string; + /** + * When `true`, a preflight trustline check is run against Horizon before + * building the transaction. Defaults to `true` for issued assets; + * has no effect for native XLM (no trustline required). + */ + skipTrustlineCheck?: boolean; +} + +/** Result of a successful payment */ +export interface PaymentResult { + /** Whether the transaction was successful */ + success: boolean; + /** Transaction hash */ + hash: string; + /** Ledger number the transaction was included in */ + ledger: number; + /** Fee charged in stroops */ + fee: string; + /** Source account public key */ + sourceAccount: string; + /** Destination account public key */ + destinationAccount: string; + /** Amount sent */ + amount: string; + /** Timestamp of the transaction */ + createdAt: string; + /** + * Asset that was sent. Present for issued-asset payments; absent (undefined) + * for native XLM payments produced by {@link sendXLM} for backward + * compatibility. Payments produced by {@link sendAsset} always include this + * field. + */ + asset?: StellarAssetSpec; +} + +// ─── Transactions ─────────────────────────────────────────────────────────── + +/** + * @deprecated Use {@link TransactionSummary}. Retained as an alias for + * backward compatibility with existing consumers. + */ +export type TransactionRecord = TransactionSummary; +/** A single payment summary — the SDK's stable typed model for one payment operation. */ +export interface PaymentSummary { + /** Operation ID */ + id: string; + /** Transaction hash this operation belongs to */ + transactionHash: string; + /** Operation type */ + type: string; + /** ISO 8601 timestamp */ + createdAt: string; + /** Source account */ + from: string; + /** Destination account */ + to: string; + /** Amount transferred */ + amount: string; + /** Asset code */ + asset: string; + /** Asset issuer (empty for native) */ + assetIssuer: string; + /** Horizon paging token (cursor) for this record */ + pagingToken: string; +} +/** + * @deprecated Use {@link PaymentSummary}. Retained as an alias for + * backward compatibility with existing consumers. + */ +export type PaymentRecord = PaymentSummary; +/** Paginated transaction list */ +export interface TransactionList { + /** Array of transaction summaries */ + records: TransactionSummary[]; + /** Number of records returned */ + count: number; + /** Paging token of the last record, for fetching the next page (undefined when empty) */ + nextCursor?: string; +} +/** Paginated payment list */ +export interface PaymentList { + /** Array of payment summaries */ + records: PaymentSummary[]; + /** Number of records returned */ + count: number; + /** Paging token of the last record, for fetching the next page (undefined when empty) */ + nextCursor?: string; +} + +// ─── Transaction Filtering ─────────────────────────────────────────────────── + +/** + * Structural shape shared by {@link TransactionSummary} and + * {@link PaymentSummary} that the pure filtering helpers operate on. + * + * Every field except `createdAt` is optional so the same helper functions + * work across both record types — including records that lack asset or + * counterparty data (e.g. a raw {@link TransactionSummary} has no `asset`). + */ +export interface FilterableTransaction { + /** ISO 8601 timestamp used for date-range filtering */ + createdAt: string; + /** Present on transaction records; the tx source account */ + sourceAccount?: string; + /** Present on payment records; the sending account */ + from?: string; + /** Present on payment records; the receiving account */ + to?: string; + /** Present on payment records; the asset code (e.g. "XLM", "USDC") */ + asset?: string; + /** Present on payment records; the asset issuer (empty for native XLM) */ + assetIssuer?: string; +} + +/** Sort direction supported by {@link sortTransactionsByDate}. */ +export type TransactionSortOrder = 'newest' | 'oldest'; + +/** + * Minimal transaction-like shape accepted by the date sorting helper. + * + * `createdAt` is optional because callers may be sorting partially populated + * API records. Missing and invalid dates are retained at the end of the result. + */ +export interface SortableTransaction { + /** Timestamp used for sorting, normally an ISO 8601 string. */ + createdAt?: string | Date | null; +} + +/** Options for the combined {@link filterTransactions} helper. */ +export interface FilterTransactionsOptions { + /** Keep only records matching this direction relative to `account` */ + direction?: TransactionDirection; + /** + * Reference Stellar account (G...) used to resolve `direction` and + * `counterparty`. Required for those two filters; ignored otherwise. + */ + account?: string; + /** Keep only records for this asset code (e.g. "XLM", "USDC"). */ + asset?: string; + /** When filtering by asset, optionally scope to a specific issuer. */ + assetIssuer?: string; + /** Keep only records created on or after this date (string or Date). */ + startDate?: string | Date; + /** Keep only records created on or before this date (string or Date). */ + endDate?: string | Date; + /** Keep only records whose counterparty equals this account (needs `account`). */ + counterparty?: string; +} + +// ─── Soroban / Vault ──────────────────────────────────────────────────────── + +/** Parameters for a vault deposit */ +export interface VaultDepositParams { + /** Secret key of the depositor */ + sourceSecret: string; + /** Amount to deposit (as string) */ + amount: string; + /** Vault contract ID */ + contractId: string; +} + +/** Parameters for a vault withdrawal */ +export interface VaultWithdrawParams { + /** Secret key of the withdrawer */ + sourceSecret: string; + /** Amount to withdraw (as string) */ + amount: string; + /** Vault contract ID */ + contractId: string; +} + +/** Vault operation result */ +export interface VaultResult { + /** Whether the operation succeeded */ + success: boolean; + /** Transaction hash (if submitted on-chain) */ + hash?: string; + /** Resulting balance after operation */ + balance?: string; + /** Error message if failed */ + error?: string; +} + +/** Vault balance query params */ +export interface VaultBalanceParams { + /** Public key of the user */ + publicKey: string; + /** Vault contract ID */ + contractId: string; +} + +// ─── Friendbot / Funding ──────────────────────────────────────────────────── + +/** + * Result of funding a testnet account via Friendbot. + * + * @remarks **Testnet only.** Friendbot is not available on Stellar mainnet. + * + * @example + * ```ts + * const result = await fundTestnetAccount(wallet.publicKey); + * if (result.success) { + * console.log('Funded!', result.hash, 'ledger:', result.ledger); + * } + * ``` + */ +export interface FundResult { + /** Whether the funding request was successful */ + success: boolean; + + /** + * The Stellar public key (G...) that was funded. + * Always present, mirrors the input public key for easy destructuring. + */ + publicKey: string; + + /** + * Transaction hash of the Friendbot funding transaction. + * Present on success; used to look up the transaction on a block explorer. + */ + hash?: string; + + /** + * Friendbot's internal operation/record ID. + * Useful as a fallback identifier when `hash` is not available. + */ + friendbotId?: string; + + /** + * Ledger sequence number the funding transaction was included in. + * Present on success. + */ + ledger?: number; + + /** + * ISO 8601 timestamp of when the funding transaction was created. + * Present on success. + */ + createdAt?: string; + + /** + * Fee charged by the Friendbot transaction (in stroops). + * Present on success. + */ + feeCharged?: string; + + /** + * The Friendbot's own source account public key. + * Present on success; useful for audit purposes. + */ + friendbotAccount?: string; + + /** + * Human-readable error message when `success` is `false`. + * Contains the Friendbot HTTP status and response body on HTTP errors. + */ + error?: string; +} + +// ─── Result Wrappers ──────────────────────────────────────────────────────── + +/** + * A typed success result. Returned by safe wrapper functions when an + * operation completes without throwing. + * + * @typeParam T - The value type on success + * + * @example + * ```ts + * const result = await safeGetBalance(publicKey); + * if (result.ok) { + * console.log(result.value.nativeBalance); + * } + * ``` + */ +export interface SuccessResult { + /** Always `true` — use this to narrow to `SuccessResult` */ + ok: true; + /** The successful return value */ + value: T; +} + +/** + * A typed failure result. Returned by safe wrapper functions when an + * operation throws. The original `PocketPayError` is always preserved. + * + * @example + * ```ts + * const result = await safeGetBalance(publicKey); + * if (!result.ok) { + * console.error(result.error.code, result.error.message); + * } + * ``` + */ +export interface FailureResult { + /** Always `false` — use this to narrow to `FailureResult` */ + ok: false; + /** The `PocketPayError` that caused the failure */ + error: PocketPayError; +} + +/** + * A discriminated union of {@link SuccessResult} and {@link FailureResult}. + * + * Check the `ok` property to narrow to the correct variant: + * - `ok === true` → `SuccessResult` — access `.value` + * - `ok === false` → `FailureResult` — access `.error` + * + * @typeParam T - The value type on success + * + * @example + * ```ts + * const result: PocketPayResult = await safeGetBalance(key); + * if (result.ok) { + * console.log(result.value.nativeBalance); + * } else { + * console.error(result.error.code); + * } + * ``` + */ +export type PocketPayResult = SuccessResult | FailureResult; + +// ─── Errors ───────────────────────────────────────────────────────────────── + +/** Metadata for validation errors to identify the field and reason */ +export interface ValidationMetadata { + /** The input field that failed validation (e.g., 'publicKey', 'amount') */ + field: string; + /** The reason validation failed (e.g., 'invalid_format', 'too_long') */ + reason: string; + /** Optional: The value that was provided (never include secrets!) */ + value?: string | number; +} + +/** Custom SDK error with additional context */ +export class PocketPayError extends Error { + /** Machine-readable error code */ + public readonly code: string; + /** HTTP status code (if applicable) */ + public readonly statusCode?: number; + /** Original error that caused this error */ + public readonly cause?: Error; + /** Validation metadata (if this is a validation error) */ + public readonly validation?: ValidationMetadata; + /** Transaction hash (if associated with a transaction) */ + public readonly transactionHash?: string; + /** Whether the operation is safe to retry */ + public readonly retryable?: boolean; + + constructor( + message: string, + code: string, + arg3?: number | { + statusCode?: number; + cause?: Error; + validation?: ValidationMetadata; + }, + arg4?: Error | string, + arg5?: string | boolean, + arg6?: boolean + ) { + super(message); + this.name = 'PocketPayError'; + this.code = code; + + if (typeof arg3 === 'object' && arg3 !== null) { + this.statusCode = arg3.statusCode; + this.cause = arg3.cause; + this.validation = arg3.validation; + } else { + this.statusCode = arg3 as number | undefined; + if (typeof arg4 === 'object') { + this.cause = arg4 as Error; + } + } + + if (typeof arg4 === 'string') { + this.transactionHash = arg4; + this.retryable = typeof arg5 === 'boolean' ? arg5 : false; + } else if (typeof arg5 === 'string') { + this.transactionHash = arg5; + this.retryable = typeof arg6 === 'boolean' ? arg6 : false; + } else if (typeof arg5 === 'boolean') { + this.retryable = arg5; + } + + Object.setPrototypeOf(this, PocketPayError.prototype); + } +} + +/** Options for cursor-based pagination on list queries. */ +export interface PaginationOptions { + /** Max records to return (default: 10) */ + limit?: number; + /** Sort order by ledger time (default: "desc") */ + order?: 'asc' | 'desc'; + /** Horizon paging token to start after (for fetching the next page) */ + cursor?: string; +} + + +// ─── Result Warning & Recovery Hint ───────────────────────────────────────── + +/** + * A non-fatal warning attached to a successful or failed result. + */ +export interface ResultWarning { + /** Machine-readable warning code */ + code: string; + /** Human-readable description */ + message: string; + /** Optional structured metadata */ + metadata?: Record; +} + +/** + * An actionable suggestion for mitigation or recovery. + */ +export interface RecoveryHint { + /** Well-known action string */ + action: string; + /** Human-readable description */ + message: string; + /** Whether the operation is safe to retry automatically */ + retryable?: boolean; + /** Suggested delay in milliseconds before retrying */ + suggestedDelayMs?: number; + /** Optional structured metadata */ + metadata?: Record; +} + +// ─── Enhanced Result Wrappers ─────────────────────────────────────────────── + +export interface EnhancedSuccessResult { + ok: true; + value: T; + warnings?: ResultWarning[]; + recoveryHints?: RecoveryHint[]; +} + +export interface EnhancedFailureResult { + ok: false; + error: PocketPayError; + warnings?: ResultWarning[]; + recoveryHints?: RecoveryHint[]; +} + +export type EnhancedPocketPayResult = EnhancedSuccessResult | EnhancedFailureResult; + +// ─── Trustline Validation ─────────────────────────────────────────────────── + +/** + * Specification of a Stellar asset (Native XLM or an issued asset). + * + * For native XLM: `code` is `"XLM"` or `"native"`, `issuer` is omitted or empty. + * For issued assets: `code` is 1-12 alphanumeric characters, `issuer` is the Stellar public key (G...). + */ +export interface StellarAssetSpec { + /** Asset code (e.g. "XLM", "USDC", "EURT") */ + code: string; + /** Stellar public key (G...) of the asset issuer (empty/omitted for native XLM) */ + issuer?: string; +} + +/** Status discriminant for trustline verification results. */ +export type TrustlineStatus = + | 'valid' // Destination can receive the payment + | 'native_xlm' // Native XLM requires no trustline check + | 'missing_trustline' // Destination account has no trustline for this asset + | 'not_authorized' // Trustline exists but issuer authorization is lacking + | 'limit_exceeded' // Payment amount exceeds remaining trustline capacity + | 'account_not_found'; // Destination account does not exist / unfunded + +/** + * Result of a destination trustline verification check. + */ +export interface TrustlineCheckResult { + /** Whether the destination account is ready to receive the issued asset payment */ + valid: boolean; + /** Granular status code indicating the trustline validation outcome */ + status: TrustlineStatus; + /** Destination Stellar public key (G...) */ + destination: string; + /** The asset specification evaluated */ + asset: StellarAssetSpec; + /** Current balance held by destination for this asset (if trustline exists) */ + currentBalance?: string; + /** Maximum trustline limit configured by destination (if trustline exists) */ + limit?: string; + /** Remaining capacity (`limit - currentBalance`) for this asset (if trustline exists) */ + availableCapacity?: string; + /** Whether the trustline is authorized by the issuer (if issuer requires auth) */ + isAuthorized?: boolean; + /** Machine-readable error code if `valid` is `false` */ + errorCode?: string; + /** Human-readable explanation of the validation result */ + message?: string; +} + +/** Options for trustline verification checks */ +export interface TrustlineCheckOptions { + /** Optional payment amount (as decimal string) to test against trustline capacity */ + amount?: string; + /** Optional SDK config overrides */ + config?: Partial; +} + +// ─── Retry Policy ──────────────────────────────────────────────────────────── + +/** + * The classified outcome of a single transaction submission attempt. + * + * Use the `kind` discriminant to branch: + * + * - `"success"` — the transaction was accepted and confirmed on-chain. + * - `"retryable_failure"` — the failure is transient (e.g. rate-limit, transient + * network glitch). It is safe to submit the **same signed transaction** again. + * - `"non_retryable_failure"` — the transaction was definitively rejected + * (e.g. bad sequence number, insufficient balance). Retrying the same envelope + * will always fail. A new transaction must be built. + * - `"unknown_status"` — a timeout or network error means we cannot tell whether + * the transaction reached validators. The transaction **must not** be blindly + * resubmitted; call {@link pollTransactionStatus} first or wait for + * {@link withRetryPolicy} to poll automatically. + * + * @example + * ```ts + * const outcome = classifySubmissionOutcome(error); + * switch (outcome.kind) { + * case 'success': // handle success + * case 'retryable_failure': // safe to resubmit same envelope + * case 'non_retryable_failure': // rebuild transaction + * case 'unknown_status': // poll before deciding + * } + * ``` + */ +export type SubmissionOutcome = + | { + /** Transaction accepted and confirmed by Stellar validators. */ + kind: 'success'; + /** Transaction hash returned by Horizon. */ + transactionHash: string; + } + | { + /** + * Transient failure — the same signed envelope may be resubmitted. + * + * Causes include: HTTP 429 (rate limit), HTTP 503 (service unavailable), + * and other recoverable network errors that do not touch on-chain state. + */ + kind: 'retryable_failure'; + /** The underlying SDK error. */ + error: PocketPayError; + /** + * Minimum suggested delay in milliseconds before the next attempt. + * Derived from the error type; callers may apply additional jitter. + */ + suggestedDelayMs: number; + } + | { + /** + * Definitive on-chain rejection — the same envelope will always fail. + * + * Causes include: `tx_bad_seq`, `tx_insufficient_balance`, `tx_bad_auth`, + * and other Horizon result codes that indicate permanent failure. + * A new transaction (new sequence number, re-signed) must be built. + */ + kind: 'non_retryable_failure'; + /** The underlying SDK error containing Horizon result codes. */ + error: PocketPayError; + } + | { + /** + * Unknown outcome — the submission timed out or the network was + * interrupted before a definitive response arrived. + * + * **Do not blindly resubmit.** The transaction may have been accepted + * by validators already. Use {@link pollTransactionStatus} (or + * {@link withRetryPolicy} which does this automatically) to determine + * the real state before taking further action. + */ + kind: 'unknown_status'; + /** The underlying SDK error (`code === 'TX_STATUS_UNKNOWN'`). */ + error: PocketPayError; + /** + * The transaction hash to poll on Horizon. + * Defined whenever the hash was available at the time of submission. + */ + transactionHash?: string; + }; + +/** + * Configuration for {@link withRetryPolicy}. + * + * Controls how many times the SDK will retry a submission and how long it + * waits between attempts. Only {@link SubmissionOutcome | retryable_failure} + * outcomes trigger a retry. `unknown_status` outcomes are resolved via + * status polling rather than blind resubmission. + * + * @example + * ```ts + * const policy: RetryPolicy = { + * maxAttempts: 4, + * initialBackoffMs: 1_000, + * maxBackoffMs: 16_000, + * backoffMultiplier: 2, + * jitter: true, + * }; + * const result = await withRetryPolicy(transaction, policy); + * ``` + */ +export interface RetryPolicy { + /** + * Total number of submission attempts, including the first. + * + * Must be ≥ 1. A value of 1 means "try once with no retries". + * @default 4 + */ + maxAttempts: number; + + /** + * Delay before the second attempt in milliseconds. + * + * Each subsequent attempt doubles this value (capped at `maxBackoffMs`). + * @default 1000 + */ + initialBackoffMs: number; + + /** + * Upper bound on the inter-attempt delay in milliseconds. + * @default 16000 + */ + maxBackoffMs: number; + + /** + * Multiplier applied to the delay on each retry. + * @default 2 + */ + backoffMultiplier: number; + + /** + * When `true`, adds a random fraction of the computed delay to reduce + * thundering-herd effects when many clients retry simultaneously. + * @default true + */ + jitter: boolean; + + /** + * Optional SDK config overrides forwarded to the underlying Horizon calls. + */ + config?: Partial; + + /** + * Optional callback invoked after each failed attempt. + * + * Useful for logging, metrics, or UI feedback. + * + * @param attempt - 1-based index of the attempt that just failed. + * @param outcome - The classified outcome of this attempt. + * @param delayMs - How long the policy will wait before the next attempt + * (0 if this was the last allowed attempt). + */ + onAttempt?: (attempt: number, outcome: SubmissionOutcome, delayMs: number) => void; +} + +/** + * Result returned by {@link withRetryPolicy} when all attempts have been + * exhausted without an unambiguous success. + * + * The `finalOutcome` tells callers why the policy gave up: + * - `"non_retryable_failure"` — rejected on-chain; rebuild required. + * - `"unknown_status"` — polling could not confirm status; manual check needed. + * - `"retryable_failure"` — retries were exhausted; transient error persisted. + */ +export interface RetryPolicyExhaustedResult { + /** Always `false` — the submission was not confirmed. */ + success: false; + /** The outcome kind that caused the policy to give up. */ + finalOutcome: Exclude; + /** The last error received. */ + error: PocketPayError; + /** Number of attempts made. */ + attempts: number; +} diff --git a/tests/config-validation.test.ts b/tests/config-validation.test.ts new file mode 100644 index 0000000..b708c6f --- /dev/null +++ b/tests/config-validation.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + validatePocketPayConfig, + ConfigValidationResult, + ConfigValidationIssue, +} from '../src'; + +describe('validatePocketPayConfig', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.STELLAR_NETWORK; + delete process.env.STELLAR_HORIZON_URL; + delete process.env.STELLAR_SOROBAN_RPC_URL; + delete process.env.STELLAR_TIMEOUT; + delete process.env.STELLAR_CONTRACT_ID; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe('Valid Configuration', () => { + it('returns valid result with default testnet config when called with no arguments', () => { + const result = validatePocketPayConfig(); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + expect(result.issues).toHaveLength(0); + expect(result.config).toBeDefined(); + expect(result.config?.network).toBe('testnet'); + expect(result.config?.horizonUrl).toContain('testnet'); + expect(result.config?.sorobanRpcUrl).toContain('testnet'); + expect(result.config?.timeout).toBe(30000); + }); + + it('returns valid result for explicit valid testnet overrides', () => { + const result = validatePocketPayConfig({ + network: 'testnet', + horizonUrl: 'https://custom-testnet.example.com', + sorobanRpcUrl: 'https://custom-soroban-testnet.example.com', + timeout: 15000, + contractId: 'CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2RL5', + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.config).toEqual({ + network: 'testnet', + horizonUrl: 'https://custom-testnet.example.com', + sorobanRpcUrl: 'https://custom-soroban-testnet.example.com', + timeout: 15000, + contractId: 'CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2RL5', + }); + }); + + it('returns valid result for explicit valid mainnet config', () => { + const result = validatePocketPayConfig({ + network: 'mainnet', + horizonUrl: 'https://horizon.stellar.org', + sorobanRpcUrl: 'https://soroban.stellar.org', + timeout: 30000, + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.config?.network).toBe('mainnet'); + }); + }); + + describe('Malformed & Invalid Inputs (Errors)', () => { + it('reports error for unsupported network name', () => { + const result = validatePocketPayConfig({ network: 'invalid-net' as any }); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject({ + severity: 'error', + field: 'network', + code: 'INVALID_NETWORK', + }); + expect(result.config).toBeUndefined(); + }); + + it('reports error for invalid Horizon URL format and protocol', () => { + const result = validatePocketPayConfig({ + horizonUrl: 'not-a-url', + }); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'horizonUrl' && e.code === 'INVALID_HORIZON_URL')).toBe(true); + + const ftpResult = validatePocketPayConfig({ + horizonUrl: 'ftp://horizon.example.com', + }); + expect(ftpResult.valid).toBe(false); + expect(ftpResult.errors.some((e) => e.field === 'horizonUrl')).toBe(true); + }); + + it('reports error for invalid Soroban RPC URL format and protocol', () => { + const result = validatePocketPayConfig({ + sorobanRpcUrl: 'ws://soroban.example.com', + }); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'sorobanRpcUrl' && e.code === 'INVALID_SOROBAN_RPC_URL')).toBe(true); + }); + + it('reports error for negative or zero timeout', () => { + const negResult = validatePocketPayConfig({ timeout: -5000 }); + expect(negResult.valid).toBe(false); + expect(negResult.errors.some((e) => e.field === 'timeout' && e.code === 'INVALID_TIMEOUT')).toBe(true); + + const zeroResult = validatePocketPayConfig({ timeout: 0 }); + expect(zeroResult.valid).toBe(false); + expect(zeroResult.errors.some((e) => e.field === 'timeout' && e.code === 'INVALID_TIMEOUT')).toBe(true); + }); + + it('reports error for non-numeric or NaN timeout', () => { + const strResult = validatePocketPayConfig({ timeout: 'invalid' as any }); + expect(strResult.valid).toBe(false); + expect(strResult.errors.some((e) => e.field === 'timeout')).toBe(true); + + const nanResult = validatePocketPayConfig({ timeout: NaN }); + expect(nanResult.valid).toBe(false); + expect(nanResult.errors.some((e) => e.field === 'timeout')).toBe(true); + }); + + it('reports error for malformed contract ID', () => { + const badLength = validatePocketPayConfig({ contractId: 'C123' }); + expect(badLength.valid).toBe(false); + expect(badLength.errors.some((e) => e.field === 'contractId' && e.code === 'INVALID_CONTRACT_ID')).toBe(true); + + const badPrefix = validatePocketPayConfig({ + contractId: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2RL5', + }); + expect(badPrefix.valid).toBe(false); + expect(badPrefix.errors.some((e) => e.field === 'contractId')).toBe(true); + }); + }); + + describe('Advisory Warnings', () => { + it('warns when unencrypted HTTP protocol is used for remote host', () => { + const result = validatePocketPayConfig({ + horizonUrl: 'http://remote-horizon.example.com', + }); + + expect(result.valid).toBe(true); // Warning does not invalidate config + expect(result.warnings.some((w) => w.code === 'INSECURE_HTTP_URL' && w.field === 'horizonUrl')).toBe(true); + }); + + it('does not warn for http protocol on localhost', () => { + const result = validatePocketPayConfig({ + horizonUrl: 'http://localhost:8000', + sorobanRpcUrl: 'http://127.0.0.1:8000', + }); + + expect(result.valid).toBe(true); + expect(result.warnings.filter((w) => w.code === 'INSECURE_HTTP_URL')).toHaveLength(0); + }); + + it('warns when endpoint network does not match configured network', () => { + const result = validatePocketPayConfig({ + network: 'mainnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + }); + + expect(result.valid).toBe(true); + expect(result.warnings.filter((w) => w.code === 'NETWORK_MISMATCH')).toHaveLength(2); + }); + + it('warns when extreme timeout values are configured', () => { + const lowResult = validatePocketPayConfig({ timeout: 500 }); + expect(lowResult.valid).toBe(true); + expect(lowResult.warnings.some((w) => w.code === 'EXTREME_TIMEOUT')).toBe(true); + + const highResult = validatePocketPayConfig({ timeout: 150000 }); + expect(highResult.valid).toBe(true); + expect(highResult.warnings.some((w) => w.code === 'EXTREME_TIMEOUT')).toBe(true); + }); + }); + + describe('Security & Sensitive Data Redaction', () => { + it('does not expose secret key material in issue outputs', () => { + const secretKey = 'SDXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX4L5'; + const result = validatePocketPayConfig({ + network: secretKey as any, + }); + + expect(result.valid).toBe(false); + const issue = result.errors.find((e) => e.field === 'network'); + expect(issue).toBeDefined(); + + const issueStr = JSON.stringify(issue); + expect(issueStr).not.toContain(secretKey); + expect(issueStr).toContain('S[REDACTED]'); + }); + }); +}); From afd61e00ec480f6491221e9682d283f93a5917ab Mon Sep 17 00:00:00 2001 From: emmyoat Date: Thu, 23 Jul 2026 17:02:43 +0100 Subject: [PATCH 3/4] docs: add SDK dependency review standards and update README links --- CONTRIBUTING.md | 2 +- README.md | 1 + docs/dependency-review.md | 88 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 docs/dependency-review.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a2aa79..41101c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ examples/ Runnable usage examples - Run `npm run lint` before pushing — this type-checks the full source tree. - Match the naming and file conventions already present in `src/`. - Keep functions focused and add JSDoc comments for any exported API. -- Do not introduce new runtime dependencies without discussion in the issue first. +- Do not introduce new dependencies without following the [Dependency Review Standards](./docs/dependency-review.md) and discussing in the issue first. --- diff --git a/README.md b/README.md index c5ca77d..1d467b1 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ npm install @axionvera/pocketpay-sdk - [Release Checklist](./docs/release-checklist.md) - Pre-release verification steps for maintainers - [Architecture Decision Records](./docs/adr/) - Records of significant SDK design decisions and their rationale - [Support Policy](./docs/support-policy.md) - Supported runtimes, versions, network status, and maintenance expectations +- [Dependency Review Standards](./docs/dependency-review.md) - Guidelines for evaluating, adding, and updating SDK dependencies - [Changelog](./CHANGELOG.md) - Track changes across SDK versions ## Package Root Imports diff --git a/docs/dependency-review.md b/docs/dependency-review.md new file mode 100644 index 0000000..4cb0fa1 --- /dev/null +++ b/docs/dependency-review.md @@ -0,0 +1,88 @@ +# SDK Dependency Review Standards + +This document outlines the guidelines and review process for introducing, evaluating, and updating third-party dependencies in the Stellar PocketPay SDK. + +--- + +## Core Philosophy + +The PocketPay SDK targets mobile applications (React Native / Expo), web apps, and server environments. Every runtime dependency added to the SDK directly impacts consumer bundle size, attack surface, startup performance, and long-term maintenance overhead. + +**Key Principles:** +1. **Minimal Runtime Footprint**: Keep `dependencies` as lean as possible. Prefer standard JavaScript/Node.js/Web APIs or standard Stellar SDK primitives over external utility libraries. +2. **Security & Supply Chain Protection**: Every dependency is code executed inside consuming applications. We rigorously audit dependency maintainership, transitive trees, and security track records. +3. **Strict License Compliance**: Only open-source licenses compatible with MIT (such as MIT, Apache 2.0, or BSD) are permitted. + +--- + +## Criteria for Adding Dependencies + +Before proposing or adding a new third-party dependency, contributors and maintainers must evaluate the request against the following criteria: + +### 1. Runtime (`dependencies`) vs. Development (`devDependencies`) + +- **Runtime Dependencies (`dependencies`)**: Require the highest bar for approval. A new runtime dependency will only be considered if: + - It solves a complex domain problem (e.g. core cryptography, official protocol bindings) that cannot be safely implemented internally in under ~100 lines of code. + - The functionality cannot be fulfilled by `@stellar/stellar-sdk` or standard ES2022+ features. + - It adds negligible transitive dependencies (preferably 0 sub-dependencies). + +- **Development Dependencies (`devDependencies`)**: Evaluated for build performance, developer experience, and test reliability. They must not pollute the published SDK package (`dist/`). + +### 2. Evaluation Criteria Checklist + +When requesting a new dependency, contributors should provide justification covering: + +| Criterion | Requirement | +|---|---| +| **Purpose & Justification** | Clear explanation of why the package is necessary and what SDK capability it enables. | +| **Alternatives Analysis** | Demonstration that native implementations or existing SDK helpers are insufficient or unsafe. | +| **Maintenance Health** | Active maintenance history, recent releases within the last 6 months, responsive issue tracker, and multiple active maintainers. | +| **Security Track Record** | Zero unpatched Known Vulnerabilities (CVEs). Proven history of prompt security patching. | +| **Transitive Weight** | Small install size and minimal tree of nested dependencies. | +| **License Compatibility** | Permissive open-source license (MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause). Copyleft licenses (GPL, AGPL) are **prohibited**. | + +--- + +## Criteria for Updating Dependencies + +Dependency updates ensure security fixes, performance improvements, and compatibility with modern JS runtimes, but must be managed to prevent unexpected regressions. + +### 1. Patch and Minor Updates (`x.Y.Z`) +- **Frequency**: Regular background review (e.g. monthly or via automated dependency PRs). +- **Requirements**: + - Full test suite (`npm run verify`) must pass without warnings. + - No breaking changes to public API signatures or peer dependency constraints. + - TypeScript definitions (`dist/index.d.ts`) must remain identical or strictly additive. + +### 2. Major Version Updates (`X.0.0`) +- **Requirements**: + - Thorough review of upstream changelogs and breaking change notices. + - Evaluation of runtime compatibility (Node.js ≥ 18, React Native Metro bundling, web browsers). + - Explicit testing against consuming applications (e.g. `pocketpay-mobile`). + - Documentation of any required migration steps in `CHANGELOG.md`. + +### 3. Emergency Security Patches +- **Protocol**: When a vulnerability (CVE) is reported in an ambient dependency: + - Immediate priority: patch or override affected sub-dependencies. + - Run full verification (`npm run verify` and integration tests). + - Release a patch SDK version immediately. + +--- + +## Review and Approval Process for Contributors + +When submitting a Pull Request that modifies `package.json` or `package-lock.json`: + +1. **State the Justification**: Describe in the PR description why the dependency change is needed and reference the issue discussion. +2. **Verify License & Vulnerabilities**: Run `npm audit` to verify zero vulnerabilities exist. +3. **Check Lockfile Integrity**: Ensure `package-lock.json` changes are cleanly generated and minimal. +4. **Maintainer Sign-Off**: PRs introducing new `dependencies` require explicit review and approval from at least two SDK maintainers. + +--- + +## Periodic Audits + +Maintainers conduct periodic reviews of the dependency graph: +- **Audit Command**: `npm audit` run in CI on every push and PR. +- **Circular & Size Audits**: `npm run check:circular` and build size inspection. +- **Unused Dependencies**: Periodic pruning of unused packages to keep the SDK clean and maintainable. From c4327e69d0c6b7016b13302f172284333f10384f Mon Sep 17 00:00:00 2001 From: emmyoat Date: Thu, 23 Jul 2026 19:03:55 +0100 Subject: [PATCH 4/4] feat(wallet): improve wallet import validation and safe error handling --- docs/api-reference.md | 19 ++++ docs/error-handling.md | 3 +- docs/wallet-import-safety.md | 37 ++++++++ src/index.ts | 3 + src/utils/index.ts | 67 ++++++++++++-- src/wallet/index.ts | 89 ++++++++++++++++++- tests/wallet.test.ts | 164 ++++++++++++++++++++++++++++++++++- 7 files changed, 367 insertions(+), 15 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 373d00e..3acbe59 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -118,3 +118,22 @@ interface ValidationError { ``` Consumers should branch on `err.code`; codes are part of the public contract, messages are not. See `docs/getting-started.md` for a form-integration example. + +## Wallet Import API + +### `importWallet(secretKey)` +Imports an existing wallet from a Stellar secret key (S...). +- **Throws**: `PocketPayError` (`INVALID_SECRET_KEY`) with typed validation reason (`not_a_string`, `missing`, `invalid_prefix`, `invalid_length`, `invalid_format`). + +### `safeImportWallet(secretKey)` +Safely attempts to import a wallet without throwing. +- **Returns**: `PocketPayResult` (`{ ok: true, value }` or `{ ok: false, error }`). + +### `enhancedImportWallet(secretKey)` +Imports a wallet and returns an enhanced result with warnings and recovery hints. +- **Returns**: `EnhancedPocketPayResult`. + +### `safeEnhancedImportWallet(secretKey)` +Non-throwing wrapper for `enhancedImportWallet`. +- **Returns**: `EnhancedPocketPayResult`. + diff --git a/docs/error-handling.md b/docs/error-handling.md index 7794a98..a22bcfd 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -254,8 +254,9 @@ The enhanced pattern is currently applied to two pilot operations: |:---|:---|:---|:---| | `sendXLM` | `enhancedSendXLM` | `HIGH_FEE_RATIO` (fee > 10% of amount) | `fund_account`, `check_input`, `retry` | | `getBalance` | `enhancedGetBalance` | `ZERO_NATIVE_BALANCE`, `MANY_ASSETS` (> 20) | `fund_account`, `check_network`, `check_input` | +| `importWallet` | `enhancedImportWallet` | None | `check_input` | -Each pilot also has a non-throwing variant: `safeEnhancedSendXLM` and `safeEnhancedGetBalance`. +Each pilot also has a non-throwing variant: `safeEnhancedSendXLM`, `safeEnhancedGetBalance`, and `safeEnhancedImportWallet`. ### Building Your Own Enhanced Results diff --git a/docs/wallet-import-safety.md b/docs/wallet-import-safety.md index 0b0f5ab..e575c9d 100644 --- a/docs/wallet-import-safety.md +++ b/docs/wallet-import-safety.md @@ -88,6 +88,43 @@ Do not send a secret key to customer support or ask a user to paste one into a support ticket. Diagnose wallet-import problems with public keys, sanitized error codes, and non-sensitive device or transaction metadata. +## Validation & Safe Error Handling + +When importing secret keys, the SDK performs strict local validation prior to any key derivation or SDK operations: + +- **Type Check**: Non-string values throw `PocketPayError` with validation reason `not_a_string`. +- **Presence Check**: Empty or whitespace-only strings throw `PocketPayError` with validation reason `missing`. +- **Prefix Check**: Keys must start with `'S'`. Non-matching values throw `PocketPayError` with validation reason `invalid_prefix`. +- **Length Check**: Keys must be exactly 56 characters. Incorrect lengths throw `PocketPayError` with validation reason `invalid_length`. +- **Format Check**: Keys with invalid strkey payloads or checksums throw `PocketPayError` with validation reason `invalid_format`. + +### Redaction Guarantee +Secret key inputs are **never** attached to `error.validation.value` or raw error messages. Sanitized error messages give clear guidance without echoing key material. + +### Safe & Enhanced Import Helpers + +In addition to `importWallet(secretKey)`, the SDK provides non-throwing and enriched wrappers: + +```typescript +import { safeImportWallet, enhancedImportWallet } from '@axionvera/pocketpay-sdk'; + +// 1. Non-throwing result wrapper +const result = safeImportWallet(userInputKey); +if (result.ok) { + console.log('Wallet imported:', result.value.publicKey); +} else { + console.error('Import failed [code]:', result.error.code); +} + +// 2. Enhanced wrapper with recovery hints +const enhanced = enhancedImportWallet(userInputKey); +if (!enhanced.ok) { + enhanced.recoveryHints?.forEach(hint => { + console.log('Recovery action:', hint.action, hint.message); + }); +} +``` + ## Integration Checklist - Secret keys never appear in logs, errors, analytics, or crash reports. diff --git a/src/index.ts b/src/index.ts index e6233de..2033242 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,9 @@ export type { ResultWarning, RecoveryHint } from './errors'; export { createWallet, importWallet, + safeImportWallet, + enhancedImportWallet, + safeEnhancedImportWallet, getPublicKey, getBalance, getBalanceOrUnfunded, diff --git a/src/utils/index.ts b/src/utils/index.ts index f21b902..911b0eb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -38,21 +38,74 @@ export function validatePublicKey(publicKey: string): boolean { } } -export function validateSecretKey(secretKey: string): boolean { +export function validateSecretKey(secretKey: unknown): boolean { + if (typeof secretKey !== 'string') { + throw new PocketPayError( + 'Invalid Stellar secret key: secret key must be a string', + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'not_a_string', + }, + }, + ); + } + + if (!secretKey || secretKey.trim().length === 0) { + throw new PocketPayError( + 'Invalid Stellar secret key: secret key cannot be empty', + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'missing', + }, + }, + ); + } + + const trimmed = secretKey.trim(); + + if (!trimmed.startsWith('S')) { + throw new PocketPayError( + "Invalid Stellar secret key: secret key must start with 'S'", + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'invalid_prefix', + }, + }, + ); + } + + if (trimmed.length !== 56) { + throw new PocketPayError( + `Invalid Stellar secret key: secret key must be 56 characters long (got ${trimmed.length})`, + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'invalid_length', + }, + }, + ); + } + try { - StellarSDK.Keypair.fromSecret(secretKey); + StellarSDK.Keypair.fromSecret(trimmed); return true; } catch { throw new PocketPayError( - 'Invalid Stellar secret key', + 'Invalid Stellar secret key: failed strkey checksum or payload verification', 'INVALID_SECRET_KEY', { validation: { field: 'secretKey', - reason: 'invalid_format' - // Do NOT include value (secret!) - } - } + reason: 'invalid_format', + }, + }, ); } } diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 41116a7..4a646b5 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -56,14 +56,97 @@ export function createWallet(): WalletKeypair { */ export function importWallet(secretKey: string): WalletKeypair { validateSecretKey(secretKey); - const kp = StellarSDK.Keypair.fromSecret(secretKey); - return { publicKey: kp.publicKey(), secretKey: kp.secret() }; + const trimmed = secretKey.trim(); + try { + const kp = StellarSDK.Keypair.fromSecret(trimmed); + return { publicKey: kp.publicKey(), secretKey: kp.secret() }; + } catch (error) { + if (error instanceof PocketPayError) { + throw error; + } + throw new PocketPayError( + 'Invalid Stellar secret key', + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'invalid_format', + }, + cause: error instanceof Error ? error : undefined, + }, + ); + } } /** Derives the public key from a secret key. */ export function getPublicKey(secretKey: string): string { validateSecretKey(secretKey); - return StellarSDK.Keypair.fromSecret(secretKey).publicKey(); + const trimmed = secretKey.trim(); + return StellarSDK.Keypair.fromSecret(trimmed).publicKey(); +} + +/** + * Safely imports a wallet from a secret key without throwing. + * + * @param secretKey - Stellar secret key (S...) to import + * @returns A {@link PocketPayResult} with either the {@link WalletKeypair} or a {@link PocketPayError} + */ +export function safeImportWallet(secretKey: string): PocketPayResult { + try { + const wallet = importWallet(secretKey); + return toSuccessResult(wallet); + } catch (error) { + const pocketErr = + error instanceof PocketPayError + ? error + : wrapError(error, 'Failed to import wallet', 'INVALID_SECRET_KEY'); + return toFailureResult(pocketErr); + } +} + +/** + * Imports an existing wallet with an enriched result containing warnings and recovery hints. + * + * @param secretKey - Stellar secret key (S...) to import + * @returns An {@link EnhancedPocketPayResult} with the imported wallet or failure details & recovery hints + */ +export function enhancedImportWallet( + secretKey: string, +): EnhancedPocketPayResult { + const warnings: ResultWarning[] = []; + const recoveryHints: RecoveryHint[] = []; + + try { + const wallet = importWallet(secretKey); + return toEnhancedSuccessResult(wallet, warnings, recoveryHints); + } catch (error) { + const pocketErr = + error instanceof PocketPayError + ? error + : wrapError(error, 'Failed to import wallet', 'INVALID_SECRET_KEY'); + + if (pocketErr.code === 'INVALID_SECRET_KEY') { + recoveryHints.push({ + action: 'check_input', + message: 'Ensure the secret key is a valid 56-character Stellar secret key starting with S.', + retryable: false, + }); + } + + return toEnhancedFailureResult(pocketErr, warnings, recoveryHints); + } +} + +/** + * Non-throwing wrapper for {@link enhancedImportWallet}. + * + * @param secretKey - Stellar secret key (S...) to import + * @returns An enriched result that never throws + */ +export function safeEnhancedImportWallet( + secretKey: string, +): EnhancedPocketPayResult { + return enhancedImportWallet(secretKey); } /** diff --git a/tests/wallet.test.ts b/tests/wallet.test.ts index c5b73e7..98d014c 100644 --- a/tests/wallet.test.ts +++ b/tests/wallet.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { createWallet, importWallet, getPublicKey, PocketPayError } from '../src'; +import { + createWallet, + importWallet, + safeImportWallet, + enhancedImportWallet, + safeEnhancedImportWallet, + getPublicKey, + validateSecretKey, + PocketPayError, +} from '../src'; import { fundedAccount } from './fixtures'; describe('Wallet Module', () => { @@ -20,6 +29,87 @@ describe('Wallet Module', () => { }); }); + describe('validateSecretKey', () => { + it('should return true for a valid secret key', () => { + const { secretKey } = createWallet(); + expect(validateSecretKey(secretKey)).toBe(true); + }); + + it('should throw typed PocketPayError with reason "not_a_string" for non-string inputs', () => { + const invalidInputs = [null, undefined, 12345, {}, ['S123']]; + for (const input of invalidInputs) { + try { + validateSecretKey(input as any); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.field).toBe('secretKey'); + expect(pErr.validation?.reason).toBe('not_a_string'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + } + }); + + it('should throw typed PocketPayError with reason "missing" for empty or whitespace strings', () => { + for (const input of ['', ' ', '\t\n']) { + try { + validateSecretKey(input); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.reason).toBe('missing'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + } + }); + + it('should throw typed PocketPayError with reason "invalid_prefix" for key not starting with S', () => { + const wrongPrefix = 'G' + 'A'.repeat(55); + try { + validateSecretKey(wrongPrefix); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.reason).toBe('invalid_prefix'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + }); + + it('should throw typed PocketPayError with reason "invalid_length" for key with incorrect length', () => { + const shortKey = 'S1234567890'; + try { + validateSecretKey(shortKey); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.reason).toBe('invalid_length'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + }); + + it('should throw typed PocketPayError with reason "invalid_format" for 56-char string starting with S but bad strkey checksum', () => { + const badChecksum = 'S' + '0'.repeat(55); + try { + validateSecretKey(badChecksum); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.reason).toBe('invalid_format'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + }); + }); + describe('importWallet', () => { it('should import a wallet from a valid secret key', () => { const original = createWallet(); @@ -28,9 +118,25 @@ describe('Wallet Module', () => { expect(imported.secretKey).toEqual(original.secretKey); }); - it('should throw PocketPayError for invalid secret key', () => { - expect(() => importWallet('INVALID_KEY')).toThrow(PocketPayError); - expect(() => importWallet('INVALID_KEY')).toThrow('Invalid Stellar secret key'); + it('should trim surrounding whitespace on import', () => { + const original = createWallet(); + const imported = importWallet(` ${original.secretKey}\n`); + expect(imported.publicKey).toEqual(original.publicKey); + }); + + it('should throw PocketPayError for invalid secret key without leaking secret data', () => { + const secretAttempt = 'S' + 'X'.repeat(55); + try { + importWallet(secretAttempt); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.message).not.toContain(secretAttempt); + expect(JSON.stringify(pErr)).not.toContain(secretAttempt); + expect((pErr.validation as any)?.value).toBeUndefined(); + } }); it('should throw for empty string', () => { @@ -38,6 +144,55 @@ describe('Wallet Module', () => { }); }); + describe('safeImportWallet', () => { + it('should return success result for valid secret key', () => { + const original = createWallet(); + const result = safeImportWallet(original.secretKey); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.publicKey).toBe(original.publicKey); + } + }); + + it('should return failure result with INVALID_SECRET_KEY error for invalid secret key without throwing', () => { + const result = safeImportWallet('invalid-key'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(PocketPayError); + expect(result.error.code).toBe('INVALID_SECRET_KEY'); + } + }); + }); + + describe('enhancedImportWallet & safeEnhancedImportWallet', () => { + it('should return enhanced success result for valid secret key', () => { + const original = createWallet(); + const result = enhancedImportWallet(original.secretKey); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.publicKey).toBe(original.publicKey); + } + }); + + it('should return enhanced failure result with recovery hints for invalid secret key', () => { + const result = enhancedImportWallet('invalid-key'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('INVALID_SECRET_KEY'); + expect(result.recoveryHints).toBeDefined(); + expect(result.recoveryHints?.some((h) => h.action === 'check_input')).toBe(true); + } + }); + + it('safeEnhancedImportWallet should never throw and return enhanced result', () => { + const result = safeEnhancedImportWallet(''); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('INVALID_SECRET_KEY'); + } + }); + }); + describe('getPublicKey', () => { it('should derive the correct public key from a secret key', () => { const wallet = createWallet(); @@ -58,3 +213,4 @@ describe('Wallet Module', () => { }); }); }); +