diff --git a/backend/src/api/controllers/sep6.controller.test.ts b/backend/src/api/controllers/sep6.controller.test.ts index 4d096ec..54395b4 100644 --- a/backend/src/api/controllers/sep6.controller.test.ts +++ b/backend/src/api/controllers/sep6.controller.test.ts @@ -97,6 +97,175 @@ describe('SEP-6 Controller', () => { }) ); }); + + it('returns fee_amount in the response', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_fee_d' }); + + const req = { + query: { asset_code: 'USDC', amount: '200' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(req, mockResponse as Response); + + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ fee_amount: expect.any(String) }) + ); + }); + + it('stores feeAmount, feeAssetCode, and feeType on the transaction', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_fstore_d' }); + + const req = { + query: { asset_code: 'USDC', amount: '50' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(req, mockResponse as Response); + + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + feeAmount: expect.any(String), + feeAssetCode: 'USDC', + feeType: 'FLAT', + }), + }) + ); + }); + + it('stores senderInfo with email, first_name, and last_name', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_si' }); + + const req = { + query: { + asset_code: 'USDC', + amount: '25', + email_address: 'alice@example.com', + first_name: 'Alice', + last_name: 'Smith', + }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(req, mockResponse as Response); + + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + senderInfo: expect.objectContaining({ + email_address: 'alice@example.com', + first_name: 'Alice', + last_name: 'Smith', + }), + }), + }) + ); + }); + + it('stores callbackUrl when callback_url is provided', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_cb_d' }); + + const req = { + query: { + asset_code: 'USDC', + amount: '10', + callback_url: 'https://my.app/deposit-callback', + }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(req, mockResponse as Response); + + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ callbackUrl: 'https://my.app/deposit-callback' }), + }) + ); + }); + + it('stores memo and memo_type in senderInfo when provided', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_memo_d' }); + + const req = { + query: { asset_code: 'USDC', amount: '10', memo: 'order-42', memo_type: 'text' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(req, mockResponse as Response); + + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + senderInfo: expect.objectContaining({ memo: 'order-42', memo_type: 'text' }), + }), + }) + ); + }); + + it('uses the caller-supplied memo in the response extra_info', async () => { + (prisma.transaction.create as jest.Mock).mockResolvedValue({ id: 'tx_memo_resp' }); + + const req = { + query: { asset_code: 'USDC', amount: '10', memo: 'custom-memo', memo_type: 'text' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(req, mockResponse as Response); + + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + extra_info: expect.objectContaining({ memo: 'custom-memo' }), + }) + ); + }); + + it('returns 400 when amount is NaN', async () => { + const req = { + query: { asset_code: 'USDC', amount: 'abc' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(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' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(req, mockResponse as Response); + + expect(statusMock).toHaveBeenCalledWith(400); + }); + + it('returns 400 when amount exceeds maximum', async () => { + const req = { + query: { asset_code: 'USDC', amount: '9999999' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(req, mockResponse as Response); + + expect(statusMock).toHaveBeenCalledWith(400); + }); + + it('returns 500 when the database throws', async () => { + (prisma.transaction.create as jest.Mock).mockRejectedValue(new Error('DB unavailable')); + + const req = { + query: { asset_code: 'USDC', amount: '10' }, + user: { publicKey: 'GTEST' }, + } as any; + + await sep6Deposit(req, mockResponse as Response); + + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ error: 'Failed to initiate deposit.' }); + }); }); describe('sep6Withdraw', () => { diff --git a/backend/src/api/controllers/sep6.controller.ts b/backend/src/api/controllers/sep6.controller.ts index 9d6d2d4..7f52bb2 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 @@ -49,9 +51,29 @@ export const sep6Info = (_req: AuthRequest, res: Response): Response => { /** * GET /sep6/deposit * SEP-6 non-interactive deposit. Creates a pending transaction and returns instructions. + * + * Enhancements over the base stub: + * - Computes applicable fees via the asset fee-configuration strategy. + * - Accepts optional `memo`, `memo_type`, `callback_url`, and `lang` params + * per the SEP-6 specification. + * - Persists `senderInfo` JSON (email, first_name, last_name, memo, memo_type), + * `callbackUrl`, `feeAmount`, `feeAssetCode`, and `feeType` on the Transaction + * record for downstream processing and reporting. + * - Returns `fee_amount` so the client can display the exact cost upfront. + * - Safe error logging: only the error message is emitted, never the full stack + * or any request secrets. */ export const sep6Deposit = async (req: AuthRequest, res: Response): Promise => { - const { asset_code, amount, email_address } = req.query as Record; + const { + asset_code, + amount, + email_address, + first_name, + last_name, + memo, + memo_type = 'text', + callback_url, + } = req.query as Record; const publicKey = req.user!.publicKey; const code = normalizeAssetCode(asset_code); @@ -61,15 +83,20 @@ export const sep6Deposit = 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 is supplied so the client always gets a fee estimate. + const feeAmount = parsedAmount > 0 ? computeAssetFee(asset, parsedAmount) : asset.feeFixed; + try { const tx = await prisma.transaction.create({ data: { @@ -87,9 +114,24 @@ export const sep6Deposit = async (req: AuthRequest, res: Response): Promise