From 40802338e78405c15283a82c54b7d6118c6dc0e9 Mon Sep 17 00:00:00 2001 From: clawnchdev Date: Fri, 14 Aug 2026 18:56:50 -0400 Subject: [PATCH 1/4] feat(robinhood): add Robinhood Chain to chains/registry + launch surface - clawmes/lib/chains.py: 4663 (robinhood) + 46630 (robinhood-testnet) Chain entries. get_chain("robinhood") now resolves. - clawmes/services/rpc.py: default endpoints for Robinhood (4663) and Robinhood Testnet (46630); CLAWMES_RPC_4663 / CLAWMES_RPC_46630 env overrides work unchanged. - clawmes/tools/clawnch_launch.py: schema accepts `chain`; forwarded as `chain` query param via `prepare_deploy` when the caller selects the Robinhood path (Bags.fm) instead of the Base/Clanker custodial path. Chain is read off the API response (`data.chainId`) so emitted UI artifacts link to Blockscout rather than Basescan on Robinhood launches. - clawmes/services/clawnch.py: `prepare_deploy` takes + forwards `chain` as a query param; docstring documents the Robinhood branch semantics (no burn gate, Bags claimer array carries the 80/20 fee split instead of Clanker's rewards array). - clawmes/tools/clawnch_fees.py: docstring notes BagsFeeShare.claim() is the Robinhood claim surface (per-token), separate from Clanker's FeeLocker on Base. --- clawmes/lib/chains.py | 18 +++++++++ clawmes/services/clawnch.py | 33 +++++++++------- clawmes/services/rpc.py | 5 +++ clawmes/tools/clawnch_fees.py | 13 ++++--- clawmes/tools/clawnch_launch.py | 69 +++++++++++++++++++++++---------- 5 files changed, 100 insertions(+), 38 deletions(-) diff --git a/clawmes/lib/chains.py b/clawmes/lib/chains.py index 9b55636..5201f26 100644 --- a/clawmes/lib/chains.py +++ b/clawmes/lib/chains.py @@ -103,6 +103,24 @@ class Chain: block_explorer_url="https://blastscan.io", is_l2=True, ), + 4663: Chain( + chain_id=4663, + name="Robinhood Chain", + short_name="robinhood", + native_symbol="ETH", + native_decimals=18, + block_explorer_url="https://robinhoodchain.blockscout.com", + is_l2=True, + ), + 46630: Chain( + chain_id=46630, + name="Robinhood Chain Testnet", + short_name="robinhood-testnet", + native_symbol="ETH", + native_decimals=18, + block_explorer_url="https://robinhoodchain-testnet.blockscout.com", + is_l2=True, + ), } diff --git a/clawmes/services/clawnch.py b/clawmes/services/clawnch.py index d5ce778..f81eb0e 100644 --- a/clawmes/services/clawnch.py +++ b/clawmes/services/clawnch.py @@ -290,19 +290,25 @@ def prepare_deploy( farcaster: str | None = None, discord: str | None = None, burn_tx_hash: str | None = None, + chain: str | None = None, ) -> dict[str, Any]: - """Get unsigned Clanker factory calldata for a non-custodial deploy. + """Get unsigned factory calldata for a non-custodial deploy. - Hits ``GET /api/prepare/deploy``. Returns the envelope shape: + ``chain`` (``"base"`` or ``"robinhood"``) is forwarded as the + ``chain`` query param to ``GET /api/prepare/deploy``. The server + resolves it into Clanker-vs-Bags calldata. Default is Base. + + Returns the envelope shape: { "ok": True, - "data": {"to": "0xE85A…", "data": "0xdf40224a…", "value": "0x0", "chainId": 8453}, + "data": {"to": "0x…", "data": "0x…", "value": "0x…", "chainId": 8453}, "meta": { "platformFeeBps": 2000, "userFeeBps": 8000, - "vaultPercentage": 0, - "source": "base-mcp", + "vaultPercentage": 0, # 0 on the Robinhood path + "creationFeeWei": "0", # non-zero on Robinhood + "chain": "base" | "robinhood", ... }, } @@ -315,16 +321,15 @@ def prepare_deploy( - No API key required (``/api/prepare/deploy`` is public). - No captcha solving — the wallet signs the deploy tx directly. - The user's wallet pays gas. - - Same 20% platform fee preserved in the rewards array. + - Same 20% platform fee preserved in the rewards array (Base) + or the Bags claimer array (Robinhood). - ``burn_tx_hash`` is **mandatory upstream**: every launch + ``burn_tx_hash`` is **mandatory upstream on Base**. Every launch requires a verified 1,000,000+ $CLAWNCH burn from - ``from_address`` to the dead address within 24h. Calling - without one raises ``ClawnchError`` with - ``code="burn_required"`` (HTTP 402) whose ``meta`` carries - ``minBurnTokens`` + ``burnAddress``. A verified burn also sets - the vault clause baked into the returned calldata (1M = 1%, - up to 10M = 10%). + ``from_address`` to the dead address within 24h; the Robinhood + path doesn't enforce it (Bags doesn't burn). Calling without one + raises ``ClawnchError`` with ``code="burn_required"`` whose + ``meta`` carries ``minBurnTokens`` + ``burnAddress``. """ if not from_address: raise ClawnchError("bad_request", "from_address is required") @@ -354,6 +359,8 @@ def prepare_deploy( params["discord"] = discord if burn_tx_hash: params["burnTxHash"] = burn_tx_hash + if chain: + params["chain"] = chain.lower() # Public endpoint — no auth header sent. body = self._get("/api/prepare/deploy", params=params) diff --git a/clawmes/services/rpc.py b/clawmes/services/rpc.py index 76be2e2..1f4f87e 100644 --- a/clawmes/services/rpc.py +++ b/clawmes/services/rpc.py @@ -57,6 +57,11 @@ 10: "https://mainnet.optimism.io", # Polygon — official public RPC 137: "https://polygon-rpc.com", + # Robinhood Chain — official public RPC. Rate-limited; Alchemy / + # QuickNode / Chainstack / dRPC offer dedicated endpoints on 4663. + 4663: "https://rpc.mainnet.chain.robinhood.com", + # Robinhood Chain Testnet — official public RPC. + 46630: "https://rpc.testnet.chain.robinhood.com", } diff --git a/clawmes/tools/clawnch_fees.py b/clawmes/tools/clawnch_fees.py index f7d30ed..140e2a6 100644 --- a/clawmes/tools/clawnch_fees.py +++ b/clawmes/tools/clawnch_fees.py @@ -8,11 +8,14 @@ far). Uses Clawnch's ``/api/launches?address=…`` endpoint. Claim-side ops aren't implemented here today: Clanker pays creator -rewards via its own LP-fee accumulator (FeeLocker), not via a -launchpad-controlled "claim()" function. Users claim through their -Clanker dashboard or directly against the FeeLocker contract. Once -v2 ships (the ClawnchFactory fork that drops Clanker), we'll revisit -adding a launchpad-orchestrated claim action. +rewards via its own LP-fee accumulator (FeeLocker) on Base, and Bags +pays creator rewards via its own per-token ``BagsFeeShare`` ledger on +Robinhood Chain (the per-token ``claim()`` on the feeShare contract +returned by a Bags launch). Neither flows through Clawnch's HTTP claim +path — Clawnch only reads launch metadata. Users claim through their +Clanker dashboard (Base) or against the token's feeShare contract +(Robinhood). Once v2 ships (the ClawnchFactory fork that drops +Clanker), we'll revisit adding a launchpad-orchestrated claim action. Requires ``CLAWNCH_API_KEY`` for ``my_launches``; ``launch_info`` is public and works without a key. diff --git a/clawmes/tools/clawnch_launch.py b/clawmes/tools/clawnch_launch.py index 2c17602..3fe661a 100644 --- a/clawmes/tools/clawnch_launch.py +++ b/clawmes/tools/clawnch_launch.py @@ -151,14 +151,17 @@ def _normalize_social(value: str, base_url: str) -> str: name="clawnch_launch", toolset="clawmes-defi", description=( - "Deploy a token on Base via the Clawnch launchpad. Clawnch " - "handles the deploy + initial liquidity atomically via the " - "Clanker SDK; the user's wallet signs a captcha challenge to " - "prove identity. Every deploy requires burn_tx_hash — a " - "verified 1,000,000+ $CLAWNCH burn (use /burn to submit one). " - "Supports image + social metadata (twitter / website / " - "telegram / farcaster / discord). Requires CLAWNCH_API_KEY " - "(register an agent with /register_agent)." + "Deploy a token via the Clawnch launchpad. Defaults to Base " + "(via Clanker); use --chain robinhood to launch on Robinhood " + "Chain via Bags.fm. Clawnch handles the deploy + initial " + "liquidity atomically; the user's wallet signs a captcha " + "challenge to prove identity (custodial) or signs the deploy tx " + "directly (non-custodial). Every deploy on Base requires a " + "verified 1,000,000+ $CLAWNCH burn (use /burn to submit one); " + "the Robinhood path currently doesn't enforce a burn. Supports " + "image + social metadata (twitter / website / telegram / " + "farcaster / discord). Requires CLAWNCH_API_KEY (register an " + "agent with /register_agent)." ), schema=_SCHEMA, emoji="\U0001f31f", @@ -202,12 +205,32 @@ def _handle_deploy(args: dict[str, Any]) -> str: bypass = read_str(args, "bypass_tx_hash") or None burn = read_str(args, "burn_tx_hash") or None + # Classic non-custodial prepare path. The API routes chain based on a + # `chain` query param, which we pass through if the caller supplied it. + start_deploy_chain = read_str(args, "chain") or None + try: - result = get_clawnch_service().deploy( - token_params=token_params, - bypass_tx_hash=bypass, - burn_tx_hash=burn, - ) + if start_deploy_chain == "robinhood": + result = get_clawnch_service().prepare_deploy( + from_address=args.get("from_address") or "", + name=name, + symbol=symbol, + description=token_params.get("description"), + image=token_params.get("image"), + twitter=token_params.get("twitter"), + website=token_params.get("website"), + telegram=token_params.get("telegram"), + farcaster=token_params.get("farcaster"), + discord=token_params.get("discord"), + burn_tx_hash=burn, + chain="robinhood", + ) + else: + result = get_clawnch_service().deploy( + token_params=token_params, + bypass_tx_hash=bypass, + burn_tx_hash=burn, + ) except ClawnchError as exc: if exc.code == "burn_required": meta = exc.meta or {} @@ -225,15 +248,21 @@ def _handle_deploy(args: dict[str, Any]) -> str: tx_hash = result.get("txHash") or result.get("tx_hash") token_address = result.get("tokenAddress") or result.get("token_address") - # Desktop UI: Clawnch launches are Base-only, so surface the tx explorer - # link plus Clanker / DexScreener / token-explorer links for the brand-new - # token as clickable Link artifacts. The enrich helpers no-op on missing or - # malformed values, so we call them unconditionally (passive descriptive - # keys — no preview auto-open from the tool itself). + + # Surface the actual chain from the launch response. The clawn.ch API + # returns chainId in `data.chainId` (prepare path) or the deploy + # metadata; default to Base when absent so historical Base launches + # stay Base-tagged. + chain_id = int(result.get("chainId") or result.get("chain_id") or 8453) + + # Desktop UI: surface the tx explorer link + token links for the + # brand-new token as clickable Link artifacts. Passive descriptive + # keys right now — no preview auto-open. enrich helpers no-op on + # missing values. from clawmes.lib.ui_artifacts import enrich_token_links, enrich_tx_links - enrich_tx_links(result, tx_hash=tx_hash or "", chain_id=8453) - enrich_token_links(result, token=token_address or "", chain_id=8453) + enrich_tx_links(result, tx_hash=tx_hash or "", chain_id=chain_id) + enrich_token_links(result, token=token_address or "", chain_id=chain_id) # Desktop UI: render a launch-receipt card and surface its path at the # envelope top level (json_result ``preview=``) so the desktop opens it in From 81c3cce67e9d364ca366a3b2a7a0f880ad411d66 Mon Sep 17 00:00:00 2001 From: clawnchdev Date: Wed, 16 Sep 2026 23:57:52 -0400 Subject: [PATCH 2/4] feat(robinhood): port the plugin's chain surfaces to Robinhood Chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allowlist: unblock the plugin's own RHC hosts (rpc mainnet/testnet, blockscout x2, bags.fm) + startup self-check so a blocked shipped default is logged at start, not at first use - clawnch service: RHC launch-router flows — rh_ticket / rh_confirm_launch / rh_deposit_launch, rh_claimable / rh_claim, rh_launches, RHC $CLAWNCH info and link helpers; tickets validated as chainId 4663; Base-only surfaces (deploy, prepare_deploy) raise unsupported_chain instead of silently hitting the wrong chain - tools: clawnch_launch rh_ticket/rh_confirm/rh_deposit/rh_token and clawnch_fees rh_launches/rh_claimable/rh_claim; RHC-aware receipt cards - tests: +112 (5028 pass, 8 skipped, 100% coverage; mypy back at the pristine 113-error baseline) --- CHANGELOG.md | 54 ++ clawmes/lib/http.py | 17 + clawmes/lib/ui_artifacts.py | 21 +- clawmes/services/clawnch.py | 591 +++++++++++++++++- clawmes/services/endpoint_allowlist.py | 38 ++ clawmes/services/explorer.py | 35 +- clawmes/services/rpc.py | 77 +++ clawmes/tools/clawnch_fees.py | 216 +++++-- clawmes/tools/clawnch_launch.py | 320 ++++++++-- tests/cli/test_doctor_cmd.py | 7 +- tests/lib/test_chains.py | 14 + tests/lib/test_http.py | 11 + tests/lib/test_ui_artifacts.py | 28 + tests/services/test_clawnch.py | 690 ++++++++++++++++++++++ tests/services/test_endpoint_allowlist.py | 46 ++ tests/services/test_explorer.py | 13 + tests/services/test_rpc.py | 65 +- tests/services/test_token_decimals.py | 10 + tests/tools/test_clawnch_fees.py | 168 ++++++ tests/tools/test_clawnch_launch.py | 289 +++++++++ 20 files changed, 2599 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e31e15e..69c0a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,60 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Fixed — Robinhood Chain was blocked by clawmes' own network allowlist + +The RHC RPC defaults shipped in `services/rpc.py` (4663 / 46630) were never +added to `lib/http._DEFAULT_ALLOWLIST`, so every RHC call raised +`NetworkAllowlistError` before leaving the process — the plugin blocked its own +endpoints. Fixes: + +- `lib/http.py` — allowlist now carries the RHC hosts: both official RPCs + (`rpc.mainnet.chain.robinhood.com`, `rpc.testnet.chain.robinhood.com`), the + Blockscout explorers (mainnet + testnet) and the `bags.fm` trade surface. +- `services/endpoint_allowlist.py` — new `blocked_hosts()` diagnostic: returns + the hosts no allowlist layer permits (defaults ∪ session set). +- `services/rpc.py` — startup self-check (`blocked_default_endpoints()` / + `blocked_user_endpoints()`) so a shipped default that the allowlist blocks is + logged loudly at start instead of failing at first use. User-configured + overrides on non-allowlisted hosts now warn with the `/allow` remediation. + +### Added — Robinhood Chain launch surface in the Clawnch service + +`services/clawnch.py` now covers the RHC launch router (Bags.fm), not just the +Base/Clanker HTTP flow: + +- `rh_ticket()` — `POST /api/robinhood/ticket`: unsigned `launch()` tx + + EIP-712 ticket bound to the registered agent wallet. Refuses to return a + ticket whose `chainId`/`meta.chain` isn't RHC. +- `rh_confirm_launch()` / `rh_deposit_launch()` — `POST /api/robinhood/launch` + `mode="confirm"` (record a ticket-path launch) / `mode="deposit"` (launch + from a verified ETH deposit). +- `rh_claimable()` / `rh_claim()` — `GET/POST /api/robinhood/claim`: claimable + fees + the unsigned `BagsFeeShare.claim(true)` tx. A 403 `not_claimer` is no + longer flattened into `no_credentials`. +- `rh_launches()` — `GET /api/robinhood/launches`, rows decorated with + bags.fm + Blockscout links. +- RHC-aware links (`rh_trade_url` / `rh_explorer_token_url` / + `rh_explorer_tx_url`), `lib/ui_artifacts.bags_url()` on chain 4663, and + `rh_token_info()` / `get_burn_config(chain="robinhood")` for the RHC $CLAWNCH + token (`0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA` — no burn on this chain). + +### Changed — no silent wrong-chain fallbacks + +- `deploy(chain="robinhood")` and `prepare_deploy(chain="robinhood")` raise + `unsupported_chain` (the clawn.ch server routes `/api/prepare/deploy` by its + own env, ignoring the `chain` query param — a Robinhood request could have + come back as Base calldata). RHC launches must use the router actions. +- `tools/clawnch_launch.py` — new `rh_ticket` / `rh_confirm` / `rh_deposit` / + `rh_token` actions; `chain` / `from_address` / `tx_hash` / `deposit_tx_hash` + are now declared in the schema (previously read by the handler but absent from + the schema); the launch chain id is read from `data.chainId`/`meta.chain` + instead of defaulting every response to Base. +- `tools/clawnch_fees.py` — new `rh_launches` / `rh_claimable` / `rh_claim` + actions. +- `services/explorer.py` — RHC (4663/46630) now fails with an explicit + Blockscout-is-not-Etherscan message instead of a generic unknown-chain error. + ## 0.20.0 — 2026-07-06 ### Added — EIP-7710 / EIP-7715 on-chain delegation diff --git a/clawmes/lib/http.py b/clawmes/lib/http.py index 080eaf5..15df421 100644 --- a/clawmes/lib/http.py +++ b/clawmes/lib/http.py @@ -50,6 +50,15 @@ "arb1.arbitrum.io", "mainnet.optimism.io", "polygon-rpc.com", + # Robinhood Chain (Arbitrum Orbit L2) — official public RPCs, both + # mainnet (4663) and testnet (46630). These match the defaults in + # ``clawmes.services.rpc._DEFAULT_ENDPOINTS``: a default endpoint that + # isn't allowlisted here is a self-inflicted outage — every call is + # rejected by our own allowlist before it leaves the process. + # ``RpcService.blocked_default_endpoints()`` fails loudly if the two + # lists ever drift apart again. + "rpc.mainnet.chain.robinhood.com", + "rpc.testnet.chain.robinhood.com", # Price feeds + market data "api.coingecko.com", "api.dexscreener.com", @@ -61,6 +70,14 @@ "api.arbiscan.io", "api.optimistic.etherscan.io", "api.polygonscan.com", + # Robinhood Chain block explorer (Blockscout family). Used for the + # explorer links emitted on RHC launches; allowlisting it also keeps + # read-only Blockscout API calls unblocked if a surface adds them. + "robinhoodchain.blockscout.com", + "robinhoodchain-testnet.blockscout.com", + # Bags.fm — the Robinhood Chain launchpad + trade surface + # (token pages + the RHC token-metadata host clawn.ch builds on). + "bags.fm", # Lending / yield "aave-api-v3.aave.com", "api.lido.fi", diff --git a/clawmes/lib/ui_artifacts.py b/clawmes/lib/ui_artifacts.py index 63c9d5f..1afa87b 100644 --- a/clawmes/lib/ui_artifacts.py +++ b/clawmes/lib/ui_artifacts.py @@ -50,6 +50,11 @@ _CLANKER_BASE_URL = "https://clanker.world/clanker" _DEXSCREENER_BASE_URL = "https://dexscreener.com" +# Bags.fm is the Robinhood Chain launchpad + trade surface (the RHC +# counterpart of Clanker's token page). +_BAGS_CHAIN_ID = 4663 +_BAGS_TOKEN_URL_BASE = "https://bags.fm/token" + def _is_tx_hash(value: str) -> bool: """True for a ``0x`` + 64 hex-char transaction hash.""" @@ -114,6 +119,13 @@ def clanker_url(token: str, chain_id: int = _CLANKER_CHAIN_ID) -> str | None: return f"{_CLANKER_BASE_URL}/{token}" +def bags_url(token: str, chain_id: int = _BAGS_CHAIN_ID) -> str | None: + """Bags.fm token page URL (Robinhood Chain only), or None otherwise.""" + if chain_id != _BAGS_CHAIN_ID or not _is_address(token): + return None + return f"{_BAGS_TOKEN_URL_BASE}/{token}" + + def enrich_tx_links(details: dict[str, Any], *, tx_hash: str, chain_id: int) -> dict[str, Any]: """Add an ``explorer_url`` for ``tx_hash`` to ``details`` (in place). @@ -136,8 +148,9 @@ def enrich_token_links( ) -> dict[str, Any]: """Add market/explorer links for a token to ``details`` (in place). - Adds ``dexscreener_url`` and ``token_explorer_url`` (and ``clanker_url`` on - Base when ``include_clanker``). Existing keys are preserved. Returns + Adds ``dexscreener_url`` and ``token_explorer_url`` (plus + ``clanker_url`` on Base — and ``bags_url`` on Robinhood Chain — when + ``include_clanker``). Existing keys are preserved. Returns ``details`` for chaining. """ dex = dexscreener_url(token, chain_id) @@ -153,4 +166,8 @@ def enrich_token_links( if clank and "clanker_url" not in details: details["clanker_url"] = clank + bags = bags_url(token, chain_id) + if bags and "bags_url" not in details: + details["bags_url"] = bags + return details diff --git a/clawmes/services/clawnch.py b/clawmes/services/clawnch.py index f81eb0e..d918307 100644 --- a/clawmes/services/clawnch.py +++ b/clawmes/services/clawnch.py @@ -28,10 +28,28 @@ HTTP API per ``clawncher/migration-v2.md``; clawmes stays valid through the swap. +Robinhood Chain (chain id 4663) is the second launch surface. The +Clanker path does not exist there; launches run through Bags.fm behind +Clawnch's launch router: + + * ``POST /api/robinhood/ticket`` — non-custodial: returns an unsigned + ``launch()`` tx (agent pays the Bags creation fee + gas) plus the + EIP-712 ticket that proves Clawnch-agent provenance. + See :meth:`ClawnchService.rh_ticket`. + * ``POST /api/robinhood/launch`` — record the launch + (``mode="confirm"``) or run it server-side from a verified ETH + deposit (``mode="deposit"``). See :meth:`rh_confirm_launch` / + :meth:`rh_deposit_launch`. + * ``GET/POST /api/robinhood/claim`` — claimable fees + an unsigned + ``BagsFeeShare.claim(true)`` tx. See :meth:`rh_claimable` / + :meth:`rh_claim`. + * ``GET /api/robinhood/launches`` — the RHC launch feed. + Auth: ``CLAWNCH_API_KEY`` env var. Issued by clawn.ch via the two-step register flow. Unauthenticated calls are rejected by the launchpad, so the service refuses to start premium ops until the key is present. -Reads (``get_launches``) work without a key. +Reads (``get_launches``, ``rh_launches``, ``rh_claimable``) work +without a key. """ from __future__ import annotations @@ -40,6 +58,7 @@ import threading from typing import Any +from clawmes.lib.addr import is_hex_address from clawmes.lib.http import http_get, http_post from clawmes.lib.logger import logger_for from clawmes.services._base import Service @@ -58,6 +77,74 @@ #: Public attribution — observers can count clawmes-sourced launches. _DEPLOY_SOURCE_TAG = "clawmes" +#: Chain id of Robinhood Chain — the Bags-powered launch surface. +RH_CHAIN_ID = 4663 + +#: $CLAWNCH ERC-20 on Robinhood Chain. Distinct from the Base token +#: (``0xa1F724…747be``) — the RHC deployment is itself a Bags.fm token. +#: Override via ``CLAWNCH_RH_TOKEN_ADDRESS`` for staging. +RH_CLAWNCH_TOKEN_DEFAULT = "0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA" + +#: Canonical RHC link bases — Blockscout explorer + Bags.fm trade pages. +RH_EXPLORER_BASE_URL = "https://robinhoodchain.blockscout.com" +RH_TRADE_BASE_URL = "https://bags.fm/token" + +#: Minimum ETH deposit for the deposit launch path. Mirrors +#: ``DEPOSIT_MIN_WEI`` in clawn.ch's ``api/lib/launch-router.ts``; the live +#: Bags ``creationFee`` can raise the effective floor above this. +RH_DEPOSIT_MIN_WEI = 20_000_000_000_000_000 + +#: Name / symbol caps enforced by the RHC ticket + deposit endpoints +#: (narrower than the Base prepare path's 64 / 16). +_RH_NAME_MAX = 32 +_RH_SYMBOL_MAX = 10 + +#: RHC upstream codes that pass through verbatim (already actionable) and +#: win over the generic HTTP-status classification in ``_reclassify`` — +#: a 403 ``not_claimer`` must not surface as ``no_credentials``. +_RH_PASSTHROUGH_CODES = frozenset( + { + "wallet_mismatch", + "not_claimer", + "no_fee_share", + "duplicate_deposit", + "deposit_invalid", + "deposit_launch_failed", + "tx_failed", + "not_agentic_launch", + "wrong_mode", + } +) + + +def is_tx_hash(value: Any) -> bool: + """True for a ``0x`` + 64 hex-char transaction hash.""" + if not isinstance(value, str) or not value.startswith("0x"): + return False + body = value[2:] + return len(body) == 64 and all(c in "0123456789abcdefABCDEF" for c in body) + + +def rh_trade_url(token: str) -> str | None: + """Bags.fm trade page for a Robinhood Chain token, or None if invalid.""" + if not isinstance(token, str) or not is_hex_address(token): + return None + return f"{RH_TRADE_BASE_URL}/{token}" + + +def rh_explorer_token_url(token: str) -> str | None: + """Blockscout token page (Robinhood Chain), or None if invalid.""" + if not isinstance(token, str) or not is_hex_address(token): + return None + return f"{RH_EXPLORER_BASE_URL}/token/{token}" + + +def rh_explorer_tx_url(tx_hash: str) -> str | None: + """Blockscout tx page (Robinhood Chain), or None if invalid.""" + if not is_tx_hash(tx_hash): + return None + return f"{RH_EXPLORER_BASE_URL}/tx/{tx_hash}" + class ClawnchError(RuntimeError): """Raised on Clawnch API failures. @@ -74,7 +161,16 @@ class ClawnchError(RuntimeError): * ``challenge_expired`` — captcha not solved within the 5s window (HTTP 408). * ``not_found`` — launch / agent / challenge not found (HTTP 404). + * ``unsupported_chain`` — the requested operation doesn't exist on + the requested chain (e.g. a Base-only deploy path asked to run + on Robinhood Chain). * ``api_error`` — generic upstream failure. + + Robinhood-chain responses carry a few extra caller-actionable codes + through verbatim (``wallet_mismatch``, ``not_claimer``, + ``no_fee_share``, ``duplicate_deposit``, ``deposit_invalid``, + ``tx_failed``, ``not_agentic_launch``, ``tx_not_found``) — see + :func:`ClawnchError._from_rh_body`. """ def __init__(self, code: str, message: str, *, meta: dict[str, Any] | None = None) -> None: @@ -83,6 +179,45 @@ def __init__(self, code: str, message: str, *, meta: dict[str, Any] | None = Non self.message = message self.meta: dict[str, Any] = meta or {} + #: Upstream RHC ``code`` → clawmes classification. Codes not listed + #: pass through verbatim (they're already actionable): e.g. + #: ``not_claimer``, ``no_fee_share``, ``duplicate_deposit``. + _RH_CODE_MAP: dict[str, str] = { + "unauthorized": "no_credentials", + "invalid_wallet": "bad_request", + "invalid_name": "bad_request", + "invalid_symbol": "bad_request", + "invalid_fee_recipient": "bad_request", + "invalid_tx_hash": "bad_request", + "invalid_token": "bad_request", + "invalid_address": "bad_request", + "invalid_agent": "bad_request", + "invalid_mode": "bad_request", + "missing_required": "bad_request", + "rate_limited": "rate_limited", + "tx_not_found": "not_found", + # Server-side or transient problems — the caller can't fix these + # by changing the request. + "misconfigured": "api_error", + "store_unavailable": "api_error", + "ticket_error": "api_error", + "launch_error": "api_error", + "claim_error": "api_error", + "launches_error": "api_error", + } + + @classmethod + def _from_rh_body(cls, body: dict[str, Any]) -> ClawnchError: + """Build a ClawnchError from an ``{ok: false, error, code}`` body.""" + code = str(body.get("code") or "api_error") + message = str(body.get("error") or "Clawnch Robinhood request failed") + meta = body.get("meta") + return cls( + cls._RH_CODE_MAP.get(code, code), + message, + meta=meta if isinstance(meta, dict) else {}, + ) + class ClawnchService(Service): """Singleton HTTP client for the Clawnch launchpad.""" @@ -254,6 +389,7 @@ def deploy( token_params: dict[str, Any], bypass_tx_hash: str | None = None, burn_tx_hash: str | None = None, + chain: str = "base", ) -> dict[str, Any]: """End-to-end deploy convenience: challenge -> solve -> confirm. @@ -261,7 +397,28 @@ def deploy( with a classified ``code`` on any step failure. ``burn_tx_hash`` claims a vault allocation; ``bypass_tx_hash`` skips the 24h cooldown — they're independent and can both be supplied. + + **Base only.** This is the custodial Clanker path (captcha + + server-side deployer). Robinhood Chain has no Clanker deployment: + launches there run through the launch-router ticket / deposit + flow (:meth:`rh_ticket` → :meth:`rh_confirm_launch`, or + :meth:`rh_deposit_launch`). Asking for ``chain="robinhood"`` + here raises ``unsupported_chain`` rather than silently deploying + through the Base path. """ + if self._is_rh_chain(chain): + raise ClawnchError( + "unsupported_chain", + "The custodial deploy path is Base-only (Clanker). Robinhood " + "Chain launches go through the Bags launch router: get an " + "unsigned launch tx with rh_ticket (ticket path) or deposit " + "ETH and call rh_deposit_launch (deposit path).", + ) + if chain and chain.strip().lower() not in ("base", "8453"): + raise ClawnchError( + "bad_request", + f"unknown chain {chain!r} — expected 'base' or 'robinhood'", + ) challenge = self.start_deploy( token_params=token_params, bypass_tx_hash=bypass_tx_hash, @@ -294,9 +451,17 @@ def prepare_deploy( ) -> dict[str, Any]: """Get unsigned factory calldata for a non-custodial deploy. - ``chain`` (``"base"`` or ``"robinhood"``) is forwarded as the - ``chain`` query param to ``GET /api/prepare/deploy``. The server - resolves it into Clanker-vs-Bags calldata. Default is Base. + ``chain`` selects the launch surface. Pass ``None`` (default) or + ``"base"`` for the Base/Clanker path. + + **Robinhood Chain raises ``unsupported_chain``.** RHC launches + run through the launch router (``POST /api/robinhood/ticket`` / + ``POST /api/robinhood/launch``) — ``/api/prepare/deploy`` serves + the Base path only and ignores a ``chain`` query param (the + clawn.ch server routes by its own deployment env), so honoring a + robinhood request here could hand back Base calldata. Use + :meth:`rh_ticket` / :meth:`rh_confirm_launch` / + :meth:`rh_deposit_launch` instead. Returns the envelope shape: @@ -306,9 +471,8 @@ def prepare_deploy( "meta": { "platformFeeBps": 2000, "userFeeBps": 8000, - "vaultPercentage": 0, # 0 on the Robinhood path - "creationFeeWei": "0", # non-zero on Robinhood - "chain": "base" | "robinhood", + "vaultPercentage": 0, + "chain": "base", ... }, } @@ -321,14 +485,12 @@ def prepare_deploy( - No API key required (``/api/prepare/deploy`` is public). - No captcha solving — the wallet signs the deploy tx directly. - The user's wallet pays gas. - - Same 20% platform fee preserved in the rewards array (Base) - or the Bags claimer array (Robinhood). + - Same 20% platform fee preserved in the rewards array. ``burn_tx_hash`` is **mandatory upstream on Base**. Every launch requires a verified 1,000,000+ $CLAWNCH burn from - ``from_address`` to the dead address within 24h; the Robinhood - path doesn't enforce it (Bags doesn't burn). Calling without one - raises ``ClawnchError`` with ``code="burn_required"`` whose + ``from_address`` to the dead address within 24h. Calling without + one raises ``ClawnchError`` with ``code="burn_required"`` whose ``meta`` carries ``minBurnTokens`` + ``burnAddress``. """ if not from_address: @@ -337,6 +499,22 @@ def prepare_deploy( raise ClawnchError("bad_request", "name is required") if not symbol: raise ClawnchError("bad_request", "symbol is required") + if self._is_rh_chain(chain): + raise ClawnchError( + "unsupported_chain", + "prepare_deploy is Base-only: /api/prepare/deploy does not " + "route by the 'chain' query param (clawn.ch decides the " + "backend server-side), so a Robinhood request could come " + "back as Base calldata. Robinhood Chain launches use the " + "launch router instead — POST /api/robinhood/ticket " + "(rh_ticket) or POST /api/robinhood/launch " + "(rh_confirm_launch / rh_deposit_launch).", + ) + if chain and chain.strip().lower() not in ("base", "8453"): + raise ClawnchError( + "bad_request", + f"unknown chain {chain!r} — expected 'base' or 'robinhood'", + ) params: dict[str, str] = { "from": from_address, @@ -359,8 +537,6 @@ def prepare_deploy( params["discord"] = discord if burn_tx_hash: params["burnTxHash"] = burn_tx_hash - if chain: - params["chain"] = chain.lower() # Public endpoint — no auth header sent. body = self._get("/api/prepare/deploy", params=params) @@ -424,26 +600,61 @@ def get_bypass_recipient(self) -> dict[str, Any]: "fee_eth": os.environ.get("CLAWNCH_BYPASS_FEE_ETH", "0.005"), } - def get_burn_config(self) -> dict[str, Any]: + def get_burn_config(self, *, chain: str = "base") -> dict[str, Any]: """Return the $CLAWNCH burn config used by ``/burn`` + ``/launch burn``. - Returns the token address (the CLAWNCH ERC-20), the burn - address (dead address — 0x…dEaD), and the minimum burn amount - in whole tokens. The frontend uses these to construct a - ``transfer(burn_address, amount * 1e18)`` calldata that the - active wallet signs. - - The minimum burn is **required for every launch** (deploys - without a verified burn are rejected with ``burn_required``); - it doubles as the vault claim (1M = 1% vault, up to 10M = 10%). + ``chain`` selects the launch surface: + + * ``"base"`` (default) — Clanker path. Returns the token + address (the CLAWNCH ERC-20), the burn address (dead + address — 0x…dEaD), and the minimum burn amount in whole + tokens. The frontend uses these to construct a + ``transfer(burn_address, amount * 1e18)`` calldata that the + active wallet signs. + * ``"robinhood"`` — Bags path. **No burn** (Bags doesn't + burn); launches pay a live ETH creation fee instead, so + ``burn_address`` is ``None`` and ``min_burn_tokens`` is 0. + Returns the RHC CLAWNCH token + (``0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA``) — a + *different* deployment from the Base one. + + Base semantics: the minimum burn is **required for every + launch** (deploys without a verified burn are rejected with + ``burn_required``); it doubles as the vault claim (1M = 1% + vault, up to 10M = 10%). Stable values today (override via env for staging): - * ``CLAWNCH_TOKEN_ADDRESS`` — default ``0xa1F724…747be`` - * ``CLAWNCH_BURN_ADDRESS`` — default ``0x000…dEaD`` - * ``CLAWNCH_MIN_BURN_TOKENS`` — default ``1_000_000`` (launch minimum, 1% vault) + * ``CLAWNCH_TOKEN_ADDRESS`` — default ``0xa1F724…747be`` (Base) + * ``CLAWNCH_BURN_ADDRESS`` — default ``0x000…dEaD`` (Base) + * ``CLAWNCH_MIN_BURN_TOKENS`` — default ``1_000_000`` (Base) + * ``CLAWNCH_RH_TOKEN_ADDRESS`` — default ``0x6a50F1…EBbeA`` (RHC) """ + key = (chain or "base").strip().lower() + if key in ("robinhood", "rh", "4663", "robinhood-chain"): + return { + "chain": "robinhood", + "chain_id": RH_CHAIN_ID, + "token_address": os.environ.get( + "CLAWNCH_RH_TOKEN_ADDRESS", RH_CLAWNCH_TOKEN_DEFAULT + ), + "burn_address": None, + "min_burn_tokens": 0, + "burn_required": False, + "note": ( + "Robinhood Chain launches (Bags.fm) do not burn $CLAWNCH. " + "The launch cost is the live Bags creation fee " + "(~0.02 ETH, read from the factory at launch time)." + ), + } + if key not in ("base", "8453"): + raise ClawnchError( + "bad_request", + f"unknown chain {chain!r} — expected 'base' or 'robinhood'", + ) return { + "chain": "base", + "chain_id": 8453, "token_address": os.environ.get( "CLAWNCH_TOKEN_ADDRESS", "0xa1F72459dfA10BAD200Ac160eCd78C6b77a747be", @@ -453,8 +664,326 @@ def get_burn_config(self) -> dict[str, Any]: "0x000000000000000000000000000000000000dEaD", ), "min_burn_tokens": int(os.environ.get("CLAWNCH_MIN_BURN_TOKENS", "1000000")), + "burn_required": True, + } + + def rh_token_info(self) -> dict[str, Any]: + """$CLAWNCH on Robinhood Chain: address + trade / explorer links. + + The RHC deployment (``0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA``) + is distinct from the Base token; use this to render per-chain + token cards without guessing which deployment is live where. + """ + token = os.environ.get("CLAWNCH_RH_TOKEN_ADDRESS", RH_CLAWNCH_TOKEN_DEFAULT) + return { + "chain": "robinhood", + "chain_id": RH_CHAIN_ID, + "symbol": "CLAWNCH", + "token_address": token, + "trade_url": rh_trade_url(token), + "explorer_url": rh_explorer_token_url(token), } + # ── Robinhood Chain: launch ticket (non-custodial) ────────────── + + #: Chain ids/short names accepted where a caller names an RHC chain. + _RH_CHAIN_KEYS = frozenset({"robinhood", "rh", "4663", "robinhood-chain"}) + + @classmethod + def _is_rh_chain(cls, chain: str | None) -> bool: + if not chain: + return False + return chain.strip().lower() in cls._RH_CHAIN_KEYS + + @staticmethod + def _require_address(value: str, field: str) -> str: + if not value or not is_hex_address(value): + raise ClawnchError("bad_request", f"{field} must be a 0x… address") + return value + + @classmethod + def _validate_rh_token_fields(cls, name: str, symbol: str) -> None: + if not name: + raise ClawnchError("bad_request", "name is required") + if not symbol: + raise ClawnchError("bad_request", "symbol is required") + if len(name) > _RH_NAME_MAX: + raise ClawnchError("bad_request", f"name too long (max {_RH_NAME_MAX} chars)") + if len(symbol) > _RH_SYMBOL_MAX: + raise ClawnchError("bad_request", f"symbol too long (max {_RH_SYMBOL_MAX} chars)") + + def rh_ticket( + self, + *, + agent_wallet: str, + name: str, + symbol: str, + description: str | None = None, + image: str | None = None, + fee_recipient: str | None = None, + ) -> dict[str, Any]: + """Issue a Robinhood Chain launch ticket. + + POST ``/api/robinhood/ticket`` (Bearer auth required — the + ticket is bound to the registered agent wallet, which must also + be ``agent_wallet``). Returns the upstream envelope:: + + { + "ok": True, + "data": {"to": "0x…", "data": "0x…", "value": "0x…", "chainId": 4663}, + "ticket": {"agent", "feeRecipient", "paramsHash", "nonce", + "deadline", "signature"}, + "meta": {"backend": "bags", "chain": "robinhood", + "router", "depositAddress", "creationFeeWei", ...}, + } + + The agent signs and sends ``data`` to ``to`` with ``value`` from + its own wallet (the Bags creation fee + gas), then records the + launch with :meth:`rh_confirm_launch`. + + Refuses to return a ticket whose ``chainId`` is not 4663 — a + wrong-chain ticket would otherwise be signed and broadcast on + the wrong network. + """ + self._require_key() + wallet = self._require_address(agent_wallet, "agent_wallet") + self._validate_rh_token_fields(name, symbol) + if fee_recipient: + self._require_address(fee_recipient, "fee_recipient") + body: dict[str, Any] = {"agentWallet": wallet, "name": name, "symbol": symbol} + if description: + body["description"] = description + if image: + body["image"] = image + if fee_recipient: + body["feeRecipient"] = fee_recipient + resp = self._rh_post("/api/robinhood/ticket", body) + self._assert_rh_chain(resp) + return resp + + def rh_confirm_launch(self, *, tx_hash: str) -> dict[str, Any]: + """Record a ticket-path launch after the agent broadcast it. + + POST ``/api/robinhood/launch`` with ``mode="confirm"`` (Bearer + auth required). The server verifies the tx receipt on Robinhood + Chain, parses the router's ``AgenticLaunch`` + ``TokenCreated`` + events, and stores the launch. Returns ``{ok, launch}`` (or + ``{ok, alreadyRecorded: True, launch}`` on a replay). + + ``tx_hash`` is the hash of the transaction the agent sent from + its own wallet — not the deposit path (see + :meth:`rh_deposit_launch`). + """ + self._require_key() + if not is_tx_hash(tx_hash): + raise ClawnchError("bad_request", "tx_hash must be a 0x + 64 hex tx hash") + resp = self._rh_post("/api/robinhood/launch", {"mode": "confirm", "txHash": tx_hash}) + return resp + + def rh_deposit_launch( + self, + *, + deposit_tx_hash: str, + agent_wallet: str, + name: str, + symbol: str, + description: str | None = None, + image: str | None = None, + ) -> dict[str, Any]: + """Launch via the deposit path: the deposit is already on-chain. + + POST ``/api/robinhood/launch`` with ``mode="deposit"`` (Bearer + auth required). The agent first sends a plain ETH transfer to + the router's deposit address (returned as ``meta.depositAddress`` + by :meth:`rh_ticket`, minimum ``RH_DEPOSIT_MIN_WEI`` or the live + Bags creation fee, whichever is larger); Clawnch then deploys + through Bags with the agent as sole fee claimer and registers + provenance. + + ``deposit_tx_hash`` must be the plain transfer from + ``agent_wallet``; the server verifies sender, recipient, value, + age, and single-use before deploying. Returns ``{ok, launch}``. + """ + self._require_key() + wallet = self._require_address(agent_wallet, "agent_wallet") + self._validate_rh_token_fields(name, symbol) + if not is_tx_hash(deposit_tx_hash): + raise ClawnchError("bad_request", "deposit_tx_hash must be a 0x + 64 hex tx hash") + body: dict[str, Any] = { + "mode": "deposit", + "depositTxHash": deposit_tx_hash, + "agentWallet": wallet, + "name": name, + "symbol": symbol, + } + if description: + body["description"] = description + if image: + body["image"] = image + # The deposit sender must also be the fee recipient (anti-hijack + # rule server-side) — the agent wallet plays both roles here. + resp = self._rh_post("/api/robinhood/launch", body) + return resp + + # ── Robinhood Chain: fee claims ───────────────────────────────── + + def rh_claimable(self, *, token: str, address: str) -> dict[str, Any]: + """Read a wallet's claimable RHC fees for a token (public). + + GET ``/api/robinhood/claim?token=…&address=…``. Returns the + token's ``BagsFeeShare`` address, whether ``address`` is one of + its claimers (with the claimer ``bps``), the claimable amount in + wei, and — when the address is a claimer — an unsigned + ``BagsFeeShare.claim(true)`` tx under ``claim``. + """ + token_addr = self._require_address(token, "token") + wallet = self._require_address(address, "address") + return self._rh_get("/api/robinhood/claim", params={"token": token_addr, "address": wallet}) + + def rh_claim(self, *, token: str) -> dict[str, Any]: + """Unsigned claim tx for the agent's own accrued RHC fees. + + POST ``/api/robinhood/claim`` (Bearer auth required). Fees + accrue in WETH inside the token's ``BagsFeeShare`` and are + claimed by the claimer itself; Clawnch never claims for the + agent. Returns ``{ok, ready, claimableWei, claim: {to, data, + value, chainId}, meta}`` — the agent signs and sends ``claim`` + from its registered wallet to receive native ETH. + + ``ready`` is ``False`` with ``claim: None`` when there is + nothing claimable yet (not an error). Raises ``not_claimer`` + when the registered wallet isn't a claimer for the token. + """ + self._require_key() + token_addr = self._require_address(token, "token") + resp = self._rh_post("/api/robinhood/claim", {"token": token_addr}) + claim = resp.get("claim") + if isinstance(claim, dict) and claim.get("chainId") not in (None, RH_CHAIN_ID): + raise ClawnchError( + "api_error", + f"claim tx targets chain {claim.get('chainId')}, expected {RH_CHAIN_ID} " + "(Robinhood Chain) — refusing to hand back a wrong-chain tx", + ) + return resp + + # ── Robinhood Chain: launch feed ──────────────────────────────── + + def rh_launches( + self, + *, + agent: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + """Read the Robinhood Chain launch feed (public, newest first). + + GET ``/api/robinhood/launches``. Optionally filtered to one + agent wallet. Covers both ticket and deposit-path launches; + every row is decorated with ``trade_url`` (bags.fm) and + ``explorer_url`` / ``tx_url`` / ``router_tx_url`` (Blockscout) + so callers don't rebuild links per surface. + """ + if agent is not None and agent != "": + self._require_address(agent, "agent") + try: + limit_n = int(limit) + offset_n = int(offset) + except (TypeError, ValueError) as exc: + raise ClawnchError("bad_request", "limit and offset must be integers") from exc + if limit_n < 1: + raise ClawnchError("bad_request", "limit must be >= 1") + if offset_n < 0: + raise ClawnchError("bad_request", "offset must be >= 0") + params: dict[str, str] = { + "limit": str(min(limit_n, 200)), + "offset": str(offset_n), + } + if agent: + params["agent"] = agent + resp = self._rh_get("/api/robinhood/launches", params=params) + launches = resp.get("launches") + if isinstance(launches, list): + resp["launches"] = [ + self._decorate_rh_launch(row) for row in launches if isinstance(row, dict) + ] + return resp + + @staticmethod + def _decorate_rh_launch(row: dict[str, Any]) -> dict[str, Any]: + """Add canonical RHC links to a launch-feed row (in place). + + Upstream already emits ``tradeUrl`` / ``explorerUrl`` / ``txUrl`` + camelCase variants; we add snake_case duplicates only when + absent so every clawmes surface reads one spelling. Values that + can't be built (malformed token / hash) are skipped rather than + emitted as ``null``. + """ + token = row.get("token") + if token and not row.get("trade_url"): + url = rh_trade_url(token) + if url: + row["trade_url"] = url + if token and not row.get("explorer_url"): + url = rh_explorer_token_url(token) + if url: + row["explorer_url"] = url + tx_hash = row.get("txHash") + if tx_hash and not row.get("tx_url"): + url = rh_explorer_tx_url(tx_hash) + if url: + row["tx_url"] = url + router_tx = row.get("routerTxHash") + if router_tx and not row.get("router_tx_url"): + url = rh_explorer_tx_url(router_tx) + if url: + row["router_tx_url"] = url + row.setdefault("chain", "robinhood") + row.setdefault("chain_id", RH_CHAIN_ID) + return row + + # ── internals: Robinhood request helpers ──────────────────────── + + def _rh_post(self, path: str, body: dict[str, Any]) -> dict[str, Any]: + """Authed POST against the RHC API; raise on ``ok: false``.""" + return self._ensure_rh_ok(self._post(path, body, auth=True)) + + def _rh_get(self, path: str, *, params: dict[str, str]) -> dict[str, Any]: + """GET against the RHC API; raise on ``ok: false``.""" + return self._ensure_rh_ok(self._get(path, params=params)) + + @staticmethod + def _ensure_rh_ok(resp: Any) -> dict[str, Any]: + if not isinstance(resp, dict): + raise ClawnchError( + "api_error", + f"Robinhood API returned non-dict body: {type(resp).__name__}", + ) + if resp.get("ok") is False: + raise ClawnchError._from_rh_body(resp) + return resp + + @staticmethod + def _assert_rh_chain(resp: dict[str, Any]) -> None: + """Refuse a response that isn't bound to Robinhood Chain (4663).""" + raw_data = resp.get("data") + data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {} + chain_id = data.get("chainId") or resp.get("chainId") + if chain_id is not None and int(chain_id) != RH_CHAIN_ID: + raise ClawnchError( + "api_error", + f"ticket targets chain {chain_id}, expected {RH_CHAIN_ID} " + "(Robinhood Chain) — refusing to return a wrong-chain launch tx", + ) + raw_meta = resp.get("meta") + meta: dict[str, Any] = raw_meta if isinstance(raw_meta, dict) else {} + chain = meta.get("chain") + if chain is not None and str(chain).lower() != "robinhood": + raise ClawnchError( + "api_error", + f"ticket meta.chain={chain!r}, expected 'robinhood' — refusing to " + "return a wrong-chain launch tx", + ) + # ── internals: HTTP ───────────────────────────────────────────── def _post(self, path: str, body: dict, *, auth: bool) -> dict[str, Any]: @@ -539,6 +1068,14 @@ def _reclassify(exc: BaseException) -> None: message, meta=meta if isinstance(meta, dict) else {}, ) + # Robinhood-chain code hints win over the generic HTTP-status + # mapping: e.g. a 403 ``not_claimer`` must not be misreported as + # ``no_credentials`` (the key IS valid — the wallet just isn't in + # the token's claimer set). + if isinstance(code_hint, str) and code_hint in ClawnchError._RH_CODE_MAP: + raise ClawnchError._from_rh_body(body) + if isinstance(code_hint, str) and code_hint in _RH_PASSTHROUGH_CODES: + raise ClawnchError._from_rh_body(body) if status == 400: raise ClawnchError("bad_request", message) if status == 401 or status == 403: diff --git a/clawmes/services/endpoint_allowlist.py b/clawmes/services/endpoint_allowlist.py index f5ce655..5f7a72f 100644 --- a/clawmes/services/endpoint_allowlist.py +++ b/clawmes/services/endpoint_allowlist.py @@ -28,6 +28,7 @@ import threading import time from collections import deque +from collections.abc import Iterable from typing import Any from clawmes.lib.logger import logger_for @@ -97,6 +98,43 @@ def is_allowed(self, host: str) -> bool: with self._lock: return normalized in self._user_hosts + # --- diagnostics ------------------------------------------------------ + + def blocked_hosts(self, hosts: Iterable[str]) -> list[str]: + """Return the hosts that *no* allowlist layer permits. + + Consults the static defaults (``lib.http._DEFAULT_ALLOWLIST``) + plus this service's session-scoped user set — i.e. exactly the + two layers :func:`clawmes.lib.http._check_allowlist` checks + before rejecting a request. Empty/blank entries are ignored. + + Built for startup self-checks: a *shipped default endpoint* + that our own allowlist blocks is a self-inflicted outage + (every call raises ``NetworkAllowlistError`` before leaving + the process). ``RpcService`` validates its defaults with this + at start so the two curated lists can't silently drift apart. + """ + # Lazy import: lib.http imports this module lazily too, and we + # never want an import-time cycle between the two. + try: + from clawmes.lib.http import _DEFAULT_ALLOWLIST + + defaults: frozenset[str] = frozenset(_DEFAULT_ALLOWLIST) + except Exception: # noqa: BLE001 — diagnostics must never raise + defaults = frozenset() + + blocked: list[str] = [] + for host in hosts: + normalized = self._normalize(host, allow_empty=True) + if not normalized: + continue + if normalized in defaults: + continue + if self.is_allowed(normalized): + continue + blocked.append(normalized) + return blocked + # --- audit ----------------------------------------------------------- def record_block(self, url: str, host: str) -> None: diff --git a/clawmes/services/explorer.py b/clawmes/services/explorer.py index 6e14311..18462f4 100644 --- a/clawmes/services/explorer.py +++ b/clawmes/services/explorer.py @@ -78,6 +78,37 @@ class ExplorerError(RuntimeError): """Raised when an explorer API returns a non-success result.""" +def _blockscout_url(chain_id: int) -> str | None: + """Blockscout base URL for ``chain_id`` if its explorer is Blockscout. + + Blockscout (used by Robinhood Chain) is *not* Etherscan-API + compatible, so this service can't serve those chains — but the + error we raise should point at the right explorer instead of a + generic "unknown chain". + """ + try: + from clawmes.lib.chains import get_chain + + chain = get_chain(chain_id) + except KeyError: + return None + url = chain.block_explorer_url or "" + return url if "blockscout" in url.lower() else None + + +def _unsupported_chain_error(chain_id: int) -> ExplorerError: + """Build the error raised for chains this Etherscan-family client can't serve.""" + blockscout = _blockscout_url(chain_id) + if blockscout: + return ExplorerError( + f"chain {chain_id} uses a Blockscout explorer ({blockscout}), which is " + "not Etherscan-API compatible — this service cannot serve it. Read " + "on-chain state through the RPC service instead, or open the explorer " + "page directly." + ) + return ExplorerError(f"no explorer configured for chain {chain_id}") + + class ExplorerService(Service): id = "clawmes.explorer" @@ -98,7 +129,7 @@ def supports_chain(self, chain_id: int) -> bool: def explorer_name(self, chain_id: int) -> str: if chain_id not in _EXPLORERS: - raise ExplorerError(f"no explorer configured for chain {chain_id}") + raise _unsupported_chain_error(chain_id) return _EXPLORERS[chain_id].name # --- public methods --- @@ -189,7 +220,7 @@ def get_logs( def _call(self, chain_id: int, **params) -> object: if chain_id not in _EXPLORERS: - raise ExplorerError(f"no explorer configured for chain {chain_id}") + raise _unsupported_chain_error(chain_id) cfg = _EXPLORERS[chain_id] api_key = os.environ.get(cfg.key_env) diff --git a/clawmes/services/rpc.py b/clawmes/services/rpc.py index 1f4f87e..2cf27d1 100644 --- a/clawmes/services/rpc.py +++ b/clawmes/services/rpc.py @@ -29,6 +29,7 @@ import time from dataclasses import dataclass from typing import Any +from urllib.parse import urlparse from clawmes.lib.http import http_post from clawmes.lib.logger import logger_for @@ -123,6 +124,32 @@ def start(self) -> None: on_defaults, ) + # Self-check against our own network allowlist. Shipping a default + # endpoint whose host isn't allowlisted (e.g. a new chain added to + # _DEFAULT_ENDPOINTS but not to lib.http._DEFAULT_ALLOWLIST) makes + # every call fail with NetworkAllowlistError before it leaves the + # process — fail loudly at startup instead of at first use. + blocked_defaults = self.blocked_default_endpoints() + if blocked_defaults: + _log.error( + "default RPC endpoints for chains %s are NOT on the clawmes " + "network allowlist — every call will raise NetworkAllowlistError. " + "Add the hosts to clawmes.lib.http._DEFAULT_ALLOWLIST, or point " + "CLAWMES_RPC_ at an allowlisted provider.", + sorted(blocked_defaults), + ) + + blocked_overrides = self.blocked_user_endpoints() + if blocked_overrides: + _log.warning( + "user-configured RPC endpoints for chains %s are NOT on the " + "clawmes network allowlist and will be rejected on use " + "(NetworkAllowlistError). Allow the host for this session with " + "/allow , or add it to clawmes.network_allowlist.extra_hosts " + "in config.yaml.", + sorted(blocked_overrides), + ) + def stop(self) -> None: with self._lock: self._endpoints.clear() @@ -345,6 +372,56 @@ def is_default_endpoint(self, chain_id: int) -> bool: ep = self._endpoints.get(chain_id) return ep is not None and ep.is_default + # --- allowlist self-check ------------------------------------------ + + @staticmethod + def _endpoint_host(url: str) -> str: + return (urlparse(url).hostname or "").lower() + + def blocked_default_endpoints(self) -> dict[int, str]: + """Default endpoints the clawmes network allowlist would reject. + + Maps ``chain_id -> url`` for every *shipped default* endpoint + whose host is not permitted by ``lib.http._DEFAULT_ALLOWLIST`` + (plus the runtime user allowlist). An entry here means the + rpc service is broken for that chain by our own configuration — + exactly the Robinhood Chain regression this check exists to + catch. Empty is the only healthy value. + """ + with self._lock: + candidates = {cid: ep.url for cid, ep in self._endpoints.items() if ep.is_default} + return self._blocked_endpoints(candidates) + + def blocked_user_endpoints(self) -> dict[int, str]: + """User-configured endpoints the network allowlist would reject. + + Same shape as :meth:`blocked_default_endpoints` but for + overrides (``CLAWMES_RPC_`` / config). These are + *expected* to be blocked until the user allows the host via + ``/allow`` or ``clawmes.network_allowlist.extra_hosts``; the + service only warns about them so the eventual + ``NetworkAllowlistError`` isn't a mystery. + """ + with self._lock: + candidates = {cid: ep.url for cid, ep in self._endpoints.items() if not ep.is_default} + return self._blocked_endpoints(candidates) + + def _blocked_endpoints(self, candidates: dict[int, str]) -> dict[int, str]: + if not candidates: + return {} + hosts = {cid: self._endpoint_host(url) for cid, url in candidates.items()} + try: + from clawmes.services.endpoint_allowlist import ( + get_endpoint_allowlist_service, + ) + + blocked_hosts = set(get_endpoint_allowlist_service().blocked_hosts(hosts.values())) + except Exception: # noqa: BLE001 — diagnostics must never break startup + return {} + return { + cid: candidates[cid] for cid, host in hosts.items() if host and host in blocked_hosts + } + _instance: RpcService | None = None diff --git a/clawmes/tools/clawnch_fees.py b/clawmes/tools/clawnch_fees.py index 140e2a6..4d41dea 100644 --- a/clawmes/tools/clawnch_fees.py +++ b/clawmes/tools/clawnch_fees.py @@ -1,24 +1,36 @@ -"""``clawnch_fees`` — read LP fee accrual for Clawnch launches. +"""``clawnch_fees`` — read LP fee accrual + claim Clawnch-launch fees. -Reads-side companion to ``clawnch_launch``. Two actions: +Reads-side companion to ``clawnch_launch``. Actions: + +Base side (Clanker): * ``my_launches`` — list the authenticated agent's launches with aggregate fee accrual. Uses Clawnch's ``/api/agents/me`` endpoint. * ``launch_info`` — per-token launch detail (price, volume, fees so far). Uses Clawnch's ``/api/launches?address=…`` endpoint. -Claim-side ops aren't implemented here today: Clanker pays creator -rewards via its own LP-fee accumulator (FeeLocker) on Base, and Bags -pays creator rewards via its own per-token ``BagsFeeShare`` ledger on -Robinhood Chain (the per-token ``claim()`` on the feeShare contract -returned by a Bags launch). Neither flows through Clawnch's HTTP claim -path — Clawnch only reads launch metadata. Users claim through their -Clanker dashboard (Base) or against the token's feeShare contract -(Robinhood). Once v2 ships (the ClawnchFactory fork that drops -Clanker), we'll revisit adding a launchpad-orchestrated claim action. - -Requires ``CLAWNCH_API_KEY`` for ``my_launches``; ``launch_info`` is -public and works without a key. +Robinhood Chain side (Bags.fm launch router): + + * ``rh_launches`` — the RHC launch feed (``/api/robinhood/launches``), + optionally filtered by agent wallet. Every row carries bags.fm + + Blockscout links. + * ``rh_claimable`` — read a wallet's accrued RHC fees for a token + (``GET /api/robinhood/claim``): claimer bps + claimable wei, plus + the unsigned ``BagsFeeShare.claim(true)`` tx when the wallet is a + claimer. + * ``rh_claim`` — the unsigned claim tx for the registered agent wallet + (``POST /api/robinhood/claim``). The agent signs and sends it from + its own wallet; Clawnch never claims on the agent's behalf. + +Base claim-side ops still aren't implemented in clawmes: Clanker pays +creator rewards via its own LP-fee accumulator (FeeLocker) on Base, and +that claim lives in the Clanker dashboard rather than the Clawnch HTTP +API. Robinhood Chain claims **are** served by the launchpad API, which +is what the ``rh_claim*`` actions wrap. + +Requires ``CLAWNCH_API_KEY`` for ``my_launches`` and ``rh_claim``; +``launch_info``, ``rh_launches`` and ``rh_claimable`` are public and +work without a key. """ from __future__ import annotations @@ -37,11 +49,36 @@ "properties": { "action": { "type": "string", - "enum": ["my_launches", "launch_info"], + "enum": [ + "my_launches", + "launch_info", + "rh_launches", + "rh_claimable", + "rh_claim", + ], }, "token": { "type": "string", - "description": "Token address (for launch_info).", + "description": "Token address (launch_info, rh_claimable, rh_claim).", + }, + "address": { + "type": "string", + "description": ( + "Wallet to read claimable fees for (rh_claimable; defaults " + "to the registered agent wallet when known)." + ), + }, + "agent": { + "type": "string", + "description": ("Filter the Robinhood Chain feed to one agent wallet (rh_launches)."), + }, + "limit": { + "type": "integer", + "description": "Feed page size, 1-200 (rh_launches, default 50).", + }, + "offset": { + "type": "integer", + "description": "Feed page offset (rh_launches, default 0).", }, "policyConfirmationNonce": { "type": "string", @@ -58,34 +95,145 @@ description=( "Read LP-fee accrual + launch metadata for tokens deployed via " "the Clawnch launchpad. my_launches lists the active agent's " - "launches; launch_info reads detail for a single token." + "Base launches; launch_info reads detail for a single token; " + "rh_launches reads the Robinhood Chain launch feed; " + "rh_claimable reads accrued RHC fees + the unsigned claim tx for " + "any wallet, and rh_claim returns the unsigned " + "BagsFeeShare.claim(true) tx for the registered agent wallet." ), schema=_SCHEMA, emoji="\U0001f4b0", ) def clawnch_fees(args: dict[str, Any], **kwargs: Any) -> str: - from clawmes.services.clawnch import ClawnchError, get_clawnch_service - action = read_str(args, "action", required=True) - svc = get_clawnch_service() try: if action == "my_launches": - data = svc.get_my_launches() - return json_result(data, summary=_format_my_launches(data)) + return _handle_my_launches() + if action == "rh_launches": + return _handle_rh_launches(args) + if action == "rh_claimable": + return _handle_rh_claimable(args) + if action == "rh_claim": + return _handle_rh_claim(args) # action == "launch_info" - token = read_str(args, "token") - if not token: - return error_result( - "launch_info requires 'token' (address).", - code="param_error", - ) - data = svc.get_launch(token) - return json_result(data, summary=f"Launch detail for {token}") - except ClawnchError as exc: - return error_result(exc.message, code=exc.code) - except Exception as exc: # noqa: BLE001 - return error_result(f"Read failed: {exc}", code="api_error") + return _handle_launch_info(args) + except Exception as exc: # noqa: BLE001 — service errors carry codes + code = getattr(exc, "code", None) or "api_error" + return error_result(str(exc), code=code) + + +def _handle_my_launches() -> str: + from clawmes.services.clawnch import get_clawnch_service + + data = get_clawnch_service().get_my_launches() + return json_result(data, summary=_format_my_launches(data)) + + +def _handle_launch_info(args: dict[str, Any]) -> str: + from clawmes.services.clawnch import get_clawnch_service + + token = read_str(args, "token") + if not token: + return error_result( + "launch_info requires 'token' (address).", + code="param_error", + ) + data = get_clawnch_service().get_launch(token) + return json_result(data, summary=f"Launch detail for {token}") + + +def _as_dict(value: Any) -> dict[str, Any]: + """Narrow an untyped JSON value to a dict (empty when it isn't one).""" + return value if isinstance(value, dict) else {} + + +def _as_list(value: Any) -> list[Any]: + """Narrow an untyped JSON value to a list (empty when it isn't one).""" + return value if isinstance(value, list) else [] + + +def _int_arg(value: Any, default: int) -> int: + """Parse an optional integer tool argument (``None``/``""`` → default).""" + if value is None or value == "": + return default + return int(value) + + +def _handle_rh_launches(args: dict[str, Any]) -> str: + from clawmes.services.clawnch import get_clawnch_service + + agent = read_str(args, "agent") or None + data = get_clawnch_service().rh_launches( + agent=agent, + limit=_int_arg(args.get("limit"), 50), + offset=_int_arg(args.get("offset"), 0), + ) + launches = _as_list(data.get("launches")) + pagination = _as_dict(data.get("pagination")) + total = pagination.get("total") + scope = f" for {agent}" if agent else "" + summary = ( + f"{len(launches)} Robinhood Chain launch(es){scope}" + + (f" of {total}" if total is not None else "") + + "." + ) + return json_result(data, summary=summary) + + +def _handle_rh_claimable(args: dict[str, Any]) -> str: + from clawmes.services.clawnch import get_clawnch_service + + token = read_str(args, "token") + if not token: + return error_result( + "rh_claimable requires 'token' (the launched token's address).", + code="param_error", + ) + address = read_str(args, "address") + if not address: + return error_result( + "rh_claimable requires 'address' (the wallet to inspect).", + code="param_error", + ) + data = get_clawnch_service().rh_claimable(token=token, address=address) + claimable_wei = data.get("claimableWei", "0") + try: + claimable_eth = f"{int(claimable_wei) / 1e18:.6f}" + except (TypeError, ValueError): + claimable_eth = "?" + if data.get("isClaimer"): + summary = ( + f"{address} can claim {claimable_eth} ETH of fees for {token} " + f"(claimer share {data.get('bps', 0)} bps)." + ) + else: + summary = f"{address} is not a fee claimer for {token}." + return json_result(data, summary=summary) + + +def _handle_rh_claim(args: dict[str, Any]) -> str: + from clawmes.services.clawnch import get_clawnch_service + + token = read_str(args, "token") + if not token: + return error_result( + "rh_claim requires 'token' (the launched token's address).", + code="param_error", + ) + data = get_clawnch_service().rh_claim(token=token) + if not data.get("ready"): + summary = "Nothing claimable right now on Robinhood Chain" + ( + f" ({data.get('note')})" if data.get("note") else "." + ) + return json_result(data, summary=summary) + claim = _as_dict(data.get("claim")) + summary = ( + f"Unsigned claim tx ready ({data.get('claimableWei', '0')} wei). " + f"Sign and send it from the agent wallet to {claim.get('to')} " + "to receive the accrued fees as native ETH." + ) + return json_result(data, summary=summary) def _format_my_launches(data: dict[str, Any]) -> str: diff --git a/clawmes/tools/clawnch_launch.py b/clawmes/tools/clawnch_launch.py index 3fe661a..2ff2f5c 100644 --- a/clawmes/tools/clawnch_launch.py +++ b/clawmes/tools/clawnch_launch.py @@ -2,18 +2,32 @@ LLM-callable surface for the Clawnch deploy flow. Wraps :class:`clawmes.services.clawnch.ClawnchService` which talks to the -launchpad HTTP API. Two actions: - - * ``deploy`` — submit a deploy. Service handles the captcha - challenge (sign message + read storage slot + compute keccak - proof), then posts the solution. Clawnch's deployer wallet pays - gas + submits the underlying Clanker tx server-side. Requires - ``burn_tx_hash`` — every launch needs a verified 1,000,000+ - $CLAWNCH burn (the launchpad rejects no-burn deploys with - ``burn_required``); the same burn sets the vault %. Optional - ``bypass_tx_hash`` skips the 24h cooldown by paying ETH to the - bypass recipient (see ``CLAWNCH_BYPASS_RECIPIENT``). - * ``info`` — read launch metadata for an existing token. +launchpad HTTP API. Two surfaces, four actions: + +Base (Clanker) — ``deploy`` submits a deploy through the custodial +flow. The service handles the captcha challenge (sign message + read +storage slot + compute keccak proof), then posts the solution. +Clawnch's deployer wallet pays gas + submits the underlying Clanker +tx server-side. Requires ``burn_tx_hash`` — every Base launch needs a +verified 1,000,000+ $CLAWNCH burn (the launchpad rejects no-burn +deploys with ``burn_required``). Optional ``bypass_tx_hash`` skips the +24h cooldown by paying ETH to the bypass recipient. + +Robinhood Chain (Bags.fm launch router) — the Clanker path does not +exist on RHC: + + * ``rh_ticket`` — POST ``/api/robinhood/ticket``: the unsigned + ``launch()`` tx + EIP-712 ticket for the agent wallet to sign and + pay for. + * ``rh_confirm`` — POST ``/api/robinhood/launch`` ``mode="confirm"``: + record the broadcast ticket-path launch. + * ``rh_deposit`` — POST ``/api/robinhood/launch`` ``mode="deposit"``: + launch from a plain ETH deposit to the router's deposit address. + * ``rh_token`` — the RHC $CLAWNCH address + trade/explorer links. + +``info`` reads launch metadata. ``chain`` on ``deploy`` is Base-only: +a robinhood request raises ``unsupported_chain`` with the RHC +alternatives rather than deploying through the wrong backend. Metadata: ``image`` + per-platform social URLs (``twitter``, ``website``, ``telegram``, ``farcaster``, ``discord``) are passed @@ -81,7 +95,7 @@ def _normalize_social(value: str, base_url: str) -> str: "properties": { "action": { "type": "string", - "enum": ["deploy", "info"], + "enum": ["deploy", "info", "rh_ticket", "rh_confirm", "rh_deposit", "rh_token"], }, "name": {"type": "string", "description": "Token name (deploy)."}, "symbol": {"type": "string", "description": "Token symbol (deploy)."}, @@ -128,10 +142,47 @@ def _normalize_social(value: str, base_url: str) -> str: "description": ( "Tx hash of a verified 1,000,000+ $CLAWNCH burn from " "the agent's wallet to the dead address within 24h. " - "Required for every deploy — the launchpad rejects " - "no-burn launches with code 'burn_required'. The same " - "burn sets the Clanker vault % (1M = 1%, 10M = 10%). " - "Use the /burn command to sign + submit one." + "Required for every Base deploy — the launchpad rejects " + "no-burn launches with code 'burn_required'. Not used " + "on Robinhood Chain (Bags does not burn)." + ), + }, + "chain": { + "type": "string", + "description": ( + "Launch surface for 'deploy'. Only 'base' (default, " + "Clanker) is served by that action; Robinhood Chain " + "launches use the rh_ticket / rh_deposit actions." + ), + }, + "from_address": { + "type": "string", + "description": ( + "The wallet that signs / pays for the launch (rh_ticket, " + "rh_confirm, rh_deposit). On the RHC actions this must be " + "the agent wallet registered with Clawnch." + ), + }, + "fee_recipient": { + "type": "string", + "description": ( + "Address receiving creator fees on RHC launches " + "(rh_ticket; optional, defaults to from_address)." + ), + }, + "tx_hash": { + "type": "string", + "description": ( + "Ticket-path launch tx hash to record (rh_confirm), sent " + "from the agent wallet to the launch router." + ), + }, + "deposit_tx_hash": { + "type": "string", + "description": ( + "Plain ETH transfer hash to the Clawnch RHC deposit " + "address (rh_deposit) — see meta.depositAddress from " + "rh_ticket." ), }, "token": { @@ -151,17 +202,17 @@ def _normalize_social(value: str, base_url: str) -> str: name="clawnch_launch", toolset="clawmes-defi", description=( - "Deploy a token via the Clawnch launchpad. Defaults to Base " - "(via Clanker); use --chain robinhood to launch on Robinhood " - "Chain via Bags.fm. Clawnch handles the deploy + initial " - "liquidity atomically; the user's wallet signs a captcha " - "challenge to prove identity (custodial) or signs the deploy tx " - "directly (non-custodial). Every deploy on Base requires a " - "verified 1,000,000+ $CLAWNCH burn (use /burn to submit one); " - "the Robinhood path currently doesn't enforce a burn. Supports " - "image + social metadata (twitter / website / telegram / " - "farcaster / discord). Requires CLAWNCH_API_KEY (register an " - "agent with /register_agent)." + "Deploy a token via the Clawnch launchpad. 'deploy' launches on " + "Base via Clanker (requires a verified 1,000,000+ $CLAWNCH burn; " + "use /burn to submit one) — it is Base-only. Robinhood Chain " + "launches use the Bags.fm router: 'rh_ticket' returns the unsigned " + "launch() tx + EIP-712 ticket the agent's wallet signs and pays " + "for, 'rh_confirm' records that tx, and 'rh_deposit' launches from " + "a plain ETH deposit to the router deposit address. 'rh_token' " + "returns the RHC $CLAWNCH address + links. 'info' reads launch " + "metadata. Supports image + social metadata (twitter / website / " + "telegram / farcaster / discord). The RHC actions need " + "CLAWNCH_API_KEY + a registered agent wallet (/register_agent)." ), schema=_SCHEMA, emoji="\U0001f31f", @@ -171,6 +222,14 @@ def clawnch_launch(args: dict[str, Any], **kwargs: Any) -> str: if action == "info": return _handle_info(args) + if action == "rh_ticket": + return _handle_rh_ticket(args) + if action == "rh_confirm": + return _handle_rh_confirm(args) + if action == "rh_deposit": + return _handle_rh_deposit(args) + if action == "rh_token": + return _handle_rh_token(args) return _handle_deploy(args) @@ -205,25 +264,28 @@ def _handle_deploy(args: dict[str, Any]) -> str: bypass = read_str(args, "bypass_tx_hash") or None burn = read_str(args, "burn_tx_hash") or None - # Classic non-custodial prepare path. The API routes chain based on a - # `chain` query param, which we pass through if the caller supplied it. + # `chain` selects the launch surface. Only Base is served by this + # action: Robinhood Chain runs through the launch-router actions + # (rh_ticket / rh_confirm / rh_deposit) and the service raises + # `unsupported_chain` for a robinhood request — we surface that + # rather than silently deploying on Base. start_deploy_chain = read_str(args, "chain") or None try: - if start_deploy_chain == "robinhood": + if start_deploy_chain and start_deploy_chain.strip().lower() not in ("base", "8453"): result = get_clawnch_service().prepare_deploy( from_address=args.get("from_address") or "", name=name, symbol=symbol, description=token_params.get("description"), image=token_params.get("image"), - twitter=token_params.get("twitter"), - website=token_params.get("website"), - telegram=token_params.get("telegram"), - farcaster=token_params.get("farcaster"), - discord=token_params.get("discord"), + twitter=read_str(args, "twitter"), + website=read_str(args, "website"), + telegram=read_str(args, "telegram"), + farcaster=read_str(args, "farcaster"), + discord=read_str(args, "discord"), burn_tx_hash=burn, - chain="robinhood", + chain=start_deploy_chain, ) else: result = get_clawnch_service().deploy( @@ -251,9 +313,9 @@ def _handle_deploy(args: dict[str, Any]) -> str: # Surface the actual chain from the launch response. The clawn.ch API # returns chainId in `data.chainId` (prepare path) or the deploy - # metadata; default to Base when absent so historical Base launches - # stay Base-tagged. - chain_id = int(result.get("chainId") or result.get("chain_id") or 8453) + # metadata; the Base custodial path echoes none, so only THAT path + # defaults to Base (a Robinhood request must never fall back to Base). + chain_id = _extract_chain_id(result) # Desktop UI: surface the tx explorer link + token links for the # brand-new token as clickable Link artifacts. Passive descriptive @@ -279,6 +341,7 @@ def _handle_deploy(args: dict[str, Any]) -> str: rows.append(("Tx", tx_hash)) links = [ ("Clanker", result.get("clanker_url", "")), + ("Bags", result.get("bags_url", "")), ("DexScreener", result.get("dexscreener_url", "")), ("Explorer", result.get("explorer_url", "")), ] @@ -295,6 +358,183 @@ def _handle_deploy(args: dict[str, Any]) -> str: return json_result(result, summary=" ".join(summary_parts), preview=preview_path) +def _extract_chain_id(result: dict[str, Any]) -> int: + """Best-effort chain id off a launch response; Base when truly absent. + + The clawn.ch deploy responses echo a chain id on the non-custodial + path (``data.chainId``) and the RHC envelope (``data.chainId`` + + ``meta.chain``). Only the Base custodial response carries none — and + only then do we default to Base (8453) for link rendering. + """ + data = result.get("data") + data = data if isinstance(data, dict) else {} + meta = result.get("meta") + meta = meta if isinstance(meta, dict) else {} + for candidate in ( + result.get("chainId"), + result.get("chain_id"), + data.get("chainId"), + data.get("chain_id"), + ): + if candidate is not None: + try: + return int(candidate) + except (TypeError, ValueError): + continue + if str(meta.get("chain") or "").strip().lower() == "robinhood": + return 4663 + return 8453 + + +def _handle_rh_ticket(args: dict[str, Any]) -> str: + """RHC ticket path: unsigned launch tx + EIP-712 ticket.""" + from clawmes.services.clawnch import ClawnchError, get_clawnch_service + + name = read_str(args, "name") + symbol = read_str(args, "symbol") + from_address = read_str(args, "from_address") + if not name or not symbol: + return error_result( + "rh_ticket requires 'name' and 'symbol'.", + code="param_error", + ) + if not from_address: + return error_result( + "rh_ticket requires 'from_address' — the registered agent wallet " + "that will sign and pay for the launch.", + code="param_error", + ) + + try: + result = get_clawnch_service().rh_ticket( + agent_wallet=from_address, + name=name, + symbol=symbol, + description=read_str(args, "description") or None, + image=read_str(args, "image") or None, + fee_recipient=read_str(args, "fee_recipient") or None, + ) + except ClawnchError as exc: + return error_result(exc.message, code=exc.code) + except Exception as exc: # noqa: BLE001 + return error_result(f"RHC ticket request failed: {exc}", code="api_error") + + data = _as_dict(result.get("data")) + meta = _as_dict(result.get("meta")) + deposit_address = meta.get("depositAddress") or "" + creation_fee = meta.get("creationFeeWei") or data.get("value") or "0" + parts = [ + f"Robinhood Chain launch ticket issued for {symbol}.", + "Sign and send the unsigned launch() tx from the agent wallet, " + "then record it with clawnch_launch action=rh_confirm tx_hash=.", + ] + if creation_fee not in ("", "0", "0x0"): + parts.append(f"Creation fee (wei): {creation_fee}.") + if deposit_address: + parts.append( + "Deposit path: send >= max(0.02 ETH, creation fee) to " + f"{deposit_address}, then call action=rh_deposit with " + "deposit_tx_hash=." + ) + return json_result(result, summary=" ".join(parts)) + + +def _as_dict(value: Any) -> dict[str, Any]: + """Narrow an untyped JSON value to a dict (empty when it isn't one).""" + return value if isinstance(value, dict) else {} + + +def _handle_rh_confirm(args: dict[str, Any]) -> str: + """RHC ticket path: record the broadcast launch tx.""" + from clawmes.services.clawnch import ( + ClawnchError, + get_clawnch_service, + rh_explorer_token_url, + rh_explorer_tx_url, + rh_trade_url, + ) + + tx_hash = read_str(args, "tx_hash") + if not tx_hash: + return error_result( + "rh_confirm requires 'tx_hash' (the launch tx sent from the agent wallet).", + code="param_error", + ) + try: + result = get_clawnch_service().rh_confirm_launch(tx_hash=tx_hash) + except ClawnchError as exc: + return error_result(exc.message, code=exc.code) + except Exception as exc: # noqa: BLE001 + return error_result(f"RHC confirm failed: {exc}", code="api_error") + + launch = _as_dict(result.get("launch")) + token = launch.get("token") or "" + tx_url = rh_explorer_tx_url(tx_hash) + if tx_url: + result["explorer_url"] = tx_url + if token: + result["token_explorer_url"] = rh_explorer_token_url(token) + result["trade_url"] = rh_trade_url(token) + summary = f"Recorded Robinhood Chain launch {token or tx_hash}." + return json_result(result, summary=summary) + + +def _handle_rh_deposit(args: dict[str, Any]) -> str: + """RHC deposit path: launch from an already-sent ETH deposit.""" + from clawmes.services.clawnch import ( + ClawnchError, + get_clawnch_service, + rh_explorer_token_url, + rh_trade_url, + ) + + deposit_tx_hash = read_str(args, "deposit_tx_hash") + from_address = read_str(args, "from_address") + name = read_str(args, "name") + symbol = read_str(args, "symbol") + if not deposit_tx_hash or not from_address or not name or not symbol: + return error_result( + "rh_deposit requires 'deposit_tx_hash', 'from_address', 'name' and 'symbol'.", + code="param_error", + ) + try: + result = get_clawnch_service().rh_deposit_launch( + deposit_tx_hash=deposit_tx_hash, + agent_wallet=from_address, + name=name, + symbol=symbol, + description=read_str(args, "description") or None, + image=read_str(args, "image") or None, + ) + except ClawnchError as exc: + return error_result(exc.message, code=exc.code) + except Exception as exc: # noqa: BLE001 + return error_result(f"RHC deposit launch failed: {exc}", code="api_error") + + launch = _as_dict(result.get("launch")) + token = launch.get("token") or "" + if token: + result["token_explorer_url"] = rh_explorer_token_url(token) + result["trade_url"] = rh_trade_url(token) + return json_result( + result, + summary=f"Launched {symbol} on Robinhood Chain via deposit.", + ) + + +def _handle_rh_token(args: dict[str, Any]) -> str: + """$CLAWNCH on Robinhood Chain: address + links.""" + from clawmes.services.clawnch import get_clawnch_service + + info = get_clawnch_service().rh_token_info() + return json_result( + info, + summary=( + f"$CLAWNCH on Robinhood Chain: {info['token_address']} (trade: {info.get('trade_url')})" + ), + ) + + def _handle_info(args: dict[str, Any]) -> str: from clawmes.services.clawnch import ClawnchError, get_clawnch_service diff --git a/tests/cli/test_doctor_cmd.py b/tests/cli/test_doctor_cmd.py index 96e2847..643c90c 100644 --- a/tests/cli/test_doctor_cmd.py +++ b/tests/cli/test_doctor_cmd.py @@ -217,16 +217,19 @@ def test_rpc_check_fail_when_no_chains(self, capsys, monkeypatch, all_green): def test_rpc_check_ok_when_all_configured(self, capsys, monkeypatch, all_green): from clawmes.cli import doctor as doc from clawmes.services import rpc as rpc_mod + from clawmes.services.rpc import _DEFAULT_ENDPOINTS monkeypatch.setattr(rpc_mod, "_instance", None) - for cid in (1, 8453, 42161, 10, 137): + for cid in _DEFAULT_ENDPOINTS: monkeypatch.setenv(f"CLAWMES_RPC_{cid}", f"https://rpc-{cid}.example.com") monkeypatch.setattr(doc, "get_wallet_state", lambda: WalletState.disconnected()) doc.run(_ns()) out = capsys.readouterr().out assert "RPC endpoints" in out - assert "all 5 user-configured" in out + # Count follows the shipped default set (7 today: 1, 8453, 42161, + # 10, 137, 4663, 46630) — no hardcoded chain list to drift. + assert f"all {len(_DEFAULT_ENDPOINTS)} user-configured" in out def test_hermes_not_importable(self, capsys, monkeypatch, node_available): """Cover the ImportError branch in _gather_checks.""" diff --git a/tests/lib/test_chains.py b/tests/lib/test_chains.py index 621ec43..384d0a7 100644 --- a/tests/lib/test_chains.py +++ b/tests/lib/test_chains.py @@ -37,6 +37,16 @@ def test_is_supported_yes(self): def test_is_supported_no(self): assert not is_supported(999999) + def test_robinhood_present(self): + chain = CHAINS[4663] + assert chain.short_name == "robinhood" + assert chain.block_explorer_url == "https://robinhoodchain.blockscout.com" + assert chain.is_l2 + # Testnet companion. + testnet = CHAINS[46630] + assert testnet.short_name == "robinhood-testnet" + assert testnet.block_explorer_url == "https://robinhoodchain-testnet.blockscout.com" + class TestGetChain: def test_by_id(self): @@ -70,6 +80,10 @@ def test_whitespace_in_name(self): # Should strip whitespace assert get_chain(" base ").chain_id == 8453 + def test_robinhood_by_name(self): + assert get_chain("robinhood").chain_id == 4663 + assert get_chain("Robinhood Chain").chain_id == 4663 + class TestChainDataclass: def test_frozen(self): diff --git a/tests/lib/test_http.py b/tests/lib/test_http.py index 4c2df34..133c24e 100644 --- a/tests/lib/test_http.py +++ b/tests/lib/test_http.py @@ -30,6 +30,17 @@ def test_allows_clawnch_apex_and_www(self): _check_allowlist("https://clawn.ch/api/agents/register") _check_allowlist("https://www.clawn.ch/api/agents/register") + def test_allows_robinhood_chain_hosts(self): + # Regression: the RHC RPC defaults shipped in services.rpc were not + # allowlisted, so the plugin blocked its OWN RPC hosts (every call + # raised NetworkAllowlistError before leaving the process). + _check_allowlist("https://rpc.mainnet.chain.robinhood.com") + _check_allowlist("https://rpc.testnet.chain.robinhood.com") + # RHC explorer + trade surfaces (links, and any future reads). + _check_allowlist("https://robinhoodchain.blockscout.com/api/v2/stats") + _check_allowlist("https://robinhoodchain-testnet.blockscout.com") + _check_allowlist("https://bags.fm/token/0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA") + def test_allows_llm_inference_gateways(self): # OpenAI-compatible inference providers (services.opengateway / venice). _check_allowlist("https://opengateway.gitlawb.com/v1/chat/completions") diff --git a/tests/lib/test_ui_artifacts.py b/tests/lib/test_ui_artifacts.py index e28b0c4..26fccd0 100644 --- a/tests/lib/test_ui_artifacts.py +++ b/tests/lib/test_ui_artifacts.py @@ -3,6 +3,7 @@ from __future__ import annotations from clawmes.lib.ui_artifacts import ( + bags_url, clanker_url, dexscreener_url, enrich_token_links, @@ -103,6 +104,21 @@ def test_bad_addr_returns_none(self): assert clanker_url(_BAD, 8453) is None +class TestBagsUrl: + def test_robinhood(self): + assert bags_url(_ADDR, 4663) == f"https://bags.fm/token/{_ADDR}" + + def test_default_chain_is_robinhood(self): + assert bags_url(_ADDR) == f"https://bags.fm/token/{_ADDR}" + + def test_non_robinhood_returns_none(self): + assert bags_url(_ADDR, 8453) is None + assert bags_url(_ADDR, 1) is None + + def test_bad_addr_returns_none(self): + assert bags_url(_BAD, 4663) is None + + class TestEnrichTxLinks: def test_adds_explorer_url(self): details: dict = {"tx_hash": _TX} @@ -138,6 +154,18 @@ def test_no_clanker_off_base(self): assert "clanker_url" not in details assert details["dexscreener_url"] == f"https://dexscreener.com/ethereum/{_ADDR}" + def test_robinhood_links(self): + # RHC launches link to Blockscout + bags.fm (Clanker doesn't exist + # there; DexScreener doesn't index the chain yet). + details: dict = {} + enrich_token_links(details, token=_ADDR, chain_id=4663) + assert details["token_explorer_url"] == ( + f"https://robinhoodchain.blockscout.com/token/{_ADDR}" + ) + assert details["bags_url"] == f"https://bags.fm/token/{_ADDR}" + assert "clanker_url" not in details + assert "dexscreener_url" not in details + def test_include_clanker_false(self): details: dict = {} enrich_token_links(details, token=_ADDR, chain_id=8453, include_clanker=False) diff --git a/tests/services/test_clawnch.py b/tests/services/test_clawnch.py index ca5db06..8787bab 100644 --- a/tests/services/test_clawnch.py +++ b/tests/services/test_clawnch.py @@ -959,6 +959,696 @@ def _get(url, params=None, headers=None, timeout=None): svc_with_key.get_my_launches() +# ────────────────────────────────────────────────────────────────────── +# Robinhood Chain — launch ticket / deposit / claim / feed +# ────────────────────────────────────────────────────────────────────── + +_TX = "0x" + "f" * 64 +_ROUTER_TX = "0x" + "e" * 64 +_WALLET = "0x" + "1" * 40 + + +def _rh_ticket_response(**overrides): + """The upstream ticket envelope (mirrors api/robinhood/ticket.ts).""" + resp = { + "ok": True, + "data": { + "to": "0xdd4e0000000000000000000000000000000053b5", + "data": "0xdeadbeef", + "value": "0x470de4df820000", + "chainId": 4663, + }, + "ticket": { + "agent": _WALLET, + "feeRecipient": _WALLET, + "paramsHash": "0x" + "a" * 64, + "nonce": "1", + "deadline": "1800000000", + "signature": "0x" + "b" * 130, + }, + "meta": { + "backend": "bags", + "chain": "robinhood", + "router": "0xdd4e0000000000000000000000000000000053b5", + "depositAddress": "0xde0000000000000000000000000000000000ad", + "creationFeeWei": "20000000000000000", + "ttlSeconds": 600, + }, + } + resp.update(overrides) + return resp + + +class TestRHUrlHelpers: + def test_trade_url(self): + from clawmes.services.clawnch import rh_trade_url + + assert rh_trade_url(ADDR) == f"https://bags.fm/token/{ADDR}" + assert rh_trade_url("0xnothex") is None + assert rh_trade_url("") is None + assert rh_trade_url(None) is None # type: ignore[arg-type] + + def test_explorer_token_url(self): + from clawmes.services.clawnch import rh_explorer_token_url + + assert rh_explorer_token_url(ADDR) == ( + f"https://robinhoodchain.blockscout.com/token/{ADDR}" + ) + assert rh_explorer_token_url("0xbad") is None + + def test_explorer_tx_url(self): + from clawmes.services.clawnch import rh_explorer_tx_url + + assert rh_explorer_tx_url(_TX) == f"https://robinhoodchain.blockscout.com/tx/{_TX}" + assert rh_explorer_tx_url("0xshort") is None + assert rh_explorer_tx_url(ADDR) is None # address is not a tx hash + + def test_is_tx_hash(self): + from clawmes.services.clawnch import is_tx_hash + + assert is_tx_hash(_TX) is True + assert is_tx_hash("0x" + "F" * 64) is True + assert is_tx_hash("0X" + "f" * 64) is False + assert is_tx_hash("0x" + "g" * 64) is False + assert is_tx_hash(42) is False + assert is_tx_hash(None) is False + + +class TestRHTokenInfo: + def test_default_address(self, svc): + info = svc.rh_token_info() + assert info["token_address"] == "0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA" + assert info["chain"] == "robinhood" + assert info["chain_id"] == 4663 + assert info["trade_url"].startswith("https://bags.fm/token/0x6a50") + assert "robinhoodchain.blockscout.com" in info["explorer_url"] + + def test_env_override(self, monkeypatch, svc): + monkeypatch.setenv("CLAWNCH_RH_TOKEN_ADDRESS", "0x" + "c" * 40) + info = svc.rh_token_info() + assert info["token_address"] == "0x" + "c" * 40 + + +class TestGetBurnConfigChains: + def test_default_is_base(self, svc): + cfg = svc.get_burn_config() + assert cfg["chain"] == "base" + assert cfg["chain_id"] == 8453 + assert cfg["burn_required"] is True + + def test_robinhood_has_no_burn_and_rhc_token(self, svc): + cfg = svc.get_burn_config(chain="robinhood") + assert cfg["chain"] == "robinhood" + assert cfg["chain_id"] == 4663 + assert cfg["token_address"] == "0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA" + assert cfg["burn_address"] is None + assert cfg["min_burn_tokens"] == 0 + assert cfg["burn_required"] is False + + def test_robinhood_aliases(self, svc): + for alias in ("rh", "4663", "ROBINHOOD"): + assert svc.get_burn_config(chain=alias)["chain_id"] == 4663 + + def test_unknown_chain_raises(self, svc): + with pytest.raises(ClawnchError) as exc_info: + svc.get_burn_config(chain="solana") + assert exc_info.value.code == "bad_request" + + +class TestRHTicket: + def test_requires_api_key(self, svc): + svc.start() + with pytest.raises(ClawnchError) as exc_info: + svc.rh_ticket(agent_wallet=_WALLET, name="X", symbol="X") + assert exc_info.value.code == "no_credentials" + + def test_requires_valid_wallet(self, svc_with_key): + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet="", name="X", symbol="X") + assert exc_info.value.code == "bad_request" + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet="0xnope", name="X", symbol="X") + assert exc_info.value.code == "bad_request" + + def test_requires_name_and_symbol(self, svc_with_key): + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="", symbol="X") + assert exc_info.value.code == "bad_request" + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="X", symbol="") + assert exc_info.value.code == "bad_request" + + def test_name_and_symbol_length_caps(self, svc_with_key): + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="x" * 33, symbol="X") + assert "name too long" in exc_info.value.message + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="X", symbol="x" * 11) + assert "symbol too long" in exc_info.value.message + + def test_rejects_bad_fee_recipient(self, svc_with_key): + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket( + agent_wallet=_WALLET, name="X", symbol="X", fee_recipient="0xnope" + ) + assert exc_info.value.code == "bad_request" + + def test_posts_ticket_with_bearer_auth(self, svc_with_key, monkeypatch): + captured: list[tuple] = [] + + def _post(url, json, headers, timeout): # noqa: A002 + captured.append((url, json, headers)) + return _rh_ticket_response() + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + out = svc_with_key.rh_ticket( + agent_wallet=_WALLET, + name="MyCoin", + symbol="MYC", + description="desc", + image="https://x/i.png", + fee_recipient="0x" + "2" * 40, + ) + assert out["ok"] is True + url, body, headers = captured[0] + assert url.endswith("/api/robinhood/ticket") + assert headers["Authorization"] == "Bearer test-key" + assert body["agentWallet"] == _WALLET + assert body["name"] == "MyCoin" + assert body["symbol"] == "MYC" + assert body["description"] == "desc" + assert body["image"] == "https://x/i.png" + assert body["feeRecipient"] == "0x" + "2" * 40 + + def test_minimal_body_omits_optionals(self, svc_with_key, monkeypatch): + captured: list[dict] = [] + + def _post(url, json, headers, timeout): # noqa: A002 + captured.append(json) + return _rh_ticket_response() + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="X", symbol="X") + assert set(captured[0]) == {"agentWallet", "name", "symbol"} + + def test_refuses_wrong_chain_ticket(self, svc_with_key, monkeypatch): + resp = _rh_ticket_response() + resp["data"]["chainId"] = 8453 + + def _post(url, json, headers, timeout): # noqa: A002 + return resp + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="X", symbol="X") + assert exc_info.value.code == "api_error" + assert "4663" in exc_info.value.message + + def test_refuses_wrong_chain_meta(self, svc_with_key, monkeypatch): + resp = _rh_ticket_response() + resp["data"].pop("chainId") + resp["meta"]["chain"] = "base" + + def _post(url, json, headers, timeout): # noqa: A002 + return resp + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="X", symbol="X") + assert exc_info.value.code == "api_error" + + def test_ok_false_wallet_mismatch_passthrough(self, svc_with_key, monkeypatch): + def _post(url, json, headers, timeout): # noqa: A002 + return { + "ok": False, + "error": "agentWallet must match the registered agent wallet", + "code": "wallet_mismatch", + } + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="X", symbol="X") + assert exc_info.value.code == "wallet_mismatch" + + def test_unauthorized_code_maps_to_no_credentials(self, svc_with_key, monkeypatch): + def _post(url, json, headers, timeout): # noqa: A002 + return {"ok": False, "error": "register first", "code": "unauthorized"} + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="X", symbol="X") + assert exc_info.value.code == "no_credentials" + + def test_non_dict_body_raises_api_error(self, svc_with_key, monkeypatch): + monkeypatch.setattr( + "clawmes.services.clawnch.http_post", + lambda *a, **k: ["not", "a", "dict"], + ) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="X", symbol="X") + assert exc_info.value.code == "api_error" + + def test_http_401_maps_to_no_credentials(self, svc_with_key, monkeypatch): + exc = _HTTPErr(_FakeResponse(401, {"ok": False, "code": "unauthorized"})) + + def _post(url, json, headers, timeout): # noqa: A002 + raise exc + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_ticket(agent_wallet=_WALLET, name="X", symbol="X") + assert exc_info.value.code == "no_credentials" + + +class TestRHConfirmLaunch: + def test_requires_api_key(self, svc): + svc.start() + with pytest.raises(ClawnchError) as exc_info: + svc.rh_confirm_launch(tx_hash=_TX) + assert exc_info.value.code == "no_credentials" + + def test_rejects_bad_tx_hash(self, svc_with_key): + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_confirm_launch(tx_hash="0x123") + assert exc_info.value.code == "bad_request" + + def test_posts_confirm_mode(self, svc_with_key, monkeypatch): + captured: list[tuple] = [] + + def _post(url, json, headers, timeout): # noqa: A002 + captured.append((url, json)) + return {"ok": True, "launch": {"token": ADDR, "agent": _WALLET, "mode": "ticket"}} + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + out = svc_with_key.rh_confirm_launch(tx_hash=_TX) + assert out["launch"]["token"] == ADDR + url, body = captured[0] + assert url.endswith("/api/robinhood/launch") + assert body == {"mode": "confirm", "txHash": _TX} + + def test_tx_not_found_maps_to_not_found(self, svc_with_key, monkeypatch): + exc = _HTTPErr( + _FakeResponse(404, {"ok": False, "error": "not found", "code": "tx_not_found"}) + ) + + def _post(url, json, headers, timeout): # noqa: A002 + raise exc + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_confirm_launch(tx_hash=_TX) + assert exc_info.value.code == "not_found" + + def test_not_agentic_launch_passthrough(self, svc_with_key, monkeypatch): + exc = _HTTPErr( + _FakeResponse( + 400, + {"ok": False, "error": "no AgenticLaunch event", "code": "not_agentic_launch"}, + ) + ) + + def _post(url, json, headers, timeout): # noqa: A002 + raise exc + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_confirm_launch(tx_hash=_TX) + assert exc_info.value.code == "not_agentic_launch" + + +class TestRHDepositLaunch: + def test_requires_api_key(self, svc): + svc.start() + with pytest.raises(ClawnchError) as exc_info: + svc.rh_deposit_launch(deposit_tx_hash=_TX, agent_wallet=_WALLET, name="X", symbol="X") + assert exc_info.value.code == "no_credentials" + + def test_validations(self, svc_with_key): + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_deposit_launch( + deposit_tx_hash=_TX, agent_wallet="0xnope", name="X", symbol="X" + ) + assert exc_info.value.code == "bad_request" + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_deposit_launch( + deposit_tx_hash="0xbad", agent_wallet=_WALLET, name="X", symbol="X" + ) + assert exc_info.value.code == "bad_request" + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_deposit_launch( + deposit_tx_hash=_TX, agent_wallet=_WALLET, name="", symbol="X" + ) + assert exc_info.value.code == "bad_request" + + def test_posts_deposit_mode(self, svc_with_key, monkeypatch): + captured: list[tuple] = [] + + def _post(url, json, headers, timeout): # noqa: A002 + captured.append((url, json)) + return {"ok": True, "launch": {"token": ADDR, "mode": "deposit"}} + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + out = svc_with_key.rh_deposit_launch( + deposit_tx_hash=_TX, + agent_wallet=_WALLET, + name="MyCoin", + symbol="MYC", + description="d", + image="https://x/i.png", + ) + assert out["launch"]["mode"] == "deposit" + url, body = captured[0] + assert url.endswith("/api/robinhood/launch") + assert body["mode"] == "deposit" + assert body["depositTxHash"] == _TX + assert body["agentWallet"] == _WALLET + assert body["name"] == "MyCoin" + assert body["symbol"] == "MYC" + assert body["description"] == "d" + assert body["image"] == "https://x/i.png" + + def test_duplicate_deposit_passthrough(self, svc_with_key, monkeypatch): + exc = _HTTPErr( + _FakeResponse(409, {"ok": False, "error": "used", "code": "duplicate_deposit"}) + ) + + def _post(url, json, headers, timeout): # noqa: A002 + raise exc + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_deposit_launch( + deposit_tx_hash=_TX, agent_wallet=_WALLET, name="X", symbol="X" + ) + assert exc_info.value.code == "duplicate_deposit" + + def test_deposit_invalid_passthrough(self, svc_with_key, monkeypatch): + exc = _HTTPErr( + _FakeResponse(400, {"ok": False, "error": "bad deposit", "code": "deposit_invalid"}) + ) + + def _post(url, json, headers, timeout): # noqa: A002 + raise exc + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_deposit_launch( + deposit_tx_hash=_TX, agent_wallet=_WALLET, name="X", symbol="X" + ) + assert exc_info.value.code == "deposit_invalid" + + +class TestRHClaimable: + _PAYLOAD = { + "ok": True, + "chainId": 4663, + "token": ADDR, + "address": _WALLET, + "feeShare": "0x" + "e" * 40, + "isClaimer": True, + "bps": 4000, + "claimableWei": "1000000000000000", + "claim": {"to": "0x" + "e" * 40, "data": "0x1234", "value": "0x0", "chainId": 4663}, + } + + def test_validations(self, svc): + svc.start() + with pytest.raises(ClawnchError) as exc_info: + svc.rh_claimable(token="0xnope", address=_WALLET) + assert exc_info.value.code == "bad_request" + with pytest.raises(ClawnchError) as exc_info: + svc.rh_claimable(token=ADDR, address="0xnope") + assert exc_info.value.code == "bad_request" + + def test_public_read_sends_params(self, svc, monkeypatch): + captured: list[tuple] = [] + + def _get(url, params=None, headers=None, timeout=None): + captured.append((url, params, headers)) + return self._PAYLOAD + + monkeypatch.setattr("clawmes.services.clawnch.http_get", _get) + svc.start() # no key + out = svc.rh_claimable(token=ADDR, address=_WALLET) + assert out["claimableWei"] == "1000000000000000" + url, params, headers = captured[0] + assert url.endswith("/api/robinhood/claim") + assert params == {"token": ADDR, "address": _WALLET} + # Public read works without auth. + assert "Authorization" not in headers + + def test_no_fee_share_passthrough(self, svc, monkeypatch): + exc = _HTTPErr( + _FakeResponse(404, {"ok": False, "error": "not a Bags token", "code": "no_fee_share"}) + ) + + def _get(url, params=None, headers=None, timeout=None): + raise exc + + monkeypatch.setattr("clawmes.services.clawnch.http_get", _get) + svc.start() + with pytest.raises(ClawnchError) as exc_info: + svc.rh_claimable(token=ADDR, address=_WALLET) + assert exc_info.value.code == "no_fee_share" + + +class TestRHClaim: + def test_requires_api_key(self, svc): + svc.start() + with pytest.raises(ClawnchError) as exc_info: + svc.rh_claim(token=ADDR) + assert exc_info.value.code == "no_credentials" + + def test_requires_valid_token(self, svc_with_key): + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_claim(token="0xnope") + assert exc_info.value.code == "bad_request" + + def test_posts_claim_request(self, svc_with_key, monkeypatch): + captured: list[tuple] = [] + payload = { + "ok": True, + "ready": True, + "claimableWei": "1000000000000000", + "claimableEth": "0.001000", + "claim": { + "to": "0x" + "e" * 40, + "data": "0xdeadbeef", + "value": "0x0", + "chainId": 4663, + }, + } + + def _post(url, json, headers, timeout): # noqa: A002 + captured.append((url, json, headers)) + return payload + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + out = svc_with_key.rh_claim(token=ADDR) + assert out["ready"] is True + url, body, headers = captured[0] + assert url.endswith("/api/robinhood/claim") + assert body == {"token": ADDR} + assert headers["Authorization"] == "Bearer test-key" + + def test_ready_false_is_not_an_error(self, svc_with_key, monkeypatch): + def _post(url, json, headers, timeout): # noqa: A002 + return {"ok": True, "ready": False, "claimableWei": "0", "claim": None} + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + out = svc_with_key.rh_claim(token=ADDR) + assert out["ready"] is False + assert out["claim"] is None + + def test_refuses_wrong_chain_claim_tx(self, svc_with_key, monkeypatch): + def _post(url, json, headers, timeout): # noqa: A002 + return { + "ok": True, + "ready": True, + "claim": {"to": "0x" + "e" * 40, "data": "0x", "value": "0x0", "chainId": 8453}, + } + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_claim(token=ADDR) + assert exc_info.value.code == "api_error" + assert "8453" in exc_info.value.message + + def test_not_claimer_403_stays_not_claimer(self, svc_with_key, monkeypatch): + """A 403 must not be flattened to no_credentials when the body says + the wallet simply isn't a claimer (the API key IS valid).""" + exc = _HTTPErr( + _FakeResponse(403, {"ok": False, "error": "not a claimer", "code": "not_claimer"}) + ) + + def _post(url, json, headers, timeout): # noqa: A002 + raise exc + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + with pytest.raises(ClawnchError) as exc_info: + svc_with_key.rh_claim(token=ADDR) + assert exc_info.value.code == "not_claimer" + + +class TestRHLaunches: + def test_rejects_bad_agent(self, svc): + svc.start() + with pytest.raises(ClawnchError) as exc_info: + svc.rh_launches(agent="0xnope") + assert exc_info.value.code == "bad_request" + + def test_rejects_bad_pagination(self, svc): + svc.start() + with pytest.raises(ClawnchError) as exc_info: + svc.rh_launches(limit=0) + assert exc_info.value.code == "bad_request" + with pytest.raises(ClawnchError) as exc_info: + svc.rh_launches(offset=-1) + assert exc_info.value.code == "bad_request" + with pytest.raises(ClawnchError) as exc_info: + svc.rh_launches(limit="lots") # type: ignore[arg-type] + assert exc_info.value.code == "bad_request" + + def test_reads_and_decorates_rows(self, svc, monkeypatch): + captured: list[tuple] = [] + payload = { + "ok": True, + "chain": "robinhood", + "launches": [ + { + "token": ADDR, + "agent": _WALLET, + "name": "MyCoin", + "symbol": "MYC", + "mode": "ticket", + "txHash": _TX, + "routerTxHash": _ROUTER_TX, + "chainId": 4663, + }, + {"token": "0xnothex", "name": "junk"}, + ], + "pagination": {"limit": 50, "offset": 0, "total": 2, "hasMore": False}, + } + + def _get(url, params=None, headers=None, timeout=None): + captured.append((url, params)) + return payload + + monkeypatch.setattr("clawmes.services.clawnch.http_get", _get) + svc.start() + out = svc.rh_launches() + url, params = captured[0] + assert url.endswith("/api/robinhood/launches") + assert params == {"limit": "50", "offset": "0"} + first = out["launches"][0] + assert first["trade_url"] == f"https://bags.fm/token/{ADDR}" + assert first["explorer_url"] == f"https://robinhoodchain.blockscout.com/token/{ADDR}" + assert first["tx_url"] == f"https://robinhoodchain.blockscout.com/tx/{_TX}" + assert first["router_tx_url"] == (f"https://robinhoodchain.blockscout.com/tx/{_ROUTER_TX}") + assert first["chain"] == "robinhood" + assert first["chain_id"] == 4663 + # Row with an invalid token gets no fabricated links. + assert "trade_url" not in out["launches"][1] + + def test_clamps_limit_and_forwards_agent(self, svc, monkeypatch): + captured: list[dict] = [] + + def _get(url, params=None, headers=None, timeout=None): + captured.append(params or {}) + return {"ok": True, "launches": [], "pagination": {}} + + monkeypatch.setattr("clawmes.services.clawnch.http_get", _get) + svc.start() + svc.rh_launches(agent=_WALLET, limit=9999, offset=5) + assert captured[0]["limit"] == "200" + assert captured[0]["offset"] == "5" + assert captured[0]["agent"] == _WALLET + + def test_ok_false_raises(self, svc, monkeypatch): + monkeypatch.setattr( + "clawmes.services.clawnch.http_get", + lambda *a, **k: {"ok": False, "error": "boom", "code": "launches_error"}, + ) + svc.start() + with pytest.raises(ClawnchError) as exc_info: + svc.rh_launches() + assert exc_info.value.code == "api_error" + + +class TestRHCWrongChainGuards: + """Base-only surfaces must refuse RHC requests instead of silently + running against the wrong chain.""" + + def test_deploy_robinhood_refused(self, svc): + with pytest.raises(ClawnchError) as exc_info: + svc.deploy(token_params={"name": "X", "symbol": "X"}, chain="robinhood") + assert exc_info.value.code == "unsupported_chain" + assert "rh_ticket" in exc_info.value.message + + def test_deploy_unknown_chain_refused(self, svc): + with pytest.raises(ClawnchError) as exc_info: + svc.deploy(token_params={"name": "X", "symbol": "X"}, chain="solana") + assert exc_info.value.code == "bad_request" + + def test_deploy_base_default_still_works(self, svc_with_key, monkeypatch): + responses = [ + { + "challengeId": "cid", + "message": "msg", + "nonce": "nonce", + "contractAddress": "0x4200000000000000000000000000000000000006", + "storageSlot": "0x00", + "deadline": "2030", + }, + {"success": True, "txHash": "0xtx", "tokenAddress": "0xtok"}, + ] + + def _post(url, json, headers, timeout): # noqa: A002 + return responses.pop(0) + + monkeypatch.setattr("clawmes.services.clawnch.http_post", _post) + monkeypatch.setattr( + "clawmes.services.wallet.get_wallet_service", + lambda: _FakeWalletSvc(_FakeWalletMode()), + ) + monkeypatch.setattr( + "clawmes.services.rpc.get_rpc_service", + lambda: _FakeRpc("0xff"), + ) + out = svc_with_key.deploy(token_params={"name": "X", "symbol": "X"}) + assert out["success"] is True + + def test_prepare_deploy_robinhood_refused(self, svc): + with pytest.raises(ClawnchError) as exc_info: + svc.prepare_deploy( + from_address="0x" + "1" * 40, name="X", symbol="X", chain="robinhood" + ) + assert exc_info.value.code == "unsupported_chain" + assert "rh_ticket" in exc_info.value.message + + def test_prepare_deploy_unknown_chain_refused(self, svc): + with pytest.raises(ClawnchError) as exc_info: + svc.prepare_deploy(from_address="0x" + "1" * 40, name="X", symbol="X", chain="solana") + assert exc_info.value.code == "bad_request" + + def test_prepare_deploy_base_does_not_send_chain_param(self, svc, monkeypatch): + captured: list[dict] = [] + + def _get(url, params=None, headers=None, timeout=None): + captured.append(params or {}) + return { + "ok": True, + "data": {"to": "0x1", "data": "0x2", "value": "0x0", "chainId": 8453}, + "meta": {}, + } + + monkeypatch.setattr("clawmes.services.clawnch.http_get", _get) + svc.start() + svc.prepare_deploy(from_address="0x" + "1" * 40, name="X", symbol="X", chain="base") + # The server ignores the chain param — never send it. + assert "chain" not in captured[0] + + # ────────────────────────────────────────────────────────────────────── # Singleton # ────────────────────────────────────────────────────────────────────── diff --git a/tests/services/test_endpoint_allowlist.py b/tests/services/test_endpoint_allowlist.py index 99b82db..08fe497 100644 --- a/tests/services/test_endpoint_allowlist.py +++ b/tests/services/test_endpoint_allowlist.py @@ -143,6 +143,52 @@ def test_returns_same_instance(self): assert a is b +class TestBlockedHosts: + """The diagnostics surface used by the RPC startup self-check.""" + + def test_default_host_not_blocked(self): + svc = EndpointAllowlistService() + # A curated default (and the RHC hosts added with the 4663 chain). + assert svc.blocked_hosts(["api.coingecko.com"]) == [] + assert svc.blocked_hosts(["rpc.mainnet.chain.robinhood.com"]) == [] + assert svc.blocked_hosts(["robinhoodchain.blockscout.com"]) == [] + assert svc.blocked_hosts(["bags.fm"]) == [] + + def test_unknown_host_blocked(self): + svc = EndpointAllowlistService() + assert svc.blocked_hosts(["not-allowlisted.example.com"]) == ["not-allowlisted.example.com"] + + def test_user_added_host_not_blocked(self): + svc = EndpointAllowlistService() + svc.add_host("My-Private-RPC.Example.COM") + assert svc.blocked_hosts(["my-private-rpc.example.com"]) == [] + + def test_normalizes_case_and_ignores_blank(self): + svc = EndpointAllowlistService() + assert svc.blocked_hosts(["API.COINGECKO.COM", "", " "]) == [] + + def test_mixed_list_preserves_only_blocked(self): + svc = EndpointAllowlistService() + blocked = svc.blocked_hosts(["api.coingecko.com", "evil.example.com"]) + assert blocked == ["evil.example.com"] + + def test_tolerates_broken_lib_http_import(self, monkeypatch): + """Diagnostics must never raise — a broken lib.http import just + means every checked host reports as blocked.""" + import builtins + + svc = EndpointAllowlistService() + real_import = builtins.__import__ + + def broken_import(name, *args, **kw): + if name == "clawmes.lib.http": + raise RuntimeError("lib.http broken") + return real_import(name, *args, **kw) + + monkeypatch.setattr(builtins, "__import__", broken_import) + assert svc.blocked_hosts(["api.coingecko.com"]) == ["api.coingecko.com"] + + # --- lib/http integration ----------------------------------------------- diff --git a/tests/services/test_explorer.py b/tests/services/test_explorer.py index 426d26d..8fe5b81 100644 --- a/tests/services/test_explorer.py +++ b/tests/services/test_explorer.py @@ -136,6 +136,19 @@ def test_unknown_chain_raises(self, svc): with pytest.raises(ExplorerError, match="no explorer configured"): svc.get_address_balance("0xabc", 999999) + def test_robinhood_chain_raises_blockscout_message(self, svc): + """RHC (4663/46630) uses Blockscout, not Etherscan — the error must + say so instead of pretending the chain is unknown, and must never + silently fall back to another chain's explorer API.""" + with pytest.raises(ExplorerError, match="robinhoodchain.blockscout.com"): + svc.get_address_balance("0xabc", 4663) + with pytest.raises(ExplorerError, match="robinhoodchain-testnet.blockscout.com"): + svc.explorer_name(46630) + + def test_robinhood_not_supported(self, svc): + assert svc.supports_chain(4663) is False + assert svc.supports_chain(46630) is False + def test_non_dict_response(self, svc, fake_http): fake_http.responses.append("not a dict") with pytest.raises(ExplorerError, match="non-dict response"): diff --git a/tests/services/test_rpc.py b/tests/services/test_rpc.py index 1ac683a..886636c 100644 --- a/tests/services/test_rpc.py +++ b/tests/services/test_rpc.py @@ -103,9 +103,9 @@ def test_start_logs_default_warning(self, monkeypatch): # emits a warning so the user sees the rate-limit caveat. import logging - from clawmes.services.rpc import RpcService + from clawmes.services.rpc import _DEFAULT_ENDPOINTS, RpcService - for cid in (1, 8453, 42161, 10, 137): + for cid in _DEFAULT_ENDPOINTS: monkeypatch.delenv(f"CLAWMES_RPC_{cid}", raising=False) records, handler, clawmes_root = self._capture_clawmes_logs(monkeypatch) try: @@ -118,9 +118,9 @@ def test_start_logs_default_warning(self, monkeypatch): def test_start_no_warning_when_all_overridden(self, monkeypatch): import logging - from clawmes.services.rpc import RpcService + from clawmes.services.rpc import _DEFAULT_ENDPOINTS, RpcService - for cid in (1, 8453, 42161, 10, 137): + for cid in _DEFAULT_ENDPOINTS: monkeypatch.setenv(f"CLAWMES_RPC_{cid}", f"https://rpc-{cid}.example.com") records, handler, clawmes_root = self._capture_clawmes_logs(monkeypatch) try: @@ -128,8 +128,65 @@ def test_start_no_warning_when_all_overridden(self, monkeypatch): finally: clawmes_root.removeHandler(handler) msgs = [r.getMessage() for r in records if r.levelno == logging.WARNING] + # User overrides on non-allowlisted hosts DO warn (with the + # /allow remediation hint) — but never the public-default caveat. assert not any("public-node default" in m for m in msgs) + def test_no_default_endpoint_is_blocked_by_own_allowlist(self): + """Regression: RHC RPCs (4663/46630) were added to _DEFAULT_ENDPOINTS + without allowlisting their hosts, so every call raised + NetworkAllowlistError before leaving the process.""" + from clawmes.services.rpc import RpcService + + svc = RpcService() + svc.start() + assert svc.blocked_default_endpoints() == {} + + def test_blocked_default_endpoints_detects_rogue_default(self, monkeypatch): + """The self-check must flag a shipped default whose host isn't + allowlisted (so the two curated lists can't drift silently).""" + from clawmes.services import rpc as rpc_mod + from clawmes.services.rpc import RpcService + + monkeypatch.setitem( + rpc_mod._DEFAULT_ENDPOINTS, 999_999, "https://not-allowlisted.example.com/rpc" + ) + svc = RpcService() + svc.start() + blocked = svc.blocked_default_endpoints() + assert 999_999 in blocked + assert blocked[999_999] == "https://not-allowlisted.example.com/rpc" + # 4663 must NOT appear — its host is allowlisted. + assert 4663 not in blocked + + def test_blocked_user_endpoints_detects_non_allowlisted_override(self, monkeypatch): + from clawmes.services.rpc import RpcService + + monkeypatch.setenv("CLAWMES_RPC_8453", "https://my-private-rpc.example.com") + svc = RpcService() + svc.start() + blocked = svc.blocked_user_endpoints() + assert 8453 in blocked + assert svc.blocked_default_endpoints() == {} + + def test_blocked_endpoints_tolerate_service_import_failure(self, monkeypatch): + """The self-check is diagnostics — it must never raise.""" + import builtins + + from clawmes.services.rpc import RpcService + + svc = RpcService() + svc.start() + real_import = builtins.__import__ + + def broken_import(name, *args, **kw): + if name == "clawmes.services.endpoint_allowlist": + raise RuntimeError("service module broken") + return real_import(name, *args, **kw) + + monkeypatch.setattr(builtins, "__import__", broken_import) + assert svc.blocked_default_endpoints() == {} + def test_env_override(self, monkeypatch): monkeypatch.setenv("CLAWMES_RPC_8453", "https://eth-mainnet.g.alchemy.com/custom") svc = RpcService() diff --git a/tests/services/test_token_decimals.py b/tests/services/test_token_decimals.py index 54f5dea..6c7fe14 100644 --- a/tests/services/test_token_decimals.py +++ b/tests/services/test_token_decimals.py @@ -93,6 +93,16 @@ def test_invalid_uint8_falls_back_to_18(self, fake_rpc): d = svc.get(token, 8453) assert d == 18 + def test_robinhood_chain_lookup(self, fake_rpc): + """The lookup is chain-agnostic: chain 4663 reads work exactly like + Base reads once the RHC RPC host is allowlisted (see services.rpc's + allowlist self-check).""" + svc = TokenDecimalsService() + svc.start() + rhc_clawnch = "0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA" + fake_rpc.responses[rhc_clawnch.lower()] = "0x12" + assert svc.get_strict(rhc_clawnch, 4663) == 18 + class TestStrictMode: def test_strict_seed_returns_value(self, fake_rpc): diff --git a/tests/tools/test_clawnch_fees.py b/tests/tools/test_clawnch_fees.py index 6e4cc90..c5a0345 100644 --- a/tests/tools/test_clawnch_fees.py +++ b/tests/tools/test_clawnch_fees.py @@ -21,6 +21,32 @@ def __init__(self): self.my_launches_return: dict = {"launches": []} self.launch_return: dict = {"name": "X"} self.raise_on: Exception | None = None + self.rh_launches_return: dict = { + "ok": True, + "chain": "robinhood", + "launches": [], + "pagination": {"limit": 50, "offset": 0, "total": 0, "hasMore": False}, + } + self.rh_launches_calls: list[dict] = [] + self.rh_claimable_return: dict = { + "ok": True, + "chainId": 4663, + "token": "0x" + "a" * 40, + "address": "0x" + "1" * 40, + "feeShare": "0x" + "e" * 40, + "isClaimer": True, + "bps": 4000, + "claimableWei": "1000000000000000", + "claim": {"to": "0x" + "e" * 40, "data": "0x1", "value": "0x0", "chainId": 4663}, + } + self.rh_claimable_calls: list[dict] = [] + self.rh_claim_return: dict = { + "ok": True, + "ready": True, + "claimableWei": "1000000000000000", + "claim": {"to": "0x" + "e" * 40, "data": "0x1", "value": "0x0", "chainId": 4663}, + } + self.rh_claim_calls: list[str] = [] def get_my_launches(self): if self.raise_on: @@ -32,6 +58,24 @@ def get_launch(self, token): raise self.raise_on return self.launch_return + def rh_launches(self, *, agent=None, limit=50, offset=0): + self.rh_launches_calls.append({"agent": agent, "limit": limit, "offset": offset}) + if self.raise_on: + raise self.raise_on + return self.rh_launches_return + + def rh_claimable(self, *, token, address): + self.rh_claimable_calls.append({"token": token, "address": address}) + if self.raise_on: + raise self.raise_on + return self.rh_claimable_return + + def rh_claim(self, *, token): + self.rh_claim_calls.append(token) + if self.raise_on: + raise self.raise_on + return self.rh_claim_return + @pytest.fixture def fake_svc(monkeypatch): @@ -105,3 +149,127 @@ def register_tool(self, **kw): register(FakeCtx()) assert captured == ["clawnch_fees"] + + +# ────────────────────────────────────────────────────────────────────── +# Robinhood Chain actions +# ────────────────────────────────────────────────────────────────────── + +_ADDR = "0x" + "a" * 40 +_WALLET = "0x" + "1" * 40 + + +class TestRHLaunches: + def test_empty_feed(self, fake_svc): + out = json.loads(clawnch_fees({"action": "rh_launches"})) + assert "0 Robinhood Chain launch" in out["content"][0]["text"] + + def test_with_rows_and_total(self, fake_svc): + fake_svc.rh_launches_return = { + "ok": True, + "launches": [{"token": _ADDR}, {"token": _ADDR}], + "pagination": {"limit": 50, "offset": 0, "total": 9, "hasMore": False}, + } + out = json.loads(clawnch_fees({"action": "rh_launches"})) + assert "2 Robinhood Chain launch(es)" in out["content"][0]["text"] + assert "of 9" in out["content"][0]["text"] + assert fake_svc.rh_launches_calls[0] == {"agent": None, "limit": 50, "offset": 0} + + def test_agent_filter_and_pagination(self, fake_svc): + out = json.loads( + clawnch_fees({"action": "rh_launches", "agent": _WALLET, "limit": 5, "offset": 10}) + ) + assert fake_svc.rh_launches_calls[0] == {"agent": _WALLET, "limit": 5, "offset": 10} + assert _WALLET in out["content"][0]["text"] + + def test_clawnch_error(self, fake_svc): + fake_svc.raise_on = ClawnchError("rate_limited", "slow down") + out = json.loads(clawnch_fees({"action": "rh_launches"})) + assert out["isError"] is True + assert out["details"]["error_code"] == "rate_limited" + + +class TestRHClaimable: + def test_requires_token(self, fake_svc): + out = json.loads(clawnch_fees({"action": "rh_claimable"})) + assert out["isError"] is True + assert out["details"]["error_code"] == "param_error" + + def test_requires_address(self, fake_svc): + out = json.loads(clawnch_fees({"action": "rh_claimable", "token": _ADDR})) + assert out["isError"] is True + assert out["details"]["error_code"] == "param_error" + + def test_claimer_summary(self, fake_svc): + out = json.loads( + clawnch_fees({"action": "rh_claimable", "token": _ADDR, "address": _WALLET}) + ) + assert fake_svc.rh_claimable_calls[0] == {"token": _ADDR, "address": _WALLET} + assert "0.001000 ETH" in out["content"][0]["text"] + assert "4000 bps" in out["content"][0]["text"] + + def test_not_claimer_summary(self, fake_svc): + fake_svc.rh_claimable_return = dict(fake_svc.rh_claimable_return, isClaimer=False) + out = json.loads( + clawnch_fees({"action": "rh_claimable", "token": _ADDR, "address": _WALLET}) + ) + assert "not a fee claimer" in out["content"][0]["text"] + + def test_bad_claimable_value_is_tolerated(self, fake_svc): + fake_svc.rh_claimable_return = dict( + fake_svc.rh_claimable_return, claimableWei="not-a-number" + ) + out = json.loads( + clawnch_fees({"action": "rh_claimable", "token": _ADDR, "address": _WALLET}) + ) + assert "isError" not in out + + def test_clawnch_error(self, fake_svc): + fake_svc.raise_on = ClawnchError("no_fee_share", "not a Bags token") + out = json.loads( + clawnch_fees({"action": "rh_claimable", "token": _ADDR, "address": _WALLET}) + ) + assert out["isError"] is True + assert out["details"]["error_code"] == "no_fee_share" + + +class TestRHClaim: + def test_requires_token(self, fake_svc): + out = json.loads(clawnch_fees({"action": "rh_claim"})) + assert out["isError"] is True + assert out["details"]["error_code"] == "param_error" + + def test_ready_claim(self, fake_svc): + out = json.loads(clawnch_fees({"action": "rh_claim", "token": _ADDR})) + assert fake_svc.rh_claim_calls == [_ADDR] + assert "Unsigned claim tx ready" in out["content"][0]["text"] + assert out["details"]["claim"]["chainId"] == 4663 + + def test_nothing_claimable(self, fake_svc): + fake_svc.rh_claim_return = { + "ok": True, + "ready": False, + "claimableWei": "0", + "claim": None, + "note": "re-check after more trading", + } + out = json.loads(clawnch_fees({"action": "rh_claim", "token": _ADDR})) + assert "isError" not in out + assert "Nothing claimable" in out["content"][0]["text"] + assert "re-check" in out["content"][0]["text"] + + def test_not_claimer_error(self, fake_svc): + fake_svc.raise_on = ClawnchError("not_claimer", "not a claimer") + out = json.loads(clawnch_fees({"action": "rh_claim", "token": _ADDR})) + assert out["isError"] is True + assert out["details"]["error_code"] == "not_claimer" + + +class TestSchema: + def test_rhc_actions_advertised(self): + from clawmes.tools.clawnch_fees import _SCHEMA + + enum = _SCHEMA["properties"]["action"]["enum"] + assert {"rh_launches", "rh_claimable", "rh_claim"} <= set(enum) + for key in ("token", "address", "agent", "limit", "offset"): + assert key in _SCHEMA["properties"] diff --git a/tests/tools/test_clawnch_launch.py b/tests/tools/test_clawnch_launch.py index cb6e516..3140d2e 100644 --- a/tests/tools/test_clawnch_launch.py +++ b/tests/tools/test_clawnch_launch.py @@ -26,6 +26,32 @@ def __init__(self): self.deploy_raise: Exception | None = None self.info_return: dict = {"name": "X"} self.info_raise: Exception | None = None + self.prepare_raises: Exception | None = None + self.prepare_calls: list[dict] = [] + self.rh_ticket_return: dict = { + "ok": True, + "data": {"to": "0xrouter", "data": "0xdead", "value": "0x1", "chainId": 4663}, + "ticket": {"agent": "0x" + "1" * 40}, + "meta": { + "chain": "robinhood", + "depositAddress": "0xdeposit", + "creationFeeWei": "20000000000000000", + }, + } + self.rh_ticket_raises: Exception | None = None + self.rh_ticket_calls: list[dict] = [] + self.rh_confirm_return: dict = {"ok": True, "launch": {"token": "0x" + "a" * 40}} + self.rh_confirm_raises: Exception | None = None + self.rh_confirm_calls: list[str] = [] + self.rh_deposit_return: dict = {"ok": True, "launch": {"token": "0x" + "a" * 40}} + self.rh_deposit_raises: Exception | None = None + self.rh_deposit_calls: list[dict] = [] + self.rh_token_info_return: dict = { + "chain": "robinhood", + "chain_id": 4663, + "token_address": "0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA", + "trade_url": "https://bags.fm/token/0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA", + } def deploy(self, *, token_params, bypass_tx_hash=None, burn_tx_hash=None): self.deploys.append( @@ -45,6 +71,33 @@ def get_launch(self, token): raise self.info_raise return self.info_return + def prepare_deploy(self, **kwargs): + self.prepare_calls.append(kwargs) + if self.prepare_raises: + raise self.prepare_raises + return self.deploy_return + + def rh_ticket(self, **kwargs): + self.rh_ticket_calls.append(kwargs) + if self.rh_ticket_raises: + raise self.rh_ticket_raises + return self.rh_ticket_return + + def rh_confirm_launch(self, *, tx_hash): + self.rh_confirm_calls.append(tx_hash) + if self.rh_confirm_raises: + raise self.rh_confirm_raises + return self.rh_confirm_return + + def rh_deposit_launch(self, **kwargs): + self.rh_deposit_calls.append(kwargs) + if self.rh_deposit_raises: + raise self.rh_deposit_raises + return self.rh_deposit_return + + def rh_token_info(self): + return self.rh_token_info_return + @pytest.fixture def fake_svc(monkeypatch): @@ -291,3 +344,239 @@ def register_tool(self, **kw): register(FakeCtx()) assert captured == ["clawnch_launch"] + + +# ────────────────────────────────────────────────────────────────────── +# Robinhood Chain actions +# ────────────────────────────────────────────────────────────────────── + +_ADDR = "0x" + "a" * 40 +_WALLET = "0x" + "1" * 40 +_TX = "0x" + "f" * 64 + + +class TestExtractChainId: + def test_data_chain_id_wins(self): + from clawmes.tools.clawnch_launch import _extract_chain_id + + assert _extract_chain_id({"data": {"chainId": 4663}}) == 4663 + assert _extract_chain_id({"data": {"chainId": 8453}}) == 8453 + + def test_top_level_and_snake_case(self): + from clawmes.tools.clawnch_launch import _extract_chain_id + + assert _extract_chain_id({"chainId": 4663}) == 4663 + assert _extract_chain_id({"chain_id": "4663"}) == 4663 + assert _extract_chain_id({"data": {"chain_id": 4663}}) == 4663 + + def test_meta_chain_fallback(self): + from clawmes.tools.clawnch_launch import _extract_chain_id + + assert _extract_chain_id({"meta": {"chain": "robinhood"}}) == 4663 + + def test_base_default_only_when_absent(self): + from clawmes.tools.clawnch_launch import _extract_chain_id + + # The Base custodial path echoes no chain id → Base. + assert _extract_chain_id({}) == 8453 + assert _extract_chain_id({"txHash": "0x1"}) == 8453 + + def test_garbage_chain_id_falls_through(self): + from clawmes.tools.clawnch_launch import _extract_chain_id + + assert _extract_chain_id({"data": {"chainId": "not-a-number"}}) == 8453 + + +class TestDeployChainGuard: + def test_robinhood_chain_surfaces_unsupported(self, fake_svc): + fake_svc.prepare_raises = ClawnchError( + "unsupported_chain", "prepare_deploy is Base-only … use rh_ticket" + ) + out = json.loads( + clawnch_launch( + { + "action": "deploy", + "name": "Foo", + "symbol": "FOO", + "chain": "robinhood", + "from_address": _WALLET, + } + ) + ) + assert out["isError"] is True + assert out["details"]["error_code"] == "unsupported_chain" + # The tool routed the request to the non-custodial prepare path — + # never silently through the Base custodial deploy. + assert fake_svc.prepare_calls[0]["chain"] == "robinhood" + assert fake_svc.prepare_calls[0]["from_address"] == _WALLET + assert fake_svc.deploys == [] + + def test_base_chain_uses_custodial_deploy(self, fake_svc): + out = json.loads( + clawnch_launch({"action": "deploy", "name": "Foo", "symbol": "FOO", "chain": "base"}) + ) + assert out["details"]["txHash"] == "0xtx" + assert fake_svc.prepare_calls == [] + + +class TestRHTicketAction: + def test_requires_name_symbol_wallet(self, fake_svc): + out = json.loads(clawnch_launch({"action": "rh_ticket", "name": "Foo"})) + assert out["isError"] is True + assert out["details"]["error_code"] == "param_error" + out = json.loads(clawnch_launch({"action": "rh_ticket", "symbol": "FOO"})) + assert out["isError"] is True + out = json.loads(clawnch_launch({"action": "rh_ticket", "name": "Foo", "symbol": "FOO"})) + assert out["isError"] is True + assert "from_address" in out["content"][0]["text"] + + def test_success_path(self, fake_svc): + out = json.loads( + clawnch_launch( + { + "action": "rh_ticket", + "name": "Foo", + "symbol": "FOO", + "from_address": _WALLET, + "description": "d", + "image": "https://x/i.png", + "fee_recipient": _ADDR, + } + ) + ) + assert out["details"]["data"]["chainId"] == 4663 + call = fake_svc.rh_ticket_calls[0] + assert call["agent_wallet"] == _WALLET + assert call["name"] == "Foo" + assert call["symbol"] == "FOO" + assert call["description"] == "d" + assert call["image"] == "https://x/i.png" + assert call["fee_recipient"] == _ADDR + text = out["content"][0]["text"] + assert "rh_confirm" in text + assert "0xdeposit" in text # deposit-path guidance + + def test_error_surfaces_code(self, fake_svc): + fake_svc.rh_ticket_raises = ClawnchError("wallet_mismatch", "wrong wallet") + out = json.loads( + clawnch_launch( + {"action": "rh_ticket", "name": "Foo", "symbol": "FOO", "from_address": _WALLET} + ) + ) + assert out["isError"] is True + assert out["details"]["error_code"] == "wallet_mismatch" + + def test_unexpected_error(self, fake_svc): + fake_svc.rh_ticket_raises = RuntimeError("boom") + out = json.loads( + clawnch_launch( + {"action": "rh_ticket", "name": "Foo", "symbol": "FOO", "from_address": _WALLET} + ) + ) + assert out["details"]["error_code"] == "api_error" + + +class TestRHConfirmAction: + def test_requires_tx_hash(self, fake_svc): + out = json.loads(clawnch_launch({"action": "rh_confirm"})) + assert out["isError"] is True + assert out["details"]["error_code"] == "param_error" + + def test_success_adds_links(self, fake_svc): + out = json.loads(clawnch_launch({"action": "rh_confirm", "tx_hash": _TX})) + assert fake_svc.rh_confirm_calls == [_TX] + assert out["details"]["explorer_url"] == f"https://robinhoodchain.blockscout.com/tx/{_TX}" + token = out["details"]["launch"]["token"] + assert out["details"]["trade_url"] == f"https://bags.fm/token/{token}" + assert out["details"]["token_explorer_url"].startswith( + "https://robinhoodchain.blockscout.com/token/" + ) + + def test_error_surfaces_code(self, fake_svc): + fake_svc.rh_confirm_raises = ClawnchError("tx_not_found", "no such tx") + out = json.loads(clawnch_launch({"action": "rh_confirm", "tx_hash": _TX})) + assert out["isError"] is True + assert out["details"]["error_code"] == "tx_not_found" + + def test_unexpected_error(self, fake_svc): + fake_svc.rh_confirm_raises = RuntimeError("boom") + out = json.loads(clawnch_launch({"action": "rh_confirm", "tx_hash": _TX})) + assert out["details"]["error_code"] == "api_error" + + +class TestRHDepositAction: + def test_requires_all_fields(self, fake_svc): + out = json.loads(clawnch_launch({"action": "rh_deposit"})) + assert out["isError"] is True + assert out["details"]["error_code"] == "param_error" + + def test_success_path(self, fake_svc): + out = json.loads( + clawnch_launch( + { + "action": "rh_deposit", + "deposit_tx_hash": _TX, + "from_address": _WALLET, + "name": "Foo", + "symbol": "FOO", + "description": "d", + } + ) + ) + call = fake_svc.rh_deposit_calls[0] + assert call["deposit_tx_hash"] == _TX + assert call["agent_wallet"] == _WALLET + assert call["name"] == "Foo" + assert call["symbol"] == "FOO" + assert out["details"]["trade_url"] == f"https://bags.fm/token/{_ADDR}" + assert "deposit" in out["content"][0]["text"] + + def test_error_surfaces_code(self, fake_svc): + fake_svc.rh_deposit_raises = ClawnchError("duplicate_deposit", "used") + out = json.loads( + clawnch_launch( + { + "action": "rh_deposit", + "deposit_tx_hash": _TX, + "from_address": _WALLET, + "name": "Foo", + "symbol": "FOO", + } + ) + ) + assert out["isError"] is True + assert out["details"]["error_code"] == "duplicate_deposit" + + def test_unexpected_error(self, fake_svc): + fake_svc.rh_deposit_raises = RuntimeError("boom") + out = json.loads( + clawnch_launch( + { + "action": "rh_deposit", + "deposit_tx_hash": _TX, + "from_address": _WALLET, + "name": "Foo", + "symbol": "FOO", + } + ) + ) + assert out["details"]["error_code"] == "api_error" + + +class TestRHTokenAction: + def test_returns_rhc_token(self, fake_svc): + out = json.loads(clawnch_launch({"action": "rh_token"})) + assert out["details"]["token_address"] == "0x6a50F139F3eD4C9c7bDa0D067c5Ed09De1EEBbeA" + assert "bags.fm" in out["content"][0]["text"] + + +class TestSchema: + def test_rhc_actions_advertised(self): + from clawmes.tools.clawnch_launch import _SCHEMA + + enum = _SCHEMA["properties"]["action"]["enum"] + assert {"rh_ticket", "rh_confirm", "rh_deposit", "rh_token"} <= set(enum) + # from_address / tx_hash / deposit_tx_hash are declared (the RHC + # flows are unreachable otherwise). + for key in ("from_address", "tx_hash", "deposit_tx_hash", "chain"): + assert key in _SCHEMA["properties"] From 53e955b38f12539d6f32b6bb9eff8299510cb2ce Mon Sep 17 00:00:00 2001 From: clawnchdev Date: Thu, 17 Sep 2026 13:29:29 -0400 Subject: [PATCH 3/4] =?UTF-8?q?release:=20v0.21.0=20=E2=80=94=20Robinhood?= =?UTF-8?q?=20Chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version 0.20.0 -> 0.21.0 (pyproject + _version.py); first PyPI release since 0.18.2 (includes unpublished 0.19.0/0.20.0 changes) - CHANGELOG: 0.21.0 section for the RHC launch surface, allowlist fix and no-silent-fallback changes; documents the new mcp>=1.0,<2 pin (the MCP 2.x SDK renamed the tool API the server targets) --- CHANGELOG.md | 16 ++++++++++++++++ clawmes/_version.py | 2 +- pyproject.toml | 6 +++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c0a5c..c77019b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +## 0.21.0 — 2026-09-17 + +Robinhood Chain becomes the plugin's primary chain surface (chain 4663): +agent-proof launches through the Clawnch RHC router (Bags.fm), fee claims and +RHC-aware links — with every silent Base fallback replaced by a loud error. +First PyPI release since 0.18.2; includes the 0.19.0 and 0.20.0 changes +documented below. + +### Fixed — dependency pin: mcp>=1.0,<2 + +The MCP 2.x SDK renamed the low-level tool API (`Tool.inputSchema`, +`Server.list_tools`) that `clawmes/mcp_server/` targets. CI resolved the +unbounded `mcp>=1.0` to 2.x and the MCP server tests failed. The dependency +(dev extra and the production `[mcp]` extra) is now pinned to the 1.x line +until the server is ported to 2.x. + ### Fixed — Robinhood Chain was blocked by clawmes' own network allowlist The RHC RPC defaults shipped in `services/rpc.py` (4663 / 46630) were never diff --git a/clawmes/_version.py b/clawmes/_version.py index 71261bf..057cfaa 100644 --- a/clawmes/_version.py +++ b/clawmes/_version.py @@ -7,4 +7,4 @@ * Tooling that does not want to incur a full package import """ -__version__ = "0.20.0" +__version__ = "0.21.0" diff --git a/pyproject.toml b/pyproject.toml index 2d32d2f..2ded963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "clawmes" -version = "0.20.0" +version = "0.21.0" description = "Hermes Agent plugin for crypto: wallets, DEX trading, lending and staking, governance, on-chain automation." readme = "README.md" license = { text = "MIT" } @@ -66,7 +66,7 @@ dev = [ # MCP SDK is in dev so tests/mcp_server/* can import it; the same # SDK ships under the [mcp] extra for production use of the # clawmes-mcp script entry. - "mcp>=1.0", + "mcp>=1.0,<2", ] test-network = [ "responses>=0.25", @@ -78,7 +78,7 @@ test-network = [ # Run with: # clawmes-mcp mcp = [ - "mcp>=1.0", + "mcp>=1.0,<2", ] all = [ "clawmes[dev,test-network,mcp]", From 68276974c60b109d259a6d2108f25bc2bd1ff51c Mon Sep 17 00:00:00 2001 From: clawnchdev Date: Thu, 17 Sep 2026 13:38:23 -0400 Subject: [PATCH 4/4] release: sync plugin.yaml manifest version to 0.21.0 --- clawmes/plugin.yaml | 2 +- plugin.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/clawmes/plugin.yaml b/clawmes/plugin.yaml index a21c0cd..f4066cc 100644 --- a/clawmes/plugin.yaml +++ b/clawmes/plugin.yaml @@ -1,5 +1,5 @@ name: clawmes -version: 0.20.0 +version: 0.21.0 description: Hermes Agent for crypto. Wallet, swaps, DeFi, launches, automation. author: Clawnch kind: standalone diff --git a/plugin.yaml b/plugin.yaml index a21c0cd..f4066cc 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: clawmes -version: 0.20.0 +version: 0.21.0 description: Hermes Agent for crypto. Wallet, swaps, DeFi, launches, automation. author: Clawnch kind: standalone