diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 44cd724..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,3 +0,0 @@ - - -- [ ] I have read the [**Contributing Guidelines**](CONTRIBUTING.md) diff --git a/apps/bot/README.md b/apps/bot/README.md index d4820a6..1777cb7 100644 --- a/apps/bot/README.md +++ b/apps/bot/README.md @@ -6,8 +6,10 @@ The bot still aims to minimize excess iCKB holdings so more liquidity stays avai ## Docs -- [iCKB Deposit Pool Rebalancing Algorithm](pool_rebalancing.md) -- [iCKB Deposit Pool Snapshot Encoding](pool_snapshot.md) +- [Current Bot Rebalancing Policy](docs/current_rebalancing_policy.md) +- Future improvement ideas: + - [iCKB Deposit Pool Rebalancing Algorithm](docs/pool_rebalancing.md) + - [iCKB Deposit Pool Snapshot Encoding](docs/pool_snapshot.md) ## Environment @@ -63,6 +65,7 @@ The start script keeps the existing JSON log format and writes one log file per - Distribute liquidity across multiple isolated bots to limit blast radius. - Keep at least roughly 130k CKB worth of capital available for the bot to operate comfortably. - The bot relies on shared CCC packages for protocol-specific transaction content, but it still owns final iCKB completion, fee completion, signing, and send. +- The interface-side maturity estimate contract now lives with `@ickb/sdk`, because the SDK owns how bot liquidity and pool maturities are summarized for UI consumers. ## Licensing diff --git a/apps/bot/docs/README.md b/apps/bot/docs/README.md index 23c76ac..de151a5 100644 --- a/apps/bot/docs/README.md +++ b/apps/bot/docs/README.md @@ -4,5 +4,11 @@ This directory hosts comprehensive documentation outlining the inner workings of ## Documents +### Current runtime behavior + +- [Current Bot Rebalancing Policy](current_rebalancing_policy.md) + +### Future improvement ideas + - [iCKB Deposit Pool Rebalancing Algorithm](pool_rebalancing.md) - [iCKB Deposit Pool Snapshot Encoding](pool_snapshot.md) diff --git a/apps/bot/docs/current_rebalancing_policy.md b/apps/bot/docs/current_rebalancing_policy.md new file mode 100644 index 0000000..1d9b110 --- /dev/null +++ b/apps/bot/docs/current_rebalancing_policy.md @@ -0,0 +1,75 @@ +# Current Bot Rebalancing Policy + +This document describes the policy currently implemented in `apps/bot/src/policy.ts`. + +## Goal + +The bot keeps enough liquid iCKB to keep matching and redemption paths responsive, while leaving as much capital as practical in CKB. + +The live policy is intentionally small: + +- keep a minimum iCKB inventory +- refill that inventory with one direct deposit when it gets too low +- request withdrawals from ready pool deposits when iCKB inventory drifts too high +- do nothing when output space or balances make the action unsafe + +## Inputs + +`planRebalance(...)` decides from five inputs: + +- `outputSlots`: how many transaction output slots remain before the bot would hit its DAO-safe output cap +- `ickbBalance`: currently available iCKB after pending order matches are applied +- `ckbBalance`: currently available CKB after pending order matches are applied +- `depositCapacity`: the current CKB capacity required for one standard iCKB deposit at the live exchange ratio +- `readyDeposits`: ready pool deposits that the bot can request for withdrawal now + +## Constants + +The current policy is shaped by three constants in `apps/bot/src/policy.ts`: + +- `CKB_RESERVE = 1000 CKB`: the bot keeps this much extra CKB after making a new deposit +- `MIN_ICKB_BALANCE = 2000 iCKB`: if iCKB falls below this line, the bot tries to replenish it +- `TARGET_ICKB_BALANCE = 100000 iCKB + 20000 iCKB`: if iCKB rises above this target band, the bot tries to convert excess iCKB back toward CKB through ready deposit withdrawals + +The current withdrawal request cap is `30` deposits per transaction. + +## Decision Order + +The policy is deliberately greedy and local. + +1. If fewer than two output slots remain, do nothing. +2. If available iCKB is below `MIN_ICKB_BALANCE`: + - request one new deposit if available CKB is at least `depositCapacity + CKB_RESERVE` + - otherwise do nothing +3. If available iCKB is at or above `MIN_ICKB_BALANCE`, compute `excessIckb = ickbBalance - TARGET_ICKB_BALANCE`. +4. If `excessIckb <= 0`, do nothing. +5. Otherwise, pick a bounded subset of ready deposits whose total `udtValue` stays within `excessIckb`, and request withdrawals for that subset. + +## Ready Deposit Selection + +`selectReadyDeposits(...)` is intentionally simple. + +- It walks the ready deposits in the order they were prepared by the bot state reader. +- It skips any deposit that would push the cumulative selected `udtValue` above the current excess target. +- It stops once it reaches the request limit. + +This keeps the live policy predictable and cheap. It does not try to globally optimize pool shape. + +## Ownership Boundary + +This file describes bot-owned operating policy only. + +- The bot owns when to add one more deposit. +- The bot owns when to request ready withdrawals. +- `@ickb/sdk` owns UI-side maturity estimation from live stack state. +- The older pool snapshot idea is not part of the current runtime path. + +## Non-Goals + +This policy does not try to: + +- maintain a global optimal distribution of deposits over the full 180-epoch clock +- encode a snapshot summary for interface use +- predict or coordinate other bots' behavior beyond acting on current visible state + +Those may still be useful research directions, but they are not the current live contract. diff --git a/apps/bot/docs/pool_rebalancing.md b/apps/bot/docs/pool_rebalancing.md index 630d646..faf22eb 100644 --- a/apps/bot/docs/pool_rebalancing.md +++ b/apps/bot/docs/pool_rebalancing.md @@ -1,5 +1,7 @@ # iCKB Deposit Pool Rebalancing Algorithm +Future improvement idea: this document captures a more ambitious rebalancing design that is not the current live bot policy. The current implemented behavior is documented in `current_rebalancing_policy.md`. + For simplicity, let's model: - NervosDAO 180 epoch cycle as a circular clock. diff --git a/apps/bot/docs/pool_snapshot.md b/apps/bot/docs/pool_snapshot.md index ad68904..69ca475 100644 --- a/apps/bot/docs/pool_snapshot.md +++ b/apps/bot/docs/pool_snapshot.md @@ -1,5 +1,7 @@ # iCKB Deposit Pool Snapshot Encoding +Future improvement idea: this document captures a possible snapshot-based estimate path for large deposit pools. The current live runtime path is documented in `packages/sdk/docs/pool_maturity_estimates.md` and still uses direct deposit scans. + ## Introduction Efficient asset conversion timing is paramount for the iCKB protocol, particularly when converting from iCKB to CKB. Although CKB-to-iCKB conversion timings are relatively simple to predict, the reverse process is influenced by factors like Bot CKB availability and, critically, the maturity of iCKB deposits available for withdrawal. diff --git a/apps/bot/package.json b/apps/bot/package.json index 87e63f8..2bbc0ad 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -53,6 +53,7 @@ "@ckb-ccc/core": "catalog:", "@ickb/core": "workspace:*", "@ickb/order": "workspace:*", - "@ickb/sdk": "workspace:*" + "@ickb/sdk": "workspace:*", + "@ickb/utils": "workspace:*" } } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 334741e..d1ece62 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -12,6 +12,7 @@ import { type OrderGroup, } from "@ickb/order"; import { getConfig, IckbSdk, type SystemState } from "@ickb/sdk"; +import { isPlainCapacityCell } from "@ickb/utils"; import { CKB, planRebalance } from "./policy.js"; const MATCH_STEP_DIVISOR = 100n; @@ -41,7 +42,7 @@ interface BotState { availableIckbBalance: bigint; unavailableCkbBalance: bigint; totalCkbBalance: bigint; - depositAmount: bigint; + depositCapacity: bigint; minCkbBalance: bigint; } @@ -210,7 +211,7 @@ async function readBotState(runtime: Runtime): Promise { (group) => group.ckbValue, ); const totalCkbBalance = availableCkbBalance + unavailableCkbBalance; - const depositAmount = convert(false, ICKB_DEPOSIT_CAP, system.exchangeRatio); + const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, system.exchangeRatio); return { accountLocks, @@ -225,8 +226,8 @@ async function readBotState(runtime: Runtime): Promise { availableIckbBalance, unavailableCkbBalance, totalCkbBalance, - depositAmount, - minCkbBalance: (21n * depositAmount) / 20n, + depositCapacity, + minCkbBalance: (21n * depositCapacity) / 20n, }; } @@ -272,7 +273,7 @@ async function buildTransaction( state.system.exchangeRatio, { feeRate: state.system.feeRate, - ckbAllowanceStep: maxBigInt(1n, state.depositAmount / MATCH_STEP_DIVISOR), + ckbAllowanceStep: maxBigInt(1n, state.depositCapacity / MATCH_STEP_DIVISOR), }, ); if (match.partials.length > 0) { @@ -283,14 +284,14 @@ async function buildTransaction( outputSlots: maxInt(0, MAX_OUTPUTS_BEFORE_CHANGE - tx.outputs.length), ickbBalance: state.availableIckbBalance + match.udtDelta, ckbBalance: state.availableCkbBalance + match.ckbDelta, - depositAmount: state.depositAmount, + depositCapacity: state.depositCapacity, readyDeposits: state.readyPoolDeposits, }); if (rebalance.kind === "deposit") { tx = await runtime.managers.logic.deposit( tx, rebalance.quantity, - state.depositAmount, + state.depositCapacity, runtime.primaryLock, runtime.client, ); @@ -343,7 +344,7 @@ async function collectCapacityCells( "asc", 400, )) { - if (cell.cellOutput.type !== undefined || cell.outputData !== "0x") { + if (!isPlainCapacityCell(cell)) { continue; } cells.push(cell); diff --git a/apps/bot/src/policy.test.ts b/apps/bot/src/policy.test.ts index e460966..295235e 100644 --- a/apps/bot/src/policy.test.ts +++ b/apps/bot/src/policy.test.ts @@ -28,7 +28,7 @@ describe("planRebalance", () => { outputSlots: 1, ickbBalance: 0n, ckbBalance: 2000n * 100000000n, - depositAmount: 1000n * 100000000n, + depositCapacity: 1000n * 100000000n, readyDeposits: [], }), ).toEqual({ kind: "none" }); @@ -40,7 +40,7 @@ describe("planRebalance", () => { outputSlots: 4, ickbBalance: 0n, ckbBalance: 2000n * 100000000n, - depositAmount: 1000n * 100000000n, + depositCapacity: 1000n * 100000000n, readyDeposits: [], }), ).toEqual({ kind: "deposit", quantity: 1 }); @@ -52,7 +52,7 @@ describe("planRebalance", () => { outputSlots: 4, ickbBalance: 0n, ckbBalance: 1999n * 100000000n, - depositAmount: 1000n * 100000000n, + depositCapacity: 1000n * 100000000n, readyDeposits: [], }), ).toEqual({ kind: "none" }); @@ -63,7 +63,7 @@ describe("planRebalance", () => { outputSlots: 6, ickbBalance: TARGET_ICKB_BALANCE + 9n, ckbBalance: 0n, - depositAmount: 1000n, + depositCapacity: 1000n, readyDeposits: [ { udtValue: 4n }, { udtValue: 6n }, @@ -83,7 +83,7 @@ describe("planRebalance", () => { outputSlots: 6, ickbBalance: TARGET_ICKB_BALANCE + 3n, ckbBalance: 0n, - depositAmount: 1000n, + depositCapacity: 1000n, readyDeposits: [{ udtValue: 4n }] as never[], }), ).toEqual({ kind: "none" }); diff --git a/apps/bot/src/policy.ts b/apps/bot/src/policy.ts index 37a3c96..0af69c5 100644 --- a/apps/bot/src/policy.ts +++ b/apps/bot/src/policy.ts @@ -17,10 +17,16 @@ export function planRebalance(options: { outputSlots: number; ickbBalance: bigint; ckbBalance: bigint; - depositAmount: bigint; + depositCapacity: bigint; readyDeposits: readonly IckbDepositCell[]; }): RebalancePlan { - const { outputSlots, ickbBalance, ckbBalance, depositAmount, readyDeposits } = + const { + outputSlots, + ickbBalance, + ckbBalance, + depositCapacity, + readyDeposits, + } = options; if (outputSlots < 2) { @@ -28,7 +34,7 @@ export function planRebalance(options: { } if (ickbBalance < MIN_ICKB_BALANCE) { - if (ckbBalance >= depositAmount + CKB_RESERVE) { + if (ckbBalance >= depositCapacity + CKB_RESERVE) { return { kind: "deposit", quantity: 1 }; } return { kind: "none" }; diff --git a/apps/interface/package.json b/apps/interface/package.json index 5bf09ce..775db34 100644 --- a/apps/interface/package.json +++ b/apps/interface/package.json @@ -25,6 +25,8 @@ "dev": "pnpm --filter @ickb/core --filter @ickb/order --filter @ickb/sdk --filter @ickb/utils build && vite", "build": "pnpm --filter @ickb/core --filter @ickb/order --filter @ickb/sdk --filter @ickb/utils build && tsc && vite build", "preview": "vite preview", + "test": "vitest", + "test:ci": "vitest run", "lint": "eslint ./src", "clean": "rm -fr dist", "clean:deep": "rm -fr dist node_modules" diff --git a/apps/interface/src/queries.test.ts b/apps/interface/src/queries.test.ts new file mode 100644 index 0000000..272b35d --- /dev/null +++ b/apps/interface/src/queries.test.ts @@ -0,0 +1,52 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { describe, expect, it } from "vitest"; +import { isPlainCapacityCell } from "@ickb/utils"; + +function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + return `0x${hexByte.repeat(32)}`; +} + +function script(codeHashByte: string): ccc.Script { + return ccc.Script.from({ + codeHash: byte32FromByte(codeHashByte), + hashType: "type", + args: "0x", + }); +} + +describe("isPlainCapacityCell", () => { + it("accepts only no-type empty-data cells", () => { + const plain = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("11"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(1000), + lock: script("22"), + }, + outputData: "0x", + }); + const typed = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("33"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(2000), + lock: script("22"), + type: script("44"), + }, + outputData: "0x", + }); + const dataCell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(3000), + lock: script("22"), + }, + outputData: "0xab", + }); + + expect(isPlainCapacityCell(plain)).toBe(true); + expect(isPlainCapacityCell(typed)).toBe(false); + expect(isPlainCapacityCell(dataCell)).toBe(false); + }); +}); diff --git a/apps/interface/src/queries.ts b/apps/interface/src/queries.ts index 140b5e5..1799eb2 100644 --- a/apps/interface/src/queries.ts +++ b/apps/interface/src/queries.ts @@ -2,7 +2,7 @@ import { ccc } from "@ckb-ccc/ccc"; import type { WithdrawalGroup } from "@ickb/core"; import { type OrderGroup } from "@ickb/order"; import { type SystemState } from "@ickb/sdk"; -import { collect, sum } from "@ickb/utils"; +import { collect, isPlainCapacityCell, sum, unique } from "@ickb/utils"; import { buildTransactionPreview, type TransactionContext, @@ -76,7 +76,7 @@ export async function getL1State( ), ]); - const capacityCells = accountCells.filter((cell) => cell.cellOutput.type === undefined); + const capacityCells = accountCells.filter(isPlainCapacityCell); const udtCells = accountCells.filter((cell) => walletConfig.managers.ickbUdt.isUdt(cell), ); @@ -169,7 +169,7 @@ export async function getL1State( async function getAccountCells(walletConfig: WalletConfig): Promise { const cells: ccc.Cell[] = []; - for (const lock of walletConfig.accountLocks) { + for (const lock of unique(walletConfig.accountLocks)) { for await (const cell of walletConfig.cccClient.findCellsOnChain( { script: lock, diff --git a/apps/interface/src/transaction.test.ts b/apps/interface/src/transaction.test.ts new file mode 100644 index 0000000..f47903b --- /dev/null +++ b/apps/interface/src/transaction.test.ts @@ -0,0 +1,152 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { Ratio } from "@ickb/order"; +import { describe, expect, it, vi } from "vitest"; +import { buildTransactionPreview, selectReadyDeposits } from "./transaction.ts"; +import type { TransactionContext } from "./transaction.ts"; +import type { WalletConfig } from "./utils.ts"; + +function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + return `0x${hexByte.repeat(32)}`; +} + +function script(codeHashByte: string): ccc.Script { + return ccc.Script.from({ + codeHash: byte32FromByte(codeHashByte), + hashType: "type", + args: "0x", + }); +} + +function context(overrides: Partial = {}): TransactionContext { + return { + system: { + feeRate: 1n, + tip: { timestamp: 0n } as ccc.ClientBlockHeader, + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + orderPool: [], + ckbAvailable: 0n, + ckbMaturing: [], + }, + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + ...overrides, + }; +} + +function identityTx(txLike: ccc.TransactionLike): ccc.Transaction { + return ccc.Transaction.from(txLike); +} + +function resolvedTx(txLike: ccc.TransactionLike): Promise { + return Promise.resolve(ccc.Transaction.from(txLike)); +} + +function emptyDeposits(): AsyncGenerator { + return (async function* (): AsyncGenerator { + await Promise.resolve(); + yield* [] as never[]; + })(); +} + +function walletConfig(overrides: Partial = {}): WalletConfig { + return { + chain: "testnet", + cccClient: {} as ccc.Client, + queryClient: {} as WalletConfig["queryClient"], + signer: {} as ccc.Signer, + address: "ckt1test", + accountLocks: [], + primaryLock: script("11"), + sdk: { + collect: identityTx, + request: resolvedTx, + } as unknown as WalletConfig["sdk"], + managers: { + ickbUdt: { + completeBy: resolvedTx, + } as unknown as WalletConfig["managers"]["ickbUdt"], + logic: { + completeDeposit: identityTx, + deposit: resolvedTx, + findDeposits: emptyDeposits, + } as unknown as WalletConfig["managers"]["logic"], + ownedOwner: { + withdraw: resolvedTx, + requestWithdrawal: resolvedTx, + } as unknown as WalletConfig["managers"]["ownedOwner"], + order: {} as WalletConfig["managers"]["order"], + }, + ...overrides, + }; +} + +describe("selectReadyDeposits", () => { + it("finds an exact-count subset when the greedy maturity path fails", () => { + const deposits = [{ udtValue: 6n }, { udtValue: 5n }, { udtValue: 5n }]; + + expect(selectReadyDeposits(deposits as never[], 2, 10n)).toEqual([ + deposits[1], + deposits[2], + ]); + }); + + it("prefers the fullest exact-count subset under the cap", () => { + const deposits = [{ udtValue: 1n }, { udtValue: 4n }, { udtValue: 5n }]; + + expect(selectReadyDeposits(deposits as never[], 2, 10n)).toEqual([ + deposits[1], + deposits[2], + ]); + }); + + it("keeps earlier deposits when equally full subsets tie", () => { + const deposits = [{ udtValue: 5n }, { udtValue: 5n }, { udtValue: 5n }]; + + expect(selectReadyDeposits(deposits as never[], 2, 10n)).toEqual([ + deposits[0], + deposits[1], + ]); + }); + + it("bounds the search to the direct-withdrawal preview cap", () => { + const deposits = [ + ...Array.from({ length: 30 }, () => ({ udtValue: 6n })), + { udtValue: 5n }, + { udtValue: 5n }, + ]; + + expect(selectReadyDeposits(deposits as never[], 2, 10n)).toEqual([]); + }); + + it("returns no subset when no exact-count fit exists", () => { + const deposits = [{ udtValue: 6n }, { udtValue: 5n }, { udtValue: 5n }]; + + expect(selectReadyDeposits(deposits as never[], 2, 9n)).toEqual([]); + }); +}); + +describe("buildTransactionPreview", () => { + it("reports the preview threshold instead of a generic build failure", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockResolvedValue([0, false]); + vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(0n); + + const txInfo = await buildTransactionPreview( + context({ ckbAvailable: 1n }), + true, + 1n, + walletConfig(), + ); + + expect(txInfo.error).toBe( + "Amount too small to exceed the minimum match and fee threshold", + ); + }); +}); diff --git a/apps/interface/src/transaction.ts b/apps/interface/src/transaction.ts index b25fed9..f90a02d 100644 --- a/apps/interface/src/transaction.ts +++ b/apps/interface/src/transaction.ts @@ -102,8 +102,8 @@ async function buildCkbToIckbPreview( amount: bigint, walletConfig: WalletConfig, ): Promise { - const depositAmount = convert(false, ICKB_DEPOSIT_CAP, context.system.exchangeRatio); - const depositQuotient = depositAmount === 0n ? 0n : amount / depositAmount; + const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, context.system.exchangeRatio); + const depositQuotient = depositCapacity === 0n ? 0n : amount / depositCapacity; const maxDeposits = depositQuotient > BigInt(MAX_DIRECT_DEPOSITS) ? MAX_DIRECT_DEPOSITS @@ -118,18 +118,21 @@ async function buildCkbToIckbPreview( tx = await walletConfig.managers.logic.deposit( tx, depositCount, - depositAmount, + depositCapacity, walletConfig.primaryLock, walletConfig.cccClient, ); } - const remainder = amount - depositAmount * BigInt(depositCount); + const remainder = amount - depositCapacity * BigInt(depositCount); if (remainder > 0n) { const amounts = { ckbValue: remainder, udtValue: 0n }; const estimate = IckbSdk.estimate(true, amounts, context.system); if (estimate.maturity === undefined) { - return txInfoWithError("Amount too small to build a transaction", estimatedMaturity); + return txInfoWithError( + "Amount too small to exceed the minimum match and fee threshold", + estimatedMaturity, + ); } estimatedMaturity = maxMaturity(estimatedMaturity, estimate.maturity); @@ -172,6 +175,8 @@ async function buildIckbToCkbPreview( Math.min(candidates.length, MAX_WITHDRAWAL_REQUESTS), async (withdrawalCount) => { try { + // DAO withdrawal requests must claim matching input/output indexes, so + // build those pairs first and append the input-only base activity later. let tx = ccc.Transaction.default(); let estimatedMaturity = context.estimatedMaturity; let remainder = amount; @@ -211,7 +216,10 @@ async function buildIckbToCkbPreview( const amounts = { ckbValue: 0n, udtValue: remainder }; const estimate = IckbSdk.estimate(false, amounts, context.system); if (estimate.maturity === undefined) { - return txInfoWithError("Amount too small to build a transaction", estimatedMaturity); + return txInfoWithError( + "Amount too small to exceed the minimum match and fee threshold", + estimatedMaturity, + ); } estimatedMaturity = maxMaturity(estimatedMaturity, estimate.maturity); @@ -257,38 +265,198 @@ async function findBestAttempt( maxQuantity: number, build: (quantity: number) => Promise, ): Promise { - let firstError: TxInfo | undefined; + let lastError: TxInfo | undefined; for (let quantity = maxQuantity; quantity >= 0; quantity -= 1) { const attempt = await build(quantity); if (attempt.error === "") { return attempt; } - firstError ??= attempt; + lastError = attempt; } - return firstError ?? txInfoWithError("Nothing to do for now", 0n); + return lastError ?? txInfoWithError("Nothing to do for now", 0n); } -function selectReadyDeposits( +export function selectReadyDeposits( deposits: IckbDepositCell[], wanted: number, amount: bigint, ): IckbDepositCell[] { - const selected: IckbDepositCell[] = []; - let cumulative = 0n; + const boundedDeposits = deposits.slice(0, MAX_WITHDRAWAL_REQUESTS); + if (wanted <= 0 || amount <= 0n || boundedDeposits.length < wanted) { + return []; + } + + interface PartialSelection { + mask: number; + total: bigint; + } + + const split = Math.floor(boundedDeposits.length / 2); + const firstHalf = boundedDeposits.slice(0, split); + const secondHalf = boundedDeposits.slice(split); + + const compareMask = (left: number, right: number, length: number): number => { + for (let i = 0; i < length; i += 1) { + const leftHas = (left & (1 << i)) !== 0; + const rightHas = (right & (1 << i)) !== 0; + if (leftHas === rightHas) { + continue; + } + + return leftHas ? -1 : 1; + } + + return 0; + }; + + const enumerate = (items: IckbDepositCell[]): PartialSelection[][] => { + const groups = Array.from( + { length: items.length + 1 }, + () => [] as PartialSelection[], + ); + + const search = ( + index: number, + mask: number, + count: number, + total: bigint, + ): void => { + if (index === items.length) { + groups[count]?.push({ mask, total }); + return; + } + + search(index + 1, mask, count, total); + + const item = items.at(index); + if (item === undefined) { + return; + } + search(index + 1, mask | (1 << index), count + 1, total + item.udtValue); + }; + + search(0, 0, 0, 0n); + return groups; + }; + + const compress = (items: PartialSelection[], length: number): PartialSelection[] => { + items.sort((left, right) => { + if (left.total < right.total) { + return -1; + } + if (left.total > right.total) { + return 1; + } + + return compareMask(left.mask, right.mask, length); + }); + + const compressed: PartialSelection[] = []; + for (const item of items) { + if (compressed.at(-1)?.total !== item.total) { + compressed.push(item); + } + } + + return compressed; + }; + + const firstByCount = enumerate(firstHalf); + const secondByCount = enumerate(secondHalf).map((items) => + compress(items, secondHalf.length) + ); - for (const deposit of deposits) { - if (selected.length >= wanted) { - break; + const findBestAtOrBelow = ( + items: PartialSelection[], + limit: bigint, + ): PartialSelection | undefined => { + let low = 0; + let high = items.length - 1; + let bestIndex = -1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const item = items.at(mid); + if (item === undefined) { + break; + } + + if (item.total <= limit) { + bestIndex = mid; + low = mid + 1; + } else { + high = mid - 1; + } } - if (cumulative + deposit.udtValue > amount) { + return bestIndex >= 0 ? items[bestIndex] : undefined; + }; + + let best: + | { + firstMask: number; + secondMask: number; + total: bigint; + } + | undefined; + + for (let firstCount = 0; firstCount <= wanted; firstCount += 1) { + const secondCount = wanted - firstCount; + const firstSelections = firstByCount[firstCount] ?? []; + const secondSelections = secondByCount[secondCount] ?? []; + if (secondSelections.length === 0) { continue; } - cumulative += deposit.udtValue; - selected.push(deposit); + for (const first of firstSelections) { + const second = findBestAtOrBelow(secondSelections, amount - first.total); + if (!second) { + continue; + } + + const total = first.total + second.total; + if (!best || total > best.total) { + best = { firstMask: first.mask, secondMask: second.mask, total }; + continue; + } + + if (total < best.total) { + continue; + } + + const firstCompare = compareMask(first.mask, best.firstMask, firstHalf.length); + if ( + firstCompare < 0 || + (firstCompare === 0 && + compareMask(second.mask, best.secondMask, secondHalf.length) < 0) + ) { + best = { firstMask: first.mask, secondMask: second.mask, total }; + } + } + } + + if (!best) { + return []; + } + + const selected: IckbDepositCell[] = []; + for (let i = 0; i < firstHalf.length; i += 1) { + if ((best.firstMask & (1 << i)) !== 0) { + const deposit = firstHalf.at(i); + if (deposit !== undefined) { + selected.push(deposit); + } + } + } + for (let i = 0; i < secondHalf.length; i += 1) { + if ((best.secondMask & (1 << i)) !== 0) { + const deposit = secondHalf.at(i); + if (deposit !== undefined) { + selected.push(deposit); + } + } } return selected; diff --git a/apps/interface/tsconfig.node.json b/apps/interface/tsconfig.node.json index 97ede7e..fee0a53 100644 --- a/apps/interface/tsconfig.node.json +++ b/apps/interface/tsconfig.node.json @@ -7,5 +7,5 @@ "allowSyntheticDefaultImports": true, "strict": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.mts"] } diff --git a/apps/interface/vitest.config.mts b/apps/interface/vitest.config.mts new file mode 100644 index 0000000..dc6a587 --- /dev/null +++ b/apps/interface/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + coverage: { + include: ["src/**/*.ts"], + }, + }, +}); diff --git a/apps/tester/package.json b/apps/tester/package.json index 88ad8b8..e974ba7 100644 --- a/apps/tester/package.json +++ b/apps/tester/package.json @@ -1,5 +1,5 @@ { - "name": "@ickb/v1-tester", + "name": "@ickb/tester", "version": "1001.0.0", "description": "Simulator of iCKB limit order creation", "keywords": [ @@ -52,6 +52,7 @@ "@ckb-ccc/core": "catalog:", "@ickb/core": "workspace:*", "@ickb/order": "workspace:*", - "@ickb/sdk": "workspace:*" + "@ickb/sdk": "workspace:*", + "@ickb/utils": "workspace:*" } } diff --git a/apps/tester/src/cells.ts b/apps/tester/src/cells.ts new file mode 100644 index 0000000..b95c88c --- /dev/null +++ b/apps/tester/src/cells.ts @@ -0,0 +1,28 @@ +import { ccc } from "@ckb-ccc/core"; +import { isPlainCapacityCell } from "@ickb/utils"; + +const FIND_CELLS_PAGE_SIZE = 400; + +export async function collectCapacityCells( + signer: Pick, +): Promise { + const cells: ccc.Cell[] = []; + + for await (const cell of signer.findCellsOnChain( + { + scriptLenRange: [0n, 1n], + outputDataLenRange: [0n, 1n], + }, + true, + "asc", + FIND_CELLS_PAGE_SIZE, + )) { + if (!isPlainCapacityCell(cell)) { + continue; + } + + cells.push(cell); + } + + return cells; +} diff --git a/apps/tester/src/index.test.ts b/apps/tester/src/index.test.ts new file mode 100644 index 0000000..f8645cc --- /dev/null +++ b/apps/tester/src/index.test.ts @@ -0,0 +1,60 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { collectCapacityCells } from "./cells.js"; + +function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + return `0x${hexByte.repeat(32)}`; +} + +function script(codeHashByte: string): ccc.Script { + return ccc.Script.from({ + codeHash: byte32FromByte(codeHashByte), + hashType: "type", + args: "0x", + }); +} + +describe("collectCapacityCells", () => { + it("keeps only plain capacity cells", async () => { + const plain = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("11"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(1000), + lock: script("22"), + }, + outputData: "0x", + }); + const typed = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("33"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(2000), + lock: script("22"), + type: script("44"), + }, + outputData: "0x", + }); + const dataCell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(3000), + lock: script("22"), + }, + outputData: "0xab", + }); + const signer = { + async *findCellsOnChain() { + await Promise.resolve(); + yield plain; + yield typed; + yield dataCell; + }, + } as Pick; + + const cells = await collectCapacityCells(signer); + + expect(cells).toEqual([plain]); + }); +}); diff --git a/apps/tester/src/index.ts b/apps/tester/src/index.ts index 2083582..26874bb 100644 --- a/apps/tester/src/index.ts +++ b/apps/tester/src/index.ts @@ -2,6 +2,7 @@ import { ccc } from "@ckb-ccc/core"; import { ICKB_DEPOSIT_CAP, convert } from "@ickb/core"; import { IckbSdk, getConfig, type SystemState } from "@ickb/sdk"; import { type OrderGroup } from "@ickb/order"; +import { collectCapacityCells } from "./cells.js"; const CKB = ccc.fixedPointFrom(1); const CKB_RESERVE = 2000n * CKB; @@ -81,7 +82,7 @@ async function main(): Promise { continue; } - const depositAmount = convert(false, ICKB_DEPOSIT_CAP, state.system.tip); + const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, state.system.tip); const totalEquivalentCkb = state.availableCkbBalance + convert(false, state.availableIckbBalance, state.system.tip); @@ -118,7 +119,7 @@ async function main(): Promise { const ckbAmount = isCkb2Udt ? min( - sampleRatio(depositAmount), + sampleRatio(depositCapacity), state.availableCkbBalance - CKB_RESERVE, ) : 0n; @@ -130,7 +131,7 @@ async function main(): Promise { ); if (ckbAmount <= 0n && udtAmount <= 0n) { - if (totalEquivalentCkb < depositAmount / MIN_TOTAL_CAPITAL_DIVISOR) { + if (totalEquivalentCkb < depositCapacity / MIN_TOTAL_CAPITAL_DIVISOR) { executionLog.error = "Not enough funds to continue testing, shutting down..."; console.log(JSON.stringify(executionLog, replacer, " ")); @@ -209,26 +210,6 @@ async function readTesterState(runtime: Runtime): Promise { }; } -async function collectCapacityCells( - signer: ccc.SignerCkbPrivateKey, -): Promise { - const cells: ccc.Cell[] = []; - - for await (const cell of signer.findCellsOnChain( - { - scriptLenRange: [0n, 1n], - outputDataLenRange: [0n, 1n], - }, - true, - "asc", - FIND_CELLS_PAGE_SIZE, - )) { - cells.push(cell); - } - - return cells; -} - async function collectWalletUdtCells( signer: ccc.SignerCkbPrivateKey, ickbUdt: Runtime["managers"]["ickbUdt"], diff --git a/package.json b/package.json index 55d9e82..b05e364 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "coworker:ask": "opencode run --pure --agent plan" }, "engines": { - "node": ">=24" + "node": ">=22" }, "devDependencies": { "@eslint/js": "^9.39.3", diff --git a/packages/core/src/cells.test.ts b/packages/core/src/cells.test.ts new file mode 100644 index 0000000..4354320 --- /dev/null +++ b/packages/core/src/cells.test.ts @@ -0,0 +1,154 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { DaoManager } from "@ickb/dao"; +import { receiptCellFrom } from "./cells.js"; +import { ReceiptData } from "./entities.js"; +import { IckbUdt, ickbValue } from "./udt.js"; + +function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + return `0x${hexByte.repeat(32)}`; +} + +function script(codeHashByte: string): ccc.Script { + return ccc.Script.from({ + codeHash: byte32FromByte(codeHashByte), + hashType: "type", + args: "0x", + }); +} + +function headerLike(ar: bigint): { + compactTarget: bigint; + dao: { + c: bigint; + ar: bigint; + s: bigint; + u: bigint; + }; + epoch: [bigint, bigint, bigint]; + extraHash: `0x${string}`; + hash: `0x${string}`; + nonce: bigint; + number: bigint; + parentHash: `0x${string}`; + proposalsHash: `0x${string}`; + timestamp: bigint; + transactionsRoot: `0x${string}`; + version: bigint; +} { + return { + compactTarget: 0n, + dao: { + c: 0n, + ar, + s: 0n, + u: 0n, + }, + epoch: [1n, 0n, 1n], + extraHash: byte32FromByte("aa"), + hash: byte32FromByte("bb"), + nonce: 0n, + number: 1n, + parentHash: byte32FromByte("cc"), + proposalsHash: byte32FromByte("dd"), + timestamp: 0n, + transactionsRoot: byte32FromByte("ee"), + version: 0n, + }; +} + +function clientWithHeader(header: ccc.ClientBlockHeader): ccc.Client { + return { + getTransactionWithHeader: () => Promise.resolve({ header }), + } as unknown as ccc.Client; +} + +function receiptOutputData( + depositQuantity: number, + depositAmount: ccc.FixedPoint, +): ccc.Hex { + return ccc.hexFrom([ + ...ReceiptData.from({ depositQuantity, depositAmount }).toBytes(), + 0xab, + 0xcd, + ]); +} + +function receiptCell(outputData: ccc.Hex, logic: ccc.Script): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("11"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("22"), + type: logic, + }, + outputData, + }); +} + +describe("receipt prefix decoding", () => { + it("lets receiptCellFrom ignore trailing bytes", async () => { + const logic = script("33"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const outputData = receiptOutputData(2, ccc.fixedPointFrom(100000)); + + const receipt = await receiptCellFrom({ + cell: receiptCell(outputData, logic), + client: clientWithHeader(header), + }); + + expect(receipt.ckbValue).toBe(ccc.fixedPointFrom(100082)); + expect(receipt.udtValue).toBe(ccc.fixedPointFrom(200000)); + }); + + it("lets IckbUdt.infoFrom value receipt prefixes with trailing bytes", async () => { + const logic = script("33"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const outputData = receiptOutputData(3, ccc.fixedPointFrom(100000)); + const cell = receiptCell(outputData, logic); + const ickbUdt = new IckbUdt( + { txHash: byte32FromByte("44"), index: 0n }, + script("55"), + { txHash: byte32FromByte("66"), index: 0n }, + logic, + new DaoManager(script("77"), []), + ); + + const info = await ickbUdt.infoFrom(clientWithHeader(header), cell); + + expect(info.balance).toBe(ickbValue(ccc.fixedPointFrom(100000), header) * 3n); + expect(info.capacity).toBe(ccc.fixedPointFrom(100082)); + expect(info.count).toBe(1); + }); + + it("subtracts deposit value from UDT balance info", async () => { + const logic = script("33"); + const dao = script("44"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const cell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("88"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: logic, + type: dao, + }, + outputData: "0x0000000000000000", + }); + const ickbUdt = new IckbUdt( + { txHash: byte32FromByte("44"), index: 0n }, + script("55"), + { txHash: byte32FromByte("66"), index: 0n }, + logic, + new DaoManager(dao, []), + ); + + const info = await ickbUdt.infoFrom(clientWithHeader(header), cell); + + expect(info.balance).toBe(-ickbValue(cell.capacityFree, header)); + expect(info.capacity).toBe(ccc.fixedPointFrom(100082)); + expect(info.count).toBe(1); + }); +}); diff --git a/packages/core/src/cells.ts b/packages/core/src/cells.ts index 7b346fc..7cd63bb 100644 --- a/packages/core/src/cells.ts +++ b/packages/core/src/cells.ts @@ -85,7 +85,7 @@ export async function receiptCellFrom( header: txWithHeader.header, txHash, }; - const { depositQuantity, depositAmount } = ReceiptData.decode( + const { depositQuantity, depositAmount } = ReceiptData.decodePrefix( cell.outputData, ); diff --git a/packages/core/src/entities.ts b/packages/core/src/entities.ts index ed7a1e2..b2e68cf 100644 --- a/packages/core/src/entities.ts +++ b/packages/core/src/entities.ts @@ -61,7 +61,7 @@ export class OwnerData extends ccc.Entity.Base() { export interface ReceiptDataLike { /** The quantity of deposits. */ depositQuantity: ccc.NumLike; - /** The total amount of deposits. */ + /** The unoccupied capacity of each deposit tracked by the receipt. */ depositAmount: ccc.FixedPointLike; } @@ -84,7 +84,7 @@ export class ReceiptData extends ccc.Entity.Base< * Creates an instance of ReceiptData. * * @param depositQuantity - The quantity of deposits. - * @param depositAmount - The total amount of deposits. + * @param depositAmount - The unoccupied capacity of each tracked deposit. */ constructor( public depositQuantity: ccc.Num, @@ -110,4 +110,8 @@ export class ReceiptData extends ccc.Entity.Base< ccc.fixedPointFrom(depositAmount), ); } + + static decodePrefix(encoded: ccc.Hex): ReceiptData { + return ReceiptData.decode(encoded.slice(0, 26)); + } } diff --git a/packages/core/src/logic.test.ts b/packages/core/src/logic.test.ts new file mode 100644 index 0000000..3b84d2f --- /dev/null +++ b/packages/core/src/logic.test.ts @@ -0,0 +1,167 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DaoManager } from "@ickb/dao"; +import { collect } from "@ickb/utils"; +import { ReceiptData } from "./entities.js"; +import { LogicManager } from "./logic.js"; + +function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + return `0x${hexByte.repeat(32)}`; +} + +function script(codeHashByte: string): ccc.Script { + return ccc.Script.from({ + codeHash: byte32FromByte(codeHashByte), + hashType: "type", + args: "0x", + }); +} + +describe("LogicManager.deposit", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("encodes receipt amounts from deposit free capacity", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const logic = script("11"); + const dao = script("22"); + const user = script("33"); + const manager = new LogicManager(logic, [], new DaoManager(dao, [])); + + const tx = await manager.deposit( + ccc.Transaction.default(), + 2, + ccc.fixedPointFrom(100082), + user, + {} as ccc.Client, + ); + + expect(tx.outputs).toHaveLength(3); + expect(tx.outputs[0]?.capacity).toBe(ccc.fixedPointFrom(100082)); + expect(tx.outputs[1]?.capacity).toBe(ccc.fixedPointFrom(100082)); + + const receiptData = tx.outputsData[2]; + if (!receiptData) { + throw new Error("Expected receipt output data"); + } + + const receipt = ReceiptData.decode(receiptData); + expect(receipt.depositQuantity).toBe(2n); + expect(receipt.depositAmount).toBe(ccc.fixedPointFrom(100000)); + }); + + it("keeps the protocol minimum on unoccupied capacity", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); + + await expect( + manager.deposit( + ccc.Transaction.default(), + 1, + ccc.fixedPointFrom(1081), + script("33"), + {} as ccc.Client, + ), + ).rejects.toThrow( + "iCKB deposit minimum is 1000 CKB free capacity (1082 CKB total capacity)", + ); + }); + + it("keeps the protocol maximum on unoccupied capacity", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); + + await expect( + manager.deposit( + ccc.Transaction.default(), + 1, + ccc.fixedPointFrom(1000083), + script("33"), + {} as ccc.Client, + ), + ).rejects.toThrow( + "iCKB deposit maximum is 1000000 CKB free capacity (1000082 CKB total capacity)", + ); + }); + + it("filters receipts by exact lock and type while deduplicating locks", async () => { + const logic = script("11"); + const wantedLock = script("22"); + const otherLock = script("33"); + const receiptData = ReceiptData.from({ + depositQuantity: 1, + depositAmount: ccc.fixedPointFrom(100000), + }).toBytes(); + const validReceipt = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: wantedLock, + type: logic, + }, + outputData: receiptData, + }); + const wrongLock = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: otherLock, + type: logic, + }, + outputData: receiptData, + }); + const wrongType = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("66"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: wantedLock, + type: script("77"), + }, + outputData: receiptData, + }); + const header = ccc.ClientBlockHeader.from({ + compactTarget: 0n, + dao: { c: 0n, ar: 10000000000000000n, s: 0n, u: 0n }, + epoch: [1n, 0n, 1n], + extraHash: byte32FromByte("aa"), + hash: byte32FromByte("bb"), + nonce: 0n, + number: 1n, + parentHash: byte32FromByte("cc"), + proposalsHash: byte32FromByte("dd"), + timestamp: 0n, + transactionsRoot: byte32FromByte("ee"), + version: 0n, + }); + let calls = 0; + const client = { + findCells: async function* () { + await Promise.resolve(); + calls += 1; + yield validReceipt; + yield wrongLock; + yield wrongType; + }, + getTransactionWithHeader: async () => { + await Promise.resolve(); + return { header }; + }, + } as unknown as ccc.Client; + const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); + + const receipts = await collect( + manager.findReceipts(client, [wantedLock, wantedLock]), + ); + + expect(calls).toBe(1); + expect(receipts).toHaveLength(1); + expect(receipts[0]?.cell.outPoint.txHash).toBe(byte32FromByte("44")); + }); +}); diff --git a/packages/core/src/logic.ts b/packages/core/src/logic.ts index 5118bb0..b22d3dd 100644 --- a/packages/core/src/logic.ts +++ b/packages/core/src/logic.ts @@ -58,7 +58,7 @@ export class LogicManager implements ScriptDeps { * * @param txLike - The transaction to add the deposit to. * @param depositQuantity - The quantity of deposits. - * @param depositAmount - The amount of each deposit. + * @param depositCapacity - The total capacity of each deposit output. * @param lock - The lock script for the output receipt cell. * * @remarks Caller must ensure UDT cellDeps are added to the transaction @@ -67,7 +67,7 @@ export class LogicManager implements ScriptDeps { async deposit( txLike: ccc.TransactionLike, depositQuantity: number, - depositAmount: ccc.FixedPoint, + depositCapacity: ccc.FixedPoint, lock: ccc.Script, client: ccc.Client, ): Promise { @@ -76,23 +76,41 @@ export class LogicManager implements ScriptDeps { return tx; } - if (depositAmount < ccc.fixedPointFrom(1082)) { - throw new Error("iCKB deposit minimum is 1082 CKB"); + const depositCell = ccc.Cell.from({ + previousOutput: { + txHash: `0x${"00".repeat(32)}`, + index: 0, + }, + cellOutput: { + capacity: depositCapacity, + lock: this.script, + type: this.daoManager.script, + }, + outputData: DaoManager.depositData(), + }); + const depositAmount = depositCell.capacityFree; + + if (depositAmount < ccc.fixedPointFrom(1000)) { + throw new Error( + "iCKB deposit minimum is 1000 CKB free capacity (1082 CKB total capacity)", + ); } - if (depositAmount > ccc.fixedPointFrom(1000082)) { - throw new Error("iCKB deposit maximum is 1000082 CKB"); + if (depositAmount > ccc.fixedPointFrom(1000000)) { + throw new Error( + "iCKB deposit maximum is 1000000 CKB free capacity (1000082 CKB total capacity)", + ); } tx.addCellDeps(this.cellDeps); const capacities = Array.from( { length: depositQuantity }, - () => depositAmount, + () => depositCapacity, ); tx = await this.daoManager.deposit(tx, capacities, this.script, client); - // Add the Receipt to the outputs + // Receipts track the deposit's free capacity, not the full DAO cell capacity. tx.addOutput( { lock: lock, diff --git a/packages/core/src/udt.ts b/packages/core/src/udt.ts index 591d278..0472d54 100644 --- a/packages/core/src/udt.ts +++ b/packages/core/src/udt.ts @@ -119,7 +119,7 @@ export class IckbUdt extends udt.Udt { } const { depositQuantity, depositAmount } = - ReceiptData.decode(cell.outputData); + ReceiptData.decodePrefix(cell.outputData); info.addAssign({ balance: ickbValue(depositAmount, txWithHeader.header) * depositQuantity, diff --git a/packages/dao/src/cells.test.ts b/packages/dao/src/cells.test.ts new file mode 100644 index 0000000..fc3c808 --- /dev/null +++ b/packages/dao/src/cells.test.ts @@ -0,0 +1,118 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { daoCellFrom } from "./cells.js"; + +function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + return `0x${hexByte.repeat(32)}`; +} + +function script(codeHashByte: string): ccc.Script { + return ccc.Script.from({ + codeHash: byte32FromByte(codeHashByte), + hashType: "type", + args: "0x", + }); +} + +function headerLike( + epoch: [bigint, bigint, bigint], + number: bigint, + timestamp = 0n, +): { + compactTarget: bigint; + dao: { + c: bigint; + ar: bigint; + s: bigint; + u: bigint; + }; + epoch: [bigint, bigint, bigint]; + extraHash: `0x${string}`; + hash: `0x${string}`; + nonce: bigint; + number: bigint; + parentHash: `0x${string}`; + proposalsHash: `0x${string}`; + timestamp: bigint; + transactionsRoot: `0x${string}`; + version: bigint; +} { + return { + compactTarget: 0n, + dao: { + c: 0n, + ar: 1000n, + s: 0n, + u: 0n, + }, + epoch, + extraHash: byte32FromByte("aa"), + hash: byte32FromByte("bb"), + nonce: 0n, + number, + parentHash: byte32FromByte("cc"), + proposalsHash: byte32FromByte("dd"), + timestamp, + transactionsRoot: byte32FromByte("ee"), + version: 0n, + }; +} + +function withdrawalCell(): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("11"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("22"), + type: script("33"), + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); +} + +function clientFor( + depositHeader: ccc.ClientBlockHeader, + withdrawHeader: ccc.ClientBlockHeader, +): ccc.Client { + return { + getHeaderByNumber: () => Promise.resolve(depositHeader), + getTransactionWithHeader: () => Promise.resolve({ header: withdrawHeader }), + } as unknown as ccc.Client; +} + +describe("daoCellFrom withdrawal readiness", () => { + it("marks withdrawal requests ready once the claim epoch is reached", async () => { + const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); + const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); + const tip = ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)); + const claimEpoch = ccc.calcDaoClaimEpoch(depositHeader, withdrawHeader); + + const daoCell = await daoCellFrom({ + cell: withdrawalCell(), + isDeposit: false, + client: clientFor(depositHeader, withdrawHeader), + tip, + }); + + expect(daoCell.maturity.eq(claimEpoch)).toBe(true); + expect(daoCell.isReady).toBe(true); + }); + + it("keeps withdrawal requests pending before the claim epoch", async () => { + const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); + const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); + const tip = ccc.ClientBlockHeader.from(headerLike([179n, 0n, 1n], 3n)); + + const daoCell = await daoCellFrom({ + cell: withdrawalCell(), + isDeposit: false, + client: clientFor(depositHeader, withdrawHeader), + tip, + }); + + expect(daoCell.isReady).toBe(false); + }); +}); diff --git a/packages/dao/src/cells.ts b/packages/dao/src/cells.ts index c3b5bcb..9a28148 100644 --- a/packages/dao/src/cells.ts +++ b/packages/dao/src/cells.ts @@ -26,7 +26,8 @@ export interface DaoCell extends ValueComponents { /** * Indicates the readiness to be consumed by a transaction. - * In case of deposit, it is false if the cycle renewal is less than minLockUp or more than maxLockUp away, + * In case of deposit, it is true only when the renewal stays strictly inside the configured window: + * `tip + minLockUp < maturity < tip + maxLockUp`. * while in case of withdrawal request, it indicates the readiness for withdrawal. */ isReady: boolean; @@ -42,7 +43,7 @@ export interface DaoCell extends ValueComponents { * * The options object also include: * - `tip`: The current tip block header. - * - `minLockUp`: An optional minimum lock-up period in epochs (Default 15 minutes) + * - `minLockUp`: An optional minimum lock-up period in epochs (Default 10 minutes) * - `maxLockUp`: An optional maximum lock-up period in epochs (Default 3 days) * * @returns A promise that resolves to a DaoCell. @@ -123,11 +124,12 @@ export async function daoCellFrom( const minLockUp = options.minLockUp ?? defaultMinLockUp; const maxLockUp = options.maxLockUp ?? defaultMaxLockUp; - // Deposit: maturity > minLockUp + tip.epoch - // WithdrawalRequest: maturity > tip.epoch + // Deposit: ready only within the current/next usable DAO window. + // The boundaries are exclusive so callers do not race an exact-edge inclusion. + // WithdrawalRequest: ready once the claim epoch has been reached. let isReady = isDeposit ? maturity.compare(minLockUp.add(tip.epoch)) > 0 - : maturity.compare(tip.epoch) > 0; + : maturity.compare(tip.epoch) <= 0; if (isDeposit) { // Deposit: maturity < tip.epoch + maxLockUp diff --git a/packages/dao/src/dao.test.ts b/packages/dao/src/dao.test.ts new file mode 100644 index 0000000..ec1ea0c --- /dev/null +++ b/packages/dao/src/dao.test.ts @@ -0,0 +1,177 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it, vi } from "vitest"; +import type { DaoCell } from "./cells.js"; +import { DaoManager } from "./dao.js"; + +function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + return `0x${hexByte.repeat(32)}`; +} + +function script(codeHashByte: string, args = "0x"): ccc.Script { + return ccc.Script.from({ + codeHash: byte32FromByte(codeHashByte), + hashType: "type", + args, + }); +} + +function headerLike(number: bigint): ccc.ClientBlockHeader { + return ccc.ClientBlockHeader.from({ + compactTarget: 0n, + dao: { + c: 0n, + ar: 1000n, + s: 0n, + u: 0n, + }, + epoch: [1n, 0n, 1n], + extraHash: byte32FromByte("aa"), + hash: byte32FromByte("bb"), + nonce: 0n, + number, + parentHash: byte32FromByte("cc"), + proposalsHash: byte32FromByte("dd"), + timestamp: 0n, + transactionsRoot: byte32FromByte("ee"), + version: 0n, + }); +} + +function headerWithHash(number: bigint, hashByte: string): ccc.ClientBlockHeader { + const header = headerLike(number); + return ccc.ClientBlockHeader.from({ + compactTarget: header.compactTarget, + dao: header.dao, + epoch: header.epoch, + extraHash: header.extraHash, + hash: byte32FromByte(hashByte), + nonce: header.nonce, + number: header.number, + parentHash: header.parentHash, + proposalsHash: header.proposalsHash, + timestamp: header.timestamp, + transactionsRoot: header.transactionsRoot, + version: header.version, + }); +} + +describe("DaoManager.requestWithdrawal", () => { + it("always rejects withdrawal locks with different args size", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const deposit: DaoCell = { + cell: ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("22"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("33", "0x1234"), + type: manager.script, + }, + outputData: DaoManager.depositData(), + }), + isDeposit: true, + headers: [{ header: depositHeader }, { header: depositHeader }], + interests: 0n, + maturity: ccc.Epoch.from([1n, 0n, 1n]), + isReady: true, + ckbValue: ccc.fixedPointFrom(100082), + udtValue: 0n, + }; + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [deposit], + script("44", "0x12"), + {} as ccc.Client, + ), + ).rejects.toThrow("Withdrawal request lock args has different size from deposit"); + }); +}); + +describe("DaoManager.withdraw", () => { + it("writes since, header deps, and witness inputType for withdrawals", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal: DaoCell = { + cell: ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("22"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("33", "0x1234"), + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }), + isDeposit: false, + headers: [{ header: depositHeader }, { header: withdrawHeader }], + interests: 0n, + maturity: ccc.Epoch.from([180n, 0n, 1n]), + isReady: true, + ckbValue: ccc.fixedPointFrom(100082), + udtValue: 0n, + }; + + const tx = await manager.withdraw( + ccc.Transaction.default(), + [withdrawal], + {} as ccc.Client, + ); + + expect(tx.headerDeps).toEqual([depositHeader.hash, withdrawHeader.hash]); + expect(tx.inputs).toHaveLength(1); + const since = tx.inputs[0]?.since; + if (since === undefined) { + throw new Error("Expected withdrawal input since"); + } + expect(ccc.Since.from(since).metric).toBe("epoch"); + expect(ccc.Since.from(since).value).toBe(withdrawal.maturity.toNum()); + expect(tx.getWitnessArgsAt(0)?.inputType).toBe( + ccc.hexFrom(ccc.numLeToBytes(0n, 8)), + ); + }); + + it("preserves an existing non-input witness by shifting it after the new input", async () => { + vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); + + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal: DaoCell = { + cell: ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("22"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("33", "0x1234"), + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }), + isDeposit: false, + headers: [{ header: depositHeader }, { header: withdrawHeader }], + interests: 0n, + maturity: ccc.Epoch.from([180n, 0n, 1n]), + isReady: true, + ckbValue: ccc.fixedPointFrom(100082), + udtValue: 0n, + }; + const tx = ccc.Transaction.default(); + const preservedWitness = ccc.WitnessArgs.from({ inputType: "0xab" }).toHex(); + tx.witnesses.push(preservedWitness); + + const updated = await manager.withdraw(tx, [withdrawal], {} as ccc.Client); + + expect(updated.getWitnessArgsAt(0)?.inputType).toBe( + ccc.hexFrom(ccc.numLeToBytes(0n, 8)), + ); + expect(updated.witnesses[1]).toBe(preservedWitness); + }); +}); diff --git a/packages/dao/src/dao.ts b/packages/dao/src/dao.ts index 7361caf..638053e 100644 --- a/packages/dao/src/dao.ts +++ b/packages/dao/src/dao.ts @@ -120,7 +120,6 @@ export class DaoManager implements ScriptDeps { * @param deposits - An array of deposits to request the withdrawal from. * @param lock - The lock script for the withdrawal request cells. * @param options - Optional parameters for the withdrawal request. - * @param options.sameSizeOnly - Whether to enforce the same size for lock args (default: true). * @param options.isReadyOnly - Whether to only process ready deposits (default: false). * @returns void * @throws Error if the transaction has different input and output lengths. @@ -133,12 +132,10 @@ export class DaoManager implements ScriptDeps { lock: ccc.Script, client: ccc.Client, options?: { - sameSizeOnly?: boolean; isReadyOnly?: boolean; }, ): Promise { const tx = ccc.Transaction.from(txLike); - const sameSizeOnly = options?.sameSizeOnly ?? true; const isReadyOnly = options?.isReadyOnly ?? false; if (isReadyOnly) { deposits = deposits.filter((d) => d.isReady); @@ -159,10 +156,7 @@ export class DaoManager implements ScriptDeps { if (!isDeposit) { throw new Error("Not a deposit"); } - if ( - sameSizeOnly && - cell.cellOutput.lock.args.length !== lock.args.length - ) { + if (cell.cellOutput.lock.args.length !== lock.args.length) { throw new Error( "Withdrawal request lock args has different size from deposit", ); diff --git a/packages/dao/src/deposit_readiness.test.ts b/packages/dao/src/deposit_readiness.test.ts new file mode 100644 index 0000000..850d2e1 --- /dev/null +++ b/packages/dao/src/deposit_readiness.test.ts @@ -0,0 +1,96 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { daoCellFrom } from "./cells.js"; +import { DaoManager } from "./dao.js"; + +function hash(byte: string): `0x${string}` { + return `0x${byte.repeat(32)}`; +} + +function script(byte: string): ccc.Script { + return ccc.Script.from({ + codeHash: hash(byte), + hashType: "type", + args: "0x", + }); +} + +function headerLike(epoch: [bigint, bigint, bigint], number: bigint): ccc.ClientBlockHeader { + return ccc.ClientBlockHeader.from({ + compactTarget: 0n, + dao: { + c: 0n, + ar: 1000n, + s: 0n, + u: 0n, + }, + epoch, + extraHash: hash("aa"), + hash: hash("bb"), + nonce: 0n, + number, + parentHash: hash("cc"), + proposalsHash: hash("dd"), + timestamp: 0n, + transactionsRoot: hash("ee"), + version: 0n, + }); +} + +function depositCell(lock: ccc.Script, dao: ccc.Script): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: hash("11"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: dao, + }, + outputData: DaoManager.depositData(), + }); +} + +function clientFor(depositHeader: ccc.ClientBlockHeader): ccc.Client { + return { + getTransactionWithHeader: () => Promise.resolve({ header: depositHeader }), + } as unknown as ccc.Client; +} + +describe("daoCellFrom deposit readiness boundaries", () => { + it("keeps deposits at the exact min boundary pending until the next tip", async () => { + const lock = script("22"); + const dao = script("33"); + const depositHeader = headerLike([1n, 0n, 1n], 1n); + const tip = headerLike([180n, 23n, 24n], 2n); + + const daoCell = await daoCellFrom({ + cell: depositCell(lock, dao), + isDeposit: true, + client: clientFor(depositHeader), + tip, + minLockUp: ccc.Epoch.from([0n, 1n, 24n]), + maxLockUp: ccc.Epoch.from([18n, 0n, 1n]), + }); + + expect(daoCell.isReady).toBe(false); + expect(daoCell.maturity.eq(ccc.calcDaoClaimEpoch(depositHeader, tip).add([180n, 0n, 1n]))).toBe(true); + }); + + it("keeps deposits at the exact max boundary out of the ready window", async () => { + const lock = script("22"); + const dao = script("33"); + const depositHeader = headerLike([1n, 0n, 1n], 1n); + const tip = headerLike([163n, 0n, 1n], 2n); + + const daoCell = await daoCellFrom({ + cell: depositCell(lock, dao), + isDeposit: true, + client: clientFor(depositHeader), + tip, + minLockUp: ccc.Epoch.from([0n, 1n, 24n]), + maxLockUp: ccc.Epoch.from([18n, 0n, 1n]), + }); + + expect(daoCell.maturity.eq(ccc.calcDaoClaimEpoch(depositHeader, tip))).toBe(true); + expect(daoCell.isReady).toBe(false); + }); +}); diff --git a/packages/order/src/cells.ts b/packages/order/src/cells.ts index 9b0dccc..7434a13 100644 --- a/packages/order/src/cells.ts +++ b/packages/order/src/cells.ts @@ -239,11 +239,17 @@ export class OrderCell implements ValueComponents { continue; } - // Pick order with best absProgress. At equality of absProgress, give preference to newly minted orders + // Directional orders rank by irreversible progress. Dual-sided orders + // rank by value because absProgress === absTotal for that shape. + // At equal progress, prefer newly minted orders. if ( !best || best.absProgress < descendant.absProgress || - (best.absProgress === descendant.absProgress && !best.data.isMint()) + ( + best.absProgress === descendant.absProgress && + descendant.data.isMint() && + !best.data.isMint() + ) ) { best = descendant; } diff --git a/packages/order/src/order.test.ts b/packages/order/src/order.test.ts index ef266d6..6ed589f 100644 --- a/packages/order/src/order.test.ts +++ b/packages/order/src/order.test.ts @@ -55,14 +55,121 @@ describe("OrderMatcher", () => { }); }); +describe("OrderCell.resolve", () => { + it("prefers directional progress over a higher-value unprogressed candidate", () => { + const master = { + txHash: byte32FromByte("55"), + index: 10n, + }; + const info = directionalInfo(); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + }); + const progressed = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(50), + udtValue: ccc.fixedPointFrom(50), + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("66"), index: 0n }, + }); + const forged = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(200), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("77"), index: 0n }, + }); + + expect(progressed.absProgress).toBeGreaterThan(forged.absProgress); + expect(forged.absTotal).toBeGreaterThan(progressed.absTotal); + expect(origin.resolve([forged, progressed])).toBe(progressed); + }); + + it("uses best value for dual-sided orders via absProgress === absTotal", () => { + const master = { + txHash: byte32FromByte("88"), + index: 10n, + }; + const info = dualInfo(); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + }); + const lowerValue = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("99"), index: 0n }, + }); + const higherValue = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(60), + udtValue: ccc.fixedPointFrom(60), + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("aa"), index: 0n }, + }); + + expect(lowerValue.absProgress).toBe(lowerValue.absTotal); + expect(higherValue.absProgress).toBe(higherValue.absTotal); + expect(higherValue.absTotal).toBeGreaterThan(lowerValue.absTotal); + expect(origin.resolve([lowerValue, higherValue])).toBe(higherValue); + }); + + it("does not replace equal-progress non-mint candidates by array order", () => { + const master = { + txHash: byte32FromByte("bb"), + index: 10n, + }; + const info = directionalInfo(); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + }); + const nonMint = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("cc"), index: 0n }, + }); + const otherNonMint = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { + type: "absolute", + value: { + txHash: master.txHash, + index: master.index, + }, + }, + outPoint: { txHash: byte32FromByte("dd"), index: 0n }, + }); + + expect(origin.resolve([nonMint, otherNonMint])).toBe(nonMint); + expect(origin.resolve([otherNonMint, nonMint])).toBe(otherNonMint); + }); +}); + function makeUdtToCkbOrder(): OrderCell { const orderScript = ccc.Script.from({ - codeHash: hash("11"), + codeHash: byte32FromByte("11"), hashType: "type", args: "0x", }); const udtScript = ccc.Script.from({ - codeHash: hash("22"), + codeHash: byte32FromByte("22"), hashType: "type", args: "0x", }); @@ -70,7 +177,7 @@ function makeUdtToCkbOrder(): OrderCell { return OrderCell.mustFrom( ccc.Cell.from({ outPoint: { - txHash: hash("44"), + txHash: byte32FromByte("44"), index: 0n, }, cellOutput: { @@ -83,7 +190,7 @@ function makeUdtToCkbOrder(): OrderCell { master: { type: "absolute", value: { - txHash: hash("33"), + txHash: byte32FromByte("33"), index: 1n, }, }, @@ -100,6 +207,88 @@ function makeUdtToCkbOrder(): OrderCell { ); } -function hash(byte: string): `0x${string}` { - return `0x${byte.repeat(32)}`; +function directionalInfo(): Info { + return Info.from({ + ckbToUdt: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + udtToCkb: Ratio.empty(), + ckbMinMatchLog: 0, + }); +} + +function dualInfo(): Info { + const ratio = Ratio.from({ ckbScale: 1n, udtScale: 1n }); + return Info.from({ + ckbToUdt: ratio, + udtToCkb: ratio, + ckbMinMatchLog: 0, + }); +} + +function makeOrderCell(options: { + ckbUnoccupied: ccc.FixedPoint; + udtValue: ccc.FixedPoint; + info: Info; + master: { + type: "relative"; + value: { + padding: Uint8Array; + distance: bigint; + }; + } | { + type: "absolute"; + value: { + txHash: `0x${string}`; + index: bigint; + }; + }; + outPoint: { + txHash: `0x${string}`; + index: bigint; + }; +}): OrderCell { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const outputData = OrderData.from({ + udtValue: options.udtValue, + master: options.master, + info: options.info, + }).toBytes(); + const minimalCell = ccc.Cell.from({ + previousOutput: { + txHash: byte32FromByte("ff"), + index: 0n, + }, + cellOutput: { + lock: orderScript, + type: udtScript, + }, + outputData, + }); + + return OrderCell.mustFrom( + ccc.Cell.from({ + outPoint: options.outPoint, + cellOutput: { + capacity: minimalCell.cellOutput.capacity + options.ckbUnoccupied, + lock: orderScript, + type: udtScript, + }, + outputData, + }), + ); +} + +function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + return `0x${hexByte.repeat(32)}`; } diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5a2c157..8f5a0b5 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -26,6 +26,14 @@ graph TD; click F "https://github.com/ickb/stack/tree/master/packages/sdk" "Go to @ickb/sdk" ``` +## Pool Maturity Estimates + +`@ickb/sdk` owns the stack-level summary that interface consumers use to estimate iCKB-to-CKB timing. + +The current runtime path uses direct deposit scans together with bot liquidity and withdrawal-request state. The older pool snapshot idea is archived because its old format was not safely self-identifying. + +See [docs/pool_maturity_estimates.md](./docs/pool_maturity_estimates.md). + ## Epoch Semantic Versioning This repository follows [Epoch Semantic Versioning](https://antfu.me/posts/epoch-semver). In short ESV aims to provide a more nuanced and effective way to communicate software changes, allowing for better user understanding and smoother upgrades. diff --git a/packages/sdk/docs/pool_maturity_estimates.md b/packages/sdk/docs/pool_maturity_estimates.md new file mode 100644 index 0000000..f290116 --- /dev/null +++ b/packages/sdk/docs/pool_maturity_estimates.md @@ -0,0 +1,53 @@ +# Pool Maturity Estimates + +This note describes the current stack-owned contract for estimating iCKB-to-CKB conversion timing in UI consumers. + +## Scope + +This is an off-chain stack mechanism, not protocol law. + +- `apps/bot` owns bot liquidity and withdrawal-request production. +- `@ickb/sdk` owns the summary that interface consumers read as `system.ckbAvailable` and `system.ckbMaturing`. +- `apps/interface` renders that summary into conversion-time estimates. + +## Current Runtime Path + +The current SDK estimate path does **not** use a bot-written pool snapshot. + +This direct-scan path assumes the deposit pool is still small enough that interface-side maturity estimates can afford a live scan when needed. + +Instead, `packages/sdk/src/sdk.ts` builds the estimate from: + +- bot plain-capacity cells +- bot-owned ready and pending withdrawal requests +- direct scans of pool deposits via `LogicManager.findDeposits(...)` + +Ready deposits are counted as immediately available CKB. +Not-ready deposits remain in the future maturity buckets. + +## Why The Older Snapshot Path Was Removed + +The older snapshot idea tried to summarize the full deposit pool without scanning every deposit. + +That design was removed from the live runtime because the old format had no explicit discriminator. In practice, arbitrary aligned bot-owned no-type data could be mistaken for a snapshot. For UI estimation, approximation is acceptable, but misidentifying unrelated bytes as an estimate source is not. + +So the current stack chooses the smaller honest contract: + +- direct deposit scans are slower at large pool sizes +- but the data source is unambiguous + +An archived copy of the older codec is kept at `packages/sdk/docs/pool_snapshot_codec.ts` as future implementation reference only. + +## What A Future Snapshot Implementation Would Need + +If deposit-pool growth makes direct scans too expensive for UI use, a snapshot design can still make sense. But it must be a real stack-owned format, not just a byte-length heuristic. + +A future revival should define: + +1. an explicit format identity, such as a versioned prefix or a dedicated cell shape +2. a clear writer, likely the bot +3. a clear reader, `@ickb/sdk` +4. freshness and fallback rules +5. exact behavior when the snapshot is missing, stale, malformed, or partial + +Until then, direct deposit scanning remains the active runtime contract. diff --git a/packages/sdk/docs/pool_snapshot_codec.ts b/packages/sdk/docs/pool_snapshot_codec.ts new file mode 100644 index 0000000..2c6b7e0 --- /dev/null +++ b/packages/sdk/docs/pool_snapshot_codec.ts @@ -0,0 +1,81 @@ +import { ccc } from "@ckb-ccc/core"; + +const N = 1024; + +/** + * Archived reference implementation of the older pool snapshot codec. + * + * This file is intentionally kept out of the live `@ickb/sdk` runtime surface. + * It exists as implementation backlog material for future snapshot work. + * The current stack runtime still uses direct deposit scans because this older + * shapeless format had no explicit discriminator. + */ +export const PoolSnapshot = ccc.Codec.from({ + encode: (bins) => { + if (bins.length !== N) { + throw new Error("Expected 1024 bins"); + } + + const bitsPerBin = computeBits(bins); + const buffer = new Uint8Array((bitsPerBin * N) >> 3); + + let bitOffset = 0; + for (const count of bins) { + packBits(buffer, bitOffset, bitsPerBin, count); + bitOffset += bitsPerBin; + } + return buffer; + }, + + decode: (bufferLike) => { + const buffer = ccc.bytesFrom(bufferLike); + const bitsPerBin = (buffer.byteLength * 8) / N; + if (!Number.isInteger(bitsPerBin)) { + throw new Error("Invalid buffer length for 1024 bins"); + } + const bins = new Array(N); + let bitOffset = 0; + for (let i = 0; i < N; i++) { + bins[i] = unpackBits(buffer, bitOffset, bitsPerBin); + bitOffset += bitsPerBin; + } + return bins; + }, +}); + +function computeBits(bins: number[]): number { + return Math.ceil(Math.log2(1 + Math.max(1, ...bins))); +} + +function packBits( + buffer: Uint8Array, + bitOffset: number, + width: number, + value: number, +): void { + let offset = bitOffset; + for (let i = 0; i < width; i++) { + const byteIndex = offset >> 3; + const bitInByte = offset % 8; + const bit = (value >> i) & 1; + buffer[byteIndex]! |= bit << bitInByte; + offset++; + } +} + +function unpackBits( + buffer: Uint8Array, + bitOffset: number, + width: number, +): number { + let value = 0; + let offset = bitOffset; + for (let i = 0; i < width; i++) { + const byteIndex = offset >> 3; + const bitInByte = offset % 8; + const bit = (buffer[byteIndex]! >> bitInByte) & 1; + value |= bit << i; + offset++; + } + return value; +} diff --git a/packages/sdk/src/codec.ts b/packages/sdk/src/codec.ts deleted file mode 100644 index f2b54db..0000000 --- a/packages/sdk/src/codec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; - -const N = 1024; - -/** - * Codec for encoding and decoding a pool snapshot. - * - * The PoolSnapshotCodec encodes an array of 1024 numbers representing event counts for bins, - * where the number of bits used per bin is dynamically computed as the ceiling of log2(max + 1) - * across all bins. The codec provides methods to encode the array into a Uint8Array and decode - * a Uint8Array back to the original array of numbers. - * - * @remarks The total number of bits for encoding all the bins is computed as bitsPerBin * 1024. The codec - * does not have a fixed byteLength since it depends on the data. - */ -export const PoolSnapshot = ccc.Codec.from({ - /** - * Encodes an array of 1024 bin counts into a Uint8Array. - * - * @param bins - An array of 1024 numbers. Each number represents the event count for a bin. - * @returns A Uint8Array containing the packed bit representation of the bin counts. - * @throws Error if the input array does not contain exactly 1024 elements. - */ - encode: (bins) => { - if (bins.length !== N) { - throw new Error("Expected 1024 bins"); - } - - const bitsPerBin = computeBits(bins); - const buffer = new Uint8Array((bitsPerBin * N) >> 3); - - let bitOffset = 0; - for (const count of bins) { - packBits(buffer, bitOffset, bitsPerBin, count); - bitOffset += bitsPerBin; - } - return buffer; - }, - - /** - * Decodes a buffer into an array of 1024 bin counts. - * - * The function automatically computes the number of bits per bin from the buffer length, - * expecting that the total number of bits in the buffer is a multiple of 1024. - * - * @param bufferLike - The input that can be converted into a Uint8Array. - * @returns An array of 1024 numbers representing the decoded bin counts. - * @throws Error if the buffer length is invalid (i.e., its total bit count is not divisible by 1024). - */ - decode: (bufferLike) => { - const buffer = ccc.bytesFrom(bufferLike); - // Determine bitsPerBin from the fixed structure (totalBits divided by N). - const bitsPerBin = (buffer.byteLength * 8) / N; - if (!Number.isInteger(bitsPerBin)) { - throw new Error("Invalid buffer length for 1024 bins"); - } - const bins = new Array(N); - let bitOffset = 0; - for (let i = 0; i < N; i++) { - bins[i] = unpackBits(buffer, bitOffset, bitsPerBin); - bitOffset += bitsPerBin; - } - return bins; - }, - // Note: The byteLength is not fixed as it depends on the data (i.e., the maximum bin count). -}); - -/** - * Computes the minimal number of bits required to represent the maximum number in the bins. - * - * Given an array of numbers that represent counts for each bin, this function calculates - * the number of bits required to represent the maximum bin count. A minimum of 1 bit is - * always returned. - * - * @param bins - An array of numbers, each representing a bin's value. - * @returns The minimal number of bits required to represent the highest value among the bins. - */ -function computeBits(bins: number[]): number { - return Math.ceil(Math.log2(1 + Math.max(1, ...bins))); -} - -/** - * Packs a given numeric value into a Uint8Array (bit array) at the specified bit offset and width. - * - * The function encodes the given number into the provided buffer, using the specified number of bits (width) - * starting from the specified bit offset. Each bit of the value is written into the buffer. - * - * @param buffer - The Uint8Array into which the value will be packed. - * @param bitOffset - The starting bit index in the array where the value should be packed. - * @param width - The number of bits to use for packing the value. - * @param value - The numeric value to pack. - */ -function packBits( - buffer: Uint8Array, - bitOffset: number, - width: number, - value: number, -): void { - let offset = bitOffset; - for (let i = 0; i < width; i++) { - const byteIndex = offset >> 3; - const bitInByte = offset % 8; - const bit = (value >> i) & 1; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - buffer[byteIndex]! |= bit << bitInByte; - offset++; - } -} - -/** - * Unpacks a numeric value from a Uint8Array (bit array) starting at a given bit offset with a specified width. - * - * The function reads bits from the buffer starting at the bit offset and reconstructs a numeric value - * using the specified number of bits (width). - * - * @param buffer - The Uint8Array from which the value will be unpacked. - * @param bitOffset - The starting bit index in the buffer. - * @param width - The number of bits to read for unpacking the value. - * @returns The numeric value resulting from decoding the specified bits. - */ -function unpackBits( - buffer: Uint8Array, - bitOffset: number, - width: number, -): number { - let value = 0; - let offset = bitOffset; - for (let i = 0; i < width; i++) { - const byteIndex = offset >> 3; - const bitInByte = offset % 8; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const bit = (buffer[byteIndex]! >> bitInByte) & 1; - value |= bit << i; - offset++; - } - return value; -} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 56dc884..3fe29f8 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,3 +1,2 @@ export * from "./sdk.js"; -export * from "./codec.js"; export * from "./constants.js"; diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 3c5959a..fd1824f 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -1,10 +1,61 @@ import { ccc } from "@ckb-ccc/core"; import { Info, Ratio } from "@ickb/order"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { DaoManager } from "@ickb/dao"; +import { + type IckbDepositCell, + LogicManager, + OwnedOwnerManager, +} from "@ickb/core"; +import { OrderManager } from "@ickb/order"; import { IckbSdk, type SystemState } from "./sdk.js"; const ratio = Ratio.from({ ckbScale: 1n, udtScale: 1n }); -const tip = { timestamp: 0n } as ccc.ClientBlockHeader; + +function headerLike( + number: bigint, + overrides: Partial = {}, +): ccc.ClientBlockHeader { + return ccc.ClientBlockHeader.from({ + compactTarget: 0n, + dao: { c: 0n, ar: 1000n, s: 0n, u: 0n }, + epoch: [1n, 0n, 1n], + extraHash: hash("aa"), + hash: hash("bb"), + nonce: 0n, + number, + parentHash: hash("cc"), + proposalsHash: hash("dd"), + timestamp: 0n, + transactionsRoot: hash("ee"), + version: 0n, + ...overrides, + }); +} + +const tip = headerLike(0n); + +function hash(byte: string): `0x${string}` { + return `0x${byte.repeat(32)}`; +} + +function script(byte: string): ccc.Script { + return ccc.Script.from({ + codeHash: hash(byte), + hashType: "type", + args: "0x", + }); +} + +async function* once(value: T): AsyncGenerator { + yield value; + await Promise.resolve(); +} + +async function* none(): AsyncGenerator { + await Promise.resolve(); + yield* [] as T[]; +} function system(overrides: Partial = {}): SystemState { return { @@ -35,13 +86,14 @@ describe("IckbSdk.estimate", () => { expect(result.maturity).toBeUndefined(); }); - it("includes maturity once the fee threshold is met", () => { - vi.spyOn(Date, "now").mockReturnValue(1234); - + it("uses the chain tip timestamp for preview maturity", () => { const result = IckbSdk.estimate( false, { ckbValue: 0n, udtValue: 1000000n }, - system({ ckbAvailable: 1000000n }), + system({ + ckbAvailable: 1000000n, + tip: headerLike(0n, { timestamp: 1234n }), + }), ); expect(result.convertedAmount).toBe(999990n); @@ -75,15 +127,16 @@ describe("IckbSdk.maturity", () => { }); it("returns the baseline maturity when enough CKB is already available", () => { - vi.spyOn(Date, "now").mockReturnValue(1234); - expect( IckbSdk.maturity( { info: Info.create(false, ratio), amounts: { ckbValue: 0n, udtValue: 100n }, }, - system({ ckbAvailable: 100n }), + system({ + ckbAvailable: 100n, + tip: headerLike(0n, { timestamp: 1234n }), + }), ), ).toBe(601234n); }); @@ -106,3 +159,120 @@ describe("IckbSdk.maturity", () => { ).toBe(2000n); }); }); + +describe("IckbSdk.getL1State snapshot detection", () => { + 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( + 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( + 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([]); + }); +}); diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 061fa3e..9591a32 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -2,12 +2,12 @@ import { ccc } from "@ckb-ccc/core"; import { collect, binarySearch, + isPlainCapacityCell, unique, type ValueComponents, } from "@ickb/utils"; import { convert, - ICKB_DEPOSIT_CAP, ickbExchangeRatio, type LogicManager, type OwnedOwnerManager, @@ -20,7 +20,6 @@ import { type OrderGroup, } from "@ickb/order"; import { getConfig } from "./constants.js"; -import { PoolSnapshot } from "./codec.js"; /** * SDK for managing iCKB operations. @@ -80,7 +79,8 @@ export class IckbSdk { * - convertedAmount: The estimated converted amount as a FixedPoint. * - ckbFee: The fee (or gain) in CKB, as a FixedPoint. * - info: Additional conversion metadata. - * - maturity: Optional maturity information (as ccc.Num) if meets criteria. + * - maturity: Optional maturity information when the preview clears the + * minimum match and fee threshold used for interface-sized orders. */ static estimate( isCkb2Udt: boolean, @@ -109,7 +109,8 @@ export class IckbSdk { options, ); - // If the fee meets a threshold, calculate the order maturity; otherwise, maturity is undefined. + // Only previews that clear the minimum match and fee threshold get a + // maturity estimate. Smaller previews still return convertedAmount/info. const maturity = ckbFee >= 10n * system.feeRate ? IckbSdk.maturity({ info, amounts }, system) @@ -126,7 +127,8 @@ export class IckbSdk { * * @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) or undefined if not applicable. + * @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: @@ -187,13 +189,13 @@ export class IckbSdk { if (ckb > 0n) { maturity *= 1n + ckb / ccc.fixedPointFrom("200000"); } - return maturity + ("info" in o ? BigInt(Date.now()) : tip.timestamp); + return maturity + tip.timestamp; } // For UDT to CKB orders, add available CKB. ckb += ckbAvailable; if (ckb >= 0n) { - return maturity + ("info" in o ? BigInt(Date.now()) : tip.timestamp); + return maturity + tip.timestamp; } // Find the earliest maturity in the ckbMaturing array that satisfies the required CKB. @@ -276,7 +278,7 @@ export class IckbSdk { * * The method performs the following: * - Obtains the current block tip and calculates the exchange ratio. - * - Fetches available CKB and the maturing CKB based on bot capacities and deposit snapshots. + * - Fetches available CKB and the maturing CKB based on bot capacities and direct deposit scans. * - Filters orders into user-owned and system orders based on the provided locks. * - Estimates user-owned orders maturity. * @@ -344,10 +346,10 @@ export class IckbSdk { * Retrieves available CKB and maturing CKB values from the blockchain. * * This method: - * - Fetches bot withdrawal requests and deposit snapshots. + * - 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) - * based on deposit pool snapshots or via direct deposit cell lookups. + * via direct deposit cell lookups. * - Sorts and cumulatively sums the maturing values for later lookup. * * @param client - The blockchain client used for fetching data. @@ -374,9 +376,6 @@ export class IckbSdk { this.ownedOwner.findWithdrawalGroups(client, this.bots, opts), ); - // Initialize deposit pool snapshot. - let poolSnapshotHex: ccc.Hex = "0x"; - let poolSnapshotEpoch = ccc.Epoch.from([0n, 0n, 1n]); // Map to track each bot's available CKB (minus a reserved amount for internal operations). const bot2Ckb = new Map(); const reserved = -ccc.fixedPointFrom("2000"); @@ -394,32 +393,15 @@ export class IckbSdk { "asc", 400, )) { - if ( - cell.cellOutput.type !== undefined || - !cell.cellOutput.lock.eq(lock) - ) { + if (cell.cellOutput.type !== undefined || !cell.cellOutput.lock.eq(lock)) { continue; } const key = cell.cellOutput.lock.toHex(); - const ckb = - (bot2Ckb.get(key) ?? reserved) + cell.cellOutput.capacity; - bot2Ckb.set(key, ckb); - - // Find the most recent deposit pool snapshot from bot cell output data. - const outputData = cell.outputData; - if (outputData.length % 256 === 2) { - const txWithHeader = await client.getTransactionWithHeader( - cell.outPoint.txHash, - ); - if (!txWithHeader?.header) { - throw new Error("Header not found for txHash"); - } - const e = ccc.Epoch.from(txWithHeader.header.epoch); - if (poolSnapshotEpoch.compare(e) < 0) { - poolSnapshotHex = outputData; - poolSnapshotEpoch = e; - } + if (isPlainCapacityCell(cell)) { + const ckb = + (bot2Ckb.get(key) ?? reserved) + cell.cellOutput.capacity; + bot2Ckb.set(key, ckb); } } } @@ -452,39 +434,19 @@ export class IckbSdk { } } - // Estimate available CKB from deposit pool snapshot. - const tipEpoch = ccc.Epoch.from(tip.epoch); - const oneCycle = ccc.Epoch.from([180n, 0n, 1n]); - if (poolSnapshotHex !== "0x") { - const eNumber = tip.epoch.integer; - let start = ccc.Epoch.from([eNumber - (eNumber % 180n), 0n, 1n]); - const step = ccc.Epoch.from([0n, 180n, 1024n]); - const depositSize = convert(false, ICKB_DEPOSIT_CAP, tip); - for (const binAmount of PoolSnapshot.decode(poolSnapshotHex)) { - const end = start.add(step); - - if (binAmount > 0) { - ckbMaturing.push({ - ckbValue: BigInt(binAmount) * depositSize, - maturity: - start.compare(tipEpoch) < 0 - ? // If the bin has already started, assume worst-case timing. - end.add(oneCycle).toUnix(tip) - : // Otherwise, use the bin end as the maturity. - end.toUnix(tip), - }); - } - - start = end; - } - } else { - // Without snapshot data, fetch deposits directly. - for await (const d of this.ickbLogic.findDeposits(client, opts)) { - ckbMaturing.push({ - ckbValue: d.ckbValue, - maturity: d.maturity.toUnix(tip), - }); + // 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 await (const d of this.ickbLogic.findDeposits(client, opts)) { + 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. diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index 00193f9..7c24027 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -77,6 +77,13 @@ export interface ScriptDeps { cellDeps: ccc.CellDep[]; } +/** + * True when a cell is plain spendable CKB capacity with no type script and no data payload. + */ +export function isPlainCapacityCell(cell: ccc.Cell): boolean { + return cell.cellOutput.type === undefined && cell.outputData === "0x"; +} + /** * Shuffles in-place an array using the Durstenfeld shuffle algorithm. * @link https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a40a9a..41bff6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,9 @@ importers: '@ickb/sdk': specifier: workspace:* version: link:../../packages/sdk + '@ickb/utils': + specifier: workspace:* + version: link:../../packages/utils devDependencies: '@types/node': specifier: 'catalog:' @@ -157,6 +160,9 @@ importers: '@ickb/sdk': specifier: workspace:* version: link:../../packages/sdk + '@ickb/utils': + specifier: workspace:* + version: link:../../packages/utils devDependencies: '@types/node': specifier: 'catalog:' diff --git a/tsconfig.json b/tsconfig.json index 4366330..962f226 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,5 @@ "declarationMap": true, "sourceMap": true }, - "include": ["packages/**/*"] + "include": ["packages/**/*", "vitest.config.mts"] } diff --git a/vitest.config.mts b/vitest.config.mts index e3610c4..5c394c3 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,9 +3,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { passWithNoTests: true, - projects: ["packages/*"], + projects: ["packages/*", "apps/bot", "apps/interface", "apps/sampler", "apps/tester"], coverage: { - include: ["packages/*"], + include: ["packages/*", "apps/bot", "apps/interface", "apps/sampler", "apps/tester"], }, }, });