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
169 changes: 169 additions & 0 deletions backend/src/api/controllers/sep6.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
58 changes: 52 additions & 6 deletions backend/src/api/controllers/sep6.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Response> => {
const { asset_code, amount, email_address } = req.query as Record<string, string>;
const {
asset_code,
amount,
email_address,
first_name,
last_name,
memo,
memo_type = 'text',
callback_url,
} = req.query as Record<string, string>;
const publicKey = req.user!.publicKey;
const code = normalizeAssetCode(asset_code);

Expand All @@ -61,15 +83,20 @@ export const sep6Deposit = async (req: AuthRequest, res: Response): Promise<Resp

const asset = getAsset(code)!;

let parsedAmount = 0;
if (amount) {
const amt = parseFloat(amount);
if (isNaN(amt) || amt < parseFloat(asset.minAmount) || amt > 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: {
Expand All @@ -87,9 +114,24 @@ export const sep6Deposit = async (req: AuthRequest, res: Response): Promise<Resp
amount: amount || '0',
type: 'DEPOSIT',
status: 'PENDING',
feeAmount: String(feeAmount),
feeAssetCode: code,
feeType: asset.feeType.toUpperCase(),
senderInfo: {
...(email_address ? { email_address } : {}),
...(first_name ? { first_name } : {}),
...(last_name ? { last_name } : {}),
...(memo ? { memo, memo_type } : {}),
},
...(callback_url ? { callbackUrl: callback_url } : {}),
},
});

logger.info('SEP-6 deposit initiated', { transactionId: tx.id, assetCode: code });

const txMemo = memo ?? tx.id;
const txMemoType = memo ? memo_type : 'text';

return res.json({
how: `Send ${code} to the anchor's receiving account.`,
id: tx.id,
Expand All @@ -98,15 +140,19 @@ export const sep6Deposit = async (req: AuthRequest, res: Response): Promise<Resp
max_amount: asset.maxAmount,
fee_fixed: asset.feeFixed,
fee_percent: asset.feePercent,
fee_amount: String(feeAmount),
extra_info: {
message: 'Include the transaction ID as the memo when sending funds.',
memo: tx.id,
memo_type: 'text',
memo: txMemo,
memo_type: txMemoType,
receiving_account: process.env.RECEIVING_ACCOUNT || 'GD5DJQDKEBTHBQC7LKLDSLRGEA3KMRMFOKMJUEKSFZLWQ5E2PJDJYZNF',
},
});
} catch (error) {
console.error('SEP-6 deposit error:', error);
logger.error('SEP-6 deposit initiation failed', {
error: error instanceof Error ? error.message : 'Unknown error',
assetCode: code,
});
return res.status(500).json({ error: 'Failed to initiate deposit.' });
}
};
Expand Down
8 changes: 8 additions & 0 deletions backend/src/api/routes/sep6.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@ const router = Router();

const depositQuerySchema = z.object({
asset_code: z.string().min(1, 'asset_code is required'),
/** Stellar account that should receive the deposited funds (optional per SEP-6). */
account: z.string().optional(),
amount: z.string().optional(),
email_address: z.string().email().optional(),
first_name: z.string().optional(),
last_name: z.string().optional(),
/** Memo value the sender should attach to their Stellar payment. */
memo: z.string().optional(),
/** Memo type: text (default), id, or hash. */
memo_type: z.enum(['text', 'id', 'hash']).optional(),
/** URL the anchor should POST status updates to (SEP-6 §4.1). */
callback_url: z.string().url().optional(),
lang: z.string().optional(),
});

const withdrawQuerySchema = z.object({
Expand Down