diff --git a/apps/bot/src/policy.ts b/apps/bot/src/policy.ts index 57f8d8d..44f9d19 100644 --- a/apps/bot/src/policy.ts +++ b/apps/bot/src/policy.ts @@ -1,9 +1,9 @@ import { ccc } from "@ckb-ccc/core"; import { ICKB_DEPOSIT_CAP, type IckbDepositCell } from "@ickb/core"; import { - selectReadyWithdrawalCleanupDeposit, selectReadyWithdrawalDeposits, -} from "@ickb/sdk"; + selectReadyWithdrawalCleanupDeposit, +} from "./withdrawal_selection.ts"; import { compareBigInt } from "@ickb/utils"; export const CKB = ccc.fixedPointFrom(1); diff --git a/packages/sdk/src/withdrawal_selection.ts b/apps/bot/src/withdrawal_selection.ts similarity index 100% rename from packages/sdk/src/withdrawal_selection.ts rename to apps/bot/src/withdrawal_selection.ts diff --git a/apps/interface/src/queries.test.ts b/apps/interface/src/queries.test.ts index b97a81b..a4c1c0c 100644 --- a/apps/interface/src/queries.test.ts +++ b/apps/interface/src/queries.test.ts @@ -13,11 +13,11 @@ function script(codeHashByte: string): ccc.Script { }); } -function cell(capacity: bigint, lock: ccc.Script): ccc.Cell { +function cell(capacity: bigint, lock: ccc.Script, outputData = "0x"): ccc.Cell { return ccc.Cell.from({ outPoint: { txHash: byte32FromByte("aa"), index: 0n }, cellOutput: { capacity, lock }, - outputData: "0x", + outputData, }); } @@ -78,6 +78,9 @@ describe("getL1State", () => { const lock = script("11"); const tip = { timestamp: 10n } as ccc.ClientBlockHeader; const nativeCapacity = ccc.fixedPointFrom(50); + const capacityCell = cell(nativeCapacity, lock); + const nativeUdtCell = cell(7n, lock, ccc.hexFrom(ccc.numLeToBytes(11n, 16))); + nativeUdtCell.outPoint.txHash = byte32FromByte("bb"); const receipt = { ckbValue: 13n, udtValue: 17n }; const readyWithdrawal = { ckbValue: 19n, @@ -116,8 +119,8 @@ describe("getL1State", () => { }, user: { orders: [availableOrder, pendingOrder] }, account: { - capacityCells: [cell(nativeCapacity, lock)], - nativeUdtCells: [], + capacityCells: [capacityCell], + nativeUdtCells: [nativeUdtCell], nativeUdtCapacity: 7n, nativeUdtBalance: 11n, receipts: [receipt], @@ -145,8 +148,8 @@ describe("getL1State", () => { "ratio=1/1", "pool=0;;;deposits=", `balances=${String(nativeCapacity + 142n)}/248`, - `capacityCells=${cell(nativeCapacity, lock).outPoint.toHex()}`, - "nativeUdtCells=", + `capacityCells=${capacityCell.outPoint.toHex()}`, + `nativeUdtCells=${nativeUdtCell.outPoint.toHex()}`, "maturity=60", "receipts=13/17@missing-outpoint", "readyWithdrawals=19/0@missing-outpoint@missing-outpoint", diff --git a/apps/tester/src/runtime.test.ts b/apps/tester/src/runtime.test.ts index 4cd6fb3..5b47c70 100644 --- a/apps/tester/src/runtime.test.ts +++ b/apps/tester/src/runtime.test.ts @@ -68,6 +68,8 @@ describe("readTesterState", () => { it("includes receipts and ready withdrawals in the actionable state", async () => { const plainLock = script("11"); const plainCell = cell(5n, plainLock); + const nativeUdtCell = cell(7n, plainLock, ccc.hexFrom(ccc.numLeToBytes(11n, 16))); + nativeUdtCell.outPoint.txHash = byte32FromByte("bb"); const userOrder = { ckbValue: 23n, udtValue: 29n, @@ -93,7 +95,7 @@ describe("readTesterState", () => { }; const account = { capacityCells: [plainCell], - nativeUdtCells: [], + nativeUdtCells: [nativeUdtCell], nativeUdtCapacity: 7n, nativeUdtBalance: 11n, receipts: [receipt], diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 989a515..77905e0 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -36,9 +36,9 @@ See [docs/pool_maturity_estimates.md](./docs/pool_maturity_estimates.md). ## Ready Withdrawal Selection -`selectReadyWithdrawalDeposits(...)` exposes the stack's pool-friendly ready-deposit selector for direct iCKB-to-CKB withdrawal requests. Callers provide ready deposits, an optional near-ready refill window, the current tip, and amount/count limits. Setting `minCount` and `maxCount` to the same value requests an exact number of deposits. `preserveSingletons` defaults to `true`, so singleton bucket anchors are protected unless the caller explicitly permits selecting them. The selector prefers crowded-bucket extras before singleton anchors, returns the chosen deposits, and also returns `requiredLiveDeposits` for protected anchors that should be added as live `cell_dep` checks when building the withdrawal request. +`selectReadyWithdrawalDeposits(...)` exposes the stack's ready-deposit selector for direct iCKB-to-CKB withdrawal requests. Callers provide ready deposits, the current tip, amount/count limits, and optional ring filters. Setting `minCount` and `maxCount` to the same value requests an exact number of deposits. The selector compares bounded best-fit and greedy candidates, returns the chosen deposits, and also returns `requiredLiveDeposits` supplied by the caller for live `cell_dep` checks when building the withdrawal request. -`selectReadyWithdrawalCleanupDeposit(...)` is the narrow cleanup helper used by the bot for over-standard crowded-bucket extras. It returns at most one extra plus the protected anchor that must remain live. Bot thresholds such as target balances, singleton unlock policy, and whether a cleanup is worth doing remain app policy in `apps/bot`. +Ring helpers such as `ringSurplusDepositFilter(...)` and `ringRequiredLiveDepositFor(...)` operate on the full live pool snapshot. Normal bot and interface direct withdrawals use ring surplus only; bot reserve recovery is app policy and may relax that rule after surplus recovery fails. `IckbSdk.buildBaseTransaction(...)` accepts `withdrawalRequest.requiredLiveDeposits` and adds those cells as live cell deps. This is an inclusion-time liveness check for public pool anchors, not a reservation of those cells after the transaction commits. @@ -56,6 +56,8 @@ The returned transaction is not completed, signed, sent, or confirmed. Callers s `IckbSdk.estimate(...)` returns order `info` even when the normal fee threshold is too small to produce a maturity estimate. Callers that intentionally build tiny iCKB-to-CKB orders can pass an explicit fee/feeBase discount to `estimate(...)`; the resulting order uses the existing order wire format. The limit-order contract can fully complete an order whose remaining match is below the configured minimum, so tiny dust orders do not need a special minimum-match encoding. This is how the interface presents small-balance conversions that may be worthwhile for recovering locked xUDT cell capacity. +SDK estimates use `OrderManager.convert(...)` as the quote boundary. The displayed `convertedAmount` and returned order `info` are paired: the `info` preserves the rounded-up full-fill quote for the same amounts. If that quote cannot be represented in the order script's Uint64 ratio fields, SDK planners treat the order as too small or unbuildable rather than producing weaker terms. + ## Send Confirmation `sendAndWaitForCommit(...)` returns the transaction hash after commit. If a transaction was broadcast but later reaches a terminal non-committed status or times out while still pending, it throws `TransactionConfirmationError` with the broadcast `txHash`, last observed `status`, and `isTimeout` flag. Callers that need to log the hash immediately after broadcast can use the `onSent` callback. Callers that need structured lifecycle evidence can use `onLifecycle`, which emits `pre_broadcast_failed`, `broadcasted`, `committed`, `timeout_after_broadcast`, `post_broadcast_unresolved`, and `terminal_rejection` events without changing the returned hash or thrown transaction error contract. diff --git a/packages/sdk/api-extractor.json b/packages/sdk/api-extractor.json new file mode 100644 index 0000000..d842b4d --- /dev/null +++ b/packages/sdk/api-extractor.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../api-extractor.base.json", + "mainEntryPointFilePath": "/dist/index.d.ts" +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index af04cfa..ac1bc71 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -13,36 +13,43 @@ "homepage": "https://ickb.org", "repository": { "type": "git", - "url": "https://github.com/ickb/stack" + "url": "git+https://github.com/ickb/stack.git" }, "bugs": { "url": "https://github.com/ickb/stack/issues" }, "sideEffects": false, "type": "module", - "main": "dist/index.js", + "engines": { + "node": ">=22.19.0" + }, + "main": "src/index.ts", "types": "src/index.ts", "exports": { ".": { + "api-extractor": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, "types": "./src/index.ts", - "import": "./dist/index.js" + "import": "./src/index.ts" } }, "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "tsc", - "lint": "eslint ./src", - "clean": "rm -fr dist", - "clean:deep": "rm -fr dist node_modules" + "build": "rm -fr dist && tsgo -p tsconfig.build.json && node ../../scripts/tooling/build/rewrite-dts-imports.ts dist", + "lint:api": "api-extractor run --local", + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/sdk/src packages/sdk/test", + "clean": "rm -fr dist" }, "files": [ - "dist", - "src" + "dist" ], "publishConfig": { "access": "public", "provenance": true, + "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { diff --git a/packages/sdk/src/client/sdk_base.ts b/packages/sdk/src/client/sdk_base.ts new file mode 100644 index 0000000..018e919 --- /dev/null +++ b/packages/sdk/src/client/sdk_base.ts @@ -0,0 +1,194 @@ +import { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell } from "@ickb/core"; +import { assertDaoOutputLimit } from "@ickb/dao"; +import type { Info, OrderGroup } from "@ickb/order"; +import type { ValueComponents } from "@ickb/utils"; +import { isChangeCellCapacityError } from "../conversion/sdk_conversion_common.ts"; +import { assertReadyWithdrawalDeposits } from "../withdrawal/withdrawal_selection.ts"; +import { sdkManagers } from "./sdk_state_store.ts"; +import type { + BuildBaseTransactionOptions, + CompleteIckbTransactionOptions, +} from "./sdk_types.ts"; + +/** + * Base SDK transaction helpers shared by conversion and L1 APIs. + * + * @public + */ +export class IckbSdkBase { + protected constructor() {} + + /** + * Completes iCKB/xUDT inputs and transaction fees for a partial transaction. + * + * @remarks + * This does not sign or send the transaction. It retries fee completion once + * when CCC needs to place fee change into an existing iCKB-owned output. + */ + public async completeTransaction( + txLike: ccc.TransactionLike, + options: CompleteIckbTransactionOptions, + ): Promise { + const { ickbUdt } = sdkManagers(this); + const tx = await ickbUdt.completeBy(txLike, options.signer); + try { + await tx.completeFeeBy(options.signer, options.feeRate); + } catch (error) { + if (!isChangeCellCapacityError(error)) { + throw error; + } + + const retryTx = await ickbUdt.completeBy(txLike, options.signer); + const feeChangeOutputIndex = await this.findFeeChangeOutputIndex( + retryTx, + options.signer, + ); + if (feeChangeOutputIndex === undefined) { + throw error; + } + await retryTx.completeFeeChangeToOutput( + options.signer, + feeChangeOutputIndex, + options.feeRate, + ); + await assertDaoOutputLimit(retryTx, options.client); + return retryTx; + } + await assertDaoOutputLimit(tx, options.client); + return tx; + } + + /** + * Adds a user-owned order request to a partial transaction. + */ + public async request( + txLike: ccc.TransactionLike, + user: ccc.Signer | ccc.Script, + info: Info, + amounts: ValueComponents, + ): Promise { + const { order } = sdkManagers(this); + const lock = + "codeHash" in user ? user : (await user.getRecommendedAddressObj()).script; + return order.mint(txLike, lock, info, amounts); + } + + /** + * Adds order group inputs for collection or fulfilled-order cleanup. + */ + public collect( + txLike: ccc.TransactionLike, + groups: OrderGroup[], + options?: { isFulfilledOnly?: boolean }, + ): ccc.Transaction { + return sdkManagers(this).order.melt(txLike, groups, options); + } + + /** + * Adds common collect steps to a partial conversion transaction. + * + * @remarks + * The result is still partial. Callers should use `completeTransaction` before + * signing and sending. + */ + public async buildBaseTransaction( + txLike: ccc.TransactionLike, + client: ccc.Client, + options: BuildBaseTransactionOptions = {}, + ): Promise { + const { ownedOwner, ickbLogic } = sdkManagers(this); + let tx = ccc.Transaction.from(txLike); + const { + withdrawalRequest, + orders = [], + receipts = [], + readyWithdrawals = [], + } = options; + if (withdrawalRequest !== undefined && withdrawalRequest.deposits.length > 0) { + const requiredLiveDeposits = withdrawalRequest.requiredLiveDeposits ?? []; + assertReadyWithdrawalDeposits(withdrawalRequest.deposits); + assertRequiredLiveWithdrawalDeposits( + withdrawalRequest.deposits, + requiredLiveDeposits, + ); + tx = await ownedOwner.requestWithdrawal( + tx, + withdrawalRequest.deposits, + withdrawalRequest.lock, + client, + requiredLiveDeposits.length > 0 ? { requiredLiveDeposits } : undefined, + ); + } + if (orders.length > 0) { + tx = this.collect(tx, orders); + } + if (receipts.length > 0) { + tx = ickbLogic.completeDeposit(tx, receipts); + } + if (readyWithdrawals.length > 0) { + tx = await ownedOwner.withdraw(tx, readyWithdrawals, client); + } + return tx; + } + + /** + * Throws when the chain tip changed since the sampled state was built. + */ + public async assertCurrentTip( + client: ccc.Client, + tip: ccc.ClientBlockHeader, + ): Promise { + const currentTip = await client.getTipHeader(); + if (currentTip.number !== tip.number || currentTip.hash !== tip.hash) { + throw new Error( + `L1 state scan crossed chain tip; sampled block ${String(tip.number)} ${tip.hash}; current block ${String(currentTip.number)} ${currentTip.hash}; retry with a fresh state`, + ); + } + } + + private async findFeeChangeOutputIndex( + tx: ccc.Transaction, + signer: ccc.Signer, + ): Promise { + const { ickbLogic, ownedOwner, order } = sdkManagers(this); + const { script: userLock } = await signer.getRecommendedAddressObj(); + let masterIndex: number | undefined; + let ownerIndex: number | undefined; + for (const [index, output] of Array.from(tx.outputs.entries()).toReversed()) { + if (!output.lock.eq(userLock)) { + continue; + } + if (output.type?.eq(ickbLogic.script) === true) { + return index; + } + if (masterIndex === undefined && output.type?.eq(order.script) === true) { + masterIndex = index; + } + if (ownerIndex === undefined && output.type?.eq(ownedOwner.script) === true) { + ownerIndex = index; + } + } + return masterIndex ?? ownerIndex; + } +} + +function assertRequiredLiveWithdrawalDeposits( + requestedDeposits: readonly IckbDepositCell[], + requiredLiveDeposits: readonly IckbDepositCell[], +): void { + const spent = new Set( + requestedDeposits.map((deposit) => deposit.cell.outPoint.toHex()), + ); + const seen = new Set(); + for (const deposit of requiredLiveDeposits) { + const outPoint = deposit.cell.outPoint.toHex(); + if (seen.has(outPoint)) { + throw new Error(`Withdrawal live deposit anchor ${outPoint} is duplicated`); + } + if (spent.has(outPoint)) { + throw new Error(`Withdrawal live deposit anchor ${outPoint} is also being spent`); + } + seen.add(outPoint); + } +} diff --git a/packages/sdk/src/client/sdk_conversion_class.ts b/packages/sdk/src/client/sdk_conversion_class.ts new file mode 100644 index 0000000..e38f0d8 --- /dev/null +++ b/packages/sdk/src/client/sdk_conversion_class.ts @@ -0,0 +1,208 @@ +import { ccc } from "@ckb-ccc/core"; +import { + baseTransactionOptions, + conversionFailure, + conversionKind, + hasTransactionActivity, + isRetryableConversionBuildError, + NOTHING_TO_DO_REASON, + orderOutputCount, + plannedDaoOutputLimitError, +} from "../conversion/sdk_conversion_common.ts"; +import { + ckbToIckbConversionPlans, + ickbToCkbConversionPlans, +} from "../conversion/sdk_conversion_plans.ts"; +import { IckbSdkBase } from "./sdk_base.ts"; +import { errorOf } from "./sdk_error.ts"; +import { sdkManagers } from "./sdk_state_store.ts"; +import type { + ConversionTransactionOptions, + ConversionTransactionResult, + GetPoolDepositsOptions, + PoolDepositState, +} from "./sdk_types.ts"; + +/** + * SDK layer that builds conversion transactions from a sampled state context. + * + * @public + */ +export abstract class IckbSdkConversion extends IckbSdkBase { + /** Reads public pool deposits and evaluates readiness against the supplied sampled tip. */ + public abstract getPoolDeposits( + client: ccc.Client, + tip: ccc.ClientBlockHeader, + options?: GetPoolDepositsOptions, + ): Promise; + + /** + * Builds a partial conversion transaction from a conversion context. + * + * @remarks + * A successful result still needs `completeTransaction`, signing, and send. + * Failure results are expected planning outcomes; unexpected build errors throw. + */ + public async buildConversionTransaction( + txLike: ccc.TransactionLike, + client: ccc.Client, + options: ConversionTransactionOptions, + ): Promise { + const { amount, context, direction } = options; + if (amount < 0n) { + return conversionFailure("amount-negative", context.estimatedMaturity); + } + if (direction === "ckb-to-ickb" && amount > context.ckbAvailable) { + return conversionFailure("insufficient-ckb", context.estimatedMaturity); + } + if (direction === "ickb-to-ckb" && amount > context.ickbAvailable) { + return conversionFailure("insufficient-ickb", context.estimatedMaturity); + } + + const baseTx = ccc.Transaction.from(txLike); + if (amount === 0n) { + return this.buildCollectOnlyConversion(baseTx, client, options); + } + return direction === "ckb-to-ickb" + ? this.buildCkbToIckbConversion(baseTx, client, options) + : this.buildIckbToCkbConversion(baseTx, client, options); + } + + private async buildCollectOnlyConversion( + baseTx: ccc.Transaction, + client: ccc.Client, + options: ConversionTransactionOptions, + ): Promise { + const { context } = options; + const tx = await this.buildBaseTransaction( + baseTx, + client, + baseTransactionOptions(context), + ); + if (!hasTransactionActivity(tx)) { + return conversionFailure(NOTHING_TO_DO_REASON, context.estimatedMaturity); + } + return { + ok: true, + tx, + estimatedMaturity: context.estimatedMaturity, + conversion: { kind: "collect-only" }, + }; + } + + private async buildCkbToIckbConversion( + baseTx: ccc.Transaction, + client: ccc.Client, + options: ConversionTransactionOptions, + ): Promise { + const { context, lock } = options; + const { ickbLogic } = sdkManagers(this); + const planResult = ckbToIckbConversionPlans(options); + const lastFailure = planResult.lastFailure ?? NOTHING_TO_DO_REASON; + let lastError: unknown; + for (const { + depositCapacity, + depositCount, + estimatedMaturity, + order, + } of planResult.plans) { + const outputLimitError = plannedDaoOutputLimitError( + baseTx, + (depositCount > 0 ? depositCount + 1 : 0) + orderOutputCount(order), + depositCount > 0 || context.readyWithdrawals.length > 0, + ); + if (outputLimitError !== undefined) { + lastError ??= outputLimitError; + continue; + } + try { + let tx = await this.buildBaseTransaction( + baseTx.clone(), + client, + baseTransactionOptions(context), + ); + if (depositCount > 0) { + tx = await ickbLogic.deposit(tx, depositCount, depositCapacity, lock, client); + } + if (order !== undefined) { + tx = await this.request(tx, lock, order.estimate.info, order.amounts); + } + return { + ok: true, + tx, + estimatedMaturity, + conversion: { kind: conversionKind(depositCount > 0, order !== undefined) }, + }; + } catch (error) { + if (!isRetryableConversionBuildError(error)) { + throw errorOf(error); + } + lastError ??= error; + } + } + if (lastError !== undefined) { + throw errorOf(lastError); + } + return conversionFailure(lastFailure, context.estimatedMaturity); + } + + private async buildIckbToCkbConversion( + baseTx: ccc.Transaction, + client: ccc.Client, + options: ConversionTransactionOptions, + ): Promise { + const { context, lock } = options; + const poolDeposits = + context.system.poolDeposits ?? + (await this.getPoolDeposits(client, context.system.tip)); + const planResult = ickbToCkbConversionPlans(options, poolDeposits); + const lastFailure = planResult.lastFailure ?? NOTHING_TO_DO_REASON; + let lastError: unknown; + for (const plan of planResult.plans) { + const { estimatedMaturity, order, requiredLiveDeposits, selectedDeposits } = plan; + const outputLimitError = plannedDaoOutputLimitError( + baseTx, + selectedDeposits.length * 2 + orderOutputCount(order), + selectedDeposits.length > 0 || context.readyWithdrawals.length > 0, + ); + if (outputLimitError !== undefined) { + lastError ??= outputLimitError; + continue; + } + try { + let tx = await this.buildBaseTransaction( + baseTx.clone(), + client, + baseTransactionOptions(context, { + deposits: selectedDeposits, + requiredLiveDeposits, + lock, + }), + ); + if (order !== undefined) { + tx = await this.request(tx, lock, order.estimate.info, order.amounts); + } + return { + ok: true, + tx, + estimatedMaturity, + conversion: { + kind: conversionKind(selectedDeposits.length > 0, order !== undefined), + }, + ...(order?.conversionNotice === undefined + ? {} + : { conversionNotice: order.conversionNotice }), + }; + } catch (error) { + if (!isRetryableConversionBuildError(error)) { + throw errorOf(error); + } + lastError ??= error; + } + } + if (lastError !== undefined) { + throw errorOf(lastError); + } + return conversionFailure(lastFailure, context.estimatedMaturity); + } +} diff --git a/packages/sdk/src/client/sdk_error.ts b/packages/sdk/src/client/sdk_error.ts new file mode 100644 index 0000000..8a0d918 --- /dev/null +++ b/packages/sdk/src/client/sdk_error.ts @@ -0,0 +1,42 @@ +export function errorOf(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + const message = errorMessage(error); + return new Error(message, { cause: error }); +} + +function errorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) { + return error.message; + } + + try { + return JSON.stringify(error, stringifyErrorValue); + } catch { + return String(error); + } +} + +function stringifyErrorValue( + this: Record | unknown[], + key: string, + value: unknown, +): unknown { + const original = Array.isArray(this) ? this[Number(key)] : this[key]; + if (original instanceof Date) { + return Number.isNaN(original.getTime()) ? null : original.toISOString(); + } + + return typeof value === "bigint" ? value.toString() : value; +} diff --git a/packages/sdk/src/client/sdk_l1_class.ts b/packages/sdk/src/client/sdk_l1_class.ts new file mode 100644 index 0000000..adf8f0f --- /dev/null +++ b/packages/sdk/src/client/sdk_l1_class.ts @@ -0,0 +1,298 @@ +import { ccc } from "@ckb-ccc/core"; +import { ickbExchangeRatio } from "@ickb/core"; +import { Info, Ratio, type OrderCell, type OrderGroup } from "@ickb/order"; +import { + collect, + collectPagedScan, + defaultCellPageSize, + isPlainCapacityCell, + unique, +} from "@ickb/utils"; +import { + addBotCkb, + botWithdrawalCkb, + cumulativeCkbMaturing, + mergeBotCkb, + poolDepositCkb, + poolDepositsKey, + positiveMapValueSum, + sortDepositsByMaturity, +} from "../conversion/sdk_value_helpers.ts"; +import { orderGroupWithMaturity } from "../estimate/sdk_maturity_order_group.ts"; +import { IckbSdkConversion } from "./sdk_conversion_class.ts"; +import { sdkManagers } from "./sdk_state_store.ts"; +import type { + AccountState, + CkbCumulative, + GetL1StateOptions, + GetPoolDepositsOptions, + MaturingCkb, + PoolDepositState, + SystemState, +} from "./sdk_types.ts"; + +/** + * SDK layer that scans public and account L1 state. + * + * @public + */ +export class IckbSdkL1 extends IckbSdkConversion { + /** Scans public iCKB pool deposits and evaluates readiness against the sampled tip. */ + public async getPoolDeposits( + client: ccc.Client, + tip: ccc.ClientBlockHeader, + options?: GetPoolDepositsOptions, + ): Promise { + const { ickbLogic } = sdkManagers(this); + const cellPageSize = options?.cellPageSize ?? defaultCellPageSize; + const deposits = await collect( + ickbLogic.findDeposits(client, { + onChain: true, + tip, + pageSize: cellPageSize, + ...(options?.minLockUp === undefined ? {} : { minLockUp: options.minLockUp }), + ...(options?.maxLockUp === undefined ? {} : { maxLockUp: options.maxLockUp }), + }), + ); + const readyDeposits = sortDepositsByMaturity( + deposits.filter((deposit) => deposit.isReady), + tip, + ); + + return { deposits, readyDeposits, id: poolDepositsKey(deposits, tip) }; + } + + /** + * Scans account cells, receipts, withdrawal groups, and native iCKB xUDT cells. + */ + public async getAccountState( + client: ccc.Client, + locks: ccc.Script[], + tip: ccc.ClientBlockHeader, + options?: { cellPageSize?: number }, + ): Promise { + const { ickbLogic, ownedOwner, ickbUdt } = sdkManagers(this); + const cellPageSize = options?.cellPageSize ?? defaultCellPageSize; + const [cells, receipts, withdrawalGroups] = await Promise.all([ + this.findAccountCells(client, locks, { pageSize: cellPageSize }), + collect( + ickbLogic.findReceipts(client, locks, { onChain: true, pageSize: cellPageSize }), + ), + collect( + ownedOwner.findWithdrawalGroups(client, locks, { + onChain: true, + tip, + pageSize: cellPageSize, + }), + ), + ]); + const nativeUdtCells = cells.filter((cell) => ickbUdt.isUdt(cell)); + const nativeUdt = nativeUdtCells.reduce( + (acc, cell) => ({ + capacity: acc.capacity + cell.cellOutput.capacity, + balance: acc.balance + ccc.udtBalanceFrom(cell.outputData), + }), + { capacity: 0n, balance: 0n }, + ); + + return { + capacityCells: cells.filter(isPlainCapacityCell), + nativeUdtCells, + nativeUdtCapacity: nativeUdt.capacity, + nativeUdtBalance: nativeUdt.balance, + receipts, + withdrawalGroups, + }; + } + + /** + * Reads system and account state using one sampled L1 system state. + */ + public async getL1AccountState( + client: ccc.Client, + locks: ccc.Script[], + options?: GetL1StateOptions, + ): Promise<{ + system: SystemState; + user: { orders: OrderGroup[] }; + account: AccountState; + }> { + const { system, user } = await this.getL1State(client, locks, options); + const account = await this.getAccountState(client, locks, system.tip, { + ...(options?.cellPageSize === undefined + ? {} + : { cellPageSize: options.cellPageSize }), + }); + + return { system, user, account }; + } + + /** + * Samples L1 system state and partitions user-owned orders from the public order pool. + */ + public async getL1State( + client: ccc.Client, + locks: ccc.Script[], + options?: GetL1StateOptions, + ): Promise<{ system: SystemState; user: { orders: OrderGroup[] } }> { + const { order } = sdkManagers(this); + const tip = await client.getTipHeader(); + const exchangeRatio = Ratio.from(ickbExchangeRatio(tip)); + const cellPageSize = options?.cellPageSize ?? defaultCellPageSize; + const [poolDeposits, orders, feeRate] = await Promise.all([ + this.getPoolDeposits(client, tip, { ...options?.poolDeposits, cellPageSize }), + collect(order.findOrders(client, { onChain: true, pageSize: cellPageSize })), + client.getFeeRate(), + ]); + const { ckbAvailable, ckbMaturing } = await this.getCkb(client, tip, poolDeposits, { + cellPageSize, + }); + const { systemOrders, userOrders } = partitionOrders(orders, locks, exchangeRatio); + const system = { + feeRate, + tip, + exchangeRatio, + orderPool: systemOrders, + ckbAvailable, + ckbMaturing, + poolDeposits, + }; + return { + system, + user: { orders: userOrders.map((group) => orderGroupWithMaturity(group, system)) }, + }; + } + + private async getCkb( + client: ccc.Client, + tip: ccc.ClientBlockHeader, + poolDeposits: PoolDepositState, + options: { cellPageSize: number }, + ): Promise<{ ckbAvailable: ccc.FixedPoint; ckbMaturing: CkbCumulative[] }> { + const botCkb = await this.getBotCkbBalances(client, { + cellPageSize: options.cellPageSize, + tip, + }); + const withdrawalCkb = await this.getBotWithdrawalCkb(client, tip, { + cellPageSize: options.cellPageSize, + }); + const poolCkb = poolDepositCkb(poolDeposits, tip); + + return { + ckbAvailable: + positiveMapValueSum(mergeBotCkb(botCkb, withdrawalCkb.ready)) + poolCkb.ready, + ckbMaturing: cumulativeCkbMaturing([ + ...withdrawalCkb.maturing, + ...poolCkb.maturing, + ]), + }; + } + + private async getBotCkbBalances( + client: ccc.Client, + options: { cellPageSize: number; tip: ccc.ClientBlockHeader }, + ): Promise> { + const { bots } = sdkManagers(this); + const bot2Ckb = new Map(); + for (const lock of unique(bots)) { + const cells = await this.findBotCapacityCells(client, lock, options); + for (const cell of cells) { + addBotCkb(bot2Ckb, lock.toHex(), cell.cellOutput.capacity); + } + } + return bot2Ckb; + } + + private async findBotCapacityCells( + client: ccc.Client, + lock: ccc.Script, + options: { cellPageSize: number }, + ): Promise { + const cells = await collectPagedScan( + (pageSize) => + client.findCellsOnChain( + { + script: lock, + scriptType: "lock", + filter: { scriptLenRange: [0n, 1n], outputDataLenRange: [0n, 1n] }, + scriptSearchMode: "exact", + withData: true, + }, + "asc", + pageSize, + ), + { pageSize: options.cellPageSize }, + ); + return cells.filter(isPlainCapacityCell); + } + + private async getBotWithdrawalCkb( + client: ccc.Client, + tip: ccc.ClientBlockHeader, + options: { cellPageSize: number }, + ): Promise<{ ready: Map; maturing: MaturingCkb[] }> { + const { bots, ownedOwner } = sdkManagers(this); + const withdrawals = await collect( + ownedOwner.findWithdrawalGroups(client, bots, { + onChain: true, + tip, + pageSize: options.cellPageSize, + }), + ); + return botWithdrawalCkb(withdrawals, tip); + } + + private async findAccountCells( + client: ccc.Client, + locks: ccc.Script[], + options: { pageSize: number }, + ): Promise { + const cells: ccc.Cell[] = []; + const { pageSize } = options; + for (const lock of unique(locks)) { + cells.push(...(await accountCellsForLock(client, lock, pageSize))); + } + return cells; + } +} + +function partitionOrders( + orders: readonly OrderGroup[], + locks: readonly ccc.Script[], + exchangeRatio: Ratio, +): { systemOrders: OrderCell[]; userOrders: OrderGroup[] } { + const midInfo = new Info(exchangeRatio, exchangeRatio, 1); + const userOrders: OrderGroup[] = []; + const systemOrders: OrderCell[] = []; + for (const group of orders) { + if (group.isOwner(...locks)) { + userOrders.push(group); + continue; + } + const { order } = group; + const info = order.data.info; + if ( + (order.isCkb2UdtMatchable() && info.ckb2UdtCompare(midInfo) < 0) || + (order.isUdt2CkbMatchable() && info.udt2CkbCompare(midInfo) < 0) + ) { + systemOrders.push(order); + } + } + return { systemOrders, userOrders }; +} + +async function accountCellsForLock( + client: ccc.Client, + lock: ccc.Script, + pageSize: number, +): Promise { + return collectPagedScan( + (requestPageSize) => + client.findCellsOnChain( + { script: lock, scriptType: "lock", scriptSearchMode: "exact", withData: true }, + "asc", + requestPageSize, + ), + { pageSize }, + ); +} diff --git a/packages/sdk/src/client/sdk_state_store.ts b/packages/sdk/src/client/sdk_state_store.ts new file mode 100644 index 0000000..c109ee5 --- /dev/null +++ b/packages/sdk/src/client/sdk_state_store.ts @@ -0,0 +1,15 @@ +import type { SdkManagers } from "./sdk_types.ts"; + +const sdkManagersByInstance = new WeakMap(); + +export function setSdkManagers(sdk: object, managers: SdkManagers): void { + sdkManagersByInstance.set(sdk, managers); +} + +export function sdkManagers(sdk: object): SdkManagers { + const managers = sdkManagersByInstance.get(sdk); + if (managers === undefined) { + throw new Error("SDK managers not initialized"); + } + return managers; +} diff --git a/packages/sdk/src/client/sdk_types.ts b/packages/sdk/src/client/sdk_types.ts new file mode 100644 index 0000000..33baf7d --- /dev/null +++ b/packages/sdk/src/client/sdk_types.ts @@ -0,0 +1,420 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { + IckbDepositCell, + IckbUdt, + LogicManager, + OwnedOwnerManager, + ReceiptCell, + WithdrawalGroup, +} from "@ickb/core"; +import type { Info, OrderCell, OrderGroup, OrderManager, Ratio } from "@ickb/order"; +import type { ValueComponents } from "@ickb/utils"; + +export const MAX_DIRECT_DEPOSITS = 60; +export const MAX_WITHDRAWAL_REQUESTS = 30; +export const ORDER_MINT_OUTPUTS = 2; +export const CONVERSION_MATURITY_BUCKET_MS = 60n * 60n * 1000n; +export const NOTHING_TO_DO_REASON: ConversionTransactionFailureReason = "nothing-to-do"; + +/** + * Direction requested by a conversion transaction. + * + * @public + */ +export type ConversionDirection = "ckb-to-ickb" | "ickb-to-ckb"; + +/** + * Public pool deposit scan used by conversion planning. + * + * @public + */ +export interface PoolDepositState { + /** All scanned iCKB pool deposits, with readiness evaluated against the sampled tip. */ + deposits: IckbDepositCell[]; + + /** Ready deposits sorted for withdrawal planning. */ + readyDeposits: IckbDepositCell[]; + + /** Opaque snapshot identity suitable for preview and cache keys. */ + id: string; +} + +/** + * Optional DAO readiness window for pool deposit scans. + * + * @public + */ +export interface PoolDepositRangeOptions { + /** Optional lower bound for deposit renewal readiness. */ + minLockUp?: ccc.Epoch; + + /** Optional upper bound for deposit renewal readiness. */ + maxLockUp?: ccc.Epoch; +} + +/** + * Options for scanning public pool deposits. + * + * @public + */ +export interface GetPoolDepositsOptions extends PoolDepositRangeOptions { + /** CCC cell pagination size. This is not a result cap. */ + cellPageSize?: number; +} + +/** + * Snapshot used to plan one wallet conversion transaction. + * + * @public + */ +export interface ConversionTransactionContext { + /** Public system state sampled for conversion planning. */ + system: SystemState; + /** User receipt cells available for deposit completion. */ + receipts: ReceiptCell[]; + /** User withdrawal groups ready to complete. */ + readyWithdrawals: WithdrawalGroup[]; + /** Order groups available for collection or budgeting as account value. */ + availableOrders: OrderGroup[]; + /** Projected CKB available to the wallet after pending state is considered. */ + ckbAvailable: bigint; + /** Projected iCKB available to the wallet after pending state is considered. */ + ickbAvailable: bigint; + /** Best available maturity estimate for the requested conversion context. */ + estimatedMaturity: bigint; +} + +/** + * Inputs and policy limits for building one conversion transaction. + * + * @public + */ +export interface ConversionTransactionOptions { + /** Conversion direction to build. */ + direction: ConversionDirection; + + /** Requested input amount in the source asset. */ + amount: bigint; + + /** User lock for newly created user-owned outputs. */ + lock: ccc.Script; + + /** State snapshot used to plan this conversion. */ + context: ConversionTransactionContext; + + /** Optional per-transaction planning limits. */ + limits?: { + /** Maximum direct DAO deposits to create or request in one transaction. */ + maxDirectDeposits?: number; + + /** Maximum withdrawal requests to create in one transaction. */ + maxWithdrawalRequests?: number; + }; +} + +/** + * Reason a conversion transaction could not be built without throwing. + * + * @public + */ +export type ConversionTransactionFailureReason = + | "amount-negative" + | "insufficient-ckb" + | "insufficient-ickb" + | "amount-too-small" + | "not-enough-ready-deposits" + | "nothing-to-do"; + +/** + * Non-fatal conversion notice for callers to surface in UI or logs. + * + * @public + */ +export interface ConversionNotice { + /** Notice category. */ + kind: "dust-ickb-to-ckb" | "maturity-unavailable"; + + /** iCKB input amount that triggered the notice. */ + inputIckb: bigint; + + /** CKB output estimate for the noticed path. */ + outputCkb: bigint; + + /** CKB incentive associated with the noticed path. */ + incentiveCkb: bigint; + + /** True when maturity could not be estimated from available state. */ + maturityEstimateUnavailable: boolean; +} + +/** + * High-level conversion composition used by a built transaction. + * + * @public + */ +export interface ConversionMetadata { + /** Composition category selected for the built conversion. */ + kind: "direct" | "order" | "direct-plus-order" | "collect-only"; +} + +/** + * Result of attempting to build a conversion transaction. + * + * @public + */ +export type ConversionTransactionResult = + | { + ok: true; + /** Partial transaction. Callers still own iCKB completion, fee completion, signing, and send. */ + tx: ccc.Transaction; + /** Estimated maturity timestamp for the conversion result. */ + estimatedMaturity: bigint; + /** Composition of the selected conversion path. */ + conversion: ConversionMetadata; + /** Optional notice about the selected path. */ + conversionNotice?: ConversionNotice; + } + | { + ok: false; + /** Machine-readable failure reason. */ + reason: ConversionTransactionFailureReason; + /** Best available maturity estimate from the input context. */ + estimatedMaturity: bigint; + }; + +/** + * Options for completing a partial iCKB transaction before signing and sending. + * + * @public + */ +export interface CompleteIckbTransactionOptions { + /** Signer used for iCKB input completion and fee completion. */ + signer: ccc.Signer; + + /** Client used for final transaction safety checks. */ + client: ccc.Client; + + /** Fee rate passed to CCC fee completion. */ + feeRate: ccc.Num; +} + +/** + * Options for scanning the L1 state snapshot. + * + * @public + */ +export interface GetL1StateOptions { + /** CCC cell pagination size for each scan. This is not a result cap. */ + cellPageSize?: number; + + /** Optional readiness window for public pool deposit scans. */ + poolDeposits?: PoolDepositRangeOptions; +} + +/** + * Estimate for the order leg of an iCKB-to-CKB conversion. + * + * @public + */ +export interface IckbToCkbOrderEstimate { + /** Order conversion estimate for the market leg. */ + estimate: ConversionOrderEstimate; + /** Estimated maturity for the output CKB, or `undefined` when unavailable. */ + maturity: bigint | undefined; + /** Optional non-fatal notice associated with the estimate. */ + notice?: ConversionNotice; +} + +/** + * Quote details for one order-based conversion path. + * + * @public + */ +export interface ConversionOrderEstimate { + /** Output amount after applying the order ratio and fee. */ + convertedAmount: ccc.FixedPoint; + /** CKB fee component embedded in the order conversion. */ + ckbFee: ccc.FixedPoint; + /** Order info that should be encoded into a created order. */ + info: Info; + /** Estimated maturity timestamp, or `undefined` when it cannot be estimated. */ + maturity: ccc.Num | undefined; +} + +/** + * Input accepted by maturity estimation, either a live order cell or raw order data plus values. + * + * @public + */ +export type MaturityOrderInput = + | OrderCell + | { + info: Info; + amounts: ValueComponents; + }; + +/** + * Raw wallet-owned cells and grouped iCKB state sampled from L1. + * + * @public + */ +export interface AccountState { + /** Plain capacity cells owned by the account locks. */ + capacityCells: ccc.Cell[]; + /** Native iCKB xUDT cells owned by the account locks. */ + nativeUdtCells: ccc.Cell[]; + /** Total capacity in native iCKB cells. */ + nativeUdtCapacity: bigint; + /** Total iCKB balance in native iCKB cells. */ + nativeUdtBalance: bigint; + /** Receipt cells owned by the account. */ + receipts: ReceiptCell[]; + /** Withdrawal groups owned by the account. */ + withdrawalGroups: WithdrawalGroup[]; +} + +/** + * Account balances split into available, pending, and order/withdrawal buckets. + * + * @public + */ +export interface AccountAvailabilityProjection { + /** Native CKB directly controlled by the account. */ + ckbNative: bigint; + /** Native iCKB directly controlled by the account. */ + ickbNative: bigint; + /** CKB available for new conversion inputs. */ + ckbAvailable: bigint; + /** iCKB available for new conversion inputs. */ + ickbAvailable: bigint; + /** CKB pending in withdrawals or orders. */ + ckbPending: bigint; + /** iCKB pending in withdrawals or orders. */ + ickbPending: bigint; + /** Total CKB balance including pending positions. */ + ckbBalance: bigint; + /** Total iCKB balance including pending positions. */ + ickbBalance: bigint; + /** Withdrawal groups ready to complete. */ + readyWithdrawals: WithdrawalGroup[]; + /** Withdrawal groups still waiting for DAO maturity. */ + pendingWithdrawals: WithdrawalGroup[]; + /** Order groups available for collection or budgeting as account value. */ + availableOrders: OrderGroup[]; + /** Order groups that are owned but unavailable or unresolved. */ + pendingOrders: OrderGroup[]; +} + +/** + * Combined projection and transaction-planning context for one account snapshot. + * + * @public + */ +export interface ConversionTransactionContextProjection { + /** User-facing availability projection. */ + projection: AccountAvailabilityProjection; + /** Builder-facing transaction context derived from the same snapshot. */ + context: ConversionTransactionContext; +} + +/** + * Public system snapshot used for quotes, maturity, and conversion planning. + * + * @public + */ +export interface SystemState { + /** The fee rate for transactions. */ + feeRate: ccc.Num; + /** Sampled tip used for this L1 scan. It may be stale after scans complete. */ + tip: ccc.ClientBlockHeader; + /** The exchange ratio between CKB and UDT. */ + exchangeRatio: Ratio; + /** The order pool containing order cells matching system criteria. */ + orderPool: OrderCell[]; + /** The total available CKB (as FixedPoint). */ + ckbAvailable: ccc.FixedPoint; + /** Array of CKB maturing entries with cumulative amounts and maturity timestamps. */ + ckbMaturing: CkbCumulative[]; + /** Public pool deposit scan evaluated against this tip for conversion planning. */ + poolDeposits?: PoolDepositState; +} + +/** + * Cumulative CKB maturity bucket used for maturity estimation. + * + * @public + */ +export interface CkbCumulative { + /** The cumulative CKB value (as FixedPoint) up to this maturity. */ + ckbCumulative: ccc.FixedPoint; + /** The maturity timestamp (as ccc.Num). */ + maturity: ccc.Num; +} + +export interface SdkManagers { + ickbUdt: IckbUdt; + ownedOwner: OwnedOwnerManager; + ickbLogic: LogicManager; + order: OrderManager; + bots: ccc.Script[]; +} + +/** + * Optional components to collect into a base iCKB transaction. + * + * @public + */ +export interface BuildBaseTransactionOptions { + /** DAO withdrawal request inputs/outputs to add before other collect steps. */ + withdrawalRequest?: { + /** Deposits to spend into owned withdrawal requests. */ + deposits: IckbDepositCell[]; + + /** Live pool deposits to add as cell deps without spending them. */ + requiredLiveDeposits?: IckbDepositCell[]; + + /** User lock for owner marker outputs. */ + lock: ccc.Script; + }; + + /** Fulfilled or selected order groups to melt. */ + orders?: OrderGroup[]; + + /** Receipt cells to spend for deposit completion. */ + receipts?: ReceiptCell[]; + + /** Ready owned withdrawal groups to complete. */ + readyWithdrawals?: WithdrawalGroup[]; +} + +export interface ConversionOrder { + amounts: ValueComponents; + estimate: ConversionOrderEstimate; + conversionNotice?: ConversionNotice; +} + +export interface CkbToIckbConversionPlan { + depositCapacity: bigint; + depositCount: number; + estimatedMaturity: bigint; + order?: ConversionOrder; +} + +export interface IckbToCkbConversionPlan { + directSurplusCkb: bigint; + directUdtValue: bigint; + estimatedMaturity: bigint; + order?: ConversionOrder; + requiredLiveDeposits: IckbDepositCell[]; + selectedDeposits: IckbDepositCell[]; +} + +export interface MaturingCkb { + ckbValue: ccc.FixedPoint; + maturity: ccc.Num; +} + +export interface CkbProjection { + ready: ccc.FixedPoint; + maturing: MaturingCkb[]; +} diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 1c467c5..f9616ae 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -4,146 +4,44 @@ import { DaoManager } from "@ickb/dao"; import { OrderManager } from "@ickb/order"; import { unique, type ScriptDeps } from "@ickb/utils"; +/** + * Script deps plus the direct code out point for scripts used as direct code deps. + * + * @public + */ export interface CodeScriptDeps extends ScriptDeps { + /** Direct code out point for deployments that reference code by cell dep. */ codeOutPoint: ccc.OutPointLike; } +/** + * Deployment scripts and cell deps used to construct SDK managers. + * + * @public + */ export interface IckbDeploymentConfig { + /** xUDT type script deps and direct code out point. */ udt: CodeScriptDeps; + /** iCKB logic script deps and direct code out point. */ logic: CodeScriptDeps; + /** Owned-owner lock script deps. */ ownedOwner: ScriptDeps; + /** Order lock script deps. */ order: ScriptDeps; + /** Nervos DAO type script deps. */ dao: ScriptDeps; } -/** - * Retrieves the configuration for the given deployment environment. - * - * Accepts either a string identifier ("mainnet" or "testnet") or a custom configuration - * object containing script dependencies for a devnet. - * - * It sets up various managers (UDT, Logic, OwnedOwner, Order, Dao) and also - * aggregates a unique list of known bot scripts. - * - * @param d - Either a network identifier ("mainnet"/"testnet") or an object containing - * explicit script dependencies for devnet. - * @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 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" | IckbDeploymentConfig, - bots: ccc.ScriptLike[] = [], -): { - managers: { - dao: DaoManager; - ickbUdt: IckbUdt; - logic: LogicManager; - ownedOwner: OwnedOwnerManager; - order: OrderManager; - }; - bots: ccc.Script[]; -} { - // If deps is provided as a network string, use the pre-defined constants. - if (d === "mainnet" || d === "testnet") { - bots = bots.concat( - d === "mainnet" ? MAINNET_KNOWN_BOTS : TESTNET_KNOWN_BOTS, - ); - const depGroup = d === "mainnet" ? MAINNET_DEP_GROUP : TESTNET_DEP_GROUP; - d = { - 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), - }; - } - - const dao = new DaoManager(d.dao.script, d.dao.cellDeps); - - const ickbUdt = new IckbUdt( - definedCodeOutPoint(d.udt.codeOutPoint, "xUDT"), - IckbUdt.typeScriptFrom( - ccc.Script.from(d.udt.script), - ccc.Script.from(d.logic.script), - ), - definedCodeOutPoint(d.logic.codeOutPoint, "Logic"), - d.logic.script, - dao, - ); - const logic = new LogicManager(d.logic.script, d.logic.cellDeps, dao); - const ownedOwner = new OwnedOwnerManager( - d.ownedOwner.script, - d.ownedOwner.cellDeps, - dao, - ); - const order = new OrderManager(d.order.script, d.order.cellDeps, ickbUdt.script); - - return { - managers: { - dao, - ickbUdt, - logic, - ownedOwner, - order, - }, - bots: [...unique(bots.map((b) => ccc.Script.from(b)))], - }; +interface ResolvedDeploymentConfig { + deployment: IckbDeploymentConfig; + bots: ccc.ScriptLike[]; } /** - * Wraps a script-like object into a ScriptDeps structure. - * - * @param script - The script or script-like object. - * @param cellDeps - Additional cell dependencies that the script may require. - * @returns An object containing the script and its associated cell dependencies. - */ -function from(script: ccc.ScriptLike, ...cellDeps: ccc.CellDep[]): ScriptDeps { - return { - script: ccc.Script.from(script), - cellDeps, - }; -} - -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. + * Nervos DAO type script information. */ const DAO = { - codeHash: - "0x82d76d1b75fe2fd9a27dfbaa65a039221a380d76c926f378d3f81cf3e7e13f2e", + codeHash: "0x82d76d1b75fe2fd9a27dfbaa65a039221a380d76c926f378d3f81cf3e7e13f2e", hashType: "type", args: "0x", }; @@ -154,8 +52,7 @@ const DAO = { * IckbUdt.typeScriptFrom() from ICKB_LOGIC. */ const UDT = { - codeHash: - "0x50bd8d6680b8b9cf98b73f3c08faf8b2a21914311954118ad6609be6e78a1b95", + codeHash: "0x50bd8d6680b8b9cf98b73f3c08faf8b2a21914311954118ad6609be6e78a1b95", hashType: "data1", args: "0x", }; @@ -164,8 +61,7 @@ const UDT = { * Logic script information for the iCKB logic contract. */ const ICKB_LOGIC = { - codeHash: - "0x2a8100ab5990fa055ab1b50891702e1e895c7bd1df6322cd725c1a6115873bd3", + codeHash: "0x2a8100ab5990fa055ab1b50891702e1e895c7bd1df6322cd725c1a6115873bd3", hashType: "data1", args: "0x", }; @@ -174,8 +70,7 @@ const ICKB_LOGIC = { * OwnedOwner lock script information. */ const OWNED_OWNER = { - codeHash: - "0xacc79e07d107831feef4c70c9e683dac5644d5993b9cb106dca6e74baa381bd0", + codeHash: "0xacc79e07d107831feef4c70c9e683dac5644d5993b9cb106dca6e74baa381bd0", hashType: "data1", args: "0x", }; @@ -184,8 +79,7 @@ const OWNED_OWNER = { * Order lock script information. */ const ORDER = { - codeHash: - "0x49dfb6afee5cc8ac4225aeea8cb8928b150caf3cd92fea33750683c74b13254a", + codeHash: "0x49dfb6afee5cc8ac4225aeea8cb8928b150caf3cd92fea33750683c74b13254a", hashType: "data1", args: "0x", }; @@ -194,8 +88,7 @@ const ORDER = { * Mainnet xUDT code cell OutPoint. */ const MAINNET_XUDT_CODE = { - txHash: - "0xc07844ce21b38e4b071dd0e1ee3b0e27afd8d7532491327f39b786343f558ab7", + txHash: "0xc07844ce21b38e4b071dd0e1ee3b0e27afd8d7532491327f39b786343f558ab7", index: "0x0", }; @@ -203,8 +96,7 @@ const MAINNET_XUDT_CODE = { * Mainnet iCKB Logic code cell OutPoint. */ const MAINNET_LOGIC_CODE = { - txHash: - "0xd7309191381f5a8a2904b8a79958a9be2752dbba6871fa193dab6aeb29dc8f44", + txHash: "0xd7309191381f5a8a2904b8a79958a9be2752dbba6871fa193dab6aeb29dc8f44", index: "0x0", }; @@ -212,8 +104,7 @@ const MAINNET_LOGIC_CODE = { * Testnet xUDT code cell OutPoint. */ const TESTNET_XUDT_CODE = { - txHash: - "0xbf6fb538763efec2a70a6a3dcb7242787087e1030c4e7d86585bc63a9d337f5f", + txHash: "0xbf6fb538763efec2a70a6a3dcb7242787087e1030c4e7d86585bc63a9d337f5f", index: "0x0", }; @@ -221,8 +112,7 @@ const TESTNET_XUDT_CODE = { * Testnet iCKB Logic code cell OutPoint. */ const TESTNET_LOGIC_CODE = { - txHash: - "0x9ac989b3355764f76cdce02c69dedb819fdfbcbda49a7db1a2c9facdfdb9a7fe", + txHash: "0x9ac989b3355764f76cdce02c69dedb819fdfbcbda49a7db1a2c9facdfdb9a7fe", index: "0x0", }; @@ -231,8 +121,7 @@ const TESTNET_LOGIC_CODE = { */ const MAINNET_DEP_GROUP = ccc.CellDep.from({ outPoint: { - txHash: - "0x621a6f38de3b9f453016780edac3b26bfcbfa3e2ecb47c2da275471a5d3ed165", + txHash: "0x621a6f38de3b9f453016780edac3b26bfcbfa3e2ecb47c2da275471a5d3ed165", index: "0x0", }, depType: "depGroup", @@ -243,8 +132,7 @@ const MAINNET_DEP_GROUP = ccc.CellDep.from({ */ const TESTNET_DEP_GROUP = ccc.CellDep.from({ outPoint: { - txHash: - "0xf7ece4fb33d8378344cab11fcd6a4c6f382fd4207ac921cf5821f30712dcd311", + txHash: "0xf7ece4fb33d8378344cab11fcd6a4c6f382fd4207ac921cf5821f30712dcd311", index: "0x0", }, depType: "depGroup", @@ -255,8 +143,7 @@ const TESTNET_DEP_GROUP = ccc.CellDep.from({ */ const MAINNET_KNOWN_BOTS = [ { - codeHash: - "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8", + codeHash: "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8", hashType: "type", args: "0xd096cb29e2f68a85a46bd6bf6cbee6327959ba64", }, @@ -267,9 +154,140 @@ const MAINNET_KNOWN_BOTS = [ */ const TESTNET_KNOWN_BOTS = [ { - codeHash: - "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8", + codeHash: "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8", hashType: "type", args: "0xb4380110f7679ac31cefe4925485645d82bf619f", }, ]; + +/** + * Retrieves the configuration for the given deployment environment. + * + * Accepts either a string identifier ("mainnet" or "testnet") or a custom + * deployment configuration object containing script dependencies. + * + * It sets up various managers (UDT, Logic, OwnedOwner, Order, Dao) and also + * aggregates a unique list of known bot scripts. + * + * @param d - Either a network identifier ("mainnet"/"testnet") or an explicit deployment config. + * @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 Builders still return partial transactions. `IckbSdk` owns the + * shared iCKB completion path as `sdk.completeTransaction(...)`, which callers + * should invoke explicitly before send. Custom `udt` and `logic` config entries + * must include `codeOutPoint` values because the SDK adds those code deps + * directly. + * + * @public + */ +export function getConfig( + d: "mainnet" | "testnet" | IckbDeploymentConfig, + bots: ccc.ScriptLike[] = [], +): { + managers: { + dao: DaoManager; + ickbUdt: IckbUdt; + logic: LogicManager; + ownedOwner: OwnedOwnerManager; + order: OrderManager; + }; + bots: ccc.Script[]; +} { + const { deployment, bots: knownBots } = resolveDeploymentConfig(d, bots); + + const dao = new DaoManager(deployment.dao.script, deployment.dao.cellDeps); + + const ickbUdt = new IckbUdt( + definedCodeOutPoint(deployment.udt.codeOutPoint, "xUDT"), + IckbUdt.typeScriptFrom( + ccc.Script.from(deployment.udt.script), + ccc.Script.from(deployment.logic.script), + ), + definedCodeOutPoint(deployment.logic.codeOutPoint, "Logic"), + deployment.logic.script, + dao, + ); + const logic = new LogicManager(deployment.logic.script, deployment.logic.cellDeps, dao); + const ownedOwner = new OwnedOwnerManager( + deployment.ownedOwner.script, + deployment.ownedOwner.cellDeps, + dao, + ); + const order = new OrderManager( + deployment.order.script, + deployment.order.cellDeps, + ickbUdt.script, + ); + + return { + managers: { + dao, + ickbUdt, + logic, + ownedOwner, + order, + }, + bots: [...unique(knownBots.map((b) => ccc.Script.from(b)))], + }; +} + +function resolveDeploymentConfig( + d: "mainnet" | "testnet" | IckbDeploymentConfig, + bots: ccc.ScriptLike[], +): ResolvedDeploymentConfig { + if (d !== "mainnet" && d !== "testnet") { + return { deployment: d, bots }; + } + + const depGroup = d === "mainnet" ? MAINNET_DEP_GROUP : TESTNET_DEP_GROUP; + const udtCode = d === "mainnet" ? MAINNET_XUDT_CODE : TESTNET_XUDT_CODE; + const logicCode = d === "mainnet" ? MAINNET_LOGIC_CODE : TESTNET_LOGIC_CODE; + const networkBots = d === "mainnet" ? MAINNET_KNOWN_BOTS : TESTNET_KNOWN_BOTS; + return { + deployment: { + udt: fromWithCode(UDT, udtCode, depGroup), + logic: fromWithCode(ICKB_LOGIC, logicCode, depGroup), + ownedOwner: from(OWNED_OWNER, depGroup), + order: from(ORDER, depGroup), + dao: from(DAO, depGroup), + }, + bots: bots.concat(networkBots), + }; +} + +/** + * Wraps a script-like object into a ScriptDeps structure. + * + * @param script - The script or script-like object. + * @param cellDeps - Additional cell dependencies that the script may require. + * @returns An object containing the script and its associated cell dependencies. + */ +function fromWithCode( + script: ccc.ScriptLike, + codeOutPoint: ccc.OutPointLike, + ...cellDeps: ccc.CellDep[] +): CodeScriptDeps { + return { + ...from(script, ...cellDeps), + codeOutPoint, + }; +} + +function from(script: ccc.ScriptLike, ...cellDeps: ccc.CellDep[]): ScriptDeps { + return { + script: ccc.Script.from(script), + cellDeps, + }; +} + +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); +} diff --git a/packages/sdk/src/conversion/sdk_conversion_common.ts b/packages/sdk/src/conversion/sdk_conversion_common.ts new file mode 100644 index 0000000..24eaa76 --- /dev/null +++ b/packages/sdk/src/conversion/sdk_conversion_common.ts @@ -0,0 +1,98 @@ +import { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell } from "@ickb/core"; +import { DAO_OUTPUT_LIMIT, DaoOutputLimitError } from "@ickb/dao"; +import { + NOTHING_TO_DO_REASON, + ORDER_MINT_OUTPUTS, + type BuildBaseTransactionOptions, + type ConversionMetadata, + type ConversionOrder, + type ConversionTransactionContext, + type ConversionTransactionFailureReason, + type ConversionTransactionResult, +} from "../client/sdk_types.ts"; + +export function conversionFailure( + reason: ConversionTransactionFailureReason, + estimatedMaturity: bigint, +): ConversionTransactionResult { + return { ok: false, reason, estimatedMaturity }; +} + +export function baseTransactionOptions( + context: ConversionTransactionContext, + withdrawalRequest?: { + deposits: IckbDepositCell[]; + requiredLiveDeposits: IckbDepositCell[]; + lock: ccc.Script; + }, +): BuildBaseTransactionOptions { + return { + ...(withdrawalRequest === undefined || withdrawalRequest.deposits.length === 0 + ? {} + : { + withdrawalRequest: { + deposits: withdrawalRequest.deposits, + ...(withdrawalRequest.requiredLiveDeposits.length > 0 + ? { requiredLiveDeposits: withdrawalRequest.requiredLiveDeposits } + : {}), + lock: withdrawalRequest.lock, + }, + }), + orders: context.availableOrders, + receipts: context.receipts, + readyWithdrawals: context.readyWithdrawals, + }; +} + +export function hasTransactionActivity(tx: ccc.Transaction): boolean { + return tx.inputs.length > 0 || tx.outputs.length > 0; +} + +export function isChangeCellCapacityError(error: unknown): boolean { + return error instanceof ccc.ErrorTransactionInsufficientCapacity && error.isForChange; +} + +export function isRetryableConversionBuildError(error: unknown): boolean { + return ( + error instanceof DaoOutputLimitError || + (error instanceof Error && error.name === "DaoOutputLimitError") + ); +} + +export function plannedDaoOutputLimitError( + tx: ccc.Transaction, + additionalOutputs: number, + hasDaoActivity: boolean, +): DaoOutputLimitError | undefined { + if (!hasDaoActivity) { + return undefined; + } + + const outputCount = tx.outputs.length + additionalOutputs; + return outputCount > DAO_OUTPUT_LIMIT + ? new DaoOutputLimitError(outputCount) + : undefined; +} + +export function orderOutputCount(order: ConversionOrder | undefined): number { + return order === undefined ? 0 : ORDER_MINT_OUTPUTS; +} + +export function conversionKind( + hasDirect: boolean, + hasOrder: boolean, +): ConversionMetadata["kind"] { + if (hasDirect && hasOrder) { + return "direct-plus-order"; + } + if (hasDirect) { + return "direct"; + } + if (hasOrder) { + return "order"; + } + return "collect-only"; +} + +export { NOTHING_TO_DO_REASON }; diff --git a/packages/sdk/src/conversion/sdk_conversion_plans.ts b/packages/sdk/src/conversion/sdk_conversion_plans.ts new file mode 100644 index 0000000..e985b76 --- /dev/null +++ b/packages/sdk/src/conversion/sdk_conversion_plans.ts @@ -0,0 +1,270 @@ +import { ICKB_DEPOSIT_CAP, convert, type IckbDepositCell } from "@ickb/core"; +import { compareBigInt } from "@ickb/utils"; +import { + MAX_DIRECT_DEPOSITS, + MAX_WITHDRAWAL_REQUESTS, + type CkbToIckbConversionPlan, + type ConversionOrder, + type ConversionTransactionFailureReason, + type ConversionTransactionOptions, + type IckbToCkbConversionPlan, + type PoolDepositState, +} from "../client/sdk_types.ts"; +import { + estimateConversionOrder, + estimateIckbToCkbOrder, + maxMaturity, +} from "../estimate/sdk_estimate.ts"; +import { + ringRequiredLiveDepositFor, + ringSurplusDepositFilter, + selectExactReadyWithdrawalDepositCandidates, +} from "../withdrawal/withdrawal_selection.ts"; +import { + directWithdrawalSurplus, + maturityBucket, + normalizeCountLimit, + sortDepositsByMaturity, + sumDirectWithdrawalSurplus, + sumUdtValue, +} from "./sdk_value_helpers.ts"; + +export function ckbToIckbConversionPlans(options: ConversionTransactionOptions): { + lastFailure: ConversionTransactionFailureReason | undefined; + plans: CkbToIckbConversionPlan[]; +} { + const { amount, context } = options; + const maxDirectDeposits = normalizeCountLimit( + options.limits?.maxDirectDeposits ?? MAX_DIRECT_DEPOSITS, + ); + const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, context.system.exchangeRatio); + const depositQuotient = depositCapacity === 0n ? 0n : amount / depositCapacity; + const maxDeposits = + depositQuotient > BigInt(maxDirectDeposits) + ? maxDirectDeposits + : Number(depositQuotient); + const plans: CkbToIckbConversionPlan[] = []; + let lastFailure: ConversionTransactionFailureReason | undefined; + + for (let depositCount = maxDeposits; depositCount >= 0; depositCount -= 1) { + const plan = ckbToIckbConversionPlan(options, depositCapacity, depositCount); + if (plan === undefined) { + lastFailure = "amount-too-small"; + continue; + } + plans.push(plan); + } + + return { lastFailure, plans }; +} + +export function ickbToCkbConversionPlans( + options: ConversionTransactionOptions, + poolDeposits: PoolDepositState, +): { + lastFailure: ConversionTransactionFailureReason | undefined; + plans: IckbToCkbConversionPlan[]; +} { + const { amount, context } = options; + const maxWithdrawalRequests = normalizeCountLimit( + options.limits?.maxWithdrawalRequests ?? MAX_WITHDRAWAL_REQUESTS, + ); + const readyDeposits = sortDepositsByMaturity( + poolDeposits.deposits.filter((deposit) => deposit.isReady), + context.system.tip, + ); + const ringSurplus = ringSurplusDepositFilter(poolDeposits.deposits); + const ringRequiredLiveDeposit = ringRequiredLiveDepositFor(poolDeposits.deposits); + const plans: IckbToCkbConversionPlan[] = []; + let lastFailure: ConversionTransactionFailureReason | undefined; + + for ( + let count = Math.min(readyDeposits.length, maxWithdrawalRequests); + count >= 0; + count -= 1 + ) { + const selections = selectionsForWithdrawalCount({ + options, + readyDeposits, + count, + amount, + canSelectDeposit: ringSurplus, + requiredLiveDepositFor: ringRequiredLiveDeposit, + }); + if (count > 0 && selections.length === 0) { + lastFailure = "not-enough-ready-deposits"; + continue; + } + for (const selection of selections) { + const plan = ickbToCkbConversionPlan( + options, + selection.deposits, + selection.requiredLiveDeposits, + ); + if (plan === undefined) { + lastFailure = "amount-too-small"; + continue; + } + plans.push(plan); + } + } + + return { lastFailure, plans: plans.toSorted(compareIckbToCkbPlans) }; +} + +function ckbToIckbConversionPlan( + options: ConversionTransactionOptions, + depositCapacity: bigint, + depositCount: number, +): CkbToIckbConversionPlan | undefined { + const { amount, context } = options; + const remainder = amount - depositCapacity * BigInt(depositCount); + let estimatedMaturity = context.estimatedMaturity; + let order: ConversionOrder | undefined; + + if (remainder > 0n) { + const amounts = { ckbValue: remainder, udtValue: 0n }; + const estimate = estimateConversionOrder(true, amounts, context.system, 1n, 100000n); + if (estimate?.maturity === undefined) { + return undefined; + } + estimatedMaturity = maxMaturity(estimatedMaturity, estimate.maturity); + order = { amounts, estimate }; + } + + return { + depositCapacity, + depositCount, + estimatedMaturity, + ...(order === undefined ? {} : { order }), + }; +} + +function ickbToCkbConversionPlan( + options: ConversionTransactionOptions, + selectedDeposits: IckbDepositCell[], + requiredLiveDeposits: IckbDepositCell[], +): IckbToCkbConversionPlan | undefined { + const { amount, context } = options; + let estimatedMaturity = context.estimatedMaturity; + let remainder = amount; + let directUdtValue = 0n; + let directSurplusCkb = 0n; + let order: ConversionOrder | undefined; + + if (selectedDeposits.length > 0) { + directUdtValue = sumUdtValue(selectedDeposits); + directSurplusCkb = sumDirectWithdrawalSurplus( + selectedDeposits, + context.system.exchangeRatio, + ); + remainder -= directUdtValue; + for (const deposit of selectedDeposits) { + estimatedMaturity = maxMaturity( + estimatedMaturity, + deposit.maturity.toUnix(context.system.tip), + ); + } + } + if (remainder > 0n) { + const remainderOrder = orderForIckbRemainder( + remainder, + context.system, + estimatedMaturity, + ); + if (remainderOrder === undefined) { + return undefined; + } + order = remainderOrder.order; + estimatedMaturity = remainderOrder.estimatedMaturity; + } + + return { + directSurplusCkb, + directUdtValue, + estimatedMaturity, + ...(order === undefined ? {} : { order }), + requiredLiveDeposits, + selectedDeposits, + }; +} + +function selectionsForWithdrawalCount({ + options, + readyDeposits, + count, + amount, + canSelectDeposit, + requiredLiveDepositFor, +}: { + options: ConversionTransactionOptions; + readyDeposits: IckbDepositCell[]; + count: number; + amount: bigint; + canSelectDeposit: (deposit: IckbDepositCell) => boolean; + requiredLiveDepositFor: (deposit: IckbDepositCell) => IckbDepositCell | undefined; +}): Array<{ deposits: IckbDepositCell[]; requiredLiveDeposits: IckbDepositCell[] }> { + return count === 0 + ? [{ deposits: [], requiredLiveDeposits: [] }] + : selectExactReadyWithdrawalDepositCandidates({ + readyDeposits, + tip: options.context.system.tip, + maxAmount: amount, + count, + canSelectDeposit, + requiredLiveDepositFor, + score: (deposit) => + directWithdrawalSurplus(deposit, options.context.system.exchangeRatio), + maturityBucket: (deposit) => + maturityBucket(deposit.maturity.toUnix(options.context.system.tip)), + }); +} + +function orderForIckbRemainder( + remainder: bigint, + system: ConversionTransactionOptions["context"]["system"], + estimatedMaturity: bigint, +): { order: ConversionOrder; estimatedMaturity: bigint } | undefined { + const amounts = { ckbValue: 0n, udtValue: remainder }; + const preview = estimateIckbToCkbOrder(amounts, system); + if (preview === undefined) { + return undefined; + } + const { estimate, maturity, notice } = preview; + const updatedMaturity = + maturity === undefined ? estimatedMaturity : maxMaturity(estimatedMaturity, maturity); + return { + order: { + amounts, + estimate, + ...(notice === undefined ? {} : { conversionNotice: notice }), + }, + estimatedMaturity: updatedMaturity, + }; +} + +function compareIckbToCkbPlans( + left: IckbToCkbConversionPlan, + right: IckbToCkbConversionPlan, +): number { + const maturityCompare = compareBigInt( + maturityBucket(left.estimatedMaturity), + maturityBucket(right.estimatedMaturity), + ); + if (maturityCompare !== 0) { + return maturityCompare; + } + const directPresenceCompare = + Number(right.selectedDeposits.length > 0) - Number(left.selectedDeposits.length > 0); + if (directPresenceCompare !== 0) { + return directPresenceCompare; + } + const surplusCompare = compareBigInt(right.directSurplusCkb, left.directSurplusCkb); + if (surplusCompare !== 0) { + return surplusCompare; + } + const directCompare = compareBigInt(right.directUdtValue, left.directUdtValue); + return directCompare !== 0 + ? directCompare + : right.selectedDeposits.length - left.selectedDeposits.length; +} diff --git a/packages/sdk/src/conversion/sdk_value_helpers.ts b/packages/sdk/src/conversion/sdk_value_helpers.ts new file mode 100644 index 0000000..b099240 --- /dev/null +++ b/packages/sdk/src/conversion/sdk_value_helpers.ts @@ -0,0 +1,160 @@ +import { ccc } from "@ckb-ccc/core"; +import { convert, type IckbDepositCell, type WithdrawalGroup } from "@ickb/core"; +import type { Ratio } from "@ickb/order"; +import { compareBigInt } from "@ickb/utils"; +import { + CONVERSION_MATURITY_BUCKET_MS, + type CkbCumulative, + type CkbProjection, + type MaturingCkb, + type PoolDepositState, +} from "../client/sdk_types.ts"; + +export function mergeBotCkb( + left: Map, + right: Map, +): Map { + const merged = new Map(left); + for (const [key, ckbValue] of right) { + addBotCkb(merged, key, ckbValue); + } + return merged; +} + +export function botWithdrawalCkb( + withdrawals: readonly WithdrawalGroup[], + tip: ccc.ClientBlockHeader, +): { ready: Map; maturing: MaturingCkb[] } { + const ready = new Map(); + const maturing: MaturingCkb[] = []; + for (const withdrawal of withdrawals) { + if (withdrawal.owned.isReady) { + addBotCkb( + ready, + withdrawal.owner.cell.cellOutput.lock.toHex(), + withdrawal.ckbValue, + ); + } else { + maturing.push({ + ckbValue: withdrawal.ckbValue, + maturity: withdrawal.owned.maturity.toUnix(tip), + }); + } + } + return { ready, maturing }; +} + +export function cumulativeCkbMaturing(maturing: readonly MaturingCkb[]): CkbCumulative[] { + let cumulative = 0n; + const cumulativeMaturing: CkbCumulative[] = []; + for (const { ckbValue, maturity } of sortMaturingCkb(maturing)) { + cumulative += ckbValue; + cumulativeMaturing.push({ ckbCumulative: cumulative, maturity }); + } + return cumulativeMaturing; +} + +export function sumDirectWithdrawalSurplus( + deposits: readonly IckbDepositCell[], + exchangeRatio: Ratio, +): bigint { + let total = 0n; + for (const deposit of deposits) { + total += directWithdrawalSurplus(deposit, exchangeRatio); + } + return total; +} + +export function poolDepositCkb( + poolDeposits: PoolDepositState, + tip: ccc.ClientBlockHeader, +): CkbProjection { + let ready = 0n; + const maturing: MaturingCkb[] = []; + for (const deposit of poolDeposits.deposits) { + if (deposit.isReady) { + ready += deposit.ckbValue; + } else { + maturing.push({ + ckbValue: deposit.ckbValue, + maturity: deposit.maturity.toUnix(tip), + }); + } + } + return { ready, maturing }; +} + +export function addBotCkb( + botCkb: Map, + key: string, + ckbValue: ccc.FixedPoint, +): void { + const reserved = -ccc.fixedPointFrom("2000"); + botCkb.set(key, (botCkb.get(key) ?? reserved) + ckbValue); +} + +export function positiveMapValueSum( + values: ReadonlyMap, +): ccc.FixedPoint { + let total = 0n; + for (const value of values.values()) { + if (value > 0n) { + total += value; + } + } + return total; +} + +export function directWithdrawalSurplus( + deposit: IckbDepositCell, + exchangeRatio: Ratio, +): bigint { + return deposit.ckbValue - convert(false, deposit.udtValue, exchangeRatio); +} + +export function poolDepositsKey( + deposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, +): string { + return deposits + .map((deposit) => + [ + deposit.cell.outPoint.toHex(), + deposit.isReady ? "ready" : "pending", + String(deposit.ckbValue), + String(deposit.udtValue), + String(deposit.maturity.toUnix(tip)), + ].join("@"), + ) + .toSorted((left, right) => left.localeCompare(right)) + .join(","); +} + +export function sortDepositsByMaturity( + deposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, +): IckbDepositCell[] { + return deposits.toSorted((left, right) => + compareBigInt(left.maturity.toUnix(tip), right.maturity.toUnix(tip)), + ); +} + +export function normalizeCountLimit(limit: number): number { + return Number.isSafeInteger(limit) && limit > 0 ? limit : 0; +} + +export function sumUdtValue(deposits: readonly IckbDepositCell[]): bigint { + let total = 0n; + for (const deposit of deposits) { + total += deposit.udtValue; + } + return total; +} + +export function maturityBucket(maturity: bigint): bigint { + return maturity / CONVERSION_MATURITY_BUCKET_MS; +} + +function sortMaturingCkb(maturing: readonly MaturingCkb[]): MaturingCkb[] { + return maturing.toSorted((left, right) => compareBigInt(left.maturity, right.maturity)); +} diff --git a/packages/sdk/src/estimate/sdk_estimate.ts b/packages/sdk/src/estimate/sdk_estimate.ts new file mode 100644 index 0000000..06587f4 --- /dev/null +++ b/packages/sdk/src/estimate/sdk_estimate.ts @@ -0,0 +1,151 @@ +import type { ccc } from "@ckb-ccc/core"; +import { OrderConversionRepresentabilityError } from "@ickb/order"; +import type { ValueComponents } from "@ickb/utils"; +import type { + ConversionOrderEstimate, + IckbToCkbOrderEstimate, + SystemState, +} from "../client/sdk_types.ts"; +import { + estimateConversionOrder, + estimateMaturityFeeThreshold, +} from "./sdk_estimate_core.ts"; +import { maturity } from "./sdk_maturity.ts"; +import { maxMaturity } from "./sdk_projection.ts"; + +export function estimate( + isCkb2Udt: boolean, + amounts: ValueComponents, + system: SystemState, + options?: { fee?: ccc.Num; feeBase?: ccc.Num }, +): ConversionOrderEstimate { + const estimateOptions = { fee: 1n, feeBase: 100000n, ...options }; + const conversion = estimateConversionOrder( + isCkb2Udt, + amounts, + system, + estimateOptions.fee, + estimateOptions.feeBase, + ); + if (conversion === undefined) { + throw new OrderConversionRepresentabilityError(); + } + + return conversion; +} + +export function estimateIckbToCkbOrder( + amounts: { ckbValue: bigint; udtValue: bigint }, + system: SystemState, +): IckbToCkbOrderEstimate | undefined { + const baseEstimate = estimateIckbToCkbOrderDefaultFee(amounts, system); + if (baseEstimate === undefined) { + const dustEstimate = estimateDustIckbToCkbOrder(amounts, system); + return dustEstimate === undefined + ? undefined + : dustIckbToCkbOrderEstimate(amounts, system, dustEstimate); + } + if (baseEstimate.maturity !== undefined) { + return { estimate: baseEstimate, maturity: baseEstimate.maturity }; + } + if (baseEstimate.ckbFee >= estimateMaturityFeeThreshold(system)) { + return { + estimate: baseEstimate, + maturity: undefined, + notice: { + kind: "maturity-unavailable", + inputIckb: amounts.udtValue, + outputCkb: baseEstimate.convertedAmount, + incentiveCkb: positiveFee(baseEstimate.ckbFee), + maturityEstimateUnavailable: true, + }, + }; + } + + const dustEstimate = estimateDustIckbToCkbOrder(amounts, system); + return dustEstimate === undefined + ? undefined + : dustIckbToCkbOrderEstimate(amounts, system, dustEstimate); +} + +export { + estimateConversionOrder, + estimateMaturityFeeThreshold, +} from "./sdk_estimate_core.ts"; +export { maxMaturity }; + +function estimateIckbToCkbOrderDefaultFee( + amounts: ValueComponents, + system: SystemState, +): ConversionOrderEstimate | undefined { + return estimateConversionOrder(false, amounts, system, 1n, 100000n); +} + +function dustIckbToCkbOrderEstimate( + amounts: ValueComponents, + system: SystemState, + orderEstimate: ConversionOrderEstimate, +): IckbToCkbOrderEstimate { + const estimatedMaturity = maturity({ info: orderEstimate.info, amounts }, system); + + return { + estimate: orderEstimate, + maturity: estimatedMaturity, + notice: { + kind: "dust-ickb-to-ckb", + inputIckb: amounts.udtValue, + outputCkb: orderEstimate.convertedAmount, + incentiveCkb: positiveFee(orderEstimate.ckbFee), + maturityEstimateUnavailable: estimatedMaturity === undefined, + }, + }; +} + +function estimateDustIckbToCkbOrder( + amounts: ValueComponents, + system: SystemState, +): ConversionOrderEstimate | undefined { + const baseEstimate = estimateConversionOrder(false, amounts, system, 0n, 100000n); + if (baseEstimate === undefined) { + return undefined; + } + const targetFee = estimateMaturityFeeThreshold(system); + const feeBase = baseEstimate.convertedAmount + 1n; + if (targetFee <= 0n || feeBase <= 1n) { + return baseEstimate; + } + const estimateWithFee = (fee: bigint): ConversionOrderEstimate | undefined => + estimateConversionOrder(false, amounts, system, fee, feeBase); + return lowestFeeEstimateAtThreshold(estimateWithFee, feeBase - 1n, targetFee); +} + +function lowestFeeEstimateAtThreshold( + estimateWithFee: (fee: bigint) => ConversionOrderEstimate | undefined, + highestFee: bigint, + targetFee: bigint, +): ConversionOrderEstimate | undefined { + const highestDiscount = estimateWithFee(highestFee); + if (highestDiscount === undefined || highestDiscount.ckbFee < targetFee) { + return undefined; + } + + let low = 0n; + let high = highestFee; + while (low < high) { + const mid = (low + high) / 2n; + const estimateAtMid = estimateWithFee(mid); + if (estimateAtMid === undefined) { + return undefined; + } + if (estimateAtMid.ckbFee >= targetFee) { + high = mid; + } else { + low = mid + 1n; + } + } + return estimateWithFee(low); +} + +function positiveFee(fee: bigint): bigint { + return fee > 0n ? fee : 0n; +} diff --git a/packages/sdk/src/estimate/sdk_estimate_core.ts b/packages/sdk/src/estimate/sdk_estimate_core.ts new file mode 100644 index 0000000..804709a --- /dev/null +++ b/packages/sdk/src/estimate/sdk_estimate_core.ts @@ -0,0 +1,43 @@ +import { OrderConversionRepresentabilityError, OrderManager } from "@ickb/order"; +import type { ValueComponents } from "@ickb/utils"; +import type { ConversionOrderEstimate, SystemState } from "../client/sdk_types.ts"; +import { maturity } from "./sdk_maturity.ts"; + +export function estimateConversionOrder( + ...[isCkb2Udt, amounts, system, fee, feeBase]: [ + isCkb2Udt: boolean, + amounts: ValueComponents, + system: SystemState, + fee: bigint, + feeBase: bigint, + ] +): ConversionOrderEstimate | undefined { + let quote: ReturnType; + try { + quote = OrderManager.convert(isCkb2Udt, system.exchangeRatio, amounts, { + fee, + feeBase, + }); + } catch (error) { + if (error instanceof OrderConversionRepresentabilityError) { + return undefined; + } + throw error; + } + const estimatedMaturity = + quote.ckbFee >= estimateMaturityFeeThreshold(system) + ? maturity({ info: quote.info, amounts }, system) + : undefined; + return { ...quote, maturity: estimatedMaturity }; +} + +/** + * Returns the CKB fee threshold above which order maturity is worth estimating. + * + * @public + */ +export function estimateMaturityFeeThreshold( + system: Pick, +): bigint { + return 10n * system.feeRate; +} diff --git a/packages/sdk/src/estimate/sdk_maturity.ts b/packages/sdk/src/estimate/sdk_maturity.ts new file mode 100644 index 0000000..442f99b --- /dev/null +++ b/packages/sdk/src/estimate/sdk_maturity.ts @@ -0,0 +1,125 @@ +import { ccc } from "@ckb-ccc/core"; +import { convert } from "@ickb/core"; +import { Info } from "@ickb/order"; +import { binarySearch, type ValueComponents } from "@ickb/utils"; +import type { + CkbCumulative, + MaturityOrderInput, + SystemState, +} from "../client/sdk_types.ts"; + +export function maturity(o: MaturityOrderInput, system: SystemState): bigint | undefined { + const { info, amounts } = maturityOrderParts(o); + if (info.isDualRatio()) { + return undefined; + } + + const isCkb2Udt = info.isCkb2Udt(); + const amount = orderSideAmount(isCkb2Udt, amounts); + if (amount === 0n) { + return 0n; + } + + return isCkb2Udt + ? ckbToIckbOrderMaturity(info, amount, system) + : ickbToCkbOrderMaturity(info, amounts, amount, system); +} + +function maturityOrderParts(o: MaturityOrderInput): { + info: Info; + amounts: ValueComponents; +} { + if ("info" in o) { + return o; + } + + return { + info: o.data.info, + amounts: { ckbValue: o.ckbUnoccupied, udtValue: o.udtValue }, + }; +} + +function orderSideAmount(isCkb2Udt: boolean, amounts: ValueComponents): bigint { + return isCkb2Udt ? amounts.ckbValue : amounts.udtValue; +} + +function ckbToIckbOrderMaturity(info: Info, amount: bigint, system: SystemState): bigint { + const ratio = info.ckbToUdt; + const pressure = orderPoolPressure(true, new Info(ratio, ratio, 1), system); + const ckb = amount + pressure.ckb - convert(false, pressure.udt, system.exchangeRatio); + const baseMaturity = 10n * 60n * 1000n; + const maturityValue = + ckb > 0n ? baseMaturity * (1n + ckb / ccc.fixedPointFrom("200000")) : baseMaturity; + return maturityValue + system.tip.timestamp; +} + +function ickbToCkbOrderMaturity( + info: Info, + amounts: ValueComponents, + amount: bigint, + system: SystemState, +): bigint | undefined { + const ratio = info.udtToCkb; + const pressure = orderPoolPressure(false, new Info(ratio, ratio, 1), system); + const orderCkb = amounts.ckbValue - ratio.convert(false, amount, true); + const ckb = + orderCkb + + pressure.ckb - + convert(false, pressure.udt, system.exchangeRatio) + + system.ckbAvailable; + const baseMaturity = 10n * 60n * 1000n; + if (ckb >= 0n) { + return baseMaturity + system.tip.timestamp; + } + + return firstCkbMaturityAtOrAbove(system.ckbMaturing, -ckb); +} + +function orderPoolPressure( + isCkb2Udt: boolean, + reference: Info, + system: SystemState, +): { ckb: bigint; udt: bigint } { + let ckb = 0n; + let udt = 0n; + for (const order of system.orderPool) { + const info = order.data.info; + if (shouldCountCkbOrder(isCkb2Udt, info, reference)) { + ckb += order.ckbUnoccupied; + } + if (shouldCountUdtOrder(isCkb2Udt, info, reference)) { + udt += order.udtValue; + } + } + return { ckb, udt }; +} + +function shouldCountCkbOrder(isCkb2Udt: boolean, info: Info, reference: Info): boolean { + return info.isCkb2Udt() && (!isCkb2Udt || info.ckb2UdtCompare(reference) < 0); +} + +function shouldCountUdtOrder(isCkb2Udt: boolean, info: Info, reference: Info): boolean { + return !info.isCkb2Udt() && (isCkb2Udt || info.udt2CkbCompare(reference) < 0); +} + +function firstCkbMaturityAtOrAbove( + ckbMaturing: readonly CkbCumulative[], + ckbNeeded: bigint, +): bigint | undefined { + const index = binarySearch(ckbMaturing.length, (n) => { + const entry = ckbMaturing[n]; + if (entry === undefined) { + throw new Error(`CKB maturity entry ${String(n)} is missing`); + } + return entry.ckbCumulative >= ckbNeeded; + }); + if (index >= ckbMaturing.length) { + return undefined; + } + + const entry = ckbMaturing[index]; + if (entry === undefined) { + throw new Error(`CKB maturity entry ${String(index)} is missing`); + } + return entry.maturity; +} diff --git a/packages/sdk/src/estimate/sdk_maturity_order_group.ts b/packages/sdk/src/estimate/sdk_maturity_order_group.ts new file mode 100644 index 0000000..3be152c --- /dev/null +++ b/packages/sdk/src/estimate/sdk_maturity_order_group.ts @@ -0,0 +1,22 @@ +import { OrderCell, OrderGroup } from "@ickb/order"; +import type { SystemState } from "../client/sdk_types.ts"; +import { maturity } from "./sdk_maturity.ts"; + +export function orderGroupWithMaturity( + group: OrderGroup, + system: SystemState, +): OrderGroup { + const { order } = group; + return new OrderGroup( + group.master, + new OrderCell( + order.cell, + order.data, + order.ckbUnoccupied, + order.absTotal, + order.absProgress, + maturity(order, system), + ), + group.origin, + ); +} diff --git a/packages/sdk/src/estimate/sdk_projection.ts b/packages/sdk/src/estimate/sdk_projection.ts new file mode 100644 index 0000000..2fd8123 --- /dev/null +++ b/packages/sdk/src/estimate/sdk_projection.ts @@ -0,0 +1,145 @@ +import { ccc } from "@ckb-ccc/core"; +import type { WithdrawalGroup } from "@ickb/core"; +import type { OrderGroup } from "@ickb/order"; +import type { + AccountAvailabilityProjection, + AccountState, + ConversionTransactionContextProjection, + SystemState, +} from "../client/sdk_types.ts"; + +/** + * Builds the conversion planner context from account and user-order state. + * + * @public + */ +export function projectConversionTransactionContext( + system: SystemState, + account: AccountState, + userOrders: OrderGroup[], + options?: Parameters[2], +): ConversionTransactionContextProjection { + const projection = projectAccountAvailability(account, userOrders, options); + const estimatedMaturity = [ + ...projection.pendingWithdrawals.map((group) => + group.owned.maturity.toUnix(system.tip), + ), + ...projection.pendingOrders + .map((group) => group.order.maturity) + .filter((maturity): maturity is bigint => maturity !== undefined), + ].reduce(maxMaturity, system.tip.timestamp); + + return { + projection, + context: { + system, + receipts: account.receipts, + readyWithdrawals: projection.readyWithdrawals, + availableOrders: projection.availableOrders, + ckbAvailable: projection.ckbAvailable, + ickbAvailable: projection.ickbAvailable, + estimatedMaturity, + }, + }; +} + +/** + * Splits wallet-owned CKB and iCKB into immediately available and pending value. + * + * @public + */ +export function projectAccountAvailability( + account: AccountState, + userOrders: OrderGroup[], + options?: { + collectedOrdersAvailable?: boolean; + }, +): AccountAvailabilityProjection { + const { readyWithdrawals, pendingWithdrawals } = splitWithdrawals( + account.withdrawalGroups, + ); + const { availableOrders, pendingOrders } = splitOrders(userOrders, options); + const ckbNative = sumValues(account.capacityCells, (cell) => cell.cellOutput.capacity); + const ickbNative = sumValues(account.nativeUdtCells, (cell) => + ccc.udtBalanceFrom(cell.outputData), + ); + 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, + }; +} + +export function maxMaturity(left: bigint, right: bigint): bigint { + return left > right ? left : right; +} + +function splitWithdrawals(withdrawalGroups: readonly WithdrawalGroup[]): { + readyWithdrawals: WithdrawalGroup[]; + pendingWithdrawals: WithdrawalGroup[]; +} { + const readyWithdrawals: WithdrawalGroup[] = []; + const pendingWithdrawals: WithdrawalGroup[] = []; + for (const group of withdrawalGroups) { + if (group.owned.isReady) { + readyWithdrawals.push(group); + } else { + pendingWithdrawals.push(group); + } + } + return { readyWithdrawals, pendingWithdrawals }; +} + +function splitOrders( + userOrders: readonly OrderGroup[], + options: { collectedOrdersAvailable?: boolean } | undefined, +): { availableOrders: OrderGroup[]; pendingOrders: OrderGroup[] } { + const availableOrders: OrderGroup[] = []; + const pendingOrders: OrderGroup[] = []; + for (const group of userOrders) { + if ( + options?.collectedOrdersAvailable === true || + group.order.isDualRatio() || + !group.order.isMatchable() + ) { + availableOrders.push(group); + } else { + pendingOrders.push(group); + } + } + return { availableOrders, pendingOrders }; +} + +function sumCkb(items: Array<{ ckbValue: bigint }>): bigint { + return sumValues(items, (item) => item.ckbValue); +} + +function sumUdt(items: Array<{ 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; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ded09de..209ffb7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,12 +1,58 @@ -export * from "./sdk.js"; -export * from "./constants.js"; +/** + * Public SDK for planning, building, completing, and sending iCKB transactions. + * + * @packageDocumentation + */ + +export { getConfig } from "./constants.ts"; +export type { CodeScriptDeps, IckbDeploymentConfig } from "./constants.ts"; export { - selectReadyWithdrawalCleanupDeposit, + IckbSdk, + IckbSdkBase, + IckbSdkConversion, + IckbSdkL1, + estimateMaturityFeeThreshold, + projectAccountAvailability, + projectConversionTransactionContext, + sendAndWaitForCommit, + TransactionConfirmationError, +} from "./sdk.ts"; +export type { + AccountAvailabilityProjection, + AccountState, + BuildBaseTransactionOptions, + CkbCumulative, + CompleteIckbTransactionOptions, + ConversionDirection, + ConversionMetadata, + ConversionNotice, + ConversionOrderEstimate, + ConversionTransactionContext, + ConversionTransactionContextProjection, + ConversionTransactionFailureReason, + ConversionTransactionOptions, + ConversionTransactionResult, + GetL1StateOptions, + GetPoolDepositsOptions, + IckbToCkbOrderEstimate, + MaturityOrderInput, + PoolDepositRangeOptions, + PoolDepositState, + SendAndWaitForCommitEvent, + SendAndWaitForCommitOptions, + SystemState, +} from "./sdk.ts"; +export { + ringRequiredLiveDepositFor, + ringSegmentAnchor, + ringSegments, + ringSurplusDepositFilter, + ringTargetSegmentIndex, selectReadyWithdrawalDeposits, -} from "./withdrawal_selection.js"; +} from "./withdrawal/withdrawal_selection.ts"; export type { - ReadyWithdrawalCleanupSelection, - ReadyWithdrawalCleanupSelectionOptions, ReadyWithdrawalSelection, ReadyWithdrawalSelectionOptions, -} from "./withdrawal_selection.js"; + RingSegment, + WithdrawalDepositCandidate, +} from "./withdrawal/withdrawal_selection.ts"; diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts deleted file mode 100644 index 441f734..0000000 --- a/packages/sdk/src/sdk.test.ts +++ /dev/null @@ -1,3003 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { - Info, - MasterCell, - OrderCell, - OrderData, - OrderGroup, - Ratio, -} from "@ickb/order"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { DaoManager, DaoOutputLimitError } from "@ickb/dao"; -import { - ICKB_DEPOSIT_CAP, - convert, - type IckbDepositCell, - LogicManager, - OwnerCell, - OwnerData, - OwnedOwnerManager, - ReceiptData, - type ReceiptCell, - WithdrawalGroup, -} from "@ickb/core"; -import { OrderManager } from "@ickb/order"; -import { headerLike as testHeaderLike, hash, script } from "@ickb/testkit"; -import { defaultCellPageSize } from "@ickb/utils"; -import { - completeIckbTransaction, - estimateMaturityFeeThreshold, - IckbSdk, - MAX_DIRECT_DEPOSITS, - projectAccountAvailability, - projectConversionTransactionContext, - sendAndWaitForCommit, - TransactionConfirmationError, - type SystemState, -} from "./sdk.js"; - -const ratio = Ratio.from({ ckbScale: 1n, udtScale: 1n }); - -function headerLike( - number: bigint, - overrides: Partial = {}, -): ccc.ClientBlockHeader { - return testHeaderLike({ - epoch: [1n, 0n, 1n], - number, - ...overrides, - }); -} - -const tip = headerLike(0n); - -function fakeIckbUdt(udt = script("66")): { - isUdt: (cell: ccc.Cell) => boolean; - completeBy: (txLike: ccc.TransactionLike) => Promise; -} { - return { - isUdt: (cell: ccc.Cell): boolean => cell.cellOutput.type?.eq(udt) ?? false, - 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(); -} - -async function* none(): AsyncGenerator { - await Promise.resolve(); - 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 readyDeposit( - udtValue: bigint, - maturityUnix = 0n, - options: { ckbValue?: bigint; id?: string } = {}, -): IckbDepositCell { - return { - cell: ccc.Cell.from({ - outPoint: { txHash: hash(options.id ?? "aa"), index: maturityUnix }, - cellOutput: { capacity: 0n, lock: script("22") }, - outputData: "0x", - }), - headers: [], - interests: 0n, - isReady: true, - isDeposit: true, - ckbValue: options.ckbValue ?? udtValue, - udtValue, - maturity: { toUnix: (): bigint => maturityUnix }, - } as unknown as IckbDepositCell; -} - -function transactionWithOutputs(count: number, lock: ccc.Script): ccc.Transaction { - const tx = ccc.Transaction.default(); - for (let index = 0; index < count; index += 1) { - tx.outputs.push(ccc.CellOutput.from({ capacity: 1n, lock })); - tx.outputsData.push("0x"); - } - return tx; -} - -function testSdk(): { - sdk: IckbSdk; - logicManager: LogicManager; - ownedOwnerManager: OwnedOwnerManager; - orderManager: OrderManager; - lock: ccc.Script; -} { - const lock = script("11"); - const logicManager = new LogicManager(script("22"), [], new DaoManager(script("33"), [])); - const ownedOwnerManager = new OwnedOwnerManager( - script("44"), - [], - new DaoManager(script("33"), []), - ); - const orderManager = new OrderManager(script("55"), [], script("66")); - return { - sdk: new IckbSdk( - fakeIckbUdt(), - ownedOwnerManager, - logicManager, - orderManager, - [], - ), - logicManager, - ownedOwnerManager, - orderManager, - lock, - }; -} - -function system(overrides: Partial = {}): SystemState { - return { - feeRate: 1n, - tip, - exchangeRatio: ratio, - orderPool: [], - ckbAvailable: 0n, - ckbMaturing: [], - ...overrides, - }; -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("IckbSdk.estimate", () => { - it("exposes the fee threshold used for maturity previews", () => { - expect(estimateMaturityFeeThreshold(system({ feeRate: 7n }))).toBe(70n); - }); - - it("omits maturity below the fee threshold", () => { - const result = IckbSdk.estimate( - false, - { ckbValue: 0n, udtValue: 100000n }, - system({ ckbAvailable: 100000n }), - ); - - expect(result.convertedAmount).toBe(99999n); - expect(result.ckbFee).toBe(1n); - expect(result.maturity).toBeUndefined(); - }); - - it("uses the chain tip timestamp for preview maturity", () => { - const result = IckbSdk.estimate( - false, - { ckbValue: 0n, udtValue: 1000000n }, - system({ - ckbAvailable: 1000000n, - tip: headerLike(0n, { timestamp: 1234n }), - }), - ); - - expect(result.convertedAmount).toBe(999990n); - 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(); - }); - - it("uses the fee-adjusted CKB output for UDT-to-CKB maturity", () => { - const exchangeRatio = Ratio.from({ - ckbScale: 10000000000000000n, - udtScale: 10008200000000000n, - }); - - const result = IckbSdk.estimate( - false, - { ckbValue: 0n, udtValue: ccc.fixedPointFrom("100000.001") }, - system({ - exchangeRatio, - ckbAvailable: ccc.fixedPointFrom(100082), - }), - ); - - expect(result.convertedAmount).toBeLessThan(ccc.fixedPointFrom(100082)); - expect(result.maturity).toBe(600000n); - }); - - it("builds normal iCKB-to-CKB orders when maturity is unavailable", () => { - const result = IckbSdk.estimateIckbToCkbOrder( - { ckbValue: 0n, udtValue: 1000000n }, - system({ ckbAvailable: 0n }), - ); - - expect(result).toBeDefined(); - expect(result?.maturity).toBeUndefined(); - expect(result?.notice).toEqual({ - kind: "maturity-unavailable", - inputIckb: 1000000n, - outputCkb: 999990n, - incentiveCkb: 10n, - maturityEstimateUnavailable: true, - }); - expect(result?.estimate.info.ckbMinMatchLog).toBe(33); - }); - - it("builds tiny iCKB-to-CKB orders with explicit dust terms", () => { - const result = IckbSdk.estimateIckbToCkbOrder( - { ckbValue: 0n, udtValue: 1n }, - system({ ckbAvailable: 1n, tip: headerLike(0n, { timestamp: 1234n }) }), - ); - - expect(result).toBeDefined(); - expect(result?.maturity).toBe(601234n); - expect(result?.notice).toEqual({ - kind: "dust-ickb-to-ckb", - inputIckb: 1n, - outputCkb: 1n, - incentiveCkb: 0n, - maturityEstimateUnavailable: false, - }); - expect(result?.estimate.info.ckbMinMatchLog).toBe(33); - }); - -}); - -describe("IckbSdk.maturity", () => { - it("returns undefined for dual-ratio orders", () => { - const dualRatio = new Info(ratio, ratio, 1); - - expect( - IckbSdk.maturity( - { info: dualRatio, amounts: { ckbValue: 1n, udtValue: 1n } }, - system(), - ), - ).toBeUndefined(); - }); - - it("returns zero for already fulfilled orders", () => { - expect( - IckbSdk.maturity( - { - info: Info.create(true, ratio), - amounts: { ckbValue: 0n, udtValue: 0n }, - }, - system(), - ), - ).toBe(0n); - }); - - it("returns the baseline maturity when enough CKB is already available", () => { - expect( - IckbSdk.maturity( - { - info: Info.create(false, ratio), - amounts: { ckbValue: 0n, udtValue: 100n }, - }, - system({ - ckbAvailable: 100n, - tip: headerLike(0n, { timestamp: 1234n }), - }), - ), - ).toBe(601234n); - }); - - it("picks the first matching maturing CKB entry", () => { - expect( - IckbSdk.maturity( - { - info: Info.create(false, ratio), - amounts: { ckbValue: 0n, udtValue: 100n }, - }, - system({ - ckbMaturing: [ - { ckbCumulative: 50n, maturity: 1000n }, - { ckbCumulative: 100n, maturity: 2000n }, - { ckbCumulative: 150n, maturity: 3000n }, - ], - }), - ), - ).toBe(2000n); - }); - - it("counts existing CKB in UDT-to-CKB orders before requiring pool liquidity", () => { - const info = Info.create(false, { - ckbScale: 9n, - udtScale: 10n, - }); - - expect( - IckbSdk.maturity( - { - info, - amounts: { - ckbValue: 7n, - udtValue: 10n, - }, - }, - system({ - ckbMaturing: [ - { ckbCumulative: 5n, maturity: 1000n }, - { ckbCumulative: 6n, maturity: 2000n }, - ], - }), - ), - ).toBe(1000n); - }); -}); - -describe("projectAccountAvailability", () => { - 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], - nativeUdtCells: [], - 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: [], - nativeUdtCells: [], - 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: [], - nativeUdtCells: [], - 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: [], - nativeUdtCells: [], - 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], - nativeUdtCells: [], - 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: [], - nativeUdtCells: [], - 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("projectConversionTransactionContext", () => { - it("projects conversion context from account state and collected-order policy", () => { - const readyWithdrawal = { owned: { isReady: true }, ckbValue: 11n, udtValue: 0n } as WithdrawalGroup; - const pendingWithdrawal = { - owned: { isReady: false, maturity: { toUnix: (): bigint => 5000n } }, - ckbValue: 17n, - udtValue: 0n, - } as WithdrawalGroup; - const matchable = orderGroup({ - ckbValue: 31n, - udtValue: 37n, - isDualRatio: false, - isMatchable: true, - }); - Object.defineProperty(matchable.order, "maturity", { value: 7000n }); - const receipt = { ckbValue: 41n, udtValue: 43n } as ReceiptCell; - const account = { - capacityCells: [{ cellOutput: { capacity: 3n } } as ccc.Cell], - nativeUdtCells: [], - nativeUdtCapacity: 0n, - nativeUdtBalance: 7n, - receipts: [receipt], - withdrawalGroups: [readyWithdrawal, pendingWithdrawal], - }; - const currentSystem = system({ tip: headerLike(0n, { timestamp: 1000n }) }); - - const { projection, context } = projectConversionTransactionContext( - currentSystem, - account, - [matchable], - { collectedOrdersAvailable: true }, - ); - - expect(projection.availableOrders).toEqual([matchable]); - expect(context).toEqual({ - system: currentSystem, - receipts: [receipt], - readyWithdrawals: [readyWithdrawal], - availableOrders: [matchable], - ckbAvailable: projection.ckbAvailable, - ickbAvailable: projection.ickbAvailable, - estimatedMaturity: 5000n, - }); - }); - - it("includes pending order maturity when collected orders are not budgeted", () => { - const matchable = orderGroup({ - ckbValue: 31n, - udtValue: 37n, - isDualRatio: false, - isMatchable: true, - }); - Object.defineProperty(matchable.order, "maturity", { value: 7000n }); - - const { context } = projectConversionTransactionContext( - system({ tip: headerLike(0n, { timestamp: 1000n }) }), - { - capacityCells: [], - nativeUdtCells: [], - nativeUdtCapacity: 0n, - nativeUdtBalance: 0n, - receipts: [], - withdrawalGroups: [], - }, - [matchable], - ); - - expect(context.estimatedMaturity).toBe(7000n); - }); -}); - -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 = depositCell("80", logic, dao, tip, tip, { - isReady: true, - }); - const requiredLiveDeposit = { - cell: ccc.Cell.from({ - outPoint: { txHash: hash("90"), index: 0n }, - cellOutput: { capacity: 1n, lock: logic }, - outputData: "0x", - }), - isReady: true, - } as IckbDepositCell; - - vi.spyOn(ownedOwnerManager, "requestWithdrawal").mockImplementation( - async (txLike, deposits, lock, _client, requestOptions) => { - await Promise.resolve(); - steps.push("request"); - expect(deposits).toEqual([requestedDeposit]); - expect(lock).toEqual(botLock); - expect(requestOptions).toEqual({ requiredLiveDeposits: [requiredLiveDeposit] }); - const tx = ccc.Transaction.from(txLike); - expect(tx.inputs).toHaveLength(0); - expect(tx.outputs).toHaveLength(0); - 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"]); - }); - - it("combines real manager transaction effects", 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 daoDep = dep("d1"); - const ownedDep = dep("d2"); - const logicDep = dep("d3"); - const orderDep = dep("d4"); - const daoManager = new DaoManager(dao, [daoDep]); - const logicManager = new LogicManager(logic, [logicDep], daoManager); - const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [ownedDep], daoManager); - const orderManager = new OrderManager(order, [orderDep], udt); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - ownedOwnerManager, - logicManager, - orderManager, - [botLock], - ); - const depositHeader = headerLike(10n, { hash: hash("a1") }); - const receiptHeader = headerLike(11n, { hash: hash("a2") }); - const withdrawalHeader = headerLike(12n, { hash: hash("a3") }); - const requestedDeposit = depositCell("70", logic, dao, depositHeader, tip, { - isReady: true, - }); - const requiredLiveDeposit = depositCell("71", logic, dao, depositHeader, tip, { - isReady: true, - }); - const { group: orderGroup, orderCell, masterCell } = makeOrderGroup({ - orderScript: order, - udtScript: udt, - ownerLock: botLock, - txHashByte: "72", - }); - const receipt = receiptCell("73", botLock, logic, receiptHeader); - const withdrawalGroup = readyWithdrawalGroup({ - ownerLock: botLock, - ownedOwner, - dao, - depositHeader, - withdrawalHeader, - }); - - const tx = await sdk.buildBaseTransaction(ccc.Transaction.default(), {} as ccc.Client, { - withdrawalRequest: { - deposits: [requestedDeposit], - requiredLiveDeposits: [requiredLiveDeposit], - lock: botLock, - }, - orders: [orderGroup], - receipts: [receipt], - readyWithdrawals: [withdrawalGroup], - }); - - expect(tx.inputs.map((input) => input.previousOutput.toHex())).toEqual([ - requestedDeposit.cell.outPoint.toHex(), - orderCell.outPoint.toHex(), - masterCell.outPoint.toHex(), - receipt.cell.outPoint.toHex(), - withdrawalGroup.owned.cell.outPoint.toHex(), - withdrawalGroup.owner.cell.outPoint.toHex(), - ]); - expect(tx.outputs).toHaveLength(2); - expect(tx.outputs[0]?.capacity).toBe(requestedDeposit.cell.cellOutput.capacity); - expect(tx.outputs[0]?.lock.eq(ownedOwner)).toBe(true); - expect(tx.outputs[0]?.type?.eq(dao)).toBe(true); - expect(tx.outputs[1]?.lock.eq(botLock)).toBe(true); - expect(tx.outputs[1]?.type?.eq(ownedOwner)).toBe(true); - expect(tx.outputsData).toEqual([ - ccc.hexFrom(ccc.mol.Uint64LE.encode(depositHeader.number)), - ccc.hexFrom(OwnerData.encode({ ownedDistance: -1n })), - ]); - expect(tx.headerDeps).toEqual([ - depositHeader.hash, - receiptHeader.hash, - withdrawalHeader.hash, - ]); - expect(tx.cellDeps).toContainEqual(daoDep); - expect(tx.cellDeps).toContainEqual(ownedDep); - expect(tx.cellDeps).toContainEqual(logicDep); - expect(tx.cellDeps).toContainEqual(orderDep); - expect(tx.cellDeps).toContainEqual( - ccc.CellDep.from({ - outPoint: requiredLiveDeposit.cell.outPoint, - depType: "code", - }), - ); - expect(new Set(tx.headerDeps).size).toBe(tx.headerDeps.length); - }); - - it("accepts withdrawal requests after balanced caller activity", async () => { - 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 = depositCell("85", logic, dao, tip, tip, { - isReady: true, - }); - 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 = depositCell("85", logic, dao, tip, tip, { - isReady: true, - }); - - 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: [depositCell("85", logic, dao, tip, tip, { isReady: true })], - lock: botLock, - }, - }), - ).rejects.toThrow("Transaction has different inputs and outputs lengths"); - }); -}); - -describe("IckbSdk.buildConversionTransaction", () => { - it("plans CKB-to-iCKB direct deposits before fallback orders", async () => { - const { sdk, logicManager, orderManager, lock } = testSdk(); - const calls: string[] = []; - const remainder = ccc.fixedPointFrom(10000); - const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( - async (txLike, quantity, depositCapacity, depositLock) => { - await Promise.resolve(); - calls.push(`deposit:${String(quantity)}`); - expect(depositCapacity).toBe(ICKB_DEPOSIT_CAP); - expect(depositLock).toBe(lock); - const tx = ccc.Transaction.from(txLike); - tx.outputs.push(ccc.CellOutput.from({ capacity: 1n, lock })); - tx.outputsData.push("0x"); - return tx; - }, - ); - const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike, _lock, _info, amounts) => { - calls.push("order"); - expect(amounts).toEqual({ ckbValue: remainder, udtValue: 0n }); - const tx = ccc.Transaction.from(txLike); - expect(tx.outputs).toHaveLength(1); - tx.outputs.push(ccc.CellOutput.from({ capacity: 2n, lock })); - tx.outputsData.push("0x"); - return tx; - }); - - const result = await sdk.buildConversionTransaction( - ccc.Transaction.default(), - {} as ccc.Client, - { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP * 2n + remainder, - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 3n }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP * 2n + remainder, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - }, - ); - - expect(result).toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); - expect(deposit).toHaveBeenCalledTimes(1); - expect(mint).toHaveBeenCalledTimes(1); - expect(calls).toEqual(["deposit:2", "order"]); - }); - - it("caps CKB-to-iCKB direct deposits", async () => { - const { sdk, logicManager, orderManager, lock } = testSdk(); - const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( - async (txLike, quantity) => { - await Promise.resolve(); - expect(quantity).toBe(MAX_DIRECT_DEPOSITS); - return ccc.Transaction.from(txLike); - }, - ); - vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); - - await sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP * BigInt(MAX_DIRECT_DEPOSITS + 1), - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP * BigInt(MAX_DIRECT_DEPOSITS + 1), - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - }); - - expect(deposit).toHaveBeenCalledTimes(1); - }); - - it("retries CKB-to-iCKB direct deposits after DAO output-limit failures", async () => { - const { sdk, logicManager, orderManager, lock } = testSdk(); - const deposit = vi.spyOn(logicManager, "deposit") - .mockRejectedValueOnce(new DaoOutputLimitError(65)) - .mockImplementation(async (txLike, quantity) => { - await Promise.resolve(); - expect(quantity).toBe(1); - return ccc.Transaction.from(txLike); - }); - vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP * 2n, - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 2n }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP * 2n, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); - - expect(deposit).toHaveBeenCalledTimes(2); - }); - - it("skips predictably oversized CKB-to-iCKB candidates before building", async () => { - const { sdk, logicManager, orderManager, lock } = testSdk(); - const quantities: number[] = []; - const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( - async (txLike, quantity) => { - await Promise.resolve(); - quantities.push(quantity); - return ccc.Transaction.from(txLike); - }, - ); - vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); - - await expect(sdk.buildConversionTransaction(transactionWithOutputs(60, lock), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP * 2n + 1n, - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 3n }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP * 2n + 1n, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); - - expect(deposit).toHaveBeenCalledTimes(1); - expect(quantities).toEqual([1]); - }); - - it("recognizes DAO output-limit errors across package runtime boundaries", async () => { - const { sdk, logicManager, orderManager, lock } = testSdk(); - const outputLimitError = new Error("same domain error from another package copy"); - outputLimitError.name = "DaoOutputLimitError"; - const deposit = vi.spyOn(logicManager, "deposit") - .mockRejectedValueOnce(outputLimitError) - .mockImplementation(async (txLike, quantity) => { - await Promise.resolve(); - expect(quantity).toBe(1); - return ccc.Transaction.from(txLike); - }); - vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP * 2n, - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 2n }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP * 2n, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); - - expect(deposit).toHaveBeenCalledTimes(2); - }); - - it("fails fast on non-retryable CKB-to-iCKB construction errors", async () => { - const { sdk, logicManager, lock } = testSdk(); - const deposit = vi.spyOn(logicManager, "deposit") - .mockRejectedValue(new Error("RPC failed")); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP * 2n, - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP * 2n }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP * 2n, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).rejects.toThrow("RPC failed"); - - expect(deposit).toHaveBeenCalledTimes(1); - }); - - it("plans exact ready withdrawals with required anchors", async () => { - const { sdk, ownedOwnerManager, lock } = testSdk(); - const extra = readyDeposit(10n, 0n); - const protectedAnchor = readyDeposit(12n, 1n); - const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") - .mockImplementation(async (txLike, deposits, requestLock, _client, requestOptions) => { - await Promise.resolve(); - expect(deposits).toEqual([extra]); - expect(requestLock).toBe(lock); - expect(requestOptions).toEqual({ requiredLiveDeposits: [protectedAnchor] }); - return ccc.Transaction.from(txLike); - }); - - const result = await sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ickb-to-ckb", - amount: 10n, - lock, - context: { - system: system({ - poolDeposits: { - deposits: [extra, protectedAnchor], - readyDeposits: [extra, protectedAnchor], - id: "pool", - }, - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: 10n, - estimatedMaturity: 0n, - }, - }); - - expect(result).toMatchObject({ ok: true, conversion: { kind: "direct" } }); - expect(requestWithdrawal).toHaveBeenCalledTimes(1); - }); - - it("builds iCKB-to-CKB direct withdrawals plus dust remainder orders", async () => { - const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); - const directDeposit = readyDeposit(ICKB_DEPOSIT_CAP); - const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") - .mockImplementation(async (txLike, deposits) => { - await Promise.resolve(); - expect(deposits).toEqual([directDeposit]); - return ccc.Transaction.from(txLike); - }); - const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike, _lock, info, amounts) => { - expect(info.ckbMinMatchLog).toBe(33); - expect(amounts).toEqual({ ckbValue: 0n, udtValue: 100000n }); - return ccc.Transaction.from(txLike); - }); - const exchangeRatio = Ratio.from({ - ckbScale: 10000000000000000n, - udtScale: 10008200000000000n, - }); - const amount = ICKB_DEPOSIT_CAP + 100000n; - - const result = await sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ickb-to-ckb", - amount, - lock, - context: { - system: system({ - exchangeRatio, - ckbAvailable: convert(false, ICKB_DEPOSIT_CAP, exchangeRatio), - poolDeposits: { - deposits: [directDeposit], - readyDeposits: [directDeposit], - id: "pool", - }, - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: amount, - estimatedMaturity: 0n, - }, - }); - - expect(result).toMatchObject({ - ok: true, - conversion: { kind: "direct-plus-order" }, - conversionNotice: { - kind: "dust-ickb-to-ckb", - inputIckb: 100000n, - outputCkb: 100072n, - incentiveCkb: 10n, - maturityEstimateUnavailable: false, - }, - }); - expect(requestWithdrawal).toHaveBeenCalledTimes(1); - expect(mint).toHaveBeenCalledTimes(1); - }); - - it("prefers better direct iCKB-to-CKB economic surplus within a maturity bucket", async () => { - const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); - const unit = ICKB_DEPOSIT_CAP / 10n; - const largerLowerGain = readyDeposit(9n * unit, 0n, { - ckbValue: 9n * unit, - id: "a1", - }); - const smallerHigherGain = readyDeposit(8n * unit, 30n * 60n * 1000n, { - ckbValue: 8n * unit + 1000n, - id: "a2", - }); - const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") - .mockImplementation(async (txLike, deposits) => { - await Promise.resolve(); - expect(deposits).toEqual([smallerHigherGain]); - return ccc.Transaction.from(txLike); - }); - const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike, _lock, _info, amounts) => { - expect(amounts).toEqual({ ckbValue: 0n, udtValue: 2n * unit }); - return ccc.Transaction.from(txLike); - }); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ickb-to-ckb", - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ - exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), - ckbAvailable: 10n, - poolDeposits: { - deposits: [largerLowerGain, smallerHigherGain], - readyDeposits: [largerLowerGain, smallerHigherGain], - id: "pool", - }, - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: ICKB_DEPOSIT_CAP, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); - - expect(requestWithdrawal).toHaveBeenCalledTimes(1); - expect(mint).toHaveBeenCalledTimes(1); - }); - - it("prefers an earlier iCKB-to-CKB maturity bucket over a marginally larger withdrawal", async () => { - const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); - const unit = ICKB_DEPOSIT_CAP / 10n; - const smallEarlier = readyDeposit(4n * unit, 0n); - const smallLater = readyDeposit(4n * unit, 15n * 60n * 1000n); - const largeMuchLater = readyDeposit(9n * unit, 2n * 60n * 60n * 1000n); - const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") - .mockImplementation(async (txLike, deposits) => { - await Promise.resolve(); - expect(deposits).toEqual([smallEarlier, smallLater]); - return ccc.Transaction.from(txLike); - }); - const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike, _lock, _info, amounts) => { - expect(amounts).toEqual({ ckbValue: 0n, udtValue: 2n * unit }); - return ccc.Transaction.from(txLike); - }); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ickb-to-ckb", - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ - exchangeRatio: Ratio.from({ ckbScale: 100n, udtScale: 1n }), - ckbAvailable: 10n, - poolDeposits: { - deposits: [smallEarlier, smallLater, largeMuchLater], - readyDeposits: [smallEarlier, smallLater, largeMuchLater], - id: "pool", - }, - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: ICKB_DEPOSIT_CAP, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); - - expect(requestWithdrawal).toHaveBeenCalledTimes(1); - expect(mint).toHaveBeenCalledTimes(1); - }); - - it("preserves iCKB-to-CKB maturity-bucket priority before direct surplus", async () => { - const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); - const unit = ICKB_DEPOSIT_CAP / 10n; - const earlier = readyDeposit(8n * unit, 30n * 60n * 1000n, { - ckbValue: 8n * unit, - id: "b1", - }); - const laterHigherGain = readyDeposit(8n * unit, 2n * 60n * 60n * 1000n, { - ckbValue: 8n * unit + 1000n, - id: "b2", - }); - const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") - .mockImplementation(async (txLike, deposits) => { - await Promise.resolve(); - expect(deposits).toEqual([earlier]); - return ccc.Transaction.from(txLike); - }); - const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike) => - ccc.Transaction.from(txLike) - ); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ickb-to-ckb", - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ - exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), - ckbAvailable: 10n, - poolDeposits: { - deposits: [laterHigherGain, earlier], - readyDeposits: [laterHigherGain, earlier], - id: "pool", - }, - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: ICKB_DEPOSIT_CAP, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); - - expect(requestWithdrawal).toHaveBeenCalledTimes(1); - expect(mint).toHaveBeenCalledTimes(1); - }); - - it("skips iCKB-to-CKB deposits above the requested amount even with high surplus", async () => { - const { sdk, ownedOwnerManager, lock } = testSdk(); - const oversized = readyDeposit(ICKB_DEPOSIT_CAP + 1n, 0n, { - ckbValue: ICKB_DEPOSIT_CAP * 2n, - id: "c1", - }); - const fitting = readyDeposit(ICKB_DEPOSIT_CAP, 15n * 60n * 1000n, { - ckbValue: ICKB_DEPOSIT_CAP, - id: "c2", - }); - const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") - .mockImplementation(async (txLike, deposits) => { - await Promise.resolve(); - expect(deposits).toEqual([fitting]); - return ccc.Transaction.from(txLike); - }); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ickb-to-ckb", - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ - exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), - poolDeposits: { - deposits: [oversized, fitting], - readyDeposits: [oversized, fitting], - id: "pool", - }, - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: ICKB_DEPOSIT_CAP, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct" } }); - - expect(requestWithdrawal).toHaveBeenCalledTimes(1); - }); - - it("returns typed failures for no activity and tiny orders", async () => { - const { sdk, lock } = testSdk(); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: 0n, - lock, - context: { - system: system(), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).resolves.toEqual({ ok: false, reason: "nothing-to-do", estimatedMaturity: 0n }); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: 1n, - lock, - context: { - system: system(), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 1n, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).resolves.toEqual({ ok: false, reason: "amount-too-small", estimatedMaturity: 0n }); - }); - - it("fails fast on non-retryable iCKB-to-CKB construction errors", async () => { - const { sdk, ownedOwnerManager, lock } = testSdk(); - const extra = readyDeposit(1n, 0n); - const protectedAnchor = readyDeposit(2n, 1n); - vi.spyOn(ownedOwnerManager, "requestWithdrawal") - .mockRejectedValue(new Error("withdrawal failed")); - - const tx = ccc.Transaction.default(); - tx.inputs.push(ccc.CellInput.from({ - previousOutput: { txHash: hash("90"), index: 0n }, - })); - tx.outputs.push(ccc.CellOutput.from({ capacity: 1n, lock })); - tx.outputsData.push("0x"); - - await expect(sdk.buildConversionTransaction(tx, {} as ccc.Client, { - direction: "ickb-to-ckb", - amount: 1n, - lock, - context: { - system: system({ - exchangeRatio: Ratio.from({ ckbScale: 100n, udtScale: 1n }), - poolDeposits: { - deposits: [extra, protectedAnchor], - readyDeposits: [extra, protectedAnchor], - id: "pool", - }, - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: 1n, - estimatedMaturity: 0n, - }, - })).rejects.toThrow("withdrawal failed"); - }); - - it("retries iCKB-to-CKB withdrawals after DAO output-limit failures", async () => { - const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); - const first = readyDeposit(ICKB_DEPOSIT_CAP / 2n, 0n); - const second = readyDeposit(ICKB_DEPOSIT_CAP / 2n, 15n * 60n * 1000n); - const requestedCounts: number[] = []; - const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") - .mockImplementation(async (txLike, deposits) => { - await Promise.resolve(); - requestedCounts.push(deposits.length); - if (requestedCounts.length === 1) { - throw new DaoOutputLimitError(65); - } - expect(deposits).toHaveLength(1); - return ccc.Transaction.from(txLike); - }); - vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ickb-to-ckb", - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ - poolDeposits: { - deposits: [first, second], - readyDeposits: [first, second], - id: "pool", - }, - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: ICKB_DEPOSIT_CAP, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); - - expect(requestWithdrawal).toHaveBeenCalledTimes(2); - expect(requestedCounts).toEqual([2, 1]); - }); - - it("skips predictably oversized iCKB-to-CKB candidates before building", async () => { - const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); - const first = readyDeposit(ICKB_DEPOSIT_CAP / 2n, 0n); - const second = readyDeposit(ICKB_DEPOSIT_CAP / 2n, 15n * 60n * 1000n); - const requestedCounts: number[] = []; - const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal") - .mockImplementation(async (txLike, deposits) => { - await Promise.resolve(); - requestedCounts.push(deposits.length); - return ccc.Transaction.from(txLike); - }); - vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); - - await expect(sdk.buildConversionTransaction(transactionWithOutputs(60, lock), {} as ccc.Client, { - direction: "ickb-to-ckb", - amount: ICKB_DEPOSIT_CAP + 1n, - lock, - context: { - system: system({ - poolDeposits: { - deposits: [first, second], - readyDeposits: [first, second], - id: "pool", - }, - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: 0n, - ickbAvailable: ICKB_DEPOSIT_CAP + 1n, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct-plus-order" } }); - - expect(requestWithdrawal).toHaveBeenCalledTimes(1); - expect(requestedCounts).toEqual([1]); - }); - - it("reports predictable DAO output-limit exhaustion", async () => { - const { sdk, logicManager, orderManager, lock } = testSdk(); - const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( - async (txLike) => { - await Promise.resolve(); - return ccc.Transaction.from(txLike); - }, - ); - const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); - - await expect(sdk.buildConversionTransaction(transactionWithOutputs(64, lock), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), - receipts: [], - readyWithdrawals: [{} as WithdrawalGroup], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).rejects.toThrow(DaoOutputLimitError); - - expect(deposit).not.toHaveBeenCalled(); - expect(mint).not.toHaveBeenCalled(); - }); - - it("does not count input-only base activities as planned DAO outputs", async () => { - const { sdk, logicManager, ownedOwnerManager, orderManager, lock } = testSdk(); - vi.spyOn(orderManager, "melt").mockImplementation((txLike) => { - const tx = ccc.Transaction.from(txLike); - tx.inputs.push(ccc.CellInput.from({ previousOutput: { txHash: hash("c1"), index: 0n } })); - return tx; - }); - vi.spyOn(logicManager, "completeDeposit").mockImplementation((txLike) => { - const tx = ccc.Transaction.from(txLike); - tx.inputs.push(ccc.CellInput.from({ previousOutput: { txHash: hash("c2"), index: 0n } })); - return tx; - }); - vi.spyOn(ownedOwnerManager, "withdraw").mockImplementation(async (txLike) => { - await Promise.resolve(); - const tx = ccc.Transaction.from(txLike); - tx.inputs.push(ccc.CellInput.from({ previousOutput: { txHash: hash("c3"), index: 0n } })); - return tx; - }); - const deposit = vi.spyOn(logicManager, "deposit").mockImplementation( - async (txLike) => { - await Promise.resolve(); - return ccc.Transaction.from(txLike); - }, - ); - const mint = vi.spyOn(orderManager, "mint").mockImplementation((txLike) => ccc.Transaction.from(txLike)); - - await expect(sdk.buildConversionTransaction(transactionWithOutputs(62, lock), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), - receipts: [{} as ReceiptCell], - readyWithdrawals: [{} as WithdrawalGroup], - availableOrders: [{} as OrderGroup], - ckbAvailable: ICKB_DEPOSIT_CAP, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).resolves.toMatchObject({ ok: true, conversion: { kind: "direct" } }); - - expect(deposit).toHaveBeenCalledTimes(1); - expect(mint).not.toHaveBeenCalled(); - }); - - it("preserves retryable construction errors when retries exhaust into planning misses", async () => { - const { sdk, logicManager, lock } = testSdk(); - vi.spyOn(logicManager, "deposit").mockRejectedValue(new DaoOutputLimitError(65)); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ - ckbAvailable: ICKB_DEPOSIT_CAP, - feeRate: ccc.fixedPointFrom(1), - }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).rejects.toBeInstanceOf(DaoOutputLimitError); - }); - - it("uses plain-object error messages in conversion construction failures", async () => { - const { sdk, logicManager, lock } = testSdk(); - vi.spyOn(logicManager, "deposit").mockRejectedValue({ message: "RPC failed" }); - - await expect(sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, { - direction: "ckb-to-ickb", - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - })).rejects.toThrow("RPC failed"); - }); - - it("uses string and bigint object error messages in conversion construction failures", async () => { - const { sdk, logicManager, lock } = testSdk(); - vi.spyOn(logicManager, "deposit") - .mockRejectedValueOnce("RPC failed") - .mockRejectedValueOnce({ code: 1n }); - - const options = { - direction: "ckb-to-ickb" as const, - amount: ICKB_DEPOSIT_CAP, - lock, - context: { - system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), - receipts: [], - readyWithdrawals: [], - availableOrders: [], - ckbAvailable: ICKB_DEPOSIT_CAP, - ickbAvailable: 0n, - estimatedMaturity: 0n, - }, - }; - - await expect( - sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, options), - ).rejects.toThrow("RPC failed"); - await expect( - sdk.buildConversionTransaction(ccc.Transaction.default(), {} as ccc.Client, options), - ).rejects.toThrow('{"code":"1"}'); - }); -}); - -describe("completeIckbTransaction", () => { - it("runs UDT completion before fee completion", 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]); - }); - - const completed = await completeIckbTransaction(tx, ickbUdt, { - signer, - client, - feeRate: 42n, - }); - - expect(completed).toBeInstanceOf(ccc.Transaction); - expect(calls).toEqual(["udt", "fee"]); - }); - - 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]); - - 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 onLifecycle = vi.fn<(event: unknown) => void>(); - 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, - onLifecycle, - sleep, - }, - )).resolves.toBe(txHash); - - expect(sendTransaction).toHaveBeenCalledTimes(1); - expect(onConfirmationWait).toHaveBeenCalledTimes(2); - expect(sleep).toHaveBeenCalledTimes(2); - expect(sleep).toHaveBeenCalledWith(7); - expect(getTransaction).toHaveBeenCalledTimes(3); - expect(getTransaction).toHaveBeenCalledWith(txHash); - expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ - { type: "broadcasted", txHash }, - { type: "committed", txHash, status: "committed", checks: 3 }, - ]); - }); - - it("emits pre-broadcast lifecycle failures without changing the thrown error", async () => { - const error = new Error("broadcast failed"); - const onLifecycle = vi.fn<(event: unknown) => void>(); - - await expect(sendAndWaitForCommit( - { - client: {} as ccc.Client, - signer: { - sendTransaction: vi.fn().mockRejectedValue(error), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { onLifecycle }, - )).rejects.toBe(error); - - expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ - { type: "pre_broadcast_failed", error }, - ]); - }); - - it("does not let lifecycle callback failures replace send errors", async () => { - const error = new Error("broadcast failed"); - const onLifecycle = vi.fn(() => { - throw new Error("observer failed"); - }); - - await expect(sendAndWaitForCommit( - { - client: {} as ccc.Client, - signer: { - sendTransaction: vi.fn().mockRejectedValue(error), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { onLifecycle }, - )).rejects.toBe(error); - - expect(onLifecycle).toHaveBeenCalledWith(expect.objectContaining({ - type: "pre_broadcast_failed", - error, - })); - }); - - it("does not let lifecycle callback failures interrupt confirmation", async () => { - const txHash = hash("a7"); - const onSent = vi.fn(); - const onLifecycle = vi.fn(() => { - throw new Error("observer failed"); - }); - - await expect(sendAndWaitForCommit( - { - client: { - getTransaction: vi.fn().mockResolvedValue({ status: "committed" }), - } as unknown as ccc.Client, - signer: { - sendTransaction: vi.fn().mockResolvedValue(txHash), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { onLifecycle, onSent }, - )).resolves.toBe(txHash); - - expect(onSent).toHaveBeenCalledWith(txHash); - expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ - { type: "broadcasted", txHash }, - { type: "committed", txHash, status: "committed", checks: 1 }, - ]); - }); - - it("surfaces terminal transaction failures", async () => { - const txHash = hash("a2"); - const sleep = vi.fn(() => Promise.resolve()); - const onLifecycle = vi.fn<(event: unknown) => void>(); - - try { - await sendAndWaitForCommit( - { - client: { - getTransaction: vi.fn().mockResolvedValue({ status: "rejected" }), - } as unknown as ccc.Client, - signer: { - sendTransaction: vi.fn().mockResolvedValue(txHash), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { onLifecycle, sleep }, - ); - expect.fail("Expected sendAndWaitForCommit to reject"); - } catch (error) { - expect(error).toBeInstanceOf(TransactionConfirmationError); - expect(error).toMatchObject({ - message: "Transaction ended with status: rejected", - txHash, - status: "rejected", - isTimeout: false, - }); - } - - expect(sleep).not.toHaveBeenCalled(); - expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ - { type: "broadcasted", txHash }, - { type: "terminal_rejection", txHash, status: "rejected", checks: 1 }, - ]); - }); - - it("surfaces transaction confirmation timeouts with the broadcast hash", async () => { - const txHash = hash("a3"); - const onSent = vi.fn(); - const sleep = vi.fn(() => Promise.resolve()); - const onLifecycle = vi.fn<(event: unknown) => void>(); - - try { - await sendAndWaitForCommit( - { - client: { - getTransaction: vi.fn().mockResolvedValue({ status: "unknown" }), - } as unknown as ccc.Client, - signer: { - sendTransaction: vi.fn().mockResolvedValue(txHash), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { - maxConfirmationChecks: 1, - onLifecycle, - onSent, - sleep, - }, - ); - expect.fail("Expected sendAndWaitForCommit to reject"); - } catch (error) { - expect(error).toBeInstanceOf(TransactionConfirmationError); - expect(error).toMatchObject({ - message: "Transaction confirmation timed out", - txHash, - status: "unknown", - isTimeout: true, - }); - } - - expect(onSent).toHaveBeenCalledWith(txHash); - expect(sleep).not.toHaveBeenCalled(); - expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ - { type: "broadcasted", txHash }, - { type: "timeout_after_broadcast", txHash, status: "unknown", checks: 1 }, - ]); - }); - - it("treats post-broadcast polling failures as unconfirmed", async () => { - const txHash = hash("a4"); - const onSent = vi.fn(); - const onConfirmationWait = vi.fn(); - const onLifecycle = vi.fn<(event: unknown) => void>(); - const sleep = vi.fn(() => Promise.resolve()); - const getTransaction = vi - .fn() - .mockRejectedValueOnce(new Error("RPC down")) - .mockResolvedValueOnce({ status: "committed" }); - - await expect(sendAndWaitForCommit( - { - client: { getTransaction } as unknown as ccc.Client, - signer: { - sendTransaction: vi.fn().mockResolvedValue(txHash), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { - onConfirmationWait, - onLifecycle, - onSent, - sleep, - }, - )).resolves.toBe(txHash); - - expect(onSent).toHaveBeenCalledWith(txHash); - expect(onConfirmationWait).toHaveBeenCalledTimes(1); - expect(sleep).toHaveBeenCalledTimes(1); - expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ - { type: "broadcasted", txHash }, - { type: "committed", txHash, status: "committed", checks: 2 }, - ]); - }); - - it("times out if post-broadcast polling keeps failing", async () => { - const txHash = hash("a5"); - const pollingError = new Error("RPC down"); - const onLifecycle = vi.fn<(event: unknown) => void>(); - - try { - await sendAndWaitForCommit( - { - client: { - getTransaction: vi.fn().mockRejectedValue(pollingError), - } as unknown as ccc.Client, - signer: { - sendTransaction: vi.fn().mockResolvedValue(txHash), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { - maxConfirmationChecks: 1, - onLifecycle, - sleep: () => Promise.resolve(), - }, - ); - expect.fail("Expected sendAndWaitForCommit to reject"); - } catch (error) { - expect(error).toBeInstanceOf(TransactionConfirmationError); - expect(error).toMatchObject({ - message: "Transaction confirmation timed out", - txHash, - status: "sent", - isTimeout: true, - }); - expect(error).toHaveProperty("cause", pollingError); - } - expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ - { type: "broadcasted", txHash }, - { - type: "post_broadcast_unresolved", - txHash, - status: "sent", - checks: 1, - error: pollingError, - }, - ]); - }); - - it("classifies timeout as proven after a successful pending poll follows a transient polling error", async () => { - const txHash = hash("a6"); - const pollingError = new Error("RPC down"); - const onLifecycle = vi.fn<(event: unknown) => void>(); - - try { - await sendAndWaitForCommit( - { - client: { - getTransaction: vi - .fn() - .mockRejectedValueOnce(pollingError) - .mockResolvedValueOnce({ status: "unknown" }), - } as unknown as ccc.Client, - signer: { - sendTransaction: vi.fn().mockResolvedValue(txHash), - } as unknown as ccc.Signer, - }, - ccc.Transaction.default(), - { - maxConfirmationChecks: 2, - onLifecycle, - sleep: () => Promise.resolve(), - }, - ); - expect.fail("Expected sendAndWaitForCommit to reject"); - } catch (error) { - expect(error).toBeInstanceOf(TransactionConfirmationError); - expect(error).toMatchObject({ - message: "Transaction confirmation timed out", - txHash, - status: "unknown", - isTimeout: true, - }); - expect(error).not.toHaveProperty("cause", pollingError); - } - expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ - { type: "broadcasted", txHash }, - { type: "timeout_after_broadcast", txHash, status: "unknown", checks: 2 }, - ]); - }); -}); - -describe("IckbSdk.getL1State snapshot detection", () => { - it("does not classify user-owned matchable orders as system liquidity", async () => { - const userLock = script("11"); - const nonUserLock = script("12"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const orderScript = script("55"); - const udt = script("66"); - const orderManager = new OrderManager(orderScript, [], udt); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), - new LogicManager(logic, [], new DaoManager(dao, [])), - orderManager, - [], - ); - const ownerOrder = makeOrderGroup({ - orderScript, - udtScript: udt, - ownerLock: userLock, - txHashByte: "a1", - }); - ownerOrder.group.order.maturity = 999n; - const marketOrder = makeOrderGroup({ - orderScript, - udtScript: udt, - ownerLock: nonUserLock, - txHashByte: "a2", - orderTxHashByte: "a3", - ratio: { ckbScale: 2n, udtScale: 1n }, - orderCapacity: ccc.fixedPointFrom(300), - udtValue: 1n, - }); - vi.spyOn(orderManager, "findOrders").mockImplementation(async function* () { - yield ownerOrder.group; - yield marketOrder.group; - await Promise.resolve(); - }); - - const client = { - getTipHeader: () => Promise.resolve(headerLike(1n)), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - - const state = await sdk.getL1State(client, [userLock]); - - expect(state.user.orders).toHaveLength(1); - expect(state.user.orders[0]).not.toBe(ownerOrder.group); - expect(state.user.orders[0]?.master).toBe(ownerOrder.group.master); - expect(state.user.orders[0]?.origin).toBe(ownerOrder.group.origin); - expect(state.user.orders[0]?.order).not.toBe(ownerOrder.group.order); - expect(state.user.orders[0]?.order.maturity).toBe(0n); - expect(ownerOrder.group.order.maturity).toBe(999n); - expect(state.system.orderPool).toEqual([marketOrder.group.order]); - }); - - it("ignores bot data cells and falls back to direct deposit scanning", async () => { - const botLock = script("11"); - const logic = script("22"); - 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 fakeAlignedData = ccc.hexFrom(new Uint8Array(128).fill(0xaa)); - const header = headerLike(1n); - const botCells = [ - ccc.Cell.from({ - outPoint: { txHash: hash("01"), index: 0n }, - cellOutput: { capacity: 1000n, lock: botLock }, - outputData: fakeAlignedData, - }), - ]; - const depositCell = ccc.Cell.from({ - outPoint: { txHash: hash("02"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: logic, - type: dao, - }, - outputData: DaoManager.depositData(), - }); - const client = { - getTipHeader: () => Promise.resolve(header), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { - scriptType?: string; - filter?: { outputData?: ccc.Hex }; - }) { - if (query.filter?.outputData === DaoManager.depositData()) { - yield depositCell; - } - if (query.scriptType === "lock") { - for (const cell of botCells) { - yield cell; - } - } - await Promise.resolve(); - }, - getTransactionWithHeader: (txHash: ccc.Hex) => Promise.resolve({ - header: txHash === hash("02") - ? headerLike(0n) - : headerLike(1n, { epoch: ccc.Epoch.from([2n, 0n, 1n]) }), - }), - } as unknown as ccc.Client; - - const state = await sdk.getL1State(client, []); - - expect(state.user.orders).toEqual([]); - expect(state.system.ckbMaturing).toHaveLength(1); - expect(state.system.ckbMaturing[0]?.ckbCumulative).toBe( - ccc.fixedPointFrom(100082), - ); - }); - - it("treats ready deposits as available CKB instead of future maturity", 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 readyDeposit = { - cell: ccc.Cell.from({ - outPoint: { txHash: hash("03"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: logic, - type: dao, - }, - outputData: DaoManager.depositData(), - }), - isDeposit: true, - headers: [{ header: headerLike(0n) }, { header: headerLike(0n) }], - interests: 0n, - maturity: ccc.Epoch.from([1n, 0n, 1n]), - isReady: true, - ckbValue: ccc.fixedPointFrom(100082), - udtValue: ccc.fixedPointFrom(100000), - } as IckbDepositCell; - 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), - [botLock], - ); - const tip = headerLike(1n, { epoch: ccc.Epoch.from([181n, 0n, 1n]) }); - const client = { - getTipHeader: () => Promise.resolve(tip), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - getTransactionWithHeader: () => Promise.resolve({ header: headerLike(0n) }), - } as unknown as ccc.Client; - - const state = await sdk.getL1State(client, []); - - expect(findDeposits).toHaveBeenCalled(); - expect(state.system.ckbAvailable).toBe(ccc.fixedPointFrom(100082)); - expect(state.system.ckbMaturing).toEqual([]); - }); - - it("uses one page size for bot capacity and withdrawal scans", async () => { - const botLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); - const findWithdrawalGroups = vi.spyOn(ownedOwnerManager, "findWithdrawalGroups") - .mockImplementation(() => none()); - const pageSize = defaultCellPageSize + 100; - 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", - }); - let requestedPageSize = 0; - const client = { - getTipHeader: () => Promise.resolve(headerLike(1n)), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { - filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; - }, _order: unknown, pageSize: number) { - if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { - requestedPageSize = pageSize; - yield* repeat(pageSize + 1, plainCell); - } - await Promise.resolve(); - }, - } as unknown as ccc.Client; - - await expect(sdk.getL1State(client, [], { cellPageSize: pageSize })).resolves.toBeDefined(); - expect(requestedPageSize).toBe(pageSize); - expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ pageSize }); - }); - - it("propagates bot withdrawal scan failures after bot capacity scanning succeeds", async () => { - const botLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(async function* () { - await Promise.resolve(); - yield* [] as WithdrawalGroup[]; - throw new Error("withdrawal failed"); - }); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - ownedOwnerManager, - new LogicManager(logic, [], new DaoManager(dao, [])), - new OrderManager(order, [], udt), - [botLock], - ); - const client = { - getTipHeader: () => Promise.resolve(headerLike(1n)), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - - await expect(sdk.getL1State(client, [])).rejects.toThrow("withdrawal failed"); - }); - - it("passes the default page size to direct deposit scanning", async () => { - const botLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const logicManager = new LogicManager(logic, [], new DaoManager(dao, [])); - const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); - const findDeposits = vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); - vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - 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 sdk.getL1State(client, []); - - expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ - pageSize: defaultCellPageSize, - }); - }); - - it("passes a custom page size to pool deposit scanning", async () => { - const { sdk, logicManager } = testSdk(); - const findDeposits = vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); - const client = { - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - const cellPageSize = defaultCellPageSize + 100; - const minLockUp = ccc.Epoch.from([0n, 1n, 16n]); - const maxLockUp = ccc.Epoch.from([0n, 4n, 16n]); - - await sdk.getPoolDeposits(client, tip, { cellPageSize, minLockUp, maxLockUp }); - - expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ - onChain: true, - tip, - pageSize: cellPageSize, - minLockUp, - maxLockUp, - }); - }); - - it("passes custom pool deposit scan options through L1 state loading", async () => { - const { sdk, logicManager } = testSdk(); - const findDeposits = vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); - const client = { - getTipHeader: () => Promise.resolve(tip), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - const cellPageSize = defaultCellPageSize + 100; - const minLockUp = ccc.Epoch.from([0n, 1n, 16n]); - const maxLockUp = ccc.Epoch.from([0n, 4n, 16n]); - - await sdk.getL1State(client, [], { - cellPageSize, - poolDeposits: { minLockUp, maxLockUp }, - }); - - expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ - onChain: true, - tip, - pageSize: cellPageSize, - minLockUp, - maxLockUp, - }); - }); - - it("passes one custom page size through L1 state loading", async () => { - const botLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const logicManager = new LogicManager(logic, [], new DaoManager(dao, [])); - const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])); - const orderManager = new OrderManager(order, [], udt); - vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); - const findWithdrawalGroups = vi.spyOn(ownedOwnerManager, "findWithdrawalGroups") - .mockImplementation(() => none()); - const findOrders = vi.spyOn(orderManager, "findOrders").mockImplementation(() => none()); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - ownedOwnerManager, - logicManager, - orderManager, - [botLock], - ); - const plainCell = ccc.Cell.from({ - outPoint: { txHash: hash("04"), index: 0n }, - cellOutput: { capacity: 1n, lock: botLock }, - outputData: "0x", - }); - const cellPageSize = defaultCellPageSize + 1; - const sampledTip = headerLike(1n); - const capacityLimits: number[] = []; - const client = { - getTipHeader: () => Promise.resolve(sampledTip), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (query: { - filter?: { outputDataLenRange?: unknown; scriptLenRange?: unknown }; - }, _order: unknown, pageSize: number) { - if (query.filter?.scriptLenRange && query.filter.outputDataLenRange) { - capacityLimits.push(pageSize); - yield* repeat(cellPageSize, plainCell); - } - await Promise.resolve(); - }, - } as unknown as ccc.Client; - - await expect(sdk.getL1State(client, [], { cellPageSize })).resolves.toBeDefined(); - - expect(capacityLimits).toEqual([cellPageSize]); - expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ - onChain: true, - tip: sampledTip, - pageSize: cellPageSize, - }); - expect(findOrders.mock.calls[0]?.[1]).toMatchObject({ - onChain: true, - pageSize: cellPageSize, - }); - }); - - it("uses the sampled L1 tip when scanning crosses forward tip progress", async () => { - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const firstTip = headerLike(1n, { hash: hash("01") }); - const secondTip = headerLike(2n, { hash: hash("02") }); - const getTipHeader = vi - .fn() - .mockResolvedValueOnce(firstTip) - .mockResolvedValueOnce(secondTip); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), - new LogicManager(logic, [], new DaoManager(dao, [])), - new OrderManager(order, [], udt), - [], - ); - const client = { - getTipHeader, - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - - const state = await sdk.getL1State(client, []); - - expect(state.system.tip).toBe(firstTip); - expect(getTipHeader).toHaveBeenCalledTimes(1); - }); - - it("does not refetch the tip to detect reorgs during L1 state scanning", async () => { - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const firstTip = headerLike(1n, { hash: hash("01") }); - const secondTip = headerLike(2n, { hash: hash("02") }); - const replacedFirstTip = headerLike(1n, { hash: hash("03") }); - const getTipHeader = vi - .fn() - .mockResolvedValueOnce(firstTip) - .mockResolvedValueOnce(secondTip); - const getHeaderByNumber = vi.fn().mockResolvedValue(replacedFirstTip); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), - new LogicManager(logic, [], new DaoManager(dao, [])), - new OrderManager(order, [], udt), - [], - ); - const client = { - getTipHeader, - getHeaderByNumber, - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - - const state = await sdk.getL1State(client, []); - - expect(state.system.tip).toBe(firstTip); - expect(getTipHeader).toHaveBeenCalledTimes(1); - expect(getHeaderByNumber).not.toHaveBeenCalled(); - }); - - it("does not refetch the tip when the chain tip is replaced during L1 state scanning", async () => { - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const firstTip = headerLike(1n, { hash: hash("01") }); - const replacementTip = headerLike(1n, { hash: hash("02") }); - const getTipHeader = vi - .fn() - .mockResolvedValueOnce(firstTip) - .mockResolvedValueOnce(replacementTip); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), - new LogicManager(logic, [], new DaoManager(dao, [])), - new OrderManager(order, [], udt), - [], - ); - const client = { - getTipHeader, - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - - const state = await sdk.getL1State(client, []); - - expect(state.system.tip).toBe(firstTip); - expect(getTipHeader).toHaveBeenCalledTimes(1); - }); - - it("uses the sampled L1 tip when account state scanning crosses forward tip progress", async () => { - const accountLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const firstTip = headerLike(1n, { hash: hash("01") }); - const secondTip = headerLike(2n, { hash: hash("02") }); - const getTipHeader = vi - .fn() - .mockResolvedValueOnce(firstTip) - .mockResolvedValueOnce(firstTip) - .mockResolvedValueOnce(secondTip); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), - new LogicManager(logic, [], new DaoManager(dao, [])), - new OrderManager(order, [], udt), - [], - ); - const client = { - getTipHeader, - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - - const state = await sdk.getL1AccountState(client, [accountLock]); - - expect(state.system.tip).toBe(firstTip); - expect(getTipHeader).toHaveBeenCalledTimes(1); - }); - - it("does not refetch the tip to detect reorgs during account state scanning", async () => { - const accountLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const firstTip = headerLike(1n, { hash: hash("01") }); - const secondTip = headerLike(2n, { hash: hash("02") }); - const replacedFirstTip = headerLike(1n, { hash: hash("03") }); - const getTipHeader = vi - .fn() - .mockResolvedValueOnce(firstTip) - .mockResolvedValueOnce(firstTip) - .mockResolvedValueOnce(secondTip); - const getHeaderByNumber = vi.fn().mockResolvedValue(replacedFirstTip); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), - new LogicManager(logic, [], new DaoManager(dao, [])), - new OrderManager(order, [], udt), - [], - ); - const client = { - getTipHeader, - getHeaderByNumber, - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - - const state = await sdk.getL1AccountState(client, [accountLock]); - - expect(state.system.tip).toBe(firstTip); - expect(getTipHeader).toHaveBeenCalledTimes(1); - expect(getHeaderByNumber).not.toHaveBeenCalled(); - }); - - it("does not refetch the tip when the chain tip is replaced during account state scanning", async () => { - const accountLock = script("11"); - const logic = script("22"); - const dao = script("33"); - const ownedOwner = script("44"); - const order = script("55"); - const udt = script("66"); - const firstTip = headerLike(1n, { hash: hash("01") }); - const replacementTip = headerLike(1n, { hash: hash("02") }); - const getTipHeader = vi - .fn() - .mockResolvedValueOnce(firstTip) - .mockResolvedValueOnce(firstTip) - .mockResolvedValueOnce(replacementTip); - const sdk = new IckbSdk( - fakeIckbUdt(udt), - new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), - new LogicManager(logic, [], new DaoManager(dao, [])), - new OrderManager(order, [], udt), - [], - ); - const client = { - getTipHeader, - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: () => none(), - } as unknown as ccc.Client; - - const state = await sdk.getL1AccountState(client, [accountLock]); - - expect(state.system.tip).toBe(firstTip); - expect(getTipHeader).toHaveBeenCalledTimes(1); - }); - - it("keeps explicit current-tip assertion available for callers that require it", async () => { - const { sdk } = testSdk(); - const sampledTip = headerLike(1n, { hash: hash("01") }); - const currentTip = headerLike(2n, { hash: hash("02") }); - const client = { - getTipHeader: vi.fn().mockResolvedValue(currentTip), - } as unknown as ccc.Client; - - await expect(sdk.assertCurrentTip(client, sampledTip)).rejects.toThrow( - "L1 state scan crossed chain tip", - ); - }); - - it("passes one custom page size through L1 account state loading", async () => { - const { sdk, logicManager, ownedOwnerManager, orderManager } = testSdk(); - const accountLock = script("77"); - vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); - const findReceipts = vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); - const findWithdrawalGroups = vi.spyOn(ownedOwnerManager, "findWithdrawalGroups") - .mockImplementation(() => none()); - vi.spyOn(orderManager, "findOrders").mockImplementation(() => none()); - const cell = ccc.Cell.from({ - outPoint: { txHash: hash("93"), index: 0n }, - cellOutput: { capacity: 5n, lock: accountLock }, - outputData: "0x", - }); - const cellPageSize = 1; - let requestedPageSize = 0; - const client = { - getTipHeader: () => Promise.resolve(tip), - getFeeRate: () => Promise.resolve(1n), - findCellsOnChain: async function* (_query: unknown, _order: unknown, pageSize: number) { - requestedPageSize = pageSize; - yield* repeat(2, cell); - await Promise.resolve(); - }, - } as unknown as ccc.Client; - - const state = await sdk.getL1AccountState(client, [accountLock], { cellPageSize }); - - expect(requestedPageSize).toBe(cellPageSize); - expect(findReceipts.mock.calls[0]?.[2]).toMatchObject({ pageSize: cellPageSize }); - expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ pageSize: cellPageSize }); - expect(state.account.capacityCells).toEqual([cell, cell]); - }); -}); - -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: ccc.numLeToBytes(11n, 16), - }); - 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(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.nativeUdtCells).toEqual([udtCell]); - expect(state.nativeUdtCapacity).toBe(udtCell.cellOutput.capacity); - expect(state.nativeUdtBalance).toBe(11n); - expect(state.receipts).toEqual([receipt]); - expect(state.withdrawalGroups).toEqual([withdrawal]); - }); - - it("uses a custom account cell scan page size", async () => { - const { sdk, logicManager, ownedOwnerManager } = testSdk(); - const accountLock = script("11"); - const findReceipts = vi.spyOn(logicManager, "findReceipts").mockImplementation(() => none()); - const findWithdrawalGroups = vi.spyOn(ownedOwnerManager, "findWithdrawalGroups") - .mockImplementation(() => none()); - const cell = ccc.Cell.from({ - outPoint: { txHash: hash("92"), index: 0n }, - cellOutput: { capacity: 5n, lock: accountLock }, - outputData: "0x", - }); - const cellPageSize = 1; - let requestedPageSize = 0; - const client = { - findCellsOnChain: async function* (_query: unknown, _order: unknown, pageSize: number) { - requestedPageSize = pageSize; - yield* repeat(2, cell); - await Promise.resolve(); - }, - } as unknown as ccc.Client; - - const state = await sdk.getAccountState(client, [accountLock], tip, { cellPageSize }); - - expect(requestedPageSize).toBe(cellPageSize); - expect(findReceipts.mock.calls[0]?.[2]).toMatchObject({ pageSize: cellPageSize }); - expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ pageSize: cellPageSize }); - expect(state.capacityCells).toEqual([cell, cell]); - }); -}); - -function dep(byte: string): ccc.CellDep { - return ccc.CellDep.from({ - outPoint: { txHash: hash(byte), index: 0n }, - depType: "code", - }); -} - -function depositCell( - byte: string, - logic: ccc.Script, - dao: ccc.Script, - depositHeader: ccc.ClientBlockHeader, - tipHeader: ccc.ClientBlockHeader, - options?: { isReady?: boolean }, -): IckbDepositCell { - const cell = ccc.Cell.from({ - outPoint: { txHash: hash(byte), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: logic, - type: dao, - }, - outputData: DaoManager.depositData(), - }); - return { - cell, - headers: [{ header: depositHeader, txHash: cell.outPoint.txHash }, { header: tipHeader }], - interests: 0n, - maturity: ccc.Epoch.from([1n, 0n, 1n]), - isReady: options?.isReady ?? false, - isDeposit: true, - ckbValue: cell.cellOutput.capacity, - udtValue: ccc.fixedPointFrom(100000), - [Symbol("isIckbDeposit")]: true, - } as unknown as IckbDepositCell; -} - -function receiptCell( - byte: string, - lock: ccc.Script, - logic: ccc.Script, - header: ccc.ClientBlockHeader, -): ReceiptCell { - const cell = ccc.Cell.from({ - outPoint: { txHash: hash(byte), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: logic, - }, - outputData: ReceiptData.encode({ - depositQuantity: 1, - depositAmount: ccc.fixedPointFrom(100000), - }), - }); - return { - cell, - header: { header, txHash: cell.outPoint.txHash }, - ckbValue: cell.cellOutput.capacity, - udtValue: ccc.fixedPointFrom(100000), - }; -} - -function makeOrderGroup(options: { - orderScript: ccc.Script; - udtScript: ccc.Script; - ownerLock: ccc.Script; - txHashByte: string; - orderTxHashByte?: string; - ratio?: { ckbScale: bigint; udtScale: bigint }; - orderCapacity?: bigint; - udtValue?: bigint; -}): { group: OrderGroup; orderCell: ccc.Cell; masterCell: ccc.Cell } { - const masterOutPoint = ccc.OutPoint.from({ - txHash: hash(options.txHashByte), - index: 1n, - }); - const orderCell = ccc.Cell.from({ - outPoint: { txHash: hash(options.orderTxHashByte ?? "74"), index: 0n }, - cellOutput: { - capacity: options.orderCapacity ?? ccc.fixedPointFrom(100), - lock: options.orderScript, - type: options.udtScript, - }, - outputData: OrderData.from({ - udtValue: options.udtValue ?? 0n, - master: { type: "absolute", value: masterOutPoint }, - info: Info.create(true, options.ratio ?? { ckbScale: 1n, udtScale: 1n }), - }).toBytes(), - }); - const masterCell = ccc.Cell.from({ - outPoint: masterOutPoint, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: options.ownerLock, - type: options.orderScript, - }, - outputData: "0x", - }); - const order = OrderCell.mustFrom(orderCell); - - return { - group: new OrderGroup(new MasterCell(masterCell), order, order), - orderCell, - masterCell, - }; -} - -function readyWithdrawalGroup(options: { - ownerLock: ccc.Script; - ownedOwner: ccc.Script; - dao: ccc.Script; - depositHeader: ccc.ClientBlockHeader; - withdrawalHeader: ccc.ClientBlockHeader; -}): WithdrawalGroup { - const ownedCell = ccc.Cell.from({ - outPoint: { txHash: hash("75"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: options.ownedOwner, - type: options.dao, - }, - outputData: ccc.mol.Uint64LE.encode(options.depositHeader.number), - }); - const owner = new OwnerCell( - ccc.Cell.from({ - outPoint: { txHash: ownedCell.outPoint.txHash, index: 1n }, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: options.ownerLock, - type: options.ownedOwner, - }, - outputData: OwnerData.encode({ ownedDistance: -1n }), - }), - ); - return new WithdrawalGroup({ - cell: ownedCell, - headers: [ - { header: options.depositHeader }, - { header: options.withdrawalHeader, txHash: ownedCell.outPoint.txHash }, - ], - interests: 0n, - maturity: ccc.Epoch.from([1n, 0n, 1n]), - isReady: true, - isDeposit: false, - ckbValue: ownedCell.cellOutput.capacity, - udtValue: 0n, - }, owner); -} diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 8bbab22..891a3e9 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -1,1777 +1,109 @@ -import { ccc } from "@ckb-ccc/core"; -import { assertDaoOutputLimit, DaoOutputLimitError } from "@ickb/dao"; -import { - collect, - collectPagedScan, - binarySearch, - compareBigInt, - defaultCellPageSize, - isPlainCapacityCell, - unique, - type ValueComponents, -} from "@ickb/utils"; -import { - ICKB_DEPOSIT_CAP, - convert, - type IckbDepositCell, - type IckbUdt, - ickbExchangeRatio, - type LogicManager, - type OwnedOwnerManager, - type ReceiptCell, - type WithdrawalGroup, -} from "@ickb/core"; -import { - Info, - OrderCell, - OrderGroup, - OrderManager, - Ratio, -} from "@ickb/order"; -import { getConfig } from "./constants.js"; -import { - selectExactReadyWithdrawalDepositCandidates, -} from "./withdrawal_selection.js"; - -export const MAX_DIRECT_DEPOSITS = 60; -export const MAX_WITHDRAWAL_REQUESTS = 30; - -const DAO_OUTPUT_LIMIT = 64; -const ORDER_MINT_OUTPUTS = 2; -const CONVERSION_MATURITY_BUCKET_MS = 60n * 60n * 1000n; - -type SleepScheduler = (handler: () => void, timeout?: number) => unknown; - -export type ConversionDirection = "ckb-to-ickb" | "ickb-to-ckb"; - -export interface PoolDepositState { - deposits: IckbDepositCell[]; - readyDeposits: IckbDepositCell[]; - id: string; -} - -export interface PoolDepositRangeOptions { - minLockUp?: ccc.Epoch; - maxLockUp?: ccc.Epoch; -} - -export interface GetPoolDepositsOptions extends PoolDepositRangeOptions { - cellPageSize?: number; -} - -export interface ConversionTransactionContext { - system: SystemState; - receipts: ReceiptCell[]; - readyWithdrawals: WithdrawalGroup[]; - availableOrders: OrderGroup[]; - ckbAvailable: bigint; - ickbAvailable: bigint; - estimatedMaturity: bigint; -} - -export interface ConversionTransactionOptions { - direction: ConversionDirection; - amount: bigint; - lock: ccc.Script; - context: ConversionTransactionContext; - limits?: { - maxDirectDeposits?: number; - maxWithdrawalRequests?: number; - }; -} - -export type ConversionTransactionFailureReason = - | "amount-negative" - | "insufficient-ckb" - | "insufficient-ickb" - | "amount-too-small" - | "not-enough-ready-deposits" - | "nothing-to-do"; - -export interface ConversionNotice { - kind: "dust-ickb-to-ckb" | "maturity-unavailable"; - inputIckb: bigint; - outputCkb: bigint; - incentiveCkb: bigint; - maturityEstimateUnavailable: boolean; -} - -export interface ConversionMetadata { - kind: "direct" | "order" | "direct-plus-order" | "collect-only"; -} - -export type ConversionTransactionResult = - | { - ok: true; - tx: ccc.Transaction; - estimatedMaturity: bigint; - conversion: ConversionMetadata; - conversionNotice?: ConversionNotice; - } - | { - ok: false; - reason: ConversionTransactionFailureReason; - estimatedMaturity: bigint; - }; - -export interface CompleteIckbTransactionOptions { - signer: ccc.Signer; - client: ccc.Client; - feeRate: ccc.Num; -} - -export interface SendAndWaitForCommitOptions { - maxConfirmationChecks?: number; - confirmationIntervalMs?: number; - onSent?: (txHash: ccc.Hex) => void; - onConfirmationWait?: () => void; - onLifecycle?: (event: SendAndWaitForCommitEvent) => void; - sleep?: (ms: number) => Promise; -} - -export type SendAndWaitForCommitEvent = - | { - type: "pre_broadcast_failed"; - error: unknown; - elapsedMs: number; - } - | { - type: "broadcasted"; - txHash: ccc.Hex; - elapsedMs: number; - } - | { - type: "committed"; - txHash: ccc.Hex; - status: "committed"; - checks: number; - elapsedMs: number; - } - | { - type: "timeout_after_broadcast"; - txHash: ccc.Hex; - status: string | undefined; - checks: number; - elapsedMs: number; - } - | { - type: "post_broadcast_unresolved"; - txHash: ccc.Hex; - status: string | undefined; - checks: number; - elapsedMs: number; - error?: unknown; - } - | { - type: "terminal_rejection"; - txHash: ccc.Hex; - status: string | undefined; - checks: number; - elapsedMs: number; - }; - -export interface GetL1StateOptions { - cellPageSize?: number; - poolDeposits?: PoolDepositRangeOptions; -} - -export interface IckbToCkbOrderEstimate { - estimate: ReturnType; - maturity: bigint | undefined; - notice?: ConversionNotice; -} - -export class TransactionConfirmationError extends Error { - constructor( - message: string, - public readonly txHash: ccc.Hex, - public readonly status: string | undefined, - public readonly isTimeout: boolean, - options?: ErrorOptions, - ) { - super(message, options); - this.name = "TransactionConfirmationError"; - } -} - -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, - onSent, - onConfirmationWait, - onLifecycle, - sleep = delay, - }: SendAndWaitForCommitOptions = {}, -): Promise { - const startedAt = Date.now(); - let txHash: ccc.Hex; - try { - txHash = await signer.sendTransaction(tx); - } catch (error) { - notifyLifecycle(onLifecycle, { - type: "pre_broadcast_failed", - error, - elapsedMs: Date.now() - startedAt, - }); - throw error; - } - onSent?.(txHash); - notifyLifecycle(onLifecycle, { type: "broadcasted", txHash, elapsedMs: Date.now() - startedAt }); - let status: string | undefined = "sent"; - let lastPollingError: unknown; - let checks = 0; - - while (checks < maxConfirmationChecks && isPendingStatus(status)) { - try { - status = (await client.getTransaction(txHash))?.status; - lastPollingError = undefined; - } catch (error) { - // Post-broadcast polling errors are transient; keep waiting until timeout. - lastPollingError = error; - } - checks += 1; - if (!isPendingStatus(status)) { - break; - } - if (checks >= maxConfirmationChecks) { - break; - } - - onConfirmationWait?.(); - await sleep(confirmationIntervalMs); - } - - if (status === "committed") { - notifyLifecycle(onLifecycle, { - type: "committed", - txHash, - status, - checks, - elapsedMs: Date.now() - startedAt, - }); - return txHash; - } - - if (isPendingStatus(status)) { - notifyLifecycle(onLifecycle, { - type: lastPollingError === undefined - ? "timeout_after_broadcast" - : "post_broadcast_unresolved", - txHash, - status, - checks, - elapsedMs: Date.now() - startedAt, - ...(lastPollingError === undefined ? {} : { error: lastPollingError }), - }); - throw new TransactionConfirmationError( - "Transaction confirmation timed out", - txHash, - status, - true, - lastPollingError === undefined ? undefined : { cause: lastPollingError }, - ); - } - - notifyLifecycle(onLifecycle, { - type: "terminal_rejection", - txHash, - status, - checks, - elapsedMs: Date.now() - startedAt, - }); - throw new TransactionConfirmationError( - `Transaction ended with status: ${status ?? "unknown"}`, - txHash, - status, - false, - ); -} - -function notifyLifecycle( - onLifecycle: SendAndWaitForCommitOptions["onLifecycle"] | undefined, - event: SendAndWaitForCommitEvent, -): void { - try { - onLifecycle?.(event); - } catch { - // Observability callbacks must not change transaction send/confirmation behavior. - } -} - -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) => { - getSleepScheduler()(resolve, ms); - }); -} - -function getSleepScheduler(): SleepScheduler { - const runtime = globalThis as typeof globalThis & { - setTimeout?: SleepScheduler; - }; - const schedule = runtime.setTimeout; - if (!schedule) { - throw new Error("setTimeout is unavailable in this runtime"); - } - - return schedule; -} +import type { ccc } from "@ckb-ccc/core"; +import type { IckbUdt, LogicManager, OwnedOwnerManager } from "@ickb/core"; +import type { OrderManager } from "@ickb/order"; +import type { ValueComponents } from "@ickb/utils"; +import { IckbSdkL1 } from "./client/sdk_l1_class.ts"; +import { setSdkManagers } from "./client/sdk_state_store.ts"; +import type { + ConversionOrderEstimate, + IckbToCkbOrderEstimate, + MaturityOrderInput, + SystemState, +} from "./client/sdk_types.ts"; +import type { getConfig } from "./constants.ts"; +import { estimate, estimateIckbToCkbOrder } from "./estimate/sdk_estimate.ts"; +import { maturity } from "./estimate/sdk_maturity.ts"; + +export { IckbSdkBase } from "./client/sdk_base.ts"; +export { IckbSdkConversion } from "./client/sdk_conversion_class.ts"; +export { IckbSdkL1 } from "./client/sdk_l1_class.ts"; +export type { + AccountAvailabilityProjection, + AccountState, + BuildBaseTransactionOptions, + CkbCumulative, + CompleteIckbTransactionOptions, + ConversionDirection, + ConversionMetadata, + ConversionNotice, + ConversionOrderEstimate, + ConversionTransactionContext, + ConversionTransactionContextProjection, + ConversionTransactionFailureReason, + ConversionTransactionOptions, + ConversionTransactionResult, + GetL1StateOptions, + GetPoolDepositsOptions, + IckbToCkbOrderEstimate, + MaturityOrderInput, + PoolDepositRangeOptions, + PoolDepositState, + SystemState, +} from "./client/sdk_types.ts"; +export { estimateMaturityFeeThreshold } from "./estimate/sdk_estimate.ts"; +export { + projectAccountAvailability, + projectConversionTransactionContext, +} from "./estimate/sdk_projection.ts"; +export { sendAndWaitForCommit } from "./send/send_and_wait.ts"; +export type { + SendAndWaitForCommitEvent, + SendAndWaitForCommitOptions, +} from "./send/send_and_wait.ts"; +export { TransactionConfirmationError } from "./send/send_and_wait_error.ts"; /** * SDK for managing iCKB operations. * - * This facade intentionally stops at protocol-specific transaction construction. - * Callers still own completion before send by explicitly calling - * `completeTransaction(...)`. + * @public */ -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. - */ +export class IckbSdk extends IckbSdkL1 { + /** Creates an SDK from resolved protocol managers and bot lock scripts. */ constructor( - private readonly ickbUdt: IckbUdtCompleter, - private readonly ownedOwner: OwnedOwnerManager, - private readonly ickbLogic: LogicManager, - private readonly order: OrderManager, - private readonly bots: ccc.Script[], - ) {} - - /** - * Creates an instance of IckbSdk from script dependencies. - * - * @param args - Parameters matching those of getConfig. - * @returns A new instance of IckbSdk. - */ - static from(...args: Parameters): IckbSdk { - return IckbSdk.fromConfig(getConfig(...args)); - } - - static fromConfig(config: ReturnType): IckbSdk { - const { - managers: { ickbUdt, ownedOwner, logic, order }, - bots, - } = config; - - return new IckbSdk(ickbUdt, ownedOwner, logic, order, bots); - } - - async completeTransaction( - txLike: ccc.TransactionLike, - options: CompleteIckbTransactionOptions, - ): Promise { - return completeIckbTransaction(txLike, this.ickbUdt, options); + ...[ickbUdt, ownedOwner, ickbLogic, order, bots]: [ + ickbUdt: IckbUdt, + ownedOwner: OwnedOwnerManager, + ickbLogic: LogicManager, + order: OrderManager, + bots: ccc.Script[], + ] + ) { + super(); + setSdkManagers(this, { ickbUdt, ownedOwner, ickbLogic, order, bots }); } - /** - * Previews the conversion between CKB and UDT. - * - * This method calculates a conversion preview using an exchange ratio midpoint. - * Optionally, a fee may be applied that influences the effective conversion rate, - * scaling the converted amount by (feeBase - fee) / feeBase. - * - * @param isCkb2Udt - Indicates the conversion direction: - * - true: Convert CKB to UDT. - * - false: Convert UDT to CKB. - * @param amounts - An object containing value components (amounts for CKB and UDT). - * @param system - The current system state containing exchange ratio, fee rate, tip, and order-related information. - * @param options - Optional parameters for fee and matching: - * - fee: The fee to apply in integer terms (defaults to 1n for 0.001% fee). - * - feeBase: The base used for fee scaling (defaults to 100000n). - * - * @returns An object with: - * - convertedAmount: The estimated converted amount as a FixedPoint. - * - ckbFee: The fee (or gain) in CKB, as a FixedPoint. - * - info: Additional conversion metadata. - * - maturity: Optional maturity information when the fee/incentive threshold - * is met and the current pool state can estimate completion timing. The - * order info can still be valid when maturity is undefined. - */ - static estimate( + /** Estimates one conversion order against the sampled system state. */ + public static estimate( isCkb2Udt: boolean, amounts: ValueComponents, system: SystemState, - options?: { - fee?: ccc.Num; - feeBase?: ccc.Num; - }, - ): { - convertedAmount: ccc.FixedPoint; - ckbFee: ccc.FixedPoint; - info: Info; - maturity: ccc.Num | undefined; - } { - // Apply a 0.001% default fee if none provided. - options = { - fee: 1n, - feeBase: 100000n, - ...options, - }; - const { convertedAmount, ckbFee, info } = OrderManager.convert( - isCkb2Udt, - system.exchangeRatio, - amounts, - options, - ); - - // Only previews that clear the fee/incentive threshold get a maturity - // estimate. Smaller previews still return convertedAmount/info. - const maturity = ckbFee >= estimateMaturityFeeThreshold(system) - ? IckbSdk.maturity({ info, amounts }, system) - : undefined; - - return { convertedAmount, ckbFee, info, maturity }; + options?: { fee?: ccc.Num; feeBase?: ccc.Num }, + ): ConversionOrderEstimate { + return estimate(isCkb2Udt, amounts, system, options); } - static estimateIckbToCkbOrder( - amounts: { ckbValue: bigint; udtValue: bigint }, + /** Estimates the order path for an iCKB-to-CKB conversion when one is available. */ + public static estimateIckbToCkbOrder( + amounts: ValueComponents, system: SystemState, ): IckbToCkbOrderEstimate | undefined { - const estimate = IckbSdk.estimate(false, amounts, system); - if (estimate.maturity !== undefined) { - return { estimate, maturity: estimate.maturity }; - } - - if (estimate.convertedAmount === 0n) { - return; - } - - if (estimate.ckbFee >= estimateMaturityFeeThreshold(system)) { - return { - estimate, - maturity: undefined, - notice: { - kind: "maturity-unavailable", - inputIckb: amounts.udtValue, - outputCkb: estimate.convertedAmount, - incentiveCkb: positiveFee(estimate.ckbFee), - maturityEstimateUnavailable: true, - }, - }; - } - - const dustEstimate = estimateDustIckbToCkbOrder(amounts, system); - const dustMaturity = IckbSdk.maturity( - { info: dustEstimate.info, amounts }, - system, - ); - - return { - estimate: dustEstimate, - maturity: dustMaturity, - notice: { - kind: "dust-ickb-to-ckb", - inputIckb: amounts.udtValue, - outputCkb: dustEstimate.convertedAmount, - incentiveCkb: positiveFee(dustEstimate.ckbFee), - maturityEstimateUnavailable: dustMaturity === undefined, - }, - }; - } - - /** - * Estimates the maturity for an order formatted as a Unix timestamp. - * - * Depending on the order type and amount remaining, the method calculates an estimated timestamp - * when the order (or part thereof) might be fulfilled. - * - * @param o - Either an OrderCell or an object containing order Info and value components. - * @param system - The current system state. - * @returns The Unix timestamp of estimated maturity as a bigint (in milliseconds), - * based on `system.tip.timestamp`, or undefined if not applicable. - */ - static maturity( - o: - | OrderCell - | { - info: Info; - amounts: ValueComponents; - }, - system: SystemState, - ): bigint | undefined { - const info = "info" in o ? o.info : o.data.info; - const amounts = - "amounts" in o - ? o.amounts - : { ckbValue: o.ckbUnoccupied, udtValue: o.udtValue }; - - // Dual-ratio orders have no fixed maturity. - if (info.isDualRatio()) { - return; - } - - const isCkb2Udt = info.isCkb2Udt(); - const amount = isCkb2Udt ? amounts.ckbValue : amounts.udtValue; - const ratio = isCkb2Udt ? info.ckbToUdt : info.udtToCkb; - - // If order is already fulfilled. - if (amount === 0n) { - return 0n; - } - - const { tip, exchangeRatio, orderPool, ckbAvailable, ckbMaturing } = system; - - // Create a reference ratio instance for comparison. - const b = new Info(ratio, ratio, 1); - let ckb = isCkb2Udt - ? amount - : amounts.ckbValue - ratio.convert(false, amount, true); - let udt = 0n; - for (const o of orderPool) { - const a = o.data.info; - if (a.isCkb2Udt()) { - // If not isCkb2Udt or a worse ratio, add available CKB. - if (!isCkb2Udt || a.ckb2UdtCompare(b) < 0) { - ckb += o.ckbUnoccupied; - } - } else { - // Conversely for UDT to CKB orders. - if (isCkb2Udt || a.udt2CkbCompare(b) < 0) { - udt += o.udtValue; - } - } - } - // Adjust ckb by the converted UDT amount. - ckb -= convert(false, udt, exchangeRatio); - - // Minimum maturity of 10 minutes (in milliseconds). - let maturity = 10n * 60n * 1000n; - if (isCkb2Udt) { - // For CKB to UDT orders, extend maturity based on the CKB amount. - if (ckb > 0n) { - maturity *= 1n + ckb / ccc.fixedPointFrom("200000"); - } - return maturity + tip.timestamp; - } - - // For UDT to CKB orders, add available CKB. - ckb += ckbAvailable; - if (ckb >= 0n) { - return maturity + tip.timestamp; - } - - // Find the earliest maturity in the ckbMaturing array that satisfies the required CKB. - const ckbNeeded = -ckb; - const i = binarySearch( - ckbMaturing.length, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (n) => ckbMaturing[n]!.ckbCumulative >= ckbNeeded, - ); - - return ckbMaturing[i]?.maturity; - } - - /** - * Mints a new order cell and appends it to the transaction. - * - * The method performs the following operations: - * - Creates order cell data using provided amounts and order information. - * - Adds the required order cell dependencies to the transaction. - * - Appends the order cell to the transaction outputs. - * - * @param txLike - The transaction to which the order cell is added. - * @param user - The user, represented either as a Signer or a Script. - * @param info - The order information meta data (usually computed via OrderManager.convert). - * @param amounts - The value components for the order, including: - * - ckbValue: The CKB amount (may include an internal surplus). - * - udtValue: The UDT amount. - * - * @returns A Promise resolving to the updated transaction. - * - * @remarks The returned transaction is not finalized. Callers own the - * completion pipeline and may use `completeTransaction(...)` before send. - */ - async request( - txLike: ccc.TransactionLike, - user: ccc.Signer | ccc.Script, - info: Info, - amounts: ValueComponents, - ): Promise { - // If the user is provided as a Signer, extract the recommended lock script. - user = - "codeHash" in user - ? user - : (await user.getRecommendedAddressObj()).script; - - return this.order.mint(txLike, user, info, amounts); - } - - /** - * Melts (cancels) the specified order groups. - * - * For each order group, if the option is set to process fulfilled orders only, - * it filters accordingly. Then, for every valid group, the master and order cells are added - * as inputs to the transaction. - * - * @param txLike - The transaction to which the inputs are added. - * @param groups - An array of order groups to be melted. - * @param options - Optional parameters: - * - isFulfilledOnly: If true, only order groups with fully or partially fulfilled orders are processed. - * - * @returns The updated transaction. - * - * @remarks The returned transaction is not finalized. Callers own the - * completion pipeline and may use `completeTransaction(...)` before send. - */ - collect( - txLike: ccc.TransactionLike, - groups: OrderGroup[], - options?: { - isFulfilledOnly?: boolean; - }, - ): ccc.Transaction { - 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); - - const withdrawalRequest = options?.withdrawalRequest; - if (withdrawalRequest?.deposits.length) { - tx = await this.ownedOwner.requestWithdrawal( - tx, - withdrawalRequest.deposits, - withdrawalRequest.lock, - client, - withdrawalRequest.requiredLiveDeposits?.length - ? { requiredLiveDeposits: withdrawalRequest.requiredLiveDeposits } - : undefined, - ); - } - - 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 getPoolDeposits( - client: ccc.Client, - tip: ccc.ClientBlockHeader, - options?: GetPoolDepositsOptions, - ): Promise { - const cellPageSize = options?.cellPageSize ?? defaultCellPageSize; - const deposits = await collect(this.ickbLogic.findDeposits(client, { - onChain: true, - tip, - pageSize: cellPageSize, - minLockUp: options?.minLockUp, - maxLockUp: options?.maxLockUp, - })); - const readyDeposits = sortDepositsByMaturity( - deposits.filter((deposit) => deposit.isReady), - tip, - ); - - return { - deposits, - readyDeposits, - id: poolDepositsKey(deposits, tip), - }; - } - - async buildConversionTransaction( - txLike: ccc.TransactionLike, - client: ccc.Client, - options: ConversionTransactionOptions, - ): Promise { - const { amount, context, direction } = options; - if (amount < 0n) { - return conversionFailure("amount-negative", context.estimatedMaturity); - } - - if (direction === "ckb-to-ickb" && amount > context.ckbAvailable) { - return conversionFailure("insufficient-ckb", context.estimatedMaturity); - } - - if (direction === "ickb-to-ckb" && amount > context.ickbAvailable) { - return conversionFailure("insufficient-ickb", context.estimatedMaturity); - } - - const baseTx = ccc.Transaction.from(txLike); - - if (amount === 0n) { - const tx = await this.buildBaseTransaction( - baseTx, - client, - baseTransactionOptions(context), - ); - if (!hasTransactionActivity(tx)) { - return conversionFailure("nothing-to-do", context.estimatedMaturity); - } - - return { - ok: true, - tx, - estimatedMaturity: context.estimatedMaturity, - conversion: { kind: "collect-only" }, - }; - } - - return direction === "ckb-to-ickb" - ? await this.buildCkbToIckbConversion(baseTx, client, options) - : await this.buildIckbToCkbConversion(baseTx, client, options); - } - - private async buildCkbToIckbConversion( - baseTx: ccc.Transaction, - client: ccc.Client, - options: ConversionTransactionOptions, - ): Promise { - const { amount, context, lock } = options; - const maxDirectDeposits = normalizeCountLimit( - options.limits?.maxDirectDeposits ?? MAX_DIRECT_DEPOSITS, - ); - const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, context.system.exchangeRatio); - const depositQuotient = depositCapacity === 0n ? 0n : amount / depositCapacity; - const maxDeposits = depositQuotient > BigInt(maxDirectDeposits) - ? maxDirectDeposits - : Number(depositQuotient); - let lastFailure: ConversionTransactionFailureReason | undefined; - let lastError: unknown; - - for (let depositCount = maxDeposits; depositCount >= 0; depositCount -= 1) { - const remainder = amount - depositCapacity * BigInt(depositCount); - let estimatedMaturity = context.estimatedMaturity; - let order: ConversionOrder | undefined; - - if (remainder > 0n) { - const amounts = { ckbValue: remainder, udtValue: 0n }; - const estimate = IckbSdk.estimate(true, amounts, context.system); - if (estimate.maturity === undefined) { - lastFailure = "amount-too-small"; - continue; - } - - estimatedMaturity = maxMaturity(estimatedMaturity, estimate.maturity); - order = { amounts, estimate }; - } - - const outputLimitError = plannedDaoOutputLimitError( - baseTx, - (depositCount > 0 ? depositCount + 1 : 0) + orderOutputCount(order), - depositCount > 0 || context.readyWithdrawals.length > 0, - ); - if (outputLimitError) { - lastFailure = undefined; - lastError ??= outputLimitError; - continue; - } - - try { - let tx = await this.buildBaseTransaction( - baseTx.clone(), - client, - baseTransactionOptions(context), - ); - if (depositCount > 0) { - tx = await this.ickbLogic.deposit( - tx, - depositCount, - depositCapacity, - lock, - client, - ); - } - if (order) { - tx = await this.request(tx, lock, order.estimate.info, order.amounts); - } - - return { - ok: true, - tx, - estimatedMaturity, - conversion: { kind: conversionKind(depositCount > 0, order !== undefined) }, - }; - } catch (error) { - if (!isRetryableConversionBuildError(error)) { - throw errorOf(error); - } - lastFailure = undefined; - lastError ??= error; - } - } - - if (lastError !== undefined) { - throw errorOf(lastError); - } - - return conversionFailure(lastFailure ?? "nothing-to-do", context.estimatedMaturity); - } - - private async buildIckbToCkbConversion( - baseTx: ccc.Transaction, - client: ccc.Client, - options: ConversionTransactionOptions, - ): Promise { - const { amount, context, lock } = options; - const maxWithdrawalRequests = normalizeCountLimit( - options.limits?.maxWithdrawalRequests ?? MAX_WITHDRAWAL_REQUESTS, - ); - const poolDeposits = context.system.poolDeposits ?? - await this.getPoolDeposits(client, context.system.tip); - const candidates = sortDepositsByMaturity( - poolDeposits.readyDeposits.filter((deposit) => deposit.isReady), - context.system.tip, - ); - const plans: IckbToCkbConversionPlan[] = []; - const score = (deposit: IckbDepositCell): bigint => - directWithdrawalSurplus(deposit, context.system.exchangeRatio); - let lastFailure: ConversionTransactionFailureReason | undefined; - let lastError: unknown; - - for ( - let withdrawalCount = Math.min(candidates.length, maxWithdrawalRequests); - withdrawalCount >= 0; - withdrawalCount -= 1 - ) { - const selections = withdrawalCount === 0 - ? [{ deposits: [], requiredLiveDeposits: [] }] - : selectExactReadyWithdrawalDepositCandidates({ - readyDeposits: candidates, - tip: context.system.tip, - maxAmount: amount, - count: withdrawalCount, - preserveSingletons: amount < ICKB_DEPOSIT_CAP, - score, - maturityBucket: (deposit) => - maturityBucket(deposit.maturity.toUnix(context.system.tip)), - }); - if (withdrawalCount > 0 && selections.length === 0) { - lastFailure = "not-enough-ready-deposits"; - continue; - } - - for (const selection of selections) { - let estimatedMaturity = context.estimatedMaturity; - let remainder = amount; - let directUdtValue = 0n; - let directSurplusCkb = 0n; - let selectedDeposits: IckbDepositCell[] = []; - let requiredLiveDeposits: IckbDepositCell[] = []; - let order: ConversionOrder | undefined; - - if (withdrawalCount > 0) { - ({ deposits: selectedDeposits, requiredLiveDeposits } = selection); - directUdtValue = sumUdtValue(selectedDeposits); - directSurplusCkb = sumDirectWithdrawalSurplus( - selectedDeposits, - context.system.exchangeRatio, - ); - remainder -= directUdtValue; - for (const deposit of selectedDeposits) { - estimatedMaturity = maxMaturity( - estimatedMaturity, - deposit.maturity.toUnix(context.system.tip), - ); - } - } - - if (remainder > 0n) { - const amounts = { ckbValue: 0n, udtValue: remainder }; - const preview = IckbSdk.estimateIckbToCkbOrder(amounts, context.system); - if (!preview) { - lastFailure = "amount-too-small"; - continue; - } - - const { estimate, maturity, notice } = preview; - if (maturity !== undefined) { - estimatedMaturity = maxMaturity(estimatedMaturity, maturity); - } - order = { amounts, estimate, conversionNotice: notice }; - } - - const outputLimitError = plannedDaoOutputLimitError( - baseTx, - selectedDeposits.length * 2 + orderOutputCount(order), - selectedDeposits.length > 0 || context.readyWithdrawals.length > 0, - ); - if (outputLimitError) { - lastFailure = undefined; - lastError ??= outputLimitError; - continue; - } - - plans.push({ - directSurplusCkb, - directUdtValue, - estimatedMaturity, - order, - requiredLiveDeposits, - selectedDeposits, - }); - } - } - - plans.sort((left, right) => { - const maturityCompare = compareBigInt( - maturityBucket(left.estimatedMaturity), - maturityBucket(right.estimatedMaturity), - ); - if (maturityCompare !== 0) { - return maturityCompare; - } - - const directPresenceCompare = Number(right.selectedDeposits.length > 0) - - Number(left.selectedDeposits.length > 0); - if (directPresenceCompare !== 0) { - return directPresenceCompare; - } - - const surplusCompare = compareBigInt(right.directSurplusCkb, left.directSurplusCkb); - if (surplusCompare !== 0) { - return surplusCompare; - } - - const directCompare = compareBigInt(right.directUdtValue, left.directUdtValue); - return directCompare !== 0 - ? directCompare - : right.selectedDeposits.length - left.selectedDeposits.length; - }); - - for (const { - estimatedMaturity, - order, - requiredLiveDeposits, - selectedDeposits, - } of plans) { - try { - let tx = await this.buildBaseTransaction( - baseTx.clone(), - client, - baseTransactionOptions(context, { - deposits: selectedDeposits, - requiredLiveDeposits, - lock, - }), - ); - if (order) { - tx = await this.request(tx, lock, order.estimate.info, order.amounts); - } - - return { - ok: true, - tx, - estimatedMaturity, - conversion: { - kind: conversionKind(selectedDeposits.length > 0, order !== undefined), - }, - ...(order?.conversionNotice - ? { conversionNotice: order.conversionNotice } - : {}), - }; - } catch (error) { - if (!isRetryableConversionBuildError(error)) { - throw errorOf(error); - } - lastFailure = undefined; - lastError ??= error; - } - } - - if (lastError !== undefined) { - throw errorOf(lastError); - } - - return conversionFailure(lastFailure ?? "nothing-to-do", context.estimatedMaturity); - } - - async getAccountState( - client: ccc.Client, - locks: ccc.Script[], - tip: ccc.ClientBlockHeader, - options?: { cellPageSize?: number }, - ): Promise { - const cellPageSize = options?.cellPageSize ?? defaultCellPageSize; - const [cells, receipts, withdrawalGroups] = await Promise.all([ - this.findAccountCells(client, locks, { pageSize: cellPageSize }), - collect(this.ickbLogic.findReceipts(client, locks, { onChain: true, pageSize: cellPageSize })), - collect( - this.ownedOwner.findWithdrawalGroups(client, locks, { - onChain: true, - tip, - pageSize: cellPageSize, - }), - ), - ]); - const nativeUdtCells = cells.filter((cell) => this.ickbUdt.isUdt(cell)); - const nativeUdt = nativeUdtCells.reduce( - (acc, cell) => ({ - capacity: acc.capacity + cell.cellOutput.capacity, - balance: acc.balance + ccc.udtBalanceFrom(cell.outputData), - }), - { capacity: 0n, balance: 0n }, - ); - - return { - capacityCells: cells.filter(isPlainCapacityCell), - nativeUdtCells, - nativeUdtCapacity: nativeUdt.capacity, - nativeUdtBalance: nativeUdt.balance, - receipts, - withdrawalGroups, - }; - } - - async getL1AccountState( - client: ccc.Client, - locks: ccc.Script[], - options?: GetL1StateOptions, - ): Promise<{ - system: SystemState; - user: { orders: OrderGroup[] }; - account: AccountState; - }> { - const { system, user } = await this.getL1State(client, locks, options); - const account = await this.getAccountState(client, locks, system.tip, { - cellPageSize: options?.cellPageSize, - }); - - return { system, user, account }; + return estimateIckbToCkbOrder(amounts, system); } /** - * Retrieves the L1 state from the blockchain. - * - * The method performs the following: - * - Obtains the current block tip and calculates the exchange ratio. - * - Fetches available CKB and the maturing CKB based on bot capacities and the pool deposit scan. - * - Filters orders into user-owned and system orders based on the provided locks. - * - Estimates user-owned orders maturity. - * - * @param client - The blockchain client interface. - * @param locks - An array of lock scripts used to filter user cells. - * @returns A promise that resolves to an object containing: - * - system: The system state (fee rate, tip header, exchange ratio, order pool, etc.). - * - user: The user's orders grouped as an array of OrderGroup. + * Creates an SDK from a chain config object. */ - async getL1State( - client: ccc.Client, - locks: ccc.Script[], - options?: GetL1StateOptions, - ): Promise<{ system: SystemState; user: { orders: OrderGroup[] } }> { - const tip = await client.getTipHeader(); - const exchangeRatio = Ratio.from(ickbExchangeRatio(tip)); - const cellPageSize = options?.cellPageSize ?? defaultCellPageSize; - - // Parallel fetching of system components. - const [poolDeposits, orders, feeRate] = await Promise.all([ - this.getPoolDeposits(client, tip, { ...options?.poolDeposits, cellPageSize }), - collect(this.order.findOrders(client, { - onChain: true, - pageSize: cellPageSize, - })), - client.getFeeRate(), - ]); - const { ckbAvailable, ckbMaturing } = await this.getCkb(client, tip, poolDeposits, { cellPageSize }); - - const midInfo = new Info(exchangeRatio, exchangeRatio, 1); - const userOrders: OrderGroup[] = []; - const systemOrders: OrderCell[] = []; - for (const group of orders) { - if (group.isOwner(...locks)) { - userOrders.push(group); - continue; - } - - const { order } = group; - const info = order.data.info; - if ( - (order.isCkb2UdtMatchable() && info.ckb2UdtCompare(midInfo) < 0) || - (order.isUdt2CkbMatchable() && info.udt2CkbCompare(midInfo) < 0) - ) { - systemOrders.push(order); - } - } - - const system = { - feeRate, - tip, - exchangeRatio, - orderPool: systemOrders, - ckbAvailable, - ckbMaturing, - poolDeposits, - }; - return { - system, - user: { - orders: userOrders.map((group) => orderGroupWithMaturity(group, system)), - }, - }; - } - - /** - * Retrieves available CKB and maturing CKB values from the blockchain. - * - * This method: - * - Fetches bot withdrawal requests and bot plain-capacity balances. - * - Aggregates available CKB balances from bot capacities. - * - Calculates maturing CKB values (with their expected maturity timestamps) - * from the already loaded pool deposits. - * - Sorts and cumulatively sums the maturing values for later lookup. - * - * @param client - The blockchain client used for fetching data. - * @param tip - The current block tip header. - * @returns A Promise that resolves with: - * - ckbAvailable: The total available CKB (as a FixedPoint). - * - ckbMaturing: An array of maturing CKB objects containing the cumulative CKB - * and the associated maturity timestamp. - */ - private async getCkb( - client: ccc.Client, - tip: ccc.ClientBlockHeader, - poolDeposits: PoolDepositState, - options: { cellPageSize: number }, - ): Promise<{ - ckbAvailable: ccc.FixedPoint; - ckbMaturing: CkbCumulative[]; - }> { - const cellPageSize = options.cellPageSize; - const withdrawalOptions = { - onChain: true, - tip, - pageSize: cellPageSize, - }; - // Map to track each bot's available CKB (minus a reserved amount for internal operations). - const bot2Ckb = new Map(); - const reserved = -ccc.fixedPointFrom("2000"); - for (const lock of unique(this.bots)) { - const key = lock.toHex(); - for (const cell of await collectPagedScan( - (pageSize) => client.findCellsOnChain( - { - script: lock, - scriptType: "lock", - filter: { - scriptLenRange: [0n, 1n], - outputDataLenRange: [0n, 1n], - }, - scriptSearchMode: "exact", - withData: true, - }, - "asc", - pageSize, - ), - { pageSize: cellPageSize }, - )) { - if (isPlainCapacityCell(cell)) { - const ckb = - (bot2Ckb.get(key) ?? reserved) + cell.cellOutput.capacity; - bot2Ckb.set(key, ckb); - } - } - } - - const ckbMaturing = new Array<{ - ckbValue: ccc.FixedPoint; - maturity: ccc.Num; - }>(); - const botWithdrawals = await collect( - this.ownedOwner.findWithdrawalGroups(client, this.bots, withdrawalOptions), - ); - for (const wr of botWithdrawals) { - if (wr.owned.isReady) { - // Update the bot's CKB based on withdrawal if the bot is ready. - const key = wr.owner.cell.cellOutput.lock.toHex(); - const ckb = (bot2Ckb.get(key) ?? reserved) + wr.ckbValue; - bot2Ckb.set(key, ckb); - continue; - } - - // Otherwise, add to maturing amounts. - ckbMaturing.push({ - ckbValue: wr.ckbValue, - maturity: wr.owned.maturity.toUnix(tip), - }); - } - - // Sum available CKB across all bot lock scripts. - let ckbAvailable = 0n; - for (const ckb of bot2Ckb.values()) { - if (ckb > 0n) { - ckbAvailable += ckb; - } - } - - // 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. - for (const d of poolDeposits.deposits) { - if (d.isReady) { - ckbAvailable += d.ckbValue; - continue; - } - - ckbMaturing.push({ - ckbValue: d.ckbValue, - maturity: d.maturity.toUnix(tip), - }); - } - - // Sort maturing CKB entries by their maturity timestamp. - ckbMaturing.sort((a, b) => compareBigInt(a.maturity, b.maturity)); - - // Calculate cumulative maturing CKB values. - let cumulative = 0n; - const ckbCumulativeMaturing: CkbCumulative[] = []; - for (const { ckbValue, maturity } of ckbMaturing) { - cumulative += ckbValue; - ckbCumulativeMaturing.push({ ckbCumulative: cumulative, maturity }); - } - - return { - ckbAvailable, - ckbMaturing: ckbCumulativeMaturing, - }; - } - - private async findAccountCells( - client: ccc.Client, - locks: ccc.Script[], - options?: { pageSize?: number }, - ): Promise { - const cells: ccc.Cell[] = []; - const pageSize = options?.pageSize ?? defaultCellPageSize; - for (const lock of unique(locks)) { - cells.push(...await collectPagedScan( - (pageSize) => client.findCellsOnChain( - { - script: lock, - scriptType: "lock", - scriptSearchMode: "exact", - withData: true, - }, - "asc", - pageSize, - ), - { pageSize }, - )); - } - return cells; - } - - async assertCurrentTip( - client: ccc.Client, - tip: ccc.ClientBlockHeader, - ): Promise { - const currentTip = await client.getTipHeader(); - if (currentTip.number !== tip.number || currentTip.hash !== tip.hash) { - throw new Error("L1 state scan crossed chain tip; retry with a fresh state"); - } - } -} - -type BuildBaseTransactionOptions = NonNullable< - Parameters[2] ->; - -interface ConversionOrder { - amounts: ValueComponents; - estimate: ReturnType; - conversionNotice?: ConversionNotice; -} - -interface IckbToCkbConversionPlan { - directSurplusCkb: bigint; - directUdtValue: bigint; - estimatedMaturity: bigint; - order?: ConversionOrder; - requiredLiveDeposits: IckbDepositCell[]; - selectedDeposits: IckbDepositCell[]; -} - -function conversionFailure( - reason: ConversionTransactionFailureReason, - estimatedMaturity: bigint, -): ConversionTransactionResult { - return { ok: false, reason, estimatedMaturity }; -} - -function baseTransactionOptions( - context: ConversionTransactionContext, - withdrawalRequest?: { - deposits: IckbDepositCell[]; - requiredLiveDeposits: IckbDepositCell[]; - lock: ccc.Script; - }, -): BuildBaseTransactionOptions { - return { - withdrawalRequest: - withdrawalRequest === undefined || withdrawalRequest.deposits.length === 0 - ? undefined - : { - deposits: withdrawalRequest.deposits, - ...(withdrawalRequest.requiredLiveDeposits.length > 0 - ? { requiredLiveDeposits: withdrawalRequest.requiredLiveDeposits } - : {}), - lock: withdrawalRequest.lock, - }, - orders: context.availableOrders, - receipts: context.receipts, - readyWithdrawals: context.readyWithdrawals, - }; -} - -function hasTransactionActivity(tx: ccc.Transaction): boolean { - return tx.inputs.length > 0 || tx.outputs.length > 0; -} - -function errorOf(error: unknown): Error { - if (error instanceof Error) { - return error; - } - - const message = errorMessage(error); - return new Error(message, { cause: error }); -} - -function errorMessage(error: unknown): string { - if (typeof error === "string") { - return error; - } - - if ( - typeof error === "object" && - error !== null && - "message" in error && - typeof error.message === "string" - ) { - return error.message; - } - - try { - return JSON.stringify(error, stringifyBigInt); - } catch { - return String(error); - } -} - -function stringifyBigInt(_key: string, value: unknown): unknown { - return typeof value === "bigint" ? value.toString() : value; -} - -function isRetryableConversionBuildError(error: unknown): boolean { - return error instanceof DaoOutputLimitError || - error instanceof Error && error.name === "DaoOutputLimitError"; -} - -function plannedDaoOutputLimitError( - tx: ccc.Transaction, - additionalOutputs: number, - hasDaoActivity: boolean, -): DaoOutputLimitError | undefined { - if (!hasDaoActivity) { - return; - } - - const outputCount = tx.outputs.length + additionalOutputs; - return outputCount > DAO_OUTPUT_LIMIT - ? new DaoOutputLimitError(outputCount) - : undefined; -} - -function orderOutputCount(order: ConversionOrder | undefined): number { - return order ? ORDER_MINT_OUTPUTS : 0; -} - -function conversionKind( - hasDirect: boolean, - hasOrder: boolean, -): ConversionMetadata["kind"] { - if (hasDirect && hasOrder) { - return "direct-plus-order"; - } - if (hasDirect) { - return "direct"; - } - if (hasOrder) { - return "order"; - } - return "collect-only"; -} - -function estimateDustIckbToCkbOrder( - amounts: ValueComponents, - system: SystemState, -): ReturnType { - const baseEstimate = IckbSdk.estimate(false, amounts, system, { - fee: 0n, - }); - const targetFee = estimateMaturityFeeThreshold(system); - const feeBase = baseEstimate.convertedAmount + 1n; - if (targetFee <= 0n || feeBase <= 1n) { - return baseEstimate; - } - - const estimateWithFee = (fee: bigint): ReturnType => - IckbSdk.estimate(false, amounts, system, { - fee, - feeBase, - }); - - const highestFee = feeBase - 1n; - const highestDiscount = estimateWithFee(highestFee); - if (highestDiscount.ckbFee < targetFee) { - return highestDiscount; - } - - let low = 0n; - let high = highestFee; - while (low < high) { - const mid = (low + high) / 2n; - if (estimateWithFee(mid).ckbFee >= targetFee) { - high = mid; - } else { - low = mid + 1n; - } - } - - return estimateWithFee(low); -} - -function normalizeCountLimit(limit: number): number { - return Number.isSafeInteger(limit) && limit > 0 ? limit : 0; -} - -function sumUdtValue(deposits: readonly IckbDepositCell[]): bigint { - let total = 0n; - for (const deposit of deposits) { - total += deposit.udtValue; - } - return total; -} - -function sumDirectWithdrawalSurplus( - deposits: readonly IckbDepositCell[], - exchangeRatio: Ratio, -): bigint { - let total = 0n; - for (const deposit of deposits) { - total += directWithdrawalSurplus(deposit, exchangeRatio); - } - return total; -} - -function directWithdrawalSurplus(deposit: IckbDepositCell, exchangeRatio: Ratio): bigint { - return deposit.ckbValue - convert(false, deposit.udtValue, exchangeRatio); -} - -function poolDepositsKey( - deposits: readonly IckbDepositCell[], - tip: ccc.ClientBlockHeader, -): string { - return deposits - .map((deposit) => [ - deposit.cell.outPoint.toHex(), - deposit.isReady ? "ready" : "pending", - String(deposit.ckbValue), - String(deposit.udtValue), - String(deposit.maturity.toUnix(tip)), - ].join("@")) - .sort() - .join(","); -} - -function sortDepositsByMaturity( - deposits: readonly IckbDepositCell[], - tip: ccc.ClientBlockHeader, -): IckbDepositCell[] { - return [...deposits].sort((left, right) => - compareBigInt(left.maturity.toUnix(tip), right.maturity.toUnix(tip)) - ); -} - -function positiveOrZero(value: bigint): bigint { - return value > 0n ? value : 0n; -} - -function positiveFee(fee: bigint): bigint { - return positiveOrZero(fee); -} - -function maxMaturity(left: bigint, right: bigint): bigint { - return left > right ? left : right; -} - -function maturityBucket(maturity: bigint): bigint { - return maturity / CONVERSION_MATURITY_BUCKET_MS; -} - -function orderGroupWithMaturity(group: OrderGroup, system: SystemState): OrderGroup { - const { order } = group; - return new OrderGroup( - group.master, - new OrderCell( - order.cell, - order.data, - order.ckbUnoccupied, - order.absTotal, - order.absProgress, - IckbSdk.maturity(order, system), - ), - group.origin, - ); -} - -export interface AccountState { - capacityCells: ccc.Cell[]; - nativeUdtCells: ccc.Cell[]; - nativeUdtCapacity: bigint; - nativeUdtBalance: bigint; - receipts: ReceiptCell[]; - 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 interface ConversionTransactionContextProjection { - projection: AccountAvailabilityProjection; - context: ConversionTransactionContext; -} - -export function projectConversionTransactionContext( - system: SystemState, - account: AccountState, - userOrders: OrderGroup[], - options?: Parameters[2], -): ConversionTransactionContextProjection { - const projection = projectAccountAvailability(account, userOrders, options); - const estimatedMaturity = [ - system.tip.timestamp, - ...projection.pendingWithdrawals.map((group) => group.owned.maturity.toUnix(system.tip)), - ...projection.pendingOrders - .map((group) => group.order.maturity) - .filter((maturity): maturity is bigint => maturity !== undefined), - ].reduce((best, maturity) => (best > maturity ? best : maturity)); - - return { - projection, - context: { - system, - receipts: account.receipts, - readyWithdrawals: projection.readyWithdrawals, - availableOrders: projection.availableOrders, - ckbAvailable: projection.ckbAvailable, - ickbAvailable: projection.ickbAvailable, - estimatedMaturity, - }, - }; -} + public static fromConfig(config: ReturnType): IckbSdk { + const { + managers: { ickbUdt, ownedOwner, logic, order }, + bots, + } = config; -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); - } + return new IckbSdk(ickbUdt, ownedOwner, logic, order, bots); } - 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); - } + /** Estimates maturity for an order input from the sampled system state. */ + public static maturity(o: MaturityOrderInput, system: SystemState): bigint | undefined { + return maturity(o, system); } - - 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; -} - -/** - * Represents the state of the system. - */ -export interface SystemState { - /** The fee rate for transactions. */ - feeRate: ccc.Num; - - /** The tip for the current block header. */ - tip: ccc.ClientBlockHeader; - - /** The exchange ratio between CKB and UDT. */ - exchangeRatio: Ratio; - - /** The order pool containing order cells matching system criteria. */ - orderPool: OrderCell[]; - - /** The total available CKB (as FixedPoint). */ - ckbAvailable: ccc.FixedPoint; - - /** Array of CKB maturing entries with cumulative amounts and maturity timestamps. */ - ckbMaturing: CkbCumulative[]; - - /** Complete public pool deposit snapshot for conversion planning at this tip. */ - poolDeposits?: PoolDepositState; -} - -export function estimateMaturityFeeThreshold( - system: Pick, -): bigint { - return 10n * system.feeRate; -} - -/** - * Represents a cumulative CKB maturing entry. - */ -export interface CkbCumulative { - /** The cumulative CKB value (as FixedPoint) up to this maturity. */ - ckbCumulative: ccc.FixedPoint; - /** The maturity timestamp (as ccc.Num). */ - maturity: ccc.Num; } diff --git a/packages/sdk/src/send/send_and_wait.ts b/packages/sdk/src/send/send_and_wait.ts new file mode 100644 index 0000000..d2e2066 --- /dev/null +++ b/packages/sdk/src/send/send_and_wait.ts @@ -0,0 +1,343 @@ +import { sleep as cccSleep, type ccc } from "@ckb-ccc/core"; +import { TransactionConfirmationError } from "./send_and_wait_error.ts"; + +/** + * Options for broadcasting a transaction and polling until commitment. + * + * @public + */ +export interface SendAndWaitForCommitOptions { + /** Maximum status checks before timing out. */ + maxConfirmationChecks?: number; + + /** Delay between pending status checks. */ + confirmationIntervalMs?: number; + + /** Called after broadcast succeeds with the transaction hash. */ + onSent?: (txHash: ccc.Hex) => void; + + /** Called before each configured confirmation wait. */ + onConfirmationWait?: () => void; + + /** Called for broadcast, commit, timeout, unresolved, and terminal rejection events. */ + onLifecycle?: (event: SendAndWaitForCommitEvent) => void; + + /** Optional sleep implementation for tests or custom runtimes. */ + sleep?: (ms: number) => Promise; +} + +/** + * Lifecycle events emitted while a sent transaction is waiting for commitment. + * + * @public + */ +export type SendAndWaitForCommitEvent = + | { + type: "pre_broadcast_failed"; + error: unknown; + elapsedMs: number; + } + | { + type: "broadcasted"; + txHash: ccc.Hex; + elapsedMs: number; + } + | { + type: "committed"; + txHash: ccc.Hex; + status: "committed"; + checks: number; + elapsedMs: number; + } + | { + type: "timeout_after_broadcast"; + txHash: ccc.Hex; + status: string | undefined; + checks: number; + elapsedMs: number; + } + | { + type: "post_broadcast_unresolved"; + txHash: ccc.Hex; + status: string | undefined; + checks: number; + elapsedMs: number; + error?: unknown; + } + | { + type: "terminal_rejection"; + txHash: ccc.Hex; + status: string | undefined; + reason?: string; + checks: number; + elapsedMs: number; + }; + +interface TransactionStatusPoll { + status: string | undefined; + reason?: string; +} + +interface TransactionConfirmationPoll extends TransactionStatusPoll { + checks: number; + lastPollingError?: unknown; +} + +interface JsonRpcRequestor { + request: (method: string, params: unknown[]) => Promise; +} + +interface MaybeJsonRpcClient { + requestor?: unknown; +} + +/** + * Sends a transaction and waits until CKB reports a committed or terminal status. + * + * @remarks Lifecycle callbacks are observation-only: callback failures are ignored + * and do not change send or confirmation behavior. Pending poll failures keep the + * transaction in the wait loop until timeout. + * + * @throws TransactionConfirmationError-shaped error when the transaction times + * out after broadcast or reaches a terminal non-committed status. The error has + * `name`, `txHash`, `status`, `isTimeout`, and optional `reason` fields. + * + * @public + */ +export async function sendAndWaitForCommit( + { client, signer }: { client: ccc.Client; signer: ccc.Signer }, + tx: ccc.Transaction, + { + maxConfirmationChecks = 60, + confirmationIntervalMs = 10_000, + onSent, + onConfirmationWait, + onLifecycle, + sleep = cccSleep, + }: SendAndWaitForCommitOptions = {}, +): Promise { + const startedAt = Date.now(); + const requestor = transactionStatusRequestor(client); + const txHash = await sendTransactionWithLifecycle(signer, tx, onLifecycle, startedAt); + onSent?.(txHash); + notifyLifecycle(onLifecycle, { + type: "broadcasted", + txHash, + elapsedMs: Date.now() - startedAt, + }); + const poll = await waitForTransactionStatus(client, requestor, txHash, { + maxConfirmationChecks, + confirmationIntervalMs, + onConfirmationWait, + sleep, + }); + + if (poll.status === "committed") { + notifyLifecycle(onLifecycle, { + type: "committed", + txHash, + status: poll.status, + checks: poll.checks, + elapsedMs: Date.now() - startedAt, + }); + return txHash; + } + + throw confirmationFailure(txHash, poll, onLifecycle, startedAt); +} + +async function sendTransactionWithLifecycle( + signer: ccc.Signer, + tx: ccc.Transaction, + onLifecycle: SendAndWaitForCommitOptions["onLifecycle"] | undefined, + startedAt: number, +): Promise { + try { + return await signer.sendTransaction(tx); + } catch (error) { + notifyLifecycle(onLifecycle, { + type: "pre_broadcast_failed", + error, + elapsedMs: Date.now() - startedAt, + }); + throw error; + } +} + +async function waitForTransactionStatus( + client: ccc.Client, + requestor: JsonRpcRequestor, + txHash: ccc.Hex, + options: Required< + Pick< + SendAndWaitForCommitOptions, + "maxConfirmationChecks" | "confirmationIntervalMs" | "sleep" + > + > & + Pick, +): Promise { + let status: string | undefined = "sent"; + let reason: string | undefined; + let lastPollingError: unknown; + let checks = 0; + while (checks < options.maxConfirmationChecks && isPendingStatus(status)) { + ({ status, reason, lastPollingError } = await pollTransactionStatus( + client, + requestor, + txHash, + )); + checks += 1; + if (!isPendingStatus(status) || checks >= options.maxConfirmationChecks) { + break; + } + options.onConfirmationWait?.(); + await options.sleep(options.confirmationIntervalMs); + } + return { + status, + ...(reason === undefined ? {} : { reason }), + checks, + lastPollingError, + }; +} + +async function pollTransactionStatus( + client: ccc.Client, + requestor: JsonRpcRequestor, + txHash: ccc.Hex, +): Promise { + try { + const poll = await getTransactionStatus(requestor, txHash); + if (poll.status === "rejected") { + await client.cache.clear(); + } + return poll; + } catch (error) { + return { status: "sent", lastPollingError: error }; + } +} + +function confirmationFailure( + txHash: ccc.Hex, + poll: TransactionConfirmationPoll, + onLifecycle: SendAndWaitForCommitOptions["onLifecycle"] | undefined, + startedAt: number, +): TransactionConfirmationError { + if (isPendingStatus(poll.status)) { + notifyLifecycle(onLifecycle, unresolvedBroadcastEvent(txHash, poll, startedAt)); + return new TransactionConfirmationError( + "Transaction confirmation timed out", + poll.lastPollingError === undefined ? undefined : { cause: poll.lastPollingError }, + txHash, + poll.status, + true, + undefined, + ); + } + + const status = poll.status; + notifyLifecycle(onLifecycle, { + type: "terminal_rejection", + txHash, + status, + ...(poll.reason === undefined ? {} : { reason: poll.reason }), + checks: poll.checks, + elapsedMs: Date.now() - startedAt, + }); + return new TransactionConfirmationError( + terminalStatusMessage(status, poll.reason), + undefined, + txHash, + status, + false, + poll.reason, + ); +} + +function unresolvedBroadcastEvent( + txHash: ccc.Hex, + poll: TransactionConfirmationPoll, + startedAt: number, +): SendAndWaitForCommitEvent { + return { + type: + poll.lastPollingError === undefined + ? "timeout_after_broadcast" + : "post_broadcast_unresolved", + txHash, + status: poll.status, + checks: poll.checks, + elapsedMs: Date.now() - startedAt, + ...(poll.lastPollingError === undefined ? {} : { error: poll.lastPollingError }), + }; +} + +function terminalStatusMessage(status: string, reason: string | undefined): string { + const message = `Transaction ended with status: ${status}`; + return reason === undefined ? message : `${message}: ${reason}`; +} + +function transactionStatusRequestor({ + requestor, +}: ccc.Client & MaybeJsonRpcClient): JsonRpcRequestor { + if (!isJsonRpcRequestor(requestor)) { + throw new TypeError("sendAndWaitForCommit requires a JSON-RPC client requestor"); + } + return requestor; +} + +function isJsonRpcRequestor(value: unknown): value is JsonRpcRequestor { + return ( + typeof value === "object" && + value !== null && + "request" in value && + typeof value.request === "function" + ); +} + +async function getTransactionStatus( + requestor: JsonRpcRequestor, + txHash: ccc.Hex, +): Promise { + const response = await requestor.request("get_transaction", [txHash, "0x1"]); + if (!isRecord(response)) { + return { status: undefined }; + } + const txStatus = response["tx_status"]; + if (!isRecord(txStatus)) { + return { status: undefined }; + } + const status = txStatus["status"]; + const reason = txStatus["reason"]; + return { + status: typeof status === "string" ? status : undefined, + ...(typeof reason === "string" ? { reason } : {}), + }; +} + +function notifyLifecycle( + onLifecycle: SendAndWaitForCommitOptions["onLifecycle"] | undefined, + event: SendAndWaitForCommitEvent, +): void { + try { + onLifecycle?.(event); + } catch { + // Observability callbacks must not change transaction send/confirmation behavior. + } +} + +function isPendingStatus( + status: string | undefined, +): status is undefined | "sent" | "pending" | "proposed" | "unknown" { + return ( + status === undefined || + status === "sent" || + status === "pending" || + status === "proposed" || + status === "unknown" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/sdk/src/send/send_and_wait_error.ts b/packages/sdk/src/send/send_and_wait_error.ts new file mode 100644 index 0000000..9bc3de8 --- /dev/null +++ b/packages/sdk/src/send/send_and_wait_error.ts @@ -0,0 +1,31 @@ +import type { ccc } from "@ckb-ccc/core"; + +/** + * Error thrown after a sent transaction times out or reaches a terminal failure. + * + * @public + */ +export class TransactionConfirmationError extends Error { + public readonly txHash: ccc.Hex; + public readonly status: string | undefined; + public readonly isTimeout: boolean; + public readonly reason: string | undefined; + + constructor( + ...[message, options, txHash, status, isTimeout, reason]: [ + message: string, + options: ErrorOptions | undefined, + txHash: ccc.Hex, + status: string | undefined, + isTimeout: boolean, + reason?: string, + ] + ) { + super(message, options); + this.name = "TransactionConfirmationError"; + this.txHash = txHash; + this.status = status; + this.isTimeout = isTimeout; + this.reason = reason; + } +} diff --git a/packages/sdk/src/withdrawal/withdrawal_best_fit.ts b/packages/sdk/src/withdrawal/withdrawal_best_fit.ts new file mode 100644 index 0000000..b696448 --- /dev/null +++ b/packages/sdk/src/withdrawal/withdrawal_best_fit.ts @@ -0,0 +1,215 @@ +import { + findBestAtOrBelow, + isBetterSelection, + pickBetterSelection, + prepareSelections, + selectByMasks, + selectGreedyDeposits, +} from "./withdrawal_best_fit_support.ts"; + +const BEST_FIT_SEARCH_CANDIDATES = 30; +export const DEFAULT_MAX_WITHDRAWAL_REQUESTS = 30; + +interface PartialSelection { + mask: number; + total: bigint; + score: bigint; +} + +interface BoundedSelection { + firstMask: number; + secondMask: number; + total: bigint; + score: bigint; +} + +export function selectReadyDeposits( + deposits: readonly T[], + maxAmount: bigint, + options: { + minCount?: number; + maxCount?: number; + score?: (deposit: T) => bigint; + } = {}, +): T[] { + const { minCount = 1, maxCount = DEFAULT_MAX_WITHDRAWAL_REQUESTS, score } = options; + const requiredCount = Math.max(1, minCount); + if ( + maxAmount <= 0n || + maxCount <= 0 || + requiredCount > maxCount || + deposits.length === 0 + ) { + return []; + } + + const bestFit = selectBoundedReadyDepositSubset(deposits, maxAmount, { + candidateLimit: BEST_FIT_SEARCH_CANDIDATES, + minCount: requiredCount, + maxCount, + ...(score === undefined ? {} : { score }), + }); + const greedy = selectGreedyDeposits( + deposits, + maxAmount, + maxCount, + requiredCount, + score, + ); + + return pickBetterSelection(deposits, bestFit, greedy, score); +} + +function selectBoundedReadyDepositSubset( + items: readonly T[], + maxAmount: bigint, + options: { + candidateLimit: number; + minCount: number; + maxCount: number; + score?: (item: T) => bigint; + }, +): T[] { + const { candidateLimit, minCount, maxCount } = options; + const scoreOf = options.score ?? ((item: T): bigint => item.udtValue); + const boundedItems = items.slice(0, candidateLimit); + const effectiveMaxCount = Math.min(maxCount, boundedItems.length); + if ( + maxAmount <= 0n || + minCount < 0 || + effectiveMaxCount < minCount || + boundedItems.length === 0 + ) { + return []; + } + + const split = Math.floor(boundedItems.length / 2); + const firstHalf = boundedItems.slice(0, split); + const secondHalf = boundedItems.slice(split); + const firstByCount = enumeratePartialSelections(firstHalf, scoreOf); + const secondByCount = enumeratePartialSelections(secondHalf, scoreOf).map( + (selections) => prepareSelections(selections, secondHalf.length), + ); + const best = findBestBoundedSelection({ + maxAmount, + minCount, + effectiveMaxCount, + firstByCount, + secondByCount, + firstLength: firstHalf.length, + secondLength: secondHalf.length, + }); + + return best === undefined + ? [] + : selectByMasks(firstHalf, best.firstMask).concat( + selectByMasks(secondHalf, best.secondMask), + ); +} + +function enumeratePartialSelections( + items: readonly T[], + scoreOf: (item: T) => bigint, +): PartialSelection[][] { + const groups = Array.from({ length: items.length + 1 }, (): PartialSelection[] => []); + const search = ( + ...[index, mask, count, total, score]: [ + index: number, + mask: number, + count: number, + total: bigint, + score: bigint, + ] + ): void => { + if (index === items.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- count ranges from 0 to items.length. + groups[count]!.push({ mask, total, score }); + return; + } + + search(index + 1, mask, count, total, score); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index is checked against items.length above. + const item = items[index]!; + search( + index + 1, + mask | (1 << index), + count + 1, + total + item.udtValue, + score + scoreOf(item), + ); + }; + + search(0, 0, 0, 0n, 0n); + return groups; +} + +function findBestBoundedSelection(options: { + maxAmount: bigint; + minCount: number; + effectiveMaxCount: number; + firstByCount: readonly PartialSelection[][]; + secondByCount: ReadonlyArray>; + firstLength: number; + secondLength: number; +}): BoundedSelection | undefined { + let best: BoundedSelection | undefined; + for (let firstCount = 0; firstCount <= options.effectiveMaxCount; firstCount += 1) { + for (const first of options.firstByCount[firstCount] ?? []) { + const candidate = bestBoundedCandidateForFirst(first, firstCount, options); + if ( + candidate !== undefined && + (best === undefined || + isBetterSelection(candidate, best, options.firstLength, options.secondLength)) + ) { + best = candidate; + } + } + } + return best; +} + +function bestBoundedCandidateForFirst( + first: PartialSelection, + firstCount: number, + options: { + maxAmount: bigint; + minCount: number; + effectiveMaxCount: number; + secondByCount: ReadonlyArray>; + firstLength: number; + secondLength: number; + }, +): BoundedSelection | undefined { + if (first.total > options.maxAmount) { + return undefined; + } + + let best: BoundedSelection | undefined; + const minSecondCount = Math.max(0, options.minCount - firstCount); + const maxSecondCount = options.effectiveMaxCount - firstCount; + for ( + let secondCount = minSecondCount; + secondCount <= maxSecondCount; + secondCount += 1 + ) { + const secondSelections = options.secondByCount[secondCount] ?? []; + const second = findBestAtOrBelow(secondSelections, options.maxAmount - first.total); + if (second === undefined) { + continue; + } + + const candidate = { + firstMask: first.mask, + secondMask: second.mask, + total: first.total + second.total, + score: first.score + second.score, + }; + if ( + best === undefined || + isBetterSelection(candidate, best, options.firstLength, options.secondLength) + ) { + best = candidate; + } + } + return best; +} diff --git a/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts b/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts new file mode 100644 index 0000000..20ddda8 --- /dev/null +++ b/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts @@ -0,0 +1,222 @@ +import { compareBigInt } from "@ickb/utils"; + +export function prepareSelections( + selections: Array<{ mask: number; total: bigint; score: bigint }>, + length: number, +): Array<{ + total: bigint; + selection: { mask: number; total: bigint; score: bigint }; +}> { + selections.sort((left, right) => { + const totalCompare = compareBigInt(left.total, right.total); + return totalCompare === 0 ? compareMask(left.mask, right.mask, length) : totalCompare; + }); + + const prepared: Array<{ + total: bigint; + selection: { mask: number; total: bigint; score: bigint }; + }> = []; + let best: { mask: number; total: bigint; score: bigint } | undefined; + for (const selection of selections) { + if (best === undefined || isBetterPartialSelection(selection, best, length)) { + best = selection; + } + prepared.push({ total: selection.total, selection: best }); + } + + return prepared; +} + +export function findBestAtOrBelow( + items: ReadonlyArray<{ + total: bigint; + selection: { mask: number; total: bigint; score: bigint }; + }>, + limit: bigint, +): { mask: number; total: bigint; score: bigint } | undefined { + let low = 0; + let high = items.length - 1; + let best: { mask: number; total: bigint; score: bigint } | undefined; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mid is inside the current binary-search bounds. + const item = items[mid]!; + + if (item.total <= limit) { + best = item.selection; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return best; +} + +export function isBetterSelection( + left: { firstMask: number; secondMask: number; total: bigint; score: bigint }, + right: { + firstMask: number; + secondMask: number; + total: bigint; + score: bigint; + }, + firstLength: number, + secondLength: number, +): boolean { + if (left.score !== right.score) { + return left.score > right.score; + } + + if (left.total !== right.total) { + return left.total > right.total; + } + + const firstCompare = compareMask(left.firstMask, right.firstMask, firstLength); + return ( + firstCompare < 0 || + (firstCompare === 0 && + compareMask(left.secondMask, right.secondMask, secondLength) < 0) + ); +} + +export 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) { + throw new Error(`Selection item ${String(i)} is missing`); + } + selected.push(item); + } + } + return selected; +} + +export function pickBetterSelection( + deposits: readonly T[], + left: T[], + right: T[], + score?: (deposit: T) => bigint, +): T[] { + if (left.length === 0) { + return right; + } + + if (right.length === 0) { + return left; + } + + if (score !== undefined) { + const leftScore = sumScore(left, score); + const rightScore = sumScore(right, score); + if (leftScore > rightScore) { + return left; + } + + if (rightScore > leftScore) { + return right; + } + } + + const leftTotal = sumUdtValue(left); + const rightTotal = sumUdtValue(right); + if (leftTotal > rightTotal) { + return left; + } + + if (rightTotal > leftTotal) { + return right; + } + + return compareSelectionOrder(deposits, left, right) <= 0 ? left : right; +} + +export function selectGreedyDeposits( + ...[deposits, maxAmount, maxCount, minCount, score]: [ + deposits: readonly T[], + maxAmount: bigint, + maxCount: number, + minCount: number, + score?: (deposit: T) => bigint, + ] +): T[] { + const selected: T[] = []; + const candidates = + score === undefined + ? deposits + : deposits.toSorted((left, right) => compareBigInt(score(right), score(left))); + let cumulative = 0n; + for (const deposit of candidates) { + if (selected.length >= maxCount) { + break; + } + if (cumulative + deposit.udtValue > maxAmount) { + continue; + } + cumulative += deposit.udtValue; + selected.push(deposit); + } + + return selected.length >= minCount ? selected : []; +} + +function isBetterPartialSelection( + left: { mask: number; total: bigint; score: bigint }, + right: { mask: number; total: bigint; score: bigint }, + length: number, +): boolean { + if (left.score !== right.score) { + return left.score > right.score; + } + if (left.total !== right.total) { + return left.total > right.total; + } + return compareMask(left.mask, right.mask, length) < 0; +} + +function 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) { + return leftHas ? -1 : 1; + } + } + return 0; +} + +function sumScore(deposits: readonly T[], score: (deposit: T) => bigint): bigint { + let total = 0n; + for (const deposit of deposits) { + total += score(deposit); + } + return total; +} + +function sumUdtValue(deposits: ReadonlyArray<{ udtValue: bigint }>): bigint { + let total = 0n; + for (const deposit of deposits) { + total += deposit.udtValue; + } + return total; +} + +function compareSelectionOrder( + deposits: readonly T[], + left: readonly T[], + right: readonly T[], +): number { + const leftSet = new Set(left); + const rightSet = new Set(right); + for (const deposit of deposits) { + const inLeft = leftSet.has(deposit); + const inRight = rightSet.has(deposit); + if (inLeft !== inRight) { + return inLeft ? -1 : 1; + } + } + return 0; +} diff --git a/packages/sdk/src/withdrawal/withdrawal_ring.ts b/packages/sdk/src/withdrawal/withdrawal_ring.ts new file mode 100644 index 0000000..f6011ef --- /dev/null +++ b/packages/sdk/src/withdrawal/withdrawal_ring.ts @@ -0,0 +1,46 @@ +import type { IckbDepositCell } from "@ickb/core"; +import { ringAnchorDeposits, ringSegmentAnchor } from "./withdrawal_ring_anchor.ts"; +import { + depositKey, + ringSegments, + ringTargetSegmentIndex, +} from "./withdrawal_ring_core.ts"; +import type { WithdrawalDepositCandidate } from "./withdrawal_selection_types.ts"; + +export type { RingSegment } from "./withdrawal_ring_core.ts"; +export { depositKey, ringSegmentAnchor, ringSegments, ringTargetSegmentIndex }; + +/** + * Returns a filter that excludes the ring anchor deposits from surplus selection. + * + * @public + */ +export function ringSurplusDepositFilter< + T extends WithdrawalDepositCandidate = IckbDepositCell, +>(poolDeposits: readonly T[]): (deposit: T) => boolean { + const anchors = new Set(ringAnchorDeposits(poolDeposits).map(depositKey)); + return (deposit) => !anchors.has(depositKey(deposit)); +} + +/** + * Returns the live anchor deposit required when withdrawing a non-anchor deposit. + * + * @public + */ +export function ringRequiredLiveDepositFor< + T extends WithdrawalDepositCandidate = IckbDepositCell, +>(poolDeposits: readonly T[]): (deposit: T) => T | undefined { + const anchors = new Map(); + for (const segment of ringSegments(poolDeposits)) { + const anchor = ringSegmentAnchor(segment.deposits); + if (anchor === undefined) { + continue; + } + for (const deposit of segment.deposits) { + if (depositKey(deposit) !== depositKey(anchor)) { + anchors.set(depositKey(deposit), anchor); + } + } + } + return (deposit) => anchors.get(depositKey(deposit)); +} diff --git a/packages/sdk/src/withdrawal/withdrawal_ring_anchor.ts b/packages/sdk/src/withdrawal/withdrawal_ring_anchor.ts new file mode 100644 index 0000000..11214e0 --- /dev/null +++ b/packages/sdk/src/withdrawal/withdrawal_ring_anchor.ts @@ -0,0 +1,43 @@ +import type { IckbDepositCell } from "@ickb/core"; +import { ringSegments } from "./withdrawal_ring_core.ts"; +import type { WithdrawalDepositCandidate } from "./withdrawal_selection_types.ts"; + +/** + * Returns the selected anchor deposit for each withdrawal ring segment. + * + * @public + */ +export function ringAnchorDeposits< + T extends WithdrawalDepositCandidate = IckbDepositCell, +>(poolDeposits: readonly T[]): T[] { + return ringSegments(poolDeposits) + .map((segment) => ringSegmentAnchor(segment.deposits)) + .filter((deposit): deposit is T => deposit !== undefined); +} + +/** + * Selects the live anchor deposit for one withdrawal ring segment. + * + * @public + */ +export function ringSegmentAnchor( + deposits: readonly T[], +): T | undefined { + let anchor: T | undefined; + for (const deposit of deposits) { + if (anchor === undefined || isBetterRingAnchor(deposit, anchor)) { + anchor = deposit; + } + } + return anchor; +} + +function isBetterRingAnchor( + candidate: T, + current: T, +): boolean { + if (candidate.isReady !== current.isReady) { + return !candidate.isReady; + } + return candidate.udtValue > current.udtValue; +} diff --git a/packages/sdk/src/withdrawal/withdrawal_ring_core.ts b/packages/sdk/src/withdrawal/withdrawal_ring_core.ts new file mode 100644 index 0000000..1d10fca --- /dev/null +++ b/packages/sdk/src/withdrawal/withdrawal_ring_core.ts @@ -0,0 +1,88 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell } from "@ickb/core"; +import type { WithdrawalDepositCandidate } from "./withdrawal_selection_types.ts"; + +const RING_EPOCHS = 180n; + +/** + * Ring segment of pool deposits grouped by maturity around the DAO cycle. + * + * @public + */ +export interface RingSegment { + /** Segment index in the ring. */ + index: number; + + /** Deposits assigned to this segment. */ + deposits: T[]; + + /** Total iCKB value of deposits in this segment. */ + udtValue: bigint; +} + +/** + * Returns the segment index containing the sampled tip epoch. + * + * @public + */ +export function ringTargetSegmentIndex( + tip: ccc.ClientBlockHeader, + segmentCount: number, +): number { + return ringSegmentIndex(tip.epoch, segmentCount); +} + +/** + * Splits pool deposits into power-of-two maturity ring segments. + * + * @public + */ +export function ringSegments( + poolDeposits: readonly T[], +): Array> { + const segmentCount = nextPowerOfTwo(poolDeposits.length); + const segments = Array.from({ length: segmentCount }, (_, index): RingSegment => ({ + index, + deposits: [], + udtValue: 0n, + })); + + for (const deposit of poolDeposits) { + const index = ringSegmentIndex(deposit.maturity, segmentCount); + const segment = segments[index]; + if (segment === undefined) { + throw new Error(`Ring segment ${String(index)} is missing`); + } + segment.deposits.push(deposit); + segment.udtValue += deposit.udtValue; + } + + return segments; +} + +/** Stable deposit identity based on its out point. */ +export function depositKey(deposit: WithdrawalDepositCandidate): string { + return deposit.cell.outPoint.toHex(); +} + +function ringSegmentIndex( + epoch: WithdrawalDepositCandidate["maturity"], + segmentCount: number, +): number { + const { denominator } = epoch; + if (denominator <= 0n) { + throw new Error("Epoch denominator must be positive"); + } + const scaled = epoch.integer * denominator + epoch.numerator; + const ring = RING_EPOCHS * denominator; + const wrapped = ((scaled % ring) + ring) % ring; + return Number((wrapped * BigInt(segmentCount)) / ring); +} + +function nextPowerOfTwo(value: number): number { + let power = 1; + while (power < value) { + power *= 2; + } + return power; +} diff --git a/packages/sdk/src/withdrawal/withdrawal_selection.ts b/packages/sdk/src/withdrawal/withdrawal_selection.ts new file mode 100644 index 0000000..667fea5 --- /dev/null +++ b/packages/sdk/src/withdrawal/withdrawal_selection.ts @@ -0,0 +1,212 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell } from "@ickb/core"; +import { compareBigInt } from "@ickb/utils"; +import { + DEFAULT_MAX_WITHDRAWAL_REQUESTS, + selectReadyDeposits, +} from "./withdrawal_best_fit.ts"; +import { depositKey } from "./withdrawal_ring.ts"; +import type { + ExactReadyWithdrawalSelectionOptions, + ReadyWithdrawalSelection, + ReadyWithdrawalSelectionOptions, + ScoredExactReadyWithdrawalSelectionOptions, + ScoredReadyWithdrawalSelectionOptions, + WithdrawalDepositCandidate, +} from "./withdrawal_selection_types.ts"; + +export { + ringRequiredLiveDepositFor, + ringSegmentAnchor, + ringSegments, + ringSurplusDepositFilter, + ringTargetSegmentIndex, +} from "./withdrawal_ring.ts"; +export type { RingSegment } from "./withdrawal_ring.ts"; +export type { + ReadyWithdrawalSelection, + ReadyWithdrawalSelectionOptions, + WithdrawalDepositCandidate, +} from "./withdrawal_selection_types.ts"; + +/** + * Selects ready deposits for a withdrawal request, returning an empty selection + * when the amount or count constraints cannot be satisfied. + * + * @public + */ +export function selectReadyWithdrawalDeposits< + T extends WithdrawalDepositCandidate = IckbDepositCell, +>(options: ReadyWithdrawalSelectionOptions): ReadyWithdrawalSelection { + assertReadyWithdrawalDeposits(options.readyDeposits); + return selectReadyWithdrawalDepositsWithScore(options); +} + +/** + * Returns distinct exact-count ready withdrawal selections across maturity buckets. + */ +export function selectExactReadyWithdrawalDepositCandidates< + T extends WithdrawalDepositCandidate = IckbDepositCell, +>( + options: ExactReadyWithdrawalSelectionOptions & { + score: (deposit: T) => bigint; + maturityBucket: (deposit: T) => bigint; + }, +): Array> { + assertReadyWithdrawalDeposits(options.readyDeposits); + const selections: Array> = []; + const seen = new Set(); + const indexByDeposit = new Map( + options.readyDeposits.map((deposit, index): [T, number] => [deposit, index]), + ); + const addSelection = (selection: ReadyWithdrawalSelection | undefined): void => { + if (selection === undefined) { + return; + } + + const key = selectionKey(selection.deposits, indexByDeposit); + if (seen.has(key)) { + return; + } + + seen.add(key); + selections.push(selection); + }; + + for (const bucket of uniqueBuckets(options.readyDeposits, options.maturityBucket)) { + const readyDeposits = options.readyDeposits.filter( + (deposit) => options.maturityBucket(deposit) <= bucket, + ); + const baseOptions = { + readyDeposits, + tip: options.tip, + maxAmount: options.maxAmount, + count: options.count, + ...(options.canSelectDeposit === undefined + ? {} + : { canSelectDeposit: options.canSelectDeposit }), + ...(options.requiredLiveDepositFor === undefined + ? {} + : { requiredLiveDepositFor: options.requiredLiveDepositFor }), + }; + addSelection( + selectReadyWithdrawalCandidateWithScore({ + ...baseOptions, + score: options.score, + }), + ); + addSelection(selectReadyWithdrawalCandidateWithScore(baseOptions)); + } + + return selections; +} + +export function assertReadyWithdrawalDeposits( + deposits: readonly WithdrawalDepositCandidate[], +): void { + const seen = new Set(); + for (const deposit of deposits) { + const outPoint = depositKey(deposit); + if (!deposit.isReady) { + throw new Error(`Withdrawal deposit ${outPoint} is not ready`); + } + if (seen.has(outPoint)) { + throw new Error(`Withdrawal deposit ${outPoint} is duplicated`); + } + seen.add(outPoint); + } +} + +function selectReadyWithdrawalCandidateWithScore( + options: ScoredExactReadyWithdrawalSelectionOptions, +): ReadyWithdrawalSelection | undefined { + const { count, ...selectionOptions } = options; + const selection = selectReadyWithdrawalDepositsWithScore({ + ...selectionOptions, + minCount: count, + maxCount: count, + }); + + return selection.deposits.length === count ? selection : undefined; +} + +function selectReadyWithdrawalDepositsWithScore( + options: ScoredReadyWithdrawalSelectionOptions, +): ReadyWithdrawalSelection { + const { + tip, + maxAmount, + readyDeposits, + minCount = 1, + maxCount = DEFAULT_MAX_WITHDRAWAL_REQUESTS, + canSelectDeposit = (): boolean => true, + requiredLiveDepositFor, + score, + } = options; + const requiredCount = Math.max(1, minCount); + if ( + maxAmount <= 0n || + maxCount <= 0 || + requiredCount > maxCount || + readyDeposits.length === 0 + ) { + return { deposits: [], requiredLiveDeposits: [] }; + } + + const candidates = sortByMaturity(readyDeposits, tip).filter(canSelectDeposit); + return selectionWithRequiredLiveDeposits( + selectReadyDeposits(candidates, maxAmount, { + maxCount, + minCount: requiredCount, + ...(score === undefined ? {} : { score }), + }), + requiredLiveDepositFor, + ); +} + +function selectionWithRequiredLiveDeposits( + deposits: T[], + requiredLiveDepositFor?: (deposit: T) => T | undefined, +): ReadyWithdrawalSelection { + const requiredLiveDeposits: T[] = []; + const seen = new Set(deposits.map(depositKey)); + for (const deposit of deposits) { + const requiredLiveDeposit = requiredLiveDepositFor?.(deposit); + if (requiredLiveDeposit === undefined || seen.has(depositKey(requiredLiveDeposit))) { + continue; + } + seen.add(depositKey(requiredLiveDeposit)); + requiredLiveDeposits.push(requiredLiveDeposit); + } + + return { deposits, requiredLiveDeposits }; +} + +function sortByMaturity( + deposits: readonly T[], + tip: ccc.ClientBlockHeader, +): T[] { + return deposits.toSorted((left, right) => + compareBigInt(left.maturity.toUnix(tip), right.maturity.toUnix(tip)), + ); +} + +function uniqueBuckets(items: readonly T[], bucket: (item: T) => bigint): bigint[] { + return [...new Set(items.map(bucket))].toSorted(compareBigInt); +} + +function selectionKey( + items: readonly T[], + indexByItem: ReadonlyMap, +): string { + return items + .map((item) => { + const index = indexByItem.get(item); + if (index === undefined) { + throw new Error("Selection item index is missing"); + } + return String(index); + }) + .toSorted((left, right) => left.localeCompare(right)) + .join(","); +} diff --git a/packages/sdk/src/withdrawal/withdrawal_selection_types.ts b/packages/sdk/src/withdrawal/withdrawal_selection_types.ts new file mode 100644 index 0000000..0f7f0ea --- /dev/null +++ b/packages/sdk/src/withdrawal/withdrawal_selection_types.ts @@ -0,0 +1,84 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell } from "@ickb/core"; + +/** + * Minimal deposit shape accepted by withdrawal selection helpers. + * + * @public + */ +export interface WithdrawalDepositCandidate { + /** Deposit out point used for identity and duplicate filtering. */ + cell: { outPoint: { toHex: () => string } }; + + /** Whether the deposit is ready to request withdrawal. */ + isReady: boolean; + + /** iCKB value represented by the deposit. */ + udtValue: bigint; + + /** DAO maturity epoch used for ring and maturity ordering. */ + maturity: ccc.Epoch; +} + +/** + * Selected deposits plus required live anchors for owned-owner withdrawal. + * + * @public + */ +export interface ReadyWithdrawalSelection< + T extends WithdrawalDepositCandidate = IckbDepositCell, +> { + /** Deposits selected to spend into withdrawal requests. */ + deposits: T[]; + + /** Extra deposits to add as live cell deps without spending. */ + requiredLiveDeposits: T[]; +} + +/** + * Options for selecting ready deposits for one withdrawal request transaction. + * + * @public + */ +export interface ReadyWithdrawalSelectionOptions< + T extends WithdrawalDepositCandidate = IckbDepositCell, +> { + /** Ready deposits available for selection. */ + readyDeposits: readonly T[]; + + /** Sampled tip used for maturity ordering. */ + tip: ccc.ClientBlockHeader; + + /** Maximum iCKB amount to cover with selected deposits. */ + maxAmount: bigint; + + /** Minimum selected deposit count. Defaults to 1. */ + minCount?: number; + + /** Maximum selected deposit count. */ + maxCount?: number; + + /** Optional predicate for excluding deposits before selection. */ + canSelectDeposit?: (deposit: T) => boolean; + + /** Optional live anchor lookup for selected deposits. */ + requiredLiveDepositFor?: (deposit: T) => T | undefined; +} + +export type ScoredReadyWithdrawalSelectionOptions< + T extends WithdrawalDepositCandidate = IckbDepositCell, +> = ReadyWithdrawalSelectionOptions & { + score?: (deposit: T) => bigint; +}; + +export type ExactReadyWithdrawalSelectionOptions< + T extends WithdrawalDepositCandidate = IckbDepositCell, +> = Omit, "minCount" | "maxCount"> & { + count: number; +}; + +export type ScoredExactReadyWithdrawalSelectionOptions< + T extends WithdrawalDepositCandidate = IckbDepositCell, +> = ExactReadyWithdrawalSelectionOptions & { + score?: (deposit: T) => bigint; +}; diff --git a/packages/sdk/src/constants.test.ts b/packages/sdk/test/constants.ts similarity index 64% rename from packages/sdk/src/constants.test.ts rename to packages/sdk/test/constants.ts index 0fe31bf..5ae0c5d 100644 --- a/packages/sdk/src/constants.test.ts +++ b/packages/sdk/test/constants.ts @@ -1,9 +1,9 @@ import { ccc } from "@ckb-ccc/core"; +import { IckbUdt } from "@ickb/core"; import { outPoint, script as typeScript } from "@ickb/testkit"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { IckbUdt } from "@ickb/core"; -import { getConfig } from "./constants.js"; -import { IckbSdk } from "./sdk.js"; +import { getConfig } from "../src/constants.ts"; +import { IckbSdk } from "../src/sdk.ts"; function script(byte: string): ccc.Script { return ccc.Script.from({ @@ -48,31 +48,48 @@ describe("getConfig", () => { 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; + const client = new ccc.ClientPublicTestnet({ + url: "https://example.invalid", }); + const signer = new ccc.SignerCkbPrivateKey(client, `0x${"11".repeat(32)}`); + 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.Transaction.prototype, "completeFeeBy").mockResolvedValue([0, false]); expect(sdk).toBeInstanceOf(IckbSdk); const completed = await sdk.completeTransaction(tx, { signer, - client: {} as ccc.Client, + client, feeRate: 1n, }); expect(completeBy).toHaveBeenCalledWith(tx, signer); expect(completed.outputsData).toEqual(["0x01"]); }); +}); + +describe("getConfig defaults", () => { + it("resolves mainnet defaults and appends custom bot locks", () => { + const customBot = script("aa"); + const config = getConfig("mainnet", [customBot]); + const customBots = config.bots.filter((bot) => bot.eq(customBot)); + + expect(config.managers.ickbUdt.udtCode.toHex()).toBe( + "0xc07844ce21b38e4b071dd0e1ee3b0e27afd8d7532491327f39b786343f558ab700000000", + ); + expect(config.managers.ickbUdt.logicCode.toHex()).toBe( + "0xd7309191381f5a8a2904b8a79958a9be2752dbba6871fa193dab6aeb29dc8f4400000000", + ); + expect(customBots).toHaveLength(1); + expect(config.bots).toHaveLength(2); + }); it("rejects custom config missing an explicit code outpoint", () => { const dep = ccc.CellDep.from({ @@ -80,10 +97,10 @@ describe("getConfig", () => { depType: "depGroup", }); - expect(() => getConfig({ + const malformedConfig = { udt: { script: script("11"), - codeOutPoint: undefined as unknown as ccc.OutPointLike, + codeOutPoint: undefined, cellDeps: [dep], }, logic: { @@ -94,6 +111,10 @@ describe("getConfig", () => { 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"); + } as unknown as Parameters[0]; + + expect(() => getConfig(malformedConfig)).toThrow( + "custom config missing xUDT code outPoint", + ); }); }); diff --git a/packages/sdk/test/conversion/deposits_and_limits/build_conversion_bounded_fee_precision_failure.ts b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_bounded_fee_precision_failure.ts new file mode 100644 index 0000000..46e8578 --- /dev/null +++ b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_bounded_fee_precision_failure.ts @@ -0,0 +1,53 @@ +import { ccc } from "@ckb-ccc/core"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "./support/sdk_fixture_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +const AMOUNT_TOO_SMALL = "amount-too-small"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("returns a typed failure when bounded iCKB-to-CKB fee precision collapses", async () => { + const { sdk, lock } = testSdk(); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: ICKB_TO_CKB, + amount: 1n, + lock, + context: conversionContext({ + system: { + exchangeRatio: Ratio.from({ + ckbScale: (1n << 64n) - 1n, + udtScale: 1n, + }), + ckbAvailable: 1n, + poolDeposits: { + deposits: [], + readyDeposits: [], + id: "pool", + }, + }, + ckbAvailable: 0n, + ickbAvailable: 1n, + }), + }), + ).resolves.toEqual({ + ok: false, + reason: AMOUNT_TOO_SMALL, + estimatedMaturity: 0n, + }); + }); +}); diff --git a/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_construction_errors.ts b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_construction_errors.ts new file mode 100644 index 0000000..9d13ca6 --- /dev/null +++ b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_construction_errors.ts @@ -0,0 +1,60 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + expectCkbToIckbDirectRetryBuild, + mockPassthroughMint, + mockUnitDeposit, + testSdk, +} from "./support/sdk_fixture_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const DAO_OUTPUT_LIMIT_ERROR_NAME = "DaoOutputLimitError"; + +const RPC_FAILED = "RPC failed"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("recognizes DAO output-limit errors across package runtime boundaries", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const outputLimitError = new Error("same domain error from another package copy"); + Object.defineProperty(outputLimitError, "name", { + value: DAO_OUTPUT_LIMIT_ERROR_NAME, + }); + const deposit = mockUnitDeposit(logicManager).mockRejectedValueOnce(outputLimitError); + mockPassthroughMint(orderManager); + + await expectCkbToIckbDirectRetryBuild(sdk, lock); + + expect(deposit).toHaveBeenCalledTimes(2); + }); + + it("fails fast on non-retryable CKB-to-iCKB construction errors", async () => { + const { sdk, logicManager, lock } = testSdk(); + const deposit = vi + .spyOn(logicManager, "deposit") + .mockRejectedValue(new Error(RPC_FAILED)); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: "ckb-to-ickb", + amount: ICKB_DEPOSIT_CAP * 2n, + lock, + context: conversionContext({ + system: { ckbAvailable: ICKB_DEPOSIT_CAP * 2n }, + ckbAvailable: ICKB_DEPOSIT_CAP * 2n, + ickbAvailable: 0n, + }), + }), + ).rejects.toThrow(RPC_FAILED); + + expect(deposit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_deposit_cap.ts b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_deposit_cap.ts new file mode 100644 index 0000000..3796bdd --- /dev/null +++ b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_deposit_cap.ts @@ -0,0 +1,48 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "./support/sdk_fixture_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const maxDirectDeposits = 60; + +const CKB_TO_ICKB = "ckb-to-ickb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("caps CKB-to-iCKB direct deposits", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const deposit = vi + .spyOn(logicManager, "deposit") + .mockImplementation(async (txLike, quantity) => { + await Promise.resolve(); + expect(quantity).toBe(maxDirectDeposits); + return ccc.Transaction.from(txLike); + }); + vi.spyOn(orderManager, "mint").mockImplementation((txLike) => + ccc.Transaction.from(txLike), + ); + + await sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: ICKB_DEPOSIT_CAP * BigInt(maxDirectDeposits + 1), + lock, + context: conversionContext({ + system: { ckbAvailable: ICKB_DEPOSIT_CAP }, + ckbAvailable: ICKB_DEPOSIT_CAP * BigInt(maxDirectDeposits + 1), + ickbAvailable: 0n, + }), + }); + + expect(deposit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_deposit_priority.ts b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_deposit_priority.ts new file mode 100644 index 0000000..035c693 --- /dev/null +++ b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_deposit_priority.ts @@ -0,0 +1,73 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "./support/sdk_fixture_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const CKB_TO_ICKB = "ckb-to-ickb"; + +const DIRECT_PLUS_ORDER = "direct-plus-order"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("plans CKB-to-iCKB direct deposits before fallback orders", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const calls: string[] = []; + const remainder = ccc.fixedPointFrom(10000); + const deposit = vi + .spyOn(logicManager, "deposit") + .mockImplementation(async (txLike, quantity, depositCapacity, depositLock) => { + await Promise.resolve(); + calls.push(`deposit:${String(quantity)}`); + expect(depositCapacity).toBe(ICKB_DEPOSIT_CAP); + expect(depositLock).toBe(lock); + const tx = ccc.Transaction.from(txLike); + tx.outputs.push(ccc.CellOutput.from({ capacity: 1n, lock })); + tx.outputsData.push("0x"); + return tx; + }); + const mint = vi + .spyOn(orderManager, "mint") + .mockImplementation((txLike, _lock, _info, amounts) => { + calls.push("order"); + expect(amounts).toEqual({ ckbValue: remainder, udtValue: 0n }); + const tx = ccc.Transaction.from(txLike); + expect(tx.outputs).toHaveLength(1); + tx.outputs.push(ccc.CellOutput.from({ capacity: 2n, lock })); + tx.outputsData.push("0x"); + return tx; + }); + + const result = await sdk.buildConversionTransaction( + ccc.Transaction.default(), + baseClient, + { + direction: CKB_TO_ICKB, + amount: ICKB_DEPOSIT_CAP * 2n + remainder, + lock, + context: conversionContext({ + system: { ckbAvailable: ICKB_DEPOSIT_CAP * 3n }, + ckbAvailable: ICKB_DEPOSIT_CAP * 2n + remainder, + ickbAvailable: 0n, + }), + }, + ); + + expect(result).toMatchObject({ + ok: true, + conversion: { kind: DIRECT_PLUS_ORDER }, + }); + expect(deposit).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + expect(calls).toEqual(["deposit:2", "order"]); + }); +}); diff --git a/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_output_limit_retry.ts b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_output_limit_retry.ts new file mode 100644 index 0000000..3d8da36 --- /dev/null +++ b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ckb_to_ickb_output_limit_retry.ts @@ -0,0 +1,68 @@ +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { DaoOutputLimitError } from "@ickb/dao"; +import { passthroughTransaction } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, + transactionWithOutputs, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + expectCkbToIckbDirectRetryBuild, + mockPassthroughMint, + mockUnitDeposit, + testSdk, +} from "./support/sdk_fixture_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const CKB_TO_ICKB = "ckb-to-ickb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("retries CKB-to-iCKB direct deposits after DAO output-limit failures", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const deposit = mockUnitDeposit(logicManager).mockRejectedValueOnce( + new DaoOutputLimitError(65), + ); + mockPassthroughMint(orderManager); + + await expectCkbToIckbDirectRetryBuild(sdk, lock); + + expect(deposit).toHaveBeenCalledTimes(2); + }); + + it("skips predictably oversized CKB-to-iCKB candidates before building", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const quantities: number[] = []; + const deposit = vi + .spyOn(logicManager, "deposit") + .mockImplementation(async (txLike, quantity) => { + await Promise.resolve(); + quantities.push(quantity); + return passthroughTransaction(txLike); + }); + vi.spyOn(orderManager, "mint").mockImplementation(passthroughTransaction); + + await expect( + sdk.buildConversionTransaction(transactionWithOutputs(60, lock), baseClient, { + direction: CKB_TO_ICKB, + amount: ICKB_DEPOSIT_CAP * 2n + 1n, + lock, + context: conversionContext({ + system: { ckbAvailable: ICKB_DEPOSIT_CAP * 3n }, + ckbAvailable: ICKB_DEPOSIT_CAP * 2n + 1n, + ickbAvailable: 0n, + }), + }), + ).resolves.toMatchObject({ + ok: true, + conversion: { kind: "direct-plus-order" }, + }); + + expect(deposit).toHaveBeenCalledTimes(1); + expect(quantities).toEqual([1]); + }); +}); diff --git a/packages/sdk/test/conversion/deposits_and_limits/build_conversion_fee_precision_uint64_failures.ts b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_fee_precision_uint64_failures.ts new file mode 100644 index 0000000..8740654 --- /dev/null +++ b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_fee_precision_uint64_failures.ts @@ -0,0 +1,82 @@ +import { ccc } from "@ckb-ccc/core"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "./support/sdk_fixture_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +const AMOUNT_TOO_SMALL = "amount-too-small"; + +const CKB_TO_ICKB = "ckb-to-ickb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("returns a typed failure when default iCKB-to-CKB fee precision exceeds Uint64", async () => { + const { sdk, lock } = testSdk(); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: ICKB_TO_CKB, + amount: 1n, + lock, + context: conversionContext({ + system: { + exchangeRatio: Ratio.from({ + ckbScale: (1n << 64n) - 1n, + udtScale: (1n << 64n) - 2n, + }), + ckbAvailable: 1n, + poolDeposits: { + deposits: [], + readyDeposits: [], + id: "pool", + }, + }, + ckbAvailable: 0n, + ickbAvailable: 1n, + }), + }), + ).resolves.toEqual({ + ok: false, + reason: AMOUNT_TOO_SMALL, + estimatedMaturity: 0n, + }); + }); + + it("returns a typed failure when default CKB-to-iCKB fee precision exceeds Uint64", async () => { + const { sdk, lock } = testSdk(); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: 1n, + lock, + context: conversionContext({ + system: { + exchangeRatio: Ratio.from({ + ckbScale: (1n << 64n) - 1n, + udtScale: (1n << 64n) - 2n, + }), + ckbAvailable: 0n, + }, + ckbAvailable: 1n, + ickbAvailable: 0n, + }), + }), + ).resolves.toEqual({ + ok: false, + reason: AMOUNT_TOO_SMALL, + estimatedMaturity: 0n, + }); + }); +}); diff --git a/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ickb_to_ckb_output_limit_exhaustion.ts b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ickb_to_ckb_output_limit_exhaustion.ts new file mode 100644 index 0000000..6f55ad7 --- /dev/null +++ b/packages/sdk/test/conversion/deposits_and_limits/build_conversion_ickb_to_ckb_output_limit_exhaustion.ts @@ -0,0 +1,156 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { DaoOutputLimitError } from "@ickb/dao"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + system, + transactionWithOutputs, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + placeholderWithdrawal, + projectionReadyDeposit, +} from "../withdrawal_quotes/support/sdk_cell_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "./support/sdk_fixture_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +const DIRECT_PLUS_ORDER = "direct-plus-order"; + +const CKB_TO_ICKB = "ckb-to-ickb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("skips predictably oversized iCKB-to-CKB candidates before building", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const first = projectionReadyDeposit(ICKB_DEPOSIT_CAP / 2n, 0n); + const second = projectionReadyDeposit(ICKB_DEPOSIT_CAP / 2n, 15n * 60n * 1000n); + const requestedCounts: number[] = []; + const requestWithdrawal = vi + .spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + requestedCounts.push(deposits.length); + return ccc.Transaction.from(txLike); + }); + vi.spyOn(orderManager, "mint").mockImplementation((txLike) => + ccc.Transaction.from(txLike), + ); + + await expect( + sdk.buildConversionTransaction(transactionWithOutputs(60, lock), baseClient, { + direction: ICKB_TO_CKB, + amount: ICKB_DEPOSIT_CAP + 1n, + lock, + context: { + system: system({ + poolDeposits: { + deposits: [first, second], + readyDeposits: [first, second], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP + 1n, + estimatedMaturity: 0n, + }, + }), + ).resolves.toMatchObject({ + ok: true, + conversion: { kind: DIRECT_PLUS_ORDER }, + }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(requestedCounts).toEqual([1]); + }); +}); + +describe(`${BUILD_CONVERSION_TRANSACTION_SUITE} output-limit exhaustion`, () => { + it("reports predictable DAO output-limit exhaustion", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const deposit = vi + .spyOn(logicManager, "deposit") + .mockImplementation(async (txLike) => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }); + const mint = vi + .spyOn(orderManager, "mint") + .mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect( + sdk.buildConversionTransaction(transactionWithOutputs(64, lock), baseClient, { + direction: CKB_TO_ICKB, + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), + receipts: [], + readyWithdrawals: [placeholderWithdrawal], + availableOrders: [], + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + }), + ).rejects.toThrow(DaoOutputLimitError); + + expect(deposit).not.toHaveBeenCalled(); + expect(mint).not.toHaveBeenCalled(); + }); + + it("reports predictable iCKB-to-CKB DAO output-limit exhaustion", async () => { + const { sdk, ownedOwnerManager, lock } = testSdk(); + const anchorDeposit = projectionReadyDeposit(ICKB_DEPOSIT_CAP + 1n, 0n, { + id: "ab", + }); + const readyDeposit = projectionReadyDeposit(ICKB_DEPOSIT_CAP, 1n, { + ckbValue: ICKB_DEPOSIT_CAP * ((1n << 64n) - 1n), + }); + const requestWithdrawal = vi + .spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike) => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }); + + await expect( + sdk.buildConversionTransaction(transactionWithOutputs(64, lock), baseClient, { + direction: ICKB_TO_CKB, + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ + exchangeRatio: Ratio.from({ + ckbScale: (1n << 64n) - 1n, + udtScale: 1n, + }), + poolDeposits: { + deposits: [anchorDeposit, readyDeposit], + readyDeposits: [anchorDeposit, readyDeposit], + id: "pool", + }, + }), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + estimatedMaturity: 0n, + }, + }), + ).rejects.toThrow(DaoOutputLimitError); + + expect(requestWithdrawal).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/test/conversion/deposits_and_limits/support/sdk_fixture_support.ts b/packages/sdk/test/conversion/deposits_and_limits/support/sdk_fixture_support.ts new file mode 100644 index 0000000..5901f93 --- /dev/null +++ b/packages/sdk/test/conversion/deposits_and_limits/support/sdk_fixture_support.ts @@ -0,0 +1,270 @@ +import { ccc } from "@ckb-ccc/core"; +import { + ICKB_DEPOSIT_CAP, + IckbUdt, + LogicManager, + OwnedOwnerManager, + type IckbDepositCell, +} from "@ickb/core"; +import { DaoManager } from "@ickb/dao"; +import { OrderManager, type Ratio } from "@ickb/order"; +import { + asyncPassthroughTransaction, + passthroughTransaction, + script, +} from "@ickb/testkit"; +import { expect, vi, type MockInstance } from "vitest"; +import { IckbSdk } from "../../../../src/sdk.ts"; +import { + baseClient, + conversionContext, + hash, +} from "../../../transaction/base/support/sdk_core_support.ts"; + +export { + BUILD_BASE_TRANSACTION_SUITE, + BUILD_CONVERSION_TRANSACTION_SUITE, +} from "../../../transaction/complete/support/sdk_suite_titles.ts"; + +interface WithdrawalRemainderOrderMocks { + mint: MockInstance; + requestWithdrawal: MockInstance; +} + +const CKB_TO_ICKB = "ckb-to-ickb"; +const ICKB_TO_CKB = "ickb-to-ckb"; +const DIRECT_PLUS_ORDER = "direct-plus-order"; + +export function baseTransactionFixture( + options: { + daoDeps?: ccc.CellDep[]; + logicDeps?: ccc.CellDep[]; + orderDeps?: ccc.CellDep[]; + ownedOwnerDeps?: ccc.CellDep[]; + } = {}, +): BaseTransactionFixture { + 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, options.daoDeps ?? []); + const logicManager = new LogicManager(logic, options.logicDeps ?? [], daoManager); + const ownedOwnerManager = new OwnedOwnerManager( + ownedOwner, + options.ownedOwnerDeps ?? [], + daoManager, + ); + const orderManager = new OrderManager(order, options.orderDeps ?? [], udt); + return { + botLock, + dao, + logic, + logicManager, + order, + orderManager, + ownedOwner, + ownedOwnerManager, + sdk: new IckbSdk(fakeIckbUdt(udt), ownedOwnerManager, logicManager, orderManager, [ + botLock, + ]), + udt, + }; +} + +export interface BaseTransactionFixture { + botLock: ccc.Script; + dao: ccc.Script; + logic: ccc.Script; + logicManager: LogicManager; + order: ccc.Script; + orderManager: OrderManager; + ownedOwner: ccc.Script; + ownedOwnerManager: OwnedOwnerManager; + sdk: IckbSdk; + udt: ccc.Script; +} + +export function testSdk(): SdkFixture { + const lock = script("11"); + const logicManager = new LogicManager( + script("22"), + [], + new DaoManager(script("33"), []), + ); + const ownedOwnerManager = new OwnedOwnerManager( + script("44"), + [], + new DaoManager(script("33"), []), + ); + const orderManager = new OrderManager(script("55"), [], script("66")); + const ickbUdt = fakeIckbUdt(); + return { + sdk: new IckbSdk(ickbUdt, ownedOwnerManager, logicManager, orderManager, []), + ickbUdt, + logicManager, + ownedOwnerManager, + orderManager, + lock, + }; +} + +export interface SdkFixture { + sdk: IckbSdk; + ickbUdt: ReturnType; + logicManager: LogicManager; + ownedOwnerManager: OwnedOwnerManager; + orderManager: OrderManager; + lock: ccc.Script; +} + +export function fakeIckbUdt(udt = script("66")): IckbUdt { + return new TestIckbUdt(udt); +} + +export function signerWithLock(lock: ccc.Script): ccc.Signer { + return new TestSigner(undefined, lock); +} + +export function signerWithSendTransaction( + sendTransaction: ccc.Signer["sendTransaction"], +): ccc.Signer { + return new TestSigner(sendTransaction); +} + +class TestSigner extends ccc.SignerCkbScriptReadonly { + public override sendTransaction: ccc.Signer["sendTransaction"]; + + constructor( + sendTransaction: ccc.Signer["sendTransaction"] = defaultSendTransaction, + lock = script("11"), + ) { + super(baseClient, lock); + this.sendTransaction = sendTransaction; + } +} + +async function defaultSendTransaction(): Promise< + Awaited> +> { + await Promise.resolve(); + return hash("ff"); +} + +export function mockPassthroughMint(orderManager: OrderManager): void { + vi.spyOn(orderManager, "mint").mockImplementation(passthroughTransaction); +} + +export function mockUnitDeposit( + logicManager: LogicManager, +): MockInstance { + return vi + .spyOn(logicManager, "deposit") + .mockImplementation(async (txLike, quantity) => { + await Promise.resolve(); + expect(quantity).toBe(1); + return passthroughTransaction(txLike); + }); +} + +export async function expectCkbToIckbDirectRetryBuild( + sdk: IckbSdk, + lock: ccc.Script, +): Promise { + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: ICKB_DEPOSIT_CAP * 2n, + lock, + context: conversionContext({ + system: { ckbAvailable: ICKB_DEPOSIT_CAP * 2n }, + ckbAvailable: ICKB_DEPOSIT_CAP * 2n, + ickbAvailable: 0n, + }), + }), + ).resolves.toMatchObject({ + ok: true, + conversion: { kind: DIRECT_PLUS_ORDER }, + }); +} + +export async function expectIckbToCkbDirectPlusOrder(options: { + sdk: IckbSdk; + lock: ccc.Script; + deposits: IckbDepositCell[]; + exchangeRatio: ReturnType; +}): Promise { + await expect( + options.sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: ICKB_TO_CKB, + amount: ICKB_DEPOSIT_CAP, + lock: options.lock, + context: conversionContext({ + system: { + exchangeRatio: options.exchangeRatio, + ckbAvailable: 10n, + poolDeposits: { + deposits: options.deposits, + readyDeposits: options.deposits, + id: "pool", + }, + }, + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + }), + }), + ).resolves.toMatchObject({ + ok: true, + conversion: { kind: DIRECT_PLUS_ORDER }, + }); +} + +export function mockWithdrawalWithRemainderOrder( + fixture: Pick, + expectedDeposits: unknown, + expectedAmounts: { ckbValue: bigint; udtValue: bigint }, +): WithdrawalRemainderOrderMocks { + const requestWithdrawal = vi + .spyOn(fixture.ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + expect(deposits).toEqual(expectedDeposits); + return passthroughTransaction(txLike); + }); + const mint = vi + .spyOn(fixture.orderManager, "mint") + .mockImplementation((txLike, _lock, _info, amounts) => { + expect(amounts).toEqual(expectedAmounts); + return passthroughTransaction(txLike); + }); + return { mint, requestWithdrawal }; +} + +class TestIckbUdt extends IckbUdt { + public readonly completeByMock = vi.fn( + async (txLike: ccc.TransactionLike): Promise => { + return asyncPassthroughTransaction(txLike); + }, + ); + + constructor(udt: ccc.Script) { + super( + { txHash: hash("a1"), index: 0n }, + udt, + { txHash: hash("a2"), index: 0n }, + script("a3"), + new DaoManager(script("a4"), []), + ); + } + + public override isUdt(cell: ccc.Cell): boolean { + return cell.cellOutput.type?.eq(this.script) ?? false; + } + + public override async completeBy( + txLike: ccc.TransactionLike, + ): Promise { + return this.completeByMock(txLike); + } +} diff --git a/packages/sdk/test/conversion/planning/build_conversion_activity_and_tiny_order_failures.ts b/packages/sdk/test/conversion/planning/build_conversion_activity_and_tiny_order_failures.ts new file mode 100644 index 0000000..095de0c --- /dev/null +++ b/packages/sdk/test/conversion/planning/build_conversion_activity_and_tiny_order_failures.ts @@ -0,0 +1,169 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + mockPassthroughMint, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { placeholderWithdrawal } from "../withdrawal_quotes/support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const CKB_TO_ICKB = "ckb-to-ickb"; +const ICKB_TO_CKB = "ickb-to-ckb"; + +const AMOUNT_TOO_SMALL = "amount-too-small"; +const FULL_WORKSPACE_TIMEOUT_MS = 20_000; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("returns typed failures for invalid requested amounts", async () => { + const { sdk, lock } = testSdk(); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: -1n, + lock, + context: conversionContext(), + }), + ).resolves.toMatchObject({ ok: false, reason: "amount-negative" }); + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: 2n, + lock, + context: conversionContext({ ckbAvailable: 1n }), + }), + ).resolves.toMatchObject({ ok: false, reason: "insufficient-ckb" }); + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: ICKB_TO_CKB, + amount: 2n, + lock, + context: conversionContext({ ickbAvailable: 1n }), + }), + ).resolves.toMatchObject({ ok: false, reason: "insufficient-ickb" }); + }); + + it("returns typed failures for no activity and tiny orders", async () => { + const { sdk, lock } = testSdk(); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: 0n, + lock, + context: conversionContext(), + }), + ).resolves.toEqual({ + ok: false, + reason: "nothing-to-do", + estimatedMaturity: 0n, + }); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: 1n, + lock, + context: conversionContext({ + ckbAvailable: 1n, + ickbAvailable: 0n, + }), + }), + ).resolves.toEqual({ + ok: false, + reason: AMOUNT_TOO_SMALL, + estimatedMaturity: 0n, + }); + }); + + it("returns collect-only success when the base transaction already has activity", async () => { + const { sdk, lock } = testSdk(); + const tx = ccc.Transaction.default(); + tx.addOutput({ lock }, "0x"); + + await expect( + sdk.buildConversionTransaction(tx, baseClient, { + direction: CKB_TO_ICKB, + amount: 0n, + lock, + context: conversionContext(), + }), + ).resolves.toMatchObject({ ok: true, conversion: { kind: "collect-only" } }); + }); +}); + +describe(`${BUILD_CONVERSION_TRANSACTION_SUITE} order-only paths`, () => { + it("builds an order-only CKB-to-iCKB conversion", async () => { + const { sdk, lock, orderManager } = testSdk(); + mockPassthroughMint(orderManager); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: ccc.fixedPointFrom(1), + lock, + context: conversionContext({ + system: { ckbAvailable: ccc.fixedPointFrom(1) }, + ckbAvailable: ccc.fixedPointFrom(1), + }), + limits: { maxDirectDeposits: 0 }, + }), + ).resolves.toMatchObject({ ok: true, conversion: { kind: "order" } }); + }); + + it( + "builds an order-only iCKB-to-CKB conversion while collecting ready withdrawals", + async () => { + const { sdk, lock, orderManager, ownedOwnerManager } = testSdk(); + mockPassthroughMint(orderManager); + vi.spyOn(ownedOwnerManager, "withdraw").mockImplementation(async (txLike) => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: ICKB_TO_CKB, + amount: ccc.fixedPointFrom(1), + lock, + context: conversionContext({ + readyWithdrawals: [placeholderWithdrawal], + ickbAvailable: ccc.fixedPointFrom(1), + }), + }), + ).resolves.toMatchObject({ ok: true, conversion: { kind: "order" } }); + }, + FULL_WORKSPACE_TIMEOUT_MS, + ); +}); + +describe(`${BUILD_CONVERSION_TRANSACTION_SUITE} pool refresh`, () => { + it("uses freshly scanned pool deposits when iCKB-to-CKB context has no pool snapshot", async () => { + const { sdk, lock } = testSdk(); + const getPoolDeposits = vi.spyOn(sdk, "getPoolDeposits").mockResolvedValue({ + deposits: [], + readyDeposits: [], + id: "fresh", + }); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: ICKB_TO_CKB, + amount: 1n, + lock, + context: conversionContext({ + ickbAvailable: 1n, + }), + }), + ).resolves.toMatchObject({ ok: false, reason: AMOUNT_TOO_SMALL }); + expect(getPoolDeposits).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/planning/build_conversion_construction_failure_messages.ts b/packages/sdk/test/conversion/planning/build_conversion_construction_failure_messages.ts new file mode 100644 index 0000000..d1edee4 --- /dev/null +++ b/packages/sdk/test/conversion/planning/build_conversion_construction_failure_messages.ts @@ -0,0 +1,89 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { DaoOutputLimitError } from "@ickb/dao"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ConversionTransactionOptions } from "../../../src/sdk.ts"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const CKB_TO_ICKB = "ckb-to-ickb"; + +const RPC_FAILED = "RPC failed"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("preserves retryable construction errors when retries exhaust into planning misses", async () => { + const { sdk, logicManager, lock } = testSdk(); + vi.spyOn(logicManager, "deposit").mockRejectedValue(new DaoOutputLimitError(65)); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: ICKB_DEPOSIT_CAP, + lock, + context: conversionContext({ + system: { + ckbAvailable: ICKB_DEPOSIT_CAP, + feeRate: ccc.fixedPointFrom(1), + }, + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + }), + }), + ).rejects.toBeInstanceOf(DaoOutputLimitError); + }); + + it("uses plain-object error messages in conversion construction failures", async () => { + const { sdk, logicManager, lock } = testSdk(); + vi.spyOn(logicManager, "deposit").mockRejectedValue({ + message: RPC_FAILED, + }); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: CKB_TO_ICKB, + amount: ICKB_DEPOSIT_CAP, + lock, + context: conversionContext({ + system: { ckbAvailable: ICKB_DEPOSIT_CAP }, + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + }), + }), + ).rejects.toThrow(RPC_FAILED); + }); + + it("uses string and bigint object error messages in conversion construction failures", async () => { + const { sdk, logicManager, lock } = testSdk(); + vi.spyOn(logicManager, "deposit") + .mockRejectedValueOnce(RPC_FAILED) + .mockRejectedValueOnce({ code: 1n }); + + const options: ConversionTransactionOptions = { + direction: CKB_TO_ICKB, + amount: ICKB_DEPOSIT_CAP, + lock, + context: conversionContext({ + system: { ckbAvailable: ICKB_DEPOSIT_CAP }, + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + }), + }; + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, options), + ).rejects.toThrow(RPC_FAILED); + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, options), + ).rejects.toThrow('{"code":"1"}'); + }); +}); diff --git a/packages/sdk/test/conversion/planning/build_conversion_dao_output_accounting.ts b/packages/sdk/test/conversion/planning/build_conversion_dao_output_accounting.ts new file mode 100644 index 0000000..88acb95 --- /dev/null +++ b/packages/sdk/test/conversion/planning/build_conversion_dao_output_accounting.ts @@ -0,0 +1,87 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + hash, + system, + transactionWithOutputs, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { + placeholderReceipt, + placeholderWithdrawal, +} from "../withdrawal_quotes/support/sdk_cell_support.ts"; +import { placeholderOrder } from "./support/sdk_order_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const CKB_TO_ICKB = "ckb-to-ickb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("does not count input-only base activities as planned DAO outputs", async () => { + const { sdk, logicManager, ownedOwnerManager, orderManager, lock } = testSdk(); + vi.spyOn(orderManager, "melt").mockImplementation((txLike) => { + const tx = ccc.Transaction.from(txLike); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { txHash: hash("c1"), index: 0n }, + }), + ); + return tx; + }); + vi.spyOn(logicManager, "completeDeposit").mockImplementation((txLike) => { + const tx = ccc.Transaction.from(txLike); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { txHash: hash("c2"), index: 0n }, + }), + ); + return tx; + }); + vi.spyOn(ownedOwnerManager, "withdraw").mockImplementation(async (txLike) => { + await Promise.resolve(); + const tx = ccc.Transaction.from(txLike); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { txHash: hash("c3"), index: 0n }, + }), + ); + return tx; + }); + const deposit = vi + .spyOn(logicManager, "deposit") + .mockImplementation(async (txLike) => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }); + const mint = vi + .spyOn(orderManager, "mint") + .mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect( + sdk.buildConversionTransaction(transactionWithOutputs(62, lock), baseClient, { + direction: CKB_TO_ICKB, + amount: ICKB_DEPOSIT_CAP, + lock, + context: { + system: system({ ckbAvailable: ICKB_DEPOSIT_CAP }), + receipts: [placeholderReceipt], + readyWithdrawals: [placeholderWithdrawal], + availableOrders: [placeholderOrder], + ckbAvailable: ICKB_DEPOSIT_CAP, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + }), + ).resolves.toMatchObject({ ok: true, conversion: { kind: "direct" } }); + + expect(deposit).toHaveBeenCalledTimes(1); + expect(mint).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/test/conversion/planning/build_conversion_ready_withdrawal_nonready_anchors.ts b/packages/sdk/test/conversion/planning/build_conversion_ready_withdrawal_nonready_anchors.ts new file mode 100644 index 0000000..2089b0b --- /dev/null +++ b/packages/sdk/test/conversion/planning/build_conversion_ready_withdrawal_nonready_anchors.ts @@ -0,0 +1,69 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "../withdrawal_quotes/support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("plans exact ready withdrawals with non-ready required anchors", async () => { + const { sdk, ownedOwnerManager, lock } = testSdk(); + const extra = projectionReadyDeposit(10n, 0n, { id: "21" }); + const nonReadyAnchor = projectionReadyDeposit(12n, 1n, { id: "22", isReady: false }); + const requestWithdrawal = vi + .spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation( + async ( + ...[txLike, deposits, requestLock, , requestOptions]: [ + txLike: ccc.TransactionLike, + deposits: unknown, + requestLock: unknown, + client: unknown, + requestOptions: unknown, + ] + ) => { + await Promise.resolve(); + expect(deposits).toEqual([extra]); + expect(requestLock).toBe(lock); + expect(requestOptions).toEqual({ + requiredLiveDeposits: [nonReadyAnchor], + }); + return ccc.Transaction.from(txLike); + }, + ); + + const result = await sdk.buildConversionTransaction( + ccc.Transaction.default(), + baseClient, + { + direction: ICKB_TO_CKB, + amount: 10n, + lock, + context: conversionContext({ + system: { + poolDeposits: { + deposits: [extra, nonReadyAnchor], + readyDeposits: [extra], + id: "pool", + }, + }, + ickbAvailable: 10n, + }), + }, + ); + + expect(result).toMatchObject({ ok: true, conversion: { kind: "direct" } }); + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/planning/build_conversion_ready_withdrawal_required_anchors.ts b/packages/sdk/test/conversion/planning/build_conversion_ready_withdrawal_required_anchors.ts new file mode 100644 index 0000000..31f344c --- /dev/null +++ b/packages/sdk/test/conversion/planning/build_conversion_ready_withdrawal_required_anchors.ts @@ -0,0 +1,69 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "../withdrawal_quotes/support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("plans exact ready withdrawals with required anchors", async () => { + const { sdk, ownedOwnerManager, lock } = testSdk(); + const extra = projectionReadyDeposit(10n, 0n); + const protectedAnchor = projectionReadyDeposit(12n, 1n); + const requestWithdrawal = vi + .spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation( + async ( + ...[txLike, deposits, requestLock, , requestOptions]: [ + txLike: ccc.TransactionLike, + deposits: unknown, + requestLock: unknown, + client: unknown, + requestOptions: unknown, + ] + ) => { + await Promise.resolve(); + expect(deposits).toEqual([extra]); + expect(requestLock).toBe(lock); + expect(requestOptions).toEqual({ + requiredLiveDeposits: [protectedAnchor], + }); + return ccc.Transaction.from(txLike); + }, + ); + + const result = await sdk.buildConversionTransaction( + ccc.Transaction.default(), + baseClient, + { + direction: ICKB_TO_CKB, + amount: 10n, + lock, + context: conversionContext({ + system: { + poolDeposits: { + deposits: [extra, protectedAnchor], + readyDeposits: [extra, protectedAnchor], + id: "pool", + }, + }, + ickbAvailable: 10n, + }), + }, + ); + + expect(result).toMatchObject({ ok: true, conversion: { kind: "direct" } }); + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/planning/sdk_conversion_planning_helpers.ts b/packages/sdk/test/conversion/planning/sdk_conversion_planning_helpers.ts new file mode 100644 index 0000000..401b3ce --- /dev/null +++ b/packages/sdk/test/conversion/planning/sdk_conversion_planning_helpers.ts @@ -0,0 +1,95 @@ +import { Ratio } from "@ickb/order"; +import { script } from "@ickb/testkit"; +import { describe, expect, it } from "vitest"; +import { errorOf } from "../../../src/client/sdk_error.ts"; +import { sdkManagers } from "../../../src/client/sdk_state_store.ts"; +import { + ckbToIckbConversionPlans, + ickbToCkbConversionPlans, +} from "../../../src/conversion/sdk_conversion_plans.ts"; +import { conversionContext } from "../../transaction/base/support/sdk_core_support.ts"; +import { projectionReadyDeposit } from "../withdrawal_quotes/support/sdk_cell_support.ts"; + +describe("sdk conversion planning helpers", () => { + it("covers conversion planning ordering edges", () => { + const lock = script("11"); + const zeroCapacityPlans = ckbToIckbConversionPlans({ + direction: "ckb-to-ickb", + amount: 1n, + lock, + context: conversionContext({ + system: { + exchangeRatio: Ratio.from({ ckbScale: 1n << 80n, udtScale: 1n }), + ckbAvailable: 1n, + }, + ckbAvailable: 1n, + }), + }); + const anchorDeposit = projectionReadyDeposit(3n, 0n, { ckbValue: 3n, id: "50" }); + const pairDeposit = projectionReadyDeposit(2n, 0n, { ckbValue: 2n, id: "51" }); + const unitA = projectionReadyDeposit(1n, 0n, { ckbValue: 1n, id: "52" }); + const unitB = projectionReadyDeposit(1n, 0n, { ckbValue: 1n, id: "53" }); + const laterDeposit = projectionReadyDeposit(2n, 2n * 60n * 60n * 1000n, { + ckbValue: 3n, + id: "54", + }); + const orderedPlans = ickbToCkbConversionPlans( + { + direction: "ickb-to-ckb", + amount: 2n, + lock, + context: conversionContext({ ickbAvailable: 2n }), + }, + { + deposits: [anchorDeposit, pairDeposit, unitA, unitB, laterDeposit], + readyDeposits: [anchorDeposit, pairDeposit, unitA, unitB, laterDeposit], + id: "pool", + }, + ); + const immediatelyMaturePlans = orderedPlans.plans.filter( + (plan) => plan.estimatedMaturity === 0n, + ); + const twoDepositPlans = orderedPlans.plans.filter( + (plan) => plan.selectedDeposits.length === 2, + ); + + expect(zeroCapacityPlans).toMatchObject({ + lastFailure: "amount-too-small", + plans: [], + }); + expect(immediatelyMaturePlans).toHaveLength(2); + expect(twoDepositPlans).toHaveLength(1); + }); + + it("derives ready pool deposits from the concrete pool snapshot", () => { + const lock = script("11"); + const fabricatedReadyDeposit = projectionReadyDeposit(2n, 0n, { id: "55" }); + + expect( + ickbToCkbConversionPlans( + { + direction: "ickb-to-ckb", + amount: 2n, + lock, + context: conversionContext({ ickbAvailable: 2n }), + }, + { + deposits: [], + readyDeposits: [fabricatedReadyDeposit], + id: "pool", + }, + ), + ).toMatchObject({ lastFailure: "amount-too-small", plans: [] }); + }); + + it("normalizes unknown errors and missing SDK managers", () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + + expect(errorOf("plain").message).toBe("plain"); + expect(errorOf({ message: "from object" }).message).toBe("from object"); + expect(errorOf({ value: 1n }).message).toBe('{"value":"1"}'); + expect(errorOf(circular).message).toBe("[object Object]"); + expect(() => sdkManagers({})).toThrow("SDK managers not initialized"); + }); +}); diff --git a/packages/sdk/test/conversion/planning/sdk_helpers.ts b/packages/sdk/test/conversion/planning/sdk_helpers.ts new file mode 100644 index 0000000..d222197 --- /dev/null +++ b/packages/sdk/test/conversion/planning/sdk_helpers.ts @@ -0,0 +1,86 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoOutputLimitError } from "@ickb/dao"; +import { Info } from "@ickb/order"; +import { script } from "@ickb/testkit"; +import { describe, expect, it, vi } from "vitest"; +import { + baseTransactionOptions, + conversionKind, + isRetryableConversionBuildError, + plannedDaoOutputLimitError, +} from "../../../src/conversion/sdk_conversion_common.ts"; +import { + conversionContext, + ratio, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + signerWithLock, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "../withdrawal_quotes/support/sdk_cell_support.ts"; +import { projectionOrderGroup } from "./support/sdk_order_support.ts"; + +describe("sdk helper coverage", () => { + it("covers conversion option helper branches", () => { + const context = conversionContext({ + availableOrders: [ + projectionOrderGroup({ + ckbValue: 1n, + udtValue: 2n, + isDualRatio: true, + isMatchable: true, + }), + ], + receipts: [], + readyWithdrawals: [], + }); + const lock = script("11"); + const deposits = [projectionReadyDeposit(5n, 0n, { id: "01" })]; + + expect(baseTransactionOptions(context)).toEqual({ + orders: context.availableOrders, + receipts: [], + readyWithdrawals: [], + }); + expect( + baseTransactionOptions(context, { deposits, requiredLiveDeposits: [], lock }), + ).toMatchObject({ + withdrawalRequest: { deposits, lock }, + }); + expect( + baseTransactionOptions(context, { deposits, requiredLiveDeposits: deposits, lock }), + ).toMatchObject({ + withdrawalRequest: { deposits, requiredLiveDeposits: deposits, lock }, + }); + expect(conversionKind(true, true)).toBe("direct-plus-order"); + expect(conversionKind(true, false)).toBe("direct"); + expect(conversionKind(false, true)).toBe("order"); + expect(conversionKind(false, false)).toBe("collect-only"); + }); + + it("mints order requests with a signer-provided lock", async () => { + const { sdk, orderManager, lock } = testSdk(); + const mint = vi + .spyOn(orderManager, "mint") + .mockImplementation((txLike) => ccc.Transaction.from(txLike)); + const info = Info.create(true, ratio); + const amounts = { ckbValue: 1n, udtValue: 0n }; + + await sdk.request(ccc.Transaction.default(), signerWithLock(lock), info, amounts); + + expect(mint).toHaveBeenCalledWith(expect.any(ccc.Transaction), lock, info, amounts); + }); + + it("covers retryable conversion errors and DAO output planning", () => { + const tx = ccc.Transaction.default(); + const limitError = plannedDaoOutputLimitError(tx, 65, true); + const namedDaoError = new Error("x"); + Object.defineProperty(namedDaoError, "name", { value: "DaoOutputLimitError" }); + + expect(plannedDaoOutputLimitError(tx, 65, false)).toBeUndefined(); + expect(limitError).toBeInstanceOf(DaoOutputLimitError); + expect(isRetryableConversionBuildError(limitError)).toBe(true); + expect(isRetryableConversionBuildError(namedDaoError)).toBe(true); + expect(isRetryableConversionBuildError(new Error("x"))).toBe(false); + }); +}); diff --git a/packages/sdk/test/conversion/planning/support/sdk_order_support.ts b/packages/sdk/test/conversion/planning/support/sdk_order_support.ts new file mode 100644 index 0000000..f4449e8 --- /dev/null +++ b/packages/sdk/test/conversion/planning/support/sdk_order_support.ts @@ -0,0 +1,120 @@ +import { ccc } from "@ckb-ccc/core"; +import { Info, MasterCell, OrderCell, OrderData, OrderGroup } from "@ickb/order"; +import { script } from "@ickb/testkit"; +import { hash, ratio } from "../../../transaction/base/support/sdk_core_support.ts"; + +export function projectionOrderGroup(options: ProjectionOrderOptions): OrderGroup { + const group = new ProjectionOrderGroup(options); + group.order.isDualRatio = (): boolean => options.isDualRatio; + group.order.isMatchable = (): boolean => options.isMatchable; + return group; +} + +interface ProjectionOrderOptions { + ckbValue: bigint; + udtValue: bigint; + isDualRatio: boolean; + isMatchable: boolean; +} + +class ProjectionOrderGroup extends OrderGroup { + private readonly projection: ProjectionOrderOptions; + + constructor(projection: ProjectionOrderOptions) { + const order = new OrderCell( + ccc.Cell.from({ + outPoint: { txHash: hash("77"), index: 0n }, + cellOutput: { capacity: projection.ckbValue, lock: script("55") }, + outputData: "0x", + }), + OrderData.from({ + udtValue: projection.udtValue, + master: { + type: "relative", + value: { distance: 1n, padding: new Uint8Array(32) }, + }, + info: Info.create(!projection.isDualRatio, ratio), + }), + projection.ckbValue, + projection.ckbValue + projection.udtValue, + projection.isMatchable ? 0n : projection.ckbValue + projection.udtValue, + undefined, + ); + super( + new MasterCell( + ccc.Cell.from({ + outPoint: { txHash: hash("77"), index: 1n }, + cellOutput: { capacity: 0n, lock: script("11"), type: script("55") }, + outputData: "0x", + }), + ), + order, + order, + ); + this.projection = projection; + } + + public override get ckbValue(): bigint { + return this.projection.ckbValue; + } + + public override get udtValue(): bigint { + return this.projection.udtValue; + } +} + +export function makeOrderGroup(options: { + orderScript: ccc.Script; + udtScript: ccc.Script; + ownerLock: ccc.Script; + txHashByte: string; + orderTxHashByte?: string; + ratio?: { ckbScale: bigint; udtScale: bigint }; + isCkb2Udt?: boolean; + orderCapacity?: bigint; + udtValue?: bigint; +}): { group: OrderGroup; orderCell: ccc.Cell; masterCell: ccc.Cell } { + const masterOutPoint = ccc.OutPoint.from({ + txHash: hash(options.txHashByte), + index: 1n, + }); + const orderCell = ccc.Cell.from({ + outPoint: { txHash: hash(options.orderTxHashByte ?? "74"), index: 0n }, + cellOutput: { + capacity: options.orderCapacity ?? ccc.fixedPointFrom(100), + lock: options.orderScript, + type: options.udtScript, + }, + outputData: OrderData.from({ + udtValue: options.udtValue ?? 0n, + master: { type: "absolute", value: masterOutPoint }, + info: Info.create( + options.isCkb2Udt ?? true, + options.ratio ?? { ckbScale: 1n, udtScale: 1n }, + ), + }).toBytes(), + }); + const masterCell = ccc.Cell.from({ + outPoint: masterOutPoint, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: options.ownerLock, + type: options.orderScript, + }, + outputData: "0x", + }); + const order = OrderCell.mustFrom(orderCell); + + return { + group: new OrderGroup(new MasterCell(masterCell), order, order), + orderCell, + masterCell, + }; +} + +export const placeholderOrder = projectionOrderGroup({ + ckbValue: 0n, + udtValue: 0n, + isDualRatio: false, + isMatchable: false, +}); diff --git a/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_deposit_amount_filter.ts b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_deposit_amount_filter.ts new file mode 100644 index 0000000..d14401f --- /dev/null +++ b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_deposit_amount_filter.ts @@ -0,0 +1,62 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "./support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("skips iCKB-to-CKB deposits above the requested amount even with high surplus", async () => { + const { sdk, ownedOwnerManager, lock } = testSdk(); + const oversized = projectionReadyDeposit(ICKB_DEPOSIT_CAP + 1n, 0n, { + ckbValue: ICKB_DEPOSIT_CAP * 2n, + id: "c1", + }); + const fitting = projectionReadyDeposit(ICKB_DEPOSIT_CAP, 15n * 60n * 1000n, { + ckbValue: ICKB_DEPOSIT_CAP, + id: "c2", + }); + const requestWithdrawal = vi + .spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + expect(deposits).toEqual([fitting]); + return ccc.Transaction.from(txLike); + }); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: ICKB_TO_CKB, + amount: ICKB_DEPOSIT_CAP, + lock, + context: conversionContext({ + system: { + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + poolDeposits: { + deposits: [oversized, fitting], + readyDeposits: [oversized, fitting], + id: "pool", + }, + }, + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + }), + }), + ).resolves.toMatchObject({ ok: true, conversion: { kind: "direct" } }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_direct_surplus_priority.ts b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_direct_surplus_priority.ts new file mode 100644 index 0000000..11a08c1 --- /dev/null +++ b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_direct_surplus_priority.ts @@ -0,0 +1,73 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "./support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +const DIRECT_PLUS_ORDER = "direct-plus-order"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("preserves iCKB-to-CKB maturity-bucket priority before direct surplus", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const unit = ICKB_DEPOSIT_CAP / 10n; + const earlier = projectionReadyDeposit(8n * unit, 30n * 60n * 1000n, { + ckbValue: 8n * unit, + id: "b1", + }); + const laterHigherGain = projectionReadyDeposit(8n * unit, 2n * 60n * 60n * 1000n, { + ckbValue: 8n * unit + 1000n, + id: "b2", + }); + const requestWithdrawal = vi + .spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + expect(deposits).toEqual([earlier]); + return ccc.Transaction.from(txLike); + }); + const mint = vi + .spyOn(orderManager, "mint") + .mockImplementation((txLike) => ccc.Transaction.from(txLike)); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: ICKB_TO_CKB, + amount: ICKB_DEPOSIT_CAP, + lock, + context: conversionContext({ + system: { + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + ckbAvailable: 10n, + poolDeposits: { + deposits: [laterHigherGain, earlier], + readyDeposits: [laterHigherGain, earlier], + id: "pool", + }, + }, + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + }), + }), + ).resolves.toMatchObject({ + ok: true, + conversion: { kind: DIRECT_PLUS_ORDER }, + }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_dust_remainder_orders.ts b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_dust_remainder_orders.ts new file mode 100644 index 0000000..1f40ecd --- /dev/null +++ b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_dust_remainder_orders.ts @@ -0,0 +1,100 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP, convert } from "@ickb/core"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "./support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +const DIRECT_PLUS_ORDER = "direct-plus-order"; + +const DUST_ICKB_TO_CKB = "dust-ickb-to-ckb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("builds iCKB-to-CKB direct withdrawals plus dust remainder orders", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const directDeposit = projectionReadyDeposit(ICKB_DEPOSIT_CAP); + const ringAnchor = projectionReadyDeposit(ICKB_DEPOSIT_CAP + 1n, 1n); + const requestWithdrawal = vi + .spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation( + async ( + ...[txLike, deposits, , , requestOptions]: [ + txLike: ccc.TransactionLike, + deposits: unknown, + lock: unknown, + client: unknown, + requestOptions: unknown, + ] + ) => { + await Promise.resolve(); + expect(deposits).toEqual([directDeposit]); + expect(requestOptions).toEqual({ + requiredLiveDeposits: [ringAnchor], + }); + return ccc.Transaction.from(txLike); + }, + ); + const mint = vi + .spyOn(orderManager, "mint") + .mockImplementation((txLike, _lock, info, amounts) => { + expect(info.ckbMinMatchLog).toBe(33); + expect(amounts).toEqual({ ckbValue: 0n, udtValue: 100000n }); + return ccc.Transaction.from(txLike); + }); + const exchangeRatio = Ratio.from({ + ckbScale: 10000000000000000n, + udtScale: 10008200000000000n, + }); + const amount = ICKB_DEPOSIT_CAP + 100000n; + + const result = await sdk.buildConversionTransaction( + ccc.Transaction.default(), + baseClient, + { + direction: ICKB_TO_CKB, + amount, + lock, + context: conversionContext({ + system: { + exchangeRatio, + ckbAvailable: convert(false, ICKB_DEPOSIT_CAP, exchangeRatio), + poolDeposits: { + deposits: [directDeposit, ringAnchor], + readyDeposits: [directDeposit, ringAnchor], + id: "pool", + }, + }, + ckbAvailable: 0n, + ickbAvailable: amount, + }), + }, + ); + + expect(result).toMatchObject({ + ok: true, + conversion: { kind: DIRECT_PLUS_ORDER }, + conversionNotice: { + kind: DUST_ICKB_TO_CKB, + inputIckb: 100000n, + outputCkb: 100072n, + incentiveCkb: 10n, + maturityEstimateUnavailable: false, + }, + }); + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_maturity_bucket_priority.ts b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_maturity_bucket_priority.ts new file mode 100644 index 0000000..935af0f --- /dev/null +++ b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_maturity_bucket_priority.ts @@ -0,0 +1,67 @@ +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + expectIckbToCkbDirectPlusOrder, + mockWithdrawalWithRemainderOrder, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "./support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("prefers better direct iCKB-to-CKB economic surplus within a maturity bucket", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const unit = ICKB_DEPOSIT_CAP / 10n; + const largerLowerGain = projectionReadyDeposit(9n * unit, 0n, { + ckbValue: 9n * unit, + id: "a1", + }); + const smallerHigherGain = projectionReadyDeposit(8n * unit, 30n * 60n * 1000n, { + ckbValue: 8n * unit + 1000n, + id: "a2", + }); + const { mint, requestWithdrawal } = mockWithdrawalWithRemainderOrder( + { ownedOwnerManager, orderManager }, + [smallerHigherGain], + { ckbValue: 0n, udtValue: 2n * unit }, + ); + + await expectIckbToCkbDirectPlusOrder({ + sdk, + lock, + deposits: [largerLowerGain, smallerHigherGain], + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + }); + + it("prefers an earlier iCKB-to-CKB maturity bucket over a marginally larger withdrawal", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const unit = ICKB_DEPOSIT_CAP / 10n; + const smallEarlier = projectionReadyDeposit(4n * unit, 0n); + const smallLater = projectionReadyDeposit(4n * unit, 15n * 60n * 1000n); + const largeMuchLater = projectionReadyDeposit(9n * unit, 2n * 60n * 60n * 1000n); + const { mint, requestWithdrawal } = mockWithdrawalWithRemainderOrder( + { ownedOwnerManager, orderManager }, + [smallEarlier, smallLater], + { ckbValue: 0n, udtValue: 2n * unit }, + ); + + await expectIckbToCkbDirectPlusOrder({ + sdk, + lock, + deposits: [smallEarlier, smallLater, largeMuchLater], + exchangeRatio: Ratio.from({ ckbScale: 100n, udtScale: 1n }), + }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_nonretryable_errors.ts b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_nonretryable_errors.ts new file mode 100644 index 0000000..42b185c --- /dev/null +++ b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_nonretryable_errors.ts @@ -0,0 +1,61 @@ +import { ccc } from "@ckb-ccc/core"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, + hash, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "./support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const WITHDRAWAL_FAILED = "withdrawal failed"; + +const ICKB_TO_CKB = "ickb-to-ckb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("fails fast on non-retryable iCKB-to-CKB construction errors", async () => { + const { sdk, ownedOwnerManager, lock } = testSdk(); + const extra = projectionReadyDeposit(1n, 0n); + const protectedAnchor = projectionReadyDeposit(2n, 1n); + vi.spyOn(ownedOwnerManager, "requestWithdrawal").mockRejectedValue( + new Error(WITHDRAWAL_FAILED), + ); + + const tx = ccc.Transaction.default(); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { txHash: hash("90"), index: 0n }, + }), + ); + tx.outputs.push(ccc.CellOutput.from({ capacity: 1n, lock })); + tx.outputsData.push("0x"); + + await expect( + sdk.buildConversionTransaction(tx, baseClient, { + direction: ICKB_TO_CKB, + amount: 1n, + lock, + context: conversionContext({ + system: { + exchangeRatio: Ratio.from({ ckbScale: 100n, udtScale: 1n }), + poolDeposits: { + deposits: [extra, protectedAnchor], + readyDeposits: [extra, protectedAnchor], + id: "pool", + }, + }, + ckbAvailable: 0n, + ickbAvailable: 1n, + }), + }), + ).rejects.toThrow(WITHDRAWAL_FAILED); + }); +}); diff --git a/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_output_limit_retry.ts b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_output_limit_retry.ts new file mode 100644 index 0000000..2d170ff --- /dev/null +++ b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_output_limit_retry.ts @@ -0,0 +1,70 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { DaoOutputLimitError } from "@ickb/dao"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "./support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +const DIRECT_PLUS_ORDER = "direct-plus-order"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("retries iCKB-to-CKB withdrawals after DAO output-limit failures", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const first = projectionReadyDeposit(ICKB_DEPOSIT_CAP / 2n, 0n); + const second = projectionReadyDeposit(ICKB_DEPOSIT_CAP / 2n, 15n * 60n * 1000n); + const ringAnchor = projectionReadyDeposit(ICKB_DEPOSIT_CAP, 1n); + const requestedCounts: number[] = []; + const requestWithdrawal = vi + .spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike, deposits) => { + await Promise.resolve(); + requestedCounts.push(deposits.length); + if (requestedCounts.length === 1) { + throw new DaoOutputLimitError(65); + } + expect(deposits).toHaveLength(1); + return ccc.Transaction.from(txLike); + }); + vi.spyOn(orderManager, "mint").mockImplementation((txLike) => + ccc.Transaction.from(txLike), + ); + + await expect( + sdk.buildConversionTransaction(ccc.Transaction.default(), baseClient, { + direction: ICKB_TO_CKB, + amount: ICKB_DEPOSIT_CAP, + lock, + context: conversionContext({ + system: { + poolDeposits: { + deposits: [first, second, ringAnchor], + readyDeposits: [first, second, ringAnchor], + id: "pool", + }, + }, + ckbAvailable: 0n, + ickbAvailable: ICKB_DEPOSIT_CAP, + }), + }), + ).resolves.toMatchObject({ + ok: true, + conversion: { kind: DIRECT_PLUS_ORDER }, + }); + + expect(requestWithdrawal).toHaveBeenCalledTimes(2); + expect(requestedCounts).toEqual([2, 1]); + }); +}); diff --git a/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_quote_preserving_dust.ts b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_quote_preserving_dust.ts new file mode 100644 index 0000000..907aacd --- /dev/null +++ b/packages/sdk/test/conversion/withdrawal_quotes/build_conversion_ickb_to_ckb_quote_preserving_dust.ts @@ -0,0 +1,84 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseClient, + conversionContext, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + BUILD_CONVERSION_TRANSACTION_SUITE, + testSdk, +} from "../deposits_and_limits/support/sdk_fixture_support.ts"; +import { projectionReadyDeposit } from "./support/sdk_cell_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ICKB_TO_CKB = "ickb-to-ckb"; + +const DIRECT_PLUS_ORDER = "direct-plus-order"; + +const DUST_ICKB_TO_CKB = "dust-ickb-to-ckb"; + +describe(BUILD_CONVERSION_TRANSACTION_SUITE, () => { + it("keeps direct withdrawals when an iCKB-to-CKB dust remainder needs quote-preserving Uint64 encoding", async () => { + const { sdk, ownedOwnerManager, orderManager, lock } = testSdk(); + const directDeposit = projectionReadyDeposit(ICKB_DEPOSIT_CAP - 1000000n); + const ringAnchor = projectionReadyDeposit(ICKB_DEPOSIT_CAP, 1n); + const requestWithdrawal = vi + .spyOn(ownedOwnerManager, "requestWithdrawal") + .mockImplementation(async (txLike) => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }); + const mint = vi + .spyOn(orderManager, "mint") + .mockImplementation((txLike, _lock, _info, amounts) => { + expect(amounts).toEqual({ ckbValue: 0n, udtValue: 1000000n }); + return ccc.Transaction.from(txLike); + }); + const exchangeRatio = Ratio.from({ + ckbScale: 10000000000000000n, + udtScale: 11850413696044750n, + }); + const amount = ICKB_DEPOSIT_CAP; + + const result = await sdk.buildConversionTransaction( + ccc.Transaction.default(), + baseClient, + { + direction: ICKB_TO_CKB, + amount, + lock, + context: conversionContext({ + system: { + exchangeRatio, + ckbAvailable: ccc.fixedPointFrom("3102.81677146"), + feeRate: 33222n, + poolDeposits: { + deposits: [directDeposit, ringAnchor], + readyDeposits: [directDeposit, ringAnchor], + id: "pool", + }, + }, + ckbAvailable: 0n, + ickbAvailable: amount, + }), + }, + ); + + expect(result).toMatchObject({ + ok: true, + conversion: { kind: DIRECT_PLUS_ORDER }, + conversionNotice: { + kind: DUST_ICKB_TO_CKB, + inputIckb: 1000000n, + maturityEstimateUnavailable: false, + }, + }); + expect(requestWithdrawal).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/conversion/withdrawal_quotes/support/sdk_cell_support.ts b/packages/sdk/test/conversion/withdrawal_quotes/support/sdk_cell_support.ts new file mode 100644 index 0000000..43ad9e0 --- /dev/null +++ b/packages/sdk/test/conversion/withdrawal_quotes/support/sdk_cell_support.ts @@ -0,0 +1,293 @@ +import { ccc } from "@ckb-ccc/core"; +import { + ickbDepositCellFrom, + OwnerCell, + OwnerData, + ReceiptData, + WithdrawalGroup, + type IckbDepositCell, + type ReceiptCell, +} from "@ickb/core"; +import { DaoManager, type DaoWithdrawalRequestCell } from "@ickb/dao"; +import { script } from "@ickb/testkit"; +import { baseTip, hash } from "../../../transaction/base/support/sdk_core_support.ts"; + +export function depositCell( + ...[byte, logic, dao, depositHeader, tipHeader, options]: [ + byte: string, + logic: ccc.Script, + dao: ccc.Script, + depositHeader: ccc.ClientBlockHeader, + tipHeader: ccc.ClientBlockHeader, + options?: { isReady?: boolean }, + ] +): IckbDepositCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: hash(byte), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: logic, + type: dao, + }, + outputData: DaoManager.depositData(), + }); + const deposit = ickbDepositCellFrom( + { + cell, + headers: [ + { header: depositHeader, txHash: cell.outPoint.txHash }, + { header: tipHeader }, + ], + interests: 0n, + maturity: ccc.Epoch.from([1n, 0n, 1n]), + isReady: options?.isReady ?? false, + isDeposit: true, + ckbValue: cell.cellOutput.capacity, + udtValue: 0n, + }, + logic, + ); + Object.assign(deposit, { udtValue: ccc.fixedPointFrom(100000) }); + return deposit; +} + +export function projectionReadyDeposit( + udtValue: bigint, + maturityUnix = 0n, + options: { ckbValue?: bigint; id?: string; isReady?: boolean } = {}, +): IckbDepositCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: hash(options.id ?? "aa"), index: maturityUnix }, + cellOutput: { + capacity: 0n, + lock: script("22"), + type: script("33"), + }, + outputData: DaoManager.depositData(), + }); + const deposit = ickbDepositCellFrom( + { + cell, + headers: [{ header: baseTip, txHash: cell.outPoint.txHash }, { header: baseTip }], + interests: 0n, + isReady: options.isReady ?? true, + isDeposit: true, + ckbValue: cell.cellOutput.capacity, + udtValue: 0n, + maturity: new TestEpoch(maturityUnix), + }, + script("22"), + ); + Object.assign(deposit, { + ckbValue: options.ckbValue ?? udtValue, + udtValue, + }); + return deposit; +} + +export function receiptValue( + ckbValue: bigint, + udtValue: bigint, + byte = "20", +): ReceiptCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: hash(byte), index: 0n }, + cellOutput: { capacity: ckbValue, lock: script("11"), type: script("22") }, + outputData: "0x", + }); + return { + cell, + header: { header: baseTip, txHash: cell.outPoint.txHash }, + ckbValue, + udtValue, + }; +} + +export function plainCapacityCell( + capacity: bigint, + lock = script("11"), + byte = "10", +): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: hash(byte), index: 0n }, + cellOutput: { capacity, lock }, + outputData: "0x", + }); +} + +export function nativeUdtCell( + udtValue: bigint, + options: { + capacity?: bigint; + lock?: ccc.Script; + type?: ccc.Script; + byte?: string; + } = {}, +): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: hash(options.byte ?? "40"), index: 0n }, + cellOutput: { + capacity: options.capacity ?? 0n, + lock: options.lock ?? script("11"), + type: options.type ?? script("66"), + }, + outputData: ccc.numLeToBytes(udtValue, 16), + }); +} + +export function receiptCell( + byte: string, + lock: ccc.Script, + logic: ccc.Script, + header: ccc.ClientBlockHeader, +): ReceiptCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: hash(byte), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: logic, + }, + outputData: ReceiptData.encode({ + depositQuantity: 1, + depositAmount: ccc.fixedPointFrom(100000), + }), + }); + return { + cell, + header: { header, txHash: cell.outPoint.txHash }, + ckbValue: cell.cellOutput.capacity, + udtValue: ccc.fixedPointFrom(100000), + }; +} + +export function withdrawalValue(options: { + ckbValue: bigint; + udtValue?: bigint; + isReady: boolean; + maturityUnix?: bigint; + byte?: string; +}): WithdrawalGroup { + return new ProjectionWithdrawalGroup(options); +} + +export function readyWithdrawalGroup(options: { + ownerLock: ccc.Script; + ownedOwner: ccc.Script; + dao: ccc.Script; + depositHeader: ccc.ClientBlockHeader; + withdrawalHeader: ccc.ClientBlockHeader; +}): WithdrawalGroup { + const ownedCell = ccc.Cell.from({ + outPoint: { txHash: hash("75"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: options.ownedOwner, + type: options.dao, + }, + outputData: ccc.mol.Uint64LE.encode(options.depositHeader.number), + }); + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: hash("75"), index: 1n }, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: options.ownerLock, + type: options.ownedOwner, + }, + outputData: OwnerData.encode({ ownedDistance: -1n }), + }), + ); + return new WithdrawalGroup( + { + cell: ownedCell, + headers: [ + { header: options.depositHeader }, + { header: options.withdrawalHeader, txHash: ownedCell.outPoint.txHash }, + ], + interests: 0n, + maturity: ccc.Epoch.from([1n, 0n, 1n]), + isReady: true, + isDeposit: false, + ckbValue: ownedCell.cellOutput.capacity, + udtValue: 0n, + }, + owner, + ); +} + +class ProjectionWithdrawalGroup extends WithdrawalGroup { + private readonly projection: { ckbValue: bigint; udtValue: bigint }; + + constructor(options: { + ckbValue: bigint; + udtValue?: bigint; + isReady: boolean; + maturityUnix?: bigint; + byte?: string; + }) { + const owned = { + cell: ccc.Cell.from({ + outPoint: { txHash: hash(options.byte ?? "30"), index: 0n }, + cellOutput: { + capacity: options.ckbValue, + lock: script("44"), + type: script("33"), + }, + outputData: ccc.mol.Uint64LE.encode(baseTip.number), + }), + headers: [ + { header: baseTip }, + { header: baseTip, txHash: hash(options.byte ?? "30") }, + ], + interests: 0n, + maturity: new TestEpoch(options.maturityUnix ?? 0n), + isReady: options.isReady, + isDeposit: false, + ckbValue: options.ckbValue, + udtValue: options.udtValue ?? 0n, + } satisfies DaoWithdrawalRequestCell; + super( + owned, + new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: hash(options.byte ?? "30"), index: 1n }, + cellOutput: { capacity: 0n, lock: script("11"), type: script("44") }, + outputData: OwnerData.encode({ ownedDistance: -1n }), + }), + ), + ); + this.projection = { + ckbValue: options.ckbValue, + udtValue: options.udtValue ?? 0n, + }; + } + + public override get ckbValue(): bigint { + return this.projection.ckbValue; + } + + public override get udtValue(): bigint { + return this.projection.udtValue; + } +} + +class TestEpoch extends ccc.Epoch { + private readonly unix: bigint; + + constructor(unix: bigint) { + super(1n, 0n, 1n); + this.unix = unix; + } + + public override toUnix(): bigint { + return this.unix; + } +} + +export const placeholderReceipt = receiptValue(0n, 0n, "21"); +export const placeholderWithdrawal = withdrawalValue({ + ckbValue: 0n, + isReady: true, + byte: "31", +}); diff --git a/packages/sdk/test/estimate/estimate_direct_ickb_to_ckb_previews.ts b/packages/sdk/test/estimate/estimate_direct_ickb_to_ckb_previews.ts new file mode 100644 index 0000000..41bafd4 --- /dev/null +++ b/packages/sdk/test/estimate/estimate_direct_ickb_to_ckb_previews.ts @@ -0,0 +1,62 @@ +import { OrderManager, Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IckbSdk } from "../../src/sdk.ts"; +import { system } from "../transaction/base/support/sdk_core_support.ts"; +import { ESTIMATE_SUITE } from "./support/estimate_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(ESTIMATE_SUITE, () => { + it("uses quote-preserving Uint64 ratio encoding for direct iCKB-to-CKB previews", () => { + const maxUint64 = (1n << 64n) - 1n; + const exchangeRatio = Ratio.from({ + ckbScale: maxUint64, + udtScale: maxUint64 - 2n, + }); + const amounts = { ckbValue: 0n, udtValue: 1000000n }; + + expect( + OrderManager.convert(false, exchangeRatio, amounts, { + fee: 1n, + feeBase: 100000n, + }).convertedAmount, + ).toBe(999990n); + + const result = IckbSdk.estimate( + false, + amounts, + system({ + exchangeRatio, + ckbAvailable: 1000000n, + }), + ); + + expect(result.convertedAmount).toBe(999990n); + expect(result.ckbFee).toBe(9n); + expect(result.maturity).toBeUndefined(); + expect(result.info.udtToCkb.ckbScale).toBeLessThanOrEqual(maxUint64); + expect(result.info.udtToCkb.udtScale).toBeLessThanOrEqual(maxUint64); + }); + + it("builds normal iCKB-to-CKB orders when maturity is unavailable", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1000000n }, + system({ ckbAvailable: 0n }), + ); + + if (result === undefined) { + throw new Error("Expected iCKB-to-CKB order estimate"); + } + expect(result.maturity).toBeUndefined(); + expect(result.notice).toEqual({ + kind: "maturity-unavailable", + inputIckb: 1000000n, + outputCkb: 999990n, + incentiveCkb: 10n, + maturityEstimateUnavailable: true, + }); + expect(result.estimate.info.ckbMinMatchLog).toBe(33); + }); +}); diff --git a/packages/sdk/test/estimate/estimate_dust_binary_search_failure.ts b/packages/sdk/test/estimate/estimate_dust_binary_search_failure.ts new file mode 100644 index 0000000..b52ae71 --- /dev/null +++ b/packages/sdk/test/estimate/estimate_dust_binary_search_failure.ts @@ -0,0 +1,71 @@ +import type * as OrderModule from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { system } from "../transaction/base/support/sdk_core_support.ts"; + +const ORDER_PACKAGE = "@ickb/order"; +const DUST_NOTICE = "dust-ickb-to-ckb"; + +afterEach(() => { + vi.doUnmock(ORDER_PACKAGE); + vi.resetModules(); +}); + +describe("IckbSdk.estimate dust fee search", () => { + it("uses a dust quote when the default quote is unrepresentable", async () => { + mockUnrepresentableQuote({ fee: 1n, feeBase: 100000n }); + + const { estimateIckbToCkbOrder } = await import("../../src/estimate/sdk_estimate.ts"); + + expect( + estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 10n }, + system({ ckbAvailable: 10n, feeRate: 0n }), + ), + ).toMatchObject({ + maturity: 600000n, + notice: { kind: DUST_NOTICE, incentiveCkb: 0n }, + }); + }); + + it("stops when an intermediate dust fee quote is unrepresentable", async () => { + mockUnrepresentableQuote({ fee: 5n, feeBase: 11n }); + + const { estimateIckbToCkbOrder } = await import("../../src/estimate/sdk_estimate.ts"); + + expect( + estimateIckbToCkbOrder({ ckbValue: 0n, udtValue: 10n }, system({ feeRate: 1n })), + ).toBeUndefined(); + }); +}); + +function mockUnrepresentableQuote(blocked: { fee: bigint; feeBase: bigint }): void { + vi.resetModules(); + vi.doMock(ORDER_PACKAGE, async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + OrderManager: class extends actual.OrderManager { + public static override convert( + isCkb2Udt: boolean, + _midpoint: unknown, + _amounts: unknown, + options?: { fee?: bigint; feeBase?: bigint }, + ): ReturnType { + if ( + !isCkb2Udt && + options?.fee === blocked.fee && + options.feeBase === blocked.feeBase + ) { + throw new actual.OrderConversionRepresentabilityError(); + } + + return { + convertedAmount: 10n, + ckbFee: options?.fee ?? 0n, + info: actual.Info.create(false, { ckbScale: 1n, udtScale: 1n }), + }; + } + }, + }; + }); +} diff --git a/packages/sdk/test/estimate/estimate_dust_order_previews.ts b/packages/sdk/test/estimate/estimate_dust_order_previews.ts new file mode 100644 index 0000000..6cc787f --- /dev/null +++ b/packages/sdk/test/estimate/estimate_dust_order_previews.ts @@ -0,0 +1,250 @@ +import { ccc } from "@ckb-ccc/core"; +import { type Info, OrderCell, OrderData, OrderManager, Ratio } from "@ickb/order"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IckbSdk } from "../../src/sdk.ts"; +import { + hash, + headerLike, + ratio, + system, +} from "../transaction/base/support/sdk_core_support.ts"; +import { ESTIMATE_SUITE } from "./support/estimate_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function orderFromEstimate( + info: Info | undefined, + amounts: { ckbValue: bigint; udtValue: bigint }, +): OrderCell { + if (info === undefined) { + throw new Error("Expected order estimate info"); + } + const udtScript = script("66"); + const outputData = OrderData.from({ + udtValue: amounts.udtValue, + master: { + type: "relative", + value: { distance: 1n, padding: new Uint8Array(32) }, + }, + info, + }).toBytes(); + const minimalCell = ccc.Cell.from({ + outPoint: { txHash: hash("78"), index: 0n }, + cellOutput: { lock: script("55"), type: udtScript }, + outputData, + }); + return OrderCell.mustFrom( + ccc.Cell.from({ + outPoint: { txHash: hash("78"), index: 0n }, + cellOutput: { + capacity: minimalCell.cellOutput.capacity + amounts.ckbValue, + lock: script("55"), + type: udtScript, + }, + outputData, + }), + ); +} + +const DUST_ICKB_TO_CKB = "dust-ickb-to-ckb"; + +describe(ESTIMATE_SUITE, () => { + it("does not advertise one-sat iCKB-to-CKB dust orders below the fee threshold", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1n }, + system({ ckbAvailable: 1n, tip: headerLike(0n, { timestamp: 1234n }) }), + ); + + expect(result).toBeUndefined(); + }); + + it("does not throw when default iCKB-to-CKB fee precision exceeds Uint64", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1n }, + system({ + exchangeRatio: Ratio.from({ + ckbScale: (1n << 64n) - 1n, + udtScale: (1n << 64n) - 2n, + }), + ckbAvailable: 1n, + tip: headerLike(0n, { timestamp: 1234n }), + }), + ); + + expect(result).toBeUndefined(); + }); + + it("throws a public representability error for unrepresentable direct estimates", () => { + expect(() => + IckbSdk.estimate( + true, + { ckbValue: 1n << 80n, udtValue: 0n }, + system({ + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n << 80n }), + }), + { fee: 0n }, + ), + ).toThrow("Order conversion quote cannot be represented as Uint64 ratio"); + }); + + it("returns no iCKB-to-CKB estimate when default and dust quotes are unrepresentable", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1n }, + system({ + exchangeRatio: Ratio.from({ + ckbScale: (1n << 64n) - 1n, + udtScale: 1n, + }), + ckbAvailable: 1n, + }), + ); + + expect(result).toBeUndefined(); + }); + + it("returns no iCKB-to-CKB estimate when both default and dust estimates are missing", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 0n }, + system({ ckbAvailable: 0n }), + ); + + expect(result).toBeUndefined(); + }); +}); + +describe(`${ESTIMATE_SUITE} dust fallback`, () => { + it("uses a dust estimate when the default iCKB-to-CKB quote is unrepresentable", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1n }, + system({ + exchangeRatio: Ratio.from({ ckbScale: 1n << 80n, udtScale: 1n }), + ckbAvailable: 1n << 80n, + feeRate: 0n, + }), + ); + + expect(result).toMatchObject({ + maturity: 600000n, + notice: { kind: DUST_ICKB_TO_CKB, incentiveCkb: 0n }, + }); + }); + + it("keeps a base estimate without fee search when fee thresholds are disabled", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1n }, + system({ ckbAvailable: 1n, feeRate: 0n }), + ); + + expect(result).toMatchObject({ + maturity: 600000n, + }); + expect(result?.notice).toBeUndefined(); + }); + + it("returns no iCKB-to-CKB estimate when the base quote converts to zero", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1n }, + system({ + exchangeRatio: Ratio.from({ ckbScale: 2n, udtScale: 1n }), + ckbAvailable: 1n, + }), + ); + + expect(result).toBeUndefined(); + }); + + it("uses a dust quote when the default quote has no actionable maturity", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 100000n }, + system({ + ckbAvailable: 100000n, + feeRate: 1n, + tip: headerLike(0n, { timestamp: 1234n }), + }), + ); + + expect(result).toMatchObject({ + maturity: 601234n, + notice: { kind: DUST_ICKB_TO_CKB, maturityEstimateUnavailable: false }, + }); + }); + + it("returns no dust estimate when no fee reaches the maturity threshold", () => { + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 2n }, + system({ + ckbAvailable: 2n, + feeRate: 1n, + }), + ); + + expect(result).toBeUndefined(); + }); +}); + +describe(`${ESTIMATE_SUITE} dust order validity`, () => { + it("keeps one-sat iCKB-to-CKB dust state-valid but not bot-actionable", () => { + const orderManager = new OrderManager(script("55"), [], script("66")); + const estimate = IckbSdk.estimate(false, { ckbValue: 0n, udtValue: 1n }, system(), { + fee: 0n, + }); + const order = orderFromEstimate(estimate.info, { + ckbValue: 0n, + udtValue: 1n, + }); + + const match = orderManager.match(order, false, 1n); + const botMatch = OrderManager.bestMatch( + [order], + { ckbValue: 1n, udtValue: 0n }, + ratio, + { + feeRate: 1n, + ckbAllowanceStep: 1n, + }, + ); + + expect(match.partials).toHaveLength(1); + expect(match.partials[0]).toMatchObject({ + ckbOut: order.ckbValue + 1n, + udtOut: 0n, + }); + expect(match.ckbDelta).toBe(-1n); + expect(match.udtDelta).toBe(1n); + expect(botMatch.partials).toHaveLength(0); + }); + + it("builds dust iCKB-to-CKB orders with quote-preserving Uint64 encoding", () => { + const maxUint64 = (1n << 64n) - 1n; + const exchangeRatio = Ratio.from({ + ckbScale: 10000000000000000n, + udtScale: 11850413696044750n, + }); + const result = IckbSdk.estimateIckbToCkbOrder( + { ckbValue: 0n, udtValue: 1000000n }, + system({ + exchangeRatio, + ckbAvailable: ccc.fixedPointFrom("3102.81677146"), + feeRate: 33222n, + tip: headerLike(0n, { timestamp: 1234n }), + }), + ); + + if (result === undefined) { + throw new Error("Expected dust iCKB-to-CKB order estimate"); + } + expect(result.maturity).toBe(601234n); + expect(result.notice).toMatchObject({ + kind: DUST_ICKB_TO_CKB, + inputIckb: 1000000n, + maturityEstimateUnavailable: false, + }); + expect(result.estimate.ckbFee).toBeGreaterThanOrEqual(332220n); + expect(result.estimate.convertedAmount).toBeGreaterThan(0n); + expect(result.estimate.info.udtToCkb.ckbScale).toBeLessThanOrEqual(maxUint64); + expect(result.estimate.info.udtToCkb.udtScale).toBeLessThanOrEqual(maxUint64); + }); +}); diff --git a/packages/sdk/test/estimate/estimate_maturity_preview_thresholds.ts b/packages/sdk/test/estimate/estimate_maturity_preview_thresholds.ts new file mode 100644 index 0000000..2fa8d44 --- /dev/null +++ b/packages/sdk/test/estimate/estimate_maturity_preview_thresholds.ts @@ -0,0 +1,78 @@ +import { ccc } from "@ckb-ccc/core"; +import { Ratio } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { estimateMaturityFeeThreshold, IckbSdk } from "../../src/sdk.ts"; +import { headerLike, system } from "../transaction/base/support/sdk_core_support.ts"; +import { ESTIMATE_SUITE } from "./support/estimate_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(ESTIMATE_SUITE, () => { + it("exposes the fee threshold used for maturity previews", () => { + expect(estimateMaturityFeeThreshold(system({ feeRate: 7n }))).toBe(70n); + }); + + it("omits maturity below the fee threshold", () => { + const result = IckbSdk.estimate( + false, + { ckbValue: 0n, udtValue: 100000n }, + system({ ckbAvailable: 100000n }), + ); + + expect(result.convertedAmount).toBe(99999n); + expect(result.ckbFee).toBe(1n); + expect(result.maturity).toBeUndefined(); + }); + + it("uses the chain tip timestamp for preview maturity", () => { + const result = IckbSdk.estimate( + false, + { ckbValue: 0n, udtValue: 1000000n }, + system({ + ckbAvailable: 1000000n, + tip: headerLike(0n, { timestamp: 1234n }), + }), + ); + + expect(result.convertedAmount).toBe(999990n); + 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(); + }); + + it("uses the fee-adjusted CKB output for UDT-to-CKB maturity", () => { + const exchangeRatio = Ratio.from({ + ckbScale: 10000000000000000n, + udtScale: 10008200000000000n, + }); + + const result = IckbSdk.estimate( + false, + { ckbValue: 0n, udtValue: ccc.fixedPointFrom("100000.001") }, + system({ + exchangeRatio, + ckbAvailable: ccc.fixedPointFrom(100082), + }), + ); + + expect(result.convertedAmount).toBeLessThan(ccc.fixedPointFrom(100082)); + expect(result.maturity).toBe(600000n); + }); +}); diff --git a/packages/sdk/test/estimate/ickb_sdk_maturity_calculation.ts b/packages/sdk/test/estimate/ickb_sdk_maturity_calculation.ts new file mode 100644 index 0000000..5fc4a54 --- /dev/null +++ b/packages/sdk/test/estimate/ickb_sdk_maturity_calculation.ts @@ -0,0 +1,223 @@ +import { ccc } from "@ckb-ccc/core"; +import { Info } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IckbSdk } from "../../src/sdk.ts"; +import { projectionOrderGroup } from "../conversion/planning/support/sdk_order_support.ts"; +import { + headerLike, + ratio, + system, +} from "../transaction/base/support/sdk_core_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("IckbSdk.maturity", () => { + it("returns undefined for dual-ratio orders", () => { + const dualRatio = new Info(ratio, ratio, 1); + + expect( + IckbSdk.maturity( + { info: dualRatio, amounts: { ckbValue: 1n, udtValue: 1n } }, + system(), + ), + ).toBeUndefined(); + }); + + it("returns zero for already fulfilled orders", () => { + expect( + IckbSdk.maturity( + { + info: Info.create(true, ratio), + amounts: { ckbValue: 0n, udtValue: 0n }, + }, + system(), + ), + ).toBe(0n); + }); + + it("returns the baseline maturity when enough CKB is already available", () => { + expect( + IckbSdk.maturity( + { + info: Info.create(false, ratio), + amounts: { ckbValue: 0n, udtValue: 100n }, + }, + system({ + ckbAvailable: 100n, + tip: headerLike(0n, { timestamp: 1234n }), + }), + ), + ).toBe(601234n); + }); +}); + +describe("IckbSdk.maturity CKB availability", () => { + it("picks the first matching maturing CKB entry", () => { + expect( + IckbSdk.maturity( + { + info: Info.create(false, ratio), + amounts: { ckbValue: 0n, udtValue: 100n }, + }, + system({ + ckbMaturing: [ + { ckbCumulative: 50n, maturity: 1000n }, + { ckbCumulative: 100n, maturity: 2000n }, + { ckbCumulative: 150n, maturity: 3000n }, + ], + }), + ), + ).toBe(2000n); + }); + + it("counts existing CKB in UDT-to-CKB orders before requiring pool liquidity", () => { + const info = Info.create(false, { + ckbScale: 9n, + udtScale: 10n, + }); + + expect( + IckbSdk.maturity( + { + info, + amounts: { + ckbValue: 7n, + udtValue: 10n, + }, + }, + system({ + ckbMaturing: [ + { ckbCumulative: 5n, maturity: 1000n }, + { ckbCumulative: 6n, maturity: 2000n }, + ], + }), + ), + ).toBe(1000n); + }); +}); + +describe("IckbSdk.maturity order pool pressure", () => { + it("scales CKB-to-iCKB maturity when positive pressure exceeds the threshold", () => { + expect( + IckbSdk.maturity( + { + info: Info.create(true, ratio), + amounts: { ckbValue: ccc.fixedPointFrom(400000), udtValue: 0n }, + }, + system({ + tip: headerLike(0n, { timestamp: 100n }), + }), + ), + ).toBe(1800100n); + }); + + it("counts opposing order pressure before estimating maturity", () => { + const pressure = projectionOrderGroup({ + ckbValue: 0n, + udtValue: 50n, + isDualRatio: false, + isMatchable: true, + }).order; + + expect( + IckbSdk.maturity( + { + info: Info.create(true, ratio), + amounts: { ckbValue: 100n, udtValue: 0n }, + }, + system({ + orderPool: [pressure], + tip: headerLike(0n, { timestamp: 100n }), + }), + ), + ).toBe(600100n); + }); + + it("counts UDT-to-CKB pressure at better reference ratios", () => { + const pressure = projectionOrderGroup({ + ckbValue: 0n, + udtValue: 25n, + isDualRatio: false, + isMatchable: true, + }).order; + pressure.data.info = Info.create(false, { ckbScale: 2n, udtScale: 1n }); + + expect( + IckbSdk.maturity( + { + info: Info.create(false, ratio), + amounts: { ckbValue: 0n, udtValue: 100n }, + }, + system({ + ckbMaturing: [{ ckbCumulative: 100n, maturity: 1234n }], + orderPool: [pressure], + }), + ), + ).toBeUndefined(); + }); +}); + +describe("IckbSdk.maturity order pressure", () => { + it("keeps the base maturity when CKB-to-iCKB pressure is not positive", () => { + expect( + IckbSdk.maturity( + { + info: Info.create(true, ratio), + amounts: { ckbValue: 100n, udtValue: 0n }, + }, + system({ + tip: headerLike(0n, { timestamp: 100n }), + }), + ), + ).toBe(600100n); + }); + + it("keeps the base maturity when CKB-to-iCKB pressure is negative", () => { + const pressure = projectionOrderGroup({ + ckbValue: 0n, + udtValue: 200n, + isDualRatio: false, + isMatchable: true, + }).order; + pressure.data.info = Info.create(false, ratio); + + expect( + IckbSdk.maturity( + { + info: Info.create(true, ratio), + amounts: { ckbValue: 100n, udtValue: 0n }, + }, + system({ + orderPool: [pressure], + tip: headerLike(0n, { timestamp: 100n }), + }), + ), + ).toBe(600100n); + }); + + it("ignores UDT-to-CKB order pressure outside the reference direction", () => { + const pressure = projectionOrderGroup({ + ckbValue: 0n, + udtValue: 50n, + isDualRatio: false, + isMatchable: true, + }).order; + pressure.data.info = Info.create(false, { ckbScale: 1n, udtScale: 2n }); + + expect( + IckbSdk.maturity( + { + info: Info.create(false, ratio), + amounts: { ckbValue: 0n, udtValue: 100n }, + }, + system({ + orderPool: [pressure], + ckbAvailable: 100n, + tip: headerLike(0n, { timestamp: 100n }), + }), + ), + ).toBe(600100n); + }); +}); diff --git a/packages/sdk/test/estimate/sdk_maturity_guard_helpers.ts b/packages/sdk/test/estimate/sdk_maturity_guard_helpers.ts new file mode 100644 index 0000000..3c7a037 --- /dev/null +++ b/packages/sdk/test/estimate/sdk_maturity_guard_helpers.ts @@ -0,0 +1,85 @@ +import { ccc } from "@ckb-ccc/core"; +import { Info } from "@ickb/order"; +import { describe, expect, it } from "vitest"; +import { estimateConversionOrder } from "../../src/estimate/sdk_estimate.ts"; +import { maturity } from "../../src/estimate/sdk_maturity.ts"; +import { ringTargetSegmentIndex } from "../../src/withdrawal/withdrawal_selection.ts"; +import { projectionOrderGroup } from "../conversion/planning/support/sdk_order_support.ts"; +import { headerLike, ratio } from "../transaction/base/support/sdk_core_support.ts"; + +describe("sdk maturity and withdrawal guard helpers", () => { + it("covers maturity and estimate helper guard branches", () => { + const highFeeSystem = { + feeRate: 1n, + tip: headerLike(0n, { timestamp: 100n }), + exchangeRatio: ratio, + orderPool: [ + projectionOrderGroup({ + ckbValue: 10n, + udtValue: 0n, + isDualRatio: false, + isMatchable: true, + }).order, + ], + ckbAvailable: 0n, + ckbMaturing: [{ ckbCumulative: 100n, maturity: 500n }], + }; + + expect( + maturity( + { + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + amounts: { ckbValue: 0n, udtValue: 0n }, + }, + highFeeSystem, + ), + ).toBe(0n); + expect( + maturity( + { + info: Info.create(false, { ckbScale: 1n, udtScale: 1n }), + amounts: { ckbValue: -50n, udtValue: 10n }, + }, + highFeeSystem, + ), + ).toBe(500n); + expect( + maturity( + { + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + amounts: { ckbValue: 10n, udtValue: 0n }, + }, + highFeeSystem, + ), + ).toBe(600100n); + expect( + estimateConversionOrder( + true, + { ckbValue: 0n, udtValue: 0n }, + highFeeSystem, + 0n, + 100000n, + ), + ).toBeUndefined(); + expect(() => + estimateConversionOrder( + true, + { ckbValue: -1n, udtValue: 0n }, + highFeeSystem, + 0n, + 100000n, + ), + ).toThrow("Order conversion amounts cannot be negative"); + }); + + it("rejects invalid ring target epochs", () => { + expect(() => + ringTargetSegmentIndex( + headerLike(0n, { + epoch: ccc.Epoch.from({ integer: 0n, numerator: 0n, denominator: 0n }), + }), + 1, + ), + ).toThrow("Epoch denominator must be positive"); + }); +}); diff --git a/packages/sdk/test/estimate/support/estimate_support.ts b/packages/sdk/test/estimate/support/estimate_support.ts new file mode 100644 index 0000000..f6c5ea5 --- /dev/null +++ b/packages/sdk/test/estimate/support/estimate_support.ts @@ -0,0 +1 @@ +export const ESTIMATE_SUITE = "IckbSdk.estimate"; diff --git a/packages/sdk/test/index.ts b/packages/sdk/test/index.ts new file mode 100644 index 0000000..7d60d0f --- /dev/null +++ b/packages/sdk/test/index.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import * as sdk from "../src/index.ts"; +import { + nativeUdtCell, + plainCapacityCell, +} from "./conversion/withdrawal_quotes/support/sdk_cell_support.ts"; + +describe("sdk package barrel", () => { + it("routes runtime behavior through package exports", () => { + const capacityCell = plainCapacityCell(5n); + const udtCell = nativeUdtCell(7n); + const account = { + capacityCells: [capacityCell], + nativeUdtCells: [udtCell], + nativeUdtCapacity: udtCell.cellOutput.capacity, + nativeUdtBalance: 7n, + receipts: [], + withdrawalGroups: [], + }; + const ckbNative = capacityCell.cellOutput.capacity; + + expect(sdk.IckbSdk.fromConfig(sdk.getConfig("testnet"))).toBeInstanceOf(sdk.IckbSdk); + expect(sdk.estimateMaturityFeeThreshold({ feeRate: 2n })).toBe(20n); + expect(sdk.projectAccountAvailability(account, [])).toMatchObject({ + ckbNative, + ckbAvailable: ckbNative, + ickbNative: 7n, + ickbAvailable: 7n, + }); + }); +}); diff --git a/packages/sdk/test/sdk_error.ts b/packages/sdk/test/sdk_error.ts new file mode 100644 index 0000000..0813408 --- /dev/null +++ b/packages/sdk/test/sdk_error.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { errorOf } from "../src/client/sdk_error.ts"; + +describe("errorOf", () => { + it("preserves thrown strings as error messages", () => { + const error = errorOf("plain failure"); + + expect(error.message).toBe("plain failure"); + expect(error.cause).toBe("plain failure"); + }); + + it("serializes bigint and date values in object errors", () => { + const thrown = { + amount: 42n, + validDate: new Date("2026-01-02T03:04:05.000Z"), + invalidDate: new Date(Number.NaN), + }; + const error = errorOf(thrown); + + expect(error.message).toBe( + '{"amount":"42","validDate":"2026-01-02T03:04:05.000Z","invalidDate":null}', + ); + expect(error.cause).toBe(thrown); + }); +}); diff --git a/packages/sdk/test/send/send_and_wait_commit_success_and_prebroadcast_events.ts b/packages/sdk/test/send/send_and_wait_commit_success_and_prebroadcast_events.ts new file mode 100644 index 0000000..96d73ec --- /dev/null +++ b/packages/sdk/test/send/send_and_wait_commit_success_and_prebroadcast_events.ts @@ -0,0 +1,193 @@ +import { ccc } from "@ckb-ccc/core"; +import { StubClient } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sendAndWaitForCommit, type SendAndWaitForCommitEvent } from "../../src/sdk.ts"; +import { signerWithSendTransaction } from "../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { hash } from "../transaction/base/support/sdk_core_support.ts"; +import { + ClearableCache, + noopAsync, + NoRequestorStubClient, + SEND_AND_WAIT_SUITE, + transactionStatus, + TransactionStatusStubClient, +} from "./support/sdk_send_wait_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(SEND_AND_WAIT_SUITE, () => { + it("waits for a sent transaction to commit before returning the hash", async () => { + const txHash = hash("a1"); + const sleep = vi.fn(noopAsync); + const onConfirmationWait = vi.fn(); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(); + const sendTransaction = vi.fn().mockResolvedValue(txHash); + const request = vi + .fn() + .mockResolvedValueOnce(transactionStatus("pending")) + .mockResolvedValueOnce(transactionStatus("unknown")) + .mockResolvedValueOnce(transactionStatus("committed")); + + await expect( + sendAndWaitForCommit( + { + client: new TransactionStatusStubClient(request), + signer: signerWithSendTransaction(sendTransaction), + }, + ccc.Transaction.default(), + { + confirmationIntervalMs: 7, + onConfirmationWait: () => { + onConfirmationWait(); + }, + onLifecycle, + sleep, + }, + ), + ).resolves.toBe(txHash); + + expect(sendTransaction).toHaveBeenCalledTimes(1); + expect(onConfirmationWait).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledWith(7); + expect(request).toHaveBeenCalledTimes(3); + expect(request).toHaveBeenCalledWith("get_transaction", [txHash, "0x1"]); + expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ + { type: "broadcasted", txHash }, + { type: "committed", txHash, status: "committed", checks: 3 }, + ]); + }); +}); + +describe(`${SEND_AND_WAIT_SUITE} failure events`, () => { + it("emits pre-broadcast lifecycle failures without changing the thrown error", async () => { + const error = new Error("broadcast failed"); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(); + + await expect( + sendAndWaitForCommit( + { + client: new TransactionStatusStubClient(vi.fn()), + signer: signerWithSendTransaction(vi.fn().mockRejectedValue(error)), + }, + ccc.Transaction.default(), + { onLifecycle }, + ), + ).rejects.toBe(error); + + expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ + { type: "pre_broadcast_failed", error }, + ]); + }); +}); + +describe(`${SEND_AND_WAIT_SUITE} polling responses`, () => { + it("requires a JSON-RPC requestor before broadcast", async () => { + const txHash = hash("a9"); + const sendTransaction = vi.fn().mockResolvedValue(txHash); + + await expect( + sendAndWaitForCommit( + { + client: new NoRequestorStubClient(), + signer: signerWithSendTransaction(sendTransaction), + }, + ccc.Transaction.default(), + ), + ).rejects.toThrow("sendAndWaitForCommit requires a JSON-RPC client requestor"); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("requires the JSON-RPC requestor to expose a request function", async () => { + const txHash = hash("ad"); + const client = new StubClient(); + const sendTransaction = vi.fn().mockResolvedValue(txHash); + Object.defineProperty(client, "requestor", { value: {} }); + + await expect( + sendAndWaitForCommit( + { + client, + signer: signerWithSendTransaction(sendTransaction), + }, + ccc.Transaction.default(), + ), + ).rejects.toThrow("sendAndWaitForCommit requires a JSON-RPC client requestor"); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("treats malformed transaction status responses as pending", async () => { + const txHash = hash("aa"); + const request = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ tx_status: [] }) + .mockResolvedValueOnce(transactionStatus("committed")); + + await expect( + sendAndWaitForCommit( + { + client: new TransactionStatusStubClient(request), + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + { confirmationIntervalMs: 0, sleep: noopAsync }, + ), + ).resolves.toBe(txHash); + + expect(request).toHaveBeenCalledTimes(3); + }); + + it("treats non-string statuses as unknown while polling", async () => { + const txHash = hash("ab"); + const request = vi + .fn() + .mockResolvedValueOnce({ tx_status: { status: 1 } }) + .mockResolvedValueOnce(transactionStatus("committed")); + + await expect( + sendAndWaitForCommit( + { + client: new TransactionStatusStubClient(request), + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + { confirmationIntervalMs: 0, sleep: noopAsync }, + ), + ).resolves.toBe(txHash); + }); +}); + +describe(`${SEND_AND_WAIT_SUITE} terminal rejections`, () => { + it("emits terminal rejection details and clears cached transaction state", async () => { + const txHash = hash("ac"); + const clear = vi.fn(noopAsync); + const request = vi.fn().mockResolvedValue(transactionStatus("rejected", "bad tx")); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(); + const client = new TransactionStatusStubClient(request, new ClearableCache(clear)); + + await expect( + sendAndWaitForCommit( + { + client, + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + { onLifecycle }, + ), + ).rejects.toMatchObject({ + txHash, + status: "rejected", + reason: "bad tx", + isTimeout: false, + }); + + expect(clear).toHaveBeenCalledTimes(1); + expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ + { type: "broadcasted", txHash }, + { type: "terminal_rejection", txHash, status: "rejected", reason: "bad tx" }, + ]); + }); +}); diff --git a/packages/sdk/test/send/send_and_wait_confirmation_timeout_hash.ts b/packages/sdk/test/send/send_and_wait_confirmation_timeout_hash.ts new file mode 100644 index 0000000..1dc87b6 --- /dev/null +++ b/packages/sdk/test/send/send_and_wait_confirmation_timeout_hash.ts @@ -0,0 +1,67 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sendAndWaitForCommit, type SendAndWaitForCommitEvent } from "../../src/sdk.ts"; +import { signerWithSendTransaction } from "../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { hash } from "../transaction/base/support/sdk_core_support.ts"; +import { + noopAsync, + SEND_AND_WAIT_SUITE, + transactionStatus, + TransactionStatusStubClient, +} from "./support/sdk_send_wait_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const SEND_AND_WAIT_REJECT = "Expected sendAndWaitForCommit to reject"; + +const TRANSACTION_CONFIRMATION_TIMED_OUT = "Transaction confirmation timed out"; + +describe(SEND_AND_WAIT_SUITE, () => { + it("surfaces transaction confirmation timeouts with the broadcast hash", async () => { + const txHash = hash("a3"); + const onSent = vi.fn(); + const sleep = vi.fn(noopAsync); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(); + + let caught: unknown; + try { + await sendAndWaitForCommit( + { + client: new TransactionStatusStubClient( + vi.fn().mockResolvedValue(transactionStatus("unknown")), + ), + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + { + maxConfirmationChecks: 1, + onLifecycle, + onSent: (sentTxHash) => { + onSent(sentTxHash); + }, + sleep, + }, + ); + } catch (error) { + caught = error; + } + + expect(caught ?? new Error(SEND_AND_WAIT_REJECT)).toBeInstanceOf(Error); + expect(caught).toMatchObject({ name: "TransactionConfirmationError" }); + expect(caught).toMatchObject({ + message: TRANSACTION_CONFIRMATION_TIMED_OUT, + txHash, + status: "unknown", + isTimeout: true, + }); + + expect(onSent).toHaveBeenCalledWith(txHash); + expect(sleep).not.toHaveBeenCalled(); + expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ + { type: "broadcasted", txHash }, + { type: "timeout_after_broadcast", txHash, status: "unknown", checks: 1 }, + ]); + }); +}); diff --git a/packages/sdk/test/send/send_and_wait_lifecycle_callback_failures.ts b/packages/sdk/test/send/send_and_wait_lifecycle_callback_failures.ts new file mode 100644 index 0000000..9a46c7f --- /dev/null +++ b/packages/sdk/test/send/send_and_wait_lifecycle_callback_failures.ts @@ -0,0 +1,73 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sendAndWaitForCommit, type SendAndWaitForCommitEvent } from "../../src/sdk.ts"; +import { signerWithSendTransaction } from "../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { baseClient, hash } from "../transaction/base/support/sdk_core_support.ts"; +import { + SEND_AND_WAIT_SUITE, + transactionStatus, + TransactionStatusStubClient, +} from "./support/sdk_send_wait_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(SEND_AND_WAIT_SUITE, () => { + it("does not let lifecycle callback failures replace send errors", async () => { + const error = new Error("broadcast failed"); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(() => { + throw new Error("observer failed"); + }); + + await expect( + sendAndWaitForCommit( + { + client: baseClient, + signer: signerWithSendTransaction(vi.fn().mockRejectedValue(error)), + }, + ccc.Transaction.default(), + { onLifecycle }, + ), + ).rejects.toBe(error); + + expect(onLifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: "pre_broadcast_failed", + error, + }), + ); + }); + + it("does not let lifecycle callback failures interrupt confirmation", async () => { + const txHash = hash("a7"); + const onSent = vi.fn(); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(() => { + throw new Error("observer failed"); + }); + + await expect( + sendAndWaitForCommit( + { + client: new TransactionStatusStubClient( + vi.fn().mockResolvedValue(transactionStatus("committed")), + ), + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + { + onLifecycle, + onSent: (sentTxHash) => { + onSent(sentTxHash); + }, + }, + ), + ).resolves.toBe(txHash); + + expect(onSent).toHaveBeenCalledWith(txHash); + expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ + { type: "broadcasted", txHash }, + { type: "committed", txHash, status: "committed", checks: 1 }, + ]); + }); +}); diff --git a/packages/sdk/test/send/send_and_wait_polling_failure_unconfirmed.ts b/packages/sdk/test/send/send_and_wait_polling_failure_unconfirmed.ts new file mode 100644 index 0000000..c56b4de --- /dev/null +++ b/packages/sdk/test/send/send_and_wait_polling_failure_unconfirmed.ts @@ -0,0 +1,57 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sendAndWaitForCommit, type SendAndWaitForCommitEvent } from "../../src/sdk.ts"; +import { signerWithSendTransaction } from "../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { hash } from "../transaction/base/support/sdk_core_support.ts"; +import { + noopAsync, + SEND_AND_WAIT_SUITE, + transactionStatus, + TransactionStatusStubClient, +} from "./support/sdk_send_wait_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(SEND_AND_WAIT_SUITE, () => { + it("treats post-broadcast polling failures as unconfirmed", async () => { + const txHash = hash("a4"); + const onSent = vi.fn(); + const onConfirmationWait = vi.fn(); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(); + const sleep = vi.fn(noopAsync); + const request = vi + .fn() + .mockRejectedValueOnce(new Error("RPC down")) + .mockResolvedValueOnce(transactionStatus("committed")); + + await expect( + sendAndWaitForCommit( + { + client: new TransactionStatusStubClient(request), + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + { + onConfirmationWait: () => { + onConfirmationWait(); + }, + onLifecycle, + onSent: (sentTxHash) => { + onSent(sentTxHash); + }, + sleep, + }, + ), + ).resolves.toBe(txHash); + + expect(onSent).toHaveBeenCalledWith(txHash); + expect(onConfirmationWait).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalledTimes(1); + expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ + { type: "broadcasted", txHash }, + { type: "committed", txHash, status: "committed", checks: 2 }, + ]); + }); +}); diff --git a/packages/sdk/test/send/send_and_wait_post_broadcast_poll_timeout.ts b/packages/sdk/test/send/send_and_wait_post_broadcast_poll_timeout.ts new file mode 100644 index 0000000..cfc292c --- /dev/null +++ b/packages/sdk/test/send/send_and_wait_post_broadcast_poll_timeout.ts @@ -0,0 +1,66 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sendAndWaitForCommit, type SendAndWaitForCommitEvent } from "../../src/sdk.ts"; +import { signerWithSendTransaction } from "../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { hash } from "../transaction/base/support/sdk_core_support.ts"; +import { + noopAsync, + SEND_AND_WAIT_SUITE, + TransactionStatusStubClient, +} from "./support/sdk_send_wait_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const SEND_AND_WAIT_REJECT = "Expected sendAndWaitForCommit to reject"; + +const TRANSACTION_CONFIRMATION_TIMED_OUT = "Transaction confirmation timed out"; + +describe(SEND_AND_WAIT_SUITE, () => { + it("times out if post-broadcast polling keeps failing", async () => { + const txHash = hash("a5"); + const pollingError = new Error("RPC down"); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(); + + let caught: unknown; + try { + await sendAndWaitForCommit( + { + client: new TransactionStatusStubClient( + vi.fn().mockRejectedValue(pollingError), + ), + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + { + maxConfirmationChecks: 1, + onLifecycle, + sleep: noopAsync, + }, + ); + } catch (error) { + caught = error; + } + + expect(caught ?? new Error(SEND_AND_WAIT_REJECT)).toBeInstanceOf(Error); + expect(caught).toMatchObject({ name: "TransactionConfirmationError" }); + expect(caught).toMatchObject({ + message: TRANSACTION_CONFIRMATION_TIMED_OUT, + txHash, + status: "sent", + isTimeout: true, + }); + expect(caught).toHaveProperty("cause", pollingError); + expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ + { type: "broadcasted", txHash }, + { + type: "post_broadcast_unresolved", + txHash, + status: "sent", + checks: 1, + error: pollingError, + }, + ]); + }); +}); diff --git a/packages/sdk/test/send/send_and_wait_terminal_status_cache_clearing.ts b/packages/sdk/test/send/send_and_wait_terminal_status_cache_clearing.ts new file mode 100644 index 0000000..a4b5c1e --- /dev/null +++ b/packages/sdk/test/send/send_and_wait_terminal_status_cache_clearing.ts @@ -0,0 +1,90 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sendAndWaitForCommit, type SendAndWaitForCommitEvent } from "../../src/sdk.ts"; +import { signerWithSendTransaction } from "../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { hash } from "../transaction/base/support/sdk_core_support.ts"; +import { + ClearableCache, + noopAsync, + SEND_AND_WAIT_SUITE, + transactionStatus, + TransactionStatusStubClient, +} from "./support/sdk_send_wait_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const SEND_AND_WAIT_REJECT = "Expected sendAndWaitForCommit to reject"; + +describe(SEND_AND_WAIT_SUITE, () => { + it("surfaces status-only terminal transaction failures and clears cache", async () => { + const txHash = hash("a2"); + const reason = + "Resolve failed Dead(OutPoint(0xabc000000000000000000000000000000000000000000000000000000000000000000000))"; + const sleep = vi.fn(noopAsync); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(); + const request = vi.fn().mockResolvedValue(transactionStatus("rejected", reason)); + const clear = vi.fn(noopAsync); + + let caught: unknown; + try { + await sendAndWaitForCommit( + { + client: new TransactionStatusStubClient(request, new ClearableCache(clear)), + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + { onLifecycle, sleep }, + ); + } catch (error) { + caught = error; + } + + expect(caught ?? new Error(SEND_AND_WAIT_REJECT)).toBeInstanceOf(Error); + expect(caught).toMatchObject({ name: "TransactionConfirmationError" }); + expect(caught).toMatchObject({ + message: `Transaction ended with status: rejected: ${reason}`, + txHash, + status: "rejected", + isTimeout: false, + reason, + }); + + expect(clear).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ + { type: "broadcasted", txHash }, + { + type: "terminal_rejection", + txHash, + status: "rejected", + reason, + checks: 1, + }, + ]); + }); + + it("formats terminal transaction failures without a reason", async () => { + const txHash = hash("a3"); + const request = vi.fn().mockResolvedValue(transactionStatus("rejected")); + const clear = vi.fn(noopAsync); + + await expect( + sendAndWaitForCommit( + { + client: new TransactionStatusStubClient(request, new ClearableCache(clear)), + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + ), + ).rejects.toMatchObject({ + message: "Transaction ended with status: rejected", + txHash, + status: "rejected", + isTimeout: false, + }); + + expect(clear).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/send/send_and_wait_timeout_proven_after_pending_poll.ts b/packages/sdk/test/send/send_and_wait_timeout_proven_after_pending_poll.ts new file mode 100644 index 0000000..8650f25 --- /dev/null +++ b/packages/sdk/test/send/send_and_wait_timeout_proven_after_pending_poll.ts @@ -0,0 +1,64 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sendAndWaitForCommit, type SendAndWaitForCommitEvent } from "../../src/sdk.ts"; +import { signerWithSendTransaction } from "../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { hash } from "../transaction/base/support/sdk_core_support.ts"; +import { + noopAsync, + SEND_AND_WAIT_SUITE, + transactionStatus, + TransactionStatusStubClient, +} from "./support/sdk_send_wait_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const SEND_AND_WAIT_REJECT = "Expected sendAndWaitForCommit to reject"; + +const TRANSACTION_CONFIRMATION_TIMED_OUT = "Transaction confirmation timed out"; + +describe(SEND_AND_WAIT_SUITE, () => { + it("classifies timeout as proven after a successful pending poll follows a transient polling error", async () => { + const txHash = hash("a6"); + const pollingError = new Error("RPC down"); + const onLifecycle = vi.fn<(event: SendAndWaitForCommitEvent) => void>(); + + let caught: unknown; + try { + await sendAndWaitForCommit( + { + client: new TransactionStatusStubClient( + vi + .fn() + .mockRejectedValueOnce(pollingError) + .mockResolvedValueOnce(transactionStatus("unknown")), + ), + signer: signerWithSendTransaction(vi.fn().mockResolvedValue(txHash)), + }, + ccc.Transaction.default(), + { + maxConfirmationChecks: 2, + onLifecycle, + sleep: noopAsync, + }, + ); + } catch (error) { + caught = error; + } + + expect(caught ?? new Error(SEND_AND_WAIT_REJECT)).toBeInstanceOf(Error); + expect(caught).toMatchObject({ name: "TransactionConfirmationError" }); + expect(caught).toMatchObject({ + message: TRANSACTION_CONFIRMATION_TIMED_OUT, + txHash, + status: "unknown", + isTimeout: true, + }); + expect(caught).not.toHaveProperty("cause", pollingError); + expect(onLifecycle.mock.calls.map(([event]) => event)).toMatchObject([ + { type: "broadcasted", txHash }, + { type: "timeout_after_broadcast", txHash, status: "unknown", checks: 2 }, + ]); + }); +}); diff --git a/packages/sdk/test/send/support/sdk_send_wait_support.ts b/packages/sdk/test/send/support/sdk_send_wait_support.ts new file mode 100644 index 0000000..84fcf06 --- /dev/null +++ b/packages/sdk/test/send/support/sdk_send_wait_support.ts @@ -0,0 +1,108 @@ +import { ccc } from "@ckb-ccc/core"; +import { StubClient } from "@ickb/testkit"; +import { vi } from "vitest"; + +export const SEND_AND_WAIT_SUITE = "sendAndWaitForCommit"; + +export function transactionStatus( + status: string, + reason?: string, +): TransactionStatusFixture { + return { + tx_status: { + status, + ...(reason === undefined ? {} : { reason }), + }, + transaction: null, + }; +} + +export interface TransactionStatusFixture { + tx_status: { + status: string; + reason?: string; + }; + transaction: null; +} + +export class TransactionStatusStubClient extends StubClient { + public readonly request: (method: string, params: unknown[]) => Promise; + public readonly clear = vi.fn(noopAsync); + + constructor( + request: (method: string, params: unknown[]) => Promise, + cache?: ccc.ClientCache, + ) { + super({}); + this.request = request; + Object.defineProperty(this, "requestor", { + value: new ccc.RequestorJsonRpc("https://example.invalid", { + transport: { + request: async ( + payload, + ): Promise<{ id: number; result: unknown; error: null }> => { + await Promise.resolve(); + if (!Array.isArray(payload.params)) { + throw new TypeError("Expected JSON-RPC array params"); + } + return { + id: payload.id, + result: await this.request(payload.method, payload.params), + error: null, + }; + }, + }, + }), + }); + if (cache !== undefined) { + this.cache = cache; + } + } +} + +export class NoRequestorStubClient extends StubClient { + constructor() { + super({}); + Object.defineProperty(this, "requestor", { value: undefined }); + } +} + +export class ClearableCache extends ccc.ClientCache { + private readonly clearHandler: () => Promise; + + constructor(clear: () => Promise) { + super(); + this.clearHandler = clear; + } + + public override async clear(): Promise { + await this.clearHandler(); + } + + public override async markUsableNoCache(): Promise { + await Promise.resolve(); + } + + public override async markUnusable(): Promise { + await Promise.resolve(); + } + + public override async *findCells(): AsyncGenerator { + yield* none(); + } + + public override async isUnusable(): Promise { + await Promise.resolve(); + return false; + } +} + +export async function noopAsync(): Promise { + await Promise.resolve(); +} + +async function* none(): AsyncGenerator { + const values: T[] = []; + yield* values; + await Promise.resolve(); +} diff --git a/packages/sdk/test/state/account/account_availability_actionable_pending_split.ts b/packages/sdk/test/state/account/account_availability_actionable_pending_split.ts new file mode 100644 index 0000000..aef7612 --- /dev/null +++ b/packages/sdk/test/state/account/account_availability_actionable_pending_split.ts @@ -0,0 +1,92 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { projectAccountAvailability } from "../../../src/sdk.ts"; +import { projectionOrderGroup } from "../../conversion/planning/support/sdk_order_support.ts"; +import { + nativeUdtCell, + plainCapacityCell, + receiptValue, + withdrawalValue, +} from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { ACCOUNT_AVAILABILITY_SUITE } from "./support/account_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(ACCOUNT_AVAILABILITY_SUITE, () => { + it("splits actionable and pending account value", () => { + const nativeCkb = ccc.fixedPointFrom(50); + const readyWithdrawal = withdrawalValue({ + ckbValue: 11n, + udtValue: 13n, + isReady: true, + byte: "32", + }); + const pendingWithdrawal = withdrawalValue({ + ckbValue: 17n, + udtValue: 19n, + isReady: false, + byte: "33", + }); + const availableOrder = projectionOrderGroup({ + ckbValue: 23n, + udtValue: 29n, + isDualRatio: true, + isMatchable: true, + }); + const pendingOrder = projectionOrderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + const nativeUdt = nativeUdtCell(7n, { capacity: 5n }); + + const projection = projectAccountAvailability( + { + capacityCells: [plainCapacityCell(nativeCkb)], + nativeUdtCells: [nativeUdt], + nativeUdtCapacity: nativeUdt.cellOutput.capacity, + nativeUdtBalance: 7n, + receipts: [receiptValue(41n, 43n)], + withdrawalGroups: [readyWithdrawal, pendingWithdrawal], + }, + [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(nativeCkb); + expect(projection.ickbNative).toBe(7n); + expect(projection.ckbAvailable).toBe(nativeCkb + 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("derives native iCKB from xUDT cells instead of the redundant total", () => { + const nativeUdt = nativeUdtCell(7n, { capacity: 5n }); + + const projection = projectAccountAvailability( + { + capacityCells: [], + nativeUdtCells: [nativeUdt], + nativeUdtCapacity: nativeUdt.cellOutput.capacity, + nativeUdtBalance: 99n, + receipts: [], + withdrawalGroups: [], + }, + [], + ); + + expect(projection.ickbNative).toBe(7n); + expect(projection.ickbAvailable).toBe(7n); + }); +}); diff --git a/packages/sdk/test/state/account/account_availability_collected_orders_and_udt.ts b/packages/sdk/test/state/account/account_availability_collected_orders_and_udt.ts new file mode 100644 index 0000000..4bff81f --- /dev/null +++ b/packages/sdk/test/state/account/account_availability_collected_orders_and_udt.ts @@ -0,0 +1,99 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { projectAccountAvailability } from "../../../src/sdk.ts"; +import { projectionOrderGroup } from "../../conversion/planning/support/sdk_order_support.ts"; +import { + nativeUdtCell, + plainCapacityCell, + withdrawalValue, +} from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { ACCOUNT_AVAILABILITY_SUITE } from "./support/account_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(ACCOUNT_AVAILABILITY_SUITE, () => { + it("can budget collected matchable orders as available", () => { + const matchable = projectionOrderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + + const projection = projectAccountAvailability( + { + capacityCells: [], + nativeUdtCells: [], + 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 nativeCkb = ccc.fixedPointFrom(50); + const nativeUdt = nativeUdtCell(7n, { capacity: 5n }); + const projection = projectAccountAvailability( + { + capacityCells: [plainCapacityCell(nativeCkb)], + nativeUdtCells: [nativeUdt], + nativeUdtCapacity: nativeUdt.cellOutput.capacity, + nativeUdtBalance: 7n, + receipts: [], + withdrawalGroups: [], + }, + [], + ); + + expect(projection.ckbNative).toBe(nativeCkb); + expect(projection.ckbAvailable).toBe(nativeCkb); + expect(projection.ckbBalance).toBe(nativeCkb); + }); + + it("does not count withdrawal UDT as available or pending iCKB", () => { + const nativeUdt = nativeUdtCell(7n, { byte: "45" }); + const projection = projectAccountAvailability( + { + capacityCells: [], + nativeUdtCells: [nativeUdt], + nativeUdtCapacity: nativeUdt.cellOutput.capacity, + nativeUdtBalance: 7n, + receipts: [], + withdrawalGroups: [ + withdrawalValue({ + ckbValue: 11n, + udtValue: 13n, + isReady: true, + byte: "34", + }), + withdrawalValue({ + ckbValue: 17n, + udtValue: 19n, + isReady: false, + byte: "35", + }), + ], + }, + [], + ); + + 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); + }); +}); diff --git a/packages/sdk/test/state/account/account_availability_user_order_matchability.ts b/packages/sdk/test/state/account/account_availability_user_order_matchability.ts new file mode 100644 index 0000000..000faf3 --- /dev/null +++ b/packages/sdk/test/state/account/account_availability_user_order_matchability.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { projectAccountAvailability } from "../../../src/sdk.ts"; +import { projectionOrderGroup } from "../../conversion/planning/support/sdk_order_support.ts"; +import { ACCOUNT_AVAILABILITY_SUITE } from "./support/account_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(ACCOUNT_AVAILABILITY_SUITE, () => { + it("treats non-matchable user orders as actionable", () => { + const nonMatchable = projectionOrderGroup({ + ckbValue: 23n, + udtValue: 29n, + isDualRatio: false, + isMatchable: false, + }); + + const projection = projectAccountAvailability( + { + capacityCells: [], + nativeUdtCells: [], + 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 = projectionOrderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + + const projection = projectAccountAvailability( + { + capacityCells: [], + nativeUdtCells: [], + 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); + }); +}); diff --git a/packages/sdk/test/state/account/project_conversion_transaction_context.ts b/packages/sdk/test/state/account/project_conversion_transaction_context.ts new file mode 100644 index 0000000..719d89d --- /dev/null +++ b/packages/sdk/test/state/account/project_conversion_transaction_context.ts @@ -0,0 +1,125 @@ +import { ccc } from "@ckb-ccc/core"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + projectConversionTransactionContext, + type SystemState, +} from "../../../src/sdk.ts"; +import { projectionOrderGroup } from "../../conversion/planning/support/sdk_order_support.ts"; +import { + nativeUdtCell, + receiptValue, + withdrawalValue, +} from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { + baseTip, + hash, + headerLike, + ratio, +} from "../../transaction/base/support/sdk_core_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function plainCapacityCell(capacity: bigint, lock = script("11"), byte = "10"): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: hash(byte), index: 0n }, + cellOutput: { capacity, lock }, + outputData: "0x", + }); +} + +function system(overrides: Partial = {}): SystemState { + return { + feeRate: 1n, + tip: baseTip, + exchangeRatio: ratio, + orderPool: [], + ckbAvailable: 0n, + ckbMaturing: [], + ...overrides, + }; +} + +describe("projectConversionTransactionContext", () => { + it("projects conversion context from account state and collected-order policy", () => { + const nativeCkb = ccc.fixedPointFrom(50); + const readyWithdrawal = withdrawalValue({ + ckbValue: 11n, + udtValue: 0n, + isReady: true, + byte: "36", + }); + const pendingWithdrawal = withdrawalValue({ + ckbValue: 17n, + udtValue: 0n, + isReady: false, + maturityUnix: 5000n, + byte: "37", + }); + const matchable = projectionOrderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + Object.defineProperty(matchable.order, "maturity", { value: 7000n }); + const receipt = receiptValue(41n, 43n); + const nativeUdt = nativeUdtCell(7n, { byte: "46" }); + const account = { + capacityCells: [plainCapacityCell(nativeCkb)], + nativeUdtCells: [nativeUdt], + nativeUdtCapacity: nativeUdt.cellOutput.capacity, + nativeUdtBalance: 7n, + receipts: [receipt], + withdrawalGroups: [readyWithdrawal, pendingWithdrawal], + }; + const currentSystem = system({ tip: headerLike(0n, { timestamp: 1000n }) }); + + const { projection, context } = projectConversionTransactionContext( + currentSystem, + account, + [matchable], + { + collectedOrdersAvailable: true, + }, + ); + + expect(projection.availableOrders).toEqual([matchable]); + expect(context).toEqual({ + system: currentSystem, + receipts: [receipt], + readyWithdrawals: [readyWithdrawal], + availableOrders: [matchable], + ckbAvailable: projection.ckbAvailable, + ickbAvailable: projection.ickbAvailable, + estimatedMaturity: 5000n, + }); + }); + + it("includes pending order maturity when collected orders are not budgeted", () => { + const matchable = projectionOrderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + Object.defineProperty(matchable.order, "maturity", { value: 7000n }); + + const { context } = projectConversionTransactionContext( + system({ tip: headerLike(0n, { timestamp: 1000n }) }), + { + capacityCells: [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + [matchable], + ); + + expect(context.estimatedMaturity).toBe(7000n); + }); +}); diff --git a/packages/sdk/test/state/account/sdk_projection_value_helpers.ts b/packages/sdk/test/state/account/sdk_projection_value_helpers.ts new file mode 100644 index 0000000..f89b11b --- /dev/null +++ b/packages/sdk/test/state/account/sdk_projection_value_helpers.ts @@ -0,0 +1,136 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { + botWithdrawalCkb, + cumulativeCkbMaturing, + mergeBotCkb, + normalizeCountLimit, + poolDepositCkb, + poolDepositsKey, + positiveMapValueSum, + sortDepositsByMaturity, + sumDirectWithdrawalSurplus, + sumUdtValue, +} from "../../../src/conversion/sdk_value_helpers.ts"; +import { + maxMaturity, + projectAccountAvailability, +} from "../../../src/estimate/sdk_projection.ts"; +import { projectionOrderGroup } from "../../conversion/planning/support/sdk_order_support.ts"; +import { + nativeUdtCell, + plainCapacityCell, + projectionReadyDeposit, + withdrawalValue, +} from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { baseTip, ratio } from "../../transaction/base/support/sdk_core_support.ts"; + +describe("sdk projection value helpers", () => { + it("covers CKB projection helper branches", () => { + const ready = withdrawalValue({ ckbValue: 10n, isReady: true, byte: "41" }); + const pending = withdrawalValue({ + ckbValue: 20n, + isReady: false, + maturityUnix: 30n, + byte: "42", + }); + const readyDeposit = projectionReadyDeposit(5n, 40n, { ckbValue: 50n, id: "43" }); + const pendingDeposit = projectionReadyDeposit(7n, 60n, { + ckbValue: 70n, + id: "44", + isReady: false, + }); + const left = new Map([["a", 1n]]); + const right = new Map([ + ["a", 2n], + ["b", 3n], + ]); + + const reserved = -ccc.fixedPointFrom("2000"); + const readyCkb = botWithdrawalCkb([ready, pending], baseTip).ready; + + expect(readyCkb.get(ready.owner.cell.cellOutput.lock.toHex())).toBe(reserved + 10n); + expect(botWithdrawalCkb([ready, pending], baseTip).maturing).toEqual([ + { ckbValue: 20n, maturity: 30n }, + ]); + expect( + cumulativeCkbMaturing([ + { ckbValue: 2n, maturity: 2n }, + { ckbValue: 3n, maturity: 1n }, + ]), + ).toEqual([ + { ckbCumulative: 3n, maturity: 1n }, + { ckbCumulative: 5n, maturity: 2n }, + ]); + expect(mergeBotCkb(left, right).get("a")).toBe(3n); + expect( + positiveMapValueSum( + new Map([ + ["a", -1n], + ["b", 3n], + ]), + ), + ).toBe(3n); + expect( + poolDepositCkb( + { + deposits: [readyDeposit, pendingDeposit], + readyDeposits: [readyDeposit], + id: "p", + }, + baseTip, + ), + ).toEqual({ + ready: 50n, + maturing: [{ ckbValue: 70n, maturity: 60n }], + }); + expect(poolDepositsKey([pendingDeposit, readyDeposit], baseTip)).toContain("pending"); + expect(sortDepositsByMaturity([pendingDeposit, readyDeposit], baseTip)).toEqual([ + readyDeposit, + pendingDeposit, + ]); + expect(sumDirectWithdrawalSurplus([readyDeposit], ratio)).toBe(45n); + expect(sumUdtValue([readyDeposit, pendingDeposit])).toBe(12n); + expect(normalizeCountLimit(2)).toBe(2); + expect(normalizeCountLimit(0)).toBe(0); + expect(normalizeCountLimit(0.5)).toBe(0); + }); +}); + +describe("sdk projection account availability", () => { + it("projects pending and available account values", () => { + const pending = projectionOrderGroup({ + ckbValue: 2n, + udtValue: 3n, + isDualRatio: false, + isMatchable: true, + }); + const dual = projectionOrderGroup({ + ckbValue: 5n, + udtValue: 7n, + isDualRatio: true, + isMatchable: true, + }); + const nativeUdt = nativeUdtCell(13n, { byte: "47" }); + const projection = projectAccountAvailability( + { + capacityCells: [plainCapacityCell(11n)], + nativeUdtCells: [nativeUdt], + nativeUdtCapacity: nativeUdt.cellOutput.capacity, + nativeUdtBalance: 13n, + receipts: [], + withdrawalGroups: [], + }, + [pending, dual], + ); + + expect(projection.availableOrders).toEqual([dual]); + expect(projection.pendingOrders).toEqual([pending]); + expect(projection.ckbBalance).toBe(projection.ckbAvailable + projection.ckbPending); + expect(projection.ickbBalance).toBe( + projection.ickbAvailable + projection.ickbPending, + ); + expect(maxMaturity(1n, 2n)).toBe(2n); + expect(maxMaturity(3n, 2n)).toBe(3n); + }); +}); diff --git a/packages/sdk/test/state/account/support/account_support.ts b/packages/sdk/test/state/account/support/account_support.ts new file mode 100644 index 0000000..1ee82f0 --- /dev/null +++ b/packages/sdk/test/state/account/support/account_support.ts @@ -0,0 +1 @@ +export const ACCOUNT_AVAILABILITY_SUITE = "projectAccountAvailability"; diff --git a/packages/sdk/test/state/l1_account/ickb_sdk_get_account_state_collection.ts b/packages/sdk/test/state/l1_account/ickb_sdk_get_account_state_collection.ts new file mode 100644 index 0000000..bab76ea --- /dev/null +++ b/packages/sdk/test/state/l1_account/ickb_sdk_get_account_state_collection.ts @@ -0,0 +1,76 @@ +import { ccc } from "@ckb-ccc/core"; +import { LogicManager, OwnedOwnerManager } from "@ickb/core"; +import { DaoManager } from "@ickb/dao"; +import { OrderManager } from "@ickb/order"; +import { script, StubClient } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IckbSdk } from "../../../src/sdk.ts"; +import { fakeIckbUdt } from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { + receiptValue, + withdrawalValue, +} from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { baseTip, hash } from "../../transaction/base/support/sdk_core_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function* once(value: T): AsyncGenerator { + yield value; + await Promise.resolve(); +} + +describe("IckbSdk.getAccountState", () => { + it("collects account cells, receipts, withdrawals, and native iCKB balance", async () => { + const accountLock = script("11"); + const udt = script("66"); + const receipt = receiptValue(13n, 17n, "22"); + const withdrawal = withdrawalValue({ + ckbValue: 19n, + isReady: true, + byte: "38", + }); + const udtCell = ccc.Cell.from({ + outPoint: { txHash: hash("90"), index: 0n }, + cellOutput: { capacity: 7n, lock: accountLock, type: udt }, + outputData: ccc.numLeToBytes(11n, 16), + }); + 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(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 = new StubClient({ + async *findCellsOnChain(): ReturnType { + yield capacityCell; + yield udtCell; + await Promise.resolve(); + }, + }); + + const state = await sdk.getAccountState(client, [accountLock, accountLock], baseTip); + + expect(state.capacityCells).toEqual([capacityCell]); + expect(state.nativeUdtCells).toEqual([udtCell]); + expect(state.nativeUdtCapacity).toBe(udtCell.cellOutput.capacity); + expect(state.nativeUdtBalance).toBe(11n); + expect(state.receipts).toEqual([receipt]); + expect(state.withdrawalGroups).toEqual([withdrawal]); + }); +}); diff --git a/packages/sdk/test/state/l1_account/ickb_sdk_get_account_state_page_size.ts b/packages/sdk/test/state/l1_account/ickb_sdk_get_account_state_page_size.ts new file mode 100644 index 0000000..b86af6d --- /dev/null +++ b/packages/sdk/test/state/l1_account/ickb_sdk_get_account_state_page_size.ts @@ -0,0 +1,54 @@ +import { ccc } from "@ckb-ccc/core"; +import { script, StubClient } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { testSdk } from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { baseTip, hash } from "../../transaction/base/support/sdk_core_support.ts"; +import { none, repeat } from "./support/sdk_l1_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("IckbSdk.getAccountState", () => { + it("uses a custom account cell scan page size", async () => { + const { sdk, logicManager, ownedOwnerManager } = testSdk(); + const accountLock = script("11"); + const findReceipts = vi + .spyOn(logicManager, "findReceipts") + .mockImplementation(() => none()); + const findWithdrawalGroups = vi + .spyOn(ownedOwnerManager, "findWithdrawalGroups") + .mockImplementation(() => none()); + const cell = ccc.Cell.from({ + outPoint: { txHash: hash("92"), index: 0n }, + cellOutput: { capacity: 5n, lock: accountLock }, + outputData: "0x", + }); + const cellPageSize = 1; + let requestedPageSize = 0; + const client = new StubClient({ + async *findCellsOnChain( + _query, + _order, + pageSize, + ): ReturnType { + requestedPageSize = pageSize ?? 0; + yield* repeat(2, cell); + await Promise.resolve(); + }, + }); + + const state = await sdk.getAccountState(client, [accountLock], baseTip, { + cellPageSize, + }); + + expect(requestedPageSize).toBe(cellPageSize); + expect(findReceipts.mock.calls[0]?.[2]).toMatchObject({ + pageSize: cellPageSize, + }); + expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ + pageSize: cellPageSize, + }); + expect(state.capacityCells).toEqual([cell, cell]); + }); +}); diff --git a/packages/sdk/test/state/l1_account/l1_account_state_tip_assertion_and_page_size.ts b/packages/sdk/test/state/l1_account/l1_account_state_tip_assertion_and_page_size.ts new file mode 100644 index 0000000..428c16b --- /dev/null +++ b/packages/sdk/test/state/l1_account/l1_account_state_tip_assertion_and_page_size.ts @@ -0,0 +1,128 @@ +import { ccc } from "@ckb-ccc/core"; +import { script, StubClient } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { testSdk } from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { + baseTip, + hash, + headerLike, +} from "../../transaction/base/support/sdk_core_support.ts"; +import { + FeeRateStubClient, + L1_STATE_SUITE, + none, + repeat, + tipHeaderHandler, +} from "./support/sdk_l1_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("keeps explicit current-tip assertion available for callers that require it", async () => { + const { sdk } = testSdk(); + const sampledTip = headerLike(1n, { hash: hash("01") }); + const currentTip = headerLike(2n, { hash: hash("02") }); + const client = new StubClient({ + getTipHeader: vi.fn().mockResolvedValue(currentTip), + }); + + await expect(sdk.assertCurrentTip(client, sampledTip)).rejects.toThrow( + `sampled block ${String(sampledTip.number)} ${sampledTip.hash}; current block ${String(currentTip.number)} ${currentTip.hash}`, + ); + }); + + it("accepts an unchanged sampled tip", async () => { + const { sdk } = testSdk(); + const sampledTip = headerLike(1n, { hash: hash("01") }); + const client = new StubClient({ + getTipHeader: vi.fn().mockResolvedValue(sampledTip), + }); + + await expect(sdk.assertCurrentTip(client, sampledTip)).resolves.toBeUndefined(); + }); +}); + +describe(`${L1_STATE_SUITE} page sizes`, () => { + it("passes one custom page size through L1 account state loading", async () => { + const { sdk, logicManager, ownedOwnerManager, orderManager } = testSdk(); + const accountLock = script("77"); + vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); + const findReceipts = vi + .spyOn(logicManager, "findReceipts") + .mockImplementation(() => none()); + const findWithdrawalGroups = vi + .spyOn(ownedOwnerManager, "findWithdrawalGroups") + .mockImplementation(() => none()); + vi.spyOn(orderManager, "findOrders").mockImplementation(() => none()); + const cell = ccc.Cell.from({ + outPoint: { txHash: hash("93"), index: 0n }, + cellOutput: { capacity: 5n, lock: accountLock }, + outputData: "0x", + }); + const cellPageSize = 1; + let requestedPageSize = 0; + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(baseTip), + async *findCellsOnChain( + _query, + _order, + pageSize, + ): ReturnType { + requestedPageSize = pageSize ?? 0; + yield* repeat(2, cell); + await Promise.resolve(); + }, + }); + + const state = await sdk.getL1AccountState(client, [accountLock], { + cellPageSize, + }); + + expect(requestedPageSize).toBe(cellPageSize); + expect(findReceipts.mock.calls[0]?.[2]).toMatchObject({ + pageSize: cellPageSize, + }); + expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ + pageSize: cellPageSize, + }); + expect(state.account.capacityCells).toEqual([cell, cell]); + }); + + it("uses default page sizes when L1 account state loading has no override", async () => { + const { sdk, logicManager, ownedOwnerManager, orderManager } = testSdk(); + const accountLock = script("78"); + vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); + const findReceipts = vi + .spyOn(logicManager, "findReceipts") + .mockImplementation(() => none()); + const findWithdrawalGroups = vi + .spyOn(ownedOwnerManager, "findWithdrawalGroups") + .mockImplementation(() => none()); + vi.spyOn(orderManager, "findOrders").mockImplementation(() => none()); + let requestedPageSize = 0; + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(baseTip), + async *findCellsOnChain( + _query, + _order, + pageSize, + ): ReturnType { + requestedPageSize = pageSize ?? 0; + yield* none(); + await Promise.resolve(); + }, + }); + + await sdk.getL1AccountState(client, [accountLock]); + + expect(requestedPageSize).toBeGreaterThan(1); + expect(findReceipts.mock.calls[0]?.[2]).toMatchObject({ + pageSize: requestedPageSize, + }); + expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ + pageSize: requestedPageSize, + }); + }); +}); diff --git a/packages/sdk/test/state/l1_account/l1_account_state_tip_reuse.ts b/packages/sdk/test/state/l1_account/l1_account_state_tip_reuse.ts new file mode 100644 index 0000000..b6fa5d9 --- /dev/null +++ b/packages/sdk/test/state/l1_account/l1_account_state_tip_reuse.ts @@ -0,0 +1,82 @@ +import type { ccc } from "@ckb-ccc/core"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { hash, headerLike } from "../../transaction/base/support/sdk_core_support.ts"; +import { + defaultL1Sdk, + emptyCellScan, + FeeRateStubClient, + L1_STATE_SUITE, +} from "./support/sdk_l1_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("uses the sampled L1 tip when account state scanning crosses forward tip progress", async () => { + const accountLock = script("11"); + const firstTip = headerLike(1n, { hash: hash("01") }); + const secondTip = headerLike(2n, { hash: hash("02") }); + const getTipHeader: ccc.Client["getTipHeader"] = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(secondTip); + const client = new FeeRateStubClient({ + getTipHeader, + findCellsOnChain: emptyCellScan, + }); + + const state = await defaultL1Sdk().getL1AccountState(client, [accountLock]); + + expect(state.system.tip).toBe(firstTip); + expect(getTipHeader).toHaveBeenCalledTimes(1); + }); + + it("does not refetch the tip to detect reorgs during account state scanning", async () => { + const accountLock = script("11"); + const firstTip = headerLike(1n, { hash: hash("01") }); + const secondTip = headerLike(2n, { hash: hash("02") }); + const replacedFirstTip = headerLike(1n, { hash: hash("03") }); + const getTipHeader: ccc.Client["getTipHeader"] = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(secondTip); + const getHeaderByNumber: ccc.Client["getHeaderByNumber"] = vi + .fn() + .mockResolvedValue(replacedFirstTip); + const client = new FeeRateStubClient({ + getTipHeader, + getHeaderByNumber, + findCellsOnChain: emptyCellScan, + }); + + const state = await defaultL1Sdk().getL1AccountState(client, [accountLock]); + + expect(state.system.tip).toBe(firstTip); + expect(getTipHeader).toHaveBeenCalledTimes(1); + expect(getHeaderByNumber).not.toHaveBeenCalled(); + }); + + it("does not refetch the tip when the chain tip is replaced during account state scanning", async () => { + const accountLock = script("11"); + const firstTip = headerLike(1n, { hash: hash("01") }); + const replacementTip = headerLike(1n, { hash: hash("02") }); + const getTipHeader: ccc.Client["getTipHeader"] = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(replacementTip); + const client = new FeeRateStubClient({ + getTipHeader, + findCellsOnChain: emptyCellScan, + }); + + const state = await defaultL1Sdk().getL1AccountState(client, [accountLock]); + + expect(state.system.tip).toBe(firstTip); + expect(getTipHeader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/state/l1_account/l1_state_bot_capacity_withdrawal_page_size.ts b/packages/sdk/test/state/l1_account/l1_state_bot_capacity_withdrawal_page_size.ts new file mode 100644 index 0000000..539a5e0 --- /dev/null +++ b/packages/sdk/test/state/l1_account/l1_state_bot_capacity_withdrawal_page_size.ts @@ -0,0 +1,130 @@ +import type { ccc } from "@ckb-ccc/core"; +import { LogicManager, OwnedOwnerManager } from "@ickb/core"; +import { DaoManager } from "@ickb/dao"; +import { OrderManager } from "@ickb/order"; +import { capacityCell, script } from "@ickb/testkit"; +import { defaultCellPageSize } from "@ickb/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { headerLike } from "../../transaction/base/support/sdk_core_support.ts"; +import { + FeeRateStubClient, + L1_STATE_SUITE, + l1SdkWithManagers, + none, + repeat, + tipHeaderHandler, +} from "./support/sdk_l1_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("uses one page size for bot capacity and withdrawal scans", async () => { + await expectSharedPageSizeForCapacityAndWithdrawalScans(); + }); + + it("passes one custom page size through L1 state loading", async () => { + await expectCustomPageSizeThroughL1StateLoading(); + }); +}); + +async function expectSharedPageSizeForCapacityAndWithdrawalScans(): Promise { + const botLock = script("11"); + const dao = script("33"); + const ownedOwnerManager = new OwnedOwnerManager( + script("44"), + [], + new DaoManager(dao, []), + ); + const findWithdrawalGroups = vi + .spyOn(ownedOwnerManager, "findWithdrawalGroups") + .mockImplementation(() => none()); + const pageSize = defaultCellPageSize + 100; + const sdk = l1SdkWithManagers({ + botLock, + ownedOwnerManager, + orderManager: new OrderManager(script("55"), [], script("66")), + }); + const plainCell = capacityCell(1n, botLock, "04"); + let requestedPageSize = 0; + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(headerLike(1n)), + async *findCellsOnChain( + query, + _order, + requestPageSize, + ): ReturnType { + if ( + query.filter?.scriptLenRange !== undefined && + query.filter.outputDataLenRange !== undefined + ) { + requestedPageSize = requestPageSize ?? 0; + yield* repeat((requestPageSize ?? 0) + 1, plainCell); + } + await Promise.resolve(); + }, + }); + + await sdk.getL1State(client, [], { cellPageSize: pageSize }); + expect(requestedPageSize).toBe(pageSize); + expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ pageSize }); +} + +async function expectCustomPageSizeThroughL1StateLoading(): Promise { + const botLock = script("11"); + const dao = script("33"); + const logicManager = new LogicManager(script("22"), [], new DaoManager(dao, [])); + const ownedOwnerManager = new OwnedOwnerManager( + script("44"), + [], + new DaoManager(dao, []), + ); + const orderManager = new OrderManager(script("55"), [], script("66")); + vi.spyOn(logicManager, "findDeposits").mockImplementation(() => none()); + const findWithdrawalGroups = vi + .spyOn(ownedOwnerManager, "findWithdrawalGroups") + .mockImplementation(() => none()); + const findOrders = vi + .spyOn(orderManager, "findOrders") + .mockImplementation(() => none()); + const sdk = l1SdkWithManagers({ + botLock, + logicManager, + ownedOwnerManager, + orderManager, + }); + const plainCell = capacityCell(1n, botLock, "04"); + const cellPageSize = defaultCellPageSize + 1; + const sampledTip = headerLike(1n); + const capacityLimits: number[] = []; + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(sampledTip), + async *findCellsOnChain( + query, + _order, + pageSize, + ): ReturnType { + if ( + query.filter?.scriptLenRange !== undefined && + query.filter.outputDataLenRange !== undefined + ) { + capacityLimits.push(pageSize ?? 0); + yield* repeat(cellPageSize, plainCell); + } + await Promise.resolve(); + }, + }); + + await sdk.getL1State(client, [], { cellPageSize }); + expect(capacityLimits).toEqual([cellPageSize]); + expect(findWithdrawalGroups.mock.calls[0]?.[2]).toMatchObject({ + onChain: true, + tip: sampledTip, + pageSize: cellPageSize, + }); + expect(findOrders.mock.calls[0]?.[1]).toMatchObject({ + onChain: true, + pageSize: cellPageSize, + }); +} diff --git a/packages/sdk/test/state/l1_account/l1_state_bot_data_cell_fallback.ts b/packages/sdk/test/state/l1_account/l1_state_bot_data_cell_fallback.ts new file mode 100644 index 0000000..2f7ff29 --- /dev/null +++ b/packages/sdk/test/state/l1_account/l1_state_bot_data_cell_fallback.ts @@ -0,0 +1,85 @@ +import { ccc } from "@ckb-ccc/core"; +import { LogicManager, OwnedOwnerManager } from "@ickb/core"; +import { DaoManager } from "@ickb/dao"; +import { OrderManager } from "@ickb/order"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IckbSdk } from "../../../src/sdk.ts"; +import { fakeIckbUdt } from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { hash, headerLike } from "../../transaction/base/support/sdk_core_support.ts"; +import { + FeeRateStubClient, + L1_STATE_SUITE, + tipHeaderHandler, + transactionWithHeader, +} from "./support/sdk_l1_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("ignores bot data cells and falls back to direct deposit scanning", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [botLock], + ); + const fakeAlignedData = ccc.hexFrom(new Uint8Array(128).fill(0xaa)); + const header = headerLike(1n); + const botCells = [ + ccc.Cell.from({ + outPoint: { txHash: hash("01"), index: 0n }, + cellOutput: { capacity: 1000n, lock: botLock }, + outputData: fakeAlignedData, + }), + ]; + const depositCell = ccc.Cell.from({ + outPoint: { txHash: hash("02"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: logic, + type: dao, + }, + outputData: DaoManager.depositData(), + }); + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(header), + async *findCellsOnChain(query): ReturnType { + if (query.filter?.outputData === DaoManager.depositData()) { + yield depositCell; + } + if (query.scriptType === "lock") { + for (const cell of botCells) { + yield cell; + } + } + await Promise.resolve(); + }, + getTransactionWithHeader: async ( + txHash: ccc.Hex, + ): ReturnType => { + await Promise.resolve(); + return transactionWithHeader( + txHash === hash("02") + ? headerLike(0n) + : headerLike(1n, { epoch: ccc.Epoch.from([2n, 0n, 1n]) }), + ); + }, + }); + + const state = await sdk.getL1State(client, []); + + expect(state.user.orders).toEqual([]); + expect(state.system.ckbMaturing).toHaveLength(1); + expect(state.system.ckbMaturing[0]?.ckbCumulative).toBe(ccc.fixedPointFrom(100082)); + }); +}); diff --git a/packages/sdk/test/state/l1_account/l1_state_bot_withdrawal_scan_failure.ts b/packages/sdk/test/state/l1_account/l1_state_bot_withdrawal_scan_failure.ts new file mode 100644 index 0000000..0d03ffe --- /dev/null +++ b/packages/sdk/test/state/l1_account/l1_state_bot_withdrawal_scan_failure.ts @@ -0,0 +1,57 @@ +import { LogicManager, OwnedOwnerManager, type WithdrawalGroup } from "@ickb/core"; +import { DaoManager } from "@ickb/dao"; +import { OrderManager } from "@ickb/order"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IckbSdk } from "../../../src/sdk.ts"; +import { fakeIckbUdt } from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { headerLike } from "../../transaction/base/support/sdk_core_support.ts"; +import { + emptyCellScan, + FeeRateStubClient, + L1_STATE_SUITE, + none, + tipHeaderHandler, +} from "./support/sdk_l1_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const WITHDRAWAL_FAILED = "withdrawal failed"; + +describe(L1_STATE_SUITE, () => { + it("propagates bot withdrawal scan failures after bot capacity scanning succeeds", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const ownedOwnerManager = new OwnedOwnerManager( + ownedOwner, + [], + new DaoManager(dao, []), + ); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation( + async function* () { + yield* none(); + await Promise.resolve(); + throw new Error(WITHDRAWAL_FAILED); + }, + ); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [botLock], + ); + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(headerLike(1n)), + findCellsOnChain: emptyCellScan, + }); + + await expect(sdk.getL1State(client, [])).rejects.toThrow(WITHDRAWAL_FAILED); + }); +}); diff --git a/packages/sdk/test/state/l1_account/support/sdk_l1_support.ts b/packages/sdk/test/state/l1_account/support/sdk_l1_support.ts new file mode 100644 index 0000000..e0adb86 --- /dev/null +++ b/packages/sdk/test/state/l1_account/support/sdk_l1_support.ts @@ -0,0 +1,89 @@ +import type { ccc } from "@ckb-ccc/core"; +import { LogicManager, OwnedOwnerManager } from "@ickb/core"; +import { DaoManager } from "@ickb/dao"; +import { OrderManager } from "@ickb/order"; +import { script, StubClient } from "@ickb/testkit"; +import { IckbSdk } from "../../../../src/sdk.ts"; +import { fakeIckbUdt } from "../../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; + +export { transactionWithHeader } from "@ickb/testkit"; +export { L1_STATE_SUITE } from "../../../transaction/complete/support/sdk_suite_titles.ts"; + +export class FeeRateStubClient extends StubClient { + private readonly feeRate: bigint; + + constructor(handlers: ConstructorParameters[0] = {}, feeRate = 1n) { + super(handlers); + this.feeRate = feeRate; + } + + public override async getFeeRate(): Promise { + await Promise.resolve(); + return this.feeRate; + } +} + +export function tipHeaderHandler( + header: ccc.ClientBlockHeader, +): ccc.Client["getTipHeader"] { + return async (): ReturnType => { + await Promise.resolve(); + return header; + }; +} + +export function emptyCellScan(): ReturnType { + return none(); +} + +export async function* none(): AsyncGenerator { + const values: T[] = []; + yield* values; + await Promise.resolve(); +} + +export async function* repeat(count: number, value: T): AsyncGenerator { + for (let index = 0; index < count; index += 1) { + yield value; + } + await Promise.resolve(); +} + +export function defaultL1Sdk(): IckbSdk { + const dao = script("33"); + const logic = script("22"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + return new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [], + ); +} + +export function l1SdkWithManagers(options: { + botLock?: ccc.Script; + logicManager?: LogicManager; + ownedOwnerManager?: OwnedOwnerManager; + orderManager?: OrderManager; + udt?: ccc.Script; +}): IckbSdk { + const dao = script("33"); + const udt = options.udt ?? script("66"); + const ownedOwnerManager = + options.ownedOwnerManager ?? + new OwnedOwnerManager(script("44"), [], new DaoManager(dao, [])); + const logicManager = + options.logicManager ?? new LogicManager(script("22"), [], new DaoManager(dao, [])); + const orderManager = options.orderManager ?? new OrderManager(script("55"), [], udt); + return new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + orderManager, + options.botLock === undefined ? [] : [options.botLock], + ); +} diff --git a/packages/sdk/test/state/l1_pool/l1_state_direct_deposit_default_page_size.ts b/packages/sdk/test/state/l1_pool/l1_state_direct_deposit_default_page_size.ts new file mode 100644 index 0000000..0dadbc4 --- /dev/null +++ b/packages/sdk/test/state/l1_pool/l1_state_direct_deposit_default_page_size.ts @@ -0,0 +1,37 @@ +import { defaultCellPageSize } from "@ickb/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { headerLike } from "../../transaction/base/support/sdk_core_support.ts"; +import { + emptyCellScan, + FeeRateStubClient, + none, + tipHeaderHandler, +} from "../l1_account/support/sdk_l1_support.ts"; +import { + directDepositPageSizeFixture, + L1_STATE_SUITE, +} from "./support/l1_pool_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("passes the default page size to direct deposit scanning", async () => { + const { logicManager, ownedOwnerManager, sdk } = directDepositPageSizeFixture(); + const findDeposits = vi + .spyOn(logicManager, "findDeposits") + .mockImplementation(() => none()); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(headerLike(1n)), + findCellsOnChain: emptyCellScan, + }); + + await sdk.getL1State(client, []); + + expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ + pageSize: defaultCellPageSize, + }); + }); +}); diff --git a/packages/sdk/test/state/l1_pool/l1_state_pool_deposit_scan_options.ts b/packages/sdk/test/state/l1_pool/l1_state_pool_deposit_scan_options.ts new file mode 100644 index 0000000..940239b --- /dev/null +++ b/packages/sdk/test/state/l1_pool/l1_state_pool_deposit_scan_options.ts @@ -0,0 +1,71 @@ +import { ccc } from "@ckb-ccc/core"; +import { StubClient } from "@ickb/testkit"; +import { defaultCellPageSize } from "@ickb/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { testSdk } from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { baseTip } from "../../transaction/base/support/sdk_core_support.ts"; +import { + emptyCellScan, + FeeRateStubClient, + none, + tipHeaderHandler, +} from "../l1_account/support/sdk_l1_support.ts"; +import { L1_STATE_SUITE } from "./support/l1_pool_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("passes a custom page size to pool deposit scanning", async () => { + const { sdk, logicManager } = testSdk(); + const findDeposits = vi + .spyOn(logicManager, "findDeposits") + .mockImplementation(() => none()); + const client = new StubClient({ findCellsOnChain: emptyCellScan }); + const cellPageSize = defaultCellPageSize + 100; + const minLockUp = ccc.Epoch.from([0n, 1n, 16n]); + const maxLockUp = ccc.Epoch.from([0n, 4n, 16n]); + + await sdk.getPoolDeposits(client, baseTip, { + cellPageSize, + minLockUp, + maxLockUp, + }); + + expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ + onChain: true, + tip: baseTip, + pageSize: cellPageSize, + minLockUp, + maxLockUp, + }); + }); + + it("passes custom pool deposit scan options through L1 state loading", async () => { + const { sdk, logicManager } = testSdk(); + const findDeposits = vi + .spyOn(logicManager, "findDeposits") + .mockImplementation(() => none()); + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(baseTip), + findCellsOnChain: emptyCellScan, + }); + const cellPageSize = defaultCellPageSize + 100; + const minLockUp = ccc.Epoch.from([0n, 1n, 16n]); + const maxLockUp = ccc.Epoch.from([0n, 4n, 16n]); + + await sdk.getL1State(client, [], { + cellPageSize, + poolDeposits: { minLockUp, maxLockUp }, + }); + + expect(findDeposits.mock.calls[0]?.[1]).toMatchObject({ + onChain: true, + tip: baseTip, + pageSize: cellPageSize, + minLockUp, + maxLockUp, + }); + }); +}); diff --git a/packages/sdk/test/state/l1_pool/l1_state_ready_deposit_availability.ts b/packages/sdk/test/state/l1_pool/l1_state_ready_deposit_availability.ts new file mode 100644 index 0000000..bd0a9df --- /dev/null +++ b/packages/sdk/test/state/l1_pool/l1_state_ready_deposit_availability.ts @@ -0,0 +1,66 @@ +import { ccc } from "@ckb-ccc/core"; +import { LogicManager, OwnedOwnerManager } from "@ickb/core"; +import { DaoManager } from "@ickb/dao"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { depositCell } from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { headerLike } from "../../transaction/base/support/sdk_core_support.ts"; +import { + emptyCellScan, + FeeRateStubClient, + l1SdkWithManagers, + none, + repeat, + tipHeaderHandler, + transactionWithHeader, +} from "../l1_account/support/sdk_l1_support.ts"; +import { L1_STATE_SUITE } from "./support/l1_pool_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("treats ready deposits as available CKB instead of future maturity", async () => { + const botLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const daoManager = new DaoManager(dao, []); + const logicManager = new LogicManager(logic, [], daoManager); + const ownedOwnerManager = new OwnedOwnerManager(ownedOwner, [], daoManager); + const readyDeposit = depositCell("03", logic, dao, headerLike(0n), headerLike(0n), { + isReady: true, + }); + const findDeposits = vi + .spyOn(logicManager, "findDeposits") + .mockImplementation(() => repeat(1, readyDeposit)); + vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => none()); + const sdk = l1SdkWithManagers({ + botLock, + ownedOwnerManager, + logicManager, + }); + const tip = headerLike(1n, { epoch: ccc.Epoch.from([181n, 0n, 1n]) }); + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(tip), + findCellsOnChain: emptyCellScan, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(headerLike(0n)); + }, + }); + + const state = await sdk.getL1State(client, []); + + expect(findDeposits).toHaveBeenCalledWith(client, { + onChain: true, + pageSize: 400, + tip, + }); + expect(state.system.ckbAvailable).toBe(ccc.fixedPointFrom(100082)); + expect(state.system.ckbMaturing).toEqual([]); + }); +}); diff --git a/packages/sdk/test/state/l1_pool/l1_state_reorg_tip_reuse.ts b/packages/sdk/test/state/l1_pool/l1_state_reorg_tip_reuse.ts new file mode 100644 index 0000000..f20f7dd --- /dev/null +++ b/packages/sdk/test/state/l1_pool/l1_state_reorg_tip_reuse.ts @@ -0,0 +1,59 @@ +import type { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { hash, headerLike } from "../../transaction/base/support/sdk_core_support.ts"; +import { + defaultL1Sdk, + emptyCellScan, + FeeRateStubClient, +} from "../l1_account/support/sdk_l1_support.ts"; +import { L1_STATE_SUITE } from "./support/l1_pool_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("does not refetch the tip to detect reorgs during L1 state scanning", async () => { + const firstTip = headerLike(1n, { hash: hash("01") }); + const secondTip = headerLike(2n, { hash: hash("02") }); + const replacedFirstTip = headerLike(1n, { hash: hash("03") }); + const getTipHeader: ccc.Client["getTipHeader"] = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(secondTip); + const getHeaderByNumber: ccc.Client["getHeaderByNumber"] = vi + .fn() + .mockResolvedValue(replacedFirstTip); + const sdk = defaultL1Sdk(); + const client = new FeeRateStubClient({ + getTipHeader, + getHeaderByNumber, + findCellsOnChain: emptyCellScan, + }); + + const state = await sdk.getL1State(client, []); + + expect(state.system.tip).toBe(firstTip); + expect(getTipHeader).toHaveBeenCalledTimes(1); + expect(getHeaderByNumber).not.toHaveBeenCalled(); + }); + + it("does not refetch the tip when the chain tip is replaced during L1 state scanning", async () => { + const firstTip = headerLike(1n, { hash: hash("01") }); + const replacementTip = headerLike(1n, { hash: hash("02") }); + const getTipHeader: ccc.Client["getTipHeader"] = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(replacementTip); + const sdk = defaultL1Sdk(); + const client = new FeeRateStubClient({ + getTipHeader, + findCellsOnChain: emptyCellScan, + }); + + const state = await sdk.getL1State(client, []); + + expect(state.system.tip).toBe(firstTip); + expect(getTipHeader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/state/l1_pool/l1_state_sampled_tip_forward_progress.ts b/packages/sdk/test/state/l1_pool/l1_state_sampled_tip_forward_progress.ts new file mode 100644 index 0000000..50aa0a4 --- /dev/null +++ b/packages/sdk/test/state/l1_pool/l1_state_sampled_tip_forward_progress.ts @@ -0,0 +1,34 @@ +import type { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { hash, headerLike } from "../../transaction/base/support/sdk_core_support.ts"; +import { + defaultL1Sdk, + emptyCellScan, + FeeRateStubClient, +} from "../l1_account/support/sdk_l1_support.ts"; +import { L1_STATE_SUITE } from "./support/l1_pool_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("uses the sampled L1 tip when scanning crosses forward tip progress", async () => { + const firstTip = headerLike(1n, { hash: hash("01") }); + const secondTip = headerLike(2n, { hash: hash("02") }); + const getTipHeader: ccc.Client["getTipHeader"] = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(secondTip); + const sdk = defaultL1Sdk(); + const client = new FeeRateStubClient({ + getTipHeader, + findCellsOnChain: emptyCellScan, + }); + + const state = await sdk.getL1State(client, []); + + expect(state.system.tip).toBe(firstTip); + expect(getTipHeader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/test/state/l1_pool/l1_state_user_order_liquidity_classification.ts b/packages/sdk/test/state/l1_pool/l1_state_user_order_liquidity_classification.ts new file mode 100644 index 0000000..30c62dc --- /dev/null +++ b/packages/sdk/test/state/l1_pool/l1_state_user_order_liquidity_classification.ts @@ -0,0 +1,136 @@ +import { ccc } from "@ckb-ccc/core"; +import { OrderManager } from "@ickb/order"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeOrderGroup } from "../../conversion/planning/support/sdk_order_support.ts"; +import { headerLike } from "../../transaction/base/support/sdk_core_support.ts"; +import { + emptyCellScan, + FeeRateStubClient, + l1SdkWithManagers, + tipHeaderHandler, +} from "../l1_account/support/sdk_l1_support.ts"; +import { L1_STATE_SUITE } from "./support/l1_pool_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(L1_STATE_SUITE, () => { + it("does not classify user-owned matchable orders as system liquidity", async () => { + const userLock = script("11"); + const nonUserLock = script("12"); + const orderScript = script("55"); + const udt = script("66"); + const orderManager = new OrderManager(orderScript, [], udt); + const sdk = l1SdkWithManagers({ + orderManager, + udt, + }); + const ownerOrder = makeOrderGroup({ + orderScript, + udtScript: udt, + ownerLock: userLock, + txHashByte: "a1", + }); + ownerOrder.group.order.maturity = 999n; + const marketOrder = makeOrderGroup({ + orderScript, + udtScript: udt, + ownerLock: nonUserLock, + txHashByte: "a2", + orderTxHashByte: "a3", + ratio: { ckbScale: 2n, udtScale: 1n }, + orderCapacity: ccc.fixedPointFrom(300), + udtValue: 1n, + }); + vi.spyOn(orderManager, "findOrders").mockImplementation(async function* () { + yield ownerOrder.group; + yield marketOrder.group; + await Promise.resolve(); + }); + + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(headerLike(1n)), + findCellsOnChain: emptyCellScan, + }); + + const state = await sdk.getL1State(client, [userLock]); + + expect(state.user.orders).toHaveLength(1); + expect(state.user.orders[0]).not.toBe(ownerOrder.group); + expect(state.user.orders[0]?.master).toBe(ownerOrder.group.master); + expect(state.user.orders[0]?.origin).toBe(ownerOrder.group.origin); + expect(state.user.orders[0]?.order).not.toBe(ownerOrder.group.order); + expect(state.user.orders[0]?.order.maturity).toBe(0n); + expect(ownerOrder.group.order.maturity).toBe(999n); + expect(state.system.orderPool).toEqual([marketOrder.group.order]); + }); +}); + +describe(`${L1_STATE_SUITE} system order liquidity`, () => { + it("classifies non-user UDT-to-CKB orders as system liquidity", async () => { + const { client, marketOrder, sdk, userLock } = l1StateWithMarketOrder({ + ratio: { ckbScale: (1n << 64n) - 1n, udtScale: 1n }, + txHashByte: "b1", + }); + + const state = await sdk.getL1State(client, [userLock]); + + expect(state.user.orders).toEqual([]); + expect(state.system.orderPool).toEqual([marketOrder.group.order]); + }); + + it("leaves non-user orders outside liquidity when neither side beats midpoint", async () => { + const { client, sdk, userLock } = l1StateWithMarketOrder({ + ratio: { ckbScale: 1n, udtScale: 1n << 63n }, + txHashByte: "c1", + }); + + const state = await sdk.getL1State(client, [userLock]); + + expect(state.user.orders).toEqual([]); + expect(state.system.orderPool).toEqual([]); + }); +}); + +function l1StateWithMarketOrder({ + ratio, + txHashByte, +}: { + ratio: { ckbScale: bigint; udtScale: bigint }; + txHashByte: string; +}): { + client: FeeRateStubClient; + marketOrder: ReturnType; + sdk: ReturnType; + userLock: ReturnType; +} { + const userLock = script("11"); + const nonUserLock = script("12"); + const orderScript = script("55"); + const udt = script("66"); + const orderManager = new OrderManager(orderScript, [], udt); + const sdk = l1SdkWithManagers({ orderManager, udt }); + const marketOrder = makeOrderGroup({ + orderScript, + udtScript: udt, + ownerLock: nonUserLock, + txHashByte, + ratio, + isCkb2Udt: false, + orderCapacity: ccc.fixedPointFrom(300), + udtValue: 1n, + }); + vi.spyOn(orderManager, "findOrders").mockImplementation(async function* () { + yield marketOrder.group; + await Promise.resolve(); + }); + + const client = new FeeRateStubClient({ + getTipHeader: tipHeaderHandler(headerLike(1n)), + findCellsOnChain: emptyCellScan, + }); + + return { client, marketOrder, sdk, userLock }; +} diff --git a/packages/sdk/test/state/l1_pool/support/l1_pool_support.ts b/packages/sdk/test/state/l1_pool/support/l1_pool_support.ts new file mode 100644 index 0000000..d9a40ee --- /dev/null +++ b/packages/sdk/test/state/l1_pool/support/l1_pool_support.ts @@ -0,0 +1,38 @@ +import { LogicManager, OwnedOwnerManager } from "@ickb/core"; +import { DaoManager } from "@ickb/dao"; +import { OrderManager } from "@ickb/order"; +import { script } from "@ickb/testkit"; +import { IckbSdk } from "../../../../src/sdk.ts"; +import { fakeIckbUdt } from "../../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; + +export const L1_STATE_SUITE = "IckbSdk.getL1State snapshot detection"; + +export function directDepositPageSizeFixture(): { + logicManager: LogicManager; + ownedOwnerManager: OwnedOwnerManager; + sdk: IckbSdk; +} { + 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, []), + ); + return { + logicManager, + ownedOwnerManager, + sdk: new IckbSdk( + fakeIckbUdt(udt), + ownedOwnerManager, + logicManager, + new OrderManager(order, [], udt), + [botLock], + ), + }; +} diff --git a/packages/sdk/test/transaction/base/build_base_transaction_balanced_withdrawal_request.ts b/packages/sdk/test/transaction/base/build_base_transaction_balanced_withdrawal_request.ts new file mode 100644 index 0000000..66db3a2 --- /dev/null +++ b/packages/sdk/test/transaction/base/build_base_transaction_balanced_withdrawal_request.ts @@ -0,0 +1,90 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseTransactionFixture, + BUILD_BASE_TRANSACTION_SUITE, +} from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { placeholderOrder } from "../../conversion/planning/support/sdk_order_support.ts"; +import { depositCell } from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { baseClient, baseTip, hash } from "./support/sdk_core_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(BUILD_BASE_TRANSACTION_SUITE, () => { + it("accepts withdrawal requests after balanced caller activity", async () => { + const { botLock, dao, logic, orderManager, ownedOwnerManager, sdk } = + baseTransactionFixture(); + const requestedDeposit = depositCell("85", logic, dao, baseTip, baseTip, { + isReady: true, + }); + 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, baseClient, { + withdrawalRequest: { + deposits: [requestedDeposit], + lock: botLock, + }, + orders: [placeholderOrder], + }); + + expect(tx.inputs).toHaveLength(3); + expect(tx.outputs).toHaveLength(2); + expect(tx.outputsData).toEqual(["0x", "0x"]); + }); +}); diff --git a/packages/sdk/test/transaction/base/build_base_transaction_deposit_after_withdrawal.ts b/packages/sdk/test/transaction/base/build_base_transaction_deposit_after_withdrawal.ts new file mode 100644 index 0000000..5b3719e --- /dev/null +++ b/packages/sdk/test/transaction/base/build_base_transaction_deposit_after_withdrawal.ts @@ -0,0 +1,87 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseTransactionFixture, + BUILD_BASE_TRANSACTION_SUITE, +} from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { depositCell } from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { baseClient, baseTip, hash } from "./support/sdk_core_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(BUILD_BASE_TRANSACTION_SUITE, () => { + it("lets callers append a deposit after the withdrawal request path", async () => { + const { botLock, dao, logic, logicManager, ownedOwnerManager, sdk } = + baseTransactionFixture(); + const calls: string[] = []; + const requestedDeposit = depositCell("85", logic, dao, baseTip, baseTip, { + isReady: true, + }); + + 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(), baseClient, { + withdrawalRequest: { + deposits: [requestedDeposit], + lock: botLock, + }, + }); + tx = await logicManager.deposit(tx, 1, 2n, botLock, baseClient); + + expect(calls).toEqual(["request", "deposit"]); + expect(tx.outputs).toHaveLength(2); + }); + + it("lets DAO withdrawal own unbalanced caller prework rejection", async () => { + const { botLock, dao, logic, sdk } = baseTransactionFixture(); + const tx = ccc.Transaction.default(); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: hash("84"), + index: 0n, + }, + }), + ); + + await expect( + sdk.buildBaseTransaction(tx, baseClient, { + withdrawalRequest: { + deposits: [depositCell("85", logic, dao, baseTip, baseTip, { isReady: true })], + lock: botLock, + }, + }), + ).rejects.toThrow("Transaction has different inputs and outputs lengths"); + }); +}); diff --git a/packages/sdk/test/transaction/base/build_base_transaction_manager_effects.ts b/packages/sdk/test/transaction/base/build_base_transaction_manager_effects.ts new file mode 100644 index 0000000..9242ee8 --- /dev/null +++ b/packages/sdk/test/transaction/base/build_base_transaction_manager_effects.ts @@ -0,0 +1,230 @@ +import { ccc } from "@ckb-ccc/core"; +import { + type IckbDepositCell, + OwnerData, + type ReceiptCell, + type WithdrawalGroup, +} from "@ickb/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseTransactionFixture, + BUILD_BASE_TRANSACTION_SUITE, +} from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { makeOrderGroup } from "../../conversion/planning/support/sdk_order_support.ts"; +import { + depositCell, + readyWithdrawalGroup, + receiptCell, +} from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { + baseClient, + baseTip, + dep, + hash, + headerLike, +} from "./support/sdk_core_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface RealBaseTransactionEffects { + botLock: ccc.Script; + dao: ccc.Script; + daoDep: ccc.CellDep; + depositHeader: ccc.ClientBlockHeader; + logicDep: ccc.CellDep; + masterCell: ccc.Cell; + orderCell: ccc.Cell; + orderDep: ccc.CellDep; + ownedDep: ccc.CellDep; + ownedOwner: ccc.Script; + receipt: ReceiptCell; + receiptHeader: ccc.ClientBlockHeader; + requestedDeposit: IckbDepositCell; + requiredLiveDeposit: IckbDepositCell; + withdrawalGroup: WithdrawalGroup; + withdrawalHeader: ccc.ClientBlockHeader; +} + +function expectRealBaseTransactionEffects( + tx: ccc.Transaction, + options: RealBaseTransactionEffects, +): void { + expect(tx.inputs.map((input) => input.previousOutput.toHex())).toEqual([ + options.requestedDeposit.cell.outPoint.toHex(), + options.orderCell.outPoint.toHex(), + options.masterCell.outPoint.toHex(), + options.receipt.cell.outPoint.toHex(), + options.withdrawalGroup.owned.cell.outPoint.toHex(), + options.withdrawalGroup.owner.cell.outPoint.toHex(), + ]); + expect(tx.outputs).toHaveLength(2); + expect(tx.outputs[0]?.capacity).toBe(options.requestedDeposit.cell.cellOutput.capacity); + expect(tx.outputs[0]?.lock.eq(options.ownedOwner)).toBe(true); + expect(tx.outputs[0]?.type?.eq(options.dao)).toBe(true); + expect(tx.outputs[1]?.lock.eq(options.botLock)).toBe(true); + expect(tx.outputs[1]?.type?.eq(options.ownedOwner)).toBe(true); + expect(tx.outputsData).toEqual([ + ccc.hexFrom(ccc.mol.Uint64LE.encode(options.depositHeader.number)), + ccc.hexFrom(OwnerData.encode({ ownedDistance: -1n })), + ]); + expect(tx.headerDeps).toEqual([ + options.depositHeader.hash, + options.receiptHeader.hash, + options.withdrawalHeader.hash, + ]); + expect(tx.cellDeps).toContainEqual(options.daoDep); + expect(tx.cellDeps).toContainEqual(options.ownedDep); + expect(tx.cellDeps).toContainEqual(options.logicDep); + expect(tx.cellDeps).toContainEqual(options.orderDep); + expect(tx.cellDeps).toContainEqual( + ccc.CellDep.from({ + outPoint: options.requiredLiveDeposit.cell.outPoint, + depType: "code", + }), + ); + expect(new Set(tx.headerDeps).size).toBe(tx.headerDeps.length); +} + +describe(BUILD_BASE_TRANSACTION_SUITE, () => { + it("combines real manager transaction effects", async () => { + const { tx, ...effects } = await buildRealBaseTransactionCase(); + + expectRealBaseTransactionEffects(tx, effects); + }); + + it("rejects non-ready withdrawal request deposits before calling core", async () => { + const { botLock, dao, logic, ownedOwnerManager, sdk } = baseTransactionFixture(); + const requestedDeposit = depositCell("74", logic, dao, baseTip, baseTip, { + isReady: false, + }); + const requestWithdrawal = vi.spyOn(ownedOwnerManager, "requestWithdrawal"); + + await expect( + sdk.buildBaseTransaction(ccc.Transaction.default(), baseClient, { + withdrawalRequest: { + deposits: [requestedDeposit], + lock: botLock, + }, + }), + ).rejects.toThrow( + `Withdrawal deposit ${requestedDeposit.cell.outPoint.toHex()} is not ready`, + ); + expect(requestWithdrawal).not.toHaveBeenCalled(); + }); + + it("rejects duplicated required live withdrawal deposits", async () => { + const { botLock, dao, logic, sdk } = baseTransactionFixture(); + const requestedDeposit = depositCell("75", logic, dao, baseTip, baseTip, { + isReady: true, + }); + const requiredLiveDeposit = depositCell("76", logic, dao, baseTip, baseTip, { + isReady: true, + }); + + await expect( + sdk.buildBaseTransaction(ccc.Transaction.default(), baseClient, { + withdrawalRequest: { + deposits: [requestedDeposit], + requiredLiveDeposits: [requiredLiveDeposit, requiredLiveDeposit], + lock: botLock, + }, + }), + ).rejects.toThrow( + `Withdrawal live deposit anchor ${requiredLiveDeposit.cell.outPoint.toHex()} is duplicated`, + ); + }); + + it("rejects required live withdrawal deposits that are also spent", async () => { + const { botLock, dao, logic, sdk } = baseTransactionFixture(); + const requestedDeposit = depositCell("77", logic, dao, baseTip, baseTip, { + isReady: true, + }); + + await expect( + sdk.buildBaseTransaction(ccc.Transaction.default(), baseClient, { + withdrawalRequest: { + deposits: [requestedDeposit], + requiredLiveDeposits: [requestedDeposit], + lock: botLock, + }, + }), + ).rejects.toThrow( + `Withdrawal live deposit anchor ${requestedDeposit.cell.outPoint.toHex()} is also being spent`, + ); + }); +}); + +async function buildRealBaseTransactionCase(): Promise< + RealBaseTransactionEffects & { tx: ccc.Transaction } +> { + const daoDep = dep("d1"); + const ownedDep = dep("d2"); + const logicDep = dep("d3"); + const orderDep = dep("d4"); + const { botLock, dao, logic, order, ownedOwner, sdk, udt } = baseTransactionFixture({ + daoDeps: [daoDep], + logicDeps: [logicDep], + orderDeps: [orderDep], + ownedOwnerDeps: [ownedDep], + }); + const depositHeader = headerLike(10n, { hash: hash("a1") }); + const receiptHeader = headerLike(11n, { hash: hash("a2") }); + const withdrawalHeader = headerLike(12n, { hash: hash("a3") }); + const requestedDeposit = depositCell("70", logic, dao, depositHeader, baseTip, { + isReady: true, + }); + const requiredLiveDeposit = depositCell("71", logic, dao, depositHeader, baseTip, { + isReady: true, + }); + const { + group: orderGroup, + orderCell, + masterCell, + } = makeOrderGroup({ + orderScript: order, + udtScript: udt, + ownerLock: botLock, + txHashByte: "72", + }); + const receipt = receiptCell("73", botLock, logic, receiptHeader); + const withdrawalGroup = readyWithdrawalGroup({ + ownerLock: botLock, + ownedOwner, + dao, + depositHeader, + withdrawalHeader, + }); + + const tx = await sdk.buildBaseTransaction(ccc.Transaction.default(), baseClient, { + withdrawalRequest: { + deposits: [requestedDeposit], + requiredLiveDeposits: [requiredLiveDeposit], + lock: botLock, + }, + orders: [orderGroup], + receipts: [receipt], + readyWithdrawals: [withdrawalGroup], + }); + + return { + tx, + botLock, + dao, + daoDep, + depositHeader, + logicDep, + masterCell, + orderCell, + orderDep, + ownedDep, + ownedOwner, + receipt, + receiptHeader, + requestedDeposit, + requiredLiveDeposit, + withdrawalGroup, + withdrawalHeader, + }; +} diff --git a/packages/sdk/test/transaction/base/build_base_transaction_withdrawal_before_input_activity.ts b/packages/sdk/test/transaction/base/build_base_transaction_withdrawal_before_input_activity.ts new file mode 100644 index 0000000..bd2f2eb --- /dev/null +++ b/packages/sdk/test/transaction/base/build_base_transaction_withdrawal_before_input_activity.ts @@ -0,0 +1,147 @@ +import { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell, LogicManager, OwnedOwnerManager } from "@ickb/core"; +import type { OrderManager } from "@ickb/order"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + baseTransactionFixture, + BUILD_BASE_TRANSACTION_SUITE, +} from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { placeholderOrder } from "../../conversion/planning/support/sdk_order_support.ts"; +import { + depositCell, + placeholderReceipt, + placeholderWithdrawal, +} from "../../conversion/withdrawal_quotes/support/sdk_cell_support.ts"; +import { baseClient, baseTip, hash } from "./support/sdk_core_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function mockBaseTransactionStepOrder(options: { + botLock: ccc.Script; + logicManager: LogicManager; + orderManager: OrderManager; + ownedOwnerManager: OwnedOwnerManager; + requestedDeposit: IckbDepositCell; + requiredLiveDeposit: IckbDepositCell; + steps: string[]; +}): void { + const { + botLock, + logicManager, + orderManager, + ownedOwnerManager, + requestedDeposit, + requiredLiveDeposit, + steps, + } = options; + vi.spyOn(ownedOwnerManager, "requestWithdrawal").mockImplementation( + async ( + ...[txLike, deposits, lock, , requestOptions]: [ + txLike: ccc.TransactionLike, + deposits: unknown, + lock: unknown, + client: unknown, + requestOptions: unknown, + ] + ) => { + await Promise.resolve(); + steps.push("request"); + expect(deposits).toEqual([requestedDeposit]); + expect(lock).toEqual(botLock); + expect(requestOptions).toEqual({ + requiredLiveDeposits: [requiredLiveDeposit], + }); + return appendInputAndOutput(txLike, hash("70"), botLock, 1n); + }, + ); + 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(cellInput("71")); + 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(cellInput("72")); + 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(cellInput("73")); + return tx; + }); +} + +function appendInputAndOutput( + txLike: ccc.TransactionLike, + txHash: ccc.Hex, + lock: ccc.Script, + capacity: bigint, +): ccc.Transaction { + const tx = ccc.Transaction.from(txLike); + expect(tx.inputs).toHaveLength(0); + expect(tx.outputs).toHaveLength(0); + tx.inputs.push(ccc.CellInput.from({ previousOutput: { txHash, index: 0n } })); + tx.outputs.push(ccc.CellOutput.from({ capacity, lock })); + tx.outputsData.push("0x"); + return tx; +} + +function cellInput(byte: string): ccc.CellInput { + return ccc.CellInput.from({ + previousOutput: { + txHash: hash(byte), + index: 0n, + }, + }); +} + +describe(BUILD_BASE_TRANSACTION_SUITE, () => { + it("requests withdrawals before input-only base activity", async () => { + const { botLock, dao, logic, orderManager, logicManager, ownedOwnerManager, sdk } = + baseTransactionFixture(); + const steps: string[] = []; + const requestedDeposit = depositCell("80", logic, dao, baseTip, baseTip, { + isReady: true, + }); + const requiredLiveDeposit = depositCell("90", logic, dao, baseTip, baseTip, { + isReady: true, + }); + mockBaseTransactionStepOrder({ + botLock, + logicManager, + orderManager, + ownedOwnerManager, + requestedDeposit, + requiredLiveDeposit, + steps, + }); + + const tx = await sdk.buildBaseTransaction(ccc.Transaction.default(), baseClient, { + withdrawalRequest: { + deposits: [requestedDeposit], + requiredLiveDeposits: [requiredLiveDeposit], + lock: botLock, + }, + orders: [placeholderOrder], + receipts: [placeholderReceipt], + readyWithdrawals: [placeholderWithdrawal], + }); + + expect(steps).toEqual(["request", "orders", "receipts", "withdrawals"]); + expect(tx.inputs).toHaveLength(4); + expect(tx.outputs).toHaveLength(1); + expect(tx.outputsData).toEqual(["0x"]); + }); +}); diff --git a/packages/sdk/test/transaction/base/support/sdk_core_support.ts b/packages/sdk/test/transaction/base/support/sdk_core_support.ts new file mode 100644 index 0000000..8c3a220 --- /dev/null +++ b/packages/sdk/test/transaction/base/support/sdk_core_support.ts @@ -0,0 +1,66 @@ +import { ccc } from "@ckb-ccc/core"; +import { Ratio } from "@ickb/order"; +import { byte32FromByte, StubClient, headerLike as testHeaderLike } from "@ickb/testkit"; +import type { ConversionTransactionContext, SystemState } from "../../../../src/sdk.ts"; + +export const hash = byte32FromByte; +export const ratio = Ratio.from({ ckbScale: 1n, udtScale: 1n }); +export const baseTip = headerLike(0n); +export const baseClient = new StubClient(); + +export function dep(byte: string): ccc.CellDep { + return ccc.CellDep.from({ + outPoint: { txHash: hash(byte), index: 0n }, + depType: "code", + }); +} + +export function headerLike( + number: bigint, + overrides: Partial = {}, +): ccc.ClientBlockHeader { + return testHeaderLike({ + epoch: [1n, 0n, 1n], + number, + ...overrides, + }); +} + +export function transactionWithOutputs(count: number, lock: ccc.Script): ccc.Transaction { + const tx = ccc.Transaction.default(); + for (let index = 0; index < count; index += 1) { + tx.outputs.push(ccc.CellOutput.from({ capacity: 1n, lock })); + tx.outputsData.push("0x"); + } + return tx; +} + +export function conversionContext( + overrides: Partial> & { + system?: Partial; + } = {}, +): ConversionTransactionContext { + const { system: systemOverrides, ...contextOverrides } = overrides; + return { + system: system(systemOverrides), + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + ...contextOverrides, + }; +} + +export function system(overrides: Partial = {}): SystemState { + return { + feeRate: 1n, + tip: baseTip, + exchangeRatio: ratio, + orderPool: [], + ckbAvailable: 0n, + ckbMaturing: [], + ...overrides, + }; +} diff --git a/packages/sdk/test/transaction/complete/complete_transaction_fee_change_fallback_rebuild.ts b/packages/sdk/test/transaction/complete/complete_transaction_fee_change_fallback_rebuild.ts new file mode 100644 index 0000000..f71da55 --- /dev/null +++ b/packages/sdk/test/transaction/complete/complete_transaction_fee_change_fallback_rebuild.ts @@ -0,0 +1,50 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + signerWithLock, + testSdk, +} from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { baseClient, hash } from "../base/support/sdk_core_support.ts"; +import { COMPLETE_TRANSACTION_SUITE } from "./support/sdk_suite_titles.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(COMPLETE_TRANSACTION_SUITE, () => { + it("rebuilds a clean transaction before fee-change fallback", async () => { + const { sdk, ickbUdt, logicManager, lock } = testSdk(); + const tx = ccc.Transaction.default(); + tx.addOutput({ lock, type: logicManager.script }, "0x"); + const signer = signerWithLock(lock); + const dirtyTx = tx.clone(); + dirtyTx.addInput({ + previousOutput: { txHash: hash("77"), index: 0n }, + }); + const completeBy = vi + .spyOn(ickbUdt, "completeBy") + .mockResolvedValueOnce(dirtyTx) + .mockImplementationOnce(async (txLike) => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }); + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockRejectedValueOnce( + new ccc.ErrorTransactionInsufficientCapacity(1n, { + isForChange: true, + }), + ); + vi.spyOn(ccc.Transaction.prototype, "completeFeeChangeToOutput").mockResolvedValue([ + 0, + true, + ]); + + const completed = await sdk.completeTransaction(tx, { + signer, + client: baseClient, + feeRate: 7n, + }); + + expect(completed.inputs).toHaveLength(0); + expect(completeBy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/sdk/test/transaction/complete/complete_transaction_fee_change_target_selection.ts b/packages/sdk/test/transaction/complete/complete_transaction_fee_change_target_selection.ts new file mode 100644 index 0000000..c5f472a --- /dev/null +++ b/packages/sdk/test/transaction/complete/complete_transaction_fee_change_target_selection.ts @@ -0,0 +1,88 @@ +import { ccc } from "@ckb-ccc/core"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + signerWithLock, + testSdk, +} from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { baseClient } from "../base/support/sdk_core_support.ts"; +import { COMPLETE_TRANSACTION_SUITE } from "./support/sdk_suite_titles.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(COMPLETE_TRANSACTION_SUITE, () => { + it("uses the latest matching output for the selected fee change target kind", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const tx = ccc.Transaction.default(); + tx.addOutput({ lock, type: logicManager.script }, "0x"); + tx.addOutput({ lock, type: orderManager.script }, "0x"); + tx.addOutput({ lock, type: logicManager.script }, "0x"); + const signer = signerWithLock(lock); + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockRejectedValueOnce( + new ccc.ErrorTransactionInsufficientCapacity(1n, { + isForChange: true, + }), + ); + const completeFeeChangeToOutput = vi + .spyOn(ccc.Transaction.prototype, "completeFeeChangeToOutput") + .mockResolvedValue([0, true]); + + await sdk.completeTransaction(tx, { + signer, + client: baseClient, + feeRate: 8n, + }); + + expect(completeFeeChangeToOutput).toHaveBeenCalledWith(signer, 2, 8n); + }); + + it("routes fee change into signer-owned order master when no receipt exists", async () => { + const { sdk, logicManager, orderManager, lock } = testSdk(); + const tx = ccc.Transaction.default(); + tx.addOutput({ lock: script("77"), type: logicManager.script }, "0x"); + tx.addOutput({ lock, type: orderManager.script }, "0x"); + const signer = signerWithLock(lock); + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockRejectedValueOnce( + new ccc.ErrorTransactionInsufficientCapacity(1n, { + isForChange: true, + }), + ); + const completeFeeChangeToOutput = vi + .spyOn(ccc.Transaction.prototype, "completeFeeChangeToOutput") + .mockResolvedValue([0, true]); + + await sdk.completeTransaction(tx, { + signer, + client: baseClient, + feeRate: 9n, + }); + + expect(completeFeeChangeToOutput).toHaveBeenCalledWith(signer, 1, 9n); + }); + + it("routes fee change into signer-owned withdrawal owner when no receipt or master exists", async () => { + const { sdk, logicManager, ownedOwnerManager, lock } = testSdk(); + const tx = ccc.Transaction.default(); + tx.addOutput({ lock: script("77"), type: logicManager.script }, "0x"); + tx.addOutput({ lock, type: ownedOwnerManager.script }, "0x"); + const signer = signerWithLock(lock); + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockRejectedValueOnce( + new ccc.ErrorTransactionInsufficientCapacity(1n, { + isForChange: true, + }), + ); + const completeFeeChangeToOutput = vi + .spyOn(ccc.Transaction.prototype, "completeFeeChangeToOutput") + .mockResolvedValue([0, true]); + + await sdk.completeTransaction(tx, { + signer, + client: baseClient, + feeRate: 11n, + }); + + expect(completeFeeChangeToOutput).toHaveBeenCalledWith(signer, 1, 11n); + }); +}); diff --git a/packages/sdk/test/transaction/complete/complete_transaction_fee_rate_and_change_routing.ts b/packages/sdk/test/transaction/complete/complete_transaction_fee_rate_and_change_routing.ts new file mode 100644 index 0000000..397d15e --- /dev/null +++ b/packages/sdk/test/transaction/complete/complete_transaction_fee_rate_and_change_routing.ts @@ -0,0 +1,83 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoOutputLimitError } from "@ickb/dao"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + signerWithLock, + testSdk, +} from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { baseClient, transactionWithOutputs } from "../base/support/sdk_core_support.ts"; +import { COMPLETE_TRANSACTION_SUITE } from "./support/sdk_suite_titles.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(COMPLETE_TRANSACTION_SUITE, () => { + it("runs UDT and fee before DAO-limit rejection", async () => { + const calls: string[] = []; + const { sdk, ickbUdt, lock } = testSdk(); + const signer = signerWithLock(lock); + const tx = transactionWithOutputs(65, lock); + 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(async () => { + calls.push("fee"); + await Promise.resolve(); + throw new DaoOutputLimitError(65); + }); + await expect( + sdk.completeTransaction(tx, { + signer, + client: baseClient, + feeRate: 42n, + }), + ).rejects.toThrow(DaoOutputLimitError); + + expect(calls).toEqual(["udt", "fee"]); + }); + + it("uses the provided fee rate", async () => { + const { sdk, lock } = testSdk(); + const signer = signerWithLock(lock); + const completeFeeBy = vi + .spyOn(ccc.Transaction.prototype, "completeFeeBy") + .mockResolvedValue([0, false]); + + await sdk.completeTransaction(ccc.Transaction.default(), { + signer, + client: baseClient, + feeRate: 123n, + }); + + expect(completeFeeBy).toHaveBeenCalledWith(signer, 123n); + }); + + it("routes fee change into the signer-owned receipt before other protocol outputs", async () => { + const { sdk, logicManager, ownedOwnerManager, orderManager, lock } = testSdk(); + const tx = ccc.Transaction.default(); + tx.addOutput({ lock, type: orderManager.script }, "0x"); + tx.addOutput({ lock, type: logicManager.script }, "0x"); + tx.addOutput({ lock, type: ownedOwnerManager.script }, "0x"); + const signer = signerWithLock(lock); + const changeError = new ccc.ErrorTransactionInsufficientCapacity(1n, { + isForChange: true, + }); + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockRejectedValueOnce( + changeError, + ); + const completeFeeChangeToOutput = vi + .spyOn(ccc.Transaction.prototype, "completeFeeChangeToOutput") + .mockResolvedValue([0, true]); + + await sdk.completeTransaction(tx, { + signer, + client: baseClient, + feeRate: 7n, + }); + + expect(completeFeeChangeToOutput).toHaveBeenCalledWith(signer, 1, 7n); + }); +}); diff --git a/packages/sdk/test/transaction/complete/complete_transaction_fee_retry_boundaries.ts b/packages/sdk/test/transaction/complete/complete_transaction_fee_retry_boundaries.ts new file mode 100644 index 0000000..f7ec6da --- /dev/null +++ b/packages/sdk/test/transaction/complete/complete_transaction_fee_retry_boundaries.ts @@ -0,0 +1,63 @@ +import { ccc } from "@ckb-ccc/core"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + signerWithLock, + testSdk, +} from "../../conversion/deposits_and_limits/support/sdk_fixture_support.ts"; +import { baseClient } from "../base/support/sdk_core_support.ts"; +import { COMPLETE_TRANSACTION_SUITE } from "./support/sdk_suite_titles.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(COMPLETE_TRANSACTION_SUITE, () => { + it("rethrows change-cell capacity errors when no safe output exists", async () => { + const { sdk, logicManager, lock } = testSdk(); + const tx = ccc.Transaction.default(); + tx.addOutput({ lock: script("77"), type: logicManager.script }, "0x"); + const signer = signerWithLock(lock); + const changeError = new ccc.ErrorTransactionInsufficientCapacity(1n, { + isForChange: true, + }); + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockRejectedValueOnce( + changeError, + ); + const completeFeeChangeToOutput = vi + .spyOn(ccc.Transaction.prototype, "completeFeeChangeToOutput") + .mockResolvedValue([0, true]); + + await expect( + sdk.completeTransaction(tx, { + signer, + client: baseClient, + feeRate: 9n, + }), + ).rejects.toBe(changeError); + + expect(completeFeeChangeToOutput).not.toHaveBeenCalled(); + }); + + it("does not retry non-change fee errors", async () => { + const { sdk, logicManager, lock } = testSdk(); + const tx = ccc.Transaction.default(); + tx.addOutput({ lock, type: logicManager.script }, "0x"); + const signer = signerWithLock(lock); + const feeError = new ccc.ErrorTransactionInsufficientCapacity(1n); + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockRejectedValueOnce(feeError); + const completeFeeChangeToOutput = vi + .spyOn(ccc.Transaction.prototype, "completeFeeChangeToOutput") + .mockResolvedValue([0, true]); + + await expect( + sdk.completeTransaction(tx, { + signer, + client: baseClient, + feeRate: 10n, + }), + ).rejects.toBe(feeError); + + expect(completeFeeChangeToOutput).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/test/transaction/complete/support/sdk_suite_titles.ts b/packages/sdk/test/transaction/complete/support/sdk_suite_titles.ts new file mode 100644 index 0000000..e7e3181 --- /dev/null +++ b/packages/sdk/test/transaction/complete/support/sdk_suite_titles.ts @@ -0,0 +1,4 @@ +export const BUILD_BASE_TRANSACTION_SUITE = "IckbSdk.buildBaseTransaction"; +export const BUILD_CONVERSION_TRANSACTION_SUITE = "IckbSdk.buildConversionTransaction"; +export const COMPLETE_TRANSACTION_SUITE = "IckbSdk.completeTransaction"; +export const L1_STATE_SUITE = "IckbSdk.getL1State snapshot detection"; diff --git a/packages/sdk/test/withdrawal/support/withdrawal_selection_scored_support.ts b/packages/sdk/test/withdrawal/support/withdrawal_selection_scored_support.ts new file mode 100644 index 0000000..c1f03ae --- /dev/null +++ b/packages/sdk/test/withdrawal/support/withdrawal_selection_scored_support.ts @@ -0,0 +1,11 @@ +import { readyDeposit, type TestDeposit } from "./withdrawal_selection_support.ts"; + +export type ScoredTestDeposit = TestDeposit & { score: bigint }; + +export function scoredReadyDeposit( + udtValue: bigint, + maturityUnix: bigint, + score: bigint, +): ScoredTestDeposit { + return { ...readyDeposit(udtValue, maturityUnix), score }; +} diff --git a/packages/sdk/test/withdrawal/support/withdrawal_selection_support.ts b/packages/sdk/test/withdrawal/support/withdrawal_selection_support.ts new file mode 100644 index 0000000..c064c23 --- /dev/null +++ b/packages/sdk/test/withdrawal/support/withdrawal_selection_support.ts @@ -0,0 +1,53 @@ +import { ccc } from "@ckb-ccc/core"; +import { headerLike } from "@ickb/testkit"; + +export const TIP = headerLike(); + +export interface TestDeposit { + cell: { outPoint: { toHex: () => string } }; + isReady: boolean; + udtValue: bigint; + maturity: ccc.Epoch; +} + +export function readyDeposit( + udtValue: bigint, + maturityUnix: bigint, + key = `ready-${String(maturityUnix)}`, +): TestDeposit { + return { + cell: depositCell(key), + isReady: true, + udtValue, + maturity: epochAtUnix(maturityUnix), + }; +} + +export function ringDeposit( + udtValue: bigint, + epoch: bigint, + options?: { isReady?: boolean; key?: string }, +): TestDeposit { + return { + cell: depositCell(options?.key ?? `ring-${String(epoch)}-${String(udtValue)}`), + isReady: options?.isReady ?? true, + udtValue, + maturity: ccc.Epoch.from([epoch, 0n, 1n]), + }; +} + +export function depositCell(key: string): { outPoint: { toHex: () => string } } { + return { outPoint: { toHex: () => key } }; +} + +function epochAtUnix(maturityUnix: bigint): ccc.Epoch { + const relativeMs = maturityUnix - TIP.timestamp; + const epochMs = 4n * 60n * 60n * 1000n; + const integer = relativeMs / epochMs; + const numerator = relativeMs % epochMs; + return ccc.Epoch.from({ + integer: TIP.epoch.integer + integer, + numerator, + denominator: epochMs, + }); +} diff --git a/packages/sdk/test/withdrawal/withdrawal_best_fit_support.ts b/packages/sdk/test/withdrawal/withdrawal_best_fit_support.ts new file mode 100644 index 0000000..c89e5d3 --- /dev/null +++ b/packages/sdk/test/withdrawal/withdrawal_best_fit_support.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { selectReadyDeposits } from "../../src/withdrawal/withdrawal_best_fit.ts"; +import { + findBestAtOrBelow, + isBetterSelection, + pickBetterSelection, + prepareSelections, + selectByMasks, + selectGreedyDeposits, +} from "../../src/withdrawal/withdrawal_best_fit_support.ts"; + +describe("withdrawal best-fit bounded selection support", () => { + it("prepares selections by score, total, and mask order", () => { + const prepared = prepareSelections( + [ + { mask: 0b11, total: 4n, score: 1n }, + { mask: 0b10, total: 5n, score: 1n }, + { mask: 0b01, total: 5n, score: 1n }, + { mask: 0b11, total: 6n, score: 0n }, + { mask: 0b00, total: 7n, score: 3n }, + ], + 2, + ); + + expect(prepared.map(({ selection }) => selection.mask)).toEqual([ + 0b11, 0b01, 0b01, 0b01, 0b00, + ]); + expect(findBestAtOrBelow(prepared, 3n)).toBeUndefined(); + expect(findBestAtOrBelow(prepared, 6n)?.mask).toBe(0b01); + }); + + it("compares bounded selections across score, total, and masks", () => { + expect( + isBetterSelection( + { firstMask: 0b10, secondMask: 0b00, total: 1n, score: 2n }, + { firstMask: 0b01, secondMask: 0b00, total: 9n, score: 1n }, + 2, + 2, + ), + ).toBe(true); + expect( + isBetterSelection( + { firstMask: 0b10, secondMask: 0b00, total: 9n, score: 1n }, + { firstMask: 0b01, secondMask: 0b00, total: 1n, score: 1n }, + 2, + 2, + ), + ).toBe(true); + expect( + isBetterSelection( + { firstMask: 0b00, secondMask: 0b01, total: 1n, score: 1n }, + { firstMask: 0b00, secondMask: 0b10, total: 1n, score: 1n }, + 2, + 2, + ), + ).toBe(true); + expect( + isBetterSelection( + { firstMask: 0b00, secondMask: 0b10, total: 1n, score: 1n }, + { firstMask: 0b00, secondMask: 0b01, total: 1n, score: 1n }, + 2, + 2, + ), + ).toBe(false); + expect( + isBetterSelection( + { firstMask: 0b00, secondMask: 0b00, total: 1n, score: 1n }, + { firstMask: 0b00, secondMask: 0b00, total: 1n, score: 1n }, + 2, + 2, + ), + ).toBe(false); + }); +}); + +describe("withdrawal best-fit concrete selection support", () => { + it("selects and compares concrete deposit choices", () => { + const a = { id: "a", udtValue: 3n, score: 1n }; + const b = { id: "b", udtValue: 5n, score: 2n }; + const c = { id: "c", udtValue: 5n, score: 2n }; + const deposits = [a, b, c]; + + expect(selectByMasks(deposits, 0b101)).toEqual([a, c]); + expect(() => selectByMasks([a, undefined, c], 0b111)).toThrow( + "Selection item 1 is missing", + ); + expect(pickBetterSelection(deposits, [b], [a], (deposit) => deposit.score)).toEqual([ + b, + ]); + expect(pickBetterSelection(deposits, [a], [b], (deposit) => deposit.score)).toEqual([ + b, + ]); + expect(pickBetterSelection(deposits, [a], [b])).toEqual([b]); + expect(pickBetterSelection(deposits, [b], [c])).toEqual([b]); + expect(pickBetterSelection(deposits, [c], [b])).toEqual([b]); + expect(selectGreedyDeposits(deposits, 5n, 2, 1, (deposit) => deposit.score)).toEqual([ + b, + ]); + expect(selectGreedyDeposits(deposits, 1n, 2, 1)).toEqual([]); + }); +}); + +describe("ready deposit selection support", () => { + it("keeps an earlier bounded candidate when a later one scores worse", () => { + const first = { udtValue: 1n, score: 0n }; + const second = { udtValue: 1n, score: -1n }; + + expect( + selectReadyDeposits([first, second], 2n, { + maxCount: 2, + score: (deposit) => deposit.score, + }), + ).toEqual([first]); + }); +}); diff --git a/packages/sdk/test/withdrawal/withdrawal_selection.ts b/packages/sdk/test/withdrawal/withdrawal_selection.ts new file mode 100644 index 0000000..65f05d2 --- /dev/null +++ b/packages/sdk/test/withdrawal/withdrawal_selection.ts @@ -0,0 +1,145 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { + ringRequiredLiveDepositFor, + ringSegments, + ringSurplusDepositFilter, + selectReadyWithdrawalDeposits, +} from "../../src/withdrawal/withdrawal_selection.ts"; +import { depositCell, ringDeposit, TIP } from "./support/withdrawal_selection_support.ts"; + +describe("selectReadyWithdrawalDeposits ring segments", () => { + it("keeps adaptive segments above the integer ring length", () => { + const deposits = Array.from({ length: 181 }, () => ringDeposit(1n, 20n)); + const segments = ringSegments(deposits); + + expect(segments).toHaveLength(256); + }); + + it("selects ring surplus and pins the ring anchor", () => { + const surplus = ringDeposit(4n, 1n); + const anchor = ringDeposit(6n, 1n); + const otherAnchor = ringDeposit(6n, 100n); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [surplus, anchor, otherAnchor], + tip: TIP, + maxAmount: 4n, + canSelectDeposit: ringSurplusDepositFilter([surplus, anchor, otherAnchor]), + requiredLiveDepositFor: ringRequiredLiveDepositFor([ + surplus, + anchor, + otherAnchor, + ]), + }), + ).toEqual({ deposits: [surplus], requiredLiveDeposits: [anchor] }); + }); + + it("identifies ring anchors as required live deposits", () => { + const surplus = ringDeposit(4n, 1n); + const anchor = ringDeposit(6n, 1n); + const otherAnchor = ringDeposit(6n, 100n); + const requiredLiveDepositFor = ringRequiredLiveDepositFor([ + surplus, + anchor, + otherAnchor, + ]); + + expect(requiredLiveDepositFor(surplus)).toBe(anchor); + expect(requiredLiveDepositFor(anchor)).toBeUndefined(); + expect(requiredLiveDepositFor(otherAnchor)).toBeUndefined(); + }); + + it("rejects malformed epoch denominators", () => { + expect(() => + ringSegments([ + { + cell: depositCell("bad-epoch"), + isReady: true, + udtValue: 1n, + maturity: ccc.Epoch.from([1n, 0n, 0n]), + }, + ]), + ).toThrow("Epoch denominator must be positive"); + }); +}); + +describe("selectReadyWithdrawalDeposits ring exclusions", () => { + it("does not select the only representative of a ring bucket", () => { + const anchor = ringDeposit(4n, 1n); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [anchor], + tip: TIP, + maxAmount: 4n, + canSelectDeposit: ringSurplusDepositFilter([anchor]), + requiredLiveDepositFor: ringRequiredLiveDepositFor([anchor]), + }), + ).toEqual({ deposits: [], requiredLiveDeposits: [] }); + }); + + it("does not select the only ring representative from another materialization", () => { + const poolAnchor = ringDeposit(4n, 1n, { key: "anchor" }); + const readyAnchor = ringDeposit(4n, 1n, { key: "anchor" }); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [readyAnchor], + tip: TIP, + maxAmount: 4n, + canSelectDeposit: ringSurplusDepositFilter([poolAnchor]), + requiredLiveDepositFor: ringRequiredLiveDepositFor([poolAnchor]), + }), + ).toEqual({ deposits: [], requiredLiveDeposits: [] }); + }); +}); + +describe("selectReadyWithdrawalDeposits ring requirements", () => { + it("pins ring anchors for selected surplus", () => { + const surplus = ringDeposit(4n, 20n); + const anchor = ringDeposit(6n, 20n); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [surplus, anchor], + tip: TIP, + maxAmount: 4n, + canSelectDeposit: ringSurplusDepositFilter([surplus, anchor]), + requiredLiveDepositFor: ringRequiredLiveDepositFor([surplus, anchor]), + }), + ).toEqual({ deposits: [surplus], requiredLiveDeposits: [anchor] }); + }); + + it("pins ring anchors for selected surplus from another materialization", () => { + const poolSurplus = ringDeposit(4n, 20n, { key: "surplus" }); + const readySurplus = ringDeposit(4n, 20n, { key: "surplus" }); + const anchor = ringDeposit(6n, 20n, { key: "anchor" }); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [readySurplus], + tip: TIP, + maxAmount: 4n, + canSelectDeposit: ringSurplusDepositFilter([poolSurplus, anchor]), + requiredLiveDepositFor: ringRequiredLiveDepositFor([poolSurplus, anchor]), + }), + ).toEqual({ deposits: [readySurplus], requiredLiveDeposits: [anchor] }); + }); + + it("pins non-ready ring anchors for selected ready surplus", () => { + const surplus = ringDeposit(4n, 20n); + const nonReadyAnchor = ringDeposit(6n, 20n, { isReady: false }); + + expect( + selectReadyWithdrawalDeposits({ + readyDeposits: [surplus], + tip: TIP, + maxAmount: 4n, + canSelectDeposit: ringSurplusDepositFilter([surplus, nonReadyAnchor]), + requiredLiveDepositFor: ringRequiredLiveDepositFor([surplus, nonReadyAnchor]), + }), + ).toEqual({ deposits: [surplus], requiredLiveDeposits: [nonReadyAnchor] }); + }); +}); diff --git a/packages/sdk/src/withdrawal_selection.test.ts b/packages/sdk/test/withdrawal/withdrawal_selection_direct.ts similarity index 52% rename from packages/sdk/src/withdrawal_selection.test.ts rename to packages/sdk/test/withdrawal/withdrawal_selection_direct.ts index 17242ba..74c3d64 100644 --- a/packages/sdk/src/withdrawal_selection.test.ts +++ b/packages/sdk/test/withdrawal/withdrawal_selection_direct.ts @@ -1,37 +1,15 @@ import { describe, expect, it } from "vitest"; -import { type IckbDepositCell } from "@ickb/core"; import { selectExactReadyWithdrawalDepositCandidates, - selectReadyWithdrawalCleanupDeposit, selectReadyWithdrawalDeposits, -} from "./withdrawal_selection.js"; - -const TIP = {} as never; - -function readyDeposit( - udtValue: bigint, - maturityUnix: bigint, -): IckbDepositCell { - return { - isReady: true, - udtValue, - maturity: { - toUnix: (): bigint => maturityUnix, - }, - } as unknown as IckbDepositCell; -} - -function scoredReadyDeposit( - udtValue: bigint, - maturityUnix: bigint, - score: bigint, -): IckbDepositCell & { score: bigint } { - const deposit = readyDeposit(udtValue, maturityUnix) as IckbDepositCell & { score: bigint }; - deposit.score = score; - return deposit; -} +} from "../../src/withdrawal/withdrawal_selection.ts"; +import { + scoredReadyDeposit, + type ScoredTestDeposit, +} from "./support/withdrawal_selection_scored_support.ts"; +import { readyDeposit, TIP } from "./support/withdrawal_selection_support.ts"; -describe("selectReadyWithdrawalDeposits", () => { +describe("selectReadyWithdrawalDeposits direct fit", () => { it("prefers the fullest valid subset under the target amount", () => { const deposits = [ readyDeposit(6n, 0n), @@ -40,12 +18,8 @@ describe("selectReadyWithdrawalDeposits", () => { ]; expect( - selectReadyWithdrawalDeposits({ - readyDeposits: deposits, - tip: TIP, - maxAmount: 10n, - preserveSingletons: false, - }).deposits, + selectReadyWithdrawalDeposits({ readyDeposits: deposits, tip: TIP, maxAmount: 10n }) + .deposits, ).toEqual([deposits[1], deposits[2]]); }); @@ -62,7 +36,6 @@ describe("selectReadyWithdrawalDeposits", () => { tip: TIP, maxAmount: 10n, maxCount: 2, - preserveSingletons: false, }).deposits, ).toEqual([deposits[0], deposits[1]]); }); @@ -81,7 +54,6 @@ describe("selectReadyWithdrawalDeposits", () => { maxAmount: 10n, minCount: 2, maxCount: 2, - preserveSingletons: false, }).deposits, ).toEqual([deposits[1], deposits[2]]); }); @@ -100,16 +72,14 @@ describe("selectReadyWithdrawalDeposits", () => { maxAmount: 9n, minCount: 2, maxCount: 2, - preserveSingletons: false, }), ).toEqual({ deposits: [], requiredLiveDeposits: [] }); }); +}); +describe("selectReadyWithdrawalDeposits direct ordering", () => { it("does not select a ready deposit above the requested amount", () => { - const deposits = [ - readyDeposit(11n, 0n), - readyDeposit(10n, 15n * 60n * 1000n), - ]; + const deposits = [readyDeposit(11n, 0n), readyDeposit(10n, 15n * 60n * 1000n)]; expect( selectReadyWithdrawalDeposits({ @@ -118,27 +88,10 @@ describe("selectReadyWithdrawalDeposits", () => { maxAmount: 10n, minCount: 1, maxCount: 1, - preserveSingletons: false, }).deposits, ).toEqual([deposits[1]]); }); - it("does not use protected crowded anchors to satisfy exact-count selection", () => { - const extra = readyDeposit(4n, 20n * 60n * 1000n); - const protectedAnchor = readyDeposit(6n, 25n * 60n * 1000n); - - expect( - selectReadyWithdrawalDeposits({ - readyDeposits: [extra, protectedAnchor], - tip: TIP, - maxAmount: 10n, - minCount: 2, - maxCount: 2, - preserveSingletons: true, - }), - ).toEqual({ deposits: [], requiredLiveDeposits: [] }); - }); - it("keeps earlier-ranked deposits when equal-total subsets tie", () => { const deposits = [ readyDeposit(6n, 0n), @@ -148,71 +101,108 @@ describe("selectReadyWithdrawalDeposits", () => { ]; expect( - selectReadyWithdrawalDeposits({ - readyDeposits: deposits, - tip: TIP, - maxAmount: 10n, - preserveSingletons: false, - }).deposits, + selectReadyWithdrawalDeposits({ readyDeposits: deposits, tip: TIP, maxAmount: 10n }) + .deposits, ).toEqual([deposits[0], deposits[1]]); }); - it("pins protected crowded anchors for selected extras", () => { - const extra = readyDeposit(4n, 20n * 60n * 1000n); - const protectedAnchor = readyDeposit(6n, 25n * 60n * 1000n); + it("prefers the larger total when scored selections tie", () => { + const lowerTotal = scoredReadyDeposit(4n, 0n, 1n); + const higherTotal = scoredReadyDeposit(5n, 15n * 60n * 1000n, 1n); expect( + selectExactReadyWithdrawalDepositCandidates({ + readyDeposits: [lowerTotal, higherTotal], + tip: TIP, + maxAmount: 5n, + count: 1, + score: (deposit: ScoredTestDeposit) => deposit.score, + maturityBucket: () => 0n, + })[0]?.deposits, + ).toEqual([higherTotal]); + }); + + it("uses selection order as the final scored tie-breaker", () => { + const first = scoredReadyDeposit(5n, 0n, 1n); + const second = scoredReadyDeposit(5n, 15n * 60n * 1000n, 1n); + + expect( + selectExactReadyWithdrawalDepositCandidates({ + readyDeposits: [first, second], + tip: TIP, + maxAmount: 5n, + count: 1, + score: (deposit: ScoredTestDeposit) => deposit.score, + maturityBucket: () => 0n, + })[0]?.deposits, + ).toEqual([first]); + }); +}); + +describe("selectReadyWithdrawalDeposits direct fallback", () => { + it("rejects non-ready deposits with the offending outpoint", () => { + const nonReady = { ...readyDeposit(1n, 0n, "not-ready"), isReady: false }; + + expect(() => selectReadyWithdrawalDeposits({ - readyDeposits: [extra, protectedAnchor], + readyDeposits: [nonReady], tip: TIP, - maxAmount: 4n, + maxAmount: 1n, }), - ).toEqual({ - deposits: [extra], - requiredLiveDeposits: [protectedAnchor], - }); + ).toThrow("Withdrawal deposit not-ready is not ready"); }); - it("preserves singleton anchors when requested", () => { - const singleton = readyDeposit(5n, 0n); + it("rejects duplicate deposits with the offending outpoint", () => { + const first = readyDeposit(1n, 0n, "duplicate"); + const second = readyDeposit(1n, 1n, "duplicate"); + + expect(() => + selectReadyWithdrawalDeposits({ + readyDeposits: [first, second], + tip: TIP, + maxAmount: 2n, + }), + ).toThrow("Withdrawal deposit duplicate is duplicated"); + }); + it("returns no deposits when selection bounds are invalid", () => { expect( selectReadyWithdrawalDeposits({ - readyDeposits: [singleton], + readyDeposits: [readyDeposit(1n, 0n)], tip: TIP, - maxAmount: 5n, - preserveSingletons: true, + maxAmount: 1n, + minCount: 2, + maxCount: 1, }), ).toEqual({ deposits: [], requiredLiveDeposits: [] }); }); - it("can spend singleton anchors when caller unlocks them", () => { - const singleton = readyDeposit(5n, 0n); + it("can select any ready deposit when the caller permits it", () => { + const sparseReady = readyDeposit(5n, 0n); expect( selectReadyWithdrawalDeposits({ - readyDeposits: [singleton], + readyDeposits: [sparseReady], tip: TIP, maxAmount: 5n, - preserveSingletons: false, }), - ).toEqual({ deposits: [singleton], requiredLiveDeposits: [] }); + ).toEqual({ + deposits: [sparseReady], + requiredLiveDeposits: [], + }); }); - it("uses near-ready refill as a singleton tie-break once anchors unlock", () => { - const earlierSingleton = readyDeposit(5n, 20n * 60n * 1000n); - const laterSingleton = readyDeposit(5n, 45n * 60n * 1000n); - const nearReadyRefill = readyDeposit(4n, 105n * 60n * 1000n); + it("orders candidates by ready maturity", () => { + const earlierSparseReady = readyDeposit(5n, 20n * 60n * 1000n); + const laterSparseReady = readyDeposit(5n, 45n * 60n * 1000n); expect( selectReadyWithdrawalDeposits({ - readyDeposits: [earlierSingleton, laterSingleton], - nearReadyDeposits: [nearReadyRefill], + readyDeposits: [laterSparseReady, earlierSparseReady], tip: TIP, maxAmount: 5n, - preserveSingletons: false, }).deposits, - ).toEqual([laterSingleton]); + ).toEqual([earlierSparseReady]); }); it("uses greedy fallback for later candidates beyond the bounded best-fit horizon", () => { @@ -227,7 +217,6 @@ describe("selectReadyWithdrawalDeposits", () => { tip: TIP, maxAmount: 10n, maxCount: 1, - preserveSingletons: false, }).deposits, ).toEqual([deposits[30]]); }); @@ -244,8 +233,7 @@ describe("selectExactReadyWithdrawalDepositCandidates", () => { tip: TIP, maxAmount: 10n, count: 1, - preserveSingletons: false, - score: (deposit) => (deposit as IckbDepositCell & { score: bigint }).score, + score: (deposit) => deposit.score, maturityBucket: (deposit) => deposit.maturity.toUnix(TIP) / (60n * 60n * 1000n), }).map((selection) => selection.deposits), ).toEqual([[earlier], [laterHigherScore]]); @@ -263,8 +251,7 @@ describe("selectExactReadyWithdrawalDepositCandidates", () => { tip: TIP, maxAmount: 10n, count: 2, - preserveSingletons: false, - score: (deposit) => (deposit as IckbDepositCell & { score: bigint }).score, + score: (deposit) => deposit.score, maturityBucket: () => 0n, }).map((selection) => selection.deposits), ).toEqual([ @@ -273,47 +260,3 @@ describe("selectExactReadyWithdrawalDepositCandidates", () => { ]); }); }); - -describe("selectReadyWithdrawalCleanupDeposit", () => { - it("selects an over-cap crowded extra and pins its protected anchor", () => { - const extra = readyDeposit(11n, 20n * 60n * 1000n); - const protectedAnchor = readyDeposit(12n, 25n * 60n * 1000n); - - expect( - selectReadyWithdrawalCleanupDeposit({ - readyDeposits: [extra, protectedAnchor], - tip: TIP, - minAmountExclusive: 10n, - maxAmount: 11n, - }), - ).toEqual({ deposit: extra, requiredLiveDeposit: protectedAnchor }); - }); - - it("does not select the protected crowded anchor", () => { - const extra = readyDeposit(11n, 20n * 60n * 1000n); - const protectedAnchor = readyDeposit(12n, 25n * 60n * 1000n); - - expect( - selectReadyWithdrawalCleanupDeposit({ - readyDeposits: [extra, protectedAnchor], - tip: TIP, - minAmountExclusive: 11n, - maxAmount: 12n, - }), - ).toBeUndefined(); - }); - - it("respects the cleanup amount ceiling", () => { - const extra = readyDeposit(11n, 20n * 60n * 1000n); - const protectedAnchor = readyDeposit(12n, 25n * 60n * 1000n); - - expect( - selectReadyWithdrawalCleanupDeposit({ - readyDeposits: [extra, protectedAnchor], - tip: TIP, - minAmountExclusive: 10n, - maxAmount: 10n, - }), - ).toBeUndefined(); - }); -}); diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json new file mode 100644 index 0000000..ca89ac2 --- /dev/null +++ b/packages/sdk/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "removeComments": false, + "rewriteRelativeImportExtensions": true, + "rootDir": "src", + "outDir": "dist", + "sourceRoot": "../src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*_test_support.ts"] +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 84a843d..c6a53fe 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -1,11 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": false, - "rootDir": "src", - "outDir": "dist", - "sourceRoot": "../src" + "noEmit": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src", "test"] } diff --git a/packages/sdk/vitest.config.mts b/packages/sdk/vitest.config.mts index dc6a587..d0205af 100644 --- a/packages/sdk/vitest.config.mts +++ b/packages/sdk/vitest.config.mts @@ -2,9 +2,13 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + include: [ + "{test,tests}/*.{ts,tsx}", + "test/{conversion,transaction,state}/*/*.{ts,tsx}", + "test/{estimate,send,withdrawal}/*.{ts,tsx}", + ], coverage: { - include: ["src/**/*.ts"], + include: ["src/**/*.{ts,tsx}"], }, }, });