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
1 change: 1 addition & 0 deletions apps/bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"dependencies": {
"@ckb-ccc/core": "catalog:",
"@ickb/core": "workspace:*",
"@ickb/node-utils": "workspace:*",
"@ickb/order": "workspace:*",
"@ickb/sdk": "workspace:*",
"@ickb/utils": "workspace:*"
Expand Down
33 changes: 2 additions & 31 deletions apps/bot/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import { OrderManager } from "@ickb/order";
import { type IckbSdk } from "@ickb/sdk";
import { defaultFindCellsLimit } from "@ickb/utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { formatCkb, jsonLogReplacer } from "./log.js";
import { CKB, TARGET_ICKB_BALANCE } from "./policy.js";
import { buildTransaction, collectPoolDeposits, parseSleepInterval } from "./runtime.js";
import { TARGET_ICKB_BALANCE } from "./policy.js";
import { buildTransaction, collectPoolDeposits } from "./runtime.js";

afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -45,21 +44,6 @@ function readyDeposit(
} as unknown as IckbDepositCell;
}

describe("parseSleepInterval", () => {
it("rejects missing, non-finite, NaN, and sub-second intervals", () => {
for (const value of [undefined, "", "abc", "NaN", "Infinity", "0", "0.5"]) {
expect(() => parseSleepInterval(value, "BOT_SLEEP_INTERVAL")).toThrow(
"Invalid env BOT_SLEEP_INTERVAL",
);
}
});

it("returns milliseconds for valid second intervals", () => {
expect(parseSleepInterval("1", "BOT_SLEEP_INTERVAL")).toBe(1000);
expect(parseSleepInterval("2.5", "BOT_SLEEP_INTERVAL")).toBe(2500);
});
});

