Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/deploy-keeper.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ permissions:
packages: write

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GHCR_REGISTRY: ghcr.io
GHCR_IMAGE: ghcr.io/lumerin-protocol/collateral-margin-keeper

Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/keeper-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ defaults:
permissions:
contents: read

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
test:
name: Test
Expand Down Expand Up @@ -88,6 +91,6 @@ jobs:
- name: Integration tests
working-directory: ./keeper
env:
PERPS_REPO: ${{ github.workspace }}/perps/contracts
FUTURES_REPO: ${{ github.workspace }}/futures-marketplace/contracts
PERPS_REPO: ${{ github.workspace }}/perps
FUTURES_REPO: ${{ github.workspace }}/futures-marketplace
run: pnpm test:integration
3 changes: 2 additions & 1 deletion keeper/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ WORKDIR /app

RUN corepack enable

COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches/ patches/
RUN pnpm install --frozen-lockfile --prod

# ── Runtime ───────────────────────────────────────────────────────────────────
Expand Down
30 changes: 26 additions & 4 deletions keeper/scripts/compile-siblings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
* imports — and the keeper just reads the resulting artifact JSON.
*
* Path resolution mirrors `tests/integration/artifacts.ts`:
* PERPS_REPO – defaults to ../../perps
* FUTURES_REPO – defaults to ../../futures-marketplace
* PERPS_REPO – sibling repo root (…/perps); defaults to ../../perps
* FUTURES_REPO – sibling repo root; defaults to ../../futures-marketplace
*
* Each repo's Hardhat project lives in `<repo>/contracts`.
*
* Compilation is skipped when `SKIP_COMPILE_SIBLINGS=1` (used in CI when
* the artifacts have already been built upstream and committed).
Expand All @@ -37,12 +39,32 @@ if (process.env.SKIP_COMPILE_SIBLINGS === "1") {
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "..", "..", "..");

/** Repo root env var → Hardhat package dir (`<root>/contracts`). */
function contractsPackageDir(repoRootEnv: string | undefined, defaultRepoRoot: string): string {
const root = repoRootEnv ?? defaultRepoRoot;
const pkg = resolve(root, "contracts");
if (existsSync(resolve(pkg, "hardhat.config.ts")) || existsSync(resolve(pkg, "hardhat.config.js"))) {
return pkg;
}
// Legacy: env pointed directly at the contracts package.
if (existsSync(resolve(root, "hardhat.config.ts")) || existsSync(resolve(root, "hardhat.config.js"))) {
return root;
}
return pkg;
}

const targets = [
{ name: "collateral-margin", dir: resolve(here, "..", "..", "contracts") },
{ name: "perps", dir: process.env.PERPS_REPO ?? resolve(workspaceRoot, "perps", "contracts") },
{
name: "perps",
dir: contractsPackageDir(process.env.PERPS_REPO, resolve(workspaceRoot, "perps")),
},
{
name: "futures-marketplace",
dir: process.env.FUTURES_REPO ?? resolve(workspaceRoot, "futures-marketplace", "contracts"),
dir: contractsPackageDir(
process.env.FUTURES_REPO,
resolve(workspaceRoot, "futures-marketplace"),
),
},
];

Expand Down
2 changes: 1 addition & 1 deletion keeper/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pino from "pino";
import { CollateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts";
import { serializeError } from "../../market-maker/src/core/errSerializer.ts";
import { serializeError } from "./util/errSerializer.ts";
import { loadConfig } from "./config.ts";
import { createChain } from "./chain.ts";
import { ParticipantTracker } from "./discovery/tracker.ts";
Expand Down
96 changes: 96 additions & 0 deletions keeper/src/util/errSerializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* viem errors nest 4-5 cause levels deep, and every level re-stringifies the
* full multicall calldata into its `message`, `stack`, and `metaMessages`.
* Naively serializing with `pino.stdSerializers.errWithCause` produces tens
* of KB of duplicated hex per failed call.
*
* This serializer instead walks the cause chain once and emits a flat,
* minimal payload: `name`, `message` (preferring viem's `shortMessage`), the
* decoded custom error (`errorName`, e.g. `"FailedCall"`), a trimmed `data`
* hex selector/blob, and a single frames-only `stack` from the top error.
*/

const MAX_DATA_LEN = 200;

function isObj(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object";
}

function* walkCauses(err: unknown): Generator<Record<string, unknown>> {
const seen = new Set<unknown>();
let cur: unknown = err;
while (isObj(cur) && !seen.has(cur)) {
seen.add(cur);
yield cur;
cur = (cur as Record<string, unknown>).cause;
}
}

function pickString(o: Record<string, unknown>, k: string): string | undefined {
const v = o[k];
return typeof v === "string" ? v : undefined;
}

function firstLine(s: string): string {
const idx = s.indexOf("\n");
return idx === -1 ? s : s.slice(0, idx);
}

function shortMessageOf(lvl: Record<string, unknown>): string | undefined {
const sm = pickString(lvl, "shortMessage");
if (sm) return sm;
const m = pickString(lvl, "message");
return m === undefined ? undefined : firstLine(m);
}

function stackFrames(stack: unknown): string {
if (typeof stack !== "string") return "";
return stack
.split("\n")
.filter((l) => /^\s*at /.test(l))
.join("\n");
}

function trimHex(s: string): string {
return s.length <= MAX_DATA_LEN ? s : `${s.slice(0, MAX_DATA_LEN)}…<+${s.length - MAX_DATA_LEN} chars>`;
}

export function serializeError(err: unknown): Record<string, unknown> {
if (err === null || typeof err !== "object" || !(err instanceof Error)) {
return { raw: err };
}

const chain = [...walkCauses(err)];
const top = chain[0] ?? {};

let errorName: string | undefined;
let data: string | undefined;
for (const lvl of chain) {
if (errorName === undefined && isObj(lvl.data)) {
errorName = pickString(lvl.data as Record<string, unknown>, "errorName");
}
if (data === undefined && typeof lvl.data === "string") {
data = trimHex(lvl.data);
}
if (errorName !== undefined && data !== undefined) break;
}

let stack = "";
for (const lvl of chain) {
stack = stackFrames(lvl.stack);
if (stack) break;
}

const name = pickString(top, "name") ?? err.name ?? "Error";
const message = shortMessageOf(top) ?? "(no message)";

const out: Record<string, unknown> = { name, message };
if (errorName) out.errorName = errorName;
if (data !== undefined) out.data = data;
if (stack) out.stack = stack;
for (const k of ["contractAddress", "functionName", "sender", "tenderlyUrl"] as const) {
const v = pickString(top, k);
if (v) out[k] = v;
}
return out;
}
Loading