#!/usr/bin/env python3 """Sign and submit a Nano forecast ladder entry. Python 3.8+, standard library only. python3 client/sign-and-submit.py --key <64 hex private key> --round 0 --forecasts '{"btc_usd":0.4,...}' python3 client/sign-and-submit.py --seed <64 hex> [--index 0] --round 0 --forecasts-file f.json Options: --url http://127.0.0.1:3002 --nonce S --stake-hash H --dry-run --work-rpc http://127.0.0.1:7076 (ask a Nano node for work; local work is ~2^29 blake2b hashes, about 1.7 MH/s per core with hashlib, so expect a few minutes on a laptop) Ed25519 with Blake2b-512 (the Nano variant) is implemented below in pure Python (slow but tiny). """ import argparse, hashlib, json, multiprocessing, os, random, struct, sys, time, urllib.request THRESHOLD = 0xfffffff800000000 ALPHABET = "13456789abcdefghijkmnopqrstuwxyz" # ---- canonical JSON (must match the server: sorted keys, no whitespace, JS number formatting) def es_number(x): if isinstance(x, bool): raise TypeError("bool") if isinstance(x, int): return str(x) if x != x or x in (float("inf"), float("-inf")): raise ValueError("non-finite") if x == int(x) and abs(x) < 1e21: return str(int(x)) r = repr(x) if "e" not in r: return r mant, exp = r.split("e"); e = int(exp) if -7 < e < 21: from decimal import Decimal return format(Decimal(r), "f") return mant + "e" + ("+" if e > 0 else "-") + str(abs(e)) def canonical(v): if v is None: return "null" if v is True: return "true" if v is False: return "false" if isinstance(v, str): return json.dumps(v, ensure_ascii=False) if isinstance(v, (int, float)): return es_number(v) if isinstance(v, list): return "[" + ",".join(canonical(x) for x in v) + "]" if isinstance(v, dict): return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical(v[k]) for k in sorted(v)) + "}" raise TypeError(type(v)) # ---- Ed25519 over the twisted Edwards curve, with blake2b-512 in place of sha512 q = 2**255 - 19 l = 2**252 + 27742317777372353535851937790883648493 def inv(x): return pow(x, q - 2, q) d = (-121665 * inv(121666)) % q I = pow(2, (q - 1) // 4, q) def xrecover(y): xx = (y * y - 1) * inv(d * y * y + 1) x = pow(xx, (q + 3) // 8, q) if (x * x - xx) % q != 0: x = (x * I) % q if x % 2 != 0: x = q - x return x By = (4 * inv(5)) % q B = (xrecover(By), By) def add(P, Q): x1, y1 = P; x2, y2 = Q x3 = (x1 * y2 + x2 * y1) * inv(1 + d * x1 * x2 * y1 * y2) y3 = (y1 * y2 + x1 * x2) * inv(1 - d * x1 * x2 * y1 * y2) return (x3 % q, y3 % q) def mul(P, e): Q = (0, 1) for bit in bin(e)[2:]: Q = add(Q, Q) if bit == "1": Q = add(Q, P) return Q def encodepoint(P): return (P[1] | ((P[0] & 1) << 255)).to_bytes(32, "little") def H(m): return hashlib.blake2b(m).digest() def clamp(h): a = int.from_bytes(h[:32], "little"); a &= (1 << 254) - 8; a |= 1 << 254; return a def public_key(sk): return encodepoint(mul(B, clamp(H(sk)))) def sign(msg, sk): h = H(sk); a = clamp(h); pk = encodepoint(mul(B, a)) r = int.from_bytes(H(h[32:] + msg), "little") % l R = encodepoint(mul(B, r)) k = int.from_bytes(H(R + pk + msg), "little") S = (r + k * a) % l return R + S.to_bytes(32, "little") # ---- Nano address def b32(data, bits): n = int.from_bytes(data, "big"); out = "" for i in range(bits // 5 - 1, -1, -1): out += ALPHABET[(n >> (5 * i)) & 31] return out def address_of(pk): check = hashlib.blake2b(pk, digest_size=5).digest()[::-1] return "nano_" + b32(pk, 260) + b32(check, 40) # ---- proof of work def _search(args): hash_bytes, start, count = args n = start for _ in range(count): v = hashlib.blake2b(n.to_bytes(8, "little") + hash_bytes, digest_size=8).digest() if int.from_bytes(v, "little") >= THRESHOLD: return n n = (n + 1) & 0xFFFFFFFFFFFFFFFF return None def compute_work(hash_hex, procs): hb = bytes.fromhex(hash_hex); chunk = 2_000_000; started = time.time(); tried = 0 with multiprocessing.Pool(procs) as pool: while True: jobs = [(hb, random.getrandbits(64), chunk) for _ in range(procs)] for res in pool.imap_unordered(_search, jobs): if res is not None: pool.terminate(); return "%016x" % res tried += chunk * procs el = time.time() - started print(" working... %d s, %.0f M hashes tried (expect ~537 M on average)" % (el, tried / 1e6), file=sys.stderr) def work_rpc(hash_hex, url): req = urllib.request.Request(url, data=json.dumps({"action": "work_generate", "hash": hash_hex, "difficulty": "%016x" % THRESHOLD}).encode()) j = json.load(urllib.request.urlopen(req, timeout=300)) if "error" in j: raise SystemExit("work_generate: " + j["error"]) return j["work"] def main(): p = argparse.ArgumentParser() p.add_argument("--key"); p.add_argument("--seed"); p.add_argument("--index", type=int, default=0) p.add_argument("--round", type=int, required=True) p.add_argument("--forecasts"); p.add_argument("--forecasts-file") p.add_argument("--url", default="http://127.0.0.1:3002"); p.add_argument("--nonce"); p.add_argument("--stake-hash") p.add_argument("--work-rpc"); p.add_argument("--procs", type=int, default=os.cpu_count() or 1); p.add_argument("--dry-run", action="store_true") a = p.parse_args() if a.seed: sk = hashlib.blake2b(bytes.fromhex(a.seed) + struct.pack(">I", a.index), digest_size=32).digest() elif a.key: sk = bytes.fromhex(a.key) else: p.error("--key or --seed required") if len(sk) != 32: p.error("key must be 32 bytes (64 hex)") if not (a.forecasts or a.forecasts_file): p.error("--forecasts or --forecasts-file required") forecasts = json.loads(a.forecasts) if a.forecasts else json.load(open(a.forecasts_file)) pk = public_key(sk); address = address_of(pk) nonce = a.nonce or ("%x%06x" % (int(time.time()), random.getrandbits(24))) fields = {"round": a.round, "address": address, "forecasts": forecasts, "nonce": nonce} canon = canonical(fields); msg = hashlib.blake2b(canon.encode(), digest_size=32).digest(); hash_hex = msg.hex().upper() print("address " + address + "\ncanonical " + canon + "\nmessage " + hash_hex, file=sys.stderr) t0 = time.time() work = work_rpc(hash_hex, a.work_rpc) if a.work_rpc else compute_work(hash_hex, a.procs) print("work %s (%d s)" % (work, time.time() - t0), file=sys.stderr) body = dict(fields, work=work, signature=sign(msg, sk).hex().upper()) if a.stake_hash: body["stake_hash"] = a.stake_hash print(json.dumps(body, indent=1)) if a.dry_run: return req = urllib.request.Request(a.url + "/v1/entries", data=json.dumps(body).encode(), headers={"content-type": "application/json"}) try: r = urllib.request.urlopen(req, timeout=60); print("HTTP %d %s" % (r.status, r.read().decode()), file=sys.stderr) except urllib.error.HTTPError as e: print("HTTP %d %s" % (e.code, e.read().decode()), file=sys.stderr); sys.exit(1) if __name__ == "__main__": main()