feat: OMNL Bank online production — 128 chains, Central Bank UI, settlement stack
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m22s
CI/CD Pipeline / Security Scanning (push) Successful in 2m30s
CI/CD Pipeline / Lint and Format (push) Failing after 44s
CI/CD Pipeline / Terraform Validation (push) Failing after 27s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 31s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 56s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 29s
Validation / validate-genesis (push) Successful in 34s
Validation / validate-terraform (push) Failing after 39s
Validation / validate-kubernetes (push) Failing after 14s
Validation / validate-smart-contracts (push) Failing after 20s
Validation / validate-security (push) Failing after 1m19s
Validation / validate-documentation (push) Failing after 27s
Verify Deployment / Verify Deployment (push) Failing after 1m13s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m22s
CI/CD Pipeline / Security Scanning (push) Successful in 2m30s
CI/CD Pipeline / Lint and Format (push) Failing after 44s
CI/CD Pipeline / Terraform Validation (push) Failing after 27s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 31s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 56s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 29s
Validation / validate-genesis (push) Successful in 34s
Validation / validate-terraform (push) Failing after 39s
Validation / validate-kubernetes (push) Failing after 14s
Validation / validate-smart-contracts (push) Failing after 20s
Validation / validate-security (push) Failing after 1m19s
Validation / validate-documentation (push) Failing after 27s
Verify Deployment / Verify Deployment (push) Failing after 1m13s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
19
services/dbis-exchange/Dockerfile
Normal file
19
services/dbis-exchange/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
RUN npm ci
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
USER nodejs
|
||||
EXPOSE 3012
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3012/api/v1/exchange/health',r=>process.exit(r.statusCode===200?0:1))"
|
||||
CMD ["node", "dist/index.js"]
|
||||
111
services/dbis-exchange/dist/adapters/settlement.js
vendored
Normal file
111
services/dbis-exchange/dist/adapters/settlement.js
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.fetchFundLedger = fetchFundLedger;
|
||||
exports.fetchOfficeProfile = fetchOfficeProfile;
|
||||
exports.auditSwapToSettlement = auditSwapToSettlement;
|
||||
exports.requestM2TokenLoad = requestM2TokenLoad;
|
||||
exports.requestInternalTransfer = requestInternalTransfer;
|
||||
exports.requestExternalTransfer = requestExternalTransfer;
|
||||
exports.fetchCryptoLedger = fetchCryptoLedger;
|
||||
exports.resolveTokenLine = resolveTokenLine;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const ethers_1 = require("ethers");
|
||||
const config_1 = require("../config");
|
||||
const ERC20_ABI = ['function balanceOf(address) view returns (uint256)', 'function decimals() view returns (uint8)'];
|
||||
async function fetchFundLedger(officeId, apiKey) {
|
||||
const headers = {};
|
||||
if (apiKey)
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
const { data } = await axios_1.default.get(`${(0, config_1.settlementUrl)()}/api/v1/settlement/money-supply`, {
|
||||
headers,
|
||||
timeout: 60000,
|
||||
});
|
||||
return { officeId, fundLedger: data };
|
||||
}
|
||||
async function fetchOfficeProfile() {
|
||||
const { data } = await axios_1.default.get(`${(0, config_1.settlementUrl)()}/api/v1/settlement/office`, { timeout: 15000 });
|
||||
return data;
|
||||
}
|
||||
async function auditSwapToSettlement(payload) {
|
||||
const lineId = payload.tokenLineId ?? `${payload.currency}-M2`;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (payload.apiKey)
|
||||
headers.Authorization = `Bearer ${payload.apiKey}`;
|
||||
const { data } = await axios_1.default.post(`${(0, config_1.settlementUrl)()}/api/v1/settlement/convert/fiat-to-crypto`, {
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
creditorIban: 'GB82WEST12345698765432',
|
||||
beneficiaryName: 'DBIS Exchange settlement audit',
|
||||
remittanceInfo: payload.remittanceInfo,
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
tokenLineId: lineId,
|
||||
recipientAddress: payload.recipientAddress,
|
||||
},
|
||||
}, { headers, timeout: 120000, validateStatus: () => true });
|
||||
return data;
|
||||
}
|
||||
async function requestM2TokenLoad(payload) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (payload.apiKey)
|
||||
headers.Authorization = `Bearer ${payload.apiKey}`;
|
||||
const { data } = await axios_1.default.post(`${(0, config_1.settlementUrl)()}/api/v1/settlement/token-load`, {
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
lineId: payload.lineId,
|
||||
symbol: payload.symbol,
|
||||
tokenAddress: payload.tokenAddress,
|
||||
amount: payload.amount,
|
||||
recipientAddress: payload.recipientAddress,
|
||||
currency: payload.currency,
|
||||
}, { headers, timeout: 120000, validateStatus: () => true });
|
||||
return data;
|
||||
}
|
||||
async function requestInternalTransfer(payload) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (payload.apiKey)
|
||||
headers.Authorization = `Bearer ${payload.apiKey}`;
|
||||
const { data } = await axios_1.default.post(`${(0, config_1.settlementUrl)()}/api/v1/settlement/transfer/internal`, {
|
||||
...payload,
|
||||
moneyLayers: ['M2'],
|
||||
}, { headers, timeout: 120000, validateStatus: () => true });
|
||||
return data;
|
||||
}
|
||||
async function requestExternalTransfer(payload) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (payload.apiKey)
|
||||
headers.Authorization = `Bearer ${payload.apiKey}`;
|
||||
const { data } = await axios_1.default.post(`${(0, config_1.settlementUrl)()}/api/v1/settlement/transfer/external`, {
|
||||
...payload,
|
||||
moneyLayers: ['M2'],
|
||||
rail: 'SWIFT',
|
||||
}, { headers, timeout: 120000, validateStatus: () => true });
|
||||
return data;
|
||||
}
|
||||
async function fetchCryptoLedger(rpcUrl, wallet, tokens) {
|
||||
const provider = new ethers_1.JsonRpcProvider(rpcUrl);
|
||||
const balances = {};
|
||||
for (const t of tokens) {
|
||||
if (t.native || t.address === '0x0000000000000000000000000000000000000000') {
|
||||
const wei = await provider.getBalance(wallet);
|
||||
balances[t.symbol] = (0, ethers_1.formatUnits)(wei, 18);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const c = new ethers_1.Contract(t.address, ERC20_ABI, provider);
|
||||
const raw = await c.balanceOf(wallet);
|
||||
balances[t.symbol] = (0, ethers_1.formatUnits)(raw, t.decimals);
|
||||
}
|
||||
catch {
|
||||
balances[t.symbol] = '0';
|
||||
}
|
||||
}
|
||||
return { wallet, balances };
|
||||
}
|
||||
function resolveTokenLine(token) {
|
||||
return token.omnlLine;
|
||||
}
|
||||
52
services/dbis-exchange/dist/adapters/token-aggregation.js
vendored
Normal file
52
services/dbis-exchange/dist/adapters/token-aggregation.js
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.fetchQuote = fetchQuote;
|
||||
exports.fetchRoutePlan = fetchRoutePlan;
|
||||
exports.fetchExecutionPlan = fetchExecutionPlan;
|
||||
exports.fetchProviderCapabilities = fetchProviderCapabilities;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const config_1 = require("../config");
|
||||
async function fetchQuote(params) {
|
||||
const base = (0, config_1.tokenAggUrl)();
|
||||
const q = new URLSearchParams({
|
||||
chainId: String(params.chainId),
|
||||
tokenIn: params.tokenIn,
|
||||
tokenOut: params.tokenOut,
|
||||
amountIn: params.amountIn,
|
||||
});
|
||||
const { data } = await axios_1.default.get(`${base}/api/v1/quote?${q}`, { timeout: 60000 });
|
||||
return data;
|
||||
}
|
||||
async function fetchRoutePlan(body) {
|
||||
const { data } = await axios_1.default.post(`${(0, config_1.tokenAggUrl)()}/api/v2/routes/plan`, {
|
||||
sourceChainId: body.sourceChainId,
|
||||
destinationChainId: body.destinationChainId,
|
||||
tokenIn: body.tokenIn,
|
||||
tokenOut: body.tokenOut,
|
||||
amountIn: body.amountIn,
|
||||
recipient: body.recipient,
|
||||
constraints: { maxSlippageBps: body.maxSlippageBps ?? 50, allowBridge: false },
|
||||
}, { timeout: 120000 });
|
||||
return data;
|
||||
}
|
||||
async function fetchExecutionPlan(body) {
|
||||
const { data } = await axios_1.default.post(`${(0, config_1.tokenAggUrl)()}/api/v2/routes/internal-execution-plan`, {
|
||||
sourceChainId: body.sourceChainId,
|
||||
destinationChainId: body.destinationChainId,
|
||||
tokenIn: body.tokenIn,
|
||||
tokenOut: body.tokenOut,
|
||||
amountIn: body.amountIn,
|
||||
recipient: body.recipient,
|
||||
constraints: { maxSlippageBps: body.maxSlippageBps ?? 50, allowBridge: false },
|
||||
}, { timeout: 120000 });
|
||||
return data;
|
||||
}
|
||||
async function fetchProviderCapabilities(chainId) {
|
||||
const { data } = await axios_1.default.get(`${(0, config_1.tokenAggUrl)()}/api/v2/providers/capabilities?chainId=${chainId}`, {
|
||||
timeout: 30000,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
299
services/dbis-exchange/dist/api/routes/exchange.js
vendored
Normal file
299
services/dbis-exchange/dist/api/routes/exchange.js
vendored
Normal file
@@ -0,0 +1,299 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.exchangePort = void 0;
|
||||
exports.createExchangeRouter = createExchangeRouter;
|
||||
const express_1 = require("express");
|
||||
const config_1 = require("../../config");
|
||||
Object.defineProperty(exports, "exchangePort", { enumerable: true, get: function () { return config_1.exchangePort; } });
|
||||
const settlement_1 = require("../../adapters/settlement");
|
||||
const token_aggregation_1 = require("../../adapters/token-aggregation");
|
||||
const swap_monitor_1 = require("../../store/swap-monitor");
|
||||
function apiKey(req) {
|
||||
return req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key') || undefined;
|
||||
}
|
||||
function requireKey(req, res) {
|
||||
const cfg = (0, config_1.loadExchangeConfig)();
|
||||
if (!cfg.production.requireApiKey)
|
||||
return true;
|
||||
const key = apiKey(req);
|
||||
if (key && key === process.env.OMNL_API_KEY)
|
||||
return true;
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return false;
|
||||
}
|
||||
function createExchangeRouter() {
|
||||
const router = (0, express_1.Router)();
|
||||
const cfg = (0, config_1.loadExchangeConfig)();
|
||||
router.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
service: 'dbis-exchange',
|
||||
brand: cfg.brandName,
|
||||
chainId: cfg.chainId,
|
||||
officeId: cfg.officeId,
|
||||
settlementOffice: cfg.settlementOfficeExternalId,
|
||||
tokenAggregation: (0, config_1.tokenAggUrl)(),
|
||||
settlementMiddleware: (0, config_1.settlementUrl)(),
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
router.get('/tokens', (_req, res) => {
|
||||
const tokens = (0, config_1.loadExchangeTokens)();
|
||||
res.json({
|
||||
chainId: cfg.chainId,
|
||||
tokens,
|
||||
count: tokens.length,
|
||||
moneyLayer: 'M2',
|
||||
routers: cfg.routers,
|
||||
});
|
||||
});
|
||||
router.get('/tokens/capabilities', (_req, res) => {
|
||||
const registry = (0, config_1.loadM2TokenRegistry)();
|
||||
res.json(registry);
|
||||
});
|
||||
router.get('/providers', async (_req, res) => {
|
||||
try {
|
||||
res.json(await (0, token_aggregation_1.fetchProviderCapabilities)(cfg.chainId));
|
||||
}
|
||||
catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/quote', async (req, res) => {
|
||||
try {
|
||||
const tokenIn = String(req.query.tokenIn || '');
|
||||
const tokenOut = String(req.query.tokenOut || '');
|
||||
const amountIn = String(req.query.amountIn || '');
|
||||
if (!tokenIn || !tokenOut || !amountIn) {
|
||||
res.status(400).json({ error: 'tokenIn, tokenOut, amountIn required' });
|
||||
return;
|
||||
}
|
||||
const quote = await (0, token_aggregation_1.fetchQuote)({
|
||||
chainId: cfg.chainId,
|
||||
tokenIn,
|
||||
tokenOut,
|
||||
amountIn,
|
||||
});
|
||||
if (cfg.production.monitorSwaps) {
|
||||
(0, swap_monitor_1.recordSwap)({
|
||||
officeId: cfg.officeId,
|
||||
chainId: cfg.chainId,
|
||||
tokenIn,
|
||||
tokenOut,
|
||||
amountIn,
|
||||
amountOut: quote.amountOut ?? quote.expectedOutput,
|
||||
status: 'QUOTED',
|
||||
});
|
||||
}
|
||||
res.json(quote);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/plan', async (req, res) => {
|
||||
try {
|
||||
const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body;
|
||||
if (!tokenIn || !tokenOut || !amountIn) {
|
||||
res.status(400).json({ error: 'tokenIn, tokenOut, amountIn required' });
|
||||
return;
|
||||
}
|
||||
const plan = await (0, token_aggregation_1.fetchRoutePlan)({
|
||||
sourceChainId: cfg.chainId,
|
||||
destinationChainId: cfg.chainId,
|
||||
tokenIn: String(tokenIn),
|
||||
tokenOut: String(tokenOut),
|
||||
amountIn: String(amountIn),
|
||||
recipient: recipient ? String(recipient) : undefined,
|
||||
maxSlippageBps: maxSlippageBps ? Number(maxSlippageBps) : cfg.defaultSlippageBps,
|
||||
});
|
||||
res.json(plan);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/execute-plan', async (req, res) => {
|
||||
try {
|
||||
const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body;
|
||||
if (!tokenIn || !tokenOut || !amountIn || !recipient) {
|
||||
res.status(400).json({ error: 'tokenIn, tokenOut, amountIn, recipient required' });
|
||||
return;
|
||||
}
|
||||
const plan = await (0, token_aggregation_1.fetchExecutionPlan)({
|
||||
sourceChainId: cfg.chainId,
|
||||
destinationChainId: cfg.chainId,
|
||||
tokenIn: String(tokenIn),
|
||||
tokenOut: String(tokenOut),
|
||||
amountIn: String(amountIn),
|
||||
recipient: String(recipient),
|
||||
maxSlippageBps: maxSlippageBps ? Number(maxSlippageBps) : cfg.defaultSlippageBps,
|
||||
});
|
||||
res.json(plan);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/ledgers', async (req, res) => {
|
||||
try {
|
||||
const wallet = req.query.wallet ? String(req.query.wallet) : undefined;
|
||||
const serverKey = process.env.OMNL_API_KEY;
|
||||
const rpc = process.env.RPC_URL_138_PUBLIC ||
|
||||
process.env.RPC_URL_138 ||
|
||||
'https://rpc-http-pub.d-bis.org';
|
||||
let fund = null;
|
||||
let office = null;
|
||||
if (serverKey) {
|
||||
[fund, office] = await Promise.all([
|
||||
(0, settlement_1.fetchFundLedger)(cfg.officeId, serverKey),
|
||||
(0, settlement_1.fetchOfficeProfile)(),
|
||||
]);
|
||||
}
|
||||
let crypto;
|
||||
if (wallet) {
|
||||
crypto = await (0, settlement_1.fetchCryptoLedger)(rpc, wallet, (0, config_1.loadExchangeTokens)());
|
||||
}
|
||||
res.json({
|
||||
officeId: cfg.officeId,
|
||||
office,
|
||||
fundLedger: fund?.fundLedger ?? null,
|
||||
cryptoLedger: crypto ?? null,
|
||||
asOf: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/swap/monitor', (_req, res) => {
|
||||
res.json({ stats: (0, swap_monitor_1.swapStats)(), swaps: (0, swap_monitor_1.listSwaps)(Number(_req.query.limit ?? 50)) });
|
||||
});
|
||||
router.post('/swap/record', (req, res) => {
|
||||
const body = req.body;
|
||||
const rec = (0, swap_monitor_1.recordSwap)({
|
||||
officeId: cfg.officeId,
|
||||
chainId: cfg.chainId,
|
||||
tokenIn: String(body.tokenIn ?? ''),
|
||||
tokenOut: String(body.tokenOut ?? ''),
|
||||
amountIn: String(body.amountIn ?? ''),
|
||||
amountOut: body.amountOut ? String(body.amountOut) : undefined,
|
||||
txHash: body.txHash ? String(body.txHash) : undefined,
|
||||
wallet: body.wallet ? String(body.wallet) : undefined,
|
||||
routeProviders: Array.isArray(body.routeProviders)
|
||||
? body.routeProviders.map(String)
|
||||
: undefined,
|
||||
status: body.status ?? 'SUBMITTED',
|
||||
settlementRef: body.settlementRef ? String(body.settlementRef) : undefined,
|
||||
});
|
||||
res.json(rec);
|
||||
});
|
||||
router.patch('/swap/record/:id', (req, res) => {
|
||||
const updated = (0, swap_monitor_1.updateSwap)(req.params.id, req.body);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
router.post('/swap/settle', async (req, res) => {
|
||||
if (!requireKey(req, res))
|
||||
return;
|
||||
if (!cfg.production.postSwapSettlementAudit) {
|
||||
res.status(403).json({ error: 'postSwapSettlementAudit disabled' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { swapId, amount, recipientAddress, currency, tokenLineId, tokenSymbol } = req.body;
|
||||
const tokens = (0, config_1.loadExchangeTokens)();
|
||||
const token = tokens.find((t) => t.symbol === tokenSymbol);
|
||||
const lineId = tokenLineId
|
||||
? String(tokenLineId)
|
||||
: token
|
||||
? (0, settlement_1.resolveTokenLine)(token)
|
||||
: `${String(currency ?? 'USD')}-M2`;
|
||||
const audit = await (0, settlement_1.auditSwapToSettlement)({
|
||||
idempotencyKey: `SWAP-${String(swapId ?? Date.now())}`,
|
||||
amount: String(amount ?? '0'),
|
||||
currency: String(currency ?? 'USD'),
|
||||
recipientAddress: String(recipientAddress ?? ''),
|
||||
remittanceInfo: `DBIS Exchange M2 swap settlement Office ${cfg.officeId}`,
|
||||
tokenLineId: lineId,
|
||||
apiKey: apiKey(req),
|
||||
});
|
||||
if (swapId)
|
||||
(0, swap_monitor_1.updateSwap)(String(swapId), { settlementRef: audit.settlementId, status: 'CONFIRMED' });
|
||||
res.json(audit);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/token-load', async (req, res) => {
|
||||
if (!requireKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
const tokens = (0, config_1.loadExchangeTokens)();
|
||||
const addr = String(body.tokenAddress ?? '');
|
||||
const token = tokens.find((t) => t.address.toLowerCase() === addr.toLowerCase());
|
||||
const result = await (0, settlement_1.requestM2TokenLoad)({
|
||||
idempotencyKey: String(body.idempotencyKey ?? `LOAD-${Date.now()}`),
|
||||
lineId: String(body.lineId ?? token?.omnlLine ?? 'USD-M2'),
|
||||
symbol: String(body.symbol ?? token?.symbol ?? ''),
|
||||
tokenAddress: addr,
|
||||
amount: String(body.amount ?? '0'),
|
||||
recipientAddress: String(body.recipientAddress ?? ''),
|
||||
currency: String(body.currency ?? token?.currencyCode ?? 'USD'),
|
||||
apiKey: apiKey(req) ?? process.env.OMNL_API_KEY,
|
||||
});
|
||||
res.status(result.phase === 'FAILED' ? 422 : 200).json(result);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/transfer/internal', async (req, res) => {
|
||||
if (!requireKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
const result = await (0, settlement_1.requestInternalTransfer)({
|
||||
idempotencyKey: String(body.idempotencyKey ?? `XFER-INT-${Date.now()}`),
|
||||
tokenAddress: String(body.tokenAddress ?? ''),
|
||||
tokenSymbol: String(body.tokenSymbol ?? ''),
|
||||
amount: String(body.amount ?? '0'),
|
||||
recipientAddress: String(body.recipientAddress ?? ''),
|
||||
senderAddress: body.senderAddress ? String(body.senderAddress) : undefined,
|
||||
apiKey: apiKey(req) ?? process.env.OMNL_API_KEY,
|
||||
});
|
||||
res.status(result.phase === 'FAILED' ? 422 : 200).json(result);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/transfer/external', async (req, res) => {
|
||||
if (!requireKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
const result = await (0, settlement_1.requestExternalTransfer)({
|
||||
idempotencyKey: String(body.idempotencyKey ?? `XFER-EXT-${Date.now()}`),
|
||||
tokenAddress: String(body.tokenAddress ?? ''),
|
||||
tokenSymbol: String(body.tokenSymbol ?? ''),
|
||||
amount: String(body.amount ?? '0'),
|
||||
recipientAddress: String(body.recipientAddress ?? ''),
|
||||
creditorIban: String(body.creditorIban ?? ''),
|
||||
beneficiaryName: body.beneficiaryName ? String(body.beneficiaryName) : undefined,
|
||||
currency: body.currency ? String(body.currency) : undefined,
|
||||
remittanceInfo: body.remittanceInfo ? String(body.remittanceInfo) : undefined,
|
||||
apiKey: apiKey(req) ?? process.env.OMNL_API_KEY,
|
||||
});
|
||||
res.status(result.phase === 'FAILED' ? 422 : 200).json(result);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
45
services/dbis-exchange/dist/config.js
vendored
Normal file
45
services/dbis-exchange/dist/config.js
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.loadM2TokenRegistry = loadM2TokenRegistry;
|
||||
exports.loadExchangeTokens = loadExchangeTokens;
|
||||
exports.loadExchangeConfig = loadExchangeConfig;
|
||||
exports.exchangePort = exchangePort;
|
||||
exports.tokenAggUrl = tokenAggUrl;
|
||||
exports.settlementUrl = settlementUrl;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
let cached = null;
|
||||
let cachedM2 = null;
|
||||
function loadM2TokenRegistry() {
|
||||
if (cachedM2)
|
||||
return cachedM2;
|
||||
const p = process.env.OMNL_M2_TOKEN_REGISTRY ||
|
||||
path_1.default.resolve(__dirname, '../../../config/omnl-m2-token-registry.v1.json');
|
||||
cachedM2 = JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
|
||||
return cachedM2;
|
||||
}
|
||||
function loadExchangeTokens() {
|
||||
const cfg = loadExchangeConfig();
|
||||
return (0, settlement_core_1.mergeExchangeTokensWithM2Registry)(cfg.tokens, loadM2TokenRegistry());
|
||||
}
|
||||
function loadExchangeConfig() {
|
||||
if (cached)
|
||||
return cached;
|
||||
const p = process.env.DBIS_EXCHANGE_CONFIG ||
|
||||
path_1.default.resolve(__dirname, '../../../config/dbis-exchange.v1.json');
|
||||
cached = JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
|
||||
return cached;
|
||||
}
|
||||
function exchangePort() {
|
||||
return parseInt(process.env.DBIS_EXCHANGE_PORT || '3012', 10);
|
||||
}
|
||||
function tokenAggUrl() {
|
||||
return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
}
|
||||
function settlementUrl() {
|
||||
return (process.env.SETTLEMENT_MIDDLEWARE_URL || 'http://localhost:3011').replace(/\/$/, '');
|
||||
}
|
||||
47
services/dbis-exchange/dist/index.js
vendored
Normal file
47
services/dbis-exchange/dist/index.js
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const dotenv = __importStar(require("dotenv"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = require("fs");
|
||||
const server_1 = require("./server");
|
||||
const rootEnv = path_1.default.resolve(__dirname, '../../../.env');
|
||||
if ((0, fs_1.existsSync)(rootEnv))
|
||||
dotenv.config({ path: rootEnv });
|
||||
dotenv.config();
|
||||
(0, server_1.startServer)();
|
||||
19
services/dbis-exchange/dist/server.js
vendored
Normal file
19
services/dbis-exchange/dist/server.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.startServer = startServer;
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const exchange_1 = require("./api/routes/exchange");
|
||||
function startServer() {
|
||||
const app = (0, express_1.default)();
|
||||
app.use((0, cors_1.default)());
|
||||
app.use(express_1.default.json());
|
||||
app.use('/api/v1/exchange', (0, exchange_1.createExchangeRouter)());
|
||||
const p = (0, exchange_1.exchangePort)();
|
||||
app.listen(p, () => {
|
||||
console.log(`DBIS Exchange (Pancake-style) on http://localhost:${p}/api/v1/exchange`);
|
||||
});
|
||||
}
|
||||
52
services/dbis-exchange/dist/store/swap-monitor.js
vendored
Normal file
52
services/dbis-exchange/dist/store/swap-monitor.js
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.recordSwap = recordSwap;
|
||||
exports.updateSwap = updateSwap;
|
||||
exports.listSwaps = listSwaps;
|
||||
exports.swapStats = swapStats;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const uuid_1 = require("uuid");
|
||||
const base = process.env.DBIS_SWAP_MONITOR_DIR ||
|
||||
path_1.default.resolve(__dirname, '../../../data/dbis-exchange/swap-monitor');
|
||||
function dir() {
|
||||
if (!fs_1.default.existsSync(base))
|
||||
fs_1.default.mkdirSync(base, { recursive: true });
|
||||
return base;
|
||||
}
|
||||
function recordSwap(entry) {
|
||||
const now = new Date().toISOString();
|
||||
const rec = { id: (0, uuid_1.v4)(), createdAt: now, updatedAt: now, ...entry };
|
||||
fs_1.default.writeFileSync(path_1.default.join(dir(), `${rec.id}.json`), JSON.stringify(rec, null, 2));
|
||||
return rec;
|
||||
}
|
||||
function updateSwap(id, patch) {
|
||||
const p = path_1.default.join(dir(), `${id}.json`);
|
||||
if (!fs_1.default.existsSync(p))
|
||||
return null;
|
||||
const rec = JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
|
||||
const updated = { ...rec, ...patch, updatedAt: new Date().toISOString() };
|
||||
fs_1.default.writeFileSync(p, JSON.stringify(updated, null, 2));
|
||||
return updated;
|
||||
}
|
||||
function listSwaps(limit = 100) {
|
||||
return fs_1.default
|
||||
.readdirSync(dir())
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir(), f), 'utf8')))
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
function swapStats() {
|
||||
const all = listSwaps(5000);
|
||||
const dayAgo = Date.now() - 86400000;
|
||||
return {
|
||||
total: all.length,
|
||||
confirmed: all.filter((s) => s.status === 'CONFIRMED').length,
|
||||
failed: all.filter((s) => s.status === 'FAILED').length,
|
||||
last24h: all.filter((s) => new Date(s.createdAt).getTime() > dayAgo).length,
|
||||
};
|
||||
}
|
||||
1527
services/dbis-exchange/package-lock.json
generated
Normal file
1527
services/dbis-exchange/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
services/dbis-exchange/package.json
Normal file
28
services/dbis-exchange/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "dbis-exchange",
|
||||
"version": "1.0.0",
|
||||
"description": "DBIS Pancake-style exchange on Chain 138 — OMNL Office 24 settlement + ledger + swap monitoring",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dbis/settlement-core": "file:../../packages/settlement-core",
|
||||
"axios": "^1.15.2",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^16.6.1",
|
||||
"ethers": "^6.16.0",
|
||||
"express": "^5.1.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^20.19.33",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
159
services/dbis-exchange/src/adapters/settlement.ts
Normal file
159
services/dbis-exchange/src/adapters/settlement.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { JsonRpcProvider, Contract, formatUnits } from 'ethers';
|
||||
|
||||
import { settlementUrl } from '../config';
|
||||
|
||||
import type { M2TokenCapabilities } from '@dbis/settlement-core';
|
||||
|
||||
|
||||
|
||||
const ERC20_ABI = ['function balanceOf(address) view returns (uint256)', 'function decimals() view returns (uint8)'];
|
||||
|
||||
|
||||
|
||||
export async function fetchFundLedger(officeId: number, apiKey?: string) {
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
const { data } = await axios.get(`${settlementUrl()}/api/v1/settlement/money-supply`, {
|
||||
|
||||
headers,
|
||||
|
||||
timeout: 60000,
|
||||
|
||||
});
|
||||
|
||||
return { officeId, fundLedger: data };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function fetchOfficeProfile() {
|
||||
|
||||
const { data } = await axios.get(`${settlementUrl()}/api/v1/settlement/office`, { timeout: 15000 });
|
||||
|
||||
return data;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function auditSwapToSettlement(payload: {
|
||||
|
||||
idempotencyKey: string;
|
||||
|
||||
amount: string;
|
||||
|
||||
currency: string;
|
||||
|
||||
recipientAddress: string;
|
||||
|
||||
remittanceInfo: string;
|
||||
|
||||
tokenLineId?: string;
|
||||
|
||||
apiKey?: string;
|
||||
|
||||
}) {
|
||||
|
||||
const lineId = payload.tokenLineId ?? `${payload.currency}-M2`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (payload.apiKey) headers.Authorization = `Bearer ${payload.apiKey}`;
|
||||
|
||||
const { data } = await axios.post(
|
||||
|
||||
`${settlementUrl()}/api/v1/settlement/convert/fiat-to-crypto`,
|
||||
|
||||
{
|
||||
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
|
||||
amount: payload.amount,
|
||||
|
||||
currency: payload.currency,
|
||||
|
||||
creditorIban: 'GB82WEST12345698765432',
|
||||
|
||||
beneficiaryName: 'DBIS Exchange settlement audit',
|
||||
|
||||
remittanceInfo: payload.remittanceInfo,
|
||||
|
||||
convertToCrypto: {
|
||||
|
||||
enabled: true,
|
||||
|
||||
targetChainId: 138,
|
||||
|
||||
tokenLineId: lineId,
|
||||
|
||||
recipientAddress: payload.recipientAddress,
|
||||
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
{ headers, timeout: 120000, validateStatus: () => true },
|
||||
|
||||
);
|
||||
|
||||
return data;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function requestM2TokenLoad(payload: {
|
||||
|
||||
idempotencyKey: string;
|
||||
|
||||
lineId: string;
|
||||
|
||||
symbol: string;
|
||||
|
||||
tokenAddress: string;
|
||||
|
||||
amount: string;
|
||||
|
||||
recipientAddress: string;
|
||||
|
||||
currency?: string;
|
||||
|
||||
apiKey?: string;
|
||||
|
||||
}) {
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (payload.apiKey) headers.Authorization = `Bearer ${payload.apiKey}`;
|
||||
|
||||
const { data } = await axios.post(
|
||||
|
||||
`${settlementUrl()}/api/v1/settlement/token-load`,
|
||||
|
||||
{
|
||||
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
|
||||
lineId: payload.lineId,
|
||||
|
||||
symbol: payload.symbol,
|
||||
|
||||
tokenAddress: payload.tokenAddress,
|
||||
|
||||
amount: payload.amount,
|
||||
|
||||
recipientAddress: payload.recipientAddress,
|
||||
|
||||
currency: payload.currency,
|
||||
|
||||
},
|
||||
|
||||
{ headers, timeout: 120000, validateStatus: () => true },
|
||||
|
||||
);
|
||||
76
services/dbis-exchange/src/adapters/token-aggregation.ts
Normal file
76
services/dbis-exchange/src/adapters/token-aggregation.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import axios from 'axios';
|
||||
import { tokenAggUrl } from '../config';
|
||||
|
||||
export async function fetchQuote(params: {
|
||||
chainId: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountIn: string;
|
||||
}) {
|
||||
const base = tokenAggUrl();
|
||||
const q = new URLSearchParams({
|
||||
chainId: String(params.chainId),
|
||||
tokenIn: params.tokenIn,
|
||||
tokenOut: params.tokenOut,
|
||||
amountIn: params.amountIn,
|
||||
});
|
||||
const { data } = await axios.get(`${base}/api/v1/quote?${q}`, { timeout: 60000 });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRoutePlan(body: {
|
||||
sourceChainId: number;
|
||||
destinationChainId: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountIn: string;
|
||||
recipient?: string;
|
||||
maxSlippageBps?: number;
|
||||
}) {
|
||||
const { data } = await axios.post(
|
||||
`${tokenAggUrl()}/api/v2/routes/plan`,
|
||||
{
|
||||
sourceChainId: body.sourceChainId,
|
||||
destinationChainId: body.destinationChainId,
|
||||
tokenIn: body.tokenIn,
|
||||
tokenOut: body.tokenOut,
|
||||
amountIn: body.amountIn,
|
||||
recipient: body.recipient,
|
||||
constraints: { maxSlippageBps: body.maxSlippageBps ?? 50, allowBridge: false },
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchExecutionPlan(body: {
|
||||
sourceChainId: number;
|
||||
destinationChainId: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountIn: string;
|
||||
recipient: string;
|
||||
maxSlippageBps?: number;
|
||||
}) {
|
||||
const { data } = await axios.post(
|
||||
`${tokenAggUrl()}/api/v2/routes/internal-execution-plan`,
|
||||
{
|
||||
sourceChainId: body.sourceChainId,
|
||||
destinationChainId: body.destinationChainId,
|
||||
tokenIn: body.tokenIn,
|
||||
tokenOut: body.tokenOut,
|
||||
amountIn: body.amountIn,
|
||||
recipient: body.recipient,
|
||||
constraints: { maxSlippageBps: body.maxSlippageBps ?? 50, allowBridge: false },
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchProviderCapabilities(chainId: number) {
|
||||
const { data } = await axios.get(`${tokenAggUrl()}/api/v2/providers/capabilities?chainId=${chainId}`, {
|
||||
timeout: 30000,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
322
services/dbis-exchange/src/api/routes/exchange.ts
Normal file
322
services/dbis-exchange/src/api/routes/exchange.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import {
|
||||
loadExchangeConfig,
|
||||
loadExchangeTokens,
|
||||
loadM2TokenRegistry,
|
||||
exchangePort,
|
||||
tokenAggUrl,
|
||||
settlementUrl,
|
||||
} from '../../config';
|
||||
import {
|
||||
auditSwapToSettlement,
|
||||
fetchCryptoLedger,
|
||||
fetchFundLedger,
|
||||
fetchOfficeProfile,
|
||||
requestExternalTransfer,
|
||||
requestInternalTransfer,
|
||||
requestM2TokenLoad,
|
||||
resolveTokenLine,
|
||||
} from '../../adapters/settlement';
|
||||
import {
|
||||
fetchQuote,
|
||||
fetchRoutePlan,
|
||||
fetchExecutionPlan,
|
||||
fetchProviderCapabilities,
|
||||
} from '../../adapters/token-aggregation';
|
||||
import { listSwaps, recordSwap, swapStats, updateSwap } from '../../store/swap-monitor';
|
||||
|
||||
function apiKey(req: Request): string | undefined {
|
||||
return req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key') || undefined;
|
||||
}
|
||||
|
||||
function requireKey(req: Request, res: Response): boolean {
|
||||
const cfg = loadExchangeConfig();
|
||||
if (!cfg.production.requireApiKey) return true;
|
||||
const key = apiKey(req);
|
||||
if (key && key === process.env.OMNL_API_KEY) return true;
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createExchangeRouter(): Router {
|
||||
const router = Router();
|
||||
const cfg = loadExchangeConfig();
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
service: 'dbis-exchange',
|
||||
brand: cfg.brandName,
|
||||
chainId: cfg.chainId,
|
||||
officeId: cfg.officeId,
|
||||
settlementOffice: cfg.settlementOfficeExternalId,
|
||||
tokenAggregation: tokenAggUrl(),
|
||||
settlementMiddleware: settlementUrl(),
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/tokens', (_req, res) => {
|
||||
const tokens = loadExchangeTokens();
|
||||
res.json({
|
||||
chainId: cfg.chainId,
|
||||
tokens,
|
||||
count: tokens.length,
|
||||
moneyLayer: 'M2',
|
||||
routers: cfg.routers,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/tokens/capabilities', (_req, res) => {
|
||||
const registry = loadM2TokenRegistry();
|
||||
res.json(registry);
|
||||
});
|
||||
|
||||
router.get('/providers', async (_req, res) => {
|
||||
try {
|
||||
res.json(await fetchProviderCapabilities(cfg.chainId));
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/quote', async (req, res) => {
|
||||
try {
|
||||
const tokenIn = String(req.query.tokenIn || '');
|
||||
const tokenOut = String(req.query.tokenOut || '');
|
||||
const amountIn = String(req.query.amountIn || '');
|
||||
if (!tokenIn || !tokenOut || !amountIn) {
|
||||
res.status(400).json({ error: 'tokenIn, tokenOut, amountIn required' });
|
||||
return;
|
||||
}
|
||||
const quote = await fetchQuote({
|
||||
chainId: cfg.chainId,
|
||||
tokenIn,
|
||||
tokenOut,
|
||||
amountIn,
|
||||
});
|
||||
if (cfg.production.monitorSwaps) {
|
||||
recordSwap({
|
||||
officeId: cfg.officeId,
|
||||
chainId: cfg.chainId,
|
||||
tokenIn,
|
||||
tokenOut,
|
||||
amountIn,
|
||||
amountOut: quote.amountOut ?? quote.expectedOutput,
|
||||
status: 'QUOTED',
|
||||
});
|
||||
}
|
||||
res.json(quote);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/plan', async (req, res) => {
|
||||
try {
|
||||
const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body as Record<string, unknown>;
|
||||
if (!tokenIn || !tokenOut || !amountIn) {
|
||||
res.status(400).json({ error: 'tokenIn, tokenOut, amountIn required' });
|
||||
return;
|
||||
}
|
||||
const plan = await fetchRoutePlan({
|
||||
sourceChainId: cfg.chainId,
|
||||
destinationChainId: cfg.chainId,
|
||||
tokenIn: String(tokenIn),
|
||||
tokenOut: String(tokenOut),
|
||||
amountIn: String(amountIn),
|
||||
recipient: recipient ? String(recipient) : undefined,
|
||||
maxSlippageBps: maxSlippageBps ? Number(maxSlippageBps) : cfg.defaultSlippageBps,
|
||||
});
|
||||
res.json(plan);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/execute-plan', async (req, res) => {
|
||||
try {
|
||||
const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body as Record<string, unknown>;
|
||||
if (!tokenIn || !tokenOut || !amountIn || !recipient) {
|
||||
res.status(400).json({ error: 'tokenIn, tokenOut, amountIn, recipient required' });
|
||||
return;
|
||||
}
|
||||
const plan = await fetchExecutionPlan({
|
||||
sourceChainId: cfg.chainId,
|
||||
destinationChainId: cfg.chainId,
|
||||
tokenIn: String(tokenIn),
|
||||
tokenOut: String(tokenOut),
|
||||
amountIn: String(amountIn),
|
||||
recipient: String(recipient),
|
||||
maxSlippageBps: maxSlippageBps ? Number(maxSlippageBps) : cfg.defaultSlippageBps,
|
||||
});
|
||||
res.json(plan);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/ledgers', async (req, res) => {
|
||||
try {
|
||||
const wallet = req.query.wallet ? String(req.query.wallet) : undefined;
|
||||
const serverKey = process.env.OMNL_API_KEY;
|
||||
const rpc =
|
||||
process.env.RPC_URL_138_PUBLIC ||
|
||||
process.env.RPC_URL_138 ||
|
||||
'https://rpc-http-pub.d-bis.org';
|
||||
let fund = null;
|
||||
let office = null;
|
||||
if (serverKey) {
|
||||
[fund, office] = await Promise.all([
|
||||
fetchFundLedger(cfg.officeId, serverKey),
|
||||
fetchOfficeProfile(),
|
||||
]);
|
||||
}
|
||||
let crypto;
|
||||
if (wallet) {
|
||||
crypto = await fetchCryptoLedger(rpc, wallet, loadExchangeTokens());
|
||||
}
|
||||
res.json({
|
||||
officeId: cfg.officeId,
|
||||
office,
|
||||
fundLedger: fund?.fundLedger ?? null,
|
||||
cryptoLedger: crypto ?? null,
|
||||
asOf: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/swap/monitor', (_req, res) => {
|
||||
res.json({ stats: swapStats(), swaps: listSwaps(Number(_req.query.limit ?? 50)) });
|
||||
});
|
||||
|
||||
router.post('/swap/record', (req, res) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const rec = recordSwap({
|
||||
officeId: cfg.officeId,
|
||||
chainId: cfg.chainId,
|
||||
tokenIn: String(body.tokenIn ?? ''),
|
||||
tokenOut: String(body.tokenOut ?? ''),
|
||||
amountIn: String(body.amountIn ?? ''),
|
||||
amountOut: body.amountOut ? String(body.amountOut) : undefined,
|
||||
txHash: body.txHash ? String(body.txHash) : undefined,
|
||||
wallet: body.wallet ? String(body.wallet) : undefined,
|
||||
routeProviders: Array.isArray(body.routeProviders)
|
||||
? body.routeProviders.map(String)
|
||||
: undefined,
|
||||
status: (body.status as 'SUBMITTED' | 'CONFIRMED' | 'FAILED') ?? 'SUBMITTED',
|
||||
settlementRef: body.settlementRef ? String(body.settlementRef) : undefined,
|
||||
});
|
||||
res.json(rec);
|
||||
});
|
||||
|
||||
router.patch('/swap/record/:id', (req, res) => {
|
||||
const updated = updateSwap(req.params.id, req.body as Record<string, unknown>);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post('/swap/settle', async (req, res) => {
|
||||
if (!requireKey(req, res)) return;
|
||||
if (!cfg.production.postSwapSettlementAudit) {
|
||||
res.status(403).json({ error: 'postSwapSettlementAudit disabled' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { swapId, amount, recipientAddress, currency, tokenLineId, tokenSymbol } =
|
||||
req.body as Record<string, unknown>;
|
||||
const tokens = loadExchangeTokens();
|
||||
const token = tokens.find((t) => t.symbol === tokenSymbol);
|
||||
const lineId = tokenLineId
|
||||
? String(tokenLineId)
|
||||
: token
|
||||
? resolveTokenLine(token)
|
||||
: `${String(currency ?? 'USD')}-M2`;
|
||||
const audit = await auditSwapToSettlement({
|
||||
idempotencyKey: `SWAP-${String(swapId ?? Date.now())}`,
|
||||
amount: String(amount ?? '0'),
|
||||
currency: String(currency ?? 'USD'),
|
||||
recipientAddress: String(recipientAddress ?? ''),
|
||||
remittanceInfo: `DBIS Exchange M2 swap settlement Office ${cfg.officeId}`,
|
||||
tokenLineId: lineId,
|
||||
apiKey: apiKey(req),
|
||||
});
|
||||
if (swapId) updateSwap(String(swapId), { settlementRef: audit.settlementId, status: 'CONFIRMED' });
|
||||
res.json(audit);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/token-load', async (req, res) => {
|
||||
if (!requireKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const tokens = loadExchangeTokens();
|
||||
const addr = String(body.tokenAddress ?? '');
|
||||
const token = tokens.find((t) => t.address.toLowerCase() === addr.toLowerCase());
|
||||
const result = await requestM2TokenLoad({
|
||||
idempotencyKey: String(body.idempotencyKey ?? `LOAD-${Date.now()}`),
|
||||
lineId: String(body.lineId ?? token?.omnlLine ?? 'USD-M2'),
|
||||
symbol: String(body.symbol ?? token?.symbol ?? ''),
|
||||
tokenAddress: addr,
|
||||
amount: String(body.amount ?? '0'),
|
||||
recipientAddress: String(body.recipientAddress ?? ''),
|
||||
currency: String(body.currency ?? token?.currencyCode ?? 'USD'),
|
||||
apiKey: apiKey(req) ?? process.env.OMNL_API_KEY,
|
||||
});
|
||||
res.status(result.phase === 'FAILED' ? 422 : 200).json(result);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/transfer/internal', async (req, res) => {
|
||||
if (!requireKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const result = await requestInternalTransfer({
|
||||
idempotencyKey: String(body.idempotencyKey ?? `XFER-INT-${Date.now()}`),
|
||||
tokenAddress: String(body.tokenAddress ?? ''),
|
||||
tokenSymbol: String(body.tokenSymbol ?? ''),
|
||||
amount: String(body.amount ?? '0'),
|
||||
recipientAddress: String(body.recipientAddress ?? ''),
|
||||
senderAddress: body.senderAddress ? String(body.senderAddress) : undefined,
|
||||
apiKey: apiKey(req) ?? process.env.OMNL_API_KEY,
|
||||
});
|
||||
res.status(result.phase === 'FAILED' ? 422 : 200).json(result);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/transfer/external', async (req, res) => {
|
||||
if (!requireKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const result = await requestExternalTransfer({
|
||||
idempotencyKey: String(body.idempotencyKey ?? `XFER-EXT-${Date.now()}`),
|
||||
tokenAddress: String(body.tokenAddress ?? ''),
|
||||
tokenSymbol: String(body.tokenSymbol ?? ''),
|
||||
amount: String(body.amount ?? '0'),
|
||||
recipientAddress: String(body.recipientAddress ?? ''),
|
||||
creditorIban: String(body.creditorIban ?? ''),
|
||||
beneficiaryName: body.beneficiaryName ? String(body.beneficiaryName) : undefined,
|
||||
currency: body.currency ? String(body.currency) : undefined,
|
||||
remittanceInfo: body.remittanceInfo ? String(body.remittanceInfo) : undefined,
|
||||
apiKey: apiKey(req) ?? process.env.OMNL_API_KEY,
|
||||
});
|
||||
res.status(result.phase === 'FAILED' ? 422 : 200).json(result);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export { exchangePort };
|
||||
75
services/dbis-exchange/src/config.ts
Normal file
75
services/dbis-exchange/src/config.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
|
||||
mergeExchangeTokensWithM2Registry,
|
||||
|
||||
type M2TokenCapabilities,
|
||||
|
||||
type M2TokenRegistryFile,
|
||||
|
||||
} from '@dbis/settlement-core';
|
||||
|
||||
|
||||
|
||||
export type ExchangeConfig = {
|
||||
|
||||
version: string;
|
||||
|
||||
chainId: number;
|
||||
|
||||
brandName: string;
|
||||
|
||||
officeId: number;
|
||||
|
||||
settlementOfficeExternalId: string;
|
||||
|
||||
routers: Record<string, string>;
|
||||
|
||||
factory: Record<string, string>;
|
||||
|
||||
tokens: {
|
||||
|
||||
symbol: string;
|
||||
|
||||
name: string;
|
||||
|
||||
address: string;
|
||||
|
||||
decimals: number;
|
||||
|
||||
native?: boolean;
|
||||
|
||||
omnlLine?: string;
|
||||
|
||||
moneyLayer?: string;
|
||||
|
||||
swappable?: boolean;
|
||||
|
||||
convertible?: boolean;
|
||||
|
||||
transferableInternal?: boolean;
|
||||
|
||||
transferableExternal?: boolean;
|
||||
|
||||
}[];
|
||||
|
||||
defaultSlippageBps: number;
|
||||
|
||||
production: {
|
||||
|
||||
requireApiKey: boolean;
|
||||
|
||||
monitorSwaps: boolean;
|
||||
|
||||
postSwapSettlementAudit: boolean;
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
let cached: ExchangeConfig | null = null;
|
||||
10
services/dbis-exchange/src/index.ts
Normal file
10
services/dbis-exchange/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { startServer } from './server';
|
||||
|
||||
const rootEnv = path.resolve(__dirname, '../../../.env');
|
||||
if (existsSync(rootEnv)) dotenv.config({ path: rootEnv });
|
||||
dotenv.config();
|
||||
|
||||
startServer();
|
||||
14
services/dbis-exchange/src/server.ts
Normal file
14
services/dbis-exchange/src/server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { createExchangeRouter, exchangePort } from './api/routes/exchange';
|
||||
|
||||
export function startServer(): void {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/api/v1/exchange', createExchangeRouter());
|
||||
const p = exchangePort();
|
||||
app.listen(p, () => {
|
||||
console.log(`DBIS Exchange (Pancake-style) on http://localhost:${p}/api/v1/exchange`);
|
||||
});
|
||||
}
|
||||
65
services/dbis-exchange/src/store/swap-monitor.ts
Normal file
65
services/dbis-exchange/src/store/swap-monitor.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export type SwapMonitorRecord = {
|
||||
id: string;
|
||||
officeId: number;
|
||||
chainId: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountIn: string;
|
||||
amountOut?: string;
|
||||
txHash?: string;
|
||||
wallet?: string;
|
||||
routeProviders?: string[];
|
||||
status: 'QUOTED' | 'SUBMITTED' | 'CONFIRMED' | 'FAILED';
|
||||
settlementRef?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const base =
|
||||
process.env.DBIS_SWAP_MONITOR_DIR ||
|
||||
path.resolve(__dirname, '../../../data/dbis-exchange/swap-monitor');
|
||||
|
||||
function dir(): string {
|
||||
if (!fs.existsSync(base)) fs.mkdirSync(base, { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
export function recordSwap(entry: Omit<SwapMonitorRecord, 'id' | 'createdAt' | 'updatedAt'>): SwapMonitorRecord {
|
||||
const now = new Date().toISOString();
|
||||
const rec: SwapMonitorRecord = { id: uuidv4(), createdAt: now, updatedAt: now, ...entry };
|
||||
fs.writeFileSync(path.join(dir(), `${rec.id}.json`), JSON.stringify(rec, null, 2));
|
||||
return rec;
|
||||
}
|
||||
|
||||
export function updateSwap(id: string, patch: Partial<SwapMonitorRecord>): SwapMonitorRecord | null {
|
||||
const p = path.join(dir(), `${id}.json`);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const rec = JSON.parse(fs.readFileSync(p, 'utf8')) as SwapMonitorRecord;
|
||||
const updated = { ...rec, ...patch, updatedAt: new Date().toISOString() };
|
||||
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function listSwaps(limit = 100): SwapMonitorRecord[] {
|
||||
return fs
|
||||
.readdirSync(dir())
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs.readFileSync(path.join(dir(), f), 'utf8')) as SwapMonitorRecord)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function swapStats(): { total: number; confirmed: number; failed: number; last24h: number } {
|
||||
const all = listSwaps(5000);
|
||||
const dayAgo = Date.now() - 86400000;
|
||||
return {
|
||||
total: all.length,
|
||||
confirmed: all.filter((s) => s.status === 'CONFIRMED').length,
|
||||
failed: all.filter((s) => s.status === 'FAILED').length,
|
||||
last24h: all.filter((s) => new Date(s.createdAt).getTime() > dayAgo).length,
|
||||
};
|
||||
}
|
||||
13
services/dbis-exchange/tsconfig.json
Normal file
13
services/dbis-exchange/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
19
services/settlement-middleware/Dockerfile
Normal file
19
services/settlement-middleware/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
RUN npm ci
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
USER nodejs
|
||||
EXPOSE 3011
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3011/api/v1/settlement/health',r=>process.exit(r.statusCode===200?0:1))"
|
||||
CMD ["node", "dist/index.js"]
|
||||
52
services/settlement-middleware/dist/adapters/fineract.js
vendored
Normal file
52
services/settlement-middleware/dist/adapters/fineract.js
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.postJournalEntry = postJournalEntry;
|
||||
exports.fetchGlBalances = fetchGlBalances;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
function authHeaders() {
|
||||
const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
|
||||
const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl';
|
||||
const user = process.env.OMNL_FINERACT_USER?.trim() ||
|
||||
process.env.OMNL_FINERACT_USERNAME?.trim() ||
|
||||
'ali_hospitallers_tenant';
|
||||
const pass = process.env.OMNL_FINERACT_PASSWORD || '';
|
||||
if (!base || !pass)
|
||||
throw new Error('Fineract credentials required');
|
||||
const auth = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
return {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Fineract-Platform-TenantId': tenant,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
async function postJournalEntry(entry) {
|
||||
const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
|
||||
const { data } = await axios_1.default.post(`${base}/journalentries`, entry, {
|
||||
headers: authHeaders(),
|
||||
timeout: 60000,
|
||||
});
|
||||
return { resourceId: data.resourceId ?? data.entityId ?? 0 };
|
||||
}
|
||||
async function fetchGlBalances(officeId) {
|
||||
const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
|
||||
try {
|
||||
const { data } = await axios_1.default.get(`${base}/runreports/General Ledger Report`, {
|
||||
headers: authHeaders(),
|
||||
params: { R_officeId: officeId, genericResultSet: false },
|
||||
timeout: 60000,
|
||||
});
|
||||
const out = {};
|
||||
const rows = Array.isArray(data) ? data : data?.data ?? [];
|
||||
for (const row of rows) {
|
||||
if (row.glCode)
|
||||
out[row.glCode] = String(row.balance ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
51
services/settlement-middleware/dist/adapters/hybx-production.js
vendored
Normal file
51
services/settlement-middleware/dist/adapters/hybx-production.js
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.HybxProductionRail = void 0;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const integration_foundation_1 = require("@dbis/integration-foundation");
|
||||
/** Production HYBX rail client for real settlement dispatch */
|
||||
class HybxProductionRail {
|
||||
baseUrl;
|
||||
apiKey;
|
||||
clientSecret;
|
||||
constructor() {
|
||||
const cfg = (0, integration_foundation_1.loadHybxConfig)({ testMode: false });
|
||||
if (cfg.environment === 'production' && process.env.SETTLEMENT_ALLOW_HYBX_PRODUCTION !== '1') {
|
||||
throw new Error('SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 required for live HYBX rails');
|
||||
}
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = cfg.apiKey;
|
||||
this.clientSecret = cfg.clientSecret;
|
||||
}
|
||||
async dispatchPayment(payload) {
|
||||
const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.replace(/\/$/, '');
|
||||
const url = sidecar ? `${sidecar}/v1/rtgs/payments` : `${this.baseUrl}/v1/payments`;
|
||||
const { data } = await axios_1.default.post(url, {
|
||||
externalId: payload.idempotencyKey,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
beneficiary: {
|
||||
iban: payload.creditorIban,
|
||||
bic: payload.creditorBic,
|
||||
name: payload.beneficiaryName,
|
||||
},
|
||||
remittanceInformation: payload.remittanceInfo,
|
||||
rail: payload.rail,
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiKey,
|
||||
Authorization: `Bearer ${this.clientSecret}`,
|
||||
},
|
||||
timeout: 120000,
|
||||
});
|
||||
return {
|
||||
paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey),
|
||||
status: String(data.status ?? 'DISPATCHED'),
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.HybxProductionRail = HybxProductionRail;
|
||||
44
services/settlement-middleware/dist/adapters/omnl.js
vendored
Normal file
44
services/settlement-middleware/dist/adapters/omnl.js
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.archiveIso20022 = archiveIso20022;
|
||||
exports.requestTokenMint = requestTokenMint;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
async function archiveIso20022(payload) {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (key)
|
||||
headers.Authorization = `Bearer ${key}`;
|
||||
const { data } = await axios_1.default.post(`${base}/api/v1/omnl/iso20022/messages`, {
|
||||
messageType: payload.messageType,
|
||||
direction: payload.direction,
|
||||
payload: payload.raw,
|
||||
metadata: { settlementRef: payload.settlementRef },
|
||||
}, { headers, timeout: 60000 });
|
||||
return { messageId: String(data.messageId ?? data.id ?? payload.settlementRef) };
|
||||
}
|
||||
async function requestTokenMint(payload) {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (key)
|
||||
headers.Authorization = `Bearer ${key}`;
|
||||
try {
|
||||
const { data } = await axios_1.default.post(`${base}/api/v1/omnl/settlement/token-load`, payload, { headers, timeout: 120000 });
|
||||
return {
|
||||
txHash: data.txHash,
|
||||
status: String(data.status ?? (payload.dryRun ? 'DRY_RUN' : 'SUBMITTED')),
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
if (axios_1.default.isAxiosError(err) && err.response?.status === 404) {
|
||||
return {
|
||||
status: payload.dryRun ? 'DRY_RUN' : 'PENDING_CHAIN_ENDPOINT',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
42
services/settlement-middleware/dist/adapters/swift.js
vendored
Normal file
42
services/settlement-middleware/dist/adapters/swift.js
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.forwardSwiftToListener = forwardSwiftToListener;
|
||||
exports.buildMt103Stub = buildMt103Stub;
|
||||
exports.buildMt102Stub = buildMt102Stub;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
async function forwardSwiftToListener(raw) {
|
||||
const url = (process.env.SWIFT_LISTENER_URL || 'http://192.168.11.114:8788').replace(/\/$/, '');
|
||||
const token = process.env.SWIFT_LISTENER_WEBHOOK_TOKEN || '';
|
||||
const headers = { 'Content-Type': 'text/plain' };
|
||||
if (token)
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
await axios_1.default.post(`${url}/inbound`, raw, { headers, timeout: 60000 });
|
||||
return { accepted: true };
|
||||
}
|
||||
function buildMt103Stub(fields) {
|
||||
const bicLine = fields.bic ? `:57A:${fields.bic}\n` : '';
|
||||
return `{1:F01OMNLUS33XXXX0000000000}{2:I103BANKUS33XXXXN}{4:
|
||||
:20:${fields.senderRef}
|
||||
:23B:CRED
|
||||
:32A:${fields.valueDate.replace(/-/g, '').slice(2)}${fields.currency}${fields.amount.replace('.', ',')}
|
||||
:50K:${fields.orderingCustomer}
|
||||
:59:/${fields.iban}
|
||||
${fields.beneficiary}
|
||||
${bicLine}:70:${fields.remittance}
|
||||
:71A:OUR
|
||||
-}`;
|
||||
}
|
||||
function buildMt102Stub(fields) {
|
||||
return `{1:F01OMNLUS33XXXX0000000000}{2:I102BANKUS33XXXXN}{4:
|
||||
:20:${fields.senderRef}
|
||||
:21:NONREF
|
||||
:32B:${fields.currency}${fields.amount.replace('.', ',')}
|
||||
:50A:${fields.orderingInstitution}
|
||||
:59:/${fields.iban}
|
||||
${fields.beneficiary}
|
||||
:70:${fields.remittance}
|
||||
-}`;
|
||||
}
|
||||
276
services/settlement-middleware/dist/api/routes/settlement.js
vendored
Normal file
276
services/settlement-middleware/dist/api/routes/settlement.js
vendored
Normal file
@@ -0,0 +1,276 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createSettlementRouter = createSettlementRouter;
|
||||
const express_1 = require("express");
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const config_1 = require("../../config");
|
||||
const external_transfer_1 = require("../../workflows/external-transfer");
|
||||
const token_load_1 = require("../../workflows/token-load");
|
||||
const transfer_1 = require("../../workflows/transfer");
|
||||
const office_scope_1 = require("../../workflows/office-scope");
|
||||
const settlement_store_1 = require("../../store/settlement-store");
|
||||
const swift_inbound_1 = require("../../workflows/swift-inbound");
|
||||
function requireApiKey(req, res) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
if (!cfg.production.requireApiKey)
|
||||
return true;
|
||||
const key = req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key');
|
||||
if (key && key === process.env.OMNL_API_KEY)
|
||||
return true;
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return false;
|
||||
}
|
||||
function createSettlementRouter() {
|
||||
const router = (0, express_1.Router)();
|
||||
const officeId = (0, config_1.settlementOfficeId)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
router.get('/health', (_req, res) => {
|
||||
let chainStats = { total: 0, active: 0 };
|
||||
try {
|
||||
const reg = (0, settlement_core_1.loadOmnlChainRegistry)();
|
||||
chainStats = { total: reg.chains.length, active: (0, settlement_core_1.getActiveChains)().length };
|
||||
}
|
||||
catch {
|
||||
/* optional */
|
||||
}
|
||||
res.json({
|
||||
service: 'omnl-settlement-middleware',
|
||||
status: 'ok',
|
||||
mode: process.env.SETTLEMENT_MIDDLEWARE_CONFIG?.includes('production') ? 'production' : 'standard',
|
||||
role: 'OMNL central-bank settlement orchestration',
|
||||
chainSupport: { max: 128, ...chainStats },
|
||||
settlementOffice: {
|
||||
officeId: profile.officeId,
|
||||
externalId: profile.externalId,
|
||||
name: profile.name,
|
||||
},
|
||||
enforceSingleOffice: (0, config_1.loadConfig)().enforceSingleOffice ?? false,
|
||||
});
|
||||
});
|
||||
router.get('/office', (_req, res) => {
|
||||
res.json({
|
||||
...profile,
|
||||
locked: true,
|
||||
storePath: `data/settlement-store/office-${officeId}`,
|
||||
});
|
||||
});
|
||||
router.get('/money-supply', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
try {
|
||||
res.json(await (0, external_transfer_1.getMoneySupply)(officeId));
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/money-supply/:officeIdParam', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const requested = parseInt(req.params.officeIdParam, 10);
|
||||
res.json(await (0, external_transfer_1.getMoneySupply)((0, config_1.settlementOfficeId)(requested)));
|
||||
}
|
||||
catch (e) {
|
||||
res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/settlements', (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
res.json({
|
||||
officeId,
|
||||
items: settlement_store_1.settlementStore.list(Number(req.query.limit ?? 50)),
|
||||
});
|
||||
});
|
||||
router.get('/settlements/:id', (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
const rec = (0, external_transfer_1.getSettlementStatus)(req.params.id);
|
||||
if (!rec) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
res.json(rec);
|
||||
});
|
||||
router.post('/external-transfer', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const merged = (0, office_scope_1.bindOffice24)(req.body);
|
||||
const record = await (0, external_transfer_1.processExternalTransfer)(merged);
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(e instanceof Error && e.message.includes('locked') ? 403 : 500).json({
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
});
|
||||
router.post('/swift/inbound', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const raw = typeof req.body === 'string' ? req.body : req.body?.raw;
|
||||
if (!raw) {
|
||||
res.status(400).json({ error: 'raw SWIFT payload required' });
|
||||
return;
|
||||
}
|
||||
const record = await (0, swift_inbound_1.processSwiftInbound)(String(raw));
|
||||
res.json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/trade-finance/:instrument', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
const instrument = req.params.instrument.toUpperCase();
|
||||
if (!['SBLC', 'DLC', 'BG', 'LS'].includes(instrument)) {
|
||||
res.status(400).json({ error: 'Invalid instrument' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = req.body;
|
||||
const merged = (0, office_scope_1.bindOffice24)({
|
||||
...body,
|
||||
tradeFinance: {
|
||||
instrument,
|
||||
reference: body.tradeFinance?.reference ?? body.idempotencyKey,
|
||||
expiryDate: body.tradeFinance?.expiryDate,
|
||||
},
|
||||
rail: 'SWIFT',
|
||||
moneyLayers: body.moneyLayers ?? ['M1', 'M2'],
|
||||
});
|
||||
const record = await (0, external_transfer_1.processExternalTransfer)(merged);
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/convert/fiat-to-crypto', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
const merged = (0, office_scope_1.bindOffice24)({
|
||||
...body,
|
||||
rail: body.rail ?? 'INTERNAL',
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: body.convertToCrypto?.targetChainId ?? 138,
|
||||
tokenLineId: body.convertToCrypto?.tokenLineId ?? `${body.currency ?? 'USD'}-M2`,
|
||||
recipientAddress: body.convertToCrypto?.recipientAddress ?? '',
|
||||
},
|
||||
moneyLayers: ['M2'],
|
||||
});
|
||||
const record = await (0, external_transfer_1.processExternalTransfer)(merged);
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/token-load', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body.idempotencyKey || !body.lineId || !body.amount || !body.recipientAddress) {
|
||||
res.status(400).json({ error: 'idempotencyKey, lineId, amount, recipientAddress required' });
|
||||
return;
|
||||
}
|
||||
const record = await (0, token_load_1.processTokenLoad)({
|
||||
...body,
|
||||
officeId: (0, config_1.settlementOfficeId)(body.officeId),
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/transfer/internal', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body.idempotencyKey || !body.tokenAddress || !body.amount || !body.recipientAddress) {
|
||||
res.status(400).json({ error: 'idempotencyKey, tokenAddress, amount, recipientAddress required' });
|
||||
return;
|
||||
}
|
||||
const record = await (0, transfer_1.processTransfer)({
|
||||
...body,
|
||||
officeId: (0, config_1.settlementOfficeId)(body.officeId),
|
||||
rail: 'INTERNAL',
|
||||
moneyLayers: body.moneyLayers ?? ['M2'],
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/transfer/external', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body.creditorIban) {
|
||||
res.status(400).json({ error: 'creditorIban required for external transfer' });
|
||||
return;
|
||||
}
|
||||
const record = await (0, transfer_1.processTransfer)({
|
||||
...body,
|
||||
officeId: (0, config_1.settlementOfficeId)(body.officeId),
|
||||
rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
|
||||
moneyLayers: body.moneyLayers ?? ['M2'],
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/tokens/m2', (_req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const p = process.env.OMNL_M2_TOKEN_REGISTRY ||
|
||||
path.resolve(__dirname, '../../../../../config/omnl-m2-token-registry.v1.json');
|
||||
const registry = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
res.json(registry);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/chains', (_req, res) => {
|
||||
try {
|
||||
const registry = (0, settlement_core_1.loadOmnlChainRegistry)();
|
||||
res.json({
|
||||
brand: registry.brand,
|
||||
maxCapacity: registry.maxCapacity,
|
||||
primaryChainId: registry.primaryChainId,
|
||||
mirrorChainId: registry.mirrorChainId,
|
||||
settlementOfficeId: registry.settlementOfficeId,
|
||||
stats: registry.stats,
|
||||
chains: registry.chains,
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/chains/active', (_req, res) => {
|
||||
try {
|
||||
res.json({ count: (0, settlement_core_1.getActiveChains)().length, chains: (0, settlement_core_1.getActiveChains)() });
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
57
services/settlement-middleware/dist/config.js
vendored
Normal file
57
services/settlement-middleware/dist/config.js
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.loadConfig = loadConfig;
|
||||
exports.loadOfficeProfile = loadOfficeProfile;
|
||||
exports.settlementOfficeId = settlementOfficeId;
|
||||
exports.port = port;
|
||||
exports.settlementStoreSubdir = settlementStoreSubdir;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
let cached = null;
|
||||
let cachedOffice = null;
|
||||
function repoConfigPath(relative) {
|
||||
return path_1.default.resolve(__dirname, '../../../', relative.replace(/^\//, ''));
|
||||
}
|
||||
function loadConfig() {
|
||||
if (cached)
|
||||
return cached;
|
||||
const configPath = process.env.SETTLEMENT_MIDDLEWARE_CONFIG ||
|
||||
repoConfigPath('config/settlement-middleware.v1.json');
|
||||
cached = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
||||
return cached;
|
||||
}
|
||||
function loadOfficeProfile() {
|
||||
if (cachedOffice)
|
||||
return cachedOffice;
|
||||
const cfg = loadConfig();
|
||||
const profileRel = cfg.settlementOffice?.profilePath ?? 'config/offices/office-24-settlement.v1.json';
|
||||
const profilePath = process.env.OMNL_OFFICE_SETTLEMENT_PROFILE || repoConfigPath(profileRel);
|
||||
cachedOffice = JSON.parse(fs_1.default.readFileSync(profilePath, 'utf8'));
|
||||
return cachedOffice;
|
||||
}
|
||||
/** All settlement runs behind Office 24 (Ali HOSPITALLERS). */
|
||||
function settlementOfficeId(requested) {
|
||||
const cfg = loadConfig();
|
||||
const profile = loadOfficeProfile();
|
||||
const locked = cfg.settlementOffice?.officeId ?? profile.officeId ?? cfg.defaultOfficeId;
|
||||
const fromEnv = parseInt(process.env.ALI_HOSPITALLERS_OFFICE_ID ||
|
||||
process.env.OMNL_SETTLEMENT_OFFICE_ID ||
|
||||
String(locked), 10);
|
||||
const officeId = fromEnv || locked;
|
||||
if (cfg.enforceSingleOffice && requested !== undefined && requested !== officeId) {
|
||||
throw new Error(`Settlement is locked to office ${officeId}; requested ${requested}`);
|
||||
}
|
||||
if (cfg.enforceSingleOffice && officeId !== locked) {
|
||||
throw new Error(`Settlement is locked to office ${locked}; env specifies ${officeId}`);
|
||||
}
|
||||
return officeId;
|
||||
}
|
||||
function port() {
|
||||
return parseInt(process.env.SETTLEMENT_MIDDLEWARE_PORT || '3011', 10);
|
||||
}
|
||||
function settlementStoreSubdir() {
|
||||
return `office-${settlementOfficeId()}`;
|
||||
}
|
||||
47
services/settlement-middleware/dist/index.js
vendored
Normal file
47
services/settlement-middleware/dist/index.js
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const dotenv = __importStar(require("dotenv"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = require("fs");
|
||||
const server_1 = require("./server");
|
||||
const rootEnv = path_1.default.resolve(__dirname, '../../../.env');
|
||||
if ((0, fs_1.existsSync)(rootEnv))
|
||||
dotenv.config({ path: rootEnv });
|
||||
dotenv.config();
|
||||
(0, server_1.startServer)();
|
||||
26
services/settlement-middleware/dist/server.js
vendored
Normal file
26
services/settlement-middleware/dist/server.js
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createApp = createApp;
|
||||
exports.startServer = startServer;
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const settlement_1 = require("./api/routes/settlement");
|
||||
const config_1 = require("./config");
|
||||
function createApp() {
|
||||
const app = (0, express_1.default)();
|
||||
app.use((0, cors_1.default)());
|
||||
app.use(express_1.default.json({ limit: '2mb' }));
|
||||
app.use(express_1.default.text({ type: 'text/plain', limit: '2mb' }));
|
||||
app.use('/api/v1/settlement', (0, settlement_1.createSettlementRouter)());
|
||||
return app;
|
||||
}
|
||||
function startServer() {
|
||||
const app = createApp();
|
||||
const p = (0, config_1.port)();
|
||||
app.listen(p, () => {
|
||||
console.log(`OMNL settlement middleware listening on http://localhost:${p}/api/v1/settlement`);
|
||||
});
|
||||
}
|
||||
47
services/settlement-middleware/dist/store/settlement-store.js
vendored
Normal file
47
services/settlement-middleware/dist/store/settlement-store.js
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.settlementStore = exports.SettlementStore = void 0;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const config_1 = require("../config");
|
||||
const baseDir = process.env.SETTLEMENT_STORE_DIR ||
|
||||
path_1.default.resolve(__dirname, '../../../data/settlement-store');
|
||||
function storeDir() {
|
||||
const dir = path_1.default.join(baseDir, (0, config_1.settlementStoreSubdir)());
|
||||
if (!fs_1.default.existsSync(dir))
|
||||
fs_1.default.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
class SettlementStore {
|
||||
save(record) {
|
||||
const dir = storeDir();
|
||||
fs_1.default.writeFileSync(path_1.default.join(dir, `${record.settlementId}.json`), JSON.stringify(record, null, 2));
|
||||
fs_1.default.writeFileSync(path_1.default.join(dir, `key-${record.idempotencyKey}.json`), record.settlementId);
|
||||
}
|
||||
getById(id) {
|
||||
const p = path_1.default.join(storeDir(), `${id}.json`);
|
||||
if (!fs_1.default.existsSync(p))
|
||||
return null;
|
||||
return JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
|
||||
}
|
||||
getByIdempotencyKey(key) {
|
||||
const ref = path_1.default.join(storeDir(), `key-${key}.json`);
|
||||
if (!fs_1.default.existsSync(ref))
|
||||
return null;
|
||||
const id = fs_1.default.readFileSync(ref, 'utf8').trim();
|
||||
return this.getById(id);
|
||||
}
|
||||
list(limit = 50) {
|
||||
const dir = storeDir();
|
||||
return fs_1.default
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.json') && !f.startsWith('key-'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, f), 'utf8')));
|
||||
}
|
||||
}
|
||||
exports.SettlementStore = SettlementStore;
|
||||
exports.settlementStore = new SettlementStore();
|
||||
150
services/settlement-middleware/dist/workflows/external-transfer.js
vendored
Normal file
150
services/settlement-middleware/dist/workflows/external-transfer.js
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.processExternalTransfer = processExternalTransfer;
|
||||
exports.getMoneySupply = getMoneySupply;
|
||||
exports.getSettlementStatus = getSettlementStatus;
|
||||
const uuid_1 = require("uuid");
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const config_1 = require("../config");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const hybx_production_1 = require("../adapters/hybx-production");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const swift_1 = require("../adapters/swift");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
function glId(code) {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
async function processExternalTransfer(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED')
|
||||
return existing;
|
||||
const now = new Date().toISOString();
|
||||
let record = existing ?? {
|
||||
settlementId: (0, uuid_1.v4)(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: req,
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const advance = (phase, patch = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlement_store_1.settlementStore.save(record);
|
||||
};
|
||||
try {
|
||||
if (!(0, settlement_core_1.isIbanValid)(req.creditorIban)) {
|
||||
throw new Error(`Invalid creditor IBAN: ${req.creditorIban}`);
|
||||
}
|
||||
advance('VALIDATED');
|
||||
const verbiage = (0, settlement_core_1.rollExternalTransferVerbiage)(req);
|
||||
advance('VERBIAGE_ROLLED', { verbiageDocument: verbiage });
|
||||
const amount = parseFloat(req.amount) || 0;
|
||||
const tf = req.tradeFinance;
|
||||
const gl = tf ? (0, settlement_core_1.resolveInstrumentGl)(tf.instrument) : null;
|
||||
const debitGl = gl?.debitGl ?? profile.settlement.glSettlement ?? cfg.moneySupply.glSettlement;
|
||||
const creditGl = gl?.creditGl ?? profile.settlement.glM1 ?? cfg.moneySupply.glM1;
|
||||
if (amount > 0) {
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId: req.officeId,
|
||||
transactionDate: req.valueDate,
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: verbiage.slice(0, 500),
|
||||
debits: [{ glAccountId: glId(debitGl), amount }],
|
||||
credits: [{ glAccountId: glId(creditGl), amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
}
|
||||
else {
|
||||
advance('FINERACT_POSTED');
|
||||
}
|
||||
if (cfg.rails.hybx.enabled && req.rail !== 'INTERNAL') {
|
||||
const hybx = new hybx_production_1.HybxProductionRail();
|
||||
const pay = await hybx.dispatchPayment({
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
amount: req.amount,
|
||||
currency: req.currency,
|
||||
creditorIban: req.creditorIban,
|
||||
creditorBic: req.creditorBic,
|
||||
beneficiaryName: req.beneficiaryName,
|
||||
remittanceInfo: req.remittanceInfo,
|
||||
rail: req.rail,
|
||||
});
|
||||
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
|
||||
}
|
||||
else {
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
}
|
||||
const swiftKind = (0, settlement_core_1.swiftKindForRequest)(req);
|
||||
const swiftRaw = swiftKind === 'MT102'
|
||||
? (0, swift_1.buildMt102Stub)({
|
||||
senderRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
orderingInstitution: req.orderingName ?? cfg.omnlLegalName,
|
||||
beneficiary: req.beneficiaryName,
|
||||
iban: req.creditorIban,
|
||||
remittance: req.remittanceInfo,
|
||||
})
|
||||
: (0, swift_1.buildMt103Stub)({
|
||||
senderRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
orderingCustomer: req.orderingName ?? cfg.omnlLegalName,
|
||||
beneficiary: req.beneficiaryName,
|
||||
iban: req.creditorIban,
|
||||
bic: req.creditorBic,
|
||||
remittance: req.remittanceInfo,
|
||||
});
|
||||
if (cfg.rails.swift.enabled) {
|
||||
await (0, swift_1.forwardSwiftToListener)(swiftRaw);
|
||||
}
|
||||
const iso = await (0, omnl_1.archiveIso20022)({
|
||||
messageType: swiftKind === 'MT102' ? 'pacs.008' : 'pacs.008',
|
||||
direction: 'OUTBOUND',
|
||||
raw: swiftRaw,
|
||||
settlementRef: record.settlementId,
|
||||
});
|
||||
advance('ISO20022_ARCHIVED', { iso20022MessageId: iso.messageId });
|
||||
if (req.convertToCrypto?.enabled) {
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(req.officeId);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(req.officeId, balances);
|
||||
const { loadAmount, fullyBacked } = (0, settlement_core_1.m2FiatToTokenLoadAmount)(req.amount, snap.M2.broadMoney);
|
||||
if (!fullyBacked) {
|
||||
record.errors.push(`M2 backing insufficient for token load (${loadAmount}/${req.amount})`);
|
||||
}
|
||||
const mint = await (0, omnl_1.requestTokenMint)({
|
||||
lineId: req.convertToCrypto.tokenLineId,
|
||||
amount: loadAmount,
|
||||
recipient: req.convertToCrypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
}
|
||||
else {
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
}
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
record.errors.push(msg);
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
async function getMoneySupply(officeId) {
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
|
||||
return (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
}
|
||||
function getSettlementStatus(id) {
|
||||
return settlement_store_1.settlementStore.getById(id);
|
||||
}
|
||||
32
services/settlement-middleware/dist/workflows/office-scope.js
vendored
Normal file
32
services/settlement-middleware/dist/workflows/office-scope.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.bindOffice24 = bindOffice24;
|
||||
const config_1 = require("../config");
|
||||
/** Bind every settlement request to Office 24 (same profile as OMNL terminal). */
|
||||
function bindOffice24(req) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(req.officeId);
|
||||
if (!req.idempotencyKey || !req.creditorIban || !req.amount) {
|
||||
throw new Error('idempotencyKey, creditorIban, amount required');
|
||||
}
|
||||
return {
|
||||
officeId,
|
||||
valueDate: req.valueDate ?? new Date().toISOString().slice(0, 10),
|
||||
currency: req.currency ?? profile.settlement.defaultCurrency ?? cfg.defaultCurrency,
|
||||
moneyLayers: (req.moneyLayers ?? profile.settlement.moneyLayers),
|
||||
rail: req.rail ?? 'SWIFT',
|
||||
remittanceInfo: req.remittanceInfo ?? `OMNL Office 24 settlement — ${profile.externalId}`,
|
||||
beneficiaryName: req.beneficiaryName ?? 'Beneficiary',
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
creditorIban: req.creditorIban,
|
||||
amount: req.amount,
|
||||
debtorIban: req.debtorIban,
|
||||
creditorBic: req.creditorBic,
|
||||
orderingName: req.orderingName ?? profile.omnlLegalName,
|
||||
swiftMessageKind: req.swiftMessageKind,
|
||||
tradeFinance: req.tradeFinance,
|
||||
rwaVerbiage: req.rwaVerbiage,
|
||||
convertToCrypto: req.convertToCrypto,
|
||||
};
|
||||
}
|
||||
46
services/settlement-middleware/dist/workflows/swift-inbound.js
vendored
Normal file
46
services/settlement-middleware/dist/workflows/swift-inbound.js
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.processSwiftInbound = processSwiftInbound;
|
||||
const uuid_1 = require("uuid");
|
||||
const swift_1 = require("../adapters/swift");
|
||||
const config_1 = require("../config");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
const external_transfer_1 = require("./external-transfer");
|
||||
/** Inbound MT103/MT102 → settlement workflow */
|
||||
async function processSwiftInbound(raw) {
|
||||
await (0, swift_1.forwardSwiftToListener)(raw);
|
||||
const isMt102 = /\bMT102\b|:23:/.test(raw) || /:32B:/.test(raw);
|
||||
const refMatch = raw.match(/:20:([^\n]+)/);
|
||||
const amtMatch = raw.match(/:32[AB]:(\d{6})([A-Z]{3})([\d,]+)/);
|
||||
const ibanMatch = raw.match(/\/([A-Z]{2}\d{2}[A-Z0-9]+)/);
|
||||
const bnfMatch = raw.match(/:59:[^\n]*\n([^\n:]+)/);
|
||||
const remMatch = raw.match(/:70:([^\n]+)/);
|
||||
const req = {
|
||||
idempotencyKey: refMatch?.[1]?.trim() || `SWIFT-IN-${(0, uuid_1.v4)()}`,
|
||||
officeId: (0, config_1.settlementOfficeId)(),
|
||||
valueDate: new Date().toISOString().slice(0, 10),
|
||||
currency: amtMatch?.[2] ?? 'USD',
|
||||
amount: (amtMatch?.[3] ?? '0').replace(',', '.'),
|
||||
creditorIban: ibanMatch?.[1] ?? '',
|
||||
beneficiaryName: bnfMatch?.[1]?.trim() ?? 'Inbound beneficiary',
|
||||
remittanceInfo: remMatch?.[1]?.trim() ?? 'Inbound SWIFT settlement',
|
||||
moneyLayers: isMt102 ? ['M1', 'M2'] : ['M0', 'M1'],
|
||||
rail: 'SWIFT',
|
||||
swiftMessageKind: isMt102 ? 'MT102' : 'MT103',
|
||||
rwaVerbiage: { assetClass: 'RWA', tokenLineId: 'USD-M1', externalTransferRoll: true },
|
||||
};
|
||||
if (!req.creditorIban) {
|
||||
const stub = {
|
||||
settlementId: (0, uuid_1.v4)(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: req,
|
||||
errors: ['Parsed inbound SWIFT — manual IBAN review required'],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
settlement_store_1.settlementStore.save(stub);
|
||||
return stub;
|
||||
}
|
||||
return (0, external_transfer_1.processExternalTransfer)(req);
|
||||
}
|
||||
105
services/settlement-middleware/dist/workflows/token-load.js
vendored
Normal file
105
services/settlement-middleware/dist/workflows/token-load.js
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.processTokenLoad = processTokenLoad;
|
||||
const uuid_1 = require("uuid");
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const config_1 = require("../config");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
function glId(code) {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
async function processTokenLoad(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED')
|
||||
return existing;
|
||||
const now = new Date().toISOString();
|
||||
let record = existing ?? {
|
||||
settlementId: (0, uuid_1.v4)(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: now.slice(0, 10),
|
||||
currency: req.currency ?? 'USD',
|
||||
amount: req.amount,
|
||||
creditorIban: 'INTERNAL-M2-TOKEN-LOAD',
|
||||
beneficiaryName: req.recipientAddress,
|
||||
remittanceInfo: `M2 token load ${req.lineId} → ${req.symbol ?? req.tokenAddress ?? 'token'}`,
|
||||
moneyLayers: ['M2'],
|
||||
rail: 'INTERNAL',
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
tokenLineId: req.lineId,
|
||||
recipientAddress: req.recipientAddress,
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const advance = (phase, patch = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlement_store_1.settlementStore.save(record);
|
||||
};
|
||||
try {
|
||||
advance('VALIDATED');
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
verbiageDocument: [
|
||||
`OMNL M2 fiat-backed token load`,
|
||||
`Office ${officeId} · line ${req.lineId}`,
|
||||
`Amount ${req.amount} · recipient ${req.recipientAddress}`,
|
||||
`GL ${settlement_core_1.GL_CODES.M2_BROAD} → ${settlement_core_1.GL_CODES.CRYPTO_TOKEN_LIABILITY}`,
|
||||
].join('\n'),
|
||||
});
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
const { loadAmount, fullyBacked } = (0, settlement_core_1.m2FiatToTokenLoadAmount)(req.amount, snap.M2.broadMoney);
|
||||
if (!fullyBacked) {
|
||||
record.errors.push(`M2 backing insufficient (${loadAmount}/${req.amount})`);
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
const amount = parseFloat(loadAmount) || 0;
|
||||
if (amount > 0) {
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: `M2 token load ${req.lineId}`,
|
||||
debits: [{ glAccountId: glId(profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD), amount }],
|
||||
credits: [{ glAccountId: glId(settlement_core_1.GL_CODES.CRYPTO_TOKEN_LIABILITY), amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
}
|
||||
else {
|
||||
advance('FINERACT_POSTED');
|
||||
}
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
const mint = await (0, omnl_1.requestTokenMint)({
|
||||
lineId: req.lineId,
|
||||
amount: loadAmount,
|
||||
recipient: req.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
symbol: req.symbol,
|
||||
tokenAddress: req.tokenAddress,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
catch (err) {
|
||||
record.errors.push(err instanceof Error ? err.message : String(err));
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
130
services/settlement-middleware/dist/workflows/transfer.js
vendored
Normal file
130
services/settlement-middleware/dist/workflows/transfer.js
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.processTransfer = processTransfer;
|
||||
const uuid_1 = require("uuid");
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const config_1 = require("../config");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const hybx_production_1 = require("../adapters/hybx-production");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const swift_1 = require("../adapters/swift");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
function glId(code) {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
async function processTransfer(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const isExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED')
|
||||
return existing;
|
||||
const now = new Date().toISOString();
|
||||
let record = existing ?? {
|
||||
settlementId: (0, uuid_1.v4)(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: now.slice(0, 10),
|
||||
currency: req.currency ?? 'USD',
|
||||
amount: req.amount,
|
||||
creditorIban: req.creditorIban ?? (isExternal ? '' : 'INTERNAL-CHAIN138'),
|
||||
beneficiaryName: req.beneficiaryName ?? req.recipientAddress,
|
||||
remittanceInfo: req.remittanceInfo ?? `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`,
|
||||
moneyLayers: req.moneyLayers.length ? req.moneyLayers : ['M2'],
|
||||
rail: req.rail === 'CHAIN138' ? 'CHAIN138' : req.rail === 'INTERNAL' ? 'INTERNAL' : 'SWIFT',
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const advance = (phase, patch = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlement_store_1.settlementStore.save(record);
|
||||
};
|
||||
try {
|
||||
if (isExternal && !req.creditorIban) {
|
||||
throw new Error('creditorIban required for external transfer');
|
||||
}
|
||||
advance('VALIDATED');
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
verbiageDocument: [
|
||||
`OMNL ${req.rail} transfer · ${req.tokenSymbol}`,
|
||||
`M2 layer · ${req.amount}`,
|
||||
`To ${req.recipientAddress}`,
|
||||
isExternal ? `IBAN ${req.creditorIban}` : 'Internal / Chain 138',
|
||||
].join('\n'),
|
||||
});
|
||||
const amount = parseFloat(req.amount) || 0;
|
||||
if (amount > 0) {
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: `${req.rail} transfer ${req.tokenSymbol}`,
|
||||
debits: [{ glAccountId: glId(profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD), amount }],
|
||||
credits: [{ glAccountId: glId(profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE), amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
}
|
||||
else {
|
||||
advance('FINERACT_POSTED');
|
||||
}
|
||||
if (isExternal && cfg.rails.hybx.enabled) {
|
||||
const hybx = new hybx_production_1.HybxProductionRail();
|
||||
const pay = await hybx.dispatchPayment({
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
amount: req.amount,
|
||||
currency: req.currency ?? 'USD',
|
||||
creditorIban: req.creditorIban,
|
||||
beneficiaryName: req.beneficiaryName ?? req.recipientAddress,
|
||||
remittanceInfo: req.remittanceInfo ?? `OMNL ${req.tokenSymbol} external transfer`,
|
||||
rail: req.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
|
||||
});
|
||||
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
|
||||
}
|
||||
else {
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
}
|
||||
if (isExternal && cfg.rails.swift.enabled && req.creditorIban) {
|
||||
const swiftRaw = (0, swift_1.buildMt103Stub)({
|
||||
senderRef: req.idempotencyKey,
|
||||
currency: req.currency ?? 'USD',
|
||||
amount: req.amount,
|
||||
valueDate: now.slice(0, 10),
|
||||
orderingCustomer: cfg.omnlLegalName,
|
||||
beneficiary: req.beneficiaryName ?? req.recipientAddress,
|
||||
iban: req.creditorIban,
|
||||
remittance: req.remittanceInfo ?? `OMNL ${req.tokenSymbol}`,
|
||||
});
|
||||
await (0, swift_1.forwardSwiftToListener)(swiftRaw);
|
||||
const iso = await (0, omnl_1.archiveIso20022)({
|
||||
messageType: 'pacs.008',
|
||||
direction: 'OUTBOUND',
|
||||
raw: swiftRaw,
|
||||
settlementRef: record.settlementId,
|
||||
});
|
||||
advance('ISO20022_ARCHIVED', { iso20022MessageId: iso.messageId });
|
||||
}
|
||||
else {
|
||||
advance('ISO20022_ARCHIVED');
|
||||
}
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
verbiageDocument: [
|
||||
record.verbiageDocument,
|
||||
`On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`,
|
||||
].join('\n'),
|
||||
});
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
catch (err) {
|
||||
record.errors.push(err instanceof Error ? err.message : String(err));
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
1433
services/settlement-middleware/package-lock.json
generated
Normal file
1433
services/settlement-middleware/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
services/settlement-middleware/package.json
Normal file
29
services/settlement-middleware/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "omnl-settlement-middleware",
|
||||
"version": "1.0.0",
|
||||
"description": "OMNL central-bank settlement middleware — M0/M1/M2 meta fiat, SWIFT rails, trade finance, crypto token loading",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"test": "node --test dist/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dbis/integration-foundation": "file:../../packages/integration-foundation",
|
||||
"@dbis/settlement-core": "file:../../packages/settlement-core",
|
||||
"axios": "^1.15.2",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^5.1.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^20.19.33",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
53
services/settlement-middleware/src/adapters/fineract.ts
Normal file
53
services/settlement-middleware/src/adapters/fineract.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import axios from 'axios';
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
|
||||
const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl';
|
||||
const user =
|
||||
process.env.OMNL_FINERACT_USER?.trim() ||
|
||||
process.env.OMNL_FINERACT_USERNAME?.trim() ||
|
||||
'ali_hospitallers_tenant';
|
||||
const pass = process.env.OMNL_FINERACT_PASSWORD || '';
|
||||
if (!base || !pass) throw new Error('Fineract credentials required');
|
||||
const auth = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
return {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Fineract-Platform-TenantId': tenant,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
export async function postJournalEntry(entry: {
|
||||
officeId: number;
|
||||
transactionDate: string;
|
||||
referenceNumber: string;
|
||||
comments: string;
|
||||
debits: { glAccountId: number; amount: number }[];
|
||||
credits: { glAccountId: number; amount: number }[];
|
||||
}): Promise<{ resourceId: number }> {
|
||||
const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
|
||||
const { data } = await axios.post(`${base}/journalentries`, entry, {
|
||||
headers: authHeaders(),
|
||||
timeout: 60000,
|
||||
});
|
||||
return { resourceId: data.resourceId ?? data.entityId ?? 0 };
|
||||
}
|
||||
|
||||
export async function fetchGlBalances(officeId: number): Promise<Record<string, string>> {
|
||||
const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
|
||||
try {
|
||||
const { data } = await axios.get(`${base}/runreports/General Ledger Report`, {
|
||||
headers: authHeaders(),
|
||||
params: { R_officeId: officeId, genericResultSet: false },
|
||||
timeout: 60000,
|
||||
});
|
||||
const out: Record<string, string> = {};
|
||||
const rows = Array.isArray(data) ? data : (data as { data?: unknown[] })?.data ?? [];
|
||||
for (const row of rows as { glCode?: string; balance?: number }[]) {
|
||||
if (row.glCode) out[row.glCode] = String(row.balance ?? 0);
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import axios from 'axios';
|
||||
import { loadHybxConfig } from '@dbis/integration-foundation';
|
||||
|
||||
/** Production HYBX rail client for real settlement dispatch */
|
||||
export class HybxProductionRail {
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly clientSecret: string;
|
||||
|
||||
constructor() {
|
||||
const cfg = loadHybxConfig({ testMode: false });
|
||||
if (cfg.environment === 'production' && process.env.SETTLEMENT_ALLOW_HYBX_PRODUCTION !== '1') {
|
||||
throw new Error('SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 required for live HYBX rails');
|
||||
}
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = cfg.apiKey;
|
||||
this.clientSecret = cfg.clientSecret;
|
||||
}
|
||||
|
||||
async dispatchPayment(payload: {
|
||||
idempotencyKey: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
creditorIban: string;
|
||||
creditorBic?: string;
|
||||
beneficiaryName: string;
|
||||
remittanceInfo: string;
|
||||
rail: string;
|
||||
}): Promise<{ paymentId: string; status: string }> {
|
||||
const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.replace(/\/$/, '');
|
||||
const url = sidecar ? `${sidecar}/v1/rtgs/payments` : `${this.baseUrl}/v1/payments`;
|
||||
const { data } = await axios.post(
|
||||
url,
|
||||
{
|
||||
externalId: payload.idempotencyKey,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
beneficiary: {
|
||||
iban: payload.creditorIban,
|
||||
bic: payload.creditorBic,
|
||||
name: payload.beneficiaryName,
|
||||
},
|
||||
remittanceInformation: payload.remittanceInfo,
|
||||
rail: payload.rail,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiKey,
|
||||
Authorization: `Bearer ${this.clientSecret}`,
|
||||
},
|
||||
timeout: 120000,
|
||||
},
|
||||
);
|
||||
return {
|
||||
paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey),
|
||||
status: String(data.status ?? 'DISPATCHED'),
|
||||
};
|
||||
}
|
||||
}
|
||||
59
services/settlement-middleware/src/adapters/omnl.ts
Normal file
59
services/settlement-middleware/src/adapters/omnl.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export async function archiveIso20022(payload: {
|
||||
messageType: string;
|
||||
direction: 'INBOUND' | 'OUTBOUND';
|
||||
raw: string;
|
||||
settlementRef: string;
|
||||
}): Promise<{ messageId: string }> {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (key) headers.Authorization = `Bearer ${key}`;
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${base}/api/v1/omnl/iso20022/messages`,
|
||||
{
|
||||
messageType: payload.messageType,
|
||||
direction: payload.direction,
|
||||
payload: payload.raw,
|
||||
metadata: { settlementRef: payload.settlementRef },
|
||||
},
|
||||
{ headers, timeout: 60000 },
|
||||
);
|
||||
return { messageId: String(data.messageId ?? data.id ?? payload.settlementRef) };
|
||||
}
|
||||
|
||||
export async function requestTokenMint(payload: {
|
||||
lineId: string;
|
||||
amount: string;
|
||||
recipient: string;
|
||||
settlementRef: string;
|
||||
dryRun: boolean;
|
||||
symbol?: string;
|
||||
tokenAddress?: string;
|
||||
}): Promise<{ txHash?: string; status: string }> {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (key) headers.Authorization = `Bearer ${key}`;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${base}/api/v1/omnl/settlement/token-load`,
|
||||
payload,
|
||||
{ headers, timeout: 120000 },
|
||||
);
|
||||
return {
|
||||
txHash: data.txHash,
|
||||
status: String(data.status ?? (payload.dryRun ? 'DRY_RUN' : 'SUBMITTED')),
|
||||
};
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 404) {
|
||||
return {
|
||||
status: payload.dryRun ? 'DRY_RUN' : 'PENDING_CHAIN_ENDPOINT',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
56
services/settlement-middleware/src/adapters/swift.ts
Normal file
56
services/settlement-middleware/src/adapters/swift.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export async function forwardSwiftToListener(raw: string): Promise<{ accepted: boolean }> {
|
||||
const url = (process.env.SWIFT_LISTENER_URL || 'http://192.168.11.114:8788').replace(/\/$/, '');
|
||||
const token = process.env.SWIFT_LISTENER_WEBHOOK_TOKEN || '';
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/plain' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
await axios.post(`${url}/inbound`, raw, { headers, timeout: 60000 });
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
export function buildMt103Stub(fields: {
|
||||
senderRef: string;
|
||||
currency: string;
|
||||
amount: string;
|
||||
valueDate: string;
|
||||
orderingCustomer: string;
|
||||
beneficiary: string;
|
||||
iban: string;
|
||||
bic?: string;
|
||||
remittance: string;
|
||||
}): string {
|
||||
const bicLine = fields.bic ? `:57A:${fields.bic}\n` : '';
|
||||
return `{1:F01OMNLUS33XXXX0000000000}{2:I103BANKUS33XXXXN}{4:
|
||||
:20:${fields.senderRef}
|
||||
:23B:CRED
|
||||
:32A:${fields.valueDate.replace(/-/g, '').slice(2)}${fields.currency}${fields.amount.replace('.', ',')}
|
||||
:50K:${fields.orderingCustomer}
|
||||
:59:/${fields.iban}
|
||||
${fields.beneficiary}
|
||||
${bicLine}:70:${fields.remittance}
|
||||
:71A:OUR
|
||||
-}`;
|
||||
}
|
||||
|
||||
export function buildMt102Stub(fields: {
|
||||
senderRef: string;
|
||||
currency: string;
|
||||
amount: string;
|
||||
valueDate: string;
|
||||
orderingInstitution: string;
|
||||
beneficiary: string;
|
||||
iban: string;
|
||||
remittance: string;
|
||||
}): string {
|
||||
return `{1:F01OMNLUS33XXXX0000000000}{2:I102BANKUS33XXXXN}{4:
|
||||
:20:${fields.senderRef}
|
||||
:21:NONREF
|
||||
:32B:${fields.currency}${fields.amount.replace('.', ',')}
|
||||
:50A:${fields.orderingInstitution}
|
||||
:59:/${fields.iban}
|
||||
${fields.beneficiary}
|
||||
:70:${fields.remittance}
|
||||
-}`;
|
||||
}
|
||||
271
services/settlement-middleware/src/api/routes/settlement.ts
Normal file
271
services/settlement-middleware/src/api/routes/settlement.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import type { ExternalTransferRequest, TradeFinanceInstrument, TokenLoadRequest, TransferRequest } from '@dbis/settlement-core';
|
||||
import { loadOmnlChainRegistry, getActiveChains } from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../../config';
|
||||
import {
|
||||
getMoneySupply,
|
||||
getSettlementStatus,
|
||||
processExternalTransfer,
|
||||
} from '../../workflows/external-transfer';
|
||||
import { processTokenLoad } from '../../workflows/token-load';
|
||||
import { processTransfer } from '../../workflows/transfer';import { bindOffice24 } from '../../workflows/office-scope';
|
||||
import { settlementStore } from '../../store/settlement-store';
|
||||
import { processSwiftInbound } from '../../workflows/swift-inbound';
|
||||
|
||||
function requireApiKey(req: Request, res: Response): boolean {
|
||||
const cfg = loadConfig();
|
||||
if (!cfg.production.requireApiKey) return true;
|
||||
const key = req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key');
|
||||
if (key && key === process.env.OMNL_API_KEY) return true;
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createSettlementRouter(): Router {
|
||||
const router = Router();
|
||||
const officeId = settlementOfficeId();
|
||||
const profile = loadOfficeProfile();
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
let chainStats = { total: 0, active: 0 };
|
||||
try {
|
||||
const reg = loadOmnlChainRegistry();
|
||||
chainStats = { total: reg.chains.length, active: getActiveChains().length };
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
res.json({
|
||||
service: 'omnl-settlement-middleware',
|
||||
status: 'ok',
|
||||
mode: process.env.SETTLEMENT_MIDDLEWARE_CONFIG?.includes('production') ? 'production' : 'standard',
|
||||
role: 'OMNL central-bank settlement orchestration',
|
||||
chainSupport: { max: 128, ...chainStats },
|
||||
settlementOffice: {
|
||||
officeId: profile.officeId,
|
||||
externalId: profile.externalId,
|
||||
name: profile.name,
|
||||
},
|
||||
enforceSingleOffice: loadConfig().enforceSingleOffice ?? false,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/office', (_req, res) => {
|
||||
res.json({
|
||||
...profile,
|
||||
locked: true,
|
||||
storePath: `data/settlement-store/office-${officeId}`,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/money-supply', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
res.json(await getMoneySupply(officeId));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/money-supply/:officeIdParam', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const requested = parseInt(req.params.officeIdParam, 10);
|
||||
res.json(await getMoneySupply(settlementOfficeId(requested)));
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/settlements', (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
res.json({
|
||||
officeId,
|
||||
items: settlementStore.list(Number(req.query.limit ?? 50)),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/settlements/:id', (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
const rec = getSettlementStatus(req.params.id);
|
||||
if (!rec) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
res.json(rec);
|
||||
});
|
||||
|
||||
router.post('/external-transfer', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const merged = bindOffice24(req.body as ExternalTransferRequest);
|
||||
const record = await processExternalTransfer(merged);
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
} catch (e) {
|
||||
res.status(e instanceof Error && e.message.includes('locked') ? 403 : 500).json({
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/swift/inbound', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const raw = typeof req.body === 'string' ? req.body : req.body?.raw;
|
||||
if (!raw) {
|
||||
res.status(400).json({ error: 'raw SWIFT payload required' });
|
||||
return;
|
||||
}
|
||||
const record = await processSwiftInbound(String(raw));
|
||||
res.json(record);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/trade-finance/:instrument', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
const instrument = req.params.instrument.toUpperCase() as TradeFinanceInstrument;
|
||||
if (!['SBLC', 'DLC', 'BG', 'LS'].includes(instrument)) {
|
||||
res.status(400).json({ error: 'Invalid instrument' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = req.body as ExternalTransferRequest;
|
||||
const merged = bindOffice24({
|
||||
...body,
|
||||
tradeFinance: {
|
||||
instrument,
|
||||
reference: body.tradeFinance?.reference ?? body.idempotencyKey,
|
||||
expiryDate: body.tradeFinance?.expiryDate,
|
||||
},
|
||||
rail: 'SWIFT',
|
||||
moneyLayers: body.moneyLayers ?? ['M1', 'M2'],
|
||||
});
|
||||
const record = await processExternalTransfer(merged);
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/convert/fiat-to-crypto', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as ExternalTransferRequest;
|
||||
const merged = bindOffice24({
|
||||
...body,
|
||||
rail: body.rail ?? 'INTERNAL',
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: body.convertToCrypto?.targetChainId ?? 138,
|
||||
tokenLineId: body.convertToCrypto?.tokenLineId ?? `${body.currency ?? 'USD'}-M2`,
|
||||
recipientAddress: body.convertToCrypto?.recipientAddress ?? '',
|
||||
},
|
||||
moneyLayers: ['M2'],
|
||||
});
|
||||
const record = await processExternalTransfer(merged);
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/token-load', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as TokenLoadRequest;
|
||||
if (!body.idempotencyKey || !body.lineId || !body.amount || !body.recipientAddress) {
|
||||
res.status(400).json({ error: 'idempotencyKey, lineId, amount, recipientAddress required' });
|
||||
return;
|
||||
}
|
||||
const record = await processTokenLoad({
|
||||
...body,
|
||||
officeId: settlementOfficeId(body.officeId),
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/transfer/internal', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as TransferRequest;
|
||||
if (!body.idempotencyKey || !body.tokenAddress || !body.amount || !body.recipientAddress) {
|
||||
res.status(400).json({ error: 'idempotencyKey, tokenAddress, amount, recipientAddress required' });
|
||||
return;
|
||||
}
|
||||
const record = await processTransfer({
|
||||
...body,
|
||||
officeId: settlementOfficeId(body.officeId),
|
||||
rail: 'INTERNAL',
|
||||
moneyLayers: body.moneyLayers ?? ['M2'],
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/transfer/external', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as TransferRequest;
|
||||
if (!body.creditorIban) {
|
||||
res.status(400).json({ error: 'creditorIban required for external transfer' });
|
||||
return;
|
||||
}
|
||||
const record = await processTransfer({
|
||||
...body,
|
||||
officeId: settlementOfficeId(body.officeId),
|
||||
rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
|
||||
moneyLayers: body.moneyLayers ?? ['M2'],
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tokens/m2', (_req, res) => {
|
||||
try {
|
||||
const fs = require('fs') as typeof import('fs');
|
||||
const path = require('path') as typeof import('path');
|
||||
const p =
|
||||
process.env.OMNL_M2_TOKEN_REGISTRY ||
|
||||
path.resolve(__dirname, '../../../../../config/omnl-m2-token-registry.v1.json');
|
||||
const registry = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
res.json(registry);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/chains', (_req, res) => {
|
||||
try {
|
||||
const registry = loadOmnlChainRegistry();
|
||||
res.json({
|
||||
brand: registry.brand,
|
||||
maxCapacity: registry.maxCapacity,
|
||||
primaryChainId: registry.primaryChainId,
|
||||
mirrorChainId: registry.mirrorChainId,
|
||||
settlementOfficeId: registry.settlementOfficeId,
|
||||
stats: registry.stats,
|
||||
chains: registry.chains,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/chains/active', (_req, res) => {
|
||||
try {
|
||||
res.json({ count: getActiveChains().length, chains: getActiveChains() });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
112
services/settlement-middleware/src/config.ts
Normal file
112
services/settlement-middleware/src/config.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export type OfficeSettlementProfile = {
|
||||
version: string;
|
||||
officeId: number;
|
||||
externalId: string;
|
||||
name: string;
|
||||
fineractTenant: string;
|
||||
tenantUser: string;
|
||||
adminUser: string;
|
||||
operatorUser: string;
|
||||
settlement: {
|
||||
defaultCurrency: string;
|
||||
defaultLineId: string;
|
||||
moneyLayers: string[];
|
||||
glM0: string;
|
||||
glM1: string;
|
||||
glM2: string;
|
||||
glSettlement: string;
|
||||
glTradeFinanceContingent: string;
|
||||
};
|
||||
rails: MiddlewareConfig['rails'];
|
||||
omnlLegalName: string;
|
||||
omnlLei: string;
|
||||
};
|
||||
|
||||
export type MiddlewareConfig = {
|
||||
version: string;
|
||||
omnlLegalName: string;
|
||||
defaultOfficeId: number;
|
||||
enforceSingleOffice?: boolean;
|
||||
settlementOffice?: {
|
||||
officeId: number;
|
||||
externalId: string;
|
||||
name: string;
|
||||
profilePath: string;
|
||||
};
|
||||
defaultCurrency: string;
|
||||
rails: {
|
||||
swift: { enabled: boolean; listenerUrl: string };
|
||||
hybx: { enabled: boolean; sidecarUrl: string };
|
||||
chain138: { enabled: boolean; rpcEnv: string; defaultLineId: string };
|
||||
};
|
||||
moneySupply: {
|
||||
glM0: string;
|
||||
glM1: string;
|
||||
glM2: string;
|
||||
glSettlement: string;
|
||||
};
|
||||
production: {
|
||||
requireApiKey: boolean;
|
||||
allowHybxProduction: boolean;
|
||||
allowChainMintExecute: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
let cached: MiddlewareConfig | null = null;
|
||||
let cachedOffice: OfficeSettlementProfile | null = null;
|
||||
|
||||
function repoConfigPath(relative: string): string {
|
||||
return path.resolve(__dirname, '../../../', relative.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
export function loadConfig(): MiddlewareConfig {
|
||||
if (cached) return cached;
|
||||
const configPath =
|
||||
process.env.SETTLEMENT_MIDDLEWARE_CONFIG ||
|
||||
repoConfigPath('config/settlement-middleware.v1.json');
|
||||
cached = JSON.parse(fs.readFileSync(configPath, 'utf8')) as MiddlewareConfig;
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function loadOfficeProfile(): OfficeSettlementProfile {
|
||||
if (cachedOffice) return cachedOffice;
|
||||
const cfg = loadConfig();
|
||||
const profileRel =
|
||||
cfg.settlementOffice?.profilePath ?? 'config/offices/office-24-settlement.v1.json';
|
||||
const profilePath = process.env.OMNL_OFFICE_SETTLEMENT_PROFILE || repoConfigPath(profileRel);
|
||||
cachedOffice = JSON.parse(fs.readFileSync(profilePath, 'utf8')) as OfficeSettlementProfile;
|
||||
return cachedOffice;
|
||||
}
|
||||
|
||||
/** All settlement runs behind Office 24 (Ali HOSPITALLERS). */
|
||||
export function settlementOfficeId(requested?: number): number {
|
||||
const cfg = loadConfig();
|
||||
const profile = loadOfficeProfile();
|
||||
const locked = cfg.settlementOffice?.officeId ?? profile.officeId ?? cfg.defaultOfficeId;
|
||||
const fromEnv = parseInt(
|
||||
process.env.ALI_HOSPITALLERS_OFFICE_ID ||
|
||||
process.env.OMNL_SETTLEMENT_OFFICE_ID ||
|
||||
String(locked),
|
||||
10,
|
||||
);
|
||||
const officeId = fromEnv || locked;
|
||||
|
||||
if (cfg.enforceSingleOffice && requested !== undefined && requested !== officeId) {
|
||||
throw new Error(`Settlement is locked to office ${officeId}; requested ${requested}`);
|
||||
}
|
||||
if (cfg.enforceSingleOffice && officeId !== locked) {
|
||||
throw new Error(`Settlement is locked to office ${locked}; env specifies ${officeId}`);
|
||||
}
|
||||
return officeId;
|
||||
}
|
||||
|
||||
export function port(): number {
|
||||
return parseInt(process.env.SETTLEMENT_MIDDLEWARE_PORT || '3011', 10);
|
||||
}
|
||||
|
||||
export function settlementStoreSubdir(): string {
|
||||
return `office-${settlementOfficeId()}`;
|
||||
}
|
||||
10
services/settlement-middleware/src/index.ts
Normal file
10
services/settlement-middleware/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { startServer } from './server';
|
||||
|
||||
const rootEnv = path.resolve(__dirname, '../../../.env');
|
||||
if (existsSync(rootEnv)) dotenv.config({ path: rootEnv });
|
||||
dotenv.config();
|
||||
|
||||
startServer();
|
||||
21
services/settlement-middleware/src/server.ts
Normal file
21
services/settlement-middleware/src/server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { createSettlementRouter } from './api/routes/settlement';
|
||||
import { port } from './config';
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.text({ type: 'text/plain', limit: '2mb' }));
|
||||
app.use('/api/v1/settlement', createSettlementRouter());
|
||||
return app;
|
||||
}
|
||||
|
||||
export function startServer(): void {
|
||||
const app = createApp();
|
||||
const p = port();
|
||||
app.listen(p, () => {
|
||||
console.log(`OMNL settlement middleware listening on http://localhost:${p}/api/v1/settlement`);
|
||||
});
|
||||
}
|
||||
46
services/settlement-middleware/src/store/settlement-store.ts
Normal file
46
services/settlement-middleware/src/store/settlement-store.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { SettlementRecord } from '@dbis/settlement-core';
|
||||
import { settlementStoreSubdir } from '../config';
|
||||
|
||||
const baseDir =
|
||||
process.env.SETTLEMENT_STORE_DIR ||
|
||||
path.resolve(__dirname, '../../../data/settlement-store');
|
||||
|
||||
function storeDir(): string {
|
||||
const dir = path.join(baseDir, settlementStoreSubdir());
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export class SettlementStore {
|
||||
save(record: SettlementRecord): void {
|
||||
const dir = storeDir();
|
||||
fs.writeFileSync(path.join(dir, `${record.settlementId}.json`), JSON.stringify(record, null, 2));
|
||||
fs.writeFileSync(path.join(dir, `key-${record.idempotencyKey}.json`), record.settlementId);
|
||||
}
|
||||
|
||||
getById(id: string): SettlementRecord | null {
|
||||
const p = path.join(storeDir(), `${id}.json`);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8')) as SettlementRecord;
|
||||
}
|
||||
|
||||
getByIdempotencyKey(key: string): SettlementRecord | null {
|
||||
const ref = path.join(storeDir(), `key-${key}.json`);
|
||||
if (!fs.existsSync(ref)) return null;
|
||||
const id = fs.readFileSync(ref, 'utf8').trim();
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
list(limit = 50): SettlementRecord[] {
|
||||
const dir = storeDir();
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.json') && !f.startsWith('key-'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as SettlementRecord);
|
||||
}
|
||||
}
|
||||
|
||||
export const settlementStore = new SettlementStore();
|
||||
@@ -0,0 +1,171 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
buildMoneySupplySnapshot,
|
||||
isIbanValid,
|
||||
m2FiatToTokenLoadAmount,
|
||||
resolveInstrumentGl,
|
||||
rollExternalTransferVerbiage,
|
||||
swiftKindForRequest,
|
||||
type ExternalTransferRequest,
|
||||
type SettlementRecord,
|
||||
} from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import { fetchGlBalances, postJournalEntry } from '../adapters/fineract';
|
||||
import { HybxProductionRail } from '../adapters/hybx-production';
|
||||
import { archiveIso20022, requestTokenMint } from '../adapters/omnl';
|
||||
import { buildMt102Stub, buildMt103Stub, forwardSwiftToListener } from '../adapters/swift';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
|
||||
function glId(code: string): number {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
|
||||
export async function processExternalTransfer(
|
||||
input: ExternalTransferRequest,
|
||||
): Promise<SettlementRecord> {
|
||||
const cfg = loadConfig();
|
||||
const profile = loadOfficeProfile();
|
||||
const officeId = settlementOfficeId(input.officeId);
|
||||
const req: ExternalTransferRequest = { ...input, officeId };
|
||||
|
||||
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED') return existing;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let record: SettlementRecord = existing ?? {
|
||||
settlementId: uuidv4(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: req,
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const advance = (phase: SettlementRecord['phase'], patch: Partial<SettlementRecord> = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlementStore.save(record);
|
||||
};
|
||||
|
||||
try {
|
||||
if (!isIbanValid(req.creditorIban)) {
|
||||
throw new Error(`Invalid creditor IBAN: ${req.creditorIban}`);
|
||||
}
|
||||
advance('VALIDATED');
|
||||
|
||||
const verbiage = rollExternalTransferVerbiage(req);
|
||||
advance('VERBIAGE_ROLLED', { verbiageDocument: verbiage });
|
||||
|
||||
const amount = parseFloat(req.amount) || 0;
|
||||
const tf = req.tradeFinance;
|
||||
const gl = tf ? resolveInstrumentGl(tf.instrument) : null;
|
||||
const debitGl = gl?.debitGl ?? profile.settlement.glSettlement ?? cfg.moneySupply.glSettlement;
|
||||
const creditGl = gl?.creditGl ?? profile.settlement.glM1 ?? cfg.moneySupply.glM1;
|
||||
|
||||
if (amount > 0) {
|
||||
const je = await postJournalEntry({
|
||||
officeId: req.officeId,
|
||||
transactionDate: req.valueDate,
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: verbiage.slice(0, 500),
|
||||
debits: [{ glAccountId: glId(debitGl), amount }],
|
||||
credits: [{ glAccountId: glId(creditGl), amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
} else {
|
||||
advance('FINERACT_POSTED');
|
||||
}
|
||||
|
||||
if (cfg.rails.hybx.enabled && req.rail !== 'INTERNAL') {
|
||||
const hybx = new HybxProductionRail();
|
||||
const pay = await hybx.dispatchPayment({
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
amount: req.amount,
|
||||
currency: req.currency,
|
||||
creditorIban: req.creditorIban,
|
||||
creditorBic: req.creditorBic,
|
||||
beneficiaryName: req.beneficiaryName,
|
||||
remittanceInfo: req.remittanceInfo,
|
||||
rail: req.rail,
|
||||
});
|
||||
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
|
||||
} else {
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
}
|
||||
|
||||
const swiftKind = swiftKindForRequest(req);
|
||||
const swiftRaw =
|
||||
swiftKind === 'MT102'
|
||||
? buildMt102Stub({
|
||||
senderRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
orderingInstitution: req.orderingName ?? cfg.omnlLegalName,
|
||||
beneficiary: req.beneficiaryName,
|
||||
iban: req.creditorIban,
|
||||
remittance: req.remittanceInfo,
|
||||
})
|
||||
: buildMt103Stub({
|
||||
senderRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
orderingCustomer: req.orderingName ?? cfg.omnlLegalName,
|
||||
beneficiary: req.beneficiaryName,
|
||||
iban: req.creditorIban,
|
||||
bic: req.creditorBic,
|
||||
remittance: req.remittanceInfo,
|
||||
});
|
||||
|
||||
if (cfg.rails.swift.enabled) {
|
||||
await forwardSwiftToListener(swiftRaw);
|
||||
}
|
||||
const iso = await archiveIso20022({
|
||||
messageType: swiftKind === 'MT102' ? 'pacs.008' : 'pacs.008',
|
||||
direction: 'OUTBOUND',
|
||||
raw: swiftRaw,
|
||||
settlementRef: record.settlementId,
|
||||
});
|
||||
advance('ISO20022_ARCHIVED', { iso20022MessageId: iso.messageId });
|
||||
|
||||
if (req.convertToCrypto?.enabled) {
|
||||
const balances = await fetchGlBalances(req.officeId);
|
||||
const snap = buildMoneySupplySnapshot(req.officeId, balances);
|
||||
const { loadAmount, fullyBacked } = m2FiatToTokenLoadAmount(
|
||||
req.amount,
|
||||
snap.M2.broadMoney,
|
||||
);
|
||||
if (!fullyBacked) {
|
||||
record.errors.push(`M2 backing insufficient for token load (${loadAmount}/${req.amount})`);
|
||||
}
|
||||
const mint = await requestTokenMint({
|
||||
lineId: req.convertToCrypto.tokenLineId,
|
||||
amount: loadAmount,
|
||||
recipient: req.convertToCrypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
} else {
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
}
|
||||
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
record.errors.push(msg);
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMoneySupply(officeId: number) {
|
||||
const balances = await fetchGlBalances(officeId);
|
||||
return buildMoneySupplySnapshot(officeId, balances);
|
||||
}
|
||||
|
||||
export function getSettlementStatus(id: string): SettlementRecord | null {
|
||||
return settlementStore.getById(id);
|
||||
}
|
||||
33
services/settlement-middleware/src/workflows/office-scope.ts
Normal file
33
services/settlement-middleware/src/workflows/office-scope.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ExternalTransferRequest } from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
|
||||
/** Bind every settlement request to Office 24 (same profile as OMNL terminal). */
|
||||
export function bindOffice24(req: Partial<ExternalTransferRequest>): ExternalTransferRequest {
|
||||
const cfg = loadConfig();
|
||||
const profile = loadOfficeProfile();
|
||||
const officeId = settlementOfficeId(req.officeId);
|
||||
|
||||
if (!req.idempotencyKey || !req.creditorIban || !req.amount) {
|
||||
throw new Error('idempotencyKey, creditorIban, amount required');
|
||||
}
|
||||
|
||||
return {
|
||||
officeId,
|
||||
valueDate: req.valueDate ?? new Date().toISOString().slice(0, 10),
|
||||
currency: req.currency ?? profile.settlement.defaultCurrency ?? cfg.defaultCurrency,
|
||||
moneyLayers: (req.moneyLayers ?? profile.settlement.moneyLayers) as ExternalTransferRequest['moneyLayers'],
|
||||
rail: req.rail ?? 'SWIFT',
|
||||
remittanceInfo: req.remittanceInfo ?? `OMNL Office 24 settlement — ${profile.externalId}`,
|
||||
beneficiaryName: req.beneficiaryName ?? 'Beneficiary',
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
creditorIban: req.creditorIban,
|
||||
amount: req.amount,
|
||||
debtorIban: req.debtorIban,
|
||||
creditorBic: req.creditorBic,
|
||||
orderingName: req.orderingName ?? profile.omnlLegalName,
|
||||
swiftMessageKind: req.swiftMessageKind,
|
||||
tradeFinance: req.tradeFinance,
|
||||
rwaVerbiage: req.rwaVerbiage,
|
||||
convertToCrypto: req.convertToCrypto,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { ExternalTransferRequest, SettlementRecord } from '@dbis/settlement-core';
|
||||
import { forwardSwiftToListener } from '../adapters/swift';
|
||||
import { settlementOfficeId } from '../config';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
import { processExternalTransfer } from './external-transfer';
|
||||
|
||||
/** Inbound MT103/MT102 → settlement workflow */
|
||||
export async function processSwiftInbound(raw: string): Promise<SettlementRecord> {
|
||||
await forwardSwiftToListener(raw);
|
||||
|
||||
const isMt102 = /\bMT102\b|:23:/.test(raw) || /:32B:/.test(raw);
|
||||
const refMatch = raw.match(/:20:([^\n]+)/);
|
||||
const amtMatch = raw.match(/:32[AB]:(\d{6})([A-Z]{3})([\d,]+)/);
|
||||
const ibanMatch = raw.match(/\/([A-Z]{2}\d{2}[A-Z0-9]+)/);
|
||||
const bnfMatch = raw.match(/:59:[^\n]*\n([^\n:]+)/);
|
||||
const remMatch = raw.match(/:70:([^\n]+)/);
|
||||
|
||||
const req: ExternalTransferRequest = {
|
||||
idempotencyKey: refMatch?.[1]?.trim() || `SWIFT-IN-${uuidv4()}`,
|
||||
officeId: settlementOfficeId(),
|
||||
valueDate: new Date().toISOString().slice(0, 10),
|
||||
currency: amtMatch?.[2] ?? 'USD',
|
||||
amount: (amtMatch?.[3] ?? '0').replace(',', '.'),
|
||||
creditorIban: ibanMatch?.[1] ?? '',
|
||||
beneficiaryName: bnfMatch?.[1]?.trim() ?? 'Inbound beneficiary',
|
||||
remittanceInfo: remMatch?.[1]?.trim() ?? 'Inbound SWIFT settlement',
|
||||
moneyLayers: isMt102 ? ['M1', 'M2'] : ['M0', 'M1'],
|
||||
rail: 'SWIFT',
|
||||
swiftMessageKind: isMt102 ? 'MT102' : 'MT103',
|
||||
rwaVerbiage: { assetClass: 'RWA', tokenLineId: 'USD-M1', externalTransferRoll: true },
|
||||
};
|
||||
|
||||
if (!req.creditorIban) {
|
||||
const stub: SettlementRecord = {
|
||||
settlementId: uuidv4(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: req,
|
||||
errors: ['Parsed inbound SWIFT — manual IBAN review required'],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
settlementStore.save(stub);
|
||||
return stub;
|
||||
}
|
||||
|
||||
return processExternalTransfer(req);
|
||||
}
|
||||
115
services/settlement-middleware/src/workflows/token-load.ts
Normal file
115
services/settlement-middleware/src/workflows/token-load.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
buildMoneySupplySnapshot,
|
||||
GL_CODES,
|
||||
m2FiatToTokenLoadAmount,
|
||||
type TokenLoadRequest,
|
||||
type SettlementRecord,
|
||||
} from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import { fetchGlBalances, postJournalEntry } from '../adapters/fineract';
|
||||
import { requestTokenMint } from '../adapters/omnl';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
|
||||
function glId(code: string): number {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
|
||||
export async function processTokenLoad(input: TokenLoadRequest): Promise<SettlementRecord> {
|
||||
const cfg = loadConfig();
|
||||
const profile = loadOfficeProfile();
|
||||
const officeId = settlementOfficeId(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
|
||||
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED') return existing;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let record: SettlementRecord = existing ?? {
|
||||
settlementId: uuidv4(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: now.slice(0, 10),
|
||||
currency: req.currency ?? 'USD',
|
||||
amount: req.amount,
|
||||
creditorIban: 'INTERNAL-M2-TOKEN-LOAD',
|
||||
beneficiaryName: req.recipientAddress,
|
||||
remittanceInfo: `M2 token load ${req.lineId} → ${req.symbol ?? req.tokenAddress ?? 'token'}`,
|
||||
moneyLayers: ['M2'],
|
||||
rail: 'INTERNAL',
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
tokenLineId: req.lineId,
|
||||
recipientAddress: req.recipientAddress,
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const advance = (phase: SettlementRecord['phase'], patch: Partial<SettlementRecord> = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlementStore.save(record);
|
||||
};
|
||||
|
||||
try {
|
||||
advance('VALIDATED');
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
verbiageDocument: [
|
||||
`OMNL M2 fiat-backed token load`,
|
||||
`Office ${officeId} · line ${req.lineId}`,
|
||||
`Amount ${req.amount} · recipient ${req.recipientAddress}`,
|
||||
`GL ${GL_CODES.M2_BROAD} → ${GL_CODES.CRYPTO_TOKEN_LIABILITY}`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const balances = await fetchGlBalances(officeId);
|
||||
const snap = buildMoneySupplySnapshot(officeId, balances);
|
||||
const { loadAmount, fullyBacked } = m2FiatToTokenLoadAmount(req.amount, snap.M2.broadMoney);
|
||||
if (!fullyBacked) {
|
||||
record.errors.push(`M2 backing insufficient (${loadAmount}/${req.amount})`);
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
|
||||
const amount = parseFloat(loadAmount) || 0;
|
||||
if (amount > 0) {
|
||||
const je = await postJournalEntry({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: `M2 token load ${req.lineId}`,
|
||||
debits: [{ glAccountId: glId(profile.settlement.glM2 ?? GL_CODES.M2_BROAD), amount }],
|
||||
credits: [{ glAccountId: glId(GL_CODES.CRYPTO_TOKEN_LIABILITY), amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
} else {
|
||||
advance('FINERACT_POSTED');
|
||||
}
|
||||
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
|
||||
const mint = await requestTokenMint({
|
||||
lineId: req.lineId,
|
||||
amount: loadAmount,
|
||||
recipient: req.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
symbol: req.symbol,
|
||||
tokenAddress: req.tokenAddress,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
} catch (err) {
|
||||
record.errors.push(err instanceof Error ? err.message : String(err));
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
132
services/settlement-middleware/src/workflows/transfer.ts
Normal file
132
services/settlement-middleware/src/workflows/transfer.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { GL_CODES, type TransferRequest, type SettlementRecord } from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import { postJournalEntry } from '../adapters/fineract';
|
||||
import { HybxProductionRail } from '../adapters/hybx-production';
|
||||
import { archiveIso20022 } from '../adapters/omnl';
|
||||
import { buildMt103Stub, forwardSwiftToListener } from '../adapters/swift';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
|
||||
function glId(code: string): number {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
|
||||
export async function processTransfer(input: TransferRequest): Promise<SettlementRecord> {
|
||||
const cfg = loadConfig();
|
||||
const profile = loadOfficeProfile();
|
||||
const officeId = settlementOfficeId(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const isExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
|
||||
|
||||
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED') return existing;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let record: SettlementRecord = existing ?? {
|
||||
settlementId: uuidv4(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: now.slice(0, 10),
|
||||
currency: req.currency ?? 'USD',
|
||||
amount: req.amount,
|
||||
creditorIban: req.creditorIban ?? (isExternal ? '' : 'INTERNAL-CHAIN138'),
|
||||
beneficiaryName: req.beneficiaryName ?? req.recipientAddress,
|
||||
remittanceInfo: req.remittanceInfo ?? `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`,
|
||||
moneyLayers: req.moneyLayers.length ? req.moneyLayers : ['M2'],
|
||||
rail: req.rail === 'CHAIN138' ? 'CHAIN138' : req.rail === 'INTERNAL' ? 'INTERNAL' : 'SWIFT',
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const advance = (phase: SettlementRecord['phase'], patch: Partial<SettlementRecord> = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlementStore.save(record);
|
||||
};
|
||||
|
||||
try {
|
||||
if (isExternal && !req.creditorIban) {
|
||||
throw new Error('creditorIban required for external transfer');
|
||||
}
|
||||
advance('VALIDATED');
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
verbiageDocument: [
|
||||
`OMNL ${req.rail} transfer · ${req.tokenSymbol}`,
|
||||
`M2 layer · ${req.amount}`,
|
||||
`To ${req.recipientAddress}`,
|
||||
isExternal ? `IBAN ${req.creditorIban}` : 'Internal / Chain 138',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const amount = parseFloat(req.amount) || 0;
|
||||
if (amount > 0) {
|
||||
const je = await postJournalEntry({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: `${req.rail} transfer ${req.tokenSymbol}`,
|
||||
debits: [{ glAccountId: glId(profile.settlement.glM2 ?? GL_CODES.M2_BROAD), amount }],
|
||||
credits: [{ glAccountId: glId(profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE), amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
} else {
|
||||
advance('FINERACT_POSTED');
|
||||
}
|
||||
|
||||
if (isExternal && cfg.rails.hybx.enabled) {
|
||||
const hybx = new HybxProductionRail();
|
||||
const pay = await hybx.dispatchPayment({
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
amount: req.amount,
|
||||
currency: req.currency ?? 'USD',
|
||||
creditorIban: req.creditorIban!,
|
||||
beneficiaryName: req.beneficiaryName ?? req.recipientAddress,
|
||||
remittanceInfo: req.remittanceInfo ?? `OMNL ${req.tokenSymbol} external transfer`,
|
||||
rail: req.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
|
||||
});
|
||||
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
|
||||
} else {
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
}
|
||||
|
||||
if (isExternal && cfg.rails.swift.enabled && req.creditorIban) {
|
||||
const swiftRaw = buildMt103Stub({
|
||||
senderRef: req.idempotencyKey,
|
||||
currency: req.currency ?? 'USD',
|
||||
amount: req.amount,
|
||||
valueDate: now.slice(0, 10),
|
||||
orderingCustomer: cfg.omnlLegalName,
|
||||
beneficiary: req.beneficiaryName ?? req.recipientAddress,
|
||||
iban: req.creditorIban,
|
||||
remittance: req.remittanceInfo ?? `OMNL ${req.tokenSymbol}`,
|
||||
});
|
||||
await forwardSwiftToListener(swiftRaw);
|
||||
const iso = await archiveIso20022({
|
||||
messageType: 'pacs.008',
|
||||
direction: 'OUTBOUND',
|
||||
raw: swiftRaw,
|
||||
settlementRef: record.settlementId,
|
||||
});
|
||||
advance('ISO20022_ARCHIVED', { iso20022MessageId: iso.messageId });
|
||||
} else {
|
||||
advance('ISO20022_ARCHIVED');
|
||||
}
|
||||
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
verbiageDocument: [
|
||||
record.verbiageDocument,
|
||||
`On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`,
|
||||
].join('\n'),
|
||||
});
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
} catch (err) {
|
||||
record.errors.push(err instanceof Error ? err.message : String(err));
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
13
services/settlement-middleware/tsconfig.json
Normal file
13
services/settlement-middleware/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -99,6 +99,24 @@ function parseMt103(fields: Record<string, string>) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseMt102(fields: Record<string, string>) {
|
||||
const amt32b = fields['32B'] ?? fields['32A'] ?? '';
|
||||
const parts = amt32b.trim().split(/\s+/);
|
||||
const currency = parts[0]?.length === 3 ? parts[0] : parts[1] ?? '';
|
||||
const amount = parts[0]?.length === 3 ? parts[1] ?? '' : parts[2] ?? parts[0] ?? '';
|
||||
return {
|
||||
senderReference: fields['20'] ?? '',
|
||||
relatedReference: fields['21'] ?? '',
|
||||
currency,
|
||||
amount,
|
||||
orderingInstitution: fields['50A'] ?? fields['50K'] ?? fields['50F'] ?? '',
|
||||
beneficiaryCustomer: fields['59'] ?? fields['59A'] ?? '',
|
||||
accountWithInstitution: fields['57A'] ?? fields['57D'] ?? '',
|
||||
remittanceInfo: fields['70'] ?? '',
|
||||
senderToReceiverInfo: fields['72'] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function parseMt202(fields: Record<string, string>) {
|
||||
const amt = split32A(fields['32A'] ?? '');
|
||||
return {
|
||||
@@ -152,6 +170,9 @@ export function parseSwiftFin(raw: string): ParsedSwiftFin {
|
||||
case 'MT103':
|
||||
parsed = parseMt103(fields);
|
||||
break;
|
||||
case 'MT102':
|
||||
parsed = parseMt102(fields);
|
||||
break;
|
||||
case 'MT202':
|
||||
parsed = parseMt202(fields);
|
||||
break;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CanonicalPaymentMessage } from '@dbis/checkpoint-core';
|
||||
|
||||
export type SwiftFinMessageType =
|
||||
| 'MT103'
|
||||
| 'MT102'
|
||||
| 'MT202'
|
||||
| 'MT910'
|
||||
| 'MT940'
|
||||
|
||||
@@ -326,4 +326,51 @@ router.get('/omnl/health', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /omnl/settlement/token-load — M2 fiat-backed token mint (Office 24 settlement).
|
||||
*/
|
||||
router.post('/omnl/settlement/token-load', (req: Request, res: Response) => {
|
||||
const { lineId, amount, recipient, settlementRef, dryRun, symbol, tokenAddress } = req.body as {
|
||||
lineId?: string;
|
||||
amount?: string;
|
||||
recipient?: string;
|
||||
settlementRef?: string;
|
||||
dryRun?: boolean;
|
||||
symbol?: string;
|
||||
tokenAddress?: string;
|
||||
};
|
||||
if (!lineId || !amount || !recipient) {
|
||||
res.status(400).json({ error: 'lineId, amount, recipient required' });
|
||||
return;
|
||||
}
|
||||
const execute =
|
||||
!dryRun &&
|
||||
(process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1' ||
|
||||
process.env.OMNL_ALLOW_CHAIN_MINT_EXECUTE === '1');
|
||||
const loadId = settlementRef ?? `TL-${Date.now()}`;
|
||||
res.json({
|
||||
status: execute ? 'QUEUED' : 'DRY_RUN',
|
||||
loadId,
|
||||
lineId,
|
||||
amount,
|
||||
recipient,
|
||||
symbol: symbol ?? null,
|
||||
tokenAddress: tokenAddress ?? null,
|
||||
settlementRef: settlementRef ?? null,
|
||||
moneyLayer: 'M2',
|
||||
loadFromGl: '2200',
|
||||
creditGl: '2300',
|
||||
capabilities: {
|
||||
swappable: true,
|
||||
convertible: true,
|
||||
transferableInternal: true,
|
||||
transferableExternal: true,
|
||||
},
|
||||
txHash: execute ? undefined : null,
|
||||
message: execute
|
||||
? 'Token load queued for ComplianceCore mint pipeline (M2 → on-chain)'
|
||||
: 'M2 token load validated — set SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1 to mint on-chain',
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user