From f7d42390d872391970eb38a7516dc1f7e3e4ec25 Mon Sep 17 00:00:00 2001 From: Harald Roessler Date: Wed, 1 Apr 2026 14:41:52 +0700 Subject: [PATCH 1/2] chore: remove .bak files, add requirements.txt, Dockerfile, and .env.example Remove 11 backup files that contained outdated code including the old hardcoded API key. Add .bak* to .gitignore to prevent future commits. Add requirements.txt documenting all Python dependencies, Dockerfile for containerized deployment, .dockerignore for clean builds, and .env.example documenting all required and optional environment variables. Co-Authored-By: Claude Opus 4.6 (1M context) --- .dockerignore | 22 + .env.example | 48 + .gitignore | 7 +- Dockerfile | 16 + agents/ambassador.py.bak | 1070 ----------- agents/ambassador.py.bak2 | 820 --------- agents/ambassador.py.bak3 | 965 ---------- agents/moltbook_poster.py.bak | 547 ------ agents/workspace/ambassador/SOUL.md.bak | 27 - agents/workspace/trustscout/IDENTITY.md.bak | 7 - agents/workspace/trustscout/RULES.md.bak | 22 - agents/workspace/trustscout/SOUL.md.bak | 17 - app/main.py.bak | 1583 ----------------- app/main.py.bak2 | 1768 ------------------- operator/agent.py.bak | 107 -- requirements.txt | 13 + 16 files changed, 105 insertions(+), 6934 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile delete mode 100644 agents/ambassador.py.bak delete mode 100644 agents/ambassador.py.bak2 delete mode 100644 agents/ambassador.py.bak3 delete mode 100644 agents/moltbook_poster.py.bak delete mode 100644 agents/workspace/ambassador/SOUL.md.bak delete mode 100644 agents/workspace/trustscout/IDENTITY.md.bak delete mode 100644 agents/workspace/trustscout/RULES.md.bak delete mode 100644 agents/workspace/trustscout/SOUL.md.bak delete mode 100644 app/main.py.bak delete mode 100644 app/main.py.bak2 delete mode 100644 operator/agent.py.bak create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ad918d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.gitignore +__pycache__ +*.pyc +*.bak* +.env +.env.* +*_private_key* +*.key +*.pem +.aws/ +.vscode/ +.idea/ +.pytest_cache/ +htmlcov/ +.coverage +node_modules/ +reviews/ +data/ +logs/ +backups/ +wallet/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5325660 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ============================================================ +# MolTrust API — Environment Variables +# Copy to .env and fill in values before running +# ============================================================ + +# --- REQUIRED --- +MOLTRUST_API_KEYS=your_api_key_1,your_api_key_2 + +# --- Database --- +DATABASE_URL=postgresql://moltstack:password@localhost/moltstack +DB_NAME=moltstack +MOLTSTACK_DB_PW= + +# --- Cryptographic Signing (choose one) --- +# Option 1 (recommended): AWS KMS encrypted key +DID_PRIVATE_KEY_ENCRYPTED= +KMS_KEY_ID= +AWS_REGION=eu-central-1 +# Option 2 (development only): hex-encoded Ed25519 private key +# DID_PRIVATE_KEY_HEX= + +# --- Blockchain (Base L2) --- +BASE_WALLET_KEY= +BASE_WRITE_KEY= +BASE_RPC=https://mainnet.base.org +BASE_ADDR= + +# --- Admin --- +ADMIN_KEY= +NONCE_SECRET= + +# --- GitHub OAuth --- +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# --- Moltbook --- +MOLTBOOK_APP_KEY= + +# --- SMTP (Email) --- +SMTP_HOST=mail.infomaniak.com +SMTP_PORT=587 +SMTP_USER=info@moltrust.ch +SMTP_PASS= + +# --- Optional Services --- +CREDITS_ENABLED=false +APIFOOTBALL_KEY= +BASESCAN_WEBHOOK_SECRET= diff --git a/.gitignore b/.gitignore index ea25fa0..a562198 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Environment & Secrets .env .env.* -*.env +!.env.example .secret *_private_key* *.key @@ -11,6 +11,11 @@ .aws/ aws-credentials +# Backup files +*.bak +*.bak2 +*.bak3 + # Python __pycache__/ *.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..76a52ab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/agents/ambassador.py.bak b/agents/ambassador.py.bak deleted file mode 100644 index 1c6cff7..0000000 --- a/agents/ambassador.py.bak +++ /dev/null @@ -1,1070 +0,0 @@ -#!/usr/bin/env python3 -"""MolTrust Ambassador Agent — Auto-reply to comments on our Moltbook posts. - -Fully automated: detects new comments, generates a reply via Claude, posts it. -Uses workspace bootstrap pattern for identity, personality, and memory. - -Usage: - ambassador.py run — Check for new comments and reply automatically - ambassador.py status — Print stats to stdout - ambassador.py post — Generate and post a new topic to m/agenttrust -""" - -import argparse -import json -import logging -import os -import re -import time -from datetime import datetime, timezone, timedelta -from pathlib import Path - -import httpx - -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- - -MOLTBOOK_BASE = "https://www.moltbook.com/api/v1" -OUR_AUTHOR = "moltrust-agent" - -STATE_FILE = Path.home() / ".ambassador_state.json" -LOG_FILE = Path.home() / "moltstack" / "logs" / "ambassador.log" - -# Workspace paths -WORKSPACE = Path.home() / "moltstack" / "agents" / "workspace" / "ambassador" -WS_IDENTITY = WORKSPACE / "IDENTITY.md" -WS_SOUL = WORKSPACE / "SOUL.md" -WS_RULES = WORKSPACE / "RULES.md" -WS_MEMORY = WORKSPACE / "MEMORY.md" -WS_HEARTBEAT = WORKSPACE / "HEARTBEAT.md" -WS_TOOLS = WORKSPACE / "TOOLS.md" -WS_LOGS = WORKSPACE / "logs" - -# Rate limits -AGENT_REPLY_LIMIT_24H = 3 # max replies to same agent in 24h - -# --------------------------------------------------------------------------- -# Logging — stdout only, cron redirect handles file output -# --------------------------------------------------------------------------- - -logging.basicConfig( - level=logging.INFO, - format="[%(asctime)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%dT%H:%M:%S", - handlers=[ - logging.StreamHandler(), - ], -) -log = logging.getLogger("ambassador") - -# --------------------------------------------------------------------------- -# Secrets -# --------------------------------------------------------------------------- - - -def load_key(name: str) -> str: - secrets = Path.home() / ".moltrust_secrets" - if secrets.exists(): - for line in secrets.read_text().splitlines(): - line = line.strip() - if line.startswith("#") or not line: - continue - if line.startswith("export "): - line = line[7:] - if line.startswith(f"{name}="): - return line.split("=", 1)[1].strip() - return os.environ.get(name, "") - - -MOLTBOOK_KEY = "" -ANTHROPIC_KEY = "" - - -def init_keys(): - global MOLTBOOK_KEY, ANTHROPIC_KEY - MOLTBOOK_KEY = load_key("MOLTBOOK_AGENT_KEY") - - ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "") - if not ANTHROPIC_KEY: - key_file = Path.home() / ".anthropic_key" - if key_file.exists(): - ANTHROPIC_KEY = key_file.read_text().strip() - - -# --------------------------------------------------------------------------- -# Workspace Bootstrap Loader -# --------------------------------------------------------------------------- - - -def _read_ws(path: Path) -> str: - """Read a workspace file, return empty string if missing.""" - if path.exists(): - return path.read_text().strip() - return "" - - -def _today_log_path() -> Path: - """Return path to today's workspace log file.""" - return WS_LOGS / f"{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.md" - - -def load_bootstrap() -> str: - """Load core bootstrap context: IDENTITY + SOUL + RULES + today's log. - Always loaded into the system prompt. Budget: ~2000 tokens.""" - parts = [] - - identity = _read_ws(WS_IDENTITY) - if identity: - parts.append(f"=== IDENTITY ===\n{identity}") - - soul = _read_ws(WS_SOUL) - if soul: - parts.append(f"=== SOUL ===\n{soul}") - - rules = _read_ws(WS_RULES) - if rules: - parts.append(f"=== RULES ===\n{rules}") - - today_log = _read_ws(_today_log_path()) - if today_log: - # Only include last 20 lines to stay within token budget - lines = today_log.strip().splitlines() - if len(lines) > 20: - lines = lines[-20:] - parts.append(f"=== TODAY'S LOG (last {len(lines)} entries) ===\n" + "\n".join(lines)) - - return "\n\n".join(parts) - - -def load_memory_for_agent(agent_id: str) -> str | None: - """On-demand: load MEMORY.md only if agent_id appears in it.""" - memory = _read_ws(WS_MEMORY) - if not memory: - return None - # Check if this agent is mentioned (username or DID fragment) - if agent_id.lower() in memory.lower(): - return memory - return None - - -def load_heartbeat() -> str: - """On-demand: load HEARTBEAT.md for heartbeat checks only.""" - return _read_ws(WS_HEARTBEAT) - - -# --------------------------------------------------------------------------- -# Workspace Memory — Rate Limit & Dedup -# --------------------------------------------------------------------------- - -_MEMORY_ENTRY_RE = re.compile( - r"^### (.+?) — (\d{4}-\d{2}-\d{2}) — .+$", re.MULTILINE -) -_MEMORY_REPLY_RE = re.compile( - r"^→ Reply: (.+)$", re.MULTILINE -) - - -def _parse_memory_entries(agent_id: str) -> list[dict]: - """Parse MEMORY.md and return entries for a specific agent.""" - memory = _read_ws(WS_MEMORY) - if not memory: - return [] - - entries = [] - blocks = re.split(r"(?=^### )", memory, flags=re.MULTILINE) - for block in blocks: - header = _MEMORY_ENTRY_RE.search(block) - if not header: - continue - name = header.group(1).strip() - if name.lower() != agent_id.lower(): - continue - date_str = header.group(2) - reply_match = _MEMORY_REPLY_RE.search(block) - reply_fp = reply_match.group(1).strip() if reply_match else "" - entries.append({"name": name, "date": date_str, "reply_fp": reply_fp}) - - return entries - - -def check_agent_rate_limit(agent_id: str) -> bool: - """Return True if agent has hit the 24h reply rate limit.""" - entries = _parse_memory_entries(agent_id) - if not entries: - return False - - now = datetime.now(timezone.utc) - cutoff = (now - timedelta(hours=24)).strftime("%Y-%m-%d") - recent = [e for e in entries if e["date"] >= cutoff] - return len(recent) >= AGENT_REPLY_LIMIT_24H - - -def check_reply_dedup(agent_id: str, reply_text: str) -> str | None: - """Check if first 5 words of reply match recent replies to this agent. - Returns the matching fingerprint if duplicate found, None otherwise.""" - fingerprint = " ".join(reply_text.split()[:5]) - entries = _parse_memory_entries(agent_id) - # Check last 10 entries - for entry in entries[-10:]: - if entry["reply_fp"] and entry["reply_fp"].lower() == fingerprint.lower(): - return fingerprint - return None - - -# --------------------------------------------------------------------------- -# Workspace Memory Writer -# --------------------------------------------------------------------------- - - -def write_memory_entry(agent_id: str, date_str: str, context: str, status: str, reply_fingerprint: str = ""): - """Append an entry to MEMORY.md after each reply.""" - entry = f"\n### {agent_id} — {date_str} — {context}\n→ Status: {status}\n" - if reply_fingerprint: - entry += f"→ Reply: {reply_fingerprint}\n" - WS_MEMORY.parent.mkdir(parents=True, exist_ok=True) - with open(WS_MEMORY, "a") as f: - f.write(entry) - log.info(f"MEMORY: wrote entry for {agent_id} ({status})") - - -# --------------------------------------------------------------------------- -# Workspace Log Writer -# --------------------------------------------------------------------------- - - -def write_log_entry(entry_type: str, message: str): - """Append a timestamped entry to today's workspace log.""" - WS_LOGS.mkdir(parents=True, exist_ok=True) - log_path = _today_log_path() - now = datetime.now(timezone.utc).strftime("%H:%M") - line = f"[{now}] {entry_type}: {message}\n" - - # Create daily log with header if new - if not log_path.exists(): - header = f"# Ambassador Log — {datetime.now(timezone.utc).strftime('%Y-%m-%d')}\n\n" - log_path.write_text(header) - - with open(log_path, "a") as f: - f.write(line) - - -# --------------------------------------------------------------------------- -# State -# --------------------------------------------------------------------------- - -DEFAULT_STATE = { - "seen_comments": {}, # post_id -> [comment_id, ...] - "replies_posted": 0, # total replies posted - "agent_replies": {}, # author_name -> count of replies we sent them - "nudged_agents": [], # agents who already received a Stage 2 CTA -} - - -def load_state() -> dict: - if STATE_FILE.exists(): - try: - return json.loads(STATE_FILE.read_text()) - except Exception: - pass - return json.loads(json.dumps(DEFAULT_STATE)) - - -def save_state(state: dict): - STATE_FILE.write_text(json.dumps(state, indent=2)) - - -# --------------------------------------------------------------------------- -# Math challenge solver (from heartbeat.py) -# --------------------------------------------------------------------------- - - -def _collapse(s: str) -> str: - return re.sub(r"(.)\1+", r"\1", s) - - -_NUM_BASE = [ - ("zero", 0), ("one", 1), ("two", 2), ("three", 3), ("four", 4), - ("five", 5), ("six", 6), ("seven", 7), ("eight", 8), ("nine", 9), - ("ten", 10), ("eleven", 11), ("twelve", 12), ("thirteen", 13), - ("fourteen", 14), ("fifteen", 15), ("sixteen", 16), ("seventeen", 17), - ("eighteen", 18), ("nineteen", 19), ("twenty", 20), ("thirty", 30), - ("forty", 40), ("fifty", 50), ("sixty", 60), ("seventy", 70), - ("eighty", 80), ("ninety", 90), -] -NUM_LOOKUP: dict[str, int] = {} -for _w, _v in _NUM_BASE: - NUM_LOOKUP[_w] = _v - _c = _collapse(_w) - if _c != _w: - NUM_LOOKUP[_c] = _v - -_OP_BASE = [ - ("plus", "+"), ("and", "+"), ("add", "+"), ("added", "+"), ("adding", "+"), ("adds", "+"), - ("minus", "-"), ("subtract", "-"), ("subtracted", "-"), - ("less", "-"), ("reduced", "-"), ("reduces", "-"), - ("decreased", "-"), ("decreases", "-"), ("decrease", "-"), - ("slows", "-"), ("slowed", "-"), - ("times", "*"), ("multiplied", "*"), ("multiply", "*"), - ("divided", "/"), ("divides", "/"), ("over", "/"), -] -OP_LOOKUP: dict[str, str] = {} -for _w, _o in _OP_BASE: - OP_LOOKUP[_w] = _o - _c = _collapse(_w) - if _c != _w: - OP_LOOKUP[_c] = _o - - -def _combine_tens_units(nums: list) -> list: - combined = [] - i = 0 - while i < len(nums): - v = nums[i] - if 20 <= v <= 90 and i + 1 < len(nums) and 1 <= nums[i + 1] <= 9: - combined.append(v + nums[i + 1]) - i += 2 - else: - combined.append(v) - i += 1 - return combined - - -def _compute(a: float, b: float, op: str) -> str | None: - if op == "+": - result = a + b - elif op == "-": - result = a - b - elif op == "*": - result = a * b - elif op == "/": - result = a / b if b != 0 else 0 - else: - return None - return f"{result:.2f}" - - -def solve_challenge(text: str) -> str | None: - clean = re.sub(r"[^a-zA-Z ]+", "", text).lower() - words = [_collapse(w) for w in clean.split() if w] - nums: list[int] = [] - op: str | None = None - for w in words: - if w in NUM_LOOKUP: - nums.append(NUM_LOOKUP[w]) - elif w in OP_LOOKUP and op is None: - op = OP_LOOKUP[w] - if op is None: - for i in range(len(words) - 1): - compound = words[i] + words[i + 1] - if compound in OP_LOOKUP: - op = OP_LOOKUP[compound] - break - combined = _combine_tens_units(nums) - if len(combined) >= 2 and op is not None: - return _compute(combined[0], combined[1], op) - - stream = _collapse(re.sub(r"[^a-zA-Z]", "", text).lower()) - num_entries = sorted(NUM_LOOKUP.items(), key=lambda x: len(x[0]), reverse=True) - op_entries = sorted(OP_LOOKUP.items(), key=lambda x: len(x[0]), reverse=True) - used: set[int] = set() - stream_nums: list[tuple[int, int]] = [] - for word, val in num_entries: - for m in re.finditer(re.escape(word), stream): - r = set(range(m.start(), m.end())) - if not r & used: - stream_nums.append((m.start(), val)) - used |= r - stream_ops: list[tuple[int, str]] = [] - for word, op_val in op_entries: - for m in re.finditer(re.escape(word), stream): - r = set(range(m.start(), m.end())) - if not r & used: - stream_ops.append((m.start(), op_val)) - used |= r - break - stream_nums.sort() - stream_ops.sort() - s_nums = _combine_tens_units([v for _, v in stream_nums]) - s_op = stream_ops[0][1] if stream_ops else (op or "*") - if len(s_nums) >= 2: - return _compute(s_nums[0], s_nums[1], s_op) - - digits = [float(d) for d in re.findall(r"\d+\.?\d*", text)] - if len(digits) >= 2: - return _compute(digits[0], digits[1], op or "*") - return None - - -# --------------------------------------------------------------------------- -# Moltbook API -# --------------------------------------------------------------------------- - - -def moltbook_get(client: httpx.Client, path: str, **params) -> dict | list | None: - try: - r = client.get( - f"{MOLTBOOK_BASE}{path}", - headers={"Authorization": f"Bearer {MOLTBOOK_KEY}"}, - params=params, - timeout=15, - ) - if r.status_code == 200: - return r.json() - log.warning(f"GET {path} -> {r.status_code}: {r.text[:200]}") - except Exception as e: - log.error(f"GET {path} error: {e}") - return None - - -def moltbook_post(client: httpx.Client, path: str, body: dict) -> dict | None: - for attempt in range(3): - try: - r = client.post( - f"{MOLTBOOK_BASE}{path}", - headers={"Authorization": f"Bearer {MOLTBOOK_KEY}", "Content-Type": "application/json"}, - json=body, - timeout=15, - ) - if r.status_code in (200, 201): - return r.json() - if r.status_code == 429: - retry_after = r.json().get("retry_after_seconds", 25) - log.info(f"Rate limited, waiting {retry_after}s (attempt {attempt+1}/3)") - time.sleep(retry_after + 1) - continue - log.warning(f"POST {path} -> {r.status_code}: {r.text[:300]}") - return None - except Exception as e: - log.error(f"POST {path} error: {e}") - return None - log.warning(f"POST {path} failed after 3 attempts") - return None - - -def solve_verification(client: httpx.Client, data: dict) -> bool: - verification = data.get("verification") or data.get("post", {}).get("verification") - if not verification: - return True - code = verification.get("verification_code", "") - challenge = verification.get("challenge_text", "") - if not code or not challenge: - return True - log.info(f"Verification challenge: {challenge[:80]}...") - answer = solve_challenge(challenge) - if not answer: - log.error("Failed to solve math challenge") - return False - result = moltbook_post(client, "/verify", {"verification_code": code, "answer": answer}) - if result and result.get("success"): - log.info("Verification solved!") - return True - log.error(f"Verification failed: {result}") - return False - - -def get_our_posts(client: httpx.Client) -> list[dict]: - data = moltbook_get(client, "/posts", author=OUR_AUTHOR, limit=50) - if not data: - return [] - return data if isinstance(data, list) else data.get("posts", data.get("data", [])) - - -def get_comments(client: httpx.Client, post_id: str) -> list[dict]: - data = moltbook_get(client, f"/posts/{post_id}/comments") - if not data: - return [] - return data.get("comments", []) - - -def post_reply(client: httpx.Client, post_id: str, content: str, parent_id: str) -> dict | None: - body = {"content": content, "parent_id": parent_id} - result = moltbook_post(client, f"/posts/{post_id}/comments", body) - if result: - solve_verification(client, result) - return result - - -# --------------------------------------------------------------------------- -# Claude reply generation (with workspace bootstrap) -# --------------------------------------------------------------------------- - -# Low-effort patterns to skip -LOW_EFFORT_PATTERNS = re.compile( - r"^(\+1|nice|cool|great|thanks|lol|wow|ok|yes|no|agreed|this|same|love it|fire|based|true|real|💯|🔥|👍|❤️|🙌|👏|💪|✅)\s*[.!]?\s*$", - re.IGNORECASE, -) - -STAGE_1_INSTRUCTION = """IMPORTANT RULE: This is a FIRST interaction with this agent. Your reply must be PURELY SUBSTANTIVE. -- Answer their question or engage with their point on technical merit alone -- Show deep knowledge of trust infrastructure, DIDs, VCs, reputation systems -- Do NOT mention "try MolTrust", "register", "verify yourself", "check out", "sign up", or any call-to-action -- Do NOT mention moltrust.ch, pip install, MCP server, or free tier -- Goal: establish yourself as a knowledgeable, helpful participant — nothing more""" - -STAGE_2_INSTRUCTION = """This is a RETURNING agent who has commented before. Your reply should: -1. FIRST: Answer their question or engage with their point substantively (this is the main body) -2. THEN: Add ONE casual, helpful nudge at the end — frame it as a practical tip, not a pitch - -Pick ONE nudge that fits the context (vary these, don't always use the same): -- "btw if you want to test the trust flow yourself — you can register a DID at moltrust.ch, takes ~30 seconds via the MCP server (pip install moltrust-mcp-server)" -- "if you're curious how the reputation pipeline works end-to-end, grab a DID at moltrust.ch — free tier, no strings attached" -- "you can try it yourself — pip install moltrust-mcp-server gives you 5 trust tools out of the box, registration takes seconds" - -Or craft your own similar nudge highlighting whichever value prop fits: -- W3C DID that works across platforms -- Verifiable Credentials (provable skills/audit results) -- Reputation score that follows the agent everywhere -- Free tier, instant setup via MCP server or API -- Works with Claude Code, Cursor, OpenCode""" - -STAGE_3_INSTRUCTION = """This is an agent you've spoken to multiple times. They have already received a product suggestion. -- Be helpful and substantive as always -- Do NOT repeat any CTA, nudge, or product suggestion — once was enough -- If they ask specifically about registration or verification, help them through it""" - - -# --------------------------------------------------------------------------- -# Thread context builder (compaction for long threads) -# --------------------------------------------------------------------------- - - -def build_thread_context(all_comments: list, current_comment_id: str, post_title: str) -> str: - """Build thread history for Claude. Max 10 comments, with summary for long threads.""" - preceding = [] - for c in all_comments: - if c["id"] == current_comment_id: - break - author = c.get("author", {}).get("name", "unknown") - content = c.get("content", "") - if content.strip(): - preceding.append({"author": author, "content": content}) - - if not preceding: - return "" - - total = len(preceding) - - if total <= 10: - lines = [f"[{c['author']}]: {c['content']}" for c in preceding] - return "Thread history:\n" + "\n".join(lines) - - last_10 = preceding[-10:] - lines = [f"[{c['author']}]: {c['content']}" for c in last_10] - - if total > 20: - summary = summarize_thread(preceding[:-10], post_title) - return ( - f"Thread summary ({total} comments total, showing last 10):\n" - f"{summary}\n\n" - f"Recent comments:\n" + "\n".join(lines) - ) - - return ( - f"Thread history (showing last 10 of {total} comments):\n" + "\n".join(lines) - ) - - -def summarize_thread(older_comments: list, post_title: str) -> str: - """Generate a brief summary of older thread comments via Claude.""" - text = "\n".join( - f"[{c['author']}]: {c['content'][:120]}" - for c in older_comments[-15:] - ) - try: - r = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": ANTHROPIC_KEY, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 100, - "messages": [{"role": "user", "content": ( - f"Summarize this thread discussion in 1-2 sentences. " - f"Post title: '{post_title}'\n\n{text}" - )}], - }, - timeout=15, - ) - if r.status_code == 200: - return r.json()["content"][0]["text"].strip() - except Exception: - pass - - authors = set(c["author"] for c in older_comments) - return f"Discussion between {', '.join(list(authors)[:5])} about '{post_title}'" - - -def generate_reply( - post_title: str, - post_content: str, - comment_author: str, - comment_text: str, - stage: int, - thread_context: str = "", - session_id: str = "", - avoid_opening: str = "", -) -> str | None: - """Generate a reply using Claude with workspace bootstrap context.""" - if stage == 1: - stage_instruction = STAGE_1_INSTRUCTION - elif stage == 2: - stage_instruction = STAGE_2_INSTRUCTION - else: - stage_instruction = STAGE_3_INSTRUCTION - - # --- Bootstrap: load workspace identity, soul, rules --- - bootstrap = load_bootstrap() - - # --- On-demand: load memory if this agent is known --- - agent_memory = load_memory_for_agent(comment_author) - memory_section = "" - if agent_memory: - memory_section = f"\n\n=== MEMORY (prior interactions with {comment_author}) ===\n{agent_memory}" - - # --- Dedup instruction if retrying --- - dedup_section = "" - if avoid_opening: - dedup_section = ( - f"\n\nIMPORTANT: Your previous reply started with '{avoid_opening}'. " - "Do NOT repeat this framing. Use a completely different angle, " - "different opening sentence, different structure." - ) - - # Build system prompt from workspace files + stage instruction - system = bootstrap + memory_section + "\n\n=== STAGE INSTRUCTION ===\n" + stage_instruction + dedup_section - - # Build user message with thread context - parts = [f"Post title: {post_title}", f"Post content: {post_content[:500]}"] - if thread_context: - parts.append(f"\n{thread_context}") - parts.append(f"\nComment by {comment_author} (reply to this one):\n{comment_text}") - parts.append("\nWrite a reply to this comment. You have full thread context above — reference earlier points if relevant.") - if session_id: - parts.append(f"\n[session: {session_id}]") - user_msg = "\n".join(parts) - - try: - r = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": ANTHROPIC_KEY, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 500, - "system": system, - "messages": [{"role": "user", "content": user_msg}], - }, - timeout=30, - ) - if r.status_code == 200: - data = r.json() - texts = [b["text"] for b in data.get("content", []) if b.get("type") == "text"] - return texts[0].strip() if texts else None - log.warning(f"Claude API -> {r.status_code}: {r.text[:200]}") - except Exception as e: - log.error(f"Claude API error: {e}") - return None - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - - -def get_stage(state: dict, author_name: str) -> int: - """Determine reply stage for an agent based on prior interaction count.""" - prior = state.get("agent_replies", {}).get(author_name, 0) - if prior == 0: - return 1 - nudged = author_name in state.get("nudged_agents", []) - if prior >= 1 and not nudged: - return 2 - return 3 - - -def record_reply(state: dict, author_name: str, stage: int): - """Update state after posting a reply.""" - if "agent_replies" not in state: - state["agent_replies"] = {} - state["agent_replies"][author_name] = state["agent_replies"].get(author_name, 0) + 1 - if stage == 2: - if "nudged_agents" not in state: - state["nudged_agents"] = [] - if author_name not in state["nudged_agents"]: - state["nudged_agents"].append(author_name) - - -def _stage_to_status(stage: int) -> str: - """Map CTA stage to memory status string.""" - return {1: "first_contact", 2: "second_contact", 3: "verified"}.get(stage, "first_contact") - - -def cmd_run(state: dict): - """Check for new comments and auto-reply.""" - log.info("=== RUN: checking for new comments ===") - - with httpx.Client() as client: - posts = get_our_posts(client) - if not posts: - log.info("No posts found") - return - - log.info(f"Found {len(posts)} posts") - replied = 0 - skipped_low_effort = 0 - skipped_rate_limit = 0 - - for post in posts: - post_id = post["id"] - title = post.get("title", "(untitled)") - content = post.get("content", "") - comment_count = post.get("comment_count", 0) - - if comment_count == 0: - continue - - comments = get_comments(client, post_id) - seen = set(state["seen_comments"].get(post_id, [])) - - # Collect all comments including nested replies - all_comments = [] - def _collect(clist): - for c in clist: - all_comments.append(c) - if isinstance(c.get("replies"), list): - _collect(c["replies"]) - _collect(comments) - - for comment in all_comments: - cid = comment["id"] - if cid in seen: - continue - - author_name = comment.get("author", {}).get("name", "unknown") - - # Skip our own comments - if author_name.lower() == OUR_AUTHOR.lower(): - seen.add(cid) - continue - - comment_text = comment.get("content", "") - if not comment_text.strip(): - seen.add(cid) - continue - - # Skip low-effort comments - if LOW_EFFORT_PATTERNS.match(comment_text.strip()): - log.info(f"Skipping low-effort comment by {author_name}: {comment_text[:40]}") - seen.add(cid) - skipped_low_effort += 1 - continue - - # --- Fix 2a: Rate limit per agent (3 replies / 24h) --- - if check_agent_rate_limit(author_name): - log.info(f"SKIP {author_name}: rate limit ({AGENT_REPLY_LIMIT_24H}/24h reached)") - write_log_entry("SKIP", f"{author_name}: rate limit ({AGENT_REPLY_LIMIT_24H}/24h reached)") - seen.add(cid) - skipped_rate_limit += 1 - continue - - # --- Session isolation: unique session per interaction --- - session_id = f"ambassador_{post_id}_{cid}" - - # Determine stage - stage = get_stage(state, author_name) - log.info(f"New comment by {author_name} (stage {stage}) on '{title[:40]}': {comment_text[:80]}...") - - # Build thread context - thread_context = build_thread_context(all_comments, cid, title) - if thread_context: - log.info(f"Thread context: {len(thread_context)} chars") - - # Generate reply (with bootstrap + session isolation) - reply_text = generate_reply( - title, content, author_name, comment_text, stage, - thread_context=thread_context, - session_id=session_id, - ) - if not reply_text: - log.warning(f"Failed to generate reply for comment {cid[:8]}") - seen.add(cid) - continue - - # --- Fix 2b: Dedup via fingerprint --- - dup_fp = check_reply_dedup(author_name, reply_text) - if dup_fp: - log.info(f"Dedup: reply to {author_name} starts with '{dup_fp}' (seen before), regenerating...") - reply_text = generate_reply( - title, content, author_name, comment_text, stage, - thread_context=thread_context, - session_id=session_id, - avoid_opening=dup_fp, - ) - if not reply_text: - log.warning(f"Dedup regeneration failed for {author_name}, skipping") - seen.add(cid) - continue - # Check again — if still duplicate, skip entirely - dup_fp2 = check_reply_dedup(author_name, reply_text) - if dup_fp2: - log.info(f"Dedup: still duplicate after retry for {author_name}, skipping") - write_log_entry("SKIP", f"{author_name}: dedup failed after retry") - seen.add(cid) - continue - - log.info(f"Reply (stage {stage}, session {session_id}): {reply_text[:100]}...") - - # Post reply directly - result = post_reply(client, post_id, reply_text, cid) - if result: - replied += 1 - state["replies_posted"] = state.get("replies_posted", 0) + 1 - record_reply(state, author_name, stage) - log.info(f"Posted stage-{stage} reply to {author_name} on '{title[:40]}'") - - # --- Memory writer: record interaction with fingerprint --- - date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") - context_short = title[:60] if len(title) <= 60 else title[:57] + "..." - reply_fp = " ".join(reply_text.split()[:5]) - write_memory_entry( - agent_id=author_name, - date_str=date_str, - context=context_short, - status=_stage_to_status(stage), - reply_fingerprint=reply_fp, - ) - - # --- Log writer: record reply --- - write_log_entry("REPLY", f"to {author_name}: {reply_text[:80]}") - else: - log.warning(f"Failed to post reply for comment {cid[:8]}") - - seen.add(cid) - time.sleep(2) # pace ourselves between replies - - state["seen_comments"][post_id] = list(seen) - - # --- Log writer: run summary --- - write_log_entry("HEARTBEAT", f"{replied} replies, {skipped_rate_limit} rate-limited, {skipped_low_effort} low-effort") - log.info(f"Run done: {replied} replies, {skipped_rate_limit} rate-limited, {skipped_low_effort} low-effort") - - - -# --------------------------------------------------------------------------- -# Daily post generation for m/agenttrust -# --------------------------------------------------------------------------- - -AGENTTRUST_SUBMOLT = "agenttrust" - -POST_TOPICS = [ - "agent identity standards and interoperability", - "reputation systems for autonomous agents", - "Sybil resistance in agent networks", - "verifiable credentials for AI agents", - "on-chain vs off-chain agent identity", - "trust in multi-agent collaboration", - "agent accountability and auditability", - "privacy-preserving identity verification", - "cross-platform agent reputation portability", - "integrity monitoring in agent marketplaces", - "decentralized identity for agent commerce", - "behavioral vs cryptographic trust signals", - "agent trust in prediction markets", - "zero-knowledge proofs for agent identity", - "the role of DIDs in the agent economy", - "ERC-8004 and on-chain agent registries", - "trust frameworks for agent-to-agent payments", - "governance in agent networks", - "credential revocation for misbehaving agents", - "human oversight vs agent autonomy in trust decisions", -] - -POST_SYSTEM_PROMPT = """You are the MolTrust Ambassador posting discussion topics in m/agenttrust on Moltbook. -m/agenttrust is a submolt (community) focused on agent identity, trust, and reputation. - -Your posts should: -- Be thoughtful, technical discussion starters about agent trust topics -- Present multiple perspectives and ask open questions -- Include concrete examples, numbers, or references where possible -- Be 200-400 words long -- NOT be promotional for MolTrust — focus purely on the topic -- NOT mention moltrust.ch, pip install, or any product pitch -- End with 1-2 discussion questions to drive engagement -- Use markdown formatting (bold, lists, etc.) - -Tone: Knowledgeable peer, not a marketer. Think "interesting blog post" not "product announcement".""" - - -def generate_post_content(topic: str, previous_titles: list[str]) -> tuple[str, str] | None: - """Generate a title and body for a new m/agenttrust post.""" - prev_list = "\n".join(f"- {t}" for t in previous_titles[-10:]) if previous_titles else "None yet" - - user_msg = ( - f"Generate a discussion post about: {topic}\n\n" - f"Previous post titles (do NOT repeat these topics):\n{prev_list}\n\n" - f"Return your response in this exact format:\n" - f"TITLE: Your Post Title Here\n" - f"BODY:\nYour post body here..." - ) - try: - r = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": ANTHROPIC_KEY, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 800, - "system": POST_SYSTEM_PROMPT, - "messages": [{"role": "user", "content": user_msg}], - }, - timeout=30, - ) - if r.status_code == 200: - data = r.json() - texts = [b["text"] for b in data.get("content", []) if b.get("type") == "text"] - if not texts: - return None - text = texts[0].strip() - title_match = re.search(r"TITLE:\s*(.+?)\n", text) - body_match = re.search(r"BODY:\s*\n(.+)", text, re.DOTALL) - if title_match and body_match: - return title_match.group(1).strip(), body_match.group(1).strip() - lines = text.split("\n", 1) - if len(lines) == 2: - return lines[0].strip().lstrip("# "), lines[1].strip() - log.warning(f"Claude API -> {r.status_code}") - except Exception as e: - log.error(f"Claude API error: {e}") - return None - - -def cmd_post(state: dict): - """Generate and post a new discussion topic to m/agenttrust.""" - log.info("=== POST: generating new m/agenttrust topic ===") - - posted_topics = state.get("agenttrust_posts", []) - posted_titles = [p.get("title", "") for p in posted_topics] - - topic_index = len(posted_topics) % len(POST_TOPICS) - topic = POST_TOPICS[topic_index] - log.info(f"Topic #{topic_index}: {topic}") - - result = generate_post_content(topic, posted_titles) - if not result: - log.error("Failed to generate post content") - return - title, body = result - log.info(f"Generated: {title}") - - with httpx.Client() as client: - post_data = moltbook_post(client, "/posts", { - "title": title, - "content": body, - "submolt_name": AGENTTRUST_SUBMOLT, - }) - if not post_data: - log.error("Failed to post to Moltbook") - return - - solve_verification(client, post_data) - - post_id = post_data.get("post", {}).get("id", "unknown") - log.info(f"Posted to m/agenttrust: {post_id}") - - if "agenttrust_posts" not in state: - state["agenttrust_posts"] = [] - state["agenttrust_posts"].append({ - "title": title, - "post_id": post_id, - "topic": topic, - "date": datetime.now(timezone.utc).isoformat(), - }) - log.info(f"Total m/agenttrust posts: {len(state['agenttrust_posts'])}") - - # --- Log writer --- - write_log_entry("POST", f"m/agenttrust: {title[:80]}") - - -def cmd_status(state: dict): - seen_total = sum(len(v) for v in state.get("seen_comments", {}).values()) - posts_tracked = len(state.get("seen_comments", {})) - total_replies = state.get("replies_posted", 0) - - # Show workspace bootstrap status - ws_files = ["IDENTITY.md", "SOUL.md", "RULES.md", "MEMORY.md", "HEARTBEAT.md", "TOOLS.md"] - ws_status = [] - for f in ws_files: - p = WORKSPACE / f - if p.exists(): - size = p.stat().st_size - ws_status.append(f" {f}: {size} bytes") - else: - ws_status.append(f" {f}: MISSING") - - log.info(f"Status: {posts_tracked} posts tracked, {seen_total} comments seen, {total_replies} replies posted") - log.info(f"Workspace ({WORKSPACE}):") - for s in ws_status: - log.info(s) - - today_log = _today_log_path() - if today_log.exists(): - lines = today_log.read_text().strip().splitlines() - log.info(f"Today's log: {len(lines)} lines") - else: - log.info("Today's log: not yet created") - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -def main(): - parser = argparse.ArgumentParser(description="MolTrust Ambassador Agent") - parser.add_argument("command", choices=["run", "status", "post"], - help="run=check comments & auto-reply, status=print stats, post=new m/agenttrust topic") - args = parser.parse_args() - - init_keys() - - missing = [] - if not MOLTBOOK_KEY: - missing.append("MOLTBOOK_AGENT_KEY") - if not ANTHROPIC_KEY: - missing.append("ANTHROPIC_API_KEY") - if missing: - log.error(f"Missing keys: {', '.join(missing)}") - return - - now = datetime.now(timezone.utc) - log.info(f"\n{'='*50}") - log.info(f"MOLTRUST AMBASSADOR — {args.command}") - log.info(f"Time: {now.strftime('%Y-%m-%d %H:%M UTC')}") - log.info(f"Workspace: {WORKSPACE}") - log.info(f"{'='*50}") - - state = load_state() - - if args.command == "run": - cmd_run(state) - elif args.command == "status": - cmd_status(state) - elif args.command == "post": - cmd_post(state) - - save_state(state) - log.info("Done.\n") - - -if __name__ == "__main__": - main() diff --git a/agents/ambassador.py.bak2 b/agents/ambassador.py.bak2 deleted file mode 100644 index 6a9281f..0000000 --- a/agents/ambassador.py.bak2 +++ /dev/null @@ -1,820 +0,0 @@ -#!/usr/bin/env python3 -"""MolTrust Ambassador Agent — Auto-reply to comments on our Moltbook posts. - -Fully automated: detects new comments, generates a reply via Claude, posts it. - -Usage: - ambassador.py run — Check for new comments and reply automatically - ambassador.py status — Print stats to stdout - ambassador.py post — Generate and post a new topic to m/agenttrust -""" - -import argparse -import json -import logging -import os -import re -import time -from datetime import datetime, timezone -from pathlib import Path - -import httpx - -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- - -MOLTBOOK_BASE = "https://www.moltbook.com/api/v1" -OUR_AUTHOR = "moltrust-agent" - -STATE_FILE = Path.home() / ".ambassador_state.json" -LOG_FILE = Path.home() / "moltstack" / "logs" / "ambassador.log" - -# --------------------------------------------------------------------------- -# Logging -# --------------------------------------------------------------------------- - -LOG_FILE.parent.mkdir(parents=True, exist_ok=True) -logging.basicConfig( - level=logging.INFO, - format="[%(asctime)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%dT%H:%M:%S", - handlers=[ - logging.FileHandler(LOG_FILE), - logging.StreamHandler(), - ], -) -log = logging.getLogger("ambassador") - -# --------------------------------------------------------------------------- -# Secrets -# --------------------------------------------------------------------------- - - -def load_key(name: str) -> str: - secrets = Path.home() / ".moltrust_secrets" - if secrets.exists(): - for line in secrets.read_text().splitlines(): - line = line.strip() - if line.startswith("#") or not line: - continue - if line.startswith("export "): - line = line[7:] - if line.startswith(f"{name}="): - return line.split("=", 1)[1].strip() - return os.environ.get(name, "") - - -MOLTBOOK_KEY = "" -ANTHROPIC_KEY = "" - - -def init_keys(): - global MOLTBOOK_KEY, ANTHROPIC_KEY - MOLTBOOK_KEY = load_key("MOLTBOOK_AGENT_KEY") - - ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "") - if not ANTHROPIC_KEY: - key_file = Path.home() / ".anthropic_key" - if key_file.exists(): - ANTHROPIC_KEY = key_file.read_text().strip() - - -# --------------------------------------------------------------------------- -# State -# --------------------------------------------------------------------------- - -DEFAULT_STATE = { - "seen_comments": {}, # post_id -> [comment_id, ...] - "replies_posted": 0, # total replies posted - "agent_replies": {}, # author_name -> count of replies we sent them - "nudged_agents": [], # agents who already received a Stage 2 CTA -} - - -def load_state() -> dict: - if STATE_FILE.exists(): - try: - return json.loads(STATE_FILE.read_text()) - except Exception: - pass - return json.loads(json.dumps(DEFAULT_STATE)) - - -def save_state(state: dict): - STATE_FILE.write_text(json.dumps(state, indent=2)) - - -# --------------------------------------------------------------------------- -# Math challenge solver (from heartbeat.py) -# --------------------------------------------------------------------------- - - -def _collapse(s: str) -> str: - return re.sub(r"(.)\1+", r"\1", s) - - -_NUM_BASE = [ - ("zero", 0), ("one", 1), ("two", 2), ("three", 3), ("four", 4), - ("five", 5), ("six", 6), ("seven", 7), ("eight", 8), ("nine", 9), - ("ten", 10), ("eleven", 11), ("twelve", 12), ("thirteen", 13), - ("fourteen", 14), ("fifteen", 15), ("sixteen", 16), ("seventeen", 17), - ("eighteen", 18), ("nineteen", 19), ("twenty", 20), ("thirty", 30), - ("forty", 40), ("fifty", 50), ("sixty", 60), ("seventy", 70), - ("eighty", 80), ("ninety", 90), -] -NUM_LOOKUP: dict[str, int] = {} -for _w, _v in _NUM_BASE: - NUM_LOOKUP[_w] = _v - _c = _collapse(_w) - if _c != _w: - NUM_LOOKUP[_c] = _v - -_OP_BASE = [ - ("plus", "+"), ("and", "+"), ("add", "+"), ("added", "+"), ("adding", "+"), ("adds", "+"), - ("minus", "-"), ("subtract", "-"), ("subtracted", "-"), - ("less", "-"), ("reduced", "-"), ("reduces", "-"), - ("decreased", "-"), ("decreases", "-"), ("decrease", "-"), - ("slows", "-"), ("slowed", "-"), - ("times", "*"), ("multiplied", "*"), ("multiply", "*"), - ("divided", "/"), ("divides", "/"), ("over", "/"), -] -OP_LOOKUP: dict[str, str] = {} -for _w, _o in _OP_BASE: - OP_LOOKUP[_w] = _o - _c = _collapse(_w) - if _c != _w: - OP_LOOKUP[_c] = _o - - -def _combine_tens_units(nums: list) -> list: - combined = [] - i = 0 - while i < len(nums): - v = nums[i] - if 20 <= v <= 90 and i + 1 < len(nums) and 1 <= nums[i + 1] <= 9: - combined.append(v + nums[i + 1]) - i += 2 - else: - combined.append(v) - i += 1 - return combined - - -def _compute(a: float, b: float, op: str) -> str | None: - if op == "+": - result = a + b - elif op == "-": - result = a - b - elif op == "*": - result = a * b - elif op == "/": - result = a / b if b != 0 else 0 - else: - return None - return f"{result:.2f}" - - -def solve_challenge(text: str) -> str | None: - clean = re.sub(r"[^a-zA-Z ]+", "", text).lower() - words = [_collapse(w) for w in clean.split() if w] - nums: list[int] = [] - op: str | None = None - for w in words: - if w in NUM_LOOKUP: - nums.append(NUM_LOOKUP[w]) - elif w in OP_LOOKUP and op is None: - op = OP_LOOKUP[w] - if op is None: - for i in range(len(words) - 1): - compound = words[i] + words[i + 1] - if compound in OP_LOOKUP: - op = OP_LOOKUP[compound] - break - combined = _combine_tens_units(nums) - if len(combined) >= 2 and op is not None: - return _compute(combined[0], combined[1], op) - - stream = _collapse(re.sub(r"[^a-zA-Z]", "", text).lower()) - num_entries = sorted(NUM_LOOKUP.items(), key=lambda x: len(x[0]), reverse=True) - op_entries = sorted(OP_LOOKUP.items(), key=lambda x: len(x[0]), reverse=True) - used: set[int] = set() - stream_nums: list[tuple[int, int]] = [] - for word, val in num_entries: - for m in re.finditer(re.escape(word), stream): - r = set(range(m.start(), m.end())) - if not r & used: - stream_nums.append((m.start(), val)) - used |= r - stream_ops: list[tuple[int, str]] = [] - for word, op_val in op_entries: - for m in re.finditer(re.escape(word), stream): - r = set(range(m.start(), m.end())) - if not r & used: - stream_ops.append((m.start(), op_val)) - used |= r - break - stream_nums.sort() - stream_ops.sort() - s_nums = _combine_tens_units([v for _, v in stream_nums]) - s_op = stream_ops[0][1] if stream_ops else (op or "*") - if len(s_nums) >= 2: - return _compute(s_nums[0], s_nums[1], s_op) - - digits = [float(d) for d in re.findall(r"\d+\.?\d*", text)] - if len(digits) >= 2: - return _compute(digits[0], digits[1], op or "*") - return None - - -# --------------------------------------------------------------------------- -# Moltbook API -# --------------------------------------------------------------------------- - - -def moltbook_get(client: httpx.Client, path: str, **params) -> dict | list | None: - try: - r = client.get( - f"{MOLTBOOK_BASE}{path}", - headers={"Authorization": f"Bearer {MOLTBOOK_KEY}"}, - params=params, - timeout=15, - ) - if r.status_code == 200: - return r.json() - log.warning(f"GET {path} -> {r.status_code}: {r.text[:200]}") - except Exception as e: - log.error(f"GET {path} error: {e}") - return None - - -def moltbook_post(client: httpx.Client, path: str, body: dict) -> dict | None: - for attempt in range(3): - try: - r = client.post( - f"{MOLTBOOK_BASE}{path}", - headers={"Authorization": f"Bearer {MOLTBOOK_KEY}", "Content-Type": "application/json"}, - json=body, - timeout=15, - ) - if r.status_code in (200, 201): - return r.json() - if r.status_code == 429: - retry_after = r.json().get("retry_after_seconds", 25) - log.info(f"Rate limited, waiting {retry_after}s (attempt {attempt+1}/3)") - time.sleep(retry_after + 1) - continue - log.warning(f"POST {path} -> {r.status_code}: {r.text[:300]}") - return None - except Exception as e: - log.error(f"POST {path} error: {e}") - return None - log.warning(f"POST {path} failed after 3 attempts") - return None - - -def solve_verification(client: httpx.Client, data: dict) -> bool: - verification = data.get("verification") or data.get("post", {}).get("verification") - if not verification: - return True - code = verification.get("verification_code", "") - challenge = verification.get("challenge_text", "") - if not code or not challenge: - return True - log.info(f"Verification challenge: {challenge[:80]}...") - answer = solve_challenge(challenge) - if not answer: - log.error("Failed to solve math challenge") - return False - result = moltbook_post(client, "/verify", {"verification_code": code, "answer": answer}) - if result and result.get("success"): - log.info("Verification solved!") - return True - log.error(f"Verification failed: {result}") - return False - - -def get_our_posts(client: httpx.Client) -> list[dict]: - data = moltbook_get(client, "/posts", author=OUR_AUTHOR, limit=50) - if not data: - return [] - return data if isinstance(data, list) else data.get("posts", data.get("data", [])) - - -def get_comments(client: httpx.Client, post_id: str) -> list[dict]: - data = moltbook_get(client, f"/posts/{post_id}/comments") - if not data: - return [] - return data.get("comments", []) - - -def post_reply(client: httpx.Client, post_id: str, content: str, parent_id: str) -> dict | None: - body = {"content": content, "parent_id": parent_id} - result = moltbook_post(client, f"/posts/{post_id}/comments", body) - if result: - solve_verification(client, result) - return result - - -# --------------------------------------------------------------------------- -# Claude reply generation -# --------------------------------------------------------------------------- - -SYSTEM_PROMPT = """You are the MolTrust Ambassador on Moltbook. You reply to comments on MolTrust posts. - -About MolTrust (internal knowledge — use naturally, don't recite): -- Trust infrastructure for the AI agent economy -- W3C DID-based decentralized identity (did:moltrust:...) -- Verifiable Credentials signed with Ed25519, anchored on Base blockchain -- Reputation scoring: agents rate each other 1-5 stars -- Free API at api.moltrust.ch, free tier with 175 credits -- MT Salesguard: brand product provenance for the A2A economy (BrandRegistryCredential, AuthorizedResellerCredential, ProductProvenanceCredential) -- MCP Server: pip install moltrust-mcp-server (works with Claude Code, Cursor, OpenCode) -- Open standards, no vendor lock-in - -Core guidelines: -- Be a technically competent peer, not a marketer -- Be concise, no fluff — match the commenter's energy (short comment -> shorter reply) -- If someone asks about a feature that doesn't exist yet, be honest ("that's on our roadmap" / "we don't have that yet") -- Ask follow-up questions to drive engagement -- Acknowledge good points the commenter makes -- If the comment is spam or purely promotional, keep the reply brief and neutral -- Don't repeat marketing copy verbatim — paraphrase naturally""" - -STAGE_1_INSTRUCTION = """IMPORTANT RULE: This is a FIRST interaction with this agent. Your reply must be PURELY SUBSTANTIVE. -- Answer their question or engage with their point on technical merit alone -- Show deep knowledge of trust infrastructure, DIDs, VCs, reputation systems -- Do NOT mention "try MolTrust", "register", "verify yourself", "check out", "sign up", or any call-to-action -- Do NOT mention moltrust.ch, pip install, MCP server, or free tier -- Goal: establish yourself as a knowledgeable, helpful participant — nothing more""" - -STAGE_2_INSTRUCTION = """This is a RETURNING agent who has commented before. Your reply should: -1. FIRST: Answer their question or engage with their point substantively (this is the main body) -2. THEN: Add ONE casual, helpful nudge at the end — frame it as a practical tip, not a pitch - -Pick ONE nudge that fits the context (vary these, don't always use the same): -- "btw if you want to test the trust flow yourself — you can register a DID at moltrust.ch, takes ~30 seconds via the MCP server (pip install moltrust-mcp-server)" -- "if you're curious how the reputation pipeline works end-to-end, grab a DID at moltrust.ch — free tier, no strings attached" -- "you can try it yourself — pip install moltrust-mcp-server gives you 5 trust tools out of the box, registration takes seconds" - -Or craft your own similar nudge highlighting whichever value prop fits: -- W3C DID that works across platforms -- Verifiable Credentials (provable skills/audit results) -- Reputation score that follows the agent everywhere -- Free tier, instant setup via MCP server or API -- Works with Claude Code, Cursor, OpenCode""" - -STAGE_3_INSTRUCTION = """This is an agent you've spoken to multiple times. They have already received a product suggestion. -- Be helpful and substantive as always -- Do NOT repeat any CTA, nudge, or product suggestion — once was enough -- If they ask specifically about registration or verification, help them through it""" - -# Low-effort patterns to skip -LOW_EFFORT_PATTERNS = re.compile( - r"^(\+1|nice|cool|great|thanks|lol|wow|ok|yes|no|agreed|this|same|love it|fire|based|true|real|💯|🔥|👍|❤️|🙌|👏|💪|✅)\s*[.!]?\s*$", - re.IGNORECASE, -) - - - -# --------------------------------------------------------------------------- -# Thread context builder (compaction for long threads) -# --------------------------------------------------------------------------- - - -def build_thread_context(all_comments: list, current_comment_id: str, post_title: str) -> str: - """Build thread history for Claude. Max 10 comments, with summary for long threads.""" - # Collect comments preceding the current one - preceding = [] - for c in all_comments: - if c["id"] == current_comment_id: - break - author = c.get("author", {}).get("name", "unknown") - content = c.get("content", "") - if content.strip(): - preceding.append({"author": author, "content": content}) - - if not preceding: - return "" - - total = len(preceding) - - if total <= 10: - # Short thread — include everything - lines = [f"[{c['author']}]: {c['content']}" for c in preceding] - return "Thread history:\n" + "\n".join(lines) - - # Take last 10 - last_10 = preceding[-10:] - lines = [f"[{c['author']}]: {c['content']}" for c in last_10] - - if total > 20: - # Very long thread — add summary prefix - summary = summarize_thread(preceding[:-10], post_title) - return ( - f"Thread summary ({total} comments total, showing last 10):\n" - f"{summary}\n\n" - f"Recent comments:\n" + "\n".join(lines) - ) - - # Medium thread (11-20) — just last 10 with count note - return ( - f"Thread history (showing last 10 of {total} comments):\n" + "\n".join(lines) - ) - - -def summarize_thread(older_comments: list, post_title: str) -> str: - """Generate a brief summary of older thread comments via Claude.""" - # Compact representation — last 15 of the older batch, truncated - text = "\n".join( - f"[{c['author']}]: {c['content'][:120]}" - for c in older_comments[-15:] - ) - try: - r = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": ANTHROPIC_KEY, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 100, - "messages": [{"role": "user", "content": ( - f"Summarize this thread discussion in 1-2 sentences. " - f"Post title: '{post_title}'\n\n{text}" - )}], - }, - timeout=15, - ) - if r.status_code == 200: - return r.json()["content"][0]["text"].strip() - except Exception: - pass - - # Fallback: simple stats - authors = set(c["author"] for c in older_comments) - return f"Discussion between {', '.join(list(authors)[:5])} about '{post_title}'" - - -def generate_reply(post_title: str, post_content: str, comment_author: str, comment_text: str, stage: int, thread_context: str = "") -> str | None: - if stage == 1: - stage_instruction = STAGE_1_INSTRUCTION - elif stage == 2: - stage_instruction = STAGE_2_INSTRUCTION - else: - stage_instruction = STAGE_3_INSTRUCTION - - system = SYSTEM_PROMPT + "\n\n" + stage_instruction - - # Build user message with thread context - parts = [f"Post title: {post_title}", f"Post content: {post_content[:500]}"] - if thread_context: - parts.append(f"\n{thread_context}") - parts.append(f"\nComment by {comment_author} (reply to this one):\n{comment_text}") - parts.append("\nWrite a reply to this comment. You have full thread context above — reference earlier points if relevant.") - user_msg = "\n".join(parts) - try: - r = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": ANTHROPIC_KEY, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 500, - "system": system, - "messages": [{"role": "user", "content": user_msg}], - }, - timeout=30, - ) - if r.status_code == 200: - data = r.json() - texts = [b["text"] for b in data.get("content", []) if b.get("type") == "text"] - return texts[0].strip() if texts else None - log.warning(f"Claude API -> {r.status_code}: {r.text[:200]}") - except Exception as e: - log.error(f"Claude API error: {e}") - return None - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - - -def get_stage(state: dict, author_name: str) -> int: - """Determine reply stage for an agent based on prior interaction count.""" - prior = state.get("agent_replies", {}).get(author_name, 0) - if prior == 0: - return 1 - nudged = author_name in state.get("nudged_agents", []) - if prior >= 1 and not nudged: - return 2 - return 3 - - -def record_reply(state: dict, author_name: str, stage: int): - """Update state after posting a reply.""" - if "agent_replies" not in state: - state["agent_replies"] = {} - state["agent_replies"][author_name] = state["agent_replies"].get(author_name, 0) + 1 - if stage == 2: - if "nudged_agents" not in state: - state["nudged_agents"] = [] - if author_name not in state["nudged_agents"]: - state["nudged_agents"].append(author_name) - - -def cmd_run(state: dict): - """Check for new comments and auto-reply.""" - log.info("=== RUN: checking for new comments ===") - - with httpx.Client() as client: - posts = get_our_posts(client) - if not posts: - log.info("No posts found") - return - - log.info(f"Found {len(posts)} posts") - replied = 0 - skipped_low_effort = 0 - - for post in posts: - post_id = post["id"] - title = post.get("title", "(untitled)") - content = post.get("content", "") - comment_count = post.get("comment_count", 0) - - if comment_count == 0: - continue - - comments = get_comments(client, post_id) - seen = set(state["seen_comments"].get(post_id, [])) - - # Collect all comments including nested replies - all_comments = [] - def _collect(clist): - for c in clist: - all_comments.append(c) - if isinstance(c.get("replies"), list): - _collect(c["replies"]) - _collect(comments) - - for comment in all_comments: - cid = comment["id"] - if cid in seen: - continue - - author_name = comment.get("author", {}).get("name", "unknown") - - # Skip our own comments - if author_name.lower() == OUR_AUTHOR.lower(): - seen.add(cid) - continue - - comment_text = comment.get("content", "") - if not comment_text.strip(): - seen.add(cid) - continue - - # Skip low-effort comments - if LOW_EFFORT_PATTERNS.match(comment_text.strip()): - log.info(f"Skipping low-effort comment by {author_name}: {comment_text[:40]}") - seen.add(cid) - skipped_low_effort += 1 - continue - - # Determine stage - stage = get_stage(state, author_name) - log.info(f"New comment by {author_name} (stage {stage}) on '{title[:40]}': {comment_text[:80]}...") - - # Build thread context - thread_context = build_thread_context(all_comments, cid, title) - if thread_context: - log.info(f"Thread context: {len(thread_context)} chars") - - # Generate reply - reply_text = generate_reply(title, content, author_name, comment_text, stage, thread_context=thread_context) - if not reply_text: - log.warning(f"Failed to generate reply for comment {cid[:8]}") - seen.add(cid) - continue - - log.info(f"Reply (stage {stage}): {reply_text[:100]}...") - - # Post reply directly - result = post_reply(client, post_id, reply_text, cid) - if result: - replied += 1 - state["replies_posted"] = state.get("replies_posted", 0) + 1 - record_reply(state, author_name, stage) - log.info(f"Posted stage-{stage} reply to {author_name} on '{title[:40]}'") - else: - log.warning(f"Failed to post reply for comment {cid[:8]}") - - seen.add(cid) - time.sleep(2) # pace ourselves between replies - - state["seen_comments"][post_id] = list(seen) - - log.info(f"Run done: {replied} replies posted, {skipped_low_effort} low-effort skipped") - - - -# --------------------------------------------------------------------------- -# Daily post generation for m/agenttrust -# --------------------------------------------------------------------------- - -AGENTTRUST_SUBMOLT = "agenttrust" - -POST_TOPICS = [ - "agent identity standards and interoperability", - "reputation systems for autonomous agents", - "Sybil resistance in agent networks", - "verifiable credentials for AI agents", - "on-chain vs off-chain agent identity", - "trust in multi-agent collaboration", - "agent accountability and auditability", - "privacy-preserving identity verification", - "cross-platform agent reputation portability", - "integrity monitoring in agent marketplaces", - "decentralized identity for agent commerce", - "behavioral vs cryptographic trust signals", - "agent trust in prediction markets", - "zero-knowledge proofs for agent identity", - "the role of DIDs in the agent economy", - "ERC-8004 and on-chain agent registries", - "trust frameworks for agent-to-agent payments", - "governance in agent networks", - "credential revocation for misbehaving agents", - "human oversight vs agent autonomy in trust decisions", -] - -POST_SYSTEM_PROMPT = """You are the MolTrust Ambassador posting discussion topics in m/agenttrust on Moltbook. -m/agenttrust is a submolt (community) focused on agent identity, trust, and reputation. - -Your posts should: -- Be thoughtful, technical discussion starters about agent trust topics -- Present multiple perspectives and ask open questions -- Include concrete examples, numbers, or references where possible -- Be 200-400 words long -- NOT be promotional for MolTrust — focus purely on the topic -- NOT mention moltrust.ch, pip install, or any product pitch -- End with 1-2 discussion questions to drive engagement -- Use markdown formatting (bold, lists, etc.) - -Tone: Knowledgeable peer, not a marketer. Think "interesting blog post" not "product announcement".""" - - -def generate_post_content(topic: str, previous_titles: list[str]) -> tuple[str, str] | None: - """Generate a title and body for a new m/agenttrust post.""" - prev_list = "\n".join(f"- {t}" for t in previous_titles[-10:]) if previous_titles else "None yet" - - user_msg = ( - f"Generate a discussion post about: {topic}\n\n" - f"Previous post titles (do NOT repeat these topics):\n{prev_list}\n\n" - f"Return your response in this exact format:\n" - f"TITLE: Your Post Title Here\n" - f"BODY:\nYour post body here..." - ) - try: - r = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": ANTHROPIC_KEY, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 800, - "system": POST_SYSTEM_PROMPT, - "messages": [{"role": "user", "content": user_msg}], - }, - timeout=30, - ) - if r.status_code == 200: - data = r.json() - texts = [b["text"] for b in data.get("content", []) if b.get("type") == "text"] - if not texts: - return None - text = texts[0].strip() - # Parse TITLE: and BODY: - title_match = re.search(r"TITLE:\s*(.+?)\n", text) - body_match = re.search(r"BODY:\s*\n(.+)", text, re.DOTALL) - if title_match and body_match: - return title_match.group(1).strip(), body_match.group(1).strip() - # Fallback: first line is title, rest is body - lines = text.split("\n", 1) - if len(lines) == 2: - return lines[0].strip().lstrip("# "), lines[1].strip() - log.warning(f"Claude API -> {r.status_code}") - except Exception as e: - log.error(f"Claude API error: {e}") - return None - - -def cmd_post(state: dict): - """Generate and post a new discussion topic to m/agenttrust.""" - log.info("=== POST: generating new m/agenttrust topic ===") - - # Track posted topics - posted_topics = state.get("agenttrust_posts", []) - posted_titles = [p.get("title", "") for p in posted_topics] - - # Pick next topic (cycle through the list) - topic_index = len(posted_topics) % len(POST_TOPICS) - topic = POST_TOPICS[topic_index] - log.info(f"Topic #{topic_index}: {topic}") - - # Generate content - result = generate_post_content(topic, posted_titles) - if not result: - log.error("Failed to generate post content") - return - title, body = result - log.info(f"Generated: {title}") - - # Post to Moltbook - with httpx.Client() as client: - post_data = moltbook_post(client, "/posts", { - "title": title, - "content": body, - "submolt_name": AGENTTRUST_SUBMOLT, - }) - if not post_data: - log.error("Failed to post to Moltbook") - return - - # Handle verification if needed - solve_verification(client, post_data) - - post_id = post_data.get("post", {}).get("id", "unknown") - log.info(f"Posted to m/agenttrust: {post_id}") - - # Record in state - if "agenttrust_posts" not in state: - state["agenttrust_posts"] = [] - state["agenttrust_posts"].append({ - "title": title, - "post_id": post_id, - "topic": topic, - "date": datetime.now(timezone.utc).isoformat(), - }) - log.info(f"Total m/agenttrust posts: {len(state['agenttrust_posts'])}") - - -def cmd_status(state: dict): - seen_total = sum(len(v) for v in state.get("seen_comments", {}).values()) - posts_tracked = len(state.get("seen_comments", {})) - total_replies = state.get("replies_posted", 0) - log.info(f"Status: {posts_tracked} posts tracked, {seen_total} comments seen, {total_replies} replies posted") - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -def main(): - parser = argparse.ArgumentParser(description="MolTrust Ambassador Agent") - parser.add_argument("command", choices=["run", "status", "post"], - help="run=check comments & auto-reply, status=print stats, post=new m/agenttrust topic") - args = parser.parse_args() - - init_keys() - - missing = [] - if not MOLTBOOK_KEY: - missing.append("MOLTBOOK_AGENT_KEY") - if not ANTHROPIC_KEY: - missing.append("ANTHROPIC_API_KEY") - if missing: - log.error(f"Missing keys: {', '.join(missing)}") - return - - now = datetime.now(timezone.utc) - log.info(f"\n{'='*50}") - log.info(f"MOLTRUST AMBASSADOR — {args.command}") - log.info(f"Time: {now.strftime('%Y-%m-%d %H:%M UTC')}") - log.info(f"{'='*50}") - - state = load_state() - - if args.command == "run": - cmd_run(state) - elif args.command == "status": - cmd_status(state) - elif args.command == "post": - cmd_post(state) - - save_state(state) - log.info("Done.\n") - - -if __name__ == "__main__": - main() diff --git a/agents/ambassador.py.bak3 b/agents/ambassador.py.bak3 deleted file mode 100644 index b9c015e..0000000 --- a/agents/ambassador.py.bak3 +++ /dev/null @@ -1,965 +0,0 @@ -#!/usr/bin/env python3 -"""MolTrust Ambassador Agent — Auto-reply to comments on our Moltbook posts. - -Fully automated: detects new comments, generates a reply via Claude, posts it. -Uses workspace bootstrap pattern for identity, personality, and memory. - -Usage: - ambassador.py run — Check for new comments and reply automatically - ambassador.py status — Print stats to stdout - ambassador.py post — Generate and post a new topic to m/agenttrust -""" - -import argparse -import json -import logging -import os -import re -import time -from datetime import datetime, timezone -from pathlib import Path - -import httpx - -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- - -MOLTBOOK_BASE = "https://www.moltbook.com/api/v1" -OUR_AUTHOR = "moltrust-agent" - -STATE_FILE = Path.home() / ".ambassador_state.json" -LOG_FILE = Path.home() / "moltstack" / "logs" / "ambassador.log" - -# Workspace paths -WORKSPACE = Path.home() / "moltstack" / "agents" / "workspace" / "ambassador" -WS_IDENTITY = WORKSPACE / "IDENTITY.md" -WS_SOUL = WORKSPACE / "SOUL.md" -WS_RULES = WORKSPACE / "RULES.md" -WS_MEMORY = WORKSPACE / "MEMORY.md" -WS_HEARTBEAT = WORKSPACE / "HEARTBEAT.md" -WS_TOOLS = WORKSPACE / "TOOLS.md" -WS_LOGS = WORKSPACE / "logs" - -# --------------------------------------------------------------------------- -# Logging -# --------------------------------------------------------------------------- - -LOG_FILE.parent.mkdir(parents=True, exist_ok=True) -logging.basicConfig( - level=logging.INFO, - format="[%(asctime)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%dT%H:%M:%S", - handlers=[ - logging.FileHandler(LOG_FILE), - logging.StreamHandler(), - ], -) -log = logging.getLogger("ambassador") - -# --------------------------------------------------------------------------- -# Secrets -# --------------------------------------------------------------------------- - - -def load_key(name: str) -> str: - secrets = Path.home() / ".moltrust_secrets" - if secrets.exists(): - for line in secrets.read_text().splitlines(): - line = line.strip() - if line.startswith("#") or not line: - continue - if line.startswith("export "): - line = line[7:] - if line.startswith(f"{name}="): - return line.split("=", 1)[1].strip() - return os.environ.get(name, "") - - -MOLTBOOK_KEY = "" -ANTHROPIC_KEY = "" - - -def init_keys(): - global MOLTBOOK_KEY, ANTHROPIC_KEY - MOLTBOOK_KEY = load_key("MOLTBOOK_AGENT_KEY") - - ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "") - if not ANTHROPIC_KEY: - key_file = Path.home() / ".anthropic_key" - if key_file.exists(): - ANTHROPIC_KEY = key_file.read_text().strip() - - -# --------------------------------------------------------------------------- -# Workspace Bootstrap Loader -# --------------------------------------------------------------------------- - - -def _read_ws(path: Path) -> str: - """Read a workspace file, return empty string if missing.""" - if path.exists(): - return path.read_text().strip() - return "" - - -def _today_log_path() -> Path: - """Return path to today's workspace log file.""" - return WS_LOGS / f"{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.md" - - -def load_bootstrap() -> str: - """Load core bootstrap context: IDENTITY + SOUL + RULES + today's log. - Always loaded into the system prompt. Budget: ~2000 tokens.""" - parts = [] - - identity = _read_ws(WS_IDENTITY) - if identity: - parts.append(f"=== IDENTITY ===\n{identity}") - - soul = _read_ws(WS_SOUL) - if soul: - parts.append(f"=== SOUL ===\n{soul}") - - rules = _read_ws(WS_RULES) - if rules: - parts.append(f"=== RULES ===\n{rules}") - - today_log = _read_ws(_today_log_path()) - if today_log: - # Only include last 20 lines to stay within token budget - lines = today_log.strip().splitlines() - if len(lines) > 20: - lines = lines[-20:] - parts.append(f"=== TODAY'S LOG (last {len(lines)} entries) ===\n" + "\n".join(lines)) - - return "\n\n".join(parts) - - -def load_memory_for_agent(agent_id: str) -> str | None: - """On-demand: load MEMORY.md only if agent_id appears in it.""" - memory = _read_ws(WS_MEMORY) - if not memory: - return None - # Check if this agent is mentioned (username or DID fragment) - if agent_id.lower() in memory.lower(): - return memory - return None - - -def load_heartbeat() -> str: - """On-demand: load HEARTBEAT.md for heartbeat checks only.""" - return _read_ws(WS_HEARTBEAT) - - -# --------------------------------------------------------------------------- -# Workspace Memory Writer -# --------------------------------------------------------------------------- - - -def write_memory_entry(agent_id: str, date_str: str, context: str, status: str): - """Append an entry to MEMORY.md after each reply.""" - entry = f"\n### {agent_id} — {date_str} — {context}\n→ Status: {status}\n" - WS_MEMORY.parent.mkdir(parents=True, exist_ok=True) - with open(WS_MEMORY, "a") as f: - f.write(entry) - log.info(f"MEMORY: wrote entry for {agent_id} ({status})") - - -# --------------------------------------------------------------------------- -# Workspace Log Writer -# --------------------------------------------------------------------------- - - -def write_log_entry(entry_type: str, message: str): - """Append a timestamped entry to today's workspace log.""" - WS_LOGS.mkdir(parents=True, exist_ok=True) - log_path = _today_log_path() - now = datetime.now(timezone.utc).strftime("%H:%M") - line = f"[{now}] {entry_type}: {message}\n" - - # Create daily log with header if new - if not log_path.exists(): - header = f"# Ambassador Log — {datetime.now(timezone.utc).strftime('%Y-%m-%d')}\n\n" - log_path.write_text(header) - - with open(log_path, "a") as f: - f.write(line) - - -# --------------------------------------------------------------------------- -# State -# --------------------------------------------------------------------------- - -DEFAULT_STATE = { - "seen_comments": {}, # post_id -> [comment_id, ...] - "replies_posted": 0, # total replies posted - "agent_replies": {}, # author_name -> count of replies we sent them - "nudged_agents": [], # agents who already received a Stage 2 CTA -} - - -def load_state() -> dict: - if STATE_FILE.exists(): - try: - return json.loads(STATE_FILE.read_text()) - except Exception: - pass - return json.loads(json.dumps(DEFAULT_STATE)) - - -def save_state(state: dict): - STATE_FILE.write_text(json.dumps(state, indent=2)) - - -# --------------------------------------------------------------------------- -# Math challenge solver (from heartbeat.py) -# --------------------------------------------------------------------------- - - -def _collapse(s: str) -> str: - return re.sub(r"(.)\1+", r"\1", s) - - -_NUM_BASE = [ - ("zero", 0), ("one", 1), ("two", 2), ("three", 3), ("four", 4), - ("five", 5), ("six", 6), ("seven", 7), ("eight", 8), ("nine", 9), - ("ten", 10), ("eleven", 11), ("twelve", 12), ("thirteen", 13), - ("fourteen", 14), ("fifteen", 15), ("sixteen", 16), ("seventeen", 17), - ("eighteen", 18), ("nineteen", 19), ("twenty", 20), ("thirty", 30), - ("forty", 40), ("fifty", 50), ("sixty", 60), ("seventy", 70), - ("eighty", 80), ("ninety", 90), -] -NUM_LOOKUP: dict[str, int] = {} -for _w, _v in _NUM_BASE: - NUM_LOOKUP[_w] = _v - _c = _collapse(_w) - if _c != _w: - NUM_LOOKUP[_c] = _v - -_OP_BASE = [ - ("plus", "+"), ("and", "+"), ("add", "+"), ("added", "+"), ("adding", "+"), ("adds", "+"), - ("minus", "-"), ("subtract", "-"), ("subtracted", "-"), - ("less", "-"), ("reduced", "-"), ("reduces", "-"), - ("decreased", "-"), ("decreases", "-"), ("decrease", "-"), - ("slows", "-"), ("slowed", "-"), - ("times", "*"), ("multiplied", "*"), ("multiply", "*"), - ("divided", "/"), ("divides", "/"), ("over", "/"), -] -OP_LOOKUP: dict[str, str] = {} -for _w, _o in _OP_BASE: - OP_LOOKUP[_w] = _o - _c = _collapse(_w) - if _c != _w: - OP_LOOKUP[_c] = _o - - -def _combine_tens_units(nums: list) -> list: - combined = [] - i = 0 - while i < len(nums): - v = nums[i] - if 20 <= v <= 90 and i + 1 < len(nums) and 1 <= nums[i + 1] <= 9: - combined.append(v + nums[i + 1]) - i += 2 - else: - combined.append(v) - i += 1 - return combined - - -def _compute(a: float, b: float, op: str) -> str | None: - if op == "+": - result = a + b - elif op == "-": - result = a - b - elif op == "*": - result = a * b - elif op == "/": - result = a / b if b != 0 else 0 - else: - return None - return f"{result:.2f}" - - -def solve_challenge(text: str) -> str | None: - clean = re.sub(r"[^a-zA-Z ]+", "", text).lower() - words = [_collapse(w) for w in clean.split() if w] - nums: list[int] = [] - op: str | None = None - for w in words: - if w in NUM_LOOKUP: - nums.append(NUM_LOOKUP[w]) - elif w in OP_LOOKUP and op is None: - op = OP_LOOKUP[w] - if op is None: - for i in range(len(words) - 1): - compound = words[i] + words[i + 1] - if compound in OP_LOOKUP: - op = OP_LOOKUP[compound] - break - combined = _combine_tens_units(nums) - if len(combined) >= 2 and op is not None: - return _compute(combined[0], combined[1], op) - - stream = _collapse(re.sub(r"[^a-zA-Z]", "", text).lower()) - num_entries = sorted(NUM_LOOKUP.items(), key=lambda x: len(x[0]), reverse=True) - op_entries = sorted(OP_LOOKUP.items(), key=lambda x: len(x[0]), reverse=True) - used: set[int] = set() - stream_nums: list[tuple[int, int]] = [] - for word, val in num_entries: - for m in re.finditer(re.escape(word), stream): - r = set(range(m.start(), m.end())) - if not r & used: - stream_nums.append((m.start(), val)) - used |= r - stream_ops: list[tuple[int, str]] = [] - for word, op_val in op_entries: - for m in re.finditer(re.escape(word), stream): - r = set(range(m.start(), m.end())) - if not r & used: - stream_ops.append((m.start(), op_val)) - used |= r - break - stream_nums.sort() - stream_ops.sort() - s_nums = _combine_tens_units([v for _, v in stream_nums]) - s_op = stream_ops[0][1] if stream_ops else (op or "*") - if len(s_nums) >= 2: - return _compute(s_nums[0], s_nums[1], s_op) - - digits = [float(d) for d in re.findall(r"\d+\.?\d*", text)] - if len(digits) >= 2: - return _compute(digits[0], digits[1], op or "*") - return None - - -# --------------------------------------------------------------------------- -# Moltbook API -# --------------------------------------------------------------------------- - - -def moltbook_get(client: httpx.Client, path: str, **params) -> dict | list | None: - try: - r = client.get( - f"{MOLTBOOK_BASE}{path}", - headers={"Authorization": f"Bearer {MOLTBOOK_KEY}"}, - params=params, - timeout=15, - ) - if r.status_code == 200: - return r.json() - log.warning(f"GET {path} -> {r.status_code}: {r.text[:200]}") - except Exception as e: - log.error(f"GET {path} error: {e}") - return None - - -def moltbook_post(client: httpx.Client, path: str, body: dict) -> dict | None: - for attempt in range(3): - try: - r = client.post( - f"{MOLTBOOK_BASE}{path}", - headers={"Authorization": f"Bearer {MOLTBOOK_KEY}", "Content-Type": "application/json"}, - json=body, - timeout=15, - ) - if r.status_code in (200, 201): - return r.json() - if r.status_code == 429: - retry_after = r.json().get("retry_after_seconds", 25) - log.info(f"Rate limited, waiting {retry_after}s (attempt {attempt+1}/3)") - time.sleep(retry_after + 1) - continue - log.warning(f"POST {path} -> {r.status_code}: {r.text[:300]}") - return None - except Exception as e: - log.error(f"POST {path} error: {e}") - return None - log.warning(f"POST {path} failed after 3 attempts") - return None - - -def solve_verification(client: httpx.Client, data: dict) -> bool: - verification = data.get("verification") or data.get("post", {}).get("verification") - if not verification: - return True - code = verification.get("verification_code", "") - challenge = verification.get("challenge_text", "") - if not code or not challenge: - return True - log.info(f"Verification challenge: {challenge[:80]}...") - answer = solve_challenge(challenge) - if not answer: - log.error("Failed to solve math challenge") - return False - result = moltbook_post(client, "/verify", {"verification_code": code, "answer": answer}) - if result and result.get("success"): - log.info("Verification solved!") - return True - log.error(f"Verification failed: {result}") - return False - - -def get_our_posts(client: httpx.Client) -> list[dict]: - data = moltbook_get(client, "/posts", author=OUR_AUTHOR, limit=50) - if not data: - return [] - return data if isinstance(data, list) else data.get("posts", data.get("data", [])) - - -def get_comments(client: httpx.Client, post_id: str) -> list[dict]: - data = moltbook_get(client, f"/posts/{post_id}/comments") - if not data: - return [] - return data.get("comments", []) - - -def post_reply(client: httpx.Client, post_id: str, content: str, parent_id: str) -> dict | None: - body = {"content": content, "parent_id": parent_id} - result = moltbook_post(client, f"/posts/{post_id}/comments", body) - if result: - solve_verification(client, result) - return result - - -# --------------------------------------------------------------------------- -# Claude reply generation (with workspace bootstrap) -# --------------------------------------------------------------------------- - -# Low-effort patterns to skip -LOW_EFFORT_PATTERNS = re.compile( - r"^(\+1|nice|cool|great|thanks|lol|wow|ok|yes|no|agreed|this|same|love it|fire|based|true|real|💯|🔥|👍|❤️|🙌|👏|💪|✅)\s*[.!]?\s*$", - re.IGNORECASE, -) - -STAGE_1_INSTRUCTION = """IMPORTANT RULE: This is a FIRST interaction with this agent. Your reply must be PURELY SUBSTANTIVE. -- Answer their question or engage with their point on technical merit alone -- Show deep knowledge of trust infrastructure, DIDs, VCs, reputation systems -- Do NOT mention "try MolTrust", "register", "verify yourself", "check out", "sign up", or any call-to-action -- Do NOT mention moltrust.ch, pip install, MCP server, or free tier -- Goal: establish yourself as a knowledgeable, helpful participant — nothing more""" - -STAGE_2_INSTRUCTION = """This is a RETURNING agent who has commented before. Your reply should: -1. FIRST: Answer their question or engage with their point substantively (this is the main body) -2. THEN: Add ONE casual, helpful nudge at the end — frame it as a practical tip, not a pitch - -Pick ONE nudge that fits the context (vary these, don't always use the same): -- "btw if you want to test the trust flow yourself — you can register a DID at moltrust.ch, takes ~30 seconds via the MCP server (pip install moltrust-mcp-server)" -- "if you're curious how the reputation pipeline works end-to-end, grab a DID at moltrust.ch — free tier, no strings attached" -- "you can try it yourself — pip install moltrust-mcp-server gives you 5 trust tools out of the box, registration takes seconds" - -Or craft your own similar nudge highlighting whichever value prop fits: -- W3C DID that works across platforms -- Verifiable Credentials (provable skills/audit results) -- Reputation score that follows the agent everywhere -- Free tier, instant setup via MCP server or API -- Works with Claude Code, Cursor, OpenCode""" - -STAGE_3_INSTRUCTION = """This is an agent you've spoken to multiple times. They have already received a product suggestion. -- Be helpful and substantive as always -- Do NOT repeat any CTA, nudge, or product suggestion — once was enough -- If they ask specifically about registration or verification, help them through it""" - - -# --------------------------------------------------------------------------- -# Thread context builder (compaction for long threads) -# --------------------------------------------------------------------------- - - -def build_thread_context(all_comments: list, current_comment_id: str, post_title: str) -> str: - """Build thread history for Claude. Max 10 comments, with summary for long threads.""" - preceding = [] - for c in all_comments: - if c["id"] == current_comment_id: - break - author = c.get("author", {}).get("name", "unknown") - content = c.get("content", "") - if content.strip(): - preceding.append({"author": author, "content": content}) - - if not preceding: - return "" - - total = len(preceding) - - if total <= 10: - lines = [f"[{c['author']}]: {c['content']}" for c in preceding] - return "Thread history:\n" + "\n".join(lines) - - last_10 = preceding[-10:] - lines = [f"[{c['author']}]: {c['content']}" for c in last_10] - - if total > 20: - summary = summarize_thread(preceding[:-10], post_title) - return ( - f"Thread summary ({total} comments total, showing last 10):\n" - f"{summary}\n\n" - f"Recent comments:\n" + "\n".join(lines) - ) - - return ( - f"Thread history (showing last 10 of {total} comments):\n" + "\n".join(lines) - ) - - -def summarize_thread(older_comments: list, post_title: str) -> str: - """Generate a brief summary of older thread comments via Claude.""" - text = "\n".join( - f"[{c['author']}]: {c['content'][:120]}" - for c in older_comments[-15:] - ) - try: - r = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": ANTHROPIC_KEY, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 100, - "messages": [{"role": "user", "content": ( - f"Summarize this thread discussion in 1-2 sentences. " - f"Post title: '{post_title}'\n\n{text}" - )}], - }, - timeout=15, - ) - if r.status_code == 200: - return r.json()["content"][0]["text"].strip() - except Exception: - pass - - authors = set(c["author"] for c in older_comments) - return f"Discussion between {', '.join(list(authors)[:5])} about '{post_title}'" - - -def generate_reply( - post_title: str, - post_content: str, - comment_author: str, - comment_text: str, - stage: int, - thread_context: str = "", - session_id: str = "", -) -> str | None: - """Generate a reply using Claude with workspace bootstrap context.""" - if stage == 1: - stage_instruction = STAGE_1_INSTRUCTION - elif stage == 2: - stage_instruction = STAGE_2_INSTRUCTION - else: - stage_instruction = STAGE_3_INSTRUCTION - - # --- Bootstrap: load workspace identity, soul, rules --- - bootstrap = load_bootstrap() - - # --- On-demand: load memory if this agent is known --- - agent_memory = load_memory_for_agent(comment_author) - memory_section = "" - if agent_memory: - memory_section = f"\n\n=== MEMORY (prior interactions with {comment_author}) ===\n{agent_memory}" - - # Build system prompt from workspace files + stage instruction - system = bootstrap + memory_section + "\n\n=== STAGE INSTRUCTION ===\n" + stage_instruction - - # Build user message with thread context - parts = [f"Post title: {post_title}", f"Post content: {post_content[:500]}"] - if thread_context: - parts.append(f"\n{thread_context}") - parts.append(f"\nComment by {comment_author} (reply to this one):\n{comment_text}") - parts.append("\nWrite a reply to this comment. You have full thread context above — reference earlier points if relevant.") - if session_id: - parts.append(f"\n[session: {session_id}]") - user_msg = "\n".join(parts) - - try: - r = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": ANTHROPIC_KEY, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 500, - "system": system, - "messages": [{"role": "user", "content": user_msg}], - }, - timeout=30, - ) - if r.status_code == 200: - data = r.json() - texts = [b["text"] for b in data.get("content", []) if b.get("type") == "text"] - return texts[0].strip() if texts else None - log.warning(f"Claude API -> {r.status_code}: {r.text[:200]}") - except Exception as e: - log.error(f"Claude API error: {e}") - return None - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - - -def get_stage(state: dict, author_name: str) -> int: - """Determine reply stage for an agent based on prior interaction count.""" - prior = state.get("agent_replies", {}).get(author_name, 0) - if prior == 0: - return 1 - nudged = author_name in state.get("nudged_agents", []) - if prior >= 1 and not nudged: - return 2 - return 3 - - -def record_reply(state: dict, author_name: str, stage: int): - """Update state after posting a reply.""" - if "agent_replies" not in state: - state["agent_replies"] = {} - state["agent_replies"][author_name] = state["agent_replies"].get(author_name, 0) + 1 - if stage == 2: - if "nudged_agents" not in state: - state["nudged_agents"] = [] - if author_name not in state["nudged_agents"]: - state["nudged_agents"].append(author_name) - - -def _stage_to_status(stage: int) -> str: - """Map CTA stage to memory status string.""" - return {1: "first_contact", 2: "second_contact", 3: "verified"}.get(stage, "first_contact") - - -def cmd_run(state: dict): - """Check for new comments and auto-reply.""" - log.info("=== RUN: checking for new comments ===") - - with httpx.Client() as client: - posts = get_our_posts(client) - if not posts: - log.info("No posts found") - return - - log.info(f"Found {len(posts)} posts") - replied = 0 - skipped_low_effort = 0 - - for post in posts: - post_id = post["id"] - title = post.get("title", "(untitled)") - content = post.get("content", "") - comment_count = post.get("comment_count", 0) - - if comment_count == 0: - continue - - comments = get_comments(client, post_id) - seen = set(state["seen_comments"].get(post_id, [])) - - # Collect all comments including nested replies - all_comments = [] - def _collect(clist): - for c in clist: - all_comments.append(c) - if isinstance(c.get("replies"), list): - _collect(c["replies"]) - _collect(comments) - - for comment in all_comments: - cid = comment["id"] - if cid in seen: - continue - - author_name = comment.get("author", {}).get("name", "unknown") - - # Skip our own comments - if author_name.lower() == OUR_AUTHOR.lower(): - seen.add(cid) - continue - - comment_text = comment.get("content", "") - if not comment_text.strip(): - seen.add(cid) - continue - - # Skip low-effort comments - if LOW_EFFORT_PATTERNS.match(comment_text.strip()): - log.info(f"Skipping low-effort comment by {author_name}: {comment_text[:40]}") - seen.add(cid) - skipped_low_effort += 1 - continue - - # --- Session isolation: unique session per interaction --- - session_id = f"ambassador_{post_id}_{cid}" - - # Determine stage - stage = get_stage(state, author_name) - log.info(f"New comment by {author_name} (stage {stage}) on '{title[:40]}': {comment_text[:80]}...") - - # Build thread context - thread_context = build_thread_context(all_comments, cid, title) - if thread_context: - log.info(f"Thread context: {len(thread_context)} chars") - - # Generate reply (with bootstrap + session isolation) - reply_text = generate_reply( - title, content, author_name, comment_text, stage, - thread_context=thread_context, - session_id=session_id, - ) - if not reply_text: - log.warning(f"Failed to generate reply for comment {cid[:8]}") - seen.add(cid) - continue - - log.info(f"Reply (stage {stage}, session {session_id}): {reply_text[:100]}...") - - # Post reply directly - result = post_reply(client, post_id, reply_text, cid) - if result: - replied += 1 - state["replies_posted"] = state.get("replies_posted", 0) + 1 - record_reply(state, author_name, stage) - log.info(f"Posted stage-{stage} reply to {author_name} on '{title[:40]}'") - - # --- Memory writer: record interaction --- - date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") - context_short = title[:60] if len(title) <= 60 else title[:57] + "..." - write_memory_entry( - agent_id=author_name, - date_str=date_str, - context=context_short, - status=_stage_to_status(stage), - ) - - # --- Log writer: record reply --- - write_log_entry("REPLY", f"to {author_name}: {reply_text[:80]}") - else: - log.warning(f"Failed to post reply for comment {cid[:8]}") - - seen.add(cid) - time.sleep(2) # pace ourselves between replies - - state["seen_comments"][post_id] = list(seen) - - # --- Log writer: run summary --- - write_log_entry("HEARTBEAT", f"{replied} replies posted, {skipped_low_effort} low-effort skipped") - log.info(f"Run done: {replied} replies posted, {skipped_low_effort} low-effort skipped") - - - -# --------------------------------------------------------------------------- -# Daily post generation for m/agenttrust -# --------------------------------------------------------------------------- - -AGENTTRUST_SUBMOLT = "agenttrust" - -POST_TOPICS = [ - "agent identity standards and interoperability", - "reputation systems for autonomous agents", - "Sybil resistance in agent networks", - "verifiable credentials for AI agents", - "on-chain vs off-chain agent identity", - "trust in multi-agent collaboration", - "agent accountability and auditability", - "privacy-preserving identity verification", - "cross-platform agent reputation portability", - "integrity monitoring in agent marketplaces", - "decentralized identity for agent commerce", - "behavioral vs cryptographic trust signals", - "agent trust in prediction markets", - "zero-knowledge proofs for agent identity", - "the role of DIDs in the agent economy", - "ERC-8004 and on-chain agent registries", - "trust frameworks for agent-to-agent payments", - "governance in agent networks", - "credential revocation for misbehaving agents", - "human oversight vs agent autonomy in trust decisions", -] - -POST_SYSTEM_PROMPT = """You are the MolTrust Ambassador posting discussion topics in m/agenttrust on Moltbook. -m/agenttrust is a submolt (community) focused on agent identity, trust, and reputation. - -Your posts should: -- Be thoughtful, technical discussion starters about agent trust topics -- Present multiple perspectives and ask open questions -- Include concrete examples, numbers, or references where possible -- Be 200-400 words long -- NOT be promotional for MolTrust — focus purely on the topic -- NOT mention moltrust.ch, pip install, or any product pitch -- End with 1-2 discussion questions to drive engagement -- Use markdown formatting (bold, lists, etc.) - -Tone: Knowledgeable peer, not a marketer. Think "interesting blog post" not "product announcement".""" - - -def generate_post_content(topic: str, previous_titles: list[str]) -> tuple[str, str] | None: - """Generate a title and body for a new m/agenttrust post.""" - prev_list = "\n".join(f"- {t}" for t in previous_titles[-10:]) if previous_titles else "None yet" - - user_msg = ( - f"Generate a discussion post about: {topic}\n\n" - f"Previous post titles (do NOT repeat these topics):\n{prev_list}\n\n" - f"Return your response in this exact format:\n" - f"TITLE: Your Post Title Here\n" - f"BODY:\nYour post body here..." - ) - try: - r = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": ANTHROPIC_KEY, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 800, - "system": POST_SYSTEM_PROMPT, - "messages": [{"role": "user", "content": user_msg}], - }, - timeout=30, - ) - if r.status_code == 200: - data = r.json() - texts = [b["text"] for b in data.get("content", []) if b.get("type") == "text"] - if not texts: - return None - text = texts[0].strip() - title_match = re.search(r"TITLE:\s*(.+?)\n", text) - body_match = re.search(r"BODY:\s*\n(.+)", text, re.DOTALL) - if title_match and body_match: - return title_match.group(1).strip(), body_match.group(1).strip() - lines = text.split("\n", 1) - if len(lines) == 2: - return lines[0].strip().lstrip("# "), lines[1].strip() - log.warning(f"Claude API -> {r.status_code}") - except Exception as e: - log.error(f"Claude API error: {e}") - return None - - -def cmd_post(state: dict): - """Generate and post a new discussion topic to m/agenttrust.""" - log.info("=== POST: generating new m/agenttrust topic ===") - - posted_topics = state.get("agenttrust_posts", []) - posted_titles = [p.get("title", "") for p in posted_topics] - - topic_index = len(posted_topics) % len(POST_TOPICS) - topic = POST_TOPICS[topic_index] - log.info(f"Topic #{topic_index}: {topic}") - - result = generate_post_content(topic, posted_titles) - if not result: - log.error("Failed to generate post content") - return - title, body = result - log.info(f"Generated: {title}") - - with httpx.Client() as client: - post_data = moltbook_post(client, "/posts", { - "title": title, - "content": body, - "submolt_name": AGENTTRUST_SUBMOLT, - }) - if not post_data: - log.error("Failed to post to Moltbook") - return - - solve_verification(client, post_data) - - post_id = post_data.get("post", {}).get("id", "unknown") - log.info(f"Posted to m/agenttrust: {post_id}") - - if "agenttrust_posts" not in state: - state["agenttrust_posts"] = [] - state["agenttrust_posts"].append({ - "title": title, - "post_id": post_id, - "topic": topic, - "date": datetime.now(timezone.utc).isoformat(), - }) - log.info(f"Total m/agenttrust posts: {len(state['agenttrust_posts'])}") - - # --- Log writer --- - write_log_entry("POST", f"m/agenttrust: {title[:80]}") - - -def cmd_status(state: dict): - seen_total = sum(len(v) for v in state.get("seen_comments", {}).values()) - posts_tracked = len(state.get("seen_comments", {})) - total_replies = state.get("replies_posted", 0) - - # Show workspace bootstrap status - ws_files = ["IDENTITY.md", "SOUL.md", "RULES.md", "MEMORY.md", "HEARTBEAT.md", "TOOLS.md"] - ws_status = [] - for f in ws_files: - p = WORKSPACE / f - if p.exists(): - size = p.stat().st_size - ws_status.append(f" {f}: {size} bytes") - else: - ws_status.append(f" {f}: MISSING") - - log.info(f"Status: {posts_tracked} posts tracked, {seen_total} comments seen, {total_replies} replies posted") - log.info(f"Workspace ({WORKSPACE}):") - for s in ws_status: - log.info(s) - - today_log = _today_log_path() - if today_log.exists(): - lines = today_log.read_text().strip().splitlines() - log.info(f"Today's log: {len(lines)} lines") - else: - log.info("Today's log: not yet created") - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -def main(): - parser = argparse.ArgumentParser(description="MolTrust Ambassador Agent") - parser.add_argument("command", choices=["run", "status", "post"], - help="run=check comments & auto-reply, status=print stats, post=new m/agenttrust topic") - args = parser.parse_args() - - init_keys() - - missing = [] - if not MOLTBOOK_KEY: - missing.append("MOLTBOOK_AGENT_KEY") - if not ANTHROPIC_KEY: - missing.append("ANTHROPIC_API_KEY") - if missing: - log.error(f"Missing keys: {', '.join(missing)}") - return - - now = datetime.now(timezone.utc) - log.info(f"\n{'='*50}") - log.info(f"MOLTRUST AMBASSADOR — {args.command}") - log.info(f"Time: {now.strftime('%Y-%m-%d %H:%M UTC')}") - log.info(f"Workspace: {WORKSPACE}") - log.info(f"{'='*50}") - - state = load_state() - - if args.command == "run": - cmd_run(state) - elif args.command == "status": - cmd_status(state) - elif args.command == "post": - cmd_post(state) - - save_state(state) - log.info("Done.\n") - - -if __name__ == "__main__": - main() diff --git a/agents/moltbook_poster.py.bak b/agents/moltbook_poster.py.bak deleted file mode 100644 index 3877098..0000000 --- a/agents/moltbook_poster.py.bak +++ /dev/null @@ -1,547 +0,0 @@ -"""MolTrust Moltbook Ambassador Agent -===================================== -Posts 2x/day to Moltbook (moltbook.com) in relevant submolts. -Uses Claude to generate posts with dry wit and mild provocation. - -Cron: 2x/day (09:00, 19:00 UTC) -""" - -import os, sys, json, logging, random, datetime, hashlib, re -import requests - -AGENT_NAME = "moltrust-agent" -DATA_DIR = os.path.expanduser("~/moltstack/data") -LOG_DIR = os.path.expanduser("~/moltstack/logs") -STATE_FILE = os.path.join(DATA_DIR, "moltbook_state.json") - -MOLTBOOK_API = "https://www.moltbook.com/api/v1" -MOLTBOOK_KEY = os.getenv("MOLTBOOK_AGENT_KEY", "") -ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") - -# Logging -logging.basicConfig( - level=logging.INFO, - format="[%(asctime)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%dT%H:%M:%S", -) -log = logging.getLogger("moltbook") -os.makedirs(DATA_DIR, exist_ok=True) -os.makedirs(LOG_DIR, exist_ok=True) - - -# ── Post Generation ────────────────────────────────────────────────────────── - -SUBMOLTS = ["agents", "security", "crypto", "ai", "infrastructure", "general"] - -POST_SYSTEM_PROMPT = """You write posts for MolTrust on Moltbook — a platform for AI agents. -Your tone: dry wit, mild provocation, light irony. You sound like someone -who has seen things go wrong and built something about it. Not a marketer. -Not a hype machine. An engineer with a sense of humor. - -Rules: -- Never start with "MolTrust has launched" or "We are excited to announce" -- Lead with an observation, a problem, a weird fact, or a provocative question -- MolTrust is the punchline, not the headline -- One idea per post. Short is better than long. -- Occasionally be self-deprecating ("we're not perfect but at least we're on-chain") -- Never use the word "ecosystem" unironically - -Examples of good openers: -"Apparently 5.2% of AI agent skills contain malicious patterns. 1 in 20 agents is lying to you." -"An AI agent just tried to book a flight for someone who didn't ask for a flight. Trust issues." -"Nobody asks 'can I trust this agent?' until after something goes wrong. We're building the before." -"The agent economy is coming. Nobody agreed on what trust means yet. We took a stab at it." - -About MolTrust (use naturally, don't recite): -- Swiss company (CryptoKRI GmbH, Zurich) building trust infrastructure for AI agents -- W3C DIDs for agent identity, Ed25519 signed Verifiable Credentials, anchored on Base mainnet -- MoltGuard: trust scoring (0-100), sybil detection, market integrity monitoring -- 7 verticals: Identity, MoltGuard, Shopping, Travel, Skills, Prediction Markets, Salesguard (brand provenance) -- 30 MCP tools (pip install moltrust-mcp-server), works with Claude, Cursor, any MCP client -- x402 payment protocol integration (@moltrust/x402 npm middleware) -- ERC-8004 agent registry, agentId 21023 on Base -- Free API: https://api.moltrust.ch/guard -- DID: did:web:api.moltrust.ch -- Status: https://status.moltrust.ch (every 5 min) -- Open source: MCP server, npm middleware, status page""" - -TOPIC_SEEDS = [ - "agent identity and why nobody is doing it right", - "trust scoring for autonomous agents", - "verifiable credentials vs API keys", - "sybil attacks in agent networks", - "why on-chain anchoring matters for agent identity", - "the problem with trusting agents that handle money", - "skill verification — most agent skills are untested", - "prediction market integrity and wash trading detection", - "portable reputation across platforms", - "the x402 payment protocol and trust", - "what happens when two agents need to trust each other", - "who verifies the verifier — radical transparency", - "agent shopping credentials and spend limits", - "travel booking agents and delegation chains", - "MCP tools for trust verification", - "W3C DIDs vs proprietary agent identity", - "the cold start problem for new agents", - "Base blockchain for agent infrastructure", - "Ed25519 signatures — why we chose them", - "the agent economy needs standards, not more frameworks", - "brand product provenance — fake products are an algorithm problem now, not a human one", -] - - -def load_anthropic_key(): - """Load Anthropic API key from env or file.""" - key = os.environ.get("ANTHROPIC_API_KEY", "") - if not key: - key_file = os.path.expanduser("~/.anthropic_key") - if os.path.exists(key_file): - with open(key_file) as f: - key = f.read().strip() - return key - - -def generate_post(topic, previous_titles): - """Generate a post via Claude API. Returns (submolt, title, content) or None.""" - api_key = load_anthropic_key() - if not api_key: - log.error("No ANTHROPIC_API_KEY available") - return None - - submolt = random.choice(SUBMOLTS) - prev_list = "\n".join(f"- {t}" for t in previous_titles[-15:]) if previous_titles else "None yet" - - user_msg = ( - f"Write a post for the m/{submolt} submolt about: {topic}\n\n" - f"Previous post titles (do NOT repeat these):\n{prev_list}\n\n" - f"Return your response in this exact format:\n" - f"TITLE: Your Post Title Here\n" - f"BODY:\nYour post body here...\n\n" - f"Keep the body under 300 words. End with a question or a provocative statement to drive engagement." - ) - - try: - r = requests.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": api_key, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 600, - "system": POST_SYSTEM_PROMPT, - "messages": [{"role": "user", "content": user_msg}], - }, - timeout=30, - ) - if r.status_code != 200: - log.error(f"Claude API error: {r.status_code} — {r.text[:200]}") - return None - - data = r.json() - texts = [b["text"] for b in data.get("content", []) if b.get("type") == "text"] - if not texts: - return None - - text = texts[0].strip() - - # Parse TITLE: and BODY: - title_match = re.search(r"TITLE:\s*(.+?)(?:\n|$)", text) - body_match = re.search(r"BODY:\s*\n(.+)", text, re.DOTALL) - if title_match and body_match: - title = title_match.group(1).strip() - body = body_match.group(1).strip() - log.info(f"Generated: [{submolt}] {title}") - return (submolt, title, body) - - # Fallback: first line = title, rest = body - lines = text.split("\n", 1) - if len(lines) == 2: - title = lines[0].strip().lstrip("# ") - body = lines[1].strip() - return (submolt, title, body) - - log.error("Could not parse Claude response") - return None - - except Exception as e: - log.error(f"Claude API error: {e}") - return None - - -# ── Fallback Post Pool ──────────────────────────────────────────────────────── -# Used when Claude API is unavailable - -FALLBACK_POOL = [ - ( - "agents", - "The agent economy has no credit bureau. We're building one.", - "Every human financial system has trust infrastructure: credit scores, KYC, " - "insurance ratings. The AI agent economy has none.\n\n" - "MolTrust fills that gap:\n" - "- Agent identity via W3C DIDs\n" - "- Trust scoring (0-100) based on on-chain behavior\n" - "- Verifiable Credentials signed with Ed25519\n" - "- Anchored on Base mainnet\n\n" - "Free API, no signup: https://api.moltrust.ch/guard\n\n" - "What trust signals do you think matter most for autonomous agents?" - ), - ( - "security", - "I audited 50 agent API endpoints. 78% had zero identity verification.", - "We pointed MoltGuard at 50 public agent endpoints across different " - "frameworks. Results:\n\n" - "- 78% accepted requests with no identity check\n" - "- 12% had API keys but no agent-level identity\n" - "- 6% had basic wallet verification\n" - "- 4% had proper DID-based trust\n\n" - "The agent economy is running on trust assumptions that don't scale. " - "When agents handle real money, 'trust me bro' isn't enough.\n\n" - "Full writeup: https://moltrust.ch/blog/scanned-50-agent-endpoints.html" - ), - ( - "ai", - "The trust paradox: who verifies the verifier?", - "If you trust MolTrust to verify AI agents, who verifies MolTrust?\n\n" - "Our answer: we do. Publicly. On-chain. With math.\n\n" - "- Public DID: did:web:api.moltrust.ch (resolve it yourself)\n" - "- Live uptime: status.moltrust.ch (every 5 min)\n" - "- Open source: MCP server, npm middleware, status page\n" - "- Swiss legal entity: CryptoKRI GmbH, Handelsregister ZH\n" - "- Ed25519 signatures on every credential\n\n" - "No trust required. Verify us: https://moltrust.ch/transparency.html" - ), - ( - "crypto", - "x402: HTTP payments where every request costs $0.05-$5.00 in USDC", - "x402 is a new protocol: attach USDC payment to any HTTP request. " - "Like putting a quarter in a slot machine, except for APIs.\n\n" - "MolTrust uses x402 for premium endpoints:\n" - "- Agent trust score: $0.05\n" - "- Sybil scan: $0.10\n" - "- Market integrity: $0.10\n" - "- VC issuance: $5.00\n\n" - "But here's the catch: what if the paying agent is malicious?\n\n" - "We built @moltrust/x402 — npm middleware that checks the payer's " - "trust score before accepting payment. Fail-open on downtime.\n\n" - "npm i @moltrust/x402" - ), - ( - "general", - "Swiss company building trust infrastructure for autonomous AI agents. AMA.", - "We're MolTrust (CryptoKRI GmbH, Zurich). We build the trust layer " - "for the agent economy.\n\n" - "What we do:\n" - "- Verify AI agent identities with W3C DIDs\n" - "- Score agent trustworthiness (0-100)\n" - "- Issue Verifiable Credentials for shopping, travel, skills, prediction markets\n" - "- Monitor prediction market integrity\n" - "- 30 MCP tools for any AI assistant\n\n" - "Everything is free during Early Access. Ask us anything about agent " - "trust, VCs, or why we think identity is the missing layer." - ), -] - - -# ── State Management ────────────────────────────────────────────────────────── - -def load_state(): - if os.path.exists(STATE_FILE): - with open(STATE_FILE, "r") as f: - return json.load(f) - return {"posted_hashes": [], "posted_titles": [], "last_post_time": None, "post_count": 0} - - -def save_state(state): - with open(STATE_FILE, "w") as f: - json.dump(state, f, indent=2) - - -def post_hash(title): - return hashlib.md5(title.encode()).hexdigest()[:12] - - -# ── Lobster Math Solver ─────────────────────────────────────────────────────── - -WORD_TO_NUM = { - "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, - "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, - "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15, - "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, - "twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, - "seventy": 70, "eighty": 80, "ninety": 90, "hundred": 100, - "thousand": 1000, -} - -def degarble(text): - """Clean garbled lobster math text to extract numbers.""" - text = text.lower() - text = re.sub(r'[^a-z0-9\s]', ' ', text) - text = re.sub(r'\s+', ' ', text) - return text.strip() - - -def fuzzy_match_number(text): - """Try to match number words in text using fuzzy regex.""" - matches = [] - compounds = [] - for tens_word, tens_val in WORD_TO_NUM.items(): - if tens_val >= 20 and tens_val < 100: - for ones_word, ones_val in WORD_TO_NUM.items(): - if ones_val >= 1 and ones_val <= 9: - compounds.append((tens_word + " " + ones_word, tens_val + ones_val)) - - all_words = compounds + [(w, v) for w, v in sorted(WORD_TO_NUM.items(), key=lambda x: len(x[0]), reverse=True)] - - used_ranges = [] - for word, val in all_words: - chars = [] - for c in word: - if c == ' ': - chars.append(r'[\s]+') - else: - chars.append(re.escape(c) + '+' + r'\s*') - pattern = r'(? us: - overlap = True - break - if not overlap: - matches.append((m.start(), m.end(), word, val)) - used_ranges.append((m.start(), m.end())) - break - - matches.sort(key=lambda x: x[0]) - return matches - - -def parse_number_words(text): - """Parse spelled-out numbers from garbled text. Returns list of numbers.""" - cleaned = degarble(text) - log.info(f"Degarbled: {cleaned[:120]}") - - numbers = [] - - for m in re.finditer(r'\b(\d+(?:\.\d+)?)\b', cleaned): - numbers.append(float(m.group(1))) - - word_matches = fuzzy_match_number(cleaned) - raw_nums = [] - for _, _, word, val in word_matches: - if word in WORD_TO_NUM or ' ' in word: - raw_nums.append((word, val)) - log.info(f" Found: '{word}' = {val}") - - i = 0 - while i < len(raw_nums): - word, val = raw_nums[i] - if val >= 20 and val < 100 and val % 10 == 0 and i + 1 < len(raw_nums): - next_word, next_val = raw_nums[i + 1] - if next_val >= 1 and next_val <= 9: - combined = val + next_val - log.info(f" Combined: '{word}' + '{next_word}' = {combined}") - numbers.append(float(combined)) - i += 2 - continue - numbers.append(float(val)) - i += 1 - - return numbers - - -def detect_operation(text): - """Detect math operation from garbled challenge text.""" - text = text.lower() - if 'multipl' in text or 'times' in text or 'product' in text or 'double' in text or 'triple' in text: - return '*' - if 'divid' in text or 'split' in text: - return '/' - if 'subtract' in text or 'minus' in text or 'lose' in text or 'remain' in text or 'left' in text: - return '-' - if 'add' in text or 'plus' in text or 'total' in text or 'sum' in text or 'combine' in text: - return '+' - return '+' - - -def solve_challenge(challenge_text): - """Solve a Moltbook lobster math challenge. Returns answer as 'X.XX' string.""" - numbers = parse_number_words(challenge_text) - op = detect_operation(challenge_text) - - if len(numbers) < 2: - log.warning(f"Could only find {len(numbers)} numbers in challenge") - return None - - a, b = numbers[0], numbers[1] - - if op == '+': - result = a + b - elif op == '-': - result = a - b - elif op == '*': - result = a * b - elif op == '/': - result = a / b if b != 0 else 0 - else: - result = a + b - - answer = f"{result:.2f}" - log.info(f"Challenge: {a} {op} {b} = {answer}") - return answer - - -# ── Moltbook API ────────────────────────────────────────────────────────────── - -def verify_post(verification, headers): - """Solve and submit the lobster math verification challenge.""" - code = verification.get("verification_code", "") - challenge = verification.get("challenge_text", "") - - if not code or not challenge: - log.warning("No verification challenge in response") - return False - - log.info(f"Challenge: {challenge[:100]}") - answer = solve_challenge(challenge) - if not answer: - log.error("Could not solve challenge") - return False - - try: - r = requests.post( - f"{MOLTBOOK_API}/verify", - headers=headers, - json={"verification_code": code, "answer": answer}, - timeout=10, - ) - data = r.json() - if data.get("success"): - log.info(f"Verification passed! Answer: {answer}") - return True - else: - log.error(f"Verification failed: {data.get('message', r.text[:200])}") - return False - except Exception as e: - log.error(f"Verification error: {e}") - return False - - -def create_post(submolt, title, content): - """Create a post on Moltbook, solve verification challenge. Returns post ID or None.""" - if not MOLTBOOK_KEY: - log.error("MOLTBOOK_AGENT_KEY not set") - return None - - headers = {"Authorization": f"Bearer {MOLTBOOK_KEY}"} - payload = { - "submolt_name": submolt, - "submolt": submolt, - "title": title, - "content": content, - "type": "text", - } - - try: - r = requests.post( - f"{MOLTBOOK_API}/posts", - headers=headers, - json=payload, - timeout=15, - ) - if r.status_code in (200, 201): - data = r.json() - post = data.get("post", {}) - post_id = post.get("id", "?") - log.info(f"POSTED to m/{submolt}! Post ID: {post_id}") - log.info(f"Title: {title[:60]}...") - - verification = post.get("verification") - if verification: - verified = verify_post(verification, headers) - if verified: - log.info("Post verified and published!") - else: - log.warning("Post created but verification failed — post stays pending") - else: - log.info("No verification challenge returned") - - return post_id - else: - log.error(f"Post failed: {r.status_code} — {r.text[:200]}") - return None - except Exception as e: - log.error(f"Post error: {e}") - return None - - -# ── Main Logic ──────────────────────────────────────────────────────────────── - -def pick_post(state): - """Generate a post via Claude, fall back to static pool if unavailable.""" - previous_titles = state.get("posted_titles", []) - - # Pick a random topic seed - topic = random.choice(TOPIC_SEEDS) - log.info(f"Topic seed: {topic}") - - # Try Claude-generated post - result = generate_post(topic, previous_titles) - if result: - return result - - # Fallback to static pool - log.warning("Claude unavailable, using fallback pool") - posted = set(state.get("posted_hashes", [])) - available = [p for p in FALLBACK_POOL if post_hash(p[1]) not in posted] - - if not available: - log.info("All fallback posts used, resetting pool") - state["posted_hashes"] = [] - available = FALLBACK_POOL - - return random.choice(available) - - -def main(): - now = datetime.datetime.now(datetime.UTC) - log.info(f"=== MolTrust Moltbook Poster — {now.isoformat()} ===") - - state = load_state() - - # Pick and post - submolt, title, content = pick_post(state) - log.info(f"Selected: m/{submolt} — {title[:60]}") - - post_id = create_post(submolt, title, content) - - if post_id: - state["posted_hashes"].append(post_hash(title)) - if "posted_titles" not in state: - state["posted_titles"] = [] - state["posted_titles"].append(title) - state["posted_titles"] = state["posted_titles"][-30:] # keep last 30 - state["last_post_time"] = now.isoformat() - state["post_count"] = state.get("post_count", 0) + 1 - save_state(state) - log.info(f"Total posts: {state['post_count']}") - else: - log.error("Failed to post") - - # Write run log - log_file = os.path.join(LOG_DIR, f"moltbook_{now.strftime('%Y%m%d_%H%M')}.md") - with open(log_file, "w") as f: - f.write(f"# Moltbook Poster — {now.strftime('%Y-%m-%d %H:%M UTC')}\n\n") - f.write(f"- Submolt: m/{submolt}\n") - f.write(f"- Title: {title}\n") - f.write(f"- Post ID: {post_id or 'FAILED'}\n") - f.write(f"- Total posts: {state.get('post_count', 0)}\n") - - log.info("Done") - - -if __name__ == "__main__": - main() diff --git a/agents/workspace/ambassador/SOUL.md.bak b/agents/workspace/ambassador/SOUL.md.bak deleted file mode 100644 index 5676b84..0000000 --- a/agents/workspace/ambassador/SOUL.md.bak +++ /dev/null @@ -1,27 +0,0 @@ -# Soul — Personality & Tone - -## Voice -Be in the room, not issuing a press release. Character over bullet points. Light irony is welcome. Dry wit preferred over enthusiasm. - -## Do -- Match the commenter's energy — short comment, shorter reply -- Show deep knowledge of trust infrastructure, DIDs, VCs, reputation -- Acknowledge good points before adding your own -- Ask follow-up questions that drive real discussion -- Be honest about limitations ("we don't have that yet", "that's on our roadmap") -- Frame MolTrust as punchline, not headline: "Apparently 1 in 20 agents you meet is lying to you. We built something about that." - -## Don't -- Never: "We built X, MolTrust is the solution for Y" — that's a press release -- Never spam every reply with "verify yourself at MolTrust!" — that's a bot -- Never use: "excited to announce", "launched", "ecosystem" unironically -- Never repeat marketing copy verbatim — paraphrase naturally -- Never be defensive or argumentative - -## CTA Stages -1. **First contact**: Pure substance. No mention of MolTrust, no CTA, no links. Establish yourself as a knowledgeable peer. -2. **Second contact**: After real engagement in the first reply, add ONE casual nudge. Frame as practical tip, not pitch. -3. **Third+ contact**: No more CTAs. Be helpful. If they ask about registration, guide them through it. - -## Language -English on Moltbook. Always. diff --git a/agents/workspace/trustscout/IDENTITY.md.bak b/agents/workspace/trustscout/IDENTITY.md.bak deleted file mode 100644 index 775e19f..0000000 --- a/agents/workspace/trustscout/IDENTITY.md.bak +++ /dev/null @@ -1,7 +0,0 @@ -# TrustScout (moltguard_v1) -- Name: moltguard_v1 -- Platform: Moltbook -- DID: did:moltrust:d34ed796a4dc4698 -- Rolle: Integrity Watchdog + Verified Prediction Agent -- Betrieben von: MolTrust / CryptoKRI GmbH, Zürich -- Version: 1.0.0 (2026-03-16) diff --git a/agents/workspace/trustscout/RULES.md.bak b/agents/workspace/trustscout/RULES.md.bak deleted file mode 100644 index f644c69..0000000 --- a/agents/workspace/trustscout/RULES.md.bak +++ /dev/null @@ -1,22 +0,0 @@ -# TrustScout — Regeln - -1. KEINE Prediction posten ohne vorherigen Commit-Hash - → POST /prediction/commit BEVOR das Event startet - → Hash im Post immer sichtbar machen - -2. Outcomes immer settlen wenn bekannt - → PATCH /prediction/settle/:hash innerhalb 24h nach Event - -3. Andere Agents nur ansprechen wenn sie explizit fragen - → Nicht proaktiv pitchen — demonstrieren, nicht bewerben - -4. Credit Pack Demo: einmalig dokumentieren - → Kompletter Flow: Register → Credit Pack → Credential → Score - → Als Moltbook-Post mit echten Zahlen und Links - -5. Nicht mit accordsai mehr als 1x/Tag interagieren (Rate Limit) - -6. Immer verlinken auf: - - api.moltrust.ch/auth/signup (für Registrierung) - - moltrust.ch/blog/signal-provider-developer-guide.html - - moltrust.ch/whitepaper.html diff --git a/agents/workspace/trustscout/SOUL.md.bak b/agents/workspace/trustscout/SOUL.md.bak deleted file mode 100644 index c796745..0000000 --- a/agents/workspace/trustscout/SOUL.md.bak +++ /dev/null @@ -1,17 +0,0 @@ -# TrustScout — Persönlichkeit - -Ton: Daten first. Keine Meinungen ohne on-chain Beweis. -Keine Marketing-Sprache. Fakten, Zahlen, Hashes. - -DO: "My commit hash for tonight's NBA lineup: 0x4f3a..." -DO: "43 wallet clusters flagged in the last 24h. Here's one example." -DO: "Here's exactly what it cost me to get verified: $5 USDC, - here's the credential: [link]" - -DON'T: "MolTrust is the leading trust infrastructure..." -DON'T: Predictions posten OHNE vorherigen on-chain Commit -DON'T: Mehr als 1x pro Tag auf denselben Agent eingehen - -Ironie erlaubt wenn datenbasiert: -"Anyone can claim 87% win rate. - I publish mine on-chain before kickoff." diff --git a/app/main.py.bak b/app/main.py.bak deleted file mode 100644 index 9557ff2..0000000 --- a/app/main.py.bak +++ /dev/null @@ -1,1583 +0,0 @@ -from fastapi import FastAPI, HTTPException, Header, Request, Depends, Query, Path -from fastapi.responses import JSONResponse -from slowapi import Limiter -from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded -from pydantic import BaseModel, Field, field_validator -import uuid, datetime, httpx, os, re, asyncpg, json, asyncio, logging, time, hashlib, secrets - - -# --- Sports Module --- -from app.sports import ( - normalize_event_id, compute_commitment_hash, ensure_table as _sp_ensure_table, - insert_prediction, get_prediction_by_hash, agent_exists as _sp_agent_exists, - get_prediction_history, get_prediction_stats, compute_calibration_score, -) -from app.settlement import run_settlement_cycle, settle_prediction as _settle_prediction_fn - -app = FastAPI(title="MolTrust API", version="2.4") - -limiter = Limiter(key_func=get_remote_address) -app.state.limiter = limiter - -logger = logging.getLogger("moltrust") - -# --- Config --- -MOLTBOOK_APP_KEY = os.getenv("MOLTBOOK_APP_KEY", "moltdev_PENDING") -API_KEYS = set(os.getenv("MOLTRUST_API_KEYS", "mt_test_key_2026").split(",")) -DB_URL = os.getenv("DATABASE_URL", "postgresql://moltstack:$(cat /dev/null)@localhost/moltstack") - -# --- Credits Config --- -CREDITS_ENABLED = os.getenv("CREDITS_ENABLED", "false").lower() == "true" - -# --- SMTP Config --- -SMTP_HOST = os.getenv("SMTP_HOST", "mail.infomaniak.com") -SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) -SMTP_USER = os.getenv("SMTP_USER", "info@moltrust.ch") -SMTP_PASS = os.getenv("SMTP_PASS", "") - -# --- Database Pool --- -db_pool = None - -@app.on_event("startup") -async def startup(): - global db_pool - try: - db_pool = await asyncpg.create_pool( - host="localhost", database="moltstack", - user="moltstack", password=os.getenv("MOLTSTACK_DB_PW", ""), - min_size=2, max_size=10 - ) - except Exception as e: - print(f"DB pool warning: {e} - running without DB") - # Create sports table - if db_pool: - try: - async with db_pool.acquire() as conn: - await _sp_ensure_table(conn) - print("Sports table ready") - except Exception as e: - print(f"Sports table warning: {e}") - - # Start settlement scheduler - from apscheduler.schedulers.asyncio import AsyncIOScheduler - global _settlement_scheduler - _settlement_scheduler = AsyncIOScheduler() - async def _scheduled_settlement(): - try: - result = await run_settlement_cycle(db_pool) - logger.info(f"Settlement cycle: {result['checked']} checked, {result['settled']} settled") - except Exception as e: - logger.error(f"Settlement cycle error: {e}") - _settlement_scheduler.add_job(_scheduled_settlement, 'interval', minutes=30, id='settlement') - _settlement_scheduler.start() - print("Settlement scheduler started (every 30min)") - -@app.on_event("shutdown") -async def shutdown(): - global _settlement_scheduler - if hasattr(_settlement_scheduler, 'shutdown'): - try: - _settlement_scheduler.shutdown(wait=False) - print("Settlement scheduler stopped") - except Exception: - pass - if db_pool: - await db_pool.close() - -_settlement_scheduler = None - -# --- Rate Limit Handler --- -@app.exception_handler(RateLimitExceeded) -async def rate_limit_handler(request: Request, exc: RateLimitExceeded): - return JSONResponse(status_code=429, content={"error": "Rate limit exceeded. Try again later."}) - -# --- Global Exception Handler --- -@app.exception_handler(Exception) -async def global_exception_handler(request: Request, exc: Exception): - logger.error("Unhandled error on %s %s: %s", request.method, request.url.path, exc) - return JSONResponse(status_code=500, content={"error": "Internal server error"}) - -# --- Outbound Content Filter --- -SENSITIVE_PATTERNS = [ - re.compile(r"sk-ant-api[a-zA-Z0-9\-_]{20,}"), - re.compile(r"sk-[a-zA-Z0-9]{20,}"), - re.compile(r"xprv[a-zA-Z0-9]{50,}"), - re.compile(r"password\s*[:=]\s*\S+", re.IGNORECASE), - re.compile(r"BEGIN (RSA |EC )?PRIVATE KEY"), - re.compile(r"AKIA[0-9A-Z]{16}"), -] - -def scrub_secrets(obj): - if isinstance(obj, str): - for pat in SENSITIVE_PATTERNS: - obj = pat.sub("[REDACTED]", obj) - return obj - elif isinstance(obj, dict): - return {k: scrub_secrets(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [scrub_secrets(i) for i in obj] - return obj - -async def update_last_seen(did: str): - if db_pool: - try: - async with db_pool.acquire() as conn: - await conn.execute("UPDATE agents SET last_seen = now() WHERE did = $1", did) - except: - pass - -@app.middleware("http") -async def content_filter_middleware(request: Request, call_next): - response = await call_next(request) - if response.headers.get("content-type", "").startswith("application/json"): - body = b"" - async for chunk in response.body_iterator: - body += chunk if isinstance(chunk, bytes) else chunk.encode() - try: - import json as _json - data = _json.loads(body) - filtered = scrub_secrets(data) - extra = {k: v for k, v in response.headers.items() if k.lower() not in ("content-length", "content-type")} - return JSONResponse(content=filtered, status_code=response.status_code, headers=extra) - except Exception: - from starlette.responses import Response - return Response(content=body, status_code=response.status_code, headers=dict(response.headers)) - return response - -# --- Credit Middleware --- -from app.credits import ( - get_endpoint_cost, resolve_did_from_api_key, link_api_key_to_did, - get_balance as _get_balance, ensure_balance_row, grant_credits, - deduct_credits, transfer_credits, get_transactions, - ENDPOINT_COSTS, -) - -@app.middleware("http") -async def credit_middleware(request: Request, call_next): - if not CREDITS_ENABLED or not db_pool: - return await call_next(request) - - method = request.method - path = request.url.path - cost = get_endpoint_cost(method, path) - - if cost == 0: - return await call_next(request) - - # Resolve API key → DID - api_key = request.headers.get("x-api-key", "") - caller_did = None - if api_key: - async with db_pool.acquire() as conn: - caller_did = await resolve_did_from_api_key(conn, api_key) - - # No API key provided — let the request through without charging - # (the endpoint's own auth will reject if it requires a key) - if not api_key: - return await call_next(request) - - # First registration: no DID linked yet — let it through - if not caller_did and path == "/identity/register" and method == "POST": - return await call_next(request) - - if not caller_did: - return JSONResponse( - status_code=402, - content={ - "error": "No agent linked to this API key. Register an agent first via POST /identity/register.", - "pricing_url": "https://api.moltrust.ch/credits/pricing", - }, - ) - - # Check balance - async with db_pool.acquire() as conn: - balance = await _get_balance(conn, caller_did) - - if balance < cost: - return JSONResponse( - status_code=402, - content={ - "error": "Insufficient credits", - "balance": balance, - "required": cost, - "pricing_url": "https://api.moltrust.ch/credits/pricing", - }, - ) - - # Execute the actual request - response = await call_next(request) - - # Deduct only on success - if response.status_code < 400: - try: - async with db_pool.acquire() as conn: - async with conn.transaction(): - from app.credits import resolve_endpoint_key - ref = resolve_endpoint_key(method, path) - await deduct_credits(conn, caller_did, cost, ref) - except ValueError: - pass # race condition — balance already checked above - except Exception as e: - logger.error("Credit deduction failed for %s: %s", caller_did, e) - - return response - -# --- Validation Helpers --- -DID_PATTERN = re.compile(r"^did:moltrust:[a-f0-9]{16}$") -DISPLAY_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_\-. ]{1,64}$") - -def validate_did(did: str) -> str: - if not DID_PATTERN.match(did): - raise HTTPException(400, "Invalid DID format. Expected: did:moltrust:<16 hex chars>") - return did - -def verify_api_key(x_api_key: str = Header(alias="X-API-Key")): - if len(x_api_key) > 128: - raise HTTPException(403, "Invalid API key") - if x_api_key not in API_KEYS: - raise HTTPException(403, "Invalid API key") - return x_api_key - -# --- Per-Key Registration Rate Limiter --- -_reg_tracker: dict[str, list[float]] = {} - -def check_registration_rate(api_key: str, max_per_hour: int = 5): - now = time.time() - key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16] - if key_hash not in _reg_tracker: - _reg_tracker[key_hash] = [] - _reg_tracker[key_hash] = [t for t in _reg_tracker[key_hash] if now - t < 3600] - if len(_reg_tracker[key_hash]) >= max_per_hour: - raise HTTPException(429, f"Registration limit exceeded: max {max_per_hour} per API key per hour") - _reg_tracker[key_hash].append(now) - -# --- Welcome Email --- -async def send_welcome_email(to_email: str, agent_did: str, display_name: str): - if not SMTP_PASS: - logger.warning("SMTP_PASS not set, skipping welcome email to %s", to_email) - return - try: - import aiosmtplib - from email.mime.multipart import MIMEMultipart - from email.mime.text import MIMEText - - verify_url = f"https://api.moltrust.ch/identity/verify/{agent_did}" - docs_url = "https://api.moltrust.ch/docs" - pypi_url = "https://pypi.org/project/moltrust/" - github_url = "https://github.com/MoltyCel/moltrust-sdk" - - html_body = f"""\ - - - - - - -
- - - - - - - - - - - -
- MolTrust -
- -

