#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Eddycoin mining pool (PPLNS, per lane) - runs NEXT TO an Eddycoin node, never inside it. =========================================================================================== * Separate process + port (default 127.0.0.1:8771). Talks to the node only through its public HTTP API (/api/work, /api/submit/e|d, /api/state, /api/wallet, /api/tx, /api/block/Y/, /api/node/info). Consensus is untouched: blocks the pool finds are ordinary blocks whose 'miner' field is the pool address. * Pool-aware miners (miner 1.3+) see a "pool" object in /api/work, put the pool address in the block header, mine at a lower share target and send their own wallet as "payout". Old miners (no "payout" field) are relayed 1:1 to the node = they keep solo mining exactly as before. * Accounting: PPLNS separately for the E lane (CPU) and the D lane (GPU), weight = 2^share_bits. Rewards are credited only after the checkpoint that paid the pool is CONFIRMATIONS checkpoints deep and still carries the pool's block as evidence. Pool fee (fee_bps, 0..500) goes to fee_address; rounding dust too. * Payouts: normal signed v2 transfers from the pool wallet (max 3 recipients + change per tx), min payout, tx fee split among the recipients of the tx. The signed tx is stored in the ledger in the same DB transaction that debits the balances BEFORE it is broadcast -> a restart can only re-broadcast the very same tx (no double pay). * Pool wallet: Eddycoin wallet v2 file (PBKDF2 + AES-256-GCM, chmod 600) + password file (chmod 600). The key is decrypted only while signing and never logged. Needs python3-cryptography for signing; everything else is stdlib. Eddycoin mainnet (eddycoin-mainnet-1). Fee address must never be an old (denylisted) testnet key. """ import argparse, base64, hashlib, html, json, math, os, re, shutil, sqlite3, sys, threading, time, urllib.error, urllib.parse, urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer POOL_VERSION = "pool-2.0-mainnet" NETWORK = "eddycoin-mainnet-1" GENESIS_ID = "f720818b202072d10b8b694d39eba3f17bb8a82d8597a92688057a5016f4746a" ADDR_RE = re.compile(r"^eddy1[0-9a-f]{40}$") HEX64 = re.compile(r"^[0-9a-f]{64}$") BLOCK_KEYS = ("chain", "height", "prev", "y_candidate", "miner", "time", "bits", "nonce", "alg", "hash") FEE_ACCT = "POOLFEE" # internal ledger account for the pool fee (paid to fee_address once it is a valid address) MIN_TX_FEE = 1000000 # 0.01 EDDY (network minimum) DEFAULTS = { "node": "http://127.0.0.1:8773", "listen": "127.0.0.1", "port": 8774, "data": "./pool_data", "wallet_file": "", "wallet_password_file": "", "fee_bps": 0, "fee_address": "", "denylist": "", "pplns_factor": 2.0, "confirmations": 6, "min_payout": "100000000", "payout_interval_s": 600, "tx_fee": "1000000", "max_txs_per_round": 5, "share_target_s": 10, "min_share_bits": {"E": 16, "D": 20}, "start_share_bits": {"E": 20, "D": 26}, "max_shares_per_min_addr": 120, "max_shares_per_min_ip": 600, "max_get_per_min_ip": 1200, "max_skew_ms": 120000, "behind_proxy": False, "start_height": None, "backup_every_s": 21600, "keep_backups": 28, "public_url": "", } def now_ms(): return int(time.time() * 1000) def is_addr(a): return isinstance(a, str) and bool(ADDR_RE.match(a)) def is_int(n): return isinstance(n, int) and not isinstance(n, bool) and abs(n) <= 2 ** 53 - 1 def lzb(h): n = 0 for c in h: v = int(c, 16) if v == 0: n += 4; continue n += 4 - v.bit_length(); break return n def block_hash(b): # == eddy-core.js blockHash (sha256d) hd = "EDDY1-%s-BLOCK|v1|%s|%s|%s|%s|%s|%s|%s" % (b["chain"], b["height"], b["prev"], b["y_candidate"], b["miner"], b["time"], b["bits"], b["nonce"]) return hashlib.sha256(hashlib.sha256(hd.encode("utf-8")).digest()).hexdigest() def reward_split(sub, fees): # == eddy-core.js rewardSplit sub, fees = int(sub), int(fees) d = sub // 2 + fees // 2 return sub + fees - d, d def fmt(m, dec=8): m = int(m); s = str(abs(m)).rjust(dec + 1, "0") return ("-" if m < 0 else "") + "{:,}".format(int(s[:-dec])) + "." + s[-dec:] def split_reward(reward, weights, fee_bps): """exact integer split: fee = floor(R*bps/10000); miners share R-fee by weight (floor); dust -> fee account. returns {addr: amount} incl. FEE_ACCT; sum == reward exactly.""" reward = int(reward); fee = reward * int(fee_bps) // 10000; rest = reward - fee W = sum(weights.values()); out = {} if W <= 0: return {FEE_ACCT: reward} for a, w in sorted(weights.items()): v = rest * w // W if v: out[a] = v dust = rest - sum(out.values()) out[FEE_ACCT] = fee + dust assert sum(out.values()) == reward return out def http_json(url, body=None, timeout=15): data = None if body is None else json.dumps(body, separators=(",", ":")).encode() req = urllib.request.Request(url, data=data, method="GET" if body is None else "POST", headers={"content-type": "application/json", "accept": "application/json", "user-agent": "eddycoin-pool/1"}) try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.status, json.loads(r.read().decode()) except urllib.error.HTTPError as e: try: return e.code, json.loads(e.read().decode()) except Exception: return e.code, {"ok": False, "error": "HTTP %d" % e.code} # ------------------------------------------------------------------------------------------------ wallet (signing only) def _b64u_dec(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def _b64u_enc(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() def address_of(pub): return "eddy1" + hashlib.sha256(("EDDY-ADDR|v1|" + pub).encode()).hexdigest()[:40] def canon(o): return json.dumps(o, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def txid_of(t): return hashlib.sha256(("EDDY1-TX|v1|" + canon({k: t[k] for k in ("v", "type", "inputs", "outputs", "fee", "time", "memo", "pubkey")})).encode()).hexdigest() class PoolWallet: """Eddycoin wallet v2 file (same format as Eddycoin Wallet). Holds only the public part in memory.""" def __init__(self, path, pw_file): self.path, self.pw_file = path, pw_file for p in (path, pw_file): if not os.path.exists(p): raise SystemExit("pool wallet: missing %s" % p) if os.name == "posix" and os.stat(p).st_mode & 0o077: raise SystemExit("pool wallet: %s must be chmod 600 (no group/other access)" % p) with open(path, encoding="utf-8") as f: w = json.load(f) if w.get("format") != "eddycoin-wallet" or not w.get("encryptedPrivateKey"): raise SystemExit("pool wallet must be an ENCRYPTED Eddycoin wallet v2 file") self.pub = w.get("pubkey") or w.get("publicKey"); self.address = w["address"] if address_of(self.pub) != self.address: raise SystemExit("pool wallet damaged (address != public key)") self._enc = w["encryptedPrivateKey"] def sign(self, t): from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC with open(self.pw_file, encoding="utf-8") as f: pw = bytearray(f.read().strip().encode()) e = self._enc key = bytearray(PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=base64.b64decode(e["salt"]), iterations=int(e["iterations"])).derive(bytes(pw))) try: pt = bytearray(AESGCM(bytes(key)).decrypt(base64.b64decode(e["nonce"]), base64.b64decode(e["ciphertext"]), self.address.encode())) except Exception: raise RuntimeError("pool wallet password wrong (or wallet damaged)") finally: for b in (key, pw): for i in range(len(b)): b[i] = 0 jwk = json.loads(pt.decode()) for i in range(len(pt)): pt[i] = 0 t["txid"] = txid_of(t) k = ec.derive_private_key(int.from_bytes(_b64u_dec(jwk["d"]), "big"), ec.SECP256R1()); jwk.clear() r, s = decode_dss_signature(k.sign(("EDDY1-TX-SIG|v1|" + t["txid"]).encode(), ec.ECDSA(hashes.SHA256()))) del k t["sig"] = r.to_bytes(32, "big").hex() + s.to_bytes(32, "big").hex() return t def keygen(path, pw_file, name="Eddycoin pool wallet"): """create an encrypted wallet v2 (opens in Eddycoin Wallet too) + a random password file, both chmod 600""" from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC if os.path.exists(path): raise SystemExit("%s exists - refusing to overwrite a wallet" % path) old = os.umask(0o077) try: if not os.path.exists(pw_file): with open(pw_file, "w") as f: f.write(base64.urlsafe_b64encode(os.urandom(24)).decode() + "\n") os.chmod(pw_file, 0o600) pw = open(pw_file).read().strip().encode() k = ec.generate_private_key(ec.SECP256R1()); n = k.private_numbers() x, y, d = n.public_numbers.x.to_bytes(32, "big"), n.public_numbers.y.to_bytes(32, "big"), n.private_value.to_bytes(32, "big") pub = "04" + x.hex() + y.hex(); a = address_of(pub) salt, nonce = os.urandom(16), os.urandom(12) key = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=600000).derive(pw) pt = json.dumps({"crv": "P-256", "d": _b64u_enc(d), "kty": "EC", "x": _b64u_enc(x), "y": _b64u_enc(y)}, separators=(",", ":")).encode() e = lambda b: base64.b64encode(b).decode() w = {"format": "eddycoin-wallet", "version": 2, "network": NETWORK, "name": name, "address": a, "publicKey": pub, "pubkey": pub, "created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "encryptedPrivateKey": {"kdf": "PBKDF2-SHA256", "iterations": 600000, "salt": e(salt), "cipher": "AES-256-GCM", "nonce": e(nonce), "ciphertext": e(AESGCM(key).encrypt(nonce, pt, a.encode())), "aad": "address", "content": "jwk"}, "warning": "Pool wallet. Keep this file + the password file private and backed up."} with open(path, "w", encoding="utf-8") as f: json.dump(w, f, indent=1) os.chmod(path, 0o600) finally: os.umask(old) return a # ------------------------------------------------------------------------------------------------ ledger (sqlite) SCHEMA = """ PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT); CREATE TABLE IF NOT EXISTS shares(id INTEGER PRIMARY KEY AUTOINCREMENT, chain TEXT, addr TEXT, bits INTEGER, weight INTEGER, ts INTEGER, hash TEXT UNIQUE, ip TEXT); CREATE INDEX IF NOT EXISTS shares_chain ON shares(chain, id); CREATE TABLE IF NOT EXISTS blocks(hash TEXT PRIMARY KEY, chain TEXT, height INTEGER, y_candidate TEXT, finder TEXT, ts INTEGER, status TEXT, reason TEXT, weights TEXT, cp_height INTEGER, cp_id TEXT, reward INTEGER, split TEXT); CREATE TABLE IF NOT EXISTS entries(id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER, addr TEXT, amount INTEGER, kind TEXT, ref TEXT, UNIQUE(kind, ref, addr)); CREATE INDEX IF NOT EXISTS entries_addr ON entries(addr); CREATE TABLE IF NOT EXISTS payouts(id INTEGER PRIMARY KEY AUTOINCREMENT, created INTEGER, status TEXT, txid TEXT UNIQUE, tx TEXT, recipients TEXT, total INTEGER, fee INTEGER, last_try INTEGER, tries INTEGER DEFAULT 0, cp_height INTEGER, note TEXT); """ class Ledger: def __init__(self, path): self.path = path; self.lock = threading.RLock() self.db = sqlite3.connect(path, check_same_thread=False, isolation_level=None, timeout=30) self.db.executescript(SCHEMA); self.db.execute("PRAGMA synchronous=FULL") def q(self, sql, args=()): with self.lock: return self.db.execute(sql, args).fetchall() def tx(self): return _Tx(self) def meta(self, k, default=None): r = self.q("SELECT v FROM meta WHERE k=?", (k,)) return json.loads(r[0][0]) if r else default def set_meta(self, k, v, cur=None): (cur or self.db).execute("INSERT INTO meta(k,v) VALUES(?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v", (k, json.dumps(v))) def balance(self, addr): return self.q("SELECT COALESCE(SUM(amount),0) FROM entries WHERE addr=?", (addr,))[0][0] def balances(self): return dict(self.q("SELECT addr, SUM(amount) FROM entries GROUP BY addr")) def backup(self, folder, keep): os.makedirs(folder, exist_ok=True); os.chmod(folder, 0o700) dst = os.path.join(folder, "pool-%s.db" % time.strftime("%Y%m%d-%H%M%S")) with self.lock: b = sqlite3.connect(dst); self.db.backup(b); b.close() os.chmod(dst, 0o600) old = sorted(f for f in os.listdir(folder) if f.startswith("pool-") and f.endswith(".db")) for f in old[:-keep]: os.remove(os.path.join(folder, f)) return dst class _Tx: def __init__(self, L): self.L = L def __enter__(self): self.L.lock.acquire(); self.L.db.execute("BEGIN IMMEDIATE"); return self.L.db def __exit__(self, et, ev, tb): try: self.L.db.execute("ROLLBACK" if et else "COMMIT") finally: self.L.lock.release() return False # ------------------------------------------------------------------------------------------------ pool class Pool: def __init__(self, cfg, wallet): self.cfg = cfg; self.wallet = wallet; self.addr = wallet.address os.makedirs(cfg["data"], exist_ok=True); os.chmod(cfg["data"], 0o700) self.L = Ledger(os.path.join(cfg["data"], "pool.db")) self.node = cfg["node"].rstrip("/") self.fee_bps = int(cfg["fee_bps"]) if not 0 <= self.fee_bps <= 500: raise SystemExit("fee_bps must be 0..500 (0-5 %)") if int(cfg["min_payout"]) <= int(cfg["tx_fee"]) * 2: raise SystemExit("min_payout must be larger than 2x tx_fee") self.wlock = threading.Lock(); self.work = {}; self.jobs = {"E": [], "D": []} self.vd = {}; self.rl = {}; self.wcache = {}; self.state_cache = (0, None); self.stats = {"shares": 0, "rejected": 0, "blocks": 0, "solo_relayed": 0} self.log_f = open(os.path.join(cfg["data"], "pool.log"), "a", buffering=1) self.stop = threading.Event() def log(self, msg): line = time.strftime("%Y-%m-%d %H:%M:%S ") + msg print(line, flush=True); self.log_f.write(line + "\n") # ---------- work ---------- def node_work(self, ch, max_age=1.5): with self.wlock: c = self.work.get(ch) if c and time.time() - c[0] < max_age: return dict(c[1]) st, w = http_json("%s/api/work?chain=%s" % (self.node, ch), timeout=10) if st != 200 or "y_candidate" not in w: raise IOError(w.get("error") or w.get("reason") or "node HTTP %d" % st) with self.wlock: self.work[ch] = (time.time(), dict(w)) key = (w["height"], w["prev"], w["y_candidate"], w["bits"]) J = self.jobs[ch] # last 8 distinct templates; "last seen" is refreshed while a template stays current J[:] = [j for j in J if j[0] != key] + [(key, time.time())] del J[:-8] return dict(w) def uniq_time(self): with self.wlock: t = max(now_ms(), getattr(self, "_last_t", 0) + 1); self._last_t = t return t def job_ok(self, ch, b): key = (b.get("height"), b.get("prev"), b.get("y_candidate"), b.get("bits")) with self.wlock: for k, t in self.jobs[ch]: if k == key: return time.time() - t < 300, k == self.jobs[ch][-1][0] return False, False def vardiff(self, addr, ch, net_bits, waiting=False): k = (addr, ch); t = time.time(); c = self.cfg v = self.vd.get(k) lo = int(c["min_share_bits"][ch]) if not v: v = self.vd[k] = {"bits": int(c["start_share_bits"][ch]), "prev": None, "prev_until": 0, "since": t, "w": 0, "n": 0, "last": t} T = float(c["share_target_s"]) if waiting: # lane waits for the other chain: nobody mines, so do not lower the difficulty meanwhile v["last"] = t; v["since"] = t; v["w"] = 0; v["n"] = 0 return max(lo, min(v["bits"], net_bits)) # retarget: after >= 6 shares and 30 s (or >= 12 shares at any time = far too easy), or when no share for 4x target el = max(t - v["since"], 1.0) new = None if (v["n"] >= 6 and el >= 30) or v["n"] >= 12: rate = v["w"] / el # hashes / s new = int(round(math.log2(max(rate * T, 1)))) elif t - v["last"] > 4 * T and el > 4 * T: new = v["bits"] - 1 if new is not None: new = max(v["bits"] - 2, min(v["bits"] + 2, new)) new = max(lo, min(new, net_bits)) if new != v["bits"]: v["prev"], v["prev_until"] = v["bits"], t + 90 v["bits"] = new v.update(since=t, w=0, n=0, last=t) return max(lo, min(v["bits"], net_bits)) def api_work(self, ch, payout): w = self.node_work(ch) w["server_time"] = self.uniq_time() # unique per response -> every miner hashes a different header (no duplicate work) sb = self.vardiff(payout, ch, w["bits"], bool(w.get("waiting"))) if is_addr(payout) else max(int(self.cfg["start_share_bits"][ch]), int(self.cfg["min_share_bits"][ch])) sb = min(sb, w["bits"]) w["pool"] = {"address": self.addr, "share_bits": sb, "fee_bps": self.fee_bps, "scheme": "PPLNS", "version": POOL_VERSION, "min_payout": str(self.cfg["min_payout"]), "note": "pool mode: put pool.address in the header, submit hashes with >= share_bits leading zero bits, add your wallet as 'payout'"} w["via_pool"] = POOL_VERSION return 200, w # ---------- shares ---------- def limit(self, key, mx): t = time.time(); e = self.rl.get(key) if not e or t - e[0] > 60: self.rl[key] = [t, 1] if len(self.rl) > 20000: self.rl = {k: v for k, v in self.rl.items() if t - v[0] < 60} return True e[1] += 1 return e[1] <= mx def api_submit(self, ch, b, ip): if not isinstance(b, dict) or b.get("chain") != ch: return 400, {"ok": False, "reason": "this endpoint accepts %s-Chain blocks only" % ch} if "payout" not in b: # old miner -> solo, relayed 1:1 to the node (reward to b.miner as always) self.stats["solo_relayed"] += 1 return http_json("%s/api/submit/%s" % (self.node, ch.lower()), b, timeout=25) payout = b.pop("payout") for k in b: if k not in BLOCK_KEYS: return 400, {"ok": False, "reason": "unknown field " + k} rej = lambda why: (self.stats.__setitem__("rejected", self.stats["rejected"] + 1), (400, {"ok": False, "share": False, "reason": why}))[1] if not is_addr(payout): return rej("payout must be your wallet address (eddy1 + 40 hex)") if not self.limit("a|" + payout, int(self.cfg["max_shares_per_min_addr"])) or not self.limit("i|" + ip, int(self.cfg["max_shares_per_min_ip"])): v = self.vd.get((payout, ch)) if v: # far too many shares: raise this miner's share difficulty right away v["bits"] = min(v["bits"] + 1, 40); v.update(since=time.time(), w=0, n=0) return 429, {"ok": False, "share": False, "reason": "rate limit: too many shares per minute (difficulty will rise)"} if b.get("miner") != self.addr: return rej("pool share must have the pool address %s as miner" % self.addr) if b.get("alg") != "sha256d" or not all(is_int(b.get(k)) for k in ("height", "nonce", "bits", "time")) or not HEX64.match(str(b.get("hash", ""))) \ or not HEX64.match(str(b.get("y_candidate", ""))) or not HEX64.match(str(b.get("prev", ""))) or b["nonce"] < 0: return rej("malformed share") if abs(now_ms() - b["time"]) > int(self.cfg["max_skew_ms"]): return rej("timestamp outside +-120 s") known, current = self.job_ok(ch, b) if not known: return rej("stale or unknown job (fetch new work)") if block_hash(b) != b["hash"]: return rej("hash mismatch") z = lzb(b["hash"]) v = self.vd.get((payout, ch)); lo = int(self.cfg["min_share_bits"][ch]) need = v["bits"] if v else max(lo, int(self.cfg["start_share_bits"][ch])) if v and v["prev"] is not None and time.time() < v["prev_until"]: need = min(need, v["prev"]) need = max(lo, min(need, b["bits"])) if z < need: return rej("share below target (%d < %d bits)" % (z, need)) weight = 1 << need try: with self.L.tx() as db: db.execute("INSERT INTO shares(chain,addr,bits,weight,ts,hash,ip) VALUES(?,?,?,?,?,?,?)", (ch, payout, need, weight, now_ms(), b["hash"], ip[:45])) except sqlite3.IntegrityError: return rej("duplicate share") if v: v["w"] += weight; v["n"] += 1; v["last"] = time.time() self.stats["shares"] += 1 res = {"ok": True, "share": True, "block": False, "share_bits": need} if z >= b["bits"]: # meets the network target: a real block - pass it on only if it still extends the node's current work res["block"] = True try: w = self.node_work(ch, max_age=0.3) except Exception: w = {} if w.get("y_candidate") == b["y_candidate"] and w.get("prev") == b["prev"] and not w.get("waiting"): res["block_result"] = self.found_block(ch, b, payout) else: res["block_result"] = {"ok": False, "reason": "stale block (the chain moved on) - counted as a share, not sent"} return 200, res def pplns(self, ch, net_bits, upto_id): window = int(float(self.cfg["pplns_factor"]) * (1 << int(net_bits))) weights, acc = {}, 0 for addr, w in self.L.q("SELECT addr, weight FROM shares WHERE chain=? AND id<=? ORDER BY id DESC LIMIT 200000", (ch, upto_id)): take = min(w, window - acc) weights[addr] = weights.get(addr, 0) + take; acc += take if acc >= window: break return weights def found_block(self, ch, b, finder): sid = self.L.q("SELECT id FROM shares WHERE hash=?", (b["hash"],))[0][0] weights = self.pplns(ch, b["bits"], sid) # PPLNS snapshot incl. this share, taken BEFORE the node sees the block with self.L.tx() as db: db.execute("INSERT OR IGNORE INTO blocks(hash,chain,height,y_candidate,finder,ts,status,weights) VALUES(?,?,?,?,?,?,?,?)", (b["hash"], ch, b["height"], b["y_candidate"], finder, now_ms(), "submitting", json.dumps(weights))) try: st, r = http_json("%s/api/submit/%s" % (self.node, ch.lower()), {k: b[k] for k in BLOCK_KEYS}, timeout=25) except Exception as ex: st, r = 0, {"ok": False, "reason": "node unreachable: %s" % str(ex)[:80]} ok = bool(r.get("ok")) with self.L.tx() as db: # the scanner may already have moved it to in_checkpoint db.execute("UPDATE blocks SET status=?, reason=? WHERE hash=? AND status='submitting'", ("accepted" if ok else "rejected", None if ok else str(r.get("reason") or r.get("error"))[:200], b["hash"])) self.log("%s BLOCK %s #%d %s by %s: %s" % ("FOUND" if ok else "rejected", ch, b["height"], b["hash"][:16], finder[:14], "accepted by node" if ok else r.get("reason") or r.get("error"))) if ok: self.stats["blocks"] += 1 with self.wlock: self.work.clear() return {"ok": ok, "reason": r.get("reason") or r.get("error"), "checkpoint": r.get("checkpoint")} # ---------- checkpoint scanner: confirm blocks, credit matured rewards, confirm payouts ---------- def scan(self): st, info = http_json(self.node + "/api/node/info", timeout=10) if st != 200: return tip = info["tipY"]; conf = int(self.cfg["confirmations"]) nxt = self.L.meta("scan_next") if nxt is None: nxt = self.cfg["start_height"] if self.cfg["start_height"] is not None else max(1, tip - 50) h = nxt while h <= tip and not self.stop.is_set(): open_blocks = {x[0] for x in self.L.q("SELECT hash FROM blocks WHERE status IN ('submitting','accepted','rejected')")} open_tx = {t: i for i, t in self.L.q("SELECT id, txid FROM payouts WHERE status IN ('signed','sent')")} st, cp = http_json("%s/api/block/Y/%d" % (self.node, h), timeout=15) if st != 200: break with self.L.tx() as db: for ev, ch in ((cp["e_evidence"], "E"), (cp["d_evidence"], "D")): if ev in open_blocks: to = cp["e_to"] if ch == "E" else cp["d_to"] e, d = reward_split(cp["subsidy"], cp["fees"]) amount = (e if ch == "E" else d) if to == self.addr else 0 db.execute("UPDATE blocks SET status='in_checkpoint', cp_height=?, cp_id=?, reward=? WHERE hash=?", (cp["height"], cp["id"], amount, ev)) self.log("pool block %s %s is in checkpoint #%d, reward %s EDDY (credited after %d confirmations)" % (ch, ev[:16], cp["height"], fmt(amount), conf)) for t in cp["txs"]: if t["txid"] in open_tx: db.execute("UPDATE payouts SET status='confirmed', cp_height=? WHERE txid=?", (cp["height"], t["txid"])) self.log("payout #%d %s confirmed in checkpoint #%d" % (open_tx[t["txid"]], t["txid"][:16], cp["height"])) self.L.set_meta("scan_next", h + 1, db) h += 1 self.mature(tip, conf) def mature(self, tip, conf): for bh, ch, cph, cpid, reward, weights in self.L.q("SELECT hash, chain, cp_height, cp_id, reward, weights FROM blocks WHERE status='in_checkpoint' AND cp_height<=?", (tip - conf,)): st, cp = http_json("%s/api/block/Y/%d" % (self.node, cph), timeout=15) if st != 200 or not isinstance(cp, dict) or "id" not in cp: continue if cp["id"] != cpid or bh not in (cp["e_evidence"], cp["d_evidence"]): with self.L.tx() as db: db.execute("UPDATE blocks SET status='orphaned', reason='checkpoint replaced (reorg)' WHERE hash=?", (bh,)) self.log("pool block %s orphaned: checkpoint #%d was replaced" % (bh[:16], cph)); continue split = split_reward(reward, json.loads(weights or "{}"), self.fee_bps) if reward else {} with self.L.tx() as db: for a, v in split.items(): db.execute("INSERT OR IGNORE INTO entries(ts,addr,amount,kind,ref) VALUES(?,?,?,?,?)", (now_ms(), a, v, "fee" if a == FEE_ACCT else "credit", bh)) db.execute("UPDATE blocks SET status='credited', split=? WHERE hash=?", (json.dumps({a: str(v) for a, v in split.items()}), bh)) self.log("credited %s EDDY from %s block %s (cp #%d): %d miners, fee %s" % (fmt(reward), ch, bh[:16], cph, len(split) - 1, fmt(split.get(FEE_ACCT, 0)))) # ---------- payouts ---------- def fee_target(self): a = self.cfg.get("fee_address") return a if is_addr(a) and a != self.addr else None def rebroadcast(self): for pid, txid, tx, status, last in self.L.q("SELECT id, txid, tx, status, last_try FROM payouts WHERE status IN ('signed','sent')"): if last and time.time() * 1000 - last < 60000 and status == "sent": continue st, r = http_json(self.node + "/api/tx", {"tx": json.loads(tx)}, timeout=20) with self.L.tx() as db: if r.get("ok"): db.execute("UPDATE payouts SET status='sent', last_try=?, tries=tries+1 WHERE id=?", (now_ms(), pid)) else: db.execute("UPDATE payouts SET last_try=?, tries=tries+1, note=? WHERE id=?", (now_ms(), str(r.get("error"))[:200], pid)) if not r.get("ok"): self.log("payout #%d %s broadcast: %s (kept; same tx retried, never re-created)" % (pid, txid[:16], r.get("error"))) def payout_round(self): if self.L.q("SELECT COUNT(*) FROM payouts WHERE status='signed'")[0][0]: return 0 # an earlier tx was not accepted by the node yet: retry that one first minp, fee = int(self.cfg["min_payout"]), int(self.cfg["tx_fee"]) if fee < MIN_TX_FEE: fee = MIN_TX_FEE bal = self.L.balances(); due = [] for a, v in bal.items(): to = self.fee_target() if a == FEE_ACCT else a if to and v >= minp: due.append((a, to, v)) if not due: return 0 st, wv = http_json("%s/api/wallet?addr=%s" % (self.node, self.addr), timeout=15) if st != 200: return 0 reserved = set() for (tx,) in self.L.q("SELECT tx FROM payouts WHERE status IN ('signed','sent')"): for i in json.loads(tx)["inputs"]: reserved.add("%s:%s" % (i["txid"], i["vout"])) utxos = sorted([u for u in wv.get("utxos", []) if u["key"] not in reserved], key=lambda u: -int(u["amount"])) due.sort(key=lambda x: -x[2]); made = 0 for k in range(0, min(len(due), 3 * int(self.cfg["max_txs_per_round"])), 3): grp = due[k:k + 3]; n = len(grp) shares = [fee // n + (1 if i < fee % n else 0) for i in range(n)] outs = [{"address": to, "amount": str(v - s)} for (a, to, v), s in zip(grp, shares)] need = sum(v for _, _, v in grp) # = sent amounts + fee ins, tot = [], 0 while utxos and tot < need and len(ins) < 50: u = utxos.pop(0); txid, vout = u["key"].split(":"); ins.append({"txid": txid, "vout": int(vout)}); tot += int(u["amount"]) if tot < need: self.log("payout skipped: pool wallet spendable %s < needed %s (waiting for finalized rewards)" % (fmt(tot), fmt(need))); break if tot > need: outs.append({"address": self.addr, "amount": str(tot - need)}) t = self.wallet.sign({"v": 2, "type": "transfer", "inputs": ins, "outputs": outs, "fee": str(fee), "time": now_ms(), "memo": "Eddycoin pool payout", "pubkey": self.wallet.pub}) with self.L.tx() as db: # re-check balances inside the DB transaction (idempotent; never debit below zero) for a, to, v in grp: if db.execute("SELECT COALESCE(SUM(amount),0) FROM entries WHERE addr=?", (a,)).fetchone()[0] < v: raise RuntimeError("balance changed during payout") cur = db.execute("INSERT INTO payouts(created,status,txid,tx,recipients,total,fee,last_try,tries) VALUES(?,?,?,?,?,?,?,0,0)", (now_ms(), "signed", t["txid"], json.dumps(t), json.dumps([[a, to, str(v)] for a, to, v in grp]), need, fee)) pid = cur.lastrowid for (a, to, v), s in zip(grp, shares): db.execute("INSERT INTO entries(ts,addr,amount,kind,ref) VALUES(?,?,?,?,?)", (now_ms(), a, -(v - s), "payout", str(pid))) db.execute("INSERT INTO entries(ts,addr,amount,kind,ref) VALUES(?,?,?,?,?)", (now_ms(), a, -s, "txfee", str(pid))) if os.environ.get("EDDY_POOL_TEST_CRASH_AFTER_SIGN") == "1": # tests only: simulate a crash between "stored" and "broadcast" self.log("TEST: crashing after storing signed payout #%d %s (not broadcast)" % (pid, t["txid"][:16])); os._exit(9) self.log("payout #%d signed %s: %s" % (pid, t["txid"][:16], ", ".join("%s %s EDDY" % (to[:14], fmt(int(o["amount"]))) for (a, to, v), o in zip(grp, outs)))) if tot > need: ch = len(outs) - 1; utxos.insert(0, {"key": "%s:%d" % (t["txid"], ch), "amount": str(tot - need)}) made += 1 if made: self.rebroadcast(); self.L.backup(os.path.join(self.cfg["data"], "backups"), int(self.cfg["keep_backups"])) return made # ---------- stats ---------- def stats_all(self): t = now_ms(); win = 600000 per = self.L.q("SELECT chain, addr, COUNT(*), SUM(weight) FROM shares WHERE ts>? GROUP BY chain, addr", (t - win,)) lane = {"E": {"hashrate": 0, "miners": 0, "shares_10min": 0}, "D": {"hashrate": 0, "miners": 0, "shares_10min": 0}} for ch, a, n, w in per: lane[ch]["hashrate"] += int(w / (win / 1000)); lane[ch]["miners"] += 1; lane[ch]["shares_10min"] += n blocks = [{"hash": h, "chain": c, "height": he, "status": s, "cp": cp, "reward": str(r or 0), "time": ts} for h, c, he, s, cp, r, ts in self.L.q("SELECT hash, chain, height, status, cp_height, reward, ts FROM blocks ORDER BY ts DESC LIMIT 25")] pays = [{"id": i, "txid": tx, "status": s, "total": str(tot), "recipients": len(json.loads(rc)), "time": c} for i, tx, s, tot, rc, c in self.L.q("SELECT id, txid, status, total, recipients, created FROM payouts ORDER BY id DESC LIMIT 25")] return {"pool": POOL_VERSION, "network": NETWORK, "address": self.addr, "fee_bps": self.fee_bps, "fee_percent": self.fee_bps / 100, "fee_address": self.fee_target(), "scheme": "PPLNS (per lane)", "pplns_factor": self.cfg["pplns_factor"], "confirmations": self.cfg["confirmations"], "min_payout": str(self.cfg["min_payout"]), "payout_interval_s": self.cfg["payout_interval_s"], "tx_fee": str(self.cfg["tx_fee"]), "lanes": lane, "blocks": blocks, "payouts": pays, "counters": dict(self.stats), "time": t} def stats_miner(self, a): t = now_ms(); win = 600000 rows = self.L.q("SELECT chain, COUNT(*), SUM(weight) FROM shares WHERE addr=? AND ts>? GROUP BY chain", (a, t - win)) hr = {ch: int(w / (win / 1000)) for ch, n, w in rows}; sh = {ch: n for ch, n, w in rows} bal = self.L.balance(a) paid = -self.L.q("SELECT COALESCE(SUM(amount),0) FROM entries WHERE addr=? AND kind IN ('payout','txfee')", (a,))[0][0] immature = 0 # estimate from blocks waiting for confirmations for reward, weights in self.L.q("SELECT reward, weights FROM blocks WHERE status='in_checkpoint'"): immature += split_reward(reward or 0, json.loads(weights or "{}"), self.fee_bps).get(a, 0) unconfirmed = 0 for (rc,) in self.L.q("SELECT recipients FROM payouts WHERE status IN ('signed','sent')"): for x in json.loads(rc): if x[0] == a: unconfirmed += int(x[2]) return {"address": a, "hashrate": hr, "shares_10min": sh, "immature": str(immature), "balance": str(bal), "paid": str(paid), "payout_in_flight": str(unconfirmed), "min_payout": str(self.cfg["min_payout"]), "fee_bps": self.fee_bps} def api_state(self): c = self.state_cache if c[1] is None or time.time() - c[0] > 3: st, s = http_json(self.node + "/api/state", timeout=15) if st != 200: return st, s self.state_cache = c = (time.time(), s) s = dict(c[1]); s["pool"] = {"address": self.addr, "fee_bps": self.fee_bps, "scheme": "PPLNS", "version": POOL_VERSION, "min_payout": str(self.cfg["min_payout"])} return 200, s # ---------- loops ---------- def loop(self): last_pay = time.time() - float(self.cfg["payout_interval_s"]) + 30; last_bk = time.time() while not self.stop.is_set(): try: self.scan(); self.rebroadcast() if time.time() - last_pay >= float(self.cfg["payout_interval_s"]): last_pay = time.time(); self.payout_round() if time.time() - last_bk >= float(self.cfg["backup_every_s"]): last_bk = time.time(); self.L.backup(os.path.join(self.cfg["data"], "backups"), int(self.cfg["keep_backups"])) if int(time.time()) % 600 < 5: self.prune() except Exception as ex: self.log("loop error: %s" % str(ex)[:300]) self.stop.wait(5) def prune(self): # keep the last 3 PPLNS windows (+ 48 h) of shares per lane; older shares can never be paid again for ch in ("E", "D"): w = self.work.get(ch) if not w: continue window = 3 * int(float(self.cfg["pplns_factor"]) * (1 << int(w[1]["bits"]))); acc = 0; cut = None for sid, wt in self.L.q("SELECT id, weight FROM shares WHERE chain=? ORDER BY id DESC LIMIT 300000", (ch,)): acc += wt if acc >= window: cut = sid; break if cut: with self.L.tx() as db: db.execute("DELETE FROM shares WHERE chain=? AND id Eddycoin Pool

