192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
/**
|
|
* broadcast hedge-off smoke (cp452).
|
|
*
|
|
* Regression for the ~60s send hang. The indexer's /v1/broadcast relayed a
|
|
* SIGNED WRITE with `userFacing:true`, which `callCondenser` maps to
|
|
* `hedge:true` — so the broadcast was parallel-fired to a second Blurt node.
|
|
* That second node can't include a duplicate transaction, so its
|
|
* `broadcast_transaction_synchronous` call blocks on the duplicate until the
|
|
* tx expires (~60s), and the pool waits on it. When nodes were fast the race
|
|
* resolved instantly ("fastchat"); once one node got slow, the loser hung to
|
|
* expiry and every send took a minute. The relay's broadcast path forbids
|
|
* exactly this with its own `hedge:false`; the indexer now does the same.
|
|
*
|
|
* This pins the fix at BOTH layers so it can't creep back:
|
|
* Layer 1 — the /v1/broadcast route MUST hand `callCondenser` `hedge:false`
|
|
* (and must NOT hand it `userFacing:true`), for the exact method
|
|
* it actually broadcasts with.
|
|
* Layer 2 — `resolveHedge` MUST honour an explicit `hedge:false` over any
|
|
* `userFacing`, while leaving user-facing READ hedging intact (so
|
|
* a fix here can't over-correct and disable read hedging).
|
|
*
|
|
* A "broadcast_transaction" (async) rewrite would drop block_num from the
|
|
* frontend contract, so the fix deliberately keeps the synchronous method and
|
|
* only removes the hedge — the single-broadcast latency is the cost of
|
|
* correctness, same trade the relay makes.
|
|
*/
|
|
import { broadcastRoute } from '../src/api/broadcast.ts';
|
|
import { resolveHedge, type RpcCallOptions, type BlurtClient } from '../src/blurt/client.ts';
|
|
|
|
interface Scenario {
|
|
name: string;
|
|
run: () => Promise<string | null> | (string | null);
|
|
}
|
|
const scenarios: Scenario[] = [];
|
|
|
|
// ─── Layer 1: the route hands callCondenser hedge:false ──────────────────────
|
|
|
|
/** A BlurtClient stub that records exactly how the route invoked
|
|
* callCondenser (method + options) and returns a successful broadcast. */
|
|
function capturingBlurt(): {
|
|
client: BlurtClient;
|
|
calls: Array<{ method: string; options: RpcCallOptions }>;
|
|
} {
|
|
const calls: Array<{ method: string; options: RpcCallOptions }> = [];
|
|
const client = {
|
|
callCondenser: async (
|
|
method: string,
|
|
_params: readonly unknown[] = [],
|
|
options: RpcCallOptions = {}
|
|
) => {
|
|
calls.push({ method, options });
|
|
// condenser_api.broadcast_transaction_synchronous shape.
|
|
return { id: 'a'.repeat(40), block_num: 42, trx_num: 0 };
|
|
}
|
|
} as unknown as BlurtClient;
|
|
return { client, calls };
|
|
}
|
|
|
|
/** A valid, allowlisted signed write (a chat message) — the exact op class
|
|
* whose send was hanging. */
|
|
const CHAT_TX = {
|
|
trx: {
|
|
ref_block_num: 1,
|
|
ref_block_prefix: 1,
|
|
expiration: '2026-01-01T00:00:00',
|
|
operations: [
|
|
[
|
|
'custom_json',
|
|
{ required_auths: [], required_posting_auths: ['kentest2'], id: 'morphit_chat_v1', json: '{}' }
|
|
]
|
|
],
|
|
extensions: [],
|
|
signatures: ['deadbeef']
|
|
}
|
|
};
|
|
|
|
async function postChat(): Promise<{
|
|
status: number;
|
|
calls: Array<{ method: string; options: RpcCallOptions }>;
|
|
}> {
|
|
const { client, calls } = capturingBlurt();
|
|
const app = broadcastRoute(client);
|
|
const res = await app.request('/', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(CHAT_TX)
|
|
});
|
|
return { status: res.status, calls };
|
|
}
|
|
|
|
scenarios.push({
|
|
name: 'route reaches the broadcast (allowlisted chat op → 200)',
|
|
async run() {
|
|
const { status } = await postChat();
|
|
return status === 200 ? null : `expected 200, got ${status}`;
|
|
}
|
|
});
|
|
|
|
scenarios.push({
|
|
name: 'route broadcasts through broadcast_transaction_synchronous exactly once',
|
|
async run() {
|
|
const { calls } = await postChat();
|
|
if (calls.length !== 1) return `expected exactly 1 callCondenser call, got ${calls.length}`;
|
|
return calls[0].method === 'broadcast_transaction_synchronous'
|
|
? null
|
|
: `expected method broadcast_transaction_synchronous, got ${calls[0].method}`;
|
|
}
|
|
});
|
|
|
|
scenarios.push({
|
|
name: 'route hands callCondenser hedge:false (never hedge a signed write)',
|
|
async run() {
|
|
const { calls } = await postChat();
|
|
if (calls.length !== 1) return `expected 1 call, got ${calls.length}`;
|
|
return calls[0].options.hedge === false
|
|
? null
|
|
: `broadcast options.hedge must be false, got ${JSON.stringify(calls[0].options.hedge)}`;
|
|
}
|
|
});
|
|
|
|
scenarios.push({
|
|
name: 'route does NOT hand callCondenser userFacing:true (the original bug)',
|
|
async run() {
|
|
const { calls } = await postChat();
|
|
if (calls.length !== 1) return `expected 1 call, got ${calls.length}`;
|
|
// userFacing:true was the exact value that turned hedging on for the write.
|
|
return calls[0].options.userFacing !== true
|
|
? null
|
|
: 'broadcast passed userFacing:true — that re-enables hedging on the write';
|
|
}
|
|
});
|
|
|
|
// ─── Layer 2: resolveHedge honours explicit hedge:false, keeps read hedging ──
|
|
|
|
interface HedgeCase {
|
|
label: string;
|
|
options: RpcCallOptions;
|
|
expected: boolean;
|
|
}
|
|
const HEDGE_CASES: HedgeCase[] = [
|
|
// The write path: explicit hedge:false disables hedging.
|
|
{ label: 'hedge:false (write) → false', options: { hedge: false }, expected: false },
|
|
// Explicit hedge:false wins even if userFacing:true is also present, so a
|
|
// future edit that re-adds userFacing to the broadcast still can't re-hedge.
|
|
{
|
|
label: 'hedge:false + userFacing:true → false (explicit wins)',
|
|
options: { hedge: false, userFacing: true },
|
|
expected: false
|
|
},
|
|
// Reads must STILL hedge — the fix must not over-correct into slow reads.
|
|
{ label: 'userFacing:true (read) → true', options: { userFacing: true }, expected: true },
|
|
// Background default: no hedge.
|
|
{ label: '{} (background) → false', options: {}, expected: false },
|
|
{ label: 'userFacing:false → false', options: { userFacing: false }, expected: false },
|
|
// Explicit hedge:true still hedges.
|
|
{ label: 'hedge:true → true', options: { hedge: true }, expected: true }
|
|
];
|
|
|
|
for (const c of HEDGE_CASES) {
|
|
scenarios.push({
|
|
name: `resolveHedge ${c.label}`,
|
|
run() {
|
|
const got = resolveHedge(c.options);
|
|
return got === c.expected ? null : `expected ${c.expected}, got ${got}`;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── runner ───
|
|
let pass = 0;
|
|
let fail = 0;
|
|
for (const s of scenarios) {
|
|
try {
|
|
const err = await s.run();
|
|
if (err) {
|
|
console.log(` ✗ ${s.name}: ${err}`);
|
|
fail++;
|
|
} else {
|
|
console.log(` ✓ ${s.name}`);
|
|
pass++;
|
|
}
|
|
} catch (e) {
|
|
console.log(` ✗ ${s.name}: threw ${e instanceof Error ? e.message : String(e)}`);
|
|
fail++;
|
|
}
|
|
}
|
|
console.log('');
|
|
if (fail > 0) {
|
|
console.log(`✗ ${fail} failed, ${pass} passed`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`✓ all ${pass} broadcast-hedge-off scenarios passed`);
|