Skip to content
Closed
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
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,11 @@ __v0_jsx-dev-runtime.ts
# Common ignores
node_modules
.next/
.DS_Store
.DS_Store

# Build outputs
dist/
*.js
*.d.ts
*.js.map
*.tsbuildinfo
10 changes: 10 additions & 0 deletions src/migrations/1710000000002-create-wallets-table.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> { await queryRunner.query('DROP TABLE IF EXISTS wallets'); }
}
17 changes: 17 additions & 0 deletions src/wallet/entities/wallet.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 37 additions & 2 deletions src/wallet/wallet.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 10 additions & 1 deletion src/wallet/wallet.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
240 changes: 240 additions & 0 deletions src/wallet/wallet.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Repository<WalletEntity>>;
let transactionRepository: jest.Mocked<Repository<TransactionEntity>>;
let blockchainService: jest.Mocked<BlockchainService>;

beforeEach(() => {
walletRepository = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
} as unknown as jest.Mocked<Repository<WalletEntity>>;

transactionRepository = {
createQueryBuilder: jest.fn(),
} as unknown as jest.Mocked<Repository<TransactionEntity>>;

blockchainService = {
getBalances: jest.fn(),
validatePositiveAmount: jest.fn(),
} as unknown as jest.Mocked<BlockchainService>;

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);
});
});
});
Loading
Loading