Setup
From a license key to a running bot. Read the whole page once before you start — particularly Configure, where the numbers that govern real money live.
Requirements
- Node.js 20.19+ or 22.12+. Check with
node --version. Older versions — including Node 18, and 22.0 through 22.11 — will not run the bot. - A Solana wallet you control, funded with SOL for trading and fees.
- Your own Solana RPC endpoint. A public RPC will rate-limit this bot almost immediately — use a provider.
- A machine that stays on. The bot only finds opportunities while it is running.
- Your BOTSOL license key.
Install
Node.js 20.19+ or 22.12+ is required for both. Windows and Linux: use the zip.
Unpack the download, then install dependencies:
Create your configuration from the supplied example:
.env holds your private key.
It is excluded from version control. Never paste its contents into a
chat, an issue tracker, a support request, or any website — including
this one.
Configure .env
Every variable the bot reads, with its real default. Defaults are taken
from the shipped .env.example.
Required
| Variable | Default | What it does |
|---|---|---|
PRIVATE_KEY |
— | Your wallet key, base58 or a JSON byte array. Used locally to sign. Never transmitted. |
RPC_URL |
— | Your own Solana RPC endpoint (not a public one — see below). |
LICENSE_KEY |
— | The key from your purchase. |
A free public RPC will rate-limit the bot almost immediately, and — more importantly for an arbitrage bot — it gives you no staked connections, which are what actually determine how reliably your transactions land under network congestion. A paid RPC with staked connections is the real requirement, not just "not the public one." A concrete reference point: Helius's Developer plan, $49/mo (50 req/s), includes staked connections automatically. Any RPC provider with staked connections works; that is simply a known-good baseline.
Safety
| Variable | Default | What it does |
|---|---|---|
DRY_RUN |
true | Runs the complete path — detect, validate, build, sign — and stops immediately before submitting. Nothing is broadcast and no funds move. |
DRY_RUN=true until you have watched it run.
Let it operate for a while and read what it says it would have done. Set
it to false only when you agree with those decisions. This
is the single most useful safety habit with this software.
Trading
| Variable | Default | What it does |
|---|---|---|
TRADING_PAIRS |
unset |
Comma-separated SYMBOL/SYMBOL. Unset means
exactly SOL/USDC and nothing else. Curated symbols: SOL,
USDC, USDT, JUP, CBBTC, ETH, BONK, JUPUSD, PUMP, BOME — plus any
label you add via CUSTOM_TOKENS. Unknown symbols are
skipped with a startup warning, not a crash. The base leg must be
SOL.
|
CUSTOM_TOKENS |
unset |
Add tokens beyond the curated list, as LABEL:MINT
pairs (comma-separated). The label is only a display nickname; a
token's identity is its mint address, never its
symbol. Each mint is verified against the Jupiter tokens API at
startup — decimals are read from the API, and an unverified mint
stops the bot rather than being traded. See the warning below.
|
MAX_TRADE_SIZE_SOL |
0.1 | Size of each attempt, in SOL. |
MIN_PROFIT_THRESHOLD_PCT |
0.5 | Minimum round-trip profit, in percentage points, before a trade is considered. Computed from real quote amounts, which already net out AMM swap fees. |
POLL_INTERVAL_MS |
3000 | How often to look. Lower means more RPC and Jupiter calls — check your rate limits. |
SLIPPAGE_BPS |
50 | Slippage tolerance passed to Jupiter. 50 = 0.5%. |
MIN_PROFIT_THRESHOLD_PCT is measured from swap quotes,
which account for AMM fees but not Solana network fees,
priority fees, or Jito tips. Set it high enough to cover those, or you
can win trades on paper and still lose balance.
CUSTOM_TOKENS are
not reviewed by BOTSOL. We check only that the mint is
verified on Jupiter and read its decimals from the API — we make no
judgement about the project. Two risks to understand before adding one:
- Thin liquidity means high price impact. A lower-liquidity token moves in price when you trade it, so your fills can be far worse than the quote — the pre-trade re-check will skip many of these, which is correct, not a bug.
- A failed sell leg leaves you holding the token. A round trip is buy (SOL → token) then sell (token → SOL). If the buy confirms but the sell fails, the bot does not auto-unwind — your wallet is left holding that token instead of SOL, and for a thin token it may be hard to exit at a fair price. The bot alerts loudly when this happens; the position is then yours to resolve.
Pre-execution validation
These tune the re-check that runs immediately before every trade. The check itself cannot be disabled — only these numbers are configurable.
| Variable | Default | What it does |
|---|---|---|
MAX_PRICE_IMPACT_PCT |
1.0 | Maximum acceptable price impact on the worse of the two legs. Guards against thin liquidity. |
MAX_PROFIT_DEGRADATION_PCT |
0.2 | Maximum drop, in percentage points, between the profit seen at detection and at the final re-check. A larger drop blocks the trade even if profit still clears the threshold — it signals an opportunity that is closing. |
Jupiter API
| Variable | Default | What it does |
|---|---|---|
JUPITER_API_BASE |
lite-api.jup.ag/swap/v1 | Keyless free tier. Reduced rate limits, intended for testing. |
JUPITER_API_KEY |
unset | Required if you move to the production host. |
The default endpoint is Jupiter's keyless testing tier — no guaranteed
rate limit, and Jupiter recommends a key for production. With a key,
Jupiter's free tier is 1 request/second (60/min). The
bot sends two quote requests per pair each polling cycle, so at the
default POLL_INTERVAL_MS=3000 a single pair runs at roughly
40 requests/minute — comfortably inside the free tier. Watching multiple
pairs (about 80/min for two) or polling faster pushes you past it; the
next step is Jupiter's Developer plan, $25/mo (10 req/s).
Any Jupiter plan is a cost you pay Jupiter directly, not us.
First run
On startup it prints what it is about to do. Check each line:
- Your wallet address and SOL balance — confirm it is the wallet you meant
- The pairs being watched — with
TRADING_PAIRSunset this must read exactlySOL/USDC - Any skipped pairs, with the reason
- Your thresholds
- Whether Jito is on or off
- Whether
DRY_RUNis on
SKIP mean an opportunity was found and then
rejected at the final check — the spread closed, liquidity was too thin,
or profit degraded. A long run of skips is normal and healthy. A bot
that never skips is one that is not checking.
Dashboard
While the bot runs it serves a local dashboard. Open it at:
Change the port with DASHBOARD_PORT in your
.env.
Status tab
- Whether the bot is running or paused, and a pause/resume control
- Your wallet's public address and SOL balance — never your private key
- The pairs actually being watched, plus any that were skipped and why
- Your thresholds, read-only
- Realised profit and loss for the session, labelled as such
- Skipped opportunities, grouped by reason
- A live activity log you can filter and copy for support
This tab works with no internet connection. If the bot stops sending updates the page dims and says so, rather than showing old numbers as though they were live.
Buy License and Builder Pro tabs
These talk to the license server, so they need
LICENSE_SERVER_BASE_URL set. If it is not set, they say so
and name the variable. The Status tab is unaffected either way.
Jito bundle execution Optional
Off by default. When off, the two legs of a round trip go out as two separate RPC transactions — which means leg A (the buy) can land while leg B (the sell) fails, leaving your wallet holding the intermediate token instead of SOL.
When on, the whole round trip is submitted as one atomic Jito
bundle in a fixed order — [leg A swap, leg B swap, tip].
Bundles execute all-or-nothing: both legs land together, or
neither does. That closes the half-completed-trade gap, removes
the edge decaying in the window between two separate submissions, and
prevents anyone sandwiching a trade in between your own two legs. It is
not a speed or win-rate upgrade — it is an atomicity guarantee, paid for
with a tip.
| Variable | Default | What it does |
|---|---|---|
JITO_ENABLED |
false | Submit the round trip as one atomic bundle instead of two plain RPC transactions. (The old name ENABLE_JITO_BUNDLES still works as a deprecated alias — the bot warns at startup and refuses to start if both are set to conflicting values.) |
JITO_TIP_MODE |
fixed | fixed uses a flat JITO_TIP_LAMPORTS. dynamic targets a recent landed-tip percentile from Jito's tip feed, and falls back to the fixed tip if that feed can't be reached — it never bids blind and never crashes the run. |
JITO_TIP_LAMPORTS |
100000 | The tip in fixed mode, and the fallback in dynamic mode. 100000 lamports = 0.0001 SOL. |
JITO_MAX_TIP_PCT_OF_PROFIT |
50 | Guardrail. The bot never tips away more than this share of a trade's expected profit; if even the minimum tip would exceed it, the trade is skipped rather than submitted at a loss. |
MIN_NET_PROFIT_LAMPORTS |
0 | Before every bundle the bot requires expected profit − base fee − priority fee − tip ≥ this value, and logs every term. A trade that fails the check is skipped, and the log shows exactly which cost killed it. |
Tip accounts are fetched live from Jito on every submission and never hardcoded. The tip is always a separate transaction alongside the swaps; the swap transactions Jupiter builds are never modified.
The one residual risk
Atomicity holds in the normal case, but there is a rare exception. If a bundle is "uncled" — dropped when its slot is skipped and then re-landed piecemeal — it can lose its all-or-nothing property, and leg A can land while leg B does not. This is uncommon, but it is not impossible, so the bot does not pretend it away:
- The same incomplete-round-trip detection used in plain-RPC mode stays fully armed when Jito is on.
- The bot reads each bundle's status and classifies the outcome explicitly — landed, not landed, landed-but-failed, or the unbundled/uncled orphan case — and surfaces the counts on the dashboard's Execution routing card.
- If an orphan is detected, it raises the same unmissable dashboard banner (and optional health-check alert) as a plain-RPC partial fill. The bot does not auto-unwind; it stops and tells you.
Remote viewing Optional
Troubleshooting
PRIVATE_KEY is not set
Your .env is missing or the variable is empty. Confirm
the file is named exactly .env, sits in the same folder
as index.js, and that you copied it from
.env.example.
PRIVATE_KEY could not be parsed
The value is not a valid base58 string or JSON byte array. Re-export it from your wallet. The bot never logs the value itself, so the error will not show you what it read.
RPC_URL is not set, or constant rate-limit errors
You are on a public RPC or have none configured. Get an endpoint from a provider. This is the single most common cause of a bot that appears to do nothing.
It finds opportunities but never trades
Expected, and usually correct. Check whether DRY_RUN is
still true — in that mode it never submits. Otherwise
read the SKIP reasons: the spread closed, price impact
exceeded your limit, or profit degraded past your tolerance.
It never finds anything at all
Most likely your threshold is above what the market is offering. Real round-trip spreads on major pairs are frequently a small fraction of a percent. Lowering the threshold finds more — but remember it must still cover network fees and any tips, or the trades lose money.
A pair I configured is not being watched
Check the startup warnings. Unknown symbols, malformed entries, and pairs whose base is not SOL are all skipped with a stated reason.