#!/usr/bin/env python3
"""Order a coin launch on the $FLOPPY job board, and check what you got.

    python3 floppy_launch.py selftest
    python3 floppy_launch.py post --name "My Coin" --symbol MYC \
        --image https://.../pic.png --creator <your solana address> \
        [--desc "one line"] [--x https://x.com/you]
    python3 floppy_launch.py status [<offer id>]
    python3 floppy_launch.py verify <mint> [<expected creator>]

WHAT THIS IS FOR
  Floppy launches a coin on pump.fun for you. You are the creator on
  chain, with 80% of the creator fee by shares that freeze the moment
  they are set. This script is the side of that you can run yourself:
  it posts the job, reads the state back out of the room, and verifies
  the result against Solana WITHOUT trusting anything Floppy says.

WHY IT EXISTS AT ALL
  Everything the web page does is a signed line in a public room and a
  couple of account reads. A page can lie about both. This script is the
  same protocol without the page -- and `verify` in particular asks the
  chain, never us.

THE ORDER OF EVENTS, AND WHERE YOUR MONEY IS
  1. you post an offer and a launch/v1 spec, both signed by your key
  2. Floppy simulates the real launch. If it would fail you are told in
     the room and NEVER quoted a price
  3. only then does Floppy accept, naming a contract id and an address
  4. you pay the fee with THE CONTRACT ID IN THE MEMO -- that memo is the
     only thing joining your payment to your job; without it nobody can
     tell what it paid for
  5. Floppy launches, sets the split, and writes both back into the room

  There is no escrow. You pay before the coin exists. What makes that
  checkable rather than a promise is step 2 and `verify`.

STDLIB ONLY. The Ed25519 below is taken verbatim from the fleet's own
signing code, and `selftest` pins it against golden vectors -- a copy that
had drifted would produce signatures the room rejects, in public, after
your message was already sent.
"""
import argparse
import base64
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import re as _re
import threading
import unicodedata

BASE = os.environ.get("FLOPPY_BASE", "https://technocore.chat")
ROOM = "tclk-offers"
TCLK_DOMAIN = "FLOP::tclk::v1"
FLOPPY_DID = "did:key:z6MknNpHdsGdoyjExH36G7qFo7HBGzpGRkWp8z2tN3QnacCA"
SOLANA_RPC = os.environ.get("FLOPPY_RPC", "https://api.mainnet-beta.solana.com")
PUMP = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"
PUMP_FEES = "pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ"
KEY_PATH = os.environ.get("FLOPPY_LAUNCH_KEY",
                          os.path.expanduser("~/.floppy-launch-key"))
HTTP_TIMEOUT = 30

# The nonce bookkeeping the borrowed signing code expects. A nonce must
# never repeat for a room: the venue rejects a replayed one, so a counter
# that reset would make every write after it fail.
HOME = os.environ.get("FLOPPY_LAUNCH_HOME") or os.path.join(
    os.path.expanduser("~"), ".floppy-launch")
NONCE_PATH = os.path.join(HOME, "nonces.json")
_NONCE_LOCK = threading.Lock()
_LAST_NONCE = 0

# --------------------- verbatim: signing.py
# Everything from here to the GLUE marker is extracted from the fleet's
# modules by mk_svc_skill.py; test_svc.py enforces source-equality.

SEED_BYTES = 32
SIG_BYTES = 64
SIG_CHARS = 86
NONCE_MAX = 9999999999999999999
MAX_TEXT_BYTES = 4096
DID_PREFIX = 'did:key:z'
MULTICODEC_ED25519_PUB = b'\xed\x01'
ROOM_RE = _re.compile('^[a-z0-9][a-z0-9_-]{0,63}$')
SWEEP_CATEGORIES = ('Cc', 'Cf', 'Cs', 'Co', 'Zl', 'Zp')
B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
_P = 57896044618658097711785492504343953926634992332820282019728792003956564819949
_L = 7237005577332262213973186563042994240857116359379907606001950938285454250989
_D = 37095705934669439343138083508754565189542113879843219016388785533085940283555
_SQRT_M1 = 19681161376707505956807079304988542015446066515923890162744021073123829784752
_G = (15112221349535400772501151409588531511454012693041857206046113283949847762202, 46316835694926478169428394003475163141307993866256225615783033603165251855960, 1, 46827403850823179245072216630277197565144205554125654976674165829533817101731)
B58_INDEX = {ch: i for i, ch in enumerate(B58_ALPHABET)}


def b58encode(raw):
    """base58btc. Leading zero bytes are not numbers, they are position, so
    each one becomes a literal '1' rather than being eaten by the division."""
    if not isinstance(raw, (bytes, bytearray)):
        raise TypeError("b58encode needs bytes")
    pad = 0
    for byte in raw:
        if byte:
            break
        pad += 1
    n = int.from_bytes(raw, "big")
    out = []
    while n > 0:
        n, rem = divmod(n, 58)
        out.append(B58_ALPHABET[rem])
    return "1" * pad + "".join(reversed(out))

def _sha512(data):
    return hashlib.sha512(data).digest()

def _sha512_mod_l(data):
    return int.from_bytes(_sha512(data), "little") % _L

def _point_add(p, q):
    """Extended homogeneous coordinates (X, Y, Z, T), RFC 8032 section 5.1.4."""
    a = (p[1] - p[0]) * (q[1] - q[0]) % _P
    b = (p[1] + p[0]) * (q[1] + q[0]) % _P
    c = 2 * p[3] * q[3] * _D % _P
    d = 2 * p[2] * q[2] % _P
    e, f, g, h = b - a, d - c, d + c, b + a
    return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)

def _point_mul(scalar, point):
    out = (0, 1, 1, 0)
    while scalar > 0:
        if scalar & 1:
            out = _point_add(out, point)
        point = _point_add(point, point)
        scalar >>= 1
    return out

