# /// script
# requires-python = ">=3.12"
# dependencies = ["pynacl>=1.5", "base58>=2.1", "httpx>=0.27"]
# ///
"""m2m.monster Arena creator tool. Single file; run it on your own machine, never on a server.

    uv run creator_tool.py keygen --out ~/arena-keys/root.keypair.json
    uv run creator_tool.py creator --root ~/arena-keys/root.keypair.json --handle alice --server
    uv run creator_tool.py strategy --root ROOT --handle alice --slug trend-sol --market SOL \\
        --name "Trend (SOL)" --description "..." --fee-type volume --fee-rate 0.0005 --server
    uv run creator_tool.py delegate --root ROOT --strategy-id alice/trend-sol \\
        --signal-pubkey <pubkey> --seq 1 --days 90 --server
    uv run creator_tool.py payout --root ROOT --payout-key ~/arena-keys/payout.keypair.json \\
        --seq 1 --server
    uv run creator_tool.py terms --root ROOT --strategy-id alice/trend-sol --version 1 --server
    uv run creator_tool.py signal --key SIGNAL --strategy-id alice/trend-sol --action open \\
        --data '{"side":"long","sl":"147.20"}' --server
    uv run creator_tool.py heartbeat --key SIGNAL --strategy-id alice/trend-sol --server
    uv run creator_tool.py status --key ROOT --creator-id <root pubkey>
    uv run creator_tool.py resume --root ROOT --strategy-id alice/trend-sol --server
    uv run creator_tool.py submit object.json [more.json ...]
    uv run creator_tool.py rotate --root ROOT --strategy-id alice/trend-sol \\
        --new-key ~/arena-keys/signal-2.keypair.json --old-signal-pubkey <old> --seq 2 \\
        --out-dir ./rotation

Keys (docs: CREATORS.md): the **root key** is your identity; it registers, delegates,
rotates, binds the payout address and signs your strategy's terms, and can stay offline.
**Signal keys** sign signals and heartbeats, always expire (at most 90 days) and rotate
without touching the signal chain. The **payout wallet** only receives fees (use a
dedicated wallet). `keygen`/`rotate` write Solana CLI keypair files (`*.keypair.json`)
outside any git repository with owner-only permissions and print only the public key.
Every other command prints or sends **public, signed** JSON objects.

Terms are accepted only here (or a local stdio tool you run): `terms` shows what followers
will be offered and asks you to type ACCEPT before signing with your root key.

`--server` sends the object to the Arena API (default https://apiarena.m2m.monster);
without it the object is printed (or written with `--out`), e.g. to send it later with
`submit` or to commit it for manual onboarding.

This file only needs PyNaCl, base58 and httpx; it does not import the server code.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any

import base58
from nacl.signing import SigningKey, VerifyKey

DEFAULT_SERVER = "https://apiarena.m2m.monster"
KIND_CREATOR = "m2m-arena/creator/v1"
KIND_STRATEGY = "m2m-arena/strategy/v1"
KIND_DELEGATION = "m2m-arena/delegation/v1"
KIND_SIGNAL = "m2m-arena/signal/v1"
KIND_HEARTBEAT = "m2m-arena/heartbeat/v1"
KIND_PAYOUT = "m2m-arena/payout/v1"
KIND_PAYOUT_CANCEL = "m2m-arena/payout-cancel/v1"
KIND_TERMS = "m2m-arena/terms/v1"
KIND_RESUME = "m2m-arena/resume/v1"
KIND_RETIRE = "m2m-arena/retire/v1"
MARKETS = ("SOL", "BTC", "ETH")
VOLUME_FEE_RATES = (*(f"0.000{n}" for n in range(2, 10)), "0.0010")
PERFORMANCE_FEE_RATES = ("0.10", "0.15", "0.20", "0.25")
INFRA_RATE = "0.0001"
PLATFORM_SHARE = "0.25"
KEY_SUFFIX = ".keypair.json"
DAY_MS = 86_400_000
MAX_KEY_LIFETIME_MS = 90 * DAY_MS
DEFAULT_OVERLAP_MS = 10 * 60_000

JsonObject = dict[str, Any]


# --------------------------------------------------------------------------- canonical JSON


def _check(value: Any, path: str) -> None:
    if isinstance(value, bool) or value is None or isinstance(value, (str, int)):
        return
    if isinstance(value, float):
        raise ValueError(f"{path}: floats are not allowed, use a decimal string")
    if isinstance(value, Mapping):
        for key, item in value.items():
            if not isinstance(key, str):
                raise ValueError(f"{path}: object keys must be strings")
            _check(item, f"{path}.{key}")
        return
    if isinstance(value, (list, tuple)):
        for index, item in enumerate(value):
            _check(item, f"{path}[{index}]")
        return
    raise ValueError(f"{path}: unsupported type {type(value).__name__}")


def canonical(obj: Any) -> bytes:
    """Compact UTF-8 JSON with recursively sorted keys; no floats (ARENA_SPEC.md §2)."""
    _check(obj, "$")
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()


def b58(data: bytes) -> str:
    return base58.b58encode(data).decode("ascii")


def pubkey_of(key: SigningKey) -> str:
    return b58(bytes(key.verify_key))


def sign(
    obj: Mapping[str, Any], key: SigningKey, field: str = "sig", exclude: tuple[str, ...] = ()
) -> JsonObject:
    """`obj` with an Ed25519 signature in `field` over the canonical object without
    `field` and every field in `exclude`."""
    payload = {k: v for k, v in obj.items() if k != field and k not in exclude}
    signed = dict(obj)
    signed[field] = b58(key.sign(canonical(payload)).signature)
    return signed


def verify(obj: Mapping[str, Any], pubkey: str, field: str = "sig") -> bool:
    try:
        payload = canonical({k: v for k, v in obj.items() if k != field})
        VerifyKey(base58.b58decode(pubkey)).verify(payload, base58.b58decode(str(obj[field])))
    except Exception:
        return False
    return True


def keypair_json(key: SigningKey) -> str:
    """Solana CLI keypair format (64 integers), importable in Phantom."""
    return json.dumps(list(bytes(key) + bytes(key.verify_key)))


def load_key(path: Path) -> SigningKey:
    text = path.read_text(encoding="utf-8").strip()
    raw = bytes(json.loads(text)) if text.startswith("[") else base58.b58decode(text)
    key = SigningKey(raw[:32])
    if len(raw) == 64 and bytes(key.verify_key) != raw[32:]:
        raise ValueError(f"{path}: secret key does not match its public half")
    if len(raw) not in (32, 64):
        raise ValueError(f"{path}: secret key must be 32 or 64 bytes")
    return key


# --------------------------------------------------------------------------- key files


def inside_git_repo(path: Path) -> Path | None:
    """The repository root containing `path`, if any."""
    for parent in [path.resolve().parent, *path.resolve().parents]:
        if (parent / ".git").exists():
            return parent
    return None


def key_path_problem(path: Path) -> str | None:
    if not path.name.endswith(KEY_SUFFIX):
        return f"key files must be named *{KEY_SUFFIX}"
    if path.exists():
        return f"{path} exists; refusing to overwrite a key file"
    repo = inside_git_repo(path)
    if repo is not None:
        return f"refusing to write a private key inside the git repository {repo}"
    return None


def write_private_key(path: Path, key: SigningKey) -> None:
    """Create the key file exclusively, readable by the owner only where supported."""
    path.parent.mkdir(parents=True, exist_ok=True)
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as handle:
        handle.write(keypair_json(key))


def _now() -> int:
    return int(time.time() * 1000)


_last_seq = 0


def next_seq() -> int:
    """Default signal `seq`: the current time in microseconds, strictly increasing within
    this process (two signals in the same millisecond, e.g. a reversal, stay ordered)."""
    global _last_seq
    _last_seq = max(time.time_ns() // 1000, _last_seq + 1)
    return _last_seq


# --------------------------------------------------------------------------- objects


def delegation(
    root: SigningKey,
    strategy_id: str,
    signal_pubkey: str,
    seq: int,
    *,
    not_before: int,
    not_after: int,
    ts: int,
) -> JsonObject:
    return sign(
        {
            "kind": KIND_DELEGATION,
            "strategy_id": strategy_id,
            "signal_pubkey": signal_pubkey,
            "not_before": not_before,
            "not_after": not_after,
            "seq": seq,
            "ts": ts,
        },
        root,
    )


def rotation_objects(
    root: SigningKey,
    strategy_id: str,
    new_pubkey: str,
    old_pubkey: str | None,
    seq: int,
    *,
    now: int,
    days: int = 90,
    overlap_ms: int = DEFAULT_OVERLAP_MS,
) -> list[JsonObject]:
    """Delegate `new_pubkey` (seq) and, if given, end `old_pubkey` after a short overlap
    (seq + 1) so signals in flight with the old key are still accepted. Nothing signed by
    the old key before that is affected (revocations are never retroactive)."""
    lifetime = min(days * DAY_MS, MAX_KEY_LIFETIME_MS)
    objects = [delegation(root, strategy_id, new_pubkey, seq, not_before=now,
                          not_after=now + lifetime, ts=now)]  # fmt: skip
    if old_pubkey:
        objects.append(
            delegation(
                root,
                strategy_id,
                old_pubkey,
                seq + 1,
                not_before=now,
                not_after=now + overlap_ms,
                ts=now,
            )
        )
    return objects


def payout_binding(root: SigningKey, payout: SigningKey, seq: int, *, ts: int) -> JsonObject:
    """Root key (`sig`) and payout wallet (`payout_sig`) both sign the object without the
    two signatures (ARENA_SPEC.md §2.1)."""
    obj: JsonObject = {
        "kind": KIND_PAYOUT,
        "creator_id": pubkey_of(root),
        "payout_address": pubkey_of(payout),
        "seq": seq,
        "ts": ts,
    }
    signatures = ("sig", "payout_sig")
    return sign(sign(obj, root, "sig", signatures), payout, "payout_sig", signatures)


def terms_object(
    root: SigningKey,
    *,
    strategy_id: str,
    market: str,
    payout_address: str,
    payout_ata: str,
    fee: Mapping[str, str],
    version: int,
    effective_ts: int,
) -> JsonObject:
    return sign(
        {
            "kind": KIND_TERMS,
            "strategy_id": strategy_id,
            "creator_id": pubkey_of(root),
            "market": market,
            "payout_address": payout_address,
            "payout_ata": payout_ata,
            "fee": dict(fee),
            "infra_rate": INFRA_RATE,
            "version": version,
            "effective_ts": effective_ts,
        },
        root,
        "creator_sig",
    )


def signal_object(
    key: SigningKey, strategy_id: str, action: str, data: Mapping[str, Any], *, seq: int, ts: int
) -> JsonObject:
    return sign(
        {
            "kind": KIND_SIGNAL,
            "strategy_id": strategy_id,
            "seq": seq,
            "ts": ts,
            "action": action,
            "data": dict(data),
            "signer": pubkey_of(key),
        },
        key,
    )


def heartbeat_object(key: SigningKey, strategy_id: str, *, ts: int) -> JsonObject:
    return sign(
        {"kind": KIND_HEARTBEAT, "strategy_id": strategy_id, "ts": ts, "signer": pubkey_of(key)},
        key,
    )


def auth_header(key: SigningKey, path: str, ts: int) -> str:
    """`M2M-Auth` for private reads: `<pubkey>.<ts>.<sig over GET\\n<path>\\n<ts>>`."""
    signature = key.sign(f"GET\n{path}\n{ts}".encode()).signature
    return f"{pubkey_of(key)}.{ts}.{b58(signature)}"


# --------------------------------------------------------------------------- network


def endpoint(obj: Mapping[str, Any]) -> tuple[str, str]:
    """(HTTP method, path under the API root) for a signed object."""
    kind = obj.get("kind")
    routes = {
        KIND_CREATOR: ("POST", "/v1/creators"),
        KIND_STRATEGY: ("POST", "/v1/strategies"),
        KIND_SIGNAL: ("POST", "/v1/signals"),
        KIND_HEARTBEAT: ("POST", "/v1/heartbeats"),
    }
    if kind in routes:
        return routes[str(kind)]
    if kind == KIND_DELEGATION:
        return "POST", f"/v1/strategies/{obj['strategy_id']}/delegations"
    if kind == KIND_TERMS:
        return "POST", f"/v1/strategies/{obj['strategy_id']}/terms"
    if kind == KIND_RESUME:
        return "POST", f"/v1/strategies/{obj['strategy_id']}/resume"
    if kind == KIND_RETIRE:
        return "POST", f"/v1/strategies/{obj['strategy_id']}/retire"
    if kind == KIND_PAYOUT:
        return "PUT", f"/v1/creators/{obj['creator_id']}/payout"
    if kind == KIND_PAYOUT_CANCEL:
        return "DELETE", f"/v1/creators/{obj['creator_id']}/payout/pending"
    raise ValueError(f"unknown object kind {kind!r}")


def send(server: str, obj: Mapping[str, Any], client: Any = None) -> tuple[int, Any]:
    import httpx

    method, path = endpoint(obj)
    http = client or httpx.Client(timeout=30)
    response = http.request(method, server.rstrip("/") + path, json=dict(obj))
    try:
        body = response.json()
    except ValueError:
        body = response.text
    return response.status_code, body


def get(server: str, path: str, headers: dict[str, str] | None = None, client: Any = None) -> Any:
    import httpx

    http = client or httpx.Client(timeout=30)
    response = http.get(server.rstrip("/") + path, headers=headers)
    if response.status_code != 200:
        raise SystemExit(f"GET {path}: {response.status_code} {response.text}")
    return response.json()


# --------------------------------------------------------------------------- CLI


def _emit(obj: Mapping[str, Any], out: Path | None) -> None:
    text = json.dumps(obj, indent=2, sort_keys=True) + "\n"
    if out is None:
        sys.stdout.write(text)
    else:
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(text, encoding="utf-8")
        print(f"wrote {out}")


def _deliver(obj: JsonObject, args: argparse.Namespace, client: Any = None) -> int:
    """Print/write the object, or send it with --server. Returns the exit code."""
    if getattr(args, "server", None) is None:
        _emit(obj, getattr(args, "out", None))
        return 0
    if getattr(args, "out", None) is not None:
        _emit(obj, args.out)
    status, body = send(args.server, obj, client)
    sys.stdout.write(json.dumps(body, indent=2, sort_keys=True) + "\n")
    if status != 200:
        error = body.get("error", {}) if isinstance(body, dict) else {}
        retry = error.get("retry_after_s")
        print(
            f"refused ({status}): {error.get('code')} — {error.get('message')} "
            f"{error.get('detail') or ''}"
            + (f" retry after {retry} s" if retry else "")
            + (" [counts as invalid]" if error.get("counts_as_invalid") else ""),
            file=sys.stderr,
        )
        return 1
    return 0


def _confirm_terms(summary: Mapping[str, Any], assume_yes: bool) -> bool:
    print("Terms followers will be offered (signed with your root key):", file=sys.stderr)
    for key, value in summary.items():
        print(f"  {key}: {value}", file=sys.stderr)
    if assume_yes:
        return True
    answer = input("Type ACCEPT to sign these terms: ")
    return answer.strip() == "ACCEPT"


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="creator_tool.py",
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    sub = parser.add_subparsers(dest="command", required=True)

    def with_server(cmd: argparse.ArgumentParser) -> None:
        cmd.add_argument("--out", type=Path, help="also write the signed object to this file")
        cmd.add_argument(
            "--server", nargs="?", const=DEFAULT_SERVER, default=None,
            help=f"send it to the API (default {DEFAULT_SERVER})",
        )  # fmt: skip

    keygen = sub.add_parser("keygen", help="create a new Ed25519 keypair file")
    keygen.add_argument("--out", type=Path, required=True)

    creator = sub.add_parser("creator", help="register your creator identity")
    creator.add_argument("--root", type=Path, required=True, help="root keypair file")
    creator.add_argument("--handle", required=True, help="[a-z0-9-]{3,24}, permanent")
    with_server(creator)

    strategy = sub.add_parser("strategy", help="register a strategy (one market)")
    strategy.add_argument("--root", type=Path, required=True)
    strategy.add_argument("--handle", required=True)
    strategy.add_argument("--slug", required=True, help="[a-z0-9-]{3,32}")
    strategy.add_argument("--market", required=True, choices=MARKETS)
    strategy.add_argument("--name", required=True)
    strategy.add_argument("--description", required=True, help="plain text, no claims")
    strategy.add_argument("--fee-type", required=True, choices=["volume", "performance"])
    strategy.add_argument("--fee-rate", required=True)
    strategy.add_argument("--max-drawdown", default="0.30", help="auto-pause, 0.10..0.50")
    with_server(strategy)

    delegate = sub.add_parser("delegate", help="delegate a signal key (expires)")
    delegate.add_argument("--root", type=Path, required=True)
    delegate.add_argument("--strategy-id", required=True)
    delegate.add_argument("--signal-pubkey", required=True)
    delegate.add_argument("--seq", type=int, required=True)
    delegate.add_argument("--days", type=int, default=90, help="expiry in days (max 90)")
    with_server(delegate)

    rotate = sub.add_parser("rotate", help="new signal key + delegation, end the old key")
    rotate.add_argument("--root", type=Path, required=True)
    rotate.add_argument("--strategy-id", required=True)
    rotate.add_argument("--new-key", type=Path, required=True, help="new *.keypair.json path")
    rotate.add_argument("--old-signal-pubkey", help="key to end after a short overlap")
    rotate.add_argument("--seq", type=int, required=True, help="next free delegation seq")
    rotate.add_argument("--days", type=int, default=90)
    rotate.add_argument("--out-dir", type=Path, required=True)
    rotate.add_argument("--server", nargs="?", const=DEFAULT_SERVER, default=None)

    payout = sub.add_parser("payout", help="bind or change the payout address (72 h lock)")
    payout.add_argument("--root", type=Path, required=True)
    payout.add_argument("--payout-key", type=Path, required=True,
                        help="keypair file of the (dedicated) payout wallet")  # fmt: skip
    payout.add_argument("--seq", type=int, required=True, help="1 for the first binding")
    with_server(payout)

    cancel = sub.add_parser("payout-cancel", help="cancel a pending payout change")
    cancel.add_argument("--root", type=Path, required=True)
    cancel.add_argument("--seq", type=int, required=True)
    with_server(cancel)

    terms = sub.add_parser("terms", help="sign the terms followers are offered")
    terms.add_argument("--root", type=Path, required=True)
    terms.add_argument("--strategy-id", required=True)
    terms.add_argument("--version", type=int, required=True, help="1, then +1 per change")
    terms.add_argument("--fee-rate", help="new rate (default: the strategy's rate)")
    terms.add_argument("--effective-ts", type=int, help="ms; default now (increases: +7 days)")
    terms.add_argument("--payout-address", help="offline mode: payout address")
    terms.add_argument("--payout-ata", help="offline mode: its USDC token account")
    terms.add_argument("--market", choices=MARKETS, help="offline mode")
    terms.add_argument("--fee-type", choices=["volume", "performance"], help="offline mode")
    terms.add_argument("--read-from", default=DEFAULT_SERVER,
                       help="API to read the strategy and payout from")  # fmt: skip
    terms.add_argument("--yes", action="store_true", help="accept without the prompt")
    with_server(terms)

    for name, text in (("resume", "resume after a drawdown pause (>= 7 days after it)"),
                       ("retire", "retire a strategy (final)")):  # fmt: skip
        request = sub.add_parser(name, help=text)
        request.add_argument("--root", type=Path, required=True)
        request.add_argument("--strategy-id", required=True)
        with_server(request)

    signal = sub.add_parser("signal", help="sign a signal with a signal key")
    signal.add_argument("--key", type=Path, required=True, help="signal keypair file")
    signal.add_argument("--strategy-id", required=True)
    signal.add_argument("--action", required=True, choices=["open", "close", "move_sl", "set_tp"])
    signal.add_argument("--data", required=True, help="JSON object, decimals as strings")
    signal.add_argument("--seq", type=int, help="strictly increasing; default: time in µs")
    with_server(signal)

    heartbeat = sub.add_parser("heartbeat", help="sign a heartbeat (at most 1 per minute)")
    heartbeat.add_argument("--key", type=Path, required=True)
    heartbeat.add_argument("--strategy-id", required=True)
    with_server(heartbeat)

    status = sub.add_parser("status", help="private status: rejections, keys, payout")
    status.add_argument("--key", type=Path, required=True, help="root or signal keypair file")
    status.add_argument("--creator-id", required=True)
    status.add_argument("--server", default=DEFAULT_SERVER)

    submit = sub.add_parser("submit", help="send signed object files to the API")
    submit.add_argument("files", type=Path, nargs="+")
    submit.add_argument("--server", default=DEFAULT_SERVER)
    return parser


def main(argv: Sequence[str] | None = None, client: Any = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    now = _now()
    if args.command == "keygen":
        problem = key_path_problem(args.out)
        if problem:
            parser.error(problem)
        key = SigningKey.generate()
        write_private_key(args.out, key)
        print(pubkey_of(key))  # only the public key is ever printed
        return 0
    if args.command == "submit":
        code = 0
        for path in args.files:
            status, body = send(args.server, json.loads(path.read_text(encoding="utf-8")), client)
            print(f"{path}: {status} {json.dumps(body, sort_keys=True)}")
            code = code or int(status != 200)
        return code
    if args.command == "status":
        key = load_key(args.key)
        path = f"/v1/creators/{args.creator_id}/status"
        body = get(args.server, path, {"M2M-Auth": auth_header(key, path, now)}, client)
        sys.stdout.write(json.dumps(body, indent=2, sort_keys=True) + "\n")
        return 0
    if args.command in ("signal", "heartbeat"):
        key = load_key(args.key)
        if args.command == "heartbeat":
            return _deliver(heartbeat_object(key, args.strategy_id, ts=now), args, client)
        data = json.loads(args.data)
        if not isinstance(data, dict):
            parser.error("--data must be a JSON object")
        seq = args.seq if args.seq is not None else next_seq()
        obj = signal_object(key, args.strategy_id, args.action, data, seq=seq, ts=now)
        return _deliver(obj, args, client)

    root = load_key(args.root)
    creator_id = pubkey_of(root)
    if args.command == "rotate":
        problem = key_path_problem(args.new_key)
        if problem:
            parser.error(problem)
        if not 1 <= args.days <= 90:
            parser.error("--days must be within 1..90")
        new_key = SigningKey.generate()
        write_private_key(args.new_key, new_key)
        objects = rotation_objects(
            root, args.strategy_id, pubkey_of(new_key), args.old_signal_pubkey, args.seq,
            now=now, days=args.days,
        )  # fmt: skip
        code = 0
        for index, rotation in enumerate(objects):
            _emit(rotation, args.out_dir / f"delegation-{args.seq + index}.json")
            if args.server:
                status, body = send(args.server, rotation, client)
                print(f"delegation seq {args.seq + index}: {status} {json.dumps(body)}")
                code = code or int(status != 200)
        print(pubkey_of(new_key))
        return code
    if args.command == "creator":
        obj = sign({"kind": KIND_CREATOR, "creator_id": creator_id, "handle": args.handle,
                    "ts": now}, root)  # fmt: skip
    elif args.command == "strategy":
        allowed = VOLUME_FEE_RATES if args.fee_type == "volume" else PERFORMANCE_FEE_RATES
        if args.fee_rate not in allowed:
            parser.error(f"--fee-rate must be one of {', '.join(allowed)}")
        obj = sign(
            {
                "kind": KIND_STRATEGY,
                "creator_id": creator_id,
                "slug": args.slug,
                "market": args.market,
                "name": args.name,
                "description": args.description,
                "max_drawdown": args.max_drawdown,
                "fee": {"type": args.fee_type, "rate": args.fee_rate},
                "ts": now,
            },
            root,
        )
    elif args.command == "delegate":
        if not 1 <= args.days <= 90:
            parser.error("--days must be within 1..90")
        obj = delegation(root, args.strategy_id, args.signal_pubkey, args.seq,
                         not_before=now, not_after=now + args.days * DAY_MS, ts=now)  # fmt: skip
    elif args.command == "payout":
        payout_key = load_key(args.payout_key)
        obj = payout_binding(root, payout_key, args.seq, ts=now)
        print(f"payout address: {pubkey_of(payout_key)}", file=sys.stderr)
    elif args.command in ("resume", "retire"):
        kind = KIND_RESUME if args.command == "resume" else KIND_RETIRE
        obj = sign({"kind": kind, "strategy_id": args.strategy_id, "ts": now}, root)
    elif args.command == "payout-cancel":
        obj = sign({"kind": KIND_PAYOUT_CANCEL, "creator_id": creator_id, "seq": args.seq,
                    "ts": now}, root)  # fmt: skip
    else:
        obj_or_none = _terms(args, root, now, client)
        if obj_or_none is None:
            print("not signed", file=sys.stderr)
            return 1
        obj = obj_or_none
    return _deliver(obj, args, client)


def _terms(args: argparse.Namespace, root: SigningKey, now: int, client: Any) -> JsonObject | None:
    effective = args.effective_ts if args.effective_ts is not None else now
    if args.payout_address and args.payout_ata and args.market and args.fee_type and args.fee_rate:
        market, fee = args.market, {"type": args.fee_type, "rate": args.fee_rate}
        address, ata = args.payout_address, args.payout_ata
    else:
        detail = get(args.read_from, f"/v1/public/strategies/{args.strategy_id}", client=client)
        strategy = detail["strategy"]
        market = strategy["market"]
        fee = {"type": strategy["fee"]["type"], "rate": args.fee_rate or strategy["fee"]["rate"]}
        handle = args.strategy_id.split("/")[0]
        payout = get(args.read_from, f"/v1/public/creators/{handle}", client=client)["payout"]
        pending = payout.get("pending")
        receipt = pending if pending and pending["effective_ts"] <= effective else payout["current"]
        if receipt is None:
            raise SystemExit("no payout binding yet: run `payout` first")
        address, ata = receipt["payout_address"], receipt["payout_ata"]
    allowed = VOLUME_FEE_RATES if fee["type"] == "volume" else PERFORMANCE_FEE_RATES
    if fee["rate"] not in allowed:
        raise SystemExit(f"fee rate must be one of {', '.join(allowed)}")
    describe = (
        f"{fee['rate']} of executed notional per side"
        if fee["type"] == "volume"
        else f"{fee['rate']} of new results above the follower's high-water mark"
    )
    summary = {
        "strategy": args.strategy_id,
        "market": market,
        "creator fee": f"{fee['type']}: {describe}",
        "infra fee (always, to M2MMonster)": f"{INFRA_RATE} of executed notional per side",
        "M2MMonster share of the creator fee": PLATFORM_SHARE,
        "payout address": address,
        "payout USDC account": ata,
        "version": args.version,
        "effective (ms)": effective,
    }
    if not _confirm_terms(summary, args.yes):
        return None
    return terms_object(
        root, strategy_id=args.strategy_id, market=market, payout_address=address,
        payout_ata=ata, fee=fee, version=args.version, effective_ts=effective,
    )  # fmt: skip


if __name__ == "__main__":
    sys.exit(main())
