From 2666ed4cf2825f564136e25443beaa45e6374541 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Fri, 29 May 2026 03:14:57 +0000 Subject: [PATCH] fix(sdk): use cell scan page sizes --- README.md | 4 +- apps/bot/docs/current_rebalancing_policy.md | 2 +- apps/bot/src/index.test.ts | 135 ++++--- apps/bot/src/index.ts | 21 +- apps/bot/src/policy.ts | 13 + apps/bot/src/runtime.ts | 30 -- packages/core/src/cells.ts | 24 +- packages/core/src/logic.test.ts | 72 +++- packages/core/src/logic.ts | 44 ++- packages/core/src/owned_owner.test.ts | 20 +- packages/core/src/owned_owner.ts | 27 +- packages/dao/src/dao.test.ts | 145 ++++++- packages/dao/src/dao.ts | 58 +-- packages/order/src/order.test.ts | 65 +--- packages/order/src/order.ts | 42 +- packages/sdk/README.md | 2 +- packages/sdk/docs/pool_maturity_estimates.md | 2 +- packages/sdk/src/sdk.test.ts | 384 ++++--------------- packages/sdk/src/sdk.ts | 80 ++-- packages/utils/src/utils.test.ts | 74 +--- packages/utils/src/utils.ts | 44 +-- 21 files changed, 590 insertions(+), 698 deletions(-) diff --git a/README.md b/README.md index 9e11ac2..7113c05 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ Callers own the final completion pipeline: Withdrawal requests built from public pool ready deposits may include `requiredLiveDeposits`. `@ickb/sdk` adds those cells as live `cell_dep` checks so a transaction fails if a protected pool anchor disappears before inclusion. -## Scan Completeness Boundary +## Scan Page Size Boundary -Stack cell scans that feed account state, pool state, order books, or maturity estimates request one sentinel entry beyond the configured logical limit and fail closed if the sentinel appears. Callers should treat these errors as incomplete state, not as zero balance or unavailable liquidity. +Stack cell scans that feed account state, pool state, order books, or maturity estimates use a per-request page size. SDK state APIs expose it as `cellPageSize`; lower-level scan wrappers expose it as `pageSize` and pass it to CCC as `limit`. ## User Lock Assumption diff --git a/apps/bot/docs/current_rebalancing_policy.md b/apps/bot/docs/current_rebalancing_policy.md index 718b328..600a276 100644 --- a/apps/bot/docs/current_rebalancing_policy.md +++ b/apps/bot/docs/current_rebalancing_policy.md @@ -25,7 +25,7 @@ The runtime reads system and account state through `@ickb/sdk`, then derives the - `depositCapacity`: CKB required for one standard 100,000 iCKB deposit at the live exchange ratio. - `minCkbBalance`: shutdown threshold set to `21 / 20 * depositCapacity`. -The public pool scan reads one sentinel entry beyond the default cell limit and fails closed if that sentinel appears, because rebalance decisions require a complete pool slice. +The public pool scan uses the SDK L1 state snapshot and the default CCC cell-query page size unless callers pass `cellPageSize`. Rebalance decisions use the collected pool deposits from that snapshot. `nearReadyPoolDeposits` only ranks ready-window withdrawal choices. Fresh deposits are scored against `futurePoolDeposits`, not against the near-ready hour. diff --git a/apps/bot/src/index.test.ts b/apps/bot/src/index.test.ts index 4954489..dfea5c3 100644 --- a/apps/bot/src/index.test.ts +++ b/apps/bot/src/index.test.ts @@ -3,13 +3,12 @@ import { type IckbDepositCell } from "@ickb/core"; import { handleLoopError, logExecution } from "@ickb/node-utils"; import { OrderManager } from "@ickb/order"; import { type IckbSdk } from "@ickb/sdk"; -import { defaultFindCellsLimit } from "@ickb/utils"; import { hash, headerLike, script } from "@ickb/testkit"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { CKB_RESERVE, TARGET_ICKB_BALANCE } from "./policy.js"; +import { CKB_RESERVE, POOL_MAX_LOCK_UP, POOL_MIN_LOCK_UP, TARGET_ICKB_BALANCE } from "./policy.js"; import { completeTerminalIteration, isRetryableBotError, @@ -19,7 +18,7 @@ import { reachedMaxRetryableAttempts, } from "./index.js"; import { BotEventEmitter, transactionLifecycleEvents } from "./observability.js"; -import { buildTransaction, collectPoolDeposits } from "./runtime.js"; +import { buildTransaction } from "./runtime.js"; afterEach(() => { vi.restoreAllMocks(); @@ -29,6 +28,7 @@ function readyDeposit( byte: string, udtValue: bigint, maturityUnix: bigint, + options: { isReady?: boolean } = {}, ): IckbDepositCell { return { cell: ccc.Cell.from({ @@ -39,6 +39,7 @@ function readyDeposit( }, outputData: "0x", }), + isReady: options.isReady ?? true, udtValue, maturity: { toUnix: (): bigint => maturityUnix, @@ -123,11 +124,18 @@ function botRuntime(overrides: { exchangeRatio: { ckbScale: 1n, udtScale: 1n }, orderPool: [], feeRate: 1n, + poolDeposits: { deposits: [], readyDeposits: [], id: "empty" }, + ckbAvailable: 0n, + ckbMaturing: [], }, user: { orders: [] }, account: { capacityCells: [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, receipts: [], + withdrawalGroups: [], }, }; }, @@ -140,74 +148,107 @@ function botRuntime(overrides: { }; } -describe("collectPoolDeposits", () => { - it("fails closed when the public pool scan reaches the sentinel limit", async () => { - async function* deposits(): AsyncGenerator { +describe("readBotState", () => { + it("partitions SDK pool deposits without a second pool scan", async () => { + const tip = headerLike({ + number: 10n, + epoch: [0n, 0n, 1n], + timestamp: "0x0", + }); + const readyWindowEnd = POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip); + const ready = readyDeposit("33", 1n, 1n, { isReady: true }); + const tooEarly = readyDeposit("34", 2n, readyWindowEnd - 1n, { isReady: false }); + const nearReady = readyDeposit("35", 3n, readyWindowEnd + 1n, { isReady: false }); + const future = readyDeposit("36", 4n, readyWindowEnd + 60n * 60n * 1000n, { isReady: false }); + const getL1AccountState = vi.fn(); + getL1AccountState.mockResolvedValue({ + system: { + tip, + exchangeRatio: { ckbScale: 1n, udtScale: 1n }, + orderPool: [], + feeRate: 1n, + poolDeposits: { + deposits: [ready, tooEarly, nearReady, future], + readyDeposits: [ready], + id: "pool", + }, + ckbAvailable: 0n, + ckbMaturing: [], + }, + user: { orders: [] }, + account: { + capacityCells: [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + }); + const assertCurrentTip = vi.fn(); + const findDeposits = vi.fn(async function* (): AsyncGenerator { await Promise.resolve(); - for (let index = 0; index <= defaultFindCellsLimit; index += 1) { - yield readyDeposit("33", 1n, BigInt(index)); - } - } - - const findDeposits = vi.fn(() => deposits()); + yield* [] as IckbDepositCell[]; + }); + const runtime = botRuntime({ + sdk: { + getL1AccountState, + assertCurrentTip, + }, + managers: { + logic: { + findDeposits, + }, + }, + }); - await expect( - collectPoolDeposits( - {} as ccc.Client, - { findDeposits } as never, - {} as ccc.ClientBlockHeader, - ), - ).rejects.toThrow( - `iCKB pool deposit scan reached limit ${String(defaultFindCellsLimit)}`, - ); + const state = await readBotState(runtime as never); - expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ - onChain: true, - limit: defaultFindCellsLimit + 1, + expect(getL1AccountState).toHaveBeenCalledTimes(1); + expect(getL1AccountState.mock.calls[0]?.[2]).toMatchObject({ + poolDeposits: { + minLockUp: POOL_MIN_LOCK_UP, + maxLockUp: POOL_MAX_LOCK_UP, + }, }); + expect(findDeposits).not.toHaveBeenCalled(); + expect(assertCurrentTip).not.toHaveBeenCalled(); + expect(state.readyPoolDeposits).toEqual([ready]); + expect(state.nearReadyPoolDeposits).toEqual([nearReady]); + expect(state.futurePoolDeposits).toEqual([future]); }); -}); -describe("readBotState", () => { - it("fails closed when pool scans cross the account-state tip", async () => { - const tip = headerLike({ number: 10n }); - const assertCurrentTip = vi.fn(async () => { - await Promise.resolve(); - throw new Error("L1 state scan crossed chain tip; retry with a fresh state"); - }); + it("fails closed when L1 account state omits the pool deposit snapshot", async () => { const runtime = botRuntime({ sdk: { getL1AccountState: async (): Promise => { await Promise.resolve(); return { system: { - tip, + tip: headerLike(), exchangeRatio: { ckbScale: 1n, udtScale: 1n }, orderPool: [], feeRate: 1n, + ckbAvailable: 0n, + ckbMaturing: [], }, user: { orders: [] }, - account: { capacityCells: [], receipts: [] }, + account: { + capacityCells: [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, }; }, - assertCurrentTip, - }, - managers: { - logic: { - findDeposits: async function* (): AsyncGenerator { - await Promise.resolve(); - for (const deposit of [] as IckbDepositCell[]) { - yield deposit; - } - }, - }, }, }); await expect(readBotState(runtime as never)).rejects.toThrow( - "L1 state scan crossed chain tip; retry with a fresh state", + "L1 account state is missing pool deposit snapshot", ); - expect(assertCurrentTip).toHaveBeenCalledWith(runtime.client, tip); }); }); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index df18583..53a6a70 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -25,11 +25,15 @@ import { } from "@ickb/node-utils"; import { buildTransaction, - collectPoolDeposits, summarizeBotState, type BotState, type Runtime, } from "./runtime.js"; +import { + partitionBotPoolDeposits, + POOL_MAX_LOCK_UP, + POOL_MIN_LOCK_UP, +} from "./policy.js"; import { BotEventEmitter, createRunId, @@ -284,13 +288,20 @@ export async function readBotState(runtime: Runtime): Promise { const { system, user, account } = await runtime.sdk.getL1AccountState( runtime.client, accountLocks, + { + poolDeposits: { + minLockUp: POOL_MIN_LOCK_UP, + maxLockUp: POOL_MAX_LOCK_UP, + }, + }, ); - const poolDeposits = await collectPoolDeposits( - runtime.client, - runtime.managers.logic, + if (!system.poolDeposits) { + throw new Error("L1 account state is missing pool deposit snapshot"); + } + const poolDeposits = partitionBotPoolDeposits( + system.poolDeposits.deposits, system.tip, ); - await runtime.sdk.assertCurrentTip(runtime.client, system.tip); const projection = projectAccountAvailability(account, user.orders, { collectedOrdersAvailable: true, diff --git a/apps/bot/src/policy.ts b/apps/bot/src/policy.ts index 0f47a63..57f8d8d 100644 --- a/apps/bot/src/policy.ts +++ b/apps/bot/src/policy.ts @@ -11,6 +11,8 @@ export const CKB_RESERVE = 1000n * CKB; export const MIN_ICKB_BALANCE = 2000n * CKB; export const TARGET_ICKB_BALANCE = ICKB_DEPOSIT_CAP + 20000n * CKB; export const NEAR_READY_LOOKAHEAD_MS = 60n * 60n * 1000n; +export const POOL_MIN_LOCK_UP = ccc.Epoch.from([0n, 1n, 16n]); +export const POOL_MAX_LOCK_UP = ccc.Epoch.from([0n, 4n, 16n]); const OUTPUTS_PER_REBALANCE_ACTION = 2; const MAX_WITHDRAWAL_REQUESTS = 30; @@ -100,6 +102,17 @@ export function partitionPoolDeposits( return { ready, nearReady, future }; } +export function partitionBotPoolDeposits( + deposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, +): ReturnType { + return partitionPoolDeposits( + deposits, + tip, + POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip), + ); +} + export function planRebalance(options: { outputSlots: number; tip: ccc.ClientBlockHeader; diff --git a/apps/bot/src/runtime.ts b/apps/bot/src/runtime.ts index be0a3d5..e780f36 100644 --- a/apps/bot/src/runtime.ts +++ b/apps/bot/src/runtime.ts @@ -14,10 +14,8 @@ import { } from "@ickb/order"; import { type getConfig, type IckbSdk, type SystemState } from "@ickb/sdk"; import { accountPlainCkbBalance, postTransactionAccountPlainCkbBalance, type SupportedChain } from "@ickb/node-utils"; -import { collectCompleteScan, defaultFindCellsLimit } from "@ickb/utils"; import { CKB_RESERVE, - partitionPoolDeposits, planRebalance, type RebalanceNoopReason, type RebalancePlan, @@ -25,8 +23,6 @@ import { 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; @@ -390,32 +386,6 @@ export function summarizeBotState(state: BotState): BotStateSummary { }; } -export async function collectPoolDeposits( - client: ccc.Client, - logic: Runtime["managers"]["logic"], - tip: ccc.ClientBlockHeader, -): Promise<{ - ready: IckbDepositCell[]; - nearReady: IckbDepositCell[]; - future: IckbDepositCell[]; -}> { - const deposits = await collectCompleteScan( - (scanLimit) => - logic.findDeposits(client, { - onChain: true, - tip, - minLockUp: POOL_MIN_LOCK_UP, - maxLockUp: POOL_MAX_LOCK_UP, - limit: scanLimit, - }), - { limit: defaultFindCellsLimit, label: "iCKB pool deposit" }, - ); - - const readyWindowEnd = POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip); - - return partitionPoolDeposits(deposits, tip, readyWindowEnd); -} - function isMatchOnly(actions: { collectedOrders: number; completedDeposits: number; diff --git a/packages/core/src/cells.ts b/packages/core/src/cells.ts index 7e0dde1..5429fa6 100644 --- a/packages/core/src/cells.ts +++ b/packages/core/src/cells.ts @@ -34,6 +34,14 @@ export interface ReceiptCell extends ValueComponents { header: TransactionHeader; } +type TransactionWithHeader = Awaited< + ReturnType +>; + +type ReceiptCellFromCache = { + transactionCache?: Map>; +}; + /** * Creates a ReceiptCell instance from the provided options. * @param options - Options for creating a ReceiptCell. @@ -42,14 +50,14 @@ export interface ReceiptCell extends ValueComponents { */ export async function receiptCellFrom( options: - | { + | ({ cell: ccc.Cell; client: ccc.Client; - } - | { + } & ReceiptCellFromCache) + | ({ outpoint: ccc.OutPoint; client: ccc.Client; - }, + } & ReceiptCellFromCache), ): Promise { const cell = "cell" in options @@ -60,8 +68,12 @@ export async function receiptCellFrom( } const txHash = cell.outPoint.txHash; - const txWithHeader = - await options.client.getTransactionWithHeader(txHash); + let txWithHeaderPromise = options.transactionCache?.get(txHash); + if (!txWithHeaderPromise) { + txWithHeaderPromise = options.client.getTransactionWithHeader(txHash); + options.transactionCache?.set(txHash, txWithHeaderPromise); + } + const txWithHeader = await txWithHeaderPromise; if (!txWithHeader?.header) { throw new Error("Header not found for txHash"); } diff --git a/packages/core/src/logic.test.ts b/packages/core/src/logic.test.ts index 4f5fe5c..3d831d6 100644 --- a/packages/core/src/logic.test.ts +++ b/packages/core/src/logic.test.ts @@ -1,5 +1,5 @@ import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, script } from "@ickb/testkit"; +import { byte32FromByte, headerLike, script } from "@ickb/testkit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DaoManager } from "@ickb/dao"; import { collect } from "@ickb/utils"; @@ -262,7 +262,7 @@ describe("LogicManager.deposit", () => { ]); }); - it("fails closed when receipt scanning exceeds the limit", async () => { + it("passes the cell page size to receipt scanning", async () => { const logic = script("11"); const wantedLock = script("22"); const receiptData = ReceiptData.from({ @@ -287,20 +287,74 @@ describe("LogicManager.deposit", () => { }, outputData: receiptData, }); - let requestedLimit = 0; + let requestedPageSize = 0; const client = { - findCells: async function* (_query: unknown, _order: unknown, limit: number) { - requestedLimit = limit; + findCells: async function* (_query: unknown, _order: unknown, pageSize: number) { + requestedPageSize = pageSize; await Promise.resolve(); yield firstReceipt; yield secondReceipt; }, + getTransactionWithHeader: async () => { + await Promise.resolve(); + return { header: headerLike() }; + }, } as unknown as ccc.Client; const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); - await expect( - collect(manager.findReceipts(client, [wantedLock], { limit: 1 })), - ).rejects.toThrow("receipt cell scan reached limit 1; state may be incomplete"); - expect(requestedLimit).toBe(2); + const receipts = await collect(manager.findReceipts(client, [wantedLock], { pageSize: 1 })); + + expect(requestedPageSize).toBe(1); + expect(receipts.map((receipt) => receipt.cell.outPoint.txHash)).toEqual([ + firstReceipt.outPoint.txHash, + secondReceipt.outPoint.txHash, + ]); + }); + + it("reuses receipt transaction header requests across lock scans", async () => { + const logic = script("11"); + const firstLock = script("22"); + const secondLock = script("33"); + const txHash = byte32FromByte("44"); + const receiptData = ReceiptData.from({ + depositQuantity: 1, + depositAmount: ccc.fixedPointFrom(100000), + }).toBytes(); + const firstReceipt = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: firstLock, + type: logic, + }, + outputData: receiptData, + }); + const secondReceipt = ccc.Cell.from({ + outPoint: { txHash, index: 1n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: secondLock, + type: logic, + }, + outputData: receiptData, + }); + let transactionCalls = 0; + const client = { + findCells: async function* (query: { script: ccc.Script }) { + await Promise.resolve(); + yield query.script.eq(firstLock) ? firstReceipt : secondReceipt; + }, + getTransactionWithHeader: async () => { + transactionCalls += 1; + await Promise.resolve(); + return { header: headerLike() }; + }, + } as unknown as ccc.Client; + const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); + + const receipts = await collect(manager.findReceipts(client, [firstLock, secondLock])); + + expect(transactionCalls).toBe(1); + expect(receipts.map((receipt) => receipt.cell.outPoint.index)).toEqual([0n, 1n]); }); }); diff --git a/packages/core/src/logic.ts b/packages/core/src/logic.ts index 501090b..14b4a29 100644 --- a/packages/core/src/logic.ts +++ b/packages/core/src/logic.ts @@ -1,8 +1,8 @@ import { ccc } from "@ckb-ccc/core"; import { assertDaoOutputLimit, DaoManager } from "@ickb/dao"; import { - collectCompleteScan, - defaultFindCellsLimit, + collectPagedScan, + defaultCellPageSize, type ScriptDeps, unique, } from "@ickb/utils"; @@ -175,8 +175,8 @@ export class LogicManager implements ScriptDeps { * * @param client * A CKB client instance providing: - * - `findCells(query, order, limit)` for cached searches - * - `findCellsOnChain(query, order, limit)` for direct on-chain searches + * - `findCells(query, order, pageSize)` for cached searches + * - `findCellsOnChain(query, order, pageSize)` for direct on-chain searches * * @param locks * An array of lock scripts. Only cells whose `cellOutput.lock` exactly matches @@ -186,8 +186,8 @@ export class LogicManager implements ScriptDeps { * Optional parameters to control query behavior: * - `onChain?: boolean` * If `true`, uses `findCellsOnChain`. Otherwise, uses `findCells`. Default: `false`. - * - `limit?: number` - * Maximum number of cells to fetch per lock script. Defaults to `defaultFindCellsLimit` (400). + * - `pageSize?: number` + * Cell query page size per lock script. Defaults to `defaultCellPageSize` (400). * * @yields * {@link ReceiptCell} objects for each valid receipt cell found. @@ -211,12 +211,16 @@ export class LogicManager implements ScriptDeps { */ onChain?: boolean; /** - * Batch size per lock script. Defaults to {@link defaultFindCellsLimit}. + * Cell query page size per lock script. Defaults to {@link defaultCellPageSize}. */ - limit?: number; + pageSize?: number; }, ): AsyncGenerator { - const limit = options?.limit ?? defaultFindCellsLimit; + const pageSize = options?.pageSize ?? defaultCellPageSize; + const transactionCache = new Map< + ccc.Hex, + Promise>> + >(); for (const lock of unique(locks)) { const findCellsArgs = [ { @@ -231,15 +235,15 @@ export class LogicManager implements ScriptDeps { "asc", ] as const; - const receiptCandidates = (await collectCompleteScan( - (scanLimit) => options?.onChain - ? client.findCellsOnChain(...findCellsArgs, scanLimit) - : client.findCells(...findCellsArgs, scanLimit), - { limit, label: "receipt cell" }, + const receiptCandidates = (await collectPagedScan( + (pageSize) => options?.onChain + ? client.findCellsOnChain(...findCellsArgs, pageSize) + : client.findCells(...findCellsArgs, pageSize), + { pageSize }, )).filter((cell) => this.isReceipt(cell) && cell.cellOutput.lock.eq(lock)); const receipts = await Promise.all( - receiptCandidates.map((cell) => receiptCellFrom({ client, cell })), + receiptCandidates.map((cell) => receiptCellFrom({ client, cell, transactionCache })), ); for (const receipt of receipts) { yield receipt; @@ -270,8 +274,8 @@ export class LogicManager implements ScriptDeps { * Minimum lock-up period in epochs. Defaults to manager’s configured minimum (~10 min). * - `maxLockUp?: ccc.Epoch` * Maximum lock-up period in epochs. Defaults to manager’s configured maximum (~3 days). - * - `limit?: number` - * Maximum cells per batch when querying. Defaults to `defaultFindCellsLimit` (400). + * - `pageSize?: number` + * Cell query page size. Defaults to `defaultCellPageSize` (400). * * @returns * An async generator yielding `IckbDepositCell` objects, each representing @@ -291,18 +295,16 @@ export class LogicManager implements ScriptDeps { onChain?: boolean; minLockUp?: ccc.Epoch; maxLockUp?: ccc.Epoch; - limit?: number; + pageSize?: number; }, ): AsyncGenerator { const tip = options?.tip ? ccc.ClientBlockHeader.from(options.tip) : await client.getTipHeader(); - options = { ...options, tip }; - for await (const deposit of this.daoManager.findDeposits( client, [this.script], - options, + { ...options, tip }, )) { if (!this.isDeposit(deposit.cell)) { continue; diff --git a/packages/core/src/owned_owner.test.ts b/packages/core/src/owned_owner.test.ts index 9ed7add..45c0580 100644 --- a/packages/core/src/owned_owner.test.ts +++ b/packages/core/src/owned_owner.test.ts @@ -29,7 +29,7 @@ describe("OwnedOwnerManager.findWithdrawalGroups", () => { expect(ownerCell.getOwned().index).toBe(0n); }); - it("fails closed when owner scanning exceeds the limit", async () => { + it("passes the cell page size to owner scanning", async () => { const ownerLock = script("11"); const ownedOwnerScript = script("22"); const daoScript = script("33"); @@ -57,20 +57,24 @@ describe("OwnedOwnerManager.findWithdrawalGroups", () => { }, outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), }); - let requestedLimit = 0; + let requestedPageSize = 0; const client = { - findCells: async function* (_query: unknown, _order: unknown, limit: number) { - requestedLimit = limit; + findCells: async function* (_query: unknown, _order: unknown, pageSize: number) { + requestedPageSize = pageSize; await Promise.resolve(); yield firstOwner; yield secondOwner; }, + getCell: async () => { + await Promise.resolve(); + return undefined; + }, } as unknown as ccc.Client; - await expect( - collect(manager.findWithdrawalGroups(client, [ownerLock], { tip, limit: 1 })), - ).rejects.toThrow("owner cell scan reached limit 1; state may be incomplete"); - expect(requestedLimit).toBe(2); + const groups = await collect(manager.findWithdrawalGroups(client, [ownerLock], { tip, pageSize: 1 })); + + expect(requestedPageSize).toBe(1); + expect(groups).toEqual([]); }); it("skips owners whose referenced withdrawal is not locked by Owned Owner", async () => { diff --git a/packages/core/src/owned_owner.ts b/packages/core/src/owned_owner.ts index c56fb95..7dd47b5 100644 --- a/packages/core/src/owned_owner.ts +++ b/packages/core/src/owned_owner.ts @@ -1,7 +1,7 @@ import { ccc } from "@ckb-ccc/core"; import { - collectCompleteScan, - defaultFindCellsLimit, + collectPagedScan, + defaultCellPageSize, unique, type ScriptDeps, } from "@ickb/utils"; @@ -212,8 +212,8 @@ export class OwnedOwnerManager implements ScriptDeps { * * @param client * A CKB client instance providing: - * - `findCells(query, order, limit)` for cached searches - * - `findCellsOnChain(query, order, limit)` for on-chain searches + * - `findCells(query, order, pageSize)` for cached searches + * - `findCellsOnChain(query, order, pageSize)` for on-chain searches * - `getTipHeader()` to fetch the latest block header * * @param locks @@ -228,9 +228,8 @@ export class OwnedOwnerManager implements ScriptDeps { * - `onChain?: boolean` * If `true`, uses `findCellsOnChain`; otherwise, uses `findCells`. * Default: `false`. - * - `limit?: number` - * Maximum number of cells to fetch per lock script. - * Defaults to `defaultFindCellsLimit` (400). + * - `pageSize?: number` + * Cell query page size per lock script. Defaults to `defaultCellPageSize` (400). * * @yields * {@link WithdrawalGroup} objects, each containing: @@ -255,11 +254,11 @@ export class OwnedOwnerManager implements ScriptDeps { options?: { tip?: ccc.ClientBlockHeader; onChain?: boolean; - limit?: number; + pageSize?: number; }, ): AsyncGenerator { const tip = options?.tip ?? (await client.getTipHeader()); - const limit = options?.limit ?? defaultFindCellsLimit; + const pageSize = options?.pageSize ?? defaultCellPageSize; for (const lock of unique(locks)) { const findCellsArgs = [ { @@ -274,11 +273,11 @@ export class OwnedOwnerManager implements ScriptDeps { "asc", ] as const; - const ownerCandidates = (await collectCompleteScan( - (scanLimit) => options?.onChain - ? client.findCellsOnChain(...findCellsArgs, scanLimit) - : client.findCells(...findCellsArgs, scanLimit), - { limit, label: "owner cell" }, + const ownerCandidates = (await collectPagedScan( + (pageSize) => options?.onChain + ? client.findCellsOnChain(...findCellsArgs, pageSize) + : client.findCells(...findCellsArgs, pageSize), + { pageSize }, )) .filter((cell) => this.isOwner(cell) && cell.cellOutput.lock.eq(lock)) .map((cell) => new OwnerCell(cell)); diff --git a/packages/dao/src/dao.test.ts b/packages/dao/src/dao.test.ts index feb9457..2f9890a 100644 --- a/packages/dao/src/dao.test.ts +++ b/packages/dao/src/dao.test.ts @@ -201,7 +201,7 @@ describe("DaoManager cell decoding ownership", () => { }); describe("DaoManager.findDeposits", () => { - it("fails closed when deposit scanning exceeds the limit", async () => { + it("passes the cell page size to deposit scanning", async () => { const manager = new DaoManager(script("11"), []); const lock = script("22"); const firstDeposit = ccc.Cell.from({ @@ -222,20 +222,27 @@ describe("DaoManager.findDeposits", () => { }, outputData: DaoManager.depositData(), }); - let requestedLimit = 0; + let requestedPageSize = 0; const client = { - findCells: async function* (_query: unknown, _order: unknown, limit: number) { - requestedLimit = limit; + findCells: async function* (_query: unknown, _order: unknown, pageSize: number) { + requestedPageSize = pageSize; await Promise.resolve(); yield firstDeposit; yield secondDeposit; }, + getTransactionWithHeader: async () => { + await Promise.resolve(); + return { header: headerLike(1n) }; + }, } as unknown as ccc.Client; - await expect( - collect(manager.findDeposits(client, [lock], { tip: headerLike(3n), limit: 1 })), - ).rejects.toThrow("DAO deposit cell scan reached limit 1; state may be incomplete"); - expect(requestedLimit).toBe(2); + const deposits = await collect(manager.findDeposits(client, [lock], { tip: headerLike(3n), pageSize: 1 })); + + expect(requestedPageSize).toBe(1); + expect(deposits.map((deposit) => deposit.cell.outPoint.txHash)).toEqual([ + firstDeposit.outPoint.txHash, + secondDeposit.outPoint.txHash, + ]); }); it("decodes deposits concurrently and yields scan order", async () => { @@ -346,10 +353,54 @@ describe("DaoManager.findDeposits", () => { expect(transactionCalls).toBe(1); expect(deposits).toHaveLength(2); }); + + it("reuses deposit transaction header requests across lock scans", async () => { + const manager = new DaoManager(script("11"), []); + const firstLock = script("22"); + const secondLock = script("33"); + const txHash = byte32FromByte("44"); + const firstDeposit = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: firstLock, + type: manager.script, + }, + outputData: DaoManager.depositData(), + }); + const secondDeposit = ccc.Cell.from({ + outPoint: { txHash, index: 1n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: secondLock, + type: manager.script, + }, + outputData: DaoManager.depositData(), + }); + let transactionCalls = 0; + const client = { + findCells: async function* (query: { script: ccc.Script }) { + await Promise.resolve(); + yield query.script.eq(firstLock) ? firstDeposit : secondDeposit; + }, + getTransactionWithHeader: async () => { + transactionCalls += 1; + await Promise.resolve(); + return { header: headerLike(1n) }; + }, + } as unknown as ccc.Client; + + const deposits = await collect(manager.findDeposits(client, [firstLock, secondLock], { + tip: headerLike(3n), + })); + + expect(transactionCalls).toBe(1); + expect(deposits.map((deposit) => deposit.cell.outPoint.index)).toEqual([0n, 1n]); + }); }); describe("DaoManager.findWithdrawalRequests", () => { - it("fails closed when withdrawal request scanning exceeds the limit", async () => { + it("passes the cell page size to withdrawal request scanning", async () => { const manager = new DaoManager(script("11"), []); const lock = script("22"); const firstWithdrawal = ccc.Cell.from({ @@ -370,20 +421,31 @@ describe("DaoManager.findWithdrawalRequests", () => { }, outputData: ccc.mol.Uint64LE.encode(1n), }); - let requestedLimit = 0; + let requestedPageSize = 0; const client = { - findCells: async function* (_query: unknown, _order: unknown, limit: number) { - requestedLimit = limit; + findCells: async function* (_query: unknown, _order: unknown, pageSize: number) { + requestedPageSize = pageSize; await Promise.resolve(); yield firstWithdrawal; yield secondWithdrawal; }, + getHeaderByNumber: async () => { + await Promise.resolve(); + return headerLike(1n); + }, + getTransactionWithHeader: async () => { + await Promise.resolve(); + return { header: headerLike(2n) }; + }, } as unknown as ccc.Client; - await expect( - collect(manager.findWithdrawalRequests(client, [lock], { tip: headerLike(3n), limit: 1 })), - ).rejects.toThrow("DAO withdrawal request cell scan reached limit 1; state may be incomplete"); - expect(requestedLimit).toBe(2); + const withdrawals = await collect(manager.findWithdrawalRequests(client, [lock], { tip: headerLike(3n), pageSize: 1 })); + + expect(requestedPageSize).toBe(1); + expect(withdrawals.map((withdrawal) => withdrawal.cell.outPoint.txHash)).toEqual([ + firstWithdrawal.outPoint.txHash, + secondWithdrawal.outPoint.txHash, + ]); }); it("decodes withdrawals concurrently and yields scan order", async () => { @@ -506,6 +568,57 @@ describe("DaoManager.findWithdrawalRequests", () => { expect(transactionCalls).toBe(1); expect(withdrawals).toHaveLength(2); }); + + it("reuses withdrawal transaction and deposit header requests across lock scans", async () => { + const manager = new DaoManager(script("11"), []); + const firstLock = script("22"); + const secondLock = script("33"); + const txHash = byte32FromByte("55"); + const firstWithdrawal = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: firstLock, + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + const secondWithdrawal = ccc.Cell.from({ + outPoint: { txHash, index: 1n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: secondLock, + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + let headerCalls = 0; + let transactionCalls = 0; + const client = { + findCells: async function* (query: { script: ccc.Script }) { + await Promise.resolve(); + yield query.script.eq(firstLock) ? firstWithdrawal : secondWithdrawal; + }, + getHeaderByNumber: async () => { + headerCalls += 1; + await Promise.resolve(); + return headerLike(1n); + }, + getTransactionWithHeader: async () => { + transactionCalls += 1; + await Promise.resolve(); + return { header: headerLike(2n) }; + }, + } as unknown as ccc.Client; + + const withdrawals = await collect(manager.findWithdrawalRequests(client, [firstLock, secondLock], { + tip: headerLike(3n), + })); + + expect(headerCalls).toBe(1); + expect(transactionCalls).toBe(1); + expect(withdrawals.map((withdrawal) => withdrawal.cell.outPoint.index)).toEqual([0n, 1n]); + }); }); describe("DaoManager.withdraw", () => { diff --git a/packages/dao/src/dao.ts b/packages/dao/src/dao.ts index 23521de..db26da0 100644 --- a/packages/dao/src/dao.ts +++ b/packages/dao/src/dao.ts @@ -1,7 +1,7 @@ import { ccc, mol } from "@ckb-ccc/core"; import { - collectCompleteScan, - defaultFindCellsLimit, + collectPagedScan, + defaultCellPageSize, unique, type ScriptDeps, } from "@ickb/utils"; @@ -311,8 +311,8 @@ export class DaoManager implements ScriptDeps { * * @param client * A CKB client instance that implements: - * - `findCells(query, order, limit)` — cached searches - * - `findCellsOnChain(query, order, limit)` — direct on-chain searches + * - `findCells(query, order, pageSize)` — cached searches + * - `findCellsOnChain(query, order, pageSize)` — direct on-chain searches * * @param locks * An array of lock scripts. Only cells whose `cellOutput.lock` exactly matches @@ -332,8 +332,8 @@ export class DaoManager implements ScriptDeps { * - `maxLockUp?: ccc.Epoch` * Maximum lock-up period allowed (in epochs). * Defaults to the manager’s configured maximum (≈3 days). - * - `limit?: number` - * Batch size per lock script. Defaults to `defaultFindCellsLimit` (400). + * - `pageSize?: number` + * Cell query page size per lock script. Defaults to `defaultCellPageSize` (400). * * @yields * {@link DaoDepositCell} objects representing deposit cells. @@ -357,12 +357,13 @@ export class DaoManager implements ScriptDeps { onChain?: boolean; minLockUp?: ccc.Epoch; maxLockUp?: ccc.Epoch; - limit?: number; + pageSize?: number; }, ): AsyncGenerator { const tip = options?.tip ?? (await client.getTipHeader()); - const limit = options?.limit ?? defaultFindCellsLimit; + const pageSize = options?.pageSize ?? defaultCellPageSize; + const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); for (const lock of unique(locks)) { const findCellsArgs = [ { @@ -379,18 +380,18 @@ export class DaoManager implements ScriptDeps { "asc", ] as const; - const depositCandidates = (await collectCompleteScan( - (scanLimit) => options?.onChain - ? client.findCellsOnChain(...findCellsArgs, scanLimit) - : client.findCells(...findCellsArgs, scanLimit), - { limit, label: "DAO deposit cell" }, + const depositCandidates = (await collectPagedScan( + (pageSize) => options?.onChain + ? client.findCellsOnChain(...findCellsArgs, pageSize) + : client.findCells(...findCellsArgs, pageSize), + { pageSize }, )).filter((cell) => this.isDeposit(cell) && cell.cellOutput.lock.eq(lock)); - const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); const deposits = await Promise.all( depositCandidates.map((cell) => this.depositCellFrom(cell, client, { - ...options, + minLockUp: options?.minLockUp, + maxLockUp: options?.maxLockUp, tip, transactionCache, }), @@ -408,8 +409,8 @@ export class DaoManager implements ScriptDeps { * * @param client * A CKB client instance that implements: - * - `findCells(query, order, limit)` — cached searches - * - `findCellsOnChain(query, order, limit)` — direct on-chain searches + * - `findCells(query, order, pageSize)` — cached searches + * - `findCellsOnChain(query, order, pageSize)` — direct on-chain searches * * @param locks * An array of lock scripts. Only cells whose `cellOutput.lock` exactly matches @@ -423,8 +424,8 @@ export class DaoManager implements ScriptDeps { * - `onChain?: boolean` * If `true`, uses `findCellsOnChain`; otherwise, uses `findCells`. * Default: `false`. - * - `limit?: number` - * Batch size per lock script. Defaults to `defaultFindCellsLimit` (400). + * - `pageSize?: number` + * Cell query page size per lock script. Defaults to `defaultCellPageSize` (400). * * @yields * {@link DaoWithdrawalRequestCell} objects representing withdrawal request cells. @@ -444,12 +445,14 @@ export class DaoManager implements ScriptDeps { options?: { tip?: ccc.ClientBlockHeader; onChain?: boolean; - limit?: number; + pageSize?: number; }, ): AsyncGenerator { const tip = options?.tip ?? (await client.getTipHeader()); - const limit = options?.limit ?? defaultFindCellsLimit; + const pageSize = options?.pageSize ?? defaultCellPageSize; + const headerCache: DaoCellFromCache["headerCache"] = new Map(); + const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); for (const lock of unique(locks)) { const findCellsArgs = [ { @@ -464,19 +467,16 @@ export class DaoManager implements ScriptDeps { "asc", ] as const; - const withdrawalCandidates = (await collectCompleteScan( - (scanLimit) => options?.onChain - ? client.findCellsOnChain(...findCellsArgs, scanLimit) - : client.findCells(...findCellsArgs, scanLimit), - { limit, label: "DAO withdrawal request cell" }, + const withdrawalCandidates = (await collectPagedScan( + (pageSize) => options?.onChain + ? client.findCellsOnChain(...findCellsArgs, pageSize) + : client.findCells(...findCellsArgs, pageSize), + { pageSize }, )).filter((cell) => this.isWithdrawalRequest(cell) && cell.cellOutput.lock.eq(lock)); - const headerCache: DaoCellFromCache["headerCache"] = new Map(); - const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); const withdrawals = await Promise.all( withdrawalCandidates.map((cell) => this.withdrawalRequestCellFrom(cell, client, { - ...options, tip, headerCache, transactionCache, diff --git a/packages/order/src/order.test.ts b/packages/order/src/order.test.ts index 682c2d1..b02c9f3 100644 --- a/packages/order/src/order.test.ts +++ b/packages/order/src/order.test.ts @@ -1,6 +1,6 @@ import { ccc } from "@ckb-ccc/core"; import { byte32FromByte, script } from "@ickb/testkit"; -import { defaultFindCellsLimit } from "@ickb/utils"; +import { defaultCellPageSize } from "@ickb/utils"; import { describe, expect, it } from "vitest"; import { OrderCell } from "./cells.js"; import { Info, OrderData, Ratio, Relative } from "./entities.js"; @@ -781,7 +781,7 @@ describe("OrderCell.resolve", () => { }); describe("OrderManager.findOrders", () => { - it("fails closed when order scanning reaches the limit", async () => { + it("passes the default page size to order scanning", async () => { const orderScript = ccc.Script.from({ codeHash: byte32FromByte("11"), hashType: "type", @@ -793,36 +793,23 @@ describe("OrderManager.findOrders", () => { args: "0x", }); const manager = new OrderManager(orderScript, [], udtScript); - const order = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "absolute", - value: { txHash: byte32FromByte("33"), index: 1n }, - }, - lock: orderScript, - outPoint: { txHash: byte32FromByte("34"), index: 0n }, - }); + let requestedPageSize = 0; const client = { - findCellsOnChain: async function* (query: { scriptType: string }) { + findCellsOnChain: async function* (query: { scriptType: string }, _order: unknown, pageSize: number) { await Promise.resolve(); if (query.scriptType !== "lock") { return; } - - for (let index = 0; index <= defaultFindCellsLimit; index += 1) { - yield order.cell; - } + requestedPageSize = pageSize; + yield* [] as ccc.Cell[]; }, } as unknown as ccc.Client; - await expect(collectOrders(manager, client)).rejects.toThrow( - `order cell scan reached limit ${String(defaultFindCellsLimit)}`, - ); + await expect(collectOrders(manager, client)).resolves.toEqual([]); + expect(requestedPageSize).toBe(defaultCellPageSize); }); - it("fails closed when master scanning reaches the limit", async () => { + it("passes the default page size to master scanning", async () => { const orderScript = ccc.Script.from({ codeHash: byte32FromByte("11"), hashType: "type", @@ -833,40 +820,24 @@ describe("OrderManager.findOrders", () => { hashType: "type", args: "0x", }); - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); const manager = new OrderManager(orderScript, [], udtScript); - const masterCell = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("35"), index: 1n }, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); + let requestedPageSize = 0; const client = { - findCellsOnChain: async function* (query: { scriptType: string }) { + findCellsOnChain: async function* (query: { scriptType: string }, _order: unknown, pageSize: number) { await Promise.resolve(); if (query.scriptType !== "type") { return; } - - for (let index = 0; index <= defaultFindCellsLimit; index += 1) { - yield masterCell; - } + requestedPageSize = pageSize; + yield* [] as ccc.Cell[]; }, } as unknown as ccc.Client; - await expect(collectOrders(manager, client)).rejects.toThrow( - `master cell scan reached limit ${String(defaultFindCellsLimit)}`, - ); + await expect(collectOrders(manager, client)).resolves.toEqual([]); + expect(requestedPageSize).toBe(defaultCellPageSize); }); - it("accepts exact-limit order and master scans", async () => { + it("accepts exact page-size order and master scans", async () => { const orderScript = ccc.Script.from({ codeHash: byte32FromByte("11"), hashType: "type", @@ -920,7 +891,7 @@ describe("OrderManager.findOrders", () => { findCellsOnChain: async function* (query: { scriptType: string }) { await Promise.resolve(); if (query.scriptType === "lock") { - for (let index = 0; index < defaultFindCellsLimit; index += 1) { + for (let index = 0; index < defaultCellPageSize; index += 1) { yield index === 0 ? order.cell : ccc.Cell.from({ outPoint: { txHash: byte32FromByte("38"), index: BigInt(index) }, cellOutput: { @@ -934,7 +905,7 @@ describe("OrderManager.findOrders", () => { return; } - for (let index = 0; index < defaultFindCellsLimit; index += 1) { + for (let index = 0; index < defaultCellPageSize; index += 1) { yield index === 0 ? masterCell : ccc.Cell.from({ outPoint: { txHash: byte32FromByte("39"), index: BigInt(index) }, cellOutput: { diff --git a/packages/order/src/order.ts b/packages/order/src/order.ts index 05e4299..b6dc04e 100644 --- a/packages/order/src/order.ts +++ b/packages/order/src/order.ts @@ -1,9 +1,9 @@ import { ccc } from "@ckb-ccc/core"; import { BufferedGenerator, - collectCompleteScan, + collectPagedScan, compareBigInt, - defaultFindCellsLimit, + defaultCellPageSize, type ExchangeRatio, type ScriptDeps, type ValueComponents, @@ -552,24 +552,24 @@ export class OrderManager implements ScriptDeps { * * @param client – Client to interact with the blockchain. * @param options.onChain – Defaults to true. When false, use cached cell queries. - * @param options.limit – Maximum cells to scan per findCells batch. Defaults to `defaultFindCellsLimit` (400). + * @param options.pageSize – Cell query page size. Defaults to `defaultCellPageSize` (400). * @yields OrderGroup instances combining master, order, and origin cells. */ async *findOrders( client: ccc.Client, options?: { onChain?: boolean; - limit?: number; + pageSize?: number; onSkippedGroup?: (reason: OrderGroupSkipReason) => void; }, ): AsyncGenerator { const onChain = options?.onChain ?? true; - const limit = options?.limit ?? defaultFindCellsLimit; + const pageSize = options?.pageSize ?? defaultCellPageSize; // Fetch simple orders & master cells in parallel const [simpleOrders, allMasters] = await Promise.all([ - this.findSimpleOrders(client, onChain, limit), - this.findAllMasters(client, onChain, limit), + this.findSimpleOrders(client, onChain, pageSize), + this.findAllMasters(client, onChain, pageSize), ]); // Prepare a map of masterCellKey → { master, orders[] } @@ -622,13 +622,13 @@ export class OrderManager implements ScriptDeps { * * @param client – The client used to interact with the blockchain. * @param onChain - When true, use live RPC queries; otherwise, use cached results. - * @param limit – Maximum cells to scan per findCells batch. + * @param pageSize – Cell query page size. * @returns Promise that resolves to an array of {@link OrderCell}. */ private async findSimpleOrders( client: ccc.Client, onChain: boolean, - limit: number, + pageSize: number, ): Promise { const findCellsArgs = [ { @@ -644,11 +644,11 @@ export class OrderManager implements ScriptDeps { ] as const; const orders: OrderCell[] = []; - for (const cell of await collectCompleteScan( - (scanLimit) => onChain - ? client.findCellsOnChain(...findCellsArgs, scanLimit) - : client.findCells(...findCellsArgs, scanLimit), - { limit, label: "order cell" }, + for (const cell of await collectPagedScan( + (requestPageSize) => onChain + ? client.findCellsOnChain(...findCellsArgs, requestPageSize) + : client.findCells(...findCellsArgs, requestPageSize), + { pageSize }, )) { const order = OrderCell.tryFrom(cell); if (!order || !this.isOrder(cell)) { @@ -669,13 +669,13 @@ export class OrderManager implements ScriptDeps { * * @param client – The client used to interact with the blockchain. * @param onChain - When true, use live RPC queries; otherwise, use cached results. - * @param limit – Maximum cells to scan per findCells batch. + * @param pageSize – Cell query page size. * @returns Promise that resolves to an array of {@link MasterCell}. */ private async findAllMasters( client: ccc.Client, onChain: boolean, - limit: number, + pageSize: number, ): Promise { const findCellsArgs = [ { @@ -688,11 +688,11 @@ export class OrderManager implements ScriptDeps { ] as const; const masters: MasterCell[] = []; - for (const cell of await collectCompleteScan( - (scanLimit) => onChain - ? client.findCellsOnChain(...findCellsArgs, scanLimit) - : client.findCells(...findCellsArgs, scanLimit), - { limit, label: "master cell" }, + for (const cell of await collectPagedScan( + (requestPageSize) => onChain + ? client.findCellsOnChain(...findCellsArgs, requestPageSize) + : client.findCells(...findCellsArgs, requestPageSize), + { pageSize }, )) { if (!this.isMaster(cell)) { // Skip cells that do not satisfy master criteria diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a143161..989a515 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -46,7 +46,7 @@ See [docs/pool_maturity_estimates.md](./docs/pool_maturity_estimates.md). `IckbSdk.buildConversionTransaction(...)` builds a partial conversion transaction plus domain metadata. It owns the reusable CKB-to-iCKB and iCKB-to-CKB planning policy: base transaction assembly, direct deposit limits, exact ready-withdrawal selection, required live deposit anchors, order fallback construction, small iCKB dust order terms, and maturity metadata. The helper returns typed failures such as `amount-too-small`, `not-enough-ready-deposits`, and `nothing-to-do`; callers own user-facing copy. -For iCKB-to-CKB planning, `getPoolDeposits(client, tip, options?)` fetches the public pool deposit snapshot on chain and accepts an optional scan `limit`. The underlying DAO deposit scan requests one sentinel cell beyond that limit and fails closed if the sentinel appears. `getL1State(...)` includes that snapshot in `system.poolDeposits` so UI callers can key previews by the same pool identity and avoid re-fetching for every preview. Callers that need larger bounded state scans can pass `poolDepositLimit` to `getL1State(...)` and `accountLimit` to `getL1AccountState(...)`; both preserve the sentinel fail-closed scan behavior. +For iCKB-to-CKB planning, `getPoolDeposits(client, tip, options?)` fetches the public pool deposit snapshot on chain. `getL1State(...)` includes that snapshot in `system.poolDeposits` so UI callers can key previews by the same pool identity and avoid re-fetching for every preview. `getPoolDeposits(...)`, `getL1State(...)`, and `getL1AccountState(...)` accept `cellPageSize` as the shared CCC cell-query page size; it does not cap total results. `getL1State(...)` also accepts `poolDeposits` range filters for callers that need a narrower pool window. `getL1State(...)` and `getL1AccountState(...)` return best-effort state computed from a sampled `system.tip`; they do not perform a final current-tip assertion after all scans complete. Callers should keep the time from state fetch to transaction build low and let transaction validation decide whether referenced cells are still live and the transaction can be accepted. diff --git a/packages/sdk/docs/pool_maturity_estimates.md b/packages/sdk/docs/pool_maturity_estimates.md index 7149055..d9abc13 100644 --- a/packages/sdk/docs/pool_maturity_estimates.md +++ b/packages/sdk/docs/pool_maturity_estimates.md @@ -25,7 +25,7 @@ Instead, `packages/sdk/src/sdk.ts` builds the estimate from: Ready deposits are counted as immediately available CKB. Not-ready deposits remain in the future maturity buckets. -These scans fail closed when the scan reaches the configured cell limit sentinel. A partial pool scan is not treated as a lower-confidence estimate, because interface timing and bot liquidity decisions need to distinguish incomplete state from genuinely unavailable liquidity. +These scans use the configured `cellPageSize` as the CCC cell-query page size. The page size tunes paging without changing how many total deposits can be collected. ## Why Direct Scans Are Used diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index af18ee4..01d0131 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -23,7 +23,7 @@ import { } from "@ickb/core"; import { OrderManager } from "@ickb/order"; import { headerLike as testHeaderLike, hash, script } from "@ickb/testkit"; -import { defaultFindCellsLimit } from "@ickb/utils"; +import { defaultCellPageSize } from "@ickb/utils"; import { completeIckbTransaction, estimateMaturityFeeThreshold, @@ -2330,83 +2330,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { expect(state.system.ckbMaturing).toEqual([]); }); - it("allows bot capacity scanning to exactly reach the limit", async () => { - const botLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - ownedOwnerManager, - new LogicManager(logic, [], new DaoManager(dao, [])), - new OrderManager(order, [], udt), - [botLock], - ); - const plainCell = ccc.Cell.from({ - outPoint: { txHash: hash("04"), index: 0n }, - cellOutput: { capacity: 1n, lock: botLock }, - outputData: "0x", - }); - const client = { - getTipHeader: () => Promise.resolve(headerLike(1n)), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { - filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; - }) { - if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { - yield* repeat(defaultFindCellsLimit, plainCell); - } - await Promise.resolve(); - }, - } as unknown as ccc.Client; - - await expect(sdk.getL1State(client, [])).resolves.toBeDefined(); - }); - - it("fails closed when bot capacity scanning exceeds the limit", async () => { - const botLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - ownedOwnerManager, - new LogicManager(logic, [], new DaoManager(dao, [])), - new OrderManager(order, [], udt), - [botLock], - ); - const plainCell = ccc.Cell.from({ - outPoint: { txHash: hash("04"), index: 0n }, - cellOutput: { capacity: 1n, lock: botLock }, - outputData: "0x", - }); - const client = { - getTipHeader: () => Promise.resolve(headerLike(1n)), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { - filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; - }) { - if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { - yield* repeat(defaultFindCellsLimit + 1, plainCell); - } - await Promise.resolve(); - }, - } as unknown as ccc.Client; - - await expect(sdk.getL1State(client, [])).rejects.toThrow( - `bot capacity scan reached limit ${String(defaultFindCellsLimit)}`, - ); - }); - - it("does not start bot withdrawal scanning when bot capacity scanning fails", async () => { + it("uses one page size for bot capacity and withdrawal scans", async () => { const botLock = script("11"); const logic = script("22"); const dao = script("33"); @@ -2416,6 +2340,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); const findWithdrawalGroups = vi.spyOn(ownedOwnerManager, "findWithdrawalGroups") .mockImplementation(() => none()); + const pageSize = defaultCellPageSize + 100; const sdk = new IckbSdk( fakeIckbUdt(udt), ownedOwnerManager, @@ -2428,23 +2353,24 @@ describe("IckbSdk.getL1State snapshot detection", () => { cellOutput: { capacity: 1n, lock: botLock }, outputData: "0x", }); + let requestedPageSize = 0; const client = { getTipHeader: () => Promise.resolve(headerLike(1n)), getFeeRate: () => Promise.resolve(1n), findCellsOnChain: async function* (query: { filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; - }) { + }, _order: unknown, pageSize: number) { if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { - yield* repeat(defaultFindCellsLimit + 1, plainCell); + requestedPageSize = pageSize; + yield* repeat(pageSize + 1, plainCell); } await Promise.resolve(); }, } as unknown as ccc.Client; - await expect(sdk.getL1State(client, [])).rejects.toThrow( - `bot capacity scan reached limit ${String(defaultFindCellsLimit)}`, - ); - expect(findWithdrawalGroups).not.toHaveBeenCalled(); + await expect(sdk.getL1State(client, [], { cellPageSize: pageSize })).resolves.toBeDefined(); + expect(requestedPageSize).toBe(pageSize); + expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ pageSize }); }); it("propagates bot withdrawal scan failures after bot capacity scanning succeeds", async () => { @@ -2476,90 +2402,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { await expect(sdk.getL1State(client, [])).rejects.toThrow("withdrawal failed"); }); - it("allows direct deposit scanning to exactly reach the limit", async () => { - const botLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const logicManager = new LogicManager(logic, [], new DaoManager(dao, [])); - const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); - const deposit = { - cell: ccc.Cell.from({ - outPoint: { txHash: hash("03"), index: 0n }, - cellOutput: { capacity: 1n, lock: logic, type: dao }, - outputData: DaoManager.depositData(), - }), - isReady: false, - ckbValue: 1n, - udtValue: 1n, - maturity: { toUnix: () => 1n }, - } as unknown as IckbDepositCell; - vi.spyOn(logicManager, "findDeposits").mockImplementation(() => - repeat(defaultFindCellsLimit, deposit) - ); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - ownedOwnerManager, - logicManager, - new OrderManager(order, [], udt), - [botLock], - ); - const client = { - getTipHeader: () => Promise.resolve(headerLike(1n)), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - - await expect(sdk.getL1State(client, [])).resolves.toBeDefined(); - }); - - it("fails closed when direct deposit scanning exceeds the limit", async () => { - const botLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const daoManager = new DaoManager(dao, []); - const logicManager = new LogicManager(logic, [], daoManager); - const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - ownedOwnerManager, - logicManager, - new OrderManager(order, [], udt), - [botLock], - ); - const deposit = ccc.Cell.from({ - outPoint: { txHash: hash("03"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: logic, - type: dao, - }, - outputData: DaoManager.depositData(), - }); - const client = { - getTipHeader: () => Promise.resolve(headerLike(1n)), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { filter?: { outputData?: ccc.Hex } }) { - if (query.filter?.outputData === DaoManager.depositData()) { - yield* repeat(defaultFindCellsLimit + 1, deposit); - } - await Promise.resolve(); - }, - } as unknown as ccc.Client; - - await expect(sdk.getL1State(client, [])).rejects.toThrow( - `DAO deposit cell scan reached limit ${String(defaultFindCellsLimit)}`, - ); - }); - - it("passes the logical limit to direct deposit scanning", async () => { + it("passes the default page size to direct deposit scanning", async () => { const botLock = script("11"); const logic = script("22"); const dao = script("33"); @@ -2586,28 +2429,32 @@ describe("IckbSdk.getL1State snapshot detection", () => { await sdk.getL1State(client, []); expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ - limit: defaultFindCellsLimit, + pageSize: defaultCellPageSize, }); }); - it("passes a custom logical limit to pool deposit scanning", async () => { + it("passes a custom page size to pool deposit scanning", async () => { const { sdk, logicManager } = testSdk(); const findDeposits = vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); const client = { findCellsOnChain: () => none(), } as unknown as ccc.Client; - const poolLimit = defaultFindCellsLimit + 100; + const cellPageSize = defaultCellPageSize + 100; + const minLockUp = ccc.Epoch.from([0n, 1n, 16n]); + const maxLockUp = ccc.Epoch.from([0n, 4n, 16n]); - await sdk.getPoolDeposits(client, tip, { limit: poolLimit }); + await sdk.getPoolDeposits(client, tip, { cellPageSize, minLockUp, maxLockUp }); expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ onChain: true, tip, - limit: poolLimit, + pageSize: cellPageSize, + minLockUp, + maxLockUp, }); }); - it("passes a custom pool deposit scan limit through L1 state loading", async () => { + it("passes custom pool deposit scan options through L1 state loading", async () => { const { sdk, logicManager } = testSdk(); const findDeposits = vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); const client = { @@ -2615,18 +2462,25 @@ describe("IckbSdk.getL1State snapshot detection", () => { getFeeRate: () => Promise.resolve(1n), findCellsOnChain: () => none(), } as unknown as ccc.Client; - const poolDepositLimit = defaultFindCellsLimit + 100; + const cellPageSize = defaultCellPageSize + 100; + const minLockUp = ccc.Epoch.from([0n, 1n, 16n]); + const maxLockUp = ccc.Epoch.from([0n, 4n, 16n]); - await sdk.getL1State(client, [], { poolDepositLimit }); + await sdk.getL1State(client, [], { + cellPageSize, + poolDeposits: { minLockUp, maxLockUp }, + }); expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ onChain: true, tip, - limit: poolDepositLimit, + pageSize: cellPageSize, + minLockUp, + maxLockUp, }); }); - it("passes a custom available capacity scan limit through L1 state loading", async () => { + it("passes one custom page size through L1 state loading", async () => { const botLock = script("11"); const logic = script("22"); const dao = script("33"); @@ -2637,8 +2491,9 @@ describe("IckbSdk.getL1State snapshot detection", () => { const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); const orderManager = new OrderManager(order, [], udt); vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); - vi.spyOn(orderManager, "findOrders").mockImplementation(() => none()); + const findWithdrawalGroups = vi.spyOn(ownedOwnerManager, "findWithdrawalGroups") + .mockImplementation(() => none()); + const findOrders = vi.spyOn(orderManager, "findOrders").mockImplementation(() => none()); const sdk = new IckbSdk( fakeIckbUdt(udt), ownedOwnerManager, @@ -2651,77 +2506,34 @@ describe("IckbSdk.getL1State snapshot detection", () => { cellOutput: { capacity: 1n, lock: botLock }, outputData: "0x", }); - const availableCapacityLimit = defaultFindCellsLimit + 1; + const cellPageSize = defaultCellPageSize + 1; + const sampledTip = headerLike(1n); + const capacityLimits: number[] = []; const client = { - getTipHeader: () => Promise.resolve(headerLike(1n)), + getTipHeader: () => Promise.resolve(sampledTip), getFeeRate: () => Promise.resolve(1n), findCellsOnChain: async function* (query: { filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; - }) { + }, _order: unknown, pageSize: number) { if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { - yield* repeat(availableCapacityLimit, plainCell); + capacityLimits.push(pageSize); + yield* repeat(cellPageSize, plainCell); } await Promise.resolve(); }, } as unknown as ccc.Client; - await expect( - sdk.getL1State(client, [], { availableCapacityLimit }), - ).resolves.toBeDefined(); - }); - - it("passes a custom pending withdrawal scan limit through L1 state loading", async () => { - const { sdk, logicManager, ownedOwnerManager, orderManager } = testSdk(); - vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); - const findWithdrawalGroups = vi.spyOn(ownedOwnerManager, "findWithdrawalGroups") - .mockImplementation(() => none()); - vi.spyOn(orderManager, "findOrders").mockImplementation(() => none()); - const client = { - getTipHeader: () => Promise.resolve(tip), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - const pendingWithdrawalLimit = defaultFindCellsLimit + 100; - - await sdk.getL1State(client, [], { pendingWithdrawalLimit }); + await expect(sdk.getL1State(client, [], { cellPageSize })).resolves.toBeDefined(); + expect(capacityLimits).toEqual([cellPageSize]); expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ onChain: true, - tip, - limit: pendingWithdrawalLimit, - }); - }); - - it("passes a custom order scan limit through L1 state loading", async () => { - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const orderManager = new OrderManager(order, [], udt); - const findOrders = vi.spyOn(orderManager, "findOrders").mockImplementation(async function* () { - await Promise.resolve(); - yield* [] as OrderGroup[]; + tip: sampledTip, + pageSize: cellPageSize, }); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), - new LogicManager(logic, [], new DaoManager(dao, [])), - orderManager, - [], - ); - const client = { - getTipHeader: () => Promise.resolve(headerLike(1n)), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - const orderLimit = defaultFindCellsLimit + 100; - - await sdk.getL1State(client, [], { orderLimit }); - expect(findOrders.mock.calls[0]?.[1]).toMatchObject({ onChain: true, - limit: orderLimit, + pageSize: cellPageSize, }); }); @@ -2938,27 +2750,37 @@ describe("IckbSdk.getL1State snapshot detection", () => { ); }); - it("passes a custom account scan limit through L1 account state loading", async () => { + it("passes one custom page size through L1 account state loading", async () => { const { sdk, logicManager, ownedOwnerManager, orderManager } = testSdk(); const accountLock = script("77"); vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); - vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + const findReceipts = vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); + const findWithdrawalGroups = vi.spyOn(ownedOwnerManager, "findWithdrawalGroups") + .mockImplementation(() => none()); vi.spyOn(orderManager, "findOrders").mockImplementation(() => none()); const cell = ccc.Cell.from({ outPoint: { txHash: hash("93"), index: 0n }, cellOutput: { capacity: 5n, lock: accountLock }, outputData: "0x", }); + const cellPageSize = 1; + let requestedPageSize = 0; const client = { getTipHeader: () => Promise.resolve(tip), getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => repeat(2, cell), + findCellsOnChain: async function* (_query: unknown, _order: unknown, pageSize: number) { + requestedPageSize = pageSize; + yield* repeat(2, cell); + await Promise.resolve(); + }, } as unknown as ccc.Client; - await expect( - sdk.getL1AccountState(client, [accountLock], { accountLimit: 1 }), - ).rejects.toThrow("account scan reached limit 1"); + const state = await sdk.getL1AccountState(client, [accountLock], { cellPageSize }); + + expect(requestedPageSize).toBe(cellPageSize); + expect(findReceipts.mock.calls[0]?.[2]).toMatchObject({ pageSize: cellPageSize }); + expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ pageSize: cellPageSize }); + expect(state.account.capacityCells).toEqual([cell, cell]); }); }); @@ -3015,79 +2837,33 @@ describe("IckbSdk.getAccountState", () => { expect(ickbUdt.infoFrom).toHaveBeenCalledWith(client, [udtCell]); }); - it("allows account cell scanning to exactly reach the limit", async () => { - const accountLock = script("11"); - const udt = script("66"); - const daoManager = new DaoManager(script("33"), []); - const logicManager = new LogicManager(script("22"), [], daoManager); - const ownedOwnerManager = new OwnedOwnerManager(script("44"), [], daoManager); - vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - ownedOwnerManager, - logicManager, - new OrderManager(script("55"), [], udt), - [], - ); - const cell = ccc.Cell.from({ - outPoint: { txHash: hash("92"), index: 0n }, - cellOutput: { capacity: 5n, lock: accountLock }, - outputData: "0x", - }); - const client = { - findCellsOnChain: () => repeat(defaultFindCellsLimit, cell), - } as unknown as ccc.Client; - - await expect(sdk.getAccountState(client, [accountLock], tip)).resolves.toBeDefined(); - }); - - it("uses a custom account cell scan limit", async () => { + it("uses a custom account cell scan page size", async () => { const { sdk, logicManager, ownedOwnerManager } = testSdk(); const accountLock = script("11"); - vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + const findReceipts = vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); + const findWithdrawalGroups = vi.spyOn(ownedOwnerManager, "findWithdrawalGroups") + .mockImplementation(() => none()); const cell = ccc.Cell.from({ outPoint: { txHash: hash("92"), index: 0n }, cellOutput: { capacity: 5n, lock: accountLock }, outputData: "0x", }); + const cellPageSize = 1; + let requestedPageSize = 0; const client = { - findCellsOnChain: () => repeat(2, cell), + findCellsOnChain: async function* (_query: unknown, _order: unknown, pageSize: number) { + requestedPageSize = pageSize; + yield* repeat(2, cell); + await Promise.resolve(); + }, } as unknown as ccc.Client; - await expect( - sdk.getAccountState(client, [accountLock], tip, { limit: 1 }), - ).rejects.toThrow("account scan reached limit 1"); - }); - - it("fails closed when account cell scanning exceeds the limit", async () => { - const accountLock = script("11"); - const udt = script("66"); - const daoManager = new DaoManager(script("33"), []); - const logicManager = new LogicManager(script("22"), [], daoManager); - const ownedOwnerManager = new OwnedOwnerManager(script("44"), [], daoManager); - vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - ownedOwnerManager, - logicManager, - new OrderManager(script("55"), [], udt), - [], - ); - const cell = ccc.Cell.from({ - outPoint: { txHash: hash("92"), index: 0n }, - cellOutput: { capacity: 5n, lock: accountLock }, - outputData: "0x", - }); - const client = { - findCellsOnChain: () => repeat(defaultFindCellsLimit + 1, cell), - } as unknown as ccc.Client; + const state = await sdk.getAccountState(client, [accountLock], tip, { cellPageSize }); - await expect(sdk.getAccountState(client, [accountLock], tip)).rejects.toThrow( - `account scan reached limit ${String(defaultFindCellsLimit)}`, - ); + expect(requestedPageSize).toBe(cellPageSize); + expect(findReceipts.mock.calls[0]?.[2]).toMatchObject({ pageSize: cellPageSize }); + expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ pageSize: cellPageSize }); + expect(state.capacityCells).toEqual([cell, cell]); }); }); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 498472e..09a7c5c 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -2,10 +2,10 @@ import { ccc } from "@ckb-ccc/core"; import { assertDaoOutputLimit, DaoOutputLimitError } from "@ickb/dao"; import { collect, - collectCompleteScan, + collectPagedScan, binarySearch, compareBigInt, - defaultFindCellsLimit, + defaultCellPageSize, isPlainCapacityCell, unique, type ValueComponents, @@ -50,6 +50,15 @@ export interface PoolDepositState { id: string; } +export interface PoolDepositRangeOptions { + minLockUp?: ccc.Epoch; + maxLockUp?: ccc.Epoch; +} + +export interface GetPoolDepositsOptions extends PoolDepositRangeOptions { + cellPageSize?: number; +} + export interface ConversionTransactionContext { system: SystemState; receipts: ReceiptCell[]; @@ -162,14 +171,8 @@ export type SendAndWaitForCommitEvent = }; export interface GetL1StateOptions { - availableCapacityLimit?: number; - pendingWithdrawalLimit?: number; - orderLimit?: number; - poolDepositLimit?: number; -} - -export interface GetL1AccountStateOptions extends GetL1StateOptions { - accountLimit?: number; + cellPageSize?: number; + poolDeposits?: PoolDepositRangeOptions; } export interface IckbToCkbOrderEstimate { @@ -722,12 +725,15 @@ export class IckbSdk { async getPoolDeposits( client: ccc.Client, tip: ccc.ClientBlockHeader, - options?: { limit?: number }, + options?: GetPoolDepositsOptions, ): Promise { + const cellPageSize = options?.cellPageSize ?? defaultCellPageSize; const deposits = await collect(this.ickbLogic.findDeposits(client, { onChain: true, tip, - limit: options?.limit ?? defaultFindCellsLimit, + pageSize: cellPageSize, + minLockUp: options?.minLockUp, + maxLockUp: options?.maxLockUp, })); const readyDeposits = sortDepositsByMaturity( deposits.filter((deposit) => deposit.isReady), @@ -1052,15 +1058,17 @@ export class IckbSdk { client: ccc.Client, locks: ccc.Script[], tip: ccc.ClientBlockHeader, - options?: { limit?: number }, + options?: { cellPageSize?: number }, ): Promise { + const cellPageSize = options?.cellPageSize ?? defaultCellPageSize; const [cells, receipts, withdrawalGroups] = await Promise.all([ - this.findAccountCells(client, locks, options), - collect(this.ickbLogic.findReceipts(client, locks, { onChain: true })), + this.findAccountCells(client, locks, { pageSize: cellPageSize }), + collect(this.ickbLogic.findReceipts(client, locks, { onChain: true, pageSize: cellPageSize })), collect( this.ownedOwner.findWithdrawalGroups(client, locks, { onChain: true, tip, + pageSize: cellPageSize, }), ), ]); @@ -1083,7 +1091,7 @@ export class IckbSdk { async getL1AccountState( client: ccc.Client, locks: ccc.Script[], - options?: GetL1AccountStateOptions, + options?: GetL1StateOptions, ): Promise<{ system: SystemState; user: { orders: OrderGroup[] }; @@ -1091,7 +1099,7 @@ export class IckbSdk { }> { const { system, user } = await this.getL1State(client, locks, options); const account = await this.getAccountState(client, locks, system.tip, { - limit: options?.accountLimit, + cellPageSize: options?.cellPageSize, }); return { system, user, account }; @@ -1102,7 +1110,7 @@ export class IckbSdk { * * The method performs the following: * - Obtains the current block tip and calculates the exchange ratio. - * - Fetches available CKB and the maturing CKB based on bot capacities and direct deposit scans. + * - Fetches available CKB and the maturing CKB based on bot capacities and the pool deposit scan. * - Filters orders into user-owned and system orders based on the provided locks. * - Estimates user-owned orders maturity. * @@ -1119,17 +1127,18 @@ export class IckbSdk { ): Promise<{ system: SystemState; user: { orders: OrderGroup[] } }> { const tip = await client.getTipHeader(); const exchangeRatio = Ratio.from(ickbExchangeRatio(tip)); + const cellPageSize = options?.cellPageSize ?? defaultCellPageSize; // Parallel fetching of system components. const [poolDeposits, orders, feeRate] = await Promise.all([ - this.getPoolDeposits(client, tip, { limit: options?.poolDepositLimit }), + this.getPoolDeposits(client, tip, { ...options?.poolDeposits, cellPageSize }), collect(this.order.findOrders(client, { onChain: true, - limit: options?.orderLimit ?? defaultFindCellsLimit, + pageSize: cellPageSize, })), client.getFeeRate(), ]); - const { ckbAvailable, ckbMaturing } = await this.getCkb(client, tip, poolDeposits, options); + const { ckbAvailable, ckbMaturing } = await this.getCkb(client, tip, poolDeposits, { cellPageSize }); const midInfo = new Info(exchangeRatio, exchangeRatio, 1); const userOrders: OrderGroup[] = []; @@ -1174,7 +1183,7 @@ export class IckbSdk { * - Fetches bot withdrawal requests and bot plain-capacity balances. * - Aggregates available CKB balances from bot capacities. * - Calculates maturing CKB values (with their expected maturity timestamps) - * via direct deposit cell lookups. + * from the already loaded pool deposits. * - Sorts and cumulatively sums the maturing values for later lookup. * * @param client - The blockchain client used for fetching data. @@ -1188,25 +1197,24 @@ export class IckbSdk { client: ccc.Client, tip: ccc.ClientBlockHeader, poolDeposits: PoolDepositState, - options?: GetL1StateOptions, + options: { cellPageSize: number }, ): Promise<{ ckbAvailable: ccc.FixedPoint; ckbMaturing: CkbCumulative[]; }> { - const botCapacityLimit = options?.availableCapacityLimit ?? defaultFindCellsLimit; - const botWithdrawalLimit = options?.pendingWithdrawalLimit ?? defaultFindCellsLimit; + const cellPageSize = options.cellPageSize; const withdrawalOptions = { onChain: true, tip, - limit: botWithdrawalLimit, + pageSize: cellPageSize, }; // Map to track each bot's available CKB (minus a reserved amount for internal operations). const bot2Ckb = new Map(); const reserved = -ccc.fixedPointFrom("2000"); for (const lock of unique(this.bots)) { const key = lock.toHex(); - for (const cell of await collectCompleteScan( - (scanLimit) => client.findCellsOnChain( + for (const cell of await collectPagedScan( + (pageSize) => client.findCellsOnChain( { script: lock, scriptType: "lock", @@ -1218,9 +1226,9 @@ export class IckbSdk { withData: true, }, "asc", - scanLimit, + pageSize, ), - { limit: botCapacityLimit, label: "bot capacity", context: lock }, + { pageSize: cellPageSize }, )) { if (isPlainCapacityCell(cell)) { const ckb = @@ -1296,13 +1304,13 @@ export class IckbSdk { private async findAccountCells( client: ccc.Client, locks: ccc.Script[], - options?: { limit?: number }, + options?: { pageSize?: number }, ): Promise { const cells: ccc.Cell[] = []; - const limit = options?.limit ?? defaultFindCellsLimit; + const pageSize = options?.pageSize ?? defaultCellPageSize; for (const lock of unique(locks)) { - cells.push(...await collectCompleteScan( - (scanLimit) => client.findCellsOnChain( + cells.push(...await collectPagedScan( + (pageSize) => client.findCellsOnChain( { script: lock, scriptType: "lock", @@ -1310,9 +1318,9 @@ export class IckbSdk { withData: true, }, "asc", - scanLimit, + pageSize, ), - { limit, label: "account", context: lock }, + { pageSize }, )); } return cells; diff --git a/packages/utils/src/utils.test.ts b/packages/utils/src/utils.test.ts index 0c2a1ca..9cb3ac3 100644 --- a/packages/utils/src/utils.test.ts +++ b/packages/utils/src/utils.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from "vitest"; -import { ccc } from "@ckb-ccc/core"; import { BufferedGenerator, - assertCompleteScan, compareBigInt, - collectCompleteScan, - scanLimit, + collectPagedScan, } from "./utils.js"; describe("compareBigInt", () => { @@ -36,72 +33,19 @@ describe("BufferedGenerator", () => { }); }); -describe("scan completeness", () => { - it("derives the sentinel scan limit", () => { - expect(scanLimit(400)).toBe(401); - }); - - it("allows scans up to the logical limit", () => { - expect(() => { - assertCompleteScan(400, 400, "account"); - }).not.toThrow(); - }); - - it("fails closed after the logical limit", () => { - expect(() => { - assertCompleteScan(401, 400, "account"); - }).toThrow( - "account scan reached limit 400; state may be incomplete", - ); - }); +describe("scan collection", () => { + it("passes the cell page size through and collects all yielded items", async () => { + const seenPageSizes: number[] = []; - it("includes script context in scan errors", () => { - const lock = ccc.Script.from({ - codeHash: `0x${"11".repeat(32)}`, - hashType: "type", - args: "0x", - }); - - expect(() => { - assertCompleteScan(401, 400, "account", lock); - }).toThrow( - `account scan reached limit 400 for ${lock.toHex()}; state may be incomplete`, - ); - }); - - it("includes string context in scan errors", () => { - expect(() => { - assertCompleteScan(401, 400, "account", " for wallet"); - }).toThrow( - "account scan reached limit 400 for wallet; state may be incomplete", - ); - }); - - it("collects scans with a sentinel limit", async () => { - const seenLimits: number[] = []; - - await expect(collectCompleteScan( - async function* (limit: number) { - seenLimits.push(limit); + await expect(collectPagedScan( + async function* (pageSize: number) { + seenPageSizes.push(pageSize); yield 1; yield 2; await Promise.resolve(); }, - { limit: 2, label: "account" }, + { pageSize: 2 }, )).resolves.toEqual([1, 2]); - expect(seenLimits).toEqual([3]); - }); - - it("fails closed when collected sentinel scans exceed the logical limit", async () => { - await expect(collectCompleteScan( - async function* () { - yield 1; - yield 2; - await Promise.resolve(); - }, - { limit: 1, label: "account" }, - )).rejects.toThrow( - "account scan reached limit 1; state may be incomplete", - ); + expect(seenPageSizes).toEqual([2]); }); }); diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index 88a3d24..4deacb8 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -1,53 +1,27 @@ import { ccc } from "@ckb-ccc/core"; /** - * The default upper limit on the number of cells to return when querying the chain. + * The default page size used when querying cells from the chain. * - * This limit is aligned with Nervos CKB's pull request #4576 + * This page size is aligned with Nervos CKB's pull request #4576 * (https://github.com/nervosnetwork/ckb/pull/4576) to avoid excessive paging. * * @remarks - * When searching for cells, callers may override this limit - * by passing a custom `limit` in their options. If no override is provided, - * this constant controls how many cells will be fetched in a single batch. + * When searching for cells, callers may override this page size by passing a + * custom `pageSize` in their options. This does not cap total results. */ -export const defaultFindCellsLimit = 400; +export const defaultCellPageSize = 400; -export function scanLimit(limit: number): number { - return limit + 1; -} - -export function assertCompleteScan( - scanned: number, - limit: number, - label: string, - context?: ccc.Script | string, -): void { - if (scanned <= limit) { - return; - } - - const suffix = typeof context === "string" - ? context - : context - ? ` for ${context.toHex()}` - : ""; - throw new Error(`${label} scan reached limit ${String(limit)}${suffix}; state may be incomplete`); -} - -export async function collectCompleteScan( - scan: (limit: number) => AsyncIterable, +export async function collectPagedScan( + scan: (pageSize: number) => AsyncIterable, options: { - limit: number; - label: string; - context?: ccc.Script | string; + pageSize: number; }, ): Promise { const results: T[] = []; - for await (const item of scan(scanLimit(options.limit))) { + for await (const item of scan(options.pageSize)) { results.push(item); } - assertCompleteScan(results.length, options.limit, options.label, options.context); return results; }