RUWEROGRAM DOCS

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.