From edfa14f37ea6980071d973123e9456e0eca7fc9c Mon Sep 17 00:00:00 2001 From: philipp Date: Wed, 13 May 2026 12:51:35 +0200 Subject: [PATCH] Add dashboard pair controls Proof: npm test passed 171/171; npm run operator-dashboard:build passed; regression tests cover pair activation/pause fail-closed behavior, per-pair edge controls, and asset deposit address exposure. Assumptions: Activating a directed pair is operator-approved live-funds-adjacent control; trading still requires existing DB strategy config, price route, armed services, and inventory, so unsupported imported assets remain blocked. Still fake: no new price routes or liquidity discovery were added; non-current pairs without routes/config remain visible but cannot trade. --- src/apps/operator-dashboard.mjs | 25 +++ src/core/operator-dashboard.mjs | 60 ++++- src/lib/postgres.mjs | 111 ++++++++++ .../static/pages/StrategyPage.jsx | 206 ++++++++++++++++-- test/operator-dashboard-app-static.test.mjs | 7 + test/operator-dashboard-ui-static.test.mjs | 9 + test/operator-dashboard.test.mjs | 8 + test/trading-config.test.mjs | 66 ++++++ 8 files changed, 471 insertions(+), 21 deletions(-) diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index d539a85..259ed0c 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -52,7 +52,9 @@ import { loadRecentQuotes, loadSubmissionPage, loadSubmissionSummary, + pauseTradingPair, seedTradingConfig, + setTradingPairMode, } from '../lib/postgres.mjs'; const config = loadConfig(); @@ -641,6 +643,29 @@ async function invokeControl(control, body) { return result; } + if (control.service === 'operator-dashboard' && control.action === 'set-pair-mode') { + const result = await setTradingPairMode(pool, { + pairId: body.pair_id || body.pair, + assetIn: body.asset_in, + assetOut: body.asset_out, + mode: body.mode, + changedBy: body.changed_by || 'operator', + reason: body.reason || 'dashboard pair mode update', + }); + await tradingConfigStore.forceRefresh(); + return result; + } + + if (control.service === 'operator-dashboard' && control.action === 'pause-pair') { + const result = await pauseTradingPair(pool, { + pairId: body.pair_id || body.pair, + changedBy: body.changed_by || 'operator', + reason: body.reason || 'dashboard pair pause', + }); + await tradingConfigStore.forceRefresh(); + return result; + } + const response = await fetchJson( `${lookupServiceBaseUrl(control.service)}${control.path}`, { diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index c36149c..d297d7a 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -78,6 +78,26 @@ const CONTROL_DEFINITIONS = [ page: 'strategy', risk_class: 'safe', }, + { + service: 'operator-dashboard', + action: 'set-pair-mode', + method: 'POST', + path: '/internal/set-pair-mode', + label: 'Set Pair Mode', + description: 'Activate a directed pair mode from durable DB config. Trading still requires valid strategy and price route state.', + page: 'strategy', + risk_class: 'live_funds', + }, + { + service: 'operator-dashboard', + action: 'pause-pair', + method: 'POST', + path: '/internal/pause-pair', + label: 'Pause Pair', + description: 'Disable a directed pair in durable DB config without deleting historical strategy versions.', + page: 'strategy', + risk_class: 'safe', + }, { service: 'trade-executor', action: 'intent-request-preflight', @@ -590,6 +610,7 @@ export function buildDashboardBootstrap({ activeAlerts, assetCatalog, pairConfig, + fundingHandles: funding.handles, recentQuotes, recentTradeDecisions, recentExecuteTradeCommands, @@ -1406,6 +1427,7 @@ function buildStrategySummary({ activeAlerts, assetCatalog = null, pairConfig = null, + fundingHandles = [], recentQuotes = [], recentTradeDecisions = [], recentExecuteTradeCommands = [], @@ -1479,7 +1501,10 @@ function buildStrategySummary({ durable_control_state: strategyState.durable_control_state || null, trading_config: strategyState.trading_config || null, }, - asset_catalog: assetCatalog || buildFallbackAssetCatalog(config), + asset_catalog: attachDepositHandlesToAssetCatalog( + assetCatalog || buildFallbackAssetCatalog(config), + fundingHandles, + ), pair_config: pairConfig || buildFallbackPairConfig(config), executor_state: { armed: executorState.armed ?? null, @@ -1503,6 +1528,39 @@ function buildStrategySummary({ }; } +function attachDepositHandlesToAssetCatalog(assetCatalog, fundingHandles = []) { + const handlesByAssetId = new Map(); + const handlesByChain = new Map(); + for (const handle of fundingHandles || []) { + if (!handle) continue; + if (handle.chain) handlesByChain.set(handle.chain, handle); + if (handle.asset_id) handlesByAssetId.set(handle.asset_id, handle); + for (const assetId of handle.asset_ids || []) { + handlesByAssetId.set(assetId, handle); + } + } + + return { + ...(assetCatalog || {}), + items: (assetCatalog?.items || []).map((asset) => { + const assetId = asset.asset_id || asset.assetId; + const chain = asset.chain || asset.blockchain || null; + const handle = handlesByAssetId.get(assetId) || handlesByChain.get(chain) || null; + return { + ...asset, + depositAddress: handle?.address || null, + deposit_address: handle?.address || null, + depositChain: handle?.chain || chain, + deposit_chain: handle?.chain || chain, + depositMemo: handle?.memo || null, + deposit_memo: handle?.memo || null, + depositRefreshedAt: handle?.refreshed_at || null, + deposit_refreshed_at: handle?.refreshed_at || null, + }; + }), + }; +} + function buildTradeFunnelSummary(lifecycleRows = []) { const counts = { observed: 0, diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index f05cd56..024bc71 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -10,6 +10,7 @@ import { buildSeedStrategyConfig, hashJson, normalizeOneClickTokenResponse, + normalizePairMode, pairCanMake, pairCanObserve, pairCanTake, @@ -818,6 +819,110 @@ export async function enableObserveOnlyPair(pool, { return pair; } +export async function setTradingPairMode(pool, { + venue = 'near-intents', + pairId = null, + pair = null, + assetIn = null, + assetOut = null, + mode = 'observe_only', + changedBy = 'operator', + reason = 'operator pair mode update', +} = {}) { + await ensureTradingConfigSchema(pool); + const normalizedMode = normalizePairMode(mode); + const resolvedPairId = pairId || pair || (assetIn && assetOut ? `${assetIn}->${assetOut}` : null); + if (!resolvedPairId) throw new Error('pair_id or asset_in/asset_out is required'); + + return withTransaction(pool, async (client) => { + const existingResult = await client.query( + ` + SELECT * + FROM ${TRADING_PAIRS_TABLE} + WHERE pair_id = $1 + LIMIT 1 + `, + [resolvedPairId], + ); + const existingPair = existingResult.rows[0] ? normalizeTradingPairRow(existingResult.rows[0]) : null; + const [pairAssetIn, pairAssetOut] = splitPairId(resolvedPairId); + const resolvedAssetIn = assetIn || existingPair?.assetIn || pairAssetIn; + const resolvedAssetOut = assetOut || existingPair?.assetOut || pairAssetOut; + if (!resolvedAssetIn || !resolvedAssetOut) throw new Error('asset_in and asset_out are required'); + + const assets = await loadTradingAssetsById(client); + if (!assets.has(resolvedAssetIn)) throw new Error(`asset_in is not registered: ${resolvedAssetIn}`); + if (!assets.has(resolvedAssetOut)) throw new Error(`asset_out is not registered: ${resolvedAssetOut}`); + + const nextPair = { + pairId: resolvedPairId, + venue: existingPair?.venue || venue, + assetIn: resolvedAssetIn, + assetOut: resolvedAssetOut, + mode: normalizedMode, + enabled: true, + status: normalizedMode, + }; + await upsertSeedPair(client, { pair: nextPair, now: new Date().toISOString() }); + await insertConfigAuditLog(client, { + entityType: 'trading_pair', + entityId: resolvedPairId, + action: 'mode_set', + oldValue: existingPair, + newValue: nextPair, + changedBy, + reason, + }); + return nextPair; + }); +} + +export async function pauseTradingPair(pool, { + pairId = null, + pair = null, + changedBy = 'operator', + reason = 'operator paused pair', +} = {}) { + await ensureTradingConfigSchema(pool); + const resolvedPairId = pairId || pair; + if (!resolvedPairId) throw new Error('pair_id is required'); + + return withTransaction(pool, async (client) => { + const existingResult = await client.query( + ` + SELECT * + FROM ${TRADING_PAIRS_TABLE} + WHERE pair_id = $1 + LIMIT 1 + `, + [resolvedPairId], + ); + const existingPair = existingResult.rows[0] ? normalizeTradingPairRow(existingResult.rows[0]) : null; + if (!existingPair) throw new Error(`trading pair not found: ${resolvedPairId}`); + + const nextPair = { + pairId: existingPair.pairId, + venue: existingPair.venue, + assetIn: existingPair.assetIn, + assetOut: existingPair.assetOut, + mode: existingPair.mode, + enabled: false, + status: 'disabled', + }; + await upsertSeedPair(client, { pair: nextPair, now: new Date().toISOString() }); + await insertConfigAuditLog(client, { + entityType: 'trading_pair', + entityId: resolvedPairId, + action: 'paused', + oldValue: existingPair, + newValue: nextPair, + changedBy, + reason, + }); + return nextPair; + }); +} + function buildTradingConfigSnapshot({ assetRows, pairRows, @@ -1057,6 +1162,12 @@ function normalizeTradingPairRow(row) { }; } +function splitPairId(pairId) { + const parts = String(pairId || '').split('->'); + if (parts.length !== 2 || !parts[0] || !parts[1]) return [null, null]; + return parts; +} + function normalizeStrategyConfigRow(row) { if (!row) return null; return { diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 89b52e3..801eed5 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; import EmptyState from '../components/EmptyState.jsx'; import MetricCard from '../components/MetricCard.jsx'; @@ -7,6 +7,7 @@ import TableFrame from '../components/TableFrame.jsx'; import { formatAgeFromTimestamp, formatBoolean, formatEur, formatTimestamp, truncateMiddle } from '../lib/format.js'; const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']); +const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']); async function copyIdentifier(value) { if (!value || !navigator?.clipboard?.writeText) return; @@ -328,6 +329,7 @@ function AssetCatalogSection({ assetCatalog, onControl }) { Asset Decimals Chain + Deposit Price Status @@ -341,6 +343,32 @@ function AssetCatalogSection({ assetCatalog, onControl }) { {asset.decimals} {asset.blockchain || asset.chain || 'Unavailable'} + + {asset.deposit_address || asset.depositAddress ? ( + <> +
+ + {truncateMiddle(asset.deposit_address || asset.depositAddress, 34)} + + +
+
+ {asset.deposit_memo || asset.depositMemo ? `Memo ${asset.deposit_memo || asset.depositMemo}` : asset.deposit_chain || asset.depositChain || 'Deposit handle'} +
+ + ) : ( +
Unavailable
+ )} + {asset.latest_price || asset.latestPrice || 'Unavailable'} )) : ( - No DB asset registry rows are available. + No DB asset registry rows are available. )} @@ -359,27 +387,85 @@ function AssetCatalogSection({ assetCatalog, onControl }) { ); } -function PairConfigSection({ pairConfig, onControl }) { +function assetOptionLabel(asset) { + return `${asset.label || asset.symbol || asset.asset_id || asset.assetId} - ${truncateMiddle(asset.asset_id || asset.assetId, 34)}`; +} + +function PairConfigSection({ assetCatalog, pairConfig, onControl }) { const pairs = pairConfig?.pairs || []; + const assets = useMemo(() => (assetCatalog?.items || []) + .filter((asset) => asset.asset_id || asset.assetId) + .sort((left, right) => String(left.label || left.symbol || left.asset_id || '').localeCompare( + String(right.label || right.symbol || right.asset_id || ''), + )), [assetCatalog?.items]); + const [pairForm, setPairForm] = useState({ + asset_in: '', + asset_out: '', + mode: 'observe_only', + }); + const [edgeDrafts, setEdgeDrafts] = useState({}); + + useEffect(() => { + if (!assets.length) return; + setPairForm((current) => ({ + ...current, + asset_in: current.asset_in || assets[0]?.asset_id || assets[0]?.assetId || '', + asset_out: current.asset_out || assets[1]?.asset_id || assets[1]?.assetId || assets[0]?.asset_id || assets[0]?.assetId || '', + })); + }, [assets]); + + useEffect(() => { + setEdgeDrafts(Object.fromEntries(pairs.map((pair) => { + const pairId = pair.pair_id || pair.pairId; + const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; + return [pairId, String(strategyConfig.edge_bps ?? pair.edge_bps ?? '')]; + }))); + }, [pairs]); async function updateEdge(pair) { - const current = pair.strategyConfig?.edge_bps ?? pair.strategy_config?.edge_bps ?? pair.edge_bps ?? ''; - const next = window.prompt('edge_bps', current); + const pairId = pair.pair_id || pair.pairId; + const next = edgeDrafts[pairId]; if (!next) return; await onControl?.('operator-dashboard', 'update-pair-edge', { - pair_id: pair.pair_id || pair.pairId, + pair_id: pairId, edge_bps: Number(next), }); } - async function enableObserveOnly() { - const assetIn = window.prompt('asset_in'); - if (!assetIn) return; - const assetOut = window.prompt('asset_out'); - if (!assetOut) return; - await onControl?.('operator-dashboard', 'enable-observe-only-pair', { - asset_in: assetIn, - asset_out: assetOut, + async function applyPairMode(event) { + event.preventDefault(); + if (!pairForm.asset_in || !pairForm.asset_out || pairForm.asset_in === pairForm.asset_out) return; + if ( + TRADING_PAIR_MODES.has(pairForm.mode) + && !window.confirm('Activate trading mode for this directed pair?') + ) { + return; + } + await onControl?.('operator-dashboard', 'set-pair-mode', { + asset_in: pairForm.asset_in, + asset_out: pairForm.asset_out, + mode: pairForm.mode, + }); + } + + async function pausePair(pair) { + await onControl?.('operator-dashboard', 'pause-pair', { + pair_id: pair.pair_id || pair.pairId, + }); + } + + async function activatePair(pair) { + const pairId = pair.pair_id || pair.pairId; + const nextMode = ['maker', 'taker', 'both'].includes(pair.mode) ? pair.mode : 'observe_only'; + if ( + TRADING_PAIR_MODES.has(nextMode) + && !window.confirm('Reactivate trading mode for this directed pair?') + ) { + return; + } + await onControl?.('operator-dashboard', 'set-pair-mode', { + pair_id: pairId, + mode: nextMode, }); } @@ -395,9 +481,56 @@ function PairConfigSection({ pairConfig, onControl }) {
-
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
@@ -409,6 +542,7 @@ function PairConfigSection({ pairConfig, onControl }) { + @@ -431,14 +565,46 @@ function PairConfigSection({ pairConfig, onControl }) { + ); }) : ( - + )}
Route Blocked ConfigControls
{pair.blockReason || pair.block_reason || 'No'}
v{strategyConfig.version || 'Unavailable'}
- +
+ setEdgeDrafts((current) => ({ + ...current, + [pair.pair_id || pair.pairId]: event.target.value, + }))} + step="1" + style={{ maxWidth: 92 }} + type="number" + value={edgeDrafts[pair.pair_id || pair.pairId] ?? ''} + /> + +
+
+
+ {pair.enabled && pair.status !== 'disabled' ? ( + + ) : ( + + )} +
No directed pairs are configured.
No directed pairs are configured.
@@ -481,7 +647,7 @@ export default function StrategyPage({ strategy, onControl }) { - +
diff --git a/test/operator-dashboard-app-static.test.mjs b/test/operator-dashboard-app-static.test.mjs index c1c9196..a708ce9 100644 --- a/test/operator-dashboard-app-static.test.mjs +++ b/test/operator-dashboard-app-static.test.mjs @@ -19,3 +19,10 @@ test('operator dashboard requests enough asset catalog rows for the current 1Cli assert.match(source, /loadAssetCatalogSummary\(pool,\s*\{\s*limit:\s*250\s*\}\)/); assert.doesNotMatch(source, /loadAssetCatalogSummary\(pool,\s*\{\s*limit:\s*80\s*\}\)/); }); + +test('operator dashboard exposes DB-backed pair activation and pause controls', () => { + assert.match(source, /setTradingPairMode/); + assert.match(source, /pauseTradingPair/); + assert.match(source, /control\.action === 'set-pair-mode'/); + assert.match(source, /control\.action === 'pause-pair'/); +}); diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index 2c68671..85b9a49 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -63,6 +63,15 @@ test('asset registry table renders the loaded catalog without a hidden 20 row ca assert.doesNotMatch(strategySource, /items\.slice\(0,\s*20\)\.map/); }); +test('strategy page exposes pair activation, pause, edge, and deposit address controls', () => { + assert.match(strategySource, /set-pair-mode/); + assert.match(strategySource, /pause-pair/); + assert.match(strategySource, /Activate pair mode/); + assert.match(strategySource, /Edge bps for/); + assert.match(strategySource, /deposit_address/); + assert.match(strategySource, /Copy/); +}); + test('system page exposes deduped environmental conditions history', () => { assert.match(systemSource, /Environmental conditions/); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index 4177b27..f0df974 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -845,6 +845,14 @@ test('bootstrap balances and funding handles distinguish nBTC reserve from legac 'nep141:btc.omft.near', ]); assert.equal(bootstrap.funds.funding.handles[0].label, 'btc:mainnet funding handle'); + assert.equal( + bootstrap.strategy.asset_catalog.items.find((item) => item.asset_id === 'nep141:nbtc.bridge.near').deposit_address, + 'bc1qdeposit', + ); + assert.equal( + bootstrap.strategy.asset_catalog.items.find((item) => item.asset_id === 'nep141:btc.omft.near').deposit_address, + 'bc1qdeposit', + ); }); test('bootstrap balances exclude imported catalog assets that are not inventory-enabled', () => { diff --git a/test/trading-config.test.mjs b/test/trading-config.test.mjs index 6acf85a..1468823 100644 --- a/test/trading-config.test.mjs +++ b/test/trading-config.test.mjs @@ -13,7 +13,9 @@ import { enableObserveOnlyPair, importSupportedAssets, loadTradingConfig, + pauseTradingPair, seedTradingConfig, + setTradingPairMode, } from '../src/lib/postgres.mjs'; test('1Click token normalizer preserves live asset fields', () => { @@ -175,6 +177,70 @@ test('repo seed does not re-enable pair runtime flags already stored in DB', asy assert.equal(pair.takerEnabled, false); }); +test('pair mode updates activate a directed pair without inventing a price route', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + const pairId = `${LEGACY_OMFT_BTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`; + await enableObserveOnlyPair(pool, { + assetIn: LEGACY_OMFT_BTC_ASSET_ID, + assetOut: CURRENT_EURE_ASSET_ID, + changedBy: 'test', + reason: 'watch legacy route', + }); + + const updated = await setTradingPairMode(pool, { + pairId, + mode: 'maker', + changedBy: 'test', + reason: 'operator activation test', + }); + const snapshot = await loadTradingConfig(pool); + const pair = snapshot.pairByKey.get(pairId); + + assert.equal(updated.mode, 'maker'); + assert.equal(pair.enabled, true); + assert.equal(pair.makerEnabled, true); + assert.equal(pair.canTrade, false); + assert.equal(pair.blockReason, 'pair_strategy_config_missing'); +}); + +test('pair pause disables trading without deleting strategy config or seed restoring it', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + const pairId = `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`; + + const paused = await pauseTradingPair(pool, { + pairId, + changedBy: 'test', + reason: 'operator pause test', + }); + await seedTradingConfig(pool); + const snapshot = await loadTradingConfig(pool); + const pair = snapshot.pairByKey.get(pairId); + + assert.equal(paused.status, 'disabled'); + assert.equal(pair.enabled, false); + assert.equal(pair.mode, 'both'); + assert.equal(pair.status, 'disabled'); + assert.equal(pair.strategyConfig.edgeBps, 49); + assert.equal(pair.makerEnabled, false); + assert.equal(pair.takerEnabled, false); +}); + +test('pair mode activation requires both assets to be registered', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + + await assert.rejects( + setTradingPairMode(pool, { + assetIn: CURRENT_NBTC_ASSET_ID, + assetOut: 'nep141:not-imported.near', + mode: 'maker', + }), + /asset_out is not registered/, + ); +}); + test('strategy uses DB pair config for current pair and persists config version', async () => { const pool = createMemoryPool(); const snapshot = await seedTradingConfig(pool);