From 0cd7e5f3bcd9512da4de5dcdef1c8e9b6b3e3d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iva=CC=81n=20Raskovsky?= Date: Thu, 30 Jul 2026 12:37:08 +0200 Subject: [PATCH 1/3] Let validators link Telegram support groups through the Deckard bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validators can now connect Telegram groups to their validator from the portal. A new "Telegram Support" page in the validator area issues one-time group bind codes: the validator creates a Telegram group, adds the Deckard support bot, and runs /bindcode there; the bot redeems the code server-to-server and the group is bound to the validator. A validator can bind multiple groups (one code per group), codes expire after 48 hours, and unredeemed codes can be revoked. The bind code follows the service-account token design: only a SHA-256 digest is stored, lookup is by a non-secret identifier embedded in the code, and redemption compares digests in constant time. Redemption is gated by a new telegram_bind:redeem service-account scope reserved for the Deckard bot and is atomic and single-use: it records the bound group chat id and the redeeming Telegram uid, and upserts a new TelegramConnection (social connection pattern, bot-confirmed instead of OAuth) so the portal account carries a verifiable Telegram identity. Issuance is throttled per user; the plaintext code is returned exactly once and never listed again. ## Claude Implementation Notes - backend/validators/models.py: TelegramGroupBindCode model — identifier + digest storage (hash_code/identifier_from_plaintext/issue mirroring ServiceAccountToken), issued/redeemed/expired/revoked statuses, 48h default TTL, lazy effective_status, is_redeemable helper - backend/validators/migrations/0018_telegramgroupbindcode.py: schema - backend/validators/views.py: TelegramBindCodeViewSet (create issues + returns plaintext once, mine lists metadata, revoke with row lock and 409 for redeemed codes) and TelegramBindCodeRedeemView (service account auth, required_scopes {'*': telegram_bind:redeem}, payload validation, select_for_update single-use redemption, constant-time digest compare, TelegramConnection upsert that never blanks an existing username) - backend/validators/serializers.py: TelegramBindCodeSerializer — metadata only, status is the lazy-expiry effective status - backend/validators/urls.py: explicit redeem path before the router so the viewset detail route never swallows it; telegram-bind-codes registered before the catch-all validator route - backend/social_connections/models.py: TelegramConnection subclass of the SocialConnection abstract base (numeric uid identity, display-only username, no OAuth), db_table social_connections_telegram - backend/social_connections/migrations/0008_telegramconnection.py: schema - backend/social_connections/admin.py: TelegramConnection admin - backend/service_accounts/scopes.py: TELEGRAM_BIND_REDEEM_SCOPE added to ALLOWED_SERVICE_ACCOUNT_SCOPES so admin/command token issuance accepts it - backend/utils/throttling.py + backend/tally/settings.py: TelegramBindCodeIssueRateThrottle, telegram_bind_issue 10/hour - backend/validators/tests/test_telegram_bind_codes.py: 22 tests — issuance auth + validator gate + single-shot plaintext, mine isolation and lazy expiry, revoke rules, redeem scope gate, single-use, expiry, invalid/tampered codes, connection upsert - frontend/src/routes/ValidatorTelegram.svelte: Link-a-Telegram-group page (Svelte 5 runes) — generate code, show once with /bindcode copy command, list codes with status chips and revoke - frontend/src/App.svelte: /validators/telegram role-gated route - frontend/src/components/Sidebar.svelte: Telegram Support link in both validator sections (role-locked pattern) - frontend/src/lib/api.js: issue/list/revoke bind-code helpers - frontend/src/lib/config.js: DECKARD_BOT_USERNAME from VITE_DECKARD_BOT_USERNAME with generic-wording fallback - backend/CLAUDE.md + frontend/CLAUDE.md: documented the new model, endpoints, scope, route, and API helpers --- backend/CLAUDE.md | 14 +- backend/service_accounts/scopes.py | 8 +- backend/social_connections/admin.py | 8 + .../migrations/0008_telegramconnection.py | 35 ++ backend/social_connections/models.py | 17 + backend/tally/settings.py | 2 + backend/utils/throttling.py | 9 + .../migrations/0018_telegramgroupbindcode.py | 36 ++ backend/validators/models.py | 114 ++++++ backend/validators/serializers.py | 27 +- .../tests/test_telegram_bind_codes.py | 331 ++++++++++++++++++ backend/validators/urls.py | 17 +- backend/validators/views.py | 216 +++++++++++- frontend/CLAUDE.md | 2 + frontend/src/App.svelte | 2 + frontend/src/components/Sidebar.svelte | 26 ++ frontend/src/lib/api.js | 7 +- frontend/src/lib/config.js | 4 + frontend/src/routes/ValidatorTelegram.svelte | 249 +++++++++++++ 19 files changed, 1116 insertions(+), 8 deletions(-) create mode 100644 backend/social_connections/migrations/0008_telegramconnection.py create mode 100644 backend/validators/migrations/0018_telegramgroupbindcode.py create mode 100644 backend/validators/tests/test_telegram_bind_codes.py create mode 100644 frontend/src/routes/ValidatorTelegram.svelte diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 3c16f8f0..a324158b 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -27,7 +27,7 @@ backend/ ├── api/ # Core API app ├── contributions/ # Contribution tracking ├── leaderboard/ # Leaderboard and rankings -├── social_connections/ # OAuth (GitHub, Twitter, Discord) + encrypted token storage +├── social_connections/ # OAuth (GitHub, Twitter, Discord) + encrypted token storage + Telegram (bot-confirmed, no OAuth) ├── social_tasks/ # Repeatable social tasks (follow, join, like) and completions ├── users/ # User management and auth ├── partners/ # Ecosystem partners directory @@ -118,6 +118,7 @@ backend/ - **Models**: `ServiceAccount` (name, description, is_active; acts as the DRF request principal; it is NOT a User and can never become a session) and `ServiceAccountToken` (unique non-secret `identifier`; unique SHA-256 `digest` of the plaintext, which is never stored; `scopes` list; `expires_at`/`revoked_at`/`last_used_at`, with `last_used_at` writes throttled to once per minute). - **Auth class**: `service_accounts.authentication.ServiceAccountAuthentication` parses `Bearer sa__`, looks up the token by non-secret id, compares digests in constant time, rejects expired/revoked/inactive with a generic 401. Non-`sa_` credentials pass through to other authenticators. - **Permission**: `service_accounts.permissions.HasServiceAccountScope`; views declare `required_scopes = {'': '', '*': ''}`. +- **Scopes** (`service_accounts/scopes.py`): `ai_review:read`, `ai_review:propose` (AI review agent), `telegram_bind:redeem` (Deckard Telegram bot redeeming validator group bind codes). New scopes must be added to `ALLOWED_SERVICE_ACCOUNT_SCOPES` or admin token issuance rejects them. - **Issue a token**: Django admin Service account change page -> "Issue token", or `python manage.py issue_service_account_token --scopes ... [--expires-days N]`; plaintext is shown exactly once. Rotate = issue new + revoke old (admin action on Service account tokens); kill switch = deactivate the account. - **Tests**: `service_accounts/tests/`; test helper `service_accounts.testing.service_account_auth_headers()` returns client auth kwargs. @@ -295,6 +296,7 @@ backend/ - ValidatorWalletStatusSnapshot - Daily wallet rollup. On-chain `status` (owned by the on-chain sync, for uptime lookback) PLUS the latched observability verdict written by the Grafana sync: `metrics_status` / `logs_status` / `version_status`, `metrics_samples` / `logs_samples` counters, and `node_version`. **Metrics and logs latch pessimistically** (worst-of-day: shame at ANY observation → the day is shame). **Version latches optimistically** (best-of-day: a single up-to-date observation → the day is OK, since once a node upgrades that day an earlier stale reading must not shame it; `on` > `warning` > `shame`). A day is "clean" only if `status=='active'` and both sample counters are ≥1 and neither metrics nor logs is `shame` and version is not `shame`. The two syncs write disjoint columns (bulk_create update_conflicts on `(wallet, date)`), so neither clobbers the other. - ValidatorWalletObservation - Append-only raw log; one row per active wallet per Grafana sync run (`observed_at`, `onchain_status`, `metrics_status`, `logs_status`, `version_status`, `node_version`). Source of truth the daily rollup is materialised from and rebuildable via `rebuild_daily_snapshots`. - SyncLock - Database-backed sync coordination row with owner token for cross-worker locking + - TelegramGroupBindCode - One-time code binding a Telegram group to a validator via the Deckard support bot. Plaintext (`tgb__`) is returned exactly once at issuance; only the SHA-256 digest is stored (identifier lookup + constant-time compare, mirroring ServiceAccountToken). 48h expiry (lazy: `effective_status`), statuses issued/redeemed/expired/revoked, multiple active codes per validator (one group per code). Redemption records `redeemed_group_chat_id` + `redeemed_by_telegram_uid` and upserts the issuing user's `social_connections.TelegramConnection` (numeric Telegram uid = identity, username display-only, no OAuth tokens). - **Services**: `validators/grafana_service.py` - GrafanaValidatorStatusService - Polls Grafana Cloud (`/api/ds/query`) Prometheus + Loki datasources and updates `ValidatorWallet.metrics_status` / `logs_status` for `status='active'` wallets, per network. The Prometheus query also reads the `version` label from `genlayer_node_info` — **normalised at ingest** in `parse_response` ('v' prefix stripped, capped to the 50-char column; when a node briefly reports two version series right after an upgrade, the higher parseable one wins). Each run writes a `ValidatorWalletObservation` and latches today's `ValidatorWalletStatusSnapshot` rollup (`_record_history`, best-effort — never breaks the live status sync). Observations are retained forever by explicit decision — no pruning in points. Used by the Wall of Shame cron. - GrafanaValidatorStatusService is also the **source of truth for node versions** (`_sync_node_versions`, best-effort, runs before the active-wallet early return so networks with zero active wallets are still covered): version detection covers **every reporting node on the network regardless of on-chain status** (a quarantined node can still record its upgrade), **except banned wallets**, and only counts versions observed on wallets known to the DB and linked to an operator — the `version` label is self-reported by the node being judged and rewarded, so unknown Prometheus series count for nothing. Only versions that are both semver-valid AND PEP 440-parseable drive comparisons (e.g. `0.6.0-genlayer.1` is excluded — `packaging` can't parse it; in the shame loop an unparseable observed version or an unparseable active target yields `version_status='unknown'`, never a lexicographic fallback verdict). It auto-creates a `TargetNodeVersion` when a STABLE release (bare `x.y.z`, no pre-release/build) higher than the active target is reported by **at least `NODE_VERSION_MIN_OPERATORS_FOR_AUTO_TARGET` (default 1: the first adopter creates the target) distinct operators** (`target_date=now`; an unparseable active target is never blindly superseded; a broadcast notification is emitted via `broadcast_target_node_version`), raises each linked operator's `node_version_` to their highest observed version via a direct `.update()` (**monotonic** — a wallet skipping a scrape cycle can't transiently downgrade the field; genuine downgrades need admin correction), and directly awards an already-approved `node-upgrade` Contribution (`_award_node_upgrade`, early-bonus 4/3/2/1) when a visible operator first reaches the active target. **Removing the node-upgrade multiplier pauses the auto-award** (it is skipped with a warning, not created at 1.0). The per-operator loop is individually fault-isolated — one operator's failure never blocks the rest. Dedup shares the exact `version {v} [{network}]` notes key with the old manual flow so nothing double-awards. A run where a whole datasource comes back empty (no Prometheus series or no Loki counts) still updates live wallet statuses (they self-heal) but **skips the permanent history latch** — a datasource blackout must not shame every validator's recorded day. @@ -306,6 +308,10 @@ backend/ - `/api/v1/validators/wallets/sync/` - POST cron-protected background sync trigger with DB-backed lock (on-chain validator sync) - `/api/v1/validators/wallets/sync-grafana/` - POST cron-protected background sync trigger for Grafana observability cross-check (separate SyncLock row `grafana_status_sync` so it can run alongside the on-chain sync) - `/api/v1/validators/wallets/wall-of-shame/` - Public read-only endpoint listing active validator wallets with `metrics_status` / `logs_status`. SHAME rows sort first. Cached 60s. Optional `?network=asimov|bradbury` filter. Each wallet also carries `clean_streak_days` + `clean_streak_broken_by` (consecutive not-shamed days for that node, from `validators/streaks.py` over the daily rollup). The grouped `validators` output adds `network_streaks` — per-operator-per-network any-node-clean streaks (a network-day is clean if ≥1 of the operator's nodes was clean) — plus per-node `clean_streak_days` on each `networks` entry. Streaks start accumulating at deploy (history wasn't recorded before). Days with no Grafana data while the node was active (sync outage, pre-history) are SKIPPED — they neither count nor break, so an infra failure on our side never resets streaks; days spent non-active per the on-chain sync break the streak with `broken_by: ['status']`. + - `/api/v1/validators/telegram-bind-codes/` - POST (auth, validator profile required, `telegram_bind_issue` throttle 10/hour) issues a bind code and returns the plaintext ONCE + - `/api/v1/validators/telegram-bind-codes/mine/` - GET the current user's codes (metadata only, never raw codes) + - `/api/v1/validators/telegram-bind-codes/{id}/revoke/` - POST owner-only revoke of an unredeemed code (409 if already redeemed) + - `/api/v1/validators/telegram-bind-codes/redeem/` - POST for the Deckard Telegram bot only: service account bearer token with scope `telegram_bind:redeem`. Body `{code, group_chat_id, telegram_uid, telegram_username?}`. Single-use atomic redemption: marks the code, records group + uid, upserts TelegramConnection. Errors carry a machine `code`: `invalid_request` (400), `invalid_code` (404), `already_redeemed`/`revoked` (409), `expired` (410). DM-vs-group is enforced bot-side. - `/api/v1/validators/wallets/grafana/` - Public minimal roster for the Grafana Infinity datasource (`GrafanaValidatorSerializer`). Flat array, one row per wallet across ALL statuses; fields: `network` (Grafana label value e.g. `asimov-phase5`), `node` (on-chain validator address == Prometheus `genlayer_node_info` `node` label, lowercased), `name`, `status`, `operator`, `account`/`account_name` (only for visible operators), `explorer_url`, plus **raw link/identity facts** (verdicts are computed dashboard-side, NOT here): `linked` (bool — wallet attributed to a portal account; a bare fact, safe for non-visible operators), `moniker` and `logo_uri` (raw synced `getIdentity()` values, empty string = unset), `has_description` (presence bool so the roster doesn't ship long texts). Excludes observability/shame fields by design. Cached 60s. Optional `?network=asimov|bradbury` filter. - The roster **also appends one synthetic `status='missing'` row per network** for every graduated portal validator (visible Validator role user) with no wallet linked on that network (`_missing_graduated_rows` in the view) — graduated validators are expected on every testnet, and an absent one otherwise has no row for dashboards to show. On these rows `node` = the account address (unique join key only — matches no metric series), `operator` is null, and the link/identity facts (`linked`/`moniker`/`logo_uri`/`has_description`) are **null** (no wallet to describe — distinct from `false`/empty on a real wallet). A wallet of ANY status (incl. `inactive`) suppresses the missing row; an unlinked wallet (`operator=None`) does not. @@ -487,6 +493,12 @@ GET /api/v1/leaderboard/user_stats/by-address/{address}/ (requires auth) GET /api/v1/multipliers/ (requires auth) GET /api/v1/multiplier-periods/ +# Validators - Telegram group bind codes (Deckard support bot) +POST /api/v1/validators/telegram-bind-codes/ (requires auth + validator profile; returns plaintext code ONCE; throttled 10/hour) +GET /api/v1/validators/telegram-bind-codes/mine/ (requires auth; metadata only) +POST /api/v1/validators/telegram-bind-codes/{id}/revoke/ (requires auth, owner-only) +POST /api/v1/validators/telegram-bind-codes/redeem/ (service account bearer token, scope telegram_bind:redeem) + # Validators - Wall of Shame POST /api/v1/validators/wallets/sync-grafana/ (cron-protected, X-Cron-Token, background) GET /api/v1/validators/wallets/wall-of-shame/ (public, cached 60s, ?network= filter) diff --git a/backend/service_accounts/scopes.py b/backend/service_accounts/scopes.py index f8627c6f..c1c3db73 100644 --- a/backend/service_accounts/scopes.py +++ b/backend/service_accounts/scopes.py @@ -6,4 +6,10 @@ AI_REVIEW_PROPOSE_SCOPE, ) -ALLOWED_SERVICE_ACCOUNT_SCOPES = frozenset(AI_REVIEW_SCOPES) +# Granted to the Deckard Telegram support bot so it can redeem validator +# Telegram group bind codes (validators app). +TELEGRAM_BIND_REDEEM_SCOPE = 'telegram_bind:redeem' + +ALLOWED_SERVICE_ACCOUNT_SCOPES = frozenset( + AI_REVIEW_SCOPES + (TELEGRAM_BIND_REDEEM_SCOPE,) +) diff --git a/backend/social_connections/admin.py b/backend/social_connections/admin.py index 517e558b..0dcd0000 100644 --- a/backend/social_connections/admin.py +++ b/backend/social_connections/admin.py @@ -12,6 +12,7 @@ DiscordRoleSyncLock, GitHubConnection, PendingOAuthState, + TelegramConnection, TwitterConnection, UsedOAuthCode, ) @@ -68,6 +69,13 @@ class TwitterConnectionAdmin(admin.ModelAdmin): readonly_fields = ('platform_user_id', 'access_token', 'refresh_token', 'linked_at', 'created_at', 'updated_at') +@admin.register(TelegramConnection) +class TelegramConnectionAdmin(admin.ModelAdmin): + list_display = ('user', 'platform_username', 'platform_user_id', 'linked_at') + search_fields = ('user__email', 'platform_username', 'platform_user_id') + readonly_fields = ('platform_user_id', 'linked_at', 'created_at', 'updated_at') + + @admin.register(DiscordConnection) class DiscordConnectionAdmin(admin.ModelAdmin): list_display = ( diff --git a/backend/social_connections/migrations/0008_telegramconnection.py b/backend/social_connections/migrations/0008_telegramconnection.py new file mode 100644 index 00000000..5f31fac3 --- /dev/null +++ b/backend/social_connections/migrations/0008_telegramconnection.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.10 on 2026-07-30 10:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('social_connections', '0007_alter_discordearnedroleassignment_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TelegramConnection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform_user_id', models.CharField(help_text="Platform's unique user ID", max_length=100)), + ('platform_username', models.CharField(help_text='Username on the platform', max_length=100)), + ('access_token', models.TextField(blank=True, help_text='Encrypted OAuth access token')), + ('refresh_token', models.TextField(blank=True, help_text='Encrypted OAuth refresh token')), + ('linked_at', models.DateTimeField(help_text='When the account was linked')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Telegram Connection', + 'verbose_name_plural': 'Telegram Connections', + 'db_table': 'social_connections_telegram', + }, + ), + ] diff --git a/backend/social_connections/models.py b/backend/social_connections/models.py index 43f61266..8801d569 100644 --- a/backend/social_connections/models.py +++ b/backend/social_connections/models.py @@ -48,6 +48,23 @@ class Meta: verbose_name_plural = 'Twitter Connections' +class TelegramConnection(SocialConnection): + """Telegram identity for a portal account. + + Telegram has no OAuth flow here: the connection is created/confirmed when + the Deckard support bot redeems a Telegram group bind code issued by this + user (see validators.TelegramGroupBindCode). `platform_user_id` holds the + numeric Telegram user id (the stable identity); `platform_username` is the + display-only @username, which Telegram users can change at any time. The + inherited OAuth token fields stay empty. + """ + + class Meta: + db_table = 'social_connections_telegram' + verbose_name = 'Telegram Connection' + verbose_name_plural = 'Telegram Connections' + + class DiscordConnection(SocialConnection): discriminator = models.CharField(max_length=10, blank=True, help_text="Discord discriminator (legacy)") avatar_hash = models.CharField(max_length=100, blank=True, help_text="Discord avatar hash for CDN URL") diff --git a/backend/tally/settings.py b/backend/tally/settings.py index 4da3eb72..7c7bd64d 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -221,6 +221,8 @@ def get_required_env(key): 'public_leaderboard': '300/minute', # Wallet linking is a one-time action per validator 'wallet_link': '10/hour', + # Telegram group bind codes are live secrets for 48h; bound hoarding + 'telegram_bind_issue': '10/hour', 'pending_email_start': '10/hour', 'pending_email_resend': '10/hour', 'pending_email_confirm': '30/hour', diff --git a/backend/utils/throttling.py b/backend/utils/throttling.py index d274590f..2c1d569b 100644 --- a/backend/utils/throttling.py +++ b/backend/utils/throttling.py @@ -18,6 +18,15 @@ class WalletLinkRateThrottle(UserRateThrottle): scope = 'wallet_link' +class TelegramBindCodeIssueRateThrottle(UserRateThrottle): + """ + Per-user throttle for issuing Telegram group bind codes. Codes are cheap + to mint but each is a live secret for 48h; a tight rate bounds hoarding + and keeps the active-code surface small. + """ + scope = 'telegram_bind_issue' + + class PendingEmailStartRateThrottle(AnonRateThrottle): scope = 'pending_email_start' diff --git a/backend/validators/migrations/0018_telegramgroupbindcode.py b/backend/validators/migrations/0018_telegramgroupbindcode.py new file mode 100644 index 00000000..8b853c1f --- /dev/null +++ b/backend/validators/migrations/0018_telegramgroupbindcode.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.10 on 2026-07-30 10:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('validators', '0017_validatoroperatorwallet'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TelegramGroupBindCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('identifier', models.CharField(help_text='Non-secret lookup id embedded in the code', max_length=16, unique=True)), + ('digest', models.CharField(help_text='SHA-256 digest of the plaintext code (plaintext is never stored)', max_length=64, unique=True)), + ('status', models.CharField(choices=[('issued', 'Issued'), ('redeemed', 'Redeemed'), ('expired', 'Expired'), ('revoked', 'Revoked')], db_index=True, default='issued', max_length=10)), + ('expires_at', models.DateTimeField()), + ('redeemed_at', models.DateTimeField(blank=True, null=True)), + ('redeemed_group_chat_id', models.CharField(blank=True, help_text='Telegram chat id of the bound group (set on redemption)', max_length=32)), + ('redeemed_by_telegram_uid', models.CharField(blank=True, help_text='Numeric Telegram user id that redeemed the code (set on redemption)', max_length=32)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='telegram_bind_codes', to=settings.AUTH_USER_MODEL)), + ('validator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='telegram_bind_codes', to='validators.validator')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/validators/models.py b/backend/validators/models.py index c200fcea..82747ca6 100644 --- a/backend/validators/models.py +++ b/backend/validators/models.py @@ -1,3 +1,7 @@ +import hashlib +import secrets +from datetime import timedelta + from django.conf import settings from django.core.exceptions import FieldDoesNotExist from django.db import IntegrityError, models, transaction @@ -190,6 +194,116 @@ def ensure_validator_profile(user): return validator +class TelegramGroupBindCode(BaseModel): + """One-time code that binds a Telegram group to a validator via the Deckard bot. + + A validator issues a code from the portal, pastes it in their Telegram + group, and the Deckard support bot redeems it server-to-server (service + account scope `telegram_bind:redeem`). One code binds exactly one group; + a validator may hold multiple active codes (and therefore bind multiple + groups). DM bindings are refused bot-side. + + The plaintext code is shown exactly once at issuance. Only its SHA-256 + digest is stored, mirroring service_accounts.ServiceAccountToken: lookup is + by the non-secret `identifier` embedded in the code, then the presented + digest is compared in constant time. + """ + + CODE_PREFIX = 'tgb_' + DEFAULT_TTL_HOURS = 48 + + STATUS_ISSUED = 'issued' + STATUS_REDEEMED = 'redeemed' + STATUS_EXPIRED = 'expired' + STATUS_REVOKED = 'revoked' + STATUS_CHOICES = [ + (STATUS_ISSUED, 'Issued'), + (STATUS_REDEEMED, 'Redeemed'), + (STATUS_EXPIRED, 'Expired'), + (STATUS_REVOKED, 'Revoked'), + ] + + validator = models.ForeignKey( + 'Validator', + on_delete=models.CASCADE, + related_name='telegram_bind_codes', + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='telegram_bind_codes', + ) + identifier = models.CharField( + max_length=16, unique=True, + help_text="Non-secret lookup id embedded in the code", + ) + digest = models.CharField( + max_length=64, unique=True, + help_text="SHA-256 digest of the plaintext code (plaintext is never stored)", + ) + status = models.CharField( + max_length=10, choices=STATUS_CHOICES, default=STATUS_ISSUED, db_index=True, + ) + expires_at = models.DateTimeField() + redeemed_at = models.DateTimeField(null=True, blank=True) + redeemed_group_chat_id = models.CharField( + max_length=32, blank=True, + help_text="Telegram chat id of the bound group (set on redemption)", + ) + redeemed_by_telegram_uid = models.CharField( + max_length=32, blank=True, + help_text="Numeric Telegram user id that redeemed the code (set on redemption)", + ) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"TelegramGroupBindCode {self.identifier} ({self.status})" + + @staticmethod + def hash_code(plaintext): + # Plain SHA-256 (not salted_hmac): the code secret has enough entropy + # for its 48h single-use lifetime, and this survives SECRET_KEY rotation. + return hashlib.sha256(plaintext.encode('utf-8')).hexdigest() + + @classmethod + def identifier_from_plaintext(cls, plaintext): + """Extract the non-secret lookup identifier from `tgb__`.""" + if not plaintext or not plaintext.startswith(cls.CODE_PREFIX): + return None + remainder = plaintext[len(cls.CODE_PREFIX):] + identifier, separator, secret = remainder.partition('_') + if not separator or not identifier or not secret: + return None + return identifier + + @classmethod + def issue(cls, validator, created_by, ttl_hours=DEFAULT_TTL_HOURS): + """Create a bind code and return (instance, plaintext).""" + identifier = secrets.token_hex(6) + plaintext = f'{cls.CODE_PREFIX}{identifier}_{secrets.token_urlsafe(12)}' + bind_code = cls.objects.create( + validator=validator, + created_by=created_by, + identifier=identifier, + digest=cls.hash_code(plaintext), + expires_at=timezone.now() + timedelta(hours=ttl_hours), + ) + return bind_code, plaintext + + @property + def effective_status(self): + """`status` with lazy expiry: an issued code past expires_at reads expired.""" + if self.status == self.STATUS_ISSUED and self.expires_at <= timezone.now(): + return self.STATUS_EXPIRED + return self.status + + def is_redeemable(self, now=None): + now = now or timezone.now() + return self.status == self.STATUS_ISSUED and self.expires_at > now + + class ValidatorWalletStatusSnapshot(BaseModel): """ Daily snapshot of a validator wallet's status. diff --git a/backend/validators/serializers.py b/backend/validators/serializers.py index f4519308..f677339e 100644 --- a/backend/validators/serializers.py +++ b/backend/validators/serializers.py @@ -1,9 +1,34 @@ from rest_framework import serializers from django.conf import settings -from .models import ValidatorOperatorWallet, ValidatorWallet, Validator +from .models import TelegramGroupBindCode, ValidatorOperatorWallet, ValidatorWallet, Validator from users.utils import truncate_address +class TelegramBindCodeSerializer(serializers.ModelSerializer): + """List/detail serializer for Telegram group bind codes. + + Never exposes the plaintext code or its digest — the plaintext is returned + exactly once by the issuance endpoint. `status` is the lazy-expiry + effective status, so an issued code past its expiry reads 'expired'. + """ + + status = serializers.CharField(source='effective_status', read_only=True) + + class Meta: + model = TelegramGroupBindCode + fields = [ + 'id', + 'identifier', + 'status', + 'expires_at', + 'redeemed_at', + 'redeemed_group_chat_id', + 'redeemed_by_telegram_uid', + 'created_at', + ] + read_only_fields = fields + + def grafana_network_label(network): """Grafana `network` label value for a portal network key (e.g. 'asimov-phase5').""" return settings.GRAFANA_NETWORK_LABELS.get(network, network) diff --git a/backend/validators/tests/test_telegram_bind_codes.py b/backend/validators/tests/test_telegram_bind_codes.py new file mode 100644 index 00000000..03ceadeb --- /dev/null +++ b/backend/validators/tests/test_telegram_bind_codes.py @@ -0,0 +1,331 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from service_accounts.testing import service_account_auth_headers +from social_connections.models import TelegramConnection +from validators.models import TelegramGroupBindCode, Validator + +User = get_user_model() + +ISSUE_URL = '/api/v1/validators/telegram-bind-codes/' +MINE_URL = '/api/v1/validators/telegram-bind-codes/mine/' +REDEEM_URL = '/api/v1/validators/telegram-bind-codes/redeem/' + +GROUP_CHAT_ID = '-1001234567890' +TELEGRAM_UID = '424242424242' + + +def redeem_payload(code, **overrides): + payload = { + 'code': code, + 'group_chat_id': GROUP_CHAT_ID, + 'telegram_uid': TELEGRAM_UID, + 'telegram_username': 'validator_ops', + } + payload.update(overrides) + return payload + + +class TelegramBindCodeIssuanceTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email='validator@example.com', + password='testpass123', + address='0x1111111111111111111111111111111111111111', + name='Validator One', + ) + self.validator = Validator.objects.create(user=self.user) + + def test_anonymous_cannot_issue(self): + response = self.client.post(ISSUE_URL) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) + self.assertEqual(TelegramGroupBindCode.objects.count(), 0) + + def test_user_without_validator_profile_cannot_issue(self): + plain_user = User.objects.create_user( + email='plain@example.com', + password='testpass123', + ) + self.client.force_authenticate(user=plain_user) + response = self.client.post(ISSUE_URL) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(TelegramGroupBindCode.objects.count(), 0) + + def test_validator_issues_code_and_plaintext_is_returned_once(self): + self.client.force_authenticate(user=self.user) + response = self.client.post(ISSUE_URL) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + code = response.data['code'] + self.assertTrue(code.startswith(TelegramGroupBindCode.CODE_PREFIX)) + self.assertEqual(response.data['status'], TelegramGroupBindCode.STATUS_ISSUED) + + bind_code = TelegramGroupBindCode.objects.get(id=response.data['id']) + self.assertEqual(bind_code.validator, self.validator) + self.assertEqual(bind_code.created_by, self.user) + # Only the digest is stored, never the plaintext. + self.assertEqual(bind_code.digest, TelegramGroupBindCode.hash_code(code)) + self.assertNotIn(code, [bind_code.identifier, bind_code.digest]) + # 48h expiry window. + ttl = bind_code.expires_at - timezone.now() + self.assertGreater(ttl, timedelta(hours=47)) + self.assertLessEqual(ttl, timedelta(hours=48)) + + def test_validator_can_hold_multiple_active_codes(self): + self.client.force_authenticate(user=self.user) + first = self.client.post(ISSUE_URL) + second = self.client.post(ISSUE_URL) + self.assertEqual(first.status_code, status.HTTP_201_CREATED) + self.assertEqual(second.status_code, status.HTTP_201_CREATED) + self.assertEqual( + TelegramGroupBindCode.objects.filter( + validator=self.validator, + status=TelegramGroupBindCode.STATUS_ISSUED, + ).count(), + 2, + ) + + def test_mine_lists_codes_without_raw_secrets(self): + self.client.force_authenticate(user=self.user) + issued = self.client.post(ISSUE_URL) + + response = self.client.get(MINE_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + entry = response.data[0] + self.assertEqual(entry['id'], issued.data['id']) + self.assertNotIn('code', entry) + self.assertNotIn('digest', entry) + + def test_mine_only_returns_own_codes(self): + other = User.objects.create_user( + email='other-validator@example.com', + password='testpass123', + ) + other_validator = Validator.objects.create(user=other) + TelegramGroupBindCode.issue(other_validator, other) + + self.client.force_authenticate(user=self.user) + response = self.client.get(MINE_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, []) + + def test_mine_reports_lazy_expiry(self): + self.client.force_authenticate(user=self.user) + bind_code, _ = TelegramGroupBindCode.issue(self.validator, self.user) + TelegramGroupBindCode.objects.filter(pk=bind_code.pk).update( + expires_at=timezone.now() - timedelta(minutes=1) + ) + + response = self.client.get(MINE_URL) + self.assertEqual( + response.data[0]['status'], TelegramGroupBindCode.STATUS_EXPIRED + ) + + +class TelegramBindCodeRevokeTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email='validator@example.com', + password='testpass123', + ) + self.validator = Validator.objects.create(user=self.user) + self.bind_code, self.plaintext = TelegramGroupBindCode.issue( + self.validator, self.user + ) + self.client.force_authenticate(user=self.user) + + def _revoke(self, code_id): + return self.client.post( + f'/api/v1/validators/telegram-bind-codes/{code_id}/revoke/' + ) + + def test_owner_can_revoke_issued_code(self): + response = self._revoke(self.bind_code.id) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], TelegramGroupBindCode.STATUS_REVOKED) + self.bind_code.refresh_from_db() + self.assertEqual(self.bind_code.status, TelegramGroupBindCode.STATUS_REVOKED) + + def test_revoked_code_cannot_be_redeemed(self): + from rest_framework.test import APIClient + + self._revoke(self.bind_code.id) + response = APIClient().post( + REDEEM_URL, + redeem_payload(self.plaintext), + format='json', + **service_account_auth_headers(['telegram_bind:redeem']), + ) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(response.data['code'], 'revoked') + + def test_redeemed_code_cannot_be_revoked(self): + TelegramGroupBindCode.objects.filter(pk=self.bind_code.pk).update( + status=TelegramGroupBindCode.STATUS_REDEEMED + ) + response = self._revoke(self.bind_code.id) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_non_owner_cannot_revoke(self): + other = User.objects.create_user( + email='other@example.com', + password='testpass123', + ) + self.client.force_authenticate(user=other) + response = self._revoke(self.bind_code.id) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.bind_code.refresh_from_db() + self.assertEqual(self.bind_code.status, TelegramGroupBindCode.STATUS_ISSUED) + + +class TelegramBindCodeRedeemTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email='validator@example.com', + password='testpass123', + name='Validator One', + ) + self.validator = Validator.objects.create(user=self.user) + self.bind_code, self.plaintext = TelegramGroupBindCode.issue( + self.validator, self.user + ) + self.auth = service_account_auth_headers(['telegram_bind:redeem']) + + def _redeem(self, payload, auth=None): + return self.client.post( + REDEEM_URL, payload, format='json', **(auth if auth is not None else self.auth) + ) + + def test_redeem_requires_service_account_token(self): + response = self._redeem(redeem_payload(self.plaintext), auth={}) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) + + def test_session_authenticated_user_cannot_redeem(self): + self.client.force_authenticate(user=self.user) + response = self._redeem(redeem_payload(self.plaintext), auth={}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_redeem_requires_matching_scope(self): + wrong_scope = service_account_auth_headers( + ['ai_review:read'], name='wrong-scope-account' + ) + response = self._redeem(redeem_payload(self.plaintext), auth=wrong_scope) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.bind_code.refresh_from_db() + self.assertEqual(self.bind_code.status, TelegramGroupBindCode.STATUS_ISSUED) + + def test_successful_redeem_binds_group_and_upserts_connection(self): + response = self._redeem(redeem_payload(self.plaintext)) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['success']) + self.assertEqual(response.data['validator_id'], self.validator.id) + self.assertEqual(response.data['group_chat_id'], GROUP_CHAT_ID) + + self.bind_code.refresh_from_db() + self.assertEqual(self.bind_code.status, TelegramGroupBindCode.STATUS_REDEEMED) + self.assertEqual(self.bind_code.redeemed_group_chat_id, GROUP_CHAT_ID) + self.assertEqual(self.bind_code.redeemed_by_telegram_uid, TELEGRAM_UID) + self.assertIsNotNone(self.bind_code.redeemed_at) + + connection = TelegramConnection.objects.get(user=self.user) + self.assertEqual(connection.platform_user_id, TELEGRAM_UID) + self.assertEqual(connection.platform_username, 'validator_ops') + + def test_redeem_is_single_use(self): + first = self._redeem(redeem_payload(self.plaintext)) + second = self._redeem( + redeem_payload(self.plaintext, group_chat_id='-1009999999999') + ) + + self.assertEqual(first.status_code, status.HTTP_200_OK) + self.assertEqual(second.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(second.data['code'], 'already_redeemed') + self.bind_code.refresh_from_db() + # The original binding is untouched. + self.assertEqual(self.bind_code.redeemed_group_chat_id, GROUP_CHAT_ID) + + def test_second_code_binds_second_group_for_same_validator(self): + second_code, second_plaintext = TelegramGroupBindCode.issue( + self.validator, self.user + ) + first = self._redeem(redeem_payload(self.plaintext)) + second = self._redeem( + redeem_payload(second_plaintext, group_chat_id='-1009999999999') + ) + + self.assertEqual(first.status_code, status.HTTP_200_OK) + self.assertEqual(second.status_code, status.HTTP_200_OK) + second_code.refresh_from_db() + self.assertEqual(second_code.redeemed_group_chat_id, '-1009999999999') + + def test_expired_code_cannot_be_redeemed(self): + TelegramGroupBindCode.objects.filter(pk=self.bind_code.pk).update( + expires_at=timezone.now() - timedelta(minutes=1) + ) + response = self._redeem(redeem_payload(self.plaintext)) + + self.assertEqual(response.status_code, status.HTTP_410_GONE) + self.assertEqual(response.data['code'], 'expired') + self.bind_code.refresh_from_db() + self.assertEqual(self.bind_code.status, TelegramGroupBindCode.STATUS_EXPIRED) + self.assertFalse(TelegramConnection.objects.filter(user=self.user).exists()) + + def test_unknown_code_is_rejected(self): + response = self._redeem(redeem_payload('tgb_deadbeefdead_notarealsecret')) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data['code'], 'invalid_code') + + def test_tampered_secret_is_rejected(self): + # Same identifier, wrong secret: the constant-time digest compare fails. + identifier = self.bind_code.identifier + response = self._redeem( + redeem_payload(f'tgb_{identifier}_wrongsecretvalue') + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.bind_code.refresh_from_db() + self.assertEqual(self.bind_code.status, TelegramGroupBindCode.STATUS_ISSUED) + + def test_malformed_payload_is_rejected(self): + missing_code = self._redeem({ + 'group_chat_id': GROUP_CHAT_ID, + 'telegram_uid': TELEGRAM_UID, + }) + bad_chat = self._redeem( + redeem_payload(self.plaintext, group_chat_id='not-a-chat-id') + ) + bad_uid = self._redeem( + redeem_payload(self.plaintext, telegram_uid='abc') + ) + for response in (missing_code, bad_chat, bad_uid): + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.bind_code.refresh_from_db() + self.assertEqual(self.bind_code.status, TelegramGroupBindCode.STATUS_ISSUED) + + def test_redeem_updates_existing_connection_and_keeps_username(self): + TelegramConnection.objects.create( + user=self.user, + platform_user_id='111', + platform_username='old_handle', + linked_at=timezone.now() - timedelta(days=30), + ) + response = self._redeem( + redeem_payload(self.plaintext, telegram_username='') + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + connection = TelegramConnection.objects.get(user=self.user) + self.assertEqual(connection.platform_user_id, TELEGRAM_UID) + # No username in the payload never blanks the stored handle. + self.assertEqual(connection.platform_username, 'old_handle') diff --git a/backend/validators/urls.py b/backend/validators/urls.py index 0af9fcde..d5a6f1e2 100644 --- a/backend/validators/urls.py +++ b/backend/validators/urls.py @@ -1,11 +1,24 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import ValidatorViewSet, ValidatorWalletViewSet +from .views import ( + TelegramBindCodeRedeemView, + TelegramBindCodeViewSet, + ValidatorViewSet, + ValidatorWalletViewSet, +) router = DefaultRouter() router.register(r'wallets', ValidatorWalletViewSet, basename='validator-wallet') +router.register(r'telegram-bind-codes', TelegramBindCodeViewSet, basename='telegram-bind-code') router.register(r'', ValidatorViewSet, basename='validator') urlpatterns = [ + # Service-account (Deckard bot) redemption; must precede the router so the + # viewset's detail route never swallows 'redeem'. + path( + 'telegram-bind-codes/redeem/', + TelegramBindCodeRedeemView.as_view(), + name='telegram-bind-code-redeem', + ), path('', include(router.urls)), -] \ No newline at end of file +] diff --git a/backend/validators/views.py b/backend/validators/views.py index 08ec6460..31a6d9bf 100644 --- a/backend/validators/views.py +++ b/backend/validators/views.py @@ -1,20 +1,30 @@ import logging import re +import secrets as secrets_lib import uuid from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.views import APIView -from utils.throttling import WalletLinkRateThrottle +from utils.throttling import TelegramBindCodeIssueRateThrottle, WalletLinkRateThrottle from django.db.models import Min, Q, Count from django.db import IntegrityError, transaction from django.conf import settings from django.utils import timezone from django.core.cache import cache -from .models import SyncLock, Validator, ValidatorOperatorWallet, ValidatorWallet +from .models import ( + SyncLock, + TelegramGroupBindCode, + Validator, + ValidatorOperatorWallet, + ValidatorWallet, + get_validator_profile, +) from .serializers import ( GrafanaValidatorSerializer, + TelegramBindCodeSerializer, ValidatorOperatorWalletSerializer, ValidatorWalletSerializer, WallOfShameSerializer, @@ -22,6 +32,9 @@ grafana_network_label, ) from .permissions import IsCronToken +from service_accounts.authentication import ServiceAccountAuthentication +from service_accounts.permissions import HasServiceAccountScope +from service_accounts.scopes import TELEGRAM_BIND_REDEEM_SCOPE from .genlayer_validators_service import GenLayerValidatorsService from .grafana_service import GrafanaValidatorStatusService from .version_status import compute_version_status @@ -1145,3 +1158,202 @@ def wall_of_shame(self, request): } cache.set(cache_key, payload, self.WALL_OF_SHAME_CACHE_TTL_SECONDS) return Response(payload) + + +class TelegramBindCodeViewSet(viewsets.GenericViewSet): + """ + Validator-facing Telegram group bind codes. + + A validator issues a one-time code here, pastes it in their Telegram group + (`/bindcode `), and the Deckard support bot redeems it through the + service-account endpoint below. The plaintext code is returned exactly once + by `create`; every other read only exposes metadata. + """ + permission_classes = [IsAuthenticated] + serializer_class = TelegramBindCodeSerializer + + def get_queryset(self): + return TelegramGroupBindCode.objects.filter(created_by=self.request.user) + + def get_throttles(self): + if self.action == 'create': + return [TelegramBindCodeIssueRateThrottle()] + return super().get_throttles() + + def create(self, request): + """Issue a new bind code. Returns the plaintext code ONCE.""" + validator = get_validator_profile(request.user) + if validator is None: + return Response( + {'error': 'Only validators can issue Telegram group bind codes.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + bind_code, plaintext = TelegramGroupBindCode.issue(validator, request.user) + logger.info( + "Telegram bind code issued: user=%s (id=%s) code_id=%s", + request.user.address, request.user.id, bind_code.id, + ) + data = TelegramBindCodeSerializer(bind_code).data + data['code'] = plaintext + return Response(data, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=['get']) + def mine(self, request): + """List the current user's bind codes (metadata only, no raw codes).""" + codes = self.get_queryset() + serializer = self.get_serializer(codes, many=True) + return Response(serializer.data) + + @action(detail=True, methods=['post']) + def revoke(self, request, pk=None): + """Revoke an unredeemed bind code so it can never bind a group.""" + with transaction.atomic(): + bind_code = ( + self.get_queryset() + .select_for_update() + .filter(pk=pk) + .first() + ) + if bind_code is None: + return Response( + {'error': 'Bind code not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + if bind_code.status == TelegramGroupBindCode.STATUS_REDEEMED: + return Response( + {'error': 'This code was already redeemed and cannot be revoked.'}, + status=status.HTTP_409_CONFLICT, + ) + if bind_code.status != TelegramGroupBindCode.STATUS_REVOKED: + bind_code.status = TelegramGroupBindCode.STATUS_REVOKED + bind_code.save(update_fields=['status', 'updated_at']) + return Response(self.get_serializer(bind_code).data) + + +class TelegramBindCodeRedeemView(APIView): + """ + Service-account endpoint for the Deckard Telegram bot. + + POST /api/v1/validators/telegram-bind-codes/redeem/ + Auth: `Authorization: Bearer sa__` with scope `telegram_bind:redeem`. + Body: {"code": "...", "group_chat_id": "...", "telegram_uid": "...", + "telegram_username": "optional"} + + Redemption is single-use and atomic: it marks the code redeemed, records + the bound group chat id and the redeeming Telegram uid, and upserts the + issuing user's TelegramConnection. The portal does not judge whether the + chat is a group vs a DM — the bot refuses DMs before ever calling this. + """ + authentication_classes = [ServiceAccountAuthentication] + permission_classes = [HasServiceAccountScope] + required_scopes = {'*': TELEGRAM_BIND_REDEEM_SCOPE} + + CHAT_ID_PATTERN = re.compile(r'^-?\d{1,31}$') + UID_PATTERN = re.compile(r'^\d{1,31}$') + + def post(self, request): + code = request.data.get('code') + group_chat_id = str(request.data.get('group_chat_id', '') or '').strip() + telegram_uid = str(request.data.get('telegram_uid', '') or '').strip() + telegram_username = str(request.data.get('telegram_username', '') or '').strip() + + if not isinstance(code, str) or not code.strip(): + return Response( + {'error': 'code is required.', 'code': 'invalid_request'}, + status=status.HTTP_400_BAD_REQUEST, + ) + code = code.strip() + if not self.CHAT_ID_PATTERN.match(group_chat_id): + return Response( + {'error': 'group_chat_id must be a Telegram chat id.', 'code': 'invalid_request'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not self.UID_PATTERN.match(telegram_uid): + return Response( + {'error': 'telegram_uid must be a numeric Telegram user id.', 'code': 'invalid_request'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + invalid_response = Response( + {'error': 'Invalid or unknown bind code.', 'code': 'invalid_code'}, + status=status.HTTP_404_NOT_FOUND, + ) + identifier = TelegramGroupBindCode.identifier_from_plaintext(code) + if identifier is None: + return invalid_response + digest = TelegramGroupBindCode.hash_code(code) + + from social_connections.models import TelegramConnection + + now = timezone.now() + with transaction.atomic(): + bind_code = ( + TelegramGroupBindCode.objects + .select_for_update() + .select_related('created_by', 'validator') + .filter(identifier=identifier) + .first() + ) + if bind_code is None or not secrets_lib.compare_digest(bind_code.digest, digest): + return invalid_response + + if bind_code.status == TelegramGroupBindCode.STATUS_REVOKED: + return Response( + {'error': 'This bind code was revoked.', 'code': 'revoked'}, + status=status.HTTP_409_CONFLICT, + ) + if bind_code.status == TelegramGroupBindCode.STATUS_REDEEMED: + return Response( + {'error': 'This bind code was already redeemed.', 'code': 'already_redeemed'}, + status=status.HTTP_409_CONFLICT, + ) + if not bind_code.is_redeemable(now): + if bind_code.status == TelegramGroupBindCode.STATUS_ISSUED: + bind_code.status = TelegramGroupBindCode.STATUS_EXPIRED + bind_code.save(update_fields=['status', 'updated_at']) + return Response( + {'error': 'This bind code has expired.', 'code': 'expired'}, + status=status.HTTP_410_GONE, + ) + + bind_code.status = TelegramGroupBindCode.STATUS_REDEEMED + bind_code.redeemed_at = now + bind_code.redeemed_group_chat_id = group_chat_id + bind_code.redeemed_by_telegram_uid = telegram_uid + bind_code.save(update_fields=[ + 'status', + 'redeemed_at', + 'redeemed_group_chat_id', + 'redeemed_by_telegram_uid', + 'updated_at', + ]) + + # Verifiable Telegram identity for the issuing portal account. The + # numeric uid is the stable identity; the display-only username is + # kept only when the bot supplies one, so a later redemption + # without it never blanks an existing handle. + connection_defaults = { + 'platform_user_id': telegram_uid, + 'linked_at': now, + } + if telegram_username: + connection_defaults['platform_username'] = telegram_username + TelegramConnection.objects.update_or_create( + user=bind_code.created_by, + defaults=connection_defaults, + ) + + logger.info( + "Telegram bind code redeemed: code_id=%s validator_id=%s group_chat_id=%s", + bind_code.id, bind_code.validator_id, group_chat_id, + ) + return Response({ + 'success': True, + 'bind_code_id': bind_code.id, + 'validator_id': bind_code.validator_id, + 'user_id': bind_code.created_by_id, + 'user_name': bind_code.created_by.name, + 'group_chat_id': group_chat_id, + 'redeemed_at': bind_code.redeemed_at, + }) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 18886390..0e9eb3f4 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -378,6 +378,7 @@ const routes = { '/validators/leaderboard': Leaderboard, '/validators/participants': Validators, '/validators/wall-of-shame': WallOfShame, + '/validators/telegram': ValidatorTelegram, // Link Telegram groups via Deckard bot bind codes '/validators/waitlist': Waitlist, '/validators/waitlist/participants': WaitlistParticipants, '/validators/waitlist/join': ValidatorWaitlist, @@ -477,6 +478,7 @@ const routes = { - `genTvAPI` - Gen TV streams (`list`, `get(slug)`) - `poapsAPI` - POAP list/detail/claims, user POAPs, secret claims, mint-link claims, and recovery wallet verification - `validatorsAPI.getWallOfShame(params)` - Public Wall of Shame list for active validators with Grafana metrics/logs status badges (renders in `routes/WallOfShame.svelte`) + - `validatorsAPI` Telegram group bind codes - `issueTelegramBindCode()` (raw code is ONLY in this response), `getMyTelegramBindCodes()`, `revokeTelegramBindCode(id)` (renders in `routes/ValidatorTelegram.svelte`; bot handle comes from `DECKARD_BOT_USERNAME` in `lib/config.js`, env `VITE_DECKARD_BOT_USERNAME`) - `notificationsAPI` - Portal notifications (`list`, `unreadCount`, `markRead(id)`, `markAllRead`) - `socialTasksAPI` - Social tasks (`list({ status, category })`, `complete(slug)`) - `stewardAPI` - Steward tools: submissions review plus structured AI-review feedback (`getAIFeedback(id)` reads all reviewer records for a submission; `submitAIFeedback(id, data)` creates or versioned-revises the current reviewer's exact-proposal feedback) and Discord XP (`getDiscordXP(params)` lists community XP rows for contributions and social task completions; `recordDiscordXPCopy(stateId)`, `markDiscordXPDistributed(stateId)`, `unsetDiscordXPDistributed(stateId)` act on a row's state id — `row.id`, not the contribution id) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 32cbb621..cd1db744 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -91,6 +91,7 @@ import Waitlist from './routes/Waitlist.svelte'; import WaitlistParticipants from './routes/WaitlistParticipants.svelte'; import WallOfShame from './routes/WallOfShame.svelte'; + import ValidatorTelegram from './routes/ValidatorTelegram.svelte'; import TermsOfUse from './routes/TermsOfUse.svelte'; import PrivacyPolicy from './routes/PrivacyPolicy.svelte'; @@ -287,6 +288,7 @@ '/validators/tasks': roleGatedRoute(SocialTasks, 'validator'), '/validators/participants': protectedRoute(Validators), '/validators/wall-of-shame': roleGatedRoute(WallOfShame, 'validator'), + '/validators/telegram': roleGatedRoute(ValidatorTelegram, 'validator'), '/validators/waitlist': protectedRoute(Waitlist), '/validators/waitlist/participants': protectedRoute(WaitlistParticipants), '/validators/waitlist/join': ValidatorWaitlist, diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index 5d9c0bf2..88d92d25 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -438,6 +438,19 @@ {/if} + { e.preventDefault(); openRoleSection('/validators/telegram', 'validator'); }} + class="flex items-center justify-between border-l-[1.5px] px-3 py-2 text-[14px] font-medium tracking-[0.28px] { + isActive('/validators/telegram') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' + } {isRoleLocked('validator') ? 'text-gray-400' : 'text-black'}" + title={isRoleLocked('validator') ? 'Become a validator to unlock' : ''} + > + Telegram Support + {#if isRoleLocked('validator')} + + {/if} + {/if} @@ -931,6 +944,19 @@ {/if} + { e.preventDefault(); openRoleSection('/validators/telegram', 'validator'); }} + class="flex items-center justify-between border-l-[1.5px] px-3 py-2 text-[14px] font-medium tracking-[0.28px] { + isActive('/validators/telegram') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' + } {isRoleLocked('validator') ? 'text-gray-400' : 'text-black'}" + title={isRoleLocked('validator') ? 'Become a validator to unlock' : ''} + > + Telegram Support + {#if isRoleLocked('validator')} + + {/if} + {/if} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index fdac386f..774d88cd 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -283,7 +283,12 @@ export const validatorsAPI = { getMyValidatorWallets: () => api.get('/validators/my-wallets/'), linkValidatorWalletsByOperator: (operatorAddress) => api.post('/validators/link-by-operator/', { operator_address: operatorAddress }), getNetworks: () => api.get('/validators/wallets/networks/'), - getWallOfShame: (params = {}) => api.get('/validators/wallets/wall-of-shame/', { params }) + getWallOfShame: (params = {}) => api.get('/validators/wallets/wall-of-shame/', { params }), + // Telegram group bind codes (Deckard support bot). The raw code is only in + // the issue response — list/revoke responses carry metadata only. + issueTelegramBindCode: () => api.post('/validators/telegram-bind-codes/'), + getMyTelegramBindCodes: () => api.get('/validators/telegram-bind-codes/mine/'), + revokeTelegramBindCode: (id) => api.post(`/validators/telegram-bind-codes/${id}/revoke/`) }; // Builders API diff --git a/frontend/src/lib/config.js b/frontend/src/lib/config.js index 485220cb..e624bae8 100644 --- a/frontend/src/lib/config.js +++ b/frontend/src/lib/config.js @@ -13,3 +13,7 @@ export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''; // External Links Configuration export const FAUCET_URL = 'https://testnet-faucet.genlayer.foundation/'; + +// Deckard Telegram support bot @username (without the @). When unset, the +// Telegram group linking page falls back to generic wording. +export const DECKARD_BOT_USERNAME = import.meta.env.VITE_DECKARD_BOT_USERNAME || ''; diff --git a/frontend/src/routes/ValidatorTelegram.svelte b/frontend/src/routes/ValidatorTelegram.svelte new file mode 100644 index 00000000..5cef71db --- /dev/null +++ b/frontend/src/routes/ValidatorTelegram.svelte @@ -0,0 +1,249 @@ + + +
+
+

