feat(settlement): production Revolut, Wise, and Binance external rails
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 59s
CI/CD Pipeline / Security Scanning (push) Successful in 2m34s
CI/CD Pipeline / Lint and Format (push) Failing after 49s
CI/CD Pipeline / Terraform Validation (push) Failing after 22s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 26s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 19s
Validation / validate-genesis (push) Successful in 31s
Validation / validate-terraform (push) Failing after 27s
Validation / validate-kubernetes (push) Failing after 10s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m26s
Validation / validate-documentation (push) Failing after 18s

Wire Business/Platform/Spot adapters with fail-closed live gates, status/probe/dispatch APIs, smoke script, and portal rails UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 12:14:37 -07:00
parent 9ea42d4df6
commit 4048112461
25 changed files with 3575 additions and 61 deletions

5
.gitignore vendored
View File

@@ -114,3 +114,8 @@ lib/dodo-gassaving-pool/
lib/dodo-limit-order/
lib/dodo-v3/
lib/deploy-starter.txt
# External rail credentials (keep *.example tracked if forced)
secrets/**/credentials.env
!secrets/**/credentials.env.example

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
# Revolut Business + Wise Platform + Binance Spot (production)
# Copy live values into secrets/revolut|wise|binance/credentials.env (gitignored).
# Do not commit real tokens.
# --- Revolut Business API ---
# https://developer.revolut.com/docs/business/business-api
REVOLUT_ENABLED=1
REVOLUT_API_BASE_URL=https://b2b.revolut.com/api/1.0
# Sandbox: https://sandbox-b2b.revolut.com/api/1.0
REVOLUT_ACCESS_TOKEN=
REVOLUT_ACCOUNT_ID=
SETTLEMENT_ALLOW_REVOLUT_PRODUCTION=0
# --- Wise / TransferWise ---
# https://docs.wise.com/api-docs
WISE_ENABLED=1
WISE_API_BASE_URL=https://api.transferwise.com
# Sandbox: https://api.sandbox.transferwise.tech
WISE_API_TOKEN=
WISE_PROFILE_ID=
SETTLEMENT_ALLOW_WISE_PRODUCTION=0
# --- Binance Spot ---
# https://binance-docs.github.io/apidocs/spot/en/
BINANCE_ENABLED=1
BINANCE_API_BASE_URL=https://api.binance.com
BINANCE_API_KEY=
BINANCE_API_SECRET=
SETTLEMENT_ALLOW_BINANCE_PRODUCTION=0
# Required for live coin withdrawals:
BINANCE_ALLOW_WITHDRAW=0

View File

@@ -0,0 +1,31 @@
# Revolut Business + Wise Platform + Binance Spot (production)
# Copy live values into secrets/revolut|wise|binance/credentials.env (gitignored).
# Do not commit real tokens.
# --- Revolut Business API ---
# https://developer.revolut.com/docs/business/business-api
REVOLUT_ENABLED=1
REVOLUT_API_BASE_URL=https://b2b.revolut.com/api/1.0
# Sandbox: https://sandbox-b2b.revolut.com/api/1.0
REVOLUT_ACCESS_TOKEN=
REVOLUT_ACCOUNT_ID=
SETTLEMENT_ALLOW_REVOLUT_PRODUCTION=0
# --- Wise / TransferWise ---
# https://docs.wise.com/api-docs
WISE_ENABLED=1
WISE_API_BASE_URL=https://api.transferwise.com
# Sandbox: https://api.sandbox.transferwise.tech
WISE_API_TOKEN=
WISE_PROFILE_ID=
SETTLEMENT_ALLOW_WISE_PRODUCTION=0
# --- Binance Spot ---
# https://binance-docs.github.io/apidocs/spot/en/
BINANCE_ENABLED=1
BINANCE_API_BASE_URL=https://api.binance.com
BINANCE_API_KEY=
BINANCE_API_SECRET=
SETTLEMENT_ALLOW_BINANCE_PRODUCTION=0
# Required for live coin withdrawals:
BINANCE_ALLOW_WITHDRAW=0

View File

