candle-launch-bot 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of candle-launch-bot might be problematic. Click here for more details.

Files changed (66) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +400 -2
  4. package/bin/candle-launch-bot +7 -0
  5. package/dist/alerts.d.ts +28 -0
  6. package/dist/alerts.js +82 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.js +321 -0
  9. package/dist/config.d.ts +88 -0
  10. package/dist/config.js +244 -0
  11. package/dist/detect/detector.d.ts +111 -0
  12. package/dist/detect/detector.js +348 -0
  13. package/dist/engine.d.ts +103 -0
  14. package/dist/engine.js +585 -0
  15. package/dist/exec/executor.d.ts +149 -0
  16. package/dist/exec/executor.js +859 -0
  17. package/dist/exec/fills.d.ts +25 -0
  18. package/dist/exec/fills.js +53 -0
  19. package/dist/exec/trader.d.ts +109 -0
  20. package/dist/exec/trader.js +310 -0
  21. package/dist/filters.d.ts +26 -0
  22. package/dist/filters.js +92 -0
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +2 -0
  25. package/dist/init.d.ts +23 -0
  26. package/dist/init.js +192 -0
  27. package/dist/ledger.d.ts +189 -0
  28. package/dist/ledger.js +243 -0
  29. package/dist/log.d.ts +22 -0
  30. package/dist/log.js +40 -0
  31. package/dist/market/curve.d.ts +80 -0
  32. package/dist/market/curve.js +168 -0
  33. package/dist/market/reader.d.ts +37 -0
  34. package/dist/market/reader.js +106 -0
  35. package/dist/redact.d.ts +20 -0
  36. package/dist/redact.js +111 -0
  37. package/dist/solana/base58.d.ts +4 -0
  38. package/dist/solana/base58.js +45 -0
  39. package/dist/solana/logs-ws.d.ts +25 -0
  40. package/dist/solana/logs-ws.js +116 -0
  41. package/dist/solana/pda.d.ts +12 -0
  42. package/dist/solana/pda.js +70 -0
  43. package/dist/solana/rpc.d.ts +29 -0
  44. package/dist/solana/rpc.js +96 -0
  45. package/dist/strategies/hold.d.ts +3 -0
  46. package/dist/strategies/hold.js +6 -0
  47. package/dist/strategies/ladder.d.ts +8 -0
  48. package/dist/strategies/ladder.js +51 -0
  49. package/dist/strategies/load.d.ts +5 -0
  50. package/dist/strategies/load.js +44 -0
  51. package/dist/strategies/preGrad.d.ts +10 -0
  52. package/dist/strategies/preGrad.js +32 -0
  53. package/dist/strategies/time.d.ts +6 -0
  54. package/dist/strategies/time.js +16 -0
  55. package/dist/strategies/tpsl.d.ts +8 -0
  56. package/dist/strategies/tpsl.js +30 -0
  57. package/dist/strategies/types.d.ts +55 -0
  58. package/dist/strategies/types.js +31 -0
  59. package/examples/dry.json +32 -0
  60. package/examples/live-embedded.json +14 -0
  61. package/examples/live-linked.json +15 -0
  62. package/examples/strategies/breakeven-stop.js +22 -0
  63. package/examples/strategies/stale-coin.js +18 -0
  64. package/examples/strategies/timeboxed-tp.js +18 -0
  65. package/package.json +60 -4
  66. package/skills/candle-launch-bot/SKILL.md +71 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ - Detection from Candle's token list and the launch-curve program on chain, with latency for both.
