diff --git a/src/modules/auth/auth.controllers.ts b/src/modules/auth/auth.controllers.ts index 438a7b1..0ce19b4 100644 --- a/src/modules/auth/auth.controllers.ts +++ b/src/modules/auth/auth.controllers.ts @@ -4,6 +4,7 @@ import { checkUserEmailExists, createNewUserWithPassword } from './auth.utils'; import { SendMailAsync } from '../../utils/mail.utils'; import { HTTP_STATUS } from '../../utils/logger.utils'; import bcrypt from 'bcrypt'; +import { refreshAccessToken } from './token-refresh.utils'; export const httpRegisterUserWithPassword: AsyncController = async ( req, @@ -139,13 +140,35 @@ export const httpResetPassword: AsyncController = async (req, res, next) => { export const httpRefreshToken: AsyncController = async (req, res, next) => { try { - console.log(req); - res.status(200).json({ + const authHeader = req.headers.authorization; + const token = + (authHeader && authHeader.startsWith('Bearer ') + ? authHeader.slice('Bearer '.length) + : undefined) ?? req.body?.token; + + if (!token) { + return res.status(HTTP_STATUS.UNAUTHORIZED).json({ + success: false, + code: 'invalid_token', + message: 'No token provided', + }); + } + + const result = refreshAccessToken(token); + + if (!result.success) { + return res.status(result.status).json({ + success: false, + code: result.code, + message: 'Token could not be refreshed', + }); + } + + return res.status(HTTP_STATUS.OK).json({ success: true, - message: 'Login Successful', + message: 'Token refreshed', data: { - name: 'Anioke Sebastian', - age: 23, + accessToken: result.token, }, }); } catch (error) { diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index 0cac96a..3e434e7 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -1,9 +1,14 @@ import { Router } from 'express'; -import { httpLogin, httpRegisterUserWithPassword } from './auth.controllers'; +import { + httpLogin, + httpRegisterUserWithPassword, + httpRefreshToken, +} from './auth.controllers'; const authRouter = Router(); authRouter.post('/login', httpLogin); authRouter.post('/register', httpRegisterUserWithPassword); +authRouter.post('/refresh', httpRefreshToken); export default authRouter; diff --git a/src/modules/auth/token-refresh.utils.test.ts b/src/modules/auth/token-refresh.utils.test.ts new file mode 100644 index 0000000..504de0a --- /dev/null +++ b/src/modules/auth/token-refresh.utils.test.ts @@ -0,0 +1,84 @@ +import { signJwt } from '../../utils/jwt.utils'; +import { + refreshAccessToken, + REFRESH_WINDOW_SECONDS, +} from './token-refresh.utils'; + +describe('refreshAccessToken', () => { + it('issues a new token when the current token has 10 minutes remaining (inside the refresh window)', () => { + const token = signJwt({ sub: 'user-123' }, 10 * 60); + + const result = refreshAccessToken(token); + + expect(result.success).toBe(true); + if (result.success) { + expect(typeof result.token).toBe('string'); + expect(result.token).not.toBe(token); + } + }); + + it('returns 400 refresh_not_due for a token with 2 hours remaining (outside the refresh window)', () => { + const token = signJwt({ sub: 'user-123' }, 2 * 60 * 60); + + const result = refreshAccessToken(token); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.status).toBe(400); + expect(result.code).toBe('refresh_not_due'); + } + }); + + it('returns 401 token_expired for an already-expired token', () => { + const token = signJwt({ sub: 'user-123' }, -60); + + const result = refreshAccessToken(token); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.status).toBe(401); + expect(result.code).toBe('token_expired'); + } + }); + + it("the new token's sub matches the original token's sub", () => { + const token = signJwt({ sub: 'user-abc-999' }, 5 * 60); + + const result = refreshAccessToken(token); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.sub).toBe('user-abc-999'); + } + }); + + it('does not invalidate the old token after refresh (stateless refresh)', () => { + const token = signJwt({ sub: 'user-123' }, 10 * 60); + + const firstRefresh = refreshAccessToken(token); + expect(firstRefresh.success).toBe(true); + + // The original token is not tracked/blacklisted, so refreshing again + // with the same original token still succeeds. + const secondRefresh = refreshAccessToken(token); + expect(secondRefresh.success).toBe(true); + }); + + it('treats a token exactly at the edge of the refresh window as due for refresh', () => { + const token = signJwt({ sub: 'user-123' }, REFRESH_WINDOW_SECONDS - 1); + + const result = refreshAccessToken(token); + + expect(result.success).toBe(true); + }); + + it('returns 401 invalid_token for a malformed token', () => { + const result = refreshAccessToken('not-a-real-token'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.status).toBe(401); + expect(result.code).toBe('invalid_token'); + } + }); +}); diff --git a/src/modules/auth/token-refresh.utils.ts b/src/modules/auth/token-refresh.utils.ts new file mode 100644 index 0000000..81e03a8 --- /dev/null +++ b/src/modules/auth/token-refresh.utils.ts @@ -0,0 +1,63 @@ +import { decodeJwt, signJwt, JwtError } from '../../utils/jwt.utils'; + +/** How long a freshly issued access token is valid for, in seconds (1 hour). */ +export const ACCESS_TOKEN_TTL_SECONDS = 60 * 60; + +/** + * The window, in seconds, before expiry during which a token is eligible + * for refresh. A token with more than this much time remaining is not yet + * due for refresh. + */ +export const REFRESH_WINDOW_SECONDS = 30 * 60; + +export interface TokenRefreshSuccess { + success: true; + token: string; + sub: string; +} + +export interface TokenRefreshFailure { + success: false; + status: 400 | 401; + code: 'refresh_not_due' | 'token_expired' | 'invalid_token'; +} + +export type TokenRefreshResult = TokenRefreshSuccess | TokenRefreshFailure; + +/** + * Issues a new access token for a valid, still-live token that is within its + * refresh window (i.e. close to expiring but not expired yet). + * + * Refresh is stateless: the original token is not tracked or invalidated, + * so it remains valid until its own expiry even after a refresh is issued. + * + * - Token with more than REFRESH_WINDOW_SECONDS remaining -> 400 refresh_not_due + * - Token already past its exp -> 401 token_expired + * - Token within the refresh window -> a new token is issued with the same `sub` + */ +export function refreshAccessToken(token: string): TokenRefreshResult { + let payload; + try { + payload = decodeJwt(token); + } catch (error) { + if (error instanceof JwtError) { + return { success: false, status: 401, code: 'invalid_token' }; + } + throw error; + } + + const nowSeconds = Math.floor(Date.now() / 1000); + const secondsRemaining = payload.exp - nowSeconds; + + if (secondsRemaining <= 0) { + return { success: false, status: 401, code: 'token_expired' }; + } + + if (secondsRemaining > REFRESH_WINDOW_SECONDS) { + return { success: false, status: 400, code: 'refresh_not_due' }; + } + + const newToken = signJwt({ sub: payload.sub }, ACCESS_TOKEN_TTL_SECONDS); + + return { success: true, token: newToken, sub: payload.sub }; +} diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index 21fba48..06f3027 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -6,6 +6,7 @@ import { updateIndexedLedger } from './ledger-gap-detection.service'; import { logger } from '../../utils/logger.utils'; import { processIndexerChainEvents, IndexerChainEvent } from '../../utils/indexer-event-processor.utils'; import { dedupeChainEvents } from '../../utils/indexer-dedupe.utils'; +import { logSellTransactionConfirmed } from '../../utils/sell-transaction-logger.utils'; /** * Processes a batch of on-chain trade events (KEY_BOUGHT or KEY_SOLD). @@ -68,6 +69,30 @@ export async function processTradeEvents(events: IndexerChainEvent[]): Promise { const payload = (row.payload ?? {}) as Record; + const type = row.type === 'KEY_BOUGHT' ? 'buy' : 'sell'; + + // A buy spends XLM (outgoing); a sell receives XLM (incoming). + let xlmDelta: string | null = null; + if (payload.price_at_trade != null) { + try { + xlmDelta = formatXlmDelta( + BigInt(payload.price_at_trade), + type === 'buy' ? 'out' : 'in' + ); + } catch (_e) { + xlmDelta = null; + } + } + return { id: row.id, - type: row.type === 'KEY_BOUGHT' ? 'buy' : 'sell', + type, creator_id: row.creatorId ?? '', creator_handle: row.creatorId ? (handleMap.get(row.creatorId) ?? null) @@ -116,6 +132,7 @@ export async function fetchWalletActivity( amount: payload.amount ?? null, price_at_trade: payload.price_at_trade ?? null, fee_paid: payload.fee_paid ?? null, + xlm_delta: xlmDelta, ledger_sequence: payload.ledger_sequence != null ? Number(payload.ledger_sequence) diff --git a/src/utils/jwt.utils.ts b/src/utils/jwt.utils.ts new file mode 100644 index 0000000..260e0d2 --- /dev/null +++ b/src/utils/jwt.utils.ts @@ -0,0 +1,95 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-only-insecure-secret'; + +export interface JwtPayload { + sub: string; + iat: number; + exp: number; + [key: string]: unknown; +} + +function base64UrlEncode(input: string): string { + return Buffer.from(input, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function base64UrlDecode(input: string): string { + const padded = input.replace(/-/g, '+').replace(/_/g, '/'); + const padding = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4)); + return Buffer.from(padded + padding, 'base64').toString('utf8'); +} + +function sign(data: string): string { + return createHmac('sha256', JWT_SECRET).update(data).digest('base64url'); +} + +/** + * Signs a minimal HMAC-SHA256 JWT-style token. Not a full JWT implementation + * (no header alg negotiation), but structurally compatible: header.payload.signature. + */ +export function signJwt( + payload: { sub: string; [key: string]: unknown }, + expiresInSeconds: number +): string { + const now = Math.floor(Date.now() / 1000); + const fullPayload: JwtPayload = { + ...payload, + sub: payload.sub, + iat: now, + exp: now + expiresInSeconds, + }; + + const header = base64UrlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = base64UrlEncode(JSON.stringify(fullPayload)); + const signature = sign(`${header}.${body}`); + + return `${header}.${body}.${signature}`; +} + +export class JwtError extends Error { + code: 'malformed' | 'invalid_signature'; + + constructor(code: 'malformed' | 'invalid_signature', message: string) { + super(message); + this.name = 'JwtError'; + this.code = code; + } +} + +/** + * Decodes and verifies the signature of a token WITHOUT checking expiry. + * Callers that need to distinguish "expired" from "not yet due for refresh" + * must check `exp` against the current time themselves. + * + * @throws {JwtError} when the token is malformed or the signature is invalid + */ +export function decodeJwt(token: string): JwtPayload { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new JwtError('malformed', 'Token must have three segments'); + } + + const [header, body, signature] = parts; + + const expectedSignature = sign(`${header}.${body}`); + const providedSigBuf = Buffer.from(signature); + const expectedSigBuf = Buffer.from(expectedSignature); + + const signaturesMatch = + providedSigBuf.length === expectedSigBuf.length && + timingSafeEqual(providedSigBuf, expectedSigBuf); + + if (!signaturesMatch) { + throw new JwtError('invalid_signature', 'Token signature is invalid'); + } + + try { + return JSON.parse(base64UrlDecode(body)) as JwtPayload; + } catch { + throw new JwtError('malformed', 'Token payload is not valid JSON'); + } +} diff --git a/src/utils/sell-transaction-logger.utils.test.ts b/src/utils/sell-transaction-logger.utils.test.ts new file mode 100644 index 0000000..bcf3c62 --- /dev/null +++ b/src/utils/sell-transaction-logger.utils.test.ts @@ -0,0 +1,66 @@ +import { logSellTransactionConfirmed } from './sell-transaction-logger.utils'; +import { logger } from './logger.utils'; + +jest.mock('./logger.utils', () => ({ + logger: { + info: jest.fn(), + }, +})); + +describe('logSellTransactionConfirmed', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const baseFields = { + sellerWallet: 'GSELLER1234567890000000000000000000000000000000000000', + creatorWallet: 'GCREATOR1234567890000000000000000000000000000000000000', + keyAmount: 3, + xlmReceivedStroops: 125_000_000n, + newSupply: 42, + txHash: 'abcd1234efgh5678', + confirmedAt: new Date('2026-01-01T00:00:00.000Z'), + }; + + it('emits exactly one info-level log', () => { + logSellTransactionConfirmed(baseFields); + expect(logger.info).toHaveBeenCalledTimes(1); + }); + + it('includes all seven required fields with the expected values', () => { + logSellTransactionConfirmed(baseFields); + + const [logFields, message] = (logger.info as jest.Mock).mock.calls[0]; + + expect(message).toBe('Sell transaction confirmed on-chain'); + expect(logFields).toMatchObject({ + seller_wallet: baseFields.sellerWallet, + creator_wallet: baseFields.creatorWallet, + key_amount: baseFields.keyAmount, + xlm_received: '+12.5000000 XLM', + new_supply: baseFields.newSupply, + tx_hash: baseFields.txHash, + }); + expect(logFields).toHaveProperty( + 'confirmed_at', + baseFields.confirmedAt.toISOString() + ); + }); + + it('formats xlm_received as a signed XLM string, not raw stroops', () => { + logSellTransactionConfirmed(baseFields); + + const [logFields] = (logger.info as jest.Mock).mock.calls[0]; + expect(logFields.xlm_received).toMatch(/^\+\d+\.\d{7} XLM$/); + }); + + it('never includes private key material or signing secrets', () => { + logSellTransactionConfirmed(baseFields); + + const [logFields] = (logger.info as jest.Mock).mock.calls[0]; + expect(logFields).not.toHaveProperty('secret'); + expect(logFields).not.toHaveProperty('secretKey'); + expect(logFields).not.toHaveProperty('privateKey'); + expect(logFields).not.toHaveProperty('signingKey'); + }); +}); diff --git a/src/utils/sell-transaction-logger.utils.ts b/src/utils/sell-transaction-logger.utils.ts new file mode 100644 index 0000000..7b78447 --- /dev/null +++ b/src/utils/sell-transaction-logger.utils.ts @@ -0,0 +1,46 @@ +import { logger } from './logger.utils'; +import { buildLogFields } from './log-fields.utils'; +import { formatXlmDelta } from './xlm-delta.utils'; + +export interface SellTransactionConfirmedFields { + /** Wallet address of the seller (the actor who sold keys) */ + sellerWallet: string; + /** Wallet address of the creator whose keys were sold */ + creatorWallet: string; + /** Number of keys sold */ + keyAmount: number; + /** XLM received by the seller, in stroops */ + xlmReceivedStroops: bigint; + /** Total key supply for the creator after this sell is applied */ + newSupply: number; + /** Stellar transaction hash the sell was confirmed in */ + txHash: string; + /** Timestamp the transaction was confirmed on-chain */ + confirmedAt: Date; +} + +/** + * Emits a structured info-level log for a sell transaction after it has + * been confirmed on-chain. Mirrors the existing buy-side trade logging so + * operators get a complete view of marketplace activity in both directions. + * + * Must only be called after on-chain confirmation, never on submission. + * Never logs private key material or signing secrets. + */ +export function logSellTransactionConfirmed( + fields: SellTransactionConfirmedFields +): void { + logger.info( + buildLogFields({ + type: 'sell_transaction_confirmed', + seller_wallet: fields.sellerWallet, + creator_wallet: fields.creatorWallet, + key_amount: fields.keyAmount, + xlm_received: formatXlmDelta(fields.xlmReceivedStroops, 'in'), + new_supply: fields.newSupply, + tx_hash: fields.txHash, + confirmed_at: fields.confirmedAt, + }), + 'Sell transaction confirmed on-chain' + ); +} diff --git a/src/utils/xlm-delta.utils.test.ts b/src/utils/xlm-delta.utils.test.ts new file mode 100644 index 0000000..75ba420 --- /dev/null +++ b/src/utils/xlm-delta.utils.test.ts @@ -0,0 +1,38 @@ +import { formatXlmDelta } from './xlm-delta.utils'; + +describe('formatXlmDelta', () => { + it('formats an "in" delta with a positive sign', () => { + expect(formatXlmDelta(10_000_000n, 'in')).toBe('+1.0000000 XLM'); + }); + + it('formats an "out" delta with a negative sign', () => { + expect(formatXlmDelta(10_000_000n, 'out')).toBe('-1.0000000 XLM'); + }); + + it('formats a zero-stroop "in" delta', () => { + expect(formatXlmDelta(0n, 'in')).toBe('+0.0000000 XLM'); + }); + + it('formats a zero-stroop "out" delta', () => { + expect(formatXlmDelta(0n, 'out')).toBe('-0.0000000 XLM'); + }); + + it('formats sub-XLM stroop amounts with full precision', () => { + expect(formatXlmDelta(1_234_567n, 'in')).toBe('+0.1234567 XLM'); + }); + + it('formats large stroop amounts without losing precision', () => { + expect(formatXlmDelta(123_456_789_012_345n, 'out')).toBe( + '-12345678.9012345 XLM' + ); + }); + + it('always takes the absolute value regardless of sign of the input bigint', () => { + expect(formatXlmDelta(-10_000_000n, 'in')).toBe('+1.0000000 XLM'); + }); + + it('throws a TypeError when stroops is not a bigint', () => { + // @ts-expect-error - intentionally passing a non-bigint to verify the guard + expect(() => formatXlmDelta(10000000, 'in')).toThrow(TypeError); + }); +}); diff --git a/src/utils/xlm-delta.utils.ts b/src/utils/xlm-delta.utils.ts new file mode 100644 index 0000000..d877315 --- /dev/null +++ b/src/utils/xlm-delta.utils.ts @@ -0,0 +1,30 @@ +const STROOPS_PER_XLM = 10_000_000n; + +/** + * Formats a bigint stroop amount as a signed XLM delta string for + * transaction history display (e.g. '+12.5000000 XLM' or '-3.0000000 XLM'). + * + * Uses bigint arithmetic throughout so large stroop values never lose + * precision through floating-point conversion. + * + * @param stroops - The absolute amount, in stroops, as a non-negative bigint + * @param direction - 'in' for incoming XLM (e.g. a sell), 'out' for outgoing XLM (e.g. a buy) + * @returns A signed, fixed 7-decimal XLM string, e.g. '+1.0000000 XLM' + */ +export function formatXlmDelta( + stroops: bigint, + direction: 'in' | 'out' +): string { + if (typeof stroops !== 'bigint') { + throw new TypeError('stroops must be a bigint'); + } + + const absStroops = stroops < 0n ? -stroops : stroops; + const sign = direction === 'in' ? '+' : '-'; + + const whole = absStroops / STROOPS_PER_XLM; + const fraction = absStroops % STROOPS_PER_XLM; + const fractionStr = fraction.toString().padStart(7, '0'); + + return `${sign}${whole.toString()}.${fractionStr} XLM`; +}