Implement NEAR Intents request creation flow
All checks were successful
deploy / deploy (push) Successful in 33s
All checks were successful
deploy / deploy (push) Successful in 33s
Proof: Adds repo-owned EURe-to-BTC request preflight, signing, gated live submission, durable request/result/outcome persistence, dashboard request lifecycle rows, and tests proving submitted/relay accepted are not completed without inventory movement. Assumptions: The NEAR Intents solver relay quote, publish_intent, and get_status JSON-RPC methods accept signed raw_ed25519 token_diff payloads with quote_hashes; live validation remains bounded to 5 EUR per attempt, at most five attempts, and 200 bps slippage. Still fake: Venue-native terminal fill linkage and fee-complete realized PnL are still unavailable; request completion is attributed from durable inventory deltas unless the venue later exposes a linked settlement id.
This commit is contained in:
parent
55ece8f5f0
commit
f34f27065a
20 changed files with 2781 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
402
src/core/intent-request-controller.mjs
Normal file
402
src/core/intent-request-controller.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
459
src/core/intent-request-outcomes.mjs
Normal file
459
src/core/intent-request-outcomes.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
142
src/core/intent-requests.mjs
Normal file
142
src/core/intent-requests.mjs
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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}.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <div className="status-subtle">{`${label}: unavailable`}</div>;
|
||||
return (
|
||||
<div className="trace-row">
|
||||
<span className="status-subtle">{`${label}:`}</span>
|
||||
<span className="mono trace-id">{value}</span>
|
||||
<button className="button secondary trace-copy-button" onClick={() => copyIdentifier(value)} type="button">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<form onSubmit={handlePreflight}>
|
||||
<div className="form-grid">
|
||||
<div className="field">
|
||||
<label htmlFor="intent-request-amount">Spend EURe</label>
|
||||
<input
|
||||
id="intent-request-amount"
|
||||
max={defaults.max_amount_eure || '5'}
|
||||
min="0.01"
|
||||
name="amount_eure"
|
||||
onChange={(event) => setForm((current) => ({ ...current, amount_eure: event.target.value }))}
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={form.amount_eure}
|
||||
/>
|
||||
<div className="status-subtle">{`Max ${defaults.max_amount_eure || '5'} EURe per live test request`}</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="intent-request-slippage">Max slippage bps</label>
|
||||
<input
|
||||
id="intent-request-slippage"
|
||||
max={defaults.max_slippage_bps ?? 200}
|
||||
min="0"
|
||||
name="slippage_bps"
|
||||
onChange={(event) => setForm((current) => ({ ...current, slippage_bps: event.target.value }))}
|
||||
step="1"
|
||||
type="number"
|
||||
value={form.slippage_bps}
|
||||
/>
|
||||
<div className="status-subtle">{`Max ${defaults.max_slippage_bps ?? 200} bps / 2%`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button className="button" type="submit">Preflight EURe to BTC</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => onControl('trade-executor', 'intent-request-refresh-outcomes')}
|
||||
type="button"
|
||||
>
|
||||
Refresh Request Outcomes
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-subtitle">Preflight asks solvers for a quote. It does not submit live funds.</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function IntentRequestLifecycle({ item }) {
|
||||
return (
|
||||
<div className="lifecycle-detail-panel">
|
||||
<div className="lifecycle-stage-grid">
|
||||
<div className="lifecycle-stage-card">
|
||||
<div className="stage-title">1. Request preflight</div>
|
||||
<div className="stage-meta">{formatTimestamp(item.created_at)}</div>
|
||||
<div className="stage-status">{item.lifecycle?.preflight?.state || item.state}</div>
|
||||
<div className="stage-body">
|
||||
<div>{item.reason_text}</div>
|
||||
<div className="status-subtle mono">{item.reason_code}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lifecycle-stage-card">
|
||||
<div className="stage-title">2. Solver quote</div>
|
||||
<div className="stage-meta">{formatTimestamp(item.deadline_at)}</div>
|
||||
<div className="stage-status">{`${item.solver_quote_count || 0} quote(s)`}</div>
|
||||
<div className="stage-body">
|
||||
<IdentifierLine label="Quote hash" value={item.quote_hash} />
|
||||
<div className="status-subtle">{item.quoted_destination_amount ? `Quoted ${item.quoted_destination_amount} BTC` : 'No usable quote stored'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lifecycle-stage-card">
|
||||
<div className="stage-title">3. Relay submission</div>
|
||||
<div className="stage-meta">{formatTimestamp(item.submitted_at)}</div>
|
||||
<div className="stage-status">{item.submission_status || 'Not submitted'}</div>
|
||||
<div className="stage-body">
|
||||
<IdentifierLine label="Submission" value={item.submission_id} />
|
||||
<IdentifierLine label="Intent hash" value={item.intent_hash} />
|
||||
<div className="status-subtle">Relay acceptance is not a completed trade.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lifecycle-stage-card">
|
||||
<div className="stage-title">4. Settlement truth</div>
|
||||
<div className="stage-meta">{formatTimestamp(item.resolved_at)}</div>
|
||||
<div className="stage-status">{item.outcome_status || item.state}</div>
|
||||
<div className="stage-body">
|
||||
<div>{item.settlement_summary?.text || 'No settled inventory delta is linked to this request.'}</div>
|
||||
{item.settlement_summary?.caveat ? <div className="status-subtle">{item.settlement_summary.caveat}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="trace-block">
|
||||
<IdentifierLine label="Request" value={item.request_id} />
|
||||
<IdentifierLine label="Idempotency" value={item.idempotency_key} />
|
||||
<IdentifierLine label="Intent" value={item.intent_hash} />
|
||||
<IdentifierLine label="Quote hash" value={item.quote_hash} />
|
||||
</div>
|
||||
<details className="raw-details">
|
||||
<summary>Raw lifecycle evidence</summary>
|
||||
<pre>{stringifyJson(item.lifecycle)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntentRequestsTable({ items, executorArmed, onControl }) {
|
||||
const [expanded, setExpanded] = useState(() => new Set());
|
||||
if (!items?.length) return <EmptyState>No repo-created EURe-to-BTC requests are stored yet.</EmptyState>;
|
||||
|
||||
function toggle(rowKey) {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(rowKey)) next.delete(rowKey);
|
||||
else next.add(rowKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TableFrame>
|
||||
<table className="intent-requests-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Request id</th>
|
||||
<th>Spend / min receive</th>
|
||||
<th>Quote / intent</th>
|
||||
<th>State</th>
|
||||
<th>Reason</th>
|
||||
<th>Settlement</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<Fragment key={rowKey}>
|
||||
<tr>
|
||||
<td>{formatTimestamp(item.resolved_at || item.submitted_at || item.created_at)}</td>
|
||||
<td><IdentifierLine label="Request" value={item.request_id} /></td>
|
||||
<td>
|
||||
<div className="mono">{`${item.source_amount} ${item.source_symbol || 'EURe'}`}</div>
|
||||
<div className="status-subtle">{`Min ${item.min_destination_amount} ${item.destination_symbol || 'BTC'}`}</div>
|
||||
<div className="status-subtle">{`${item.slippage_bps ?? 'n/a'} bps slippage`}</div>
|
||||
</td>
|
||||
<td>
|
||||
<IdentifierLine label="Quote" value={item.quote_hash} />
|
||||
<IdentifierLine label="Intent" value={item.intent_hash} />
|
||||
</td>
|
||||
<td><Pill label={item.state_label || item.state} stateLabel={item.state} /></td>
|
||||
<td>
|
||||
<div>{item.reason_text}</div>
|
||||
<div className="status-subtle mono">{item.reason_code}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{item.settlement_summary?.text || 'No settled inventory delta linked'}</div>
|
||||
<div className="status-subtle">{item.has_settlement_evidence ? 'Settlement evidence present' : 'Not completed'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="button-row compact">
|
||||
<button className="button secondary" onClick={() => toggle(rowKey)} type="button">
|
||||
{isExpanded ? 'Hide lifecycle' : 'Show lifecycle'}
|
||||
</button>
|
||||
{item.live_submit_capable ? (
|
||||
<button
|
||||
className="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => onControl('trade-executor', 'intent-request-submit', { request_id: item.request_id })}
|
||||
type="button"
|
||||
>
|
||||
Submit live
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{item.live_submit_capable && executorArmed !== true ? (
|
||||
<div className="status-subtle">Executor must be armed before live submit.</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded ? (
|
||||
<tr className="lifecycle-expanded-row">
|
||||
<td colSpan={8}><IntentRequestLifecycle item={item} /></td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function LastControlResult({ result }) {
|
||||
if (!result) return null;
|
||||
|
||||
|
|
@ -425,6 +664,26 @@ export default function FundsPage({
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel full-width-evidence-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Own requests</div>
|
||||
<h3>EURe to BTC request creation</h3>
|
||||
<div className="panel-subtitle">Create a solver quote request first, then submit only a drafted request. Completed requires inventory movement, not relay acceptance.</div>
|
||||
</div>
|
||||
<div className="pills">
|
||||
<Pill label={`Executor ${funds.intent_requests?.executor_armed ? 'Armed' : 'Disarmed'}`} stateLabel={funds.intent_requests?.executor_armed ? 'healthy' : 'warning'} />
|
||||
</div>
|
||||
</div>
|
||||
<IntentRequestForm onControl={onControl} summary={funds.intent_requests} />
|
||||
<div className="panel-subtitle">{funds.intent_requests?.caveat}</div>
|
||||
<IntentRequestsTable
|
||||
executorArmed={funds.intent_requests?.executor_armed}
|
||||
items={funds.intent_requests?.items || []}
|
||||
onControl={onControl}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="stack-grid">
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
161
test/intent-request-outcomes.test.mjs
Normal file
161
test/intent-request-outcomes.test.mjs
Normal file
|
|
@ -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');
|
||||
});
|
||||
275
test/intent-requests.test.mjs
Normal file
275
test/intent-requests.test.mjs
Normal file
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue