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:'