Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/controllers/wallet-status.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
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<void> => {
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<void> => {
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<void> => {
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' } })
}
}
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
38 changes: 38 additions & 0 deletions src/routes/v1/wallet.routes.ts
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions src/schemas/wallet-status.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof walletHistoryQuerySchema>
188 changes: 188 additions & 0 deletions src/services/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AccountSnapshot> {
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<PaymentHistoryPage> {
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. */
Expand Down Expand Up @@ -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<string, unknown>
const transaction = (r.transaction ?? undefined) as Record<string, unknown> | 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
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading