Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_<id>_<secret>`, 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 = {'<action>': '<scope>', '*': '<default>'}`.
- **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 <account> --scopes <scope>... [--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.

Expand Down Expand Up @@ -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_<id>_<secret>`) 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_<network>` 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.
Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion backend/service_accounts/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,)
)
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: prefer unpacking over concatenation.

Ruff flags the tuple concatenation; unpacking is idiomatic and avoids the implicit tuple-type check.

♻️ Suggested fix
-ALLOWED_SERVICE_ACCOUNT_SCOPES = frozenset(
-    AI_REVIEW_SCOPES + (TELEGRAM_BIND_REDEEM_SCOPE,)
-)
+ALLOWED_SERVICE_ACCOUNT_SCOPES = frozenset(
+    (*AI_REVIEW_SCOPES, TELEGRAM_BIND_REDEEM_SCOPE)
+)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ALLOWED_SERVICE_ACCOUNT_SCOPES = frozenset(
AI_REVIEW_SCOPES + (TELEGRAM_BIND_REDEEM_SCOPE,)
)
ALLOWED_SERVICE_ACCOUNT_SCOPES = frozenset(
(*AI_REVIEW_SCOPES, TELEGRAM_BIND_REDEEM_SCOPE)
)
🧰 Tools
🪛 Ruff (0.16.0)

[warning] 14-14: Consider (*AI_REVIEW_SCOPES, TELEGRAM_BIND_REDEEM_SCOPE) instead of concatenation

Replace with (*AI_REVIEW_SCOPES, TELEGRAM_BIND_REDEEM_SCOPE)

(RUF005)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/service_accounts/scopes.py` around lines 13 - 15, Update the
ALLOWED_SERVICE_ACCOUNT_SCOPES definition to build the frozenset using iterable
unpacking for AI_REVIEW_SCOPES and TELEGRAM_BIND_REDEEM_SCOPE instead of tuple
concatenation, preserving the same allowed scope values.

Source: Linters/SAST tools

8 changes: 8 additions & 0 deletions backend/social_connections/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
DiscordRoleSyncLock,
GitHubConnection,
PendingOAuthState,
TelegramConnection,
TwitterConnection,
UsedOAuthCode,
)
Expand Down Expand Up @@ -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 = (
Expand Down
35 changes: 35 additions & 0 deletions backend/social_connections/migrations/0008_telegramconnection.py
Original file line number Diff line number Diff line change
@@ -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',
},
),
]
17 changes: 17 additions & 0 deletions backend/social_connections/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'


Comment on lines +51 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

TelegramConnection doesn't inherit utils.models.BaseModel.

It subclasses the abstract SocialConnection, which is models.Model, not BaseModel. This mirrors existing GitHubConnection/TwitterConnection/DiscordConnection siblings, but as per coding guidelines, "All Django models must inherit from utils.models.BaseModel." Fixing this cleanly would require migrating the whole social_connections app's base, so this is best tracked as a follow-up rather than blocking this PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/social_connections/models.py` around lines 51 - 67, The requested
change is a broader follow-up: the social connection model hierarchy currently
does not inherit BaseModel. Do not modify TelegramConnection in this PR; track
updating SocialConnection and its GitHubConnection, TwitterConnection,
DiscordConnection, and TelegramConnection subclasses to inherit
utils.models.BaseModel as a separate migration-aware change.

Source: Coding guidelines

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")
Expand Down
2 changes: 2 additions & 0 deletions backend/tally/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions backend/utils/throttling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
36 changes: 36 additions & 0 deletions backend/validators/migrations/0018_telegramgroupbindcode.py
Original file line number Diff line number Diff line change
@@ -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'],
},
),
]
Loading