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
20 changes: 17 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
- name: cargo deny (advisories, licenses, bans, sources)
working-directory: contracts
run: cargo deny check
# scripts/volume-bot is deliberately untracked (.git/info/exclude) — its
# lockfile never exists in a CI checkout, so it cannot be scanned here.
- name: osv-scanner (Cargo.lock + package-lock.json)
run: |
curl -sSL -o osv-scanner \
Expand All @@ -43,8 +45,7 @@ jobs:
--lockfile package-lock.json \
--lockfile contracts/Cargo.lock \
--lockfile web/package-lock.json \
--lockfile scripts/keeper/package-lock.json \
--lockfile scripts/volume-bot/package-lock.json
--lockfile scripts/keeper/package-lock.json

scout:
name: Scout (Soroban detector suite, weekly report)
Expand All @@ -56,11 +57,24 @@ jobs:
with:
workspaces: contracts
cache-all-crates: true
# cargo-scout-audit does its toolchain setup (nightly + rust-src/
# llvm-tools/rustc-dev + dylint-link) in its build.rs, which only runs
# when the crate is COMPILED. On a rust-cache hit the restored binary
# makes `cargo install` a no-op, ~/.rustup starts empty, and rustup
# bare-auto-installs the nightly WITHOUT rustc-dev → E0463 "can't find
# crate for rustc_*" on every warm-cache run (2026-08-24). Install it
# explicitly. The date is scout 0.3.16's pinned detector toolchain —
# keep in sync with TOOLCHAIN in its build.rs if scout is ever bumped.
- name: Install detector toolchain (scout's build.rs skips it on warm cache)
run: rustup toolchain install nightly-2025-08-07 --profile minimal --component rust-src --component llvm-tools --component rustc-dev
- name: Install cargo-scout-audit
run: cargo install cargo-scout-audit --locked
# --verbose matters: without it scout builds its detector suite with
# stderr piped to /dev/null, so a detector-build failure is undiagnosable
# from the CI log (seen 2026-08-24).
- name: Run scout
working-directory: contracts
run: cargo scout-audit -o json --output-path ../scout-report.json
run: cargo scout-audit --verbose -o json --output-path ../scout-report.json
- uses: actions/upload-artifact@v4
with:
name: scout-report-${{ github.run_number }}
Expand Down
10 changes: 1 addition & 9 deletions api/src/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isMissingTable, type Db } from '@noether/db';
import { resolvedContracts, type ContractKey, type ContractsManifest } from '@noether/shared';
import type { PauseStateService } from '../services/pauseState.js';
import { TtlCache } from '../services/cache.js';
import { withTimeout } from '../services/timeout.js';

/** How long one sampled counts block serves /v1/health hits. Uptime probes
* poll this route continuously; without a cache every hit costs an RPC
Expand Down Expand Up @@ -43,15 +44,6 @@ interface CountsBlock {
drift: boolean | null;
}

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms).unref?.(),
),
]);
}

export async function registerHealthRoutes(app: FastifyInstance, deps?: HealthDeps): Promise<void> {
const countsCache = new TtlCache<CountsBlock>(COUNTS_TTL_MS);
app.get(
Expand Down
75 changes: 72 additions & 3 deletions api/src/routes/markets.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,68 @@
import type { FastifyInstance } from 'fastify';
import type { MarketsService } from '../services/markets.js';
import type { StatsService } from '../services/stats.js';
import type { CapacityService } from '../services/capacity.js';

interface AssetParam {
asset: string;
}

const BINDING_ENUM = ['aggregate', 'side', 'skew', 'liquidity', 'maxPosition'] as const;

/** L1-13 pool-capacity headroom — advisory; omitted when the chain read failed. */
const CAPACITY_SCHEMA = {
type: 'object',
description:
'Largest notional the vault accepts for a new long/short on this market right now, ' +
'with the exact chain inputs behind it (vault caps + market AssetExposure). ' +
'Advisory: the contract stays the authority (#82 / #89). Omitted when the read failed.',
properties: {
headroomLong: { type: 'string' },
headroomShort: { type: 'string' },
bindingLong: { type: 'string', enum: BINDING_ENUM },
bindingShort: { type: 'string', enum: BINDING_ENUM },
oiLong: { type: 'string' },
oiShort: { type: 'string' },
netSkew: { type: 'string' },
sideCap: { type: 'string' },
skewCap: { type: 'string' },
assetCapBps: { type: 'integer' },
capAbs: { type: 'string' },
skewCapBps: { type: 'integer' },
maxPositionSize: { type: ['string', 'null'] },
},
required: [
'headroomLong', 'headroomShort', 'bindingLong', 'bindingShort',
'oiLong', 'oiShort', 'netSkew', 'sideCap', 'skewCap',
'assetCapBps', 'capAbs', 'skewCapBps', 'maxPositionSize',
],
} as const;