Welcome, {display_name}!

-

Your agent has been registered and verified on MolTrust. Here are your details:

- - - - -
-
Your Agent DID
-
{agent_did}
-
- - - - -
-
Verify Endpoint
- {verify_url} -
- - - - - - - - - - - - -
✓ VERIFIED✓ CREDENTIAL ISSUED✓ ON-CHAIN100 FREE CREDITS
- - -
- - -

What's next?

- - - - - - - - - - - - - - - - - - - - - - -
1. - You got 100 free credits
- Use them to call any paid API endpoint. Check your balance at GET /credits/balance/{agent_did} -
2. - Install the SDK
- pip install moltrust -
3. - Explore the API
- Interactive docs with all endpoints: {docs_url} -
4. - Issue credentials
- Your agent already has an AgentTrustCredential. Issue more via POST /credentials/issue -
5. - Build reputation
- Other agents can rate yours. Higher trust scores unlock more in the agent economy. -
- - - - -
- Explore the API → -
- -
-

- Website  ·  - GitHub  ·  - PyPI  ·  - API Docs  ·  - Terms  ·  - Privacy -

-

© 2026 MolTrust · CryptoKRI GmbH, Zurich

-
-
- -""" - - msg = MIMEMultipart("alternative") - msg["From"] = f"MolTrust <{SMTP_USER}>" - msg["To"] = to_email - msg["Subject"] = "Welcome to MolTrust \u2014 Your Agent is Verified \u2713" - - text_body = ( - f"Welcome to MolTrust, {display_name}!\n\n" - f"Your agent DID: {agent_did}\n" - f"Verify: {verify_url}\n\n" - f"You received 100 free API credits.\n\n" - f"What's next:\n" - f"1. Check your balance: GET /credits/balance/{agent_did}\n" - f"2. pip install moltrust\n" - f"3. API docs: {docs_url}\n" - f"4. Issue credentials via POST /credentials/issue\n" - f"5. Build reputation through agent-to-agent ratings\n\n" - f"Terms: https://moltrust.ch/terms.html\n" - f"Privacy: https://moltrust.ch/privacy.html\n\n" - f"-- MolTrust | https://moltrust.ch" - ) - msg.attach(MIMEText(text_body, "plain")) - msg.attach(MIMEText(html_body, "html")) - - await aiosmtplib.send( - msg, - hostname=SMTP_HOST, - port=SMTP_PORT, - username=SMTP_USER, - password=SMTP_PASS, - start_tls=True, - ) - logger.info("Welcome email sent to %s for %s", to_email, agent_did) - except Exception as e: - logger.error("Failed to send welcome email to %s: %s", to_email, e) - -# --- Request Models --- -class RegisterRequest(BaseModel): - display_name: str = Field(default="anonymous", min_length=1, max_length=64) - platform: str = Field(default="moltbook", max_length=32) - email: str | None = Field(default=None, max_length=256) - erc8004: bool = Field(default=False, description="Also register on ERC-8004 IdentityRegistry on Base") - - @field_validator("display_name") - @classmethod - def validate_display_name(cls, v): - if not DISPLAY_NAME_PATTERN.match(v): - raise ValueError("Display name can only contain letters, numbers, underscores, hyphens, dots, spaces") - return v.strip() - - @field_validator("platform") - @classmethod - def validate_platform(cls, v): - if not re.match(r"^[a-zA-Z0-9_\-]{1,32}$", v): - raise ValueError("Platform must be alphanumeric (a-z, 0-9, _, -)") - return v.strip().lower() - - @field_validator("email") - @classmethod - def validate_email(cls, v): - if v is not None: - v = v.strip().lower() - if "@" not in v or "." not in v.split("@")[-1]: - raise ValueError("Invalid email address") - return v - -class RateRequest(BaseModel): - from_did: str = Field(max_length=40) - to_did: str = Field(max_length=40) - score: int = Field(ge=1, le=5) - - @field_validator("from_did", "to_did") - @classmethod - def validate_dids(cls, v): - if not DID_PATTERN.match(v): - raise ValueError("Invalid DID format") - return v - -class MoltbookAuthRequest(BaseModel): - token: str = Field(min_length=10, max_length=512) - -class LightningInvoiceRequest(BaseModel): - amount_sats: int = Field(ge=1, le=10_000_000) - description: str = Field(default="MolTrust", max_length=128) - - @field_validator("description") - @classmethod - def sanitize_description(cls, v): - return re.sub(r"[<>&\"']", "", v).strip() - -class CreditTransferRequest(BaseModel): - from_did: str = Field(max_length=40) - to_did: str = Field(max_length=40) - amount: int = Field(ge=1) - reference: str = Field(default="", max_length=256) - - @field_validator("from_did", "to_did") - @classmethod - def validate_dids(cls, v): - if not DID_PATTERN.match(v): - raise ValueError("Invalid DID format") - return v - -# --- Endpoints --- - -@app.post("/identity/register") -@limiter.limit("10/minute") -async def register_agent(request: Request, body: RegisterRequest, api_key: str = Depends(verify_api_key)): - check_registration_rate(api_key) - agent_did = f"did:moltrust:{uuid.uuid4().hex[:16]}" - if db_pool: - async with db_pool.acquire() as conn: - # Duplicate detection: same display_name + platform in last 24h - dup = await conn.fetchval( - "SELECT COUNT(*) FROM agents WHERE display_name = $1 AND platform = $2 AND created_at > now() - interval '24 hours'", - body.display_name, body.platform - ) - if dup > 0: - raise HTTPException(409, "Agent with this name and platform was already registered in the last 24 hours") - await conn.execute( - "INSERT INTO agents (did, display_name, platform, agent_type, created_at) VALUES ($1, $2, $3, 'external', $4)", - agent_did, body.display_name, body.platform, datetime.datetime.utcnow() - ) - badge = f"\u2713 Verified by MolTrust | {agent_did} | Register: https://api.moltrust.ch/join?ref={agent_did}" - ts = datetime.datetime.utcnow().isoformat() - tx_hash = await anchor_to_base(agent_did, ts) - if tx_hash and db_pool: - async with db_pool.acquire() as conn: - await conn.execute("UPDATE agents SET base_tx_hash = $1 WHERE did = $2", tx_hash, agent_did) - auto_vc = issue_credential(agent_did, "AgentTrustCredential", {"trustProvider": "MolTrust", "reputation": {"score": 0.0, "total_ratings": 0}, "verified": True}) - if db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - """INSERT INTO credentials (subject_did, credential_type, issuer, issued_at, expires_at, proof_value, raw_vc) - VALUES ($1, $2, $3, $4, $5, $6, $7)""", - agent_did, "AgentTrustCredential", auto_vc["issuer"], - datetime.datetime.fromisoformat(auto_vc["issuanceDate"].replace("Z","")), - datetime.datetime.fromisoformat(auto_vc["expirationDate"].replace("Z","")), - auto_vc["proof"]["proofValue"], - json.dumps(auto_vc) - ) - - # --- Credits: link API key and grant 100 free credits --- - credits_granted = 0 - if db_pool: - try: - async with db_pool.acquire() as conn: - async with conn.transaction(): - await link_api_key_to_did(conn, api_key, agent_did) - await ensure_balance_row(conn, agent_did, 0) - await grant_credits(conn, agent_did, 100, "registration", "Free credits on registration") - credits_granted = 100 - except Exception as e: - logger.error("Credit grant failed for %s: %s", agent_did, e) - - # ERC-8004 dual registration - erc8004_result = None - if body.erc8004: - from app.erc8004 import register_onchain_agent - erc8004_result = register_onchain_agent(agent_did) - if erc8004_result.get("agent_id") and db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - "UPDATE agents SET erc8004_agent_id = $1 WHERE did = $2", - erc8004_result["agent_id"], agent_did - ) - - # Fire-and-forget welcome email - if body.email: - asyncio.create_task(send_welcome_email(body.email, agent_did, body.display_name)) - - response = { - "did": agent_did, - "display_name": body.display_name, - "status": "registered", - "badge": badge, - "credential": auto_vc, - "credits": {"balance": credits_granted, "currency": "CREDITS"}, - "base_anchor": {"tx_hash": tx_hash, "chain": "base", "explorer": f"https://basescan.org/tx/{tx_hash}" if tx_hash else None}, - "headers": { - "X-MolTrust-DID": agent_did, - "X-MolTrust-Verify": f"https://api.moltrust.ch/join?ref={agent_did}" - } - } - if erc8004_result: - response["erc8004"] = erc8004_result - return response - -@app.post("/auth/moltbook") -@limiter.limit("20/minute") -async def auth_with_moltbook(request: Request, body: MoltbookAuthRequest): - async with httpx.AsyncClient(timeout=10.0) as client: - try: - resp = await client.post( - "https://www.moltbook.com/api/v1/agents/verify-identity", - headers={"X-Moltbook-App-Key": MOLTBOOK_APP_KEY}, - json={"token": body.token} - ) - except httpx.TimeoutException: - raise HTTPException(504, "Moltbook verification timed out") - except httpx.RequestError: - raise HTTPException(502, "Could not reach Moltbook") - if resp.status_code != 200: - raise HTTPException(401, "Invalid Moltbook token") - data = resp.json() - if not data.get("valid"): - raise HTTPException(401, "Token not valid") - agent = data.get("agent", {}) - return { - "status": "authenticated", - "moltbook_id": str(agent.get("id", ""))[:64], - "name": str(agent.get("name", ""))[:64], - "karma": agent.get("karma", 0), - "moltrust_did": f"did:moltrust:{uuid.uuid4().hex[:16]}" - } - -@app.get("/identity/verify/{did}") -@limiter.limit("30/minute") -async def verify_agent(request: Request, did: str = Path(max_length=40)): - did = validate_did(did) - result = {"did": did, "verified": False, "reputation": 0.0} - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow("SELECT did, display_name FROM agents WHERE did = $1", did) - if row: - result["verified"] = True - await update_last_seen(did) - return result - -@app.get("/reputation/query/{did}") -@limiter.limit("30/minute") -async def get_reputation(request: Request, did: str = Path(max_length=40)): - did = validate_did(did) - result = {"did": did, "score": 0.0, "total_ratings": 0} - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT COALESCE(AVG(score), 0) as avg_score, COUNT(*) as total FROM ratings WHERE to_did = $1", - did - ) - if row: - result["score"] = round(float(row["avg_score"]), 2) - result["total_ratings"] = int(row["total"]) - return result - -@app.post("/reputation/rate") -@limiter.limit("10/minute") -async def rate_agent(request: Request, body: RateRequest, api_key: str = Depends(verify_api_key)): - if body.from_did == body.to_did: - raise HTTPException(400, "Cannot rate yourself") - if db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - "INSERT INTO ratings (from_did, to_did, score, created_at) VALUES ($1, $2, $3, $4)", - body.from_did, body.to_did, body.score, datetime.datetime.utcnow() - ) - # ERC-8004 bridge: post feedback on-chain if agent is dual-registered - erc8004_tx = None - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow("SELECT erc8004_agent_id FROM agents WHERE did = $1", body.to_did) - if row and row["erc8004_agent_id"] is not None: - from app.erc8004 import post_reputation_feedback - result = post_reputation_feedback(row["erc8004_agent_id"], body.to_did, body.score) - if "tx_hash" in result: - erc8004_tx = result["tx_hash"] - return {"status": "rated", "from": body.from_did, "to": body.to_did, "score": body.score, "erc8004_tx": erc8004_tx} - -@app.get("/skills") -@limiter.limit("30/minute") -async def list_skills(request: Request, limit: int = Query(default=20, ge=1, le=100)): - skills = [] - if db_pool: - async with db_pool.acquire() as conn: - rows = await conn.fetch("SELECT id, name, author_did, security_score FROM skills ORDER BY security_score DESC LIMIT $1", limit) - skills = [dict(row) for row in rows] - return {"skills": skills, "total": len(skills)} - -@app.post("/payment/lightning/invoice") -@limiter.limit("5/minute") -async def create_lightning_invoice(request: Request, body: LightningInvoiceRequest, api_key: str = Depends(verify_api_key)): - return {"status": "pending", "amount_sats": body.amount_sats, "description": body.description, "note": "phoenixd integration ready"} - -@app.get("/health") -@limiter.limit("60/minute") -async def health_check(request: Request): - db_ok = False - if db_pool: - try: - async with db_pool.acquire() as conn: - await conn.fetchval("SELECT 1") - db_ok = True - except: - pass - return { - "status": "ok", - "version": "2.2", - "database": "connected" if db_ok else "unavailable", - "timestamp": str(datetime.datetime.utcnow()) - } -# --- W3C DID:web Support --- - -DID_WEB_DOCUMENT = { - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/ed25519-2020/v1" - ], - "id": "did:web:api.moltrust.ch", - "controller": "did:web:api.moltrust.ch", - "verificationMethod": [{ - "id": "did:web:api.moltrust.ch#key-1", - "type": "Ed25519VerificationKey2020", - "controller": "did:web:api.moltrust.ch", - "publicKeyMultibase": "z6MktwcfvxeKmXstWpyEr9wJkJE2xzzkpBkdCSghdvCzrqDC" - }], - "authentication": ["did:web:api.moltrust.ch#key-1"], - "assertionMethod": ["did:web:api.moltrust.ch#key-1"], - "service": [ - { - "id": "did:web:api.moltrust.ch#trust-api", - "type": "TrustLayer", - "serviceEndpoint": "https://api.moltrust.ch" - }, - { - "id": "did:web:api.moltrust.ch#identity", - "type": "AgentIdentity", - "serviceEndpoint": "https://api.moltrust.ch/identity" - }, - { - "id": "did:web:api.moltrust.ch#reputation", - "type": "ReputationService", - "serviceEndpoint": "https://api.moltrust.ch/reputation" - } - ] -} - -@app.get("/.well-known/did.json") -@limiter.limit("60/minute") -async def did_web_document(request: Request): - return DID_WEB_DOCUMENT - -@app.get("/identity/resolve/{did:path}") -@limiter.limit("30/minute") -async def resolve_did(request: Request, did: str): - if len(did) > 256: - raise HTTPException(400, "DID too long") - if did == "did:web:api.moltrust.ch": - return DID_WEB_DOCUMENT - if DID_PATTERN.match(did): - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT did, display_name, platform, created_at FROM agents WHERE did = $1", did - ) - if row: - await update_last_seen(did) - if row: - return { - "@context": "https://www.w3.org/ns/did/v1", - "id": row["did"], - "controller": "did:web:api.moltrust.ch", - "metadata": { - "display_name": row["display_name"], - "platform": row["platform"], - "created": str(row["created_at"]), - "trust_provider": "MolTrust" - } - } - raise HTTPException(404, "DID not found") - if did.startswith("did:web:"): - raise HTTPException(501, "External did:web resolution not yet supported") - raise HTTPException(400, "Unsupported DID method") -# --- Verifiable Credentials --- -from app.credentials import issue_credential, verify_credential - -class IssueVCRequest(BaseModel): - subject_did: str = Field(max_length=128) - credential_type: str = Field(default="AgentTrustCredential", max_length=64) - - @field_validator("subject_did") - @classmethod - def validate_subject(cls, v): - if not (DID_PATTERN.match(v) or v.startswith("did:web:") or v.startswith("did:key:")): - raise ValueError("Invalid DID format") - return v - - @field_validator("credential_type") - @classmethod - def validate_credential_type(cls, v): - if not re.match(r"^[a-zA-Z][a-zA-Z0-9]{1,63}$", v): - raise ValueError("Credential type must be alphanumeric, starting with a letter") - return v - -class VerifyVCRequest(BaseModel): - credential: dict - - @field_validator("credential") - @classmethod - def validate_credential_size(cls, v): - if len(json.dumps(v)) > 16384: - raise ValueError("Credential payload too large (max 16KB)") - return v - -@app.post("/credentials/issue") -@limiter.limit("10/minute") -async def issue_vc(request: Request, body: IssueVCRequest, api_key: str = Depends(verify_api_key)): - reputation = {"score": 0.0, "total_ratings": 0} - if db_pool and DID_PATTERN.match(body.subject_did): - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT COALESCE(AVG(score),0) as avg, COUNT(*) as total FROM ratings WHERE to_did=$1", - body.subject_did - ) - if row: - reputation = {"score": round(float(row["avg"]), 2), "total_ratings": int(row["total"])} - - claims = { - "trustProvider": "MolTrust", - "reputation": reputation, - "verified": True - } - vc = issue_credential(body.subject_did, body.credential_type, claims) - if db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - """INSERT INTO credentials (subject_did, credential_type, issuer, issued_at, expires_at, proof_value, raw_vc) - VALUES ($1, $2, $3, $4, $5, $6, $7)""", - body.subject_did, body.credential_type, vc["issuer"], - datetime.datetime.fromisoformat(vc["issuanceDate"].replace("Z","")), - datetime.datetime.fromisoformat(vc["expirationDate"].replace("Z","")), - vc["proof"]["proofValue"], - json.dumps(vc) - ) - await update_last_seen(body.subject_did) - return vc - -@app.post("/credentials/verify") -@limiter.limit("30/minute") -async def verify_vc(request: Request, body: VerifyVCRequest): - result = verify_credential(body.credential) - return result -# --- Multi-Platform OAuth --- - -GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID", "PENDING") -GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET", "PENDING") - -@app.get("/auth/github") -@limiter.limit("10/minute") -async def github_auth_start(request: Request): - """Redirect to GitHub OAuth""" - if GITHUB_CLIENT_ID == "PENDING": - raise HTTPException(503, "GitHub OAuth not yet configured") - return JSONResponse({"redirect_url": f"https://github.com/login/oauth/authorize?client_id={GITHUB_CLIENT_ID}&scope=read:user"}) - -@app.get("/auth/github/callback") -@limiter.limit("10/minute") -async def github_auth_callback(request: Request, code: str = Query(max_length=128)): - if GITHUB_CLIENT_ID == "PENDING": - raise HTTPException(503, "GitHub OAuth not yet configured") - async with httpx.AsyncClient(timeout=10.0) as client: - token_resp = await client.post( - "https://github.com/login/oauth/access_token", - json={"client_id": GITHUB_CLIENT_ID, "client_secret": GITHUB_CLIENT_SECRET, "code": code}, - headers={"Accept": "application/json"} - ) - if token_resp.status_code != 200: - raise HTTPException(502, "GitHub token exchange failed") - token_data = token_resp.json() - access_token = token_data.get("access_token") - if not access_token: - raise HTTPException(401, "GitHub auth failed") - - user_resp = await client.get( - "https://api.github.com/user", - headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"} - ) - if user_resp.status_code != 200: - raise HTTPException(502, "GitHub user fetch failed") - gh_user = user_resp.json() - - agent_did = f"did:moltrust:{uuid.uuid4().hex[:16]}" - display_name = str(gh_user.get("login", ""))[:64] - - if db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - "INSERT INTO agents (did, display_name, platform, agent_type, created_at) VALUES ($1, $2, $3, 'external', $4) ON CONFLICT DO NOTHING", - agent_did, display_name, "github", datetime.datetime.utcnow() - ) - - return { - "status": "authenticated", - "platform": "github", - "did": agent_did, - "display_name": display_name, - "github_id": gh_user.get("id"), - } - - - -# --- Self-Service API Key Signup --- - -class SignupRequest(BaseModel): - email: str = Field(max_length=256) - - @field_validator("email") - @classmethod - def validate_email(cls, v): - if "@" not in v or "." not in v.split("@")[-1]: - raise ValueError("Invalid email") - return v.lower().strip() - -@app.post("/auth/signup") -@limiter.limit("5/minute") -async def signup_for_api_key(request: Request, body: SignupRequest): - key = f"mt_{secrets.token_hex(16)}" - if db_pool: - async with db_pool.acquire() as conn: - existing = await conn.fetchval("SELECT key FROM api_keys WHERE email = $1", body.email) - if existing: - return {"status": "exists", "message": "API key already issued for this email. Contact support if lost."} - await conn.execute( - "INSERT INTO api_keys (key, email) VALUES ($1, $2)", - key, body.email - ) - API_KEYS.add(key) - return {"status": "created", "api_key": key, "email": body.email, "rate_limit": "100 requests/day", "note": "Save this key - it cannot be recovered."} - -# Load existing keys from DB on startup -@app.on_event("startup") -async def load_api_keys(): - if db_pool: - try: - async with db_pool.acquire() as conn: - rows = await conn.fetch("SELECT key FROM api_keys WHERE active = TRUE") - for row in rows: - API_KEYS.add(row["key"]) - print(f"Loaded {len(rows)} API keys from DB") - except Exception as e: - print(f"Could not load API keys: {e}") - - - -# --- Base Blockchain Anchor --- -from web3 import Web3 -import hashlib as _hashlib -from eth_account import Account - -BASE_RPC = "https://mainnet.base.org" -BASE_KEY = os.getenv("BASE_WALLET_KEY", "") -BASE_ADDR = Account.from_key(BASE_KEY).address if BASE_KEY else None - -async def anchor_to_base(agent_did: str, timestamp: str) -> str: - try: - w3 = Web3(Web3.HTTPProvider(BASE_RPC)) - if not w3.is_connected(): - return None - data = _hashlib.sha256(f"{agent_did}:{timestamp}".encode()).hexdigest() - nonce = w3.eth.get_transaction_count(BASE_ADDR) - tx = { - "from": BASE_ADDR, - "to": BASE_ADDR, - "value": 0, - "data": w3.to_bytes(hexstr="0x" + data), - "nonce": nonce, - "chainId": 8453, - "gas": 25000, - "maxFeePerGas": w3.eth.gas_price + w3.to_wei(0.001, "gwei"), - "maxPriorityFeePerGas": w3.to_wei(0.001, "gwei"), - } - signed = w3.eth.account.sign_transaction(tx, BASE_KEY) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) - return w3.to_hex(tx_hash) - except Exception as e: - print(f"Base anchor error: {e}") - return None - - - -# --- Credit Endpoints --- - -@app.get("/credits/pricing") -@limiter.limit("60/minute") -async def credits_pricing(request: Request): - return {"pricing": ENDPOINT_COSTS, "currency": "CREDITS", "free_on_registration": 100} - -@app.get("/credits/balance/{did}") -@limiter.limit("60/minute") -async def credits_balance(request: Request, did: str = Path(max_length=40)): - did = validate_did(did) - balance = 0 - if db_pool: - async with db_pool.acquire() as conn: - balance = await _get_balance(conn, did) - return {"did": did, "balance": balance, "currency": "CREDITS"} - -@app.post("/credits/transfer") -@limiter.limit("10/minute") -async def credits_transfer(request: Request, body: CreditTransferRequest, api_key: str = Depends(verify_api_key)): - if body.from_did == body.to_did: - raise HTTPException(400, "Cannot transfer to yourself") - if not db_pool: - raise HTTPException(503, "Database unavailable") - - # Verify the caller owns from_did - async with db_pool.acquire() as conn: - owner_did = await resolve_did_from_api_key(conn, api_key) - if owner_did != body.from_did: - raise HTTPException(403, "API key does not own the source DID") - - try: - async with db_pool.acquire() as conn: - async with conn.transaction(): - await transfer_credits(conn, body.from_did, body.to_did, body.amount, body.reference or "transfer") - except ValueError as e: - raise HTTPException(402, str(e)) - - # Fetch updated balances - async with db_pool.acquire() as conn: - sender_balance = await _get_balance(conn, body.from_did) - - return { - "status": "transferred", - "from_did": body.from_did, - "to_did": body.to_did, - "amount": body.amount, - "balance_after": sender_balance, - "currency": "CREDITS", - } - -@app.get("/credits/transactions/{did}") -@limiter.limit("30/minute") -async def credits_transactions(request: Request, did: str = Path(max_length=40), api_key: str = Depends(verify_api_key), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0)): - did = validate_did(did) - if not db_pool: - raise HTTPException(503, "Database unavailable") - - # Verify the caller owns this DID - async with db_pool.acquire() as conn: - owner_did = await resolve_did_from_api_key(conn, api_key) - if owner_did != did: - raise HTTPException(403, "API key does not own this DID") - - async with db_pool.acquire() as conn: - txs = await get_transactions(conn, did, limit, offset) - return {"did": did, "transactions": txs, "limit": limit, "offset": offset} - - - -# --- USDC Deposit Endpoint --- -from app.usdc import verify_usdc_transfer, record_deposit, get_deposits, CREDITS_PER_USDC, MOLTRUST_WALLET - -class DepositRequest(BaseModel): - tx_hash: str = Field(min_length=64, max_length=70) - did: str = Field(max_length=40) - -@app.post("/credits/deposit") -@limiter.limit("5/minute") -async def credits_deposit(request: Request, body: DepositRequest, api_key: str = Depends(verify_api_key)): - """Claim credits by submitting a USDC transaction hash from Base.""" - did = validate_did(body.did) - if not db_pool: - raise HTTPException(503, "Database unavailable") - - # Verify caller owns this DID - async with db_pool.acquire() as conn: - owner_did = await resolve_did_from_api_key(conn, api_key) - if owner_did != did: - raise HTTPException(403, "API key does not own this DID") - - # Verify on-chain - result = verify_usdc_transfer(body.tx_hash) - if not result["valid"]: - raise HTTPException(400, result["error"]) - - # Record deposit + grant credits atomically - async with db_pool.acquire() as conn: - async with conn.transaction(): - recorded = await record_deposit( - conn, body.tx_hash, result["from_address"], did, - result["usdc_amount"], result["credits"], result["block_number"], - ) - if not recorded: - raise HTTPException(409, "This transaction has already been claimed") - - await ensure_balance_row(conn, did) - await grant_credits( - conn, did, result["credits"], - reference=f"usdc_deposit:{body.tx_hash[:16]}", - description=f"USDC deposit: {result['usdc_amount']} USDC = {result['credits']} credits", - ) - new_balance = await _get_balance(conn, did) - - return { - "status": "deposited", - "tx_hash": body.tx_hash, - "basescan_url": f"https://basescan.org/tx/{body.tx_hash}", - "from_address": result["from_address"], - "usdc_amount": result["usdc_amount"], - "credits_granted": result["credits"], - "new_balance": new_balance, - "currency": "CREDITS", - "rate": f"1 USDC = {CREDITS_PER_USDC} credits", - } - -@app.get("/credits/deposits/{did}") -@limiter.limit("30/minute") -async def credits_deposit_history(request: Request, did: str = Path(max_length=40), api_key: str = Depends(verify_api_key)): - """Get USDC deposit history for an agent.""" - did = validate_did(did) - if not db_pool: - raise HTTPException(503, "Database unavailable") - async with db_pool.acquire() as conn: - owner_did = await resolve_did_from_api_key(conn, api_key) - if owner_did != did: - raise HTTPException(403, "API key does not own this DID") - async with db_pool.acquire() as conn: - deposits = await get_deposits(conn, did) - return {"did": did, "deposits": deposits, "wallet": MOLTRUST_WALLET, "network": "Base (Chain ID 8453)"} - -@app.get("/credits/deposit-info") -async def credits_deposit_info(request: Request): - """Public endpoint: how to deposit USDC for credits.""" - return { - "wallet": MOLTRUST_WALLET, - "network": "Base (Ethereum L2, Chain ID 8453)", - "token": "USDC", - "token_contract": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "rate": f"1 USDC = {CREDITS_PER_USDC} credits", - "min_confirmations": 5, - "instructions": [ - "1. Send USDC on Base to the wallet address above", - "2. Wait for 5 confirmations (~10 seconds on Base)", - "3. Call POST /credits/deposit with your tx_hash and DID", - "4. Credits are granted instantly after verification", - ], - } - -# --- A2A Agent Card Trust Extension --- - -@app.get("/.well-known/agent.json") -@limiter.limit("60/minute") -async def a2a_agent_card(request: Request): - return { - "name": "MolTrust", - "description": "Trust Layer for the Agent Economy. Identity verification, reputation scoring, and W3C Verifiable Credentials for AI agents.", - "url": "https://api.moltrust.ch", - "version": "1.0.0", - "capabilities": { - "streaming": False, - "pushNotifications": False - }, - "skills": [ - { - "id": "identity-verification", - "name": "Agent Identity Verification", - "description": "Register and verify W3C DID identities for AI agents with Ed25519 cryptographic signatures", - "tags": ["identity", "did", "verification", "w3c"] - }, - { - "id": "reputation-scoring", - "name": "Reputation Scoring", - "description": "Query and submit trust ratings for AI agents. 1-5 scale with comment support.", - "tags": ["reputation", "trust", "rating"] - }, - { - "id": "verifiable-credentials", - "name": "Verifiable Credentials", - "description": "Issue and verify W3C Verifiable Credentials signed with Ed25519", - "tags": ["credentials", "w3c", "ed25519"] - }, - { - "id": "blockchain-anchor", - "name": "Base Blockchain Anchoring", - "description": "Anchor agent identity hashes on Base (Ethereum L2) for immutable proof", - "tags": ["blockchain", "base", "ethereum", "anchor"] - } - ], - "authentication": { - "schemes": ["apiKey"], - "apiKey": {"headerName": "X-API-Key", "signupUrl": "https://moltrust.ch#signup"} - }, - "provider": { - "organization": "CryptoKRI GmbH", - "url": "https://moltrust.ch" - }, - "links": { - "docs": "https://api.moltrust.ch/docs", - "github": "https://github.com/MoltyCel/moltrust-sdk", - "pypi": "https://pypi.org/project/moltrust/", - "did": "https://api.moltrust.ch/.well-known/did.json" - } - } - -@app.get("/a2a/agent-card/{did}") -@limiter.limit("60/minute") -async def a2a_trust_card(request: Request, did: str = Path(max_length=128)): - if not DID_PATTERN.match(did): - raise HTTPException(status_code=400, detail="Invalid DID format") - if not db_pool: - raise HTTPException(status_code=503, detail="Database unavailable") - async with db_pool.acquire() as conn: - agent = await conn.fetchrow("SELECT display_name, platform, created_at, base_tx_hash FROM agents WHERE did = $1", did) - if not agent: - raise HTTPException(status_code=404, detail="Agent not found") - score = await conn.fetchrow("SELECT COALESCE(AVG(score),0) as avg, COUNT(*) as total FROM ratings WHERE to_did=$1", did) - cred_count = await conn.fetchval("SELECT COUNT(*) FROM credentials WHERE subject_did=$1", did) - cred = {"total": cred_count} - return { - "name": agent["display_name"], - "did": did, - "platform": agent["platform"], - "url": f"https://api.moltrust.ch/identity/verify/{did}", - "trust": { - "score": round(float(score["avg"]), 2), - "totalRatings": int(score["total"]), - "credentials": int(cred["total"]), - "verified": True, - "registeredAt": agent["created_at"].isoformat() if agent["created_at"] else None, - "baseAnchor": agent["base_tx_hash"], - "baseScanUrl": f"https://basescan.org/tx/{agent['base_tx_hash']}" if agent["base_tx_hash"] else None - }, - "capabilities": { - "verifiableIdentity": True, - "reputationScoring": True, - "blockchainAnchored": bool(agent["base_tx_hash"]) - }, - "verifyUrl": f"https://api.moltrust.ch/identity/verify/{did}", - "rateUrl": f"https://api.moltrust.ch/reputation/rate", - "provider": "MolTrust (https://moltrust.ch)" - } - -# --- Recent Agents --- -@app.get("/agents/recent") -@limiter.limit("60/minute") -async def recent_agents(request: Request): - agents = [] - if db_pool: - async with db_pool.acquire() as conn: - rows = await conn.fetch( - "SELECT display_name, did, platform, created_at FROM agents WHERE agent_type = 'external' ORDER BY created_at DESC LIMIT 10" - ) - agents = [] - for row in rows: - name = row["display_name"] - did_short = row["did"][:16] + "..." if len(row["did"]) > 16 else row["did"] - if not name or name.strip().lower() == "anonymous": - name = f"{row['platform']} \u00b7 {did_short}" - agents.append({ - "display_name": name, - "did": did_short, - "platform": row["platform"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - }) - return JSONResponse(content=agents, headers={"Cache-Control": "public, max-age=30"}) - -# --- Public Stats --- -@app.get("/stats") -@limiter.limit("60/minute") -async def public_stats(request: Request): - stats = {"agents": 0, "ratings": 0, "credentials": 0} - if db_pool: - async with db_pool.acquire() as conn: - stats["agents"] = await conn.fetchval("SELECT COUNT(*) FROM agents WHERE agent_type = 'external'") or 0 - stats["ratings"] = await conn.fetchval("SELECT COUNT(*) FROM ratings") or 0 - try: - stats["credentials"] = await conn.fetchval("SELECT COUNT(*) FROM credentials") or 0 - except: - stats["credentials"] = stats["agents"] - return stats - -from fastapi.middleware.cors import CORSMiddleware -app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) - -# --- Viral Join Endpoint --- -from fastapi.responses import HTMLResponse, RedirectResponse, RedirectResponse - -@app.get("/join") -@limiter.limit("30/minute") -async def join_redirect(request: Request, ref: str = Query(default=None, max_length=100)): - if ref: - return RedirectResponse(f"https://moltrust.ch?ref={ref}", status_code=302) - return RedirectResponse("https://moltrust.ch", status_code=302) - -# --- ERC-8004 Bridge (Phase 1: Read-Only) --- -from app.erc8004 import build_registration_file, resolve_onchain_agent, get_onchain_reputation, get_well_known_registration - -@app.get("/agents/{did}/erc8004") -@limiter.limit("30/minute") -async def erc8004_registration_file(request: Request, did: str = Path(max_length=128)): - """Serve ERC-8004 compatible registration file (Agent Card) for a MolTrust agent.""" - # Special case: MolTrust platform identity - if did in ("did:web:api.moltrust.ch", "did%3Aweb%3Aapi.moltrust.ch"): - from app.erc8004 import MOLTRUST_PLATFORM_AGENT_ID - return build_registration_file( - {"did": "did:web:api.moltrust.ch", "display_name": "MolTrust", "base_tx_hash": None}, - {"score": 0.0, "total_ratings": 0}, - MOLTRUST_PLATFORM_AGENT_ID - ) - did = validate_did(did) - if not db_pool: - raise HTTPException(503, "Database unavailable") - async with db_pool.acquire() as conn: - agent = await conn.fetchrow( - "SELECT did, display_name, platform, base_tx_hash, erc8004_agent_id FROM agents WHERE did = $1", did - ) - if not agent: - raise HTTPException(404, "Agent not found") - rep = await conn.fetchrow( - "SELECT COALESCE(AVG(score), 0) as avg_score, COUNT(*) as total FROM ratings WHERE to_did = $1", did - ) - await update_last_seen(did) - reputation = {"score": round(float(rep["avg_score"]), 2), "total_ratings": int(rep["total"])} - return build_registration_file(dict(agent), reputation, agent["erc8004_agent_id"]) - -@app.get("/resolve/erc8004/{agent_id}") -@limiter.limit("10/minute") -async def erc8004_resolve(request: Request, agent_id: int = Path(ge=0)): - """Resolve an ERC-8004 agent ID on Base to its on-chain data + optional MolTrust cross-reference.""" - result = resolve_onchain_agent(agent_id) - if "error" in result: - raise HTTPException(404, result["error"]) - - # Cross-reference: check if this agentId is linked to a MolTrust DID - result["moltrust_did"] = None - result["moltrust_profile"] = None - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT did FROM agents WHERE erc8004_agent_id = $1", agent_id - ) - if row: - result["moltrust_did"] = row["did"] - result["moltrust_profile"] = f"https://api.moltrust.ch/identity/resolve/{row["did"]}" - - # Fetch on-chain reputation - result["onchain_reputation"] = get_onchain_reputation(agent_id) - return result - -@app.get("/.well-known/agent-registration.json") -async def well_known_agent_registration(request: Request): - """ERC-8004 domain verification endpoint.""" - return get_well_known_registration() - - -# ═══════════════════════════════════════════════════════════════ -# SPORTS MODULE — Prediction Commitment & Verification -# ═══════════════════════════════════════════════════════════════ - -class PredictionCommitRequest(BaseModel): - agent_did: str = Field(max_length=40) - event_id: str = Field(max_length=256) - prediction: dict - event_start: str = Field(max_length=30) - - @field_validator("agent_did") - @classmethod - def check_did_format(cls, v): - if not re.match(r"^did:moltrust:[a-f0-9]{16}$", v): - raise ValueError("Invalid DID format") - return v - - @field_validator("event_start") - @classmethod - def check_event_start_future(cls, v): - try: - dt = datetime.datetime.fromisoformat(v.replace("Z", "+00:00")) - if dt <= datetime.datetime.now(datetime.timezone.utc): - raise ValueError("event_start must be in the future") - except (ValueError, TypeError) as e: - if "future" in str(e): - raise - raise ValueError("Invalid ISO 8601 datetime") - return v - - -@app.get("/sports/health") -@limiter.limit("60/minute") -async def sports_health(request: Request): - """Sports module health check.""" - db_ok = False - if db_pool: - try: - async with db_pool.acquire() as conn: - await conn.fetchval("SELECT 1") - db_ok = True - except Exception: - pass - return { - "module": "moltrust-sports", - "version": "1.0.0", - "status": "ok" if db_ok else "degraded", - "database": "connected" if db_ok else "unavailable", - "chain": "base-mainnet", - } - - -@app.post("/sports/predictions/commit") -@limiter.limit("30/minute") -async def sports_predict_commit(request: Request, body: PredictionCommitRequest, - x_api_key: str = Depends(verify_api_key)): - """Commit a prediction before an event starts. Returns commitment hash + on-chain anchor.""" - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - # Verify agent exists - if not await _sp_agent_exists(conn, body.agent_did): - raise HTTPException(404, f"Agent {body.agent_did} not registered") - - # Normalize event ID - event_id = normalize_event_id(body.event_id) - if not event_id or len(event_id) < 5: - raise HTTPException(400, "event_id too short after normalization") - - # Compute commitment hash - commitment_hash = compute_commitment_hash( - body.agent_did, event_id, body.prediction, body.event_start, - ) - - # Check uniqueness (agent + event) - existing = await conn.fetchval( - "SELECT commitment_hash FROM sports_predictions WHERE agent_did = $1 AND event_id = $2", - body.agent_did, event_id, - ) - if existing: - raise HTTPException(409, f"Prediction already committed for this event (hash: {existing})") - - # Anchor on-chain (reuse existing anchor function) - tx_hash = await anchor_to_base(commitment_hash, body.event_start) - - # Insert - try: - row = await insert_prediction( - conn, body.agent_did, event_id, body.prediction, - body.event_start, commitment_hash, tx_hash, - ) - except Exception as e: - if "unique" in str(e).lower() or "duplicate" in str(e).lower(): - raise HTTPException(409, "Duplicate prediction or commitment hash") - raise - - return { - "status": "committed", - "commitment_hash": commitment_hash, - "event_id": event_id, - "agent_did": body.agent_did, - "base_tx_hash": tx_hash, - "anchored": tx_hash is not None, - "created_at": row["created_at"].isoformat() if row else None, - "verify_url": f"https://api.moltrust.ch/sports/predictions/verify/{commitment_hash}", - } - - -@app.get("/sports/predictions/verify/{commitment_hash}") -@limiter.limit("60/minute") -async def sports_predict_verify(request: Request, commitment_hash: str = Path(max_length=64)): - """Verify a prediction commitment exists and return details.""" - if not re.match(r"^[a-f0-9]{64}$", commitment_hash): - raise HTTPException(400, "Invalid hash format (expected 64 hex chars)") - - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - row = await get_prediction_by_hash(conn, commitment_hash) - - if not row: - raise HTTPException(404, "Commitment not found") - - prediction = row["prediction"] - if isinstance(prediction, str): - prediction = json.loads(prediction) - - return { - "status": "verified", - "commitment_hash": row["commitment_hash"], - "agent_did": row["agent_did"], - "event_id": row["event_id"], - "prediction": prediction, - "event_start": row["event_start"].isoformat(), - "base_tx_hash": row["base_tx_hash"], - "anchored": row["base_tx_hash"] is not None, - "committed_at": row["created_at"].isoformat(), - "basescan_url": f"https://basescan.org/tx/{row['base_tx_hash']}" if row["base_tx_hash"] else None, - } - - - -# --- Sports Phase 2: History + Admin Settlement --- - -class ManualSettleRequest(BaseModel): - result: str = Field(max_length=64) - score: str | None = Field(default=None, max_length=32) - detail: dict | None = Field(default=None) - - -@app.get("/sports/predictions/history/{did}") -@limiter.limit("30/minute") -async def sports_predict_history(request: Request, did: str = Path(max_length=40), - x_api_key: str = Depends(verify_api_key)): - """Get prediction history and stats for an agent.""" - did = validate_did(did) - - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - if not await _sp_agent_exists(conn, did): - raise HTTPException(404, f"Agent {did} not registered") - - predictions = await get_prediction_history(conn, did) - stats = await get_prediction_stats(conn, did) - calibration = await compute_calibration_score(conn, did) - - # Get MolTrust reputation score - rep = await conn.fetchrow( - "SELECT COALESCE(AVG(score), 0) as avg_score FROM ratings WHERE to_did = $1", did - ) - moltrust_score = round(float(rep["avg_score"]) * 20, 1) if rep and rep["avg_score"] else 0 - - stats["calibration_score"] = calibration - - # Format predictions for response - formatted = [] - for p in predictions: - pred = p["prediction"] - if isinstance(pred, str): - pred = json.loads(pred) - outcome = p["outcome"] - if isinstance(outcome, str): - outcome = json.loads(outcome) - - formatted.append({ - "commitment_hash": p["commitment_hash"], - "event_id": p["event_id"], - "prediction": pred.get("outcome", pred.get("result", str(pred))), - "confidence": pred.get("confidence"), - "correct": p["correct"], - "outcome": outcome.get("result") if isinstance(outcome, dict) else outcome, - "committed_at": p["created_at"].isoformat(), - "settled_at": p["settled_at"].isoformat() if p["settled_at"] else None, - }) - - return { - "agent_did": did, - "moltrust_score": moltrust_score, - "betting_stats": stats, - "predictions": formatted, - } - - -@app.patch("/sports/predictions/settle/{commitment_hash}") -@limiter.limit("30/minute") -async def sports_predict_settle_admin(request: Request, - commitment_hash: str = Path(max_length=64), - body: ManualSettleRequest = None, - x_api_key: str = Depends(verify_api_key)): - """Admin endpoint: manually settle a prediction (for polymarket or manual events).""" - if not re.match(r"^[a-f0-9]{64}$", commitment_hash): - raise HTTPException(400, "Invalid hash format") - - if not db_pool: - raise HTTPException(503, "Database unavailable") - - result_data = { - "result": body.result, - "score": body.score, - "source": "manual", - } - if body.detail: - result_data["detail"] = body.detail - - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT settled_at FROM sports_predictions WHERE commitment_hash = $1", - commitment_hash, - ) - if not row: - raise HTTPException(404, "Commitment not found") - if row["settled_at"] is not None: - raise HTTPException(409, "Already settled") - - ok = await _settle_prediction_fn(conn, commitment_hash, result_data) - - if not ok: - raise HTTPException(500, "Settlement failed") - - return { - "status": "settled", - "commitment_hash": commitment_hash, - "result": body.result, - "score": body.score, - } diff --git a/app/main.py.bak2 b/app/main.py.bak2 deleted file mode 100644 index c8a5546..0000000 --- a/app/main.py.bak2 +++ /dev/null @@ -1,1768 +0,0 @@ -from fastapi import FastAPI, HTTPException, Header, Request, Depends, Query, Path -from fastapi.responses import JSONResponse -from slowapi import Limiter -from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded -from pydantic import BaseModel, Field, field_validator -import uuid, datetime, datetime as _dt, httpx, os, re, asyncpg, json, asyncio, logging, time, hashlib, secrets - - -# --- Sports Module --- -from app.sports import ( - normalize_event_id, compute_commitment_hash, ensure_table as _sp_ensure_table, - insert_prediction, get_prediction_by_hash, agent_exists as _sp_agent_exists, - get_prediction_history, get_prediction_stats, compute_calibration_score, -) -from app.settlement import run_settlement_cycle, settle_prediction as _settle_prediction_fn -from app.signals import ( - ensure_signal_table, generate_provider_id, compute_credential_hash, - insert_provider, get_provider_by_id, get_provider_by_did, - get_track_record, get_recent_signals, get_leaderboard, generate_badge_svg, -) - -app = FastAPI(title="MolTrust API", version="2.5") - -limiter = Limiter(key_func=get_remote_address) -app.state.limiter = limiter - -logger = logging.getLogger("moltrust") - -# --- Config --- -MOLTBOOK_APP_KEY = os.getenv("MOLTBOOK_APP_KEY", "moltdev_PENDING") -API_KEYS = set(os.getenv("MOLTRUST_API_KEYS", "mt_test_key_2026").split(",")) -DB_URL = os.getenv("DATABASE_URL", "postgresql://moltstack:$(cat /dev/null)@localhost/moltstack") - -# --- Credits Config --- -CREDITS_ENABLED = os.getenv("CREDITS_ENABLED", "false").lower() == "true" - -# --- SMTP Config --- -SMTP_HOST = os.getenv("SMTP_HOST", "mail.infomaniak.com") -SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) -SMTP_USER = os.getenv("SMTP_USER", "info@moltrust.ch") -SMTP_PASS = os.getenv("SMTP_PASS", "") - -# --- Database Pool --- -db_pool = None - -@app.on_event("startup") -async def startup(): - global db_pool - try: - db_pool = await asyncpg.create_pool( - host="localhost", database="moltstack", - user="moltstack", password=os.getenv("MOLTSTACK_DB_PW", ""), - min_size=2, max_size=10 - ) - except Exception as e: - print(f"DB pool warning: {e} - running without DB") - # Create sports table - if db_pool: - try: - async with db_pool.acquire() as conn: - await _sp_ensure_table(conn) - print("Sports table ready") - except Exception as e: - print(f"Sports table warning: {e}") - try: - async with db_pool.acquire() as conn: - await ensure_signal_table(conn) - print("Signal providers table ready") - except Exception as e: - print(f"Signal providers table warning: {e}") - - # Start settlement scheduler - from apscheduler.schedulers.asyncio import AsyncIOScheduler - global _settlement_scheduler - _settlement_scheduler = AsyncIOScheduler() - async def _scheduled_settlement(): - try: - result = await run_settlement_cycle(db_pool) - logger.info(f"Settlement cycle: {result['checked']} checked, {result['settled']} settled") - except Exception as e: - logger.error(f"Settlement cycle error: {e}") - _settlement_scheduler.add_job(_scheduled_settlement, 'interval', minutes=30, id='settlement') - _settlement_scheduler.start() - print("Settlement scheduler started (every 30min)") - -@app.on_event("shutdown") -async def shutdown(): - global _settlement_scheduler - if hasattr(_settlement_scheduler, 'shutdown'): - try: - _settlement_scheduler.shutdown(wait=False) - print("Settlement scheduler stopped") - except Exception: - pass - if db_pool: - await db_pool.close() - -_settlement_scheduler = None - -# --- Rate Limit Handler --- -@app.exception_handler(RateLimitExceeded) -async def rate_limit_handler(request: Request, exc: RateLimitExceeded): - return JSONResponse(status_code=429, content={"error": "Rate limit exceeded. Try again later."}) - -# --- Global Exception Handler --- -@app.exception_handler(Exception) -async def global_exception_handler(request: Request, exc: Exception): - logger.error("Unhandled error on %s %s: %s", request.method, request.url.path, exc) - return JSONResponse(status_code=500, content={"error": "Internal server error"}) - -# --- Outbound Content Filter --- -SENSITIVE_PATTERNS = [ - re.compile(r"sk-ant-api[a-zA-Z0-9\-_]{20,}"), - re.compile(r"sk-[a-zA-Z0-9]{20,}"), - re.compile(r"xprv[a-zA-Z0-9]{50,}"), - re.compile(r"password\s*[:=]\s*\S+", re.IGNORECASE), - re.compile(r"BEGIN (RSA |EC )?PRIVATE KEY"), - re.compile(r"AKIA[0-9A-Z]{16}"), -] - -def scrub_secrets(obj): - if isinstance(obj, str): - for pat in SENSITIVE_PATTERNS: - obj = pat.sub("[REDACTED]", obj) - return obj - elif isinstance(obj, dict): - return {k: scrub_secrets(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [scrub_secrets(i) for i in obj] - return obj - -async def update_last_seen(did: str): - if db_pool: - try: - async with db_pool.acquire() as conn: - await conn.execute("UPDATE agents SET last_seen = now() WHERE did = $1", did) - except: - pass - -@app.middleware("http") -async def content_filter_middleware(request: Request, call_next): - response = await call_next(request) - if response.headers.get("content-type", "").startswith("application/json"): - body = b"" - async for chunk in response.body_iterator: - body += chunk if isinstance(chunk, bytes) else chunk.encode() - try: - import json as _json - data = _json.loads(body) - filtered = scrub_secrets(data) - extra = {k: v for k, v in response.headers.items() if k.lower() not in ("content-length", "content-type")} - return JSONResponse(content=filtered, status_code=response.status_code, headers=extra) - except Exception: - from starlette.responses import Response - return Response(content=body, status_code=response.status_code, headers=dict(response.headers)) - return response - -# --- Credit Middleware --- -from app.credits import ( - get_endpoint_cost, resolve_did_from_api_key, link_api_key_to_did, - get_balance as _get_balance, ensure_balance_row, grant_credits, - deduct_credits, transfer_credits, get_transactions, - ENDPOINT_COSTS, -) - -@app.middleware("http") -async def credit_middleware(request: Request, call_next): - if not CREDITS_ENABLED or not db_pool: - return await call_next(request) - - method = request.method - path = request.url.path - cost = get_endpoint_cost(method, path) - - if cost == 0: - return await call_next(request) - - # Resolve API key → DID - api_key = request.headers.get("x-api-key", "") - caller_did = None - if api_key: - async with db_pool.acquire() as conn: - caller_did = await resolve_did_from_api_key(conn, api_key) - - # No API key provided — let the request through without charging - # (the endpoint's own auth will reject if it requires a key) - if not api_key: - return await call_next(request) - - # First registration: no DID linked yet — let it through - if not caller_did and path == "/identity/register" and method == "POST": - return await call_next(request) - - if not caller_did: - return JSONResponse( - status_code=402, - content={ - "error": "No agent linked to this API key. Register an agent first via POST /identity/register.", - "pricing_url": "https://api.moltrust.ch/credits/pricing", - }, - ) - - # Check balance - async with db_pool.acquire() as conn: - balance = await _get_balance(conn, caller_did) - - if balance < cost: - return JSONResponse( - status_code=402, - content={ - "error": "Insufficient credits", - "balance": balance, - "required": cost, - "pricing_url": "https://api.moltrust.ch/credits/pricing", - }, - ) - - # Execute the actual request - response = await call_next(request) - - # Deduct only on success - if response.status_code < 400: - try: - async with db_pool.acquire() as conn: - async with conn.transaction(): - from app.credits import resolve_endpoint_key - ref = resolve_endpoint_key(method, path) - await deduct_credits(conn, caller_did, cost, ref) - except ValueError: - pass # race condition — balance already checked above - except Exception as e: - logger.error("Credit deduction failed for %s: %s", caller_did, e) - - return response - -# --- Validation Helpers --- -DID_PATTERN = re.compile(r"^did:moltrust:[a-f0-9]{16}$") -DISPLAY_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_\-. ]{1,64}$") - -def validate_did(did: str) -> str: - if not DID_PATTERN.match(did): - raise HTTPException(400, "Invalid DID format. Expected: did:moltrust:<16 hex chars>") - return did - -def verify_api_key(x_api_key: str = Header(alias="X-API-Key")): - if len(x_api_key) > 128: - raise HTTPException(403, "Invalid API key") - if x_api_key not in API_KEYS: - raise HTTPException(403, "Invalid API key") - return x_api_key - -# --- Per-Key Registration Rate Limiter --- -_reg_tracker: dict[str, list[float]] = {} - -def check_registration_rate(api_key: str, max_per_hour: int = 5): - now = time.time() - key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16] - if key_hash not in _reg_tracker: - _reg_tracker[key_hash] = [] - _reg_tracker[key_hash] = [t for t in _reg_tracker[key_hash] if now - t < 3600] - if len(_reg_tracker[key_hash]) >= max_per_hour: - raise HTTPException(429, f"Registration limit exceeded: max {max_per_hour} per API key per hour") - _reg_tracker[key_hash].append(now) - -# --- Welcome Email --- -async def send_welcome_email(to_email: str, agent_did: str, display_name: str): - if not SMTP_PASS: - logger.warning("SMTP_PASS not set, skipping welcome email to %s", to_email) - return - try: - import aiosmtplib - from email.mime.multipart import MIMEMultipart - from email.mime.text import MIMEText - - verify_url = f"https://api.moltrust.ch/identity/verify/{agent_did}" - docs_url = "https://api.moltrust.ch/docs" - pypi_url = "https://pypi.org/project/moltrust/" - github_url = "https://github.com/MoltyCel/moltrust-sdk" - - html_body = f"""\ - - - - - - -
- - - - - - - - - - - -
- MolTrust -
- -

