Refine OMNL portal and vault controls
This commit is contained in:
@@ -18,6 +18,7 @@ export default function Layout() {
|
||||
{ to: '/bridge', label: 'Bridge', active: isActive('/bridge') },
|
||||
{ to: '/swap', label: 'Swap', active: isActive('/swap') },
|
||||
{ to: '/reserve', label: 'Reserve', active: isActive('/reserve') },
|
||||
{ href: '/omnl/gru-vault-controls', label: 'Vault Controls' },
|
||||
{ to: '/history', label: 'History', active: isActive('/history') },
|
||||
{ to: '/wallets', label: 'Wallets', active: isActive('/wallets') },
|
||||
{ to: '/admin', label: 'Admin', active: isActive('/admin') },
|
||||
@@ -55,6 +56,12 @@ export default function Layout() {
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<a
|
||||
href="/omnl/gru-vault-controls"
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium text-[#A0A0A0] hover:text-white hover:bg-white/5 transition-colors whitespace-nowrap min-h-[44px] flex items-center"
|
||||
>
|
||||
Vault Controls
|
||||
</a>
|
||||
<a
|
||||
href={defaultFrontendExplorerUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -56,7 +56,13 @@ export default function MobileNavDrawer({ open, onClose, title, items }: Props)
|
||||
if (item.href) {
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer" className={className} onClick={onClose}>
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className={className}
|
||||
onClick={onClose}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function OmnlProductLayout() {
|
||||
{ to: '/bridge', label: 'Bridge', active: location.pathname === '/bridge' },
|
||||
{ to: '/admin', label: 'Admin', active: location.pathname === '/admin' },
|
||||
{ to: '/wallets', label: 'Wallets', active: location.pathname === '/wallets' },
|
||||
{ href: '/omnl/gru-vault-controls', label: 'Vault Controls' },
|
||||
];
|
||||
|
||||
const isBanking = location.pathname === '/central-bank';
|
||||
@@ -75,6 +76,13 @@ export default function OmnlProductLayout() {
|
||||
<NasaIcon name="bridge" size={14} />
|
||||
Bridge
|
||||
</Link>
|
||||
<a
|
||||
href="/omnl/gru-vault-controls"
|
||||
className="hidden xl:inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-[#848e9c] hover:text-white hover:bg-white/5 min-h-[40px]"
|
||||
>
|
||||
<NasaIcon name="admin" size={14} />
|
||||
Vault Controls
|
||||
</a>
|
||||
<div className="shrink-0 max-w-[45%] sm:max-w-none ml-auto">
|
||||
<WalletConnect onBeforeDisconnect={() => { userInitiatedDisconnectRef.current = true; }} />
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ export const OPERATOR_CONSOLE_LINKS: EcosystemLink[] = [
|
||||
{ id: 'compliance', label: 'OMNL Compliance Console', description: 'Posture, pending actions, Safe tx', icon: 'shield', href: '/omnl/compliance', accent: 'purple' },
|
||||
{ id: 'omnl-dash', label: 'OMNL API Snapshot', description: 'Registry and Fineract compare JSON', icon: 'chart', href: '/omnl/dashboard', accent: 'blue' },
|
||||
{ id: 'reserve-dash', label: 'Reserve Dashboard', description: 'Proof rows, coverage, institutional view', icon: 'money', href: '/reserve', accent: 'green' },
|
||||
{ id: 'gru-controls', label: 'GRU Vault Controls', description: 'Runtime smart-vault policy knobs', icon: 'admin', href: '/omnl/gru-vault-controls', accent: 'gold' },
|
||||
{ id: 'settlement-term', label: 'Settlement Terminal', description: 'Operator settlement terminal UI', icon: 'terminal', href: '/omnl/terminal', accent: 'gold' },
|
||||
{ id: 'token-admin', label: 'Token Aggregation API', description: 'API catalog JSON', icon: 'ledger', href: '/api/v1/omnl/catalog', accent: 'teal' },
|
||||
];
|
||||
|
||||
@@ -22,6 +22,7 @@ const CHAIN138: { path: string; label: string; icon: NasaIconName }[] = [
|
||||
const OMNL_OPS: { path: string; label: string; icon: NasaIconName }[] = [
|
||||
{ path: '/omnl/compliance', label: 'OMNL Compliance console', icon: 'ledger' },
|
||||
{ path: '/omnl/dashboard', label: 'OMNL API snapshot', icon: 'chart' },
|
||||
{ path: '/omnl/gru-vault-controls', label: 'GRU Vault Controls', icon: 'admin' },
|
||||
{ path: '/omnl/terminal', label: 'Settlement terminal', icon: 'rail' },
|
||||
];
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function ReservePage() {
|
||||
}, []);
|
||||
|
||||
const reserveLinks = OPERATOR_CONSOLE_LINKS.filter((l) =>
|
||||
['reserve-dash', 'compliance', 'omnl-dash'].includes(l.id),
|
||||
['reserve-dash', 'compliance', 'omnl-dash', 'gru-controls'].includes(l.id),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
209
services/token-aggregation/public/gru-vault-controls.css
Normal file
209
services/token-aggregation/public/gru-vault-controls.css
Normal file
@@ -0,0 +1,209 @@
|
||||
:root {
|
||||
--bg: #090d12;
|
||||
--panel: #111822;
|
||||
--panel-2: #0f1520;
|
||||
--border: #223146;
|
||||
--text: #e8eef8;
|
||||
--muted: #8aa0b8;
|
||||
--accent: #65d6ff;
|
||||
--accent-2: #8cffc1;
|
||||
--warn: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(101, 214, 255, 0.14), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(140, 255, 193, 0.12), transparent 24%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 18px 40px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 3vw, 3.2rem);
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button,
|
||||
.btn {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text);
|
||||
border-radius: 12px;
|
||||
padding: 11px 14px;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, rgba(101, 214, 255, 0.9), rgba(140, 255, 193, 0.86));
|
||||
color: #04111b;
|
||||
border-color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.banner {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.banner.loading { color: var(--muted); }
|
||||
.banner.ok { border-color: rgba(140, 255, 193, 0.35); }
|
||||
.banner.error { border-color: rgba(239, 68, 68, 0.5); color: #fecaca; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(17, 24, 34, 0.96), rgba(11, 16, 25, 0.96));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kv div,
|
||||
.form-grid label {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.kv span,
|
||||
.form-grid span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.kv strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.note,
|
||||
.form-grid small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
label input,
|
||||
label select,
|
||||
label textarea {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(5, 8, 13, 0.9);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
label textarea {
|
||||
resize: vertical;
|
||||
min-height: 118px;
|
||||
}
|
||||
|
||||
label.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
label.switch input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: rgba(5, 8, 13, 0.95);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
max-height: 360px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.grid,
|
||||
.form-grid,
|
||||
.kv {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
125
services/token-aggregation/public/gru-vault-controls.html
Normal file
125
services/token-aggregation/public/gru-vault-controls.html
Normal file
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="omnl-api-base" content="auto" />
|
||||
<title>GRU Smart Vault Controls</title>
|
||||
<link rel="stylesheet" href="/omnl/static/gru-vault-controls.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Operator dashboard</p>
|
||||
<h1>GRU Smart Vault Controls</h1>
|
||||
<p class="sub">
|
||||
Runtime policy knobs for the smart vault layer. The ladder remains fixed at
|
||||
<code>1 M00 = 5 M0 = 25 M1</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button id="btn-refresh" type="button" class="primary">Refresh</button>
|
||||
<button id="btn-save" type="button">Save</button>
|
||||
<button id="btn-reset" type="button">Reset defaults</button>
|
||||
<button id="btn-raw" type="button">Raw JSON</button>
|
||||
<a class="btn" href="/reserve">Reserve dashboard</a>
|
||||
<a class="btn" href="/omnl/compliance">Compliance console</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="status" class="banner loading">Initializing…</div>
|
||||
<p id="updated-at" class="sub"></p>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>Canonical ladder</h2>
|
||||
<div class="kv">
|
||||
<div><span>Expression</span><strong>1 M00 = 5 M0 = 25 M1</strong></div>
|
||||
<div><span>M00 → M0</span><strong>5x fixed</strong></div>
|
||||
<div><span>M0 → M1</span><strong>5x fixed</strong></div>
|
||||
</div>
|
||||
<p class="note">
|
||||
The dashboard can tune runtime policy references and component selections, but the canonical
|
||||
ratio is hard-coded and cannot be changed here.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Profile</h2>
|
||||
<label>
|
||||
<span>Runtime profile</span>
|
||||
<select id="profile">
|
||||
<option value="live">Live</option>
|
||||
<option value="conservative">Conservative</option>
|
||||
<option value="expanded">Expanded</option>
|
||||
<option value="emergency">Emergency</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input id="cstar-enabled" type="checkbox" />
|
||||
<span>c* settlement tier enabled</span>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input id="expanded-enabled" type="checkbox" />
|
||||
<span>Expanded asset classes enabled</span>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input id="bond-enabled" type="checkbox" />
|
||||
<span>Approved bond basket enabled</span>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Operational tuning</h2>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>LiXAU withdrawal fraction</span>
|
||||
<input id="withdraw-fraction" type="number" min="0.05" max="0.5" step="0.01" />
|
||||
<small>Default 0.20. Display and policy hint only.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Reserve reference multiplier</span>
|
||||
<input id="reserve-multiplier" type="number" min="0.5" max="5" step="0.01" />
|
||||
<small>Controls reserve reference presentation; archival formula reference remains explicit.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Refresh interval (seconds)</span>
|
||||
<input id="refresh-interval" type="number" min="5" max="300" step="1" />
|
||||
<small>Dashboard polling interval for the operator page.</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Component families</h2>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>M00 Li* base components</span>
|
||||
<textarea id="li-components" rows="4" spellcheck="false"></textarea>
|
||||
<small>Comma or newline separated. Example: LiXAU, LiPMG, LiBMG1, LiBMG2, LiBMG3.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Approved bond components</span>
|
||||
<textarea id="bond-components" rows="4" spellcheck="false"></textarea>
|
||||
<small>Editable bundle for approved bond rows when policy needs to change without redeploy.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Expanded asset components</span>
|
||||
<textarea id="expanded-components" rows="4" spellcheck="false"></textarea>
|
||||
<small>Future or registry-only classes such as LiCARB / LiRES*.</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="raw-section" class="card hidden">
|
||||
<h2>Raw snapshot</h2>
|
||||
<pre id="raw-json">{}</pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/omnl/static/omnl-api-base.js"></script>
|
||||
<script src="/omnl/static/gru-vault-controls.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
147
services/token-aggregation/public/gru-vault-controls.js
Normal file
147
services/token-aggregation/public/gru-vault-controls.js
Normal file
@@ -0,0 +1,147 @@
|
||||
(function () {
|
||||
const apiBaseResolver = window.resolveOmnlApiBase || (() => '/api/v1');
|
||||
const apiBase = apiBaseResolver().replace(/\/$/, '');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('access_token')?.trim() || '';
|
||||
const authQuery = accessToken ? `?access_token=${encodeURIComponent(accessToken)}` : '';
|
||||
|
||||
const els = {
|
||||
status: document.getElementById('status'),
|
||||
updatedAt: document.getElementById('updated-at'),
|
||||
profile: document.getElementById('profile'),
|
||||
withdrawFraction: document.getElementById('withdraw-fraction'),
|
||||
reserveMultiplier: document.getElementById('reserve-multiplier'),
|
||||
refreshInterval: document.getElementById('refresh-interval'),
|
||||
cstarEnabled: document.getElementById('cstar-enabled'),
|
||||
expandedEnabled: document.getElementById('expanded-enabled'),
|
||||
bondEnabled: document.getElementById('bond-enabled'),
|
||||
liComponents: document.getElementById('li-components'),
|
||||
bondComponents: document.getElementById('bond-components'),
|
||||
expandedComponents: document.getElementById('expanded-components'),
|
||||
rawSection: document.getElementById('raw-section'),
|
||||
rawJson: document.getElementById('raw-json'),
|
||||
btnRefresh: document.getElementById('btn-refresh'),
|
||||
btnSave: document.getElementById('btn-save'),
|
||||
btnReset: document.getElementById('btn-reset'),
|
||||
btnRaw: document.getElementById('btn-raw'),
|
||||
};
|
||||
|
||||
function listToText(list) {
|
||||
return Array.isArray(list) ? list.join(', ') : '';
|
||||
}
|
||||
|
||||
function textToList(text) {
|
||||
return String(text || '')
|
||||
.split(/[\n,]/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function setStatus(type, message) {
|
||||
els.status.className = `banner ${type}`;
|
||||
els.status.textContent = message;
|
||||
}
|
||||
|
||||
function apiUrl(path) {
|
||||
return `${apiBase}${path}${authQuery}`;
|
||||
}
|
||||
|
||||
function readForm() {
|
||||
return {
|
||||
profile: els.profile.value,
|
||||
smartVault: {
|
||||
liXauWithdrawalFraction: Number(els.withdrawFraction.value),
|
||||
cStarSettlementEnabled: Boolean(els.cstarEnabled.checked),
|
||||
expandedAssetClassesEnabled: Boolean(els.expandedEnabled.checked),
|
||||
approvedBondBasketEnabled: Boolean(els.bondEnabled.checked),
|
||||
},
|
||||
componentFamilies: {
|
||||
liIndexComponents: textToList(els.liComponents.value),
|
||||
approvedBondComponents: textToList(els.bondComponents.value),
|
||||
expandedAssetComponents: textToList(els.expandedComponents.value),
|
||||
},
|
||||
dashboard: {
|
||||
reserveReferenceMultiplier: Number(els.reserveMultiplier.value),
|
||||
refreshIntervalSeconds: Number(els.refreshInterval.value),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fillForm(snapshot) {
|
||||
const controls = snapshot.controls || snapshot;
|
||||
els.profile.value = controls.profile || 'live';
|
||||
els.withdrawFraction.value = controls.smartVault?.liXauWithdrawalFraction ?? 0.2;
|
||||
els.reserveMultiplier.value = controls.dashboard?.reserveReferenceMultiplier ?? 1.2;
|
||||
els.refreshInterval.value = controls.dashboard?.refreshIntervalSeconds ?? 30;
|
||||
els.cstarEnabled.checked = Boolean(controls.smartVault?.cStarSettlementEnabled);
|
||||
els.expandedEnabled.checked = Boolean(controls.smartVault?.expandedAssetClassesEnabled);
|
||||
els.bondEnabled.checked = Boolean(controls.smartVault?.approvedBondBasketEnabled);
|
||||
els.liComponents.value = listToText(controls.componentFamilies?.liIndexComponents);
|
||||
els.bondComponents.value = listToText(controls.componentFamilies?.approvedBondComponents);
|
||||
els.expandedComponents.value = listToText(controls.componentFamilies?.expandedAssetComponents);
|
||||
els.updatedAt.textContent = `Updated: ${controls.updatedAt || snapshot.updatedAt || '—'} · Profile: ${controls.profile || 'live'}`;
|
||||
els.rawJson.textContent = JSON.stringify(snapshot, null, 2);
|
||||
}
|
||||
|
||||
async function loadControls() {
|
||||
setStatus('loading', 'Loading GRU vault controls…');
|
||||
try {
|
||||
const res = await fetch(apiUrl('/omnl/gru-vault-controls'), { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
fillForm(data);
|
||||
setStatus('ok', 'GRU vault controls loaded. Changes are persisted server-side.');
|
||||
} catch (error) {
|
||||
setStatus('error', `Load failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveControls() {
|
||||
setStatus('loading', 'Saving runtime controls…');
|
||||
try {
|
||||
const payload = readForm();
|
||||
const res = await fetch(apiUrl('/omnl/gru-vault-controls'), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
fillForm(data);
|
||||
setStatus('ok', 'Runtime controls saved without redeploying.');
|
||||
} catch (error) {
|
||||
setStatus('error', `Save failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetControls() {
|
||||
setStatus('loading', 'Resetting controls to defaults…');
|
||||
try {
|
||||
const res = await fetch(apiUrl('/omnl/gru-vault-controls/reset'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
fillForm(data);
|
||||
setStatus('ok', 'Controls reset to defaults.');
|
||||
} catch (error) {
|
||||
setStatus('error', `Reset failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
els.btnRefresh?.addEventListener('click', loadControls);
|
||||
els.btnSave?.addEventListener('click', saveControls);
|
||||
els.btnReset?.addEventListener('click', resetControls);
|
||||
els.btnRaw?.addEventListener('click', () => {
|
||||
els.rawSection?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
loadControls();
|
||||
})();
|
||||
@@ -17,6 +17,7 @@
|
||||
<div class="toolbar">
|
||||
<button type="button" class="primary" id="btn-refresh">Refresh</button>
|
||||
<button type="button" id="btn-toggle-raw">Raw JSON</button>
|
||||
<a class="btn" href="/omnl/gru-vault-controls">Vault controls</a>
|
||||
<a class="btn" href="/api/v1/reserve/proof-index">Proof index API</a>
|
||||
<a class="btn" href="/omnl/compliance">Compliance console</a>
|
||||
</div>
|
||||
|
||||
@@ -4,3 +4,10 @@
|
||||
{"id":"4d21094d-56fd-4324-a75b-1075f9a5dd59","ts":"2026-07-06T00:23:58.822Z","category":"reconcile","action":"triple_state_reconcile","traceId":"0xfefb7411285a639b00be06bbffe897eb802ee817983aa57facf5bde6a1b52ff4","status":"error","metadata":{"breakCount":3,"aligned":false}}
|
||||
{"id":"9b28b030-b396-4e07-9aa8-47854797096c","ts":"2026-07-06T00:28:58.771Z","category":"reconcile","action":"triple_state_reconcile","traceId":"0xfefb7411285a639b00be06bbffe897eb802ee817983aa57facf5bde6a1b52ff4","status":"error","metadata":{"breakCount":3,"aligned":false}}
|
||||
{"id":"d7ea5f0c-5827-4795-ae98-23f958b92646","ts":"2026-07-06T00:29:30.710Z","category":"reconcile","action":"triple_state_reconcile","traceId":"0xfefb7411285a639b00be06bbffe897eb802ee817983aa57facf5bde6a1b52ff4","status":"error","metadata":{"breakCount":3,"aligned":false}}
|
||||
{"id":"c284835f-23c1-4f7f-9648-0558da9fd540","ts":"2026-07-12T22:29:52.477Z","category":"api","action":"GET /omnl/gru-vault-controls","sourceSystem":"http","traceId":"e9598ce7-448f-44e0-bbea-0366d9d4108f","status":"ok","metadata":{"statusCode":200,"durationMs":9,"query":{}}}
|
||||
{"id":"93b1b6c2-58c5-4379-841c-8568f8c85524","ts":"2026-07-12T22:29:52.578Z","category":"api","action":"update_gru_vault_controls","actor":"operator","status":"ok","metadata":{"profile":"conservative","liIndexComponents":2,"approvedBondComponents":2,"expandedAssetComponents":2,"liXauWithdrawalFraction":0.15,"reserveReferenceMultiplier":1.35,"refreshIntervalSeconds":45}}
|
||||
{"id":"e02b9819-3040-458d-828e-9a045d818667","ts":"2026-07-12T22:29:52.582Z","category":"api","action":"PUT /omnl/gru-vault-controls","sourceSystem":"http","traceId":"6967adc2-e7c8-4d8d-b05a-b727f43cbc82","status":"ok","metadata":{"statusCode":200,"durationMs":10,"query":{}}}
|
||||
{"id":"b85e62ba-1cd1-41e7-a3ce-f7b32db46808","ts":"2026-07-12T22:29:52.609Z","category":"api","action":"update_gru_vault_controls","actor":"operator","status":"ok","metadata":{"profile":"emergency","liIndexComponents":5,"approvedBondComponents":0,"expandedAssetComponents":4,"liXauWithdrawalFraction":0.3,"reserveReferenceMultiplier":1.2,"refreshIntervalSeconds":30}}
|
||||
{"id":"0847e9a4-6d0d-44b2-9024-52653806cd02","ts":"2026-07-12T22:29:52.611Z","category":"api","action":"PUT /omnl/gru-vault-controls","sourceSystem":"http","traceId":"399f9edb-15f0-4dad-9b96-faf579336f4e","status":"ok","metadata":{"statusCode":200,"durationMs":3,"query":{}}}
|
||||
{"id":"fbc5fc45-7ded-4174-b316-49a990b01120","ts":"2026-07-12T22:29:52.624Z","category":"api","action":"reset_gru_vault_controls","actor":"operator","status":"ok","metadata":{"profile":"live"}}
|
||||
{"id":"420b3ed9-88e6-4748-a9b8-2697589e8c1c","ts":"2026-07-12T22:29:52.626Z","category":"api","action":"POST /omnl/gru-vault-controls/reset","sourceSystem":"http","traceId":"ae0ec963-aca9-461c-833c-b7d2dab1c7db","status":"ok","metadata":{"statusCode":200,"durationMs":4,"query":{}}}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import gruVaultControlsRoutes from './gru-vault-controls';
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1', gruVaultControlsRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
return { server, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
describe('GRU vault controls API', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
const originalApiKey = process.env.OMNL_API_KEY;
|
||||
const originalControlsPath = process.env.GRU_VAULT_CONTROLS_PATH;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.OMNL_API_KEY = 'test-omnl-key';
|
||||
process.env.GRU_VAULT_CONTROLS_PATH = path.join('/tmp', `gru-vault-controls-${Date.now()}.json`);
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(process.env.GRU_VAULT_CONTROLS_PATH || '', { force: true }).catch(() => undefined);
|
||||
process.env.GRU_VAULT_CONTROLS_PATH = path.join('/tmp', `gru-vault-controls-${Date.now()}-${Math.random()}.json`);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
if (originalApiKey === undefined) {
|
||||
delete process.env.OMNL_API_KEY;
|
||||
} else {
|
||||
process.env.OMNL_API_KEY = originalApiKey;
|
||||
}
|
||||
if (originalControlsPath === undefined) {
|
||||
delete process.env.GRU_VAULT_CONTROLS_PATH;
|
||||
} else {
|
||||
process.env.GRU_VAULT_CONTROLS_PATH = originalControlsPath;
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('returns canonical defaults when no runtime file exists', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/omnl/gru-vault-controls`, {
|
||||
headers: { Authorization: 'Bearer test-omnl-key' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.controls.canonicalLadder).toEqual({
|
||||
expression: '1 M00 = 5 M0 = 25 M1',
|
||||
m00ToM0Ratio: 5,
|
||||
m0ToM1Ratio: 5,
|
||||
});
|
||||
expect(body.controls.smartVault.liXauWithdrawalFraction).toBeCloseTo(0.2);
|
||||
expect(body.controls.componentFamilies.liIndexComponents).toEqual(
|
||||
expect.arrayContaining(['LiXAU', 'LiPMG', 'LiBMG1', 'LiBMG2', 'LiBMG3'])
|
||||
);
|
||||
});
|
||||
|
||||
it('persists runtime adjustments without changing the canonical ladder', async () => {
|
||||
const payload = {
|
||||
profile: 'conservative',
|
||||
smartVault: {
|
||||
liXauWithdrawalFraction: 0.15,
|
||||
cStarSettlementEnabled: false,
|
||||
expandedAssetClassesEnabled: true,
|
||||
approvedBondBasketEnabled: true,
|
||||
},
|
||||
componentFamilies: {
|
||||
liIndexComponents: ['LiXAU', 'LiPMG'],
|
||||
approvedBondComponents: ['Bond-A', 'Bond-B'],
|
||||
expandedAssetComponents: ['LiCARB', 'LiRES1'],
|
||||
},
|
||||
dashboard: {
|
||||
reserveReferenceMultiplier: 1.35,
|
||||
refreshIntervalSeconds: 45,
|
||||
},
|
||||
canonicalLadder: {
|
||||
expression: 'bad',
|
||||
m00ToM0Ratio: 7,
|
||||
m0ToM1Ratio: 9,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/omnl/gru-vault-controls`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-omnl-key',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.controls.profile).toBe('conservative');
|
||||
expect(body.controls.smartVault.liXauWithdrawalFraction).toBeCloseTo(0.15);
|
||||
expect(body.controls.smartVault.cStarSettlementEnabled).toBe(false);
|
||||
expect(body.controls.componentFamilies.liIndexComponents).toEqual(['LiXAU', 'LiPMG']);
|
||||
expect(body.controls.canonicalLadder).toEqual({
|
||||
expression: '1 M00 = 5 M0 = 25 M1',
|
||||
m00ToM0Ratio: 5,
|
||||
m0ToM1Ratio: 5,
|
||||
});
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(process.env.GRU_VAULT_CONTROLS_PATH || '', 'utf8')) as Record<string, any>;
|
||||
expect(stored.profile).toBe('conservative');
|
||||
expect(stored.canonicalLadder).toEqual({
|
||||
expression: '1 M00 = 5 M0 = 25 M1',
|
||||
m00ToM0Ratio: 5,
|
||||
m0ToM1Ratio: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('can reset controls to defaults', async () => {
|
||||
await fetch(`${baseUrl}/api/v1/omnl/gru-vault-controls`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-omnl-key',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
profile: 'emergency',
|
||||
smartVault: { liXauWithdrawalFraction: 0.3 },
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/omnl/gru-vault-controls/reset`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer test-omnl-key' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.controls.profile).toBe('live');
|
||||
expect(body.controls.smartVault.liXauWithdrawalFraction).toBeCloseTo(0.2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { omnlRateLimiter } from '../middleware/rate-limit';
|
||||
import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware';
|
||||
import { omnlSensitiveRouteGuard, omnlRequireApiKeyInProduction } from '../middleware/omnl-guards';
|
||||
import {
|
||||
getDefaultGruVaultControls,
|
||||
getGruVaultControlsPath,
|
||||
loadGruVaultControls,
|
||||
resetGruVaultControls,
|
||||
saveGruVaultControls,
|
||||
} from '../../services/gru-vault-controls';
|
||||
import { appendOmnlAudit } from '../../services/omnl-audit-log';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(omnlRateLimiter);
|
||||
router.use(omnlAuditMiddleware);
|
||||
router.use(omnlRequireApiKeyInProduction);
|
||||
|
||||
function extractBody(req: Request): unknown {
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
if (!body) return {};
|
||||
if (body.controls && typeof body.controls === 'object') return body.controls;
|
||||
return body;
|
||||
}
|
||||
|
||||
router.get('/omnl/gru-vault-controls', omnlSensitiveRouteGuard, (_req: Request, res: Response) => {
|
||||
res.json(loadGruVaultControls());
|
||||
});
|
||||
|
||||
router.get('/omnl/gru-vault-controls/defaults', omnlSensitiveRouteGuard, (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
path: getGruVaultControlsPath(),
|
||||
controls: getDefaultGruVaultControls(),
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/omnl/gru-vault-controls', omnlSensitiveRouteGuard, (req: Request, res: Response) => {
|
||||
try {
|
||||
const snapshot = saveGruVaultControls(extractBody(req));
|
||||
appendOmnlAudit({
|
||||
category: 'api',
|
||||
action: 'update_gru_vault_controls',
|
||||
actor: String(req.headers['x-omnl-actor'] ?? req.headers['x-forwarded-user'] ?? 'operator'),
|
||||
status: 'ok',
|
||||
metadata: {
|
||||
profile: snapshot.controls.profile,
|
||||
liIndexComponents: snapshot.controls.componentFamilies.liIndexComponents.length,
|
||||
approvedBondComponents: snapshot.controls.componentFamilies.approvedBondComponents.length,
|
||||
expandedAssetComponents: snapshot.controls.componentFamilies.expandedAssetComponents.length,
|
||||
liXauWithdrawalFraction: snapshot.controls.smartVault.liXauWithdrawalFraction,
|
||||
reserveReferenceMultiplier: snapshot.controls.dashboard.reserveReferenceMultiplier,
|
||||
refreshIntervalSeconds: snapshot.controls.dashboard.refreshIntervalSeconds,
|
||||
},
|
||||
});
|
||||
res.json(snapshot);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(400).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/omnl/gru-vault-controls/reset', omnlSensitiveRouteGuard, (_req: Request, res: Response) => {
|
||||
try {
|
||||
const snapshot = resetGruVaultControls();
|
||||
appendOmnlAudit({
|
||||
category: 'api',
|
||||
action: 'reset_gru_vault_controls',
|
||||
actor: String(_req.headers['x-omnl-actor'] ?? _req.headers['x-forwarded-user'] ?? 'operator'),
|
||||
status: 'ok',
|
||||
metadata: { profile: snapshot.controls.profile },
|
||||
});
|
||||
res.json(snapshot);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(400).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -26,6 +26,7 @@ import omnlIpsasRoutes from './routes/omnl-ipsas';
|
||||
import omnlPayoutJournalRoutes from './routes/omnl-payout-journal-routes';
|
||||
import omnlComplianceRoutes from './routes/omnl-compliance-routes';
|
||||
import omnlTerminalRoutes from './routes/omnl-terminal-routes';
|
||||
import gruVaultControlsRoutes from './routes/gru-vault-controls';
|
||||
import zWalletRoutes from './routes/z-wallet';
|
||||
import zBotRoutes from './routes/z-bot';
|
||||
import checkpointRoutes from './routes/checkpoint';
|
||||
@@ -193,6 +194,7 @@ export class ApiServer {
|
||||
const settlementTerminalPath = path.join(__dirname, '../../public/omnl-settlement-terminal.html');
|
||||
const reserveDashboardPath = path.join(__dirname, '../../public/reserve-dashboard.html');
|
||||
const reserveInstitutionalPath = path.join(__dirname, '../../public/reserve-institutional.html');
|
||||
const gruVaultControlsPath = path.join(__dirname, '../../public/gru-vault-controls.html');
|
||||
|
||||
const authorizeOmnlHtml = (req: Request, res: Response): boolean => {
|
||||
const tok = process.env.OMNL_DASHBOARD_TOKEN?.trim();
|
||||
@@ -252,6 +254,15 @@ export class ApiServer {
|
||||
res.type('html').send(readFileSync(file, 'utf8'));
|
||||
});
|
||||
|
||||
this.app.get('/omnl/gru-vault-controls', (req: Request, res: Response) => {
|
||||
if (!authorizeOmnlHtml(req, res)) return;
|
||||
if (!existsSync(gruVaultControlsPath)) {
|
||||
res.status(404).type('text/plain').send('gru-vault-controls.html missing');
|
||||
return;
|
||||
}
|
||||
res.type('html').send(readFileSync(gruVaultControlsPath, 'utf8'));
|
||||
});
|
||||
|
||||
// Public API catalog (register before routers so GET /api/v1 is not swallowed by middleware-only mounts)
|
||||
const sendApiV1Catalog = (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
@@ -321,6 +332,7 @@ export class ApiServer {
|
||||
this.app.use('/api/v1/prices/metamask', metamaskPriceRoutes);
|
||||
this.app.use('/api/v1', omnlComplianceRoutes);
|
||||
this.app.use('/api/v1', omnlTerminalRoutes);
|
||||
this.app.use('/api/v1', gruVaultControlsRoutes);
|
||||
this.app.use('/api/v1', omnlRoutes);
|
||||
this.app.use('/api/v1', omnlIpsasRoutes);
|
||||
this.app.use('/api/v1', omnlPayoutJournalRoutes);
|
||||
|
||||
243
services/token-aggregation/src/services/gru-vault-controls.ts
Normal file
243
services/token-aggregation/src/services/gru-vault-controls.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
export type GruVaultProfile = 'live' | 'conservative' | 'expanded' | 'emergency';
|
||||
|
||||
export interface GruVaultControls {
|
||||
schemaVersion: '1.0.0';
|
||||
updatedAt: string;
|
||||
profile: GruVaultProfile;
|
||||
canonicalLadder: {
|
||||
expression: '1 M00 = 5 M0 = 25 M1';
|
||||
m00ToM0Ratio: 5;
|
||||
m0ToM1Ratio: 5;
|
||||
};
|
||||
smartVault: {
|
||||
liXauWithdrawalFraction: number;
|
||||
cStarSettlementEnabled: boolean;
|
||||
expandedAssetClassesEnabled: boolean;
|
||||
approvedBondBasketEnabled: boolean;
|
||||
};
|
||||
componentFamilies: {
|
||||
liIndexComponents: string[];
|
||||
approvedBondComponents: string[];
|
||||
expandedAssetComponents: string[];
|
||||
};
|
||||
dashboard: {
|
||||
reserveReferenceMultiplier: number;
|
||||
refreshIntervalSeconds: number;
|
||||
notes: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GruVaultControlsSnapshot {
|
||||
source: 'defaults' | 'file';
|
||||
path: string;
|
||||
controls: GruVaultControls;
|
||||
}
|
||||
|
||||
const DEFAULT_CONTROLS: GruVaultControls = {
|
||||
schemaVersion: '1.0.0',
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
profile: 'live',
|
||||
canonicalLadder: {
|
||||
expression: '1 M00 = 5 M0 = 25 M1',
|
||||
m00ToM0Ratio: 5,
|
||||
m0ToM1Ratio: 5,
|
||||
},
|
||||
smartVault: {
|
||||
liXauWithdrawalFraction: 0.2,
|
||||
cStarSettlementEnabled: true,
|
||||
expandedAssetClassesEnabled: false,
|
||||
approvedBondBasketEnabled: true,
|
||||
},
|
||||
componentFamilies: {
|
||||
liIndexComponents: ['LiXAU', 'LiPMG', 'LiBMG1', 'LiBMG2', 'LiBMG3'],
|
||||
approvedBondComponents: [],
|
||||
expandedAssetComponents: ['LiCARB', 'LiRES1', 'LiRES2', 'LiRES3'],
|
||||
},
|
||||
dashboard: {
|
||||
reserveReferenceMultiplier: 1.2,
|
||||
refreshIntervalSeconds: 30,
|
||||
notes: 'Runtime policy knobs for the smart-vault dashboard; canonical ladder remains fixed.',
|
||||
},
|
||||
};
|
||||
|
||||
const ALLOWED_PROFILES: GruVaultProfile[] = ['live', 'conservative', 'expanded', 'emergency'];
|
||||
|
||||
function projectRoot(): string {
|
||||
return (
|
||||
process.env.PROXMOX_ROOT?.trim() ||
|
||||
process.env.PHOENIX_REPO_ROOT?.trim() ||
|
||||
resolve(__dirname, '../../../../../..')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveControlsPath(): string {
|
||||
const configured = process.env.GRU_VAULT_CONTROLS_PATH?.trim();
|
||||
if (configured) return resolve(configured);
|
||||
return resolve(projectRoot(), 'reports/status/gru-vault-controls-latest.json');
|
||||
}
|
||||
|
||||
function cloneDefaults(): GruVaultControls {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_CONTROLS)) as GruVaultControls;
|
||||
}
|
||||
|
||||
function parseStringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => String(item).trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
function parseBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown, fallback: GruVaultProfile): GruVaultProfile {
|
||||
if (typeof value === 'string' && ALLOWED_PROFILES.includes(value as GruVaultProfile)) {
|
||||
return value as GruVaultProfile;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeControls(raw: unknown, updatedAt?: string): GruVaultControls {
|
||||
const defaults = cloneDefaults();
|
||||
const input = (raw && typeof raw === 'object' ? raw : {}) as Partial<GruVaultControls> & {
|
||||
smartVault?: Partial<GruVaultControls['smartVault']>;
|
||||
componentFamilies?: Partial<GruVaultControls['componentFamilies']>;
|
||||
dashboard?: Partial<GruVaultControls['dashboard']>;
|
||||
};
|
||||
|
||||
const controls: GruVaultControls = {
|
||||
...defaults,
|
||||
profile: normalizeProfile(input.profile, defaults.profile),
|
||||
updatedAt: updatedAt || defaults.updatedAt,
|
||||
canonicalLadder: defaults.canonicalLadder,
|
||||
smartVault: {
|
||||
liXauWithdrawalFraction: clampNumber(
|
||||
input.smartVault?.liXauWithdrawalFraction,
|
||||
defaults.smartVault.liXauWithdrawalFraction,
|
||||
0.05,
|
||||
0.5,
|
||||
),
|
||||
cStarSettlementEnabled: parseBoolean(
|
||||
input.smartVault?.cStarSettlementEnabled,
|
||||
defaults.smartVault.cStarSettlementEnabled,
|
||||
),
|
||||
expandedAssetClassesEnabled: parseBoolean(
|
||||
input.smartVault?.expandedAssetClassesEnabled,
|
||||
defaults.smartVault.expandedAssetClassesEnabled,
|
||||
),
|
||||
approvedBondBasketEnabled: parseBoolean(
|
||||
input.smartVault?.approvedBondBasketEnabled,
|
||||
defaults.smartVault.approvedBondBasketEnabled,
|
||||
),
|
||||
},
|
||||
componentFamilies: {
|
||||
liIndexComponents: parseStringList(input.componentFamilies?.liIndexComponents).length
|
||||
? parseStringList(input.componentFamilies?.liIndexComponents)
|
||||
: defaults.componentFamilies.liIndexComponents,
|
||||
approvedBondComponents: parseStringList(input.componentFamilies?.approvedBondComponents),
|
||||
expandedAssetComponents: parseStringList(input.componentFamilies?.expandedAssetComponents).length
|
||||
? parseStringList(input.componentFamilies?.expandedAssetComponents)
|
||||
: defaults.componentFamilies.expandedAssetComponents,
|
||||
},
|
||||
dashboard: {
|
||||
reserveReferenceMultiplier: clampNumber(
|
||||
input.dashboard?.reserveReferenceMultiplier,
|
||||
defaults.dashboard.reserveReferenceMultiplier,
|
||||
0.5,
|
||||
5,
|
||||
),
|
||||
refreshIntervalSeconds: Math.round(
|
||||
clampNumber(input.dashboard?.refreshIntervalSeconds, defaults.dashboard.refreshIntervalSeconds, 5, 300),
|
||||
),
|
||||
notes:
|
||||
typeof input.dashboard?.notes === 'string' && input.dashboard.notes.trim().length > 0
|
||||
? input.dashboard.notes.trim()
|
||||
: defaults.dashboard.notes,
|
||||
},
|
||||
};
|
||||
|
||||
if (!controls.smartVault.cStarSettlementEnabled) {
|
||||
controls.smartVault.cStarSettlementEnabled = false;
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
export function getGruVaultControlsPath(): string {
|
||||
return resolveControlsPath();
|
||||
}
|
||||
|
||||
export function getDefaultGruVaultControls(): GruVaultControls {
|
||||
return cloneDefaults();
|
||||
}
|
||||
|
||||
export function loadGruVaultControls(): GruVaultControlsSnapshot {
|
||||
const path = resolveControlsPath();
|
||||
if (!existsSync(path)) {
|
||||
return {
|
||||
source: 'defaults',
|
||||
path,
|
||||
controls: cloneDefaults(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
||||
const updatedAt = typeof (raw as { updatedAt?: unknown })?.updatedAt === 'string'
|
||||
? String((raw as { updatedAt?: unknown }).updatedAt)
|
||||
: new Date().toISOString();
|
||||
return {
|
||||
source: 'file',
|
||||
path,
|
||||
controls: normalizeControls(raw, updatedAt),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
source: 'defaults',
|
||||
path,
|
||||
controls: cloneDefaults(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveGruVaultControls(raw: unknown): GruVaultControlsSnapshot {
|
||||
const path = resolveControlsPath();
|
||||
const controls = normalizeControls(raw, new Date().toISOString());
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, `${JSON.stringify(controls, null, 2)}\n`, 'utf8');
|
||||
renameSync(tmp, path);
|
||||
return {
|
||||
source: 'file',
|
||||
path,
|
||||
controls,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetGruVaultControls(): GruVaultControlsSnapshot {
|
||||
return saveGruVaultControls(cloneDefaults());
|
||||
}
|
||||
@@ -68,6 +68,10 @@ export function getOmnlApiCatalog(): {
|
||||
{ method: 'POST', path: '/omnl/terminal/alchemy/rpc', description: 'Alchemy JSON-RPC (read/estimate methods)', body: '{ network, method, params }', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'POST', path: '/omnl/terminal/alchemy/prices', description: 'Alchemy token prices by address', body: '{ network, addresses[] }', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'POST', path: '/omnl/terminal/alchemy/tx-prep', description: 'Prepare unsigned ERC-20 transfer calldata via Alchemy (dry-run default)', body: '{ network, from, to, tokenAddress, amount, dryRun }', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/gru-vault-controls', description: 'GRU smart-vault runtime control snapshot', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/gru-vault-controls/defaults', description: 'GRU smart-vault control defaults', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'PUT', path: '/omnl/gru-vault-controls', description: 'Persist GRU smart-vault runtime controls', body: '{ profile, smartVault, componentFamilies, dashboard }', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'POST', path: '/omnl/gru-vault-controls/reset', description: 'Reset GRU smart-vault runtime controls to defaults', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'POST', path: '/journal-entries', description: 'Create payout journal entry draft (IPSAS-validated pair)', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/journal-entries/:id', description: 'Get journal entry by id', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'POST', path: '/journal-entries/:id/post', description: 'Post journal entry to ledger path', auth: 'OMNL_API_KEY' },
|
||||
|
||||
Reference in New Issue
Block a user