@@ -1,6 +1,6 @@
/** OMNL central-bank settlement domain types */
export type MoneyLayer = 'M0' | 'M1' | 'M2' | 'M3' | 'M4' | 'M00';
export type PaymentRail = 'SWIFT' | 'SEPA' | 'TARGET2' | 'RTGS' | 'ACH' | 'FEDWIRE' | 'INTERNAL' | 'CHAIN138';
export type PaymentRail = 'SWIFT' | 'SEPA' | 'TARGET2' | 'RTGS' | 'ACH' | 'FEDWIRE' | 'INTERNAL' | 'CHAIN138' | 'REVOLUT' | 'WISE' | 'BINANCE';
export type TradeFinanceInstrument = 'SBLC' | 'DLC' | 'BG' | 'LS';
export type SwiftMessageKind = 'MT103' | 'MT102' | 'MT202' | 'MT910';
export type SettlementPhase = 'RECEIVED' | 'VALIDATED' | 'VERBIAGE_ROLLED' | 'FINERACT_POSTED' | 'HYBX_RAIL_DISPATCHED' | 'ISO20022_ARCHIVED' | 'CHAIN_MINT_REQUESTED' | 'SETTLED' | 'FAILED';
@@ -57,9 +57,10 @@ export type TransferRequest = {
recipientAddress: string;
senderAddress?: string;
moneyLayers: MoneyLayer[];
rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS';
/** External SWIFT/HYBX beneficiary IBAN when rail is external */
rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS' | 'REVOLUT' | 'WISE' | 'BINANCE';
/** External SWIFT/HYBX/Revolut/Wise beneficiary IBAN when rail is external */
creditorIban?: string;
creditorBic?: string;
beneficiaryName?: string;
remittanceInfo?: string;
currency?: string;

View File

@@ -10,7 +10,10 @@ export type PaymentRail =
| 'ACH'
| 'FEDWIRE'
| 'INTERNAL'
| 'CHAIN138';
| 'CHAIN138'
| 'REVOLUT'
| 'WISE'
| 'BINANCE';
export type TradeFinanceInstrument = 'SBLC' | 'DLC' | 'BG' | 'LS';
@@ -82,9 +85,10 @@ export type TransferRequest = {
recipientAddress: string;
senderAddress?: string;
moneyLayers: MoneyLayer[];
rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS';
/** External SWIFT/HYBX beneficiary IBAN when rail is external */
rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS' | 'REVOLUT' | 'WISE' | 'BINANCE';
/** External SWIFT/HYBX/Revolut/Wise beneficiary IBAN when rail is external */
creditorIban?: string;
creditorBic?: string;
beneficiaryName?: string;
remittanceInfo?: string;
currency?: string;

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* Smoke-test Revolut Business + Wise + Binance credentials.
* Loads secrets/{revolut,wise,binance}/credentials.env then process.env.
* Usage: node scripts/rails/smoke-revolut-wise-binance.mjs
*/
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '../..');
function loadEnvFile(rel) {
const p = path.join(root, rel);
if (!fs.existsSync(p)) return {};
const out = {};
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
if (!line || line.trim().startsWith('#')) continue;
const i = line.indexOf('=');
if (i < 0) continue;
out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
}
return out;
}
function applyEnv(obj) {
for (const [k, v] of Object.entries(obj)) {
if (v && (!(k in process.env) || !process.env[k])) process.env[k] = v;
}
}
function redact(s) {
if (!s) return '(empty)';
if (s.length <= 10) return '***';
return `${s.slice(0, 4)}${s.slice(-4)} (len=${s.length})`;
}
applyEnv(loadEnvFile('.env'));
applyEnv(loadEnvFile('secrets/revolut/credentials.env'));
applyEnv(loadEnvFile('secrets/wise/credentials.env'));
applyEnv(loadEnvFile('secrets/binance/credentials.env'));
async function smokeRevolut() {
const token = process.env.REVOLUT_ACCESS_TOKEN || process.env.REVOLUT_API_TOKEN;
const base = (process.env.REVOLUT_API_BASE_URL || 'https://b2b.revolut.com/api/1.0').replace(/\/$/, '');
if (!token) return { ok: false, configured: false, error: 'REVOLUT_ACCESS_TOKEN missing' };
const res = await fetch(`${base}/accounts`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = { raw: text.slice(0, 160) };
}
const accounts = Array.isArray(body) ? body : body?.accounts ?? [];
return {
ok: res.status >= 200 && res.status < 300,
configured: true,
http: res.status,
token: redact(token),
accountCount: Array.isArray(accounts) ? accounts.length : null,
accountIdSet: Boolean(process.env.REVOLUT_ACCOUNT_ID),
liveGate: process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1',
error: res.ok ? undefined : body?.message || text.slice(0, 180),
};
}
async function smokeWise() {
const token = process.env.WISE_API_TOKEN || process.env.TRANSFERWISE_API_TOKEN;
const base = (process.env.WISE_API_BASE_URL || 'https://api.transferwise.com').replace(/\/$/, '');
if (!token) return { ok: false, configured: false, error: 'WISE_API_TOKEN missing' };
const res = await fetch(`${base}/v1/profiles`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = { raw: text.slice(0, 160) };
}
const profiles = Array.isArray(body) ? body : [];
return {
ok: res.status >= 200 && res.status < 300,
configured: true,
http: res.status,
token: redact(token),
profileCount: profiles.length,
profileIdSet: Boolean(process.env.WISE_PROFILE_ID),
liveGate: process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1',
error: res.ok ? undefined : body?.error || body?.message || text.slice(0, 180),
};
}
async function smokeBinance() {
const key = process.env.BINANCE_API_KEY;
const secret = process.env.BINANCE_API_SECRET;
const base = (process.env.BINANCE_API_BASE_URL || 'https://api.binance.com').replace(/\/$/, '');
if (!key || !secret) return { ok: false, configured: false, error: 'BINANCE_API_KEY/SECRET missing' };
const ping = await fetch(`${base}/api/v3/ping`);
const timestamp = Date.now();
const qs = `timestamp=${timestamp}`;
const sig = crypto.createHmac('sha256', secret).update(qs).digest('hex');
const res = await fetch(`${base}/api/v3/account?${qs}&signature=${sig}`, {
headers: { 'X-MBX-APIKEY': key, Accept: 'application/json' },
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = { raw: text.slice(0, 160) };
}
const funded = (body.balances || []).filter((b) => Number(b.free) > 0).length;
return {
ok: ping.ok && res.status >= 200 && res.status < 300,
configured: true,
http: res.status,
ping: ping.status,
apiKey: redact(key),
fundedAssets: funded,
canTrade: body.canTrade,
liveGate: process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1',
withdrawGate: process.env.BINANCE_ALLOW_WITHDRAW === '1',
error: res.ok ? undefined : body?.msg || text.slice(0, 180),
};
}
const report = {
asOf: new Date().toISOString(),
revolut: await smokeRevolut(),
wise: await smokeWise(),
binance: await smokeBinance(),
};
console.log(JSON.stringify(report, null, 2));
const anyConfigured = report.revolut.configured || report.wise.configured || report.binance.configured;
const allOk =
(!report.revolut.configured || report.revolut.ok) &&
(!report.wise.configured || report.wise.ok) &&
(!report.binance.configured || report.binance.ok);
process.exit(anyConfigured && allOk ? 0 : anyConfigured ? 2 : 3);

View File

@@ -0,0 +1,114 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BinanceExchangeRail = void 0;
exports.loadBinanceConfig = loadBinanceConfig;
/**
* Binance Spot API (production) — account / withdraw / ticker.
* Docs: https://binance-docs.github.io/apidocs/spot/en/
* Auth: API key + HMAC SHA256 secret.
*/
const crypto_1 = __importDefault(require("crypto"));
const axios_1 = __importDefault(require("axios"));
function loadBinanceConfig() {
const apiKey = (process.env.BINANCE_API_KEY || '').trim();
const apiSecret = (process.env.BINANCE_API_SECRET || '').trim();
const baseUrl = (process.env.BINANCE_API_BASE_URL || 'https://api.binance.com').replace(/\/$/, '');
return {
baseUrl,
apiKey,
apiSecret,
enabled: Boolean(apiKey && apiSecret) && process.env.BINANCE_ENABLED !== '0',
};
}
class BinanceExchangeRail {
config;
http;
constructor(cfg = loadBinanceConfig()) {
this.config = cfg;
this.http = axios_1.default.create({
baseURL: cfg.baseUrl,
timeout: 60000,
headers: {
'X-MBX-APIKEY': cfg.apiKey,
Accept: 'application/json',
},
});
}
isReady() {
return this.config.enabled && Boolean(this.config.apiKey && this.config.apiSecret);
}
sign(query) {
return crypto_1.default.createHmac('sha256', this.config.apiSecret).update(query).digest('hex');
}
async signedGet(path, params = {}) {
const timestamp = Date.now();
const qs = new URLSearchParams({
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
timestamp: String(timestamp),
}).toString();
const signature = this.sign(qs);
const { data } = await this.http.get(`${path}?${qs}&signature=${signature}`);
return data;
}
async signedPost(path, params = {}) {
const timestamp = Date.now();
const body = new URLSearchParams({
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
timestamp: String(timestamp),
});
const signature = this.sign(body.toString());
body.append('signature', signature);
const { data } = await this.http.post(path, body.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
return data;
}
async ping() {
const { status } = await this.http.get('/api/v3/ping');
return status === 200;
}
async accountInfo() {
if (!this.isReady())
throw new Error('Binance not configured (BINANCE_API_KEY/SECRET)');
const data = await this.signedGet('/api/v3/account');
const balances = (data.balances || [])
.filter((b) => Number(b.free) > 0)
.slice(0, 20)
.map((b) => ({ asset: b.asset, free: b.free }));
return { balances, canTrade: Boolean(data.canTrade) };
}
async tickerPrice(symbol) {
const { data } = await this.http.get('/api/v3/ticker/price', { params: { symbol } });
return { symbol: data.symbol, price: data.price };
}
/**
* Withdraw to external address (requires withdrawal permission on API key).
* Gate with BINANCE_ALLOW_WITHDRAW=1 in production.
*/
async withdraw(payload) {
if (!this.isReady())
throw new Error('Binance not configured');
if (process.env.BINANCE_ALLOW_WITHDRAW !== '1') {
throw new Error('BINANCE_ALLOW_WITHDRAW=1 required for live withdrawals');
}
const params = {
coin: payload.coin.toUpperCase(),
amount: payload.amount,
address: payload.address,
};
if (payload.network)
params.network = payload.network;
if (payload.name)
params.name = payload.name.slice(0, 30);
const data = await this.signedPost('/sapi/v1/capital/withdraw/apply', params);
return {
id: String(data?.id ?? data?.withdrawOrderId ?? ''),
status: 'SUBMITTED',
raw: data,
};
}
}
exports.BinanceExchangeRail = BinanceExchangeRail;

View File

@@ -0,0 +1,137 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getExternalRailsStatus = getExternalRailsStatus;
exports.probeExternalRails = probeExternalRails;
exports.dispatchExternalBankOrExchange = dispatchExternalBankOrExchange;
/**
* External banks & exchange hub — Revolut Business + Wise + Binance.
* Fail-closed until credentials present; live dispatch behind env gates.
*/
const revolut_business_1 = require("./revolut-business");
const wise_platform_1 = require("./wise-platform");
const binance_exchange_1 = require("./binance-exchange");
function getExternalRailsStatus() {
const revolut = (0, revolut_business_1.loadRevolutConfig)();
const wise = (0, wise_platform_1.loadWiseConfig)();
const binance = (0, binance_exchange_1.loadBinanceConfig)();
return [
{
id: 'REVOLUT',
name: 'Revolut Business',
configured: Boolean(revolut.accessToken),
liveDispatchAllowed: Boolean(revolut.accessToken) && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1',
baseUrl: revolut.baseUrl,
note: 'Business API Bearer token · set REVOLUT_ACCESS_TOKEN + REVOLUT_ACCOUNT_ID',
},
{
id: 'WISE',
name: 'Wise Platform',
configured: Boolean(wise.apiToken),
liveDispatchAllowed: Boolean(wise.apiToken) && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1',
baseUrl: wise.baseUrl,
note: 'API token · set WISE_API_TOKEN (+ WISE_PROFILE_ID recommended)',
},
{
id: 'BINANCE',
name: 'Binance Exchange',
configured: Boolean(binance.apiKey && binance.apiSecret),
liveDispatchAllowed: Boolean(binance.apiKey && binance.apiSecret) &&
process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1',
baseUrl: binance.baseUrl,
note: 'HMAC API key · withdrawals need BINANCE_ALLOW_WITHDRAW=1',
},
];
}
async function probeExternalRails() {
const status = getExternalRailsStatus();
const out = [];
for (const s of status) {
if (!s.configured) {
out.push({ ...s, probe: { ok: false, detail: 'credentials missing' } });
continue;
}
try {
if (s.id === 'REVOLUT') {
const rail = new revolut_business_1.RevolutBusinessRail();
const profile = (await rail.getProfile());
out.push({
...s,
probe: { ok: true, detail: `accounts=${profile.accountCount ?? '?'}` },
});
}
else if (s.id === 'WISE') {
const rail = new wise_platform_1.WisePlatformRail();
const profiles = await rail.listProfiles();
out.push({ ...s, probe: { ok: true, detail: `profiles=${profiles.length}` } });
}
else {
const rail = new binance_exchange_1.BinanceExchangeRail();
const ping = await rail.ping();
const acct = await rail.accountInfo();
out.push({
...s,
probe: {
ok: ping,
detail: `ping=${ping} balances=${acct.balances.length} canTrade=${acct.canTrade}`,
},
});
}
}
catch (e) {
out.push({
...s,
probe: { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) },
});
}
}
return out;
}
async function dispatchExternalBankOrExchange(payload) {
const allow = (payload.rail === 'REVOLUT' && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1') ||
(payload.rail === 'WISE' && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1') ||
(payload.rail === 'BINANCE' && process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1');
if (!allow) {
throw new Error(`${payload.rail} live dispatch blocked — set SETTLEMENT_ALLOW_${payload.rail}_PRODUCTION=1`);
}
if (payload.rail === 'REVOLUT') {
const rail = new revolut_business_1.RevolutBusinessRail();
const r = await rail.createPayment({
idempotencyKey: payload.idempotencyKey,
amount: payload.amount,
currency: payload.currency,
beneficiaryName: payload.beneficiaryName,
iban: payload.creditorIban,
bic: payload.creditorBic,
reference: payload.remittanceInfo,
});
return { providerId: r.paymentId, status: r.status, rail: 'REVOLUT' };
}
if (payload.rail === 'WISE') {
if (!payload.creditorIban)
throw new Error('creditorIban required for Wise');
const rail = new wise_platform_1.WisePlatformRail();
const r = await rail.payout({
idempotencyKey: payload.idempotencyKey,
amount: payload.amount,
currency: payload.currency,
beneficiaryName: payload.beneficiaryName,
iban: payload.creditorIban,
bic: payload.creditorBic,
reference: payload.remittanceInfo,
});
return { providerId: r.transferId, status: r.status, rail: 'WISE' };
}
// BINANCE
if (!payload.cryptoAddress)
throw new Error('cryptoAddress required for Binance withdraw');
const rail = new binance_exchange_1.BinanceExchangeRail();
const coin = (payload.coin || payload.currency || 'USDT').toUpperCase();
const r = await rail.withdraw({
coin,
amount: payload.amount,
address: payload.cryptoAddress,
network: payload.cryptoNetwork,
name: payload.beneficiaryName,
});
return { providerId: r.id, status: r.status, rail: 'BINANCE' };
}

View File

@@ -0,0 +1,111 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RevolutBusinessRail = void 0;
exports.loadRevolutConfig = loadRevolutConfig;
/**
* Revolut Business API (production) — accounts / payments.
* Docs: https://developer.revolut.com/docs/business/business-api
* Auth: Bearer access token (OAuth / Business API token).
*/
const axios_1 = __importDefault(require("axios"));
function loadRevolutConfig() {
const accessToken = (process.env.REVOLUT_ACCESS_TOKEN || process.env.REVOLUT_API_TOKEN || '').trim();
const baseUrl = (process.env.REVOLUT_API_BASE_URL || 'https://b2b.revolut.com/api/1.0').replace(/\/$/, '');
return {
baseUrl,
accessToken,
accountId: process.env.REVOLUT_ACCOUNT_ID?.trim() || undefined,
enabled: Boolean(accessToken) && process.env.REVOLUT_ENABLED !== '0',
};
}
class RevolutBusinessRail {
config;
http;
constructor(cfg = loadRevolutConfig()) {
this.config = cfg;
this.http = axios_1.default.create({
baseURL: cfg.baseUrl,
timeout: 60000,
headers: {
Authorization: `Bearer ${cfg.accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
}
isReady() {
return this.config.enabled && Boolean(this.config.accessToken);
}
async listAccounts() {
const { data } = await this.http.get('/accounts');
return Array.isArray(data) ? data : data?.accounts ?? [];
}
async getProfile() {
// Business API: /accounts is the usual smoke; some tenants expose /me via Open Banking
const accounts = await this.listAccounts();
return { accountCount: accounts.length, sample: accounts.slice(0, 2) };
}
/** Create a payment draft / counterparty payment (production path). */
async createPayment(payload) {
if (!this.isReady())
throw new Error('Revolut Business not configured (REVOLUT_ACCESS_TOKEN)');
const accountId = payload.accountId || this.config.accountId;
if (!accountId)
throw new Error('REVOLUT_ACCOUNT_ID required for payments');
if (!payload.iban)
throw new Error('creditor IBAN required for Revolut payout');
const body = {
request_id: payload.idempotencyKey.slice(0, 40),
account_id: accountId,
receiver: {
counterparty_id: undefined,
// Direct IBAN payout (Business API pay shape varies by account region)
iban: payload.iban,
bic: payload.bic,
name: payload.beneficiaryName,
},
amount: Number(payload.amount),
currency: payload.currency.toUpperCase(),
reference: payload.reference || payload.idempotencyKey.slice(0, 35),
};
const { data, status } = await this.http.post('/pay', body, {
headers: { 'X-Idempotency-Key': payload.idempotencyKey },
validateStatus: () => true,
});
if (status >= 400) {
// Fallback: create counterparty then pay (sandbox/prod variance)
const cp = await this.http.post('/counterparty', {
company_name: payload.beneficiaryName,
bank_country: payload.iban.slice(0, 2),
currency: payload.currency.toUpperCase(),
iban: payload.iban,
bic: payload.bic,
}, { validateStatus: () => true });
if (cp.status >= 400) {
throw new Error(`Revolut pay failed ${status}: ${JSON.stringify(data).slice(0, 300)}`);
}
const pay2 = await this.http.post('/pay', {
request_id: `${payload.idempotencyKey.slice(0, 30)}-2`,
account_id: accountId,
receiver: { counterparty_id: cp.data?.id },
amount: Number(payload.amount),
currency: payload.currency.toUpperCase(),
reference: payload.reference || 'OMNL',
}, { headers: { 'X-Idempotency-Key': `${payload.idempotencyKey}-2` } });
return {
paymentId: String(pay2.data?.id ?? payload.idempotencyKey),
status: String(pay2.data?.state ?? pay2.data?.status ?? 'PENDING'),
raw: pay2.data,
};
}
return {
paymentId: String(data?.id ?? payload.idempotencyKey),
status: String(data?.state ?? data?.status ?? 'PENDING'),
raw: data,
};
}
}
exports.RevolutBusinessRail = RevolutBusinessRail;

View File

@@ -0,0 +1,142 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WisePlatformRail = void 0;
exports.loadWiseConfig = loadWiseConfig;
/**
* Wise Platform API (production) — profiles / quotes / transfers.
* Docs: https://docs.wise.com/api-docs
* Auth: Bearer personal/business API token.
*/
const axios_1 = __importDefault(require("axios"));
function loadWiseConfig() {
const apiToken = (process.env.WISE_API_TOKEN || process.env.TRANSFERWISE_API_TOKEN || '').trim();
const baseUrl = (process.env.WISE_API_BASE_URL ||
process.env.TRANSFERWISE_API_BASE_URL ||
'https://api.transferwise.com').replace(/\/$/, '');
return {
baseUrl,
apiToken,
profileId: (process.env.WISE_PROFILE_ID || process.env.TRANSFERWISE_PROFILE_ID || '').trim() || undefined,
enabled: Boolean(apiToken) && process.env.WISE_ENABLED !== '0',
};
}
class WisePlatformRail {
config;
http;
constructor(cfg = loadWiseConfig()) {
this.config = cfg;
this.http = axios_1.default.create({
baseURL: cfg.baseUrl,
timeout: 60000,
headers: {
Authorization: `Bearer ${cfg.apiToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
}
isReady() {
return this.config.enabled && Boolean(this.config.apiToken);
}
async listProfiles() {
const { data } = await this.http.get('/v1/profiles');
return Array.isArray(data) ? data : [];
}
async resolveProfileId() {
if (this.config.profileId)
return this.config.profileId;
const profiles = await this.listProfiles();
const first = profiles[0];
if (!first?.id)
throw new Error('Wise profile not found — set WISE_PROFILE_ID');
return String(first.id);
}
async createQuote(payload) {
if (!this.isReady())
throw new Error('Wise not configured (WISE_API_TOKEN)');
const profileId = await this.resolveProfileId();
const body = {
profile: Number(profileId) || profileId,
sourceCurrency: payload.sourceCurrency.toUpperCase(),
targetCurrency: payload.targetCurrency.toUpperCase(),
payOut: 'BANK_TRANSFER',
};
if (payload.sourceAmount)
body.sourceAmount = Number(payload.sourceAmount);
else if (payload.targetAmount)
body.targetAmount = Number(payload.targetAmount);
else
throw new Error('sourceAmount or targetAmount required');
const { data } = await this.http.post('/v3/quotes', body);
return {
quoteId: String(data?.id ?? data?.uuid ?? ''),
rate: data?.rate != null ? Number(data.rate) : undefined,
raw: data,
};
}
async createRecipient(payload) {
const profileId = await this.resolveProfileId();
const currency = payload.currency.toUpperCase();
const type = payload.type || (payload.iban ? 'iban' : 'email');
const details = {};
if (payload.iban) {
details.IBAN = payload.iban;
if (payload.bic)
details.BIC = payload.bic;
}
const { data } = await this.http.post('/v1/accounts', {
profile: Number(profileId) || profileId,
accountHolderName: payload.accountHolderName,
currency,
type,
details,
});
return { accountId: Number(data?.id), raw: data };
}
async createTransfer(payload) {
if (!this.isReady())
throw new Error('Wise not configured (WISE_API_TOKEN)');
const { data } = await this.http.post('/v1/transfers', {
targetAccount: payload.targetAccountId,
quoteUuid: payload.quoteId,
customerTransactionId: payload.idempotencyKey.slice(0, 36),
details: { reference: payload.reference || 'OMNL Z Online Bank' },
});
return {
transferId: String(data?.id ?? payload.idempotencyKey),
status: String(data?.status ?? 'incoming_payment_waiting'),
raw: data,
};
}
/** Full payout: quote → recipient → transfer (fund separately if required). */
async payout(payload) {
const source = payload.currency.toUpperCase();
const target = (payload.targetCurrency || payload.currency).toUpperCase();
const quote = await this.createQuote({
sourceCurrency: source,
targetCurrency: target,
sourceAmount: payload.amount,
});
const recipient = await this.createRecipient({
currency: target,
accountHolderName: payload.beneficiaryName,
iban: payload.iban,
bic: payload.bic,
});
const transfer = await this.createTransfer({
idempotencyKey: payload.idempotencyKey,
quoteId: quote.quoteId,
targetAccountId: recipient.accountId,
reference: payload.reference,
});
return {
transferId: transfer.transferId,
status: transfer.status,
quoteId: quote.quoteId,
};
}
}
exports.WisePlatformRail = WisePlatformRail;

View File

@@ -15,6 +15,7 @@ const fiat_lp_to_liquidity_1 = require("../../workflows/fiat-lp-to-liquidity");
const btc_l1_settlement_1 = require("../../workflows/btc-l1-settlement");
const fineract_1 = require("../../adapters/fineract");
const pricing_1 = require("../../adapters/pricing");
const external_banks_hub_1 = require("../../adapters/external-banks-hub");
const auth_1 = require("../../middleware/auth");
async function respondPublicMoneySupply(res, officeId) {
const cfg = (0, config_1.loadConfig)();
@@ -353,16 +354,54 @@ function createSettlementRouter() {
});
return;
}
if (!body.creditorIban) {
res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' });
if (!body.creditorIban && body.rail !== 'BINANCE') {
res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto / rail=BINANCE)' });
return;
}
const externalRail = body.rail === 'RTGS'
? 'RTGS'
: body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE'
? body.rail
: 'SWIFT';
const record = await (0, transfer_1.processTransfer)({
...body,
officeId: (0, config_1.settlementOfficeId)(body.officeId),
rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
rail: externalRail,
moneyLayers: body.moneyLayers ?? ['M2', 'M4'],
});
if (record.phase !== 'FAILED' &&
(body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE')) {
try {
const dispatched = await (0, external_banks_hub_1.dispatchExternalBankOrExchange)({
rail: body.rail,
idempotencyKey: body.idempotencyKey || record.idempotencyKey || record.settlementId,
amount: String(body.amount),
currency: String(body.currency || 'USD'),
beneficiaryName: body.beneficiaryName || body.remittanceInfo || 'OMNL beneficiary',
creditorIban: body.creditorIban,
creditorBic: body.creditorBic,
remittanceInfo: body.remittanceInfo,
cryptoAddress: body.recipientAddress,
cryptoNetwork: body.cryptoNetwork,
coin: body.tokenSymbol || body.currency,
});
res.status(200).json({
...record,
externalRail: dispatched,
transferableExternal: true,
mode: body.rail === 'BINANCE' ? 'exchange' : 'bank',
});
return;
}
catch (e) {
res.status(422).json({
...record,
phase: 'FAILED',
errors: [...(record.errors || []), e instanceof Error ? e.message : String(e)],
});
return;
}
}
res.status(record.phase === 'FAILED' ? 422 : 200).json({
...record,
transferableExternal: true,
@@ -373,6 +412,56 @@ function createSettlementRouter() {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/rails/external', (_req, res) => {
res.json({
asOf: new Date().toISOString(),
rails: (0, external_banks_hub_1.getExternalRailsStatus)(),
usage: {
transfer: 'POST /transfer/external with rail=REVOLUT|WISE|BINANCE',
probe: 'GET /rails/external/probe',
smoke: 'node scripts/rails/smoke-revolut-wise-binance.mjs',
},
});
});
router.get('/rails/external/probe', async (req, res) => {
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const rails = await (0, external_banks_hub_1.probeExternalRails)();
res.json({ asOf: new Date().toISOString(), rails });
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.post('/rails/external/dispatch', async (req, res) => {
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const rail = String(req.body?.rail || '').toUpperCase();
if (!['REVOLUT', 'WISE', 'BINANCE'].includes(rail)) {
res.status(400).json({ error: 'rail must be REVOLUT, WISE, or BINANCE' });
return;
}
const out = await (0, external_banks_hub_1.dispatchExternalBankOrExchange)({
rail,
idempotencyKey: String(req.body?.idempotencyKey || `rail-${Date.now()}`),
amount: String(req.body?.amount || ''),
currency: String(req.body?.currency || 'USD'),
beneficiaryName: String(req.body?.beneficiaryName || 'OMNL'),
creditorIban: req.body?.creditorIban,
creditorBic: req.body?.creditorBic,
remittanceInfo: req.body?.remittanceInfo,
cryptoAddress: req.body?.cryptoAddress || req.body?.recipientAddress,
cryptoNetwork: req.body?.cryptoNetwork,
coin: req.body?.coin || req.body?.tokenSymbol,
});
res.status(200).json({ ok: true, ...out });
}
catch (e) {
res.status(422).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/tokens/m2', (_req, res) => {
try {
const fs = require('fs');

View File

@@ -44,4 +44,16 @@ const rootEnv = path_1.default.resolve(__dirname, '../../../.env');
if ((0, fs_1.existsSync)(rootEnv))
dotenv.config({ path: rootEnv });
dotenv.config();
/** Production rail secrets (gitignored) — Revolut / Wise / Binance */
const repoRoot = path_1.default.resolve(__dirname, '../../..');
for (const rel of [
'secrets/revolut/credentials.env',
'secrets/wise/credentials.env',
'secrets/binance/credentials.env',
'config/rails-revolut-wise-binance.env',
]) {
const p = path_1.default.join(repoRoot, rel);
if ((0, fs_1.existsSync)(p))
dotenv.config({ path: p, override: false });
}
(0, server_1.startServer)();

View File

@@ -12,7 +12,8 @@ const settlement_store_1 = require("../store/settlement-store");
const btc_l1_settlement_1 = require("./btc-l1-settlement");
/**
* INTERNAL / CHAIN138 → on-chain ERC-20 when execute enabled.
* SWIFT / RTGS → bank rail (IBAN).
* SWIFT / RTGS / REVOLUT / WISE → bank rail (IBAN).
* BINANCE → exchange withdraw (address) after ledger journal.
* CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain).
*/
async function processTransfer(input) {
@@ -20,13 +21,15 @@ async function processTransfer(input) {
const profile = (0, config_1.loadOfficeProfile)();
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
const req = { ...input, officeId };
const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS' || req.rail === 'REVOLUT' || req.rail === 'WISE';
const isExchangeExternal = req.rail === 'BINANCE';
/** Crypto leaving OMNL custody to any 0x wallet (external transferable). */
const isCryptoExternal = !isBankExternal &&
!isExchangeExternal &&
Boolean(req.tokenAddress?.startsWith('0x')) &&
Boolean(req.recipientAddress?.startsWith('0x')) &&
(req.rail === 'CHAIN138' || req.remittanceInfo?.includes('EXTERNAL_CRYPTO'));
const isExternal = isBankExternal || isCryptoExternal;
const isExternal = isBankExternal || isCryptoExternal || isExchangeExternal;
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
if (existing?.phase === 'SETTLED')
return existing;
@@ -42,20 +45,34 @@ async function processTransfer(input) {
currency: req.currency ?? (req.tokenSymbol === 'cBTC' ? 'BTC' : 'USD'),
amount: req.amount,
creditorIban: req.creditorIban ??
(isBankExternal ? '' : isCryptoExternal ? `EXT-CRYPTO-${req.recipientAddress}` : 'INTERNAL-CHAIN138'),
(isBankExternal
? ''
: isExchangeExternal
? `EXT-BINANCE-${req.recipientAddress || 'withdraw'}`
: isCryptoExternal
? `EXT-CRYPTO-${req.recipientAddress}`
: 'INTERNAL-CHAIN138'),
beneficiaryName: req.beneficiaryName ?? req.recipientAddress,
remittanceInfo: req.remittanceInfo ??
(isCryptoExternal
? `EXTERNAL_CRYPTO ${req.tokenSymbol}${req.recipientAddress}`
: `Transfer ${req.tokenSymbol}${req.recipientAddress}`),
: isExchangeExternal
? `BINANCE_WITHDRAW ${req.currency || req.tokenSymbol}`
: `Transfer ${req.tokenSymbol}${req.recipientAddress}`),
moneyLayers: req.moneyLayers.length
? req.moneyLayers
: isBankExternal
: isBankExternal || isExchangeExternal
? ['M2', 'M4']
: isCryptoExternal
? ['M3', 'M4']
: ['M2', 'M3'],
rail: req.rail === 'CHAIN138' ? 'CHAIN138' : req.rail === 'INTERNAL' ? 'INTERNAL' : 'SWIFT',
rail: req.rail === 'CHAIN138'
? 'CHAIN138'
: req.rail === 'INTERNAL'
? 'INTERNAL'
: req.rail === 'REVOLUT' || req.rail === 'WISE' || req.rail === 'BINANCE'
? req.rail
: 'SWIFT',
},
errors: [],
createdAt: now,
@@ -69,7 +86,11 @@ async function processTransfer(input) {
if (isBankExternal && !req.creditorIban) {
throw new Error('creditorIban required for external bank transfer');
}
if ((isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') && !req.recipientAddress?.startsWith('0x')) {
if (isExchangeExternal && !req.recipientAddress?.startsWith('0x') && !req.recipientAddress) {
throw new Error('recipientAddress required for Binance withdraw');
}
if ((isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') &&
!req.recipientAddress?.startsWith('0x')) {
throw new Error('recipientAddress (0x) required for on-chain transfer');
}
advance('VALIDATED');

View File

@@ -0,0 +1,126 @@
/**
* Binance Spot API (production) — account / withdraw / ticker.
* Docs: https://binance-docs.github.io/apidocs/spot/en/
* Auth: API key + HMAC SHA256 secret.
*/
import crypto from 'crypto';
import axios, { AxiosInstance } from 'axios';
export type BinanceConfig = {
baseUrl: string;
apiKey: string;
apiSecret: string;
enabled: boolean;
};
export function loadBinanceConfig(): BinanceConfig {
const apiKey = (process.env.BINANCE_API_KEY || '').trim();
const apiSecret = (process.env.BINANCE_API_SECRET || '').trim();
const baseUrl = (process.env.BINANCE_API_BASE_URL || 'https://api.binance.com').replace(/\/$/, '');
return {
baseUrl,
apiKey,
apiSecret,
enabled: Boolean(apiKey && apiSecret) && process.env.BINANCE_ENABLED !== '0',
};
}
export class BinanceExchangeRail {
readonly config: BinanceConfig;
private readonly http: AxiosInstance;
constructor(cfg = loadBinanceConfig()) {
this.config = cfg;
this.http = axios.create({
baseURL: cfg.baseUrl,
timeout: 60000,
headers: {
'X-MBX-APIKEY': cfg.apiKey,
Accept: 'application/json',
},
});
}
isReady(): boolean {
return this.config.enabled && Boolean(this.config.apiKey && this.config.apiSecret);
}
private sign(query: string): string {
return crypto.createHmac('sha256', this.config.apiSecret).update(query).digest('hex');
}
private async signedGet(path: string, params: Record<string, string | number> = {}) {
const timestamp = Date.now();
const qs = new URLSearchParams({
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
timestamp: String(timestamp),
}).toString();
const signature = this.sign(qs);
const { data } = await this.http.get(`${path}?${qs}&signature=${signature}`);
return data;
}
private async signedPost(path: string, params: Record<string, string | number> = {}) {
const timestamp = Date.now();
const body = new URLSearchParams({
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
timestamp: String(timestamp),
});
const signature = this.sign(body.toString());
body.append('signature', signature);
const { data } = await this.http.post(path, body.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
return data;
}
async ping(): Promise<boolean> {
const { status } = await this.http.get('/api/v3/ping');
return status === 200;
}
async accountInfo(): Promise<{ balances: Array<{ asset: string; free: string }>; canTrade: boolean }> {
if (!this.isReady()) throw new Error('Binance not configured (BINANCE_API_KEY/SECRET)');
const data = await this.signedGet('/api/v3/account');
const balances = (data.balances || [])
.filter((b: { free: string }) => Number(b.free) > 0)
.slice(0, 20)
.map((b: { asset: string; free: string }) => ({ asset: b.asset, free: b.free }));
return { balances, canTrade: Boolean(data.canTrade) };
}
async tickerPrice(symbol: string): Promise<{ symbol: string; price: string }> {
const { data } = await this.http.get('/api/v3/ticker/price', { params: { symbol } });
return { symbol: data.symbol, price: data.price };
}
/**
* Withdraw to external address (requires withdrawal permission on API key).
* Gate with BINANCE_ALLOW_WITHDRAW=1 in production.
*/
async withdraw(payload: {
coin: string;
amount: string;
address: string;
network?: string;
name?: string;
}): Promise<{ id: string; status: string; raw: unknown }> {
if (!this.isReady()) throw new Error('Binance not configured');
if (process.env.BINANCE_ALLOW_WITHDRAW !== '1') {
throw new Error('BINANCE_ALLOW_WITHDRAW=1 required for live withdrawals');
}
const params: Record<string, string> = {
coin: payload.coin.toUpperCase(),
amount: payload.amount,
address: payload.address,
};
if (payload.network) params.network = payload.network;
if (payload.name) params.name = payload.name.slice(0, 30);
const data = await this.signedPost('/sapi/v1/capital/withdraw/apply', params);
return {
id: String(data?.id ?? data?.withdrawOrderId ?? ''),
status: 'SUBMITTED',
raw: data,
};
}
}

View File

@@ -0,0 +1,165 @@
/**
* External banks & exchange hub — Revolut Business + Wise + Binance.
* Fail-closed until credentials present; live dispatch behind env gates.
*/
import { RevolutBusinessRail, loadRevolutConfig } from './revolut-business';
import { WisePlatformRail, loadWiseConfig } from './wise-platform';
import { BinanceExchangeRail, loadBinanceConfig } from './binance-exchange';
export type ExternalRailId = 'REVOLUT' | 'WISE' | 'BINANCE';
export type RailStatus = {
id: ExternalRailId;
name: string;
configured: boolean;
liveDispatchAllowed: boolean;
baseUrl: string;
note: string;
};
export function getExternalRailsStatus(): RailStatus[] {
const revolut = loadRevolutConfig();
const wise = loadWiseConfig();
const binance = loadBinanceConfig();
return [
{
id: 'REVOLUT',
name: 'Revolut Business',
configured: Boolean(revolut.accessToken),
liveDispatchAllowed:
Boolean(revolut.accessToken) && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1',
baseUrl: revolut.baseUrl,
note: 'Business API Bearer token · set REVOLUT_ACCESS_TOKEN + REVOLUT_ACCOUNT_ID',
},
{
id: 'WISE',
name: 'Wise Platform',
configured: Boolean(wise.apiToken),
liveDispatchAllowed: Boolean(wise.apiToken) && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1',
baseUrl: wise.baseUrl,
note: 'API token · set WISE_API_TOKEN (+ WISE_PROFILE_ID recommended)',
},
{
id: 'BINANCE',
name: 'Binance Exchange',
configured: Boolean(binance.apiKey && binance.apiSecret),
liveDispatchAllowed:
Boolean(binance.apiKey && binance.apiSecret) &&
process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1',
baseUrl: binance.baseUrl,
note: 'HMAC API key · withdrawals need BINANCE_ALLOW_WITHDRAW=1',
},
];
}
export async function probeExternalRails(): Promise<
Array<RailStatus & { probe?: { ok: boolean; detail: string } }>
> {
const status = getExternalRailsStatus();
const out: Array<RailStatus & { probe?: { ok: boolean; detail: string } }> = [];
for (const s of status) {
if (!s.configured) {
out.push({ ...s, probe: { ok: false, detail: 'credentials missing' } });
continue;
}
try {
if (s.id === 'REVOLUT') {
const rail = new RevolutBusinessRail();
const profile = (await rail.getProfile()) as { accountCount?: number };
out.push({
...s,
probe: { ok: true, detail: `accounts=${profile.accountCount ?? '?'}` },
});
} else if (s.id === 'WISE') {
const rail = new WisePlatformRail();
const profiles = await rail.listProfiles();
out.push({ ...s, probe: { ok: true, detail: `profiles=${profiles.length}` } });
} else {
const rail = new BinanceExchangeRail();
const ping = await rail.ping();
const acct = await rail.accountInfo();
out.push({
...s,
probe: {
ok: ping,
detail: `ping=${ping} balances=${acct.balances.length} canTrade=${acct.canTrade}`,
},
});
}
} catch (e) {
out.push({
...s,
probe: { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) },
});
}
}
return out;
}
export async function dispatchExternalBankOrExchange(payload: {
rail: ExternalRailId;
idempotencyKey: string;
amount: string;
currency: string;
beneficiaryName: string;
creditorIban?: string;
creditorBic?: string;
remittanceInfo?: string;
/** Binance withdraw */
cryptoAddress?: string;
cryptoNetwork?: string;
coin?: string;
}): Promise<{ providerId: string; status: string; rail: ExternalRailId }> {
const allow =
(payload.rail === 'REVOLUT' && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1') ||
(payload.rail === 'WISE' && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1') ||
(payload.rail === 'BINANCE' && process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1');
if (!allow) {
throw new Error(
`${payload.rail} live dispatch blocked — set SETTLEMENT_ALLOW_${payload.rail}_PRODUCTION=1`,
);
}
if (payload.rail === 'REVOLUT') {
const rail = new RevolutBusinessRail();
const r = await rail.createPayment({
idempotencyKey: payload.idempotencyKey,
amount: payload.amount,
currency: payload.currency,
beneficiaryName: payload.beneficiaryName,
iban: payload.creditorIban,
bic: payload.creditorBic,
reference: payload.remittanceInfo,
});
return { providerId: r.paymentId, status: r.status, rail: 'REVOLUT' };
}
if (payload.rail === 'WISE') {
if (!payload.creditorIban) throw new Error('creditorIban required for Wise');
const rail = new WisePlatformRail();
const r = await rail.payout({
idempotencyKey: payload.idempotencyKey,
amount: payload.amount,
currency: payload.currency,
beneficiaryName: payload.beneficiaryName,
iban: payload.creditorIban,
bic: payload.creditorBic,
reference: payload.remittanceInfo,
});
return { providerId: r.transferId, status: r.status, rail: 'WISE' };
}
// BINANCE
if (!payload.cryptoAddress) throw new Error('cryptoAddress required for Binance withdraw');
const rail = new BinanceExchangeRail();
const coin = (payload.coin || payload.currency || 'USDT').toUpperCase();
const r = await rail.withdraw({
coin,
amount: payload.amount,
address: payload.cryptoAddress,
network: payload.cryptoNetwork,
name: payload.beneficiaryName,
});
return { providerId: r.id, status: r.status, rail: 'BINANCE' };
}

View File

@@ -0,0 +1,133 @@
/**
* Revolut Business API (production) — accounts / payments.
* Docs: https://developer.revolut.com/docs/business/business-api
* Auth: Bearer access token (OAuth / Business API token).
*/
import axios, { AxiosInstance } from 'axios';
export type RevolutConfig = {
baseUrl: string;
accessToken: string;
accountId?: string;
enabled: boolean;
};
export function loadRevolutConfig(): RevolutConfig {
const accessToken = (process.env.REVOLUT_ACCESS_TOKEN || process.env.REVOLUT_API_TOKEN || '').trim();
const baseUrl = (process.env.REVOLUT_API_BASE_URL || 'https://b2b.revolut.com/api/1.0').replace(/\/$/, '');
return {
baseUrl,
accessToken,
accountId: process.env.REVOLUT_ACCOUNT_ID?.trim() || undefined,
enabled: Boolean(accessToken) && process.env.REVOLUT_ENABLED !== '0',
};
}
export class RevolutBusinessRail {
readonly config: RevolutConfig;
private readonly http: AxiosInstance;
constructor(cfg = loadRevolutConfig()) {
this.config = cfg;
this.http = axios.create({
baseURL: cfg.baseUrl,
timeout: 60000,
headers: {
Authorization: `Bearer ${cfg.accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
}
isReady(): boolean {
return this.config.enabled && Boolean(this.config.accessToken);
}
async listAccounts(): Promise<unknown[]> {
const { data } = await this.http.get('/accounts');
return Array.isArray(data) ? data : data?.accounts ?? [];
}
async getProfile(): Promise<unknown> {
// Business API: /accounts is the usual smoke; some tenants expose /me via Open Banking
const accounts = await this.listAccounts();
return { accountCount: accounts.length, sample: accounts.slice(0, 2) };
}
/** Create a payment draft / counterparty payment (production path). */
async createPayment(payload: {
idempotencyKey: string;
amount: string;
currency: string;
beneficiaryName: string;
iban?: string;
bic?: string;
accountId?: string;
reference?: string;
}): Promise<{ paymentId: string; status: string; raw: unknown }> {
if (!this.isReady()) throw new Error('Revolut Business not configured (REVOLUT_ACCESS_TOKEN)');
const accountId = payload.accountId || this.config.accountId;
if (!accountId) throw new Error('REVOLUT_ACCOUNT_ID required for payments');
if (!payload.iban) throw new Error('creditor IBAN required for Revolut payout');
const body = {
request_id: payload.idempotencyKey.slice(0, 40),
account_id: accountId,
receiver: {
counterparty_id: undefined as string | undefined,
// Direct IBAN payout (Business API pay shape varies by account region)
iban: payload.iban,
bic: payload.bic,
name: payload.beneficiaryName,
},
amount: Number(payload.amount),
currency: payload.currency.toUpperCase(),
reference: payload.reference || payload.idempotencyKey.slice(0, 35),
};
const { data, status } = await this.http.post('/pay', body, {
headers: { 'X-Idempotency-Key': payload.idempotencyKey },
validateStatus: () => true,
});
if (status >= 400) {
// Fallback: create counterparty then pay (sandbox/prod variance)
const cp = await this.http.post(
'/counterparty',
{
company_name: payload.beneficiaryName,
bank_country: payload.iban.slice(0, 2),
currency: payload.currency.toUpperCase(),
iban: payload.iban,
bic: payload.bic,
},
{ validateStatus: () => true },
);
if (cp.status >= 400) {
throw new Error(`Revolut pay failed ${status}: ${JSON.stringify(data).slice(0, 300)}`);
}
const pay2 = await this.http.post(
'/pay',
{
request_id: `${payload.idempotencyKey.slice(0, 30)}-2`,
account_id: accountId,
receiver: { counterparty_id: cp.data?.id },
amount: Number(payload.amount),
currency: payload.currency.toUpperCase(),
reference: payload.reference || 'OMNL',
},
{ headers: { 'X-Idempotency-Key': `${payload.idempotencyKey}-2` } },
);
return {
paymentId: String(pay2.data?.id ?? payload.idempotencyKey),
status: String(pay2.data?.state ?? pay2.data?.status ?? 'PENDING'),
raw: pay2.data,
};
}
return {
paymentId: String(data?.id ?? payload.idempotencyKey),
status: String(data?.state ?? data?.status ?? 'PENDING'),
raw: data,
};
}
}

View File

@@ -0,0 +1,171 @@
/**
* Wise Platform API (production) — profiles / quotes / transfers.
* Docs: https://docs.wise.com/api-docs
* Auth: Bearer personal/business API token.
*/
import axios, { AxiosInstance } from 'axios';
export type WiseConfig = {
baseUrl: string;
apiToken: string;
profileId?: string;
enabled: boolean;
};
export function loadWiseConfig(): WiseConfig {
const apiToken = (process.env.WISE_API_TOKEN || process.env.TRANSFERWISE_API_TOKEN || '').trim();
const baseUrl = (
process.env.WISE_API_BASE_URL ||
process.env.TRANSFERWISE_API_BASE_URL ||
'https://api.transferwise.com'
).replace(/\/$/, '');
return {
baseUrl,
apiToken,
profileId: (process.env.WISE_PROFILE_ID || process.env.TRANSFERWISE_PROFILE_ID || '').trim() || undefined,
enabled: Boolean(apiToken) && process.env.WISE_ENABLED !== '0',
};
}
export class WisePlatformRail {
readonly config: WiseConfig;
private readonly http: AxiosInstance;
constructor(cfg = loadWiseConfig()) {
this.config = cfg;
this.http = axios.create({
baseURL: cfg.baseUrl,
timeout: 60000,
headers: {
Authorization: `Bearer ${cfg.apiToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
}
isReady(): boolean {
return this.config.enabled && Boolean(this.config.apiToken);
}
async listProfiles(): Promise<unknown[]> {
const { data } = await this.http.get('/v1/profiles');
return Array.isArray(data) ? data : [];
}
async resolveProfileId(): Promise<string> {
if (this.config.profileId) return this.config.profileId;
const profiles = await this.listProfiles();
const first = profiles[0] as { id?: number | string } | undefined;
if (!first?.id) throw new Error('Wise profile not found — set WISE_PROFILE_ID');
return String(first.id);
}
async createQuote(payload: {
sourceCurrency: string;
targetCurrency: string;
sourceAmount?: string;
targetAmount?: string;
}): Promise<{ quoteId: string; rate?: number; raw: unknown }> {
if (!this.isReady()) throw new Error('Wise not configured (WISE_API_TOKEN)');
const profileId = await this.resolveProfileId();
const body: Record<string, unknown> = {
profile: Number(profileId) || profileId,
sourceCurrency: payload.sourceCurrency.toUpperCase(),
targetCurrency: payload.targetCurrency.toUpperCase(),
payOut: 'BANK_TRANSFER',
};
if (payload.sourceAmount) body.sourceAmount = Number(payload.sourceAmount);
else if (payload.targetAmount) body.targetAmount = Number(payload.targetAmount);
else throw new Error('sourceAmount or targetAmount required');
const { data } = await this.http.post('/v3/quotes', body);
return {
quoteId: String(data?.id ?? data?.uuid ?? ''),
rate: data?.rate != null ? Number(data.rate) : undefined,
raw: data,
};
}
async createRecipient(payload: {
currency: string;
accountHolderName: string;
iban?: string;
bic?: string;
type?: string;
}): Promise<{ accountId: number; raw: unknown }> {
const profileId = await this.resolveProfileId();
const currency = payload.currency.toUpperCase();
const type = payload.type || (payload.iban ? 'iban' : 'email');
const details: Record<string, unknown> = {};
if (payload.iban) {
details.IBAN = payload.iban;
if (payload.bic) details.BIC = payload.bic;
}
const { data } = await this.http.post('/v1/accounts', {
profile: Number(profileId) || profileId,
accountHolderName: payload.accountHolderName,
currency,
type,
details,
});
return { accountId: Number(data?.id), raw: data };
}
async createTransfer(payload: {
idempotencyKey: string;
quoteId: string;
targetAccountId: number;
reference?: string;
}): Promise<{ transferId: string; status: string; raw: unknown }> {
if (!this.isReady()) throw new Error('Wise not configured (WISE_API_TOKEN)');
const { data } = await this.http.post('/v1/transfers', {
targetAccount: payload.targetAccountId,
quoteUuid: payload.quoteId,
customerTransactionId: payload.idempotencyKey.slice(0, 36),
details: { reference: payload.reference || 'OMNL Z Online Bank' },
});
return {
transferId: String(data?.id ?? payload.idempotencyKey),
status: String(data?.status ?? 'incoming_payment_waiting'),
raw: data,
};
}
/** Full payout: quote → recipient → transfer (fund separately if required). */
async payout(payload: {
idempotencyKey: string;
amount: string;
currency: string;
targetCurrency?: string;
beneficiaryName: string;
iban: string;
bic?: string;
reference?: string;
}): Promise<{ transferId: string; status: string; quoteId: string }> {
const source = payload.currency.toUpperCase();
const target = (payload.targetCurrency || payload.currency).toUpperCase();
const quote = await this.createQuote({
sourceCurrency: source,
targetCurrency: target,
sourceAmount: payload.amount,
});
const recipient = await this.createRecipient({
currency: target,
accountHolderName: payload.beneficiaryName,
iban: payload.iban,
bic: payload.bic,
});
const transfer = await this.createTransfer({
idempotencyKey: payload.idempotencyKey,
quoteId: quote.quoteId,
targetAccountId: recipient.accountId,
reference: payload.reference,
});
return {
transferId: transfer.transferId,
status: transfer.status,
quoteId: quote.quoteId,
};
}
}

View File

@@ -29,6 +29,12 @@ import {
} from '../../workflows/btc-l1-settlement';
import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract';
import { resolveLiveBtcUsd } from '../../adapters/pricing';
import {
dispatchExternalBankOrExchange,
getExternalRailsStatus,
probeExternalRails,
type ExternalRailId,
} from '../../adapters/external-banks-hub';
import { requireApiKey } from '../../middleware/auth';
async function respondPublicMoneySupply(res: Response, officeId: number): Promise<void> {
@@ -368,16 +374,58 @@ export function createSettlementRouter(): Router {
return;
}
if (!body.creditorIban) {
res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' });
if (!body.creditorIban && body.rail !== 'BINANCE') {
res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto / rail=BINANCE)' });
return;
}
const externalRail =
body.rail === 'RTGS'
? 'RTGS'
: body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE'
? body.rail
: 'SWIFT';
const record = await processTransfer({
...body,
officeId: settlementOfficeId(body.officeId),
rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
rail: externalRail,
moneyLayers: body.moneyLayers ?? ['M2', 'M4'],
});
if (
record.phase !== 'FAILED' &&
(body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE')
) {
try {
const dispatched = await dispatchExternalBankOrExchange({
rail: body.rail as ExternalRailId,
idempotencyKey: body.idempotencyKey || record.idempotencyKey || record.settlementId,
amount: String(body.amount),
currency: String(body.currency || 'USD'),
beneficiaryName: body.beneficiaryName || body.remittanceInfo || 'OMNL beneficiary',
creditorIban: body.creditorIban,
creditorBic: body.creditorBic,
remittanceInfo: body.remittanceInfo,
cryptoAddress: body.recipientAddress,
cryptoNetwork: (body as { cryptoNetwork?: string }).cryptoNetwork,
coin: body.tokenSymbol || body.currency,
});
res.status(200).json({
...record,
externalRail: dispatched,
transferableExternal: true,
mode: body.rail === 'BINANCE' ? 'exchange' : 'bank',
});
return;
} catch (e) {
res.status(422).json({
...record,
phase: 'FAILED',
errors: [...(record.errors || []), e instanceof Error ? e.message : String(e)],
});
return;
}
}
res.status(record.phase === 'FAILED' ? 422 : 200).json({
...record,
transferableExternal: true,
@@ -388,6 +436,55 @@ export function createSettlementRouter(): Router {
}
});
router.get('/rails/external', (_req, res) => {
res.json({
asOf: new Date().toISOString(),
rails: getExternalRailsStatus(),
usage: {
transfer: 'POST /transfer/external with rail=REVOLUT|WISE|BINANCE',
probe: 'GET /rails/external/probe',
smoke: 'node scripts/rails/smoke-revolut-wise-binance.mjs',
},
});
});
router.get('/rails/external/probe', async (req, res) => {
if (!requireApiKey(req, res)) return;
try {
const rails = await probeExternalRails();
res.json({ asOf: new Date().toISOString(), rails });
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.post('/rails/external/dispatch', async (req, res) => {
if (!requireApiKey(req, res)) return;
try {
const rail = String(req.body?.rail || '').toUpperCase() as ExternalRailId;
if (!['REVOLUT', 'WISE', 'BINANCE'].includes(rail)) {
res.status(400).json({ error: 'rail must be REVOLUT, WISE, or BINANCE' });
return;
}
const out = await dispatchExternalBankOrExchange({
rail,
idempotencyKey: String(req.body?.idempotencyKey || `rail-${Date.now()}`),
amount: String(req.body?.amount || ''),
currency: String(req.body?.currency || 'USD'),
beneficiaryName: String(req.body?.beneficiaryName || 'OMNL'),
creditorIban: req.body?.creditorIban,
creditorBic: req.body?.creditorBic,
remittanceInfo: req.body?.remittanceInfo,
cryptoAddress: req.body?.cryptoAddress || req.body?.recipientAddress,
cryptoNetwork: req.body?.cryptoNetwork,
coin: req.body?.coin || req.body?.tokenSymbol,
});
res.status(200).json({ ok: true, ...out });
} catch (e) {
res.status(422).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/tokens/m2', (_req, res) => {
try {
const fs = require('fs') as typeof import('fs');

View File

@@ -7,4 +7,16 @@ const rootEnv = path.resolve(__dirname, '../../../.env');
if (existsSync(rootEnv)) dotenv.config({ path: rootEnv });
dotenv.config();
/** Production rail secrets (gitignored) — Revolut / Wise / Binance */
const repoRoot = path.resolve(__dirname, '../../..');
for (const rel of [
'secrets/revolut/credentials.env',
'secrets/wise/credentials.env',
'secrets/binance/credentials.env',
'config/rails-revolut-wise-binance.env',
]) {
const p = path.join(repoRoot, rel);
if (existsSync(p)) dotenv.config({ path: p, override: false });
}
startServer();

View File

@@ -10,7 +10,8 @@ import { allowChainMintExecute } from './btc-l1-settlement';
/**
* INTERNAL / CHAIN138 → on-chain ERC-20 when execute enabled.
* SWIFT / RTGS → bank rail (IBAN).
* SWIFT / RTGS / REVOLUT / WISE → bank rail (IBAN).
* BINANCE → exchange withdraw (address) after ledger journal.
* CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain).
*/
export async function processTransfer(input: TransferRequest): Promise<SettlementRecord> {
@@ -18,14 +19,17 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
const profile = loadOfficeProfile();
const officeId = settlementOfficeId(input.officeId);
const req = { ...input, officeId };
const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
const isBankExternal =
req.rail === 'SWIFT' || req.rail === 'RTGS' || req.rail === 'REVOLUT' || req.rail === 'WISE';
const isExchangeExternal = req.rail === 'BINANCE';
/** Crypto leaving OMNL custody to any 0x wallet (external transferable). */
const isCryptoExternal =
!isBankExternal &&
!isExchangeExternal &&
Boolean(req.tokenAddress?.startsWith('0x')) &&
Boolean(req.recipientAddress?.startsWith('0x')) &&
(req.rail === 'CHAIN138' || req.remittanceInfo?.includes('EXTERNAL_CRYPTO'));
const isExternal = isBankExternal || isCryptoExternal;
const isExternal = isBankExternal || isCryptoExternal || isExchangeExternal;
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
if (existing?.phase === 'SETTLED') return existing;
@@ -43,21 +47,36 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
amount: req.amount,
creditorIban:
req.creditorIban ??
(isBankExternal ? '' : isCryptoExternal ? `EXT-CRYPTO-${req.recipientAddress}` : 'INTERNAL-CHAIN138'),
(isBankExternal
? ''
: isExchangeExternal
? `EXT-BINANCE-${req.recipientAddress || 'withdraw'}`
: isCryptoExternal
? `EXT-CRYPTO-${req.recipientAddress}`
: 'INTERNAL-CHAIN138'),
beneficiaryName: req.beneficiaryName ?? req.recipientAddress,
remittanceInfo:
req.remittanceInfo ??
(isCryptoExternal
? `EXTERNAL_CRYPTO ${req.tokenSymbol}${req.recipientAddress}`
: `Transfer ${req.tokenSymbol}${req.recipientAddress}`),
: isExchangeExternal
? `BINANCE_WITHDRAW ${req.currency || req.tokenSymbol}`
: `Transfer ${req.tokenSymbol}${req.recipientAddress}`),
moneyLayers: req.moneyLayers.length
? req.moneyLayers
: isBankExternal
: isBankExternal || isExchangeExternal
? ['M2', 'M4']
: isCryptoExternal
? ['M3', 'M4']
: ['M2', 'M3'],
rail: req.rail === 'CHAIN138' ? 'CHAIN138' : req.rail === 'INTERNAL' ? 'INTERNAL' : 'SWIFT',
rail:
req.rail === 'CHAIN138'
? 'CHAIN138'
: req.rail === 'INTERNAL'
? 'INTERNAL'
: req.rail === 'REVOLUT' || req.rail === 'WISE' || req.rail === 'BINANCE'
? req.rail
: 'SWIFT',
},
errors: [],
createdAt: now,
@@ -73,7 +92,13 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
if (isBankExternal && !req.creditorIban) {
throw new Error('creditorIban required for external bank transfer');
}
if ((isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') && !req.recipientAddress?.startsWith('0x')) {
if (isExchangeExternal && !req.recipientAddress?.startsWith('0x') && !req.recipientAddress) {
throw new Error('recipientAddress required for Binance withdraw');
}
if (
(isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') &&
!req.recipientAddress?.startsWith('0x')
) {
throw new Error('recipientAddress (0x) required for on-chain transfer');
}
advance('VALIDATED');

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>OMNL — Revolut · Wise · Binance rails</title>
<style>
:root { --bg:#071018; --fg:#e8eef5; --muted:#8fa3b8; --ok:#3dd68c; --warn:#f0b429; --bad:#f07178; --line:#1a2836; --accent:#c9a227; }
body { margin:0; font-family:"Segoe UI",system-ui,sans-serif; background:var(--bg); color:var(--fg); }
main { max-width:900px; margin:0 auto; padding:2rem 1.25rem; }
h1 { color:var(--accent); margin:0 0 .5rem; }
.badge { display:inline-block; margin:.2rem .35rem .2rem 0; padding:.25rem .65rem; border:1px solid var(--ok); color:var(--ok); border-radius:999px; font-size:.72rem; font-weight:700; }
.sub { color:var(--muted); margin:0 0 1.5rem; line-height:1.5; }
table { width:100%; border-collapse:collapse; }
th,td { text-align:left; padding:.7rem .45rem; border-bottom:1px solid var(--line); vertical-align:top; font-size:.9rem; }
th { color:var(--muted); font-size:.7rem; text-transform:uppercase; }
.ok { color:var(--ok); font-weight:600; }
.warn { color:var(--warn); }
.bad { color:var(--bad); }
code { font-size:.78rem; color:#c5d4e3; }
a { color:var(--accent); }
.note { margin-top:1.25rem; color:var(--muted); font-size:.85rem; line-height:1.5; }
</style></head>
<body><main>
<div>
<span class="badge">REVOLUT BUSINESS</span>
<span class="badge">WISE PLATFORM</span>
<span class="badge">BINANCE SPOT</span>
</div>
<h1>External production rails</h1>
<p class="sub">Live adapters for Revolut Business, Wise, and Binance. Credentials load from env / <code>secrets/*</code>. Live payouts require explicit <code>SETTLEMENT_ALLOW_*_PRODUCTION=1</code> gates.</p>
<table>
<thead><tr><th>Rail</th><th>Configured</th><th>Live gate</th><th>Base URL</th><th>Note</th></tr></thead>
<tbody id="tbody"><tr><td colspan="5">Loading…</td></tr></tbody>
</table>
<p class="note">
Settlement: <code>GET /api/v1/settlement/rails/external</code><br>
Transfer: <code>POST /transfer/external</code> with <code>rail=REVOLUT|WISE|BINANCE</code><br>
Smoke: <code>node scripts/rails/smoke-revolut-wise-binance.mjs</code><br>
Cards: <a href="/zbank/cards">/zbank/cards</a> · Random pack includes Revolut / Wise / Binance themes
</p>
</main>
<script>
async function load() {
const tbody = document.getElementById('tbody');
// Prefer settlement middleware if proxied; else show static template from known env pattern
const urls = [
'/settlement/rails/external',
'/api/v1/settlement/rails/external',
'http://127.0.0.1:3011/api/v1/settlement/rails/external',
];
let data = null;
for (const u of urls) {
try {
const r = await fetch(u);
if (r.ok) { data = await r.json(); break; }
} catch {}
}
if (!data?.rails) {
tbody.innerHTML = `<tr><td colspan="5" class="warn">Settlement rails endpoint unreachable — start settlement-middleware. Adapters are shipped; add credentials to secrets/revolut|wise|binance/credentials.env</td></tr>`;
return;
}
tbody.innerHTML = data.rails.map(r => {
const cfg = r.configured ? '<span class="ok">YES</span>' : '<span class="bad">NO</span>';
const live = r.liveDispatchAllowed ? '<span class="ok">OPEN</span>' : '<span class="warn">CLOSED</span>';
return `<tr><td><strong>${r.name}</strong><br><code>${r.id}</code></td><td>${cfg}</td><td>${live}</td><td><code>${r.baseUrl}</code></td><td>${r.note}</td></tr>`;
}).join('');
}
load();
</script>
</body></html>

View File

@@ -65,6 +65,22 @@
}
.face.front.eur { background: linear-gradient(135deg, #1b4332 0%, #2d6a4f 50%, #081c15 100%); }
.face.front.gbp { background: linear-gradient(135deg, #4a1942 0%, #7b2d5e 45%, #1a0a16 100%); }
.face.front.theme-nova { background: linear-gradient(135deg, #0ea5e9 0%, #0369a1 50%, #0c4a6e 100%); }
.face.front.theme-anaka { background: linear-gradient(135deg, #a855f7 0%, #6b21a8 50%, #3b0764 100%); }
.face.front.theme-onex { background: linear-gradient(135deg, #f97316 0%, #c2410c 50%, #7c2d12 100%); }
.face.front.theme-omnl { background: linear-gradient(135deg, #d4af37 0%, #8a6d1a 55%, #3d3008 100%); }
.face.front.theme-hermes { background: linear-gradient(135deg, #14b8a6 0%, #0f766e 50%, #134e4a 100%); }
.face.front.theme-tsunami { background: linear-gradient(135deg, #38bdf8 0%, #0284c7 45%, #082f49 100%); }
.face.front.theme-hybx { background: linear-gradient(135deg, #ef4444 0%, #991b1b 50%, #450a0a 100%); }
.face.front.theme-zbank { background: linear-gradient(135deg, #1a1f71 0%, #0b3d91 45%, #162447 100%); }
.face.front.theme-revolut { background: linear-gradient(135deg, #191c1f 0%, #2d3436 45%, #636e72 100%); }
.face.front.theme-wise { background: linear-gradient(135deg, #9fe870 0%, #2e7d32 50%, #1b5e20 100%); color:#0a0a0a; }
.face.front.theme-binance { background: linear-gradient(135deg, #f0b90b 0%, #c99400 45%, #3d2e00 100%); }
.ex-name {
position: absolute; top: 1rem; left: 1.15rem;
font-size: .68rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase;
opacity: .95; max-width: 55%;
}
.face.back {
transform: rotateY(180deg);
background: linear-gradient(160deg, #1c2430 0%, #121820 55%, #0a0e14 100%);
@@ -202,10 +218,10 @@
<div>
<span class="badge">FRONT + BACK</span>
<span class="badge">ONLINE VISA</span>
<span class="badge">CVV / MAGSTRIPE</span>
<span class="badge">RANDOM EXCHANGES</span>
</div>
<h1>Online Cards — Front &amp; Back</h1>
<p class="sub">Hover or click a card to flip. Back shows magnetic stripe, signature strip, and CVV. Create front + back for each IBAN wallet.</p>
<p class="sub">Hover or click to flip. Create Z Online cards or mint a random pack of exchange-branded Visas (Nova, Anaka, OneX, HYBX, Tsunami…).</p>
<div class="grid" id="faces"></div>
@@ -222,14 +238,18 @@
<button id="issue" type="button">Create Front + Back</button>
<button id="issueAll" type="button" class="secondary">Create All 3 CCY</button>
</div>
<div class="btn-row">
<button id="issueRandom" type="button">Random Exchange Cards ×4</button>
<button id="issueRandom8" type="button" class="secondary">All Exchanges Pack</button>
</div>
<div class="result" id="result"></div>
</div>
<div class="panel list">
<h2>Issued cards</h2>
<table>
<thead><tr><th>Currency</th><th>Masked PAN</th><th>CVV</th><th>Expiry</th><th>Back</th><th>IBAN</th></tr></thead>
<tbody id="tbody"><tr><td colspan="6">Loading…</td></tr></tbody>
<thead><tr><th>Exchange</th><th>Currency</th><th>Masked PAN</th><th>CVV</th><th>Expiry</th><th>Back</th><th>IBAN</th></tr></thead>
<tbody id="tbody"><tr><td colspan="7">Loading…</td></tr></tbody>
</table>
<p style="margin-top:1rem;color:var(--muted);font-size:.85rem">
<a href="/zbank/cards">/zbank/cards</a> ·
@@ -243,6 +263,8 @@ const tbody = document.getElementById('tbody');
const result = document.getElementById('result');
const issueBtn = document.getElementById('issue');
const issueAllBtn = document.getElementById('issueAll');
const issueRandomBtn = document.getElementById('issueRandom');
const issueRandom8Btn = document.getElementById('issueRandom8');
const sessionSecrets = {}; // panMasked -> { pan, cvv }
function cvvDisplay(c) {
@@ -259,11 +281,14 @@ function cvvDisplay(c) {
function cardPairHtml(c) {
const cls = (c.currency || '').toLowerCase();
const theme = c.exchange?.theme ? `theme-${c.exchange.theme}` : (cls === 'usd' ? 'theme-zbank' : cls);
const cvv = cvvDisplay(c);
const last4 = (c.panMasked || '').slice(-4);
const exLabel = c.exchange?.name || 'Z Online Bank';
return `<div class="flip-wrap" tabindex="0" title="Click to flip">
<div class="flip-inner">
<div class="face front ${cls}">
<div class="face front ${cls} ${theme}">
<div class="ex-name">${exLabel}</div>
<div class="ccy-tag">${c.currency} FRONT</div>
<div class="chip"></div>
<div class="pan">${c.panMasked || '•••• •••• •••• ••••'}</div>
@@ -284,13 +309,13 @@ function cardPairHtml(c) {
<div class="cvv-box"><span>CVV</span>${cvv}</div>
</div>
<div class="back-meta">
<strong>${c.currency} BACK</strong> · Z Online Bank<br>
<strong>${exLabel}</strong> · ${c.currency} BACK<br>
Debit · **** ${last4} · Exp ${c.expiry || '—'}<br>
IBAN ${(c.linkedIban || '').slice(0, 8)} · ATM / ecomm enabled
BIC ${c.exchange?.bicHint || 'ZBKNOMNLXXX'} · IBAN ${(c.linkedIban || '').slice(0, 8)}
</div>
</div>
</div>
<div class="hint">hover / click → back</div>
<div class="hint">hover / click → flip</div>
</div>`;
}
@@ -302,10 +327,11 @@ function collectCards(data) {
if (a.card) cards.push({ ...a.card, currency: a.currency, linkedIban: a.iban });
for (const oc of a.onlineCards || []) cards.push(oc);
}
for (const ec of data.exchangeCards || []) cards.push(ec);
const seen = new Set();
const uniq = [];
for (const c of cards) {
const k = `${c.currency}:${c.panMasked}`;
const k = `${c.currency}:${c.panMasked}:${c.exchange?.id || ''}`;
if (seen.has(k)) continue;
seen.add(k);
uniq.push(c);
@@ -320,7 +346,7 @@ async function load() {
return;
}
const uniq = collectCards(await r.json());
faces.innerHTML = uniq.slice(0, 9).map(cardPairHtml).join('') || '<p style="color:var(--muted)">No cards yet.</p>';
faces.innerHTML = uniq.slice(0, 12).map(cardPairHtml).join('') || '<p style="color:var(--muted)">No cards yet.</p>';
faces.querySelectorAll('.flip-wrap').forEach((el) => {
el.addEventListener('click', () => el.classList.toggle('flipped'));
el.addEventListener('keydown', (e) => {
@@ -328,13 +354,14 @@ async function load() {
});
});
tbody.innerHTML = uniq.map(c => `<tr>
<td>${c.exchange?.name || 'Z Online'}</td>
<td>${c.currency || ''}</td>
<td><code>${c.panMasked || ''}</code></td>
<td><code>${cvvDisplay(c)}</code></td>
<td>${c.expiry || ''}</td>
<td class="ok">READY</td>
<td><code>${c.linkedIban || ''}</code></td>
</tr>`).join('') || '<tr><td colspan="6">None yet</td></tr>';
</tr>`).join('') || '<tr><td colspan="7">None yet</td></tr>';
}
async function issueOne(currency, revealPan) {
@@ -384,7 +411,7 @@ issueAllBtn.addEventListener('click', async () => {
lines.push(`${currency} ${c.panMasked} CVV ${c.cvv || c.cvvMasked || '•••'} BACK READY`);
}
result.classList.add('show');
result.innerHTML = `<span class="ok">CREATED 3× FRONT + BACK</span>\\n` + lines.join('\\n');
result.innerHTML = `<span class="ok">CREATED 3× FRONT + BACK</span><br>` + lines.join('<br>');
await load();
} catch (e) {
result.classList.add('show');
@@ -394,6 +421,55 @@ issueAllBtn.addEventListener('click', async () => {
}
});
async function issueRandomPack(count) {
const revealPan = document.getElementById('reveal').checked;
const r = await fetch('/zbank/api/cards/random-exchanges', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count, revealPan }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || r.statusText);
for (const c of j.cards || []) {
if (c.panMasked && (c.pan || c.cvv)) sessionSecrets[c.panMasked] = { pan: c.pan, cvv: c.cvv };
}
return j.cards || [];
}
issueRandomBtn.addEventListener('click', async () => {
issueRandomBtn.disabled = true;
result.classList.remove('show');
try {
const cards = await issueRandomPack(4);
result.classList.add('show');
result.innerHTML = `<span class="ok">RANDOM EXCHANGE PACK ×${cards.length}</span><br>` +
cards.map(c => `${c.exchange?.name || '?'} · ${c.currency} · ${c.panMasked}`).join('<br>');
await load();
} catch (e) {
result.classList.add('show');
result.textContent = 'Error: ' + (e && e.message ? e.message : e);
} finally {
issueRandomBtn.disabled = false;
}
});
issueRandom8Btn.addEventListener('click', async () => {
issueRandom8Btn.disabled = true;
result.classList.remove('show');
try {
const cards = await issueRandomPack(8);
result.classList.add('show');
result.innerHTML = `<span class="ok">FULL EXCHANGE PACK ×${cards.length}</span><br>` +
cards.map(c => `${c.exchange?.name || '?'} · ${c.currency} · ${c.panMasked} · ${c.exchange?.bicHint || ''}`).join('<br>');
await load();
} catch (e) {
result.classList.add('show');
result.textContent = 'Error: ' + (e && e.message ? e.message : e);
} finally {
issueRandom8Btn.disabled = false;
}
});
load();
</script>
</body>

View File

@@ -36,7 +36,7 @@ import metamaskPriceRoutes from './routes/metamask-prices';
import { MultiChainIndexer } from '../indexer/chain-indexer';
import { OmnlEventPoller } from '../indexer/omnl-event-poller';
import { getDatabasePool } from '../database/client';
import { issueCardForCurrency, loadCustomerRegistry } from '../lib/zbank-online-cards';
import { issueCardForCurrency, issueRandomExchangeCards, loadCustomerRegistry, EXCHANGE_CATALOG } from '../lib/zbank-online-cards';
import winston from 'winston';
// Setup logger
@@ -299,6 +299,22 @@ export class ApiServer {
this.app.get('/zbank/cards', sendZbankOnlineCards);
this.app.get('/zbank/online-cards', sendZbankOnlineCards);
const railsHubPath = path.join(__dirname, '../../public/rails-revolut-wise-binance.html');
this.app.get('/rails/revolut-wise-binance', (_req: Request, res: Response) => {
if (!existsSync(railsHubPath)) {
res.status(404).type('text/plain').send('rails-revolut-wise-binance.html missing');
return;
}
res.type('html').send(readFileSync(railsHubPath, 'utf8'));
});
this.app.get('/zbank/rails', (_req: Request, res: Response) => {
if (!existsSync(railsHubPath)) {
res.status(404).type('text/plain').send('rails page missing');
return;
}
res.type('html').send(readFileSync(railsHubPath, 'utf8'));
});
const repoRoot = path.resolve(__dirname, '../../../..');
this.app.get('/zbank/api/customer/zardasht', (_req: Request, res: Response) => {
try {
@@ -317,7 +333,8 @@ export class ApiServer {
try {
const currency = String(req.body?.currency || 'USD').toUpperCase();
const revealPan = Boolean(req.body?.revealPan);
const out = issueCardForCurrency(repoRoot, currency, revealPan);
const exchange = req.body?.exchange ? String(req.body.exchange) : undefined;
const out = issueCardForCurrency(repoRoot, currency, revealPan, exchange);
if (!out.ok) {
res.status(400).json({ error: out.error });
return;
@@ -328,6 +345,25 @@ export class ApiServer {
}
});
this.app.get('/zbank/api/exchanges', (_req: Request, res: Response) => {
res.json({ exchanges: EXCHANGE_CATALOG });
});
this.app.post('/zbank/api/cards/random-exchanges', (req: Request, res: Response) => {
try {
const count = Math.min(8, Math.max(1, Number(req.body?.count || 4)));
const revealPan = Boolean(req.body?.revealPan);
const out = issueRandomExchangeCards(repoRoot, count, revealPan);
if (!out.ok) {
res.status(400).json({ error: out.error });
return;
}
res.status(201).json({ ok: true, count: out.cards.length, cards: out.cards });
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
// 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({

View File

@@ -24,6 +24,14 @@ export type OnlineCard = {
atmEnabled: boolean;
contactless: boolean;
createdAt: string;
/** Exchange / ISB member branding on the plastic. */
exchange?: {
id: string;
name: string;
officeId: number | null;
theme: string;
bicHint: string;
};
/** Back-of-card panel (magstripe / signature / CVV area). */
back: {
magstripe: true;
@@ -35,6 +43,40 @@ export type OnlineCard = {
networks: Record<string, { status: string; note: string }>;
};
/** OMNL ISB / partner exchanges for random branded cards. */
export const EXCHANGE_CATALOG = [
{ id: 'zbank', name: 'Z Online Bank', officeId: 29, theme: 'zbank', bicHint: 'ZBKNOMNLXXX' },
{ id: 'novabank', name: 'NovaBank', officeId: 30, theme: 'nova', bicHint: 'NOVAGB2LXXX' },
{ id: 'anakabank', name: 'AnakaBank', officeId: 31, theme: 'anaka', bicHint: 'ANAKOMNL33' },
{ id: 'onex', name: 'OneX Exchange', officeId: null, theme: 'onex', bicHint: 'ONEXSHIV33' },
{ id: 'omnl-fx', name: 'OMNL Forex Desk', officeId: 1, theme: 'omnl', bicHint: 'OMNLUS33XXX' },
{ id: 'hermes', name: 'Hermes Desk', officeId: 32, theme: 'hermes', bicHint: 'HRMSOMNL33' },
{ id: 'tsunami', name: 'Tsunami Exchange', officeId: 15, theme: 'tsunami', bicHint: 'TSUNOMNL33' },
{ id: 'hybx', name: 'HYBX Rail', officeId: 3, theme: 'hybx', bicHint: 'HYBXOMNL33' },
{ id: 'revolut', name: 'Revolut Business', officeId: null, theme: 'revolut', bicHint: 'REVOGB2LXXX' },
{ id: 'wise', name: 'Wise Bank', officeId: null, theme: 'wise', bicHint: 'TRWIUS33XXX' },
{ id: 'binance', name: 'Binance Exchange', officeId: null, theme: 'binance', bicHint: 'BINANCE' },
] as const;
export type ExchangeDef = (typeof EXCHANGE_CATALOG)[number];
export function pickRandomExchanges(count: number): ExchangeDef[] {
const pool = [...EXCHANGE_CATALOG];
const out: ExchangeDef[] = [];
const n = Math.min(Math.max(1, count), pool.length);
for (let i = 0; i < n; i++) {
const idx = randomInt(0, pool.length);
out.push(pool.splice(idx, 1)[0]);
}
return out;
}
export function pickRandomCurrency(accounts: Array<{ currency?: string }>): string {
const codes = accounts.map((a) => String(a.currency || '').toUpperCase()).filter(Boolean);
if (codes.length === 0) return 'USD';
return codes[randomInt(0, codes.length)];
}
function luhnCheckDigit(partial: string): string {
let sum = 0;
let dbl = true;
@@ -83,6 +125,7 @@ export function buildOnlineCard(opts: {
iban: string;
accountNo: string | null;
nameOnCard: string;
exchange?: ExchangeDef;
}): OnlineCard & { pan?: string; cvv?: string } {
const pan = issueOnlineVisaPan();
const cvv = String(randomInt(100, 999));
@@ -90,10 +133,12 @@ export function buildOnlineCard(opts: {
const expYear = String(new Date().getFullYear() + 4).slice(-2);
const id = `zcard_${Date.now().toString(36)}_${randomInt(1000, 9999)}`;
const nameOnCard = opts.nameOnCard.toUpperCase();
const exchange = opts.exchange;
const product = exchange ? `${exchange.name} Visa` : 'Z Online Card';
return {
id,
brand: 'Visa',
product: 'Z Online Card',
product,
formFactor: 'online',
currency: opts.currency,
linkedIban: opts.iban,
@@ -110,6 +155,17 @@ export function buildOnlineCard(opts: {
createdAt: new Date().toISOString(),
pan,
cvv,
...(exchange
? {
exchange: {
id: exchange.id,
name: exchange.name,
officeId: exchange.officeId,
theme: exchange.theme,
bicHint: exchange.bicHint,
},
}
: {}),
back: {
magstripe: true,
signatureStrip: true,
@@ -125,6 +181,10 @@ export function buildOnlineCard(opts: {
westernUnion: { status: 'RAIL_MAPPED', note: 'WU via SWIFT/HYBX' },
atm: { status: 'ENABLED_LEDGER', note: 'ATM when BIN switch live' },
ecommerce: { status: 'ACTIVE', note: 'Online CNP spend on linked IBAN balance' },
exchange: {
status: exchange ? 'BRANDED' : 'ZBANK',
note: exchange ? `${exchange.name} co-brand · BIC ${exchange.bicHint}` : 'Z Online Bank default',
},
},
};
}
@@ -133,6 +193,7 @@ export function issueCardForCurrency(
repoRoot: string,
currency: string,
revealPan = false,
exchange?: ExchangeDef | string,
): { ok: true; card: OnlineCard; account: Record<string, unknown> } | { ok: false; error: string } {
const reg = loadCustomerRegistry(repoRoot);
if (!reg) return { ok: false, error: 'Customer registry missing — run provision script first' };
@@ -141,19 +202,31 @@ export function issueCardForCurrency(
if (!acct) return { ok: false, error: `No IBAN account for ${currency}` };
const customer = reg.customer as { displayName?: string };
let ex: ExchangeDef | undefined;
if (typeof exchange === 'string' && exchange) {
ex = EXCHANGE_CATALOG.find((e) => e.id === exchange.toLowerCase() || e.theme === exchange.toLowerCase());
} else if (exchange && typeof exchange === 'object') {
ex = exchange;
}
const card = buildOnlineCard({
currency: String(acct.currency),
iban: String(acct.iban),
accountNo: acct.fineractAccountNo ? String(acct.fineractAccountNo) : null,
nameOnCard: customer.displayName || 'CARDHOLDER',
exchange: ex,
});
const { pan, cvv, ...publicCard } = card;
const onlineCards = (Array.isArray(acct.onlineCards) ? acct.onlineCards : []) as OnlineCard[];
onlineCards.push(publicCard);
acct.onlineCards = onlineCards;
// Primary online card pointer
acct.onlineCard = publicCard;
const exchangeCards = (Array.isArray(reg.exchangeCards) ? reg.exchangeCards : []) as OnlineCard[];
if (ex) exchangeCards.push(publicCard);
reg.exchangeCards = exchangeCards;
saveCustomerRegistry(repoRoot, reg);
const out: OnlineCard & { pan?: string; cvv?: string } = { ...publicCard };
@@ -163,3 +236,25 @@ export function issueCardForCurrency(
}
return { ok: true, card: out, account: acct };
}
/** Issue N randomly selected exchange-branded cards across funded IBANs. */
export function issueRandomExchangeCards(
repoRoot: string,
count = 4,
revealPan = false,
): { ok: true; cards: OnlineCard[] } | { ok: false; error: string } {
const reg = loadCustomerRegistry(repoRoot);
if (!reg) return { ok: false, error: 'Customer registry missing — run provision script first' };
const accounts = (reg.accounts as Array<Record<string, unknown>>) || [];
if (accounts.length === 0) return { ok: false, error: 'No IBAN accounts' };
const exchanges = pickRandomExchanges(count);
const cards: OnlineCard[] = [];
for (const ex of exchanges) {
const currency = pickRandomCurrency(accounts as Array<{ currency?: string }>);
const issued = issueCardForCurrency(repoRoot, currency, revealPan, ex);
if (!issued.ok) return { ok: false, error: issued.error };
cards.push(issued.card);
}
return { ok: true, cards };
}