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,
|
assertIntentRequestSubmissionResultEvent,
|
||||||
assertTradeResult,
|
assertTradeResult,
|
||||||
} from '../core/schemas.mjs';
|
} from '../core/schemas.mjs';
|
||||||
|
import { createVerifierSaltCache } from '../core/verifier-salt-cache.mjs';
|
||||||
import { loadConfig } from '../lib/config.mjs';
|
import { loadConfig } from '../lib/config.mjs';
|
||||||
import {
|
import {
|
||||||
createPostgresPool,
|
createPostgresPool,
|
||||||
|
|
@ -74,6 +75,11 @@ const verifierClient = createVerifierClient({
|
||||||
verifierContract: config.nearVerifierContract,
|
verifierContract: config.nearVerifierContract,
|
||||||
signerPrivateKey: config.nearIntentsSignerPrivateKey,
|
signerPrivateKey: config.nearIntentsSignerPrivateKey,
|
||||||
});
|
});
|
||||||
|
const verifierSaltCache = createVerifierSaltCache({
|
||||||
|
loadSalt: () => verifierClient.currentSalt(),
|
||||||
|
logger: logger.child({ component: 'verifier-salt-cache' }),
|
||||||
|
});
|
||||||
|
verifierSaltCache.start();
|
||||||
const signer = verifierClient.getSigner();
|
const signer = verifierClient.getSigner();
|
||||||
const solverRelayRpcClient = createSolverRelayRpcClient({
|
const solverRelayRpcClient = createSolverRelayRpcClient({
|
||||||
rpcUrl: config.nearIntentsRpcUrl,
|
rpcUrl: config.nearIntentsRpcUrl,
|
||||||
|
|
@ -247,7 +253,10 @@ async function handleCommand(event) {
|
||||||
const saltStartMs = performance.now();
|
const saltStartMs = performance.now();
|
||||||
let currentSaltHex;
|
let currentSaltHex;
|
||||||
try {
|
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 {
|
} finally {
|
||||||
recordExecutorTiming(timing, 'current_salt_ms', saltStartMs);
|
recordExecutorTiming(timing, 'current_salt_ms', saltStartMs);
|
||||||
}
|
}
|
||||||
|
|
@ -343,6 +352,8 @@ function finishExecutorTiming(timing) {
|
||||||
received_at: timing.received_at,
|
received_at: timing.received_at,
|
||||||
command_event_age_ms: timing.command_event_age_ms,
|
command_event_age_ms: timing.command_event_age_ms,
|
||||||
current_salt_ms: timing.current_salt_ms ?? null,
|
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,
|
sign_ms: timing.sign_ms ?? null,
|
||||||
relay_response_ms: timing.relay_response_ms ?? null,
|
relay_response_ms: timing.relay_response_ms ?? null,
|
||||||
executor_total_ms: roundTimingMs(performance.now() - timing.started_at_ms),
|
executor_total_ms: roundTimingMs(performance.now() - timing.started_at_ms),
|
||||||
|
|
@ -395,6 +406,7 @@ const controlApi = startControlApi({
|
||||||
signer_public_key: signer.getPublicKey().toString(),
|
signer_public_key: signer.getPublicKey().toString(),
|
||||||
signer_registered: signerRegistered,
|
signer_registered: signerRegistered,
|
||||||
relay: relayClient.getState(),
|
relay: relayClient.getState(),
|
||||||
|
verifier_salt_cache: verifierSaltCache.getState(),
|
||||||
trading_config: tradingConfigStore.getState(),
|
trading_config: tradingConfigStore.getState(),
|
||||||
...state,
|
...state,
|
||||||
durable_control_state: armedStateStore.getState(),
|
durable_control_state: armedStateStore.getState(),
|
||||||
|
|
@ -589,6 +601,7 @@ function createIntentRequestStore() {
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
await controlApi.close().catch(() => {});
|
await controlApi.close().catch(() => {});
|
||||||
|
verifierSaltCache.stop();
|
||||||
relayClient.close();
|
relayClient.close();
|
||||||
await consumer.disconnect();
|
await consumer.disconnect();
|
||||||
await producer.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) {
|
function formatExecutionTiming(timing) {
|
||||||
if (!timing) return null;
|
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 = [
|
const parts = [
|
||||||
['total', timing.executor_total_ms],
|
['total', timing.executor_total_ms],
|
||||||
['cmd age', timing.command_event_age_ms],
|
['cmd age', timing.command_event_age_ms],
|
||||||
['salt', timing.current_salt_ms],
|
|
||||||
['sign', timing.sign_ms],
|
['sign', timing.sign_ms],
|
||||||
['relay', timing.relay_response_ms],
|
['relay', timing.relay_response_ms],
|
||||||
]
|
]
|
||||||
|
|
@ -54,6 +56,9 @@ function formatExecutionTiming(timing) {
|
||||||
return formatted ? `${label} ${formatted}` : null;
|
return formatted ? `${label} ${formatted}` : null;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
if (saltMs) {
|
||||||
|
parts.splice(2, 0, `salt ${saltMs}${saltSource}${saltAgeMs ? ` age ${saltAgeMs}` : ''}`);
|
||||||
|
}
|
||||||
return parts.length ? `Timing: ${parts.join(', ')}` : null;
|
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, /formatExecutionTiming/);
|
||||||
assert.match(strategySource, /item\.execution\?\.timing/);
|
assert.match(strategySource, /item\.execution\?\.timing/);
|
||||||
assert.match(strategySource, /current_salt_ms/);
|
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, /relay_response_ms/);
|
||||||
assert.match(strategySource, /Timing:/);
|
assert.match(strategySource, /Timing:/);
|
||||||
assert.match(strategySource, /item\.execution\?\.status === 'submitted'/);
|
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, /executor_total_ms/);
|
||||||
assert.match(source, /withExecutorTiming\(\{[\s\S]*?result_code: 'submission_failed'/);
|
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