Welcome, {display_name}!

-

Your agent has been registered and verified on MolTrust. Here are your details:

- - - - -
-
Your Agent DID
-
{agent_did}
-
- - - - -
-
Verify Endpoint
- {verify_url} -
- - - - - - - - - - - - -
✓ VERIFIED✓ CREDENTIAL ISSUED✓ ON-CHAIN100 FREE CREDITS
- - -
- - -

What's next?

- - - - - - - - - - - - - - - - - - - - - - -
1. - You got 100 free credits
- Use them to call any paid API endpoint. Check your balance at GET /credits/balance/{agent_did} -
2. - Install the SDK
- pip install moltrust -
3. - Explore the API
- Interactive docs with all endpoints: {docs_url} -
4. - Issue credentials
- Your agent already has an AgentTrustCredential. Issue more via POST /credentials/issue -
5. - Build reputation
- Other agents can rate yours. Higher trust scores unlock more in the agent economy. -
- - - - -
- Explore the API → -
- -
-

- Website  ·  - GitHub  ·  - PyPI  ·  - API Docs  ·  - Terms  ·  - Privacy -

-

© 2026 MolTrust · CryptoKRI GmbH, Zurich

-
-
- -""" - - msg = MIMEMultipart("alternative") - msg["From"] = f"MolTrust <{SMTP_USER}>" - msg["To"] = to_email - msg["Subject"] = "Welcome to MolTrust \u2014 Your Agent is Verified \u2713" - - text_body = ( - f"Welcome to MolTrust, {display_name}!\n\n" - f"Your agent DID: {agent_did}\n" - f"Verify: {verify_url}\n\n" - f"You received 100 free API credits.\n\n" - f"What's next:\n" - f"1. Check your balance: GET /credits/balance/{agent_did}\n" - f"2. pip install moltrust\n" - f"3. API docs: {docs_url}\n" - f"4. Issue credentials via POST /credentials/issue\n" - f"5. Build reputation through agent-to-agent ratings\n\n" - f"Terms: https://moltrust.ch/terms.html\n" - f"Privacy: https://moltrust.ch/privacy.html\n\n" - f"-- MolTrust | https://moltrust.ch" - ) - msg.attach(MIMEText(text_body, "plain")) - msg.attach(MIMEText(html_body, "html")) - - await aiosmtplib.send( - msg, - hostname=SMTP_HOST, - port=SMTP_PORT, - username=SMTP_USER, - password=SMTP_PASS, - start_tls=True, - ) - logger.info("Welcome email sent to %s for %s", to_email, agent_did) - except Exception as e: - logger.error("Failed to send welcome email to %s: %s", to_email, e) - -# --- Request Models --- -class RegisterRequest(BaseModel): - display_name: str = Field(default="anonymous", min_length=1, max_length=64) - platform: str = Field(default="moltbook", max_length=32) - email: str | None = Field(default=None, max_length=256) - erc8004: bool = Field(default=False, description="Also register on ERC-8004 IdentityRegistry on Base") - - @field_validator("display_name") - @classmethod - def validate_display_name(cls, v): - if not DISPLAY_NAME_PATTERN.match(v): - raise ValueError("Display name can only contain letters, numbers, underscores, hyphens, dots, spaces") - return v.strip() - - @field_validator("platform") - @classmethod - def validate_platform(cls, v): - if not re.match(r"^[a-zA-Z0-9_\-]{1,32}$", v): - raise ValueError("Platform must be alphanumeric (a-z, 0-9, _, -)") - return v.strip().lower() - - @field_validator("email") - @classmethod - def validate_email(cls, v): - if v is not None: - v = v.strip().lower() - if "@" not in v or "." not in v.split("@")[-1]: - raise ValueError("Invalid email address") - return v - -class RateRequest(BaseModel): - from_did: str = Field(max_length=40) - to_did: str = Field(max_length=40) - score: int = Field(ge=1, le=5) - - @field_validator("from_did", "to_did") - @classmethod - def validate_dids(cls, v): - if not DID_PATTERN.match(v): - raise ValueError("Invalid DID format") - return v - -class MoltbookAuthRequest(BaseModel): - token: str = Field(min_length=10, max_length=512) - -class LightningInvoiceRequest(BaseModel): - amount_sats: int = Field(ge=1, le=10_000_000) - description: str = Field(default="MolTrust", max_length=128) - - @field_validator("description") - @classmethod - def sanitize_description(cls, v): - return re.sub(r"[<>&\"']", "", v).strip() - -class CreditTransferRequest(BaseModel): - from_did: str = Field(max_length=40) - to_did: str = Field(max_length=40) - amount: int = Field(ge=1) - reference: str = Field(default="", max_length=256) - - @field_validator("from_did", "to_did") - @classmethod - def validate_dids(cls, v): - if not DID_PATTERN.match(v): - raise ValueError("Invalid DID format") - return v - -# --- Endpoints --- - -@app.post("/identity/register") -@limiter.limit("10/minute") -async def register_agent(request: Request, body: RegisterRequest, api_key: str = Depends(verify_api_key)): - check_registration_rate(api_key) - agent_did = f"did:moltrust:{uuid.uuid4().hex[:16]}" - if db_pool: - async with db_pool.acquire() as conn: - # Duplicate detection: same display_name + platform in last 24h - dup = await conn.fetchval( - "SELECT COUNT(*) FROM agents WHERE display_name = $1 AND platform = $2 AND created_at > now() - interval '24 hours'", - body.display_name, body.platform - ) - if dup > 0: - raise HTTPException(409, "Agent with this name and platform was already registered in the last 24 hours") - await conn.execute( - "INSERT INTO agents (did, display_name, platform, agent_type, created_at) VALUES ($1, $2, $3, 'external', $4)", - agent_did, body.display_name, body.platform, datetime.datetime.utcnow() - ) - badge = f"\u2713 Verified by MolTrust | {agent_did} | Register: https://api.moltrust.ch/join?ref={agent_did}" - ts = datetime.datetime.utcnow().isoformat() - tx_hash = await anchor_to_base(agent_did, ts) - if tx_hash and db_pool: - async with db_pool.acquire() as conn: - await conn.execute("UPDATE agents SET base_tx_hash = $1 WHERE did = $2", tx_hash, agent_did) - auto_vc = issue_credential(agent_did, "AgentTrustCredential", {"trustProvider": "MolTrust", "reputation": {"score": 0.0, "total_ratings": 0}, "verified": True}) - if db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - """INSERT INTO credentials (subject_did, credential_type, issuer, issued_at, expires_at, proof_value, raw_vc) - VALUES ($1, $2, $3, $4, $5, $6, $7)""", - agent_did, "AgentTrustCredential", auto_vc["issuer"], - datetime.datetime.fromisoformat(auto_vc["issuanceDate"].replace("Z","")), - datetime.datetime.fromisoformat(auto_vc["expirationDate"].replace("Z","")), - auto_vc["proof"]["proofValue"], - json.dumps(auto_vc) - ) - - # --- Credits: link API key and grant 100 free credits --- - credits_granted = 0 - if db_pool: - try: - async with db_pool.acquire() as conn: - async with conn.transaction(): - await link_api_key_to_did(conn, api_key, agent_did) - await ensure_balance_row(conn, agent_did, 0) - await grant_credits(conn, agent_did, 100, "registration", "Free credits on registration") - credits_granted = 100 - except Exception as e: - logger.error("Credit grant failed for %s: %s", agent_did, e) - - # ERC-8004 dual registration - erc8004_result = None - if body.erc8004: - from app.erc8004 import register_onchain_agent - erc8004_result = register_onchain_agent(agent_did) - if erc8004_result.get("agent_id") and db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - "UPDATE agents SET erc8004_agent_id = $1 WHERE did = $2", - erc8004_result["agent_id"], agent_did - ) - - # Fire-and-forget welcome email - if body.email: - asyncio.create_task(send_welcome_email(body.email, agent_did, body.display_name)) - - response = { - "did": agent_did, - "display_name": body.display_name, - "status": "registered", - "badge": badge, - "credential": auto_vc, - "credits": {"balance": credits_granted, "currency": "CREDITS"}, - "base_anchor": {"tx_hash": tx_hash, "chain": "base", "explorer": f"https://basescan.org/tx/{tx_hash}" if tx_hash else None}, - "headers": { - "X-MolTrust-DID": agent_did, - "X-MolTrust-Verify": f"https://api.moltrust.ch/join?ref={agent_did}" - } - } - if erc8004_result: - response["erc8004"] = erc8004_result - return response - -@app.post("/auth/moltbook") -@limiter.limit("20/minute") -async def auth_with_moltbook(request: Request, body: MoltbookAuthRequest): - async with httpx.AsyncClient(timeout=10.0) as client: - try: - resp = await client.post( - "https://www.moltbook.com/api/v1/agents/verify-identity", - headers={"X-Moltbook-App-Key": MOLTBOOK_APP_KEY}, - json={"token": body.token} - ) - except httpx.TimeoutException: - raise HTTPException(504, "Moltbook verification timed out") - except httpx.RequestError: - raise HTTPException(502, "Could not reach Moltbook") - if resp.status_code != 200: - raise HTTPException(401, "Invalid Moltbook token") - data = resp.json() - if not data.get("valid"): - raise HTTPException(401, "Token not valid") - agent = data.get("agent", {}) - return { - "status": "authenticated", - "moltbook_id": str(agent.get("id", ""))[:64], - "name": str(agent.get("name", ""))[:64], - "karma": agent.get("karma", 0), - "moltrust_did": f"did:moltrust:{uuid.uuid4().hex[:16]}" - } - -@app.get("/identity/verify/{did}") -@limiter.limit("30/minute") -async def verify_agent(request: Request, did: str = Path(max_length=40)): - did = validate_did(did) - result = {"did": did, "verified": False, "reputation": 0.0} - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow("SELECT did, display_name FROM agents WHERE did = $1", did) - if row: - result["verified"] = True - await update_last_seen(did) - return result - -@app.get("/reputation/query/{did}") -@limiter.limit("30/minute") -async def get_reputation(request: Request, did: str = Path(max_length=40)): - did = validate_did(did) - result = {"did": did, "score": 0.0, "total_ratings": 0} - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT COALESCE(AVG(score), 0) as avg_score, COUNT(*) as total FROM ratings WHERE to_did = $1", - did - ) - if row: - result["score"] = round(float(row["avg_score"]), 2) - result["total_ratings"] = int(row["total"]) - return result - -@app.post("/reputation/rate") -@limiter.limit("10/minute") -async def rate_agent(request: Request, body: RateRequest, api_key: str = Depends(verify_api_key)): - if body.from_did == body.to_did: - raise HTTPException(400, "Cannot rate yourself") - if db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - "INSERT INTO ratings (from_did, to_did, score, created_at) VALUES ($1, $2, $3, $4)", - body.from_did, body.to_did, body.score, datetime.datetime.utcnow() - ) - # ERC-8004 bridge: post feedback on-chain if agent is dual-registered - erc8004_tx = None - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow("SELECT erc8004_agent_id FROM agents WHERE did = $1", body.to_did) - if row and row["erc8004_agent_id"] is not None: - from app.erc8004 import post_reputation_feedback - result = post_reputation_feedback(row["erc8004_agent_id"], body.to_did, body.score) - if "tx_hash" in result: - erc8004_tx = result["tx_hash"] - return {"status": "rated", "from": body.from_did, "to": body.to_did, "score": body.score, "erc8004_tx": erc8004_tx} - -@app.get("/skills") -@limiter.limit("30/minute") -async def list_skills(request: Request, limit: int = Query(default=20, ge=1, le=100)): - skills = [] - if db_pool: - async with db_pool.acquire() as conn: - rows = await conn.fetch("SELECT id, name, author_did, security_score FROM skills ORDER BY security_score DESC LIMIT $1", limit) - skills = [dict(row) for row in rows] - return {"skills": skills, "total": len(skills)} - -@app.post("/payment/lightning/invoice") -@limiter.limit("5/minute") -async def create_lightning_invoice(request: Request, body: LightningInvoiceRequest, api_key: str = Depends(verify_api_key)): - return {"status": "pending", "amount_sats": body.amount_sats, "description": body.description, "note": "phoenixd integration ready"} - -@app.get("/health") -@limiter.limit("60/minute") -async def health_check(request: Request): - db_ok = False - if db_pool: - try: - async with db_pool.acquire() as conn: - await conn.fetchval("SELECT 1") - db_ok = True - except: - pass - return { - "status": "ok", - "version": "2.2", - "database": "connected" if db_ok else "unavailable", - "timestamp": str(datetime.datetime.utcnow()) - } -# --- W3C DID:web Support --- - -DID_WEB_DOCUMENT = { - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/ed25519-2020/v1" - ], - "id": "did:web:api.moltrust.ch", - "controller": "did:web:api.moltrust.ch", - "verificationMethod": [{ - "id": "did:web:api.moltrust.ch#key-1", - "type": "Ed25519VerificationKey2020", - "controller": "did:web:api.moltrust.ch", - "publicKeyMultibase": "z6MktwcfvxeKmXstWpyEr9wJkJE2xzzkpBkdCSghdvCzrqDC" - }], - "authentication": ["did:web:api.moltrust.ch#key-1"], - "assertionMethod": ["did:web:api.moltrust.ch#key-1"], - "service": [ - { - "id": "did:web:api.moltrust.ch#trust-api", - "type": "TrustLayer", - "serviceEndpoint": "https://api.moltrust.ch" - }, - { - "id": "did:web:api.moltrust.ch#identity", - "type": "AgentIdentity", - "serviceEndpoint": "https://api.moltrust.ch/identity" - }, - { - "id": "did:web:api.moltrust.ch#reputation", - "type": "ReputationService", - "serviceEndpoint": "https://api.moltrust.ch/reputation" - } - ] -} - -@app.get("/.well-known/did.json") -@limiter.limit("60/minute") -async def did_web_document(request: Request): - return DID_WEB_DOCUMENT - -@app.get("/identity/resolve/{did:path}") -@limiter.limit("30/minute") -async def resolve_did(request: Request, did: str): - if len(did) > 256: - raise HTTPException(400, "DID too long") - if did == "did:web:api.moltrust.ch": - return DID_WEB_DOCUMENT - if DID_PATTERN.match(did): - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT did, display_name, platform, created_at FROM agents WHERE did = $1", did - ) - if row: - await update_last_seen(did) - if row: - return { - "@context": "https://www.w3.org/ns/did/v1", - "id": row["did"], - "controller": "did:web:api.moltrust.ch", - "metadata": { - "display_name": row["display_name"], - "platform": row["platform"], - "created": str(row["created_at"]), - "trust_provider": "MolTrust" - } - } - raise HTTPException(404, "DID not found") - if did.startswith("did:web:"): - raise HTTPException(501, "External did:web resolution not yet supported") - raise HTTPException(400, "Unsupported DID method") -# --- Verifiable Credentials --- -from app.credentials import issue_credential, verify_credential - -class IssueVCRequest(BaseModel): - subject_did: str = Field(max_length=128) - credential_type: str = Field(default="AgentTrustCredential", max_length=64) - - @field_validator("subject_did") - @classmethod - def validate_subject(cls, v): - if not (DID_PATTERN.match(v) or v.startswith("did:web:") or v.startswith("did:key:")): - raise ValueError("Invalid DID format") - return v - - @field_validator("credential_type") - @classmethod - def validate_credential_type(cls, v): - if not re.match(r"^[a-zA-Z][a-zA-Z0-9]{1,63}$", v): - raise ValueError("Credential type must be alphanumeric, starting with a letter") - return v - -class VerifyVCRequest(BaseModel): - credential: dict - - @field_validator("credential") - @classmethod - def validate_credential_size(cls, v): - if len(json.dumps(v)) > 16384: - raise ValueError("Credential payload too large (max 16KB)") - return v - -@app.post("/credentials/issue") -@limiter.limit("10/minute") -async def issue_vc(request: Request, body: IssueVCRequest, api_key: str = Depends(verify_api_key)): - reputation = {"score": 0.0, "total_ratings": 0} - if db_pool and DID_PATTERN.match(body.subject_did): - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT COALESCE(AVG(score),0) as avg, COUNT(*) as total FROM ratings WHERE to_did=$1", - body.subject_did - ) - if row: - reputation = {"score": round(float(row["avg"]), 2), "total_ratings": int(row["total"])} - - claims = { - "trustProvider": "MolTrust", - "reputation": reputation, - "verified": True - } - vc = issue_credential(body.subject_did, body.credential_type, claims) - if db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - """INSERT INTO credentials (subject_did, credential_type, issuer, issued_at, expires_at, proof_value, raw_vc) - VALUES ($1, $2, $3, $4, $5, $6, $7)""", - body.subject_did, body.credential_type, vc["issuer"], - datetime.datetime.fromisoformat(vc["issuanceDate"].replace("Z","")), - datetime.datetime.fromisoformat(vc["expirationDate"].replace("Z","")), - vc["proof"]["proofValue"], - json.dumps(vc) - ) - await update_last_seen(body.subject_did) - return vc - -@app.post("/credentials/verify") -@limiter.limit("30/minute") -async def verify_vc(request: Request, body: VerifyVCRequest): - result = verify_credential(body.credential) - return result -# --- Multi-Platform OAuth --- - -GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID", "PENDING") -GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET", "PENDING") - -@app.get("/auth/github") -@limiter.limit("10/minute") -async def github_auth_start(request: Request): - """Redirect to GitHub OAuth""" - if GITHUB_CLIENT_ID == "PENDING": - raise HTTPException(503, "GitHub OAuth not yet configured") - return JSONResponse({"redirect_url": f"https://github.com/login/oauth/authorize?client_id={GITHUB_CLIENT_ID}&scope=read:user"}) - -@app.get("/auth/github/callback") -@limiter.limit("10/minute") -async def github_auth_callback(request: Request, code: str = Query(max_length=128)): - if GITHUB_CLIENT_ID == "PENDING": - raise HTTPException(503, "GitHub OAuth not yet configured") - async with httpx.AsyncClient(timeout=10.0) as client: - token_resp = await client.post( - "https://github.com/login/oauth/access_token", - json={"client_id": GITHUB_CLIENT_ID, "client_secret": GITHUB_CLIENT_SECRET, "code": code}, - headers={"Accept": "application/json"} - ) - if token_resp.status_code != 200: - raise HTTPException(502, "GitHub token exchange failed") - token_data = token_resp.json() - access_token = token_data.get("access_token") - if not access_token: - raise HTTPException(401, "GitHub auth failed") - - user_resp = await client.get( - "https://api.github.com/user", - headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"} - ) - if user_resp.status_code != 200: - raise HTTPException(502, "GitHub user fetch failed") - gh_user = user_resp.json() - - agent_did = f"did:moltrust:{uuid.uuid4().hex[:16]}" - display_name = str(gh_user.get("login", ""))[:64] - - if db_pool: - async with db_pool.acquire() as conn: - await conn.execute( - "INSERT INTO agents (did, display_name, platform, agent_type, created_at) VALUES ($1, $2, $3, 'external', $4) ON CONFLICT DO NOTHING", - agent_did, display_name, "github", datetime.datetime.utcnow() - ) - - return { - "status": "authenticated", - "platform": "github", - "did": agent_did, - "display_name": display_name, - "github_id": gh_user.get("id"), - } - - - -# --- Self-Service API Key Signup --- - -class SignupRequest(BaseModel): - email: str = Field(max_length=256) - - @field_validator("email") - @classmethod - def validate_email(cls, v): - if "@" not in v or "." not in v.split("@")[-1]: - raise ValueError("Invalid email") - return v.lower().strip() - -@app.post("/auth/signup") -@limiter.limit("5/minute") -async def signup_for_api_key(request: Request, body: SignupRequest): - key = f"mt_{secrets.token_hex(16)}" - if db_pool: - async with db_pool.acquire() as conn: - existing = await conn.fetchval("SELECT key FROM api_keys WHERE email = $1", body.email) - if existing: - return {"status": "exists", "message": "API key already issued for this email. Contact support if lost."} - await conn.execute( - "INSERT INTO api_keys (key, email) VALUES ($1, $2)", - key, body.email - ) - API_KEYS.add(key) - return {"status": "created", "api_key": key, "email": body.email, "rate_limit": "100 requests/day", "note": "Save this key - it cannot be recovered."} - -# Load existing keys from DB on startup -@app.on_event("startup") -async def load_api_keys(): - if db_pool: - try: - async with db_pool.acquire() as conn: - rows = await conn.fetch("SELECT key FROM api_keys WHERE active = TRUE") - for row in rows: - API_KEYS.add(row["key"]) - print(f"Loaded {len(rows)} API keys from DB") - except Exception as e: - print(f"Could not load API keys: {e}") - - - -# --- Base Blockchain Anchor --- -from web3 import Web3 -import hashlib as _hashlib -from eth_account import Account - -BASE_RPC = "https://mainnet.base.org" -BASE_KEY = os.getenv("BASE_WALLET_KEY", "") -BASE_ADDR = Account.from_key(BASE_KEY).address if BASE_KEY else None - -async def anchor_to_base(agent_did: str, timestamp: str) -> str: - try: - w3 = Web3(Web3.HTTPProvider(BASE_RPC)) - if not w3.is_connected(): - return None - data = _hashlib.sha256(f"{agent_did}:{timestamp}".encode()).hexdigest() - nonce = w3.eth.get_transaction_count(BASE_ADDR) - tx = { - "from": BASE_ADDR, - "to": BASE_ADDR, - "value": 0, - "data": w3.to_bytes(hexstr="0x" + data), - "nonce": nonce, - "chainId": 8453, - "gas": 25000, - "maxFeePerGas": w3.eth.gas_price + w3.to_wei(0.001, "gwei"), - "maxPriorityFeePerGas": w3.to_wei(0.001, "gwei"), - } - signed = w3.eth.account.sign_transaction(tx, BASE_KEY) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) - return w3.to_hex(tx_hash) - except Exception as e: - print(f"Base anchor error: {e}") - return None - - - -# --- Credit Endpoints --- - -@app.get("/credits/pricing") -@limiter.limit("60/minute") -async def credits_pricing(request: Request): - return {"pricing": ENDPOINT_COSTS, "currency": "CREDITS", "free_on_registration": 100} - -@app.get("/credits/balance/{did}") -@limiter.limit("60/minute") -async def credits_balance(request: Request, did: str = Path(max_length=40)): - did = validate_did(did) - balance = 0 - if db_pool: - async with db_pool.acquire() as conn: - balance = await _get_balance(conn, did) - return {"did": did, "balance": balance, "currency": "CREDITS"} - -@app.post("/credits/transfer") -@limiter.limit("10/minute") -async def credits_transfer(request: Request, body: CreditTransferRequest, api_key: str = Depends(verify_api_key)): - if body.from_did == body.to_did: - raise HTTPException(400, "Cannot transfer to yourself") - if not db_pool: - raise HTTPException(503, "Database unavailable") - - # Verify the caller owns from_did - async with db_pool.acquire() as conn: - owner_did = await resolve_did_from_api_key(conn, api_key) - if owner_did != body.from_did: - raise HTTPException(403, "API key does not own the source DID") - - try: - async with db_pool.acquire() as conn: - async with conn.transaction(): - await transfer_credits(conn, body.from_did, body.to_did, body.amount, body.reference or "transfer") - except ValueError as e: - raise HTTPException(402, str(e)) - - # Fetch updated balances - async with db_pool.acquire() as conn: - sender_balance = await _get_balance(conn, body.from_did) - - return { - "status": "transferred", - "from_did": body.from_did, - "to_did": body.to_did, - "amount": body.amount, - "balance_after": sender_balance, - "currency": "CREDITS", - } - -@app.get("/credits/transactions/{did}") -@limiter.limit("30/minute") -async def credits_transactions(request: Request, did: str = Path(max_length=40), api_key: str = Depends(verify_api_key), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0)): - did = validate_did(did) - if not db_pool: - raise HTTPException(503, "Database unavailable") - - # Verify the caller owns this DID - async with db_pool.acquire() as conn: - owner_did = await resolve_did_from_api_key(conn, api_key) - if owner_did != did: - raise HTTPException(403, "API key does not own this DID") - - async with db_pool.acquire() as conn: - txs = await get_transactions(conn, did, limit, offset) - return {"did": did, "transactions": txs, "limit": limit, "offset": offset} - - - -# --- USDC Deposit Endpoint --- -from app.usdc import verify_usdc_transfer, record_deposit, get_deposits, CREDITS_PER_USDC, MOLTRUST_WALLET - -class DepositRequest(BaseModel): - tx_hash: str = Field(min_length=64, max_length=70) - did: str = Field(max_length=40) - -@app.post("/credits/deposit") -@limiter.limit("5/minute") -async def credits_deposit(request: Request, body: DepositRequest, api_key: str = Depends(verify_api_key)): - """Claim credits by submitting a USDC transaction hash from Base.""" - did = validate_did(body.did) - if not db_pool: - raise HTTPException(503, "Database unavailable") - - # Verify caller owns this DID - async with db_pool.acquire() as conn: - owner_did = await resolve_did_from_api_key(conn, api_key) - if owner_did != did: - raise HTTPException(403, "API key does not own this DID") - - # Verify on-chain - result = verify_usdc_transfer(body.tx_hash) - if not result["valid"]: - raise HTTPException(400, result["error"]) - - # Record deposit + grant credits atomically - async with db_pool.acquire() as conn: - async with conn.transaction(): - recorded = await record_deposit( - conn, body.tx_hash, result["from_address"], did, - result["usdc_amount"], result["credits"], result["block_number"], - ) - if not recorded: - raise HTTPException(409, "This transaction has already been claimed") - - await ensure_balance_row(conn, did) - await grant_credits( - conn, did, result["credits"], - reference=f"usdc_deposit:{body.tx_hash[:16]}", - description=f"USDC deposit: {result['usdc_amount']} USDC = {result['credits']} credits", - ) - new_balance = await _get_balance(conn, did) - - return { - "status": "deposited", - "tx_hash": body.tx_hash, - "basescan_url": f"https://basescan.org/tx/{body.tx_hash}", - "from_address": result["from_address"], - "usdc_amount": result["usdc_amount"], - "credits_granted": result["credits"], - "new_balance": new_balance, - "currency": "CREDITS", - "rate": f"1 USDC = {CREDITS_PER_USDC} credits", - } - -@app.get("/credits/deposits/{did}") -@limiter.limit("30/minute") -async def credits_deposit_history(request: Request, did: str = Path(max_length=40), api_key: str = Depends(verify_api_key)): - """Get USDC deposit history for an agent.""" - did = validate_did(did) - if not db_pool: - raise HTTPException(503, "Database unavailable") - async with db_pool.acquire() as conn: - owner_did = await resolve_did_from_api_key(conn, api_key) - if owner_did != did: - raise HTTPException(403, "API key does not own this DID") - async with db_pool.acquire() as conn: - deposits = await get_deposits(conn, did) - return {"did": did, "deposits": deposits, "wallet": MOLTRUST_WALLET, "network": "Base (Chain ID 8453)"} - -@app.get("/credits/deposit-info") -async def credits_deposit_info(request: Request): - """Public endpoint: how to deposit USDC for credits.""" - return { - "wallet": MOLTRUST_WALLET, - "network": "Base (Ethereum L2, Chain ID 8453)", - "token": "USDC", - "token_contract": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "rate": f"1 USDC = {CREDITS_PER_USDC} credits", - "min_confirmations": 5, - "instructions": [ - "1. Send USDC on Base to the wallet address above", - "2. Wait for 5 confirmations (~10 seconds on Base)", - "3. Call POST /credits/deposit with your tx_hash and DID", - "4. Credits are granted instantly after verification", - ], - } - -# --- A2A Agent Card Trust Extension --- - -@app.get("/.well-known/agent.json") -@limiter.limit("60/minute") -async def a2a_agent_card(request: Request): - return { - "name": "MolTrust", - "description": "Trust Layer for the Agent Economy. Identity verification, reputation scoring, and W3C Verifiable Credentials for AI agents.", - "url": "https://api.moltrust.ch", - "version": "1.0.0", - "capabilities": { - "streaming": False, - "pushNotifications": False - }, - "skills": [ - { - "id": "identity-verification", - "name": "Agent Identity Verification", - "description": "Register and verify W3C DID identities for AI agents with Ed25519 cryptographic signatures", - "tags": ["identity", "did", "verification", "w3c"] - }, - { - "id": "reputation-scoring", - "name": "Reputation Scoring", - "description": "Query and submit trust ratings for AI agents. 1-5 scale with comment support.", - "tags": ["reputation", "trust", "rating"] - }, - { - "id": "verifiable-credentials", - "name": "Verifiable Credentials", - "description": "Issue and verify W3C Verifiable Credentials signed with Ed25519", - "tags": ["credentials", "w3c", "ed25519"] - }, - { - "id": "blockchain-anchor", - "name": "Base Blockchain Anchoring", - "description": "Anchor agent identity hashes on Base (Ethereum L2) for immutable proof", - "tags": ["blockchain", "base", "ethereum", "anchor"] - } - ], - "authentication": { - "schemes": ["apiKey"], - "apiKey": {"headerName": "X-API-Key", "signupUrl": "https://moltrust.ch#signup"} - }, - "provider": { - "organization": "CryptoKRI GmbH", - "url": "https://moltrust.ch" - }, - "links": { - "docs": "https://api.moltrust.ch/docs", - "github": "https://github.com/MoltyCel/moltrust-sdk", - "pypi": "https://pypi.org/project/moltrust/", - "did": "https://api.moltrust.ch/.well-known/did.json" - } - } - -@app.get("/a2a/agent-card/{did}") -@limiter.limit("60/minute") -async def a2a_trust_card(request: Request, did: str = Path(max_length=128)): - if not DID_PATTERN.match(did): - raise HTTPException(status_code=400, detail="Invalid DID format") - if not db_pool: - raise HTTPException(status_code=503, detail="Database unavailable") - async with db_pool.acquire() as conn: - agent = await conn.fetchrow("SELECT display_name, platform, created_at, base_tx_hash FROM agents WHERE did = $1", did) - if not agent: - raise HTTPException(status_code=404, detail="Agent not found") - score = await conn.fetchrow("SELECT COALESCE(AVG(score),0) as avg, COUNT(*) as total FROM ratings WHERE to_did=$1", did) - cred_count = await conn.fetchval("SELECT COUNT(*) FROM credentials WHERE subject_did=$1", did) - cred = {"total": cred_count} - return { - "name": agent["display_name"], - "did": did, - "platform": agent["platform"], - "url": f"https://api.moltrust.ch/identity/verify/{did}", - "trust": { - "score": round(float(score["avg"]), 2), - "totalRatings": int(score["total"]), - "credentials": int(cred["total"]), - "verified": True, - "registeredAt": agent["created_at"].isoformat() if agent["created_at"] else None, - "baseAnchor": agent["base_tx_hash"], - "baseScanUrl": f"https://basescan.org/tx/{agent['base_tx_hash']}" if agent["base_tx_hash"] else None - }, - "capabilities": { - "verifiableIdentity": True, - "reputationScoring": True, - "blockchainAnchored": bool(agent["base_tx_hash"]) - }, - "verifyUrl": f"https://api.moltrust.ch/identity/verify/{did}", - "rateUrl": f"https://api.moltrust.ch/reputation/rate", - "provider": "MolTrust (https://moltrust.ch)" - } - -# --- Recent Agents --- -@app.get("/agents/recent") -@limiter.limit("60/minute") -async def recent_agents(request: Request): - agents = [] - if db_pool: - async with db_pool.acquire() as conn: - rows = await conn.fetch( - "SELECT display_name, did, platform, created_at FROM agents WHERE agent_type = 'external' ORDER BY created_at DESC LIMIT 10" - ) - agents = [] - for row in rows: - name = row["display_name"] - did_short = row["did"][:16] + "..." if len(row["did"]) > 16 else row["did"] - if not name or name.strip().lower() == "anonymous": - name = f"{row['platform']} \u00b7 {did_short}" - agents.append({ - "display_name": name, - "did": did_short, - "platform": row["platform"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - }) - return JSONResponse(content=agents, headers={"Cache-Control": "public, max-age=30"}) - -# --- Public Stats --- -@app.get("/stats") -@limiter.limit("60/minute") -async def public_stats(request: Request): - stats = {"agents": 0, "ratings": 0, "credentials": 0} - if db_pool: - async with db_pool.acquire() as conn: - stats["agents"] = await conn.fetchval("SELECT COUNT(*) FROM agents WHERE agent_type = 'external'") or 0 - stats["ratings"] = await conn.fetchval("SELECT COUNT(*) FROM ratings") or 0 - try: - stats["credentials"] = await conn.fetchval("SELECT COUNT(*) FROM credentials") or 0 - except: - stats["credentials"] = stats["agents"] - return stats - -from fastapi.middleware.cors import CORSMiddleware -app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) - -# --- Viral Join Endpoint --- -from fastapi.responses import HTMLResponse, RedirectResponse, RedirectResponse - -@app.get("/join") -@limiter.limit("30/minute") -async def join_redirect(request: Request, ref: str = Query(default=None, max_length=100)): - if ref: - return RedirectResponse(f"https://moltrust.ch?ref={ref}", status_code=302) - return RedirectResponse("https://moltrust.ch", status_code=302) - -# --- ERC-8004 Bridge (Phase 1: Read-Only) --- -from app.erc8004 import build_registration_file, resolve_onchain_agent, get_onchain_reputation, get_well_known_registration - -@app.get("/agents/{did}/erc8004") -@limiter.limit("30/minute") -async def erc8004_registration_file(request: Request, did: str = Path(max_length=128)): - """Serve ERC-8004 compatible registration file (Agent Card) for a MolTrust agent.""" - # Special case: MolTrust platform identity - if did in ("did:web:api.moltrust.ch", "did%3Aweb%3Aapi.moltrust.ch"): - from app.erc8004 import MOLTRUST_PLATFORM_AGENT_ID - return build_registration_file( - {"did": "did:web:api.moltrust.ch", "display_name": "MolTrust", "base_tx_hash": None}, - {"score": 0.0, "total_ratings": 0}, - MOLTRUST_PLATFORM_AGENT_ID - ) - did = validate_did(did) - if not db_pool: - raise HTTPException(503, "Database unavailable") - async with db_pool.acquire() as conn: - agent = await conn.fetchrow( - "SELECT did, display_name, platform, base_tx_hash, erc8004_agent_id FROM agents WHERE did = $1", did - ) - if not agent: - raise HTTPException(404, "Agent not found") - rep = await conn.fetchrow( - "SELECT COALESCE(AVG(score), 0) as avg_score, COUNT(*) as total FROM ratings WHERE to_did = $1", did - ) - await update_last_seen(did) - reputation = {"score": round(float(rep["avg_score"]), 2), "total_ratings": int(rep["total"])} - return build_registration_file(dict(agent), reputation, agent["erc8004_agent_id"]) - -@app.get("/resolve/erc8004/{agent_id}") -@limiter.limit("10/minute") -async def erc8004_resolve(request: Request, agent_id: int = Path(ge=0)): - """Resolve an ERC-8004 agent ID on Base to its on-chain data + optional MolTrust cross-reference.""" - result = resolve_onchain_agent(agent_id) - if "error" in result: - raise HTTPException(404, result["error"]) - - # Cross-reference: check if this agentId is linked to a MolTrust DID - result["moltrust_did"] = None - result["moltrust_profile"] = None - if db_pool: - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT did FROM agents WHERE erc8004_agent_id = $1", agent_id - ) - if row: - result["moltrust_did"] = row["did"] - result["moltrust_profile"] = f"https://api.moltrust.ch/identity/resolve/{row["did"]}" - - # Fetch on-chain reputation - result["onchain_reputation"] = get_onchain_reputation(agent_id) - return result - -@app.get("/.well-known/agent-registration.json") -async def well_known_agent_registration(request: Request): - """ERC-8004 domain verification endpoint.""" - return get_well_known_registration() - - -# ═══════════════════════════════════════════════════════════════ -# SPORTS MODULE — Prediction Commitment & Verification -# ═══════════════════════════════════════════════════════════════ - -class PredictionCommitRequest(BaseModel): - agent_did: str = Field(max_length=40) - event_id: str = Field(max_length=256) - prediction: dict - event_start: str = Field(max_length=30) - - @field_validator("agent_did") - @classmethod - def check_did_format(cls, v): - if not re.match(r"^did:moltrust:[a-f0-9]{16}$", v): - raise ValueError("Invalid DID format") - return v - - @field_validator("event_start") - @classmethod - def check_event_start_future(cls, v): - try: - dt = datetime.datetime.fromisoformat(v.replace("Z", "+00:00")) - if dt <= datetime.datetime.now(datetime.timezone.utc): - raise ValueError("event_start must be in the future") - except (ValueError, TypeError) as e: - if "future" in str(e): - raise - raise ValueError("Invalid ISO 8601 datetime") - return v - - -@app.get("/sports/health") -@limiter.limit("60/minute") -async def sports_health(request: Request): - """Sports module health check.""" - db_ok = False - if db_pool: - try: - async with db_pool.acquire() as conn: - await conn.fetchval("SELECT 1") - db_ok = True - except Exception: - pass - return { - "module": "moltrust-sports", - "version": "1.0.0", - "status": "ok" if db_ok else "degraded", - "database": "connected" if db_ok else "unavailable", - "chain": "base-mainnet", - } - - -@app.post("/sports/predictions/commit") -@limiter.limit("30/minute") -async def sports_predict_commit(request: Request, body: PredictionCommitRequest, - x_api_key: str = Depends(verify_api_key)): - """Commit a prediction before an event starts. Returns commitment hash + on-chain anchor.""" - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - # Verify agent exists - if not await _sp_agent_exists(conn, body.agent_did): - raise HTTPException(404, f"Agent {body.agent_did} not registered") - - # Normalize event ID - event_id = normalize_event_id(body.event_id) - if not event_id or len(event_id) < 5: - raise HTTPException(400, "event_id too short after normalization") - - # Compute commitment hash - commitment_hash = compute_commitment_hash( - body.agent_did, event_id, body.prediction, body.event_start, - ) - - # Check uniqueness (agent + event) - existing = await conn.fetchval( - "SELECT commitment_hash FROM sports_predictions WHERE agent_did = $1 AND event_id = $2", - body.agent_did, event_id, - ) - if existing: - raise HTTPException(409, f"Prediction already committed for this event (hash: {existing})") - - # Anchor on-chain (reuse existing anchor function) - tx_hash = await anchor_to_base(commitment_hash, body.event_start) - - # Insert - try: - row = await insert_prediction( - conn, body.agent_did, event_id, body.prediction, - body.event_start, commitment_hash, tx_hash, - ) - except Exception as e: - if "unique" in str(e).lower() or "duplicate" in str(e).lower(): - raise HTTPException(409, "Duplicate prediction or commitment hash") - raise - - return { - "status": "committed", - "commitment_hash": commitment_hash, - "event_id": event_id, - "agent_did": body.agent_did, - "base_tx_hash": tx_hash, - "anchored": tx_hash is not None, - "created_at": row["created_at"].isoformat() if row else None, - "verify_url": f"https://api.moltrust.ch/sports/predictions/verify/{commitment_hash}", - } - - -@app.get("/sports/predictions/verify/{commitment_hash}") -@limiter.limit("60/minute") -async def sports_predict_verify(request: Request, commitment_hash: str = Path(max_length=64)): - """Verify a prediction commitment exists and return details.""" - if not re.match(r"^[a-f0-9]{64}$", commitment_hash): - raise HTTPException(400, "Invalid hash format (expected 64 hex chars)") - - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - row = await get_prediction_by_hash(conn, commitment_hash) - - if not row: - raise HTTPException(404, "Commitment not found") - - prediction = row["prediction"] - if isinstance(prediction, str): - prediction = json.loads(prediction) - - return { - "status": "verified", - "commitment_hash": row["commitment_hash"], - "agent_did": row["agent_did"], - "event_id": row["event_id"], - "prediction": prediction, - "event_start": row["event_start"].isoformat(), - "base_tx_hash": row["base_tx_hash"], - "anchored": row["base_tx_hash"] is not None, - "committed_at": row["created_at"].isoformat(), - "basescan_url": f"https://basescan.org/tx/{row['base_tx_hash']}" if row["base_tx_hash"] else None, - } - - - -# --- Sports Phase 2: History + Admin Settlement --- - -class ManualSettleRequest(BaseModel): - result: str = Field(max_length=64) - score: str | None = Field(default=None, max_length=32) - detail: dict | None = Field(default=None) - - -@app.get("/sports/predictions/history/{did}") -@limiter.limit("30/minute") -async def sports_predict_history(request: Request, did: str = Path(max_length=40), - x_api_key: str = Depends(verify_api_key)): - """Get prediction history and stats for an agent.""" - did = validate_did(did) - - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - if not await _sp_agent_exists(conn, did): - raise HTTPException(404, f"Agent {did} not registered") - - predictions = await get_prediction_history(conn, did) - stats = await get_prediction_stats(conn, did) - calibration = await compute_calibration_score(conn, did) - - # Get MolTrust reputation score - rep = await conn.fetchrow( - "SELECT COALESCE(AVG(score), 0) as avg_score FROM ratings WHERE to_did = $1", did - ) - moltrust_score = round(float(rep["avg_score"]) * 20, 1) if rep and rep["avg_score"] else 0 - - stats["calibration_score"] = calibration - - # Format predictions for response - formatted = [] - for p in predictions: - pred = p["prediction"] - if isinstance(pred, str): - pred = json.loads(pred) - outcome = p["outcome"] - if isinstance(outcome, str): - outcome = json.loads(outcome) - - formatted.append({ - "commitment_hash": p["commitment_hash"], - "event_id": p["event_id"], - "prediction": pred.get("outcome", pred.get("result", str(pred))), - "confidence": pred.get("confidence"), - "correct": p["correct"], - "outcome": outcome.get("result") if isinstance(outcome, dict) else outcome, - "committed_at": p["created_at"].isoformat(), - "settled_at": p["settled_at"].isoformat() if p["settled_at"] else None, - }) - - return { - "agent_did": did, - "moltrust_score": moltrust_score, - "betting_stats": stats, - "predictions": formatted, - } - - -@app.patch("/sports/predictions/settle/{commitment_hash}") -@limiter.limit("30/minute") -async def sports_predict_settle_admin(request: Request, - commitment_hash: str = Path(max_length=64), - body: ManualSettleRequest = None, - x_api_key: str = Depends(verify_api_key)): - """Admin endpoint: manually settle a prediction (for polymarket or manual events).""" - if not re.match(r"^[a-f0-9]{64}$", commitment_hash): - raise HTTPException(400, "Invalid hash format") - - if not db_pool: - raise HTTPException(503, "Database unavailable") - - result_data = { - "result": body.result, - "score": body.score, - "source": "manual", - } - if body.detail: - result_data["detail"] = body.detail - - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT settled_at FROM sports_predictions WHERE commitment_hash = $1", - commitment_hash, - ) - if not row: - raise HTTPException(404, "Commitment not found") - if row["settled_at"] is not None: - raise HTTPException(409, "Already settled") - - ok = await _settle_prediction_fn(conn, commitment_hash, result_data) - - if not ok: - raise HTTPException(500, "Settlement failed") - - return { - "status": "settled", - "commitment_hash": commitment_hash, - "result": body.result, - "score": body.score, - } - - -# --- Signal Provider Endpoints --- - -class SignalProviderRegisterRequest(BaseModel): - agent_did: str = Field(max_length=40) - provider_name: str = Field(max_length=128) - provider_url: str | None = Field(default=None, max_length=512) - sport_focus: list[str] = Field(default_factory=list) - description: str | None = Field(default=None, max_length=500) - - @field_validator("agent_did") - @classmethod - def check_did_format(cls, v): - if not re.match(r"^did:(moltrust:[a-f0-9]{16}|web:.+)$", v): - raise ValueError("Invalid DID format (expected did:moltrust:... or did:web:...)") - return v - - -@app.post("/sports/signals/register", status_code=201) -@limiter.limit("10/minute") -async def signal_provider_register(request: Request, body: SignalProviderRegisterRequest, - x_api_key: str = Depends(verify_api_key)): - """Register as a Verified Signal Provider. Returns credential with on-chain anchor.""" - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - # Verify agent exists - if not await _sp_agent_exists(conn, body.agent_did): - raise HTTPException(404, f"Agent {body.agent_did} not registered. Register first via POST /identity/register") - - # Check if already registered - existing = await get_provider_by_did(conn, body.agent_did) - if existing: - raise HTTPException(409, f"Agent already registered as signal provider (id: {existing['provider_id']})") - - # Generate provider ID - ts = _dt.datetime.now(_dt.timezone.utc).isoformat() - provider_id = generate_provider_id(body.agent_did, ts) - - # Compute credential hash - cred_hash = compute_credential_hash(provider_id, body.agent_did, body.provider_name, ts) - - # Anchor on-chain - tx_hash = await anchor_to_base(cred_hash, ts) - - # Insert - try: - row = await insert_provider( - conn, provider_id, body.agent_did, body.provider_name, - body.provider_url, body.sport_focus, body.description, - cred_hash, tx_hash, - ) - except Exception as e: - if "unique" in str(e).lower() or "duplicate" in str(e).lower(): - raise HTTPException(409, "Duplicate registration") - raise - - return { - "provider_id": provider_id, - "agent_did": body.agent_did, - "provider_name": body.provider_name, - "credential": { - "type": "MolTrustVerifiedSignalProvider", - "issued_at": ts, - "issuer": "did:web:moltrust.ch", - "credential_hash": cred_hash, - "tx_hash": tx_hash, - "chain": "base", - }, - "badge_url": f"https://moltrust.ch/badges/signals/{provider_id}", - "verify_url": f"https://api.moltrust.ch/sports/signals/verify/{provider_id}", - } - - -@app.get("/sports/signals/verify/{provider_id}") -@limiter.limit("60/minute") -async def signal_provider_verify(request: Request, provider_id: str = Path(max_length=11)): - """Public: verify a signal provider and see their track record.""" - if not re.match(r"^sp_[a-f0-9]{8}$", provider_id): - raise HTTPException(400, "Invalid provider_id format (expected sp_ + 8 hex chars)") - - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - provider = await get_provider_by_id(conn, provider_id) - if not provider: - raise HTTPException(404, "Signal provider not found") - - track = await get_track_record(conn, provider["agent_did"]) - calibration = await compute_calibration_score(conn, provider["agent_did"]) - recent = await get_recent_signals(conn, provider["agent_did"]) - - track["calibration_score"] = calibration - - sport_focus = provider["sport_focus"] - if isinstance(sport_focus, str): - import json as _json - sport_focus = _json.loads(sport_focus) - - return { - "provider_id": provider["provider_id"], - "provider_name": provider["provider_name"], - "agent_did": provider["agent_did"], - "provider_url": provider["provider_url"], - "sport_focus": sport_focus, - "description": provider["description"], - "credential": { - "type": "MolTrustVerifiedSignalProvider", - "issued_at": provider["created_at"].isoformat(), - "on_chain_verified": provider["credential_tx_hash"] is not None, - "tx_hash": provider["credential_tx_hash"], - "credential_hash": provider["credential_hash"], - }, - "track_record": track, - "recent_signals": recent, - "badge_svg_url": f"https://api.moltrust.ch/sports/signals/badge/{provider_id}.svg", - } - - -@app.get("/sports/signals/leaderboard") -@limiter.limit("30/minute") -async def signal_provider_leaderboard(request: Request): - """Public: top signal providers ranked by accuracy.""" - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - providers = await get_leaderboard(conn) - - # Add calibration scores - for p in providers: - prov = await get_provider_by_id(conn, p["provider_id"]) - if prov: - cal = await compute_calibration_score(conn, prov["agent_did"]) - p["calibration_score"] = cal - - return { - "updated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), - "min_settled_threshold": 20, - "providers": providers, - } - - -@app.get("/sports/signals/badge/{provider_id}.svg") -@limiter.limit("120/minute") -async def signal_provider_badge(request: Request, provider_id: str = Path(max_length=11)): - """Public: SVG badge for embedding in websites.""" - pid = provider_id.replace(".svg", "") - if not re.match(r"^sp_[a-f0-9]{8}$", pid): - raise HTTPException(400, "Invalid provider_id format") - - if not db_pool: - raise HTTPException(503, "Database unavailable") - - async with db_pool.acquire() as conn: - provider = await get_provider_by_id(conn, pid) - if not provider: - raise HTTPException(404, "Signal provider not found") - - track = await get_track_record(conn, provider["agent_did"]) - - accuracy = track["accuracy"] if track["settled"] > 0 else None - svg = generate_badge_svg(provider["provider_name"], accuracy) - - from starlette.responses import Response - return Response(content=svg, media_type="image/svg+xml", - headers={"Cache-Control": "public, max-age=300"}) - - -# --- Endpoint Costs for Signals --- -# (Note: update credits.py ENDPOINT_COSTS if credits system is enabled) diff --git a/operator/agent.py.bak b/operator/agent.py.bak deleted file mode 100644 index 4d4c0de..0000000 --- a/operator/agent.py.bak +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -"""MolTrust Operator Agent - Heartbeat with real API checks""" - -import os -import json -import httpx -import datetime -from anthropic import Anthropic - -API_BASE = "http://localhost:8000" -API_KEY = "mt_test_key_2026" -LOG_PREFIX = datetime.datetime.utcnow().isoformat() - -def check_health(): - try: - r = httpx.get(f"{API_BASE}/health", timeout=5) - return r.json() - except Exception as e: - return {"error": str(e)} - -def check_register(): - try: - r = httpx.post( - f"{API_BASE}/identity/register", - headers={"X-API-Key": API_KEY, "Content-Type": "application/json"}, - json={"display_name": "operator_probe"}, - timeout=5 - ) - return r.json() - except Exception as e: - return {"error": str(e)} - -def check_verify(did): - try: - r = httpx.get(f"{API_BASE}/identity/verify/{did}", timeout=5) - return r.json() - except Exception as e: - return {"error": str(e)} - -def check_lightning(): - try: - r = httpx.post( - f"{API_BASE}/payment/lightning/invoice", - headers={"X-API-Key": API_KEY, "Content-Type": "application/json"}, - json={"amount_sats": 100, "description": "operator_probe"}, - timeout=10 - ) - return r.json() - except Exception as e: - return {"error": str(e)} - -def run_checks(): - results = {} - results["health"] = check_health() - - reg = check_register() - results["register"] = reg - - if "did" in reg: - results["verify"] = check_verify(reg["did"]) - - results["lightning"] = check_lightning() - return results - -def generate_report(checks): - client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) - - prompt = f"""Du bist der MolTrust Operator Agent. -Hier sind die Ergebnisse der API-Health-Checks: - -{json.dumps(checks, indent=2)} - -Erstelle einen kurzen Status-Report (max 15 Zeilen): -- Welche Endpoints sind UP/DOWN -- Gibt es Anomalien oder Fehler -- DB-Status (aus health check) -- Lightning-Status -- Handlungsempfehlung falls noetig - -Antworte auf Deutsch, knapp und technisch.""" - - response = client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=500, - messages=[{"role": "user", "content": prompt}] - ) - return response.content[0].text - -def main(): - ts = str(datetime.datetime.utcnow()) - print(f"[{ts}] Heartbeat started") - - checks = run_checks() - print(f"[{ts}] Checks: {json.dumps(checks)}") - - try: - report = generate_report(checks) - print(f"[{ts}] Report:\n{report}") - except Exception as e: - print(f"[{ts}] Report generation failed: {e}") - # Still log raw checks even if Claude is unavailable - print(f"[{ts}] Raw status: health={checks.get('health',{}).get('status','UNKNOWN')}") - - print(f"[{ts}] Heartbeat complete") - -if __name__ == "__main__": - main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bf0ba4a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastapi>=0.111.0 +uvicorn>=0.30.0 +asyncpg>=0.29.0 +pydantic>=2.7.0 +httpx>=0.27.0 +slowapi>=0.1.9 +web3>=6.19.0 +eth-account>=0.11.0 +PyNaCl>=1.5.0 +boto3>=1.34.0 +jcs>=0.2.1 +python-multipart>=0.0.9 +starlette>=0.37.0 From 259a3eaf4d532f94dca2e254a869330d40f96edc Mon Sep 17 00:00:00 2001 From: Harald Roessler Date: Wed, 1 Apr 2026 16:11:54 +0700 Subject: [PATCH 2/2] fix: add missing apscheduler dep, configurable DB_HOST, update init_db.sql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found during local testing with docker-compose: - apscheduler is imported at startup but was missing from requirements.txt - DB host was hardcoded to localhost, now reads DB_HOST env var - init_db.sql schema was outdated — updated to match current codebase columns (agent_type, base_tx_hash, erc8004_agent_id, wallet fields, from_did/to_did in ratings, credentials table, api_keys, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 1 + app/main.py | 2 +- init_db.sql | 154 ++++++++++++++++++++++++++++++++++------------- requirements.txt | 1 + 4 files changed, 115 insertions(+), 43 deletions(-) diff --git a/.env.example b/.env.example index 5325660..1fdf74f 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ MOLTRUST_API_KEYS=your_api_key_1,your_api_key_2 # --- Database --- DATABASE_URL=postgresql://moltstack:password@localhost/moltstack +DB_HOST=localhost DB_NAME=moltstack MOLTSTACK_DB_PW= diff --git a/app/main.py b/app/main.py index 4e6c1b9..d1977f8 100644 --- a/app/main.py +++ b/app/main.py @@ -166,7 +166,7 @@ async def startup(): global db_pool try: db_pool = await asyncpg.create_pool( - host="localhost", database=os.getenv("DB_NAME", "moltstack"), + host=os.getenv("DB_HOST", "localhost"), database=os.getenv("DB_NAME", "moltstack"), user="moltstack", password=os.getenv("MOLTSTACK_DB_PW", ""), min_size=2, max_size=10 ) diff --git a/init_db.sql b/init_db.sql index 6bdd32c..e3d4205 100644 --- a/init_db.sql +++ b/init_db.sql @@ -1,43 +1,113 @@ -CREATE TABLE agents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - did TEXT UNIQUE NOT NULL, - public_key TEXT NOT NULL, - display_name TEXT, - platform TEXT DEFAULT 'moltbook', - created_at TIMESTAMP DEFAULT NOW(), - reputation_score DECIMAL(5,2) DEFAULT 0.00 -); - -CREATE TABLE ratings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - from_agent UUID REFERENCES agents(id), - to_agent UUID REFERENCES agents(id), - score INTEGER CHECK (score BETWEEN 1 AND 5), - context TEXT, - transaction_hash TEXT, - created_at TIMESTAMP DEFAULT NOW() -); - -CREATE TABLE skills ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - description TEXT, - developer_agent UUID REFERENCES agents(id), - security_score DECIMAL(3,2), - price_usdc DECIMAL(10,2), - price_sats BIGINT, - repo_url TEXT, - status TEXT DEFAULT 'pending', - created_at TIMESTAMP DEFAULT NOW() -); - -CREATE TABLE transactions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - buyer_agent UUID REFERENCES agents(id), - skill_id UUID REFERENCES skills(id), - amount DECIMAL(10,2), - currency TEXT CHECK (currency IN ('USDC', 'BTC')), - payment_hash TEXT, - status TEXT DEFAULT 'pending', - created_at TIMESTAMP DEFAULT NOW() +-- MolTrust API — Database Schema (dev/test) +-- This matches the columns expected by the current codebase. + +CREATE TABLE IF NOT EXISTS agents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT UNIQUE NOT NULL, + public_key TEXT DEFAULT '', + display_name TEXT, + platform TEXT DEFAULT 'moltbook', + agent_type TEXT DEFAULT 'external', + created_at TIMESTAMP DEFAULT NOW(), + reputation_score DECIMAL(5,2) DEFAULT 0.00, + base_tx_hash TEXT, + erc8004_agent_id INTEGER, + wallet_address TEXT, + wallet_chain TEXT, + wallet_bound_at TIMESTAMP, + last_seen TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS ratings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + from_did TEXT NOT NULL, + to_did TEXT NOT NULL, + score INTEGER CHECK (score BETWEEN 1 AND 5), + context TEXT, + transaction_hash TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subject_did TEXT NOT NULL, + credential_type TEXT NOT NULL, + issuer TEXT NOT NULL, + issued_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP, + proof_value TEXT, + raw_vc TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS skills ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + developer_agent UUID, + security_score DECIMAL(3,2), + price_usdc DECIMAL(10,2), + price_sats BIGINT, + repo_url TEXT, + status TEXT DEFAULT 'pending', + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + buyer_agent UUID, + skill_id UUID, + amount DECIMAL(10,2), + currency TEXT CHECK (currency IN ('USDC', 'BTC')), + payment_hash TEXT, + status TEXT DEFAULT 'pending', + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key TEXT UNIQUE NOT NULL, + owner_did TEXT NOT NULL, + active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS credit_balances ( + agent_did TEXT PRIMARY KEY, + balance INTEGER DEFAULT 0, + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS usdc_deposits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tx_hash TEXT UNIQUE NOT NULL, + from_address TEXT, + to_did TEXT, + usdc_amount DECIMAL(20,6), + credits_granted INTEGER, + block_number BIGINT, + claimed_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS endorsements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + endorser_did TEXT NOT NULL, + endorsed_did TEXT NOT NULL, + skill TEXT, + evidence_hash TEXT, + vc_jwt TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS music_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + artist_did TEXT NOT NULL, + track_title TEXT, + track_hash TEXT, + credential_type TEXT DEFAULT 'MusicProvenanceCredential', + raw_vc TEXT, + anchor_tx TEXT, + anchor_block TEXT, + revoked BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW() ); diff --git a/requirements.txt b/requirements.txt index bf0ba4a..2ce8430 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ boto3>=1.34.0 jcs>=0.2.1 python-multipart>=0.0.9 starlette>=0.37.0 +APScheduler>=3.10.0