#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Eddycoin Wallet (v3 kit build: 24 recovery words) - desktop wallet. The chain is chosen ONLY in eddycoin_chain.json (see eddycoin_chain.py). ===================================================================================================== * 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). * Recovery words (v3): a new wallet gets 24 BIP39 English words (+ an optional extra word / passphrase). The key is derived from them (SLIP-0010 P-256, m/44'/3339'/0'/0/0 - see SEED_SPEC.md), so 'Restore from words' recreates the same address on any computer. The words are stored only inside the password-encrypted wallet file; the optional passphrase is never stored. * 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 calls to --server (default https://eddycoin-demo.pages.dev; http:// allowed for a node on this PC, in the LAN or a node URL with a port): 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 | restore | show-words | set-password | change-password | send | export-key (create/restore: add --with-passphrase for the optional extra word; --words-out FILE writes the new words to a private file instead of the screen) """ import argparse, base64, getpass, glob, hashlib, json, os, re, shutil, ssl, sys, threading, time, urllib.error, urllib.parse, urllib.request from datetime import datetime, timezone import eddycoin_chain as _CH # single switch: eddycoin_chain.json next to the program NETWORK = _CH.NETWORK DEFAULT_SERVER = _CH.SERVER import eddycoin_old_keys as _OLD # old testnet keys are refused on mainnet import eddycoin_seed as _SEED # BIP39 recovery words + SLIP-0010 key derivation class OldWalletRefused(ValueError): pass OLD_MSG = ("This is an OLD Eddycoin testnet wallet (%s).\n\nOld testnet wallets cannot be used on the new Eddycoin mainnet - " "their keys and coins do not carry over. Please open the NEW wallet file you received.\n(The old file was not changed.)") SUPERSEDED_MSG = ("This is a superseded wallet without recovery words - use your new wallet.\n\n(%s)\n" "Open the NEW wallet file you received (it comes with 24 recovery words). This old file was not changed.") HTTP_WARNING = "plain http:// is unencrypted - fine for a test-coin node, but others on the network could read or change the traffic" def check_server(url): """Return (ok, message). Allowed: any https:// URL; http:// only for this PC (localhost / 127.x), private LAN IPs (10.x, 172.16-31.x, 192.168.x) or an explicit node URL with a port (e.g. http://name.duckdns.org:8773). For http:// to another computer the message is a warning (unencrypted).""" import ipaddress, urllib.parse try: u = urllib.parse.urlsplit(url.strip()) host = (u.hostname or "").lower(); port = u.port except ValueError: return False, "invalid server URL" if u.scheme == "https" and host: return True, "" if u.scheme != "http" or not host: return False, "server must be an https:// URL or an http:// node URL (e.g. http://192.168.1.20:8773)" if host == "localhost": return True, "" try: ip = ipaddress.ip_address(host) except ValueError: ip = None if ip is not None and ip.is_loopback: return True, "" lan = ip is not None and ((ip.version == 4 and any(ip in ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"))) or (ip.version == 6 and (ip.is_private or ip.is_link_local))) if lan or port is not None: return True, HTTP_WARNING return False, "http:// is allowed only for this PC, a LAN IP (10.x / 172.16-31.x / 192.168.x) or a node URL with a port, e.g. http://name.duckdns.org:8773 - otherwise use https://" def normalize_server(text, default_port=8773): """Turn what the user typed into a server URL: 192.168.1.5:8773 -> http://192.168.1.5:8773 host.example:8773 -> http://host.example:8773 [fd00::5]:8773 -> http://[fd00::5]:8773 192.168.1.5 / raspberrypi / localhost -> http://...:8773 eddycoin-demo.pages.dev -> https://eddycoin-demo.pages.dev (plain domain without port = https) Full URLs (http:// or https://) are kept; a trailing slash / path is removed.""" import ipaddress, re as _re, urllib.parse t = _re.sub(r"\s+", "", str(text or "")) if not t: return "" if "://" in t: try: u = urllib.parse.urlsplit(t) except ValueError: return t return "%s://%s" % (u.scheme.lower(), u.netloc.lower()) if u.netloc else t hp = t.split("/")[0] m = _re.match(r"^\[([0-9A-Fa-f:.]+)\](?::(\d{1,5}))?$", hp) if m: return "http://[%s]:%s" % (m.group(1).lower(), m.group(2) or default_port) try: ip = ipaddress.ip_address(hp) return ("http://[%s]:%d" if ip.version == 6 else "http://%s:%d") % (str(ip), default_port) except ValueError: pass m = _re.match(r"^([A-Za-z0-9.\-]+):(\d{1,5})$", hp) if m: return "http://%s:%s" % (m.group(1).lower(), m.group(2)) if _re.match(r"^[A-Za-z0-9\-]+$", hp): return "http://%s:%d" % (hp.lower(), default_port) if _re.match(r"^[A-Za-z0-9.\-]+$", hp): return "https://" + hp.lower() return t ITER, MIN_PW = 600000, 8 TX_DOMAIN, SIG_DOMAIN = "EDDY1-TX|v1", "EDDY1-TX-SIG|v1" # mainnet eddycoin-mainnet-1 (genesis f720818b...), replay protection vs the old testnet 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") if _OLD.is_mainnet(NETWORK) and getattr(_OLD, "is_superseded", lambda a: False)(w["address"]): raise OldWalletRefused(SUPERSEDED_MSG % w["address"]) if _OLD.is_mainnet(NETWORK) and _OLD.is_old(w["address"], pub): raise OldWalletRefused(OLD_MSG % w["address"]) 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_blob(pt, password, aad, aad_name, content): 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) ct = AESGCM(bytes(key)).encrypt(nonce, bytes(pt), aad.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": aad_name, "content": content} def encrypt_jwk(jwk, password, address): pt = bytearray(json.dumps({"crv": "P-256", "d": jwk["d"], "kty": "EC", "x": jwk["x"], "y": jwk["y"]}, separators=(",", ":")).encode()) return _encrypt_blob(pt, password, address, "address", "jwk") WORDS_AAD = "|bip39-words" def encrypt_words(words, password, address): """the 24 recovery words, encrypted exactly like the key (own salt + nonce); the passphrase is never stored""" return _encrypt_blob(bytearray(words.encode("utf-8")), password, address + WORDS_AAD, "address|bip39-words", "bip39-words") 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 has_words(w): return bool(w.get("encryptedWords")) def decrypt_words(w, password): from cryptography.hazmat.primitives.ciphers.aead import AESGCM e = w["encryptedWords"] 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 = AESGCM(bytes(key)).decrypt(base64.b64decode(e["nonce"]), base64.b64decode(e["ciphertext"]), (w["address"] + WORDS_AAD).encode()) except Exception: raise WrongPassword("wrong password (or damaged wallet file)") finally: _wipe(key); _wipe(pwb) return pt.decode("utf-8") def new_keypair(): """random key WITHOUT recovery words (used only for throwaway test addresses; wallets use keypair_from_words)""" from cryptography.hazmat.primitives.asymmetric import ec return _keypair(ec.generate_private_key(ec.SECP256R1())) def keypair_from_words(words, passphrase=""): """24 BIP39 words (+ optional passphrase) -> the same P-256 key / address on every computer""" from cryptography.hazmat.primitives.asymmetric import ec return _keypair(ec.derive_private_key(_SEED.private_key_int(words, passphrase), ec.SECP256R1())) def address_from_words(words, passphrase=""): pub, jwk = keypair_from_words(words, passphrase); jwk.clear() return address_of(pub) def _keypair(k): 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, words=None): """file format v2 (unchanged, opens in older wallets and on the web page); v3 wallets add encryptedWords + recovery""" a = address_of(pub) w = {"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": "Keep this file private and backed up; the password cannot be recovered."} if words: w["encryptedWords"] = encrypt_words(words, password, a) w["recovery"] = {"scheme": "BIP39 English 24 words + optional passphrase, SLIP-0010 nist256p1", "path": _SEED.PATH, "note": "Restore from words recreates this address. The words are inside encryptedWords (password needed); the passphrase is never stored."} w["warning"] = "Keep this file private and backed up. Your 24 recovery words (on paper) restore the wallet if this file or the password is lost." return w def wallet_from_words(name, words, passphrase, password, created=None): words = _SEED.check(words) pub, jwk = keypair_from_words(words, passphrase) try: return wallet_v2(name, pub, jwk, password, created, words=words) finally: jwk.clear() 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."} def write_private_text(path, text): """new file readable only by this user (0600); never overwrites""" fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(text) 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) words = decrypt_words(w, old) if has_words(w) else None nw = wallet_v2(w["name"], w.get("pubkey") or w["publicKey"], jwk, new, w.get("created"), words=words) jwk.clear(); words = None 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(TX_DOMAIN + "|" + canon(body)) key = ec.derive_private_key(int.from_bytes(b64u_dec(jwk["d"]), "big"), ec.SECP256R1()) r, s = decode_dss_signature(key.sign((SIG_DOMAIN + "|" + 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 _api1(server, path, body=None, timeout=20): if not check_server(server)[0]: raise ValueError(check_server(server)[1]) 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/3 (mainnet EDDY1)"}) 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} FAILOVER = [] # ordered fallback nodes; the GUI fills it from its server list. Empty: requests to the default server fall back to eddycoin_chain.SERVERS _NET_OK = set() # fallback nodes that answered /api/state with exactly this network _DOWN = ("HTTP 502", "HTTP 503", "HTTP 504", "HTTP 521", "HTTP 522", "HTTP 523", "HTTP 530") def api(server, path, body=None, timeout=20, failover=True): """Request to the selected node. If it is unreachable (network error, or 502-504 from its proxy), the SAME request goes to the next fallback node that reports exactly this network in /api/state. A signed transaction is identical on every node (same txid), so sending it to another node of the same chain is safe.""" err = None try: r = _api1(server, path, body, timeout) if not failover or not (isinstance(r, dict) and str(r.get("error", "")).startswith(_DOWN)): return r except (urllib.error.URLError, OSError) as ex: if not failover: raise r, err = None, ex for alt in [s for s in (FAILOVER or (_CH.SERVERS if server == DEFAULT_SERVER else [])) if s != server]: try: if alt not in _NET_OK: st = _api1(alt, "/api/state", timeout=8) if not (_CH.network_ok(st.get("network")) and isinstance(st.get("tips"), dict)): continue _NET_OK.add(alt) r2 = _api1(alt, path, body, timeout) if isinstance(r2, dict) and str(r2.get("error", "")).startswith(_DOWN): continue return r2 except (urllib.error.URLError, OSError, ValueError): continue if err is not None: raise err return r 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 OldWalletRefused: out.append(p) # listed so that opening it shows the clear refusal message 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 WORDS_WARNING = ("Write these 24 words on PAPER, in this order. Never share them, never photograph them, never type them into a website.\n" "Anyone who has them owns your coins. They are the only way back if this computer, the wallet file or the password is lost.") PASSPHRASE_WARNING = ("OPTIONAL extra word (BIP39 passphrase, the '25th word'): any text, also a made-up word. It is case-sensitive and spaces count.\n" "It is NEVER stored. If you lose it, the 24 words alone restore a DIFFERENT, empty wallet - your coins are lost.") def ask_passphrase(a): if not a.with_passphrase: return "" if a.password_stdin: return sys.stdin.readline().rstrip("\r\n") print(PASSPHRASE_WARNING) p = getpass.getpass("Extra word / passphrase (hidden): ") if getpass.getpass("Repeat extra word / passphrase: ") != p: sys.exit("passphrases do not match - nothing was created") return p def confirm_words_cli(words): import secrets ws = words.split() for i in sorted(secrets.SystemRandom().sample(range(24), 3)): for _ in range(3): if input("Confirm: type word #%d: " % (i + 1)).strip().lower() == ws[i]: break print("That is not word #%d." % (i + 1)) else: return False return True def cli(a): if not need_crypto(): sys.exit(INSTALL_HELP) c = a.cmd if c in ("create", "restore"): if os.path.exists(a.file): sys.exit("file exists: " + a.file) if c == "restore": if a.password_stdin: words = sys.stdin.readline().strip() else: words = getpass.getpass("Your 24 recovery words, separated by spaces (hidden): ") try: words = _SEED.check(words) except _SEED.BadWords as e: sys.exit("cannot restore: %s" % e) pw = ask_pw(a, "New password for this computer (min 8): ", True) if len(pw) < MIN_PW: sys.exit("password must have at least 8 characters") print("password strength: " + strength(pw)) if c == "create": words = _SEED.generate(256) if a.words_out: write_private_text(a.words_out, words + "\n") print("24 recovery words written to %s (readable only by you). Copy them to paper, then delete that file." % a.words_out) else: print("\n" + WORDS_WARNING + "\n") ws = words.split() for r in range(6): print(" " + "".join("%2d. %-10s" % (r * 4 + k + 1, ws[r * 4 + k]) for k in range(4))) input("\nPress Enter when the words are on paper (the screen is cleared) ...") print("\n" * 60) if not confirm_words_cli(words): sys.exit("words not confirmed - nothing was created. Run create again.") pp = ask_passphrase(a) w = wallet_from_words(a.name or os.path.splitext(os.path.basename(a.file))[0], words, pp, pw); words = pp = pw = None write_json_atomic(a.file, w) print("%s %s address %s (password-protected, with recovery words%s)." % ("created" if c == "create" else "restored", a.file, w["address"], ", extra word used" if a.with_passphrase else "")) 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 == "show-words": if not has_words(w): sys.exit("this wallet was created without recovery words (older wallet) - back up the wallet file instead") pw = ask_pw(a, "Password: ") try: words = decrypt_words(w, pw) except WrongPassword as e: sys.exit(str(e)) pw = None if a.out: write_private_text(a.out, words + "\n"); print("recovery words written to %s (readable only by you)" % a.out) else: print(WORDS_WARNING); ws = words.split() for r in range(6): print(" " + "".join("%2d. %-10s" % (r * 4 + k + 1, ws[r * 4 + k]) for k in range(4))) words = None 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 - %s" % _CH.LABEL); root.geometry("900x580"); root.minsize(760, 480) st = {"path": None, "w": None, "me": None, "sent": {}} sp = os.path.join(app_dir(), "eddycoin_wallet_settings.json") # optional: server / own node URL chosen with the "Server" button try: with open(sp, encoding="utf-8") as f: WS = json.load(f) except Exception: WS = {} srv_list = [normalize_server(x) for x in (WS.get("servers") or []) if check_server(normalize_server(x))[0]] for x in (WS.get("server"), a.server, DEFAULT_SERVER): # the live server is always in the list x = str(x or "").strip().rstrip("/") if x and check_server(x)[0] and x not in srv_list: srv_list.insert(0, x) if x == DEFAULT_SERVER else srv_list.append(x) for x in _CH.SERVERS: # failover nodes of this chain (eddycoin_chain.json + local node) are always in the list if x and x not in srv_list and check_server(x)[0]: srv_list.append(x) FAILOVER[:] = srv_list # same list object order; api() uses it when the selected node is unreachable if a.server == DEFAULT_SERVER: sv = str(WS.get("server") or "").strip().rstrip("/") if sv and check_server(sv)[0]: a.server = sv def save_ws(): try: with open(sp, "w", encoding="utf-8") as f: json.dump({"server": a.server, "servers": srv_list}, f, indent=1) except Exception: pass def probe(url): try: r = api(url, "/api/state", timeout=8, failover=False) # testing one node: never answer from another except Exception as ex: return False, str(ex)[:120] if _CH.network_ok(r.get("network")) and isinstance(r.get("tips"), dict): t = r["tips"]; return True, "Y #%s, E #%s, D #%s" % (t.get("Y"), t.get("E"), t.get("D")) return False, str(r.get("error") or r.get("reason") or "not an Eddycoin server")[:120] 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.map("TCombobox", fieldbackground=[("readonly", "#121a30")], foreground=[("readonly", FG)], selectbackground=[("readonly", "#121a30")], selectforeground=[("readonly", FG)]) 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=_CH.LABEL + ("" if _CH.ACTIVE else " - balance and sending start when the new chain is activated"), 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 save_new_wallet(parent, name, w): fn = re.sub(r'[<>:"/\\|?*]', "_", name) + ".json"; path = os.path.join(app_dir(), fn) if os.path.exists(path): path = filedialog.asksaveasfilename(parent=parent, initialdir=app_dir(), initialfile=fn, defaultextension=".json") if not path: return None write_json_atomic(path, w) return path def words_grid(parent, words): g = tk.Frame(parent, bg="#121a30", padx=10, pady=8) for i, wd in enumerate(words.split()): r, c = i % 6, i // 6 # numbered down the columns: 1-6 | 7-12 | 13-18 | 19-24 tk.Label(g, text="%2d." % (i + 1), bg="#121a30", fg="#9aa6c0", font=(MONO[0], 11)).grid(row=r, column=c * 2, sticky="e", padx=(10, 3), pady=2) tk.Label(g, text=wd, bg="#121a30", fg=FG, font=(MONO[0], 12, "bold"), width=10, anchor="w").grid(row=r, column=c * 2 + 1, sticky="w") return g WORDS_WARN_GUI = ("WRITE THESE 24 WORDS DOWN ON PAPER, in this order.\n" "Never share them with anyone. Never photograph them or take a screenshot. Never type them into a website, e-mail or chat.\n" "Anyone who has these words owns your coins. They are the only way back if this computer, the wallet file or the password is lost.") PASSPHRASE_WARN_GUI = ("OPTIONAL - most people skip this.\n\n" "An extra word (BIP39 passphrase, the '25th word') adds protection: someone who finds your paper with the 24 words " "still cannot open the wallet without it. It can be any text, also a made-up word that does not exist. " "It is case-sensitive and spaces count.\n\n" "It is NEVER saved anywhere. If you lose it, your coins are lost: the 24 words alone then restore a DIFFERENT, empty wallet. " "Write it down too and keep it separate from the 24 words.") def create_new(): d = tk.Toplevel(root); d.title("Create new wallet"); d.configure(bg=BG); d.transient(root); d.grab_set(); d.resizable(False, False) S = {"words": None, "pw": None, "name": None} body = ttk.Frame(d, padding=16); body.pack(fill="both", expand=True) def clear(): d.unbind("") for ch in body.winfo_children(): ch.destroy() def close(): S["words"] = S["pw"] = None; d.destroy() d.protocol("WM_DELETE_WINDOW", close) def title(txt, sub=""): ttk.Label(body, text=txt, font=("Segoe UI", 12, "bold")).pack(anchor="w") if sub: ttk.Label(body, text=sub, style="Muted.TLabel", wraplength=560, justify="left").pack(anchor="w", pady=(2, 8)) def step1(): clear() title("Step 1 of 4 - password for this computer", "The password unlocks the wallet file on this computer (needed for sending). Minimum 8 characters. " "Your 24 recovery words (next step) are the backup if you ever forget it.") ttk.Label(body, text="Wallet name").pack(anchor="w"); en = ttk.Entry(body, width=40); en.pack(anchor="w"); en.insert(0, S["name"] or "my wallet") ttk.Label(body, text="Password").pack(anchor="w", pady=(8, 0)); e1 = ttk.Entry(body, show="*", width=40); e1.pack(anchor="w"); e1.focus_set() ttk.Label(body, text="Repeat password").pack(anchor="w", pady=(8, 0)); e2 = ttk.Entry(body, show="*", width=40); e2.pack(anchor="w") hint = ttk.Label(body, text="strength: -", style="Muted.TLabel"); hint.pack(anchor="w") e1.bind("", lambda ev: hint.config(text="strength: " + strength(e1.get()))) err = tk.Label(body, text="", bg=BG, fg="#f87171"); err.pack(anchor="w") def nxt(*_): p = 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 S["name"], S["pw"] = en.get().strip() or "my wallet", p; e1.delete(0, "end"); e2.delete(0, "end") if not S["words"]: S["words"] = _SEED.generate(256) # 256-bit entropy from the OS -> 24 words step2() b = ttk.Frame(body); b.pack(fill="x", pady=(12, 0)) ttk.Button(b, text="Create new wallet", command=nxt).pack(side="right"); ttk.Button(b, text="Cancel", command=close).pack(side="right", padx=6) d.bind("", nxt) def step2(): clear() title("Step 2 of 4 - your 24 recovery words") tk.Label(body, text=WORDS_WARN_GUI, bg="#2a1f05", fg="#fde68a", justify="left", wraplength=600, padx=10, pady=8, font=("Segoe UI", 10, "bold")).pack(fill="x", pady=(6, 8)) words_grid(body, S["words"]).pack(anchor="w") v = tk.BooleanVar(value=False) b = ttk.Frame(body) nb = ttk.Button(b, text="Next", command=step3); nb.state(["disabled"]) cb = ttk.Checkbutton(body, text="I have written all 24 words on paper, in this order", variable=v, command=lambda: nb.state(["!disabled"] if v.get() else ["disabled"])) cb.pack(anchor="w", pady=(10, 0)); cb.focus_set() b.pack(fill="x", pady=(12, 0)); nb.pack(side="right"); ttk.Button(b, text="Cancel", command=close).pack(side="right", padx=6) d.bind("", lambda *_: step3() if v.get() else None) def step3(): import secrets clear() ws = S["words"].split(); idx = sorted(secrets.SystemRandom().sample(range(24), 3)) title("Step 3 of 4 - check your paper", "The words are hidden now. Type these three words from your paper:") ents = [] for i in idx: f = ttk.Frame(body); f.pack(anchor="w", pady=2) ttk.Label(f, text="Word #%d" % (i + 1), width=10).pack(side="left"); e = ttk.Entry(f, width=24); e.pack(side="left"); ents.append((i, e)) ents[0][1].focus_set() err = tk.Label(body, text="", bg=BG, fg="#f87171", wraplength=560, justify="left"); err.pack(anchor="w") def ok(*_): for i, e in ents: if _SEED.normalize_words(e.get()) != ws[i]: err.config(text="Word #%d is not correct. Check your paper - or press Back to see the words again." % (i + 1)); return step4() b = ttk.Frame(body); b.pack(fill="x", pady=(12, 0)) ttk.Button(b, text="Next", command=ok).pack(side="right"); ttk.Button(b, text="Back", command=step2).pack(side="right", padx=6) d.bind("", ok) def step4(): clear() title("Step 4 of 4 - optional extra word (passphrase)") tk.Label(body, text=PASSPHRASE_WARN_GUI, bg="#121a30", fg=FG, justify="left", wraplength=600, padx=10, pady=8).pack(fill="x", pady=(6, 8)) v = tk.BooleanVar(value=False); show = tk.BooleanVar(value=False) f = ttk.Frame(body) e1 = ttk.Entry(f, show="*", width=40); e2 = ttk.Entry(f, show="*", width=40) def toggle(): for e in (e1, e2): e.state(["!disabled"] if v.get() else ["disabled"]); e.config(show="" if show.get() else "*") if v.get(): e1.focus_set() cbx = ttk.Checkbutton(body, text="Use an extra word (optional)", variable=v, command=toggle); cbx.pack(anchor="w"); cbx.focus_set() f.pack(anchor="w", padx=(20, 0), pady=4) ttk.Label(f, text="Extra word").grid(row=0, column=0, sticky="w"); e1.grid(row=0, column=1, padx=6, pady=2) ttk.Label(f, text="Repeat").grid(row=1, column=0, sticky="w"); e2.grid(row=1, column=1, padx=6, pady=2) ttk.Checkbutton(f, text="show", variable=show, command=toggle).grid(row=0, column=2) toggle() err = tk.Label(body, text="", bg=BG, fg="#f87171", wraplength=560, justify="left"); err.pack(anchor="w") def finish(*_): pp = "" if v.get(): pp = e1.get() if not pp: err.config(text="Type the extra word, or untick 'Use an extra word'."); return if pp != e2.get(): err.config(text="The two extra words do not match."); return err.config(text="Creating the wallet ...", fg="#9aa6c0"); d.update_idletasks() try: w = wallet_from_words(S["name"], S["words"], pp, S["pw"]) path = save_new_wallet(d, S["name"], w) except Exception as ex: err.config(text="Could not create the wallet: %s" % ex, fg="#f87171"); return if not path: err.config(text="Not saved.", fg="#f87171"); return used = v.get(); e1.delete(0, "end"); e2.delete(0, "end"); pp = None close(); open_path(path) messagebox.showinfo("Eddycoin Wallet", "Wallet created and saved as:\n%s\n\nYour 24 words on paper restore it on any computer with 'Restore from words'%s.\n" "Keep the paper safe and offline. Never share the words, the file or the password." % (path, " + your extra word" if used else "")) b = ttk.Frame(body); b.pack(fill="x", pady=(12, 0)) ttk.Button(b, text="Finish - create wallet", command=finish).pack(side="right"); ttk.Button(b, text="Back", command=step3).pack(side="right", padx=6) d.bind("", finish) step1() def restore_words(): d = tk.Toplevel(root); d.title("Restore from words"); d.configure(bg=BG); d.transient(root); d.grab_set(); d.resizable(False, False) body = ttk.Frame(d, padding=16); body.pack(fill="both", expand=True) ttk.Label(body, text="Restore a wallet from your 24 recovery words", font=("Segoe UI", 12, "bold")).pack(anchor="w") ttk.Label(body, text="Type the words from your paper, separated by spaces. Only do this on your own computer.", style="Muted.TLabel").pack(anchor="w", pady=(2, 6)) tx = tk.Text(body, width=66, height=4, wrap="word", bg="#121a30", fg=FG, insertbackground=FG, font=(MONO[0], 11)); tx.pack(anchor="w"); tx.focus_set() chk = ttk.Label(body, text="0 of 24 words", style="Muted.TLabel"); chk.pack(anchor="w") v = tk.BooleanVar(value=False) ttk.Checkbutton(body, text="I used an extra word (passphrase) when I created this wallet", variable=v, command=lambda: (ep.state(["!disabled"] if v.get() else ["disabled"]), v.get() and ep.focus_set())).pack(anchor="w", pady=(8, 0)) fp = ttk.Frame(body); fp.pack(anchor="w", padx=(20, 0)) ttk.Label(fp, text="Extra word").pack(side="left"); ep = ttk.Entry(fp, show="*", width=36); ep.pack(side="left", padx=6); ep.state(["disabled"]) ttk.Label(body, text="Exactly as you created it (case-sensitive). A wrong or missing extra word restores a DIFFERENT, empty wallet.", style="Muted.TLabel").pack(anchor="w", padx=(20, 0)) ttk.Label(body, text="Wallet name").pack(anchor="w", pady=(8, 0)); en = ttk.Entry(body, width=40); en.pack(anchor="w"); en.insert(0, "restored wallet") ttk.Label(body, text="New password for this computer (min 8)").pack(anchor="w", pady=(8, 0)); e1 = ttk.Entry(body, show="*", width=40); e1.pack(anchor="w") ttk.Label(body, text="Repeat password").pack(anchor="w", pady=(8, 0)); e2 = ttk.Entry(body, show="*", width=40); e2.pack(anchor="w") err = tk.Label(body, text="", bg=BG, fg="#f87171", wraplength=560, justify="left"); err.pack(anchor="w") def check_words(*_): n = len(_SEED.normalize_words(tx.get("1.0", "end")).split()) if n == 24: try: _SEED.check(tx.get("1.0", "end")); chk.config(text="24 of 24 words - the words fit together (checksum OK)") except _SEED.BadWords as ex: chk.config(text=str(ex)) else: chk.config(text="%d of 24 words" % n) tx.bind("", check_words) tx.bind("", lambda ev: (tx.tk_focusNext().focus_set(), "break")[1]) # Tab leaves the word box (no tab characters) def close(): tx.delete("1.0", "end"); ep.delete(0, "end"); e1.delete(0, "end"); e2.delete(0, "end"); d.destroy() d.protocol("WM_DELETE_WINDOW", close) def go(*_): try: words = _SEED.check(tx.get("1.0", "end")) except _SEED.BadWords as ex: err.config(text="Cannot restore: %s." % ex); return p = 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 pp = ep.get() if v.get() else "" if v.get() and not pp: err.config(text="Type the extra word, or untick the box."); return name = en.get().strip() or "restored wallet" err.config(text="Restoring ...", fg="#9aa6c0"); d.update_idletasks() w = wallet_from_words(name, words, pp, p); words = pp = None err.config(text="", fg="#f87171") if not messagebox.askokcancel("Restore from words", "These words restore the address:\n\n%s\n\nIs this your address? If not, check the words and the extra word " "(a wrong extra word gives a different, empty address).\n\nSave this wallet on this computer?" % w["address"], parent=d): return path = save_new_wallet(d, name, w) if not path: return close(); open_path(path) messagebox.showinfo("Eddycoin Wallet", "Wallet restored and saved as:\n%s\n\nAddress: %s" % (path, w["address"])) b = ttk.Frame(body); b.pack(fill="x", pady=(12, 0)) ttk.Button(b, text="Restore", command=go).pack(side="right"); ttk.Button(b, text="Cancel", command=close).pack(side="right", padx=6) for e in (ep, en, e1, e2): e.bind("", go) def show_words(): w = st["w"] if not w: return if not has_words(w): messagebox.showinfo("Recovery words", "This wallet was created without recovery words (older wallet).\nKeep backup copies of the wallet file and the password instead."); return if not messagebox.askyesno("Recovery words", "Make sure nobody can see your screen and no screen recording or remote access is running.\n\nShow your 24 recovery words?"): return r = pw_dialog("Recovery words", "Password") if not r["pw"]: return try: words = decrypt_words(w, r["pw"]) except WrongPassword: r["pw"] = None; messagebox.showerror("Eddycoin Wallet", "Wrong password."); return r["pw"] = None d = tk.Toplevel(root); d.title("Recovery words - %s" % w["name"]); d.configure(bg=BG); d.transient(root); d.grab_set(); d.resizable(False, False) body = ttk.Frame(d, padding=16); body.pack(fill="both", expand=True) tk.Label(body, text=WORDS_WARN_GUI, bg="#2a1f05", fg="#fde68a", justify="left", wraplength=600, padx=10, pady=8, font=("Segoe UI", 10, "bold")).pack(fill="x", pady=(0, 8)) words_grid(body, words).pack(anchor="w"); words = None ttk.Label(body, text="An optional extra word (passphrase), if you used one, is never stored and not shown here.", style="Muted.TLabel").pack(anchor="w", pady=(8, 0)) ttk.Button(body, text="Close", command=d.destroy).pack(anchor="e", pady=(12, 0)) 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") def set_server(): d = tk.Toplevel(root); d.title("Servers"); d.configure(bg=BG); d.transient(root); d.resizable(False, False) fr = ttk.Frame(d, padding=12); fr.pack(fill="both") ttk.Label(fr, text="Server list - the live server and e.g. your friends' Eddycoin Nodes. Pick one and press Use.").pack(anchor="w") cur = tk.StringVar(value=a.server) cb = ttk.Combobox(fr, textvariable=cur, values=srv_list, state="readonly", width=52); cb.pack(fill="x", pady=6) info = tk.StringVar(value="in use: %s" % a.server) ttk.Label(fr, textvariable=info, style="Muted.TLabel", wraplength=520, justify="left").pack(anchor="w") bf = ttk.Frame(fr); bf.pack(fill="x", pady=(8, 0)) def add(): from tkinter import simpledialog v = simpledialog.askstring("Add server", "Server or node address - with or without http://, port included, e.g.\n 127.0.0.1:8773 (Eddycoin Node on this PC)\n 192.168.1.20:8773 (node in your home network)\n name.duckdns.org:8773 (a friend's node)\n [fd00::20]:8773 (IPv6)\nNo port: an IP / PC name gets :8773, a plain domain uses https://", parent=d) if not v: return v = normalize_server(v) ok, msg = check_server(v) if not ok: messagebox.showerror("Eddycoin Wallet", msg[0].upper() + msg[1:] + ".", parent=d); return if msg and not messagebox.askokcancel("Eddycoin Wallet", "Note: " + msg + ".\n\nAdd %s anyway?" % v, parent=d): return info.set("checking %s …" % v); d.update_idletasks() ok2, txt = probe(v) if not ok2: info.set(""); messagebox.showerror("Eddycoin Wallet", "%s did not answer like an Eddycoin server/node (/api/state):\n%s\n\nNot added." % (v, txt), parent=d); return if v not in srv_list: srv_list.append(v) cb.config(values=srv_list); cur.set(v); save_ws(); info.set("added %s: %s - press Use to switch to it" % (v, txt)) def remove(): v = cur.get() if v == DEFAULT_SERVER: info.set("The live server always stays in the list."); return if v in srv_list: srv_list.remove(v) if a.server == v: use(DEFAULT_SERVER) cb.config(values=srv_list); cur.set(a.server); save_ws(); info.set("removed %s" % v) def test(): info.set("testing %d server(s) …" % len(srv_list)) def job(): res = [] for v in list(srv_list): ok2, txt = probe(v) res.append("%s %s - %s" % ("OK " if ok2 else "---", v, txt if ok2 else "unreachable (%s)" % txt)) root.after(0, lambda: info.set("\n".join(res))) threading.Thread(target=job, daemon=True).start() def use(v=None): v = v or cur.get() a.server = v; st["me"] = None; save_ws(); srv_btn.config(text="Server: %s" % (urllib.parse.urlsplit(v).netloc or v)) info.set("in use: %s" % v); status_v.set("Server: %s" % v); poll(once=True) ttk.Button(bf, text="Use", command=use).pack(side="left") ttk.Button(bf, text="Add", command=add).pack(side="left", padx=4) ttk.Button(bf, text="Remove", command=remove).pack(side="left") ttk.Button(bf, text="Test", command=test).pack(side="left", padx=4) ttk.Button(bf, text="Close", command=d.destroy).pack(side="right") ttk.Button(hdr, text="Open wallet file", command=open_file).pack(side="right"); ttk.Button(hdr, text="Restore from words", command=restore_words).pack(side="right", padx=(6, 0)) ttk.Button(hdr, text="Create new wallet", command=create_new).pack(side="right", padx=6) srv_btn = ttk.Button(hdr, text="Server: %s" % (urllib.parse.urlsplit(a.server).netloc or a.server), command=set_server); srv_btn.pack(side="right") 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) def receive(): if not addr_v.get(): return d = tk.Toplevel(root); d.title("Receive EDDY"); d.configure(bg=BG); d.transient(root) ttk.Label(d, text="Your receive address - give this to whoever sends you EDDY:").pack(padx=16, pady=(14, 4), anchor="w") e = ttk.Entry(d, width=50, font=(MONO[0], 12)); e.insert(0, addr_v.get()); e.state(["readonly"]); e.pack(padx=16, fill="x") ttk.Label(d, text="Share only this address. Never share the wallet file or the password.", style="Muted.TLabel").pack(padx=16, pady=6, anchor="w") b = ttk.Frame(d); b.pack(padx=16, pady=(0, 12), fill="x"); ttk.Button(b, text="Copy address", command=copy).pack(side="left"); ttk.Button(b, text="Close", command=d.destroy).pack(side="right") ttk.Button(af, text="Receive", command=receive).pack(side="left") 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) ttk.Button(bf, text="Recovery words", command=lambda: show_words()).pack(side="left", padx=(0, 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"] and not _CH.ACTIVE and a.server == DEFAULT_SERVER: status_v.set("The new Eddycoin chain is not live yet. Your address already works for receiving once it starts.") elif 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()); av = sum(int(u["amount"]) for u in (st["me"] or {}).get("utxos", [])) if st["me"] and v + MIN_FEE > av: msgs.append("amount + fee is more than your available %s EDDY (max %s)" % (fmt(av), fmt(max(0, av - MIN_FEE)))) 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?)") av = sum(int(u["amount"]) for u in st["me"].get("utxos", [])) if amount + MIN_FEE > av: raise ValueError("amount %s + fee %s is more than your available %s EDDY (max %s)" % (fmt(amount), fmt(MIN_FEE), fmt(av), fmt(max(0, av - MIN_FEE)))) if dest == st["w"]["address"]: raise ValueError("recipient is your own address") except ValueError as ex: err.config(text=str(ex)); return r = pw_dialog("Confirm with password", "Please check before sending:\n\nRecipient: %s\nAmount: %s EDDY\nFee: %s EDDY\nTotal: %s EDDY\n\nEnter your password to sign and send." % (dest, fmt(amount), fmt(MIN_FEE), fmt(amount + MIN_FEE))) 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="Restore from words", command=lambda: (d.destroy(), restore_words())).pack(side="left", padx=6) ttk.Button(b, text="Open wallet file", command=lambda: (d.destroy(), open_file())).pack(side="left") 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) if os.environ.get("EDDY_GUI_DEMO") == "restore": root.after(2500, restore_words) if os.environ.get("EDDY_GUI_DEMO") == "words": root.after(2500, show_words) root.mainloop() def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--server", default=DEFAULT_SERVER, help="Eddycoin server or node (https://, or http:// for this PC, LAN IPs or node URLs with a port)") ap.add_argument("--cli", dest="cmd", choices=["create", "restore", "show-words", "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)") ap.add_argument("--with-passphrase", action="store_true", help="create/restore: also ask for the optional extra word (BIP39 passphrase)") ap.add_argument("--words-out", help="create: write the new recovery words to this new private file instead of the screen") a = ap.parse_args() a.server = normalize_server(a.server) ok, msg = check_server(a.server) if not ok: sys.exit("--server: " + msg) if msg and a.cmd: print("NOTE: " + msg, file=sys.stderr) if a.cmd: if not a.file: sys.exit("wallet file required") try: return cli(a) except OldWalletRefused as e: sys.exit(str(e)) gui(a) if __name__ == "__main__": main()