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
154 changes: 151 additions & 3 deletions apps/bot/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TARGET_ICKB_BALANCE } from "./policy.js";
import { completeTerminalIteration, readBotRuntimeConfig } from "./index.js";
import { buildTransaction, collectPoolDeposits } from "./runtime.js";
import { buildTransaction, collectPoolDeposits, postTransactionPlainCkbBalance } from "./runtime.js";

afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -52,6 +52,7 @@ function readyDeposit(
function botState(overrides: Record<string, unknown>): Record<string, unknown> {
return {
accountLocks: [],
capacityCells: [],
marketOrders: [],
availableCkbBalance: 0n,
availableIckbBalance: 0n,
Expand Down Expand Up @@ -93,6 +94,10 @@ function botRuntime(overrides: {
addMatch: (txLike: ccc.TransactionLike): ccc.Transaction =>
ccc.Transaction.from(txLike),
},
logic: {
deposit: (txLike: ccc.TransactionLike): Promise<ccc.Transaction> =>
Promise.resolve(ccc.Transaction.from(txLike)),
},
},
sdk: {
buildBaseTransaction: async (
Expand Down Expand Up @@ -190,6 +195,99 @@ describe("readBotRuntimeConfig", () => {
});

describe("buildTransaction", () => {
it("preserves the bot plain CKB reserve when matching orders", async () => {
const bestMatch = vi.spyOn(OrderManager, "bestMatch").mockReturnValue({
ckbDelta: 0n,
udtDelta: 0n,
partials: [],
});

await buildTransaction(botRuntime() as never, botState({
availableCkbBalance: ccc.fixedPointFrom(5000),
availableIckbBalance: TARGET_ICKB_BALANCE,
}) as never);

expect(bestMatch.mock.calls[0]?.[1]).toMatchObject({
ckbValue: ccc.fixedPointFrom(4000),
});
});

it("skips built transactions that would violate the bot plain CKB reserve", async () => {
const lock = script("11");
const spent = capacityCell(1000n, lock, "77");
vi.spyOn(OrderManager, "bestMatch").mockReturnValue({
ckbDelta: -1n,
udtDelta: 0n,
partials: [{} as never],
});
vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n);
const runtime = botRuntime({
primaryLock: lock,
sdk: {
completeTransaction: async (txLike: ccc.TransactionLike): Promise<ccc.Transaction> => {
await Promise.resolve();
const tx = ccc.Transaction.from(txLike);
tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint }));
tx.addOutput({ capacity: 1n, lock });
return tx;
},
},
});
const state = botState({
accountLocks: [lock],
capacityCells: [spent],
marketOrders: [{}],
availableCkbBalance: ccc.fixedPointFrom(5000),
availableIckbBalance: TARGET_ICKB_BALANCE,
totalCkbBalance: ccc.fixedPointFrom(5000),
});

await expect(buildTransaction(runtime as never, state as never)).resolves.toMatchObject({
kind: "skipped",
reason: "post_tx_ckb_reserve",
decision: { skip: { reason: "post_tx_ckb_reserve" } },
});
});

it("allows CKB-replenishing transactions even when plain CKB remains below reserve", async () => {
const lock = script("11");
const spent = capacityCell(1000n, lock, "78");
vi.spyOn(OrderManager, "bestMatch").mockReturnValue({
ckbDelta: 1n,
udtDelta: 0n,
partials: [],
});
vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n);
const runtime = botRuntime({
primaryLock: lock,
sdk: {
completeTransaction: async (txLike: ccc.TransactionLike): Promise<ccc.Transaction> => {
await Promise.resolve();
const tx = ccc.Transaction.from(txLike);
tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint }));
tx.addOutput({ capacity: 1n, lock });
return tx;
},
},
});
const state = botState({
accountLocks: [lock],
capacityCells: [spent],
readyWithdrawals: [{}],
availableCkbBalance: 1000n,
availableIckbBalance: TARGET_ICKB_BALANCE,
totalCkbBalance: 1000n,
});

const result = await buildTransaction(runtime as never, state as never);

expect(result).toMatchObject({
kind: "built",
actions: { withdrawals: 1 },
});
expect(result.decision.skip).toBeUndefined();
});

