#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Eddycoin Wallet - desktop wallet for the Eddycoin Testnet (sandbox). TEST COIN ONLY, no real money. ===================================================================================================== * Keys: ECDSA P-256. The private key is encrypted with PBKDF2-SHA256 (600,000 iterations) + AES-256-GCM (file format v2, the same as the /live web page, so wallet files move between the page and the app). * Viewing the balance needs no password. Sending, exporting the key and changing the password ask for it. The password is never stored, logged or sent anywhere. * Opening a wallet without a password asks you to create one right away (a backup '.unencrypted-backup.json' is written first - delete it yourself once you have a safe copy). * Network: only HTTPS calls to --server (default https://eddycoin-demo.pages.dev): balance lookups and the signed transaction. The private key never leaves this computer. Setup (Windows): Python 3.12+ from python.org ("Add python.exe to PATH"), then py -m pip install cryptography Run: py eddycoin_wallet.py (GUI; looks for wallet files next to this program) CLI: py eddycoin_wallet.py --cli info "wallet 2.json" | create | set-password | change-password | send | export-key """ import argparse, base64, getpass, glob, hashlib, json, os, re, shutil, ssl, sys, time, urllib.error, urllib.request from datetime import datetime, timezone NETWORK = "eddycoin-testnet-sandbox" DEFAULT_SERVER = "https://eddycoin-demo.pages.dev" ITER, MIN_PW = 600000, 8 DEC, COIN = 8, 100000000 # 1 EDDY = 100,000,000 minor units MIN_FEE = 1000000 # 0.01 EDDY MONO = ["Consolas"] ADDR_RE = re.compile(r"^eddy1[0-9a-f]{40}$") INSTALL_HELP = "The 'cryptography' package is missing. Install it with: py -m pip install cryptography" def need_crypto(): try: from cryptography.hazmat.primitives.asymmetric import ec # noqa return True except Exception: return False def canon(o): return json.dumps(o, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def sha(s): return hashlib.sha256(s.encode("utf-8")).hexdigest() 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(pubhex): return "eddy1" + sha("EDDY-ADDR|v1|" + pubhex)[:40] def fmt(minor, dec=DEC): m = int(minor) # accepts int or decimal string (server amounts are decimal strings since tx v2) q, r = divmod(abs(m), 10 ** dec) return ("-" if m < 0 else "") + "{:,}".format(q) + "." + str(r).rjust(dec, "0") def parse_amount(s): s = str(s).strip().replace(",", ".").replace(" ", "") if not re.match(r"^\d+(\.\d{1,8})?$", s): raise ValueError("enter an amount like 12.34 (max 8 decimals)") i, _, d = s.partition(".") v = int(i) * COIN + int((d or "0").ljust(DEC, "0")) if v > 900000000 * COIN: raise ValueError("amount above the 900,000,000 EDDY cap") if v <= 0: raise ValueError("amount must be greater than 0") return v def strength(pw): s = sum([len(pw) >= 8, len(pw) >= 12, len(pw) >= 16, bool(re.search("[a-z]", pw) and re.search("[A-Z]", pw)), bool(re.search(r"\d", pw)), bool(re.search(r"[^A-Za-z0-9]", pw))]) if len(pw) < MIN_PW: return "too short (min 8 characters)" return "weak" if s <= 2 else "medium" if s <= 4 else "strong" # ---------------- wallet file (v1 clear / v2 encrypted) ---------------- def load_wallet(path): with open(path, encoding="utf-8") as f: w = json.load(f) if w.get("format") != "eddycoin-wallet" or not ADDR_RE.match(w.get("address", "")): raise ValueError("not an Eddycoin wallet file") pub = w.get("pubkey") or w.get("publicKey") or "" if not re.match(r"^04[0-9a-f]{128}$", pub) or address_of(pub) != w["address"]: raise ValueError("wallet file damaged: address does not match the public key") if not (w.get("private_jwk") or w.get("encryptedPrivateKey")): raise ValueError("wallet file has no private key") w.setdefault("name", w.get("label") or os.path.splitext(os.path.basename(path))[0]) return w def is_encrypted(w): return bool(w.get("encryptedPrivateKey")) def _kdf(pw_bytes, salt, iterations): from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC return bytearray(PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iterations).derive(bytes(pw_bytes))) def _wipe(b): if isinstance(b, bytearray): for i in range(len(b)): b[i] = 0 def encrypt_jwk(jwk, password, address): from cryptography.hazmat.primitives.ciphers.aead import AESGCM if len(password) < MIN_PW: raise ValueError("password must have at least 8 characters") salt, nonce = os.urandom(16), os.urandom(12) pwb = bytearray(password.encode("utf-8")) key = _kdf(pwb, salt, ITER) pt = bytearray(json.dumps({"crv": "P-256", "d": jwk["d"], "kty": "EC", "x": jwk["x"], "y": jwk["y"]}, separators=(",", ":")).encode()) ct = AESGCM(bytes(key)).encrypt(nonce, bytes(pt), address.encode()) _wipe(key); _wipe(pwb); _wipe(pt) e = lambda b: base64.b64encode(b).decode() return {"kdf": "PBKDF2-SHA256", "iterations": ITER, "salt": e(salt), "cipher": "AES-256-GCM", "nonce": e(nonce), "ciphertext": e(ct), "aad": "address", "content": "jwk"} class WrongPassword(Exception): pass def decrypt_jwk(w, password): if w.get("private_jwk"): return dict(w["private_jwk"]) from cryptography.hazmat.primitives.ciphers.aead import AESGCM e = w["encryptedPrivateKey"] if e.get("kdf") != "PBKDF2-SHA256" or e.get("cipher") != "AES-256-GCM": raise ValueError("unsupported wallet encryption") pwb = bytearray((password or "").encode("utf-8")) key = _kdf(pwb, base64.b64decode(e["salt"]), int(e["iterations"])) try: pt = bytearray(AESGCM(bytes(key)).decrypt(base64.b64decode(e["nonce"]), base64.b64decode(e["ciphertext"]), w["address"].encode())) except Exception: raise WrongPassword("wrong password (or damaged wallet file) - nothing was sent") finally: _wipe(key); _wipe(pwb) j = json.loads(pt.decode()); _wipe(pt) return j def new_keypair(): from cryptography.hazmat.primitives.asymmetric import ec 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() return pub, {"kty": "EC", "crv": "P-256", "x": b64u_enc(x), "y": b64u_enc(y), "d": b64u_enc(d), "ext": True, "key_ops": ["sign"]} def wallet_v2(name, pub, jwk, password, created=None): a = address_of(pub) return {"format": "eddycoin-wallet", "version": 2, "network": NETWORK, "name": name[:60], "address": a, "publicKey": pub, "pubkey": pub, "created": created or datetime.now(timezone.utc).isoformat(timespec="seconds"), "encryptedPrivateKey": encrypt_jwk(jwk, password, a), "warning": "Test coin only. Keep this file private and backed up; the password cannot be recovered."} def wallet_v1(name, pub, jwk): """unencrypted wallet (the receiver must set a password on first open)""" return {"format": "eddycoin-wallet", "version": 1, "network": NETWORK, "name": name[:60], "label": name[:60], "address": address_of(pub), "pubkey": pub, "publicKey": pub, "private_jwk": dict(jwk), "created": datetime.now(timezone.utc).isoformat(timespec="seconds"), "warning": "NOT password-protected yet: Eddycoin Wallet asks you to create a password on first open. Test coin only."} def write_json_atomic(path, obj): tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(obj, f, indent=1, ensure_ascii=False) f.flush(); os.fsync(f.fileno()) os.replace(tmp, path) def backup_path(path): return os.path.join(os.path.dirname(os.path.abspath(path)), os.path.splitext(os.path.basename(path))[0] + ".unencrypted-backup.json") def set_password_in_place(path, w, password): """encrypt an unencrypted (v1) wallet file in place; writes .unencrypted-backup.json first (never deleted by the app)""" if is_encrypted(w): raise ValueError("wallet already has a password - use Change password") bp = backup_path(path) if not os.path.exists(bp): shutil.copy2(path, bp) nw = wallet_v2(w["name"], w["pubkey"], w["private_jwk"], password, w.get("created")) write_json_atomic(path, nw) return nw, bp def change_password(path, w, old, new): jwk = decrypt_jwk(w, old) nw = wallet_v2(w["name"], w.get("pubkey") or w["publicKey"], jwk, new, w.get("created")) jwk.clear() write_json_atomic(path, nw) return nw # ---------------- transactions (same format the live Y-Chain verifies) ---------------- def sign_tx(t, jwk): from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature body = {k: t[k] for k in ("v", "type", "inputs", "outputs", "fee", "time", "memo", "pubkey")} t["txid"] = sha("EDDY-TX|v1|" + canon(body)) key = ec.derive_private_key(int.from_bytes(b64u_dec(jwk["d"]), "big"), ec.SECP256R1()) r, s = decode_dss_signature(key.sign(("EDDY-TX-SIG|v1|" + t["txid"]).encode(), ec.ECDSA(hashes.SHA256()))) t["sig"] = r.to_bytes(32, "big").hex() + s.to_bytes(32, "big").hex() del key return t def build_transfer(w, me, to, amount, memo, password): if not ADDR_RE.match(to or ""): raise ValueError("recipient must be an Eddycoin address: eddy1 + 40 hex characters") if to == w["address"]: raise ValueError("recipient is your own address") if len(memo) > 80: raise ValueError("note is limited to 80 characters") fee, ins, total = MIN_FEE, [], 0 for u in sorted(me["utxos"], key=lambda u: -int(u["amount"])): if total >= amount + fee: break txid, vout = u["key"].split(":") ins.append({"txid": txid, "vout": int(vout)}); total += int(u["amount"]) if len(ins) >= 50: break if total < amount + fee: raise ValueError("insufficient EDDY (have %s, need %s incl. fee)" % (fmt(total), fmt(amount + fee))) outs = [{"address": to, "amount": str(amount)}] # v2: amounts are decimal strings (exact up to the 900M cap) if total - amount - fee > 0: outs.append({"address": w["address"], "amount": str(total - amount - fee)}) jwk = decrypt_jwk(w, password) # raises WrongPassword before anything is sent try: return sign_tx({"v": 2, "type": "transfer", "inputs": ins, "outputs": outs, "fee": str(fee), "time": int(time.time() * 1000), "memo": memo, "pubkey": w.get("pubkey") or w["publicKey"]}, jwk) finally: jwk.clear() def api(server, path, body=None, timeout=20): if not (server.startswith("https://") or server.startswith(("http://127.0.0.1", "http://localhost"))): raise ValueError("server must be https://") data = None if body is None else json.dumps(body, separators=(",", ":")).encode() req = urllib.request.Request(server.rstrip("/") + path, data=data, method="GET" if body is None else "POST", headers={"content-type": "application/json", "user-agent": "eddycoin-wallet/2 (test coin)"}) try: with urllib.request.urlopen(req, timeout=timeout, context=ssl.create_default_context()) as r: return json.loads(r.read().decode()) except urllib.error.HTTPError as e: try: return json.loads(e.read().decode()) except Exception: return {"ok": False, "error": "HTTP %d" % e.code} def tx_status(me, txid): for h in (me or {}).get("history", []): if h.get("txid") == txid: return h.get("status") return None def app_dir(): return os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)) def find_wallets(d): out = [] for p in sorted(glob.glob(os.path.join(d, "*.json"))): if p.endswith(".unencrypted-backup.json"): continue try: load_wallet(p); out.append(p) except Exception: pass return out # ---------------- CLI ---------------- def ask_pw(a, prompt, confirm=False): if a.password_stdin: line = sys.stdin.readline().rstrip("\r\n") return line p = getpass.getpass(prompt) if confirm and getpass.getpass("Repeat password: ") != p: sys.exit("passwords do not match") return p def cli(a): if not need_crypto(): sys.exit(INSTALL_HELP) c = a.cmd if c == "create": if os.path.exists(a.file): sys.exit("file exists: " + a.file) pw = ask_pw(a, "New password (min 8): ", True) if len(pw) < MIN_PW: sys.exit("password must have at least 8 characters") print("password strength: " + strength(pw)) pub, jwk = new_keypair() w = wallet_v2(a.name or os.path.splitext(os.path.basename(a.file))[0], pub, jwk, pw); jwk.clear(); pw = None write_json_atomic(a.file, w) print("created %s address %s (password-protected, v2). Make two backups; the password cannot be recovered." % (a.file, w["address"])) return w = load_wallet(a.file) if c == "info": me = api(a.server, "/api/state?addr=" + w["address"]).get("me") print("name %s\naddress %s\nprotected %s" % (w["name"], w["address"], "yes (v2, PBKDF2-SHA256 600k + AES-256-GCM)" if is_encrypted(w) else "NO - set a password: --cli set-password")) if me: print("balance %s EDDY finalized, pending reward %s, E/D blocks %d/%d" % (fmt(me["eddy"]), fmt(me["pendingReward"]), me["blocks"]["E"], me["blocks"]["D"])) for h in me["history"][:10]: print(" %-8s %-12s %10s %s" % (h["type"], h["status"], fmt(h["amount"]), h.get("txid", "")[:16])) return if c == "set-password": if is_encrypted(w): sys.exit("wallet already has a password; use change-password") pw = ask_pw(a, "Create password (min 8, cannot be recovered): ", True) if len(pw) < MIN_PW: sys.exit("password must have at least 8 characters") _, bp = set_password_in_place(a.file, w, pw); pw = None print("wallet encrypted. Backup of the unencrypted file: %s - delete it yourself once you have a safe copy." % bp) return if not is_encrypted(w): sys.exit("This wallet is not password-protected. Set a password first: --cli set-password \"%s\"" % a.file) if c == "change-password": old = ask_pw(a, "Current password: ") new = ask_pw(a, "New password (min 8): ", True) if len(new) < MIN_PW: sys.exit("password must have at least 8 characters") try: change_password(a.file, w, old, new) except WrongPassword as e: sys.exit(str(e).replace(" - nothing was sent", "")) print("password changed") return if c == "export-key": pw = ask_pw(a, "Password: ") try: jwk = decrypt_jwk(w, pw) except WrongPassword as e: sys.exit(str(e).replace(" - nothing was sent", "")) out = a.out or (os.path.splitext(a.file)[0] + ".PRIVATE-KEY-export.json") write_json_atomic(out, wallet_v1(w["name"], w["pubkey"], jwk)); jwk.clear() print("UNENCRYPTED private key written to %s - anyone with this file controls the wallet. Keep it offline." % out) return if c == "send": amount = parse_amount(a.amount) st = api(a.server, "/api/state?addr=" + w["address"]); me = st.get("me") if not me: sys.exit("server error: %s" % st.get("error")) print("send %s EDDY + fee %s = total %s EDDY to %s" % (fmt(amount), fmt(MIN_FEE), fmt(amount + MIN_FEE), a.to)) pw = ask_pw(a, "Confirm with password: ") try: t = build_transfer(w, me, a.to, amount, a.note or "", pw) except (WrongPassword, ValueError) as e: sys.exit("not sent: %s" % e) pw = None r = api(a.server, "/api/tx", {"tx": t}) if not r.get("ok"): sys.exit("rejected by the server: %s" % r.get("error")) print("sent: txid %s - status unconfirmed" % t["txid"]) last, end = "unconfirmed", time.time() + a.wait while time.time() < end: time.sleep(5) s2 = tx_status(api(a.server, "/api/state?addr=" + w["address"]).get("me"), t["txid"]) if s2 and s2 != last: print("status: %s" % ("in Y candidate" if s2 == "candidate" else s2)); last = s2 if s2 == "finalized": break return # ---------------- GUI (tkinter, included with Python for Windows) ---------------- def gui(a): import tkinter as tk from tkinter import ttk, messagebox, filedialog if not need_crypto(): r = tk.Tk(); r.withdraw(); messagebox.showerror("Eddycoin Wallet", INSTALL_HELP); return root = tk.Tk(); root.title("Eddycoin Wallet - TESTNET (no real money)"); root.geometry("760x560"); root.minsize(640, 480) st = {"path": None, "w": None, "me": None, "sent": {}} import tkinter.font as tkf MONO[0] = "Consolas" if "Consolas" in tkf.families() else "DejaVu Sans Mono" if "DejaVu Sans Mono" in tkf.families() else "Courier" style = ttk.Style() try: style.theme_use("clam") except Exception: pass BG, FG, ACC, WARN = "#0b1020", "#e6ebf5", "#10b981", "#f59e0b" root.configure(bg=BG) style.configure(".", background=BG, foreground=FG, fieldbackground="#121a30") style.configure("TButton", padding=6); style.configure("Big.TLabel", font=("Segoe UI", 26, "bold"), foreground=ACC) style.configure("Warn.TFrame", background="#2a1f05"); style.configure("Warn.TLabel", background="#2a1f05", foreground="#fde68a") style.configure("Muted.TLabel", foreground="#9aa6c0") style.configure("Treeview", background="#121a30", fieldbackground="#121a30", foreground=FG, rowheight=22) style.configure("Treeview.Heading", background="#18223d", foreground=FG) style.map("Treeview", background=[("selected", "#1f3b5c")]) top = ttk.Frame(root, padding=12); top.pack(fill="both", expand=True) tk.Label(top, text="Eddycoin Testnet (sandbox) - test coin only, no real money", bg="#3b2a05", fg="#fde68a").pack(fill="x") def pw_dialog(title, text, twice=False, allow_later=False): d = tk.Toplevel(root); d.title(title); d.configure(bg=BG); d.transient(root); d.grab_set(); d.resizable(False, False) res = {"pw": None, "later": False} ttk.Label(d, text=text, wraplength=420, justify="left").pack(padx=16, pady=(14, 6), anchor="w") e1 = ttk.Entry(d, show="*", width=40); e1.pack(padx=16, pady=3); e1.focus_set() e2 = ttk.Entry(d, show="*", width=40) hint = ttk.Label(d, text="", style="Muted.TLabel") if twice: ttk.Label(d, text="Repeat password").pack(padx=16, anchor="w"); e2.pack(padx=16, pady=3); hint.pack(padx=16, anchor="w") e1.bind("", lambda ev: hint.config(text="strength: " + strength(e1.get()))) err = tk.Label(d, text="", bg=BG, fg="#f87171", wraplength=420, justify="left"); err.pack(padx=16, anchor="w") def ok(*_): p = e1.get() if twice: if len(p) < MIN_PW: err.config(text="Password must have at least 8 characters."); return if p != e2.get(): err.config(text="Passwords do not match."); return res["pw"] = p; e1.delete(0, "end"); e2.delete(0, "end"); d.destroy() def later(): res["later"] = True; d.destroy() b = ttk.Frame(d); b.pack(padx=16, pady=12, fill="x") ttk.Button(b, text="OK", command=ok).pack(side="right") ttk.Button(b, text="Later (view only)" if allow_later else "Cancel", command=later if allow_later else d.destroy).pack(side="right", padx=6) d.bind("", ok); root.wait_window(d) return res def ensure_password(first_open=True): w = st["w"] if is_encrypted(w): return True r = pw_dialog("Create password", "Create a password for '%s'.\nMinimum 8 characters. It CANNOT be recovered - if you forget it, the coins are lost.\n" "A copy of the current unprotected file is saved as '%s' first; delete it yourself once you have a safe backup." % (w["name"], os.path.basename(backup_path(st["path"]))), twice=True, allow_later=first_open) if not r["pw"]: refresh_view(); return False try: st["w"], bp = set_password_in_place(st["path"], w, r["pw"]) except Exception as ex: messagebox.showerror("Eddycoin Wallet", "Could not encrypt the wallet: %s" % ex); return False r["pw"] = None messagebox.showinfo("Eddycoin Wallet", "Wallet is now password-protected.\n\nUnprotected backup written to:\n%s\n\nDelete it yourself once you have a safe copy. The app never deletes it." % bp) refresh_view(); return True def open_path(p): try: st["w"] = load_wallet(p); st["path"] = p; st["me"] = None except Exception as ex: messagebox.showerror("Eddycoin Wallet", "Cannot open %s:\n%s" % (p, ex)); return refresh_view(); poll(once=True) if not is_encrypted(st["w"]): root.after(200, ensure_password) def create_new(): d = tk.Toplevel(root); d.title("Create new wallet"); d.configure(bg=BG); d.transient(root); d.grab_set() ttk.Label(d, text="Wallet name").pack(padx=16, pady=(14, 2), anchor="w"); en = ttk.Entry(d, width=40); en.pack(padx=16); en.insert(0, "my wallet"); en.focus_set() ttk.Label(d, text="Password (min 8 characters - cannot be recovered)").pack(padx=16, pady=(8, 2), anchor="w"); e1 = ttk.Entry(d, show="*", width=40); e1.pack(padx=16) ttk.Label(d, text="Repeat password").pack(padx=16, pady=(8, 2), anchor="w"); e2 = ttk.Entry(d, show="*", width=40); e2.pack(padx=16) hint = ttk.Label(d, text="strength: -", style="Muted.TLabel"); hint.pack(padx=16, anchor="w") e1.bind("", lambda ev: hint.config(text="strength: " + strength(e1.get()))) err = tk.Label(d, text="", bg=BG, fg="#f87171"); err.pack(padx=16, anchor="w") def ok(*_): name, p = en.get().strip() or "my wallet", e1.get() if len(p) < MIN_PW: err.config(text="Password must have at least 8 characters."); return if p != e2.get(): err.config(text="Passwords do not match."); return fn = re.sub(r'[<>:"/\\|?*]', "_", name) + ".json"; path = os.path.join(app_dir(), fn) if os.path.exists(path): path = filedialog.asksaveasfilename(parent=d, initialdir=app_dir(), initialfile=fn, defaultextension=".json") if not path: return pub, jwk = new_keypair(); w = wallet_v2(name, pub, jwk, p); jwk.clear() e1.delete(0, "end"); e2.delete(0, "end") write_json_atomic(path, w); d.destroy(); open_path(path) messagebox.showinfo("Eddycoin Wallet", "Wallet created and saved as:\n%s\n\nMake TWO backup copies (e.g. this PC + a USB stick). Never share the file or the password." % path) b = ttk.Frame(d); b.pack(padx=16, pady=12, fill="x"); ttk.Button(b, text="Create", command=ok).pack(side="right"); ttk.Button(b, text="Cancel", command=d.destroy).pack(side="right", padx=6) d.bind("", ok) def open_file(): p = filedialog.askopenfilename(initialdir=app_dir(), filetypes=[("Eddycoin wallet", "*.json"), ("All files", "*.*")]) if p: open_path(p) # main view hdr = ttk.Frame(top); hdr.pack(fill="x", pady=(10, 4)) name_l = ttk.Label(hdr, text="", font=("Segoe UI", 14, "bold")); name_l.pack(side="left") ttk.Button(hdr, text="Open wallet file", command=open_file).pack(side="right"); ttk.Button(hdr, text="Create new wallet", command=create_new).pack(side="right", padx=6) banner = ttk.Frame(top, style="Warn.TFrame", padding=8) ttk.Label(banner, text="This wallet is not password-protected. Sending and export are blocked until you set a password.", style="Warn.TLabel").pack(side="left") ttk.Button(banner, text="Set password", command=lambda: ensure_password(False)).pack(side="right") af = ttk.Frame(top); af.pack(fill="x") addr_v = tk.StringVar(); ae = ttk.Entry(af, textvariable=addr_v, state="readonly", width=60, font=(MONO[0], 10)); ae.pack(side="left", fill="x", expand=True) def copy(): root.clipboard_clear(); root.clipboard_append(addr_v.get()); status_v.set("Address copied.") ttk.Button(af, text="Copy address", command=copy).pack(side="left", padx=6) bal = ttk.Label(top, text="-", style="Big.TLabel"); bal.pack(anchor="w", pady=(10, 0)) sub = ttk.Label(top, text="", style="Muted.TLabel"); sub.pack(anchor="w") bf = ttk.Frame(top); bf.pack(fill="x", pady=8) send_b = ttk.Button(bf, text="Send EDDY", command=lambda: send_dialog()); send_b.pack(side="left") ttk.Button(bf, text="Change password", command=lambda: change_pw()).pack(side="left", padx=6) exp_b = ttk.Button(bf, text="Export private key", command=lambda: export_key()); exp_b.pack(side="left") ttk.Button(bf, text="Refresh", command=lambda: poll(once=True)).pack(side="right") cols = ("time", "type", "amount", "status", "tx") tree = ttk.Treeview(top, columns=cols, show="headings", height=10) for c, wd in zip(cols, (110, 90, 110, 130, 220)): tree.heading(c, text=c.capitalize()); tree.column(c, width=wd, anchor="w") tree.pack(fill="both", expand=True) status_v = tk.StringVar(value="") ttk.Label(top, textvariable=status_v, style="Muted.TLabel").pack(anchor="w", pady=(6, 0)) def refresh_view(): w, me = st["w"], st["me"] if not w: return name_l.config(text="%s %s" % (w["name"], "(password-protected)" if is_encrypted(w) else "(NO PASSWORD)")) addr_v.set(w["address"]) if is_encrypted(w): banner.pack_forget(); send_b.state(["!disabled"]); exp_b.state(["!disabled"]) else: banner.pack(fill="x", pady=4, after=hdr); send_b.state(["disabled"]); exp_b.state(["disabled"]) if me: bal.config(text="%s EDDY" % fmt(me["eddy"])) wt = {"D": "waiting for D-Chain (GPU) block", "E": "waiting for E-Chain (CPU) block"}.get(me.get("waiting"), "") sub.config(text="pending reward %s EDDY %s | blocks E %d / D %d | server %s" % (fmt(me["pendingReward"]), wt, me["blocks"]["E"], me["blocks"]["D"], a.server)) tree.delete(*tree.get_children()) for h in me["history"]: s = h.get("status"); s = "in Y candidate" if s == "candidate" else s tree.insert("", "end", values=(time.strftime("%H:%M:%S", time.localtime((h.get("time") or 0) / 1000)), h["type"], ("+" if int(h["amount"]) > 0 else "") + fmt(h["amount"]), s, (h.get("txid") or "")[:24])) for txid, s0 in list(st["sent"].items()): s = tx_status(me, txid) if s and s != s0: st["sent"][txid] = s; status_v.set("tx %s… is now %s" % (txid[:12], "in Y candidate" if s == "candidate" else s)) if s == "finalized": del st["sent"][txid] def poll(once=False): if st["w"]: try: r = api(a.server, "/api/state?addr=" + st["w"]["address"]) if r.get("me"): st["me"] = r["me"] else: status_v.set("server: %s" % r.get("error")) except Exception as ex: status_v.set("offline: %s" % ex) refresh_view() if not once: root.after(10000, poll) def send_dialog(): if not ensure_password(False): return d = tk.Toplevel(root); d.title("Send EDDY"); d.configure(bg=BG); d.transient(root); d.grab_set() ttk.Label(d, text="Recipient address (eddy1…)").pack(padx=16, pady=(14, 2), anchor="w"); to = ttk.Entry(d, width=52, font=(MONO[0], 10)); to.pack(padx=16) ttk.Label(d, text="Amount (EDDY)").pack(padx=16, pady=(8, 2), anchor="w"); am = ttk.Entry(d, width=20); am.pack(padx=16, anchor="w") ttk.Label(d, text="Note (optional, max 80)").pack(padx=16, pady=(8, 2), anchor="w"); no = ttk.Entry(d, width=52); no.pack(padx=16) info = ttk.Label(d, text="fee 0.01000000 EDDY", style="Muted.TLabel"); info.pack(padx=16, pady=6, anchor="w") err = tk.Label(d, text="", bg=BG, fg="#f87171", wraplength=440, justify="left"); err.pack(padx=16, anchor="w") def upd(*_): msgs = [] if to.get().strip() and not ADDR_RE.match(to.get().strip()): msgs.append("address format: eddy1 + 40 hex characters") try: v = parse_amount(am.get()); info.config(text="amount %s + fee %s = total %s EDDY (available %s)" % (fmt(v), fmt(MIN_FEE), fmt(v + MIN_FEE), fmt(sum(int(u["amount"]) for u in (st["me"] or {}).get("utxos", []))))) except Exception: info.config(text="fee 0.01000000 EDDY") err.config(text="; ".join(msgs)) for e in (to, am): e.bind("", upd) def go(): try: amount = parse_amount(am.get()); dest = to.get().strip() if not ADDR_RE.match(dest): raise ValueError("recipient must be eddy1 + 40 hex characters") if not st["me"]: raise ValueError("balance not loaded yet (server unreachable?)") except ValueError as ex: err.config(text=str(ex)); return r = pw_dialog("Confirm with password", "Send %s EDDY + fee %s = %s EDDY to\n%s" % (fmt(amount), fmt(MIN_FEE), fmt(amount + MIN_FEE), dest)) if not r["pw"]: return try: t = build_transfer(st["w"], st["me"], dest, amount, no.get()[:80], r["pw"]) except (WrongPassword, ValueError) as ex: r["pw"] = None; err.config(text="Not sent: %s" % ex); return r["pw"] = None res = api(a.server, "/api/tx", {"tx": t}) if not res.get("ok"): err.config(text="Rejected by the server: %s" % res.get("error")); return st["sent"][t["txid"]] = "unconfirmed"; d.destroy() status_v.set("Sent tx %s… - status: unconfirmed -> in Y candidate -> finalized (needs miners)" % t["txid"][:12]); poll(once=True) b = ttk.Frame(d); b.pack(padx=16, pady=12, fill="x"); ttk.Button(b, text="Confirm with password", command=go).pack(side="right"); ttk.Button(b, text="Cancel", command=d.destroy).pack(side="right", padx=6) def change_pw(): w = st["w"] if not w: return if not is_encrypted(w): ensure_password(False); return r = pw_dialog("Change password", "Current password") if not r["pw"]: return try: decrypt_jwk(w, r["pw"]).clear() except WrongPassword: messagebox.showerror("Eddycoin Wallet", "Wrong password."); return n = pw_dialog("Change password", "New password (min 8 characters, cannot be recovered)", twice=True) if not n["pw"]: return try: st["w"] = change_password(st["path"], w, r["pw"], n["pw"]) except Exception as ex: messagebox.showerror("Eddycoin Wallet", str(ex)); return r["pw"] = n["pw"] = None; messagebox.showinfo("Eddycoin Wallet", "Password changed."); refresh_view() def export_key(): w = st["w"] if not ensure_password(False): return if not messagebox.askyesno("Export private key", "This writes your private key UNENCRYPTED to a file. Anyone with that file controls the wallet. Continue?"): return r = pw_dialog("Export private key", "Password") if not r["pw"]: return try: jwk = decrypt_jwk(w, r["pw"]) except WrongPassword: messagebox.showerror("Eddycoin Wallet", "Wrong password."); return r["pw"] = None p = filedialog.asksaveasfilename(initialdir=app_dir(), initialfile=w["name"] + ".PRIVATE-KEY-export.json", defaultextension=".json") if p: write_json_atomic(p, wallet_v1(w["name"], w["pubkey"], jwk)); messagebox.showinfo("Eddycoin Wallet", "Exported to %s. Keep it offline and private." % p) jwk.clear() def start(): if a.file: open_path(a.file); return found = find_wallets(app_dir()) if len(found) == 1: open_path(found[0]); return d = tk.Toplevel(root); d.title("Eddycoin Wallet"); d.configure(bg=BG); d.transient(root); d.grab_set() if found: ttk.Label(d, text="Wallet files next to this program:").pack(padx=16, pady=(14, 4), anchor="w") lb = tk.Listbox(d, width=50, height=min(8, len(found))); lb.pack(padx=16) for p in found: lb.insert("end", os.path.basename(p)) def pick(): s = lb.curselection() if s: d.destroy(); open_path(found[s[0]]) ttk.Button(d, text="Open selected", command=pick).pack(padx=16, pady=6, anchor="e") else: ttk.Label(d, text="No wallet file was found next to Eddycoin Wallet.", wraplength=380).pack(padx=16, pady=(14, 4)) b = ttk.Frame(d); b.pack(padx=16, pady=12, fill="x") ttk.Button(b, text="Create new wallet", command=lambda: (d.destroy(), create_new())).pack(side="left") ttk.Button(b, text="Open wallet file", command=lambda: (d.destroy(), open_file())).pack(side="left", padx=6) root.after(100, start); root.after(10000, poll) if os.environ.get("EDDY_GUI_DEMO") == "send": # automated screenshot tests only root.after(4000, send_dialog) if os.environ.get("EDDY_GUI_DEMO") == "create": root.after(2500, create_new) root.mainloop() def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--server", default=DEFAULT_SERVER, help="Eddycoin server (https only)") ap.add_argument("--cli", dest="cmd", choices=["create", "info", "set-password", "change-password", "send", "export-key"], help="command-line mode") ap.add_argument("file", nargs="?", help="wallet file") ap.add_argument("--name"); ap.add_argument("--to"); ap.add_argument("--amount"); ap.add_argument("--note", default="") ap.add_argument("--out"); ap.add_argument("--wait", type=int, default=0, help="send: seconds to watch the status") ap.add_argument("--password-stdin", action="store_true", help="read passwords from stdin lines (automation/tests)") a = ap.parse_args() if a.cmd: if not a.file: sys.exit("wallet file required") return cli(a) gui(a) if __name__ == "__main__": main()