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
189 changes: 189 additions & 0 deletions backend/src/api/controllers/sep6.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand All @@ -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', () => {
Expand Down
59 changes: 53 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 @@ -114,9 +116,28 @@ export const sep6Deposit = async (req: AuthRequest, res: Response): Promise<Resp
/**
* GET /sep6/withdraw
* SEP-6 non-interactive withdrawal. Creates a pending transaction and returns instructions.
*
* Enhancements over the base stub:
* - Computes applicable fees via the asset fee-configuration strategy.
* - Accepts optional `account` (Stellar account requesting the withdrawal),
* `callback_url`, and `lang` per the SEP-6 specification.
* - Persists `receiverInfo`, `callbackUrl`, `feeAmount`, `feeAssetCode`, and
* `feeType` on the Transaction record for downstream processing and reporting.
* - Returns `fee_amount` and (when an amount is provided) `amount_out` so the
* client knows exactly how much the recipient will receive.
* - Safe error logging: only the error message is logged, never the full stack
* or any request secrets.
*/
export const sep6Withdraw = async (req: AuthRequest, res: Response): Promise<Response> => {
const { asset_code, amount, dest, dest_extra, type = 'bank_account' } = req.query as Record<string, string>;
const {
asset_code,
amount,
dest,
dest_extra,
type = 'bank_account',
account,
callback_url,
} = req.query as Record<string, string>;
const publicKey = req.user!.publicKey;
const code = normalizeAssetCode(asset_code);

Expand All @@ -130,15 +151,23 @@ export const sep6Withdraw = async (req: AuthRequest, res: Response): Promise<Res

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 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: {
Expand All @@ -153,9 +182,21 @@ export const sep6Withdraw = async (req: AuthRequest, res: Response): Promise<Res
amount: amount || '0',
type: 'WITHDRAW',
status: 'PENDING',
feeAmount: String(feeAmount),
feeAssetCode: code,
feeType: asset.feeType.toUpperCase(),
receiverInfo: {
dest,
dest_extra: dest_extra || null,
type,
account: account || null,
},
...(callback_url ? { callbackUrl: callback_url } : {}),
},
});

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

return res.json({
account_id: process.env.RECEIVING_ACCOUNT || 'GD5DJQDKEBTHBQC7LKLDSLRGEA3KMRMFOKMJUEKSFZLWQ5E2PJDJYZNF',
memo_type: 'text',
Expand All @@ -166,15 +207,21 @@ export const sep6Withdraw = async (req: AuthRequest, res: Response): Promise<Res
max_amount: asset.maxAmount,
fee_fixed: asset.feeFixed,
fee_percent: asset.feePercent,
fee_amount: String(feeAmount),
...(amountOut !== undefined ? { amount_out: String(amountOut) } : {}),
type,
extra_info: {
message: `Send ${code} to the anchor account with the memo. Funds will be sent to ${dest}${dest_extra ? ` (routing: ${dest_extra})` : ''}.`,
type,
dest,
dest_extra,
...(dest_extra ? { dest_extra } : {}),
type,
},
});
} catch (error) {
console.error('SEP-6 withdraw error:', error);
logger.error('SEP-6 withdrawal initiation failed', {
error: error instanceof Error ? error.message : 'Unknown error',
assetCode: code,
});
return res.status(500).json({ error: 'Failed to initiate withdrawal.' });
}
};
Expand Down
5 changes: 5 additions & 0 deletions backend/src/api/routes/sep6.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@ const depositQuerySchema = z.object({

const withdrawQuerySchema = z.object({
asset_code: z.string().min(1, 'asset_code is required'),
/** Stellar account that is initiating the withdrawal (optional per SEP-6). */
account: z.string().optional(),
amount: z.string().optional(),
dest: z.string().min(1, 'dest is required'),
dest_extra: z.string().optional(),
type: z.enum(['bank_account', 'crypto']).optional(),
/** URL the anchor should POST status updates to (SEP-6 §4.1). */
callback_url: z.string().url().optional(),
lang: z.string().optional(),
});

const transactionQuerySchema = z.object({
Expand Down