Skip to content

Commit 063cd7b

Browse files
author
S. K. Rotwang MMMMMMMMM
committed
feat: operator activity panel + freshness signals + FlashBank LP path
- queries.js: getStatsOverview now exposes operator_activity (counts only — liquidation attempts/wins for 24h+7d, builder slots landed for 24h/7d/30d, plus per-source data freshness ages). No profit or strategy fields are leaked. - stats.html: new "Seneschal activity — last 24h" panel surfaces these in a 6-tile strip beneath the hero KPIs. Freshness coloring is signal-aware (slot / continuous / sporadic cadences). - KPI grid: switched to minmax(0, 1fr) + dedicated auto-fit hero strip so a single long sub-label can no longer squish siblings. - flashloan-providers.js: FlashBank entry now documents the LP / commit-capital path (approve + setCommitment, pause anytime). - tests: +3 new assertions covering operator_activity shape and FlashBank lp_path. 75 tests pass.
1 parent a372ed4 commit 063cd7b

7 files changed

Lines changed: 341 additions & 27 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ Nine tools become available to your agent:
5353
| `seneschal_get_borrower` | Latest state of one borrower across protocols |
5454
| `seneschal_get_borrower_history` | Time-series health-factor traces |
5555
| `seneschal_builder_leaderboard` | Ethereum builder market share (24h, 7d, 30d, all-time) |
56-
| `seneschal_stats_overview` | Aggregate snapshot powering the public dashboard |
57-
| `seneschal_flashloan_providers` | Curated catalogue of mainnet flash-loan providers |
56+
| `seneschal_stats_overview` | Aggregate snapshot powering the public dashboard, incl. operator activity (counts only — no profit fields) |
57+
| `seneschal_flashloan_providers` | Curated catalogue of mainnet flash-loan providers, incl. LP-side commit-capital paths where applicable |
5858

5959
## REST endpoints
6060

