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
81 changes: 78 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions services/stellar-wallet/src/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { NextFunction, Request, Response } from 'express'
import jwt from 'jsonwebtoken'
import { type Request, type Response, type NextFunction } from 'express'
import envs from '../config/envs'

export interface JwtPayload {
Expand All @@ -15,7 +15,7 @@ export interface JwtPayload {
* @param role - The user role (defaults to 'user')
* @returns JWT token string
*/
export const generateToken = (user_id: string, role: string = 'user'): string => {
export const generateToken = (user_id: string, role = 'user'): string => {
const payload: JwtPayload = {
user_id,
role,
Expand Down
41 changes: 41 additions & 0 deletions services/stellar-wallet/src/db/kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export type AccountRow = {
private_key: string
}

export type TransactionRow = {
id: number
user_id: number
transaction_hash: string
status: string
}

/**
* Returns a single shared SQLite database instance (singleton).
* Creates the file/directory if missing and applies PRAGMAs once.
Expand Down Expand Up @@ -170,3 +177,37 @@ export async function findAccountByUserId(
])
return rows.length ? rows[0] : null
}

/**
* Creates the `transactions` table if it doesn't exist (idempotent).
* FK: transactions.user_id → kyc(id) ON DELETE CASCADE
*/
export async function initializeTransactionsTable(db?: sqlite3.Database): Promise<void> {
const conn = db ?? (await connectDB())
const sql = `
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
transaction_hash TEXT NOT NULL,
status TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES kyc(id) ON DELETE CASCADE
);
`
await run(conn, sql)
await run(conn, 'CREATE INDEX IF NOT EXISTS idx_transactions_user_id ON transactions (user_id);')
await run(
conn,
'CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_hash ON transactions (transaction_hash);',
)
}

/**
* Inserts a new transaction record.
*/
export async function insertTransaction(
db: sqlite3.Database,
args: { user_id: number; transaction_hash: string; status: string },
): Promise<void> {
const sql = 'INSERT INTO transactions (user_id, transaction_hash, status) VALUES (?, ?, ?);'
await run(db, sql, [args.user_id, args.transaction_hash, args.status])
}
2 changes: 1 addition & 1 deletion services/stellar-wallet/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import cors from 'cors'
import express, { type NextFunction, type Request, type Response } from 'express'
import envs from './config/envs'
import { logger, loggerMiddleware, logError } from './middlewares/logger'
import { logError, logger, loggerMiddleware } from './middlewares/logger'
import { authLimiter, kycLimiter, walletLimiter } from './middlewares/rate-limit'
import { authLoginRouter } from './routes/auth-login'
import { kycRouter } from './routes/kyc'
Expand Down
2 changes: 1 addition & 1 deletion services/stellar-wallet/src/middlewares/logger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { NextFunction, Request, Response } from 'express'
import fs from 'node:fs'
import path from 'node:path'
import type { NextFunction, Request, Response } from 'express'
import winston from 'winston'

const logsDir = path.join(process.cwd(), 'services', 'stellar-wallet', 'logs')
Expand Down
6 changes: 3 additions & 3 deletions services/stellar-wallet/src/routes/auth-login.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Router, type Request, type Response } from 'express'
import { type Request, type Response, Router } from 'express'
import { generateToken } from '../auth/jwt'
import {
verifyWebAuthnAuthentication,
getUserCredentials,
type WebAuthnAuthenticationResponse,
getUserCredentials,
verifyWebAuthnAuthentication,
} from '../auth/webauthn'

export const authLoginRouter = Router()
Expand Down
10 changes: 5 additions & 5 deletions services/stellar-wallet/src/routes/kyc-verify.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { createHash } from 'node:crypto'
import { Router, type Request, type Response } from 'express'
import * as StellarSdk from '@stellar/stellar-sdk'
import { type Request, type Response, Router } from 'express'
import envs from '../config/envs'
import { connectDB, findKycById, run } from '../db/kyc'
import { validateKycData } from '../kyc/validate'
import { logError, logger } from '../middlewares/logger'
import { connectSoroban } from '../soroban/client'
import envs from '../config/envs'
import { logger, logError } from '../middlewares/logger'

export const kycVerifyRouter = Router()

Expand Down Expand Up @@ -41,7 +41,7 @@ kycVerifyRouter.post('/verify', async (req: Request, res: Response) => {

// Connect to database and verify kyc_id exists
const db = await connectDB()
const kycRecord = await findKycById(db, parseInt(kyc_id))
const kycRecord = await findKycById(db, Number.parseInt(kyc_id))
if (!kycRecord) {
return res.status(400).json({ error: 'Invalid kyc_id' })
}
Expand Down Expand Up @@ -95,7 +95,7 @@ kycVerifyRouter.post('/verify', async (req: Request, res: Response) => {
}

// Update database status to approved
await run(db, 'UPDATE kyc SET status = ? WHERE id = ?', ['approved', parseInt(kyc_id)])
await run(db, 'UPDATE kyc SET status = ? WHERE id = ?', ['approved', Number.parseInt(kyc_id)])

// Return success response
const verifyResponse: VerifyKycResponse = {
Expand Down
2 changes: 1 addition & 1 deletion services/stellar-wallet/src/routes/kyc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type Request, type Response, Router } from 'express'
import { type KycRow, all, connectDB, initializeKycTable, run } from '../db/kyc'
import { validateKycData } from '../kyc/validate'
import { logger, logError } from '../middlewares/logger'
import { logError, logger } from '../middlewares/logger'

export const kycRouter = Router()

Expand Down
166 changes: 164 additions & 2 deletions services/stellar-wallet/src/routes/wallet.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
import { Asset, Memo, Networks, Operation, StrKey, TransactionBuilder } from '@stellar/stellar-sdk'
import { type Request, type Response, Router } from 'express'
import { z } from 'zod'
import { connectDB, findKycById, initializeAccountsTable, insertAccount } from '../db/kyc'
import { jwtMiddleware } from '../auth/jwt'
import {
connectDB,
findAccountByUserId,
findKycById,
initializeAccountsTable,
initializeTransactionsTable,
insertAccount,
insertTransaction,
} from '../db/kyc'
import { logError, logger } from '../middlewares/logger'
import { connect } from '../stellar/client'
import { fundAccount } from '../stellar/fund'
import { generateKeyPair } from '../stellar/keys'
import { signTransaction } from '../stellar/sign'
import { encryptPrivateKey, getEncryptionKey } from '../utils/encryption'
import { logger, logError } from '../middlewares/logger'

export const walletRouter = Router()

// Narrow type to access JWT payload without global augmentation
type AuthRequest = Request & { user?: { user_id: string } }

const CreateWalletBody = z.object({
user_id: z.number().int().positive(),
})

const SendTransactionBody = z.object({
user_id: z.number().int().positive(),
destination: z.string(),
amount: z.string(), // validated below with regex and range
asset: z.string().optional().default('native'),
memo: z.string().optional(),
})

// up to 7 decimals, positive
const AMOUNT_REGEX = /^(?:0|[1-9]\d*)(?:\.\d{1,7})?$/

/**
* POST /wallet/create
* Body: { user_id: number }
Expand Down Expand Up @@ -66,3 +92,139 @@ walletRouter.post('/create', async (req: Request, res: Response) => {
return res.status(500).json({ error: 'Failed to create account' })
}
})

/**
* POST /wallet/send
* Body: { user_id: number, destination: string, amount: string, asset?: string, memo?: string }
* Protection: jwtMiddleware
* Flow: validate -> build payment -> sign -> submit -> persist -> respond
*/
walletRouter.post('/send', jwtMiddleware, async (req: Request, res: Response) => {
// Validate body
const parsed = SendTransactionBody.safeParse(req.body)
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request body' })
}
const { user_id, destination, amount, asset, memo } = parsed.data

const authReq = req as AuthRequest

// Verify user_id matches JWT
if (Number.parseInt(authReq.user?.user_id || '0') !== user_id) {
return res.status(400).json({ error: 'user_id does not match token' })
}

// Validate destination
if (!StrKey.isValidEd25519PublicKey(destination)) {
return res.status(400).json({ error: 'invalid destination' })
}

// Validate amount format & range
if (!AMOUNT_REGEX.test(amount)) {
return res
.status(400)
.json({ error: 'amount must be a positive decimal with up to 7 decimals' })
}
const amountNum = Number(amount)
if (amountNum <= 0 || amountNum > 1000) {
return res.status(400).json({ error: 'amount must be > 0 and ≤ 1000' })
}

// Validate asset
if (asset !== 'native') {
return res.status(400).json({ error: 'only native asset supported' })
}

// Validate memo length in BYTES (≤ 28)
if (memo && Buffer.byteLength(memo, 'utf8') > 28) {
return res.status(400).json({ error: 'memo must be ≤ 28 bytes' })
}

try {
const db = await connectDB()
await initializeTransactionsTable(db)

// Find user account
const account = await findAccountByUserId(db, user_id)
if (!account) {
return res.status(400).json({ error: 'user account not found' })
}

// Connect to Stellar
const server = connect()

// Load account to get sequence number
const sourceAccount = await server.loadAccount(account.public_key)

// Base fee from Horizon
const baseFee = String(await server.fetchBaseFee())

// Build transaction
const txBuilder = new TransactionBuilder(sourceAccount, {
fee: baseFee,
networkPassphrase: Networks.TESTNET,
})

// Add payment operation
txBuilder.addOperation(
Operation.payment({
destination,
asset: Asset.native(),
amount,
}),
)

// Add memo if provided
if (memo) {
txBuilder.addMemo(Memo.text(memo))
}

// Set timeout
txBuilder.setTimeout(30)

// Build transaction
const transaction = txBuilder.build()

// Sign transaction
const signedTx = await signTransaction(user_id, transaction, db)

// Submit transaction
const result = await server.submitTransaction(signedTx)

// Persist success
await insertTransaction(db, {
user_id,
transaction_hash: result.hash,
status: 'success',
})

logger.info({ message: 'transaction_sent', user_id, hash: result.hash })
return res.status(201).json({
user_id,
transaction_hash: result.hash,
status: 'success',
})
} catch (err: unknown) {
// Try to persist failure if we can get the hash
try {
const db = await connectDB()
await initializeTransactionsTable(db)

let hash = 'unknown'
if (err && typeof err === 'object' && 'hash' in err && typeof err.hash === 'string') {
hash = err.hash
}

await insertTransaction(db, {
user_id,
transaction_hash: hash,
status: 'failed',
})
} catch {
// Ignore persistence errors
}

logError(err, { route: '/wallet/send', user_id })
return res.status(500).json({ error: 'Transaction failed' })
}
})
2 changes: 1 addition & 1 deletion services/stellar-wallet/src/stellar/sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { type FeeBumpTransaction, Keypair, StrKey, type Transaction } from '@ste
import type sqlite3 from 'sqlite3'
import { connectDB } from '../db/kyc'
import { findAccountByUserId } from '../db/kyc'
import { logError, logger } from '../middlewares/logger'
import { decryptPrivateKey, getEncryptionKey } from '../utils/encryption'
import { logger, logError } from '../middlewares/logger'

/** Union type for Stellar base transaction types we can sign. */
export type StellarTx = Transaction | FeeBumpTransaction
Expand Down
4 changes: 2 additions & 2 deletions services/stellar-wallet/tests/auth/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Set environment variable for testing
process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only-32-chars-long'

import { generateToken, verifyToken, jwtMiddleware } from '../../src/auth/jwt'
import { type Request, type Response, type NextFunction } from 'express'
import type { NextFunction, Request, Response } from 'express'
import { generateToken, jwtMiddleware, verifyToken } from '../../src/auth/jwt'

describe('JWT Authentication', () => {
describe('generateToken', () => {
Expand Down
Loading
Loading