Skip to content
Open
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
1 change: 1 addition & 0 deletions .claude/PRs.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ PRs raised through Claude Code.
| [#265](https://github.com/ewitulsk/SuiOptions/pull/265) | [SO-266](https://suioptions.atlassian.net/browse/SO-266) | SO-19 Frontend | Gate Exercise on Expired Options + Fix Off-Screen Popup Positioning |
| [#274](https://github.com/ewitulsk/SuiOptions/pull/274) | [SO-272](https://suioptions.atlassian.net/browse/SO-272) | SO-19 Frontend | Fix Put Earn-Page Writer UI (USDC Collateral, Mirrored Outcomes, Dual-Denom Input) |
| [#275](https://github.com/ewitulsk/SuiOptions/pull/275) | [SO-273](https://suioptions.atlassian.net/browse/SO-273) | — Gas Station | Sponsor Cash-Secured Put PTBs in Gas-Station Templates |
| [#281](https://github.com/ewitulsk/SuiOptions/pull/281) | [SO-279](https://suioptions.atlassian.net/browse/SO-279) | — Socials | Engagement Tracking, Airdrop Points & Leaderboard MVP (Twitter Mentions + Discord Bot) |
2 changes: 1 addition & 1 deletion .github/workflows/_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ jobs:
# Keep this in sync with ALL_SERVICES in deployment/ec2/deploy.sh
# and affected.py — a service missing here is never tag-seeded on
# force_all, so deploy.sh rejects the deploy on a fresh box.
services='["indexer","quoting-service","mm-bot","option-scheduler","api-service","token-info","auth-service","gas-station","price-charting","balance-monitor","keeper","oracle-service","cctp-relay","twitter-service","social-bot"]'
services='["indexer","quoting-service","mm-bot","option-scheduler","api-service","token-info","auth-service","gas-station","price-charting","balance-monitor","keeper","oracle-service","cctp-relay","twitter-service","social-bot","engagement-service","airdrop-bot"]'
echo "force_all or image_tag override → rolling all services"
else
services=$(printf '%s\n' "$CHANGED_FILES" \
Expand Down
97 changes: 97 additions & 0 deletions docs/engagement-airdrop-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Engagement → Airdrop Leaderboard

Plan + MVP scope for the engagement/airdrop program: track engagement on
tweets that mention us, convert it into airdrop points, rank ambassadors and
the general public on a leaderboard, and expose it all through a Discord bot.

## Architecture

```
Twitter API v2 (OAuth 1.0a, per-account)
│ POST /2/tweets (existing)
│ GET /2/tweets/search/recent (new)
│ GET /2/tweets?ids=… (new)
┌───────┴───────┐
│ twitter-service│ internal :9014
└───┬───────▲───┘
GET /mentions │ │ (also: social-bot /tweet,
GET /tweets/metrics │ unchanged)
┌───▼───────┴───┐
│ engagement- │ internal :9017
│ service │──▶ Postgres engagement_<env>
│ poll loop │ (tracked_tweets,
│ points module │ poll_cursor)
└───┬───────────┘
GET /leaderboard │ GET /points/{handle}
┌───▼───────────┐
│ airdrop-bot │ public :9018
│ (Discord app, │ nginx /<env>/airdrop-bot/
│ separate from │
│ social-bot) │
└───────────────┘
```

Four pieces from the original sketch, mapped to what shipped:

1. **Engagement tracking** — twitter-service grew two signed read endpoints
(`GET /mentions`, `GET /tweets/metrics`); it stays the single owner of
Twitter credentials. engagement-service polls it every 5 minutes: new
mentions of `@suioptions` (original tweets only, no retweets, last 7
days — Twitter's recent-search window) are upserted into Postgres, and
the stalest counters among tweets younger than 7 days are refreshed 100
at a time.
2. **Airdrop conversion** — a pure module inside engagement-service, not a
separate service (see "Decisions"). Config weights per like/reply/
retweet/quote → engagement points; a rate + ambassador multiplier →
airdrop points. Derived at read time, so tuning weights or the
ambassador roster is a config deploy, no backfill.
3. **Leaderboard** — `GET /leaderboard` on engagement-service: authors
(keyed by twitter handle) ranked by airdrop points; ambassadors are
flagged, the general public competes on the same board.
4. **Discord bot** — airdrop-bot, a separate Discord application and
container from social-bot (the team tweeting bot): community-facing,
read-only `/leaderboard [count]` and `/points <handle>` commands, no
allow list, no shared secrets.

## Decisions (and why)

- **One service for tracking + conversion + leaderboard.** The partner
sketch has engagement-tracking and airdrop-tracking as two services, but
the conversion is a pure function over the tracked totals — a second
service would add a network hop, a deployment and a DB with no state of
its own. `points.rs` keeps the boundary; split it out when the airdrop
needs real state (claims, epochs, on-chain distribution).
- **Points are derived, never stored.** Only raw counters live in the DB.
- **Handles, not user ids, key the leaderboard.** Simpler to read, matches
the ambassador roster in config; `author_id` is stored per tweet so we
can re-key later if handle changes ever matter.
- **airdrop-bot is webhook-based like social-bot** (no gateway connection):
one axum server, Ed25519-verified interactions, defer + follow-up within
Discord's 3s ack window.
- **Staging-only for now**, like twitter-service/social-bot — deliberately
not declared in docker-compose.prod.yml.

## MVP scoping (not built, by design)

- Engagement history/snapshots (trend charts), follower-weighting,
spam/bot filtering beyond excluding retweets.
- Mentions older than Twitter's 7-day recent-search window: the poll cursor
makes this moot once the service is running continuously.
- Claim flow / on-chain distribution — the leaderboard is the deliverable;
distribution is a later phase with its own design.
- Discord↔Twitter account linking (`/register`): points accrue to twitter
handles; anyone can query any handle.

## Rollout (staging)

1. Terraform apply (new `options/staging/airdrop-bot` secret placeholder).
2. Provision the DB (one-time, infra/README.md convention):
`CREATE DATABASE engagement_staging; CREATE USER engagement_staging …`.
3. Fill the airdrop-bot secret with the new Discord application's public
key; register the slash commands (services/airdrop-bot/README.md).
4. Deploy `engagement-service` + `airdrop-bot` (+ rebuilt
`twitter-service`); point the Discord app's Interactions Endpoint URL at
`https://<alb-host>/staging/airdrop-bot/discord/interactions`.
5. Set `points.ambassadors` in engagement-service's config as the
ambassador roster firms up.
41 changes: 41 additions & 0 deletions rust-backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions rust-backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ members = [
"services/oracle-service",
"services/twitter-service",
"services/social-bot",
"services/engagement-service",
"services/airdrop-bot",
"tools/deployment-manager",
"tools/exchange",
"tools/writer",
Expand Down
30 changes: 30 additions & 0 deletions rust-backend/Dockerfile.airdrop-bot
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Multi-stage build for airdrop-bot.
#
# The builder runs under the target platform set by bake.hcl. Do NOT add
# `--platform=$BUILDPLATFORM` here — that would pin the builder to the GH
# runner's native arch and produce a binary that doesn't match the image
# manifest's arch.
FROM rust:1-bookworm AS builder
WORKDIR /src

RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev clang cmake protobuf-compiler git \
&& rm -rf /var/lib/apt/lists/*

# Copy the whole workspace. The Sui git deps make smarter caching strategies
# fragile; one COPY is simpler and the layer rebuilds only when the workspace
# changes.
COPY . .
RUN cargo build --release -p airdrop-bot

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /src/target/release/airdrop-bot /usr/local/bin/airdrop-bot
COPY services/airdrop-bot/config/ /app/config/

ENV APP_ENV=staging
ENTRYPOINT ["/bin/sh", "-c", "exec /usr/local/bin/airdrop-bot \
--config /app/config/config.${APP_ENV}.toml \
--secrets /run/secrets/airdrop-bot.toml"]
30 changes: 30 additions & 0 deletions rust-backend/Dockerfile.engagement-service
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Multi-stage build for engagement-service.
#
# The builder runs under the target platform set by bake.hcl. Do NOT add
# `--platform=$BUILDPLATFORM` here — that would pin the builder to the GH
# runner's native arch and produce a binary that doesn't match the image
# manifest's arch.
FROM rust:1-bookworm AS builder
WORKDIR /src

RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev clang cmake protobuf-compiler git \
&& rm -rf /var/lib/apt/lists/*

# Copy the whole workspace. The Sui git deps make smarter caching strategies
# fragile; one COPY is simpler and the layer rebuilds only when the workspace
# changes.
COPY . .
RUN cargo build --release -p engagement-service

FROM debian:bookworm-slim
# libpq5: Postgres runtime lib for the diesel connection.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 libpq5 curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /src/target/release/engagement-service /usr/local/bin/engagement-service
COPY services/engagement-service/config/ /app/config/

ENV APP_ENV=staging
ENTRYPOINT ["/bin/sh", "-c", "exec /usr/local/bin/engagement-service \
--config /app/config/config.${APP_ENV}.toml"]
18 changes: 17 additions & 1 deletion rust-backend/deployment/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

# Order here is the canonical "all services" list. Keep in sync with the
# ALL_SERVICES array in deployment/ec2/deploy.sh.
ALL_SERVICES = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "price-charting", "balance-monitor", "keeper", "oracle-service", "cctp-relay", "twitter-service", "social-bot"]
ALL_SERVICES = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "price-charting", "balance-monitor", "keeper", "oracle-service", "cctp-relay", "twitter-service", "social-bot", "engagement-service", "airdrop-bot"]

# Path globs that, when matched, force every service to rebuild +
# redeploy. Catches lockfile churn, workspace-wide config, infra-side
Expand Down Expand Up @@ -73,6 +73,8 @@
# balance-monitor : runtime-config, cli-spec, sui-tx, observability
# twitter-service : runtime-config, cli-spec
# social-bot : runtime-config, cli-spec
# engagement-service: runtime-config, cli-spec
# airdrop-bot : runtime-config, cli-spec
# keeper : protocol-types, runtime-config, cli-spec, sui-tx,
# pyth-client, pricing, token-info-client,
# indexer-graphql, observability
Expand Down Expand Up @@ -219,6 +221,20 @@
"rust-backend/crates/observability/**",
"rust-backend/crates/cli-spec/**",
],
"engagement-service": [
"rust-backend/services/engagement-service/**",
"rust-backend/Dockerfile.engagement-service",
"rust-backend/crates/runtime-config/**",
"rust-backend/crates/observability/**",
"rust-backend/crates/cli-spec/**",
],
"airdrop-bot": [
"rust-backend/services/airdrop-bot/**",
"rust-backend/Dockerfile.airdrop-bot",
"rust-backend/crates/runtime-config/**",
"rust-backend/crates/observability/**",
"rust-backend/crates/cli-spec/**",
],
"oracle-service": [
"rust-backend/services/oracle-service/**",
"rust-backend/Dockerfile.oracle-service",
Expand Down
18 changes: 17 additions & 1 deletion rust-backend/deployment/bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ target "social-bot" {
cache-to = [{ type = "gha", mode = "max", scope = "social-bot" }]
}

target "engagement-service" {
inherits = ["_common"]
dockerfile = "Dockerfile.engagement-service"
tags = ["${ECR}/options/engagement-service:${IMAGE_TAG}"]
cache-from = [{ type = "gha", scope = "engagement-service" }]
cache-to = [{ type = "gha", mode = "max", scope = "engagement-service" }]
}

target "airdrop-bot" {
inherits = ["_common"]
dockerfile = "Dockerfile.airdrop-bot"
tags = ["${ECR}/options/airdrop-bot:${IMAGE_TAG}"]
cache-from = [{ type = "gha", scope = "airdrop-bot" }]
cache-to = [{ type = "gha", mode = "max", scope = "airdrop-bot" }]
}

group "default" {
targets = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "price-charting", "keeper", "balance-monitor", "oracle-service", "cctp-relay", "twitter-service", "social-bot"]
targets = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "price-charting", "keeper", "balance-monitor", "oracle-service", "cctp-relay", "twitter-service", "social-bot", "engagement-service", "airdrop-bot"]
}
36 changes: 35 additions & 1 deletion rust-backend/deployment/compose/docker-compose.staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,40 @@ services:
restart: unless-stopped
networks: [net]

# Engagement→airdrop-points tracker (staging-only, like twitter-service).
# Polls twitter-service for mentions of our account, persists per-tweet
# engagement counters in the shared RDS Postgres, and serves the airdrop
# leaderboard. Internal-only port 9017 (never proxied by nginx) —
# airdrop-bot is the public surface.
engagement-service:
image: ${ECR}/options/engagement-service:${ENGAGEMENT_SERVICE_TAG}
environment:
APP_ENV: staging
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_ENDPOINT:-}
DB_PASSWORD: ${DB_PASSWORD}
DB_HOST: ${DB_HOST}
RUST_LOG: info,engagement_service=debug
depends_on: [twitter-service]
restart: unless-stopped
networks: [net]

# Discord bot for the engagement airdrop (staging-only). Deliberately a
# separate Discord application + container from social-bot. Read-only
# /leaderboard + /points commands served from engagement-service. Public
# port 9018 (proxied by nginx — Discord delivers signed webhooks). Reads
# its Discord public key from /run/secrets/airdrop-bot.toml.
airdrop-bot:
image: ${ECR}/options/airdrop-bot:${AIRDROP_BOT_TAG}
environment:
APP_ENV: staging
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_ENDPOINT:-}
RUST_LOG: info,airdrop_bot=debug
volumes:
- /opt/options/staging/secrets:/run/secrets:ro
depends_on: [engagement-service]
restart: unless-stopped
networks: [net]

# Single public entrypoint per env. Strips the /staging/{quoting,api,indexer,scheduler,mm-bot}
# prefix before forwarding. depends_on is start-order only; nginx
# tolerates backends being briefly unreachable on first boot.
Expand All @@ -268,7 +302,7 @@ services:
volumes:
- /opt/options/staging/nginx/:/etc/nginx/options/:ro
command: ["nginx", "-c", "/etc/nginx/options/nginx.conf", "-g", "daemon off;"]
depends_on: [quoting, api-service, indexer, option-scheduler, mm-bot, token-info, auth-service, gas-station, keeper, social-bot]
depends_on: [quoting, api-service, indexer, option-scheduler, mm-bot, token-info, auth-service, gas-station, keeper, social-bot, airdrop-bot]
restart: unless-stopped
networks: [net]

Expand Down
7 changes: 6 additions & 1 deletion rust-backend/deployment/ec2/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ COMPOSE_FILE="docker-compose.${ENV}.yml"
# Canonical service set + their .env tag-variable names + the compose
# service name (mostly identical to the cargo crate name, except
# quoting-service is referenced as `quoting` in compose).
ALL_SERVICES=(indexer quoting-service mm-bot option-scheduler api-service token-info auth-service gas-station price-charting balance-monitor keeper oracle-service cctp-relay twitter-service social-bot)
ALL_SERVICES=(indexer quoting-service mm-bot option-scheduler api-service token-info auth-service gas-station price-charting balance-monitor keeper oracle-service cctp-relay twitter-service social-bot engagement-service airdrop-bot)

tag_var_for() {
case "$1" in
Expand All @@ -69,6 +69,8 @@ tag_var_for() {
cctp-relay) echo CCTP_RELAY_TAG ;;
twitter-service) echo TWITTER_SERVICE_TAG ;;
social-bot) echo SOCIAL_BOT_TAG ;;
engagement-service) echo ENGAGEMENT_SERVICE_TAG ;;
airdrop-bot) echo AIRDROP_BOT_TAG ;;
*) return 1 ;;
esac
}
Expand All @@ -89,6 +91,8 @@ compose_name_for() {
cctp-relay) echo cctp-relay ;;
twitter-service) echo twitter-service ;;
social-bot) echo social-bot ;;
engagement-service) echo engagement-service ;;
airdrop-bot) echo airdrop-bot ;;
*) return 1 ;;
esac
}
Expand Down Expand Up @@ -242,6 +246,7 @@ health_path_for() {
cctp-relay) echo "/$ENV/cctp/health" ;;
keeper) echo "/$ENV/keeper/health" ;;
social-bot) echo "/$ENV/social-bot/health" ;;
airdrop-bot) echo "/$ENV/airdrop-bot/health" ;;
*) return 1 ;;
esac
}
Expand Down
Loading