diff --git a/config/deployment-omnl.production.env.example b/config/deployment-omnl.production.env.example index 75cbf32..d5726e9 100644 --- a/config/deployment-omnl.production.env.example +++ b/config/deployment-omnl.production.env.example @@ -5,7 +5,6 @@ OMNL_BANK_PUBLIC_URL=https://bank.omnl.hybxfinance.io VITE_TOKEN_AGGREGATION_URL=https://bank.omnl.hybxfinance.io/api/v1 VITE_SETTLEMENT_MIDDLEWARE_URL=https://bank.omnl.hybxfinance.io/settlement VITE_DBIS_EXCHANGE_URL=https://bank.omnl.hybxfinance.io/exchange -VITE_OMNL_API_KEY= # === Production configs === SETTLEMENT_MIDDLEWARE_CONFIG=config/settlement-middleware.production.v1.json @@ -21,6 +20,15 @@ OMNL_ALLOW_CHAIN_MINT_EXECUTE=1 # === Auth === OMNL_API_KEY= +VITE_OMNL_API_KEY= + +# === On-chain M2 mint / transfer (Chain 138 operator) === +OMNL_MINT_OPERATOR_PRIVATE_KEY= +TOKEN_AGGREGATION_URL=http://127.0.0.1:3000 + +# === HYBX / bank rails (SWIFT + IBAN) === +HYBX_MIFOS_FINERACT_SIDECAR_URL= +SWIFT_LISTENER_URL= # === Primary chain 138 === RPC_URL_138=http://192.168.11.221:8545 diff --git a/frontend-dapp/src/config/dex.ts b/frontend-dapp/src/config/dex.ts index b3d390e..2ca61e0 100644 --- a/frontend-dapp/src/config/dex.ts +++ b/frontend-dapp/src/config/dex.ts @@ -40,6 +40,16 @@ export function settlementApi(path: string): string { export function exchangeApi(path: string): string { return proxiedServiceUrl(DBIS_EXCHANGE_URL, '/api/v1/exchange', path); } + +/** Bearer auth for OMNL mutating APIs (token load, transfers, swap settle). */ +export function omnlApiHeaders(json = true): Record { + const headers: Record = {}; + if (json) headers['Content-Type'] = 'application/json'; + const key = import.meta.env.VITE_OMNL_API_KEY?.trim(); + if (key) headers.Authorization = `Bearer ${key}`; + return headers; +} + export const ENHANCED_SWAP_ROUTER_V2 = import.meta.env.VITE_ENHANCED_SWAP_ROUTER_V2 || '0xa421706768aeb7fafa2d912c5e10824ef3437ad4'; diff --git a/frontend-dapp/src/features/swap/useDbisSwap.ts b/frontend-dapp/src/features/swap/useDbisSwap.ts index 52ea436..14e64de 100644 --- a/frontend-dapp/src/features/swap/useDbisSwap.ts +++ b/frontend-dapp/src/features/swap/useDbisSwap.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useAccount, useSendTransaction } from 'wagmi'; import { exchangeApi, + omnlApiHeaders, DEFAULT_TOKENS, fetchExchangeTokens, parseAmountToWei, @@ -86,7 +87,7 @@ export function useDbisSwap() { const wei = parseAmountToWei(amountIn, tokenIn.decimals); const res = await fetch(exchangeApi('/execute-plan'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: omnlApiHeaders(), body: JSON.stringify({ tokenIn: tokenIn.address, tokenOut: tokenOut.address, @@ -110,7 +111,7 @@ export function useDbisSwap() { try { const res = await fetch(exchangeApi('/swap/settle'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: omnlApiHeaders(), body: JSON.stringify({ amount: amountIn, recipientAddress: address, @@ -143,7 +144,7 @@ export function useDbisSwap() { setTxHash(hash); await fetch(exchangeApi('/swap/record'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: omnlApiHeaders(), body: JSON.stringify({ tokenIn: tokenIn.address, tokenOut: tokenOut.address, @@ -169,7 +170,7 @@ export function useDbisSwap() { try { const res = await fetch(exchangeApi('/token-load'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: omnlApiHeaders(), body: JSON.stringify({ idempotencyKey: `LOAD-${Date.now()}`, tokenAddress: tokenOut.address, @@ -198,7 +199,7 @@ export function useDbisSwap() { try { const res = await fetch(exchangeApi('/transfer/internal'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: omnlApiHeaders(), body: JSON.stringify({ idempotencyKey: `XFER-INT-${Date.now()}`, tokenAddress: tokenIn.address, @@ -225,7 +226,7 @@ export function useDbisSwap() { try { const res = await fetch(exchangeApi('/transfer/external'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: omnlApiHeaders(), body: JSON.stringify({ idempotencyKey: `XFER-EXT-${Date.now()}`, tokenAddress: tokenIn.address, diff --git a/scripts/deployment/deploy-omnl-bank-production.sh b/scripts/deployment/deploy-omnl-bank-production.sh index 043e83a..dfb4fbf 100644 --- a/scripts/deployment/deploy-omnl-bank-production.sh +++ b/scripts/deployment/deploy-omnl-bank-production.sh @@ -60,6 +60,7 @@ cd "$REPO_DIR/frontend-dapp" export VITE_TOKEN_AGGREGATION_URL="${VITE_TOKEN_AGGREGATION_URL:-/api/v1}" export VITE_SETTLEMENT_MIDDLEWARE_URL="${VITE_SETTLEMENT_MIDDLEWARE_URL:-/settlement}" export VITE_DBIS_EXCHANGE_URL="${VITE_DBIS_EXCHANGE_URL:-/exchange}" +export VITE_OMNL_API_KEY="${VITE_OMNL_API_KEY:-${OMNL_API_KEY:-}}" if command -v pnpm >/dev/null 2>&1; then pnpm install --frozen-lockfile 2>/dev/null || pnpm install pnpm run build @@ -90,6 +91,15 @@ start_svc() { OMNL_FINERACT_USERNAME="${OMNL_FINERACT_USERNAME:-}" \ OMNL_FINERACT_PASSWORD="${OMNL_FINERACT_PASSWORD:-}" \ OMNL_PUBLIC_MONEY_SUPPLY="${OMNL_PUBLIC_MONEY_SUPPLY:-1}" \ + SETTLEMENT_ALLOW_HYBX_PRODUCTION="${SETTLEMENT_ALLOW_HYBX_PRODUCTION:-}" \ + SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE="${SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE:-}" \ + OMNL_ALLOW_CHAIN_MINT_EXECUTE="${OMNL_ALLOW_CHAIN_MINT_EXECUTE:-}" \ + TOKEN_AGGREGATION_URL="${TOKEN_AGGREGATION_URL:-http://127.0.0.1:3000}" \ + HYBX_MIFOS_FINERACT_SIDECAR_URL="${HYBX_MIFOS_FINERACT_SIDECAR_URL:-}" \ + SWIFT_LISTENER_URL="${SWIFT_LISTENER_URL:-}" \ + RPC_URL_138="${RPC_URL_138:-${CHAIN_138_RPC_URL:-}}" \ + CHAIN_138_RPC_URL="${CHAIN_138_RPC_URL:-${RPC_URL_138:-}}" \ + OMNL_MINT_OPERATOR_PRIVATE_KEY="${OMNL_MINT_OPERATOR_PRIVATE_KEY:-}" \ bash -c "$cmd" >"$LOG_DIR/$name.log" 2>&1 & echo $! >"$LOG_DIR/$name.pid" } diff --git a/services/settlement-middleware/dist/adapters/fineract.js b/services/settlement-middleware/dist/adapters/fineract.js index d1e1191..33b78df 100644 --- a/services/settlement-middleware/dist/adapters/fineract.js +++ b/services/settlement-middleware/dist/adapters/fineract.js @@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.fineractOfficeReachable = fineractOfficeReachable; exports.postJournalEntry = postJournalEntry; +exports.resolveGlAccountId = resolveGlAccountId; exports.fetchGlBalances = fetchGlBalances; const axios_1 = __importDefault(require("axios")); const GL_HINTS = ['1000', '1050', '1410', '2000', '2100', '2200', '2300']; @@ -148,6 +149,26 @@ async function postJournalEntry(entry) { }); return { resourceId: data.resourceId ?? data.entityId ?? 0 }; } +const glCodeCache = new Map(); +/** Resolve Fineract GL account id from chart code (e.g. 1410 → 24). */ +async function resolveGlAccountId(glCode) { + const code = String(glCode).trim(); + const cached = glCodeCache.get(code); + if (cached != null) + return cached; + const { data } = await axios_1.default.get(`${fineractBase()}/glaccounts`, { + headers: authHeaders(), + timeout: 30000, + params: { glCode: code }, + }); + const rows = Array.isArray(data) ? data : data?.pageItems ?? []; + const match = rows.find((r) => r.glCode === code); + if (!match?.id) { + throw new Error(`GL account not found for code ${code}`); + } + glCodeCache.set(code, match.id); + return match.id; +} async function fetchGlBalances(officeId) { const base = fineractBase(); if (!base || !process.env.OMNL_FINERACT_PASSWORD) diff --git a/services/settlement-middleware/dist/adapters/hybx-production.js b/services/settlement-middleware/dist/adapters/hybx-production.js index 9d81229..2bb825e 100644 --- a/services/settlement-middleware/dist/adapters/hybx-production.js +++ b/services/settlement-middleware/dist/adapters/hybx-production.js @@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.HybxProductionRail = void 0; const axios_1 = __importDefault(require("axios")); +const config_1 = require("../config"); const integration_foundation_1 = require("@dbis/integration-foundation"); /** Production HYBX rail client for real settlement dispatch */ class HybxProductionRail { @@ -12,9 +13,12 @@ class HybxProductionRail { 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'); + const settlement = (0, config_1.loadConfig)(); + const allowLive = process.env.SETTLEMENT_ALLOW_HYBX_PRODUCTION === '1' || + settlement.production.allowHybxProduction; + const cfg = (0, integration_foundation_1.loadHybxConfig)({ testMode: !allowLive }); + if (cfg.environment === 'production' && !allowLive) { + throw new Error('HYBX production requires SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 or production.allowHybxProduction in config'); } this.baseUrl = cfg.baseUrl.replace(/\/$/, ''); this.apiKey = cfg.apiKey; diff --git a/services/settlement-middleware/dist/adapters/omnl.js b/services/settlement-middleware/dist/adapters/omnl.js index a38b470..6ae446e 100644 --- a/services/settlement-middleware/dist/adapters/omnl.js +++ b/services/settlement-middleware/dist/adapters/omnl.js @@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.archiveIso20022 = archiveIso20022; exports.requestTokenMint = requestTokenMint; +exports.requestTokenTransfer = requestTokenTransfer; const axios_1 = __importDefault(require("axios")); async function archiveIso20022(payload) { const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); @@ -42,3 +43,25 @@ async function requestTokenMint(payload) { throw err; } } +async function requestTokenTransfer(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-transfer`, 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; + } +} diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index 949e5be..3884c1e 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -21,11 +21,40 @@ function requireApiKey(req, res) { res.status(401).json({ error: 'Unauthorized' }); return false; } +async function respondPublicMoneySupply(res, officeId) { + const cfg = (0, config_1.loadConfig)(); + const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1'; + if (!allow) { + res.status(404).json({ error: 'Public money supply disabled' }); + return; + } + try { + const [balances, fineractConnected] = await Promise.all([ + (0, fineract_1.fetchGlBalances)(officeId), + (0, fineract_1.fineractOfficeReachable)(officeId), + ]); + const snapshot = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances); + const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0); + res.json({ + ...snapshot, + fineractConnected, + fineractLive: fineractConnected && hasActivity, + interoffice: { + dueFromHeadOffice: balances['1410'] ?? '0', + gl1410: balances['1410'] ?? '0', + }, + }); + } + catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } +} 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) => { + router.get('/health/ledger', (_req, res) => respondPublicMoneySupply(res, officeId)); + router.get('/health', async (req, res) => { let chainStats = { total: 0, active: 0 }; try { const reg = (0, settlement_core_1.loadOmnlChainRegistry)(); @@ -34,7 +63,7 @@ function createSettlementRouter() { catch { /* optional */ } - res.json({ + const payload = { service: 'omnl-settlement-middleware', status: 'ok', mode: process.env.SETTLEMENT_MIDDLEWARE_CONFIG?.includes('production') ? 'production' : 'standard', @@ -46,7 +75,37 @@ function createSettlementRouter() { name: profile.name, }, enforceSingleOffice: (0, config_1.loadConfig)().enforceSingleOffice ?? false, - }); + }; + const includeLedger = req.query.ledger === '1' || + req.query.include === 'money-supply' || + req.query.include === 'ledger'; + if (includeLedger) { + const cfg = (0, config_1.loadConfig)(); + const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1'; + if (allow) { + try { + const [balances, fineractConnected] = await Promise.all([ + (0, fineract_1.fetchGlBalances)(officeId), + (0, fineract_1.fineractOfficeReachable)(officeId), + ]); + const snapshot = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances); + const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0); + payload.moneySupply = { + ...snapshot, + fineractConnected, + fineractLive: fineractConnected && hasActivity, + interoffice: { + dueFromHeadOffice: balances['1410'] ?? '0', + gl1410: balances['1410'] ?? '0', + }, + }; + } + catch (e) { + payload.moneySupplyError = e instanceof Error ? e.message : String(e); + } + } + } + res.json(payload); }); router.get('/office', (_req, res) => { res.json({ @@ -55,34 +114,8 @@ function createSettlementRouter() { storePath: `data/settlement-store/office-${officeId}`, }); }); - router.get('/money-supply/public', async (_req, res) => { - const cfg = (0, config_1.loadConfig)(); - const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1'; - if (!allow) { - res.status(404).json({ error: 'Public money supply disabled' }); - return; - } - try { - const [balances, fineractConnected] = await Promise.all([ - (0, fineract_1.fetchGlBalances)(officeId), - (0, fineract_1.fineractOfficeReachable)(officeId), - ]); - const snapshot = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances); - const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0); - res.json({ - ...snapshot, - fineractConnected, - fineractLive: fineractConnected && hasActivity, - interoffice: { - dueFromHeadOffice: balances['1410'] ?? '0', - gl1410: balances['1410'] ?? '0', - }, - }); - } - catch (e) { - res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); - } - }); + router.get('/public/money-supply', (_req, res) => respondPublicMoneySupply(res, officeId)); + router.get('/money-supply/public', (_req, res) => respondPublicMoneySupply(res, officeId)); router.get('/money-supply', async (req, res) => { if (!requireApiKey(req, res)) return; @@ -96,8 +129,12 @@ function createSettlementRouter() { router.get('/money-supply/:officeIdParam', async (req, res) => { if (!requireApiKey(req, res)) return; + const requested = parseInt(req.params.officeIdParam, 10); + if (!Number.isFinite(requested)) { + res.status(404).json({ error: 'Not found' }); + return; + } try { - const requested = parseInt(req.params.officeIdParam, 10); res.json(await (0, external_transfer_1.getMoneySupply)((0, config_1.settlementOfficeId)(requested))); } catch (e) { diff --git a/services/settlement-middleware/dist/workflows/external-transfer.js b/services/settlement-middleware/dist/workflows/external-transfer.js index 4abc9be..cc64680 100644 --- a/services/settlement-middleware/dist/workflows/external-transfer.js +++ b/services/settlement-middleware/dist/workflows/external-transfer.js @@ -11,9 +11,6 @@ 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)(); @@ -49,13 +46,17 @@ async function processExternalTransfer(input) { const debitGl = gl?.debitGl ?? profile.settlement.glSettlement ?? cfg.moneySupply.glSettlement; const creditGl = gl?.creditGl ?? profile.settlement.glM1 ?? cfg.moneySupply.glM1; if (amount > 0) { + const [debitGlId, creditGlId] = await Promise.all([ + (0, fineract_1.resolveGlAccountId)(debitGl), + (0, fineract_1.resolveGlAccountId)(creditGl), + ]); 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 }], + debits: [{ glAccountId: debitGlId, amount }], + credits: [{ glAccountId: creditGlId, amount }], }); advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) }); } diff --git a/services/settlement-middleware/dist/workflows/token-load.js b/services/settlement-middleware/dist/workflows/token-load.js index eeaa828..4afaaad 100644 --- a/services/settlement-middleware/dist/workflows/token-load.js +++ b/services/settlement-middleware/dist/workflows/token-load.js @@ -7,9 +7,6 @@ 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)(); @@ -69,13 +66,17 @@ async function processTokenLoad(input) { } const amount = parseFloat(loadAmount) || 0; if (amount > 0) { + const [debitGlId, creditGlId] = await Promise.all([ + (0, fineract_1.resolveGlAccountId)(profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD), + (0, fineract_1.resolveGlAccountId)(settlement_core_1.GL_CODES.CRYPTO_TOKEN_LIABILITY), + ]); 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 }], + debits: [{ glAccountId: debitGlId, amount }], + credits: [{ glAccountId: creditGlId, amount }], }); advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) }); } diff --git a/services/settlement-middleware/dist/workflows/transfer.js b/services/settlement-middleware/dist/workflows/transfer.js index db67c19..98163e3 100644 --- a/services/settlement-middleware/dist/workflows/transfer.js +++ b/services/settlement-middleware/dist/workflows/transfer.js @@ -9,9 +9,6 @@ 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)(); @@ -61,13 +58,17 @@ async function processTransfer(input) { }); const amount = parseFloat(req.amount) || 0; if (amount > 0) { + const [debitGlId, creditGlId] = await Promise.all([ + (0, fineract_1.resolveGlAccountId)(profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD), + (0, fineract_1.resolveGlAccountId)(profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE), + ]); 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 }], + debits: [{ glAccountId: debitGlId, amount }], + credits: [{ glAccountId: creditGlId, amount }], }); advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) }); } @@ -113,12 +114,34 @@ async function processTransfer(input) { else { advance('ISO20022_ARCHIVED'); } - advance('CHAIN_MINT_REQUESTED', { - verbiageDocument: [ - record.verbiageDocument, - `On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`, - ].join('\n'), - }); + if (!isExternal && req.tokenAddress && cfg.production.allowChainMintExecute) { + const xfer = await (0, omnl_1.requestTokenTransfer)({ + tokenAddress: req.tokenAddress, + amount: req.amount, + recipient: req.recipientAddress, + settlementRef: record.settlementId, + dryRun: false, + symbol: req.tokenSymbol, + }); + advance('CHAIN_MINT_REQUESTED', { + chainTxHash: xfer.txHash, + verbiageDocument: [ + record.verbiageDocument, + `On-chain ERC-20 transfer: ${req.tokenAddress} → ${req.recipientAddress}`, + xfer.txHash ? `tx ${xfer.txHash}` : `status ${xfer.status}`, + ].join('\n'), + }); + } + else { + advance('CHAIN_MINT_REQUESTED', { + verbiageDocument: [ + record.verbiageDocument, + isExternal + ? 'Bank rail (SWIFT/IBAN) — no on-chain transfer' + : `On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`, + ].join('\n'), + }); + } advance('SETTLED'); return record; } diff --git a/services/settlement-middleware/src/adapters/fineract.ts b/services/settlement-middleware/src/adapters/fineract.ts index e3847e0..6625c4c 100644 --- a/services/settlement-middleware/src/adapters/fineract.ts +++ b/services/settlement-middleware/src/adapters/fineract.ts @@ -154,6 +154,28 @@ export async function postJournalEntry(entry: { return { resourceId: data.resourceId ?? data.entityId ?? 0 }; } +const glCodeCache = new Map(); + +/** Resolve Fineract GL account id from chart code (e.g. 1410 → 24). */ +export async function resolveGlAccountId(glCode: string): Promise { + const code = String(glCode).trim(); + const cached = glCodeCache.get(code); + if (cached != null) return cached; + + const { data } = await axios.get(`${fineractBase()}/glaccounts`, { + headers: authHeaders(), + timeout: 30000, + params: { glCode: code }, + }); + const rows = Array.isArray(data) ? data : (data as { pageItems?: unknown[] })?.pageItems ?? []; + const match = (rows as { id?: number; glCode?: string }[]).find((r) => r.glCode === code); + if (!match?.id) { + throw new Error(`GL account not found for code ${code}`); + } + glCodeCache.set(code, match.id); + return match.id; +} + export async function fetchGlBalances(officeId: number): Promise> { const base = fineractBase(); if (!base || !process.env.OMNL_FINERACT_PASSWORD) return {}; diff --git a/services/settlement-middleware/src/adapters/hybx-production.ts b/services/settlement-middleware/src/adapters/hybx-production.ts index 627efd6..9b13e4e 100644 --- a/services/settlement-middleware/src/adapters/hybx-production.ts +++ b/services/settlement-middleware/src/adapters/hybx-production.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { loadConfig } from '../config'; import { loadHybxConfig } from '@dbis/integration-foundation'; /** Production HYBX rail client for real settlement dispatch */ @@ -8,11 +9,16 @@ export class HybxProductionRail { 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(/\/$/, ''); + const settlement = loadConfig(); + const allowLive = + process.env.SETTLEMENT_ALLOW_HYBX_PRODUCTION === '1' || + settlement.production.allowHybxProduction; + const cfg = loadHybxConfig({ testMode: !allowLive }); + if (cfg.environment === 'production' && !allowLive) { + throw new Error( + 'HYBX production requires SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 or production.allowHybxProduction in config', + ); + } this.baseUrl = cfg.baseUrl.replace(/\/$/, ''); this.apiKey = cfg.apiKey; this.clientSecret = cfg.clientSecret; } diff --git a/services/settlement-middleware/src/adapters/omnl.ts b/services/settlement-middleware/src/adapters/omnl.ts index 33fce2f..8020be8 100644 --- a/services/settlement-middleware/src/adapters/omnl.ts +++ b/services/settlement-middleware/src/adapters/omnl.ts @@ -57,3 +57,36 @@ export async function requestTokenMint(payload: { throw err; } } + +export async function requestTokenTransfer(payload: { + tokenAddress: string; + amount: string; + recipient: string; + settlementRef: string; + dryRun: boolean; + symbol?: 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 = { 'Content-Type': 'application/json' }; + if (key) headers.Authorization = `Bearer ${key}`; + + try { + const { data } = await axios.post( + `${base}/api/v1/omnl/settlement/token-transfer`, + 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; + } +} diff --git a/services/settlement-middleware/src/workflows/external-transfer.ts b/services/settlement-middleware/src/workflows/external-transfer.ts index 2244338..7b15be6 100644 --- a/services/settlement-middleware/src/workflows/external-transfer.ts +++ b/services/settlement-middleware/src/workflows/external-transfer.ts @@ -10,16 +10,12 @@ import { type SettlementRecord, } from '@dbis/settlement-core'; import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config'; -import { fetchGlBalances, postJournalEntry } from '../adapters/fineract'; +import { fetchGlBalances, postJournalEntry, resolveGlAccountId } 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 { @@ -63,13 +59,17 @@ export async function processExternalTransfer( const creditGl = gl?.creditGl ?? profile.settlement.glM1 ?? cfg.moneySupply.glM1; if (amount > 0) { + const [debitGlId, creditGlId] = await Promise.all([ + resolveGlAccountId(debitGl), + resolveGlAccountId(creditGl), + ]); 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 }], + debits: [{ glAccountId: debitGlId, amount }], + credits: [{ glAccountId: creditGlId, amount }], }); advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) }); } else { diff --git a/services/settlement-middleware/src/workflows/token-load.ts b/services/settlement-middleware/src/workflows/token-load.ts index 152d8c6..d7dc172 100644 --- a/services/settlement-middleware/src/workflows/token-load.ts +++ b/services/settlement-middleware/src/workflows/token-load.ts @@ -7,14 +7,10 @@ import { type SettlementRecord, } from '@dbis/settlement-core'; import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config'; -import { fetchGlBalances, postJournalEntry } from '../adapters/fineract'; +import { fetchGlBalances, postJournalEntry, resolveGlAccountId } 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 { const cfg = loadConfig(); const profile = loadOfficeProfile(); @@ -79,13 +75,17 @@ export async function processTokenLoad(input: TokenLoadRequest): Promise 0) { + const [debitGlId, creditGlId] = await Promise.all([ + resolveGlAccountId(profile.settlement.glM2 ?? GL_CODES.M2_BROAD), + resolveGlAccountId(GL_CODES.CRYPTO_TOKEN_LIABILITY), + ]); 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 }], + debits: [{ glAccountId: debitGlId, amount }], + credits: [{ glAccountId: creditGlId, amount }], }); advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) }); } else { diff --git a/services/settlement-middleware/src/workflows/transfer.ts b/services/settlement-middleware/src/workflows/transfer.ts index b4ddcd8..88d9596 100644 --- a/services/settlement-middleware/src/workflows/transfer.ts +++ b/services/settlement-middleware/src/workflows/transfer.ts @@ -1,16 +1,12 @@ 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 { postJournalEntry, resolveGlAccountId } from '../adapters/fineract'; import { HybxProductionRail } from '../adapters/hybx-production'; -import { archiveIso20022 } from '../adapters/omnl'; +import { archiveIso20022, requestTokenTransfer } 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 { const cfg = loadConfig(); const profile = loadOfficeProfile(); @@ -64,13 +60,17 @@ export async function processTransfer(input: TransferRequest): Promise 0) { + const [debitGlId, creditGlId] = await Promise.all([ + resolveGlAccountId(profile.settlement.glM2 ?? GL_CODES.M2_BROAD), + resolveGlAccountId(profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE), + ]); 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 }], + debits: [{ glAccountId: debitGlId, amount }], + credits: [{ glAccountId: creditGlId, amount }], }); advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) }); } else { @@ -116,12 +116,33 @@ export async function processTransfer(input: TransferRequest): Promise { /** * POST /omnl/settlement/token-load — M2 fiat-backed token mint (Office 24 settlement). */ -router.post('/omnl/settlement/token-load', (req: Request, res: Response) => { +router.post('/omnl/settlement/token-load', async (req: Request, res: Response) => { const { lineId, amount, recipient, settlementRef, dryRun, symbol, tokenAddress } = req.body as { lineId?: string; amount?: string; @@ -348,29 +353,127 @@ router.post('/omnl/settlement/token-load', (req: Request, res: Response) => { (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', - }); + const capabilities = { + swappable: true, + convertible: true, + transferableInternal: true, + transferableExternal: true, + }; + + if (!execute) { + res.json({ + status: 'DRY_RUN', + loadId, + lineId, + amount, + recipient, + symbol: symbol ?? null, + tokenAddress: tokenAddress ?? null, + settlementRef: settlementRef ?? null, + moneyLayer: 'M2', + loadFromGl: '2200', + creditGl: '2300', + capabilities, + txHash: null, + chainRailConfigured: chainRailConfigured(), + message: 'M2 token load validated — set SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1 to mint on-chain', + }); + return; + } + + if (!tokenAddress?.startsWith('0x')) { + res.status(400).json({ + error: 'tokenAddress required for on-chain mint', + status: 'QUEUED', + loadId, + capabilities, + }); + return; + } + + try { + const { txHash } = await executeM2TokenMint({ + tokenAddress, + recipient, + amount, + }); + res.json({ + status: 'SETTLED', + loadId, + lineId, + amount, + recipient, + symbol: symbol ?? null, + tokenAddress, + settlementRef: settlementRef ?? null, + moneyLayer: 'M2', + capabilities, + txHash, + message: 'M2 token minted on Chain 138 — tradable, swappable, transferable', + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(502).json({ + error: msg, + status: 'CHAIN_MINT_FAILED', + loadId, + lineId, + capabilities, + }); + } +}); + +/** + * POST /omnl/settlement/token-transfer — ERC-20 transfer to web3 wallet (internal rail). + */ +router.post('/omnl/settlement/token-transfer', async (req: Request, res: Response) => { + const { tokenAddress, amount, recipient, settlementRef, dryRun, symbol } = req.body as { + tokenAddress?: string; + amount?: string; + recipient?: string; + settlementRef?: string; + dryRun?: boolean; + symbol?: string; + }; + if (!tokenAddress || !amount || !recipient) { + res.status(400).json({ error: 'tokenAddress, amount, recipient required' }); + return; + } + const execute = + !dryRun && + (process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1' || + process.env.OMNL_ALLOW_CHAIN_MINT_EXECUTE === '1'); + + if (!execute) { + res.json({ + status: 'DRY_RUN', + transferId: settlementRef ?? `TT-${Date.now()}`, + tokenAddress, + amount, + recipient, + symbol: symbol ?? null, + txHash: null, + message: 'Transfer validated — enable SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1 for on-chain delivery', + }); + return; + } + + try { + const { txHash } = await executeErc20Transfer({ tokenAddress, recipient, amount }); + res.json({ + status: 'SETTLED', + transferId: settlementRef ?? `TT-${Date.now()}`, + tokenAddress, + amount, + recipient, + symbol: symbol ?? null, + txHash, + message: 'ERC-20 transferred to recipient wallet on Chain 138', + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(502).json({ error: msg, status: 'CHAIN_TRANSFER_FAILED' }); + } }); export default router; diff --git a/services/token-aggregation/src/services/omnl-settlement-chain.ts b/services/token-aggregation/src/services/omnl-settlement-chain.ts new file mode 100644 index 0000000..5eed720 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-settlement-chain.ts @@ -0,0 +1,94 @@ +import { Contract, JsonRpcProvider, Wallet, parseUnits } from 'ethers'; + +function rpcUrl(): string | undefined { + return process.env.RPC_URL_138 || process.env.CHAIN_138_RPC_URL; +} + +function operatorKey(): string | undefined { + return process.env.OMNL_MINT_OPERATOR_PRIVATE_KEY?.trim(); +} + +function chainExecuteEnabled(): boolean { + return ( + process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1' || + process.env.OMNL_ALLOW_CHAIN_MINT_EXECUTE === '1' + ); +} + +async function tokenDecimals(token: Contract, fallback = 18): Promise { + try { + return Number(await token.decimals()); + } catch { + return fallback; + } +} + +/** Mint M2 compliant token to recipient wallet (Chain 138). */ +export async function executeM2TokenMint(params: { + tokenAddress: string; + recipient: string; + amount: string; + decimals?: number; +}): Promise<{ txHash: string }> { + const pk = operatorKey(); + const rpc = rpcUrl(); + if (!chainExecuteEnabled()) { + throw new Error('Chain mint execute disabled — set SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1'); + } + if (!pk || !rpc) { + throw new Error('OMNL_MINT_OPERATOR_PRIVATE_KEY and RPC_URL_138 required for on-chain mint'); + } + if (!params.tokenAddress?.startsWith('0x')) { + throw new Error('tokenAddress required for on-chain mint'); + } + + const provider = new JsonRpcProvider(rpc); + const wallet = new Wallet(pk, provider); + const abi = [ + 'function mint(address to, uint256 amount)', + 'function decimals() view returns (uint8)', + ]; + const token = new Contract(params.tokenAddress, abi, wallet); + const decimals = await tokenDecimals(token, params.decimals ?? 18); + const wei = parseUnits(params.amount, decimals); + const tx = await token.mint(params.recipient, wei); + const receipt = await tx.wait(1); + return { txHash: receipt?.hash ?? tx.hash }; +} + +/** Transfer ERC-20 from operator treasury to recipient (internal / web3 wallet). */ +export async function executeErc20Transfer(params: { + tokenAddress: string; + recipient: string; + amount: string; + decimals?: number; +}): Promise<{ txHash: string }> { + const pk = operatorKey(); + const rpc = rpcUrl(); + if (!chainExecuteEnabled()) { + throw new Error('Chain transfer execute disabled — set SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1'); + } + if (!pk || !rpc) { + throw new Error('OMNL_MINT_OPERATOR_PRIVATE_KEY and RPC_URL_138 required for on-chain transfer'); + } + if (!params.tokenAddress?.startsWith('0x')) { + throw new Error('tokenAddress required for on-chain transfer'); + } + + const provider = new JsonRpcProvider(rpc); + const wallet = new Wallet(pk, provider); + const abi = [ + 'function transfer(address to, uint256 amount) returns (bool)', + 'function decimals() view returns (uint8)', + ]; + const token = new Contract(params.tokenAddress, abi, wallet); + const decimals = await tokenDecimals(token, params.decimals ?? 18); + const wei = parseUnits(params.amount, decimals); + const tx = await token.transfer(params.recipient, wei); + const receipt = await tx.wait(1); + return { txHash: receipt?.hash ?? tx.hash }; +} + +export function chainRailConfigured(): boolean { + return Boolean(operatorKey() && rpcUrl() && chainExecuteEnabled()); +}