Skip to content

About

Accept TAO for a paid Discord community — payments verified directly on the Bittensor chain. No processor, no middleman, no screenshots.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

tsc-payments — accept TAO for a paid Discord community

Python 3.10+ License: MIT Bittensor

A complete, production-running membership system that takes payment in TAO, verifies it directly on the Bittensor chain, and handles everything after: Discord invite, member record, renewal reminder, and removal when the term runs out.

No payment processor. No middleman. No trust in a screenshot. The customer sends TAO from their own wallet and pastes one ID — the chain is the receipt.

customer sends TAO  ─►  pastes extrinsic ID  ─►  verified on-chain  ─►  invite in their inbox
                                                (5 gates, ~2 seconds)

Why this exists

Card subscriptions are pull: the processor charges the customer every month and tells you what happened. Crypto is push — nobody can charge a wallet. The customer decides to send TAO, or doesn't. That single difference breaks every assumption a normal subscription system makes, and it leaves you with one hard question:

Did this person really send that payment, and have they already used it?

Answering it by hand does not scale, and answering it from a screenshot is not an answer at all. This repo answers it from the chain, in about two seconds, and then runs the whole membership lifecycle around that answer.

Who it's for

  • Anyone running a paid Discord — alpha group, signals room, research community, course cohort
  • Bittensor / TAO projects that want to get paid in the token they believe in
  • Builders who want a worked example of verifying a Substrate extrinsic in Python
  • Anyone who needs on-chain payment verification and can swap Discord for their own gate

It runs today on a single small VPS. Python, Flask, a Google Sheet, and a Discord bot — nothing exotic, nothing you can't read in an afternoon.


How a payment actually works

The mental model

email the identity — who this person is, across every payment they ever make
extrinsic ID the proof of one payment — verified on-chain, spendable exactly once

An extrinsic ID looks like 1234567-0042 — block number, then the transaction's index inside that block. It's the receipt number of the Bittensor blockchain, and anyone can look it up on taostats.io.

The flow

flowchart TD
    A["Customer opens the pay page<br/>GET /price → live TAO/USD + plan amounts"] --> B["Sends TAO from their own wallet"]
    B --> C["Copies the extrinsic ID from taostats.io<br/>block-index, e.g. 1234567-0042"]
    C --> D["POST /pay { email, extrinsic_id }"]
    D --> E{"GATE 1 — verify.py<br/>real, successful, paid US, recent?"}
    E -- no --> X["Rejected, with the actual reason"]
    E -- yes --> F{"GATE 2 — ledger.py<br/>already redeemed?"}
    F -- yes --> X
    F -- no --> G{"GATE 3 — pricing.py<br/>how many months did that buy?"}
    G -- "under 1 month" --> X
    G -- "1 / 3 / 6 / 12" --> H["GATE 4 — lock the hash<br/>before anything else happens"]
    H --> I{"GATE 5 — who is this email?"}
    I -- "unknown" --> J["NEW: single-use invite + new row + welcome email"]
    I -- "active member" --> K["RENEWAL: stack months onto their existing date"]
    I -- "expired member" --> L["REACTIVATION: fresh invite, fresh date"]
    J --> M["They click the invite and join"]
    L --> M
    M --> N["join_method_tracker.py spots which invite just died<br/>→ writes their Discord ID into their row"]
Loading

The five gates, in plain English

Gate 1 — is the payment real? (verify.py) Connect to a Bittensor node, pull that exact block and extrinsic, and check four things: it succeeded, it is a transfer, the recipient is your wallet, and it isn't older than MAX_EXTRINSIC_AGE_DAYS. Then read the amount straight off the chain.

Gate 2 — was it already spent? (ledger.py) Every redeemed extrinsic hash is appended to used_extrinsics.jsonl. Without this, one real payment can be pasted by ten different emails and every one of them gets a membership.

Gate 3 — how much did it buy? (pricing.py) Multiply the TAO that actually landed by the live TAO/USD price, then walk the plans from highest to lowest and round down to the one fully covered (with a 5% tolerance for price drift between quote and settlement). The amount decides the term — not what they clicked.

Gate 4 — lock it. (ledger.py) The hash is marked spent before any invite is minted or any email goes out, so a double submit, a browser retry, or two tabs can never buy two memberships.

Gate 5 — route by email. (form_api.py) New customer → invite + row + welcome. Existing active member → stack the months onto their current renewal date, no new invite (renew early and you lose nothing). Expired member → fresh invite, fresh date.


The part worth stealing

Two problems come up the moment you try this yourself. Both are solved here, and both are easy to get subtly wrong.

1. "Not found" is two completely different answers

A pruned node only keeps recent blocks. When it says "block not found" it means "too old for me" — which tells you nothing about whether the payment exists. An archive node keeps every block ever, so when it says the same words, the block truly does not exist.

