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
8 changes: 8 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ import { notificationsRouter } from './routes/notifications.js';
import { auditRouter } from './routes/audit.js';
import { hedgingRouter } from './routes/hedging.js';
import { complianceRouter } from './routes/compliance.js';
import { amlMonitoringRouter } from './routes/aml-monitoring.js';
import { sanctionsScreeningRouter } from './routes/sanctions.js';
import { pciDssRouter } from './routes/pci-dss.js';
import { nanoRouter } from './routes/nano.js';
import { gdprRouter } from './routes/gdpr.js';
import dataExportRouter from './routes/dataExport.js';
import securityRouter from './routes/security.js';
Expand Down Expand Up @@ -378,6 +382,10 @@ app.use('/api/v1/notifications', notificationsRouter);
app.use('/api/v1/audit', auditRouter);
app.use('/api/v1/hedging', hedgingRouter);
app.use('/api/v1/compliance', complianceRouter);
app.use('/api/v1/aml', amlMonitoringRouter);
app.use('/api/v1/sanctions', sanctionsScreeningRouter);
app.use('/api/v1/pci-dss', pciDssRouter);
app.use('/api/v1/nano', nanoRouter);
app.use('/api/v1/gdpr', gdprRouter);
app.use('/api/v1/escrow', escrowRouter);
app.use('/api/v1/multisig', multisigRouter);
Expand Down
151 changes: 151 additions & 0 deletions backend/src/routes/aml-monitoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* AML transaction monitoring API — Issue #922
*
* Endpoints:
* - GET /aml/rules → rule catalogue and thresholds
* - POST /aml/score → score a single transaction
* - POST /aml/monitor → batch score + auto-create alerts
* - GET /aml/alerts → list alerts (filter by status/severity/account)
* - PATCH /aml/alerts/:id → triage an alert
*/

import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { asyncHandler } from '../middleware/errorHandler.js';
import { validate } from '../middleware/validate.js';
import {
AML_THRESHOLDS,
AmlAccountContext,
AmlAlertSeverity,
AmlAlertStatus,
AmlTransaction,
getAmlAlert,
getAmlAlerts,
getAmlRuleCatalogue,
monitorAmlTransactions,
resolveAmlAlert,
scoreAmlRisk,
} from '../services/aml/transaction-monitor.js';

export const amlMonitoringRouter = Router();

const transactionSchema = z.object({
transactionId: z.string().min(1),
accountId: z.string().min(1),
counterpartyId: z.string().min(1).optional(),
amount: z.number(),
currency: z.string().min(1),
timestamp: z.union([z.number(), z.string().min(1)]),
direction: z.enum(['inbound', 'outbound']).optional(),
channel: z.enum(['crypto', 'bank', 'card', 'wallet']).optional(),
originCountry: z.string().optional(),
destinationCountry: z.string().optional(),
counterpartySanctioned: z.boolean().optional(),
counterpartyIsPep: z.boolean().optional(),
structuringSignals: z.number().int().nonnegative().optional(),
metadata: z.record(z.unknown()).optional(),
});

const accountContextSchema = z.object({
accountId: z.string().min(1),
accountAgeDays: z.number().nonnegative().optional(),
kycVerified: z.boolean().optional(),
customerRiskRating: z.enum(['low', 'medium', 'high']).optional(),
transactionsLastHour: z.number().int().nonnegative().optional(),
transactionsLast24h: z.number().int().nonnegative().optional(),
totalAmountLast24h: z.number().nonnegative().optional(),
baselineAmount: z.number().nonnegative().optional(),
distinctCounterpartiesLast24h: z.number().int().nonnegative().optional(),
highRiskCountries: z.array(z.string()).optional(),
knownCounterparties: z.array(z.string()).optional(),
});

const monitorSchema = z.object({
transactions: z.array(transactionSchema).min(1),
accounts: z.array(accountContextSchema).optional(),
});

const triageSchema = z.object({
status: z.enum(['under_review', 'resolved', 'dismissed']),
reviewedBy: z.string().min(1).optional(),
resolution: z.string().min(1).optional(),
});

