From e34ca2d12b744848a1445979261f14cdad6fdfbc Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 10 May 2026 11:31:44 +0000 Subject: [PATCH 1/9] fix(sdk): report confirmation failures --- packages/sdk/README.md | 4 ++ packages/sdk/src/sdk.test.ts | 121 +++++++++++++++++++++++++++-------- packages/sdk/src/sdk.ts | 42 +++++++++++- 3 files changed, 136 insertions(+), 31 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 8f5a0b5..69a7b69 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -34,6 +34,10 @@ The current runtime path uses direct deposit scans together with bot liquidity a See [docs/pool_maturity_estimates.md](./docs/pool_maturity_estimates.md). +## Send Confirmation + +`sendAndWaitForCommit(...)` returns the transaction hash after commit. If a transaction was broadcast but later reaches a terminal non-committed status or times out while still pending, it throws `TransactionConfirmationError` with the broadcast `txHash`, last observed `status`, and `isTimeout` flag. Callers that need to log the hash immediately after broadcast can use the `onSent` callback. + ## Epoch Semantic Versioning This repository follows [Epoch Semantic Versioning](https://antfu.me/posts/epoch-semver). In short ESV aims to provide a more nuanced and effective way to communicate software changes, allowing for better user understanding and smoother upgrades. diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 57a2133..61675e1 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -16,6 +16,7 @@ import { IckbSdk, projectAccountAvailability, sendAndWaitForCommit, + TransactionConfirmationError, type SystemState, } from "./sdk.js"; @@ -797,40 +798,104 @@ describe("sendAndWaitForCommit", () => { }); it("surfaces terminal transaction failures", async () => { + const txHash = hash("a2"); const sleep = vi.fn(() => Promise.resolve()); - await expect(sendAndWaitForCommit( - { - client: { - getTransaction: vi.fn().mockResolvedValue({ status: "rejected" }), - } as unknown as ccc.Client, - signer: { - sendTransaction: vi.fn().mockResolvedValue(hash("a2")), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { sleep }, - )).rejects.toThrow("Transaction ended with status: rejected"); + try { + await sendAndWaitForCommit( + { + client: { + getTransaction: vi.fn().mockResolvedValue({ status: "rejected" }), + } as unknown as ccc.Client, + signer: { + sendTransaction: vi.fn().mockResolvedValue(txHash), + } as unknown as ccc.Signer, + }, + ccc.Transaction.default(), + { sleep }, + ); + expect.fail("Expected sendAndWaitForCommit to reject"); + } catch (error) { + expect(error).toBeInstanceOf(TransactionConfirmationError); + expect(error).toMatchObject({ + message: "Transaction ended with status: rejected", + txHash, + status: "rejected", + isTimeout: false, + }); + } expect(sleep).not.toHaveBeenCalled(); }); - it("surfaces transaction confirmation timeouts", async () => { - await expect(sendAndWaitForCommit( - { - client: { - getTransaction: vi.fn().mockResolvedValue({ status: "unknown" }), - } as unknown as ccc.Client, - signer: { - sendTransaction: vi.fn().mockResolvedValue(hash("a3")), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { - maxConfirmationChecks: 1, - sleep: () => Promise.resolve(), - }, - )).rejects.toThrow("Transaction confirmation timed out"); + it("surfaces transaction confirmation timeouts with the broadcast hash", async () => { + const txHash = hash("a3"); + const onSent = vi.fn(); + + try { + await sendAndWaitForCommit( + { + client: { + getTransaction: vi.fn().mockResolvedValue({ status: "unknown" }), + } as unknown as ccc.Client, + signer: { + sendTransaction: vi.fn().mockResolvedValue(txHash), + } as unknown as ccc.Signer, + }, + ccc.Transaction.default(), + { + maxConfirmationChecks: 1, + onSent, + sleep: () => Promise.resolve(), + }, + ); + expect.fail("Expected sendAndWaitForCommit to reject"); + } catch (error) { + expect(error).toBeInstanceOf(TransactionConfirmationError); + expect(error).toMatchObject({ + message: "Transaction confirmation timed out", + txHash, + status: "unknown", + isTimeout: true, + }); + } + + expect(onSent).toHaveBeenCalledWith(txHash); + }); + + it("surfaces post-broadcast polling failures with the broadcast hash", async () => { + const txHash = hash("a4"); + const onSent = vi.fn(); + + try { + await sendAndWaitForCommit( + { + client: { + getTransaction: vi.fn().mockRejectedValue(new Error("RPC down")), + } as unknown as ccc.Client, + signer: { + sendTransaction: vi.fn().mockResolvedValue(txHash), + } as unknown as ccc.Signer, + }, + ccc.Transaction.default(), + { + maxConfirmationChecks: 1, + onSent, + sleep: () => Promise.resolve(), + }, + ); + expect.fail("Expected sendAndWaitForCommit to reject"); + } catch (error) { + expect(error).toBeInstanceOf(TransactionConfirmationError); + expect(error).toMatchObject({ + message: "Transaction confirmation failed: RPC down", + txHash, + status: "sent", + isTimeout: true, + }); + } + + expect(onSent).toHaveBeenCalledWith(txHash); }); }); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index fde6322..0b25ee3 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -36,10 +36,23 @@ export interface CompleteIckbTransactionOptions { export interface SendAndWaitForCommitOptions { maxConfirmationChecks?: number; confirmationIntervalMs?: number; + onSent?: (txHash: ccc.Hex) => void; onConfirmationWait?: () => void; sleep?: (ms: number) => Promise; } +export class TransactionConfirmationError extends Error { + constructor( + message: string, + public readonly txHash: ccc.Hex, + public readonly status: string | undefined, + public readonly isTimeout: boolean, + ) { + super(message); + this.name = "TransactionConfirmationError"; + } +} + type IckbUdtCompleter = Pick; /** @@ -66,15 +79,28 @@ export async function sendAndWaitForCommit( { maxConfirmationChecks = 60, confirmationIntervalMs = 10_000, + onSent, onConfirmationWait, sleep = delay, }: SendAndWaitForCommitOptions = {}, ): Promise { const txHash = await signer.sendTransaction(tx); + onSent?.(txHash); let status: string | undefined = "sent"; for (let checks = 0; checks < maxConfirmationChecks && isPendingStatus(status); checks += 1) { - status = (await client.getTransaction(txHash))?.status; + try { + status = (await client.getTransaction(txHash))?.status; + } catch (error) { + throw new TransactionConfirmationError( + error instanceof Error && error.message + ? `Transaction confirmation failed: ${error.message}` + : "Transaction confirmation failed", + txHash, + status, + true, + ); + } if (!isPendingStatus(status)) { break; } @@ -88,10 +114,20 @@ export async function sendAndWaitForCommit( } if (isPendingStatus(status)) { - throw new Error("Transaction confirmation timed out"); + throw new TransactionConfirmationError( + "Transaction confirmation timed out", + txHash, + status, + true, + ); } - throw new Error(`Transaction ended with status: ${status ?? "unknown"}`); + throw new TransactionConfirmationError( + `Transaction ended with status: ${status ?? "unknown"}`, + txHash, + status, + false, + ); } function isPendingStatus(status: string | undefined): boolean { From 68f8d274f79068b18d97f73065b7e8ea9db923dd Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 10 May 2026 11:31:44 +0000 Subject: [PATCH 2/9] fix(bot): move runtime onto SDK boundaries --- apps/bot/package.json | 10 +- apps/bot/src/index.test.ts | 226 ++++++++++++++++++++++ apps/bot/src/index.ts | 365 +++++------------------------------ apps/bot/src/runtime.ts | 227 ++++++++++++++++++++++ apps/bot/tsconfig.build.json | 9 + 5 files changed, 520 insertions(+), 317 deletions(-) create mode 100644 apps/bot/src/index.test.ts create mode 100644 apps/bot/src/runtime.ts create mode 100644 apps/bot/tsconfig.build.json diff --git a/apps/bot/package.json b/apps/bot/package.json index 2bbc0ad..7102934 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -31,16 +31,16 @@ "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "tsc", + "build": "pnpm clean && tsc -p tsconfig.build.json", "lint": "eslint ./src", "clean": "rm -fr dist", "clean:deep": "rm -fr dist node_modules", - "start": "[ -n \"$CHAIN\" ] || { echo 'CHAIN not set (testnet|mainnet)' >&2; exit 1; } && node --env-file=env/${CHAIN}/.env dist/index.js | tee log_${CHAIN}_$(date +%F_%H-%M-%S).json", - "start:loop": "while true; do pnpm start; sleep 10; done" + "start": "[ -n \"$CHAIN\" ] || { echo 'CHAIN not set (testnet|mainnet)' >&2; exit 1; }; bash -o pipefail -c 'node --env-file=\"env/${CHAIN}/.env\" dist/index.js | tee \"log_${CHAIN}_$(date +%F_%H-%M-%S).json\"'", + "start:loop": "while true; do pnpm start; status=$?; [ \"$status\" -ne 2 ] || exit 2; sleep 10; done" }, "files": [ - "dist", - "src" + "docs", + "dist" ], "publishConfig": { "access": "public", diff --git a/apps/bot/src/index.test.ts b/apps/bot/src/index.test.ts new file mode 100644 index 0000000..1a16896 --- /dev/null +++ b/apps/bot/src/index.test.ts @@ -0,0 +1,226 @@ +import { ccc } from "@ckb-ccc/core"; +import { type IckbDepositCell } from "@ickb/core"; +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 { TARGET_ICKB_BALANCE } from "./policy.js"; +import { buildTransaction, collectPoolDeposits, parseSleepInterval } from "./runtime.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function hash(byte: string): `0x${string}` { + return `0x${byte.repeat(32)}`; +} + +function script(byte: string): ccc.Script { + return ccc.Script.from({ + codeHash: hash(byte), + hashType: "type", + args: "0x", + }); +} + +function readyDeposit( + byte: string, + udtValue: bigint, + maturityUnix: bigint, +): IckbDepositCell { + return { + cell: ccc.Cell.from({ + outPoint: { txHash: hash(byte), index: 0n }, + cellOutput: { + capacity: 0n, + lock: script("22"), + }, + outputData: "0x", + }), + udtValue, + maturity: { + toUnix: (): bigint => maturityUnix, + }, + } 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 { + await Promise.resolve(); + for (let index = 0; index <= defaultFindCellsLimit; index += 1) { + yield readyDeposit("33", 1n, BigInt(index)); + } + } + + const findDeposits = vi.fn(() => deposits()); + + await expect( + collectPoolDeposits( + {} as ccc.Client, + { findDeposits } as never, + {} as ccc.ClientBlockHeader, + ), + ).rejects.toThrow( + `iCKB pool deposit scan reached limit ${String(defaultFindCellsLimit)}`, + ); + + expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ + onChain: true, + limit: defaultFindCellsLimit + 1, + }); + }); +}); + +describe("buildTransaction", () => { + it("skips match-only transactions when the completed fee consumes the match value", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 1n, + udtDelta: 0n, + partials: [{} as never], + }); + vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(1n); + + const runtime = { + client: {} as ccc.Client, + signer: {} as ccc.SignerCkbPrivateKey, + managers: { + order: { + addMatch: (txLike: ccc.TransactionLike): ccc.Transaction => + ccc.Transaction.from(txLike), + }, + }, + sdk: { + buildBaseTransaction: async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + completeTransaction: async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + }, + primaryLock: ccc.Script.from({ + codeHash: `0x${"11".repeat(32)}`, + hashType: "type", + args: "0x", + }), + }; + const state = { + marketOrders: [{}], + availableCkbBalance: 100n, + availableIckbBalance: 0n, + depositCapacity: 100n, + readyPoolDeposits: [], + nearReadyPoolDeposits: [], + futurePoolDeposits: [], + userOrders: [], + receipts: [], + readyWithdrawals: [], + system: { + feeRate: 1n, + exchangeRatio: { ckbScale: 1n, udtScale: 1n }, + tip: {} as ccc.ClientBlockHeader, + }, + }; + + await expect( + buildTransaction(runtime as never, state as never), + ).resolves.toBeUndefined(); + }); + + it("passes required live deposits to SDK base transaction construction", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + + const first = readyDeposit("11", 4n, 20n * 60n * 1000n); + const protectedAnchor = readyDeposit("12", 6n, 25n * 60n * 1000n); + const third = readyDeposit("13", 5n, 40n * 60n * 1000n); + const calls: string[] = []; + const buildBaseTransaction = vi.fn(); + buildBaseTransaction.mockImplementation( + async (txLike: ccc.TransactionLike): Promise => { + calls.push("base"); + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + ); + const completeTransaction = vi.fn( + async (txLike: ccc.TransactionLike): Promise => { + calls.push("complete"); + await Promise.resolve(); + expect(calls).toEqual(["base", "complete"]); + const tx = ccc.Transaction.from(txLike); + expect(tx.cellDeps).toEqual([]); + return tx; + }, + ); + const runtime = { + client: {} as ccc.Client, + signer: {} as ccc.SignerCkbPrivateKey, + managers: { + order: { + addMatch: (txLike: ccc.TransactionLike): ccc.Transaction => + ccc.Transaction.from(txLike), + }, + }, + sdk: { + buildBaseTransaction, + completeTransaction, + }, + primaryLock: script("44"), + }; + const state = { + marketOrders: [], + availableCkbBalance: 0n, + availableIckbBalance: TARGET_ICKB_BALANCE + 9n, + depositCapacity: 1000n, + readyPoolDeposits: [first, protectedAnchor, third], + nearReadyPoolDeposits: [], + futurePoolDeposits: [], + userOrders: [], + receipts: [], + readyWithdrawals: [], + system: { + feeRate: 1n, + exchangeRatio: { ckbScale: 1n, udtScale: 1n }, + tip: {} as ccc.ClientBlockHeader, + }, + }; + + const result = await buildTransaction(runtime as never, state as never); + + expect(result?.actions.withdrawalRequests).toBe(1); + expect(buildBaseTransaction.mock.calls[0]?.[2]).toMatchObject({ + withdrawalRequest: { + deposits: [first], + requiredLiveDeposits: [protectedAnchor], + }, + }); + expect(completeTransaction).toHaveBeenCalledTimes(1); + expect(calls).toEqual(["base", "complete"]); + expect(result?.tx.cellDeps).toEqual([]); + }); +}); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 010fc47..3c27cf5 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,52 +1,24 @@ import { ccc } from "@ckb-ccc/core"; +import { pathToFileURL } from "node:url"; +import { ICKB_DEPOSIT_CAP, convert } from "@ickb/core"; import { - ICKB_DEPOSIT_CAP, - convert, - type IckbDepositCell, - type ReceiptCell, - type WithdrawalGroup, -} from "@ickb/core"; + getConfig, + IckbSdk, + projectAccountAvailability, + sendAndWaitForCommit, + TransactionConfirmationError, +} from "@ickb/sdk"; +import { CKB } from "./policy.js"; import { - OrderManager, - type OrderCell, - type OrderGroup, -} from "@ickb/order"; -import { getConfig, IckbSdk, type SystemState } from "@ickb/sdk"; -import { isPlainCapacityCell } from "@ickb/utils"; -import { CKB, planRebalance } from "./policy.js"; + buildTransaction, + collectPoolDeposits, + parseSleepInterval, + type BotState, + type Runtime, + type SupportedChain, +} from "./runtime.js"; -const MATCH_STEP_DIVISOR = 100n; -const POOL_MIN_LOCK_UP = ccc.Epoch.from([0n, 1n, 16n]); -const POOL_MAX_LOCK_UP = ccc.Epoch.from([0n, 4n, 16n]); -const MAX_OUTPUTS_BEFORE_CHANGE = 58; - -interface Runtime { - chain: SupportedChain; - client: ccc.Client; - signer: ccc.SignerCkbPrivateKey; - sdk: IckbSdk; - managers: ReturnType["managers"]; - primaryLock: ccc.Script; -} - -interface BotState { - accountLocks: ccc.Script[]; - system: SystemState; - userOrders: OrderGroup[]; - marketOrders: OrderCell[]; - receipts: ReceiptCell[]; - readyWithdrawals: WithdrawalGroup[]; - notReadyWithdrawals: WithdrawalGroup[]; - readyPoolDeposits: IckbDepositCell[]; - availableCkbBalance: bigint; - availableIckbBalance: bigint; - unavailableCkbBalance: bigint; - totalCkbBalance: bigint; - depositCapacity: bigint; - minCkbBalance: bigint; -} - -type SupportedChain = "mainnet" | "testnet"; +const STOP_EXIT_CODE = 2; async function main(): Promise { const { CHAIN, RPC_URL, BOT_PRIVATE_KEY, BOT_SLEEP_INTERVAL } = process.env; @@ -56,9 +28,7 @@ async function main(): Promise { if (!BOT_PRIVATE_KEY) { throw new Error("Empty env BOT_PRIVATE_KEY"); } - if (!BOT_SLEEP_INTERVAL || Number(BOT_SLEEP_INTERVAL) < 1) { - throw new Error("Invalid env BOT_SLEEP_INTERVAL"); - } + const sleepInterval = parseSleepInterval(BOT_SLEEP_INTERVAL, "BOT_SLEEP_INTERVAL"); const chain = parseChain(CHAIN); const client = createClient(chain, RPC_URL); @@ -74,8 +44,7 @@ async function main(): Promise { managers, primaryLock, }; - const sleepInterval = Number(BOT_SLEEP_INTERVAL) * 1000; - + let stopAfterLog = false; for (;;) { await sleep(Math.floor(2 * Math.random() * sleepInterval)); @@ -120,6 +89,7 @@ async function main(): Promise { "The bot must have more than " + String(fmtCkb(state.minCkbBalance)) + " CKB worth of capital to be able to operate, shutting down..."; + process.exitCode = STOP_EXIT_CODE; console.log(JSON.stringify(executionLog, replacer, " ")); return; } @@ -134,15 +104,26 @@ async function main(): Promise { fee: fmtCkb(await result.tx.getFee(runtime.client)), feeRate: state.system.feeRate, }; - executionLog.txHash = await runtime.signer.sendTransaction(result.tx); + executionLog.txHash = await sendAndWaitForCommit(runtime, result.tx, { + onSent: (txHash) => { + executionLog.txHash = txHash; + }, + }); } catch (error) { executionLog.error = errorToLog(error); + if (error instanceof TransactionConfirmationError && error.isTimeout) { + process.exitCode = STOP_EXIT_CODE; + stopAfterLog = true; + } } executionLog.ElapsedSeconds = Math.round( (Date.now() - startTime.getTime()) / 1000, ); console.log(JSON.stringify(executionLog, replacer, " ")); + if (stopAfterLog) { + return; + } } } @@ -155,36 +136,14 @@ async function readBotState(runtime: Runtime): Promise { accountLocks, ); - const [capacityCells, walletUdtCells, receipts, withdrawalGroups, readyPoolDeposits] = - await Promise.all([ - collectCapacityCells(runtime.signer), - collectWalletUdtCells(runtime.signer, runtime.managers.ickbUdt), - collectAsync( - runtime.managers.logic.findReceipts(runtime.client, accountLocks, { - onChain: true, - }), - ), - collectAsync( - runtime.managers.ownedOwner.findWithdrawalGroups( - runtime.client, - accountLocks, - { - onChain: true, - tip: system.tip, - }, - ), - ), - collectReadyPoolDeposits(runtime.client, runtime.managers.logic, system.tip), - ]); - const walletUdtInfo = await runtime.managers.ickbUdt.infoFrom( - runtime.client, - walletUdtCells, - ); + const [account, poolDeposits] = await Promise.all([ + runtime.sdk.getAccountState(runtime.client, accountLocks, system.tip), + collectPoolDeposits(runtime.client, runtime.managers.logic, system.tip), + ]); - const { yes: readyWithdrawals, no: notReadyWithdrawals } = partition( - withdrawalGroups, - (group) => group.owned.isReady, - ); + const projection = projectAccountAvailability(account, user.orders, { + collectedOrdersAvailable: true, + }); const ownedOrderKeys = new Set( user.orders.map((group) => outPointKey(group.order.cell.outPoint)), ); @@ -192,20 +151,9 @@ async function readBotState(runtime: Runtime): Promise { (order) => !ownedOrderKeys.has(outPointKey(order.cell.outPoint)), ); - const availableCkbBalance = - sumValues(capacityCells, (cell) => cell.cellOutput.capacity) + - walletUdtInfo.capacity + - sumValues(user.orders, (group) => group.ckbValue) + - sumValues(receipts, (receipt) => receipt.ckbValue) + - sumValues(readyWithdrawals, (group) => group.ckbValue); - const availableIckbBalance = - walletUdtInfo.balance + - sumValues(user.orders, (group) => group.udtValue) + - sumValues(receipts, (receipt) => receipt.udtValue); - const unavailableCkbBalance = sumValues( - notReadyWithdrawals, - (group) => group.ckbValue, - ); + const availableCkbBalance = projection.ckbAvailable; + const availableIckbBalance = projection.ickbAvailable; + const unavailableCkbBalance = projection.ckbPending; const totalCkbBalance = availableCkbBalance + unavailableCkbBalance; const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, system.exchangeRatio); @@ -214,10 +162,12 @@ async function readBotState(runtime: Runtime): Promise { system, userOrders: user.orders, marketOrders, - receipts, - readyWithdrawals, - notReadyWithdrawals, - readyPoolDeposits, + receipts: account.receipts, + readyWithdrawals: projection.readyWithdrawals, + notReadyWithdrawals: projection.pendingWithdrawals, + readyPoolDeposits: poolDeposits.ready, + nearReadyPoolDeposits: poolDeposits.nearReady, + futurePoolDeposits: poolDeposits.future, availableCkbBalance, availableIckbBalance, unavailableCkbBalance, @@ -227,170 +177,6 @@ async function readBotState(runtime: Runtime): Promise { }; } -async function buildTransaction( - runtime: Runtime, - state: BotState, -): Promise< - | { - tx: ccc.Transaction; - actions: { - collectedOrders: number; - completedDeposits: number; - matchedOrders: number; - deposits: number; - withdrawalRequests: number; - withdrawals: number; - }; - } - | undefined -> { - let tx = ccc.Transaction.default(); - - if (state.userOrders.length > 0) { - tx = runtime.sdk.collect(tx, state.userOrders); - } - if (state.receipts.length > 0) { - tx = runtime.managers.logic.completeDeposit(tx, state.receipts); - } - if (state.readyWithdrawals.length > 0) { - tx = await runtime.managers.ownedOwner.withdraw( - tx, - state.readyWithdrawals, - runtime.client, - ); - } - - const match = OrderManager.bestMatch( - state.marketOrders, - { - ckbValue: state.availableCkbBalance, - udtValue: state.availableIckbBalance, - }, - state.system.exchangeRatio, - { - feeRate: state.system.feeRate, - ckbAllowanceStep: maxBigInt(1n, state.depositCapacity / MATCH_STEP_DIVISOR), - }, - ); - if (match.partials.length > 0) { - tx = runtime.managers.order.addMatch(tx, match); - } - - const rebalance = planRebalance({ - outputSlots: maxInt(0, MAX_OUTPUTS_BEFORE_CHANGE - tx.outputs.length), - ickbBalance: state.availableIckbBalance + match.udtDelta, - ckbBalance: state.availableCkbBalance + match.ckbDelta, - depositCapacity: state.depositCapacity, - readyDeposits: state.readyPoolDeposits, - }); - if (rebalance.kind === "deposit") { - tx = await runtime.managers.logic.deposit( - tx, - rebalance.quantity, - state.depositCapacity, - runtime.primaryLock, - runtime.client, - ); - } else if (rebalance.kind === "withdraw") { - tx = await runtime.managers.ownedOwner.requestWithdrawal( - tx, - rebalance.deposits, - runtime.primaryLock, - runtime.client, - ); - } - - const actions = { - collectedOrders: state.userOrders.length, - completedDeposits: state.receipts.length, - matchedOrders: match.partials.length, - deposits: rebalance.kind === "deposit" ? rebalance.quantity : 0, - withdrawalRequests: - rebalance.kind === "withdraw" ? rebalance.deposits.length : 0, - withdrawals: state.readyWithdrawals.length, - }; - const actionCount = Object.values(actions).reduce((sum, count) => sum + count, 0); - if (actionCount === 0) { - return; - } - - tx = await runtime.managers.ickbUdt.completeBy(tx, runtime.signer); - await tx.completeFeeBy(runtime.signer, state.system.feeRate); - - if (await ccc.isDaoOutputLimitExceeded(tx, runtime.client)) { - throw new Error( - `NervosDAO transaction has ${String(tx.outputs.length)} output cells, exceeding the limit of 64`, - ); - } - - return { tx, actions }; -} - -async function collectCapacityCells( - signer: ccc.SignerCkbPrivateKey, -): Promise { - const cells: ccc.Cell[] = []; - - for await (const cell of signer.findCellsOnChain( - { - scriptLenRange: [0n, 1n], - outputDataLenRange: [0n, 1n], - }, - true, - "asc", - 400, - )) { - if (!isPlainCapacityCell(cell)) { - continue; - } - cells.push(cell); - } - - return cells; -} - -async function collectWalletUdtCells( - signer: ccc.SignerCkbPrivateKey, - ickbUdt: Runtime["managers"]["ickbUdt"], -): Promise { - const cells: ccc.Cell[] = []; - - for await (const cell of signer.findCellsOnChain( - ickbUdt.filter, - true, - "asc", - 400, - )) { - if (!ickbUdt.isUdt(cell)) { - continue; - } - cells.push(cell); - } - - return cells; -} - -async function collectReadyPoolDeposits( - client: ccc.Client, - logic: Runtime["managers"]["logic"], - tip: ccc.ClientBlockHeader, -): Promise { - const deposits = await collectAsync( - logic.findDeposits(client, { - onChain: true, - tip, - minLockUp: POOL_MIN_LOCK_UP, - maxLockUp: POOL_MAX_LOCK_UP, - }), - ); - - return deposits - .filter((deposit) => deposit.isReady) - .sort((left, right) => - compareBigInt(left.maturity.toUnix(tip), right.maturity.toUnix(tip)), - ); -} - function createClient(chain: SupportedChain, rpcUrl: string | undefined): ccc.Client { const config = rpcUrl ? { url: rpcUrl } : undefined; return chain === "mainnet" @@ -406,14 +192,6 @@ function parseChain(chain: string): SupportedChain { throw new Error("Invalid env CHAIN: " + chain); } -async function collectAsync(iterable: AsyncIterable): Promise { - const items: T[] = []; - for await (const item of iterable) { - items.push(item); - } - return items; -} - function dedupeScripts(scripts: ccc.Script[]): ccc.Script[] { const seen = new Set(); const unique: ccc.Script[] = []; @@ -430,51 +208,10 @@ function dedupeScripts(scripts: ccc.Script[]): ccc.Script[] { return unique; } -function partition( - items: readonly T[], - predicate: (item: T) => boolean, -): { yes: T[]; no: T[] } { - const yes: T[] = []; - const no: T[] = []; - - for (const item of items) { - if (predicate(item)) { - yes.push(item); - } else { - no.push(item); - } - } - - return { yes, no }; -} - -function sumValues(items: readonly T[], project: (item: T) => bigint): bigint { - let total = 0n; - for (const item of items) { - total += project(item); - } - return total; -} - -function compareBigInt(left: bigint, right: bigint): number { - if (left === right) { - return 0; - } - return left < right ? -1 : 1; -} - function outPointKey(outPoint: ccc.OutPoint): string { return ccc.hexFrom(outPoint.toBytes()); } -function maxBigInt(left: bigint, right: bigint): bigint { - return left > right ? left : right; -} - -function maxInt(left: number, right: number): number { - return left > right ? left : right; -} - function fmtCkb(balance: bigint): number { return Number(balance) / Number(CKB); } @@ -492,6 +229,8 @@ function errorToLog(error: unknown): unknown { "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, }; } @@ -505,4 +244,6 @@ function sleep(ms: number): Promise { }); } -await main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/apps/bot/src/runtime.ts b/apps/bot/src/runtime.ts new file mode 100644 index 0000000..62389ed --- /dev/null +++ b/apps/bot/src/runtime.ts @@ -0,0 +1,227 @@ +import { ccc } from "@ckb-ccc/core"; +import { + type IckbDepositCell, + type ReceiptCell, + type WithdrawalGroup, +} from "@ickb/core"; +import { + OrderManager, + type OrderCell, + type OrderGroup, +} from "@ickb/order"; +import { type getConfig, type IckbSdk, type SystemState } from "@ickb/sdk"; +import { defaultFindCellsLimit } from "@ickb/utils"; +import { partitionPoolDeposits, planRebalance } from "./policy.js"; + +const MATCH_STEP_DIVISOR = 100n; +const MAX_OUTPUTS_BEFORE_CHANGE = 58; + +export interface Runtime { + chain: SupportedChain; + client: ccc.Client; + signer: ccc.SignerCkbPrivateKey; + sdk: IckbSdk; + managers: ReturnType["managers"]; + primaryLock: ccc.Script; +} + +export interface BotState { + accountLocks: ccc.Script[]; + system: SystemState; + userOrders: OrderGroup[]; + marketOrders: OrderCell[]; + receipts: ReceiptCell[]; + readyWithdrawals: WithdrawalGroup[]; + notReadyWithdrawals: WithdrawalGroup[]; + readyPoolDeposits: IckbDepositCell[]; + nearReadyPoolDeposits: IckbDepositCell[]; + futurePoolDeposits: IckbDepositCell[]; + availableCkbBalance: bigint; + availableIckbBalance: bigint; + unavailableCkbBalance: bigint; + totalCkbBalance: bigint; + depositCapacity: bigint; + minCkbBalance: bigint; +} + +export type SupportedChain = "mainnet" | "testnet"; + +const POOL_MIN_LOCK_UP = ccc.Epoch.from([0n, 1n, 16n]); +const POOL_MAX_LOCK_UP = ccc.Epoch.from([0n, 4n, 16n]); + +export function parseSleepInterval( + intervalSeconds: string | undefined, + envName: string, +): number { + const seconds = Number(intervalSeconds); + if (intervalSeconds === undefined || !Number.isFinite(seconds) || seconds < 1) { + throw new Error("Invalid env " + envName); + } + + return seconds * 1000; +} + +export async function buildTransaction( + runtime: Runtime, + state: BotState, +): Promise< + | { + tx: ccc.Transaction; + actions: { + collectedOrders: number; + completedDeposits: number; + matchedOrders: number; + deposits: number; + withdrawalRequests: number; + withdrawals: number; + }; + } + | undefined +> { + const match = OrderManager.bestMatch( + state.marketOrders, + { + ckbValue: state.availableCkbBalance, + udtValue: state.availableIckbBalance, + }, + state.system.exchangeRatio, + { + feeRate: state.system.feeRate, + ckbAllowanceStep: maxBigInt(1n, state.depositCapacity / MATCH_STEP_DIVISOR), + maxPartials: MAX_OUTPUTS_BEFORE_CHANGE, + }, + ); + let tx = ccc.Transaction.default(); + if (match.partials.length > 0) { + tx = runtime.managers.order.addMatch(tx, match); + } + + const rebalance = planRebalance({ + outputSlots: maxInt(0, MAX_OUTPUTS_BEFORE_CHANGE - tx.outputs.length), + tip: state.system.tip, + ickbBalance: state.availableIckbBalance + match.udtDelta, + ckbBalance: state.availableCkbBalance + match.ckbDelta, + depositCapacity: state.depositCapacity, + readyDeposits: state.readyPoolDeposits, + nearReadyDeposits: state.nearReadyPoolDeposits, + futurePoolDeposits: state.futurePoolDeposits, + }); + tx = await runtime.sdk.buildBaseTransaction(tx, runtime.client, { + withdrawalRequest: + rebalance.kind === "withdraw" + ? { + deposits: rebalance.deposits, + requiredLiveDeposits: rebalance.requiredLiveDeposits, + lock: runtime.primaryLock, + } + : undefined, + orders: state.userOrders, + receipts: state.receipts, + readyWithdrawals: state.readyWithdrawals, + }); + if (rebalance.kind === "deposit") { + tx = await runtime.managers.logic.deposit( + tx, + rebalance.quantity, + state.depositCapacity, + runtime.primaryLock, + runtime.client, + ); + } + + const actions = { + collectedOrders: state.userOrders.length, + completedDeposits: state.receipts.length, + matchedOrders: match.partials.length, + deposits: + rebalance.kind === "deposit" ? rebalance.quantity : 0, + withdrawalRequests: + rebalance.kind === "withdraw" ? rebalance.deposits.length : 0, + withdrawals: state.readyWithdrawals.length, + }; + const actionCount = Object.values(actions).reduce((sum, count) => sum + count, 0); + if (actionCount === 0) { + return; + } + + tx = await runtime.sdk.completeTransaction(tx, { + signer: runtime.signer, + client: runtime.client, + feeRate: state.system.feeRate, + }); + + if (isMatchOnly(actions)) { + const fee = await tx.getFee(runtime.client); + const matchValue = + match.ckbDelta * state.system.exchangeRatio.ckbScale + + match.udtDelta * state.system.exchangeRatio.udtScale; + if (matchValue <= fee * state.system.exchangeRatio.ckbScale) { + return; + } + } + + return { tx, actions }; +} + +export async function collectPoolDeposits( + client: ccc.Client, + logic: Runtime["managers"]["logic"], + tip: ccc.ClientBlockHeader, +): Promise<{ + ready: IckbDepositCell[]; + nearReady: IckbDepositCell[]; + future: IckbDepositCell[]; +}> { + const deposits = await collectAsync( + logic.findDeposits(client, { + onChain: true, + tip, + minLockUp: POOL_MIN_LOCK_UP, + maxLockUp: POOL_MAX_LOCK_UP, + limit: defaultFindCellsLimit + 1, + }), + ); + if (deposits.length > defaultFindCellsLimit) { + throw new Error( + `iCKB pool deposit scan reached limit ${String(defaultFindCellsLimit)}; state may be incomplete`, + ); + } + + const readyWindowEnd = POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip); + + return partitionPoolDeposits(deposits, tip, readyWindowEnd); +} + +function isMatchOnly(actions: { + collectedOrders: number; + completedDeposits: number; + matchedOrders: number; + deposits: number; + withdrawalRequests: number; + withdrawals: number; +}): boolean { + return ( + actions.matchedOrders > 0 && + actions.collectedOrders === 0 && + actions.completedDeposits === 0 && + actions.deposits === 0 && + actions.withdrawalRequests === 0 && + actions.withdrawals === 0 + ); +} + +function maxBigInt(left: bigint, right: bigint): bigint { + return left > right ? left : right; +} + +function maxInt(left: number, right: number): number { + return left > right ? left : right; +} + +async function collectAsync(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) { + items.push(item); + } + return items; +} diff --git a/apps/bot/tsconfig.build.json b/apps/bot/tsconfig.build.json new file mode 100644 index 0000000..998f832 --- /dev/null +++ b/apps/bot/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false, + "sourceRoot": "", + "sourceMap": false + }, + "exclude": ["src/**/*.test.ts"] +} From 8dd3c3a038fda7b04b9d778274dd7eb66e4b8eca Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 10 May 2026 11:31:44 +0000 Subject: [PATCH 3/9] fix(bot): harden pool rebalance policy --- apps/bot/src/policy.test.ts | 1139 ++++++++++++++++++++++++++++++++++- apps/bot/src/policy.ts | 637 +++++++++++++++++++- 2 files changed, 1755 insertions(+), 21 deletions(-) diff --git a/apps/bot/src/policy.test.ts b/apps/bot/src/policy.test.ts index 295235e..ccf825a 100644 --- a/apps/bot/src/policy.test.ts +++ b/apps/bot/src/policy.test.ts @@ -1,12 +1,89 @@ +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; import { describe, expect, it } from "vitest"; -import { planRebalance, selectReadyDeposits, TARGET_ICKB_BALANCE } from "./policy.js"; +import { + CKB, + CKB_RESERVE, + MIN_ICKB_BALANCE, + NEAR_READY_LOOKAHEAD_MS, + partitionPoolDeposits, + planRebalance, + selectReadyDeposits, + TARGET_ICKB_BALANCE, +} from "./policy.js"; + +const FUTURE_RING_LENGTH_MS = 16n; + +const TIP = { + epoch: { + toUnix: (): bigint => 0n, + add: (): { toUnix: () => bigint } => ({ + toUnix: (): bigint => FUTURE_RING_LENGTH_MS, + }), + }, +} as never; + +function readyDeposit( + udtValue: bigint, + maturityUnix: bigint, +): { + udtValue: bigint; + maturity: { toUnix: () => bigint }; +} { + return { + udtValue, + maturity: { + toUnix: (): bigint => maturityUnix, + }, + }; +} + +function futureDeposit( + maturityUnix: bigint, + udtValue = ICKB_DEPOSIT_CAP, + options?: { + isReady?: boolean; + }, +): { + udtValue: bigint; + maturity: { toUnix: () => bigint }; + isReady?: boolean; +} { + return { + ...readyDeposit(udtValue, maturityUnix), + isReady: options?.isReady, + }; +} + +const NO_NEAR_READY: never[] = []; +const NO_FUTURE: never[] = []; + +describe("partitionPoolDeposits", () => { + it("keeps not-ready deposits before the near-ready window out of future coverage", () => { + const ready = futureDeposit(2n, ICKB_DEPOSIT_CAP, { isReady: true }); + const notReadyBeforeWindow = futureDeposit(8n); + const nearReady = futureDeposit(16n); + const future = futureDeposit(16n + NEAR_READY_LOOKAHEAD_MS); + + expect( + partitionPoolDeposits( + [future, nearReady, notReadyBeforeWindow, ready] as never[], + TIP, + FUTURE_RING_LENGTH_MS, + ), + ).toEqual({ + ready: [ready], + nearReady: [nearReady], + future: [future], + }); + }); +}); describe("selectReadyDeposits", () => { - it("keeps the cumulative selection under the target amount", () => { + it("prefers the fullest valid subset under the target amount", () => { const deposits = [{ udtValue: 4n }, { udtValue: 7n }, { udtValue: 3n }]; expect(selectReadyDeposits(deposits, 10n)).toEqual([ - { udtValue: 4n }, + { udtValue: 7n }, { udtValue: 3n }, ]); }); @@ -19,6 +96,17 @@ describe("selectReadyDeposits", () => { { 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", () => { @@ -26,10 +114,13 @@ describe("planRebalance", () => { expect( planRebalance({ outputSlots: 1, + tip: TIP, ickbBalance: 0n, ckbBalance: 2000n * 100000000n, depositCapacity: 1000n * 100000000n, readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, }), ).toEqual({ kind: "none" }); }); @@ -38,10 +129,28 @@ describe("planRebalance", () => { expect( planRebalance({ outputSlots: 4, + tip: TIP, ickbBalance: 0n, ckbBalance: 2000n * 100000000n, depositCapacity: 1000n * 100000000n, readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("requests one deposit when CKB is exactly at the reserve boundary", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: 0n, + ckbBalance: 1000n * CKB + CKB_RESERVE, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, }), ).toEqual({ kind: "deposit", quantity: 1 }); }); @@ -50,41 +159,1045 @@ describe("planRebalance", () => { expect( planRebalance({ outputSlots: 4, + tip: TIP, ickbBalance: 0n, ckbBalance: 1999n * 100000000n, depositCapacity: 1000n * 100000000n, readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("does not deposit when iCKB is exactly at the minimum balance", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: MIN_ICKB_BALANCE, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("seeds one future deposit when no future anchors exist", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: [futureDeposit(105n * 60n * 1000n)] as never[], + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("does not seed when a lone future anchor must be preserved", () => { + const loneAnchor = futureDeposit(9n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [loneAnchor] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("seeds, without withdrawing, when two future anchors crowd the same adaptive segment", () => { + const firstDuplicate = futureDeposit(9n); + const secondDuplicate = futureDeposit(10n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [firstDuplicate, secondDuplicate] as never[], + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("does not seed when two future anchors already span both adaptive segments", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(1n), futureDeposit(9n)] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("seeds when the coarse target segment is under-covered by udt per meter", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(5n), futureDeposit(9n), futureDeposit(13n)] as never[], + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("does not seed when the coarse target segment meets the density threshold", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [ + futureDeposit(1n, 1n), + futureDeposit(5n, 3n), + futureDeposit(9n, 4n), + ] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("does not seed from zero-total future coverage", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(9n, 0n), futureDeposit(10n, 0n)] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("does not seed future shaping when the reserve gate fails", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("does not seed when one more deposit would cross the target band", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP + 1n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("does not treat sparse next-hour near-ready as future-segmentation coverage", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: [futureDeposit(105n * 60n * 1000n)] as never[], + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("does not withdraw from a duplicate dense future segment to fill an empty target segment", () => { + const firstDuplicate = futureDeposit(5n); + const secondDuplicate = futureDeposit(6n); + const otherSegment = futureDeposit(9n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [firstDuplicate, secondDuplicate, otherSegment] as never[], + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("keeps direct seeding when future crowding has only deposit output slots", () => { + expect( + planRebalance({ + outputSlots: 3, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)] as never[], + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("does not withdraw stale ready-shaped entries from the future pool", () => { + const readyDuplicate = futureDeposit(5n, ICKB_DEPOSIT_CAP, { isReady: true }); + const pendingDuplicate = futureDeposit(6n); + const secondPendingDuplicate = futureDeposit(7n); + const otherSegment = futureDeposit(9n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [readyDuplicate, pendingDuplicate, secondPendingDuplicate, otherSegment] as never[], + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("does not treat ready entries as future anchors", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [ + futureDeposit(5n, ICKB_DEPOSIT_CAP, { isReady: true }), + futureDeposit(6n, ICKB_DEPOSIT_CAP, { isReady: true }), + futureDeposit(9n), + ] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("keeps direct seeding when removing a future source would leave the source segment below the preservation floor", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [ + futureDeposit(5n, 50n), + futureDeposit(6n, 50n), + futureDeposit(9n, 200n), + futureDeposit(13n, 200n), + ] as never[], + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("keeps direct seeding when density improvement would be too small", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [ + futureDeposit(1n, 10n), + futureDeposit(4n), + futureDeposit(4n), + futureDeposit(4n), + futureDeposit(5n), + futureDeposit(5n), + futureDeposit(9n, 10n), + ] as never[], + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("returns none, not a withdrawal, when dust crowds the future pool without deposit budget", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(5n, 1n), futureDeposit(6n, 1n), futureDeposit(9n)] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("does not reserve withdrawals when public drain leaves the future target under-covered", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(5n), futureDeposit(9n, 1n), futureDeposit(13n)] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("does not expand future horizon or remove a farther source", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n), futureDeposit(10_000n)] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("returns none when a raced future-removal source disappears", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(9n)] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("does not treat pending future withdrawal value as liquid CKB for future seeding", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: [futureDeposit(105n * 60n * 1000n, 10n * ICKB_DEPOSIT_CAP)] as never[], + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("does nothing instead of withdrawing for cosmetic future smoothing", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [ + futureDeposit(1n), + futureDeposit(4n), + futureDeposit(8n), + futureDeposit(12n), + ] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("does not seed or withdraw future inventory when the reserve gate fails", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("does not seed or withdraw future inventory when one more deposit would cross the target band", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP + 1n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)] as never[], }), ).toEqual({ kind: "none" }); }); - it("requests withdrawals when iCKB is above the target band", () => { + 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); + const third = readyDeposit(5n, 40n * 60n * 1000n); + const plan = planRebalance({ outputSlots: 6, + tip: TIP, ickbBalance: TARGET_ICKB_BALANCE + 9n, ckbBalance: 0n, depositCapacity: 1000n, - readyDeposits: [ - { udtValue: 4n }, - { udtValue: 6n }, - { udtValue: 5n }, - ] as never[], + readyDeposits: [first, second, third] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, }); expect(plan).toEqual({ kind: "withdraw", - deposits: [{ udtValue: 4n }, { udtValue: 5n }], + deposits: [first], + requiredLiveDeposits: [second], }); }); - it("does nothing when iCKB is above target but no ready deposits fit", () => { + it("prefers thinning crowded ready buckets before isolated deposits", () => { + const singleton = readyDeposit(5n, 0n); + const crowdedEarly = readyDeposit(4n, 20n * 60n * 1000n); + const crowdedLate = readyDeposit(4n, 25n * 60n * 1000n); + expect( planRebalance({ outputSlots: 6, - ickbBalance: TARGET_ICKB_BALANCE + 3n, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 5n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [singleton, crowdedEarly, crowdedLate] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ + kind: "withdraw", + deposits: [crowdedEarly], + requiredLiveDeposits: [crowdedLate], + }); + }); + + it("pins protected crowded anchors for ordinary extra withdrawals", () => { + const protectedAnchor = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const extra = readyDeposit(ICKB_DEPOSIT_CAP - CKB, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP - CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [extra, protectedAnchor] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ + kind: "withdraw", + deposits: [extra], + requiredLiveDeposits: [protectedAnchor], + }); + }); + + it("still prefers a crowded-bucket fit before touching singleton anchors", () => { + const singleton = readyDeposit(5n, 0n); + const crowdedProtected = readyDeposit(6n, 20n * 60n * 1000n); + const crowdedExtra = readyDeposit(5n, 25n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 5n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [crowdedExtra, crowdedProtected, singleton] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }); + + expect(plan).toMatchObject({ kind: "withdraw" }); + expect(plan.kind === "withdraw" ? plan.deposits.map((deposit) => deposit.udtValue) : []).toEqual([crowdedExtra.udtValue]); + }); + + it("protects isolated singleton anchors while moderate excess can still thin a crowded bucket", () => { + const singleton = readyDeposit(5n, 0n); + const crowdedLarge = readyDeposit(9n, 20n * 60n * 1000n); + const crowdedSmall = readyDeposit(4n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 9n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [singleton, crowdedLarge, crowdedSmall] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ + kind: "withdraw", + deposits: [crowdedSmall], + requiredLiveDeposits: [crowdedLarge], + }); + }); + + it("spends singleton anchors again once excess reaches one deposit step above target", () => { + const singleton = readyDeposit(5n, 0n); + const crowdedLarge = readyDeposit(9n, 20n * 60n * 1000n); + const crowdedSmall = readyDeposit(4n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 9n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [singleton, crowdedLarge, crowdedSmall] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ + kind: "withdraw", + deposits: [singleton, crowdedSmall], + requiredLiveDeposits: [crowdedLarge], + }); + }); + + it("ranks crowded buckets by overfull value before smaller crowded buckets", () => { + const lowExtra = readyDeposit(3n, 20n * 60n * 1000n); + const lowProtected = readyDeposit(5n, 25n * 60n * 1000n); + const highExtra = readyDeposit(4n, 40n * 60n * 1000n); + const highProtected = readyDeposit(5n, 44n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 4n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [lowExtra, lowProtected, highExtra, highProtected] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ + kind: "withdraw", + deposits: [highExtra], + requiredLiveDeposits: [highProtected], + }); + }); + + it("leaves one deposit in a crowded ready bucket when that already reduces excess", () => { + const first = readyDeposit(3n, 20n * 60n * 1000n); + const last = readyDeposit(3n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 6n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [first, last] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ + kind: "withdraw", + deposits: [first], + requiredLiveDeposits: [last], + }); + }); + + it("keeps the latest deposit when crowded bucket values tie", () => { + const earlier = readyDeposit(3n, 20n * 60n * 1000n); + const later = readyDeposit(3n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 3n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [earlier, later] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ + kind: "withdraw", + deposits: [earlier], + requiredLiveDeposits: [later], + }); + }); + + it("keeps singleton anchors when only they remain under moderate excess", () => { + const earlierSingleton = readyDeposit(5n, 20n * 60n * 1000n); + const laterSingleton = readyDeposit(5n, 45n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 5n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [earlierSingleton, laterSingleton] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("uses near-ready refill as a tie-break for equal singleton choices once anchors unlock", () => { + const earlierSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const laterSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const nearReadyRefill = readyDeposit(4n, 105n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [earlierSingleton, laterSingleton] as never[], + nearReadyDeposits: [nearReadyRefill] as never[], + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "withdraw", deposits: [laterSingleton] }); + }); + + it("uses near-ready refill as a tie-break for equal crowded-bucket extras", () => { + const firstExtra = readyDeposit(4n, 20n * 60n * 1000n); + const firstProtected = readyDeposit(5n, 25n * 60n * 1000n); + const secondExtra = readyDeposit(4n, 40n * 60n * 1000n); + const secondProtected = readyDeposit(5n, 44n * 60n * 1000n); + const nearReadyRefill = readyDeposit(3n, 105n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 4n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [firstExtra, firstProtected, secondExtra, secondProtected] as never[], + nearReadyDeposits: [nearReadyRefill] as never[], + futurePoolDeposits: NO_FUTURE, + }); + + expect(plan).toMatchObject({ kind: "withdraw" }); + expect(plan.kind === "withdraw" ? plan.deposits.map((deposit) => deposit.udtValue) : []).toEqual([secondExtra.udtValue]); + }); + + it("ignores near-ready refill exactly at the lookahead cutoff", () => { + const earlierSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const laterSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const atCutoff = readyDeposit(4n, 120n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [earlierSingleton, laterSingleton] as never[], + nearReadyDeposits: [atCutoff] as never[], + futurePoolDeposits: NO_FUTURE, + }); + + expect(plan).toMatchObject({ kind: "withdraw" }); + expect(plan.kind === "withdraw" ? plan.deposits.map((deposit) => deposit.udtValue) : []).toEqual([earlierSingleton.udtValue]); + }); + + it("ignores near-ready refill just outside the one-hour lookahead", () => { + const earlierSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const laterSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const outsideLookahead = readyDeposit(4n, 121n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [earlierSingleton, laterSingleton] as never[], + nearReadyDeposits: [outsideLookahead] as never[], + futurePoolDeposits: NO_FUTURE, + }); + + expect(plan).toMatchObject({ kind: "withdraw" }); + expect(plan.kind === "withdraw" ? plan.deposits.map((deposit) => deposit.udtValue) : []).toEqual([earlierSingleton.udtValue]); + }); + + it("returns none for tiny excess with a near-cap crowded extra", () => { + const singleton = readyDeposit(5n, 0n); + const crowdedEarly = readyDeposit(ICKB_DEPOSIT_CAP - 1n, 20n * 60n * 1000n); + const crowdedLate = readyDeposit(ICKB_DEPOSIT_CAP, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 1n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [singleton, crowdedEarly, crowdedLate] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("returns none when neither extras nor singletons fit", () => { + const singleton = readyDeposit(6n, 0n); + const crowdedProtected = readyDeposit(6n, 20n * 60n * 1000n); + const crowdedExtra = readyDeposit(7n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 5n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [crowdedExtra, crowdedProtected, singleton] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("limits withdrawal requests by the available output slots", () => { + const first = readyDeposit(3n, 0n); + const second = readyDeposit(3n, 20n * 60n * 1000n); + const third = readyDeposit(3n, 40n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 5, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 10n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [first, second, third] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }); + + expect(plan).toEqual({ + kind: "withdraw", + deposits: [first, second], + }); + }); + + it("caps withdrawal requests at thirty deposits", () => { + const readyDeposits = Array.from( + { length: 31 }, + (_, index) => readyDeposit(1n, BigInt(index) * 20n * 60n * 1000n), + ) as never[]; + + const plan = planRebalance({ + outputSlots: 100, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 100n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits, + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }); + + expect(plan).toMatchObject({ kind: "withdraw" }); + 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({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 3n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [readyDeposit(ICKB_DEPOSIT_CAP + 4n, 0n)] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("cleanup withdraws one over-cap extra and pins its protected anchor", () => { + const first = futureDeposit(20n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB, { isReady: true }); + const second = futureDeposit(25n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB, { isReady: true }); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [first, second] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ + kind: "withdraw", + deposits: [first], + requiredLiveDeposits: [second], + }); + }); + + it("does not clean up an exact-cap ready deposit", () => { + const capBait = futureDeposit(0n, ICKB_DEPOSIT_CAP, { isReady: true }); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 1n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [capBait] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("does not let cleanup consume a protected crowded ready anchor", () => { + const tinyExtra = futureDeposit(20n * 60n * 1000n, 1n, { isReady: true }); + const protectedAnchor = futureDeposit(25n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB, { isReady: true }); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [tinyExtra, protectedAnchor] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ + kind: "withdraw", + deposits: [tinyExtra], + requiredLiveDeposits: [protectedAnchor], + }); + }); + + it("ignores under-cap non-standard bait for cleanup", () => { + const dustBait = futureDeposit(0n, ICKB_DEPOSIT_CAP - 1n, { isReady: true }); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 1n, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [dustBait] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("does not clean up near-ready or future non-standard deposits", () => { + const nearReady = futureDeposit(105n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB); + const future = futureDeposit(10n, ICKB_DEPOSIT_CAP + CKB); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [], + nearReadyDeposits: [nearReady] as never[], + futurePoolDeposits: [future] as never[], + }), + ).toEqual({ kind: "none" }); + }); + + it("requests one ready singleton once anchors unlock and a full withdrawal keeps the target buffer", () => { + const deposit = readyDeposit(ICKB_DEPOSIT_CAP + CKB, 0n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [deposit] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "withdraw", deposits: [deposit] }); + }); + + it("does not request a full withdrawal that would cut below the target buffer", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [readyDeposit(ICKB_DEPOSIT_CAP + 2n * CKB, 0n)] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("does not let near-ready refill unlock cleanup below the target floor", () => { + const unsafeReady = futureDeposit(0n, ICKB_DEPOSIT_CAP + 2n * CKB, { isReady: true }); + const hugeNearReady = readyDeposit(10n * ICKB_DEPOSIT_CAP, 105n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [unsafeReady] as never[], + nearReadyDeposits: [hugeNearReady] as never[], + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("keeps deposit action exclusive when future seeding gates pass", () => { + const cleanupReady = futureDeposit(0n, ICKB_DEPOSIT_CAP + CKB, { isReady: true }); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [cleanupReady] as never[], + nearReadyDeposits: NO_NEAR_READY, + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "deposit", quantity: 1 }); + }); + + it("does not treat near-ready refill as current liquidity budget", () => { + const unsafeReady = readyDeposit(ICKB_DEPOSIT_CAP + 2n * CKB, 0n); + const hugeNearReady = readyDeposit(10n * ICKB_DEPOSIT_CAP, 105n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + CKB, + ckbBalance: 0n, + depositCapacity: 1000n, + readyDeposits: [unsafeReady] as never[], + nearReadyDeposits: [hugeNearReady] as never[], + futurePoolDeposits: NO_FUTURE, + }), + ).toEqual({ kind: "none" }); + }); + + it("does not let fake near-ready refill unlock singleton anchors before the excess gate", () => { + const earlierSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const laterSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const fakeRefill = readyDeposit(10n * ICKB_DEPOSIT_CAP, 105n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP - 1n, ckbBalance: 0n, depositCapacity: 1000n, - readyDeposits: [{ udtValue: 4n }] as never[], + readyDeposits: [earlierSingleton, laterSingleton] as never[], + nearReadyDeposits: [fakeRefill] as never[], + futurePoolDeposits: NO_FUTURE, }), ).toEqual({ kind: "none" }); }); diff --git a/apps/bot/src/policy.ts b/apps/bot/src/policy.ts index 0af69c5..a691310 100644 --- a/apps/bot/src/policy.ts +++ b/apps/bot/src/policy.ts @@ -1,35 +1,95 @@ import { ccc } from "@ckb-ccc/core"; import { ICKB_DEPOSIT_CAP, type IckbDepositCell } from "@ickb/core"; +import { selectBoundedUdtSubset } from "@ickb/utils"; export const CKB = ccc.fixedPointFrom(1); 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; +const OUTPUTS_PER_REBALANCE_ACTION = 2; +const READY_POOL_BUCKET_SPAN_MS = 15n * 60n * 1000n; +const BEST_FIT_SEARCH_CANDIDATES = 30; const MAX_WITHDRAWAL_REQUESTS = 30; +const SINGLETON_ANCHOR_OVERRIDE_EXCESS = ICKB_DEPOSIT_CAP; +const FRESH_DEPOSIT_TARGET_EPOCH_OFFSET: [bigint, bigint, bigint] = [180n, 0n, 1n]; +const FUTURE_SEGMENT_UNDERCOVERAGE_RATIO_DENOMINATOR = 2n; +const NEAR_READY_BUCKET_LOOKAHEAD = + NEAR_READY_LOOKAHEAD_MS / READY_POOL_BUCKET_SPAN_MS; export type RebalancePlan = | { kind: "none" } | { kind: "deposit"; quantity: 1 } - | { kind: "withdraw"; deposits: IckbDepositCell[] }; + | { + kind: "withdraw"; + deposits: IckbDepositCell[]; + requiredLiveDeposits?: IckbDepositCell[]; + }; + +export function partitionPoolDeposits( + deposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, + readyWindowEnd: bigint, +): { + ready: IckbDepositCell[]; + nearReady: IckbDepositCell[]; + future: IckbDepositCell[]; +} { + const ready: IckbDepositCell[] = []; + const nearReady: IckbDepositCell[] = []; + const future: IckbDepositCell[] = []; + const nearReadyCutoff = readyWindowEnd + NEAR_READY_LOOKAHEAD_MS; + + for (const deposit of deposits) { + const maturityUnix = deposit.maturity.toUnix(tip); + if (deposit.isReady) { + ready.push(deposit); + continue; + } + + if (maturityUnix < readyWindowEnd) { + continue; + } + + if (maturityUnix < nearReadyCutoff) { + nearReady.push(deposit); + continue; + } + + future.push(deposit); + } + + ready.sort(compareDepositsByMaturity(tip)); + nearReady.sort(compareDepositsByMaturity(tip)); + future.sort(compareDepositsByMaturity(tip)); + + return { ready, nearReady, future }; +} export function planRebalance(options: { outputSlots: number; + tip: ccc.ClientBlockHeader; ickbBalance: bigint; ckbBalance: bigint; depositCapacity: bigint; readyDeposits: readonly IckbDepositCell[]; + nearReadyDeposits: readonly IckbDepositCell[]; + futurePoolDeposits: readonly IckbDepositCell[]; }): RebalancePlan { const { outputSlots, + tip, ickbBalance, ckbBalance, depositCapacity, readyDeposits, + nearReadyDeposits, + futurePoolDeposits, } = options; - if (outputSlots < 2) { + if (outputSlots < OUTPUTS_PER_REBALANCE_ACTION) { return { kind: "none" }; } @@ -40,19 +100,305 @@ export function planRebalance(options: { return { kind: "none" }; } + if ( + shouldSeedFutureSegment( + futurePoolDeposits, + tip, + ickbBalance, + ckbBalance, + depositCapacity, + ) + ) { + return { kind: "deposit", quantity: 1 }; + } + const excessIckb = ickbBalance - TARGET_ICKB_BALANCE; if (excessIckb <= 0n) { return { kind: "none" }; } - const deposits = selectReadyDeposits( + const withdrawalLimit = Math.min( + MAX_WITHDRAWAL_REQUESTS, + Math.floor(outputSlots / OUTPUTS_PER_REBALANCE_ACTION), + ); + const cleanup = selectNonStandardCleanupDeposit( + readyDeposits, + tip, + ickbBalance, + ); + if (cleanup) { + return { + kind: "withdraw", + deposits: [cleanup.deposit], + requiredLiveDeposits: [cleanup.anchor], + }; + } + + const selection = selectPoolRebalancingDeposits( readyDeposits, + nearReadyDeposits, + tip, excessIckb, - Math.min(MAX_WITHDRAWAL_REQUESTS, Math.floor(outputSlots / 2)), + withdrawalLimit, + ); + if (selection.deposits.length > 0) { + return { + kind: "withdraw", + deposits: selection.deposits, + ...(selection.requiredLiveDeposits.length > 0 + ? { requiredLiveDeposits: selection.requiredLiveDeposits } + : {}), + }; + } + return { kind: "none" }; +} + +function selectPoolRebalancingDeposits( + readyDeposits: readonly IckbDepositCell[], + nearReadyDeposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, + maxAmount: bigint, + limit: number, +): { + deposits: IckbDepositCell[]; + requiredLiveDeposits: IckbDepositCell[]; +} { + if (maxAmount <= 0n || limit <= 0 || readyDeposits.length === 0) { + return { deposits: [], requiredLiveDeposits: [] }; + } + + const { cleanupExtras, extras, singletons, nonSingletonReady } = classifyReadyDeposits( + readyDeposits, + nearReadyDeposits, + tip, + ); + const anchorsByExtra = new Map( + cleanupExtras.map(({ deposit, anchor }) => [deposit, anchor]), + ); + const allowSingletonConsumption = canSpendSingletonAnchors(maxAmount); + const selectedExtras = selectReadyDeposits(extras, maxAmount, limit); + if (selectedExtras.length > 0) { + const remainingAmount = maxAmount - sumUdtValue(selectedExtras); + const remainingLimit = limit - selectedExtras.length; + if ( + !allowSingletonConsumption || + remainingAmount <= 0n || + remainingLimit <= 0 || + singletons.length === 0 + ) { + return selectionWithRequiredAnchors(selectedExtras, anchorsByExtra); + } + + const selectedSingletons = selectReadyDeposits( + singletons, + remainingAmount, + remainingLimit, + ); + if (selectedSingletons.length === 0) { + return selectionWithRequiredAnchors(selectedExtras, anchorsByExtra); + } + + const selected = new Set([ + ...selectedExtras, + ...selectedSingletons, + ]); + return selectionWithRequiredAnchors( + readyDeposits.filter((deposit) => selected.has(deposit)), + anchorsByExtra, + ); + } + + if (!allowSingletonConsumption) { + return selectionWithRequiredAnchors( + selectReadyDeposits(nonSingletonReady, maxAmount, limit), + anchorsByExtra, + ); + } + + const selectedSingletons = selectReadyDeposits(singletons, maxAmount, limit); + if (selectedSingletons.length === 0) { + return selectionWithRequiredAnchors( + selectReadyDeposits(readyDeposits, maxAmount, limit), + anchorsByExtra, + ); + } + + return { deposits: selectedSingletons, requiredLiveDeposits: [] }; +} + +function selectionWithRequiredAnchors( + deposits: IckbDepositCell[], + anchorsByExtra: ReadonlyMap, +): { + deposits: IckbDepositCell[]; + requiredLiveDeposits: IckbDepositCell[]; +} { + const requiredLiveDeposits: IckbDepositCell[] = []; + const seen = new Set(); + for (const deposit of deposits) { + const anchor = anchorsByExtra.get(deposit); + if (!anchor || seen.has(anchor)) { + continue; + } + seen.add(anchor); + requiredLiveDeposits.push(anchor); + } + + return { deposits, requiredLiveDeposits }; +} + +function shouldSeedFutureSegment( + futurePoolDeposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, + ickbBalance: bigint, + ckbBalance: bigint, + depositCapacity: bigint, +): boolean { + const futureDeposits = futurePoolDeposits.filter((deposit) => !deposit.isReady); + + if (!canCreateFutureInventory(ickbBalance, ckbBalance, depositCapacity)) { + return false; + } + + if (futureDeposits.length === 0) { + return true; + } + + if (futureDeposits.length === 1) { + return false; + } + + const futureLayout = analyzeFutureSegments(futureDeposits, tip); + if (futureDeposits.length === 2 && !futureLayout.anchorsShareOneSegment) { + return false; + } + + if (futureLayout.totalFutureUdt <= 0n) { + return false; + } + + return isUnderCoveredFutureSegment( + futureLayout.targetSegment.udtValue, + futureLayout.targetSegment.length, + futureLayout.totalFutureUdt, + futureLayout.ringLength, + ); +} + +function canCreateFutureInventory( + ickbBalance: bigint, + ckbBalance: bigint, + depositCapacity: bigint, +): boolean { + return ( + ickbBalance > MIN_ICKB_BALANCE && + ckbBalance >= depositCapacity + CKB_RESERVE && + ickbBalance + ICKB_DEPOSIT_CAP <= TARGET_ICKB_BALANCE + ); +} + +function selectNonStandardCleanupDeposit( + readyDeposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, + ickbBalance: bigint, +): ReadyExtra | undefined { + const { cleanupExtras } = classifyReadyDeposits(readyDeposits, [], tip); + + return cleanupExtras.find( + ({ deposit }) => + deposit.isReady && + deposit.udtValue > ICKB_DEPOSIT_CAP && + ickbBalance - deposit.udtValue >= TARGET_ICKB_BALANCE, ); - return deposits.length === 0 - ? { kind: "none" } - : { kind: "withdraw", deposits }; +} + +function classifyReadyDeposits( + readyDeposits: readonly IckbDepositCell[], + nearReadyDeposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, +): { + extras: IckbDepositCell[]; + cleanupExtras: ReadyExtra[]; + singletons: IckbDepositCell[]; + nonSingletonReady: IckbDepositCell[]; +} { + const readyBuckets = new Map(); + const nearReadyBucketValues = new Map(); + + for (const deposit of readyDeposits) { + const key = deposit.maturity.toUnix(tip) / READY_POOL_BUCKET_SPAN_MS; + const bucket = readyBuckets.get(key); + if (bucket) { + bucket.push(deposit); + continue; + } + readyBuckets.set(key, [deposit]); + } + + for (const deposit of nearReadyDeposits) { + const key = deposit.maturity.toUnix(tip) / READY_POOL_BUCKET_SPAN_MS; + nearReadyBucketValues.set( + key, + (nearReadyBucketValues.get(key) ?? 0n) + deposit.udtValue, + ); + } + + const crowdedBuckets: ReadyBucket[] = []; + const singletonBuckets: ReadyBucket[] = []; + for (const [key, deposits] of readyBuckets) { + const protectedDeposit = selectProtectedBucketDeposit(deposits); + const totalValue = sumUdtValue(deposits); + const bucket = { + key, + deposits, + protectedDeposit, + extraValue: totalValue - protectedDeposit.udtValue, + futureRefillValue: futureRefillValueForBucket(key, nearReadyBucketValues), + } satisfies ReadyBucket; + + if (deposits.length === 1) { + singletonBuckets.push(bucket); + } else { + crowdedBuckets.push(bucket); + } + } + + crowdedBuckets.sort(compareCrowdedBuckets); + singletonBuckets.sort(compareSingletonBuckets); + + const cleanupExtras = crowdedBuckets.flatMap((bucket) => + bucket.deposits + .filter((deposit) => deposit !== bucket.protectedDeposit) + .map((deposit) => ({ deposit, anchor: bucket.protectedDeposit })) + ); + + return { + extras: cleanupExtras.map(({ deposit }) => deposit), + cleanupExtras, + singletons: singletonBuckets.flatMap((bucket) => bucket.deposits), + nonSingletonReady: crowdedBuckets.flatMap((bucket) => bucket.deposits), + }; +} + +function canSpendSingletonAnchors(excessIckb: bigint): boolean { + return excessIckb >= SINGLETON_ANCHOR_OVERRIDE_EXCESS; +} + +function selectProtectedBucketDeposit( + deposits: readonly IckbDepositCell[], +): IckbDepositCell { + let protectedDeposit = deposits[0]; + if (!protectedDeposit) { + throw new Error("Expected at least one deposit in bucket"); + } + + for (const deposit of deposits.slice(1)) { + if (deposit.udtValue >= protectedDeposit.udtValue) { + protectedDeposit = deposit; + } + } + + return protectedDeposit; } export function selectReadyDeposits( @@ -60,10 +406,21 @@ export function selectReadyDeposits( maxAmount: bigint, limit = MAX_WITHDRAWAL_REQUESTS, ): T[] { - if (maxAmount <= 0n || limit <= 0) { + if (maxAmount <= 0n || limit <= 0 || deposits.length === 0) { return []; } + const bestFit = selectBestFitDeposits(deposits, maxAmount, limit); + const greedy = selectGreedyDeposits(deposits, maxAmount, limit); + + return pickBetterSelection(deposits, bestFit, greedy); +} + +function selectGreedyDeposits( + deposits: readonly T[], + maxAmount: bigint, + limit: number, +): T[] { const selected: T[] = []; let cumulative = 0n; @@ -82,3 +439,267 @@ export function selectReadyDeposits( return selected; } + +function selectBestFitDeposits( + deposits: readonly T[], + maxAmount: bigint, + limit: number, +): T[] { + return selectBoundedUdtSubset(deposits, maxAmount, { + candidateLimit: BEST_FIT_SEARCH_CANDIDATES, + minCount: 1, + maxCount: limit, + }); +} + +function pickBetterSelection( + deposits: readonly T[], + left: T[], + right: T[], +): T[] { + const leftTotal = sumUdtValue(left); + const rightTotal = sumUdtValue(right); + if (leftTotal > rightTotal) { + return left; + } + + if (rightTotal > leftTotal) { + return right; + } + + return compareSelectionOrder(deposits, left, right) <= 0 ? left : right; +} + +function compareSelectionOrder( + deposits: readonly T[], + left: readonly T[], + right: readonly T[], +): number { + const leftSet = new Set(left); + const rightSet = new Set(right); + + for (const deposit of deposits) { + const inLeft = leftSet.has(deposit); + const inRight = rightSet.has(deposit); + if (inLeft === inRight) { + continue; + } + + return inLeft ? -1 : 1; + } + + return 0; +} + +function sumUdtValue( + deposits: readonly { udtValue: bigint }[], +): bigint { + let total = 0n; + for (const deposit of deposits) { + total += deposit.udtValue; + } + return total; +} + +function futureRefillValueForBucket( + bucketKey: bigint, + nearReadyBucketValues: ReadonlyMap, +): bigint { + let total = 0n; + // nearReadyDeposits only cover maturities after the current ready window, so + // refill for one ready bucket starts in the next absolute maturity bucket. + for (let offset = 1n; offset <= NEAR_READY_BUCKET_LOOKAHEAD; offset += 1n) { + total += nearReadyBucketValues.get(bucketKey + offset) ?? 0n; + } + return total; +} + +// Phase 1 future shaping keeps the current direct-deposit transaction shape, +// but it now uses the historical 180-epoch ring in the smallest honest live +// form: ringLength = tip+180 epochs - tip, origin = absolute unix 0 modulo that +// ring, Q = 2^(ceil(log2(anchorCount))) for 2+ future anchors, and wraparound = +// floor((maturityUnix mod ringLength) * Q / ringLength). Low-count base cases +// stay explicit: 0 bootstraps the first anchor, 1 preserves the lone anchor, +// and 2 only shape if both anchors crowd the same Q=2 segment. +function futureRingLengthForTip(tip: ccc.ClientBlockHeader): bigint { + return tip.epoch.add(FRESH_DEPOSIT_TARGET_EPOCH_OFFSET).toUnix(tip) - + tip.epoch.toUnix(tip); +} + +function analyzeFutureSegments( + futurePoolDeposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, +): FutureLayout { + const ringLength = futureRingLengthForTip(tip); + const segmentCount = nextPowerOfTwo(futurePoolDeposits.length); + const targetSegmentIndex = futureSegmentIndexForUnix( + tip.epoch.add(FRESH_DEPOSIT_TARGET_EPOCH_OFFSET).toUnix(tip), + ringLength, + segmentCount, + ); + const segments = Array.from({ length: segmentCount }, (_, index) => ({ + index, + length: + futureSegmentBoundary(index + 1, ringLength, segmentCount) - + futureSegmentBoundary(index, ringLength, segmentCount), + deposits: [] as IckbDepositCell[], + udtValue: 0n, + } satisfies FutureSegment)); + + let totalFutureUdt = 0n; + let firstSegmentIndex: number | undefined; + let anchorsShareOneSegment = true; + + for (const deposit of futurePoolDeposits) { + totalFutureUdt += deposit.udtValue; + + const segmentIndex = futureSegmentIndexForUnix( + deposit.maturity.toUnix(tip), + ringLength, + segmentCount, + ); + const segment = segments[segmentIndex]; + if (!segment) { + throw new Error("Expected future segment to exist"); + } + segment.deposits.push(deposit); + segment.udtValue += deposit.udtValue; + + if (firstSegmentIndex === undefined) { + firstSegmentIndex = segmentIndex; + continue; + } + + if (segmentIndex !== firstSegmentIndex) { + anchorsShareOneSegment = false; + } + } + + const targetSegment = segments[targetSegmentIndex]; + if (!targetSegment) { + throw new Error("Expected target future segment to exist"); + } + + return { + ringLength, + segmentCount, + targetSegmentIndex, + targetSegment, + totalFutureUdt, + anchorsShareOneSegment, + segments, + }; +} + +function nextPowerOfTwo(value: number): number { + let power = 1; + while (power < value) { + power *= 2; + } + return power; +} + +function futureSegmentBoundary( + segmentIndex: number, + ringLength: bigint, + segmentCount: number, +): bigint { + return (ringLength * BigInt(segmentIndex)) / BigInt(segmentCount); +} + +function futureSegmentIndexForUnix( + maturityUnix: bigint, + ringLength: bigint, + segmentCount: number, +): number { + const wrappedMaturity = ((maturityUnix % ringLength) + ringLength) % ringLength; + return Number((wrappedMaturity * BigInt(segmentCount)) / ringLength); +} + +function isUnderCoveredFutureSegment( + segmentUdtValue: bigint, + segmentLength: bigint, + totalFutureUdt: bigint, + ringLength: bigint, +): boolean { + return ( + FUTURE_SEGMENT_UNDERCOVERAGE_RATIO_DENOMINATOR * segmentUdtValue * ringLength < + totalFutureUdt * segmentLength + ); +} + +function compareCrowdedBuckets(left: ReadyBucket, right: ReadyBucket): number { + const extraCompare = compareBigInt(right.extraValue, left.extraValue); + if (extraCompare !== 0) { + return extraCompare; + } + + const refillCompare = compareBigInt( + right.futureRefillValue, + left.futureRefillValue, + ); + if (refillCompare !== 0) { + return refillCompare; + } + + return compareBigInt(left.key, right.key); +} + +function compareSingletonBuckets(left: ReadyBucket, right: ReadyBucket): number { + const refillCompare = compareBigInt( + right.futureRefillValue, + left.futureRefillValue, + ); + if (refillCompare !== 0) { + return refillCompare; + } + + return compareBigInt(left.key, right.key); +} + +function compareBigInt(left: bigint, right: bigint): number { + if (left < right) { + return -1; + } + + if (left > right) { + return 1; + } + + return 0; +} + +function compareDepositsByMaturity(tip: ccc.ClientBlockHeader) { + return (left: IckbDepositCell, right: IckbDepositCell): number => + compareBigInt(left.maturity.toUnix(tip), right.maturity.toUnix(tip)); +} + +interface ReadyBucket { + key: bigint; + deposits: IckbDepositCell[]; + protectedDeposit: IckbDepositCell; + extraValue: bigint; + futureRefillValue: bigint; +} + +interface ReadyExtra { + deposit: IckbDepositCell; + anchor: IckbDepositCell; +} + +interface FutureLayout { + ringLength: bigint; + segmentCount: number; + targetSegmentIndex: number; + targetSegment: FutureSegment; + totalFutureUdt: bigint; + anchorsShareOneSegment: boolean; + segments: FutureSegment[]; +} + +interface FutureSegment { + index: number; + length: bigint; + deposits: IckbDepositCell[]; + udtValue: bigint; +} From 659471ff9fdee4ac7632f45c0193825fcc5baeba Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 10 May 2026 11:31:44 +0000 Subject: [PATCH 4/9] docs(bot): document live rebalancing policy --- apps/bot/README.md | 14 +- apps/bot/docs/README.md | 13 +- apps/bot/docs/current_rebalancing_policy.md | 167 ++++++++++++++------ apps/bot/docs/pool_rebalancing.md | 95 ----------- apps/bot/docs/pool_snapshot.md | 73 --------- 5 files changed, 128 insertions(+), 234 deletions(-) delete mode 100644 apps/bot/docs/pool_rebalancing.md delete mode 100644 apps/bot/docs/pool_snapshot.md diff --git a/apps/bot/README.md b/apps/bot/README.md index 1777cb7..ca8fd08 100644 --- a/apps/bot/README.md +++ b/apps/bot/README.md @@ -1,15 +1,12 @@ # iCKB Bot -The bot is now CCC-native. It reads market state from `@ickb/sdk`, melts the bot's own orders, completes matured receipts and withdrawals, matches profitable limit orders, optionally rebalances between CKB and iCKB, then completes iCKB UDT balance, CKB capacity, fees, signs, and sends. +The bot is CCC-native. It reads market state from `@ickb/sdk`, matches profitable limit orders, collects the bot's own orders, completes receipts and ready withdrawals, optionally rebalances between CKB and iCKB, completes iCKB UDT balance, CKB capacity, and fees, then signs, sends, and waits for commit. -The bot still aims to minimize excess iCKB holdings so more liquidity stays available in CKB during iCKB-to-CKB redemption pressure. +The bot minimizes excess iCKB holdings so more liquidity stays available in CKB during iCKB-to-CKB redemption pressure. ## Docs - [Current Bot Rebalancing Policy](docs/current_rebalancing_policy.md) -- Future improvement ideas: - - [iCKB Deposit Pool Rebalancing Algorithm](docs/pool_rebalancing.md) - - [iCKB Deposit Pool Snapshot Encoding](docs/pool_snapshot.md) ## Environment @@ -58,15 +55,14 @@ pnpm run start:loop `CHAIN` selects `env/${CHAIN}/.env`, which must contain the remaining runtime variables such as `BOT_PRIVATE_KEY` and `BOT_SLEEP_INTERVAL`. -The start script keeps the existing JSON log format and writes one log file per run. +The start script writes JSON logs and one log file per run. Intentional shutdowns, including low capital and transaction confirmation timeouts after broadcast, exit with code `2`; `start:loop` stops on that code instead of restarting immediately. ## Notes - Distribute liquidity across multiple isolated bots to limit blast radius. - Keep at least roughly 130k CKB worth of capital available for the bot to operate comfortably. -- The bot relies on shared CCC packages for protocol-specific transaction content, but it still owns final iCKB completion, fee completion, signing, and send. -- The interface-side maturity estimate contract now lives with `@ickb/sdk`, because the SDK owns how bot liquidity and pool maturities are summarized for UI consumers. +- The bot relies on shared CCC packages for protocol-specific transaction content and owns final iCKB completion, fee completion, signing, sending, and commit waiting. ## Licensing -Released under the [MIT License](../../LICENSE). +Released under the [MIT License](https://github.com/ickb/stack/blob/master/LICENSE). diff --git a/apps/bot/docs/README.md b/apps/bot/docs/README.md index de151a5..10d35db 100644 --- a/apps/bot/docs/README.md +++ b/apps/bot/docs/README.md @@ -1,14 +1,5 @@ -# iCKB Fulfillment Bot Document Directory +# iCKB Bot Docs -This directory hosts comprehensive documentation outlining the inner workings of the iCKB Fulfillment Bot. As a living document, it will be continuously updated to reflect the Bot’s evolution and ongoing improvements. - -## Documents - -### Current runtime behavior +This directory documents the current iCKB bot runtime behavior. - [Current Bot Rebalancing Policy](current_rebalancing_policy.md) - -### Future improvement ideas - -- [iCKB Deposit Pool Rebalancing Algorithm](pool_rebalancing.md) -- [iCKB Deposit Pool Snapshot Encoding](pool_snapshot.md) diff --git a/apps/bot/docs/current_rebalancing_policy.md b/apps/bot/docs/current_rebalancing_policy.md index 1d9b110..bd44cf8 100644 --- a/apps/bot/docs/current_rebalancing_policy.md +++ b/apps/bot/docs/current_rebalancing_policy.md @@ -1,75 +1,150 @@ # Current Bot Rebalancing Policy -This document describes the policy currently implemented in `apps/bot/src/policy.ts`. +This document describes the behavior implemented by `apps/bot/src/index.ts`, `apps/bot/src/runtime.ts`, and `apps/bot/src/policy.ts`. ## Goal -The bot keeps enough liquid iCKB to keep matching and redemption paths responsive, while leaving as much capital as practical in CKB. +The bot keeps enough liquid iCKB for order matching and withdrawals while leaving as much capital as practical in CKB. Each loop builds at most one completed transaction, sends it, and waits until the transaction is committed before starting the next loop. -The live policy is intentionally small: +The bot exits when its total CKB-equivalent capital is less than or equal to `21 / 20 * depositCapacity`, where `depositCapacity` is recalculated from the live exchange ratio. -- keep a minimum iCKB inventory -- refill that inventory with one direct deposit when it gets too low -- request withdrawals from ready pool deposits when iCKB inventory drifts too high -- do nothing when output space or balances make the action unsafe +## Runtime State -## Inputs +The runtime reads system and account state through `@ickb/sdk`, then derives the balances and pool slices used by `planRebalance(...)`. -`planRebalance(...)` decides from five inputs: +- `accountLocks`: all signer address locks, deduplicated by full script bytes. +- `system`: live exchange ratio, tip header, fee rate, and market order pool from `sdk.getL1State(...)`. +- `userOrders`: the bot's order groups from `sdk.getL1State(...)`. +- `account`: spendable capacity, native iCKB, receipts, and withdrawals from `sdk.getAccountState(...)`. +- `marketOrders`: system order-pool entries not already owned by the bot. +- `readyPoolDeposits`: pool deposits that are ready now. +- `nearReadyPoolDeposits`: not-ready pool deposits from the end of the current ready window until, but not including, one hour later. +- `futurePoolDeposits`: not-ready pool deposits after that near-ready hour. +- `availableCkbBalance` and `availableIckbBalance`: account balances projected with collected orders available. +- `unavailableCkbBalance`: CKB pending in not-ready withdrawals. +- `depositCapacity`: CKB required for one standard 100,000 iCKB deposit at the live exchange ratio. +- `minCkbBalance`: shutdown threshold set to `21 / 20 * depositCapacity`. -- `outputSlots`: how many transaction output slots remain before the bot would hit its DAO-safe output cap -- `ickbBalance`: currently available iCKB after pending order matches are applied -- `ckbBalance`: currently available CKB after pending order matches are applied -- `depositCapacity`: the current CKB capacity required for one standard iCKB deposit at the live exchange ratio -- `readyDeposits`: ready pool deposits that the bot can request for withdrawal now +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. -## Constants +`nearReadyPoolDeposits` only ranks ready-window withdrawal choices. Fresh deposits are scored against `futurePoolDeposits`, not against the near-ready hour. -The current policy is shaped by three constants in `apps/bot/src/policy.ts`: +## Constants -- `CKB_RESERVE = 1000 CKB`: the bot keeps this much extra CKB after making a new deposit -- `MIN_ICKB_BALANCE = 2000 iCKB`: if iCKB falls below this line, the bot tries to replenish it -- `TARGET_ICKB_BALANCE = 100000 iCKB + 20000 iCKB`: if iCKB rises above this target band, the bot tries to convert excess iCKB back toward CKB through ready deposit withdrawals +- `CKB_RESERVE = 1000 CKB`: CKB left aside when creating a direct refill or future inventory. +- `MIN_ICKB_BALANCE = 2000 iCKB`: below this value, the bot prioritizes a direct deposit refill. +- `TARGET_ICKB_BALANCE = 120000 iCKB`: above this value, the bot may request ready withdrawals. +- `NEAR_READY_LOOKAHEAD_MS = 1 hour`: exclusive horizon used to compute ready-bucket refill tie-breaks. +- `READY_POOL_BUCKET_SPAN_MS = 15 minutes`: maturity bucket width for ready deposit selection. +- `MAX_WITHDRAWAL_REQUESTS = 30`: maximum deposits requested for withdrawal by one rebalance action. +- `BEST_FIT_SEARCH_CANDIDATES = 30`: bounded top-ranked horizon for exact subset selection. -The current withdrawal request cap is `30` deposits per transaction. +One direct deposit or withdrawal request uses two output slots. The bot computes remaining output slots before rebalancing as `58 - tx.outputs.length` after order matches have been added. ## Decision Order -The policy is deliberately greedy and local. +`planRebalance(...)` returns one of three actions: `none`, `deposit`, or `withdraw`. -1. If fewer than two output slots remain, do nothing. -2. If available iCKB is below `MIN_ICKB_BALANCE`: - - request one new deposit if available CKB is at least `depositCapacity + CKB_RESERVE` - - otherwise do nothing -3. If available iCKB is at or above `MIN_ICKB_BALANCE`, compute `excessIckb = ickbBalance - TARGET_ICKB_BALANCE`. -4. If `excessIckb <= 0`, do nothing. -5. Otherwise, pick a bounded subset of ready deposits whose total `udtValue` stays within `excessIckb`, and request withdrawals for that subset. +1. If fewer than two output slots remain, return `none`. +2. If `ickbBalance < MIN_ICKB_BALANCE`, return one `deposit` only when `ckbBalance >= depositCapacity + CKB_RESERVE`; otherwise return `none`. +3. If future seeding gates pass, return one direct `deposit`. +4. Compute `excessIckb = ickbBalance - TARGET_ICKB_BALANCE`. +5. If `excessIckb <= 0`, return `none`. +6. Try at most one ready-only non-standard cleanup withdrawal. +7. Select ordinary ready deposits for withdrawal using the ready-window rules below. +8. If no withdrawal candidate satisfies the rules, return `none`. -## Ready Deposit Selection +Runtime transaction construction applies the chosen action after order matching. For `withdraw`, the withdrawal request is passed into `sdk.buildBaseTransaction(...)`. For `deposit`, `logic.deposit(...)` adds the fresh deposit. The bot completes iCKB UDT balance, CKB capacity, fees, and the DAO output-limit check through `sdk.completeTransaction(...)` before signing. -`selectReadyDeposits(...)` is intentionally simple. +## Future Inventory -- It walks the ready deposits in the order they were prepared by the bot state reader. -- It skips any deposit that would push the cumulative selected `udtValue` above the current excess target. -- It stops once it reaches the request limit. +Future inventory actions use a fixed 180-epoch ring model around the coarse fresh-deposit target `tip.epoch.add([180, 0, 1]).toUnix(tip)`. This target is only a candidate region for a future deposit, not an exact post-inclusion maturity prediction. -This keeps the live policy predictable and cheap. It does not try to globally optimize pool shape. +The ring model is: -## Ownership Boundary +- ring length: `tip.epoch.add([180, 0, 1]).toUnix(tip) - tip.epoch.toUnix(tip)` +- origin: absolute unix `0` modulo the ring length +- segment count: `2^(ceil(log2(futureDepositCount)))` +- segment index: `floor(((maturityUnix mod ringLength) * segmentCount) / ringLength)` +- segment density: `segmentUdtValue / segmentLength` +- average density: `totalFutureUdt / ringLength` -This file describes bot-owned operating policy only. +The target segment is under-covered when `targetDensity < 0.5 * averageDensity`. If total future `udtValue` is zero, density-based seeding does not run. -- The bot owns when to add one more deposit. -- The bot owns when to request ready withdrawals. -- `@ickb/sdk` owns UI-side maturity estimation from live stack state. -- The older pool snapshot idea is not part of the current runtime path. +Future seeding requires all future-inventory creation gates: -## Non-Goals +- `ickbBalance > MIN_ICKB_BALANCE` +- `ckbBalance >= depositCapacity + CKB_RESERVE` +- `ickbBalance + ICKB_DEPOSIT_CAP <= TARGET_ICKB_BALANCE` + +Then the topology rules apply: + +- `0` future deposits: return one direct `deposit`. +- `1` future deposit: return `none`. +- `2` future deposits: seed only when both deposits land in the same `Q = 2` segment and the target segment is under-covered. +- `3+` future deposits: seed when the target segment is under-covered. + +Public future pool shape may veto or choose whether the already-budgeted direct deposit targets the first future segment policy path, but it cannot create any withdrawal request, same-transaction rotation, retry widening, or persistent state. This is the non-amplification invariant: public pool state is negative-only for removals. A known-code attacker can crowd, drain, dust, or stale-shape public future deposits, but those shapes can only block or admit the bot's independently budgeted direct deposit; they cannot make the bot remove future liquidity. + +Far-future withdrawal, same-transaction future rotation, retry widening, and persistence are disabled. + +## Non-Standard Cleanup + +Non-standard cleanup is a narrow ready-only withdrawal path for crowded-bucket extras whose iCKB value is larger than one standard deposit. It runs only after output slots and `excessIckb` are known and only when no deposit action has already been selected. + +The bot admits at most one cleanup candidate per rebalance. The candidate must come from `readyPoolDeposits`, have `deposit.isReady === true`, be a withdrawable extra rather than a singleton or protected crowded anchor, have `deposit.udtValue > ICKB_DEPOSIT_CAP`, and leave `ickbBalance - deposit.udtValue >= TARGET_ICKB_BALANCE`. Cleanup also pins the protected anchor from the same ready bucket as a `cell_dep`; if that anchor is spent before inclusion, the cleanup transaction fails instead of consuming the extra as the new live anchor. + +The value-positive predicate is intentionally the implementation predicate from `@ickb/core`: iCKB value discounts only amounts above `ICKB_DEPOSIT_CAP`, so cleanup starts with `deposit.udtValue > ICKB_DEPOSIT_CAP`. Under-cap and cap-sized dust are ignored. + +Cleanup does not inspect `nearReadyPoolDeposits` or `futurePoolDeposits`, does not persist observations, does not widen retries, and does not couple a withdrawal to a same-transaction deposit. It classifies ready buckets without near-ready refill, so public near-ready state cannot steer cleanup. Pending CKB from the withdrawal is not treated as liquid CKB for future seeding until the normal send loop observes it in account state after chain processing. + +The `ickbBalance` used for cleanup is the post-match liquid iCKB passed to `planRebalance(...)`. Positive-gain matched orders are already selected before rebalancing and are treated as current transaction liquidity; public pool candidates still cannot enlarge the cleanup budget. + +Cleanup is not a standard redeposit policy. The bot may later create standard deposits only through the ordinary deposit gates, in a later exclusive rebalance action. -This policy does not try to: +Attack assumption: a known-code attacker can add near-ready, future, under-cap, cap-sized, or over-cap public deposits. Only a ready over-cap extra that preserves the target liquid iCKB floor can be removed, and only one per loop. Public non-ready state cannot unlock cleanup, protected-anchor consumption, or same-transaction rotation. This is the cleanup non-amplification invariant. + +## Ready Withdrawals + +Ready withdrawals run only when `ickbBalance > TARGET_ICKB_BALANCE` and no deposit action has already been selected. + +The selector groups ready deposits into 15-minute maturity buckets. + +- A bucket with one ready deposit is a singleton anchor. +- A bucket with multiple ready deposits is crowded. +- In each crowded bucket, the protected deposit is the largest `udtValue` deposit. With equal values, the runtime keeps the latest deposit because ready deposits are sorted by maturity before selection. +- The other deposits in crowded buckets are withdrawable extras. +- Crowded buckets rank by withdrawable extra value first, then by near-ready refill in the following hour, then by earlier bucket. +- Singleton buckets rank by near-ready refill first, then by earlier bucket. + +Candidate selection calls `selectReadyDeposits(...)`, which compares a bounded best-fit search over the top 30 ranked candidates against a greedy scan over the full candidate list. The selected set is the higher-value valid subset under the amount and count limits. Ties keep the earlier candidate order. + +Singleton anchors are spendable only when `excessIckb >= ICKB_DEPOSIT_CAP`. + +The ordinary ready withdrawal flow is: + +1. Try crowded-bucket extras under `excessIckb`. +2. If extras were selected and singleton consumption is unlocked, top up from singleton buckets with remaining amount and withdrawal slots. +3. If no extras were selected and singleton consumption is locked, try all non-singleton ready deposits. +4. If singleton consumption is unlocked, try singleton buckets, then all ready deposits. + +When an ordinary withdrawal selects a crowded-bucket extra, the transaction also pins that bucket's protected deposit as a `cell_dep`. If the protected deposit is spent before inclusion, the withdrawal transaction fails instead of succeeding against stale bucket classification. This is only an inclusion-time liveness check: it does not reserve public protected deposits after the bot transaction commits, and it cannot stop a later same-block or later transaction from spending a public protected deposit. + +Withdrawal count is capped by `min(MAX_WITHDRAWAL_REQUESTS, floor(outputSlots / 2))`. + +## Send Loop + +The bot validates `BOT_SLEEP_INTERVAL` as a finite number of seconds greater than or equal to one. Each loop sleeps for a random duration from `0` to `2 * BOT_SLEEP_INTERVAL`, builds at most one transaction, sends it, and polls the transaction status every 10 seconds until it is committed. `sent`, `pending`, `proposed`, `unknown`, and missing status are treated as pending. Rejected transactions and confirmation timeouts are reported in the JSON log with the broadcast hash when one exists. Confirmation timeouts stop the loop with exit code `2` so the wrapper does not immediately build conflicting replacement work. + +## Non-Goals -- maintain a global optimal distribution of deposits over the full 180-epoch clock -- encode a snapshot summary for interface use -- predict or coordinate other bots' behavior beyond acting on current visible state +The bot does not try to: -Those may still be useful research directions, but they are not the current live contract. +- globally optimize the full 180-epoch pool +- predict the exact inclusion maturity of a pending fresh deposit +- withdraw far-future deposits or rotate future sources in the same transaction as a fresh deposit +- create future inventory when reserve, minimum iCKB, target-band, or output-slot gates fail +- persist future-pool observations or retry-widen across loops +- treat pending CKB from cleanup withdrawals as liquid before account state reports it +- encode or publish a pool snapshot summary +- coordinate with other bots beyond the current visible chain state diff --git a/apps/bot/docs/pool_rebalancing.md b/apps/bot/docs/pool_rebalancing.md deleted file mode 100644 index faf22eb..0000000 --- a/apps/bot/docs/pool_rebalancing.md +++ /dev/null @@ -1,95 +0,0 @@ -# iCKB Deposit Pool Rebalancing Algorithm - -Future improvement idea: this document captures a more ambitious rebalancing design that is not the current live bot policy. The current implemented behavior is documented in `current_rebalancing_policy.md`. - -For simplicity, let's model: - -- NervosDAO 180 epoch cycle as a circular clock. -- Current Tip Header Epoch as the the clock needle. -- iCKB Deposits as coins scattered continuously along the clock perimeter. -- iCKB Deposit Pool size as the total coins. -- Making an iCKB Deposit into the Pool as depositing a coin. -- Withdrawing an iCKB Deposit from the Pool as picking up a coin. - -## Environment - -- **Setting Idea:** A circular clock with coins scattered continuously along the perimeter. The environment deterministically over time sets the direction in which the agent is looking at, the one pointed by the needle. -- **Dynamics:** Our agent can only interact with coins pointed by the needle. External parties can add or remove coins arbitrarily, so the total coins `N` may change over time. - -## Agent Details - -- **Position:** Center of the circle, facing a specific segment of the perimeter. -- **Action:** Operates in discrete snapshots, snapshots occur at regular intervals. -- **Container:** Holds `m` coins up to a capacity `M`. -- **Memory:** None (each snapshot is processed independently). - -## Overall Objectives - -- **Uniformity:** Aim for a reasonably uniform coin distribution across the perimeter. - -- **Minimize Re-shuffling:** Minimize coin re-shuffling when external parties add or remove coins by using a robust representation. - -- **Maximize Holdings:** Maximize the coins held by the agent (up to `M`). - -## Perimeter Segmentation - -- **Free Coins:** `O = N + m - M`, which accounts for the total coins available minus the agent's capacity. This way `O` is independent from the coins held by agent. - -- **Segmentation Function:** Total segments `Q = 2^(ceil(log2(O)))`. Each segment have the same length and it can be indexed (0,1,2...) starting from an absolute origin that remains unchanged across snapshots. Segmentation is recalculated at every snapshot, so coins placed previously may change immediately the segment they belong to. Segment evaluation cycles modulo `O`: 0, 1, 2, … `O` - 1 and then repeats. - -- **Segment Priority:** - - **High-Priority Segments:** Odd-numbered segments (indices 1,3...) are High-Priority and at equilibrium each must have exactly one coin. There are `Q/2` of them. - - **Low-Priority Segments:** Even-numbered segments (indices 0,2...) are Low-Priority and at equilibrium each must have either one or zero coins. There are `Q/2` of them, but at equilibrium only `O - Q/2` will have one coin, while the rest `Q - O` will have zero coins. - - **Visual Idea:** We can visualize this segmentation prioritization as the alternating colors of the outer rim of a circular Dart Board. - -## Strategy and Dynamics - -Given a snapshot, segmentation recalculation always occurs prior to action decisions. Agent actions are greedy (taken as soon as available) to minimize disruptions from other agents, which modify the system state outside of our agent control. - -### General Pick-up Rules - -- Never pick up the last coin of an High-Priority Segment. -- Pick up coins from each segment sequentially, starting with the coin nearest the segment’s beginning and proceeding toward its end. Within any stack of coins at the same position, pick them from the top down. Leave the last segment coin in place if applicable. - -### General Deposit Rules - -- When depositing a coin, deposit it as close as possible to the end of the chosen segment, while still staying in the right segment by a reasonably small margin. This margin size is snapshot-based, extremely small relative to segment size. This ensures that if resolution increases and the interval is subdivided, the coin remains in the high-priority portion. - -### Initial State: Non-Equilibrium - -**State:** - -- At least one High-Priority Segment has zero coins. -- Some segments have multiple coins. - -**Pick-up Strategy:** Follow general rules for multiple coins in one segment. If no segments with multiple coins exist, pick up coins from Low-Priority Segments. - -**Deposit Strategy:** Deposit a coin when an High-Priority Segment is empty and the agent has coins. - -### Intermediate Target: Near Equilibrium - -**State:** - -- All High-Priority Segments have at least one coin. -- Some segments have multiple coins. - -**Pick-up Strategy:** Follow general rules for multiple coins in one segment. - -**Deposit Strategy:** Deposit a coin on a empty Low-Priority Segment if there are less than `O - Q/2` Low-Priority Segments with at least a coin and the agent has coins. - -### Final Target: Equilibrium - -**State:** - -- All High-Priority Segments have exactly one coin. -- All Low-Priority Segments have exactly one or zero coins. - -**Pick-up Strategy:** Pick up coins from Low-Priority Segments until the agent has `M` coins. - -**Deposit Strategy:** None. - -### Dynamics - -- If Segmentation resolution increases (due to increased `O`), deposited coins in previous snapshots (placed at the end of segments) automatically fall into the High-Priority portions of their new subdivided segments. -- If Segmentation resolution decreases (due to decreased `O`), previous high and low priority segments naturally merge into the lower resolution segments. -- The system adapts at each snapshot to reach uniformity and priority requirements, gaining stability. Oscillatory behaviors (frequent pick-ups and deposits) are prevented as much as possible by a smart segmentation. diff --git a/apps/bot/docs/pool_snapshot.md b/apps/bot/docs/pool_snapshot.md deleted file mode 100644 index 69ca475..0000000 --- a/apps/bot/docs/pool_snapshot.md +++ /dev/null @@ -1,73 +0,0 @@ -# iCKB Deposit Pool Snapshot Encoding - -Future improvement idea: this document captures a possible snapshot-based estimate path for large deposit pools. The current live runtime path is documented in `packages/sdk/docs/pool_maturity_estimates.md` and still uses direct deposit scans. - -## Introduction - -Efficient asset conversion timing is paramount for the iCKB protocol, particularly when converting from iCKB to CKB. Although CKB-to-iCKB conversion timings are relatively simple to predict, the reverse process is influenced by factors like Bot CKB availability and, critically, the maturity of iCKB deposits available for withdrawal. - -The protocol is currently evolving to route all conversions through the Bot and Limit Orders, with the objectives of: - -- Optimizing deposit distribution over a 180-epoch cycle -- Minimizing direct interactions with the core protocol - -This approach removes the need to fetch the entire deposit pool for core protocol interactions. However, evaluating iCKB-to-CKB conversion timings still requires accessing the pool, which introduces a significant challenge. For instance, if the iCKB TLV were to reach 10G CKB, a DApp might be forced to continuously retrieve around 100,000 deposit cells merely to estimate conversion timings. Such an approach is not only impractical, but it would also impose undue strain on Nervos L1 RPCs. Hence, there is an urgent need to accurately estimate conversion timings without incurring the overhead of querying an ever-growing number of deposit cells. - -To address this, we propose a deposit pool snapshot mechanism. This solution offers a compact and efficient representation of deposit maturity events over the 180-epoch period. By capturing the maturity timings in a fixed snapshot and storing this information in the Bot CKB change cells, the system effectively eliminates the need for real-time data processing across extensive deposit cell datasets. - -## Overview - -The proposed solution encodes deposit maturity events on a 180-epoch cycle (~30 days) by partitioning the time interval into 1024 fixed bins, starting from an absolute origin that remains unchanged across snapshots. Each bin represents roughly 42 minutes. The number of bits allotted for each bin is determined dynamically on a per-snapshot basis, driven by the global maximum event count observed in any bin. This strategy is based on the following assumptions: - -- Deposit maturity events are typically distributed evenly over time. -- Any clustering is smoothed by the pool rebalancing algorithm. -- The timing estimation can tolerate some resolution reduction. - -## Key Components of the Encoding Approach - -1. **Fixed Bin Count:** - The 180-epoch interval is divided into 1024 bins, ensuring a consistent duration per bin. - -2. **Dynamic Bits-per-Bin Selection:** - The encoding process implicitly determines the bits allocated per bin by: - - Assessing the total length of the serialized bit stream. - - Computing the bits-per-bin value as (total_bits / 1024). - - For example: - - If there is at most 1 event per bin, then 1 bit per bin suffices, so a minimum of 128 CKB is needed to store this information in the Bot CKB change cells. - - If there is a maximum of 15 events in any bin, 4 bits per bin are required, totaling 512 CKB. - - If there is a maximum of 255 events in any bin, 8 bits per bin are needed, adding up to 1024 CKB - -3. **Implicit Parameter Communication:** - Both the encoder and decoder rely on a pre-agreed fixed structure of 1024 bins. The decoder can deduce the appropriate bits-per-bin value solely by inspecting the bot cell output data length, so there is no need for additional metadata specifying bin boundaries or bit allocations. - -## Key Advantages - -- **Simplicity:** - The fixed hierarchical structure permits straightforward and efficient packing and unpacking of the data. - -- **Efficient Serialization:** - By dynamically allocating bits according to the maximum event count per bin, the serialized representation remains compact, while still accommodating peak loads. - -- **Implicit Communication of Structure:** - Both the encoder and decoder derive necessary parameters from the known 1024-bin structure, obviating the need to transmit extra control information. - -## Considerations and Mitigations - -1. **Inflexibility of the Fixed Structure:** - - Concern: The 1024-bin configuration is fixed and does not scale dynamically. - - Mitigation: Future protocol revisions can agree on increasing the bin count if finer granularity becomes necessary. - -2. **Impact of Outliers on Bit Allocation:** - - Concern: A single bin with a high event count forces an increase in bits-per-bin across the entire snapshot. - - Mitigation: The external smoothing provided by the rebalancing algorithm typically ameliorates the effect of such outliers. - -3. **Limited Local Resolution:** - - Concern: Uniform resolution across fixed bins may overlook the precise timing of densely clustered events. - - Mitigation: The trade-off is acceptable for the current use case, considering that the overall timing precision remains within operational requirements. - -## Conclusion - -The fixed-bin snapshot encoding method presents an elegant balance between simplicity, efficiency and resolution. By mapping deposit maturity events over a 180-epoch period into a fixed 1024-bin structure, the mechanism minimizes real-time data processing while ensuring sufficient timing resolution for reliable conversion estimations. Although the design involves certain trade-offs, it effectively meets the current operational requirements by reducing RPC load and enhancing system agility. - -Looking ahead, further refinements to the encoding model could be explored to adapt the granularity based on evolving event patterns. For now, this robust mechanism underpins the rapid, dependable performance of the Fulfillment bot in a dynamic liquidity environment. From 6a67928253790102c4b138e02e74aaa48a4a9717 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 10 May 2026 11:44:10 +0000 Subject: [PATCH 5/9] fix(bot): remove redundant cleanup readiness guard --- apps/bot/docs/current_rebalancing_policy.md | 2 +- apps/bot/src/policy.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/bot/docs/current_rebalancing_policy.md b/apps/bot/docs/current_rebalancing_policy.md index bd44cf8..3e56557 100644 --- a/apps/bot/docs/current_rebalancing_policy.md +++ b/apps/bot/docs/current_rebalancing_policy.md @@ -92,7 +92,7 @@ Far-future withdrawal, same-transaction future rotation, retry widening, and per Non-standard cleanup is a narrow ready-only withdrawal path for crowded-bucket extras whose iCKB value is larger than one standard deposit. It runs only after output slots and `excessIckb` are known and only when no deposit action has already been selected. -The bot admits at most one cleanup candidate per rebalance. The candidate must come from `readyPoolDeposits`, have `deposit.isReady === true`, be a withdrawable extra rather than a singleton or protected crowded anchor, have `deposit.udtValue > ICKB_DEPOSIT_CAP`, and leave `ickbBalance - deposit.udtValue >= TARGET_ICKB_BALANCE`. Cleanup also pins the protected anchor from the same ready bucket as a `cell_dep`; if that anchor is spent before inclusion, the cleanup transaction fails instead of consuming the extra as the new live anchor. +The bot admits at most one cleanup candidate per rebalance. The candidate must come from `readyPoolDeposits`, be a withdrawable extra rather than a singleton or protected crowded anchor, have `deposit.udtValue > ICKB_DEPOSIT_CAP`, and leave `ickbBalance - deposit.udtValue >= TARGET_ICKB_BALANCE`. Cleanup also pins the protected anchor from the same ready bucket as a `cell_dep`; if that anchor is spent before inclusion, the cleanup transaction fails instead of consuming the extra as the new live anchor. The value-positive predicate is intentionally the implementation predicate from `@ickb/core`: iCKB value discounts only amounts above `ICKB_DEPOSIT_CAP`, so cleanup starts with `deposit.udtValue > ICKB_DEPOSIT_CAP`. Under-cap and cap-sized dust are ignored. diff --git a/apps/bot/src/policy.ts b/apps/bot/src/policy.ts index a691310..aae28f0 100644 --- a/apps/bot/src/policy.ts +++ b/apps/bot/src/policy.ts @@ -306,7 +306,6 @@ function selectNonStandardCleanupDeposit( return cleanupExtras.find( ({ deposit }) => - deposit.isReady && deposit.udtValue > ICKB_DEPOSIT_CAP && ickbBalance - deposit.udtValue >= TARGET_ICKB_BALANCE, ); From b16bf900cfe110cb06c195e9d261b1553a5baf95 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 10 May 2026 12:02:54 +0000 Subject: [PATCH 6/9] fix(bot): preserve log precision --- apps/bot/README.md | 2 +- apps/bot/docs/current_rebalancing_policy.md | 2 +- apps/bot/src/index.test.ts | 72 ++++++++++++++++++++- apps/bot/src/index.ts | 16 ++--- apps/bot/src/log.ts | 18 ++++++ 5 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 apps/bot/src/log.ts diff --git a/apps/bot/README.md b/apps/bot/README.md index ca8fd08..3b8b8ef 100644 --- a/apps/bot/README.md +++ b/apps/bot/README.md @@ -55,7 +55,7 @@ pnpm run start:loop `CHAIN` selects `env/${CHAIN}/.env`, which must contain the remaining runtime variables such as `BOT_PRIVATE_KEY` and `BOT_SLEEP_INTERVAL`. -The start script writes JSON logs and one log file per run. Intentional shutdowns, including low capital and transaction confirmation timeouts after broadcast, exit with code `2`; `start:loop` stops on that code instead of restarting immediately. +The start script writes JSON logs and one log file per run. Balance and fee amounts are logged as decimal strings so large on-chain values do not lose precision. Intentional shutdowns, including low capital and transaction confirmation timeouts after broadcast, exit with code `2`; `start:loop` stops on that code instead of restarting immediately. ## Notes diff --git a/apps/bot/docs/current_rebalancing_policy.md b/apps/bot/docs/current_rebalancing_policy.md index 3e56557..a848c3f 100644 --- a/apps/bot/docs/current_rebalancing_policy.md +++ b/apps/bot/docs/current_rebalancing_policy.md @@ -134,7 +134,7 @@ Withdrawal count is capped by `min(MAX_WITHDRAWAL_REQUESTS, floor(outputSlots / ## Send Loop -The bot validates `BOT_SLEEP_INTERVAL` as a finite number of seconds greater than or equal to one. Each loop sleeps for a random duration from `0` to `2 * BOT_SLEEP_INTERVAL`, builds at most one transaction, sends it, and polls the transaction status every 10 seconds until it is committed. `sent`, `pending`, `proposed`, `unknown`, and missing status are treated as pending. Rejected transactions and confirmation timeouts are reported in the JSON log with the broadcast hash when one exists. Confirmation timeouts stop the loop with exit code `2` so the wrapper does not immediately build conflicting replacement work. +The bot validates `BOT_SLEEP_INTERVAL` as a finite number of seconds greater than or equal to one. Each loop sleeps for a random duration from `0` to `2 * BOT_SLEEP_INTERVAL`, builds at most one transaction, sends it, and polls the transaction status every 10 seconds until it is committed. `sent`, `pending`, `proposed`, `unknown`, and missing status are treated as pending. Rejected transactions and confirmation timeouts are reported in the JSON log with the broadcast hash when one exists. Large numeric values are logged as strings to preserve bigint precision. Confirmation timeouts stop the loop with exit code `2` so the wrapper does not immediately build conflicting replacement work. ## Non-Goals diff --git a/apps/bot/src/index.test.ts b/apps/bot/src/index.test.ts index 1a16896..bdc8c20 100644 --- a/apps/bot/src/index.test.ts +++ b/apps/bot/src/index.test.ts @@ -4,7 +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 { TARGET_ICKB_BALANCE } from "./policy.js"; +import { formatCkb, jsonLogReplacer } from "./log.js"; +import { CKB, TARGET_ICKB_BALANCE } from "./policy.js"; import { buildTransaction, collectPoolDeposits, parseSleepInterval } from "./runtime.js"; afterEach(() => { @@ -87,6 +88,19 @@ 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({ @@ -148,6 +162,62 @@ describe("buildTransaction", () => { ).resolves.toBeUndefined(); }); + it("uses the repo exchange-ratio scale when checking match-only profitability", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: -2n, + udtDelta: 2n, + partials: [{} as never], + }); + vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(1n); + + const runtime = { + client: {} as ccc.Client, + signer: {} as ccc.SignerCkbPrivateKey, + managers: { + order: { + addMatch: (txLike: ccc.TransactionLike): ccc.Transaction => + ccc.Transaction.from(txLike), + }, + }, + sdk: { + buildBaseTransaction: async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + completeTransaction: async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + }, + primaryLock: script("11"), + }; + const state = { + marketOrders: [{}], + availableCkbBalance: 100n, + availableIckbBalance: 0n, + depositCapacity: 100n, + readyPoolDeposits: [], + nearReadyPoolDeposits: [], + futurePoolDeposits: [], + userOrders: [], + receipts: [], + readyWithdrawals: [], + system: { + feeRate: 1n, + exchangeRatio: { ckbScale: 3n, udtScale: 5n }, + tip: {} as ccc.ClientBlockHeader, + }, + }; + + await expect(buildTransaction(runtime as never, state as never)).resolves.toMatchObject({ + actions: { matchedOrders: 1 }, + }); + }); + it("passes required live deposits to SDK base transaction construction", async () => { vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ ckbDelta: 0n, diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 3c27cf5..fcb07d0 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -8,7 +8,6 @@ import { sendAndWaitForCommit, TransactionConfirmationError, } from "@ickb/sdk"; -import { CKB } from "./policy.js"; import { buildTransaction, collectPoolDeposits, @@ -17,6 +16,7 @@ import { type Runtime, type SupportedChain, } from "./runtime.js"; +import { formatCkb, jsonLogReplacer } from "./log.js"; const STOP_EXIT_CODE = 2; @@ -87,10 +87,10 @@ async function main(): Promise { ) { executionLog.error = "The bot must have more than " + - String(fmtCkb(state.minCkbBalance)) + + fmtCkb(state.minCkbBalance) + " CKB worth of capital to be able to operate, shutting down..."; process.exitCode = STOP_EXIT_CODE; - console.log(JSON.stringify(executionLog, replacer, " ")); + console.log(JSON.stringify(executionLog, jsonLogReplacer, " ")); return; } @@ -120,7 +120,7 @@ async function main(): Promise { executionLog.ElapsedSeconds = Math.round( (Date.now() - startTime.getTime()) / 1000, ); - console.log(JSON.stringify(executionLog, replacer, " ")); + console.log(JSON.stringify(executionLog, jsonLogReplacer, " ")); if (stopAfterLog) { return; } @@ -212,13 +212,7 @@ function outPointKey(outPoint: ccc.OutPoint): string { return ccc.hexFrom(outPoint.toBytes()); } -function fmtCkb(balance: bigint): number { - return Number(balance) / Number(CKB); -} - -function replacer(_: unknown, value: unknown): unknown { - return typeof value === "bigint" ? Number(value) : value; -} +const fmtCkb = formatCkb; function errorToLog(error: unknown): unknown { if (error instanceof Object && "stack" in error) { diff --git a/apps/bot/src/log.ts b/apps/bot/src/log.ts new file mode 100644 index 0000000..4e8d5fd --- /dev/null +++ b/apps/bot/src/log.ts @@ -0,0 +1,18 @@ +import { CKB } from "./policy.js"; + +export function formatCkb(balance: bigint): string { + const sign = balance < 0n ? "-" : ""; + const absolute = balance < 0n ? -balance : balance; + const whole = absolute / CKB; + const fraction = absolute % CKB; + + if (fraction === 0n) { + return sign + whole.toString(); + } + + return `${sign}${whole.toString()}.${fraction.toString().padStart(8, "0").replace(/0+$/u, "")}`; +} + +export function jsonLogReplacer(_: unknown, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} From 7391ee28e6e9c9aa64501e46cdf452bbbba34adb Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 10 May 2026 13:23:26 +0000 Subject: [PATCH 7/9] fix(bot): tighten rebalance helper invariants --- apps/bot/src/policy.ts | 2 +- apps/bot/src/runtime.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/bot/src/policy.ts b/apps/bot/src/policy.ts index aae28f0..b06fbb6 100644 --- a/apps/bot/src/policy.ts +++ b/apps/bot/src/policy.ts @@ -234,7 +234,7 @@ function selectionWithRequiredAnchors( requiredLiveDeposits: IckbDepositCell[]; } { const requiredLiveDeposits: IckbDepositCell[] = []; - const seen = new Set(); + const seen = new Set(deposits); for (const deposit of deposits) { const anchor = anchorsByExtra.get(deposit); if (!anchor || seen.has(anchor)) { diff --git a/apps/bot/src/runtime.ts b/apps/bot/src/runtime.ts index 62389ed..eb59cce 100644 --- a/apps/bot/src/runtime.ts +++ b/apps/bot/src/runtime.ts @@ -97,7 +97,7 @@ export async function buildTransaction( } const rebalance = planRebalance({ - outputSlots: maxInt(0, MAX_OUTPUTS_BEFORE_CHANGE - tx.outputs.length), + outputSlots: Math.max(0, MAX_OUTPUTS_BEFORE_CHANGE - tx.outputs.length), tip: state.system.tip, ickbBalance: state.availableIckbBalance + match.udtDelta, ckbBalance: state.availableCkbBalance + match.ckbDelta, @@ -214,10 +214,6 @@ function maxBigInt(left: bigint, right: bigint): bigint { return left > right ? left : right; } -function maxInt(left: number, right: number): number { - return left > right ? left : right; -} - async function collectAsync(iterable: AsyncIterable): Promise { const items: T[] = []; for await (const item of iterable) { From 6a3a899851f7260a15eca16f5aea9851bd627a05 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 10 May 2026 13:46:12 +0000 Subject: [PATCH 8/9] fix(sdk): tolerate transient confirmation polling failures --- apps/bot/src/policy.ts | 6 +++++- packages/sdk/src/sdk.test.ts | 36 +++++++++++++++++++++++++++++++----- packages/sdk/src/sdk.ts | 11 ++--------- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/bot/src/policy.ts b/apps/bot/src/policy.ts index b06fbb6..dffeb19 100644 --- a/apps/bot/src/policy.ts +++ b/apps/bot/src/policy.ts @@ -391,7 +391,11 @@ function selectProtectedBucketDeposit( throw new Error("Expected at least one deposit in bucket"); } - for (const deposit of deposits.slice(1)) { + for (let index = 1; index < deposits.length; index += 1) { + const deposit = deposits[index]; + if (!deposit) { + throw new Error("Expected bucket deposit to exist"); + } if (deposit.udtValue >= protectedDeposit.udtValue) { protectedDeposit = deposit; } diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 61675e1..272d700 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -863,9 +863,38 @@ describe("sendAndWaitForCommit", () => { expect(onSent).toHaveBeenCalledWith(txHash); }); - it("surfaces post-broadcast polling failures with the broadcast hash", async () => { + it("treats post-broadcast polling failures as unconfirmed", async () => { const txHash = hash("a4"); const onSent = vi.fn(); + const onConfirmationWait = vi.fn(); + const sleep = vi.fn(() => Promise.resolve()); + const getTransaction = vi + .fn() + .mockRejectedValueOnce(new Error("RPC down")) + .mockResolvedValueOnce({ status: "committed" }); + + await expect(sendAndWaitForCommit( + { + client: { getTransaction } as unknown as ccc.Client, + signer: { + sendTransaction: vi.fn().mockResolvedValue(txHash), + } as unknown as ccc.Signer, + }, + ccc.Transaction.default(), + { + onConfirmationWait, + onSent, + sleep, + }, + )).resolves.toBe(txHash); + + expect(onSent).toHaveBeenCalledWith(txHash); + expect(onConfirmationWait).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("times out if post-broadcast polling keeps failing", async () => { + const txHash = hash("a5"); try { await sendAndWaitForCommit( @@ -880,7 +909,6 @@ describe("sendAndWaitForCommit", () => { ccc.Transaction.default(), { maxConfirmationChecks: 1, - onSent, sleep: () => Promise.resolve(), }, ); @@ -888,14 +916,12 @@ describe("sendAndWaitForCommit", () => { } catch (error) { expect(error).toBeInstanceOf(TransactionConfirmationError); expect(error).toMatchObject({ - message: "Transaction confirmation failed: RPC down", + message: "Transaction confirmation timed out", txHash, status: "sent", isTimeout: true, }); } - - expect(onSent).toHaveBeenCalledWith(txHash); }); }); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 0b25ee3..06f369b 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -91,15 +91,8 @@ export async function sendAndWaitForCommit( for (let checks = 0; checks < maxConfirmationChecks && isPendingStatus(status); checks += 1) { try { status = (await client.getTransaction(txHash))?.status; - } catch (error) { - throw new TransactionConfirmationError( - error instanceof Error && error.message - ? `Transaction confirmation failed: ${error.message}` - : "Transaction confirmation failed", - txHash, - status, - true, - ); + } catch { + // Post-broadcast polling errors are transient; keep waiting until timeout. } if (!isPendingStatus(status)) { break; From 4f4490b2f2c1400504f3d63c1082bbd9b2dc2beb Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 10 May 2026 14:16:33 +0000 Subject: [PATCH 9/9] fix(bot): estimate completed transaction fees locally --- apps/bot/src/index.test.ts | 4 ++-- apps/bot/src/index.ts | 2 +- apps/bot/src/runtime.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/bot/src/index.test.ts b/apps/bot/src/index.test.ts index bdc8c20..69c57a7 100644 --- a/apps/bot/src/index.test.ts +++ b/apps/bot/src/index.test.ts @@ -108,7 +108,7 @@ describe("buildTransaction", () => { udtDelta: 0n, partials: [{} as never], }); - vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(1n); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); const runtime = { client: {} as ccc.Client, @@ -168,7 +168,7 @@ describe("buildTransaction", () => { udtDelta: 2n, partials: [{} as never], }); - vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(1n); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); const runtime = { client: {} as ccc.Client, diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index fcb07d0..9fe5cc9 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -101,7 +101,7 @@ async function main(): Promise { executionLog.actions = result.actions; executionLog.txFee = { - fee: fmtCkb(await result.tx.getFee(runtime.client)), + fee: fmtCkb(result.tx.estimateFee(state.system.feeRate)), feeRate: state.system.feeRate, }; executionLog.txHash = await sendAndWaitForCommit(runtime, result.tx, { diff --git a/apps/bot/src/runtime.ts b/apps/bot/src/runtime.ts index eb59cce..ee47ab9 100644 --- a/apps/bot/src/runtime.ts +++ b/apps/bot/src/runtime.ts @@ -151,7 +151,7 @@ export async function buildTransaction( }); if (isMatchOnly(actions)) { - const fee = await tx.getFee(runtime.client); + const fee = tx.estimateFee(state.system.feeRate); const matchValue = match.ckbDelta * state.system.exchangeRatio.ckbScale + match.udtDelta * state.system.exchangeRatio.udtScale;