From 65852933228a4b2a349f105dccd7b0608b85efde Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 00:05:03 +0000 Subject: [PATCH 01/14] test: add shared iCKB testkit --- packages/core/package.json | 3 +++ packages/dao/package.json | 3 +++ packages/order/package.json | 3 +++ packages/sdk/package.json | 3 +++ packages/testkit/README.md | 9 +++++++ packages/testkit/package.json | 24 ++++++++++++++++++ packages/testkit/src/index.ts | 46 ++++++++++++++++++++++++++++++++++ packages/testkit/tsconfig.json | 10 ++++++++ pnpm-lock.yaml | 22 ++++++++++++++++ 9 files changed, 123 insertions(+) create mode 100644 packages/testkit/README.md create mode 100644 packages/testkit/package.json create mode 100644 packages/testkit/src/index.ts create mode 100644 packages/testkit/tsconfig.json diff --git a/packages/core/package.json b/packages/core/package.json index b6d8ddd..1997775 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,5 +57,8 @@ "@ickb/dao": "workspace:*", "@ickb/utils": "workspace:*", "tslib": "^2.8.1" + }, + "devDependencies": { + "@ickb/testkit": "workspace:*" } } diff --git a/packages/dao/package.json b/packages/dao/package.json index d09053d..7e784d4 100644 --- a/packages/dao/package.json +++ b/packages/dao/package.json @@ -54,5 +54,8 @@ "dependencies": { "@ckb-ccc/core": "catalog:", "@ickb/utils": "workspace:*" + }, + "devDependencies": { + "@ickb/testkit": "workspace:*" } } diff --git a/packages/order/package.json b/packages/order/package.json index 4eb5ba8..d210103 100644 --- a/packages/order/package.json +++ b/packages/order/package.json @@ -55,5 +55,8 @@ "@ckb-ccc/core": "catalog:", "@ickb/utils": "workspace:*", "tslib": "^2.8.1" + }, + "devDependencies": { + "@ickb/testkit": "workspace:*" } } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index df28ca1..f65a28f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -57,5 +57,8 @@ "@ickb/dao": "workspace:*", "@ickb/order": "workspace:*", "@ickb/utils": "workspace:*" + }, + "devDependencies": { + "@ickb/testkit": "workspace:*" } } diff --git a/packages/testkit/README.md b/packages/testkit/README.md new file mode 100644 index 0000000..c2148b3 --- /dev/null +++ b/packages/testkit/README.md @@ -0,0 +1,9 @@ +# iCKB/Testkit + +Private workspace test helpers for iCKB packages and apps. + +`@ickb/testkit` provides compact constructors and fixtures for tests that need CCC-compatible scripts, byte strings, headers, cells, and related values. It is not a runtime dependency and is not published. + +## Licensing + +Released under the [MIT License](https://github.com/ickb/stack/tree/master/LICENSE). diff --git a/packages/testkit/package.json b/packages/testkit/package.json new file mode 100644 index 0000000..379771d --- /dev/null +++ b/packages/testkit/package.json @@ -0,0 +1,24 @@ +{ + "name": "@ickb/testkit", + "version": "1001.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc", + "test": "vitest", + "test:ci": "vitest run", + "lint": "eslint ./src", + "clean": "rm -fr dist", + "clean:deep": "rm -fr dist node_modules" + }, + "dependencies": { + "@ckb-ccc/core": "catalog:" + } +} diff --git a/packages/testkit/src/index.ts b/packages/testkit/src/index.ts new file mode 100644 index 0000000..27b53b7 --- /dev/null +++ b/packages/testkit/src/index.ts @@ -0,0 +1,46 @@ +import { ccc } from "@ckb-ccc/core"; + +export function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + + return `0x${hexByte.repeat(32)}`; +} + +export const hash = byte32FromByte; + +export function script(codeHashByte: string, args = "0x"): ccc.Script { + return ccc.Script.from({ + codeHash: byte32FromByte(codeHashByte), + hashType: "type", + args, + }); +} + +export function outPoint(txHashByte: string, index = 0n): ccc.OutPoint { + return ccc.OutPoint.from({ + txHash: byte32FromByte(txHashByte), + index, + }); +} + +export function headerLike( + overrides: Partial = {}, +): ccc.ClientBlockHeader { + return ccc.ClientBlockHeader.from({ + compactTarget: 0n, + dao: { c: 0n, ar: 1000n, s: 0n, u: 0n }, + epoch: [181n, 0n, 1n], + extraHash: byte32FromByte("aa"), + hash: byte32FromByte("bb"), + nonce: 0n, + number: 3n, + parentHash: byte32FromByte("cc"), + proposalsHash: byte32FromByte("dd"), + timestamp: 0n, + transactionsRoot: byte32FromByte("ee"), + version: 0n, + ...overrides, + }); +} diff --git a/packages/testkit/tsconfig.json b/packages/testkit/tsconfig.json new file mode 100644 index 0000000..ca222a6 --- /dev/null +++ b/packages/testkit/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist", + "sourceRoot": "../src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb44295..2c83682 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1072,6 +1072,10 @@ importers: tslib: specifier: ^2.8.1 version: 2.8.1 + devDependencies: + '@ickb/testkit': + specifier: workspace:* + version: link:../testkit packages/dao: dependencies: @@ -1081,6 +1085,10 @@ importers: '@ickb/utils': specifier: workspace:* version: link:../utils + devDependencies: + '@ickb/testkit': + specifier: workspace:* + version: link:../testkit packages/order: dependencies: @@ -1093,6 +1101,10 @@ importers: tslib: specifier: ^2.8.1 version: 2.8.1 + devDependencies: + '@ickb/testkit': + specifier: workspace:* + version: link:../testkit packages/sdk: dependencies: @@ -1111,6 +1123,16 @@ importers: '@ickb/utils': specifier: workspace:* version: link:../utils + devDependencies: + '@ickb/testkit': + specifier: workspace:* + version: link:../testkit + + packages/testkit: + dependencies: + '@ckb-ccc/core': + specifier: workspace:* + version: link:../../forks/ccc/repo/packages/core packages/utils: dependencies: From 8a8c8c38fc618d6784ee8f2d516075f7935a68ca Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 00:05:03 +0000 Subject: [PATCH 02/14] fix(core): harden scan and order primitives --- packages/core/README.md | 6 +- packages/core/src/cells.test.ts | 56 +-- packages/core/src/logic.test.ts | 88 ++++- packages/core/src/logic.ts | 28 +- packages/core/src/owned_owner.test.ts | 292 ++++++++++++++-- packages/core/src/owned_owner.ts | 80 ++++- packages/core/tsconfig.json | 1 + packages/dao/src/cells.test.ts | 54 +-- packages/dao/src/dao.test.ts | 124 +++++-- packages/dao/src/dao.ts | 48 ++- packages/dao/src/deposit_readiness.test.ts | 43 +-- packages/dao/tsconfig.json | 1 + packages/order/README.md | 6 +- packages/order/src/entities.ts | 13 +- packages/order/src/index.test.ts | 15 + packages/order/src/index.ts | 2 +- packages/order/src/order.test.ts | 378 ++++++++++++++++++++- packages/order/src/order.ts | 170 ++++++--- packages/order/tsconfig.json | 1 + packages/utils/src/utils.test.ts | 80 ++++- packages/utils/src/utils.ts | 38 +++ packages/utils/tsconfig.json | 1 + 22 files changed, 1175 insertions(+), 350 deletions(-) create mode 100644 packages/order/src/index.test.ts diff --git a/packages/core/README.md b/packages/core/README.md index c18db6e..93cc5b6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -24,9 +24,9 @@ graph TD; If a caller will send the returned transaction, it still must: -1. Finish iCKB UDT completion. -2. Finish CCC-native CKB capacity and fee completion. -3. Check `ccc.isDaoOutputLimitExceeded(...)` before send. +1. Complete the transaction before send. +2. Prefer the shared stack path in `@ickb/sdk`: `sdk.completeTransaction(...)` or `completeIckbTransaction(...)`. +3. Only use lower-level manual completion when the caller intentionally owns UDT completion, CCC-native fee/capacity completion, and the DAO output-limit check itself. ## Epoch Semantic Versioning diff --git a/packages/core/src/cells.test.ts b/packages/core/src/cells.test.ts index b96933a..528777d 100644 --- a/packages/core/src/cells.test.ts +++ b/packages/core/src/cells.test.ts @@ -1,63 +1,17 @@ import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, headerLike as testHeaderLike, script } from "@ickb/testkit"; import { describe, expect, it, vi } from "vitest"; import { DaoManager } from "@ickb/dao"; import { receiptCellFrom } from "./cells.js"; import { ReceiptData } from "./entities.js"; import { IckbUdt, ickbValue } from "./udt.js"; -function byte32FromByte(hexByte: string): `0x${string}` { - if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { - throw new Error("Expected exactly one byte as two hex chars"); - } - return `0x${hexByte.repeat(32)}`; -} - -function script(codeHashByte: string): ccc.Script { - return ccc.Script.from({ - codeHash: byte32FromByte(codeHashByte), - hashType: "type", - args: "0x", - }); -} - -function headerLike(ar: bigint): { - compactTarget: bigint; - dao: { - c: bigint; - ar: bigint; - s: bigint; - u: bigint; - }; - epoch: [bigint, bigint, bigint]; - extraHash: `0x${string}`; - hash: `0x${string}`; - nonce: bigint; - number: bigint; - parentHash: `0x${string}`; - proposalsHash: `0x${string}`; - timestamp: bigint; - transactionsRoot: `0x${string}`; - version: bigint; -} { - return { - compactTarget: 0n, - dao: { - c: 0n, - ar, - s: 0n, - u: 0n, - }, +function headerLike(ar: bigint): ccc.ClientBlockHeader { + return testHeaderLike({ + dao: { c: 0n, ar, s: 0n, u: 0n }, epoch: [1n, 0n, 1n], - extraHash: byte32FromByte("aa"), - hash: byte32FromByte("bb"), - nonce: 0n, number: 1n, - parentHash: byte32FromByte("cc"), - proposalsHash: byte32FromByte("dd"), - timestamp: 0n, - transactionsRoot: byte32FromByte("ee"), - version: 0n, - }; + }); } function clientWithHeader(header: ccc.ClientBlockHeader): ccc.Client { diff --git a/packages/core/src/logic.test.ts b/packages/core/src/logic.test.ts index 18a1aba..4f5fe5c 100644 --- a/packages/core/src/logic.test.ts +++ b/packages/core/src/logic.test.ts @@ -1,25 +1,11 @@ import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, script } from "@ickb/testkit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DaoManager } from "@ickb/dao"; import { collect } from "@ickb/utils"; import { ReceiptData } from "./entities.js"; import { LogicManager } from "./logic.js"; -function byte32FromByte(hexByte: string): `0x${string}` { - if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { - throw new Error("Expected exactly one byte as two hex chars"); - } - return `0x${hexByte.repeat(32)}`; -} - -function script(codeHashByte: string): ccc.Script { - return ccc.Script.from({ - codeHash: byte32FromByte(codeHashByte), - hashType: "type", - args: "0x", - }); -} - describe("LogicManager.deposit", () => { afterEach(() => { vi.restoreAllMocks(); @@ -91,6 +77,36 @@ describe("LogicManager.deposit", () => { ); }); + it("rejects non-safe-integer deposit quantities before allocation", async () => { + const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); + + for (const quantity of [1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + await expect( + manager.deposit( + ccc.Transaction.default(), + quantity, + ccc.fixedPointFrom(1082), + script("33"), + {} as ccc.Client, + ), + ).rejects.toThrow("iCKB deposit quantity must be a safe integer"); + } + }); + + it("rejects deposit quantities that cannot fit in one DAO transaction", async () => { + const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); + + await expect( + manager.deposit( + ccc.Transaction.default(), + 64, + ccc.fixedPointFrom(1082), + script("33"), + {} as ccc.Client, + ), + ).rejects.toThrow("iCKB deposit quantity maximum is 63"); + }); + it("filters receipts by exact lock and type while deduplicating locks", async () => { const logic = script("11"); const wantedLock = script("22"); @@ -245,4 +261,46 @@ describe("LogicManager.deposit", () => { secondReceipt.outPoint.txHash, ]); }); + + it("fails closed when receipt scanning exceeds the limit", async () => { + const logic = script("11"); + const wantedLock = script("22"); + const receiptData = ReceiptData.from({ + depositQuantity: 1, + depositAmount: ccc.fixedPointFrom(100000), + }).toBytes(); + const firstReceipt = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: wantedLock, + type: logic, + }, + outputData: receiptData, + }); + const secondReceipt = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: wantedLock, + type: logic, + }, + outputData: receiptData, + }); + let requestedLimit = 0; + const client = { + findCells: async function* (_query: unknown, _order: unknown, limit: number) { + requestedLimit = limit; + await Promise.resolve(); + yield firstReceipt; + yield secondReceipt; + }, + } 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); + }); }); diff --git a/packages/core/src/logic.ts b/packages/core/src/logic.ts index 1f11c4e..501090b 100644 --- a/packages/core/src/logic.ts +++ b/packages/core/src/logic.ts @@ -1,6 +1,7 @@ import { ccc } from "@ckb-ccc/core"; import { assertDaoOutputLimit, DaoManager } from "@ickb/dao"; import { + collectCompleteScan, defaultFindCellsLimit, type ScriptDeps, unique, @@ -13,6 +14,8 @@ import { } from "./cells.js"; import { ReceiptData } from "./entities.js"; +const maxDepositQuantity = 63; + /** * Manages logic related to deposits and receipts in the blockchain. * Implements the ScriptDeps interface. @@ -75,6 +78,14 @@ export class LogicManager implements ScriptDeps { if (depositQuantity <= 0) { return tx; } + if (!Number.isSafeInteger(depositQuantity)) { + throw new Error("iCKB deposit quantity must be a safe integer"); + } + if (depositQuantity > maxDepositQuantity) { + throw new Error( + `iCKB deposit quantity maximum is ${String(maxDepositQuantity)}`, + ); + } const depositCell = ccc.Cell.from({ previousOutput: { @@ -218,19 +229,14 @@ export class LogicManager implements ScriptDeps { withData: true, }, "asc", - limit, ] as const; - const receiptCandidates: ccc.Cell[] = []; - for await (const cell of options?.onChain - ? client.findCellsOnChain(...findCellsArgs) - : client.findCells(...findCellsArgs)) { - if (!this.isReceipt(cell) || !cell.cellOutput.lock.eq(lock)) { - continue; - } - - receiptCandidates.push(cell); - } + const receiptCandidates = (await collectCompleteScan( + (scanLimit) => options?.onChain + ? client.findCellsOnChain(...findCellsArgs, scanLimit) + : client.findCells(...findCellsArgs, scanLimit), + { limit, label: "receipt cell" }, + )).filter((cell) => this.isReceipt(cell) && cell.cellOutput.lock.eq(lock)); const receipts = await Promise.all( receiptCandidates.map((cell) => receiptCellFrom({ client, cell })), diff --git a/packages/core/src/owned_owner.test.ts b/packages/core/src/owned_owner.test.ts index c32cb18..9ed7add 100644 --- a/packages/core/src/owned_owner.test.ts +++ b/packages/core/src/owned_owner.test.ts @@ -1,46 +1,16 @@ import { ccc } from "@ckb-ccc/core"; -import { describe, expect, it, vi } from "vitest"; +import { byte32FromByte, headerLike, script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { collect } from "@ickb/utils"; import { DaoManager } from "@ickb/dao"; import { OwnerData } from "./entities.js"; -import { OwnerCell } from "./cells.js"; +import { OwnerCell, type IckbDepositCell } from "./cells.js"; import { ickbValue } from "./udt.js"; import { OwnedOwnerManager } from "./owned_owner.js"; -function byte32FromByte(hexByte: string): `0x${string}` { - if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { - throw new Error("Expected exactly one byte as two hex chars"); - } - return `0x${hexByte.repeat(32)}`; -} - -function script(codeHashByte: string): ccc.Script { - return ccc.Script.from({ - codeHash: byte32FromByte(codeHashByte), - hashType: "type", - args: "0x", - }); -} - -function headerLike( - overrides: Partial = {}, -): ccc.ClientBlockHeader { - return ccc.ClientBlockHeader.from({ - compactTarget: 0n, - dao: { c: 0n, ar: 1000n, s: 0n, u: 0n }, - epoch: [181n, 0n, 1n], - extraHash: byte32FromByte("aa"), - hash: byte32FromByte("bb"), - nonce: 0n, - number: 3n, - parentHash: byte32FromByte("cc"), - proposalsHash: byte32FromByte("dd"), - timestamp: 0n, - transactionsRoot: byte32FromByte("ee"), - version: 0n, - ...overrides, - }); -} +afterEach(() => { + vi.restoreAllMocks(); +}); describe("OwnedOwnerManager.findWithdrawalGroups", () => { it("decodes owner relative distances from prefixed data", () => { @@ -59,6 +29,50 @@ describe("OwnedOwnerManager.findWithdrawalGroups", () => { expect(ownerCell.getOwned().index).toBe(0n); }); + it("fails closed when owner scanning exceeds the limit", async () => { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const tip = headerLike(); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const firstOwner = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const secondOwner = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("66"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + let requestedLimit = 0; + const client = { + findCells: async function* (_query: unknown, _order: unknown, limit: number) { + requestedLimit = limit; + await Promise.resolve(); + yield firstOwner; + yield secondOwner; + }, + } 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); + }); + it("skips owners whose referenced withdrawal is not locked by Owned Owner", async () => { const ownerLock = script("11"); const ownedOwnerScript = script("22"); @@ -651,3 +665,209 @@ describe("OwnedOwnerManager.findWithdrawalGroups", () => { expect(transactionCalls).toBe(1); }); }); + +describe("OwnedOwnerManager.requestWithdrawal", () => { + it("encodes owner distances from the actual withdrawal output indexes", async () => { + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const ownerLock = script("44"); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const depositHeader = headerLike({ number: 1n }); + const deposits = [ + depositCell("55", ownedOwnerScript, daoScript, depositHeader), + depositCell("66", ownedOwnerScript, daoScript, depositHeader), + ]; + const baseTx = ccc.Transaction.default(); + baseTx.addInput({ previousOutput: { txHash: byte32FromByte("77"), index: 0n } }); + baseTx.addOutput({ capacity: 1n, lock: ownerLock }, "0x"); + + const tx = await manager.requestWithdrawal( + baseTx, + deposits, + ownerLock, + clientForDepositHeader(depositHeader), + ); + + expect(tx.outputsData.slice(3)).toEqual([ + ccc.hexFrom(OwnerData.encode({ ownedDistance: -2n })), + ccc.hexFrom(OwnerData.encode({ ownedDistance: -2n })), + ]); + const ownerAOutput = tx.outputs[3]; + const ownerAData = tx.outputsData[3]; + const ownerBOutput = tx.outputs[4]; + const ownerBData = tx.outputsData[4]; + if (!ownerAOutput || ownerAData === undefined || !ownerBOutput || ownerBData === undefined) { + throw new Error("Expected owner outputs"); + } + const ownerA = new OwnerCell(ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("99"), index: 3n }, + cellOutput: ownerAOutput, + outputData: ownerAData, + })); + const ownerB = new OwnerCell(ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("99"), index: 4n }, + cellOutput: ownerBOutput, + outputData: ownerBData, + })); + expect(ownerA.getOwned().index).toBe(1n); + expect(ownerB.getOwned().index).toBe(2n); + }); + + it("adds required live deposit anchors as cell deps", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const ownerLock = script("44"); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const depositHeader = headerLike({ number: 1n }); + const requestedDeposit = depositCell("55", ownedOwnerScript, daoScript, depositHeader); + const requiredLiveDeposit = depositCell("66", ownedOwnerScript, daoScript, depositHeader); + + const tx = await manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { requiredLiveDeposits: [requiredLiveDeposit] }, + ); + + expect(tx.cellDeps).toContainEqual( + ccc.CellDep.from({ + outPoint: requiredLiveDeposit.cell.outPoint, + depType: "code", + }), + ); + }); + + it("rejects duplicated or already spent withdrawal deposits", async () => { + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const ownerLock = script("44"); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const depositHeader = headerLike({ number: 1n }); + const requestedDeposit = depositCell("55", ownedOwnerScript, daoScript, depositHeader); + const spentTx = ccc.Transaction.default(); + spentTx.addInput(requestedDeposit.cell); + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit, requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + ), + ).rejects.toThrow("Withdrawal deposit is duplicated"); + await expect( + manager.requestWithdrawal( + spentTx, + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + ), + ).rejects.toThrow("Withdrawal deposit is already being spent"); + }); + + it("rejects invalid required live deposit anchors", async () => { + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const ownerLock = script("44"); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const depositHeader = headerLike({ number: 1n }); + const requestedDeposit = depositCell("55", ownedOwnerScript, daoScript, depositHeader); + const requiredLiveDeposit = depositCell("66", ownedOwnerScript, daoScript, depositHeader); + const notReadyLiveDeposit = { + ...requiredLiveDeposit, + isReady: false, + } as IckbDepositCell; + const spentTx = ccc.Transaction.default(); + spentTx.addInput(requiredLiveDeposit.cell); + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { requiredLiveDeposits: [notReadyLiveDeposit] }, + ), + ).rejects.toThrow("Withdrawal live deposit anchor is not ready"); + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { requiredLiveDeposits: [requiredLiveDeposit, requiredLiveDeposit] }, + ), + ).rejects.toThrow("Withdrawal live deposit anchor is duplicated"); + await expect( + manager.requestWithdrawal( + spentTx, + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { requiredLiveDeposits: [requiredLiveDeposit] }, + ), + ).rejects.toThrow("Withdrawal live deposit anchor is also being spent"); + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { requiredLiveDeposits: [requestedDeposit] }, + ), + ).rejects.toThrow("Withdrawal live deposit anchor is also being spent"); + }); +}); + +function depositCell( + txHashByte: string, + lock: ccc.Script, + dao: ccc.Script, + depositHeader: ccc.ClientBlockHeader, +): IckbDepositCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte(txHashByte), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: dao, + }, + outputData: DaoManager.depositData(), + }); + return { + cell, + headers: [{ header: depositHeader }], + ckbValue: cell.cellOutput.capacity, + udtValue: 0n, + isDeposit: true, + isReady: true, + } as unknown as IckbDepositCell; +} + +function clientForDepositHeader(depositHeader: ccc.ClientBlockHeader): ccc.Client { + return { + getTransactionWithHeader: async () => { + await Promise.resolve(); + return { header: depositHeader }; + }, + } as unknown as ccc.Client; +} diff --git a/packages/core/src/owned_owner.ts b/packages/core/src/owned_owner.ts index 54d0fe3..c56fb95 100644 --- a/packages/core/src/owned_owner.ts +++ b/packages/core/src/owned_owner.ts @@ -1,5 +1,6 @@ import { ccc } from "@ckb-ccc/core"; import { + collectCompleteScan, defaultFindCellsLimit, unique, type ScriptDeps, @@ -64,6 +65,7 @@ export class OwnedOwnerManager implements ScriptDeps { * @param lock - The lock script for the output. * @param options - Optional parameters for the withdrawal request. * @param options.isReadyOnly - Whether to only process ready deposits (default: false). + * @param options.requiredLiveDeposits - Live deposit anchors that must remain resolvable while requested deposits are spent. * @returns void * * @remarks Caller must ensure UDT cellDeps are added to the transaction @@ -76,6 +78,7 @@ export class OwnedOwnerManager implements ScriptDeps { client: ccc.Client, options?: { isReadyOnly?: boolean; + requiredLiveDeposits?: IckbDepositCell[]; }, ): Promise { let tx = ccc.Transaction.from(txLike); @@ -86,29 +89,75 @@ export class OwnedOwnerManager implements ScriptDeps { if (deposits.length === 0) { return tx; } - options = { ...options, isReadyOnly: false }; // non isReady deposits already filtered + const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); + const requestedDepositOutPoints = new Set(); + for (const deposit of deposits) { + const outPoint = deposit.cell.outPoint.toHex(); + if (requestedDepositOutPoints.has(outPoint)) { + throw new Error("Withdrawal deposit is duplicated"); + } + requestedDepositOutPoints.add(outPoint); + if (spentOutPoints.has(outPoint)) { + throw new Error("Withdrawal deposit is already being spent"); + } + spentOutPoints.add(outPoint); + } + const requiredLiveDeposits = options?.requiredLiveDeposits ?? []; + const requiredAnchorOutPoints = new Set(); + for (const deposit of requiredLiveDeposits) { + if (!deposit.isReady) { + throw new Error("Withdrawal live deposit anchor is not ready"); + } + const outPoint = deposit.cell.outPoint.toHex(); + if (requiredAnchorOutPoints.has(outPoint)) { + throw new Error("Withdrawal live deposit anchor is duplicated"); + } + requiredAnchorOutPoints.add(outPoint); + if (spentOutPoints.has(outPoint)) { + throw new Error("Withdrawal live deposit anchor is also being spent"); + } + } + const daoOptions = { isReadyOnly: false }; // non isReady deposits already filtered + + const withdrawalOutputStart = tx.outputs.length; tx = await this.daoManager.requestWithdrawal( tx, deposits, this.script, client, - options, + daoOptions, ); + if (tx.outputs.length < withdrawalOutputStart + deposits.length) { + throw new Error("DAO withdrawal request did not add expected outputs"); + } tx.addCellDeps(this.cellDeps); - const outputData = OwnerData.encode({ ownedDistance: -deposits.length }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const _ of deposits) { + for (let index = 0; index < deposits.length; index += 1) { + const withdrawalOutput = tx.outputs[withdrawalOutputStart + index]; + if ( + !withdrawalOutput?.lock.eq(this.script) || + withdrawalOutput.type?.eq(this.daoManager.script) !== true + ) { + throw new Error("DAO withdrawal request output order changed"); + } + + const ownerOutputIndex = tx.outputs.length; tx.addOutput( { lock: lock, type: this.script, }, - outputData, + OwnerData.encode({ + ownedDistance: BigInt(withdrawalOutputStart + index) - BigInt(ownerOutputIndex), + }), ); } + for (const deposit of requiredLiveDeposits) { + tx.addCellDeps({ outPoint: deposit.cell.outPoint, depType: "code" }); + } + await assertDaoOutputLimit(tx, client); return tx; } @@ -223,19 +272,16 @@ export class OwnedOwnerManager implements ScriptDeps { withData: true, }, "asc", - limit, ] as const; - const ownerCandidates: OwnerCell[] = []; - for await (const cell of options?.onChain - ? client.findCellsOnChain(...findCellsArgs) - : client.findCells(...findCellsArgs)) { - if (!this.isOwner(cell) || !cell.cellOutput.lock.eq(lock)) { - continue; - } - - ownerCandidates.push(new OwnerCell(cell)); - } + const ownerCandidates = (await collectCompleteScan( + (scanLimit) => options?.onChain + ? client.findCellsOnChain(...findCellsArgs, scanLimit) + : client.findCells(...findCellsArgs, scanLimit), + { limit, label: "owner cell" }, + )) + .filter((cell) => this.isOwner(cell) && cell.cellOutput.lock.eq(lock)) + .map((cell) => new OwnerCell(cell)); const ownedCells = await Promise.all( ownerCandidates.map((owner) => client.getCell(owner.getOwned())), diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 80da643..84a843d 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -7,4 +7,5 @@ "sourceRoot": "../src" }, "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/dao/src/cells.test.ts b/packages/dao/src/cells.test.ts index e552bf0..959707d 100644 --- a/packages/dao/src/cells.test.ts +++ b/packages/dao/src/cells.test.ts @@ -1,64 +1,18 @@ import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, headerLike as testHeaderLike, script } from "@ickb/testkit"; import { describe, expect, it, vi } from "vitest"; import { DaoManager } from "./dao.js"; -function byte32FromByte(hexByte: string): `0x${string}` { - if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { - throw new Error("Expected exactly one byte as two hex chars"); - } - return `0x${hexByte.repeat(32)}`; -} - -function script(codeHashByte: string): ccc.Script { - return ccc.Script.from({ - codeHash: byte32FromByte(codeHashByte), - hashType: "type", - args: "0x", - }); -} - function headerLike( epoch: [bigint, bigint, bigint], number: bigint, timestamp = 0n, -): { - compactTarget: bigint; - dao: { - c: bigint; - ar: bigint; - s: bigint; - u: bigint; - }; - epoch: [bigint, bigint, bigint]; - extraHash: `0x${string}`; - hash: `0x${string}`; - nonce: bigint; - number: bigint; - parentHash: `0x${string}`; - proposalsHash: `0x${string}`; - timestamp: bigint; - transactionsRoot: `0x${string}`; - version: bigint; -} { - return { - compactTarget: 0n, - dao: { - c: 0n, - ar: 1000n, - s: 0n, - u: 0n, - }, +): ccc.ClientBlockHeader { + return testHeaderLike({ epoch, - extraHash: byte32FromByte("aa"), - hash: byte32FromByte("bb"), - nonce: 0n, number, - parentHash: byte32FromByte("cc"), - proposalsHash: byte32FromByte("dd"), timestamp, - transactionsRoot: byte32FromByte("ee"), - version: 0n, - }; + }); } function withdrawalCell(): ccc.Cell { diff --git a/packages/dao/src/dao.test.ts b/packages/dao/src/dao.test.ts index ef23598..feb9457 100644 --- a/packages/dao/src/dao.test.ts +++ b/packages/dao/src/dao.test.ts @@ -1,7 +1,8 @@ import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, headerLike as testHeaderLike, script } from "@ickb/testkit"; import { describe, expect, it, vi } from "vitest"; import type { DaoDepositCell, DaoWithdrawalRequestCell } from "./cells.js"; -import { DaoManager } from "./dao.js"; +import { DaoManager, DaoOutputLimitError } from "./dao.js"; async function collect(inputs: AsyncIterable): Promise { const result: T[] = []; @@ -11,40 +12,10 @@ async function collect(inputs: AsyncIterable): Promise { return result; } -function byte32FromByte(hexByte: string): `0x${string}` { - if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { - throw new Error("Expected exactly one byte as two hex chars"); - } - return `0x${hexByte.repeat(32)}`; -} - -function script(codeHashByte: string, args = "0x"): ccc.Script { - return ccc.Script.from({ - codeHash: byte32FromByte(codeHashByte), - hashType: "type", - args, - }); -} - function headerLike(number: bigint): ccc.ClientBlockHeader { - return ccc.ClientBlockHeader.from({ - compactTarget: 0n, - dao: { - c: 0n, - ar: 1000n, - s: 0n, - u: 0n, - }, + return testHeaderLike({ epoch: [1n, 0n, 1n], - extraHash: byte32FromByte("aa"), - hash: byte32FromByte("bb"), - nonce: 0n, number, - parentHash: byte32FromByte("cc"), - proposalsHash: byte32FromByte("dd"), - timestamp: 0n, - transactionsRoot: byte32FromByte("ee"), - version: 0n, }); } @@ -99,6 +70,21 @@ function depositCell( } describe("DaoManager.requestWithdrawal", () => { + it("throws a typed DAO output-limit error", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(true); + + const manager = new DaoManager(script("11"), []); + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [depositCell(manager)], + script("44"), + {} as ccc.Client, + ), + ).rejects.toBeInstanceOf(DaoOutputLimitError); + }); + it("always rejects withdrawal locks with different args size", async () => { vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); @@ -215,6 +201,43 @@ describe("DaoManager cell decoding ownership", () => { }); describe("DaoManager.findDeposits", () => { + it("fails closed when deposit scanning exceeds the limit", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const firstDeposit = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("33"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: manager.script, + }, + outputData: DaoManager.depositData(), + }); + const secondDeposit = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: manager.script, + }, + outputData: DaoManager.depositData(), + }); + let requestedLimit = 0; + const client = { + findCells: async function* (_query: unknown, _order: unknown, limit: number) { + requestedLimit = limit; + await Promise.resolve(); + yield firstDeposit; + yield secondDeposit; + }, + } 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); + }); + it("decodes deposits concurrently and yields scan order", async () => { const manager = new DaoManager(script("11"), []); const lock = script("22"); @@ -326,6 +349,43 @@ describe("DaoManager.findDeposits", () => { }); describe("DaoManager.findWithdrawalRequests", () => { + it("fails closed when withdrawal request scanning exceeds the limit", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const firstWithdrawal = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + const secondWithdrawal = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("66"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + let requestedLimit = 0; + const client = { + findCells: async function* (_query: unknown, _order: unknown, limit: number) { + requestedLimit = limit; + await Promise.resolve(); + yield firstWithdrawal; + yield secondWithdrawal; + }, + } 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); + }); + it("decodes withdrawals concurrently and yields scan order", async () => { const manager = new DaoManager(script("11"), []); const lock = script("22"); diff --git a/packages/dao/src/dao.ts b/packages/dao/src/dao.ts index 7caba38..23521de 100644 --- a/packages/dao/src/dao.ts +++ b/packages/dao/src/dao.ts @@ -1,5 +1,6 @@ import { ccc, mol } from "@ckb-ccc/core"; import { + collectCompleteScan, defaultFindCellsLimit, unique, type ScriptDeps, @@ -17,15 +18,22 @@ type DaoCellFromOptions = { maxLockUp?: ccc.Epoch; } & DaoCellFromCache; +export class DaoOutputLimitError extends Error { + constructor(outputCount: number) { + super( + `NervosDAO transaction has ${String(outputCount)} output cells, exceeding the limit of 64`, + ); + this.name = "DaoOutputLimitError"; + } +} + export async function assertDaoOutputLimit( txLike: ccc.TransactionLike, client: ccc.Client, ): Promise { const tx = ccc.Transaction.from(txLike); if (await ccc.isDaoOutputLimitExceeded(tx, client)) { - throw new Error( - `NervosDAO transaction has ${String(tx.outputs.length)} output cells, exceeding the limit of 64`, - ); + throw new DaoOutputLimitError(tx.outputs.length); } } @@ -369,19 +377,14 @@ export class DaoManager implements ScriptDeps { withData: true, }, "asc", - limit, ] as const; - const depositCandidates: ccc.Cell[] = []; - for await (const cell of options?.onChain - ? client.findCellsOnChain(...findCellsArgs) - : client.findCells(...findCellsArgs)) { - if (!this.isDeposit(cell) || !cell.cellOutput.lock.eq(lock)) { - continue; - } - - depositCandidates.push(cell); - } + const depositCandidates = (await collectCompleteScan( + (scanLimit) => options?.onChain + ? client.findCellsOnChain(...findCellsArgs, scanLimit) + : client.findCells(...findCellsArgs, scanLimit), + { limit, label: "DAO deposit cell" }, + )).filter((cell) => this.isDeposit(cell) && cell.cellOutput.lock.eq(lock)); const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); const deposits = await Promise.all( @@ -459,19 +462,14 @@ export class DaoManager implements ScriptDeps { withData: true, }, "asc", - limit, ] as const; - const withdrawalCandidates: ccc.Cell[] = []; - for await (const cell of options?.onChain - ? client.findCellsOnChain(...findCellsArgs) - : client.findCells(...findCellsArgs)) { - if (!this.isWithdrawalRequest(cell) || !cell.cellOutput.lock.eq(lock)) { - continue; - } - - withdrawalCandidates.push(cell); - } + const withdrawalCandidates = (await collectCompleteScan( + (scanLimit) => options?.onChain + ? client.findCellsOnChain(...findCellsArgs, scanLimit) + : client.findCells(...findCellsArgs, scanLimit), + { limit, label: "DAO withdrawal request cell" }, + )).filter((cell) => this.isWithdrawalRequest(cell) && cell.cellOutput.lock.eq(lock)); const headerCache: DaoCellFromCache["headerCache"] = new Map(); const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); diff --git a/packages/dao/src/deposit_readiness.test.ts b/packages/dao/src/deposit_readiness.test.ts index 25fb6a9..ad5d095 100644 --- a/packages/dao/src/deposit_readiness.test.ts +++ b/packages/dao/src/deposit_readiness.test.ts @@ -1,41 +1,8 @@ import { ccc } from "@ckb-ccc/core"; +import { headerLike, hash, script } from "@ickb/testkit"; import { describe, expect, it } from "vitest"; import { DaoManager } from "./dao.js"; -function hash(byte: string): `0x${string}` { - return `0x${byte.repeat(32)}`; -} - -function script(byte: string): ccc.Script { - return ccc.Script.from({ - codeHash: hash(byte), - hashType: "type", - args: "0x", - }); -} - -function headerLike(epoch: [bigint, bigint, bigint], number: bigint): ccc.ClientBlockHeader { - return ccc.ClientBlockHeader.from({ - compactTarget: 0n, - dao: { - c: 0n, - ar: 1000n, - s: 0n, - u: 0n, - }, - epoch, - extraHash: hash("aa"), - hash: hash("bb"), - nonce: 0n, - number, - parentHash: hash("cc"), - proposalsHash: hash("dd"), - timestamp: 0n, - transactionsRoot: hash("ee"), - version: 0n, - }); -} - function depositCell(lock: ccc.Script, dao: ccc.Script): ccc.Cell { return ccc.Cell.from({ outPoint: { txHash: hash("11"), index: 0n }, @@ -59,8 +26,8 @@ describe("daoCellFrom deposit readiness boundaries", () => { const lock = script("22"); const dao = script("33"); const manager = new DaoManager(dao, []); - const depositHeader = headerLike([1n, 0n, 1n], 1n); - const tip = headerLike([180n, 23n, 24n], 2n); + const depositHeader = headerLike({ epoch: [1n, 0n, 1n], number: 1n }); + const tip = headerLike({ epoch: [180n, 23n, 24n], number: 2n }); const daoCell = await manager.depositCellFrom( depositCell(lock, dao), @@ -80,8 +47,8 @@ describe("daoCellFrom deposit readiness boundaries", () => { const lock = script("22"); const dao = script("33"); const manager = new DaoManager(dao, []); - const depositHeader = headerLike([1n, 0n, 1n], 1n); - const tip = headerLike([163n, 0n, 1n], 2n); + const depositHeader = headerLike({ epoch: [1n, 0n, 1n], number: 1n }); + const tip = headerLike({ epoch: [163n, 0n, 1n], number: 2n }); const daoCell = await manager.depositCellFrom( depositCell(lock, dao), diff --git a/packages/dao/tsconfig.json b/packages/dao/tsconfig.json index 80da643..84a843d 100644 --- a/packages/dao/tsconfig.json +++ b/packages/dao/tsconfig.json @@ -7,4 +7,5 @@ "sourceRoot": "../src" }, "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/order/README.md b/packages/order/README.md index 7f1bdbf..6612bde 100644 --- a/packages/order/README.md +++ b/packages/order/README.md @@ -21,9 +21,9 @@ graph TD; If a caller will send the returned transaction, it still must: -1. Finish iCKB UDT completion. -2. Finish CCC-native CKB capacity and fee completion. -3. Check `ccc.isDaoOutputLimitExceeded(...)` before send. +1. Complete the transaction before send. +2. Prefer the shared stack path in `@ickb/sdk`: `sdk.completeTransaction(...)` or `completeIckbTransaction(...)`. +3. Only use lower-level manual completion when the caller intentionally owns UDT completion, CCC-native fee/capacity completion, and the DAO output-limit check itself. ## Limit Order Confusion Boundary diff --git a/packages/order/src/entities.ts b/packages/order/src/entities.ts index e09b237..2120a17 100644 --- a/packages/order/src/entities.ts +++ b/packages/order/src/entities.ts @@ -1,6 +1,8 @@ import { ccc, mol } from "@ckb-ccc/core"; import { CheckedInt32LE, compareBigInt, type ExchangeRatio } from "@ickb/utils"; +const maxUint64 = (1n << 64n) - 1n; + /** * Represents a ratio of two scales, CKB and UDT, with validation and comparison methods. * @@ -151,6 +153,9 @@ export class Ratio extends ccc.Entity.Base() { if (fee === 0n) { return this; } + if (!this.isPopulated()) { + throw new Error("Invalid ExchangeRatio"); + } // Extract scaling factors from the current Ratio. let { ckbScale: aScale, udtScale: bScale } = this; @@ -169,12 +174,8 @@ export class Ratio extends ccc.Entity.Base() { aScale /= g; bScale /= g; - // Prevent potential overflow by ensuring the bit length stays within 64 bits. - const maxBitLen = Math.max(aScale.toString(2).length, bScale.toString(2).length); - if (maxBitLen > 64) { - const shift = BigInt(maxBitLen - 64); - aScale >>= shift; - bScale >>= shift; + if (aScale > maxUint64 || bScale > maxUint64) { + throw new Error("Ratio scale exceeds Uint64"); } // Rebuild and return the adjusted ratio based on the conversion direction. diff --git a/packages/order/src/index.test.ts b/packages/order/src/index.test.ts new file mode 100644 index 0000000..1f6ef65 --- /dev/null +++ b/packages/order/src/index.test.ts @@ -0,0 +1,15 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { OrderGroupSkipReason } from "./index.js"; + +describe("package root exports", () => { + it("exports the skipped order group callback reason", () => { + expectTypeOf().toEqualTypeOf< + | "missing-master" + | "missing-origin" + | "ambiguous-origin" + | "missing-order" + | "ambiguous-order" + | "invalid-group" + >(); + }); +}); diff --git a/packages/order/src/index.ts b/packages/order/src/index.ts index 73dfbda..b5f5862 100644 --- a/packages/order/src/index.ts +++ b/packages/order/src/index.ts @@ -11,4 +11,4 @@ export { type OrderDataLike, type RelativeLike, } from "./entities.js"; -export { OrderManager, type Match } from "./order.js"; +export { OrderManager, type Match, type OrderGroupSkipReason } from "./order.js"; diff --git a/packages/order/src/order.test.ts b/packages/order/src/order.test.ts index 767ba00..c1c7044 100644 --- a/packages/order/src/order.test.ts +++ b/packages/order/src/order.test.ts @@ -1,9 +1,10 @@ import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, script } from "@ickb/testkit"; import { defaultFindCellsLimit } from "@ickb/utils"; import { describe, expect, it } from "vitest"; import { OrderCell } from "./cells.js"; import { Info, OrderData, Ratio, Relative } from "./entities.js"; -import { OrderManager, OrderMatcher } from "./order.js"; +import { OrderManager, OrderMatcher, type Match, type OrderGroupSkipReason } from "./order.js"; describe("Ratio", () => { it("compares ratios exactly beyond Number precision", () => { @@ -17,6 +18,28 @@ describe("Ratio", () => { expect(larger.compare(smaller)).toBe(1); expect(smaller.compare(larger)).toBe(-1); }); + + it("rejects nonzero fee application to empty ratios", () => { + expect(() => Ratio.empty().applyFee(true, 1n, 2n)).toThrow( + "Invalid ExchangeRatio", + ); + }); + + it("rejects fee-adjusted ratios that do not fit Uint64 exactly", () => { + expect(() => + Ratio.from({ ckbScale: (2n ** 64n) + 1n, udtScale: 1n }).applyFee(true, 1n, 2n) + ).toThrow("Ratio scale exceeds Uint64"); + expect(() => + Ratio.from({ ckbScale: 2n ** 65n, udtScale: 1n }).applyFee(true, 1n, 2n) + ).toThrow("Ratio scale exceeds Uint64"); + }); + + it("keeps exactly representable fee-adjusted ratios", () => { + expect( + Ratio.from({ ckbScale: (1n << 64n) - 1n, udtScale: 1n }) + .applyFee(true, 1n, 2n), + ).toEqual(Ratio.from({ ckbScale: (1n << 64n) - 1n, udtScale: 2n })); + }); }); describe("OrderMatcher", () => { @@ -204,6 +227,113 @@ describe("OrderMatcher", () => { }); }); + it("does not use the same order cell in both match directions", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: ccc.fixedPointFrom(50), + info: dualInfo(), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("44"), + index: 0n, + }, + }); + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: ccc.fixedPointFrom(50), + udtValue: ccc.fixedPointFrom(50), + }, + { + ckbScale: 2n, + udtScale: 1n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + + expect(match.partials.map((partial) => partial.order.cell.outPoint.toHex())).toEqual([ + order.cell.outPoint.toHex(), + ]); + }); + + it("rejects invalid best-match search parameters", () => { + const order = makeUdtToCkbOrder(); + const allowance = { + ckbValue: ccc.fixedPointFrom(50), + udtValue: ccc.fixedPointFrom(50), + }; + + expect(() => + OrderManager.bestMatch( + [order], + allowance, + { ckbScale: 0n, udtScale: 1n }, + { ckbAllowanceStep: ccc.fixedPointFrom(1) }, + ) + ).toThrow("Exchange rate scales must be positive"); + expect(() => + OrderManager.bestMatch( + [order], + allowance, + { ckbScale: 1n, udtScale: 0n }, + { ckbAllowanceStep: ccc.fixedPointFrom(1) }, + ) + ).toThrow("Exchange rate scales must be positive"); + expect(() => + OrderManager.bestMatch( + [order], + allowance, + { ckbScale: 1n, udtScale: 1n }, + { ckbAllowanceStep: 0n }, + ) + ).toThrow("CKB allowance step must be positive"); + }); + + it("uses the largest order size when estimating per-partial mining fees", () => { + const smallOrder = makeUdtToCkbOrder({ + txHashByte: "40", + orderTxHashByte: "50", + }); + const largeOrder = makeUdtToCkbOrder({ + txHashByte: "41", + orderTxHashByte: "51", + lockArgs: `0x${"00".repeat(100)}`, + }); + const allowance = { + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 0n, + }; + const exchangeRate = { ckbScale: 3n, udtScale: 5n }; + const options = { + feeRate: 1000n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }; + + expect(largeOrder.cell.occupiedSize).toBeGreaterThan(smallOrder.cell.occupiedSize); + + expect(matchKey(OrderManager.bestMatch( + [smallOrder, largeOrder], + allowance, + exchangeRate, + options, + ))).toEqual(matchKey(exhaustiveSequentialBestMatch( + [smallOrder, largeOrder], + allowance, + exchangeRate, + options, + ))); + }); + it("rejects UDT-to-CKB partials below the converted CKB minimum", () => { const order = makeUdtToCkbOrder(); const matcher = OrderMatcher.from(order, false, 0n); @@ -216,6 +346,144 @@ describe("OrderMatcher", () => { expect(atMinimum?.partials[0]?.ckbOut).toBe(ccc.fixedPointFrom(200) + 3n); expect(atMinimum?.partials[0]?.udtOut).toBe(ccc.fixedPointFrom(100) - 7n); }); + + it("allows full consumption when the remaining CKB match is below the default minimum", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(50), + udtValue: ccc.fixedPointFrom(50), + info: Info.create(false, { ckbScale: 1n, udtScale: 1n }), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("45"), + index: 0n, + }, + }); + const matcher = OrderMatcher.from(order, false, 0n); + + expect(matcher).toBeDefined(); + if (!matcher) { + throw new Error("Expected order to be matchable"); + } + expect(matcher.bMaxMatch).toBeLessThan(1n << 33n); + expect(matcher.bMinMatch).toBe(matcher.bMaxMatch); + + const match = matcher.match(matcher.bMaxMatch); + + expect(match.partials).toHaveLength(1); + expect(match.partials[0]?.ckbOut).toBe(matcher.bMaxOut); + }); + + it("continues trying larger allowances after an allowance below the minimum", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(200), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("46"), + index: 0n, + }, + }); + const matcher = OrderMatcher.from(order, true, 0n); + + expect(matcher).toBeDefined(); + if (!matcher) { + throw new Error("Expected order to be matchable"); + } + expect(ccc.fixedPointFrom(50)).toBeLessThan(matcher.bMinMatch); + + const matches = Array.from( + OrderManager.sequentialMatcher( + [order], + true, + ccc.fixedPointFrom(50), + 0n, + ), + ); + + expect(matches.some((match) => match.partials.length === 1)).toBe(true); + }); + + it("matches an exhaustive cross-product on a bounded pool", () => { + const orders = [ + makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(90), + udtValue: ccc.fixedPointFrom(40), + info: dualInfo(), + master: { + type: "absolute", + value: { txHash: byte32FromByte("33"), index: 1n }, + }, + outPoint: { txHash: byte32FromByte("47"), index: 0n }, + }), + makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(60), + udtValue: ccc.fixedPointFrom(80), + info: dualInfo(), + master: { + type: "absolute", + value: { txHash: byte32FromByte("34"), index: 1n }, + }, + outPoint: { txHash: byte32FromByte("48"), index: 0n }, + }), + makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(30), + udtValue: ccc.fixedPointFrom(120), + info: dualInfo(), + master: { + type: "absolute", + value: { txHash: byte32FromByte("35"), index: 1n }, + }, + outPoint: { txHash: byte32FromByte("49"), index: 0n }, + }), + ]; + const allowance = { + ckbValue: ccc.fixedPointFrom(160), + udtValue: ccc.fixedPointFrom(120), + }; + const exchangeRate = { ckbScale: 1n, udtScale: 1n }; + const options = { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(50), + maxPartials: 3, + }; + + expect(matchKey(OrderManager.bestMatch(orders, allowance, exchangeRate, options))) + .toEqual(matchKey(exhaustiveSequentialBestMatch( + orders, + allowance, + exchangeRate, + options, + ))); + }); +}); + +describe("OrderManager.addMatch", () => { + it("rejects duplicate partials for the same order cell", () => { + const manager = new OrderManager(script("11"), [], script("22")); + const order = makeUdtToCkbOrder(); + const partial = { order, ckbOut: order.ckbValue, udtOut: order.udtValue }; + + expect(() => + manager.addMatch(ccc.Transaction.default(), { + ckbDelta: 0n, + udtDelta: 0n, + partials: [partial, partial], + }) + ).toThrow("Match contains duplicate order cells"); + }); }); describe("OrderCell.resolve", () => { @@ -747,12 +1015,16 @@ describe("OrderManager.findOrders", () => { }, } as unknown as ccc.Client; + const skippedReasons: OrderGroupSkipReason[] = []; const groups = []; - for await (const group of manager.findOrders(client)) { + for await (const group of manager.findOrders(client, { + onSkippedGroup: (reason) => skippedReasons.push(reason), + })) { groups.push(group); } expect(groups).toHaveLength(0); + expect(skippedReasons).toEqual(["missing-origin"]); }); it("findOrigin fails closed for multiple minted origins in the master transaction", async () => { @@ -838,12 +1110,16 @@ describe("OrderManager.findOrders", () => { expect(firstOrigin.getMaster().eq(originMaster)).toBe(true); expect(secondOrigin.getMaster().eq(originMaster)).toBe(true); + const skippedReasons: OrderGroupSkipReason[] = []; const groups = []; - for await (const group of manager.findOrders(client)) { + for await (const group of manager.findOrders(client, { + onSkippedGroup: (reason) => skippedReasons.push(reason), + })) { groups.push(group); } expect(groups).toHaveLength(0); + expect(skippedReasons).toEqual(["ambiguous-origin"]); }); it("uses live queries by default", async () => { @@ -1203,12 +1479,16 @@ describe("OrderManager.findOrders", () => { }, } as unknown as ccc.Client; + const skippedReasons: OrderGroupSkipReason[] = []; const groups = []; - for await (const group of manager.findOrders(client)) { + for await (const group of manager.findOrders(client, { + onSkippedGroup: (reason) => skippedReasons.push(reason), + })) { groups.push(group); } expect(groups).toHaveLength(0); + expect(skippedReasons).toEqual(["ambiguous-order"]); }); it("uses live queries when onChain is requested", async () => { @@ -1296,11 +1576,12 @@ function makeUdtToCkbOrder(options?: { txHashByte?: string; orderTxHashByte?: string; udtValue?: ccc.FixedPoint; + lockArgs?: ccc.Hex; }): OrderCell { const orderScript = ccc.Script.from({ codeHash: byte32FromByte("11"), hashType: "type", - args: "0x", + args: options?.lockArgs ?? "0x", }); const udtScript = ccc.Script.from({ codeHash: byte32FromByte("22"), @@ -1358,6 +1639,86 @@ function dualInfo(): Info { }); } +function exhaustiveSequentialBestMatch( + orderPool: OrderCell[], + allowance: { ckbValue: bigint; udtValue: bigint }, + exchangeRate: { ckbScale: bigint; udtScale: bigint }, + options: { + feeRate: bigint; + ckbAllowanceStep: bigint; + maxPartials?: number; + }, +): Match { + const orderSize = orderPool.reduce( + (maxSize, order) => Math.max(maxSize, order.cell.occupiedSize), + 0, + ); + const ckbMiningFee = (ccc.numFrom(36 + orderSize) * options.feeRate + 999n) / 1000n; + const udtAllowanceStep = ( + options.ckbAllowanceStep * exchangeRate.ckbScale + exchangeRate.udtScale - 1n + ) / exchangeRate.udtScale; + let best: Match | undefined; + let bestGain = -1n << 256n; + for (const c2u of OrderManager.sequentialMatcher( + orderPool, + true, + options.ckbAllowanceStep, + ckbMiningFee, + )) { + for (const u2c of OrderManager.sequentialMatcher( + orderPool, + false, + udtAllowanceStep, + ckbMiningFee, + )) { + const partials = c2u.partials.concat(u2c.partials); + if (options.maxPartials !== undefined && partials.length > options.maxPartials) { + continue; + } + if (!hasUniquePartialOrderOutPoints(partials)) { + continue; + } + + const ckbDelta = c2u.ckbDelta + u2c.ckbDelta; + const udtDelta = c2u.udtDelta + u2c.udtDelta; + const ckbFee = ckbMiningFee * BigInt(partials.length); + const ckbAllowance = allowance.ckbValue + ckbDelta - ckbFee; + const udtAllowance = allowance.udtValue + udtDelta; + const gain = (ckbDelta - ckbFee) * exchangeRate.ckbScale + udtDelta * exchangeRate.udtScale; + + if (ckbAllowance >= 0n && udtAllowance >= 0n && gain > bestGain) { + best = { ckbDelta, udtDelta, partials }; + bestGain = gain; + } + } + } + return best ?? { ckbDelta: 0n, udtDelta: 0n, partials: [] }; +} + +function hasUniquePartialOrderOutPoints(partials: Match["partials"]): boolean { + const seen = new Set(); + for (const partial of partials) { + const key = partial.order.cell.outPoint.toHex(); + if (seen.has(key)) { + return false; + } + seen.add(key); + } + return true; +} + +function matchKey(match: Match): unknown { + return { + ckbDelta: match.ckbDelta, + udtDelta: match.udtDelta, + partials: match.partials.map((partial) => ({ + outPoint: partial.order.cell.outPoint.toHex(), + ckbOut: partial.ckbOut, + udtOut: partial.udtOut, + })), + }; +} + async function collectOrders( manager: OrderManager, client: ccc.Client, @@ -1432,10 +1793,3 @@ function makeOrderCell(options: { }), ); } - -function byte32FromByte(hexByte: string): `0x${string}` { - if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { - throw new Error("Expected exactly one byte as two hex chars"); - } - return `0x${hexByte.repeat(32)}`; -} diff --git a/packages/order/src/order.ts b/packages/order/src/order.ts index 56e8356..0c1f839 100644 --- a/packages/order/src/order.ts +++ b/packages/order/src/order.ts @@ -1,6 +1,7 @@ import { ccc } from "@ckb-ccc/core"; import { BufferedGenerator, + collectCompleteScan, compareBigInt, defaultFindCellsLimit, type ExchangeRatio, @@ -227,6 +228,9 @@ export class OrderManager implements ScriptDeps { if (partials.length === 0) { return tx; } + if (!hasUniquePartialOrderOutPoints(partials)) { + throw new Error("Match contains duplicate order cells"); + } tx.addCellDeps(this.cellDeps); @@ -311,7 +315,7 @@ export class OrderManager implements ScriptDeps { maxPartials?: number; }, ): Match { - const orderSize = orderPool[0]?.cell.occupiedSize ?? 0; + const orderSize = maxOrderOccupiedSize(orderPool); if (!orderSize) { return { ckbDelta: 0n, @@ -321,13 +325,21 @@ export class OrderManager implements ScriptDeps { } const { ckbScale, udtScale } = exchangeRate; - // Get fee rate or base fee rate if not provided - const feeRate = options?.feeRate ?? 1000n; - const ckbMiningFee = (ccc.numFrom(36 + orderSize) * feeRate + 999n) / 1000n; + if (ckbScale <= 0n || udtScale <= 0n) { + throw new Error("Exchange rate scales must be positive"); + } // ckbAllowanceStep should be 1000 CKB if not provided const ckbAllowanceStep = options?.ckbAllowanceStep ?? ccc.fixedPointFrom("1000"); + if (ckbAllowanceStep <= 0n) { + throw new Error("CKB allowance step must be positive"); + } + + // Get fee rate or base fee rate if not provided + const feeRate = options?.feeRate ?? 1000n; + const ckbMiningFee = (ccc.numFrom(36 + orderSize) * feeRate + 999n) / 1000n; + const maxPartials = options?.maxPartials; const udtAllowanceStep = (ckbAllowanceStep * ckbScale + udtScale - 1n) / udtScale; @@ -361,6 +373,8 @@ export class OrderManager implements ScriptDeps { udtAllowance: allowance.udtValue, gain: -1n << 256n, }; + // best.i/best.j are offsets into the current two-entry frontiers; (0, 0) + // means the current frontier head is already optimal, so the search stops. while (best.i !== 0 || best.j !== 0) { ckb2UdtMatches.next(best.i); udt2CkbMatches.next(best.j); @@ -375,6 +389,9 @@ export class OrderManager implements ScriptDeps { if (maxPartials !== undefined && partials.length > maxPartials) { continue; } + if (!hasUniquePartialOrderOutPoints(partials)) { + continue; + } const ckbFee = ckbMiningFee * BigInt(partials.length); const ckbAllowance = allowance.ckbValue + ckbDelta - ckbFee; const udtAllowance = allowance.udtValue + udtDelta; @@ -453,7 +470,7 @@ export class OrderManager implements ScriptDeps { yield curr; // Process each matcher in sequence. - loop: for (const matcher of matchers) { + for (const matcher of matchers) { const maxMatch = matcher.bMaxMatch; // Distribute maxMatch into partial matches according to a fair distribution policy: // - Each partial match is of at least of allowanceStep size. @@ -477,9 +494,9 @@ export class OrderManager implements ScriptDeps { // Compute the match using the current allowance. const m = matcher.match(allowance); // If the current allowance is too low to yield any partial matches, - // skip to the next matcher. + // try the next allowance for the same matcher. if (m.partials.length === 0) { - continue loop; + continue; } // Update the cumulative match by aggregating the deltas and partials. curr = { @@ -553,7 +570,11 @@ export class OrderManager implements ScriptDeps { */ async *findOrders( client: ccc.Client, - options?: { onChain?: boolean; limit?: number }, + options?: { + onChain?: boolean; + limit?: number; + onSkippedGroup?: (reason: OrderGroupSkipReason) => void; + }, ): AsyncGenerator { const onChain = options?.onChain ?? true; const limit = options?.limit ?? defaultFindCellsLimit; @@ -583,6 +604,7 @@ export class OrderManager implements ScriptDeps { if (!rawGroup) { // No matching master cell found + options?.onSkippedGroup?.("missing-master"); continue; } @@ -595,22 +617,13 @@ export class OrderManager implements ScriptDeps { continue; } - const origin = await this.findOrigin(client, master.cell.outPoint); - if (!origin) { + const orderGroup = await this.resolveOrderGroup(client, master, orders); + if (!orderGroup.ok) { + options?.onSkippedGroup?.(orderGroup.reason); continue; } - const order = origin.resolve(orders); - if (!order) { - continue; - } - - const orderGroup = OrderGroup.tryFrom(master, order, origin); - if (!orderGroup) { - continue; - } - - yield orderGroup; + yield orderGroup.group; } } @@ -630,8 +643,6 @@ export class OrderManager implements ScriptDeps { onChain: boolean, limit: number, ): Promise { - const orders: OrderCell[] = []; - const scanLimit = limit + 1; const findCellsArgs = [ { script: this.script, @@ -643,14 +654,15 @@ export class OrderManager implements ScriptDeps { withData: true, }, "asc", - scanLimit, ] as const; - let scanned = 0; - for await (const cell of onChain - ? client.findCellsOnChain(...findCellsArgs) - : client.findCells(...findCellsArgs)) { - scanned += 1; + const orders: OrderCell[] = []; + for (const cell of await collectCompleteScan( + (scanLimit) => onChain + ? client.findCellsOnChain(...findCellsArgs, scanLimit) + : client.findCells(...findCellsArgs, scanLimit), + { limit, label: "order cell" }, + )) { const order = OrderCell.tryFrom(cell); if (!order || !this.isOrder(cell)) { // Skip non-order cells or failed conversions @@ -658,7 +670,6 @@ export class OrderManager implements ScriptDeps { } orders.push(order); } - assertCompleteScan(scanned, limit, "order cell"); return orders; } @@ -679,8 +690,6 @@ export class OrderManager implements ScriptDeps { onChain: boolean, limit: number, ): Promise { - const masters: MasterCell[] = []; - const scanLimit = limit + 1; const findCellsArgs = [ { script: this.script, @@ -689,21 +698,21 @@ export class OrderManager implements ScriptDeps { withData: true, }, "asc", - scanLimit, ] as const; - let scanned = 0; - for await (const cell of onChain - ? client.findCellsOnChain(...findCellsArgs) - : client.findCells(...findCellsArgs)) { - scanned += 1; + const masters: MasterCell[] = []; + for (const cell of await collectCompleteScan( + (scanLimit) => onChain + ? client.findCellsOnChain(...findCellsArgs, scanLimit) + : client.findCells(...findCellsArgs, scanLimit), + { limit, label: "master cell" }, + )) { if (!this.isMaster(cell)) { // Skip cells that do not satisfy master criteria continue; } masters.push(new MasterCell(cell)); } - assertCompleteScan(scanned, limit, "master cell"); return masters; } @@ -715,14 +724,43 @@ export class OrderManager implements ScriptDeps { * may already be spent by later matches while still anchoring the order group. * * @param client - The client used to interact with the blockchain. - * @param master - The master out point to find the origin for. + * @param master - The master cell anchoring the order group. + * @param orders - Candidate live descendant orders for this master. * - * @returns A promise that resolves to the originating OrderCell or undefined if not found. + * @returns A promise that resolves to the validated group or a skip reason. */ + private async resolveOrderGroup( + client: ccc.Client, + master: MasterCell, + orders: OrderCell[], + ): Promise { + const origin = await this.findOrigin(client, master.cell.outPoint); + if (!origin.ok) { + return origin; + } + + const order = origin.origin.resolve(orders); + if (!order) { + return { + ok: false, + reason: orders.some((candidate) => origin.origin.isValid(candidate)) + ? "ambiguous-order" + : "missing-order", + }; + } + + const group = OrderGroup.tryFrom(master, order, origin.origin); + if (!group) { + return { ok: false, reason: "invalid-group" }; + } + + return { ok: true, group }; + } + private async findOrigin( client: ccc.Client, master: ccc.OutPoint, - ): Promise { + ): Promise { const { txHash, index: mIndex } = master; let res = await client.cache.getTransactionResponse(txHash); if (!res) { @@ -732,7 +770,7 @@ export class OrderManager implements ScriptDeps { } } if (!res) { - return; + return { ok: false, reason: "missing-origin" }; } let origin: OrderCell | undefined; @@ -758,21 +796,55 @@ export class OrderManager implements ScriptDeps { order.getMaster().eq(master) ) { if (origin) { - return; + return { ok: false, reason: "ambiguous-origin" }; } origin = order; } } - return origin; + return origin + ? { ok: true, origin } + : { ok: false, reason: "missing-origin" }; } } -function assertCompleteScan(scanned: number, limit: number, label: string): void { - if (scanned <= limit) { - return; +type FindOriginResult = + | { ok: true; origin: OrderCell } + | { ok: false; reason: "missing-origin" | "ambiguous-origin" }; + +export type OrderGroupSkipReason = + | "missing-master" + | "missing-origin" + | "ambiguous-origin" + | "missing-order" + | "ambiguous-order" + | "invalid-group"; + +type ResolveOrderGroupResult = + | { ok: true; group: OrderGroup } + | { + ok: false; + reason: Exclude; + }; + +function hasUniquePartialOrderOutPoints(partials: Match["partials"]): boolean { + const outPoints = new Set(); + for (const partial of partials) { + const key = partial.order.cell.outPoint.toHex(); + if (outPoints.has(key)) { + return false; + } + outPoints.add(key); } - throw new Error(`${label} scan reached limit ${String(limit)}; state may be incomplete`); + return true; +} + +function maxOrderOccupiedSize(orderPool: OrderCell[]): number { + let maxSize = 0; + for (const order of orderPool) { + maxSize = Math.max(maxSize, order.cell.occupiedSize); + } + return maxSize; } /** diff --git a/packages/order/tsconfig.json b/packages/order/tsconfig.json index 80da643..84a843d 100644 --- a/packages/order/tsconfig.json +++ b/packages/order/tsconfig.json @@ -7,4 +7,5 @@ "sourceRoot": "../src" }, "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/utils/src/utils.test.ts b/packages/utils/src/utils.test.ts index 5b88349..4178328 100644 --- a/packages/utils/src/utils.test.ts +++ b/packages/utils/src/utils.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { BufferedGenerator, compareBigInt, selectBoundedUdtSubset } from "./utils.js"; +import { ccc } from "@ckb-ccc/core"; +import { + BufferedGenerator, + assertCompleteScan, + compareBigInt, + collectCompleteScan, + scanLimit, + selectBoundedUdtSubset, +} from "./utils.js"; describe("compareBigInt", () => { it("orders bigint values", () => { @@ -29,6 +37,76 @@ 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", + ); + }); + + 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); + yield 1; + yield 2; + await Promise.resolve(); + }, + { limit: 2, label: "account" }, + )).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", + ); + }); +}); + describe("selectBoundedUdtSubset", () => { it("finds an exact-count subset when the greedy path fails", () => { const deposits = [{ udtValue: 6n }, { udtValue: 5n }, { udtValue: 5n }]; diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index 4163f06..b1ef172 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -13,6 +13,44 @@ import { ccc } from "@ckb-ccc/core"; */ export const defaultFindCellsLimit = 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, + options: { + limit: number; + label: string; + context?: ccc.Script | string; + }, +): Promise { + const results: T[] = []; + for await (const item of scan(scanLimit(options.limit))) { + results.push(item); + } + assertCompleteScan(results.length, options.limit, options.label, options.context); + return results; +} + /** * Represents a transaction header that includes a block header and an optional transaction hash. */ diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 80da643..84a843d 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -7,4 +7,5 @@ "sourceRoot": "../src" }, "include": ["src"], + "exclude": ["src/**/*.test.ts"] } From 15e6174f49d730eac64f043846646be2ecbf9870 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 00:05:03 +0000 Subject: [PATCH 03/14] fix(sdk): add conversion withdrawal planning --- README.md | 6 + packages/sdk/README.md | 20 + packages/sdk/docs/pool_maturity_estimates.md | 2 + packages/sdk/src/constants.test.ts | 14 +- packages/sdk/src/index.ts | 1 + packages/sdk/src/sdk.test.ts | 1254 ++++++++++++++++- packages/sdk/src/sdk.ts | 799 +++++++++-- packages/sdk/src/withdrawal_selection.test.ts | 306 ++++ packages/sdk/src/withdrawal_selection.ts | 435 ++++++ packages/sdk/tsconfig.json | 1 + 10 files changed, 2691 insertions(+), 147 deletions(-) create mode 100644 packages/sdk/src/withdrawal_selection.test.ts create mode 100644 packages/sdk/src/withdrawal_selection.ts diff --git a/README.md b/README.md index ecf4e20..f6b49c1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ Callers own the final completion pipeline: 2. Before send, call `sdk.completeTransaction(...)` or `completeIckbTransaction(...)` from `@ickb/sdk`. 3. Only then send the transaction. +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 + +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. + ## User Lock Assumption Current stack flows assume user-owned cells are protected by locks whose signatures bind the whole transaction, such as standard `sighash` wallet flows. Passing a raw `ccc.Script` is only safe when that lock gives the same output and recipient binding. Delegated-signature or OTX-style locks are integration-specific and must account for the weak-lock boundary documented in the iCKB whitepaper and contracts audit. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 69a7b69..40d6b58 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -34,6 +34,26 @@ The current runtime path uses direct deposit scans together with bot liquidity a See [docs/pool_maturity_estimates.md](./docs/pool_maturity_estimates.md). +## Ready Withdrawal Selection + +`selectReadyWithdrawalDeposits(...)` exposes the stack's pool-friendly ready-deposit selector for direct iCKB-to-CKB withdrawal requests. Callers provide ready deposits, an optional near-ready refill window, the current tip, and amount/count limits. Setting `minCount` and `maxCount` to the same value requests an exact number of deposits. `preserveSingletons` defaults to `true`, so singleton bucket anchors are protected unless the caller explicitly permits selecting them. The selector prefers crowded-bucket extras before singleton anchors, returns the chosen deposits, and also returns `requiredLiveDeposits` for protected anchors that should be added as live `cell_dep` checks when building the withdrawal request. + +`selectReadyWithdrawalCleanupDeposit(...)` is the narrow cleanup helper used by the bot for over-standard crowded-bucket extras. It returns at most one extra plus the protected anchor that must remain live. Bot thresholds such as target balances, singleton unlock policy, and whether a cleanup is worth doing remain app policy in `apps/bot`. + +`IckbSdk.buildBaseTransaction(...)` accepts `withdrawalRequest.requiredLiveDeposits` and adds those cells as live cell deps. This is an inclusion-time liveness check for public pool anchors, not a reservation of those cells after the transaction commits. + +## Conversion Transaction Builder + +`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. + +The returned transaction is not completed, signed, sent, or confirmed. Callers still explicitly call `sdk.completeTransaction(...)` with their signer/client/fee rate before sending. + +## Small iCKB Order Previews + +`IckbSdk.estimate(...)` returns order `info` even when the normal fee threshold is too small to produce a maturity estimate. Callers that intentionally build tiny iCKB-to-CKB orders can pass an explicit fee/feeBase discount to `estimate(...)`; the resulting order uses the existing order wire format. The limit-order contract can fully complete an order whose remaining match is below the configured minimum, so tiny dust orders do not need a special minimum-match encoding. This is how the interface presents small-balance conversions that may be worthwhile for recovering locked xUDT cell capacity. + ## Send Confirmation `sendAndWaitForCommit(...)` returns the transaction hash after commit. If a transaction was broadcast but later reaches a terminal non-committed status or times out while still pending, it throws `TransactionConfirmationError` with the broadcast `txHash`, last observed `status`, and `isTimeout` flag. Callers that need to log the hash immediately after broadcast can use the `onSent` callback. diff --git a/packages/sdk/docs/pool_maturity_estimates.md b/packages/sdk/docs/pool_maturity_estimates.md index f290116..c857e10 100644 --- a/packages/sdk/docs/pool_maturity_estimates.md +++ b/packages/sdk/docs/pool_maturity_estimates.md @@ -25,6 +25,8 @@ 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. + ## Why The Older Snapshot Path Was Removed The older snapshot idea tried to summarize the full deposit pool without scanning every deposit. diff --git a/packages/sdk/src/constants.test.ts b/packages/sdk/src/constants.test.ts index fb62086..ae20300 100644 --- a/packages/sdk/src/constants.test.ts +++ b/packages/sdk/src/constants.test.ts @@ -1,28 +1,18 @@ import { ccc } from "@ckb-ccc/core"; +import { outPoint, script as typeScript } from "@ickb/testkit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { IckbUdt } from "@ickb/core"; import { getConfig } from "./constants.js"; import { IckbSdk } from "./sdk.js"; -function hash(byte: string): `0x${string}` { - return `0x${byte.repeat(32)}`; -} - function script(byte: string): ccc.Script { return ccc.Script.from({ - codeHash: hash(byte), + codeHash: typeScript(byte).codeHash, hashType: "data1", args: "0x", }); } -function outPoint(byte: string): ccc.OutPoint { - return ccc.OutPoint.from({ - txHash: hash(byte), - index: 0n, - }); -} - afterEach(() => { vi.restoreAllMocks(); }); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 3fe29f8..f1ac58a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,2 +1,3 @@ export * from "./sdk.js"; export * from "./constants.js"; +export * from "./withdrawal_selection.js"; diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 272d700..ae4e43f 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -1,19 +1,34 @@ import { ccc } from "@ckb-ccc/core"; -import { Info, Ratio, type OrderGroup } from "@ickb/order"; +import { + Info, + MasterCell, + OrderCell, + OrderData, + OrderGroup, + Ratio, +} from "@ickb/order"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DaoManager } from "@ickb/dao"; +import { DaoManager, DaoOutputLimitError } from "@ickb/dao"; import { + ICKB_DEPOSIT_CAP, + convert, type IckbDepositCell, LogicManager, + OwnerCell, + OwnerData, OwnedOwnerManager, + ReceiptData, type ReceiptCell, - type WithdrawalGroup, + WithdrawalGroup, } from "@ickb/core"; import { OrderManager } from "@ickb/order"; +import { headerLike as testHeaderLike, hash, script } from "@ickb/testkit"; import { defaultFindCellsLimit } from "@ickb/utils"; import { completeIckbTransaction, + estimateMaturityFeeThreshold, IckbSdk, + MAX_DIRECT_DEPOSITS, projectAccountAvailability, sendAndWaitForCommit, TransactionConfirmationError, @@ -26,37 +41,15 @@ function headerLike( number: bigint, overrides: Partial = {}, ): ccc.ClientBlockHeader { - return ccc.ClientBlockHeader.from({ - compactTarget: 0n, - dao: { c: 0n, ar: 1000n, s: 0n, u: 0n }, + return testHeaderLike({ epoch: [1n, 0n, 1n], - extraHash: hash("aa"), - hash: hash("bb"), - nonce: 0n, number, - parentHash: hash("cc"), - proposalsHash: hash("dd"), - timestamp: 0n, - transactionsRoot: hash("ee"), - version: 0n, ...overrides, }); } const tip = headerLike(0n); -function hash(byte: string): `0x${string}` { - return `0x${byte.repeat(32)}`; -} - -function script(byte: string): ccc.Script { - return ccc.Script.from({ - codeHash: hash(byte), - hashType: "type", - args: "0x", - }); -} - function fakeIckbUdt(udt = script("66")): { isUdt: (cell: ccc.Cell) => boolean; infoFrom: () => Promise; @@ -105,6 +98,62 @@ function orderGroup(options: { } as unknown as OrderGroup; } +function readyDeposit(udtValue: bigint, maturityUnix = 0n): IckbDepositCell { + return { + cell: ccc.Cell.from({ + outPoint: { txHash: hash("aa"), index: maturityUnix }, + cellOutput: { capacity: 0n, lock: script("22") }, + outputData: "0x", + }), + headers: [], + interests: 0n, + isReady: true, + isDeposit: true, + ckbValue: 0n, + udtValue, + maturity: { toUnix: (): bigint => maturityUnix }, + } as unknown as IckbDepositCell; +} + +function transactionWithOutputs(count: number, lock: ccc.Script): ccc.Transaction { + const tx = ccc.Transaction.default(); + for (let index = 0; index < count; index += 1) { + tx.outputs.push(ccc.CellOutput.from({ capacity: 1n, lock })); + tx.outputsData.push("0x"); + } + return tx; +} + +function testSdk(): { + sdk: IckbSdk; + logicManager: LogicManager; + ownedOwnerManager: OwnedOwnerManager; + orderManager: OrderManager; + lock: ccc.Script; +} { + const lock = script("11"); + const logicManager = new LogicManager(script("22"), [], new DaoManager(script("33"), [])); + const ownedOwnerManager = new OwnedOwnerManager( + script("44"), + [], + new DaoManager(script("33"), []), + ); + const orderManager = new OrderManager(script("55"), [], script("66")); + return { + sdk: new IckbSdk( + fakeIckbUdt(), + ownedOwnerManager, + logicManager, + orderManager, + [], + ), + logicManager, + ownedOwnerManager, + orderManager, + lock, + }; +} + function system(overrides: Partial = {}): SystemState { return { feeRate: 1n, @@ -122,6 +171,10 @@ afterEach(() => { }); describe("IckbSdk.estimate", () => { + it("exposes the fee threshold used for maturity previews", () => { + expect(estimateMaturityFeeThreshold(system({ feeRate: 7n }))).toBe(70n); + }); + it("omits maturity below the fee threshold", () => { const result = IckbSdk.estimate( false, @@ -164,6 +217,62 @@ describe("IckbSdk.estimate", () => { expect(result.ckbFee).toBe(5n); expect(result.maturity).toBeUndefined(); }); + + it("uses the fee-adjusted CKB output for UDT-to-CKB maturity", () => { + const exchangeRatio = Ratio.from({ + ckbScale: 10000000000000000n, + udtScale: 10008200000000000n, + }); + + const result = IckbSdk.estimate( + false, + { ckbValue: 0n, udtValue: ccc.fixedPointFrom("100000.001") }, + system({ + exchangeRatio, + ckbAvailable: ccc.fixedPointFrom(100082), + }), + ); + + expect(result.convertedAmount).toBeLessThan(ccc.fixedPointFrom(100082)); + expect(result.maturity).toBe(600000n); + }); + + it("builds normal iCKB-to-CKB orders when maturity is unavailable", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1000000n }, + system({ ckbAvailable: 0n }), + ); + + expect(result).toBeDefined(); + expect(result?.maturity).toBeUndefined(); + expect(result?.notice).toEqual({ + kind: "maturity-unavailable", + inputIckb: 1000000n, + outputCkb: 999990n, + incentiveCkb: 10n, + maturityEstimateUnavailable: true, + }); + expect(result?.estimate.info.ckbMinMatchLog).toBe(33); + }); + + it("builds tiny iCKB-to-CKB orders with explicit dust terms", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1n }, + system({ ckbAvailable: 1n, tip: headerLike(0n, { timestamp: 1234n }) }), + ); + + expect(result).toBeDefined(); + expect(result?.maturity).toBe(601234n); + expect(result?.notice).toEqual({ + kind: "dust-ickb-to-ckb", + inputIckb: 1n, + outputCkb: 1n, + incentiveCkb: 0n, + maturityEstimateUnavailable: false, + }); + expect(result?.estimate.info.ckbMinMatchLog).toBe(33); + }); + }); describe("IckbSdk.maturity", () => { @@ -222,6 +331,31 @@ describe("IckbSdk.maturity", () => { ), ).toBe(2000n); }); + + it("counts existing CKB in UDT-to-CKB orders before requiring pool liquidity", () => { + const info = Info.create(false, { + ckbScale: 9n, + udtScale: 10n, + }); + + expect( + IckbSdk.maturity( + { + info, + amounts: { + ckbValue: 7n, + udtValue: 10n, + }, + }, + system({ + ckbMaturing: [ + { ckbCumulative: 5n, maturity: 1000n }, + { ckbCumulative: 6n, maturity: 2000n }, + ], + }), + ), + ).toBe(1000n); + }); }); describe("projectAccountAvailability", () => { @@ -248,6 +382,7 @@ describe("projectAccountAvailability", () => { const projection = projectAccountAvailability( { capacityCells: [{ cellOutput: { capacity: 3n } } as ccc.Cell], + nativeUdtCells: [], nativeUdtCapacity: 5n, nativeUdtBalance: 7n, receipts: [{ ckbValue: 41n, udtValue: 43n } as ReceiptCell], @@ -286,6 +421,7 @@ describe("projectAccountAvailability", () => { const projection = projectAccountAvailability( { capacityCells: [], + nativeUdtCells: [], nativeUdtCapacity: 0n, nativeUdtBalance: 0n, receipts: [], @@ -311,6 +447,7 @@ describe("projectAccountAvailability", () => { const projection = projectAccountAvailability( { capacityCells: [], + nativeUdtCells: [], nativeUdtCapacity: 0n, nativeUdtBalance: 0n, receipts: [], @@ -338,6 +475,7 @@ describe("projectAccountAvailability", () => { const projection = projectAccountAvailability( { capacityCells: [], + nativeUdtCells: [], nativeUdtCapacity: 0n, nativeUdtBalance: 0n, receipts: [], @@ -359,6 +497,7 @@ describe("projectAccountAvailability", () => { const projection = projectAccountAvailability( { capacityCells: [{ cellOutput: { capacity: 3n } } as ccc.Cell], + nativeUdtCells: [], nativeUdtCapacity: 5n, nativeUdtBalance: 7n, receipts: [], @@ -376,6 +515,7 @@ describe("projectAccountAvailability", () => { const projection = projectAccountAvailability( { capacityCells: [], + nativeUdtCells: [], nativeUdtCapacity: 0n, nativeUdtBalance: 7n, receipts: [], @@ -415,23 +555,25 @@ describe("IckbSdk.buildBaseTransaction", () => { [botLock], ); const steps: string[] = []; - const requestedDeposit = { - udtValue: 10n, - } as IckbDepositCell; + const requestedDeposit = depositCell("80", logic, dao, tip, tip, { + isReady: true, + }); const requiredLiveDeposit = { cell: ccc.Cell.from({ outPoint: { txHash: hash("90"), index: 0n }, cellOutput: { capacity: 1n, lock: logic }, outputData: "0x", }), + isReady: true, } as IckbDepositCell; vi.spyOn(ownedOwnerManager, "requestWithdrawal").mockImplementation( - async (txLike, deposits, lock) => { + async (txLike, deposits, lock, _client, requestOptions) => { await Promise.resolve(); steps.push("request"); expect(deposits).toEqual([requestedDeposit]); expect(lock).toEqual(botLock); + expect(requestOptions).toEqual({ requiredLiveDeposits: [requiredLiveDeposit] }); const tx = ccc.Transaction.from(txLike); expect(tx.inputs).toHaveLength(0); expect(tx.outputs).toHaveLength(0); @@ -515,12 +657,101 @@ describe("IckbSdk.buildBaseTransaction", () => { expect(tx.inputs).toHaveLength(4); expect(tx.outputs).toHaveLength(1); expect(tx.outputsData).toEqual(["0x"]); + }); + + it("combines real manager transaction effects", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + 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 daoDep = dep("d1"); + const ownedDep = dep("d2"); + const logicDep = dep("d3"); + const orderDep = dep("d4"); + const daoManager = new DaoManager(dao, [daoDep]); + const logicManager = new LogicManager(logic, [logicDep], daoManager); + const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [ownedDep], daoManager); + const orderManager = new OrderManager(order, [orderDep], udt); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + orderManager, + [botLock], + ); + const depositHeader = headerLike(10n, { hash: hash("a1") }); + const receiptHeader = headerLike(11n, { hash: hash("a2") }); + const withdrawalHeader = headerLike(12n, { hash: hash("a3") }); + const requestedDeposit = depositCell("70", logic, dao, depositHeader, tip, { + isReady: true, + }); + const requiredLiveDeposit = depositCell("71", logic, dao, depositHeader, tip, { + isReady: true, + }); + const { group: orderGroup, orderCell, masterCell } = makeOrderGroup({ + orderScript: order, + udtScript: udt, + ownerLock: botLock, + txHashByte: "72", + }); + const receipt = receiptCell("73", botLock, logic, receiptHeader); + const withdrawalGroup = readyWithdrawalGroup({ + ownerLock: botLock, + ownedOwner, + dao, + depositHeader, + withdrawalHeader, + }); + + const tx = await sdk.buildBaseTransaction(ccc.Transaction.default(), {} as ccc.Client, { + withdrawalRequest: { + deposits: [requestedDeposit], + requiredLiveDeposits: [requiredLiveDeposit], + lock: botLock, + }, + orders: [orderGroup], + receipts: [receipt], + readyWithdrawals: [withdrawalGroup], + }); + + expect(tx.inputs.map((input) => input.previousOutput.toHex())).toEqual([ + requestedDeposit.cell.outPoint.toHex(), + orderCell.outPoint.toHex(), + masterCell.outPoint.toHex(), + receipt.cell.outPoint.toHex(), + withdrawalGroup.owned.cell.outPoint.toHex(), + withdrawalGroup.owner.cell.outPoint.toHex(), + ]); + expect(tx.outputs).toHaveLength(2); + expect(tx.outputs[0]?.capacity).toBe(requestedDeposit.cell.cellOutput.capacity); + expect(tx.outputs[0]?.lock.eq(ownedOwner)).toBe(true); + expect(tx.outputs[0]?.type?.eq(dao)).toBe(true); + expect(tx.outputs[1]?.lock.eq(botLock)).toBe(true); + expect(tx.outputs[1]?.type?.eq(ownedOwner)).toBe(true); + expect(tx.outputsData).toEqual([ + ccc.hexFrom(ccc.mol.Uint64LE.encode(depositHeader.number)), + ccc.hexFrom(OwnerData.encode({ ownedDistance: -1n })), + ]); + expect(tx.headerDeps).toEqual([ + depositHeader.hash, + receiptHeader.hash, + withdrawalHeader.hash, + ]); + expect(tx.cellDeps).toContainEqual(daoDep); + expect(tx.cellDeps).toContainEqual(ownedDep); + expect(tx.cellDeps).toContainEqual(logicDep); + expect(tx.cellDeps).toContainEqual(orderDep); expect(tx.cellDeps).toContainEqual( ccc.CellDep.from({ outPoint: requiredLiveDeposit.cell.outPoint, depType: "code", }), ); + expect(new Set(tx.headerDeps).size).toBe(tx.headerDeps.length); }); it("accepts withdrawal requests after balanced caller activity", async () => { @@ -541,9 +772,9 @@ describe("IckbSdk.buildBaseTransaction", () => { orderManager, [botLock], ); - const requestedDeposit = { - udtValue: 10n, - } as IckbDepositCell; + const requestedDeposit = depositCell("85", logic, dao, tip, tip, { + isReady: true, + }); const baseTx = ccc.Transaction.default(); baseTx.inputs.push( ccc.CellInput.from({ @@ -632,9 +863,9 @@ describe("IckbSdk.buildBaseTransaction", () => { [botLock], ); const calls: string[] = []; - const requestedDeposit = { - udtValue: 10n, - } as IckbDepositCell; + const requestedDeposit = depositCell("85", logic, dao, tip, tip, { + isReady: true, + }); vi.spyOn(ownedOwnerManager, "requestWithdrawal").mockImplementation( async (txLike) => { @@ -706,7 +937,7 @@ describe("IckbSdk.buildBaseTransaction", () => { await expect( sdk.buildBaseTransaction(tx, {} as ccc.Client, { withdrawalRequest: { - deposits: [{ udtValue: 10n } as IckbDepositCell], + deposits: [depositCell("85", logic, dao, tip, tip, { isReady: true })], lock: botLock, }, }), @@ -714,6 +945,501 @@ describe("IckbSdk.buildBaseTransaction", () => { }); }); +describe("IckbSdk.buildConversionTransaction", () => { + it("plans CKB-to-iCKB direct deposits before fallback orders", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const calls: string[] = []; + const remainder = ccc.fixedPointFrom(10000); + const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( + async (txLike, quantity, depositCapacity, depositLock) => { + await Promise.resolve(); + calls.push(`deposit:${String(quantity)}`); + expect(depositCapacity).toBe(ICKB_DEPOSIT_CAP); + expect(depositLock).toBe(lock); + const tx = ccc.Transaction.from(txLike); + tx.outputs.push(ccc.CellOutput.from({ capacity: 1n, lock })); + tx.outputsData.push("0x"); + return tx; + }, + ); + const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike, _lock, _info, amounts) => { + calls.push("order"); + expect(amounts).toEqual({ ckbValue: remainder, udtValue: 0n }); + const tx = ccc.Transaction.from(txLike); + expect(tx.outputs).toHaveLength(1); + tx.outputs.push(ccc.CellOutput.from({ capacity: 2n, lock })); + tx.outputsData.push("0x"); + return tx; + }); + + const result = await sdk.buildConversionTransaction( + ccc.Transaction.default(), + {} as ccc.Client, + { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP * 2n + remainder, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 3n }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP * 2n + remainder, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + }, + ); + + expect(result).toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); + expect(deposit).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + expect(calls).toEqual(["deposit:2", "order"]); + }); + + it("caps CKB-to-iCKB direct deposits", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( + async (txLike, quantity) => { + await Promise.resolve(); + expect(quantity).toBe(MAX_DIRECT_DEPOSITS); + return ccc.Transaction.from(txLike); + }, + ); + vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP * BigInt(MAX_DIRECT_DEPOSITS + 1), + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP * BigInt(MAX_DIRECT_DEPOSITS + 1), + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + }); + + expect(deposit).toHaveBeenCalledTimes(1); + }); + + it("retries CKB-to-iCKB direct deposits after DAO output-limit failures", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const deposit = vi.spyOn(logicManager, "deposit") + .mockRejectedValueOnce(new DaoOutputLimitError(65)) + .mockImplementation(async (txLike, quantity) => { + await Promise.resolve(); + expect(quantity).toBe(1); + return ccc.Transaction.from(txLike); + }); + vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP * 2n, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 2n }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP * 2n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); + + expect(deposit).toHaveBeenCalledTimes(2); + }); + + it("skips predictably oversized CKB-to-iCKB candidates before building", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const quantities: number[] = []; + const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( + async (txLike, quantity) => { + await Promise.resolve(); + quantities.push(quantity); + return ccc.Transaction.from(txLike); + }, + ); + vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect(sdk.buildConversionTransaction(transactionWithOutputs(60, lock), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP * 2n + 1n, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 3n }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP * 2n + 1n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); + + expect(deposit).toHaveBeenCalledTimes(1); + expect(quantities).toEqual([1]); + }); + + it("recognizes DAO output-limit errors across package runtime boundaries", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const outputLimitError = new Error("same domain error from another package copy"); + outputLimitError.name = "DaoOutputLimitError"; + const deposit = vi.spyOn(logicManager, "deposit") + .mockRejectedValueOnce(outputLimitError) + .mockImplementation(async (txLike, quantity) => { + await Promise.resolve(); + expect(quantity).toBe(1); + return ccc.Transaction.from(txLike); + }); + vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP * 2n, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 2n }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP * 2n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); + + expect(deposit).toHaveBeenCalledTimes(2); + }); + + it("fails fast on non-retryable CKB-to-iCKB construction errors", async () => { + const { sdk, logicManager, lock } = testSdk(); + const deposit = vi.spyOn(logicManager, "deposit") + .mockRejectedValue(new Error("RPC failed")); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP * 2n, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 2n }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP * 2n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).rejects.toThrow("RPC failed"); + + expect(deposit).toHaveBeenCalledTimes(1); + }); + + it("plans exact ready withdrawals with required anchors", async () => { + const { sdk, ownedOwnerManager, lock } = testSdk(); + const extra = readyDeposit(10n, 0n); + const protectedAnchor = readyDeposit(12n, 1n); + const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits, requestLock, _client, requestOptions) => { + await Promise.resolve(); + expect(deposits).toEqual([extra]); + expect(requestLock).toBe(lock); + expect(requestOptions).toEqual({ requiredLiveDeposits: [protectedAnchor] }); + return ccc.Transaction.from(txLike); + }); + + const result = await sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ickb-to-ckb", + amount: 10n, + lock, + context: { + system: system({ + poolDeposits: { + deposits: [extra, protectedAnchor], + readyDeposits: [extra, protectedAnchor], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: 10n, + estimatedMaturity: 0n, + }, + }); + + expect(result).toMatchObject({ ok: true, conversion: { kind: "direct" } }); + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + }); + + it("builds iCKB-to-CKB direct withdrawals plus dust remainder orders", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const directDeposit = readyDeposit(ICKB_DEPOSIT_CAP); + const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + expect(deposits).toEqual([directDeposit]); + return ccc.Transaction.from(txLike); + }); + const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike, _lock, info, amounts) => { + expect(info.ckbMinMatchLog).toBe(33); + expect(amounts).toEqual({ ckbValue: 0n, udtValue: 100000n }); + return ccc.Transaction.from(txLike); + }); + const exchangeRatio = Ratio.from({ + ckbScale: 10000000000000000n, + udtScale: 10008200000000000n, + }); + const amount = ICKB_DEPOSIT_CAP + 100000n; + + const result = await sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ickb-to-ckb", + amount, + lock, + context: { + system: system({ + exchangeRatio, + ckbAvailable: convert(false, ICKB_DEPOSIT_CAP, exchangeRatio), + poolDeposits: { + deposits: [directDeposit], + readyDeposits: [directDeposit], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: amount, + estimatedMaturity: 0n, + }, + }); + + expect(result).toMatchObject({ + ok: true, + conversion: { kind: "direct-plus-order" }, + conversionNotice: { + kind: "dust-ickb-to-ckb", + inputIckb: 100000n, + outputCkb: 100072n, + incentiveCkb: 10n, + maturityEstimateUnavailable: false, + }, + }); + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + }); + + it("returns typed failures for no activity and tiny orders", async () => { + const { sdk, lock } = testSdk(); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: 0n, + lock, + context: { + system: system(), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).resolves.toEqual({ ok: false, reason: "nothing-to-do", estimatedMaturity: 0n }); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: 1n, + lock, + context: { + system: system(), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 1n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).resolves.toEqual({ ok: false, reason: "amount-too-small", estimatedMaturity: 0n }); + }); + + it("fails fast on non-retryable iCKB-to-CKB construction errors", async () => { + const { sdk, ownedOwnerManager, lock } = testSdk(); + const extra = readyDeposit(1n, 0n); + const protectedAnchor = readyDeposit(2n, 1n); + vi.spyOn(ownedOwnerManager, "requestWithdrawal") + .mockRejectedValue(new Error("withdrawal failed")); + + const tx = ccc.Transaction.default(); + tx.inputs.push(ccc.CellInput.from({ + previousOutput: { txHash: hash("90"), index: 0n }, + })); + tx.outputs.push(ccc.CellOutput.from({ capacity: 1n, lock })); + tx.outputsData.push("0x"); + + await expect(sdk.buildConversionTransaction(tx, {} as ccc.Client, { + direction: "ickb-to-ckb", + amount: 1n, + lock, + context: { + system: system({ + exchangeRatio: Ratio.from({ ckbScale: 100n, udtScale: 1n }), + poolDeposits: { + deposits: [extra, protectedAnchor], + readyDeposits: [extra, protectedAnchor], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: 1n, + estimatedMaturity: 0n, + }, + })).rejects.toThrow("withdrawal failed"); + }); + + it("retries iCKB-to-CKB withdrawals after DAO output-limit failures", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const first = readyDeposit(ICKB_DEPOSIT_CAP / 2n, 0n); + const second = readyDeposit(ICKB_DEPOSIT_CAP / 2n, 15n * 60n * 1000n); + const requestedCounts: number[] = []; + const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + requestedCounts.push(deposits.length); + if (requestedCounts.length === 1) { + throw new DaoOutputLimitError(65); + } + expect(deposits).toHaveLength(1); + return ccc.Transaction.from(txLike); + }); + vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ickb-to-ckb", + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ + poolDeposits: { + deposits: [first, second], + readyDeposits: [first, second], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(2); + expect(requestedCounts).toEqual([2, 1]); + }); + + it("skips predictably oversized iCKB-to-CKB candidates before building", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const first = readyDeposit(ICKB_DEPOSIT_CAP / 2n, 0n); + const second = readyDeposit(ICKB_DEPOSIT_CAP / 2n, 15n * 60n * 1000n); + const requestedCounts: number[] = []; + const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + requestedCounts.push(deposits.length); + return ccc.Transaction.from(txLike); + }); + vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect(sdk.buildConversionTransaction(transactionWithOutputs(60, lock), {} as ccc.Client, { + direction: "ickb-to-ckb", + amount: ICKB_DEPOSIT_CAP + 1n, + lock, + context: { + system: system({ + poolDeposits: { + deposits: [first, second], + readyDeposits: [first, second], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP + 1n, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(requestedCounts).toEqual([1]); + }); + + it("reports predictable DAO output-limit exhaustion", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( + async (txLike) => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + ); + const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect(sdk.buildConversionTransaction(transactionWithOutputs(64, lock), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), + receipts: [], + readyWithdrawals: [{} as WithdrawalGroup], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).rejects.toThrow(DaoOutputLimitError); + + expect(deposit).not.toHaveBeenCalled(); + expect(mint).not.toHaveBeenCalled(); + }); + + it("preserves retryable construction errors when retries exhaust into planning misses", async () => { + const { sdk, logicManager, lock } = testSdk(); + vi.spyOn(logicManager, "deposit").mockRejectedValue(new DaoOutputLimitError(65)); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ + ckbAvailable: ICKB_DEPOSIT_CAP, + feeRate: ccc.fixedPointFrom(1), + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).rejects.toBeInstanceOf(DaoOutputLimitError); + }); +}); + describe("completeIckbTransaction", () => { it("runs UDT, fee, DAO-limit in order", async () => { const calls: string[] = []; @@ -895,12 +1621,13 @@ describe("sendAndWaitForCommit", () => { it("times out if post-broadcast polling keeps failing", async () => { const txHash = hash("a5"); + const pollingError = new Error("RPC down"); try { await sendAndWaitForCommit( { client: { - getTransaction: vi.fn().mockRejectedValue(new Error("RPC down")), + getTransaction: vi.fn().mockRejectedValue(pollingError), } as unknown as ccc.Client, signer: { sendTransaction: vi.fn().mockResolvedValue(txHash), @@ -921,11 +1648,69 @@ describe("sendAndWaitForCommit", () => { status: "sent", isTimeout: true, }); + expect(error).toHaveProperty("cause", pollingError); } }); }); describe("IckbSdk.getL1State snapshot detection", () => { + it("does not classify user-owned matchable orders as system liquidity", async () => { + const userLock = script("11"); + const nonUserLock = script("12"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const orderScript = script("55"); + const udt = script("66"); + const orderManager = new OrderManager(orderScript, [], udt); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + orderManager, + [], + ); + const ownerOrder = makeOrderGroup({ + orderScript, + udtScript: udt, + ownerLock: userLock, + txHashByte: "a1", + }); + ownerOrder.group.order.maturity = 999n; + const marketOrder = makeOrderGroup({ + orderScript, + udtScript: udt, + ownerLock: nonUserLock, + txHashByte: "a2", + orderTxHashByte: "a3", + ratio: { ckbScale: 2n, udtScale: 1n }, + orderCapacity: ccc.fixedPointFrom(300), + udtValue: 1n, + }); + vi.spyOn(orderManager, "findOrders").mockImplementation(async function* () { + yield ownerOrder.group; + yield marketOrder.group; + await Promise.resolve(); + }); + + const client = { + getTipHeader: () => Promise.resolve(headerLike(1n)), + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => none(), + } as unknown as ccc.Client; + + const state = await sdk.getL1State(client, [userLock]); + + expect(state.user.orders).toHaveLength(1); + expect(state.user.orders[0]).not.toBe(ownerOrder.group); + expect(state.user.orders[0]?.master).toBe(ownerOrder.group.master); + expect(state.user.orders[0]?.origin).toBe(ownerOrder.group.origin); + expect(state.user.orders[0]?.order).not.toBe(ownerOrder.group.order); + expect(state.user.orders[0]?.order.maturity).toBe(0n); + expect(ownerOrder.group.order.maturity).toBe(999n); + expect(state.system.orderPool).toEqual([marketOrder.group.order]); + }); + it("ignores bot data cells and falls back to direct deposit scanning", async () => { const botLock = script("11"); const logic = script("22"); @@ -1115,6 +1900,74 @@ describe("IckbSdk.getL1State snapshot detection", () => { ); }); + it("does not start bot withdrawal scanning when bot capacity scanning fails", 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, [])); + const findWithdrawalGroups = 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?: { scriptLenRange?: unknown } }) { + if (query.filter?.scriptLenRange) { + 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)}`, + ); + expect(findWithdrawalGroups).not.toHaveBeenCalled(); + }); + + it("propagates bot withdrawal scan failures after bot capacity scanning succeeds", 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(async function* () { + await Promise.resolve(); + yield* [] as WithdrawalGroup[]; + throw new Error("withdrawal failed"); + }); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + new LogicManager(logic, [], new DaoManager(dao, [])), + 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, [])).rejects.toThrow("withdrawal failed"); + }); + it("allows direct deposit scanning to exactly reach the limit", async () => { const botLock = script("11"); const logic = script("22"); @@ -1125,8 +1978,14 @@ describe("IckbSdk.getL1State snapshot detection", () => { 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(() => @@ -1156,16 +2015,52 @@ describe("IckbSdk.getL1State snapshot detection", () => { const ownedOwner = script("44"); const order = script("55"); const udt = script("66"); - const logicManager = new LogicManager(logic, [], new DaoManager(dao, [])); + const daoManager = new DaoManager(dao, []); + const logicManager = new LogicManager(logic, [], daoManager); const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); - const deposit = { - isReady: false, - ckbValue: 1n, - maturity: { toUnix: () => 1n }, - } as unknown as IckbDepositCell; - vi.spyOn(logicManager, "findDeposits").mockImplementation(() => - repeat(defaultFindCellsLimit + 1, deposit) + 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 () => { + 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 findDeposits = vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); const sdk = new IckbSdk( fakeIckbUdt(udt), @@ -1180,8 +2075,122 @@ describe("IckbSdk.getL1State snapshot detection", () => { findCellsOnChain: () => none(), } as unknown as ccc.Client; + await sdk.getL1State(client, []); + + expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ + limit: defaultFindCellsLimit, + }); + }); + + it("passes a custom logical limit 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; + + await sdk.getPoolDeposits(client, tip, { limit: poolLimit }); + + expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ + onChain: true, + tip, + limit: poolLimit, + }); + }); + + 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[]; + }); + 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, + }); + }); + + it("fails closed when the chain tip changes during L1 state scanning", async () => { + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const firstTip = headerLike(1n, { hash: hash("01") }); + const secondTip = headerLike(2n, { hash: hash("02") }); + const getTipHeader = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(secondTip); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [], + ); + const client = { + getTipHeader, + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => none(), + } as unknown as ccc.Client; + await expect(sdk.getL1State(client, [])).rejects.toThrow( - `iCKB deposit scan reached limit ${String(defaultFindCellsLimit)}`, + "L1 state scan crossed chain tip", + ); + }); + + it("fails closed when the chain tip changes during account state scanning", async () => { + const accountLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const firstTip = headerLike(1n, { hash: hash("01") }); + const secondTip = headerLike(2n, { hash: hash("02") }); + const getTipHeader = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(secondTip); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [], + ); + const client = { + getTipHeader, + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => none(), + } as unknown as ccc.Client; + + await expect(sdk.getL1AccountState(client, [accountLock])).rejects.toThrow( + "L1 state scan crossed chain tip", ); }); }); @@ -1231,6 +2240,7 @@ describe("IckbSdk.getAccountState", () => { const state = await sdk.getAccountState(client, [accountLock, accountLock], tip); expect(state.capacityCells).toEqual([capacityCell]); + expect(state.nativeUdtCells).toEqual([udtCell]); expect(state.nativeUdtCapacity).toBe(7n); expect(state.nativeUdtBalance).toBe(11n); expect(state.receipts).toEqual([receipt]); @@ -1294,3 +2304,153 @@ describe("IckbSdk.getAccountState", () => { ); }); }); + +function dep(byte: string): ccc.CellDep { + return ccc.CellDep.from({ + outPoint: { txHash: hash(byte), index: 0n }, + depType: "code", + }); +} + +function depositCell( + byte: string, + logic: ccc.Script, + dao: ccc.Script, + depositHeader: ccc.ClientBlockHeader, + tipHeader: ccc.ClientBlockHeader, + options?: { isReady?: boolean }, +): IckbDepositCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: hash(byte), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: logic, + type: dao, + }, + outputData: DaoManager.depositData(), + }); + return { + cell, + headers: [{ header: depositHeader }, { header: tipHeader }], + interests: 0n, + maturity: ccc.Epoch.from([1n, 0n, 1n]), + isReady: options?.isReady ?? false, + isDeposit: true, + ckbValue: cell.cellOutput.capacity, + udtValue: ccc.fixedPointFrom(100000), + [Symbol("isIckbDeposit")]: true, + } as unknown as IckbDepositCell; +} + +function receiptCell( + byte: string, + lock: ccc.Script, + logic: ccc.Script, + header: ccc.ClientBlockHeader, +): ReceiptCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: hash(byte), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: logic, + }, + outputData: ReceiptData.encode({ + depositQuantity: 1, + depositAmount: ccc.fixedPointFrom(100000), + }), + }); + return { + cell, + header: { header, txHash: cell.outPoint.txHash }, + ckbValue: cell.cellOutput.capacity, + udtValue: ccc.fixedPointFrom(100000), + }; +} + +function makeOrderGroup(options: { + orderScript: ccc.Script; + udtScript: ccc.Script; + ownerLock: ccc.Script; + txHashByte: string; + orderTxHashByte?: string; + ratio?: { ckbScale: bigint; udtScale: bigint }; + orderCapacity?: bigint; + udtValue?: bigint; +}): { group: OrderGroup; orderCell: ccc.Cell; masterCell: ccc.Cell } { + const masterOutPoint = ccc.OutPoint.from({ + txHash: hash(options.txHashByte), + index: 1n, + }); + const orderCell = ccc.Cell.from({ + outPoint: { txHash: hash(options.orderTxHashByte ?? "74"), index: 0n }, + cellOutput: { + capacity: options.orderCapacity ?? ccc.fixedPointFrom(100), + lock: options.orderScript, + type: options.udtScript, + }, + outputData: OrderData.from({ + udtValue: options.udtValue ?? 0n, + master: { type: "absolute", value: masterOutPoint }, + info: Info.create(true, options.ratio ?? { ckbScale: 1n, udtScale: 1n }), + }).toBytes(), + }); + const masterCell = ccc.Cell.from({ + outPoint: masterOutPoint, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: options.ownerLock, + type: options.orderScript, + }, + outputData: "0x", + }); + const order = OrderCell.mustFrom(orderCell); + + return { + group: new OrderGroup(new MasterCell(masterCell), order, order), + orderCell, + masterCell, + }; +} + +function readyWithdrawalGroup(options: { + ownerLock: ccc.Script; + ownedOwner: ccc.Script; + dao: ccc.Script; + depositHeader: ccc.ClientBlockHeader; + withdrawalHeader: ccc.ClientBlockHeader; +}): WithdrawalGroup { + const ownedCell = ccc.Cell.from({ + outPoint: { txHash: hash("75"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: options.ownedOwner, + type: options.dao, + }, + outputData: ccc.mol.Uint64LE.encode(options.depositHeader.number), + }); + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: hash("76"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: options.ownerLock, + type: options.ownedOwner, + }, + outputData: OwnerData.encode({ ownedDistance: -1n }), + }), + ); + return new WithdrawalGroup({ + cell: ownedCell, + headers: [ + { header: options.depositHeader }, + { header: options.withdrawalHeader }, + ], + interests: 0n, + maturity: ccc.Epoch.from([1n, 0n, 1n]), + isReady: true, + isDeposit: false, + ckbValue: ownedCell.cellOutput.capacity, + udtValue: 0n, + }, owner); +} diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 06f369b..d28a04b 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -1,14 +1,17 @@ import { ccc } from "@ckb-ccc/core"; -import { assertDaoOutputLimit } from "@ickb/dao"; +import { assertDaoOutputLimit, DaoOutputLimitError } from "@ickb/dao"; import { collect, + collectCompleteScan, binarySearch, + compareBigInt, defaultFindCellsLimit, isPlainCapacityCell, unique, type ValueComponents, } from "@ickb/utils"; import { + ICKB_DEPOSIT_CAP, convert, type IckbDepositCell, type IckbUdt, @@ -20,12 +23,84 @@ import { } from "@ickb/core"; import { Info, + OrderCell, + OrderGroup, OrderManager, Ratio, - type OrderCell, - type OrderGroup, } from "@ickb/order"; import { getConfig } from "./constants.js"; +import { selectExactReadyWithdrawalDeposits } from "./withdrawal_selection.js"; + +export const MAX_DIRECT_DEPOSITS = 60; +export const MAX_WITHDRAWAL_REQUESTS = 30; + +const DAO_OUTPUT_LIMIT = 64; +const ORDER_MINT_OUTPUTS = 2; + +type SleepScheduler = (handler: () => void, timeout?: number) => unknown; + +export type ConversionDirection = "ckb-to-ickb" | "ickb-to-ckb"; + +export interface PoolDepositState { + deposits: IckbDepositCell[]; + readyDeposits: IckbDepositCell[]; + id: string; +} + +export interface ConversionTransactionContext { + system: SystemState; + receipts: ReceiptCell[]; + readyWithdrawals: WithdrawalGroup[]; + availableOrders: OrderGroup[]; + ckbAvailable: bigint; + ickbAvailable: bigint; + estimatedMaturity: bigint; +} + +export interface ConversionTransactionOptions { + direction: ConversionDirection; + amount: bigint; + lock: ccc.Script; + context: ConversionTransactionContext; + limits?: { + maxDirectDeposits?: number; + maxWithdrawalRequests?: number; + }; +} + +export type ConversionTransactionFailureReason = + | "amount-negative" + | "insufficient-ckb" + | "insufficient-ickb" + | "amount-too-small" + | "not-enough-ready-deposits" + | "nothing-to-do"; + +export interface ConversionNotice { + kind: "dust-ickb-to-ckb" | "maturity-unavailable"; + inputIckb: bigint; + outputCkb: bigint; + incentiveCkb: bigint; + maturityEstimateUnavailable: boolean; +} + +export interface ConversionMetadata { + kind: "direct" | "order" | "direct-plus-order" | "collect-only"; +} + +export type ConversionTransactionResult = + | { + ok: true; + tx: ccc.Transaction; + estimatedMaturity: bigint; + conversion: ConversionMetadata; + conversionNotice?: ConversionNotice; + } + | { + ok: false; + reason: ConversionTransactionFailureReason; + estimatedMaturity: bigint; + }; export interface CompleteIckbTransactionOptions { signer: ccc.Signer; @@ -41,14 +116,25 @@ export interface SendAndWaitForCommitOptions { sleep?: (ms: number) => Promise; } +export interface GetL1StateOptions { + orderLimit?: number; +} + +export interface IckbToCkbOrderEstimate { + estimate: ReturnType; + maturity: bigint | undefined; + notice?: ConversionNotice; +} + export class TransactionConfirmationError extends Error { constructor( message: string, public readonly txHash: ccc.Hex, public readonly status: string | undefined, public readonly isTimeout: boolean, + options?: ErrorOptions, ) { - super(message); + super(message, options); this.name = "TransactionConfirmationError"; } } @@ -87,12 +173,14 @@ export async function sendAndWaitForCommit( const txHash = await signer.sendTransaction(tx); onSent?.(txHash); let status: string | undefined = "sent"; + let lastPollingError: unknown; for (let checks = 0; checks < maxConfirmationChecks && isPendingStatus(status); checks += 1) { try { status = (await client.getTransaction(txHash))?.status; - } catch { + } catch (error) { // Post-broadcast polling errors are transient; keep waiting until timeout. + lastPollingError = error; } if (!isPendingStatus(status)) { break; @@ -112,6 +200,7 @@ export async function sendAndWaitForCommit( txHash, status, true, + lastPollingError === undefined ? undefined : { cause: lastPollingError }, ); } @@ -135,10 +224,22 @@ function isPendingStatus(status: string | undefined): boolean { async function delay(ms: number): Promise { await new Promise((resolve) => { - setTimeout(resolve, ms); + getSleepScheduler()(resolve, ms); }); } +function getSleepScheduler(): SleepScheduler { + const runtime = globalThis as typeof globalThis & { + setTimeout?: SleepScheduler; + }; + const schedule = runtime.setTimeout; + if (!schedule) { + throw new Error("setTimeout is unavailable in this runtime"); + } + + return schedule; +} + /** * SDK for managing iCKB operations. * @@ -210,8 +311,9 @@ export class IckbSdk { * - convertedAmount: The estimated converted amount as a FixedPoint. * - ckbFee: The fee (or gain) in CKB, as a FixedPoint. * - info: Additional conversion metadata. - * - maturity: Optional maturity information when the preview clears the - * minimum match and fee threshold used for interface-sized orders. + * - maturity: Optional maturity information when the fee/incentive threshold + * is met and the current pool state can estimate completion timing. The + * order info can still be valid when maturity is undefined. */ static estimate( isCkb2Udt: boolean, @@ -240,16 +342,61 @@ export class IckbSdk { options, ); - // Only previews that clear the minimum match and fee threshold get a - // maturity estimate. Smaller previews still return convertedAmount/info. - const maturity = - ckbFee >= 10n * system.feeRate - ? IckbSdk.maturity({ info, amounts }, system) - : undefined; + // Only previews that clear the fee/incentive threshold get a maturity + // estimate. Smaller previews still return convertedAmount/info. + const maturity = ckbFee >= estimateMaturityFeeThreshold(system) + ? IckbSdk.maturity({ info, amounts }, system) + : undefined; return { convertedAmount, ckbFee, info, maturity }; } + static estimateIckbToCkbOrder( + amounts: { ckbValue: bigint; udtValue: bigint }, + system: SystemState, + ): IckbToCkbOrderEstimate | undefined { + const estimate = IckbSdk.estimate(false, amounts, system); + if (estimate.maturity !== undefined) { + return { estimate, maturity: estimate.maturity }; + } + + if (estimate.convertedAmount === 0n) { + return; + } + + if (estimate.ckbFee >= estimateMaturityFeeThreshold(system)) { + return { + estimate, + maturity: undefined, + notice: { + kind: "maturity-unavailable", + inputIckb: amounts.udtValue, + outputCkb: estimate.convertedAmount, + incentiveCkb: positiveFee(estimate.ckbFee), + maturityEstimateUnavailable: true, + }, + }; + } + + const dustEstimate = estimateDustIckbToCkbOrder(amounts, system); + const dustMaturity = IckbSdk.maturity( + { info: dustEstimate.info, amounts }, + system, + ); + + return { + estimate: dustEstimate, + maturity: dustMaturity, + notice: { + kind: "dust-ickb-to-ckb", + inputIckb: amounts.udtValue, + outputCkb: dustEstimate.convertedAmount, + incentiveCkb: positiveFee(dustEstimate.ckbFee), + maturityEstimateUnavailable: dustMaturity === undefined, + }, + }; + } + /** * Estimates the maturity for an order formatted as a Unix timestamp. * @@ -294,8 +441,10 @@ export class IckbSdk { // Create a reference ratio instance for comparison. const b = new Info(ratio, ratio, 1); - let ckb = isCkb2Udt ? amount : 0n; - let udt = isCkb2Udt ? 0n : amount; + let ckb = isCkb2Udt + ? amount + : amounts.ckbValue - ratio.convert(false, amount, true); + let udt = 0n; for (const o of orderPool) { const a = o.data.info; if (a.isCkb2Udt()) { @@ -436,16 +585,17 @@ export class IckbSdk { ): Promise { let tx = ccc.Transaction.from(txLike); - if (options?.withdrawalRequest?.deposits.length) { + const withdrawalRequest = options?.withdrawalRequest; + if (withdrawalRequest?.deposits.length) { tx = await this.ownedOwner.requestWithdrawal( tx, - options.withdrawalRequest.deposits, - options.withdrawalRequest.lock, + withdrawalRequest.deposits, + withdrawalRequest.lock, client, + withdrawalRequest.requiredLiveDeposits?.length + ? { requiredLiveDeposits: withdrawalRequest.requiredLiveDeposits } + : undefined, ); - for (const deposit of options.withdrawalRequest.requiredLiveDeposits ?? []) { - tx.addCellDeps({ outPoint: deposit.cell.outPoint, depType: "code" }); - } } if (options?.orders?.length) { @@ -463,6 +613,274 @@ export class IckbSdk { return tx; } + async getPoolDeposits( + client: ccc.Client, + tip: ccc.ClientBlockHeader, + options?: { limit?: number }, + ): Promise { + const deposits = await collect(this.ickbLogic.findDeposits(client, { + onChain: true, + tip, + limit: options?.limit ?? defaultFindCellsLimit, + })); + const readyDeposits = sortDepositsByMaturity( + deposits.filter((deposit) => deposit.isReady), + tip, + ); + + return { + deposits, + readyDeposits, + id: poolDepositsKey(deposits, tip), + }; + } + + async buildConversionTransaction( + txLike: ccc.TransactionLike, + client: ccc.Client, + options: ConversionTransactionOptions, + ): Promise { + const { amount, context, direction } = options; + if (amount < 0n) { + return conversionFailure("amount-negative", context.estimatedMaturity); + } + + if (direction === "ckb-to-ickb" && amount > context.ckbAvailable) { + return conversionFailure("insufficient-ckb", context.estimatedMaturity); + } + + if (direction === "ickb-to-ckb" && amount > context.ickbAvailable) { + return conversionFailure("insufficient-ickb", context.estimatedMaturity); + } + + if (amount === 0n) { + const tx = await this.buildBaseTransaction( + txLike, + client, + baseTransactionOptions(context), + ); + if (!hasTransactionActivity(tx)) { + return conversionFailure("nothing-to-do", context.estimatedMaturity); + } + + return { + ok: true, + tx, + estimatedMaturity: context.estimatedMaturity, + conversion: { kind: "collect-only" }, + }; + } + + return direction === "ckb-to-ickb" + ? await this.buildCkbToIckbConversion(txLike, client, options) + : await this.buildIckbToCkbConversion(txLike, client, options); + } + + private async buildCkbToIckbConversion( + txLike: ccc.TransactionLike, + client: ccc.Client, + options: ConversionTransactionOptions, + ): Promise { + const { amount, context, lock } = options; + const maxDirectDeposits = normalizeCountLimit( + options.limits?.maxDirectDeposits ?? MAX_DIRECT_DEPOSITS, + ); + const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, context.system.exchangeRatio); + const depositQuotient = depositCapacity === 0n ? 0n : amount / depositCapacity; + const maxDeposits = depositQuotient > BigInt(maxDirectDeposits) + ? maxDirectDeposits + : Number(depositQuotient); + let lastFailure: ConversionTransactionFailureReason | undefined; + let lastError: unknown; + + for (let depositCount = maxDeposits; depositCount >= 0; depositCount -= 1) { + const remainder = amount - depositCapacity * BigInt(depositCount); + let estimatedMaturity = context.estimatedMaturity; + let order: ConversionOrder | undefined; + + if (remainder > 0n) { + const amounts = { ckbValue: remainder, udtValue: 0n }; + const estimate = IckbSdk.estimate(true, amounts, context.system); + if (estimate.maturity === undefined) { + lastFailure = "amount-too-small"; + continue; + } + + estimatedMaturity = maxMaturity(estimatedMaturity, estimate.maturity); + order = { amounts, estimate }; + } + + const outputLimitError = plannedDaoOutputLimitError( + txLike, + (depositCount > 0 ? depositCount + 1 : 0) + orderOutputCount(order), + depositCount > 0 || context.readyWithdrawals.length > 0, + ); + if (outputLimitError) { + lastFailure = undefined; + lastError ??= outputLimitError; + continue; + } + + try { + let tx = await this.buildBaseTransaction( + txLike, + client, + baseTransactionOptions(context), + ); + if (depositCount > 0) { + tx = await this.ickbLogic.deposit( + tx, + depositCount, + depositCapacity, + lock, + client, + ); + } + if (order) { + tx = await this.request(tx, lock, order.estimate.info, order.amounts); + } + + return { + ok: true, + tx, + estimatedMaturity, + conversion: { kind: conversionKind(depositCount > 0, order !== undefined) }, + }; + } catch (error) { + if (!isRetryableConversionBuildError(error)) { + throw errorOf(error); + } + lastFailure = undefined; + lastError ??= error; + } + } + + if (lastError !== undefined) { + throw errorOf(lastError); + } + + return conversionFailure(lastFailure ?? "nothing-to-do", context.estimatedMaturity); + } + + private async buildIckbToCkbConversion( + txLike: ccc.TransactionLike, + client: ccc.Client, + options: ConversionTransactionOptions, + ): Promise { + const { amount, context, lock } = options; + const maxWithdrawalRequests = normalizeCountLimit( + options.limits?.maxWithdrawalRequests ?? MAX_WITHDRAWAL_REQUESTS, + ); + const poolDeposits = context.system.poolDeposits ?? + await this.getPoolDeposits(client, context.system.tip); + const candidates = sortDepositsByMaturity( + poolDeposits.readyDeposits.filter((deposit) => deposit.isReady), + context.system.tip, + ); + let lastFailure: ConversionTransactionFailureReason | undefined; + let lastError: unknown; + + for ( + let withdrawalCount = Math.min(candidates.length, maxWithdrawalRequests); + withdrawalCount >= 0; + withdrawalCount -= 1 + ) { + let estimatedMaturity = context.estimatedMaturity; + let remainder = amount; + let selectedDeposits: IckbDepositCell[] = []; + let requiredLiveDeposits: IckbDepositCell[] = []; + let order: ConversionOrder | undefined; + + if (withdrawalCount > 0) { + const selection = selectExactReadyWithdrawalDeposits({ + readyDeposits: candidates, + tip: context.system.tip, + maxAmount: remainder, + count: withdrawalCount, + preserveSingletons: amount < ICKB_DEPOSIT_CAP, + }); + if (selection === undefined) { + lastFailure = "not-enough-ready-deposits"; + continue; + } + + ({ deposits: selectedDeposits, requiredLiveDeposits } = selection); + remainder -= sumUdtValue(selectedDeposits); + for (const deposit of selectedDeposits) { + estimatedMaturity = maxMaturity( + estimatedMaturity, + deposit.maturity.toUnix(context.system.tip), + ); + } + } + + if (remainder > 0n) { + const amounts = { ckbValue: 0n, udtValue: remainder }; + const preview = IckbSdk.estimateIckbToCkbOrder(amounts, context.system); + if (!preview) { + lastFailure = "amount-too-small"; + continue; + } + + const { estimate, maturity, notice } = preview; + if (maturity !== undefined) { + estimatedMaturity = maxMaturity(estimatedMaturity, maturity); + } + order = { amounts, estimate, conversionNotice: notice }; + } + + const outputLimitError = plannedDaoOutputLimitError( + txLike, + selectedDeposits.length * 2 + orderOutputCount(order), + selectedDeposits.length > 0 || context.readyWithdrawals.length > 0, + ); + if (outputLimitError) { + lastFailure = undefined; + lastError ??= outputLimitError; + continue; + } + + try { + let tx = await this.buildBaseTransaction( + txLike, + client, + baseTransactionOptions(context, { + deposits: selectedDeposits, + requiredLiveDeposits, + lock, + }), + ); + if (order) { + tx = await this.request(tx, lock, order.estimate.info, order.amounts); + } + + return { + ok: true, + tx, + estimatedMaturity, + conversion: { + kind: conversionKind(selectedDeposits.length > 0, order !== undefined), + }, + ...(order?.conversionNotice + ? { conversionNotice: order.conversionNotice } + : {}), + }; + } catch (error) { + if (!isRetryableConversionBuildError(error)) { + throw errorOf(error); + } + lastFailure = undefined; + lastError ??= error; + } + } + + if (lastError !== undefined) { + throw errorOf(lastError); + } + + return conversionFailure(lastFailure ?? "nothing-to-do", context.estimatedMaturity); + } + async getAccountState( client: ccc.Client, locks: ccc.Script[], @@ -478,13 +896,15 @@ export class IckbSdk { }), ), ]); + const nativeUdtCells = cells.filter((cell) => this.ickbUdt.isUdt(cell)); const nativeUdtInfo = await this.ickbUdt.infoFrom( client, - cells.filter((cell) => this.ickbUdt.isUdt(cell)), + nativeUdtCells, ); return { capacityCells: cells.filter(isPlainCapacityCell), + nativeUdtCells, nativeUdtCapacity: nativeUdtInfo.capacity, nativeUdtBalance: nativeUdtInfo.balance, receipts, @@ -492,6 +912,22 @@ export class IckbSdk { }; } + async getL1AccountState( + client: ccc.Client, + locks: ccc.Script[], + options?: GetL1StateOptions, + ): Promise<{ + system: SystemState; + user: { orders: OrderGroup[] }; + account: AccountState; + }> { + const { system, user } = await this.getL1State(client, locks, options); + const account = await this.getAccountState(client, locks, system.tip); + await this.assertCurrentTip(client, system.tip); + + return { system, user, account }; + } + /** * Retrieves the L1 state from the blockchain. * @@ -510,16 +946,21 @@ export class IckbSdk { async getL1State( client: ccc.Client, locks: ccc.Script[], + options?: GetL1StateOptions, ): Promise<{ system: SystemState; user: { orders: OrderGroup[] } }> { const tip = await client.getTipHeader(); const exchangeRatio = Ratio.from(ickbExchangeRatio(tip)); // Parallel fetching of system components. - const [{ ckbAvailable, ckbMaturing }, orders, feeRate] = await Promise.all([ - this.getCkb(client, tip), - collect(this.order.findOrders(client, { onChain: true })), + const [poolDeposits, orders, feeRate] = await Promise.all([ + this.getPoolDeposits(client, tip), + collect(this.order.findOrders(client, { + onChain: true, + limit: options?.orderLimit ?? defaultFindCellsLimit, + })), client.getFeeRate(), ]); + const { ckbAvailable, ckbMaturing } = await this.getCkb(client, tip, poolDeposits); const midInfo = new Info(exchangeRatio, exchangeRatio, 1); const userOrders: OrderGroup[] = []; @@ -527,6 +968,7 @@ export class IckbSdk { for (const group of orders) { if (group.isOwner(...locks)) { userOrders.push(group); + continue; } const { order } = group; @@ -546,17 +988,14 @@ export class IckbSdk { orderPool: systemOrders, ckbAvailable, ckbMaturing, + poolDeposits, }; - - // Estimates user orders maturity. - for (const { order } of userOrders) { - order.maturity = IckbSdk.maturity(order, system); - } + await this.assertCurrentTip(client, tip); return { system, user: { - orders: userOrders, + orders: userOrders.map((group) => orderGroupWithMaturity(group, system)), }, }; } @@ -581,6 +1020,7 @@ export class IckbSdk { private async getCkb( client: ccc.Client, tip: ccc.ClientBlockHeader, + poolDeposits: PoolDepositState, ): Promise<{ ckbAvailable: ccc.FixedPoint; ckbMaturing: CkbCumulative[]; @@ -591,36 +1031,26 @@ export class IckbSdk { tip, limit, }; - const directDepositOptions = { - onChain: true, - tip, - limit: scanLimit(limit), - }; - - // Start fetching bot iCKB withdrawal requests. - const promiseBotWithdrawals = collect( - this.ownedOwner.findWithdrawalGroups(client, this.bots, withdrawalOptions), - ); - // 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)) { - let scanned = 0; - for await (const cell of client.findCellsOnChain( - { - script: lock, - scriptType: "lock", - filter: { - scriptLenRange: [0n, 1n], + for (const cell of await collectCompleteScan( + (scanLimit) => client.findCellsOnChain( + { + script: lock, + scriptType: "lock", + filter: { + scriptLenRange: [0n, 1n], + }, + scriptSearchMode: "exact", + withData: true, }, - scriptSearchMode: "exact", - withData: true, - }, - "asc", - scanLimit(limit), + "asc", + scanLimit, + ), + { limit, label: "bot capacity", context: lock }, )) { - scanned += 1; if (cell.cellOutput.type !== undefined || !cell.cellOutput.lock.eq(lock)) { continue; } @@ -632,14 +1062,16 @@ export class IckbSdk { bot2Ckb.set(key, ckb); } } - assertCompleteScan(scanned, limit, "bot capacity", lock); } const ckbMaturing = new Array<{ ckbValue: ccc.FixedPoint; maturity: ccc.Num; }>(); - for (const wr of await promiseBotWithdrawals) { + const botWithdrawals = await collect( + this.ownedOwner.findWithdrawalGroups(client, this.bots, withdrawalOptions), + ); + for (const wr of botWithdrawals) { if (wr.owned.isReady) { // Update the bot's CKB based on withdrawal if the bot is ready. const key = wr.owner.cell.cellOutput.lock.toHex(); @@ -666,9 +1098,7 @@ export class IckbSdk { // Bot-owned no-type data cells are not distinguishable from arbitrary payloads, // so the SDK currently falls back to direct deposit scanning instead of trusting // snapshot-like bytes from wallet-owned cells. - let depositsScanned = 0; - for await (const d of this.ickbLogic.findDeposits(client, directDepositOptions)) { - depositsScanned += 1; + for (const d of poolDeposits.deposits) { if (d.isReady) { ckbAvailable += d.ckbValue; continue; @@ -679,7 +1109,6 @@ export class IckbSdk { maturity: d.maturity.toUnix(tip), }); } - assertCompleteScan(depositsScanned, limit, "iCKB deposit"); // Sort maturing CKB entries by their maturity timestamp. ckbMaturing.sort((a, b) => Number(a.maturity - b.maturity)); @@ -705,46 +1134,231 @@ export class IckbSdk { const cells: ccc.Cell[] = []; const limit = defaultFindCellsLimit; for (const lock of unique(locks)) { - let scanned = 0; - for await (const cell of client.findCellsOnChain( - { - script: lock, - scriptType: "lock", - scriptSearchMode: "exact", - withData: true, - }, - "asc", - scanLimit(limit), - )) { - scanned += 1; - cells.push(cell); - } - assertCompleteScan(scanned, limit, "account", lock); + cells.push(...await collectCompleteScan( + (scanLimit) => client.findCellsOnChain( + { + script: lock, + scriptType: "lock", + scriptSearchMode: "exact", + withData: true, + }, + "asc", + scanLimit, + ), + { limit, label: "account", context: lock }, + )); } return cells; } + + async assertCurrentTip( + client: ccc.Client, + tip: ccc.ClientBlockHeader, + ): Promise { + const currentTip = await client.getTipHeader(); + if (currentTip.hash !== tip.hash) { + throw new Error("L1 state scan crossed chain tip; retry with a fresh state"); + } + } +} + +type BuildBaseTransactionOptions = NonNullable< + Parameters[2] +>; + +interface ConversionOrder { + amounts: ValueComponents; + estimate: ReturnType; + conversionNotice?: ConversionNotice; } -function assertCompleteScan( - scanned: number, - limit: number, - label: string, - lock?: ccc.Script, -): void { - if (scanned <= limit) { +function conversionFailure( + reason: ConversionTransactionFailureReason, + estimatedMaturity: bigint, +): ConversionTransactionResult { + return { ok: false, reason, estimatedMaturity }; +} + +function baseTransactionOptions( + context: ConversionTransactionContext, + withdrawalRequest?: { + deposits: IckbDepositCell[]; + requiredLiveDeposits: IckbDepositCell[]; + lock: ccc.Script; + }, +): BuildBaseTransactionOptions { + return { + withdrawalRequest: + withdrawalRequest === undefined || withdrawalRequest.deposits.length === 0 + ? undefined + : { + deposits: withdrawalRequest.deposits, + ...(withdrawalRequest.requiredLiveDeposits.length > 0 + ? { requiredLiveDeposits: withdrawalRequest.requiredLiveDeposits } + : {}), + lock: withdrawalRequest.lock, + }, + orders: context.availableOrders, + receipts: context.receipts, + readyWithdrawals: context.readyWithdrawals, + }; +} + +function hasTransactionActivity(tx: ccc.Transaction): boolean { + return tx.inputs.length > 0 || tx.outputs.length > 0; +} + +function errorOf(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function isRetryableConversionBuildError(error: unknown): boolean { + return error instanceof DaoOutputLimitError || + error instanceof Error && error.name === "DaoOutputLimitError"; +} + +function plannedDaoOutputLimitError( + txLike: ccc.TransactionLike, + additionalOutputs: number, + hasDaoActivity: boolean, +): DaoOutputLimitError | undefined { + if (!hasDaoActivity) { return; } - const suffix = lock ? ` for ${lock.toHex()}` : ""; - throw new Error(`${label} scan reached limit ${String(limit)}${suffix}; state may be incomplete`); + const outputCount = ccc.Transaction.from(txLike).outputs.length + additionalOutputs; + return outputCount > DAO_OUTPUT_LIMIT + ? new DaoOutputLimitError(outputCount) + : undefined; +} + +function orderOutputCount(order: ConversionOrder | undefined): number { + return order ? ORDER_MINT_OUTPUTS : 0; +} + +function conversionKind( + hasDirect: boolean, + hasOrder: boolean, +): ConversionMetadata["kind"] { + if (hasDirect && hasOrder) { + return "direct-plus-order"; + } + if (hasDirect) { + return "direct"; + } + if (hasOrder) { + return "order"; + } + return "collect-only"; +} + +function estimateDustIckbToCkbOrder( + amounts: ValueComponents, + system: SystemState, +): ReturnType { + const baseEstimate = IckbSdk.estimate(false, amounts, system, { + fee: 0n, + }); + const targetFee = estimateMaturityFeeThreshold(system); + const feeBase = baseEstimate.convertedAmount + 1n; + if (targetFee <= 0n || feeBase <= 1n) { + return baseEstimate; + } + + const estimateWithFee = (fee: bigint): ReturnType => + IckbSdk.estimate(false, amounts, system, { + fee, + feeBase, + }); + + const highestFee = feeBase - 1n; + const highestDiscount = estimateWithFee(highestFee); + if (highestDiscount.ckbFee < targetFee) { + return highestDiscount; + } + + let low = 0n; + let high = highestFee; + while (low < high) { + const mid = (low + high) / 2n; + if (estimateWithFee(mid).ckbFee >= targetFee) { + high = mid; + } else { + low = mid + 1n; + } + } + + return estimateWithFee(low); +} + +function normalizeCountLimit(limit: number): number { + return Number.isSafeInteger(limit) && limit > 0 ? limit : 0; +} + +function sumUdtValue(deposits: readonly IckbDepositCell[]): bigint { + let total = 0n; + for (const deposit of deposits) { + total += deposit.udtValue; + } + return total; +} + +function poolDepositsKey( + deposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, +): string { + return deposits + .map((deposit) => [ + deposit.cell.outPoint.toHex(), + deposit.isReady ? "ready" : "pending", + String(deposit.ckbValue), + String(deposit.udtValue), + String(deposit.maturity.toUnix(tip)), + ].join("@")) + .sort() + .join(","); +} + +function sortDepositsByMaturity( + deposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, +): IckbDepositCell[] { + return [...deposits].sort((left, right) => + compareBigInt(left.maturity.toUnix(tip), right.maturity.toUnix(tip)) + ); +} + +function positiveOrZero(value: bigint): bigint { + return value > 0n ? value : 0n; +} + +function positiveFee(fee: bigint): bigint { + return positiveOrZero(fee); +} + +function maxMaturity(left: bigint, right: bigint): bigint { + return left > right ? left : right; } -function scanLimit(limit: number): number { - return limit + 1; +function orderGroupWithMaturity(group: OrderGroup, system: SystemState): OrderGroup { + const { order } = group; + return new OrderGroup( + group.master, + new OrderCell( + order.cell, + order.data, + order.ckbUnoccupied, + order.absTotal, + order.absProgress, + IckbSdk.maturity(order, system), + ), + group.origin, + ); } export interface AccountState { capacityCells: ccc.Cell[]; + nativeUdtCells: ccc.Cell[]; nativeUdtCapacity: bigint; nativeUdtBalance: bigint; receipts: ReceiptCell[]; @@ -871,6 +1485,15 @@ export interface SystemState { /** Array of CKB maturing entries with cumulative amounts and maturity timestamps. */ ckbMaturing: CkbCumulative[]; + + /** Complete public pool deposit snapshot for conversion planning at this tip. */ + poolDeposits?: PoolDepositState; +} + +export function estimateMaturityFeeThreshold( + system: Pick, +): bigint { + return 10n * system.feeRate; } /** diff --git a/packages/sdk/src/withdrawal_selection.test.ts b/packages/sdk/src/withdrawal_selection.test.ts new file mode 100644 index 0000000..d262d07 --- /dev/null +++ b/packages/sdk/src/withdrawal_selection.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from "vitest"; +import { type IckbDepositCell } from "@ickb/core"; +import { + selectExactReadyWithdrawalDeposits, + selectReadyWithdrawalCleanupDeposit, + selectReadyWithdrawalDeposits, +} from "./withdrawal_selection.js"; + +const TIP = {} as never; + +function readyDeposit( + udtValue: bigint, + maturityUnix: bigint, +): IckbDepositCell { + return { + isReady: true, + udtValue, + maturity: { + toUnix: (): bigint => maturityUnix, + }, + } as unknown as IckbDepositCell; +} + +describe("selectReadyWithdrawalDeposits", () => { + it("prefers the fullest valid subset under the target amount", () => { + const deposits = [ + readyDeposit(6n, 0n), + readyDeposit(5n, 15n * 60n * 1000n), + readyDeposit(5n, 30n * 60n * 1000n), + ]; + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: deposits, + tip: TIP, + maxAmount: 10n, + preserveSingletons: false, + }).deposits, + ).toEqual([deposits[1], deposits[2]]); + }); + + it("respects the request limit", () => { + const deposits = [ + readyDeposit(1n, 0n), + readyDeposit(1n, 15n * 60n * 1000n), + readyDeposit(1n, 30n * 60n * 1000n), + ]; + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: deposits, + tip: TIP, + maxAmount: 10n, + maxCount: 2, + preserveSingletons: false, + }).deposits, + ).toEqual([deposits[0], deposits[1]]); + }); + + it("supports exact-count direct withdrawal selection", () => { + const deposits = [ + readyDeposit(10n, 0n), + readyDeposit(1n, 15n * 60n * 1000n), + readyDeposit(1n, 30n * 60n * 1000n), + ]; + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: deposits, + tip: TIP, + maxAmount: 10n, + minCount: 2, + maxCount: 2, + preserveSingletons: false, + }).deposits, + ).toEqual([deposits[1], deposits[2]]); + }); + + it("returns no deposits when an exact-count fit is unavailable", () => { + const deposits = [ + readyDeposit(6n, 0n), + readyDeposit(5n, 15n * 60n * 1000n), + readyDeposit(5n, 30n * 60n * 1000n), + ]; + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: deposits, + tip: TIP, + maxAmount: 9n, + minCount: 2, + maxCount: 2, + preserveSingletons: false, + }), + ).toEqual({ deposits: [], requiredLiveDeposits: [] }); + }); + + it("does not select a ready deposit above the requested amount", () => { + const deposits = [ + readyDeposit(11n, 0n), + readyDeposit(10n, 15n * 60n * 1000n), + ]; + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: deposits, + tip: TIP, + maxAmount: 10n, + minCount: 1, + maxCount: 1, + preserveSingletons: false, + }).deposits, + ).toEqual([deposits[1]]); + }); + + it("does not use protected crowded anchors to satisfy exact-count selection", () => { + const extra = readyDeposit(4n, 20n * 60n * 1000n); + const protectedAnchor = readyDeposit(6n, 25n * 60n * 1000n); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [extra, protectedAnchor], + tip: TIP, + maxAmount: 10n, + minCount: 2, + maxCount: 2, + preserveSingletons: true, + }), + ).toEqual({ deposits: [], requiredLiveDeposits: [] }); + }); + + it("keeps earlier-ranked deposits when equal-total subsets tie", () => { + const deposits = [ + readyDeposit(6n, 0n), + readyDeposit(4n, 15n * 60n * 1000n), + readyDeposit(6n, 30n * 60n * 1000n), + readyDeposit(4n, 45n * 60n * 1000n), + ]; + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: deposits, + tip: TIP, + maxAmount: 10n, + preserveSingletons: false, + }).deposits, + ).toEqual([deposits[0], deposits[1]]); + }); + + it("pins protected crowded anchors for selected extras", () => { + const extra = readyDeposit(4n, 20n * 60n * 1000n); + const protectedAnchor = readyDeposit(6n, 25n * 60n * 1000n); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [extra, protectedAnchor], + tip: TIP, + maxAmount: 4n, + }), + ).toEqual({ + deposits: [extra], + requiredLiveDeposits: [protectedAnchor], + }); + }); + + it("preserves singleton anchors when requested", () => { + const singleton = readyDeposit(5n, 0n); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [singleton], + tip: TIP, + maxAmount: 5n, + preserveSingletons: true, + }), + ).toEqual({ deposits: [], requiredLiveDeposits: [] }); + }); + + it("can spend singleton anchors when caller unlocks them", () => { + const singleton = readyDeposit(5n, 0n); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [singleton], + tip: TIP, + maxAmount: 5n, + preserveSingletons: false, + }), + ).toEqual({ deposits: [singleton], requiredLiveDeposits: [] }); + }); + + it("uses near-ready refill as a singleton tie-break once anchors unlock", () => { + const earlierSingleton = readyDeposit(5n, 20n * 60n * 1000n); + const laterSingleton = readyDeposit(5n, 45n * 60n * 1000n); + const nearReadyRefill = readyDeposit(4n, 105n * 60n * 1000n); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [earlierSingleton, laterSingleton], + nearReadyDeposits: [nearReadyRefill], + tip: TIP, + maxAmount: 5n, + preserveSingletons: false, + }).deposits, + ).toEqual([laterSingleton]); + }); + + it("uses greedy fallback for later candidates beyond the bounded best-fit horizon", () => { + const deposits = [ + ...Array.from({ length: 30 }, (_, index) => readyDeposit(11n, BigInt(index))), + readyDeposit(10n, 31n), + ]; + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: deposits, + tip: TIP, + maxAmount: 10n, + maxCount: 1, + preserveSingletons: false, + }).deposits, + ).toEqual([deposits[30]]); + }); +}); + +describe("selectExactReadyWithdrawalDeposits", () => { + it("returns an exact-count selection when available", () => { + const deposits = [ + readyDeposit(10n, 0n), + readyDeposit(1n, 15n * 60n * 1000n), + readyDeposit(1n, 30n * 60n * 1000n), + ]; + + expect( + selectExactReadyWithdrawalDeposits({ + readyDeposits: deposits, + tip: TIP, + maxAmount: 10n, + count: 2, + preserveSingletons: false, + })?.deposits, + ).toEqual([deposits[1], deposits[2]]); + }); + + it("returns undefined when an exact-count selection is unavailable", () => { + const deposits = [ + readyDeposit(6n, 0n), + readyDeposit(5n, 15n * 60n * 1000n), + readyDeposit(5n, 30n * 60n * 1000n), + ]; + + expect( + selectExactReadyWithdrawalDeposits({ + readyDeposits: deposits, + tip: TIP, + maxAmount: 9n, + count: 2, + preserveSingletons: false, + }), + ).toBeUndefined(); + }); +}); + +describe("selectReadyWithdrawalCleanupDeposit", () => { + it("selects an over-cap crowded extra and pins its protected anchor", () => { + const extra = readyDeposit(11n, 20n * 60n * 1000n); + const protectedAnchor = readyDeposit(12n, 25n * 60n * 1000n); + + expect( + selectReadyWithdrawalCleanupDeposit({ + readyDeposits: [extra, protectedAnchor], + tip: TIP, + minAmountExclusive: 10n, + maxAmount: 11n, + }), + ).toEqual({ deposit: extra, requiredLiveDeposit: protectedAnchor }); + }); + + it("does not select the protected crowded anchor", () => { + const extra = readyDeposit(11n, 20n * 60n * 1000n); + const protectedAnchor = readyDeposit(12n, 25n * 60n * 1000n); + + expect( + selectReadyWithdrawalCleanupDeposit({ + readyDeposits: [extra, protectedAnchor], + tip: TIP, + minAmountExclusive: 11n, + maxAmount: 12n, + }), + ).toBeUndefined(); + }); + + it("respects the cleanup amount ceiling", () => { + const extra = readyDeposit(11n, 20n * 60n * 1000n); + const protectedAnchor = readyDeposit(12n, 25n * 60n * 1000n); + + expect( + selectReadyWithdrawalCleanupDeposit({ + readyDeposits: [extra, protectedAnchor], + tip: TIP, + minAmountExclusive: 10n, + maxAmount: 10n, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/withdrawal_selection.ts b/packages/sdk/src/withdrawal_selection.ts new file mode 100644 index 0000000..4386768 --- /dev/null +++ b/packages/sdk/src/withdrawal_selection.ts @@ -0,0 +1,435 @@ +import { ccc } from "@ckb-ccc/core"; +import { type IckbDepositCell } from "@ickb/core"; +import { compareBigInt, selectBoundedUdtSubset } from "@ickb/utils"; + +const READY_POOL_BUCKET_SPAN_MS = 15n * 60n * 1000n; +const NEAR_READY_LOOKAHEAD_MS = 60n * 60n * 1000n; +const NEAR_READY_BUCKET_LOOKAHEAD = NEAR_READY_LOOKAHEAD_MS / READY_POOL_BUCKET_SPAN_MS; +const BEST_FIT_SEARCH_CANDIDATES = 30; +const DEFAULT_MAX_WITHDRAWAL_REQUESTS = 30; + +export interface ReadyWithdrawalSelection { + deposits: IckbDepositCell[]; + requiredLiveDeposits: IckbDepositCell[]; +} + +export interface ReadyWithdrawalCleanupSelection { + deposit: IckbDepositCell; + requiredLiveDeposit: IckbDepositCell; +} + +export interface ReadyWithdrawalSelectionOptions { + readyDeposits: readonly IckbDepositCell[]; + nearReadyDeposits?: readonly IckbDepositCell[]; + tip: ccc.ClientBlockHeader; + maxAmount: bigint; + minCount?: number; + maxCount?: number; + preserveSingletons?: boolean; +} + +export interface ReadyWithdrawalCleanupSelectionOptions { + readyDeposits: readonly IckbDepositCell[]; + tip: ccc.ClientBlockHeader; + minAmountExclusive?: bigint; + maxAmount?: bigint; +} + +export function selectReadyWithdrawalDeposits( + options: ReadyWithdrawalSelectionOptions, +): ReadyWithdrawalSelection { + const { + tip, + maxAmount, + readyDeposits, + nearReadyDeposits = [], + minCount = 1, + maxCount = DEFAULT_MAX_WITHDRAWAL_REQUESTS, + preserveSingletons = true, + } = options; + const requiredCount = Math.max(1, minCount); + if ( + maxAmount <= 0n || + maxCount <= 0 || + requiredCount > maxCount || + readyDeposits.length === 0 + ) { + return { deposits: [], requiredLiveDeposits: [] }; + } + + const { extras, singletons, anchorsByExtra } = classifyReadyDeposits( + readyDeposits, + nearReadyDeposits, + tip, + ); + const selectedExtras = selectReadyDeposits(extras, maxAmount, { + maxCount, + minCount: preserveSingletons ? requiredCount : 1, + }); + if (selectedExtras.length > 0) { + if (preserveSingletons) { + return selectionWithRequiredAnchors(selectedExtras, anchorsByExtra); + } + + const remainingAmount = maxAmount - sumUdtValue(selectedExtras); + const remainingCount = maxCount - selectedExtras.length; + const remainingRequiredCount = Math.max(1, requiredCount - selectedExtras.length); + const selectedSingletons = selectReadyDeposits( + singletons, + remainingAmount, + { + maxCount: remainingCount, + minCount: remainingRequiredCount, + }, + ); + if (selectedSingletons.length === 0 && selectedExtras.length >= requiredCount) { + return selectionWithRequiredAnchors(selectedExtras, anchorsByExtra); + } + + const selected = new Set([ + ...selectedExtras, + ...selectedSingletons, + ]); + const selectedDeposits = sortByMaturity(readyDeposits, tip).filter((deposit) => + selected.has(deposit) + ); + if (selectedDeposits.length >= requiredCount) { + return selectionWithRequiredAnchors(selectedDeposits, anchorsByExtra); + } + } + + if (preserveSingletons) { + return { deposits: [], requiredLiveDeposits: [] }; + } + + const selectedSingletons = selectReadyDeposits(singletons, maxAmount, { + maxCount, + minCount: requiredCount, + }); + if (selectedSingletons.length > 0) { + return { deposits: selectedSingletons, requiredLiveDeposits: [] }; + } + + return selectionWithRequiredAnchors( + selectReadyDeposits(sortByMaturity(readyDeposits, tip), maxAmount, { + maxCount, + minCount: requiredCount, + }), + anchorsByExtra, + ); +} + +export function selectExactReadyWithdrawalDeposits( + options: Omit & { + count: number; + }, +): ReadyWithdrawalSelection | undefined { + const { count, ...selectionOptions } = options; + const selection = selectReadyWithdrawalDeposits({ + ...selectionOptions, + minCount: count, + maxCount: count, + }); + + return selection.deposits.length === count ? selection : undefined; +} + +export function selectReadyWithdrawalCleanupDeposit( + options: ReadyWithdrawalCleanupSelectionOptions, +): ReadyWithdrawalCleanupSelection | undefined { + const { + readyDeposits, + tip, + minAmountExclusive = 0n, + maxAmount, + } = options; + if (readyDeposits.length === 0 || maxAmount !== undefined && maxAmount <= 0n) { + return undefined; + } + + const { readyExtras } = classifyReadyDeposits(readyDeposits, [], tip); + const cleanup = readyExtras.find( + ({ deposit }) => + deposit.udtValue > minAmountExclusive && + (maxAmount === undefined || deposit.udtValue <= maxAmount), + ); + + return cleanup === undefined + ? undefined + : { deposit: cleanup.deposit, requiredLiveDeposit: cleanup.anchor }; +} + +function selectReadyDeposits( + deposits: readonly T[], + maxAmount: bigint, + options: { + minCount?: number; + maxCount?: number; + } = {}, +): T[] { + const { minCount = 1, maxCount = DEFAULT_MAX_WITHDRAWAL_REQUESTS } = options; + const requiredCount = Math.max(1, minCount); + if ( + maxAmount <= 0n || + maxCount <= 0 || + requiredCount > maxCount || + deposits.length === 0 + ) { + return []; + } + + const bestFit = selectBoundedUdtSubset(deposits, maxAmount, { + candidateLimit: BEST_FIT_SEARCH_CANDIDATES, + minCount: requiredCount, + maxCount, + }); + const greedy = selectGreedyDeposits( + deposits, + maxAmount, + maxCount, + requiredCount, + ); + + return pickBetterSelection(deposits, bestFit, greedy); +} + +function classifyReadyDeposits( + readyDeposits: readonly IckbDepositCell[], + nearReadyDeposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, +): { + extras: IckbDepositCell[]; + singletons: IckbDepositCell[]; + anchorsByExtra: Map; + readyExtras: ReadyExtra[]; +} { + const readyBuckets = new Map(); + const nearReadyBucketValues = new Map(); + + for (const deposit of sortByMaturity(readyDeposits, tip)) { + 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 sortByMaturity(nearReadyDeposits, tip)) { + 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 readyExtras = crowdedBuckets.flatMap((bucket) => + bucket.deposits + .filter((deposit) => deposit !== bucket.protectedDeposit) + .map((deposit) => ({ deposit, anchor: bucket.protectedDeposit })) + ); + + return { + extras: readyExtras.map(({ deposit }) => deposit), + singletons: singletonBuckets.flatMap((bucket) => bucket.deposits), + anchorsByExtra: new Map(readyExtras.map(({ deposit, anchor }) => [deposit, anchor])), + readyExtras, + }; +} + +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; +} + +function selectionWithRequiredAnchors( + deposits: IckbDepositCell[], + anchorsByExtra: ReadonlyMap, +): ReadyWithdrawalSelection { + 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 }; +} + +function selectGreedyDeposits( + deposits: readonly T[], + maxAmount: bigint, + maxCount: number, + minCount: number, +): T[] { + const selected: T[] = []; + let cumulative = 0n; + + for (const deposit of deposits) { + if (selected.length >= maxCount) { + break; + } + + if (cumulative + deposit.udtValue > maxAmount) { + continue; + } + + cumulative += deposit.udtValue; + selected.push(deposit); + } + + return selected.length >= minCount ? selected : []; +} + +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 futureRefillValueForBucket( + bucketKey: bigint, + nearReadyBucketValues: ReadonlyMap, +): bigint { + let total = 0n; + for (let offset = 1n; offset <= NEAR_READY_BUCKET_LOOKAHEAD; offset += 1n) { + total += nearReadyBucketValues.get(bucketKey + offset) ?? 0n; + } + return total; +} + +function sortByMaturity( + deposits: readonly T[], + tip: ccc.ClientBlockHeader, +): T[] { + return [...deposits].sort((left, right) => + compareBigInt(left.maturity.toUnix(tip), right.maturity.toUnix(tip)) + ); +} + +function sumUdtValue(deposits: readonly { udtValue: bigint }[]): bigint { + let total = 0n; + for (const deposit of deposits) { + total += deposit.udtValue; + } + return total; +} + +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); +} + +interface ReadyBucket { + key: bigint; + deposits: IckbDepositCell[]; + protectedDeposit: IckbDepositCell; + extraValue: bigint; + futureRefillValue: bigint; +} + +interface ReadyExtra { + deposit: IckbDepositCell; + anchor: IckbDepositCell; +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 80da643..84a843d 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -7,4 +7,5 @@ "sourceRoot": "../src" }, "include": ["src"], + "exclude": ["src/**/*.test.ts"] } From e0530fb1810be4960d21db876b7456fded6fe6bd Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 00:37:21 +0000 Subject: [PATCH 04/14] fix(sdk): prefer larger direct withdrawal plans --- packages/sdk/src/sdk.test.ts | 44 ++++++++++++++++++++++++++++++++++++ packages/sdk/src/sdk.ts | 41 ++++++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index ae4e43f..4d74604 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -1236,6 +1236,50 @@ describe("IckbSdk.buildConversionTransaction", () => { expect(mint).toHaveBeenCalledTimes(1); }); + it("prefers the largest direct iCKB-to-CKB withdrawal value", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const unit = ICKB_DEPOSIT_CAP / 10n; + const smallEarlier = readyDeposit(4n * unit, 0n); + const smallLater = readyDeposit(4n * unit, 15n * 60n * 1000n); + const large = readyDeposit(9n * unit, 30n * 60n * 1000n); + const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + expect(deposits).toEqual([large]); + return ccc.Transaction.from(txLike); + }); + const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike, _lock, _info, amounts) => { + expect(amounts).toEqual({ ckbValue: 0n, udtValue: unit }); + return ccc.Transaction.from(txLike); + }); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ickb-to-ckb", + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ + exchangeRatio: Ratio.from({ ckbScale: 100n, udtScale: 1n }), + ckbAvailable: 10n, + poolDeposits: { + deposits: [smallEarlier, smallLater, large], + readyDeposits: [smallEarlier, smallLater, large], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + }); + it("returns typed failures for no activity and tiny orders", async () => { const { sdk, lock } = testSdk(); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index d28a04b..70f2e48 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -777,6 +777,7 @@ export class IckbSdk { poolDeposits.readyDeposits.filter((deposit) => deposit.isReady), context.system.tip, ); + const plans: IckbToCkbConversionPlan[] = []; let lastFailure: ConversionTransactionFailureReason | undefined; let lastError: unknown; @@ -787,6 +788,7 @@ export class IckbSdk { ) { let estimatedMaturity = context.estimatedMaturity; let remainder = amount; + let directUdtValue = 0n; let selectedDeposits: IckbDepositCell[] = []; let requiredLiveDeposits: IckbDepositCell[] = []; let order: ConversionOrder | undefined; @@ -805,7 +807,8 @@ export class IckbSdk { } ({ deposits: selectedDeposits, requiredLiveDeposits } = selection); - remainder -= sumUdtValue(selectedDeposits); + directUdtValue = sumUdtValue(selectedDeposits); + remainder -= directUdtValue; for (const deposit of selectedDeposits) { estimatedMaturity = maxMaturity( estimatedMaturity, @@ -840,6 +843,28 @@ export class IckbSdk { continue; } + plans.push({ + directUdtValue, + estimatedMaturity, + order, + requiredLiveDeposits, + selectedDeposits, + }); + } + + plans.sort((left, right) => { + const directCompare = compareBigInt(right.directUdtValue, left.directUdtValue); + return directCompare !== 0 + ? directCompare + : right.selectedDeposits.length - left.selectedDeposits.length; + }); + + for (const { + estimatedMaturity, + order, + requiredLiveDeposits, + selectedDeposits, + } of plans) { try { let tx = await this.buildBaseTransaction( txLike, @@ -1035,6 +1060,7 @@ export class IckbSdk { 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( { @@ -1051,11 +1077,6 @@ export class IckbSdk { ), { limit, label: "bot capacity", context: lock }, )) { - if (cell.cellOutput.type !== undefined || !cell.cellOutput.lock.eq(lock)) { - continue; - } - - const key = cell.cellOutput.lock.toHex(); if (isPlainCapacityCell(cell)) { const ckb = (bot2Ckb.get(key) ?? reserved) + cell.cellOutput.capacity; @@ -1172,6 +1193,14 @@ interface ConversionOrder { conversionNotice?: ConversionNotice; } +interface IckbToCkbConversionPlan { + directUdtValue: bigint; + estimatedMaturity: bigint; + order?: ConversionOrder; + requiredLiveDeposits: IckbDepositCell[]; + selectedDeposits: IckbDepositCell[]; +} + function conversionFailure( reason: ConversionTransactionFailureReason, estimatedMaturity: bigint, From a678c2765b618a5b519e16c208ea595336d9f421 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 00:58:18 +0000 Subject: [PATCH 05/14] fix(sdk): balance withdrawal value with maturity --- packages/sdk/src/sdk.test.ts | 46 +++++++++++++++++++++++++++++++++++- packages/sdk/src/sdk.ts | 13 ++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 4d74604..616f760 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -1236,7 +1236,7 @@ describe("IckbSdk.buildConversionTransaction", () => { expect(mint).toHaveBeenCalledTimes(1); }); - it("prefers the largest direct iCKB-to-CKB withdrawal value", async () => { + it("prefers the largest direct iCKB-to-CKB withdrawal value within a maturity bucket", async () => { const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); const unit = ICKB_DEPOSIT_CAP / 10n; const smallEarlier = readyDeposit(4n * unit, 0n); @@ -1280,6 +1280,50 @@ describe("IckbSdk.buildConversionTransaction", () => { expect(mint).toHaveBeenCalledTimes(1); }); + it("prefers an earlier iCKB-to-CKB maturity bucket over a marginally larger withdrawal", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const unit = ICKB_DEPOSIT_CAP / 10n; + const smallEarlier = readyDeposit(4n * unit, 0n); + const smallLater = readyDeposit(4n * unit, 15n * 60n * 1000n); + const largeMuchLater = readyDeposit(9n * unit, 2n * 60n * 60n * 1000n); + const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + expect(deposits).toEqual([smallEarlier, smallLater]); + return ccc.Transaction.from(txLike); + }); + const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike, _lock, _info, amounts) => { + expect(amounts).toEqual({ ckbValue: 0n, udtValue: 2n * unit }); + return ccc.Transaction.from(txLike); + }); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ickb-to-ckb", + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ + exchangeRatio: Ratio.from({ ckbScale: 100n, udtScale: 1n }), + ckbAvailable: 10n, + poolDeposits: { + deposits: [smallEarlier, smallLater, largeMuchLater], + readyDeposits: [smallEarlier, smallLater, largeMuchLater], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + }); + it("returns typed failures for no activity and tiny orders", async () => { const { sdk, lock } = testSdk(); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 70f2e48..6fe7d8b 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -36,6 +36,7 @@ export const MAX_WITHDRAWAL_REQUESTS = 30; const DAO_OUTPUT_LIMIT = 64; const ORDER_MINT_OUTPUTS = 2; +const CONVERSION_MATURITY_BUCKET_MS = 60n * 60n * 1000n; type SleepScheduler = (handler: () => void, timeout?: number) => unknown; @@ -853,6 +854,14 @@ export class IckbSdk { } plans.sort((left, right) => { + const maturityCompare = compareBigInt( + maturityBucket(left.estimatedMaturity), + maturityBucket(right.estimatedMaturity), + ); + if (maturityCompare !== 0) { + return maturityCompare; + } + const directCompare = compareBigInt(right.directUdtValue, left.directUdtValue); return directCompare !== 0 ? directCompare @@ -1369,6 +1378,10 @@ function maxMaturity(left: bigint, right: bigint): bigint { return left > right ? left : right; } +function maturityBucket(maturity: bigint): bigint { + return maturity / CONVERSION_MATURITY_BUCKET_MS; +} + function orderGroupWithMaturity(group: OrderGroup, system: SystemState): OrderGroup { const { order } = group; return new OrderGroup( From a69b2d12d289b78b190f0cdd70fd0afe5986d4af Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 02:15:28 +0000 Subject: [PATCH 06/14] fix(sdk): order withdrawal plans by direct surplus --- packages/sdk/src/sdk.test.ts | 207 +++++++++++++++++- packages/sdk/src/sdk.ts | 189 +++++++++++----- packages/sdk/src/withdrawal_selection.test.ts | 47 ++++ packages/sdk/src/withdrawal_selection.ts | 105 ++++++++- packages/utils/src/utils.test.ts | 18 ++ packages/utils/src/utils.ts | 108 ++++++--- 6 files changed, 567 insertions(+), 107 deletions(-) diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 616f760..9462d0c 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -98,10 +98,14 @@ function orderGroup(options: { } as unknown as OrderGroup; } -function readyDeposit(udtValue: bigint, maturityUnix = 0n): IckbDepositCell { +function readyDeposit( + udtValue: bigint, + maturityUnix = 0n, + options: { ckbValue?: bigint; id?: string } = {}, +): IckbDepositCell { return { cell: ccc.Cell.from({ - outPoint: { txHash: hash("aa"), index: maturityUnix }, + outPoint: { txHash: hash(options.id ?? "aa"), index: maturityUnix }, cellOutput: { capacity: 0n, lock: script("22") }, outputData: "0x", }), @@ -109,7 +113,7 @@ function readyDeposit(udtValue: bigint, maturityUnix = 0n): IckbDepositCell { interests: 0n, isReady: true, isDeposit: true, - ckbValue: 0n, + ckbValue: options.ckbValue ?? udtValue, udtValue, maturity: { toUnix: (): bigint => maturityUnix }, } as unknown as IckbDepositCell; @@ -1236,20 +1240,25 @@ describe("IckbSdk.buildConversionTransaction", () => { expect(mint).toHaveBeenCalledTimes(1); }); - it("prefers the largest direct iCKB-to-CKB withdrawal value within a maturity bucket", async () => { + it("prefers better direct iCKB-to-CKB economic surplus within a maturity bucket", async () => { const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); const unit = ICKB_DEPOSIT_CAP / 10n; - const smallEarlier = readyDeposit(4n * unit, 0n); - const smallLater = readyDeposit(4n * unit, 15n * 60n * 1000n); - const large = readyDeposit(9n * unit, 30n * 60n * 1000n); + const largerLowerGain = readyDeposit(9n * unit, 0n, { + ckbValue: 9n * unit, + id: "a1", + }); + const smallerHigherGain = readyDeposit(8n * unit, 30n * 60n * 1000n, { + ckbValue: 8n * unit + 1000n, + id: "a2", + }); const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") .mockImplementation(async (txLike, deposits) => { await Promise.resolve(); - expect(deposits).toEqual([large]); + expect(deposits).toEqual([smallerHigherGain]); return ccc.Transaction.from(txLike); }); const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike, _lock, _info, amounts) => { - expect(amounts).toEqual({ ckbValue: 0n, udtValue: unit }); + expect(amounts).toEqual({ ckbValue: 0n, udtValue: 2n * unit }); return ccc.Transaction.from(txLike); }); @@ -1259,11 +1268,11 @@ describe("IckbSdk.buildConversionTransaction", () => { lock, context: { system: system({ - exchangeRatio: Ratio.from({ ckbScale: 100n, udtScale: 1n }), + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), ckbAvailable: 10n, poolDeposits: { - deposits: [smallEarlier, smallLater, large], - readyDeposits: [smallEarlier, smallLater, large], + deposits: [largerLowerGain, smallerHigherGain], + readyDeposits: [largerLowerGain, smallerHigherGain], id: "pool", }, }), @@ -1324,6 +1333,96 @@ describe("IckbSdk.buildConversionTransaction", () => { expect(mint).toHaveBeenCalledTimes(1); }); + it("preserves iCKB-to-CKB maturity-bucket priority before direct surplus", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const unit = ICKB_DEPOSIT_CAP / 10n; + const earlier = readyDeposit(8n * unit, 30n * 60n * 1000n, { + ckbValue: 8n * unit, + id: "b1", + }); + const laterHigherGain = readyDeposit(8n * unit, 2n * 60n * 60n * 1000n, { + ckbValue: 8n * unit + 1000n, + id: "b2", + }); + const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + expect(deposits).toEqual([earlier]); + return ccc.Transaction.from(txLike); + }); + const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike) => + ccc.Transaction.from(txLike) + ); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ickb-to-ckb", + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + ckbAvailable: 10n, + poolDeposits: { + deposits: [laterHigherGain, earlier], + readyDeposits: [laterHigherGain, earlier], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + }); + + it("skips iCKB-to-CKB deposits above the requested amount even with high surplus", async () => { + const { sdk, ownedOwnerManager, lock } = testSdk(); + const oversized = readyDeposit(ICKB_DEPOSIT_CAP + 1n, 0n, { + ckbValue: ICKB_DEPOSIT_CAP * 2n, + id: "c1", + }); + const fitting = readyDeposit(ICKB_DEPOSIT_CAP, 15n * 60n * 1000n, { + ckbValue: ICKB_DEPOSIT_CAP, + id: "c2", + }); + const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + expect(deposits).toEqual([fitting]); + return ccc.Transaction.from(txLike); + }); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ickb-to-ckb", + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + poolDeposits: { + deposits: [oversized, fitting], + readyDeposits: [oversized, fitting], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct" } }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + }); + it("returns typed failures for no activity and tiny orders", async () => { const { sdk, lock } = testSdk(); @@ -1504,6 +1603,51 @@ describe("IckbSdk.buildConversionTransaction", () => { expect(mint).not.toHaveBeenCalled(); }); + it("does not count input-only base activities as planned DAO outputs", async () => { + const { sdk, logicManager, ownedOwnerManager, orderManager, lock } = testSdk(); + vi.spyOn(orderManager, "melt").mockImplementation((txLike) => { + const tx = ccc.Transaction.from(txLike); + tx.inputs.push(ccc.CellInput.from({ previousOutput: { txHash: hash("c1"), index: 0n } })); + return tx; + }); + vi.spyOn(logicManager, "completeDeposit").mockImplementation((txLike) => { + const tx = ccc.Transaction.from(txLike); + tx.inputs.push(ccc.CellInput.from({ previousOutput: { txHash: hash("c2"), index: 0n } })); + return tx; + }); + vi.spyOn(ownedOwnerManager, "withdraw").mockImplementation(async (txLike) => { + await Promise.resolve(); + const tx = ccc.Transaction.from(txLike); + tx.inputs.push(ccc.CellInput.from({ previousOutput: { txHash: hash("c3"), index: 0n } })); + return tx; + }); + const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( + async (txLike) => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + ); + const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect(sdk.buildConversionTransaction(transactionWithOutputs(62, lock), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), + receipts: [{} as ReceiptCell], + readyWithdrawals: [{} as WithdrawalGroup], + availableOrders: [{} as OrderGroup], + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct" } }); + + expect(deposit).toHaveBeenCalledTimes(1); + expect(mint).not.toHaveBeenCalled(); + }); + it("preserves retryable construction errors when retries exhaust into planning misses", async () => { const { sdk, logicManager, lock } = testSdk(); vi.spyOn(logicManager, "deposit").mockRejectedValue(new DaoOutputLimitError(65)); @@ -1526,6 +1670,26 @@ describe("IckbSdk.buildConversionTransaction", () => { }, })).rejects.toBeInstanceOf(DaoOutputLimitError); }); + + it("uses plain-object error messages in conversion construction failures", async () => { + const { sdk, logicManager, lock } = testSdk(); + vi.spyOn(logicManager, "deposit").mockRejectedValue({ message: "RPC failed" }); + + await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + })).rejects.toThrow("RPC failed"); + }); }); describe("completeIckbTransaction", () => { @@ -2187,6 +2351,25 @@ describe("IckbSdk.getL1State snapshot detection", () => { }); }); + it("passes a custom pool deposit scan limit through L1 state loading", async () => { + const { sdk, logicManager } = testSdk(); + const findDeposits = vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); + const client = { + getTipHeader: () => Promise.resolve(tip), + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => none(), + } as unknown as ccc.Client; + const poolDepositLimit = defaultFindCellsLimit + 100; + + await sdk.getL1State(client, [], { poolDepositLimit }); + + expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ + onChain: true, + tip, + limit: poolDepositLimit, + }); + }); + it("passes a custom order scan limit through L1 state loading", async () => { const logic = script("22"); const dao = script("33"); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 6fe7d8b..a7e0bc5 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -29,7 +29,9 @@ import { Ratio, } from "@ickb/order"; import { getConfig } from "./constants.js"; -import { selectExactReadyWithdrawalDeposits } from "./withdrawal_selection.js"; +import { + selectExactReadyWithdrawalDepositCandidates, +} from "./withdrawal_selection.js"; export const MAX_DIRECT_DEPOSITS = 60; export const MAX_WITHDRAWAL_REQUESTS = 30; @@ -119,6 +121,11 @@ export interface SendAndWaitForCommitOptions { export interface GetL1StateOptions { orderLimit?: number; + poolDepositLimit?: number; +} + +export interface GetL1AccountStateOptions extends GetL1StateOptions { + accountLimit?: number; } export interface IckbToCkbOrderEstimate { @@ -779,6 +786,8 @@ export class IckbSdk { context.system.tip, ); const plans: IckbToCkbConversionPlan[] = []; + const score = (deposit: IckbDepositCell): bigint => + directWithdrawalSurplus(deposit, context.system.exchangeRatio); let lastFailure: ConversionTransactionFailureReason | undefined; let lastError: unknown; @@ -787,70 +796,83 @@ export class IckbSdk { withdrawalCount >= 0; withdrawalCount -= 1 ) { - let estimatedMaturity = context.estimatedMaturity; - let remainder = amount; - let directUdtValue = 0n; - let selectedDeposits: IckbDepositCell[] = []; - let requiredLiveDeposits: IckbDepositCell[] = []; - let order: ConversionOrder | undefined; - - if (withdrawalCount > 0) { - const selection = selectExactReadyWithdrawalDeposits({ + const selections = withdrawalCount === 0 + ? [{ deposits: [], requiredLiveDeposits: [] }] + : selectExactReadyWithdrawalDepositCandidates({ readyDeposits: candidates, tip: context.system.tip, - maxAmount: remainder, + maxAmount: amount, count: withdrawalCount, preserveSingletons: amount < ICKB_DEPOSIT_CAP, + score, + maturityBucket: (deposit) => + maturityBucket(deposit.maturity.toUnix(context.system.tip)), }); - if (selection === undefined) { - lastFailure = "not-enough-ready-deposits"; - continue; - } + if (withdrawalCount > 0 && selections.length === 0) { + lastFailure = "not-enough-ready-deposits"; + continue; + } - ({ deposits: selectedDeposits, requiredLiveDeposits } = selection); - directUdtValue = sumUdtValue(selectedDeposits); - remainder -= directUdtValue; - for (const deposit of selectedDeposits) { - estimatedMaturity = maxMaturity( - estimatedMaturity, - deposit.maturity.toUnix(context.system.tip), + for (const selection of selections) { + let estimatedMaturity = context.estimatedMaturity; + let remainder = amount; + let directUdtValue = 0n; + let directSurplusCkb = 0n; + let selectedDeposits: IckbDepositCell[] = []; + let requiredLiveDeposits: IckbDepositCell[] = []; + let order: ConversionOrder | undefined; + + if (withdrawalCount > 0) { + ({ deposits: selectedDeposits, requiredLiveDeposits } = selection); + directUdtValue = sumUdtValue(selectedDeposits); + directSurplusCkb = sumDirectWithdrawalSurplus( + selectedDeposits, + context.system.exchangeRatio, ); + remainder -= directUdtValue; + for (const deposit of selectedDeposits) { + estimatedMaturity = maxMaturity( + estimatedMaturity, + deposit.maturity.toUnix(context.system.tip), + ); + } } - } - if (remainder > 0n) { - const amounts = { ckbValue: 0n, udtValue: remainder }; - const preview = IckbSdk.estimateIckbToCkbOrder(amounts, context.system); - if (!preview) { - lastFailure = "amount-too-small"; - continue; + if (remainder > 0n) { + const amounts = { ckbValue: 0n, udtValue: remainder }; + const preview = IckbSdk.estimateIckbToCkbOrder(amounts, context.system); + if (!preview) { + lastFailure = "amount-too-small"; + continue; + } + + const { estimate, maturity, notice } = preview; + if (maturity !== undefined) { + estimatedMaturity = maxMaturity(estimatedMaturity, maturity); + } + order = { amounts, estimate, conversionNotice: notice }; } - const { estimate, maturity, notice } = preview; - if (maturity !== undefined) { - estimatedMaturity = maxMaturity(estimatedMaturity, maturity); + const outputLimitError = plannedDaoOutputLimitError( + txLike, + selectedDeposits.length * 2 + orderOutputCount(order), + selectedDeposits.length > 0 || context.readyWithdrawals.length > 0, + ); + if (outputLimitError) { + lastFailure = undefined; + lastError ??= outputLimitError; + continue; } - order = { amounts, estimate, conversionNotice: notice }; - } - const outputLimitError = plannedDaoOutputLimitError( - txLike, - selectedDeposits.length * 2 + orderOutputCount(order), - selectedDeposits.length > 0 || context.readyWithdrawals.length > 0, - ); - if (outputLimitError) { - lastFailure = undefined; - lastError ??= outputLimitError; - continue; + plans.push({ + directSurplusCkb, + directUdtValue, + estimatedMaturity, + order, + requiredLiveDeposits, + selectedDeposits, + }); } - - plans.push({ - directUdtValue, - estimatedMaturity, - order, - requiredLiveDeposits, - selectedDeposits, - }); } plans.sort((left, right) => { @@ -862,6 +884,17 @@ export class IckbSdk { return maturityCompare; } + const directPresenceCompare = Number(right.selectedDeposits.length > 0) - + Number(left.selectedDeposits.length > 0); + if (directPresenceCompare !== 0) { + return directPresenceCompare; + } + + const surplusCompare = compareBigInt(right.directSurplusCkb, left.directSurplusCkb); + if (surplusCompare !== 0) { + return surplusCompare; + } + const directCompare = compareBigInt(right.directUdtValue, left.directUdtValue); return directCompare !== 0 ? directCompare @@ -919,9 +952,10 @@ export class IckbSdk { client: ccc.Client, locks: ccc.Script[], tip: ccc.ClientBlockHeader, + options?: { limit?: number }, ): Promise { const [cells, receipts, withdrawalGroups] = await Promise.all([ - this.findAccountCells(client, locks), + this.findAccountCells(client, locks, options), collect(this.ickbLogic.findReceipts(client, locks, { onChain: true })), collect( this.ownedOwner.findWithdrawalGroups(client, locks, { @@ -949,14 +983,16 @@ export class IckbSdk { async getL1AccountState( client: ccc.Client, locks: ccc.Script[], - options?: GetL1StateOptions, + options?: GetL1AccountStateOptions, ): Promise<{ system: SystemState; user: { orders: OrderGroup[] }; account: AccountState; }> { const { system, user } = await this.getL1State(client, locks, options); - const account = await this.getAccountState(client, locks, system.tip); + const account = await this.getAccountState(client, locks, system.tip, { + limit: options?.accountLimit, + }); await this.assertCurrentTip(client, system.tip); return { system, user, account }; @@ -987,7 +1023,7 @@ export class IckbSdk { // Parallel fetching of system components. const [poolDeposits, orders, feeRate] = await Promise.all([ - this.getPoolDeposits(client, tip), + this.getPoolDeposits(client, tip, { limit: options?.poolDepositLimit }), collect(this.order.findOrders(client, { onChain: true, limit: options?.orderLimit ?? defaultFindCellsLimit, @@ -1160,9 +1196,10 @@ export class IckbSdk { private async findAccountCells( client: ccc.Client, locks: ccc.Script[], + options?: { limit?: number }, ): Promise { const cells: ccc.Cell[] = []; - const limit = defaultFindCellsLimit; + const limit = options?.limit ?? defaultFindCellsLimit; for (const lock of unique(locks)) { cells.push(...await collectCompleteScan( (scanLimit) => client.findCellsOnChain( @@ -1203,6 +1240,7 @@ interface ConversionOrder { } interface IckbToCkbConversionPlan { + directSurplusCkb: bigint; directUdtValue: bigint; estimatedMaturity: bigint; order?: ConversionOrder; @@ -1247,7 +1285,29 @@ function hasTransactionActivity(tx: ccc.Transaction): boolean { } function errorOf(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); + if (error instanceof Error) { + return error; + } + + const message = errorMessage(error); + return new Error(message, { cause: error }); +} + +function errorMessage(error: unknown): string { + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) { + return error.message; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } } function isRetryableConversionBuildError(error: unknown): boolean { @@ -1341,6 +1401,21 @@ function sumUdtValue(deposits: readonly IckbDepositCell[]): bigint { return total; } +function sumDirectWithdrawalSurplus( + deposits: readonly IckbDepositCell[], + exchangeRatio: Ratio, +): bigint { + let total = 0n; + for (const deposit of deposits) { + total += directWithdrawalSurplus(deposit, exchangeRatio); + } + return total; +} + +function directWithdrawalSurplus(deposit: IckbDepositCell, exchangeRatio: Ratio): bigint { + return deposit.ckbValue - convert(false, deposit.udtValue, exchangeRatio); +} + function poolDepositsKey( deposits: readonly IckbDepositCell[], tip: ccc.ClientBlockHeader, diff --git a/packages/sdk/src/withdrawal_selection.test.ts b/packages/sdk/src/withdrawal_selection.test.ts index d262d07..754d3b2 100644 --- a/packages/sdk/src/withdrawal_selection.test.ts +++ b/packages/sdk/src/withdrawal_selection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { type IckbDepositCell } from "@ickb/core"; import { + selectExactReadyWithdrawalDepositCandidates, selectExactReadyWithdrawalDeposits, selectReadyWithdrawalCleanupDeposit, selectReadyWithdrawalDeposits, @@ -21,6 +22,16 @@ function readyDeposit( } as unknown as IckbDepositCell; } +function scoredReadyDeposit( + udtValue: bigint, + maturityUnix: bigint, + score: bigint, +): IckbDepositCell & { score: bigint } { + const deposit = readyDeposit(udtValue, maturityUnix) as IckbDepositCell & { score: bigint }; + deposit.score = score; + return deposit; +} + describe("selectReadyWithdrawalDeposits", () => { it("prefers the fullest valid subset under the target amount", () => { const deposits = [ @@ -147,6 +158,25 @@ describe("selectReadyWithdrawalDeposits", () => { ).toEqual([deposits[0], deposits[1]]); }); + it("uses an opt-in score for exact-count selection", () => { + const fullerFirst = scoredReadyDeposit(6n, 0n, 1n); + const fullerSecond = scoredReadyDeposit(4n, 15n * 60n * 1000n, 1n); + const scoredFirst = scoredReadyDeposit(3n, 30n * 60n * 1000n, 5n); + const scoredSecond = scoredReadyDeposit(3n, 45n * 60n * 1000n, 5n); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [fullerFirst, fullerSecond, scoredFirst, scoredSecond], + tip: TIP, + maxAmount: 10n, + minCount: 2, + maxCount: 2, + preserveSingletons: false, + score: (deposit) => (deposit as IckbDepositCell & { score: bigint }).score, + }).deposits, + ).toEqual([scoredFirst, scoredSecond]); + }); + it("pins protected crowded anchors for selected extras", () => { const extra = readyDeposit(4n, 20n * 60n * 1000n); const protectedAnchor = readyDeposit(6n, 25n * 60n * 1000n); @@ -259,6 +289,23 @@ describe("selectExactReadyWithdrawalDeposits", () => { }), ).toBeUndefined(); }); + + it("returns scored and unscored candidates for each maturity bucket", () => { + const earlier = scoredReadyDeposit(8n, 30n * 60n * 1000n, 1n); + const laterHigherScore = scoredReadyDeposit(8n, 2n * 60n * 60n * 1000n, 2n); + + expect( + selectExactReadyWithdrawalDepositCandidates({ + readyDeposits: [laterHigherScore, earlier], + tip: TIP, + maxAmount: 10n, + count: 1, + preserveSingletons: false, + score: (deposit) => (deposit as IckbDepositCell & { score: bigint }).score, + maturityBucket: (deposit) => deposit.maturity.toUnix(TIP) / (60n * 60n * 1000n), + }).map((selection) => selection.deposits), + ).toEqual([[earlier], [laterHigherScore]]); + }); }); describe("selectReadyWithdrawalCleanupDeposit", () => { diff --git a/packages/sdk/src/withdrawal_selection.ts b/packages/sdk/src/withdrawal_selection.ts index 4386768..7bb9891 100644 --- a/packages/sdk/src/withdrawal_selection.ts +++ b/packages/sdk/src/withdrawal_selection.ts @@ -26,6 +26,7 @@ export interface ReadyWithdrawalSelectionOptions { minCount?: number; maxCount?: number; preserveSingletons?: boolean; + score?: (deposit: IckbDepositCell) => bigint; } export interface ReadyWithdrawalCleanupSelectionOptions { @@ -46,6 +47,7 @@ export function selectReadyWithdrawalDeposits( minCount = 1, maxCount = DEFAULT_MAX_WITHDRAWAL_REQUESTS, preserveSingletons = true, + score, } = options; const requiredCount = Math.max(1, minCount); if ( @@ -65,6 +67,7 @@ export function selectReadyWithdrawalDeposits( const selectedExtras = selectReadyDeposits(extras, maxAmount, { maxCount, minCount: preserveSingletons ? requiredCount : 1, + score, }); if (selectedExtras.length > 0) { if (preserveSingletons) { @@ -80,6 +83,7 @@ export function selectReadyWithdrawalDeposits( { maxCount: remainingCount, minCount: remainingRequiredCount, + score, }, ); if (selectedSingletons.length === 0 && selectedExtras.length >= requiredCount) { @@ -105,6 +109,7 @@ export function selectReadyWithdrawalDeposits( const selectedSingletons = selectReadyDeposits(singletons, maxAmount, { maxCount, minCount: requiredCount, + score, }); if (selectedSingletons.length > 0) { return { deposits: selectedSingletons, requiredLiveDeposits: [] }; @@ -114,6 +119,7 @@ export function selectReadyWithdrawalDeposits( selectReadyDeposits(sortByMaturity(readyDeposits, tip), maxAmount, { maxCount, minCount: requiredCount, + score, }), anchorsByExtra, ); @@ -134,6 +140,52 @@ export function selectExactReadyWithdrawalDeposits( return selection.deposits.length === count ? selection : undefined; } +export function selectExactReadyWithdrawalDepositCandidates( + options: Omit[0], "score"> & { + score: (deposit: IckbDepositCell) => bigint; + maturityBucket: (deposit: IckbDepositCell) => bigint; + }, +): ReadyWithdrawalSelection[] { + const selections: ReadyWithdrawalSelection[] = []; + const seen = new Set(); + const indexByDeposit = new Map( + options.readyDeposits.map((deposit, index) => [deposit, index] as const), + ); + const addSelection = (selection: ReadyWithdrawalSelection | undefined): void => { + if (selection === undefined) { + return; + } + + const key = selectionKey(selection.deposits, indexByDeposit); + if (seen.has(key)) { + return; + } + + seen.add(key); + selections.push(selection); + }; + + for (const bucket of uniqueBuckets(options.readyDeposits, options.maturityBucket)) { + const readyDeposits = options.readyDeposits.filter( + (deposit) => options.maturityBucket(deposit) <= bucket, + ); + const baseOptions = { + readyDeposits, + tip: options.tip, + maxAmount: options.maxAmount, + count: options.count, + preserveSingletons: options.preserveSingletons, + }; + addSelection(selectExactReadyWithdrawalDeposits({ + ...baseOptions, + score: options.score, + })); + addSelection(selectExactReadyWithdrawalDeposits(baseOptions)); + } + + return selections; +} + export function selectReadyWithdrawalCleanupDeposit( options: ReadyWithdrawalCleanupSelectionOptions, ): ReadyWithdrawalCleanupSelection | undefined { @@ -165,9 +217,10 @@ function selectReadyDeposits( options: { minCount?: number; maxCount?: number; + score?: (deposit: T) => bigint; } = {}, ): T[] { - const { minCount = 1, maxCount = DEFAULT_MAX_WITHDRAWAL_REQUESTS } = options; + const { minCount = 1, maxCount = DEFAULT_MAX_WITHDRAWAL_REQUESTS, score } = options; const requiredCount = Math.max(1, minCount); if ( maxAmount <= 0n || @@ -182,15 +235,17 @@ function selectReadyDeposits( candidateLimit: BEST_FIT_SEARCH_CANDIDATES, minCount: requiredCount, maxCount, + ...(score ? { score } : {}), }); const greedy = selectGreedyDeposits( deposits, maxAmount, maxCount, requiredCount, + score, ); - return pickBetterSelection(deposits, bestFit, greedy); + return pickBetterSelection(deposits, bestFit, greedy, score); } function classifyReadyDeposits( @@ -305,11 +360,15 @@ function selectGreedyDeposits( maxAmount: bigint, maxCount: number, minCount: number, + score?: (deposit: T) => bigint, ): T[] { const selected: T[] = []; + const candidates = score + ? [...deposits].sort((left, right) => compareBigInt(score(right), score(left))) + : deposits; let cumulative = 0n; - for (const deposit of deposits) { + for (const deposit of candidates) { if (selected.length >= maxCount) { break; } @@ -329,7 +388,28 @@ function pickBetterSelection( deposits: readonly T[], left: T[], right: T[], + score?: (deposit: T) => bigint, ): T[] { + if (left.length === 0) { + return right; + } + + if (right.length === 0) { + return left; + } + + if (score) { + const leftScore = sumScore(left, score); + const rightScore = sumScore(right, score); + if (leftScore > rightScore) { + return left; + } + + if (rightScore > leftScore) { + return right; + } + } + const leftTotal = sumUdtValue(left); const rightTotal = sumUdtValue(right); if (leftTotal > rightTotal) { @@ -343,6 +423,25 @@ function pickBetterSelection( return compareSelectionOrder(deposits, left, right) <= 0 ? left : right; } +function sumScore(deposits: readonly T[], score: (deposit: T) => bigint): bigint { + let total = 0n; + for (const deposit of deposits) { + total += score(deposit); + } + return total; +} + +function uniqueBuckets(items: readonly T[], bucket: (item: T) => bigint): bigint[] { + return [...new Set(items.map(bucket))].sort(compareBigInt); +} + +function selectionKey(items: readonly T[], indexByItem: ReadonlyMap): string { + return items + .map((item) => String(indexByItem.get(item))) + .sort() + .join(","); +} + function compareSelectionOrder( deposits: readonly T[], left: readonly T[], diff --git a/packages/utils/src/utils.test.ts b/packages/utils/src/utils.test.ts index 4178328..a0558ef 100644 --- a/packages/utils/src/utils.test.ts +++ b/packages/utils/src/utils.test.ts @@ -145,6 +145,24 @@ describe("selectBoundedUdtSubset", () => { )).toEqual([firstSix, firstFour]); }); + it("uses opt-in score before fullness", () => { + const fullerFirst = { udtValue: 9n, score: 1n }; + const fullerSecond = { udtValue: 1n, score: 1n }; + const scoredFirst = { udtValue: 4n, score: 5n }; + const scoredSecond = { udtValue: 4n, score: 5n }; + + expect(selectBoundedUdtSubset( + [fullerFirst, fullerSecond, scoredFirst, scoredSecond], + 10n, + { + candidateLimit: 32, + minCount: 2, + maxCount: 2, + score: (deposit) => deposit.score, + }, + )).toEqual([scoredFirst, scoredSecond]); + }); + it("bounds the search to the requested candidate limit", () => { const deposits = [ ...Array.from({ length: 32 }, () => ({ udtValue: 6n })), diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index b1ef172..167bd6c 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -238,9 +238,11 @@ export function selectBoundedUdtSubset( candidateLimit: number; minCount: number; maxCount: number; + score?: (item: T) => bigint; }, ): T[] { const { candidateLimit, minCount, maxCount } = options; + const scoreOf = options.score ?? ((item: T): bigint => item.udtValue); const boundedItems = items.slice(0, candidateLimit); const effectiveMaxCount = Math.min(maxCount, boundedItems.length); if ( @@ -255,6 +257,7 @@ export function selectBoundedUdtSubset( interface PartialSelection { mask: number; total: bigint; + score: bigint; } const split = Math.floor(boundedItems.length / 2); @@ -274,30 +277,35 @@ export function selectBoundedUdtSubset( mask: number, count: number, total: bigint, + score: bigint, ): void => { if (index === half.length) { - groups[count]?.push({ mask, total }); + groups[count]?.push({ mask, total, score }); return; } - search(index + 1, mask, count, total); + search(index + 1, mask, count, total, score); const item = half[index]; if (item === undefined) { return; } - search(index + 1, mask | (1 << index), count + 1, total + item.udtValue); + search( + index + 1, + mask | (1 << index), + count + 1, + total + item.udtValue, + score + scoreOf(item), + ); }; - search(0, 0, 0, 0n); + search(0, 0, 0, 0n, 0n); return groups; }; - const firstByCount = enumerate(firstHalf).map((selections) => - compressSelections(selections, firstHalf.length) - ); + const firstByCount = enumerate(firstHalf); const secondByCount = enumerate(secondHalf).map((selections) => - compressSelections(selections, secondHalf.length) + prepareSelections(selections, secondHalf.length) ); let best: @@ -305,6 +313,7 @@ export function selectBoundedUdtSubset( firstMask: number; secondMask: number; total: bigint; + score: bigint; } | undefined; @@ -325,22 +334,14 @@ export function selectBoundedUdtSubset( } const total = first.total + second.total; - if (!best || total > best.total) { - best = { firstMask: first.mask, secondMask: second.mask, total }; - continue; - } - - if (total < best.total) { - continue; - } - - const firstCompare = compareMask(first.mask, best.firstMask, firstHalf.length); - if ( - firstCompare < 0 || - (firstCompare === 0 && - compareMask(second.mask, best.secondMask, secondHalf.length) < 0) - ) { - best = { firstMask: first.mask, secondMask: second.mask, total }; + const candidate = { + firstMask: first.mask, + secondMask: second.mask, + total, + score: first.score + second.score, + }; + if (!best || isBetterSelection(candidate, best, firstHalf.length, secondHalf.length)) { + best = candidate; } } } @@ -361,10 +362,10 @@ function assertBitmaskSearchSize(length: number): void { } } -function compressSelections( - selections: { mask: number; total: bigint }[], +function prepareSelections( + selections: { mask: number; total: bigint; score: bigint }[], length: number, -): { mask: number; total: bigint }[] { +): { total: bigint; selection: { mask: number; total: bigint; score: bigint } }[] { selections.sort((left, right) => { const totalCompare = compareBigInt(left.total, right.total); if (totalCompare !== 0) { @@ -374,20 +375,22 @@ function compressSelections( return compareMask(left.mask, right.mask, length); }); - const compressed: { mask: number; total: bigint }[] = []; + const prepared: { total: bigint; selection: { mask: number; total: bigint; score: bigint } }[] = []; + let best: { mask: number; total: bigint; score: bigint } | undefined; for (const selection of selections) { - if (compressed.at(-1)?.total !== selection.total) { - compressed.push(selection); + if (!best || isBetterPartialSelection(selection, best, length)) { + best = selection; } + prepared.push({ total: selection.total, selection: best }); } - return compressed; + return prepared; } -function findBestAtOrBelow( - items: readonly T[], +function findBestAtOrBelow( + items: readonly { total: bigint; selection: { mask: number; total: bigint; score: bigint } }[], limit: bigint, -): T | undefined { +): { mask: number; total: bigint; score: bigint } | undefined { let low = 0; let high = items.length - 1; let bestIndex = -1; @@ -407,7 +410,42 @@ function findBestAtOrBelow( } } - return bestIndex >= 0 ? items[bestIndex] : undefined; + return bestIndex >= 0 ? items[bestIndex]?.selection : undefined; +} + +function isBetterPartialSelection( + left: { mask: number; total: bigint; score: bigint }, + right: { mask: number; total: bigint; score: bigint }, + length: number, +): boolean { + if (left.score !== right.score) { + return left.score > right.score; + } + + if (left.total !== right.total) { + return left.total > right.total; + } + + return compareMask(left.mask, right.mask, length) < 0; +} + +function isBetterSelection( + left: { firstMask: number; secondMask: number; total: bigint; score: bigint }, + right: { firstMask: number; secondMask: number; total: bigint; score: bigint }, + firstLength: number, + secondLength: number, +): boolean { + if (left.score !== right.score) { + return left.score > right.score; + } + + if (left.total !== right.total) { + return left.total > right.total; + } + + const firstCompare = compareMask(left.firstMask, right.firstMask, firstLength); + return firstCompare < 0 || + (firstCompare === 0 && compareMask(left.secondMask, right.secondMask, secondLength) < 0); } function selectByMasks(items: readonly T[], mask: number): T[] { From f8d16706468002a5de55d418e40e2eb8fac199d0 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 02:59:59 +0000 Subject: [PATCH 07/14] fix(sdk): compare maturing ckb as bigint --- packages/sdk/src/sdk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index a7e0bc5..ba8a323 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -1177,7 +1177,7 @@ export class IckbSdk { } // Sort maturing CKB entries by their maturity timestamp. - ckbMaturing.sort((a, b) => Number(a.maturity - b.maturity)); + ckbMaturing.sort((a, b) => compareBigInt(a.maturity, b.maturity)); // Calculate cumulative maturing CKB values. let cumulative = 0n; From e1c705aa31f31e6016c1ed39f5f6cb53cfac989b Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 03:16:15 +0000 Subject: [PATCH 08/14] fix(sdk): keep withdrawal scoring internal --- packages/sdk/src/index.ts | 12 +++++- packages/sdk/src/withdrawal_selection.test.ts | 41 ++++++++++--------- packages/sdk/src/withdrawal_selection.ts | 40 ++++++++++++++---- 3 files changed, 65 insertions(+), 28 deletions(-) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f1ac58a..e78e39e 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,3 +1,13 @@ export * from "./sdk.js"; export * from "./constants.js"; -export * from "./withdrawal_selection.js"; +export { + selectExactReadyWithdrawalDeposits, + selectReadyWithdrawalCleanupDeposit, + selectReadyWithdrawalDeposits, +} from "./withdrawal_selection.js"; +export type { + ReadyWithdrawalCleanupSelection, + ReadyWithdrawalCleanupSelectionOptions, + ReadyWithdrawalSelection, + ReadyWithdrawalSelectionOptions, +} from "./withdrawal_selection.js"; diff --git a/packages/sdk/src/withdrawal_selection.test.ts b/packages/sdk/src/withdrawal_selection.test.ts index 754d3b2..832aa0c 100644 --- a/packages/sdk/src/withdrawal_selection.test.ts +++ b/packages/sdk/src/withdrawal_selection.test.ts @@ -158,25 +158,6 @@ describe("selectReadyWithdrawalDeposits", () => { ).toEqual([deposits[0], deposits[1]]); }); - it("uses an opt-in score for exact-count selection", () => { - const fullerFirst = scoredReadyDeposit(6n, 0n, 1n); - const fullerSecond = scoredReadyDeposit(4n, 15n * 60n * 1000n, 1n); - const scoredFirst = scoredReadyDeposit(3n, 30n * 60n * 1000n, 5n); - const scoredSecond = scoredReadyDeposit(3n, 45n * 60n * 1000n, 5n); - - expect( - selectReadyWithdrawalDeposits({ - readyDeposits: [fullerFirst, fullerSecond, scoredFirst, scoredSecond], - tip: TIP, - maxAmount: 10n, - minCount: 2, - maxCount: 2, - preserveSingletons: false, - score: (deposit) => (deposit as IckbDepositCell & { score: bigint }).score, - }).deposits, - ).toEqual([scoredFirst, scoredSecond]); - }); - it("pins protected crowded anchors for selected extras", () => { const extra = readyDeposit(4n, 20n * 60n * 1000n); const protectedAnchor = readyDeposit(6n, 25n * 60n * 1000n); @@ -306,6 +287,28 @@ describe("selectExactReadyWithdrawalDeposits", () => { }).map((selection) => selection.deposits), ).toEqual([[earlier], [laterHigherScore]]); }); + + it("uses an SDK-owned score for conversion candidates", () => { + const fullerFirst = scoredReadyDeposit(6n, 0n, 1n); + const fullerSecond = scoredReadyDeposit(4n, 15n * 60n * 1000n, 1n); + const scoredFirst = scoredReadyDeposit(3n, 30n * 60n * 1000n, 5n); + const scoredSecond = scoredReadyDeposit(3n, 45n * 60n * 1000n, 5n); + + expect( + selectExactReadyWithdrawalDepositCandidates({ + readyDeposits: [fullerFirst, fullerSecond, scoredFirst, scoredSecond], + tip: TIP, + maxAmount: 10n, + count: 2, + preserveSingletons: false, + score: (deposit) => (deposit as IckbDepositCell & { score: bigint }).score, + maturityBucket: () => 0n, + }).map((selection) => selection.deposits), + ).toEqual([ + [scoredFirst, scoredSecond], + [fullerFirst, fullerSecond], + ]); + }); }); describe("selectReadyWithdrawalCleanupDeposit", () => { diff --git a/packages/sdk/src/withdrawal_selection.ts b/packages/sdk/src/withdrawal_selection.ts index 7bb9891..8dfa9e0 100644 --- a/packages/sdk/src/withdrawal_selection.ts +++ b/packages/sdk/src/withdrawal_selection.ts @@ -26,7 +26,6 @@ export interface ReadyWithdrawalSelectionOptions { minCount?: number; maxCount?: number; preserveSingletons?: boolean; - score?: (deposit: IckbDepositCell) => bigint; } export interface ReadyWithdrawalCleanupSelectionOptions { @@ -36,8 +35,29 @@ export interface ReadyWithdrawalCleanupSelectionOptions { maxAmount?: bigint; } +type ScoredReadyWithdrawalSelectionOptions = ReadyWithdrawalSelectionOptions & { + score?: (deposit: IckbDepositCell) => bigint; +}; + +type ExactReadyWithdrawalSelectionOptions = Omit< + ReadyWithdrawalSelectionOptions, + "minCount" | "maxCount" +> & { + count: number; +}; + +type ScoredExactReadyWithdrawalSelectionOptions = ExactReadyWithdrawalSelectionOptions & { + score?: (deposit: IckbDepositCell) => bigint; +}; + export function selectReadyWithdrawalDeposits( options: ReadyWithdrawalSelectionOptions, +): ReadyWithdrawalSelection { + return selectReadyWithdrawalDepositsWithScore(options); +} + +function selectReadyWithdrawalDepositsWithScore( + options: ScoredReadyWithdrawalSelectionOptions, ): ReadyWithdrawalSelection { const { tip, @@ -126,12 +146,16 @@ export function selectReadyWithdrawalDeposits( } export function selectExactReadyWithdrawalDeposits( - options: Omit & { - count: number; - }, + options: ExactReadyWithdrawalSelectionOptions, +): ReadyWithdrawalSelection | undefined { + return selectExactReadyWithdrawalDepositsWithScore(options); +} + +function selectExactReadyWithdrawalDepositsWithScore( + options: ScoredExactReadyWithdrawalSelectionOptions, ): ReadyWithdrawalSelection | undefined { const { count, ...selectionOptions } = options; - const selection = selectReadyWithdrawalDeposits({ + const selection = selectReadyWithdrawalDepositsWithScore({ ...selectionOptions, minCount: count, maxCount: count, @@ -141,7 +165,7 @@ export function selectExactReadyWithdrawalDeposits( } export function selectExactReadyWithdrawalDepositCandidates( - options: Omit[0], "score"> & { + options: ExactReadyWithdrawalSelectionOptions & { score: (deposit: IckbDepositCell) => bigint; maturityBucket: (deposit: IckbDepositCell) => bigint; }, @@ -176,11 +200,11 @@ export function selectExactReadyWithdrawalDepositCandidates( count: options.count, preserveSingletons: options.preserveSingletons, }; - addSelection(selectExactReadyWithdrawalDeposits({ + addSelection(selectExactReadyWithdrawalDepositsWithScore({ ...baseOptions, score: options.score, })); - addSelection(selectExactReadyWithdrawalDeposits(baseOptions)); + addSelection(selectExactReadyWithdrawalDepositsWithScore(baseOptions)); } return selections; From f36eb05f8879dc1f446438a6edee18e539355825 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 03:16:15 +0000 Subject: [PATCH 09/14] test(sdk): cover configurable account scan limits --- packages/sdk/README.md | 2 +- packages/sdk/src/sdk.test.ts | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 40d6b58..94bfed4 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. +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. The returned transaction is not completed, signed, sent, or confirmed. Callers still explicitly call `sdk.completeTransaction(...)` with their signer/client/fee rate before sending. diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 9462d0c..a3d923e 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -2464,6 +2464,29 @@ describe("IckbSdk.getL1State snapshot detection", () => { "L1 state scan crossed chain tip", ); }); + + it("passes a custom account scan limit 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()); + 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 client = { + getTipHeader: () => Promise.resolve(tip), + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => repeat(2, cell), + } as unknown as ccc.Client; + + await expect( + sdk.getL1AccountState(client, [accountLock], { accountLimit: 1 }), + ).rejects.toThrow("account scan reached limit 1"); + }); }); describe("IckbSdk.getAccountState", () => { @@ -2546,6 +2569,25 @@ describe("IckbSdk.getAccountState", () => { await expect(sdk.getAccountState(client, [accountLock], tip)).resolves.toBeDefined(); }); + it("uses a custom account cell scan limit", async () => { + const { sdk, logicManager, ownedOwnerManager } = testSdk(); + const accountLock = script("11"); + vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); + 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 client = { + findCellsOnChain: () => repeat(2, cell), + } 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"); From 17fe3ea7eb3bc9c616d457c648ce751b52034006 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 03:21:25 +0000 Subject: [PATCH 10/14] fix(sdk): tighten conversion scan prechecks --- packages/sdk/src/sdk.test.ts | 18 ++++++++++++------ packages/sdk/src/sdk.ts | 25 ++++++++++++++----------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index a3d923e..bcf9f04 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -2104,8 +2104,10 @@ describe("IckbSdk.getL1State snapshot detection", () => { const client = { getTipHeader: () => Promise.resolve(headerLike(1n)), getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { filter?: { scriptLenRange?: unknown } }) { - if (query.filter?.scriptLenRange) { + findCellsOnChain: async function* (query: { + filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; + }) { + if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { yield* repeat(defaultFindCellsLimit, plainCell); } await Promise.resolve(); @@ -2139,8 +2141,10 @@ describe("IckbSdk.getL1State snapshot detection", () => { const client = { getTipHeader: () => Promise.resolve(headerLike(1n)), getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { filter?: { scriptLenRange?: unknown } }) { - if (query.filter?.scriptLenRange) { + findCellsOnChain: async function* (query: { + filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; + }) { + if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { yield* repeat(defaultFindCellsLimit + 1, plainCell); } await Promise.resolve(); @@ -2177,8 +2181,10 @@ describe("IckbSdk.getL1State snapshot detection", () => { const client = { getTipHeader: () => Promise.resolve(headerLike(1n)), getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { filter?: { scriptLenRange?: unknown } }) { - if (query.filter?.scriptLenRange) { + findCellsOnChain: async function* (query: { + filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; + }) { + if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { yield* repeat(defaultFindCellsLimit + 1, plainCell); } await Promise.resolve(); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index ba8a323..c1faf4c 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -661,9 +661,11 @@ export class IckbSdk { return conversionFailure("insufficient-ickb", context.estimatedMaturity); } + const baseTx = ccc.Transaction.from(txLike); + if (amount === 0n) { const tx = await this.buildBaseTransaction( - txLike, + baseTx, client, baseTransactionOptions(context), ); @@ -680,12 +682,12 @@ export class IckbSdk { } return direction === "ckb-to-ickb" - ? await this.buildCkbToIckbConversion(txLike, client, options) - : await this.buildIckbToCkbConversion(txLike, client, options); + ? await this.buildCkbToIckbConversion(baseTx, client, options) + : await this.buildIckbToCkbConversion(baseTx, client, options); } private async buildCkbToIckbConversion( - txLike: ccc.TransactionLike, + baseTx: ccc.Transaction, client: ccc.Client, options: ConversionTransactionOptions, ): Promise { @@ -719,7 +721,7 @@ export class IckbSdk { } const outputLimitError = plannedDaoOutputLimitError( - txLike, + baseTx, (depositCount > 0 ? depositCount + 1 : 0) + orderOutputCount(order), depositCount > 0 || context.readyWithdrawals.length > 0, ); @@ -731,7 +733,7 @@ export class IckbSdk { try { let tx = await this.buildBaseTransaction( - txLike, + baseTx.clone(), client, baseTransactionOptions(context), ); @@ -771,7 +773,7 @@ export class IckbSdk { } private async buildIckbToCkbConversion( - txLike: ccc.TransactionLike, + baseTx: ccc.Transaction, client: ccc.Client, options: ConversionTransactionOptions, ): Promise { @@ -854,7 +856,7 @@ export class IckbSdk { } const outputLimitError = plannedDaoOutputLimitError( - txLike, + baseTx, selectedDeposits.length * 2 + orderOutputCount(order), selectedDeposits.length > 0 || context.readyWithdrawals.length > 0, ); @@ -909,7 +911,7 @@ export class IckbSdk { } of plans) { try { let tx = await this.buildBaseTransaction( - txLike, + baseTx.clone(), client, baseTransactionOptions(context, { deposits: selectedDeposits, @@ -1113,6 +1115,7 @@ export class IckbSdk { scriptType: "lock", filter: { scriptLenRange: [0n, 1n], + outputDataLenRange: [0n, 1n], }, scriptSearchMode: "exact", withData: true, @@ -1316,7 +1319,7 @@ function isRetryableConversionBuildError(error: unknown): boolean { } function plannedDaoOutputLimitError( - txLike: ccc.TransactionLike, + tx: ccc.Transaction, additionalOutputs: number, hasDaoActivity: boolean, ): DaoOutputLimitError | undefined { @@ -1324,7 +1327,7 @@ function plannedDaoOutputLimitError( return; } - const outputCount = ccc.Transaction.from(txLike).outputs.length + additionalOutputs; + const outputCount = tx.outputs.length + additionalOutputs; return outputCount > DAO_OUTPUT_LIMIT ? new DaoOutputLimitError(outputCount) : undefined; From 306963d2fa4f5136ffc3a3c23a86f2c3638eafaf Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 05:05:26 +0000 Subject: [PATCH 11/14] fix(sdk): preserve conversion error details --- packages/sdk/src/sdk.test.ts | 29 +++++++++++++++++++++++++++++ packages/sdk/src/sdk.ts | 8 +++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index bcf9f04..b14b5c6 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -1690,6 +1690,35 @@ describe("IckbSdk.buildConversionTransaction", () => { }, })).rejects.toThrow("RPC failed"); }); + + it("uses string and bigint object error messages in conversion construction failures", async () => { + const { sdk, logicManager, lock } = testSdk(); + vi.spyOn(logicManager, "deposit") + .mockRejectedValueOnce("RPC failed") + .mockRejectedValueOnce({ code: 1n }); + + const options = { + direction: "ckb-to-ickb" as const, + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + }; + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, options), + ).rejects.toThrow("RPC failed"); + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, options), + ).rejects.toThrow('{"code":"1"}'); + }); }); describe("completeIckbTransaction", () => { diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index c1faf4c..319af66 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -1297,6 +1297,10 @@ function errorOf(error: unknown): Error { } function errorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + if ( typeof error === "object" && error !== null && @@ -1307,7 +1311,9 @@ function errorMessage(error: unknown): string { } try { - return JSON.stringify(error); + return JSON.stringify(error, (_key, value) => + typeof value === "bigint" ? value.toString() : value + ); } catch { return String(error); } From 280708ef793a53c125cfe690ae3b2307396a6a42 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 05:12:53 +0000 Subject: [PATCH 12/14] fix(sdk): type bigint error serializer --- packages/sdk/src/sdk.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 319af66..81b1e2d 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -1311,14 +1311,16 @@ function errorMessage(error: unknown): string { } try { - return JSON.stringify(error, (_key, value) => - typeof value === "bigint" ? value.toString() : value - ); + return JSON.stringify(error, stringifyBigInt); } catch { return String(error); } } +function stringifyBigInt(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} + function isRetryableConversionBuildError(error: unknown): boolean { return error instanceof DaoOutputLimitError || error instanceof Error && error.name === "DaoOutputLimitError"; From 31621c0698c67a18a67af8e6e110d125f56ae1e9 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 11:19:09 +0000 Subject: [PATCH 13/14] fix(sdk): expose bot scan limits --- packages/sdk/src/sdk.test.ts | 66 ++++++++++++++++++++++++++++++++++++ packages/sdk/src/sdk.ts | 12 ++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index b14b5c6..e43a121 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -2405,6 +2405,72 @@ describe("IckbSdk.getL1State snapshot detection", () => { }); }); + it("passes a custom bot capacity scan limit through L1 state loading", 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 orderManager = new OrderManager(order, [], udt); + vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + vi.spyOn(orderManager, "findOrders").mockImplementation(() => none()); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + orderManager, + [botLock], + ); + const plainCell = ccc.Cell.from({ + outPoint: { txHash: hash("04"), index: 0n }, + cellOutput: { capacity: 1n, lock: botLock }, + outputData: "0x", + }); + const botCapacityLimit = defaultFindCellsLimit + 1; + 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(botCapacityLimit, plainCell); + } + await Promise.resolve(); + }, + } as unknown as ccc.Client; + + await expect( + sdk.getL1State(client, [], { botCapacityLimit }), + ).resolves.toBeDefined(); + }); + + it("passes a custom bot 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 botWithdrawalLimit = defaultFindCellsLimit + 100; + + await sdk.getL1State(client, [], { botWithdrawalLimit }); + + expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ + onChain: true, + tip, + limit: botWithdrawalLimit, + }); + }); + it("passes a custom order scan limit through L1 state loading", async () => { const logic = script("22"); const dao = script("33"); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 81b1e2d..5f6c6b8 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -120,6 +120,8 @@ export interface SendAndWaitForCommitOptions { } export interface GetL1StateOptions { + botCapacityLimit?: number; + botWithdrawalLimit?: number; orderLimit?: number; poolDepositLimit?: number; } @@ -1032,7 +1034,7 @@ export class IckbSdk { })), client.getFeeRate(), ]); - const { ckbAvailable, ckbMaturing } = await this.getCkb(client, tip, poolDeposits); + const { ckbAvailable, ckbMaturing } = await this.getCkb(client, tip, poolDeposits, options); const midInfo = new Info(exchangeRatio, exchangeRatio, 1); const userOrders: OrderGroup[] = []; @@ -1093,15 +1095,17 @@ export class IckbSdk { client: ccc.Client, tip: ccc.ClientBlockHeader, poolDeposits: PoolDepositState, + options?: GetL1StateOptions, ): Promise<{ ckbAvailable: ccc.FixedPoint; ckbMaturing: CkbCumulative[]; }> { - const limit = defaultFindCellsLimit; + const botCapacityLimit = options?.botCapacityLimit ?? defaultFindCellsLimit; + const botWithdrawalLimit = options?.botWithdrawalLimit ?? defaultFindCellsLimit; const withdrawalOptions = { onChain: true, tip, - limit, + limit: botWithdrawalLimit, }; // Map to track each bot's available CKB (minus a reserved amount for internal operations). const bot2Ckb = new Map(); @@ -1123,7 +1127,7 @@ export class IckbSdk { "asc", scanLimit, ), - { limit, label: "bot capacity", context: lock }, + { limit: botCapacityLimit, label: "bot capacity", context: lock }, )) { if (isPlainCapacityCell(cell)) { const ckb = From 0abf0925c7b653b60aeb9c30fb8607f8cb8a00be Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 11:31:42 +0000 Subject: [PATCH 14/14] fix(sdk): use neutral scan limit names --- packages/sdk/src/sdk.test.ts | 16 ++++++++-------- packages/sdk/src/sdk.ts | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index e43a121..605ce95 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -2405,7 +2405,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { }); }); - it("passes a custom bot capacity scan limit through L1 state loading", async () => { + it("passes a custom available capacity scan limit through L1 state loading", async () => { const botLock = script("11"); const logic = script("22"); const dao = script("33"); @@ -2430,7 +2430,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { cellOutput: { capacity: 1n, lock: botLock }, outputData: "0x", }); - const botCapacityLimit = defaultFindCellsLimit + 1; + const availableCapacityLimit = defaultFindCellsLimit + 1; const client = { getTipHeader: () => Promise.resolve(headerLike(1n)), getFeeRate: () => Promise.resolve(1n), @@ -2438,18 +2438,18 @@ describe("IckbSdk.getL1State snapshot detection", () => { filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; }) { if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { - yield* repeat(botCapacityLimit, plainCell); + yield* repeat(availableCapacityLimit, plainCell); } await Promise.resolve(); }, } as unknown as ccc.Client; await expect( - sdk.getL1State(client, [], { botCapacityLimit }), + sdk.getL1State(client, [], { availableCapacityLimit }), ).resolves.toBeDefined(); }); - it("passes a custom bot withdrawal scan limit through L1 state loading", async () => { + 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") @@ -2460,14 +2460,14 @@ describe("IckbSdk.getL1State snapshot detection", () => { getFeeRate: () => Promise.resolve(1n), findCellsOnChain: () => none(), } as unknown as ccc.Client; - const botWithdrawalLimit = defaultFindCellsLimit + 100; + const pendingWithdrawalLimit = defaultFindCellsLimit + 100; - await sdk.getL1State(client, [], { botWithdrawalLimit }); + await sdk.getL1State(client, [], { pendingWithdrawalLimit }); expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ onChain: true, tip, - limit: botWithdrawalLimit, + limit: pendingWithdrawalLimit, }); }); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 5f6c6b8..f791423 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -120,8 +120,8 @@ export interface SendAndWaitForCommitOptions { } export interface GetL1StateOptions { - botCapacityLimit?: number; - botWithdrawalLimit?: number; + availableCapacityLimit?: number; + pendingWithdrawalLimit?: number; orderLimit?: number; poolDepositLimit?: number; } @@ -1100,8 +1100,8 @@ export class IckbSdk { ckbAvailable: ccc.FixedPoint; ckbMaturing: CkbCumulative[]; }> { - const botCapacityLimit = options?.botCapacityLimit ?? defaultFindCellsLimit; - const botWithdrawalLimit = options?.botWithdrawalLimit ?? defaultFindCellsLimit; + const botCapacityLimit = options?.availableCapacityLimit ?? defaultFindCellsLimit; + const botWithdrawalLimit = options?.pendingWithdrawalLimit ?? defaultFindCellsLimit; const withdrawalOptions = { onChain: true, tip,