Conflate the two and you either reject honest customers during a node hiccup, or accept invented IDs. verify.py tags which node is authoritative and separates three outcomes:

What the chain says What it means What the customer sees
extrinsic index out of range block exists, that extrinsic doesn't — true on every node rejected immediately, no pointless retries
block not found from the archive the block genuinely never existed rejected immediately
block not found from a pruned node "too old for me" — meaningless falls through to the next node
every node unreachable your problem, not theirs "chain temporarily unavailable, try again"

A bad ID must never look like an outage. An outage must never look like fraud.

2. A TAO transfer has no Discord account attached to it

At payment time there is no shared key between the blockchain and Discord. You know an email and a wallet; you don't know who is about to walk through the door.

The fix is a trick, and it's the nicest part of the system: every member gets their own single-use invite, and that invite code is stored in their row. A single-use invite 404s the instant it's consumed. So when somebody joins, the bot asks Discord which of the pending invite codes just died — and that dead invite is the joiner.

No OAuth, no bot commands, no asking the customer to type their handle. One HTTP GET per pending row. (join_method_tracker.py)


What's in the box

tsc-payments-public/
├── join_method_tracker.py      Discord bot — bridges a join back to its payment
├── requirements.txt
├── .env.example                every setting, commented
├── codeflow.txt                the whole system on one page
│
└── crypto-flow/
    ├── form_api.py             Flask :5057 — GET /price, POST /pay, GET /ping
    ├── verify.py               on-chain verifier (3-tier node fallback)  ← the core
    ├── ledger.py               append-only replay wall
    ├── pricing.py              TAO/USD + amount → months
    ├── invites.py              mints one single-use Discord invite
    ├── mailer.py               all four emails, one file
    └── kicker.py               daily cron — remind, then expire

Leaf rule: no file does two jobs. verify.py only reads the chain; it doesn't know what a membership is. mailer.py is the only file that sends email. You can open any one of these and understand it without reading the others — which is also why you can rip verify.py out and drop it into something completely different.


Quickstart

1. Install

git clone https://github.com/RadoTSC/tsc-payments-public.git
cd tsc-payments-public
python -m venv venv && source venv/bin/activate      # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env                                 # then fill it in

2. Try the verifier on its own — no setup needed

