Skip to content

Feat/audit phase0 1 security - #32

Open
mertcicekci0 wants to merge 34 commits into
mainfrom
feat/audit-phase0-1-security
Open

Feat/audit phase0 1 security#32
mertcicekci0 wants to merge 34 commits into
mainfrom
feat/audit-phase0-1-security

Conversation

@mertcicekci0

@mertcicekci0 mertcicekci0 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Tranche / Phase

Type of Change

  • Feature (feat)
  • Bug fix (fix)
  • Documentation (docs)
  • Refactor (refactor)
  • Test (test)
  • Chore / tooling (chore, ci, build)
  • Performance (perf)
  • Breaking change

Scope

  • contracts/* (Soroban)
  • indexer/
  • api/
  • sdk-ts/
  • sdk-py/
  • packages/types/
  • packages/shared/
  • web/
  • scripts/keeper/
  • docs/ / root config

Changes

Test Plan

  • Unit tests pass (<command>)
  • Integration tests pass (<command>)
  • Manual verification (describe)

Contract / WASM Checks

  • cargo test passes
  • cargo build --release --target wasm32-unknown-unknown succeeds
  • Market WASM size ≤ 64 KB
  • New contract (if any) added to contracts.json

Breaking Changes

None.

Screenshots / Recording

Checklist

  • Branch follows naming: feature/t2-p<N>-<slug>, fix/t2-<slug>, or hotfix/<slug>
  • Commits follow Conventional Commits (enforced by commit-msg hook)
  • Self-reviewed the diff
  • Updated relevant docs
  • CI is green

y4hyya and others added 19 commits June 10, 2026 00:29
…market RPC scan

Personal mode fetched positions with getPositions(): get_all_position_ids
(EVERY position in the market) then a get_position per id over the
rate-limited public RPC, filtering to the trader client-side. Cost grows
with TOTAL market size, so as more traders opened positions the list —
and the post-open refresh — got noticeably slower.

Switch personal mode to the same fast path leader mode already uses: ask
the indexer-backed /v1/positions/open?trader= for just this trader's open
ids (constant-time), then hydrate on-chain detail for that short list via
getPositionsByIds. Fall back to the full contract scan only if the API
call errors, so a degraded indexer never hides positions; an empty-but-OK
response is trusted as "no open positions" (same as leader mode).

Because the API lags a beat behind a just-submitted trade, add a staggered
post-trade refetch (now + 2.5s + 6s) so a freshly opened position appears
and a freshly closed one drops without a manual refresh (read-your-writes).

Verified prod API live and projecting current-stack data. Audit W-2 /
TASKS P4-14 (positions portion).
…, not just on error

The personal-mode fast path trusted an empty-but-OK /v1/positions/open
response as "no open positions". But the shared API indexes the
PRODUCTION market, so on staging (different market) it always returns
empty — making freshly opened positions invisible in the Positions tab
even though they're on-chain. The same empty also occurs in the brief
window after opening before the indexer projects the new position.

Fall back to the full contract scan whenever the API yields nothing
(empty OR error), not only on error. Fast whenever the API has the
trader's positions; otherwise exactly the old whole-market scan — never
slower, never hides a position the chain has. Fixes the regression from
4c5788d on staging.
Real wallet balances (P0-6), SSE price-staleness badge + shim fallback (P0-9),
debug-log gating + decodeContractError (P0-7), ConfigGuard with required
NOERACLE_SHIM + router banner (P0-8), and corrected referral copy + resolveCode
(P0-5/P0-4).
Filter events_raw by trader in SQL via EventsService.listByTrader instead of
scanning a global recent-events window, so an active trader past the window
still sees their positions/orders (P0-12). Adds a regression test.
Per-event try/catch routes failures to a dead_letter table and keeps the cursor
advancing (P0-15); skip applyEvent + bus emit on duplicate persistRaw and make
code_created ON CONFLICT preserve counters (P0-16); fix decodeCrossLiq field
mapping and clean up phantom open positions on cross_liq (P0-17).
Pause/unpause + upgrade entry points (P1-1); cross-margin SL/TP guard + zombie
cancel (P1-2); aggregate reserve cap + non-reverting winner settle with
shortfall accounting (P1-3); unrealized PnL -> vault NAV push (P1-4); oracle
deviation + staleness guard, halt-open/allow-close (P1-5); shared TTL constants
(P1-6); reduce-only closes the opposing position (P1-7); loss capped at
collateral (P1-8); expanded vault suite (P1-9); protocol fee share (P1-10);
shared symbol_to_tag (P1-11); router publisher allowlist (P2-4).
Run on staging; new jobs for cargo test/clippy (build vault.wasm first for the
contractimport), web tsc, and sdk-py pytest; block direct commits to staging
in the pre-commit hook (P0-18).
Refuse to boot when API_HMAC_PEPPER is unset/default, API_CORS_ORIGIN is unset
or "*", or the API_KEY_ALLOWLIST is empty (opt out via API_ALLOW_OPEN_ISSUANCE)
— P0-13/P0-14. Also reject the leaderboard cron with 500 when CRON_SECRET is
unset so it never accepts a literal "Bearer undefined". Documents all four in
.env.example.
Mirror close_with_price so the keeper can liquidate a position or execute a
triggered order against a freshly-verified Noeracle price — even when the
on-chain heartbeat slot is >60s stale (O-3, K-3). Adds router tests.
15s rpc.Server HTTP timeout; a 3-min watchdog that exits(1) for supervisor
restart if no cycle completes; optional Discord/Slack webhook alerts on
startup, 5-error streak, watchdog trip, and shutdown (ALERT_WEBHOOK_URL,
WATCHDOG_MS). Prod should run start:prod (node dist/index.js).
Persist last-pushed prices to a state file so the jump circuit breaker survives
restarts (loaded on boot); tighten the breaker to per-asset bounds (BTC/ETH 10%,
XLM 15%); sanity-check each attestation against an independent Binance ticker
before pushing and skip+alert on >3% divergence (best-effort, never blocks on a
Binance outage).
…mainnet (K-8)

Drop the 8-char secret-key fragment from the startup log (the keeper address is
logged separately from the derived pubkey), and hard-fail when NETWORK=mainnet
falls back to ADMIN_SECRET_KEY — mainnet requires a dedicated KEEPER_SECRET_KEY.
… (O-7)

Reject a non-positive or absurdly large price (>1e18) before relaying it to
Noeracle, as a cheap backstop against a glitch reaching the on-chain oracle.
…eview

Settlement conservation (V-3): unify isolated-close on a single conservation-safe
settle_and_close helper that reserves keeper fee + positive funding out of
collateral BEFORE capping the loss, so loss+funding+fee can never draw on other
positions' pooled collateral. Fixes the CRITICAL gap where execute_close_order
(keeper SL/TP/trailing close) transferred the full uncapped mark loss; applies the
same cap to close_position_cross.

Oracle (M-2): SL/TP/trailing execution + should_execute_order now use the lenient
close-price reader so protective exits aren't blocked by the deviation/staleness
breaker exactly when they matter; limit entries stay strict. Coarse 50% catch-all
bound when the deviation baseline is stale so a quiet window can't disarm it.

Orders: execute_order only finalizes Executed when no sub-path set a terminal
status (reduce-only-no-position stays Cancelled, no double event); pause now gates
the limit-entry OPEN path (close/reduce-only stay exempt); execute_close_order
cancels the sibling SL/TP via cancel_linked_position_orders (no zombie).

Keeper: jump breaker only fires as a fallback when the independent ticker is
unavailable, so a Binance-corroborated fast move can't permanently freeze the feed
(K-2); K-8 mainnet guard normalizes NETWORK and keys off the passphrase so a
mis-cased/padded NETWORK or mainnet RPC can't slip the admin key through.

Deploy: pass --publishers (ROUTER_PUBLISHERS_JSON, default []) to router
initialize so the deploy no longer aborts on the new required arg (O-2).

Adds regression tests: execute_close_order_caps_loss_at_collateral, reduce-only
stays Cancelled. 133 contract tests pass, clippy clean.
Verification of the prior fix batch surfaced three issues; all resolved:

- HIGH (conservation): a position owed NEGATIVE funding paid the trader the
  funding credit out of the pooled market balance — unbacked, drawing on other
  traders' collateral. Fix: fold funding INTO the value settled with the vault
  (settle net = pnl - funding), so the vault is the funding counterparty and its
  total_usdc accounts for it. Funding now never flows into/out of the market pool.
  Same fold applied to close_position_cross.
- MEDIUM (accounting): positive funding was raw-transferred to the vault without
  crediting total_usdc (AUM/GLP mispricing) — also resolved by the fold.
- REGRESSION: a keeper-fired SL/TP ended Cancelled (not Executed) because
  settle_and_close cancelled the firing order along with its sibling, then the
  still-Pending guard skipped finalizing it — dropping the order_executed/keeper
  reward signal. Fix: cancel_linked_position_orders takes an exclude id; the
  firing order is excluded and finalized to Executed; the sibling is still cancelled.

Adds regression tests: sl_fire_marks_executed_and_cancels_sibling_tp,
funding_settles_through_vault_not_pool. 135 contract tests pass, clippy clean.
#6 (contract): reduce-only limit/stop-limit orders are risk-OUT — execute_order
and should_execute_order now use the lenient oracle read for them too (bit 8 of
time_in_force), so a protective offset isn't blocked during a deviation window.

#7/#8 (keeper): corroboration now RAISES the jump bound to a hard ceiling
(CORROBORATED_MAX_JUMP_PCT, default 50%) instead of removing the breaker, so a
glitch correlated across Binance + the index can't publish an extreme wick; and a
baseline older than MAX_BASELINE_AGE_MS (default 5m) is treated as expired so the
breaker can never freeze the feed permanently (sustained move / persistent ref
divergence re-seed instead of wedging).

#9 (keeper): the K-8 mainnet guard now also flags an RPC_URL that looks like
mainnet, so the admin key can't target a mainnet RPC under a testnet passphrase.

#10 (keeper): REFERENCE_DIVERGENCE_PCT (and the new bounds) parse through a
validated parseFloatEnv that falls back to a default + clamps, so a bad/empty env
value can't silently disable or invert a guard.

(#3 OI-decrement: the existing 'if total > size {..} else {0}' is already
equivalent to saturating_sub for the non-negative domain — no change needed.)
45 market tests pass, keeper type-clean, clippy clean.
GET /v1/stats — cumulative + rolling-24h opening volume & trade count, open
interest / open positions, unique traders. GET /v1/volume — 24h volume overall
and per asset. Both public (no auth), read-only off the indexer (position_opened
events in events_raw + the positions projection). 3 vitest cases.
…ify)

HIGH (contract): a TrailingStop order was never registered in a per-position slot,
so cancel_linked_position_orders couldn't cancel it — closing a position (SL/TP
fire, user close, or liquidation) left the trailing stop a Pending zombie that the
keeper would retry forever (PositionNotFound). Fix: a new PositionTrailingStop slot
set in place_trailing_stop and cancelled (peak cleaned, honoring the firing-order
exclude) in cancel_linked_position_orders, mirroring SL/TP. +1 regression test.

HIGH (keeper): MAX_BASELINE_AGE_MS now parses through a validated parseIntEnv
(clamped [30s,1h]) so a 0/NaN can't permanently disable or break the breaker.

MEDIUM (keeper): baseline-expiry now WIDENS the jump bound to the hard ceiling
instead of REMOVING it, so an extreme wick can't publish unchecked while Binance is
down + the baseline is stale — that case stays frozen + alerted for an operator
rather than pushing an arbitrary price (safer for a perp DEX).

(LOW cross-leg loss cap: acknowledged, conservation-safe, bounded by account
liquidation — left as a documented deliberate design choice. Persistent reference
divergence is kept as skip+alert by design; the real fix is oracle-layer median,
P2-1.) 136 contract tests pass, keeper type-clean, clippy clean.
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
noether Ready Ready Preview, Comment Jul 3, 2026 11:40am

…e methods (P2-5 tail)

When NEXT_PUBLIC_NOETHER_ROUTER_ID is configured, the keeper now liquidates and
executes orders via the router's liquidate_with_price / execute_with_price — it
refreshes the on-chain price from the cycle's just-signed Noeracle attestation and
acts atomically on it, instead of relying on the heartbeat slot (<=60s stale).
Falls back to direct market.liquidate / execute_order when the router or a fresh
attestation isn't available, so it works before and after the redeploy.
…raise (P5-1, P5-2)

P5-1: new per-asset RiskConfig (max OI long/short, max leverage, maintenance-margin
bps, max position size), admin-settable via set_risk_config (validates bounds +
that MM keeps the liq price inside entry), stored per asset. enforce_open_limits
applies leverage/size/OI caps at all three open paths (open_position,
open_position_cross, execute_limit_entry keeper fill). Per-asset OI counters are
kept in sync via add_oi/drop_oi helpers at every open and close/liquidate site, so
caps stay accurate. Unset assets fall back to uncapped OI (from_market) — existing
behaviour preserved (all prior tests green).

P5-2: isolated liquidation health (should_liquidate_with_funding) + new-position
liq price now read the LIVE per-asset maintenance margin, so an admin MM raise
takes effect on existing positions immediately. The frozen-liq-price fast-path is
kept as a conservative floor.

New error OiCapExceeded (#26). +4 tests (OI cap blocks/frees, unsafe-MM rejected,
MM raise makes a position liquidatable). 140 contract tests pass, clippy clean.
Both faucet mint/claim routes now refuse (403) when NETWORK.NAME isn't testnet, so
the test-asset faucet auto-disables the moment the network constant flips to
mainnet — it can never issue assets against the public network.
NETWORK.{NAME,PASSPHRASE,RPC_URL,HORIZON_URL} now read from NEXT_PUBLIC_* with
testnet defaults, so a mainnet cutover is a build-env change, not a code change —
closing the largest config-parity risk. The faucet fail-close (P6-3) keys off
NAME, so it auto-disables on a mainnet build.
… keeper cache)

HIGH: liquidate_cross_account skipped a leg whose oracle read failed (continue) but
then unconditionally wiped the cross account — permanently leaking that leg's OI
(global + per-asset), which P5-1's per-asset counters turn into an open-path DoS as
the leak drifts toward the cap. Fix: abort the whole liquidation (return PriceStale,
Soroban reverts all state) if ANY leg can't be priced; the keeper retries when the
oracle recovers — no partial wipe, OI stays conserved.

MEDIUM (P5-2 cross tail): cross-margin health used the GLOBAL maintenance margin,
ignoring per-asset RiskConfig — inconsistent with isolated and exploitable when an
admin raises an asset's MM. aggregate_cross_positions now resolves the LIVE
per-asset MM per leg, so a per-asset MM raise tightens cross health too.

MEDIUM (keeper): latestAttestations was cached unconditionally before the
divergence/jump checks, so the router liq/exec path could use a price the keeper
refused to publish. Now cached only after an attestation passes those checks.

140 contract tests pass, clippy clean, keeper type-clean.
close_position_partial(trader, position_id, close_size) settles a fraction of an
isolated position and keeps the residual open, via a new conservation-safe
settle_and_close_partial core: collateral is pro-rated to the closed size rounding
DOWN (residual never shorted by repeated tiny partials), the closed-portion net PnL
(incl. funding) settles with the vault loss-capped at its own collateral, OI +
reservation drop by close_size, and the residual keeps the same collateral/size
ratio so its liquidation price stays valid. Rejects cross positions, non-positive /
oversized close_size, and a residual below the min-collateral dust floor
(PositionTooSmall #27); a full-size request delegates to the normal close. Emits a
distinct position_reduced event. +2 tests. 142 contract tests pass, clippy clean.

Indexer follow-up: handle position_reduced to update the positions projection.
…ion note

Indexer now decodes the position_reduced event (partial close), keeps the position
row and UPDATEs its size to the residual (not delete), and emits position/trade
projections — so /v1/positions/open and the trade panel stay accurate after a
partial close. P5-9 is now complete on-chain + off-chain.

Also documents P5-5 (partial liquidation) as blocked on a product decision: a pure
pro-rata partial can't change the margin ratio, so health-restoring partial liq
needs survivor-recapitalisation (needs the insurance fund P5-6 first). Full
liquidation remains the safe default. See docs/issues/P5-5-partial-liquidation.md.
The vault page rendered a hardcoded 12.5% APY and the trade page a
hardcoded $1.2M OI / $890K 24h volume. Show an em dash until real
values are wired; APY copy states variable/realized-only semantics.
- README: T1/T2 delivered and paid, T3 in progress (final stage);
  guarded-launch plan of record; current contracts.json addresses
  (old market CC2HH34Q... replaced); testnet.noether.exchange links
  fixed to noether.exchange (subdomain no longer resolves); SDK
  publish steps marked done (npm/PyPI noether-sdk 0.1.1)
- TRANCHE2_STATUS: superseded-snapshot banner with executed punch list
- EXECUTION_PLAN: mark P3-4 and P6-3 done (already committed)
Snapshots for the risk-config, OI-cap, partial-close, and trailing-stop
tests added in the P5 sprint; regenerated by a green
'cargo test --workspace' run (all suites passing).
y4hyya added a commit that referenced this pull request Jul 18, 2026
…r position_id

Market-side support for vault_factory fund isolation + NAV (V-1/V-4):
- get_position_equity(position_id): max(0, collateral + uPnL − pending
  funding) at the lenient close price; fail-closed (#20/#32 propagate) so
  the factory reverts rather than mispricing deployed capital.
- execute_limit_entry stamps the created position id onto the executed
  order row (set_order_position_id); the row persists with its final
  status, making reconcile_order trustlessly verifiable. Covers StopLimit
  phase-1 (routes through execute_limit_entry). No struct/event change.

market 96,974 B optimized (+1KB, ~34KB headroom). 2 tests; 137 market pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants