unrip/src/apps/liquidity-manager.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

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);
}