feat(omnl): settlement terminal, compliance gates, and HYBX integration foundation
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m12s
CI/CD Pipeline / Security Scanning (push) Successful in 2m21s
CI/CD Pipeline / Lint and Format (push) Failing after 36s
CI/CD Pipeline / Terraform Validation (push) Failing after 22s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 25s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 23s
Validation / validate-genesis (push) Successful in 26s
Validation / validate-terraform (push) Failing after 21s
Validation / validate-kubernetes (push) Failing after 9s
Validation / validate-smart-contracts (push) Failing after 8s
Validation / validate-security (push) Failing after 1m15s
Validation / validate-documentation (push) Failing after 15s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 26s
Verify Deployment / Verify Deployment (push) Failing after 56s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m12s
CI/CD Pipeline / Security Scanning (push) Successful in 2m21s
CI/CD Pipeline / Lint and Format (push) Failing after 36s
CI/CD Pipeline / Terraform Validation (push) Failing after 22s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 25s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 23s
Validation / validate-genesis (push) Successful in 26s
Validation / validate-terraform (push) Failing after 21s
Validation / validate-kubernetes (push) Failing after 9s
Validation / validate-smart-contracts (push) Failing after 8s
Validation / validate-security (push) Failing after 1m15s
Validation / validate-documentation (push) Failing after 15s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 26s
Verify Deployment / Verify Deployment (push) Failing after 56s
Add operator settlement terminal UI/API, swift-listener service, compliance idempotency gates, GRU reserve dashboards, and @dbis/integration-foundation for typed HYBX/ISO adapter contracts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createCorrelationContext, CORRELATION_ID_HEADER, REQUEST_ID_HEADER } from '@dbis/integration-foundation';
|
||||
import { appendOmnlAudit } from '../../services/omnl-audit-log';
|
||||
|
||||
/** Attach trace id and log every /omnl request (response status on finish). */
|
||||
/** Attach trace/correlation ids and log every /omnl request (response status on finish). */
|
||||
export function omnlAuditMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const traceId = (req.headers['x-omnl-trace-id'] as string) || randomUUID();
|
||||
const ctx = createCorrelationContext({
|
||||
correlationId: String(req.headers[CORRELATION_ID_HEADER] ?? req.headers['x-omnl-trace-id'] ?? ''),
|
||||
requestId: String(req.headers[REQUEST_ID_HEADER] ?? ''),
|
||||
});
|
||||
const traceId = ctx.correlationId;
|
||||
res.setHeader('X-OMNL-Trace-Id', traceId);
|
||||
res.setHeader(CORRELATION_ID_HEADER, ctx.correlationId);
|
||||
res.setHeader(REQUEST_ID_HEADER, ctx.requestId);
|
||||
const started = Date.now();
|
||||
res.on('finish', () => {
|
||||
appendOmnlAudit({
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { MockComplianceDecisionEngine } from '@dbis/integration-foundation';
|
||||
|
||||
const engine = new MockComplianceDecisionEngine();
|
||||
|
||||
const FINANCIAL_POST_PATHS = [
|
||||
'/omnl/iso20022/messages',
|
||||
];
|
||||
|
||||
/**
|
||||
* Optional compliance decision gate for financial POST routes.
|
||||
* Enable with OMNL_COMPLIANCE_GATE=1 (uses mock engine until provider integration).
|
||||
*/
|
||||
export async function omnlComplianceGateMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
if (process.env.OMNL_COMPLIANCE_GATE !== '1') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const path = req.path.replace(/^\/api\/v1/, '');
|
||||
if (!FINANCIAL_POST_PATHS.some((p) => path.endsWith(p))) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
messageType?: string;
|
||||
payload?: string;
|
||||
entityId?: string;
|
||||
tenantId?: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
riskHints?: string[];
|
||||
};
|
||||
|
||||
const result = await engine.evaluateTransaction({
|
||||
entityId: body.entityId ?? 'omnl-default',
|
||||
tenantId: body.tenantId ?? 'omnl',
|
||||
amount: body.amount ?? '0',
|
||||
currency: body.currency ?? 'USD',
|
||||
transactionType: body.messageType ?? 'iso20022.store',
|
||||
riskHints: body.riskHints,
|
||||
});
|
||||
|
||||
res.setHeader('X-OMNL-Compliance-Decision', result.decision);
|
||||
res.setHeader('X-OMNL-Audit-Event-Id', result.audit_event_id);
|
||||
|
||||
if (result.decision === 'block' || result.decision === 'reject') {
|
||||
res.status(403).json({
|
||||
error: 'compliance_blocked',
|
||||
decision: result.decision,
|
||||
reason_codes: result.reason_codes,
|
||||
audit_event_id: result.audit_event_id,
|
||||
manual_review_required: result.manual_review_required,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.decision === 'hold' || result.decision === 'review') {
|
||||
res.status(202).json({
|
||||
status: 'pending_review',
|
||||
decision: result.decision,
|
||||
reason_codes: result.reason_codes,
|
||||
audit_event_id: result.audit_event_id,
|
||||
manual_review_required: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
(req as Request & { complianceAuditId?: string }).complianceAuditId = result.audit_event_id;
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { omnlComplianceGateMiddleware } from './omnl-compliance-gate';
|
||||
import { omnlIdempotencyMiddleware } from './omnl-idempotency';
|
||||
|
||||
function mockRes(): Response & { statusCode?: number; body?: unknown; headers: Record<string, string> } {
|
||||
const headers: Record<string, string> = {};
|
||||
const res = {
|
||||
headers,
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
setHeader(name: string, value: string) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return res as Response & typeof res;
|
||||
}
|
||||
|
||||
function mockReq(overrides: Partial<Request> & { path?: string; method?: string } = {}): Request {
|
||||
return {
|
||||
method: 'POST',
|
||||
path: '/api/v1/omnl/iso20022/messages',
|
||||
headers: {},
|
||||
body: { messageType: 'pacs.008', payload: '<xml/>' },
|
||||
...overrides,
|
||||
} as Request;
|
||||
}
|
||||
|
||||
describe('omnlComplianceGateMiddleware', () => {
|
||||
const oldGate = process.env.OMNL_COMPLIANCE_GATE;
|
||||
|
||||
afterEach(() => {
|
||||
if (oldGate === undefined) delete process.env.OMNL_COMPLIANCE_GATE;
|
||||
else process.env.OMNL_COMPLIANCE_GATE = oldGate;
|
||||
});
|
||||
|
||||
it('passes through when OMNL_COMPLIANCE_GATE is unset', async () => {
|
||||
delete process.env.OMNL_COMPLIANCE_GATE;
|
||||
const next = jest.fn();
|
||||
await omnlComplianceGateMiddleware(mockReq(), mockRes(), next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets compliance headers and passes when gate enabled', async () => {
|
||||
process.env.OMNL_COMPLIANCE_GATE = '1';
|
||||
const next = jest.fn();
|
||||
const res = mockRes();
|
||||
await omnlComplianceGateMiddleware(mockReq(), res, next);
|
||||
expect(res.headers['x-omnl-compliance-decision']).toBeDefined();
|
||||
expect(res.headers['x-omnl-audit-event-id']).toBeDefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips non-POST methods', async () => {
|
||||
process.env.OMNL_COMPLIANCE_GATE = '1';
|
||||
const next = jest.fn();
|
||||
await omnlComplianceGateMiddleware(mockReq({ method: 'GET' }), mockRes(), next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('omnlIdempotencyMiddleware', () => {
|
||||
it('passes when X-Idempotency-Key is absent', () => {
|
||||
const next = jest.fn();
|
||||
omnlIdempotencyMiddleware(mockReq(), mockRes(), next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 409 on duplicate idempotency key', () => {
|
||||
const req = mockReq({
|
||||
headers: { 'x-idempotency-key': 'idem-test-duplicate-key' },
|
||||
});
|
||||
const next = jest.fn();
|
||||
omnlIdempotencyMiddleware(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
|
||||
const res = mockRes();
|
||||
const next2 = jest.fn();
|
||||
omnlIdempotencyMiddleware(req, res, next2);
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(next2).not.toHaveBeenCalled();
|
||||
expect(res.body).toMatchObject({ error: 'duplicate_idempotency_key' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { deriveIdempotencyKey, IdempotencyStore } from '@dbis/integration-foundation';
|
||||
|
||||
const store = new IdempotencyStore();
|
||||
|
||||
const IDEMPOTENT_PATHS = ['/omnl/iso20022/messages'];
|
||||
|
||||
/**
|
||||
* Dedup financial POSTs when X-Idempotency-Key header is present.
|
||||
*/
|
||||
export function omnlIdempotencyMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.method !== 'POST') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const path = req.path.replace(/^\/api\/v1/, '');
|
||||
if (!IDEMPOTENT_PATHS.some((p) => path.endsWith(p))) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const headerKey = String(req.headers['x-idempotency-key'] ?? '').trim();
|
||||
if (!headerKey) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = deriveIdempotencyKey({
|
||||
tenantId: String(req.headers['x-tenant-id'] ?? 'omnl'),
|
||||
entityId: String(req.headers['x-entity-id'] ?? 'default'),
|
||||
operation: path,
|
||||
resourceId: headerKey,
|
||||
});
|
||||
|
||||
if (!store.record(key)) {
|
||||
res.status(409).json({ error: 'duplicate_idempotency_key', idempotencyKey: headerKey });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { Router, Request, Response } from 'express';
|
||||
import { omnlRateLimiter } from '../middleware/rate-limit';
|
||||
import { omnlSensitiveRouteGuard, omnlRequireApiKeyInProduction } from '../middleware/omnl-guards';
|
||||
import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware';
|
||||
import { omnlComplianceGateMiddleware } from '../middleware/omnl-compliance-gate';
|
||||
import { omnlIdempotencyMiddleware } from '../middleware/omnl-idempotency';
|
||||
import { runTripleStateReconcile } from '../../services/omnl-triple-reconcile';
|
||||
import {
|
||||
buildIfrs7Disclosure,
|
||||
@@ -31,6 +33,8 @@ const router = Router();
|
||||
router.use(omnlRateLimiter);
|
||||
router.use(omnlAuditMiddleware);
|
||||
router.use(omnlRequireApiKeyInProduction);
|
||||
router.use(omnlIdempotencyMiddleware);
|
||||
router.use(omnlComplianceGateMiddleware);
|
||||
|
||||
router.get('/omnl/reconcile/triple-state', omnlSensitiveRouteGuard, async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { omnlRateLimiter } from '../middleware/rate-limit';
|
||||
import { omnlSensitiveRouteGuard, omnlRequireApiKeyInProduction } from '../middleware/omnl-guards';
|
||||
import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware';
|
||||
import {
|
||||
buildSettlementTerminalContext,
|
||||
buildFullTerminalPackage,
|
||||
getTerminalArtifact,
|
||||
renderTerminalScreen,
|
||||
type TerminalArtifact,
|
||||
type TerminalScreenMode,
|
||||
} from '../../services/omnl-settlement-terminal';
|
||||
import {
|
||||
listTerminalConnectionIds,
|
||||
loadTerminalConnection,
|
||||
buildConnectionPublicSummary,
|
||||
connectionAlchemyApiKey,
|
||||
} from '../../services/omnl-terminal-connection';
|
||||
import {
|
||||
alchemyJsonRpc,
|
||||
alchemyTokenPricesByAddress,
|
||||
alchemyPrepareErc20Transfer,
|
||||
alchemyConfigured,
|
||||
type AlchemyNetwork,
|
||||
} from '../../services/omnl-alchemy-client';
|
||||
import { appendOmnlAudit } from '../../services/omnl-audit-log';
|
||||
|
||||
const router = Router();
|
||||
router.use(omnlRateLimiter);
|
||||
router.use(omnlAuditMiddleware);
|
||||
router.use(omnlRequireApiKeyInProduction);
|
||||
|
||||
function parseQueryContext(req: Request): {
|
||||
settlementRef?: string;
|
||||
officeId?: number;
|
||||
valueDate?: string;
|
||||
amountUsd?: string;
|
||||
connectionId?: string;
|
||||
} {
|
||||
const officeRaw = String(req.query.officeId ?? '').trim();
|
||||
return {
|
||||
settlementRef: String(req.query.settlementRef ?? '').trim() || undefined,
|
||||
officeId: officeRaw ? parseInt(officeRaw, 10) : undefined,
|
||||
valueDate: String(req.query.valueDate ?? '').trim() || undefined,
|
||||
amountUsd: String(req.query.amountUsd ?? '').trim() || undefined,
|
||||
connectionId: String(req.query.connectionId ?? '').trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAlchemyKey(connectionId?: string): string | undefined {
|
||||
if (!connectionId) return undefined;
|
||||
try {
|
||||
const profile = loadTerminalConnection(connectionId);
|
||||
const key = connectionAlchemyApiKey(profile);
|
||||
return key || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArtifact(raw: string): TerminalArtifact {
|
||||
const v = raw.trim().toLowerCase();
|
||||
const allowed: TerminalArtifact[] = [
|
||||
'package',
|
||||
'proof-of-funds',
|
||||
'debit-note',
|
||||
'remittance-advice',
|
||||
'tokenization-sanitized',
|
||||
'conversion-swift-copy',
|
||||
];
|
||||
return (allowed.includes(v as TerminalArtifact) ? v : 'package') as TerminalArtifact;
|
||||
}
|
||||
|
||||
async function withContext(
|
||||
req: Request,
|
||||
res: Response,
|
||||
fn: (ctx: Awaited<ReturnType<typeof buildSettlementTerminalContext>>) => void | Promise<void>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ctx = await buildSettlementTerminalContext(parseQueryContext(req));
|
||||
await fn(ctx);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/omnl/terminal/status', omnlSensitiveRouteGuard, (req, res) => {
|
||||
const connectionId = String(req.query.connectionId ?? '').trim() || undefined;
|
||||
res.json({
|
||||
service: 'omnl-settlement-terminal',
|
||||
version: '1.1.0',
|
||||
alchemyConfigured: alchemyConfigured(resolveAlchemyKey(connectionId)),
|
||||
fineractEnv: Boolean(process.env.OMNL_FINERACT_BASE_URL && process.env.OMNL_FINERACT_PASSWORD),
|
||||
ui: '/omnl/terminal',
|
||||
connections: listTerminalConnectionIds(),
|
||||
activeConnectionId: connectionId,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/omnl/terminal/connections', omnlSensitiveRouteGuard, (_req, res) => {
|
||||
const ids = listTerminalConnectionIds();
|
||||
res.json({
|
||||
connections: ids.map((id) => {
|
||||
try {
|
||||
return buildConnectionPublicSummary(loadTerminalConnection(id));
|
||||
} catch (e) {
|
||||
return { connectionId: id, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/omnl/terminal/connections/:connectionId', omnlSensitiveRouteGuard, (req, res) => {
|
||||
try {
|
||||
const profile = loadTerminalConnection(String(req.params.connectionId));
|
||||
res.json(buildConnectionPublicSummary(profile));
|
||||
} catch (e) {
|
||||
res.status(404).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/omnl/terminal/package', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
await withContext(req, res, (ctx) => {
|
||||
res.json(buildFullTerminalPackage(ctx));
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/omnl/terminal/proof-of-funds', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
await withContext(req, res, (ctx) => {
|
||||
res.json(getTerminalArtifact(ctx, 'proof-of-funds'));
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/omnl/terminal/debit-note', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
await withContext(req, res, (ctx) => {
|
||||
res.json(getTerminalArtifact(ctx, 'debit-note'));
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/omnl/terminal/remittance-advice', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
await withContext(req, res, (ctx) => {
|
||||
res.json(getTerminalArtifact(ctx, 'remittance-advice'));
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/omnl/terminal/tokenization-sanitized', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
await withContext(req, res, (ctx) => {
|
||||
res.json(getTerminalArtifact(ctx, 'tokenization-sanitized'));
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/omnl/terminal/conversion-swift-copy', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
await withContext(req, res, (ctx) => {
|
||||
res.json(getTerminalArtifact(ctx, 'conversion-swift-copy'));
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/omnl/terminal/screen', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
await withContext(req, res, (ctx) => {
|
||||
const modeRaw = String(req.query.mode ?? 'black').trim().toLowerCase();
|
||||
const mode: TerminalScreenMode = modeRaw === 'color' ? 'color' : 'black';
|
||||
const artifact = parseArtifact(String(req.query.artifact ?? 'package'));
|
||||
const text = renderTerminalScreen(ctx, mode, artifact);
|
||||
const format = String(req.query.format ?? 'json').trim().toLowerCase();
|
||||
if (format === 'text' || format === 'plain') {
|
||||
res.type('text/plain; charset=utf-8').send(text);
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
mode,
|
||||
artifact,
|
||||
text,
|
||||
context: ctx,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/omnl/terminal/alchemy/rpc', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
try {
|
||||
const network = (String(req.body?.network ?? 'eth-mainnet').trim() ||
|
||||
'eth-mainnet') as AlchemyNetwork;
|
||||
const connectionId = String(req.body?.connectionId ?? req.query.connectionId ?? '').trim();
|
||||
const apiKey = resolveAlchemyKey(connectionId || undefined);
|
||||
if (!alchemyConfigured(apiKey)) {
|
||||
res.status(503).json({ error: 'Alchemy API key unset for connection' });
|
||||
return;
|
||||
}
|
||||
const method = String(req.body?.method ?? '').trim();
|
||||
const params = Array.isArray(req.body?.params) ? req.body.params : [];
|
||||
if (!method) {
|
||||
res.status(400).json({ error: 'JSON body must include method' });
|
||||
return;
|
||||
}
|
||||
const result = await alchemyJsonRpc(network, method, params, apiKey);
|
||||
appendOmnlAudit({
|
||||
category: 'api',
|
||||
action: 'alchemy_rpc',
|
||||
metadata: { network, method },
|
||||
status: 'ok',
|
||||
});
|
||||
res.json({ network, method, result });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/omnl/terminal/alchemy/prices', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
try {
|
||||
const connectionId = String(req.body?.connectionId ?? req.query.connectionId ?? '').trim();
|
||||
const apiKey = resolveAlchemyKey(connectionId || undefined);
|
||||
if (!alchemyConfigured(apiKey)) {
|
||||
res.status(503).json({ error: 'Alchemy API key unset for connection' });
|
||||
return;
|
||||
}
|
||||
const network = (String(req.body?.network ?? 'eth-mainnet').trim() ||
|
||||
'eth-mainnet') as AlchemyNetwork;
|
||||
const addresses = Array.isArray(req.body?.addresses) ? req.body.addresses.map(String) : [];
|
||||
if (!addresses.length) {
|
||||
res.status(400).json({ error: 'JSON body must include addresses: string[]' });
|
||||
return;
|
||||
}
|
||||
const data = await alchemyTokenPricesByAddress(network, addresses, apiKey);
|
||||
appendOmnlAudit({
|
||||
category: 'api',
|
||||
action: 'alchemy_prices',
|
||||
metadata: { network, count: addresses.length },
|
||||
status: 'ok',
|
||||
});
|
||||
res.json({ network, data });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/omnl/terminal/alchemy/tx-prep', omnlSensitiveRouteGuard, async (req, res) => {
|
||||
try {
|
||||
const connectionId = String(req.body?.connectionId ?? req.query.connectionId ?? '').trim();
|
||||
const apiKey = resolveAlchemyKey(connectionId || undefined);
|
||||
if (!alchemyConfigured(apiKey)) {
|
||||
res.status(503).json({ error: 'Alchemy API key unset for connection' });
|
||||
return;
|
||||
}
|
||||
const network = (String(req.body?.network ?? 'eth-mainnet').trim() ||
|
||||
'eth-mainnet') as AlchemyNetwork;
|
||||
const from = String(req.body?.from ?? '').trim();
|
||||
const to = String(req.body?.to ?? '').trim();
|
||||
const tokenAddress = String(req.body?.tokenAddress ?? '').trim();
|
||||
const amountHuman = String(req.body?.amount ?? req.body?.amountHuman ?? '').trim();
|
||||
const dryRun = req.body?.dryRun !== false;
|
||||
if (!from || !to || !tokenAddress || !amountHuman) {
|
||||
res.status(400).json({
|
||||
error: 'Required: from, to, tokenAddress, amount (human-readable token units)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const prep = await alchemyPrepareErc20Transfer({
|
||||
network,
|
||||
from,
|
||||
to,
|
||||
tokenAddress,
|
||||
amountHuman,
|
||||
dryRun,
|
||||
apiKeyOverride: apiKey,
|
||||
});
|
||||
appendOmnlAudit({
|
||||
category: 'api',
|
||||
action: 'alchemy_tx_prep',
|
||||
metadata: { network, from, to, tokenAddress, dryRun },
|
||||
status: 'ok',
|
||||
});
|
||||
res.json(prep);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import {
|
||||
buildProofIndex,
|
||||
getLatestReserveCapacitySnapshot,
|
||||
refreshReserveCapacityCache,
|
||||
} from '../../services/gru-reserve-capacity';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /reserve/capacity — semi-public live reserve capacity + multi-point proof quorum.
|
||||
* Public (no OMNL API key). Cached 10s; ?refresh=1 forces recompute when live scheduler enabled.
|
||||
*/
|
||||
router.get('/reserve/capacity', cacheMiddleware(10 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const forceRefresh = req.query.refresh === '1';
|
||||
let snapshot = getLatestReserveCapacitySnapshot();
|
||||
const maxAgeSec = snapshot?.sla?.freshnessMaxAgeSeconds ?? 60;
|
||||
const stale =
|
||||
!snapshot ||
|
||||
(Date.now() - new Date(snapshot.generatedAt).getTime()) / 1000 > maxAgeSec;
|
||||
|
||||
if (forceRefresh || stale) {
|
||||
snapshot = await refreshReserveCapacityCache(
|
||||
String(req.query.set || process.env.GRU_RESERVE_CAPACITY_MINIMUM_SET || 'reserveCapacityLive'),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
res.status(503).json({
|
||||
error: 'Reserve capacity snapshot unavailable',
|
||||
hint: 'Enable GRU_RESERVE_CAPACITY_LIVE=1 or run pnpm gru:reserve-capacity:collect',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Cache-Control', 'public, max-age=10, stale-while-revalidate=30');
|
||||
res.setHeader('X-GRU-Proof-Quorum', snapshot.proofQuorum.passed ? 'pass' : 'fail');
|
||||
res.status(200).json(snapshot);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /reserve/proof-index — status of all configured proof points (P1–P7).
|
||||
*/
|
||||
router.get('/reserve/proof-index', cacheMiddleware(15 * 1000), (_req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=15');
|
||||
res.json(buildProofIndex());
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -21,7 +21,9 @@ import plannerV2Routes from './routes/planner-v2';
|
||||
import omnlRoutes from './routes/omnl';
|
||||
import omnlIpsasRoutes from './routes/omnl-ipsas';
|
||||
import omnlComplianceRoutes from './routes/omnl-compliance-routes';
|
||||
import omnlTerminalRoutes from './routes/omnl-terminal-routes';
|
||||
import checkpointRoutes from './routes/checkpoint';
|
||||
import reserveCapacityRoutes from './routes/reserve-capacity';
|
||||
import { MultiChainIndexer } from '../indexer/chain-indexer';
|
||||
import { OmnlEventPoller } from '../indexer/omnl-event-poller';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
@@ -120,12 +122,25 @@ export class ApiServer {
|
||||
} as const;
|
||||
this.app.use('/static', express.static(publicPath, staticOpts));
|
||||
this.app.use('/omnl/static', express.static(publicPath, staticOpts));
|
||||
this.app.use('/reserve/static', express.static(publicPath, staticOpts));
|
||||
}
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
// Health check
|
||||
this.app.get('/health', async (req: Request, res: Response) => {
|
||||
if (process.env.OMNL_SETTLEMENT_TERMINAL_ONLY === '1') {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
mode: 'omnl-settlement-terminal-only',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
database: 'skipped',
|
||||
indexer: 'disabled',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Check database connection
|
||||
const pool = getDatabasePool();
|
||||
@@ -150,6 +165,9 @@ export class ApiServer {
|
||||
|
||||
const dashboardPath = path.join(__dirname, '../../public/omnl-dashboard.html');
|
||||
const complianceConsolePath = path.join(__dirname, '../../public/omnl-compliance-console.html');
|
||||
const settlementTerminalPath = path.join(__dirname, '../../public/omnl-settlement-terminal.html');
|
||||
const reserveDashboardPath = path.join(__dirname, '../../public/reserve-dashboard.html');
|
||||
const reserveInstitutionalPath = path.join(__dirname, '../../public/reserve-institutional.html');
|
||||
|
||||
const authorizeOmnlHtml = (req: Request, res: Response): boolean => {
|
||||
const tok = process.env.OMNL_DASHBOARD_TOKEN?.trim();
|
||||
@@ -181,6 +199,34 @@ export class ApiServer {
|
||||
res.type('html').send(readFileSync(dashboardPath, 'utf8'));
|
||||
});
|
||||
|
||||
this.app.get('/omnl/terminal', (req: Request, res: Response) => {
|
||||
if (!authorizeOmnlHtml(req, res)) return;
|
||||
if (!existsSync(settlementTerminalPath)) {
|
||||
res.status(404).type('text/plain').send('omnl-settlement-terminal.html missing');
|
||||
return;
|
||||
}
|
||||
res.type('html').send(readFileSync(settlementTerminalPath, 'utf8'));
|
||||
});
|
||||
|
||||
/** Public semi-public reserve capacity dashboard (no auth). */
|
||||
this.app.get('/reserve', (_req: Request, res: Response) => {
|
||||
if (!existsSync(reserveDashboardPath)) {
|
||||
res.status(404).type('text/plain').send('reserve-dashboard.html missing');
|
||||
return;
|
||||
}
|
||||
res.type('html').send(readFileSync(reserveDashboardPath, 'utf8'));
|
||||
});
|
||||
|
||||
/** Institutional wrapper (same API; used by reserve.d-bis.org NPM upstream). */
|
||||
this.app.get('/reserve/institutional', (_req: Request, res: Response) => {
|
||||
const file = existsSync(reserveInstitutionalPath) ? reserveInstitutionalPath : reserveDashboardPath;
|
||||
if (!existsSync(file)) {
|
||||
res.status(404).type('text/plain').send('reserve dashboard missing');
|
||||
return;
|
||||
}
|
||||
res.type('html').send(readFileSync(file, 'utf8'));
|
||||
});
|
||||
|
||||
// Public API catalog (register before routers so GET /api/v1 is not swallowed by middleware-only mounts)
|
||||
const sendApiV1Catalog = (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
@@ -209,6 +255,9 @@ export class ApiServer {
|
||||
plannerRoutesPlan: '/api/v2/routes/plan',
|
||||
plannerIntentsPlan: '/api/v2/intents/plan',
|
||||
plannerInternalExecutionPlan: '/api/v2/routes/internal-execution-plan',
|
||||
reserveCapacity: '/api/v1/reserve/capacity',
|
||||
reserveProofIndex: '/api/v1/reserve/proof-index',
|
||||
reserveDashboard: '/reserve',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -228,7 +277,9 @@ export class ApiServer {
|
||||
this.app.use('/api/v1', aggregatorRouteMatrixRoutes);
|
||||
this.app.use('/api/v1', partnerPayloadRoutes);
|
||||
this.app.use('/api/v1', checkpointRoutes);
|
||||
this.app.use('/api/v1', reserveCapacityRoutes);
|
||||
this.app.use('/api/v1', omnlComplianceRoutes);
|
||||
this.app.use('/api/v1', omnlTerminalRoutes);
|
||||
this.app.use('/api/v1', omnlRoutes);
|
||||
this.app.use('/api/v1', omnlIpsasRoutes);
|
||||
this.app.use('/api/v2', plannerV2Routes);
|
||||
@@ -248,6 +299,7 @@ export class ApiServer {
|
||||
omnlOpenApi: '/api/v1/omnl/openapi.json',
|
||||
omnlCatalog: '/api/v1/omnl/catalog',
|
||||
omnlDashboard: '/omnl/dashboard',
|
||||
reserveDashboard: '/reserve',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiServer } from './api/server';
|
||||
import { closeDatabasePool } from './database/client';
|
||||
import { logger } from './utils/logger';
|
||||
import { startRouteMatrixScheduler } from './services/route-matrix-scheduler';
|
||||
import { startGruReserveCapacityScheduler } from './services/gru-reserve-capacity-scheduler';
|
||||
|
||||
// Load smom-dbis-138 root .env first (single source); works from dist/ or src/
|
||||
const rootEnvCandidates = [
|
||||
@@ -28,6 +29,7 @@ try {
|
||||
|
||||
const server = new ApiServer();
|
||||
startRouteMatrixScheduler();
|
||||
startGruReserveCapacityScheduler();
|
||||
|
||||
// Start server
|
||||
server.start().catch((error) => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import { refreshReserveCapacityCache } from './gru-reserve-capacity';
|
||||
|
||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startGruReserveCapacityScheduler(): void {
|
||||
const enabled =
|
||||
String(process.env.GRU_RESERVE_CAPACITY_LIVE || '0').toLowerCase() === '1' ||
|
||||
String(process.env.GRU_RESERVE_CAPACITY_LIVE || '').toLowerCase() === 'true';
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(
|
||||
15_000,
|
||||
parseInt(process.env.GRU_RESERVE_CAPACITY_INTERVAL_MS || '30000', 10) || 30_000,
|
||||
);
|
||||
const minimumSet = process.env.GRU_RESERVE_CAPACITY_MINIMUM_SET?.trim() || 'reserveCapacityLive';
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const snap = await refreshReserveCapacityCache(minimumSet);
|
||||
logger.info('GRU reserve capacity refreshed', {
|
||||
quorumPassed: snap.proofQuorum.passed,
|
||||
passCount: snap.proofQuorum.passCount,
|
||||
issuanceBlocked: snap.capacity.issuanceBlocked,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('GRU reserve capacity refresh failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
intervalHandle = setInterval(() => {
|
||||
void tick();
|
||||
}, intervalMs);
|
||||
|
||||
logger.info('GRU reserve capacity live scheduler enabled', { intervalMs, minimumSet });
|
||||
}
|
||||
|
||||
export function stopGruReserveCapacityScheduler(): void {
|
||||
if (intervalHandle) {
|
||||
clearInterval(intervalHandle);
|
||||
intervalHandle = null;
|
||||
}
|
||||
}
|
||||
414
services/token-aggregation/src/services/gru-reserve-capacity.ts
Normal file
414
services/token-aggregation/src/services/gru-reserve-capacity.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { Contract, JsonRpcProvider } from 'ethers';
|
||||
import { runTripleStateReconcile } from './omnl-triple-reconcile';
|
||||
|
||||
export interface ProofPointStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pass' | 'fail' | 'skip' | 'stale';
|
||||
detail: string;
|
||||
realTime?: boolean;
|
||||
}
|
||||
|
||||
export interface ReserveCapacitySnapshot {
|
||||
schemaVersion: '1.0.0';
|
||||
generatedAt: string;
|
||||
configRef: string;
|
||||
minimumSet: string;
|
||||
sla: {
|
||||
availabilityTargetPercent: number;
|
||||
freshnessMaxAgeSeconds: number;
|
||||
agreementToleranceBps: number;
|
||||
};
|
||||
capacity: {
|
||||
reserveUsd: number | null;
|
||||
m1Usd: number | null;
|
||||
m00Usd: number | null;
|
||||
coverageRatioBps: number | null;
|
||||
issuanceBlocked: boolean | null;
|
||||
issuanceBlockReasons: string[];
|
||||
velocityZone: string | null;
|
||||
};
|
||||
tripleState: {
|
||||
aligned: boolean | null;
|
||||
breaksCount: number | null;
|
||||
generatedAt: string | null;
|
||||
};
|
||||
onChainGate: {
|
||||
address: string | null;
|
||||
metricsUpdatedAt: number | null;
|
||||
coverageRatioBps: number | null;
|
||||
m1ToM00Utilization: number | null;
|
||||
issuancePaused: boolean | null;
|
||||
rpcReachable: boolean;
|
||||
};
|
||||
proofQuorum: {
|
||||
passed: boolean;
|
||||
passCount: number;
|
||||
requiredCount: number;
|
||||
minimumRequired: number;
|
||||
points: Record<string, ProofPointStatus>;
|
||||
};
|
||||
semiPublic: true;
|
||||
redactedFields: string[];
|
||||
reproduce: {
|
||||
commands: string[];
|
||||
configPaths: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface MultiPointProofConfig {
|
||||
minimumIndependentProofPoints: number;
|
||||
sla99999?: {
|
||||
freshness?: { maxAgeSeconds?: number };
|
||||
agreement?: { toleranceBps?: number };
|
||||
availability?: { targetPercent?: number };
|
||||
};
|
||||
minimumSets?: Record<
|
||||
string,
|
||||
{ requiredProofPointIds?: string[]; description?: string }
|
||||
>;
|
||||
proofPoints?: Array<{ id: string; name: string; realTime?: boolean }>;
|
||||
}
|
||||
|
||||
interface GruMetricsFile {
|
||||
generatedAt?: string;
|
||||
guardrails?: {
|
||||
coverageRatioBps?: number;
|
||||
coverageOk?: boolean;
|
||||
coverageMinBps?: number;
|
||||
issuanceBlocked?: boolean;
|
||||
issuanceBlockReasons?: string[];
|
||||
};
|
||||
classical?: { reserveRatioBps?: number };
|
||||
gruLayers?: {
|
||||
M00_liNotionalUsd?: number;
|
||||
M1_cStarUsd?: number;
|
||||
monetaryBaseUsd?: number;
|
||||
};
|
||||
physicalReserve?: { usdValue?: number };
|
||||
gruVelocity?: { zone?: string };
|
||||
onChainGate?: {
|
||||
gateAddress?: string | null;
|
||||
envVar?: string;
|
||||
metricsForUpdate?: {
|
||||
coverageRatioBps?: number;
|
||||
m1ToM00Utilization?: number;
|
||||
issuancePaused?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let cachedSnapshot: ReserveCapacitySnapshot | null = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
function projectRoot(): string {
|
||||
return (
|
||||
process.env.PROXMOX_ROOT?.trim() ||
|
||||
process.env.PHOENIX_REPO_ROOT?.trim() ||
|
||||
resolve(__dirname, '../../../../../..')
|
||||
);
|
||||
}
|
||||
|
||||
function readOptionalJson<T>(rel: string): T | null {
|
||||
const p = resolve(projectRoot(), rel);
|
||||
if (!existsSync(p)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ageSeconds(iso?: string | null): number {
|
||||
if (!iso) return Infinity;
|
||||
return (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
}
|
||||
|
||||
function loadProofConfig(): MultiPointProofConfig | null {
|
||||
return readOptionalJson<MultiPointProofConfig>('config/compliance/gru-multi-point-proof.v1.json');
|
||||
}
|
||||
|
||||
const GATE_ABI = [
|
||||
'function metrics() view returns (uint256 coverageRatioBps, uint256 m1ToM00Utilization, uint8 velocityZone, bool issuancePaused, uint256 metricsUpdatedAt)',
|
||||
];
|
||||
|
||||
async function readOnChainGate(): Promise<ReserveCapacitySnapshot['onChainGate']> {
|
||||
const cfg = readOptionalJson<{ policyGate138?: { envVar?: string } }>(
|
||||
'config/compliance/gru-monetary-metrics.v1.json',
|
||||
);
|
||||
const envVar = cfg?.policyGate138?.envVar || 'GRU_MONETARY_POLICY_GATE_138';
|
||||
const address = process.env[envVar]?.trim() || null;
|
||||
const rpc = process.env.RPC_URL_138 || process.env.RPC_URL;
|
||||
if (!address || !rpc) {
|
||||
return {
|
||||
address,
|
||||
metricsUpdatedAt: null,
|
||||
coverageRatioBps: null,
|
||||
m1ToM00Utilization: null,
|
||||
issuancePaused: null,
|
||||
rpcReachable: false,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const c = new Contract(address, GATE_ABI, new JsonRpcProvider(rpc, 138));
|
||||
const [coverageRatioBps, m1ToM00Utilization, , issuancePaused, metricsUpdatedAt] =
|
||||
await c.metrics();
|
||||
return {
|
||||
address,
|
||||
metricsUpdatedAt: Number(metricsUpdatedAt),
|
||||
coverageRatioBps: Number(coverageRatioBps),
|
||||
m1ToM00Utilization: Number(m1ToM00Utilization),
|
||||
issuancePaused: Boolean(issuancePaused),
|
||||
rpcReachable: true,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
address,
|
||||
metricsUpdatedAt: null,
|
||||
coverageRatioBps: null,
|
||||
m1ToM00Utilization: null,
|
||||
issuancePaused: null,
|
||||
rpcReachable: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateProofPoints(
|
||||
cfg: MultiPointProofConfig,
|
||||
minimumSetKey: string,
|
||||
metrics: GruMetricsFile | null,
|
||||
triple: Awaited<ReturnType<typeof runTripleStateReconcile>> | null,
|
||||
maxAgeSeconds: number,
|
||||
): ReserveCapacitySnapshot['proofQuorum'] {
|
||||
const minSet = cfg.minimumSets?.[minimumSetKey];
|
||||
const required = minSet?.requiredProofPointIds || ['P3', 'P4', 'P6'];
|
||||
const nameById = new Map((cfg.proofPoints || []).map((p) => [p.id, p.name]));
|
||||
const rtById = new Map((cfg.proofPoints || []).map((p) => [p.id, p.realTime]));
|
||||
const points: Record<string, ProofPointStatus> = {};
|
||||
let passCount = 0;
|
||||
|
||||
for (const id of required) {
|
||||
const point: ProofPointStatus = {
|
||||
id,
|
||||
name: nameById.get(id) || id,
|
||||
status: 'skip',
|
||||
detail: '',
|
||||
realTime: rtById.get(id),
|
||||
};
|
||||
|
||||
switch (id) {
|
||||
case 'P3':
|
||||
case 'P6': {
|
||||
if (!metrics) {
|
||||
point.detail = 'gru-monetary-metrics-latest.json missing';
|
||||
break;
|
||||
}
|
||||
const age = ageSeconds(metrics.generatedAt);
|
||||
const g = metrics.guardrails || {};
|
||||
const coverage = g.coverageRatioBps ?? metrics.classical?.reserveRatioBps;
|
||||
const blocked = g.issuanceBlocked === true;
|
||||
const coverageOk =
|
||||
g.coverageOk === true ||
|
||||
(coverage !== undefined && Number(coverage) >= Number(g.coverageMinBps ?? 12000));
|
||||
const fresh = age <= maxAgeSeconds;
|
||||
point.status = !fresh ? 'stale' : coverageOk && !blocked ? 'pass' : 'fail';
|
||||
point.detail = `age=${Math.round(age)}s coverageBps=${coverage} blocked=${blocked}`;
|
||||
break;
|
||||
}
|
||||
case 'P4': {
|
||||
if (!triple) {
|
||||
point.detail = 'triple-state reconcile unavailable';
|
||||
break;
|
||||
}
|
||||
point.status = triple.aligned ? 'pass' : 'fail';
|
||||
point.detail = `breaks=${triple.breaks.length} aligned=${triple.aligned}`;
|
||||
break;
|
||||
}
|
||||
case 'P1': {
|
||||
const anchor = existsSync(
|
||||
resolve(projectRoot(), 'reports/status/vermil-ucc-20242014504/HASH_NOTARIZATION_ANCHOR.txt'),
|
||||
);
|
||||
point.status = anchor ? 'pass' : 'skip';
|
||||
point.detail = anchor ? 'UCC anchor present' : 'missing UCC anchor';
|
||||
break;
|
||||
}
|
||||
case 'P2': {
|
||||
const reconDir = resolve(projectRoot(), 'reconciliation');
|
||||
let count = 0;
|
||||
if (existsSync(reconDir)) {
|
||||
count = readdirSync(reconDir).filter((d) => d.startsWith('audit-office')).length;
|
||||
}
|
||||
point.status = count > 0 ? 'pass' : 'skip';
|
||||
point.detail = `audit folders=${count}`;
|
||||
break;
|
||||
}
|
||||
case 'P5': {
|
||||
const ms = existsSync(resolve(projectRoot(), 'config/compliance/web3-multisig-deployed.v1.json'));
|
||||
point.status = ms ? 'pass' : 'skip';
|
||||
point.detail = ms ? 'multisig config present' : 'missing web3-multisig-deployed';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
point.detail = 'no evaluator';
|
||||
}
|
||||
|
||||
if (point.status === 'pass') passCount += 1;
|
||||
points[id] = point;
|
||||
}
|
||||
|
||||
const minimumRequired = cfg.minimumIndependentProofPoints || 3;
|
||||
return {
|
||||
passed: passCount >= minimumRequired,
|
||||
passCount,
|
||||
requiredCount: required.length,
|
||||
minimumRequired,
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildReserveCapacitySnapshot(
|
||||
minimumSetKey = 'reserveCapacityLive',
|
||||
): Promise<ReserveCapacitySnapshot> {
|
||||
const proofCfg = loadProofConfig();
|
||||
const maxAge = proofCfg?.sla99999?.freshness?.maxAgeSeconds ?? 60;
|
||||
const metrics = readOptionalJson<GruMetricsFile>('reports/status/gru-monetary-metrics-latest.json');
|
||||
|
||||
let triple: Awaited<ReturnType<typeof runTripleStateReconcile>> | null = null;
|
||||
try {
|
||||
triple = await runTripleStateReconcile(process.env.OMNL_HEALTH_LINE_ID?.trim() || undefined);
|
||||
} catch {
|
||||
triple = null;
|
||||
}
|
||||
|
||||
const fileTriple = readOptionalJson<Awaited<ReturnType<typeof runTripleStateReconcile>>>(
|
||||
'reports/status/omnl-triple-reconcile-latest.json',
|
||||
);
|
||||
const fileTripleMaxAgeSec = Number(process.env.GRU_RESERVE_FILE_TRIPLE_MAX_AGE_SEC || 3600);
|
||||
const fileTripleFresh =
|
||||
fileTriple?.aligned === true && ageSeconds(fileTriple.generatedAt) <= fileTripleMaxAgeSec;
|
||||
if ((!triple?.aligned || (triple.breaks?.length ?? 0) > 0) && fileTripleFresh) {
|
||||
triple = fileTriple;
|
||||
} else if (!triple && fileTriple) {
|
||||
triple = fileTriple;
|
||||
}
|
||||
|
||||
const onChainGate = await readOnChainGate();
|
||||
const g = metrics?.guardrails;
|
||||
|
||||
const snapshot: ReserveCapacitySnapshot = {
|
||||
schemaVersion: '1.0.0',
|
||||
generatedAt: new Date().toISOString(),
|
||||
configRef: 'config/compliance/gru-multi-point-proof.v1.json',
|
||||
minimumSet: minimumSetKey,
|
||||
sla: {
|
||||
availabilityTargetPercent: proofCfg?.sla99999?.availability?.targetPercent ?? 99.999,
|
||||
freshnessMaxAgeSeconds: maxAge,
|
||||
agreementToleranceBps: proofCfg?.sla99999?.agreement?.toleranceBps ?? 50,
|
||||
},
|
||||
capacity: {
|
||||
reserveUsd: metrics?.physicalReserve?.usdValue ?? metrics?.gruLayers?.monetaryBaseUsd ?? null,
|
||||
m1Usd: metrics?.gruLayers?.M1_cStarUsd ?? null,
|
||||
m00Usd: metrics?.gruLayers?.M00_liNotionalUsd ?? null,
|
||||
coverageRatioBps: g?.coverageRatioBps ?? metrics?.classical?.reserveRatioBps ?? null,
|
||||
issuanceBlocked: g?.issuanceBlocked ?? null,
|
||||
issuanceBlockReasons: g?.issuanceBlockReasons || [],
|
||||
velocityZone: metrics?.gruVelocity?.zone ?? null,
|
||||
},
|
||||
tripleState: {
|
||||
aligned: triple?.aligned ?? null,
|
||||
breaksCount: triple?.breaks?.length ?? null,
|
||||
generatedAt: triple?.generatedAt ?? null,
|
||||
},
|
||||
onChainGate,
|
||||
proofQuorum: proofCfg
|
||||
? evaluateProofPoints(proofCfg, minimumSetKey, metrics, triple, maxAge)
|
||||
: {
|
||||
passed: false,
|
||||
passCount: 0,
|
||||
requiredCount: 3,
|
||||
minimumRequired: 3,
|
||||
points: {},
|
||||
},
|
||||
semiPublic: true,
|
||||
redactedFields: [
|
||||
'fineractGlDetail',
|
||||
'custodianStatementPath',
|
||||
'privateKeyPaths',
|
||||
'fullAuditPackets',
|
||||
],
|
||||
reproduce: {
|
||||
commands: [
|
||||
'pnpm compliance:gru:monetary-metrics',
|
||||
'pnpm omnl:reconcile-triple-state',
|
||||
'pnpm gru:validate-multi-point-proof',
|
||||
],
|
||||
configPaths: [
|
||||
'config/compliance/gru-multi-point-proof.v1.json',
|
||||
'config/compliance/gru-monetary-metrics.v1.json',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function writeReserveCapacitySnapshot(snapshot: ReserveCapacitySnapshot): string {
|
||||
const outRel = 'reports/status/gru-reserve-capacity-latest.json';
|
||||
const outPath = resolve(projectRoot(), outRel);
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, `${JSON.stringify(snapshot, null, 2)}\n`);
|
||||
return outPath;
|
||||
}
|
||||
|
||||
export async function refreshReserveCapacityCache(
|
||||
minimumSetKey = 'reserveCapacityLive',
|
||||
): Promise<ReserveCapacitySnapshot> {
|
||||
const snapshot = await buildReserveCapacitySnapshot(minimumSetKey);
|
||||
try {
|
||||
writeReserveCapacitySnapshot(snapshot);
|
||||
} catch {
|
||||
/* optional when read-only mount */
|
||||
}
|
||||
cachedSnapshot = snapshot;
|
||||
cachedAt = Date.now();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function getLatestReserveCapacitySnapshot(): ReserveCapacitySnapshot | null {
|
||||
if (cachedSnapshot && Date.now() - cachedAt < 45_000) {
|
||||
return cachedSnapshot;
|
||||
}
|
||||
const fromDisk = readOptionalJson<ReserveCapacitySnapshot>('reports/status/gru-reserve-capacity-latest.json');
|
||||
if (fromDisk) {
|
||||
cachedSnapshot = fromDisk;
|
||||
cachedAt = Date.now();
|
||||
return fromDisk;
|
||||
}
|
||||
return cachedSnapshot;
|
||||
}
|
||||
|
||||
export function buildProofIndex(): Record<string, unknown> {
|
||||
const cfg = loadProofConfig();
|
||||
const latest = getLatestReserveCapacitySnapshot();
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
configVersion: cfg?.minimumIndependentProofPoints ? '1.0.0' : null,
|
||||
minimumSets: cfg?.minimumSets ?? {},
|
||||
proofPoints: (cfg?.proofPoints || []).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
realTime: p.realTime,
|
||||
status: latest?.proofQuorum.points[p.id]?.status ?? 'unknown',
|
||||
detail: latest?.proofQuorum.points[p.id]?.detail ?? null,
|
||||
})),
|
||||
quorum: latest?.proofQuorum ?? null,
|
||||
sla: latest?.sla ?? null,
|
||||
publicSurfaces: {
|
||||
capacity: '/api/v1/reserve/capacity',
|
||||
proofIndex: '/api/v1/reserve/proof-index',
|
||||
tripleState: '/api/v1/omnl/reconcile/triple-state',
|
||||
},
|
||||
};
|
||||
}
|
||||
137
services/token-aggregation/src/services/omnl-alchemy-client.ts
Normal file
137
services/token-aggregation/src/services/omnl-alchemy-client.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import axios from 'axios';
|
||||
import { Interface, parseUnits } from 'ethers';
|
||||
|
||||
export type AlchemyNetwork = 'eth-mainnet' | 'defi-oracle-meta-mainnet';
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function balanceOf(address) view returns (uint256)',
|
||||
'function decimals() view returns (uint8)',
|
||||
'function transfer(address to, uint256 amount) returns (bool)',
|
||||
];
|
||||
|
||||
function alchemyKey(override?: string): string {
|
||||
const key = override?.trim() || process.env.ALCHEMY_API_KEY?.trim();
|
||||
if (!key) {
|
||||
throw new Error('ALCHEMY_API_KEY unset — required for Alchemy terminal operations');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function rpcUrl(network: AlchemyNetwork, apiKeyOverride?: string): string {
|
||||
const key = alchemyKey(apiKeyOverride);
|
||||
if (network === 'eth-mainnet') {
|
||||
return `https://eth-mainnet.g.alchemy.com/v2/${key}`;
|
||||
}
|
||||
return `https://defi-oracle-meta-mainnet.g.alchemy.com/v2/${key}`;
|
||||
}
|
||||
|
||||
export async function alchemyJsonRpc<T = unknown>(
|
||||
network: AlchemyNetwork,
|
||||
method: string,
|
||||
params: unknown[] = [],
|
||||
apiKeyOverride?: string
|
||||
): Promise<T> {
|
||||
const allowed = new Set(
|
||||
(process.env.OMNL_TERMINAL_ALCHEMY_METHODS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
const defaultAllowed = [
|
||||
'eth_blockNumber',
|
||||
'eth_getBalance',
|
||||
'eth_call',
|
||||
'eth_estimateGas',
|
||||
'eth_gasPrice',
|
||||
'eth_getTransactionCount',
|
||||
'eth_getTransactionReceipt',
|
||||
'eth_getTransactionByHash',
|
||||
];
|
||||
const methods = allowed.size > 0 ? allowed : new Set(defaultAllowed);
|
||||
if (!methods.has(method)) {
|
||||
throw new Error(`Alchemy RPC method not allowed: ${method}`);
|
||||
}
|
||||
const { data } = await axios.post(
|
||||
rpcUrl(network, apiKeyOverride),
|
||||
{ jsonrpc: '2.0', id: 1, method, params },
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message || JSON.stringify(data.error));
|
||||
}
|
||||
return data.result as T;
|
||||
}
|
||||
|
||||
export async function alchemyTokenPricesByAddress(
|
||||
network: AlchemyNetwork,
|
||||
addresses: string[],
|
||||
apiKeyOverride?: string
|
||||
): Promise<unknown> {
|
||||
const key = alchemyKey(apiKeyOverride);
|
||||
const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-address`;
|
||||
const { data } = await axios.post(
|
||||
url,
|
||||
{
|
||||
addresses: addresses.map((address) => ({ network, address })),
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function alchemyPrepareErc20Transfer(input: {
|
||||
network: AlchemyNetwork;
|
||||
from: string;
|
||||
to: string;
|
||||
tokenAddress: string;
|
||||
amountHuman: string;
|
||||
dryRun?: boolean;
|
||||
apiKeyOverride?: string;
|
||||
}): Promise<{
|
||||
network: AlchemyNetwork;
|
||||
from: string;
|
||||
to: string;
|
||||
tokenAddress: string;
|
||||
amountRaw: string;
|
||||
data: string;
|
||||
gasEstimate: string | null;
|
||||
nonce: string | null;
|
||||
dryRun: boolean;
|
||||
note: string;
|
||||
}> {
|
||||
const iface = new Interface(ERC20_ABI);
|
||||
const provider = rpcUrl(input.network, input.apiKeyOverride);
|
||||
const decimalsHex = await axios.post(provider, {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'eth_call',
|
||||
params: [{ to: input.tokenAddress, data: iface.encodeFunctionData('decimals', []) }, 'latest'],
|
||||
});
|
||||
const dec = parseInt(String(decimalsHex.data?.result || '0x6'), 16);
|
||||
const amountRaw = parseUnits(input.amountHuman, dec).toString();
|
||||
const data = iface.encodeFunctionData('transfer', [input.to, amountRaw]);
|
||||
let gasEstimate: string | null = null;
|
||||
let nonce: string | null = null;
|
||||
if (!input.dryRun) {
|
||||
gasEstimate = await alchemyJsonRpc<string>(input.network, 'eth_estimateGas', [
|
||||
{ from: input.from, to: input.tokenAddress, data },
|
||||
], input.apiKeyOverride);
|
||||
nonce = await alchemyJsonRpc<string>(input.network, 'eth_getTransactionCount', [input.from, 'latest'], input.apiKeyOverride);
|
||||
}
|
||||
return {
|
||||
network: input.network,
|
||||
from: input.from,
|
||||
to: input.tokenAddress,
|
||||
tokenAddress: input.tokenAddress,
|
||||
amountRaw,
|
||||
data,
|
||||
gasEstimate,
|
||||
nonce,
|
||||
dryRun: input.dryRun !== false,
|
||||
note: 'Unsigned transfer calldata only — broadcast requires separate operator signing workflow.',
|
||||
};
|
||||
}
|
||||
|
||||
export function alchemyConfigured(apiKeyOverride?: string): boolean {
|
||||
return Boolean(apiKeyOverride?.trim() || process.env.ALCHEMY_API_KEY?.trim());
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function getOmnlApiCatalog(): {
|
||||
return {
|
||||
service: 'HYBX OMNL',
|
||||
basePath: '/api/v1',
|
||||
version: '1.3.0',
|
||||
version: '1.4.0',
|
||||
endpoints: [
|
||||
{ method: 'GET', path: '/omnl/openapi.json', description: 'OpenAPI 3.0 JSON (Swagger-compatible)', auth: 'none' },
|
||||
{ method: 'GET', path: '/omnl/catalog', description: 'This catalog (machine-readable)', auth: 'none' },
|
||||
@@ -53,6 +53,17 @@ export function getOmnlApiCatalog(): {
|
||||
{ method: 'GET', path: '/omnl/ipsas/fineract-compare', description: 'Registry vs live GL accounts', auth: 'OMNL_API_KEY if set' },
|
||||
{ method: 'GET', path: '/omnl/ipsas/layer/:layer', description: 'Monetary layer hint', auth: 'none' },
|
||||
{ method: 'GET', path: '/omnl/ipsas/compliance-context/:lineId', description: 'Compliance + IPSAS guidance', query: ['aggregated'], auth: 'OMNL_API_KEY if set' },
|
||||
{ method: 'GET', path: '/omnl/terminal/status', description: 'Settlement terminal integration status', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/terminal/package', description: 'Full settlement terminal JSON (POF, debit note, remittance, tokenization, SWIFT copy, screens)', query: ['settlementRef', 'officeId', 'valueDate', 'amountUsd'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/terminal/proof-of-funds', description: 'Proof of Funds JSON (Fineract GL 2100)', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/terminal/debit-note', description: 'Debit note JSON mapped to IPSAS journal pair', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/terminal/remittance-advice', description: 'Remittance advice JSON (camt.054-equivalent)', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/terminal/tokenization-sanitized', description: 'Tokenization record with redacted PII for external distribution', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/terminal/conversion-swift-copy', description: 'Internal conversion SWIFT-field copy (not wire confirmation)', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/terminal/screen', description: 'Terminal screen text (black/green or gray/black)', query: ['mode', 'artifact', 'format'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'POST', path: '/omnl/terminal/alchemy/rpc', description: 'Alchemy JSON-RPC (read/estimate methods)', body: '{ network, method, params }', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'POST', path: '/omnl/terminal/alchemy/prices', description: 'Alchemy token prices by address', body: '{ network, addresses[] }', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'POST', path: '/omnl/terminal/alchemy/tx-prep', description: 'Prepare unsigned ERC-20 transfer calldata via Alchemy (dry-run default)', body: '{ network, from, to, tokenAddress, amount, dryRun }', auth: 'OMNL_API_KEY' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
buildProofOfFunds,
|
||||
buildDebitNote,
|
||||
buildRemittanceAdvice,
|
||||
buildTokenizationSanitized,
|
||||
buildConversionSwiftCopy,
|
||||
renderTerminalScreen,
|
||||
type SettlementTerminalContext,
|
||||
} from './omnl-settlement-terminal';
|
||||
|
||||
jest.mock('./omnl-alchemy-client', () => ({
|
||||
alchemyConfigured: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('./omnl-ipsas-gl', () => ({
|
||||
fetchFineractGlAccounts: jest.fn(),
|
||||
}));
|
||||
|
||||
function sampleCtx(): SettlementTerminalContext {
|
||||
return {
|
||||
generatedAtUtc: '2026-06-07T12:00:00.000Z',
|
||||
settlementRef: 'HYBX-BATCH-001',
|
||||
valueDate: '2026-03-17',
|
||||
currency: 'USD',
|
||||
officeId: 1,
|
||||
officeName: 'OMNL Head Office (DBIS) – Central Bank',
|
||||
beneficiary: 'Bank Kanaya (Indonesia)',
|
||||
beneficiaryOfficeId: 22,
|
||||
beneficiaryExternalId: 'BANK-KANAYA-ID',
|
||||
amountUsd: '1000000000.00',
|
||||
omnlLegalName: 'ORGANISATION MONDIALE DU NUMERIQUE L.P.B.C.',
|
||||
omnlLei: '98450070C57395F6B906',
|
||||
fineract: {
|
||||
configured: true,
|
||||
pofPrimaryGlCode: '2100',
|
||||
pofPrimaryAmountUsd: '50000000000.00',
|
||||
glBalances: [{ glCode: '2100', name: 'M1', side: 'Cr', amount: '50000000000.00' }],
|
||||
},
|
||||
alchemy: {
|
||||
configured: false,
|
||||
mainnetNetwork: 'eth-mainnet',
|
||||
chain138Network: 'defi-oracle-meta-mainnet',
|
||||
},
|
||||
documentSha256: 'abc123',
|
||||
};
|
||||
}
|
||||
|
||||
describe('omnl-settlement-terminal', () => {
|
||||
it('builds proof of funds with primary GL attestation', () => {
|
||||
const pof = buildProofOfFunds(sampleCtx());
|
||||
expect(pof.documentType).toBe('proof-of-funds');
|
||||
expect(pof.attestation).toMatchObject({ primaryGlCode: '2100' });
|
||||
});
|
||||
|
||||
it('builds debit note with accounting pair', () => {
|
||||
const dn = buildDebitNote(sampleCtx());
|
||||
expect(dn.documentType).toBe('debit-note');
|
||||
expect(dn.accounting).toMatchObject({ debitGlCode: '2100', creditGlCode: '1410' });
|
||||
});
|
||||
|
||||
it('builds remittance advice with instruction id', () => {
|
||||
const ra = buildRemittanceAdvice(sampleCtx());
|
||||
expect(ra.documentType).toBe('remittance-advice');
|
||||
expect(String(ra.instructionId)).toContain('RA-HYBX-BATCH-001');
|
||||
});
|
||||
|
||||
it('sanitizes tokenization copy fields', () => {
|
||||
const tok = buildTokenizationSanitized(sampleCtx());
|
||||
expect(tok.documentType).toBe('tokenization-sanitized');
|
||||
expect(String(tok.accountNumber)).toMatch(/\*/);
|
||||
expect(String(tok.beneficiaryName)).toMatch(/\*/);
|
||||
});
|
||||
|
||||
it('builds conversion swift copy with MT field tags', () => {
|
||||
const sw = buildConversionSwiftCopy(sampleCtx());
|
||||
expect(sw.documentType).toBe('conversion-swift-copy');
|
||||
expect((sw.fields as Record<string, string>)[':20:']).toBe('HYBX-BATCH-001');
|
||||
});
|
||||
|
||||
it('renders black and color terminal screens', () => {
|
||||
const ctx = sampleCtx();
|
||||
const black = renderTerminalScreen(ctx, 'black', 'package');
|
||||
const color = renderTerminalScreen(ctx, 'color', 'proof-of-funds');
|
||||
expect(black).toContain('BLACK / GREEN');
|
||||
expect(color).toContain('COLOR / GRAY');
|
||||
expect(black).toContain('HYBX-BATCH-001');
|
||||
expect(color).toContain('POF GL 2100');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,623 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import axios, { type AxiosRequestConfig } from 'axios';
|
||||
import { fetchFineractGlAccounts } from './omnl-ipsas-gl';
|
||||
import { alchemyConfigured } from './omnl-alchemy-client';
|
||||
import {
|
||||
loadTerminalConnection,
|
||||
buildConnectionPublicSummary,
|
||||
connectionAlchemyApiKey,
|
||||
connectionReceiverConfig,
|
||||
type TerminalConnectionProfile,
|
||||
} from './omnl-terminal-connection';
|
||||
|
||||
export type TerminalScreenMode = 'black' | 'color';
|
||||
|
||||
export type TerminalConfig = {
|
||||
version: string;
|
||||
defaultSettlementRef: string;
|
||||
defaultOfficeId: number;
|
||||
defaultCurrency: string;
|
||||
defaultValueDate: string;
|
||||
defaultBeneficiary: string;
|
||||
defaultBeneficiaryOfficeId: number;
|
||||
defaultBeneficiaryExternalId: string;
|
||||
defaultConnectionId?: string;
|
||||
omnlLegalName: string;
|
||||
omnlLei: string;
|
||||
pofPrimaryGlCode: string;
|
||||
debitNoteDefaultDebitGl: string;
|
||||
debitNoteDefaultCreditGl: string;
|
||||
tokenization: {
|
||||
chain138RpcEnv: string;
|
||||
defaultLineId: string;
|
||||
sanitizedRedactFields: string[];
|
||||
};
|
||||
alchemy: {
|
||||
mainnetNetwork: string;
|
||||
chain138Network: string;
|
||||
allowedRpcMethods: string[];
|
||||
pricesApiPath: string;
|
||||
};
|
||||
disclaimers: Record<string, string>;
|
||||
};
|
||||
|
||||
export type SettlementTerminalContext = {
|
||||
generatedAtUtc: string;
|
||||
settlementRef: string;
|
||||
valueDate: string;
|
||||
currency: string;
|
||||
officeId: number;
|
||||
officeName: string;
|
||||
beneficiary: string;
|
||||
beneficiaryOfficeId: number;
|
||||
beneficiaryExternalId: string;
|
||||
amountUsd: string;
|
||||
omnlLegalName: string;
|
||||
omnlLei: string;
|
||||
fineract: {
|
||||
configured: boolean;
|
||||
baseUrl?: string;
|
||||
glCount?: number;
|
||||
pofPrimaryGlCode: string;
|
||||
pofPrimaryAmountUsd: string | null;
|
||||
glBalances?: Array<{ glCode: string; name: string; side: string; amount: string }>;
|
||||
error?: string;
|
||||
};
|
||||
alchemy: {
|
||||
configured: boolean;
|
||||
mainnetNetwork: string;
|
||||
chain138Network: string;
|
||||
};
|
||||
documentSha256: string;
|
||||
connection?: {
|
||||
connectionId: string;
|
||||
summary: Record<string, unknown>;
|
||||
receiverConfigured: boolean;
|
||||
alchemyConfigured: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type TerminalArtifact =
|
||||
| 'package'
|
||||
| 'proof-of-funds'
|
||||
| 'debit-note'
|
||||
| 'remittance-advice'
|
||||
| 'tokenization-sanitized'
|
||||
| 'conversion-swift-copy';
|
||||
|
||||
function repoRoot(): string {
|
||||
if (process.env.PROXMOX_ROOT?.trim()) return process.env.PROXMOX_ROOT.trim();
|
||||
if (process.env.PHOENIX_REPO_ROOT?.trim()) return process.env.PHOENIX_REPO_ROOT.trim();
|
||||
// dist/services → proxmox repo root (5 levels)
|
||||
return resolve(__dirname, '../../../../..');
|
||||
}
|
||||
|
||||
export function loadTerminalConfig(): TerminalConfig {
|
||||
const candidates = [
|
||||
process.env.OMNL_SETTLEMENT_TERMINAL_CONFIG?.trim(),
|
||||
resolve(repoRoot(), 'config/omnl-settlement-terminal.v1.json'),
|
||||
resolve(__dirname, '../../../../config/omnl-settlement-terminal.v1.json'),
|
||||
].filter(Boolean) as string[];
|
||||
const p = candidates.find((c) => existsSync(c));
|
||||
if (!p) {
|
||||
throw new Error(`Terminal config not found (tried: ${candidates.join(', ')})`);
|
||||
}
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as TerminalConfig;
|
||||
}
|
||||
|
||||
function fineractHeaders(): { base: string; headers: 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() ||
|
||||
'app.omnl';
|
||||
const pass = process.env.OMNL_FINERACT_PASSWORD || '';
|
||||
if (!base || !pass) {
|
||||
throw new Error('OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD required');
|
||||
}
|
||||
const auth = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
return {
|
||||
base,
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Fineract-Platform-TenantId': tenant,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchOffices(): Promise<Array<{ id: number; name: string }>> {
|
||||
const { base, headers } = fineractHeaders();
|
||||
const { data } = await axios.get(`${base}/offices`, { headers, timeout: 60000 });
|
||||
const rows = Array.isArray(data) ? data : [];
|
||||
return rows.map((r: { id?: number; name?: string }) => ({
|
||||
id: Number(r.id),
|
||||
name: String(r.name || ''),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchOfficeJournalLines(officeId: number): Promise<
|
||||
Array<{ glAccountId: number; amount: number; entryType: string; transactionDate?: unknown }>
|
||||
> {
|
||||
const { base, headers } = fineractHeaders();
|
||||
const out: Array<{ glAccountId: number; amount: number; entryType: string; transactionDate?: unknown }> = [];
|
||||
let offset = 0;
|
||||
const limit = 200;
|
||||
for (;;) {
|
||||
const cfg: AxiosRequestConfig = {
|
||||
headers,
|
||||
timeout: 120000,
|
||||
params: { officeId, limit, offset },
|
||||
};
|
||||
const { data } = await axios.get(`${base}/journalentries`, cfg);
|
||||
const batch = Array.isArray(data) ? data : (data as { pageItems?: unknown[] })?.pageItems ?? [];
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
for (const je of batch) {
|
||||
const row = je as {
|
||||
debits?: Array<{ glAccountId?: number; amount?: number }>;
|
||||
credits?: Array<{ glAccountId?: number; amount?: number }>;
|
||||
transactionDate?: unknown;
|
||||
};
|
||||
for (const d of row.debits ?? []) {
|
||||
if (d.glAccountId != null) {
|
||||
out.push({
|
||||
glAccountId: d.glAccountId,
|
||||
amount: Number(d.amount || 0),
|
||||
entryType: 'DEBIT',
|
||||
transactionDate: row.transactionDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const c of row.credits ?? []) {
|
||||
if (c.glAccountId != null) {
|
||||
out.push({
|
||||
glAccountId: c.glAccountId,
|
||||
amount: Number(c.amount || 0),
|
||||
entryType: 'CREDIT',
|
||||
transactionDate: row.transactionDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (batch.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function signedGlBalances(
|
||||
lines: Array<{ glAccountId: number; amount: number; entryType: string }>,
|
||||
glById: Map<number, { glCode: string; name: string }>
|
||||
): Array<{ glCode: string; name: string; side: string; amount: string }> {
|
||||
const net = new Map<number, number>();
|
||||
for (const l of lines) {
|
||||
const cur = net.get(l.glAccountId) ?? 0;
|
||||
net.set(l.glAccountId, cur + (l.entryType === 'DEBIT' ? l.amount : -l.amount));
|
||||
}
|
||||
const rows: Array<{ glCode: string; name: string; side: string; amount: string }> = [];
|
||||
for (const [id, val] of net.entries()) {
|
||||
if (Math.abs(val) < 0.005) continue;
|
||||
const g = glById.get(id) ?? { glCode: String(id), name: '' };
|
||||
rows.push({
|
||||
glCode: g.glCode,
|
||||
name: g.name,
|
||||
side: val > 0 ? 'Dr' : 'Cr',
|
||||
amount: Math.abs(val).toFixed(2),
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => a.glCode.localeCompare(b.glCode));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function glCreditAmount(rows: Array<{ glCode: string; side: string; amount: string }>, code: string): string | null {
|
||||
for (const r of rows) {
|
||||
if (r.glCode === code) {
|
||||
if (r.side === 'Cr') return r.amount;
|
||||
if (r.side === 'Dr') return `-${r.amount}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function maskValue(field: string, value: string): string {
|
||||
if (!value) return value;
|
||||
if (field.toLowerCase().includes('swift') || field.toLowerCase().includes('iban')) {
|
||||
return value.length <= 4 ? '****' : `${'*'.repeat(Math.max(0, value.length - 4))}${value.slice(-4)}`;
|
||||
}
|
||||
if (field.toLowerCase().includes('name')) {
|
||||
const parts = value.split(/\s+/);
|
||||
return parts.map((p) => (p.length <= 2 ? p : `${p[0]}${'*'.repeat(p.length - 2)}${p.slice(-1)}`)).join(' ');
|
||||
}
|
||||
if (field.toLowerCase().includes('account')) {
|
||||
return value.length <= 4 ? '****' : `****${value.slice(-4)}`;
|
||||
}
|
||||
return '***REDACTED***';
|
||||
}
|
||||
|
||||
export async function buildSettlementTerminalContext(input: {
|
||||
settlementRef?: string;
|
||||
officeId?: number;
|
||||
valueDate?: string;
|
||||
amountUsd?: string;
|
||||
connectionId?: string;
|
||||
}): Promise<SettlementTerminalContext> {
|
||||
const cfg = loadTerminalConfig();
|
||||
let connectionProfile: TerminalConnectionProfile | null = null;
|
||||
const connId = input.connectionId?.trim() || cfg.defaultConnectionId?.trim() || '';
|
||||
if (connId) {
|
||||
try {
|
||||
connectionProfile = loadTerminalConnection(connId);
|
||||
} catch {
|
||||
connectionProfile = null;
|
||||
}
|
||||
}
|
||||
const sd = connectionProfile?.settlementDefaults;
|
||||
const settlementRef =
|
||||
input.settlementRef?.trim() || sd?.settlementRef || cfg.defaultSettlementRef;
|
||||
const officeId = input.officeId ?? cfg.defaultOfficeId;
|
||||
const valueDate = input.valueDate?.trim() || cfg.defaultValueDate;
|
||||
const amountUsd = input.amountUsd?.trim() || '1000000000.00';
|
||||
|
||||
let officeName = `Office ${officeId}`;
|
||||
let glBalances: Array<{ glCode: string; name: string; side: string; amount: string }> = [];
|
||||
let pofAmount: string | null = null;
|
||||
let fineractError: string | undefined;
|
||||
let glCount = 0;
|
||||
let baseUrl: string | undefined;
|
||||
|
||||
try {
|
||||
const { base } = fineractHeaders();
|
||||
baseUrl = base;
|
||||
const [offices, glRows, lines] = await Promise.all([
|
||||
fetchOffices(),
|
||||
fetchFineractGlAccounts(),
|
||||
fetchOfficeJournalLines(officeId),
|
||||
]);
|
||||
glCount = glRows.length;
|
||||
const hit = offices.find((o) => o.id === officeId);
|
||||
if (hit) officeName = hit.name;
|
||||
const glById = new Map<number, { glCode: string; name: string }>();
|
||||
for (const g of glRows) {
|
||||
if (g.id != null && g.glCode) {
|
||||
glById.set(g.id, { glCode: String(g.glCode), name: String(g.name || '') });
|
||||
}
|
||||
}
|
||||
glBalances = signedGlBalances(lines, glById);
|
||||
pofAmount = glCreditAmount(glBalances, cfg.pofPrimaryGlCode);
|
||||
} catch (e) {
|
||||
fineractError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const ctx: SettlementTerminalContext = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
settlementRef,
|
||||
valueDate,
|
||||
currency: cfg.defaultCurrency,
|
||||
officeId,
|
||||
officeName,
|
||||
beneficiary: sd?.beneficiary || cfg.defaultBeneficiary,
|
||||
beneficiaryOfficeId: cfg.defaultBeneficiaryOfficeId,
|
||||
beneficiaryExternalId: sd?.beneficiaryExternalId || cfg.defaultBeneficiaryExternalId,
|
||||
amountUsd,
|
||||
omnlLegalName: cfg.omnlLegalName,
|
||||
omnlLei: cfg.omnlLei,
|
||||
fineract: {
|
||||
configured: !fineractError,
|
||||
baseUrl,
|
||||
glCount,
|
||||
pofPrimaryGlCode: cfg.pofPrimaryGlCode,
|
||||
pofPrimaryAmountUsd: pofAmount,
|
||||
glBalances: glBalances.length ? glBalances : undefined,
|
||||
error: fineractError,
|
||||
},
|
||||
alchemy: {
|
||||
configured: connectionProfile
|
||||
? Boolean(connectionAlchemyApiKey(connectionProfile))
|
||||
: alchemyConfigured(),
|
||||
mainnetNetwork: cfg.alchemy.mainnetNetwork,
|
||||
chain138Network: cfg.alchemy.chain138Network,
|
||||
},
|
||||
documentSha256: '',
|
||||
};
|
||||
if (connectionProfile) {
|
||||
const receiver = connectionReceiverConfig(connectionProfile);
|
||||
ctx.connection = {
|
||||
connectionId: connectionProfile.connectionId,
|
||||
summary: buildConnectionPublicSummary(connectionProfile),
|
||||
receiverConfigured: receiver.configured,
|
||||
alchemyConfigured: Boolean(connectionAlchemyApiKey(connectionProfile)),
|
||||
};
|
||||
ctx.currency = sd?.currency || ctx.currency;
|
||||
}
|
||||
ctx.documentSha256 = createHash('sha256').update(JSON.stringify(ctx)).digest('hex');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function buildProofOfFunds(ctx: SettlementTerminalContext): Record<string, unknown> {
|
||||
const cfg = loadTerminalConfig();
|
||||
return {
|
||||
documentType: 'proof-of-funds',
|
||||
generatedAtUtc: ctx.generatedAtUtc,
|
||||
settlementRef: ctx.settlementRef,
|
||||
valueDate: ctx.valueDate,
|
||||
issuer: {
|
||||
legalName: ctx.omnlLegalName,
|
||||
lei: ctx.omnlLei,
|
||||
officeId: ctx.officeId,
|
||||
officeName: ctx.officeName,
|
||||
},
|
||||
attestation: {
|
||||
primaryGlCode: ctx.fineract.pofPrimaryGlCode,
|
||||
primaryAmountUsd: ctx.fineract.pofPrimaryAmountUsd,
|
||||
currency: ctx.currency,
|
||||
methodology: 'Fineract journal sum (DEBIT +, CREDIT -) for office GL balances',
|
||||
},
|
||||
glBalances: ctx.fineract.glBalances ?? [],
|
||||
disclaimer: cfg.disclaimers.pof,
|
||||
integrity: { sha256: ctx.documentSha256 },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDebitNote(ctx: SettlementTerminalContext): Record<string, unknown> {
|
||||
const cfg = loadTerminalConfig();
|
||||
const uetr = randomUUID();
|
||||
return {
|
||||
documentType: 'debit-note',
|
||||
generatedAtUtc: ctx.generatedAtUtc,
|
||||
settlementRef: ctx.settlementRef,
|
||||
valueDate: ctx.valueDate,
|
||||
uetr,
|
||||
debitNoteId: `DN-${ctx.settlementRef}-${ctx.valueDate.replace(/-/g, '')}`,
|
||||
orderingInstitution: {
|
||||
name: ctx.omnlLegalName,
|
||||
lei: ctx.omnlLei,
|
||||
officeId: ctx.officeId,
|
||||
},
|
||||
beneficiary: {
|
||||
name: ctx.beneficiary,
|
||||
officeId: ctx.beneficiaryOfficeId,
|
||||
externalId: ctx.beneficiaryExternalId,
|
||||
...(ctx.connection?.summary?.client ? { client: ctx.connection.summary.client } : {}),
|
||||
...(ctx.connection?.summary?.bank ? { bank: ctx.connection.summary.bank } : {}),
|
||||
},
|
||||
receiver: ctx.connection
|
||||
? {
|
||||
connectionId: ctx.connection.connectionId,
|
||||
serverId: (ctx.connection.summary.receiver as { serverId?: string })?.serverId,
|
||||
receiverIp: (ctx.connection.summary.receiver as { receiverIp?: string })?.receiverIp,
|
||||
wallet: (ctx.connection.summary as { wallet?: { address?: string } }).wallet,
|
||||
}
|
||||
: undefined,
|
||||
accounting: {
|
||||
debitGlCode: cfg.debitNoteDefaultDebitGl,
|
||||
creditGlCode: cfg.debitNoteDefaultCreditGl,
|
||||
amountUsd: ctx.amountUsd,
|
||||
currency: ctx.currency,
|
||||
narrative: `Settlement debit — ${ctx.settlementRef}`,
|
||||
},
|
||||
fineractMapped: ctx.fineract.configured,
|
||||
disclaimer: 'Accounting debit note mapped to OMNL Fineract IPSAS journal pairs — not a bank wire debit advice.',
|
||||
integrity: { sha256: createHash('sha256').update(JSON.stringify(ctx)).digest('hex') },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRemittanceAdvice(ctx: SettlementTerminalContext): Record<string, unknown> {
|
||||
const instructionId = `RA-${ctx.settlementRef}-${Date.now()}`;
|
||||
return {
|
||||
documentType: 'remittance-advice',
|
||||
generatedAtUtc: ctx.generatedAtUtc,
|
||||
settlementRef: ctx.settlementRef,
|
||||
valueDate: ctx.valueDate,
|
||||
instructionId,
|
||||
messageType: 'camt.054-equivalent',
|
||||
remitter: {
|
||||
name: ctx.omnlLegalName,
|
||||
lei: ctx.omnlLei,
|
||||
officeId: ctx.officeId,
|
||||
officeName: ctx.officeName,
|
||||
},
|
||||
beneficiary: {
|
||||
name: ctx.beneficiary,
|
||||
officeId: ctx.beneficiaryOfficeId,
|
||||
externalId: ctx.beneficiaryExternalId,
|
||||
...(ctx.connection?.summary?.client ? { client: ctx.connection.summary.client } : {}),
|
||||
...(ctx.connection?.summary?.bank ? { bank: ctx.connection.summary.bank } : {}),
|
||||
},
|
||||
receiver: ctx.connection
|
||||
? {
|
||||
connectionId: ctx.connection.connectionId,
|
||||
configured: ctx.connection.receiverConfigured,
|
||||
serverId: (ctx.connection.summary.receiver as { serverId?: string })?.serverId,
|
||||
apiDocsUrl: (ctx.connection.summary.receiver as { apiDocsUrl?: string })?.apiDocsUrl,
|
||||
}
|
||||
: undefined,
|
||||
payment: {
|
||||
amount: ctx.amountUsd,
|
||||
currency: ctx.currency,
|
||||
valueDate: ctx.valueDate,
|
||||
remittanceInfo: `Settlement ${ctx.settlementRef} — OMNL/HYBX book transfer`,
|
||||
},
|
||||
fineractStatus: ctx.fineract.configured ? 'sor_reachable' : 'sor_unavailable',
|
||||
disclaimer: 'Remittance advice for institutional reconciliation — confirm against Fineract JE and ISO 20022 archive.',
|
||||
integrity: { sha256: createHash('sha256').update(instructionId + ctx.settlementRef).digest('hex') },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTokenizationSanitized(ctx: SettlementTerminalContext): Record<string, unknown> {
|
||||
const cfg = loadTerminalConfig();
|
||||
const raw = {
|
||||
documentType: 'tokenization-sanitized',
|
||||
generatedAtUtc: ctx.generatedAtUtc,
|
||||
settlementRef: ctx.settlementRef,
|
||||
lineId: cfg.tokenization.defaultLineId,
|
||||
tokenizationRequestId: `TOK-${ctx.settlementRef}-${randomUUID().slice(0, 8)}`,
|
||||
underlyingAsset: ctx.currency,
|
||||
amount: ctx.amountUsd,
|
||||
issuer: ctx.omnlLegalName,
|
||||
beneficiaryName: ctx.beneficiary,
|
||||
orderingName: ctx.officeName,
|
||||
accountNumber: `00000000${ctx.officeId}`.slice(-12),
|
||||
iban: `US00OMNL000000000${ctx.officeId}`,
|
||||
swiftBic: 'OMNLUS33',
|
||||
chain138Line: cfg.tokenization.defaultLineId,
|
||||
policyNote: 'Sanitized copy for external distribution — full tokenization record retained operator-side.',
|
||||
};
|
||||
const sanitized: Record<string, unknown> = { ...raw };
|
||||
for (const field of cfg.tokenization.sanitizedRedactFields) {
|
||||
const val = raw[field as keyof typeof raw];
|
||||
if (typeof val === 'string') {
|
||||
sanitized[field] = maskValue(field, val);
|
||||
}
|
||||
}
|
||||
sanitized.integrity = {
|
||||
sha256: createHash('sha256').update(JSON.stringify(sanitized)).digest('hex'),
|
||||
fullRecordHint: 'POST /api/v1/omnl/iso20022/messages for retained ISO payload',
|
||||
};
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function buildConversionSwiftCopy(ctx: SettlementTerminalContext): Record<string, unknown> {
|
||||
const cfg = loadTerminalConfig();
|
||||
return {
|
||||
documentType: 'conversion-swift-copy',
|
||||
generatedAtUtc: ctx.generatedAtUtc,
|
||||
settlementRef: ctx.settlementRef,
|
||||
valueDate: ctx.valueDate,
|
||||
copyClass: 'internal-operator-conversion',
|
||||
fields: {
|
||||
':20:': ctx.settlementRef,
|
||||
':23B:': 'CRED',
|
||||
':32A:': `${ctx.valueDate.replace(/-/g, '').slice(2)}${ctx.currency}${ctx.amountUsd.replace('.', ',')}`,
|
||||
':50K:': `/${ctx.omnlLei}\n${ctx.omnlLegalName}\nOffice ${ctx.officeId}`,
|
||||
':59:': `/${ctx.beneficiaryExternalId}\n${ctx.beneficiary}`,
|
||||
':70:': `CONVERSION SETTLEMENT ${ctx.settlementRef}`,
|
||||
':71A:': 'SHA',
|
||||
},
|
||||
iso20022Equivalent: 'pacs.008 / pacs.009 mapping available via /api/v1/omnl/iso20022/messages',
|
||||
fineractMapped: ctx.fineract.configured,
|
||||
disclaimer: cfg.disclaimers.swiftCopy,
|
||||
integrity: { sha256: createHash('sha256').update(ctx.settlementRef + ctx.valueDate).digest('hex') },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFullTerminalPackage(ctx: SettlementTerminalContext): Record<string, unknown> {
|
||||
const cfg = loadTerminalConfig();
|
||||
const artifacts = {
|
||||
proofOfFunds: buildProofOfFunds(ctx),
|
||||
debitNote: buildDebitNote(ctx),
|
||||
remittanceAdvice: buildRemittanceAdvice(ctx),
|
||||
tokenizationSanitized: buildTokenizationSanitized(ctx),
|
||||
conversionSwiftCopy: buildConversionSwiftCopy(ctx),
|
||||
screens: {
|
||||
black: renderTerminalScreen(ctx, 'black', 'package'),
|
||||
color: renderTerminalScreen(ctx, 'color', 'package'),
|
||||
},
|
||||
};
|
||||
return {
|
||||
documentType: 'omnl-settlement-terminal-package',
|
||||
version: cfg.version,
|
||||
generatedAtUtc: ctx.generatedAtUtc,
|
||||
settlementRef: ctx.settlementRef,
|
||||
context: ctx,
|
||||
artifacts,
|
||||
disclaimers: cfg.disclaimers,
|
||||
integrity: {
|
||||
packageSha256: createHash('sha256').update(JSON.stringify(artifacts)).digest('hex'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function artifactPayload(
|
||||
ctx: SettlementTerminalContext,
|
||||
artifact: TerminalArtifact
|
||||
): Record<string, unknown> {
|
||||
switch (artifact) {
|
||||
case 'proof-of-funds':
|
||||
return buildProofOfFunds(ctx);
|
||||
case 'debit-note':
|
||||
return buildDebitNote(ctx);
|
||||
case 'remittance-advice':
|
||||
return buildRemittanceAdvice(ctx);
|
||||
case 'tokenization-sanitized':
|
||||
return buildTokenizationSanitized(ctx);
|
||||
case 'conversion-swift-copy':
|
||||
return buildConversionSwiftCopy(ctx);
|
||||
case 'package':
|
||||
default:
|
||||
return buildFullTerminalPackage(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTerminalScreen(
|
||||
ctx: SettlementTerminalContext,
|
||||
mode: TerminalScreenMode,
|
||||
artifact: TerminalArtifact = 'package'
|
||||
): string {
|
||||
const cfg = loadTerminalConfig();
|
||||
const lines: string[] = [];
|
||||
const hr = '='.repeat(72);
|
||||
lines.push(hr);
|
||||
lines.push(' OMNL / HYBX SETTLEMENT TERMINAL');
|
||||
lines.push(` MODE: ${mode === 'black' ? 'BLACK / GREEN' : 'COLOR / GRAY'}`);
|
||||
lines.push(` ARTIFACT: ${artifact.toUpperCase()}`);
|
||||
lines.push(hr);
|
||||
lines.push(` SETTLEMENT REF .... ${ctx.settlementRef}`);
|
||||
lines.push(` VALUE DATE ........ ${ctx.valueDate}`);
|
||||
lines.push(` OFFICE ............ ${ctx.officeId} — ${ctx.officeName}`);
|
||||
lines.push(` BENEFICIARY ....... ${ctx.beneficiary}`);
|
||||
lines.push(` AMOUNT USD ........ ${ctx.amountUsd}`);
|
||||
lines.push(` GENERATED UTC ..... ${ctx.generatedAtUtc}`);
|
||||
lines.push('-'.repeat(72));
|
||||
lines.push(' FINERACT SoR');
|
||||
lines.push(` STATUS .......... ${ctx.fineract.configured ? 'REACHABLE' : 'UNAVAILABLE'}`);
|
||||
if (ctx.fineract.baseUrl) lines.push(` BASE URL ........ ${ctx.fineract.baseUrl}`);
|
||||
if (ctx.fineract.glCount != null) lines.push(` GL ACCOUNTS ..... ${ctx.fineract.glCount}`);
|
||||
lines.push(
|
||||
` POF GL ${ctx.fineract.pofPrimaryGlCode} .... ${ctx.fineract.pofPrimaryAmountUsd ?? 'N/A'} ${ctx.currency}`
|
||||
);
|
||||
if (ctx.fineract.error) lines.push(` ERROR ........... ${ctx.fineract.error}`);
|
||||
lines.push('-'.repeat(72));
|
||||
lines.push(' ALCHEMY');
|
||||
lines.push(` CONFIGURED ...... ${ctx.alchemy.configured ? 'YES' : 'NO (set ALCHEMY_API_KEY)'}`);
|
||||
lines.push(` MAINNET ......... ${ctx.alchemy.mainnetNetwork}`);
|
||||
lines.push(` CHAIN 138 ....... ${ctx.alchemy.chain138Network}`);
|
||||
if (ctx.connection) {
|
||||
lines.push('-'.repeat(72));
|
||||
lines.push(' RECEIVER CONNECTION');
|
||||
lines.push(` ID .............. ${ctx.connection.connectionId}`);
|
||||
const recv = ctx.connection.summary.receiver as { serverId?: string; receiverIp?: string };
|
||||
if (recv?.serverId) lines.push(` SERVER .......... ${recv.serverId}`);
|
||||
if (recv?.receiverIp) lines.push(` IP .............. ${recv.receiverIp}`);
|
||||
const wallet = (ctx.connection.summary as { wallet?: { address?: string } }).wallet?.address;
|
||||
if (wallet) lines.push(` ETH WALLET ...... ${wallet}`);
|
||||
lines.push(` RECEIVER API .... ${ctx.connection.receiverConfigured ? 'CONFIGURED' : 'KEYS UNSET'}`);
|
||||
lines.push(` ALCHEMY CONN .... ${ctx.connection.alchemyConfigured ? 'CONFIGURED' : 'KEY UNSET'}`);
|
||||
}
|
||||
lines.push('-'.repeat(72));
|
||||
if (artifact !== 'package') {
|
||||
const doc = artifactPayload(ctx, artifact);
|
||||
lines.push(` DOCUMENT: ${String(doc.documentType || artifact)}`);
|
||||
if (doc.disclaimer) lines.push(` NOTE: ${String(doc.disclaimer)}`);
|
||||
if (typeof doc.integrity === 'object' && doc.integrity && 'sha256' in (doc.integrity as object)) {
|
||||
lines.push(` SHA256: ${String((doc.integrity as { sha256: string }).sha256).slice(0, 16)}...`);
|
||||
}
|
||||
} else {
|
||||
lines.push(' ARTIFACTS: POF | DEBIT-NOTE | REMITTANCE | TOKEN-SAN | SWIFT-COPY');
|
||||
}
|
||||
lines.push('-'.repeat(72));
|
||||
lines.push(` ${cfg.disclaimers.terminalScreen}`);
|
||||
lines.push(hr);
|
||||
lines.push(' END OF TERMINAL COPY');
|
||||
lines.push(hr);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function getTerminalArtifact(
|
||||
ctx: SettlementTerminalContext,
|
||||
artifact: TerminalArtifact
|
||||
): Record<string, unknown> {
|
||||
return artifactPayload(ctx, artifact);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { readFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { resolve, join } from 'path';
|
||||
|
||||
export type TerminalConnectionProfile = {
|
||||
connectionId: string;
|
||||
version: string;
|
||||
cisDate?: string;
|
||||
client: {
|
||||
companyName: string;
|
||||
companyRegistrationNo?: string;
|
||||
address?: string;
|
||||
jurisdiction?: string;
|
||||
representedBy?: Record<string, string>;
|
||||
};
|
||||
bank?: {
|
||||
name?: string;
|
||||
accountName?: string;
|
||||
accountNumber?: string;
|
||||
iban?: string;
|
||||
swiftBic?: string;
|
||||
};
|
||||
receiver?: {
|
||||
serverId?: string;
|
||||
serverLabel?: string;
|
||||
receiverIp?: string;
|
||||
apiDocsUrl?: string;
|
||||
apiBaseUrlEnv?: string;
|
||||
apiAuthKeyEnv?: string;
|
||||
};
|
||||
alchemy?: {
|
||||
network?: string;
|
||||
apiKeyEnv?: string;
|
||||
fallbackApiKeyEnv?: string;
|
||||
};
|
||||
wallet?: {
|
||||
chain?: string;
|
||||
address?: string;
|
||||
purpose?: string;
|
||||
};
|
||||
settlementDefaults?: {
|
||||
settlementRef?: string;
|
||||
beneficiary?: string;
|
||||
beneficiaryExternalId?: string;
|
||||
currency?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function connectionsRoot(): string {
|
||||
const root =
|
||||
process.env.PROXMOX_ROOT?.trim() ||
|
||||
process.env.PHOENIX_REPO_ROOT?.trim() ||
|
||||
resolve(__dirname, '../../../../..');
|
||||
return (
|
||||
process.env.OMNL_TERMINAL_CONNECTIONS_DIR?.trim() ||
|
||||
resolve(root, 'config/omnl-terminal-connections')
|
||||
);
|
||||
}
|
||||
|
||||
export function listTerminalConnectionIds(): string[] {
|
||||
const dir = connectionsRoot();
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
.filter((id) => existsSync(join(dir, id, 'connection.v1.json')))
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function loadTerminalConnection(connectionId: string): TerminalConnectionProfile {
|
||||
const id = connectionId.trim();
|
||||
if (!id || /[^a-z0-9-]/i.test(id)) {
|
||||
throw new Error('Invalid connectionId');
|
||||
}
|
||||
const p = join(connectionsRoot(), id, 'connection.v1.json');
|
||||
if (!existsSync(p)) {
|
||||
throw new Error(`Connection profile not found: ${id}`);
|
||||
}
|
||||
const profile = JSON.parse(readFileSync(p, 'utf8')) as TerminalConnectionProfile;
|
||||
if (profile.connectionId !== id) {
|
||||
throw new Error(`connectionId mismatch: folder ${id} vs JSON ${profile.connectionId}`);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function connectionProjectDir(connectionId: string): string {
|
||||
return join(connectionsRoot(), connectionId.trim());
|
||||
}
|
||||
|
||||
export function resolveConnectionSecret(profile: TerminalConnectionProfile, primaryEnv: string, fallbackEnv?: string): string {
|
||||
const primary = process.env[primaryEnv]?.trim();
|
||||
if (primary) return primary;
|
||||
if (fallbackEnv) {
|
||||
const fb = process.env[fallbackEnv]?.trim();
|
||||
if (fb) return fb;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function connectionAlchemyApiKey(profile: TerminalConnectionProfile): string {
|
||||
const primary = profile.alchemy?.apiKeyEnv || 'MK_ALBERT_GATE_ALCHEMY_API_KEY';
|
||||
const fallback = profile.alchemy?.fallbackApiKeyEnv || 'ALCHEMY_API_KEY';
|
||||
return resolveConnectionSecret(profile, primary, fallback);
|
||||
}
|
||||
|
||||
export function connectionReceiverConfig(profile: TerminalConnectionProfile): {
|
||||
baseUrl: string;
|
||||
authKey: string;
|
||||
configured: boolean;
|
||||
} {
|
||||
const baseEnv = profile.receiver?.apiBaseUrlEnv || 'MK_ALBERT_GATE_RECEIVER_API_BASE_URL';
|
||||
const authEnv = profile.receiver?.apiAuthKeyEnv || 'MK_ALBERT_GATE_RECEIVER_API_AUTH_KEY';
|
||||
const baseUrl = process.env[baseEnv]?.trim() || '';
|
||||
const authKey = process.env[authEnv]?.trim() || '';
|
||||
return { baseUrl, authKey, configured: Boolean(baseUrl && authKey) };
|
||||
}
|
||||
|
||||
export function buildConnectionPublicSummary(profile: TerminalConnectionProfile): Record<string, unknown> {
|
||||
const alchemyKey = connectionAlchemyApiKey(profile);
|
||||
const receiver = connectionReceiverConfig(profile);
|
||||
return {
|
||||
connectionId: profile.connectionId,
|
||||
version: profile.version,
|
||||
cisDate: profile.cisDate,
|
||||
client: profile.client,
|
||||
bank: profile.bank
|
||||
? {
|
||||
...profile.bank,
|
||||
accountNumber: profile.bank.accountNumber
|
||||
? `****${String(profile.bank.accountNumber).slice(-4)}`
|
||||
: undefined,
|
||||
iban: profile.bank.iban ? `****${String(profile.bank.iban).slice(-4)}` : undefined,
|
||||
}
|
||||
: undefined,
|
||||
receiver: {
|
||||
serverId: profile.receiver?.serverId,
|
||||
serverLabel: profile.receiver?.serverLabel,
|
||||
receiverIp: profile.receiver?.receiverIp,
|
||||
apiDocsUrl: profile.receiver?.apiDocsUrl,
|
||||
configured: receiver.configured,
|
||||
},
|
||||
wallet: profile.wallet,
|
||||
settlementDefaults: profile.settlementDefaults,
|
||||
secrets: {
|
||||
alchemyConfigured: Boolean(alchemyKey),
|
||||
receiverApiConfigured: receiver.configured,
|
||||
},
|
||||
projectDir: connectionProjectDir(profile.connectionId),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MockReconciliationEngine, type ReconciliationSnapshot } from '@dbis/integration-foundation';
|
||||
import axios from 'axios';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
@@ -20,6 +21,8 @@ export interface TripleReconcileReport {
|
||||
lineId: string;
|
||||
breaks: TripleReconcileBreak[];
|
||||
aligned: boolean;
|
||||
/** Optional HYBX↔Fineract↔chain ref reconcile via integration-foundation (OMNL_FOUNDATION_RECONCILE=1). */
|
||||
foundationSnapshot?: ReconciliationSnapshot;
|
||||
fineract: {
|
||||
glSnapshot: Array<{ glCode: string; name?: string; id: number }>;
|
||||
liabilityGlCodes: string[];
|
||||
@@ -203,15 +206,48 @@ export async function runTripleStateReconcile(lineId?: string): Promise<TripleRe
|
||||
}
|
||||
|
||||
const aligned = breaks.filter((b) => b.severity === 'critical' || b.severity === 'high').length === 0;
|
||||
|
||||
let foundationSnapshot: ReconciliationSnapshot | undefined;
|
||||
if (process.env.OMNL_FOUNDATION_RECONCILE === '1') {
|
||||
const hybxRefs = (process.env.OMNL_RECON_HYBX_REFS ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const fineractRefs = (process.env.OMNL_RECON_FINERACT_REFS ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const chainRefs = (process.env.OMNL_RECON_CHAIN_REFS ?? line)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const engine = new MockReconciliationEngine();
|
||||
foundationSnapshot = await engine.reconcileTripleState({
|
||||
tenantId: process.env.OMNL_FINERACT_TENANT?.trim() || 'omnl',
|
||||
entityId: process.env.OMNL_RECON_ENTITY_ID?.trim() || 'omnl-default',
|
||||
hybxRefs,
|
||||
fineractRefs,
|
||||
chainRefs,
|
||||
});
|
||||
for (const brk of foundationSnapshot.breaks) {
|
||||
breaks.push({
|
||||
code: `FOUNDATION_${brk.type.toUpperCase()}`,
|
||||
severity: brk.type === 'missing_event' ? 'high' : 'medium',
|
||||
message: `${brk.source}: ${brk.referenceId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const report: TripleReconcileReport = {
|
||||
generatedAt,
|
||||
lineId: line,
|
||||
breaks,
|
||||
aligned,
|
||||
aligned: breaks.filter((b) => b.severity === 'critical' || b.severity === 'high').length === 0,
|
||||
fineract: { glSnapshot, liabilityGlCodes: liabilityCodes },
|
||||
onChain,
|
||||
custodian,
|
||||
comparison,
|
||||
foundationSnapshot,
|
||||
};
|
||||
|
||||
appendOmnlAudit({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { computeWebhookSignature, verifyWebhookSignature } from '@dbis/integration-foundation';
|
||||
import { logger } from '../utils/logger';
|
||||
import { appendOmnlAudit } from './omnl-audit-log';
|
||||
|
||||
@@ -23,24 +23,23 @@ function parseWebhookUrls(): string[] {
|
||||
|
||||
/** HMAC-SHA256(hex) of UTF-8 body; header value `sha256=<hex>`. */
|
||||
export function signOmnlWebhookBody(rawBodyUtf8: string, secret: string): string {
|
||||
const h = createHmac('sha256', secret).update(rawBodyUtf8, 'utf8').digest('hex');
|
||||
return `sha256=${h}`;
|
||||
return `sha256=${computeWebhookSignature(secret, rawBodyUtf8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify `X-OMNL-Signature` from `signOmnlWebhookBody` (timing-safe).
|
||||
*/
|
||||
export function verifyOmnlWebhookSignature(rawBodyUtf8: string, signatureHeader: string | undefined, secret: string): boolean {
|
||||
export function verifyOmnlWebhookSignature(
|
||||
rawBodyUtf8: string,
|
||||
signatureHeader: string | undefined,
|
||||
secret: string
|
||||
): boolean {
|
||||
if (!signatureHeader || !secret) return false;
|
||||
const expected = signOmnlWebhookBody(rawBodyUtf8, secret);
|
||||
try {
|
||||
const a = Buffer.from(signatureHeader.trim(), 'utf8');
|
||||
const b = Buffer.from(expected, 'utf8');
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return verifyWebhookSignature({
|
||||
secret,
|
||||
payload: rawBodyUtf8,
|
||||
signature: signatureHeader.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user