Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ pnpm --filter @ickb/supervisor build
pnpm live:supervisor
```

To rebuild the standard ignored testnet configs from environment variables without printing private keys, set `ICKB_TESTNET_BOT_PRIVATE_KEY` and `ICKB_TESTNET_TESTER_PRIVATE_KEY`, optionally set `ICKB_TESTNET_RPC_URL`, `ICKB_TESTNET_SLEEP_INTERVAL_SECONDS`, `ICKB_TESTNET_MAX_ITERATIONS`, and `ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS`, then run:

```bash
pnpm live:config-from-env
```

By default the supervisor uses ignored `config/bot-testnet.json` and `config/tester-testnet.json`, writes artifacts under ignored `logs/live-supervisor/<run-id>/` paths, and runs deterministic bounded bot/tester commands only. It does not patch, verify, rebuild, relaunch, or invoke an LLM; external loops and operators consume `summary.json` between runs.

For repeated bounded invocations, keep loop-owned options before `--` and supervisor options after it:
Expand Down
4 changes: 2 additions & 2 deletions apps/bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ The bot minimizes excess iCKB holdings so more liquidity stays available in CKB
The bot reads one strict JSON config file named by `BOT_CONFIG_FILE`:

```json
{"chain":"testnet","privateKey":"0x...","rpcUrl":"http://127.0.0.1:8114/","sleepIntervalSeconds":60}
{"chain":"testnet","privateKey":"0x...","sleepIntervalSeconds":60,"maxRetryableAttempts":10}
```

The JSON config accepts exactly `chain`, `privateKey`, `rpcUrl`, `sleepIntervalSeconds`, and optional `maxIterations`. Unknown keys, wrong types, non-HTTP(S) RPC URLs, whitespace/control characters in `rpcUrl`, and non-canonical private keys are rejected. The private key must be exactly lowercase `0x` plus 64 lowercase hex characters, with no newline, spaces, tabs, or comments. Local config files under `config/` are ignored by git.
The JSON config accepts exactly `chain`, `privateKey`, optional `rpcUrl`, `sleepIntervalSeconds`, optional `maxIterations`, and optional `maxRetryableAttempts`. Unknown keys, wrong types, non-HTTP(S) RPC URLs, whitespace/control characters in `rpcUrl`, and non-canonical private keys are rejected. Omitting `rpcUrl` lets CCC use its default endpoint. The private key must be exactly lowercase `0x` plus 64 lowercase hex characters, with no newline, spaces, tabs, or comments. Local config files under `config/` are ignored by git.

Current network support:

Expand Down
4 changes: 2 additions & 2 deletions apps/tester/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ The tester is now CCC-native. It waits while its own fresh matchable orders are
The tester reads one strict JSON config file named by `TESTER_CONFIG_FILE`:

```json
{"chain":"testnet","privateKey":"0x...","rpcUrl":"http://127.0.0.1:8114/","sleepIntervalSeconds":10,"maxIterations":1}
{"chain":"testnet","privateKey":"0x...","sleepIntervalSeconds":10,"maxIterations":1,"maxRetryableAttempts":10}
```

The JSON config accepts exactly `chain`, `privateKey`, `rpcUrl`, `sleepIntervalSeconds`, and optional `maxIterations`. Unknown keys, wrong types, non-HTTP(S) RPC URLs, whitespace/control characters in `rpcUrl`, and non-canonical private keys are rejected. The private key must be exactly lowercase `0x` plus 64 lowercase hex characters, with no newline, spaces, tabs, or comments. Local config files under `config/` are ignored by git.
The JSON config accepts exactly `chain`, `privateKey`, optional `rpcUrl`, `sleepIntervalSeconds`, optional `maxIterations`, and optional `maxRetryableAttempts`. Unknown keys, wrong types, non-HTTP(S) RPC URLs, whitespace/control characters in `rpcUrl`, and non-canonical private keys are rejected. Omitting `rpcUrl` lets CCC use its default endpoint. The private key must be exactly lowercase `0x` plus 64 lowercase hex characters, with no newline, spaces, tabs, or comments. Local config files under `config/` are ignored by git.

Current network support:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"forks:ccc:smoke": "node scripts/forks-ccc-smoke.mjs",
"forks:ccc:watch": "node scripts/forks-ccc.mjs --watch",
"live:generate-config": "node scripts/ickb-generate-config.mjs",
"live:config-from-env": "node scripts/ickb-live-config-from-env.mjs",
"live:preflight": "node scripts/ickb-live-preflight.mjs",
"live:supervisor": "node apps/supervisor/dist/index.js",
"live:supervisor:loop": "node scripts/ickb-supervisor-loop.mjs",
Expand Down
142 changes: 95 additions & 47 deletions scripts/ickb-generate-config.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { lstat, mkdir, open, realpath, writeFile } from "node:fs/promises";
import { link, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { defaultCheckIgnored } from "./ickb-live-config-git.mjs";

const rootDir = fileURLToPath(new URL("..", import.meta.url));
const SECP256K1_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141");
Expand All @@ -21,6 +21,7 @@ export function parseArgs(argv) {
role: "bot",
sleepIntervalSeconds: 1,
maxIterations: 1,
maxRetryableAttempts: 10,
force: false,
};
for (let index = 0; index < argv.length; index += 1) {
Expand Down Expand Up @@ -56,6 +57,14 @@ export function parseArgs(argv) {
args.maxIterations = parsePositiveInteger(valueAfter(argv, ++index, arg), arg);
continue;
}
if (arg === "--max-retryable-attempts") {
args.maxRetryableAttempts = parsePositiveInteger(valueAfter(argv, ++index, arg), arg);
continue;
}
if (arg === "--no-max-retryable-attempts") {
args.maxRetryableAttempts = undefined;
continue;
}
if (arg === "--no-max-iterations") {
args.maxIterations = undefined;
continue;
Expand All @@ -74,8 +83,8 @@ export function parseArgs(argv) {

export function usage() {
return [
"Usage: node scripts/ickb-generate-config.mjs [--chain testnet|mainnet] [--role <label>] [--out <ignored-json-config>] [--rpc-url <url>] [--sleep-interval-seconds <n>] [--max-iterations <n>|--no-max-iterations] [--force]",
"Defaults: --chain testnet --role bot --out config/<role>-<chain>.json --sleep-interval-seconds 1 --max-iterations 1",
"Usage: node scripts/ickb-generate-config.mjs [--chain testnet|mainnet] [--role <label>] [--out <ignored-json-config>] [--rpc-url <url>] [--sleep-interval-seconds <n>] [--max-iterations <n>|--no-max-iterations] [--max-retryable-attempts <n>|--no-max-retryable-attempts] [--force]",
"Defaults: --chain testnet --role bot --out config/<role>-<chain>.json --sleep-interval-seconds 1 --max-iterations 1 --max-retryable-attempts 10",
].join("\n");
}

Expand All @@ -99,13 +108,15 @@ export function buildRuntimeConfig({
rpcUrl,
sleepIntervalSeconds,
maxIterations,
maxRetryableAttempts,
}) {
return {
chain,
privateKey,
rpcUrl,
sleepIntervalSeconds,
...(maxIterations === undefined ? {} : { maxIterations }),
...(maxRetryableAttempts === undefined ? {} : { maxRetryableAttempts }),
};
}

Expand All @@ -114,26 +125,24 @@ export async function runGenerateConfig({ argv, root = rootDir, dependencies = {
if (args.help) {
return { help: usage() };
}
const originalRoot = resolve(root);
const resolvedRoot = await (dependencies.realpath ?? realpath)(originalRoot);

const privateKey = generateSecp256k1PrivateKey(dependencies.randomBytes ?? randomBytes);
const config = buildRuntimeConfig({ ...args, privateKey });
const output = outputPath(root, args.out);
assertIgnoredPath(root, output.relativePath, dependencies.checkIgnored);
await makeSafeParentDir(output.absolutePath, root, dependencies);
await writeConfigFile(
output.absolutePath,
`${JSON.stringify(config)}\n`,
args.force,
dependencies,
);
const output = outputPath(originalRoot, resolvedRoot, args.out);
assertIgnoredPath(resolvedRoot, output.relativePath, dependencies.checkIgnored);
await makeSafeParentDir(output.absolutePath, resolvedRoot, dependencies);
await writeStagedConfigFile(output.absolutePath, `${JSON.stringify(config)}\n`, args.force, dependencies);

return {
outputPath: output.relativePath,
role: args.role,
chain: args.chain,
redactedRpcUrl: redactRpcUrl(args.rpcUrl),
rpcConfigured: args.rpcUrl !== undefined,
sleepIntervalSeconds: args.sleepIntervalSeconds,
maxIterations: args.maxIterations,
maxRetryableAttempts: args.maxRetryableAttempts,
privateKey: "<written-to-config-file>",
};
}
Expand Down Expand Up @@ -205,39 +214,77 @@ function parseRpcUrl(value) {
return value;
}

function redactRpcUrl(rpcUrl) {
let url;
try {
url = new URL(rpcUrl);
} catch {
return "<invalid-url>";
function outputPath(originalRoot, resolvedRoot, out) {
const absolutePath = isAbsolute(out) ? out : resolve(resolvedRoot, out);
const relativePath = relative(resolvedRoot, absolutePath);
if (isInsideRelativePath(relativePath)) {
return { absolutePath, relativePath };
}

if (url.username !== "" || url.password !== "") {
url.username = "redacted";
url.password = url.password === "" ? "" : "redacted";
if (isAbsolute(out)) {
const originalRelativePath = relative(originalRoot, out);
if (isInsideRelativePath(originalRelativePath)) {
return {
absolutePath: resolve(resolvedRoot, originalRelativePath),
relativePath: originalRelativePath,
};
}
}
if (url.pathname !== "" && url.pathname !== "/") {
url.pathname = "/...";
throw new Error("Output path must stay inside the repo");
}

function isInsideRelativePath(relativePath) {
return !relativePath.startsWith("..") && !isAbsolute(relativePath);
}

async function writeStagedConfigFile(path, text, force, dependencies) {
const tempPath = tempConfigPath(path);
let caught;
try {
await writeConfigFile(tempPath, text, false, dependencies);
await installStagedConfig(tempPath, path, force, dependencies);
} catch (error) {
caught = error;
}
if (url.search !== "") {
const redactedParams = new URLSearchParams();
for (const [key] of url.searchParams) {
redactedParams.append(key, "redacted");
try {
await cleanupPath(tempPath, dependencies);
} catch (error) {
if (caught === undefined) {
throw error;
}
url.search = redactedParams.toString();
}
if (caught !== undefined) {
throw caught;
}
}

return url.toString();
function tempConfigPath(path) {
return `${path}.tmp-${process.pid}-${Date.now()}`;
}

function outputPath(root, out) {
const absolutePath = isAbsolute(out) ? out : resolve(root, out);
const relativePath = relative(root, absolutePath);
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
throw new Error("Output path must stay inside the repo");
async function installStagedConfig(tempPath, targetPath, force, dependencies) {
await assertNoSymlinkTarget(targetPath, dependencies);
if (force) {
await (dependencies.rename ?? rename)(tempPath, targetPath);
return;
}
try {
await (dependencies.link ?? link)(tempPath, targetPath);
} catch (error) {
if (isAlreadyExistsError(error)) {
throw new Error("Config already exists; rerun with --force to overwrite", { cause: error });
}
throw error;
}
}

async function cleanupPath(path, dependencies) {
try {
await (dependencies.unlink ?? unlink)(path);
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
}
return { absolutePath, relativePath };
}

async function writeConfigFile(path, text, force, dependencies) {
Expand Down Expand Up @@ -270,7 +317,7 @@ async function makeSafeParentDir(path, root, dependencies) {
await assertRealAncestor(current, dependencies);
break;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
if (isNotFoundError(error)) {
missing.push(current);
const next = dirname(current);
if (next === current) {
Expand Down Expand Up @@ -302,13 +349,21 @@ async function assertNoSymlinkTarget(path, dependencies) {
throw new Error("Refusing to write through symlink config path");
}
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
if (isNotFoundError(error)) {
return;
}
throw error;
}
}

function isNotFoundError(error) {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}

function isAlreadyExistsError(error) {
return error instanceof Error && "code" in error && error.code === "EEXIST";
}

async function assertRealParent(path, dependencies) {
const parent = dirname(path);
await assertRealAncestor(parent, dependencies);
Expand All @@ -324,13 +379,6 @@ function assertIgnoredPath(root, relativePath, checkIgnored = defaultCheckIgnore
}
}

function defaultCheckIgnored(root, relativePath) {
const result = spawnSync("git", ["-C", root, "check-ignore", "--", relativePath], {
encoding: "utf8",
});
return result.status === 0;
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
process.exit(await main(process.argv.slice(2)));
}
Loading
Loading