Skip to content

x402 wire-level fix + live Polymarket/Kalshi adapters - #1

Open
johnsmithxy1mmm-sys wants to merge 74 commits into
mainfrom
claude/nice-dirac-tkhe8v
Open

x402 wire-level fix + live Polymarket/Kalshi adapters#1
johnsmithxy1mmm-sys wants to merge 74 commits into
mainfrom
claude/nice-dirac-tkhe8v

Conversation

@johnsmithxy1mmm-sys

Copy link
Copy Markdown
Owner

Summary

Two follow-ups on top of the initial predmarket-mcp server (the base commit on main):

  1. Fix the x402 ASGI middleware so forwarded requests don't hang.
  2. Add real Polymarket/Kalshi adapters behind a CORE_ENGINE flag (mock stays the default).

The MCP surface (7 tools, 1 resource, 1 prompt) is unchanged — this is engine + billing plumbing only.

1. x402 middleware fix (c49cdc7)

The payment middleware buffers the request body to read the tool name, then forwards to the streamable-HTTP handler. The replayed receive emitted synthetic http.request messages once the buffer drained, so the handler — which keeps calling receive() to detect client disconnect — never saw http.disconnect and hung on every forwarded request (paid-with-payment, and free tools in paid mode). Only the 402 paths (which don't forward) worked, so unit tests missed it.

  • Fix: after replaying buffered messages, delegate to the original receive so http.disconnect flows.
  • Strengthened the ASGI test: the downstream now reads the body and asserts it observes the disconnect, catching this regression.
  • Verified end-to-end over real HTTP with PAID_ENABLED=true: 402 without payment, 402 on underpayment, 200 + data with a valid signed X-PAYMENT, free tools bypass; one receipt + one usage record (paid_via=x402) logged.

2. Live venue adapters (bc95622)

  • core/algorithms.py — shared, venue-agnostic intelligence: realizable-edge math (walk depth, apply fees + gas + slippage), a computed cross-venue matcher (token-Jaccard placeholder), and bundle/cross-venue signal detectors. Mock now delegates its edge calc here, so the math is defined once, not duplicated.
  • core/adapters/base.py (single HTTP boundary: every httpx failure maps to AdapterError), polymarket.py (Gamma markets + CLOB book), kalshi.py (public markets + orderbook, cents→prob). Fetch + normalize only; no intelligence.
  • core/live.pyLiveRepo + engine over the adapters, TTL-cached, degrades gracefully (empty results) when a venue is unreachable rather than crashing a tool.
  • deps.pyCORE_ENGINE=mock|live switch (mock default). Nothing else in the MCP layer changes.

Testing

  • uv run pytest29 passed (22 prior + 7 new adapter/live tests).
  • Adapter normalization + live-engine behavior verified via httpx.MockTransport with recorded sample payloads (no network).
  • Mock remains the default, so the offline suite and MCP Inspector run unchanged.

Notes / caveats

  • The venue APIs (gamma-api.polymarket.com, clob.polymarket.com, api.elections.kalshi.com) are not reachable from this sandbox (network policy denies them), so CORE_ENGINE=live was exercised against mocked HTTP, not real endpoints. Run it where egress to those hosts is allowed to hit live data.
  • The matcher is a token-Jaccard placeholder (# TODO: embeddings); live history returns empty until a time-series store is ingested.
  • Docker image build was not run here (CLI present, no daemon).

🤖 Generated with Claude Code

https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj


Generated by Claude Code

claude added 30 commits July 14, 2026 18:23
…eware

The x402 ASGI middleware buffers the request body to read the tool name, then
forwards to the streamable-HTTP handler. The replayed `receive` emitted
synthetic `http.request` messages once the buffer drained, so the handler —
which keeps calling `receive()` to detect client disconnect — never saw
`http.disconnect` and hung on every forwarded (paid-with-payment / free-in-paid-
mode) request. Only the 402 paths (which don't forward) worked.

Fix: after replaying buffered messages, delegate to the original `receive` so
`http.disconnect` flows. Strengthen the ASGI test so the downstream reads the
body and asserts it observes the disconnect, catching this regression.

Verified end-to-end over real HTTP with PAID_ENABLED=true: 402 without payment,
402 on underpayment, 200 + data with a valid signed X-PAYMENT, free tools
bypass; one receipt + one usage record (paid_via=x402) logged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Replaces the mock engine with real venue data when CORE_ENGINE=live; mock stays
the default so the offline suite is unchanged. The MCP/tools layer is untouched —
deps.py picks the engine, both engines expose the same surface.

- core/algorithms.py: shared, venue-agnostic intelligence (realizable-edge math,
  computed cross-venue matcher, bundle/cross-venue signal detectors). Mock now
  delegates its edge calc here, so the math is defined once, not duplicated.
- core/adapters/: base (single HTTP boundary — every httpx failure maps to
  AdapterError), polymarket (Gamma markets + CLOB book), kalshi (public markets
  + orderbook, cents->prob). Fetch + normalize only; no intelligence.
- core/live.py: LiveRepo + engine over the adapters, TTL-cached, degrades
  gracefully (empty results) when a venue is unreachable.
- deps.py: CORE_ENGINE=mock|live switch (mock default).
- tests/test_adapters.py: normalization + live-engine tests via httpx.MockTransport
  (no network). 29 tests green.
- README: engine selection, CORE_ENGINE env, updated layout.

Note: venue APIs aren't reachable from this sandbox (network policy), so live
calls were verified against mocked HTTP, not real endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…istory

Live mode returned an empty history series (get_market_history had no source).
This adds a time-series store and ingestion so it returns real data.

- core/storage.py: HistoryStore mirroring the metering pattern — SQLite by
  default (zero infra, HISTORY_DB_URL), Timescale/Postgres DSN seam for prod
  (# TODO real asyncpg writer). Indexed by (venue, market_id, ts).
- core/live.py: LiveRepo records a price point for every observed market on each
  fresh fetch; get_history now reads the ingested series back (backfill depth
  grows with uptime / a Timescale DSN).
- tests/test_storage.py: range/isolation/spread-from-market + a live ingest->read
  test with mocked adapters. 33 tests green.
- README: HISTORY_DB_URL env + layout.

Mock engine's synthetic history is unchanged; this only affects CORE_ENGINE=live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…ined score)

Replaces the token-Jaccard title matcher with a normalized, combined-similarity
one that markedly improves cross-venue matching on real titles:

- Normalize numbers/units ($100k == 100,000 == 100000), month names, and
  stopwords before comparing, so equivalent phrasings reinforce a match.
- Score = 0.5*token Jaccard + 0.35*difflib ratio + 0.15*number overlap.
- match_markets default threshold raised to 0.45 to favor PRECISION: a false
  pair is a fake arbitrage opportunity, worse for trust than a missed real one.

True pairs now score ~0.66-0.70; cross-topic negatives ~0.08-0.12. Known
limitation (documented in code): "shared entity + shared year" titles can still
false-positive — that's the boundary of lexical matching and where embeddings
would take over.

tests/test_matcher.py added (normalization, ranking, cross-venue pairing,
same-venue exclusion). 38 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Runs on every push and pull request: install uv (pinned 0.8.17), provision
Python 3.12, `uv sync --extra postgres --frozen`, then `uv run pytest`. Caches
the uv download. Verified the exact commands locally (38 tests pass, lockfile in
sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Adds an embedding-based matcher tier that resolves the lexical tier's
"shared entity + shared year" false positives (e.g. "Ethereum flips Bitcoin
2026" vs "Bitcoin above 100k 2026"). Lexical stays the default; nothing else
in the pipeline changes.

- core/embeddings.py: Embedder abstraction. Default FastEmbedEmbedder (local
  ONNX bge-small, CPU-only, no torch); Voyage stub for a hosted rail. Degrades
  to None (-> lexical fallback) if the package/weights are unavailable, so the
  server never crashes offline.
- core/algorithms.py: semantic_similarity (cosine) + a `similarity` dispatcher
  selected by MATCHER=lexical|semantic|hybrid; match_markets/best_match use it;
  threshold is env-tunable (MATCH_MIN_CONFIDENCE) since cosine sits on a
  different scale than the lexical blend.
- pyproject: optional `semantic` extra (fastembed).
- tests/test_semantic_matcher.py: mocked concept-vector embedder proves the
  semantic tier keeps the true BTC pair and rejects the ETH false positive the
  lexical tier accepts; plus hybrid blend and no-embedder fallback. 44 green.
- README: MATCHER / MATCH_MIN_CONFIDENCE / EMBED_* env + layout.

Note: Anthropic has no embeddings endpoint; fastembed downloads weights from
Hugging Face once (blocked in this sandbox), so the semantic path runs where
that egress is allowed or with weights baked into the image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…c tier)

Makes the semantic matcher production-ready:

- core/embeddings.py: `embed_texts` caches each string's vector, so matching
  embeds every unique title once (O(N)) instead of re-embedding per pair
  (O(N^2)) — essential for a paid/hosted backend. semantic_similarity routes
  through it.
- VoyageEmbedder implemented for real (POST api.voyageai.com/v1/embeddings,
  bearer auth, index-ordered results, all failures -> EmbedderError -> lexical
  fallback). No model in the image, so it's serverless/cold-start friendly;
  EMBED_BACKEND=voyage + VOYAGE_API_KEY. get_embedder returns None without a key.
- tests/test_embeddings.py: Voyage parse/ordering + HTTP/connection error
  mapping (mocked transport, no network); cache batches misses and serves
  repeats (embed-once proof); no-embedder path returns None.
- conftest clears the vector cache between tests. README documents the hosted
  option. 50 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…k arb

Deepens the actual intelligence (where non-obvious edge lives), folded into
existing tools — no new tools, keeps the <=12 discipline.

- Calibrated fee model: venue_fee() replaces the flat-bps approximation with
  Kalshi's price-dependent 0.07*notional*(1-price) (peaks at 50c, ~0 at the
  tails); estimate_realizable_edge now returns a per-venue cost_breakdown.
- Risk-adjusted edge: annotate_risk() adds holding_days, annualized_edge
  (edge/days*365), and a resolution_risk haircut — a distant-resolution edge
  is worth less than the same edge next week. Surfaced on every opportunity.
- Dutch-book detector: scan_dutch_book() finds combinatorial arb across
  mutually-exclusive outcome groups (YES sum != 1 -> buy all YES / all NO),
  wired into both engines and find_mispricing (kind=dutch_book). Mock seed
  gains a 2028-president group to demonstrate it.
- Models: DUTCH_BOOK kind, Market.event_group, risk fields on Opportunity,
  cost_breakdown on ExecutionEstimate.
- tests/test_intelligence.py: fee curve, cost breakdown, annualization,
  under/over-round Dutch book, singleton/fair-book rejection, engine
  integration. 58 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
The trust moat — turns "honest realizable edge" from a claim into evidence.

- core/reconciliation.py: records every flagged opportunity (idempotent,
  captures per-leg entry price), resolves it once markets settle, and computes
  realized edge, hit-rate, predicted-vs-realized edge_slippage, and a Brier
  score on the implied probabilities surfaced. SQLite default (RECON_DB_URL).
- find_mispricing now records what it flags; a new FREE tool track_record()
  exposes the aggregate performance — the funnel/trust surface an agent checks
  before paying.
- src/predmarket_mcp/provenance.py: tamper-evident HMAC-SHA256 signature over
  the response data (SIGNING_KEY env; marked signed:false when unset).
  find_mispricing and track_record carry a provenance block.
- deps: record_flagged / resolve_outcomes / track_record_metrics seams.
- tests/test_reconciliation.py: idempotent record; arb realizes regardless of
  outcome; divergent resolution breaks it; hit-rate + Brier math; sign/verify +
  tamper detection; track_record tool reflects a resolution end-to-end.

8 tools now (still within the <=12 target). 66 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Changes the usage model from polling to subscribing, and the economics from
per-scan to per-subscription + per-alert.

- core/watches.py: WatchStore registers watches (edge threshold + optional
  kind/category/event filters) and queues alerts when live opportunities cross
  them. Alerts are computed server-side (push) and drained by the client
  (pull) — deduped per (watch, opportunity), delivered once, per-client.
  Serverless-friendly; no server-initiated SSE required (TODO: MCP resource
  notifications for true push). SQLite default (WATCH_DB_URL).
- Tools: watch() (paid $0.02 subscription; returns an id + immediate matches)
  and poll_alerts() (paid $0.005; drains newly-fired alerts). Client identity
  from x-client-id / authorization header, else "anonymous".
- deps: create_watch / poll_alerts / list_watches orchestration (scan -> fire
  -> drain). conftest isolates the watch DB.
- tests/test_watches.py: threshold gating, dedupe + deliver-once, kind/event
  filters, per-client isolation, and the watch->poll tool flow.

10 tools now (~1.7k-token catalog, within target). 71 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Turns the three production seams from env-only stubs into working code.

- HttpFacilitator (billing/x402.py): speaks the x402 facilitator protocol
  (POST /verify + /settle) to a Coinbase/self-hosted facilitator that recovers
  the EIP-712 signature and settles USDC on-chain, returning a real tx hash into
  the receipt. Selected by build_facilitator() when X402_FACILITATOR_URL is set;
  MockFacilitator remains the default. HTTP client injectable for tests.
- Kalshi RSA-PSS request signing (core/adapters/kalshi.py): signs
  timestamp+method+path with the operator private key (KALSHI_API_KEY_ID +
  KALSHI_PRIVATE_KEY[_PATH]) for private endpoints (orderbook); legacy bearer
  and no-auth fallbacks. `cryptography` added to the dev group + a `kalshi` extra.
- Dockerfile: EXTRAS build-arg to bake optional extras, and WITH_SEMANTIC=true
  to pre-download bge-small weights at build time (no HF egress at runtime).
- tests/test_facilitator.py (verify/settle happy path, rejection, settlement
  failure, HTTP error, config selection) and tests/test_kalshi_auth.py
  (signature verifies against the public key, tamper fails, bearer + no-auth
  fallbacks) — all offline via mocked HTTP / a local keypair.

80 tests green; lockfile updated; README + env docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…st free-tier delay, async IO

Part A (A1-A5) — closes the security/honesty bugs the audit surfaced.

A1 replay: NonceStore records each accepted payment fingerprint (signature +
   EIP-3009 authorization); a reused X-PAYMENT is rejected with 402
   "payment_reused" and never reaches /settle. One signed header = one paid call.
A2 batch: _paid_tool_calls collects EVERY paid call in a JSON-RPC batch;
   check_x402 requires ONE payment covering their SUM. Paying for one tool no
   longer unlocks the rest.
A3 privacy: metering stores a hashed client fingerprint (never the raw
   Authorization token) and only the param SHAPE ({keys,count}), never argument
   values. Shared "anonymous" client id replaced with per-caller ephemeral hash
   so alert queues stay isolated (deliver-once can't leak across callers).
A4 honest delay: evaluate_market now serves a genuinely delayed price from the
   history store with its TRUE as_of (delayed:true), instead of backdating live
   data; no live orderbook leak on the free tier. Falls back honestly when no
   delayed snapshot exists yet.
A5 async: payment verify/settle and metering writes run via anyio.to_thread so
   SQLite + facilitator HTTP never block the event loop.

Tests: replay->402, batch pays the sum, client_fingerprint hides secrets,
metering stores shape-only params, evaluate_market price comes from history. 84 pass.
…-A9)

A6: LRU-bound the embedding vector cache (EMBED_CACHE_SIZE) and the live
    TTL cache (LIVE_CACHE_MAX) so neither grows without limit.
A7: Kalshi volume/depth are contract counts -> convert to USD notional
    (contracts * price). Polymarket fetch_orderbook resolves the YES clob
    token via a persistent map + Gamma fallback, never sending a
    conditionId as a token_id (which silently returns an empty book).
A8: per-client active-watch cap (WATCH_MAX_PER_CLIENT) and an opt-in
    background AlertEngine (ALERT_ENGINE) that fires watches off the hot
    path; poll_alerts becomes drain-only when it runs. Off by default so
    offline/serverless stays synchronous.
A9: shared keep-alive httpx client pool per host; HistoryStore retention
    (HISTORY_RETENTION_DAYS, throttled prune); finalize_opportunities
    annotates risk from the EARLIEST close across ALL legs (not leg[0])
    and caps max_size_usd by real top-of-book depth.

93 tests green (8 new hardening regressions).
B1: core/streaming.py — flag-gated (STREAMING) background WSS ingestor that
    writes venue price ticks into the history store. Pure normalize_tick()
    is unit-tested offline; the socket loop degrades gracefully (no flag /
    no websockets pkg / dropped conn -> server runs on the fetch path).
B2: core/notifier.py — flag-gated (ALERT_WEBHOOK_URL) webhook push of newly
    fired alerts, best-effort (never breaks firing; alert stays queued for
    poll). New alerts:// resource peeks queued alerts WITHOUT draining, and
    WatchStore.peek() backs it. fire() now collects + pushes new alerts.

104 tests green (12 new).
…e (B3, B4)

B3: core/fairvalue.py — liquidity-weighted cross-venue consensus probability,
    per-venue deviation, and dispersion-based confidence. compare_across_venues
    now reports fair_value + which venue is rich/cheap vs it; cross_venue
    opportunities carry a fair_value anchor (new Opportunity.fair_value field).
B4: core/sizing.py — fractional-Kelly stake (KELLY_FRACTION, half-Kelly
    default, clamped, never sizes a negative edge) + a market-impact curve
    walking real book depth. estimate_execution takes optional bankroll_usd +
    fair_value and returns a sizing block (kelly_fraction, recommended_size_usd,
    impact_curve). Both pure/offline over the shared book_getter seam.

118 tests green (14 new).
B5: core/analyst.py — resolution-risk scoring. Offline heuristic
    (category/liquidity/horizon) is always available; when ANALYST_ENABLED +
    ANTHROPIC_API_KEY are set, an LLMAnalyst calls Claude (claude-opus-4-8)
    with a structured tool (no free-text parsing) and adaptive extended
    thinking, degrading to the heuristic on any error. find_mispricing
    enriches the top N opportunities (ANALYST_MAX_ENRICH cost guard).
B6: Trust v2 — provenance now signs with Ed25519 when SIGNING_KEY_ED25519 is
    set (asymmetric; verifiable via the new public GET /pubkey), HMAC
    fallback otherwise. core/merkle.py commits the resolved track record to a
    Merkle root (inclusion proofs); track_record + a public GET /track-record
    expose it so the record is auditable and tamper-evident without an MCP
    session.

130 tests green (12 new).
…g (B7)

core/circuit.py: per-host circuit breaker around fetch_json — after N
    consecutive failures it opens and fast-fails for a cooldown, then
    half-opens a probe (CIRCUIT_FAIL_MAX / CIRCUIT_RESET_TIMEOUT). A dead
    venue no longer costs every call a full timeout.
ops.py: thread-safe Metrics registry + Prometheus-text GET /metrics (folds in
    circuit states); token-bucket RateLimitMiddleware (per-client, 429 +
    Retry-After, inert unless RATE_LIMIT_RPM>0); JSON structured logging via
    LOG_FORMAT=json. Rate limiter sits outermost in the ASGI stack.

138 tests green (8 new).
core/adapters/manifold.py: normalizes Manifold binary markets (live
    probability -> YES price) and synthesizes a shallow two-level book from
    CPMM liquidity for the edge model (no central order book on Manifold).
    Adds Venue.MANIFOLD; opt-in in the live engine via MANIFOLD_ENABLED. A
    third venue turns the fair-value spread into a real consensus with a
    dispersion signal.

141 tests green (3 new).
README: v2 config table (streaming, alert engine/webhook, analyst, Kelly,
fair value, Manifold, rate limit, circuit breaker, retention, logging,
Ed25519), updated layout + tool/resource counts + endpoints (/metrics,
/pubkey, /track-record). Catalog stays 10 tools / ~1.8k schema tokens.
1. Circuit breaker no longer counts 4xx as venue failures (new
   AdapterClientError; 429 still counts as pushback) — repeated bad market
   ids could previously open the breaker and block good calls.
2. Breaker registry cleared between tests (state leaked across tests).
3. Stream sink invalidation throttled to half the market TTL — clearing on
   every tick defeated the cache and hammered venue APIs under streaming.
4. normalize_tick: a price of 0 is a real quote, no longer dropped by
   or-chaining.
5. embed_texts assembles results locally — one call with more unique texts
   than the cache bound could return None vectors after self-eviction.
6. RateLimiter bucket map LRU-bounded (RATE_LIMIT_MAX_CLIENTS) — was an
   unbounded per-client memory leak.
7. History prune uses a None sentinel — monotonic()~0 on fresh boots
   silently skipped pruning for the machine's first hour.
8. Holding period = LATEST close across legs (was earliest): a basket is
   realized only when its last leg resolves; earliest close overstated
   annualized edge, violating the never-overstate principle.
9. alerts:// resource enforces caller identity — anyone who guessed a
   client_id could peek that client's paid alerts.

Plus consistency: single _http_middleware() shared by all entrypoints (the
Docker __main__ path was missing the rate limiter + logging), server
instructions copy updated to the real 10-tool catalog, /track-record DB
reads offloaded from the event loop, hoisted hot-loop imports.

148 tests green (7 new audit regressions).
core/entailment.py: parses market titles into (entity, direction, threshold,
deadline, cumulative) claims and detects RISK-FREE arbitrage no spread scanner
can see:
  * threshold monotonicity — P(BTC>150k) must be <= P(BTC>100k) at the same
    date; a violation is banked by shorting the over-priced stronger claim and
    longing the under-priced weaker one (whenever the strong one pays, the weak
    one pays too).
  * term structure — for cumulative 'reach X by DATE' claims, probability must
    be non-decreasing in the deadline; point-in-time snapshots are deliberately
    excluded to keep precision high.
New OpportunityKind.ENTAILMENT wired into find_mispricing (mock + live) and its
kind filter. Also exposes the implied probability term structure as a by-product.

157 tests green (9 new).
core/basis.py: scores whether two 'matching' markets actually resolve by the
same rules. Offline heuristic reads resolution-sensitive wording (snapshot vs
cumulative framing, explicit data source, venue) into a basis_risk score +
same_contract flag; the LLM path (ANALYST_ENABLED) does a structured
point-by-point diff via a shared analyst.call_tool() helper, degrading to the
heuristic. compare_across_venues now returns basis_risk, same_contract, and
resolution_differences — so a spread flagged same_contract=false is exposed as
a bet on which rulebook wins, not free money. Refactored analyst.score() onto
the reusable call_tool() boundary.

163 tests green (6 new).
core/calibration.py: turns the resolution history into a probability
correction. Bins surfaced implied-probabilities, takes the empirical resolution
rate per bin, enforces monotonicity with Pool-Adjacent-Violators isotonic
regression, and interpolates — so 'we said 0.70, it resolved YES 64% of the
time' becomes a calibrated forecast that differs from the raw market (edge, not
just arbitrage). Identity below CALIBRATION_MIN_SAMPLES (never correct on thin
evidence); cached by resolved-sample count and rebuilt as outcomes land.
evaluate_market now returns a calibration block; the moat compounds with every
resolution no competitor has.

169 tests green (6 new).
core/backtest.py: replays a mean-reversion rule (long YES on a dip below the
trailing mean, exit on recovery) over the server's recorded price history, net
of venue fees + slippage — same cost model as the live edge math. Returns
trades, hit_rate, total_return, sharpe_per_trade, max_drawdown. New paid tool
simulate_strategy (11th tool, under the 12 cap). Lets an agent validate a rule
on real history before paying for live scans, and runs on price data no
competitor starting today has.

175 tests green (6 new).
core/options.py: Breeden-Litzenberger — risk-neutral P(S>K) = -e^{rT}.dC/dK,
the negative slope of the call chain across strikes (pure, central-difference,
offline-testable). DeribitProvider fetches the live call chain (flag-gated
OPTIONS_ENABLED, injectable client, degrades to None). evaluate_market on a
crypto strike market now carries an 'options' block comparing the
prediction-market probability to what billions in options imply — a real-money
cross-check no prediction-market aggregator has. Reuses the C1 claim parser to
extract (currency, strike) from the title.

183 tests green (8 new).
core/anchor.py: periodically anchors the reconciliation Merkle root in a public
chain so 'we didn't rewrite history' is provable against a ledger we don't
control, not against our own endpoint. Pluggable submitter (x402-facilitator
pattern): MockChainAnchor (deterministic, offline, tested) by default;
EvmChainAnchor when ANCHOR_RPC_URL is set (structural, degrades to None).
Anchors persist in SQLite, deduped by root and rate-limited by
ANCHOR_MIN_INTERVAL. Public GET /track-record includes onchain_anchor when
ANCHOR_ENABLED. Off by default.

190 tests green (7 new).
…ing (C8, C5)

C8 core/microstructure.py: tick-direction imbalance, realized vol and drift over
    the recent price series -> signed momentum + informed_flow flag ('an edge is
    ABOUT to appear', not just 'exists now'). Pure/offline; evaluate_market
    surfaces it from recorded history.
C5 value-based pricing: get_market_history now charges by requested range
    (+$0.005/30d, capped $0.10, floored at base) via Pricing.price_for(). The
    x402 gate and metering both use the value-based amount, threaded through the
    middleware as (tool, args) pairs. Money-safety pinned by tests: monotone,
    capped, never below base, batch pays exactly the sum. Flat tools unchanged.

200 tests green (10 new).
README: v3 config table (calibration, basis LLM diff, options/Deribit,
microstructure, on-chain anchor), core/ layout with the 8 new modules
(entailment, basis, calibration, backtest, options, microstructure, anchor),
tool/resource counts (11 tools). Catalog stays under budget (~2.26k schema
tokens, 11 tools).
kv.py: lazy, optional Redis client from REDIS_URL (degrades to local backend if
unset or redis-py missing). NonceStore gains a Redis path using atomic SET NX,
so a signed X-PAYMENT consumed on one instance is globally consumed — the only
correct replay guard behind a load balancer (SQLite-per-process would let the
same header buy N calls across N instances). RateLimiter gains a shared
fixed-window INCR counter so limits are global, not per-process, and degrades
to the local token bucket on any Redis error. Both default to the existing
single-instance backends; new optional [redis] extra.

206 tests green (6 new, with a self-contained fake Redis).
core/pg.py: an AsyncLoop runs a single asyncio loop on a daemon thread so the
synchronous stores can drive async asyncpg via run_coroutine_threadsafe — a
real Postgres/Timescale path without rewriting callers as async. storage.py:
PgHistoryStore mirrors the SQLite store's surface (record/query/count/prune,
server-side retention via DELETE ... WHERE ts < cutoff); HistoryStore() is now
a factory that dispatches by DSN (postgresql:// -> PgHistoryStore, else SQLite)
and degrades to SQLite if the driver/DB is unavailable. README documents the
multi-instance deploy (shared REDIS_URL + postgresql:// HISTORY_DB_URL).

212 tests green (6 new, fake asyncpg pool — no live DB).
claude added 30 commits July 21, 2026 13:45
…lass strategy (I1)

Every ranking so far optimized edge quality; none priced TIME. For a compounding
bankroll a 1% edge recycled weekly (~68%/yr) beats a 5% edge locked for a year
(5%/yr). core/velocity.py makes turnover the strategy:
  * velocity metrics on every opportunity — capital_efficiency (EV per
    locked-capital day), an illustrative compound_annual_growth under recycling
    (capped), and early-exit liquidity from the unwind side of the books (deep
    bids = realize convergence without holding to resolution);
  * rank=velocity mode in find_mispricing — fastest turnover first;
  * a rotation_plan (bankroll_usd + horizon_days): greedy allocation across
    short-cycle opportunities by efficiency, cycles = horizon // holding,
    projected compounded bankroll with idle capital carried flat — explicitly
    flagged as a plan, not a promise. Unknown holding periods use a conservative
    default and are marked holding_known=false.
Mock markets gained real close_times so holding periods and cycles are real
(Fed ~2 months = the fast-turnover winner; BTC year-end). Trimmed tool copy to
hold the catalog near the ~2.5k-token budget (12 tools).

311 tests green (9 new).
…ot path

Audit of the I1 velocity module found two real issues:
1. (crash) compound_annual_growth = (1+edge)^cycles - 1 applied the display cap
   AFTER exponentiating; a short holding + edge>1.63 raised OverflowError,
   crashing find_mispricing (annotate_velocity had no guard). Now computed in
   log-space with the cap applied BEFORE exp — verified no overflow at edge=1.7.
2. (perf) early-exit liquidity fetched an orderbook per leg for EVERY
   opportunity on EVERY find_mispricing call (O(opps x legs) book fetches in
   live mode, even when rank!=velocity). Split out into annotate_early_exit,
   applied only to the velocity-ranked shortlist (top N); the default EV path is
   now book-free.

314 tests green (4 new regressions).
core/adverse.py: scores P(this edge is a trap — you're the one being picked off)
from three signals we already compute: informed flow moving AGAINST the
position (microstructure momentum vs leg side), the historical hit-rate of edges
of this KIND (reconciliation ground truth — new hit_rate_by_kind()), and quote
quality flags. Returns trap_score, factors, and trust_adjusted_edge =
edge*(1-trap). find_mispricing now carries an adverse block per opportunity.
Never penalizes on thin history (ADVERSE_MIN_SAMPLES). Fewer bad fills -> better
hit-rate, the strongest marketing asset.

322 tests green (8 new).
…(J6)

core/auditbundle.py: the track record proves the aggregate is honest; an audit
bundle proves a SPECIFIC edge existed at a specific moment. For a flagged
opportunity it assembles the evidence (each leg's market snapshot, top-of-book
depth, the edge, a timestamp), signs the canonical payload (Ed25519, via
provenance) and stores it. find_mispricing takes opt-in audit=true to attach an
audit_bundle_id per opportunity; public GET /audit/{id} serves the signed
bundle so a buyer independently verifies the edge was real — no trust required.
Added provenance.verify_block (Ed25519/HMAC) + tamper-detection tests. Off by
default -> zero overhead on the hot path. The deepest, least-copyable trust
primitive: cryptographic evidence for each call, not just aggregate stats.

329 tests green (7 new).
…is (J2)

Extends H1: the threshold ladder gives discrete survival points; J2 fits a
lognormal to them (regress ln(strike) on the probit of the CDF -> mu, sigma) so
the implied distribution becomes CONTINUOUS. Answers P(X in [a,b]) for ANY range
with principled tails, plus mean/median/mode/stdev and arbitrary quantiles, not
just the rungs the market happens to list. Verified the fit recovers known
lognormal params from a synthetic ladder. distribution://{entity} now carries a
continuous block; degrades to None on a non-monotone ladder.

333 tests green (4 new).
Extends H2 from pairwise P(A|B) to multi-hop P(A | B, C, ...). core/inference.py
combines each piece of evidence's weight of evidence in log-odds (naive Bayes):
start from the market's price as prior, then update for every conditioning event
— corroborating evidence compounds, contradictory evidence cancels, a
mutually-exclusive sibling observed true drives the posterior to ~0, certain
evidence to ~1 (sigmoid overflow-guarded). The naive-Bayes independence
assumption is flagged in the output. conditional://{event} now carries a
bayesian_updates section: each market's posterior given all the others as
evidence.

340 tests green (7 new).
Fair value (B3) weights venues by liquidity; J4 weights them by ACCURACY. Every
time a market resolves, reconciliation.resolve now records the venue's surfaced
YES-price vs the outcome; core/metaconsensus.py scores each venue by Brier over
those samples and fuses venue prices into a best-estimate + credible interval,
with historically-more-accurate venues getting more say. Neutral (equal)
weighting until ACCURACY_MIN_SAMPLES, so no venue is over-trusted on thin
evidence — meta-calibration that compounds with resolution history.
compare_across_venues now returns meta_consensus.

347 tests green (7 new).
…ore growth

Release-hardening pass across the money and hot paths:

* rotation_plan OverflowError (release blocker): (1+ev)^cycles with a short
  holding over a long horizon overflowed and 500'd a paid find_mispricing
  call with bankroll_usd. The factor is now computed in log-space with the
  same cap as compound growth.
* x402 middleware body cap: payment gating buffered the whole POST body
  unbounded; an oversized request now gets a 413 before parsing
  (X402_MAX_BODY_BYTES, default 2 MB).
* Rate-limit bypass: rotating spoofable x-client-id values minted fresh
  buckets, sidestepping the per-client limit. Added a secondary per-IP cap
  at rpm x RATE_LIMIT_IP_MULT (default 5x; 0 disables).
* SQLite alert queue cap: a client that never polls now keeps only the
  newest ALERT_QUEUE_MAX undelivered alerts (mirrors the Redis LTRIM path).
* NonceStore TTL prune: the SQLite replay table never deleted expired
  fingerprints; now swept hourly past NONCE_TTL_SECONDS.
* Live index bound: LiveRepo's (venue, id) market index grows with churn;
  now LRU-bounded (LIVE_INDEX_MAX, default 50k).
* fit_lognormal sigma guard: an absurd strike span (garbage in a title)
  produced a sigma whose exp(sigma^2) overflowed; such fits are rejected.

7 new regression tests; 355 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…ooks (K1-K3)

Three uniqueness modules built on the existing signal stack — no new tools
(house folds into evaluate_market/track_record; scenario is a resource plus an
assess_portfolio arg), so the catalog stays at 12 tools.

K1 — house probability. core/houseview.fuse blends calibration (C3),
options-implied (C7), accuracy-weighted consensus (J4) and a bounded
microstructure tilt (C8) into one house_probability with a confidence and its
edge_vs_market. Every forecast is recorded (core/houseforecast) and Brier-graded
at resolution; track_record.house_forecast reports brier_edge (>0 = the server's
number beats the raw market) — a provable, un-fakeable calibration claim.
Surfaced in evaluate_market, a new house:// resource, and /track-record. The
autonomous resolver grades house forecasts alongside the track record.

K2 — scenario engine. core/scenario propagates hypothetical outcomes across the
entity graph: pin markets (logical entailment/exclusion edges exact, copula edges
from history) and read each related market's prior->posterior->shift. Exposed as
the free scenario://scan/{spec} resource and an optional `scenario=` arg on
assess_portfolio that stress-tests a book. Cluster-scoped so a stopword can't drag
unrelated markets in and manufacture spurious shifts.

K3 — client-sampled rulebooks (the deferred J5). With RULEBOOK_SAMPLING=on,
compare_across_venues uses MCP sampling to ask the CALLING agent's own model to
judge settlement basis risk (core/rulesample) — the deepest analysis at the
client's expense. Degrades to the C2 heuristic when the client can't sample.

28 new tests; 383 passing. Catalog ~2.65k tokens, 12 tools, 8 resources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
The network effect the product was missing: an agent doesn't just find edge here,
it can prove it. estimate_execution(commit=true) logs the position as a paper
trade tagged to the caller; the autonomous resolver grades its P&L at resolution
with the same model as the track record (realized_edge = (payoff-cost)/cost,
pnl = size x edge). core/leaderboard.PaperTradeStore aggregates per client.

New surfaces, no new tools (stays at 12): a `commit` arg on estimate_execution,
a public server-signed `leaderboard://top` resource ranking agents by REAL,
resolution-verified return (handles anonymized via sha256; fewer than
LEADERBOARD_MIN_RESOLVED resolved trades stay provisional so one lucky trade
can't top a proven record), and an ownership-checked `portfolio://{client_id}`
resource for a caller's own trades, rank and realized P&L. The server becomes an
independent notary of agent performance — a portable, tamper-evident track record
to show a third party.

12 new tests; 395 passing. Catalog ~2.69k tokens, 12 tools, 10 resources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Two new signal classes, no new tools (both are resource/response additions);
catalog stays at 12 tools.

K5 — market-maker advisor. Every other tool serves a taker; core/maker serves a
maker. The free maker://{venue}/{market_id} resource recommends a two-sided limit
quote (recommended_bid/ask, half_spread, skew) to earn the spread, and a
pull_quotes guard to step aside when flow looks informed. It prices
adverse-selection risk from microstructure (C8) + quote quality (F7): widening the
spread under choppy/informed conditions and skewing the center with informed flow
so you don't get run over. Intelligence only — never posts orders. Pure/offline.

K7 — reality feeds (market vs the world). core/reality adds an EXTERNAL anchor: a
real-world nowcast (polls / macro / on-chain) for the same event, and the market's
divergence from it. Structured like the Deribit options cross-check (C7): pure,
offline divergence math + a flag-gated HttpNowcastProvider that degrades to None
without a feed. The reality-implied probability feeds the house forecast (K1) as
an independent, weight-2 component, and evaluate_market surfaces a `reality` block.
Off by default (REALITY_ENABLED + REALITY_NOWCAST_URL).

17 new tests; 412 passing. 12 tools, 11 resources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…rboard integrity, nowcast cache

Findings and fixes from a line-by-line audit of the K-series before release:

* K2 pin lookup broken on the live engine: find_market keyword-searched by
  internal market id, which venue text-search APIs never match. Now a direct
  per-venue id lookup (repo.get_market) first, search as fallback; and one
  lookup per pin instead of two.
* K2 volume cap could evict the PINNED market itself (lowest-volume pin in a
  big cluster + small limit), silently producing zero-evidence shifts. Pinned
  markets are now always kept in the propagation set.
* K4 leaderboard integrity: a self-declared paper size was unbounded, so
  "$1B on a coin flip" could top the proof-of-alpha ranking, and open trades
  per client were unlimited (disk growth). Size is clamped to
  LEADERBOARD_MAX_SIZE_USD (default 100k) and commits are refused past
  LEADERBOARD_MAX_OPEN (default 200); the tool path degrades gracefully
  (response without paper_trade_id).
* K7 double fetch + no cache: one evaluate_market hit the nowcast feed twice
  (K1 fusion + reality block), every call paid a network round trip, and a
  downed feed added its timeout each time. Added a bounded per-entity TTL
  cache (REALITY_TTL_SECONDS, default 60) that also caches negative reads.
* K5 cleanup: dead `* 0.0` fee expression replaced with an honest env-driven
  MAKER_FEE_PER_SIDE (default 0 — these venues fee takers, not makers);
  elevated realized volatility is now named in the adverse factors.

8 new regression tests; 420 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…rdening, backtest clamp

Line-by-line pass over every module not covered by the two previous audit
commits (algorithms, sizing, portfolio, multioutcome, entailment, persistence,
matchlearn, merkle, backtest, embeddings, streaming, circuit, anchor, quality,
adapters, deploy artifacts). Findings and fixes:

* estimate_realizable_edge was dimensionally wrong (critical): every filled
  dollar was counted as profit, so a naked single-leg buy reported ~97%
  "realizable edge" and gross_edge assumed an N-payout on complementary pairs.
  Rewritten as contract-based basket math: per-leg average price + contract
  count, NO legs costed as the YES-complement, baskets executable only in
  complete units (units = thinnest leg), profit = units x (guaranteed payout -
  unit cost) - fees - gas. Payout is structure-aware (mixed pairs / all-YES
  exclusive sets pay 1; all-NO over N outcomes pays N-1). A naked leg reports
  the (negative) execution drag instead of invented profit, and a basket with
  a dead/unfillable leg reports 0 fillable/0 edge rather than selling the
  surviving leg as an arb. Sanity: cross-venue mock basket now ~+2.3% net vs
  4% gross (depth and fees eat the paper edge), single leg ~-2.6%.
* Adapter orderbook parsing hardened: one malformed depth level from a venue
  (bad tuple shape / non-numeric size) raised TypeError/ValueError past the
  AdapterError boundary and would 500 a paid call; per-level parses now skip
  bad rows (Kalshi + Polymarket).
* Backtest net-return clamped at -100%: fees on top of a total wipeout could
  push net below -1, flipping equity negative and corrupting compounding.

Verified clean: sizing (Kelly), portfolio, multioutcome, entailment ordering,
persistence, matchlearn, merkle (domain-separated, odd-tail), circuit
half-open logic, anchor (base-0 chain id), streaming normalization,
embeddings LRU, quality flags, Dockerfile/compose/CI.

4 new regression tests; 424 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…mplate

Closes the two mandatory pre-launch gaps identified in the code review, plus a
launch-blocking packaging bug found while doing it.

* Live path was never validated against real venues (all 430 tests run on
  mocks; the adapters normalize a response shape assumed from docs). Added
  deploy/preflight.py — the check the test suite structurally cannot do: it
  calls the real APIs and pushes what comes back through the actual adapters
  and the whole pipeline (normalization -> depth -> matching -> claim parsing
  -> scan -> execution estimate), naming the exact link that breaks. Verifies
  price normalization, yes/no coherence, titles, volume, close times,
  event_group, book sanity (crossed/empty/zero-size), scan latency and edge
  plausibility. Read-only, exits non-zero on failure, --json for monitoring,
  and audits prod config (unsigned provenance, PAID_ENABLED with no real
  facilitator = accepting payments that never settle, missing RESOLUTION_ENGINE
  = a moat that never grows).

* Fee model carried a "TODO: source live fee schedules" while being the basis
  of the product's core claim. Now documented against the published schedules
  and env-overridable (KALSHI_FEE_RATE / POLYMARKET_FEE_RATE / GAS_USD_*): the
  Kalshi formula rounds UP to the cent per trade as the venue does (understating
  a real cost is the one error an "honest edge" product must not make), makers
  correctly pay no taker fee, and gas resolves at call time instead of freezing
  an import-time constant. The mock engine now shares this model instead of its
  own stale constants (gas 0.35 vs 0.05), which had edges evaporating the moment
  CORE_ENGINE=live.

* .gitignore's `.env.*` swallowed deploy/.env.example, so the template was never
  committed and the documented first step (`cp deploy/.env.example deploy/.env`)
  failed on a fresh clone. Negated it; real .env files stay ignored. The
  template also gained the cost-model, RESOLUTION_ENGINE and ADMIN_TOKEN keys.

6 new regression tests; 430 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
* Gas was still frozen at import time despite the previous commit's claim:
  DEFAULT_GAS_USD / mock._GAS_USD were module-level env snapshots containing
  every venue, so the call-time gas_for() fallback was dead code and a runtime
  GAS_USD_* override never reached the edge estimate (proven empirically:
  override to 5.00 still produced 0.05). Removed both dicts; each leg now
  resolves gas_for() at call time unless the caller passes an explicit
  mapping. End-to-end regression tests cover the env override through both
  the shared estimator and the mock engine, plus explicit-override precedence.

* Lint sweep (ruff F/B/PLE): removed 23 unused imports/variables across core,
  src and tests; zip(strict=True) where an embedder answering short must fail
  loudly instead of silently dropping vectors; B904 `from None` on the payment
  parse error; unused loop var renamed; AdapterClientError added to the
  adapters' __all__. Remaining B905s are equal-length-by-construction zips —
  left as is.

431 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
The MCP registry listing requires a manifest and none existed, blocking the
first distribution step. Declares the remote streamable-http transport (so any
agent connects with one line, no local install) and leads with the
differentiators rather than "prediction market data", which is free at source.

Two fields must be filled before publishing: the remote URL (your domain) and
a re-check of the $schema/field names against the current registry docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Findings are recorded only; nothing fixed, per audit protocol.
Critical, audit finding INV-001. check_x402 ordered its steps
is_used -> verify_and_settle -> mark_used, with the irreversible step in the
middle. check_x402 runs in the ASGI thread pool, so two concurrent requests
carrying the SAME signed X-PAYMENT both passed is_used() and both reached the
facilitator: the payer was charged twice, one call was served, and a single
receipt was written — leaving /revenue reconciliation reporting zero
discrepancy. The operator would never have seen it.

Reordered so the atomic claim (SET NX on Redis, INSERT OR IGNORE on SQLite)
happens BEFORE settlement. The claim is not released on settlement failure:
a failed settlement moved no money, so the caller simply presents a freshly
signed payment, whereas releasing would re-open the window whenever a
facilitator error is ambiguous about whether the transfer went through.

Two regression tests: concurrent same-payment settles at most once (kills the
old ordering), and a failed settlement does not free the claim.

Repro: docs/audit/repro/repro_double_settle.py — was 2 settlements, now 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Critical, audit finding INV-004. A market whose title contained a very long
number killed find_mispricing — the paid flagship — for every caller with an
OverflowError, and took compare_across_venues with it through the matcher.
On venues where anyone can create a market with an arbitrary question this is
a remote, unauthenticated denial of service against the revenue path, using
data the operator does not control.

Two functions consume untrusted title text and neither was total:

* _canon_number (algorithms) ran float() on an arbitrarily long digit string
  (yielding inf) and then int(round(...)) on it. Now bounded by _MAX_CANON,
  finiteness-checked before and after scaling, and it returns the span
  unchanged rather than raising — an unparseable span simply fails to match a
  threshold ladder.
* _threshold (entailment) converted that possibly-unchanged span straight back
  with float(), and separately called int(digits) for the year check, which
  raises past Python's 4300-digit conversion limit. Split into _usable_threshold
  (total parse, None on absurd input) and _is_year (length-guarded before int).

Regression tests: five hostile titles never raise, one poisoned market does not
break a full scan, and the same input passes through the matcher.

Repro: docs/audit/repro/repro_hostile_input.py — parser section now fully green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
High, audit findings INV-005 and INV-008. One root cause: a venue response was
treated as trusted. Its shape is documented, not guaranteed.

INV-005 — Market.yes_price/no_price documented '[0, 1]' with no validation, so
a venue returning 999 constructed a Market that poisoned every downstream
number (edge, probabilities, house forecast, calibration) and was persisted to
history. The range is now ENFORCED on the model, so an invalid Market cannot
exist anywhere in the system. Rejection, not clamping: 999 is garbage, and
clamping it to 1.0 would manufacture a fake certainty.

INV-008 — _normalize_market assumed every row was a dict; the loops checked the
outer type only. A None or a scalar inside the array raised AttributeError past
the AdapterError boundary, i.e. a 500 on a paid call.

Both are closed by one defensive boundary, base.normalize_rows, now shared by
all three adapters: non-list payloads yield nothing, non-dict rows are skipped,
and a row that fails normalization or model validation costs exactly that row.

Regression tests: out-of-range price rejected while a healthy market in the
same response survives; model raises on out-of-range; None/scalar/nested rows
skip without raising; wrong top-level shape yields no markets.

Repro: docs/audit/repro/repro_hostile_input.py — 0 failures (was 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Critical, audit finding INV-002. Ownership of watches, alerts, portfolios and
leaderboard entries was keyed on the x-client-id header, and 'checked' by
comparing a caller-supplied URI parameter against that caller-supplied header.
Both sides were attacker-controlled, so the check was vacuous: setting
x-client-id to a victim's id and reading portfolio://<victim> returned their
positions, sides, sizes and P&L. OAuth did not help — the header outranked the
authorization header in the old _client_id().

Replaced with a derived-identity model (new identity.py):

* identity comes only from something the caller cannot forge — an OAuth subject
  (signature verified upstream) or the payer address of a VERIFIED x402 payment
  (EIP-712 signature checked by the facilitator before settling), falling back
  to an ephemeral connection hash marked proven=False;
* x-client-id survives only as a namespace hint INSIDE an already-proven
  identity, sanitized, and is ignored entirely for ephemeral callers — letting
  an unproven caller pick a sub-namespace is the defect itself;
* personal resources are addressed as portfolio://me and alerts://me. No URI
  names another principal, so this class of IDOR is impossible by construction
  rather than by check;
* REQUIRE_PROVEN_IDENTITY=on additionally refuses personal resources to
  unauthenticated callers.

X402Middleware now publishes the verified payer into the request context, so
'whoever paid owns it' becomes the natural identity for the paid tier.

BREAKING: alerts://{client_id} and portfolio://{client_id} are replaced by
alerts://me and portfolio://me. estimate_execution(commit=true) returns the new
link. README updated.

Regression tests (tests/test_identity.py): a header alone cannot choose an
identity, a verified payer is proven, the hint cannot forge another principal,
ephemeral callers are isolated, proven identity can be required, and the full
original exploit reads nothing.

Repro: docs/audit/repro/repro_idor.py — the foreign URI no longer exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…state

Wave 2 of the audit. Four findings, one theme: a guard that only held for part
of the sequence it was protecting.

INV-003 (High) — the metering write sat in a bare "finally", so a storage
failure escaped it, cancelled a successful "return result" and replaced any
real tool exception with the storage error. Under the paid gate the payment has
already settled by then: the caller paid, the tool ran, and they got an error
with no usage record. The write is now best effort and its failure is NOT
silent — it increments metering_write_failures_total, visible on /metrics.

INV-007 (Medium) — AnchorStore.maybe_anchor spanned read-decide-submit-save
with an irreversible chain write in the middle, so concurrent /track-record
requests could burn gas twice on one root. Serialized on a dedicated lock held
across the whole decision, separate from the row lock.

INV-009 (Medium) — create_watch called count_active(), which took and released
the lock before the insert took it again; concurrent requests all passed the
check and overshot the cap. Since every watch is evaluated on every fire, that
is per-scan CPU the caller controls. Count and insert now share one lock.

INV-010 (Medium) — an unrecognized METERING_DB_URL silently relocated usage,
receipts AND the x402 replay guard to cwd/metering.db, i.e. the container's
ephemeral filesystem rather than the mounted volume: a one-character typo lost
revenue records and made consumed payments reusable after every redeploy, with
nothing in the logs. It now raises with a message naming the expected form. The
same parsing was duplicated inline in three places and the first pass only
fixed two — ReceiptStore now uses the shared resolver.

Regression tests: metering failure preserves a successful call and does not
mask a real tool error; 20 concurrent watch creations respect the cap; 8
concurrent anchors submit once; unrecognized DSNs raise across all three stores.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
Wave 3, closing the remaining audit findings.

INV-006 (Low) — gross_edge and realizable_edge sat side by side in one response
in DIFFERENT units: gross was an absolute per-unit difference, realizable a
fraction of deployed capital. They agreed only when unit cost happened to be
near 1, so an agent comparing them (the obvious thing to do with two fields
named *_edge) was misled on any cheap basket. Gross is now a return on capital
too, which makes the difference between the two exactly the execution drag.

INV-011 (Low) — de-vigging divided by the ROUNDED total, pushing the "fair"
probabilities off 1 by up to ~0.2% on small numbers and breaking the single
property that defines de-vigging. Divides by the unrounded sum.

INV-012 (Low) — fillable_size_usd rounded to the nearest cent, so it could be
reported ABOVE the requested size. Rounds down.

TEST-01 (High, test quality) — three surgical mutations of the money and access
paths previously survived the whole suite. Added the missing tests: a basket is
limited by its thinnest leg (units = min, not max), and a replayed payment never
reaches the facilitator at all (the old test could not tell which of the two
barriers had caught it). Re-running the mutation set now kills 6/6 applicable
mutants; the other two no longer apply because the code they broke was removed
by the INV-001 and INV-002 fixes.

Property-based suite: 10/10 invariants hold (was 8/10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195BXP7CY4CMy57uUsNuDTj
…xed)

Phases 0-2 of an adversarial audit plus the full remediation.

Three Critical: one signed payment settled twice under concurrency while the
books showed a single receipt (invisible to /revenue); any caller could read any
client's alerts and portfolio by setting one header; a single market title
containing a long number killed the paid flagship for everyone.

Three High: a metering write failure destroyed an already-paid successful call;
an out-of-range venue price was ingested and poisoned every downstream number;
malformed venue rows crashed past the AdapterError boundary. Plus INV-009/010/007
(cap and DSN and anchor races), INV-006/011/012 (units and rounding), and
TEST-01 — three surgical mutations of the money and access paths that the whole
suite failed to notice.

Includes a breaking change: alerts://{client_id} and portfolio://{client_id} are
now alerts://me and portfolio://me, resolved from a derived identity (OAuth
subject or verified x402 payer) rather than a caller-supplied header.

Audit trail, invariants, findings and executable repros in docs/audit/.
461 tests green; every repro verified against this HEAD.
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