+ Link a Telegram group +

+

+ Connect a Telegram group to your validator so {botLabel} can support you + there. You can link as many groups as you need — each one uses its own + one-time code. Direct messages are not supported: the bot only binds + groups. +

+
+ + +
+

+ How it works +

+
    +
  1. Create a new Telegram group for your validator.
  2. +
  3. Add {botLabel} to that group.
  4. +
  5. Generate a code below, then run /bindcode <code> in the group.
  6. +
+

+ Each code binds one group, works once, and expires after 48 hours. +

+
+ + {#if error} +
+ {error} +
+ {:else} + +
+
+
+

+ Generate a group code +

+

+ The code is shown only once — paste it in your group right away. +

+
+ +
+ + {#if freshCode} +
+

+ Your one-time code +

+
+ + /bindcode {freshCode} + + +
+

+ Run this command in your Telegram group. It will not be shown + again{freshCodeExpiresAt ? ` and expires ${formatDate(freshCodeExpiresAt)}` : ''}. +

+
+ {/if} +
+ + +
+

+ Your codes +

+ {#if loading} +

Loading...

+ {:else if codes.length === 0} +

+ No codes yet. Generate one above to link your first group. +

+ {:else} +
+ {#each codes as code (code.id)} +
+
+
+
+ tgb_{code.identifier}_… + + {STATUS_LABELS[code.status] || code.status} + +
+

+ Created {formatDate(code.created_at)} + {#if code.status === 'issued'} + · Expires {formatDate(code.expires_at)} + {:else if code.status === 'redeemed'} + · Group linked {formatDate(code.redeemed_at)} + {#if code.redeemed_group_chat_id} + (chat {code.redeemed_group_chat_id}) + {/if} + {/if} +

+
+ {#if code.status === 'issued'} + + {/if} +
+
+ {/each} +
+ {/if} +
+ {/if} +
From b01989985b404b85e192bf2feaa7836256acd488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iva=CC=81n=20Raskovsky?= Date: Thu, 30 Jul 2026 12:37:16 +0200 Subject: [PATCH 2/3] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb8af0e..1652aad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable user-facing changes to this project will be documented in this file. ## Unreleased +- Validators can link Telegram support groups to their validator: generate a one-time code on the new Telegram Support page, paste it in a Telegram group with the Deckard support bot, and the group is bound to the validator (multiple groups supported, codes expire in 48 hours and can be revoked) (0cd7e5f) + - Marketing can create campaign links like portal.genlayer.foundation/join/builders/ethcc from the admin panel without a deployment; each link tracks visits, signups, and role activations per campaign, campaign traffic reaches Google Analytics with clean final URLs, and new accounts are attributed to the campaign that brought them (810bdc32) - Notification bodies in the navbar dropdown now stop at 120 characters with an ellipsis while keeping their formatting and links (b4815f48) From 276271668ef3f9fd525f56500fde1a7070dc99d7 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Sun, 2 Aug 2026 17:41:27 +0200 Subject: [PATCH 3/3] Keep wallet addresses out of Telegram bind code logs, document bot env var The bind code issuance log now identifies the issuing user by portal id only, keeping full wallet addresses out of application log sinks in line with the portal's address-privacy stance. The Deckard bot username setting is now part of the documented frontend environment so deployments configure it instead of silently falling back to generic wording. ## Claude Implementation Notes - backend/validators/views.py: issuance log drops request.user.address; user_id + code_id remain as identifiers - frontend/CLAUDE.md: add VITE_DECKARD_BOT_USERNAME to the Environment Variables section - frontend/.env.example: add VITE_DECKARD_BOT_USERNAME with a note that unset falls back to generic wording --- backend/validators/views.py | 4 ++-- frontend/.env.example | 4 ++++ frontend/CLAUDE.md | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/validators/views.py b/backend/validators/views.py index 31a6d9bf..1bd8d6bb 100644 --- a/backend/validators/views.py +++ b/backend/validators/views.py @@ -1191,8 +1191,8 @@ def create(self, request): bind_code, plaintext = TelegramGroupBindCode.issue(validator, request.user) logger.info( - "Telegram bind code issued: user=%s (id=%s) code_id=%s", - request.user.address, request.user.id, bind_code.id, + "Telegram bind code issued: user_id=%s code_id=%s", + request.user.id, bind_code.id, ) data = TelegramBindCodeSerializer(bind_code).data data['code'] = plaintext diff --git a/frontend/.env.example b/frontend/.env.example index 1fde14bd..86b05c5c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -32,6 +32,10 @@ VITE_RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI # Cloudflare Turnstile for email verification VITE_TURNSTILE_SITE_KEY=your_turnstile_site_key +# Deckard Telegram support bot @username without the @ (optional - the +# Telegram group linking page falls back to generic wording when unset) +VITE_DECKARD_BOT_USERNAME= + # Production values (to be set in Amplify environment variables) # VITE_API_URL=https://your-app-runner-url.amazonaws.com # VITE_GOOGLE_ANALYTICS_ID=G-5X6PRBXWJ7 diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 0e9eb3f4..40719439 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -766,6 +766,7 @@ Set in `.env` file: - `VITE_VALIDATOR_RPC_URL` - Blockchain RPC endpoint for Asimov testnet - `VITE_EXPLORER_URL` - Blockchain explorer URL - `VITE_RECAPTCHA_SITE_KEY` - Google reCAPTCHA site key (required - use test key from .env.example for development) +- `VITE_DECKARD_BOT_USERNAME` - Deckard Telegram support bot @username without the @ (optional - the Telegram group linking page falls back to generic wording) ## Common Commands ```bash