Documentation
Everything you need to understand, operate, and extend the ECHO airdrop engine — from the drop cycle internals to every environment variable.
What is ECHO
ECHO is a Solana on-chain airdrop protocol built around a simple loop: creator fees accumulate in an engine wallet, the radar finds the hottest trending coin, Jupiter buys it with the fee pot, and the bag is airdropped pro-rata to every qualifying $ECHO holder — automatically, every 30 minutes, with no claiming step.
The protocol is fully transparent. Every swap and airdrop is a public Solana transaction. The engine wallet address is published on the site. Holders can verify every drop on Solscan.
Stack
node:sqlite (native, no ORM) and node:crypto for ed25519 + PDA math. Zero required dependencies — @solana/web3.js and bs58 are optional and only needed for live swap/airdrop execution.getTokenAccounts DAS method (paginated, up to 100k accounts). Swap execution uses Jupiter v6 quote + swap APIs. ATA creation and SPL transfers are built with raw instructions.holders (live balance snapshot) and drops (immutable drop log). A meta k/v table stores state across restarts. No external database required./api/state, /api/drops) and static file serving for public/. The frontend polls every 4 seconds — no WebSockets needed.Architecture
The codebase is intentionally flat — one process, no build step, no framework. Each module has a single responsibility.
Module dependencies
index.js imports engine, indexer, and server, each of which imports config and db. engine additionally imports trending, solnative, pda, and bot. There are no circular deps.
No build step. Everything runs directly with node src/index.js. Node 22+ is required for the node:sqlite built-in. The optional packages (@solana/web3.js, bs58) are dynamically imported at runtime only when a live swap or airdrop is about to execute.
Drop cycle
The engine runs a tick() every 5 seconds. When Date.now() ≥ nextDropAt, runDrop() fires. The cycle repeats on a fixed interval (DROP_INTERVAL_MIN, default 30 min).
AUTO_CLAIM=1, the engine sends a request to pumpportal.fun to collect outstanding creator fees into the engine wallet before calculating the balance. Skipped in dry-run or mock mode.spendable = balance − RESERVE_SOL. dropLamports = spendable × SPLIT_DROP, then capped at MAX_DROP_SOL. If dropLamports < MIN_DROP_SOL, the cycle is logged as skipped and fees roll over to the next cycle.pickTrending(). In auto mode, tries DexScreener top-boosted Solana tokens first, then falls back to pump.fun king-of-the-hill. A manual override bypasses both. See Trending radar.getRecipients() from the indexer: filters holders with balance ≥ MIN_HOLD_TOKENS, removes EXCLUDED_WALLETS, sorts by balance descending, takes top MAX_RECIPIENTS. If the list is empty, the drop is logged as skipped (silent — no console output).VersionedTransaction, sends and confirms. The output token amount and decimals are read from the quote response. Slippage tolerance is SLIPPAGE_BPS (default 500 = 5%).transferChecked per recipient). Each batch is sent and confirmed before the next. See SPL airdrop.spendable × SPLIT_RAKE SOL to RAKE_WALLET via a raw SystemProgram transfer.drops with status done. If Twitter keys are configured, bot.js posts a drop announcement including the Solscan swap link.Budget math
balance = engine wallet SOL balance
spendable = max(0, balance − RESERVE_SOL)
dropLamports = floor(spendable × SPLIT_DROP)
dropLamports = min(dropLamports, MAX_DROP_SOL × 10⁹) // per-drop cap
rakeLamports = floor(spendable × SPLIT_RAKE)
→ SPLIT_DROP + SPLIT_RAKE must be ≤ 1.0
→ excess above MAX_DROP_SOL rolls over to the next cycle
Trending radar
Implemented in src/trending.js. Source priority is controlled by TRENDING_SOURCE.
https://api.dexscreener.com/token-boosts/top/v1. Takes the first Solana token (chainId === 'solana') with a valid tokenAddress. Reason recorded: "top boosted on dexscreener".https://frontend-api.pump.fun/coins/king-of-the-hill?includeNsfw=false. Used when DexScreener fails or returns no Solana token. Reason recorded: "pump.fun king of the hill".TRENDING_SOURCE=manual + MANUAL_TARGET_MINT + optionally MANUAL_TARGET_SYMBOL. Bypasses all API calls. Your safety valve if either external API changes or goes down.MOCK=1.API fragility. The DexScreener and pump.fun endpoints are unofficial and can change without notice. Always have MANUAL_TARGET_MINT ready as a fallback. If both APIs fail and no manual target is set, the drop is logged as failed.
Holder indexer
Implemented in src/indexer.js. Polls Helius for every token account under TOKEN_MINT and maintains a live snapshot in the holders SQLite table.
Helius getTokenAccounts
Uses Helius's proprietary DAS RPC method (not a standard Solana method). Paginates with a cursor field, up to 100 pages × 1000 accounts = 100k holders maximum. Multiple accounts owned by the same wallet are summed. Wallets with zero balance after summing are deleted from the table.
POST https://mainnet.helius-rpc.com/?api-key=<KEY>
{
"jsonrpc": "2.0",
"method": "getTokenAccounts",
"params": { "mint": "<TOKEN_MINT>", "limit": 1000, "cursor": "<prev>" }
}
Recipient selection
getRecipients() applies three filters in order:
- Dust filter —
balance < MIN_HOLD_TOKENS— removes wallets holding fewer than the minimum token amount. Default: 100,000 tokens. - Exclusion list — removes all addresses in
EXCLUDED_WALLETS. Critical: the pump.fun bonding curve holds a large fraction of supply and must always be excluded, or nearly the entire airdrop goes to the curve and is lost. - Top-N cap — sorts by balance descending and takes the top
MAX_RECIPIENTSwallets. Default: 150. Each recipient requires an ATA creation instruction (~0.002 SOL rent), so the cap exists for economic reasons.
Poll interval is controlled by POLL_INTERVAL_SEC (default 30 s). On the first poll after a fresh start, the table is empty — drops fire as skipped until the first poll completes.
SPL airdrop mechanism
Implemented in Engine.distribute() inside src/engine.js. Executes entirely with raw Solana instructions — no SPL helper libraries required.
Per-recipient share
totalBal = sum of all recipient.balance values
share_i = floor(totalRaw × (balance_i / totalBal))
where totalRaw = floor(tokensBought × 10^decimals)
Rounding is floor, so the engine wallet keeps any dust remainder.
Transaction structure
Each transaction handles 6 recipients. For each recipient, two instructions are packed:
0x01 on the Associated Token Account program. Creates the recipient's ATA if it does not exist; no-ops if it already exists. The engine wallet pays the rent (~0.002 SOL per new account).0x0C (12) on the Token program. Transfers exactly share_i raw token units from the engine's source ATA to the recipient's ATA, with a decimals check as a safety guard.ATA rent is real money. Creating a new ATA costs ~0.002039 SOL. With MAX_RECIPIENTS=150 and all recipients being new wallets, worst-case ATA overhead is ~0.31 SOL per drop. Keep RESERVE_SOL high enough to cover this in addition to transaction fees.
Configuration
All configuration is via environment variables. Parsed in src/config.js. No config files — change a var and restart.
Chain required in live mode
| Variable | Default | Description |
|---|---|---|
| HELIUS_API_KEY | — | Helius API key. Used to build the RPC URL unless RPC_URL is set. Free tier at helius.dev. |
| RPC_URL | — | Override the Helius RPC URL with any Solana RPC endpoint. |
| TOKEN_MINT | — | The $ECHO token mint address. All holder polling and CA display is driven by this. |
| ENGINE_WALLET_SECRET | — | Base58 private key of the engine wallet. Must be the pump.fun creator wallet. Required when DRY_RUN=0. |
| ENGINE_WALLET_ADDRESS | — | Public address of the engine wallet. Used in read-only mode when the secret is not set. |
| RAKE_WALLET | — | SOL destination for the rake (15% of spendable by default). If unset, rake stays in engine wallet. |
| EXCLUDED_WALLETS | — | Comma-separated list of wallet addresses to exclude from airdrops. Must include the bonding curve address and the engine wallet. |
Modes
| Variable | Default | Description |
|---|---|---|
| MOCK | false | Simulate everything. Fake holders, fake fees, fake drops. No chain calls. Safe for UI testing. |
| DRY_RUN | true | Live chain reads (balance, holders, trending) but no transactions. Drops are logged with swap_sig = DRY_RUN. Flip to 0 for real execution. |
Drop cycle
| Variable | Default | Description |
|---|---|---|
| DROP_INTERVAL_MIN | 30 | Minutes between drop cycles. |
| MIN_DROP_SOL | 0.05 | Minimum spendable SOL to trigger a drop. Below this, fees roll over. |
| MAX_DROP_SOL | 0.75 | Maximum SOL per drop. Excess above this rolls over to the next cycle. |
| RESERVE_SOL | 0.1 | Always-kept SOL buffer. Covers ATA rent + tx fees. Never spent on swaps. |
| SPLIT_DROP | 0.85 | Fraction of spendable SOL that buys the trending coin. Must satisfy SPLIT_DROP + SPLIT_RAKE ≤ 1. |
| SPLIT_RAKE | 0.15 | Fraction of spendable SOL sent to RAKE_WALLET. |
| SLIPPAGE_BPS | 500 | Jupiter swap slippage tolerance in basis points (500 = 5%). |
| AUTO_CLAIM | false | Automatically claim pump.fun creator fees before each drop via pumpportal.fun. |
Recipient eligibility
| Variable | Default | Description |
|---|---|---|
| MIN_HOLD_TOKENS | 100000 | Minimum $ECHO balance (UI units) to be eligible. Dust filter. Disclose this publicly. |
| MAX_RECIPIENTS | 150 | Maximum holders paid per drop (top by balance). ATA rent makes unlimited recipients uneconomical. |
Trending source
| Variable | Default | Description |
|---|---|---|
| TRENDING_SOURCE | auto | auto tries DexScreener then pump.fun KOTH. manual always uses MANUAL_TARGET_MINT. |
| MANUAL_TARGET_MINT | — | Mint address to always buy when TRENDING_SOURCE=manual. Also acts as safety fallback if set alongside auto. |
| MANUAL_TARGET_SYMBOL | TARGET | Display symbol for the manual target. |
Infrastructure
| Variable | Default | Description |
|---|---|---|
| PORT | 3000 | HTTP server port. |
| DB_PATH | ./data/echo.db | SQLite file path. Created automatically on first run. |
| POLL_INTERVAL_SEC | 30 | How often the indexer polls Helius for updated holder balances. |
Twitter / X bot optional
| Variable | Default | Description |
|---|---|---|
| TWITTER_APP_KEY | — | All four must be set to enable auto-posting. Obtain from the X developer portal with read+write permissions. If any are missing, drops are only logged locally. |
| TWITTER_APP_SECRET | — | |
| TWITTER_ACCESS_TOKEN | — | |
| TWITTER_ACCESS_SECRET | — |
Mock / demo mode
| Variable | Default | Description |
|---|---|---|
| MOCK_FEE_SOL_PER_MIN | 0.12 | Simulated fee accrual rate (SOL/min) in mock mode. |
| MOCK_DROP_INTERVAL_SEC | 40 | Drop cycle interval in mock mode (seconds, not minutes). |
| MOCK_HOLDER_COUNT | 140 | Number of synthetic holders generated in mock mode. |
HTTP API
Two read-only JSON endpoints. The frontend polls both every 4 seconds. No authentication.
Engine status, live stats, and branding. Polled every 4 s by the frontend.
{
"brand": { "name", "ticker", "tagline", "buyUrl" },
"ca": string, // TOKEN_MINT — drives Copy CA
"mock": boolean,
"dryRun": boolean,
"now": number, // unix seconds
"nextDropAt": number, // unix seconds
"cycleSec": number, // DROP_INTERVAL_MIN × 60
"current": { "mint", "symbol", "name", "reason" } | null,
"tankSol": number, // spendable SOL (balance − RESERVE_SOL)
"totalSol": number, // sum of sol_spent across all done drops
"dropsDone": number,
"distinctCoins": number,
"holders": number, // wallets with balance ≥ MIN_HOLD_TOKENS
"engineWallet": string | null,
"dropIntervalMin": number
}
Last 40 drop records, newest first.
[{
"id": number,
"at": number, // unix seconds
"target_mint": string,
"target_symbol": string,
"target_name": string,
"reason": string, // why this coin was chosen
"sol_spent": number,
"tokens_bought": number,
"recipients": number,
"swap_sig": string, // tx signature or "DRY_RUN"
"status": "done" | "skipped" | "failed",
"note": string
}]
Database schema
SQLite, WAL mode. File path: DB_PATH (default ./data/echo.db).
wallet TEXT PRIMARY KEY -- base58 owner address
balance REAL NOT NULL -- UI units (raw / 10^decimals)
last_seen INTEGER -- unix seconds of last Helius observation
updated_at INTEGER
id INTEGER PRIMARY KEY AUTOINCREMENT
at INTEGER NOT NULL -- unix seconds
target_mint TEXT
target_symbol TEXT
target_name TEXT
reason TEXT -- "top boosted on dexscreener" etc.
sol_spent REAL DEFAULT 0
tokens_bought REAL DEFAULT 0
recipients INTEGER DEFAULT 0
swap_sig TEXT -- tx signature or "DRY_RUN"
status TEXT DEFAULT 'done' -- done | skipped | failed
note TEXT
key TEXT PRIMARY KEY
value TEXT
The meta table is used for persistent state across restarts (currently reserved for future use — state is reconstructed from drops and live RPC calls).
Resetting state. Run rm -rf data/ and restart. The directory and schema are re-created automatically. Do this when changing TOKEN_MINT — stale holder data for the old mint will cause incorrect recipient selection.
Scripts
npm run keygen
Generates a fresh Solana keypair using only node:crypto (no dependencies). Prints the public address and base58 private key. Use this to create the engine wallet — then launch your pump.fun coin from this wallet so it becomes the creator.
node scripts/keygen.js
→ Public address (use as the pump.fun creator wallet):
73FL5Jwx...
→ POT_WALLET_SECRET (keep secret!):
5Kd3NbFMvy...
node scripts/prelaunch.js <CA> [siteUrl]
Offline pre-launch helper. Takes your token's mint address (CA) and derives the pump.fun bonding curve address using the same PDA math as the Solana SDK. Outputs a ready-to-copy environment variable block.
node scripts/prelaunch.js CAnht1PfUb... https://yoursite.com
→ CA: CAnht1PfUb...
→ Bonding curve: BZoQUSnD9p... (must be in EXCLUDED_WALLETS)
→ ... full env block ...
The bonding curve exclusion is critical. The pump.fun bonding curve holds a large fraction of token supply. If it is not in EXCLUDED_WALLETS, the majority of every airdrop is sent to the curve address and irrecoverably lost.
Running locally
Demo mode (zero keys)
MOCK=1 PORT=5000 node src/index.js
Simulated fees, synthetic holders, fake drops on a 40-second cycle. No chain calls. Safe for UI development and testing.
Dry-run mode (live reads, no transactions)
MOCK=0 DRY_RUN=1 \
HELIUS_API_KEY=... \
TOKEN_MINT=... \
ENGINE_WALLET_SECRET=... \
DROP_INTERVAL_MIN=2 \
PORT=5000 node src/index.js
Polls real holders from Helius, picks a real trending coin, calculates a real buy — but sends no transactions. Drops are logged with swap_sig = DRY_RUN. Use a short DROP_INTERVAL_MIN (1–2) to quickly verify the full cycle works before going live.
Live mode
MOCK=0 DRY_RUN=0 PORT=5000 node src/index.js
Real swaps and real airdrops. Ensure ENGINE_WALLET_SECRET is set, the engine wallet is funded, and EXCLUDED_WALLETS includes the bonding curve + engine wallet + your personal wallets.
Production deployment. Use a Reserved VM (always-on). The 30-minute drop cycle requires a process that never sleeps. The run command is node src/index.js. Never hardcode PORT in the run command for production — let the hosting environment inject it.
Launch checklist
- Run
npm run keygen→ save public address + store secret inENGINE_WALLET_SECRET - Launch the pump.fun coin from the engine wallet address (this makes it the creator)
- Run
node scripts/prelaunch.js <CA>→ copy the derived bonding curve address - Set
TOKEN_MINTto the coin's CA - Set
EXCLUDED_WALLETS=<bonding curve>,<engine wallet>,<your personal wallets> - Set
BUY_URL=https://pump.fun/coin/<CA> - Set
RAKE_WALLETto your personal wallet - Fund the engine wallet with ≥ 0.3 SOL (covers RESERVE_SOL + ATA rent + a few drops)
- Start with
DRY_RUN=1andDROP_INTERVAL_MIN=2— watch for[engine] 📡 DROPin logs - Verify the drop log shows real holders and a real Jupiter quote resolved
- Flip
DRY_RUN=0andDROP_INTERVAL_MIN=30— restart - Monitor the first live drop on Solscan before announcing publicly
Security notes
Engine wallet
The engine wallet holds real SOL and signs real transactions. Store ENGINE_WALLET_SECRET only in an environment secret manager (Replit Secrets, Doppler, etc.) — never in a file, never in a repo, never in a log.
API endpoints are public and unauthenticated
/api/state and /api/drops are intentionally public — transparency is a feature of the protocol. They are read-only. No write endpoints exist.
Bonding curve exclusion
The bonding curve address for any pump.fun token is deterministic and can be derived offline with scripts/prelaunch.js. Always derive it before launch and add it to EXCLUDED_WALLETS. Failure to do so means most of every airdrop is lost.
Recipient cap disclosure
The MAX_RECIPIENTS=150 cap and MIN_HOLD_TOKENS=100000 dust filter should be disclosed publicly. Holders below the threshold or outside the top 150 receive nothing from a given drop. This is by design — ATA rent makes unlimited recipients uneconomical.
DRY_RUN safety
The engine defaults to DRY_RUN=true. No transaction is ever signed or sent unless DRY_RUN is explicitly set to 0 or false. The config validator enforces that ENGINE_WALLET_SECRET is present before allowing DRY_RUN=0.