feat: wire production settlement rails for M2 mint, transfers, and bank SWIFT
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m31s
CI/CD Pipeline / Security Scanning (push) Successful in 3m4s
CI/CD Pipeline / Lint and Format (push) Failing after 45s
CI/CD Pipeline / Terraform Validation (push) Failing after 28s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 28s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 44s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 36s
Validation / validate-genesis (push) Successful in 30s
Validation / validate-terraform (push) Failing after 30s
Validation / validate-kubernetes (push) Failing after 10s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m52s
Validation / validate-documentation (push) Failing after 18s
Verify Deployment / Verify Deployment (push) Failing after 57s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m31s
CI/CD Pipeline / Security Scanning (push) Successful in 3m4s
CI/CD Pipeline / Lint and Format (push) Failing after 45s
CI/CD Pipeline / Terraform Validation (push) Failing after 28s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 28s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 44s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 36s
Validation / validate-genesis (push) Successful in 30s
Validation / validate-terraform (push) Failing after 30s
Validation / validate-kubernetes (push) Failing after 10s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m52s
Validation / validate-documentation (push) Failing after 18s
Verify Deployment / Verify Deployment (push) Failing after 57s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,6 @@ OMNL_BANK_PUBLIC_URL=https://bank.omnl.hybxfinance.io
|
||||
VITE_TOKEN_AGGREGATION_URL=https://bank.omnl.hybxfinance.io/api/v1
|
||||
VITE_SETTLEMENT_MIDDLEWARE_URL=https://bank.omnl.hybxfinance.io/settlement
|
||||
VITE_DBIS_EXCHANGE_URL=https://bank.omnl.hybxfinance.io/exchange
|
||||
VITE_OMNL_API_KEY=
|
||||
|
||||
# === Production configs ===
|
||||
SETTLEMENT_MIDDLEWARE_CONFIG=config/settlement-middleware.production.v1.json
|
||||
@@ -21,6 +20,15 @@ OMNL_ALLOW_CHAIN_MINT_EXECUTE=1
|
||||
|
||||
# === Auth ===
|
||||
OMNL_API_KEY=
|
||||
VITE_OMNL_API_KEY=
|
||||
|
||||
# === On-chain M2 mint / transfer (Chain 138 operator) ===
|
||||
OMNL_MINT_OPERATOR_PRIVATE_KEY=
|
||||
TOKEN_AGGREGATION_URL=http://127.0.0.1:3000
|
||||
|
||||
# === HYBX / bank rails (SWIFT + IBAN) ===
|
||||
HYBX_MIFOS_FINERACT_SIDECAR_URL=
|
||||
SWIFT_LISTENER_URL=
|
||||
|
||||
# === Primary chain 138 ===
|
||||
RPC_URL_138=http://192.168.11.221:8545
|
||||
|
||||
@@ -40,6 +40,16 @@ export function settlementApi(path: string): string {
|
||||
export function exchangeApi(path: string): string {
|
||||
return proxiedServiceUrl(DBIS_EXCHANGE_URL, '/api/v1/exchange', path);
|
||||
}
|
||||
|
||||
/** Bearer auth for OMNL mutating APIs (token load, transfers, swap settle). */
|
||||
export function omnlApiHeaders(json = true): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (json) headers['Content-Type'] = 'application/json';
|
||||
const key = import.meta.env.VITE_OMNL_API_KEY?.trim();
|
||||
if (key) headers.Authorization = `Bearer ${key}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export const ENHANCED_SWAP_ROUTER_V2 =
|
||||
import.meta.env.VITE_ENHANCED_SWAP_ROUTER_V2 ||
|
||||
'0xa421706768aeb7fafa2d912c5e10824ef3437ad4';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAccount, useSendTransaction } from 'wagmi';
|
||||
import {
|
||||
exchangeApi,
|
||||
omnlApiHeaders,
|
||||
DEFAULT_TOKENS,
|
||||
fetchExchangeTokens,
|
||||
parseAmountToWei,
|
||||
@@ -86,7 +87,7 @@ export function useDbisSwap() {
|
||||
const wei = parseAmountToWei(amountIn, tokenIn.decimals);
|
||||
const res = await fetch(exchangeApi('/execute-plan'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: omnlApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
tokenIn: tokenIn.address,
|
||||
tokenOut: tokenOut.address,
|
||||
@@ -110,7 +111,7 @@ export function useDbisSwap() {
|
||||
try {
|
||||
const res = await fetch(exchangeApi('/swap/settle'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: omnlApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
amount: amountIn,
|
||||
recipientAddress: address,
|
||||
@@ -143,7 +144,7 @@ export function useDbisSwap() {
|
||||
setTxHash(hash);
|
||||
await fetch(exchangeApi('/swap/record'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: omnlApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
tokenIn: tokenIn.address,
|
||||
tokenOut: tokenOut.address,
|
||||
@@ -169,7 +170,7 @@ export function useDbisSwap() {
|
||||
try {
|
||||
const res = await fetch(exchangeApi('/token-load'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: omnlApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: `LOAD-${Date.now()}`,
|
||||
tokenAddress: tokenOut.address,
|
||||
@@ -198,7 +199,7 @@ export function useDbisSwap() {
|
||||
try {
|
||||
const res = await fetch(exchangeApi('/transfer/internal'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: omnlApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: `XFER-INT-${Date.now()}`,
|
||||
tokenAddress: tokenIn.address,
|
||||
@@ -225,7 +226,7 @@ export function useDbisSwap() {
|
||||
try {
|
||||
const res = await fetch(exchangeApi('/transfer/external'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: omnlApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: `XFER-EXT-${Date.now()}`,
|
||||
tokenAddress: tokenIn.address,
|
||||
|
||||
@@ -60,6 +60,7 @@ cd "$REPO_DIR/frontend-dapp"
|
||||
export VITE_TOKEN_AGGREGATION_URL="${VITE_TOKEN_AGGREGATION_URL:-/api/v1}"
|
||||
export VITE_SETTLEMENT_MIDDLEWARE_URL="${VITE_SETTLEMENT_MIDDLEWARE_URL:-/settlement}"
|
||||
export VITE_DBIS_EXCHANGE_URL="${VITE_DBIS_EXCHANGE_URL:-/exchange}"
|
||||
export VITE_OMNL_API_KEY="${VITE_OMNL_API_KEY:-${OMNL_API_KEY:-}}"
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
pnpm run build
|
||||
@@ -90,6 +91,15 @@ start_svc() {
|
||||
OMNL_FINERACT_USERNAME="${OMNL_FINERACT_USERNAME:-}" \
|
||||
OMNL_FINERACT_PASSWORD="${OMNL_FINERACT_PASSWORD:-}" \
|
||||
OMNL_PUBLIC_MONEY_SUPPLY="${OMNL_PUBLIC_MONEY_SUPPLY:-1}" \
|
||||
SETTLEMENT_ALLOW_HYBX_PRODUCTION="${SETTLEMENT_ALLOW_HYBX_PRODUCTION:-}" \
|
||||
SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE="${SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE:-}" \
|
||||
OMNL_ALLOW_CHAIN_MINT_EXECUTE="${OMNL_ALLOW_CHAIN_MINT_EXECUTE:-}" \
|
||||
TOKEN_AGGREGATION_URL="${TOKEN_AGGREGATION_URL:-http://127.0.0.1:3000}" \
|
||||
HYBX_MIFOS_FINERACT_SIDECAR_URL="${HYBX_MIFOS_FINERACT_SIDECAR_URL:-}" \
|
||||
SWIFT_LISTENER_URL="${SWIFT_LISTENER_URL:-}" \
|
||||
RPC_URL_138="${RPC_URL_138:-${CHAIN_138_RPC_URL:-}}" \
|
||||
CHAIN_138_RPC_URL="${CHAIN_138_RPC_URL:-${RPC_URL_138:-}}" \
|
||||
OMNL_MINT_OPERATOR_PRIVATE_KEY="${OMNL_MINT_OPERATOR_PRIVATE_KEY:-}" \
|
||||
bash -c "$cmd" >"$LOG_DIR/$name.log" 2>&1 &
|
||||
echo $! >"$LOG_DIR/$name.pid"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.fineractOfficeReachable = fineractOfficeReachable;
|
||||
exports.postJournalEntry = postJournalEntry;
|
||||
exports.resolveGlAccountId = resolveGlAccountId;
|
||||
exports.fetchGlBalances = fetchGlBalances;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const GL_HINTS = ['1000', '1050', '1410', '2000', '2100', '2200', '2300'];
|
||||
@@ -148,6 +149,26 @@ async function postJournalEntry(entry) {
|
||||
});
|
||||
return { resourceId: data.resourceId ?? data.entityId ?? 0 };
|
||||
}
|
||||
const glCodeCache = new Map();
|
||||
/** Resolve Fineract GL account id from chart code (e.g. 1410 → 24). */
|
||||
async function resolveGlAccountId(glCode) {
|
||||
const code = String(glCode).trim();
|
||||
const cached = glCodeCache.get(code);
|
||||
if (cached != null)
|
||||
return cached;
|
||||
const { data } = await axios_1.default.get(`${fineractBase()}/glaccounts`, {
|
||||
headers: authHeaders(),
|
||||
timeout: 30000,
|
||||
params: { glCode: code },
|
||||
});
|
||||
const rows = Array.isArray(data) ? data : data?.pageItems ?? [];
|
||||
const match = rows.find((r) => r.glCode === code);
|
||||
if (!match?.id) {
|
||||
throw new Error(`GL account not found for code ${code}`);
|
||||
}
|
||||
glCodeCache.set(code, match.id);
|
||||
return match.id;
|
||||
}
|
||||
async function fetchGlBalances(officeId) {
|
||||
const base = fineractBase();
|
||||
if (!base || !process.env.OMNL_FINERACT_PASSWORD)
|
||||
|
||||
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.HybxProductionRail = void 0;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const config_1 = require("../config");
|
||||
const integration_foundation_1 = require("@dbis/integration-foundation");
|
||||
/** Production HYBX rail client for real settlement dispatch */
|
||||
class HybxProductionRail {
|
||||
@@ -12,9 +13,12 @@ class HybxProductionRail {
|
||||
apiKey;
|
||||
clientSecret;
|
||||
constructor() {
|
||||
const cfg = (0, integration_foundation_1.loadHybxConfig)({ testMode: false });
|
||||
if (cfg.environment === 'production' && process.env.SETTLEMENT_ALLOW_HYBX_PRODUCTION !== '1') {
|
||||
throw new Error('SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 required for live HYBX rails');
|
||||
const settlement = (0, config_1.loadConfig)();
|
||||
const allowLive = process.env.SETTLEMENT_ALLOW_HYBX_PRODUCTION === '1' ||
|
||||
settlement.production.allowHybxProduction;
|
||||
const cfg = (0, integration_foundation_1.loadHybxConfig)({ testMode: !allowLive });
|
||||
if (cfg.environment === 'production' && !allowLive) {
|
||||
throw new Error('HYBX production requires SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 or production.allowHybxProduction in config');
|
||||
}
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = cfg.apiKey;
|
||||
|
||||
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.archiveIso20022 = archiveIso20022;
|
||||
exports.requestTokenMint = requestTokenMint;
|
||||
exports.requestTokenTransfer = requestTokenTransfer;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
async function archiveIso20022(payload) {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
@@ -42,3 +43,25 @@ async function requestTokenMint(payload) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async function requestTokenTransfer(payload) {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (key)
|
||||
headers.Authorization = `Bearer ${key}`;
|
||||
try {
|
||||
const { data } = await axios_1.default.post(`${base}/api/v1/omnl/settlement/token-transfer`, payload, { headers, timeout: 120000 });
|
||||
return {
|
||||
txHash: data.txHash,
|
||||
status: String(data.status ?? (payload.dryRun ? 'DRY_RUN' : 'SUBMITTED')),
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
if (axios_1.default.isAxiosError(err) && err.response?.status === 404) {
|
||||
return {
|
||||
status: payload.dryRun ? 'DRY_RUN' : 'PENDING_CHAIN_ENDPOINT',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,40 @@ function requireApiKey(req, res) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return false;
|
||||
}
|
||||
async function respondPublicMoneySupply(res, officeId) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
|
||||
if (!allow) {
|
||||
res.status(404).json({ error: 'Public money supply disabled' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [balances, fineractConnected] = await Promise.all([
|
||||
(0, fineract_1.fetchGlBalances)(officeId),
|
||||
(0, fineract_1.fineractOfficeReachable)(officeId),
|
||||
]);
|
||||
const snapshot = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0);
|
||||
res.json({
|
||||
...snapshot,
|
||||
fineractConnected,
|
||||
fineractLive: fineractConnected && hasActivity,
|
||||
interoffice: {
|
||||
dueFromHeadOffice: balances['1410'] ?? '0',
|
||||
gl1410: balances['1410'] ?? '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
function createSettlementRouter() {
|
||||
const router = (0, express_1.Router)();
|
||||
const officeId = (0, config_1.settlementOfficeId)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
router.get('/health', (_req, res) => {
|
||||
router.get('/health/ledger', (_req, res) => respondPublicMoneySupply(res, officeId));
|
||||
router.get('/health', async (req, res) => {
|
||||
let chainStats = { total: 0, active: 0 };
|
||||
try {
|
||||
const reg = (0, settlement_core_1.loadOmnlChainRegistry)();
|
||||
@@ -34,7 +63,7 @@ function createSettlementRouter() {
|
||||
catch {
|
||||
/* optional */
|
||||
}
|
||||
res.json({
|
||||
const payload = {
|
||||
service: 'omnl-settlement-middleware',
|
||||
status: 'ok',
|
||||
mode: process.env.SETTLEMENT_MIDDLEWARE_CONFIG?.includes('production') ? 'production' : 'standard',
|
||||
@@ -46,7 +75,37 @@ function createSettlementRouter() {
|
||||
name: profile.name,
|
||||
},
|
||||
enforceSingleOffice: (0, config_1.loadConfig)().enforceSingleOffice ?? false,
|
||||
});
|
||||
};
|
||||
const includeLedger = req.query.ledger === '1' ||
|
||||
req.query.include === 'money-supply' ||
|
||||
req.query.include === 'ledger';
|
||||
if (includeLedger) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
|
||||
if (allow) {
|
||||
try {
|
||||
const [balances, fineractConnected] = await Promise.all([
|
||||
(0, fineract_1.fetchGlBalances)(officeId),
|
||||
(0, fineract_1.fineractOfficeReachable)(officeId),
|
||||
]);
|
||||
const snapshot = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0);
|
||||
payload.moneySupply = {
|
||||
...snapshot,
|
||||
fineractConnected,
|
||||
fineractLive: fineractConnected && hasActivity,
|
||||
interoffice: {
|
||||
dueFromHeadOffice: balances['1410'] ?? '0',
|
||||
gl1410: balances['1410'] ?? '0',
|
||||
},
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
payload.moneySupplyError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json(payload);
|
||||
});
|
||||
router.get('/office', (_req, res) => {
|
||||
res.json({
|
||||
@@ -55,34 +114,8 @@ function createSettlementRouter() {
|
||||
storePath: `data/settlement-store/office-${officeId}`,
|
||||
});
|
||||
});
|
||||
router.get('/money-supply/public', async (_req, res) => {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
|
||||
if (!allow) {
|
||||
res.status(404).json({ error: 'Public money supply disabled' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [balances, fineractConnected] = await Promise.all([
|
||||
(0, fineract_1.fetchGlBalances)(officeId),
|
||||
(0, fineract_1.fineractOfficeReachable)(officeId),
|
||||
]);
|
||||
const snapshot = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0);
|
||||
res.json({
|
||||
...snapshot,
|
||||
fineractConnected,
|
||||
fineractLive: fineractConnected && hasActivity,
|
||||
interoffice: {
|
||||
dueFromHeadOffice: balances['1410'] ?? '0',
|
||||
gl1410: balances['1410'] ?? '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/public/money-supply', (_req, res) => respondPublicMoneySupply(res, officeId));
|
||||
router.get('/money-supply/public', (_req, res) => respondPublicMoneySupply(res, officeId));
|
||||
router.get('/money-supply', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
@@ -96,8 +129,12 @@ function createSettlementRouter() {
|
||||
router.get('/money-supply/:officeIdParam', async (req, res) => {
|
||||
if (!requireApiKey(req, res))
|
||||
return;
|
||||
const requested = parseInt(req.params.officeIdParam, 10);
|
||||
if (!Number.isFinite(requested)) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const requested = parseInt(req.params.officeIdParam, 10);
|
||||
res.json(await (0, external_transfer_1.getMoneySupply)((0, config_1.settlementOfficeId)(requested)));
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@@ -11,9 +11,6 @@ const hybx_production_1 = require("../adapters/hybx-production");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const swift_1 = require("../adapters/swift");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
function glId(code) {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
async function processExternalTransfer(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
@@ -49,13 +46,17 @@ async function processExternalTransfer(input) {
|
||||
const debitGl = gl?.debitGl ?? profile.settlement.glSettlement ?? cfg.moneySupply.glSettlement;
|
||||
const creditGl = gl?.creditGl ?? profile.settlement.glM1 ?? cfg.moneySupply.glM1;
|
||||
if (amount > 0) {
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
(0, fineract_1.resolveGlAccountId)(debitGl),
|
||||
(0, fineract_1.resolveGlAccountId)(creditGl),
|
||||
]);
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId: req.officeId,
|
||||
transactionDate: req.valueDate,
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: verbiage.slice(0, 500),
|
||||
debits: [{ glAccountId: glId(debitGl), amount }],
|
||||
credits: [{ glAccountId: glId(creditGl), amount }],
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ const config_1 = require("../config");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
function glId(code) {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
async function processTokenLoad(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
@@ -69,13 +66,17 @@ async function processTokenLoad(input) {
|
||||
}
|
||||
const amount = parseFloat(loadAmount) || 0;
|
||||
if (amount > 0) {
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
(0, fineract_1.resolveGlAccountId)(profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD),
|
||||
(0, fineract_1.resolveGlAccountId)(settlement_core_1.GL_CODES.CRYPTO_TOKEN_LIABILITY),
|
||||
]);
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: `M2 token load ${req.lineId}`,
|
||||
debits: [{ glAccountId: glId(profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD), amount }],
|
||||
credits: [{ glAccountId: glId(settlement_core_1.GL_CODES.CRYPTO_TOKEN_LIABILITY), amount }],
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@ const hybx_production_1 = require("../adapters/hybx-production");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const swift_1 = require("../adapters/swift");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
function glId(code) {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
async function processTransfer(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
@@ -61,13 +58,17 @@ async function processTransfer(input) {
|
||||
});
|
||||
const amount = parseFloat(req.amount) || 0;
|
||||
if (amount > 0) {
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
(0, fineract_1.resolveGlAccountId)(profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD),
|
||||
(0, fineract_1.resolveGlAccountId)(profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE),
|
||||
]);
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: `${req.rail} transfer ${req.tokenSymbol}`,
|
||||
debits: [{ glAccountId: glId(profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD), amount }],
|
||||
credits: [{ glAccountId: glId(profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE), amount }],
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
}
|
||||
@@ -113,12 +114,34 @@ async function processTransfer(input) {
|
||||
else {
|
||||
advance('ISO20022_ARCHIVED');
|
||||
}
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
verbiageDocument: [
|
||||
record.verbiageDocument,
|
||||
`On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`,
|
||||
].join('\n'),
|
||||
});
|
||||
if (!isExternal && req.tokenAddress && cfg.production.allowChainMintExecute) {
|
||||
const xfer = await (0, omnl_1.requestTokenTransfer)({
|
||||
tokenAddress: req.tokenAddress,
|
||||
amount: req.amount,
|
||||
recipient: req.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: false,
|
||||
symbol: req.tokenSymbol,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
chainTxHash: xfer.txHash,
|
||||
verbiageDocument: [
|
||||
record.verbiageDocument,
|
||||
`On-chain ERC-20 transfer: ${req.tokenAddress} → ${req.recipientAddress}`,
|
||||
xfer.txHash ? `tx ${xfer.txHash}` : `status ${xfer.status}`,
|
||||
].join('\n'),
|
||||
});
|
||||
}
|
||||
else {
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
verbiageDocument: [
|
||||
record.verbiageDocument,
|
||||
isExternal
|
||||
? 'Bank rail (SWIFT/IBAN) — no on-chain transfer'
|
||||
: `On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`,
|
||||
].join('\n'),
|
||||
});
|
||||
}
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -154,6 +154,28 @@ export async function postJournalEntry(entry: {
|
||||
return { resourceId: data.resourceId ?? data.entityId ?? 0 };
|
||||
}
|
||||
|
||||
const glCodeCache = new Map<string, number>();
|
||||
|
||||
/** Resolve Fineract GL account id from chart code (e.g. 1410 → 24). */
|
||||
export async function resolveGlAccountId(glCode: string): Promise<number> {
|
||||
const code = String(glCode).trim();
|
||||
const cached = glCodeCache.get(code);
|
||||
if (cached != null) return cached;
|
||||
|
||||
const { data } = await axios.get(`${fineractBase()}/glaccounts`, {
|
||||
headers: authHeaders(),
|
||||
timeout: 30000,
|
||||
params: { glCode: code },
|
||||
});
|
||||
const rows = Array.isArray(data) ? data : (data as { pageItems?: unknown[] })?.pageItems ?? [];
|
||||
const match = (rows as { id?: number; glCode?: string }[]).find((r) => r.glCode === code);
|
||||
if (!match?.id) {
|
||||
throw new Error(`GL account not found for code ${code}`);
|
||||
}
|
||||
glCodeCache.set(code, match.id);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
export async function fetchGlBalances(officeId: number): Promise<Record<string, string>> {
|
||||
const base = fineractBase();
|
||||
if (!base || !process.env.OMNL_FINERACT_PASSWORD) return {};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { loadConfig } from '../config';
|
||||
import { loadHybxConfig } from '@dbis/integration-foundation';
|
||||
|
||||
/** Production HYBX rail client for real settlement dispatch */
|
||||
@@ -8,11 +9,16 @@ export class HybxProductionRail {
|
||||
private readonly clientSecret: string;
|
||||
|
||||
constructor() {
|
||||
const cfg = loadHybxConfig({ testMode: false });
|
||||
if (cfg.environment === 'production' && process.env.SETTLEMENT_ALLOW_HYBX_PRODUCTION !== '1') {
|
||||
throw new Error('SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 required for live HYBX rails');
|
||||
}
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/$/, '');
|
||||
const settlement = loadConfig();
|
||||
const allowLive =
|
||||
process.env.SETTLEMENT_ALLOW_HYBX_PRODUCTION === '1' ||
|
||||
settlement.production.allowHybxProduction;
|
||||
const cfg = loadHybxConfig({ testMode: !allowLive });
|
||||
if (cfg.environment === 'production' && !allowLive) {
|
||||
throw new Error(
|
||||
'HYBX production requires SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 or production.allowHybxProduction in config',
|
||||
);
|
||||
} this.baseUrl = cfg.baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = cfg.apiKey;
|
||||
this.clientSecret = cfg.clientSecret;
|
||||
}
|
||||
|
||||
@@ -57,3 +57,36 @@ export async function requestTokenMint(payload: {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestTokenTransfer(payload: {
|
||||
tokenAddress: string;
|
||||
amount: string;
|
||||
recipient: string;
|
||||
settlementRef: string;
|
||||
dryRun: boolean;
|
||||
symbol?: string;
|
||||
}): Promise<{ txHash?: string; status: string }> {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (key) headers.Authorization = `Bearer ${key}`;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${base}/api/v1/omnl/settlement/token-transfer`,
|
||||
payload,
|
||||
{ headers, timeout: 120000 },
|
||||
);
|
||||
return {
|
||||
txHash: data.txHash,
|
||||
status: String(data.status ?? (payload.dryRun ? 'DRY_RUN' : 'SUBMITTED')),
|
||||
};
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 404) {
|
||||
return {
|
||||
status: payload.dryRun ? 'DRY_RUN' : 'PENDING_CHAIN_ENDPOINT',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,12 @@ import {
|
||||
type SettlementRecord,
|
||||
} from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import { fetchGlBalances, postJournalEntry } from '../adapters/fineract';
|
||||
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
|
||||
import { HybxProductionRail } from '../adapters/hybx-production';
|
||||
import { archiveIso20022, requestTokenMint } from '../adapters/omnl';
|
||||
import { buildMt102Stub, buildMt103Stub, forwardSwiftToListener } from '../adapters/swift';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
|
||||
function glId(code: string): number {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
|
||||
export async function processExternalTransfer(
|
||||
input: ExternalTransferRequest,
|
||||
): Promise<SettlementRecord> {
|
||||
@@ -63,13 +59,17 @@ export async function processExternalTransfer(
|
||||
const creditGl = gl?.creditGl ?? profile.settlement.glM1 ?? cfg.moneySupply.glM1;
|
||||
|
||||
if (amount > 0) {
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
resolveGlAccountId(debitGl),
|
||||
resolveGlAccountId(creditGl),
|
||||
]);
|
||||
const je = await postJournalEntry({
|
||||
officeId: req.officeId,
|
||||
transactionDate: req.valueDate,
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: verbiage.slice(0, 500),
|
||||
debits: [{ glAccountId: glId(debitGl), amount }],
|
||||
credits: [{ glAccountId: glId(creditGl), amount }],
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
} else {
|
||||
|
||||
@@ -7,14 +7,10 @@ import {
|
||||
type SettlementRecord,
|
||||
} from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import { fetchGlBalances, postJournalEntry } from '../adapters/fineract';
|
||||
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
|
||||
import { requestTokenMint } from '../adapters/omnl';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
|
||||
function glId(code: string): number {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
|
||||
export async function processTokenLoad(input: TokenLoadRequest): Promise<SettlementRecord> {
|
||||
const cfg = loadConfig();
|
||||
const profile = loadOfficeProfile();
|
||||
@@ -79,13 +75,17 @@ export async function processTokenLoad(input: TokenLoadRequest): Promise<Settlem
|
||||
|
||||
const amount = parseFloat(loadAmount) || 0;
|
||||
if (amount > 0) {
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
resolveGlAccountId(profile.settlement.glM2 ?? GL_CODES.M2_BROAD),
|
||||
resolveGlAccountId(GL_CODES.CRYPTO_TOKEN_LIABILITY),
|
||||
]);
|
||||
const je = await postJournalEntry({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: `M2 token load ${req.lineId}`,
|
||||
debits: [{ glAccountId: glId(profile.settlement.glM2 ?? GL_CODES.M2_BROAD), amount }],
|
||||
credits: [{ glAccountId: glId(GL_CODES.CRYPTO_TOKEN_LIABILITY), amount }],
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
} else {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { GL_CODES, type TransferRequest, type SettlementRecord } from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import { postJournalEntry } from '../adapters/fineract';
|
||||
import { postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
|
||||
import { HybxProductionRail } from '../adapters/hybx-production';
|
||||
import { archiveIso20022 } from '../adapters/omnl';
|
||||
import { archiveIso20022, requestTokenTransfer } from '../adapters/omnl';
|
||||
import { buildMt103Stub, forwardSwiftToListener } from '../adapters/swift';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
|
||||
function glId(code: string): number {
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
|
||||
export async function processTransfer(input: TransferRequest): Promise<SettlementRecord> {
|
||||
const cfg = loadConfig();
|
||||
const profile = loadOfficeProfile();
|
||||
@@ -64,13 +60,17 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
|
||||
const amount = parseFloat(req.amount) || 0;
|
||||
if (amount > 0) {
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
resolveGlAccountId(profile.settlement.glM2 ?? GL_CODES.M2_BROAD),
|
||||
resolveGlAccountId(profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE),
|
||||
]);
|
||||
const je = await postJournalEntry({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: req.idempotencyKey,
|
||||
comments: `${req.rail} transfer ${req.tokenSymbol}`,
|
||||
debits: [{ glAccountId: glId(profile.settlement.glM2 ?? GL_CODES.M2_BROAD), amount }],
|
||||
credits: [{ glAccountId: glId(profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE), amount }],
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
} else {
|
||||
@@ -116,12 +116,33 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
advance('ISO20022_ARCHIVED');
|
||||
}
|
||||
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
verbiageDocument: [
|
||||
record.verbiageDocument,
|
||||
`On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`,
|
||||
].join('\n'),
|
||||
});
|
||||
if (!isExternal && req.tokenAddress && cfg.production.allowChainMintExecute) {
|
||||
const xfer = await requestTokenTransfer({
|
||||
tokenAddress: req.tokenAddress,
|
||||
amount: req.amount,
|
||||
recipient: req.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: false,
|
||||
symbol: req.tokenSymbol,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
chainTxHash: xfer.txHash,
|
||||
verbiageDocument: [
|
||||
record.verbiageDocument,
|
||||
`On-chain ERC-20 transfer: ${req.tokenAddress} → ${req.recipientAddress}`,
|
||||
xfer.txHash ? `tx ${xfer.txHash}` : `status ${xfer.status}`,
|
||||
].join('\n'),
|
||||
});
|
||||
} else {
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
verbiageDocument: [
|
||||
record.verbiageDocument,
|
||||
isExternal
|
||||
? 'Bank rail (SWIFT/IBAN) — no on-chain transfer'
|
||||
: `On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`,
|
||||
].join('\n'),
|
||||
});
|
||||
}
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,6 +16,11 @@ import { computeOmnlReconcileAnchor } from '../../services/omnl-reconcile-anchor
|
||||
import { getOmnlIntegrationStatus } from '../../services/omnl-integration-status';
|
||||
import { getOmnlApiCatalog } from '../../services/omnl-api-catalog';
|
||||
import omnlOpenApi from '../../resources/omnl-openapi.json';
|
||||
import {
|
||||
executeErc20Transfer,
|
||||
executeM2TokenMint,
|
||||
chainRailConfigured,
|
||||
} from '../../services/omnl-settlement-chain';
|
||||
|
||||
const router = Router();
|
||||
router.use(omnlRateLimiter);
|
||||
@@ -329,7 +334,7 @@ router.get('/omnl/health', async (req: Request, res: Response) => {
|
||||
/**
|
||||
* POST /omnl/settlement/token-load — M2 fiat-backed token mint (Office 24 settlement).
|
||||
*/
|
||||
router.post('/omnl/settlement/token-load', (req: Request, res: Response) => {
|
||||
router.post('/omnl/settlement/token-load', async (req: Request, res: Response) => {
|
||||
const { lineId, amount, recipient, settlementRef, dryRun, symbol, tokenAddress } = req.body as {
|
||||
lineId?: string;
|
||||
amount?: string;
|
||||
@@ -348,29 +353,127 @@ router.post('/omnl/settlement/token-load', (req: Request, res: Response) => {
|
||||
(process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1' ||
|
||||
process.env.OMNL_ALLOW_CHAIN_MINT_EXECUTE === '1');
|
||||
const loadId = settlementRef ?? `TL-${Date.now()}`;
|
||||
res.json({
|
||||
status: execute ? 'QUEUED' : 'DRY_RUN',
|
||||
loadId,
|
||||
lineId,
|
||||
amount,
|
||||
recipient,
|
||||
symbol: symbol ?? null,
|
||||
tokenAddress: tokenAddress ?? null,
|
||||
settlementRef: settlementRef ?? null,
|
||||
moneyLayer: 'M2',
|
||||
loadFromGl: '2200',
|
||||
creditGl: '2300',
|
||||
capabilities: {
|
||||
swappable: true,
|
||||
convertible: true,
|
||||
transferableInternal: true,
|
||||
transferableExternal: true,
|
||||
},
|
||||
txHash: execute ? undefined : null,
|
||||
message: execute
|
||||
? 'Token load queued for ComplianceCore mint pipeline (M2 → on-chain)'
|
||||
: 'M2 token load validated — set SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1 to mint on-chain',
|
||||
});
|
||||
const capabilities = {
|
||||
swappable: true,
|
||||
convertible: true,
|
||||
transferableInternal: true,
|
||||
transferableExternal: true,
|
||||
};
|
||||
|
||||
if (!execute) {
|
||||
res.json({
|
||||
status: 'DRY_RUN',
|
||||
loadId,
|
||||
lineId,
|
||||
amount,
|
||||
recipient,
|
||||
symbol: symbol ?? null,
|
||||
tokenAddress: tokenAddress ?? null,
|
||||
settlementRef: settlementRef ?? null,
|
||||
moneyLayer: 'M2',
|
||||
loadFromGl: '2200',
|
||||
creditGl: '2300',
|
||||
capabilities,
|
||||
txHash: null,
|
||||
chainRailConfigured: chainRailConfigured(),
|
||||
message: 'M2 token load validated — set SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1 to mint on-chain',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tokenAddress?.startsWith('0x')) {
|
||||
res.status(400).json({
|
||||
error: 'tokenAddress required for on-chain mint',
|
||||
status: 'QUEUED',
|
||||
loadId,
|
||||
capabilities,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { txHash } = await executeM2TokenMint({
|
||||
tokenAddress,
|
||||
recipient,
|
||||
amount,
|
||||
});
|
||||
res.json({
|
||||
status: 'SETTLED',
|
||||
loadId,
|
||||
lineId,
|
||||
amount,
|
||||
recipient,
|
||||
symbol: symbol ?? null,
|
||||
tokenAddress,
|
||||
settlementRef: settlementRef ?? null,
|
||||
moneyLayer: 'M2',
|
||||
capabilities,
|
||||
txHash,
|
||||
message: 'M2 token minted on Chain 138 — tradable, swappable, transferable',
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(502).json({
|
||||
error: msg,
|
||||
status: 'CHAIN_MINT_FAILED',
|
||||
loadId,
|
||||
lineId,
|
||||
capabilities,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /omnl/settlement/token-transfer — ERC-20 transfer to web3 wallet (internal rail).
|
||||
*/
|
||||
router.post('/omnl/settlement/token-transfer', async (req: Request, res: Response) => {
|
||||
const { tokenAddress, amount, recipient, settlementRef, dryRun, symbol } = req.body as {
|
||||
tokenAddress?: string;
|
||||
amount?: string;
|
||||
recipient?: string;
|
||||
settlementRef?: string;
|
||||
dryRun?: boolean;
|
||||
symbol?: string;
|
||||
};
|
||||
if (!tokenAddress || !amount || !recipient) {
|
||||
res.status(400).json({ error: 'tokenAddress, amount, recipient required' });
|
||||
return;
|
||||
}
|
||||
const execute =
|
||||
!dryRun &&
|
||||
(process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1' ||
|
||||
process.env.OMNL_ALLOW_CHAIN_MINT_EXECUTE === '1');
|
||||
|
||||
if (!execute) {
|
||||
res.json({
|
||||
status: 'DRY_RUN',
|
||||
transferId: settlementRef ?? `TT-${Date.now()}`,
|
||||
tokenAddress,
|
||||
amount,
|
||||
recipient,
|
||||
symbol: symbol ?? null,
|
||||
txHash: null,
|
||||
message: 'Transfer validated — enable SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1 for on-chain delivery',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { txHash } = await executeErc20Transfer({ tokenAddress, recipient, amount });
|
||||
res.json({
|
||||
status: 'SETTLED',
|
||||
transferId: settlementRef ?? `TT-${Date.now()}`,
|
||||
tokenAddress,
|
||||
amount,
|
||||
recipient,
|
||||
symbol: symbol ?? null,
|
||||
txHash,
|
||||
message: 'ERC-20 transferred to recipient wallet on Chain 138',
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(502).json({ error: msg, status: 'CHAIN_TRANSFER_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Contract, JsonRpcProvider, Wallet, parseUnits } from 'ethers';
|
||||
|
||||
function rpcUrl(): string | undefined {
|
||||
return process.env.RPC_URL_138 || process.env.CHAIN_138_RPC_URL;
|
||||
}
|
||||
|
||||
function operatorKey(): string | undefined {
|
||||
return process.env.OMNL_MINT_OPERATOR_PRIVATE_KEY?.trim();
|
||||
}
|
||||
|
||||
function chainExecuteEnabled(): boolean {
|
||||
return (
|
||||
process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1' ||
|
||||
process.env.OMNL_ALLOW_CHAIN_MINT_EXECUTE === '1'
|
||||
);
|
||||
}
|
||||
|
||||
async function tokenDecimals(token: Contract, fallback = 18): Promise<number> {
|
||||
try {
|
||||
return Number(await token.decimals());
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint M2 compliant token to recipient wallet (Chain 138). */
|
||||
export async function executeM2TokenMint(params: {
|
||||
tokenAddress: string;
|
||||
recipient: string;
|
||||
amount: string;
|
||||
decimals?: number;
|
||||
}): Promise<{ txHash: string }> {
|
||||
const pk = operatorKey();
|
||||
const rpc = rpcUrl();
|
||||
if (!chainExecuteEnabled()) {
|
||||
throw new Error('Chain mint execute disabled — set SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1');
|
||||
}
|
||||
if (!pk || !rpc) {
|
||||
throw new Error('OMNL_MINT_OPERATOR_PRIVATE_KEY and RPC_URL_138 required for on-chain mint');
|
||||
}
|
||||
if (!params.tokenAddress?.startsWith('0x')) {
|
||||
throw new Error('tokenAddress required for on-chain mint');
|
||||
}
|
||||
|
||||
const provider = new JsonRpcProvider(rpc);
|
||||
const wallet = new Wallet(pk, provider);
|
||||
const abi = [
|
||||
'function mint(address to, uint256 amount)',
|
||||
'function decimals() view returns (uint8)',
|
||||
];
|
||||
const token = new Contract(params.tokenAddress, abi, wallet);
|
||||
const decimals = await tokenDecimals(token, params.decimals ?? 18);
|
||||
const wei = parseUnits(params.amount, decimals);
|
||||
const tx = await token.mint(params.recipient, wei);
|
||||
const receipt = await tx.wait(1);
|
||||
return { txHash: receipt?.hash ?? tx.hash };
|
||||
}
|
||||
|
||||
/** Transfer ERC-20 from operator treasury to recipient (internal / web3 wallet). */
|
||||
export async function executeErc20Transfer(params: {
|
||||
tokenAddress: string;
|
||||
recipient: string;
|
||||
amount: string;
|
||||
decimals?: number;
|
||||
}): Promise<{ txHash: string }> {
|
||||
const pk = operatorKey();
|
||||
const rpc = rpcUrl();
|
||||
if (!chainExecuteEnabled()) {
|
||||
throw new Error('Chain transfer execute disabled — set SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1');
|
||||
}
|
||||
if (!pk || !rpc) {
|
||||
throw new Error('OMNL_MINT_OPERATOR_PRIVATE_KEY and RPC_URL_138 required for on-chain transfer');
|
||||
}
|
||||
if (!params.tokenAddress?.startsWith('0x')) {
|
||||
throw new Error('tokenAddress required for on-chain transfer');
|
||||
}
|
||||
|
||||
const provider = new JsonRpcProvider(rpc);
|
||||
const wallet = new Wallet(pk, provider);
|
||||
const abi = [
|
||||
'function transfer(address to, uint256 amount) returns (bool)',
|
||||
'function decimals() view returns (uint8)',
|
||||
];
|
||||
const token = new Contract(params.tokenAddress, abi, wallet);
|
||||
const decimals = await tokenDecimals(token, params.decimals ?? 18);
|
||||
const wei = parseUnits(params.amount, decimals);
|
||||
const tx = await token.transfer(params.recipient, wei);
|
||||
const receipt = await tx.wait(1);
|
||||
return { txHash: receipt?.hash ?? tx.hash };
|
||||
}
|
||||
|
||||
export function chainRailConfigured(): boolean {
|
||||
return Boolean(operatorKey() && rpcUrl() && chainExecuteEnabled());
|
||||
}
|
||||
Reference in New Issue
Block a user