dev docs · architecture

How the bot works

A non-custodial multi-chain stablecoin tipping bot in ~3300 lines of TypeScript. Two listeners, four chain watchers, two platform adapters, one status registry. No database. No signing. the architecture fits on one page because that's on purpose.

On this page

  1. One-minute overview
  2. Principles behind the design
  3. File layout
  4. The two listeners
  5. The chain adapter contract
  6. Per-chain implementation notes
  7. The platform adapter contract
  8. Status registry
  9. HTTP routes
  10. What state the bot holds
  11. What's deliberately not here
  12. Adding a new chain or platform

One-minute overview

A creator runs the bot on their own infrastructure. The bot does two things:

  1. Listens in chat — when mentioned, replies with a signing link tipper can open in their own wallet.
  2. Watches chains — when payments land on the creator's addresses, announces a thank-you in chat.

That's it. No database, no custody, no user accounts, no fees. Everything the bot knows at any given moment is either in memory for less than a minute, or already on-chain and publicly visible.

Single process, multi-chain, multi-platform One running bot process handles everything at once. If you configure XRPL, Base, Arbitrum, and Solana addresses, that one process opens four chain subscriptions in parallel. If you also configure any combination of Discord, Telegram, Twitch, and X credentials, the same process serves all of those chat platforms simultaneously. You deploy once, to one hosting provider, and the single running bot does all eight potential jobs without a database, queue, or coordination layer. This is a direct consequence of the stateless-by-default and chain-agnostic principles below.
       ┌─────────────┐                      ┌──────────────┐
       │   TIPPER    │                      │   CREATOR    │
       │  (in chat)  │                      │ (addresses)  │
       └──────┬──────┘                      └──────┬───────┘
              │                                    │
              │ @bot tip                           │ payment lands
              ▼                                    │
     ┌────────────────┐                            │
     │  BOT: CHAT   <──────── listens              │
     │    adapter     │                            │
     └───────┬────────┘                            │
             │                                     │
             │ signing URL                         ▼
             ▼                             ┌────────────────┐
     ┌────────────────┐                    │  BOT: CHAIN    │
     │ TIPPER'S       │    signs &         │  watchers      │
     │ OWN WALLET     │────transfers──────▶│  (XRPL/Base/   │
     │ (off-system)   │                    │   ARB/Solana)  │
     └────────────────┘                    └───────┬────────┘
                                                   │
              ┌────────────────────────────────────┘
              │ "thanks @tipper!"
              ▼
     ┌────────────────┐
     │  BOT: CHAT     │
     │    adapter     │
     └────────────────┘

The bot never holds keys. It never signs a transaction. It never touches the tipper's wallet. It just listens in two directions (chat, chain) and speaks in one (chat).

Principles behind the design

These are non-negotiable constraints baked into the codebase. When evaluating a change, any change that breaks one of these is a bad change.

1. Non-custodial, always

The bot never signs transactions. It never holds private keys. It has no path to spend funds. The worst thing a compromised bot can do is fail to announce a tip — the tip itself still lands, because it was signed by the tipper in their own wallet.

2. Stateless by default

No database. No persistent queue. No on-disk cache. The bot reads state from the chain when it needs to, and holds exactly one piece of in-memory state (the 60-second message buffer, used only for EVM memo-pairing — more on that later). If the bot crashes and restarts, nothing is lost.

3. Chain-agnostic where possible

Every chain watcher produces the same TipEvent shape. Platform adapters don't know (or care) which chain a tip came from — they announce based on amount, asset, and tipper display name. Adding a new chain means writing a new watcher that conforms to the contract, not touching the adapters.

4. Platform-agnostic where possible

Same shape going the other way. Every platform adapter exposes the same onInvoke and announceTip surface. Chain watchers don't know which chat platform is listening — they emit a TipEvent to the registry, and every enabled platform's adapter hears it.

5. Plain-English errors

When the bot can't start, it says why in complete sentences, not error codes. Misconfigurations surface as "OWNER_EVM_BASE_ADDRESS is not a valid EVM address. Should start with 0x and be 42 characters." — not Error: validation failed at index 3.

6. The three-gate

No release ships unless typecheck, lint, and tests all pass. The release script enforces this. Broken main branch is not a thing that happens.

File layout

The src/ tree, with line counts at release:

