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.
A creator runs the bot on their own infrastructure. The bot does two things:
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.
┌─────────────┐ ┌──────────────┐
│ 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).
These are non-negotiable constraints baked into the codebase. When evaluating a change, any change that breaks one of these is a bad change.
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.
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.
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.
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.
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.
No release ships unless typecheck, lint, and tests all pass. The release script enforces this. Broken main branch is not a thing that happens.
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 whole bot is two separate listeners that never share mutable state:
Trigger: a user mentions the bot or issues a tip command in Discord, Telegram, Twitch, or X.
/tip route.RecentMessageBuffer. XRPL and Solana skip this — they use on-chain memos.Trigger: a payment lands at the creator's address on any enabled chain.
TipEvent.{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.
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.
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.
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).
Memo field from the transaction envelope — this is where tippers can include their name or a note.ALLOWED_ASSETS env var (defaults to USDC+RLUSD; we ship with XRP only for our own deployment since we don't hold trust lines).issuer is populated).RecentMessageBuffer — memos come from the chain directly.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.
chains.ts). Not bridged USDbC / USDC.e variants.message_buffer.ts — a 60-second in-memory buffer populated by Listener A when users mention the bot. When a tip lands, the watcher grabs the freshest buffered message as the "memo" field.ENS_MAINNET_RPC_URL is set, so fromDisplay can be alice.eth instead of 0x7a…b3c.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.
MemoProgram instruction — Solana supports native memos via a dedicated program (IDs in chains.ts).RecentMessageBuffer — same reason as XRPL.api.mainnet-beta.solana.com RPC (rate-limited for free use; configure SOL_RPC_URL for real traffic — Helius, QuickNode, Alchemy all work).
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.
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:
xrpl — connected to XRPL cluster, owner account existsevmBase — connected to Base RPC, watching USDC transfersevmArbitrum — same, Arbitrumsolana — connected to Solana RPC, ATA deriveddiscord — logged in as the bot, ready to receive eventstelegram — same, Telegram
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.
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 /tipHTML page. Renders a multi-chain signing form based on which chains the bot has configured addresses for.
ethereum:0xabc...?value=1) that opens in MetaMask, Rainbow, etc.solana:7xKX...?spl-token=EPjFWdd...) that opens in Phantom, Solflare.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.
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:
| State | Lifetime | Contents | Persisted? |
|---|---|---|---|
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.
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 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 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.
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.
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 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.
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.
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.
src/chains/<newchain>/ with address.ts, startup.ts, watcher.ts (and chains.ts if you need constants).ChainWatcher interface (start / stop).TipEvent objects via the onTip callback passed in at construction.ChainNetwork union in src/chains/types.ts to include your new network name.src/api/server.ts — add config parsing, construct watcher if enabled, register status.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.
src/adapters/<newplatform>/adapter.ts.start, stop, and announceTip.start, install an event handler for bot-mentions that pushes to RecentMessageBuffer (if EVM is enabled) and replies with a signing URL.src/api/server.ts.tests/adapters/.Both adapters are small — about 250 lines each. The pattern is well-established.