Cache verifier salt on executor hot path
All checks were successful
deploy / deploy (push) Successful in 46s
All checks were successful
deploy / deploy (push) Successful in 46s
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.
This commit is contained in:
parent
a4db57182c
commit
c2675df141
6 changed files with 258 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
143
src/core/verifier-salt-cache.mjs
Normal file
143
src/core/verifier-salt-cache.mjs
Normal file
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'/);
|
||||
|
|
|
|||
|
|
@ -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\(\);/);
|
||||
});
|
||||
|
|
|
|||
82
test/verifier-salt-cache.test.mjs
Normal file
82
test/verifier-salt-cache.test.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue