candle-rug-watcher 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog: candle-rug-watcher
2
+
3
+ ## 0.1.0
4
+
5
+ First release, on candle-bot-core 0.5.0 (pinned exactly).
6
+
7
+ - Watches the coins a wallet holds (the core's adoption: a list, or the whole wallet less `exclude`, other bots' open
8
+ coins from `excludeStateDirs`, dust and junk mints) and sells, or only alerts, when a rule fires. It never buys.
9
+ - Rules, each optional with its own threshold, `sell` share and `alertOnly`: `liquidityPull` (a pool's SOL, DAMM v2 or
10
+ PumpSwap, falls X % in a window), `priceCrash` (price falls X % in a window), `devDump` (the creator sells X % of its
11
+ holding, or goes under a share of supply), `holderExit` (a top-N wallet sells X % of supply in a window).
12
+ - Each firing is recorded once (`rug_events` in the ledger), alerted with the numbers that tripped it, and its sell
13
+ goes through the engine's exit path. `PAUSE_SELLS` holds the sell, never the alert.
14
+ - The dev rule needs two agreeing reads, ignores answers at an older slot, and is off for a coin whose creator is the
15
+ bot's own wallet. An owed sell is checked against the current rules; `dismiss` settles an event by hand.
16
+ - Commands `run`, `status` (each coin: venue, pool SOL, creator, rules fired), `sell`, `adopt`, `release`, `dismiss`.
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,213 @@
1
- # Temporary Holding Version
1
+ # candle-rug-watcher
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
+ Watches the coins a Solana wallet holds and sells, or only alerts, when a coin starts to look like a rug: the pool's
4
+ SOL is pulled, the price crashes, the creator dumps, or a large holder leaves. It never buys. One config file, one
5
+ process, a local SQLite ledger. Built on Candle's official SDK,
6
+ [`@candledottv/agent-sdk`](https://www.npmjs.com/package/@candledottv/agent-sdk) (pinned at 0.6.14), and
7
+ [candle-bot-core](https://www.npmjs.com/package/candle-bot-core).
8
+
9
+ Built by [Bruce Jeet](https://github.com/BruceJeet).
10
+
11
+ - **Which coins:** a list you name, or the whole wallet, adopted the same way as candle-exit-manager: other bots' coins
12
+ (`excludeStateDirs`), dust, junk mints and anything it cannot price are left alone.
13
+ - **Venues:** Candle coins (launch curve, then the DAMM v2 pool), pump.fun bonding curves, PumpSwap. Read from chain
14
+ per coin, every exit tick.
15
+ - **Rules,** each optional, each with its own threshold, its own `sell` share (of what remains) and `alertOnly`:
16
+
17
+ | Rule | Fires when |
18
+ |---|---|
19
+ | `liquidityPull` | the SOL in the coin's pool (DAMM v2, PumpSwap) falls `dropPct` % from its high in the last `windowSec` |
20
+ | `priceCrash` | the price falls `dropPct` % from its high in the last `windowSec` |
21
+ | `devDump` | the creator's wallet holds `soldPct` % less than the most it held while watched, or falls under `floorPct` % of supply |
22
+ | `holderExit` | a wallet that was among the `topN` holders in the window now holds `soldPctOfSupply` % of supply less than its high in the window |
23
+
24
+ - **A balance drop is not always a sale.** The dev and holder rules see a wallet hold less. They cannot tell a sale
25
+ from tokens locked in a locker, burned, added to a pool as liquidity, or moved to the same person's other wallet.
26
+ All of these fire the rule. Use `alertOnly` on those rules if you would rather look before selling.
27
+ - **Every firing alerts** (the core's sinks: Telegram, webhook, a command) with the numbers that tripped it, whether
28
+ or not it sells.
29
+ - **Sells** go through the same order path as every candle-bot-core bot: fills booked from the transaction, clamped to
30
+ the wallet balance, retries capped with backoff, uncertain orders recovered from chain.
31
+ - **Dry mode** adopts into its own ledger, runs the rules, and logs every alert and sell it would make; it signs
32
+ nothing.
33
+
34
+ Solana only.
35
+
36
+ ## Quick start
37
+
38
+ Node 22.18 or newer.
39
+
40
+ ```sh
41
+ npm install -g candle-rug-watcher # or npx candle-rug-watcher ...
42
+ cp "$(npm root -g)/candle-rug-watcher/examples/dry.json" bot.json
43
+ # set "wallet" in bot.json to the wallet to watch
44
+ candle-rug-watcher run --config bot.json # dry run: watches and logs what it would do
45
+ candle-rug-watcher status --config bot.json # each coin: venue, pool SOL, creator, rules fired
46
+ ```
47
+
48
+ When the dry run looks right, use a new `stateDir` for live, set your API key, `"mode": "live"`, and run again:
49
+
50
+ ```sh
51
+ export CANDLE_API_KEY=cndl_live_...
52
+ candle-rug-watcher run --config bot.json
53
+ ```
54
+
55
+ **The dev and holder rules need an RPC that serves `getTokenLargestAccounts`.** Solana's public RPC refuses that call
56
+ (HTTP 429, "Too many requests for a specific RPC call", on every try as of November 2026); the watcher then reads the
57
+ holders as unknown, fires nothing on them, and alerts after three polls. The price and liquidity rules work on any RPC.
58
+
59
+ What it needs (API key with `swap:write`, a payer, an RPC) is the same as candle-exit-manager's; its README has the
60
+ details, and the section on sharing a wallet with other bots applies here word for word.
61
+
62
+ ## Once per event
63
+
64
+ - `priceCrash`, `liquidityPull`: after a firing the window starts again from the value then. Another firing needs a
65
+ new fall of `dropPct` from there. A move from the launch curve to a pool starts both windows again (that is not a
66
+ pull).
67
+ - `devDump`: each part (`soldPct`, `floorPct`) fires once per position. The creator's highest holding since the coin
68
+ was adopted is kept in the ledger, so a dump while the watcher was stopped fires on the first poll after. A coin
69
+ released and adopted again starts from the creator's holding then.
70
+ - `holderExit`: once per wallet per position. Before it fires, the wallet's whole balance (all its token accounts) is
71
+ read: the largest-accounts list can show only some of them.
72
+ - **The watcher's own sells** (a rule's, or the `exit` strategy's):
73
+ - While one is pending or in flight, both windows are frozen: no new sample, the highs kept. The freeze lasts at
74
+ most `watch.freezeMaxSec` (default 120 s); after that, sampling resumes. For one window length after a freeze,
75
+ the window still reaches back to what it held when the freeze began, so a pull inside a freeze longer than
76
+ `windowSec` still has its reference.
77
+ - Once it fills, the price window starts again, so the crash rule measures from the price after that fill.
78
+ - The pool window carries on. Each sample from before one of our fills is lowered by the SOL that fill took out
79
+ (its proceeds, after fees, so a little less than it took). A pull during or around our own sell still shows; our
80
+ sell alone does not. The credit leaves with those samples, so it never inflates a later pull.
81
+ - **`devDump` never fires on one read.** A second read, `rules.devDump.confirmAfterMs` later (default 5 s), must
82
+ agree. With `watch.confirmRpcUrlEnv` set, it is made on that second node. A node whose index misses the account
83
+ answers 0, and it has to be confirmed by a later read, ideally from another node.
84
+ - **Stale answers.** An answer more than `watch.slotTolerance` slots (default 5) older than the newest seen for the
85
+ coin is stale. It is read once more. If it is still stale it gives no verdict this poll, and it does not count as a
86
+ failed poll. Stale answers several polls in a row are backed off (2x up to 16x the interval) and alerted after three.
87
+ - **Our own wallets.** If the creator is this bot's wallet, or one listed in `rules.devDump.ourWallets` (another bot's,
88
+ a launch helper's), the dev rule is off for that coin and alerted once, because their own sells would read as the
89
+ dev selling. Those wallets never count as large holders either.
90
+ - **The creator is looked up again** once per run and every 6 hours when it came from chain. A different answer
91
+ replaces the stored creator only when a second re-check, at least a minute later, gives the same one. It is then
92
+ used, alerted, and its high starts again.
93
+
94
+ Firings are kept in the ledger (`rug_events`): a restart never fires the same event again. A selling firing stays owed
95
+ until a sell with its reason (`rug:<rule>#<event>`) has filled; a refused sell is retried on the engine's clock (and
96
+ goes `exit_stuck`, then the slow retry, like any exit). Two rules firing together each sell their share of what
97
+ remains.
98
+
99
+ An owed sell is checked against the rules as they are when it would sell: if the rule has since been turned off or
100
+ set to `alertOnly`, the event is dismissed and nothing is sold, and the share sold is the rule's current `sell`.
101
+ `candle-rug-watcher dismiss <event>` settles one event by hand (the coin stays watched). It is refused while that
102
+ event's sell is pending or in flight: a dismiss does not cancel a sell already sent. A dismissed `dev:sold`,
103
+ `dev:floor` or `holder:<wallet>` event cannot fire again for that position (each fires once per position). An owed share that rounds to
104
+ nothing of what remains is settled, so it never holds back the `exit` strategy.
105
+
106
+ ## Controls
107
+
108
+ Files in the state dir, read every cycle:
109
+
110
+ - `STOP`: finish the cycle, write status, exit 0.
111
+ - `PAUSE_SELLS`: rules keep running and alerting; their sells (and the `exit` strategy's) are held. Lift it and what is
112
+ owed sells on the next tick.
113
+ - `candle-rug-watcher sell <mint> [--fraction 0.5]`, `adopt <mint>`, `release <mint>`: as candle-exit-manager.
114
+ - `candle-rug-watcher dismiss <event>`: the firing owes no sell (event ids are in `status.json`, `watched[].fired`).
115
+
116
+ ## Who is the creator
117
+
118
+ - **pump.fun coins:** the bonding curve names its creator on chain.
119
+ - **Other coins:** the fee payer of the mint's oldest transaction, found by paging the mint's history (at most 10
120
+ pages of 1000). On Candle that is often a payer wallet, not the person who launched the coin. Name the creator by
121
+ hand where you know it: `rules.devDump.creators: { "<mint>": "<wallet>" }`.
122
+ - A coin with no creator found on chain is alerted once; the dev rule does not run for it, and the lookup is retried
123
+ hourly. A read that fails on the way (a rate limit) is not "no creator": nothing is kept, it counts as a failed
124
+ poll, and it is tried again at the next poll.
125
+ - The dev rule watches the creator's wallet. Tokens moved to another wallet first and sold from there look like a
126
+ dump of the creator's holding (it holds less), not like a sale of that other wallet.
127
+
128
+ ## Holders: how they are read
129
+
130
+ A poll, not a stream: every `watch.holdersPollMs` (default 15 s), at most `watch.maxCoinsPerPoll` coins a poll.
131
+
132
+ - One `getTokenLargestAccounts` (the 20 largest token accounts) and one `getTokenSupply` per coin per poll. One
133
+ `getMultipleAccounts` reads the owners of accounts not seen before (owners are cached).
134
+ - One `getTokenAccountsByOwner` reads the creator's holding (dev rule). Another reads each watched wallet that fell
135
+ out of the largest list (at most `topN` a poll).
136
+ - Accounts owned by programs (bonding curves, pools, vaults, lockers: addresses off the ed25519 curve) are not
137
+ holders. Your own wallet, the creator (when the dev rule is on), and `holderExit.exclude` are never counted as
138
+ large holders.
139
+ - Lag: the poll interval plus about a second (confirmed commitment). A wallet outside the 20 largest accounts is not
140
+ seen until it is in them.
141
+ - A failed read is unknown, never a sale. Any failed read in a coin's poll (the holder list, the creator, its
142
+ balance, a direct or a confirming read) makes the poll a failure: a failing coin is polled less often (2x, 4x, up
143
+ to 16x the interval), and three failed polls in a row are alerted.
144
+ - The poll budget (`watch.pollBudgetMs`, default 3 s) is checked between coins: once it has passed, the coins left
145
+ are due first next cycle. A coin's own reads are not cut short (each RPC call has its own 15 s timeout). Set
146
+ `watch.holdersRpcUrlEnv` to read holders through their own RPC, so a rate limit on these heavy calls never reaches
147
+ the balance read before a sell.
148
+ - A creator found on chain that holds none of the coin at the first read is alerted once (it may be the wrong
149
+ wallet).
150
+
151
+ ## Config reference
152
+
153
+ `examples/dry.json` is a working start. Besides the shared keys (see candle-launch-bot's README) and `adoption` (see
154
+ candle-exit-manager's), a `buy` section is refused.
155
+
156
+ ```jsonc
157
+ "rules": {
158
+ "liquidityPull": { "dropPct": 40, "windowSec": 120, "sell": 1 },
159
+ "priceCrash": { "dropPct": 50, "windowSec": 300, "sell": 0.5 },
160
+ "devDump": { "soldPct": 50, "floorPct": 1, "confirmAfterMs": 5000, "ourWallets": [], "creators": { "Mint…": "Wallet…" } },
161
+ "holderExit": { "topN": 10, "soldPctOfSupply": 3, "windowSec": 600, "exclude": [], "alertOnly": true }
162
+ },
163
+ "watch": { "holdersPollMs": 15000, "maxCoinsPerPoll": 10, "pollBudgetMs": 3000, "holdersRpcUrlEnv": "HOLDERS_RPC_URL", "confirmRpcUrlEnv": "CONFIRM_RPC_URL", "slotTolerance": 5, "freezeMaxSec": 120, "checkPrograms": true, "extraPrograms": [], "sellFloorMarginBps": 200 },
164
+ "exit": { "strategy": "hold" }
165
+ ```
166
+
167
+ | Key | Meaning |
168
+ |---|---|
169
+ | `rules.*.sell` | share of what remains to sell when the rule fires (0 to 1, default 1) |
170
+ | `rules.*.alertOnly` | alert, never sell (default false) |
171
+ | `liquidityPull`, `priceCrash` | `dropPct` (1 to 99), `windowSec` (5 to 86400) |
172
+ | `devDump` | `soldPct` and/or `floorPct` (% of supply); `creators` by hand; `confirmAfterMs` (0 to 120000, default 5000); `ourWallets` |
173
+ | `holderExit` | `topN` (1 to 20, default 10), `soldPctOfSupply`, `windowSec` (default 600), `exclude` |
174
+ | `watch.holdersPollMs` | how often each coin's holders are read (min 2000, default 15000) |
175
+ | `watch.maxCoinsPerPoll` | coins read in one poll (default 10); the rest wait for the next |
176
+ | `watch.pollBudgetMs` | longest one poll runs (default 3000) |
177
+ | `watch.holdersRpcUrlEnv` | an env var with an RPC URL for holder and creator reads (default: the bot's RPC) |
178
+ | `watch.confirmRpcUrlEnv` | an env var with a second RPC URL for confirming reads (dev and holder rules) |
179
+ | `watch.slotTolerance` | slots an answer may lag the newest seen before it is stale (default 5) |
180
+ | `watch.freezeMaxSec` | longest the windows stay frozen while one of our sells is open (default 120) |
181
+ | `watch.checkPrograms`, `extraPrograms`, `sellFloorMarginBps` | TEE wallet signing checks, as candle-exit-manager's `manager.*` |
182
+ | `exit` | the exit strategy for everything the rules do not sell (default `hold`; a `trail` works too) |
183
+
184
+ ## State dir
185
+
186
+ | File | What |
187
+ |---|---|
188
+ | `ledger.sqlite` | positions, orders, attempts, `adoptions`, `rug_events` (every firing: rule, numbers, sell share, when it was settled and why: sold, position closed, dismissed), `rug_creators` (each coin's creator and its source), `rug_dev` (the creator's highest holding, per position) |
189
+ | `status.json` | every cycle: `watched` (each coin's venue, pool SOL, creator, rules fired), `rules`, `adoption.skipped`, `sellsPaused` |
190
+ | `events.jsonl` | one JSON line per event: `rug_rule` (with its numbers), `rug_creator`, `rug_holders_unreadable`, adoption events, sells and fills |
191
+
192
+ ## Known limits
193
+
194
+ - **Price and pool SOL** are sampled on the engine's market reads (every `exitMs` while it watches, every `loopMs` at
195
+ least; one cycle later under `PAUSE_SELLS` or while a sell is open), held in memory. After a restart the price,
196
+ pool and holder windows all start empty. Fired events, creators and the creator's high are in the ledger.
197
+ - **Holders are polled** (above). A fast dump between two polls is seen at the next one. A wallet fires once per
198
+ position: its second dump is not a new event.
199
+ - **Gaps:** a crash that happens while the watcher is stopped, or while reads fail, for longer than `windowSec` is
200
+ never seen as a crash (the window starts again after the gap). The dev rule still sees a dump made while stopped.
201
+ - **Liquidity** is the SOL side of the pool. A bonding curve has no pool to pull; its falls are price crashes.
202
+ - **Only the canonical PumpSwap pool** is read, as candle-exit-manager.
203
+ - **Solana only.**
204
+
205
+ ## Development
206
+
207
+ From the repo root: `npm run typecheck`, `npm run build`. The rules are pure functions (`src/rules.ts`); the watcher
208
+ (`src/watch.ts`) keeps the samples, makes the holder reads and records each firing; `src/watcher.ts` wires the core's
209
+ adopter, engine and holder poller (`candle-bot-core/sources/holders`).
210
+
211
+ ## License
212
+
213
+ MIT
@@ -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)));
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare const HELP = "candle-rug-watcher: watches the coins a wallet holds (a list, or the whole wallet) and sells, or only alerts, when\na rule fires: the pool's SOL pulled, a price crash, the creator dumping, a large holder leaving. It never buys.\n\n candle-rug-watcher run --config bot.json [--mode dry|live] run the watcher\n candle-rug-watcher status --config bot.json print status.json (each coin: venue, creator, rules fired)\n candle-rug-watcher sell <mint> --config bot.json [--fraction 1]\n candle-rug-watcher adopt <mint> --config bot.json [--signatures s1,s2 | --cost-sol 0.2 | --basis mark]\n candle-rug-watcher release <mint> --config bot.json stop watching a coin; nothing is sold\n candle-rug-watcher dismiss <event> --config bot.json a firing owes no sell (the coin stays watched)\n\nStart from examples/dry.json. Controls in the state folder: STOP (stop), PAUSE_SELLS (hold every sell, keep watching and alerting).\nDocs: 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>;
package/dist/cli.js ADDED
@@ -0,0 +1,169 @@
1
+ /** The command line: run, status, sell, adopt, release (the exit manager's commands). */
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { Ledger, acquireLock, asAddress } from "candle-bot-core";
5
+ import { args, context as coreContext, die, guardMain, run, sell, status } from "candle-bot-core/runtime";
6
+ import { loadConfig, ConfigError } from "./config.js";
7
+ import { checkExcludeDirs, ledgerPath, makeWatcher } from "./watcher.js";
8
+ export const HELP = `candle-rug-watcher: watches the coins a wallet holds (a list, or the whole wallet) and sells, or only alerts, when
9
+ a rule fires: the pool's SOL pulled, a price crash, the creator dumping, a large holder leaving. It never buys.
10
+
11
+ candle-rug-watcher run --config bot.json [--mode dry|live] run the watcher
12
+ candle-rug-watcher status --config bot.json print status.json (each coin: venue, creator, rules fired)
13
+ candle-rug-watcher sell <mint> --config bot.json [--fraction 1]
14
+ candle-rug-watcher adopt <mint> --config bot.json [--signatures s1,s2 | --cost-sol 0.2 | --basis mark]
15
+ candle-rug-watcher release <mint> --config bot.json stop watching a coin; nothing is sold
16
+ candle-rug-watcher dismiss <event> --config bot.json a firing owes no sell (the coin stays watched)
17
+
18
+ Start from examples/dry.json. Controls in the state folder: STOP (stop), PAUSE_SELLS (hold every sell, keep watching and alerting).
19
+ Docs: the README.
20
+ `;
21
+ function context(configPath, modeOverride) {
22
+ let cfg;
23
+ try {
24
+ cfg = loadConfig(configPath);
25
+ }
26
+ catch (e) {
27
+ if (e instanceof ConfigError)
28
+ die(e.message);
29
+ throw e;
30
+ }
31
+ return coreContext(cfg, modeOverride);
32
+ }
33
+ const openLedger = (ctx) => () => new Ledger(ledgerPath(ctx.cfg));
34
+ /** adopt / release: a request file while the watcher runs; done here (under the lock) while it is stopped. */
35
+ async function request(ctx, kind, mint, opt) {
36
+ if (asAddress(mint) !== mint)
37
+ die(`${mint} is not a mint address`);
38
+ const body = { mint };
39
+ if (kind === "adopt") {
40
+ if (opt.signatures)
41
+ body.signatures = opt.signatures.split(",").map((s) => s.trim()).filter(Boolean);
42
+ if (opt["cost-sol"] !== undefined) {
43
+ const c = Number(opt["cost-sol"]);
44
+ if (!(c >= 0))
45
+ die("--cost-sol: a number >= 0");
46
+ body.costSol = c;
47
+ }
48
+ if (opt.basis !== undefined) {
49
+ if (opt.basis !== "mark")
50
+ die("--basis: mark");
51
+ body.basis = "mark";
52
+ }
53
+ if (Object.keys(body).length > 2)
54
+ die("give one of --signatures, --cost-sol, --basis");
55
+ }
56
+ const lock = acquireLock(ctx.cfg.stateDir);
57
+ if (!lock) {
58
+ const dir = join(ctx.cfg.stateDir, "requests");
59
+ mkdirSync(dir, { recursive: true });
60
+ const f = join(dir, `${kind}-${Date.now()}-${process.pid}.json`);
61
+ writeFileSync(f + ".tmp", JSON.stringify(body));
62
+ renameSync(f + ".tmp", f);
63
+ process.stdout.write(`the watcher is running: request written to ${f}; watch events.jsonl for ${kind}_request\n`);
64
+ return 0;
65
+ }
66
+ const ledger = openLedger(ctx)();
67
+ try {
68
+ const { adopter } = await makeWatcher(ctx, ledger);
69
+ if (kind === "release") {
70
+ const a = adopter.managed().find((r) => r.mint === mint);
71
+ const r = a ? adopter.release(a.position_id, "release command") : "no adopted position for that mint";
72
+ process.stdout.write(r + "\n");
73
+ return a && r.startsWith("released") ? 0 : 1;
74
+ }
75
+ // Stopped: the same path as the request, run once now.
76
+ const dir = join(ctx.cfg.stateDir, "requests");
77
+ mkdirSync(dir, { recursive: true });
78
+ const f = join(dir, `adopt-${Date.now()}-${process.pid}.json`);
79
+ writeFileSync(f, JSON.stringify(body));
80
+ await adopter.step(() => false, true);
81
+ const p = adopter.managed().find((r) => r.mint === mint);
82
+ const skip = adopter.skips.find((s) => s.mint === mint);
83
+ process.stdout.write(p ? `adopted: position ${p.position_id} (basis ${p.basis_source})\n` : `not adopted${skip ? `: ${skip.why}` : ""}\n`);
84
+ return p ? 0 : 1;
85
+ }
86
+ finally {
87
+ ledger.close();
88
+ lock.release();
89
+ }
90
+ }
91
+ /** dismiss: a request file while the watcher runs; done here (under the lock) while it is stopped. */
92
+ async function dismissCommand(ctx, event) {
93
+ const id = Number(event);
94
+ if (!Number.isSafeInteger(id) || id <= 0)
95
+ die(`${event} is not an event id (status.json lists them)`);
96
+ const lock = acquireLock(ctx.cfg.stateDir);
97
+ if (!lock) {
98
+ const dir = join(ctx.cfg.stateDir, "requests");
99
+ mkdirSync(dir, { recursive: true });
100
+ const f = join(dir, `dismiss-${Date.now()}-${process.pid}.json`);
101
+ writeFileSync(f + ".tmp", JSON.stringify({ event: id }));
102
+ renameSync(f + ".tmp", f);
103
+ process.stdout.write(`the watcher is running: request written to ${f}; watch events.jsonl for dismiss_request\n`);
104
+ return 0;
105
+ }
106
+ const ledger = openLedger(ctx)();
107
+ try {
108
+ const { watch } = await makeWatcher(ctx, ledger);
109
+ const r = watch.dismiss(id);
110
+ process.stdout.write(r + "\n");
111
+ return r.startsWith("dismissed") ? 0 : 1;
112
+ }
113
+ finally {
114
+ ledger.close();
115
+ lock.release();
116
+ }
117
+ }
118
+ /** The entry point: never lets an error out unredacted (the SDK puts RPC URLs in its messages). */
119
+ export async function main(argv = process.argv.slice(2)) {
120
+ return guardMain(async () => {
121
+ const { pos, opt } = args(argv);
122
+ const cmd = pos[0];
123
+ if (!cmd || cmd === "help" || opt.help) {
124
+ process.stdout.write(HELP);
125
+ return cmd || opt.help ? 0 : 2;
126
+ }
127
+ if (cmd === "version" || opt.version) {
128
+ process.stdout.write(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version + "\n");
129
+ return 0;
130
+ }
131
+ const config = opt.config ?? "bot.json";
132
+ switch (cmd) {
133
+ case "run": {
134
+ const ctx = context(config, opt.mode);
135
+ try {
136
+ checkExcludeDirs(ctx.cfg);
137
+ }
138
+ catch (e) {
139
+ die(e.message);
140
+ }
141
+ return run(ctx, openLedger(ctx), async (ledger) => {
142
+ const b = await makeWatcher(ctx, ledger);
143
+ return { engine: b.engine, trigger: b.trigger };
144
+ }, { adoption: ctx.cfg.adoption.mode, excludeStateDirs: ctx.cfg.adoption.excludeStateDirs.length, rules: Object.keys(ctx.cfg.rules) });
145
+ }
146
+ case "status":
147
+ return status(context(config));
148
+ case "sell": {
149
+ if (!pos[1])
150
+ die("sell <mint> [--fraction f]");
151
+ const ctx = context(config);
152
+ return sell(ctx, pos[1], opt.fraction ? Number(opt.fraction) : 1, openLedger(ctx), async (ledger) => ({ engine: (await makeWatcher(ctx, ledger)).engine }));
153
+ }
154
+ case "dismiss": {
155
+ if (!pos[1])
156
+ die("dismiss <event>");
157
+ return dismissCommand(context(config), pos[1]);
158
+ }
159
+ case "adopt":
160
+ case "release": {
161
+ if (!pos[1])
162
+ die(`${cmd} <mint>`);
163
+ return request(context(config), cmd, pos[1], opt);
164
+ }
165
+ default:
166
+ die(`unknown command ${cmd}`);
167
+ }
168
+ });
169
+ }
@@ -0,0 +1,76 @@
1
+ import { type Config as CoreConfig, type SectionParser } from "candle-bot-core";
2
+ import { type AdoptionConfig } from "candle-bot-core/adopt/adopter";
3
+ export { ConfigError } from "candle-bot-core/config";
4
+ /** What a rule does when it fires: sell this share of what remains, or only alert. */
5
+ export interface Action {
6
+ sell: number;
7
+ alertOnly: boolean;
8
+ }
9
+ export type WindowRule = Action & {
10
+ dropPct: number;
11
+ windowSec: number;
12
+ };
13
+ /** creators: a coin's creator wallet by hand, for coins whose launch transaction does not name it (see the README). */
14
+ /**
15
+ * confirmAfterMs: a trip fires only if a second read this long after the first agrees (default 5000; 0: at once).
16
+ * ourWallets: wallets of our own besides `wallet` (another bot's, a launch helper's): the rule is off for a coin one
17
+ * of them created, and they never count as large holders.
18
+ */
19
+ export type DevRule = Action & {
20
+ soldPct?: number;
21
+ floorPct?: number;
22
+ creators: Record<string, string>;
23
+ confirmAfterMs: number;
24
+ ourWallets: string[];
25
+ };
26
+ /** exclude: wallets never counted as large holders (an exchange, a locker that is a plain wallet). */
27
+ export type HolderRule = Action & {
28
+ topN: number;
29
+ soldPctOfSupply: number;
30
+ windowSec: number;
31
+ exclude: string[];
32
+ };
33
+ export interface Rules {
34
+ liquidityPull?: WindowRule;
35
+ priceCrash?: WindowRule;
36
+ devDump?: DevRule;
37
+ holderExit?: HolderRule;
38
+ }
39
+ export interface Watch {
40
+ /** How often each watched coin's holders (and its creator's balance) are read. */
41
+ holdersPollMs: number;
42
+ /** Coins whose holders are read in one poll at most (the rest in the next). */
43
+ maxCoinsPerPoll: number;
44
+ /** Longest one holder poll runs before the coins left wait for the next cycle (ms). */
45
+ pollBudgetMs: number;
46
+ /** An env var with a separate RPC URL for holder reads (they are heavy; keeps them off the trading RPC). */
47
+ holdersRpcUrlEnv?: string;
48
+ /** An env var with an RPC URL for confirming reads (a second node: one node's lagging index cannot fire alone). */
49
+ confirmRpcUrlEnv?: string;
50
+ /** Slots an answer may lag the newest seen for the coin before it is stale (pooled providers differ by a few). */
51
+ slotTolerance: number;
52
+ /** Longest the price and pool windows stay frozen while one of our sells is open (s); then sampling resumes. */
53
+ freezeMaxSec: number;
54
+ checkPrograms: boolean;
55
+ extraPrograms: string[];
56
+ sellFloorMarginBps: number;
57
+ }
58
+ export type Config = CoreConfig & {
59
+ adoption: AdoptionConfig;
60
+ rules: Rules;
61
+ watch: Watch;
62
+ };
63
+ export declare const NO_BUYS: Readonly<{
64
+ sol: 0.000001;
65
+ maxSlippageBps: 0;
66
+ maxOpen: 0;
67
+ lifetimeSol: 0;
68
+ maxPerCoin: 1;
69
+ }>;
70
+ export declare const SECTIONS: {
71
+ adoption: SectionParser<AdoptionConfig>;
72
+ rules: SectionParser<Rules>;
73
+ watch: SectionParser<Watch>;
74
+ };
75
+ export declare function parseConfig(raw: unknown, baseDir?: string): Config;
76
+ export declare function loadConfig(path: string): Config;
package/dist/config.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The one config file: the core's shared sections (candle-bot-core) plus `adoption` (which coins to watch: the core's
3
+ * adoption, as the exit manager), `rules` (what counts as a rug, what to do) and `watch` (holder polling, signing
4
+ * checks). This bot never buys: a `buy` section is refused. Unknown keys are refused at every level.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, resolve } from "node:path";
8
+ import { parseConfig as parseCore, ConfigError, isObj, only, bool, num, strList, asAddress, ENV_NAME } from "candle-bot-core";
9
+ import { parseAdoption } from "candle-bot-core/adopt/adopter";
10
+ export { ConfigError } from "candle-bot-core/config";
11
+ export const NO_BUYS = Object.freeze({ sol: 0.000001, maxSlippageBps: 0, maxOpen: 0, lifetimeSol: 0, maxPerCoin: 1 });
12
+ function action(o, w) {
13
+ return { sell: num(o, "sell", w, { min: 0, max: 1, def: 1 }), alertOnly: bool(o, "alertOnly", w, false) };
14
+ }
15
+ function checkAction(a, w) {
16
+ if (!a.alertOnly && !(a.sell > 0))
17
+ throw new ConfigError(`${w}: sell must be > 0 unless alertOnly is true`);
18
+ }
19
+ const parseRules = (raw) => {
20
+ const w = "config.rules";
21
+ if (!isObj(raw))
22
+ throw new ConfigError(`${w}: required object with at least one of liquidityPull, priceCrash, devDump, holderExit`);
23
+ only(raw, ["liquidityPull", "priceCrash", "devDump", "holderExit"], w);
24
+ const out = {};
25
+ for (const k of ["liquidityPull", "priceCrash"]) {
26
+ const r = raw[k];
27
+ if (r === undefined)
28
+ continue;
29
+ if (!isObj(r))
30
+ throw new ConfigError(`${w}.${k}: an object`);
31
+ only(r, ["dropPct", "windowSec", "sell", "alertOnly"], `${w}.${k}`);
32
+ out[k] = { dropPct: num(r, "dropPct", `${w}.${k}`, { min: 1, max: 99 }), windowSec: num(r, "windowSec", `${w}.${k}`, { min: 5, max: 86_400, int: true }), ...action(r, `${w}.${k}`) };
33
+ checkAction(out[k], `${w}.${k}`);
34
+ }
35
+ if (raw.devDump !== undefined) {
36
+ const r = raw.devDump;
37
+ if (!isObj(r))
38
+ throw new ConfigError(`${w}.devDump: an object`);
39
+ only(r, ["soldPct", "floorPct", "creators", "confirmAfterMs", "ourWallets", "sell", "alertOnly"], `${w}.devDump`);
40
+ const creators = {};
41
+ const cr = r.creators === undefined ? {} : r.creators;
42
+ if (!isObj(cr))
43
+ throw new ConfigError(`${w}.devDump.creators: an object { "<mint>": "<creator wallet>" }`);
44
+ for (const [mint, who] of Object.entries(cr)) {
45
+ if (asAddress(mint) !== mint)
46
+ throw new ConfigError(`${w}.devDump.creators: "${mint}" is not a mint address`);
47
+ if (typeof who !== "string" || asAddress(who) !== who)
48
+ throw new ConfigError(`${w}.devDump.creators.${mint}: a wallet address`);
49
+ creators[mint] = who;
50
+ }
51
+ const soldPct = r.soldPct === undefined ? undefined : num(r, "soldPct", `${w}.devDump`, { min: 1, max: 100 });
52
+ const floorPct = r.floorPct === undefined ? undefined : num(r, "floorPct", `${w}.devDump`, { min: 0.0001, max: 100 });
53
+ if (soldPct === undefined && floorPct === undefined)
54
+ throw new ConfigError(`${w}.devDump: set soldPct, floorPct or both`);
55
+ out.devDump = {
56
+ ...(soldPct !== undefined ? { soldPct } : {}), ...(floorPct !== undefined ? { floorPct } : {}), creators,
57
+ confirmAfterMs: num(r, "confirmAfterMs", `${w}.devDump`, { min: 0, max: 120_000, int: true, def: 5_000 }),
58
+ ourWallets: strList(r, "ourWallets", `${w}.devDump`, { def: [], address: true }),
59
+ ...action(r, `${w}.devDump`),
60
+ };
61
+ checkAction(out.devDump, `${w}.devDump`);
62
+ }
63
+ if (raw.holderExit !== undefined) {
64
+ const r = raw.holderExit;
65
+ if (!isObj(r))
66
+ throw new ConfigError(`${w}.holderExit: an object`);
67
+ only(r, ["topN", "soldPctOfSupply", "windowSec", "exclude", "sell", "alertOnly"], `${w}.holderExit`);
68
+ out.holderExit = {
69
+ topN: num(r, "topN", `${w}.holderExit`, { min: 1, max: 20, int: true, def: 10 }),
70
+ soldPctOfSupply: num(r, "soldPctOfSupply", `${w}.holderExit`, { min: 0.01, max: 100 }),
71
+ windowSec: num(r, "windowSec", `${w}.holderExit`, { min: 10, max: 86_400, int: true, def: 600 }),
72
+ exclude: strList(r, "exclude", `${w}.holderExit`, { def: [], address: true }),
73
+ ...action(r, `${w}.holderExit`),
74
+ };
75
+ checkAction(out.holderExit, `${w}.holderExit`);
76
+ }
77
+ if (!Object.keys(out).length)
78
+ throw new ConfigError(`${w}: at least one rule`);
79
+ return out;
80
+ };
81
+ const parseWatch = (raw) => {
82
+ const r = raw === undefined ? {} : raw;
83
+ if (!isObj(r))
84
+ throw new ConfigError("config.watch: must be an object");
85
+ only(r, ["holdersPollMs", "maxCoinsPerPoll", "pollBudgetMs", "holdersRpcUrlEnv", "confirmRpcUrlEnv", "slotTolerance", "freezeMaxSec", "checkPrograms", "extraPrograms", "sellFloorMarginBps"], "config.watch");
86
+ const env = r.holdersRpcUrlEnv;
87
+ if (env !== undefined && (typeof env !== "string" || !ENV_NAME.test(env)))
88
+ throw new ConfigError("config.watch.holdersRpcUrlEnv: an environment variable name");
89
+ const cenv = r.confirmRpcUrlEnv;
90
+ if (cenv !== undefined && (typeof cenv !== "string" || !ENV_NAME.test(cenv)))
91
+ throw new ConfigError("config.watch.confirmRpcUrlEnv: an environment variable name");
92
+ return {
93
+ holdersPollMs: num(r, "holdersPollMs", "config.watch", { min: 2_000, int: true, def: 15_000 }),
94
+ maxCoinsPerPoll: num(r, "maxCoinsPerPoll", "config.watch", { min: 1, max: 100, int: true, def: 10 }),
95
+ pollBudgetMs: num(r, "pollBudgetMs", "config.watch", { min: 200, max: 60_000, int: true, def: 3_000 }),
96
+ ...(env !== undefined ? { holdersRpcUrlEnv: env } : {}),
97
+ ...(cenv !== undefined ? { confirmRpcUrlEnv: cenv } : {}),
98
+ slotTolerance: num(r, "slotTolerance", "config.watch", { min: 0, max: 10_000, int: true, def: 5 }),
99
+ freezeMaxSec: num(r, "freezeMaxSec", "config.watch", { min: 5, max: 3_600, int: true, def: 120 }),
100
+ checkPrograms: bool(r, "checkPrograms", "config.watch", true),
101
+ extraPrograms: strList(r, "extraPrograms", "config.watch", { def: [], address: true }),
102
+ sellFloorMarginBps: num(r, "sellFloorMarginBps", "config.watch", { min: 0, max: 10_000, int: true, def: 200 }),
103
+ };
104
+ };
105
+ const adoptionSection = (raw, core) => parseAdoption(raw, core.baseDir);
106
+ export const SECTIONS = { adoption: adoptionSection, rules: parseRules, watch: parseWatch };
107
+ export function parseConfig(raw, baseDir = process.cwd()) {
108
+ if (!isObj(raw))
109
+ throw new ConfigError("config: must be a JSON object");
110
+ if (raw.buy !== undefined)
111
+ throw new ConfigError("config.buy: this bot never buys; remove the buy section");
112
+ // `exit` is optional here (the rules are the point): default hold.
113
+ const cfg = parseCore({ ...raw, buy: NO_BUYS, exit: raw.exit ?? { strategy: "hold" } }, baseDir, SECTIONS);
114
+ for (const d of cfg.adoption.excludeStateDirs)
115
+ if (resolve(d) === resolve(cfg.stateDir))
116
+ throw new ConfigError(`config.adoption.excludeStateDirs: "${d}" is this bot's own stateDir`);
117
+ return cfg;
118
+ }
119
+ export function loadConfig(path) {
120
+ let text;
121
+ try {
122
+ text = readFileSync(path, "utf8");
123
+ }
124
+ catch (e) {
125
+ throw new ConfigError(`cannot read config ${path}: ${e.message}`);
126
+ }
127
+ let raw;
128
+ try {
129
+ raw = JSON.parse(text);
130
+ }
131
+ catch (e) {
132
+ throw new ConfigError(`config ${path} is not valid JSON: ${e.message}`);
133
+ }
134
+ return parseConfig(raw, dirname(resolve(path)));
135
+ }