amlMonitoringRouter.get(
'/rules',
asyncHandler(async (_req: Request, res: Response) => {
res.status(200).json({ success: true, data: { rules: getAmlRuleCatalogue(), thresholds: AML_THRESHOLDS } });
}),
);

amlMonitoringRouter.post(
'/score',
validate(z.object({ transaction: transactionSchema, account: accountContextSchema.optional() })),
asyncHandler(async (req: Request, res: Response) => {
const { transaction, account } = req.body as {
transaction: AmlTransaction;
account?: AmlAccountContext;
};
const assessment = scoreAmlRisk(transaction, account);
res.status(200).json({ success: true, data: assessment });
}),
);

amlMonitoringRouter.post(
'/monitor',
validate(monitorSchema),
asyncHandler(async (req: Request, res: Response) => {
const { transactions, accounts } = req.body as {
transactions: AmlTransaction[];
accounts?: AmlAccountContext[];
};

const accountContexts: Record<string, AmlAccountContext> = {};
for (const account of accounts ?? []) {
accountContexts[account.accountId] = account;
}

const result = monitorAmlTransactions(transactions, accountContexts);
res.status(200).json({ success: true, data: result });
}),
);

amlMonitoringRouter.get(
'/alerts',
asyncHandler(async (req: Request, res: Response) => {
const status = req.query.status as AmlAlertStatus | undefined;
const severity = req.query.severity as AmlAlertSeverity | undefined;
const accountId = req.query.accountId as string | undefined;
const list = getAmlAlerts({ status, severity, accountId });
res.status(200).json({ success: true, data: list });
}),
);

amlMonitoringRouter.get(
'/alerts/:id',
asyncHandler(async (req: Request, res: Response) => {
const alert = getAmlAlert(req.params.id);
if (!alert) {
res.status(404).json({ success: false, error: { message: 'AML alert not found' } });
return;
}
res.status(200).json({ success: true, data: alert });
}),
);

amlMonitoringRouter.patch(
'/alerts/:id',
validate(triageSchema),
asyncHandler(async (req: Request, res: Response) => {
try {
const { status, reviewedBy, resolution } = req.body as z.infer<typeof triageSchema>;
const alert = resolveAmlAlert(req.params.id, status, { reviewedBy, resolution });
res.status(200).json({ success: true, data: alert });
} catch (error) {
res.status(404).json({
success: false,
error: { message: error instanceof Error ? error.message : 'Unable to update AML alert' },
});
}
}),
);
183 changes: 183 additions & 0 deletions backend/src/routes/nano.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Nano (XNO) settle rail API — Issue #1004
*
* Endpoints:
* - GET /nano/status → rail info (feeless, finality, metrics)
* - POST /nano/escrows → hold funds (one feeless send)
* - POST /nano/escrows/:id/release → release funds (one feeless send = confirmation)
* - POST /nano/escrows/:id/refund → refund funds (one feeless send)
* - GET /nano/escrows → list escrows (?status=&payer=&payee=)
* - GET /nano/escrows/:id → fetch an escrow
* - GET /nano/escrows/:id/receipt → verify the public release receipt
* - GET /nano/blocks/:hash → look up a block by hash
* - POST /nano/fund → fund an account (sandbox/faucet helper)
*/

import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { asyncHandler } from '../middleware/errorHandler.js';
import { validate } from '../middleware/validate.js';
import {
NANO_CONFIG,
NanoEscrowStatus,
SUPPORTED_SETTLEMENT_RAILS,
createNanoSettleRail,
} from '../services/settlement/nano-settle-rail.js';

export const nanoRouter = Router();
export const nanoRail = createNanoSettleRail();

const holdSchema = z.object({
payer: z.string().min(1),
payee: z.string().min(1),
amountXno: z.number().positive(),
representative: z.string().min(1).optional(),
escrowId: z.string().min(1).optional(),
idempotencyKey: z.string().min(1).optional(),
metadata: z.record(z.unknown()).optional(),
});