Eddycoin Pool

Server for the miner: %(url)s · pool fee %(fee)s %% of each pool block reward (to %(feeaddr)s) · PPLNS · min payout %(minp)s EDDY · network eddycoin-mainnet-1 (LIVE)

loading…


"""


def make_handler(pool):
    class H(BaseHTTPRequestHandler):
        protocol_version = "HTTP/1.1"; server_version = "eddycoin-pool"

        def log_message(self, *a):
            pass

        def ip(self):
            ip = self.client_address[0]
            if pool.cfg["behind_proxy"] and ip in ("127.0.0.1", "::1") and self.headers.get("X-Forwarded-For"):
                ip = self.headers["X-Forwarded-For"].split(",")[-1].strip()
            return ip

        def send(self, code, obj, ctype="application/json; charset=utf-8"):
            data = obj.encode() if isinstance(obj, str) else json.dumps(obj, separators=(",", ":")).encode()
            self.send_response(code); self.send_header("content-type", ctype); self.send_header("cache-control", "no-store")
            self.send_header("access-control-allow-origin", "*"); self.send_header("content-length", str(len(data))); self.end_headers(); self.wfile.write(data)

        def do_GET(self):
            try:
                u = urllib.parse.urlsplit(self.path); p = u.path.rstrip("/"); q = urllib.parse.parse_qs(u.query); g = lambda k: (q.get(k) or [""])[0]
                if not pool.limit("g|" + self.ip(), int(pool.cfg["max_get_per_min_ip"])):
                    return self.send(429, {"error": "rate limit"})
                if p == "/api/work":
                    ch = g("chain").upper()
                    if ch not in ("E", "D"):
                        return self.send(400, {"error": "use /api/work?chain=E or ?chain=D"})
                    return self.send(*pool.api_work(ch, g("payout")))
                if p == "/api/state":
                    return self.send(*pool.api_state())
                if p in ("/api/wallet",) or p.startswith("/api/wallet/"):
                    a = g("addr") or p[len("/api/wallet/"):]
                    if not is_addr(a):
                        return self.send(400, {"error": "addr must be eddy1 + 40 hex"})
                    c = pool.wcache.get(a)
                    if c and time.time() - c[0] < 10:   # GUIs poll every 15 s: cache so the node's per-IP limit is not hit by many miners
                        st, r = 200, dict(c[1])
                    else:
                        st, r = http_json("%s/api/wallet?addr=%s" % (pool.node, a), timeout=15)
                        if st == 200:
                            if len(pool.wcache) > 5000:
                                pool.wcache.clear()
                            pool.wcache[a] = (time.time(), dict(r))
                    if st == 200:  # miner GUI 'Pending reward' = what the pool still owes you
                        m = pool.stats_miner(a); r["pool"] = m
                        r["pendingReward"] = str(int(m["immature"]) + int(m["balance"]) + int(m["payout_in_flight"]))
                    return self.send(st, r)
                if p in ("", "/pool"):
                    return self.send(200, STATS_HTML % {"url": html.escape(pool.cfg.get("public_url") or "http://%s:%d" % (pool.cfg["listen"], pool.cfg["port"])),
                                                         "fee": "%g" % (pool.fee_bps / 100), "feeaddr": pool.fee_target() or "not set", "minp": fmt(pool.cfg["min_payout"])}, "text/html; charset=utf-8")
                if p in ("/pool/api/stats", "/api/pool/stats"):
                    return self.send(200, pool.stats_all())
                if p in ("/pool/api/miner", "/api/pool/miner"):
                    a = g("addr")
                    return self.send(200, pool.stats_miner(a)) if is_addr(a) else self.send(400, {"error": "addr must be eddy1 + 40 hex"})
                if p.startswith("/api/") and p.split("/")[2] in ("block", "verify", "node", "peers"):  # read-only pass-through
                    st, r = http_json(pool.node + self.path, timeout=60)
                    return self.send(st, r)
                return self.send(404, {"error": "not found", "endpoints": ["GET /api/work?chain=E|D[&payout=]", "POST /api/submit/e|d", "GET /pool", "GET /pool/api/stats", "GET /pool/api/miner?addr="]})
            except Exception as ex:
                try:
                    self.send(502, {"error": "pool: %s" % str(ex)[:200]})
                except Exception:
                    pass

        def do_POST(self):
            try:
                p = urllib.parse.urlsplit(self.path).path.rstrip("/"); ip = self.ip()
                n = int(self.headers.get("content-length") or 0)
                if n > 4096:
                    return self.send(413, {"ok": False, "reason": "body too large"})
                b = json.loads(self.rfile.read(n).decode() or "null")
                if p in ("/api/submit/e", "/api/submit/d"):
                    return self.send(*pool.api_submit(p[-1].upper(), b, ip))
                if p == "/api/tx":  # wallets pointed at the pool still work
                    return self.send(*http_json(pool.node + "/api/tx", b, timeout=25))
                return self.send(404, {"error": "not found"})
            except Exception as ex:
                try:
                    self.send(400, {"ok": False, "reason": "bad request: %s" % str(ex)[:120]})
                except Exception:
                    pass
    return H


def load_cfg(path):
    cfg = json.loads(json.dumps(DEFAULTS))
    if path:
        with open(path, encoding="utf-8") as f:
            user = json.load(f)
        for k, v in user.items():
            if k not in DEFAULTS:
                raise SystemExit("unknown config key %s" % k)
            cfg[k] = v
    return cfg


def main():
    ap = argparse.ArgumentParser(description="Eddycoin mining pool (separate service next to a node)")
    ap.add_argument("--config", help="JSON config (see pool.example.json)")
    ap.add_argument("--keygen", nargs=2, metavar=("WALLET_FILE", "PASSWORD_FILE"), help="create the encrypted pool wallet + random password file (600) and exit")
    a = ap.parse_args()
    if a.keygen:
        print("pool wallet address: %s" % keygen(*a.keygen)); return
    cfg = load_cfg(a.config)
    pw = PoolWallet(cfg["wallet_file"], cfg["wallet_password_file"])
    # mainnet startup checks: node must be eddycoin-mainnet-1 with our genesis; pool wallet + fee address must not be old (denylisted) keys
    dl = json.load(open(cfg["denylist"])) if cfg.get("denylist") else None
    if dl is None:
        raise SystemExit("REFUSING TO START: config 'denylist' (old_key_denylist.json) is required on mainnet")
    bad = set(dl.get("addresses", [])) | set(dl.get("superseded_v2_addresses", []))
    if pw.address in bad or (cfg.get("fee_address") and cfg["fee_address"] in bad):
        raise SystemExit("REFUSING TO START: pool wallet or fee address is an OLD denylisted key")
    for i in range(60):
        try:
            with urllib.request.urlopen(cfg["node"].rstrip("/") + "/api/node/info", timeout=10) as f:
                ni = json.loads(f.read())
            break
        except Exception:
            time.sleep(5)
    else:
        raise SystemExit("REFUSING TO START: node %s unreachable" % cfg["node"])
    if ni.get("network") != NETWORK or ni.get("genesis") != GENESIS_ID:
        raise SystemExit("REFUSING TO START: node is not on %s / genesis %s" % (NETWORK, GENESIS_ID[:12]))
    pool = Pool(cfg, pw)
    pool.log("%s start: node %s, pool address %s, fee %.2f %% -> %s, PPLNS x%s, %d confirmations, min payout %s EDDY" % (
        POOL_VERSION, pool.node, pool.addr, pool.fee_bps / 100, "fee address set" if pool.fee_target() else "NOT SET (fee stays in pool wallet account POOLFEE)",
        cfg["pplns_factor"], cfg["confirmations"], fmt(cfg["min_payout"])))
    threading.Thread(target=pool.loop, daemon=True).start()
    srv = ThreadingHTTPServer((cfg["listen"], int(cfg["port"])), make_handler(pool)); srv.daemon_threads = True
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        pass
    pool.stop.set()


if __name__ == "__main__":
    main()