candle-creator-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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/bin/candle-creator-bot +7 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +70 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.js +87 -0
- package/dist/filters.d.ts +25 -0
- package/dist/filters.js +53 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/ledger.d.ts +34 -0
- package/dist/ledger.js +47 -0
- package/dist/trigger.d.ts +87 -0
- package/dist/trigger.js +243 -0
- package/examples/dry.json +17 -0
- package/package.json +60 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog: candle-creator-bot
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release, on candle-bot-core 0.4.0 (pinned exactly).
|
|
6
|
+
|
|
7
|
+
- Follows a list of creators (Candle token-list creator or launch signer) through the core's launch source. Each
|
|
8
|
+
creator has an optional label, ticket (at most `buy.sol`), and lifetime caps on entries and SOL, counted from the
|
|
9
|
+
ledger's positions for that creator with the core's rules for what spent. The core's caps on top.
|
|
10
|
+
- Global filters as candle-launch-bot's: launch modes, max age, impact on the curve; a chain-first launch waits for
|
|
11
|
+
Candle's market record; creator unknown waits. Optional: wait for the creator's own dev buy.
|
|
12
|
+
- `run`, `status` (per-creator use), `sell`; `STOP`, `PAUSE_BUYS`; a `follows` table in the ledger.
|
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,115 @@
|
|
|
1
|
-
#
|
|
1
|
+
# candle-creator-bot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Buys the new [Candle](https://candle.tv) launches of the creators you list, with a ticket and caps per creator, and
|
|
4
|
+
exits them by a strategy you pick. One config file, one process, a local SQLite ledger. Built on Candle's official SDK,
|
|
5
|
+
[`@candledottv/agent-sdk`](https://www.npmjs.com/package/@candledottv/agent-sdk) (pinned at 0.6.14), and
|
|
6
|
+
[candle-bot-core](https://www.npmjs.com/package/candle-bot-core), the engine
|
|
7
|
+
[candle-launch-bot](https://www.npmjs.com/package/candle-launch-bot) runs on.
|
|
8
|
+
|
|
9
|
+
Built by [Bruce Jeet](https://github.com/BruceJeet).
|
|
10
|
+
|
|
11
|
+
**How it differs from candle-launch-bot with `creatorAllow`:** the list of creators is the main input, not a filter
|
|
12
|
+
on top of every launch: each creator has its own label, ticket and lifetime caps (entries and SOL), and `status.json`
|
|
13
|
+
shows what each creator has used.
|
|
14
|
+
|
|
15
|
+
- **What it buys:** a new Candle launch whose creator (from Candle's token list) or signer (the launch transaction's
|
|
16
|
+
fee payer) is on your list. A launch whose creator is not known yet waits, until Candle's list names it or the launch
|
|
17
|
+
ages out. Never a launch by your own wallet.
|
|
18
|
+
- **Per creator:** `sol` (the ticket, at most `buy.sol`), `maxEntries` and `maxSol` over the bot's lifetime, on top of
|
|
19
|
+
the core's caps (`buy.maxOpen`, `buy.lifetimeSol`, `buy.maxPerCoin`). A creator's caps can only tighten them.
|
|
20
|
+
- **Global filters**, as candle-launch-bot's: launch modes, max age, max impact of the ticket on the curve. Optional:
|
|
21
|
+
wait for the creator's own dev buy.
|
|
22
|
+
- **Detection, execution, exits and dry mode** as candle-launch-bot: Candle's token list and the curve program on
|
|
23
|
+
chain; Candle's trade API from the embedded wallet or a TEE wallet; fills from the transaction; `hold`, `tpsl`,
|
|
24
|
+
`time`, `ladder`, `preGrad` or your own file.
|
|
25
|
+
|
|
26
|
+
Solana only.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
Node 22.18 or newer.
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm install -g candle-creator-bot # or npx candle-creator-bot ...
|
|
34
|
+
cp "$(npm root -g)/candle-creator-bot/examples/dry.json" bot.json # put your creators in follow.creators
|
|
35
|
+
candle-creator-bot run --config bot.json # dry run: the whole loop with paper fills
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For live: export the API key (`apiKeyEnv`), fund the wallet, set `"mode": "live"` and your `wallet`, run again.
|
|
39
|
+
|
|
40
|
+
## What it needs
|
|
41
|
+
|
|
42
|
+
- A **Candle API key** with the `swap:write` scope, in an environment variable (`apiKeyEnv`).
|
|
43
|
+
- A **payer**: `embedded` (the account's embedded wallet; Candle signs) or `linked` (a TEE wallet signed on this
|
|
44
|
+
machine). Setup is the same as candle-launch-bot's; its README walks through it.
|
|
45
|
+
- An **RPC** (`rpcUrlEnv`); the public one works for a dry run.
|
|
46
|
+
- The **creators**: Candle wallet addresses. A launch matches when Candle's token list names the address as creator,
|
|
47
|
+
or when the address signed (paid for) the launch transaction.
|
|
48
|
+
|
|
49
|
+
## Config reference
|
|
50
|
+
|
|
51
|
+
The core's sections (`mode`, `payer`, `apiKeyEnv`, `rpcUrlEnv`, `wallet`, `signer`, `stateDir`, `buy`, `sell`, `exit`,
|
|
52
|
+
`retry`, `alerts`) are the same as candle-launch-bot's. This bot adds:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"follow": {
|
|
57
|
+
"creators": [
|
|
58
|
+
{ "address": "<creator address>", "label": "dev one", "sol": 0.01, "maxEntries": 5, "maxSol": 0.05 },
|
|
59
|
+
{ "address": "<another>" }
|
|
60
|
+
],
|
|
61
|
+
"launchModes": ["open", "exclusive", "exp-a", "exp-b", "exp-c", "exp-custom"],
|
|
62
|
+
"maxAgeSec": 60,
|
|
63
|
+
"maxImpactBps": 300,
|
|
64
|
+
"inferModeFromCurve": true,
|
|
65
|
+
"requireDevBuy": false
|
|
66
|
+
},
|
|
67
|
+
"detect": { "api": true, "chain": true, "apiPollMs": 3000, "chainPollMs": 15000, "apiLimit": 20 }
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Key | Meaning |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `follow.creators[].address` | the creator (token list) or launch signer to follow; each listed once |
|
|
74
|
+
| `follow.creators[].label` | a name for logs, the `follows` table and `status.json` (optional) |
|
|
75
|
+
| `follow.creators[].sol` | this creator's ticket (default `buy.sol`, never above it) |
|
|
76
|
+
| `follow.creators[].maxEntries` / `maxSol` | lifetime caps for this creator (optional). Counted from the ledger's positions for the creator, as the core counts `lifetimeSol`: a buy Candle refused before anything was signed, or one that reverted, does not count |
|
|
77
|
+
| `follow.launchModes` | modes to buy (default every production mode) |
|
|
78
|
+
| `follow.maxAgeSec` | buy only this soon after the launch (default 60); no order is built after it |
|
|
79
|
+
| `follow.maxImpactBps` | impact of the creator's ticket on the curve (default 300); also the buy-retry limit |
|
|
80
|
+
| `follow.inferModeFromCurve` | decide a chain-first launch's mode from its curve (only the full `open` preset) instead of waiting for Candle's list |
|
|
81
|
+
| `follow.requireDevBuy` | buy only once the listed creator's own wallet holds the coin |
|
|
82
|
+
| `detect.*` | the launch source, as candle-launch-bot's |
|
|
83
|
+
|
|
84
|
+
## Controls
|
|
85
|
+
|
|
86
|
+
`STOP` and `PAUSE_BUYS` files in the state dir; `candle-creator-bot sell <mint> [--fraction f]`. A restart picks up from
|
|
87
|
+
the ledger, the creators' caps included.
|
|
88
|
+
|
|
89
|
+
## State dir
|
|
90
|
+
|
|
91
|
+
| File | What |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `ledger.sqlite` | positions (each with the creator it followed in its entry context), orders, attempts, `follows` (every launch of a listed creator with the decision), alerts |
|
|
94
|
+
| `status.json` | equity, open positions, caps used, `follow.creators` (entries and SOL used per creator), pending launches, recent decisions |
|
|
95
|
+
| `events.jsonl` | one JSON line per event: `follow_buy`, `follow_skip`, `follow_wait`, `buy_order`, fills, exits |
|
|
96
|
+
|
|
97
|
+
## Not here yet
|
|
98
|
+
|
|
99
|
+
- **Selling when the creator sells.** Exits are the strategy's. A creator-sell exit needs a wallet watcher (the
|
|
100
|
+
creator's token account changes, from the chain); it would plug in as an exit source that marks the position for a
|
|
101
|
+
sell, the way a strategy's `sell` decision does.
|
|
102
|
+
|
|
103
|
+
## Known limits
|
|
104
|
+
|
|
105
|
+
- **Creator unknown waits.** A launch seen on chain first carries only its signer; if the signer is not on the list,
|
|
106
|
+
the bot waits for Candle's token list to name the creator, up to `follow.maxAgeSec`.
|
|
107
|
+
- **Dev buy check.** `requireDevBuy` reads the listed creator's token balance in the coin, at most every 2 s. It
|
|
108
|
+
measures a holding, not a buy: it sees a dev buy in the launch transaction and one in the same block after it, and it
|
|
109
|
+
cannot tell a dev buy from a later buy, or from tokens handed to the creator's wallet at launch.
|
|
110
|
+
- **One entry per coin.** `buy.maxPerCoin` is 1.
|
|
111
|
+
- **Solana only.**
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
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-creator-bot: buys the new Candle launches of the creators you list, with a ticket and caps per creator,\nand exits them by a strategy.\n\n candle-creator-bot run --config bot.json [--mode dry|live] run the bot\n candle-creator-bot status --config bot.json print status.json (each creator's entries and SOL used)\n candle-creator-bot sell <mint> --config bot.json [--fraction 1]\n\nStart from examples/dry.json and put your creators in follow.creators. Controls: STOP or PAUSE_BUYS in the state folder.\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,70 @@
|
|
|
1
|
+
/** The command line: run, status, sell. */
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { args, context as coreContext, die, guardMain, parts, run, sell, status } from "candle-bot-core/runtime";
|
|
5
|
+
import { Detector } from "candle-bot-core/sources/candleLaunches";
|
|
6
|
+
import { loadConfig, ConfigError } from "./config.js";
|
|
7
|
+
import { Ledger } from "./ledger.js";
|
|
8
|
+
import { Engine } from "./trigger.js";
|
|
9
|
+
export const HELP = `candle-creator-bot: buys the new Candle launches of the creators you list, with a ticket and caps per creator,
|
|
10
|
+
and exits them by a strategy.
|
|
11
|
+
|
|
12
|
+
candle-creator-bot run --config bot.json [--mode dry|live] run the bot
|
|
13
|
+
candle-creator-bot status --config bot.json print status.json (each creator's entries and SOL used)
|
|
14
|
+
candle-creator-bot sell <mint> --config bot.json [--fraction 1]
|
|
15
|
+
|
|
16
|
+
Start from examples/dry.json and put your creators in follow.creators. Controls: STOP or PAUSE_BUYS in the state folder.
|
|
17
|
+
Docs: the README.
|
|
18
|
+
`;
|
|
19
|
+
function context(configPath, modeOverride) {
|
|
20
|
+
let cfg;
|
|
21
|
+
try {
|
|
22
|
+
cfg = loadConfig(configPath);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof ConfigError)
|
|
26
|
+
die(e.message);
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
return coreContext(cfg, modeOverride);
|
|
30
|
+
}
|
|
31
|
+
async function makeEngine(ctx, ledger) {
|
|
32
|
+
const { strategy, alerter, executor } = await parts(ctx, ledger, { maxBuyImpactBps: ctx.cfg.follow.maxImpactBps });
|
|
33
|
+
const d = ctx.cfg.detect;
|
|
34
|
+
const source = new Detector({ rpc: ctx.rpc, wsUrl: d.chain ? ctx.wsUrl : undefined, apiUrl: ctx.cfg.apiUrl, log: ctx.log }, d);
|
|
35
|
+
const engine = new Engine({ cfg: ctx.cfg, ledger, source, executor, strategy, rpc: ctx.rpc, log: ctx.log, alerter, markets: ctx.client });
|
|
36
|
+
return { engine, trigger: engine.trigger };
|
|
37
|
+
}
|
|
38
|
+
const openLedger = (ctx) => () => new Ledger(join(ctx.cfg.stateDir, "ledger.sqlite"));
|
|
39
|
+
/** The entry point: never lets an error out unredacted (the SDK puts RPC URLs in its messages). */
|
|
40
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
41
|
+
return guardMain(async () => {
|
|
42
|
+
const { pos, opt } = args(argv);
|
|
43
|
+
const cmd = pos[0];
|
|
44
|
+
if (!cmd || cmd === "help" || opt.help) {
|
|
45
|
+
process.stdout.write(HELP);
|
|
46
|
+
return cmd || opt.help ? 0 : 2;
|
|
47
|
+
}
|
|
48
|
+
if (cmd === "version" || opt.version) {
|
|
49
|
+
process.stdout.write(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version + "\n");
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
const config = opt.config ?? "bot.json";
|
|
53
|
+
switch (cmd) {
|
|
54
|
+
case "run": {
|
|
55
|
+
const ctx = context(config, opt.mode);
|
|
56
|
+
return run(ctx, openLedger(ctx), (ledger) => makeEngine(ctx, ledger), { creators: ctx.cfg.follow.creators.length });
|
|
57
|
+
}
|
|
58
|
+
case "status":
|
|
59
|
+
return status(context(config));
|
|
60
|
+
case "sell": {
|
|
61
|
+
if (!pos[1])
|
|
62
|
+
die("sell <mint> [--fraction f]");
|
|
63
|
+
const ctx = context(config);
|
|
64
|
+
return sell(ctx, pos[1], opt.fraction ? Number(opt.fraction) : 1, openLedger(ctx), (ledger) => makeEngine(ctx, ledger));
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
die(`unknown command ${cmd}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one config file: the core's shared sections (candle-bot-core) plus `follow` (the creators and the global filters)
|
|
3
|
+
* and `detect` (where launches are seen). Unknown keys are refused at every level.
|
|
4
|
+
*/
|
|
5
|
+
import { type Config as CoreConfig, type SectionParser } from "candle-bot-core";
|
|
6
|
+
export { ConfigError } from "candle-bot-core/config";
|
|
7
|
+
export interface Creator {
|
|
8
|
+
address: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
/** This creator's ticket, SOL; at most buy.sol (default buy.sol). */
|
|
11
|
+
sol: number;
|
|
12
|
+
/** Most buys of this creator's launches over the bot's lifetime (optional). */
|
|
13
|
+
maxEntries?: number;
|
|
14
|
+
/** Most SOL spent on this creator's launches over the bot's lifetime (optional). */
|
|
15
|
+
maxSol?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface Follow {
|
|
18
|
+
creators: Creator[];
|
|
19
|
+
launchModes: string[];
|
|
20
|
+
maxAgeSec: number;
|
|
21
|
+
maxImpactBps: number;
|
|
22
|
+
inferModeFromCurve: boolean;
|
|
23
|
+
/** Buy only once the creator's wallet holds the coin (its own dev buy). */
|
|
24
|
+
requireDevBuy: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface Detect {
|
|
27
|
+
api: boolean;
|
|
28
|
+
chain: boolean;
|
|
29
|
+
apiPollMs: number;
|
|
30
|
+
chainPollMs: number;
|
|
31
|
+
apiLimit: number;
|
|
32
|
+
}
|
|
33
|
+
export type Config = CoreConfig & {
|
|
34
|
+
follow: Follow;
|
|
35
|
+
detect: Detect;
|
|
36
|
+
};
|
|
37
|
+
/** Candle's launch modes (agent-sdk 0.6.14 launch-definition `curve.values`). */
|
|
38
|
+
export declare const LAUNCH_MODES: string[];
|
|
39
|
+
/** Every production mode: the default, since the list of creators is the main filter. */
|
|
40
|
+
export declare const DEFAULT_MODES: string[];
|
|
41
|
+
declare function parseDetect(raw: unknown): Detect;
|
|
42
|
+
/** This bot's sections, in the order they are parsed (after `sell`, before `exit`). */
|
|
43
|
+
export declare const SECTIONS: {
|
|
44
|
+
follow: SectionParser<Follow>;
|
|
45
|
+
detect: typeof parseDetect;
|
|
46
|
+
};
|
|
47
|
+
export declare function parseConfig(raw: unknown, baseDir?: string): Config;
|
|
48
|
+
export declare function loadConfig(path: string): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one config file: the core's shared sections (candle-bot-core) plus `follow` (the creators and the global filters)
|
|
3
|
+
* and `detect` (where launches are seen). Unknown keys are refused at every level.
|
|
4
|
+
*/
|
|
5
|
+
import { parseConfig as parseCore, loadConfig as loadCore, ConfigError, isObj, only, num, bool, str, strList, asAddress } from "candle-bot-core";
|
|
6
|
+
export { ConfigError } from "candle-bot-core/config";
|
|
7
|
+
/** Candle's launch modes (agent-sdk 0.6.14 launch-definition `curve.values`). */
|
|
8
|
+
export const LAUNCH_MODES = ["open", "exclusive", "test-open", "test-exclusive", "exp-a", "exp-b", "exp-c", "exp-custom"];
|
|
9
|
+
/** Every production mode: the default, since the list of creators is the main filter. */
|
|
10
|
+
export const DEFAULT_MODES = ["open", "exclusive", "exp-a", "exp-b", "exp-c", "exp-custom"];
|
|
11
|
+
const parseFollow = (raw, core) => {
|
|
12
|
+
if (!isObj(raw))
|
|
13
|
+
throw new ConfigError("config.follow: required object with a `creators` list");
|
|
14
|
+
only(raw, ["creators", "launchModes", "maxAgeSec", "maxImpactBps", "inferModeFromCurve", "requireDevBuy"], "config.follow");
|
|
15
|
+
const list = raw.creators;
|
|
16
|
+
if (!Array.isArray(list) || !list.length)
|
|
17
|
+
throw new ConfigError("config.follow.creators: required, a list of { address, label?, sol?, maxEntries?, maxSol? }");
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
const creators = list.map((c, i) => {
|
|
20
|
+
const where = `config.follow.creators[${i}]`;
|
|
21
|
+
if (!isObj(c))
|
|
22
|
+
throw new ConfigError(`${where}: must be an object`);
|
|
23
|
+
only(c, ["address", "label", "sol", "maxEntries", "maxSol"], where);
|
|
24
|
+
const address = str(c, "address", where);
|
|
25
|
+
if (!asAddress(address))
|
|
26
|
+
throw new ConfigError(`${where}.address: "${address}" is not an address`);
|
|
27
|
+
if (seen.has(address))
|
|
28
|
+
throw new ConfigError(`${where}.address: listed twice`);
|
|
29
|
+
seen.add(address);
|
|
30
|
+
const sol = num(c, "sol", where, { min: 0.000001, def: core.buy.sol });
|
|
31
|
+
if (sol > core.buy.sol)
|
|
32
|
+
throw new ConfigError(`${where}.sol: ${sol} is above buy.sol (${core.buy.sol}); a creator's ticket can only be smaller`);
|
|
33
|
+
return {
|
|
34
|
+
address,
|
|
35
|
+
label: str(c, "label", where, { optional: true }),
|
|
36
|
+
sol,
|
|
37
|
+
maxEntries: c.maxEntries === undefined ? undefined : num(c, "maxEntries", where, { min: 1, int: true }),
|
|
38
|
+
maxSol: c.maxSol === undefined ? undefined : num(c, "maxSol", where, { min: 0.000001 }),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
for (const [i, c] of creators.entries()) {
|
|
42
|
+
if (c.maxSol !== undefined && c.maxSol < c.sol)
|
|
43
|
+
throw new ConfigError(`config.follow.creators[${i}].maxSol: ${c.maxSol} is below its ticket ${c.sol}; it could never buy`);
|
|
44
|
+
}
|
|
45
|
+
return finish(creators, raw);
|
|
46
|
+
};
|
|
47
|
+
function finish(creators, raw) {
|
|
48
|
+
const launchModes = strList(raw, "launchModes", "config.follow", { def: DEFAULT_MODES });
|
|
49
|
+
if (!launchModes.length)
|
|
50
|
+
throw new ConfigError("config.follow.launchModes: at least one mode");
|
|
51
|
+
for (const m of launchModes)
|
|
52
|
+
if (!LAUNCH_MODES.includes(m))
|
|
53
|
+
throw new ConfigError(`config.follow.launchModes: "${m}" is not a Candle launch mode (${LAUNCH_MODES.join(", ")})`);
|
|
54
|
+
return {
|
|
55
|
+
creators,
|
|
56
|
+
launchModes,
|
|
57
|
+
maxAgeSec: num(raw, "maxAgeSec", "config.follow", { min: 1, def: 60 }),
|
|
58
|
+
maxImpactBps: num(raw, "maxImpactBps", "config.follow", { min: 0, max: 10_000, def: 300 }),
|
|
59
|
+
inferModeFromCurve: bool(raw, "inferModeFromCurve", "config.follow", true),
|
|
60
|
+
requireDevBuy: bool(raw, "requireDevBuy", "config.follow", false),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
;
|
|
64
|
+
function parseDetect(raw) {
|
|
65
|
+
const d = raw === undefined ? {} : raw;
|
|
66
|
+
if (!isObj(d))
|
|
67
|
+
throw new ConfigError("config.detect: must be an object");
|
|
68
|
+
only(d, ["api", "chain", "apiPollMs", "chainPollMs", "apiLimit"], "config.detect");
|
|
69
|
+
const detect = {
|
|
70
|
+
api: bool(d, "api", "config.detect", true),
|
|
71
|
+
chain: bool(d, "chain", "config.detect", true),
|
|
72
|
+
apiPollMs: num(d, "apiPollMs", "config.detect", { min: 500, int: true, def: 3000 }),
|
|
73
|
+
chainPollMs: num(d, "chainPollMs", "config.detect", { min: 1000, int: true, def: 15_000 }),
|
|
74
|
+
apiLimit: num(d, "apiLimit", "config.detect", { min: 1, max: 200, int: true, def: 20 }),
|
|
75
|
+
};
|
|
76
|
+
if (!detect.api && !detect.chain)
|
|
77
|
+
throw new ConfigError("config.detect: at least one of api and chain must be on");
|
|
78
|
+
return detect;
|
|
79
|
+
}
|
|
80
|
+
/** This bot's sections, in the order they are parsed (after `sell`, before `exit`). */
|
|
81
|
+
export const SECTIONS = { follow: parseFollow, detect: parseDetect };
|
|
82
|
+
export function parseConfig(raw, baseDir = process.cwd()) {
|
|
83
|
+
return parseCore(raw, baseDir, SECTIONS);
|
|
84
|
+
}
|
|
85
|
+
export function loadConfig(path) {
|
|
86
|
+
return loadCore(path, SECTIONS);
|
|
87
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The global filters (launch mode, age, impact of the creator's ticket on the curve), as candle-launch-bot's: a
|
|
3
|
+
* verdict is `buy`, `skip` (final) or `wait` (a field is not known yet). Unknown never passes. A copy of the launch
|
|
4
|
+
* bot's checks for these three filters (this bot installs alone, without the launch bot).
|
|
5
|
+
*/
|
|
6
|
+
import type { CandleCurve } from "candle-bot-core/market/curve";
|
|
7
|
+
import type { Launch } from "candle-bot-core/sources/candleLaunches";
|
|
8
|
+
import type { Follow } from "./config.ts";
|
|
9
|
+
export type FilterVerdict = {
|
|
10
|
+
verdict: "buy";
|
|
11
|
+
impactBps: number;
|
|
12
|
+
expectedOutRaw: bigint;
|
|
13
|
+
} | {
|
|
14
|
+
verdict: "skip";
|
|
15
|
+
reason: string;
|
|
16
|
+
} | {
|
|
17
|
+
verdict: "wait";
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
/** Age and launch mode: what needs no curve read (a chain-first launch's mode may need it: then it waits for one). */
|
|
21
|
+
export declare function ageAndMode(f: Follow, l: Launch, curve: CandleCurve | null | "unreadable", nowMs: number): Exclude<FilterVerdict, {
|
|
22
|
+
verdict: "buy";
|
|
23
|
+
}> | null;
|
|
24
|
+
/** The curve checks and the impact of `lamports` on it. */
|
|
25
|
+
export declare function onCurve(f: Follow, l: Launch, curve: CandleCurve | null | "unreadable", lamports: bigint): FilterVerdict;
|
package/dist/filters.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { buyImpact, curveClass } from "candle-bot-core/market/curve";
|
|
2
|
+
import { WSOL } from "candle-bot-core/solana/pda";
|
|
3
|
+
/** Age and launch mode: what needs no curve read (a chain-first launch's mode may need it: then it waits for one). */
|
|
4
|
+
export function ageAndMode(f, l, curve, nowMs) {
|
|
5
|
+
if (l.chain !== "solana")
|
|
6
|
+
return { verdict: "skip", reason: `chain ${l.chain}` };
|
|
7
|
+
if (l.launchedAtMs === null)
|
|
8
|
+
return { verdict: "skip", reason: "launch time unknown" };
|
|
9
|
+
const ageSec = (nowMs - l.launchedAtMs) / 1000;
|
|
10
|
+
if (ageSec > f.maxAgeSec)
|
|
11
|
+
return { verdict: "skip", reason: `age ${Math.round(ageSec)}s > ${f.maxAgeSec}s` };
|
|
12
|
+
if (!l.launchMode) {
|
|
13
|
+
if (!f.inferModeFromCurve)
|
|
14
|
+
return { verdict: "wait", reason: "launch mode unknown" };
|
|
15
|
+
if (curve === null)
|
|
16
|
+
return { verdict: "wait", reason: "curve not readable yet" };
|
|
17
|
+
if (curve === "unreadable")
|
|
18
|
+
return { verdict: "skip", reason: "curve unreadable" };
|
|
19
|
+
// Only a full match of the open preset decides here; every other curve waits for Candle's `tier`.
|
|
20
|
+
if (curveClass(curve) !== "open")
|
|
21
|
+
return { verdict: "wait", reason: "launch mode unknown" };
|
|
22
|
+
if (!f.launchModes.includes("open"))
|
|
23
|
+
return { verdict: "skip", reason: "mode open (from the curve preset)" };
|
|
24
|
+
}
|
|
25
|
+
else if (!f.launchModes.includes(l.launchMode))
|
|
26
|
+
return { verdict: "skip", reason: `mode ${l.launchMode}` };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
/** The curve checks and the impact of `lamports` on it. */
|
|
30
|
+
export function onCurve(f, l, curve, lamports) {
|
|
31
|
+
if (curve === null)
|
|
32
|
+
return { verdict: "wait", reason: "curve not readable yet" };
|
|
33
|
+
if (curve === "unreadable")
|
|
34
|
+
return { verdict: "skip", reason: "curve unreadable" };
|
|
35
|
+
if (curve.baseMint !== l.mint)
|
|
36
|
+
return { verdict: "skip", reason: "curve names another mint" };
|
|
37
|
+
if (curve.quoteMint !== WSOL)
|
|
38
|
+
return { verdict: "skip", reason: "curve is not SOL-quoted" };
|
|
39
|
+
if (curve.migrated)
|
|
40
|
+
return { verdict: "skip", reason: "already graduated" };
|
|
41
|
+
if (curve.quoteReserve >= curve.migrationThreshold)
|
|
42
|
+
return { verdict: "skip", reason: "curve full (migrating)" };
|
|
43
|
+
let r;
|
|
44
|
+
try {
|
|
45
|
+
r = buyImpact(curve, lamports);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return { verdict: "skip", reason: e.message };
|
|
49
|
+
}
|
|
50
|
+
if (r.impactBps > f.maxImpactBps)
|
|
51
|
+
return { verdict: "skip", reason: `impact ${r.impactBps} bps > ${f.maxImpactBps}` };
|
|
52
|
+
return { verdict: "buy", impactBps: r.impactBps, expectedOutRaw: r.expectedOutRaw };
|
|
53
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** candle-creator-bot: the trigger, the filters, the config and the ledger, for use as a library. */
|
|
2
|
+
export { CreatorTrigger, Engine, candleHasMarket, followedOf, NO_CANDLE_MARKET } from "./trigger.ts";
|
|
3
|
+
export type { TriggerDeps, EngineDeps, LaunchSource, MarketInfoSource } from "./trigger.ts";
|
|
4
|
+
export { ageAndMode, onCurve } from "./filters.ts";
|
|
5
|
+
export type { FilterVerdict } from "./filters.ts";
|
|
6
|
+
export { parseConfig, loadConfig, SECTIONS, ConfigError, LAUNCH_MODES, DEFAULT_MODES } from "./config.ts";
|
|
7
|
+
export type { Config, Creator, Follow, Detect } from "./config.ts";
|
|
8
|
+
export { Ledger } from "./ledger.ts";
|
|
9
|
+
export type { FollowRow } from "./ledger.ts";
|
|
10
|
+
export { main, HELP } from "./cli.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** candle-creator-bot: the trigger, the filters, the config and the ledger, for use as a library. */
|
|
2
|
+
export { CreatorTrigger, Engine, candleHasMarket, followedOf, NO_CANDLE_MARKET } from "./trigger.js";
|
|
3
|
+
export { ageAndMode, onCurve } from "./filters.js";
|
|
4
|
+
export { parseConfig, loadConfig, SECTIONS, ConfigError, LAUNCH_MODES, DEFAULT_MODES } from "./config.js";
|
|
5
|
+
export { Ledger } from "./ledger.js";
|
|
6
|
+
export { main, HELP } from "./cli.js";
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The creator follower's ledger: the core ledger (candle-bot-core) plus `follows`, one row per launch of a listed
|
|
3
|
+
* creator with the decision. A creator's usage (entries and SOL) is read from the core's own positions and orders,
|
|
4
|
+
* by the `followed` address the bot writes into each position's entry context: the same counting rules as the core's
|
|
5
|
+
* maxPerCoin and lifetimeSol (a buy Candle refused before anything was signed, or one that reverted, spent nothing and
|
|
6
|
+
* does not count), and nothing a booking could miss or a restart could lose.
|
|
7
|
+
*/
|
|
8
|
+
import { Ledger as CoreLedger } from "candle-bot-core/ledger";
|
|
9
|
+
export type { PositionRow, OrderRow, PositionStatus, OrderStatus, Side, QtySource } from "candle-bot-core/ledger";
|
|
10
|
+
export interface FollowRow {
|
|
11
|
+
mint: string;
|
|
12
|
+
creator: string;
|
|
13
|
+
label: string | null;
|
|
14
|
+
launched_at: number | null;
|
|
15
|
+
first_seen_at: number;
|
|
16
|
+
symbol: string | null;
|
|
17
|
+
launch_mode: string | null;
|
|
18
|
+
decision: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
21
|
+
export declare class Ledger extends CoreLedger {
|
|
22
|
+
constructor(path: string);
|
|
23
|
+
recordFollow(r: FollowRow): void;
|
|
24
|
+
follow(mint: string): FollowRow | undefined;
|
|
25
|
+
recentFollows(limit?: number): FollowRow[];
|
|
26
|
+
/**
|
|
27
|
+
* What this mode has committed to a followed creator: entries (positions that may have spent) and lamports (their
|
|
28
|
+
* buy tickets, one per transaction on a double execution), from the positions whose entry context names it.
|
|
29
|
+
*/
|
|
30
|
+
creatorUsage(creator: string, dry: boolean): {
|
|
31
|
+
entries: number;
|
|
32
|
+
lamports: bigint;
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The creator follower's ledger: the core ledger (candle-bot-core) plus `follows`, one row per launch of a listed
|
|
3
|
+
* creator with the decision. A creator's usage (entries and SOL) is read from the core's own positions and orders,
|
|
4
|
+
* by the `followed` address the bot writes into each position's entry context: the same counting rules as the core's
|
|
5
|
+
* maxPerCoin and lifetimeSol (a buy Candle refused before anything was signed, or one that reverted, spent nothing and
|
|
6
|
+
* does not count), and nothing a booking could miss or a restart could lose.
|
|
7
|
+
*/
|
|
8
|
+
import { Ledger as CoreLedger } from "candle-bot-core/ledger";
|
|
9
|
+
const FOLLOWS = `
|
|
10
|
+
create table if not exists follows (
|
|
11
|
+
mint text primary key, creator text not null, label text, launched_at integer, first_seen_at integer not null,
|
|
12
|
+
symbol text, launch_mode text, decision text not null, reason text not null
|
|
13
|
+
);
|
|
14
|
+
`;
|
|
15
|
+
export class Ledger extends CoreLedger {
|
|
16
|
+
constructor(path) {
|
|
17
|
+
super(path);
|
|
18
|
+
this.db.exec(FOLLOWS);
|
|
19
|
+
}
|
|
20
|
+
recordFollow(r) {
|
|
21
|
+
this.db
|
|
22
|
+
.prepare(`insert into follows (mint, creator, label, launched_at, first_seen_at, symbol, launch_mode, decision, reason)
|
|
23
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
24
|
+
on conflict(mint) do update set decision = excluded.decision, reason = excluded.reason,
|
|
25
|
+
symbol = coalesce(excluded.symbol, follows.symbol), launch_mode = coalesce(excluded.launch_mode, follows.launch_mode)`)
|
|
26
|
+
.run(r.mint, r.creator, r.label, r.launched_at, r.first_seen_at, r.symbol, r.launch_mode, r.decision, r.reason);
|
|
27
|
+
}
|
|
28
|
+
follow(mint) {
|
|
29
|
+
return this.db.prepare("select * from follows where mint = ?").get(mint);
|
|
30
|
+
}
|
|
31
|
+
recentFollows(limit = 20) {
|
|
32
|
+
return this.db.prepare("select * from follows order by first_seen_at desc limit ?").all(limit);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What this mode has committed to a followed creator: entries (positions that may have spent) and lamports (their
|
|
36
|
+
* buy tickets, one per transaction on a double execution), from the positions whose entry context names it.
|
|
37
|
+
*/
|
|
38
|
+
creatorUsage(creator, dry) {
|
|
39
|
+
const rows = this.db
|
|
40
|
+
.prepare(`select p.id pid, o.amount_raw a, o.extra_signatures x from positions p join orders o on o.position_id = p.id
|
|
41
|
+
where o.side = 'buy' and p.dry = ? and json_extract(p.launch_json, '$.followed') = ?
|
|
42
|
+
and o.status != 'reverted' and not (o.status = 'rejected' and o.ever_signed = 0)`)
|
|
43
|
+
.all(dry ? 1 : 0, creator);
|
|
44
|
+
const lamports = rows.reduce((s, r) => s + BigInt(r.a) * BigInt(1 + (r.x ? JSON.parse(r.x).length : 0)), 0n);
|
|
45
|
+
return { entries: new Set(rows.map((r) => r.pid)).size, lamports };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The creator follower's trigger on the core engine (candle-bot-core): new Candle launches come from the core's launch
|
|
3
|
+
* source; a launch whose creator (Candle's token list) or signer (the launch transaction's fee payer) is on the list is
|
|
4
|
+
* checked against the global filters, the creator's own caps and, optionally, the creator's dev buy, and bought with
|
|
5
|
+
* the creator's ticket. A launch whose creator is not known yet waits, as in candle-launch-bot. Every decision on a
|
|
6
|
+
* listed creator's launch is a row in `follows`.
|
|
7
|
+
*/
|
|
8
|
+
import { Engine as CoreEngine } from "candle-bot-core/engine";
|
|
9
|
+
import type { Candidate, PendingInfo, Trigger, TriggerContext, Verdict } from "candle-bot-core/trigger";
|
|
10
|
+
import type { Alerter } from "candle-bot-core/alerts";
|
|
11
|
+
import type { Executor } from "candle-bot-core/exec/executor";
|
|
12
|
+
import type { Logger } from "candle-bot-core/log";
|
|
13
|
+
import { type Rpc } from "candle-bot-core/solana/rpc";
|
|
14
|
+
import type { Launch } from "candle-bot-core/sources/candleLaunches";
|
|
15
|
+
import type { ExitStrategy } from "candle-bot-core/strategies/types";
|
|
16
|
+
import type { Config, Creator } from "./config.ts";
|
|
17
|
+
import type { Ledger } from "./ledger.ts";
|
|
18
|
+
/** What the trigger needs from a launch source (the core's CandleLaunches; a fake in tests). */
|
|
19
|
+
export interface LaunchSource {
|
|
20
|
+
start(): Promise<void>;
|
|
21
|
+
stop(): void;
|
|
22
|
+
drain(): Launch[];
|
|
23
|
+
get(mint: string): Launch | undefined;
|
|
24
|
+
onNew: (() => void) | null;
|
|
25
|
+
readonly stats?: unknown;
|
|
26
|
+
}
|
|
27
|
+
export interface MarketInfoSource {
|
|
28
|
+
/** Candle's public market record (`tier`, `candleLaunched`, `trade.routable`). */
|
|
29
|
+
getMarket(chain: "solana", mint: string): Promise<any>;
|
|
30
|
+
}
|
|
31
|
+
export declare const NO_CANDLE_MARKET = "no Candle market";
|
|
32
|
+
export declare const MARKET_CHECK_MS = 2000;
|
|
33
|
+
export declare const MARKET_READ_TIMEOUT_MS = 3000;
|
|
34
|
+
export declare const DEV_BUY_CHECK_MS = 2000;
|
|
35
|
+
/** As candle-launch-bot's: only an explicit `candleLaunched: true` passes; `trade.routable: false` refuses. */
|
|
36
|
+
export declare function candleHasMarket(m: any): {
|
|
37
|
+
ok: true;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
why: string;
|
|
41
|
+
};
|
|
42
|
+
export interface TriggerDeps {
|
|
43
|
+
cfg: Config;
|
|
44
|
+
ledger: Ledger;
|
|
45
|
+
source: LaunchSource;
|
|
46
|
+
rpc: Rpc;
|
|
47
|
+
log: Logger;
|
|
48
|
+
markets?: MarketInfoSource | null;
|
|
49
|
+
now?: () => number;
|
|
50
|
+
}
|
|
51
|
+
/** The listed creator of a launch: the token list's creator first, then the launch transaction's signer. */
|
|
52
|
+
export declare function followedOf(creators: Creator[], l: Launch): Creator | undefined;
|
|
53
|
+
export declare class CreatorTrigger implements Trigger {
|
|
54
|
+
readonly name = "creator";
|
|
55
|
+
readonly candidateLabel = "launches";
|
|
56
|
+
readonly buyIdPrefix = "ccb";
|
|
57
|
+
readonly sellIdPrefix = "ccs";
|
|
58
|
+
/** One decision at a time: a creator's caps are read from the ledger, and the booking of one buy lands before the next is decided. */
|
|
59
|
+
readonly concurrency = 1;
|
|
60
|
+
private d;
|
|
61
|
+
private now;
|
|
62
|
+
private state;
|
|
63
|
+
constructor(d: TriggerDeps);
|
|
64
|
+
start(ctx?: TriggerContext): Promise<void>;
|
|
65
|
+
stop(): void;
|
|
66
|
+
poll(): Candidate[];
|
|
67
|
+
private launchOf;
|
|
68
|
+
private st;
|
|
69
|
+
decide(c: Candidate, ctx: TriggerContext, pending: PendingInfo): Promise<Verdict>;
|
|
70
|
+
/** The listed creator's own wallet holds the coin (not the launch's signer, which is often a payer wallet). */
|
|
71
|
+
private devBought;
|
|
72
|
+
private readMarket;
|
|
73
|
+
private noteMarket;
|
|
74
|
+
onVerdict(c: Candidate, v: Verdict, _ctx: TriggerContext, pending: PendingInfo): void;
|
|
75
|
+
status(pending: Array<{
|
|
76
|
+
candidate: Candidate;
|
|
77
|
+
} & PendingInfo>): Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
export interface EngineDeps extends TriggerDeps {
|
|
80
|
+
executor: Executor;
|
|
81
|
+
strategy: ExitStrategy;
|
|
82
|
+
alerter: Alerter;
|
|
83
|
+
}
|
|
84
|
+
/** The creator follower's engine: the core engine with the creator trigger. */
|
|
85
|
+
export declare class Engine extends CoreEngine {
|
|
86
|
+
constructor(d: EngineDeps);
|
|
87
|
+
}
|
package/dist/trigger.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The creator follower's trigger on the core engine (candle-bot-core): new Candle launches come from the core's launch
|
|
3
|
+
* source; a launch whose creator (Candle's token list) or signer (the launch transaction's fee payer) is on the list is
|
|
4
|
+
* checked against the global filters, the creator's own caps and, optionally, the creator's dev buy, and bought with
|
|
5
|
+
* the creator's ticket. A launch whose creator is not known yet waits, as in candle-launch-bot. Every decision on a
|
|
6
|
+
* listed creator's launch is a row in `follows`.
|
|
7
|
+
*/
|
|
8
|
+
import { Engine as CoreEngine, lamportsOf } from "candle-bot-core/engine";
|
|
9
|
+
import { candleCurve } from "candle-bot-core/solana/pda";
|
|
10
|
+
import { tokenBalance } from "candle-bot-core/solana/rpc";
|
|
11
|
+
import { ageAndMode, onCurve } from "./filters.js";
|
|
12
|
+
export const NO_CANDLE_MARKET = "no Candle market";
|
|
13
|
+
export const MARKET_CHECK_MS = 2_000;
|
|
14
|
+
export const MARKET_READ_TIMEOUT_MS = 3_000;
|
|
15
|
+
export const DEV_BUY_CHECK_MS = 2_000;
|
|
16
|
+
/** As candle-launch-bot's: only an explicit `candleLaunched: true` passes; `trade.routable: false` refuses. */
|
|
17
|
+
export function candleHasMarket(m) {
|
|
18
|
+
if (!m || typeof m !== "object")
|
|
19
|
+
return { ok: false, why: "no market record" };
|
|
20
|
+
if (m.candleLaunched !== true)
|
|
21
|
+
return { ok: false, why: m.candleLaunched === false ? "not a Candle market" : "candleLaunched unknown" };
|
|
22
|
+
if (m.trade && m.trade.routable === false)
|
|
23
|
+
return { ok: false, why: `not routable${m.trade.reason ? ` (${m.trade.reason})` : ""}` };
|
|
24
|
+
return { ok: true };
|
|
25
|
+
}
|
|
26
|
+
/** The listed creator of a launch: the token list's creator first, then the launch transaction's signer. */
|
|
27
|
+
export function followedOf(creators, l) {
|
|
28
|
+
for (const who of [l.creator, l.signer]) {
|
|
29
|
+
if (!who)
|
|
30
|
+
continue;
|
|
31
|
+
const c = creators.find((x) => x.address === who);
|
|
32
|
+
if (c)
|
|
33
|
+
return c;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
export class CreatorTrigger {
|
|
38
|
+
name = "creator";
|
|
39
|
+
candidateLabel = "launches";
|
|
40
|
+
buyIdPrefix = "ccb";
|
|
41
|
+
sellIdPrefix = "ccs";
|
|
42
|
+
/** One decision at a time: a creator's caps are read from the ledger, and the booking of one buy lands before the next is decided. */
|
|
43
|
+
concurrency = 1;
|
|
44
|
+
d;
|
|
45
|
+
now;
|
|
46
|
+
state = new Map();
|
|
47
|
+
constructor(d) {
|
|
48
|
+
this.d = d;
|
|
49
|
+
this.now = d.now ?? Date.now;
|
|
50
|
+
}
|
|
51
|
+
async start(ctx) {
|
|
52
|
+
if (ctx?.wake)
|
|
53
|
+
this.d.source.onNew = () => ctx.wake();
|
|
54
|
+
await this.d.source.start();
|
|
55
|
+
}
|
|
56
|
+
stop() {
|
|
57
|
+
this.d.source.stop();
|
|
58
|
+
}
|
|
59
|
+
poll() {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const l of this.d.source.drain()) {
|
|
62
|
+
this.state.set(l.mint, { marketTried: 0 });
|
|
63
|
+
out.push({ mint: l.mint, chain: l.chain, seenAtMs: l.detectedAtMs, source: l.source, reason: "new launch", meta: l });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
launchOf(c) {
|
|
68
|
+
return this.d.source.get(c.mint) ?? c.meta;
|
|
69
|
+
}
|
|
70
|
+
st(mint) {
|
|
71
|
+
let s = this.state.get(mint);
|
|
72
|
+
if (!s)
|
|
73
|
+
this.state.set(mint, (s = { marketTried: 0 }));
|
|
74
|
+
return s;
|
|
75
|
+
}
|
|
76
|
+
async decide(c, ctx, pending) {
|
|
77
|
+
const l = this.launchOf(c);
|
|
78
|
+
const s = this.st(c.mint);
|
|
79
|
+
const f = this.d.cfg.follow;
|
|
80
|
+
if (l.creator === this.d.cfg.wallet || l.signer === this.d.cfg.wallet)
|
|
81
|
+
return { verdict: "skip", reason: "own wallet's launch" };
|
|
82
|
+
const who = followedOf(f.creators, l);
|
|
83
|
+
if (!who) {
|
|
84
|
+
if (!l.creator) {
|
|
85
|
+
// A chain-first launch: Candle's list (which names the creator) may not have it yet. Wait until it ages out.
|
|
86
|
+
if (l.launchedAtMs === null)
|
|
87
|
+
return { verdict: "skip", reason: "launch time unknown" };
|
|
88
|
+
const ageSec = (this.now() - l.launchedAtMs) / 1000;
|
|
89
|
+
if (ageSec > f.maxAgeSec)
|
|
90
|
+
return { verdict: "skip", reason: `age ${Math.round(ageSec)}s > ${f.maxAgeSec}s` };
|
|
91
|
+
return { verdict: "wait", reason: "creator unknown" };
|
|
92
|
+
}
|
|
93
|
+
return { verdict: "skip", reason: "creator not followed" };
|
|
94
|
+
}
|
|
95
|
+
const lamports = lamportsOf(who.sol);
|
|
96
|
+
let v = ageAndMode(f, l, null, this.now());
|
|
97
|
+
if (v && v.verdict === "wait" && /mode unknown/.test(v.reason) && this.d.markets && s.marketTried < 10) {
|
|
98
|
+
s.marketTried++;
|
|
99
|
+
try {
|
|
100
|
+
const m = await this.readMarket(l.mint);
|
|
101
|
+
s.marketCheckedAt = this.now();
|
|
102
|
+
this.noteMarket(s, m);
|
|
103
|
+
if (m?.tier && !l.launchMode) {
|
|
104
|
+
l.launchMode = String(m.tier);
|
|
105
|
+
s.modeFromMarket = true;
|
|
106
|
+
}
|
|
107
|
+
if (typeof m?.decimals === "number" && l.decimals === undefined)
|
|
108
|
+
l.decimals = m.decimals;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* not indexed yet */
|
|
112
|
+
}
|
|
113
|
+
v = ageAndMode(f, l, null, this.now());
|
|
114
|
+
}
|
|
115
|
+
let curve = null;
|
|
116
|
+
if (v === null || (v.verdict === "wait" && v.reason === "curve not readable yet")) {
|
|
117
|
+
const r = await ctx.readCurve(l.mint, l.curve ?? candleCurve(l.mint));
|
|
118
|
+
curve = r.status === "ok" ? r.curve : r.status === "missing" ? null : /owner|layout/.test(r.error) ? "unreadable" : null;
|
|
119
|
+
v = ageAndMode(f, l, curve, this.now());
|
|
120
|
+
}
|
|
121
|
+
if (v)
|
|
122
|
+
return v;
|
|
123
|
+
// The creator's caps (on top of the core's): from the ledger's own positions for this creator.
|
|
124
|
+
const used = this.d.ledger.creatorUsage(who.address, ctx.dry);
|
|
125
|
+
if (who.maxEntries !== undefined && used.entries >= who.maxEntries)
|
|
126
|
+
return { verdict: "skip", reason: `creator maxEntries ${who.maxEntries}` };
|
|
127
|
+
if (who.maxSol !== undefined && used.lamports + lamports > lamportsOf(who.maxSol))
|
|
128
|
+
return { verdict: "skip", reason: `creator maxSol ${who.maxSol}` };
|
|
129
|
+
const caps = ctx.caps(l.mint);
|
|
130
|
+
if (caps.paused)
|
|
131
|
+
return { verdict: "skip", reason: "PAUSE_BUYS" };
|
|
132
|
+
if (caps.entriesForMint >= this.d.cfg.buy.maxPerCoin)
|
|
133
|
+
return { verdict: "skip", reason: `maxPerCoin ${this.d.cfg.buy.maxPerCoin}` };
|
|
134
|
+
if (caps.open >= this.d.cfg.buy.maxOpen)
|
|
135
|
+
return { verdict: "skip", reason: `maxOpen ${this.d.cfg.buy.maxOpen}` };
|
|
136
|
+
if (caps.lifetimeLamports + lamports > lamportsOf(this.d.cfg.buy.lifetimeSol))
|
|
137
|
+
return { verdict: "skip", reason: `lifetimeSol ${this.d.cfg.buy.lifetimeSol}` };
|
|
138
|
+
const cv = onCurve(f, l, curve, lamports);
|
|
139
|
+
if (cv.verdict !== "buy")
|
|
140
|
+
return cv;
|
|
141
|
+
// The creator's own dev buy: the listed wallet holds the coin. Read at most every 2 s.
|
|
142
|
+
if (f.requireDevBuy && !s.devBuy) {
|
|
143
|
+
if (s.devCheckedAt === undefined || this.now() - s.devCheckedAt >= DEV_BUY_CHECK_MS) {
|
|
144
|
+
s.devCheckedAt = this.now();
|
|
145
|
+
s.devBuy = await this.devBought(l, who);
|
|
146
|
+
}
|
|
147
|
+
if (!s.devBuy)
|
|
148
|
+
return { verdict: "wait", reason: "no dev buy yet" };
|
|
149
|
+
}
|
|
150
|
+
// A launch whose mode Candle's list did not give is bought only once Candle's market record has the market.
|
|
151
|
+
if ((!l.launchMode || s.modeFromMarket) && !s.marketOk) {
|
|
152
|
+
if (this.d.markets && (s.marketCheckedAt === undefined || this.now() - s.marketCheckedAt >= MARKET_CHECK_MS)) {
|
|
153
|
+
s.marketCheckedAt = this.now();
|
|
154
|
+
try {
|
|
155
|
+
this.noteMarket(s, await this.readMarket(l.mint));
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
s.marketWhy = `market read failed: ${String(e?.message ?? e).slice(0, 120)}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (!this.d.markets)
|
|
162
|
+
s.marketWhy = "no market source";
|
|
163
|
+
if (!s.marketOk)
|
|
164
|
+
return { verdict: "wait", reason: NO_CANDLE_MARKET };
|
|
165
|
+
}
|
|
166
|
+
s.impactBps = cv.impactBps;
|
|
167
|
+
return {
|
|
168
|
+
verdict: "buy",
|
|
169
|
+
reason: `${who.label ?? who.address.slice(0, 8)}: entry, impact ${cv.impactBps} bps`,
|
|
170
|
+
lamports,
|
|
171
|
+
deadlineAtMs: l.launchedAtMs === null ? null : l.launchedAtMs + f.maxAgeSec * 1000,
|
|
172
|
+
expectedOutRaw: cv.expectedOutRaw,
|
|
173
|
+
decimals: l.decimals,
|
|
174
|
+
// `followed` is what the creator's caps count by (Ledger.creatorUsage).
|
|
175
|
+
context: { ...l, followed: who.address, label: who.label ?? null },
|
|
176
|
+
log: (now) => ({ symbol: l.symbol, creator: who.address, label: who.label, sol: who.sol, impactBps: cv.impactBps, ageMs: l.launchedAtMs ? now - l.launchedAtMs : null, lastWait: pending.lastReason }),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** The listed creator's own wallet holds the coin (not the launch's signer, which is often a payer wallet). */
|
|
180
|
+
async devBought(l, who) {
|
|
181
|
+
try {
|
|
182
|
+
const b = await tokenBalance(this.d.rpc, who.address, l.mint);
|
|
183
|
+
return b !== null && b > 0n;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false; // unread: not proven
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
readMarket(mint) {
|
|
190
|
+
let timer;
|
|
191
|
+
return Promise.race([
|
|
192
|
+
this.d.markets.getMarket("solana", mint),
|
|
193
|
+
new Promise((_r, rej) => (timer = setTimeout(() => rej(new Error("market read timeout")), MARKET_READ_TIMEOUT_MS))),
|
|
194
|
+
]).finally(() => clearTimeout(timer));
|
|
195
|
+
}
|
|
196
|
+
noteMarket(s, m) {
|
|
197
|
+
const has = candleHasMarket(m);
|
|
198
|
+
if (has.ok) {
|
|
199
|
+
s.marketOk = true;
|
|
200
|
+
s.marketWhy = undefined;
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
s.marketWhy = has.why;
|
|
204
|
+
}
|
|
205
|
+
onVerdict(c, v, _ctx, pending) {
|
|
206
|
+
const l = this.launchOf(c);
|
|
207
|
+
const who = followedOf(this.d.cfg.follow.creators, l);
|
|
208
|
+
if (v.verdict === "wait") {
|
|
209
|
+
if (who && v.reason !== pending.lastReason)
|
|
210
|
+
this.d.log.info("follow_wait", { mint: c.mint, creator: who.address, reason: v.reason });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const s = this.state.get(c.mint);
|
|
214
|
+
this.state.delete(c.mint);
|
|
215
|
+
if (!who)
|
|
216
|
+
return; // launches of creators not on the list leave no row
|
|
217
|
+
this.d.ledger.recordFollow({
|
|
218
|
+
mint: c.mint, creator: who.address, label: who.label ?? null, launched_at: l.launchedAtMs, first_seen_at: l.detectedAtMs,
|
|
219
|
+
symbol: l.symbol ?? null, launch_mode: l.launchMode ?? null, decision: v.verdict, reason: v.verdict === "skip" ? v.reason : `impact ${s?.impactBps} bps`,
|
|
220
|
+
});
|
|
221
|
+
this.d.log.info(v.verdict === "skip" ? "follow_skip" : "follow_buy", { mint: c.mint, creator: who.address, label: who.label, symbol: l.symbol, ...(v.verdict === "skip" ? { reason: v.reason } : {}) });
|
|
222
|
+
}
|
|
223
|
+
status(pending) {
|
|
224
|
+
const dry = this.d.cfg.mode !== "live";
|
|
225
|
+
return {
|
|
226
|
+
follow: {
|
|
227
|
+
creators: this.d.cfg.follow.creators.map((c) => {
|
|
228
|
+
const u = this.d.ledger.creatorUsage(c.address, dry);
|
|
229
|
+
return { address: c.address, label: c.label ?? null, sol: c.sol, entries: u.entries, spentSol: Number(u.lamports) / 1e9, maxEntries: c.maxEntries ?? null, maxSol: c.maxSol ?? null };
|
|
230
|
+
}),
|
|
231
|
+
pending: pending.map((p) => ({ mint: p.candidate.mint, waiting: p.lastReason })),
|
|
232
|
+
recent: this.d.ledger.recentFollows(10),
|
|
233
|
+
source: this.d.source.stats ?? null,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** The creator follower's engine: the core engine with the creator trigger. */
|
|
239
|
+
export class Engine extends CoreEngine {
|
|
240
|
+
constructor(d) {
|
|
241
|
+
super({ cfg: d.cfg, ledger: d.ledger, trigger: new CreatorTrigger(d), executor: d.executor, strategy: d.strategy, rpc: d.rpc, log: d.log, alerter: d.alerter, now: d.now });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"comment": "Dry run: decides and logs, never trades. No API key needed. Put the creators you follow in follow.creators.",
|
|
3
|
+
"mode": "dry",
|
|
4
|
+
"payer": "embedded",
|
|
5
|
+
"apiKeyEnv": "CANDLE_API_KEY",
|
|
6
|
+
"rpcUrlEnv": "SOLANA_RPC_URL",
|
|
7
|
+
"wallet": "11111111111111111111111111111111",
|
|
8
|
+
"stateDir": "./state-dry",
|
|
9
|
+
"buy": { "sol": 0.01, "maxSlippageBps": 500, "maxOpen": 3, "lifetimeSol": 0.1, "maxPerCoin": 1 },
|
|
10
|
+
"sell": { "maxSlippageBps": 1000 },
|
|
11
|
+
"follow": {
|
|
12
|
+
"creators": [{ "address": "11111111111111111111111111111112", "label": "example", "sol": 0.005, "maxEntries": 3 }],
|
|
13
|
+
"maxAgeSec": 60,
|
|
14
|
+
"maxImpactBps": 300
|
|
15
|
+
},
|
|
16
|
+
"exit": { "strategy": "tpsl", "takeProfit": 2, "stopLoss": 0.5 }
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candle-creator-bot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Buys the new Candle launches of the creators you list, with a ticket and caps per creator, and exits them by a pluggable strategy. A Solana bot built on @candledottv/agent-sdk.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/BruceJeet/candle-bots.git",
|
|
10
|
+
"directory": "packages/creator-bot"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/BruceJeet/candle-bots/tree/main/packages/creator-bot#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/BruceJeet/candle-bots/issues"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"candle-creator-bot": "bin/candle-creator-bot"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.18"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"dist",
|
|
25
|
+
"examples",
|
|
26
|
+
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
32
|
+
"typecheck": "tsc --noEmit -p .",
|
|
33
|
+
"prepack": "npm run build"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"candle-bot-core": "0.4.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "26.6.6",
|
|
40
|
+
"typescript": "7.0.2"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"candle",
|
|
44
|
+
"solana",
|
|
45
|
+
"trading-bot",
|
|
46
|
+
"creator",
|
|
47
|
+
"copy",
|
|
48
|
+
"launch",
|
|
49
|
+
"memecoin",
|
|
50
|
+
"bot",
|
|
51
|
+
"agent"
|
|
52
|
+
],
|
|
53
|
+
"main": "dist/index.js",
|
|
54
|
+
"types": "dist/index.d.ts",
|
|
55
|
+
"exports": {
|
|
56
|
+
".": {
|
|
57
|
+
"types": "./dist/index.d.ts",
|
|
58
|
+
"default": "./dist/index.js"
|
|
59
|
+
},
|
|
60
|
+
"./package.json": "./package.json"
|
|
61
|
+
}
|
|
62
|
+
}
|