unrip/src/venues/near-intents/ws.mjs
philipp 2ffa4b17f1
Some checks failed
deploy / deploy (push) Failing after 34s
Move trading config into Postgres
Proof: npm test passed 159/159; npm run operator-dashboard:build passed; repo-local Postgres importer smoke test imported 163 live 1Click tokens with only 3 inventory-enabled seed assets and nBTC/EURe pairs at 49 bps.

Assumptions: Forgejo main push is the repo deployment path; production has existing repo-managed POSTGRES_URL/POSTGRES_PASSWORD/NEAR_INTENTS_API_KEY secrets; startup seed may create initial current nBTC/EURe config but must preserve DB runtime pair flags after creation.

Still fake: no live funds movement was attempted; imported supported assets remain catalog-only unless explicitly enabled in DB; production rollout evidence still depends on the Forgejo deploy job completing after this push.
2026-05-12 21:34:58 +02:00

320 lines
9 KiB
JavaScript

import { matchesPairFilter } from '../../core/pair-filter.mjs';
import { serializeError } from '../../core/log.mjs';
import { assertNormalizedSwapDemand } from '../../core/schemas.mjs';
import { buildNearIntentsQuoteEnvelope, buildNearIntentsRawEnvelope } from './normalize.mjs';
const DEFAULT_WS_URL = 'wss://solver-relay-v2.chaindefuser.com/ws';
const QUOTE_SUB_ID = 1;
const QUOTE_STATUS_SUB_ID = 2;
export async function startNearIntentsWs({
apiKey,
wsUrl = DEFAULT_WS_URL,
pairFilter,
getPairFilter = () => pairFilter,
matchesPair = null,
producer,
rawTopic,
normalizedTopic,
logger,
namespace = 'unrip',
onPublish = defaultOnPublish,
reconnectDelayMs = 2_000,
}) {
if (!apiKey) throw new Error('Missing NEAR_INTENTS_API_KEY');
let quoteSubscriptionId = null;
let quoteStatusSubscriptionId = null;
let publishedCount = 0;
let rawPublishedCount = 0;
let publishLocked = false;
let closed = false;
let reconnectTimer = null;
let activeSocket = null;
let connected = false;
let framesReceived = 0;
let quoteFramesReceived = 0;
let filteredCount = 0;
let publishErrorCount = 0;
let invalidJsonCount = 0;
let lastMessageAt = null;
let lastMatchingQuoteAt = null;
let lastPublishedAt = null;
let lastPublishedPair = null;
let reconnectCount = 0;
let lastConnectedAt = null;
let lastDisconnectedAt = null;
let lastReconnectAt = null;
const closingSockets = new WeakSet();
function connect() {
if (closed) return;
reconnectTimer = null;
reconnectCount += 1;
lastReconnectAt = new Date().toISOString();
const ws = new WebSocket(wsUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
});
activeSocket = ws;
ws.addEventListener('open', () => {
if (activeSocket !== ws) return;
connected = true;
lastConnectedAt = new Date().toISOString();
logger?.info('connection_established', {
namespace,
});
ws.send(JSON.stringify({ jsonrpc: '2.0', id: QUOTE_SUB_ID, method: 'subscribe', params: ['quote'] }));
ws.send(JSON.stringify({ jsonrpc: '2.0', id: QUOTE_STATUS_SUB_ID, method: 'subscribe', params: ['quote_status'] }));
});
ws.addEventListener('message', async (event) => {
if (activeSocket !== ws) return;
framesReceived += 1;
lastMessageAt = new Date().toISOString();
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
let payload;
try {
payload = JSON.parse(text);
} catch {
invalidJsonCount += 1;
return;
}
if (payload?.id === QUOTE_SUB_ID) {
quoteSubscriptionId = extractSubscriptionId(payload.result);
return;
}
if (payload?.id === QUOTE_STATUS_SUB_ID) {
quoteStatusSubscriptionId = extractSubscriptionId(payload.result);
return;
}
const eventFrame = extractQuoteEventFrame(payload);
if (!eventFrame) return;
quoteFramesReceived += 1;
const { subscription, merged } = eventFrame;
if (quoteStatusSubscriptionId && subscription === quoteStatusSubscriptionId) return;
if (quoteSubscriptionId && subscription && subscription !== quoteSubscriptionId) return;
if (publishLocked) return;
const envelope = buildNearIntentsQuoteEnvelope(merged);
const rawEnvelope = buildNearIntentsRawEnvelope(merged);
try {
await producer.sendJson(rawTopic, rawEnvelope, { key: rawEnvelope.event_id });
rawPublishedCount += 1;
} catch (error) {
publishErrorCount += 1;
logger?.error('raw_publish_failed', {
namespace,
topic: rawTopic,
details: {
error: serializeError(error),
quote_id: rawEnvelope.payload?.message?.quote_id || rawEnvelope.payload?.message?.quote_hash || null,
},
});
}
if (!envelope) return;
assertNormalizedSwapDemand(envelope);
const assetIn = envelope.payload?.asset_in;
const assetOut = envelope.payload?.asset_out;
if (!assetIn || !assetOut) return;
const pairAllowed = matchesPair
? await matchesPair(assetIn, assetOut)
: matchesPairFilter(assetIn, assetOut, getPairFilter());
if (!pairAllowed) {
filteredCount += 1;
return;
}
lastMatchingQuoteAt = new Date().toISOString();
publishLocked = true;
try {
await producer.sendJson(normalizedTopic, envelope, { key: envelope.payload.quote_id });
publishedCount += 1;
lastPublishedAt = new Date().toISOString();
lastPublishedPair = `${assetIn}->${assetOut}`;
onPublish(envelope, publishedCount);
} catch (error) {
publishErrorCount += 1;
logger?.error('publish_failed', {
namespace,
topic: normalizedTopic,
pair: `${assetIn}->${assetOut}`,
details: {
raw_topic: rawTopic,
error: serializeError(error),
quote_id: envelope.payload?.quote_id,
},
});
} finally {
publishLocked = false;
}
});
ws.addEventListener('close', () => {
if (activeSocket !== ws) return;
connected = false;
activeSocket = null;
lastDisconnectedAt = new Date().toISOString();
logger?.warn('connection_lost', {
namespace,
details: {
reconnect_in_ms: reconnectDelayMs,
},
});
scheduleReconnect();
});
ws.addEventListener('error', (err) => {
if (activeSocket !== ws) return;
if (closingSockets.has(ws)) return;
connected = false;
lastDisconnectedAt = new Date().toISOString();
logger?.error('socket_error', {
namespace,
details: {
error: serializeError(err),
},
});
closeSocket(ws);
scheduleReconnect();
});
}
function closeSocket(ws) {
if (!ws || ws.readyState > WebSocket.OPEN || closingSockets.has(ws)) return;
closingSockets.add(ws);
try {
ws.close();
} catch (error) {
logger?.warn('socket_close_failed', {
namespace,
details: {
error: serializeError(error),
},
});
}
}
function scheduleReconnect() {
if (closed || reconnectTimer) return;
reconnectTimer = setTimeout(connect, reconnectDelayMs);
}
connect();
return {
getState() {
return {
connected,
reconnect_count: reconnectCount,
frames_received: framesReceived,
quote_frames_received: quoteFramesReceived,
filtered_count: filteredCount,
raw_published_count: rawPublishedCount,
published_count: publishedCount,
publish_error_count: publishErrorCount,
invalid_json_count: invalidJsonCount,
last_message_at: lastMessageAt,
last_matching_quote_at: lastMatchingQuoteAt,
last_published_at: lastPublishedAt,
last_published_pair: lastPublishedPair,
last_connected_at: lastConnectedAt,
last_disconnected_at: lastDisconnectedAt,
last_reconnect_at: lastReconnectAt,
raw_topic: rawTopic,
normalized_topic: normalizedTopic,
};
},
reconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (activeSocket && activeSocket.readyState <= WebSocket.OPEN) {
closeSocket(activeSocket);
} else {
connect();
}
},
close() {
closed = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (activeSocket && activeSocket.readyState <= WebSocket.OPEN) {
closeSocket(activeSocket);
}
},
};
}
function extractSubscriptionId(result) {
if (typeof result === 'string') return result;
if (result && typeof result === 'object') {
return result.subscription || result.subscription_id || result.subscriber_id || null;
}
return null;
}
function extractQuoteEventFrame(payload) {
const candidates = [];
if (payload?.method === 'event' && payload?.params) {
candidates.push(payload.params);
}
if (payload?.result && typeof payload.result === 'object') {
candidates.push(payload.result);
}
if (payload && typeof payload === 'object') {
candidates.push(payload);
}
for (const candidate of candidates) {
const data = candidate?.data;
const metadata = candidate?.metadata;
const merged = isRecord(data) || isRecord(metadata)
? { ...(isRecord(metadata) ? metadata : {}), ...(isRecord(data) ? data : {}) }
: candidate;
if (!isRecord(merged)) continue;
if (!looksLikeQuotePayload(merged)) continue;
return {
subscription: candidate?.subscription ?? null,
merged,
};
}
return null;
}
function looksLikeQuotePayload(payload) {
return Boolean(
payload.quote_hash
|| payload.quote_id
|| payload.defuse_asset_identifier_in
|| payload.defuse_asset_identifier_out
|| payload.asset_in
|| payload.asset_out,
);
}
function isRecord(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function defaultOnPublish() {}