Skip to content

Telegram Verification

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 enabled flag, set by an ONS proposal (and, during the transition phase, also by the deploying principals). To read the live state, query get_telegram_config on the ecosystem canister — the configuration is public.

The provider ships unconfigured and closed by default (enabled = false, accepts_new_proofs = false, bot_id = 0); a set_telegram_config execution 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.


StrengthStrong
ProofThe signed launch data (initData) of a Telegram Mini App session
Uniqueness anchorOne Telegram account ↔ one wallet
Third party contacted by the canisterNone. The canister verifies an Ed25519 signature against Telegram’s published key
Secret held by OhShiiNone. The bot token is used once, at bot creation, and stored nowhere
Wallets supportedAny wallet — the binding is carried by the flow, not by the session
Shipped defaultUnconfigured, 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.


Telegram is described by a TelegramConfig record carrying the same two independent flags every provider has:

FlagMeaning
enabledDoes this provider make the verification gate mandatory?
accepts_new_proofsWill 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).


  1. Start. From the verification page, the wallet calls start_telegram_verification(). The canister returns a one-time value bound to that wallet, valid for nonce_ttl_secs.
  2. 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> and tg://resolve?domain=<bot>&appname=<app>&startapp=<value>.
  3. 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.
  4. Submit. That page calls submit_telegram_proof(init_data) on the ecosystem canister with the session data Telegram signed.
  5. 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.

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.


  • 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.

That someone opened the OhShii Mini App. Telegram does not see the wallet, the DAO, or anything the wallet later does.

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.

ActionTelegram (Strong)
Vote or create a standard proposal, on the ecosystem DAO and on campaign DAOsyes
Delegation and hotkey registrationyes
Create a token, an LGE, an index canister or an imported DAOyes
Sponsor attributionyes
Vote or create a critical proposalyes
A delegated ballot landing on a critical proposalyes
LGE purchase tier above Guest, guest-fee waiver, exemption from the Guest capyes
Veto-override ballotyes, 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.


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.


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.

ChangeHow it is made
Configure Telegram, or flip either flagset_telegram_config, executed by ohshii_governance through an approved critical proposal (CriticalGovernanceOperation)
Clear a wallet’s Telegram verificationadmin_clear_telegram_verified, same route
Release a Telegram account’s fingerprint so it can be used againadmin_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.


  • 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_proof deliberately 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.


  • The Mini App is a static page — /tg-verify.html plus a single /tg-verify.js bundle — 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’s connect-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 from submit_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.

  1. The wallet is bound by the signed launch value, not by who calls. The Mini App’s throwaway identity is untrusted by construction.
  2. The signature covers the bot id, so a misconfigured bot id cannot verify anyone else’s wallet.
  3. 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.
  4. One session, one registration. The one-time value is consumed, so the same session data cannot be replayed.
  5. 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.
  6. Nothing but the account anchor, the timestamp and the binding value is read, and nothing else is written.
  7. 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.
  8. 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.

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 = true with enabled = false first, so wallets can verify before anyone is required to.
  • Check the public configuration after a proposal executes: get_telegram_config shows 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.