src/
├── api/
│   ├── server.ts          # entry point, orchestrator (~465 lines)
│   └── routes/
│       ├── health.ts      # GET /healthz
│       ├── tip.ts         # GET /tip  (multi-chain signing page)
│       └── check.ts       # GET /check (status page)
│
├── chains/
│   ├── types.ts           # TipEvent, ChainWatcher, ChainFamily (~68 lines)
│   ├── xrpl/
│   │   ├── address.ts     # validate "r..." addresses
│   │   ├── startup.ts     # connect + validate account exists
│   │   └── watcher.ts     # subscribe to account_tx, decode memos
│   ├── evm/
│   │   ├── address.ts     # validate "0x..." addresses
│   │   ├── chains.ts      # Base + Arbitrum config constants
│   │   ├── message_buffer.ts # 60-second in-memory mem pairing
│   │   ├── startup.ts     # verify RPC is on expected chain
│   │   └── watcher.ts     # subscribe to USDC Transfer logs
│   └── solana/
│       ├── address.ts     # validate base58 addresses
│       ├── chains.ts      # USDC mint, Memo program IDs
│       ├── startup.ts     # verify RPC reachable, ATA exists
│       └── watcher.ts     # onLogs subscription on ATA
│
├── adapters/
│   ├── discord/
│   │   └── adapter.ts     # discord.js client + announce method
│   └── telegram/
│       └── adapter.ts     # telegraf client + announce method
│
├── config.ts              # load + validate env, return typed config
├── logger.ts              # pino setup
├── node_version.ts        # startup guard for Node 22.6+
├── status.ts              # StatusRegistry for /check
└── thankyous.ts           # load templates, {name} substitution

24 files total in src/. Another 14 in tests/, three scripts in scripts/, a handful of configuration files at the root. About 3300 lines of TypeScript source.

There is no dist/ or build/ directory in the tarball. The bot runs TypeScript directly via Node's --experimental-strip-types flag. What you see is what executes.

The two listeners

The whole bot is two separate listeners that never share mutable state:

Listener A

chat → ledger

Trigger: a user mentions the bot or issues a tip command in Discord, Telegram, Twitch, or X.

  1. Platform adapter detects the mention.
  2. Bot replies with a signing URL pointing at its own /tip route.
  3. For EVM: adapter pushes the message text into RecentMessageBuffer. XRPL and Solana skip this — they use on-chain memos.
  4. Listener A forgets everything else.

Listener B

ledger → chat

Trigger: a payment lands at the creator's address on any enabled chain.

  1. Chain watcher receives the event from its RPC subscription.
  2. Decodes the transfer into a canonical TipEvent.
  3. Looks up the memo (on-chain for XRPL/Solana; freshest buffer entry for EVM).
  4. Emits the event to every enabled platform adapter.
  5. Adapter picks a random thank-you template, substitutes {name}, posts.

The listeners don't share state because they don't need to. Listener A produces the opportunity for a tip; Listener B reacts to the fact of one. The only thing connecting them is the RecentMessageBuffer for EVM, and that's purely because EVM lacks native memo support.

The chain adapter contract

Defined in src/chains/types.ts. Every chain watcher implements the same interface:

interface ChainWatcher {
  start(): Promise<void>;  # connects RPC, subscribes, runs forever
  stop():  Promise<void>;  # disconnects cleanly
}

And every event a watcher produces is a canonical TipEvent:

interface TipEvent {
  chain:       'xrpl' | 'evm' | 'solana';
  network:     'xrpl' | 'base' | 'arbitrum' | 'solana';
  txHash:      string;         # chain-native format
  fromAddress: string;
  fromDisplay: string;         # ENS name / memo name / short address
  amount:      string;         # decimal string, e.g. "1.50"
  asset:       string;         # "XRP" / "USDC" / "SOL" / ...
  issuer:      string | null;  # contract/issuer for non-native
  memo:        string;         # may be empty
  ledgerIndex: number;         # ledger or block number
}

Platform adapters consume TipEvent and don't need to know anything about the originating chain. This is the main abstraction boundary in the codebase — it's why you can add Solana without touching Discord code, and add a new platform without touching XRPL code.

Why fromDisplay is pre-computed The watcher is the thing with access to the chain's data — it knows whether a memo has a name in it, it has the RPC to do ENS reverse-resolution. Adapters just rendering strings shouldn't re-derive this. Having one pre-computed display name keeps the announcement layer dumb, which keeps it right.

Per-chain implementation notes

XRPL src/chains/xrpl/

Uses the xrpl npm package. Subscribes to transaction events filtered to the owner's account via a WebSocket connection to xrplcluster.com (configurable via XRPL_WS_URL).

EVM src/chains/evm/

Covers Base and Arbitrum. Uses viem's public client to subscribe to Transfer event logs on the USDC contract, filtered to the owner's address as recipient.

Solana src/chains/solana/

Uses @solana/web3.js. Subscribes to onLogs on the owner's associated token account (ATA) for USDC, plus a separate subscription for native SOL transfers.

The platform adapter contract

Defined implicitly by src/api/server.ts and the two existing adapters. Each platform adapter exposes two methods:

interface PlatformAdapter {
  start(): Promise<void>;
  stop():  Promise<void>;

  # Called by the server when the chain watcher reports a confirmed tip.
  # Adapter is responsible for picking a thank-you template, substituting,
  # and posting to the configured announce channel.
  announceTip(input: AnnounceTipInput): Promise<void>;
}

AnnounceTipInput carries the fields the adapter needs to render a thank-you: the TipEvent, the resolved thank-you template (pre-substituted), and an optional image path if the user configured per-template images.

The inverse direction (platform → bot) isn't a method — it's an event handler the adapter installs during start(). Discord uses client.on('messageCreate', ...); Telegraf uses bot.on('text', ...). Both push into RecentMessageBuffer and reply with a signing URL.

