A multi-project on-chain Twitter / X bot. Watches Ethereum and Base for NFT mints, auction activity, secondary sales, and a few project-specific events, and posts formatted tweets with rendered token images.
It was built to operate several art collections (TBAM, Value Discovery, Ripe, and others) from a single deployment, but the architecture is generic — each project is a config object that plugs into the same indexer / poller / tweet formatter pipeline.
Most "tweet on mint" bots are hard-coded to one contract. This one is built
around a ProjectConfig registry so a single process can:
- Poll multiple contracts across multiple chains.
- Use a different image renderer per project (a thumbnail API, a GIF service, whatever).
- Format each event type with a project-specific tweet template.
- Pull secondary-sales activity from OpenSea and merge it with on-chain mints in a single dedup'd feed.
- Generate daily and weekly recap tweets with composite grid images.
If you want to bolt a new project on, you add one file under
src/config/projects/ and register it in src/config/projects/index.ts.
On-chain events OpenSea API
│ │
▼ ▼
chainPoller / Ponder salesIndexer (HTTP polling)
│ │
└──────────────┬─────────────────┘
▼
eventPoller (every 30s)
│
▼
tweetedLog (dedup, persistent)
│
▼
templates/tweetTemplates + imageService
│
▼
twitterClient (X API v2)
The bot runs an Express server that exposes a small HTTP API (health, stats, preview dashboard) so you can sanity-check what's about to be tweeted.
| Event | Source | Image |
|---|---|---|
mint |
Contract Transfer |
Token thumbnail / slideshow GIF |
batch_mint |
Contract Transfer |
First token's GIF |
auction_created |
Auction house | Static PNG |
auction_bid |
Auction house | Static PNG |
auction_settled |
Auction house | Static PNG |
sale |
OpenSea API | Token thumbnail |
batch_sale |
OpenSea API | First token thumbnail |
edition_created |
Edition contract | Token PNG + grid composite |
edition_mint |
Edition contract | Edition grid PNG |
ritual_complete |
Custom contract | (project-specific) |
Not every project enables every event type — that's controlled by
EventTrackingConfig in each ProjectConfig.
# 1. Install
npm install
# 2. Configure (see Environment Variables below)
cp .env.example .env
# fill in TWITTER_*, RPC URLs, OPENSEA_API_KEY, and the project vars you want
# 3. Generate Twitter access tokens for the posting account
npx tsx scripts/get-tokens.ts
# 4. Verify credentials
npm run verify
# 5. Run in dry-run mode (logs tweets, doesn't post)
DRY_RUN=true npm run dev
# 6. When you're happy, run for real
npm run build && npm startThe bot will:
- Load every project registered in
src/config/projects/index.ts. - Skip projects whose contract addresses aren't set in
.env. - Start a poller per (project, chain) pair.
- Serve the preview dashboard at
http://localhost:3000/.
- Create
src/config/projects/your-project.tsexporting aProjectConfig. Look attbam.tsfor a feature-complete example, orlayer-burns.tsfor a minimal one that only tracks a single custom event. - Register it in
src/config/projects/index.ts. - Add the env vars your config reads to
.env.example. - If your project uses a new event type, add it to the
EventTypeunion insrc/types.ts, the dedup tracking insrc/lib/tweetedLog.ts, and a tweet template insrc/templates/tweetTemplates.ts.
See .env.example for the full annotated list. The minimum
set to post any tweet is:
| Var | Purpose |
|---|---|
TWITTER_API_KEY/SECRET |
The X developer app (the owner pays). |
TWITTER_ACCESS_TOKEN/SECRET |
The posting account (run get-tokens). |
MAINNET_RPC_URL |
Any JSON-RPC endpoint. |
DATA_DIR |
Where dedup state is persisted. |
Per-project env vars (contract addresses, deployment blocks, image API URLs)
are documented in .env.example.
The bot serves these endpoints on PORT (default 3000):
| Endpoint | Description |
|---|---|
GET / |
Tweet preview dashboard (HTML). |
GET /health |
Health check (poller status, last events). |
GET /api/stats |
Tweet counts grouped by event type. |
GET /config |
Non-secret config snapshot. |
POST /api/seed |
Mark a list of event IDs as already tweeted (use this on first deploy to skip backfill). |
GET /api/preview/:projectId/:eventType/:tokenId |
Render the tweet for a specific event without posting. |
GET /api/image/:projectId/:tokenId |
Proxy the rendered image for a token. |
The bot stores state in DATA_DIR (default ./data/). Mount this as a
persistent volume in production:
| File | Purpose |
|---|---|
tweeted.json |
Dedup log of every event → tweet ID. |
twitter-handles.json |
ENS / Farcaster / X handle cache (skip lookups). |
state-{project}-{chain}.json |
Last indexed block per (project, chain). |
spread-state.json |
Spread-tracker state (Value Discovery). |
weekly-snapshot.json |
Rolling 7-day stats for the weekly recap. |
Writes are atomic (temp file + rename) so a kill mid-write won't corrupt the
log. Losing tweeted.json means the bot will re-tweet historical events when
it restarts — use POST /api/seed to skip them.
npm run dev # tsx watch, restarts on file changes
npm run dry-run # builds then runs with DRY_RUN=true (logs only)
npm run verify # checks Twitter credentials and exits
npm run typecheck # tsc --noEmit
npm run gif-server # standalone GIF renderer service (for Value Discovery)The included Dockerfile and fly.toml are a working Fly.io deployment, but
nothing about the bot is Fly-specific — it's a stock Node.js app that wants:
- A writable persistent volume mounted at
DATA_DIR. - The env vars listed in
.env.example. - One process per region (the dedup log is local).
Pick whatever runtime you prefer. If you use Fly.io, rename the app in
fly.toml first.
Tweet marked as sent but not visible on X. Check the tweeted log:
cat $DATA_DIR/tweeted.json | jq '.tweetIds'The recorded tweet ID can be opened at https://twitter.com/i/status/{id}.
If the ID is missing, the event predates the audit-trail feature; if it
exists but isn't on X, the tweet was deleted or the account is suspended.
Image fetch failures. The bot retries each image up to 10 times before
giving up and tweeting without media. Look for [EventPoller] No image for ... retry N/10 in the logs. Usually means the project's image API is down.
Ponder not healthy. Projects that use Ponder (vs. direct RPC polling) log
Ponder is not healthy, skipping poll. Check the Ponder service and its RPC
endpoint.
MIT — see LICENSE.