diff --git a/README.md b/README.md
index 4b98bb2..cf1814e 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
-
+
@@ -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