Status registry

src/status.ts exports a StatusRegistry that tracks the live state of every component. One global instance, passed to every watcher and adapter at construction.

Tracked components:

Each component has one of three states: ok (green), error (red, with a specific message), or disabled (grey, meaning the user didn't configure this component).

The /check route renders the registry as an HTML status page. The registry is the single source of truth for "is my bot working?" — users bookmark /check and it's the first thing the troubleshooter tells them to open.

HTTP routes

Three routes, no more, no authentication on any of them. Served by Fastify on the port specified by PORT env var (default 3000).

GET /healthz

Returns {"ok": true} with a 200 status if the server is running. Used by hosting platforms (Railway, Render, Fly) for liveness checks and by Dockerfile's HEALTHCHECK directive.

GET /tip

HTML page. Renders a multi-chain signing form based on which chains the bot has configured addresses for.

This route is what the bot's chat replies link to. Tippers open the link, pick their chain, sign in their wallet.

GET /check

HTML page rendering StatusRegistry's current state. Color-coded rows, plain-English error text per component. Updates on page refresh (no WebSocket — keep it simple).

This is the page every deploy guide tells users to open after deploying. Green rows = live. Red rows explain what's wrong and what to do.

What state the bot holds

Enumerating this is important because "nothing persistent" is one of the product's main promises. Here's the full list of state the bot process holds at any given moment:

StateLifetimeContentsPersisted?
Config Process lifetime Env vars parsed at startup No (re-read each boot)
StatusRegistry Process lifetime Six status entries + timestamps No
RecentMessageBuffer 60 seconds per entry Message text only (no user IDs) No
Thank-you templates Process lifetime Loaded from thankyous.json at startup No (re-read each boot)
Chain WebSocket / RPC connections Until disconnect Network sockets (no app data) No
ENS name cache Process lifetime Map of address → name No

Restart the bot — every row above resets. Nothing is lost because nothing is accumulated.

Why the message buffer only holds message text Intentional minimality. Storing user IDs would make this a deanonymization vector; storing addresses would make it a financial-pattern-analysis vector. The buffer only needs the text to put in the next announcement, so that's all it holds. Entry expires in 60 seconds whether it was used or not.

What's deliberately not here

A list of things that look like obvious features but are missing on purpose. Each one was considered and rejected for a reason. If you're thinking about adding one of these, read the rationale first.

No database

No Postgres, no SQLite, no Redis, no flat-file log. The moment you add a database, you add data that needs backing up, migration logic, and a dozen attack surfaces (SQL injection, misconfigured connection strings, credentials in logs). The bot doesn't need any of it — every piece of "state" it might want is either on-chain already or ephemeral.

No user accounts

No sign-up, no login, no session management. The configurator runs in the browser; the bot runs on your infrastructure. There's no reason for a user concept to exist.

No smart contracts

The bot doesn't deploy contracts. It doesn't interact with contracts except to watch for USDC Transfer events. No onchain state that belongs to the bot, no contract upgradability, no governance.

No analytics, no telemetry

Zero phone-home. The bot doesn't report usage anywhere. The site doesn't include trackers. The configurator runs entirely client-side. If you want to know how often your bot is tipped, read your chain's block explorer — that's where the data lives.

No fee mechanism

No protocol fee, no revenue share, no "tip the publishers" interception. The pay-it-forward link on the landing page is purely voluntary; it's a link to our own addresses, separate from anyone else's deployment.

No REST API for third parties

The three HTTP routes exist for specific purposes (health, signing UI, status). There's no /api/tips endpoint for dashboards to consume, no webhook outbound, no GraphQL. Third-party integration means forking the code or scraping /check.

Adding a new chain or platform

The canonical release is feature-complete relative to its original four-chain, four-platform design, but forks for new chains or platforms are explicitly welcome. The adapter pattern is chain-agnostic and platform-agnostic by design — here's the minimum path.

To add a chain

  1. Create src/chains/<newchain>/ with address.ts, startup.ts, watcher.ts (and chains.ts if you need constants).
  2. Make your watcher implement the ChainWatcher interface (start / stop).
  3. Emit canonical TipEvent objects via the onTip callback passed in at construction.
  4. Extend ChainNetwork union in src/chains/types.ts to include your new network name.
  5. Wire up in src/api/server.ts — add config parsing, construct watcher if enabled, register status.
  6. Add tests mirroring the existing chain tests in tests/chains/.

XRPL is the simplest reference — about 350 lines. Solana is a good middle reference. EVM is more involved because of the message buffer, so skip it as a template.

To add a platform

  1. Create src/adapters/<newplatform>/adapter.ts.
  2. Expose start, stop, and announceTip.
  3. In start, install an event handler for bot-mentions that pushes to RecentMessageBuffer (if EVM is enabled) and replies with a signing URL.
  4. Wire up in src/api/server.ts.
  5. Add tests in tests/adapters/.

Both adapters are small — about 250 lines each. The pattern is well-established.

Related