unrip/src/core/notification-layer.mjs
philipp 5542a6f00b
All checks were successful
deploy / deploy (push) Successful in 56s
Fix late settlement ntfy notifications
Proof: Targeted notification and outcome refresh tests pass; full npm test passes with 284 tests; operator dashboard bundle builds.

Assumptions: A completed settlement can be computed after its inventory movement timestamp, and notification_deliveries remains the canonical once-only de-dup guard for ntfy delivery.

Still fake: Venue-native terminal fill ids and fee-complete realized PnL remain unavailable; ntfy is still an external utility service outside the unrip app deployment ownership.
2026-06-12 15:11:25 +02:00

241 lines
10 KiB
JavaScript

import { formatUnitsDecimal } from './intent-requests.mjs';
const CREDITED_DEPOSIT_STATUSES = new Set(['CREDITED', 'COMPLETED', 'FINALIZED', 'SETTLED']);
const COMPLETED_WITHDRAWAL_STATUSES = new Set(['COMPLETED', 'FINALIZED', 'SETTLED']);
const SETTLED_ATTRIBUTION_STATUSES = new Set(['heuristic_match', 'linked_settlement']);
export function buildLiquidityActionNotification({ event, config } = {}) {
const payload = event?.payload || event || {};
const details = payload.details || {};
const status = normalizeStatus(payload.status || details.status);
if (payload.action_type === 'deposit_status_observed' && CREDITED_DEPOSIT_STATUSES.has(status)) {
if (!details.tx_hash) return null;
const asset = assetFor(config, payload.asset_id || details.asset_id);
const amount = formatAssetUnits(details.amount || '0', asset);
const sourceId = `${payload.chain || details.chain || 'unknown'}:${payload.asset_id || details.asset_id || 'unknown'}:${details.tx_hash}:${details.amount || '0'}`;
return {
notification_key: `deposit_credited:${sourceId}`,
notification_type: 'deposit_credited',
source_kind: 'liquidity_action',
source_id: details.tx_hash,
title: 'Deposit credited',
message: [
`Deposit credited: ${amount}`,
`chain=${payload.chain || details.chain || 'unknown'}`,
`tx=${details.tx_hash}`,
].join('\n'),
priority: 'default',
tags: ['unrip', 'deposit'],
data: {
action_type: payload.action_type,
status,
asset_id: payload.asset_id || details.asset_id || null,
amount_units: String(details.amount || '0'),
tx_hash: details.tx_hash,
chain: payload.chain || details.chain || null,
},
};
}
if (payload.action_type === 'withdrawal_status_changed' && COMPLETED_WITHDRAWAL_STATUSES.has(status)) {
if (!details.withdrawal_hash) return null;
const asset = assetFor(config, payload.asset_id || details.asset_id);
const amount = formatAssetUnits(details.amount || '0', asset);
const sourceId = `${payload.chain || details.chain || 'unknown'}:${payload.asset_id || details.asset_id || 'unknown'}:${details.withdrawal_hash}`;
return {
notification_key: `withdrawal_completed:${sourceId}`,
notification_type: 'withdrawal_completed',
source_kind: 'liquidity_action',
source_id: details.withdrawal_hash,
title: 'Withdrawal completed',
message: [
`Withdrawal completed: ${amount}`,
`chain=${payload.chain || details.chain || 'unknown'}`,
`withdrawal=${details.withdrawal_hash}`,
details.transfer_tx_hash ? `transfer=${details.transfer_tx_hash}` : null,
].filter(Boolean).join('\n'),
priority: 'default',
tags: ['unrip', 'withdrawal'],
data: {
action_type: payload.action_type,
status,
asset_id: payload.asset_id || details.asset_id || null,
amount_units: String(details.amount || '0'),
withdrawal_hash: details.withdrawal_hash,
transfer_tx_hash: details.transfer_tx_hash || null,
chain: payload.chain || details.chain || null,
},
};
}
return null;
}
export function buildQuoteOutcomeNotification({ record, config } = {}) {
if (!hasProvenSettlement(record)) return null;
const quoteId = record.quote_id || record.payload?.quote_id || null;
if (!quoteId) return null;
const delta = record.attributed_inventory_delta || record.payload?.attributed_inventory_delta || {};
const inventoryId = delta.inventory_id || 'unknown-inventory';
const deltaSummary = formatDeltaSummary(delta.delta_units || {}, config);
return {
notification_key: `trade_completed:quote:${quoteId}:${inventoryId}`,
notification_type: 'trade_completed',
source_kind: 'quote_response',
source_id: quoteId,
title: 'Trade completed',
message: [
`Quote trade completed: ${quoteId}`,
`asset movement: ${deltaSummary}`,
record.gross_edge_pct || record.payload?.gross_edge_pct ? `edge=${record.gross_edge_pct || record.payload?.gross_edge_pct}%` : null,
`inventory=${inventoryId}`,
].filter(Boolean).join('\n'),
priority: 'default',
tags: ['unrip', 'trade'],
data: {
trade_kind: 'quote_response',
quote_id: quoteId,
decision_id: record.decision_id || record.payload?.decision_id || null,
command_id: record.command_id || record.payload?.command_id || null,
outcome_status: record.outcome_status || record.payload?.outcome_status || null,
attribution_status: record.attribution_status || record.payload?.attribution_status || null,
inventory_id: inventoryId,
delta_units: delta.delta_units || null,
gross_edge_pct: record.gross_edge_pct || record.payload?.gross_edge_pct || null,
},
};
}
export function buildIntentRequestOutcomeNotification({ record, config } = {}) {
if (!hasProvenSettlement(record)) return null;
const requestId = record.request_id || record.payload?.request_id || null;
if (!requestId) return null;
const delta = record.attributed_inventory_delta || record.payload?.attributed_inventory_delta || {};
const inventoryId = delta.inventory_id || 'unknown-inventory';
const deltaSummary = formatDeltaSummary(delta.delta_units || {}, config);
return {
notification_key: `trade_completed:intent_request:${requestId}:${inventoryId}`,
notification_type: 'trade_completed',
source_kind: 'intent_request',
source_id: requestId,
title: 'Trade completed',
message: [
`Own request completed: ${requestId}`,
`asset movement: ${deltaSummary}`,
record.intent_hash || record.payload?.intent_hash ? `intent=${record.intent_hash || record.payload?.intent_hash}` : null,
`inventory=${inventoryId}`,
].filter(Boolean).join('\n'),
priority: 'default',
tags: ['unrip', 'trade'],
data: {
trade_kind: 'intent_request',
request_id: requestId,
idempotency_key: record.idempotency_key || record.payload?.idempotency_key || null,
submission_id: record.submission_id || record.payload?.submission_id || null,
intent_hash: record.intent_hash || record.payload?.intent_hash || null,
outcome_status: record.outcome_status || record.payload?.outcome_status || null,
attribution_status: record.attribution_status || record.payload?.attribution_status || null,
inventory_id: inventoryId,
delta_units: delta.delta_units || null,
},
};
}
export function observedAtOrAfter(value, minObservedAt) {
if (!minObservedAt) return true;
const valueTs = Date.parse(value || '');
const minTs = Date.parse(minObservedAt || '');
return Number.isFinite(valueTs) && Number.isFinite(minTs) && valueTs >= minTs;
}
export function outcomeNotificationObservedAtOrAfter(record = {}, minObservedAt) {
return observedAtOrAfter(record.outcome_observed_at || record.payload?.outcome_observed_at, minObservedAt)
|| observedAtOrAfter(record.computed_at || record.payload?.computed_at, minObservedAt);
}
export function hasProvenSettlement(record = {}) {
const payload = record.payload || {};
const status = record.outcome_status || payload.outcome_status;
const attributionStatus = record.attribution_status || payload.attribution_status;
const delta = record.attributed_inventory_delta || payload.attributed_inventory_delta;
return status === 'completed'
&& SETTLED_ATTRIBUTION_STATUSES.has(attributionStatus)
&& Boolean(delta?.delta_units);
}
export function createNotificationDispatcher({ client, store, logger = null } = {}) {
return {
async publishOnce(notification) {
if (!notification) return { ok: true, skipped: true, reason: 'no_notification' };
if (!client?.isConfigured?.()) {
return { ok: true, skipped: true, reason: 'notification_client_not_configured' };
}
const claimed = await store.claim(notification);
if (!claimed) return { ok: true, skipped: true, reason: 'notification_already_delivered' };
const result = await client.publish({
title: notification.title,
message: notification.message,
priority: notification.priority,
tags: notification.tags,
click: notification.click,
});
await store.finish(notification, result);
if (!result.ok) {
logger?.warn?.('notification_delivery_failed', {
details: {
notification_key: notification.notification_key,
notification_type: notification.notification_type,
result,
},
});
}
return result.ok
? { ...result, delivered: true }
: { ...result, delivered: false };
},
};
}
function hasAssetRegistry(config) {
return config?.assetRegistry && typeof config.assetRegistry.get === 'function';
}
function assetFor(config, assetId) {
if (hasAssetRegistry(config) && config.assetRegistry.get(assetId)) return config.assetRegistry.get(assetId);
if (config?.tradingBtc?.assetId === assetId) return config.tradingBtc;
if (config?.tradingEure?.assetId === assetId) return config.tradingEure;
return { assetId, symbol: shortAssetId(assetId), decimals: 0 };
}
function formatAssetUnits(units, asset) {
return `${formatUnitsDecimal(units || '0', Number(asset?.decimals || 0))} ${asset?.symbol || shortAssetId(asset?.assetId)}`;
}
function formatSignedAssetUnits(units, asset) {
const value = BigInt(String(units || '0'));
const sign = value > 0n ? '+' : value < 0n ? '-' : '';
const absolute = value < 0n ? -value : value;
return `${sign}${formatAssetUnits(absolute.toString(), asset)}`;
}
function formatDeltaSummary(deltaUnits, config) {
const parts = Object.entries(deltaUnits || {})
.filter(([, units]) => BigInt(String(units || '0')) !== 0n)
.map(([assetId, units]) => formatSignedAssetUnits(units, assetFor(config, assetId)));
return parts.length ? parts.join(', ') : 'no non-zero delta';
}
function normalizeStatus(status) {
return String(status || '').trim().toUpperCase();
}
function shortAssetId(assetId) {
if (!assetId) return 'asset';
const raw = String(assetId);
if (raw.length <= 18) return raw;
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
}