const POOL_SCHEMA = {
type: 'object',
description:
'Vault-wide capacity (L1-13): AUM, reserved payouts, the aggregate cap and the headroom ' +
'left for new positions on ANY market. Omitted when the read failed; stale:true when served ' +
'from the last good snapshot after a failed refresh.',
properties: {
aum: { type: 'string' },
reservedPayout: { type: 'string' },
usdcBalance: { type: 'string' },
shortfallReserve: { type: 'string' },
reserveCapBps: { type: 'integer' },
reserveCap: { type: 'string' },
aggregateHeadroom: { type: 'string' },
aggregateBinding: { type: 'string', enum: ['aggregate', 'liquidity'] },
asOfLedger: { type: ['integer', 'null'] },
ts: { type: 'integer' },
stale: { type: 'boolean' },
},
required: [
'aum', 'reservedPayout', 'usdcBalance', 'shortfallReserve', 'reserveCapBps', 'reserveCap',
'aggregateHeadroom', 'aggregateBinding', 'asOfLedger', 'ts', 'stale',
],
} as const;

const ASSET_STATS_SCHEMA = {
type: 'object',
properties: {
Expand All @@ -15,6 +72,7 @@ const ASSET_STATS_SCHEMA = {
openInterestNet: { type: 'string' },
openPositions: { type: 'integer' },
volume24h: { type: 'string' },
capacity: CAPACITY_SCHEMA,
},
required: [
'asset',
Expand All @@ -30,6 +88,7 @@ export async function registerMarketsRoutes(
app: FastifyInstance,
service: MarketsService,
stats: StatsService,
capacity?: CapacityService,
): Promise<void> {
app.get(
'/v1/markets/stats',
Expand All @@ -40,13 +99,15 @@ export async function registerMarketsRoutes(
'24h traded volume (position_opened + realized position_closed / liquidated notional). ' +
'All amounts are i128 decimal strings with 7-decimal USDC precision. The solvency object ' +
'carries market-scoped lifetime bad debt (L0-2): how much the insurance buffer absorbed ' +
'vs how much fell through to LP NAV.',
'vs how much fell through to LP NAV. Each row may carry a `capacity` block and the response a ' +
'`pool` block (L1-13 headroom, chain-read; omitted — never zeroed — when the read failed).',
tags: ['markets'],
response: {
200: {
type: 'object',
properties: {
stats: { type: 'array', items: ASSET_STATS_SCHEMA },
pool: POOL_SCHEMA,
solvency: {
type: 'object',
properties: {
Expand All @@ -63,8 +124,16 @@ export async function registerMarketsRoutes(
},
},
async (_req, reply) => {
const [assetStats, solvency] = await Promise.all([stats.marketStats(), stats.solvencyStats()]);
return reply.send({ stats: assetStats, solvency });
const [assetStats, solvency, snapshot] = await Promise.all([
stats.marketStats(),
stats.solvencyStats(),
capacity ? capacity.snapshot() : Promise.resolve(null),
]);
const rows = assetStats.map((s) => {
const c = snapshot?.assets[s.asset];
return c ? { ...s, capacity: c } : s;
});
return reply.send(snapshot ? { stats: rows, pool: snapshot.pool, solvency } : { stats: rows, solvency });
},
);

Expand Down
13 changes: 11 additions & 2 deletions api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { LiveTailer } from './services/liveTailer.js';
import { StatsService } from './services/stats.js';
import { AdlQueueService } from './services/adlQueue.js';
import { ShortfallService } from './services/shortfall.js';
import { CapacityService } from './services/capacity.js';
import { createIndexerDb } from './services/indexerDb.js';
import { getNetworkPassphrase } from '@noether/shared';
import { authPlugin } from './plugins/auth.js';
Expand Down Expand Up @@ -75,6 +76,8 @@ export interface ServerDeps {
stats: StatsService;
adlQueue: AdlQueueService;
shortfall: ShortfallService;
/** L1-13 pool-capacity headroom folded into /v1/markets/stats. */
capacity: CapacityService;
/** L0-15 pause-state probe for /v1/health — optional in test setups. */
pauseState?: PauseStateService;
/** Chain reader for the /v1/health open-count drift alarm (Phase 4) —
Expand Down Expand Up @@ -172,7 +175,7 @@ export async function buildServer(config: ApiConfig, depsOverride?: ServerDeps):
marketId: config.contracts.contracts.market,
}),
);
await app.register((instance) => registerMarketsRoutes(instance, deps.markets, deps.stats));
await app.register((instance) => registerMarketsRoutes(instance, deps.markets, deps.stats, deps.capacity));
await app.register((instance) => registerOracleRoutes(instance, deps.oracle));
await app.register((instance) =>
registerOracleHealthRoutes(instance, {
Expand Down Expand Up @@ -270,5 +273,11 @@ function buildDefaultDeps(config: ApiConfig, log: import('pino').Logger): Server
rpcUrl: config.rpcUrl,
});
const shortfall = new ShortfallService(reader, config.contracts.contracts.vault);
return { oracle, markets, events, apiKeys, walletAuth, access, accessWalletAuth, turnstile, approvalEmailer, rateLimiter, db, orders, tx, wsBus, wsManager, oracleTicker, liveTailer, vaults, referral, stats, adlQueue, shortfall, pauseState, reader };
// L1-13: chain-read capacity headroom (vault views + market AssetExposure).
const capacity = new CapacityService({
reader,
vaultId: config.contracts.contracts.vault ?? '',
marketId: config.contracts.contracts.market ?? '',
});
return { oracle, markets, events, apiKeys, walletAuth, access, accessWalletAuth, turnstile, approvalEmailer, rateLimiter, db, orders, tx, wsBus, wsManager, oracleTicker, liveTailer, vaults, referral, stats, adlQueue, shortfall, capacity, pauseState, reader };
}
Loading
Loading