Some checks failed
deploy / deploy (push) Failing after 2s
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.
235 lines
6.9 KiB
JavaScript
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);
|