@@ -123,10 +123,11 @@ producing the same shapes you can point this server at it.
123123
npm test
124124
```
125125

126-
45 jest tests covering the query layer (in-memory SQLite fixtures), the
126+
75 jest tests covering the query layer (in-memory SQLite fixtures), the
127127
Fastify REST routes (via `fastify.inject`), and the MCP server (both
128128
in-process via `InMemoryTransport` and end-to-end via
129-
`StreamableHTTPClientTransport`).
129+
`StreamableHTTPClientTransport`). Plus `test/live-smoke.mjs` which
130+
exercises the live `mcp.seneschal.space` endpoint over Streamable HTTP.
130131

131132
## Architecture
132133

docs/stats.html

Lines changed: 135 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,29 @@
4848
@keyframes pulse { from { opacity: .3 } to { opacity: 1 } }
4949

5050
/* ── grid layout ─────────────────────────────────────────── */
51-
.grid { display: grid; gap: 1em; grid-template-columns: repeat(12, 1fr) }
52-
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 1.1em 1.2em }
51+
/* minmax(0,1fr) instead of 1fr so columns don't stretch when a
52+
child's content (e.g. long KPI sub-text) exceeds the column's
53+
intrinsic min-width. */
54+
.grid { display: grid; gap: 1em; grid-template-columns: repeat(12, minmax(0, 1fr)) }
55+
/* Dedicated KPI strip uses auto-fit so the tiles stay equal-width
56+
and gracefully wrap on narrow viewports instead of one card
57+
stealing space from its siblings. */
58+
.kpi-strip { display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); margin-bottom: 1em }
59+
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 1.1em 1.2em; min-width: 0 }
5360
.panel h2 { margin: 0 0 .8em; font-size: 0.85em; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted) }
5461
.panel.span-3 { grid-column: span 3 }
5562
.panel.span-4 { grid-column: span 4 }
63+
.panel.span-5 { grid-column: span 5 }
5664
.panel.span-6 { grid-column: span 6 }
65+
.panel.span-7 { grid-column: span 7 }
5766
.panel.span-8 { grid-column: span 8 }
5867
.panel.span-12 { grid-column: span 12 }
5968
@media (max-width: 900px) {
60-
.panel.span-3, .panel.span-4 { grid-column: span 6 }
61-
.panel.span-6, .panel.span-8 { grid-column: span 12 }
69+
.panel.span-3, .panel.span-4, .panel.span-5 { grid-column: span 6 }
70+
.panel.span-6, .panel.span-7, .panel.span-8 { grid-column: span 12 }
6271
}
6372
@media (max-width: 540px) {
64-
.panel.span-3, .panel.span-4, .panel.span-6 { grid-column: span 12 }
73+
.panel.span-3, .panel.span-4, .panel.span-5, .panel.span-6 { grid-column: span 12 }
6574
}
6675

6776
/* ── KPI tiles ───────────────────────────────────────────── */
@@ -71,6 +80,19 @@
7180
.kpi-value.warn { color: var(--warn) }
7281
.kpi-value.bad { color: var(--bad) }
7382

83+
/* ── operator activity strip ─────────────────────────────── */
84+
.operator-strip { border-left: 3px solid var(--accent); margin-bottom: 1em }
85+
.operator-strip-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1em; margin-bottom: 0.9em; flex-wrap: wrap }
86+
.operator-tiles { display: grid; gap: 0.9em; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)) }
87+
.op-tile { background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 0.8em 0.9em; min-width: 0 }
88+
.op-label { color: var(--muted); font-size: 0.78em; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 600 }
89+
.op-value { font: 600 1.45em/1.15 var(--mono); margin: 0.18em 0 0.1em; color: var(--fg); word-break: break-word }
90+
.op-value.mono-sm { font-size: 1.05em }
91+
.op-value.good { color: var(--good) }
92+
.op-value.warn { color: var(--warn) }
93+
.op-value.bad { color: var(--bad) }
94+
.op-sub { color: var(--muted); font-size: 0.82em }
95+
7496
/* ── tables ──────────────────────────────────────────────── */
7597
table { width: 100%; border-collapse: collapse; font-size: 0.9em }
7698
th, td { padding: 0.45em 0.6em; text-align: left }
@@ -120,29 +142,69 @@ <h1>Seneschal — Public Stats</h1>
120142

121143
<div id="error-banner" class="error-banner"></div>
122144

123-
<div class="grid">
124-
125-
<!-- Hero KPIs -->
126-
<div class="panel span-3">
145+
<div class="kpi-strip">
146+
<div class="panel">
127147
<h2>Positions tracked</h2>
128148
<div id="kpi-positions" class="kpi-value"></div>
129149
<div class="kpi-sub" id="kpi-positions-sub">across Aave + Morpho</div>
130150
</div>
131-
<div class="panel span-3">
151+
<div class="panel">
132152
<h2>Total debt watched</h2>
133153
<div id="kpi-debt" class="kpi-value"></div>
134154
<div class="kpi-sub" id="kpi-debt-sub">aggregate USD across snapshots</div>
135155
</div>
136-
<div class="panel span-3">
156+
<div class="panel">
137157
<h2>At risk (HF &lt; 1.05)</h2>
138158
<div id="kpi-atrisk" class="kpi-value"></div>
139159
<div class="kpi-sub" id="kpi-atrisk-sub">live count</div>
140160
</div>
141-
<div class="panel span-3">
161+
<div class="panel">
142162
<h2>Liquidations 24 h</h2>
143163
<div id="kpi-liq-24h" class="kpi-value"></div>
144164
<div class="kpi-sub" id="kpi-liq-24h-sub">on-chain events observed</div>
145165
</div>
166+
</div>
167+
168+
<section class="operator-strip panel">
169+
<header class="operator-strip-head">
170+
<h2 style="color:var(--accent);margin:0">Seneschal activity — last 24h</h2>
171+
<span class="kpi-sub">counts only — no profit figures exposed</span>
172+
</header>
173+
<div class="operator-tiles">
174+
<div class="op-tile">
175+
<div class="op-label">Liquidation attempts</div>
176+
<div class="op-value" id="op-attempts"></div>
177+
<div class="op-sub" id="op-attempts-sub">7d: —</div>
178+
</div>
179+
<div class="op-tile">
180+
<div class="op-label">Liquidations won</div>
181+
<div class="op-value" id="op-wins"></div>
182+
<div class="op-sub" id="op-wins-sub">win rate —</div>
183+
</div>
184+
<div class="op-tile">
185+
<div class="op-label">Builder blocks landed</div>
186+
<div class="op-value" id="op-builder"></div>
187+
<div class="op-sub" id="op-builder-sub">7d / 30d: — / —</div>
188+
</div>
189+
<div class="op-tile">
190+
<div class="op-label">Borrower snapshot age</div>
191+
<div class="op-value mono-sm" id="op-snap-age"></div>
192+
<div class="op-sub" id="op-snap-age-sub">writer freshness</div>
193+
</div>
194+
<div class="op-tile">
195+
<div class="op-label">Shadow recorder age</div>
196+
<div class="op-value mono-sm" id="op-shadow-age"></div>
197+
<div class="op-sub" id="op-shadow-age-sub">per-slot bid log</div>
198+
</div>
199+
<div class="op-tile">
200+
<div class="op-label">Last execution</div>
201+
<div class="op-value mono-sm" id="op-exec-age"></div>
202+
<div class="op-sub" id="op-exec-age-sub">most recent bot action</div>
203+
</div>
204+
</div>
205+
</section>
206+
207+
<div class="grid">
146208

147209
<!-- HF histogram + builder pie -->
148210
<div class="panel span-7">
@@ -282,7 +344,7 @@ <h2>Recent on-chain liquidations (last 24 h)</h2>
282344
`Aave ${fmtNum(d.totals.borrower_snapshots)} · Morpho ${fmtNum(d.totals.morpho_borrower_snapshots)}`;
283345
document.getElementById('kpi-debt').textContent = fmtUSD(k.aave_debt_under_watch_usd);
284346
document.getElementById('kpi-debt-sub').textContent =
285-
`backed by ${fmtUSD(k.aave_collateral_under_watch_usd)} collateral (Aave V3)`;
347+
`with ${fmtUSD(k.aave_collateral_under_watch_usd)} collateral`;
286348
const atrisk = k.at_risk_count ?? 0;
287349
document.getElementById('kpi-atrisk').textContent = fmtNum(atrisk);
288350
document.getElementById('kpi-atrisk').className = 'kpi-value ' + (atrisk > 200 ? 'bad' : atrisk > 50 ? 'warn' : 'good');
@@ -293,6 +355,65 @@ <h2>Recent on-chain liquidations (last 24 h)</h2>
293355
${fmtUSD(k.liquidations_24h_debt_usd)} debt liquidated`;
294356
}
295357

