unrip/src/apps/inventory-sync.mjs
philipp 860471f267
Some checks failed
deploy / deploy (push) Failing after 2s
Add pre-credit funding visibility and durable alerts
Proof: Implement the active turn for pre-credit funding visibility and durable operator alerts while keeping spendable inventory truth limited to bridge/verifier credit.

Assumptions: The BTC deposit handle can be observed through a mempool.space-compatible API, bridge recent_deposits remains the credit truth for correlation, and pausing market-reference-ingest or inventory-sync briefly for alert validation is safe without disarming strategy or executor.

Still fake: Gnosis pre-credit observation is not implemented, executor failure alert validation may still depend on an existing real failure unless a separate live failure is explicitly approved, and a new live deposit is still required to prove a fresh pre-credit-to-credit path if no suitable recent funding exists.
2026-04-03 17:50:39 +02:00

235 lines
6.9 KiB
JavaScript

import process from 'node:process';
import { createConsumer } from '../bus/kafka/consumer.mjs';
import { createProducer } from '../bus/kafka/producer.mjs';
import { startControlApi } from '../core/control-api.mjs';
import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs';
import { buildFundingVisibility } from '../core/funding-observations.mjs';
import { buildInventorySnapshot } from '../core/inventory.mjs';
import { createLogger, serializeError } from '../core/log.mjs';
import {
assertFundingObservationEvent,
assertInventorySnapshotEvent,
assertLiquidityActionEvent,
} from '../core/schemas.mjs';
import { loadConfig } from '../lib/config.mjs';
import { createNearBridgeClient } from '../venues/near-intents/bridge-client.mjs';
import { createVerifierClient } from '../venues/near-intents/verifier-client.mjs';
const config = loadConfig();
const logger = createLogger({
service: 'inventory-sync',
component: 'inventory',
namespace: config.projectNamespace,
venue: 'near-intents',
});
if (!config.nearIntentsAccountId) {
logger.error('missing_account_id', {
details: {
variable: 'NEAR_INTENTS_ACCOUNT_ID',
},
});
process.exit(1);
}
const bridgeClient = createNearBridgeClient({ rpcUrl: config.nearBridgeRpcUrl });
const verifierClient = createVerifierClient({
nearRpcUrl: config.nearRpcUrl,
verifierContract: config.nearVerifierContract,
});
const producer = await createProducer({
brokers: config.kafkaBrokers,
clientId: config.kafkaClientId,
logger,
});
const consumer = await createConsumer({
groupId: config.kafkaConsumerGroupInventory,
brokers: config.kafkaBrokers,
clientId: config.kafkaClientId,
logger,
});
const state = {
paused: false,
tracked_withdrawals: {},
funding_observations: {},
funding_visibility: {
last_observed_at: null,
pre_credit_inbound: {},
by_asset: {},
by_handle: {},
},
last_snapshot: null,
last_sync_at: null,
last_error: null,
publish_count: 0,
};
await consumer.subscribe({ topic: config.kafkaTopicOpsLiquidityAction, fromBeginning: true });
await consumer.subscribe({ topic: config.kafkaTopicOpsFundingObservation, fromBeginning: true });
await consumer.run({
eachMessage: async ({ topic, message }) => {
if (!message.value) return;
try {
const event = parseEventMessage(message.value.toString());
if (topic === config.kafkaTopicOpsLiquidityAction) {
assertLiquidityActionEvent(event);
if (event.payload.action_type === 'withdrawal_tracked'
|| event.payload.action_type === 'withdrawal_status_changed') {
const details = event.payload.details || {};
if (details.withdrawal_hash) {
state.tracked_withdrawals[details.withdrawal_hash] = {
withdrawal_hash: details.withdrawal_hash,
asset_id: details.asset_id,
chain: details.chain,
amount: String(details.amount || '0'),
status: details.status || event.payload.status,
address: details.address || null,
};
}
}
return;
}
if (topic === config.kafkaTopicOpsFundingObservation) {
assertFundingObservationEvent(event);
state.funding_observations[event.payload.funding_observation_id] = event.payload;
state.funding_visibility = buildFundingVisibility(
Object.values(state.funding_observations),
{ now: new Date().toISOString() },
);
}
} catch (error) {
logger.error('inventory_side_input_consume_failed', {
topic,
details: {
error: serializeError(error),
},
});
}
},
});
async function refresh() {
if (state.paused) return;
try {
const balances = await verifierClient.mtBatchBalanceOf({
accountId: config.nearIntentsAccountId,
tokenIds: config.activeAssetIds,
});
const recentDeposits = [];
for (const chain of [config.tradingBtc.chain, config.tradingEure.chain]) {
const response = await bridgeClient.recentDeposits({
accountId: config.nearIntentsAccountId,
chain,
});
for (const deposit of response?.deposits || []) {
recentDeposits.push({
tx_hash: deposit.tx_hash || null,
chain,
asset_id: chain === config.tradingBtc.chain ? config.tradingBtc.assetId : config.tradingEure.assetId,
amount: String(deposit.amount || '0'),
address: deposit.address,
status: deposit.status,
decimals: deposit.decimals,
});
}
}
const snapshot = buildInventorySnapshot({
accountId: config.nearIntentsAccountId,
balances,
recentDeposits,
trackedWithdrawals: Object.values(state.tracked_withdrawals),
assetRegistry: config.assetRegistry,
observedAt: new Date().toISOString(),
});
state.last_snapshot = snapshot;
state.last_sync_at = snapshot.synced_at;
state.last_error = null;
const event = buildEventEnvelope({
source: 'inventory-sync',
venue: 'near-intents',
eventType: 'intent_inventory',
observedAt: snapshot.synced_at,
payload: snapshot,
});
assertInventorySnapshotEvent(event);
await producer.sendJson(config.kafkaTopicStateIntentInventory, event, { key: snapshot.inventory_id });
state.publish_count += 1;
} catch (error) {
state.last_error = serializeError(error);
logger.error('inventory_refresh_failed', {
topic: config.kafkaTopicStateIntentInventory,
pair: config.activePair,
details: {
error: serializeError(error),
},
});
}
}
const timer = setInterval(refresh, config.inventorySyncRefreshMs);
timer.unref?.();
await refresh();
const controlApi = startControlApi({
host: config.inventorySyncControlHost,
port: config.inventorySyncControlPort,
logger: logger.child({ component: 'control-api' }),
service: 'inventory-sync',
namespace: config.projectNamespace,
stateProvider: {
getState() {
return {
account_id: config.nearIntentsAccountId,
...state,
funding_visibility: buildFundingVisibility(
Object.values(state.funding_observations),
{ now: new Date().toISOString() },
),
};
},
},
routes: [
{
method: 'POST',
path: '/refresh',
handler: async () => {
await refresh();
return { ok: true, ...state };
},
},
{
method: 'POST',
path: '/pause',
handler: () => {
state.paused = true;
return { ok: true, paused: true };
},
},
{
method: 'POST',
path: '/resume',
handler: async () => {
state.paused = false;
await refresh();
return { ok: true, paused: false };
},
},
],
});
async function shutdown() {
clearInterval(timer);
await controlApi.close().catch(() => {});
await consumer.disconnect();
await producer.disconnect();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);