Some checks failed
deploy / deploy (push) Failing after 34s
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.
893 lines
26 KiB
JavaScript
893 lines
26 KiB
JavaScript
import process from 'node:process';
|
|
|
|
import { createProducer } from '../bus/kafka/producer.mjs';
|
|
import {
|
|
assetsByChain as groupAssetsByChain,
|
|
bridgeDepositAssetId,
|
|
bridgeDepositObservedAt,
|
|
uniqueChainsForAssets,
|
|
} from '../core/bridge-assets.mjs';
|
|
import { startControlApi } from '../core/control-api.mjs';
|
|
import { buildEventEnvelope } from '../core/event-envelope.mjs';
|
|
import {
|
|
buildFundingObservationKey,
|
|
correlateFundingObservation,
|
|
hasFundingObservationChanged,
|
|
matchBridgeDeposit,
|
|
summarizeFundingObservations,
|
|
} from '../core/funding-observations.mjs';
|
|
import { createJsonStateStore } from '../core/json-state-store.mjs';
|
|
import { normalizeLiquidityState } from '../core/liquidity-state.mjs';
|
|
import { buildBridgeWithdrawalPlan } from '../core/liquidity-withdrawals.mjs';
|
|
import { createLogger, serializeError } from '../core/log.mjs';
|
|
import { assertFundingObservationEvent, assertLiquidityActionEvent } from '../core/schemas.mjs';
|
|
import { loadConfig } from '../lib/config.mjs';
|
|
import {
|
|
createPostgresPool,
|
|
createTradingConfigStore,
|
|
ensureHistorySchema,
|
|
seedTradingConfig,
|
|
} from '../lib/postgres.mjs';
|
|
import { createBtcAddressObserver } from '../observers/btc-address-observer.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: 'liquidity-manager',
|
|
component: 'funding',
|
|
namespace: config.projectNamespace,
|
|
venue: 'near-intents',
|
|
});
|
|
|
|
if (!config.nearIntentsAccountId) {
|
|
logger.error('missing_account_id', {
|
|
details: {
|
|
variable: 'NEAR_INTENTS_ACCOUNT_ID',
|
|
},
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
const producer = await createProducer({
|
|
brokers: config.kafkaBrokers,
|
|
clientId: config.kafkaClientId,
|
|
logger,
|
|
});
|
|
const configPool = createPostgresPool({
|
|
connectionString: config.postgresUrl,
|
|
});
|
|
await ensureHistorySchema(configPool);
|
|
await seedTradingConfig(configPool);
|
|
const tradingConfigStore = createTradingConfigStore({
|
|
pool: configPool,
|
|
logger: logger.child({ component: 'trading-config' }),
|
|
});
|
|
const initialTradingConfig = await tradingConfigStore.forceRefresh();
|
|
const runtimeConfig = {
|
|
...config,
|
|
...initialTradingConfig,
|
|
assetRegistry: initialTradingConfig.assetRegistry || config.assetRegistry,
|
|
trackedAssets: initialTradingConfig.trackedAssets?.length
|
|
? initialTradingConfig.trackedAssets
|
|
: config.trackedAssets,
|
|
tradingBtc: initialTradingConfig.tradingBtc || config.tradingBtc,
|
|
tradingEure: initialTradingConfig.tradingEure || config.tradingEure,
|
|
};
|
|
const bridgeClient = createNearBridgeClient({ rpcUrl: config.nearBridgeRpcUrl });
|
|
const verifierClient = createVerifierClient({
|
|
nearRpcUrl: config.nearRpcUrl,
|
|
verifierContract: config.nearVerifierContract,
|
|
accountId: config.nearIntentsAccountId,
|
|
signerPrivateKey: config.nearIntentsSignerPrivateKey,
|
|
});
|
|
const btcAddressObserver = config.btcFundingObserverEnabled
|
|
? createBtcAddressObserver({
|
|
baseUrl: config.btcFundingObserverBaseUrl,
|
|
})
|
|
: null;
|
|
const store = createJsonStateStore({
|
|
stateDir: config.liquidityStateDir,
|
|
fileName: 'liquidity.json',
|
|
initialState: {
|
|
paused: false,
|
|
funding_observer_paused: false,
|
|
withdrawals_frozen: config.withdrawalsFrozen,
|
|
deposit_addresses: {},
|
|
deposits: {},
|
|
tracked_withdrawals: {},
|
|
supported_tokens: {},
|
|
funding_observations: {},
|
|
funding_observations_by_handle: {},
|
|
funding_visibility_by_asset: {},
|
|
uncredited_funding_total_by_asset: {},
|
|
credit_correlation: {},
|
|
observer_health: {},
|
|
last_refresh_at: null,
|
|
last_funding_observation_at: null,
|
|
funding_observer_last_refresh_at: null,
|
|
funding_observer_last_error: null,
|
|
last_error: null,
|
|
last_withdrawal_request: null,
|
|
last_withdrawal_result: null,
|
|
publish_count: 0,
|
|
funding_publish_count: 0,
|
|
},
|
|
});
|
|
|
|
const chains = uniqueChainsForAssets(runtimeConfig.trackedAssets);
|
|
const trackedAssetsByChain = groupAssetsByChain(runtimeConfig.trackedAssets);
|
|
const fallbackAssetByChain = new Map([
|
|
[runtimeConfig.tradingBtc.chain, runtimeConfig.tradingBtc.assetId],
|
|
[runtimeConfig.tradingEure.chain, runtimeConfig.tradingEure.assetId],
|
|
]);
|
|
|
|
const fundingObserverByChain = new Map(
|
|
btcAddressObserver ? [[runtimeConfig.tradingBtc.chain, btcAddressObserver]] : [],
|
|
);
|
|
|
|
async function refresh() {
|
|
const state = normalizeLiquidityState(store.getState(), {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
if (state.paused) return;
|
|
|
|
try {
|
|
const supported = await bridgeClient.supportedTokens({ chains });
|
|
state.supported_tokens = mapSupportedTokens(supported?.tokens || []);
|
|
|
|
for (const chain of chains) {
|
|
await refreshChain(chain, state);
|
|
}
|
|
|
|
for (const tracked of Object.values(state.tracked_withdrawals)) {
|
|
await refreshWithdrawal(tracked, state);
|
|
}
|
|
|
|
state.last_refresh_at = new Date().toISOString();
|
|
state.last_error = null;
|
|
applyFundingObservationSummary(state, state.last_refresh_at);
|
|
store.setState(state);
|
|
} catch (error) {
|
|
state.last_error = serializeError(error);
|
|
store.setState(state);
|
|
logger.error('liquidity_refresh_failed', {
|
|
topic: config.kafkaTopicOpsLiquidityAction,
|
|
details: {
|
|
error: serializeError(error),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
async function refreshChain(chain, state) {
|
|
const depositAddress = await bridgeClient.depositAddress({
|
|
accountId: config.nearIntentsAccountId,
|
|
chain,
|
|
});
|
|
const refreshedAt = new Date().toISOString();
|
|
const previousAddress = state.deposit_addresses[chain]?.address || null;
|
|
state.deposit_addresses[chain] = {
|
|
...(state.deposit_addresses[chain] || {}),
|
|
...depositAddress,
|
|
refreshed_at: refreshedAt,
|
|
};
|
|
|
|
if (previousAddress !== depositAddress.address) {
|
|
await publishAction({
|
|
action_type: 'deposit_address_refreshed',
|
|
status: 'READY',
|
|
chain,
|
|
asset_id: chainAssetIds(chain).length === 1 ? chainAssetIds(chain)[0] : null,
|
|
details: {
|
|
...depositAddress,
|
|
asset_ids: chainAssetIds(chain),
|
|
},
|
|
}, state);
|
|
}
|
|
|
|
const deposits = await bridgeClient.recentDeposits({
|
|
accountId: config.nearIntentsAccountId,
|
|
chain,
|
|
});
|
|
const bridgeDeposits = deposits?.deposits || [];
|
|
for (const deposit of bridgeDeposits) {
|
|
const assetId = mapDepositAssetId(deposit, chain);
|
|
const key = [
|
|
chain,
|
|
deposit.tx_hash || deposit.address,
|
|
deposit.intents_token_id || deposit.near_token_id || deposit.defuse_asset_identifier,
|
|
].join(':');
|
|
const normalized = {
|
|
tx_hash: deposit.tx_hash || null,
|
|
chain,
|
|
asset_id: assetId,
|
|
amount: String(deposit.amount || '0'),
|
|
account_id: deposit.account_id,
|
|
address: deposit.address,
|
|
status: deposit.status,
|
|
decimals: deposit.decimals,
|
|
created_at: bridgeDepositObservedAt(deposit),
|
|
near_token_id: deposit.near_token_id || null,
|
|
intents_token_id: deposit.intents_token_id || null,
|
|
defuse_asset_identifier: deposit.defuse_asset_identifier || null,
|
|
mint_tx_hash: deposit.mint_tx_hash || null,
|
|
};
|
|
const previous = state.deposits[key];
|
|
state.deposits[key] = normalized;
|
|
if (!previous || previous.status !== normalized.status) {
|
|
await publishAction({
|
|
action_type: 'deposit_status_observed',
|
|
status: normalized.status,
|
|
chain,
|
|
asset_id: assetId,
|
|
details: normalized,
|
|
}, state, { observedAt: normalized.created_at });
|
|
}
|
|
}
|
|
|
|
await refreshFundingObservations({
|
|
chain,
|
|
state,
|
|
fundingHandle: depositAddress.address,
|
|
bridgeDeposits,
|
|
});
|
|
}
|
|
|
|
async function refreshFundingObservations({ chain, state, fundingHandle, bridgeDeposits }) {
|
|
const refreshedAt = new Date().toISOString();
|
|
const observer = fundingObserverByChain.get(chain);
|
|
if (!fundingHandle) {
|
|
state.observer_health[chain] = {
|
|
chain,
|
|
healthy: false,
|
|
configured: false,
|
|
supported: Boolean(observer),
|
|
paused: state.funding_observer_paused,
|
|
source: observer ? 'configured' : 'unsupported',
|
|
refreshed_at: refreshedAt,
|
|
};
|
|
applyFundingObservationSummary(state, refreshedAt);
|
|
return;
|
|
}
|
|
|
|
if (!observer) {
|
|
state.observer_health[chain] = {
|
|
chain,
|
|
healthy: false,
|
|
configured: true,
|
|
supported: false,
|
|
paused: false,
|
|
handle: fundingHandle,
|
|
source: 'unsupported',
|
|
refreshed_at: refreshedAt,
|
|
};
|
|
applyFundingObservationSummary(state, refreshedAt);
|
|
return;
|
|
}
|
|
|
|
if (state.funding_observer_paused) {
|
|
state.observer_health[chain] = {
|
|
...(state.observer_health[chain] || {}),
|
|
chain,
|
|
healthy: true,
|
|
configured: true,
|
|
supported: true,
|
|
paused: true,
|
|
handle: fundingHandle,
|
|
refreshed_at: refreshedAt,
|
|
source: state.observer_health[chain]?.source || 'btc_mempool_space',
|
|
};
|
|
applyFundingObservationSummary(state, refreshedAt);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const observed = await observer.listTransactions({ address: fundingHandle });
|
|
for (const tx of observed.transactions) {
|
|
const bridgeDeposit = matchBridgeDeposit({
|
|
txHash: tx.tx_hash,
|
|
fundingHandle,
|
|
bridgeDeposits,
|
|
});
|
|
const key = buildFundingObservationKey({
|
|
chain,
|
|
fundingHandle,
|
|
txHash: tx.tx_hash,
|
|
});
|
|
const previous = state.funding_observations[key] || null;
|
|
const next = correlateFundingObservation({
|
|
existing: previous,
|
|
accountId: config.nearIntentsAccountId,
|
|
assetId: bridgeDeposit
|
|
? mapDepositAssetId(bridgeDeposit, chain)
|
|
: fallbackAssetByChain.get(chain),
|
|
chain,
|
|
fundingHandle,
|
|
source: tx.source || observed.source,
|
|
txHash: tx.tx_hash,
|
|
amount: tx.amount,
|
|
confirmations: tx.confirmations,
|
|
observedAt: tx.observed_at || observed.observed_at,
|
|
bridgeDeposit,
|
|
stuckAfterMs: config.fundingObservationStuckMs,
|
|
});
|
|
state.funding_observations[key] = next;
|
|
if (hasFundingObservationChanged(previous, next)) {
|
|
await publishFundingObservation(next, state);
|
|
}
|
|
}
|
|
|
|
for (const [key, previous] of Object.entries(state.funding_observations)) {
|
|
if (previous.chain !== chain || previous.funding_handle !== fundingHandle) continue;
|
|
|
|
const bridgeDeposit = matchBridgeDeposit({
|
|
txHash: previous.tx_hash,
|
|
fundingHandle,
|
|
bridgeDeposits,
|
|
});
|
|
const next = correlateFundingObservation({
|
|
existing: previous,
|
|
accountId: previous.account_id,
|
|
assetId: bridgeDeposit ? mapDepositAssetId(bridgeDeposit, chain) : previous.asset_id,
|
|
chain: previous.chain,
|
|
fundingHandle: previous.funding_handle,
|
|
source: previous.source,
|
|
txHash: previous.tx_hash,
|
|
amount: previous.amount,
|
|
confirmations: previous.confirmations,
|
|
observedAt: refreshedAt,
|
|
bridgeDeposit,
|
|
stuckAfterMs: config.fundingObservationStuckMs,
|
|
});
|
|
state.funding_observations[key] = next;
|
|
if (hasFundingObservationChanged(previous, next)) {
|
|
await publishFundingObservation(next, state);
|
|
}
|
|
}
|
|
|
|
state.funding_observer_last_refresh_at = observed.observed_at || refreshedAt;
|
|
state.funding_observer_last_error = null;
|
|
state.observer_health[chain] = {
|
|
chain,
|
|
healthy: true,
|
|
configured: true,
|
|
supported: true,
|
|
paused: false,
|
|
handle: fundingHandle,
|
|
source: observed.source,
|
|
observed_count: observed.transactions.length,
|
|
refreshed_at: observed.observed_at || refreshedAt,
|
|
};
|
|
applyFundingObservationSummary(state, refreshedAt);
|
|
} catch (error) {
|
|
state.funding_observer_last_error = serializeError(error);
|
|
state.observer_health[chain] = {
|
|
chain,
|
|
healthy: false,
|
|
configured: true,
|
|
supported: true,
|
|
paused: false,
|
|
handle: fundingHandle,
|
|
source: 'btc_mempool_space',
|
|
refreshed_at: refreshedAt,
|
|
error: serializeError(error),
|
|
};
|
|
applyFundingObservationSummary(state, refreshedAt);
|
|
logger.error('funding_observation_refresh_failed', {
|
|
topic: config.kafkaTopicOpsFundingObservation,
|
|
details: {
|
|
chain,
|
|
funding_handle: fundingHandle,
|
|
error: serializeError(error),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function applyFundingObservationSummary(state, now = new Date().toISOString()) {
|
|
const summary = summarizeFundingObservations(
|
|
Object.values(state.funding_observations),
|
|
{ now },
|
|
);
|
|
state.funding_observations_by_handle = summary.funding_observations_by_handle;
|
|
state.funding_visibility_by_asset = summary.funding_visibility_by_asset;
|
|
state.latest_funding_observation_at = summary.latest_funding_observation_at;
|
|
state.uncredited_funding_total_by_asset = summary.uncredited_funding_total_by_asset;
|
|
state.credit_correlation = summary.credit_correlation;
|
|
}
|
|
|
|
async function refreshWithdrawal(tracked, state) {
|
|
const status = await bridgeClient.withdrawalStatus({
|
|
withdrawalHash: tracked.withdrawal_hash,
|
|
});
|
|
const next = {
|
|
...tracked,
|
|
status: status.status,
|
|
transfer_tx_hash: status.data?.transfer_tx_hash || null,
|
|
last_checked_at: new Date().toISOString(),
|
|
};
|
|
state.tracked_withdrawals[tracked.withdrawal_hash] = next;
|
|
|
|
if (tracked.status !== next.status) {
|
|
await publishAction({
|
|
action_type: 'withdrawal_status_changed',
|
|
status: next.status,
|
|
chain: next.chain,
|
|
asset_id: next.asset_id,
|
|
details: next,
|
|
}, state);
|
|
}
|
|
}
|
|
|
|
async function estimateWithdrawal({ assetId, amount, destinationAddress, chain = null, state }) {
|
|
const plan = buildBridgeWithdrawalPlan({
|
|
assetId,
|
|
amount,
|
|
destinationAddress,
|
|
chain,
|
|
supportedTokens: state.supported_tokens,
|
|
config: runtimeConfig,
|
|
});
|
|
const estimate = await bridgeClient.withdrawalEstimate({
|
|
chain: plan.chain,
|
|
token: plan.near_token_id,
|
|
address: plan.destination_address,
|
|
});
|
|
|
|
return {
|
|
...plan,
|
|
estimate,
|
|
};
|
|
}
|
|
|
|
async function submitWithdrawal({ assetId, amount, destinationAddress, chain = null }) {
|
|
const state = store.getState();
|
|
normalizeLiquidityState(state, {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
|
|
if (state.paused) throw new Error('liquidity manager is paused');
|
|
if (state.withdrawals_frozen) throw new Error('withdrawals are frozen');
|
|
|
|
await refresh();
|
|
const planned = await estimateWithdrawal({
|
|
assetId,
|
|
amount,
|
|
destinationAddress,
|
|
chain,
|
|
state,
|
|
});
|
|
|
|
state.last_withdrawal_request = {
|
|
asset_id: planned.asset_id,
|
|
amount: planned.amount,
|
|
chain: planned.chain,
|
|
destination_address: planned.destination_address,
|
|
requested_at: new Date().toISOString(),
|
|
estimate: planned.estimate,
|
|
};
|
|
store.setState(state);
|
|
|
|
const outcome = await verifierClient.ftWithdrawRaw({
|
|
token: planned.near_token_id,
|
|
receiverId: planned.receiver_id,
|
|
amount: planned.amount,
|
|
memo: planned.memo,
|
|
});
|
|
const withdrawalHash = outcome.transaction?.hash || outcome.transaction_outcome?.id;
|
|
if (!withdrawalHash) throw new Error('missing withdrawal hash from ft_withdraw outcome');
|
|
|
|
const tracked = {
|
|
withdrawal_hash: withdrawalHash,
|
|
asset_id: planned.asset_id,
|
|
chain: planned.chain,
|
|
amount: planned.amount,
|
|
address: planned.destination_address,
|
|
near_token_id: planned.near_token_id,
|
|
defuse_asset_identifier: planned.defuse_asset_identifier,
|
|
estimate: planned.estimate,
|
|
status: 'SUBMITTED',
|
|
submitted_at: new Date().toISOString(),
|
|
};
|
|
state.tracked_withdrawals[withdrawalHash] = tracked;
|
|
state.last_withdrawal_result = {
|
|
withdrawal_hash: withdrawalHash,
|
|
status: 'SUBMITTED',
|
|
transaction: outcome.transaction || null,
|
|
transaction_outcome: outcome.transaction_outcome || null,
|
|
receipts_outcome: outcome.receipts_outcome || [],
|
|
};
|
|
store.setState(state);
|
|
|
|
await publishAction({
|
|
action_type: 'withdrawal_submitted',
|
|
status: 'SUBMITTED',
|
|
chain: planned.chain,
|
|
asset_id: planned.asset_id,
|
|
details: tracked,
|
|
}, state);
|
|
store.setState(state);
|
|
|
|
refreshWithdrawal(tracked, state)
|
|
.then(() => store.setState(state))
|
|
.catch(() => {});
|
|
|
|
return {
|
|
plan: planned,
|
|
tracked,
|
|
outcome,
|
|
};
|
|
}
|
|
|
|
async function publishAction(payload, state, { observedAt = null } = {}) {
|
|
const event = buildEventEnvelope({
|
|
source: 'liquidity-manager',
|
|
venue: 'near-intents',
|
|
eventType: 'liquidity_action',
|
|
observedAt:
|
|
observedAt
|
|
|| payload.observed_at
|
|
|| payload.details?.created_at
|
|
|| payload.details?.last_checked_at
|
|
|| payload.details?.submitted_at
|
|
|| null,
|
|
payload: {
|
|
liquidity_action_id: `${payload.action_type}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
|
account_id: config.nearIntentsAccountId,
|
|
...payload,
|
|
},
|
|
});
|
|
assertLiquidityActionEvent(event);
|
|
await producer.sendJson(config.kafkaTopicOpsLiquidityAction, event, { key: event.payload.liquidity_action_id });
|
|
state.publish_count += 1;
|
|
}
|
|
|
|
async function publishFundingObservation(payload, state) {
|
|
const event = buildEventEnvelope({
|
|
source: 'liquidity-manager',
|
|
venue: 'near-intents',
|
|
eventType: 'funding_observation',
|
|
observedAt: payload.last_seen_at,
|
|
payload,
|
|
});
|
|
assertFundingObservationEvent(event);
|
|
await producer.sendJson(config.kafkaTopicOpsFundingObservation, event, {
|
|
key: payload.funding_observation_id,
|
|
});
|
|
state.funding_publish_count += 1;
|
|
}
|
|
|
|
const timer = setInterval(refresh, config.liquidityRefreshMs);
|
|
timer.unref?.();
|
|
await refresh();
|
|
|
|
const controlApi = startControlApi({
|
|
host: config.liquidityManagerControlHost,
|
|
port: config.liquidityManagerControlPort,
|
|
logger: logger.child({ component: 'control-api' }),
|
|
service: 'liquidity-manager',
|
|
namespace: config.projectNamespace,
|
|
stateProvider: {
|
|
getState() {
|
|
return buildPublicState();
|
|
},
|
|
},
|
|
routes: [
|
|
{
|
|
method: 'POST',
|
|
path: '/refresh',
|
|
handler: async () => {
|
|
await refresh();
|
|
return {
|
|
ok: true,
|
|
...buildPublicState(),
|
|
};
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/refresh-funding-observations',
|
|
handler: async () => {
|
|
await refresh();
|
|
return {
|
|
ok: true,
|
|
...buildPublicState(),
|
|
};
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/pause-funding-observer',
|
|
handler: () => {
|
|
const state = store.getState();
|
|
normalizeLiquidityState(state, {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
state.funding_observer_paused = true;
|
|
store.setState(state);
|
|
return { ok: true, funding_observer_paused: true };
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/resume-funding-observer',
|
|
handler: async () => {
|
|
const state = store.getState();
|
|
normalizeLiquidityState(state, {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
state.funding_observer_paused = false;
|
|
store.setState(state);
|
|
await refresh();
|
|
return {
|
|
ok: true,
|
|
...buildPublicState(),
|
|
};
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/pause',
|
|
handler: () => {
|
|
const state = store.getState();
|
|
normalizeLiquidityState(state, {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
state.paused = true;
|
|
store.setState(state);
|
|
return { ok: true, paused: true };
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/resume',
|
|
handler: async () => {
|
|
const state = store.getState();
|
|
normalizeLiquidityState(state, {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
state.paused = false;
|
|
store.setState(state);
|
|
await refresh();
|
|
return { ok: true, paused: false };
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/freeze-withdrawals',
|
|
handler: async ({ body }) => {
|
|
const state = store.getState();
|
|
normalizeLiquidityState(state, {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
state.withdrawals_frozen = body.frozen !== false;
|
|
store.setState(state);
|
|
await publishAction({
|
|
action_type: 'withdrawals_frozen',
|
|
status: state.withdrawals_frozen ? 'FROZEN' : 'UNFROZEN',
|
|
chain: null,
|
|
asset_id: null,
|
|
details: {
|
|
frozen: state.withdrawals_frozen,
|
|
},
|
|
}, state);
|
|
store.setState(state);
|
|
return { ok: true, withdrawals_frozen: state.withdrawals_frozen };
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/withdrawal-estimate',
|
|
handler: async ({ body }) => {
|
|
if (!body.asset_id || !body.amount) {
|
|
return {
|
|
statusCode: 400,
|
|
payload: { error: 'asset_id and amount are required' },
|
|
};
|
|
}
|
|
|
|
const state = store.getState();
|
|
normalizeLiquidityState(state, {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
if (!Object.keys(state.supported_tokens).length) {
|
|
await refresh();
|
|
}
|
|
try {
|
|
const withdrawal = await estimateWithdrawal({
|
|
assetId: body.asset_id,
|
|
amount: body.amount,
|
|
destinationAddress: body.destination_address,
|
|
chain: body.chain,
|
|
state,
|
|
});
|
|
return {
|
|
ok: true,
|
|
withdrawal,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
statusCode: 400,
|
|
payload: {
|
|
error: error.message,
|
|
},
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/track-withdrawal',
|
|
handler: async ({ body }) => {
|
|
if (!body.withdrawal_hash || !body.asset_id || !body.chain) {
|
|
return {
|
|
statusCode: 400,
|
|
payload: { error: 'withdrawal_hash, asset_id, and chain are required' },
|
|
};
|
|
}
|
|
|
|
const state = store.getState();
|
|
normalizeLiquidityState(state, {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
state.tracked_withdrawals[body.withdrawal_hash] = {
|
|
withdrawal_hash: body.withdrawal_hash,
|
|
asset_id: body.asset_id,
|
|
chain: body.chain,
|
|
amount: String(body.amount || '0'),
|
|
status: 'TRACKED',
|
|
address: body.address || null,
|
|
noted_at: new Date().toISOString(),
|
|
};
|
|
store.setState(state);
|
|
await publishAction({
|
|
action_type: 'withdrawal_tracked',
|
|
status: 'TRACKED',
|
|
chain: body.chain,
|
|
asset_id: body.asset_id,
|
|
details: state.tracked_withdrawals[body.withdrawal_hash],
|
|
}, state);
|
|
store.setState(state);
|
|
return { ok: true, tracked: state.tracked_withdrawals[body.withdrawal_hash] };
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/withdraw',
|
|
handler: async ({ body }) => {
|
|
if (!body.asset_id || !body.amount) {
|
|
return {
|
|
statusCode: 400,
|
|
payload: { error: 'asset_id and amount are required' },
|
|
};
|
|
}
|
|
|
|
try {
|
|
const submitted = await submitWithdrawal({
|
|
assetId: body.asset_id,
|
|
amount: body.amount,
|
|
destinationAddress: body.destination_address,
|
|
chain: body.chain,
|
|
});
|
|
return {
|
|
ok: true,
|
|
withdrawal: submitted.plan,
|
|
tracked: submitted.tracked,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
statusCode: inferWithdrawStatusCode(error),
|
|
payload: {
|
|
error: error.message,
|
|
},
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/notify-deposit',
|
|
handler: async ({ body }) => {
|
|
if (!body.deposit_address || !body.tx_hash) {
|
|
return {
|
|
statusCode: 400,
|
|
payload: { error: 'deposit_address and tx_hash are required' },
|
|
};
|
|
}
|
|
await bridgeClient.notifyDeposit({
|
|
depositAddress: body.deposit_address,
|
|
txHash: body.tx_hash,
|
|
});
|
|
return { ok: true };
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
async function shutdown() {
|
|
clearInterval(timer);
|
|
await controlApi.close().catch(() => {});
|
|
await producer.disconnect();
|
|
await configPool.end().catch(() => {});
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|
|
|
|
function mapSupportedTokens(tokens) {
|
|
return Object.fromEntries(
|
|
tokens.map((token) => [
|
|
`${token.near_token_id}:${token.defuse_asset_identifier}`,
|
|
token,
|
|
]),
|
|
);
|
|
}
|
|
|
|
function mapDepositAssetId(deposit, chain) {
|
|
return bridgeDepositAssetId(deposit, {
|
|
assetRegistry: runtimeConfig.assetRegistry,
|
|
fallbackAssetId: fallbackAssetByChain.get(chain),
|
|
});
|
|
}
|
|
|
|
function chainAssetIds(chain) {
|
|
return (trackedAssetsByChain.get(chain) || []).map((asset) => asset.assetId);
|
|
}
|
|
|
|
function inferWithdrawStatusCode(error) {
|
|
const message = String(error?.message || '');
|
|
if (
|
|
message.includes('required')
|
|
|| message.includes('unsupported')
|
|
|| message.includes('amount')
|
|
|| message.includes('chain mismatch')
|
|
) {
|
|
return 400;
|
|
}
|
|
return 409;
|
|
}
|
|
|
|
function buildPublicState() {
|
|
const now = new Date().toISOString();
|
|
const state = normalizeLiquidityState(structuredClone(store.getState()), {
|
|
withdrawalsFrozen: config.withdrawalsFrozen,
|
|
});
|
|
applyFundingObservationSummary(state, now);
|
|
|
|
return {
|
|
account_id: config.nearIntentsAccountId,
|
|
tracked_assets: runtimeConfig.trackedAssets,
|
|
trading_config: tradingConfigStore.getState(),
|
|
withdrawal_defaults: Object.fromEntries(
|
|
runtimeConfig.trackedAssets.map((asset) => [asset.assetId, asset.withdrawAddress || null]),
|
|
),
|
|
...state,
|
|
observer_health: buildObserverHealth(state.observer_health, {
|
|
now,
|
|
fundingObserverPaused: state.funding_observer_paused,
|
|
}),
|
|
observer_age_ms: ageMs(state.funding_observer_last_refresh_at, now),
|
|
};
|
|
}
|
|
|
|
function buildObserverHealth(observerHealth, { now, fundingObserverPaused }) {
|
|
return Object.fromEntries(
|
|
Object.entries(observerHealth || {}).map(([chain, health]) => [
|
|
chain,
|
|
{
|
|
...health,
|
|
paused: fundingObserverPaused || health?.paused || false,
|
|
age_ms: ageMs(health?.refreshed_at, now),
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
|
|
function ageMs(from, to) {
|
|
const left = Date.parse(from || '');
|
|
const right = Date.parse(to || '');
|
|
if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
|
|
return Math.max(0, right - left);
|
|
}
|