#!/usr/bin/env python3
"""zcd-escrow.py -- OTC hashlock escrow companion for HashlockEscrow on Zycord.

Non-custodial deal driver: prepares secrets, submits open/claim/refund
certificates, and verifies deal cells straight off /storage. The operator
holds no key that can move a deal; this tool only drives the two parties'
own wallets.

External users: the KEYS/pass/token PATHS below are the pool operator's
server layout -- use the raw `zcd wallet call` commands from
pool.zcd.lol/otc/escrow.html for your own wallets. Retries are safe:
a resubmitted open/claim/refund that already landed reverts on the
write-once guard or the delta guards, never double-paying.

Secrets flow file -> stdin pipe only; never in args, env, or logs.
Run as root (wallet files follow the pool-wallets precedent).
"""
import hashlib, json, os, subprocess, sys, time, urllib.request

ZCD = "/opt/zycord-0.7.1/zcd"
RPC_READ = "http://127.0.0.1:9420"
RPC_TX = "http://127.0.0.1:9424"
TOKEN = "/var/lib/zycord/pool-mainnet/rpc-token"
ENV_FILE = "/home/linuxuser/pool-anchor/contract.env"
RUNTIME = "/home/linuxuser/pool-anchor/HashlockEscrow.runtime.bin"
WALLETS = "/home/linuxuser/pool-wallets"
STATE_DIR = "/var/lib/zcd-escrow"
UNIT = 100000000
LOG = "esc:"
EPOCH = 2880

KEYS = {  # role -> (key file, passphrase line prefix in PASSPHRASES.txt / own pass file)
    "anchor": (WALLETS + "/anchor-mainnet.key", WALLETS + "/anchor-mainnet.pass", "anchor-mainnet passphrase:"),
    "bridge": (WALLETS + "/bridge-1022.key", WALLETS + "/PASSPHRASES.txt", "bridge-1022 passphrase:"),
}


def die(m):
    print(LOG, m)
    sys.exit(1)


def log(m):
    print(LOG, m)


# ---- keccak-256 (verbatim from zcd-cred-mint)
def _keccak_f(state):
    RC = [0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,
          0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
          0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
          0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
          0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,
          0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008]
    R = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61],
         [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]
    M = (1 << 64) - 1
    for rnd in range(24):
        C = [state[x][0] ^ state[x][1] ^ state[x][2] ^ state[x][3] ^ state[x][4] for x in range(5)]
        D = [C[(x - 1) % 5] ^ ((C[(x + 1) % 5] << 1) & M) ^ (C[(x + 1) % 5] >> 63) for x in range(5)]
        for x in range(5):
            for y in range(5):
                state[x][y] ^= D[x]
        B = [[0] * 5 for _ in range(5)]
        for x in range(5):
            for y in range(5):
                v = state[x][y]
                B[y][(2 * x + 3 * y) % 5] = ((v << R[x][y]) & M) ^ (v >> (64 - R[x][y]))
        for x in range(5):
            for y in range(5):
                state[x][y] = B[x][y] ^ ((~B[(x + 1) % 5][y]) & M) & B[(x + 2) % 5][y]
        state[0][0] ^= RC[rnd]
    return state