const releaseSchema = z.object({
approvedBy: z.string().min(1).optional(),
});

const refundSchema = z.object({
reason: z.string().min(1).optional(),
});

const fundSchema = z.object({
address: z.string().min(1),
amountXno: z.number().positive(),
});

nanoRouter.get(
'/status',
asyncHandler(async (_req: Request, res: Response) => {
res.status(200).json({
success: true,
data: {
rail: 'nano',
ticker: NANO_CONFIG.ticker,
decimals: NANO_CONFIG.decimals,
feeless: true,
finalityMs: NANO_CONFIG.finalityMs,
supportedRails: SUPPORTED_SETTLEMENT_RAILS,
metrics: nanoRail.getMetrics(),
},
});
}),
);

nanoRouter.post(
'/escrows',
validate(holdSchema),
asyncHandler(async (req: Request, res: Response) => {
try {
const escrow = nanoRail.holdEscrow(req.body);
res.status(201).json({ success: true, data: escrow });
} catch (error) {
res.status(400).json({
success: false,
error: { message: error instanceof Error ? error.message : 'Unable to hold escrow' },
});
}
}),
);

nanoRouter.get(
'/escrows',
asyncHandler(async (req: Request, res: Response) => {
const status = req.query.status as NanoEscrowStatus | undefined;
const payer = req.query.payer as string | undefined;
const payee = req.query.payee as string | undefined;
res.status(200).json({ success: true, data: nanoRail.listEscrows({ status, payer, payee }) });
}),
);

nanoRouter.get(
'/escrows/:id',
asyncHandler(async (req: Request, res: Response) => {
const escrow = nanoRail.getEscrow(req.params.id);
if (!escrow) {
res.status(404).json({ success: false, error: { message: 'Escrow not found' } });
return;
}
res.status(200).json({ success: true, data: escrow });
}),
);

nanoRouter.post(
'/escrows/:id/release',
validate(releaseSchema),
asyncHandler(async (req: Request, res: Response) => {
try {
const escrow = nanoRail.releaseEscrow(req.params.id, req.body);
res.status(200).json({ success: true, data: escrow });
} catch (error) {
res.status(400).json({
success: false,
error: { message: error instanceof Error ? error.message : 'Unable to release escrow' },
});
}
}),
);

nanoRouter.post(
'/escrows/:id/refund',
validate(refundSchema),
asyncHandler(async (req: Request, res: Response) => {
try {
const escrow = nanoRail.refundEscrow(req.params.id, req.body);
res.status(200).json({ success: true, data: escrow });
} catch (error) {
res.status(400).json({
success: false,
error: { message: error instanceof Error ? error.message : 'Unable to refund escrow' },
});
}
}),
);

nanoRouter.get(
'/escrows/:id/receipt',
asyncHandler(async (req: Request, res: Response) => {
try {
const receipt = nanoRail.verifyEscrowReceipt(req.params.id);
res.status(200).json({ success: true, data: receipt });
} catch (error) {
res.status(404).json({
success: false,
error: { message: error instanceof Error ? error.message : 'Escrow not found' },
});
}
}),
);

nanoRouter.get(
'/blocks/:hash',
asyncHandler(async (req: Request, res: Response) => {
const block = nanoRail.getBlock(req.params.hash);
if (!block) {
res.status(404).json({ success: false, error: { message: 'Block not found' } });
return;
}
res.status(200).json({ success: true, data: block });
}),
);

nanoRouter.post(
'/fund',
validate(fundSchema),
asyncHandler(async (req: Request, res: Response) => {
const { address, amountXno } = req.body as z.infer<typeof fundSchema>;
try {
nanoRail.fundAccount(address, amountXno);
res.status(200).json({ success: true, data: { address, balanceXno: nanoRail.getBalanceXno(address) } });
} catch (error) {
res.status(400).json({
success: false,
error: { message: error instanceof Error ? error.message : 'Unable to fund account' },
});
}
}),
);
Loading
Loading