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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ Callers own the final completion pipeline:

Withdrawal requests built from public pool ready deposits may include `requiredLiveDeposits`. `@ickb/sdk` adds those cells as live `cell_dep` checks so a transaction fails if a protected pool anchor disappears before inclusion.

## Scan Completeness Boundary
## Scan Page Size Boundary

Stack cell scans that feed account state, pool state, order books, or maturity estimates request one sentinel entry beyond the configured logical limit and fail closed if the sentinel appears. Callers should treat these errors as incomplete state, not as zero balance or unavailable liquidity.
Stack cell scans that feed account state, pool state, order books, or maturity estimates use a per-request page size. SDK state APIs expose it as `cellPageSize`; lower-level scan wrappers expose it as `pageSize` and pass it to CCC as `limit`.

## User Lock Assumption

Expand Down
2 changes: 1 addition & 1 deletion apps/bot/docs/current_rebalancing_policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The runtime reads system and account state through `@ickb/sdk`, then derives the
- `depositCapacity`: CKB required for one standard 100,000 iCKB deposit at the live exchange ratio.
- `minCkbBalance`: shutdown threshold set to `21 / 20 * depositCapacity`.

The public pool scan reads one sentinel entry beyond the default cell limit and fails closed if that sentinel appears, because rebalance decisions require a complete pool slice.
The public pool scan uses the SDK L1 state snapshot and the default CCC cell-query page size unless callers pass `cellPageSize`. Rebalance decisions use the collected pool deposits from that snapshot.

`nearReadyPoolDeposits` only ranks ready-window withdrawal choices. Fresh deposits are scored against `futurePoolDeposits`, not against the near-ready hour.

