commit ab537a8f73d1563b5928e33b23c8f392008ca6e0 Author: goyban Date: Sun Apr 26 16:44:21 2026 +0000 Initial commit: Electrum Telegram wallet bot with Tor support Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c7d60ea --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Electrum version to install. +ELECTRUM_VERSION=4.7.2 + +# Telegram bot token from @BotFather +TELEGRAM_BOT_TOKEN=your_bot_token_here diff --git a/.env.tor.example b/.env.tor.example new file mode 100644 index 0000000..2ac9714 --- /dev/null +++ b/.env.tor.example @@ -0,0 +1,17 @@ +# Electrum version to install. +ELECTRUM_VERSION=4.7.2 + +# Telegram bot token from @BotFather +TELEGRAM_BOT_TOKEN=your_bot_token_here + +# ── Tor / server options ────────────────────────────────────────────────────── + +# Option A — connect to your own Electrum server over Tor. +# Set this to your server's .onion address in host:port:protocol format. +# Protocol: t = TCP (unencrypted, Tor layer handles it), s = SSL. +# Example: +# ELECTRUM_SERVER=mypersonalnode123.onion:50001:t + +# Option B — leave ELECTRUM_SERVER unset to use a public .onion server. +# The default is electrums3lojbuj.onion:50001:t, but you can override it: +# ELECTRUM_ONION_SERVER=electrums3lojbuj.onion:50001:t diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83a921e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +*.pyc +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6ebebe1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM debian:bookworm-slim + +ARG VERSION=4.7.2 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget curl \ + gnupg \ + ca-certificates \ + python3 \ + python3-venv \ + python3-cryptography \ + libsecp256k1-dev \ + build-essential \ + pkg-config \ + autoconf \ + automake \ + libtool \ + procps \ + && rm -rf /var/lib/apt/lists/* + +COPY install.sh /usr/local/bin/install-electrum.sh +RUN chmod +x /usr/local/bin/install-electrum.sh \ + && /usr/local/bin/install-electrum.sh "${VERSION}" + +COPY requirements.txt /app/requirements.txt +RUN /opt/electrum/bin/pip install --quiet -r /app/requirements.txt + +COPY bot.py /app/bot.py +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +WORKDIR /root + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/Dockerfile.tor b/Dockerfile.tor new file mode 100644 index 0000000..b31c85b --- /dev/null +++ b/Dockerfile.tor @@ -0,0 +1,35 @@ +FROM debian:bookworm-slim + +ARG VERSION=4.7.2 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget curl \ + gnupg \ + ca-certificates \ + python3 \ + python3-venv \ + python3-cryptography \ + libsecp256k1-dev \ + build-essential \ + pkg-config \ + autoconf \ + automake \ + libtool \ + procps \ + tor \ + && rm -rf /var/lib/apt/lists/* + +COPY install.sh /usr/local/bin/install-electrum.sh +RUN chmod +x /usr/local/bin/install-electrum.sh \ + && /usr/local/bin/install-electrum.sh "${VERSION}" + +COPY requirements.txt /app/requirements.txt +RUN /opt/electrum/bin/pip install --quiet -r /app/requirements.txt + +COPY bot.py /app/bot.py +COPY entrypoint.tor.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +WORKDIR /root + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.fa.md b/README.fa.md new file mode 100644 index 0000000..af7bb7b --- /dev/null +++ b/README.fa.md @@ -0,0 +1,243 @@ +# ربات کیف‌پول آنلاین + +یک ربات تلگرام خودمیزبان برای مدیریت کیف‌پول‌های بیت‌کوین با استفاده از Electrum. طراحی‌شده برای کیف‌پول‌های فقط-تماشا (xpub) با پشتیبانی از امضای آفلاین (air-gapped). + +## قابلیت‌ها + +- کیف‌پول جداگانه برای هر کاربر +- بازیابی کیف‌پول فقط-تماشا از xpub +- نمایش موجودی (تأییدشده / تأییدنشده) +- دریافت آدرس +- ساخت تراکنش امضانشده (PSBT) با انتخاب کارمزد +- تغییر واحد: BTC / mBTC / ساتوشی +- پشتیبانی از زبان فارسی و انگلیسی +- کیبورد ثابت در پایین چت +- پشتیبانی اختیاری از Tor (اتصال از طریق .onion به سرور عمومی یا نود شخصی) + +--- + +## پیش‌نیازها + +- یک سرور لینوکسی با Docker و Docker Compose نصب‌شده +- توکن ربات تلگرام (از [@BotFather](https://t.me/BotFather)) +- شناسه کاربری تلگرام شما (از [@userinfobot](https://t.me/userinfobot)) + +--- + +## راه‌اندازی سرور (گام‌به‌گام) + +### ۱. کلون کردن مخزن + +```bash +git clone https://git.goyban.com/goyban/telegtrum_bot.git +cd telegtrum_bot +``` + +### ۲. ساخت فایل `.env` + +```bash +cp .env.example .env +nano .env +``` + +مقادیر را پر کنید: + +``` +ELECTRUM_VERSION=4.7.2 +TELEGRAM_BOT_TOKEN=توکن_ربات_شما +``` + +### ۳. افزودن شناسه کاربری تلگرام به لیست کاربران مجاز + +```bash +nano allowed_users.txt +``` + +یک خط به این فرمت اضافه کنید: + +``` +123456789, علی +``` + +شناسه کاربری خود را می‌توانید با ارسال پیام به [@userinfobot](https://t.me/userinfobot) در تلگرام پیدا کنید. + +### ۴. ساخت و راه‌اندازی کانتینر + +```bash +docker compose up -d --build +``` + +این دستور: +- ایمیج Docker با Electrum نصب‌شده را می‌سازد +- دیمن Electrum را راه‌اندازی می‌کند +- ربات تلگرام را اجرا می‌کند + +### ۵. بررسی لاگ‌ها + +```bash +docker compose logs -f +``` + +باید پیام `Electrum daemon started. Launching bot...` را ببینید. + +--- + +## حالت Tor + +حالت Tor، Electrum را از طریق شبکه Tor اجرا می‌کند. از یک فایل Docker Compose و Dockerfile جداگانه استفاده می‌کند تا راه‌اندازی استاندارد تحت تأثیر قرار نگیرد. + +### راه‌اندازی + +```bash +cp .env.tor.example .env +nano .env +``` + +`TELEGRAM_BOT_TOKEN` را پر کنید و یکی از حالت‌های اتصال را انتخاب کنید: + +**گزینه الف — نود Electrum شخصی از طریق Tor:** + +``` +ELECTRUM_SERVER=yournode123.onion:50001:t +``` + +**گزینه ب — سرور عمومی .onion (پیش‌فرض، نیاز به تنظیم اضافه ندارد):** + +`ELECTRUM_SERVER` را خالی بگذارید. ربات به `electrums3lojbuj.onion:50001:t` وصل می‌شود. برای تغییر از `ELECTRUM_ONION_SERVER` استفاده کنید. + +### راه‌اندازی + +```bash +docker compose -f docker-compose.tor.yml up -d --build +``` + +### نکات + +- Tor داخل همان کانتینر اجرا می‌شود — نیازی به سرویس جداگانه نیست. +- کانتینر صبر می‌کند تا SOCKS proxy آماده شود، سپس Electrum را راه‌اندازی می‌کند. +- داده‌های کیف‌پول در یک volume جداگانه (`electrum-tor-data`) ذخیره می‌شود. +- حرف `t` در آدرس سرور به معنای TCP است — رمزنگاری توسط لایه Tor انجام می‌شود نه SSL. + +--- + +## به‌روزرسانی ربات + +فایل `bot.py` به‌صورت volume سوار شده، پس بدون نیاز به rebuild می‌توانید آن را تغییر دهید: + +```bash +# ویرایش bot.py، سپس: +docker compose restart +# یا برای حالت Tor: +docker compose -f docker-compose.tor.yml restart +``` + +برای به‌روزرسانی Electrum یا پکیج‌های سیستم: + +```bash +docker compose up -d --build +# یا برای حالت Tor: +docker compose -f docker-compose.tor.yml up -d --build +``` + +--- + +## افزودن یا حذف کاربران مجاز + +فایل `allowed_users.txt` را ویرایش کرده و ریستارت کنید: + +```bash +nano allowed_users.txt +docker compose restart +``` + +--- + +## ذخیره‌سازی داده‌ها + +داده‌های کیف‌پول در یک volume نام‌دار Docker در مسیر `/root/.electrum` داخل کانتینر ذخیره می‌شود و در ریستارت‌ها و rebuild‌ها باقی می‌ماند. + +| حالت | نام volume | +|---|---| +| استاندارد | `electrum-data` | +| Tor | `electrum-tor-data` | + +برای پشتیبان‌گیری (حالت استاندارد): + +```bash +docker run --rm -v electrum-data:/data -v $(pwd):/backup debian:bookworm-slim \ + tar czf /backup/electrum-backup.tar.gz -C /data . +``` + +--- + +## راهنمای استفاده از ربات + +### شروع کار + +ربات را در تلگرام باز کنید و هر پیامی ارسال کنید. یک کیبورد ثابت در پایین چت با تمام دکمه‌ها نمایش داده می‌شود. + +### افزودن کیف‌پول (Add Wallet) + +وارد کردن کیف‌پول فقط-تماشا از xpub: + +1. روی **Add Wallet** بزنید +2. نام کیف‌پول را وارد کنید +3. xpub خود را پیست کنید +4. رمز عبور تعیین کنید (حداقل ۸ کاراکتر، شامل حرف بزرگ، کوچک و عدد) + +### لیست (List) + +نمایش تمام کیف‌پول‌های شما. + +### بارگذاری (Load) + +بارگذاری یک کیف‌پول برای فعال‌سازی: + +1. روی **Load** بزنید +2. کیف‌پول را انتخاب کنید (اگر فقط یک کیف‌پول دارید، خودکار انتخاب می‌شود) +3. رمز عبور کیف‌پول را وارد کنید + +### موجودی (Balance) + +نمایش موجودی تأییدشده و تأییدنشده کیف‌پول فعال. + +با دکمه‌های inline زیر موجودی می‌توانید بین **BTC**، **mBTC** و **ساتوشی** تغییر دهید. + +### دریافت (Receive) + +نمایش آدرس دریافت جدید برای کیف‌پول فعال. + +### ارسال (Send) + +ساخت تراکنش امضانشده (PSBT) برای امضای آفلاین: + +1. روی **Send** بزنید +2. منبع کارمزد را انتخاب کنید: **Electrum** (پیشنهادی) یا **mempool.space** (زنده) +3. نرخ کارمزد را به صورت sat/vB وارد کنید +4. رمز عبور کیف‌پول را وارد کنید +5. آدرس گیرنده را وارد کنید +6. مقدار را وارد کنید (بر اساس واحد انتخابی) +7. تراکنش امضانشده را به‌صورت کد و فایل `.txt` دریافت کنید +8. آن را با کیف‌پول آفلاین خود امضا کنید، سپس از **Broadcast** استفاده کنید + +### بستن (Close) + +کیف‌پول فعال را از دیمن Electrum خارج می‌کند. + +### زبان + +روی **🌐 Language** بزنید تا بین فارسی و انگلیسی تغییر دهید. + +--- + +## عیب‌یابی + +**ربات پاسخ نمی‌دهد:** لاگ‌ها را با `docker compose logs -f` بررسی کنید. مطمئن شوید توکن ربات درست است و کانتینر در حال اجراست. + +**"شما مجاز نیستید":** شناسه کاربری تلگرام شما در `allowed_users.txt` نیست. آن را اضافه کرده و ریستارت کنید. + +**خطای کیف‌پول بارگذاری‌نشده:** قبل از بررسی موجودی، دریافت یا ارسال، از **Load** استفاده کنید. + +**دیمن شروع نمی‌شود:** کانتینر lockfile را هنگام راه‌اندازی خودکار حذف می‌کند. اگر مشکل ادامه داشت، `docker compose restart` را امتحان کنید. + +**حالت Tor — Electrum وصل نمی‌شود:** bootstrap اولیه Tor تا ۳۰ ثانیه طول می‌کشد. لاگ‌ها را با `docker compose -f docker-compose.tor.yml logs -f` بررسی کنید. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fb6292 --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# Online Wallet Bot + +A self-hosted Telegram bot for managing Bitcoin wallets using Electrum. Designed for watch-only (xpub) wallets with air-gapped signing support. + +## Features + +- Per-user isolated wallets +- Watch-only wallet restore from xpub +- Balance display (Confirmed / Unconfirmed) +- Receive address generation +- Unsigned transaction (PSBT) creation with fee options +- Unit switching: BTC / mBTC / sats +- Persian (Farsi) and English language support +- Persistent bottom keyboard +- Optional Tor support (connect via .onion to a public server or your own node) + +--- + +## Prerequisites + +- A Linux server with Docker and Docker Compose installed +- A Telegram bot token (from [@BotFather](https://t.me/BotFather)) +- Your Telegram user ID (from [@userinfobot](https://t.me/userinfobot)) + +--- + +## Server Setup (Step by Step) + +### 1. Clone the repository + +```bash +git clone https://git.goyban.com/goyban/telegtrum_bot.git +cd telegtrum_bot +``` + +### 2. Create your `.env` file + +```bash +cp .env.example .env +nano .env +``` + +Fill in: + +``` +ELECTRUM_VERSION=4.7.2 +TELEGRAM_BOT_TOKEN=your_bot_token_here +``` + +### 3. Add your Telegram user ID to the allowed users list + +```bash +nano allowed_users.txt +``` + +Add a line in the format `userid, Name`: + +``` +123456789, Alice +``` + +You can find your user ID by messaging [@userinfobot](https://t.me/userinfobot) on Telegram. + +### 4. Build and start the container + +```bash +docker compose up -d --build +``` + +This will: +- Build the Docker image with Electrum installed +- Start the Electrum daemon +- Launch the Telegram bot + +### 5. Check logs + +```bash +docker compose logs -f +``` + +You should see `Electrum daemon started. Launching bot...` followed by the bot starting up. + +--- + +## Tor Mode + +Tor mode runs Electrum through the Tor network. It uses a separate Docker Compose file and Dockerfile so the standard setup is not affected. + +### Setup + +```bash +cp .env.tor.example .env +nano .env +``` + +Fill in `TELEGRAM_BOT_TOKEN` and choose a connection mode: + +**Option A — your own Electrum server over Tor:** + +``` +ELECTRUM_SERVER=yournode123.onion:50001:t +``` + +**Option B — public .onion server (default, no extra config needed):** + +Leave `ELECTRUM_SERVER` unset. The bot will connect to `electrums3lojbuj.onion:50001:t` by default. Override with `ELECTRUM_ONION_SERVER` if needed. + +### Start + +```bash +docker compose -f docker-compose.tor.yml up -d --build +``` + +### Notes + +- Tor runs inside the same container — no sidecar needed. +- The container waits for the Tor SOCKS proxy to be ready before starting Electrum. +- Wallet data is stored in a separate volume (`electrum-tor-data`) so it does not mix with the standard setup. +- `t` in the server string means TCP — encryption is handled by the Tor layer, not SSL. + +--- + +## Updating the Bot + +The `bot.py` file is mounted as a volume, so you can update it without rebuilding the image: + +```bash +# Edit bot.py, then: +docker compose restart +# or for Tor mode: +docker compose -f docker-compose.tor.yml restart +``` + +To update Electrum itself or system packages: + +```bash +docker compose up -d --build +# or for Tor mode: +docker compose -f docker-compose.tor.yml up -d --build +``` + +--- + +## Adding or Removing Allowed Users + +Edit `allowed_users.txt` and restart: + +```bash +nano allowed_users.txt +docker compose restart +``` + +--- + +## Data Persistence + +Wallet data is stored in a named Docker volume at `/root/.electrum` inside the container. This persists across restarts and rebuilds. + +| Mode | Volume name | +|---|---| +| Standard | `electrum-data` | +| Tor | `electrum-tor-data` | + +To back up wallet data (standard mode): + +```bash +docker run --rm -v electrum-data:/data -v $(pwd):/backup debian:bookworm-slim \ + tar czf /backup/electrum-backup.tar.gz -C /data . +``` + +--- + +## Bot Usage Guide + +### Starting the bot + +Open the bot in Telegram and send any message. You'll see a persistent keyboard at the bottom with all available actions. + +### Add Wallet + +Import a watch-only wallet from an xpub (extended public key): + +1. Press **Add Wallet** +2. Enter a wallet name +3. Paste your xpub +4. Set a password (min 8 chars, must include upper, lower, and digit) + +### List + +Shows all your wallets. + +### Load + +Load a wallet to make it active: + +1. Press **Load** +2. Select a wallet (auto-selects if you only have one) +3. Enter the wallet password + +### Balance + +Shows the confirmed and unconfirmed balance of the loaded wallet. + +Use the inline buttons below the balance to switch between **BTC**, **mBTC**, and **sats**. + +### Receive + +Shows a fresh receiving address for the loaded wallet. + +### Send + +Creates an unsigned transaction (PSBT) for air-gapped signing: + +1. Press **Send** +2. Choose a fee source: **Electrum** (recommended) or **mempool.space** (live) +3. Enter fee rate in sat/vB +4. Enter the wallet password +5. Enter the recipient address +6. Enter the amount (in your selected unit) +7. Receive the unsigned TX as both a code block and a `.txt` file for offline signing +8. Sign it with your offline wallet, then use **Broadcast** + +### Close + +Unloads the active wallet from the Electrum daemon. + +### Language + +Press **🌐 Language** to switch between English and Persian (Farsi). + +--- + +## Troubleshooting + +**Bot not responding:** Check logs with `docker compose logs -f`. Make sure your bot token is correct and the container is running. + +**"You are not authorized":** Your Telegram user ID is not in `allowed_users.txt`. Add it and restart. + +**Wallet not loaded error:** Use **Load** before checking balance, receiving, or sending. + +**Daemon not starting:** The container removes the lockfile on startup automatically. If issues persist, try `docker compose restart`. + +**Tor mode — Electrum not connecting:** Tor bootstrap can take up to ~30 seconds on first run. Check logs with `docker compose -f docker-compose.tor.yml logs -f`. diff --git a/allowed_users.txt b/allowed_users.txt new file mode 100644 index 0000000..94194fd --- /dev/null +++ b/allowed_users.txt @@ -0,0 +1,10 @@ +# Allowed Telegram user IDs +# Format: userid, comment +# One user per line. Lines starting with # are ignored. +# +# Example: +# 123456789, Alice (admin) +# 987654321, Bob +69027304, Me (Goyban) +85794988, Momo +8031636275, TheBaaaaan \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..c035207 --- /dev/null +++ b/bot.py @@ -0,0 +1,919 @@ +#!/usr/bin/env python3 +import io +import json +import logging +import os +import subprocess +from decimal import Decimal, InvalidOperation + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, Update +from telegram.constants import ParseMode +from telegram.ext import ( + Application, + CallbackQueryHandler, + CommandHandler, + ConversationHandler, + ContextTypes, + MessageHandler, + filters, +) + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, +) +logging.getLogger("httpx").setLevel(logging.WARNING) +logger = logging.getLogger(__name__) + +TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] +ALLOWED_USERS_FILE = os.environ.get("ALLOWED_USERS_FILE", "/app/allowed_users.txt") +WALLETS_BASE = "/root/.electrum/wallets" +SETTINGS_FILE = "/root/.electrum/bot_settings.json" + + +# ── Translations ────────────────────────────────────────────────────────────── + +STRINGS: dict[str, dict[str, str]] = { + "en": { + "welcome": ( + "Welcome to Electrum Wallet Bot! 🔐\n\n" + "This bot lets you manage Bitcoin watch-only wallets using Electrum on your own server. " + "Private keys never leave your offline device.\n\n" + "What you can do:\n" + "• Restore a wallet from its master public key (xpub)\n" + "• Check balance in BTC, mBTC, or sats\n" + "• Get an unused receiving address\n" + "• Load / close wallets in the Electrum daemon\n" + "• Check network fee recommendations\n" + "• Create unsigned transactions for air-gapped signing\n" + "• Broadcast signed transactions to the network\n\n" + "Use the buttons below to get started." + ), + "not_authorized": "You are not authorized to use this bot.", + "cancelled": "Cancelled.", + "cancel_for_button": "Previous operation cancelled. Tap the button again.", + "your_wallets": "Your wallets:", + "no_wallets": "No wallets found.", + "no_wallets_restore": "No wallets found. Use 'Add Wallet' to add one.", + # Add Wallet + "add_wallet_intro": ( + "➕ Add Online (Watch-Only) Wallet\n\n" + "This adds a watch-only wallet using your master public key (xpub). " + "Your private keys never leave your offline device — the bot can only " + "view balances, generate addresses, and create unsigned transactions " + "for you to sign offline.\n\n" + "You will need:\n" + "• A wallet name\n" + "• Your master public key (xpub / zpub / ypub)\n" + "• A password to encrypt the wallet file\n\n" + "Let's start — enter a name for this wallet:" + ), + "enter_password": "🔑 Set a password for this wallet:\n\n" + "Requirements: at least 8 characters, one uppercase letter, one lowercase letter, and one number.", + "password_invalid": "❌ Password does not meet requirements.\n\n" + "It must be at least 8 characters and contain at least one uppercase letter, " + "one lowercase letter, and one number.\n\nTry again:", + "enter_xpub": "Enter master public key (xpub / zpub / ypub):", + "enter_name": "Enter wallet name:", + "restoring": "Restoring '{name}'...", + "wallet_restored": "✅ Wallet '{name}' added successfully. Use Load to activate it.", + # Wallet not loaded + "wallet_not_loaded": "⚠️ No wallet is loaded.\n\nPlease use Load to load a wallet first.", + # Load / Close + "select_wallet": "Select wallet:", + "wallet_enter_password": "Wallet: {name}\nEnter password:", + "wallet_loaded": "Wallet '{name}' loaded and selected successfully.", + "wallet_closed": "Wallet '{name}' closed successfully.", + # Balance + "balance_header": "Balance ({unit})", + "confirmed": "Confirmed:", + "unconfirmed": "Unconfirmed:", + "unmatured": "Unmatured:", + # Send + "send_menu": "Send menu:", + "fee_source": "Select fee source:", + "recommended_fee": "Recommended fee: {tip} ({desc})", + "enter_feerate": "Enter fee rate (sat/vB):", + "enter_addr": "Enter recipient address:", + "enter_amount": "Enter amount ({unit}):", + "invalid_amount": "Invalid amount: {error}\nTry again:", + "creating_tx": "Creating unsigned transaction...", + "unsigned_tx_header": "🔐 Unsigned Transaction", + "signing_instructions": ( + "Sign this with your offline wallet, then use Broadcast.\n\n" + "📱 Android (Electrum):\n" + "Send → Paste from clipboard\n\n" + "💻 Computer (Electrum):\n" + "Save the file → Send → wrench & screwdriver icon → Read text from file" + ), + "enter_signed_tx": "Paste your signed transaction:", + "broadcast_success": "✅ Broadcast successful!\n\nTXID:", + # Send submenu buttons + "btn_check_fee": "Check Mempool Fee", + "btn_send_tx": "Send", + "btn_broadcast": "Broadcast", + "btn_send_max": "Send Max (all)", + # Fee display + "fee_mempool_msg": ( + "mempool.space fees:\n" + "⚡ Fastest: {fastest} sat/vB\n" + "🕐 30 min: {half_hour} sat/vB\n" + "🕑 1 hour: {hour} sat/vB\n" + "💰 Economy: {economy} sat/vB\n" + "📉 Minimum: {minimum} sat/vB" + ), + # Language + "language_select": "Select language:", + "language_changed": "Language changed to English 🇬🇧", + # Generic + "error": "Error:\n{error}", + "unknown_op": "Unknown operation.", + "done": "Done.", + }, + "fa": { + "welcome": ( + "به ربات کیف پول الکترام خوش آمدید! 🔐\n\n" + "این ربات مدیریت کیف پول‌های بیتکوین را از طریق الکترام روی سرور شخصی شما فراهم می‌کند. " + "کلیدهای خصوصی هرگز دستگاه آفلاین شما را ترک نمی‌کنند.\n\n" + "امکانات:\n" + "• بازیابی کیف پول از کلید عمومی اصلی (xpub)\n" + "• مشاهده موجودی به BTC، mBTC یا ساتوشی\n" + "• دریافت آدرس استفاده نشده\n" + "• بارگذاری و بستن کیف پول در Electrum daemon\n" + "• بررسی کارمزد شبکه\n" + "• ایجاد تراکنش بدون امضا برای امضای آفلاین\n" + "• ارسال تراکنش امضا شده به شبکه\n\n" + "از دکمه‌های زیر شروع کنید." + ), + "not_authorized": "شما مجاز به استفاده از این ربات نیستید.", + "cancelled": "لغو شد.", + "cancel_for_button": "عملیات قبلی لغو شد. دوباره دکمه را فشار دهید.", + "your_wallets": "کیف پول‌های شما:", + "no_wallets": "کیف پولی یافت نشد.", + "no_wallets_restore": "کیف پولی یافت نشد. از 'Add Wallet' برای افزودن استفاده کنید.", + # Add Wallet + "add_wallet_intro": ( + "➕ افزودن کیف پول آنلاین (فقط مشاهده)\n\n" + "این کیف پول با استفاده از کلید عمومی اصلی (xpub) شما ایجاد می‌شود. " + "کلیدهای خصوصی هرگز دستگاه آفلاین شما را ترک نمی‌کنند — ربات فقط می‌تواند " + "موجودی را مشاهده کند، آدرس تولید کند و تراکنش‌های بدون امضا ایجاد کند.\n\n" + "به موارد زیر نیاز دارید:\n" + "• نام کیف پول\n" + "• کلید عمومی اصلی (xpub / zpub / ypub)\n" + "• رمز عبور برای رمزگذاری فایل کیف پول\n\n" + "بیایید شروع کنیم — نامی برای این کیف پول وارد کنید:" + ), + "enter_password": "🔑 رمز عبور این کیف پول را تعیین کنید:\n\n" + "الزامات: حداقل ۸ کاراکتر، یک حرف بزرگ، یک حرف کوچک و یک عدد.", + "password_invalid": "❌ رمز عبور الزامات را برآورده نمی‌کند.\n\n" + "باید حداقل ۸ کاراکتر داشته باشد و شامل حداقل یک حرف بزرگ، " + "یک حرف کوچک و یک عدد باشد.\n\nدوباره امتحان کنید:", + "enter_xpub": "کلید عمومی اصلی (xpub / zpub / ypub) را وارد کنید:", + "enter_name": "نام کیف پول را وارد کنید:", + "restoring": "در حال افزودن '{name}'...", + "wallet_restored": "✅ کیف پول '{name}' با موفقیت افزوده شد. برای فعال‌سازی از Load استفاده کنید.", + # Wallet not loaded + "wallet_not_loaded": "⚠️ هیچ کیف پولی بارگذاری نشده است.\n\nلطفاً ابتدا از Load برای بارگذاری کیف پول استفاده کنید.", + # Load / Close + "select_wallet": "کیف پول را انتخاب کنید:", + "wallet_enter_password": "کیف پول: {name}\nرمز عبور را وارد کنید:", + "wallet_loaded": "کیف پول '{name}' با موفقیت بارگذاری و انتخاب شد.", + "wallet_closed": "کیف پول '{name}' با موفقیت بسته شد.", + # Balance + "balance_header": "موجودی ({unit})", + "confirmed": "تأیید شده:", + "unconfirmed": "تأیید نشده:", + "unmatured": "بلوغ نیافته:", + # Send + "send_menu": "منوی ارسال:", + "fee_source": "منبع کارمزد را انتخاب کنید:", + "recommended_fee": "کارمزد پیشنهادی: {tip} ({desc})", + "enter_feerate": "نرخ کارمزد را وارد کنید (sat/vB):", + "enter_addr": "آدرس گیرنده را وارد کنید:", + "enter_amount": "مقدار ({unit}) را وارد کنید:", + "invalid_amount": "مقدار نامعتبر: {error}\nدوباره وارد کنید:", + "creating_tx": "در حال ایجاد تراکنش بدون امضا...", + "unsigned_tx_header": "🔐 تراکنش بدون امضا", + "signing_instructions": ( + "این تراکنش را با کیف پول آفلاین خود امضا کنید، سپس از Broadcast استفاده کنید.\n\n" + "📱 اندروید (الکترام):\n" + "Send → Paste from clipboard\n\n" + "💻 کامپیوتر (الکترام):\n" + "فایل را ذخیره کنید → Send → آیکون آچار و پیچ‌گوشتی → Read text from file" + ), + "enter_signed_tx": "تراکنش امضا شده را الصاق کنید:", + "broadcast_success": "✅ تراکنش با موفقیت ارسال شد!\n\nشناسه تراکنش:", + # Send submenu buttons + "btn_check_fee": "بررسی کارمزد", + "btn_send_tx": "ارسال", + "btn_broadcast": "انتشار", + "btn_send_max": "ارسال حداکثر (همه)", + # Fee display + "fee_mempool_msg": ( + "کارمزدهای mempool.space:\n" + "⚡ سریع‌ترین: {fastest} sat/vB\n" + "🕐 ۳۰ دقیقه: {half_hour} sat/vB\n" + "🕑 ۱ ساعت: {hour} sat/vB\n" + "💰 اقتصادی: {economy} sat/vB\n" + "📉 حداقل: {minimum} sat/vB" + ), + # Language + "language_select": "زبان را انتخاب کنید:", + "language_changed": "زبان به فارسی تغییر یافت 🇮🇷", + # Generic + "error": "خطا:\n{error}", + "unknown_op": "عملیات نامشخص.", + "done": "انجام شد.", + }, +} + + +def t(user_id: int, key: str, **kwargs) -> str: + lang = get_user_lang(user_id) + text = STRINGS.get(lang, STRINGS["en"]).get(key) or STRINGS["en"].get(key, key) + try: + return text.format(**kwargs) if kwargs else text + except KeyError: + return text + + +# ── Settings persistence ────────────────────────────────────────────────────── + +def _load_settings() -> dict: + try: + with open(SETTINGS_FILE) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_settings(settings: dict) -> None: + os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True) + with open(SETTINGS_FILE, "w") as f: + json.dump(settings, f) + + +def get_user_unit(user_id: int) -> str: + return _load_settings().get(str(user_id), {}).get("unit", "BTC") + + +def set_user_unit(user_id: int, unit: str) -> None: + s = _load_settings() + s.setdefault(str(user_id), {})["unit"] = unit + _save_settings(s) + + +def get_user_lang(user_id: int) -> str: + return _load_settings().get(str(user_id), {}).get("lang", "en") + + +def set_user_lang(user_id: int, lang: str) -> None: + s = _load_settings() + s.setdefault(str(user_id), {})["lang"] = lang + _save_settings(s) + + +# ── Unit conversion ─────────────────────────────────────────────────────────── + +def btc_to_unit(btc_str: str, unit: str) -> str: + try: + btc = Decimal(btc_str) + except InvalidOperation: + return f"{btc_str} {unit}" + if unit == "BTC": + return f"{btc:.8f} BTC" + if unit == "mBTC": + return f"{btc * 1000:.5f} mBTC" + return f"{int(btc * Decimal('100000000')):,} sats" + + +def unit_to_btc(amount_str: str, unit: str) -> str: + try: + amount = Decimal(amount_str.replace(",", "")) + except InvalidOperation: + raise ValueError(amount_str) + if unit == "BTC": + return f"{amount:.8f}" + if unit == "mBTC": + return f"{amount / Decimal('1000'):.8f}" + return f"{amount / Decimal('100000000'):.8f}" + + +def validate_password(password: str) -> bool: + if len(password) < 8: + return False + if not any(c.isupper() for c in password): + return False + if not any(c.islower() for c in password): + return False + if not any(c.isdigit() for c in password): + return False + return True + + +def get_loaded_wallet(user_id: int) -> str | None: + return _load_settings().get(str(user_id), {}).get("loaded_wallet") + + +def set_loaded_wallet(user_id: int, name: str | None) -> None: + s = _load_settings() + s.setdefault(str(user_id), {})["loaded_wallet"] = name + _save_settings(s) + + +def format_balance(data: dict, unit: str, lang: str) -> str: + s = STRINGS.get(lang, STRINGS["en"]) + confirmed = data.get("confirmed", "0") or "0" + unconfirmed = data.get("unconfirmed", "0") or "0" + unmatured = data.get("unmatured", "0") or "0" + lines = [s["balance_header"].format(unit=unit)] + lines.append(f"{s['confirmed']} {btc_to_unit(confirmed, unit)}") + if Decimal(unconfirmed) != 0: + lines.append(f"{s['unconfirmed']} {btc_to_unit(unconfirmed, unit)}") + if Decimal(unmatured) != 0: + lines.append(f"{s['unmatured']} {btc_to_unit(unmatured, unit)}") + return "\n".join(lines) + + +# ── Keyboards ───────────────────────────────────────────────────────────────── + +MAIN_KEYBOARD = ReplyKeyboardMarkup( + [["Add Wallet", "List"], ["Balance", "Receive"], ["Load", "Close"], ["Send"], ["🌐 Language"]], + resize_keyboard=True, + is_persistent=True, +) + +def submenu_kb(user_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([ + [InlineKeyboardButton(t(user_id, "btn_check_fee"), callback_data="check_fee")], + [InlineKeyboardButton(t(user_id, "btn_send_tx"), callback_data="do_send")], + [InlineKeyboardButton(t(user_id, "btn_broadcast"), callback_data="do_broadcast")], + ]) + + +def fee_kb(user_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([[ + InlineKeyboardButton("Electrum", callback_data="fee_electrum"), + InlineKeyboardButton("mempool.space", callback_data="fee_mempool"), + ]]) + + +def amount_kb(user_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([[ + InlineKeyboardButton(t(user_id, "btn_send_max"), callback_data="send_max"), + ]]) + +LANG_KB = InlineKeyboardMarkup([[ + InlineKeyboardButton("🇬🇧 English", callback_data="lang:en"), + InlineKeyboardButton("🇮🇷 فارسی", callback_data="lang:fa"), +]]) + + +def unit_keyboard(current: str) -> InlineKeyboardMarkup: + def btn(u: str) -> InlineKeyboardButton: + return InlineKeyboardButton(f"● {u}" if u == current else u, callback_data=f"unit:{u}") + return InlineKeyboardMarkup([[btn("BTC"), btn("mBTC"), btn("sats")]]) + + +MENU_FILTER = filters.Regex(r"^(Add Wallet|List|Balance|Receive|Load|Close|Send|🌐 Language)$") +NOT_MENU = ~MENU_FILTER + +# ── Conversation states ─────────────────────────────────────────────────────── +RESTORE_NAME, RESTORE_XPUB, RESTORE_PASSWORD = range(3) +OP_SELECT, OP_PASSWORD = range(2) +S_MENU, S_FEE_SRC, S_RATE, S_PASS, S_ADDR, S_AMT, S_BCAST = range(7) + + +# ── Core helpers ────────────────────────────────────────────────────────────── + +def load_allowed_users() -> set[int]: + users: set[int] = set() + try: + with open(ALLOWED_USERS_FILE) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + uid = line.split(",", 1)[0].strip() + if uid.isdigit(): + users.add(int(uid)) + except FileNotFoundError: + logger.warning("Allowed users file not found: %s", ALLOWED_USERS_FILE) + return users + + +def user_wallet_path(user_id: int) -> str: + path = f"{WALLETS_BASE}/{user_id}" + os.makedirs(path, exist_ok=True) + return path + + +def list_user_wallets(user_id: int) -> list[str]: + return sorted(os.listdir(user_wallet_path(user_id))) + + +def run_electrum(*args: str) -> tuple[str, str, int]: + result = subprocess.run( + ["electrum", *args], capture_output=True, text=True, timeout=30, + ) + return result.stdout.strip(), result.stderr.strip(), result.returncode + + +async def auth_check(update: Update) -> bool: + uid = update.effective_user.id + if uid not in load_allowed_users(): + await update.message.reply_text(t(uid, "not_authorized")) + return False + return True + + +async def reply(update: Update, text: str, **kwargs) -> None: + if update.callback_query: + await update.callback_query.edit_message_text(text, **kwargs) + else: + await update.message.reply_text(text, reply_markup=MAIN_KEYBOARD, **kwargs) + + +async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.effective_user.id + context.user_data.clear() + await update.message.reply_text(t(uid, "cancelled"), reply_markup=MAIN_KEYBOARD) + return ConversationHandler.END + + +async def cancel_for_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.effective_user.id + context.user_data.clear() + await update.message.reply_text(t(uid, "cancel_for_button"), reply_markup=MAIN_KEYBOARD) + return ConversationHandler.END + + +CONV_FALLBACKS = [ + CommandHandler("cancel", cancel), + MessageHandler(MENU_FILTER, cancel_for_button), +] + + +# ── Simple commands ─────────────────────────────────────────────────────────── + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not await auth_check(update): + return + uid = update.effective_user.id + await update.message.reply_text(t(uid, "welcome"), reply_markup=MAIN_KEYBOARD) + + +async def list_wallets(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not await auth_check(update): + return + uid = update.effective_user.id + wallets = list_user_wallets(uid) + if wallets: + msg = t(uid, "your_wallets") + "\n" + "\n".join(wallets) + else: + msg = t(uid, "no_wallets") + await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD) + + +async def balance_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not await auth_check(update): + return + uid = update.effective_user.id + if not get_loaded_wallet(uid): + await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD) + return + unit = get_user_unit(uid) + lang = get_user_lang(uid) + wallet_path = f"{user_wallet_path(uid)}/{get_loaded_wallet(uid)}" + stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path) + if rc != 0: + await update.message.reply_text(t(uid, "error", error=stderr or stdout), reply_markup=MAIN_KEYBOARD) + return + try: + data = json.loads(stdout) + msg = format_balance(data, unit, lang) + except (json.JSONDecodeError, InvalidOperation): + await update.message.reply_text(stdout, reply_markup=MAIN_KEYBOARD) + return + await update.message.reply_text(msg, reply_markup=unit_keyboard(unit)) + + +async def unit_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + uid = query.from_user.id + unit = query.data.split(":", 1)[1] + set_user_unit(uid, unit) + lang = get_user_lang(uid) + loaded = get_loaded_wallet(uid) + if not loaded: + await query.edit_message_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML) + return + wallet_path = f"{user_wallet_path(uid)}/{loaded}" + stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path) + if rc != 0: + await query.edit_message_text(t(uid, "error", error=stderr or stdout)) + return + try: + data = json.loads(stdout) + msg = format_balance(data, unit, lang) + except (json.JSONDecodeError, InvalidOperation): + msg = stdout + await query.edit_message_text(msg, reply_markup=unit_keyboard(unit)) + + +async def receive_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not await auth_check(update): + return + uid = update.effective_user.id + loaded = get_loaded_wallet(uid) + if not loaded: + await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD) + return + wallet_path = f"{user_wallet_path(uid)}/{loaded}" + stdout, stderr, rc = run_electrum("getunusedaddress", "-w", wallet_path) + msg = stdout if rc == 0 else t(uid, "error", error=stderr or stdout) + await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD) + + +async def lang_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not await auth_check(update): + return + uid = update.effective_user.id + await update.message.reply_text(t(uid, "language_select"), reply_markup=LANG_KB) + + +async def lang_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + uid = query.from_user.id + lang = query.data.split(":", 1)[1] + set_user_lang(uid, lang) + await query.edit_message_text(t(uid, "language_changed")) + + +# ── Restore conversation ────────────────────────────────────────────────────── + +async def restore_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await auth_check(update): + return ConversationHandler.END + uid = update.effective_user.id + await update.message.reply_text(t(uid, "add_wallet_intro"), parse_mode=ParseMode.HTML) + return RESTORE_NAME + + +async def restore_got_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["walletname"] = update.message.text.strip() + uid = update.effective_user.id + await update.message.reply_text(t(uid, "enter_xpub")) + return RESTORE_XPUB + + +async def restore_got_pubkey(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["pubkey"] = update.message.text.strip() + uid = update.effective_user.id + await update.message.reply_text(t(uid, "enter_password")) + return RESTORE_PASSWORD + + +async def restore_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.effective_user.id + password = update.message.text.strip() + await update.message.delete() + if not validate_password(password): + await context.bot.send_message(update.effective_chat.id, t(uid, "password_invalid")) + return RESTORE_PASSWORD + context.user_data["password"] = password + walletname = context.user_data["walletname"] + wallet_path = f"{user_wallet_path(uid)}/{walletname}" + await update.message.reply_text(t(uid, "restoring", name=walletname)) + stdout, stderr, rc = run_electrum( + "restore", "--password", context.user_data["password"], + "--encrypt_file", "true", "-w", wallet_path, context.user_data["pubkey"], + ) + msg = t(uid, "wallet_restored", name=walletname) if rc == 0 else t(uid, "error", error=stderr or stdout) + await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD) + context.user_data.clear() + return ConversationHandler.END + + +# ── Load / Close conversation ───────────────────────────────────────────────── + +async def op_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await auth_check(update): + return ConversationHandler.END + uid = update.effective_user.id + raw = (update.message.text or "").lstrip("/").split()[0].lower() + context.user_data["op"] = raw + wallets = list_user_wallets(uid) + if not wallets: + await update.message.reply_text(t(uid, "no_wallets"), reply_markup=MAIN_KEYBOARD) + return ConversationHandler.END + if len(wallets) == 1: + return await _wallet_selected(update, context, wallets[0]) + keyboard = [[InlineKeyboardButton(w, callback_data=w)] for w in wallets] + await update.message.reply_text(t(uid, "select_wallet"), reply_markup=InlineKeyboardMarkup(keyboard)) + return OP_SELECT + + +async def op_wallet_selected(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + await update.callback_query.answer() + return await _wallet_selected(update, context, update.callback_query.data) + + +async def _wallet_selected( + update: Update, context: ContextTypes.DEFAULT_TYPE, walletname: str +) -> int: + uid = update.effective_user.id + context.user_data["walletname"] = walletname + if context.user_data.get("op") == "load": + await reply(update, t(uid, "wallet_enter_password", name=walletname)) + return OP_PASSWORD + return await _execute_op(update, context) + + +async def op_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["password"] = update.message.text.strip() + await update.message.delete() + return await _execute_op(update, context) + + +async def _execute_op(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.effective_user.id + op = context.user_data.get("op", "") + walletname = context.user_data.get("walletname", "") + wallet_path = f"{user_wallet_path(uid)}/{walletname}" + cmd_map = { + "load": ("load_wallet", "-w", wallet_path, "--password", context.user_data.get("password", "")), + "close": ("close_wallet", "-w", wallet_path), + } + electrum_cmd = cmd_map.get(op) + if electrum_cmd: + _, stderr, rc = run_electrum(*electrum_cmd) + if rc == 0: + if op == "load": + set_loaded_wallet(uid, walletname) + msg = t(uid, "wallet_loaded", name=walletname) + else: + set_loaded_wallet(uid, None) + msg = t(uid, "wallet_closed", name=walletname) + else: + msg = t(uid, "error", error=stderr) + else: + msg = t(uid, "unknown_op") + await reply(update, msg) + context.user_data.clear() + return ConversationHandler.END + + +# ── Send conversation ───────────────────────────────────────────────────────── + +async def send_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await auth_check(update): + return ConversationHandler.END + uid = update.effective_user.id + await update.message.reply_text(t(uid, "send_menu"), reply_markup=submenu_kb(uid)) + return S_MENU + + +async def _show_submenu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.effective_user.id + await context.bot.send_message(update.effective_chat.id, t(uid, "send_menu"), reply_markup=submenu_kb(uid)) + return S_MENU + + +async def fee_choice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.callback_query.from_user.id + await update.callback_query.answer() + await update.callback_query.edit_message_text(t(uid, "fee_source"), reply_markup=fee_kb(uid)) + return S_FEE_SRC + + +async def fee_electrum(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query = update.callback_query + uid = query.from_user.id + await query.answer() + stdout, stderr, rc = run_electrum("getfeerate") + if rc == 0: + try: + d = json.loads(stdout) + msg = t(uid, "recommended_fee", tip=d["tooltip"], desc=d["description"]) + except (json.JSONDecodeError, KeyError): + msg = stdout + else: + msg = t(uid, "error", error=stderr or stdout) + await query.edit_message_text(msg) + return await _show_submenu(update, context) + + +async def fee_mempool(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query = update.callback_query + await query.answer() + uid = query.from_user.id + try: + res = subprocess.run( + ["curl", "-sSL", "https://mempool.space/api/v1/fees/recommended"], + capture_output=True, text=True, timeout=10, + ) + d = json.loads(res.stdout) + msg = t(uid, "fee_mempool_msg", + fastest=d["fastestFee"], half_hour=d["halfHourFee"], + hour=d["hourFee"], economy=d["economyFee"], minimum=d["minimumFee"]) + except Exception as e: + msg = t(uid, "error", error=str(e)) + await query.edit_message_text(msg) + return await _show_submenu(update, context) + + +async def send_ask_feerate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.callback_query.from_user.id + await update.callback_query.answer() + if not get_loaded_wallet(uid): + await update.callback_query.edit_message_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML) + return ConversationHandler.END + await update.callback_query.edit_message_text(t(uid, "enter_feerate")) + return S_RATE + + +async def send_got_feerate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["feerate"] = update.message.text.strip() + uid = update.effective_user.id + await update.message.reply_text(t(uid, "enter_password")) + return S_PASS + + +async def send_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["password"] = update.message.text.strip() + uid = update.effective_user.id + await update.message.delete() + await context.bot.send_message(update.effective_chat.id, t(uid, "enter_addr")) + return S_ADDR + + +async def send_got_address(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["address"] = update.message.text.strip() + uid = update.effective_user.id + unit = get_user_unit(uid) + await update.message.reply_text(t(uid, "enter_amount", unit=unit), reply_markup=amount_kb(uid)) + return S_AMT + + +async def send_max_cb(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + await update.callback_query.answer() + await update.callback_query.edit_message_reply_markup(reply_markup=None) + return await _build_tx(update, context, "!") + + +async def send_got_amount(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.effective_user.id + unit = get_user_unit(uid) + amount_raw = update.message.text.strip() + try: + btc_amount = unit_to_btc(amount_raw, unit) + except ValueError as e: + await update.message.reply_text(t(uid, "invalid_amount", error=str(e))) + return S_AMT + return await _build_tx(update, context, btc_amount) + + +async def _build_tx(update: Update, context: ContextTypes.DEFAULT_TYPE, btc_amount: str) -> int: + uid = update.effective_user.id + + notify = update.callback_query.message if update.callback_query else update.message + await notify.reply_text(t(uid, "creating_tx")) + + wallet_path = f"{user_wallet_path(uid)}/{get_loaded_wallet(uid)}" + stdout, stderr, rc = run_electrum( + "payto", + "-w", wallet_path, + "--feerate", context.user_data["feerate"], + "--rbf", "true", + "--password", context.user_data["password"], + context.user_data["address"], + btc_amount, + ) + + if rc != 0: + await notify.reply_text(t(uid, "error", error=stderr or stdout), reply_markup=MAIN_KEYBOARD) + context.user_data.clear() + return ConversationHandler.END + + psbt = stdout.strip() + await notify.reply_text( + f"{t(uid, 'unsigned_tx_header')}\n\n
{psbt}
", + parse_mode=ParseMode.HTML, + ) + + file_obj = io.BytesIO(psbt.encode()) + await context.bot.send_document( + chat_id=update.effective_chat.id, + document=file_obj, + filename="unsigned_tx.txt", + caption="Unsigned transaction", + ) + + await notify.reply_text(t(uid, "signing_instructions"), reply_markup=MAIN_KEYBOARD) + context.user_data.clear() + return ConversationHandler.END + + +async def broadcast_ask(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.callback_query.from_user.id + await update.callback_query.answer() + await update.callback_query.edit_message_text(t(uid, "enter_signed_tx")) + return S_BCAST + + +async def broadcast_got_tx(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + uid = update.effective_user.id + stdout, stderr, rc = run_electrum("broadcast", update.message.text.strip()) + if rc == 0: + await update.message.reply_text( + f"{t(uid, 'broadcast_success')}\n{stdout}", + parse_mode=ParseMode.HTML, + reply_markup=MAIN_KEYBOARD, + ) + else: + await update.message.reply_text(t(uid, "error", error=stderr or stdout), reply_markup=MAIN_KEYBOARD) + return ConversationHandler.END + + +# ── App wiring ──────────────────────────────────────────────────────────────── + +def main() -> None: + app = Application.builder().token(TOKEN).build() + + for cmd in ("start", "help"): + app.add_handler(CommandHandler(cmd, start)) + app.add_handler(CommandHandler("list", list_wallets)) + app.add_handler(CommandHandler("balance", balance_cmd)) + app.add_handler(CommandHandler("receive", receive_cmd)) + + app.add_handler(MessageHandler(filters.Regex(r"^List$"), list_wallets)) + app.add_handler(MessageHandler(filters.Regex(r"^Balance$"), balance_cmd)) + app.add_handler(MessageHandler(filters.Regex(r"^Receive$"), receive_cmd)) + app.add_handler(MessageHandler(filters.Regex(r"^🌐 Language$"), lang_menu)) + + # Global inline callbacks (registered before ConversationHandlers — patterns are unique) + app.add_handler(CallbackQueryHandler(unit_switch, pattern=r"^unit:")) + app.add_handler(CallbackQueryHandler(lang_switch, pattern=r"^lang:")) + + app.add_handler(ConversationHandler( + entry_points=[ + CommandHandler("restore", restore_start), + MessageHandler(filters.Regex(r"^Add Wallet$"), restore_start), + ], + states={ + RESTORE_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, restore_got_name)], + RESTORE_XPUB: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, restore_got_pubkey)], + RESTORE_PASSWORD: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, restore_got_password)], + }, + fallbacks=CONV_FALLBACKS, + )) + + app.add_handler(ConversationHandler( + entry_points=[ + CommandHandler("load", op_start), + CommandHandler("close", op_start), + MessageHandler(filters.Regex(r"^(Load|Close)$"), op_start), + ], + states={ + OP_SELECT: [CallbackQueryHandler(op_wallet_selected)], + OP_PASSWORD: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, op_got_password)], + }, + fallbacks=CONV_FALLBACKS, + )) + + app.add_handler(ConversationHandler( + entry_points=[ + CommandHandler("send", send_menu), + MessageHandler(filters.Regex(r"^Send$"), send_menu), + ], + states={ + S_MENU: [ + CallbackQueryHandler(fee_choice, pattern="^check_fee$"), + CallbackQueryHandler(send_ask_feerate, pattern="^do_send$"), + CallbackQueryHandler(broadcast_ask, pattern="^do_broadcast$"), + ], + S_FEE_SRC: [ + CallbackQueryHandler(fee_electrum, pattern="^fee_electrum$"), + CallbackQueryHandler(fee_mempool, pattern="^fee_mempool$"), + ], + S_RATE: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, send_got_feerate)], + S_PASS: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, send_got_password)], + S_ADDR: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, send_got_address)], + S_AMT: [ + CallbackQueryHandler(send_max_cb, pattern="^send_max$"), + MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, send_got_amount), + ], + S_BCAST: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, broadcast_got_tx)], + }, + fallbacks=CONV_FALLBACKS, + )) + + logger.info("Bot starting...") + app.run_polling(allowed_updates=Update.ALL_TYPES) + + +if __name__ == "__main__": + main() diff --git a/docker-compose.tor.yml b/docker-compose.tor.yml new file mode 100644 index 0000000..fe14997 --- /dev/null +++ b/docker-compose.tor.yml @@ -0,0 +1,25 @@ +services: + electrum: + build: + context: . + dockerfile: Dockerfile.tor + args: + VERSION: ${ELECTRUM_VERSION:-4.7.2} + container_name: electrum-tor + env_file: .env + environment: + ALLOWED_USERS_FILE: /app/allowed_users.txt + volumes: + - electrum-tor-data:/root/.electrum + - ./allowed_users.txt:/app/allowed_users.txt:ro + - ./bot.py:/app/bot.py:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "electrum", "getinfo"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + electrum-tor-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b6fb2fb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + electrum: + build: + context: . + args: + VERSION: ${ELECTRUM_VERSION:-4.7.2} + container_name: electrum + env_file: .env + environment: + ALLOWED_USERS_FILE: /app/allowed_users.txt + volumes: + - electrum-data:/root/.electrum + - ./allowed_users.txt:/app/allowed_users.txt:ro + - ./bot.py:/app/bot.py:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "electrum", "getinfo"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 15s + +volumes: + electrum-data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..326e19d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -e + +mkdir -p /root/.electrum/wallets + +echo "Starting Electrum daemon..." +rm -f /root/.electrum/daemon +electrum daemon -d + +echo "Electrum daemon started. Launching bot..." +exec /opt/electrum/bin/python3 /app/bot.py diff --git a/entrypoint.tor.sh b/entrypoint.tor.sh new file mode 100644 index 0000000..848f829 --- /dev/null +++ b/entrypoint.tor.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -e + +mkdir -p /root/.electrum/wallets + +echo "Starting Tor..." +tor --RunAsDaemon 1 --SocksPort 9050 --DataDirectory /var/lib/tor + +echo "Waiting for Tor SOCKS proxy on 127.0.0.1:9050..." +until echo > /dev/tcp/127.0.0.1/9050 2>/dev/null; do + sleep 1 +done +echo "Tor is ready." + +echo "Starting Electrum daemon..." +rm -f /root/.electrum/daemon + +PROXY="socks5:localhost:9050" + +if [ -n "${ELECTRUM_SERVER:-}" ]; then + # Pin to a single user-specified server (own node) + electrum -1 -s "${ELECTRUM_SERVER}" -p "${PROXY}" daemon -d +else + # Route through Tor using a public .onion server + electrum -s "${ELECTRUM_ONION_SERVER:-electrums3lojbuj.onion:50001:t}" -p "${PROXY}" daemon -d +fi + +echo "Electrum daemon started. Launching bot..." +exec /opt/electrum/bin/python3 /app/bot.py diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..4776325 --- /dev/null +++ b/install.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +ELECTRUM_VERSION="${1:-4.7.2}" + +echo ">>> Installing Electrum ${ELECTRUM_VERSION}" + +WORKDIR="/tmp/electrum" +mkdir -p "$WORKDIR" +cd "$WORKDIR" + +BASE_URL="https://download.electrum.org/${ELECTRUM_VERSION}" +TARBALL="Electrum-${ELECTRUM_VERSION}.tar.gz" +SIG_FILE="${TARBALL}.asc" + +FINGERPRINT_THOMASV='6694 D8DE 7BE8 EE56 31BE D950 2BD5 824B 7F94 70E6' +FINGERPRINT_SOMBERNIGHT='0EED CFD5 CAFB 4590 6734 9B23 CA9E EEC4 3DF9 11DC' +FINGERPRINT_EMZY='9EDA FF80 E080 6596 04F4 A76B 2EBB 056F D847 F8A7' + +PUB_THOMASV='https://raw.githubusercontent.com/spesmilo/electrum/master/pubkeys/ThomasV.asc' +PUB_SOMBERNIGHT='https://raw.githubusercontent.com/spesmilo/electrum/master/pubkeys/sombernight_releasekey.asc' +PUB_EMZY='https://raw.githubusercontent.com/spesmilo/electrum/master/pubkeys/Emzy.asc' + +echo ">>> Importing signing keys..." +for pubkey_url in "$PUB_THOMASV" "$PUB_SOMBERNIGHT" "$PUB_EMZY"; do + wget -q "$pubkey_url" +done +for key_file in *.asc; do + gpg --import "$key_file" + rm -f "$key_file" +done + +echo ">>> Downloading Electrum ${ELECTRUM_VERSION}..." +wget -q "${BASE_URL}/${TARBALL}" +wget -q "${BASE_URL}/${SIG_FILE}" + +echo ">>> Verifying GPG signature..." +VERIFY_OUTPUT=$(gpg --verify "$SIG_FILE" "$TARBALL" 2>&1 || true) +echo "$VERIFY_OUTPUT" + +echo "$VERIFY_OUTPUT" | grep -q "Good signature" \ + || { echo "ERROR: GPG verification failed — no good signature found" >&2; exit 1; } + +MATCHED=0 +for fp in "$FINGERPRINT_THOMASV" "$FINGERPRINT_SOMBERNIGHT" "$FINGERPRINT_EMZY"; do + if echo "$VERIFY_OUTPUT" | grep -qF "$fp"; then + echo ">>> Fingerprint matched: ${fp}" + MATCHED=1 + break + fi +done +[ "$MATCHED" -eq 1 ] \ + || { echo "ERROR: no trusted fingerprint found in verify output" >&2; exit 1; } + +echo ">>> Creating venv and installing Electrum..." +python3 -m venv --system-site-packages /opt/electrum +/opt/electrum/bin/pip install --quiet --use-pep517 "${WORKDIR}/${TARBALL}" + +ln -sf /opt/electrum/bin/electrum /usr/bin/electrum + +rm -rf "$WORKDIR" + +echo ">>> Done: Electrum ${ELECTRUM_VERSION} installed" +echo ">>> Try: electrum --help" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7d5efd5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +python-telegram-bot==20.7