Telegram Verification
Status
Section titled “Status”Live on mainnet since 2026-09-05. The provider is configured and accepts new proofs; it is the Strong method open to new wallets, now that World ID and DecideID are closed to new proofs. Whether it also makes the gate mandatory is its
enabledflag, set by an ONS proposal (and, during the transition phase, also by the deploying principals). To read the live state, queryget_telegram_configon the ecosystem canister — the configuration is public.The provider ships unconfigured and closed by default (
enabled = false,accepts_new_proofs = false,bot_id = 0); aset_telegram_configexecution is what opened it, and one is what would close it again.
Telegram is a Strong-strength provider: it opens the LGE purchase-tier ladder, critical proposals, delegated ballots on critical proposals, and the veto-override attestation. For how strength composes across the four providers, see Governance → Identity verification.
At a glance
Section titled “At a glance”| Strength | Strong |
| Proof | The signed launch data (initData) of a Telegram Mini App session |
| Uniqueness anchor | One Telegram account ↔ one wallet |
| Third party contacted by the canister | None. The canister verifies an Ed25519 signature against Telegram’s published key |
| Secret held by OhShii | None. The bot token is used once, at bot creation, and stored nowhere |
| Wallets supported | Any wallet — the binding is carried by the flow, not by the session |
| Shipped default | Unconfigured, gate off, registrations closed |
What Telegram attests, stated honestly. That the person controls a phone-verified Telegram account. That is stronger than a Google account — Telegram requires a phone number per account and runs its own anti-abuse — and weaker than World ID, which anchors on a biometric enrolment. Telegram accounts are cheaper to accumulate than World IDs. This is the residual, it is deliberate, and it is not compensated for anywhere in the browser.
1. Configuration states
Section titled “1. Configuration states”Telegram is described by a TelegramConfig record carrying the same two
independent flags every provider has:
| Flag | Meaning |
|---|---|
enabled | Does this provider make the verification gate mandatory? |
accepts_new_proofs | Will this provider still bind a new wallet? |
They are orthogonal on purpose, and the combinations are all meaningful:
enabled = false, accepts_new_proofs = true— the “optional” state. Verifying is worth doing for what it unlocks; nobody is required to verify to vote.enabled = true, accepts_new_proofs = true— the gate is mandatory and the provider is open.enabled = true, accepts_new_proofs = false— a retired provider that still counts for everyone who already holds it.
The remaining fields are the bot’s numeric id, the bot username (stored without a
leading @), the Mini App short name, how old a session may be
(auth_date_max_age_secs, default 300, accepted range 60–900) and how long an
issued onboarding value stays valid (nonce_ttl_secs, default 600, accepted
range 120–1800). The bot id is configuration, not a compiled-in constant: it
is public, the signature covers it, and a wrong bot id can only ever verify
wallets whose onboarding values the attacker already holds — their own.
The verifying key is a constant compiled into the canister. It is not a configuration field and it is not held in state, so no proposal and no setter can change which key a signature is checked against; changing it takes a canister upgrade, which is an ONS proposal — and, during the transition phase, also a deploy by the deploying principals. Telegram’s test-server key is deliberately not compiled in.
Unconfigured is the shipped default: no bot, gate off, registrations closed,
bot_id = 0. Like Google, a new provider does not begin accepting proofs merely
by being deployed, and the setter refuses enabled = true while the bot id is
still the unconfigured sentinel. Both flags are opened by a deliberate
configuration change: an ONS proposal — and, during the transition phase, also a
call by the deploying principals (§7).
2. End-user flow
Section titled “2. End-user flow”- Start. From the verification page, the wallet calls
start_telegram_verification(). The canister returns a one-time value bound to that wallet, valid fornonce_ttl_secs. - Open the Mini App. The browser hands that value to Telegram as the launch
parameter of a direct link to the OhShii Mini App. Two link forms are built
together and carry the same value:
https://t.me/<bot>/<app>?startapp=<value>andtg://resolve?domain=<bot>&appname=<app>&startapp=<value>. - Confirm in Telegram. A small static page inside Telegram (
/tg-verify.html) shows what is about to happen and asks for one tap. It does not open a wallet, does not load the main application, and reads nothing from the URL. - Submit. That page calls
submit_telegram_proof(init_data)on the ecosystem canister with the session data Telegram signed. - Done. The wallet is Telegram-verified, and therefore Strong.
Anonymous callers are refused at the boundary, as they are for every update call on this canister. The Mini App page signs its call with a throwaway key generated in the page and never stored, and the canister treats that caller as untrusted: the one-time value, not the caller, identifies the wallet being verified. This is the property that makes the flow safe to run from inside someone else’s application.
The page reads only the signed launch data. It takes the launch value from
Telegram.WebApp.initData — the string Telegram’s server signed — and never from
the tgWebAppStartParam URL parameter, which carries the same value unsigned. A
page that fell back to the URL would accept a value nobody proved came from
Telegram, which is the one substitution that would let a single Telegram account
verify unlimited wallets.
Adding Telegram to an existing verification. A wallet already verified with Google, World ID or DecideID can add Telegram without undoing anything. Each provider’s “already verified” check looks only at that provider’s own record, so holding one method never blocks another.
What one Telegram account can do
Section titled “What one Telegram account can do”One Telegram account, one wallet. If the account has already verified a different wallet, the attempt is refused — and the refusal never says which wallet, because that would link two identities the platform is not allowed to link. Re-running the flow for the same account and the same wallet succeeds and changes nothing.
3. Privacy model
Section titled “3. Privacy model”What the canister stores
Section titled “What the canister stores”- That this wallet is Telegram-verified.
- A salted fingerprint derived from the Telegram account, used only to answer “has this Telegram account already verified a wallet?”, plus a reverse index from wallet to fingerprint so a verification can be released by wallet (§8).
That is the whole list. The canister never stores, logs or returns the Telegram user id, the username, the display name or the profile photo. The structure the session’s user field is parsed into carries exactly one field — the numeric id — so the other values never become values inside the canister at all and cannot be persisted by accident. The id itself is turned into a salted digest and dropped; it is never written.
The salt is generated on-chain by the canister on first use, written by no configuration setter, and returned by no query. The fingerprint is one-way and, because the salt is canister-held and unexposed, cannot be checked against a guessed account from outside.
The salt is kept across code
upgrades. An upgrade whose code cannot read the stored salt (its value has the
wrong length, or its storage header is not recognised) is refused, and the
canister keeps running its previous code with the salt untouched; the refusal
names the setting, its MemoryId (103) and which of the two faults it found.
A salt that cannot be read is never replaced by a new one: a verification that
needs it is refused instead, so every existing fingerprint keeps matching.
A fingerprint is not recoverable from the browser. That is a deliberate consequence and it is why the escape hatches in §8 are shaped the way they are.
What Telegram learns
Section titled “What Telegram learns”That someone opened the OhShii Mini App. Telegram does not see the wallet, the DAO, or anything the wallet later does.
What the logs say
Section titled “What the logs say”User-flow events are the generic identity_verified and
identity_verification_cleared with empty details — the provider is not named,
exactly as it is not named for the other three providers. Configuration events
carry the flags and the bot id, which are public. Clearing a fingerprint records
the wallet, deliberately, so that the DAO can audit every reset.
4. Sybil resistance and its honest residual
Section titled “4. Sybil resistance and its honest residual”The binding is one Telegram account, one wallet. Someone who wants ten verified wallets needs ten Telegram accounts, and each of those needs a phone number that Telegram accepts.
Stated as the design rule rather than as reassurance:
- Telegram raises the price of a Sybil set more than Google does, because a phone number costs more than an email address and Telegram runs its own anti-abuse against bulk registration.
- It raises it less than World ID does, because a World ID is anchored on a biometric enrolment and a Telegram account is not.
- Nothing is compensated client-side. The browser half of this flow is a convenience; every check that matters runs in the canister.
- A wallet holding Telegram satisfies every Standard gate as well. Strong is a superset of Standard.
5. What Telegram verification unlocks
Section titled “5. What Telegram verification unlocks”| Action | Telegram (Strong) |
|---|---|
| Vote or create a standard proposal, on the ecosystem DAO and on campaign DAOs | yes |
| Delegation and hotkey registration | yes |
| Create a token, an LGE, an index canister or an imported DAO | yes |
| Sponsor attribution | yes |
| Vote or create a critical proposal | yes |
| A delegated ballot landing on a critical proposal | yes |
| LGE purchase tier above Guest, guest-fee waiver, exemption from the Guest cap | yes |
| Veto-override ballot | yes, with a fresh attestation for that round |
Guest participation is unaffected. Buying into an LGE as a Guest requires no verification of any kind, and this provider adds none. Telegram only adds capabilities.
6. The veto-override round
Section titled “6. The veto-override round”When a guardian vetoes a critical proposal, the community override round that
decides whether the veto stands demands a fresh proof from every override
voter — not the standing verification that already let them vote in round one.
The Telegram attestation is that proof, and the flow has the same shape as
onboarding: start_telegram_override_attestation(dao, proposal_id, override_expiry_ns), open the Mini App, confirm, submit_telegram_proof.
Three properties are the same as they were under the legacy path, because they are what the round is for:
- The gate reads an immutable per-proposal snapshot taken at veto time, never mutable configuration, so it cannot be switched off mid-round.
- The human attests directly on the ecosystem canister. For a campaign-DAO override the attestation is still taken on the ecosystem canister; the campaign DAO only reads whether the human attested, and the ecosystem canister trusts no assertion coming from a campaign DAO.
- One account, one override ballot. Each attestation spends a fingerprint scoped to that exact round.
Two properties are new, and both are improvements:
- Rounds are unlinkable. The round is folded into the fingerprint’s own preimage, not only into the key it is stored under, so the same person’s attestations in two different rounds cannot be matched to each other.
- No outcall. The legacy World ID attestation requires an outbound HTTPS call drawn from a shared, capped outcall budget. A signature check needs no outcall, so the Telegram attestation does not depend on that budget.
Why the anchor has to be per-account. The override exists to defeat a stockpile of already-verified wallets: forcing a live proof collapses a farm of N wallets onto the real accounts behind them, because one account can produce one attestation per round. That collapse only works if the proof’s anchor survives a change of wallet. A Telegram account is such an anchor — the fingerprint is derived from the account, with no wallet in it — while a per-wallet verification is not, which is why the override was never routed through DecideID and is not routed through Google. See Capture Resistance.
Honest residual. Telegram accounts are cheaper than World IDs. A coalition of enough distinct real accounts, each holding real voting power, can still clear the override bar. The mechanism prices that up; it does not make it impossible, and it is not meant to.
A World ID attestation is accepted for override rounds only while World ID accepts new proofs. World ID is closed to new proofs, so the override attestation is Telegram’s alone.
7. Governance control
Section titled “7. Governance control”Everything on this page is set through ONS governance — and, during the transition phase, also by the deploying principals, who can call the same three methods directly.
| Change | How it is made |
|---|---|
| Configure Telegram, or flip either flag | set_telegram_config, executed by ohshii_governance through an approved critical proposal (CriticalGovernanceOperation) |
| Clear a wallet’s Telegram verification | admin_clear_telegram_verified, same route |
| Release a Telegram account’s fingerprint so it can be used again | admin_clear_telegram_nullifier, executed by ohshii_governance through an approved standard proposal (ExecuteAdminMethod): it frees an account binding and verifies nobody by itself |
Across code upgrades: an upgrade whose code cannot read
the stored Telegram configuration is refused, and the canister keeps running its
previous code with the stored value untouched. While the value cannot be read, it
reads as not configured, disabled and closed to new proofs, and set_telegram_config
refuses to write.
There is no DAO-held secret. Verification is a signature check against Telegram’s published key. The bot token is used once, in Telegram’s own bot setup, and is stored nowhere — not in the repository, not in the canister, not in the deployment secrets. The only secret in the design is the canister-generated salt, which no setter writes and no query reads. That is why every provider setter here can be a full, public proposal body.
8. Escape hatches, and what they do to a fingerprint
Section titled “8. Escape hatches, and what they do to a fingerprint”Two governance-executed levers, deliberately separate, for opposite situations:
admin_clear_telegram_verified(wallet)clears the wallet’s verified flag and keeps the fingerprint. Revoking a wallet must not hand its Telegram account back for reuse.admin_clear_telegram_nullifier(wallet)releases the fingerprint so the Telegram account can bind again. This is the lever for someone who lost a wallet.
The second one exists because of the privacy property in §3: the fingerprint is salted with a value nothing exposes, so it cannot be recovered from a user’s browser and passed in by hand the way the World ID equivalent can. That is also why the lever takes a wallet rather than a fingerprint, and why the canister keeps a reverse index — without one, revoking a verification would lock that Telegram account out permanently, and the only alternative, re-rolling the salt, would release every other account’s fingerprint at the same time.
Verification already granted is not revoked by configuration. Turning the gate off, or closing the provider to new proofs, changes nothing for a wallet that is already verified. That guarantee is the same one every provider carries.
9. Rate limits and cycle budget
Section titled “9. Rate limits and cycle budget”- Per wallet. The verification
start_*endpoints share one per-caller budget, so repeatedly starting a flow is throttled per wallet rather than per provider.submit_telegram_proofdeliberately has no per-caller limit: its caller is a freshly generated throwaway identity, so a per-principal counter there would measure nothing. - Canister-wide. A global hourly cap bounds how many of these operations can run in total, per endpoint.
- Per round. The override attestation additionally carries a per-
(DAO, proposal)hourly cap, so one round cannot be starved by another. - Cycle floor. A pre-check refuses the operation while the balance is below a reserve, so a flood cannot grind the canister down through this path.
- Cheap rejection first. An oversized submission is refused on size before anything expensive runs, at the message-inspection boundary as well as in the body.
- Debit on success. For the override attestation, an unauthorised or failing caller does not consume the canister-wide budget of a time-bounded round.
The full table of caps lives in Rate Limit Inventory.
10. Frontend integration
Section titled “10. Frontend integration”- The Mini App is a static page —
/tg-verify.htmlplus a single/tg-verify.jsbundle — served from the launcher’s asset canister. Plain HTML and a small script, no framework and no router, so it loads quickly inside a mobile webview and never pulls the main bundle. The script filename is stable and unhashed because the page names it literally and the security policy matches it by name. - It has its own Content-Security-Policy block, separate from the
application’s, and on every axis but two it is stricter:
default-src 'none', no inline script, no images, no forms, no base URI, no framing of others. The two widenings are the ones the flow cannot work without — it may load Telegram’s own script, and it may be embedded by Telegram’s web clients. Telegram is deliberately absent from the page’sconnect-src, so the browser itself enforces that the signed session data goes nowhere except the canister. The application’s own policy is unchanged; it forbids embedding, and the Mini App never runs inside it. - It never opens Internet Identity and never reads the launch parameter from the URL. The only launch parameter it trusts is the one inside the data Telegram signed.
- Provider-specific refusals carry stable
TELEGRAM_*prefixes that the interface classifies on, and no refusal string contains a user id, a fingerprint or a wallet. There are 23 such prefixes, 22 of them reachable fromsubmit_telegram_proof; they are enumerated in the canister’s Candid interface next to the endpoint. Note that not every refusal on these endpoints is prefixed: the shared guards — cycle floor, rate limits, a closed voting window — answer with their own un-prefixed messages, so treat the prefix set as the provider’s vocabulary rather than as the complete refusal surface.
11. Security properties
Section titled “11. Security properties”- The wallet is bound by the signed launch value, not by who calls. The Mini App’s throwaway identity is untrusted by construction.
- The signature covers the bot id, so a misconfigured bot id cannot verify anyone else’s wallet.
- Freshness is enforced in both directions, with a small tolerance for clock skew: a session that is too old and a session dated in the future are both refused, with distinct reasons.
- One session, one registration. The one-time value is consumed, so the same session data cannot be replayed.
- The session data is never re-serialised. It is checked exactly as received, byte for byte, because re-encoding it would change what the signature covers.
- Nothing but the account anchor, the timestamp and the binding value is read, and nothing else is written.
- The submit path takes no inter-canister call. It is fully synchronous, so there is no commit point inside it and no third-party dependency in the way of a verification.
- Strong strength is enforced downstream, not assumed. The gate composes provider flags in one place, so no consumer decides for itself what Telegram is worth.
12. How the provider is configured
Section titled “12. How the provider is configured”Every configuration change is an ONS proposal (during the transition phase the
deploying principals can also call the same setter); none of it is a console
action. The bot itself is created outside the canister, in Telegram’s own bot
setup, with the Mini App pointed at the static page’s public URL; its numeric id,
username and Mini App short name are what set_telegram_config records. The bot
token is not part of the configuration and is stored nowhere.
- Open registrations before mandating. The two flags allow
accepts_new_proofs = truewithenabled = falsefirst, so wallets can verify before anyone is required to. - Check the public configuration after a proposal executes:
get_telegram_configshows the flags and the bot id. - Closing the legacy providers to new proofs leaves everyone already verified verified, and moves the override round to Telegram.
See also
Section titled “See also”- Google Verification — the Standard provider
- World ID Verification and DecideID Verification — the two legacy Strong providers
- Governance — the four-provider model and the gate semantics
- Capture Resistance — why the override needs a per-account anchor