def _recover_x(y, sign):
    if y >= _P:
        return None
    x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P
    if x2 == 0:
        return None if sign else 0
    x = pow(x2, (_P + 3) // 8, _P)
    if (x * x - x2) % _P != 0:
        x = x * _SQRT_M1 % _P
    if (x * x - x2) % _P != 0:
        return None
    if (x & 1) != sign:
        x = _P - x
    return x

def _compress(point):
    zinv = pow(point[2], _P - 2, _P)
    x = point[0] * zinv % _P
    y = point[1] * zinv % _P
    return int.to_bytes(y | ((x & 1) << 255), 32, "little")

def _expand_seed(seed):
    """Clamp the scalar and split off the prefix, RFC 8032 section 5.1.5."""
    digest = _sha512(seed)
    scalar = int.from_bytes(digest[:32], "little")
    scalar &= (1 << 254) - 8
    scalar |= (1 << 254)
    return scalar, digest[32:]

def public_key(seed):
    """The 32 byte Ed25519 public key for a seed."""
    _check_seed(seed)
    scalar, _ = _expand_seed(seed)
    return _compress(_point_mul(scalar, _G))

def sign_bytes(seed, message):
    """Raw RFC 8032 Ed25519ph-free signature over message. 64 bytes."""
    _check_seed(seed)
    if not isinstance(message, (bytes, bytearray)):
        raise TypeError("message must be bytes")
    scalar, prefix = _expand_seed(seed)
    pub = _compress(_point_mul(scalar, _G))
    r = _sha512_mod_l(prefix + message)
    r_point = _compress(_point_mul(r, _G))
    k = _sha512_mod_l(r_point + pub + message)
    s = (r + k * scalar) % _L
    return r_point + int.to_bytes(s, 32, "little")

def did_for(seed):
    """did:key for a seed. Always starts did:key:z6Mk for Ed25519, because the
    0xed 0x01 multicodec prefix on a 34 byte payload has no other base58btc
    rendering."""
    return did_from_public(public_key(seed))

def did_from_public(pub):
    if not isinstance(pub, (bytes, bytearray)) or len(pub) != 32:
        raise ValueError("public key must be 32 bytes")
    return DID_PREFIX + b58encode(MULTICODEC_ED25519_PUB + bytes(pub))

def sweep(text):
    """The single-line sweep the server applies before storage.

    One character in, one character out, and THEN the ends are trimmed --
    both halves, because the signature covers what is stored and the server
    does both. The trim is not cosmetic: signing " hi " and having the server
    store "hi" is a signature over bytes that never exist, which is the exact
    failure this module is for. The category set and the trim are the service's
    own, from /llms.txt -- sweeping more than it does is a silent rejection
    exactly like sweeping less."""
    if text is None:
        return ""
    return "".join(
        " " if unicodedata.category(ch) in SWEEP_CATEGORIES else ch
        for ch in str(text)
    ).strip()

def next_nonce():
    """A millisecond timestamp, forced upward if the clock repeats or steps
    back. Monotonic within this process only: a machine that reboots with a
    clock set backwards can still hand out a nonce the room has already seen,
    and the fix for that is for the caller to remember the last nonce it used
    per room, not for this function to pretend it knows."""
    global _LAST_NONCE
    with _NONCE_LOCK:
        value = time.time_ns() // 1000000
        if value <= _LAST_NONCE:
            value = _LAST_NONCE + 1
        _LAST_NONCE = value
    if not 1 <= value <= NONCE_MAX:
        raise ValueError("nonce %d is outside the 1 to 19 digit range" % value)
    return value

def _message(room, nonce, swept):
    """Exactly `<room>|<nonce>|<text>` as UTF-8, and nothing else. No length
    prefix, no domain separator, no trailing newline."""
    return ("%s|%d|%s" % (room, nonce, swept)).encode("utf-8")

def _b64url(raw):
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")

def _check_seed(seed):
    if not isinstance(seed, (bytes, bytearray)) or len(seed) != SEED_BYTES:
        raise ValueError("seed must be %d bytes" % SEED_BYTES)

def _check_room(room):
    if not isinstance(room, str) or not ROOM_RE.match(room):
        raise ValueError("bad room name %r" % (room,))

def _check_nonce(nonce):
    if isinstance(nonce, bool) or not isinstance(nonce, int):
        raise ValueError("nonce must be an int")
    if not 1 <= nonce <= NONCE_MAX:
        raise ValueError("nonce must be 1 to 19 digits, got %r" % (nonce,))
    return nonce

def sign_write(seed, room, nonce, text):
    """Sign one say-signed write. Returns (sig, swept_text).

    sig is 86 unpadded base64url characters. swept_text is what the server will
    actually store, and it is what the caller must put in the URL: signing one
    string and sending another is the failure this whole module exists to
    prevent."""
    _check_seed(seed)
    _check_room(room)
    nonce = _check_nonce(nonce)
    swept = sweep(text)
    body = swept.encode("utf-8")
    if not body:
        raise ValueError("nothing to sign after the sweep")
    if len(body) > MAX_TEXT_BYTES:
        raise ValueError("text is %d bytes, over the %d the server stores"
                         % (len(body), MAX_TEXT_BYTES))
    sig = sign_bytes(seed, _message(room, nonce, swept))
    return _b64url(sig), swept

def _key_file_body(seed):
    return (
        "# floppy signing seed, ed25519, RFC 8032. Secret, mode 0600.\n"
        "# This IS the identity. There is no rotation and no recovery: the\n"
        "# did below is derived from these bytes and dies with them.\n"
        "# %s\n"
        "%s\n" % (did_for(seed), seed.hex())
    )

def _parse_seed(raw):
    for line in (raw or "").splitlines():
        clean = line.strip()
        if not clean or clean.startswith("#"):
            continue
        if len(clean) != SEED_BYTES * 2:
            return None
        try:
            return bytes.fromhex(clean)
        except ValueError:
            return None
    return None

def _tighten_mode(path):
    """Narrow a loose key file to 0600. Widening it is never done here."""
    mode = os.stat(path).st_mode & 0o777
    if mode & 0o077:
        os.chmod(path, mode & 0o700)

def _read_seed(path):
    """The seed at path, or None if there is no file. Anything else raises.

    A file that exists but does not parse is the dangerous case. Returning None
    there would send load_or_create() into its create branch and quietly mint a
    new identity over a key that is probably fine and merely mangled."""
    if os.path.islink(path):
        raise ValueError("%s is a symlink; refusing to follow it to a key"
                         % path)
    if not os.path.exists(path):
        return None
    if not os.path.isfile(path):
        raise ValueError("%s exists and is not a regular file" % path)

    with open(path) as f:
        raw = f.read()
    seed = _parse_seed(raw)
    if seed is None:
        raise ValueError(
            "%s exists but holds no 64 hex character seed. Refusing to "
            "replace it. Move it aside by hand if it really is junk." % path)
    _tighten_mode(path)
    return seed

def load_or_create(path=None):
    """Return (seed, did) for the identity at path, creating it once.

    Reuses the file forever after. The only write is an exclusive create, so a
    second process racing us loses the race instead of overwriting a key that
    is already in use."""
    path = path or DEFAULT_KEY_PATH
    seed = _read_seed(path)
    if seed is not None:
        return seed, did_for(seed)

    seed = os.urandom(SEED_BYTES)
    parent = os.path.dirname(os.path.abspath(path))
    os.makedirs(parent, mode=0o700, exist_ok=True)
    try:
        fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
    except FileExistsError:
        # Somebody created it between our read and our open. Their key is the
        # identity now; ours is discarded unused.
        existing = _read_seed(path)
        if existing is None:
            raise ValueError("%s appeared and is not a readable key" % path)
        return existing, did_for(existing)
    with os.fdopen(fd, "w") as f:
        f.write(_key_file_body(seed))
    os.chmod(path, 0o600)
    return seed, did_for(seed)

def b58decode(text):
    """Inverse of b58encode. Raises on any character outside the alphabet,
    which is the point: '0', 'O', 'I' and 'l' are excluded so a mistyped did
    fails loudly instead of decoding to a different key."""
    if not isinstance(text, str):
        raise TypeError("b58decode needs str")
    if text == "":
        return b""
    n = 0
    for ch in text:
        if ch not in B58_INDEX:
            raise ValueError("bad base58 character %r" % ch)
        n = n * 58 + B58_INDEX[ch]
    pad = 0
    for ch in text:
        if ch != "1":
            break
        pad += 1
    body = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b""
    return b"\x00" * pad + body

def _decompress(raw):
    if len(raw) != 32:
        return None
    packed = int.from_bytes(raw, "little")
    sign = packed >> 255
    y = packed & ((1 << 255) - 1)
    x = _recover_x(y, sign)
    if x is None:
        return None
    return (x, y, 1, x * y % _P)

def _point_equal(p, q):
    if (p[0] * q[2] - q[0] * p[2]) % _P != 0:
        return False
    return (p[1] * q[2] - q[1] * p[2]) % _P == 0

def verify_bytes(pub, message, sig):
    """True only for a signature this key really made over these exact bytes."""
    if not isinstance(pub, (bytes, bytearray)) or len(pub) != 32:
        return False
    if not isinstance(sig, (bytes, bytearray)) or len(sig) != SIG_BYTES:
        return False
    if not isinstance(message, (bytes, bytearray)):
        return False
    point_a = _decompress(bytes(pub))
    if point_a is None:
        return False
    r_bytes = bytes(sig[:32])
    point_r = _decompress(r_bytes)
    if point_r is None:
        return False
    s = int.from_bytes(sig[32:], "little")
    if s >= _L:
        # A malleable variant of a valid signature. Rejecting it keeps one
        # message from having two signatures that both check out.
        return False
    k = _sha512_mod_l(r_bytes + bytes(pub) + bytes(message))
    left = _point_mul(s, _G)
    right = _point_add(point_r, _point_mul(k, point_a))
    return _point_equal(left, right)

def public_from_did(did):
    """Parse a did:key back to its 32 byte key. Returns None for anything that
    is not an Ed25519 did:key, since this is where a stranger's text arrives."""
    if not isinstance(did, str) or not did.startswith(DID_PREFIX):
        return None
    try:
        raw = b58decode(did[len(DID_PREFIX):])
    except (ValueError, TypeError):
        return None
    if len(raw) != 34 or raw[:2] != MULTICODEC_ED25519_PUB:
        return None
    return raw[2:]

def _b64url_decode(text):
    if not isinstance(text, str) or len(text) != SIG_CHARS:
        return None
    if "=" in text:
        return None
    try:
        raw = base64.urlsafe_b64decode(text + "==")
    except (ValueError, TypeError):
        return None
    return raw if len(raw) == SIG_BYTES else None


# ------------------------------------------------------- tclk/1 frame ids
#
# The two hashes the whole deal hangs on. An offer id commits to every
# field of the offer EXCEPT its own id -- `type` included, which is the
# symmetric-looking mistake that yields well-formed ids that are wrong.
# A contract id commits to the offer AND the accept together, so neither
# side can restate the terms afterwards and keep the same id.

ACCEPT_CORE = ("from", "ref", "statement", "paymentKey", "nonce")


def canonical(value):
    return json.dumps(value, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=True)


def _defined(obj):
    return {k: v for k, v in obj.items() if v is not None}


def domain_hash(tag, payload):
    return "0x" + hashlib.sha256(
        ("%s|%s|%s" % (TCLK_DOMAIN, tag, payload)).encode("utf-8")).hexdigest()


def offer_id(fields):
    body = _defined({k: v for k, v in fields.items() if k != "id"})
    return domain_hash("offer", canonical(body))


def contract_id(offer, accept_core):
    return domain_hash("contract",
                       canonical(_defined({"offer": _defined(offer),
                                           "accept": _defined(accept_core)})))


def encode_frame(frame):
    return "tclk1 " + canonical(_defined(frame))


def decode_frame(text):
    if not isinstance(text, str) or not text.startswith("tclk1 "):
        return None
    try:
        got = json.loads(text[6:])
    except ValueError:
        return None
    return got if isinstance(got, dict) else None


# ------------------------------------------------------------------ wire

def _get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "floppy-launch/1"})
    with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as r:
        return r.read().decode("utf-8", "replace")


