diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 93d6363..37afc2d 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -576,6 +576,7 @@ async function loadNearIntentsStatus() { postsResponse, postEnumsResponse, observedAt: new Date().toISOString(), + trackedAssets: config.trackedAssets, }); } diff --git a/src/apps/ops-sentinel.mjs b/src/apps/ops-sentinel.mjs index ea59d12..17266fa 100644 --- a/src/apps/ops-sentinel.mjs +++ b/src/apps/ops-sentinel.mjs @@ -631,6 +631,7 @@ async function pollNearIntentsEnvironmentStatus() { postsResponse, postEnumsResponse, observedAt, + trackedAssets: config.trackedAssets, }); state.near_intents_status = normalized; diff --git a/src/core/near-intents-status.mjs b/src/core/near-intents-status.mjs index 81b66fd..5b99008 100644 --- a/src/core/near-intents-status.mjs +++ b/src/core/near-intents-status.mjs @@ -12,6 +12,10 @@ export function normalizeNearIntentsStatus({ servicesResponse = null, postEnumsResponse = null, observedAt = new Date().toISOString(), + trackedAssets = [], + relevantChains = [], + relevantAssetIds = [], + relevantServiceNames = [], } = {}) { const posts = Array.isArray(postsResponse?.posts) ? postsResponse.posts @@ -41,31 +45,60 @@ export function normalizeNearIntentsStatus({ ); const serviceById = new Map(services.map((entry) => [entry.id, entry])); - const incidents = posts + const scope = buildRelevanceScope({ + trackedAssets, + relevantChains, + relevantAssetIds, + relevantServiceNames, + }); + const activeIncidents = posts .map((post) => normalizePost(post, { statusById, severityById, serviceById })) .filter(Boolean) .filter((post) => post.active); + const relevantIncidents = activeIncidents.filter((incident) => ( + isRelevantIncident(incident, scope) + )); + const unrelatedIncidents = activeIncidents.filter((incident) => ( + !isRelevantIncident(incident, scope) + )); const affectedServices = [...new Set( - incidents.flatMap((incident) => incident.impacts.map((impact) => impact.service_name || impact.service_id)), + relevantIncidents.flatMap((incident) => incident.impacts.map((impact) => ( + impact.service_name || impact.service_id + ))), )].filter(Boolean); - const primaryIncident = incidents[0] || null; - const status = incidents.length > 0 ? 'disrupted' : 'operational'; - const label = incidents.length > 0 ? 'upstream paused' : 'operational'; - const decisiveReason = primaryIncident - ? [primaryIncident.title, primaryIncident.message_text].filter(Boolean).join(': ') - : 'NEAR Intents status page reports no active incident.'; - const quotingStopped = incidents.some((incident) => /1click|quoting|solver|swap/i.test( - `${incident.title || ''} ${incident.message_text || ''}`, + const observedAffectedServices = [...new Set( + activeIncidents.flatMap((incident) => incident.impacts.map((impact) => ( + impact.service_name || impact.service_id + ))), + )].filter(Boolean); + const primaryIncident = relevantIncidents[0] || null; + const primaryUnrelatedIncident = unrelatedIncidents[0] || null; + const status = relevantIncidents.length > 0 ? 'disrupted' : 'operational'; + const label = relevantIncidents.length > 0 ? 'upstream paused' : 'operational'; + const decisiveReason = buildDecisiveReason({ + primaryIncident, + primaryUnrelatedIncident, + activeIncidentCount: activeIncidents.length, + scope, + }); + const quotingStopped = relevantIncidents.some((incident) => /1click|quoting|solver|swap/i.test( + incidentText(incident), )); const normalized = { observed_at: observedAt, source: 'near_intents_status_page', status, label, - current_incident_count: incidents.length, - current_incidents: incidents, + current_incident_count: relevantIncidents.length, + current_incidents: relevantIncidents, affected_services: affectedServices, + observed_incident_count: activeIncidents.length, + observed_incidents: activeIncidents, + observed_affected_services: observedAffectedServices, + unrelated_incident_count: unrelatedIncidents.length, + unrelated_incidents: unrelatedIncidents, + relevance_scope: scope.publicScope, quoting_stopped: quotingStopped, decisive_reason: decisiveReason, }; @@ -95,6 +128,12 @@ export function buildNearIntentsStatusEventPayload(status, { current_incident_count: status.current_incident_count || 0, current_incidents: status.current_incidents || [], affected_services: status.affected_services || [], + observed_incident_count: status.observed_incident_count || status.current_incident_count || 0, + observed_incidents: status.observed_incidents || status.current_incidents || [], + observed_affected_services: status.observed_affected_services || status.affected_services || [], + unrelated_incident_count: status.unrelated_incident_count || 0, + unrelated_incidents: status.unrelated_incidents || [], + relevance_scope: status.relevance_scope || null, quoting_stopped: status.quoting_stopped ?? null, }; } @@ -104,21 +143,160 @@ export function buildNearIntentsStatusFingerprint(status = {}) { status: status.status || 'unknown', label: status.label || null, decisive_reason: status.decisive_reason || null, - current_incidents: (status.current_incidents || []).map((incident) => ({ - id: incident.id || null, - title: incident.title || null, - status: incident.status || null, - severity: incident.severity || null, - last_update_at: incident.last_update_at || null, - message_text: incident.message_text || null, - impacts: incident.impacts || [], - })), + current_incidents: (status.current_incidents || []).map(stableIncident), + observed_incidents: (status.observed_incidents || status.current_incidents || []).map(stableIncident), affected_services: [...(status.affected_services || [])].sort(), + observed_affected_services: [...(status.observed_affected_services || [])].sort(), + unrelated_incident_count: status.unrelated_incident_count || 0, + relevance_scope: status.relevance_scope || null, quoting_stopped: status.quoting_stopped ?? null, }; return crypto.createHash('sha256').update(JSON.stringify(stable)).digest('hex'); } +function stableIncident(incident) { + return { + id: incident.id || null, + title: incident.title || null, + status: incident.status || null, + severity: incident.severity || null, + last_update_at: incident.last_update_at || null, + message_text: incident.message_text || null, + impacts: incident.impacts || [], + }; +} + +function buildDecisiveReason({ + primaryIncident, + primaryUnrelatedIncident, + activeIncidentCount, + scope, +}) { + if (primaryIncident) { + return [primaryIncident.title, primaryIncident.message_text].filter(Boolean).join(': '); + } + if (primaryUnrelatedIncident) { + const scopeText = scope.publicScope.terms.length + ? scope.publicScope.terms.slice(0, 8).join(', ') + : 'unscoped deployment'; + return [ + `NEAR Intents status page has ${activeIncidentCount} active incident(s), but none match this deployment scope (${scopeText}).`, + [primaryUnrelatedIncident.title, primaryUnrelatedIncident.message_text].filter(Boolean).join(': '), + ].filter(Boolean).join(' '); + } + return 'NEAR Intents status page reports no active incident.'; +} + +const CORE_INTENTS_SERVICE_PATTERN = /\b(1click|solver|message bus)\b/i; +const GLOBAL_INTENTS_DISRUPTION_PATTERN = /\b(1click|quoting|solver|message bus|protocol is paused|swaps are paused|all swaps)\b/i; + +function isRelevantIncident(incident, scope) { + if (scope.unfiltered) return true; + + const text = incidentText(incident); + if (GLOBAL_INTENTS_DISRUPTION_PATTERN.test(text)) return true; + + const impactedServices = (incident.impacts || []) + .map((impact) => `${impact.service_name || ''} ${impact.service_id || ''}`.trim()) + .filter(Boolean); + if (impactedServices.some((service) => CORE_INTENTS_SERVICE_PATTERN.test(service))) return true; + if (impactedServices.some((service) => scope.serviceNames.has(normalizeToken(service)))) return true; + + return scope.termPatterns.some((pattern) => pattern.test(text)); +} + +function incidentText(incident) { + return [ + incident?.title, + incident?.message_text, + ...(incident?.impacts || []).map((impact) => impact.service_name || impact.service_id), + ].filter(Boolean).join(' '); +} + +function buildRelevanceScope({ + trackedAssets = [], + relevantChains = [], + relevantAssetIds = [], + relevantServiceNames = [], +} = {}) { + const assets = Array.isArray(trackedAssets) ? trackedAssets : []; + const configuredChains = Array.isArray(relevantChains) ? relevantChains : [relevantChains]; + const configuredAssetIds = Array.isArray(relevantAssetIds) ? relevantAssetIds : [relevantAssetIds]; + const configuredServiceNames = Array.isArray(relevantServiceNames) + ? relevantServiceNames + : [relevantServiceNames]; + const chains = unique([ + ...configuredChains, + ...assets.map((asset) => asset?.chain), + ]); + const assetIds = unique([ + ...configuredAssetIds, + ...assets.map((asset) => asset?.assetId), + ]); + const serviceNames = new Set(configuredServiceNames.map(normalizeToken).filter(Boolean)); + const terms = unique([ + ...chains.flatMap(chainTerms), + ...assetIds.flatMap(assetTerms), + ]).map(normalizeToken).filter(Boolean); + + return { + unfiltered: assets.length === 0 + && configuredChains.filter(Boolean).length === 0 + && configuredAssetIds.filter(Boolean).length === 0 + && configuredServiceNames.filter(Boolean).length === 0, + termPatterns: terms.map(termPattern), + serviceNames, + publicScope: { + chains, + asset_ids: assetIds, + service_names: [...serviceNames], + terms, + }, + }; +} + +function chainTerms(chain) { + const normalized = String(chain || '').trim().toLowerCase(); + const terms = [normalized]; + if (normalized === 'btc:mainnet') terms.push('btc', 'bitcoin'); + const [, evmChainId] = normalized.match(/^eth:(\d+)$/) || []; + if (evmChainId === '1') terms.push('ethereum', 'eth mainnet'); + if (evmChainId === '100') terms.push('gnosis', 'gno', 'xdai'); + return terms; +} + +function assetTerms(assetId) { + const normalized = String(assetId || '').trim().toLowerCase(); + const terms = [normalized]; + if (normalized.includes('nbtc') || normalized.includes('btc.')) { + terms.push('btc', 'bitcoin'); + } + if (normalized.includes('gnosis')) { + terms.push('gnosis', 'gno', 'xdai'); + } + return terms; +} + +function termPattern(term) { + const escaped = escapeRegExp(term); + if (/^[a-z0-9]+$/i.test(term)) { + return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i'); + } + return new RegExp(escaped, 'i'); +} + +function normalizeToken(value) { + return String(value || '').trim().toLowerCase(); +} + +function unique(values) { + return [...new Set((values || []).filter(Boolean))]; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function normalizePost(post, { statusById, severityById, serviceById }) { if (!post || typeof post !== 'object') return null; const latestUpdate = post.latest_update || [...(post.updates || [])].pop() || {}; diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 6b7517b..cedb0f7 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -1625,6 +1625,9 @@ function resolveServiceUpstreamStatus(service, nearIntentsStatus) { decisive_reason: nearIntentsStatus.decisive_reason || null, current_incident_count: nearIntentsStatus.current_incident_count || 0, affected_services: nearIntentsStatus.affected_services || [], + observed_incident_count: nearIntentsStatus.observed_incident_count ?? null, + observed_affected_services: nearIntentsStatus.observed_affected_services || [], + unrelated_incident_count: nearIntentsStatus.unrelated_incident_count || 0, quoting_stopped: nearIntentsStatus.quoting_stopped ?? null, }; } diff --git a/test/near-intents-status.test.mjs b/test/near-intents-status.test.mjs index f02e649..ef4e383 100644 --- a/test/near-intents-status.test.mjs +++ b/test/near-intents-status.test.mjs @@ -20,9 +20,21 @@ const servicesResponse = { services: [ { id: 'PXQFSY1', name: 'Cross-Chain Bridging', display_name: 'Cross-Chain Bridging' }, { id: 'PLT88AT', name: 'Solvers Network', display_name: 'Solvers Network' }, + { id: 'PNEJBRE', name: 'Other Blockchains', display_name: 'Other Blockchains' }, ], }; +const trackedAssets = [ + { + assetId: 'nep141:nbtc.bridge.near', + chain: 'btc:mainnet', + }, + { + assetId: 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', + chain: 'eth:100', + }, +]; + test('NEAR Intents status normalizer exposes current quoting disruption as upstream paused evidence', () => { const normalized = normalizeNearIntentsStatus({ observedAt: '2026-04-16T12:40:00.000Z', @@ -85,6 +97,40 @@ test('resolved NEAR Intents status posts do not make the relay look disrupted', assert.match(normalized.decisive_reason, /no active incident/i); }); +test('scoped NEAR Intents status ignores unrelated paused destination chains', () => { + const normalized = normalizeNearIntentsStatus({ + observedAt: '2026-05-07T15:55:00.000Z', + servicesResponse, + postEnumsResponse, + trackedAssets, + postsResponse: { + posts: [{ + id: 'PO2LXSS', + title: 'BSC, TON, XML destinations are temporarily paused', + post_type: 'incident', + latest_update: { + status_id: 'PSCS3IV', + severity_id: 'P187122', + reported_at: 1778165160000, + impacts: [{ service_id: 'PNEJBRE', severity_id: 'PCIGMKW' }], + message: '
HOT bridge is having a reliability incident. Full list of chains: BSC, MONAD, XLAYER, PLASMA, POL, TON, OP, AVAX, STELLAR, ADI
', + }, + }], + }, + }); + + assert.equal(normalized.status, 'operational'); + assert.equal(normalized.label, 'operational'); + assert.equal(normalized.current_incident_count, 0); + assert.equal(normalized.observed_incident_count, 1); + assert.equal(normalized.unrelated_incident_count, 1); + assert.equal(normalized.quoting_stopped, false); + assert.deepEqual(normalized.affected_services, []); + assert.deepEqual(normalized.observed_affected_services, ['Other Blockchains']); + assert.match(normalized.decisive_reason, /none match this deployment scope/); + assert.match(normalized.decisive_reason, /BSC, TON, XML/); +}); + test('NEAR Intents status fingerprint is stable across polls and changes on official updates', () => { const first = normalizeNearIntentsStatus({ diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index 4411f79..0ab6df5 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -1735,6 +1735,76 @@ test('dashboard surfaces NEAR upstream disruption without calling submitted work }); +test('dashboard does not pause relay services for unrelated destination-chain incident', () => { + const config = buildConfig(); + const nearIntentsStatus = { + source: 'near_intents_status_page', + status: 'operational', + label: 'operational', + observed_at: '2026-05-07T15:55:00.000Z', + decisive_reason: 'NEAR Intents status page has 1 active incident(s), but none match this deployment scope.', + current_incident_count: 0, + affected_services: [], + observed_incident_count: 1, + observed_affected_services: ['Other Blockchains'], + unrelated_incident_count: 1, + quoting_stopped: false, + }; + + const dashboard = buildDashboardBootstrap({ + config, + auth: { authenticated: true }, + portfolioMetric: null, + inventorySnapshot: null, + marketPrice: null, + recentQuotes: [], + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [] }, + submissionSummary: { total: 0, last_submission_at: null }, + fundingObservations: [], + recentDepositStatuses: [], + recentTradeDecisions: [], + recentExecuteTradeCommands: [], + recentExecutionResults: [], + recentQuoteOutcomes: [], + recentIntentRequests: [], + recentAlertTransitions: [], + nearIntentsStatus, + serviceSnapshots: [ + { + service: 'near-intents-ingest', + label: 'NEAR Intents Ingest', + base_url: 'http://near-intents-ingest', + reachable: true, + health: { ok: true }, + state: { ingest: { connected: true, last_message_at: '2026-05-07T15:54:59.000Z' } }, + }, + { + service: 'trade-executor', + label: 'Trade Executor', + base_url: 'http://trade-executor', + reachable: true, + health: { ok: true }, + state: { relay: { connected: true, last_message_at: '2026-05-07T15:54:59.000Z' } }, + }, + ], + }); + + assert.equal(dashboard.status_bar.near_intents_upstream_status, 'operational'); + assert.match(dashboard.status_bar.near_intents_upstream_reason, /none match this deployment scope/); + + const services = Object.fromEntries( + dashboard.system.service_health.map((service) => [service.service, service]), + ); + assert.equal(services['near-intents-ingest'].health_status, 'online'); + assert.equal(services['near-intents-ingest'].health_label, 'online'); + assert.equal(services['near-intents-ingest'].health_ok, true); + assert.equal(services['trade-executor'].health_status, 'online'); + assert.equal(services['trade-executor'].health_label, 'online'); + assert.equal(services['trade-executor'].upstream_status.status, 'operational'); + assert.equal(services['trade-executor'].upstream_status.unrelated_incident_count, 1); +}); + + test('bootstrap exposes deduped environment status history as environmental conditions', () => { const config = buildConfig(); const dashboard = buildDashboardBootstrap({