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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Book fills from the transaction, never from a quote.
|
|
3
|
+
* - token delta: the owner's post minus pre token balance for the mint (all of its accounts)
|
|
4
|
+
* - quote delta: the owner's native SOL change, plus any WSOL token change, with the network fee (when the
|
|
5
|
+
* owner paid it) and token-account rent (created or returned) separated out
|
|
6
|
+
*/
|
|
7
|
+
export interface Fill {
|
|
8
|
+
signature: string;
|
|
9
|
+
slot: number | null;
|
|
10
|
+
blockTimeMs: number | null;
|
|
11
|
+
/** Token raw units the owner gained (buy, > 0) or gave up (sell, < 0). */
|
|
12
|
+
tokenDeltaRaw: bigint;
|
|
13
|
+
/** Lamports the owner's SOL (native + WSOL) changed by, all-in (includes fee and rent). */
|
|
14
|
+
solDeltaLamports: bigint;
|
|
15
|
+
/** Network fee the owner paid as fee payer (0 when someone else paid). */
|
|
16
|
+
networkFeeLamports: bigint;
|
|
17
|
+
/** Lamports moved into (> 0) or back out of (< 0) the owner's token accounts for this mint: rent, not price. */
|
|
18
|
+
rentLamports: bigint;
|
|
19
|
+
/** Swap leg in lamports, net of the network fee and rent: what the trade itself cost (buy, < 0) or paid (sell, > 0). */
|
|
20
|
+
swapLamports: bigint;
|
|
21
|
+
err: unknown;
|
|
22
|
+
}
|
|
23
|
+
export declare class FillError extends Error {
|
|
24
|
+
}
|
|
25
|
+
export declare function fillFromTx(tx: any, owner: string, mint: string): Fill;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function keysOf(tx) {
|
|
2
|
+
const keys = (tx?.transaction?.message?.accountKeys ?? []).map((k) => (typeof k === "string" ? k : k?.pubkey));
|
|
3
|
+
const loaded = tx?.meta?.loadedAddresses;
|
|
4
|
+
if (loaded)
|
|
5
|
+
keys.push(...(loaded.writable ?? []), ...(loaded.readonly ?? []));
|
|
6
|
+
return keys;
|
|
7
|
+
}
|
|
8
|
+
const WSOL = "So11111111111111111111111111111111111111112";
|
|
9
|
+
function tokenSum(rows, owner, mint) {
|
|
10
|
+
let s = 0n;
|
|
11
|
+
for (const r of rows ?? [])
|
|
12
|
+
if (r?.owner === owner && r?.mint === mint)
|
|
13
|
+
s += BigInt(r?.uiTokenAmount?.amount ?? "0");
|
|
14
|
+
return s;
|
|
15
|
+
}
|
|
16
|
+
export class FillError extends Error {
|
|
17
|
+
}
|
|
18
|
+
export function fillFromTx(tx, owner, mint) {
|
|
19
|
+
if (!tx?.meta)
|
|
20
|
+
throw new FillError("transaction has no meta");
|
|
21
|
+
const meta = tx.meta;
|
|
22
|
+
const keys = keysOf(tx);
|
|
23
|
+
const i = keys.indexOf(owner);
|
|
24
|
+
const pre = meta.preBalances ?? [];
|
|
25
|
+
const post = meta.postBalances ?? [];
|
|
26
|
+
const native = i >= 0 ? BigInt(post[i] ?? 0) - BigInt(pre[i] ?? 0) : 0n;
|
|
27
|
+
const wsol = tokenSum(meta.postTokenBalances, owner, WSOL) - tokenSum(meta.preTokenBalances, owner, WSOL);
|
|
28
|
+
const tokenDelta = tokenSum(meta.postTokenBalances, owner, mint) - tokenSum(meta.preTokenBalances, owner, mint);
|
|
29
|
+
const fee = i === 0 ? BigInt(meta.fee ?? 0) : 0n;
|
|
30
|
+
// Rent: lamport change of the owner's token accounts for this mint (an account opened on a buy, closed on a sell).
|
|
31
|
+
const accts = new Set();
|
|
32
|
+
for (const r of [...(meta.preTokenBalances ?? []), ...(meta.postTokenBalances ?? [])])
|
|
33
|
+
if (r?.owner === owner && r?.mint === mint)
|
|
34
|
+
accts.add(r.accountIndex);
|
|
35
|
+
let rent = 0n;
|
|
36
|
+
for (const a of accts)
|
|
37
|
+
rent += BigInt(post[a] ?? 0) - BigInt(pre[a] ?? 0);
|
|
38
|
+
// An account closed in this tx drops out of postTokenBalances but keeps its index: covered by the set above.
|
|
39
|
+
const solDelta = native + wsol;
|
|
40
|
+
// The owner's SOL went to: the swap, the fee, and rent into its token account. Add the last two back.
|
|
41
|
+
const swap = solDelta + fee + rent;
|
|
42
|
+
return {
|
|
43
|
+
signature: tx.transaction?.signatures?.[0] ?? "",
|
|
44
|
+
slot: typeof tx.slot === "number" ? tx.slot : null,
|
|
45
|
+
blockTimeMs: typeof tx.blockTime === "number" ? tx.blockTime * 1000 : null,
|
|
46
|
+
tokenDeltaRaw: tokenDelta,
|
|
47
|
+
solDeltaLamports: solDelta,
|
|
48
|
+
networkFeeLamports: fee,
|
|
49
|
+
rentLamports: rent,
|
|
50
|
+
swapLamports: swap,
|
|
51
|
+
err: meta.err ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trade rail the executor drives: Candle's linked-payer flow for a TEE wallet, step by step, so the
|
|
3
|
+
* signature can be persisted BEFORE anything is broadcast:
|
|
4
|
+
* buildTrade (payer linked) -> signLinkedTransaction (our P-256 signer, through Candle's relay)
|
|
5
|
+
* -> [persist signature] -> submit (Candle broadcasts and confirms)
|
|
6
|
+
* The SDK's one-shot trade() hides the signature until it has been sent, so it is not used here.
|
|
7
|
+
*/
|
|
8
|
+
import { CandleClient, type BuildTradeRequest, type BuildTradeResult, type ExecutedTradeResult, type SecretStore, type TradeJob } from "@candledottv/agent-sdk";
|
|
9
|
+
import type { Config } from "../config.ts";
|
|
10
|
+
export interface TradeRail {
|
|
11
|
+
/** Who pays: a linked TEE wallet (signed here, submitted by Candle) or the embedded wallet (Candle signs and sends inside build). */
|
|
12
|
+
readonly payer: {
|
|
13
|
+
type: "linked";
|
|
14
|
+
linkedWalletId: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: "main";
|
|
17
|
+
};
|
|
18
|
+
build(req: BuildTradeRequest): Promise<BuildTradeResult>;
|
|
19
|
+
sign(unsignedTransactionBase64: string): Promise<string>;
|
|
20
|
+
submit(clientTradeId: string, signedTransactionBase64: string): Promise<ExecutedTradeResult>;
|
|
21
|
+
confirm(clientTradeId: string, signature: string): Promise<ExecutedTradeResult>;
|
|
22
|
+
/** The trade's row in Candle's idempotency ledger, or null when the write never began (JOB_NOT_FOUND). */
|
|
23
|
+
job(clientTradeId: string): Promise<TradeJob | null>;
|
|
24
|
+
}
|
|
25
|
+
/** base58 of a signed transaction's first signature (the fee payer's): its id on chain. */
|
|
26
|
+
export declare function signatureOfSigned(signedBase64: string): string;
|
|
27
|
+
/** base58 of the recent blockhash inside a signed transaction (legacy or v0 message). */
|
|
28
|
+
export declare function blockhashOfSigned(signedBase64: string): string;
|
|
29
|
+
/** A fetch that always carries a timeout (the SDK's own requests have none). */
|
|
30
|
+
export declare function timedFetch(timeoutMs: number, base?: typeof fetch): typeof fetch;
|
|
31
|
+
export type ExecFn = (binary: string, args: string[]) => Promise<{
|
|
32
|
+
status: number;
|
|
33
|
+
stdout: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** base64 PKCS#8 DER (the CLI's single-line form) or PEM -> PEM, checked to be a P-256 private key. */
|
|
36
|
+
export declare function toPem(stored: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* The Candle CLI's own signer storage (OS keychain, service tv.candle.cli):
|
|
39
|
+
* - a TEE wallet bound to the API key signs with the KEY signer (`candle tee signer new --key <prefix>`): keychain
|
|
40
|
+
* account `key_signer_<keyPrefix>_<spkiSha256>`, indexed in ~/.config/candle/config.json `keySigners.entries`
|
|
41
|
+
* by keyPrefix and signerQuorumId;
|
|
42
|
+
* - an imported linked wallet signs with its own wallet signer: account `wallet_signer_<linkedWalletId>`.
|
|
43
|
+
* get(linkedWalletId) returns the key signer when the wallet is the resolved TEE wallet, else the wallet signer.
|
|
44
|
+
* The stored key's public key is checked against the index's spkiSha256. Nothing secret is ever logged.
|
|
45
|
+
*/
|
|
46
|
+
export declare class CandleCliSignerStore implements SecretStore {
|
|
47
|
+
private exec;
|
|
48
|
+
private backend;
|
|
49
|
+
private configPath;
|
|
50
|
+
private keySigner;
|
|
51
|
+
constructor(opts?: {
|
|
52
|
+
exec?: ExecFn;
|
|
53
|
+
backend?: "security" | "secret-tool";
|
|
54
|
+
configPath?: string;
|
|
55
|
+
});
|
|
56
|
+
forTeeWallet(linkedWalletId: string, keyPrefix: string, quorumId: string | null): this;
|
|
57
|
+
private read;
|
|
58
|
+
get(walletRef: string): Promise<string | null>;
|
|
59
|
+
set(): Promise<void>;
|
|
60
|
+
delete(): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
export declare function makeSecretStore(cfg: Config, w: ResolvedWallet, env?: NodeJS.ProcessEnv): Promise<SecretStore>;
|
|
63
|
+
export interface ResolvedWallet {
|
|
64
|
+
linkedWalletId: string;
|
|
65
|
+
privyWalletId: string;
|
|
66
|
+
keyPrefix: string;
|
|
67
|
+
privyAppId: string;
|
|
68
|
+
signerQuorumId: string | null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The TEE wallet this API key may trade from (GET /api/v1/agent/wallets/trading, every page), with the key's prefix
|
|
72
|
+
* and Candle's Privy app id from the same answer. Config pins (linkedWalletId, privyWalletId, privyAppId) must match.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveWallet(cfg: Config, apiKey: string, f?: typeof fetch): Promise<ResolvedWallet>;
|
|
75
|
+
/**
|
|
76
|
+
* The account's embedded wallet: GET /api/v1/agent/wallets/embedded must name `wallet` as its Solana address, and the
|
|
77
|
+
* key must not be denied the wallet (embeddedWalletPermission) nor the wallet undelegated (Candle could not sign).
|
|
78
|
+
*/
|
|
79
|
+
export declare function resolveEmbedded(cfg: Config, apiKey: string, f?: typeof fetch): Promise<{
|
|
80
|
+
address: string;
|
|
81
|
+
permission: string | null;
|
|
82
|
+
}>;
|
|
83
|
+
/** payer main: buildTrade executes the trade inside the call (Candle signs and broadcasts); there is nothing to sign here. */
|
|
84
|
+
export declare class EmbeddedRail implements TradeRail {
|
|
85
|
+
readonly payer: {
|
|
86
|
+
type: "main";
|
|
87
|
+
};
|
|
88
|
+
private client;
|
|
89
|
+
constructor(client: CandleClient);
|
|
90
|
+
build(req: BuildTradeRequest): any;
|
|
91
|
+
sign(): Promise<string>;
|
|
92
|
+
submit(): Promise<ExecutedTradeResult>;
|
|
93
|
+
confirm(clientTradeId: string, signature: string): any;
|
|
94
|
+
job(clientTradeId: string): Promise<any>;
|
|
95
|
+
}
|
|
96
|
+
export declare class SdkRail implements TradeRail {
|
|
97
|
+
readonly payer: {
|
|
98
|
+
type: "linked";
|
|
99
|
+
linkedWalletId: string;
|
|
100
|
+
};
|
|
101
|
+
private client;
|
|
102
|
+
private w;
|
|
103
|
+
constructor(client: CandleClient, wallet: ResolvedWallet);
|
|
104
|
+
build(req: BuildTradeRequest): any;
|
|
105
|
+
sign(unsignedTransactionBase64: string): Promise<any>;
|
|
106
|
+
submit(clientTradeId: string, signed: string): any;
|
|
107
|
+
confirm(clientTradeId: string, signature: string): any;
|
|
108
|
+
job(clientTradeId: string): Promise<any>;
|
|
109
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trade rail the executor drives: Candle's linked-payer flow for a TEE wallet, step by step, so the
|
|
3
|
+
* signature can be persisted BEFORE anything is broadcast:
|
|
4
|
+
* buildTrade (payer linked) -> signLinkedTransaction (our P-256 signer, through Candle's relay)
|
|
5
|
+
* -> [persist signature] -> submit (Candle broadcasts and confirms)
|
|
6
|
+
* The SDK's one-shot trade() hides the signature until it has been sent, so it is not used here.
|
|
7
|
+
*/
|
|
8
|
+
import { CandleClient, EncryptedFileSecretStore, InMemorySecretStore, } from "@candledottv/agent-sdk";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { createHash, createPrivateKey, createPublicKey } from "node:crypto";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { b58encode } from "../solana/base58.js";
|
|
15
|
+
import { addSecret } from "../redact.js";
|
|
16
|
+
/** base58 of a signed transaction's first signature (the fee payer's): its id on chain. */
|
|
17
|
+
export function signatureOfSigned(signedBase64) {
|
|
18
|
+
const raw = Buffer.from(signedBase64, "base64");
|
|
19
|
+
let n = 0;
|
|
20
|
+
let shift = 0;
|
|
21
|
+
let i = 0;
|
|
22
|
+
for (; i < 3; i++) {
|
|
23
|
+
const x = raw[i];
|
|
24
|
+
n |= (x & 0x7f) << shift;
|
|
25
|
+
if (!(x & 0x80))
|
|
26
|
+
break;
|
|
27
|
+
shift += 7;
|
|
28
|
+
}
|
|
29
|
+
i++;
|
|
30
|
+
if (n < 1 || raw.length < i + 64)
|
|
31
|
+
throw new Error("signed transaction has no signature");
|
|
32
|
+
const sig = raw.subarray(i, i + 64);
|
|
33
|
+
if (sig.every((b) => b === 0))
|
|
34
|
+
throw new Error("signed transaction carries an empty signature");
|
|
35
|
+
return b58encode(sig);
|
|
36
|
+
}
|
|
37
|
+
/** base58 of the recent blockhash inside a signed transaction (legacy or v0 message). */
|
|
38
|
+
export function blockhashOfSigned(signedBase64) {
|
|
39
|
+
const raw = Buffer.from(signedBase64, "base64");
|
|
40
|
+
const vec = (at) => {
|
|
41
|
+
let n = 0, shift = 0, i = at;
|
|
42
|
+
for (; i < at + 3; i++) {
|
|
43
|
+
n |= (raw[i] & 0x7f) << shift;
|
|
44
|
+
if (!(raw[i] & 0x80))
|
|
45
|
+
return [n, i + 1];
|
|
46
|
+
shift += 7;
|
|
47
|
+
}
|
|
48
|
+
throw new Error("bad shortvec");
|
|
49
|
+
};
|
|
50
|
+
let [nsig, at] = vec(0);
|
|
51
|
+
at += 64 * nsig;
|
|
52
|
+
if (raw[at] & 0x80)
|
|
53
|
+
at += 1; // v0 version prefix
|
|
54
|
+
at += 3; // message header
|
|
55
|
+
const [nkeys, after] = vec(at);
|
|
56
|
+
at = after + 32 * nkeys;
|
|
57
|
+
if (raw.length < at + 32)
|
|
58
|
+
throw new Error("signed transaction too short for a blockhash");
|
|
59
|
+
return b58encode(raw.subarray(at, at + 32));
|
|
60
|
+
}
|
|
61
|
+
/** A fetch that always carries a timeout (the SDK's own requests have none). */
|
|
62
|
+
export function timedFetch(timeoutMs, base = fetch) {
|
|
63
|
+
return ((input, init = {}) => {
|
|
64
|
+
const t = AbortSignal.timeout(timeoutMs);
|
|
65
|
+
const signal = init.signal ? AbortSignal.any([init.signal, t]) : t;
|
|
66
|
+
return base(input, { ...init, signal });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const execFile = (binary, args) => new Promise((resolve) => {
|
|
70
|
+
try {
|
|
71
|
+
const c = spawn(binary, args, { stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 });
|
|
72
|
+
let out = "";
|
|
73
|
+
c.stdout.on("data", (d) => (out += d));
|
|
74
|
+
c.on("error", () => resolve({ status: 127, stdout: "" }));
|
|
75
|
+
c.on("close", (code) => resolve({ status: code ?? 1, stdout: out }));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
resolve({ status: 127, stdout: "" });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
/** base64 PKCS#8 DER (the CLI's single-line form) or PEM -> PEM, checked to be a P-256 private key. */
|
|
82
|
+
export function toPem(stored) {
|
|
83
|
+
const pem = stored.includes("BEGIN") ? stored : `-----BEGIN PRIVATE KEY-----\n${stored.replace(/\s+/g, "").replace(/(.{64})/g, "$1\n").replace(/\n$/, "")}\n-----END PRIVATE KEY-----\n`;
|
|
84
|
+
const k = createPrivateKey(pem);
|
|
85
|
+
if (k.asymmetricKeyType !== "ec" || k.asymmetricKeyDetails?.namedCurve !== "prime256v1")
|
|
86
|
+
throw new Error("signer key is not P-256");
|
|
87
|
+
return pem;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The Candle CLI's own signer storage (OS keychain, service tv.candle.cli):
|
|
91
|
+
* - a TEE wallet bound to the API key signs with the KEY signer (`candle tee signer new --key <prefix>`): keychain
|
|
92
|
+
* account `key_signer_<keyPrefix>_<spkiSha256>`, indexed in ~/.config/candle/config.json `keySigners.entries`
|
|
93
|
+
* by keyPrefix and signerQuorumId;
|
|
94
|
+
* - an imported linked wallet signs with its own wallet signer: account `wallet_signer_<linkedWalletId>`.
|
|
95
|
+
* get(linkedWalletId) returns the key signer when the wallet is the resolved TEE wallet, else the wallet signer.
|
|
96
|
+
* The stored key's public key is checked against the index's spkiSha256. Nothing secret is ever logged.
|
|
97
|
+
*/
|
|
98
|
+
export class CandleCliSignerStore {
|
|
99
|
+
exec;
|
|
100
|
+
backend;
|
|
101
|
+
configPath;
|
|
102
|
+
keySigner = null;
|
|
103
|
+
constructor(opts = {}) {
|
|
104
|
+
this.exec = opts.exec ?? execFile;
|
|
105
|
+
this.backend = opts.backend ?? (process.platform === "darwin" ? "security" : "secret-tool");
|
|
106
|
+
this.configPath = opts.configPath ?? join(homedir(), ".config", "candle", "config.json");
|
|
107
|
+
}
|
|
108
|
+
forTeeWallet(linkedWalletId, keyPrefix, quorumId) {
|
|
109
|
+
this.keySigner = { linkedWalletId, keyPrefix, quorumId };
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
async read(account) {
|
|
113
|
+
const r = this.backend === "security"
|
|
114
|
+
? await this.exec("security", ["find-generic-password", "-s", "tv.candle.cli", "-a", account, "-w"])
|
|
115
|
+
: await this.exec("secret-tool", ["lookup", "service", "tv.candle.cli", "account", account]);
|
|
116
|
+
const v = r.stdout.trim();
|
|
117
|
+
return r.status === 0 && v ? v : null;
|
|
118
|
+
}
|
|
119
|
+
async get(walletRef) {
|
|
120
|
+
if (this.keySigner && walletRef === this.keySigner.linkedWalletId) {
|
|
121
|
+
let entries = [];
|
|
122
|
+
try {
|
|
123
|
+
entries = JSON.parse(readFileSync(this.configPath, "utf8"))?.keySigners?.entries ?? [];
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const ks = this.keySigner;
|
|
129
|
+
const mine = entries.filter((e) => e && e.keyPrefix === ks.keyPrefix && (!ks.quorumId || e.signerQuorumId === ks.quorumId));
|
|
130
|
+
if (mine.length !== 1 || !/^[0-9a-f]{64}$/.test(String(mine[0].spkiSha256)))
|
|
131
|
+
return null;
|
|
132
|
+
const stored = await this.read(`key_signer_${ks.keyPrefix}_${mine[0].spkiSha256}`);
|
|
133
|
+
if (!stored)
|
|
134
|
+
return null;
|
|
135
|
+
const pem = toPem(stored);
|
|
136
|
+
addSecret(stored);
|
|
137
|
+
const spki = createPublicKey(pem).export({ type: "spki", format: "der" });
|
|
138
|
+
if (createHash("sha256").update(spki).digest("hex") !== mine[0].spkiSha256)
|
|
139
|
+
throw new Error("key signer in the keychain does not match the CLI's index");
|
|
140
|
+
return pem;
|
|
141
|
+
}
|
|
142
|
+
const stored = await this.read(`wallet_signer_${walletRef}`);
|
|
143
|
+
if (!stored)
|
|
144
|
+
return null;
|
|
145
|
+
addSecret(stored);
|
|
146
|
+
return toPem(stored);
|
|
147
|
+
}
|
|
148
|
+
async set() {
|
|
149
|
+
throw new Error("read-only: signers are created with the Candle CLI");
|
|
150
|
+
}
|
|
151
|
+
async delete() {
|
|
152
|
+
throw new Error("read-only");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export async function makeSecretStore(cfg, w, env = process.env) {
|
|
156
|
+
const s = cfg.signer;
|
|
157
|
+
if (!s)
|
|
158
|
+
throw new Error("config.signer is required to trade");
|
|
159
|
+
if (s.store === "keychain")
|
|
160
|
+
return new CandleCliSignerStore().forTeeWallet(w.linkedWalletId, w.keyPrefix, w.signerQuorumId);
|
|
161
|
+
if (s.store === "file") {
|
|
162
|
+
const pass = env[s.passphraseEnv];
|
|
163
|
+
if (!pass)
|
|
164
|
+
throw new Error(`signer.passphraseEnv: ${s.passphraseEnv} is not set`);
|
|
165
|
+
addSecret(pass);
|
|
166
|
+
return new EncryptedFileSecretStore(s.path, pass);
|
|
167
|
+
}
|
|
168
|
+
const pem = env[s.pemEnv];
|
|
169
|
+
if (!pem)
|
|
170
|
+
throw new Error(`signer.pemEnv: ${s.pemEnv} is not set`);
|
|
171
|
+
addSecret(pem);
|
|
172
|
+
for (const line of pem.split(/\s+/))
|
|
173
|
+
if (line.length >= 16 && !line.startsWith("-----"))
|
|
174
|
+
addSecret(line);
|
|
175
|
+
const m = new InMemorySecretStore();
|
|
176
|
+
await m.set(w.linkedWalletId, toPem(pem));
|
|
177
|
+
return m;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The TEE wallet this API key may trade from (GET /api/v1/agent/wallets/trading, every page), with the key's prefix
|
|
181
|
+
* and Candle's Privy app id from the same answer. Config pins (linkedWalletId, privyWalletId, privyAppId) must match.
|
|
182
|
+
*/
|
|
183
|
+
export async function resolveWallet(cfg, apiKey, f = fetch) {
|
|
184
|
+
const get = async (path) => {
|
|
185
|
+
const r = await f(cfg.apiUrl + path, { headers: { "x-api-key": apiKey, accept: "application/json" }, signal: AbortSignal.timeout(20_000) });
|
|
186
|
+
if (!r.ok)
|
|
187
|
+
throw new Error(`GET ${path.split("?")[0]}: HTTP ${r.status}`);
|
|
188
|
+
return (await r.json());
|
|
189
|
+
};
|
|
190
|
+
const rows = [];
|
|
191
|
+
let head = null;
|
|
192
|
+
let cursor = null;
|
|
193
|
+
for (let page = 0; page < 20; page++) {
|
|
194
|
+
const body = await get(`/api/v1/agent/wallets/trading${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`);
|
|
195
|
+
head ??= body;
|
|
196
|
+
rows.push(...(body.page ?? []));
|
|
197
|
+
if (body.isDone !== false || !body.continueCursor)
|
|
198
|
+
break;
|
|
199
|
+
cursor = body.continueCursor;
|
|
200
|
+
}
|
|
201
|
+
const row = rows.find((r) => r.address === cfg.wallet && r.chain === "solana" && r.active !== false);
|
|
202
|
+
if (!row)
|
|
203
|
+
throw new Error(`wallet ${cfg.wallet} is not a TEE wallet this API key can trade from (GET /api/v1/agent/wallets/trading)`);
|
|
204
|
+
const id = row.id ?? row._id;
|
|
205
|
+
if (!id || !row.privyWalletId)
|
|
206
|
+
throw new Error(`wallet ${cfg.wallet}: Candle returned no wallet id or Privy wallet id`);
|
|
207
|
+
const appId = head?.privyAppId ?? cfg.privyAppId;
|
|
208
|
+
if (!appId)
|
|
209
|
+
throw new Error("Candle returned no privyAppId for this key; set config.privyAppId");
|
|
210
|
+
if (cfg.privyAppId && cfg.privyAppId !== appId)
|
|
211
|
+
throw new Error("config.privyAppId does not match the one Candle returns for this key");
|
|
212
|
+
if (cfg.linkedWalletId && cfg.linkedWalletId !== id)
|
|
213
|
+
throw new Error("config.linkedWalletId does not match the wallet's row");
|
|
214
|
+
if (cfg.privyWalletId && cfg.privyWalletId !== row.privyWalletId)
|
|
215
|
+
throw new Error("config.privyWalletId does not match the wallet's row");
|
|
216
|
+
let quorum = row.signerQuorumId ?? null;
|
|
217
|
+
if (!quorum) {
|
|
218
|
+
try {
|
|
219
|
+
const all = await get("/api/v1/agent/wallets");
|
|
220
|
+
quorum = (all.page ?? []).find((r) => (r._id ?? r.id) === id)?.signerQuorumId ?? null;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
quorum = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { linkedWalletId: id, privyWalletId: row.privyWalletId, keyPrefix: String(head?.keyPrefix ?? ""), privyAppId: appId, signerQuorumId: quorum };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The account's embedded wallet: GET /api/v1/agent/wallets/embedded must name `wallet` as its Solana address, and the
|
|
230
|
+
* key must not be denied the wallet (embeddedWalletPermission) nor the wallet undelegated (Candle could not sign).
|
|
231
|
+
*/
|
|
232
|
+
export async function resolveEmbedded(cfg, apiKey, f = fetch) {
|
|
233
|
+
const r = await f(cfg.apiUrl + "/api/v1/agent/wallets/embedded", { headers: { "x-api-key": apiKey, accept: "application/json" }, signal: AbortSignal.timeout(20_000) });
|
|
234
|
+
if (!r.ok)
|
|
235
|
+
throw new Error(`GET /api/v1/agent/wallets/embedded: HTTP ${r.status}`);
|
|
236
|
+
const b = (await r.json());
|
|
237
|
+
const sol = b?.wallets?.solana ?? {};
|
|
238
|
+
if (sol.address !== cfg.wallet)
|
|
239
|
+
throw new Error(`wallet ${cfg.wallet} is not this API key's embedded Solana wallet (Candle names ${sol.address ?? "none"})`);
|
|
240
|
+
if (b?.embeddedWalletPermission === "denied")
|
|
241
|
+
throw new Error("this API key is denied the embedded wallet (embeddedWalletPermission: denied)");
|
|
242
|
+
if (sol.delegated === false)
|
|
243
|
+
throw new Error("the embedded wallet is not delegated: Candle cannot sign for it");
|
|
244
|
+
return { address: sol.address, permission: b?.embeddedWalletPermission ?? null };
|
|
245
|
+
}
|
|
246
|
+
/** payer main: buildTrade executes the trade inside the call (Candle signs and broadcasts); there is nothing to sign here. */
|
|
247
|
+
export class EmbeddedRail {
|
|
248
|
+
payer = { type: "main" };
|
|
249
|
+
client;
|
|
250
|
+
constructor(client) {
|
|
251
|
+
this.client = client;
|
|
252
|
+
}
|
|
253
|
+
build(req) {
|
|
254
|
+
return this.client.buildTrade(req);
|
|
255
|
+
}
|
|
256
|
+
async sign() {
|
|
257
|
+
throw Object.assign(new Error("payer main has nothing to sign"), { code: "NOT_SIGNABLE", status: 400 });
|
|
258
|
+
}
|
|
259
|
+
async submit() {
|
|
260
|
+
throw Object.assign(new Error("payer main has nothing to submit"), { code: "NOT_SUBMITTABLE", status: 400 });
|
|
261
|
+
}
|
|
262
|
+
confirm(clientTradeId, signature) {
|
|
263
|
+
return this.client.confirmTrade({ clientTradeId, signature });
|
|
264
|
+
}
|
|
265
|
+
async job(clientTradeId) {
|
|
266
|
+
try {
|
|
267
|
+
return await this.client.getTradeJob(clientTradeId);
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
if (e?.code === "JOB_NOT_FOUND")
|
|
271
|
+
return null;
|
|
272
|
+
throw e;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export class SdkRail {
|
|
277
|
+
payer;
|
|
278
|
+
client;
|
|
279
|
+
w;
|
|
280
|
+
constructor(client, wallet) {
|
|
281
|
+
this.client = client;
|
|
282
|
+
this.w = wallet;
|
|
283
|
+
this.payer = { type: "linked", linkedWalletId: wallet.linkedWalletId };
|
|
284
|
+
}
|
|
285
|
+
build(req) {
|
|
286
|
+
return this.client.buildTrade(req);
|
|
287
|
+
}
|
|
288
|
+
async sign(unsignedTransactionBase64) {
|
|
289
|
+
const r = await this.client.signLinkedTransaction({ linkedWalletId: this.w.linkedWalletId, privyWalletId: this.w.privyWalletId, chain: "solana", unsignedTransactionBase64 });
|
|
290
|
+
if (r.encoding && r.encoding !== "base64")
|
|
291
|
+
throw new Error(`sign relay returned encoding ${r.encoding}`);
|
|
292
|
+
return r.signedTransaction;
|
|
293
|
+
}
|
|
294
|
+
submit(clientTradeId, signed) {
|
|
295
|
+
return this.client.submit({ clientTradeId, signedTransactions: [signed] });
|
|
296
|
+
}
|
|
297
|
+
confirm(clientTradeId, signature) {
|
|
298
|
+
return this.client.confirmTrade({ clientTradeId, signature });
|
|
299
|
+
}
|
|
300
|
+
async job(clientTradeId) {
|
|
301
|
+
try {
|
|
302
|
+
return await this.client.getTradeJob(clientTradeId);
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
if (e?.code === "JOB_NOT_FOUND")
|
|
306
|
+
return null;
|
|
307
|
+
throw e;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry filters. A verdict is `buy`, `skip` (final, with a reason), or `wait` (a field the filter needs is
|
|
3
|
+
* not known yet: retried each cycle until it is, or the coin ages past maxAgeSec). Unknown never passes.
|
|
4
|
+
*/
|
|
5
|
+
import type { Config } from "./config.ts";
|
|
6
|
+
import type { Launch } from "./detect/detector.ts";
|
|
7
|
+
import type { CandleCurve } from "./market/curve.ts";
|
|
8
|
+
export type Verdict = {
|
|
9
|
+
verdict: "buy";
|
|
10
|
+
impactBps: number;
|
|
11
|
+
expectedOutRaw: bigint;
|
|
12
|
+
} | {
|
|
13
|
+
verdict: "skip";
|
|
14
|
+
reason: string;
|
|
15
|
+
} | {
|
|
16
|
+
verdict: "wait";
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
export interface Caps {
|
|
20
|
+
open: number;
|
|
21
|
+
entriesForMint: number;
|
|
22
|
+
lifetimeLamports: bigint;
|
|
23
|
+
paused: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function lamportsOf(sol: number): bigint;
|
|
26
|
+
export declare function evaluate(cfg: Config, l: Launch, curve: CandleCurve | null | "unreadable", caps: Caps, nowMs: number): Verdict;
|
package/dist/filters.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { buyImpact, curveClass } from "./market/curve.js";
|
|
2
|
+
import { WSOL } from "./solana/pda.js";
|
|
3
|
+
export function lamportsOf(sol) {
|
|
4
|
+
return BigInt(Math.round(sol * 1e9));
|
|
5
|
+
}
|
|
6
|
+
export function evaluate(cfg, l, curve, caps, nowMs) {
|
|
7
|
+
const f = cfg.filters;
|
|
8
|
+
if (!f.chains.includes(l.chain))
|
|
9
|
+
return { verdict: "skip", reason: `chain ${l.chain}` };
|
|
10
|
+
if (l.launchedAtMs === null)
|
|
11
|
+
return { verdict: "skip", reason: "launch time unknown" };
|
|
12
|
+
const ageSec = (nowMs - l.launchedAtMs) / 1000;
|
|
13
|
+
if (ageSec > f.maxAgeSec)
|
|
14
|
+
return { verdict: "skip", reason: `age ${Math.round(ageSec)}s > ${f.maxAgeSec}s` };
|
|
15
|
+
// The bot never buys a launch by its own wallet.
|
|
16
|
+
if (l.creator === cfg.wallet || l.signer === cfg.wallet)
|
|
17
|
+
return { verdict: "skip", reason: "own wallet's launch" };
|
|
18
|
+
if (f.creatorDeny?.length) {
|
|
19
|
+
const who = [l.creator, l.signer].filter(Boolean);
|
|
20
|
+
if (who.some((w) => f.creatorDeny.includes(w)))
|
|
21
|
+
return { verdict: "skip", reason: "creator denied" };
|
|
22
|
+
if (!l.creator)
|
|
23
|
+
return { verdict: "wait", reason: "creator unknown (deny list)" };
|
|
24
|
+
}
|
|
25
|
+
if (f.creatorAllow?.length) {
|
|
26
|
+
const who = [l.creator, l.signer].filter(Boolean);
|
|
27
|
+
if (!who.some((w) => f.creatorAllow.includes(w)))
|
|
28
|
+
return l.creator ? { verdict: "skip", reason: "creator not allowed" } : { verdict: "wait", reason: "creator unknown (allow list)" };
|
|
29
|
+
}
|
|
30
|
+
if (!l.launchMode) {
|
|
31
|
+
// A chain-first launch: Candle's list may not have it yet. The curve account says open / exclusive / exp-*.
|
|
32
|
+
if (!f.inferModeFromCurve)
|
|
33
|
+
return { verdict: "wait", reason: "launch mode unknown" };
|
|
34
|
+
if (curve === null)
|
|
35
|
+
return { verdict: "wait", reason: "curve not readable yet" };
|
|
36
|
+
if (curve === "unreadable")
|
|
37
|
+
return { verdict: "skip", reason: "curve unreadable" };
|
|
38
|
+
// Only a full match of the open preset decides here; every other curve waits for Candle's `tier`.
|
|
39
|
+
if (curveClass(curve) !== "open")
|
|
40
|
+
return { verdict: "wait", reason: "launch mode unknown" };
|
|
41
|
+
if (!f.launchModes.includes("open"))
|
|
42
|
+
return { verdict: "skip", reason: "mode open (from the curve preset)" };
|
|
43
|
+
}
|
|
44
|
+
else if (!f.launchModes.includes(l.launchMode))
|
|
45
|
+
return { verdict: "skip", reason: `mode ${l.launchMode}` };
|
|
46
|
+
if (f.nameMatch) {
|
|
47
|
+
if (l.name === undefined)
|
|
48
|
+
return { verdict: "wait", reason: "name unknown" };
|
|
49
|
+
if (!new RegExp(f.nameMatch, "i").test(l.name))
|
|
50
|
+
return { verdict: "skip", reason: "name does not match" };
|
|
51
|
+
}
|
|
52
|
+
if (f.symbolMatch) {
|
|
53
|
+
if (l.symbol === undefined)
|
|
54
|
+
return { verdict: "wait", reason: "symbol unknown" };
|
|
55
|
+
if (!new RegExp(f.symbolMatch, "i").test(l.symbol))
|
|
56
|
+
return { verdict: "skip", reason: "symbol does not match" };
|
|
57
|
+
}
|
|
58
|
+
if (caps.paused)
|
|
59
|
+
return { verdict: "skip", reason: "PAUSE_BUYS" };
|
|
60
|
+
if (caps.entriesForMint >= cfg.buy.maxPerCoin)
|
|
61
|
+
return { verdict: "skip", reason: `maxPerCoin ${cfg.buy.maxPerCoin}` };
|
|
62
|
+
if (caps.open >= cfg.buy.maxOpen)
|
|
63
|
+
return { verdict: "skip", reason: `maxOpen ${cfg.buy.maxOpen}` };
|
|
64
|
+
const ticket = lamportsOf(cfg.buy.sol);
|
|
65
|
+
if (caps.lifetimeLamports + ticket > lamportsOf(cfg.buy.lifetimeSol))
|
|
66
|
+
return { verdict: "skip", reason: `lifetimeSol ${cfg.buy.lifetimeSol}` };
|
|
67
|
+
if (curve === null)
|
|
68
|
+
return { verdict: "wait", reason: "curve not readable yet" };
|
|
69
|
+
if (curve === "unreadable")
|
|
70
|
+
return { verdict: "skip", reason: "curve unreadable" };
|
|
71
|
+
if (curve.baseMint !== l.mint)
|
|
72
|
+
return { verdict: "skip", reason: "curve names another mint" };
|
|
73
|
+
if (curve.quoteMint !== WSOL)
|
|
74
|
+
return { verdict: "skip", reason: "curve is not SOL-quoted" };
|
|
75
|
+
if (curve.migrated)
|
|
76
|
+
return { verdict: "skip", reason: "already graduated" };
|
|
77
|
+
if (curve.quoteReserve >= curve.migrationThreshold)
|
|
78
|
+
return { verdict: "skip", reason: "curve full (migrating)" };
|
|
79
|
+
let impact;
|
|
80
|
+
let expectedOutRaw;
|
|
81
|
+
try {
|
|
82
|
+
const r = buyImpact(curve, ticket);
|
|
83
|
+
impact = r.impactBps;
|
|
84
|
+
expectedOutRaw = r.expectedOutRaw;
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
return { verdict: "skip", reason: e.message };
|
|
88
|
+
}
|
|
89
|
+
if (impact > f.maxImpactBps)
|
|
90
|
+
return { verdict: "skip", reason: `impact ${impact} bps > ${f.maxImpactBps}` };
|
|
91
|
+
return { verdict: "buy", impactBps: impact, expectedOutRaw };
|
|
92
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Library entry: the types a custom exit strategy is written against, and the config parser. */
|
|
2
|
+
export type { Decision, ExitStrategy, MarketView, PositionView, StrategyFactory } from "./strategies/types.ts";
|
|
3
|
+
export { HOLD, StrategyConfigError } from "./strategies/types.ts";
|
|
4
|
+
export { parseConfig, loadConfig, ConfigError, LAUNCH_MODES } from "./config.ts";
|
|
5
|
+
export type { Config } from "./config.ts";
|
package/dist/index.js
ADDED
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** A valid address that is nobody's wallet: lets a dry run start before a wallet is chosen. */
|
|
2
|
+
export declare const PLACEHOLDER_WALLET = "11111111111111111111111111111111";
|
|
3
|
+
export interface InitIO {
|
|
4
|
+
env: Record<string, string | undefined>;
|
|
5
|
+
fetch: typeof fetch;
|
|
6
|
+
out: (s: string) => void;
|
|
7
|
+
/** Ask a question (interactive only). Returns the answer, or the default on an empty line. */
|
|
8
|
+
ask?: (q: string, def: string) => Promise<string>;
|
|
9
|
+
}
|
|
10
|
+
type Opt = Record<string, string>;
|
|
11
|
+
/** Build the config object from flags (and answers), validate it, and return it with notes for the user. */
|
|
12
|
+
export declare function buildInitConfig(opt: Opt, io: InitIO): Promise<{
|
|
13
|
+
config: Record<string, unknown>;
|
|
14
|
+
notes: string[];
|
|
15
|
+
}>;
|
|
16
|
+
/** The `init` command. Returns a process exit code. */
|
|
17
|
+
export declare function init(opt: Opt, io: InitIO): Promise<number>;
|
|
18
|
+
/** Terminal prompts for `init` (only when stdin is a TTY and --yes is not given). */
|
|
19
|
+
export declare function terminalAsk(): {
|
|
20
|
+
ask: (q: string, def: string) => Promise<string>;
|
|
21
|
+
close: () => void;
|
|
22
|
+
};
|
|
23
|
+
export {};
|