Hi! I'm a mobile developer (Kotlin by day), and a couple of weeks ago I started a side project with a simple, slightly audacious idea: a chat where sending a message costs money — and the recipient gets paid. You write "hi", you pay. Someone writes to you, you earn. This is the story of how "what if attention literally had a price" became a working Telegram Mini App with real on-chain transactions — and the rakes I stepped on along the way, from app store rules to the anatomy of TON gas. An LLM wrote the code with me, we argued about architecture together, but the debugging bills were mine — literally, since debugging happened on real money. The idea: a market of expensive attention Free messages are worthless — in both senses. Spam, "hey what's up" from strangers, group chats with 10,000 unread messages. What if we flip it: every character costs money? The economics get interesting fast: you can message any stranger — but you'll pay them. A message with money inside always gets opened; the public chat becomes an auction of wit: the priciest message of the week wears the crown 👑 and stays pinned; spam dies on its own: spamming at $0.05 per character is a fast way to go broke. I called it Centence. Between the idea and the product stood two walls. Wall one: the app stores A mobile developer's first reflex is a native app. It's also the last one: on iOS and Android any payment for "digital content" must go through in-app purchases — 30% commission and rules that treat P2P money transfers between users as a minefield, and crypto as a minefield in fog. Option two: a Telegram Mini App. But there's a rule there too — digital goods and services inside mini apps must be sold for Telegram Stars. Paying for "a message" with Stars, with the platform taking a cut, killed the whole point: money must go to the recipient, directly. Then came the pleasant realization: the Stars rule covers buying digital goods from the app. If the app sells nothing and never touches money — if it's just an interface through which one person transfers money from their wallet to another person's wallet — that's not a purchase. That's a transfer. Wall two: the legal entity The moment a service accepts users' money into its own account (even "for a second", even "just to forward it"), it becomes a money operator. That means licenses, KYC/AML, a legal entity — everything that kills a side project on day one. Hence the architectural principle that everything else obeys: The platform never touches user funds. No app balance, no deposits, no withdrawals, no escrow, no refunds. The server holds no private keys and is physically incapable of moving anyone's money. A payment goes from the sender's wallet straight to the recipient's wallet via TON Connect. My fee (10% on direct messages) is a separate output of the same transaction, to my own wallet. If the server gets hacked — there's no money in it. None. Legal bonus: since we're not a money operator, no licenses and no legal entity needed. Taxes on the fee are ordinary personal income. The architecture: matching a transaction to a message The central technical problem of a non-custodial design: the server must figure out that a specific on-chain transaction pays for a specific message — while the transaction is signed by the user in their own wallet, and until the network confirms it, no "payment" exists. The scheme: 1. Client sends the text to the server → server computes the price, stores {uuid, text, amounts, status: pending} 2. Client builds the transaction: transfer to the recipient (90%) + transfer to the platform (10%), both carrying the uuid as a comment 3. User signs it in their wallet (TON Connect) 4. An indexer polls toncenter for incoming transfers to the platform wallet: comment == uuid AND amount == exact expected fee → confirmed 5. Only after "confirmed" can the recipient open the text A few invariants I will die defending in code review: the server computes the price; the client's numbers are never trusted. The pricing formula lives in one shared module imported by both client (to display) and server (to verify) — they physically cannot diverge; every check happens before money moves: recipient exists? wallet connected? sender not blocked? text passed moderation? All before the transaction is built — because we can't refund, we never have the money; matching is idempotent: comment + exact amount; reprocessing the same transfer breaks nothing. The stack is boring, and that's a compliment: Bun + Fastify + bun:sqlite on the server, React + Vite + TON Connect UI on the client, TypeScript everywhere with shared types, deployed on Railway. No Postgres, no WebSocket, no Redis: SQLite on a volume and 7-second polling cover an MVP with room to spare. Every technology you don't adopt is a whole category of bugs you don't have. Rakes, paid for with real money Debugging a payment pipeline has a particular property: unit tests are green, and the truth only comes out on real transfers. Rake 1: toncenter returns comment = null. My uuid traveled in the jetton transfer's forward_payload, unit tests passed — and the production indexer just couldn't see the comment: the v3 API returned comment: null. Turns out you decode it yourself from the base64 BOC: open the cell, check op-code 0x0, read the string tail. Thirty minutes of "the money left and the message didn't confirm" panic — and a 15-line decoder. Rake 2: raw vs friendly addresses. toncenter returns raw form (0:hex), wallets in TON Connect accept only friendly (UQ…). "Wrong address format in message at index 0" — on a live transfer, naturally. One Address.parse().toString() later it works, but you only learn this when a user (me) can't pay. Rake 3: the webview caches your bundle forever. Telegram's webview loves serving a stale build after a deploy. A user reports a bug you fixed three versions ago. Fix: a version.json next to the bundle, client-side polling, and an "update available" banner. Rake 4, my favorite: "insufficient funds, 0.11 TON required". Initially everything ran on USDT (a stablecoin — users think in dollars, seemed logical). Newcomers got 0.07 TON as a gas gift from a hot wallet so they could reply. Then a friend receives a message, money, the gift — taps "reply" — and the wallet demands 0.11 TON. The anatomy: a jetton transfer isn't "send tokens", it's a chain of contract calls, and the wallet requires the gas budget upfront, per transfer. A direct message = two transfers (recipient + fee) = 2 × 0.05 TON of prepaid budget. Only ~0.01 actually burns, the rest returns — but all of it must sit on the balance at send time. The 0.07 gift mathematically couldn't cover a reply. I bumped the gift, trimmed the budget… then realized I was treating symptoms. The disease was two-currency onboarding: users needed USDT for messages AND TON for gas. Normal for a crypto product. A funeral for a chat. The migration: throwing out the stablecoin The fix looked like heresy: drop the stablecoin, price everything in native TON. The price is a constant 0.04 TON per character; dollars in the UI are just a reference line at the current rate. What happened after the migration: a TON transfer is a plain transaction with a value and a comment. No contract chains, no prepaid budgets: ~0.005 TON network fee, done; the exchange rate left the critical path: if the rate API dies, the dollar hints disappear — the money keeps working; amounts became round, matching became trivial, the indexer and client shed a third of their code; leaderboards became literally verifiable: the number in the top is the number on-chain, one to one. Volatility? Accepted deliberately: tickets are small, and if the rate moves several-fold, the per-character price is one constant to change. Fun fact: the original plan used the native coin; then I "improved" it to USDT; then reality reverted my improvement. The attention economy, in detail Threads in the public chat. I wanted replies — but how should they count? If replies boost the parent's rating ×2, nobody writes originals anymore, everyone parasites on hot roots. If they count for nothing, why reply? The answer: a reply is a payment to the author. 90% of a reply's price goes to the person you're replying to. Write something so good people pay to respond — your message literally earns. Ratings stay untouched: everyone pays for themselves, the crown belongs to a single message. The newcomer gift. The first incoming direct message brings the recipient a bit of TON from the platform's hot wallet (limits: once per user, daily cap, total cap — so the wallet can't be drained). It's our money, not users' — non-custodial stays non-custodial. Why: an invited person can reply immediately without figuring out top-ups. The profile as a billboard. If attention costs money, it can be resold: a user's profile has a "card" — a title, a line of text, a link. Write an expensive message → people open your profile → they see your ad. Links are https/t.me only, pre-moderated, reportable, and open behind a confirmation — so phishing can't ride on the bot's reputation. Small things that weren't small Telegram webview on iOS: inputs under 16px auto-zoom; fullscreen collides with system buttons (fixed with Mini Apps 2.0 safe-area vars); the keyboard covers the composer (fixed by listening to viewportChanged + visualViewport and shrinking the layout); TON Connect sessions are per-device: connect on your phone, open the app on desktop — "wallet not connected". Catch it and quietly reopen the connect modal; Railway emails "deployment crashed" on every deploy if your process ignores SIGTERM: Bun exits with code 143, and a non-zero exit is a "crash" to Railway. Three lines of graceful shutdown — clean inbox; \b in JS regex doesn't know Cyrillic — moderation with Russian word lists needs hand-built word boundaries; emoji must cost more: "hello" is 5 characters, but "👨‍👩‍👧" is 5 code points and one grapheme. Count graphemes via Intl.Segmenter, price any emoji as 4 characters — or you incentivize emoji-speak. The result Three days from idea to an MVP with live transactions, another week of iterations with early users. 63 unit tests on everything that touches money. Zero custody of user funds, zero legal entities, ~$10/month of infrastructure. The first message in the product's history sold for $3: "Talk is cheap. This wasn't." If you want to poke it: @centence_bot — early users get a TON welcome gift, and I personally send paid messages to interesting newcomers because I'm testing push notifications. And a question for the room — an argument I keep having with myself: the indexer polls toncenter v3 every 7 seconds and at my volume it's flawless. Is there any point in webhooks/event streaming at small scale, or will polling forgive me for a long time yet? How do you confirm incoming TON payments in production?