Skip to content

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.
Use a dedicated trading wallet. Fund it with only what you intend to trade. Do not put your main holdings behind a key that sits in a config file on a machine that runs unattended.

Install

Node.js 20.19+ or 22.12+ is required for both. Windows and Linux: use the zip.

Unpack the download, then install dependencies:

cd solana-arb-bot npm install

Create your configuration from the supplied example:

cp .env.example .env
.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

VariableDefaultWhat 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

VariableDefaultWhat 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.
Leave 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

VariableDefaultWhat 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%.
Profit does not include network costs. 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 yours to vet, not ours. Tokens you add through 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.
Prefer the curated tokens unless you know exactly what you are adding.

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.

VariableDefaultWhat 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

VariableDefaultWhat 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

npm start

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_PAIRS unset this must read exactly SOL/USDC
  • Any skipped pairs, with the reason
  • Your thresholds
  • Whether Jito is on or off
  • Whether DRY_RUN is on
Skips are the bot working. Lines marked 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:

http://127.0.0.1:4747

Change the port with DASHBOARD_PORT in your .env.

It is local only. The dashboard binds to 127.0.0.1 and is never exposed to your network. That is not configurable. Anyone else on your network cannot reach it.

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.

VariableDefaultWhat 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.
The tip is a real cost that raises the profit you need to come out ahead. With an atomic bundle it is paid once per round trip, but it is still paid whenever the bundle lands — win or lose on the spread. The net-profit gate above subtracts it (along with network and priority fees) before every submission, so a trade only goes out if it clears all of them. The default of 100000 lamports is a starting point and is unlikely to win against serious competition without tuning upward, which raises your cost proportionally.

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

Not yet available. The relay that lets you view a running bot from another machine is still in development. When it ships it will be opt-in, outbound-only, and carry status and pause/resume controls only — never keys, never funds.

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.

Risk. Arbitrage is competitive and profitability is not guaranteed. This software finds and executes opportunities; it does not create them. Never trade funds you cannot afford to lose.