diff --git a/frontend-dapp/src/components/layout/Layout.tsx b/frontend-dapp/src/components/layout/Layout.tsx index 23a5857..5d3df0e 100644 --- a/frontend-dapp/src/components/layout/Layout.tsx +++ b/frontend-dapp/src/components/layout/Layout.tsx @@ -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} ))} + + Vault Controls + - + {item.label} diff --git a/frontend-dapp/src/components/layout/OmnlProductLayout.tsx b/frontend-dapp/src/components/layout/OmnlProductLayout.tsx index 56ff748..2acc75d 100644 --- a/frontend-dapp/src/components/layout/OmnlProductLayout.tsx +++ b/frontend-dapp/src/components/layout/OmnlProductLayout.tsx @@ -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() { Bridge + + + Vault Controls +
{ userInitiatedDisconnectRef.current = true; }} />
diff --git a/frontend-dapp/src/config/ecosystemLinks.ts b/frontend-dapp/src/config/ecosystemLinks.ts index a80c9e3..1db5b3f 100644 --- a/frontend-dapp/src/config/ecosystemLinks.ts +++ b/frontend-dapp/src/config/ecosystemLinks.ts @@ -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' }, ]; diff --git a/frontend-dapp/src/pages/HubPage.tsx b/frontend-dapp/src/pages/HubPage.tsx index a5048c3..2d5f977 100644 --- a/frontend-dapp/src/pages/HubPage.tsx +++ b/frontend-dapp/src/pages/HubPage.tsx @@ -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' }, ]; diff --git a/frontend-dapp/src/pages/ReservePage.tsx b/frontend-dapp/src/pages/ReservePage.tsx index 107915e..bb355bc 100644 --- a/frontend-dapp/src/pages/ReservePage.tsx +++ b/frontend-dapp/src/pages/ReservePage.tsx @@ -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 ( diff --git a/services/token-aggregation/public/gru-vault-controls.css b/services/token-aggregation/public/gru-vault-controls.css new file mode 100644 index 0000000..f67b731 --- /dev/null +++ b/services/token-aggregation/public/gru-vault-controls.css @@ -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; + } +} diff --git a/services/token-aggregation/public/gru-vault-controls.html b/services/token-aggregation/public/gru-vault-controls.html new file mode 100644 index 0000000..d399702 --- /dev/null +++ b/services/token-aggregation/public/gru-vault-controls.html @@ -0,0 +1,125 @@ + + + + + + + GRU Smart Vault Controls + + + +
+
+
+

Operator dashboard

+

GRU Smart Vault Controls

+

+ Runtime policy knobs for the smart vault layer. The ladder remains fixed at + 1 M00 = 5 M0 = 25 M1. +

+
+
+ + + + + Reserve dashboard + Compliance console +
+
+ + +

+ +
+
+

Canonical ladder

+
+
Expression1 M00 = 5 M0 = 25 M1
+
M00 → M05x fixed
+
M0 → M15x fixed
+
+

+ The dashboard can tune runtime policy references and component selections, but the canonical + ratio is hard-coded and cannot be changed here. +

+
+ +
+

Profile

+ + + + +
+
+ +
+

Operational tuning

+
+ + + +
+
+ +
+

Component families

+
+ + + +
+
+ + +
+ + + + + diff --git a/services/token-aggregation/public/gru-vault-controls.js b/services/token-aggregation/public/gru-vault-controls.js new file mode 100644 index 0000000..ea9967c --- /dev/null +++ b/services/token-aggregation/public/gru-vault-controls.js @@ -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(); +})(); diff --git a/services/token-aggregation/public/reserve-dashboard.html b/services/token-aggregation/public/reserve-dashboard.html index bc7db2e..b157514 100644 --- a/services/token-aggregation/public/reserve-dashboard.html +++ b/services/token-aggregation/public/reserve-dashboard.html @@ -17,6 +17,7 @@
+ Vault controls Proof index API Compliance console
diff --git a/services/token-aggregation/reports/audit/omnl-audit.jsonl b/services/token-aggregation/reports/audit/omnl-audit.jsonl index 0b900d3..b8e1491 100644 --- a/services/token-aggregation/reports/audit/omnl-audit.jsonl +++ b/services/token-aggregation/reports/audit/omnl-audit.jsonl @@ -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":{}}} diff --git a/services/token-aggregation/src/api/routes/gru-vault-controls.test.ts b/services/token-aggregation/src/api/routes/gru-vault-controls.test.ts new file mode 100644 index 0000000..572c5cc --- /dev/null +++ b/services/token-aggregation/src/api/routes/gru-vault-controls.test.ts @@ -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; baseUrl: string }> { + const server = createServer(app); + await new Promise((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; + 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; + 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; + 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; + 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; + expect(body.controls.profile).toBe('live'); + expect(body.controls.smartVault.liXauWithdrawalFraction).toBeCloseTo(0.2); + }); +}); diff --git a/services/token-aggregation/src/api/routes/gru-vault-controls.ts b/services/token-aggregation/src/api/routes/gru-vault-controls.ts new file mode 100644 index 0000000..27c2804 --- /dev/null +++ b/services/token-aggregation/src/api/routes/gru-vault-controls.ts @@ -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 | 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; diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index 6a3347c..92ceb2a 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -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); diff --git a/services/token-aggregation/src/services/gru-vault-controls.ts b/services/token-aggregation/src/services/gru-vault-controls.ts new file mode 100644 index 0000000..7fd0e1f --- /dev/null +++ b/services/token-aggregation/src/services/gru-vault-controls.ts @@ -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 & { + smartVault?: Partial; + componentFamilies?: Partial; + dashboard?: Partial; + }; + + 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()); +} diff --git a/services/token-aggregation/src/services/omnl-api-catalog.ts b/services/token-aggregation/src/services/omnl-api-catalog.ts index c86a7e8..3bbaf16 100644 --- a/services/token-aggregation/src/services/omnl-api-catalog.ts +++ b/services/token-aggregation/src/services/omnl-api-catalog.ts @@ -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' },