From f2b817b7fab3c4259f1c3b707c057414ff7ea32b Mon Sep 17 00:00:00 2001 From: Bayu Benjamin Date: Sat, 5 Jul 2025 12:17:40 +0700 Subject: [PATCH] feat: Add cliv2 with send-from-file feature and update README --- README.md | 110 +++++--- cliv2.py | 788 ++++++++++++++++++++++++++++++++++++++++++++++++++++ wallets.txt | 20 ++ 3 files changed, 882 insertions(+), 36 deletions(-) create mode 100644 cliv2.py create mode 100644 wallets.txt diff --git a/README.md b/README.md index 55168f5..847e7cc 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,91 @@ -# octra terminal client +# Octra Terminal Client -a terminal wallet reminiscent of dos-era tui interfaces — but built with modern asynchronous architecture +A simple and fast terminal wallet, reminiscent of DOS-era TUI interfaces, but built with a modern, asynchronous architecture. -## what it does +## ✨ Key Features +* **View Balance & History**: Shows your Octra wallet balance and recent transaction history. +* **Send Transactions**: Lets you send transactions to a single address or multiple addresses. +* **Export Keys**: Allows you to export your private key or full wallet file. -- shows your octra wallet balance and tx history -- lets you send one or many transactions -- exports your private key or full wallet file +## 💻 System Compatibility +* **Linux** +* **Mac** +* **Windows** (Note: some features like clipboard may not work) -## works on +--- -- linux -- mac -- windows (some features like clipboard may not work) +## 🚀 Quick Start Guide -## what you need +Follow the steps below to set up and run the application. -- python 3.8 or higher -- internet connection -- your wallet file (private key) +### 🧩 1. Initial Setup & System Requirements -## how to install and run (step by step) +Before you begin, ensure your system has: +* **Git** and **Python 3** (version 3.8 or higher). +* A stable **internet connection**. -1. open terminal +Open your terminal and run this command to install the necessary packages (for Debian/Ubuntu-based Linux users): +```bash +sudo apt update && sudo apt install git python3 python3-venv -y +``` -2. run these commands one by one: +### 📥 2. Clone the Repository +Use `git` to download the project's source code to your computer. ```bash -git clone https://github.com/octra-labs/octra_pre_client.git -cd octra_pre_client -python3 -m venv venv -source venv/bin/activate # for windows use: venv\Scripts\activate -pip install -r requirements.txt -cp wallet.json.example wallet.json +git clone [https://github.com/Octra-Labs/octra-pre-client.git](https://github.com/Octra-Labs/octra-pre-client.git) +cd octra-pre-client ``` -3. open wallet.json and edit it (change placeholders to your wallet data): +### 🛠️ 3. Set Up Environment & Install Dependencies -```json -{ - "priv": "private-key-here", - "addr": "octxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "rpc": "https://octra.network" -} -``` +This step creates an isolated environment for the project to prevent conflicts. -3. run +1. **Create and Activate the Virtual Environment** + ```bash + python3 -m venv venv + source venv/bin/activate + ``` + *Note for **Windows** users: use `venv\Scripts\activate`* -```bash -./run.sh # on linux/mac -run.bat # on windows -``` +2. **Install Required Libraries** + This command reads the `requirements.txt` file and installs all necessary packages. + ```bash + pip install -r requirements.txt + ``` + +### ⚙️ 4. Configure Your Wallet (IMPORTANT) + +You cannot run the application until you have configured your wallet details. + +1. **Create the Configuration File** + Copy the example file to create your personal configuration file. + ```bash + cp wallet.json.example wallet.json + ``` + +2. **Edit Your Wallet Details** + Open `wallet.json` with a text editor and enter your information. + ```json + { + "priv": "YOUR_PRIVATE_KEY_HERE", + "addr": "YOUR_OCTRA_WALLET_ADDRESS_HERE", + "rpc": "[https://octra.network](https://octra.network)" + } + ``` + * **`priv`**: Your secret private key. **NEVER SHARE THIS WITH ANYONE!** + * **`addr`**: Your public wallet address, which starts with `oct...`. + +### ✅ 5. Run the Application + +Once all previous steps are complete, you are ready to run the client. + +* To run the **standard client**: + ```bash + python3 cli.py + ``` + +* To run the **v2 client** (which supports sending from a `.txt` file): + ```bash + python3 cliv2.py + diff --git a/cliv2.py b/cliv2.py new file mode 100644 index 0000000..5b55a35 --- /dev/null +++ b/cliv2.py @@ -0,0 +1,788 @@ +#!/usr/bin/env python3 +import json, base64, hashlib, time, sys, re, random, string, os, shutil, asyncio, aiohttp, threading +from datetime import datetime, timedelta +from concurrent.futures import ThreadPoolExecutor +import nacl.signing + +c = {'r': '\033[0m', 'b': '\033[34m', 'c': '\033[36m', 'g': '\033[32m', 'y': '\033[33m', 'R': '\033[31m', 'B': '\033[1m', 'bg': '\033[44m', 'bgr': '\033[41m', 'bgg': '\033[42m', 'w': '\033[37m'} + +priv, addr, rpc = None, None, None +sk, pub = None, None +b58 = re.compile(r"^oct[1-9A-HJ-NP-Za-km-z]{44}$") +μ = 1_000_000 +h = [] +cb, cn, lu, lh = None, None, 0, 0 +session = None +executor = ThreadPoolExecutor(max_workers=1) +stop_flag = threading.Event() +spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +spinner_idx = 0 + +def cls(): + os.system('cls' if os.name == 'nt' else 'clear') + +def sz(): + return shutil.get_terminal_size((80, 25)) + +def at(x, y, t, cl=''): + print(f"\033[{y};{x}H{c['bg']}{cl}{t}{c['bg']}", end='') + +def inp(x, y): + print(f"\033[{y};{x}H{c['bg']}{c['B']}{c['w']}", end='', flush=True) + return input() + +async def ainp(x, y): + print(f"\033[{y};{x}H{c['bg']}{c['B']}{c['w']}", end='', flush=True) + try: + return await asyncio.get_event_loop().run_in_executor(executor, input) + except: + stop_flag.set() + return '' + +def wait(): + cr = sz() + msg = "press enter to continue..." + msg_len = len(msg) + y_pos = cr[1] - 2 + x_pos = max(2, (cr[0] - msg_len) // 2) + at(x_pos, y_pos, msg, c['y']) + print(f"\033[{y_pos};{x_pos + msg_len}H", end='', flush=True) + input() + +async def awaitkey(): + cr = sz() + msg = "press enter to continue..." + msg_len = len(msg) + y_pos = cr[1] - 2 + x_pos = max(2, (cr[0] - msg_len) // 2) + at(x_pos, y_pos, msg, c['y']) + print(f"\033[{y_pos};{x_pos + msg_len}H{c['bg']}", end='', flush=True) + try: + await asyncio.get_event_loop().run_in_executor(executor, input) + except: + stop_flag.set() + +def ld(): + global priv, addr, rpc, sk, pub + try: + with open('wallet.json', 'r') as f: + d = json.load(f) + priv = d.get('priv') + addr = d.get('addr') + rpc = d.get('rpc', 'https://octra.network') + sk = nacl.signing.SigningKey(base64.b64decode(priv)) + pub = base64.b64encode(sk.verify_key.encode()).decode() + return True + except: + return False + +def fill(): + cr = sz() + print(f"{c['bg']}", end='') + for _ in range(cr[1]): + print(" " * cr[0]) + print("\033[H", end='') + +def box(x, y, w, h, t=""): + print(f"\033[{y};{x}H{c['bg']}{c['w']}┌{'─' * (w - 2)}┐{c['bg']}") + if t: + print(f"\033[{y};{x}H{c['bg']}{c['w']}┤ {c['B']}{t} {c['w']}├{c['bg']}") + for i in range(1, h - 1): + print(f"\033[{y + i};{x}H{c['bg']}{c['w']}│{' ' * (w - 2)}│{c['bg']}") + print(f"\033[{y + h - 1};{x}H{c['bg']}{c['w']}└{'─' * (w - 2)}┘{c['bg']}") + +async def spin_animation(x, y, msg): + global spinner_idx + try: + while True: + at(x, y, f"{c['c']}{spinner_frames[spinner_idx]} {msg}", c['c']) + spinner_idx = (spinner_idx + 1) % len(spinner_frames) + await asyncio.sleep(0.1) + except asyncio.CancelledError: + at(x, y, " " * (len(msg) + 3), "") + +async def req(m, p, d=None, t=10): + global session + if not session: + session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=t)) + try: + url = f"{rpc}{p}" + async with getattr(session, m.lower())(url, json=d if m == 'POST' else None) as resp: + text = await resp.text() + try: + j = json.loads(text) if text else None + except: + j = None + return resp.status, text, j + except asyncio.TimeoutError: + return 0, "timeout", None + except Exception as e: + return 0, str(e), None + +async def st(): + global cb, cn, lu + now = time.time() + if cb is not None and (now - lu) < 30: + return cn, cb + + results = await asyncio.gather( + req('GET', f'/balance/{addr}'), + req('GET', '/staging', 5), + return_exceptions=True + ) + + s, t, j = results[0] if not isinstance(results[0], Exception) else (0, str(results[0]), None) + s2, _, j2 = results[1] if not isinstance(results[1], Exception) else (0, None, None) + + if s == 200 and j: + cn = int(j.get('nonce', 0)) + cb = float(j.get('balance', 0)) + lu = now + if s2 == 200 and j2: + our = [tx for tx in j2.get('staged_transactions', []) if tx.get('from') == addr] + if our: + cn = max(cn, max(int(tx.get('nonce', 0)) for tx in our)) + elif s == 404: + cn, cb, lu = 0, 0.0, now + elif s == 200 and t and not j: + try: + parts = t.strip().split() + if len(parts) >= 2: + cb = float(parts[0]) if parts[0].replace('.', '').isdigit() else 0.0 + cn = int(parts[1]) if parts[1].isdigit() else 0 + lu = now + else: + cn, cb = None, None + except: + cn, cb = None, None + return cn, cb + +async def gh(): + global h, lh + now = time.time() + if now - lh < 60 and h: + return + s, t, j = await req('GET', f'/address/{addr}?limit=20') + if s != 200 or (not j and not t): + return + + if j and 'recent_transactions' in j: + tx_hashes = [ref["hash"] for ref in j.get('recent_transactions', [])] + tx_results = await asyncio.gather(*[req('GET', f'/tx/{hash}', 5) for hash in tx_hashes], return_exceptions=True) + + existing_hashes = {tx['hash'] for tx in h} + nh = [] + + for i, (ref, result) in enumerate(zip(j.get('recent_transactions', []), tx_results)): + if isinstance(result, Exception): + continue + s2, _, j2 = result + if s2 == 200 and j2 and 'parsed_tx' in j2: + p = j2['parsed_tx'] + tx_hash = ref['hash'] + + if tx_hash in existing_hashes: + continue + + ii = p.get('to') == addr + ar = p.get('amount_raw', p.get('amount', '0')) + a = float(ar) if '.' in str(ar) else int(ar) / μ + msg = None + if 'data' in j2: + try: + data = json.loads(j2['data']) + msg = data.get('message') + except: + pass + nh.append({ + 'time': datetime.fromtimestamp(p.get('timestamp', 0)), + 'hash': tx_hash, + 'amt': a, + 'to': p.get('to') if not ii else p.get('from'), + 'type': 'in' if ii else 'out', + 'ok': True, + 'nonce': p.get('nonce', 0), + 'epoch': ref.get('epoch', 0), + 'msg': msg + }) + + oh = datetime.now() - timedelta(hours=1) + h[:] = sorted(nh + [tx for tx in h if tx.get('time', datetime.now()) > oh], key=lambda x: x['time'], reverse=True)[:50] + lh = now + elif s == 404 or (s == 200 and t and 'no transactions' in t.lower()): + h.clear() + lh = now + +def mk(to, a, n, msg=None): + tx = { + "from": addr, + "to_": to, + "amount": str(int(a * μ)), + "nonce": int(n), + "ou": "1" if a < 1000 else "3", + "timestamp": time.time() + random.random() * 0.01 + } + if msg: + tx["message"] = msg + bl = json.dumps({k: v for k, v in tx.items() if k != "message"}, separators=(",", ":")) + sig = base64.b64encode(sk.sign(bl.encode()).signature).decode() + tx.update(signature=sig, public_key=pub) + return tx, hashlib.sha256(bl.encode()).hexdigest() + +async def snd(tx): + t0 = time.time() + s, t, j = await req('POST', '/send-tx', tx) + dt = time.time() - t0 + if s == 200: + if j and j.get('status') == 'accepted': + return True, j.get('tx_hash', ''), dt, j + elif t.lower().startswith('ok'): + return True, t.split()[-1], dt, None + return False, json.dumps(j) if j else t, dt, j + +async def expl(x, y, w, hb): + box(x, y, w, hb, "wallet explorer") + n, b = await st() + await gh() + at(x + 2, y + 2, "address:", c['c']) + at(x + 11, y + 2, addr, c['w']) + at(x + 2, y + 3, "balance:", c['c']) + at(x + 11, y + 3, f"{b:.6f} oct" if b is not None else "---", c['B'] + c['g'] if b else c['w']) + at(x + 2, y + 4, "nonce: ", c['c']) + at(x + 11, y + 4, str(n) if n is not None else "---", c['w']) + at(x + 2, y + 5, "public: ", c['c']) + at(x + 11, y + 5, pub, c['w']) + _, _, j = await req('GET', '/staging', 2) + sc = len([tx for tx in j.get('staged_transactions', []) if tx.get('from') == addr]) if j else 0 + at(x + 2, y + 6, "staging:", c['c']) + at(x + 11, y + 6, f"{sc} pending" if sc else "none", c['y'] if sc else c['w']) + at(x + 1, y + 7, "─" * (w - 2), c['w']) + + at(x + 2, y + 8, "recent transactions:", c['B'] + c['c']) + if not h: + at(x + 2, y + 10, "no transactions yet", c['y']) + else: + at(x + 2, y + 10, "time type amount address", c['c']) + at(x + 2, y + 11, "─" * (w - 4), c['w']) + seen_hashes = set() + display_count = 0 + sorted_h = sorted(h, key=lambda x: x['time'], reverse=True) + for tx in sorted_h: + if tx['hash'] in seen_hashes: + continue + seen_hashes.add(tx['hash']) + if display_count >= min(len(h), hb - 15): + break + is_pending = not tx.get('epoch') + time_color = c['y'] if is_pending else c['w'] + at(x + 2, y + 12 + display_count, tx['time'].strftime('%H:%M:%S'), time_color) + at(x + 11, y + 12 + display_count, " in" if tx['type'] == 'in' else "out", c['g'] if tx['type'] == 'in' else c['R']) + at(x + 16, y + 12 + display_count, f"{float(tx['amt']):>10.6f}", c['w']) + at(x + 28, y + 12 + display_count, str(tx.get('to', '---')), c['y']) + if tx.get('msg'): + at(x + 77, y + 12 + display_count, "msg", c['c']) + status_text = "pen" if is_pending else f"e{tx.get('epoch', 0)}" + status_color = c['y'] + c['B'] if is_pending else c['c'] + at(x + w - 6, y + 12 + display_count, status_text, status_color) + display_count += 1 + +def menu(x, y, w, h): + box(x, y, w, h, "commands") + at(x + 2, y + 3, "[1] send tx", c['w']) + at(x + 2, y + 5, "[2] refresh balance", c['w']) + at(x + 2, y + 7, "[3] multi send", c['w']) + at(x + 2, y + 9, "[4] export keys", c['w']) + at(x + 2, y + 11, "[5] clear hist", c['w']) + at(x + 2, y + 13, "[6] multi send from file", c['w']) + at(x + 2, y + 15, "[0] exit", c['w']) + at(x + 2, y + h - 2, "command: ", c['B'] + c['y']) + +async def scr(): + cr = sz() + cls() + fill() + t = f" octra pre-client v0.0.13 (dev) │ {datetime.now().strftime('%H:%M:%S')} " + at((cr[0] - len(t)) // 2, 1, t, c['B'] + c['w']) + + sidebar_w = 28 + menu(2, 3, sidebar_w, 19) + + info_y = 23 + box(2, info_y, sidebar_w, 9) + at(4, info_y + 2, "testnet environment.", c['y']) + at(4, info_y + 3, "actively updated.", c['y']) + at(4, info_y + 4, "monitor changes!", c['y']) + at(4, info_y + 5, "", c['y']) + at(4, info_y + 6, "testnet tokens have", c['y']) + at(4, info_y + 7, "no commercial value.", c['y']) + + explorer_x = sidebar_w + 4 + explorer_w = cr[0] - explorer_x - 2 + await expl(explorer_x, 3, explorer_w, cr[1] - 6) + + at(2, cr[1] - 1, " " * (cr[0] - 4), c['bg']) + at(2, cr[1] - 1, "ready", c['bgg'] + c['w']) + return await ainp(13, 20) + +async def tx(): + cr = sz() + cls() + fill() + w, hb = 85, 26 + x = (cr[0] - w) // 2 + y = (cr[1] - hb) // 2 + box(x, y, w, hb, "send transaction") + at(x + 2, y + 2, "to address: (or [esc] to cancel)", c['y']) + at(x + 2, y + 3, "─" * (w - 4), c['w']) + to = await ainp(x + 2, y + 4) + if not to or to.lower() == 'esc': + return + if not b58.match(to): + at(x + 2, y + 14, "invalid address!", c['bgr'] + c['w']) + at(x + 2, y + 15, "press enter to go back...", c['y']) + await ainp(x + 2, y + 16) + return + at(x + 2, y + 5, f"to: {to}", c['g']) + at(x + 2, y + 7, "amount: (or [esc] to cancel)", c['y']) + at(x + 2, y + 8, "─" * (w - 4), c['w']) + a = await ainp(x + 2, y + 9) + if not a or a.lower() == 'esc': + return + if not re.match(r"^\d+(\.\d+)?$", a) or float(a) <= 0: + at(x + 2, y + 14, "invalid amount!", c['bgr'] + c['w']) + at(x + 2, y + 15, "press enter to go back...", c['y']) + await ainp(x + 2, y + 16) + return + a = float(a) + at(x + 2, y + 10, f"amount: {a:.6f} oct", c['g']) + at(x + 2, y + 12, "message (optional, max 1024): (or enter to skip)", c['y']) + at(x + 2, y + 13, "─" * (w - 4), c['w']) + msg = await ainp(x + 2, y + 14) + if not msg: + msg = None + elif len(msg) > 1024: + msg = msg[:1024] + at(x + 2, y + 15, "message truncated to 1024 chars", c['y']) + + global lu + lu = 0 + n, b = await st() + if n is None: + at(x + 2, y + 17, "failed to get nonce!", c['bgr'] + c['w']) + at(x + 2, y + 18, "press enter to go back...", c['y']) + await ainp(x + 2, y + 19) + return + if not b or b < a: + at(x + 2, y + 17, f"insufficient balance ({b:.6f} < {a})", c['bgr'] + c['w']) + at(x + 2, y + 18, "press enter to go back...", c['y']) + await ainp(x + 2, y + 19) + return + at(x + 2, y + 16, "─" * (w - 4), c['w']) + at(x + 2, y + 17, f"send {a:.6f} oct", c['B'] + c['g']) + at(x + 2, y + 18, f"to: {to}", c['g']) + if msg: + at(x + 2, y + 19, f"msg: {msg[:50]}{'...' if len(msg) > 50 else ''}", c['c']) + at(x + 2, y + 20, f"fee: {'0.001' if a < 1000 else '0.003'} oct (nonce: {n + 1})", c['y']) + at(x + 2, y + 21, "[y]es / [n]o: ", c['B'] + c['y']) + if (await ainp(x + 16, y + 21)).strip().lower() != 'y': + return + + spin_task = asyncio.create_task(spin_animation(x + 2, y + 22, "sending transaction")) + + t, _ = mk(to, a, n + 1, msg) + ok, hs, dt, r = await snd(t) + + spin_task.cancel() + try: + await spin_task + except asyncio.CancelledError: + pass + + if ok: + for i in range(17, 25): + at(x + 2, y + i, " " * (w - 4), c['bg']) + at(x + 2, y + 20, f"✓ transaction accepted!", c['bgg'] + c['w']) + at(x + 2, y + 21, f"hash: {hs[:64]}...", c['g']) + at(x + 2, y + 22, f" {hs[64:]}", c['g']) + at(x + 2, y + 23, f"time: {dt:.2f}s", c['w']) + if r and 'pool_info' in r: + at(x + 2, y + 24, f"pool: {r['pool_info'].get('total_pool_size', 0)} txs pending", c['y']) + h.append({ + 'time': datetime.now(), + 'hash': hs, + 'amt': a, + 'to': to, + 'type': 'out', + 'ok': True, + 'msg': msg + }) + lu = 0 + else: + at(x + 2, y + 20, f"✗ transaction failed!", c['bgr'] + c['w']) + at(x + 2, y + 21, f"error: {str(hs)[:w - 10]}", c['R']) + await awaitkey() + +async def multi(): + cr = sz() + cls() + fill() + w, hb = 70, cr[1] - 4 + x = (cr[0] - w) // 2 + y = 2 + box(x, y, w, hb, "multi send") + at(x + 2, y + 2, "enter recipients (address amount), empty line to finish:", c['y']) + at(x + 2, y + 3, "type [esc] to cancel", c['c']) + at(x + 2, y + 4, "─" * (w - 4), c['w']) + rcp = [] + tot = 0 + ly = y + 5 + while ly < y + hb - 8: + at(x + 2, ly, f"[{len(rcp) + 1}] ", c['c']) + l = await ainp(x + 7, ly) + if l.lower() == 'esc': + return + if not l: + break + p = l.split() + if len(p) == 2 and b58.match(p[0]) and re.match(r"^\d+(\.\d+)?$", p[1]) and float(p[1]) > 0: + a = float(p[1]) + rcp.append((p[0], a)) + tot += a + at(x + 50, ly, f"+{a:.6f}", c['g']) + ly += 1 + else: + at(x + 50, ly, "invalid!", c['R']) + if not rcp: + return + at(x + 2, y + hb - 7, "─" * (w - 4), c['w']) + at(x + 2, y + hb - 6, f"total: {tot:.6f} oct to {len(rcp)} addresses", c['B'] + c['y']) + global lu + lu = 0 + n, b = await st() + if n is None: + at(x + 2, y + hb - 5, "failed to get nonce!", c['bgr'] + c['w']) + at(x + 2, y + hb - 4, "press enter to go back...", c['y']) + await ainp(x + 2, y + hb - 3) + return + if not b or b < tot: + at(x + 2, y + hb - 5, f"insufficient balance! ({b:.6f} < {tot})", c['bgr'] + c['w']) + at(x + 2, y + hb - 4, "press enter to go back...", c['y']) + await ainp(x + 2, y + hb - 3) + return + at(x + 2, y + hb - 5, f"send all? [y/n] (starting nonce: {n + 1}): ", c['y']) + if (await ainp(x + 48, y + hb - 5)).strip().lower() != 'y': + return + + spin_task = asyncio.create_task(spin_animation(x + 2, y + hb - 3, "sending transactions")) + + batch_size = 5 + batches = [rcp[i:i+batch_size] for i in range(0, len(rcp), batch_size)] + s_total, f_total = 0, 0 + + for batch_idx, batch in enumerate(batches): + tasks = [] + for i, (to, a) in enumerate(batch): + idx = batch_idx * batch_size + i + at(x + 2, y + hb - 2, f"[{idx + 1}/{len(rcp)}] preparing batch...", c['c']) + t, _ = mk(to, a, n + 1 + idx) + tasks.append(snd(t)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + for i, (result, (to, a)) in enumerate(zip(results, batch)): + idx = batch_idx * batch_size + i + if isinstance(result, Exception): + f_total += 1 + at(x + 55, y + hb - 2, "✗ fail ", c['R']) + else: + ok, hs, _, _ = result + if ok: + s_total += 1 + at(x + 55, y + hb - 2, "✓ ok ", c['g']) + h.append({ + 'time': datetime.now(), + 'hash': hs, + 'amt': a, + 'to': to, + 'type': 'out', + 'ok': True + }) + else: + f_total += 1 + at(x + 55, y + hb - 2, "✗ fail ", c['R']) + at(x + 2, y + hb - 2, f"[{idx + 1}/{len(rcp)}] {a:.6f} to {to[:20]}...", c['c']) + await asyncio.sleep(0.05) + + spin_task.cancel() + try: + await spin_task + except asyncio.CancelledError: + pass + + lu = 0 + at(x + 2, y + hb - 2, " " * 65, c['bg']) + at(x + 2, y + hb - 2, f"completed: {s_total} success, {f_total} failed", c['bgg'] + c['w'] if f_total == 0 else c['bgr'] + c['w']) + await awaitkey() + +async def multi_from_file(): + cr = sz() + cls() + fill() + w, hb = 70, cr[1] - 4 + x = (cr[0] - w) // 2 + y = 2 + box(x, y, w, hb, "multi send from file") + at(x + 2, y + 2, "enter path to txt file:", c['y']) + at(x + 2, y + 3, "type [esc] to cancel", c['c']) + at(x + 2, y + 4, "─" * (w - 4), c['w']) + + file_path = await ainp(x + 2, y + 5) + if not file_path or file_path.lower() == 'esc': + return + + try: + with open(file_path, 'r') as f: + lines = f.readlines() + except FileNotFoundError: + at(x + 2, y + 7, "file not found!", c['bgr'] + c['w']) + await awaitkey() + return + except Exception as e: + at(x + 2, y + 7, f"error reading file: {e}", c['bgr'] + c['w']) + await awaitkey() + return + + rcp = [] + tot = 0 + ly = y + 7 + + at(x + 2, y + 6, "─" * (w - 4), c['w']) + + # This part is updated to only use spaces as separator + wallet_line_re = re.compile(r"^\s*(oct[1-9A-HJ-NP-Za-km-z]{44})\s+(\d*\.?\d+)\s*$") + + for line in lines: + line = line.strip() + if not line: + continue + + match = wallet_line_re.match(line) + + if match: + addr_to, amount_str = match.groups() + a = float(amount_str) + if a > 0: + rcp.append((addr_to, a)) + tot += a + at(x + 2, ly, f"✓ {addr_to[:20]}... {a:.6f}", c['g']) + ly += 1 + else: + at(x + 2, ly, f"✗ zero amount: {line[:30]}...", c['R']) + ly += 1 + else: + at(x + 2, ly, f"✗ invalid line: {line[:30]}...", c['R']) + ly += 1 + + if ly >= y + hb - 8: + at(x+2, ly, "too many lines to display...", c['y']) + break + + if not rcp: + at(x + 2, ly, "no valid recipients found in file.", c['R']) + await awaitkey() + return + + at(x + 2, y + hb - 7, "─" * (w - 4), c['w']) + at(x + 2, y + hb - 6, f"total: {tot:.6f} oct to {len(rcp)} addresses", c['B'] + c['y']) + + global lu + lu = 0 + n, b = await st() + if n is None: + at(x + 2, y + hb - 5, "failed to get nonce!", c['bgr'] + c['w']) + await awaitkey() + return + + if not b or b < tot: + at(x + 2, y + hb - 5, f"insufficient balance! ({b:.6f} < {tot})", c['bgr'] + c['w']) + await awaitkey() + return + + at(x + 2, y + hb - 5, f"send all? [y/n] (starting nonce: {n + 1}): ", c['y']) + if (await ainp(x + 48, y + hb - 5)).strip().lower() != 'y': + return + + spin_task = asyncio.create_task(spin_animation(x + 2, y + hb - 3, "sending transactions")) + + batch_size = 5 + batches = [rcp[i:i+batch_size] for i in range(0, len(rcp), batch_size)] + s_total, f_total = 0, 0 + + for batch_idx, batch in enumerate(batches): + tasks = [] + for i, (to, a) in enumerate(batch): + idx = batch_idx * batch_size + i + at(x + 2, y + hb - 2, f"[{idx + 1}/{len(rcp)}] preparing batch...", c['c']) + t, _ = mk(to, a, n + 1 + idx) + tasks.append(snd(t)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + for i, (result, (to, a)) in enumerate(zip(results, batch)): + idx = batch_idx * batch_size + i + if isinstance(result, Exception): + f_total += 1 + at(x + 55, y + hb - 2, "✗ fail ", c['R']) + else: + ok, hs, _, _ = result + if ok: + s_total += 1 + at(x + 55, y + hb - 2, "✓ ok ", c['g']) + h.append({ + 'time': datetime.now(), + 'hash': hs, + 'amt': a, + 'to': to, + 'type': 'out', + 'ok': True + }) + else: + f_total += 1 + at(x + 55, y + hb - 2, "✗ fail ", c['R']) + at(x + 2, y + hb - 2, f"[{idx + 1}/{len(rcp)}] {a:.6f} to {to[:20]}...", c['c']) + await asyncio.sleep(0.05) + + spin_task.cancel() + try: + await spin_task + except asyncio.CancelledError: + pass + + lu = 0 + at(x + 2, y + hb - 2, " " * 65, c['bg']) + at(x + 2, y + hb - 2, f"completed: {s_total} success, {f_total} failed", c['bgg'] + c['w'] if f_total == 0 else c['bgr'] + c['w']) + await awaitkey() + +async def exp(): + cr = sz() + cls() + fill() + w, hb = 70, 15 + x = (cr[0] - w) // 2 + y = (cr[1] - hb) // 2 + box(x, y, w, hb, "export keys") + + at(x + 2, y + 2, "current wallet info:", c['c']) + at(x + 2, y + 4, "address:", c['c']) + at(x + 11, y + 4, addr[:32] + "...", c['w']) + at(x + 2, y + 5, "balance:", c['c']) + n, b = await st() + at(x + 11, y + 5, f"{b:.6f} oct" if b is not None else "---", c['g']) + + at(x + 2, y + 7, "export options:", c['y']) + at(x + 2, y + 8, "[1] show private key", c['w']) + at(x + 2, y + 9, "[2] save full wallet to file", c['w']) + at(x + 2, y + 10, "[3] copy address to clipboard", c['w']) + at(x + 2, y + 11, "[0] cancel", c['w']) + at(x + 2, y + 13, "choice: ", c['B'] + c['y']) + + choice = await ainp(x + 10, y + 13) + choice = choice.strip() + + if choice == '1': + at(x + 2, y + 7, " " * (w - 4), c['bg']) + at(x + 2, y + 8, " " * (w - 4), c['bg']) + at(x + 2, y + 9, " " * (w - 4), c['bg']) + at(x + 2, y + 10, " " * (w - 4), c['bg']) + at(x + 2, y + 11, " " * (w - 4), c['bg']) + at(x + 2, y + 13, " " * (w - 4), c['bg']) + + at(x + 2, y + 7, "private key (keep secret!):", c['R']) + at(x + 2, y + 8, priv[:32], c['R']) + at(x + 2, y + 9, priv[32:], c['R']) + at(x + 2, y + 11, "public key:", c['g']) + at(x + 2, y + 12, pub[:44] + "...", c['g']) + await awaitkey() + + elif choice == '2': + fn = f"octra_wallet_{int(time.time())}.json" + wallet_data = { + 'priv': priv, + 'addr': addr, + 'rpc': rpc + } + with open(fn, 'w') as f: + json.dump(wallet_data, f, indent=2) + at(x + 2, y + 7, " " * (w - 4), c['bg']) + at(x + 2, y + 8, " " * (w - 4), c['bg']) + at(x + 2, y + 9, " " * (w - 4), c['bg']) + at(x + 2, y + 10, " " * (w - 4), c['bg']) + at(x + 2, y + 11, " " * (w - 4), c['bg']) + at(x + 2, y + 13, " " * (w - 4), c['bg']) + at(x + 2, y + 9, f"saved to {fn}", c['g']) + at(x + 2, y + 11, "file contains private key - keep safe!", c['R']) + await awaitkey() + + elif choice == '3': + try: + import pyperclip + pyperclip.copy(addr) + at(x + 2, y + 7, " " * (w - 4), c['bg']) + at(x + 2, y + 9, "address copied to clipboard!", c['g']) + except: + at(x + 2, y + 7, " " * (w - 4), c['bg']) + at(x + 2, y + 9, "clipboard not available", c['R']) + at(x + 2, y + 11, " " * (w - 4), c['bg']) + await awaitkey() + +async def main(): + global session + + if not ld(): + sys.exit("[!] wallet.json error") + if not addr: + sys.exit("[!] wallet.json not configured") + + try: + await st() + await gh() + + while not stop_flag.is_set(): + cmd = await scr() + if cmd == '1': + await tx() + elif cmd == '2': + global lu, lh + lu = lh = 0 + await st() + await gh() + elif cmd == '3': + await multi() + elif cmd == '4': + await exp() + elif cmd == '5': + h.clear() + lh = 0 + elif cmd == '6': + await multi_from_file() + elif cmd in ['0', 'q', '']: + break + except: + pass + finally: + if session: + await session.close() + executor.shutdown(wait=False) + +if __name__ == "__main__": + import warnings + warnings.filterwarnings("ignore", category=ResourceWarning) + + try: + asyncio.run(main()) + except: + pass + finally: + cls() + print(f"{c['r']}") + os._exit(0) diff --git a/wallets.txt b/wallets.txt new file mode 100644 index 0000000..14bc676 --- /dev/null +++ b/wallets.txt @@ -0,0 +1,20 @@ +octGhW9hQtEo4UTW7RbtsV3YFJTr9zYNYbjdgRt2VErfEoH 0.080000 +oct2unpupf7yJk46TqpN8PQwvc3RdH19UW5spN3XrZP3HJn 0.070000 +octB5gVMxC8uuiaVaT525kJApqyfYqv6aAPfhFK26LwN99o 0.080000 +octDKb9tR36dvo6grTKWU43CSxux2Mdicadk6xusZA7EGEq 0.080000 +oct4M33BxGEUXsdUDLgt9tpZx64NYwd5FkW6QMV3Pei7hGa 0.080000 +octEcg4AsKg9FCLvn2rCrq7EzfXoHoxhdwHjsJBZXozfJqN 0.060000 +octGgpRQ4SNR9c65xsaJtBUcNV4AXxxmvj2X7eGPCLBrn7q 0.060000 +oct2unpupf7yJk46TqpN8PQwvc3RdH19UW5spN3XrZP3HJn 0.060000 +oct9fWbK183xcbk8LcvBkk2ANfLaJxVyDDJFozNU1iGWRbe 0.070000 +octApcengt6Tegjfyb6BypkS38zfuKoxNQdekmsMEfYMMUH 0.060000 +octDDzcjEsNPatb7RuyHK817apmxi4TShsmAgzXcHuhztFL 0.040000 +octE1fQc1jAghcTfEZ4bMirdhBCNQJpEoJjDFzRw7EPYvMn 0.040000 +octDb5Syeu9ixj3ADMgShDHjen6c9gKs8s5SknMDEjBLmPw 0.040000 +octEWCSXTAhDF8TB3EUudhpYv9MpQuihcVZzfh1RgHJ1xdy 0.050000 +oct3gwBJriMgGhxodcrEb7VTWKsVDzh8Y3ieQjoX5L4UvMz 0.050000 +oct7eP3Rq1jn4ssUcz2a7WQWHrvsVKuHcYrhAtqxdu7Mk9J 0.020000 +octB5gVMxC8uuiaVaT525kJApqyfYqv6aAPfhFK26LwN99o 0.030000 +octB5gVMxC8uuiaVaT525kJApqyfYqv6aAPfhFK26LwN99o 1 +octHWiYYJUtz262FU4Lpy9CeWU9E9usqCEVRoy2Bbv2Xejn 2 +octAqzfjMY3qGwf5aQwhjoE9Vk38zRyWe4hy7udUkDoK8Sj 3