Expand Down
135 changes: 88 additions & 47 deletions apps/bot/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ import { type IckbDepositCell } from "@ickb/core";
import { handleLoopError, logExecution } from "@ickb/node-utils";
import { OrderManager } from "@ickb/order";
import { type IckbSdk } from "@ickb/sdk";
import { defaultFindCellsLimit } from "@ickb/utils";
import { hash, headerLike, script } from "@ickb/testkit";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CKB_RESERVE, TARGET_ICKB_BALANCE } from "./policy.js";
import { CKB_RESERVE, POOL_MAX_LOCK_UP, POOL_MIN_LOCK_UP, TARGET_ICKB_BALANCE } from "./policy.js";
import {
completeTerminalIteration,
isRetryableBotError,
Expand All @@ -19,7 +18,7 @@ import {
reachedMaxRetryableAttempts,
} from "./index.js";
import { BotEventEmitter, transactionLifecycleEvents } from "./observability.js";
import { buildTransaction, collectPoolDeposits } from "./runtime.js";
import { buildTransaction } from "./runtime.js";

afterEach(() => {
vi.restoreAllMocks();
Expand All @@ -29,6 +28,7 @@ function readyDeposit(
byte: string,
udtValue: bigint,
maturityUnix: bigint,
options: { isReady?: boolean } = {},
): IckbDepositCell {
return {
cell: ccc.Cell.from({
Expand All @@ -39,6 +39,7 @@ function readyDeposit(
},
outputData: "0x",
}),
isReady: options.isReady ?? true,
udtValue,
maturity: {
toUnix: (): bigint => maturityUnix,
Expand Down Expand Up @@ -123,11 +124,18 @@ function botRuntime(overrides: {
exchangeRatio: { ckbScale: 1n, udtScale: 1n },
orderPool: [],
feeRate: 1n,
poolDeposits: { deposits: [], readyDeposits: [], id: "empty" },
ckbAvailable: 0n,
ckbMaturing: [],
},
user: { orders: [] },
account: {
capacityCells: [],
nativeUdtCells: [],
nativeUdtCapacity: 0n,
nativeUdtBalance: 0n,
receipts: [],
withdrawalGroups: [],
},
};
},
Expand All @@ -140,74 +148,107 @@ function botRuntime(overrides: {
};
}

describe("collectPoolDeposits", () => {
it("fails closed when the public pool scan reaches the sentinel limit", async () => {
async function* deposits(): AsyncGenerator<IckbDepositCell> {
describe("readBotState", () => {
it("partitions SDK pool deposits without a second pool scan", async () => {
const tip = headerLike({
number: 10n,
epoch: [0n, 0n, 1n],
timestamp: "0x0",
});
const readyWindowEnd = POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip);
const ready = readyDeposit("33", 1n, 1n, { isReady: true });
const tooEarly = readyDeposit("34", 2n, readyWindowEnd - 1n, { isReady: false });
const nearReady = readyDeposit("35", 3n, readyWindowEnd + 1n, { isReady: false });
const future = readyDeposit("36", 4n, readyWindowEnd + 60n * 60n * 1000n, { isReady: false });
const getL1AccountState = vi.fn<IckbSdk["getL1AccountState"]>();
getL1AccountState.mockResolvedValue({
system: {
tip,
exchangeRatio: { ckbScale: 1n, udtScale: 1n },
orderPool: [],
feeRate: 1n,
poolDeposits: {
deposits: [ready, tooEarly, nearReady, future],
readyDeposits: [ready],
id: "pool",
},
ckbAvailable: 0n,
ckbMaturing: [],
},
user: { orders: [] },
account: {
capacityCells: [],
nativeUdtCells: [],
nativeUdtCapacity: 0n,
nativeUdtBalance: 0n,
receipts: [],
withdrawalGroups: [],
},
});
const assertCurrentTip = vi.fn<IckbSdk["assertCurrentTip"]>();
const findDeposits = vi.fn(async function* (): AsyncGenerator<IckbDepositCell> {
await Promise.resolve();
for (let index = 0; index <= defaultFindCellsLimit; index += 1) {
yield readyDeposit("33", 1n, BigInt(index));
}
}

const findDeposits = vi.fn(() => deposits());
yield* [] as IckbDepositCell[];
});
const runtime = botRuntime({
sdk: {
getL1AccountState,
assertCurrentTip,
},
managers: {
logic: {
findDeposits,
},
},
});

await expect(
collectPoolDeposits(
{} as ccc.Client,
{ findDeposits } as never,
{} as ccc.ClientBlockHeader,
),
).rejects.toThrow(
`iCKB pool deposit scan reached limit ${String(defaultFindCellsLimit)}`,
);
const state = await readBotState(runtime as never);

expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({
onChain: true,
limit: defaultFindCellsLimit + 1,
expect(getL1AccountState).toHaveBeenCalledTimes(1);
expect(getL1AccountState.mock.calls[0]?.[2]).toMatchObject({
poolDeposits: {
minLockUp: POOL_MIN_LOCK_UP,
maxLockUp: POOL_MAX_LOCK_UP,
},
});
expect(findDeposits).not.toHaveBeenCalled();
expect(assertCurrentTip).not.toHaveBeenCalled();
expect(state.readyPoolDeposits).toEqual([ready]);
expect(state.nearReadyPoolDeposits).toEqual([nearReady]);
expect(state.futurePoolDeposits).toEqual([future]);
});
});

describe("readBotState", () => {
it("fails closed when pool scans cross the account-state tip", async () => {
const tip = headerLike({ number: 10n });
const assertCurrentTip = vi.fn(async () => {
await Promise.resolve();
throw new Error("L1 state scan crossed chain tip; retry with a fresh state");
});
it("fails closed when L1 account state omits the pool deposit snapshot", async () => {
const runtime = botRuntime({
sdk: {
getL1AccountState: async (): Promise<unknown> => {
await Promise.resolve();
return {
system: {
tip,
tip: headerLike(),
exchangeRatio: { ckbScale: 1n, udtScale: 1n },
orderPool: [],
feeRate: 1n,
ckbAvailable: 0n,
ckbMaturing: [],
},
user: { orders: [] },
account: { capacityCells: [], receipts: [] },
account: {
capacityCells: [],
nativeUdtCells: [],
nativeUdtCapacity: 0n,
nativeUdtBalance: 0n,
receipts: [],
withdrawalGroups: [],
},
};
},
assertCurrentTip,
},
managers: {
logic: {
findDeposits: async function* (): AsyncGenerator<IckbDepositCell> {
await Promise.resolve();
for (const deposit of [] as IckbDepositCell[]) {
yield deposit;
}
},
},
},
});

await expect(readBotState(runtime as never)).rejects.toThrow(
"L1 state scan crossed chain tip; retry with a fresh state",
"L1 account state is missing pool deposit snapshot",
);
expect(assertCurrentTip).toHaveBeenCalledWith(runtime.client, tip);
});
});

Expand Down
21 changes: 16 additions & 5 deletions apps/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ import {
} from "@ickb/node-utils";
import {
buildTransaction,
collectPoolDeposits,
summarizeBotState,
type BotState,
type Runtime,
} from "./runtime.js";
import {
partitionBotPoolDeposits,
POOL_MAX_LOCK_UP,
POOL_MIN_LOCK_UP,
} from "./policy.js";
import {
BotEventEmitter,
createRunId,
Expand Down Expand Up @@ -284,13 +288,20 @@ export async function readBotState(runtime: Runtime): Promise<BotState> {
const { system, user, account } = await runtime.sdk.getL1AccountState(
runtime.client,
accountLocks,
{
poolDeposits: {
minLockUp: POOL_MIN_LOCK_UP,
maxLockUp: POOL_MAX_LOCK_UP,
},
},
);
const poolDeposits = await collectPoolDeposits(
runtime.client,
runtime.managers.logic,
if (!system.poolDeposits) {
throw new Error("L1 account state is missing pool deposit snapshot");
}
const poolDeposits = partitionBotPoolDeposits(
system.poolDeposits.deposits,
system.tip,
);
await runtime.sdk.assertCurrentTip(runtime.client, system.tip);

const projection = projectAccountAvailability(account, user.orders, {
collectedOrdersAvailable: true,
Expand Down
13 changes: 13 additions & 0 deletions apps/bot/src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const CKB_RESERVE = 1000n * CKB;
export const MIN_ICKB_BALANCE = 2000n * CKB;
export const TARGET_ICKB_BALANCE = ICKB_DEPOSIT_CAP + 20000n * CKB;
export const NEAR_READY_LOOKAHEAD_MS = 60n * 60n * 1000n;
export const POOL_MIN_LOCK_UP = ccc.Epoch.from([0n, 1n, 16n]);
export const POOL_MAX_LOCK_UP = ccc.Epoch.from([0n, 4n, 16n]);

const OUTPUTS_PER_REBALANCE_ACTION = 2;
const MAX_WITHDRAWAL_REQUESTS = 30;
Expand Down Expand Up @@ -100,6 +102,17 @@ export function partitionPoolDeposits(
return { ready, nearReady, future };
}

export function partitionBotPoolDeposits(
deposits: readonly IckbDepositCell[],
tip: ccc.ClientBlockHeader,
): ReturnType<typeof partitionPoolDeposits> {
return partitionPoolDeposits(
deposits,
tip,
POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip),
);
}

export function planRebalance(options: {
outputSlots: number;
tip: ccc.ClientBlockHeader;
Expand Down
30 changes: 0 additions & 30 deletions apps/bot/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,15 @@ import {
} from "@ickb/order";
import { type getConfig, type IckbSdk, type SystemState } from "@ickb/sdk";
import { accountPlainCkbBalance, postTransactionAccountPlainCkbBalance, type SupportedChain } from "@ickb/node-utils";
import { collectCompleteScan, defaultFindCellsLimit } from "@ickb/utils";
import {
CKB_RESERVE,
partitionPoolDeposits,
planRebalance,
type RebalanceNoopReason,
type RebalancePlan,
} from "./policy.js";

const MATCH_STEP_DIVISOR = 100n;
const MAX_OUTPUTS_BEFORE_CHANGE = 58;
const POOL_MIN_LOCK_UP = ccc.Epoch.from([0n, 1n, 16n]);
const POOL_MAX_LOCK_UP = ccc.Epoch.from([0n, 4n, 16n]);

export interface Runtime {
chain: SupportedChain;
Expand Down Expand Up @@ -390,32 +386,6 @@ export function summarizeBotState(state: BotState): BotStateSummary {
};
}

export async function collectPoolDeposits(
client: ccc.Client,
logic: Runtime["managers"]["logic"],
tip: ccc.ClientBlockHeader,
): Promise<{
ready: IckbDepositCell[];
nearReady: IckbDepositCell[];
future: IckbDepositCell[];
}> {
const deposits = await collectCompleteScan(
(scanLimit) =>
logic.findDeposits(client, {
onChain: true,
tip,
minLockUp: POOL_MIN_LOCK_UP,
maxLockUp: POOL_MAX_LOCK_UP,
limit: scanLimit,
}),
{ limit: defaultFindCellsLimit, label: "iCKB pool deposit" },
);

const readyWindowEnd = POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip);

return partitionPoolDeposits(deposits, tip, readyWindowEnd);
}

function isMatchOnly(actions: {
collectedOrders: number;
completedDeposits: number;
Expand Down
Loading
Loading