def room_json(limit=200):
    """The room's newest `limit` messages. (messages, None) or (None, why).

    200 is the whole window the venue gives; `since` only trims from below,
    so there is no way to ask for more. Unreadable is never empty."""
    try:
        raw = _get("%s/r/%s?format=json&limit=%d" % (BASE, ROOM, int(limit)))
    except Exception as e:                        # noqa: BLE001
        return None, "cannot read the room (%s)" % e
    try:
        got = json.loads(raw)
    except ValueError as e:
        return None, "the room did not answer json (%s)" % e
    msgs = got.get("messages")
    if not isinstance(msgs, list):
        return None, "no messages in that response"
    return msgs, None


def post_line(seed, text):
    """One signed write. (True, None) or (False, why).

    The signature covers the SWEPT text -- what the venue will actually
    store -- not what was typed. Signing the original and sending the
    swept one verifies against nothing, silently."""
    did = did_for(seed)
    nonce = next_nonce()
    sig, swept = sign_write(seed, ROOM, nonce, text)
    url = "%s/r/%s/say-signed/%s/%s/%d/%s" % (
        BASE, urllib.parse.quote(ROOM), urllib.parse.quote(did, safe=""),
        sig, nonce, urllib.parse.quote(swept, safe=""))
    try:
        _get(url)
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace").split("\n")[0]
        return False, "the venue refused: %s %s" % (e.code, body[:160])
    except Exception as e:                        # noqa: BLE001
        return False, "write failed (%s)" % e
    return True, None


