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.keyby default (TELESRV_DONATION_WALLET_KEY_PATH), generated automatically the first time the server runs with donations enabled -- the exact same patterninternal/mtprotoedge.LoadOrGenerateRSAKeyalready 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 setTELESRV_DONATION_WALLET_KEYto a literal 64-hex-char value, which overrides the file entirely. - The wallet mnemonic itself is generated automatically the first time
cmd/telesrvstarts 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):
- Native currency: fetch each new block, scan every transaction's
toaddress against known donation addresses. - USDT/USDC:
eth_getLogsfiltered to the token contract and the standardTransfer(address,address,uint256)topic,to= the address -- a targeted, cheap query, not a full block scan. - 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 toconfirmed. - 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/depositQR code. The command answers with the raw address today; ideally it'd also send a QR image viabotReply.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.PrivateKeyHexexists for this; nothing calls it yet. - Reorg handling. A deposit currently only moves forward
(
pending → confirmed → credited); there is noorphanedtransition 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.