From c2675df1418cdcd562df21ca45e5c32561e3b267 Mon Sep 17 00:00:00 2001 From: philipp Date: Mon, 18 May 2026 22:54:03 +0200 Subject: [PATCH] Cache verifier salt on executor hot path Proof: targeted verifier salt cache and executor/dashboard tests pass; npm test passes 244/244; operator dashboard bundle builds; git diff --check passes. Assumptions: a 500 ms verifier salt freshness bound with 250 ms background prefetch is conservative for quote-response signing, and stale or malformed salt must block signing instead of using cached data. Still fake: venue-native terminal fill ids and fee-complete realized PnL remain unavailable. --- src/apps/trade-executor.mjs | 15 +- src/core/verifier-salt-cache.mjs | 143 ++++++++++++++++++ .../static/pages/StrategyPage.jsx | 7 +- test/operator-dashboard-ui-static.test.mjs | 2 + test/trade-executor-static.test.mjs | 11 ++ test/verifier-salt-cache.test.mjs | 82 ++++++++++ 6 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 src/core/verifier-salt-cache.mjs create mode 100644 test/verifier-salt-cache.test.mjs diff --git a/src/apps/trade-executor.mjs b/src/apps/trade-executor.mjs index b4f1dcb..15e89e6 100644 --- a/src/apps/trade-executor.mjs +++ b/src/apps/trade-executor.mjs @@ -16,6 +16,7 @@ import { assertIntentRequestSubmissionResultEvent, assertTradeResult, } from '../core/schemas.mjs'; +import { createVerifierSaltCache } from '../core/verifier-salt-cache.mjs'; import { loadConfig } from '../lib/config.mjs'; import { createPostgresPool, @@ -74,6 +75,11 @@ const verifierClient = createVerifierClient({ verifierContract: config.nearVerifierContract, signerPrivateKey: config.nearIntentsSignerPrivateKey, }); +const verifierSaltCache = createVerifierSaltCache({ + loadSalt: () => verifierClient.currentSalt(), + logger: logger.child({ component: 'verifier-salt-cache' }), +}); +verifierSaltCache.start(); const signer = verifierClient.getSigner(); const solverRelayRpcClient = createSolverRelayRpcClient({ rpcUrl: config.nearIntentsRpcUrl, @@ -247,7 +253,10 @@ async function handleCommand(event) { const saltStartMs = performance.now(); let currentSaltHex; try { - currentSaltHex = await verifierClient.currentSalt(); + const salt = await verifierSaltCache.getFreshSalt(); + currentSaltHex = salt.currentSaltHex; + timing.current_salt_source = salt.source; + timing.current_salt_age_ms = roundTimingMs(salt.ageMs); } finally { recordExecutorTiming(timing, 'current_salt_ms', saltStartMs); } @@ -343,6 +352,8 @@ function finishExecutorTiming(timing) { received_at: timing.received_at, command_event_age_ms: timing.command_event_age_ms, current_salt_ms: timing.current_salt_ms ?? null, + current_salt_source: timing.current_salt_source ?? null, + current_salt_age_ms: timing.current_salt_age_ms ?? null, sign_ms: timing.sign_ms ?? null, relay_response_ms: timing.relay_response_ms ?? null, executor_total_ms: roundTimingMs(performance.now() - timing.started_at_ms), @@ -395,6 +406,7 @@ const controlApi = startControlApi({ signer_public_key: signer.getPublicKey().toString(), signer_registered: signerRegistered, relay: relayClient.getState(), + verifier_salt_cache: verifierSaltCache.getState(), trading_config: tradingConfigStore.getState(), ...state, durable_control_state: armedStateStore.getState(), @@ -589,6 +601,7 @@ function createIntentRequestStore() { async function shutdown() { await controlApi.close().catch(() => {}); + verifierSaltCache.stop(); relayClient.close(); await consumer.disconnect(); await producer.disconnect(); diff --git a/src/core/verifier-salt-cache.mjs b/src/core/verifier-salt-cache.mjs new file mode 100644 index 0000000..2de7e87 --- /dev/null +++ b/src/core/verifier-salt-cache.mjs @@ -0,0 +1,143 @@ +const DEFAULT_MAX_AGE_MS = 500; +const DEFAULT_REFRESH_INTERVAL_MS = 250; +const ERROR_LOG_SUPPRESSION_MS = 30_000; + +export function createVerifierSaltCache({ + loadSalt, + maxAgeMs = DEFAULT_MAX_AGE_MS, + refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, + now = () => Date.now(), + setIntervalFn = setInterval, + clearIntervalFn = clearInterval, + logger = null, +} = {}) { + if (typeof loadSalt !== 'function') { + throw new Error('verifier salt cache requires loadSalt'); + } + + let cached = null; + let refreshInFlight = null; + let timer = null; + let lastRefreshError = null; + let lastRefreshErrorLoggedAtMs = 0; + + async function refresh({ reason = 'manual' } = {}) { + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = Promise.resolve() + .then(() => loadSalt()) + .then((salt) => { + cached = { + currentSaltHex: normalizeSalt(salt), + refreshedAtMs: now(), + refreshedReason: reason, + }; + lastRefreshError = null; + return buildSaltResult(cached, 'refresh'); + }) + .catch((error) => { + logRefreshError(error, reason); + lastRefreshError = serializeSaltError(error); + throw error; + }) + .finally(() => { + refreshInFlight = null; + }); + + return refreshInFlight; + } + + async function getFreshSalt() { + if (isFresh(cached)) return buildSaltResult(cached, 'cache'); + + await refresh({ reason: cached ? 'stale_cache' : 'empty_cache' }); + if (!isFresh(cached)) { + throw new Error('verifier salt cache refresh did not produce a fresh salt'); + } + return buildSaltResult(cached, 'refresh'); + } + + function start() { + if (timer) return; + void refresh({ reason: 'startup' }).catch(() => {}); + timer = setIntervalFn(() => { + void refresh({ reason: 'prefetch' }).catch(() => {}); + }, refreshIntervalMs); + timer?.unref?.(); + } + + function stop() { + if (!timer) return; + clearIntervalFn(timer); + timer = null; + } + + function getState() { + const ageMs = cached ? now() - cached.refreshedAtMs : null; + return { + configured: true, + has_cached_salt: Boolean(cached), + fresh: isFresh(cached), + max_age_ms: maxAgeMs, + refresh_interval_ms: refreshIntervalMs, + cached_age_ms: ageMs == null ? null : Math.max(0, Math.round(ageMs)), + refreshed_reason: cached?.refreshedReason || null, + refresh_in_flight: Boolean(refreshInFlight), + last_refresh_error: lastRefreshError, + }; + } + + function isFresh(entry) { + if (!entry) return false; + const ageMs = now() - entry.refreshedAtMs; + return ageMs >= 0 && ageMs <= maxAgeMs; + } + + function buildSaltResult(entry, source) { + return { + currentSaltHex: entry.currentSaltHex, + source, + ageMs: Math.max(0, now() - entry.refreshedAtMs), + refreshedReason: entry.refreshedReason, + }; + } + + function logRefreshError(error, reason) { + if (!logger?.warn) return; + const serialized = serializeSaltError(error); + const currentTimeMs = now(); + const shouldLog = serialized.message !== lastRefreshError?.message + || currentTimeMs - lastRefreshErrorLoggedAtMs >= ERROR_LOG_SUPPRESSION_MS; + if (!shouldLog) return; + lastRefreshErrorLoggedAtMs = currentTimeMs; + logger.warn('verifier_salt_refresh_failed', { + details: { + reason, + error: serialized, + }, + }); + } + + return { + getFreshSalt, + getState, + refresh, + start, + stop, + }; +} + +function normalizeSalt(value) { + const salt = String(value || '').replace(/^0x/, ''); + if (!/^[0-9a-fA-F]{8}$/.test(salt)) { + throw new Error('current_salt must be 4 bytes in hex'); + } + return salt.toLowerCase(); +} + +function serializeSaltError(error) { + return { + name: error?.name || 'Error', + message: error?.message || String(error), + }; +} diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index bc9de80..81207dc 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -42,10 +42,12 @@ function formatTimingMs(value) { function formatExecutionTiming(timing) { if (!timing) return null; + const saltMs = formatTimingMs(timing.current_salt_ms); + const saltAgeMs = formatTimingMs(timing.current_salt_age_ms); + const saltSource = timing.current_salt_source ? ` ${plainCodeLabel(timing.current_salt_source).toLowerCase()}` : ''; const parts = [ ['total', timing.executor_total_ms], ['cmd age', timing.command_event_age_ms], - ['salt', timing.current_salt_ms], ['sign', timing.sign_ms], ['relay', timing.relay_response_ms], ] @@ -54,6 +56,9 @@ function formatExecutionTiming(timing) { return formatted ? `${label} ${formatted}` : null; }) .filter(Boolean); + if (saltMs) { + parts.splice(2, 0, `salt ${saltMs}${saltSource}${saltAgeMs ? ` age ${saltAgeMs}` : ''}`); + } return parts.length ? `Timing: ${parts.join(', ')}` : null; } diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index 91d3515..af5e7b4 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -32,6 +32,8 @@ test('strategy page owns consolidated quote lifecycle and successful trade table assert.match(strategySource, /formatExecutionTiming/); assert.match(strategySource, /item\.execution\?\.timing/); assert.match(strategySource, /current_salt_ms/); + assert.match(strategySource, /current_salt_source/); + assert.match(strategySource, /current_salt_age_ms/); assert.match(strategySource, /relay_response_ms/); assert.match(strategySource, /Timing:/); assert.match(strategySource, /item\.execution\?\.status === 'submitted'/); diff --git a/test/trade-executor-static.test.mjs b/test/trade-executor-static.test.mjs index 812d1e6..e865e86 100644 --- a/test/trade-executor-static.test.mjs +++ b/test/trade-executor-static.test.mjs @@ -36,3 +36,14 @@ test('trade executor records hot path timing in result payloads', () => { assert.match(source, /executor_total_ms/); assert.match(source, /withExecutorTiming\(\{[\s\S]*?result_code: 'submission_failed'/); }); + +test('trade executor uses bounded verifier salt cache instead of per-command salt RPC', () => { + assert.match(source, /createVerifierSaltCache/); + assert.match(source, /verifierSaltCache\.start\(\)/); + assert.match(source, /verifierSaltCache\.getFreshSalt\(\)/); + assert.match(source, /current_salt_source/); + assert.match(source, /current_salt_age_ms/); + assert.match(source, /verifier_salt_cache: verifierSaltCache\.getState\(\)/); + assert.match(source, /verifierSaltCache\.stop\(\)/); + assert.doesNotMatch(source, /await verifierClient\.currentSalt\(\);/); +}); diff --git a/test/verifier-salt-cache.test.mjs b/test/verifier-salt-cache.test.mjs new file mode 100644 index 0000000..b9b7f26 --- /dev/null +++ b/test/verifier-salt-cache.test.mjs @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createVerifierSaltCache } from '../src/core/verifier-salt-cache.mjs'; + +test('verifier salt cache reuses a fresh salt without calling the verifier hot path', async () => { + let nowMs = 1_000; + let calls = 0; + const cache = createVerifierSaltCache({ + loadSalt: async () => { + calls += 1; + return '252812b3'; + }, + maxAgeMs: 500, + now: () => nowMs, + }); + + const refreshed = await cache.getFreshSalt(); + nowMs += 100; + const cached = await cache.getFreshSalt(); + + assert.equal(calls, 1); + assert.equal(refreshed.source, 'refresh'); + assert.equal(cached.source, 'cache'); + assert.equal(cached.currentSaltHex, '252812b3'); + assert.equal(cached.ageMs, 100); +}); + +test('verifier salt cache refreshes a stale salt before returning it', async () => { + let nowMs = 1_000; + const salts = ['252812b3', '252812b4']; + const cache = createVerifierSaltCache({ + loadSalt: async () => salts.shift(), + maxAgeMs: 500, + now: () => nowMs, + }); + + const first = await cache.getFreshSalt(); + nowMs += 501; + const second = await cache.getFreshSalt(); + + assert.equal(first.currentSaltHex, '252812b3'); + assert.equal(second.currentSaltHex, '252812b4'); + assert.equal(second.source, 'refresh'); + assert.equal(second.ageMs, 0); +}); + +test('verifier salt cache fails closed when stale salt refresh fails', async () => { + let nowMs = 1_000; + let fail = false; + const cache = createVerifierSaltCache({ + loadSalt: async () => { + if (fail) throw new Error('rpc unavailable'); + return '252812b3'; + }, + maxAgeMs: 500, + now: () => nowMs, + }); + + await cache.getFreshSalt(); + nowMs += 501; + fail = true; + + await assert.rejects( + () => cache.getFreshSalt(), + /rpc unavailable/, + ); + assert.equal(cache.getState().fresh, false); + assert.equal(cache.getState().last_refresh_error.message, 'rpc unavailable'); +}); + +test('verifier salt cache rejects malformed salts before signing can use them', async () => { + const cache = createVerifierSaltCache({ + loadSalt: async () => 'not-a-salt', + }); + + await assert.rejects( + () => cache.getFreshSalt(), + /current_salt must be 4 bytes in hex/, + ); + assert.equal(cache.getState().has_cached_salt, false); +});