Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import "dotenv/config";
import http from "node:http";
import { timingSafeEqual } from "node:crypto";
import { config, createLogger, initSentry, captureException, sendInfoAlert, sendCriticalAlert, sendWarningAlert, createServiceMonitors, getConnection, loadKeypair } from "@percolatorct/shared";
import { config, createLogger, initSentry, captureException, sendInfoAlert, sendCriticalAlert, sendWarningAlert, getConnection, loadKeypair } from "@percolatorct/shared";
import { monitors } from "./lib/service-monitors.js";
import { OracleService } from "./services/oracle.js";
import { CrankService } from "./services/crank.js";
import { LiquidationService } from "./services/liquidation.js";
Expand All @@ -26,8 +27,8 @@ import { initSharedShadowHarness, sharedShadowHarness } from "./lib/shadow-harne
import { sharedDecisionLog } from "./lib/decision-log.js";
import { createLaserStreamAccountLoader } from "./lib/laserstream-entrypoint.js";

// Monitoring — alerts to Discord on threshold breaches
export const monitors = createServiceMonitors("Keeper");
// Monitoring — alerts to Discord on threshold breaches (see src/lib/service-monitors.ts)
export { monitors };
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Expose the new db lane in /health.

This re-export makes monitors.db available, but Lines 658-662 still serialize only rpc, scan, and oracle. The DB monitor updates added in src/services/crank.ts never reach /health, so DB outages remain invisible despite this PR’s stated goal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` around lines 30 - 31, The `/health` payload still omits the new
DB lane, so `monitors.db` is exposed but never serialized. Update the health
response builder where `rpc`, `scan`, and `oracle` are collected to also include
the DB monitor from `monitors` (the same object re-exported in `src/index.ts`),
and ensure the `/health` JSON reflects the DB status alongside the other lanes.


// Initialize Sentry first
initSentry("keeper");
Expand Down Expand Up @@ -157,6 +158,10 @@ const solBalanceCheckInterval = setInterval(async () => {
_keeperSolBalanceLamports = lamports;
const solBalance = lamports / 1e9;
walletBalanceSol.set(solBalance);
// BUG-110: this periodic getBalance is a real, regular RPC round trip —
// record it so /health's monitors.rpc reflects actual connectivity
// instead of permanently-green placeholder data.
monitors.rpc.recordSuccess().catch(() => {});

if (solBalance < SOL_BALANCE_WARN_THRESHOLD) {
// Rate-limit alerts to once per 5 minutes to avoid Discord spam
Expand All @@ -180,6 +185,7 @@ const solBalanceCheckInterval = setInterval(async () => {
logger.warn("Failed to fetch keeper SOL balance", {
error: err instanceof Error ? err.message : String(err),
});
monitors.rpc.recordFailure(err instanceof Error ? err.message : String(err)).catch(() => {});
}
}, 60_000);
solBalanceCheckInterval.unref();
Expand Down
11 changes: 11 additions & 0 deletions src/lib/service-monitors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createServiceMonitors } from "@percolatorct/shared";

/**
* BUG-110: standard health monitors (rpc/scan/oracle/db), surfaced in
* /health's `monitors` sub-object. Factored out of index.ts so crank.ts and
* oracle.ts can record real outcomes without a circular import on index.ts.
* Each monitor is only as accurate as its wiring — see the recordSuccess/
* recordFailure call sites in index.ts (rpc), crank.ts (scan, db), and
* oracle.ts (oracle).
*/
export const monitors = createServiceMonitors("Keeper");
10 changes: 10 additions & 0 deletions src/services/crank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
txLandTimeSeconds,
} from "../lib/metrics.js";
import type { AccountLoader } from "../lib/account-loader.js";
import { monitors } from "../lib/service-monitors.js";
import { keeperSend, sharedBudget } from "../lib/keeper-send.js";
import { sharedTxQueue } from "../lib/tx-queue.js";
import { parseV17RiskParams, V17_RISK_PARAMS_MIN_DATA_LEN } from "../lib/v17-risk.js";
Expand Down Expand Up @@ -1189,6 +1190,10 @@ export class CrankService {
.in("slab_address", slabAddresses);
if (error) {
logger.warn("Supabase market metadata query error", { error: error.message });
// BUG-110: record so /health's monitors.db reflects real DB outcomes.
monitors.db.recordFailure(error.message).catch(() => {});
} else {
monitors.db.recordSuccess().catch(() => {});
}
if (data) {
const base58Re = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
Expand All @@ -1207,6 +1212,7 @@ export class CrankService {
logger.warn("Failed to fetch market metadata from Supabase", {
error: err instanceof Error ? err.message : String(err),
});
monitors.db.recordFailure(err instanceof Error ? err.message : String(err)).catch(() => {});
}

const discoveredKeys = new Set<string>();
Expand Down Expand Up @@ -2065,8 +2071,12 @@ export class CrankService {
});
}
}
// BUG-110: the cycle (discovery + crank pass) completed without
// throwing — record so /health's monitors.scan reflects real outcomes.
monitors.scan.recordSuccess().catch(() => {});
} catch (err) {
logger.error("Crank cycle failed", { error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined });
monitors.scan.recordFailure(err instanceof Error ? err.message : String(err)).catch(() => {});
Comment on lines +2074 to +2079

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Don’t treat “didn’t throw” as a successful scan cycle.

Lines 1122-1145 and 1157-1168 inside discover() log per-program scan failures and keep going, so this block can still hit recordSuccess() even when discovery actually failed across the cycle. That leaves /health.monitors.scan green during real scan outages. Base the scan monitor on an explicit cycle result from discover()/crankAll(), not just the absence of an exception.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/crank.ts` around lines 2074 - 2079, The scan monitor success
path in crank.ts is incorrectly tied only to the absence of a thrown exception,
so a cycle with logged per-program failures can still call
monitors.scan.recordSuccess(). Update the discover()/crankAll() flow to return
or propagate an explicit cycle result that reflects whether any scan failed,
then use that result in the Crank cycle handling block before calling
recordSuccess or recordFailure. Keep the existing logger.error and monitors.scan
methods, but make the success decision based on the explicit outcome instead of
the try/catch alone.

} finally {
this._cycling = false;
// H4: disarm the watchdog on natural recovery so a transient slow
Expand Down
22 changes: 18 additions & 4 deletions src/services/oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import { eventBus, createLogger, getErrorMessage, sendWarningAlert, sendCriticalAlert } from "@percolatorct/shared";
import { isMainnet } from "../config/network.js";
import { oraclePushCountTotal, oracleStalenessSeconds } from "../lib/metrics.js";
import { monitors } from "../lib/service-monitors.js";

const logger = createLogger("keeper:oracle");

Expand Down Expand Up @@ -184,8 +185,14 @@ export class OracleService {
});
clearTimeout(timeoutId);