it("skips match-only transactions when the completed fee consumes the match value", async () => {
vi.spyOn(OrderManager, "bestMatch").mockReturnValue({
ckbDelta: 1n,
Expand All @@ -198,10 +296,14 @@ describe("buildTransaction", () => {
});
vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n);

const runtime = botRuntime();
const lock = script("11");
const runtime = botRuntime({ primaryLock: lock });
const state = botState({
accountLocks: [lock],
capacityCells: [capacityCell(ccc.fixedPointFrom(2000), lock, "66")],
marketOrders: [{}],
availableCkbBalance: 100n,
availableIckbBalance: TARGET_ICKB_BALANCE,
totalCkbBalance: 100n,
});

Expand All @@ -226,10 +328,14 @@ describe("buildTransaction", () => {
});
vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n);

const runtime = botRuntime();
const lock = script("11");
const runtime = botRuntime({ primaryLock: lock });
const state = botState({
accountLocks: [lock],
capacityCells: [capacityCell(ccc.fixedPointFrom(2000), lock, "67")],
marketOrders: [{}],
availableCkbBalance: 100n,
availableIckbBalance: TARGET_ICKB_BALANCE,
totalCkbBalance: 100n,
system: {
feeRate: 1n,
Expand Down Expand Up @@ -311,6 +417,8 @@ describe("buildTransaction", () => {
primaryLock: script("44"),
});
const state = botState({
accountLocks: [script("44")],
capacityCells: [capacityCell(ccc.fixedPointFrom(2000), script("44"), "68")],
marketOrders: [],
availableIckbBalance: TARGET_ICKB_BALANCE + 9n,
depositCapacity: 1000n,
Expand All @@ -335,3 +443,43 @@ describe("buildTransaction", () => {
expect(result.tx.cellDeps).toEqual([]);
});
});

describe("postTransactionPlainCkbBalance", () => {
it("counts unspent account plain CKB plus account plain outputs", () => {
const lock = script("11");
const otherLock = script("22");
const spent = capacityCell(ccc.fixedPointFrom(1000), lock, "aa");
const unspent = capacityCell(ccc.fixedPointFrom(2000), lock, "bb");
const typed = ccc.Cell.from({
outPoint: { txHash: hash("cc"), index: 0n },
cellOutput: { capacity: ccc.fixedPointFrom(4000), lock, type: script("33") },
outputData: "0x",
});
const data = ccc.Cell.from({
outPoint: { txHash: hash("dd"), index: 0n },
cellOutput: { capacity: ccc.fixedPointFrom(8000), lock },
outputData: "0x1234",
});
const tx = ccc.Transaction.default();
tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint }));
tx.outputs.push(
ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(300), lock }),
ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(500), lock, type: script("33") }),
ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(700), lock: otherLock }),
);
tx.outputsData.push("0x", "0x", "0x");

expect(postTransactionPlainCkbBalance(
tx,
botState({ accountLocks: [lock], capacityCells: [spent, unspent, typed, data] }) as never,
)).toBe(ccc.fixedPointFrom(2300));
});
});

