Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/bot/src/policy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ccc } from "@ckb-ccc/core";
import { ICKB_DEPOSIT_CAP, type IckbDepositCell } from "@ickb/core";
import {
selectReadyWithdrawalCleanupDeposit,
selectReadyWithdrawalDeposits,
} from "@ickb/sdk";
selectReadyWithdrawalCleanupDeposit,
} from "./withdrawal_selection.ts";
import { compareBigInt } from "@ickb/utils";

export const CKB = ccc.fixedPointFrom(1);
Expand Down
15 changes: 9 additions & 6 deletions apps/interface/src/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ function script(codeHashByte: string): ccc.Script {
});
}

function cell(capacity: bigint, lock: ccc.Script): ccc.Cell {
function cell(capacity: bigint, lock: ccc.Script, outputData = "0x"): ccc.Cell {
return ccc.Cell.from({
outPoint: { txHash: byte32FromByte("aa"), index: 0n },
cellOutput: { capacity, lock },
outputData: "0x",
outputData,
});
}

Expand Down Expand Up @@ -78,6 +78,9 @@ describe("getL1State", () => {
const lock = script("11");
const tip = { timestamp: 10n } as ccc.ClientBlockHeader;
const nativeCapacity = ccc.fixedPointFrom(50);
const capacityCell = cell(nativeCapacity, lock);
const nativeUdtCell = cell(7n, lock, ccc.hexFrom(ccc.numLeToBytes(11n, 16)));
nativeUdtCell.outPoint.txHash = byte32FromByte("bb");
const receipt = { ckbValue: 13n, udtValue: 17n };
const readyWithdrawal = {
ckbValue: 19n,
Expand Down Expand Up @@ -116,8 +119,8 @@ describe("getL1State", () => {
},
user: { orders: [availableOrder, pendingOrder] },
account: {
capacityCells: [cell(nativeCapacity, lock)],
nativeUdtCells: [],
capacityCells: [capacityCell],
nativeUdtCells: [nativeUdtCell],
nativeUdtCapacity: 7n,
nativeUdtBalance: 11n,
receipts: [receipt],
Expand Down Expand Up @@ -145,8 +148,8 @@ describe("getL1State", () => {
"ratio=1/1",
"pool=0;;;deposits=",
`balances=${String(nativeCapacity + 142n)}/248`,
`capacityCells=${cell(nativeCapacity, lock).outPoint.toHex()}`,
"nativeUdtCells=",
`capacityCells=${capacityCell.outPoint.toHex()}`,
`nativeUdtCells=${nativeUdtCell.outPoint.toHex()}`,
"maturity=60",
"receipts=13/17@missing-outpoint",
"readyWithdrawals=19/0@missing-outpoint@missing-outpoint",
Expand Down
4 changes: 3 additions & 1 deletion apps/tester/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ describe("readTesterState", () => {
it("includes receipts and ready withdrawals in the actionable state", async () => {
const plainLock = script("11");
const plainCell = cell(5n, plainLock);
const nativeUdtCell = cell(7n, plainLock, ccc.hexFrom(ccc.numLeToBytes(11n, 16)));
nativeUdtCell.outPoint.txHash = byte32FromByte("bb");
const userOrder = {
ckbValue: 23n,
udtValue: 29n,
Expand All @@ -93,7 +95,7 @@ describe("readTesterState", () => {
};
const account = {
capacityCells: [plainCell],
nativeUdtCells: [],
nativeUdtCells: [nativeUdtCell],
nativeUdtCapacity: 7n,
nativeUdtBalance: 11n,
receipts: [receipt],
Expand Down
6 changes: 4 additions & 2 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ See [docs/pool_maturity_estimates.md](./docs/pool_maturity_estimates.md).

## Ready Withdrawal Selection

`selectReadyWithdrawalDeposits(...)` exposes the stack's pool-friendly ready-deposit selector for direct iCKB-to-CKB withdrawal requests. Callers provide ready deposits, an optional near-ready refill window, the current tip, and amount/count limits. Setting `minCount` and `maxCount` to the same value requests an exact number of deposits. `preserveSingletons` defaults to `true`, so singleton bucket anchors are protected unless the caller explicitly permits selecting them. The selector prefers crowded-bucket extras before singleton anchors, returns the chosen deposits, and also returns `requiredLiveDeposits` for protected anchors that should be added as live `cell_dep` checks when building the withdrawal request.
`selectReadyWithdrawalDeposits(...)` exposes the stack's ready-deposit selector for direct iCKB-to-CKB withdrawal requests. Callers provide ready deposits, the current tip, amount/count limits, and optional ring filters. Setting `minCount` and `maxCount` to the same value requests an exact number of deposits. The selector compares bounded best-fit and greedy candidates, returns the chosen deposits, and also returns `requiredLiveDeposits` supplied by the caller for live `cell_dep` checks when building the withdrawal request.

`selectReadyWithdrawalCleanupDeposit(...)` is the narrow cleanup helper used by the bot for over-standard crowded-bucket extras. It returns at most one extra plus the protected anchor that must remain live. Bot thresholds such as target balances, singleton unlock policy, and whether a cleanup is worth doing remain app policy in `apps/bot`.
Ring helpers such as `ringSurplusDepositFilter(...)` and `ringRequiredLiveDepositFor(...)` operate on the full live pool snapshot. Normal bot and interface direct withdrawals use ring surplus only; bot reserve recovery is app policy and may relax that rule after surplus recovery fails.

`IckbSdk.buildBaseTransaction(...)` accepts `withdrawalRequest.requiredLiveDeposits` and adds those cells as live cell deps. This is an inclusion-time liveness check for public pool anchors, not a reservation of those cells after the transaction commits.

Expand All @@ -56,6 +56,8 @@ The returned transaction is not completed, signed, sent, or confirmed. Callers s

`IckbSdk.estimate(...)` returns order `info` even when the normal fee threshold is too small to produce a maturity estimate. Callers that intentionally build tiny iCKB-to-CKB orders can pass an explicit fee/feeBase discount to `estimate(...)`; the resulting order uses the existing order wire format. The limit-order contract can fully complete an order whose remaining match is below the configured minimum, so tiny dust orders do not need a special minimum-match encoding. This is how the interface presents small-balance conversions that may be worthwhile for recovering locked xUDT cell capacity.

SDK estimates use `OrderManager.convert(...)` as the quote boundary. The displayed `convertedAmount` and returned order `info` are paired: the `info` preserves the rounded-up full-fill quote for the same amounts. If that quote cannot be represented in the order script's Uint64 ratio fields, SDK planners treat the order as too small or unbuildable rather than producing weaker terms.

## Send Confirmation

`sendAndWaitForCommit(...)` returns the transaction hash after commit. If a transaction was broadcast but later reaches a terminal non-committed status or times out while still pending, it throws `TransactionConfirmationError` with the broadcast `txHash`, last observed `status`, and `isTimeout` flag. Callers that need to log the hash immediately after broadcast can use the `onSent` callback. Callers that need structured lifecycle evidence can use `onLifecycle`, which emits `pre_broadcast_failed`, `broadcasted`, `committed`, `timeout_after_broadcast`, `post_broadcast_unresolved`, and `terminal_rejection` events without changing the returned hash or thrown transaction error contract.
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/api-extractor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../api-extractor.base.json",
"mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts"
}
25 changes: 16 additions & 9 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,36 +13,43 @@
"homepage": "https://ickb.org",
"repository": {
"type": "git",
"url": "https://github.com/ickb/stack"
"url": "git+https://github.com/ickb/stack.git"
},
"bugs": {
"url": "https://github.com/ickb/stack/issues"
},
"sideEffects": false,
"type": "module",
"main": "dist/index.js",
"engines": {
"node": ">=22.19.0"
},
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"api-extractor": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"types": "./src/index.ts",
"import": "./dist/index.js"
"import": "./src/index.ts"
}
},
"scripts": {
"test": "vitest",
"test:ci": "vitest run",
"build": "tsc",
"lint": "eslint ./src",
"clean": "rm -fr dist",
"clean:deep": "rm -fr dist node_modules"
"build": "rm -fr dist && tsgo -p tsconfig.build.json && node ../../scripts/tooling/build/rewrite-dts-imports.ts dist",
"lint:api": "api-extractor run --local",
"lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/sdk/src packages/sdk/test",
"clean": "rm -fr dist"
},
"files": [
"dist",
"src"
"dist"
],
"publishConfig": {
"access": "public",
"provenance": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
Expand Down
194 changes: 194 additions & 0 deletions packages/sdk/src/client/sdk_base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { ccc } from "@ckb-ccc/core";
import type { IckbDepositCell } from "@ickb/core";
import { assertDaoOutputLimit } from "@ickb/dao";
import type { Info, OrderGroup } from "@ickb/order";
import type { ValueComponents } from "@ickb/utils";
import { isChangeCellCapacityError } from "../conversion/sdk_conversion_common.ts";
import { assertReadyWithdrawalDeposits } from "../withdrawal/withdrawal_selection.ts";
import { sdkManagers } from "./sdk_state_store.ts";
import type {
BuildBaseTransactionOptions,
CompleteIckbTransactionOptions,
} from "./sdk_types.ts";

/**
* Base SDK transaction helpers shared by conversion and L1 APIs.
*
* @public
*/
export class IckbSdkBase {
protected constructor() {}

/**
* Completes iCKB/xUDT inputs and transaction fees for a partial transaction.
*
* @remarks
* This does not sign or send the transaction. It retries fee completion once
* when CCC needs to place fee change into an existing iCKB-owned output.
*/
public async completeTransaction(
txLike: ccc.TransactionLike,
options: CompleteIckbTransactionOptions,
): Promise<ccc.Transaction> {
const { ickbUdt } = sdkManagers(this);
const tx = await ickbUdt.completeBy(txLike, options.signer);
try {
await tx.completeFeeBy(options.signer, options.feeRate);
} catch (error) {
if (!isChangeCellCapacityError(error)) {
throw error;
}

const retryTx = await ickbUdt.completeBy(txLike, options.signer);
const feeChangeOutputIndex = await this.findFeeChangeOutputIndex(
retryTx,
options.signer,
);
if (feeChangeOutputIndex === undefined) {
throw error;
}
await retryTx.completeFeeChangeToOutput(
options.signer,
feeChangeOutputIndex,
options.feeRate,
);
await assertDaoOutputLimit(retryTx, options.client);
return retryTx;
}
await assertDaoOutputLimit(tx, options.client);
return tx;
}

/**
* Adds a user-owned order request to a partial transaction.
*/
public async request(
txLike: ccc.TransactionLike,
user: ccc.Signer | ccc.Script,
info: Info,
amounts: ValueComponents,
): Promise<ccc.Transaction> {
const { order } = sdkManagers(this);
const lock =
"codeHash" in user ? user : (await user.getRecommendedAddressObj()).script;
return order.mint(txLike, lock, info, amounts);
}

/**
* Adds order group inputs for collection or fulfilled-order cleanup.
*/
public collect(
txLike: ccc.TransactionLike,
groups: OrderGroup[],
options?: { isFulfilledOnly?: boolean },
): ccc.Transaction {
return sdkManagers(this).order.melt(txLike, groups, options);
}

/**
* Adds common collect steps to a partial conversion transaction.
*
* @remarks
* The result is still partial. Callers should use `completeTransaction` before
* signing and sending.
*/
public async buildBaseTransaction(
txLike: ccc.TransactionLike,
client: ccc.Client,
options: BuildBaseTransactionOptions = {},
): Promise<ccc.Transaction> {
const { ownedOwner, ickbLogic } = sdkManagers(this);
let tx = ccc.Transaction.from(txLike);
const {
withdrawalRequest,
orders = [],
receipts = [],
readyWithdrawals = [],
} = options;
if (withdrawalRequest !== undefined && withdrawalRequest.deposits.length > 0) {
const requiredLiveDeposits = withdrawalRequest.requiredLiveDeposits ?? [];
assertReadyWithdrawalDeposits(withdrawalRequest.deposits);
assertRequiredLiveWithdrawalDeposits(
withdrawalRequest.deposits,
requiredLiveDeposits,
);
tx = await ownedOwner.requestWithdrawal(
tx,
withdrawalRequest.deposits,
withdrawalRequest.lock,
client,
requiredLiveDeposits.length > 0 ? { requiredLiveDeposits } : undefined,
);
}
if (orders.length > 0) {
tx = this.collect(tx, orders);
}
if (receipts.length > 0) {
tx = ickbLogic.completeDeposit(tx, receipts);
}
if (readyWithdrawals.length > 0) {
tx = await ownedOwner.withdraw(tx, readyWithdrawals, client);
}
return tx;
}

/**
* Throws when the chain tip changed since the sampled state was built.
*/
public async assertCurrentTip(
client: ccc.Client,
tip: ccc.ClientBlockHeader,
): Promise<void> {
const currentTip = await client.getTipHeader();
if (currentTip.number !== tip.number || currentTip.hash !== tip.hash) {
throw new Error(
`L1 state scan crossed chain tip; sampled block ${String(tip.number)} ${tip.hash}; current block ${String(currentTip.number)} ${currentTip.hash}; retry with a fresh state`,
);
}
}

private async findFeeChangeOutputIndex(
tx: ccc.Transaction,
signer: ccc.Signer,
): Promise<number | undefined> {
const { ickbLogic, ownedOwner, order } = sdkManagers(this);
const { script: userLock } = await signer.getRecommendedAddressObj();
let masterIndex: number | undefined;
let ownerIndex: number | undefined;
for (const [index, output] of Array.from(tx.outputs.entries()).toReversed()) {
if (!output.lock.eq(userLock)) {
continue;
}
if (output.type?.eq(ickbLogic.script) === true) {
return index;
}
if (masterIndex === undefined && output.type?.eq(order.script) === true) {
masterIndex = index;
}
if (ownerIndex === undefined && output.type?.eq(ownedOwner.script) === true) {
ownerIndex = index;
}
}
return masterIndex ?? ownerIndex;
}
}

function assertRequiredLiveWithdrawalDeposits(
requestedDeposits: readonly IckbDepositCell[],
requiredLiveDeposits: readonly IckbDepositCell[],
): void {
const spent = new Set(
requestedDeposits.map((deposit) => deposit.cell.outPoint.toHex()),
);
const seen = new Set<string>();
for (const deposit of requiredLiveDeposits) {
const outPoint = deposit.cell.outPoint.toHex();
if (seen.has(outPoint)) {
throw new Error(`Withdrawal live deposit anchor ${outPoint} is duplicated`);
}
if (spent.has(outPoint)) {
throw new Error(`Withdrawal live deposit anchor ${outPoint} is also being spent`);
}
seen.add(outPoint);
}
}
Loading
Loading