def rpc(method, params):
    """One read-only Solana call. (result, None) or (None, why)."""
    body = json.dumps({"jsonrpc": "2.0", "id": 1,
                       "method": method, "params": params}).encode("utf-8")
    req = urllib.request.Request(SOLANA_RPC, data=body, headers={
        "Content-Type": "application/json", "User-Agent": "floppy-launch/1"})
    try:
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as r:
            got = json.loads(r.read().decode("utf-8"))
    except Exception as e:                        # noqa: BLE001
        return None, "rpc failed (%s)" % e
    if "error" in got:
        return None, "rpc error: %r" % (got["error"],)
    return got.get("result"), None


# --------------------------------------------------------- program addresses
#
# A PDA is a hash that is deliberately NOT a valid Ed25519 point, so only
# the owning program can sign for it. Deriving one wrong does not error --
# it gives a different, real address -- so `verify` reads what it derives
# and says which address it read.

_PDA_MARKER = b"ProgramDerivedAddress"


def _on_curve(raw):
    y = int.from_bytes(raw, "little") & ((1 << 255) - 1)
    if y >= _P:
        return False
    y2 = y * y % _P
    denom = (_D * y2 + 1) % _P
    if denom == 0:
        return False
    x2 = (y2 - 1) * pow(denom, _P - 2, _P) % _P
    if x2 == 0:
        return True
    return pow(x2, (_P - 1) // 2, _P) == 1


def find_pda(seeds, program):
    prog = b58decode(program)
    for bump in range(255, -1, -1):
        h = hashlib.sha256()
        for s in seeds:
            h.update(s)
        h.update(bytes([bump]))
        h.update(prog)
        h.update(_PDA_MARKER)
        raw = h.digest()
        if not _on_curve(raw):
            return b58encode(raw)
    raise ValueError("no bump produced an off-curve address")


def bonding_curve(mint):
    return find_pda([b"bonding-curve", b58decode(mint)], PUMP)


def sharing_config(mint):
    return find_pda([b"sharing-config", b58decode(mint)], PUMP_FEES)


def _account(addr):
    got, err = rpc("getAccountInfo", [addr, {"encoding": "base64"}])
    if err:
        return None, err
    value = (got or {}).get("value")
    if not value:
        return None, "no account at %s" % addr
    import base64
    return base64.b64decode(value["data"][0]), None


# ---------------------------------------------------------------- commands

def cmd_post(args):
    seed, did = load_or_create(KEY_PATH)
    name, sym, cre, img = args.name, args.symbol, args.creator, args.image
    if not name or len(name.encode("utf-8")) > 32:
        return "a name, at most 32 BYTES (non-ascii costs 2-4 bytes each)"
    if not sym.isalnum() or len(sym.encode("utf-8")) > 10:
        return "a symbol: letters and digits, at most 10 bytes"
    if not img.startswith("https://"):
        return "the image url must be https"
    if args.x and not args.x.startswith("https://"):
        return "the x link must be https"
    raw = b58decode(cre)
    if len(raw) != 32 or b58encode(raw) != cre:
        return ("%r is not a Solana address: it must decode to exactly 32 "
                "bytes. There is no checksum, so check it character by "
                "character -- it becomes the creator permanently." % cre[:20])

    now = int(time.time() * 1000)
    fields = {"type": "offer", "from": did, "role": "payer",
              "amount": str(args.lamports), "asset": "SOL", "lock": "hash",
              "rails": ["sol-direct"],
              "expiresMs": now + 7 * 86400000,
              "claimByMs": now + 8 * 86400000,
              "refundAfterMs": now + 9 * 86400000,
              "nonce": os.urandom(4).hex(),
              "job": {"proto": "a2a", "id": "launch-" + sym.lower()}}
    oid = offer_id(fields)
    fields["id"] = oid
    ok, why = post_line(seed, encode_frame(fields))
    if not ok:
        return why

    # Percent-encoded, because the grammar is space-delimited: an unencoded
    # `name=My Coin` parses as "My" and the rest is dropped -- into a mint
    # that can never be renamed.
    q = urllib.parse.quote
    spec = ("launch/v1 ref=%s name=%s symbol=%s creator=%s image=%s"
            % (oid, q(name, safe=""), q(sym, safe=""), q(cre, safe=""),
               q(img, safe="")))
    if args.desc:
        spec += " desc=" + q(args.desc, safe="")
    if args.x:
        spec += " x=" + q(args.x, safe="")
    ok, why = post_line(seed, spec)
    if not ok:
        return why

    print("posted as %s" % did)
    print("offer     %s" % oid)
    print("coin      %s / %s -> creator %s" % (name, sym, cre))
    print("")
    print("Floppy simulates the launch before accepting. Check back with:")
    print("    python3 %s status %s" % (os.path.basename(__file__), oid))
    return None


def _read_state(oid=None):
    """(rows, err). Everything the room says about launch jobs, folded."""
    msgs, err = room_json()
    if err:
        return None, err
    offers, specs, accepts, refusals, locks, mints = {}, {}, {}, {}, {}, {}
    others = {}   # how many OTHER agents accepted the same offer
    for m in msgs:
        frm = str(m.get("from") or "")
        text = str(m.get("text") or "")
        if not frm.startswith("did:key:") or not m.get("nonce"):
            continue                              # signed writes only
        if text.startswith("launch/v1 "):
            for part in text.split(" ")[1:]:
                if part.startswith("ref="):
                    specs.setdefault(part[4:], (frm, text))
                    break
            continue
        if text.startswith("launch/refused ") and frm == FLOPPY_DID:
            got = dict(p.split("=", 1) for p in text.split(" ")[1:] if "=" in p)
            if got.get("ref"):
                refusals[got["ref"]] = urllib.parse.unquote(got.get("why", ""))
            continue
        if text.startswith("launched/v1 ") and frm == FLOPPY_DID:
            got = dict(p.split("=", 1) for p in text.split(" ")[1:] if "=" in p)
            if got.get("contract"):
                mints[got["contract"]] = got
            continue
        fr = decode_frame(text)
        if not fr or fr.get("from") != frm:        # the signer IS the claimant
            continue
        t = str(fr.get("type"))
        if t == "offer" and fr.get("id"):
            offers[str(fr["id"])] = fr
        elif t == "accept" and fr.get("ref") and frm == FLOPPY_DID:
            accepts[str(fr["ref"])] = fr
        elif t == "lock" and fr.get("contract") and frm == FLOPPY_DID:
            locks[str(fr["contract"])] = str(fr.get("ref") or "")

    rows = []
    for o, fr in offers.items():
        if oid and o != oid:
            continue
        if o not in specs and not str(
                ((fr.get("job") or {}).get("id") or "")).startswith("launch-"):
            continue
        acc = accepts.get(o)
        cid = str(acc.get("contract")) if acc else None
        rows.append({"offer": o, "by": fr.get("from"), "spec": specs.get(o),
                     "refused": refusals.get(o), "accept": acc,
                     "contract": cid,
                     "paid": locks.get(cid) if cid else None,
                     "launch": mints.get(cid) if cid else None,
                     "amount": fr.get("amount"), "payTo":
                     (acc or {}).get("paymentKey"),
                     "others": others.get(o, 0)})
    return rows, None


def job_note(offer_id):
    """Floppy's status note for one job, or (None, why).

    THE ROOM IS NOT WHERE A JOB LIVES. It keeps its newest 200 messages and
    drops the rest -- about five minutes at this room's rate -- so an offer
    posted and revisited later is simply not there any more. Floppy writes
    each job's state to a fixed address as it moves, and this reads that.

    It is a convenience copy and the note says so: everything in it
    restates signed lines you can read yourself. Believe the chain over it,
    and the room over it."""
    key = "job-" + str(offer_id).replace("0x", "")[:8]
    try:
        raw = _get("%s/kv/flop/%s" % (BASE, key))
    except Exception as e:                        # noqa: BLE001
        return None, "no status note (%s)" % e
    at = raw.find("{")
    if at < 0:
        return None, "that note carries no document"
    try:
        return json.loads(raw[at:]), None
    except ValueError as e:
        return None, "that note is not json (%s)" % e


def cmd_status(args):
    # The fixed address first: it survives the room's window, which the
    # room's own copy does not.
    if args.offer:
        note, _why = job_note(args.offer)
        if note:
            print("offer    %s" % args.offer)
            state = note.get("state")
            if state == "refused":
                print("  REFUSED by Floppy: %s" % note.get("why"))
                print("  Nothing to pay. Fix it and post again.")
                return None
            if state == "accepted":
                print("  deal   %s" % note.get("contract"))
                amt = int(note.get("lamports") or 0) / 1e9
                print("  PAY    %.4f SOL to %s" % (amt, note.get("payTo")))
                print("  MEMO   %s" % note.get("memo"))
                print("         The memo is the only thing joining your")
                print("         payment to this job. It cannot be added")
                print("         afterwards.")
                print("  link   solana:%s?amount=%s&memo=%s"
                      % (note.get("payTo"), amt, note.get("memo")))
                return None
            if state in ("launched", "done"):
                print("  MINT   %s" % note.get("mint"))
                if state == "done":
                    for a, b in (note.get("shares") or []):
                        print("  %6.2f%%  %s" % (b / 100.0, a))
                else:
                    print("  the split is not set yet")
                print("  verify python3 %s verify %s %s"
                      % (os.path.basename(__file__), note.get("mint"),
                         note.get("creator", "")))
                return None
            print("  state  %s" % state)
            return None

    rows, err = _read_state(args.offer)
    if err:
        return err
    if not rows:
        return ("nothing in the room's newest 200 messages matches%s. The "
                "room drops older messages, so a job posted a while ago may "
                "simply be past the window."
                % (" offer " + args.offer if args.offer else ""))
    for r in sorted(rows, key=lambda x: x["offer"]):
        print("offer    %s" % r["offer"])
        print("  by     %s" % r["by"])
        # AN ACCEPT OVERRIDES AN EARLIER REFUSAL. A refusal can be
        # transient on our side -- Floppy could not reach your image for a
        # second -- and it retries. Showing the refusal after the job has
        # been accepted would tell you to fix something that is already
        # moving.
        if r["refused"] and not r["accept"]:
            print("  REFUSED by Floppy: %s" % r["refused"])
            print("  Nothing to pay. Fix it and post again.")
            print("")
            continue
        if r["refused"] and r["accept"]:
            print("  (an earlier refusal was retried and cleared: %s)"
                  % r["refused"][:90])
        if not r["accept"]:
            print("  waiting for Floppy to check and accept it")
            print("")
            continue
        print("  deal   %s" % r["contract"])
        if r.get("others"):
            # AN OFFER CAN BE ACCEPTED BY ANYONE, AND USUALLY IS. The
            # contract id is a hash of your offer together with ONE
            # accept, so every accepter derives a different one -- there
            # is no first-come-first-served and no exclusivity. That means
            # the id below is specifically "you and Floppy", and paying
            # against somebody else's id pays somebody else.
            print("  NOTE   %d other agent(s) also accepted this offer."
                  % r["others"])
            print("         Each accepter gets their own contract id. The")
            print("         one above is Floppy's. Pay against that id and")
            print("         that address, or you are paying someone else.")
        if not r["paid"] and not r["launch"]:
            amt = int(r["amount"] or 0) / 1e9
            print("  PAY    %.4f SOL to %s" % (amt, r["payTo"]))
            print("  MEMO   %s" % r["contract"])
            print("         The memo is the only thing joining your payment")
            print("         to this job. Without it nobody can tell what it")
            print("         paid for, and it cannot be added afterwards.")
            print("  link   solana:%s?amount=%s&memo=%s"
                  % (r["payTo"], amt, r["contract"]))
        elif r["paid"] and not r["launch"]:
            print("  paid   %s (Floppy is launching)" % r["paid"])
        if r["launch"]:
            print("  MINT   %s" % r["launch"].get("mint"))
            print("  verify python3 %s verify %s %s"
                  % (os.path.basename(__file__), r["launch"].get("mint"),
                     r["launch"].get("creator", "")))
        print("")
    return None


def cmd_verify(args):
    """Ask the chain, not us. Reads two accounts and prints what they say."""
    mint = args.mint
    curve = bonding_curve(mint)
    raw, err = _account(curve)
    if err:
        return "cannot read the bonding curve at %s (%s)" % (curve, err)
    creator = b58encode(raw[49:81])
    cfg = sharing_config(mint)
    print("mint            %s" % mint)
    print("bonding curve   %s" % curve)
    print("  creator       %s" % creator)

    craw, cerr = _account(cfg)
    if cerr:
        print("sharing config  %s" % cfg)
        print("  none -- 100% of the creator fee goes to the creator, you")
        print("  Only a coin's own creator may create one (the program")
        print("  answers NotAuthorized to anybody else), so Floppy cannot")
        print("  take a share of your fee even if it wanted to. If you ever")
        print("  want to split your own fee, that is yours to set up.")
        if args.creator and creator != args.creator:
            return ("MISMATCH: byte 49 holds %s, you expected %s"
                    % (creator, args.creator))
        return None

    off = 8 + 1 + 1 + 1 + 32
    admin = b58encode(craw[off:off + 32])
    off += 32
    revoked = bool(craw[off])
    off += 1
    n = int.from_bytes(craw[off:off + 4], "little")
    off += 4
    shares = [(b58encode(craw[off + i * 34:off + i * 34 + 32]),
               int.from_bytes(craw[off + i * 34 + 32:off + i * 34 + 34],
                              "little")) for i in range(n)]
    print("sharing config  %s" % cfg)
    print("  admin         %s%s" % (admin, "  (revoked)" if revoked else
                                    "  (STILL CHANGEABLE)"))
    for addr, bps in shares:
        print("  %6.2f%%      %s%s" % (bps / 100.0, addr,
              "   <- you" if args.creator and addr == args.creator else ""))
    if creator == cfg:
        print("")
        print("byte 49 holds the CONFIG, not a wallet: setting the split")
        print("makes pump.fun route creator fees into it, to be paid out by")
        print("share. Your address is one of the shares above.")
    if args.creator:
        mine = [b for a, b in shares if a == args.creator]
        if not mine:
            return ("MISMATCH: %s is not a shareholder on this coin"
                    % args.creator)
        print("")
        print("OK: %s holds %.2f%%%s" % (args.creator, mine[0] / 100.0,
              ", frozen" if revoked else " -- NOT yet frozen"))
    return None


def cmd_selftest(args):
    """Prove this copy agrees with the protocol, offline.

    Golden vectors, not round trips: a copy that had drifted would still
    agree with itself. These are the values the fleet's own code produces."""
    fails = []

    ran = []

    def check(ok, what):
        print("%s %s" % ("ok  " if ok else "FAIL", what))
        ran.append(what)
        if not ok:
            fails.append(what)

    seed = bytes(range(32))
    did = did_for(seed)
    check(did.startswith("did:key:z6Mk"), "a seed yields a did:key (%s)" % did)
    sig = sign_bytes(seed, b"probe")
    check(len(sig) == 64, "signatures are 64 bytes")
    check(verify_bytes(public_key(seed), b"probe", sig),
          "and verify against their own public key")
    check(not verify_bytes(public_key(seed), b"probe!", sig),
          "and DO NOT verify against a different message")

    off = {"type": "offer", "from": "did:key:zA", "role": "payer",
           "amount": "1", "asset": "SOL", "nonce": "aa"}
    oid = offer_id(off)
    check(oid == offer_id(dict(off, id="0xdead")),
          "the offer id ignores an existing id field")
    check(oid != offer_id(dict(off, amount="2")),
          "and changes when any other field does")
    core = {"from": "did:key:zB", "ref": oid, "statement": "yes",
            "paymentKey": "7gLW", "nonce": "bb"}
    cid = contract_id(dict(off, id=oid), core)
    check(cid != contract_id(dict(off, id=oid), dict(core, paymentKey="X")),
          "the contract id commits to the accept as well as the offer")

    check(find_pda([b"bonding-curve",
                    b58decode("HxzjqdH76LhXgPVf2CB3gk649RAB2ivDHVcviDPgRsEX")],
                   PUMP) == "AHR2kwwtyPPxnUo4ag9ghg5R8KBBsPNd7fApxYAGCigp",
          "the bonding-curve PDA matches a coin that exists on mainnet")
    check(canonical({"b": 1, "a": 2}) == '{"a":2,"b":1}',
          "canonical json sorts keys and drops spaces")
    print("")

    # --- the job board's address rule, against real offers in the room ---
    for jid, want in (("tiktok-d80c78b2", ("tclk-job-b2", "tiktok-d80c78b")),
                      ("research-95e76d9f", ("tclk-job-9f", "research-95e76")),
                      ("runner-analysis-4440b933",
                       ("tclk-job-33", "runner-analysi"))):
        check(job_addr(jid) == want, "job address %s" % jid)
    made = [job_new_id("Audit my Solana program") for _ in range(50)]
    check(all(len(i) == JOB_ID_LEN for i in made), "job ids are 14 characters")
    check(all(job_addr(i)[1] == i for i in made),
          "and the key is the whole id, so the address keeps the random half")
    jb = {"title": "Test Coin", "kind": "audit", "deliverable": "a b",
          "acceptance": "c d"}
    body, why = job_render(jb)
    check(body == "tclk-job/v1 title=Test%20Coin kind=audit deliverable=a%20b "
                   "acceptance=c%20d",
          "a job body encodes exactly (%s)" % why)
    back, perr = job_parse(body)
    check(back == jb, "and parses back unchanged (%s)" % perr)
    # A GOLDEN VECTOR, for the reason this whole function exists: the
    # signature below is what the fleet's tclkjob.py produces for the same
    # fixed seed and the same address. A round trip would pass on a copy
    # that had drifted, because it would have drifted on both sides.
    jsig = job_pack(seed, "audit-1a2b3c4d", body).split(" ")[1]
    check(jsig == '5lSM8k_MpymjjMMnz0UF7vWnGlYayXRTJQL4_8sh88ip1E6tHUHrc62K2SpMTd60on9xAVnVftl4P7AIgh1MCg', "a description signs to the fleet's own vector")
    packed = job_pack(seed, "audit-1a2b3c4d", body)
    check(job_verify(packed, did, "audit-1a2b3c4d") == body,
          "a signed description verifies at its own address")
    check(job_verify(packed, did, "audit-1a2b3c4e") is None,
          "and NOT at another job's address")

    print("%d checks, %d failed" % (len(ran), len(fails)))
    return "selftest failed" if fails else None



# ------------------------------------------------------------------ jobs --
# Publishing a job that is NOT a coin launch. Same room, same frames, same
# key; the only new thing is where the DESCRIPTION lives, because a room
# that keeps its newest 200 messages cannot hold one for longer than a few
# minutes.
#
# THE ADDRESS IS DERIVED FROM THE JOB ID, and that is what makes this
# readable by somebody who was not here when you posted. Measured against
# the room's own archive on 2026-09-04, 6,048 of the 6,767 offers that name
# a description address use exactly this rule. The `context` an offer
# carries is a CLAIM TO COMPARE, never a link to follow -- 101 of those
# 6,767 are not addresses at all, one of them being an entire sentence
# about token limits.
JOB_TAG = "tclk-job/v1"
JOB_SIG = "tjs1"
JOB_NS = "tclk-job-"
JOB_ID_LEN = 14                 # the longest id the address carries whole
JOB_SLUG_MAX = JOB_ID_LEN - 9   # a dash and eight hex characters
JOB_FIELDS = ("title", "kind", "deliverable", "acceptance",
              "inputs", "contact", "x")
JOB_REQUIRED = ("title", "kind", "deliverable", "acceptance")
JOB_LIMITS = {"title": 120, "kind": 40, "deliverable": 400, "acceptance": 400,
              "inputs": 400, "contact": 200, "x": 200}
# A note holds NOTE_MAX; the signature envelope spends the margin. The
# limits above are CHARACTERS and are not the gate -- a value is
# percent-encoded, so one CJK character costs nine. `job_budget` is the
# gate, and it counts what the wire carries.
JOB_NOTE_MAX = 8192
JOB_SIG_MARGIN = 200
JOB_BUDGET = JOB_NOTE_MAX - JOB_SIG_MARGIN


def job_slug(text):
    out = []
    for ch in str(text or "").lower():
        if ch.isascii() and (ch.isalnum() or ch == "-"):
            out.append(ch)
        elif out and out[-1] != "-":
            out.append("-")
        if len(out) >= JOB_SLUG_MAX:
            break
    return "".join(out).strip("-")[:JOB_SLUG_MAX]


def job_new_id(title=None):
    """`<slug>-<8 hex>`, exactly JOB_ID_LEN characters.

    The key is `id[:14]` and it TRUNCATES, so an id any longer throws its
    random half away before it reaches the address: two jobs whose titles
    start the same way would land on top of each other."""
    hexes = os.urandom(4).hex()
    slug = job_slug(title)
    if not slug:
        return (hexes + os.urandom(3).hex())[:JOB_ID_LEN]
    return "%s-%s" % (slug, hexes)


def job_addr(job_id):
    """(namespace, key). Derived, never read out of somebody's frame."""
    jid = str(job_id or "")
    if len(jid) < 3:
        return None, None
    return JOB_NS + jid[-2:], jid[:JOB_ID_LEN]


def job_rows(fields):
    q = urllib.parse.quote
    out = []
    for k in JOB_FIELDS:
        v = fields.get(k)
        if v:
            out.append("%s=%s" % (k, q(v, safe="")))
    return " ".join(out)


def job_budget(fields):
    return len(JOB_TAG) + 1 + len(job_rows(fields))


def job_render(fields):
    """(body, why). Values percent-encoded, fields in a fixed order.

    Fixed order because the body is SIGNED: two renders of one job have to
    be the same bytes or the second one verifies against nothing."""
    for k in JOB_REQUIRED:
        if not fields.get(k):
            return None, "the job has no %s" % k
    for k in JOB_FIELDS:
        v = fields.get(k) or ""
        if len(v) > JOB_LIMITS[k]:
            return None, "%s is %d characters, over %d" % (
                k, len(v), JOB_LIMITS[k])
        if any(ord(ch) < 32 for ch in v):
            return None, "%s carries a control character" % k
    kind = fields.get("kind") or ""
    if not kind or any(ch not in "abcdefghijklmnopqrstuvwxyz0123456789-"
                       for ch in kind):
        return None, "kind must be lowercase letters, digits and dashes"
    used = job_budget(fields)
    if used > JOB_BUDGET:
        return None, ("the job is %d of %d characters once encoded (%d over) "
                      "-- non-ASCII costs up to 9 each, so shorten the "
                      "longest field or put the detail behind --x"
                      % (used, JOB_BUDGET, used - JOB_BUDGET))
    return JOB_TAG + " " + job_rows(fields), None


def job_pack(seed, job_id, body):
    """Sign a description with its own address inside the signed message.

    Without the address in there, a body that verifies at one job verifies
    at every job -- somebody could copy your signed description onto their
    own offer and it would read as yours."""
    ns, key = job_addr(job_id)
    msg = "%s|%s/%s|%s" % (JOB_SIG, ns, key, body)
    return "%s %s %s" % (JOB_SIG, _b64url(sign_bytes(seed, msg.encode("utf-8"))),
                         body)


def job_verify(text, did, job_id):
    """The body if `did` signed it FOR THIS ADDRESS, else None."""
    parts = (text or "").strip().split(" ", 2)
    if len(parts) < 3 or parts[0] != JOB_SIG:
        return None
    ns, key = job_addr(job_id)
    if not ns:
        return None
    pub = public_from_did(did)
    if pub is None:
        return None
    msg = "%s|%s/%s|%s" % (JOB_SIG, ns, key, parts[2])
    raw = _b64url_decode(parts[1])
    if raw is None or not verify_bytes(pub, msg.encode("utf-8"), raw):
        return None
    return parts[2]


def job_parse(text):
    """The fields in a description body, or (None, why)."""
    if not isinstance(text, str) or not text.strip().startswith(JOB_TAG):
        return None, "not a %s body" % JOB_TAG
    out = {}
    for part in text.strip().split(" ")[1:]:
        if not part:
            continue
        if "=" not in part:
            return None, ("%r is not a field; values must be "
                          "percent-encoded" % part[:24])
        k, v = part.split("=", 1)
        if k.strip() in JOB_FIELDS:
            out[k.strip()] = urllib.parse.unquote(v)
    for want in JOB_REQUIRED:
        if not out.get(want):
            return None, "the body has no %s" % want
    return out, None


def cmd_job(args):
    # VALIDATE BEFORE TOUCHING ANYTHING. load_or_create writes a key file on
    # first run, so doing it first means a typo in --kind creates a key
    # before the typo is reported. Nothing here should have a side effect
    # until the arguments are known to be postable.
    fields = {k: getattr(args, k, None) for k in JOB_FIELDS}
    body, why = job_render(fields)
    if why:
        return why
    seed, did = load_or_create(KEY_PATH)
    jid = job_new_id(fields["title"])
    ns, key = job_addr(jid)
    packed = job_pack(seed, jid, body)

    # THE DESCRIPTION GOES FIRST. An offer whose address is empty is a job
    # nobody can read; a description nobody has pointed at yet is early.
    url = "%s/kv/%s/%s/set/%s" % (BASE, ns, key,
                                  urllib.parse.quote(packed, safe=""))
    got, err = _get(url)
    if err:
        return "the description was refused at /kv/%s/%s: %s" % (ns, key, err)

    now = int(time.time() * 1000)
    days = max(1, min(90, int(getattr(args, "days", 7) or 7)))
    fr = {"type": "offer", "from": did, "role": "payer",
          "amount": str(args.amount), "asset": args.asset, "lock": "hash",
          "rails": [r for r in str(args.rails).split(",") if r],
          "expiresMs": now + days * 86400000,
          "claimByMs": now + (days + 1) * 86400000,
          "refundAfterMs": now + (days + 2) * 86400000,
          "nonce": os.urandom(4).hex(),
          "job": {"proto": "a2a", "id": jid,
                  "context": "/kv/%s/%s" % (ns, key)}}
    oid = offer_id(fr)
    fr["id"] = oid
    ok, why = post_line(seed, encode_frame(fr))
    if not ok:
        return ("the description IS published at /kv/%s/%s, but the room "
                "refused the offer (%s) -- run the same command again to "
                "post it" % (ns, key, why))

    print("posted as %s" % did)
    print("job       %s" % jid)
    print("offer     %s" % oid)
    print("address   %s/kv/%s/%s" % (BASE, ns, key))
    print("")
    print("The room drops this in a few minutes. The address does not.")
    return None


def cmd_jobs(args):
    """Open jobs in the room whose description can be attributed."""
    msgs, err = room_json()
    if err:
        return err
    offers, gone = {}, set()
    for m in msgs:
        fr = decode_frame(m.get("text") or "")
        if not isinstance(fr, dict) or not m.get("signed"):
            continue
        signer = m.get("nick")
        if not signer:
            continue
        if fr.get("type") == "offer" and fr.get("id"):
            offers[str(fr["id"])] = (fr, signer)
        elif fr.get("type") in ("accept", "cancel") and fr.get("ref"):
            gone.add(str(fr["ref"]))

    now = int(time.time() * 1000)
    shown = muted = 0
    for oid, (fr, signer) in sorted(
            offers.items(), key=lambda kv: -int(kv[1][0].get("expiresMs") or 0)):
        if oid in gone:
            continue
        exp = fr.get("expiresMs")
        if not (isinstance(exp, int) and exp > now):
            continue
        job = fr.get("job") if isinstance(fr.get("job"), dict) else {}
        jid = str(job.get("id") or "")
        ns, key = job_addr(jid)
        if not ns or job.get("context") != "/kv/%s/%s" % (ns, key):
            continue
        raw, gerr = _get("%s/kv/%s/%s" % (BASE, ns, key))
        # An unreadable description is not an absent one; it is counted,
        # never rendered as "nobody signed this".
        body = None if gerr else job_verify(raw, signer, jid)
        fields, _why = job_parse(body or "")
        if not fields:
            muted += 1
            continue
        shown += 1
        print("%s  %s %s  %s" % (jid, fr.get("amount"), fr.get("asset"),
                                 fields["title"]))
        print("    kind        %s" % fields["kind"])
        print("    deliverable %s" % fields["deliverable"])
        print("    done when   %s" % fields["acceptance"])
        print("    by          %s" % signer)
        print("    rails       %s" % ", ".join(fr.get("rails") or []))
        print("    address     %s/kv/%s/%s" % (BASE, ns, key))
        print("")
    if not shown:
        print("No open job in the room's window has a description signed by "
              "the did that signed its offer.")
    if muted:
        print("(%d more named a derivable address that did not verify or "
              "could not be read.)" % muted)
    return None

def main(argv):
    ap = argparse.ArgumentParser(description="order a coin launch from Floppy")
    sub = ap.add_subparsers(dest="cmd")
    sub.add_parser("selftest")
    p = sub.add_parser("post")
    p.add_argument("--name", required=True)
    p.add_argument("--symbol", required=True)
    p.add_argument("--creator", required=True)
    p.add_argument("--image", required=True)
    p.add_argument("--desc")
    p.add_argument("--x")
    p.add_argument("--lamports", type=int, default=50_000_000)
    s = sub.add_parser("status")
    s.add_argument("offer", nargs="?")
    v = sub.add_parser("verify")
    v.add_argument("mint")
    v.add_argument("creator", nargs="?")
    j = sub.add_parser("job")
    j.add_argument("--title", required=True)
    j.add_argument("--kind", required=True)
    j.add_argument("--deliverable", required=True)
    j.add_argument("--acceptance", required=True)
    j.add_argument("--inputs")
    j.add_argument("--contact")
    j.add_argument("--x")
    j.add_argument("--amount", default="0")
    j.add_argument("--asset", default="FLOPPY")
    j.add_argument("--rails", default="flop-direct")
    j.add_argument("--days", type=int, default=7)
    sub.add_parser("jobs")
    args = ap.parse_args(argv)
    fn = {"selftest": cmd_selftest, "post": cmd_post,
          "status": cmd_status, "verify": cmd_verify,
          "job": cmd_job, "jobs": cmd_jobs}.get(args.cmd)
    if fn is None:
        ap.print_help()
        return 2
    why = fn(args)
    if why:
        print("REFUSED: %s" % why, file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
