diff --git a/deploy/k8s/base/unrip.yaml b/deploy/k8s/base/unrip.yaml index b475377..0e949ed 100644 --- a/deploy/k8s/base/unrip.yaml +++ b/deploy/k8s/base/unrip.yaml @@ -85,6 +85,16 @@ data: STRATEGY_INVENTORY_MAX_AGE_MS: "30000" EXECUTOR_INITIAL_ARMED: "false" EXECUTOR_RESPONSE_TIMEOUT_MS: "10000" + INTENT_REQUEST_DEFAULT_AMOUNT_EURE: "5" + INTENT_REQUEST_MAX_AMOUNT_EURE: "5" + INTENT_REQUEST_DEFAULT_SLIPPAGE_BPS: "200" + INTENT_REQUEST_MAX_SLIPPAGE_BPS: "200" + INTENT_REQUEST_MIN_DEADLINE_MS: "60000" + INTENT_REQUEST_QUOTE_TIMEOUT_MS: "10000" + INTENT_REQUEST_PUBLISH_TIMEOUT_MS: "10000" + INTENT_REQUEST_STATUS_TIMEOUT_MS: "10000" + INTENT_REQUEST_INVENTORY_MAX_AGE_MS: "30000" + INTENT_REQUEST_PRICE_MAX_AGE_MS: "30000" LIQUIDITY_WITHDRAWALS_FROZEN: "true" BTC_FUNDING_OBSERVER_ENABLED: "true" BTC_FUNDING_OBSERVER_BASE_URL: https://mempool.space/api diff --git a/src/apps/history-writer.mjs b/src/apps/history-writer.mjs index 63f0be4..19ebbdc 100644 --- a/src/apps/history-writer.mjs +++ b/src/apps/history-writer.mjs @@ -13,6 +13,7 @@ import { insertHistoryEvent, loadLatestPortfolioMetric, loadPortfolioMetricInputs, + refreshIntentRequestOutcomes, refreshQuoteOutcomes, upsertPortfolioMetric, } from '../lib/postgres.mjs'; @@ -60,6 +61,9 @@ const quoteOutcomeTopics = new Set([ config.kafkaTopicCmdExecuteTrade, config.kafkaTopicExecTradeResult, ]); +const intentRequestOutcomeTopics = new Set([ + config.kafkaTopicStateIntentInventory, +]); for (const topic of topics) { await consumer.subscribe({ topic, fromBeginning: false }); @@ -80,6 +84,9 @@ const state = { last_quote_outcomes_at: null, latest_quote_outcomes: null, quote_outcomes_error: null, + last_intent_request_outcomes_at: null, + latest_intent_request_outcomes: null, + intent_request_outcomes_error: null, }; await refreshPortfolioMetrics().catch((error) => { @@ -88,6 +95,9 @@ await refreshPortfolioMetrics().catch((error) => { await refreshQuoteOutcomeAttributions().catch((error) => { state.quote_outcomes_error = serializeError(error); }); +await refreshIntentRequestOutcomeAttributions().catch((error) => { + state.intent_request_outcomes_error = serializeError(error); +}); await consumer.run({ eachMessage: async ({ topic, partition, message }) => { @@ -141,6 +151,19 @@ await consumer.run({ }); } } + if (intentRequestOutcomeTopics.has(topic)) { + try { + await refreshIntentRequestOutcomeAttributions(); + } catch (error) { + state.intent_request_outcomes_error = serializeError(error); + logger.error('intent_request_outcomes_refresh_failed', { + topic, + details: { + error: serializeError(error), + }, + }); + } + } } catch (error) { state.last_error = serializeError(error); state.error_count += 1; @@ -282,6 +305,22 @@ async function refreshPortfolioMetrics() { return state.latest_portfolio_metrics; } +async function refreshIntentRequestOutcomeAttributions() { + const records = await refreshIntentRequestOutcomes(pool, { + btcAsset: config.tradingBtc, + eureAsset: config.tradingEure, + }); + state.last_intent_request_outcomes_at = new Date().toISOString(); + state.latest_intent_request_outcomes = { + refreshed_count: records.length, + completed_count: records.filter((entry) => entry.outcome_status === 'completed').length, + not_filled_count: records.filter((entry) => entry.outcome_status === 'not_filled').length, + awaiting_settlement_count: records.filter((entry) => entry.outcome_status === 'awaiting_settlement').length, + }; + state.intent_request_outcomes_error = null; + return records; +} + async function refreshQuoteOutcomeAttributions() { const records = await refreshQuoteOutcomes(pool, { btcAsset: config.tradingBtc, diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index f7cb691..75efd26 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -26,6 +26,7 @@ import { loadConfig } from '../lib/config.mjs'; import { fetchJson } from '../lib/http.mjs'; import { createPostgresPool, + ensureHistorySchema, loadCurrentFundingObservations, loadLatestInventorySnapshot, loadLatestMarketPrice, @@ -34,6 +35,7 @@ import { loadRecentDepositStatuses, loadRecentExecuteTradeCommands, loadRecentExecutionResults, + loadRecentIntentRequests, loadRecentQuoteOutcomes, loadRecentTradeDecisions, loadRecentQuotes, @@ -72,6 +74,7 @@ if ( const pool = createPostgresPool({ connectionString: config.postgresUrl, }); +await ensureHistorySchema(pool); const staticAssets = await loadStaticAssets(); const initialServiceSnapshots = await loadServiceSnapshots(); @@ -345,6 +348,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { recentExecuteTradeCommands, recentExecutionResults, recentQuoteOutcomes, + recentIntentRequests, recentAlertTransitions, serviceSnapshots, ] = await Promise.all([ @@ -411,6 +415,16 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { [], sourceErrors, ), + safeSourceLoad( + 'recent_intent_requests', + () => loadRecentIntentRequests(pool, { + limit: 20, + btcAsset: config.tradingBtc, + eureAsset: config.tradingEure, + }), + [], + sourceErrors, + ), safeSourceLoad( 'recent_alert_transitions', () => loadRecentAlertTransitions(pool, { limit: 20 }), @@ -435,6 +449,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { recentExecuteTradeCommands, recentExecutionResults, recentQuoteOutcomes, + recentIntentRequests, recentAlertTransitions, serviceSnapshots, sourceErrors, diff --git a/src/apps/trade-executor.mjs b/src/apps/trade-executor.mjs index e0f4e64..2462edf 100644 --- a/src/apps/trade-executor.mjs +++ b/src/apps/trade-executor.mjs @@ -6,12 +6,28 @@ import { createArmedStateStore } from '../core/armed-state-store.mjs'; import { startControlApi } from '../core/control-api.mjs'; import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs'; import { createExecutorStateStore } from '../core/executor-state-store.mjs'; +import { createIntentRequestController } from '../core/intent-request-controller.mjs'; import { createLogger, serializeError } from '../core/log.mjs'; -import { assertExecuteTradeCommand, assertTradeResult } from '../core/schemas.mjs'; +import { + assertExecuteTradeCommand, + assertIntentRequestPreflightEvent, + assertIntentRequestSubmissionResultEvent, + assertTradeResult, +} from '../core/schemas.mjs'; import { loadConfig } from '../lib/config.mjs'; +import { + createPostgresPool, + ensureHistorySchema, + insertHistoryEvent, + loadIntentRequestPreflightByIdOrKey, + loadLatestIntentRequestSubmission, + loadLatestInventorySnapshot, + loadLatestMarketPrice, + refreshIntentRequestOutcomes, +} from '../lib/postgres.mjs'; import { buildQuoteResponseSubmission } from '../venues/near-intents/signing.mjs'; import { startSolverRelayWs } from '../venues/near-intents/solver-relay-ws.mjs'; -import { createVerifierClient } from '../venues/near-intents/verifier-client.mjs'; +import { createSolverRelayRpcClient, createVerifierClient } from '../venues/near-intents/verifier-client.mjs'; import { ageMs } from '../core/runtime-health.mjs'; const config = loadConfig(); @@ -53,6 +69,14 @@ const verifierClient = createVerifierClient({ signerPrivateKey: config.nearIntentsSignerPrivateKey, }); const signer = verifierClient.getSigner(); +const solverRelayRpcClient = createSolverRelayRpcClient({ + rpcUrl: config.nearIntentsRpcUrl, + apiKey: config.nearIntentsApiKey, +}); +const requestPool = createPostgresPool({ + connectionString: config.postgresUrl, +}); +await ensureHistorySchema(requestPool); const relayClient = await startSolverRelayWs({ apiKey: config.nearIntentsApiKey, wsUrl: config.nearIntentsWsUrl, @@ -80,8 +104,27 @@ const state = { last_error: null, in_flight_count: 0, submitted_count: 0, + request_creation: { + last_preflight: null, + last_submission_result: null, + preflight_count: 0, + accepted_count: 0, + blocked_count: 0, + failed_count: 0, + }, }; +const requestController = createIntentRequestController({ + config, + store: createIntentRequestStore(), + relayRpcClient: solverRelayRpcClient, + verifierClient, + signer, + isArmed: () => state.armed, + isPaused: () => state.paused, + logger: logger.child({ component: 'intent-request-controller' }), +}); + await consumer.subscribe({ topic: config.kafkaTopicCmdExecuteTrade, fromBeginning: false }); await consumer.run({ eachMessage: async ({ message }) => { @@ -286,6 +329,36 @@ const controlApi = startControlApi({ return { ok: true, paused: false }; }, }, + { + method: 'POST', + path: '/intent-request/preflight', + handler: async ({ body }) => { + const result = await requestController.preflight(body || {}); + state.request_creation.last_preflight = result; + state.request_creation.preflight_count += 1; + if (result.state === 'blocked') state.request_creation.blocked_count += 1; + return result; + }, + }, + { + method: 'POST', + path: '/intent-request/submit', + handler: async ({ body }) => { + const result = await requestController.submit(body || {}); + if (result?.statusCode != null) return result; + state.request_creation.last_submission_result = result.submission_result || null; + const status = result.submission_result?.status; + if (status === 'accepted_by_relay') state.request_creation.accepted_count += 1; + if (status === 'blocked') state.request_creation.blocked_count += 1; + if (status === 'failed') state.request_creation.failed_count += 1; + return result; + }, + }, + { + method: 'POST', + path: '/intent-request/refresh-outcomes', + handler: async () => requestController.refreshOutcomes(), + }, { method: 'POST', path: '/drain', @@ -302,11 +375,69 @@ const controlApi = startControlApi({ ], }); +function createIntentRequestStore() { + return { + loadLatestInventorySnapshot: () => loadLatestInventorySnapshot(requestPool), + loadLatestMarketPrice: () => loadLatestMarketPrice(requestPool), + findPreflight: ({ requestId = null, idempotencyKey = null } = {}) => ( + loadIntentRequestPreflightByIdOrKey(requestPool, { requestId, idempotencyKey }) + ), + findSubmissionByRequest: ({ requestId } = {}) => ( + loadLatestIntentRequestSubmission(requestPool, { requestId }) + ), + async insertPreflight(payload) { + const event = buildEventEnvelope({ + source: 'trade-executor', + venue: 'near-intents', + eventType: 'intent_request_preflight', + observedAt: payload.created_at, + payload, + }); + assertIntentRequestPreflightEvent(event); + await insertHistoryEvent(requestPool, { + table: 'intent_request_preflights', + topic: 'intent.request.preflight', + event, + record: { + quote_id: null, + pair: payload.source_asset_id + '->' + payload.destination_asset_id, + decision_key: payload.request_id, + }, + }); + }, + async insertSubmissionResult(payload) { + const event = buildEventEnvelope({ + source: 'trade-executor', + venue: 'near-intents', + eventType: 'intent_request_submission_result', + observedAt: payload.submitted_at, + payload, + }); + assertIntentRequestSubmissionResultEvent(event); + await insertHistoryEvent(requestPool, { + table: 'intent_request_submission_results', + topic: 'intent.request.submission_result', + event, + record: { + quote_id: null, + pair: payload.source_asset_id + '->' + payload.destination_asset_id, + decision_key: payload.request_id, + }, + }); + }, + refreshOutcomes: () => refreshIntentRequestOutcomes(requestPool, { + btcAsset: config.tradingBtc, + eureAsset: config.tradingEure, + }), + }; +} + async function shutdown() { await controlApi.close().catch(() => {}); relayClient.close(); await consumer.disconnect(); await producer.disconnect(); + await requestPool.end().catch(() => {}); process.exit(0); } diff --git a/src/core/history-records.mjs b/src/core/history-records.mjs index c53be8b..dff8f6c 100644 --- a/src/core/history-records.mjs +++ b/src/core/history-records.mjs @@ -1,6 +1,8 @@ import { assertExecuteTradeCommand, assertFundingObservationEvent, + assertIntentRequestPreflightEvent, + assertIntentRequestSubmissionResultEvent, assertInventorySnapshotEvent, assertLiquidityActionEvent, assertMarketPriceEvent, @@ -140,6 +142,32 @@ export function routeHistoryRecord({ topic, event }) { decision_key: event.payload.command_id, }, }; + case 'intent.request.preflight': + assertIntentRequestPreflightEvent(event); + return { + table: 'intent_request_preflights', + record: { + event_id: event.event_id, + observed_at: event.observed_at, + ingested_at: event.ingested_at, + quote_id: null, + pair: `${event.payload.source_asset_id}->${event.payload.destination_asset_id}`, + decision_key: event.payload.request_id, + }, + }; + case 'intent.request.submission_result': + assertIntentRequestSubmissionResultEvent(event); + return { + table: 'intent_request_submission_results', + record: { + event_id: event.event_id, + observed_at: event.observed_at, + ingested_at: event.ingested_at, + quote_id: null, + pair: `${event.payload.source_asset_id}->${event.payload.destination_asset_id}`, + decision_key: event.payload.request_id, + }, + }; default: throw new Error(`Unsupported topic: ${topic}`); } diff --git a/src/core/intent-request-controller.mjs b/src/core/intent-request-controller.mjs new file mode 100644 index 0000000..a5c4d2e --- /dev/null +++ b/src/core/intent-request-controller.mjs @@ -0,0 +1,402 @@ +import crypto from 'node:crypto'; + +import { serializeError } from './log.mjs'; +import { + applySlippageBps, + buildSolverQuoteRequest, + computeBtcReceiveUnitsFromEure, + futureIso, + isExpired, + normalizeRelayPublishResponse, + normalizeSolverQuotes, + parseDecimalToUnits, + selectBestSolverQuote, +} from './intent-requests.mjs'; +import { buildIntentRequestSubmission } from '../venues/near-intents/signing.mjs'; + +export function createIntentRequestController({ + config, + store, + relayRpcClient, + verifierClient, + signer, + isArmed = () => false, + isPaused = () => false, + now = () => Date.now(), + uuid = () => crypto.randomUUID(), + logger = null, +} = {}) { + if (!config) throw new Error('config is required'); + if (!store) throw new Error('store is required'); + if (!relayRpcClient) throw new Error('relayRpcClient is required'); + if (!verifierClient) throw new Error('verifierClient is required'); + if (!signer) throw new Error('signer is required'); + + async function preflight(body = {}) { + const createdAt = new Date(now()).toISOString(); + const requestId = String(body.request_id || uuid()); + const idempotencyKey = String(body.idempotency_key || `intent-request:${requestId}`); + const sourceAsset = config.tradingEure; + const destinationAsset = config.tradingBtc; + const amountEure = String(body.amount_eure || config.intentRequestDefaultAmountEure || '5'); + const slippageBps = Number(body.slippage_bps ?? config.intentRequestDefaultSlippageBps ?? 200); + const minDeadlineMs = Number(body.min_deadline_ms || config.intentRequestMinDeadlineMs || 60_000); + const maxAmountUnits = parseDecimalToUnits( + String(config.intentRequestMaxAmountEure || 5), + sourceAsset.decimals, + { field: 'intent_request_max_amount_eure' }, + ); + + let sourceAmountUnits = '0'; + let expectedDestinationAmountUnits = '0'; + let minDestinationAmountUnits = '0'; + let inventorySnapshot = null; + let marketPrice = null; + let signerRegistered = null; + let solverQuoteResponse = null; + let solverQuotes = []; + let selectedQuote = null; + let state = 'blocked'; + let reasonCode = 'preflight_failed'; + let reasonText = 'Preflight failed before a solver quote was requested.'; + let deadlineAt = futureIso(now(), minDeadlineMs); + let blockedBeforeQuote = false; + + try { + sourceAmountUnits = parseDecimalToUnits(amountEure, sourceAsset.decimals, { field: 'amount_eure' }); + if (BigInt(sourceAmountUnits) > BigInt(maxAmountUnits)) { + blockedBeforeQuote = true; + throw codedError( + 'amount_exceeds_request_limit', + `Requested ${amountEure} EURe exceeds configured live request limit ${config.intentRequestMaxAmountEure || 5} EURe.`, + ); + } + if (!Number.isInteger(slippageBps) || slippageBps < 0) { + blockedBeforeQuote = true; + throw codedError('invalid_slippage', 'Slippage must be a non-negative integer in basis points.'); + } + if (slippageBps > Number(config.intentRequestMaxSlippageBps ?? 200)) { + blockedBeforeQuote = true; + throw codedError( + 'slippage_exceeds_request_limit', + `Slippage ${slippageBps} bps exceeds configured limit ${config.intentRequestMaxSlippageBps ?? 200} bps.`, + ); + } + + [inventorySnapshot, marketPrice, signerRegistered] = await Promise.all([ + store.loadLatestInventorySnapshot(), + store.loadLatestMarketPrice(), + verifierClient.isPublicKeyRegistered({ accountId: config.nearIntentsAccountId }), + ]); + + const inventoryObservedAt = inventorySnapshot?.payload?.synced_at || inventorySnapshot?.ingested_at || null; + const priceObservedAt = marketPrice?.payload?.observed_at || marketPrice?.ingested_at || null; + if (!inventorySnapshot?.payload?.spendable) { + blockedBeforeQuote = true; + throw codedError('inventory_unavailable', 'No spendable inventory snapshot is available.'); + } + if (!isFresh(inventoryObservedAt, config.intentRequestInventoryMaxAgeMs ?? config.strategyInventoryMaxAgeMs, now())) { + blockedBeforeQuote = true; + throw codedError('stale_inventory', 'Inventory snapshot is too stale for request creation.'); + } + if (!marketPrice?.payload?.eure_per_btc) { + blockedBeforeQuote = true; + throw codedError('reference_price_unavailable', 'No BTC/EUR reference price is available.'); + } + if (!isFresh(priceObservedAt, config.intentRequestPriceMaxAgeMs ?? config.strategyPriceMaxAgeMs, now())) { + blockedBeforeQuote = true; + throw codedError('stale_reference_price', 'Reference price is too stale for request creation.'); + } + if (signerRegistered !== true) { + blockedBeforeQuote = true; + throw codedError('signer_not_registered', 'Configured signer public key is not registered on the verifier contract.'); + } + + const spendableUnits = String(inventorySnapshot.payload.spendable[sourceAsset.assetId] || '0'); + if (BigInt(spendableUnits) < BigInt(sourceAmountUnits)) { + blockedBeforeQuote = true; + throw codedError('insufficient_spendable_eure', 'Spendable EURe is below the requested amount.'); + } + + expectedDestinationAmountUnits = computeBtcReceiveUnitsFromEure({ + eureUnits: sourceAmountUnits, + eurPerBtc: marketPrice.payload.eure_per_btc, + eureDecimals: sourceAsset.decimals, + btcDecimals: destinationAsset.decimals, + }); + minDestinationAmountUnits = applySlippageBps(expectedDestinationAmountUnits, slippageBps); + + solverQuoteResponse = await relayRpcClient.quote( + buildSolverQuoteRequest({ + sourceAssetId: sourceAsset.assetId, + destinationAssetId: destinationAsset.assetId, + sourceAmountUnits, + minDeadlineMs, + }), + { timeoutMs: config.intentRequestQuoteTimeoutMs || config.executorResponseTimeoutMs }, + ); + solverQuotes = normalizeSolverQuotes(solverQuoteResponse); + selectedQuote = selectBestSolverQuote(solverQuotes, { minDestinationAmountUnits }); + + if (!solverQuotes.length) { + throw codedError('solver_quote_unanswered', 'The relay returned no solver quotes for this request.'); + } + if (!selectedQuote) { + throw codedError('quote_below_min_receive', 'Solver quotes were below the explicit minimum BTC receive amount.'); + } + + state = 'draft'; + reasonCode = 'quote_available'; + reasonText = 'Solver quote meets the explicit slippage/minimum receive policy.'; + deadlineAt = selectedQuote.expiration_time || deadlineAt; + } catch (error) { + state = 'blocked'; + reasonCode = error.code || 'preflight_failed'; + reasonText = error.message || 'Preflight failed.'; + if (!blockedBeforeQuote) logger?.warn?.('intent_request_preflight_blocked', { + details: { request_id: requestId, reason_code: reasonCode, error: serializeError(error) }, + }); + } + + const payload = { + request_id: requestId, + idempotency_key: idempotencyKey, + state, + reason_code: reasonCode, + reason_text: reasonText, + source_asset_id: sourceAsset.assetId, + source_symbol: sourceAsset.symbol, + source_decimals: sourceAsset.decimals, + destination_asset_id: destinationAsset.assetId, + destination_symbol: destinationAsset.symbol, + destination_decimals: destinationAsset.decimals, + source_amount_units: sourceAmountUnits, + amount_eure: amountEure, + expected_destination_amount_units: expectedDestinationAmountUnits, + min_destination_amount_units: minDestinationAmountUnits, + quoted_destination_amount_units: selectedQuote?.amount_out || null, + slippage_bps: slippageBps, + min_deadline_ms: minDeadlineMs, + deadline_at: deadlineAt, + signer_account_id: config.nearIntentsAccountId, + signer_public_key: signer.getPublicKey().toString(), + signer_registered: signerRegistered, + verifier_contract: config.nearVerifierContract, + nonce_policy: 'current_salt_plus_random_28_bytes_on_submit', + inventory_snapshot: inventorySnapshot ? { + ingested_at: inventorySnapshot.ingested_at, + synced_at: inventorySnapshot.payload?.synced_at || null, + spendable: inventorySnapshot.payload?.spendable || {}, + pending_inbound: inventorySnapshot.payload?.pending_inbound || {}, + } : null, + market_price: marketPrice ? { + ingested_at: marketPrice.ingested_at, + observed_at: marketPrice.payload?.observed_at || null, + eure_per_btc: marketPrice.payload?.eure_per_btc || null, + } : null, + solver_quote_count: solverQuotes.length, + selected_quote: selectedQuote, + relay_quote_response: solverQuoteResponse, + live_submit_capable: state === 'draft', + created_at: createdAt, + lifecycle: { + came_in_at: createdAt, + preflight_at: createdAt, + quote_requested_at: blockedBeforeQuote ? null : createdAt, + decision: state, + decisive_reason: reasonCode, + }, + }; + + await store.insertPreflight(payload); + return payload; + } + + async function submit(body = {}) { + const preflight = await store.findPreflight({ + requestId: body.request_id || null, + idempotencyKey: body.idempotency_key || null, + }); + if (!preflight) { + return { statusCode: 404, payload: { error: 'request_preflight_not_found' } }; + } + + const existing = await store.findSubmissionByRequest({ requestId: preflight.request_id }); + if (existing) { + return { + duplicate: true, + preflight, + submission_result: existing, + }; + } + + if (preflight.state !== 'draft' || !preflight.live_submit_capable || !preflight.selected_quote?.quote_hash) { + const blocked = await recordSubmissionResult(preflight, { + status: 'blocked', + result_code: preflight.reason_code || 'preflight_not_submittable', + result_text: 'Preflight is not live-submit capable.', + }); + return { preflight, submission_result: blocked }; + } + + if (isPaused()) { + const blocked = await recordSubmissionResult(preflight, { + status: 'blocked', + result_code: 'executor_paused', + result_text: 'Executor request submission is paused.', + }); + return { preflight, submission_result: blocked }; + } + + if (!isArmed()) { + const blocked = await recordSubmissionResult(preflight, { + status: 'blocked', + result_code: 'executor_disarmed', + result_text: 'Executor is disarmed; live request was not submitted.', + }); + return { preflight, submission_result: blocked }; + } + + if (isExpired(preflight.deadline_at, now())) { + const blocked = await recordSubmissionResult(preflight, { + status: 'blocked', + result_code: 'selected_quote_expired', + result_text: 'Selected solver quote expired before submit.', + }); + return { preflight, submission_result: blocked }; + } + + const latestInventory = await store.loadLatestInventorySnapshot(); + const spendableUnits = String(latestInventory?.payload?.spendable?.[preflight.source_asset_id] || '0'); + if (BigInt(spendableUnits) < BigInt(preflight.source_amount_units)) { + const blocked = await recordSubmissionResult(preflight, { + status: 'blocked', + result_code: 'insufficient_spendable_eure', + result_text: 'Spendable EURe changed below the requested amount before submit.', + }); + return { preflight, submission_result: blocked }; + } + + const submissionId = String(body.submission_id || uuid()); + await recordSubmissionResult(preflight, { + submissionId, + status: 'submit_requested', + result_code: 'submit_requested', + result_text: 'Durable submit request was recorded before relay publish.', + }); + + try { + const currentSaltHex = await verifierClient.currentSalt(); + const submission = buildIntentRequestSubmission({ + request: preflight, + signerAccountId: config.nearIntentsAccountId, + signer, + verifierContract: config.nearVerifierContract, + currentSaltHex, + }); + const relayResponse = await relayRpcClient.publishIntent({ + quoteHashes: submission.quote_hashes, + signedData: submission.signed_data, + }, { timeoutMs: config.intentRequestPublishTimeoutMs || config.executorResponseTimeoutMs }); + const normalized = normalizeRelayPublishResponse(relayResponse); + let relayStatusResponse = null; + let relayStatus = normalized.relay_status; + let statusCheckedAt = null; + + if (normalized.intent_hash) { + statusCheckedAt = new Date(now()).toISOString(); + relayStatusResponse = await relayRpcClient.getStatus( + normalized.intent_hash, + { timeoutMs: config.intentRequestStatusTimeoutMs || config.executorResponseTimeoutMs }, + ).catch((error) => ({ error: serializeError(error) })); + relayStatus = relayStatusResponse?.status || relayStatus; + } + + const result = await recordSubmissionResult(preflight, { + submissionId, + status: normalized.accepted ? 'accepted_by_relay' : 'failed', + result_code: normalized.accepted ? 'publish_intent_accepted' : 'publish_intent_rejected', + result_text: normalized.accepted + ? 'Relay accepted the signed request. This is not settlement.' + : 'Relay rejected the signed request.', + quote_hash: submission.quote_hashes[0], + intent_hash: normalized.intent_hash, + destination_amount_units: submission.destination_amount_units, + nonce: submission.nonce, + signed_payload: submission.signed_payload, + relay_response: relayResponse, + relay_status: relayStatus || null, + relay_status_response: relayStatusResponse, + status_checked_at: statusCheckedAt, + }); + await store.refreshOutcomes?.(); + return { preflight, submission_result: result }; + } catch (error) { + const failed = await recordSubmissionResult(preflight, { + submissionId, + status: 'failed', + result_code: 'publish_intent_failed', + result_text: error.message || 'Relay publish failed.', + error: serializeError(error), + }); + await store.refreshOutcomes?.(); + return { preflight, submission_result: failed }; + } + } + + async function refreshOutcomes() { + const outcomes = await store.refreshOutcomes?.(); + return { ok: true, outcomes: outcomes || [] }; + } + + async function recordSubmissionResult(preflight, { + submissionId = null, + status, + result_code, + result_text, + ...extra + }) { + const submittedAt = new Date(now()).toISOString(); + const payload = { + request_id: preflight.request_id, + idempotency_key: preflight.idempotency_key, + submission_id: submissionId || uuid(), + status, + result_code, + result_text, + submitted_at: submittedAt, + source_asset_id: preflight.source_asset_id, + destination_asset_id: preflight.destination_asset_id, + source_amount_units: preflight.source_amount_units, + min_destination_amount_units: preflight.min_destination_amount_units, + quote_hash: preflight.selected_quote?.quote_hash || extra.quote_hash || null, + lifecycle: { + submit_requested_at: submittedAt, + relay_result_at: status === 'submit_requested' ? null : submittedAt, + state: status, + decisive_reason: result_code, + }, + ...extra, + }; + await store.insertSubmissionResult(payload); + return payload; + } + + return { + preflight, + submit, + refreshOutcomes, + }; +} + +function isFresh(timestamp, maxAgeMs, nowMs) { + const parsed = Date.parse(timestamp || ''); + if (!Number.isFinite(parsed)) return false; + return nowMs - parsed <= Number(maxAgeMs || 0); +} + +function codedError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} diff --git a/src/core/intent-request-outcomes.mjs b/src/core/intent-request-outcomes.mjs new file mode 100644 index 0000000..a2e3be9 --- /dev/null +++ b/src/core/intent-request-outcomes.mjs @@ -0,0 +1,459 @@ +const DEFAULT_ATTRIBUTION_WINDOW_MS = 10 * 60 * 1000; +const DEFAULT_SETTLEMENT_GRACE_MS = 60 * 1000; + +export const REQUEST_TERMINAL_SETTLEMENT_ATTRIBUTION_STATUSES = new Set([ + 'linked_settlement', + 'heuristic_match', +]); + +export function deriveIntentRequestOutcomeRecords({ + preflights = [], + submissions = [], + inventorySnapshots = [], + btcAsset, + eureAsset, + now = Date.now(), + attributionWindowMs = DEFAULT_ATTRIBUTION_WINDOW_MS, + settlementGraceMs = DEFAULT_SETTLEMENT_GRACE_MS, +} = {}) { + const activeAssetIds = [btcAsset?.assetId, eureAsset?.assetId].filter(Boolean); + const preflightsByRequest = new Map( + preflights + .map(normalizePreflight) + .filter((entry) => entry?.request_id) + .map((entry) => [entry.request_id, entry]), + ); + const latestSubmissionByRequest = new Map(); + + for (const submission of submissions.map(normalizeSubmission).filter(Boolean)) { + if (!submission.request_id) continue; + const previous = latestSubmissionByRequest.get(submission.request_id); + if (!previous || timestampValue(submission.submitted_at) >= timestampValue(previous.submitted_at)) { + latestSubmissionByRequest.set(submission.request_id, submission); + } + } + + const inventoryDeltas = deriveInventoryDeltas({ + inventorySnapshots, + activeAssetIds, + }); + const latestInventoryAt = latestSnapshotTimestamp(inventorySnapshots); + + return [...preflightsByRequest.values()] + .map((preflight) => deriveOneOutcome({ + preflight, + submission: latestSubmissionByRequest.get(preflight.request_id) || null, + inventoryDeltas, + latestInventoryAt, + now, + attributionWindowMs, + settlementGraceMs, + })) + .filter(Boolean); +} + +export function deriveInventoryDeltas({ inventorySnapshots = [], activeAssetIds = [] } = {}) { + const sorted = inventorySnapshots + .map(normalizeInventorySnapshot) + .filter((entry) => entry?.observed_at) + .sort((left, right) => timestampValue(left.observed_at) - timestampValue(right.observed_at)); + const deltas = []; + + for (let index = 1; index < sorted.length; index += 1) { + const previous = sorted[index - 1]; + const current = sorted[index]; + const deltaUnits = {}; + let changed = false; + + for (const assetId of activeAssetIds) { + const delta = safeBigInt(current.spendable?.[assetId]) - safeBigInt(previous.spendable?.[assetId]); + deltaUnits[assetId] = delta.toString(); + if (delta !== 0n) changed = true; + } + + if (!changed) continue; + + deltas.push({ + movement_id: `${previous.observed_at}->${current.observed_at}`, + observed_at: current.observed_at, + previous_observed_at: previous.observed_at, + inventory_id: current.inventory_id, + previous_inventory_id: previous.inventory_id, + delta_units: deltaUnits, + }); + } + + return deltas; +} + +function deriveOneOutcome({ + preflight, + submission, + inventoryDeltas, + latestInventoryAt, + now, + attributionWindowMs, + settlementGraceMs, +}) { + if (!submission) { + return baseOutcomeRecord({ + preflight, + submission: null, + outcome_status: preflight.state === 'draft' ? 'draft' : 'blocked', + outcome_observed_at: preflight.created_at, + outcome_source: 'request_preflight', + outcome_reason: preflight.reason_code || 'preflight_recorded', + attribution_status: 'unattributed', + attribution_method: null, + attributed_inventory_delta: null, + evidence: { preflight_state: preflight.state }, + }); + } + + if (['failed', 'blocked'].includes(submission.status)) { + return baseOutcomeRecord({ + preflight, + submission, + outcome_status: submission.status, + outcome_observed_at: submission.submitted_at, + outcome_source: 'request_submission_result', + outcome_reason: submission.result_code || submission.status, + attribution_status: 'unattributed', + attribution_method: null, + attributed_inventory_delta: null, + evidence: { relay_status: submission.relay_status || null }, + }); + } + + const expectedDelta = buildExpectedRequestDelta(preflight, submission); + const matches = expectedDelta + ? inventoryDeltas.filter((movement) => movementMatchesExpectedDelta({ + movement, + expectedDelta, + submittedAt: submission.submitted_at, + attributionWindowMs, + })) + : []; + + if (matches.length === 1) { + const movement = matches[0]; + return baseOutcomeRecord({ + preflight, + submission, + outcome_status: 'completed', + outcome_observed_at: movement.observed_at, + outcome_source: 'intent_inventory_spendable_delta', + outcome_reason: 'matched_inventory_delta', + attribution_status: 'heuristic_match', + attribution_method: 'exact_asset_delta_within_window', + attributed_inventory_delta: { + inventory_id: movement.inventory_id, + previous_inventory_id: movement.previous_inventory_id, + observed_at: movement.observed_at, + previous_observed_at: movement.previous_observed_at, + delta_units: movement.delta_units, + attribution_window_ms: attributionWindowMs, + uncertainty: + 'Matched by exact asset-unit delta after request submission; no venue-native fill id is linked.', + }, + evidence: { + settlement_movement_id: movement.movement_id, + settlement_source: 'intent_inventory_snapshots', + relay_status: submission.relay_status || null, + }, + }); + } + + if (matches.length > 1) { + return baseOutcomeRecord({ + preflight, + submission, + outcome_status: 'awaiting_settlement', + outcome_observed_at: submission.submitted_at, + outcome_source: 'request_submission_and_inventory_snapshots', + outcome_reason: 'ambiguous_inventory_delta_match', + attribution_status: 'ambiguous', + attribution_method: null, + attributed_inventory_delta: null, + evidence: { + candidate_movement_count: matches.length, + candidate_movement_ids: matches.map((entry) => entry.movement_id), + relay_status: submission.relay_status || null, + }, + }); + } + + if (submission.relay_status === 'NOT_FOUND_OR_NOT_VALID') { + return baseOutcomeRecord({ + preflight, + submission, + outcome_status: 'not_filled', + outcome_observed_at: submission.status_checked_at || submission.submitted_at, + outcome_source: 'solver_relay_get_status', + outcome_reason: 'relay_not_found_or_not_valid', + attribution_status: 'unattributed', + attribution_method: null, + attributed_inventory_delta: null, + evidence: { + relay_status: submission.relay_status, + relay_status_response: submission.relay_status_response, + }, + }); + } + + const expiredWindow = getExpiredSettlementWindow({ + submission, + preflight, + latestInventoryAt, + now, + settlementGraceMs, + }); + if (expiredWindow) { + return baseOutcomeRecord({ + preflight, + submission, + outcome_status: 'not_filled', + outcome_observed_at: expiredWindow.latestInventoryAt || expiredWindow.expiresAt || submission.submitted_at, + outcome_source: 'request_deadline_and_inventory_snapshots', + outcome_reason: 'deadline_elapsed_without_settlement', + attribution_status: 'unattributed', + attribution_method: null, + attributed_inventory_delta: null, + evidence: { + deadline_at: preflight.deadline_at || null, + settlement_grace_ms: settlementGraceMs, + settlement_window_expired_at: expiredWindow.expiresAt, + latest_inventory_observed_at: expiredWindow.latestInventoryAt, + relay_status: submission.relay_status || null, + }, + }); + } + + return baseOutcomeRecord({ + preflight, + submission, + outcome_status: 'awaiting_settlement', + outcome_observed_at: submission.submitted_at, + outcome_source: 'request_submission_result', + outcome_reason: submission.relay_status === 'SETTLED' + ? 'relay_settled_but_inventory_delta_missing' + : 'accepted_by_relay_without_settlement', + attribution_status: 'unattributed', + attribution_method: null, + attributed_inventory_delta: null, + evidence: { + relay_status: submission.relay_status || null, + relay_status_response: submission.relay_status_response || null, + uncertainty: + 'Relay acceptance or status is not counted as completed until inventory movement is linked.', + }, + }); +} + +function buildExpectedRequestDelta(preflight, submission) { + const destinationAmount = submission?.destination_amount_units + || preflight?.selected_quote?.amount_out + || preflight?.quoted_destination_amount_units; + if (!preflight?.source_asset_id || !preflight?.destination_asset_id) return null; + if (!preflight?.source_amount_units || !destinationAmount) return null; + + return { + [preflight.source_asset_id]: -safeBigInt(preflight.source_amount_units), + [preflight.destination_asset_id]: safeBigInt(destinationAmount), + }; +} + +function baseOutcomeRecord({ + preflight, + submission, + outcome_status, + outcome_observed_at, + outcome_source, + outcome_reason, + attribution_status, + attribution_method, + attributed_inventory_delta, + evidence, +}) { + const payload = { + request_id: preflight.request_id, + idempotency_key: preflight.idempotency_key, + submission_id: submission?.submission_id || null, + intent_hash: submission?.intent_hash || null, + source_asset_id: preflight.source_asset_id, + destination_asset_id: preflight.destination_asset_id, + source_amount_units: preflight.source_amount_units, + destination_amount_units: submission?.destination_amount_units || null, + min_destination_amount_units: preflight.min_destination_amount_units, + quote_hash: submission?.quote_hash || preflight.selected_quote?.quote_hash || null, + submitted_at: submission?.submitted_at || null, + deadline_at: preflight.deadline_at || null, + outcome_status, + outcome_observed_at, + outcome_source, + outcome_reason, + attribution_status, + attribution_method, + attributed_inventory_delta, + evidence, + }; + + return { + request_id: preflight.request_id, + idempotency_key: preflight.idempotency_key, + submission_id: submission?.submission_id || null, + intent_hash: submission?.intent_hash || null, + submission_status: submission?.status || null, + relay_status: submission?.relay_status || null, + submitted_at: submission?.submitted_at || null, + outcome_status, + outcome_observed_at, + outcome_source, + outcome_reason, + attribution_status, + attribution_method, + attributed_inventory_delta, + payload, + }; +} + +function movementMatchesExpectedDelta({ + movement, + expectedDelta, + submittedAt, + attributionWindowMs, +}) { + const submittedTs = timestampValue(submittedAt); + const movementTs = timestampValue(movement.observed_at); + if (!Number.isFinite(submittedTs) || !Number.isFinite(movementTs)) return false; + if (movementTs < submittedTs) return false; + if (movementTs - submittedTs > attributionWindowMs) return false; + + for (const [assetId, expected] of Object.entries(expectedDelta)) { + if (safeBigInt(movement.delta_units?.[assetId]) !== expected) return false; + } + return true; +} + +function getExpiredSettlementWindow({ + submission, + preflight, + latestInventoryAt, + now, + settlementGraceMs, +}) { + const submittedTs = timestampValue(submission.submitted_at); + if (!Number.isFinite(submittedTs)) return null; + + const deadlineTs = timestampValue(preflight.deadline_at); + const fallbackDeadlineMs = Number(preflight.min_deadline_ms || 60_000); + const expiresAt = Number.isFinite(deadlineTs) + ? deadlineTs + settlementGraceMs + : submittedTs + (Number.isFinite(fallbackDeadlineMs) ? fallbackDeadlineMs : 60_000) + settlementGraceMs; + const nowTs = typeof now === 'number' ? now : timestampValue(now); + const latestInventoryTs = timestampValue(latestInventoryAt); + + if ( + Number.isFinite(nowTs) + && nowTs >= expiresAt + && Number.isFinite(latestInventoryTs) + && latestInventoryTs >= expiresAt + ) { + return { + expiresAt: new Date(expiresAt).toISOString(), + latestInventoryAt: toIsoTimestamp(latestInventoryAt), + }; + } + return null; +} + +function normalizePreflight(entry) { + const payload = payloadOf(entry); + if (!payload) return null; + return { + request_id: payload.request_id || entry?.request_id || null, + idempotency_key: payload.idempotency_key || entry?.idempotency_key || null, + state: payload.state || null, + reason_code: payload.reason_code || null, + source_asset_id: payload.source_asset_id || null, + destination_asset_id: payload.destination_asset_id || null, + source_amount_units: payload.source_amount_units || null, + min_destination_amount_units: payload.min_destination_amount_units || null, + quoted_destination_amount_units: payload.quoted_destination_amount_units || null, + selected_quote: payload.selected_quote || null, + min_deadline_ms: payload.min_deadline_ms || null, + deadline_at: payload.deadline_at || null, + created_at: toIsoTimestamp( + payload.created_at + || entry?.observed_at + || entry?.ingested_at, + ), + }; +} + +function normalizeSubmission(entry) { + const payload = payloadOf(entry); + if (!payload) return null; + return { + request_id: payload.request_id || entry?.request_id || null, + idempotency_key: payload.idempotency_key || entry?.idempotency_key || null, + submission_id: payload.submission_id || null, + status: payload.status || null, + result_code: payload.result_code || null, + quote_hash: payload.quote_hash || null, + intent_hash: payload.intent_hash || null, + destination_amount_units: payload.destination_amount_units || null, + submitted_at: toIsoTimestamp( + payload.submitted_at + || entry?.observed_at + || entry?.ingested_at, + ), + relay_status: payload.relay_status || null, + relay_status_response: payload.relay_status_response || null, + status_checked_at: payload.status_checked_at || null, + }; +} + +function normalizeInventorySnapshot(entry) { + const payload = payloadOf(entry); + if (!payload?.spendable) return null; + return { + inventory_id: payload.inventory_id || null, + observed_at: toIsoTimestamp( + entry?.observed_at + || entry?.ingested_at + || payload.observed_at + || payload.synced_at, + ), + spendable: payload.spendable || {}, + }; +} + +function latestSnapshotTimestamp(inventorySnapshots) { + const timestamps = inventorySnapshots + .map((entry) => normalizeInventorySnapshot(entry)?.observed_at) + .filter(Boolean) + .sort((left, right) => timestampValue(right) - timestampValue(left)); + return timestamps[0] || null; +} + +function payloadOf(entry) { + if (!entry) return null; + return entry.payload || entry; +} + +function safeBigInt(value) { + if (value == null || value === '') return 0n; + return BigInt(String(value)); +} + +function toIsoTimestamp(value) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function timestampValue(value) { + const parsed = Date.parse(value || ''); + return Number.isFinite(parsed) ? parsed : NaN; +} diff --git a/src/core/intent-requests.mjs b/src/core/intent-requests.mjs new file mode 100644 index 0000000..7daa03f --- /dev/null +++ b/src/core/intent-requests.mjs @@ -0,0 +1,142 @@ +const BPS_DENOMINATOR = 10_000n; + +export function parseDecimalToUnits(value, decimals, { field = 'amount' } = {}) { + const raw = String(value ?? '').trim(); + if (!raw) throw new Error(`${field} is required`); + if (!/^\d+(\.\d+)?$/.test(raw)) throw new Error(`${field} must be a positive decimal`); + + const [whole, fraction = ''] = raw.split('.'); + if (fraction.length > decimals) throw new Error(`${field} has too many decimal places`); + const paddedFraction = fraction.padEnd(decimals, '0'); + const digits = `${whole}${paddedFraction}`.replace(/^0+/, '') || '0'; + const units = BigInt(digits); + if (units <= 0n) throw new Error(`${field} must be greater than zero`); + return units.toString(); +} + +export function formatUnitsDecimal(units, decimals) { + const raw = String(units ?? '0'); + const negative = raw.startsWith('-'); + const digits = negative ? raw.slice(1) : raw; + const padded = digits.padStart(decimals + 1, '0'); + const whole = padded.slice(0, padded.length - decimals) || '0'; + const fraction = decimals > 0 ? padded.slice(-decimals).replace(/0+$/, '') : ''; + return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`; +} + +export function computeBtcReceiveUnitsFromEure({ + eureUnits, + eurPerBtc, + eureDecimals, + btcDecimals, +}) { + const price = parsePositiveDecimal(eurPerBtc, { field: 'eur_per_btc' }); + const sourceUnits = BigInt(String(eureUnits || '0')); + if (sourceUnits <= 0n) throw new Error('eureUnits must be greater than zero'); + + const numerator = sourceUnits * (10n ** BigInt(btcDecimals)) * price.scale; + const denominator = (10n ** BigInt(eureDecimals)) * price.units; + if (denominator <= 0n) throw new Error('invalid price denominator'); + return (numerator / denominator).toString(); +} + +export function applySlippageBps(units, slippageBps) { + const parsed = Number(slippageBps); + if (!Number.isInteger(parsed) || parsed < 0 || parsed >= 10_000) { + throw new Error('slippage_bps must be an integer between 0 and 9999'); + } + return ((BigInt(String(units || '0')) * (BPS_DENOMINATOR - BigInt(parsed))) / BPS_DENOMINATOR).toString(); +} + +export function buildSolverQuoteRequest({ + sourceAssetId, + destinationAssetId, + sourceAmountUnits, + minDeadlineMs, +}) { + return { + defuse_asset_identifier_in: sourceAssetId, + defuse_asset_identifier_out: destinationAssetId, + exact_amount_in: String(sourceAmountUnits), + min_deadline_ms: Number(minDeadlineMs), + }; +} + +export function normalizeSolverQuotes(response) { + const quotes = Array.isArray(response) + ? response + : Array.isArray(response?.quotes) + ? response.quotes + : Array.isArray(response?.result) + ? response.result + : response?.quote_hash || response?.hash + ? [response] + : []; + + return quotes + .map((quote) => { + const quoteHash = quote.quote_hash || quote.quoteHash || quote.hash || null; + const amountIn = quote.amount_in ?? quote.exact_amount_in ?? quote.amountIn ?? null; + const amountOut = quote.amount_out ?? quote.exact_amount_out ?? quote.amountOut ?? null; + if (!quoteHash || amountOut == null) return null; + return { + quote_hash: String(quoteHash), + amount_in: amountIn == null ? null : String(amountIn), + amount_out: String(amountOut), + expiration_time: quote.expiration_time || quote.expires_at || quote.expirationTime || null, + raw: quote, + }; + }) + .filter(Boolean); +} + +export function selectBestSolverQuote(quotes, { minDestinationAmountUnits }) { + const minimum = BigInt(String(minDestinationAmountUnits || '0')); + let best = null; + + for (const quote of quotes || []) { + const amountOut = BigInt(String(quote.amount_out || '0')); + if (amountOut < minimum) continue; + if (!best || amountOut > BigInt(String(best.amount_out || '0'))) best = quote; + } + + return best; +} + +export function normalizeRelayPublishResponse(response) { + const body = response && typeof response === 'object' ? response : { status: response }; + const intentHash = body.intent_hash || body.intentHash || body.hash || body.tx_hash || null; + const relayStatus = body.status || body.result || (response === 'OK' ? 'OK' : null); + const accepted = response === 'OK' + || relayStatus === 'OK' + || relayStatus === 'PENDING' + || relayStatus === 'TX_BROADCASTED' + || relayStatus === 'SETTLED' + || Boolean(intentHash); + + return { + accepted, + intent_hash: intentHash ? String(intentHash) : null, + relay_status: relayStatus ? String(relayStatus) : null, + raw: response, + }; +} + +export function isExpired(timestamp, nowMs = Date.now()) { + const parsed = Date.parse(timestamp || ''); + return Number.isFinite(parsed) && parsed <= nowMs; +} + +export function futureIso(nowMs, durationMs) { + return new Date(nowMs + Number(durationMs || 0)).toISOString(); +} + +function parsePositiveDecimal(value, { field }) { + const raw = String(value ?? '').trim(); + if (!/^\d+(\.\d+)?$/.test(raw)) throw new Error(`${field} must be a positive decimal`); + const [whole, fraction = ''] = raw.split('.'); + const scale = 10n ** BigInt(fraction.length); + const units = BigInt(`${whole}${fraction}`.replace(/^0+/, '') || '0'); + if (units <= 0n) throw new Error(`${field} must be greater than zero`); + return { units, scale }; +} diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index b0702b8..8b10ea4 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -46,6 +46,36 @@ const CONTROL_DEFINITIONS = [ page: 'funds', risk_class: 'safe', }, + { + service: 'trade-executor', + action: 'intent-request-preflight', + method: 'POST', + path: '/intent-request/preflight', + label: 'Preflight BTC Request', + description: 'Ask solvers for an EURe-to-BTC request quote without submitting live funds.', + page: 'funds', + risk_class: 'safe', + }, + { + service: 'trade-executor', + action: 'intent-request-submit', + method: 'POST', + path: '/intent-request/submit', + label: 'Submit BTC Request', + description: 'Submit a previously drafted EURe-to-BTC request. Relay acceptance is not settlement.', + page: 'funds', + risk_class: 'live_funds', + }, + { + service: 'trade-executor', + action: 'intent-request-refresh-outcomes', + method: 'POST', + path: '/intent-request/refresh-outcomes', + label: 'Refresh Request Outcomes', + description: 'Recompute own-request settlement attribution from durable inventory snapshots.', + page: 'funds', + risk_class: 'safe', + }, { service: 'liquidity-manager', action: 'refresh', @@ -317,6 +347,7 @@ export function buildDashboardBootstrap({ recentExecuteTradeCommands, recentExecutionResults, recentQuoteOutcomes = [], + recentIntentRequests = [], recentAlertTransitions, serviceSnapshots, sourceErrors = [], @@ -378,6 +409,11 @@ export function buildDashboardBootstrap({ submissions: normalizedSubmissionPage.items, }), recent_quotes: (recentQuotes || []).slice(0, config.operatorDashboardQuoteLimit || DASHBOARD_LIVE_QUOTE_LIMIT), + intent_requests: buildIntentRequestSummary({ + config, + intentRequests: recentIntentRequests, + executorState: servicesByName['trade-executor']?.state || {}, + }), submission_ledger: normalizedSubmissionPage, controls: listDashboardControls({ page: 'funds' }), caveats: profitability.caveats, @@ -645,6 +681,52 @@ function buildFundingSummary({ config, fundingObservations, recentDepositStatuse }; } +function buildIntentRequestSummary({ config, intentRequests = [], executorState = {} } = {}) { + return { + defaults: { + source_symbol: config.tradingEure.symbol, + destination_symbol: config.tradingBtc.symbol, + amount_eure: String(config.intentRequestDefaultAmountEure || 5), + max_amount_eure: String(config.intentRequestMaxAmountEure || 5), + slippage_bps: Number(config.intentRequestDefaultSlippageBps ?? 200), + max_slippage_bps: Number(config.intentRequestMaxSlippageBps ?? 200), + }, + executor_armed: executorState.armed ?? null, + executor_paused: executorState.paused ?? null, + request_creation_state: executorState.request_creation || null, + items: (intentRequests || []).map((request) => normalizeIntentRequestForUi({ config, request })), + caveat: + 'Own request relay acceptance is not a completed trade. Completed requires durable EURe decrease and BTC increase evidence.', + }; +} + +function normalizeIntentRequestForUi({ config, request }) { + const sourceAsset = config.assetRegistry.get(request.source_asset_id) || config.tradingEure; + const destinationAsset = config.assetRegistry.get(request.destination_asset_id) || config.tradingBtc; + return { + ...request, + source_amount: formatUnits(request.source_amount_units || '0', sourceAsset?.decimals || 0), + expected_destination_amount: formatUnits( + request.expected_destination_amount_units || '0', + destinationAsset?.decimals || 0, + ), + min_destination_amount: formatUnits( + request.min_destination_amount_units || '0', + destinationAsset?.decimals || 0, + ), + quoted_destination_amount: request.quoted_destination_amount_units == null + ? null + : formatUnits(request.quoted_destination_amount_units || '0', destinationAsset?.decimals || 0), + settlement_summary: buildSettlementSummary({ + config, + delta: request.attributed_inventory_delta, + attributionStatus: request.attribution_status, + attributionMethod: request.attribution_method, + subject: 'request', + }), + }; +} + function buildRecentWithdrawals({ config, liquidityState }) { return Object.values(liquidityState?.tracked_withdrawals || {}) .sort((left, right) => sortTimestamps( @@ -1425,15 +1507,21 @@ function estimateGrossEdgeValueEure(row) { return value.toFixed(8).replace(/\.?0+$/, ''); } -function buildSettlementSummary({ config, delta, attributionStatus, attributionMethod }) { +function buildSettlementSummary({ + config, + delta, + attributionStatus, + attributionMethod, + subject = 'quote', +}) { if (!delta?.delta_units) { return { status: attributionStatus || 'unattributed', method: attributionMethod || null, lines: [], text: attributionStatus === 'ambiguous' - ? 'Inventory movement is ambiguous and is not assigned to this quote.' - : 'No settled inventory delta is linked to this quote.', + ? `Inventory movement is ambiguous and is not assigned to this ${subject}.` + : `No settled inventory delta is linked to this ${subject}.`, }; } diff --git a/src/core/schemas.mjs b/src/core/schemas.mjs index 5f6881e..1919238 100644 --- a/src/core/schemas.mjs +++ b/src/core/schemas.mjs @@ -169,3 +169,74 @@ export function assertTradeResult(event) { if (payload.result_code != null) requireString(payload.result_code, 'payload.result_code'); return event; } + +export function assertIntentRequestPreflightEvent(event) { + assertEventEnvelope(event); + if (event.event_type !== 'intent_request_preflight') { + throw new Error(`Unexpected event_type: ${event.event_type}`); + } + + const payload = event.payload; + requireString(payload.request_id, 'payload.request_id'); + requireString(payload.idempotency_key, 'payload.idempotency_key'); + requireString(payload.state, 'payload.state'); + requireOneOf(payload.state, 'payload.state', ['draft', 'blocked']); + requireString(payload.reason_code, 'payload.reason_code'); + requireString(payload.source_asset_id, 'payload.source_asset_id'); + requireString(payload.destination_asset_id, 'payload.destination_asset_id'); + requireString(payload.source_amount_units, 'payload.source_amount_units'); + requireString(payload.min_destination_amount_units, 'payload.min_destination_amount_units'); + requireNumber(payload.slippage_bps, 'payload.slippage_bps'); + requireString(payload.signer_account_id, 'payload.signer_account_id'); + requireString(payload.verifier_contract, 'payload.verifier_contract'); + requireString(payload.created_at, 'payload.created_at'); + requireObject(payload.lifecycle, 'payload.lifecycle'); + return event; +} + +export function assertIntentRequestSubmissionResultEvent(event) { + assertEventEnvelope(event); + if (event.event_type !== 'intent_request_submission_result') { + throw new Error(`Unexpected event_type: ${event.event_type}`); + } + + const payload = event.payload; + requireString(payload.request_id, 'payload.request_id'); + requireString(payload.idempotency_key, 'payload.idempotency_key'); + requireString(payload.submission_id, 'payload.submission_id'); + requireString(payload.status, 'payload.status'); + requireOneOf(payload.status, 'payload.status', [ + 'submit_requested', + 'accepted_by_relay', + 'failed', + 'blocked', + ]); + requireString(payload.result_code, 'payload.result_code'); + requireString(payload.submitted_at, 'payload.submitted_at'); + requireObject(payload.lifecycle, 'payload.lifecycle'); + return event; +} + +export function assertIntentRequestOutcomeEvent(event) { + assertEventEnvelope(event); + if (event.event_type !== 'intent_request_outcome') { + throw new Error(`Unexpected event_type: ${event.event_type}`); + } + + const payload = event.payload; + requireString(payload.request_id, 'payload.request_id'); + requireString(payload.idempotency_key, 'payload.idempotency_key'); + requireString(payload.outcome_status, 'payload.outcome_status'); + requireOneOf(payload.outcome_status, 'payload.outcome_status', [ + 'draft', + 'blocked', + 'failed', + 'awaiting_settlement', + 'not_filled', + 'completed', + ]); + requireString(payload.outcome_source, 'payload.outcome_source'); + requireString(payload.outcome_reason, 'payload.outcome_reason'); + requireString(payload.attribution_status, 'payload.attribution_status'); + return event; +} diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 18c607d..7d9036b 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -69,6 +69,16 @@ const DEFAULTS = { strategyInventoryMaxAgeMs: 30_000, executorInitialArmed: false, executorResponseTimeoutMs: 10_000, + intentRequestDefaultAmountEure: 5, + intentRequestMaxAmountEure: 5, + intentRequestDefaultSlippageBps: 200, + intentRequestMaxSlippageBps: 200, + intentRequestMinDeadlineMs: 60_000, + intentRequestQuoteTimeoutMs: 10_000, + intentRequestPublishTimeoutMs: 10_000, + intentRequestStatusTimeoutMs: 10_000, + intentRequestInventoryMaxAgeMs: 30_000, + intentRequestPriceMaxAgeMs: 30_000, withdrawalsFrozen: true, btcFundingObserverEnabled: true, btcFundingObserverBaseUrl: 'https://mempool.space/api', @@ -417,6 +427,46 @@ export function loadConfig({ envPath = '.env' } = {}) { process.env.EXECUTOR_RESPONSE_TIMEOUT_MS, DEFAULTS.executorResponseTimeoutMs, ), + intentRequestDefaultAmountEure: parseNumber( + process.env.INTENT_REQUEST_DEFAULT_AMOUNT_EURE, + DEFAULTS.intentRequestDefaultAmountEure, + ), + intentRequestMaxAmountEure: parseNumber( + process.env.INTENT_REQUEST_MAX_AMOUNT_EURE, + DEFAULTS.intentRequestMaxAmountEure, + ), + intentRequestDefaultSlippageBps: parseNumber( + process.env.INTENT_REQUEST_DEFAULT_SLIPPAGE_BPS, + DEFAULTS.intentRequestDefaultSlippageBps, + ), + intentRequestMaxSlippageBps: parseNumber( + process.env.INTENT_REQUEST_MAX_SLIPPAGE_BPS, + DEFAULTS.intentRequestMaxSlippageBps, + ), + intentRequestMinDeadlineMs: parseNumber( + process.env.INTENT_REQUEST_MIN_DEADLINE_MS, + DEFAULTS.intentRequestMinDeadlineMs, + ), + intentRequestQuoteTimeoutMs: parseNumber( + process.env.INTENT_REQUEST_QUOTE_TIMEOUT_MS, + DEFAULTS.intentRequestQuoteTimeoutMs, + ), + intentRequestPublishTimeoutMs: parseNumber( + process.env.INTENT_REQUEST_PUBLISH_TIMEOUT_MS, + DEFAULTS.intentRequestPublishTimeoutMs, + ), + intentRequestStatusTimeoutMs: parseNumber( + process.env.INTENT_REQUEST_STATUS_TIMEOUT_MS, + DEFAULTS.intentRequestStatusTimeoutMs, + ), + intentRequestInventoryMaxAgeMs: parseNumber( + process.env.INTENT_REQUEST_INVENTORY_MAX_AGE_MS, + DEFAULTS.intentRequestInventoryMaxAgeMs, + ), + intentRequestPriceMaxAgeMs: parseNumber( + process.env.INTENT_REQUEST_PRICE_MAX_AGE_MS, + DEFAULTS.intentRequestPriceMaxAgeMs, + ), withdrawalsFrozen: parseBoolean( process.env.LIQUIDITY_WITHDRAWALS_FROZEN, DEFAULTS.withdrawalsFrozen, diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 721ca84..6093135 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -1,5 +1,6 @@ import { Pool } from 'pg'; +import { deriveIntentRequestOutcomeRecords } from '../core/intent-request-outcomes.mjs'; import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs'; const TABLES = [ @@ -13,10 +14,13 @@ const TABLES = [ 'trade_decisions', 'execute_trade_commands', 'trade_execution_results', + 'intent_request_preflights', + 'intent_request_submission_results', ]; const PORTFOLIO_METRICS_TABLE = 'portfolio_metrics_snapshots'; const QUOTE_OUTCOMES_TABLE = 'quote_outcome_attributions'; +const INTENT_REQUEST_OUTCOMES_TABLE = 'intent_request_outcomes'; const CREDITED_LIQUIDITY_STATUSES = ['CREDITED', 'COMPLETED', 'FINALIZED', 'SETTLED']; const COMPLETED_WITHDRAWAL_STATUSES = ['COMPLETED', 'FINALIZED', 'SETTLED']; @@ -140,8 +144,59 @@ export async function ensureHistorySchema(pool) { CREATE INDEX IF NOT EXISTS ${QUOTE_OUTCOMES_TABLE}_outcome_status_idx ON ${QUOTE_OUTCOMES_TABLE} (outcome_status) `); + + await ensureExpressionIndex(pool, { + name: 'intent_request_preflights_request_id_idx', + table: 'intent_request_preflights', + expression: "(payload->>'request_id')", + }); + await ensureExpressionIndex(pool, { + name: 'intent_request_preflights_idempotency_key_idx', + table: 'intent_request_preflights', + expression: "(payload->>'idempotency_key')", + }); + await ensureExpressionIndex(pool, { + name: 'intent_request_submission_results_request_id_idx', + table: 'intent_request_submission_results', + expression: "(payload->>'request_id')", + }); + await ensureExpressionIndex(pool, { + name: 'intent_request_submission_results_idempotency_key_idx', + table: 'intent_request_submission_results', + expression: "(payload->>'idempotency_key')", + }); + + await pool.query(` + CREATE TABLE IF NOT EXISTS ${INTENT_REQUEST_OUTCOMES_TABLE} ( + request_id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL, + submission_id TEXT, + intent_hash TEXT, + submission_status TEXT, + relay_status TEXT, + submitted_at TIMESTAMPTZ, + outcome_status TEXT NOT NULL, + outcome_observed_at TIMESTAMPTZ, + outcome_source TEXT NOT NULL, + outcome_reason TEXT NOT NULL, + attribution_status TEXT NOT NULL, + attribution_method TEXT, + attributed_inventory_delta JSONB, + computed_at TIMESTAMPTZ NOT NULL, + payload JSONB NOT NULL + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${INTENT_REQUEST_OUTCOMES_TABLE}_outcome_observed_at_idx + ON ${INTENT_REQUEST_OUTCOMES_TABLE} (outcome_observed_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${INTENT_REQUEST_OUTCOMES_TABLE}_outcome_status_idx + ON ${INTENT_REQUEST_OUTCOMES_TABLE} (outcome_status) + `); } + export async function insertHistoryEvent(pool, { table, topic, event, record }) { await pool.query( ` @@ -461,6 +516,254 @@ export async function loadRecentQuoteOutcomes(pool, { limit = 200 } = {}) { return result.rows.map(normalizeQuoteOutcomeRow); } +export async function loadIntentRequestPreflightByIdOrKey(pool, { + requestId = null, + idempotencyKey = null, +} = {}) { + const result = await pool.query( + ` + SELECT observed_at, ingested_at, payload + FROM intent_request_preflights + WHERE ($1::text IS NOT NULL AND payload->>'request_id' = $1) + OR ($2::text IS NOT NULL AND payload->>'idempotency_key' = $2) + ORDER BY + COALESCE(observed_at, ingested_at) DESC, + CASE payload->>'status' + WHEN 'accepted_by_relay' THEN 0 + WHEN 'failed' THEN 1 + WHEN 'blocked' THEN 2 + WHEN 'submit_requested' THEN 3 + ELSE 4 + END + LIMIT 1 + `, + [requestId, idempotencyKey], + ); + + return normalizeEventPayloadRow(result.rows[0])?.payload || null; +} + +export async function loadLatestIntentRequestSubmission(pool, { requestId } = {}) { + if (!requestId) return null; + const result = await pool.query( + ` + SELECT observed_at, ingested_at, payload + FROM intent_request_submission_results + WHERE payload->>'request_id' = $1 + ORDER BY + COALESCE(observed_at, ingested_at) DESC, + CASE payload->>'status' + WHEN 'accepted_by_relay' THEN 0 + WHEN 'failed' THEN 1 + WHEN 'blocked' THEN 2 + WHEN 'submit_requested' THEN 3 + ELSE 4 + END + LIMIT 1 + `, + [requestId], + ); + + return normalizeEventPayloadRow(result.rows[0])?.payload || null; +} + +export async function refreshIntentRequestOutcomes(pool, { + btcAsset = null, + eureAsset = null, + now = Date.now(), +} = {}) { + if (!btcAsset?.assetId || !eureAsset?.assetId) return []; + + const [preflightResult, submissionResult, inventoryResult] = await Promise.all([ + pool.query(` + SELECT DISTINCT ON (payload->>'request_id') + event_id, observed_at, ingested_at, payload + FROM intent_request_preflights + WHERE COALESCE(payload->>'request_id', '') <> '' + ORDER BY payload->>'request_id', COALESCE(observed_at, ingested_at) DESC + `), + pool.query(` + SELECT event_id, observed_at, ingested_at, payload + FROM intent_request_submission_results + ORDER BY COALESCE(observed_at, ingested_at) ASC + `), + pool.query(` + SELECT event_id, observed_at, ingested_at, payload + FROM intent_inventory_snapshots + ORDER BY COALESCE(observed_at, ingested_at) ASC + `), + ]); + + const records = deriveIntentRequestOutcomeRecords({ + preflights: preflightResult.rows, + submissions: submissionResult.rows, + inventorySnapshots: inventoryResult.rows, + btcAsset, + eureAsset, + now, + }); + + if (!records.length) return []; + + const computedAt = new Date( + typeof now === 'number' ? now : Date.parse(now), + ).toISOString(); + for (const record of records) { + await upsertIntentRequestOutcome(pool, { + ...record, + computedAt, + }); + } + return records; +} + +export async function upsertIntentRequestOutcome(pool, { + request_id, + idempotency_key, + submission_id = null, + intent_hash = null, + submission_status = null, + relay_status = null, + submitted_at = null, + outcome_status, + outcome_observed_at = null, + outcome_source, + outcome_reason, + attribution_status, + attribution_method = null, + attributed_inventory_delta = null, + computedAt, + payload, +}) { + await pool.query( + ` + INSERT INTO ${INTENT_REQUEST_OUTCOMES_TABLE} ( + request_id, + idempotency_key, + submission_id, + intent_hash, + submission_status, + relay_status, + submitted_at, + outcome_status, + outcome_observed_at, + outcome_source, + outcome_reason, + attribution_status, + attribution_method, + attributed_inventory_delta, + computed_at, + payload + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16::jsonb + ) + ON CONFLICT (request_id) DO UPDATE SET + idempotency_key = EXCLUDED.idempotency_key, + submission_id = EXCLUDED.submission_id, + intent_hash = EXCLUDED.intent_hash, + submission_status = EXCLUDED.submission_status, + relay_status = EXCLUDED.relay_status, + submitted_at = EXCLUDED.submitted_at, + outcome_status = EXCLUDED.outcome_status, + outcome_observed_at = EXCLUDED.outcome_observed_at, + outcome_source = EXCLUDED.outcome_source, + outcome_reason = EXCLUDED.outcome_reason, + attribution_status = EXCLUDED.attribution_status, + attribution_method = EXCLUDED.attribution_method, + attributed_inventory_delta = EXCLUDED.attributed_inventory_delta, + computed_at = EXCLUDED.computed_at, + payload = EXCLUDED.payload + `, + [ + request_id, + idempotency_key, + submission_id, + intent_hash, + submission_status, + relay_status, + submitted_at, + outcome_status, + outcome_observed_at, + outcome_source, + outcome_reason, + attribution_status, + attribution_method, + attributed_inventory_delta ? JSON.stringify(attributed_inventory_delta) : null, + computedAt, + JSON.stringify(payload || {}), + ], + ); +} + +export async function loadRecentIntentRequests(pool, { + limit = 20, + btcAsset = null, + eureAsset = null, + now = Date.now(), +} = {}) { + if (btcAsset?.assetId && eureAsset?.assetId) { + await refreshIntentRequestOutcomes(pool, { btcAsset, eureAsset, now }).catch(() => []); + } + + const result = await pool.query( + ` + WITH latest_preflights AS ( + SELECT DISTINCT ON (payload->>'request_id') + observed_at AS preflight_observed_at, + ingested_at AS preflight_ingested_at, + payload AS preflight_payload + FROM intent_request_preflights + WHERE COALESCE(payload->>'request_id', '') <> '' + ORDER BY payload->>'request_id', COALESCE(observed_at, ingested_at) DESC + ), latest_submissions AS ( + SELECT DISTINCT ON (payload->>'request_id') + observed_at AS submission_observed_at, + ingested_at AS submission_ingested_at, + payload AS submission_payload + FROM intent_request_submission_results + WHERE COALESCE(payload->>'request_id', '') <> '' + ORDER BY + payload->>'request_id', + COALESCE(observed_at, ingested_at) DESC, + CASE payload->>'status' + WHEN 'accepted_by_relay' THEN 0 + WHEN 'failed' THEN 1 + WHEN 'blocked' THEN 2 + WHEN 'submit_requested' THEN 3 + ELSE 4 + END + ) + SELECT + p.preflight_observed_at, + p.preflight_ingested_at, + p.preflight_payload, + s.submission_observed_at, + s.submission_ingested_at, + s.submission_payload, + o.outcome_observed_at, + o.computed_at AS outcome_computed_at, + o.payload AS outcome_payload + FROM latest_preflights p + LEFT JOIN latest_submissions s + ON s.submission_payload->>'request_id' = p.preflight_payload->>'request_id' + LEFT JOIN ${INTENT_REQUEST_OUTCOMES_TABLE} o + ON o.request_id = p.preflight_payload->>'request_id' + ORDER BY COALESCE( + o.outcome_observed_at, + s.submission_observed_at, + s.submission_ingested_at, + p.preflight_observed_at, + p.preflight_ingested_at + ) DESC + LIMIT $1 + `, + [Math.max(1, Number(limit) || 20)], + ); + + return result.rows.map(normalizeIntentRequestRow); +} + + export async function loadLatestInventorySnapshot(pool) { const latest = await loadLatestEventPayload(pool, 'intent_inventory_snapshots'); if (!latest) return null; @@ -899,6 +1202,118 @@ function normalizeQuoteOutcomeRow(row) { }; } +function normalizeEventPayloadRow(row) { + if (!row) return null; + return { + observed_at: toIsoTimestamp(row.observed_at), + ingested_at: toIsoTimestamp(row.ingested_at), + payload: row.payload || {}, + }; +} + +function normalizeIntentRequestRow(row) { + const preflight = row.preflight_payload || {}; + const submission = row.submission_payload || null; + const outcome = row.outcome_payload || null; + const state = outcome?.outcome_status + || mapSubmissionStatusToRequestState(submission?.status) + || preflight.state + || 'unknown'; + const reasonCode = outcome?.outcome_reason + || submission?.result_code + || preflight.reason_code + || 'reason_unknown'; + const reasonText = submission?.result_text + || preflight.reason_text + || reasonCode.replaceAll('_', ' '); + + return { + request_id: preflight.request_id || null, + idempotency_key: preflight.idempotency_key || null, + submission_id: submission?.submission_id || outcome?.submission_id || null, + intent_hash: submission?.intent_hash || outcome?.intent_hash || null, + quote_hash: submission?.quote_hash || preflight.selected_quote?.quote_hash || null, + created_at: toIsoTimestamp(preflight.created_at || row.preflight_observed_at || row.preflight_ingested_at), + submitted_at: toIsoTimestamp(submission?.submitted_at || outcome?.submitted_at || row.submission_observed_at || row.submission_ingested_at), + resolved_at: isTerminalIntentRequestState(state) + ? toIsoTimestamp(outcome?.outcome_observed_at || row.outcome_observed_at || submission?.submitted_at) + : null, + state, + state_label: labelIntentRequestState(state), + reason_code: reasonCode, + reason_text: reasonText, + source_asset_id: preflight.source_asset_id || null, + source_symbol: preflight.source_symbol || null, + source_decimals: preflight.source_decimals ?? null, + destination_asset_id: preflight.destination_asset_id || null, + destination_symbol: preflight.destination_symbol || null, + destination_decimals: preflight.destination_decimals ?? null, + source_amount_units: preflight.source_amount_units || null, + expected_destination_amount_units: preflight.expected_destination_amount_units || null, + min_destination_amount_units: preflight.min_destination_amount_units || null, + quoted_destination_amount_units: + submission?.destination_amount_units + || preflight.quoted_destination_amount_units + || preflight.selected_quote?.amount_out + || null, + slippage_bps: preflight.slippage_bps ?? null, + deadline_at: preflight.deadline_at || null, + signer_account_id: preflight.signer_account_id || null, + signer_public_key: preflight.signer_public_key || null, + verifier_contract: preflight.verifier_contract || null, + nonce: submission?.nonce || null, + nonce_policy: preflight.nonce_policy || null, + live_submit_capable: preflight.live_submit_capable === true && !submission, + solver_quote_count: preflight.solver_quote_count || 0, + selected_quote: preflight.selected_quote || null, + submission_status: submission?.status || outcome?.submission_status || null, + relay_status: submission?.relay_status || outcome?.evidence?.relay_status || null, + relay_response: submission?.relay_response || null, + relay_status_response: submission?.relay_status_response || null, + outcome_status: outcome?.outcome_status || null, + outcome_source: outcome?.outcome_source || null, + attribution_status: outcome?.attribution_status || null, + attribution_method: outcome?.attribution_method || null, + attributed_inventory_delta: outcome?.attributed_inventory_delta || null, + has_settlement_evidence: Boolean( + outcome?.attributed_inventory_delta + && ['heuristic_match', 'linked_settlement'].includes(outcome?.attribution_status), + ), + lifecycle: { + preflight, + submission, + outcome, + }, + }; +} + +function mapSubmissionStatusToRequestState(status) { + if (status === 'accepted_by_relay') return 'awaiting_settlement'; + if (status === 'submit_requested') return 'submitted'; + if (status === 'blocked') return 'blocked'; + if (status === 'failed') return 'failed'; + return null; +} + +function labelIntentRequestState(state) { + const labels = { + draft: 'Draft', + blocked: 'Blocked', + submitted: 'Submitted', + accepted_by_relay: 'Accepted by relay', + awaiting_settlement: 'Awaiting settlement', + failed: 'Failed', + not_filled: 'Not filled', + completed: 'Completed', + }; + return labels[state] || state || 'Unknown'; +} + +function isTerminalIntentRequestState(state) { + return ['blocked', 'failed', 'not_filled', 'completed'].includes(state); +} + + function normalizeRecentQuoteRow(row) { const payload = row.payload || {}; return { diff --git a/src/operator-dashboard/static/App.jsx b/src/operator-dashboard/static/App.jsx index 4358989..99d9a2c 100644 --- a/src/operator-dashboard/static/App.jsx +++ b/src/operator-dashboard/static/App.jsx @@ -51,6 +51,7 @@ export default function App() { if (reload) { await loadBootstrap(1); } + return response.result; } catch (error) { dispatch({ type: 'error.changed', error: error.message }); } diff --git a/src/operator-dashboard/static/pages/FundsPage.jsx b/src/operator-dashboard/static/pages/FundsPage.jsx index 017f8e4..2771b27 100644 --- a/src/operator-dashboard/static/pages/FundsPage.jsx +++ b/src/operator-dashboard/static/pages/FundsPage.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import EmptyState from '../components/EmptyState.jsx'; import MetricCard from '../components/MetricCard.jsx'; @@ -252,6 +252,245 @@ function WithdrawalEstimateForm({ balances, withdrawalDefaults, onControl }) { ); } +async function copyIdentifier(value) { + if (!value || typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return; + try { + await navigator.clipboard.writeText(value); + } catch { + // Full ids remain visible when clipboard access is unavailable. + } +} + +function IdentifierLine({ label, value }) { + if (!value) return
{`${label}: unavailable`}
; + return ( +
+ {`${label}:`} + {value} + +
+ ); +} + +function IntentRequestForm({ summary, onControl }) { + const defaults = summary?.defaults || {}; + const [form, setForm] = useState({ + amount_eure: defaults.amount_eure || '5', + slippage_bps: String(defaults.slippage_bps ?? 200), + }); + + useEffect(() => { + setForm({ + amount_eure: defaults.amount_eure || '5', + slippage_bps: String(defaults.slippage_bps ?? 200), + }); + }, [defaults.amount_eure, defaults.slippage_bps]); + + async function handlePreflight(event) { + event.preventDefault(); + await onControl('trade-executor', 'intent-request-preflight', { + amount_eure: form.amount_eure, + slippage_bps: Number(form.slippage_bps), + }); + } + + return ( +
+
+
+ + setForm((current) => ({ ...current, amount_eure: event.target.value }))} + step="0.01" + type="number" + value={form.amount_eure} + /> +
{`Max ${defaults.max_amount_eure || '5'} EURe per live test request`}
+
+
+ + setForm((current) => ({ ...current, slippage_bps: event.target.value }))} + step="1" + type="number" + value={form.slippage_bps} + /> +
{`Max ${defaults.max_slippage_bps ?? 200} bps / 2%`}
+
+
+
+ + +
+
Preflight asks solvers for a quote. It does not submit live funds.
+
+ ); +} + +function IntentRequestLifecycle({ item }) { + return ( +
+
+
+
1. Request preflight
+
{formatTimestamp(item.created_at)}
+
{item.lifecycle?.preflight?.state || item.state}
+
+
{item.reason_text}
+
{item.reason_code}
+
+
+
+
2. Solver quote
+
{formatTimestamp(item.deadline_at)}
+
{`${item.solver_quote_count || 0} quote(s)`}
+
+ +
{item.quoted_destination_amount ? `Quoted ${item.quoted_destination_amount} BTC` : 'No usable quote stored'}
+
+
+
+
3. Relay submission
+
{formatTimestamp(item.submitted_at)}
+
{item.submission_status || 'Not submitted'}
+
+ + +
Relay acceptance is not a completed trade.
+
+
+
+
4. Settlement truth
+
{formatTimestamp(item.resolved_at)}
+
{item.outcome_status || item.state}
+
+
{item.settlement_summary?.text || 'No settled inventory delta is linked to this request.'}
+ {item.settlement_summary?.caveat ?
{item.settlement_summary.caveat}
: null} +
+
+
+
+ + + + +
+
+ Raw lifecycle evidence +
{stringifyJson(item.lifecycle)}
+
+
+ ); +} + +function IntentRequestsTable({ items, executorArmed, onControl }) { + const [expanded, setExpanded] = useState(() => new Set()); + if (!items?.length) return No repo-created EURe-to-BTC requests are stored yet.; + + function toggle(rowKey) { + setExpanded((current) => { + const next = new Set(current); + if (next.has(rowKey)) next.delete(rowKey); + else next.add(rowKey); + return next; + }); + } + + return ( + + + + + + + + + + + + + + + + {items.map((item, index) => { + const rowKey = item.request_id || item.idempotency_key || String(index); + const isExpanded = expanded.has(rowKey); + const canSubmit = item.live_submit_capable && executorArmed === true; + return ( + + + + + + + + + + + + {isExpanded ? ( + + + + ) : null} + + ); + })} + +
TimeRequest idSpend / min receiveQuote / intentStateReasonSettlementAction
{formatTimestamp(item.resolved_at || item.submitted_at || item.created_at)} +
{`${item.source_amount} ${item.source_symbol || 'EURe'}`}
+
{`Min ${item.min_destination_amount} ${item.destination_symbol || 'BTC'}`}
+
{`${item.slippage_bps ?? 'n/a'} bps slippage`}
+
+ + + +
{item.reason_text}
+
{item.reason_code}
+
+
{item.settlement_summary?.text || 'No settled inventory delta linked'}
+
{item.has_settlement_evidence ? 'Settlement evidence present' : 'Not completed'}
+
+
+ + {item.live_submit_capable ? ( + + ) : null} +
+ {item.live_submit_capable && executorArmed !== true ? ( +
Executor must be armed before live submit.
+ ) : null} +
+
+ ); +} + + function LastControlResult({ result }) { if (!result) return null; @@ -425,6 +664,26 @@ export default function FundsPage({ +
+
+
+
Own requests
+

EURe to BTC request creation

+
Create a solver quote request first, then submit only a drafted request. Completed requires inventory movement, not relay acceptance.
+
+
+ +
+
+ +
{funds.intent_requests?.caveat}
+ +
+
diff --git a/src/venues/near-intents/signing.mjs b/src/venues/near-intents/signing.mjs index 671d287..529aeb5 100644 --- a/src/venues/near-intents/signing.mjs +++ b/src/venues/near-intents/signing.mjs @@ -54,6 +54,60 @@ export function buildQuoteResponseSubmission({ }; } +export function buildIntentRequestSubmission({ + request, + signerAccountId, + signer, + verifierContract, + currentSaltHex, + nonce = null, + now = Date.now(), +}) { + const selectedQuote = request.selected_quote || {}; + const quoteHash = selectedQuote.quote_hash || request.quote_hash; + const destinationAmount = request.destination_amount_units + || selectedQuote.amount_out + || request.quoted_destination_amount_units; + if (!quoteHash) throw new Error('request quote_hash is required'); + if (!request.source_asset_id || !request.destination_asset_id) throw new Error('request assets are required'); + if (!request.source_amount_units || !destinationAmount) throw new Error('request amounts are required'); + + const deadline = request.deadline_at || new Date(now + Number(request.min_deadline_ms || 60_000)).toISOString(); + const intentNonce = nonce || buildIntentNonce(currentSaltHex); + const payload = { + signer_id: signerAccountId, + verifying_contract: verifierContract, + deadline, + nonce: intentNonce, + intents: [ + { + intent: 'token_diff', + diff: { + [request.source_asset_id]: `-${String(request.source_amount_units)}`, + [request.destination_asset_id]: String(destinationAmount), + }, + }, + ], + }; + + const payloadString = JSON.stringify(payload); + const signed = signer.sign(Buffer.from(payloadString)); + + return { + quote_hashes: [String(quoteHash)], + signed_data: { + standard: 'raw_ed25519', + payload: payloadString, + public_key: signer.getPublicKey().toString(), + signature: encodeNearSignature(signed.signature), + }, + signed_payload: payload, + destination_amount_units: String(destinationAmount), + nonce: intentNonce, + deadline, + }; +} + function encodeNearSignature(signatureBytes) { return `ed25519:${encodeBase58(signatureBytes)}`; } diff --git a/src/venues/near-intents/verifier-client.mjs b/src/venues/near-intents/verifier-client.mjs index 2cc8146..0cd2fc4 100644 --- a/src/venues/near-intents/verifier-client.mjs +++ b/src/venues/near-intents/verifier-client.mjs @@ -72,22 +72,45 @@ export function createVerifierClient({ }; } -export function createSolverRelayRpcClient({ rpcUrl }) { +export function createSolverRelayRpcClient({ rpcUrl, apiKey = '' }) { let id = 1; - return { - async getStatus(intentHash) { - const response = await postJson(rpcUrl, { + + async function request(method, params, { timeoutMs = 10_000 } = {}) { + const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; + const response = await postJson( + rpcUrl, + { jsonrpc: '2.0', id: id++, - method: 'get_status', - params: [{ intent_hash: intentHash }], - }); + method, + params, + }, + { + headers, + signal: AbortSignal.timeout(timeoutMs), + }, + ); - if (response.error) { - throw new Error(response.error.message || 'Solver Relay get_status failed'); - } + if (response.error) { + throw new Error(response.error.message || `Solver Relay ${method} failed`); + } - return response.result; + return response.result; + } + + return { + request, + async quote(params, options = {}) { + return request('quote', [params], options); + }, + async publishIntent({ quoteHashes, signedData }, options = {}) { + return request('publish_intent', [{ + quote_hashes: quoteHashes, + signed_data: signedData, + }], options); + }, + async getStatus(intentHash, options = {}) { + return request('get_status', [{ intent_hash: intentHash }], options); }, }; } diff --git a/test/intent-request-outcomes.test.mjs b/test/intent-request-outcomes.test.mjs new file mode 100644 index 0000000..3b328ef --- /dev/null +++ b/test/intent-request-outcomes.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { deriveIntentRequestOutcomeRecords } from '../src/core/intent-request-outcomes.mjs'; + +const BTC = { + assetId: 'nep141:btc.omft.near', + symbol: 'BTC', + decimals: 8, +}; +const EURE = { + assetId: 'nep141:eure.omft.near', + symbol: 'EURe', + decimals: 18, +}; + +function preflight(overrides = {}) { + return { + request_id: 'request-1', + idempotency_key: 'idem-1', + state: 'draft', + reason_code: 'quote_available', + source_asset_id: EURE.assetId, + destination_asset_id: BTC.assetId, + source_amount_units: '5000000000000000000', + min_destination_amount_units: '9800', + quoted_destination_amount_units: '10000', + selected_quote: { + quote_hash: 'quote-hash-1', + amount_out: '10000', + }, + min_deadline_ms: 60000, + deadline_at: '2026-04-12T10:01:00.000Z', + created_at: '2026-04-12T10:00:00.000Z', + ...overrides, + }; +} + +function submission(overrides = {}) { + return { + request_id: 'request-1', + idempotency_key: 'idem-1', + submission_id: 'submission-1', + status: 'accepted_by_relay', + result_code: 'publish_intent_accepted', + quote_hash: 'quote-hash-1', + intent_hash: 'intent-hash-1', + destination_amount_units: '10000', + submitted_at: '2026-04-12T10:00:10.000Z', + relay_status: 'PENDING', + ...overrides, + }; +} + +function outcomes({ preflights = [preflight()], submissions = [], inventorySnapshots = [], now = '2026-04-12T10:00:30.000Z' } = {}) { + return deriveIntentRequestOutcomeRecords({ + preflights, + submissions, + inventorySnapshots, + btcAsset: BTC, + eureAsset: EURE, + now: Date.parse(now), + }); +} + +test('request submitted or relay accepted does not become completed without inventory delta', () => { + const [record] = outcomes({ + submissions: [submission({ relay_status: 'SETTLED' })], + inventorySnapshots: [ + { + observed_at: '2026-04-12T10:00:00.000Z', + spendable: { + [EURE.assetId]: '5000000000000000000', + [BTC.assetId]: '0', + }, + }, + { + observed_at: '2026-04-12T10:00:20.000Z', + spendable: { + [EURE.assetId]: '5000000000000000000', + [BTC.assetId]: '0', + }, + }, + ], + }); + + assert.equal(record.submission_status, 'accepted_by_relay'); + assert.equal(record.relay_status, 'SETTLED'); + assert.equal(record.outcome_status, 'awaiting_settlement'); + assert.equal(record.outcome_reason, 'relay_settled_but_inventory_delta_missing'); + assert.equal(record.attribution_status, 'unattributed'); + assert.equal(record.attributed_inventory_delta, null); + assert.notEqual(record.outcome_status, 'completed'); +}); + +test('completed request requires exact EURe decrease and BTC increase after submission', () => { + const [record] = outcomes({ + submissions: [submission()], + inventorySnapshots: [ + { + inventory_id: 'inventory-before', + observed_at: '2026-04-12T10:00:00.000Z', + spendable: { + [EURE.assetId]: '5000000000000000000', + [BTC.assetId]: '0', + }, + }, + { + inventory_id: 'inventory-after', + observed_at: '2026-04-12T10:00:15.000Z', + spendable: { + [EURE.assetId]: '0', + [BTC.assetId]: '10000', + }, + }, + ], + }); + + assert.equal(record.outcome_status, 'completed'); + assert.equal(record.outcome_source, 'intent_inventory_spendable_delta'); + assert.equal(record.outcome_reason, 'matched_inventory_delta'); + assert.equal(record.attribution_status, 'heuristic_match'); + assert.equal(record.attributed_inventory_delta.inventory_id, 'inventory-after'); + assert.deepEqual(record.attributed_inventory_delta.delta_units, { + [BTC.assetId]: '10000', + [EURE.assetId]: '-5000000000000000000', + }); +}); + +test('accepted request becomes not filled only after deadline grace and fresh inventory evidence', () => { + const [record] = outcomes({ + submissions: [submission()], + inventorySnapshots: [ + { + observed_at: '2026-04-12T10:02:01.000Z', + spendable: { + [EURE.assetId]: '5000000000000000000', + [BTC.assetId]: '0', + }, + }, + ], + now: '2026-04-12T10:02:01.000Z', + }); + + assert.equal(record.outcome_status, 'not_filled'); + assert.equal(record.outcome_reason, 'deadline_elapsed_without_settlement'); + assert.equal(record.attribution_status, 'unattributed'); + assert.equal(record.attributed_inventory_delta, null); +}); + +test('blocked preflight remains blocked and is distinct from request rejection or completion', () => { + const [record] = outcomes({ + preflights: [preflight({ state: 'blocked', reason_code: 'insufficient_spendable_eure' })], + }); + + assert.equal(record.outcome_status, 'blocked'); + assert.equal(record.outcome_reason, 'insufficient_spendable_eure'); + assert.equal(record.submission_status, null); + assert.notEqual(record.outcome_status, 'completed'); + assert.notEqual(record.outcome_status, 'rejected'); +}); diff --git a/test/intent-requests.test.mjs b/test/intent-requests.test.mjs new file mode 100644 index 0000000..151465f --- /dev/null +++ b/test/intent-requests.test.mjs @@ -0,0 +1,275 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { KeyPair } from 'near-api-js'; + +import { createIntentRequestController } from '../src/core/intent-request-controller.mjs'; +import { + applySlippageBps, + buildSolverQuoteRequest, + computeBtcReceiveUnitsFromEure, + normalizeSolverQuotes, + parseDecimalToUnits, + selectBestSolverQuote, +} from '../src/core/intent-requests.mjs'; + +const BTC = { + assetId: 'nep141:btc.omft.near', + symbol: 'BTC', + decimals: 8, +}; +const EURE = { + assetId: 'nep141:eure.omft.near', + symbol: 'EURe', + decimals: 18, +}; + +function buildConfig() { + return { + tradingBtc: BTC, + tradingEure: EURE, + nearIntentsAccountId: 'unrip.test.near', + nearVerifierContract: 'intents.near', + intentRequestDefaultAmountEure: 5, + intentRequestMaxAmountEure: 5, + intentRequestDefaultSlippageBps: 200, + intentRequestMaxSlippageBps: 200, + intentRequestMinDeadlineMs: 60_000, + intentRequestQuoteTimeoutMs: 10_000, + intentRequestPublishTimeoutMs: 10_000, + intentRequestStatusTimeoutMs: 10_000, + intentRequestInventoryMaxAgeMs: 30_000, + intentRequestPriceMaxAgeMs: 30_000, + executorResponseTimeoutMs: 10_000, + }; +} + +function buildStore({ + inventoryUnits = '5000000000000000000', + nowIso = '2026-04-12T10:00:00.000Z', + inventorySyncedAt = nowIso, + priceObservedAt = nowIso, +} = {}) { + const preflights = []; + const submissions = []; + return { + preflights, + submissions, + async loadLatestInventorySnapshot() { + return { + ingested_at: nowIso, + payload: { + synced_at: inventorySyncedAt, + spendable: { + [EURE.assetId]: inventoryUnits, + [BTC.assetId]: '1000', + }, + pending_inbound: { + [EURE.assetId]: '100000000000000000000', + }, + }, + }; + }, + async loadLatestMarketPrice() { + return { + ingested_at: nowIso, + payload: { + observed_at: priceObservedAt, + eure_per_btc: '50000', + }, + }; + }, + async insertPreflight(payload) { + preflights.push(payload); + }, + async findPreflight({ requestId, idempotencyKey }) { + return preflights.find((entry) => ( + (requestId && entry.request_id === requestId) + || (idempotencyKey && entry.idempotency_key === idempotencyKey) + )) || null; + }, + async findSubmissionByRequest({ requestId }) { + return [...submissions].reverse().find((entry) => entry.request_id === requestId) || null; + }, + async insertSubmissionResult(payload) { + submissions.push(payload); + }, + async refreshOutcomes() { + return []; + }, + }; +} + +function buildRelay() { + return { + quoteCalls: 0, + publishCalls: 0, + async quote() { + this.quoteCalls += 1; + return [{ + quote_hash: 'quote-hash-1', + amount_out: '10000', + expiration_time: '2026-04-12T10:01:00.000Z', + }]; + }, + async publishIntent() { + this.publishCalls += 1; + return { status: 'OK', intent_hash: 'intent-hash-1' }; + }, + async getStatus() { + return { status: 'PENDING' }; + }, + }; +} + +function buildController({ store = buildStore(), relay = buildRelay(), armed = true, verifierRegistered = true } = {}) { + return { + store, + relay, + controller: createIntentRequestController({ + config: buildConfig(), + store, + relayRpcClient: relay, + verifierClient: { + async isPublicKeyRegistered() { return verifierRegistered; }, + async currentSalt() { return '252812b3'; }, + }, + signer: KeyPair.fromRandom('ed25519'), + isArmed: () => armed, + isPaused: () => false, + now: () => Date.parse('2026-04-12T10:00:00.000Z'), + uuid: (() => { + let next = 1; + return () => `id-${next++}`; + })(), + }), + }; +} + +test('EURe decimal parsing, BTC expected receive, and slippage math are exact enough for request limits', () => { + const sourceUnits = parseDecimalToUnits('5', EURE.decimals, { field: 'amount_eure' }); + const expectedBtc = computeBtcReceiveUnitsFromEure({ + eureUnits: sourceUnits, + eurPerBtc: '50000', + eureDecimals: EURE.decimals, + btcDecimals: BTC.decimals, + }); + + assert.equal(sourceUnits, '5000000000000000000'); + assert.equal(expectedBtc, '10000'); + assert.equal(applySlippageBps(expectedBtc, 200), '9800'); +}); + +test('solver quote normalization selects the best quote above explicit min receive', () => { + const quotes = normalizeSolverQuotes({ + quotes: [ + { quote_hash: 'low', amount_out: '9700' }, + { quote_hash: 'best', amount_out: '10050' }, + { quote_hash: 'ok', amount_out: '9900' }, + ], + }); + const selected = selectBestSolverQuote(quotes, { minDestinationAmountUnits: '9800' }); + + assert.equal(selected.quote_hash, 'best'); + assert.deepEqual(buildSolverQuoteRequest({ + sourceAssetId: EURE.assetId, + destinationAssetId: BTC.assetId, + sourceAmountUnits: '5000000000000000000', + minDeadlineMs: 60_000, + }), { + defuse_asset_identifier_in: EURE.assetId, + defuse_asset_identifier_out: BTC.assetId, + exact_amount_in: '5000000000000000000', + min_deadline_ms: 60000, + }); +}); + +test('preflight is side-effect-free and does not publish a live intent', async () => { + const { controller, relay } = buildController(); + const preflight = await controller.preflight({ amount_eure: '5', slippage_bps: 200 }); + + assert.equal(preflight.state, 'draft'); + assert.equal(preflight.live_submit_capable, true); + assert.equal(preflight.reason_code, 'quote_available'); + assert.equal(relay.quoteCalls, 1); + assert.equal(relay.publishCalls, 0); +}); + +test('insufficient spendable EURe blocks before solver quote or signing', async () => { + const store = buildStore({ inventoryUnits: '0' }); + const relay = buildRelay(); + const { controller } = buildController({ store, relay }); + const preflight = await controller.preflight({ amount_eure: '5', slippage_bps: 200 }); + + assert.equal(preflight.state, 'blocked'); + assert.equal(preflight.reason_code, 'insufficient_spendable_eure'); + assert.equal(preflight.inventory_snapshot.pending_inbound[EURE.assetId], '100000000000000000000'); + assert.equal(relay.quoteCalls, 0); + assert.equal(relay.publishCalls, 0); +}); + +test('duplicate request submit returns stored result and never publishes twice', async () => { + const { controller, store, relay } = buildController(); + const preflight = await controller.preflight({ amount_eure: '5', slippage_bps: 200 }); + const first = await controller.submit({ request_id: preflight.request_id }); + const second = await controller.submit({ request_id: preflight.request_id }); + + assert.equal(first.submission_result.status, 'accepted_by_relay'); + assert.equal(second.duplicate, true); + assert.equal(relay.publishCalls, 1); + assert.equal(store.submissions.filter((entry) => entry.status === 'submit_requested').length, 1); + assert.equal(store.submissions.filter((entry) => entry.status === 'accepted_by_relay').length, 1); +}); + +test('executor disarmed blocks request submission without calling relay publish', async () => { + const { controller, relay } = buildController({ armed: false }); + const preflight = await controller.preflight({ amount_eure: '5', slippage_bps: 200 }); + const result = await controller.submit({ request_id: preflight.request_id }); + + assert.equal(result.submission_result.status, 'blocked'); + assert.equal(result.submission_result.result_code, 'executor_disarmed'); + assert.equal(relay.publishCalls, 0); +}); + + +test('stale reference price blocks request preflight before solver quote', async () => { + const store = buildStore({ priceObservedAt: '2026-04-12T09:59:00.000Z' }); + const relay = buildRelay(); + const { controller } = buildController({ store, relay }); + const preflight = await controller.preflight({ amount_eure: '5', slippage_bps: 200 }); + + assert.equal(preflight.state, 'blocked'); + assert.equal(preflight.reason_code, 'stale_reference_price'); + assert.equal(relay.quoteCalls, 0); + assert.equal(relay.publishCalls, 0); +}); + +test('unregistered signer blocks request preflight before solver quote or signing', async () => { + const relay = buildRelay(); + const { controller } = buildController({ relay, verifierRegistered: false }); + const preflight = await controller.preflight({ amount_eure: '5', slippage_bps: 200 }); + + assert.equal(preflight.state, 'blocked'); + assert.equal(preflight.reason_code, 'signer_not_registered'); + assert.equal(relay.quoteCalls, 0); + assert.equal(relay.publishCalls, 0); +}); + +test('relay publish failure records submit_requested first and never reports completion', async () => { + const relay = buildRelay(); + relay.publishIntent = async function publishIntent() { + this.publishCalls += 1; + throw new Error('relay publish unavailable'); + }; + const { controller, store } = buildController({ relay }); + const preflight = await controller.preflight({ amount_eure: '5', slippage_bps: 200 }); + const result = await controller.submit({ request_id: preflight.request_id }); + + assert.equal(result.submission_result.status, 'failed'); + assert.equal(result.submission_result.result_code, 'publish_intent_failed'); + assert.equal(result.submission_result.result_text, 'relay publish unavailable'); + assert.equal(relay.publishCalls, 1); + assert.equal(store.submissions[0].status, 'submit_requested'); + assert.equal(store.submissions[1].status, 'failed'); + assert.notEqual(result.submission_result.status, 'completed'); +}); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index e08a73c..66c5726 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -117,6 +117,14 @@ test('control routing only resolves the allowlisted safe dashboard actions', () service: 'trade-executor', action: 'resume', }); + const requestPreflight = resolveDashboardControl({ + service: 'trade-executor', + action: 'intent-request-preflight', + }); + const requestSubmit = resolveDashboardControl({ + service: 'trade-executor', + action: 'intent-request-submit', + }); const risky = resolveDashboardControl({ service: 'strategy-engine', action: 'arm', @@ -128,6 +136,10 @@ test('control routing only resolves the allowlisted safe dashboard actions', () assert.equal(armExecutor?.label, 'Arm Executor'); assert.equal(resumeExecutor?.path, '/resume'); assert.equal(resumeExecutor?.label, 'Resume Executor Intake'); + assert.equal(requestPreflight?.path, '/intent-request/preflight'); + assert.equal(requestPreflight?.risk_class, 'safe'); + assert.equal(requestSubmit?.path, '/intent-request/submit'); + assert.equal(requestSubmit?.risk_class, 'live_funds'); assert.equal(risky, null); }); @@ -1190,3 +1202,81 @@ test('bootstrap lifecycle rows preserve quote terms, submitted terms, and gross assert.equal(row.submitted_terms.asset_out_symbol, 'EURe'); assert.equal(row.gross_edge_value_eure, '1.5'); }); + + +test('own request dashboard rows do not label relay accepted evidence as completed trade', () => { + const config = buildConfig(); + const bootstrap = buildDashboardBootstrap({ + config, + auth: { authenticated: true, subject: 'local-operator', mode: 'stub', roles: ['operator'] }, + portfolioMetric: null, + inventorySnapshot: null, + marketPrice: null, + recentQuotes: [], + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [] }, + submissionSummary: { total: 0, last_submission_at: null }, + fundingObservations: [], + recentDepositStatuses: [], + recentTradeDecisions: [], + recentExecuteTradeCommands: [], + recentExecutionResults: [], + recentQuoteOutcomes: [], + recentIntentRequests: [{ + request_id: 'request-accepted-only', + idempotency_key: 'idem-accepted-only', + submission_id: 'submission-accepted-only', + intent_hash: 'intent-hash-accepted-only', + quote_hash: 'quote-hash-accepted-only', + created_at: '2026-04-12T10:00:00.000Z', + submitted_at: '2026-04-12T10:00:01.000Z', + state: 'awaiting_settlement', + state_label: 'Awaiting settlement', + reason_code: 'accepted_by_relay_without_settlement', + reason_text: 'Relay accepted the signed request. This is not settlement.', + source_asset_id: config.tradingEure.assetId, + source_symbol: 'EURe', + source_decimals: 18, + destination_asset_id: config.tradingBtc.assetId, + destination_symbol: 'BTC', + destination_decimals: 8, + source_amount_units: '5000000000000000000', + expected_destination_amount_units: '10000', + min_destination_amount_units: '9800', + quoted_destination_amount_units: '10000', + slippage_bps: 200, + submission_status: 'accepted_by_relay', + relay_status: 'PENDING', + outcome_status: 'awaiting_settlement', + attribution_status: 'unattributed', + attribution_method: null, + attributed_inventory_delta: null, + has_settlement_evidence: false, + live_submit_capable: false, + lifecycle: { + preflight: { state: 'draft' }, + submission: { status: 'accepted_by_relay' }, + outcome: { outcome_status: 'awaiting_settlement' }, + }, + }], + recentAlertTransitions: [], + serviceSnapshots: [{ + service: 'trade-executor', + label: 'Trade Executor', + base_url: 'http://trade-executor', + reachable: true, + health: { ok: true }, + state: { armed: true, paused: false }, + }], + }); + + const row = bootstrap.funds.intent_requests.items[0]; + assert.equal(row.state, 'awaiting_settlement'); + assert.equal(row.state_label, 'Awaiting settlement'); + assert.equal(row.submission_status, 'accepted_by_relay'); + assert.equal(row.has_settlement_evidence, false); + assert.equal(row.settlement_summary.text, 'No settled inventory delta is linked to this request.'); + assert.doesNotMatch( + [row.state_label, row.reason_text, row.settlement_summary.text].join(' '), + /successful trade|completed trade|asset delta/i, + ); +}); diff --git a/test/signing.test.mjs b/test/signing.test.mjs index b7ddd15..4c3ff52 100644 --- a/test/signing.test.mjs +++ b/test/signing.test.mjs @@ -3,7 +3,11 @@ import assert from 'node:assert/strict'; import { KeyPair } from 'near-api-js'; -import { buildIntentNonce, buildQuoteResponseSubmission } from '../src/venues/near-intents/signing.mjs'; +import { + buildIntentNonce, + buildIntentRequestSubmission, + buildQuoteResponseSubmission, +} from '../src/venues/near-intents/signing.mjs'; test('intent nonce uses verifier salt prefix and 32 byte base64 payload', () => { const nonce = buildIntentNonce('252812b3'); @@ -42,3 +46,49 @@ test('quote response signing builds token_diff payload for solver submission', ( assert.equal(payload.intents[0].diff['nep141:btc.omft.near'], '5000'); assert.equal(payload.intents[0].diff['nep141:eure.omft.near'], '-4900000000000000000'); }); + + +test('request signing builds taker token_diff payload for EURe to BTC submission', () => { + const signer = { + getPublicKey() { + return { toString: () => 'ed25519:test-public-key' }; + }, + sign() { + return { signature: Uint8Array.from({ length: 64 }, () => 7) }; + }, + }; + const submission = buildIntentRequestSubmission({ + request: { + request_id: 'request-1', + source_asset_id: 'nep141:eure.omft.near', + destination_asset_id: 'nep141:btc.omft.near', + source_amount_units: '5000000000000000000', + selected_quote: { + quote_hash: 'quote-hash-1', + amount_out: '10000', + }, + deadline_at: '2026-04-12T10:01:00.000Z', + }, + signerAccountId: 'solver.near', + signer, + verifierContract: 'intents.near', + currentSaltHex: '252812b3', + nonce: 'fixed-nonce', + }); + + assert.deepEqual(submission.quote_hashes, ['quote-hash-1']); + assert.equal(submission.destination_amount_units, '10000'); + assert.equal(submission.nonce, 'fixed-nonce'); + assert.equal(submission.signed_data.standard, 'raw_ed25519'); + assert.equal(submission.signed_data.public_key, 'ed25519:test-public-key'); + assert.match(submission.signed_data.signature, /^ed25519:/); + + const payload = JSON.parse(submission.signed_data.payload); + assert.equal(payload.signer_id, 'solver.near'); + assert.equal(payload.verifying_contract, 'intents.near'); + assert.equal(payload.deadline, '2026-04-12T10:01:00.000Z'); + assert.equal(payload.nonce, 'fixed-nonce'); + assert.equal(payload.intents[0].intent, 'token_diff'); + assert.equal(payload.intents[0].diff['nep141:eure.omft.near'], '-5000000000000000000'); + assert.equal(payload.intents[0].diff['nep141:btc.omft.near'], '10000'); +});