function capacityCell(capacity: bigint, lock: ccc.Script, txByte: string): ccc.Cell {
return ccc.Cell.from({
outPoint: { txHash: hash(txByte), index: 0n },
cellOutput: { capacity, lock },
outputData: "0x",
});
}
19 changes: 16 additions & 3 deletions apps/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
sleep,
STOP_EXIT_CODE,
type RuntimeConfig,
verifyChainPreflight,
} from "@ickb/node-utils";
import {
buildTransaction,
Expand All @@ -40,13 +41,15 @@ import {
async function main(): Promise<void> {
const runtimeConfig = await readBotRuntimeConfig(process.env);
const { chain, privateKey, rpcUrl, sleepIntervalMs, maxIterations } = runtimeConfig;
const secrets = { privateKey, rpcUrl };
const runId = createRunId();
const events = new BotEventEmitter({ chain, runId });
events.emit(0, "bot.run.started", {
maxIterations,
bounded: maxIterations !== undefined,
});
const client = createPublicClient(chain, rpcUrl);
await verifyChainPreflight(client, chain);
const config = getConfig(chain);
const { managers } = config;
const signer = new ccc.SignerCkbPrivateKey(client, privateKey);
Expand Down Expand Up @@ -133,7 +136,7 @@ async function main(): Promise<void> {
executionLog.txHash = txHash;
},
onLifecycle: (event) => {
for (const lifecycle of transactionLifecycleEvents(event)) {
for (const lifecycle of transactionLifecycleEvents(event, secrets)) {
events.emit(iterationId, lifecycle.type, {
...lifecycle.fields,
...(event.type === "broadcasted"
Expand All @@ -145,10 +148,14 @@ async function main(): Promise<void> {
});
}
} catch (error) {
stopAfterLog = handleLoopError(executionLog, error);
stopAfterLog = handleLoopError(executionLog, error, secrets);
events.emit(iterationId, "bot.iteration.failed", {
error: errorSummary(error),
error: errorSummary(error, secrets),
});
if (isRetryableBotError(error)) {
logExecution(executionLog, startTime);
continue;
}
}

const completion = completeTerminalIteration(completedIterations, maxIterations);
Expand Down Expand Up @@ -211,6 +218,7 @@ async function readBotState(runtime: Runtime): Promise<BotState> {

return {
accountLocks,
capacityCells: account.capacityCells,
system,
userOrders: user.orders,
marketOrders,
Expand All @@ -235,6 +243,11 @@ function outPointKey(outPoint: ccc.OutPoint): string {

const fmtCkb = formatCkb;

function isRetryableBotError(error: unknown): boolean {
return error instanceof Error && error.message.includes("L1 state scan crossed chain tip");
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
process.exit(process.exitCode ?? 0);
}
55 changes: 55 additions & 0 deletions apps/bot/src/observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,61 @@ describe("bot observability", () => {
});
});

it("redacts runtime secrets in structured error summaries", () => {
const privateKey = `0x${"11".repeat(32)}`;
const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret";
const cause = new Error(`inner ${privateKey} ${rpcUrl}`);
const error = new Error(`outer ${privateKey} ${rpcUrl}`, { cause });
error.stack = `outer stack ${privateKey} ${rpcUrl}`;
cause.stack = `inner stack ${privateKey} ${rpcUrl}`;

const summary = errorSummary(error, { privateKey, rpcUrl }) as Record<string, unknown>;
const serialized = JSON.stringify(summary);

expect(serialized).not.toContain(privateKey);
expect(serialized).not.toContain("user:pass");
expect(serialized).not.toContain("secret");
expect(serialized).toContain("<redacted-private-key>");
expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted");
});

it("redacts runtime secrets in structured object error summaries", () => {
const privateKey = `0x${"11".repeat(32)}`;
const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret";

const summary = errorSummary({
message: `object ${privateKey}`,
rpcUrl,
amount: 9007199254740993n,
}, { privateKey, rpcUrl }) as Record<string, unknown>;
const serialized = JSON.stringify(summary);

expect(serialized).not.toContain(privateKey);
expect(serialized).not.toContain("user:pass");
expect(serialized).not.toContain("secret");
expect(serialized).toContain("<redacted-private-key>");
expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted");
});

it("redacts runtime secrets in transaction lifecycle errors", () => {
const privateKey = `0x${"11".repeat(32)}`;
const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret";
const error = new Error(`lifecycle ${privateKey} ${rpcUrl}`);

const events = transactionLifecycleEvents({
type: "pre_broadcast_failed",
elapsedMs: 12,
error,
}, { privateKey, rpcUrl });
const serialized = JSON.stringify(events);

expect(serialized).not.toContain(privateKey);
expect(serialized).not.toContain("user:pass");
expect(serialized).not.toContain("secret");
expect(serialized).toContain("<redacted-private-key>");
expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted");
});

it("summarizes thrown objects with JSON-safe details", () => {
const summary = errorSummary({
code: "RPC_FAILURE",
Expand Down
Loading
Loading