diff --git a/backend/src/api/controllers/sep6.controller.test.ts b/backend/src/api/controllers/sep6.controller.test.ts index 4d096ec..46c7e21 100644 --- a/backend/src/api/controllers/sep6.controller.test.ts +++ b/backend/src/api/controllers/sep6.controller.test.ts @@ -112,6 +112,17 @@ describe('SEP-6 Controller', () => { expect(jsonMock).toHaveBeenCalledWith({ error: 'dest is required for withdrawal.' }); }); + it('returns 400 for unsupported asset', async () => { + const req = { + query: { asset_code: 'FAKE', dest: 'bank-1' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(statusMock).toHaveBeenCalledWith(400); + }); + it('creates a pending withdraw transaction', async () => { (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_2' }); @@ -134,6 +145,184 @@ describe('SEP-6 Controller', () => { expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ id: 'tx_2' })); }); + + it('returns fee_amount in the response', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_fee' }); + + const req = { + query: { asset_code: 'USDC', amount: '100', dest: 'bank-acc' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ fee_amount: expect.any(String) }) + ); + }); + + it('returns amount_out deducting the fee when amount is provided', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_out' }); + + const req = { + query: { asset_code: 'USDC', amount: '100', dest: 'bank-acc' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + const response = jsonMock.mock.calls[0][0]; + // USDC has feeFixed=0.5 (flat), so amount_out = 100 - 0.5 = 99.5 + expect(parseFloat(response.amount_out)).toBeCloseTo(99.5, 5); + }); + + it('does not return amount_out when no amount is given', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_no_amt' }); + + const req = { + query: { asset_code: 'USDC', dest: 'bank-acc' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + const response = jsonMock.mock.calls[0][0]; + expect(response.amount_out).toBeUndefined(); + }); + + it('stores receiverInfo with dest, dest_extra, and type', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_ri' }); + + const req = { + query: { + asset_code: 'USDC', + amount: '10', + dest: 'acc-123', + dest_extra: 'routing-456', + type: 'bank_account', + }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + receiverInfo: expect.objectContaining({ + dest: 'acc-123', + dest_extra: 'routing-456', + type: 'bank_account', + }), + }), + }) + ); + }); + + it('stores callbackUrl when callback_url is provided', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_cb' }); + + const req = { + query: { + asset_code: 'USDC', + amount: '10', + dest: 'acc-123', + callback_url: 'https://my.app/callback', + }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ callbackUrl: 'https://my.app/callback' }), + }) + ); + }); + + it('stores feeAmount, feeAssetCode, and feeType in the transaction', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_fstore' }); + + const req = { + query: { asset_code: 'USDC', amount: '50', dest: 'bank-1' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + feeAmount: expect.any(String), + feeAssetCode: 'USDC', + feeType: 'FLAT', + }), + }) + ); + }); + + it('returns 400 when amount is NaN', async () => { + const req = { + query: { asset_code: 'USDC', amount: 'notanumber', dest: 'bank-1' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(statusMock).toHaveBeenCalledWith(400); + }); + + it('returns 400 when amount is below minimum', async () => { + const req = { + query: { asset_code: 'USDC', amount: '0.001', dest: 'bank-1' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(statusMock).toHaveBeenCalledWith(400); + }); + + it('returns 400 when amount exceeds maximum', async () => { + const req = { + query: { asset_code: 'USDC', amount: '9999999', dest: 'bank-1' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(statusMock).toHaveBeenCalledWith(400); + }); + + it('handles crypto type withdrawal', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_crypto' }); + + const req = { + query: { asset_code: 'USDC', amount: '20', dest: 'GCRYPTO...ADDR', type: 'crypto' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ type: 'crypto', id: 'tx_crypto' }) + ); + }); + + it('returns 500 when the database throws', async () => { + (prisma.transaction.create as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + + const req = { + query: { asset_code: 'USDC', amount: '10', dest: 'bank-1' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Withdraw(req, mockResponse as Response); + + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ error: 'Failed to initiate withdrawal.' }); + }); }); describe('sep6GetTransaction', () => { diff --git a/backend/src/api/controllers/sep6.controller.ts b/backend/src/api/controllers/sep6.controller.ts index 9d6d2d4..9e4c4ec 100644 --- a/backend/src/api/controllers/sep6.controller.ts +++ b/backend/src/api/controllers/sep6.controller.ts @@ -4,6 +4,8 @@ import prisma from '../../lib/prisma'; import { AuthRequest } from '../middleware/auth.middleware'; import { getAsset, isDepositSupported, isWithdrawSupported, normalizeAssetCode } from '../../services/kyc.service'; import { ASSETS } from '../../config/assets'; +import { computeAssetFee } from '../../services/fee.service'; +import logger from '../../utils/logger'; /** * GET /sep6/info @@ -114,9 +116,28 @@ export const sep6Deposit = async (req: AuthRequest, res: Response): Promise => { - const { asset_code, amount, dest, dest_extra, type = 'bank_account' } = req.query as Record; + const { + asset_code, + amount, + dest, + dest_extra, + type = 'bank_account', + account, + callback_url, + } = req.query as Record; const publicKey = req.user!.publicKey; const code = normalizeAssetCode(asset_code); @@ -130,15 +151,23 @@ export const sep6Withdraw = async (req: AuthRequest, res: Response): Promise parseFloat(asset.maxAmount)) { + parsedAmount = parseFloat(amount); + if (isNaN(parsedAmount) || parsedAmount < parseFloat(asset.minAmount) || parsedAmount > parseFloat(asset.maxAmount)) { return res.status(400).json({ error: `Amount must be between ${asset.minAmount} and ${asset.maxAmount} for ${code}.`, }); } } + // Compute the fee for the given amount; fall back to the asset's fixed fee + // when no amount was supplied so the client always gets a fee estimate. + const feeAmount = parsedAmount > 0 ? computeAssetFee(asset, parsedAmount) : asset.feeFixed; + const amountOut = parsedAmount > 0 + ? parseFloat((parsedAmount - feeAmount).toFixed(7)) + : undefined; + try { const tx = await prisma.transaction.create({ data: { @@ -153,9 +182,21 @@ export const sep6Withdraw = async (req: AuthRequest, res: Response): Promise