358+
function fmtAge(seconds) {
359+
if (seconds == null) return '—';
360+
if (seconds < 60) return `${seconds}s`;
361+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
362+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
363+
return `${Math.floor(seconds / 86400)}d`;
364+
}
365+
366+
// Different signals have different natural cadences so a single
367+
// threshold misleads. shadow_blocks_age = per-slot (every ~12s),
368+
// borrower_snapshot_age = continuous, execution_age = sporadic
369+
// (only fires when an opportunity arises — minutes-to-hours is fine).
370+
function freshnessClass(seconds, kind) {
371+
if (seconds == null) return 'warn';
372+
if (kind === 'slot') return seconds < 60 ? 'good' : seconds < 300 ? 'warn' : 'bad';
373+
if (kind === 'continuous') return seconds < 600 ? 'good' : seconds < 3600 ? 'warn' : 'bad';
374+
if (kind === 'sporadic') return seconds < 6*3600 ? 'good' : seconds < 24*3600 ? 'warn' : 'bad';
375+
// fallback
376+
return seconds < 600 ? 'good' : seconds < 3600 ? 'warn' : 'bad';
377+
}
378+
379+
function renderOperator(d) {
380+
const op = d.operator_activity ?? {};
381+
const attempts = op.liquidation_attempts_24h ?? 0;
382+
const wins = op.liquidations_won_24h ?? 0;
383+
const winRate = op.win_rate_24h;
384+
385+
const elAttempts = document.getElementById('op-attempts');
386+
elAttempts.textContent = fmtNum(attempts);
387+
elAttempts.className = 'op-value ' + (attempts > 0 ? 'good' : 'warn');
388+
document.getElementById('op-attempts-sub').textContent =
389+
`7d: ${fmtNum(op.liquidation_attempts_7d ?? 0)}`;
390+
391+
const elWins = document.getElementById('op-wins');
392+
elWins.textContent = fmtNum(wins);
393+
elWins.className = 'op-value ' + (wins > 0 ? 'good' : '');
394+
document.getElementById('op-wins-sub').textContent =
395+
winRate != null ? `win rate ${(winRate * 100).toFixed(1)}%` : 'win rate —';
396+
397+
const elBuilder = document.getElementById('op-builder');
398+
elBuilder.textContent = fmtNum(op.builder_blocks_landed_24h ?? 0);
399+
elBuilder.className = 'op-value ' + ((op.builder_blocks_landed_24h ?? 0) > 0 ? 'good' : 'warn');
400+
document.getElementById('op-builder-sub').textContent =
401+
`7d / 30d: ${fmtNum(op.builder_blocks_landed_7d ?? 0)} / ${fmtNum(op.builder_blocks_landed_30d ?? 0)}`;
402+
403+
const fresh = op.data_freshness ?? {};
404+
const elSnap = document.getElementById('op-snap-age');
405+
elSnap.textContent = fmtAge(fresh.latest_borrower_snapshot_age_s);
406+
elSnap.className = 'op-value mono-sm ' + freshnessClass(fresh.latest_borrower_snapshot_age_s, 'continuous');
407+
408+
const elShadow = document.getElementById('op-shadow-age');
409+
elShadow.textContent = fmtAge(fresh.shadow_blocks_age_s);
410+
elShadow.className = 'op-value mono-sm ' + freshnessClass(fresh.shadow_blocks_age_s, 'slot');
411+
412+
const elExec = document.getElementById('op-exec-age');
413+
elExec.textContent = fmtAge(fresh.latest_execution_age_s);
414+
elExec.className = 'op-value mono-sm ' + freshnessClass(fresh.latest_execution_age_s, 'sporadic');
415+
}
416+
296417
function renderHfHistogram(d) {
297418
const labels = d.hf_histogram.map(b => b.bucket);
298419
const aaveCounts = d.hf_histogram.map(b => b.aave_count);
@@ -461,6 +582,7 @@ <h2>Recent on-chain liquidations (last 24 h)</h2>
461582
const d = await r.json();
462583
lastOverview = d;
463584
renderKpis(d);
585+
renderOperator(d);
464586
renderHfHistogram(d);
465587
renderBuilders(d);
466588
renderTopAtRisk(d);

src/flashloan-providers.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,28 @@ export const FLASHLOAN_PROVIDERS = Object.freeze([
7272
fee_bps: 2,
7373
fee_bps_note: "0.02% — configurable per token via setTokenConfig (1–100 bps range)",
7474
docs: 'https://flashbank.net',
75-
liquidity_note: "Just-in-time model: WETH is pulled from LPs' wallets only during the flash loan, never deposited into a pool. Available liquidity equals the sum of active LP commitments, queryable via `getCommitments(token)`. As of launch, mainnet commitments are 0 — useful if and when LPs arrive.",
75+
repo: 'https://github.com/Rotwang9000/flashbank-net',
76+
liquidity_note: "Just-in-time model: WETH stays in LPs' wallets and is only pulled for the microseconds of a flash loan. Available liquidity = sum of active LP commitments, queryable via `getCommitments(token)`. Multi-chain (Ethereum, Arbitrum, Base) — see repo for each router address.",
7677
supports_multi_asset: false,
7778
notable_constraints: [
7879
'WETH-first at launch; other ERC-20s require setTokenConfig.',
79-
'Provider availability depends on LP commitments — query before assuming liquidity.',
80-
'Lower fee than Aave but currently lower liquidity than Balancer + Aave.'
81-
]
80+
'Provider availability depends on LP commitments — query getCommitments(token) before assuming liquidity.',
81+
'Lower fee than Aave but currently lower committed liquidity than Balancer/Aave; will improve as LPs onboard.'
82+
],
83+
// FlashBank is unique in this catalogue in that anyone can be
84+
// liquidity provider with just a wallet approval — no deposit,
85+
// no lock-up, no smart-contract custody. Surfaced explicitly
86+
// so MEV / treasury agents discovering this catalogue see the
87+
// LP path, not just the borrower path.
88+
lp_path: {
89+
steps: [
90+
'Approve `FlashBankRouter` to spend WETH from your wallet (`weth.approve(router, limit)`).',
91+
'Call `router.setCommitment(weth, limit, expiry, paused=false)` to advertise how much WETH you will lend.',
92+
'Earn the per-flash-loan fee automatically; WETH never leaves your wallet outside of borrow execution.',
93+
'Pause anytime with `router.setCommitment(weth, limit, expiry, paused=true)` or drop limit to 0.'
94+
],
95+
interface_url: 'https://flashbank.net'
96+
}
8297
},
8398
{
8499
id: 'uniswap-v3',

src/queries.js

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,26 @@ export async function getStatsOverview(db, params = {}) {
893893
FROM missed_liquidations WHERE timestamp >= ?
894894
`).get(cutoff24h);
895895

896+
// Seneschal's own activity over the last 24h. Counts only — we
897+
// deliberately do NOT expose total profit in USD or strategy
898+
// labels because those are operator-private. The intent is a
899+
// "is the bot doing its job?" signal that operators (and curious
900+
// outsiders) can see without revealing how much money is at stake.
901+
const ourExec24h = db.prepare(`
902+
SELECT
903+
COUNT(*) AS attempts,
904+
COALESCE(SUM(CASE WHEN success=1 THEN 1 ELSE 0 END), 0) AS successes
905+
FROM executions
906+
WHERE timestamp >= ?
907+
`).get(cutoff24h);
908+
const ourExec7d = db.prepare(`
909+
SELECT
910+
COUNT(*) AS attempts,
911+
COALESCE(SUM(CASE WHEN success=1 THEN 1 ELSE 0 END), 0) AS successes
912+
FROM executions
913+
WHERE timestamp >= ?
914+
`).get(now - 7 * 24 * 60 * 60 * 1000);
915+
896916
// At-risk count — anything with HF below 1.05. Aave straight from
897917
// snapshots; Morpho synthesised from ltv/lltv.
898918
const aaveAtRisk = db.prepare(`
@@ -910,18 +930,53 @@ export async function getStatsOverview(db, params = {}) {
910930
// Builder leaderboard for multiple windows so the dashboard can
911931
// show trend without persisted history (24h vs 7d delta).
912932
const [share24h, share7d, share30d] = await Promise.all([
913-
getBuilderLeaderboard({ window: '24h', limit: 8, _shadowPath: shadowPath, _ttlMs: ttlMs }),
914-
getBuilderLeaderboard({ window: '7d', limit: 8, _shadowPath: shadowPath, _ttlMs: ttlMs }),
915-
getBuilderLeaderboard({ window: '30d', limit: 8, _shadowPath: shadowPath, _ttlMs: ttlMs })
933+
getBuilderLeaderboard({ window: '24h', limit: 50, _shadowPath: shadowPath, _ttlMs: ttlMs }),
934+
getBuilderLeaderboard({ window: '7d', limit: 50, _shadowPath: shadowPath, _ttlMs: ttlMs }),
935+
getBuilderLeaderboard({ window: '30d', limit: 50, _shadowPath: shadowPath, _ttlMs: ttlMs })
916936
]);
917937

938+
// Find Seneschal's own row in each window so the operator activity
939+
// panel can show "our blocks landed". Matched on the builder label
940+
// produced by getBuilderLeaderboard (which normalises extra_data
941+
// and known coinbase addresses to a clean display string).
942+
const findSelf = (builders) =>
943+
builders.find(b => /^seneschal/i.test(b.builder)) ?? { builder: 'seneschal', slots_won: 0, captured_eth: 0 };
944+
const selfBuilder24h = findSelf(share24h.builders);
945+
const selfBuilder7d = findSelf(share7d.builders);
946+
const selfBuilder30d = findSelf(share30d.builders);
947+
// Trim the public leaderboard slice back to top 8 for the donut.
948+
share24h.builders = share24h.builders.slice(0, 8);
949+
share7d.builders = share7d.builders.slice(0, 8);
950+
share30d.builders = share30d.builders.slice(0, 8);
951+
918952
// Recent on-chain liquidations (last 24h) — the dashboard renders
919953
// this as a feed.
920954
const recent24h = recentLiquidations(db, {
921955
since_ms: now - 24 * 60 * 60 * 1000,
922956
limit: 20
923957
});
924958

959+
// File mtimes for the freshness panel. None of these reveal data
960+
// content; they're just "yes, the writer is alive" indicators.
961+
const freshness = {
962+
shadow_blocks_age_s: fileMtimeMs(shadowPath)
963+
? Math.round((now - fileMtimeMs(shadowPath)) / 1000) : null,
964+
spark_borrowers_age_s: fileMtimeMs(params._sparkPath)
965+
? Math.round((now - fileMtimeMs(params._sparkPath)) / 1000) : null,
966+
latest_borrower_snapshot_age_s: (() => {
967+
const r = db.prepare(`SELECT max(last_seen_ts) AS t FROM borrower_snapshots`).get();
968+
return r?.t ? Math.round((now - r.t) / 1000) : null;
969+
})(),
970+
latest_execution_age_s: (() => {
971+
const r = db.prepare(`SELECT max(timestamp) AS t FROM executions`).get();
972+
return r?.t ? Math.round((now - r.t) / 1000) : null;
973+
})(),
974+
latest_missed_liquidation_age_s: (() => {
975+
const r = db.prepare(`SELECT max(timestamp) AS t FROM missed_liquidations`).get();
976+
return r?.t ? Math.round((now - r.t) / 1000) : null;
977+
})()
978+
};
979+
925980
return {
926981
as_of_ms: now,
927982
// Pre-aggregated KPI block. The dashboard reads these directly
@@ -938,6 +993,22 @@ export async function getStatsOverview(db, params = {}) {
938993
liquidations_24h_count: liqs24h.count,
939994
liquidations_24h_debt_usd: liqs24h.debt_usd
940995
},
996+
// "Are we actually working?" panel. Counts only — never profit.
997+
// All numbers here are derivable from on-chain data anyway, so
998+
// publishing them gives no edge to competitors but reassures the
999+
// operator that the bot is doing its job.
1000+
operator_activity: {
1001+
liquidation_attempts_24h: ourExec24h.attempts,
1002+
liquidations_won_24h: ourExec24h.successes,
1003+
win_rate_24h: ourExec24h.attempts > 0
1004+
? ourExec24h.successes / ourExec24h.attempts : null,
1005+
liquidation_attempts_7d: ourExec7d.attempts,
1006+
liquidations_won_7d: ourExec7d.successes,
1007+
builder_blocks_landed_24h: selfBuilder24h.slots_won ?? 0,
1008+
builder_blocks_landed_7d: selfBuilder7d.slots_won ?? 0,
1009+
builder_blocks_landed_30d: selfBuilder30d.slots_won ?? 0,
1010+
data_freshness: freshness
1011+
},
9411012
totals,
9421013
aave_aggregate: aaveAggregate,
9431014
hf_histogram: histogram,

test/flashloan-providers.test.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@ describe('FLASHLOAN_PROVIDERS catalogue', () => {
2525
}
2626
});
2727

28-
test('FlashBank is included with the documented fee', () => {
28+
test('FlashBank is included with the documented fee + LP path', () => {
2929
const fb = FLASHLOAN_PROVIDERS.find(p => p.id === 'flashbank');
3030
expect(fb).toBeDefined();
3131
expect(fb.fee_bps).toBe(2);
3232
expect(fb.docs).toContain('flashbank.net');
33+
expect(fb.lp_path).toBeDefined();
34+
expect(Array.isArray(fb.lp_path.steps)).toBe(true);
35+
expect(fb.lp_path.steps.length).toBeGreaterThanOrEqual(3);
3336
});
3437

3538
test('catalogue is frozen — accidental mutation is forbidden', () => {

0 commit comments

Comments
 (0)