describe("collectPoolDeposits", () => {
it("fails closed when the public pool scan reaches the sentinel limit", async () => {
async function* deposits(): AsyncGenerator<IckbDepositCell> {
Expand Down Expand Up @@ -88,19 +72,6 @@ describe("collectPoolDeposits", () => {
});
});

describe("bot log formatting", () => {
it("formats CKB values without losing bigint precision", () => {
const whole = 123456789012345678901234567890n;

expect(formatCkb(whole * CKB + 12345670n)).toBe(`${whole.toString()}.1234567`);
expect(formatCkb(-CKB - 1n)).toBe("-1.00000001");
});

it("serializes bigint values as strings", () => {
expect(jsonLogReplacer("", 9007199254740993n)).toBe("9007199254740993");
});
});

describe("buildTransaction", () => {
it("skips match-only transactions when the completed fee consumes the match value", async () => {
vi.spyOn(OrderManager, "bestMatch").mockReturnValue({
Expand Down
109 changes: 26 additions & 83 deletions apps/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,24 @@ import {
IckbSdk,
projectAccountAvailability,
sendAndWaitForCommit,
TransactionConfirmationError,
} from "@ickb/sdk";
import {
createPublicClient,
formatCkb,
handleLoopError,
logExecution,
parseSleepInterval,
parseSupportedChain,
signerAccountLocks,
sleep,
STOP_EXIT_CODE,
} from "@ickb/node-utils";
import {
buildTransaction,
collectPoolDeposits,
parseSleepInterval,
type BotState,
type Runtime,
type SupportedChain,
} from "./runtime.js";
import { formatCkb, jsonLogReplacer } from "./log.js";

const STOP_EXIT_CODE = 2;

async function main(): Promise<void> {
const { CHAIN, RPC_URL, BOT_PRIVATE_KEY, BOT_SLEEP_INTERVAL } = process.env;
Expand All @@ -30,12 +35,13 @@ async function main(): Promise<void> {
}
const sleepInterval = parseSleepInterval(BOT_SLEEP_INTERVAL, "BOT_SLEEP_INTERVAL");

const chain = parseChain(CHAIN);
const client = createClient(chain, RPC_URL);
const chain = parseSupportedChain(CHAIN, "CHAIN");
const client = createPublicClient(chain, RPC_URL);
const config = getConfig(chain);
const { managers } = config;
const signer = new ccc.SignerCkbPrivateKey(client, BOT_PRIVATE_KEY);
const primaryLock = (await signer.getRecommendedAddressObj()).script;
const recommendedAddress = await signer.getRecommendedAddressObj();
const primaryLock = recommendedAddress.script;
const runtime: Runtime = {
chain,
client,
Expand Down Expand Up @@ -90,7 +96,7 @@ async function main(): Promise<void> {
fmtCkb(state.minCkbBalance) +
" CKB worth of capital to be able to operate, shutting down...";
process.exitCode = STOP_EXIT_CODE;
console.log(JSON.stringify(executionLog, jsonLogReplacer, " "));
logExecution(executionLog, startTime);
return;
}

Expand All @@ -110,36 +116,28 @@ async function main(): Promise<void> {
},
});
} catch (error) {
executionLog.error = errorToLog(error);
if (error instanceof TransactionConfirmationError && error.isTimeout) {
process.exitCode = STOP_EXIT_CODE;
stopAfterLog = true;
}
stopAfterLog = handleLoopError(executionLog, error);
}

executionLog.ElapsedSeconds = Math.round(
(Date.now() - startTime.getTime()) / 1000,
);
console.log(JSON.stringify(executionLog, jsonLogReplacer, " "));
logExecution(executionLog, startTime);
if (stopAfterLog) {
return;
}
}
}

async function readBotState(runtime: Runtime): Promise<BotState> {
const accountLocks = dedupeScripts(
(await runtime.signer.getAddressObjs()).map(({ script }) => script),
);
const { system, user } = await runtime.sdk.getL1State(
const accountLocks = await signerAccountLocks(runtime.signer, runtime.primaryLock);
const { system, user, account } = await runtime.sdk.getL1AccountState(
runtime.client,
accountLocks,
);

const [account, poolDeposits] = await Promise.all([
runtime.sdk.getAccountState(runtime.client, accountLocks, system.tip),
collectPoolDeposits(runtime.client, runtime.managers.logic, system.tip),
]);
const poolDeposits = await collectPoolDeposits(
runtime.client,
runtime.managers.logic,
system.tip,
);
await runtime.sdk.assertCurrentTip(runtime.client, system.tip);

const projection = projectAccountAvailability(account, user.orders, {
collectedOrdersAvailable: true,
Expand Down Expand Up @@ -177,67 +175,12 @@ async function readBotState(runtime: Runtime): Promise<BotState> {
};
}

function createClient(chain: SupportedChain, rpcUrl: string | undefined): ccc.Client {
const config = rpcUrl ? { url: rpcUrl } : undefined;
return chain === "mainnet"
? new ccc.ClientPublicMainnet(config)
: new ccc.ClientPublicTestnet(config);
}

function parseChain(chain: string): SupportedChain {
if (chain === "mainnet" || chain === "testnet") {
return chain;
}

throw new Error("Invalid env CHAIN: " + chain);
}

function dedupeScripts(scripts: ccc.Script[]): ccc.Script[] {
const seen = new Set<string>();
const unique: ccc.Script[] = [];

for (const script of scripts) {
const key = script.toHex();
if (seen.has(key)) {
continue;
}
seen.add(key);
unique.push(script);
}

return unique;
}

function outPointKey(outPoint: ccc.OutPoint): string {
return ccc.hexFrom(outPoint.toBytes());
}

const fmtCkb = formatCkb;

function errorToLog(error: unknown): unknown {
if (error instanceof Object && "stack" in error) {
const stack = error.stack ?? "";
return {
name: "name" in error ? error.name : undefined,
message:
"message" in error && typeof error.message === "string"
? error.message
: "Unknown error",
txHash: "txHash" in error ? error.txHash : undefined,
status: "status" in error ? error.status : undefined,
stack,
};
}

return error ?? "Empty Error";
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
18 changes: 0 additions & 18 deletions apps/bot/src/log.ts

This file was deleted.

52 changes: 0 additions & 52 deletions apps/bot/src/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
NEAR_READY_LOOKAHEAD_MS,
partitionPoolDeposits,
planRebalance,
selectReadyDeposits,
TARGET_ICKB_BALANCE,
} from "./policy.js";

Expand Down Expand Up @@ -78,37 +77,6 @@ describe("partitionPoolDeposits", () => {
});
});

describe("selectReadyDeposits", () => {
it("prefers the fullest valid subset under the target amount", () => {
const deposits = [{ udtValue: 4n }, { udtValue: 7n }, { udtValue: 3n }];

expect(selectReadyDeposits(deposits, 10n)).toEqual([
{ udtValue: 7n },
{ udtValue: 3n },
]);
});

it("respects the request limit", () => {
const deposits = [{ udtValue: 1n }, { udtValue: 1n }, { udtValue: 1n }];

expect(selectReadyDeposits(deposits, 10n, 2)).toEqual([
{ udtValue: 1n },
{ udtValue: 1n },
]);
});

it("keeps earlier-ranked deposits when equal-total subsets tie", () => {
const firstSix = { udtValue: 6n };
const firstFour = { udtValue: 4n };
const secondSix = { udtValue: 6n };
const secondFour = { udtValue: 4n };

expect(
selectReadyDeposits([firstSix, firstFour, secondSix, secondFour], 10n),
).toEqual([firstSix, firstFour]);
});
});

describe("planRebalance", () => {
it("does nothing when fewer than two output slots remain", () => {
expect(
Expand Down Expand Up @@ -585,15 +553,6 @@ describe("planRebalance", () => {
).toEqual({ kind: "none" });
});

it("uses a fuller bounded subset than the old greedy walk", () => {
const deposits = [readyDeposit(6n, 0n), readyDeposit(5n, 1n), readyDeposit(5n, 2n)];

expect(selectReadyDeposits(deposits, 10n)).toEqual([
deposits[1],
deposits[2],
]);
});

it("requests withdrawals when iCKB is above the target band and a crowded-bucket fit exists", () => {
const first = readyDeposit(4n, 20n * 60n * 1000n);
const second = readyDeposit(6n, 25n * 60n * 1000n);
Expand Down Expand Up @@ -976,17 +935,6 @@ describe("planRebalance", () => {
expect(plan.kind === "withdraw" ? plan.deposits : []).toHaveLength(30);
});

it("lets greedy fallback use later candidates beyond the bounded best-fit horizon", () => {
const deposits = [
...Array.from({ length: 30 }, () => readyDeposit(11n, 0n)),
readyDeposit(10n, 31n),
];

expect(selectReadyDeposits(deposits as never[], 10n, 1)).toEqual([
deposits[30],
]);
});

it("does nothing when iCKB is above target but a full withdrawal would cut below the buffer", () => {
expect(
planRebalance({
Expand Down
Loading
Loading