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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +400 -2
- package/bin/candle-launch-bot +7 -0
- package/dist/alerts.d.ts +28 -0
- package/dist/alerts.js +82 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +321 -0
- package/dist/config.d.ts +88 -0
- package/dist/config.js +244 -0
- package/dist/detect/detector.d.ts +111 -0
- package/dist/detect/detector.js +348 -0
- package/dist/engine.d.ts +103 -0
- package/dist/engine.js +585 -0
- package/dist/exec/executor.d.ts +149 -0
- package/dist/exec/executor.js +859 -0
- package/dist/exec/fills.d.ts +25 -0
- package/dist/exec/fills.js +53 -0
- package/dist/exec/trader.d.ts +109 -0
- package/dist/exec/trader.js +310 -0
- package/dist/filters.d.ts +26 -0
- package/dist/filters.js +92 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/init.d.ts +23 -0
- package/dist/init.js +192 -0
- package/dist/ledger.d.ts +189 -0
- package/dist/ledger.js +243 -0
- package/dist/log.d.ts +22 -0
- package/dist/log.js +40 -0
- package/dist/market/curve.d.ts +80 -0
- package/dist/market/curve.js +168 -0
- package/dist/market/reader.d.ts +37 -0
- package/dist/market/reader.js +106 -0
- package/dist/redact.d.ts +20 -0
- package/dist/redact.js +111 -0
- package/dist/solana/base58.d.ts +4 -0
- package/dist/solana/base58.js +45 -0
- package/dist/solana/logs-ws.d.ts +25 -0
- package/dist/solana/logs-ws.js +116 -0
- package/dist/solana/pda.d.ts +12 -0
- package/dist/solana/pda.js +70 -0
- package/dist/solana/rpc.d.ts +29 -0
- package/dist/solana/rpc.js +96 -0
- package/dist/strategies/hold.d.ts +3 -0
- package/dist/strategies/hold.js +6 -0
- package/dist/strategies/ladder.d.ts +8 -0
- package/dist/strategies/ladder.js +51 -0
- package/dist/strategies/load.d.ts +5 -0
- package/dist/strategies/load.js +44 -0
- package/dist/strategies/preGrad.d.ts +10 -0
- package/dist/strategies/preGrad.js +32 -0
- package/dist/strategies/time.d.ts +6 -0
- package/dist/strategies/time.js +16 -0
- package/dist/strategies/tpsl.d.ts +8 -0
- package/dist/strategies/tpsl.js +30 -0
- package/dist/strategies/types.d.ts +55 -0
- package/dist/strategies/types.js +31 -0
- package/examples/dry.json +32 -0
- package/examples/live-embedded.json +14 -0
- package/examples/live-linked.json +15 -0
- package/examples/strategies/breakeven-stop.js +22 -0
- package/examples/strategies/stale-coin.js +18 -0
- package/examples/strategies/timeboxed-tp.js +18 -0
- package/package.json +60 -4
- package/skills/candle-launch-bot/SKILL.md +71 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/** The command line: init, scan, run, status, sell. */
|
|
2
|
+
import { CandleClient } from "@candledottv/agent-sdk";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadConfig, ConfigError } from "./config.js";
|
|
6
|
+
import { Detector } from "./detect/detector.js";
|
|
7
|
+
import { Engine, acquireLock } from "./engine.js";
|
|
8
|
+
import { Executor } from "./exec/executor.js";
|
|
9
|
+
import { EmbeddedRail, makeSecretStore, resolveEmbedded, resolveWallet, SdkRail, timedFetch } from "./exec/trader.js";
|
|
10
|
+
import { makeAlerter } from "./alerts.js";
|
|
11
|
+
import { evaluate } from "./filters.js";
|
|
12
|
+
import { Ledger } from "./ledger.js";
|
|
13
|
+
import { makeLogger } from "./log.js";
|
|
14
|
+
import { decodeCandleCurve } from "./market/curve.js";
|
|
15
|
+
import { dryPricer } from "./market/reader.js";
|
|
16
|
+
import { addSecret, redact } from "./redact.js";
|
|
17
|
+
import { candleCurve } from "./solana/pda.js";
|
|
18
|
+
import { PUBLIC_RPC, httpRpc } from "./solana/rpc.js";
|
|
19
|
+
import { wsUrlFor } from "./solana/logs-ws.js";
|
|
20
|
+
import { loadStrategy } from "./strategies/load.js";
|
|
21
|
+
import { init, terminalAsk } from "./init.js";
|
|
22
|
+
export const HELP = `candle-launch-bot: buys Candle launches as they appear and exits them by a strategy.
|
|
23
|
+
|
|
24
|
+
candle-launch-bot init [--out bot.json] [--yes] [flags] write a config (dry mode unless --mode live)
|
|
25
|
+
candle-launch-bot scan --config bot.json [--for 600] detect and print what it would buy; never trades
|
|
26
|
+
candle-launch-bot run --config bot.json [--mode dry|live] run the bot
|
|
27
|
+
candle-launch-bot status --config bot.json print status.json
|
|
28
|
+
candle-launch-bot sell <mint> --config bot.json [--fraction 1]
|
|
29
|
+
|
|
30
|
+
init flags (all optional; with --yes nothing is asked; defaults shown):
|
|
31
|
+
--mode dry|live (dry) --payer embedded|linked (embedded) --key-env <env var with the API key> (CANDLE_API_KEY)
|
|
32
|
+
--wallet <address> (looked up when the key is set) --api-url <https url>
|
|
33
|
+
--sol 0.01 --max-open 3 --lifetime-sol (10 tickets) --modes open,exp-custom --max-age 60 --max-impact 300
|
|
34
|
+
--slippage 500
|
|
35
|
+
--exit hold|tpsl|time|ladder|preGrad (tpsl) --take-profit 2 --stop-loss 0.5 --trailing <0-1> --after-sec 900
|
|
36
|
+
--progress 0.8 --fraction 1 --rungs 2:0.3,5:0.3,10:0.2
|
|
37
|
+
--exit-module ./my-exit.js --exit-params '{"arm":1.5}' your own strategy file and its params
|
|
38
|
+
--signer keychain|file|env (keychain) --signer-env <env var with the PEM> --signer-path <file>
|
|
39
|
+
--signer-pass-env <env var with the file's passphrase> payer linked only
|
|
40
|
+
--rpc-env <env var with an RPC url> --rpc-ws-env <env var with a websocket url>
|
|
41
|
+
--webhook <env var with an alert webhook url> --state-dir <dir> (./state-dry or ./state-live)
|
|
42
|
+
--out bot.json --force (overwrite)
|
|
43
|
+
|
|
44
|
+
Controls: create STOP or PAUSE_BUYS in the state folder. Docs: the README.
|
|
45
|
+
`;
|
|
46
|
+
/** Longest a single Candle API call may take (build, sign relay, submit, reads). */
|
|
47
|
+
const SDK_TIMEOUT_MS = 45_000;
|
|
48
|
+
function args(argv) {
|
|
49
|
+
const pos = [];
|
|
50
|
+
const opt = {};
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a.startsWith("--")) {
|
|
54
|
+
const [k, v] = a.slice(2).split("=", 2);
|
|
55
|
+
if (v !== undefined)
|
|
56
|
+
opt[k] = v;
|
|
57
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith("--"))
|
|
58
|
+
opt[k] = argv[++i];
|
|
59
|
+
else
|
|
60
|
+
opt[k] = "true";
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
pos.push(a);
|
|
64
|
+
}
|
|
65
|
+
return { pos, opt };
|
|
66
|
+
}
|
|
67
|
+
function die(msg, code = 2) {
|
|
68
|
+
process.stderr.write(redact(msg) + "\n");
|
|
69
|
+
process.exit(code);
|
|
70
|
+
}
|
|
71
|
+
function context(configPath, modeOverride) {
|
|
72
|
+
let cfg;
|
|
73
|
+
try {
|
|
74
|
+
cfg = loadConfig(configPath);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
if (e instanceof ConfigError)
|
|
78
|
+
die(e.message);
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
if (modeOverride) {
|
|
82
|
+
if (modeOverride !== "dry" && modeOverride !== "live")
|
|
83
|
+
die("--mode: dry or live");
|
|
84
|
+
if (modeOverride === "live" && cfg.mode !== "live")
|
|
85
|
+
die('--mode live needs "mode": "live" in the config too (and, for payer linked, its signer)');
|
|
86
|
+
cfg = { ...cfg, mode: modeOverride };
|
|
87
|
+
}
|
|
88
|
+
const apiKey = process.env[cfg.apiKeyEnv];
|
|
89
|
+
addSecret(apiKey);
|
|
90
|
+
const rpcUrl = (cfg.rpcUrlEnv && process.env[cfg.rpcUrlEnv]) || PUBLIC_RPC;
|
|
91
|
+
const wsUrl = (cfg.rpcWsUrlEnv && process.env[cfg.rpcWsUrlEnv]) || wsUrlFor(rpcUrl);
|
|
92
|
+
addSecret(rpcUrl);
|
|
93
|
+
addSecret(wsUrl);
|
|
94
|
+
mkdirSync(cfg.stateDir, { recursive: true });
|
|
95
|
+
const log = makeLogger({ file: join(cfg.stateDir, "events.jsonl") });
|
|
96
|
+
// Every SDK request carries a timeout: the SDK's own requests have none.
|
|
97
|
+
const client = new CandleClient({ apiUrl: cfg.apiUrl, apiKey: apiKey || undefined, privyAppId: cfg.privyAppId, solanaRpcUrl: rpcUrl, fetch: timedFetch(SDK_TIMEOUT_MS) });
|
|
98
|
+
return { cfg, rpc: httpRpc(rpcUrl), wsUrl, client, log };
|
|
99
|
+
}
|
|
100
|
+
async function buildRail(ctx) {
|
|
101
|
+
if (ctx.cfg.mode !== "live")
|
|
102
|
+
return { rail: null };
|
|
103
|
+
const apiKey = process.env[ctx.cfg.apiKeyEnv];
|
|
104
|
+
if (!apiKey)
|
|
105
|
+
die(`${ctx.cfg.apiKeyEnv} is not set (the Candle API key)`);
|
|
106
|
+
if (ctx.cfg.payer === "embedded") {
|
|
107
|
+
const e = await resolveEmbedded(ctx.cfg, apiKey, timedFetch(SDK_TIMEOUT_MS));
|
|
108
|
+
ctx.log.info("wallet_resolved", { wallet: e.address, payer: "embedded", permission: e.permission });
|
|
109
|
+
return { rail: new EmbeddedRail(new CandleClient({ apiUrl: ctx.cfg.apiUrl, apiKey, fetch: timedFetch(SDK_TIMEOUT_MS) })) };
|
|
110
|
+
}
|
|
111
|
+
const w = await resolveWallet(ctx.cfg, apiKey, timedFetch(SDK_TIMEOUT_MS));
|
|
112
|
+
const store = await makeSecretStore(ctx.cfg, w);
|
|
113
|
+
const client = new CandleClient({ apiUrl: ctx.cfg.apiUrl, apiKey, privyAppId: w.privyAppId, secretStore: store, fetch: timedFetch(SDK_TIMEOUT_MS) });
|
|
114
|
+
if (!(await store.get(w.linkedWalletId)))
|
|
115
|
+
die(`no signer key for TEE wallet ${ctx.cfg.wallet} in the ${ctx.cfg.signer.store} store (key ${w.keyPrefix})`);
|
|
116
|
+
ctx.log.info("wallet_resolved", { wallet: ctx.cfg.wallet, linkedWalletId: w.linkedWalletId, keyPrefix: w.keyPrefix, signer: ctx.cfg.signer.store });
|
|
117
|
+
return { rail: new SdkRail(client, w), linkedWalletId: w.linkedWalletId };
|
|
118
|
+
}
|
|
119
|
+
async function makeEngine(ctx, ledger) {
|
|
120
|
+
const strategy = await loadStrategy(ctx.cfg.exit, ctx.cfg.configDir).catch((e) => die(String(e.message)));
|
|
121
|
+
const { rail, linkedWalletId } = await buildRail(ctx);
|
|
122
|
+
const decimals = new Map();
|
|
123
|
+
const modes = ledger.modes();
|
|
124
|
+
if ((ctx.cfg.mode === "dry" && modes.live) || (ctx.cfg.mode === "live" && modes.dry))
|
|
125
|
+
die(`${ctx.cfg.stateDir} holds ${modes.live ? "live" : "dry"} positions; use a separate stateDir for ${ctx.cfg.mode} runs`);
|
|
126
|
+
let alerter;
|
|
127
|
+
try {
|
|
128
|
+
alerter = makeAlerter(ctx.cfg.alerts, ledger, ctx.log);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
die(String(e.message));
|
|
132
|
+
}
|
|
133
|
+
const executor = new Executor({
|
|
134
|
+
cfg: ctx.cfg, ledger, rail, linkedWalletId, rpc: ctx.rpc, log: ctx.log, alerter,
|
|
135
|
+
buysPaused: () => existsSync(join(ctx.cfg.stateDir, "PAUSE_BUYS")),
|
|
136
|
+
dry: dryPricer(ctx.rpc, ctx.client, (m) => decimals.get(m) ?? ledger.allPositions().find((p) => p.mint === m)?.decimals ?? 6),
|
|
137
|
+
});
|
|
138
|
+
const detector = new Detector({ rpc: ctx.rpc, wsUrl: ctx.cfg.detect.chain ? ctx.wsUrl : undefined, apiUrl: ctx.cfg.apiUrl, log: ctx.log }, ctx.cfg.detect);
|
|
139
|
+
const engine = new Engine({ cfg: ctx.cfg, ledger, detector, executor, strategy, rpc: ctx.rpc, log: ctx.log, alerter, markets: ctx.client });
|
|
140
|
+
return { engine, detector };
|
|
141
|
+
}
|
|
142
|
+
async function run(ctx) {
|
|
143
|
+
const lock = acquireLock(ctx.cfg.stateDir);
|
|
144
|
+
if (!lock)
|
|
145
|
+
die(`another process holds ${ctx.cfg.stateDir}/lock`);
|
|
146
|
+
const ledger = new Ledger(join(ctx.cfg.stateDir, "ledger.sqlite"));
|
|
147
|
+
const { engine, detector } = await makeEngine(ctx, ledger);
|
|
148
|
+
let stopping = false;
|
|
149
|
+
const stop = () => {
|
|
150
|
+
if (!stopping)
|
|
151
|
+
ctx.log.info("signal_stop");
|
|
152
|
+
stopping = true;
|
|
153
|
+
engine.requestStop();
|
|
154
|
+
};
|
|
155
|
+
process.on("SIGINT", stop);
|
|
156
|
+
process.on("SIGTERM", stop);
|
|
157
|
+
ctx.log.info("start", { mode: ctx.cfg.mode, payer: ctx.cfg.payer, wallet: ctx.cfg.wallet, strategy: ctx.cfg.exit.strategy, stateDir: ctx.cfg.stateDir, ticketSol: ctx.cfg.buy.sol, maxOpen: ctx.cfg.buy.maxOpen, lifetimeSol: ctx.cfg.buy.lifetimeSol });
|
|
158
|
+
await detector.start();
|
|
159
|
+
try {
|
|
160
|
+
while (await engine.cycle())
|
|
161
|
+
await new Promise((r) => setTimeout(r, ctx.cfg.loopMs));
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
detector.stop();
|
|
165
|
+
engine.writeStatus();
|
|
166
|
+
ledger.close();
|
|
167
|
+
lock.release();
|
|
168
|
+
ctx.log.info("stopped");
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
async function scan(ctx, seconds) {
|
|
173
|
+
const det = new Detector({ rpc: ctx.rpc, wsUrl: ctx.cfg.detect.chain ? ctx.wsUrl : undefined, apiUrl: ctx.cfg.apiUrl, log: ctx.log }, ctx.cfg.detect);
|
|
174
|
+
await det.start();
|
|
175
|
+
const stopAt = seconds ? Date.now() + seconds * 1000 : Infinity;
|
|
176
|
+
const waiting = new Map();
|
|
177
|
+
let quit = false;
|
|
178
|
+
process.on("SIGINT", () => (quit = true));
|
|
179
|
+
process.on("SIGTERM", () => (quit = true));
|
|
180
|
+
const caps = { open: 0, entriesForMint: 0, lifetimeLamports: 0n, paused: false };
|
|
181
|
+
while (!quit && Date.now() < stopAt) {
|
|
182
|
+
for (const l of det.drain())
|
|
183
|
+
waiting.set(l.mint, Date.now());
|
|
184
|
+
for (const mint of [...waiting.keys()]) {
|
|
185
|
+
const l = det.get(mint);
|
|
186
|
+
let v = evaluate(ctx.cfg, l, null, caps, Date.now());
|
|
187
|
+
if (v.verdict === "wait" && /unknown/.test(v.reason)) {
|
|
188
|
+
try {
|
|
189
|
+
const m = await ctx.client.getMarket("solana", mint);
|
|
190
|
+
if (m?.tier && !l.launchMode)
|
|
191
|
+
l.launchMode = m.tier;
|
|
192
|
+
if (m?.metadata?.name && l.name === undefined)
|
|
193
|
+
l.name = m.metadata.name;
|
|
194
|
+
if (m?.metadata?.symbol && l.symbol === undefined)
|
|
195
|
+
l.symbol = m.metadata.symbol;
|
|
196
|
+
}
|
|
197
|
+
catch { /* not indexed yet */ }
|
|
198
|
+
v = evaluate(ctx.cfg, l, null, caps, Date.now());
|
|
199
|
+
}
|
|
200
|
+
if (v.verdict === "wait" && v.reason === "curve not readable yet") {
|
|
201
|
+
try {
|
|
202
|
+
const r = await ctx.rpc.call("getAccountInfo", [l.curve ?? candleCurve(mint), { encoding: "base64", commitment: "confirmed" }]);
|
|
203
|
+
v = evaluate(ctx.cfg, l, r?.value ? decodeCandleCurve(r.value) : null, caps, Date.now());
|
|
204
|
+
}
|
|
205
|
+
catch { /* next round */ }
|
|
206
|
+
}
|
|
207
|
+
if (v.verdict === "wait")
|
|
208
|
+
continue;
|
|
209
|
+
waiting.delete(mint);
|
|
210
|
+
const s = det.sightingsFor(mint);
|
|
211
|
+
ctx.log.info(v.verdict === "buy" ? "scan_would_buy" : "scan_skip", {
|
|
212
|
+
mint, symbol: l.symbol, name: l.name, mode: l.launchMode, creator: l.creator, signer: l.signer, firstSource: l.source,
|
|
213
|
+
apiLatencyMs: s.api?.latencyMs ?? null, chainLatencyMs: s.chain?.latencyMs ?? null, ...(v.verdict === "buy" ? { impactBps: v.impactBps, sol: ctx.cfg.buy.sol } : { reason: v.reason }),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
217
|
+
}
|
|
218
|
+
det.stop();
|
|
219
|
+
ctx.log.info("scan_done", { api: det.stats.api, chain: det.stats.chain });
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
function status(ctx) {
|
|
223
|
+
const p = join(ctx.cfg.stateDir, "status.json");
|
|
224
|
+
if (!existsSync(p))
|
|
225
|
+
die(`no ${p} yet (has the bot run?)`, 1);
|
|
226
|
+
process.stdout.write(readFileSync(p, "utf8") + "\n");
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
async function sell(ctx, mint, fraction) {
|
|
230
|
+
if (!(fraction > 0 && fraction <= 1))
|
|
231
|
+
die("--fraction must be in (0, 1]");
|
|
232
|
+
const lock = acquireLock(ctx.cfg.stateDir);
|
|
233
|
+
if (!lock) {
|
|
234
|
+
// The bot is running: hand it the request; it runs the sell through its own executor.
|
|
235
|
+
const dir = join(ctx.cfg.stateDir, "requests");
|
|
236
|
+
mkdirSync(dir, { recursive: true });
|
|
237
|
+
const f = join(dir, `sell-${Date.now()}-${process.pid}.json`);
|
|
238
|
+
writeFileSync(f + ".tmp", JSON.stringify({ mint, fraction }));
|
|
239
|
+
renameSync(f + ".tmp", f); // the bot only reads complete files
|
|
240
|
+
process.stdout.write(`the bot is running: request written to ${f}; watch events.jsonl for manual_sell_request\n`);
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
const ledger = new Ledger(join(ctx.cfg.stateDir, "ledger.sqlite"));
|
|
244
|
+
try {
|
|
245
|
+
const { engine } = await makeEngine(ctx, ledger);
|
|
246
|
+
const r = await engine.manualSell(mint, fraction);
|
|
247
|
+
process.stdout.write(r + "\n");
|
|
248
|
+
const m = /^order (\d+)$/.exec(r);
|
|
249
|
+
if (!m)
|
|
250
|
+
return 1;
|
|
251
|
+
const id = Number(m[1]);
|
|
252
|
+
const exec = engine.d.executor;
|
|
253
|
+
for (let i = 0; i < 600; i++) {
|
|
254
|
+
await exec.sweep(1, { onlyIds: [id] }); // only this order: never a stale buy queued by an earlier run
|
|
255
|
+
const o = ledger.order(id);
|
|
256
|
+
if (!["pending", "inflight"].includes(o.status)) {
|
|
257
|
+
process.stdout.write(`order ${id}: ${o.status}${o.last_error_code ? " (" + o.last_error_code + ")" : ""}\n`);
|
|
258
|
+
return o.status === "filled" ? 0 : 1;
|
|
259
|
+
}
|
|
260
|
+
await new Promise((r) => setTimeout(r, Math.max(500, Math.min(5000, o.next_attempt_at - Date.now()))));
|
|
261
|
+
}
|
|
262
|
+
process.stdout.write(`order ${id} still open; it resumes on the next run\n`);
|
|
263
|
+
return 1;
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
ledger.close();
|
|
267
|
+
lock.release();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** The entry point: never lets an error out unredacted (the SDK puts RPC URLs in its messages). */
|
|
271
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
272
|
+
try {
|
|
273
|
+
return await mainInner(argv);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
process.stderr.write(redact(String(e?.stack ?? e)) + "\n");
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function mainInner(argv) {
|
|
281
|
+
const { pos, opt } = args(argv);
|
|
282
|
+
const cmd = pos[0];
|
|
283
|
+
if (!cmd || cmd === "help" || opt.help) {
|
|
284
|
+
process.stdout.write(HELP);
|
|
285
|
+
return cmd || opt.help ? 0 : 2;
|
|
286
|
+
}
|
|
287
|
+
if (cmd === "version" || opt.version) {
|
|
288
|
+
process.stdout.write(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version + "\n");
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
if (cmd === "init") {
|
|
292
|
+
const interactive = !opt.yes && process.stdin.isTTY === true;
|
|
293
|
+
const t = interactive ? terminalAsk() : null;
|
|
294
|
+
try {
|
|
295
|
+
return await init(opt, { env: process.env, fetch, out: (s) => void process.stdout.write(s), ...(t ? { ask: t.ask } : {}) });
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
if (e instanceof ConfigError || e?.name === "StrategyConfigError")
|
|
299
|
+
die(String(e.message));
|
|
300
|
+
throw e;
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
t?.close();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const config = opt.config ?? "bot.json";
|
|
307
|
+
switch (cmd) {
|
|
308
|
+
case "run":
|
|
309
|
+
return run(context(config, opt.mode));
|
|
310
|
+
case "scan":
|
|
311
|
+
return scan(context(config, "dry"), opt.for ? Number(opt.for) : null);
|
|
312
|
+
case "status":
|
|
313
|
+
return status(context(config));
|
|
314
|
+
case "sell":
|
|
315
|
+
if (!pos[1])
|
|
316
|
+
die("sell <mint> [--fraction f]");
|
|
317
|
+
return sell(context(config), pos[1], opt.fraction ? Number(opt.fraction) : 1);
|
|
318
|
+
default:
|
|
319
|
+
die(`unknown command ${cmd}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export type Mode = "dry" | "live";
|
|
2
|
+
export type SignerConfig = {
|
|
3
|
+
store: "keychain";
|
|
4
|
+
} | {
|
|
5
|
+
store: "file";
|
|
6
|
+
path: string;
|
|
7
|
+
passphraseEnv: string;
|
|
8
|
+
} | {
|
|
9
|
+
store: "env";
|
|
10
|
+
pemEnv: string;
|
|
11
|
+
};
|
|
12
|
+
export type Payer = "linked" | "embedded";
|
|
13
|
+
export interface Config {
|
|
14
|
+
mode: Mode;
|
|
15
|
+
/** linked: a TEE wallet signed locally with the key signer; embedded: the account's embedded wallet, signed by Candle. */
|
|
16
|
+
payer: Payer;
|
|
17
|
+
apiUrl: string;
|
|
18
|
+
apiKeyEnv: string;
|
|
19
|
+
rpcUrlEnv?: string;
|
|
20
|
+
rpcWsUrlEnv?: string;
|
|
21
|
+
wallet: string;
|
|
22
|
+
linkedWalletId?: string;
|
|
23
|
+
privyWalletId?: string;
|
|
24
|
+
privyAppId?: string;
|
|
25
|
+
signer?: SignerConfig;
|
|
26
|
+
stateDir: string;
|
|
27
|
+
loopMs: number;
|
|
28
|
+
buy: {
|
|
29
|
+
sol: number;
|
|
30
|
+
maxSlippageBps: number;
|
|
31
|
+
maxOpen: number;
|
|
32
|
+
lifetimeSol: number;
|
|
33
|
+
maxPerCoin: number;
|
|
34
|
+
maxPriorityFeeLamports?: number;
|
|
35
|
+
};
|
|
36
|
+
sell: {
|
|
37
|
+
maxSlippageBps: number;
|
|
38
|
+
maxPriorityFeeLamports?: number;
|
|
39
|
+
};
|
|
40
|
+
filters: {
|
|
41
|
+
chains: string[];
|
|
42
|
+
launchModes: string[];
|
|
43
|
+
maxAgeSec: number;
|
|
44
|
+
maxImpactBps: number;
|
|
45
|
+
creatorAllow?: string[];
|
|
46
|
+
creatorDeny?: string[];
|
|
47
|
+
nameMatch?: string;
|
|
48
|
+
symbolMatch?: string;
|
|
49
|
+
/** Classify a chain-first launch from its curve account (open / exclusive / exp-*) instead of waiting on Candle's list. */
|
|
50
|
+
inferModeFromCurve: boolean;
|
|
51
|
+
};
|
|
52
|
+
detect: {
|
|
53
|
+
api: boolean;
|
|
54
|
+
chain: boolean;
|
|
55
|
+
apiPollMs: number;
|
|
56
|
+
chainPollMs: number;
|
|
57
|
+
apiLimit: number;
|
|
58
|
+
};
|
|
59
|
+
exit: {
|
|
60
|
+
strategy: string;
|
|
61
|
+
[param: string]: unknown;
|
|
62
|
+
};
|
|
63
|
+
retry: {
|
|
64
|
+
maxAttempts: number;
|
|
65
|
+
baseDelayMs: number;
|
|
66
|
+
maxDelayMs: number;
|
|
67
|
+
maxExitOrders: number;
|
|
68
|
+
stuckRetryMs: number;
|
|
69
|
+
builtProbeMs: number;
|
|
70
|
+
builtHoldMs: number;
|
|
71
|
+
};
|
|
72
|
+
alerts: {
|
|
73
|
+
command?: string[];
|
|
74
|
+
commandEnv?: string[];
|
|
75
|
+
webhookEnv?: string;
|
|
76
|
+
renotifyMs: number;
|
|
77
|
+
holdAlertMs: number;
|
|
78
|
+
};
|
|
79
|
+
/** The folder of the config file: relative paths in it (stateDir, signer.path, exit.module) resolve here. */
|
|
80
|
+
configDir: string;
|
|
81
|
+
}
|
|
82
|
+
export declare class ConfigError extends Error {
|
|
83
|
+
}
|
|
84
|
+
/** Candle's launch modes (agent-sdk 0.6.14 launch-definition `curve.values`). */
|
|
85
|
+
export declare const LAUNCH_MODES: string[];
|
|
86
|
+
export declare const KNOWN_CHAINS: string[];
|
|
87
|
+
export declare function parseConfig(raw: unknown, baseDir?: string): Config;
|
|
88
|
+
export declare function loadConfig(path: string): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one config file. Validated at start; unknown keys are refused at every level so a typo can never be
|
|
3
|
+
* silently ignored. Secrets never live here: the file names the environment variables that hold them.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
7
|
+
import { asAddress } from "./solana/base58.js";
|
|
8
|
+
export class ConfigError extends Error {
|
|
9
|
+
}
|
|
10
|
+
function isObj(v) {
|
|
11
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
+
}
|
|
13
|
+
function only(o, allowed, where) {
|
|
14
|
+
for (const k of Object.keys(o))
|
|
15
|
+
if (!allowed.includes(k))
|
|
16
|
+
throw new ConfigError(`${where}: unknown key "${k}"`);
|
|
17
|
+
}
|
|
18
|
+
function num(o, k, where, opts = {}) {
|
|
19
|
+
const v = o[k];
|
|
20
|
+
if (v === undefined) {
|
|
21
|
+
if (opts.def !== undefined)
|
|
22
|
+
return opts.def;
|
|
23
|
+
throw new ConfigError(`${where}.${k}: required`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
26
|
+
throw new ConfigError(`${where}.${k}: must be a number`);
|
|
27
|
+
if (opts.int && !Number.isInteger(v))
|
|
28
|
+
throw new ConfigError(`${where}.${k}: must be an integer`);
|
|
29
|
+
if (opts.min !== undefined && v < opts.min)
|
|
30
|
+
throw new ConfigError(`${where}.${k}: must be >= ${opts.min}`);
|
|
31
|
+
if (opts.max !== undefined && v > opts.max)
|
|
32
|
+
throw new ConfigError(`${where}.${k}: must be <= ${opts.max}`);
|
|
33
|
+
return v;
|
|
34
|
+
}
|
|
35
|
+
function str(o, k, where, opts = {}) {
|
|
36
|
+
const v = o[k];
|
|
37
|
+
if (v === undefined) {
|
|
38
|
+
if (opts.def !== undefined)
|
|
39
|
+
return opts.def;
|
|
40
|
+
if (opts.optional)
|
|
41
|
+
return undefined;
|
|
42
|
+
throw new ConfigError(`${where}.${k}: required`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof v !== "string" || !v)
|
|
45
|
+
throw new ConfigError(`${where}.${k}: must be a non-empty string`);
|
|
46
|
+
if (opts.re && !opts.re.test(v))
|
|
47
|
+
throw new ConfigError(`${where}.${k}: invalid value`);
|
|
48
|
+
return v;
|
|
49
|
+
}
|
|
50
|
+
function bool(o, k, where, def) {
|
|
51
|
+
const v = o[k];
|
|
52
|
+
if (v === undefined)
|
|
53
|
+
return def;
|
|
54
|
+
if (typeof v !== "boolean")
|
|
55
|
+
throw new ConfigError(`${where}.${k}: must be true or false`);
|
|
56
|
+
return v;
|
|
57
|
+
}
|
|
58
|
+
function strList(o, k, where, opts = {}) {
|
|
59
|
+
const v = o[k];
|
|
60
|
+
if (v === undefined)
|
|
61
|
+
return opts.def;
|
|
62
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string" || !x))
|
|
63
|
+
throw new ConfigError(`${where}.${k}: must be a list of strings`);
|
|
64
|
+
if (opts.address)
|
|
65
|
+
for (const a of v)
|
|
66
|
+
if (!asAddress(a))
|
|
67
|
+
throw new ConfigError(`${where}.${k}: "${a}" is not an address`);
|
|
68
|
+
return v;
|
|
69
|
+
}
|
|
70
|
+
function regex(o, k, where) {
|
|
71
|
+
const v = str(o, k, where, { optional: true });
|
|
72
|
+
if (v === undefined)
|
|
73
|
+
return v;
|
|
74
|
+
try {
|
|
75
|
+
new RegExp(v, "i");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new ConfigError(`${where}.${k}: not a valid regular expression`);
|
|
79
|
+
}
|
|
80
|
+
return v;
|
|
81
|
+
}
|
|
82
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
83
|
+
/** Candle's launch modes (agent-sdk 0.6.14 launch-definition `curve.values`). */
|
|
84
|
+
export const LAUNCH_MODES = ["open", "exclusive", "test-open", "test-exclusive", "exp-a", "exp-b", "exp-c", "exp-custom"];
|
|
85
|
+
export const KNOWN_CHAINS = ["solana"];
|
|
86
|
+
export function parseConfig(raw, baseDir = process.cwd()) {
|
|
87
|
+
if (!isObj(raw))
|
|
88
|
+
throw new ConfigError("config: must be a JSON object");
|
|
89
|
+
only(raw, ["mode", "apiUrl", "apiKeyEnv", "rpcUrlEnv", "rpcWsUrlEnv", "wallet", "linkedWalletId", "privyWalletId", "privyAppId",
|
|
90
|
+
"signer", "payer", "stateDir", "loopMs", "buy", "sell", "filters", "detect", "exit", "retry", "alerts", "$schema", "comment"], "config");
|
|
91
|
+
const mode = str(raw, "mode", "config");
|
|
92
|
+
if (mode !== "dry" && mode !== "live")
|
|
93
|
+
throw new ConfigError('config.mode: must be "dry" or "live"');
|
|
94
|
+
const apiUrl = str(raw, "apiUrl", "config", { def: "https://api.alpha.candle.tv" });
|
|
95
|
+
if (!/^https:\/\//.test(apiUrl))
|
|
96
|
+
throw new ConfigError("config.apiUrl: must be https://");
|
|
97
|
+
const apiKeyEnv = str(raw, "apiKeyEnv", "config", { re: ENV_NAME });
|
|
98
|
+
const rpcUrlEnv = str(raw, "rpcUrlEnv", "config", { optional: true, re: ENV_NAME });
|
|
99
|
+
const rpcWsUrlEnv = str(raw, "rpcWsUrlEnv", "config", { optional: true, re: ENV_NAME });
|
|
100
|
+
const wallet = str(raw, "wallet", "config");
|
|
101
|
+
if (!asAddress(wallet))
|
|
102
|
+
throw new ConfigError("config.wallet: not a Solana address");
|
|
103
|
+
const linkedWalletId = str(raw, "linkedWalletId", "config", { optional: true, re: /^[A-Za-z0-9_-]{4,128}$/ });
|
|
104
|
+
const privyWalletId = str(raw, "privyWalletId", "config", { optional: true, re: /^[A-Za-z0-9_-]{4,128}$/ });
|
|
105
|
+
const privyAppId = str(raw, "privyAppId", "config", { optional: true, re: /^[A-Za-z0-9]{8,64}$/ });
|
|
106
|
+
let signer;
|
|
107
|
+
if (raw.signer !== undefined) {
|
|
108
|
+
const s = raw.signer;
|
|
109
|
+
if (!isObj(s))
|
|
110
|
+
throw new ConfigError("config.signer: must be an object");
|
|
111
|
+
const store = str(s, "store", "config.signer");
|
|
112
|
+
if (store === "keychain") {
|
|
113
|
+
only(s, ["store"], "config.signer");
|
|
114
|
+
signer = { store };
|
|
115
|
+
}
|
|
116
|
+
else if (store === "file") {
|
|
117
|
+
only(s, ["store", "path", "passphraseEnv"], "config.signer");
|
|
118
|
+
signer = { store, path: resolve(baseDir, str(s, "path", "config.signer")), passphraseEnv: str(s, "passphraseEnv", "config.signer", { re: ENV_NAME }) };
|
|
119
|
+
}
|
|
120
|
+
else if (store === "env") {
|
|
121
|
+
only(s, ["store", "pemEnv"], "config.signer");
|
|
122
|
+
signer = { store, pemEnv: str(s, "pemEnv", "config.signer", { re: ENV_NAME }) };
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
throw new ConfigError('config.signer.store: "keychain", "file" or "env"');
|
|
126
|
+
}
|
|
127
|
+
const payer = str(raw, "payer", "config", { def: "linked" });
|
|
128
|
+
if (payer !== "linked" && payer !== "embedded")
|
|
129
|
+
throw new ConfigError('config.payer: "linked" (TEE wallet, local signer) or "embedded" (Candle signs)');
|
|
130
|
+
if (payer === "embedded" && (signer || privyAppId || linkedWalletId || privyWalletId))
|
|
131
|
+
throw new ConfigError("config: payer \"embedded\" signs on Candle's side; remove signer / privyAppId / linkedWalletId / privyWalletId");
|
|
132
|
+
if (mode === "live" && payer === "linked" && !signer)
|
|
133
|
+
throw new ConfigError("config.signer: required in live mode with payer \"linked\" (where the wallet's P-256 signer key lives)");
|
|
134
|
+
const stateDir = resolve(baseDir, str(raw, "stateDir", "config", { def: "./state" }));
|
|
135
|
+
const loopMs = num(raw, "loopMs", "config", { min: 200, max: 60_000, int: true, def: 2000 });
|
|
136
|
+
if (!isObj(raw.buy))
|
|
137
|
+
throw new ConfigError("config.buy: required object");
|
|
138
|
+
const b = raw.buy;
|
|
139
|
+
only(b, ["sol", "maxSlippageBps", "maxOpen", "lifetimeSol", "maxPerCoin", "maxPriorityFeeLamports"], "config.buy");
|
|
140
|
+
const buy = {
|
|
141
|
+
sol: num(b, "sol", "config.buy", { min: 0.000001, max: 1000 }),
|
|
142
|
+
maxSlippageBps: num(b, "maxSlippageBps", "config.buy", { min: 0, max: 10_000, int: true }),
|
|
143
|
+
maxOpen: num(b, "maxOpen", "config.buy", { min: 0, int: true }),
|
|
144
|
+
lifetimeSol: num(b, "lifetimeSol", "config.buy", { min: 0 }),
|
|
145
|
+
// One entry per coin: re-entry is not supported (a second position in a coin would share its wallet balance).
|
|
146
|
+
maxPerCoin: num(b, "maxPerCoin", "config.buy", { min: 1, max: 1, int: true, def: 1 }),
|
|
147
|
+
maxPriorityFeeLamports: b.maxPriorityFeeLamports === undefined ? undefined : num(b, "maxPriorityFeeLamports", "config.buy", { min: 0, max: 10_000_000, int: true }),
|
|
148
|
+
};
|
|
149
|
+
const sl = raw.sell === undefined ? {} : raw.sell;
|
|
150
|
+
if (!isObj(sl))
|
|
151
|
+
throw new ConfigError("config.sell: must be an object");
|
|
152
|
+
only(sl, ["maxSlippageBps", "maxPriorityFeeLamports"], "config.sell");
|
|
153
|
+
const sell = {
|
|
154
|
+
maxSlippageBps: num(sl, "maxSlippageBps", "config.sell", { min: 0, max: 10_000, int: true, def: Math.max(buy.maxSlippageBps, 1000) }),
|
|
155
|
+
maxPriorityFeeLamports: sl.maxPriorityFeeLamports === undefined ? undefined : num(sl, "maxPriorityFeeLamports", "config.sell", { min: 0, max: 10_000_000, int: true }),
|
|
156
|
+
};
|
|
157
|
+
if (!isObj(raw.filters))
|
|
158
|
+
throw new ConfigError("config.filters: required object");
|
|
159
|
+
const f = raw.filters;
|
|
160
|
+
only(f, ["chains", "launchModes", "maxAgeSec", "maxImpactBps", "creatorAllow", "creatorDeny", "nameMatch", "symbolMatch", "inferModeFromCurve"], "config.filters");
|
|
161
|
+
const chains = strList(f, "chains", "config.filters", { def: ["solana"] });
|
|
162
|
+
for (const c of chains)
|
|
163
|
+
if (!KNOWN_CHAINS.includes(c))
|
|
164
|
+
throw new ConfigError(`config.filters.chains: "${c}" is not supported yet (${KNOWN_CHAINS.join(", ")})`);
|
|
165
|
+
const launchModes = strList(f, "launchModes", "config.filters");
|
|
166
|
+
if (!launchModes || !launchModes.length)
|
|
167
|
+
throw new ConfigError("config.filters.launchModes: required, e.g. [\"open\", \"exp-custom\"]");
|
|
168
|
+
for (const m of launchModes)
|
|
169
|
+
if (!LAUNCH_MODES.includes(m))
|
|
170
|
+
throw new ConfigError(`config.filters.launchModes: "${m}" is not a Candle launch mode (${LAUNCH_MODES.join(", ")})`);
|
|
171
|
+
const filters = {
|
|
172
|
+
chains,
|
|
173
|
+
launchModes,
|
|
174
|
+
maxAgeSec: num(f, "maxAgeSec", "config.filters", { min: 1 }),
|
|
175
|
+
maxImpactBps: num(f, "maxImpactBps", "config.filters", { min: 0, max: 10_000 }),
|
|
176
|
+
creatorAllow: strList(f, "creatorAllow", "config.filters", { address: true }),
|
|
177
|
+
creatorDeny: strList(f, "creatorDeny", "config.filters", { address: true }),
|
|
178
|
+
nameMatch: regex(f, "nameMatch", "config.filters"),
|
|
179
|
+
symbolMatch: regex(f, "symbolMatch", "config.filters"),
|
|
180
|
+
inferModeFromCurve: bool(f, "inferModeFromCurve", "config.filters", true),
|
|
181
|
+
};
|
|
182
|
+
const d = raw.detect === undefined ? {} : raw.detect;
|
|
183
|
+
if (!isObj(d))
|
|
184
|
+
throw new ConfigError("config.detect: must be an object");
|
|
185
|
+
only(d, ["api", "chain", "apiPollMs", "chainPollMs", "apiLimit"], "config.detect");
|
|
186
|
+
const detect = {
|
|
187
|
+
api: bool(d, "api", "config.detect", true),
|
|
188
|
+
chain: bool(d, "chain", "config.detect", true),
|
|
189
|
+
apiPollMs: num(d, "apiPollMs", "config.detect", { min: 500, int: true, def: 3000 }),
|
|
190
|
+
chainPollMs: num(d, "chainPollMs", "config.detect", { min: 1000, int: true, def: 15_000 }),
|
|
191
|
+
apiLimit: num(d, "apiLimit", "config.detect", { min: 1, max: 200, int: true, def: 20 }),
|
|
192
|
+
};
|
|
193
|
+
if (!detect.api && !detect.chain)
|
|
194
|
+
throw new ConfigError("config.detect: at least one of api and chain must be on");
|
|
195
|
+
if (!isObj(raw.exit))
|
|
196
|
+
throw new ConfigError("config.exit: required object with a strategy name");
|
|
197
|
+
const strategy = str(raw.exit, "strategy", "config.exit", { re: /^[A-Za-z][A-Za-z0-9_-]{0,40}$/ });
|
|
198
|
+
const exit = { ...raw.exit, strategy };
|
|
199
|
+
const r = raw.retry === undefined ? {} : raw.retry;
|
|
200
|
+
if (!isObj(r))
|
|
201
|
+
throw new ConfigError("config.retry: must be an object");
|
|
202
|
+
only(r, ["maxAttempts", "baseDelayMs", "maxDelayMs", "maxExitOrders", "stuckRetryMs", "builtProbeMs", "builtHoldMs"], "config.retry");
|
|
203
|
+
const retry = {
|
|
204
|
+
maxAttempts: num(r, "maxAttempts", "config.retry", { min: 1, max: 50, int: true, def: 6 }),
|
|
205
|
+
baseDelayMs: num(r, "baseDelayMs", "config.retry", { min: 100, int: true, def: 2000 }),
|
|
206
|
+
maxDelayMs: num(r, "maxDelayMs", "config.retry", { min: 100, int: true, def: 120_000 }),
|
|
207
|
+
maxExitOrders: num(r, "maxExitOrders", "config.retry", { min: 1, max: 20, int: true, def: 3 }),
|
|
208
|
+
stuckRetryMs: num(r, "stuckRetryMs", "config.retry", { min: 60_000, int: true, def: 600_000 }),
|
|
209
|
+
builtProbeMs: num(r, "builtProbeMs", "config.retry", { min: 60_000, int: true, def: 600_000 }),
|
|
210
|
+
builtHoldMs: num(r, "builtHoldMs", "config.retry", { min: 600_000, int: true, def: 21_600_000 }),
|
|
211
|
+
};
|
|
212
|
+
const al = raw.alerts === undefined ? {} : raw.alerts;
|
|
213
|
+
if (!isObj(al))
|
|
214
|
+
throw new ConfigError("config.alerts: must be an object");
|
|
215
|
+
only(al, ["command", "commandEnv", "webhookEnv", "renotifyMs", "holdAlertMs"], "config.alerts");
|
|
216
|
+
const command = strList(al, "command", "config.alerts");
|
|
217
|
+
if (command && !command.length)
|
|
218
|
+
throw new ConfigError("config.alerts.command: an argv list, e.g. [\"/usr/local/bin/notify\", \"bot\"]");
|
|
219
|
+
const alerts = {
|
|
220
|
+
command,
|
|
221
|
+
commandEnv: strList(al, "commandEnv", "config.alerts"),
|
|
222
|
+
webhookEnv: str(al, "webhookEnv", "config.alerts", { optional: true, re: ENV_NAME }),
|
|
223
|
+
renotifyMs: num(al, "renotifyMs", "config.alerts", { min: 60_000, int: true, def: 3_600_000 }),
|
|
224
|
+
holdAlertMs: num(al, "holdAlertMs", "config.alerts", { min: 0, int: true, def: 300_000 }),
|
|
225
|
+
};
|
|
226
|
+
return { mode, payer, apiUrl, apiKeyEnv, rpcUrlEnv, rpcWsUrlEnv, wallet, linkedWalletId, privyWalletId, privyAppId, signer, stateDir, loopMs, buy, sell, filters, detect, exit, retry, alerts, configDir: resolve(baseDir) };
|
|
227
|
+
}
|
|
228
|
+
export function loadConfig(path) {
|
|
229
|
+
let text;
|
|
230
|
+
try {
|
|
231
|
+
text = readFileSync(path, "utf8");
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
throw new ConfigError(`cannot read config ${path}: ${e.message}`);
|
|
235
|
+
}
|
|
236
|
+
let raw;
|
|
237
|
+
try {
|
|
238
|
+
raw = JSON.parse(text);
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
throw new ConfigError(`config ${path} is not valid JSON: ${e.message}`);
|
|
242
|
+
}
|
|
243
|
+
return parseConfig(raw, dirname(resolve(path)));
|
|
244
|
+
}
|