# Crypto donations Deposit crypto (native currency, or USDT/USDC) to a personal, permanent address and have it automatically credited to your Stars balance once the deposit reaches the chain's required confirmation depth. This is separate from "support the project" (a single operator wallet shown on the site and in the admin panel, not wired into any per-user crediting) -- this document covers the per-user deposit-to-Stars pipeline only. ## Address model Every EVM-compatible chain (Ethereum, Base, Polygon, BSC, Sepolia, Ganache, ...) shares the same secp256k1 curve and address derivation, so **one address per user works, unmodified, on every enabled chain at once**. There is never a per-chain address. The server holds a single BIP39 mnemonic and derives each user's address at `m/44'/60'/0'/0/{index}`, where `index` is a compact sequence value (`donation_address_index_seq`), not the raw Telegram user ID -- user IDs could someday exceed BIP32's unhardened `uint32` index range, and keeping the two decoupled avoids ever having to think about that again. ## Custody Full custodial: the server holds the mnemonic, encrypted at rest with AES-256-GCM. **Off by default**, via `TELESRV_DONATIONS_ENABLED` (default `false`). This is the important operational note in this document: enabling the feature means generating a real custodial wallet and polling third-party chain endpoints, so neither should happen to a deployment that did not ask for it. With the switch off, no wallet is provisioned, no key file is written, and no chain is contacted; the admin Donations page still lists the chain registry read-only so you can see what turning it on would involve. Once enabled, there is no further manual step: - The encryption key is a local file, `data/donation_wallet.key` by default (`TELESRV_DONATION_WALLET_KEY_PATH`), generated automatically the first time the server runs with donations enabled -- the exact same pattern `internal/mtprotoedge.LoadOrGenerateRSAKey` already uses for the server's MTProto RSA key. An operator who wants to supply their own key instead (e.g. from a secrets manager) can set `TELESRV_DONATION_WALLET_KEY` to a literal 64-hex-char value, which overrides the file entirely. - The wallet mnemonic itself is generated automatically the first time `cmd/telesrv` starts with donations enabled against a database with no wallet row yet (`donationsService.EnsureWallet`, called iff `!Ready()`). The recovery phrase is never printed or logged -- it only ever exists encrypted in the database -- and every later restart loads the already-encrypted wallet instead. Enabling the switch does **not** enable any chain. Each chain is turned on in the admin panel after its RPC endpoint is filled in, and a watcher only starts for a row that is both enabled and has a URL; an enabled row with no endpoint logs one warning per boot and is skipped rather than dialled. Losing the key file is exactly like losing a hardware wallet's seed phrase -- there is no recovery path, because the encrypted mnemonic in the database cannot be decrypted without it. Back up `data/donation_wallet.key` the same way you'd back up `data/server_rsa.pem`. **Deriving addresses and reading balances never touches the private key.** The only code that can produce a private key at all is `Wallet.PrivateKeyHex`, and nothing in the automatic watcher/crediting path calls it. Sweeping funds out of a user's deposit address into an operator treasury is a deliberately separate, manual, operator-triggered action -- not implemented yet, and never automatic by design (see "Not yet built" below). ## Chains `donation_chains` is a normal table, seeded by migration `20260924140000_donations` with six rows: **Ganache** (local dev, `http://127.0.0.1:7545`), **Sepolia** (testnet), Ethereum mainnet, Base, Polygon and BNB Smart Chain. **No chain ships enabled.** The seed migration enabled Ganache and Sepolia, which was wrong in practice: Sepolia had no endpoint filled in at all, and Ganache pointed at a port that exists only on a developer's laptop, so every boot of every real deployment dialled `127.0.0.1:7545` and logged an ERROR. Migration `20261017010000_donation_chains_off_by_default` clears both -- and only rows still carrying the seeded placeholder endpoint, so a deployment that had already configured a real endpoint is not switched off by the migration. An operator fills in an RPC endpoint and flips `enabled` in the admin panel once ready; no migration or deploy needed. A watcher starts only for a row that is enabled *and* has a URL (`domain.DonationChain.Watchable`). `donation_tokens` holds the USDT/USDC contract per chain. Sepolia's USDC row points at Circle's own canonical testnet deployment (`0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238`, verified against Circle's own docs). There is no citable official Sepolia USDT deployment, so that row (and every mainnet/L2 token row, and both Ganache rows) ships with an empty `contract_address` -- the watcher skips any token row shaped like that rather than watching the zero address. Ganache has no real stablecoins at all; testing the ERC-20 path there means deploying a mock ERC-20 and writing its address into `donation_tokens` by hand. ## Watching One goroutine per enabled, watchable chain (`Service.WatchChain`), polling (no WebSocket subscription -- HTTP polling is simpler and works identically against Ganache, Sepolia, and every future chain without needing `ws_url` wired up): 1. **Native currency**: fetch each new block, scan every transaction's `to` address against known donation addresses. 2. **USDT/USDC**: `eth_getLogs` filtered to the token contract and the standard `Transfer(address,address,uint256)` topic, `to` = the address -- a targeted, cheap query, not a full block scan. 3. Every still-pending deposit's confirmation count is refreshed against the chain's latest block each pass; once it reaches `donation_chains.confirmations_required` (currently 1 for Ganache, 10 for Sepolia and everything else) it flips to `confirmed`. 4. Confirmed deposits are priced and credited to Stars in the same pass. A deposit is uniquely identified by `(chain_key, tx_hash, log_index)` (`log_index = -1` for a native transfer, which has no log). Re-scanning a block range that was already covered is a safe no-op: `RecordDonationDeposit` is `ON CONFLICT DO NOTHING`, and `CreditDonationDeposit` only fires from a `WHERE status = 'confirmed'` guard, so nothing can ever be credited twice -- proven by `internal/store/postgres/donations_ganache_integration_test.go`, which sends a real 1 ETH transaction on a local Ganache node and asserts the resulting Stars credit end to end. ## Pricing Stablecoins are priced 1:1 to USD. Native currency uses `donation_chains.manual_usd_rate_micros` (USD per one whole unit, ×1e6) -- an admin-set placeholder rate until a Chainlink price feed is wired in via `donation_chains.price_feed_address` (not implemented yet; the column exists, reading it doesn't). USD converts to Stars at the same $0.013/Star rate this server already uses elsewhere (`internal/compat/tdesktop/startup_stubs.go`'s `UsdRate: 0.013`), rounded down -- a donor is never credited more Stars than their deposit was actually worth. ## Notifications Once the watcher credits a deposit, `donations.Service` calls its `CreditNotifier` port (`app/bots.Service.NotifyDonationCredited`, wired via `donationsService.SetNotifier(botsService)` in `cmd/telesrv/main.go`), which sends the donor a `@premiumbot` chat message naming the amount, asset, chain and Stars credited. Proven end to end (not mocked) by `internal/store/postgres/donations_ganache_integration_test.go`, which asserts the actual chat message lands after a real Ganache deposit. All chat text from `@premiumbot` (the `/deposit` reply included) uses `domain.MessageEntity` spans for formatting -- the server has no markdown parser on the send path, so a literal backtick/asterisk in `botReply.Text` is sent and shown verbatim rather than rendered. ## Admin panel The Donations page (`permission: donations.manage`) shows wallet provisioning status and address count, every configured chain (enabled or not) with an editable RPC/WS endpoint, confirmation depth, price feed address and manual USD rate, and the deposit ledger across every user. It never exposes the wallet mnemonic or a private key -- no admin route returns either; `Wallet.PrivateKeyHex` (see "Not yet built" below) is not reachable from the admin API at all. Writes go through `internal/admin.Service.UpdateDonationChain` (audit trail, actor/reason required), the same pattern as Premium plan edits. ## Not yet built - **`@premiumbot` `/deposit` QR code.** The command answers with the raw address today; ideally it'd also send a QR image via `botReply.Media`. - **"Support the project"**, the separate single-operator-wallet flow for the site and admin panel -- unrelated to per-user crediting, not started. - **Chainlink price feeds** for native currency, replacing the manual rate. - **Manual, operator-triggered sweep** of accumulated funds out of per-user deposit addresses into a treasury address. `Wallet.PrivateKeyHex` exists for this; nothing calls it yet. - **Reorg handling.** A deposit currently only moves forward (`pending → confirmed → credited`); there is no `orphaned` transition yet if a block gets reorganized out before reaching depth. Ganache/Sepolia testing at low confirmation counts makes this more likely to matter in practice than it will once mainnet chains (12+ confirmations) are enabled.