From e981307312d8e7d884c4ab66f7e4667eb6369b58 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 21:22:27 +0000 Subject: [PATCH 01/17] fix(utils): harden shared helpers --- packages/utils/src/codec.test.ts | 12 ++ packages/utils/src/codec.ts | 5 +- packages/utils/src/heap.test.ts | 31 +++++ packages/utils/src/heap.ts | 4 +- packages/utils/src/utils.test.ts | 75 +++++++++++ packages/utils/src/utils.ts | 224 ++++++++++++++++++++++++++++++- 6 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 packages/utils/src/codec.test.ts create mode 100644 packages/utils/src/heap.test.ts create mode 100644 packages/utils/src/utils.test.ts diff --git a/packages/utils/src/codec.test.ts b/packages/utils/src/codec.test.ts new file mode 100644 index 0000000..0434baf --- /dev/null +++ b/packages/utils/src/codec.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { CheckedInt32LE } from "./codec.js"; + +describe("CheckedInt32LE", () => { + it("decodes from the provided byte view offset", () => { + const backing = new Uint8Array(8); + backing.set([0xaa, 0xbb, 0xcc, 0xdd], 0); + backing.set(CheckedInt32LE.encode(1), 4); + + expect(CheckedInt32LE.decode(backing.subarray(4, 8))).toBe(1); + }); +}); diff --git a/packages/utils/src/codec.ts b/packages/utils/src/codec.ts index 80d608a..aadb17c 100644 --- a/packages/utils/src/codec.ts +++ b/packages/utils/src/codec.ts @@ -16,6 +16,9 @@ export const CheckedInt32LE = ccc.Codec.from({ }, decode: (bytesLike) => { const bytes = ccc.bytesFrom(bytesLike); - return new DataView(bytes.buffer).getInt32(0, true); + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getInt32( + 0, + true, + ); }, }); diff --git a/packages/utils/src/heap.test.ts b/packages/utils/src/heap.test.ts new file mode 100644 index 0000000..93bc7bc --- /dev/null +++ b/packages/utils/src/heap.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { MinHeap } from "./heap.js"; + +describe("MinHeap.remove", () => { + it("removes the requested non-root element", () => { + const heap = MinHeap.from(compareNumbers, [1, 2, 3]); + + expect(heap.remove(1)).toBe(2); + expect(heap.pop()).toBe(1); + expect(heap.pop()).toBe(3); + }); + + it("ignores negative indexes", () => { + const heap = MinHeap.from(compareNumbers, [1, 2, 3]); + + expect(heap.remove(-1)).toBeUndefined(); + expect(heap.pop()).toBe(1); + expect(heap.pop()).toBe(2); + expect(heap.pop()).toBe(3); + }); +}); + +function compareNumbers(items: number[], i: number, j: number): boolean { + const left = items.at(i); + const right = items.at(j); + if (left === undefined || right === undefined) { + throw new Error("Heap comparator index out of bounds"); + } + + return left < right; +} diff --git a/packages/utils/src/heap.ts b/packages/utils/src/heap.ts index f4f44b6..72ca3e7 100644 --- a/packages/utils/src/heap.ts +++ b/packages/utils/src/heap.ts @@ -90,7 +90,7 @@ export class MinHeap { * @returns {T | undefined} The removed element, or undefined if the index is out of bounds. */ remove(i: number): T | undefined { - if (this.len() <= i) { + if (i < 0 || this.len() <= i) { return; } @@ -101,7 +101,7 @@ export class MinHeap { this.up(i); } } - return this.pop(); + return this.heap.pop(); } /** diff --git a/packages/utils/src/utils.test.ts b/packages/utils/src/utils.test.ts new file mode 100644 index 0000000..5036c26 --- /dev/null +++ b/packages/utils/src/utils.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { BufferedGenerator, selectBoundedUdtSubset } from "./utils.js"; + +describe("BufferedGenerator", () => { + it("keeps advancing the wrapped generator after the initial fill", () => { + function* numbers(): Generator { + yield 1; + yield 2; + yield 3; + } + + const buffered = new BufferedGenerator(numbers(), 2); + + expect(buffered.buffer).toEqual([1, 2]); + + buffered.next(1); + expect(buffered.buffer).toEqual([2, 3]); + + buffered.next(1); + expect(buffered.buffer).toEqual([3]); + }); +}); + +describe("selectBoundedUdtSubset", () => { + it("finds an exact-count subset when the greedy path fails", () => { + const deposits = [{ udtValue: 6n }, { udtValue: 5n }, { udtValue: 5n }]; + + expect(selectBoundedUdtSubset(deposits, 10n, { + candidateLimit: 30, + minCount: 2, + maxCount: 2, + })).toEqual([deposits[1], deposits[2]]); + }); + + it("finds the fullest non-empty subset up to the count limit", () => { + const deposits = [{ udtValue: 4n }, { udtValue: 7n }, { udtValue: 3n }]; + + expect(selectBoundedUdtSubset(deposits, 10n, { + candidateLimit: 30, + minCount: 1, + maxCount: 30, + })).toEqual([deposits[1], deposits[2]]); + }); + + it("keeps earlier-ranked deposits when equally full subsets tie", () => { + const firstSix = { udtValue: 6n }; + const firstFour = { udtValue: 4n }; + const secondSix = { udtValue: 6n }; + const secondFour = { udtValue: 4n }; + + expect(selectBoundedUdtSubset( + [firstSix, firstFour, secondSix, secondFour], + 10n, + { + candidateLimit: 30, + minCount: 1, + maxCount: 30, + }, + )).toEqual([firstSix, firstFour]); + }); + + it("bounds the search to the requested candidate limit", () => { + const deposits = [ + ...Array.from({ length: 30 }, () => ({ udtValue: 6n })), + { udtValue: 5n }, + { udtValue: 5n }, + ]; + + expect(selectBoundedUdtSubset(deposits, 10n, { + candidateLimit: 30, + minCount: 2, + maxCount: 2, + })).toEqual([]); + }); +}); diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index 7c24027..f52d8bd 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -193,6 +193,222 @@ export async function collect(inputs: AsyncIterable): Promise { return res; } +export function selectBoundedUdtSubset( + items: readonly T[], + maxAmount: bigint, + options: { + candidateLimit: number; + minCount: number; + maxCount: number; + }, +): T[] { + const { candidateLimit, minCount, maxCount } = options; + const boundedItems = items.slice(0, candidateLimit); + const effectiveMaxCount = Math.min(maxCount, boundedItems.length); + if ( + maxAmount <= 0n || + minCount < 0 || + effectiveMaxCount < minCount || + boundedItems.length === 0 + ) { + return []; + } + + interface PartialSelection { + mask: number; + total: bigint; + } + + const split = Math.floor(boundedItems.length / 2); + const firstHalf = boundedItems.slice(0, split); + const secondHalf = boundedItems.slice(split); + assertBitmaskSearchSize(firstHalf.length); + assertBitmaskSearchSize(secondHalf.length); + + const enumerate = (half: readonly T[]): PartialSelection[][] => { + const groups = Array.from( + { length: half.length + 1 }, + () => [] as PartialSelection[], + ); + + const search = ( + index: number, + mask: number, + count: number, + total: bigint, + ): void => { + if (index === half.length) { + groups[count]?.push({ mask, total }); + return; + } + + search(index + 1, mask, count, total); + + const item = half[index]; + if (item === undefined) { + return; + } + search(index + 1, mask | (1 << index), count + 1, total + item.udtValue); + }; + + search(0, 0, 0, 0n); + return groups; + }; + + const firstByCount = enumerate(firstHalf); + const secondByCount = enumerate(secondHalf).map((selections) => + compressSelections(selections, secondHalf.length) + ); + + let best: + | { + firstMask: number; + secondMask: number; + total: bigint; + } + | undefined; + + for (let firstCount = 0; firstCount <= effectiveMaxCount; firstCount += 1) { + const firstSelections = firstByCount[firstCount] ?? []; + for (const first of firstSelections) { + if (first.total > maxAmount) { + continue; + } + + const minSecondCount = Math.max(0, minCount - firstCount); + const maxSecondCount = effectiveMaxCount - firstCount; + for (let secondCount = minSecondCount; secondCount <= maxSecondCount; secondCount += 1) { + const secondSelections = secondByCount[secondCount] ?? []; + const second = findBestAtOrBelow(secondSelections, maxAmount - first.total); + if (!second) { + continue; + } + + 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 }; + } + } + } + } + + if (!best) { + return []; + } + + return selectByMasks(firstHalf, best.firstMask).concat( + selectByMasks(secondHalf, best.secondMask), + ); +} + +function assertBitmaskSearchSize(length: number): void { + if (length > 30) { + throw new Error("Bounded subset search supports at most 30 items per half"); + } +} + +function compressSelections( + selections: { mask: number; total: bigint }[], + length: number, +): { mask: number; total: bigint }[] { + selections.sort((left, right) => { + const totalCompare = compareBigInt(left.total, right.total); + if (totalCompare !== 0) { + return totalCompare; + } + + return compareMask(left.mask, right.mask, length); + }); + + const compressed: { mask: number; total: bigint }[] = []; + for (const selection of selections) { + if (compressed.at(-1)?.total !== selection.total) { + compressed.push(selection); + } + } + + return compressed; +} + +function findBestAtOrBelow( + items: readonly T[], + limit: bigint, +): T | undefined { + let low = 0; + let high = items.length - 1; + let bestIndex = -1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const item = items[mid]; + if (item === undefined) { + break; + } + + if (item.total <= limit) { + bestIndex = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return bestIndex >= 0 ? items[bestIndex] : undefined; +} + +function selectByMasks(items: readonly T[], mask: number): T[] { + const selected: T[] = []; + for (let i = 0; i < items.length; i += 1) { + if ((mask & (1 << i)) !== 0) { + const item = items[i]; + if (item !== undefined) { + selected.push(item); + } + } + } + return selected; +} + +function compareMask(left: number, right: number, length: number): number { + for (let i = 0; i < length; i += 1) { + const leftHas = (left & (1 << i)) !== 0; + const rightHas = (right & (1 << i)) !== 0; + if (leftHas === rightHas) { + continue; + } + + return leftHas ? -1 : 1; + } + + return 0; +} + +function compareBigInt(left: bigint, right: bigint): number { + if (left < right) { + return -1; + } + + if (left > right) { + return 1; + } + + return 0; +} + /** * A buffered generator that tries to maintain a fixed-size buffer of values. */ @@ -208,12 +424,12 @@ export class BufferedGenerator { public generator: Generator, public maxSize: number, ) { - // Try to populate the buffer - for (const value of generator) { - this.buffer.push(value); - if (this.buffer.length >= this.maxSize) { + while (this.buffer.length < this.maxSize) { + const { value, done } = this.generator.next(); + if (done) { break; } + this.buffer.push(value); } } From f7985a75cd09c6fd5e38b125fb4954bcb24d5f15 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 21:22:35 +0000 Subject: [PATCH 02/17] fix(core): type DAO cells before grouping withdrawals --- packages/core/src/cells.test.ts | 21 ++ packages/core/src/cells.ts | 28 +- packages/core/src/index.ts | 8 +- packages/core/src/logic.ts | 5 +- packages/core/src/owned_owner.test.ts | 331 +++++++++++++++++++++ packages/core/src/owned_owner.ts | 24 +- packages/dao/src/cells.test.ts | 26 +- packages/dao/src/cells.ts | 99 +++--- packages/dao/src/dao.test.ts | 156 ++++++++-- packages/dao/src/dao.ts | 79 +++-- packages/dao/src/deposit_readiness.test.ts | 37 +-- packages/dao/src/index.ts | 2 +- 12 files changed, 648 insertions(+), 168 deletions(-) create mode 100644 packages/core/src/owned_owner.test.ts diff --git a/packages/core/src/cells.test.ts b/packages/core/src/cells.test.ts index 4354320..fdf49e3 100644 --- a/packages/core/src/cells.test.ts +++ b/packages/core/src/cells.test.ts @@ -151,4 +151,25 @@ describe("receipt prefix decoding", () => { expect(info.capacity).toBe(ccc.fixedPointFrom(100082)); expect(info.count).toBe(1); }); + + it("adds xUDT and logic code deps explicitly", () => { + const logic = script("33"); + const xudtCode = { txHash: byte32FromByte("44"), index: 1n }; + const logicCode = { txHash: byte32FromByte("66"), index: 2n }; + const ickbUdt = new IckbUdt( + xudtCode, + script("55"), + logicCode, + logic, + new DaoManager(script("77"), []), + ); + + const tx = ickbUdt.addCellDeps(ccc.Transaction.default()); + + expect(tx.cellDeps).toHaveLength(2); + expect(tx.cellDeps[0]?.depType).toBe("code"); + expect(tx.cellDeps[0]?.outPoint.eq(ccc.OutPoint.from(xudtCode))).toBe(true); + expect(tx.cellDeps[1]?.depType).toBe("code"); + expect(tx.cellDeps[1]?.outPoint.eq(ccc.OutPoint.from(logicCode))).toBe(true); + }); }); diff --git a/packages/core/src/cells.ts b/packages/core/src/cells.ts index 7cd63bb..7e0dde1 100644 --- a/packages/core/src/cells.ts +++ b/packages/core/src/cells.ts @@ -2,9 +2,9 @@ import { ccc } from "@ckb-ccc/core"; import { type TransactionHeader, type ValueComponents } from "@ickb/utils"; import { OwnerData, ReceiptData } from "./entities.js"; import { ickbValue } from "./udt.js"; -import { daoCellFrom, type DaoCell } from "@ickb/dao"; +import type { DaoDepositCell, DaoWithdrawalRequestCell } from "@ickb/dao"; -export interface IckbDepositCell extends DaoCell { +export interface IckbDepositCell extends DaoDepositCell { /** * A symbol property indicating that this cell is a Ickb Deposit Cell. * This property is always set to true. @@ -15,23 +15,7 @@ export interface IckbDepositCell extends DaoCell { // Symbol to represent the isIckbDeposit property of Ickb Deposit Cells const isIckbDepositSymbol = Symbol("isIckbDeposit"); -/** - * Creates an IckbDepositCell from the provided parameters. - * - * @param options - The options to create a DaoCell. It can be one of the following: - * - An object omitting "interests" and "maturity" from DaoCell. - * - An object containing a cell, isDeposit flag, client, and an optional tip. - * - An object containing an outpoint, isDeposit flag, client, and an optional tip. - * - an instance of `DaoCell`. - * - * @returns A promise that resolves to a IckbDepositCell instance. - * - * @throws Error if the cell is not found. - */ -export async function ickbDepositCellFrom( - options: Parameters[0] | DaoCell, -): Promise { - const daoCell = "maturity" in options ? options : await daoCellFrom(options); +export function ickbDepositCellFrom(daoCell: DaoDepositCell): IckbDepositCell { return { ...daoCell, udtValue: ickbValue(daoCell.cell.capacityFree, daoCell.headers[0].header), @@ -105,7 +89,7 @@ export async function receiptCellFrom( */ export class WithdrawalGroup implements ValueComponents { constructor( - public owned: DaoCell, + public owned: DaoWithdrawalRequestCell, public owner: OwnerCell, ) {} @@ -121,10 +105,10 @@ export class WithdrawalGroup implements ValueComponents { /** * Gets the UDT value of the group. * - * @returns The UDT amount as a `ccc.FixedPoint`, derived from the owned cell. + * @returns The iCKB amount represented by the owned withdrawal request. */ get udtValue(): ccc.FixedPoint { - return this.owned.udtValue; + return ickbValue(this.owned.cell.capacityFree, this.owned.headers[0].header); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1133495..9d5dafa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,10 @@ -export * from "./cells.js"; +export { + OwnerCell, + receiptCellFrom, + WithdrawalGroup, + type IckbDepositCell, + type ReceiptCell, +} from "./cells.js"; export * from "./entities.js"; export * from "./logic.js"; export * from "./owned_owner.js"; diff --git a/packages/core/src/logic.ts b/packages/core/src/logic.ts index b22d3dd..16586db 100644 --- a/packages/core/src/logic.ts +++ b/packages/core/src/logic.ts @@ -268,7 +268,7 @@ export class LogicManager implements ScriptDeps { * `ClientBlockHeader.from(...)` if a plain object is provided. * - Delegates to `this.daoManager.findDeposits(client, [this.script], options)` to locate * raw DAO deposit cells locked under `this.script`. - * - Converts each raw `DaoCell` into an `IckbDepositCell` via `ickbDepositCellFrom`. + * - Converts each validated DAO deposit into an `IckbDepositCell` via `ickbDepositCellFrom`. */ async *findDeposits( client: ccc.Client, @@ -290,6 +290,9 @@ export class LogicManager implements ScriptDeps { [this.script], options, )) { + if (!this.isDeposit(deposit.cell)) { + continue; + } yield ickbDepositCellFrom(deposit); } } diff --git a/packages/core/src/owned_owner.test.ts b/packages/core/src/owned_owner.test.ts new file mode 100644 index 0000000..1147fd4 --- /dev/null +++ b/packages/core/src/owned_owner.test.ts @@ -0,0 +1,331 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { collect } from "@ickb/utils"; +import { DaoManager } from "@ickb/dao"; +import { OwnerData } from "./entities.js"; +import { OwnerCell } 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, + }); +} + +describe("OwnedOwnerManager.findWithdrawalGroups", () => { + it("decodes owner relative distances from prefixed data", () => { + const ownerCell = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: script("11"), + type: script("22"), + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }), + ); + + expect(ownerCell.getOwned().index).toBe(0n); + }); + + it("skips owners whose referenced withdrawal is not locked by Owned Owner", async () => { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const outsiderLock = script("44"); + const tip = headerLike(); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const ownerCell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const fakeOwned = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: outsiderLock, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + const client = { + getTipHeader: async () => { + await Promise.resolve(); + return tip; + }, + findCells: async function* () { + await Promise.resolve(); + yield ownerCell; + }, + getCell: async () => { + await Promise.resolve(); + return fakeOwned; + }, + getHeaderByNumber: async () => { + await Promise.resolve(); + return headerLike(); + }, + getTransactionWithHeader: async () => { + await Promise.resolve(); + return { header: tip }; + }, + } as unknown as ccc.Client; + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toEqual([]); + }); + + it("skips owner targets before DAO header decoding", 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 ownerCell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("77"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const fakeOwned = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("77"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("44"), + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + let headerLookups = 0; + const client = { + getTipHeader: async () => { + await Promise.resolve(); + return tip; + }, + findCells: async function* () { + await Promise.resolve(); + yield ownerCell; + }, + getCell: async () => { + await Promise.resolve(); + return fakeOwned; + }, + getHeaderByNumber: async () => { + headerLookups += 1; + await Promise.resolve(); + return headerLike(); + }, + getTransactionWithHeader: async () => { + headerLookups += 1; + await Promise.resolve(); + return { header: tip }; + }, + } as unknown as ccc.Client; + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toEqual([]); + expect(headerLookups).toBe(0); + }); + + it("skips referenced cells that are not DAO withdrawal requests", async () => { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const foreignType = script("44"); + 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(), + }); + const deposit = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: "0x0000000000000000", + }); + const foreignCell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("66"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: foreignType, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + const referencedCells = new Map([ + [deposit.outPoint.toHex(), deposit], + [foreignCell.outPoint.toHex(), foreignCell], + ]); + const client = { + getTipHeader: async () => { + await Promise.resolve(); + return tip; + }, + findCells: async function* () { + await Promise.resolve(); + yield firstOwner; + yield secondOwner; + }, + getCell: async (outPoint: ccc.OutPoint) => { + await Promise.resolve(); + return referencedCells.get(outPoint.toHex()); + }, + getHeaderByNumber: async () => { + await Promise.resolve(); + return headerLike(); + }, + getTransactionWithHeader: async () => { + await Promise.resolve(); + return { header: tip }; + }, + } as unknown as ccc.Client; + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toEqual([]); + }); + + it("keeps withdrawal-group iCKB value from the referenced deposit header", 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 ownerCell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("88"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const depositHeader = headerLike({ + epoch: [1n, 0n, 1n], + number: 1n, + }); + const withdrawalHeader = headerLike({ + hash: byte32FromByte("99"), + number: 2n, + }); + const owned = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("88"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }); + const client = { + getTipHeader: async () => { + await Promise.resolve(); + return tip; + }, + findCells: async function* () { + await Promise.resolve(); + yield ownerCell; + }, + getCell: async () => { + await Promise.resolve(); + return owned; + }, + getHeaderByNumber: async () => { + await Promise.resolve(); + return depositHeader; + }, + getTransactionWithHeader: async () => { + await Promise.resolve(); + return { header: withdrawalHeader }; + }, + } as unknown as ccc.Client; + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toHaveLength(1); + expect(groups[0]?.udtValue).toBe(ickbValue(owned.capacityFree, depositHeader)); + }); +}); diff --git a/packages/core/src/owned_owner.ts b/packages/core/src/owned_owner.ts index 2ab3ccf..a3b6a06 100644 --- a/packages/core/src/owned_owner.ts +++ b/packages/core/src/owned_owner.ts @@ -4,7 +4,7 @@ import { unique, type ScriptDeps, } from "@ickb/utils"; -import { assertDaoOutputLimit, daoCellFrom, DaoManager } from "@ickb/dao"; +import { assertDaoOutputLimit, DaoManager } from "@ickb/dao"; import { OwnerData } from "./entities.js"; import { OwnerCell, WithdrawalGroup, type IckbDepositCell } from "./cells.js"; @@ -182,7 +182,7 @@ export class OwnedOwnerManager implements ScriptDeps { * @yields * {@link WithdrawalGroup} objects, each containing: * - the owner cell (`OwnerCell`) - * - the corresponding DAO withdrawal cell (`DaoCell`) + * - the corresponding DAO withdrawal request cell * * @remarks * - Deduplicates `locks` via `unique(locks)`. @@ -192,9 +192,9 @@ export class OwnedOwnerManager implements ScriptDeps { * 1. Fails `this.isOwner(cell)` * 2. Has a non-matching lock script * - For each owner cell: - * 1. Construct an `OwnerCell` instance - * 2. Fetch the owned DAO withdrawal cell via `daoCellFrom({ outpoint, isDeposit: false, client, tip })` - * 3. Yield a new `WithdrawalGroup(ownedDaoCell, ownerCell)` + * 1. Construct an `OwnerCell` instance. + * 2. Fetch the referenced cell and skip it unless it is an Owned Owner withdrawal request. + * 3. Decode the validated withdrawal request and yield a `WithdrawalGroup`. */ async *findWithdrawalGroups( client: ccc.Client, @@ -230,12 +230,16 @@ export class OwnedOwnerManager implements ScriptDeps { } const owner = new OwnerCell(cell); - const owned = await daoCellFrom({ - outpoint: owner.getOwned(), - isDeposit: false, + const ownedOutPoint = owner.getOwned(); + const ownedCell = await client.getCell(ownedOutPoint); + if (!ownedCell || !this.isOwned(ownedCell)) { + continue; + } + const owned = await this.daoManager.withdrawalRequestCellFrom( + ownedCell, client, - tip, - }); + { tip }, + ); yield new WithdrawalGroup(owned, owner); } } diff --git a/packages/dao/src/cells.test.ts b/packages/dao/src/cells.test.ts index fc3c808..8fa206c 100644 --- a/packages/dao/src/cells.test.ts +++ b/packages/dao/src/cells.test.ts @@ -1,6 +1,6 @@ import { ccc } from "@ckb-ccc/core"; import { describe, expect, it } from "vitest"; -import { daoCellFrom } from "./cells.js"; +import { DaoManager } from "./dao.js"; function byte32FromByte(hexByte: string): `0x${string}` { if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { @@ -85,33 +85,33 @@ function clientFor( describe("daoCellFrom withdrawal readiness", () => { it("marks withdrawal requests ready once the claim epoch is reached", async () => { + const manager = new DaoManager(script("33"), []); const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); const tip = ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)); const claimEpoch = ccc.calcDaoClaimEpoch(depositHeader, withdrawHeader); - const daoCell = await daoCellFrom({ - cell: withdrawalCell(), - isDeposit: false, - client: clientFor(depositHeader, withdrawHeader), - tip, - }); + const daoCell = await manager.withdrawalRequestCellFrom( + withdrawalCell(), + clientFor(depositHeader, withdrawHeader), + { tip }, + ); expect(daoCell.maturity.eq(claimEpoch)).toBe(true); expect(daoCell.isReady).toBe(true); }); it("keeps withdrawal requests pending before the claim epoch", async () => { + const manager = new DaoManager(script("33"), []); const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); const tip = ccc.ClientBlockHeader.from(headerLike([179n, 0n, 1n], 3n)); - const daoCell = await daoCellFrom({ - cell: withdrawalCell(), - isDeposit: false, - client: clientFor(depositHeader, withdrawHeader), - tip, - }); + const daoCell = await manager.withdrawalRequestCellFrom( + withdrawalCell(), + clientFor(depositHeader, withdrawHeader), + { tip }, + ); expect(daoCell.isReady).toBe(false); }); diff --git a/packages/dao/src/cells.ts b/packages/dao/src/cells.ts index 9a28148..376f38e 100644 --- a/packages/dao/src/cells.ts +++ b/packages/dao/src/cells.ts @@ -1,16 +1,10 @@ import { ccc, mol } from "@ckb-ccc/core"; import { type TransactionHeader, type ValueComponents } from "@ickb/utils"; -/** - * Represents a DAO cell with its associated properties. - */ -export interface DaoCell extends ValueComponents { +interface DaoCellBase extends ValueComponents { /** The DAO cell. */ cell: ccc.Cell; - /** Indicates whether the cell is a deposit. */ - isDeposit: boolean; - /** * The headers associated with the transaction. * In case of deposit, it contains [depositHeader, tipHeader], @@ -33,57 +27,41 @@ export interface DaoCell extends ValueComponents { isReady: boolean; } -/** - * Creates a DaoCell from the provided options. - * - * @param options - The options to create a DaoCell. It can be one of the following: - * - An object omitting "interests" and "maturity" from DaoCell. - * - An object containing a cell, isDeposit flag and client. - * - An object containing an outpoint, isDeposit flag and client. - * - * The options object also include: - * - `tip`: The current tip block header. - * - `minLockUp`: An optional minimum lock-up period in epochs (Default 10 minutes) - * - `maxLockUp`: An optional maximum lock-up period in epochs (Default 3 days) - * - * @returns A promise that resolves to a DaoCell. - * - * @throws Error if the cell is not found. - */ +export interface DaoDepositCell extends DaoCellBase { + readonly isDeposit: true; +} + +export interface DaoWithdrawalRequestCell extends DaoCellBase { + readonly isDeposit: false; +} + +type DaoCell = DaoDepositCell | DaoWithdrawalRequestCell; + +type DaoCellFromOptions = { + client: ccc.Client; + tip: ccc.ClientBlockHeader; + minLockUp?: ccc.Epoch; + maxLockUp?: ccc.Epoch; +}; + +export function daoCellFrom( + cell: ccc.Cell, + options: DaoCellFromOptions & { isDeposit: true }, +): Promise; + +export function daoCellFrom( + cell: ccc.Cell, + options: DaoCellFromOptions & { isDeposit: false }, +): Promise; + export async function daoCellFrom( - options: ( - | Omit - | { - cell: ccc.Cell; - isDeposit: boolean; - client: ccc.Client; - } - | { - outpoint: ccc.OutPoint; - isDeposit: boolean; - client: ccc.Client; - } - ) & { - tip: ccc.ClientBlockHeader; - minLockUp?: ccc.Epoch; - maxLockUp?: ccc.Epoch; - }, + cell: ccc.Cell, + options: DaoCellFromOptions & { isDeposit: boolean }, ): Promise { - const isDeposit = options.isDeposit; - const cell = - "cell" in options - ? options.cell - : await options.client.getCell(options.outpoint); - if (!cell) { - throw new Error("Cell not found"); - } - - const tip = options.tip; + const { isDeposit, tip } = options; const txHash = cell.outPoint.txHash; let oldest: TransactionHeader; - if ("headers" in options) { - oldest = options.headers[0]; - } else if (!isDeposit) { + if (!isDeposit) { const header = await options.client.getHeaderByNumber( mol.Uint64LE.decode(cell.outputData), ); @@ -101,9 +79,7 @@ export async function daoCellFrom( } let newest: TransactionHeader; - if ("headers" in options) { - newest = options.headers[1]; - } else if (!isDeposit) { + if (!isDeposit) { const txWithHeader = await options.client.getTransactionWithHeader(txHash); if (!txWithHeader?.header) { @@ -144,16 +120,19 @@ export async function daoCellFrom( const ckbValue = cell.cellOutput.capacity + interests; const udtValue = 0n; - return { + const common = { cell, - isDeposit, headers: [oldest, newest], interests, maturity, isReady, ckbValue, udtValue, - }; + } satisfies DaoCellBase; + + return isDeposit + ? { ...common, isDeposit: true } + : { ...common, isDeposit: false }; } /** diff --git a/packages/dao/src/dao.test.ts b/packages/dao/src/dao.test.ts index ec1ea0c..fc8584d 100644 --- a/packages/dao/src/dao.test.ts +++ b/packages/dao/src/dao.test.ts @@ -1,6 +1,6 @@ import { ccc } from "@ckb-ccc/core"; import { describe, expect, it, vi } from "vitest"; -import type { DaoCell } from "./cells.js"; +import type { DaoDepositCell, DaoWithdrawalRequestCell } from "./cells.js"; import { DaoManager } from "./dao.js"; function byte32FromByte(hexByte: string): `0x${string}` { @@ -58,30 +58,44 @@ function headerWithHash(number: bigint, hashByte: string): ccc.ClientBlockHeader }); } +function depositCell( + manager: DaoManager, + options?: { + lock?: ccc.Script; + txHashByte?: string; + isReady?: boolean; + }, +): DaoDepositCell { + const depositHeader = headerLike(1n); + return { + cell: ccc.Cell.from({ + outPoint: { + txHash: byte32FromByte(options?.txHashByte ?? "22"), + index: 0n, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: options?.lock ?? script("33"), + type: manager.script, + }, + outputData: DaoManager.depositData(), + }), + isDeposit: true, + headers: [{ header: depositHeader }, { header: depositHeader }], + interests: 0n, + maturity: ccc.Epoch.from([1n, 0n, 1n]), + isReady: options?.isReady ?? true, + ckbValue: ccc.fixedPointFrom(100082), + udtValue: 0n, + }; +} + describe("DaoManager.requestWithdrawal", () => { it("always rejects withdrawal locks with different args size", async () => { vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); const manager = new DaoManager(script("11"), []); - const depositHeader = headerLike(1n); - const deposit: DaoCell = { - cell: ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("22"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: script("33", "0x1234"), - type: manager.script, - }, - outputData: DaoManager.depositData(), - }), - isDeposit: true, - headers: [{ header: depositHeader }, { header: depositHeader }], - interests: 0n, - maturity: ccc.Epoch.from([1n, 0n, 1n]), - isReady: true, - ckbValue: ccc.fixedPointFrom(100082), - udtValue: 0n, - }; + const deposit = depositCell(manager, { lock: script("33", "0x1234") }); await expect( manager.requestWithdrawal( @@ -92,6 +106,104 @@ describe("DaoManager.requestWithdrawal", () => { ), ).rejects.toThrow("Withdrawal request lock args has different size from deposit"); }); + + it("keeps non-ready deposits unless isReadyOnly is set", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const manager = new DaoManager(script("11"), []); + const pending = depositCell(manager, { isReady: false, txHashByte: "22" }); + const ready = depositCell(manager, { isReady: true, txHashByte: "23" }); + + const tx = await manager.requestWithdrawal( + ccc.Transaction.default(), + [pending, ready], + script("44"), + {} as ccc.Client, + ); + + expect(tx.inputs).toHaveLength(2); + expect(tx.outputs).toHaveLength(2); + expect(tx.outputsData).toHaveLength(2); + }); + + it("filters non-ready deposits when isReadyOnly is set", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const manager = new DaoManager(script("11"), []); + const pending = depositCell(manager, { isReady: false, txHashByte: "22" }); + const ready = depositCell(manager, { isReady: true, txHashByte: "23" }); + + const tx = await manager.requestWithdrawal( + ccc.Transaction.default(), + [pending, ready], + script("44"), + {} as ccc.Client, + { isReadyOnly: true }, + ); + + expect(tx.inputs).toHaveLength(1); + expect(tx.outputs).toHaveLength(1); + expect(tx.inputs[0]?.previousOutput.txHash).toBe(ready.cell.outPoint.txHash); + }); + + it("requires matched input and output counts before appending requests", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const manager = new DaoManager(script("11"), []); + const tx = ccc.Transaction.default(); + tx.addOutput( + { + capacity: ccc.fixedPointFrom(1000), + lock: script("55"), + }, + "0x", + ); + + await expect( + manager.requestWithdrawal( + tx, + [depositCell(manager)], + script("44"), + {} as ccc.Client, + ), + ).rejects.toThrow("Transaction has different inputs and outputs lengths"); + }); +}); + +describe("DaoManager cell decoding ownership", () => { + it("rejects depositCellFrom on non-deposit cells", async () => { + const manager = new DaoManager(script("11"), []); + + await expect( + manager.depositCellFrom( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("22"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("33"), + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }), + {} as ccc.Client, + { tip: headerLike(2n) }, + ), + ).rejects.toThrow("Not a deposit"); + }); + + it("rejects withdrawalRequestCellFrom on non-withdrawal cells", async () => { + const manager = new DaoManager(script("11"), []); + + await expect( + manager.withdrawalRequestCellFrom( + depositCell(manager).cell, + {} as ccc.Client, + { + tip: headerLike(2n), + }, + ), + ).rejects.toThrow("Not a withdrawal request"); + }); }); describe("DaoManager.withdraw", () => { @@ -101,7 +213,7 @@ describe("DaoManager.withdraw", () => { const manager = new DaoManager(script("11"), []); const depositHeader = headerLike(1n); const withdrawHeader = headerWithHash(2n, "99"); - const withdrawal: DaoCell = { + const withdrawal: DaoWithdrawalRequestCell = { cell: ccc.Cell.from({ outPoint: { txHash: byte32FromByte("22"), index: 0n }, cellOutput: { @@ -145,7 +257,7 @@ describe("DaoManager.withdraw", () => { const manager = new DaoManager(script("11"), []); const depositHeader = headerLike(1n); const withdrawHeader = headerWithHash(2n, "99"); - const withdrawal: DaoCell = { + const withdrawal: DaoWithdrawalRequestCell = { cell: ccc.Cell.from({ outPoint: { txHash: byte32FromByte("22"), index: 0n }, cellOutput: { diff --git a/packages/dao/src/dao.ts b/packages/dao/src/dao.ts index 638053e..3ae4f27 100644 --- a/packages/dao/src/dao.ts +++ b/packages/dao/src/dao.ts @@ -4,7 +4,17 @@ import { unique, type ScriptDeps, } from "@ickb/utils"; -import { daoCellFrom, type DaoCell } from "./cells.js"; +import { + daoCellFrom, + type DaoDepositCell, + type DaoWithdrawalRequestCell, +} from "./cells.js"; + +type DaoCellFromOptions = { + tip: ccc.ClientBlockHeader; + minLockUp?: ccc.Epoch; + maxLockUp?: ccc.Epoch; +}; export async function assertDaoOutputLimit( txLike: ccc.TransactionLike, @@ -76,6 +86,42 @@ export class DaoManager implements ScriptDeps { return "0x0000000000000000"; } + async depositCellFrom( + cellLike: ccc.Cell | ccc.OutPoint, + client: ccc.Client, + options: DaoCellFromOptions, + ): Promise { + const cell = cellLike instanceof ccc.OutPoint + ? await client.getCell(cellLike) + : cellLike; + if (!cell) { + throw new Error("Cell not found"); + } + if (!this.isDeposit(cell)) { + throw new Error("Not a deposit"); + } + + return daoCellFrom(cell, { ...options, client, isDeposit: true }); + } + + async withdrawalRequestCellFrom( + cellLike: ccc.Cell | ccc.OutPoint, + client: ccc.Client, + options: DaoCellFromOptions, + ): Promise { + const cell = cellLike instanceof ccc.OutPoint + ? await client.getCell(cellLike) + : cellLike; + if (!cell) { + throw new Error("Cell not found"); + } + if (!this.isWithdrawalRequest(cell)) { + throw new Error("Not a withdrawal request"); + } + + return daoCellFrom(cell, { ...options, client, isDeposit: false }); + } + /** * Adds a deposit to a transaction. * @@ -128,7 +174,7 @@ export class DaoManager implements ScriptDeps { */ async requestWithdrawal( txLike: ccc.TransactionLike, - deposits: DaoCell[], + deposits: DaoDepositCell[], lock: ccc.Script, client: ccc.Client, options?: { @@ -152,10 +198,7 @@ export class DaoManager implements ScriptDeps { } for (const deposit of deposits) { - const { cell, isDeposit, headers } = deposit; - if (!isDeposit) { - throw new Error("Not a deposit"); - } + const { cell, headers } = deposit; if (cell.cellOutput.lock.args.length !== lock.args.length) { throw new Error( "Withdrawal request lock args has different size from deposit", @@ -195,7 +238,7 @@ export class DaoManager implements ScriptDeps { */ async withdraw( txLike: ccc.TransactionLike, - withdrawalRequests: DaoCell[], + withdrawalRequests: DaoWithdrawalRequestCell[], client: ccc.Client, options?: { isReadyOnly?: boolean; @@ -215,13 +258,9 @@ export class DaoManager implements ScriptDeps { for (const withdrawalRequest of withdrawalRequests) { const { cell: { outPoint, cellOutput, outputData }, - isDeposit, headers, maturity, } = withdrawalRequest; - if (isDeposit) { - throw new Error("Not a withdrawal request"); - } for (const th of headers) { const hash = th.header.hash; if (!tx.headerDeps.some((h) => h === hash)) { @@ -288,7 +327,7 @@ export class DaoManager implements ScriptDeps { * Batch size per lock script. Defaults to `defaultFindCellsLimit` (400). * * @yields - * {@link DaoCell} objects representing deposit cells. + * {@link DaoDepositCell} objects representing deposit cells. * * @remarks * - Deduplicates `locks` via `unique(locks)`. @@ -299,8 +338,7 @@ export class DaoManager implements ScriptDeps { * - Skips any cell that: * 1. Fails `this.isDeposit(cell)` * 2. Has a non-matching lock script - * - Each yielded `DaoCell` is constructed via: - * `daoCellFrom({ cell, ...options, isDeposit: true, client, tip })` + * - Each yielded `DaoDepositCell` is constructed via `depositCellFrom(...)`. */ async *findDeposits( client: ccc.Client, @@ -312,7 +350,7 @@ export class DaoManager implements ScriptDeps { maxLockUp?: ccc.Epoch; limit?: number; }, - ): AsyncGenerator { + ): AsyncGenerator { const tip = options?.tip ?? (await client.getTipHeader()); const limit = options?.limit ?? defaultFindCellsLimit; @@ -340,7 +378,7 @@ export class DaoManager implements ScriptDeps { continue; } - yield daoCellFrom({ cell, ...options, isDeposit: true, client, tip }); + yield this.depositCellFrom(cell, client, { ...options, tip }); } } } @@ -370,7 +408,7 @@ export class DaoManager implements ScriptDeps { * Batch size per lock script. Defaults to `defaultFindCellsLimit` (400). * * @yields - * {@link DaoCell} objects representing withdrawal‐request cells. + * {@link DaoWithdrawalRequestCell} objects representing withdrawal request cells. * * @remarks * - Deduplicates `locks` via `unique(locks)`. @@ -379,8 +417,7 @@ export class DaoManager implements ScriptDeps { * - Skips any cell that: * 1. Fails `this.isWithdrawalRequest(cell)` * 2. Has a non-matching lock script - * - Each yielded `DaoCell` is constructed via: - * `daoCellFrom({ cell, ...options, isDeposit: false, client, tip })` + * - Each yielded `DaoWithdrawalRequestCell` is constructed via `withdrawalRequestCellFrom(...)`. */ async *findWithdrawalRequests( client: ccc.Client, @@ -390,7 +427,7 @@ export class DaoManager implements ScriptDeps { onChain?: boolean; limit?: number; }, - ): AsyncGenerator { + ): AsyncGenerator { const tip = options?.tip ?? (await client.getTipHeader()); const limit = options?.limit ?? defaultFindCellsLimit; @@ -416,7 +453,7 @@ export class DaoManager implements ScriptDeps { continue; } - yield daoCellFrom({ cell, ...options, isDeposit: false, client, tip }); + yield this.withdrawalRequestCellFrom(cell, client, { ...options, tip }); } } } diff --git a/packages/dao/src/deposit_readiness.test.ts b/packages/dao/src/deposit_readiness.test.ts index 850d2e1..25fb6a9 100644 --- a/packages/dao/src/deposit_readiness.test.ts +++ b/packages/dao/src/deposit_readiness.test.ts @@ -1,6 +1,5 @@ import { ccc } from "@ckb-ccc/core"; import { describe, expect, it } from "vitest"; -import { daoCellFrom } from "./cells.js"; import { DaoManager } from "./dao.js"; function hash(byte: string): `0x${string}` { @@ -59,17 +58,19 @@ describe("daoCellFrom deposit readiness boundaries", () => { it("keeps deposits at the exact min boundary pending until the next tip", async () => { 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 daoCell = await daoCellFrom({ - cell: depositCell(lock, dao), - isDeposit: true, - client: clientFor(depositHeader), - tip, - minLockUp: ccc.Epoch.from([0n, 1n, 24n]), - maxLockUp: ccc.Epoch.from([18n, 0n, 1n]), - }); + const daoCell = await manager.depositCellFrom( + depositCell(lock, dao), + clientFor(depositHeader), + { + tip, + minLockUp: ccc.Epoch.from([0n, 1n, 24n]), + maxLockUp: ccc.Epoch.from([18n, 0n, 1n]), + }, + ); expect(daoCell.isReady).toBe(false); expect(daoCell.maturity.eq(ccc.calcDaoClaimEpoch(depositHeader, tip).add([180n, 0n, 1n]))).toBe(true); @@ -78,17 +79,19 @@ describe("daoCellFrom deposit readiness boundaries", () => { it("keeps deposits at the exact max boundary out of the ready window", async () => { 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 daoCell = await daoCellFrom({ - cell: depositCell(lock, dao), - isDeposit: true, - client: clientFor(depositHeader), - tip, - minLockUp: ccc.Epoch.from([0n, 1n, 24n]), - maxLockUp: ccc.Epoch.from([18n, 0n, 1n]), - }); + const daoCell = await manager.depositCellFrom( + depositCell(lock, dao), + clientFor(depositHeader), + { + tip, + minLockUp: ccc.Epoch.from([0n, 1n, 24n]), + maxLockUp: ccc.Epoch.from([18n, 0n, 1n]), + }, + ); expect(daoCell.maturity.eq(ccc.calcDaoClaimEpoch(depositHeader, tip))).toBe(true); expect(daoCell.isReady).toBe(false); diff --git a/packages/dao/src/index.ts b/packages/dao/src/index.ts index 26d5fce..a3f01ec 100644 --- a/packages/dao/src/index.ts +++ b/packages/dao/src/index.ts @@ -1,2 +1,2 @@ -export * from "./cells.js"; +export type { DaoDepositCell, DaoWithdrawalRequestCell } from "./cells.js"; export * from "./dao.js"; From 196691348655e135d05027988720fb2d914e361a Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 21:22:40 +0000 Subject: [PATCH 03/17] fix(order): harden order matching and discovery --- packages/order/README.md | 16 + packages/order/src/cells.ts | 39 +- packages/order/src/entities.ts | 21 +- packages/order/src/index.ts | 17 +- packages/order/src/order.test.ts | 944 ++++++++++++++++++++++++++++++- packages/order/src/order.ts | 144 +++-- 6 files changed, 1115 insertions(+), 66 deletions(-) diff --git a/packages/order/README.md b/packages/order/README.md index 7b5ad97..7f1bdbf 100644 --- a/packages/order/README.md +++ b/packages/order/README.md @@ -15,6 +15,22 @@ graph TD; click C "https://github.com/ickb/stack/tree/master/packages/order" "Go to @ickb/order" ``` +## Partial Transactions + +`@ickb/order` transaction builders stop at order-specific construction. + +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. + +## Limit Order Confusion Boundary + +The deployed Limit Order script has a known confusion surface because CKB does not execute output locks at creation time. `@ickb/order` keeps the stack-side mitigation in `findOrders(...)`: it fetches the mint origin for each master, rejects candidates whose order script, UDT type, resolved master, or parameters differ from the origin, rejects candidates whose normalized value or directional progress is lower than the origin, then selects the best remaining candidate by progress/value. Ambiguous mint origins and ambiguous equal-score descendants are skipped instead of selected by indexer order. This is a best-effort stack-side heuristic for immutable deployed behavior, not proof that forged higher-progress descendants cannot exist. Consumers should use resolved `OrderGroup`s from `findOrders(...)` for matching and melting instead of hand-pairing order and master cells. + +Minting does not execute the master output lock. If you call `OrderManager.mint(...)` with a raw `ccc.Script`, ensure it is a spendable whole-transaction-binding user lock; otherwise the order can be created but later become uncollectable. + ## Epoch Semantic Versioning This repository follows [Epoch Semantic Versioning](https://antfu.me/posts/epoch-semver). In short ESV aims to provide a more nuanced and effective way to communicate software changes, allowing for better user understanding and smoother upgrades. diff --git a/packages/order/src/cells.ts b/packages/order/src/cells.ts index 7434a13..29e0a4b 100644 --- a/packages/order/src/cells.ts +++ b/packages/order/src/cells.ts @@ -234,6 +234,7 @@ export class OrderCell implements ValueComponents { */ resolve(descendants: OrderCell[]): OrderCell | undefined { let best: OrderCell | undefined = undefined; + let isAmbiguous = false; for (const descendant of descendants) { if (!this.isValid(descendant)) { continue; @@ -242,17 +243,37 @@ export class OrderCell implements ValueComponents { // Directional orders rank by irreversible progress. Dual-sided orders // rank by value because absProgress === absTotal for that shape. // At equal progress, prefer newly minted orders. - if ( - !best || - best.absProgress < descendant.absProgress || - ( - best.absProgress === descendant.absProgress && - descendant.data.isMint() && - !best.data.isMint() - ) - ) { + if (!best || best.absProgress < descendant.absProgress) { best = descendant; + isAmbiguous = false; + continue; + } + + if (best.absProgress !== descendant.absProgress) { + continue; + } + + if (best.cell.outPoint.eq(descendant.cell.outPoint)) { + continue; } + + if (descendant.data.isMint() && !best.data.isMint()) { + best = descendant; + isAmbiguous = false; + continue; + } + + if (!descendant.data.isMint() && best.data.isMint()) { + continue; + } + + if (best.absProgress === descendant.absProgress) { + isAmbiguous = true; + } + } + + if (isAmbiguous) { + return undefined; } return best; diff --git a/packages/order/src/entities.ts b/packages/order/src/entities.ts index 4758416..1cca562 100644 --- a/packages/order/src/entities.ts +++ b/packages/order/src/entities.ts @@ -108,19 +108,20 @@ export class Ratio extends ccc.Entity.Base() { */ compare(other: Ratio): number { if (this.udtScale === other.udtScale) { - return Number(this.ckbScale - other.ckbScale); + return compareBigInt(this.ckbScale, other.ckbScale); } if (this.ckbScale === other.ckbScale) { - return Number(other.udtScale - this.udtScale); + return compareBigInt(other.udtScale, this.udtScale); } // Idea: o0.Ckb2Udt - o1.Ckb2Udt // ~ o0.ckbScale / o0.udtScale - o1.ckbScale / o1.udtScale // order equivalent to: // ~ o0.ckbScale * o1.udtScale - o1.ckbScale * o0.udtScale - return Number( - this.ckbScale * other.udtScale - other.ckbScale * this.udtScale, + return compareBigInt( + this.ckbScale * other.udtScale, + other.ckbScale * this.udtScale, ); } @@ -451,6 +452,18 @@ export class Info extends ccc.Entity.Base() { } } +function compareBigInt(left: bigint, right: bigint): number { + if (left < right) { + return -1; + } + + if (left > right) { + return 1; + } + + return 0; +} + /** * Represents a structure containing padding and distance values. * diff --git a/packages/order/src/index.ts b/packages/order/src/index.ts index e16a5a6..73dfbda 100644 --- a/packages/order/src/index.ts +++ b/packages/order/src/index.ts @@ -1,3 +1,14 @@ -export * from "./cells.js"; -export * from "./entities.js"; -export * from "./order.js"; +export { MasterCell, OrderCell, OrderGroup } from "./cells.js"; +export { + Info, + MasterCodec, + OrderData, + Ratio, + Relative, + type InfoLike, + type Master, + type MasterLike, + type OrderDataLike, + type RelativeLike, +} from "./entities.js"; +export { OrderManager, type Match } from "./order.js"; diff --git a/packages/order/src/order.test.ts b/packages/order/src/order.test.ts index 6ed589f..7177890 100644 --- a/packages/order/src/order.test.ts +++ b/packages/order/src/order.test.ts @@ -1,10 +1,74 @@ import { ccc } from "@ckb-ccc/core"; +import { defaultFindCellsLimit } from "@ickb/utils"; import { describe, expect, it } from "vitest"; import { OrderCell } from "./cells.js"; -import { Info, OrderData, Ratio } from "./entities.js"; +import { Info, OrderData, Ratio, Relative } from "./entities.js"; import { OrderManager, OrderMatcher } from "./order.js"; +describe("Ratio", () => { + it("compares ratios exactly beyond Number precision", () => { + const scale = 2n ** 60n; + const larger = Ratio.from({ ckbScale: scale + 1n, udtScale: scale }); + const smaller = Ratio.from({ ckbScale: scale, udtScale: scale }); + + expect(Number((scale + 1n) * scale - scale * scale)).toBe( + Number(scale), + ); + expect(larger.compare(smaller)).toBe(1); + expect(smaller.compare(larger)).toBe(-1); + }); +}); + describe("OrderMatcher", () => { + it("sorts effective ratios exactly beyond Number precision", () => { + const order = makeUdtToCkbOrder(); + const scale = 2n ** 60n; + const better = new OrderMatcher( + order, + true, + 1n, + 1n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + scale + 1n, + scale, + ); + const worse = new OrderMatcher( + order, + true, + 1n, + 1n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + scale, + scale, + ); + + expect(Number(scale + 1n) / Number(scale)).toBe(1); + expect(OrderMatcher.compareRealRatioDesc(better, worse)).toBeLessThan(0); + expect(OrderMatcher.compareRealRatioDesc(worse, better)).toBeGreaterThan(0); + }); + + it("reports UDT-to-CKB fee in CKB units", () => { + const result = OrderManager.convert( + false, + Ratio.from({ ckbScale: 2n, udtScale: 1n }), + { ckbValue: 0n, udtValue: 100n }, + { fee: 1n, feeBase: 10n }, + ); + + expect(result.convertedAmount).toBe(45n); + expect(result.ckbFee).toBe(5n); + }); + it("uses udtToCkb scales for UDT-to-CKB orders", () => { const order = makeUdtToCkbOrder(); @@ -41,6 +105,105 @@ describe("OrderMatcher", () => { expect(match.udtDelta).toBeGreaterThan(0n); }); + it("respects a partial cap when selecting the best match", () => { + const orders = [ + makeUdtToCkbOrder({ + txHashByte: "10", + orderTxHashByte: "20", + }), + makeUdtToCkbOrder({ + txHashByte: "11", + orderTxHashByte: "21", + }), + ]; + + const uncapped = OrderManager.bestMatch( + orders, + { + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 0n, + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + const capped = OrderManager.bestMatch( + orders, + { + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 0n, + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + maxPartials: 1, + }, + ); + + expect(uncapped.partials).toHaveLength(2); + expect(capped.partials).toHaveLength(1); + expect(capped.ckbDelta).toBeLessThan(0n); + expect(capped.udtDelta).toBeGreaterThan(0n); + }); + + it("charges one mining fee unit per selected partial", () => { + const order = makeUdtToCkbOrder(); + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: ccc.fixedPointFrom(60), + udtValue: 0n, + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: 1000n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + + expect(match.partials).toHaveLength(1); + expect(match.ckbDelta).toBe(-ccc.fixedPointFrom(40)); + }); + + it("ignores matches whose estimated mining fee exceeds the value gain", () => { + const order = makeUdtToCkbOrder(); + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 0n, + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: ccc.fixedPointFrom(1000), + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + + expect(match).toEqual({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + }); + it("rejects UDT-to-CKB partials below the converted CKB minimum", () => { const order = makeUdtToCkbOrder(); const matcher = OrderMatcher.from(order, false, 0n); @@ -123,7 +286,7 @@ describe("OrderCell.resolve", () => { expect(origin.resolve([lowerValue, higherValue])).toBe(higherValue); }); - it("does not replace equal-progress non-mint candidates by array order", () => { + it("fails closed for ambiguous equal-progress non-mint candidates", () => { const master = { txHash: byte32FromByte("bb"), index: 10n, @@ -157,12 +320,760 @@ describe("OrderCell.resolve", () => { outPoint: { txHash: byte32FromByte("dd"), index: 0n }, }); - expect(origin.resolve([nonMint, otherNonMint])).toBe(nonMint); - expect(origin.resolve([otherNonMint, nonMint])).toBe(otherNonMint); + expect(origin.resolve([nonMint, otherNonMint])).toBeUndefined(); + expect(origin.resolve([otherNonMint, nonMint])).toBeUndefined(); + }); + + it("prefers a mint candidate over an equal-progress non-mint candidate", () => { + const master = { + txHash: byte32FromByte("bc"), + index: 10n, + }; + const info = directionalInfo(); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + }); + const nonMint = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("ce"), index: 0n }, + }); + const mint = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { + type: "relative", + value: Relative.create(1n), + }, + outPoint: { txHash: master.txHash, index: 9n }, + }); + + expect(mint.getMaster().eq(origin.getMaster())).toBe(true); + expect(origin.resolve([nonMint, mint])).toBe(mint); + expect(origin.resolve([mint, nonMint])).toBe(mint); + }); + + it("does not treat duplicate same-outpoint candidates as ambiguous", () => { + const master = { + txHash: byte32FromByte("bd"), + index: 10n, + }; + const info = directionalInfo(); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + }); + const duplicate = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("cf"), index: 0n }, + }); + + expect(origin.resolve([duplicate, duplicate])).toBe(duplicate); + }); +}); + +describe("OrderManager.findOrders", () => { + it("fails closed when order scanning reaches the limit", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "absolute", + value: { txHash: byte32FromByte("33"), index: 1n }, + }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("34"), index: 0n }, + }); + const client = { + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + if (query.scriptType !== "lock") { + return; + } + + for (let index = 0; index < defaultFindCellsLimit; index += 1) { + yield order.cell; + } + }, + } as unknown as ccc.Client; + + await expect(collectOrders(manager, client)).rejects.toThrow( + `order cell scan reached limit ${String(defaultFindCellsLimit)}`, + ); + }); + + it("fails closed when master scanning reaches the limit", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const masterCell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("35"), index: 1n }, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + const client = { + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + if (query.scriptType !== "type") { + return; + } + + for (let index = 0; index < defaultFindCellsLimit; index += 1) { + yield masterCell; + } + }, + } as unknown as ccc.Client; + + await expect(collectOrders(manager, client)).rejects.toThrow( + `master cell scan reached limit ${String(defaultFindCellsLimit)}`, + ); + }); + + it("findOrigin skips parseable non-mint origins in the master transaction", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const originMaster = { txHash: byte32FromByte("55"), index: 2n }; + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const forgedOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(200), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 1n }, + }); + const trueOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(2n), + }, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("56"), index: 0n }, + }); + const masterCell = ccc.Cell.from({ + outPoint: originMaster, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + const tx = ccc.Transaction.default(); + tx.outputs.push(trueOrigin.cell.cellOutput, forgedOrigin.cell.cellOutput, masterCell.cellOutput); + tx.outputsData.push(trueOrigin.cell.outputData, forgedOrigin.cell.outputData, masterCell.outputData); + const client = { + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + if (query.scriptType === "lock") { + yield liveOrder.cell; + } else { + yield masterCell; + } + }, + getTransaction: async (txHash: ccc.Hex) => { + await Promise.resolve(); + return txHash === originMaster.txHash + ? ccc.ClientTransactionResponse.from({ + transaction: tx, + status: "committed", + }) + : undefined; + }, + } as unknown as ccc.Client; + + expect(trueOrigin.data.master.type).toBe("relative"); + if (trueOrigin.data.master.type !== "relative") { + throw new Error("Expected relative master"); + } + expect(trueOrigin.data.master.value.distance).toBe(2n); + expect(trueOrigin.getMaster().eq(originMaster)).toBe(true); + const groups = []; + for await (const group of manager.findOrders(client)) { + groups.push(group); + } + + expect(groups).toHaveLength(1); + expect(groups[0]?.origin.cell.outPoint.eq(trueOrigin.cell.outPoint)).toBe(true); + }); + + it("round-trips non-zero relative master distances", () => { + const encoded = OrderData.from({ + udtValue: 0n, + master: { + type: "relative", + value: Relative.create(2n), + }, + info: directionalInfo(), + }).toBytes(); + + const decoded = OrderData.decode(encoded); + + expect(decoded.master.type).toBe("relative"); + if (decoded.master.type !== "relative") { + throw new Error("Expected relative master"); + } + expect(decoded.master.value.distance).toBe(2n); + }); + + it("findOrigin requires a minted origin in the master transaction", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const originMaster = { txHash: byte32FromByte("65"), index: 1n }; + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const fakeOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("67"), index: 0n }, + }); + const masterCell = ccc.Cell.from({ + outPoint: originMaster, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + const tx = ccc.Transaction.default(); + tx.outputs.push(fakeOrigin.cell.cellOutput, masterCell.cellOutput); + tx.outputsData.push(fakeOrigin.cell.outputData, masterCell.outputData); + const client = { + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + if (query.scriptType === "lock") { + yield liveOrder.cell; + } else { + yield masterCell; + } + }, + getTransaction: async (txHash: ccc.Hex) => { + await Promise.resolve(); + return txHash === originMaster.txHash + ? ccc.ClientTransactionResponse.from({ + transaction: tx, + status: "committed", + }) + : undefined; + }, + } as unknown as ccc.Client; + + const groups = []; + for await (const group of manager.findOrders(client)) { + groups.push(group); + } + + expect(groups).toHaveLength(0); + }); + + it("findOrigin fails closed for multiple minted origins in the master transaction", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const originMaster = { txHash: byte32FromByte("66"), index: 2n }; + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const firstOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(2n), + }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const secondOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 1n }, + }); + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("68"), index: 0n }, + }); + const masterCell = ccc.Cell.from({ + outPoint: originMaster, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + const tx = ccc.Transaction.default(); + tx.outputs.push(firstOrigin.cell.cellOutput, secondOrigin.cell.cellOutput, masterCell.cellOutput); + tx.outputsData.push(firstOrigin.cell.outputData, secondOrigin.cell.outputData, masterCell.outputData); + const client = { + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + if (query.scriptType === "lock") { + yield liveOrder.cell; + } else { + yield masterCell; + } + }, + getTransaction: async (txHash: ccc.Hex) => { + await Promise.resolve(); + return txHash === originMaster.txHash + ? ccc.ClientTransactionResponse.from({ + transaction: tx, + status: "committed", + }) + : undefined; + }, + } as unknown as ccc.Client; + + expect(firstOrigin.getMaster().eq(originMaster)).toBe(true); + expect(secondOrigin.getMaster().eq(originMaster)).toBe(true); + const groups = []; + for await (const group of manager.findOrders(client)) { + groups.push(group); + } + + expect(groups).toHaveLength(0); + }); + + it("uses live queries by default", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const originMaster = { txHash: byte32FromByte("77"), index: 1n }; + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const masterCell = ccc.Cell.from({ + outPoint: originMaster, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + let cachedCalls = 0; + let onChainCalls = 0; + const tx = ccc.Transaction.default(); + tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); + tx.outputsData.push(origin.cell.outputData, masterCell.outputData); + const client = { + findCells: async function* () { + await Promise.resolve(); + cachedCalls += 1; + yield* [] as ccc.Cell[]; + }, + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + onChainCalls += 1; + if (query.scriptType === "lock") { + yield origin.cell; + } else { + yield masterCell; + } + }, + getTransaction: async (txHash: ccc.Hex) => { + await Promise.resolve(); + return txHash === originMaster.txHash + ? ccc.ClientTransactionResponse.from({ + transaction: tx, + status: "committed", + }) + : undefined; + }, + } as unknown as ccc.Client; + + const groups = []; + for await (const group of manager.findOrders(client)) { + groups.push(group); + } + + expect(groups).toHaveLength(1); + expect(cachedCalls).toBe(0); + expect(onChainCalls).toBe(2); + }); + + it("uses cached queries when onChain is false", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const originMaster = { txHash: byte32FromByte("77"), index: 1n }; + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const masterCell = ccc.Cell.from({ + outPoint: originMaster, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + let cachedCalls = 0; + let onChainCalls = 0; + const tx = ccc.Transaction.default(); + tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); + tx.outputsData.push(origin.cell.outputData, masterCell.outputData); + const client = { + findCells: async function* (query: { scriptType: string }) { + await Promise.resolve(); + cachedCalls += 1; + if (query.scriptType === "lock") { + yield origin.cell; + } else { + yield masterCell; + } + }, + findCellsOnChain: async function* () { + await Promise.resolve(); + onChainCalls += 1; + yield* [] as ccc.Cell[]; + }, + getTransaction: async (txHash: ccc.Hex) => { + await Promise.resolve(); + return txHash === originMaster.txHash + ? ccc.ClientTransactionResponse.from({ + transaction: tx, + status: "committed", + }) + : undefined; + }, + } as unknown as ccc.Client; + + const groups = []; + for await (const group of manager.findOrders(client, { onChain: false })) { + groups.push(group); + } + + expect(groups).toHaveLength(1); + expect(cachedCalls).toBe(2); + expect(onChainCalls).toBe(0); + }); + + it("skips groups with ambiguous same-score descendants", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const originMaster = { txHash: byte32FromByte("87"), index: 1n }; + const info = directionalInfo(); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("97"), index: 0n }, + }); + const forgedOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("98"), index: 0n }, + }); + const masterCell = ccc.Cell.from({ + outPoint: originMaster, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + const tx = ccc.Transaction.default(); + tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); + tx.outputsData.push(origin.cell.outputData, masterCell.outputData); + const client = { + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + if (query.scriptType === "lock") { + yield liveOrder.cell; + yield forgedOrder.cell; + } else { + yield masterCell; + } + }, + getTransaction: async (txHash: ccc.Hex) => { + await Promise.resolve(); + return txHash === originMaster.txHash + ? ccc.ClientTransactionResponse.from({ + transaction: tx, + status: "committed", + }) + : undefined; + }, + } as unknown as ccc.Client; + + const groups = []; + for await (const group of manager.findOrders(client)) { + groups.push(group); + } + + expect(groups).toHaveLength(0); + }); + + it("uses live queries when onChain is requested", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const originMaster = { txHash: byte32FromByte("88"), index: 1n }; + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const masterCell = ccc.Cell.from({ + outPoint: originMaster, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + let cachedCalls = 0; + let onChainCalls = 0; + const tx = ccc.Transaction.default(); + tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); + tx.outputsData.push(origin.cell.outputData, masterCell.outputData); + const client = { + findCells: async function* () { + await Promise.resolve(); + cachedCalls += 1; + yield* [] as ccc.Cell[]; + }, + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + onChainCalls += 1; + if (query.scriptType === "lock") { + yield origin.cell; + } else { + yield masterCell; + } + }, + getTransaction: async (txHash: ccc.Hex) => { + await Promise.resolve(); + return txHash === originMaster.txHash + ? ccc.ClientTransactionResponse.from({ + transaction: tx, + status: "committed", + }) + : undefined; + }, + } as unknown as ccc.Client; + + const groups = []; + for await (const group of manager.findOrders(client, { onChain: true })) { + groups.push(group); + } + + expect(groups).toHaveLength(1); + expect(cachedCalls).toBe(0); + expect(onChainCalls).toBe(2); }); }); -function makeUdtToCkbOrder(): OrderCell { +function makeUdtToCkbOrder(options?: { + txHashByte?: string; + orderTxHashByte?: string; + udtValue?: ccc.FixedPoint; +}): OrderCell { const orderScript = ccc.Script.from({ codeHash: byte32FromByte("11"), hashType: "type", @@ -177,7 +1088,7 @@ function makeUdtToCkbOrder(): OrderCell { return OrderCell.mustFrom( ccc.Cell.from({ outPoint: { - txHash: byte32FromByte("44"), + txHash: byte32FromByte(options?.orderTxHashByte ?? "44"), index: 0n, }, cellOutput: { @@ -186,11 +1097,11 @@ function makeUdtToCkbOrder(): OrderCell { type: udtScript, }, outputData: OrderData.from({ - udtValue: ccc.fixedPointFrom(100), + udtValue: options?.udtValue ?? ccc.fixedPointFrom(100), master: { type: "absolute", value: { - txHash: byte32FromByte("33"), + txHash: byte32FromByte(options?.txHashByte ?? "33"), index: 1n, }, }, @@ -224,10 +1135,22 @@ function dualInfo(): Info { }); } +async function collectOrders( + manager: OrderManager, + client: ccc.Client, +): Promise { + const groups = []; + for await (const group of manager.findOrders(client)) { + groups.push(group); + } + return groups; +} + function makeOrderCell(options: { ckbUnoccupied: ccc.FixedPoint; udtValue: ccc.FixedPoint; info: Info; + lock?: ccc.Script; master: { type: "relative"; value: { @@ -256,6 +1179,7 @@ function makeOrderCell(options: { hashType: "type", args: "0x", }); + const lock = options.lock ?? orderScript; const outputData = OrderData.from({ udtValue: options.udtValue, master: options.master, @@ -267,7 +1191,7 @@ function makeOrderCell(options: { index: 0n, }, cellOutput: { - lock: orderScript, + lock, type: udtScript, }, outputData, @@ -278,7 +1202,7 @@ function makeOrderCell(options: { outPoint: options.outPoint, cellOutput: { capacity: minimalCell.cellOutput.capacity + options.ckbUnoccupied, - lock: orderScript, + lock, type: udtScript, }, outputData, diff --git a/packages/order/src/order.ts b/packages/order/src/order.ts index 92d8fe1..5f701e3 100644 --- a/packages/order/src/order.ts +++ b/packages/order/src/order.ts @@ -141,7 +141,7 @@ export class OrderManager implements ScriptDeps { if (amount > 0n && fee !== 0n) { ckbFee = isCkb2Udt ? amount - base.convert(false, convertedAmount, false) - : base.convert(true, amount, false) - convertedAmount; + : base.convert(false, amount, false) - convertedAmount; } // Generate additional conversion info for further processing. @@ -293,6 +293,7 @@ export class OrderManager implements ScriptDeps { * @param options - Optional parameters for matching: * @param options.feeRate - Fee rate for the transaction (defaults to 1000n if not provided). * @param options.ckbAllowanceStep - The step value for CKB allowance (defaults to 1000 CKB as fixed point). + * @param options.maxPartials - Maximum matched partial outputs to keep in the result. * * @returns A Match object containing the best combination of: * • ckbDelta: net change in CKB, @@ -306,6 +307,7 @@ export class OrderManager implements ScriptDeps { options?: { feeRate?: ccc.Num; // Fee rate for the transaction ckbAllowanceStep?: ccc.FixedPoint; + maxPartials?: number; }, ): Match { const orderSize = orderPool[0]?.cell.occupiedSize ?? 0; @@ -325,6 +327,7 @@ export class OrderManager implements ScriptDeps { // ckbAllowanceStep should be 1000 CKB if not provided const ckbAllowanceStep = options?.ckbAllowanceStep ?? ccc.fixedPointFrom("1000"); + const maxPartials = options?.maxPartials; const udtAllowanceStep = (ckbAllowanceStep * ckbScale + udtScale - 1n) / udtScale; @@ -357,7 +360,7 @@ export class OrderManager implements ScriptDeps { udtAllowance: allowance.udtValue, gain: -1n << 256n, }; - while (best.i !== 0 && best.j !== 0) { + while (best.i !== 0 || best.j !== 0) { ckb2UdtMatches.next(best.i); udt2CkbMatches.next(best.j); best.i = 0; @@ -368,10 +371,13 @@ export class OrderManager implements ScriptDeps { const ckbDelta = c2u.ckbDelta + u2c.ckbDelta; const udtDelta = c2u.udtDelta + u2c.udtDelta; const partials = c2u.partials.concat(u2c.partials); - const ckbFee = ckbMiningFee * ccc.fixedPointFrom(partials.length); + if (maxPartials !== undefined && partials.length > maxPartials) { + continue; + } + const ckbFee = ckbMiningFee * BigInt(partials.length); const ckbAllowance = allowance.ckbValue + ckbDelta - ckbFee; const udtAllowance = allowance.udtValue + udtDelta; - const gain = ckbDelta * ckbScale + udtDelta * udtScale; + const gain = (ckbDelta - ckbFee) * ckbScale + udtDelta * udtScale; if (ckbAllowance >= 0n && udtAllowance >= 0n && gain > best.gain) { best = { @@ -433,7 +439,7 @@ export class OrderManager implements ScriptDeps { const matchers = orderPool .map((o) => OrderMatcher.from(o, isCkb2Udt, ckbMiningFee)) .filter((m) => m !== undefined) - .sort((a, b) => b.realRatio - a.realRatio); + .sort((a, b) => OrderMatcher.compareRealRatioDesc(a, b)); // Initialize an accumulator for the cumulative match. let acc: Match = { @@ -540,19 +546,21 @@ export class OrderManager implements ScriptDeps { * - Yield the valid `OrderGroup`. * * @param client – Client to interact with the blockchain. + * @param options.onChain – Defaults to true. When false, use cached cell queries. * @param options.limit – Maximum cells to scan per findCells batch. Defaults to `defaultFindCellsLimit` (400). * @yields OrderGroup instances combining master, order, and origin cells. */ async *findOrders( client: ccc.Client, - options?: { limit?: number }, + options?: { onChain?: boolean; limit?: number }, ): AsyncGenerator { + const onChain = options?.onChain ?? true; const limit = options?.limit ?? defaultFindCellsLimit; // Fetch simple orders & master cells in parallel const [simpleOrders, allMasters] = await Promise.all([ - this.findSimpleOrders(client, limit), - this.findAllMasters(client, limit), + this.findSimpleOrders(client, onChain, limit), + this.findAllMasters(client, onChain, limit), ]); // Prepare a map of masterCellKey → { master, originPromise?, orders[] } @@ -620,16 +628,17 @@ export class OrderManager implements ScriptDeps { * matches the UDT type script, returning only valid {@link OrderCell} instances. * * @param client – The client used to interact with the blockchain. + * @param onChain - When true, use live RPC queries; otherwise, use cached results. * @param limit – Maximum cells to scan per findCells batch. * @returns Promise that resolves to an array of {@link OrderCell}. */ private async findSimpleOrders( client: ccc.Client, + onChain: boolean, limit: number, ): Promise { const orders: OrderCell[] = []; - - for await (const cell of client.findCellsOnChain( + const findCellsArgs = [ { script: this.script, scriptType: "lock", @@ -641,7 +650,13 @@ export class OrderManager implements ScriptDeps { }, "asc", limit, - )) { + ] as const; + + let scanned = 0; + for await (const cell of onChain + ? client.findCellsOnChain(...findCellsArgs) + : client.findCells(...findCellsArgs)) { + scanned += 1; const order = OrderCell.tryFrom(cell); if (!order || !this.isOrder(cell)) { // Skip non-order cells or failed conversions @@ -649,6 +664,7 @@ export class OrderManager implements ScriptDeps { } orders.push(order); } + assertCompleteScan(scanned, limit, "order cell"); return orders; } @@ -660,16 +676,17 @@ export class OrderManager implements ScriptDeps { * then wraps them as {@link MasterCell} instances. * * @param client – The client used to interact with the blockchain. + * @param onChain - When true, use live RPC queries; otherwise, use cached results. * @param limit – Maximum cells to scan per findCells batch. * @returns Promise that resolves to an array of {@link MasterCell}. */ private async findAllMasters( client: ccc.Client, + onChain: boolean, limit: number, ): Promise { const masters: MasterCell[] = []; - - for await (const cell of client.findCellsOnChain( + const findCellsArgs = [ { script: this.script, scriptType: "type", @@ -678,22 +695,29 @@ export class OrderManager implements ScriptDeps { }, "asc", limit, - )) { + ] as const; + + let scanned = 0; + for await (const cell of onChain + ? client.findCellsOnChain(...findCellsArgs) + : client.findCells(...findCellsArgs)) { + scanned += 1; 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; } /** - * Finds the origin order associated with a given master out point. + * Finds the mint origin order associated with a given master out point. * - * Starting from the master cell's index, the method searches backwards first for an order matching the master. - * If not found, it searches forwards until an order is found or there is no more cell. + * The origin is historical transaction output data, not live cell state: it + * 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. @@ -705,33 +729,51 @@ export class OrderManager implements ScriptDeps { master: ccc.OutPoint, ): Promise { const { txHash, index: mIndex } = master; - for (let index = mIndex - 1n; index >= 0n; index--) { - const cell = await client.getCell({ txHash, index }); - if (!cell) { - return; - } - - const order = OrderCell.tryFrom(cell); - if (order?.getMaster().eq(master)) { - return order; - } + const res = await client.getTransaction(txHash); + if (!res) { + return; } - // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition - for (let index = mIndex + 1n; true; index++) { - const cell = await client.getCell({ txHash, index }); - if (!cell) { - return; + let origin: OrderCell | undefined; + for (let index = 0n; index < BigInt(res.transaction.outputs.length); index++) { + if (index === mIndex) { + continue; } + const output = res.transaction.getOutput(index); + if (!output) { + continue; + } + const cell = ccc.Cell.from({ + cellOutput: output.cellOutput, + outputData: output.outputData, + outPoint: { txHash, index }, + }); const order = OrderCell.tryFrom(cell); - if (order?.getMaster().eq(master)) { - return order; + if ( + order && + this.isOrder(cell) && + order.data.isMint() && + order.getMaster().eq(master) + ) { + if (origin) { + return; + } + origin = order; } } + return origin; } } +function assertCompleteScan(scanned: number, limit: number, label: string): void { + if (scanned < limit) { + return; + } + + throw new Error(`${label} scan reached limit ${String(limit)}; state may be incomplete`); +} + /** * Represents a partial match result for an order. */ @@ -787,7 +829,8 @@ export class OrderMatcher { * @param bMinMatch - The minimum matching amount for asset B. * @param bMaxMatch - The maximum amount of asset B that can be matched. * @param bMaxOut - The maximum output amount for asset B. - * @param realRatio - The actual exchange ratio computed based on the available amounts. + * @param realRatioNumerator - Numerator of the exact effective ratio used for sorting. + * @param realRatioDenominator - Denominator of the exact effective ratio used for sorting. */ constructor( public readonly order: OrderCell, @@ -800,9 +843,17 @@ export class OrderMatcher { public readonly bMinMatch: ccc.FixedPoint, public readonly bMaxMatch: ccc.FixedPoint, public readonly bMaxOut: ccc.FixedPoint, - public readonly realRatio: number, + public readonly realRatioNumerator: ccc.FixedPoint, + public readonly realRatioDenominator: ccc.FixedPoint, ) {} + static compareRealRatioDesc(left: OrderMatcher, right: OrderMatcher): number { + return compareBigInt( + right.realRatioNumerator * left.realRatioDenominator, + left.realRatioNumerator * right.realRatioDenominator, + ); + } + /** * Factory method to create an OrderMatcher instance from an order. * @@ -867,10 +918,10 @@ export class OrderMatcher { bMinMatch = bMaxMatch; } - const realRatio = - Number(aIn - aMin - aMiningFee) / Number(bMaxMatch + bMiningFee); + const realRatioNumerator = aIn - aMin - aMiningFee; + const realRatioDenominator = bMaxMatch + bMiningFee; - if (realRatio <= 0) { + if (realRatioNumerator <= 0n || realRatioDenominator <= 0n) { return; } @@ -885,7 +936,8 @@ export class OrderMatcher { bMinMatch, bMaxMatch, bMaxOut, - realRatio, + realRatioNumerator, + realRatioDenominator, ); } @@ -1007,3 +1059,15 @@ export class OrderMatcher { return (aScale * (aIn - aOut) + bScale * (bIn + 1n) - 1n) / bScale; } } + +function compareBigInt(left: bigint, right: bigint): number { + if (left < right) { + return -1; + } + + if (left > right) { + return 1; + } + + return 0; +} From 68a4b509bea190d864a3229f1b2ab0158b308594 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 21:22:47 +0000 Subject: [PATCH 04/17] fix(sdk): own iCKB completion boundary --- README.md | 10 +- apps/bot/src/index.ts | 10 +- apps/interface/src/main.tsx | 10 +- apps/tester/src/index.ts | 10 +- packages/core/README.md | 10 + packages/core/src/udt.ts | 10 +- packages/sdk/src/constants.test.ts | 110 ++++ packages/sdk/src/constants.ts | 96 ++-- packages/sdk/src/sdk.test.ts | 831 ++++++++++++++++++++++++++++- packages/sdk/src/sdk.ts | 363 ++++++++++++- 10 files changed, 1373 insertions(+), 87 deletions(-) create mode 100644 packages/sdk/src/constants.test.ts diff --git a/README.md b/README.md index a220561..ecf4e20 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,18 @@ iCKB Stack is the monorepo for the current TypeScript iCKB libraries and apps bu ## Transaction Completion Boundary -`@ickb/sdk` stops at protocol-specific transaction construction. It returns partial `ccc.Transaction` values and does not finalize iCKB UDT balance, CKB capacity, or fees on behalf of the caller. +`@ickb/sdk` builders still return partial `ccc.Transaction` values. Callers explicitly choose when to finalize, and the shared completion path now also lives in `@ickb/sdk`. Callers own the final completion pipeline: -1. Use `getConfig(...).managers.ickbUdt` to finish iCKB UDT completion. -2. Then run CCC-native CKB capacity and fee completion. +1. Build the partial transaction through `IckbSdk` and the package managers. +2. Before send, call `sdk.completeTransaction(...)` or `completeIckbTransaction(...)` from `@ickb/sdk`. 3. Only then send the transaction. +## 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. + ## Local CCC Workflow The shared CCC baseline lives in `forks/ccc/pin/` and materializes into `forks/ccc/repo/`. diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index d1ece62..010fc47 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -62,19 +62,15 @@ async function main(): Promise { const chain = parseChain(CHAIN); const client = createClient(chain, RPC_URL); - const { managers, bots } = getConfig(chain); + const config = getConfig(chain); + const { managers } = config; const signer = new ccc.SignerCkbPrivateKey(client, BOT_PRIVATE_KEY); const primaryLock = (await signer.getRecommendedAddressObj()).script; const runtime: Runtime = { chain, client, signer, - sdk: new IckbSdk( - managers.ownedOwner, - managers.logic, - managers.order, - bots, - ), + sdk: IckbSdk.fromConfig(config), managers, primaryLock, }; diff --git a/apps/interface/src/main.tsx b/apps/interface/src/main.tsx index e020ecb..3a4b905 100644 --- a/apps/interface/src/main.tsx +++ b/apps/interface/src/main.tsx @@ -10,7 +10,8 @@ import appIcon from "/favicon.png?url"; const appName = "iCKB DApp"; function createRootConfig(chain: "mainnet" | "testnet"): RootConfig { - const { managers, bots } = getConfig(chain); + const config = getConfig(chain); + const { managers } = config; return { chain, @@ -19,12 +20,7 @@ function createRootConfig(chain: "mainnet" | "testnet"): RootConfig { chain === "mainnet" ? new ccc.ClientPublicMainnet() : new ccc.ClientPublicTestnet(), - sdk: new IckbSdk( - managers.ownedOwner, - managers.logic, - managers.order, - bots, - ), + sdk: IckbSdk.fromConfig(config), managers: { ickbUdt: managers.ickbUdt, logic: managers.logic, diff --git a/apps/tester/src/index.ts b/apps/tester/src/index.ts index 26874bb..62066c3 100644 --- a/apps/tester/src/index.ts +++ b/apps/tester/src/index.ts @@ -48,19 +48,15 @@ async function main(): Promise { const chain = parseChain(CHAIN); const client = createClient(chain, RPC_URL); - const { managers, bots } = getConfig(chain); + const config = getConfig(chain); + const { managers } = config; const signer = new ccc.SignerCkbPrivateKey(client, TESTER_PRIVATE_KEY); const primaryLock = (await signer.getRecommendedAddressObj()).script; const runtime: Runtime = { chain, client, signer, - sdk: new IckbSdk( - managers.ownedOwner, - managers.logic, - managers.order, - bots, - ), + sdk: IckbSdk.fromConfig(config), managers, primaryLock, accountLocks: dedupeScripts( diff --git a/packages/core/README.md b/packages/core/README.md index 74485e3..c18db6e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -18,6 +18,16 @@ graph TD; click D "https://github.com/ickb/stack/tree/master/packages/core" "Go to @ickb/core" ``` +## Partial Transactions + +`@ickb/core` transaction builders stop at protocol-specific construction. + +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. + ## Epoch Semantic Versioning This repository follows [Epoch Semantic Versioning](https://antfu.me/posts/epoch-semver). In short ESV aims to provide a more nuanced and effective way to communicate software changes, allowing for better user understanding and smoother upgrades. diff --git a/packages/core/src/udt.ts b/packages/core/src/udt.ts index 0472d54..5d0ecb0 100644 --- a/packages/core/src/udt.ts +++ b/packages/core/src/udt.ts @@ -18,6 +18,7 @@ import type { ExchangeRatio } from "@ickb/utils"; * processing, since only input cells (resolved by CellInput.getCell()) have outPoint. */ export class IckbUdt extends udt.Udt { + public readonly udtCode: ccc.OutPoint; public readonly logicCode: ccc.OutPoint; public readonly logicScript: ccc.Script; public readonly daoManager: DaoManager; @@ -39,6 +40,7 @@ export class IckbUdt extends udt.Udt { daoManager: DaoManager, ) { super(code, script); + this.udtCode = ccc.OutPoint.from(code); this.logicCode = ccc.OutPoint.from(logicCode); this.logicScript = ccc.Script.from(logicScript); this.daoManager = daoManager; @@ -47,8 +49,8 @@ export class IckbUdt extends udt.Udt { /** * Computes the iCKB UDT type script from raw UDT and Logic scripts. * - * Concatenates the iCKB logic script hash with a fixed 4-byte LE length - * postfix ("00000080") to form the UDT type script args. + * Concatenates the iCKB logic script hash with the fixed 4-byte little-endian + * xUDT owner-mode flags postfix ("00000080") to form the UDT type script args. * * @param udt - The raw xUDT script (codeHash and hashType reused). * @param ickbLogic - The iCKB logic script (hash used for args). @@ -158,7 +160,7 @@ export class IckbUdt extends udt.Udt { * Adds iCKB-specific cell dependencies to a transaction. * * Adds individual code deps (not dep group) for: - * - xUDT code cell (this.code from ssri.Trait) + * - xUDT code cell (this.udtCode) * - iCKB Logic code cell (this.logicCode) * * @param txLike - The transaction to add cell deps to. @@ -167,7 +169,7 @@ export class IckbUdt extends udt.Udt { override addCellDeps(txLike: ccc.TransactionLike): ccc.Transaction { const tx = ccc.Transaction.from(txLike); // xUDT code dep - tx.addCellDeps({ outPoint: this.code, depType: "code" }); + tx.addCellDeps({ outPoint: this.udtCode, depType: "code" }); // iCKB Logic code dep tx.addCellDeps({ outPoint: this.logicCode, depType: "code" }); return tx; diff --git a/packages/sdk/src/constants.test.ts b/packages/sdk/src/constants.test.ts new file mode 100644 index 0000000..fb62086 --- /dev/null +++ b/packages/sdk/src/constants.test.ts @@ -0,0 +1,110 @@ +import { ccc } from "@ckb-ccc/core"; +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), + hashType: "data1", + args: "0x", + }); +} + +function outPoint(byte: string): ccc.OutPoint { + return ccc.OutPoint.from({ + txHash: hash(byte), + index: 0n, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("getConfig", () => { + it("uses explicit custom code outpoints instead of cellDep order", () => { + const udt = script("11"); + const logic = script("22"); + const udtCode = outPoint("33"); + const logicCode = outPoint("44"); + const decoyDep = ccc.CellDep.from({ + outPoint: outPoint("55"), + depType: "depGroup", + }); + + const { managers } = getConfig({ + udt: { script: udt, codeOutPoint: udtCode, cellDeps: [decoyDep] }, + logic: { script: logic, codeOutPoint: logicCode, cellDeps: [decoyDep] }, + ownedOwner: { script: script("66"), cellDeps: [decoyDep] }, + order: { script: script("77"), cellDeps: [decoyDep] }, + dao: { script: script("88"), cellDeps: [decoyDep] }, + }); + + expect(managers.ickbUdt.udtCode.eq(udtCode)).toBe(true); + expect(managers.ickbUdt.logicCode.eq(logicCode)).toBe(true); + expect(managers.ickbUdt.script.eq(IckbUdt.typeScriptFrom(udt, logic))).toBe(true); + expect(managers.logic.daoManager).toBe(managers.dao); + expect(managers.ownedOwner.daoManager).toBe(managers.dao); + expect(managers.order.udtScript.eq(managers.ickbUdt.script)).toBe(true); + }); + + it("builds the SDK from one coherent config object", async () => { + const config = getConfig("testnet"); + const sdk = IckbSdk.fromConfig(config); + const tx = ccc.Transaction.default(); + const signer = {} as ccc.Signer; + const completeBy = vi.fn(async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + const completed = ccc.Transaction.from(txLike); + completed.outputsData.push("0x01"); + return completed; + }); + config.managers.ickbUdt.completeBy = completeBy; + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockResolvedValue([ + 0, + false, + ]); + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + expect(sdk).toBeInstanceOf(IckbSdk); + const completed = await sdk.completeTransaction(tx, { + signer, + client: {} as ccc.Client, + feeRate: 1n, + }); + + expect(completeBy).toHaveBeenCalledWith(tx, signer); + expect(completed.outputsData).toEqual(["0x01"]); + }); + + it("rejects custom config missing an explicit code outpoint", () => { + const dep = ccc.CellDep.from({ + outPoint: outPoint("99"), + depType: "depGroup", + }); + + expect(() => getConfig({ + udt: { + script: script("11"), + codeOutPoint: undefined as unknown as ccc.OutPointLike, + cellDeps: [dep], + }, + logic: { + script: script("22"), + codeOutPoint: outPoint("33"), + cellDeps: [dep], + }, + ownedOwner: { script: script("44"), cellDeps: [dep] }, + order: { script: script("55"), cellDeps: [dep] }, + dao: { script: script("66"), cellDeps: [dep] }, + })).toThrow("custom config missing xUDT code outPoint"); + }); +}); diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index ee92703..1c467c5 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -4,6 +4,18 @@ import { DaoManager } from "@ickb/dao"; import { OrderManager } from "@ickb/order"; import { unique, type ScriptDeps } from "@ickb/utils"; +export interface CodeScriptDeps extends ScriptDeps { + codeOutPoint: ccc.OutPointLike; +} + +export interface IckbDeploymentConfig { + udt: CodeScriptDeps; + logic: CodeScriptDeps; + ownedOwner: ScriptDeps; + order: ScriptDeps; + dao: ScriptDeps; +} + /** * Retrieves the configuration for the given deployment environment. * @@ -18,22 +30,12 @@ import { unique, type ScriptDeps } from "@ickb/utils"; * @param bots - An optional array of bot script-like objects to augment the list of known bots. * @returns An object containing the instantiated managers and bots. * - * @remarks `managers.ickbUdt` stays caller-owned on purpose. `IckbSdk` - * builders return partial transactions, so callers should use `ickbUdt` - * for UDT completion before running CCC-native capacity and fee completion. + * @remarks Builders still return partial transactions. `IckbSdk` owns the + * shared iCKB completion path as `sdk.completeTransaction(...)`, which callers + * should invoke explicitly before send. */ export function getConfig( - d: - | "mainnet" - | "testnet" - | { - // For devnet configuration provide explicit script dependencies. - udt: ScriptDeps; - logic: ScriptDeps; - ownedOwner: ScriptDeps; - order: ScriptDeps; - dao: ScriptDeps; - }, + d: "mainnet" | "testnet" | IckbDeploymentConfig, bots: ccc.ScriptLike[] = [], ): { managers: { @@ -45,9 +47,6 @@ export function getConfig( }; bots: ccc.Script[]; } { - // Capture network before d gets reassigned to ScriptDeps. - const network = typeof d === "string" ? d : undefined; - // If deps is provided as a network string, use the pre-defined constants. if (d === "mainnet" || d === "testnet") { bots = bots.concat( @@ -55,8 +54,16 @@ export function getConfig( ); const depGroup = d === "mainnet" ? MAINNET_DEP_GROUP : TESTNET_DEP_GROUP; d = { - udt: from(UDT, depGroup), - logic: from(ICKB_LOGIC, depGroup), + udt: fromWithCode( + UDT, + d === "mainnet" ? MAINNET_XUDT_CODE : TESTNET_XUDT_CODE, + depGroup, + ), + logic: fromWithCode( + ICKB_LOGIC, + d === "mainnet" ? MAINNET_LOGIC_CODE : TESTNET_LOGIC_CODE, + depGroup, + ), ownedOwner: from(OWNED_OWNER, depGroup), order: from(ORDER, depGroup), dao: from(DAO, depGroup), @@ -65,38 +72,13 @@ export function getConfig( const dao = new DaoManager(d.dao.script, d.dao.cellDeps); - // xUDT code cell OutPoint: use known constants for mainnet/testnet, - // fall back to first cellDep's outPoint for devnet. - const xudtCode = network - ? network === "mainnet" - ? MAINNET_XUDT_CODE - : TESTNET_XUDT_CODE - : d.udt.cellDeps[0]?.outPoint; - if (!xudtCode) { - throw new Error( - "devnet config missing xUDT code cell outPoint in udt.cellDeps", - ); - } - - // iCKB Logic code cell OutPoint: same pattern as above. - const logicCode = network - ? network === "mainnet" - ? MAINNET_LOGIC_CODE - : TESTNET_LOGIC_CODE - : d.logic.cellDeps[0]?.outPoint; - if (!logicCode) { - throw new Error( - "devnet config missing Logic code cell outPoint in logic.cellDeps", - ); - } - const ickbUdt = new IckbUdt( - xudtCode, + definedCodeOutPoint(d.udt.codeOutPoint, "xUDT"), IckbUdt.typeScriptFrom( ccc.Script.from(d.udt.script), ccc.Script.from(d.logic.script), ), - logicCode, + definedCodeOutPoint(d.logic.codeOutPoint, "Logic"), d.logic.script, dao, ); @@ -134,6 +116,28 @@ function from(script: ccc.ScriptLike, ...cellDeps: ccc.CellDep[]): ScriptDeps { }; } +function fromWithCode( + script: ccc.ScriptLike, + codeOutPoint: ccc.OutPointLike, + ...cellDeps: ccc.CellDep[] +): CodeScriptDeps { + return { + ...from(script, ...cellDeps), + codeOutPoint, + }; +} + +function definedCodeOutPoint( + codeOutPoint: ccc.OutPointLike | undefined, + label: string, +): ccc.OutPoint { + if (codeOutPoint === undefined) { + throw new Error(`custom config missing ${label} code outPoint`); + } + + return ccc.OutPoint.from(codeOutPoint); +} + /** * DAO (Decentralized Autonomous Organization) lock script information. */ diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index fd1824f..e77f37c 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -1,14 +1,23 @@ import { ccc } from "@ckb-ccc/core"; -import { Info, Ratio } from "@ickb/order"; +import { Info, Ratio, type OrderGroup } from "@ickb/order"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DaoManager } from "@ickb/dao"; import { type IckbDepositCell, LogicManager, OwnedOwnerManager, + type ReceiptCell, + type WithdrawalGroup, } from "@ickb/core"; import { OrderManager } from "@ickb/order"; -import { IckbSdk, type SystemState } from "./sdk.js"; +import { defaultFindCellsLimit } from "@ickb/utils"; +import { + completeIckbTransaction, + IckbSdk, + projectAccountAvailability, + sendAndWaitForCommit, + type SystemState, +} from "./sdk.js"; const ratio = Ratio.from({ ckbScale: 1n, udtScale: 1n }); @@ -47,6 +56,21 @@ function script(byte: string): ccc.Script { }); } +function fakeIckbUdt(udt = script("66")): { + isUdt: (cell: ccc.Cell) => boolean; + infoFrom: () => Promise; + completeBy: (txLike: ccc.TransactionLike) => Promise; +} { + return { + isUdt: (cell: ccc.Cell): boolean => cell.cellOutput.type?.eq(udt) ?? false, + infoFrom: () => Promise.resolve({ capacity: 0n, balance: 0n, count: 0 } as never), + completeBy: async (txLike: ccc.TransactionLike): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + }; +} + async function* once(value: T): AsyncGenerator { yield value; await Promise.resolve(); @@ -57,6 +81,29 @@ async function* none(): AsyncGenerator { yield* [] as T[]; } +async function* repeat(count: number, value: T): AsyncGenerator { + for (let index = 0; index < count; index += 1) { + yield value; + } + await Promise.resolve(); +} + +function orderGroup(options: { + ckbValue: bigint; + udtValue: bigint; + isDualRatio: boolean; + isMatchable: boolean; +}): OrderGroup { + return { + ckbValue: options.ckbValue, + udtValue: options.udtValue, + order: { + isDualRatio: () => options.isDualRatio, + isMatchable: () => options.isMatchable, + }, + } as unknown as OrderGroup; +} + function system(overrides: Partial = {}): SystemState { return { feeRate: 1n, @@ -100,6 +147,22 @@ describe("IckbSdk.estimate", () => { expect(result.ckbFee).toBe(10n); expect(result.maturity).toBe(601234n); }); + + it("uses UDT-to-CKB fee units when deciding preview maturity", () => { + const result = IckbSdk.estimate( + false, + { ckbValue: 0n, udtValue: 100n }, + system({ + exchangeRatio: Ratio.from({ ckbScale: 2n, udtScale: 1n }), + ckbAvailable: 100n, + }), + { fee: 1n, feeBase: 10n }, + ); + + expect(result.convertedAmount).toBe(45n); + expect(result.ckbFee).toBe(5n); + expect(result.maturity).toBeUndefined(); + }); }); describe("IckbSdk.maturity", () => { @@ -160,6 +223,613 @@ describe("IckbSdk.maturity", () => { }); }); +describe("projectAccountAvailability", () => { + it("splits actionable and pending account value", () => { + const readyWithdrawal = { owned: { isReady: true }, ckbValue: 11n, udtValue: 13n }; + const pendingWithdrawal = { + owned: { isReady: false }, + ckbValue: 17n, + udtValue: 19n, + }; + const availableOrder = orderGroup({ + ckbValue: 23n, + udtValue: 29n, + isDualRatio: true, + isMatchable: true, + }); + const pendingOrder = orderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + + const projection = projectAccountAvailability( + { + capacityCells: [{ cellOutput: { capacity: 3n } } as ccc.Cell], + nativeUdtCapacity: 5n, + nativeUdtBalance: 7n, + receipts: [{ ckbValue: 41n, udtValue: 43n } as ReceiptCell], + withdrawalGroups: [ + readyWithdrawal as WithdrawalGroup, + pendingWithdrawal as WithdrawalGroup, + ], + }, + [availableOrder, pendingOrder], + ); + + expect(projection.readyWithdrawals).toEqual([readyWithdrawal]); + expect(projection.pendingWithdrawals).toEqual([pendingWithdrawal]); + expect(projection.availableOrders).toEqual([availableOrder]); + expect(projection.pendingOrders).toEqual([pendingOrder]); + expect(projection.ckbNative).toBe(3n); + expect(projection.ickbNative).toBe(7n); + expect(projection.ckbAvailable).toBe(3n + 41n + 11n + 23n); + expect(projection.ickbAvailable).toBe(7n + 43n + 29n); + expect(projection.ckbPending).toBe(17n + 31n); + expect(projection.ickbPending).toBe(37n); + expect(projection.ckbBalance).toBe(projection.ckbAvailable + projection.ckbPending); + expect(projection.ickbBalance).toBe( + projection.ickbAvailable + projection.ickbPending, + ); + }); + + it("treats non-matchable user orders as actionable", () => { + const nonMatchable = orderGroup({ + ckbValue: 23n, + udtValue: 29n, + isDualRatio: false, + isMatchable: false, + }); + + const projection = projectAccountAvailability( + { + capacityCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + [nonMatchable], + ); + + expect(projection.availableOrders).toEqual([nonMatchable]); + expect(projection.pendingOrders).toEqual([]); + expect(projection.ckbAvailable).toBe(23n); + expect(projection.ickbAvailable).toBe(29n); + }); + + it("keeps matchable non-dual orders pending by default", () => { + const matchable = orderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + + const projection = projectAccountAvailability( + { + capacityCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + [matchable], + ); + + expect(projection.availableOrders).toEqual([]); + expect(projection.pendingOrders).toEqual([matchable]); + expect(projection.ckbAvailable).toBe(0n); + expect(projection.ickbAvailable).toBe(0n); + expect(projection.ckbPending).toBe(31n); + expect(projection.ickbPending).toBe(37n); + }); + + it("can budget collected matchable orders as available", () => { + const matchable = orderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + + const projection = projectAccountAvailability( + { + capacityCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + [matchable], + { collectedOrdersAvailable: true }, + ); + + expect(projection.availableOrders).toEqual([matchable]); + expect(projection.pendingOrders).toEqual([]); + expect(projection.ckbAvailable).toBe(31n); + expect(projection.ickbAvailable).toBe(37n); + expect(projection.ckbPending).toBe(0n); + expect(projection.ickbPending).toBe(0n); + }); + + it("does not count native UDT capacity as spendable CKB", () => { + const projection = projectAccountAvailability( + { + capacityCells: [{ cellOutput: { capacity: 3n } } as ccc.Cell], + nativeUdtCapacity: 5n, + nativeUdtBalance: 7n, + receipts: [], + withdrawalGroups: [], + }, + [], + ); + + expect(projection.ckbNative).toBe(3n); + expect(projection.ckbAvailable).toBe(3n); + expect(projection.ckbBalance).toBe(3n); + }); + + it("does not count withdrawal UDT as available or pending iCKB", () => { + const projection = projectAccountAvailability( + { + capacityCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 7n, + receipts: [], + withdrawalGroups: [ + { owned: { isReady: true }, ckbValue: 11n, udtValue: 13n }, + { owned: { isReady: false }, ckbValue: 17n, udtValue: 19n }, + ] as WithdrawalGroup[], + }, + [], + ); + + expect(projection.ckbAvailable).toBe(11n); + expect(projection.ckbPending).toBe(17n); + expect(projection.ickbAvailable).toBe(7n); + expect(projection.ickbPending).toBe(0n); + expect(projection.ickbBalance).toBe(7n); + }); +}); + +describe("IckbSdk.buildBaseTransaction", () => { + it("requests withdrawals before input-only base activity", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const daoManager = new DaoManager(dao, []); + const logicManager = new LogicManager(logic, [], daoManager); + const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], daoManager); + const orderManager = new OrderManager(order, [], udt); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + orderManager, + [botLock], + ); + const steps: string[] = []; + const requestedDeposit = { + udtValue: 10n, + } as IckbDepositCell; + const requiredLiveDeposit = { + cell: ccc.Cell.from({ + outPoint: { txHash: hash("90"), index: 0n }, + cellOutput: { capacity: 1n, lock: logic }, + outputData: "0x", + }), + } as IckbDepositCell; + + vi.spyOn(ownedOwnerManager, "requestWithdrawal").mockImplementation( + async (txLike, deposits, lock) => { + await Promise.resolve(); + steps.push("request"); + expect(deposits).toEqual([requestedDeposit]); + expect(lock).toEqual(botLock); + const tx = ccc.Transaction.from(txLike); + expect(tx.inputs).toHaveLength(0); + expect(tx.outputs).toHaveLength(0); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: hash("70"), + index: 0n, + }, + }), + ); + tx.outputs.push( + ccc.CellOutput.from({ + capacity: 1n, + lock: botLock, + }), + ); + tx.outputsData.push("0x"); + return tx; + }, + ); + vi.spyOn(orderManager, "melt").mockImplementation((txLike) => { + steps.push("orders"); + const tx = ccc.Transaction.from(txLike); + expect(tx.inputs).toHaveLength(1); + expect(tx.outputs).toHaveLength(1); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: hash("71"), + index: 0n, + }, + }), + ); + return tx; + }); + vi.spyOn(logicManager, "completeDeposit").mockImplementation((txLike) => { + steps.push("receipts"); + const tx = ccc.Transaction.from(txLike); + expect(tx.inputs).toHaveLength(2); + expect(tx.outputs).toHaveLength(1); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: hash("72"), + index: 0n, + }, + }), + ); + return tx; + }); + vi.spyOn(ownedOwnerManager, "withdraw").mockImplementation(async (txLike) => { + await Promise.resolve(); + steps.push("withdrawals"); + const tx = ccc.Transaction.from(txLike); + expect(tx.inputs).toHaveLength(3); + expect(tx.outputs).toHaveLength(1); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: hash("73"), + index: 0n, + }, + }), + ); + return tx; + }); + + const tx = await sdk.buildBaseTransaction(ccc.Transaction.default(), {} as ccc.Client, { + withdrawalRequest: { + deposits: [requestedDeposit], + requiredLiveDeposits: [requiredLiveDeposit], + lock: botLock, + }, + orders: [{} as OrderGroup], + receipts: [{} as ReceiptCell], + readyWithdrawals: [{} as WithdrawalGroup], + }); + + expect(steps).toEqual(["request", "orders", "receipts", "withdrawals"]); + expect(tx.inputs).toHaveLength(4); + expect(tx.outputs).toHaveLength(1); + expect(tx.outputsData).toEqual(["0x"]); + expect(tx.cellDeps).toContainEqual( + ccc.CellDep.from({ + outPoint: requiredLiveDeposit.cell.outPoint, + depType: "code", + }), + ); + }); + + it("accepts withdrawal requests after balanced caller activity", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const daoManager = new DaoManager(dao, []); + const logicManager = new LogicManager(logic, [], daoManager); + const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], daoManager); + const orderManager = new OrderManager(order, [], udt); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + orderManager, + [botLock], + ); + const requestedDeposit = { + udtValue: 10n, + } as IckbDepositCell; + const baseTx = ccc.Transaction.default(); + baseTx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: hash("80"), + index: 0n, + }, + }), + ); + baseTx.outputs.push( + ccc.CellOutput.from({ + capacity: 1n, + lock: botLock, + }), + ); + baseTx.outputsData.push("0x"); + + vi.spyOn(ownedOwnerManager, "requestWithdrawal").mockImplementation( + async (txLike) => { + await Promise.resolve(); + const tx = ccc.Transaction.from(txLike); + expect(tx.inputs).toHaveLength(1); + expect(tx.outputs).toHaveLength(1); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: hash("81"), + index: 0n, + }, + }), + ); + tx.outputs.push( + ccc.CellOutput.from({ + capacity: 2n, + lock: botLock, + }), + ); + tx.outputsData.push("0x"); + return tx; + }, + ); + vi.spyOn(orderManager, "melt").mockImplementation((txLike) => { + const tx = ccc.Transaction.from(txLike); + expect(tx.inputs).toHaveLength(2); + expect(tx.outputs).toHaveLength(2); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: hash("82"), + index: 0n, + }, + }), + ); + return tx; + }); + + const tx = await sdk.buildBaseTransaction(baseTx, {} as ccc.Client, { + withdrawalRequest: { + deposits: [requestedDeposit], + lock: botLock, + }, + orders: [{} as OrderGroup], + }); + + expect(tx.inputs).toHaveLength(3); + expect(tx.outputs).toHaveLength(2); + expect(tx.outputsData).toEqual(["0x", "0x"]); + }); + + it("lets callers append a deposit after the withdrawal request path", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const daoManager = new DaoManager(dao, []); + const logicManager = new LogicManager(logic, [], daoManager); + const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], daoManager); + const orderManager = new OrderManager(order, [], udt); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + orderManager, + [botLock], + ); + const calls: string[] = []; + const requestedDeposit = { + udtValue: 10n, + } as IckbDepositCell; + + vi.spyOn(ownedOwnerManager, "requestWithdrawal").mockImplementation( + async (txLike) => { + await Promise.resolve(); + calls.push("request"); + const tx = ccc.Transaction.from(txLike); + expect(tx.outputs).toHaveLength(0); + tx.outputs.push( + ccc.CellOutput.from({ + capacity: 1n, + lock: botLock, + }), + ); + tx.outputsData.push("0x"); + return tx; + }, + ); + vi.spyOn(logicManager, "deposit").mockImplementation(async (txLike) => { + await Promise.resolve(); + calls.push("deposit"); + const tx = ccc.Transaction.from(txLike); + expect(tx.outputs).toHaveLength(1); + tx.outputs.push( + ccc.CellOutput.from({ + capacity: 2n, + lock: botLock, + }), + ); + tx.outputsData.push("0x"); + return tx; + }); + + let tx = await sdk.buildBaseTransaction(ccc.Transaction.default(), {} as ccc.Client, { + withdrawalRequest: { + deposits: [requestedDeposit], + lock: botLock, + }, + }); + tx = await logicManager.deposit(tx, 1, 2n, botLock, {} as ccc.Client); + + expect(calls).toEqual(["request", "deposit"]); + expect(tx.outputs).toHaveLength(2); + }); + + it("lets DAO withdrawal own unbalanced caller prework rejection", 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 sdk = new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [botLock], + ); + const tx = ccc.Transaction.default(); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: hash("84"), + index: 0n, + }, + }), + ); + + await expect( + sdk.buildBaseTransaction(tx, {} as ccc.Client, { + withdrawalRequest: { + deposits: [{ udtValue: 10n } as IckbDepositCell], + lock: botLock, + }, + }), + ).rejects.toThrow("Transaction has different inputs and outputs lengths"); + }); +}); + +describe("completeIckbTransaction", () => { + it("runs UDT, fee, DAO-limit in order", async () => { + const calls: string[] = []; + const signer = {} as ccc.Signer; + const client = {} as ccc.Client; + const tx = ccc.Transaction.default(); + const ickbUdt = fakeIckbUdt(); + vi.spyOn(ickbUdt, "completeBy").mockImplementation(async (txLike) => { + calls.push("udt"); + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }); + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockImplementation(() => { + calls.push("fee"); + return Promise.resolve([0, false]); + }); + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockImplementation(() => { + calls.push("dao-limit"); + return Promise.resolve(false); + }); + + const completed = await completeIckbTransaction(tx, ickbUdt, { + signer, + client, + feeRate: 42n, + }); + + expect(completed).toBeInstanceOf(ccc.Transaction); + expect(calls).toEqual(["udt", "fee", "dao-limit"]); + }); + + it("uses the provided fee rate", async () => { + const signer = {} as ccc.Signer; + const client = {} as ccc.Client; + const completeFeeBy = vi + .spyOn(ccc.Transaction.prototype, "completeFeeBy") + .mockResolvedValue([0, false]); + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + await completeIckbTransaction(ccc.Transaction.default(), fakeIckbUdt(), { + signer, + client, + feeRate: 123n, + }); + + expect(completeFeeBy).toHaveBeenCalledWith(signer, 123n); + }); + +}); + +describe("sendAndWaitForCommit", () => { + it("waits for a sent transaction to commit before returning the hash", async () => { + const txHash = hash("a1"); + const sleep = vi.fn(() => Promise.resolve()); + const onConfirmationWait = vi.fn(); + const sendTransaction = vi.fn().mockResolvedValue(txHash); + const getTransaction = vi + .fn() + .mockResolvedValueOnce({ status: "pending" }) + .mockResolvedValueOnce({ status: "unknown" }) + .mockResolvedValueOnce({ status: "committed" }); + + await expect(sendAndWaitForCommit( + { + client: { getTransaction } as unknown as ccc.Client, + signer: { sendTransaction } as unknown as ccc.Signer, + }, + ccc.Transaction.default(), + { + confirmationIntervalMs: 7, + onConfirmationWait, + sleep, + }, + )).resolves.toBe(txHash); + + expect(sendTransaction).toHaveBeenCalledTimes(1); + expect(onConfirmationWait).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledWith(7); + expect(getTransaction).toHaveBeenCalledTimes(3); + expect(getTransaction).toHaveBeenCalledWith(txHash); + }); + + it("surfaces terminal transaction failures", async () => { + await expect(sendAndWaitForCommit( + { + client: { + getTransaction: vi.fn().mockResolvedValue({ status: "rejected" }), + } as unknown as ccc.Client, + signer: { + sendTransaction: vi.fn().mockResolvedValue(hash("a2")), + } as unknown as ccc.Signer, + }, + ccc.Transaction.default(), + { sleep: () => Promise.resolve() }, + )).rejects.toThrow("Transaction ended with status: rejected"); + }); + + it("surfaces transaction confirmation timeouts", async () => { + await expect(sendAndWaitForCommit( + { + client: { + getTransaction: vi.fn().mockResolvedValue({ status: "unknown" }), + } as unknown as ccc.Client, + signer: { + sendTransaction: vi.fn().mockResolvedValue(hash("a3")), + } as unknown as ccc.Signer, + }, + ccc.Transaction.default(), + { + maxConfirmationChecks: 1, + sleep: () => Promise.resolve(), + }, + )).rejects.toThrow("Transaction confirmation timed out"); + }); +}); + describe("IckbSdk.getL1State snapshot detection", () => { it("ignores bot data cells and falls back to direct deposit scanning", async () => { const botLock = script("11"); @@ -169,6 +839,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { const order = script("55"); const udt = script("66"); const sdk = new IckbSdk( + fakeIckbUdt(udt), new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), new LogicManager(logic, [], new DaoManager(dao, [])), new OrderManager(order, [], udt), @@ -256,6 +927,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { const findDeposits = vi.spyOn(logicManager, "findDeposits").mockImplementation(() => once(readyDeposit)); vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); const sdk = new IckbSdk( + fakeIckbUdt(udt), ownedOwnerManager, logicManager, new OrderManager(order, [], udt), @@ -275,4 +947,159 @@ describe("IckbSdk.getL1State snapshot detection", () => { expect(state.system.ckbAvailable).toBe(ccc.fixedPointFrom(100082)); expect(state.system.ckbMaturing).toEqual([]); }); + + it("fails closed when bot capacity scanning reaches the limit", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [botLock], + ); + const plainCell = ccc.Cell.from({ + outPoint: { txHash: hash("04"), index: 0n }, + cellOutput: { capacity: 1n, lock: botLock }, + outputData: "0x", + }); + const client = { + getTipHeader: () => Promise.resolve(headerLike(1n)), + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: async function* (query: { scriptType?: string }) { + if (query.scriptType === "lock") { + yield* repeat(defaultFindCellsLimit, plainCell); + } + await Promise.resolve(); + }, + } as unknown as ccc.Client; + + await expect(sdk.getL1State(client, [])).rejects.toThrow( + `bot capacity scan reached limit ${String(defaultFindCellsLimit)}`, + ); + }); + + it("fails closed when direct deposit scanning reaches the limit", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const logicManager = new LogicManager(logic, [], new DaoManager(dao, [])); + const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); + const deposit = { + isReady: false, + ckbValue: 1n, + maturity: { toUnix: () => 1n }, + } as unknown as IckbDepositCell; + vi.spyOn(logicManager, "findDeposits").mockImplementation(() => + repeat(defaultFindCellsLimit, deposit) + ); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + new OrderManager(order, [], udt), + [botLock], + ); + const client = { + getTipHeader: () => Promise.resolve(headerLike(1n)), + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => none(), + } as unknown as ccc.Client; + + await expect(sdk.getL1State(client, [])).rejects.toThrow( + `iCKB deposit scan reached limit ${String(defaultFindCellsLimit)}`, + ); + }); +}); + +describe("IckbSdk.getAccountState", () => { + it("collects account cells, receipts, withdrawals, and native iCKB balance", async () => { + const accountLock = script("11"); + const udt = script("66"); + const receipt = { ckbValue: 13n, udtValue: 17n } as ReceiptCell; + const withdrawal = { owned: { isReady: true }, ckbValue: 19n } as WithdrawalGroup; + const udtCell = ccc.Cell.from({ + outPoint: { txHash: hash("90"), index: 0n }, + cellOutput: { capacity: 7n, lock: accountLock, type: udt }, + outputData: "0x01", + }); + const capacityCell = ccc.Cell.from({ + outPoint: { txHash: hash("91"), index: 0n }, + cellOutput: { capacity: 5n, lock: accountLock }, + outputData: "0x", + }); + const daoManager = new DaoManager(script("33"), []); + const logicManager = new LogicManager(script("22"), [], daoManager); + const ownedOwnerManager = new OwnedOwnerManager(script("44"), [], daoManager); + const ickbUdt = fakeIckbUdt(udt); + vi.spyOn(ickbUdt, "infoFrom").mockResolvedValue({ + capacity: 7n, + balance: 11n, + count: 1, + } as never); + vi.spyOn(logicManager, "findReceipts").mockImplementation(() => once(receipt)); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => once(withdrawal)); + const sdk = new IckbSdk( + ickbUdt, + ownedOwnerManager, + logicManager, + new OrderManager(script("55"), [], udt), + [], + ); + const client = { + findCellsOnChain: async function* () { + yield capacityCell; + yield udtCell; + await Promise.resolve(); + }, + } as unknown as ccc.Client; + + const state = await sdk.getAccountState(client, [accountLock, accountLock], tip); + + expect(state.capacityCells).toEqual([capacityCell]); + expect(state.nativeUdtCapacity).toBe(7n); + expect(state.nativeUdtBalance).toBe(11n); + expect(state.receipts).toEqual([receipt]); + expect(state.withdrawalGroups).toEqual([withdrawal]); + expect(ickbUdt.infoFrom).toHaveBeenCalledWith(client, [udtCell]); + }); + + it("fails closed when account cell scanning reaches the limit", async () => { + const accountLock = script("11"); + const udt = script("66"); + const daoManager = new DaoManager(script("33"), []); + const logicManager = new LogicManager(script("22"), [], daoManager); + const ownedOwnerManager = new OwnedOwnerManager(script("44"), [], daoManager); + vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + new OrderManager(script("55"), [], udt), + [], + ); + const cell = ccc.Cell.from({ + outPoint: { txHash: hash("92"), index: 0n }, + cellOutput: { capacity: 5n, lock: accountLock }, + outputData: "0x", + }); + const client = { + findCellsOnChain: () => repeat(defaultFindCellsLimit, cell), + } as unknown as ccc.Client; + + await expect(sdk.getAccountState(client, [accountLock], tip)).rejects.toThrow( + `account scan reached limit ${String(defaultFindCellsLimit)}`, + ); + }); }); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 9591a32..1d0059a 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -1,16 +1,22 @@ import { ccc } from "@ckb-ccc/core"; +import { assertDaoOutputLimit } from "@ickb/dao"; import { collect, binarySearch, + defaultFindCellsLimit, isPlainCapacityCell, unique, type ValueComponents, } from "@ickb/utils"; import { convert, + type IckbDepositCell, + type IckbUdt, ickbExchangeRatio, type LogicManager, type OwnedOwnerManager, + type ReceiptCell, + type WithdrawalGroup, } from "@ickb/core"; import { Info, @@ -21,23 +27,104 @@ import { } from "@ickb/order"; import { getConfig } from "./constants.js"; +export interface CompleteIckbTransactionOptions { + signer: ccc.Signer; + client: ccc.Client; + feeRate: ccc.Num; +} + +export interface SendAndWaitForCommitOptions { + maxConfirmationChecks?: number; + confirmationIntervalMs?: number; + onConfirmationWait?: () => void; + sleep?: (ms: number) => Promise; +} + +type IckbUdtCompleter = Pick; + +/** + * Completes a stack-built partial transaction with the iCKB post-processing + * steps. + * + * The transaction completion boundary stays the same: callers still decide when + * to finalize, but they no longer need to duplicate the required order. + */ +export async function completeIckbTransaction( + txLike: ccc.TransactionLike, + ickbUdt: IckbUdtCompleter, + options: CompleteIckbTransactionOptions, +): Promise { + const tx = await ickbUdt.completeBy(txLike, options.signer); + await tx.completeFeeBy(options.signer, options.feeRate); + await assertDaoOutputLimit(tx, options.client); + return tx; +} + +export async function sendAndWaitForCommit( + { client, signer }: { client: ccc.Client; signer: ccc.Signer }, + tx: ccc.Transaction, + { + maxConfirmationChecks = 60, + confirmationIntervalMs = 10_000, + onConfirmationWait, + sleep = delay, + }: SendAndWaitForCommitOptions = {}, +): Promise { + const txHash = await signer.sendTransaction(tx); + let status: string | undefined = "sent"; + + for (let checks = 0; checks < maxConfirmationChecks && isPendingStatus(status); checks += 1) { + onConfirmationWait?.(); + await sleep(confirmationIntervalMs); + status = (await client.getTransaction(txHash))?.status; + } + + if (status === "committed") { + return txHash; + } + + if (isPendingStatus(status)) { + throw new Error("Transaction confirmation timed out"); + } + + throw new Error(`Transaction ended with status: ${status ?? "unknown"}`); +} + +function isPendingStatus(status: string | undefined): boolean { + return ( + status === undefined || + status === "sent" || + status === "pending" || + status === "proposed" || + status === "unknown" + ); +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + /** * SDK for managing iCKB operations. * * This facade intentionally stops at protocol-specific transaction construction. - * Callers complete iCKB UDT balance first, then CKB capacity and fees, with - * CCC-native APIs before sending the transaction. + * Callers still own completion before send by explicitly calling + * `completeTransaction(...)`. */ export class IckbSdk { /** * Creates an instance of IckbSdk. * + * @param ickbUdt - The manager for iCKB UDT completion and account balance. * @param ownedOwner - The manager for owned owner operations. * @param ickbLogic - The manager for iCKB logic operations. * @param order - The manager for order operations. * @param bots - An array of bot lock scripts. */ constructor( + private readonly ickbUdt: IckbUdtCompleter, private readonly ownedOwner: OwnedOwnerManager, private readonly ickbLogic: LogicManager, private readonly order: OrderManager, @@ -51,12 +138,23 @@ export class IckbSdk { * @returns A new instance of IckbSdk. */ static from(...args: Parameters): IckbSdk { + return IckbSdk.fromConfig(getConfig(...args)); + } + + static fromConfig(config: ReturnType): IckbSdk { const { - managers: { ownedOwner, logic, order }, + managers: { ickbUdt, ownedOwner, logic, order }, bots, - } = getConfig(...args); + } = config; - return new IckbSdk(ownedOwner, logic, order, bots); + return new IckbSdk(ickbUdt, ownedOwner, logic, order, bots); + } + + async completeTransaction( + txLike: ccc.TransactionLike, + options: CompleteIckbTransactionOptions, + ): Promise { + return completeIckbTransaction(txLike, this.ickbUdt, options); } /** @@ -227,8 +325,7 @@ export class IckbSdk { * @returns A Promise resolving to the updated transaction. * * @remarks The returned transaction is not finalized. Callers own the - * completion pipeline: finish iCKB UDT completion first, then CKB - * capacity/fee completion, before sending. + * completion pipeline and may use `completeTransaction(...)` before send. */ async request( txLike: ccc.TransactionLike, @@ -260,8 +357,7 @@ export class IckbSdk { * @returns The updated transaction. * * @remarks The returned transaction is not finalized. Callers own the - * completion pipeline: finish iCKB UDT completion first, then CKB - * capacity/fee completion, before sending. + * completion pipeline and may use `completeTransaction(...)` before send. */ collect( txLike: ccc.TransactionLike, @@ -273,6 +369,96 @@ export class IckbSdk { return this.order.melt(txLike, groups, options); } + /** + * Builds the shared partial transaction from currently actionable account state. + * + * This keeps the order of stack-owned steps in one place: optional withdrawal + * requests first, then collect user orders, complete ready receipts, and + * finalize ready withdrawals. + * + * @param txLike - The transaction to extend. + * @param client - The blockchain client used by withdrawal completion. + * @param options.withdrawalRequest - Optional DAO withdrawal request to append + * before the input-only base activity. + * @param options.withdrawalRequest.requiredLiveDeposits - Live deposit anchors + * that must remain resolvable while the requested deposits are spent. + * @param options.orders - User-owned order groups to collect. + * @param options.receipts - Receipts ready for deposit phase 2 completion. + * @param options.readyWithdrawals - Mature withdrawal groups ready to complete. + * @returns A Promise resolving to the updated partial transaction. + */ + async buildBaseTransaction( + txLike: ccc.TransactionLike, + client: ccc.Client, + options?: { + withdrawalRequest?: { + deposits: IckbDepositCell[]; + requiredLiveDeposits?: IckbDepositCell[]; + lock: ccc.Script; + }; + orders?: OrderGroup[]; + receipts?: ReceiptCell[]; + readyWithdrawals?: WithdrawalGroup[]; + }, + ): Promise { + let tx = ccc.Transaction.from(txLike); + + if (options?.withdrawalRequest?.deposits.length) { + tx = await this.ownedOwner.requestWithdrawal( + tx, + options.withdrawalRequest.deposits, + options.withdrawalRequest.lock, + client, + ); + for (const deposit of options.withdrawalRequest.requiredLiveDeposits ?? []) { + tx.addCellDeps({ outPoint: deposit.cell.outPoint, depType: "code" }); + } + } + + if (options?.orders?.length) { + tx = this.collect(tx, options.orders); + } + + if (options?.receipts?.length) { + tx = this.ickbLogic.completeDeposit(tx, options.receipts); + } + + if (options?.readyWithdrawals?.length) { + tx = await this.ownedOwner.withdraw(tx, options.readyWithdrawals, client); + } + + return tx; + } + + async getAccountState( + client: ccc.Client, + locks: ccc.Script[], + tip: ccc.ClientBlockHeader, + ): Promise { + const [cells, receipts, withdrawalGroups] = await Promise.all([ + this.findAccountCells(client, locks), + collect(this.ickbLogic.findReceipts(client, locks, { onChain: true })), + collect( + this.ownedOwner.findWithdrawalGroups(client, locks, { + onChain: true, + tip, + }), + ), + ]); + const nativeUdtInfo = await this.ickbUdt.infoFrom( + client, + cells.filter((cell) => this.ickbUdt.isUdt(cell)), + ); + + return { + capacityCells: cells.filter(isPlainCapacityCell), + nativeUdtCapacity: nativeUdtInfo.capacity, + nativeUdtBalance: nativeUdtInfo.balance, + receipts, + withdrawalGroups, + }; + } + /** * Retrieves the L1 state from the blockchain. * @@ -298,7 +484,7 @@ export class IckbSdk { // Parallel fetching of system components. const [{ ckbAvailable, ckbMaturing }, orders, feeRate] = await Promise.all([ this.getCkb(client, tip), - collect(this.order.findOrders(client)), + collect(this.order.findOrders(client, { onChain: true })), client.getFeeRate(), ]); @@ -366,9 +552,11 @@ export class IckbSdk { ckbAvailable: ccc.FixedPoint; ckbMaturing: CkbCumulative[]; }> { + const limit = defaultFindCellsLimit; const opts = { onChain: true, tip, + limit, }; // Start fetching bot iCKB withdrawal requests. @@ -380,6 +568,7 @@ export class IckbSdk { 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, @@ -391,8 +580,9 @@ export class IckbSdk { withData: true, }, "asc", - 400, + limit, )) { + scanned += 1; if (cell.cellOutput.type !== undefined || !cell.cellOutput.lock.eq(lock)) { continue; } @@ -404,6 +594,7 @@ export class IckbSdk { bot2Ckb.set(key, ckb); } } + assertCompleteScan(scanned, limit, "bot capacity", lock); } const ckbMaturing = new Array<{ @@ -437,7 +628,9 @@ 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, opts)) { + depositsScanned += 1; if (d.isReady) { ckbAvailable += d.ckbValue; continue; @@ -448,6 +641,7 @@ 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)); @@ -465,6 +659,153 @@ export class IckbSdk { ckbMaturing: ckbCumulativeMaturing, }; } + + private async findAccountCells( + client: ccc.Client, + locks: ccc.Script[], + ): Promise { + 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", + limit, + )) { + scanned += 1; + cells.push(cell); + } + assertCompleteScan(scanned, limit, "account", lock); + } + return cells; + } +} + +function assertCompleteScan( + scanned: number, + limit: number, + label: string, + lock?: ccc.Script, +): void { + if (scanned < limit) { + return; + } + + const suffix = lock ? ` for ${lock.toHex()}` : ""; + throw new Error(`${label} scan reached limit ${String(limit)}${suffix}; state may be incomplete`); +} + +export interface AccountState { + capacityCells: ccc.Cell[]; + nativeUdtCapacity: bigint; + nativeUdtBalance: bigint; + receipts: ReceiptCell[]; + withdrawalGroups: WithdrawalGroup[]; +} + +export interface AccountAvailabilityProjection { + ckbNative: bigint; + ickbNative: bigint; + ckbAvailable: bigint; + ickbAvailable: bigint; + ckbPending: bigint; + ickbPending: bigint; + ckbBalance: bigint; + ickbBalance: bigint; + readyWithdrawals: WithdrawalGroup[]; + pendingWithdrawals: WithdrawalGroup[]; + availableOrders: OrderGroup[]; + pendingOrders: OrderGroup[]; +} + +export function projectAccountAvailability( + account: AccountState, + userOrders: OrderGroup[], + options?: { + /** + * Treat matchable orders as available only when the caller will collect them + * before spending the projected balance in the same transaction. + */ + collectedOrdersAvailable?: boolean; + }, +): AccountAvailabilityProjection { + const readyWithdrawals: WithdrawalGroup[] = []; + const pendingWithdrawals: WithdrawalGroup[] = []; + for (const group of account.withdrawalGroups) { + if (group.owned.isReady) { + readyWithdrawals.push(group); + } else { + pendingWithdrawals.push(group); + } + } + + const availableOrders: OrderGroup[] = []; + const pendingOrders: OrderGroup[] = []; + for (const group of userOrders) { + if ( + options?.collectedOrdersAvailable || + group.order.isDualRatio() || + !group.order.isMatchable() + ) { + availableOrders.push(group); + } else { + pendingOrders.push(group); + } + } + + const ckbNative = sumValues( + account.capacityCells, + (cell) => cell.cellOutput.capacity, + ); + const ickbNative = account.nativeUdtBalance; + const ckbAvailable = + ckbNative + + sumCkb(account.receipts) + + sumCkb(readyWithdrawals) + + sumCkb(availableOrders); + const ickbAvailable = + ickbNative + + sumUdt(account.receipts) + + sumUdt(availableOrders); + const ckbPending = sumCkb(pendingWithdrawals) + sumCkb(pendingOrders); + const ickbPending = sumUdt(pendingOrders); + + return { + ckbNative, + ickbNative, + ckbAvailable, + ickbAvailable, + ckbPending, + ickbPending, + ckbBalance: ckbAvailable + ckbPending, + ickbBalance: ickbAvailable + ickbPending, + readyWithdrawals, + pendingWithdrawals, + availableOrders, + pendingOrders, + }; +} + +function sumCkb(items: { ckbValue: bigint }[]): bigint { + return sumValues(items, (item) => item.ckbValue); +} + +function sumUdt(items: { udtValue: bigint }[]): bigint { + return sumValues(items, (item) => item.udtValue); +} + +function sumValues(items: readonly T[], project: (item: T) => bigint): bigint { + let total = 0n; + for (const item of items) { + total += project(item); + } + return total; } /** From 9d3e156f1d8de586702b607d798711991f6032c6 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 21:22:54 +0000 Subject: [PATCH 05/17] test: strengthen library validation --- package.json | 5 +++-- packages/core/package.json | 7 ++++--- packages/dao/package.json | 4 ++-- packages/order/package.json | 7 ++++--- packages/sdk/package.json | 4 ++-- packages/utils/package.json | 4 ++-- pnpm-lock.yaml | 9 +++++++-- scripts/forks-ccc-smoke.mjs | 6 +++--- tsconfig.json | 3 ++- 9 files changed, 29 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index b05e364..8516cf5 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "check:base": "pnpm clean:deep && pnpm check:ccc-overrides && pnpm forks:bootstrap && pnpm install && pnpm forks:ccc && pnpm forks:ccc:smoke && pnpm lint && pnpm build:all && pnpm test:ci", "check:ccc-overrides": "node scripts/check-ccc-overrides.mjs", "check:fresh": "rm -f pnpm-lock.yaml && pnpm check", - "test": "vitest", - "test:ci": "vitest run && node --test scripts/*.test.mjs", + "test": "NODE_OPTIONS='--disable-warning=DEP0040' vitest", + "test:ci": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run && node --test scripts/*.test.mjs && pnpm test:ccc", + "test:ccc": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run --root forks/ccc/repo --project @ckb-ccc/core && NODE_OPTIONS='--disable-warning=DEP0040' vitest run --root forks/ccc/repo --project @ckb-ccc/udt -t 'infoFrom|isUdt'", "lint": "pnpm -r --filter '!./forks/**' lint", "clean": "rm -fr dist packages/*/dist apps/*/dist", "clean:deep": "pnpm clean && rm -fr node_modules packages/*/node_modules apps/*/node_modules forks/ccc/repo/packages/*/tsconfig.tsbuildinfo", diff --git a/packages/core/package.json b/packages/core/package.json index c82951e..b6d8ddd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,8 +29,8 @@ } }, "scripts": { - "test": "vitest", - "test:ci": "vitest run", + "test": "NODE_OPTIONS='--disable-warning=DEP0040' vitest", + "test:ci": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run", "build": "tsc", "lint": "eslint ./src", "clean": "rm -fr dist", @@ -55,6 +55,7 @@ "@ckb-ccc/core": "catalog:", "@ckb-ccc/udt": "catalog:", "@ickb/dao": "workspace:*", - "@ickb/utils": "workspace:*" + "@ickb/utils": "workspace:*", + "tslib": "^2.8.1" } } diff --git a/packages/dao/package.json b/packages/dao/package.json index 8252080..d09053d 100644 --- a/packages/dao/package.json +++ b/packages/dao/package.json @@ -29,8 +29,8 @@ } }, "scripts": { - "test": "vitest", - "test:ci": "vitest run", + "test": "NODE_OPTIONS='--disable-warning=DEP0040' vitest", + "test:ci": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run", "build": "tsc", "lint": "eslint ./src", "clean": "rm -fr dist", diff --git a/packages/order/package.json b/packages/order/package.json index 99c971a..4eb5ba8 100644 --- a/packages/order/package.json +++ b/packages/order/package.json @@ -29,8 +29,8 @@ } }, "scripts": { - "test": "vitest", - "test:ci": "vitest run", + "test": "NODE_OPTIONS='--disable-warning=DEP0040' vitest", + "test:ci": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run", "build": "tsc", "lint": "eslint ./src", "clean": "rm -fr dist", @@ -53,6 +53,7 @@ }, "dependencies": { "@ckb-ccc/core": "catalog:", - "@ickb/utils": "workspace:*" + "@ickb/utils": "workspace:*", + "tslib": "^2.8.1" } } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f468590..df28ca1 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -29,8 +29,8 @@ } }, "scripts": { - "test": "vitest", - "test:ci": "vitest run", + "test": "NODE_OPTIONS='--disable-warning=DEP0040' vitest", + "test:ci": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run", "build": "tsc", "lint": "eslint ./src", "clean": "rm -fr dist", diff --git a/packages/utils/package.json b/packages/utils/package.json index 880448f..9c82fa8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -29,8 +29,8 @@ } }, "scripts": { - "test": "vitest", - "test:ci": "vitest run", + "test": "NODE_OPTIONS='--disable-warning=DEP0040' vitest", + "test:ci": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run", "build": "tsc", "lint": "eslint ./src", "clean": "rm -fr dist", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41bff6f..39b0f53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1069,6 +1069,9 @@ importers: '@ickb/utils': specifier: workspace:* version: link:../utils + tslib: + specifier: ^2.8.1 + version: 2.8.1 packages/dao: dependencies: @@ -1087,6 +1090,9 @@ importers: '@ickb/utils': specifier: workspace:* version: link:../utils + tslib: + specifier: ^2.8.1 + version: 2.8.1 packages/sdk: dependencies: @@ -5939,8 +5945,7 @@ snapshots: tslib@2.7.0: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} type-check@0.4.0: dependencies: diff --git a/scripts/forks-ccc-smoke.mjs b/scripts/forks-ccc-smoke.mjs index a480dd4..fee5eb9 100644 --- a/scripts/forks-ccc-smoke.mjs +++ b/scripts/forks-ccc-smoke.mjs @@ -6,17 +6,17 @@ const targets = [ { filter: "@ickb/utils", script: - "const mod = await import('@ckb-ccc/core'); if (!('ccc' in mod)) throw new Error('Missing ccc namespace export from @ckb-ccc/core');", + "const { ccc } = await import('@ckb-ccc/core'); if (!ccc) throw new Error('Missing ccc namespace export from @ckb-ccc/core'); const lock = ccc.Script.from({ codeHash: '0x' + '11'.repeat(32), hashType: 'type', args: '0x1234' }); if (!lock.eq({ codeHash: '0x' + '11'.repeat(32), hashType: 'type', args: '0x1234' })) throw new Error('Script equality rejected identical script'); if (lock.eq({ codeHash: '0x' + '11'.repeat(32), hashType: 'data1', args: '0x1234' })) throw new Error('Script equality ignored hashType'); const tx = ccc.Transaction.default(); tx.addOutput({ capacity: ccc.fixedPointFrom(100), lock }, '0x'); if (tx.outputs.length !== 1 || tx.outputsData[0] !== '0x') throw new Error('Transaction output construction failed'); if (!/^0x[0-9a-f]{64}$/.test(tx.hash())) throw new Error('Transaction hashing failed');", }, { filter: "@ickb/core", script: - "const mod = await import('@ckb-ccc/udt'); if (!('udt' in mod)) throw new Error('Missing udt namespace export from @ckb-ccc/udt');", + "const { ccc } = await import('@ckb-ccc/core'); const { udt } = await import('@ckb-ccc/udt'); if (!udt) throw new Error('Missing udt namespace export from @ckb-ccc/udt'); const type = ccc.Script.from({ codeHash: '0x' + '22'.repeat(32), hashType: 'type', args: '0xab' }); const lock = ccc.Script.from({ codeHash: '0x' + '33'.repeat(32), hashType: 'type', args: '0x' }); const token = new udt.Udt({ txHash: '0x' + '44'.repeat(32), index: 0n }, type); const cell = ccc.Cell.from({ outPoint: { txHash: '0x' + '55'.repeat(32), index: 0n }, cellOutput: { capacity: ccc.fixedPointFrom(100), lock, type }, outputData: ccc.numLeToBytes(123n, 16) }); const info = await token.infoFrom({}, [cell]); if (info.balance !== 123n || info.count !== 1) throw new Error('UDT balance extraction failed');", }, { filter: "interface", script: - "const mod = await import('@ckb-ccc/ccc'); if (!('ccc' in mod) || !('JoyId' in mod) || !('Transaction' in mod)) throw new Error('Missing expected @ckb-ccc/ccc exports');", + "const mod = await import('@ckb-ccc/ccc'); if (!('ccc' in mod) || !('JoyId' in mod) || !('Transaction' in mod)) throw new Error('Missing expected @ckb-ccc/ccc exports'); const lock = mod.ccc.Script.from({ codeHash: '0x' + '66'.repeat(32), hashType: 'type', args: '0x' }); const tx = mod.Transaction.default(); tx.addOutput({ capacity: mod.ccc.fixedPointFrom(61), lock }, '0x'); if (tx.outputs[0]?.capacity !== mod.ccc.fixedPointFrom(61)) throw new Error('@ckb-ccc/ccc transaction behavior failed');", }, ]; diff --git a/tsconfig.json b/tsconfig.json index 962f226..d7e673e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,8 @@ "skipLibCheck": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "importHelpers": true }, "include": ["packages/**/*", "vitest.config.mts"] } From d8f3c20389469d67d120a24baec5611d819c88a3 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 21:45:51 +0000 Subject: [PATCH 06/17] fix(utils): cap bounded subset search --- packages/utils/src/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index f52d8bd..f639452 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -316,8 +316,8 @@ export function selectBoundedUdtSubset( } function assertBitmaskSearchSize(length: number): void { - if (length > 30) { - throw new Error("Bounded subset search supports at most 30 items per half"); + if (length > 24) { + throw new Error("Bounded subset search supports at most 24 items per half"); } } From 7d6dcf087d39340828c12d750bb15725e8676324 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 22:42:33 +0000 Subject: [PATCH 07/17] fix(order): reuse cached origin transactions --- packages/order/src/order.test.ts | 128 +++++++++++++++++++++++++++++++ packages/order/src/order.ts | 22 ++---- 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/packages/order/src/order.test.ts b/packages/order/src/order.test.ts index 7177890..475126a 100644 --- a/packages/order/src/order.test.ts +++ b/packages/order/src/order.test.ts @@ -528,6 +528,7 @@ describe("OrderManager.findOrders", () => { tx.outputs.push(trueOrigin.cell.cellOutput, forgedOrigin.cell.cellOutput, masterCell.cellOutput); tx.outputsData.push(trueOrigin.cell.outputData, forgedOrigin.cell.outputData, masterCell.outputData); const client = { + cache: new ccc.ClientCacheMemory(), findCellsOnChain: async function* (query: { scriptType: string }) { await Promise.resolve(); if (query.scriptType === "lock") { @@ -627,6 +628,7 @@ describe("OrderManager.findOrders", () => { tx.outputs.push(fakeOrigin.cell.cellOutput, masterCell.cellOutput); tx.outputsData.push(fakeOrigin.cell.outputData, masterCell.outputData); const client = { + cache: new ccc.ClientCacheMemory(), findCellsOnChain: async function* (query: { scriptType: string }) { await Promise.resolve(); if (query.scriptType === "lock") { @@ -715,6 +717,7 @@ describe("OrderManager.findOrders", () => { tx.outputs.push(firstOrigin.cell.cellOutput, secondOrigin.cell.cellOutput, masterCell.cellOutput); tx.outputsData.push(firstOrigin.cell.outputData, secondOrigin.cell.outputData, masterCell.outputData); const client = { + cache: new ccc.ClientCacheMemory(), findCellsOnChain: async function* (query: { scriptType: string }) { await Promise.resolve(); if (query.scriptType === "lock") { @@ -788,6 +791,7 @@ describe("OrderManager.findOrders", () => { tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); tx.outputsData.push(origin.cell.outputData, masterCell.outputData); const client = { + cache: new ccc.ClientCacheMemory(), findCells: async function* () { await Promise.resolve(); cachedCalls += 1; @@ -823,6 +827,127 @@ describe("OrderManager.findOrders", () => { expect(onChainCalls).toBe(2); }); + it("caches master transaction lookups within findOrders", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const txHash = byte32FromByte("77"); + const firstMaster = ccc.OutPoint.from({ txHash, index: 1n }); + const secondMaster = ccc.OutPoint.from({ txHash, index: 3n }); + const firstOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash, index: 0n }, + }); + const secondOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash, index: 2n }, + }); + const firstMasterCell = ccc.Cell.from({ + outPoint: firstMaster, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + const secondMasterCell = ccc.Cell.from({ + outPoint: secondMaster, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + const tx = ccc.Transaction.default(); + tx.outputs.push( + firstOrigin.cell.cellOutput, + firstMasterCell.cellOutput, + secondOrigin.cell.cellOutput, + secondMasterCell.cellOutput, + ); + tx.outputsData.push( + firstOrigin.cell.outputData, + firstMasterCell.outputData, + secondOrigin.cell.outputData, + secondMasterCell.outputData, + ); + const actualTxHash = tx.hash(); + firstMaster.txHash = actualTxHash; + secondMaster.txHash = actualTxHash; + firstOrigin.cell.outPoint.txHash = actualTxHash; + secondOrigin.cell.outPoint.txHash = actualTxHash; + firstMasterCell.outPoint.txHash = actualTxHash; + secondMasterCell.outPoint.txHash = actualTxHash; + const cache = new ccc.ClientCacheMemory(); + let getTransactionCalls = 0; + const client = { + cache, + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + if (query.scriptType === "lock") { + yield firstOrigin.cell; + yield secondOrigin.cell; + } else { + yield firstMasterCell; + yield secondMasterCell; + } + }, + getTransaction: async (queriedTxHash: ccc.Hex) => { + getTransactionCalls += 1; + await Promise.resolve(); + const res = queriedTxHash === actualTxHash + ? ccc.ClientTransactionResponse.from({ + transaction: tx, + status: "committed", + }) + : undefined; + if (res) { + await cache.recordTransactionResponses(res); + } + return res; + }, + } as unknown as ccc.Client; + + const groups = []; + for await (const group of manager.findOrders(client)) { + groups.push(group); + } + + expect(groups).toHaveLength(2); + expect(groups[0]?.master.cell.outPoint.eq(firstMaster)).toBe(true); + expect(groups[1]?.master.cell.outPoint.eq(secondMaster)).toBe(true); + expect(getTransactionCalls).toBe(1); + }); + it("uses cached queries when onChain is false", async () => { const orderScript = ccc.Script.from({ codeHash: byte32FromByte("11"), @@ -867,6 +992,7 @@ describe("OrderManager.findOrders", () => { tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); tx.outputsData.push(origin.cell.outputData, masterCell.outputData); const client = { + cache: new ccc.ClientCacheMemory(), findCells: async function* (query: { scriptType: string }) { await Promise.resolve(); cachedCalls += 1; @@ -961,6 +1087,7 @@ describe("OrderManager.findOrders", () => { tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); tx.outputsData.push(origin.cell.outputData, masterCell.outputData); const client = { + cache: new ccc.ClientCacheMemory(), findCellsOnChain: async function* (query: { scriptType: string }) { await Promise.resolve(); if (query.scriptType === "lock") { @@ -1033,6 +1160,7 @@ describe("OrderManager.findOrders", () => { tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); tx.outputsData.push(origin.cell.outputData, masterCell.outputData); const client = { + cache: new ccc.ClientCacheMemory(), findCells: async function* () { await Promise.resolve(); cachedCalls += 1; diff --git a/packages/order/src/order.ts b/packages/order/src/order.ts index 5f701e3..003ff66 100644 --- a/packages/order/src/order.ts +++ b/packages/order/src/order.ts @@ -563,19 +563,18 @@ export class OrderManager implements ScriptDeps { this.findAllMasters(client, onChain, limit), ]); - // Prepare a map of masterCellKey → { master, originPromise?, orders[] } + // Prepare a map of masterCellKey → { master, orders[] } const rawGroups = new Map( allMasters.map((master) => [ master.cell.outPoint.toHex(), { master, - origin: undefined as Promise | undefined, orders: [] as OrderCell[], }, ]), ); - // Group simple orders by their master cell, kick off origin lookup once per master + // Group simple orders by their master cell for (const order of simpleOrders) { const master = order.getMaster(); const key = master.toHex(); @@ -587,22 +586,15 @@ export class OrderManager implements ScriptDeps { } rawGroup.orders.push(order); - - // Only initialize origin lookup once - rawGroup.origin ??= this.findOrigin(client, master); } // For each populated group, await origin, resolve the best order, and yield OrderGroup - for (const { - master, - origin: originPromise, - orders, - } of rawGroups.values()) { - if (orders.length === 0 || !originPromise) { + for (const { master, orders } of rawGroups.values()) { + if (orders.length === 0) { continue; } - const origin = await originPromise; + const origin = await this.findOrigin(client, master.cell.outPoint); if (!origin) { continue; } @@ -729,7 +721,9 @@ export class OrderManager implements ScriptDeps { master: ccc.OutPoint, ): Promise { const { txHash, index: mIndex } = master; - const res = await client.getTransaction(txHash); + const res = + (await client.cache.getTransactionResponse(txHash)) ?? + (await client.getTransaction(txHash)); if (!res) { return; } From e69d142d6048a6c8503ba8fe5b73d53dba7c1b21 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 22:42:33 +0000 Subject: [PATCH 08/17] fix(core): parallelize state scan lookups --- packages/core/src/cells.test.ts | 66 ++++++- packages/core/src/logic.test.ts | 81 +++++++++ packages/core/src/logic.ts | 10 +- packages/core/src/owned_owner.test.ts | 236 +++++++++++++++++++++++++- packages/core/src/owned_owner.ts | 37 ++-- packages/core/src/udt.ts | 99 ++++++----- packages/dao/src/cells.test.ts | 46 ++++- packages/dao/src/cells.ts | 14 +- packages/dao/src/dao.test.ts | 147 ++++++++++++++++ packages/dao/src/dao.ts | 24 ++- 10 files changed, 692 insertions(+), 68 deletions(-) diff --git a/packages/core/src/cells.test.ts b/packages/core/src/cells.test.ts index fdf49e3..2a0a956 100644 --- a/packages/core/src/cells.test.ts +++ b/packages/core/src/cells.test.ts @@ -1,5 +1,5 @@ import { ccc } from "@ckb-ccc/core"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DaoManager } from "@ickb/dao"; import { receiptCellFrom } from "./cells.js"; import { ReceiptData } from "./entities.js"; @@ -152,6 +152,70 @@ describe("receipt prefix decoding", () => { expect(info.count).toBe(1); }); + it("fetches receipt and deposit headers concurrently", async () => { + const logic = script("33"); + const dao = script("44"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const receipt = receiptCell( + receiptOutputData(2, ccc.fixedPointFrom(100000)), + logic, + ); + const deposit = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("88"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: logic, + type: dao, + }, + outputData: "0x0000000000000000", + }); + const ickbUdt = new IckbUdt( + { txHash: byte32FromByte("44"), index: 0n }, + script("55"), + { txHash: byte32FromByte("66"), index: 0n }, + logic, + new DaoManager(dao, []), + ); + let resolveReceipt!: (res: { header: ccc.ClientBlockHeader }) => void; + let resolveDeposit!: (res: { header: ccc.ClientBlockHeader }) => void; + const receiptFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveReceipt = resolve; + }); + const depositFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveDeposit = resolve; + }); + const requests: ccc.Hex[] = []; + const client = { + getTransactionWithHeader: async (txHash: ccc.Hex) => { + requests.push(txHash); + return txHash === receipt.outPoint.txHash ? receiptFetch : depositFetch; + }, + } as unknown as ccc.Client; + + const infoPromise = ickbUdt.infoFrom(client, [receipt, deposit]); + + await vi.waitFor(() => { + expect(requests).toEqual([ + receipt.outPoint.txHash, + deposit.outPoint.txHash, + ]); + }); + resolveDeposit({ header }); + await Promise.resolve(); + resolveReceipt({ header }); + + const info = await infoPromise; + + expect(info.balance).toBe( + ickbValue(ccc.fixedPointFrom(100000), header) * 2n - + ickbValue(deposit.capacityFree, header), + ); + expect(info.capacity).toBe( + receipt.cellOutput.capacity + deposit.cellOutput.capacity, + ); + expect(info.count).toBe(2); + }); + it("adds xUDT and logic code deps explicitly", () => { const logic = script("33"); const xudtCode = { txHash: byte32FromByte("44"), index: 1n }; diff --git a/packages/core/src/logic.test.ts b/packages/core/src/logic.test.ts index 3b84d2f..18a1aba 100644 --- a/packages/core/src/logic.test.ts +++ b/packages/core/src/logic.test.ts @@ -164,4 +164,85 @@ describe("LogicManager.deposit", () => { expect(receipts).toHaveLength(1); expect(receipts[0]?.cell.outPoint.txHash).toBe(byte32FromByte("44")); }); + + it("fetches receipt headers concurrently and yields scan order", 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, + }); + const header = ccc.ClientBlockHeader.from({ + compactTarget: 0n, + dao: { c: 0n, ar: 10000000000000000n, 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, + }); + let resolveFirst!: (res: { header: ccc.ClientBlockHeader }) => void; + let resolveSecond!: (res: { header: ccc.ClientBlockHeader }) => void; + const firstFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveFirst = resolve; + }); + const secondFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveSecond = resolve; + }); + const requests: ccc.Hex[] = []; + const client = { + findCells: async function* () { + await Promise.resolve(); + yield firstReceipt; + yield secondReceipt; + }, + getTransactionWithHeader: async (txHash: ccc.Hex) => { + requests.push(txHash); + return txHash === firstReceipt.outPoint.txHash ? firstFetch : secondFetch; + }, + } as unknown as ccc.Client; + const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); + + const receiptsPromise = collect(manager.findReceipts(client, [wantedLock])); + + await vi.waitFor(() => { + expect(requests).toEqual([ + firstReceipt.outPoint.txHash, + secondReceipt.outPoint.txHash, + ]); + }); + resolveSecond({ header }); + await Promise.resolve(); + resolveFirst({ header }); + + const receipts = await receiptsPromise; + + expect(receipts.map((receipt) => receipt.cell.outPoint.txHash)).toEqual([ + firstReceipt.outPoint.txHash, + secondReceipt.outPoint.txHash, + ]); + }); }); diff --git a/packages/core/src/logic.ts b/packages/core/src/logic.ts index 16586db..1f11c4e 100644 --- a/packages/core/src/logic.ts +++ b/packages/core/src/logic.ts @@ -221,6 +221,7 @@ export class LogicManager implements ScriptDeps { limit, ] as const; + const receiptCandidates: ccc.Cell[] = []; for await (const cell of options?.onChain ? client.findCellsOnChain(...findCellsArgs) : client.findCells(...findCellsArgs)) { @@ -228,7 +229,14 @@ export class LogicManager implements ScriptDeps { continue; } - yield receiptCellFrom({ client, cell }); + receiptCandidates.push(cell); + } + + const receipts = await Promise.all( + receiptCandidates.map((cell) => receiptCellFrom({ client, cell })), + ); + for (const receipt of receipts) { + yield receipt; } } } diff --git a/packages/core/src/owned_owner.test.ts b/packages/core/src/owned_owner.test.ts index 1147fd4..c391c41 100644 --- a/packages/core/src/owned_owner.test.ts +++ b/packages/core/src/owned_owner.test.ts @@ -1,5 +1,5 @@ import { ccc } from "@ckb-ccc/core"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { collect } from "@ickb/utils"; import { DaoManager } from "@ickb/dao"; import { OwnerData } from "./entities.js"; @@ -328,4 +328,238 @@ describe("OwnedOwnerManager.findWithdrawalGroups", () => { expect(groups).toHaveLength(1); expect(groups[0]?.udtValue).toBe(ickbValue(owned.capacityFree, depositHeader)); }); + + it("fetches referenced owned cells concurrently and yields in owner scan order", 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("88"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const secondOwner = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("99"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const depositHeader = headerLike({ + epoch: [1n, 0n, 1n], + number: 1n, + }); + const withdrawalHeader = headerLike({ + hash: byte32FromByte("aa"), + number: 2n, + }); + const firstOwned = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("88"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }); + const secondOwned = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("99"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }); + let resolveFirst!: (cell: ccc.Cell | undefined) => void; + let resolveSecond!: (cell: ccc.Cell | undefined) => void; + const firstFetch = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondFetch = new Promise((resolve) => { + resolveSecond = resolve; + }); + const pending = new Map([ + [firstOwned.outPoint.toHex(), firstFetch], + [secondOwned.outPoint.toHex(), secondFetch], + ]); + const fetches: ccc.OutPoint[] = []; + const client = { + getTipHeader: async () => { + await Promise.resolve(); + return tip; + }, + findCells: async function* () { + await Promise.resolve(); + yield firstOwner; + yield secondOwner; + }, + getCell: async (outPoint: ccc.OutPoint) => { + fetches.push(outPoint); + const fetch = pending.get(outPoint.toHex()); + if (!fetch) { + throw new Error("Unexpected getCell out point"); + } + return fetch; + }, + getHeaderByNumber: async () => { + await Promise.resolve(); + return depositHeader; + }, + getTransactionWithHeader: async () => { + await Promise.resolve(); + return { header: withdrawalHeader }; + }, + } as unknown as ccc.Client; + + const groupsPromise = collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + await vi.waitFor(() => { + expect(fetches).toHaveLength(2); + }); + expect(fetches.map((outPoint) => outPoint.toHex())).toEqual([ + firstOwned.outPoint.toHex(), + secondOwned.outPoint.toHex(), + ]); + + resolveSecond(secondOwned); + await Promise.resolve(); + resolveFirst(firstOwned); + + const groups = await groupsPromise; + + expect(groups.map((group) => group.owner.cell.outPoint.toHex())).toEqual([ + firstOwner.outPoint.toHex(), + secondOwner.outPoint.toHex(), + ]); + }); + + it("decodes referenced withdrawals concurrently and yields in owner scan order", 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("88"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const secondOwner = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("99"), index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const depositHeader = headerLike({ + epoch: [1n, 0n, 1n], + number: 1n, + }); + const withdrawalHeader = headerLike({ + hash: byte32FromByte("aa"), + number: 2n, + }); + const firstOwned = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("88"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }); + const secondOwned = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("99"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }); + const referencedCells = new Map([ + [firstOwned.outPoint.toHex(), firstOwned], + [secondOwned.outPoint.toHex(), secondOwned], + ]); + const headerRequests: ccc.Hex[] = []; + let resolveFirst!: (res: { header: ccc.ClientBlockHeader }) => void; + let resolveSecond!: (res: { header: ccc.ClientBlockHeader }) => void; + const firstWithdrawalFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveFirst = resolve; + }); + const secondWithdrawalFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveSecond = resolve; + }); + const client = { + getTipHeader: async () => { + await Promise.resolve(); + return tip; + }, + findCells: async function* () { + await Promise.resolve(); + yield firstOwner; + yield secondOwner; + }, + getCell: async (outPoint: ccc.OutPoint) => { + await Promise.resolve(); + return referencedCells.get(outPoint.toHex()); + }, + getHeaderByNumber: async () => { + await Promise.resolve(); + return depositHeader; + }, + getTransactionWithHeader: async (txHash: ccc.Hex) => { + headerRequests.push(txHash); + return txHash === firstOwned.outPoint.txHash + ? firstWithdrawalFetch + : secondWithdrawalFetch; + }, + } as unknown as ccc.Client; + + const groupsPromise = collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + await vi.waitFor(() => { + expect(headerRequests).toEqual([ + firstOwned.outPoint.txHash, + secondOwned.outPoint.txHash, + ]); + }); + resolveSecond({ header: withdrawalHeader }); + await Promise.resolve(); + resolveFirst({ header: withdrawalHeader }); + + const groups = await groupsPromise; + + expect(groups.map((group) => group.owner.cell.outPoint.toHex())).toEqual([ + firstOwner.outPoint.toHex(), + secondOwner.outPoint.toHex(), + ]); + }); }); diff --git a/packages/core/src/owned_owner.ts b/packages/core/src/owned_owner.ts index a3b6a06..78d2fc8 100644 --- a/packages/core/src/owned_owner.ts +++ b/packages/core/src/owned_owner.ts @@ -222,6 +222,7 @@ export class OwnedOwnerManager implements ScriptDeps { limit, ] as const; + const ownerCandidates: OwnerCell[] = []; for await (const cell of options?.onChain ? client.findCellsOnChain(...findCellsArgs) : client.findCells(...findCellsArgs)) { @@ -229,18 +230,32 @@ export class OwnedOwnerManager implements ScriptDeps { continue; } - const owner = new OwnerCell(cell); - const ownedOutPoint = owner.getOwned(); - const ownedCell = await client.getCell(ownedOutPoint); - if (!ownedCell || !this.isOwned(ownedCell)) { - continue; + ownerCandidates.push(new OwnerCell(cell)); + } + + const ownedCells = await Promise.all( + ownerCandidates.map((owner) => client.getCell(owner.getOwned())), + ); + + const withdrawalGroups = await Promise.all( + ownerCandidates.map(async (owner, index) => { + const ownedCell = ownedCells[index]; + if (!ownedCell || !this.isOwned(ownedCell)) { + return; + } + const owned = await this.daoManager.withdrawalRequestCellFrom( + ownedCell, + client, + { tip }, + ); + return new WithdrawalGroup(owned, owner); + }), + ); + + for (const group of withdrawalGroups) { + if (group) { + yield group; } - const owned = await this.daoManager.withdrawalRequestCellFrom( - ownedCell, - client, - { tip }, - ); - yield new WithdrawalGroup(owned, owner); } } } diff --git a/packages/core/src/udt.ts b/packages/core/src/udt.ts index 5d0ecb0..5042dca 100644 --- a/packages/core/src/udt.ts +++ b/packages/core/src/udt.ts @@ -90,66 +90,73 @@ export class IckbUdt extends udt.Udt { ): Promise { const info = udt.UdtInfo.from(acc).clone(); - for (const cellLike of [cells].flat()) { - const cell = ccc.CellAny.from(cellLike); + const deltas = await Promise.all( + [cells].flat().map(async (cellLike) => { + const cell = ccc.CellAny.from(cellLike); // Standard xUDT cell -- delegate to base class pattern - if (this.isUdt(cell)) { - info.addAssign({ - balance: udt.Udt.balanceFromUnsafe(cell.outputData), - capacity: cell.cellOutput.capacity, - count: 1, - }); - continue; - } + if (this.isUdt(cell)) { + return { + balance: udt.Udt.balanceFromUnsafe(cell.outputData), + capacity: cell.cellOutput.capacity, + count: 1, + }; + } // Receipt and deposit cells need outPoint for header fetch. // Output cells (no outPoint) are skipped -- correct by design. - if (!cell.outPoint) { - continue; - } + if (!cell.outPoint) { + return; + } - const { type, lock } = cell.cellOutput; + const { type, lock } = cell.cellOutput; // Receipt cell: type === logicScript - if (type && this.logicScript.eq(type)) { - const txWithHeader = await client.getTransactionWithHeader( - cell.outPoint.txHash, - ); - if (!txWithHeader?.header) { - throw new Error("Header not found for txHash"); - } + if (type && this.logicScript.eq(type)) { + const txWithHeader = await client.getTransactionWithHeader( + cell.outPoint.txHash, + ); + if (!txWithHeader?.header) { + throw new Error("Header not found for txHash"); + } - const { depositQuantity, depositAmount } = - ReceiptData.decodePrefix(cell.outputData); - info.addAssign({ - balance: ickbValue(depositAmount, txWithHeader.header) * - depositQuantity, - capacity: cell.cellOutput.capacity, - count: 1, - }); - continue; - } + const { depositQuantity, depositAmount } = + ReceiptData.decodePrefix(cell.outputData); + return { + balance: ickbValue(depositAmount, txWithHeader.header) * + depositQuantity, + capacity: cell.cellOutput.capacity, + count: 1, + }; + } // Deposit cell: lock === logicScript AND isDeposit // Output cells are gated by the !cell.outPoint check above and never reach here. - if ( - this.logicScript.eq(lock) && - this.daoManager.isDeposit(cell) - ) { - const txWithHeader = await client.getTransactionWithHeader( - cell.outPoint.txHash, - ); - if (!txWithHeader?.header) { - throw new Error("Header not found for txHash"); + if ( + this.logicScript.eq(lock) && + this.daoManager.isDeposit(cell) + ) { + const txWithHeader = await client.getTransactionWithHeader( + cell.outPoint.txHash, + ); + if (!txWithHeader?.header) { + throw new Error("Header not found for txHash"); + } + + return { + balance: -ickbValue(cell.capacityFree, txWithHeader.header), + capacity: cell.cellOutput.capacity, + count: 1, + }; } - info.addAssign({ - balance: -ickbValue(cell.capacityFree, txWithHeader.header), - capacity: cell.cellOutput.capacity, - count: 1, - }); - continue; + return; + }), + ); + + for (const delta of deltas) { + if (delta) { + info.addAssign(delta); } } diff --git a/packages/dao/src/cells.test.ts b/packages/dao/src/cells.test.ts index 8fa206c..e552bf0 100644 --- a/packages/dao/src/cells.test.ts +++ b/packages/dao/src/cells.test.ts @@ -1,5 +1,5 @@ import { ccc } from "@ckb-ccc/core"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DaoManager } from "./dao.js"; function byte32FromByte(hexByte: string): `0x${string}` { @@ -115,4 +115,48 @@ describe("daoCellFrom withdrawal readiness", () => { expect(daoCell.isReady).toBe(false); }); + + it("fetches withdrawal deposit and request headers concurrently", async () => { + const manager = new DaoManager(script("33"), []); + const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); + const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); + const tip = ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)); + const calls: string[] = []; + let resolveDeposit!: (header: ccc.ClientBlockHeader | undefined) => void; + let resolveWithdraw!: (res: { header: ccc.ClientBlockHeader } | undefined) => void; + const depositFetch = new Promise((resolve) => { + resolveDeposit = resolve; + }); + const withdrawFetch = new Promise<{ header: ccc.ClientBlockHeader } | undefined>((resolve) => { + resolveWithdraw = resolve; + }); + const client = { + getHeaderByNumber: async () => { + calls.push("deposit"); + return depositFetch; + }, + getTransactionWithHeader: async () => { + calls.push("withdraw"); + return withdrawFetch; + }, + } as unknown as ccc.Client; + + const daoCellPromise = manager.withdrawalRequestCellFrom( + withdrawalCell(), + client, + { tip }, + ); + + await vi.waitFor(() => { + expect(calls).toEqual(["deposit", "withdraw"]); + }); + resolveWithdraw({ header: withdrawHeader }); + await Promise.resolve(); + resolveDeposit(depositHeader); + + const daoCell = await daoCellPromise; + + expect(daoCell.headers[0].header.number).toBe(depositHeader.number); + expect(daoCell.headers[1].header.number).toBe(withdrawHeader.number); + }); }); diff --git a/packages/dao/src/cells.ts b/packages/dao/src/cells.ts index 376f38e..e9d2d00 100644 --- a/packages/dao/src/cells.ts +++ b/packages/dao/src/cells.ts @@ -61,14 +61,19 @@ export async function daoCellFrom( const { isDeposit, tip } = options; const txHash = cell.outPoint.txHash; let oldest: TransactionHeader; + let withdrawalTxWithHeader: + | Awaited> + | undefined; if (!isDeposit) { - const header = await options.client.getHeaderByNumber( - mol.Uint64LE.decode(cell.outputData), - ); + const [header, txWithHeader] = await Promise.all([ + options.client.getHeaderByNumber(mol.Uint64LE.decode(cell.outputData)), + options.client.getTransactionWithHeader(txHash), + ]); if (!header) { throw new Error("Header not found for block number"); } oldest = { header }; + withdrawalTxWithHeader = txWithHeader; } else { const txWithHeader = await options.client.getTransactionWithHeader(txHash); @@ -80,8 +85,7 @@ export async function daoCellFrom( let newest: TransactionHeader; if (!isDeposit) { - const txWithHeader = - await options.client.getTransactionWithHeader(txHash); + const txWithHeader = withdrawalTxWithHeader; if (!txWithHeader?.header) { throw new Error("Header not found for txHash"); } diff --git a/packages/dao/src/dao.test.ts b/packages/dao/src/dao.test.ts index fc8584d..8ad0f6c 100644 --- a/packages/dao/src/dao.test.ts +++ b/packages/dao/src/dao.test.ts @@ -3,6 +3,14 @@ import { describe, expect, it, vi } from "vitest"; import type { DaoDepositCell, DaoWithdrawalRequestCell } from "./cells.js"; import { DaoManager } from "./dao.js"; +async function collect(inputs: AsyncIterable): Promise { + const result: T[] = []; + for await (const input of inputs) { + result.push(input); + } + 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"); @@ -206,6 +214,145 @@ describe("DaoManager cell decoding ownership", () => { }); }); +describe("DaoManager.findDeposits", () => { + it("decodes deposits concurrently and yields scan order", 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(), + }); + const tip = headerLike(3n); + const requests: ccc.Hex[] = []; + let resolveFirst!: (res: { header: ccc.ClientBlockHeader }) => void; + let resolveSecond!: (res: { header: ccc.ClientBlockHeader }) => void; + const firstFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveFirst = resolve; + }); + const secondFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveSecond = resolve; + }); + const client = { + findCells: async function* () { + await Promise.resolve(); + yield firstDeposit; + yield secondDeposit; + }, + getTransactionWithHeader: async (txHash: ccc.Hex) => { + requests.push(txHash); + return txHash === firstDeposit.outPoint.txHash ? firstFetch : secondFetch; + }, + } as unknown as ccc.Client; + + const depositsPromise = collect( + manager.findDeposits(client, [lock], { tip }), + ); + + await vi.waitFor(() => { + expect(requests).toEqual([ + firstDeposit.outPoint.txHash, + secondDeposit.outPoint.txHash, + ]); + }); + resolveSecond({ header: headerLike(1n) }); + await Promise.resolve(); + resolveFirst({ header: headerLike(1n) }); + + const deposits = await depositsPromise; + + expect(deposits.map((deposit) => deposit.cell.outPoint.txHash)).toEqual([ + firstDeposit.outPoint.txHash, + secondDeposit.outPoint.txHash, + ]); + }); +}); + +describe("DaoManager.findWithdrawalRequests", () => { + it("decodes withdrawals concurrently and yields scan order", 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), + }); + const tip = headerLike(3n); + const depositHeader = headerLike(1n); + const requests: ccc.Hex[] = []; + let resolveFirst!: (res: { header: ccc.ClientBlockHeader }) => void; + let resolveSecond!: (res: { header: ccc.ClientBlockHeader }) => void; + const firstFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveFirst = resolve; + }); + const secondFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { + resolveSecond = resolve; + }); + const client = { + findCells: async function* () { + await Promise.resolve(); + yield firstWithdrawal; + yield secondWithdrawal; + }, + getHeaderByNumber: async () => { + await Promise.resolve(); + return depositHeader; + }, + getTransactionWithHeader: async (txHash: ccc.Hex) => { + requests.push(txHash); + return txHash === firstWithdrawal.outPoint.txHash ? firstFetch : secondFetch; + }, + } as unknown as ccc.Client; + + const withdrawalsPromise = collect( + manager.findWithdrawalRequests(client, [lock], { tip }), + ); + + await vi.waitFor(() => { + expect(requests).toEqual([ + firstWithdrawal.outPoint.txHash, + secondWithdrawal.outPoint.txHash, + ]); + }); + resolveSecond({ header: headerLike(2n) }); + await Promise.resolve(); + resolveFirst({ header: headerLike(2n) }); + + const withdrawals = await withdrawalsPromise; + + expect(withdrawals.map((withdrawal) => withdrawal.cell.outPoint.txHash)).toEqual([ + firstWithdrawal.outPoint.txHash, + secondWithdrawal.outPoint.txHash, + ]); + }); +}); + describe("DaoManager.withdraw", () => { it("writes since, header deps, and witness inputType for withdrawals", async () => { vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); diff --git a/packages/dao/src/dao.ts b/packages/dao/src/dao.ts index 3ae4f27..6e746e6 100644 --- a/packages/dao/src/dao.ts +++ b/packages/dao/src/dao.ts @@ -371,6 +371,7 @@ export class DaoManager implements ScriptDeps { limit, ] as const; + const depositCandidates: ccc.Cell[] = []; for await (const cell of options?.onChain ? client.findCellsOnChain(...findCellsArgs) : client.findCells(...findCellsArgs)) { @@ -378,7 +379,16 @@ export class DaoManager implements ScriptDeps { continue; } - yield this.depositCellFrom(cell, client, { ...options, tip }); + depositCandidates.push(cell); + } + + const deposits = await Promise.all( + depositCandidates.map((cell) => + this.depositCellFrom(cell, client, { ...options, tip }), + ), + ); + for (const deposit of deposits) { + yield deposit; } } } @@ -446,6 +456,7 @@ export class DaoManager implements ScriptDeps { limit, ] as const; + const withdrawalCandidates: ccc.Cell[] = []; for await (const cell of options?.onChain ? client.findCellsOnChain(...findCellsArgs) : client.findCells(...findCellsArgs)) { @@ -453,7 +464,16 @@ export class DaoManager implements ScriptDeps { continue; } - yield this.withdrawalRequestCellFrom(cell, client, { ...options, tip }); + withdrawalCandidates.push(cell); + } + + const withdrawals = await Promise.all( + withdrawalCandidates.map((cell) => + this.withdrawalRequestCellFrom(cell, client, { ...options, tip }), + ), + ); + for (const withdrawal of withdrawals) { + yield withdrawal; } } } From 1edb37441a1ace4f0f1d84276a3dece3adb18114 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 22:42:33 +0000 Subject: [PATCH 09/17] fix(sdk): allow exact-limit scans --- packages/sdk/src/sdk.test.ts | 106 +++++++++++++++++++++++++++++++++-- packages/sdk/src/sdk.ts | 21 +++++-- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index e77f37c..46c8baa 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -948,7 +948,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { expect(state.system.ckbMaturing).toEqual([]); }); - it("fails closed when bot capacity scanning reaches the limit", async () => { + it("allows bot capacity scanning to exactly reach the limit", async () => { const botLock = script("11"); const logic = script("22"); const dao = script("33"); @@ -972,20 +972,55 @@ describe("IckbSdk.getL1State snapshot detection", () => { const client = { getTipHeader: () => Promise.resolve(headerLike(1n)), getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { scriptType?: string }) { - if (query.scriptType === "lock") { + findCellsOnChain: async function* (query: { filter?: { scriptLenRange?: unknown } }) { + if (query.filter?.scriptLenRange) { yield* repeat(defaultFindCellsLimit, plainCell); } await Promise.resolve(); }, } as unknown as ccc.Client; + await expect(sdk.getL1State(client, [])).resolves.toBeDefined(); + }); + + it("fails closed when bot capacity scanning exceeds the limit", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [botLock], + ); + const plainCell = ccc.Cell.from({ + outPoint: { txHash: hash("04"), index: 0n }, + cellOutput: { capacity: 1n, lock: botLock }, + outputData: "0x", + }); + const client = { + getTipHeader: () => Promise.resolve(headerLike(1n)), + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: async function* (query: { filter?: { 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)}`, ); }); - it("fails closed when direct deposit scanning reaches the limit", async () => { + it("allows direct deposit scanning to exactly reach the limit", async () => { const botLock = script("11"); const logic = script("22"); const dao = script("33"); @@ -1016,6 +1051,40 @@ describe("IckbSdk.getL1State snapshot detection", () => { findCellsOnChain: () => none(), } as unknown as ccc.Client; + await expect(sdk.getL1State(client, [])).resolves.toBeDefined(); + }); + + it("fails closed when direct deposit scanning exceeds the limit", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const logicManager = new LogicManager(logic, [], new DaoManager(dao, [])); + 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 client = { + getTipHeader: () => Promise.resolve(headerLike(1n)), + 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)}`, ); @@ -1074,7 +1143,7 @@ describe("IckbSdk.getAccountState", () => { expect(ickbUdt.infoFrom).toHaveBeenCalledWith(client, [udtCell]); }); - it("fails closed when account cell scanning reaches the limit", async () => { + it("allows account cell scanning to exactly reach the limit", async () => { const accountLock = script("11"); const udt = script("66"); const daoManager = new DaoManager(script("33"), []); @@ -1098,6 +1167,33 @@ describe("IckbSdk.getAccountState", () => { findCellsOnChain: () => repeat(defaultFindCellsLimit, cell), } as unknown as ccc.Client; + await expect(sdk.getAccountState(client, [accountLock], tip)).resolves.toBeDefined(); + }); + + it("fails closed when account cell scanning exceeds the limit", async () => { + const accountLock = script("11"); + const udt = script("66"); + const daoManager = new DaoManager(script("33"), []); + const logicManager = new LogicManager(script("22"), [], daoManager); + const ownedOwnerManager = new OwnedOwnerManager(script("44"), [], daoManager); + vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + new OrderManager(script("55"), [], udt), + [], + ); + const cell = ccc.Cell.from({ + outPoint: { txHash: hash("92"), index: 0n }, + cellOutput: { capacity: 5n, lock: accountLock }, + outputData: "0x", + }); + const client = { + findCellsOnChain: () => repeat(defaultFindCellsLimit + 1, cell), + } as unknown as ccc.Client; + await expect(sdk.getAccountState(client, [accountLock], tip)).rejects.toThrow( `account scan reached limit ${String(defaultFindCellsLimit)}`, ); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 1d0059a..fdd719a 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -553,15 +553,20 @@ export class IckbSdk { ckbMaturing: CkbCumulative[]; }> { const limit = defaultFindCellsLimit; - const opts = { + const withdrawalOptions = { onChain: true, 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, opts), + this.ownedOwner.findWithdrawalGroups(client, this.bots, withdrawalOptions), ); // Map to track each bot's available CKB (minus a reserved amount for internal operations). @@ -580,7 +585,7 @@ export class IckbSdk { withData: true, }, "asc", - limit, + scanLimit(limit), )) { scanned += 1; if (cell.cellOutput.type !== undefined || !cell.cellOutput.lock.eq(lock)) { @@ -629,7 +634,7 @@ export class IckbSdk { // 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, opts)) { + for await (const d of this.ickbLogic.findDeposits(client, directDepositOptions)) { depositsScanned += 1; if (d.isReady) { ckbAvailable += d.ckbValue; @@ -676,7 +681,7 @@ export class IckbSdk { withData: true, }, "asc", - limit, + scanLimit(limit), )) { scanned += 1; cells.push(cell); @@ -693,7 +698,7 @@ function assertCompleteScan( label: string, lock?: ccc.Script, ): void { - if (scanned < limit) { + if (scanned <= limit) { return; } @@ -701,6 +706,10 @@ function assertCompleteScan( throw new Error(`${label} scan reached limit ${String(limit)}${suffix}; state may be incomplete`); } +function scanLimit(limit: number): number { + return limit + 1; +} + export interface AccountState { capacityCells: ccc.Cell[]; nativeUdtCapacity: bigint; From 2ecabd03890c3e3b4b31a9581f89bd489db5a340 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 23:15:27 +0000 Subject: [PATCH 10/17] fix(utils): tighten subset search and share bigint compare --- packages/order/src/entities.ts | 14 +------------- packages/order/src/order.ts | 13 +------------ packages/utils/src/utils.test.ts | 24 ++++++++++++++++-------- packages/utils/src/utils.ts | 6 +++--- 4 files changed, 21 insertions(+), 36 deletions(-) diff --git a/packages/order/src/entities.ts b/packages/order/src/entities.ts index 1cca562..e09b237 100644 --- a/packages/order/src/entities.ts +++ b/packages/order/src/entities.ts @@ -1,5 +1,5 @@ import { ccc, mol } from "@ckb-ccc/core"; -import { CheckedInt32LE, type ExchangeRatio } from "@ickb/utils"; +import { CheckedInt32LE, compareBigInt, type ExchangeRatio } from "@ickb/utils"; /** * Represents a ratio of two scales, CKB and UDT, with validation and comparison methods. @@ -452,18 +452,6 @@ export class Info extends ccc.Entity.Base() { } } -function compareBigInt(left: bigint, right: bigint): number { - if (left < right) { - return -1; - } - - if (left > right) { - return 1; - } - - return 0; -} - /** * Represents a structure containing padding and distance values. * diff --git a/packages/order/src/order.ts b/packages/order/src/order.ts index 003ff66..6155059 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, + compareBigInt, defaultFindCellsLimit, type ExchangeRatio, type ScriptDeps, @@ -1053,15 +1054,3 @@ export class OrderMatcher { return (aScale * (aIn - aOut) + bScale * (bIn + 1n) - 1n) / bScale; } } - -function compareBigInt(left: bigint, right: bigint): number { - if (left < right) { - return -1; - } - - if (left > right) { - return 1; - } - - return 0; -} diff --git a/packages/utils/src/utils.test.ts b/packages/utils/src/utils.test.ts index 5036c26..5b88349 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, selectBoundedUdtSubset } from "./utils.js"; +import { BufferedGenerator, compareBigInt, selectBoundedUdtSubset } from "./utils.js"; + +describe("compareBigInt", () => { + it("orders bigint values", () => { + expect(compareBigInt(1n, 2n)).toBe(-1); + expect(compareBigInt(2n, 2n)).toBe(0); + expect(compareBigInt(3n, 2n)).toBe(1); + }); +}); describe("BufferedGenerator", () => { it("keeps advancing the wrapped generator after the initial fill", () => { @@ -26,7 +34,7 @@ describe("selectBoundedUdtSubset", () => { const deposits = [{ udtValue: 6n }, { udtValue: 5n }, { udtValue: 5n }]; expect(selectBoundedUdtSubset(deposits, 10n, { - candidateLimit: 30, + candidateLimit: 32, minCount: 2, maxCount: 2, })).toEqual([deposits[1], deposits[2]]); @@ -36,9 +44,9 @@ describe("selectBoundedUdtSubset", () => { const deposits = [{ udtValue: 4n }, { udtValue: 7n }, { udtValue: 3n }]; expect(selectBoundedUdtSubset(deposits, 10n, { - candidateLimit: 30, + candidateLimit: 32, minCount: 1, - maxCount: 30, + maxCount: 32, })).toEqual([deposits[1], deposits[2]]); }); @@ -52,22 +60,22 @@ describe("selectBoundedUdtSubset", () => { [firstSix, firstFour, secondSix, secondFour], 10n, { - candidateLimit: 30, + candidateLimit: 32, minCount: 1, - maxCount: 30, + maxCount: 32, }, )).toEqual([firstSix, firstFour]); }); it("bounds the search to the requested candidate limit", () => { const deposits = [ - ...Array.from({ length: 30 }, () => ({ udtValue: 6n })), + ...Array.from({ length: 32 }, () => ({ udtValue: 6n })), { udtValue: 5n }, { udtValue: 5n }, ]; expect(selectBoundedUdtSubset(deposits, 10n, { - candidateLimit: 30, + candidateLimit: 32, minCount: 2, maxCount: 2, })).toEqual([]); diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index f639452..e7ecb28 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -316,8 +316,8 @@ export function selectBoundedUdtSubset( } function assertBitmaskSearchSize(length: number): void { - if (length > 24) { - throw new Error("Bounded subset search supports at most 24 items per half"); + if (length > 16) { + throw new Error("Bounded subset search supports at most 16 items per half"); } } @@ -397,7 +397,7 @@ function compareMask(left: number, right: number, length: number): number { return 0; } -function compareBigInt(left: bigint, right: bigint): number { +export function compareBigInt(left: bigint, right: bigint): number { if (left < right) { return -1; } From cd579f716d49696cdd8b74309e16fc939680923b Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 23:15:27 +0000 Subject: [PATCH 11/17] fix(dao): dedupe scan header lookups --- packages/dao/src/cells.ts | 46 ++++++++++++++--- packages/dao/src/dao.test.ts | 95 ++++++++++++++++++++++++++++++++++++ packages/dao/src/dao.ts | 19 ++++++-- 3 files changed, 150 insertions(+), 10 deletions(-) diff --git a/packages/dao/src/cells.ts b/packages/dao/src/cells.ts index e9d2d00..e0ee05e 100644 --- a/packages/dao/src/cells.ts +++ b/packages/dao/src/cells.ts @@ -35,6 +35,15 @@ export interface DaoWithdrawalRequestCell extends DaoCellBase { readonly isDeposit: false; } +type TransactionWithHeader = Awaited< + ReturnType +>; + +export type DaoCellFromCache = { + headerCache?: Map>; + transactionCache?: Map>; +}; + type DaoCell = DaoDepositCell | DaoWithdrawalRequestCell; type DaoCellFromOptions = { @@ -42,7 +51,7 @@ type DaoCellFromOptions = { tip: ccc.ClientBlockHeader; minLockUp?: ccc.Epoch; maxLockUp?: ccc.Epoch; -}; +} & DaoCellFromCache; export function daoCellFrom( cell: ccc.Cell, @@ -61,13 +70,12 @@ export async function daoCellFrom( const { isDeposit, tip } = options; const txHash = cell.outPoint.txHash; let oldest: TransactionHeader; - let withdrawalTxWithHeader: - | Awaited> - | undefined; + let withdrawalTxWithHeader: TransactionWithHeader | undefined; if (!isDeposit) { + const depositBlockNumber = mol.Uint64LE.decode(cell.outputData); const [header, txWithHeader] = await Promise.all([ - options.client.getHeaderByNumber(mol.Uint64LE.decode(cell.outputData)), - options.client.getTransactionWithHeader(txHash), + getCachedHeaderByNumber(options, depositBlockNumber), + getCachedTransactionWithHeader(options, txHash), ]); if (!header) { throw new Error("Header not found for block number"); @@ -76,7 +84,7 @@ export async function daoCellFrom( withdrawalTxWithHeader = txWithHeader; } else { const txWithHeader = - await options.client.getTransactionWithHeader(txHash); + await getCachedTransactionWithHeader(options, txHash); if (!txWithHeader?.header) { throw new Error("Header not found for txHash"); } @@ -139,6 +147,30 @@ export async function daoCellFrom( : { ...common, isDeposit: false }; } +function getCachedHeaderByNumber( + options: DaoCellFromOptions, + blockNumber: ccc.Num, +): Promise { + let promise = options.headerCache?.get(blockNumber); + if (!promise) { + promise = options.client.getHeaderByNumber(blockNumber); + options.headerCache?.set(blockNumber, promise); + } + return promise; +} + +function getCachedTransactionWithHeader( + options: DaoCellFromOptions, + txHash: ccc.Hex, +): Promise { + let promise = options.transactionCache?.get(txHash); + if (!promise) { + promise = options.client.getTransactionWithHeader(txHash); + options.transactionCache?.set(txHash, promise); + } + return promise; +} + /** * The default minimum lock-up period represented as an Epoch. * diff --git a/packages/dao/src/dao.test.ts b/packages/dao/src/dao.test.ts index 8ad0f6c..ef23598 100644 --- a/packages/dao/src/dao.test.ts +++ b/packages/dao/src/dao.test.ts @@ -279,6 +279,50 @@ describe("DaoManager.findDeposits", () => { secondDeposit.outPoint.txHash, ]); }); + + it("deduplicates deposit transaction header requests during a scan", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const txHash = byte32FromByte("33"); + const firstDeposit = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: manager.script, + }, + outputData: DaoManager.depositData(), + }); + const secondDeposit = ccc.Cell.from({ + outPoint: { txHash, index: 1n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: manager.script, + }, + outputData: DaoManager.depositData(), + }); + let transactionCalls = 0; + const client = { + findCells: async function* () { + await Promise.resolve(); + yield firstDeposit; + yield secondDeposit; + }, + getTransactionWithHeader: async () => { + transactionCalls += 1; + await Promise.resolve(); + return { header: headerLike(1n) }; + }, + } as unknown as ccc.Client; + + const deposits = await collect(manager.findDeposits(client, [lock], { + tip: headerLike(3n), + })); + + expect(transactionCalls).toBe(1); + expect(deposits).toHaveLength(2); + }); }); describe("DaoManager.findWithdrawalRequests", () => { @@ -351,6 +395,57 @@ describe("DaoManager.findWithdrawalRequests", () => { secondWithdrawal.outPoint.txHash, ]); }); + + it("deduplicates withdrawal transaction and deposit header requests during a scan", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const txHash = byte32FromByte("55"); + const firstWithdrawal = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + const secondWithdrawal = ccc.Cell.from({ + outPoint: { txHash, index: 1n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + let headerCalls = 0; + let transactionCalls = 0; + const client = { + findCells: async function* () { + await Promise.resolve(); + yield firstWithdrawal; + yield secondWithdrawal; + }, + getHeaderByNumber: async () => { + headerCalls += 1; + await Promise.resolve(); + return headerLike(1n); + }, + getTransactionWithHeader: async () => { + transactionCalls += 1; + await Promise.resolve(); + return { header: headerLike(2n) }; + }, + } as unknown as ccc.Client; + + const withdrawals = await collect( + manager.findWithdrawalRequests(client, [lock], { tip: headerLike(3n) }), + ); + + expect(headerCalls).toBe(1); + expect(transactionCalls).toBe(1); + expect(withdrawals).toHaveLength(2); + }); }); describe("DaoManager.withdraw", () => { diff --git a/packages/dao/src/dao.ts b/packages/dao/src/dao.ts index 6e746e6..7caba38 100644 --- a/packages/dao/src/dao.ts +++ b/packages/dao/src/dao.ts @@ -6,6 +6,7 @@ import { } from "@ickb/utils"; import { daoCellFrom, + type DaoCellFromCache, type DaoDepositCell, type DaoWithdrawalRequestCell, } from "./cells.js"; @@ -14,7 +15,7 @@ type DaoCellFromOptions = { tip: ccc.ClientBlockHeader; minLockUp?: ccc.Epoch; maxLockUp?: ccc.Epoch; -}; +} & DaoCellFromCache; export async function assertDaoOutputLimit( txLike: ccc.TransactionLike, @@ -382,9 +383,14 @@ export class DaoManager implements ScriptDeps { depositCandidates.push(cell); } + const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); const deposits = await Promise.all( depositCandidates.map((cell) => - this.depositCellFrom(cell, client, { ...options, tip }), + this.depositCellFrom(cell, client, { + ...options, + tip, + transactionCache, + }), ), ); for (const deposit of deposits) { @@ -467,9 +473,16 @@ export class DaoManager implements ScriptDeps { withdrawalCandidates.push(cell); } + const headerCache: DaoCellFromCache["headerCache"] = new Map(); + const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); const withdrawals = await Promise.all( withdrawalCandidates.map((cell) => - this.withdrawalRequestCellFrom(cell, client, { ...options, tip }), + this.withdrawalRequestCellFrom(cell, client, { + ...options, + tip, + headerCache, + transactionCache, + }), ), ); for (const withdrawal of withdrawals) { From 8788e0e7140e2fe6f7788aad5cb10303d3caaca3 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 23:15:27 +0000 Subject: [PATCH 12/17] fix(core): dedupe iCKB info header lookups --- packages/core/src/cells.test.ts | 48 +++++++++++++++++++++++++++++++ packages/core/src/udt.ts | 50 ++++++++++++++++++++------------- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/packages/core/src/cells.test.ts b/packages/core/src/cells.test.ts index 2a0a956..b96933a 100644 --- a/packages/core/src/cells.test.ts +++ b/packages/core/src/cells.test.ts @@ -216,6 +216,54 @@ describe("receipt prefix decoding", () => { expect(info.count).toBe(2); }); + it("deduplicates repeated transaction header lookups", async () => { + const logic = script("33"); + const dao = script("44"); + const txHash = byte32FromByte("88"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const receipt = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("22"), + type: logic, + }, + outputData: receiptOutputData(2, ccc.fixedPointFrom(100000)), + }); + const deposit = ccc.Cell.from({ + outPoint: { txHash, index: 1n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: logic, + type: dao, + }, + outputData: "0x0000000000000000", + }); + const ickbUdt = new IckbUdt( + { txHash: byte32FromByte("44"), index: 0n }, + script("55"), + { txHash: byte32FromByte("66"), index: 0n }, + logic, + new DaoManager(dao, []), + ); + let calls = 0; + const client = { + getTransactionWithHeader: async () => { + calls += 1; + await Promise.resolve(); + return { header }; + }, + } as unknown as ccc.Client; + + const info = await ickbUdt.infoFrom(client, [receipt, deposit]); + + expect(calls).toBe(1); + expect(info.balance).toBe( + ickbValue(ccc.fixedPointFrom(100000), header) * 2n - + ickbValue(deposit.capacityFree, header), + ); + }); + it("adds xUDT and logic code deps explicitly", () => { const logic = script("33"); const xudtCode = { txHash: byte32FromByte("44"), index: 1n }; diff --git a/packages/core/src/udt.ts b/packages/core/src/udt.ts index 5042dca..8e85ab3 100644 --- a/packages/core/src/udt.ts +++ b/packages/core/src/udt.ts @@ -89,12 +89,32 @@ export class IckbUdt extends udt.Udt { acc?: udt.UdtInfoLike, ): Promise { const info = udt.UdtInfo.from(acc).clone(); + const headerByTx = new Map< + ccc.Hex, + Promise + >(); + const getTransactionHeader = async ( + txHash: ccc.Hex, + ): Promise => { + let headerPromise = headerByTx.get(txHash); + if (!headerPromise) { + headerPromise = client + .getTransactionWithHeader(txHash) + .then((res) => res?.header); + headerByTx.set(txHash, headerPromise); + } + const header = await headerPromise; + if (!header) { + throw new Error("Header not found for txHash"); + } + return header; + }; const deltas = await Promise.all( [cells].flat().map(async (cellLike) => { const cell = ccc.CellAny.from(cellLike); - // Standard xUDT cell -- delegate to base class pattern + // Standard xUDT cell -- delegate to base class pattern if (this.isUdt(cell)) { return { balance: udt.Udt.balanceFromUnsafe(cell.outputData), @@ -103,48 +123,38 @@ export class IckbUdt extends udt.Udt { }; } - // Receipt and deposit cells need outPoint for header fetch. - // Output cells (no outPoint) are skipped -- correct by design. + // Receipt and deposit cells need outPoint for header fetch. + // Output cells (no outPoint) are skipped -- correct by design. if (!cell.outPoint) { return; } const { type, lock } = cell.cellOutput; - // Receipt cell: type === logicScript + // Receipt cell: type === logicScript if (type && this.logicScript.eq(type)) { - const txWithHeader = await client.getTransactionWithHeader( - cell.outPoint.txHash, - ); - if (!txWithHeader?.header) { - throw new Error("Header not found for txHash"); - } + const header = await getTransactionHeader(cell.outPoint.txHash); const { depositQuantity, depositAmount } = ReceiptData.decodePrefix(cell.outputData); return { - balance: ickbValue(depositAmount, txWithHeader.header) * + balance: ickbValue(depositAmount, header) * depositQuantity, capacity: cell.cellOutput.capacity, count: 1, }; } - // Deposit cell: lock === logicScript AND isDeposit - // Output cells are gated by the !cell.outPoint check above and never reach here. + // Deposit cell: lock === logicScript AND isDeposit + // Output cells are gated by the !cell.outPoint check above and never reach here. if ( this.logicScript.eq(lock) && this.daoManager.isDeposit(cell) ) { - const txWithHeader = await client.getTransactionWithHeader( - cell.outPoint.txHash, - ); - if (!txWithHeader?.header) { - throw new Error("Header not found for txHash"); - } + const header = await getTransactionHeader(cell.outPoint.txHash); return { - balance: -ickbValue(cell.capacityFree, txWithHeader.header), + balance: -ickbValue(cell.capacityFree, header), capacity: cell.cellOutput.capacity, count: 1, }; From 2698acef719bc9318a5f21885bf09c051ce4ffdc Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 23:41:46 +0000 Subject: [PATCH 13/17] test: add circular import check --- package.json | 4 +- pnpm-lock.yaml | 746 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 749 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8516cf5..01e5a5c 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,10 @@ "build": "pnpm -r --filter !./apps/** --filter '!./forks/**' build", "build:all": "pnpm -r --filter '!./forks/**' build", "check": "CI=true pnpm check:base", - "check:base": "pnpm clean:deep && pnpm check:ccc-overrides && pnpm forks:bootstrap && pnpm install && pnpm forks:ccc && pnpm forks:ccc:smoke && pnpm lint && pnpm build:all && pnpm test:ci", + "check:base": "pnpm clean:deep && pnpm check:ccc-overrides && pnpm forks:bootstrap && pnpm install && pnpm forks:ccc && pnpm forks:ccc:smoke && pnpm madge && pnpm lint && pnpm build:all && pnpm test:ci", "check:ccc-overrides": "node scripts/check-ccc-overrides.mjs", "check:fresh": "rm -f pnpm-lock.yaml && pnpm check", + "madge": "madge --circular --extensions ts,tsx --ts-config ./tsconfig.json --exclude '^forks/' packages apps", "test": "NODE_OPTIONS='--disable-warning=DEP0040' vitest", "test:ci": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run && node --test scripts/*.test.mjs && pnpm test:ccc", "test:ccc": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run --root forks/ccc/repo --project @ckb-ccc/core && NODE_OPTIONS='--disable-warning=DEP0040' vitest run --root forks/ccc/repo --project @ckb-ccc/udt -t 'infoFrom|isUdt'", @@ -31,6 +32,7 @@ "eslint-plugin-react-compiler": "19.1.0-rc.2", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.24", + "madge": "^8.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39b0f53..8771b83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.24 version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) + madge: + specifier: ^8.0.0 + version: 8.0.0(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1321,6 +1324,10 @@ packages: resolution: {integrity: sha512-faqOZpj0H21vsqfQXfzRRQUEgF3vZ9i3PcqyF7hNbrNeR6VUcIzJL8QskhjFhBusxAvKf56QbX0/T06/PAgbfg==} engines: {node: '>=12.0.0'} + '@dependents/detective-less@5.0.3': + resolution: {integrity: sha512-v6oD9Ukp+N7V4n6p5I/+mM5fIohSfkrDSGlFm5w/pYmchvbk+sMIHsLxrFJ5Lnujewj1BzWL0K84d88lwZAMQA==} + engines: {node: '>=18'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -2061,6 +2068,22 @@ packages: peerDependencies: react: ^18 || ^19 + '@ts-graphviz/adapter@2.0.6': + resolution: {integrity: sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q==} + engines: {node: '>=18'} + + '@ts-graphviz/ast@2.0.7': + resolution: {integrity: sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw==} + engines: {node: '>=18'} + + '@ts-graphviz/common@2.1.5': + resolution: {integrity: sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg==} + engines: {node: '>=18'} + + '@ts-graphviz/core@2.0.7': + resolution: {integrity: sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg==} + engines: {node: '>=18'} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2232,6 +2255,21 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vue/compiler-core@3.5.34': + resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} + + '@vue/compiler-dom@3.5.34': + resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} + + '@vue/compiler-sfc@3.5.34': + resolution: {integrity: sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==} + + '@vue/compiler-ssr@3.5.34': + resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + + '@vue/shared@3.5.34': + resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + abitype@0.8.7: resolution: {integrity: sha512-wQ7hV8Yg/yKmGyFpqrNZufCxbszDe5es4AZGYPBitocfSqXtjrTG9JMWFcc4N30ukl2ve48aBTwt7NJxVQdU3w==} peerDependencies: @@ -2281,6 +2319,12 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + app-module-path@2.2.0: + resolution: {integrity: sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2292,6 +2336,10 @@ packages: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} + ast-module-types@6.0.1: + resolution: {integrity: sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA==} + engines: {node: '>=18'} + ast-v8-to-istanbul@0.3.12: resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} @@ -2347,6 +2395,9 @@ packages: resolution: {integrity: sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==} engines: {node: '>=18.0.0'} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake2b-wasm@2.4.0: resolution: {integrity: sha512-S1kwmW2ZhZFFFOghcx73+ZajEfKBqhP82JMssxtLVMxlaPea1p9uoLiUZ5WYyHn0KddwbLc+0vh4wR0KBNoT5w==} @@ -2383,6 +2434,9 @@ packages: bs58check@4.0.0: resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2417,9 +2471,21 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2431,6 +2497,17 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2470,12 +2547,19 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-freeze-strict@1.1.1: resolution: {integrity: sha512-QemROZMM2IvhAcCFvahdX2Vbm4S/txeq5rFYU9fh4mQP79WTMW5c/HkQ2ICl1zuzcDZdPZ6zarDxQeQMsVYoNA==} deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -2483,10 +2567,58 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dependency-tree@11.4.3: + resolution: {integrity: sha512-Y2gzOJ2Rb2X7MN6pT9llWpXxl5J5s5/11CBpJ5b85DjEqZH7jv3T9RO6HRV/PI/3MDmaKn/g7uoYdYmSb9vLlw==} + engines: {node: '>=18'} + hasBin: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detective-amd@6.1.0: + resolution: {integrity: sha512-fmI6LGMvotqd49QaA3ZYw+q0aGp2yXmMjzIuY6fH9j9YFIXY/73yDhMwhX9cPbhWd+AH06NH1Di/LKOuCH0Ubg==} + engines: {node: '>=18'} + hasBin: true + + detective-cjs@6.1.1: + resolution: {integrity: sha512-pSh7mkCKEtLlmANqLu3KDFS3NV8Hx41jy/JF1/gAWOgU+Uo5QTkeI1tWNP4dWGo4L0E9j18Ez9EPsTleautKqA==} + engines: {node: '>=18'} + + detective-es6@5.0.2: + resolution: {integrity: sha512-+qHHGYhjupiVs4rnIpI9nZ5B130A4AmE35ZX1w33hb46vcZ7T3jfDbvmPw0FhWtMHn5BS5HHu7ZtnZ53bMcXZA==} + engines: {node: '>=18'} + + detective-postcss@8.0.3: + resolution: {integrity: sha512-0AQjxn13b14tLmeXQq0QAFXSP6vBZhWFfmEazyFQ+JVlVwfrYlKF6dGy4R06hqAiSZ9cRvFx0FW4uvVnx0WXiw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4.47 + + detective-sass@6.0.2: + resolution: {integrity: sha512-i3xpXHDKS0qI2aFW4asQ7fqlPK00ndOVZELvQapFJCaF0VxYmsNWtd0AmvXbTLMk7bfO5VdIeorhY9KfmHVoVA==} + engines: {node: '>=18'} + + detective-scss@5.0.2: + resolution: {integrity: sha512-9JOEMZ8pDh3ShXmftq7hoQqqJsClaGgxo1hghfCeFlmKf5TC/Twtwb0PAaK8dXwpg9Z0uCmEYSrCxO+kel2eEg==} + engines: {node: '>=18'} + + detective-stylus@5.0.1: + resolution: {integrity: sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA==} + engines: {node: '>=18'} + + detective-typescript@14.1.2: + resolution: {integrity: sha512-bIeEn0eVi/JRsE1YizBR2ilnMlWRAIBJJ6kXCKNFxEEWhUcEY3R6I3KYIAy48ieURbD1hcb3Ebvl8AqeoPMSzg==} + engines: {node: '>=18'} + peerDependencies: + typescript: ^5.4.4 || ^6.0.2 + + detective-vue2@2.3.0: + resolution: {integrity: sha512-3gwbZPqVTm9sL9XdZsgEJ7x4x99O853VVZHapQAiEkGuMJMpFPjHDrecSgfqnS5JW3FJfYXesLZGvUOibjn49g==} + engines: {node: '>=18'} + peerDependencies: + typescript: ^5.4.4 || ^6.0.2 + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -2527,6 +2659,10 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2559,6 +2695,11 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -2626,6 +2767,11 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -2638,6 +2784,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2682,6 +2831,11 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filing-cabinet@5.4.2: + resolution: {integrity: sha512-Qjh7TLeO/J2wipPA/6rlEiMAqQU6MLw2OdgXhwUdJG2lyHFQ1vdxssXVQUE6mlNuNU8aK5B6T6KMKjrfawq+ZQ==} + engines: {node: '>=18'} + hasBin: true + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2725,6 +2879,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-amd-module-type@6.0.2: + resolution: {integrity: sha512-7zShVYAYtMnj9S65CfN+hvpBCByfuB1OY8xID01nZEzXTZbx4YyysAfi+nMl95JSR6odt4q8TCj2W63KAoyVLQ==} + engines: {node: '>=18'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -2733,6 +2891,9 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2761,6 +2922,11 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + gonzales-pe@4.3.0: + resolution: {integrity: sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==} + engines: {node: '>=0.6.0'} + hasBin: true + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2835,6 +3001,13 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2847,6 +3020,26 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-url-superb@4.0.0: + resolution: {integrity: sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==} + engines: {node: '>=10'} + isarray@0.0.1: resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} @@ -3023,6 +3216,10 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -3036,6 +3233,16 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + madge@8.0.0: + resolution: {integrity: sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: ^5.4.4 + peerDependenciesMeta: + typescript: + optional: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3058,6 +3265,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -3075,6 +3286,9 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -3084,6 +3298,16 @@ packages: engines: {node: '>=10'} hasBin: true + module-definition@6.0.2: + resolution: {integrity: sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA==} + engines: {node: '>=18'} + hasBin: true + + module-lookup-amd@9.1.3: + resolution: {integrity: sha512-Jc3XmOaR9FdfMJSK8+vyLgsCkzm8z2L0NS6vrlRWi12DjS7MY7TMNE7E1yj8yXx837xtMDbKSSgcdXnFlJ2YLg==} + engines: {node: '>=18'} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3113,6 +3337,10 @@ packages: node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-source-walk@7.0.2: + resolution: {integrity: sha512-71kFFjYaSshDTA8/a2HiTYPLdASWjLJxUyJxGE+ffxU+KhxSBtM9kiLUX+R2yooFdSFKMFpi4n3PFtDy6qXv8A==} + engines: {node: '>=18'} + noms@0.0.0: resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==} @@ -3122,10 +3350,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3141,6 +3377,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-ms@2.1.0: + resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} + engines: {node: '>=6'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3153,6 +3393,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -3175,10 +3418,29 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss-values-parser@6.0.2: + resolution: {integrity: sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==} + engines: {node: '>=10'} + peerDependencies: + postcss: ^8.2.9 + postcss@8.5.13: resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + precinct@12.3.2: + resolution: {integrity: sha512-JbJevI1K80z8e/WIyDt/4vUN/4qcfBSKKqOjJA4mosPPPb7zODKRJQV7YN7apVWN3k58nZYm/vEsLgEGYmnxwg==} + engines: {node: '>=18'} + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3202,6 +3464,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-ms@7.0.1: + resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} + engines: {node: '>=10'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -3216,6 +3482,13 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + quote-unquote@1.0.0: + resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -3235,10 +3508,27 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + requirejs-config-file@4.0.0: + resolution: {integrity: sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==} + engines: {node: '>=10.13.0'} + + requirejs@2.3.8: + resolution: {integrity: sha512-7/cTSLOdYkNBNJcDMWf+luFvMriVm7eYxp4BcFCsAX0wF421Vyce5SXP17c+Jd5otXKGNehIonFlyQXSowL6Mw==} + engines: {node: '>=0.4.0'} + hasBin: true + + resolve-dependency-path@4.0.1: + resolution: {integrity: sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ==} + engines: {node: '>=18'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3246,6 +3536,15 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + rimraf@6.1.3: resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} engines: {node: 20 || >=22} @@ -3288,6 +3587,11 @@ packages: safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + sass-lookup@6.1.2: + resolution: {integrity: sha512-GjmndmKQBtlPil79RK72L7yc5kDXZPCQeH97bP8R8DcxtXQJO6vECExb3WP/m6+cxaV9h4ZxrSRvCkPG2v/VSw==} + engines: {node: '>=18'} + hasBin: true + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3311,6 +3615,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3319,12 +3626,19 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-to-array@2.3.0: + resolution: {integrity: sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3339,6 +3653,10 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3347,6 +3665,14 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3354,10 +3680,19 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stylus-lookup@6.1.2: + resolution: {integrity: sha512-O+Q/SJ8s1X2aMLh4213fQ9X/bND9M3dhSsyTRe+O1OXPcewGLiYmAtKCrnP7FDvDBaXB2ZHPkCt3zi4cJXBlCQ==} + engines: {node: '>=18'} + hasBin: true + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3415,6 +3750,14 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-graphviz@2.1.6: + resolution: {integrity: sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw==} + engines: {node: '>=18'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + tsdown@0.19.0-beta.3: resolution: {integrity: sha512-Ud75SBmTap0kDf9hs31yBBlU0iAV17gtZgTJlW6nG/e4J6wXPXwQtUXt/Fck4XSmHXXgSuYRwGrjF6AxTLwk+Q==} engines: {node: '>=20.19.0'} @@ -3602,6 +3945,13 @@ packages: jsdom: optional: true + walkdir@0.4.1: + resolution: {integrity: sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==} + engines: {node: '>=6.0.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -3991,6 +4341,11 @@ snapshots: dependencies: '@ckb-lumos/bi': 0.24.0-next.2 + '@dependents/detective-less@5.0.3': + dependencies: + gonzales-pe: 4.3.0 + node-source-walk: 7.0.2 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -4504,6 +4859,21 @@ snapshots: '@tanstack/query-core': 5.100.9 react: 19.2.5 + '@ts-graphviz/adapter@2.0.6': + dependencies: + '@ts-graphviz/common': 2.1.5 + + '@ts-graphviz/ast@2.0.7': + dependencies: + '@ts-graphviz/common': 2.1.5 + + '@ts-graphviz/common@2.1.5': {} + + '@ts-graphviz/core@2.0.7': + dependencies: + '@ts-graphviz/ast': 2.0.7 + '@ts-graphviz/common': 2.1.5 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -4745,6 +5115,38 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vue/compiler-core@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.34 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.34': + dependencies: + '@vue/compiler-core': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/compiler-sfc@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.34 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.14 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.34': + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/shared@3.5.34': {} + abitype@0.8.7(typescript@5.9.3)(zod@3.25.76): dependencies: typescript: 5.9.3 @@ -4782,6 +5184,10 @@ snapshots: ansis@4.2.0: {} + any-promise@1.3.0: {} + + app-module-path@2.2.0: {} + argparse@2.0.1: {} assertion-error@2.0.1: {} @@ -4791,6 +5197,8 @@ snapshots: '@babel/parser': 7.29.3 pathe: 2.0.3 + ast-module-types@6.0.1: {} + ast-v8-to-istanbul@0.3.12: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -4846,6 +5254,12 @@ snapshots: transitivePeerDependencies: - typescript + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + blake2b-wasm@2.4.0: dependencies: b4a: 1.8.1 @@ -4898,6 +5312,11 @@ snapshots: '@noble/hashes': 1.8.0 bs58: 6.0.0 + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -4931,12 +5350,20 @@ snapshots: check-error@2.1.3: {} + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + cliui@7.0.4: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone@1.0.4: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4947,6 +5374,12 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@12.1.0: {} + + commander@7.2.0: {} + + commondir@1.0.1: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -4989,16 +5422,87 @@ snapshots: deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + deep-freeze-strict@1.1.1: {} deep-is@0.1.4: {} + defaults@1.0.4: + dependencies: + clone: 1.0.4 + defu@6.1.7: {} delayed-stream@1.0.0: {} + dependency-tree@11.4.3: + dependencies: + commander: 12.1.0 + filing-cabinet: 5.4.2 + precinct: 12.3.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + detect-libc@2.1.2: {} + detective-amd@6.1.0: + dependencies: + ast-module-types: 6.0.1 + escodegen: 2.1.0 + get-amd-module-type: 6.0.2 + node-source-walk: 7.0.2 + + detective-cjs@6.1.1: + dependencies: + ast-module-types: 6.0.1 + node-source-walk: 7.0.2 + + detective-es6@5.0.2: + dependencies: + node-source-walk: 7.0.2 + + detective-postcss@8.0.3(postcss@8.5.14): + dependencies: + is-url-superb: 4.0.0 + postcss: 8.5.14 + postcss-values-parser: 6.0.2(postcss@8.5.14) + + detective-sass@6.0.2: + dependencies: + gonzales-pe: 4.3.0 + node-source-walk: 7.0.2 + + detective-scss@5.0.2: + dependencies: + gonzales-pe: 4.3.0 + node-source-walk: 7.0.2 + + detective-stylus@5.0.1: {} + + detective-typescript@14.1.2(typescript@5.9.3): + dependencies: + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + ast-module-types: 6.0.1 + node-source-walk: 7.0.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + detective-vue2@2.3.0(typescript@5.9.3): + dependencies: + '@dependents/detective-less': 5.0.3 + '@vue/compiler-sfc': 3.5.34 + detective-es6: 5.0.2 + detective-sass: 6.0.2 + detective-scss: 5.0.2 + detective-stylus: 5.0.1 + detective-typescript: 14.1.2(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + dotenv@17.4.2: {} dts-resolver@2.1.3: {} @@ -5034,6 +5538,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@7.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -5084,6 +5590,14 @@ snapshots: escape-string-regexp@4.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -5175,6 +5689,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -5185,6 +5701,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -5224,6 +5742,20 @@ snapshots: dependencies: flat-cache: 4.0.1 + filing-cabinet@5.4.2: + dependencies: + app-module-path: 2.2.0 + commander: 12.1.0 + enhanced-resolve: 5.21.0 + module-definition: 6.0.2 + module-lookup-amd: 9.1.3 + resolve: 1.22.12 + resolve-dependency-path: 4.0.1 + sass-lookup: 6.1.2 + stylus-lookup: 6.1.2 + tsconfig-paths: 4.2.0 + typescript: 5.9.3 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5260,6 +5792,11 @@ snapshots: gensync@1.0.0-beta.2: {} + get-amd-module-type@6.0.2: + dependencies: + ast-module-types: 6.0.1 + node-source-walk: 7.0.2 + get-caller-file@2.0.5: {} get-intrinsic@1.3.0: @@ -5275,6 +5812,8 @@ snapshots: hasown: 2.0.3 math-intrinsics: 1.1.0 + get-own-enumerable-property-symbols@3.0.2: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -5314,6 +5853,10 @@ snapshots: globals@14.0.0: {} + gonzales-pe@4.3.0: + dependencies: + minimist: 1.2.8 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -5375,6 +5918,12 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -5383,6 +5932,16 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-interactive@1.0.0: {} + + is-obj@1.0.1: {} + + is-regexp@1.0.0: {} + + is-unicode-supported@0.1.0: {} + + is-url-superb@4.0.0: {} + isarray@0.0.1: {} isarray@1.0.0: {} @@ -5528,6 +6087,11 @@ snapshots: lodash.merge@4.6.2: {} + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -5538,6 +6102,25 @@ snapshots: dependencies: yallist: 3.1.1 + madge@8.0.0(typescript@5.9.3): + dependencies: + chalk: 4.1.2 + commander: 7.2.0 + commondir: 1.0.1 + debug: 4.4.3 + dependency-tree: 11.4.3 + ora: 5.4.1 + pluralize: 8.0.0 + pretty-ms: 7.0.1 + rc: 1.2.8 + stream-to-array: 2.3.0 + ts-graphviz: 2.1.6 + walkdir: 0.4.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5560,6 +6143,8 @@ snapshots: dependencies: mime-db: 1.52.0 + mimic-fn@2.1.0: {} + minimalistic-assert@1.0.1: {} minimalistic-crypto-utils@1.0.1: {} @@ -5576,10 +6161,23 @@ snapshots: dependencies: brace-expansion: 2.1.0 + minimist@1.2.8: {} + minipass@7.1.3: {} mkdirp@1.0.4: {} + module-definition@6.0.2: + dependencies: + ast-module-types: 6.0.1 + node-source-walk: 7.0.2 + + module-lookup-amd@9.1.3: + dependencies: + commander: 12.1.0 + requirejs: 2.3.8 + requirejs-config-file: 4.0.0 + ms@2.1.3: {} multiformats@13.4.2: {} @@ -5596,6 +6194,10 @@ snapshots: node-releases@2.0.38: {} + node-source-walk@7.0.2: + dependencies: + '@babel/parser': 7.29.3 + noms@0.0.0: dependencies: inherits: 2.0.4 @@ -5607,6 +6209,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5616,6 +6222,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -5630,12 +6248,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-ms@2.1.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} path-key@3.1.1: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -5654,12 +6276,47 @@ snapshots: picomatch@4.0.4: {} + pluralize@8.0.0: {} + + postcss-values-parser@6.0.2(postcss@8.5.14): + dependencies: + color-name: 1.1.4 + is-url-superb: 4.0.0 + postcss: 8.5.14 + quote-unquote: 1.0.0 + postcss@8.5.13: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + precinct@12.3.2: + dependencies: + '@dependents/detective-less': 5.0.3 + commander: 12.1.0 + detective-amd: 6.1.0 + detective-cjs: 6.1.1 + detective-es6: 5.0.2 + detective-postcss: 8.0.3(postcss@8.5.14) + detective-sass: 6.0.2 + detective-scss: 5.0.2 + detective-stylus: 5.0.1 + detective-typescript: 14.1.2(typescript@5.9.3) + detective-vue2: 2.3.0(typescript@5.9.3) + module-definition: 6.0.2 + node-source-walk: 7.0.2 + postcss: 8.5.14 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -5673,6 +6330,10 @@ snapshots: prettier@3.8.3: {} + pretty-ms@7.0.1: + dependencies: + parse-ms: 2.1.0 + process-nextick-args@2.0.1: {} proxy-from-env@2.1.0: {} @@ -5681,6 +6342,15 @@ snapshots: quansync@1.0.0: {} + quote-unquote@1.0.0: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 @@ -5707,12 +6377,39 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + require-directory@2.1.1: {} + requirejs-config-file@4.0.0: + dependencies: + esprima: 4.0.1 + stringify-object: 3.3.0 + + requirejs@2.3.8: {} + + resolve-dependency-path@4.0.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + rimraf@6.1.3: dependencies: glob: 13.0.6 @@ -5810,6 +6507,11 @@ snapshots: safe-buffer@5.1.2: {} + sass-lookup@6.1.2: + dependencies: + commander: 12.1.0 + enhanced-resolve: 5.21.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -5824,14 +6526,23 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} source-map-js@1.2.1: {} + source-map@0.6.1: + optional: true + stackback@0.0.2: {} std-env@3.10.0: {} + stream-to-array@2.3.0: + dependencies: + any-promise: 1.3.0 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5850,6 +6561,12 @@ snapshots: dependencies: safe-buffer: 5.1.2 + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -5858,16 +6575,26 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-bom@3.0.0: {} + + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 + stylus-lookup@6.1.2: + dependencies: + commander: 12.1.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -5912,6 +6639,19 @@ snapshots: dependencies: typescript: 5.9.3 + ts-graphviz@2.1.6: + dependencies: + '@ts-graphviz/adapter': 2.0.6 + '@ts-graphviz/ast': 2.0.7 + '@ts-graphviz/common': 2.1.5 + '@ts-graphviz/core': 2.0.7 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + tsdown@0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(synckit@0.11.12)(typescript@5.9.3): dependencies: ansis: 4.2.0 @@ -6101,6 +6841,12 @@ snapshots: - tsx - yaml + walkdir@0.4.1: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: From ccad108f034741f2bc30811a0d00ef519193b94a Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 23:50:41 +0000 Subject: [PATCH 14/17] fix(core): share owned withdrawal decode caches --- packages/core/src/owned_owner.test.ts | 88 +++++++++++++++++++++++++++ packages/core/src/owned_owner.ts | 10 ++- packages/dao/src/index.ts | 6 +- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/core/src/owned_owner.test.ts b/packages/core/src/owned_owner.test.ts index c391c41..c32cb18 100644 --- a/packages/core/src/owned_owner.test.ts +++ b/packages/core/src/owned_owner.test.ts @@ -562,4 +562,92 @@ describe("OwnedOwnerManager.findWithdrawalGroups", () => { secondOwner.outPoint.toHex(), ]); }); + + it("deduplicates referenced withdrawal header lookups during a scan", 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 txHash = byte32FromByte("88"); + const firstOwner = ccc.Cell.from({ + outPoint: { txHash, index: 1n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const secondOwner = ccc.Cell.from({ + outPoint: { txHash, index: 3n }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + const firstOwned = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + const secondOwned = ccc.Cell.from({ + outPoint: { txHash, index: 2n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + const referencedCells = new Map([ + [firstOwned.outPoint.toHex(), firstOwned], + [secondOwned.outPoint.toHex(), secondOwned], + ]); + let headerCalls = 0; + let transactionCalls = 0; + const client = { + getTipHeader: async () => { + await Promise.resolve(); + return tip; + }, + findCells: async function* () { + await Promise.resolve(); + yield firstOwner; + yield secondOwner; + }, + getCell: async (outPoint: ccc.OutPoint) => { + await Promise.resolve(); + return referencedCells.get(outPoint.toHex()); + }, + getHeaderByNumber: async () => { + headerCalls += 1; + await Promise.resolve(); + return headerLike({ number: 1n }); + }, + getTransactionWithHeader: async () => { + transactionCalls += 1; + await Promise.resolve(); + return { header: headerLike({ number: 2n }) }; + }, + } as unknown as ccc.Client; + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toHaveLength(2); + expect(headerCalls).toBe(1); + expect(transactionCalls).toBe(1); + }); }); diff --git a/packages/core/src/owned_owner.ts b/packages/core/src/owned_owner.ts index 78d2fc8..54d0fe3 100644 --- a/packages/core/src/owned_owner.ts +++ b/packages/core/src/owned_owner.ts @@ -4,7 +4,11 @@ import { unique, type ScriptDeps, } from "@ickb/utils"; -import { assertDaoOutputLimit, DaoManager } from "@ickb/dao"; +import { + assertDaoOutputLimit, + DaoManager, + type DaoCellFromCache, +} from "@ickb/dao"; import { OwnerData } from "./entities.js"; import { OwnerCell, WithdrawalGroup, type IckbDepositCell } from "./cells.js"; @@ -237,6 +241,8 @@ export class OwnedOwnerManager implements ScriptDeps { ownerCandidates.map((owner) => client.getCell(owner.getOwned())), ); + const headerCache: DaoCellFromCache["headerCache"] = new Map(); + const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); const withdrawalGroups = await Promise.all( ownerCandidates.map(async (owner, index) => { const ownedCell = ownedCells[index]; @@ -246,7 +252,7 @@ export class OwnedOwnerManager implements ScriptDeps { const owned = await this.daoManager.withdrawalRequestCellFrom( ownedCell, client, - { tip }, + { tip, headerCache, transactionCache }, ); return new WithdrawalGroup(owned, owner); }), diff --git a/packages/dao/src/index.ts b/packages/dao/src/index.ts index a3f01ec..fd72b69 100644 --- a/packages/dao/src/index.ts +++ b/packages/dao/src/index.ts @@ -1,2 +1,6 @@ -export type { DaoDepositCell, DaoWithdrawalRequestCell } from "./cells.js"; +export type { + DaoCellFromCache, + DaoDepositCell, + DaoWithdrawalRequestCell, +} from "./cells.js"; export * from "./dao.js"; From cb0852ebe5e64cc58670d90335fde8c3b5cedb60 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 23:50:41 +0000 Subject: [PATCH 15/17] fix(order): allow exact-limit scans --- packages/order/src/order.test.ts | 109 +++++++++++++++++++++++++++++-- packages/order/src/order.ts | 18 +++-- 2 files changed, 114 insertions(+), 13 deletions(-) diff --git a/packages/order/src/order.test.ts b/packages/order/src/order.test.ts index 475126a..767ba00 100644 --- a/packages/order/src/order.test.ts +++ b/packages/order/src/order.test.ts @@ -416,7 +416,7 @@ describe("OrderManager.findOrders", () => { return; } - for (let index = 0; index < defaultFindCellsLimit; index += 1) { + for (let index = 0; index <= defaultFindCellsLimit; index += 1) { yield order.cell; } }, @@ -460,7 +460,7 @@ describe("OrderManager.findOrders", () => { return; } - for (let index = 0; index < defaultFindCellsLimit; index += 1) { + for (let index = 0; index <= defaultFindCellsLimit; index += 1) { yield masterCell; } }, @@ -471,6 +471,105 @@ describe("OrderManager.findOrders", () => { ); }); + it("accepts exact-limit order and master scans", async () => { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + const master = ccc.OutPoint.from({ txHash: byte32FromByte("36"), index: 1n }); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash: master.txHash, index: 0n }, + }); + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: master }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("37"), index: 0n }, + }); + const masterCell = ccc.Cell.from({ + outPoint: master, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + const tx = ccc.Transaction.default(); + tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); + tx.outputsData.push(origin.cell.outputData, masterCell.outputData); + const client = { + cache: new ccc.ClientCacheMemory(), + findCellsOnChain: async function* (query: { scriptType: string }) { + await Promise.resolve(); + if (query.scriptType === "lock") { + for (let index = 0; index < defaultFindCellsLimit; index += 1) { + yield index === 0 ? order.cell : ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("38"), index: BigInt(index) }, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: orderScript, + type: udtScript, + }, + outputData: "0x", + }); + } + return; + } + + for (let index = 0; index < defaultFindCellsLimit; index += 1) { + yield index === 0 ? masterCell : ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("39"), index: BigInt(index) }, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); + } + }, + getTransaction: async (txHash: ccc.Hex) => { + await Promise.resolve(); + return txHash === master.txHash + ? ccc.ClientTransactionResponse.from({ + transaction: tx, + status: "committed", + }) + : undefined; + }, + } as unknown as ccc.Client; + + const groups = []; + for await (const group of manager.findOrders(client)) { + groups.push(group); + } + + expect(groups).toHaveLength(1); + }); + it("findOrigin skips parseable non-mint origins in the master transaction", async () => { const orderScript = ccc.Script.from({ codeHash: byte32FromByte("11"), @@ -924,16 +1023,12 @@ describe("OrderManager.findOrders", () => { getTransaction: async (queriedTxHash: ccc.Hex) => { getTransactionCalls += 1; await Promise.resolve(); - const res = queriedTxHash === actualTxHash + return queriedTxHash === actualTxHash ? ccc.ClientTransactionResponse.from({ transaction: tx, status: "committed", }) : undefined; - if (res) { - await cache.recordTransactionResponses(res); - } - return res; }, } as unknown as ccc.Client; diff --git a/packages/order/src/order.ts b/packages/order/src/order.ts index 6155059..56e8356 100644 --- a/packages/order/src/order.ts +++ b/packages/order/src/order.ts @@ -631,6 +631,7 @@ export class OrderManager implements ScriptDeps { limit: number, ): Promise { const orders: OrderCell[] = []; + const scanLimit = limit + 1; const findCellsArgs = [ { script: this.script, @@ -642,7 +643,7 @@ export class OrderManager implements ScriptDeps { withData: true, }, "asc", - limit, + scanLimit, ] as const; let scanned = 0; @@ -679,6 +680,7 @@ export class OrderManager implements ScriptDeps { limit: number, ): Promise { const masters: MasterCell[] = []; + const scanLimit = limit + 1; const findCellsArgs = [ { script: this.script, @@ -687,7 +689,7 @@ export class OrderManager implements ScriptDeps { withData: true, }, "asc", - limit, + scanLimit, ] as const; let scanned = 0; @@ -722,9 +724,13 @@ export class OrderManager implements ScriptDeps { master: ccc.OutPoint, ): Promise { const { txHash, index: mIndex } = master; - const res = - (await client.cache.getTransactionResponse(txHash)) ?? - (await client.getTransaction(txHash)); + let res = await client.cache.getTransactionResponse(txHash); + if (!res) { + res = await client.getTransaction(txHash); + if (res) { + await client.cache.recordTransactionResponses(res); + } + } if (!res) { return; } @@ -762,7 +768,7 @@ export class OrderManager implements ScriptDeps { } function assertCompleteScan(scanned: number, limit: number, label: string): void { - if (scanned < limit) { + if (scanned <= limit) { return; } From 3e974cc9cafc0bb5498dd6f73914b13a3786e703 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 23:50:41 +0000 Subject: [PATCH 16/17] fix(sdk): poll confirmation before waiting --- packages/sdk/src/sdk.test.ts | 10 +++++++--- packages/sdk/src/sdk.ts | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 46c8baa..57a2133 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -789,14 +789,16 @@ describe("sendAndWaitForCommit", () => { )).resolves.toBe(txHash); expect(sendTransaction).toHaveBeenCalledTimes(1); - expect(onConfirmationWait).toHaveBeenCalledTimes(3); - expect(sleep).toHaveBeenCalledTimes(3); + expect(onConfirmationWait).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(2); expect(sleep).toHaveBeenCalledWith(7); expect(getTransaction).toHaveBeenCalledTimes(3); expect(getTransaction).toHaveBeenCalledWith(txHash); }); it("surfaces terminal transaction failures", async () => { + const sleep = vi.fn(() => Promise.resolve()); + await expect(sendAndWaitForCommit( { client: { @@ -807,8 +809,10 @@ describe("sendAndWaitForCommit", () => { } as unknown as ccc.Signer, }, ccc.Transaction.default(), - { sleep: () => Promise.resolve() }, + { sleep }, )).rejects.toThrow("Transaction ended with status: rejected"); + + expect(sleep).not.toHaveBeenCalled(); }); it("surfaces transaction confirmation timeouts", async () => { diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index fdd719a..fde6322 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -74,9 +74,13 @@ export async function sendAndWaitForCommit( let status: string | undefined = "sent"; for (let checks = 0; checks < maxConfirmationChecks && isPendingStatus(status); checks += 1) { + status = (await client.getTransaction(txHash))?.status; + if (!isPendingStatus(status)) { + break; + } + onConfirmationWait?.(); await sleep(confirmationIntervalMs); - status = (await client.getTransaction(txHash))?.status; } if (status === "committed") { From 7cbaddea9bd970cabef9f760dabb250d90151a33 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sat, 9 May 2026 23:50:41 +0000 Subject: [PATCH 17/17] fix(utils): compress subset search halves --- packages/utils/src/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index e7ecb28..4163f06 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -255,7 +255,9 @@ export function selectBoundedUdtSubset( return groups; }; - const firstByCount = enumerate(firstHalf); + const firstByCount = enumerate(firstHalf).map((selections) => + compressSelections(selections, firstHalf.length) + ); const secondByCount = enumerate(secondHalf).map((selections) => compressSelections(selections, secondHalf.length) );