174 lines
7.1 KiB
TypeScript
174 lines
7.1 KiB
TypeScript
/**
|
|
* price-input-block-enforcement-smoke (cp209).
|
|
*
|
|
* Companion to orderbook-block-enforcement-smoke. Instance-local
|
|
* blocking hides a seller's LISTINGS from the orderbook; cp209 extends
|
|
* the same guarantee to the instance's DERIVED price feeds, so a
|
|
* manually-blocked (not merely signal-flagged) seller can't move the
|
|
* morphit_native / depeg price this instance computes from its own
|
|
* orderbook.
|
|
*
|
|
* Enforcement lives in three per-account order-price queries:
|
|
* - morphitNativeFetcher.ts tier1 (USD-direct) + tier2 (stablecoin)
|
|
* - stablecoinDepegDetector.ts cross-stablecoin ratio query
|
|
* and is fed by `operatorAccountName` threaded from the indexer config
|
|
* through two construction sites (factory.ts, priceReceipt.ts).
|
|
*
|
|
* This static smoke is the leak sentinel: it asserts each price-input
|
|
* query still carries the operator-block exclusion (`NOT EXISTS ...
|
|
* operator_blocks ... ob.blocked = o.account ... state = 'blocked'`),
|
|
* that both config types declare `operatorAccountName` (NOT the old
|
|
* `officialAccountName` — cp258: operator_blocks is keyed by the operator
|
|
* account, so binding the official account made the exclusion inert for
|
|
* any instance with a separate MORPHIT_INDEXER_OPERATOR_ACCOUNT_NAME),
|
|
* and that both construction sites pass `config.operatorAccountName`. If someone weakens a query or adds a new
|
|
* per-account price-input read without the filter, this fails.
|
|
*
|
|
* (Runtime proof that the clause actually drops a blocked account's
|
|
* orders from the median is identical in shape to the orderbook clause
|
|
* already proven against real Postgres in development; this is the
|
|
* portable CI guard against regression.)
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const srcDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src');
|
|
const priceDir = join(srcDir, 'indexer', 'price');
|
|
|
|
// The distinctive fragment of the exclusion — order-account scoped.
|
|
// Matches across the multi-line clause (whitespace + the AND between).
|
|
const EXCLUSION = /ob\.blocked\s*=\s*o\.account\b[\s\S]{0,80}ob\.state\s*=\s*'blocked'/g;
|
|
const TABLE = /operator_blocks/;
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
const ok = (m: string) => {
|
|
pass++;
|
|
console.log(` \u2713 ${m}`);
|
|
};
|
|
const bad = (m: string, d = '') => {
|
|
fail++;
|
|
console.log(` \u2717 ${m}`);
|
|
if (d) console.log(` ${d}`);
|
|
};
|
|
|
|
function read(path: string): string | null {
|
|
try {
|
|
return readFileSync(path, 'utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function countExclusions(src: string): number {
|
|
if (!TABLE.test(src)) return 0;
|
|
return (src.match(EXCLUSION) ?? []).length;
|
|
}
|
|
|
|
// ── PB-1/PB-2: every per-account price-input query carries the filter ──
|
|
// Expected counts = number of distinct per-account order-price SELECTs
|
|
// in each file (the `prev`-existence subqueries don't need it — once the
|
|
// outer o.account is excluded, the prior-trade check is moot).
|
|
const QUERY_SURFACES: Record<string, number> = {
|
|
'morphitNativeFetcher.ts': 2, // tier1 USD-direct + tier2 stablecoin
|
|
'stablecoinDepegDetector.ts': 1 // cross-stablecoin ratio (queryDir2 reuses queryDir1)
|
|
};
|
|
|
|
for (const [file, expected] of Object.entries(QUERY_SURFACES)) {
|
|
const src = read(join(priceDir, file));
|
|
if (src === null) {
|
|
bad(`${file}: cannot read (price fetcher moved/renamed?)`);
|
|
continue;
|
|
}
|
|
const count = countExclusions(src);
|
|
if (count >= expected) {
|
|
ok(`${file}: ${count} operator-block exclusion(s) present (\u2265 ${expected})`);
|
|
} else {
|
|
bad(
|
|
`${file}: only ${count} operator-block exclusion(s), expected \u2265 ${expected}`,
|
|
'a per-account price-input query is missing the blocked-account filter — blocked sellers would influence this instance\u2019s price'
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── PB-3/PB-4: both config types require operatorAccountName ──
|
|
// cp258: the field MUST be operatorAccountName (the operator_blocks write
|
|
// key) — it was officialAccountName, which made the exclusion inert for any
|
|
// instance running a separate MORPHIT_INDEXER_OPERATOR_ACCOUNT_NAME.
|
|
const CONFIG_DECLS: Record<string, string> = {
|
|
'morphitNativeFetcher.ts': 'MorphitNativeFetcherConfig',
|
|
'stablecoinDepegDetector.ts': 'DepegDetectorConfig'
|
|
};
|
|
const DECL = /readonly\s+operatorAccountName\s*:\s*string\s*;/;
|
|
|
|
for (const [file, iface] of Object.entries(CONFIG_DECLS)) {
|
|
const src = read(join(priceDir, file));
|
|
if (src === null) {
|
|
bad(`${file}: cannot read for ${iface} check`);
|
|
continue;
|
|
}
|
|
if (!src.includes(iface)) {
|
|
bad(`${file}: ${iface} not found (renamed?)`);
|
|
continue;
|
|
}
|
|
if (/readonly\s+officialAccountName\s*:\s*string\s*;/.test(src)) {
|
|
bad(
|
|
`${file}: ${iface} still declares officialAccountName — must be operatorAccountName (cp258)`,
|
|
'officialAccountName is the federation-wide release-signer; operator_blocks is keyed by operatorAccountName, so a query bound to officialAccountName is inert in the separate-operator-account config'
|
|
);
|
|
} else if (DECL.test(src)) {
|
|
ok(`${file}: ${iface} declares a required operatorAccountName: string`);
|
|
} else {
|
|
bad(
|
|
`${file}: ${iface} is missing \`readonly operatorAccountName: string;\``,
|
|
'without the required field a construction site could silently omit the operator name, making the exclusion inert'
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── PB-5/PB-6: both construction sites pass config.operatorAccountName ──
|
|
// cp258: the VALUE must be config.operatorAccountName (the write key), NOT
|
|
// config.officialAccountName — the bug this guard now catches.
|
|
const CALL_SITES: Array<{ file: string; fn: string }> = [
|
|
{ file: join(srcDir, 'indexer', 'price', 'factory.ts'), fn: 'createMorphitNativeFetcher' },
|
|
{ file: join(srcDir, 'api', 'priceReceipt.ts'), fn: 'deriveMorphitNativePrice' }
|
|
];
|
|
const PASSES_FIELD = /operatorAccountName\s*:\s*config\.operatorAccountName/;
|
|
const PASSES_WRONG = /operatorAccountName\s*:\s*config\.officialAccountName|officialAccountName\s*:\s*config\./;
|
|
|
|
for (const { file, fn } of CALL_SITES) {
|
|
const src = read(file);
|
|
const label = file.split('/src/')[1] ?? file;
|
|
if (src === null) {
|
|
bad(`${label}: cannot read for ${fn} call-site check`);
|
|
continue;
|
|
}
|
|
if (!src.includes(fn)) {
|
|
bad(`${label}: does not call ${fn} (moved?)`);
|
|
continue;
|
|
}
|
|
if (PASSES_WRONG.test(src)) {
|
|
bad(
|
|
`${label}: passes the WRONG account to ${fn} (config.officialAccountName / officialAccountName)`,
|
|
'operator_blocks is keyed by operatorAccountName — bind config.operatorAccountName or the exclusion is inert for separate-operator-account instances (cp258)'
|
|
);
|
|
} else if (PASSES_FIELD.test(src)) {
|
|
ok(`${label}: passes operatorAccountName: config.operatorAccountName to ${fn}`);
|
|
} else {
|
|
bad(
|
|
`${label}: calls ${fn} but never passes operatorAccountName: config.operatorAccountName`,
|
|
'the price fetcher would receive no operator name — blocked-account exclusion would be inert'
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log('');
|
|
console.log(`${pass} passed, ${fail} failed`);
|
|
if (fail > 0) {
|
|
console.log('\u2717 price-input-block-enforcement smoke FAILED');
|
|
process.exit(1);
|
|
}
|
|
console.log('\u2713 every per-account price-input query filters blocked accounts');
|
|
console.log(`\u2713 all ${pass} price-input-block-enforcement scenarios passed`);
|