if (!res.ok) return null;

// BUG-110: record real connectivity outcomes so /health's monitors.oracle
// reflects whether the external price feeds are actually reachable.
if (!res.ok) {
monitors.oracle.recordFailure(`DexScreener HTTP ${res.status}`).catch(() => {});
return null;
}
monitors.oracle.recordSuccess().catch(() => {});

Comment on lines +188 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Avoid racing two upstreams into one oracle monitor.

fetchPrice() / peekPrice() run DexScreener and Jupiter in parallel, but these branches all mutate the same monitors.oracle lane. If one source fails and the other succeeds, whichever finishes last wins, so /health.monitors.oracle becomes nondeterministic and can hide a degraded single-source state. Record the monitor once after both requests settle, or split the sources into separate lanes.

Also applies to: 228-228, 262-268, 284-284

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/oracle.ts` around lines 188 - 195, The `monitors.oracle` updates
in `fetchPrice()` and `peekPrice()` are racing because both the DexScreener and
Jupiter branches call `recordSuccess()`/`recordFailure()` independently, so the
last finisher overwrites the health state. Refactor the oracle health reporting
so it is written once after both upstream requests settle, or route DexScreener
and Jupiter to separate monitor lanes; use the `monitors.oracle` calls in
`fetchPrice`, `peekPrice`, and any shared upstream helper logic to keep the
health status deterministic.

const json = (await res.json()) as DexScreenerResponse;

// M7: Validate BEFORE caching — don't cache bad responses that would
Expand Down Expand Up @@ -218,6 +225,7 @@ export class OracleService {
mint,
error: err instanceof Error ? err.message : String(err),
});
monitors.oracle.recordFailure(err instanceof Error ? err.message : String(err)).catch(() => {});
return null;
}
}
Expand Down Expand Up @@ -251,8 +259,13 @@ export class OracleService {
});
clearTimeout(timeoutId);

if (!res.ok) return null;

// BUG-110: see fetchDexScreenerPrice — same connectivity signal for Jupiter.
if (!res.ok) {
monitors.oracle.recordFailure(`Jupiter HTTP ${res.status}`).catch(() => {});
return null;
}
monitors.oracle.recordSuccess().catch(() => {});

const json = (await res.json()) as JupiterResponse;
const priceStr = json.data?.[mint]?.price;
if (!priceStr) return null;
Expand All @@ -268,6 +281,7 @@ export class OracleService {
mint,
error: err instanceof Error ? err.message : String(err),
});
monitors.oracle.recordFailure(err instanceof Error ? err.message : String(err)).catch(() => {});
return null;
}
}
Expand Down
5 changes: 5 additions & 0 deletions tests/services/crank-error-code.poc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ vi.mock("@percolatorct/shared", () => ({
sendWithRetryKeeper: vi.fn(),
eventBus: { publish: vi.fn() },
getSupabase: vi.fn(),
// BUG-110: src/lib/service-monitors.ts calls this at import time.
createServiceMonitors: vi.fn(() => {
const m = () => ({ recordSuccess: vi.fn(async () => {}), recordFailure: vi.fn(async () => {}), getErrorRate: vi.fn(() => 0), getStatus: vi.fn(() => ({ healthy: true, consecutiveFailures: 0, errorRate: 0, timeSinceSuccessMs: 0, alertActive: false })) });
return { rpc: m(), scan: m(), oracle: m(), db: m() };
}),
}));
vi.mock("../../src/lib/keeper-send.js", async () => {
const { KeeperBudget } = await vi.importActual<typeof import("../../src/lib/budget.js")>("../../src/lib/budget.js");
Expand Down
5 changes: 5 additions & 0 deletions tests/services/crank-hyperp-detection.poc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ vi.mock("@percolatorct/shared", () => ({
sendWithRetryKeeper: vi.fn(),
eventBus: { publish: vi.fn() },
getSupabase: vi.fn(),
// BUG-110: src/lib/service-monitors.ts calls this at import time.
createServiceMonitors: vi.fn(() => {
const m = () => ({ recordSuccess: vi.fn(async () => {}), recordFailure: vi.fn(async () => {}), getErrorRate: vi.fn(() => 0), getStatus: vi.fn(() => ({ healthy: true, consecutiveFailures: 0, errorRate: 0, timeSinceSuccessMs: 0, alertActive: false })) });
return { rpc: m(), scan: m(), oracle: m(), db: m() };
}),
}));
vi.mock("../../src/lib/keeper-send.js", async () => {
const { KeeperBudget } = await vi.importActual<typeof import("../../src/lib/budget.js")>("../../src/lib/budget.js");
Expand Down
5 changes: 5 additions & 0 deletions tests/services/crank.b-fixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ vi.mock("@percolatorct/shared", () => ({
})),
})),
eventBus: { publish: vi.fn() },
// BUG-110: src/lib/service-monitors.ts calls this at import time.
createServiceMonitors: vi.fn(() => {
const m = () => ({ recordSuccess: vi.fn(async () => {}), recordFailure: vi.fn(async () => {}), getErrorRate: vi.fn(() => 0), getStatus: vi.fn(() => ({ healthy: true, consecutiveFailures: 0, errorRate: 0, timeSinceSuccessMs: 0, alertActive: false })) });
return { rpc: m(), scan: m(), oracle: m(), db: m() };
}),
}));

// After the #119 merge, crank.ts routes sends through keeperSend (not shared.sendWithRetryKeeper
Expand Down
5 changes: 5 additions & 0 deletions tests/services/crank.processBatched.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ vi.mock("@percolatorct/shared", () => ({
from: vi.fn(() => ({ select: vi.fn(() => ({ in: vi.fn(() => ({ data: [], error: null })) })) })),
})),
eventBus: { publish: vi.fn() },
// BUG-110: src/lib/service-monitors.ts calls this at import time.
createServiceMonitors: vi.fn(() => {
const m = () => ({ recordSuccess: vi.fn(async () => {}), recordFailure: vi.fn(async () => {}), getErrorRate: vi.fn(() => 0), getStatus: vi.fn(() => ({ healthy: true, consecutiveFailures: 0, errorRate: 0, timeSinceSuccessMs: 0, alertActive: false })) });
return { rpc: m(), scan: m(), oracle: m(), db: m() };
}),
}));

import { processBatched } from "../../src/services/crank.js";
Expand Down
179 changes: 138 additions & 41 deletions tests/services/crank.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,50 +55,65 @@ vi.mock('../../src/lib/v17-risk.js', () => ({
})),
}));

vi.mock('@percolatorct/shared', () => ({
config: {
crankIntervalMs: 30000,
crankInactiveIntervalMs: 120000,
discoveryIntervalMs: 300000,
allProgramIds: ['11111111111111111111111111111111', 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'],
crankKeypair: 'mock-keypair-path',
},
createLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
getConnection: vi.fn(() => ({
getAccountInfo: vi.fn(),
getSlot: vi.fn().mockResolvedValue(200),
})),
getFallbackConnection: vi.fn(() => ({
getProgramAccounts: vi.fn(),
})),
loadKeypair: vi.fn(() => ({
publicKey: {
toBase58: () => '11111111111111111111111111111111',
// Use string-based equality so foreign oracle authorities correctly return false.
equals: (other: any) => other?.toBase58?.() === '11111111111111111111111111111111',
vi.mock('@percolatorct/shared', () => {
const makeMonitor = () => ({
recordSuccess: vi.fn(async () => {}),
recordFailure: vi.fn(async () => {}),
getErrorRate: vi.fn(() => 0),
getStatus: vi.fn(() => ({ healthy: true, consecutiveFailures: 0, errorRate: 0, timeSinceSuccessMs: 0, alertActive: false })),
});
return {
config: {
crankIntervalMs: 30000,
crankInactiveIntervalMs: 120000,
discoveryIntervalMs: 300000,
allProgramIds: ['11111111111111111111111111111111', 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'],
crankKeypair: 'mock-keypair-path',
},
secretKey: new Uint8Array(64),
})),
sendWithRetry: vi.fn(async () => 'mock-signature-' + Date.now()),
sendWithRetryKeeper: vi.fn(async () => 'mock-keeper-sig-' + Date.now()),
rateLimitedCall: vi.fn((fn) => fn()),
sendCriticalAlert: vi.fn(),
getSupabase: vi.fn(() => ({
from: vi.fn(() => ({
select: vi.fn(() => ({
in: vi.fn(() => ({ data: [], error: null })),
createLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
getConnection: vi.fn(() => ({
getAccountInfo: vi.fn(),
getSlot: vi.fn().mockResolvedValue(200),
})),
getFallbackConnection: vi.fn(() => ({
getProgramAccounts: vi.fn(),
})),
loadKeypair: vi.fn(() => ({
publicKey: {
toBase58: () => '11111111111111111111111111111111',
// Use string-based equality so foreign oracle authorities correctly return false.
equals: (other: any) => other?.toBase58?.() === '11111111111111111111111111111111',
},
secretKey: new Uint8Array(64),
})),
sendWithRetry: vi.fn(async () => 'mock-signature-' + Date.now()),
sendWithRetryKeeper: vi.fn(async () => 'mock-keeper-sig-' + Date.now()),
rateLimitedCall: vi.fn((fn) => fn()),
sendCriticalAlert: vi.fn(),
getSupabase: vi.fn(() => ({
from: vi.fn(() => ({
select: vi.fn(() => ({
in: vi.fn(() => ({ data: [], error: null })),
})),
})),
})),
})),
eventBus: {
publish: vi.fn(),
},
}));
eventBus: {
publish: vi.fn(),
},
// BUG-110: src/lib/service-monitors.ts calls this at import time.
createServiceMonitors: vi.fn(() => ({
rpc: makeMonitor(),
scan: makeMonitor(),
oracle: makeMonitor(),
db: makeMonitor(),
})),
};
});

vi.mock('../../src/lib/keeper-send.js', async () => {
const { KeeperBudget } = await vi.importActual<typeof import('../../src/lib/budget.js')>('../../src/lib/budget.js');
Expand All @@ -113,6 +128,7 @@ import { CrankService } from '../../src/services/crank.js';
import * as core from '@percolatorct/sdk';
import * as shared from '@percolatorct/shared';
import * as keeperSendModule from '../../src/lib/keeper-send.js';
import { monitors } from '../../src/lib/service-monitors.js';

const MOCK_PORTFOLIO = new PublicKey('9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin');

Expand Down Expand Up @@ -183,6 +199,87 @@ describe('CrankService', () => {
expect(crankService.getMarkets().size).toBe(1);
});

// BUG-110: monitors.db was never wired to a real outcome -- /health's
// monitors.db sub-object was permanently-green placeholder data.
it('BUG-110: records monitors.db success on a clean Supabase query', async () => {
vi.mocked(core.discoverMarkets).mockResolvedValue([] as any);

await crankService.discover();

expect(monitors.db.recordSuccess).toHaveBeenCalled();
expect(monitors.db.recordFailure).not.toHaveBeenCalled();
});

it('BUG-110: records monitors.db failure on a Supabase query error', async () => {
vi.mocked(core.discoverMarkets).mockResolvedValue([] as any);
vi.mocked(shared.getSupabase).mockReturnValueOnce({
from: vi.fn(() => ({
select: vi.fn(() => ({
in: vi.fn(() => ({ data: null, error: { message: 'connection refused' } })),
})),
})),
} as any);

await crankService.discover();

expect(monitors.db.recordFailure).toHaveBeenCalledWith('connection refused');
expect(monitors.db.recordSuccess).not.toHaveBeenCalled();
});

it('BUG-110: records monitors.db failure when the Supabase call throws', async () => {
vi.mocked(core.discoverMarkets).mockResolvedValue([] as any);
vi.mocked(shared.getSupabase).mockImplementationOnce(() => {
throw new Error('supabase client init failed');
});

await crankService.discover();

expect(monitors.db.recordFailure).toHaveBeenCalledWith('supabase client init failed');
expect(monitors.db.recordSuccess).not.toHaveBeenCalled();
});

});

// BUG-110: monitors.scan was never wired to a real outcome -- /health's
// monitors.scan sub-object was permanently-green placeholder data.
describe('BUG-110: monitors.scan wiring in the periodic cycle', () => {
it('records success after a cycle completes without throwing', async () => {
const mockMarket = {
slabAddress: { toBase58: () => 'MarketMonScan111111111111111111111111111' },
programId: { toBase58: () => '11111111111111111111111111111111' },
config: {
collateralMint: { toBase58: () => 'Mint1111111111111111111111111111111111' },
oracleAuthority: { toBase58: () => 'Oracle11111111111111111111111111111111', equals: () => false },
indexFeedId: { toBytes: () => new Uint8Array(32) },
},
params: { maintenanceMarginBps: 500n, initialMarginBps: 1000n },
header: { admin: { toBase58: () => 'Admin111111111111111111111111111111111' } },
};
vi.mocked(core.discoverMarkets).mockResolvedValue([mockMarket] as any);
const svc = new CrankService(mockOracleService, 1_000);
// Populate markets + lastDiscoveryTime under REAL timers first, so the
// periodic tick below takes the "markets pre-loaded" path and sees
// needsDiscovery===false -- avoiding crank.ts's real inter-program
// discovery delay (a setTimeout) while fake timers are active, which
// would otherwise hang this test and leak fake timers into every test
// that runs after it in this file.
await svc.discover();
vi.mocked(core.discoverMarkets).mockClear();

vi.useFakeTimers();
try {
await svc.start();
await vi.advanceTimersByTimeAsync(1_100);
expect(monitors.scan.recordSuccess).toHaveBeenCalled();
expect(monitors.scan.recordFailure).not.toHaveBeenCalled();
} finally {
svc.stop();
vi.useRealTimers();
}
});
});

describe('discover (LaserStream)', () => {
it('LaserStream fast-path retries v17 keeper portfolio provisioning when keeperPortfolio is null', async () => {
const prevLaserStream = process.env.KEEPER_USE_LASERSTREAM;
process.env.KEEPER_USE_LASERSTREAM = 'true';
Expand Down
Loading
Loading