verify.py is read-only, needs no keys, and never signs anything. Point it at any real transfer on the chain (grab an extrinsic ID from any wallet's Transfers tab on taostats.io):

cd crypto-flow
python verify.py 1234567-0042          # verify one payment
python verify.py probe 1234567-0042    # dump the raw block structure
node used: archive
  valid: True
  hash: 0xabc123...
  sender: 5Dxxxxxxxx...
  recipient: 5Fxxxxxxxx...
  amount_tao: 0.2666
  age_days: 1.34

Set TAO_PAYMENT_WALLET in .env to the address that received the transfer and it returns valid: True; point it anywhere else and you'll get wrong recipient. That's the whole security model, visible in one command.

3. Wire up the rest

What Why Where
Google service account the Sheet is the member database download the JSON as service_acc.json, share the sheet with its client_email as Editor
Google Sheet tab one row per member name it crypto members, headers A..M (below)
Discord bot mint invites, remove lapsed members enable Server Members Intent, invite with Create Invite + Kick Members
Gmail app password sending the emails Google Account → Security → App passwords
CoinMarketCap + CoinGecko keys live TAO/USD free tiers are plenty; CMC is primary, CoinGecko is the fallback

Sheet headers, columns A → M:

discord_handle | discord_user_id | name | email | months | tao_paid | usd_value |
renewal_date | status | sender_wallet | extrinsic_id | joined_at | invite_code

4. Run it

python crypto-flow/form_api.py        # API on :5057
python join_method_tracker.py         # Discord bot, long-running
python crypto-flow/kicker.py          # daily cron — safe to run by hand

In production, put the first two under systemd and the kicker on cron (0 9 * * *), behind Nginx so the port isn't public:

location /api/tsc-payments/ {
    proxy_pass http://127.0.0.1:5057/;   # trailing slash strips the prefix
}

5. The two endpoints your page talks to

# what to charge, right now
curl -s localhost:5057/price
{"ok": true, "tao_price": 412.55,
 "tiers": [{"months": 12, "usd": 200.0, "tao": 0.4848},
           {"months": 6,  "usd": 110.0, "tao": 0.2666},
           {"months": 3,  "usd": 60.0,  "tao": 0.1454},
           {"months": 1,  "usd": 25.0,  "tao": 0.0606}]}

(Those plan prices are just the example values in .env.example — set your own.)

# redeem a payment
curl -X POST localhost:5057/pay -H 'Content-Type: application/json' \
     -d '{"email":"them@example.com","extrinsic_id":"1234567-0042"}'
{"ok": true, "message": "Payment verified. Check your email for your invite."}

Every rejection comes back as {"ok": false, "error": "..."} with a reason you can show the customer as-is: already redeemed, wrong recipient, amount too low for one month, payment too old, Extrinsic ID not found, chain temporarily unavailable.

The frontend is deliberately not in this repo — it's two fetch calls, and yours should look like your brand, not mine.


The membership lifecycle

kicker.py runs once a day and does exactly two things:

  • 3 days before renewal (REMINDER_DAYS_BEFORE) → reminder email with instructions and the form link, status active → reminded. Fires once. They keep full access.
  • past renewal + 2 days grace (CRYPTO_GRACE_DAYS) → log the churn, remove from Discord, status → expired, send a "here's how to come back" email.

Anyone who pays in time never appears due — /pay already pushed their renewal date forward.

active ──► reminded ──► expired
   ▲            │            │
   └────────────┴────────────┘
        they pay → back to active

Nothing is ever deleted. Rows only change status, so the sheet doubles as your churn history (and kicked_history.jsonl is written before any removal, so you never lose the record).


Security model

  • verify.py is read-only. It opens a fresh connection, reads a block, closes it. It holds no keys and cannot sign, spend, or move anything. Your receiving wallet's private key is never anywhere near this code.
  • Recipient is checked against your wallet, so a transfer to somebody else — even a real, successful, recent one — is rejected.
  • Every hash is single-use, locked before any side effect. Replay is dead on arrival.
  • The amount is read from the chain, never from the request body. The customer cannot claim 12 months for a 1-month payment.
  • Nothing is trusted from the browser except an email address and an extrinsic ID, and the ID only matters if the chain agrees with it.
  • .env and service_acc.json are gitignored. Never commit either.

Worth knowing before you go live: TAO/USD is the one thing that can't come from the chain. Price comes from CoinMarketCap with a CoinGecko fallback and a 60-second cache; if both are down, /pay returns 503 rather than guessing at a price. CRYPTO_PRICE_TOLERANCE (default 5%) absorbs the drift between the moment you quote an amount and the moment it settles.


FAQ

Why an extrinsic ID and not a transaction hash? Because it's what taostats.io shows the customer, in a format they can copy without understanding it: block number, dash, index. The hash is read off the chain during verification and that's what the replay wall stores.

What if someone pastes a random ID? The chain is asked. If that block has no such extrinsic — or the archive says the block never existed — it's rejected immediately with "Extrinsic ID not found", no retries burned.

What if they overpay? They get the highest plan fully covered, rounded down. Pay $120 against a $110 six-month plan and you get six months. This is deliberate: silently upgrading people on partial amounts causes far more support mail than it saves.

What if they renew early? The new months stack onto their existing renewal date. Renewing early never costs them days.

Can I use this without Discord / Google Sheets? Yes — that's what the leaf rule is for. verify.py + ledger.py + pricing.py are the payment engine and know nothing about either. Swap invites.py for your own access grant and the sheet calls in form_api.py for your database.

Does this work for other Substrate chains? The structure does. verify.py looks for Balances.Transfer events, which most Substrate chains have — you'd change the node URLs, the token decimals (RAO_PER_TAO), and the price source.

Why a Google Sheet as the database? Because a non-technical operator can look at it, sort it, and fix a row without a query console — and every write in this codebase is a full-row write, so there's nothing clever to break. Outgrow it and swap get_sheet for your ORM.


Want the card side too?

This repo is the crypto half of a system I run in production. The other half handles Stripe: card checkout and onboarding, cancellations, failed-payment grace periods and dunning, un-cancels and recoveries, and automatic removal when a subscription really does end — all feeding the same member list, so card members and TAO members live in one place.

That half isn't open source. I'm building it into a proper SaaS for people running paid Discord communities — client management, billing on both rails, onboarding and offboarding handled for you. It's pre-launch, and I'd rather build it around real setups than guesses.

So, honestly:

  • Want the full setup (Stripe onboarding + offboarding wired to this crypto rail)? Get in touch.
  • Need a custom Bittensor payment integration? Different chain gate, different membership logic, a subnet or product that needs to take TAO and verify it properly — that's the work I do, and I'm happy to build it with you.
  • Running a paid Discord and doing any of this by hand? I'd genuinely like to hear how your setup works and what's painful about it — that's shaping what I build.
  • Want early access when it launches? Just say so.

📧 radotsc@gmail.com 𝕏 @RadoTSC

Tell me what you're running and what you need. No pitch deck, no funnel; I read and answer these myself.

The crypto rail in this repo stays free and MIT, forever. It's yours either way.


Contributing

Issues and PRs welcome. The leaf rule is the one hard convention: one file, one job. If a change makes verify.py know what a membership is, it belongs somewhere else.

License

MIT — see LICENSE. Use it, fork it, sell what you build with it.

Built by RadoTSC.

About

Accept TAO for a paid Discord community — payments verified directly on the Bittensor chain. No processor, no middleman, no screenshots.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages