diff --git a/.gitignore b/.gitignore index 97eb8c0..16b06e4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,11 @@ __v0_jsx-dev-runtime.ts # Common ignores node_modules .next/ -.DS_Store \ No newline at end of file +.DS_Store + +# Build outputs +dist/ +*.js +*.d.ts +*.js.map +*.tsbuildinfo \ No newline at end of file diff --git a/src/migrations/1710000000002-create-wallets-table.ts b/src/migrations/1710000000002-create-wallets-table.ts new file mode 100644 index 0000000..5b1be70 --- /dev/null +++ b/src/migrations/1710000000002-create-wallets-table.ts @@ -0,0 +1,10 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +/** Creates the wallets table for linking Stellar public keys to user accounts. */ +export class CreateWalletsTable1710000000002 implements MigrationInterface { + /** Applies the wallets schema. */ + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE IF NOT EXISTS wallets (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid UNIQUE NOT NULL, public_key varchar(56) UNIQUE NOT NULL, spending_limit_xlm numeric(30,12), spending_limit_usdc numeric(30,12), created_at timestamptz NOT NULL DEFAULT now())`); + } + /** Removes only the schema introduced by this migration. */ + async down(queryRunner: QueryRunner): Promise { await queryRunner.query('DROP TABLE IF EXISTS wallets'); } +} diff --git a/src/wallet/entities/wallet.entity.ts b/src/wallet/entities/wallet.entity.ts new file mode 100644 index 0000000..27ba649 --- /dev/null +++ b/src/wallet/entities/wallet.entity.ts @@ -0,0 +1,17 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; +/** Stores the mapping between user accounts and their Stellar public keys. */ +@Entity('wallets') +export class WalletEntity { + /** Wallet identifier. */ + @PrimaryGeneratedColumn('uuid') id!: string; + /** Owning user identifier (one wallet per user). */ + @Column({ unique: true }) userId!: string; + /** Stellar public key (G-prefixed, 56 characters). */ + @Column({ unique: true, length: 56 }) publicKey!: string; + /** Weekly spending limit for XLM (stored as decimal string). */ + @Column({ type: 'numeric', precision: 30, scale: 12, nullable: true }) spendingLimitXlm!: string | null; + /** Weekly spending limit for USDC (stored as decimal string). */ + @Column({ type: 'numeric', precision: 30, scale: 12, nullable: true }) spendingLimitUsdc!: string | null; + /** Wallet creation timestamp. */ + @CreateDateColumn() createdAt!: Date; +} diff --git a/src/wallet/wallet.controller.ts b/src/wallet/wallet.controller.ts index 89a8a45..44b8aa1 100644 --- a/src/wallet/wallet.controller.ts +++ b/src/wallet/wallet.controller.ts @@ -1,9 +1,44 @@ -import { Controller, Get } from '@nestjs/common'; +import { Body, Controller, Get, Post, Put, Query, Req, UseGuards } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; import { WalletService } from './wallet.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; /** Exposes the wallet API surface. */ @Controller('wallet') +@UseGuards(JwtAuthGuard) export class WalletController { - constructor(private readonly service: WalletService) {} + constructor(private readonly service: WalletService, private readonly jwt: JwtService) {} /** Reports module availability for operations and smoke tests. */ @Get('status') status(): { module: string; status: string } { return this.service.status(); } + /** Links a Stellar public key to the authenticated user. */ + @Post('link') async link(@Body() body: { publicKey: string }, @Req() req: { headers: { authorization?: string } }) { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new Error('Authorization token required'); + const decoded = this.jwt.decode(token) as { sub: string } | null; + if (!decoded?.sub) throw new Error('Invalid token'); + return await this.service.linkWallet(decoded.sub, body.publicKey); + } + /** Fetches live Stellar balances for the authenticated user's wallet. */ + @Get('balance') async getBalance(@Req() req: { headers: { authorization?: string } }) { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new Error('Authorization token required'); + const decoded = this.jwt.decode(token) as { sub: string } | null; + if (!decoded?.sub) throw new Error('Invalid token'); + return await this.service.getBalances(decoded.sub); + } + /** Sets the weekly spending limit for a specific asset. */ + @Put('spending-limit') async setSpendingLimit(@Body() body: { asset: 'XLM' | 'USDC' | 'EURC'; limit: string }, @Req() req: { headers: { authorization?: string } }) { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new Error('Authorization token required'); + const decoded = this.jwt.decode(token) as { sub: string } | null; + if (!decoded?.sub) throw new Error('Invalid token'); + return await this.service.setSpendingLimit(decoded.sub, body.asset, body.limit); + } + /** Checks if a transaction amount is within the weekly spending limit. */ + @Get('spending-limit/check') async checkSpendingLimit(@Query('asset') asset: string, @Query('amount') amount: string, @Req() req: { headers: { authorization?: string } }) { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new Error('Authorization token required'); + const decoded = this.jwt.decode(token) as { sub: string } | null; + if (!decoded?.sub) throw new Error('Invalid token'); + return await this.service.checkSpendingLimit(decoded.sub, asset, amount); + } } diff --git a/src/wallet/wallet.module.ts b/src/wallet/wallet.module.ts index c3298b9..58dd314 100644 --- a/src/wallet/wallet.module.ts +++ b/src/wallet/wallet.module.ts @@ -1,6 +1,15 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BlockchainModule } from '../blockchain/blockchain.module'; +import { TransactionEntity } from '../transactions/entities/transaction.entity'; import { WalletController } from './wallet.controller'; +import { WalletEntity } from './entities/wallet.entity'; import { WalletService } from './wallet.service'; /** Registers the wallet feature. */ -@Module({ controllers: [WalletController], providers: [WalletService], exports: [WalletService] }) +@Module({ + imports: [BlockchainModule, TypeOrmModule.forFeature([WalletEntity, TransactionEntity])], + controllers: [WalletController], + providers: [WalletService], + exports: [WalletService], +}) export class WalletModule {} diff --git a/src/wallet/wallet.service.spec.ts b/src/wallet/wallet.service.spec.ts new file mode 100644 index 0000000..4f0969d --- /dev/null +++ b/src/wallet/wallet.service.spec.ts @@ -0,0 +1,240 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { WalletService } from './wallet.service'; +import { WalletEntity } from './entities/wallet.entity'; +import { TransactionEntity } from '../transactions/entities/transaction.entity'; +import { BlockchainService } from '../blockchain/blockchain.service'; + +describe('WalletService', () => { + let service: WalletService; + let walletRepository: jest.Mocked>; + let transactionRepository: jest.Mocked>; + let blockchainService: jest.Mocked; + + beforeEach(() => { + walletRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + } as unknown as jest.Mocked>; + + transactionRepository = { + createQueryBuilder: jest.fn(), + } as unknown as jest.Mocked>; + + blockchainService = { + getBalances: jest.fn(), + validatePositiveAmount: jest.fn(), + } as unknown as jest.Mocked; + + service = new WalletService(walletRepository, transactionRepository, blockchainService); + }); + + describe('status', () => { + it('returns module status', () => { + expect(service.status()).toEqual({ module: 'wallet', status: 'ready' }); + }); + }); + + describe('linkWallet', () => { + const validPublicKey = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const userId = 'user-123'; + + it('links a valid public key to a user', async () => { + walletRepository.findOne.mockResolvedValue(null); + const mockWallet = { id: 'wallet-1', userId, publicKey: validPublicKey, spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.create.mockReturnValue(mockWallet); + walletRepository.save.mockResolvedValue(mockWallet); + + const result = await service.linkWallet(userId, validPublicKey); + + expect(result).toEqual(mockWallet); + expect(walletRepository.findOne).toHaveBeenCalledWith({ where: { publicKey: validPublicKey } }); + expect(walletRepository.create).toHaveBeenCalledWith({ userId, publicKey: validPublicKey }); + }); + + it('throws BadRequestException for invalid public key format', async () => { + await expect(service.linkWallet(userId, 'invalid-key')).rejects.toThrow(BadRequestException); + }); + + it('throws ConflictException when public key already linked to another user', async () => { + const existingWallet = { id: 'wallet-1', userId: 'other-user', publicKey: validPublicKey, spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(existingWallet); + + await expect(service.linkWallet(userId, validPublicKey)).rejects.toThrow(ConflictException); + }); + + it('returns existing wallet when user links same key again', async () => { + const existingWallet = { id: 'wallet-1', userId, publicKey: validPublicKey, spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(null); + walletRepository.create.mockReturnValue(existingWallet); + walletRepository.save.mockResolvedValue(existingWallet); + + const result = await service.linkWallet(userId, validPublicKey); + expect(result).toEqual(existingWallet); + }); + + it('throws ConflictException when user already has a wallet', async () => { + const existingWallet = { id: 'wallet-1', userId, publicKey: validPublicKey, spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(existingWallet); + + await expect(service.linkWallet(userId, validPublicKey)).rejects.toThrow(ConflictException); + }); + }); + + describe('getBalances', () => { + it('returns balances for linked wallet', async () => { + const userId = 'user-123'; + const wallet = { id: 'wallet-1', userId, publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + const mockBalances = [{ asset: 'XLM', balance: '100' }]; + blockchainService.getBalances.mockResolvedValue(mockBalances); + + const result = await service.getBalances(userId); + + expect(result).toEqual(mockBalances); + expect(blockchainService.getBalances).toHaveBeenCalledWith(wallet.publicKey); + }); + + it('throws NotFoundException when user has no linked wallet', async () => { + walletRepository.findOne.mockResolvedValue(null); + + await expect(service.getBalances('user-123')).rejects.toThrow(NotFoundException); + }); + }); + + describe('setSpendingLimit', () => { + it('sets XLM spending limit', async () => { + const userId = 'user-123'; + const wallet = { id: 'wallet-1', userId, publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + blockchainService.validatePositiveAmount.mockReturnValue('100'); + const updatedWallet = { ...wallet, spendingLimitXlm: '100' }; + walletRepository.save.mockResolvedValue(updatedWallet); + + const result = await service.setSpendingLimit(userId, 'XLM', '100'); + + expect(result.spendingLimitXlm).toBe('100'); + expect(blockchainService.validatePositiveAmount).toHaveBeenCalledWith('100'); + }); + + it('sets USDC spending limit', async () => { + const userId = 'user-123'; + const wallet = { id: 'wallet-1', userId, publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + blockchainService.validatePositiveAmount.mockReturnValue('50'); + const updatedWallet = { ...wallet, spendingLimitUsdc: '50' }; + walletRepository.save.mockResolvedValue(updatedWallet); + + const result = await service.setSpendingLimit(userId, 'USDC', '50'); + + expect(result.spendingLimitUsdc).toBe('50'); + }); + + it('sets EURC spending limit (uses USDC column)', async () => { + const userId = 'user-123'; + const wallet = { id: 'wallet-1', userId, publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + blockchainService.validatePositiveAmount.mockReturnValue('75'); + const updatedWallet = { ...wallet, spendingLimitUsdc: '75' }; + walletRepository.save.mockResolvedValue(updatedWallet); + + const result = await service.setSpendingLimit(userId, 'EURC', '75'); + + expect(result.spendingLimitUsdc).toBe('75'); + }); + + it('throws NotFoundException when user has no linked wallet', async () => { + walletRepository.findOne.mockResolvedValue(null); + + await expect(service.setSpendingLimit('user-123', 'XLM', '100')).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException for invalid amount', async () => { + const wallet = { id: 'wallet-1', userId: 'user-123', publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + blockchainService.validatePositiveAmount.mockImplementation(() => { + throw new BadRequestException('Invalid amount'); + }); + + await expect(service.setSpendingLimit('user-123', 'XLM', 'invalid')).rejects.toThrow(BadRequestException); + }); + }); + + describe('checkSpendingLimit', () => { + it('allows transaction when under limit', async () => { + const userId = 'user-123'; + const wallet = { id: 'wallet-1', userId, publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: '100', spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + blockchainService.validatePositiveAmount.mockReturnValue('50'); + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: '25' }), + }; + transactionRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.checkSpendingLimit(userId, 'XLM', '50'); + + expect(result.allowed).toBe(true); + expect(result.remaining).toBe('75'); + }); + + it('denies transaction when over limit', async () => { + const userId = 'user-123'; + const wallet = { id: 'wallet-1', userId, publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: '100', spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + blockchainService.validatePositiveAmount.mockReturnValue('50'); + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: '75' }), + }; + transactionRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.checkSpendingLimit(userId, 'XLM', '50'); + + expect(result.allowed).toBe(false); + expect(result.remaining).toBe('0'); + }); + + it('allows unlimited when no limit is set', async () => { + const userId = 'user-123'; + const wallet = { id: 'wallet-1', userId, publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + blockchainService.validatePositiveAmount.mockReturnValue('50'); + + const result = await service.checkSpendingLimit(userId, 'XLM', '50'); + + expect(result.allowed).toBe(true); + expect(result.remaining).toBe('unlimited'); + }); + + it('throws NotFoundException when user has no linked wallet', async () => { + walletRepository.findOne.mockResolvedValue(null); + + await expect(service.checkSpendingLimit('user-123', 'XLM', '50')).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException for unsupported asset', async () => { + const wallet = { id: 'wallet-1', userId: 'user-123', publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: null, spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + + await expect(service.checkSpendingLimit('user-123', 'BTC', '50')).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException for invalid amount', async () => { + const wallet = { id: 'wallet-1', userId: 'user-123', publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', spendingLimitXlm: '100', spendingLimitUsdc: null, createdAt: new Date() }; + walletRepository.findOne.mockResolvedValue(wallet); + blockchainService.validatePositiveAmount.mockImplementation(() => { + throw new BadRequestException('Invalid amount'); + }); + + await expect(service.checkSpendingLimit('user-123', 'XLM', 'invalid')).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/wallet/wallet.service.ts b/src/wallet/wallet.service.ts index e7240c2..5433b68 100644 --- a/src/wallet/wallet.service.ts +++ b/src/wallet/wallet.service.ts @@ -1,7 +1,66 @@ -import { Injectable } from '@nestjs/common'; -/** Provides the wallet application capability. */ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BlockchainService } from '../blockchain/blockchain.service'; +import { TransactionEntity } from '../transactions/entities/transaction.entity'; +import { WalletEntity } from './entities/wallet.entity'; +/** Provides wallet management and balance fetching with spending limit enforcement. */ @Injectable() export class WalletService { + constructor( + @InjectRepository(WalletEntity) private readonly walletRepo: Repository, + @InjectRepository(TransactionEntity) private readonly transactionRepo: Repository, + private readonly blockchain: BlockchainService, + ) {} /** Returns a stable service health payload for this capability. */ status(): { module: string; status: string } { return { module: 'wallet', status: 'ready' }; } + /** Links a Stellar public key to a user account. */ + async linkWallet(userId: string, publicKey: string): Promise { + if (!/^G[A-Z2-7]{55}$/.test(publicKey)) throw new BadRequestException('Invalid Stellar public key'); + const existingKey = await this.walletRepo.findOne({ where: { publicKey } }); + if (existingKey) throw new ConflictException('Public key already linked to another user'); + const wallet = this.walletRepo.create({ userId, publicKey }); + return await this.walletRepo.save(wallet); + } + /** Fetches live Stellar balances for the user's linked wallet. */ + async getBalances(userId: string): Promise { + const wallet = await this.walletRepo.findOne({ where: { userId } }); + if (!wallet) throw new NotFoundException('No wallet linked to this user'); + return await this.blockchain.getBalances(wallet.publicKey); + } + /** Sets the weekly spending limit for a specific asset. */ + async setSpendingLimit(userId: string, asset: 'XLM' | 'USDC' | 'EURC', limit: string): Promise { + const validatedLimit = this.blockchain.validatePositiveAmount(limit); + const wallet = await this.walletRepo.findOne({ where: { userId } }); + if (!wallet) throw new NotFoundException('No wallet linked to this user'); + if (asset === 'XLM') wallet.spendingLimitXlm = validatedLimit; + else if (asset === 'USDC' || asset === 'EURC') wallet.spendingLimitUsdc = validatedLimit; + else throw new BadRequestException('Unsupported asset for spending limit'); + return await this.walletRepo.save(wallet); + } + /** Checks if a transaction amount is within the weekly spending limit. */ + async checkSpendingLimit(userId: string, asset: string, amount: string): Promise<{ allowed: boolean; remaining: string }> { + const validatedAmount = this.blockchain.validatePositiveAmount(amount); + const wallet = await this.walletRepo.findOne({ where: { userId } }); + if (!wallet) throw new NotFoundException('No wallet linked to this user'); + let limit: string | null; + if (asset === 'XLM') limit = wallet.spendingLimitXlm; + else if (asset === 'USDC' || asset === 'EURC') limit = wallet.spendingLimitUsdc; + else throw new BadRequestException('Unsupported asset for spending limit'); + if (!limit) return { allowed: true, remaining: 'unlimited' }; + const weekStart = new Date(); + weekStart.setUTCDate(weekStart.getUTCDate() - weekStart.getUTCDay()); + weekStart.setUTCHours(0, 0, 0, 0); + const weeklySpendResult = await this.transactionRepo + .createQueryBuilder('transaction') + .select('SUM(transaction.amount)', 'total') + .where('transaction.userId = :userId', { userId }) + .andWhere('transaction.asset = :asset', { asset }) + .andWhere('transaction.createdAt >= :weekStart', { weekStart }) + .getRawOne(); + const weeklySpend = weeklySpendResult?.total || '0'; + const remaining = (Number(limit) - Number(weeklySpend)).toString(); + const allowed = Number(remaining) >= Number(validatedAmount); + return { allowed, remaining: allowed ? remaining : '0' }; + } }