def keccak256(data: bytes) -> bytes:
    rate = 136
    state = [[0] * 5 for _ in range(5)]
    padded = bytearray(data)
    padded.append(0x01)
    while len(padded) % rate != 0:
        padded.append(0x00)
    padded[-1] ^= 0x80
    for off in range(0, len(padded), rate):
        blk = padded[off:off + rate]
        for i in range(rate // 8):
            state[i % 5][i // 5] ^= int.from_bytes(blk[i * 8:(i + 1) * 8], "little")
        _keccak_f(state)
    out = bytearray()
    for i in range(4):
        out += state[i % 5][i // 5].to_bytes(8, "little")
    return bytes(out)


def pad32(v: int) -> bytes:
    return v.to_bytes(32, "big")


def mapping_key(slot: int, key_int: int) -> str:
    return keccak256(pad32(key_int) + pad32(slot)).hex()


def selector(sig: str) -> str:
    return keccak256(sig.encode()).hex()[:8]


def word(v) -> str:
    if isinstance(v, str):
        v = int(v, 16)  # all string args here are hex, with or without 0x
    return "%064x" % v

# ---- encoding (single copies; the verbatim block above owns the originals)
def escrow_id(opener20_hex: str, salt_hex: str) -> str:
    """keccak256(abi.encode(address, bytes32)) == keccak(pad32(addr) . salt)."""
    a = int(opener20_hex, 16)
    s = int(salt_hex, 16)
    return keccak256(pad32(a) + pad32(s)).hex()


# ---------------------------------------------------------------- slots
# Declaration order: _locked slot 0, _terms slot 1, _amount slot 2, _hashlock slot 3.
# Terms member offsets: 0 = claimant(20B)+refundHeight(8B) packed, 1 = opener.
def slot_locked() -> str:
    return "0" * 63 + "0"


def slot_terms(id_hex: str, member: int) -> str:
    return "%064x" % (int(mapping_key(1, int(id_hex, 16)), 16) + member)


def slot_amount(id_hex: str) -> str:
    return mapping_key(2, int(id_hex, 16))


def slot_hashlock(id_hex: str) -> str:
    return mapping_key(3, int(id_hex, 16))


# ---------------------------------------------------------------- chain reads
def storage(contract: str, key64hex: str):
    url = "%s/storage?addr=%s&key=0x%s" % (RPC_READ, contract, key64hex)
    with urllib.request.urlopen(url, timeout=30) as r:
        return json.load(r).get("value")


def balance(addr: str):
    with urllib.request.urlopen("%s/balance?addr=%s" % (RPC_READ, addr), timeout=30) as r:
        return int(json.load(r)["balance"])


def chain_height():
    with urllib.request.urlopen("%s/status" % RPC_READ, timeout=30) as r:
        return json.load(r)["height"]


def epoch_start():
    return chain_height() // EPOCH * EPOCH


def env(k):
    for line in open(ENV_FILE):
        if line.startswith(k + "="):
            return line.split("=", 1)[1].strip()
    die("contract.env missing " + k)


# ---------------------------------------------------------------- wallet plumbing
def read_pass(role):
    _, passfile, prefix = KEYS[role]
    for line in open(passfile):
        if line.startswith(prefix):
            return line.split(":", 1)[1].strip()
    die("passphrase entry missing for " + role)


def wallet_call(role, args, timeout=420, value=None):
    keyf = KEYS[role][0]
    pw = read_pass(role)
    # These certificates exact-read terms cells under --accept-poison-risk, so
    # the wallet demands a fee ceiling that can cover a skip fee; the default
    # headroom (8x base fee) is far below it. Raising the ceiling does not
    # raise the fee actually charged.
    cmd = [ZCD, "wallet", "call", "--accept-poison-risk", "-headroom", "20000",
           "--key", keyf, "--rpc", RPC_TX, "--rpc-token-file", TOKEN]
    if value is not None:
        cmd += ["--value", str(value)]
    cmd += args
    out = ""
    for attempt in range(4):
        p = subprocess.run(cmd, input=pw + "\n", capture_output=True, text=True, timeout=timeout)
        out = p.stdout + p.stderr
        if "rate-limit" in out or "429" in out:
            log("rate limited (try %d), waiting 70s" % (attempt + 1))
            time.sleep(70)
            continue
        return out
    return out


def submit(role, calldata, what, value=None):
    os.makedirs(STATE_DIR, exist_ok=True)
    path = STATE_DIR + "/calldata.hex"
    with open(path, "w") as f:
        f.write(calldata + "\n")
    args = ["--to", env("ESCROW_CONTRACT"), "--data", path, "--code", RUNTIME]
    for attempt in range(2):
        out = wallet_call(role, args, value=value)
        if "applied at height" in out and "REVERTED" not in out and "dropped" not in out:
            h = [l for l in out.splitlines() if "applied at height" in l][0].split()[3]
            log("%s: applied at height %s" % (what, h))
            return out
        log("%s: not clean -> %s" % (what, out.strip().splitlines()[-1][:160] if out.strip() else "?"))
        time.sleep(5)
    die("%s: failed twice" % what)


# ---------------------------------------------------------------- deals
def deal_path(id_hex):
    return "%s/deal-%s.json" % (STATE_DIR, id_hex[:16])


def save_deal(d):
    os.makedirs(STATE_DIR, exist_ok=True)
    p = deal_path(d["id"])
    with open(p, "w") as f:
        json.dump(d, f, indent=1)
    os.chmod(p, 0o600)
    return p


def load_deal(ref):
    p = ref if os.path.exists(ref) else deal_path(ref)
    try:
        return json.load(open(p))
    except Exception:
        die("no deal file for " + ref)


def cmd_prepare(a):
    amount = int(a["--amount"])
    claimant32 = a["--claimant-addr"].lower().replace("0x", "")
    claimant_key = a.get("--claimant-key", "bridge")
    refund_h = int(a["--refund-height"]) if "--refund-height" in a else epoch_start() + EPOCH + 1
    opener20 = subprocess.run(["/usr/local/bin/zcd-nome", env("ANCHOR_ADDR").lower().replace("0x", "")],
                              capture_output=True, text=True).stdout.strip()
    claimant20 = subprocess.run(["/usr/local/bin/zcd-nome", claimant32], capture_output=True, text=True).stdout.strip()
    if len(opener20) != 40 or len(claimant20) != 40:
        die("zcd-nome failed to derive machine names")
    secret = os.urandom(32)
    h = hashlib.sha256(secret).hexdigest()
    salt = os.urandom(32).hex()
    did = escrow_id(opener20, salt)
    d = {"id": did, "salt": salt, "secret": secret.hex(), "hash": h,
         "amount": amount, "claimant32": claimant32, "claimant20": claimant20,
         "claimant_key": claimant_key, "opener20": opener20, "refund_height": refund_h,
         "created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "state": "prepared"}
    p = save_deal(d)
    log("deal %s prepared (%s)" % (did[:16], p))
    log("  amount %s drops  refund_height %d (epoch start %d)" % (amount, refund_h, epoch_start()))
    log("  hashlock 0x%s" % h)
    log("  claimant name 0x%s" % claimant20)
    return d


def cmd_open(a):
    d = load_deal(a["<deal>"])
    calldata = (selector("open(bytes32,address,bytes32,uint64)")
                + word(d["salt"]) + word("0x" + d["claimant20"])
                + word(d["hash"]) + word(d["refund_height"]))
    submit("anchor", calldata, "open %s" % d["id"][:16], value=d["amount"])
    d["state"] = "open"
    save_deal(d)


def cmd_claim(a):
    d = load_deal(a["<deal>"])
    role = a.get("--by", d.get("claimant_key", "bridge"))
    calldata = (selector("claim(bytes32,uint256,bytes32)")
                + word(d["id"]) + word(d["amount"]) + word(d["secret"]))
    submit(role, calldata, "claim %s" % d["id"][:16])
    d["state"] = "claimed"
    save_deal(d)


def cmd_refund(a):
    d = load_deal(a["<deal>"])
    role = a.get("--by", "anchor")
    calldata = (selector("refund(bytes32,uint256)")
                + word(d["id"]) + word(d["amount"]))
    submit(role, calldata, "refund %s" % d["id"][:16])
    d["state"] = "refunded"
    save_deal(d)


def cmd_info(a):
    d = load_deal(a["<deal>"])
    c = env("ESCROW_CONTRACT")
    ta = int(storage(c, slot_terms(d["id"], 0)) or "0")
    claimant = ta & ((1 << 160) - 1)
    rh = ta >> 160
    opener = int(storage(c, slot_terms(d["id"], 1)) or "0")
    hl = int(storage(c, slot_hashlock(d["id"])) or "0")
    locked = int(storage(c, slot_locked()) or "0")
    log("deal %s" % d["id"])
    log("  chain: claimant 0x%040x" % claimant)
    log("  chain: refund_height %d (epoch start %d, unlock in %d..%d h)" % (
        rh, epoch_start(), max(0, (rh - chain_height())) * 30 // 3600, max(0, (rh - epoch_start()) * 30 // 3600)))
    log("  chain: opener 0x%040x" % opener)
    log("  chain: hashlock 0x%064x" % hl)
    log("  chain: global locked %d drops" % locked)
    log("  contract balance %d drops" % balance(c))
    log("  local: state %s amount %d" % (d["state"], d["amount"]))
    ok_h = ("%064x" % hl) == d["hash"]
    ok_c = ("%040x" % claimant) == d["claimant20"]
    log("  verify: hashlock %s  claimant %s" % ("OK" if ok_h else "MISMATCH", "OK" if ok_c else "MISMATCH"))


def cmd_list(a):
    if not os.path.isdir(STATE_DIR):
        return
    for fn in sorted(os.listdir(STATE_DIR)):
        if fn.startswith("deal-"):
            d = json.load(open(STATE_DIR + "/" + fn))
            log("%s  %10s drops  refund@%d  %s" % (d["id"][:16], d["amount"], d["refund_height"], d["state"]))


def cmd_share(a):
    """Print the deal WITHOUT the secret, for the buyer to verify on-chain."""
    d = load_deal(a["<deal>"])
    keys = ("id", "salt", "hash", "amount", "claimant32", "claimant20",
            "opener20", "refund_height", "created", "state")
    print(json.dumps({k: d[k] for k in keys if k in d}, indent=1))


def cmd_verify(a):
    """Buyer-side check of a shared deal record against live cells."""
    p = a["<deal>"]
    d = json.load(open(p))
    c = env("ESCROW_CONTRACT")
    want_id = escrow_id(d["opener20"], d["salt"])
    ta = int(storage(c, slot_terms(d["id"], 0)) or "0")
    claimant = ta & ((1 << 160) - 1)
    rh = ta >> 160
    opener = int(storage(c, slot_terms(d["id"], 1)) or "0")
    hl = int(storage(c, slot_hashlock(d["id"])) or "0")
    amt = int(storage(c, slot_amount(d["id"])) or "0")
    bal = balance(c)
    checks = [
        ("id re-derives from opener+salt", want_id == d["id"]),
        ("claimant cell == shared claimant", "%040x" % claimant == d["claimant20"]),
        ("refund_height cell matches", rh == d["refund_height"]),
        ("opener cell matches", "%040x" % opener == d["opener20"]),
        ("hashlock cell == shared hash", "%064x" % hl == d["hash"]),
        ("amount cell == shared amount", amt == d["amount"]),
        ("contract holds at least the amount", bal >= d["amount"]),
    ]
    for n, ok in checks:
        log("  %-36s %s" % (n, "OK" if ok else "MISMATCH"))
    if not all(ok for _, ok in checks):
        die("VERIFY FAILED - do not pay the external leg")
    log("verified. amount %s drops, refund_height %d (epoch start %d)" % (d["amount"], rh, epoch_start()))


def main():
    args = sys.argv[1:]
    if not args:
        print(__doc__)
        return
    cmd = args[0]
    rest = args[1:]
    flags = {}
    pos = []
    for x in rest:
        if x.startswith("--") and "=" in x:
            k, v = x.split("=", 1)
            flags[k] = v
        elif x.startswith("--"):
            flags[x] = "1"
        else:
            pos.append(x)
    flags["<deal>"] = pos[0] if pos else None
    {"prepare": cmd_prepare, "open": cmd_open, "claim": cmd_claim, "share": cmd_share,
     "verify": cmd_verify,
     "refund": cmd_refund, "info": cmd_info, "list": cmd_list}.get(cmd, lambda a: print(__doc__))(flags)


if __name__ == "__main__":
    main()
