alloc-context 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. alloc_context-0.1.0.dist-info/METADATA +154 -0
  2. alloc_context-0.1.0.dist-info/RECORD +85 -0
  3. alloc_context-0.1.0.dist-info/WHEEL +5 -0
  4. alloc_context-0.1.0.dist-info/entry_points.txt +4 -0
  5. alloc_context-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. alloc_context-0.1.0.dist-info/top_level.txt +1 -0
  7. alloccontext/__init__.py +3 -0
  8. alloccontext/__main__.py +149 -0
  9. alloccontext/config.py +415 -0
  10. alloccontext/horizon.py +30 -0
  11. alloccontext/ingest/__init__.py +1 -0
  12. alloccontext/ingest/cf_benchmarks.py +38 -0
  13. alloccontext/ingest/cf_history.py +65 -0
  14. alloccontext/ingest/coinbase_client.py +234 -0
  15. alloccontext/ingest/coinbase_portfolio.py +53 -0
  16. alloccontext/ingest/coingecko.py +148 -0
  17. alloccontext/ingest/coinmarketcap.py +135 -0
  18. alloccontext/ingest/env_keys.py +12 -0
  19. alloccontext/ingest/etf_flows.py +282 -0
  20. alloccontext/ingest/exchange/__init__.py +4 -0
  21. alloccontext/ingest/exchange/coinbase_adapter.py +64 -0
  22. alloccontext/ingest/exchange/kraken_adapter.py +66 -0
  23. alloccontext/ingest/exchange/live.py +95 -0
  24. alloccontext/ingest/exchange/portfolio.py +8 -0
  25. alloccontext/ingest/exchange/registry.py +27 -0
  26. alloccontext/ingest/exchange/types.py +5 -0
  27. alloccontext/ingest/exchange_http.py +28 -0
  28. alloccontext/ingest/fear_greed.py +89 -0
  29. alloccontext/ingest/fred.py +138 -0
  30. alloccontext/ingest/http_errors.py +29 -0
  31. alloccontext/ingest/kalshi.py +84 -0
  32. alloccontext/ingest/kalshi_api.py +199 -0
  33. alloccontext/ingest/kalshi_client.py +95 -0
  34. alloccontext/ingest/kalshi_files.py +44 -0
  35. alloccontext/ingest/kalshi_state.py +67 -0
  36. alloccontext/ingest/kraken_client.py +177 -0
  37. alloccontext/ingest/kraken_portfolio.py +161 -0
  38. alloccontext/ingest/macro_calendar.py +310 -0
  39. alloccontext/ingest/macro_normalize.py +98 -0
  40. alloccontext/ingest/market_snapshots.py +113 -0
  41. alloccontext/ingest/outcome.py +110 -0
  42. alloccontext/ingest/parse_helpers.py +23 -0
  43. alloccontext/ingest/runner.py +148 -0
  44. alloccontext/mcp/__init__.py +1 -0
  45. alloccontext/mcp/assets.py +153 -0
  46. alloccontext/mcp/bazaar.py +630 -0
  47. alloccontext/mcp/contracts.py +286 -0
  48. alloccontext/mcp/handlers.py +487 -0
  49. alloccontext/mcp/http.py +250 -0
  50. alloccontext/mcp/payment_middleware.py +211 -0
  51. alloccontext/mcp/server.py +319 -0
  52. alloccontext/mcp/staleness.py +30 -0
  53. alloccontext/mcp/validation.py +56 -0
  54. alloccontext/mcp/x402_bazaar_dynamic.py +104 -0
  55. alloccontext/mcp/x402_config.py +131 -0
  56. alloccontext/mcp/x402_pricing.py +55 -0
  57. alloccontext/mcp/x402_stables.py +179 -0
  58. alloccontext/rollup/__init__.py +1 -0
  59. alloccontext/rollup/band.py +50 -0
  60. alloccontext/rollup/breadth.py +45 -0
  61. alloccontext/rollup/cf_math.py +103 -0
  62. alloccontext/rollup/cluster.py +149 -0
  63. alloccontext/rollup/cluster_config.py +86 -0
  64. alloccontext/rollup/comparison.py +67 -0
  65. alloccontext/rollup/context.py +118 -0
  66. alloccontext/rollup/delta.py +109 -0
  67. alloccontext/rollup/etf.py +113 -0
  68. alloccontext/rollup/fear_greed.py +61 -0
  69. alloccontext/rollup/macro.py +185 -0
  70. alloccontext/rollup/portfolio.py +137 -0
  71. alloccontext/rollup/rebalance.py +125 -0
  72. alloccontext/rollup/regime.py +188 -0
  73. alloccontext/rollup/sentiment.py +118 -0
  74. alloccontext/rollup/snapshots.py +64 -0
  75. alloccontext/rollup/tape.py +176 -0
  76. alloccontext/status_report.py +321 -0
  77. alloccontext/store/__init__.py +0 -0
  78. alloccontext/store/db.py +216 -0
  79. alloccontext/store/jsonutil.py +10 -0
  80. alloccontext/store/meta.py +20 -0
  81. alloccontext/store/retention.py +63 -0
  82. alloccontext/store/status.py +89 -0
  83. alloccontext/timeutil.py +11 -0
  84. alloccontext/x402_production_check.py +193 -0
  85. alloccontext/x402_smoke_redact.py +41 -0
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+ SCHEMA_VERSION = 8
7
+
8
+ _SCHEMA = """
9
+ CREATE TABLE IF NOT EXISTS schema_meta (
10
+ key TEXT PRIMARY KEY,
11
+ value TEXT NOT NULL
12
+ );
13
+
14
+ CREATE TABLE IF NOT EXISTS ingest_runs (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ source TEXT NOT NULL,
17
+ started_at TEXT NOT NULL,
18
+ finished_at TEXT,
19
+ rows_upserted INTEGER NOT NULL DEFAULT 0,
20
+ error TEXT
21
+ );
22
+
23
+ CREATE TABLE IF NOT EXISTS portfolio_snapshots (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ ts TEXT NOT NULL UNIQUE,
26
+ nav_usd REAL,
27
+ cash_usd REAL,
28
+ allocation_json TEXT,
29
+ raw_json TEXT
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS market_bars (
33
+ pair TEXT NOT NULL,
34
+ interval_minutes INTEGER NOT NULL,
35
+ bar_ts INTEGER NOT NULL,
36
+ open REAL NOT NULL,
37
+ high REAL NOT NULL,
38
+ low REAL NOT NULL,
39
+ close REAL NOT NULL,
40
+ PRIMARY KEY (pair, interval_minutes, bar_ts)
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS fear_greed (
44
+ ts TEXT PRIMARY KEY,
45
+ value INTEGER NOT NULL,
46
+ classification TEXT,
47
+ fetched_at TEXT NOT NULL
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS kalshi_snapshots (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ ts TEXT NOT NULL,
53
+ tape_summary TEXT,
54
+ cluster_json TEXT,
55
+ raw_json TEXT
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS context_snapshots (
59
+ scope TEXT NOT NULL,
60
+ as_of TEXT NOT NULL,
61
+ context_json TEXT NOT NULL,
62
+ PRIMARY KEY (scope, as_of)
63
+ );
64
+
65
+ CREATE INDEX IF NOT EXISTS idx_context_snapshots_scope_as_of
66
+ ON context_snapshots(scope, as_of);
67
+
68
+ CREATE TABLE IF NOT EXISTS macro_events (
69
+ event_id TEXT PRIMARY KEY,
70
+ event_ts TEXT NOT NULL,
71
+ country TEXT NOT NULL,
72
+ name TEXT NOT NULL,
73
+ impact TEXT NOT NULL,
74
+ category TEXT,
75
+ actual TEXT,
76
+ estimate TEXT,
77
+ previous TEXT,
78
+ unit TEXT,
79
+ source TEXT NOT NULL,
80
+ raw_json TEXT,
81
+ fetched_at TEXT NOT NULL
82
+ );
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_macro_events_ts ON macro_events(event_ts);
85
+
86
+ CREATE TABLE IF NOT EXISTS etf_flow_days (
87
+ asset TEXT NOT NULL,
88
+ flow_date TEXT NOT NULL,
89
+ net_flow_usd REAL,
90
+ total_value_traded_usd REAL,
91
+ total_net_assets_usd REAL,
92
+ cum_net_inflow_usd REAL,
93
+ source TEXT NOT NULL,
94
+ fetched_at TEXT NOT NULL,
95
+ PRIMARY KEY (asset, flow_date)
96
+ );
97
+
98
+ CREATE TABLE IF NOT EXISTS etf_ticker_flows (
99
+ asset TEXT NOT NULL,
100
+ ticker TEXT NOT NULL,
101
+ flow_date TEXT NOT NULL,
102
+ net_flow_usd REAL,
103
+ net_assets_usd REAL,
104
+ institute TEXT,
105
+ source TEXT NOT NULL,
106
+ fetched_at TEXT NOT NULL,
107
+ PRIMARY KEY (asset, ticker, flow_date)
108
+ );
109
+
110
+ CREATE TABLE IF NOT EXISTS crypto_market_snapshots (
111
+ source TEXT NOT NULL,
112
+ snapshot_ts TEXT NOT NULL,
113
+ total_market_cap_usd REAL,
114
+ btc_dominance_pct REAL,
115
+ eth_dominance_pct REAL,
116
+ btc_rank INTEGER,
117
+ eth_rank INTEGER,
118
+ btc_price_usd REAL,
119
+ eth_price_usd REAL,
120
+ btc_market_cap_usd REAL,
121
+ eth_market_cap_usd REAL,
122
+ btc_change_pct_24h REAL,
123
+ eth_change_pct_24h REAL,
124
+ fetched_at TEXT NOT NULL,
125
+ PRIMARY KEY (source, snapshot_ts)
126
+ );
127
+
128
+ CREATE INDEX IF NOT EXISTS idx_crypto_market_snapshots_ts ON crypto_market_snapshots(snapshot_ts);
129
+
130
+ CREATE TABLE IF NOT EXISTS fred_observations (
131
+ series_id TEXT NOT NULL,
132
+ obs_date TEXT NOT NULL,
133
+ value REAL,
134
+ fetched_at TEXT NOT NULL,
135
+ PRIMARY KEY (series_id, obs_date)
136
+ );
137
+
138
+ CREATE INDEX IF NOT EXISTS idx_fred_observations_series_date
139
+ ON fred_observations(series_id, obs_date);
140
+ """
141
+
142
+
143
+ def connect(db_path: Path) -> sqlite3.Connection:
144
+ db_path.parent.mkdir(parents=True, exist_ok=True)
145
+ conn = sqlite3.connect(str(db_path))
146
+ conn.row_factory = sqlite3.Row
147
+ conn.execute("PRAGMA foreign_keys = ON")
148
+ conn.execute("PRAGMA journal_mode = WAL")
149
+ conn.execute("PRAGMA busy_timeout = 5000")
150
+ migrate(conn)
151
+ return conn
152
+
153
+
154
+ def _migrate_brief_archive_to_context_snapshots(conn: sqlite3.Connection) -> None:
155
+ row = conn.execute(
156
+ """
157
+ SELECT name FROM sqlite_master
158
+ WHERE type = 'table' AND name = 'brief_archive'
159
+ """
160
+ ).fetchone()
161
+ if row is None:
162
+ return
163
+ conn.execute(
164
+ """
165
+ INSERT INTO context_snapshots(scope, as_of, context_json)
166
+ SELECT scope, as_of, context_json
167
+ FROM brief_archive
168
+ WHERE context_json IS NOT NULL AND context_json != ''
169
+ ON CONFLICT(scope, as_of) DO NOTHING
170
+ """
171
+ )
172
+
173
+
174
+ def migrate(conn: sqlite3.Connection) -> None:
175
+ conn.executescript(_SCHEMA)
176
+ row = conn.execute(
177
+ "SELECT value FROM schema_meta WHERE key = 'version'"
178
+ ).fetchone()
179
+ if row is None:
180
+ conn.execute(
181
+ "INSERT INTO schema_meta(key, value) VALUES ('version', ?)",
182
+ (str(SCHEMA_VERSION),),
183
+ )
184
+ conn.commit()
185
+ elif int(row["value"]) < SCHEMA_VERSION:
186
+ if int(row["value"]) <= 7:
187
+ _migrate_brief_archive_to_context_snapshots(conn)
188
+ conn.execute(
189
+ "UPDATE schema_meta SET value = ? WHERE key = 'version'",
190
+ (str(SCHEMA_VERSION),),
191
+ )
192
+ conn.commit()
193
+ elif int(row["value"]) > SCHEMA_VERSION:
194
+ raise RuntimeError(
195
+ f"database schema version {row['value']} is newer than "
196
+ f"supported {SCHEMA_VERSION}; upgrade the application"
197
+ )
198
+
199
+
200
+ def record_ingest_run(
201
+ conn: sqlite3.Connection,
202
+ *,
203
+ source: str,
204
+ started_at: str,
205
+ finished_at: str,
206
+ rows_upserted: int,
207
+ error: str | None = None,
208
+ ) -> None:
209
+ conn.execute(
210
+ """
211
+ INSERT INTO ingest_runs(source, started_at, finished_at, rows_upserted, error)
212
+ VALUES (?, ?, ?, ?, ?)
213
+ """,
214
+ (source, started_at, finished_at, rows_upserted, error),
215
+ )
216
+ conn.commit()
@@ -0,0 +1,10 @@
1
+ """Canonical JSON serialization for persisted snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def canonical_json(obj: Any) -> str:
10
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+
6
+ def get_meta(conn: sqlite3.Connection, key: str) -> str | None:
7
+ row = conn.execute(
8
+ "SELECT value FROM schema_meta WHERE key = ?", (key,)
9
+ ).fetchone()
10
+ return str(row["value"]) if row else None
11
+
12
+
13
+ def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
14
+ conn.execute(
15
+ """
16
+ INSERT INTO schema_meta(key, value) VALUES (?, ?)
17
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
18
+ """,
19
+ (key, value),
20
+ )
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from alloccontext.horizon import cutoff_iso, cutoff_unix, horizon_days
8
+
9
+
10
+ def prune_to_horizon(conn: sqlite3.Connection, config) -> dict[str, int]:
11
+ """Drop rows older than the configured quarterly horizon."""
12
+ days = horizon_days(config)
13
+ fg_cutoff = str(cutoff_unix(days=days))
14
+ bar_cutoff = cutoff_unix(days=days)
15
+ ts_cutoff = cutoff_iso(days=days)
16
+
17
+ deleted: dict[str, int] = {}
18
+ deleted["fear_greed"] = conn.execute(
19
+ "DELETE FROM fear_greed WHERE CAST(ts AS INTEGER) < ?",
20
+ (fg_cutoff,),
21
+ ).rowcount
22
+ deleted["market_bars"] = conn.execute(
23
+ "DELETE FROM market_bars WHERE bar_ts < ?",
24
+ (bar_cutoff,),
25
+ ).rowcount
26
+ deleted["portfolio_snapshots"] = conn.execute(
27
+ "DELETE FROM portfolio_snapshots WHERE ts < ?",
28
+ (ts_cutoff,),
29
+ ).rowcount
30
+ deleted["kalshi_snapshots"] = conn.execute(
31
+ "DELETE FROM kalshi_snapshots WHERE ts < ?",
32
+ (ts_cutoff,),
33
+ ).rowcount
34
+ deleted["context_snapshots"] = conn.execute(
35
+ "DELETE FROM context_snapshots WHERE as_of < ?",
36
+ (ts_cutoff,),
37
+ ).rowcount
38
+ deleted["macro_events"] = conn.execute(
39
+ "DELETE FROM macro_events WHERE event_ts < ?",
40
+ (ts_cutoff,),
41
+ ).rowcount
42
+ deleted["etf_flow_days"] = conn.execute(
43
+ "DELETE FROM etf_flow_days WHERE flow_date < ?",
44
+ (ts_cutoff[:10],),
45
+ ).rowcount
46
+ deleted["etf_ticker_flows"] = conn.execute(
47
+ "DELETE FROM etf_ticker_flows WHERE flow_date < ?",
48
+ (ts_cutoff[:10],),
49
+ ).rowcount
50
+ deleted["crypto_market_snapshots"] = conn.execute(
51
+ "DELETE FROM crypto_market_snapshots WHERE snapshot_ts < ?",
52
+ (ts_cutoff,),
53
+ ).rowcount
54
+ deleted["fred_observations"] = conn.execute(
55
+ "DELETE FROM fred_observations WHERE obs_date < ?",
56
+ (ts_cutoff[:10],),
57
+ ).rowcount
58
+ deleted["ingest_runs"] = conn.execute(
59
+ "DELETE FROM ingest_runs WHERE finished_at < ?",
60
+ (ts_cutoff,),
61
+ ).rowcount
62
+ conn.commit()
63
+ return deleted
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from alloccontext.mcp.staleness import age_seconds, parse_as_of
8
+ from alloccontext.timeutil import utc_now
9
+
10
+
11
+ def _finished_age_seconds(
12
+ finished_at: str | None,
13
+ *,
14
+ now: datetime | None = None,
15
+ ) -> int | None:
16
+ if not finished_at:
17
+ return None
18
+ try:
19
+ return age_seconds(parse_as_of(str(finished_at)), now=now)
20
+ except (TypeError, ValueError):
21
+ return None
22
+
23
+
24
+ def _source_health(
25
+ last_by_source: dict[str, dict[str, Any]],
26
+ *,
27
+ now: datetime | None = None,
28
+ ) -> dict[str, dict[str, Any]]:
29
+ ref = now or utc_now()
30
+ health: dict[str, dict[str, Any]] = {}
31
+ for source, row in last_by_source.items():
32
+ error = row.get("error")
33
+ finished_at = row.get("finished_at")
34
+ health[source] = {
35
+ "ok": error is None,
36
+ "finished_at": finished_at,
37
+ "age_seconds": _finished_age_seconds(finished_at, now=ref),
38
+ "rows_upserted": row.get("rows_upserted"),
39
+ "error": error,
40
+ }
41
+ return health
42
+
43
+
44
+ def ingest_status(
45
+ conn: sqlite3.Connection,
46
+ *,
47
+ now: datetime | None = None,
48
+ ) -> dict[str, Any]:
49
+ fg = conn.execute(
50
+ """
51
+ SELECT ts, value, classification, fetched_at
52
+ FROM fear_greed ORDER BY CAST(ts AS INTEGER) DESC LIMIT 1
53
+ """
54
+ ).fetchone()
55
+ portfolio = conn.execute(
56
+ """
57
+ SELECT ts, nav_usd, cash_usd, allocation_json
58
+ FROM portfolio_snapshots ORDER BY ts DESC LIMIT 1
59
+ """
60
+ ).fetchone()
61
+ bars = conn.execute(
62
+ """
63
+ SELECT pair, interval_minutes, MAX(bar_ts) AS latest_bar_ts, COUNT(*) AS bar_count
64
+ FROM market_bars
65
+ GROUP BY pair, interval_minutes
66
+ ORDER BY pair
67
+ """
68
+ ).fetchall()
69
+ recent_ingest = conn.execute(
70
+ """
71
+ SELECT source, started_at, finished_at, rows_upserted, error
72
+ FROM ingest_runs ORDER BY id DESC LIMIT 20
73
+ """
74
+ ).fetchall()
75
+ last_by_source: dict[str, dict[str, Any]] = {}
76
+ for row in recent_ingest:
77
+ source = str(row["source"])
78
+ if source not in last_by_source:
79
+ last_by_source[source] = dict(row)
80
+
81
+ ref = now or utc_now()
82
+ return {
83
+ "fear_greed_latest": dict(fg) if fg else None,
84
+ "portfolio_latest": dict(portfolio) if portfolio else None,
85
+ "market_bars": [dict(row) for row in bars],
86
+ "last_ingest_by_source": last_by_source,
87
+ "source_health": _source_health(last_by_source, now=ref),
88
+ "recent_ingest": [dict(row) for row in recent_ingest[:10]],
89
+ }
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+
5
+
6
+ def utc_now() -> datetime:
7
+ return datetime.now(timezone.utc).replace(microsecond=0)
8
+
9
+
10
+ def utc_now_iso() -> str:
11
+ return utc_now().isoformat()
@@ -0,0 +1,193 @@
1
+ """x402 production verification (CDP facilitator + discovery endpoints)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ from dataclasses import dataclass
10
+ from typing import Mapping
11
+
12
+
13
+ class X402ProductionCheckError(RuntimeError):
14
+ """One or more production checks failed."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class X402CheckConfig:
19
+ public_url: str
20
+ local_url: str
21
+ pay_to: str
22
+ network: str
23
+ facilitator: str
24
+ cdp_api_key_id: str | None
25
+ cdp_api_key_secret: str | None
26
+
27
+
28
+ def load_check_config(env: Mapping[str, str] | None = None) -> X402CheckConfig:
29
+ source = env if env is not None else os.environ
30
+ public_url = source.get("X402_PUBLIC_URL", "").rstrip("/")
31
+ if not public_url:
32
+ raise X402ProductionCheckError("X402_PUBLIC_URL is required")
33
+ pay_to = source.get("X402_PAY_TO", "").strip()
34
+ if not pay_to:
35
+ raise X402ProductionCheckError("X402_PAY_TO is required")
36
+ return X402CheckConfig(
37
+ public_url=public_url,
38
+ local_url=source.get("X402_CHECK_LOCAL", "http://127.0.0.1:8000").rstrip("/"),
39
+ pay_to=pay_to,
40
+ network=source.get("X402_NETWORK", "eip155:84532").strip(),
41
+ facilitator=source.get(
42
+ "X402_FACILITATOR_URL",
43
+ "https://x402.org/facilitator",
44
+ ).strip(),
45
+ cdp_api_key_id=source.get("CDP_API_KEY_ID"),
46
+ cdp_api_key_secret=source.get("CDP_API_KEY_SECRET"),
47
+ )
48
+
49
+
50
+ def check_cdp_facilitator(config: X402CheckConfig) -> str:
51
+ if not config.facilitator.startswith("https://api.cdp.coinbase.com"):
52
+ return f"facilitator {config.facilitator} (non-CDP)"
53
+ if not (config.cdp_api_key_id and config.cdp_api_key_secret):
54
+ raise X402ProductionCheckError(
55
+ "CDP facilitator requires CDP_API_KEY_ID and CDP_API_KEY_SECRET"
56
+ )
57
+ try:
58
+ import httpx
59
+ from cdp.x402 import create_facilitator_config
60
+ except ImportError as exc:
61
+ raise X402ProductionCheckError(
62
+ "cdp-sdk and httpx required for CDP facilitator check"
63
+ ) from exc
64
+ cfg = create_facilitator_config()
65
+ headers = cfg["create_headers"]()["supported"]
66
+ with httpx.Client(timeout=30) as client:
67
+ response = client.get(f"{cfg['url']}/supported", headers=headers)
68
+ if response.status_code != 200:
69
+ raise X402ProductionCheckError(
70
+ f"CDP /supported returned {response.status_code}: {response.text[:200]}"
71
+ )
72
+ return "CDP facilitator /supported authenticated"
73
+
74
+
75
+ def _fetch_ok(url: str, *, timeout: float = 20) -> tuple[int, bytes]:
76
+ with urllib.request.urlopen(url, timeout=timeout) as response:
77
+ return response.status, response.read()
78
+
79
+
80
+ def check_discovery_paths(config: X402CheckConfig) -> list[str]:
81
+ messages: list[str] = []
82
+ paths = ("/health", "/llms.txt", "/.well-known/x402.json")
83
+ for path in paths:
84
+ checked = False
85
+ for base in (config.local_url, config.public_url):
86
+ if not base:
87
+ continue
88
+ url = f"{base}{path}"
89
+ try:
90
+ status, _body = _fetch_ok(url)
91
+ except urllib.error.HTTPError as exc:
92
+ if base == config.local_url:
93
+ continue
94
+ raise X402ProductionCheckError(f"{path} returned HTTP {exc.code}") from exc
95
+ except urllib.error.URLError:
96
+ if base == config.local_url:
97
+ continue
98
+ raise
99
+ else:
100
+ if status != 200:
101
+ if base == config.local_url:
102
+ continue
103
+ raise X402ProductionCheckError(f"{path} returned HTTP {status}")
104
+ messages.append(f"GET {path} -> 200 ({base})")
105
+ checked = True
106
+ break
107
+ if not checked:
108
+ raise X402ProductionCheckError(
109
+ f"could not reach {path} on local or public URL"
110
+ )
111
+ return messages
112
+
113
+
114
+ def check_manifest_pay_to(config: X402CheckConfig) -> None:
115
+ manifest_base = config.local_url or config.public_url
116
+ _status, body = _fetch_ok(f"{manifest_base}/.well-known/x402.json")
117
+ manifest = json.loads(body)
118
+ if manifest.get("payment", {}).get("payTo") != config.pay_to:
119
+ raise X402ProductionCheckError("x402.json payTo does not match X402_PAY_TO")
120
+
121
+
122
+ def check_discovery_metadata(config: X402CheckConfig) -> list[str]:
123
+ """Assert static discovery copy includes Bazaar title, tags, and keywords."""
124
+ from alloccontext.mcp.bazaar import (
125
+ BAZAAR_INDEX_TAGS,
126
+ BAZAAR_SERVICE_NAME,
127
+ DISCOVERY_KEYWORD_MARKERS,
128
+ SERVICE_NAME,
129
+ SERVICE_TITLE,
130
+ )
131
+
132
+ messages: list[str] = []
133
+ manifest_base = config.local_url or config.public_url
134
+ _status, manifest_body = _fetch_ok(f"{manifest_base}/.well-known/x402.json")
135
+ manifest = json.loads(manifest_body)
136
+ if manifest.get("name") != SERVICE_NAME:
137
+ raise X402ProductionCheckError("x402.json name missing or wrong")
138
+ if manifest.get("title") != SERVICE_TITLE:
139
+ raise X402ProductionCheckError("x402.json title missing or wrong")
140
+ manifest_tags = manifest.get("tags") or []
141
+ for tag in BAZAAR_INDEX_TAGS:
142
+ if tag not in manifest_tags:
143
+ raise X402ProductionCheckError(f"x402.json missing tag {tag!r}")
144
+ messages.append(f"x402.json title/tags ok ({len(manifest_tags)} tags)")
145
+
146
+ _status, llms_body = _fetch_ok(f"{manifest_base}/llms.txt")
147
+ llms = llms_body.decode("utf-8")
148
+ if BAZAAR_SERVICE_NAME not in llms and SERVICE_TITLE not in llms:
149
+ raise X402ProductionCheckError("llms.txt missing service title")
150
+ for marker in DISCOVERY_KEYWORD_MARKERS:
151
+ if marker not in llms.lower():
152
+ raise X402ProductionCheckError(f"llms.txt missing keyword marker {marker!r}")
153
+ messages.append("llms.txt discovery keywords ok")
154
+ return messages
155
+
156
+
157
+ def check_mcp_payment_gate(config: X402CheckConfig) -> str:
158
+ mcp_base = config.local_url or config.public_url
159
+ req = urllib.request.Request(
160
+ f"{mcp_base}/mcp",
161
+ data=b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
162
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
163
+ method="POST",
164
+ )
165
+ try:
166
+ urllib.request.urlopen(req, timeout=20)
167
+ raise X402ProductionCheckError("POST /mcp should return 402 without payment")
168
+ except urllib.error.HTTPError as exc:
169
+ if exc.code != 402:
170
+ raise X402ProductionCheckError(
171
+ f"POST /mcp returned HTTP {exc.code}, expected 402"
172
+ ) from exc
173
+ payment_required = exc.headers.get("PAYMENT-REQUIRED") or exc.headers.get(
174
+ "payment-required"
175
+ )
176
+ if not payment_required:
177
+ raise X402ProductionCheckError("POST /mcp 402 missing PAYMENT-REQUIRED header")
178
+ return "POST /mcp returns 402 with PAYMENT-REQUIRED"
179
+
180
+
181
+ def run_production_checks(env: Mapping[str, str] | None = None) -> list[str]:
182
+ """Run all x402 production checks; return OK messages."""
183
+ config = load_check_config(env)
184
+ messages = [
185
+ f"public URL {config.public_url}",
186
+ f"network {config.network}",
187
+ ]
188
+ messages.append(check_cdp_facilitator(config))
189
+ messages.extend(check_discovery_paths(config))
190
+ check_manifest_pay_to(config)
191
+ messages.extend(check_discovery_metadata(config))
192
+ messages.append(check_mcp_payment_gate(config))
193
+ return messages
@@ -0,0 +1,41 @@
1
+ """Redact EVM addresses from paid smoke logs (CI-safe output)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+
8
+ _EVM_ADDRESS = re.compile(r"0x[a-fA-F0-9]{40}")
9
+
10
+
11
+ def smoke_log_addresses_enabled() -> bool:
12
+ """When true, print full 0x addresses (local debugging only)."""
13
+ return os.environ.get("SMOKE_LOG_ADDRESSES", "").strip().lower() in (
14
+ "1",
15
+ "true",
16
+ "yes",
17
+ )
18
+
19
+
20
+ def mask_evm_address(address: str) -> str:
21
+ """Return a shortened address safe for CI logs."""
22
+ value = address.strip()
23
+ if not value.startswith("0x") or len(value) < 10:
24
+ return value
25
+ return f"{value[:6]}…{value[-4:]}"
26
+
27
+
28
+ def redact_evm_addresses(text: str) -> str:
29
+ """Replace EVM addresses in free-form text unless logging is enabled."""
30
+ if smoke_log_addresses_enabled():
31
+ return text
32
+
33
+ def _replace(match: re.Match[str]) -> str:
34
+ return mask_evm_address(match.group(0))
35
+
36
+ return _EVM_ADDRESS.sub(_replace, text)
37
+
38
+
39
+ def smoke_log(message: str) -> None:
40
+ """Print a line with addresses redacted unless SMOKE_LOG_ADDRESSES is set."""
41
+ print(redact_evm_addresses(message))