diff --git a/apps/bot/package.json b/apps/bot/package.json index 7102934..021a8be 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -52,6 +52,7 @@ "dependencies": { "@ckb-ccc/core": "catalog:", "@ickb/core": "workspace:*", + "@ickb/node-utils": "workspace:*", "@ickb/order": "workspace:*", "@ickb/sdk": "workspace:*", "@ickb/utils": "workspace:*" diff --git a/apps/bot/src/index.test.ts b/apps/bot/src/index.test.ts index 69c57a7..9fbf3de 100644 --- a/apps/bot/src/index.test.ts +++ b/apps/bot/src/index.test.ts @@ -4,9 +4,8 @@ import { OrderManager } from "@ickb/order"; import { type IckbSdk } from "@ickb/sdk"; import { defaultFindCellsLimit } from "@ickb/utils"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { formatCkb, jsonLogReplacer } from "./log.js"; -import { CKB, TARGET_ICKB_BALANCE } from "./policy.js"; -import { buildTransaction, collectPoolDeposits, parseSleepInterval } from "./runtime.js"; +import { TARGET_ICKB_BALANCE } from "./policy.js"; +import { buildTransaction, collectPoolDeposits } from "./runtime.js"; afterEach(() => { vi.restoreAllMocks(); @@ -45,21 +44,6 @@ function readyDeposit( } as unknown as IckbDepositCell; } -describe("parseSleepInterval", () => { - it("rejects missing, non-finite, NaN, and sub-second intervals", () => { - for (const value of [undefined, "", "abc", "NaN", "Infinity", "0", "0.5"]) { - expect(() => parseSleepInterval(value, "BOT_SLEEP_INTERVAL")).toThrow( - "Invalid env BOT_SLEEP_INTERVAL", - ); - } - }); - - it("returns milliseconds for valid second intervals", () => { - expect(parseSleepInterval("1", "BOT_SLEEP_INTERVAL")).toBe(1000); - expect(parseSleepInterval("2.5", "BOT_SLEEP_INTERVAL")).toBe(2500); - }); -}); - describe("collectPoolDeposits", () => { it("fails closed when the public pool scan reaches the sentinel limit", async () => { async function* deposits(): AsyncGenerator { @@ -88,19 +72,6 @@ describe("collectPoolDeposits", () => { }); }); -describe("bot log formatting", () => { - it("formats CKB values without losing bigint precision", () => { - const whole = 123456789012345678901234567890n; - - expect(formatCkb(whole * CKB + 12345670n)).toBe(`${whole.toString()}.1234567`); - expect(formatCkb(-CKB - 1n)).toBe("-1.00000001"); - }); - - it("serializes bigint values as strings", () => { - expect(jsonLogReplacer("", 9007199254740993n)).toBe("9007199254740993"); - }); -}); - describe("buildTransaction", () => { it("skips match-only transactions when the completed fee consumes the match value", async () => { vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 9fe5cc9..30d7c5f 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -6,19 +6,24 @@ import { IckbSdk, projectAccountAvailability, sendAndWaitForCommit, - TransactionConfirmationError, } from "@ickb/sdk"; +import { + createPublicClient, + formatCkb, + handleLoopError, + logExecution, + parseSleepInterval, + parseSupportedChain, + signerAccountLocks, + sleep, + STOP_EXIT_CODE, +} from "@ickb/node-utils"; import { buildTransaction, collectPoolDeposits, - parseSleepInterval, type BotState, type Runtime, - type SupportedChain, } from "./runtime.js"; -import { formatCkb, jsonLogReplacer } from "./log.js"; - -const STOP_EXIT_CODE = 2; async function main(): Promise { const { CHAIN, RPC_URL, BOT_PRIVATE_KEY, BOT_SLEEP_INTERVAL } = process.env; @@ -30,12 +35,13 @@ async function main(): Promise { } const sleepInterval = parseSleepInterval(BOT_SLEEP_INTERVAL, "BOT_SLEEP_INTERVAL"); - const chain = parseChain(CHAIN); - const client = createClient(chain, RPC_URL); + const chain = parseSupportedChain(CHAIN, "CHAIN"); + const client = createPublicClient(chain, RPC_URL); const config = getConfig(chain); const { managers } = config; const signer = new ccc.SignerCkbPrivateKey(client, BOT_PRIVATE_KEY); - const primaryLock = (await signer.getRecommendedAddressObj()).script; + const recommendedAddress = await signer.getRecommendedAddressObj(); + const primaryLock = recommendedAddress.script; const runtime: Runtime = { chain, client, @@ -90,7 +96,7 @@ async function main(): Promise { fmtCkb(state.minCkbBalance) + " CKB worth of capital to be able to operate, shutting down..."; process.exitCode = STOP_EXIT_CODE; - console.log(JSON.stringify(executionLog, jsonLogReplacer, " ")); + logExecution(executionLog, startTime); return; } @@ -110,17 +116,10 @@ async function main(): Promise { }, }); } catch (error) { - executionLog.error = errorToLog(error); - if (error instanceof TransactionConfirmationError && error.isTimeout) { - process.exitCode = STOP_EXIT_CODE; - stopAfterLog = true; - } + stopAfterLog = handleLoopError(executionLog, error); } - executionLog.ElapsedSeconds = Math.round( - (Date.now() - startTime.getTime()) / 1000, - ); - console.log(JSON.stringify(executionLog, jsonLogReplacer, " ")); + logExecution(executionLog, startTime); if (stopAfterLog) { return; } @@ -128,18 +127,17 @@ async function main(): Promise { } async function readBotState(runtime: Runtime): Promise { - const accountLocks = dedupeScripts( - (await runtime.signer.getAddressObjs()).map(({ script }) => script), - ); - const { system, user } = await runtime.sdk.getL1State( + const accountLocks = await signerAccountLocks(runtime.signer, runtime.primaryLock); + const { system, user, account } = await runtime.sdk.getL1AccountState( runtime.client, accountLocks, ); - - const [account, poolDeposits] = await Promise.all([ - runtime.sdk.getAccountState(runtime.client, accountLocks, system.tip), - collectPoolDeposits(runtime.client, runtime.managers.logic, system.tip), - ]); + const poolDeposits = await collectPoolDeposits( + runtime.client, + runtime.managers.logic, + system.tip, + ); + await runtime.sdk.assertCurrentTip(runtime.client, system.tip); const projection = projectAccountAvailability(account, user.orders, { collectedOrdersAvailable: true, @@ -177,67 +175,12 @@ async function readBotState(runtime: Runtime): Promise { }; } -function createClient(chain: SupportedChain, rpcUrl: string | undefined): ccc.Client { - const config = rpcUrl ? { url: rpcUrl } : undefined; - return chain === "mainnet" - ? new ccc.ClientPublicMainnet(config) - : new ccc.ClientPublicTestnet(config); -} - -function parseChain(chain: string): SupportedChain { - if (chain === "mainnet" || chain === "testnet") { - return chain; - } - - throw new Error("Invalid env CHAIN: " + chain); -} - -function dedupeScripts(scripts: ccc.Script[]): ccc.Script[] { - const seen = new Set(); - const unique: ccc.Script[] = []; - - for (const script of scripts) { - const key = script.toHex(); - if (seen.has(key)) { - continue; - } - seen.add(key); - unique.push(script); - } - - return unique; -} - function outPointKey(outPoint: ccc.OutPoint): string { return ccc.hexFrom(outPoint.toBytes()); } const fmtCkb = formatCkb; -function errorToLog(error: unknown): unknown { - if (error instanceof Object && "stack" in error) { - const stack = error.stack ?? ""; - return { - name: "name" in error ? error.name : undefined, - message: - "message" in error && typeof error.message === "string" - ? error.message - : "Unknown error", - txHash: "txHash" in error ? error.txHash : undefined, - status: "status" in error ? error.status : undefined, - stack, - }; - } - - return error ?? "Empty Error"; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); } diff --git a/apps/bot/src/log.ts b/apps/bot/src/log.ts deleted file mode 100644 index 4e8d5fd..0000000 --- a/apps/bot/src/log.ts +++ /dev/null @@ -1,18 +0,0 @@ -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; -} diff --git a/apps/bot/src/policy.test.ts b/apps/bot/src/policy.test.ts index ccf825a..a107050 100644 --- a/apps/bot/src/policy.test.ts +++ b/apps/bot/src/policy.test.ts @@ -7,7 +7,6 @@ import { NEAR_READY_LOOKAHEAD_MS, partitionPoolDeposits, planRebalance, - selectReadyDeposits, TARGET_ICKB_BALANCE, } from "./policy.js"; @@ -78,37 +77,6 @@ describe("partitionPoolDeposits", () => { }); }); -describe("selectReadyDeposits", () => { - it("prefers the fullest valid subset under the target amount", () => { - const deposits = [{ udtValue: 4n }, { udtValue: 7n }, { udtValue: 3n }]; - - expect(selectReadyDeposits(deposits, 10n)).toEqual([ - { udtValue: 7n }, - { udtValue: 3n }, - ]); - }); - - it("respects the request limit", () => { - const deposits = [{ udtValue: 1n }, { udtValue: 1n }, { udtValue: 1n }]; - - expect(selectReadyDeposits(deposits, 10n, 2)).toEqual([ - { udtValue: 1n }, - { udtValue: 1n }, - ]); - }); - - it("keeps earlier-ranked deposits when equal-total subsets tie", () => { - const firstSix = { udtValue: 6n }; - const firstFour = { udtValue: 4n }; - const secondSix = { udtValue: 6n }; - const secondFour = { udtValue: 4n }; - - expect( - selectReadyDeposits([firstSix, firstFour, secondSix, secondFour], 10n), - ).toEqual([firstSix, firstFour]); - }); -}); - describe("planRebalance", () => { it("does nothing when fewer than two output slots remain", () => { expect( @@ -585,15 +553,6 @@ describe("planRebalance", () => { ).toEqual({ kind: "none" }); }); - it("uses a fuller bounded subset than the old greedy walk", () => { - const deposits = [readyDeposit(6n, 0n), readyDeposit(5n, 1n), readyDeposit(5n, 2n)]; - - expect(selectReadyDeposits(deposits, 10n)).toEqual([ - deposits[1], - deposits[2], - ]); - }); - it("requests withdrawals when iCKB is above the target band and a crowded-bucket fit exists", () => { const first = readyDeposit(4n, 20n * 60n * 1000n); const second = readyDeposit(6n, 25n * 60n * 1000n); @@ -976,17 +935,6 @@ describe("planRebalance", () => { expect(plan.kind === "withdraw" ? plan.deposits : []).toHaveLength(30); }); - it("lets greedy fallback use later candidates beyond the bounded best-fit horizon", () => { - const deposits = [ - ...Array.from({ length: 30 }, () => readyDeposit(11n, 0n)), - readyDeposit(10n, 31n), - ]; - - expect(selectReadyDeposits(deposits as never[], 10n, 1)).toEqual([ - deposits[30], - ]); - }); - it("does nothing when iCKB is above target but a full withdrawal would cut below the buffer", () => { expect( planRebalance({ diff --git a/apps/bot/src/policy.ts b/apps/bot/src/policy.ts index dffeb19..cf9635d 100644 --- a/apps/bot/src/policy.ts +++ b/apps/bot/src/policy.ts @@ -1,6 +1,10 @@ import { ccc } from "@ckb-ccc/core"; import { ICKB_DEPOSIT_CAP, type IckbDepositCell } from "@ickb/core"; -import { selectBoundedUdtSubset } from "@ickb/utils"; +import { + selectReadyWithdrawalCleanupDeposit, + selectReadyWithdrawalDeposits, +} from "@ickb/sdk"; +import { compareBigInt } from "@ickb/utils"; export const CKB = ccc.fixedPointFrom(1); export const CKB_RESERVE = 1000n * CKB; @@ -9,14 +13,10 @@ 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" } @@ -130,7 +130,7 @@ export function planRebalance(options: { return { kind: "withdraw", deposits: [cleanup.deposit], - requiredLiveDeposits: [cleanup.anchor], + requiredLiveDeposits: [cleanup.requiredLiveDeposit], }; } @@ -167,84 +167,14 @@ function selectPoolRebalancingDeposits( return { deposits: [], requiredLiveDeposits: [] }; } - const { cleanupExtras, extras, singletons, nonSingletonReady } = classifyReadyDeposits( + return selectReadyWithdrawalDeposits({ 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(deposits); - 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 }; + maxAmount, + maxCount: limit, + preserveSingletons: !canSpendSingletonAnchors(maxAmount), + }); } function shouldSeedFutureSegment( @@ -301,222 +231,19 @@ function selectNonStandardCleanupDeposit( readyDeposits: readonly IckbDepositCell[], tip: ccc.ClientBlockHeader, ickbBalance: bigint, -): ReadyExtra | undefined { - const { cleanupExtras } = classifyReadyDeposits(readyDeposits, [], tip); - - return cleanupExtras.find( - ({ deposit }) => - deposit.udtValue > ICKB_DEPOSIT_CAP && - ickbBalance - deposit.udtValue >= TARGET_ICKB_BALANCE, - ); -} - -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), - }; +): ReturnType { + return selectReadyWithdrawalCleanupDeposit({ + readyDeposits, + tip, + minAmountExclusive: ICKB_DEPOSIT_CAP, + maxAmount: ickbBalance - TARGET_ICKB_BALANCE, + }); } 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 (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; - } - } - - return protectedDeposit; -} - -export function selectReadyDeposits( - deposits: readonly T[], - maxAmount: bigint, - limit = MAX_WITHDRAWAL_REQUESTS, -): T[] { - 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; - - for (const deposit of deposits) { - if (selected.length >= limit) { - break; - } - - if (cumulative + deposit.udtValue > maxAmount) { - continue; - } - - cumulative += deposit.udtValue; - selected.push(deposit); - } - - 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 @@ -631,65 +358,11 @@ function isUnderCoveredFutureSegment( ); } -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; diff --git a/apps/bot/src/runtime.ts b/apps/bot/src/runtime.ts index ee47ab9..c69207d 100644 --- a/apps/bot/src/runtime.ts +++ b/apps/bot/src/runtime.ts @@ -10,11 +10,14 @@ import { type OrderGroup, } from "@ickb/order"; import { type getConfig, type IckbSdk, type SystemState } from "@ickb/sdk"; -import { defaultFindCellsLimit } from "@ickb/utils"; +import { type SupportedChain } from "@ickb/node-utils"; +import { collectCompleteScan, defaultFindCellsLimit } from "@ickb/utils"; import { partitionPoolDeposits, planRebalance } from "./policy.js"; const MATCH_STEP_DIVISOR = 100n; const MAX_OUTPUTS_BEFORE_CHANGE = 58; +const POOL_MIN_LOCK_UP = ccc.Epoch.from([0n, 1n, 16n]); +const POOL_MAX_LOCK_UP = ccc.Epoch.from([0n, 4n, 16n]); export interface Runtime { chain: SupportedChain; @@ -44,22 +47,7 @@ export interface BotState { 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 type { SupportedChain }; export async function buildTransaction( runtime: Runtime, @@ -172,20 +160,17 @@ export async function collectPoolDeposits( 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, - }), + const deposits = await collectCompleteScan( + (scanLimit) => + logic.findDeposits(client, { + onChain: true, + tip, + minLockUp: POOL_MIN_LOCK_UP, + maxLockUp: POOL_MAX_LOCK_UP, + limit: scanLimit, + }), + { limit: defaultFindCellsLimit, label: "iCKB pool deposit" }, ); - 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); @@ -213,11 +198,3 @@ function isMatchOnly(actions: { function maxBigInt(left: bigint, right: bigint): bigint { 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/packages/node-utils/src/index.test.ts b/packages/node-utils/src/index.test.ts index ce495d3..eff232c 100644 --- a/packages/node-utils/src/index.test.ts +++ b/packages/node-utils/src/index.test.ts @@ -10,6 +10,7 @@ import { parseSleepInterval, parseSupportedChain, signerAccountLocks, + STOP_EXIT_CODE, } from "./index.js"; describe("node utilities", () => { @@ -88,11 +89,16 @@ describe("node utilities", () => { message: "failed", }); expect(executionLog.error).toHaveProperty("stack"); + + const emptyLog: Record = {}; + expect(handleLoopError(emptyLog, undefined)).toBe(false); + expect(emptyLog.error).toBe("Empty Error"); }); it("stops after broadcast confirmation timeouts", () => { + expect(STOP_EXIT_CODE).toBe(2); expect(handleLoopError({}, transactionError(true))).toBe(true); - expect(process.exitCode).toBe(2); + expect(process.exitCode).toBe(STOP_EXIT_CODE); process.exitCode = undefined; expect(handleLoopError({}, transactionError(false))).toBe(false); @@ -104,7 +110,7 @@ describe("node utilities", () => { const executionLog: Record = { txHash }; expect(handleLoopError(executionLog, transactionError(true, txHash))).toBe(true); - expect(process.exitCode).toBe(2); + expect(process.exitCode).toBe(STOP_EXIT_CODE); expect(executionLog.txHash).toBe(txHash); expect(executionLog.error).toMatchObject({ name: "TransactionConfirmationError", diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts index 088d140..b8caa75 100644 --- a/packages/node-utils/src/index.ts +++ b/packages/node-utils/src/index.ts @@ -5,7 +5,7 @@ import { setTimeout } from "node:timers"; const CKB = 100000000n; -const STOP_EXIT_CODE = 2; +export const STOP_EXIT_CODE = 2; export type SupportedChain = "mainnet" | "testnet"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97a54dc..fa4c35f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,9 @@ importers: '@ickb/core': specifier: workspace:* version: link:../../packages/core + '@ickb/node-utils': + specifier: workspace:* + version: link:../../packages/node-utils '@ickb/order': specifier: workspace:* version: link:../../packages/order