diff --git a/README.md b/README.md index 4b98bb2..cf1814e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Strategies Tests License - MCP + MCP

@@ -77,18 +77,21 @@ hl apex run --mainnet ### Funding Hedge -Propose a read-only BTCSWP funding-rate hedge from the CLI or any MCP client. The default `hl hedge propose` path reads the current account position; passing `--perp-notional` switches to pure sizing mode with no account fetch or order execution. +Propose, backtest, or execute a BTCSWP funding-rate hedge from the CLI or any MCP client. The default `hl hedge propose` path reads the current account position; passing `--perp-notional` switches to pure sizing mode with no account fetch or order execution. ```bash hl hedge info --json +hl auth import --token --address 0x... --permission-tier testnet_trading hl hedge propose --asset BTC --side long --perp-notional 150000 --funding-apr 42 hl hedge propose --asset BTC --side long --perp-notional 150000 --funding-rate-8h 0.0003 --json +hl hedge execute BTC --dry-run +hl hedge execute BTC --yes hl hedge backtest --csv funding.csv --asset BTC --side long --perp-notional 150000 ``` Backtest CSVs need a `funding_rate_8h`, `perp_funding_rate_8h`, `funding_rate`, or `rate` column. Add `hedge_rate_8h`, `btcswp_rate_8h`, or `btcswp_funding_rate_8h` when you have realized BTCSWP rates; otherwise the backtest uses an idealized offset. -MCP tools: `funding_hedge_info`, `funding_hedge_propose`, `funding_hedge_backtest` +MCP tools: `funding_hedge_info`, `funding_hedge_propose`, `funding_hedge_backtest`, `funding_hedge_execute`. Live MCP execution requires `confirmed=true` plus a signing context from `HL_PRIVATE_KEY`, keystore, trusted hosted context, or a local scoped token stored with `hl auth import`. --- @@ -477,9 +480,11 @@ hl guard run -i ETH-PERP [options] # Guard trailing stop hl reflect run [--since DATE] # Performance review hl hedge info [--json] # Funding hedge profiles and schemas hl hedge propose [options] # BTCSWP funding hedge proposal +hl hedge execute BTC [--dry-run] # Execute or preview BTCSWP hedge hl hedge backtest --csv # Local funding hedge cashflow backtest # Infrastructure +hl auth import/status/export-env # Local scoped-token keyless auth hl builder approve [--mainnet] # Approve builder fee hl wallet auto [--save-env] # Create wallet (agent-friendly) hl setup check # Validate environment @@ -531,7 +536,7 @@ hl mcp serve # stdio transport (default) hl mcp serve --transport sse # SSE transport ``` -**24 tools exposed:** `strategies`, `builder_status`, `wallet_list`, `wallet_auto`, `setup_check`, `funding_hedge_info`, `funding_hedge_propose`, `funding_hedge_backtest`, `account`, `status`, `trade`, `run_strategy`, `radar_run`, `apex_status`, `apex_run`, `reflect_run`, `schedule_cancel`, `emergency_close_all`, `order_status`, `funding_rates`, `agent_memory`, `trade_journal`, `judge_report`, `obsidian_context` +**25 tools exposed:** `strategies`, `builder_status`, `wallet_list`, `wallet_auto`, `setup_check`, `funding_hedge_info`, `funding_hedge_propose`, `funding_hedge_backtest`, `funding_hedge_execute`, `account`, `status`, `trade`, `run_strategy`, `radar_run`, `apex_status`, `apex_run`, `reflect_run`, `schedule_cancel`, `emergency_close_all`, `order_status`, `funding_rates`, `agent_memory`, `trade_journal`, `judge_report`, `obsidian_context` Fast tools (strategies, builder, wallet, setup, memory, journal, judge) call Python directly — zero subprocess overhead. Local MCP is for development and agent harness testing only; hosted deployment goes through Nunchi Auth and the subscription-gated hosted-agent flow. @@ -594,7 +599,7 @@ hl run engine_mm -i BTCSWP-USDYP --tick 10 ``` cli/ CLI commands and trading engine commands/ Subcommand modules (run, apex, radar, pulse, guard, reflect, house, ...) - mcp_server.py MCP server (24 tools via FastMCP) + mcp_server.py MCP server (25 tools via FastMCP) hl_adapter.py Direct HL API adapter (live + mock) builder_fee.py Builder fee config (HL native BuilderInfo) keystore.py Encrypted keystore (geth-compatible) diff --git a/cli/commands/auth.py b/cli/commands/auth.py new file mode 100644 index 0000000..b96c586 --- /dev/null +++ b/cli/commands/auth.py @@ -0,0 +1,133 @@ +"""hl auth — local scoped-token management for keyless agent flows.""" +from __future__ import annotations + +import json +import time +from typing import Optional + +import typer + +auth_app = typer.Typer(no_args_is_help=True) + + +def _redact(token: str) -> str: + if len(token) <= 12: + return token[:2] + "..." + return token[:6] + "..." + token[-4:] + + +@auth_app.command("import", help="Store a scoped Nunchi web-auth token locally") +def auth_import( + token: str = typer.Option(..., "--token", prompt=True, hide_input=True, help="Scoped web-auth token."), + address: str = typer.Option(..., "--address", help="Authorized wallet address."), + account_id: str = typer.Option("", "--account-id", help="Optional Nunchi account id."), + permission_tier: str = typer.Option( + "testnet_trading", + "--permission-tier", + help="read_only, testnet_trading, or live_trading.", + ), + network: str = typer.Option("testnet", "--network", help="testnet or mainnet."), + allow_mainnet: bool = typer.Option(False, "--allow-mainnet", help="Allow mainnet actions."), + max_order_size: Optional[float] = typer.Option(None, "--max-order-size", help="Optional max order size."), + max_hedge_notional: Optional[float] = typer.Option( + None, + "--max-hedge-notional", + help="Optional max BTCSWP hedge notional in USD.", + ), + max_strategy_ticks: Optional[int] = typer.Option(None, "--max-strategy-ticks", help="Optional max ticks."), + require_confirmation: bool = typer.Option( + True, + "--require-confirmation/--no-require-confirmation", + help="Require confirmed=true for hosted/MCP write tools.", + ), + json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON."), +) -> None: + """Persist a scoped token so local CLI/MCP can sign without raw private keys.""" + from cli.web_auth import ScopedToken, save_scoped_token + + tier = permission_tier.strip().lower() + if tier not in {"read_only", "testnet_trading", "live_trading"}: + raise typer.BadParameter("permission-tier must be read_only, testnet_trading, or live_trading") + net = network.strip().lower() + if net not in {"testnet", "mainnet"}: + raise typer.BadParameter("network must be testnet or mainnet") + + scoped = ScopedToken( + token=token.strip(), + address=address.strip(), + account_id=account_id.strip(), + permission_tier=tier, + network=net, + allow_mainnet=allow_mainnet, + max_order_size=max_order_size, + max_hedge_notional=max_hedge_notional, + max_strategy_ticks=max_strategy_ticks, + require_confirmation=require_confirmation, + created_at_ms=int(time.time() * 1000), + ) + path = save_scoped_token(scoped) + payload = { + "stored": True, + "path": str(path), + "address": scoped.address, + "permission_tier": scoped.permission_tier, + "network": scoped.network, + "allow_mainnet": scoped.allow_mainnet, + "token": _redact(scoped.token), + } + typer.echo(json.dumps(payload, indent=2) if json_output else f"Stored scoped token for {scoped.address} at {path}") + + +@auth_app.command("status", help="Show stored scoped-token status") +def auth_status(json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON.")) -> None: + from cli.web_auth import load_scoped_token, scoped_token_path + + scoped = load_scoped_token() + if scoped is None: + payload = {"configured": False, "path": str(scoped_token_path())} + else: + payload = { + "configured": True, + "path": str(scoped_token_path()), + "address": scoped.address, + "account_id": scoped.account_id, + "permission_tier": scoped.permission_tier, + "network": scoped.network, + "allow_mainnet": scoped.allow_mainnet, + "max_order_size": scoped.max_order_size, + "max_hedge_notional": scoped.max_hedge_notional, + "max_strategy_ticks": scoped.max_strategy_ticks, + "require_confirmation": scoped.require_confirmation, + "token": _redact(scoped.token), + } + if json_output: + typer.echo(json.dumps(payload, indent=2)) + elif not payload["configured"]: + typer.echo(f"No scoped token configured at {payload['path']}") + else: + typer.echo( + f"Scoped token active for {payload['address']} " + f"({payload['permission_tier']}, {payload['network']})" + ) + + +@auth_app.command("export-env", help="Print shell exports for the stored scoped token") +def auth_export_env() -> None: + from cli.web_auth import scoped_token_env + + env = scoped_token_env() + if not env: + typer.echo("No scoped token configured.", err=True) + raise typer.Exit(1) + for key, value in env.items(): + escaped = value.replace("'", "'\"'\"'") + typer.echo(f"export {key}='{escaped}'") + + +@auth_app.command("revoke", help="Delete the local scoped token") +def auth_revoke() -> None: + from cli.web_auth import clear_scoped_token, scoped_token_path + + path = scoped_token_path() + clear_scoped_token() + typer.echo(f"Removed local scoped token at {path}") diff --git a/cli/commands/hedge.py b/cli/commands/hedge.py index 2292e59..56daf41 100644 --- a/cli/commands/hedge.py +++ b/cli/commands/hedge.py @@ -245,6 +245,16 @@ def execute_cmd( dry_run: bool = typer.Option(False, "--dry-run", help="Preview only; do not sign or submit"), yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirm"), mainnet: bool = typer.Option(False, "--mainnet", help="Use mainnet (default: testnet)"), + max_hedge_notional: Optional[float] = typer.Option( + None, + "--max-hedge-notional", + help="Reject execution if proposed BTCSWP hedge notional exceeds this USD cap.", + ), + policy: Optional[Path] = typer.Option( + None, + "--policy", + help="Session policy file (or inline JSON / NUNCHI_SESSION_POLICY env).", + ), ): """Build the proposal and optionally sign + submit a real yex:{COIN}SWP order. @@ -256,8 +266,13 @@ def execute_cmd( from cli.display import BOLD, GREEN, RESET from cli.hedge_display import hedge_proposal_block from cli.hl_adapter import DirectHLProxy + from cli.session_policy import ACTION_HEDGE, current_workspace, guard_or_exit from parent.hl_proxy import HLProxy + network = "mainnet" if mainnet else "testnet" + policy_path = str(policy) if policy else None + guard_or_exit(ACTION_HEDGE, policy_path=policy_path, network=network) + cfg = TradingConfig() private_key = cfg.get_private_key() raw_hl = HLProxy(private_key=private_key, testnet=not mainnet) @@ -266,6 +281,23 @@ def execute_cmd( proposal, snapshot = _build_proposal(hl, coin) typer.echo(hedge_proposal_block(proposal, snapshot, mainnet=mainnet)) + if max_hedge_notional is not None and proposal.hedge_notional_usd > max_hedge_notional: + typer.echo( + f"Refusing hedge: proposed notional ${proposal.hedge_notional_usd:,.2f} " + f"exceeds cap ${max_hedge_notional:,.2f}.", + err=True, + ) + raise typer.Exit(2) + + pol = guard_or_exit( + ACTION_HEDGE, + policy_path=policy_path, + wallet=getattr(hl, "_address", None), + network=network, + market=proposal.profile.cfi_instrument, + notional_usd=proposal.hedge_notional_usd, + ) + # Size the order in CFI v2 (BTCSWP) units. SDK rounds to szDecimals. wire_px = snapshot.oracle_px or proposal.profile.baseline_b0 size = proposal.hedge_notional_usd / wire_px @@ -309,6 +341,15 @@ def execute_cmd( f"{fill.instrument} @ {fill.price} (oid={fill.oid})" ) + if pol is not None and pol.daily_notional_limit_usd is not None: + from cli.session_policy import PolicyCounters + PolicyCounters().record( + getattr(hl, "_address", None), + network, + current_workspace(), + abs(float(fill.quantity) * float(fill.price)), + ) + # Persist the HedgeJob. job_id = f"HEDGE-{int(time.time() * 1000)}" job = { @@ -327,7 +368,7 @@ def execute_cmd( "status": "active", "cumulative_savings_usd": 0.0, "last_sample_at_ms": int(time.time() * 1000), - "network": "mainnet" if mainnet else "testnet", + "network": network, } hedges = _load_hedges() hedges.insert(0, job) diff --git a/cli/commands/setup.py b/cli/commands/setup.py index 4eb788b..c77e474 100644 --- a/cli/commands/setup.py +++ b/cli/commands/setup.py @@ -38,7 +38,7 @@ def setup_check(): ok_items.append("HL_PRIVATE_KEY set") if pairing is None: warnings.append( - "Raw-key mode active. For MCP/agent use, prefer `hl pair connect` or hosted Nunchi Auth " + "Raw-key mode active. For MCP/agent use, prefer `hl auth import` or hosted Nunchi Auth " "so the AI client receives scoped access instead of a private key." ) elif has_keystore: @@ -50,6 +50,8 @@ def setup_check(): ok_items.append("HL_KEYSTORE_PASSWORD found in ~/.hl-agent/env") else: issues.append("HL_KEYSTORE_PASSWORD not set (needed for auto-unlock)") + elif pairing is not None: + ok_items.append(f"scoped-token signing context found ({pairing.address})") else: issues.append("No private key: set HL_PRIVATE_KEY or run 'hl wallet import'") if pairing is not None: @@ -57,7 +59,7 @@ def setup_check(): else: warnings.append( "No web-auth pairing context found. Hosted/keyless signing uses " - "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS." + "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS, or run `hl auth import` locally." ) # 3. Network diff --git a/cli/main.py b/cli/main.py index a928b7a..e0b84a7 100644 --- a/cli/main.py +++ b/cli/main.py @@ -36,6 +36,7 @@ from cli.commands.journal import journal_app from cli.commands.keys import keys_app from cli.commands.hedge import hedge_app +from cli.commands.auth import auth_app from cli.commands.margin import margin_app from cli.commands.trading import trading_app from cli.commands.house import house_app @@ -66,6 +67,7 @@ app.add_typer(skills_app, name="skills", help="Skill discovery and registry") app.add_typer(journal_app, name="journal", help="Trade journal — structured position records with reasoning") app.add_typer(keys_app, name="keys", help="Unified key management across backends") +app.add_typer(auth_app, name="auth", help="Scoped-token auth for keyless local agents") app.add_typer(hedge_app, name="hedge", help="CFI v2 funding-rate hedge — propose, execute, status, backtest, auto") app.add_typer(margin_app, name="margin", help="HL collateral — deposits, sub-DEX transfers, isolated margin, auto-topup") app.add_typer(trading_app, name="trading", help="Trading data surfaces — joined JSON contracts for UIs/bridges") diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 3f54491..5a25f33 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -34,6 +34,7 @@ # Tools that move funds or cancel/close live orders/positions — handle with care. _DESTRUCTIVE_TOOLS = { "trade", "run_strategy", "apex_run", "schedule_cancel", "emergency_close_all", + "funding_hedge_execute", } # Everything else (wallet_auto, radar_run, reflect_run) is # state-changing-but-safe: neither a pure read nor fund-destructive. @@ -61,6 +62,8 @@ "x-nunchi-secret-nunchi-allow-mainnet": "NUNCHI_ALLOW_MAINNET", "x-nunchi-max-order-size": "NUNCHI_MAX_ORDER_SIZE", "x-nunchi-secret-nunchi-max-order-size": "NUNCHI_MAX_ORDER_SIZE", + "x-nunchi-max-hedge-notional": "NUNCHI_MAX_HEDGE_NOTIONAL", + "x-nunchi-secret-nunchi-max-hedge-notional": "NUNCHI_MAX_HEDGE_NOTIONAL", "x-nunchi-max-strategy-ticks": "NUNCHI_MAX_STRATEGY_TICKS", "x-nunchi-secret-nunchi-max-strategy-ticks": "NUNCHI_MAX_STRATEGY_TICKS", "x-nunchi-require-confirmation": "NUNCHI_REQUIRE_CONFIRMATION", @@ -187,7 +190,14 @@ def _policy_from_context_env(env: dict[str, str]) -> Optional[str]: if tier == "read_only": policy["allowed_actions"] = ["__read_only__"] elif tier in ("testnet_trading", "live_trading"): - policy["allowed_actions"] = ["trade", "run", "builder-approve"] + policy["allowed_actions"] = ["trade", "run", "builder-approve", "hedge"] + + hedge_cap = (env.get("NUNCHI_MAX_HEDGE_NOTIONAL") or "").strip() + if hedge_cap: + try: + policy["max_notional_usd_per_action"] = float(hedge_cap) + except ValueError: + pass if not policy: return None @@ -356,24 +366,36 @@ def _ann(name: str, title: str): "yex-trader", instructions=( "Autonomous Hyperliquid trading CLI — 14 strategies, APEX orchestrator, " - "REFLECT reviews, BTCSWP funding hedge proposals. Always confirm details with the user before calling " + "REFLECT reviews, BTCSWP funding hedge proposal and execution. Use `hl auth import` locally " + "or trusted Nunchi gateway context for scoped-token keyless signing. Always confirm details with the user before calling " "destructive tools (trade, run_strategy, apex_run, schedule_cancel, " - "emergency_close_all). " - "emergency_close_all requires confirm=true." + "emergency_close_all, funding_hedge_execute). " + "emergency_close_all requires confirm=true; funding_hedge_execute requires confirmed=true for live execution." ), ) def _request_env(ctx: Any = None) -> dict[str, str]: + def _local_scoped_env() -> dict[str, str]: + try: + from cli.web_auth import scoped_token_env + env = scoped_token_env() + policy = _policy_from_context_env(env) + if policy is not None: + env["NUNCHI_SESSION_POLICY"] = policy + return env + except Exception: + return {} + if ctx is not None: - return _trusted_context_env_overrides(ctx) + return _trusted_context_env_overrides(ctx) or _local_scoped_env() try: get_context = getattr(mcp, "get_context") except AttributeError: - return {} + return _local_scoped_env() try: - return _trusted_context_env_overrides(get_context()) + return _trusted_context_env_overrides(get_context()) or _local_scoped_env() except Exception: - return {} + return _local_scoped_env() # ------------------------------------------------------------------ # Fast tools — call Python directly (no subprocess overhead) @@ -493,7 +515,7 @@ def setup_check(ctx: FastMCPContext = None) -> str: ok_items.append("HL_PRIVATE_KEY set") if pairing is None and not has_web_auth: warnings.append( - "Raw-key mode active. Prefer hl pair connect or hosted Nunchi Auth for MCP/agent use." + "Raw-key mode active. Prefer hl auth import or hosted Nunchi Auth for MCP/agent use." ) elif has_web_auth: ok_items.append("web-auth pairing context provided") @@ -509,7 +531,7 @@ def setup_check(ctx: FastMCPContext = None) -> str: elif not has_web_auth: warnings.append( "No web-auth pairing context found. Hosted/keyless signing uses " - "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS." + "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS, or hl auth import locally." ) # Network @@ -547,7 +569,7 @@ def funding_hedge_propose( funding_rate_8h: Optional[float] = None, vol_multiplier: float = 15.0, ) -> str: - """Propose a read-only BTCSWP funding-rate hedge. + """Propose a BTCSWP funding-rate hedge without placing orders. Args: asset: Underlying perp exposure. BTC is deployed today. @@ -607,6 +629,53 @@ def funding_hedge_backtest( return json.dumps({"error": str(exc)}, indent=2) return json.dumps(backtest.to_dict(), indent=2) + @mcp.tool(**_ann("funding_hedge_execute", "Execute funding hedge")) + def funding_hedge_execute( + coin: str = "BTC", + dry_run: bool = True, + mainnet: bool = False, + max_hedge_notional_usd: Optional[float] = None, + confirmed: bool = False, + ctx: FastMCPContext = None, + ) -> str: + """Execute the live CFI v2 hedge path through MCP. + + This wraps `hl hedge execute`. Live execution requires confirmed=true + and a signing context from trusted MCP headers, environment, keystore, + private key, or local `hl auth import` scoped-token storage. + """ + if not dry_run and not confirmed: + return _json_error("funding_hedge_execute requires confirmed=true unless dry_run=true.") + env_overrides = _request_env(ctx) + error = _context_limit_error( + "funding_hedge_execute", + env_overrides, + mainnet=mainnet, + confirmed=confirmed or dry_run, + require_signing=True, + ) + if error: + return _json_error(error) + hedge_cap = max_hedge_notional_usd + env_cap = _effective_env("NUNCHI_MAX_HEDGE_NOTIONAL", env_overrides) + if hedge_cap is None and env_cap: + try: + hedge_cap = float(env_cap) + except ValueError: + return _json_error("invalid NUNCHI_MAX_HEDGE_NOTIONAL in scoped context.") + if hedge_cap is not None and hedge_cap <= 0: + return _json_error("max_hedge_notional_usd must be positive.") + args = ["hedge", "execute", coin] + if dry_run: + args.append("--dry-run") + else: + args.append("--yes") + if hedge_cap is not None: + args.extend(["--max-hedge-notional", str(hedge_cap)]) + if mainnet: + args.append("--mainnet") + return _run_hl(*args, timeout=300, env_overrides=env_overrides) + @mcp.tool(**_ann("account", "Account state")) def account(mainnet: bool = False, ctx: FastMCPContext = None) -> str: """Get Hyperliquid account state (balances, positions).""" diff --git a/cli/session_policy.py b/cli/session_policy.py index 93c1135..e3f4974 100644 --- a/cli/session_policy.py +++ b/cli/session_policy.py @@ -59,6 +59,7 @@ ``run`` — start an autonomous trading loop (cli/commands/run.py) ``trade`` — place a single manual order (cli/commands/trade.py) ``builder-approve`` — approve a builder fee on-chain (cli/commands/builder.py) +``hedge`` — execute a BTCSWP funding hedge (cli/commands/hedge.py) Future commands should reuse these or add their own canonical name and pass it to ``guard_or_exit`` / ``enforce`` (e.g. ``fleet``, ``house``, ``hedge``, @@ -87,6 +88,7 @@ ACTION_RUN = "run" ACTION_TRADE = "trade" ACTION_BUILDER_APPROVE = "builder-approve" +ACTION_HEDGE = "hedge" class PolicyViolation(Exception): diff --git a/cli/web_auth.py b/cli/web_auth.py index 1173142..7fce659 100644 --- a/cli/web_auth.py +++ b/cli/web_auth.py @@ -9,7 +9,9 @@ import os import secrets import time -from dataclasses import dataclass +import json +from dataclasses import asdict, dataclass +from pathlib import Path from typing import Any, Callable, Optional import requests @@ -20,6 +22,7 @@ PAIR_TOKEN_ENV = "NUNCHI_WEB_AUTH_PAIR_TOKEN" PAIR_ADDRESS_ENV = "NUNCHI_WEB_AUTH_ADDRESS" +SCOPED_TOKEN_PATH_ENV = "NUNCHI_SCOPED_TOKEN_PATH" AGENT_WALLET_ADDRESS_ENV = "NUNCHI_AGENT_WALLET_ADDRESS" ACCOUNT_ID_ENV = "NUNCHI_ACCOUNT_ID" AGENT_ID_ENV = "NUNCHI_AGENT_ID" @@ -47,6 +50,118 @@ class WebAuthPairing: master_address: str = "" +@dataclass(frozen=True) +class ScopedToken: + token: str + address: str + account_id: str = "" + permission_tier: str = "testnet_trading" + network: str = "testnet" + allow_mainnet: bool = False + max_order_size: Optional[float] = None + max_hedge_notional: Optional[float] = None + max_strategy_ticks: Optional[int] = None + require_confirmation: bool = True + created_at_ms: int = 0 + + def to_json(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_json(cls, raw: dict[str, Any]) -> "ScopedToken": + def _bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + return cls( + token=str(raw["token"]), + address=str(raw["address"]), + account_id=str(raw.get("account_id", "")), + permission_tier=str(raw.get("permission_tier", "testnet_trading")), + network=str(raw.get("network", "testnet")), + allow_mainnet=_bool(raw.get("allow_mainnet"), False), + max_order_size=( + float(raw["max_order_size"]) + if raw.get("max_order_size") not in (None, "") + else None + ), + max_hedge_notional=( + float(raw["max_hedge_notional"]) + if raw.get("max_hedge_notional") not in (None, "") + else None + ), + max_strategy_ticks=( + int(raw["max_strategy_ticks"]) + if raw.get("max_strategy_ticks") not in (None, "") + else None + ), + require_confirmation=_bool(raw.get("require_confirmation"), True), + created_at_ms=int(raw.get("created_at_ms") or int(time.time() * 1000)), + ) + + def to_pairing(self) -> WebAuthPairing: + return WebAuthPairing(token=self.token, address=self.address, account_id=self.account_id) + + def to_env(self) -> dict[str, str]: + env = { + PAIR_TOKEN_ENV: self.token, + PAIR_ADDRESS_ENV: self.address, + "NUNCHI_TRADING_PERMISSION_TIER": self.permission_tier, + "NUNCHI_TRADING_NETWORK": self.network, + "NUNCHI_ALLOW_MAINNET": "true" if self.allow_mainnet else "false", + "NUNCHI_REQUIRE_CONFIRMATION": "true" if self.require_confirmation else "false", + } + if self.account_id: + env["NUNCHI_ACCOUNT_ID"] = self.account_id + if self.max_order_size is not None: + env["NUNCHI_MAX_ORDER_SIZE"] = str(self.max_order_size) + if self.max_hedge_notional is not None: + env["NUNCHI_MAX_HEDGE_NOTIONAL"] = str(self.max_hedge_notional) + if self.max_strategy_ticks is not None: + env["NUNCHI_MAX_STRATEGY_TICKS"] = str(self.max_strategy_ticks) + return env + + +def scoped_token_path() -> Path: + return Path(os.environ.get(SCOPED_TOKEN_PATH_ENV, "~/.hl-agent/scoped-token.json")).expanduser() + + +def load_scoped_token() -> Optional[ScopedToken]: + path = scoped_token_path() + if not path.exists(): + return None + try: + return ScopedToken.from_json(json.loads(path.read_text("utf-8"))) + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + + +def save_scoped_token(token: ScopedToken) -> Path: + path = scoped_token_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(token.to_json(), indent=2) + "\n", "utf-8") + try: + path.chmod(0o600) + except OSError: + pass + return path + + +def clear_scoped_token() -> None: + try: + scoped_token_path().unlink() + except FileNotFoundError: + pass + + +def scoped_token_env() -> dict[str, str]: + token = load_scoped_token() + return token.to_env() if token is not None else {} + + def pairing_from_env() -> Optional[WebAuthPairing]: token = os.environ.get(PAIR_TOKEN_ENV, "").strip() address = ( @@ -59,7 +174,8 @@ def pairing_from_env() -> Optional[WebAuthPairing]: agent_id = os.environ.get(AGENT_ID_ENV, "").strip() master_address = os.environ.get(MASTER_ADDRESS_ENV, "").strip() if not token or not address: - return None + scoped = load_scoped_token() + return scoped.to_pairing() if scoped is not None else None return WebAuthPairing( token=token, address=address, diff --git a/modules/funding_hedge.py b/modules/funding_hedge.py index 5cb0b31..16422ce 100644 --- a/modules/funding_hedge.py +++ b/modules/funding_hedge.py @@ -109,10 +109,17 @@ def funding_hedge_info() -> dict[str, object]: "default_vol_multiplier": BTCSWP_PROFILE["vol_multiplier"], "sizing_rule": "same-side BTCSWP, hedge_notional = perp_notional / vol_multiplier", "supported_cli": [ + "hl auth import --token ... --address ...", "hl hedge propose --perp-notional ... --funding-apr ...", + "hl hedge execute BTC --yes", "hl hedge backtest --csv ... --perp-notional ...", ], - "mcp_tools": ["funding_hedge_info", "funding_hedge_propose", "funding_hedge_backtest"], + "mcp_tools": [ + "funding_hedge_info", + "funding_hedge_propose", + "funding_hedge_backtest", + "funding_hedge_execute", + ], "csv_required_columns": ["funding_rate_8h", "perp_funding_rate_8h", "funding_rate", "rate"], "csv_optional_columns": ["hedge_rate_8h", "btcswp_rate_8h", "btcswp_funding_rate_8h"], "hedge_agent_distinction": ( @@ -121,7 +128,8 @@ def funding_hedge_info() -> dict[str, object]: ), "execution_boundary": ( "funding_hedge_info/propose/backtest do not place orders, sign payloads, " - "fetch private account state, or expose private rate methodology." + "fetch private account state, or expose private rate methodology. " + "funding_hedge_execute is live and requires confirmed=true plus a signing context." ), } diff --git a/tests/test_auth_scoped_token.py b/tests/test_auth_scoped_token.py new file mode 100644 index 0000000..ec49db6 --- /dev/null +++ b/tests/test_auth_scoped_token.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json + +from typer.testing import CliRunner + +from cli.main import app + + +runner = CliRunner() + + +def test_auth_import_status_export_and_revoke(monkeypatch, tmp_path): + token_path = tmp_path / "scoped-token.json" + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(token_path)) + + result = runner.invoke( + app, + [ + "auth", + "import", + "--token", + "scoped-token-123", + "--address", + "0x" + "8" * 40, + "--permission-tier", + "testnet_trading", + "--network", + "testnet", + "--max-order-size", + "0.5", + "--max-hedge-notional", + "12000", + "--json", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["stored"] is True + assert payload["token"] != "scoped-token-123" + assert token_path.exists() + + status = runner.invoke(app, ["auth", "status", "--json"]) + assert status.exit_code == 0 + status_payload = json.loads(status.stdout) + assert status_payload["configured"] is True + assert status_payload["address"] == "0x" + "8" * 40 + assert status_payload["max_order_size"] == 0.5 + assert status_payload["max_hedge_notional"] == 12000.0 + + exported = runner.invoke(app, ["auth", "export-env"]) + assert exported.exit_code == 0 + assert "export NUNCHI_WEB_AUTH_PAIR_TOKEN='scoped-token-123'" in exported.stdout + assert "export NUNCHI_WEB_AUTH_ADDRESS='0x" + "8" * 40 in exported.stdout + assert "export NUNCHI_MAX_HEDGE_NOTIONAL='12000.0'" in exported.stdout + + revoked = runner.invoke(app, ["auth", "revoke"]) + assert revoked.exit_code == 0 + assert not token_path.exists() + + empty = runner.invoke(app, ["auth", "status", "--json"]) + assert json.loads(empty.stdout)["configured"] is False diff --git a/tests/test_engine_strategies.py b/tests/test_engine_strategies.py index 64a6279..bc81f3a 100644 --- a/tests/test_engine_strategies.py +++ b/tests/test_engine_strategies.py @@ -259,7 +259,7 @@ def test_all_engine_strategies_registered(self): def test_total_strategies(self): from cli.strategy_registry import STRATEGY_REGISTRY - assert len(STRATEGY_REGISTRY) == 18 # 14 original + 4 directional + assert len(STRATEGY_REGISTRY) == 19 # 14 original + 4 directional + cfi_hedge def test_resolve_engine_strategies(self): from cli.strategy_registry import resolve_strategy_path diff --git a/tests/test_funding_hedge.py b/tests/test_funding_hedge.py index aae315d..04385c7 100644 --- a/tests/test_funding_hedge.py +++ b/tests/test_funding_hedge.py @@ -137,6 +137,7 @@ def test_mcp_funding_hedge_info(monkeypatch): assert payload["deployed_profiles"][0]["asset"] == "BTC" assert "funding_hedge_propose" in payload["mcp_tools"] + assert "funding_hedge_execute" in payload["mcp_tools"] def test_mcp_funding_hedge_rejects_roadmap_assets(monkeypatch): @@ -231,3 +232,100 @@ def test_mcp_funding_hedge_backtest(monkeypatch, tmp_path): assert payload["periods"] == 1 assert payload["unhedged_cashflow_usd"] == -45 assert payload["hedge_cashflow_usd"] == 45 + + +def test_mcp_funding_hedge_execute_requires_confirmation(monkeypatch, tmp_path): + install_fake_mcp(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(tmp_path / "missing-token.json")) + monkeypatch.setenv("NUNCHI_WEB_AUTH_PAIR_TOKEN", "pair-token") + monkeypatch.setenv("NUNCHI_WEB_AUTH_ADDRESS", "0x" + "9" * 40) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads(server.tools["funding_hedge_execute"](dry_run=False, confirmed=False)) + + assert "confirmed=true" in payload["error"] + + +def test_mcp_funding_hedge_execute_uses_local_scoped_token(monkeypatch, tmp_path): + install_fake_mcp(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(tmp_path / "scoped-token.json")) + + from cli.web_auth import ScopedToken, save_scoped_token + + save_scoped_token( + ScopedToken( + token="stored-token", + address="0x" + "a" * 40, + permission_tier="testnet_trading", + network="testnet", + max_hedge_notional=12_000, + require_confirmation=True, + ) + ) + + import cli.mcp_server as mcp_server + from cli.mcp_server import create_mcp_server + + captured = {} + + def fake_run_hl(*args, timeout=30, env_overrides=None): + captured["args"] = args + captured["timeout"] = timeout + captured["env_overrides"] = env_overrides + return "executed" + + monkeypatch.setattr(mcp_server, "_run_hl", fake_run_hl) + + server = create_mcp_server() + output = server.tools["funding_hedge_execute"](coin="BTC", dry_run=False, confirmed=True) + + assert output == "executed" + assert captured["args"] == ("hedge", "execute", "BTC", "--yes", "--max-hedge-notional", "12000.0") + assert captured["timeout"] == 300 + assert captured["env_overrides"]["NUNCHI_WEB_AUTH_PAIR_TOKEN"] == "stored-token" + assert captured["env_overrides"]["NUNCHI_WEB_AUTH_ADDRESS"] == "0x" + "a" * 40 + assert captured["env_overrides"]["NUNCHI_MAX_HEDGE_NOTIONAL"] == "12000.0" + policy = json.loads(captured["env_overrides"]["NUNCHI_SESSION_POLICY"]) + assert "hedge" in policy["allowed_actions"] + assert policy["max_notional_usd_per_action"] == 12000.0 + + +def test_mcp_funding_hedge_dry_run_does_not_require_confirmation(monkeypatch, tmp_path): + install_fake_mcp(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(tmp_path / "scoped-token.json")) + + from cli.web_auth import ScopedToken, save_scoped_token + + save_scoped_token( + ScopedToken( + token="stored-token", + address="0x" + "b" * 40, + permission_tier="testnet_trading", + network="testnet", + require_confirmation=True, + ) + ) + + import cli.mcp_server as mcp_server + from cli.mcp_server import create_mcp_server + + captured = {} + + def fake_run_hl(*args, timeout=30, env_overrides=None): + captured["args"] = args + captured["env_overrides"] = env_overrides + return "dry-run preview" + + monkeypatch.setattr(mcp_server, "_run_hl", fake_run_hl) + + server = create_mcp_server() + output = server.tools["funding_hedge_execute"](coin="BTC") + + assert output == "dry-run preview" + assert captured["args"] == ("hedge", "execute", "BTC", "--dry-run") + assert captured["env_overrides"]["NUNCHI_REQUIRE_CONFIRMATION"] == "true" diff --git a/tests/test_hedge_margin_port.py b/tests/test_hedge_margin_port.py index 0aa03d9..96f163f 100644 --- a/tests/test_hedge_margin_port.py +++ b/tests/test_hedge_margin_port.py @@ -254,6 +254,140 @@ def fail_persist(hedges): assert persisted is False +def test_hedge_execute_respects_max_hedge_notional(monkeypatch): + import cli.commands.hedge as hedge_cmd + import cli.config as cfgmod + import cli.hl_adapter as adapter_mod + import parent.hl_proxy as proxy_mod + + class FakeDirectHLProxy: + placed = False + + def __init__(self, raw_hl): + self.raw_hl = raw_hl + + def place_order(self, **kwargs): + FakeDirectHLProxy.placed = True + raise AssertionError("place_order should not be called when cap rejects") + + profile = SimpleNamespace(cfi_instrument="yex:BTCSWP", baseline_b0=75_000.0) + proposal = SimpleNamespace( + profile=profile, + hedge_notional_usd=10_000.0, + legs=[SimpleNamespace(), SimpleNamespace(side="long")], + ) + snapshot = SimpleNamespace(oracle_px=75_000.0) + persisted = False + + def fail_persist(hedges): + nonlocal persisted + persisted = True + raise AssertionError("cap rejection should not persist hedge state") + + monkeypatch.setattr(cfgmod.TradingConfig, "get_private_key", lambda self: "0x" + "1" * 64) + monkeypatch.setattr(proxy_mod, "HLProxy", lambda private_key, testnet: object()) + monkeypatch.setattr(adapter_mod, "DirectHLProxy", FakeDirectHLProxy) + monkeypatch.setattr(hedge_cmd, "_build_proposal", lambda hl, coin: (proposal, snapshot)) + monkeypatch.setattr("cli.hedge_display.hedge_proposal_block", lambda proposal, snapshot, mainnet=False: "proposal") + monkeypatch.setattr(hedge_cmd, "_save_hedges", fail_persist) + + result = runner.invoke(app, ["hedge", "execute", "BTC", "--max-hedge-notional", "9999", "--yes"]) + + assert result.exit_code == 2 + assert "exceeds cap" in result.output + assert FakeDirectHLProxy.placed is False + assert persisted is False + + +def test_hedge_execute_enforces_session_policy_action(monkeypatch): + import cli.commands.hedge as hedge_cmd + import cli.config as cfgmod + import cli.hl_adapter as adapter_mod + import parent.hl_proxy as proxy_mod + + class FakeDirectHLProxy: + placed = False + + def __init__(self, raw_hl): + self.raw_hl = raw_hl + + def place_order(self, **kwargs): + FakeDirectHLProxy.placed = True + raise AssertionError("place_order should not be called when policy rejects") + + profile = SimpleNamespace(cfi_instrument="yex:BTCSWP", baseline_b0=75_000.0) + proposal = SimpleNamespace( + profile=profile, + hedge_notional_usd=10_000.0, + legs=[SimpleNamespace(), SimpleNamespace(side="long")], + ) + snapshot = SimpleNamespace(oracle_px=75_000.0) + + monkeypatch.setattr(cfgmod.TradingConfig, "get_private_key", lambda self: "0x" + "1" * 64) + monkeypatch.setattr(proxy_mod, "HLProxy", lambda private_key, testnet: object()) + monkeypatch.setattr(adapter_mod, "DirectHLProxy", FakeDirectHLProxy) + monkeypatch.setattr(hedge_cmd, "_build_proposal", lambda hl, coin: (proposal, snapshot)) + monkeypatch.setattr("cli.hedge_display.hedge_proposal_block", lambda proposal, snapshot, mainnet=False: "proposal") + + result = runner.invoke( + app, + ["hedge", "execute", "BTC", "--dry-run", "--policy", '{"allowed_actions": ["trade"]}'], + ) + + assert result.exit_code == 2 + assert "REFUSED by session policy" in result.output + assert "action 'hedge'" in result.output + assert FakeDirectHLProxy.placed is False + + +def test_hedge_execute_enforces_session_policy_notional(monkeypatch): + import cli.commands.hedge as hedge_cmd + import cli.config as cfgmod + import cli.hl_adapter as adapter_mod + import parent.hl_proxy as proxy_mod + + class FakeDirectHLProxy: + placed = False + + def __init__(self, raw_hl): + self.raw_hl = raw_hl + + def place_order(self, **kwargs): + FakeDirectHLProxy.placed = True + raise AssertionError("place_order should not be called when policy rejects") + + profile = SimpleNamespace(cfi_instrument="yex:BTCSWP", baseline_b0=75_000.0) + proposal = SimpleNamespace( + profile=profile, + hedge_notional_usd=10_000.0, + legs=[SimpleNamespace(), SimpleNamespace(side="long")], + ) + snapshot = SimpleNamespace(oracle_px=75_000.0) + + monkeypatch.setattr(cfgmod.TradingConfig, "get_private_key", lambda self: "0x" + "1" * 64) + monkeypatch.setattr(proxy_mod, "HLProxy", lambda private_key, testnet: object()) + monkeypatch.setattr(adapter_mod, "DirectHLProxy", FakeDirectHLProxy) + monkeypatch.setattr(hedge_cmd, "_build_proposal", lambda hl, coin: (proposal, snapshot)) + monkeypatch.setattr("cli.hedge_display.hedge_proposal_block", lambda proposal, snapshot, mainnet=False: "proposal") + + result = runner.invoke( + app, + [ + "hedge", + "execute", + "BTC", + "--dry-run", + "--policy", + '{"allowed_actions": ["hedge"], "max_notional_usd_per_action": 9999}', + ], + ) + + assert result.exit_code == 2 + assert "REFUSED by session policy" in result.output + assert "per-action limit" in result.output + assert FakeDirectHLProxy.placed is False + + def _assert_margin_dry_run_does_not_open_hl(monkeypatch, args, expected_output): import cli.commands.margin as margin_cmd diff --git a/tests/test_mcp_annotations.py b/tests/test_mcp_annotations.py index 27baea2..4098495 100644 --- a/tests/test_mcp_annotations.py +++ b/tests/test_mcp_annotations.py @@ -13,7 +13,7 @@ def test_classification_sets_are_disjoint(): def test_destructive_set_covers_fund_movers(): from cli.mcp_server import _DESTRUCTIVE_TOOLS - for name in ("trade", "run_strategy", "apex_run", "schedule_cancel", "emergency_close_all"): + for name in ("trade", "run_strategy", "apex_run", "schedule_cancel", "emergency_close_all", "funding_hedge_execute"): assert name in _DESTRUCTIVE_TOOLS @@ -42,5 +42,6 @@ def test_server_applies_annotations(): assert by_name["trade"].annotations.readOnlyHint is False assert by_name["schedule_cancel"].annotations.destructiveHint is True assert by_name["emergency_close_all"].annotations.destructiveHint is True + assert by_name["funding_hedge_execute"].annotations.destructiveHint is True assert by_name["account"].annotations.readOnlyHint is True assert by_name["funding_rates"].annotations.readOnlyHint is True diff --git a/tests/test_mcp_gateway_context.py b/tests/test_mcp_gateway_context.py index c57b0fc..a8d7c24 100644 --- a/tests/test_mcp_gateway_context.py +++ b/tests/test_mcp_gateway_context.py @@ -32,6 +32,7 @@ def test_trusted_gateway_headers_become_scoped_env(monkeypatch): "x-nunchi-trading-permission-tier": "testnet_trading", "x-nunchi-trading-network": "testnet", "x-nunchi-max-order-size": "0.5", + "x-nunchi-max-hedge-notional": "12000", "x-nunchi-max-strategy-ticks": "12", }) @@ -45,6 +46,8 @@ def test_trusted_gateway_headers_become_scoped_env(monkeypatch): assert policy["wallets"] == ["0x" + "2" * 40] assert policy["network"] == "testnet" assert "trade" in policy["allowed_actions"] + assert "hedge" in policy["allowed_actions"] + assert policy["max_notional_usd_per_action"] == 12000.0 def test_context_limits_fail_closed_without_signing_context(monkeypatch, tmp_path): diff --git a/tests/test_web_auth_signer.py b/tests/test_web_auth_signer.py index 7475fe6..dbea389 100644 --- a/tests/test_web_auth_signer.py +++ b/tests/test_web_auth_signer.py @@ -36,6 +36,21 @@ def test_pairing_from_env_accepts_hosted_agent_wallet_address(monkeypatch): assert pairing.address == "0x" + "4" * 40 +def test_pairing_from_env_falls_back_to_stored_scoped_token(monkeypatch, tmp_path): + from cli.web_auth import ScopedToken, pairing_from_env, save_scoped_token + + monkeypatch.delenv("NUNCHI_WEB_AUTH_PAIR_TOKEN", raising=False) + monkeypatch.delenv("NUNCHI_WEB_AUTH_ADDRESS", raising=False) + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(tmp_path / "scoped-token.json")) + save_scoped_token(ScopedToken(token="stored-token", address="0x" + "7" * 40)) + + pairing = pairing_from_env() + + assert pairing is not None + assert pairing.token == "stored-token" + assert pairing.address == "0x" + "7" * 40 + + def test_split_signature_normalizes_v(): from cli.web_auth import split_signature