8
+ - Filters: launch mode, age, price impact, creator allow/deny, name/symbol match; caps for ticket, open positions,
9
+ lifetime spend and entries per coin.
10
+ - Payers `embedded` (Candle signs) and `linked` (TEE wallet, local key signer).
11
+ - Exits `hold`, `tpsl`, `time`, `ladder`, `preGrad`, and custom strategy files; three example strategies.
12
+ - Fills booked from the chain; sell clamp to the wallet balance; capped retries with backoff; recovery of trades that
13
+ may have executed; alerts to a command or a webhook.
14
+ - `init` (interactive or from flags), `scan`, `run`, `status`, `sell`; STOP and PAUSE_BUYS controls; `status.json`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 candle-launch-bot contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,401 @@
1
- # Temporary Holding Version
1
+ # candle-launch-bot
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Every Candle launch. First in line. Your size, your exit.**
4
+
5
+ Built by [Bruce Jeet](https://github.com/BruceJeet).
6
+
7
+ Somebody is first on every coin that drops on [Candle](https://candle.tv). Might as well be you.
8
+
9
+ candle-launch-bot watches the chain and Candle's token list at the same time, sizes in the second a launch clears your
10
+ filters, and works your exit while you sleep. One config file. One process. A local SQLite ledger that books what
11
+ actually landed, not what somebody quoted you.
12
+
13
+ - **Sees it first.** Two feeds race: Candle's public token list and the launch-curve program on chain (`logsSubscribe`,
14
+ signature polling as backup). First sighting wins, and both latencies get logged so you know which feed is carrying.
15
+ - **Picks its fights.** Launch mode, coin age, the price impact of your own buy on the curve, creator allow and deny
16
+ lists, name and symbol regex. Hard caps on ticket size, open bags, lifetime spend and entries per coin.
17
+ - **Pulls the trigger.** Candle's trade API, from your account's embedded wallet (Candle signs) or a TEE wallet you
18
+ sign for on your own box.
19
+ - **Gets paid.** `ladder`, `tpsl`, `time`, `preGrad`, `hold`, or drop in your own exit as one file.
20
+ - **Dry mode** runs the whole hunt with paper fills priced off Candle's own curve quote. Watch it work before it
21
+ spends a lamport.
22
+
23
+ Built on Candle's official SDK, [`@candledottv/agent-sdk`](https://www.npmjs.com/package/@candledottv/agent-sdk)
24
+ (pinned at 0.6.14). Solana only for now.
25
+
26
+ ## House rules
27
+
28
+ The bot runs on four rules. They're in the code, not in a promise:
29
+
30
+ 1. **Book what landed.** Every fill is read off the transaction. A quote is not a fill.
31
+ 2. **Never sell what you can't see.** Every sell is clamped to the wallet's real balance, and the clamp only goes down.
32
+ 3. **Never loop on your SOL.** Retries have a cap, a backoff and an end. A stuck order stops; it doesn't spin.
33
+ 4. **One live transaction per order.** No double buys from a retry storm.
34
+
35
+ The full details are under "How it handles your money".
36
+
37
+ Words you'll see: a **launch mode** is how a Candle coin's bonding curve was set up (`open`, `exclusive`, and the
38
+ experimental `exp-a`, `exp-b`, `exp-c` and `exp-custom`, whose curves can take any shape); **graduation** is the moment
39
+ a curve fills and the coin moves to its DAMM v2 pool; a **TEE wallet** is a Candle trading wallet held in a secure
40
+ enclave (through Privy, Candle's wallet provider) and signed for by your key's signer.
41
+
42
+ ## Get in (five minutes)
43
+
44
+ Node 22.18 or newer.
45
+
46
+ ```sh
47
+ npx candle-launch-bot init # a few questions; writes bot.json in dry mode
48
+ npx candle-launch-bot scan --config bot.json --for 600 # print what it would buy; never trades
49
+ npx candle-launch-bot run --config bot.json # dry run: the whole loop with paper fills
50
+ ```
51
+
52
+ Dry run looks right? Set your API key, fund the wallet, and go live:
53
+
54
+ ```sh
55
+ export CANDLE_API_KEY=cndl_live_...
56
+ npx candle-launch-bot init --mode live --force [your flags again] # or edit bot.json: "mode": "live", your wallet
57
+ npx candle-launch-bot run --config bot.json
58
+ ```
59
+
60
+ `init` also runs without questions, from flags (`--yes`), for scripts and agents. `candle-launch-bot --help` lists the
61
+ flags:
62
+
63
+ ```sh
64
+ npx candle-launch-bot init --yes --payer embedded --sol 0.01 --max-open 3 --lifetime-sol 0.1 \
65
+ --modes open,exp-custom --max-age 60 --max-impact 300 --exit tpsl --take-profit 2 --stop-loss 0.5
66
+ ```
67
+
68
+ With the API key in the environment, `init` looks up the key's wallet for you. Without a wallet, a dry config gets a
69
+ placeholder address; live mode needs the real one.
70
+
71
+ ### Or let your agent set it up
72
+
73
+ Paste this to a coding agent (Claude Code, or any agent with a shell):
74
+
75
+ > Install and set up candle-launch-bot for me. Read `npx candle-launch-bot --help` and the package's README.md. Ask me
76
+ > for: payer (embedded or linked), ticket size in SOL, max open positions, lifetime SOL cap, launch modes, and the exit
77
+ > strategy. My Candle API key is in the env var CANDLE_API_KEY; never print it. Run `npx candle-launch-bot init --yes`
78
+ > with my answers, then `scan --for 600` and show me what it would have bought. Then start a dry run
79
+ > (`run --mode dry`) and show me `status.json` after a launch. Do not go live until I say so.
80
+
81
+ The package also ships an agent skill, `skills/candle-launch-bot/SKILL.md`, that walks an agent through the same steps.
82
+
83
+ ## Setup in detail
84
+
85
+ ### 1. Install
86
+
87
+ `npx candle-launch-bot ...` needs nothing else. To keep a copy: `npm install -g candle-launch-bot`. From source:
88
+
89
+ ```sh
90
+ git clone <this repo> && cd candle-launch-bot
91
+ npm ci
92
+ npm test
93
+ ```
94
+
95
+ A source checkout runs the TypeScript in `src/` directly (Node strips the types); the package runs the compiled `dist/`.
96
+
97
+ ### 2. Candle API key
98
+
99
+ Make an agent key for your Candle account (`cndl_live_…`; on candle.tv's agents page, or `candle keys create` with
100
+ the Candle CLI). Trading needs the **`swap:write`** scope, which a key only gets when it is asked for (pass it in the
101
+ key's scopes when you create it). The key decides which Candle profile the trades show under. Put the key in an
102
+ environment variable, never in the config file:
103
+
104
+ ```sh
105
+ export CANDLE_API_KEY=cndl_live_...
106
+ ```
107
+
108
+ If your account caps SOL per trade on the key, set the cap at or above `buy.sol`.
109
+
110
+ ### 3. Pick a payer
111
+
112
+ | `payer` | Pays from | Signs | Needs |
113
+ |---|---|---|---|
114
+ | `embedded` | your Candle account's embedded Solana wallet | Candle, server-side, inside the trade call | the API key only |
115
+ | `linked` (default) | a TEE wallet bound to the API key | this machine, with the key's signer, through Candle's relay | the API key and the key signer (below) |
116
+
117
+ `embedded` is the simple one: it needs nothing but the API key, so it also suits a server with no OS keychain (a Linux
118
+ box without `secret-tool`, where the Candle CLI keeps its secrets in an encrypted file). At start the bot reads the key's embedded wallet (`GET /api/v1/agent/wallets/embedded`)
119
+ and refuses to run unless `wallet` is its Solana address, the key isn't denied the wallet, and the wallet is
120
+ delegated (so Candle can sign). There is no signer to set up: leave `signer`, `privyAppId`, `linkedWalletId` and
121
+ `privyWalletId` out (the config refuses them with `embedded`).
122
+
123
+ ```json
124
+ { "mode": "live", "payer": "embedded", "apiKeyEnv": "CANDLE_API_KEY", "wallet": "<your embedded wallet address>", ... }
125
+ ```
126
+
127
+ With `embedded`, Candle signs and broadcasts inside the build call, so the bot can't save a signature before sending
128
+ (it can with `linked`). What it does instead:
129
+
130
+ - every order has one `clientTradeId` for life;
131
+ - a build that fails in any way other than Candle's own 4xx refusal counts as possibly executed (it counts against
132
+ `lifetimeSol` and `maxPerCoin`);
133
+ - the next attempt first reads Candle's trade job: `confirmed` is booked from its transaction, an id still in flight
134
+ is waited on (also at the attempt cap), and a same-id rebuild is Candle's replay of that trade, never a second one.
135
+
136
+ The embedded wallet is shared by the whole account: other API keys, the candle.tv app and you by hand all trade from
137
+ it. The bot's caps (`lifetimeSol`, `maxOpen`, `maxPerCoin`) count only this bot's own orders, not the wallet's other
138
+ trades. A sell of the same coin by someone else shows up as a wallet shortfall: the position is written down to what
139
+ the wallet still holds (alert `shortfall`). When a possibly executed buy can't be confirmed through Candle, the bot looks for its
140
+ transaction in the history of the wallet's token account for that coin (a buy since the order was made, for about the
141
+ ticket's SOL, landed while one of the order's attempts could have, not booked by another order, read back to the
142
+ order's creation) and books that (alert `found_tx`; two
143
+ such transactions are a double execution, booked together, alert `double_execution`). Without one, it adopts only tokens
144
+ that arrived *after* the order was made in buys that spent SOL (never a transfer or airdrop), and at most 1.5 x the
145
+ order's expected fill plus slippage (alert `adopted`).
146
+ Tokens that nothing explains yet may be an RPC that lags the balance: the order stays open and the scan repeats on the
147
+ backoff, up to 10 times (alert `rescan`). From then on the order only scans; it never builds again. A bag that was
148
+ already in the wallet, or anything bigger than this order could have bought, is left alone with an alert
149
+ (`unexplained_tokens`). `sell` on that coin reopens the ended buy's position by the same rule: its own transaction if the
150
+ history now shows it (its tokens, its cost), else only tokens bought since the order, capped (cost the ticket); never a
151
+ bag that was already there (alert `reopened`). Every 10 min the bot also compares the wallet's balance of each held coin
152
+ with what it booked (also for an hour after a position ends); more than booked on two readings a minute apart is
153
+ alerted (`surplus`), never adopted. Trading the bot's coins by hand in the same wallet confuses these checks; a wallet of its own (a separate account, or
154
+ `linked`) avoids all of it.
155
+
156
+ Everything else is the same for both payers: fills booked from the transaction, the sell clamp (on the wallet that
157
+ pays), capped retries, alerts.
158
+
159
+ ### 4. TEE wallet and its signer (payer `linked`)
160
+
161
+ The bot pays from a Candle **TEE wallet bound to the API key**. At start it lists the wallets the key can trade
162
+ from (`GET /api/v1/agent/wallets/trading`) and refuses to run unless `wallet` is one of them. The same answer gives the
163
+ key's prefix and Candle's Privy app id, so `privyAppId`, `linkedWalletId` and `privyWalletId` need no config. Set them
164
+ only to pin values; a pin that disagrees with Candle stops the bot.
165
+
166
+ Signing happens on your machine with the key's P-256 **key signer**, made once with the Candle CLI on the machine that
167
+ will run the bot (`candle tee signer new --key <key prefix>`) and approved by the account owner
168
+ (`candle keys signer approve <code> --key <key prefix>`). Bind the wallet to the key with the CLI as Candle documents.
169
+ Then tell the bot where the signer is with `signer`:
170
+
171
+ | `signer.store` | Where the key is |
172
+ |---|---|
173
+ | `keychain` | Where the Candle CLI keeps it in the OS keychain (service `tv.candle.cli`): account `key_signer_<keyPrefix>_<spkiSha256>`, found through `keySigners.entries` in `~/.config/candle/config.json` by key prefix and the wallet's signer quorum, and checked against that index. For an imported (non-TEE) linked wallet, account `wallet_signer_<linkedWalletId>`. Uses `security` on macOS, `secret-tool` on Linux. A CLI that keeps its secrets in an encrypted file instead of an OS keychain can't be read this way: use `file` or `env`. |
174
+ | `file` | An `EncryptedFileSecretStore` file from the SDK (`path`, relative to the config file), passphrase in an env var (`passphraseEnv`), keyed by the wallet's linked-wallet id |
175
+ | `env` | The PEM (or the CLI's single-line base64 PKCS#8 form) in an env var (`pemEnv`) |
176
+
177
+ `keychain` reads where the Candle CLI stores the signer today (its config index and keychain account names). If a
178
+ later CLI version stores it differently, the bot reports the signer as missing at start; `file` or `env` don't depend
179
+ on the CLI.
180
+
181
+ ### 5. Funding
182
+
183
+ Send SOL to the wallet that pays (embedded or TEE): tickets (`buy.sol` × the number of entries you allow) plus about 0.003 SOL per coin for
184
+ token-account rent and network fees. Every buy also pays Candle's platform fee on top of the ticket.
185
+
186
+ ### 6. Run
187
+
188
+ ```sh
189
+ candle-launch-bot init [--out bot.json] # write a config
190
+ candle-launch-bot scan --config bot.json # watch and print what it would buy; never trades
191
+ candle-launch-bot run --config bot.json --mode dry # the whole loop with paper fills
192
+ candle-launch-bot run --config bot.json # live, if the config says "mode": "live"
193
+ candle-launch-bot status --config bot.json # the last status.json
194
+ candle-launch-bot sell <mint> --config bot.json [--fraction 0.5]
195
+ ```
196
+
197
+ `sell` goes through the same order path as the bot (balance clamp, retries). If the bot is running, `sell` hands it
198
+ the request through `<stateDir>/requests/` and the bot runs it. A position whose exits Candle keeps refusing
199
+ (`exit_stuck`) gets one automatic try every `retry.stuckRetryMs` and an alert until it sells; `sell` tries at once.
200
+
201
+ Each cycle runs exits first (sell orders, then the strategy's new exits), then entries, within a time budget. A slow
202
+ market read while filtering times out after 3 s, and STOP or a signal is checked between steps and between orders.
203
+
204
+ ## Controls (the brakes)
205
+
206
+ Files in the state dir, read at the top of every cycle:
207
+
208
+ - `STOP`: finish the cycle, write status, exit 0. The file stays: a restart exits again at once until you remove it.
209
+ - `PAUSE_BUYS`: no new entries; exits keep running.
210
+
211
+ `SIGINT`/`SIGTERM` stop the same way as `STOP`. A restart picks up from the ledger: open positions, orders in flight
212
+ (a signed transaction is checked on chain before anything is rebuilt), caps used.
213
+
214
+ ## State dir
215
+
216
+ | File | What |
217
+ |---|---|
218
+ | `ledger.sqlite` | positions, orders, every attempt with its full error (program logs included), launches seen, alerts |
219
+ | `status.json` | written every cycle: equity, open positions with price and multiple, caps used, order-queue health, detection stats and latencies |
220
+ | `events.jsonl` | one JSON line per event (also on stdout) |
221
+ | `lock` | one process per state dir |
222
+
223
+ Every line that leaves the process is redacted first: the values of env vars whose names look secret
224
+ (`KEY`, `TOKEN`, `SECRET`, `PASSPHRASE`, `RPC`, …), the API key, the RPC URLs' keys, and common key patterns.
225
+
226
+ ## Config reference
227
+
228
+ ```json
229
+ {
230
+ "mode": "live",
231
+ "apiUrl": "https://api.alpha.candle.tv",
232
+ "apiKeyEnv": "CANDLE_API_KEY",
233
+ "rpcUrlEnv": "SOLANA_RPC_URL",
234
+ "rpcWsUrlEnv": "SOLANA_WS_URL",
235
+ "payer": "linked",
236
+ "wallet": "<TEE wallet address>",
237
+ "signer": { "store": "keychain" },
238
+ "stateDir": "./state",
239
+ "loopMs": 2000,
240
+ "buy": { "sol": 0.05, "maxSlippageBps": 500, "maxOpen": 3, "lifetimeSol": 1, "maxPriorityFeeLamports": 200000 },
241
+ "sell": { "maxSlippageBps": 1000 },
242
+ "filters": {
243
+ "chains": ["solana"],
244
+ "launchModes": ["open", "exp-custom"],
245
+ "maxAgeSec": 60,
246
+ "maxImpactBps": 300,
247
+ "creatorAllow": [],
248
+ "creatorDeny": [],
249
+ "nameMatch": "dog|cat",
250
+ "symbolMatch": "^[A-Z]{3,6}$",
251
+ "inferModeFromCurve": true
252
+ },
253
+ "detect": { "api": true, "chain": true, "apiPollMs": 3000, "chainPollMs": 15000, "apiLimit": 20 },
254
+ "exit": { "strategy": "ladder", "rungs": [{ "at": 2, "sell": 0.5 }, { "at": 5, "sell": 0.25 }], "stopLoss": 0.4 },
255
+ "retry": { "maxAttempts": 6, "baseDelayMs": 2000, "maxDelayMs": 120000, "maxExitOrders": 3, "stuckRetryMs": 600000, "builtProbeMs": 600000 },
256
+ "alerts": { "command": ["/usr/local/bin/notify-me", "launch-bot"], "webhookEnv": "ALERT_WEBHOOK_URL", "renotifyMs": 3600000 }
257
+ }
258
+ ```
259
+
260
+ Unknown keys are refused at every level, so a typo fails at start instead of being ignored.
261
+
262
+ | Key | Meaning |
263
+ |---|---|
264
+ | `mode` | `dry` (decide and log, paper fills) or `live` |
265
+ | `apiUrl` | Candle API host (must be https) |
266
+ | `apiKeyEnv` | name of the env var holding the API key |
267
+ | `rpcUrlEnv`, `rpcWsUrlEnv` | env vars with your Solana RPC http and websocket URLs. Default: the public mainnet RPC, websocket derived from the http URL |
268
+ | `payer` | `embedded` (Candle signs; API key only) or `linked` (TEE wallet, local key signer; default) |
269
+ | `wallet` | the wallet that pays: the key's embedded wallet, or the TEE wallet |
270
+ | `linkedWalletId`, `privyWalletId`, `privyAppId` | optional pins; Candle supplies all three, and a pin that disagrees stops the bot |
271
+ | `buy.sol` | SOL per ticket |
272
+ | `buy.maxSlippageBps` | slippage tolerance on buys |
273
+ | `buy.maxOpen` | most positions open at once (open, opening, or stuck) |
274
+ | `buy.lifetimeSol` | most SOL in tickets ever committed to buys by this state dir. Candle's platform fee (its `bps`, about 0.25 %) and network fees come on top. A buy refused before anything was signed, or reverted, doesn't count; a refusal after signing does, because it might have landed |
275
+ | `buy.maxPerCoin` | entries per coin: 1 (the only value; re-entering a coin is not supported) |
276
+ | `buy.maxPriorityFeeLamports`, `sell.maxPriorityFeeLamports` | priority-fee ceiling Candle's build may bid (Jupiter venue) |
277
+ | `sell.maxSlippageBps` | slippage tolerance on sells (default: the larger of the buy's and 1000) |
278
+ | `filters.launchModes` | Candle launch modes to buy: `open`, `exclusive`, `exp-a`, `exp-b`, `exp-c`, `exp-custom` (also `test-open`, `test-exclusive`); anything else is a config error |
279
+ | `filters.maxAgeSec` | skip a coin older than this at decision time (age from the launch transaction's block time) |
280
+ | `filters.maxImpactBps` | skip when your ticket's average fill price would be more than this above the spot price (`s1/s0 - 1` on the curve's square-root price, after the curve's fee), from the curve account; checked again before every buy retry |
281
+ | `filters.inferModeFromCurve` | a launch seen on chain before Candle's list has it counts as `open` when its curve account matches the open preset exactly (threshold, fee, both liquidity segments, start price). Every other curve, `exclusive` and all `exp-*` modes included, waits for Candle's `tier`, since an exp-custom curve can take any threshold. Default true |
282
+ | `filters.creatorAllow` / `creatorDeny` | addresses; matched against the token list's creator and the launch transaction's signer. The bot's own `wallet` is always denied |
283
+ | `filters.nameMatch` / `symbolMatch` | case-insensitive regular expressions |
284
+ | `detect.*` | turn a source off, set poll intervals |
285
+ | `exit` | `{ "strategy": "<name>", ...params }`, or `{ "strategy": "x", "module": "./my-exit.ts", ... }` |
286
+ | `retry.maxAttempts` | attempts per order before it is terminal |
287
+ | `retry.baseDelayMs`, `maxDelayMs` | exponential backoff with jitter, per order |
288
+ | `retry.maxExitOrders` | exit orders Candle *refuses* (or that revert) before a position goes `exit_stuck`. Orders that run out of attempts on network errors, timeouts, 5xx or rate limits don't count: the next exit is minted on the position's backoff, without limit |
289
+ | `retry.builtProbeMs` | payer `embedded`: a trade Candle still shows as `built` after this long (default 10 min) gets one same-id build that asks Candle for its signature. For a buy it runs only while the buy would still be placed (before its deadline, buys not paused, impact passing) |
290
+ | `retry.builtHoldMs` | payer `embedded`: a buy past its deadline whose job stays `built` this long (default 6 h) is ended after a wallet scan (alert `built_held`); it stays counted in `lifetimeSol` |
291
+ | `retry.stuckRetryMs` | an `exit_stuck` position still gets one more exit try this often (default 10 min), and its alert keeps repeating until an exit fills |
292
+ | `alerts.command` | argv run (no shell) for each alert; the alert is JSON on stdin and in `ALERT_KEY` / `ALERT_MESSAGE`. It gets a minimal environment: `PATH`, those two, and the names in `alerts.commandEnv`; never the API key, signer or RPC URL |
293
+ | `alerts.commandEnv` | extra env var names to pass to the alert command |
294
+ | `alerts.webhookEnv` | env var holding a URL that gets each alert as a JSON POST |
295
+ | `alerts.renotifyMs` | how often a condition that lasts (an `exit_stuck` position, a fill that can't be read) is announced again; default 1 h |
296
+ | `alerts.holdAlertMs` | exits held for want of a sell route (migrating, pool at its floor, no price, unreadable) longer than this reach the alert sinks as `no_route`; default 5 min |
297
+
298
+ A filter that needs a field the bot doesn't have yet (a launch seen on chain before Candle's list has its mode or
299
+ name) waits for it and asks Candle's market endpoint. A field that never arrives never counts as a pass.
300
+
301
+ ## Exit strategies
302
+
303
+ | Strategy | Params | Does |
304
+ |---|---|---|
305
+ | `hold` | none | never sells by itself |
306
+ | `tpsl` | `takeProfit` (×), `stopLoss` (×), `trailing` (fraction off the peak) | sells all on any of them |
307
+ | `time` | `afterSec` | sells all N seconds after entry |
308
+ | `ladder` | `rungs: [{ at, sell }]`, `stopLoss` | sells `sell` of the original size at each multiple, keeps the rest as a core |
309
+ | `preGrad` | `progress` (0..1), `fraction`, `stopLoss` | sells before graduation at a curve-progress threshold |
310
+
311
+ Multiples are against your all-in entry price (SOL spent including Candle's fee, over tokens received). `preGrad`
312
+ exists because graduated Candle coins sell only through Jupiter, and `exp-custom` pools often sit at their price floor
313
+ with no sell route at all. Selling while the curve can still take it avoids that.
314
+
315
+ ### Writing one
316
+
317
+ A strategy is one file that exports `create(params)` and returns `{ name, onTick }`. Put it next to your config and
318
+ point `exit.module` at it (a path relative to the config file); the other keys of `exit` are its params. The engine
319
+ needs no change. A `.js` file works everywhere; a `.ts` file works too on Node 22.18+ (outside `node_modules`).
320
+
321
+ ```js
322
+ // my-exit.js "exit": { "strategy": "breakeven", "module": "./my-exit.js", "arm": 1.5 }
323
+ export function create(params) {
324
+ const arm = Number(params.arm ?? 1.5);
325
+ return {
326
+ name: "breakeven",
327
+ onTick(p, m) {
328
+ if (m.priceSol === null) return { action: "hold" };
329
+ const armed = p.peakPriceSol >= p.entryPriceSol * arm;
330
+ return armed && m.priceSol <= p.entryPriceSol ? { action: "sell", fraction: 1, reason: "back to entry" } : { action: "hold" };
331
+ },
332
+ };
333
+ }
334
+ ```
335
+
336
+ `examples/strategies/` has three to start from: `timeboxed-tp.js` (take profit inside a time window, else sell when it
337
+ closes), `breakeven-stop.js` (a stop that arms at a multiple and sits at entry) and `stale-coin.js` (sell a coin that
338
+ has not moved after a while). Types for TypeScript authors: `import type { ExitStrategy } from "candle-launch-bot"`.
339
+
340
+ `onTick(position, market)` runs once per cycle for each open position with no exit order in flight:
341
+
342
+ - `position`: `mint, openedAtMs, qtyRaw, remainingRaw, decimals, entryPriceSol, peakPriceSol, soldFraction`
343
+ - `market`: `priceSol` (null if unreadable this cycle), `progress` (graduation, 0..1), `migrated`, `venue`
344
+ (`curve` / `damm`), `nowMs`
345
+ - return `{ action: "hold" }` or `{ action: "sell", fraction, reason }`, where `fraction` is a share of what remains
346
+
347
+ ## How it handles your money
348
+
349
+ - **Quantity is what the chain says.** After a fill the bot reads the transaction and books the wallet's token and
350
+ SOL balance changes, with the network fee and token-account rent kept apart. If the transaction can't be read it
351
+ keeps reading; after several failed reads it asks Candle again (`confirm` replay) for `actualOutRaw`, else books the
352
+ build's `minOutRaw`, and raises
353
+ an alert. Every position records which source it used.
354
+ - **Sells are clamped** to the wallet's on-chain balance when the order is made, less what the bot's other positions
355
+ in the same coin own. The clamp only reduces, never sells more than the position holds (so tokens held by others in
356
+ the same wallet are left alone), and a zero or unreadable balance refuses the sell and raises an alert. When the
357
+ wallet holds less than the ledger, the position is written down to the balance (alert `shortfall`) so it can close.
358
+ - **One live transaction per order.** The signature and the transaction's blockhash are saved before submit. Before
359
+ any rebuild, the saved signature is checked on chain, and the rebuild waits until the chain says that blockhash can no
360
+ longer be used (`isBlockhashValid`, judged by block height, not by the clock). Rebuilds reuse the order's
361
+ `clientTradeId`. `IDEMPOTENCY_CONFLICT` is never treated as a failure: `prior_attempt_on_chain` is booked as the fill,
362
+ and anything else waits and reconciles.
363
+ - **Retries** have a cap, jittered exponential backoff on each order's own clock, and a terminal state. A throw costs
364
+ an attempt like any failure. Errors are read by `code`, `status`, `stage` and `reason`, never by message text.
365
+ Only Candle's own non-retryable 4xx refusal at build or sign (nothing broadcast yet) ends an order at once. Network
366
+ errors (`fetch failed` with ECONNRESET, ENOTFOUND, …), timeouts and 5xx are always retried. A refusal from Candle's
367
+ submit is treated as possibly broadcast, because it doesn't say whether the transaction went out: the bot waits and
368
+ reconciles on chain before it builds again. Every SDK and RPC call has a timeout, and settling a fill never sleeps
369
+ inside the loop.
370
+ - **Exits wait for a route.** While a coin is migrating to its pool (its curve full, or flagged migrated with the pool
371
+ not there yet; about 3.5 min), has no readable price, or its pool sits at its price floor, no sell is placed and no
372
+ exit attempt is used up.
373
+ - **A conflict is never a failure.** An order Candle answered with `IDEMPOTENCY_CONFLICT` is not rebuilt and can't go
374
+ terminal until Candle's trade job settles: `confirmed` is booked, gone or failed allows a same-id rebuild.
375
+ - **Stale buys stop.** A buy order carries a deadline (launch time + `maxAgeSec`) and is never built past it, nor
376
+ while `PAUSE_BUYS` is set. A dry run never touches live orders, and a state dir holds one mode only.
377
+ - **Alerts** carry the worsening dimension in their key (attempt buckets, terminal states, stuck exits), so a
378
+ condition that gets worse raises a new alert; a repeat of the same one is announced again every `alerts.renotifyMs`.
379
+
380
+ ## Know the terrain
381
+
382
+ - **Thin curves.** Candle curves start thin: a ticket of a few tenths of a SOL can move the price several percent.
383
+ `filters.maxImpactBps` is checked against the curve before every buy.
384
+ - **Graduated coins.** After graduation a coin trades in its DAMM v2 pool and sells route through Jupiter. Some
385
+ `exp-custom` pools sit at their price floor with no sell route; the bot holds exits until a route exists and alerts
386
+ when that lasts (`alerts.holdAlertMs`). `preGrad` sells while the curve can still take it.
387
+ - **Shared embedded wallet.** With `payer: "embedded"` the wallet belongs to the whole account (see "Pick a payer").
388
+ - **One entry per coin.** `buy.maxPerCoin` is 1; re-entering a coin is not supported.
389
+ - **The keychain signer follows the Candle CLI's storage layout** (see "TEE wallet and its signer").
390
+ - **Solana only.**
391
+
392
+ ## Development
393
+
394
+ `npm test` runs `scripts/check-neutral.sh` (no home paths, emails, keys) and the suite (`node --test`, no network). `npm run typecheck` checks types and
395
+ `npm run build` compiles `dist/` (run on `npm pack`). See CONTRIBUTING.md.
396
+
397
+ ## License
398
+
399
+ MIT. Fork it, tune it, run it.
400
+
401
+ Ladder out. Keep a core. Keep the receipts.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // The published package runs the compiled dist/; a source checkout runs src/ directly (Node strips types).
3
+ import { existsSync } from "node:fs";
4
+ const dist = new URL("../dist/cli.js", import.meta.url);
5
+ const entry = existsSync(dist) ? dist : new URL("../src/cli.ts", import.meta.url);
6
+ const { main } = await import(entry.href);
7
+ process.exit(await main(process.argv.slice(2)));
@@ -0,0 +1,28 @@
1
+ import type { Ledger } from "./ledger.ts";
2
+ import type { Logger } from "./log.ts";
3
+ export interface AlertConfig {
4
+ command?: string[];
5
+ /** Env var names passed to the command besides PATH, ALERT_KEY, ALERT_MESSAGE (nothing else is inherited). */
6
+ commandEnv?: string[];
7
+ webhookEnv?: string;
8
+ renotifyMs: number;
9
+ }
10
+ export type AlertSink = (a: {
11
+ key: string;
12
+ message: string;
13
+ at: string;
14
+ }) => Promise<void>;
15
+ export declare function commandSink(argv: string[], timeoutMs?: number, passEnv?: string[], env?: NodeJS.ProcessEnv): AlertSink;
16
+ export declare function webhookSink(url: string, f?: typeof fetch, timeoutMs?: number): AlertSink;
17
+ export declare class Alerter {
18
+ private ledger;
19
+ private log;
20
+ private sinks;
21
+ private now;
22
+ readonly renotifyMs: number;
23
+ sent: number;
24
+ constructor(ledger: Ledger, log: Logger, sinks: AlertSink[], renotifyMs: number, now?: () => number);
25
+ /** Record an alert; announce it (log + sinks) when it is new or its renotify time has come. */
26
+ raise(key: string, message: string, fields?: Record<string, unknown>): boolean;
27
+ }
28
+ export declare function makeAlerter(cfg: AlertConfig, ledger: Ledger, log: Logger, env?: NodeJS.ProcessEnv, now?: () => number): Alerter;
package/dist/alerts.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Alerts that leave the process. Every alert is deduped in the ledger under a key that carries the worsening
3
+ * dimension (attempt bucket, terminal state); a key is announced when first seen and again after `renotifyMs`
4
+ * while it keeps recurring. Conditions that need a human (exit_stuck, a fill that cannot be read) are re-raised by
5
+ * the engine every cycle, so they re-announce on that schedule until cleared.
6
+ *
7
+ * Sinks (both optional, both redacted, both bounded by a timeout, neither can stop the bot):
8
+ * - command: an argv list run without a shell; the alert is JSON on its stdin, ALERT_KEY / ALERT_MESSAGE in env
9
+ * - webhook: POST JSON {key, message, at} to the URL held in the env var named by webhookEnv
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { addSecret, redact } from "./redact.js";
13
+ export function commandSink(argv, timeoutMs = 10_000, passEnv = [], env = process.env) {
14
+ return (a) => new Promise((resolve) => {
15
+ try {
16
+ // A minimal environment: the API key, signer, RPC URL and webhook never reach the alert command.
17
+ const childEnv = { PATH: env.PATH ?? "/usr/bin:/bin", ALERT_KEY: a.key, ALERT_MESSAGE: a.message };
18
+ for (const n of passEnv)
19
+ if (env[n] !== undefined)
20
+ childEnv[n] = env[n];
21
+ const child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "ignore", "ignore"], env: childEnv, timeout: timeoutMs });
22
+ child.on("error", () => resolve());
23
+ child.on("exit", () => resolve());
24
+ child.stdin.on("error", () => undefined);
25
+ child.stdin.end(JSON.stringify(a) + "\n");
26
+ }
27
+ catch {
28
+ resolve();
29
+ }
30
+ });
31
+ }
32
+ export function webhookSink(url, f = fetch, timeoutMs = 10_000) {
33
+ addSecret(url);
34
+ return async (a) => {
35
+ try {
36
+ await f(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(a), signal: AbortSignal.timeout(timeoutMs) });
37
+ }
38
+ catch {
39
+ /* a dead webhook never stops the bot; the ledger and the log keep the alert */
40
+ }
41
+ };
42
+ }
43
+ export class Alerter {
44
+ ledger;
45
+ log;
46
+ sinks;
47
+ now;
48
+ renotifyMs;
49
+ sent = 0;
50
+ constructor(ledger, log, sinks, renotifyMs, now = Date.now) {
51
+ this.ledger = ledger;
52
+ this.log = log;
53
+ this.sinks = sinks;
54
+ this.renotifyMs = renotifyMs;
55
+ this.now = now;
56
+ }
57
+ /** Record an alert; announce it (log + sinks) when it is new or its renotify time has come. */
58
+ raise(key, message, fields = {}) {
59
+ const due = this.ledger.alert(key, message, this.now(), this.renotifyMs);
60
+ if (!due)
61
+ return false;
62
+ const msg = redact(message);
63
+ this.log.warn("alert", { key, message: msg, ...fields });
64
+ const a = { key: redact(key), message: msg, at: new Date(this.now()).toISOString() };
65
+ this.sent++;
66
+ for (const s of this.sinks)
67
+ void s(a).catch(() => undefined);
68
+ return true;
69
+ }
70
+ }
71
+ export function makeAlerter(cfg, ledger, log, env = process.env, now) {
72
+ const sinks = [];
73
+ if (cfg.command?.length)
74
+ sinks.push(commandSink(cfg.command, 10_000, cfg.commandEnv ?? [], env));
75
+ if (cfg.webhookEnv) {
76
+ const url = env[cfg.webhookEnv];
77
+ if (!url)
78
+ throw new Error(`alerts.webhookEnv: ${cfg.webhookEnv} is not set`);
79
+ sinks.push(webhookSink(url));
80
+ }
81
+ return new Alerter(ledger, log, sinks, cfg.renotifyMs, now);
82
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare const HELP = "candle-launch-bot: buys Candle launches as they appear and exits them by a strategy.\n\n candle-launch-bot init [--out bot.json] [--yes] [flags] write a config (dry mode unless --mode live)\n candle-launch-bot scan --config bot.json [--for 600] detect and print what it would buy; never trades\n candle-launch-bot run --config bot.json [--mode dry|live] run the bot\n candle-launch-bot status --config bot.json print status.json\n candle-launch-bot sell <mint> --config bot.json [--fraction 1]\n\ninit flags (all optional; with --yes nothing is asked; defaults shown):\n --mode dry|live (dry) --payer embedded|linked (embedded) --key-env <env var with the API key> (CANDLE_API_KEY)\n --wallet <address> (looked up when the key is set) --api-url <https url>\n --sol 0.01 --max-open 3 --lifetime-sol (10 tickets) --modes open,exp-custom --max-age 60 --max-impact 300\n --slippage 500\n --exit hold|tpsl|time|ladder|preGrad (tpsl) --take-profit 2 --stop-loss 0.5 --trailing <0-1> --after-sec 900\n --progress 0.8 --fraction 1 --rungs 2:0.3,5:0.3,10:0.2\n --exit-module ./my-exit.js --exit-params '{\"arm\":1.5}' your own strategy file and its params\n --signer keychain|file|env (keychain) --signer-env <env var with the PEM> --signer-path <file>\n --signer-pass-env <env var with the file's passphrase> payer linked only\n --rpc-env <env var with an RPC url> --rpc-ws-env <env var with a websocket url>\n --webhook <env var with an alert webhook url> --state-dir <dir> (./state-dry or ./state-live)\n --out bot.json --force (overwrite)\n\nControls: create STOP or PAUSE_BUYS in the state folder. Docs: the README.\n";
2
+ /** The entry point: never lets an error out unredacted (the SDK puts RPC URLs in its messages). */
3
+ export declare function main(argv?: string[]): Promise<number>;