diff --git a/apps/interface/src/Action.tsx b/apps/interface/src/Action.tsx
index 0a51c37..3e4c230 100644
--- a/apps/interface/src/Action.tsx
+++ b/apps/interface/src/Action.tsx
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useState, type JSX } from "react";
-import type { L1StateType } from "./queries.ts";
+import { sendAndWaitForCommit } from "@ickb/sdk";
+import { getL1State, type L1StateType } from "./queries.ts";
import Progress from "./Progress.tsx";
import {
errorMessageOf,
@@ -19,7 +20,6 @@ export default function Action({
walletConfig,
l1State,
isStateFetching,
- isStateStale,
}: {
isCkb2Udt: boolean;
amount: bigint;
@@ -28,7 +28,6 @@ export default function Action({
walletConfig: WalletConfig;
l1State: L1StateType | undefined;
isStateFetching: boolean;
- isStateStale: boolean;
}): JSX.Element {
const [message, setMessage] = useState("");
const [failure, setFailure] = useState("");
@@ -79,14 +78,10 @@ export default function Action({
{failure !== "" ? {failure} : null}
@@ -122,50 +115,50 @@ export default function Action({
}
async function transact(
- txInfo: TxInfo,
+ buildFreshTxInfo: () => Promise,
+ previewTxInfo: TxInfo,
freezeTxInfo: (txInfo: TxInfo) => void,
setMessage: (message: string) => void,
setFailure: (message: string) => void,
formReset: () => void,
walletConfig: WalletConfig,
): Promise {
- const { address, chain, cccClient, queryClient, signer } = walletConfig;
- const maxConfirmationChecks = 60;
+ const { address, chain, queryClient } = walletConfig;
try {
- freezeTxInfo(txInfo);
+ freezeTxInfo(previewTxInfo);
setFailure("");
- setMessage("Waiting for user confirmation...");
- const txHash = await signer.sendTransaction(txInfo.tx);
-
- let status: string | undefined = "sent";
- let checks = 0;
- while (
- checks < maxConfirmationChecks &&
- (status === undefined || ["sent", "pending", "proposed"].includes(status))
- ) {
- setMessage("Waiting for network confirmation...");
- await new Promise((resolve) => setTimeout(resolve, 10000));
- status = (await cccClient.getTransaction(txHash))?.status;
- checks += 1;
+ setMessage("Refreshing transaction...");
+ const txInfo = await buildFreshTxInfo();
+ if (txInfo.error !== "") {
+ throw new Error(txInfo.error);
}
-
- if (checks >= maxConfirmationChecks) {
- throw new Error("Transaction confirmation timed out");
+ if (!hasTransactionActivity(txInfo.tx)) {
+ throw new Error("Nothing to do right now");
}
-
- if (status !== "committed") {
- throw new Error(`Transaction ended with status: ${status ?? "unknown"}`);
+ if (txInfo.fee <= 0n) {
+ throw new Error("Transaction fee is missing or invalid");
}
+ freezeTxInfo(txInfo);
+ setMessage("Waiting for user confirmation...");
+ await sendAndWaitForCommit({
+ client: walletConfig.cccClient,
+ signer: walletConfig.signer,
+ }, txInfo.tx, {
+ onConfirmationWait: () => {
+ setMessage("Waiting for network confirmation...");
+ },
+ });
+
setMessage("Transaction confirmed.");
formReset();
await new Promise((resolve) => setTimeout(resolve, 2000));
} catch (error) {
setFailure(errorMessageOf(error));
} finally {
- await queryClient.invalidateQueries({ queryKey: [chain, address] });
freezeTxInfo(txInfoPadding);
+ await queryClient.invalidateQueries({ queryKey: [chain, address] });
setMessage("");
}
}
diff --git a/apps/interface/src/App.tsx b/apps/interface/src/App.tsx
index c508ef7..6c6f2d4 100644
--- a/apps/interface/src/App.tsx
+++ b/apps/interface/src/App.tsx
@@ -5,8 +5,7 @@ import { Dashboard } from "./Dashboard.tsx";
import Form from "./Form.tsx";
import Progress from "./Progress.tsx";
import {
- getL1State,
- l1StateQueryKey,
+ l1StateOptions,
type L1StateType,
} from "./queries.ts";
import {
@@ -25,12 +24,7 @@ export default function App({
const [isFrozen, freeze] = useState(false);
const [rawText, setRawText] = useState(direction2Symbol(true));
const l1StateQuery = useQuery({
- retry: 2,
- refetchInterval: ({ state }) => 60000 * (state.data?.hasMatchable ? 1 : 10),
- staleTime: 10000,
- queryKey: l1StateQueryKey(walletConfig),
- queryFn: async () => await getL1State(walletConfig),
- enabled: !isFrozen,
+ ...l1StateOptions(walletConfig, isFrozen),
});
const symbol = rawText.startsWith("I") ? "I" : "C";
const isCkb2Udt = symbol2Direction(symbol);
@@ -46,7 +40,6 @@ export default function App({
walletConfig: WalletConfig;
l1State: L1StateType | undefined;
isStateFetching: boolean;
- isStateStale: boolean;
}>({
isCkb2Udt,
amount,
@@ -55,7 +48,6 @@ export default function App({
walletConfig,
l1State: l1StateQuery.data,
isStateFetching: l1StateQuery.isFetching,
- isStateStale: l1StateQuery.isStale,
});
if (l1StateQuery.data === undefined) {
diff --git a/apps/interface/src/queries.test.ts b/apps/interface/src/queries.test.ts
index 272b35d..ac252ed 100644
--- a/apps/interface/src/queries.test.ts
+++ b/apps/interface/src/queries.test.ts
@@ -1,6 +1,8 @@
import { ccc } from "@ckb-ccc/ccc";
+import { Ratio, type OrderGroup } from "@ickb/order";
import { describe, expect, it } from "vitest";
-import { isPlainCapacityCell } from "@ickb/utils";
+import { getL1State } from "./queries.ts";
+import type { WalletConfig } from "./utils.ts";
function byte32FromByte(hexByte: string): `0x${string}` {
if (!/^[0-9a-f]{2}$/iu.test(hexByte)) {
@@ -17,36 +19,98 @@ function script(codeHashByte: string): ccc.Script {
});
}
-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"),
+function cell(capacity: bigint, lock: ccc.Script): ccc.Cell {
+ return ccc.Cell.from({
+ outPoint: { txHash: byte32FromByte("aa"), index: 0n },
+ cellOutput: { capacity, lock },
+ outputData: "0x",
+ });
+}
+
+function orderGroup(
+ ckbValue: bigint,
+ udtValue: bigint,
+ isMatchable: boolean,
+ maturity?: bigint,
+): OrderGroup {
+ return {
+ ckbValue,
+ udtValue,
+ order: {
+ isDualRatio: (): boolean => false,
+ isMatchable: (): boolean => isMatchable,
+ maturity,
+ },
+ } as OrderGroup;
+}
+
+describe("getL1State", () => {
+ it("projects account state through the SDK and makes collected orders available", async () => {
+ const lock = script("11");
+ const tip = { timestamp: 10n } as ccc.ClientBlockHeader;
+ const nativeCapacity = ccc.fixedPointFrom(50);
+ const receipt = { ckbValue: 13n, udtValue: 17n };
+ const readyWithdrawal = {
+ ckbValue: 19n,
+ udtValue: 0n,
+ owned: { isReady: true },
+ };
+ const pendingWithdrawal = {
+ ckbValue: 31n,
+ udtValue: 0n,
+ owned: {
+ isReady: false,
+ maturity: { toUnix: (): bigint => 60n },
},
- outputData: "0xab",
- });
+ };
+ const availableOrder = orderGroup(10n, 20n, false);
+ const pendingOrder = orderGroup(100n, 200n, true, 40n);
+ const walletConfig: WalletConfig = {
+ chain: "testnet",
+ cccClient: {} as ccc.Client,
+ queryClient: {} as WalletConfig["queryClient"],
+ signer: {} as ccc.Signer,
+ address: "ckt1test",
+ accountLocks: [lock],
+ primaryLock: lock,
+ sdk: {
+ getL1State: async () => {
+ await Promise.resolve();
+ return {
+ system: {
+ feeRate: 1n,
+ tip,
+ exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }),
+ orderPool: [],
+ ckbAvailable: 0n,
+ ckbMaturing: [],
+ },
+ user: { orders: [availableOrder, pendingOrder] },
+ };
+ },
+ getAccountState: async () => {
+ await Promise.resolve();
+ return {
+ capacityCells: [cell(nativeCapacity, lock)],
+ nativeUdtCapacity: 7n,
+ nativeUdtBalance: 11n,
+ receipts: [receipt],
+ withdrawalGroups: [readyWithdrawal, pendingWithdrawal],
+ };
+ },
+ } as unknown as WalletConfig["sdk"],
+ managers: {} as WalletConfig["managers"],
+ };
+
+ const state = await getL1State(walletConfig);
- expect(isPlainCapacityCell(plain)).toBe(true);
- expect(isPlainCapacityCell(typed)).toBe(false);
- expect(isPlainCapacityCell(dataCell)).toBe(false);
+ expect(state.ckbNative).toBe(nativeCapacity);
+ expect(state.ickbNative).toBe(11n);
+ expect(state.ckbAvailable).toBe(nativeCapacity + 142n);
+ expect(state.ickbAvailable).toBe(248n);
+ expect(state.ckbBalance).toBe(nativeCapacity + 173n);
+ expect(state.ickbBalance).toBe(248n);
+ expect(state.hasMatchable).toBe(false);
+ expect(state.stateId).toBe("testnet:10:1:1:1:2:0");
});
});
diff --git a/apps/interface/src/queries.ts b/apps/interface/src/queries.ts
index 1799eb2..36b8c1d 100644
--- a/apps/interface/src/queries.ts
+++ b/apps/interface/src/queries.ts
@@ -1,8 +1,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, isPlainCapacityCell, sum, unique } from "@ickb/utils";
+import {
+ projectAccountAvailability,
+ type SystemState,
+} from "@ickb/sdk";
import {
buildTransactionPreview,
type TransactionContext,
@@ -58,71 +57,26 @@ export async function getL1State(
walletConfig.accountLocks,
);
const { system, user } = sdkState;
- const [accountCells, receipts, withdrawalGroups] = await Promise.all([
- getAccountCells(walletConfig),
- collect(
- walletConfig.managers.logic.findReceipts(
- walletConfig.cccClient,
- walletConfig.accountLocks,
- { onChain: true },
- ),
- ),
- collect(
- walletConfig.managers.ownedOwner.findWithdrawalGroups(
- walletConfig.cccClient,
- walletConfig.accountLocks,
- { onChain: true, tip: system.tip },
- ),
- ),
- ]);
-
- const capacityCells = accountCells.filter(isPlainCapacityCell);
- const udtCells = accountCells.filter((cell) =>
- walletConfig.managers.ickbUdt.isUdt(cell),
- );
- const nativeUdtInfo = await walletConfig.managers.ickbUdt.infoFrom(
+ const account = await walletConfig.sdk.getAccountState(
walletConfig.cccClient,
- udtCells,
+ walletConfig.accountLocks,
+ system.tip,
);
-
- const ckbNative =
- sum(0n, ...capacityCells.map((cell) => cell.cellOutput.capacity)) +
- nativeUdtInfo.capacity;
- const ickbNative = nativeUdtInfo.balance;
-
- const readyWithdrawals: WithdrawalGroup[] = [];
- const pendingWithdrawals: WithdrawalGroup[] = [];
- for (const group of withdrawalGroups) {
- if (group.owned.isReady) {
- readyWithdrawals.push(group);
- } else {
- pendingWithdrawals.push(group);
- }
- }
-
- const availableOrders: OrderGroup[] = [];
- const pendingOrders: OrderGroup[] = [];
- for (const group of user.orders) {
- if (group.order.isDualRatio() || !group.order.isMatchable()) {
- availableOrders.push(group);
- } else {
- pendingOrders.push(group);
- }
- }
-
- const ckbAvailable =
- ckbNative +
- sumCkb(receipts) +
- sumCkb(readyWithdrawals) +
- sumCkb(availableOrders);
- const ickbAvailable =
- ickbNative +
- sumUdt(receipts) +
- sumUdt(readyWithdrawals) +
- sumUdt(availableOrders);
-
- const ckbBalance = ckbAvailable + sumCkb(pendingWithdrawals) + sumCkb(pendingOrders);
- const ickbBalance = ickbAvailable + sumUdt(pendingWithdrawals) + sumUdt(pendingOrders);
+ const projection = projectAccountAvailability(account, user.orders, {
+ collectedOrdersAvailable: true,
+ });
+ const {
+ ckbNative,
+ ickbNative,
+ ckbBalance,
+ ickbBalance,
+ ckbAvailable,
+ ickbAvailable,
+ readyWithdrawals,
+ pendingWithdrawals,
+ availableOrders,
+ pendingOrders,
+ } = projection;
const estimatedMaturity = [
system.tip.timestamp,
@@ -134,7 +88,7 @@ export async function getL1State(
const txContext: TransactionContext = {
system,
- receipts,
+ receipts: account.receipts,
readyWithdrawals,
availableOrders,
ckbAvailable,
@@ -154,7 +108,7 @@ export async function getL1State(
stateId: [
walletConfig.chain,
String(system.tip.timestamp),
- String(receipts.length),
+ String(account.receipts.length),
String(readyWithdrawals.length),
String(pendingWithdrawals.length),
String(availableOrders.length),
@@ -165,32 +119,3 @@ export async function getL1State(
hasMatchable: pendingOrders.length > 0,
};
}
-
-async function getAccountCells(walletConfig: WalletConfig): Promise {
- const cells: ccc.Cell[] = [];
-
- for (const lock of unique(walletConfig.accountLocks)) {
- for await (const cell of walletConfig.cccClient.findCellsOnChain(
- {
- script: lock,
- scriptType: "lock",
- scriptSearchMode: "exact",
- withData: true,
- },
- "asc",
- 400,
- )) {
- cells.push(cell);
- }
- }
-
- return cells;
-}
-
-function sumCkb(items: { ckbValue: bigint }[]): bigint {
- return sum(0n, ...items.map((item) => item.ckbValue));
-}
-
-function sumUdt(items: { udtValue: bigint }[]): bigint {
- return sum(0n, ...items.map((item) => item.udtValue));
-}
diff --git a/apps/interface/src/transaction.test.ts b/apps/interface/src/transaction.test.ts
index f47903b..0c58f31 100644
--- a/apps/interface/src/transaction.test.ts
+++ b/apps/interface/src/transaction.test.ts
@@ -1,7 +1,10 @@
import { ccc } from "@ckb-ccc/ccc";
-import { Ratio } from "@ickb/order";
+import { Ratio, type OrderGroup } from "@ickb/order";
import { describe, expect, it, vi } from "vitest";
-import { buildTransactionPreview, selectReadyDeposits } from "./transaction.ts";
+import {
+ buildTransactionPreview,
+ selectExactCountReadyDepositsUnderAmount,
+} from "./transaction.ts";
import type { TransactionContext } from "./transaction.ts";
import type { WalletConfig } from "./utils.ts";
@@ -55,6 +58,15 @@ function emptyDeposits(): AsyncGenerator {
})();
}
+function deposits(...values: T[]): AsyncGenerator {
+ return (async function* (): AsyncGenerator {
+ await Promise.resolve();
+ for (const value of values) {
+ yield value;
+ }
+ })();
+}
+
function walletConfig(overrides: Partial = {}): WalletConfig {
return {
chain: "testnet",
@@ -65,6 +77,8 @@ function walletConfig(overrides: Partial = {}): WalletConfig {
accountLocks: [],
primaryLock: script("11"),
sdk: {
+ buildBaseTransaction: resolvedTx,
+ completeTransaction: resolvedTx,
collect: identityTx,
request: resolvedTx,
} as unknown as WalletConfig["sdk"],
@@ -87,11 +101,11 @@ function walletConfig(overrides: Partial = {}): WalletConfig {
};
}
-describe("selectReadyDeposits", () => {
+describe("selectExactCountReadyDepositsUnderAmount", () => {
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([
+ expect(selectExactCountReadyDepositsUnderAmount(deposits as never[], 2, 10n)).toEqual([
deposits[1],
deposits[2],
]);
@@ -100,7 +114,7 @@ describe("selectReadyDeposits", () => {
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([
+ expect(selectExactCountReadyDepositsUnderAmount(deposits as never[], 2, 10n)).toEqual([
deposits[1],
deposits[2],
]);
@@ -109,7 +123,7 @@ describe("selectReadyDeposits", () => {
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([
+ expect(selectExactCountReadyDepositsUnderAmount(deposits as never[], 2, 10n)).toEqual([
deposits[0],
deposits[1],
]);
@@ -122,13 +136,13 @@ describe("selectReadyDeposits", () => {
{ udtValue: 5n },
];
- expect(selectReadyDeposits(deposits as never[], 2, 10n)).toEqual([]);
+ expect(selectExactCountReadyDepositsUnderAmount(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([]);
+ expect(selectExactCountReadyDepositsUnderAmount(deposits as never[], 2, 9n)).toEqual([]);
});
});
@@ -149,4 +163,202 @@ describe("buildTransactionPreview", () => {
"Amount too small to exceed the minimum match and fee threshold",
);
});
+
+ it("passes the system fee rate through SDK completion", async () => {
+ vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(0n);
+ const completeTransaction = vi
+ .fn()
+ .mockImplementation(async (txLike) => {
+ await Promise.resolve();
+ return ccc.Transaction.from(txLike);
+ });
+
+ await buildTransactionPreview(
+ context({
+ availableOrders: [{} as OrderGroup],
+ system: {
+ ...context().system,
+ feeRate: 42n,
+ },
+ }),
+ true,
+ 0n,
+ walletConfig({
+ sdk: Object.assign({}, walletConfig().sdk, {
+ completeTransaction,
+ buildBaseTransaction: async () => {
+ await Promise.resolve();
+ const tx = ccc.Transaction.default();
+ tx.inputs.push(
+ ccc.CellInput.from({
+ previousOutput: {
+ txHash: byte32FromByte("99"),
+ index: 0n,
+ },
+ }),
+ );
+ return tx;
+ },
+ }) as unknown as WalletConfig["sdk"],
+ }),
+ );
+
+ expect(completeTransaction.mock.calls[0]?.[1]).toEqual({
+ signer: walletConfig().signer,
+ client: walletConfig().cccClient,
+ feeRate: 42n,
+ });
+ });
+
+ it("uses SDK completion instead of local UDT, fee, and DAO steps", async () => {
+ vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(0n);
+ const calls: string[] = [];
+ const completeBy = vi.fn().mockImplementation(async (txLike: ccc.TransactionLike) => {
+ calls.push("udt");
+ await Promise.resolve();
+ return ccc.Transaction.from(txLike);
+ });
+ const completeFeeBy = vi
+ .spyOn(ccc.Transaction.prototype, "completeFeeBy")
+ .mockImplementation(() => {
+ calls.push("fee");
+ return Promise.resolve([0, false]);
+ });
+ const daoLimit = vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockImplementation(() => {
+ calls.push("dao-limit");
+ return Promise.resolve(false);
+ });
+ const completeTransaction = vi
+ .fn()
+ .mockImplementation(async (txLike) => {
+ calls.push("sdk-complete");
+ await Promise.resolve();
+ return ccc.Transaction.from(txLike);
+ });
+
+ await buildTransactionPreview(
+ context({ availableOrders: [{} as OrderGroup] }),
+ true,
+ 0n,
+ walletConfig({
+ sdk: Object.assign({}, walletConfig().sdk, {
+ completeTransaction,
+ buildBaseTransaction: async () => {
+ await Promise.resolve();
+ const tx = ccc.Transaction.default();
+ tx.inputs.push(
+ ccc.CellInput.from({
+ previousOutput: {
+ txHash: byte32FromByte("77"),
+ index: 0n,
+ },
+ }),
+ );
+ return tx;
+ },
+ }) as unknown as WalletConfig["sdk"],
+ managers: Object.assign({}, walletConfig().managers, {
+ ickbUdt: { completeBy } as unknown as WalletConfig["managers"]["ickbUdt"],
+ }),
+ }),
+ );
+
+ expect(completeTransaction).toHaveBeenCalledTimes(1);
+ expect(completeBy).not.toHaveBeenCalled();
+ expect(completeFeeBy).not.toHaveBeenCalled();
+ expect(daoLimit).not.toHaveBeenCalled();
+ expect(calls).toEqual(["sdk-complete"]);
+ });
+
+ it("passes direct withdrawal requests through the SDK base builder", async () => {
+ vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(0n);
+ const buildBaseTransaction = vi
+ .fn()
+ .mockImplementation(async (txLike) => {
+ await Promise.resolve();
+ return ccc.Transaction.from(txLike);
+ });
+ const request = vi
+ .fn()
+ .mockImplementation(async (txLike) => {
+ await Promise.resolve();
+ return ccc.Transaction.from(txLike);
+ });
+ const readyDeposit = {
+ isReady: true,
+ udtValue: 10n,
+ maturity: { toUnix: (): bigint => 5n },
+ };
+
+ const txInfo = await buildTransactionPreview(
+ context({ ickbAvailable: 10n }),
+ false,
+ 10n,
+ walletConfig({
+ sdk: Object.assign({}, walletConfig().sdk, {
+ buildBaseTransaction,
+ request,
+ }) as unknown as WalletConfig["sdk"],
+ managers: Object.assign({}, walletConfig().managers, {
+ logic: Object.assign({}, walletConfig().managers.logic, {
+ findDeposits: () => deposits(readyDeposit),
+ }),
+ }),
+ }),
+ );
+
+ expect(txInfo.error).toBe("");
+ expect(buildBaseTransaction).toHaveBeenCalledTimes(2);
+ expect(buildBaseTransaction.mock.calls[0]?.[2]).toEqual({
+ withdrawalRequest: undefined,
+ orders: [],
+ receipts: [],
+ readyWithdrawals: [],
+ });
+ expect(buildBaseTransaction.mock.calls[1]?.[2]).toEqual({
+ withdrawalRequest: {
+ deposits: [readyDeposit],
+ lock: script("11"),
+ },
+ orders: [],
+ receipts: [],
+ readyWithdrawals: [],
+ });
+ expect(request).not.toHaveBeenCalled();
+ });
+
+ it("keeps UDT-to-CKB fallback preview buildable under live-like ratios", async () => {
+ vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(0n);
+ const request = vi
+ .fn()
+ .mockImplementation(async (txLike) => {
+ await Promise.resolve();
+ return ccc.Transaction.from(txLike);
+ });
+
+ const txInfo = await buildTransactionPreview(
+ context({
+ system: {
+ ...context().system,
+ exchangeRatio: Ratio.from({
+ ckbScale: 10000000000000000n,
+ udtScale: 10100000000000000n,
+ }),
+ ckbAvailable: ccc.fixedPointFrom(1000000),
+ tip: { timestamp: 1234n } as ccc.ClientBlockHeader,
+ },
+ ickbAvailable: ccc.fixedPointFrom(10000),
+ }),
+ false,
+ ccc.fixedPointFrom(10000),
+ walletConfig({
+ sdk: Object.assign({}, walletConfig().sdk, {
+ request,
+ }) as unknown as WalletConfig["sdk"],
+ }),
+ );
+
+ expect(txInfo.error).toBe("");
+ expect(request).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/apps/interface/src/transaction.ts b/apps/interface/src/transaction.ts
index f90a02d..ede714d 100644
--- a/apps/interface/src/transaction.ts
+++ b/apps/interface/src/transaction.ts
@@ -7,7 +7,7 @@ import {
type WithdrawalGroup,
} from "@ickb/core";
import { type OrderGroup } from "@ickb/order";
-import { collect, sum } from "@ickb/utils";
+import { collect, selectBoundedUdtSubset, sum } from "@ickb/utils";
import { IckbSdk, type SystemState } from "@ickb/sdk";
import {
errorMessageOf,
@@ -59,6 +59,7 @@ export async function buildTransactionPreview(
return await finalizeTransaction(
baseTx,
context.estimatedMaturity,
+ context.system.feeRate,
walletConfig,
);
}
@@ -74,26 +75,24 @@ export async function buildTransactionPreview(
async function buildBaseTransaction(
context: TransactionContext,
walletConfig: WalletConfig,
+ withdrawalRequestDeposits: IckbDepositCell[] = [],
): Promise {
- let tx = ccc.Transaction.default();
-
- if (context.availableOrders.length > 0) {
- tx = walletConfig.sdk.collect(tx, context.availableOrders);
- }
-
- if (context.receipts.length > 0) {
- tx = walletConfig.managers.logic.completeDeposit(tx, context.receipts);
- }
-
- if (context.readyWithdrawals.length > 0) {
- tx = await walletConfig.managers.ownedOwner.withdraw(
- tx,
- context.readyWithdrawals,
- walletConfig.cccClient,
- );
- }
-
- return tx;
+ return walletConfig.sdk.buildBaseTransaction(
+ ccc.Transaction.default(),
+ walletConfig.cccClient,
+ {
+ withdrawalRequest:
+ withdrawalRequestDeposits.length === 0
+ ? undefined
+ : {
+ deposits: withdrawalRequestDeposits,
+ lock: walletConfig.primaryLock,
+ },
+ orders: context.availableOrders,
+ receipts: context.receipts,
+ readyWithdrawals: context.readyWithdrawals,
+ },
+ );
}
async function buildCkbToIckbPreview(
@@ -144,7 +143,12 @@ async function buildCkbToIckbPreview(
);
}
- return await finalizeTransaction(tx, estimatedMaturity, walletConfig);
+ return await finalizeTransaction(
+ tx,
+ estimatedMaturity,
+ context.system.feeRate,
+ walletConfig,
+ );
} catch (error) {
return txInfoWithError(errorMessageOf(error), context.estimatedMaturity);
}
@@ -175,14 +179,12 @@ 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 tx = baseTx.clone();
let estimatedMaturity = context.estimatedMaturity;
let remainder = amount;
if (withdrawalCount > 0) {
- const selectedDeposits = selectReadyDeposits(
+ const selectedDeposits = selectExactCountReadyDepositsUnderAmount(
candidates,
withdrawalCount,
remainder,
@@ -194,12 +196,7 @@ async function buildIckbToCkbPreview(
);
}
- tx = await walletConfig.managers.ownedOwner.requestWithdrawal(
- tx,
- selectedDeposits,
- walletConfig.primaryLock,
- walletConfig.cccClient,
- );
+ tx = await buildBaseTransaction(context, walletConfig, selectedDeposits);
remainder -= sum(0n, ...selectedDeposits.map((deposit) => deposit.udtValue));
for (const deposit of selectedDeposits) {
@@ -210,8 +207,6 @@ async function buildIckbToCkbPreview(
}
}
- tx = appendTransaction(tx, baseTx);
-
if (remainder > 0n) {
const amounts = { ckbValue: 0n, udtValue: remainder };
const estimate = IckbSdk.estimate(false, amounts, context.system);
@@ -231,7 +226,12 @@ async function buildIckbToCkbPreview(
);
}
- return await finalizeTransaction(tx, estimatedMaturity, walletConfig);
+ return await finalizeTransaction(
+ tx,
+ estimatedMaturity,
+ context.system.feeRate,
+ walletConfig,
+ );
} catch (error) {
return txInfoWithError(errorMessageOf(error), context.estimatedMaturity);
}
@@ -242,16 +242,14 @@ async function buildIckbToCkbPreview(
async function finalizeTransaction(
tx: ccc.Transaction,
estimatedMaturity: bigint,
+ feeRate: ccc.Num,
walletConfig: WalletConfig,
): Promise {
- tx = await walletConfig.managers.ickbUdt.completeBy(tx, walletConfig.signer);
- await tx.completeFeeBy(walletConfig.signer);
-
- if (await ccc.isDaoOutputLimitExceeded(tx, walletConfig.cccClient)) {
- throw new Error(
- `NervosDAO transaction has ${String(tx.outputs.length)} output cells, exceeding the limit of 64`,
- );
- }
+ tx = await walletConfig.sdk.completeTransaction(tx, {
+ signer: walletConfig.signer,
+ client: walletConfig.cccClient,
+ feeRate,
+ });
return Object.freeze({
tx,
@@ -278,213 +276,16 @@ async function findBestAttempt(
return lastError ?? txInfoWithError("Nothing to do for now", 0n);
}
-export function selectReadyDeposits(
+export function selectExactCountReadyDepositsUnderAmount(
deposits: IckbDepositCell[],
wanted: number,
amount: bigint,
): IckbDepositCell[] {
- 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)
- );
-
- 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;
- }
- }
-
- 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;
- }
-
- 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;
-}
-
-function appendTransaction(
- target: ccc.Transaction,
- source: ccc.Transaction,
-): ccc.Transaction {
- for (const cellDep of source.cellDeps) {
- target.addCellDeps(cellDep);
- }
-
- for (const headerDep of source.headerDeps) {
- if (!target.headerDeps.some((hash) => hash === headerDep)) {
- target.headerDeps.push(headerDep);
- }
- }
-
- for (const input of source.inputs) {
- target.inputs.push(input);
- }
-
- target.outputs.push(...source.outputs);
- target.outputsData.push(...source.outputsData);
- target.witnesses.push(...source.witnesses);
-
- return target;
+ return selectBoundedUdtSubset(deposits, amount, {
+ candidateLimit: MAX_WITHDRAWAL_REQUESTS,
+ minCount: wanted,
+ maxCount: wanted,
+ });
}
function txInfoWithError(error: string, estimatedMaturity: bigint): TxInfo {
diff --git a/apps/tester/package.json b/apps/tester/package.json
index e974ba7..57ec53e 100644
--- a/apps/tester/package.json
+++ b/apps/tester/package.json
@@ -52,7 +52,6 @@
"@ckb-ccc/core": "catalog:",
"@ickb/core": "workspace:*",
"@ickb/order": "workspace:*",
- "@ickb/sdk": "workspace:*",
- "@ickb/utils": "workspace:*"
+ "@ickb/sdk": "workspace:*"
}
}
diff --git a/apps/tester/src/cells.ts b/apps/tester/src/cells.ts
deleted file mode 100644
index b95c88c..0000000
--- a/apps/tester/src/cells.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-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
index f8645cc..a31f753 100644
--- a/apps/tester/src/index.test.ts
+++ b/apps/tester/src/index.test.ts
@@ -1,60 +1,17 @@
-import { ccc } from "@ckb-ccc/core";
import { describe, expect, it } from "vitest";
-import { collectCapacityCells } from "./cells.js";
+import { parseSleepInterval } from "./index.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("parseSleepInterval", () => {
+ it("rejects missing, non-finite, NaN, and sub-second intervals", () => {
+ for (const value of [undefined, "", "abc", "NaN", "Infinity", "0", "0.5"]) {
+ expect(() => parseSleepInterval(value, "TESTER_SLEEP_INTERVAL")).toThrow(
+ "Invalid env TESTER_SLEEP_INTERVAL",
+ );
+ }
});
-}
-
-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]);
+ it("returns milliseconds for valid second intervals", () => {
+ expect(parseSleepInterval("1", "TESTER_SLEEP_INTERVAL")).toBe(1000);
+ expect(parseSleepInterval("2.5", "TESTER_SLEEP_INTERVAL")).toBe(2500);
});
});
diff --git a/apps/tester/src/index.ts b/apps/tester/src/index.ts
index 62066c3..e83a5ea 100644
--- a/apps/tester/src/index.ts
+++ b/apps/tester/src/index.ts
@@ -1,8 +1,9 @@
import { ccc } from "@ckb-ccc/core";
import { ICKB_DEPOSIT_CAP, convert } from "@ickb/core";
-import { IckbSdk, getConfig, type SystemState } from "@ickb/sdk";
+import { IckbSdk, getConfig, sendAndWaitForCommit } from "@ickb/sdk";
import { type OrderGroup } from "@ickb/order";
-import { collectCapacityCells } from "./cells.js";
+import { pathToFileURL } from "node:url";
+import { buildTransaction, readTesterState, type Runtime } from "./runtime.js";
const CKB = ccc.fixedPointFrom(1);
const CKB_RESERVE = 2000n * CKB;
@@ -11,26 +12,8 @@ const MIN_TOTAL_CAPITAL_DIVISOR = 20n;
const TESTER_FEE = 100n;
const TESTER_FEE_BASE = 100000n;
const MAX_ELAPSED_BLOCKS = 100800n;
-const FIND_CELLS_PAGE_SIZE = 400;
const RANDOM_SCALE = 1000000n;
-interface Runtime {
- chain: SupportedChain;
- client: ccc.Client;
- signer: ccc.SignerCkbPrivateKey;
- sdk: IckbSdk;
- managers: ReturnType["managers"];
- primaryLock: ccc.Script;
- accountLocks: ccc.Script[];
-}
-
-interface TesterState {
- system: SystemState;
- userOrders: OrderGroup[];
- availableCkbBalance: bigint;
- availableIckbBalance: bigint;
-}
-
type SupportedChain = "mainnet" | "testnet";
async function main(): Promise {
@@ -42,28 +25,25 @@ async function main(): Promise {
if (!TESTER_PRIVATE_KEY) {
throw new Error("Empty env TESTER_PRIVATE_KEY");
}
- if (!TESTER_SLEEP_INTERVAL || Number(TESTER_SLEEP_INTERVAL) < 1) {
- throw new Error("Invalid env TESTER_SLEEP_INTERVAL");
- }
+ const sleepInterval = parseSleepInterval(
+ TESTER_SLEEP_INTERVAL,
+ "TESTER_SLEEP_INTERVAL",
+ );
const chain = parseChain(CHAIN);
const client = createClient(chain, RPC_URL);
const config = getConfig(chain);
- const { managers } = config;
const signer = new ccc.SignerCkbPrivateKey(client, TESTER_PRIVATE_KEY);
const primaryLock = (await signer.getRecommendedAddressObj()).script;
const runtime: Runtime = {
- chain,
client,
signer,
sdk: IckbSdk.fromConfig(config),
- managers,
primaryLock,
accountLocks: dedupeScripts(
(await signer.getAddressObjs()).map(({ script }) => script),
),
};
- const sleepInterval = Number(TESTER_SLEEP_INTERVAL) * 1000;
for (;;) {
await sleep(2 * Math.random() * sleepInterval);
@@ -172,7 +152,7 @@ async function main(): Promise {
fee: fmtCkb(await tx.getFee(runtime.client)),
feeRate: state.system.feeRate,
};
- executionLog.txHash = await runtime.signer.sendTransaction(tx);
+ executionLog.txHash = await sendAndWaitForCommit(runtime, tx);
} catch (e) {
executionLog.error = errorToLog(e);
}
@@ -183,49 +163,16 @@ async function main(): Promise {
}
}
-async function readTesterState(runtime: Runtime): Promise {
- const [{ system, user }, capacityCells, udtCells] = await Promise.all([
- runtime.sdk.getL1State(runtime.client, runtime.accountLocks),
- collectCapacityCells(runtime.signer),
- collectWalletUdtCells(runtime.signer, runtime.managers.ickbUdt),
- ]);
- const walletUdtInfo = await runtime.managers.ickbUdt.infoFrom(
- runtime.client,
- udtCells,
- );
-
- return {
- system,
- userOrders: user.orders,
- availableCkbBalance:
- sumValues(capacityCells, (cell) => cell.cellOutput.capacity) +
- walletUdtInfo.capacity +
- sumValues(user.orders, (group) => group.ckbValue),
- availableIckbBalance:
- walletUdtInfo.balance + sumValues(user.orders, (group) => group.udtValue),
- };
-}
-
-async function collectWalletUdtCells(
- signer: ccc.SignerCkbPrivateKey,
- ickbUdt: Runtime["managers"]["ickbUdt"],
-): Promise {
- const cells: ccc.Cell[] = [];
-
- for await (const cell of signer.findCellsOnChain(
- ickbUdt.filter,
- true,
- "asc",
- FIND_CELLS_PAGE_SIZE,
- )) {
- if (!ickbUdt.isUdt(cell)) {
- continue;
- }
-
- cells.push(cell);
+export function parseSleepInterval(
+ intervalSeconds: string | undefined,
+ envName: string,
+): number {
+ const seconds = Number(intervalSeconds);
+ if (intervalSeconds === undefined || !Number.isFinite(seconds) || seconds < 1) {
+ throw new Error("Invalid env " + envName);
}
- return cells;
+ return seconds * 1000;
}
async function hasFreshMatchableOrders(
@@ -260,31 +207,6 @@ async function hasFreshMatchableOrders(
return false;
}
-async function buildTransaction(
- runtime: Runtime,
- state: TesterState,
- amounts: { ckbValue: bigint; udtValue: bigint },
- info: Parameters[2],
-): Promise {
- let tx = ccc.Transaction.default();
-
- if (state.userOrders.length > 0) {
- tx = runtime.sdk.collect(tx, state.userOrders);
- }
-
- tx = await runtime.sdk.request(tx, runtime.primaryLock, info, amounts);
- tx = await runtime.managers.ickbUdt.completeBy(tx, runtime.signer);
- await tx.completeFeeBy(runtime.signer, state.system.feeRate);
-
- if (await ccc.isDaoOutputLimitExceeded(tx, runtime.client)) {
- throw new Error(
- `NervosDAO transaction has ${String(tx.outputs.length)} output cells, exceeding the limit of 64`,
- );
- }
-
- return tx;
-}
-
function createClient(chain: SupportedChain, rpcUrl: string | undefined): ccc.Client {
const config = rpcUrl ? { url: rpcUrl } : undefined;
return chain === "mainnet"
@@ -316,14 +238,6 @@ function dedupeScripts(scripts: ccc.Script[]): ccc.Script[] {
return unique;
}
-function sumValues(items: readonly T[], project: (item: T) => bigint): bigint {
- let total = 0n;
- for (const item of items) {
- total += project(item);
- }
- return total;
-}
-
function fmtCkb(balance: bigint): number {
return Number(balance) / Number(CKB);
}
@@ -369,4 +283,6 @@ function sleep(ms: number): Promise {
});
}
-await main();
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main();
+}
diff --git a/apps/tester/src/runtime.test.ts b/apps/tester/src/runtime.test.ts
new file mode 100644
index 0000000..16e1279
--- /dev/null
+++ b/apps/tester/src/runtime.test.ts
@@ -0,0 +1,159 @@
+import { ccc } from "@ckb-ccc/core";
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildTransaction,
+ readTesterState,
+ type Runtime,
+ type TesterState,
+} from "./runtime.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 cell(capacity: bigint, lock: ccc.Script, outputData = "0x"): ccc.Cell {
+ return ccc.Cell.from({
+ outPoint: { txHash: byte32FromByte("aa"), index: 0n },
+ cellOutput: { capacity, lock },
+ outputData,
+ });
+}
+
+describe("readTesterState", () => {
+ it("includes receipts and ready withdrawals in the actionable state", async () => {
+ const plainLock = script("11");
+ const plainCell = cell(5n, plainLock);
+ const userOrder = {
+ ckbValue: 23n,
+ udtValue: 29n,
+ order: {
+ isDualRatio: (): boolean => false,
+ isMatchable: (): boolean => false,
+ },
+ };
+ const pendingOrder = {
+ ckbValue: 31n,
+ udtValue: 37n,
+ order: {
+ isDualRatio: (): boolean => false,
+ isMatchable: (): boolean => true,
+ },
+ };
+ const receipt = { ckbValue: 13n, udtValue: 17n };
+ const readyWithdrawal = { owned: { isReady: true }, ckbValue: 19n, udtValue: 0n };
+ const pendingWithdrawal = { owned: { isReady: false }, ckbValue: 31n, udtValue: 0n };
+ const runtime: Runtime = {
+ client: {} as ccc.Client,
+ signer: {} as ccc.SignerCkbPrivateKey,
+ sdk: {
+ getL1State: async () => {
+ await Promise.resolve();
+ return {
+ system: { tip: { timestamp: 0n } } as TesterState["system"],
+ user: { orders: [userOrder, pendingOrder] },
+ };
+ },
+ getAccountState: async () => {
+ await Promise.resolve();
+ return {
+ capacityCells: [plainCell],
+ nativeUdtCapacity: 7n,
+ nativeUdtBalance: 11n,
+ receipts: [receipt],
+ withdrawalGroups: [readyWithdrawal, pendingWithdrawal],
+ };
+ },
+ } as unknown as Runtime["sdk"],
+ primaryLock: plainLock,
+ accountLocks: [plainLock],
+ };
+
+ const state = await readTesterState(runtime);
+
+ expect(state.userOrders).toEqual([userOrder, pendingOrder]);
+ expect(state.receipts).toEqual([receipt]);
+ expect(state.readyWithdrawals).toEqual([readyWithdrawal]);
+ expect(state.availableCkbBalance).toBe(
+ plainCell.cellOutput.capacity + 23n + 31n + 13n + 19n,
+ );
+ expect(state.availableIckbBalance).toBe(11n + 29n + 37n + 17n);
+ });
+});
+
+describe("buildTransaction", () => {
+ it("delegates base construction and completion to the SDK", async () => {
+ const calls: string[] = [];
+ const buildBaseTransaction = vi
+ .fn()
+ .mockImplementation(async (txLike) => {
+ calls.push("base");
+ await Promise.resolve();
+ return ccc.Transaction.from(txLike);
+ });
+ const request = vi
+ .fn()
+ .mockImplementation(async (txLike) => {
+ calls.push("request");
+ await Promise.resolve();
+ return ccc.Transaction.from(txLike);
+ });
+ const completeTransaction = vi
+ .fn()
+ .mockImplementation(async (txLike) => {
+ calls.push("complete");
+ await Promise.resolve();
+ return ccc.Transaction.from(txLike);
+ });
+ const receipts = [{ id: "receipt" }];
+ const readyWithdrawals = [{ id: "withdrawal" }];
+ const state: TesterState = {
+ system: { feeRate: 42n } as TesterState["system"],
+ userOrders: [{ id: "order" }] as unknown as TesterState["userOrders"],
+ receipts: receipts as unknown as TesterState["receipts"],
+ readyWithdrawals: readyWithdrawals as unknown as TesterState["readyWithdrawals"],
+ availableCkbBalance: 0n,
+ availableIckbBalance: 0n,
+ };
+ const runtime: Runtime = {
+ client: {} as ccc.Client,
+ signer: {} as ccc.SignerCkbPrivateKey,
+ sdk: {
+ buildBaseTransaction,
+ completeTransaction,
+ request,
+ } as unknown as Runtime["sdk"],
+ primaryLock: script("11"),
+ accountLocks: [],
+ };
+
+ await buildTransaction(
+ runtime,
+ state,
+ { ckbValue: 10n, udtValue: 0n },
+ {} as Parameters[2],
+ );
+
+ expect(buildBaseTransaction.mock.calls[0]?.[2]).toEqual({
+ orders: state.userOrders,
+ receipts,
+ readyWithdrawals,
+ });
+ expect(completeTransaction.mock.calls[0]?.[1]).toEqual({
+ signer: runtime.signer,
+ client: runtime.client,
+ feeRate: 42n,
+ });
+ expect(calls).toEqual(["base", "request", "complete"]);
+ });
+});
diff --git a/apps/tester/src/runtime.ts b/apps/tester/src/runtime.ts
new file mode 100644
index 0000000..4479f17
--- /dev/null
+++ b/apps/tester/src/runtime.ts
@@ -0,0 +1,74 @@
+import { ccc } from "@ckb-ccc/core";
+import { type ReceiptCell, type WithdrawalGroup } from "@ickb/core";
+import { type OrderGroup } from "@ickb/order";
+import {
+ IckbSdk,
+ projectAccountAvailability,
+ type SystemState,
+} from "@ickb/sdk";
+
+export interface Runtime {
+ client: ccc.Client;
+ signer: ccc.SignerCkbPrivateKey;
+ sdk: IckbSdk;
+ primaryLock: ccc.Script;
+ accountLocks: ccc.Script[];
+}
+
+export interface TesterState {
+ system: SystemState;
+ userOrders: OrderGroup[];
+ receipts: ReceiptCell[];
+ readyWithdrawals: WithdrawalGroup[];
+ availableCkbBalance: bigint;
+ availableIckbBalance: bigint;
+}
+
+export async function readTesterState(runtime: Runtime): Promise {
+ const { system, user } = await runtime.sdk.getL1State(
+ runtime.client,
+ runtime.accountLocks,
+ );
+ const account = await runtime.sdk.getAccountState(
+ runtime.client,
+ runtime.accountLocks,
+ system.tip,
+ );
+
+ const projection = projectAccountAvailability(account, user.orders, {
+ collectedOrdersAvailable: true,
+ });
+
+ return {
+ system,
+ userOrders: user.orders,
+ receipts: account.receipts,
+ readyWithdrawals: projection.readyWithdrawals,
+ availableCkbBalance: projection.ckbAvailable,
+ availableIckbBalance: projection.ickbAvailable,
+ };
+}
+
+export async function buildTransaction(
+ runtime: Runtime,
+ state: TesterState,
+ amounts: { ckbValue: bigint; udtValue: bigint },
+ info: Parameters[2],
+): Promise {
+ let tx = await runtime.sdk.buildBaseTransaction(
+ ccc.Transaction.default(),
+ runtime.client,
+ {
+ orders: state.userOrders,
+ receipts: state.receipts,
+ readyWithdrawals: state.readyWithdrawals,
+ },
+ );
+
+ tx = await runtime.sdk.request(tx, runtime.primaryLock, info, amounts);
+ return runtime.sdk.completeTransaction(tx, {
+ signer: runtime.signer,
+ client: runtime.client,
+ feeRate: state.system.feeRate,
+ });
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8771b83..bb44295 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -163,9 +163,6 @@ importers:
'@ickb/sdk':
specifier: workspace:*
version: link:../../packages/sdk
- '@ickb/utils':
- specifier: workspace:*
- version: link:../../packages/utils
devDependencies:
'@types/node':
specifier: 'catalog:'