From 9a686faa24af1f959270098e50bef080f992274e Mon Sep 17 00:00:00 2001 From: daxvinci Date: Sun, 30 Aug 2026 11:26:58 +0100 Subject: [PATCH 1/2] feat(wallet): add wallet status, balance, and history endpoints --- src/controllers/wallet-status.controller.ts | 74 ++++++ src/routes/index.ts | 2 + src/routes/v1/wallet.routes.ts | 38 +++ src/schemas/wallet-status.schema.ts | 11 + src/services/stellar.service.ts | 188 +++++++++++++++ src/services/wallet-status.service.ts | 122 ++++++++++ src/types/wallet-status.types.ts | 60 +++++ tests/stellar-wallet-status.service.test.ts | 214 ++++++++++++++++ tests/wallet-status.controller.test.ts | 142 +++++++++++ tests/wallet-status.service.test.ts | 255 ++++++++++++++++++++ 10 files changed, 1106 insertions(+) create mode 100644 src/controllers/wallet-status.controller.ts create mode 100644 src/routes/v1/wallet.routes.ts create mode 100644 src/schemas/wallet-status.schema.ts create mode 100644 src/services/wallet-status.service.ts create mode 100644 src/types/wallet-status.types.ts create mode 100644 tests/stellar-wallet-status.service.test.ts create mode 100644 tests/wallet-status.controller.test.ts create mode 100644 tests/wallet-status.service.test.ts diff --git a/src/controllers/wallet-status.controller.ts b/src/controllers/wallet-status.controller.ts new file mode 100644 index 00000000..75d31cf6 --- /dev/null +++ b/src/controllers/wallet-status.controller.ts @@ -0,0 +1,74 @@ +import type { Request, Response } from 'express' +import type { WalletStatusService } from '../services/wallet-status.service' +import { WalletStatusError } from '../types/wallet-status.types' +import { walletHistoryQuerySchema } from '../schemas/wallet-status.schema' +import logger from '../utils/logger' + +const PROVIDER_ERROR_STATUS: Record = { + WALLET_NOT_FOUND: 404, + HORIZON_TIMEOUT: 504, + HORIZON_UNAVAILABLE: 503, +} + +/** Only ever reads the caller's own wallet — no address parameter is accepted. */ +export class WalletStatusController { + constructor(private readonly service: WalletStatusService) {} + + getStatus = async (req: Request, res: Response): Promise => { + try { + const status = await this.service.getStatus(req.user!.id) + res.status(200).json({ success: true, data: status }) + } catch (error) { + this.respondWithError(res, error) + } + } + + getBalances = async (req: Request, res: Response): Promise => { + try { + const balances = await this.service.getBalances(req.user!.id) + res.status(200).json({ success: true, data: balances }) + } catch (error) { + this.respondWithError(res, error) + } + } + + getHistory = async (req: Request, res: Response): Promise => { + const parsed = walletHistoryQuerySchema.safeParse(req.query) + if (!parsed.success) { + res.status(400).json({ + success: false, + error: { code: 'VALIDATION_ERROR', details: parsed.error.format() }, + }) + + return + } + + try { + const { entries, nextCursor } = await this.service.getHistory(req.user!.id, parsed.data) + res.status(200).json({ + success: true, + data: entries, + meta: { + cursor: parsed.data.cursor, + nextCursor, + hasMore: nextCursor !== null, + limit: parsed.data.limit, + }, + }) + } catch (error) { + this.respondWithError(res, error) + } + } + + private respondWithError(res: Response, error: unknown): void { + if (error instanceof WalletStatusError) { + const statusCode = PROVIDER_ERROR_STATUS[error.code] ?? 500 + res.status(statusCode).json({ success: false, error: { code: error.code, message: error.message } }) + + return + } + + logger.error('[WalletStatusController] Unexpected error:', error) + res.status(500).json({ success: false, error: { code: 'INTERNAL_SERVER_ERROR' } }) + } +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 273edf20..af358411 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -12,6 +12,7 @@ import accountRoutes from './v1/account.routes' import onboardingRoutes from './v1/onboarding.routes' import consentRoutes from './v1/consent.routes' import sessionRoutes from './v1/sessions.routes' +import walletRoutes from './v1/wallet.routes' const router: Router = Router() @@ -32,5 +33,6 @@ router.use('/v1/account', accountRoutes) router.use('/v1/onboarding', onboardingRoutes) router.use('/v1/consents', consentRoutes) router.use('/v1/sessions', sessionRoutes) +router.use('/v1/wallet', walletRoutes) export default router diff --git a/src/routes/v1/wallet.routes.ts b/src/routes/v1/wallet.routes.ts new file mode 100644 index 00000000..bacfb5d6 --- /dev/null +++ b/src/routes/v1/wallet.routes.ts @@ -0,0 +1,38 @@ +import { Router } from 'express' +import { WalletStatusController } from '../../controllers/wallet-status.controller' +import { WalletStatusService } from '../../services/wallet-status.service' +import { PrismaWalletProvisioningRepository } from '../../services/wallet-provisioning.repository' +import { stellarService } from '../../services/stellar.service' +import { authenticate, requireActiveAccount } from '../../middleware/auth.middleware' +import prisma from '../../config/database' + +const repository = new PrismaWalletProvisioningRepository(prisma) +const service = new WalletStatusService(repository, stellarService) +const controller = new WalletStatusController(service) + +const router: Router = Router() + +router.use(authenticate, requireActiveAccount) + +/** + * @route GET /api/v1/wallet/status + * @desc Get the current user's wallet provisioning status, network, custody, and public address + * @access Private (active accounts only) + */ +router.get('/status', controller.getStatus) + +/** + * @route GET /api/v1/wallet/balances + * @desc Get the current user's exact on-chain balances (asset, issuer, amount, source time) + * @access Private (active accounts only) + */ +router.get('/balances', controller.getBalances) + +/** + * @route GET /api/v1/wallet/history + * @desc Get a stable, cursor-paginated payment history for the current user's wallet + * @access Private (active accounts only) + */ +router.get('/history', controller.getHistory) + +export default router diff --git a/src/schemas/wallet-status.schema.ts b/src/schemas/wallet-status.schema.ts new file mode 100644 index 00000000..943963f3 --- /dev/null +++ b/src/schemas/wallet-status.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const walletHistoryQuerySchema = z + .object({ + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + direction: z.enum(['all', 'incoming', 'outgoing']).default('all'), + }) + .strict() + +export type WalletHistoryQuery = z.infer diff --git a/src/services/stellar.service.ts b/src/services/stellar.service.ts index ecc00f43..d94d88dc 100644 --- a/src/services/stellar.service.ts +++ b/src/services/stellar.service.ts @@ -68,6 +68,42 @@ export interface AccountBalance { limit?: string; } +export interface AccountBalanceDetail { + assetType: 'native' | 'credit_alphanum4' | 'credit_alphanum12'; + assetCode: string; + issuer: string | null; + amount: string; +} + +export interface AccountSnapshot { + found: boolean; + lastModifiedTime: string | null; + balances: AccountBalanceDetail[]; +} + +export interface PaymentHistoryRecord { + id: string; + pagingToken: string; + createdAt: string; + transactionHash: string; + transactionSuccessful: boolean; + ledger: number | null; + type: string; + from: string | null; + to: string | null; + assetType: string; + assetCode: string; + issuer: string | null; + amount: string | null; + memo: string | null; + memoType: string | null; +} + +export interface PaymentHistoryPage { + records: PaymentHistoryRecord[]; + nextCursor: string | null; +} + export interface PaymentOptions { sourceSecret: string; destinationPublicKey: string; @@ -252,6 +288,87 @@ export class StellarService { return balances.find((b) => b.asset === 'XLM')?.balance ?? '0' } + /** + * Load the account's exact balances plus its Horizon last-modified time. + * A 404 (account not yet funded on-ledger) is a normal, non-error state and + * resolves to `found: false` with no balances rather than throwing. + */ + async getAccountSnapshot (publicKey: string): Promise { + try { + const account = await this.horizonServer.loadAccount(publicKey) + const balances: AccountBalanceDetail[] = account.balances.map((b) => { + if (b.asset_type === 'native') { + return { assetType: 'native', assetCode: 'XLM', issuer: null, amount: b.balance } + } + const issued = b as unknown as IssuedBalance + + return { + assetType: issued.asset_type, + assetCode: issued.asset_code, + issuer: issued.asset_issuer, + amount: issued.balance, + } + }) + + return { + found: true, + lastModifiedTime: + (account as unknown as { last_modified_time?: string }).last_modified_time ?? null, + balances, + } + } catch (err) { + if (isHorizonNotFound(err)) { + return { found: false, lastModifiedTime: null, balances: [] } + } + if (isHorizonTimeout(err)) { + throw new StellarServiceError('Horizon request timed out', 'HORIZON_TIMEOUT', err) + } + throw new StellarServiceError('Horizon is unavailable', 'HORIZON_UNAVAILABLE', err) + } + } + + /** + * Cursor-paginated payment history for an account (payments, path payments, + * and account-creation credits), using Horizon's own paging_token as the + * cursor so results stay stable under concurrent ledger writes. + */ + async getPaymentHistory ( + publicKey: string, + options: { cursor?: string; limit?: number; order?: 'asc' | 'desc' } = {} + ): Promise { + const limit = options.limit ?? 20 + + try { + let builder = this.horizonServer + .payments() + .forAccount(publicKey) + .order(options.order ?? 'desc') + .limit(limit) + .join('transactions') + + if (options.cursor) builder = builder.cursor(options.cursor) + + const page = await builder.call() + const relevant = page.records.filter((record) => + HISTORY_OPERATION_TYPES.has((record as { type: string }).type) + ) + + return { + records: relevant.map(toPaymentHistoryRecord), + nextCursor: + page.records.length > 0 + ? (page.records[page.records.length - 1] as { paging_token: string }).paging_token + : null, + } + } catch (err) { + if (isHorizonNotFound(err)) return { records: [], nextCursor: null } + if (isHorizonTimeout(err)) { + throw new StellarServiceError('Horizon request timed out', 'HORIZON_TIMEOUT', err) + } + throw new StellarServiceError('Horizon is unavailable', 'HORIZON_UNAVAILABLE', err) + } + } + // ── Payments ────────────────────────────────────────────────────────────── /** Alias kept for test compatibility. */ @@ -548,6 +665,77 @@ export class StellarService { } } +// --------------------------------------------------------------------------- +// Payment history helpers +// --------------------------------------------------------------------------- + +const HISTORY_OPERATION_TYPES = new Set([ + 'payment', + 'create_account', + 'path_payment_strict_receive', + 'path_payment_strict_send', +]) + +const MAX_MEMO_LENGTH = 256 + +/** Text memos are free-form user input; hash/id/return memos are opaque public identifiers already. */ +function applyMemoPolicy (memoType: string | null, memo: string | null): string | null { + if (!memo) return null + if (memoType === 'text') { + // eslint-disable-next-line no-control-regex + const sanitized = memo.replace(/[\x00-\x1F\x7F]/g, '').slice(0, MAX_MEMO_LENGTH) + + return sanitized.length > 0 ? sanitized : null + } + + return memo +} + +function toPaymentHistoryRecord (record: unknown): PaymentHistoryRecord { + const r = record as Record + const transaction = (r.transaction ?? undefined) as Record | undefined + const memoType = (transaction?.memo_type as string | undefined) ?? null + const rawMemo = (transaction?.memo as string | undefined) ?? null + const assetType = (r.asset_type as string | undefined) ?? 'native' + + return { + id: String(r.id), + pagingToken: String(r.paging_token), + createdAt: String(r.created_at), + transactionHash: String(r.transaction_hash), + transactionSuccessful: r.transaction_successful !== false, + ledger: typeof transaction?.ledger_attr === 'number' ? (transaction.ledger_attr as number) : null, + type: String(r.type), + from: (r.from as string | undefined) ?? (r.funder as string | undefined) ?? null, + to: (r.to as string | undefined) ?? (r.account as string | undefined) ?? null, + assetType, + assetCode: assetType === 'native' ? 'XLM' : String(r.asset_code ?? ''), + issuer: (r.asset_issuer as string | undefined) ?? null, + amount: (r.amount as string | undefined) ?? (r.starting_balance as string | undefined) ?? null, + memo: applyMemoPolicy(memoType, rawMemo), + memoType, + } +} + +function isHorizonNotFound (err: unknown): boolean { + const status = (err as { response?: { status?: number } } | undefined)?.response?.status + + return status === 404 +} + +function isHorizonTimeout (err: unknown): boolean { + const code = (err as { code?: string } | undefined)?.code + const name = (err as { name?: string } | undefined)?.name + const message = err instanceof Error ? err.message.toLowerCase() : '' + + return ( + code === 'ETIMEDOUT' || + code === 'ECONNABORTED' || + name === 'TimeoutError' || + message.includes('timeout') + ) +} + // --------------------------------------------------------------------------- // Singleton export // --------------------------------------------------------------------------- diff --git a/src/services/wallet-status.service.ts b/src/services/wallet-status.service.ts new file mode 100644 index 00000000..4d674571 --- /dev/null +++ b/src/services/wallet-status.service.ts @@ -0,0 +1,122 @@ +import type { WalletProvisioningRepository } from './wallet-provisioning.repository' +import type { AccountSnapshot, PaymentHistoryPage } from './stellar.service' +import { StellarServiceError } from './stellar.service' +import type { WalletRecord } from '../types/wallet-provisioning.types' +import { + WalletStatusError, + type WalletBalancesView, + type WalletHistoryDirection, + type WalletHistoryPageView, + type WalletHistoryQueryOptions, + type WalletStatusView, +} from '../types/wallet-status.types' + +const DEFAULT_HISTORY_LIMIT = 20 + +/** The subset of StellarService this orchestration layer depends on. */ +export interface WalletStatusStellarProvider { + getAccountSnapshot(publicKey: string): Promise + getPaymentHistory( + publicKey: string, + options?: { cursor?: string; limit?: number; order?: 'asc' | 'desc' } + ): Promise +} + +/** Orchestrates the current user's wallet status, balances, and history. Never accepts an arbitrary address. */ +export class WalletStatusService { + constructor( + private readonly repository: WalletProvisioningRepository, + private readonly stellar: WalletStatusStellarProvider, + ) {} + + async getStatus(userId: string): Promise { + const wallet = await this.repository.getByUserId(userId) + if (!wallet) { + return { status: 'NOT_PROVISIONED', network: null, custody: null, publicKey: null, provisionedAt: null } + } + + return { + status: toStatusValue(wallet.status), + network: wallet.network, + custody: wallet.custody, + publicKey: wallet.status === 'ACTIVE' ? wallet.publicKey : null, + provisionedAt: wallet.provisionedAt ? wallet.provisionedAt.toISOString() : null, + } + } + + async getBalances(userId: string): Promise { + const wallet = await this.requireActiveWallet(userId) + const snapshot = await this.wrapProviderErrors(() => + this.stellar.getAccountSnapshot(wallet.publicKey!), + ) + + return { + publicKey: wallet.publicKey!, + sourceTime: snapshot.lastModifiedTime, + balances: snapshot.balances, + } + } + + async getHistory( + userId: string, + options: WalletHistoryQueryOptions, + ): Promise { + const wallet = await this.requireActiveWallet(userId) + const publicKey = wallet.publicKey! + const limit = options.limit ?? DEFAULT_HISTORY_LIMIT + const direction = options.direction ?? 'all' + + const page = await this.wrapProviderErrors(() => + this.stellar.getPaymentHistory(publicKey, { cursor: options.cursor, limit }), + ) + + const entries = page.records + .map((record) => ({ + id: record.id, + direction: (record.to === publicKey ? 'incoming' : 'outgoing') as WalletHistoryDirection, + status: (record.transactionSuccessful ? 'success' : 'failed') as 'success' | 'failed', + assetType: record.assetType, + assetCode: record.assetCode, + issuer: record.issuer, + amount: record.amount, + transactionHash: record.transactionHash, + ledger: record.ledger, + createdAt: record.createdAt, + memo: record.memo, + memoType: record.memoType, + })) + .filter((entry) => direction === 'all' || entry.direction === direction) + + return { entries, nextCursor: page.nextCursor } + } + + private async requireActiveWallet(userId: string): Promise { + const wallet = await this.repository.getByUserId(userId) + if (!wallet || wallet.status !== 'ACTIVE' || !wallet.publicKey) { + throw new WalletStatusError('WALLET_NOT_FOUND') + } + + return wallet + } + + private async wrapProviderErrors(fn: () => Promise): Promise { + try { + return await fn() + } catch (err) { + if ( + err instanceof StellarServiceError && + (err.code === 'HORIZON_TIMEOUT' || err.code === 'HORIZON_UNAVAILABLE') + ) { + throw new WalletStatusError(err.code, err.message) + } + throw err + } + } +} + +function toStatusValue(walletStatus: WalletRecord['status']): WalletStatusView['status'] { + if (walletStatus === 'ACTIVE') return 'ACTIVE' + if (walletStatus === 'DISABLED' || walletStatus === 'FAILED') return 'UNAVAILABLE' + + return 'PENDING' +} diff --git a/src/types/wallet-status.types.ts b/src/types/wallet-status.types.ts new file mode 100644 index 00000000..a87d02f8 --- /dev/null +++ b/src/types/wallet-status.types.ts @@ -0,0 +1,60 @@ +export type WalletStatusValue = 'NOT_PROVISIONED' | 'PENDING' | 'ACTIVE' | 'UNAVAILABLE' + +export interface WalletStatusView { + status: WalletStatusValue + network: string | null + custody: string | null + publicKey: string | null + provisionedAt: string | null +} + +export interface WalletBalanceView { + assetType: 'native' | 'credit_alphanum4' | 'credit_alphanum12' + assetCode: string + issuer: string | null + amount: string +} + +export interface WalletBalancesView { + publicKey: string + sourceTime: string | null + balances: WalletBalanceView[] +} + +export type WalletHistoryDirection = 'incoming' | 'outgoing' +export type WalletHistoryFilter = 'all' | WalletHistoryDirection + +export interface WalletHistoryEntry { + id: string + direction: WalletHistoryDirection + status: 'success' | 'failed' + assetType: string + assetCode: string + issuer: string | null + amount: string | null + transactionHash: string + ledger: number | null + createdAt: string + memo: string | null + memoType: string | null +} + +export interface WalletHistoryQueryOptions { + cursor?: string + limit?: number + direction?: WalletHistoryFilter +} + +export interface WalletHistoryPageView { + entries: WalletHistoryEntry[] + nextCursor: string | null +} + +export type WalletStatusErrorCode = 'WALLET_NOT_FOUND' | 'HORIZON_TIMEOUT' | 'HORIZON_UNAVAILABLE' + +export class WalletStatusError extends Error { + constructor(readonly code: WalletStatusErrorCode, message?: string) { + super(message ?? code) + this.name = 'WalletStatusError' + } +} diff --git a/tests/stellar-wallet-status.service.test.ts b/tests/stellar-wallet-status.service.test.ts new file mode 100644 index 00000000..a99697b8 --- /dev/null +++ b/tests/stellar-wallet-status.service.test.ts @@ -0,0 +1,214 @@ +/** + * stellar-wallet-status.service.test.ts + * + * Unit tests for the wallet-status additions to StellarService: + * getAccountSnapshot() and getPaymentHistory(). No real network calls. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetAccount, mockPaymentsCall } = vi.hoisted(() => ({ + mockGetAccount: vi.fn(), + mockPaymentsCall: vi.fn(), +})) + +vi.mock('@stellar/stellar-sdk', () => { + function FakeHorizonServer(this: any) { + this.loadAccount = mockGetAccount + this.payments = () => { + const builder = { + forAccount: () => builder, + order: () => builder, + limit: () => builder, + join: () => builder, + cursor: () => builder, + call: mockPaymentsCall, + } + + return builder + } + } + + function FakeServer(this: any) {} + + return { + Keypair: { random: vi.fn(), fromSecret: vi.fn() }, + Networks: { + TESTNET: 'Test SDF Network ; September 2015', + PUBLIC: 'Public Global Stellar Network ; September 2015', + }, + Horizon: { Server: FakeHorizonServer }, + rpc: { Server: FakeServer, Api: {} }, + TransactionBuilder: vi.fn(), + Asset: { native: vi.fn() }, + Operation: {}, + Memo: {}, + BASE_FEE: '100', + nativeToScVal: vi.fn(), + scValToNative: vi.fn(), + Contract: vi.fn(), + } +}) + +import { StellarService } from '../src/services/stellar.service' + +describe('StellarService — wallet status additions', () => { + let service: StellarService + + beforeEach(() => { + vi.clearAllMocks() + service = new StellarService('testnet', '') + }) + + describe('getAccountSnapshot()', () => { + it('returns exact balances plus the Horizon last-modified time', async () => { + mockGetAccount.mockResolvedValue({ + last_modified_time: '2026-08-30T00:00:00Z', + balances: [ + { asset_type: 'native', balance: '100.1234567' }, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: 'GCISSUER...', + balance: '5.0000001', + limit: '1000.0000000', + }, + ], + }) + + const snapshot = await service.getAccountSnapshot('GPUBKEY...') + + expect(snapshot.found).toBe(true) + expect(snapshot.lastModifiedTime).toBe('2026-08-30T00:00:00Z') + expect(snapshot.balances).toEqual([ + { assetType: 'native', assetCode: 'XLM', issuer: null, amount: '100.1234567' }, + { assetType: 'credit_alphanum4', assetCode: 'USDC', issuer: 'GCISSUER...', amount: '5.0000001' }, + ]) + }) + + it('treats a 404 (unfunded account) as found: false rather than an error', async () => { + const notFound = Object.assign(new Error('not found'), { response: { status: 404 } }) + mockGetAccount.mockRejectedValue(notFound) + + const snapshot = await service.getAccountSnapshot('GPUBKEY...') + + expect(snapshot).toEqual({ found: false, lastModifiedTime: null, balances: [] }) + }) + + it('classifies a timeout as HORIZON_TIMEOUT', async () => { + mockGetAccount.mockRejectedValue( + Object.assign(new Error('timeout of 30000ms exceeded'), { code: 'ECONNABORTED' }), + ) + + await expect(service.getAccountSnapshot('GPUBKEY...')).rejects.toMatchObject({ + code: 'HORIZON_TIMEOUT', + }) + }) + + it('classifies other failures as HORIZON_UNAVAILABLE', async () => { + mockGetAccount.mockRejectedValue(new Error('ECONNREFUSED')) + + await expect(service.getAccountSnapshot('GPUBKEY...')).rejects.toMatchObject({ + code: 'HORIZON_UNAVAILABLE', + }) + }) + }) + + describe('getPaymentHistory()', () => { + it('maps payment records to normalized history entries with a stable cursor', async () => { + mockPaymentsCall.mockResolvedValue({ + records: [ + { + id: 'op-1', + paging_token: 'tok-1', + created_at: '2026-08-29T00:00:00Z', + transaction_hash: 'hash-1', + transaction_successful: true, + type: 'payment', + from: 'GSENDER', + to: 'GRECEIVER', + asset_type: 'native', + amount: '5.0000000', + transaction: { memo_type: 'text', memo: 'hello', ledger_attr: 42 }, + }, + ], + }) + + const page = await service.getPaymentHistory('GRECEIVER') + + expect(page.nextCursor).toBe('tok-1') + expect(page.records).toEqual([ + { + id: 'op-1', + pagingToken: 'tok-1', + createdAt: '2026-08-29T00:00:00Z', + transactionHash: 'hash-1', + transactionSuccessful: true, + ledger: 42, + type: 'payment', + from: 'GSENDER', + to: 'GRECEIVER', + assetType: 'native', + assetCode: 'XLM', + issuer: null, + amount: '5.0000000', + memo: 'hello', + memoType: 'text', + }, + ]) + }) + + it('strips control characters from text memos and caps their length', async () => { + mockPaymentsCall.mockResolvedValue({ + records: [ + { + id: 'op-1', + paging_token: 'tok-1', + created_at: '2026-08-29T00:00:00Z', + transaction_hash: 'hash-1', + transaction_successful: true, + type: 'payment', + from: 'GSENDER', + to: 'GRECEIVER', + asset_type: 'native', + amount: '1.0000000', + transaction: { memo_type: 'text', memo: `bad\x00memo${'x'.repeat(300)}` }, + }, + ], + }) + + const page = await service.getPaymentHistory('GRECEIVER') + + expect(page.records[0].memo).not.toMatch(/\x00/) + expect(page.records[0].memo!.length).toBeLessThanOrEqual(256) + }) + + it('excludes non-payment operation types (e.g. trustline changes)', async () => { + mockPaymentsCall.mockResolvedValue({ + records: [ + { id: 'op-1', paging_token: 'tok-1', type: 'change_trust', created_at: 'x', transaction_hash: 'h' }, + ], + }) + + const page = await service.getPaymentHistory('GRECEIVER') + + expect(page.records).toEqual([]) + }) + + it('returns an empty page for a 404 rather than throwing', async () => { + mockPaymentsCall.mockRejectedValue(Object.assign(new Error('not found'), { response: { status: 404 } })) + + const page = await service.getPaymentHistory('GRECEIVER') + + expect(page).toEqual({ records: [], nextCursor: null }) + }) + + it('classifies provider failures as HORIZON_UNAVAILABLE', async () => { + mockPaymentsCall.mockRejectedValue(new Error('ECONNRESET')) + + await expect(service.getPaymentHistory('GRECEIVER')).rejects.toMatchObject({ + code: 'HORIZON_UNAVAILABLE', + }) + }) + }) +}) diff --git a/tests/wallet-status.controller.test.ts b/tests/wallet-status.controller.test.ts new file mode 100644 index 00000000..2c68b7a8 --- /dev/null +++ b/tests/wallet-status.controller.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { WalletStatusController } from '../src/controllers/wallet-status.controller' +import { WalletStatusError } from '../src/types/wallet-status.types' +import type { WalletStatusService } from '../src/services/wallet-status.service' + +describe('WalletStatusController', () => { + let service: { + getStatus: ReturnType + getBalances: ReturnType + getHistory: ReturnType + } + let controller: WalletStatusController + let req: any + let res: any + + beforeEach(() => { + service = { + getStatus: vi.fn(), + getBalances: vi.fn(), + getHistory: vi.fn(), + } + controller = new WalletStatusController(service as unknown as WalletStatusService) + req = { user: { id: 'user-1' }, query: {} } + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } + }) + + describe('getStatus', () => { + it('reads the wallet for the authenticated caller only', async () => { + service.getStatus.mockResolvedValue({ status: 'ACTIVE' }) + + await controller.getStatus(req, res) + + expect(service.getStatus).toHaveBeenCalledWith('user-1') + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ success: true, data: { status: 'ACTIVE' } }) + }) + }) + + describe('getBalances', () => { + it('returns exact balances on success', async () => { + const balances = { publicKey: 'GABC', sourceTime: '2026-08-30T00:00:00Z', balances: [] } + service.getBalances.mockResolvedValue(balances) + + await controller.getBalances(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ success: true, data: balances }) + }) + + it('returns 404 without leaking details when the caller has no active wallet', async () => { + service.getBalances.mockRejectedValue(new WalletStatusError('WALLET_NOT_FOUND')) + + await controller.getBalances(req, res) + + expect(res.status).toHaveBeenCalledWith(404) + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: { code: 'WALLET_NOT_FOUND', message: 'WALLET_NOT_FOUND' }, + }) + }) + + it('maps a Horizon timeout to 504', async () => { + service.getBalances.mockRejectedValue(new WalletStatusError('HORIZON_TIMEOUT', 'timed out')) + + await controller.getBalances(req, res) + + expect(res.status).toHaveBeenCalledWith(504) + }) + + it('maps Horizon unavailability to 503', async () => { + service.getBalances.mockRejectedValue(new WalletStatusError('HORIZON_UNAVAILABLE', 'down')) + + await controller.getBalances(req, res) + + expect(res.status).toHaveBeenCalledWith(503) + }) + + it('returns 500 for unexpected errors without leaking internals', async () => { + service.getBalances.mockRejectedValue(new Error('unexpected db failure')) + + await controller.getBalances(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: { code: 'INTERNAL_SERVER_ERROR' }, + }) + }) + }) + + describe('getHistory', () => { + it('rejects invalid pagination query parameters', async () => { + req.query = { limit: '0' } + + await controller.getHistory(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(service.getHistory).not.toHaveBeenCalled() + }) + + it('rejects invalid direction filters', async () => { + req.query = { direction: 'sideways' } + + await controller.getHistory(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(service.getHistory).not.toHaveBeenCalled() + }) + + it('returns paginated history with stable cursor metadata', async () => { + req.query = { cursor: 'abc', limit: '10', direction: 'incoming' } + service.getHistory.mockResolvedValue({ entries: [{ id: 'op-1' }], nextCursor: 'def' }) + + await controller.getHistory(req, res) + + expect(service.getHistory).toHaveBeenCalledWith('user-1', { + cursor: 'abc', + limit: 10, + direction: 'incoming', + }) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: [{ id: 'op-1' }], + meta: { cursor: 'abc', nextCursor: 'def', hasMore: true, limit: 10 }, + }) + }) + + it('reports hasMore: false when there is no next cursor', async () => { + service.getHistory.mockResolvedValue({ entries: [], nextCursor: null }) + + await controller.getHistory(req, res) + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ meta: expect.objectContaining({ hasMore: false }) }), + ) + }) + }) +}) diff --git a/tests/wallet-status.service.test.ts b/tests/wallet-status.service.test.ts new file mode 100644 index 00000000..5bd4140c --- /dev/null +++ b/tests/wallet-status.service.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest' +import { WalletStatusService, type WalletStatusStellarProvider } from '../src/services/wallet-status.service' +import { StellarServiceError } from '../src/services/stellar.service' +import { WalletStatusError } from '../src/types/wallet-status.types' +import type { WalletProvisioningRepository } from '../src/services/wallet-provisioning.repository' +import type { WalletRecord } from '../src/types/wallet-provisioning.types' + +const OWNER_ID = 'user-1' +const PUBLIC_KEY = 'GWALLETSTATUSTESTPUBLICKEY00000000000000000000000000000000' + +function wallet(overrides: Partial = {}): WalletRecord { + return { + id: 'wallet-1', + userId: OWNER_ID, + network: 'testnet', + custody: 'MANAGED', + publicKey: PUBLIC_KEY, + status: 'ACTIVE', + managedKeyReferenceId: 'key-1', + failureCode: null, + attemptCount: 1, + provisionedAt: new Date('2026-08-01T00:00:00.000Z'), + statusChangedAt: new Date('2026-08-01T00:00:00.000Z'), + createdAt: new Date('2026-07-31T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + ...overrides, + } +} + +/** Minimal repository double — only getByUserId is exercised by this service. */ +class StubRepository implements Partial { + constructor(private readonly record: WalletRecord | null) {} + + async getByUserId(userId: string): Promise { + return this.record && this.record.userId === userId ? this.record : null + } +} + +function repositoryFor(record: WalletRecord | null): WalletProvisioningRepository { + return new StubRepository(record) as unknown as WalletProvisioningRepository +} + +describe('WalletStatusService', () => { + describe('getStatus', () => { + it('reports NOT_PROVISIONED when no wallet exists', async () => { + const service = new WalletStatusService(repositoryFor(null), {} as WalletStatusStellarProvider) + + const status = await service.getStatus(OWNER_ID) + + expect(status).toEqual({ + status: 'NOT_PROVISIONED', + network: null, + custody: null, + publicKey: null, + provisionedAt: null, + }) + }) + + it('exposes the public key only when the wallet is ACTIVE', async () => { + const repository = repositoryFor(wallet({ status: 'PROVISIONING', publicKey: null })) + const service = new WalletStatusService(repository, {} as WalletStatusStellarProvider) + + const status = await service.getStatus(OWNER_ID) + + expect(status.status).toBe('PENDING') + expect(status.publicKey).toBeNull() + }) + + it('maps FAILED and DISABLED wallets to UNAVAILABLE', async () => { + const service = new WalletStatusService( + repositoryFor(wallet({ status: 'FAILED', publicKey: null })), + {} as WalletStatusStellarProvider, + ) + + const status = await service.getStatus(OWNER_ID) + + expect(status.status).toBe('UNAVAILABLE') + }) + + it('never exposes another user\'s wallet', async () => { + const service = new WalletStatusService(repositoryFor(wallet()), {} as WalletStatusStellarProvider) + + const status = await service.getStatus('someone-else') + + expect(status.status).toBe('NOT_PROVISIONED') + }) + }) + + describe('getBalances', () => { + it('rejects when the wallet is not ACTIVE', async () => { + const service = new WalletStatusService( + repositoryFor(wallet({ status: 'PROVISIONING', publicKey: null })), + {} as WalletStatusStellarProvider, + ) + + await expect(service.getBalances(OWNER_ID)).rejects.toMatchObject({ + code: 'WALLET_NOT_FOUND', + }) + }) + + it('returns exact amounts, asset identity, and source time', async () => { + const stellar: WalletStatusStellarProvider = { + getAccountSnapshot: async (publicKey) => { + expect(publicKey).toBe(PUBLIC_KEY) + + return { + found: true, + lastModifiedTime: '2026-08-30T00:00:00Z', + balances: [ + { assetType: 'native', assetCode: 'XLM', issuer: null, amount: '123.4567890' }, + { assetType: 'credit_alphanum4', assetCode: 'USDC', issuer: 'GISSUER', amount: '10.0000001' }, + ], + } + }, + getPaymentHistory: async () => ({ records: [], nextCursor: null }), + } + const service = new WalletStatusService(repositoryFor(wallet()), stellar) + + const balances = await service.getBalances(OWNER_ID) + + expect(balances.publicKey).toBe(PUBLIC_KEY) + expect(balances.sourceTime).toBe('2026-08-30T00:00:00Z') + expect(balances.balances).toEqual([ + { assetType: 'native', assetCode: 'XLM', issuer: null, amount: '123.4567890' }, + { assetType: 'credit_alphanum4', assetCode: 'USDC', issuer: 'GISSUER', amount: '10.0000001' }, + ]) + }) + + it('does not show an unfunded (not-yet-on-ledger) account as a zero balance error', async () => { + const stellar: WalletStatusStellarProvider = { + getAccountSnapshot: async () => ({ found: false, lastModifiedTime: null, balances: [] }), + getPaymentHistory: async () => ({ records: [], nextCursor: null }), + } + const service = new WalletStatusService(repositoryFor(wallet()), stellar) + + const balances = await service.getBalances(OWNER_ID) + + expect(balances.balances).toEqual([]) + }) + + it('normalizes a Horizon timeout to a stable provider error code', async () => { + const stellar: WalletStatusStellarProvider = { + getAccountSnapshot: async () => { + throw new StellarServiceError('Horizon request timed out', 'HORIZON_TIMEOUT') + }, + getPaymentHistory: async () => ({ records: [], nextCursor: null }), + } + const service = new WalletStatusService(repositoryFor(wallet()), stellar) + + await expect(service.getBalances(OWNER_ID)).rejects.toMatchObject({ + code: 'HORIZON_TIMEOUT', + }) + }) + + it('normalizes Horizon unavailability to a stable provider error code', async () => { + const stellar: WalletStatusStellarProvider = { + getAccountSnapshot: async () => { + throw new StellarServiceError('Horizon is unavailable', 'HORIZON_UNAVAILABLE') + }, + getPaymentHistory: async () => ({ records: [], nextCursor: null }), + } + const service = new WalletStatusService(repositoryFor(wallet()), stellar) + + await expect(service.getBalances(OWNER_ID)).rejects.toBeInstanceOf(WalletStatusError) + }) + }) + + describe('getHistory', () => { + const baseRecord = { + id: 'op-1', + pagingToken: 'cursor-1', + createdAt: '2026-08-29T00:00:00Z', + transactionHash: 'hash-1', + transactionSuccessful: true, + ledger: 100, + type: 'payment', + assetType: 'native', + assetCode: 'XLM', + issuer: null, + amount: '5.0000000', + memo: null, + memoType: null, + } + + it('marks records as incoming or outgoing relative to the owner\'s address', async () => { + const stellar: WalletStatusStellarProvider = { + getAccountSnapshot: async () => ({ found: true, lastModifiedTime: null, balances: [] }), + getPaymentHistory: async () => ({ + records: [ + { ...baseRecord, id: 'op-in', to: PUBLIC_KEY, from: 'GOTHER' }, + { ...baseRecord, id: 'op-out', to: 'GOTHER', from: PUBLIC_KEY }, + ], + nextCursor: 'cursor-2', + }), + } + const service = new WalletStatusService(repositoryFor(wallet()), stellar) + + const page = await service.getHistory(OWNER_ID, {}) + + expect(page.entries.map((e) => e.direction)).toEqual(['incoming', 'outgoing']) + expect(page.nextCursor).toBe('cursor-2') + }) + + it('reports failed transactions with status "failed" rather than success', async () => { + const stellar: WalletStatusStellarProvider = { + getAccountSnapshot: async () => ({ found: true, lastModifiedTime: null, balances: [] }), + getPaymentHistory: async () => ({ + records: [{ ...baseRecord, to: PUBLIC_KEY, from: 'GOTHER', transactionSuccessful: false }], + nextCursor: null, + }), + } + const service = new WalletStatusService(repositoryFor(wallet()), stellar) + + const page = await service.getHistory(OWNER_ID, {}) + + expect(page.entries[0].status).toBe('failed') + }) + + it('filters by direction when requested', async () => { + const stellar: WalletStatusStellarProvider = { + getAccountSnapshot: async () => ({ found: true, lastModifiedTime: null, balances: [] }), + getPaymentHistory: async () => ({ + records: [ + { ...baseRecord, id: 'op-in', to: PUBLIC_KEY, from: 'GOTHER' }, + { ...baseRecord, id: 'op-out', to: 'GOTHER', from: PUBLIC_KEY }, + ], + nextCursor: null, + }), + } + const service = new WalletStatusService(repositoryFor(wallet()), stellar) + + const page = await service.getHistory(OWNER_ID, { direction: 'incoming' }) + + expect(page.entries).toHaveLength(1) + expect(page.entries[0].id).toBe('op-in') + }) + + it('preserves the stable cursor for pagination', async () => { + let receivedCursor: string | undefined + const stellar: WalletStatusStellarProvider = { + getAccountSnapshot: async () => ({ found: true, lastModifiedTime: null, balances: [] }), + getPaymentHistory: async (_publicKey, options) => { + receivedCursor = options?.cursor + + return { records: [], nextCursor: null } + }, + } + const service = new WalletStatusService(repositoryFor(wallet()), stellar) + + await service.getHistory(OWNER_ID, { cursor: 'cursor-abc' }) + + expect(receivedCursor).toBe('cursor-abc') + }) + }) +}) From 34d1fb805a627ec4c78759dd8a512695e51bb8cc Mon Sep 17 00:00:00 2001 From: daxvinci Date: Sun, 30 Aug 2026 11:34:18 +0100 Subject: [PATCH 2/2] fix lint --- tests/stellar-wallet-status.service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/stellar-wallet-status.service.test.ts b/tests/stellar-wallet-status.service.test.ts index a99697b8..a1bd0f6f 100644 --- a/tests/stellar-wallet-status.service.test.ts +++ b/tests/stellar-wallet-status.service.test.ts @@ -179,7 +179,7 @@ describe('StellarService — wallet status additions', () => { const page = await service.getPaymentHistory('GRECEIVER') - expect(page.records[0].memo).not.toMatch(/\x00/) + expect(page.records[0].memo).not.toContain('\x00') expect(page.records[0].memo!.length).toBeLessThanOrEqual(256) })