From ad3633cc4ebea43547d51aa5bcce2c409690b28d Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 13:07:35 +0000 Subject: [PATCH 1/3] fix(apps): move runtime flows onto SDK boundaries --- README.md | 21 + apps/interface/README.md | 6 +- apps/interface/package.json | 6 +- apps/interface/src/Action.tsx | 31 +- apps/interface/src/Connector.test.ts | 32 ++ apps/interface/src/Connector.tsx | 30 +- apps/interface/src/connectorQueryKey.ts | 34 ++ apps/interface/src/main.tsx | 13 +- apps/interface/src/queries.test.ts | 152 ++++++-- apps/interface/src/queries.ts | 175 +++++++-- apps/interface/src/transaction.test.ts | 424 +++++++++------------ apps/interface/src/transaction.ts | 288 +++----------- apps/interface/src/utils.test.ts | 27 ++ apps/interface/src/utils.ts | 37 +- apps/interface/vite.config.ts | 6 +- apps/interface/vitest.config.mts | 20 + apps/sampler/.gitignore | 1 + apps/sampler/README.md | 24 +- apps/sampler/package.json | 3 +- apps/sampler/src/index.test.ts | 105 +++++ apps/sampler/src/index.ts | 66 ++-- apps/sampler/tsconfig.build.json | 9 + apps/tester/.gitignore | 3 +- apps/tester/README.md | 6 +- apps/tester/package.json | 6 +- apps/tester/src/freshMatchableOrderSkip.ts | 54 +++ apps/tester/src/index.test.ts | 87 ++++- apps/tester/src/index.ts | 197 +++------- apps/tester/src/runtime.test.ts | 92 +++-- apps/tester/src/runtime.ts | 7 +- apps/tester/tsconfig.build.json | 9 + apps/tester/vitest.config.mts | 8 + package.json | 1 + packages/node-utils/.gitignore | 3 + packages/node-utils/README.md | 11 + packages/node-utils/package.json | 48 +++ packages/node-utils/src/index.test.ts | 147 +++++++ packages/node-utils/src/index.ts | 124 ++++++ packages/node-utils/tsconfig.json | 12 + packages/node-utils/vitest.config.mts | 3 + pnpm-lock.yaml | 34 ++ 41 files changed, 1539 insertions(+), 823 deletions(-) create mode 100644 apps/interface/src/Connector.test.ts create mode 100644 apps/interface/src/connectorQueryKey.ts create mode 100644 apps/interface/src/utils.test.ts create mode 100644 apps/sampler/.gitignore create mode 100644 apps/sampler/src/index.test.ts create mode 100644 apps/sampler/tsconfig.build.json create mode 100644 apps/tester/src/freshMatchableOrderSkip.ts create mode 100644 apps/tester/tsconfig.build.json create mode 100644 packages/node-utils/.gitignore create mode 100644 packages/node-utils/README.md create mode 100644 packages/node-utils/package.json create mode 100644 packages/node-utils/src/index.test.ts create mode 100644 packages/node-utils/src/index.ts create mode 100644 packages/node-utils/tsconfig.json create mode 100644 packages/node-utils/vitest.config.mts diff --git a/README.md b/README.md index f6b49c1..21236b5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,27 @@ Stack cell scans that feed account state, pool state, order books, or maturity e Current stack flows assume user-owned cells are protected by locks whose signatures bind the whole transaction, such as standard `sighash` wallet flows. Passing a raw `ccc.Script` is only safe when that lock gives the same output and recipient binding. Delegated-signature or OTX-style locks are integration-specific and must account for the weak-lock boundary documented in the iCKB whitepaper and contracts audit. +## Workspace Map + +Apps: + +- `apps/bot`: Node order-fulfillment and rebalance bot for matching profitable orders, collecting owned orders, completing receipts and withdrawals, and rebalancing pool exposure. +- `apps/interface`: Browser interface for CCC wallet connection, conversion previews, transaction completion, signing, sending, and confirmation. +- `apps/sampler`: Mainnet sampling utility that writes historical iCKB exchange-rate CSV output. +- `apps/tester`: Node simulator that creates random conversion orders to exercise the order and conversion flows. + +The Node app packages (`@ickb/bot`, `@ickb/sampler`, and `@ickb/tester`) publish their built entrypoints for distribution, but the supported reusable API surface lives in the packages below. `@ickb/interface` is a deployable browser app package and does not expose a library entrypoint. + +Packages: + +- `packages/core`: iCKB protocol primitives, cells, UDT conversion helpers, and low-level transaction builders. +- `packages/dao`: Nervos DAO cell classification, readiness, deposit, request, and withdrawal helpers. +- `packages/node-utils`: Private Node app utilities for env parsing, RPC client setup, signer locks, sleeps, and JSON logs. +- `packages/order`: UDT limit-order entities, grouping, matching, minting, melting, and deployed-script confusion mitigation. +- `packages/sdk`: Stack-level SDK that composes core, DAO, and order packages into account state, conversion planning, completion, sending, and confirmation helpers. +- `packages/testkit`: Private test helpers and fixtures for workspace tests. +- `packages/utils`: Shared low-level utilities such as complete-scan enforcement, binary search, collection helpers, and bounded subset selection. + ## Local CCC Workflow The shared CCC baseline lives in `forks/ccc/pin/` and materializes into `forks/ccc/repo/`. diff --git a/apps/interface/README.md b/apps/interface/README.md index 05c8c89..03d085b 100644 --- a/apps/interface/README.md +++ b/apps/interface/README.md @@ -40,7 +40,11 @@ pnpm --filter ./apps/interface build Like `dev`, the build script refreshes those workspace package `dist/` outputs first so a clean checkout does not rely on stale generated files. -The interface now uses CCC-native wallet connection and transaction completion. Protocol-specific transaction construction comes from `@ickb/sdk`, then the app completes iCKB UDT balance, CKB capacity, and fees before sending. +The interface now uses CCC-native wallet connection and transaction completion. Protocol-specific conversion planning and partial transaction construction come from `@ickb/sdk`; the app maps domain results to UI copy, calls `sdk.completeTransaction(...)`, and then sends. + +## Small iCKB Balances + +For iCKB-to-CKB requests below the normal order preview threshold, the interface automatically builds a discounted dust order instead of adding another confirmation step. The preview shows the tiny iCKB input, approximate CKB output, and matcher incentive inline before the normal wallet signature. This path is useful when the user mainly wants to recover CKB capacity locked in an iCKB xUDT cell; the user accepts or rejects the exact terms by signing or cancelling the transaction. ## Licensing diff --git a/apps/interface/package.json b/apps/interface/package.json index 775db34..30063fe 100644 --- a/apps/interface/package.json +++ b/apps/interface/package.json @@ -22,8 +22,8 @@ "type": "module", "private": false, "scripts": { - "dev": "pnpm --filter @ickb/core --filter @ickb/order --filter @ickb/sdk --filter @ickb/utils build && vite", - "build": "pnpm --filter @ickb/core --filter @ickb/order --filter @ickb/sdk --filter @ickb/utils build && tsc && vite build", + "dev": "pnpm --filter @ickb/core --filter @ickb/dao --filter @ickb/order --filter @ickb/sdk --filter @ickb/utils build && vite", + "build": "pnpm --filter @ickb/core --filter @ickb/dao --filter @ickb/order --filter @ickb/sdk --filter @ickb/utils build && tsc && vite build", "preview": "vite preview", "test": "vitest", "test:ci": "vitest run", @@ -32,6 +32,7 @@ "clean:deep": "rm -fr dist node_modules" }, "devDependencies": { + "@ickb/testkit": "workspace:*", "@babel/preset-react": "^7.27.1", "@tailwindcss/vite": "^4.1.14", "@types/node": "^22.18.11", @@ -47,6 +48,7 @@ "dependencies": { "@ckb-ccc/ccc": "catalog:", "@ickb/core": "workspace:*", + "@ickb/dao": "workspace:*", "@ickb/order": "workspace:*", "@ickb/sdk": "workspace:*", "@ickb/utils": "workspace:*", diff --git a/apps/interface/src/Action.tsx b/apps/interface/src/Action.tsx index 3e4c230..514cd56 100644 --- a/apps/interface/src/Action.tsx +++ b/apps/interface/src/Action.tsx @@ -71,6 +71,9 @@ export default function Action({ txInfo.estimatedMaturity, l1State.tipTimestamp, ); + const shownMaturity = txInfo.conversionNotice?.maturityEstimateUnavailable + ? "Waiting for CKB liquidity" + : maturity; return ( @@ -106,10 +109,36 @@ export default function Action({ {failure !== "" ? {failure} : null} + {txInfo.conversionNotice ? : null} Fee: {toText(txInfo.fee)} CKB Maturity: - {maturity} + {shownMaturity} + + ); +} + +function DustConversionNotice({ + notice, +}: { + notice: NonNullable; +}): JSX.Element { + if (notice.kind === "maturity-unavailable") { + return ( + + Conversion terms: this order converts {toText(notice.inputIckb)} iCKB to + about {toText(notice.outputCkb)} CKB with {toText(notice.incentiveCkb)} + CKB matcher incentive. The current pool state does not provide a maturity estimate yet. + + ); + } + + return ( + + Small-balance conversion: this discounted order converts{" "} + {toText(notice.inputIckb)} iCKB to about {toText(notice.outputCkb)} CKB + with {toText(notice.incentiveCkb)} CKB matcher incentive, helping recover + locked iCKB cell capacity after the order is fulfilled or collected. ); } diff --git a/apps/interface/src/Connector.test.ts b/apps/interface/src/Connector.test.ts new file mode 100644 index 0000000..af0bbce --- /dev/null +++ b/apps/interface/src/Connector.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { connectorWalletConfigQueryKey } from "./connectorQueryKey.ts"; +import type { RootConfig } from "./utils.ts"; + +describe("connectorWalletConfigQueryKey", () => { + it("keys wallet config by root config identity, wallet, and signer object", () => { + const rootConfig = { + chain: "testnet", + cccClient: {}, + queryClient: {}, + sdk: {}, + } as RootConfig; + const signerA = 1; + const signerB = 2; + const key = connectorWalletConfigQueryKey(rootConfig, "JoyID", signerA, 0); + + expect(key[0]).toBe("testnet"); + expect(key.slice(4)).toEqual(["JoyID", signerA, 0, "walletConfig"]); + expect(connectorWalletConfigQueryKey(rootConfig, "JoyID", signerB, 0)).not.toEqual( + key, + ); + expect(connectorWalletConfigQueryKey(rootConfig, "JoyID", signerA, 1)).not.toEqual( + key, + ); + expect(connectorWalletConfigQueryKey({ + ...rootConfig, + sdk: {} as RootConfig["sdk"], + }, "JoyID", signerA, 0)).not.toEqual( + key, + ); + }); +}); diff --git a/apps/interface/src/Connector.tsx b/apps/interface/src/Connector.tsx index 91054ee..59d1d35 100644 --- a/apps/interface/src/Connector.tsx +++ b/apps/interface/src/Connector.tsx @@ -1,9 +1,15 @@ import { useQuery } from "@tanstack/react-query"; import { ccc } from "@ckb-ccc/ccc"; +import { unique } from "@ickb/utils"; +import { useEffect, useState } from "react"; import type { JSX } from "react/jsx-runtime"; import App from "./App.tsx"; import { EmptyDashboard } from "./Dashboard.tsx"; import Progress from "./Progress.tsx"; +import { + connectorWalletConfigQueryKey, + objectIdentityKey, +} from "./connectorQueryKey.ts"; import { errorMessageOf, type RootConfig } from "./utils.ts"; export default function Connector({ @@ -15,12 +21,25 @@ export default function Connector({ signer: ccc.Signer; walletName: string; }): JSX.Element { + const signerKey = objectIdentityKey(signer); + const [signerVersion, setSignerVersion] = useState(0); + useEffect( + () => signer.onReplaced(() => { + setSignerVersion((version) => version + 1); + }), + [signer], + ); const { isPending, error, data: walletConfig, } = useQuery({ - queryKey: [rootConfig.chain, "walletConfig"], + queryKey: connectorWalletConfigQueryKey( + rootConfig, + walletName, + signerKey, + signerVersion, + ), queryFn: async () => { if (!(await signer.isConnected())) { await signer.connect(); @@ -31,14 +50,9 @@ export default function Connector({ signer.getAddressObjs(), ]); - let accountLocks = [recommendedAddressObj, ...addressObjs].map(({ script }) => + const accountLocks = [...unique([recommendedAddressObj, ...addressObjs].map(({ script }) => ccc.Script.from(script), - ); - - // Keep unique account locks, preferred one is the first one. - accountLocks = [ - ...new Map(accountLocks.map((script) => [script.toHex(), script])).values(), - ]; + ))]; return { ...rootConfig, diff --git a/apps/interface/src/connectorQueryKey.ts b/apps/interface/src/connectorQueryKey.ts new file mode 100644 index 0000000..b97cad0 --- /dev/null +++ b/apps/interface/src/connectorQueryKey.ts @@ -0,0 +1,34 @@ +import type { RootConfig } from "./utils.ts"; + +export function connectorWalletConfigQueryKey( + rootConfig: RootConfig, + walletName: string, + signerKey: number, + signerVersion: number, +): readonly [RootConfig["chain"], number, number, number, string, number, number, "walletConfig"] { + return [ + rootConfig.chain, + objectIdentityKey(rootConfig.cccClient), + objectIdentityKey(rootConfig.queryClient), + objectIdentityKey(rootConfig.sdk), + walletName, + signerKey, + signerVersion, + "walletConfig", + ] as const; +} + +let nextObjectKey = 1; +const objectKeys = new WeakMap(); + +export function objectIdentityKey(value: object): number { + const existing = objectKeys.get(value); + if (existing !== undefined) { + return existing; + } + + const key = nextObjectKey; + nextObjectKey += 1; + objectKeys.set(value, key); + return key; +} diff --git a/apps/interface/src/main.tsx b/apps/interface/src/main.tsx index 3a4b905..aca8747 100644 --- a/apps/interface/src/main.tsx +++ b/apps/interface/src/main.tsx @@ -4,14 +4,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ccc, JoyId } from "@ckb-ccc/ccc"; import { getConfig, IckbSdk } from "@ickb/sdk"; import Connector from "./Connector.tsx"; -import type { RootConfig } from "./utils.ts"; +import { parseWalletChain, type RootConfig } from "./utils.ts"; import appIcon from "/favicon.png?url"; const appName = "iCKB DApp"; function createRootConfig(chain: "mainnet" | "testnet"): RootConfig { const config = getConfig(chain); - const { managers } = config; return { chain, @@ -21,12 +20,6 @@ function createRootConfig(chain: "mainnet" | "testnet"): RootConfig { ? new ccc.ClientPublicMainnet() : new ccc.ClientPublicTestnet(), sdk: IckbSdk.fromConfig(config), - managers: { - ickbUdt: managers.ickbUdt, - logic: managers.logic, - ownedOwner: managers.ownedOwner, - order: managers.order, - }, }; } @@ -36,8 +29,8 @@ const rootConfigs = { }; export function startApp(walletChain: string): void { - const [walletName, chain] = walletChain.split("_"); - const rootConfig = chain === "mainnet" ? rootConfigs.mainnet : rootConfigs.testnet; + const { walletName, chain } = parseWalletChain(walletChain); + const rootConfig = rootConfigs[chain]; const signerInfo = JoyId.getJoyIdSigners( rootConfig.cccClient, diff --git a/apps/interface/src/queries.test.ts b/apps/interface/src/queries.test.ts index ac252ed..b97a81b 100644 --- a/apps/interface/src/queries.test.ts +++ b/apps/interface/src/queries.test.ts @@ -1,16 +1,10 @@ import { ccc } from "@ckb-ccc/ccc"; import { Ratio, type OrderGroup } from "@ickb/order"; +import { byte32FromByte } from "@ickb/testkit"; import { describe, expect, it } from "vitest"; -import { getL1State } from "./queries.ts"; +import { getL1State, l1StateOptions, l1StateQueryKey } from "./queries.ts"; import type { WalletConfig } from "./utils.ts"; -function byte32FromByte(hexByte: string): `0x${string}` { - if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { - throw new Error("Expected exactly one byte as two hex chars"); - } - return `0x${hexByte.repeat(32)}`; -} - function script(codeHashByte: string): ccc.Script { return ccc.Script.from({ codeHash: byte32FromByte(codeHashByte), @@ -45,6 +39,41 @@ function orderGroup( } describe("getL1State", () => { + it("keys L1 state by account locks as well as address", () => { + const primaryLock = script("11"); + const firstAccountLock = script("22"); + const secondAccountLock = script("33"); + const walletConfig = { + chain: "testnet", + address: "ckt1same", + primaryLock, + accountLocks: [firstAccountLock], + } as WalletConfig; + + expect(l1StateQueryKey(walletConfig)).toEqual([ + "testnet", + "ckt1same", + `primary=${primaryLock.toHex()};accounts=${firstAccountLock.toHex()}`, + "l1State", + ]); + expect(l1StateQueryKey({ + ...walletConfig, + accountLocks: [secondAccountLock], + })).not.toEqual(l1StateQueryKey(walletConfig)); + }); + + it("disables live state polling while a transaction is frozen", () => { + const walletConfig = { + chain: "testnet", + address: "ckt1same", + primaryLock: script("11"), + accountLocks: [script("22")], + } as WalletConfig; + + expect(l1StateOptions(walletConfig, false).enabled).toBe(true); + expect(l1StateOptions(walletConfig, true).enabled).toBe(false); + }); + it("projects account state through the SDK and makes collected orders available", async () => { const lock = script("11"); const tip = { timestamp: 10n } as ccc.ClientBlockHeader; @@ -74,7 +103,7 @@ describe("getL1State", () => { accountLocks: [lock], primaryLock: lock, sdk: { - getL1State: async () => { + getL1AccountState: async () => { await Promise.resolve(); return { system: { @@ -86,20 +115,17 @@ describe("getL1State", () => { ckbMaturing: [], }, user: { orders: [availableOrder, pendingOrder] }, - }; - }, - getAccountState: async () => { - await Promise.resolve(); - return { - capacityCells: [cell(nativeCapacity, lock)], - nativeUdtCapacity: 7n, - nativeUdtBalance: 11n, - receipts: [receipt], - withdrawalGroups: [readyWithdrawal, pendingWithdrawal], + account: { + capacityCells: [cell(nativeCapacity, lock)], + nativeUdtCells: [], + nativeUdtCapacity: 7n, + nativeUdtBalance: 11n, + receipts: [receipt], + withdrawalGroups: [readyWithdrawal, pendingWithdrawal], + }, }; }, } as unknown as WalletConfig["sdk"], - managers: {} as WalletConfig["managers"], }; const state = await getL1State(walletConfig); @@ -110,7 +136,89 @@ describe("getL1State", () => { 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"); + expect(state.hasMatchable).toBe(true); + expect(state.stateId).toBe([ + "chain=testnet", + `locks=primary=${lock.toHex()};accounts=${lock.toHex()}`, + "tip=missing-tip.hash/missing-tip.number/10", + "fee=1", + "ratio=1/1", + "pool=0;;;deposits=", + `balances=${String(nativeCapacity + 142n)}/248`, + `capacityCells=${cell(nativeCapacity, lock).outPoint.toHex()}`, + "nativeUdtCells=", + "maturity=60", + "receipts=13/17@missing-outpoint", + "readyWithdrawals=19/0@missing-outpoint@missing-outpoint", + "availableOrders=10/20@missing-outpoint@missing-outpoint@missing-outpoint,100/200@missing-outpoint@missing-outpoint@missing-outpoint", + "pendingWithdrawals=31/0@missing-outpoint@missing-outpoint", + "pendingOrders=", + ].join("|")); + }); + + it("changes stateId when transaction-preview inputs change without count changes", async () => { + const lock = script("11"); + const tip = { timestamp: 10n } as ccc.ClientBlockHeader; + const stateIdFor = async (options?: { + feeRate?: bigint; + exchangeRatio?: Ratio; + nativeCapacity?: bigint; + nativeCapacityTxHashByte?: string; + nativeUdtTxHashByte?: string; + ckbMaturing?: { ckbCumulative: bigint; maturity: bigint }[]; + }): Promise => { + const walletConfig: WalletConfig = { + chain: "testnet", + cccClient: {} as ccc.Client, + queryClient: {} as WalletConfig["queryClient"], + signer: {} as ccc.Signer, + address: "ckt1test", + accountLocks: [lock], + primaryLock: lock, + sdk: { + getL1AccountState: async () => { + await Promise.resolve(); + const capacityCell = cell(options?.nativeCapacity ?? ccc.fixedPointFrom(100), lock); + capacityCell.outPoint.txHash = byte32FromByte(options?.nativeCapacityTxHashByte ?? "aa"); + const nativeUdtCell = cell(1n, lock); + nativeUdtCell.outPoint.txHash = byte32FromByte(options?.nativeUdtTxHashByte ?? "bb"); + return { + system: { + feeRate: options?.feeRate ?? 1n, + tip, + exchangeRatio: options?.exchangeRatio ?? Ratio.from({ ckbScale: 1n, udtScale: 1n }), + orderPool: [], + ckbAvailable: 0n, + ckbMaturing: options?.ckbMaturing ?? [], + }, + user: { orders: [] }, + account: { + capacityCells: [capacityCell], + nativeUdtCells: [nativeUdtCell], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + }; + }, + } as unknown as WalletConfig["sdk"], + }; + + return (await getL1State(walletConfig)).stateId; + }; + + const base = await stateIdFor(); + + await expect(stateIdFor({ feeRate: 2n })).resolves.not.toBe(base); + await expect(stateIdFor({ + exchangeRatio: Ratio.from({ ckbScale: 2n, udtScale: 1n }), + })).resolves.not.toBe(base); + await expect(stateIdFor({ nativeCapacity: ccc.fixedPointFrom(101) })).resolves.not.toBe(base); + await expect(stateIdFor({ nativeCapacityTxHashByte: "ab" })).resolves.not.toBe(base); + await expect(stateIdFor({ nativeUdtTxHashByte: "bc" })).resolves.not.toBe(base); + await expect(stateIdFor({ + ckbMaturing: [{ ckbCumulative: 1n, maturity: 20n }], + })).resolves.not.toBe(base); }); }); diff --git a/apps/interface/src/queries.ts b/apps/interface/src/queries.ts index 36b8c1d..3b0d9ed 100644 --- a/apps/interface/src/queries.ts +++ b/apps/interface/src/queries.ts @@ -8,6 +8,32 @@ import { } from "./transaction.ts"; import { type TxInfo, type WalletConfig } from "./utils.ts"; +interface StateValue { + ckbValue: bigint; + udtValue: bigint; +} +interface OutPointLike { + toHex?: () => string; + txHash?: unknown; + index?: unknown; +} +interface CellLike { + outPoint?: OutPointLike; +} +interface ReceiptState extends StateValue { + cell?: CellLike; +} +interface WithdrawalState extends StateValue { + owned?: { cell?: CellLike }; + owner?: { cell?: CellLike }; +} +interface OrderState extends StateValue { + cell?: CellLike; + order?: { cell?: CellLike }; + master?: { cell?: CellLike }; + origin?: { cell?: CellLike }; +} + export interface L1StateType { ckbNative: bigint; ickbNative: bigint; @@ -24,47 +50,48 @@ export interface L1StateType { export function l1StateQueryKey( walletConfig: WalletConfig, -): readonly [WalletConfig["chain"], string, "l1State"] { - return [walletConfig.chain, walletConfig.address, "l1State"] as const; +): readonly [WalletConfig["chain"], string, string, "l1State"] { + return [ + walletConfig.chain, + walletConfig.address, + walletLocksKey(walletConfig), + "l1State", + ] as const; } export function l1StateOptions( walletConfig: WalletConfig, isFrozen: boolean, ): { + enabled: boolean; retry: number; refetchInterval: (context: { state: { data?: L1StateType } }) => number; staleTime: number; - queryKey: readonly [WalletConfig["chain"], string, "l1State"]; + queryKey: readonly [WalletConfig["chain"], string, string, "l1State"]; queryFn: () => Promise; - enabled: boolean; } { return { + enabled: !isFrozen, retry: 2, refetchInterval: ({ state }) => 60000 * (state.data?.hasMatchable ? 1 : 10), staleTime: 10000, queryKey: l1StateQueryKey(walletConfig), queryFn: async () => await getL1State(walletConfig), - enabled: !isFrozen, }; } export async function getL1State( walletConfig: WalletConfig, ): Promise { - const sdkState = await walletConfig.sdk.getL1State( + const sdkState = await walletConfig.sdk.getL1AccountState( walletConfig.cccClient, walletConfig.accountLocks, ); - const { system, user } = sdkState; - const account = await walletConfig.sdk.getAccountState( - walletConfig.cccClient, - walletConfig.accountLocks, - system.tip, - ); + const { system, user, account } = sdkState; const projection = projectAccountAvailability(account, user.orders, { collectedOrdersAvailable: true, }); + const hasMatchable = user.orders.some((group) => group.order.isMatchable()); const { ckbNative, ickbNative, @@ -88,6 +115,8 @@ export async function getL1State( const txContext: TransactionContext = { system, + capacityCells: account.capacityCells, + nativeUdtCells: account.nativeUdtCells, receipts: account.receipts, readyWithdrawals, availableOrders, @@ -105,17 +134,119 @@ export async function getL1State( ickbAvailable, tipTimestamp: system.tip.timestamp, system, - stateId: [ - walletConfig.chain, - String(system.tip.timestamp), - String(account.receipts.length), - String(readyWithdrawals.length), - String(pendingWithdrawals.length), - String(availableOrders.length), - String(pendingOrders.length), - ].join(":"), + stateId: buildStateId(walletConfig, txContext, pendingWithdrawals, pendingOrders), txBuilder: (isCkb2Udt, amount) => buildTransactionPreview(txContext, isCkb2Udt, amount, walletConfig), - hasMatchable: pendingOrders.length > 0, + hasMatchable, }; } + +function buildStateId( + walletConfig: WalletConfig, + context: TransactionContext, + pendingWithdrawals: readonly WithdrawalState[], + pendingOrders: readonly OrderState[], +): string { + const { system } = context; + return [ + `chain=${walletConfig.chain}`, + `locks=${walletLocksKey(walletConfig)}`, + `tip=${tipKey(system.tip)}`, + `fee=${String(system.feeRate)}`, + `ratio=${String(system.exchangeRatio.ckbScale)}/${String(system.exchangeRatio.udtScale)}`, + `pool=${String(system.ckbAvailable)};${system.ckbMaturing.map(maturingKey).join(",")};${orderCellsKey(system.orderPool)};deposits=${system.poolDeposits?.id ?? ""}`, + `balances=${String(context.ckbAvailable)}/${String(context.ickbAvailable)}`, + `capacityCells=${cellsKey(context.capacityCells)}`, + `nativeUdtCells=${cellsKey(context.nativeUdtCells)}`, + `maturity=${String(context.estimatedMaturity)}`, + `receipts=${receiptsKey(context.receipts)}`, + `readyWithdrawals=${withdrawalsKey(context.readyWithdrawals)}`, + `availableOrders=${ordersKey(context.availableOrders)}`, + `pendingWithdrawals=${withdrawalsKey(pendingWithdrawals)}`, + `pendingOrders=${ordersKey(pendingOrders)}`, + ].join("|"); +} + +function walletLocksKey(walletConfig: WalletConfig): string { + return `primary=${walletConfig.primaryLock.toHex()};accounts=${scriptsKey(walletConfig.accountLocks)}`; +} + +function scriptsKey(scripts: readonly { toHex: () => string }[]): string { + return [...new Set(scripts.map((script) => script.toHex()))].sort().join(","); +} + +function tipKey(tip: SystemState["tip"]): string { + return `${primitiveKey(tip.hash, "tip.hash")}/${primitiveKey(tip.number, "tip.number")}/${primitiveKey(tip.timestamp, "tip.timestamp")}`; +} + +function maturingKey(item: SystemState["ckbMaturing"][number]): string { + return `${String(item.ckbCumulative)}@${String(item.maturity)}`; +} + +function valueKey(item: StateValue): string { + return `${String(item.ckbValue)}/${String(item.udtValue)}`; +} + +function outPointKey(outPoint: OutPointLike | undefined): string { + if (!outPoint) { + return "missing-outpoint"; + } + if (outPoint.toHex) { + return outPoint.toHex(); + } + return `${primitiveKey(outPoint.txHash, "outpoint.txHash")}#${primitiveKey(outPoint.index, "outpoint.index")}`; +} + +function primitiveKey(value: unknown, label: string): string { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "bigint" || + typeof value === "boolean" + ) { + return String(value); + } + + if (value === undefined || value === null) { + return `missing-${label}`; + } + + return `invalid-${label}`; +} + +function cellKey(cell: CellLike | undefined): string { + return outPointKey(cell?.outPoint); +} + +function receiptsKey(receipts: readonly ReceiptState[]): string { + return receipts.map((receipt) => `${valueKey(receipt)}@${cellKey(receipt.cell)}`).join(","); +} + +function cellsKey(cells: readonly CellLike[]): string { + return cells.map(cellKey).join(","); +} + +function withdrawalsKey(withdrawals: readonly WithdrawalState[]): string { + return withdrawals + .map((withdrawal) => [ + valueKey(withdrawal), + cellKey(withdrawal.owned?.cell), + cellKey(withdrawal.owner?.cell), + ].join("@")) + .join(","); +} + +function orderCellsKey(orders: readonly OrderState[]): string { + return orders.map((order) => `${valueKey(order)}@${cellKey(order.cell)}`).join(","); +} + +function ordersKey(orders: readonly OrderState[]): string { + return orders + .map((order) => [ + valueKey(order), + cellKey(order.order?.cell), + cellKey(order.master?.cell), + cellKey(order.origin?.cell), + ].join("@")) + .join(","); +} diff --git a/apps/interface/src/transaction.test.ts b/apps/interface/src/transaction.test.ts index 0c58f31..861fc74 100644 --- a/apps/interface/src/transaction.test.ts +++ b/apps/interface/src/transaction.test.ts @@ -1,19 +1,27 @@ import { ccc } from "@ckb-ccc/ccc"; -import { Ratio, type OrderGroup } from "@ickb/order"; -import { describe, expect, it, vi } from "vitest"; -import { - buildTransactionPreview, - selectExactCountReadyDepositsUnderAmount, -} from "./transaction.ts"; +import { Ratio } from "@ickb/order"; +import type { + ConversionTransactionFailureReason, + ConversionTransactionResult, +} from "@ickb/sdk"; +import { byte32FromByte } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildTransactionPreview } from "./transaction.ts"; import type { TransactionContext } from "./transaction.ts"; import type { WalletConfig } from "./utils.ts"; -function byte32FromByte(hexByte: string): `0x${string}` { - if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { - throw new Error("Expected exactly one byte as two hex chars"); - } - return `0x${hexByte.repeat(32)}`; -} +type BuildConversionTransactionMock = ReturnType< + typeof vi.fn +>; +type CompleteTransactionMock = ReturnType< + typeof vi.fn +>; +type SuccessfulPlan = Extract; +type FailedPlan = Extract; + +afterEach(() => { + vi.restoreAllMocks(); +}); function script(codeHashByte: string): ccc.Script { return ccc.Script.from({ @@ -33,6 +41,8 @@ function context(overrides: Partial = {}): TransactionContex ckbAvailable: 0n, ckbMaturing: [], }, + capacityCells: [], + nativeUdtCells: [], receipts: [], readyWithdrawals: [], availableOrders: [], @@ -43,28 +53,48 @@ function context(overrides: Partial = {}): TransactionContex }; } -function identityTx(txLike: ccc.TransactionLike): ccc.Transaction { - return ccc.Transaction.from(txLike); -} - function resolvedTx(txLike: ccc.TransactionLike): Promise { return Promise.resolve(ccc.Transaction.from(txLike)); } -function emptyDeposits(): AsyncGenerator { - return (async function* (): AsyncGenerator { - await Promise.resolve(); - yield* [] as never[]; - })(); +function completeTransactionMock(): CompleteTransactionMock { + return vi.fn().mockImplementation(resolvedTx); } -function deposits(...values: T[]): AsyncGenerator { - return (async function* (): AsyncGenerator { - await Promise.resolve(); - for (const value of values) { - yield value; - } - })(); +function txWithInput(txHashByte: string): ccc.Transaction { + const tx = ccc.Transaction.default(); + tx.inputs.push( + ccc.CellInput.from({ + previousOutput: { + txHash: byte32FromByte(txHashByte), + index: 0n, + }, + }), + ); + return tx; +} + +function successfulPlan(overrides: Partial = {}): SuccessfulPlan { + return { + ok: true, + tx: txWithInput("aa"), + estimatedMaturity: 0n, + conversion: { kind: "order" }, + ...overrides, + }; +} + +function failedPlan( + reason: ConversionTransactionFailureReason, + estimatedMaturity = 0n, +): FailedPlan { + return { ok: false, reason, estimatedMaturity }; +} + +function buildConversionTransactionMock( + result: ConversionTransactionResult = successfulPlan(), +): BuildConversionTransactionMock { + return vi.fn().mockResolvedValue(result); } function walletConfig(overrides: Partial = {}): WalletConfig { @@ -77,147 +107,121 @@ function walletConfig(overrides: Partial = {}): WalletConfig { accountLocks: [], primaryLock: script("11"), sdk: { - buildBaseTransaction: resolvedTx, + buildConversionTransaction: buildConversionTransactionMock(), completeTransaction: resolvedTx, - collect: identityTx, - request: resolvedTx, } as unknown as WalletConfig["sdk"], - managers: { - ickbUdt: { - completeBy: resolvedTx, - } as unknown as WalletConfig["managers"]["ickbUdt"], - logic: { - completeDeposit: identityTx, - deposit: resolvedTx, - findDeposits: emptyDeposits, - } as unknown as WalletConfig["managers"]["logic"], - ownedOwner: { - withdraw: resolvedTx, - requestWithdrawal: resolvedTx, - } as unknown as WalletConfig["managers"]["ownedOwner"], - order: {} as WalletConfig["managers"]["order"], - }, ...overrides, }; } -describe("selectExactCountReadyDepositsUnderAmount", () => { - it("finds an exact-count subset when the greedy maturity path fails", () => { - const deposits = [{ udtValue: 6n }, { udtValue: 5n }, { udtValue: 5n }]; - - expect(selectExactCountReadyDepositsUnderAmount(deposits as never[], 2, 10n)).toEqual([ - deposits[1], - deposits[2], - ]); - }); - - it("prefers the fullest exact-count subset under the cap", () => { - const deposits = [{ udtValue: 1n }, { udtValue: 4n }, { udtValue: 5n }]; +interface WalletConfigTestOverrides { + sdk?: Partial; +} - expect(selectExactCountReadyDepositsUnderAmount(deposits as never[], 2, 10n)).toEqual([ - deposits[1], - deposits[2], - ]); +function walletConfigWith(overrides: WalletConfigTestOverrides): WalletConfig { + const base = walletConfig(); + return walletConfig({ + sdk: Object.assign({}, base.sdk, overrides.sdk), }); +} - it("keeps earlier deposits when equally full subsets tie", () => { - const deposits = [{ udtValue: 5n }, { udtValue: 5n }, { udtValue: 5n }]; - - expect(selectExactCountReadyDepositsUnderAmount(deposits as never[], 2, 10n)).toEqual([ - deposits[0], - deposits[1], - ]); +describe("buildTransactionPreview", () => { + it("validates user amounts before delegating to the SDK planner", async () => { + const buildConversionTransaction = buildConversionTransactionMock(); + const config = walletConfigWith({ sdk: { buildConversionTransaction } }); + + await expect(buildTransactionPreview(context(), true, -1n, config)) + .resolves.toMatchObject({ error: "Amount must be positive" }); + await expect(buildTransactionPreview(context({ ckbAvailable: 1n }), true, 2n, config)) + .resolves.toMatchObject({ error: "Not enough CKB" }); + await expect(buildTransactionPreview(context({ ickbAvailable: 1n }), false, 2n, config)) + .resolves.toMatchObject({ error: "Not enough iCKB" }); + expect(buildConversionTransaction).not.toHaveBeenCalled(); }); - it("bounds the search to the direct-withdrawal preview cap", () => { - const deposits = [ - ...Array.from({ length: 30 }, () => ({ udtValue: 6n })), - { udtValue: 5n }, - { udtValue: 5n }, - ]; - - expect(selectExactCountReadyDepositsUnderAmount(deposits as never[], 2, 10n)).toEqual([]); - }); + it("delegates protocol planning to the SDK and completes the partial transaction", async () => { + const tx = txWithInput("99"); + const notice = { + kind: "dust-ickb-to-ckb" as const, + inputIckb: 1n, + outputCkb: 1n, + incentiveCkb: 0n, + maturityEstimateUnavailable: false, + }; + const buildConversionTransaction = buildConversionTransactionMock(successfulPlan({ + tx, + estimatedMaturity: 123n, + conversionNotice: notice, + })); + const completeTransaction = completeTransactionMock(); + vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(42n); + const txContext = context({ + ckbAvailable: 7n, + system: { ...context().system, feeRate: 9n }, + }); + const config = walletConfigWith({ + sdk: { buildConversionTransaction, completeTransaction }, + }); - it("returns no subset when no exact-count fit exists", () => { - const deposits = [{ udtValue: 6n }, { udtValue: 5n }, { udtValue: 5n }]; + const txInfo = await buildTransactionPreview(txContext, true, 7n, config); - expect(selectExactCountReadyDepositsUnderAmount(deposits as never[], 2, 9n)).toEqual([]); + expect(buildConversionTransaction).toHaveBeenCalledTimes(1); + expect(buildConversionTransaction.mock.calls[0]?.[0]).toBeInstanceOf(ccc.Transaction); + expect(buildConversionTransaction.mock.calls[0]?.[1]).toBe(config.cccClient); + expect(buildConversionTransaction.mock.calls[0]?.[2]).toEqual({ + direction: "ckb-to-ickb", + amount: 7n, + lock: config.primaryLock, + context: txContext, + }); + expect(completeTransaction).toHaveBeenCalledWith(tx, { + signer: config.signer, + client: config.cccClient, + feeRate: 9n, + }); + expect(txInfo).toMatchObject({ + error: "", + fee: 42n, + estimatedMaturity: 123n, + conversionNotice: notice, + }); }); -}); -describe("buildTransactionPreview", () => { - it("reports the preview threshold instead of a generic build failure", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - vi.spyOn(ccc.Transaction.prototype, "completeFeeBy").mockResolvedValue([0, false]); - vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(0n); + it("delegates iCKB-to-CKB direction without leaking app managers", async () => { + const buildConversionTransaction = buildConversionTransactionMock(); + const config = walletConfigWith({ sdk: { buildConversionTransaction } }); + vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(1n); - const txInfo = await buildTransactionPreview( - context({ ckbAvailable: 1n }), - true, - 1n, - walletConfig(), - ); + await buildTransactionPreview(context({ ickbAvailable: 5n }), false, 5n, config); - expect(txInfo.error).toBe( - "Amount too small to exceed the minimum match and fee threshold", - ); + expect(buildConversionTransaction.mock.calls[0]?.[2]).toMatchObject({ + direction: "ickb-to-ckb", + amount: 5n, + }); }); - 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); - }); + it("maps SDK planner failures to interface copy", async () => { + const cases: [ConversionTransactionFailureReason, string][] = [ + ["amount-too-small", "Amount too small to exceed the minimum match and fee threshold"], + ["not-enough-ready-deposits", "Not enough ready deposits to convert now"], + ["nothing-to-do", "Nothing to do for now"], + ["amount-negative", "Amount must be positive"], + ["insufficient-ckb", "Not enough CKB"], + ["insufficient-ickb", "Not enough iCKB"], + ]; - 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"], - }), - ); + for (const [reason, message] of cases) { + const config = walletConfigWith({ + sdk: { buildConversionTransaction: buildConversionTransactionMock(failedPlan(reason, 77n)) }, + }); - expect(completeTransaction.mock.calls[0]?.[1]).toEqual({ - signer: walletConfig().signer, - client: walletConfig().cccClient, - feeRate: 42n, - }); + await expect(buildTransactionPreview(context({ ckbAvailable: 1n }), true, 1n, config)) + .resolves.toMatchObject({ error: message, estimatedMaturity: 77n }); + } }); 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(() => { @@ -235,130 +239,42 @@ describe("buildTransactionPreview", () => { await Promise.resolve(); return ccc.Transaction.from(txLike); }); + vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(1n); await buildTransactionPreview( - context({ availableOrders: [{} as OrderGroup] }), + context({ ckbAvailable: 1n }), 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"], - }), + 1n, + walletConfigWith({ + sdk: { completeTransaction }, }), ); 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: [], + it("surfaces planner and completion failures as TxInfo errors", async () => { + const plannerFailure = walletConfigWith({ + sdk: { + buildConversionTransaction: vi + .fn() + .mockRejectedValue(new Error("planner failed")), + }, }); - expect(buildBaseTransaction.mock.calls[1]?.[2]).toEqual({ - withdrawalRequest: { - deposits: [readyDeposit], - lock: script("11"), + await expect(buildTransactionPreview(context({ ckbAvailable: 1n }), true, 1n, plannerFailure)) + .resolves.toMatchObject({ error: "planner failed" }); + + const completionFailure = walletConfigWith({ + sdk: { + completeTransaction: vi + .fn() + .mockRejectedValue(new Error("completion failed")), }, - 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); + await expect(buildTransactionPreview(context({ ckbAvailable: 1n }), true, 1n, completionFailure)) + .resolves.toMatchObject({ error: "completion failed" }); }); }); diff --git a/apps/interface/src/transaction.ts b/apps/interface/src/transaction.ts index ede714d..1886e82 100644 --- a/apps/interface/src/transaction.ts +++ b/apps/interface/src/transaction.ts @@ -1,33 +1,18 @@ import { ccc } from "@ckb-ccc/ccc"; import { - ICKB_DEPOSIT_CAP, - convert, - type IckbDepositCell, - type ReceiptCell, - type WithdrawalGroup, -} from "@ickb/core"; -import { type OrderGroup } from "@ickb/order"; -import { collect, selectBoundedUdtSubset, sum } from "@ickb/utils"; -import { IckbSdk, type SystemState } from "@ickb/sdk"; + type ConversionTransactionContext, + type ConversionTransactionFailureReason, +} from "@ickb/sdk"; import { errorMessageOf, - hasTransactionActivity, txInfoPadding, type TxInfo, type WalletConfig, } from "./utils.ts"; -const MAX_DIRECT_DEPOSITS = 60; -const MAX_WITHDRAWAL_REQUESTS = 30; - -export interface TransactionContext { - system: SystemState; - receipts: ReceiptCell[]; - readyWithdrawals: WithdrawalGroup[]; - availableOrders: OrderGroup[]; - ckbAvailable: bigint; - ickbAvailable: bigint; - estimatedMaturity: bigint; +export interface TransactionContext extends ConversionTransactionContext { + capacityCells: ccc.Cell[]; + nativeUdtCells: ccc.Cell[]; } export async function buildTransactionPreview( @@ -49,201 +34,41 @@ export async function buildTransactionPreview( return txInfoWithError("Not enough iCKB", context.estimatedMaturity); } - const baseTx = await buildBaseTransaction(context, walletConfig); - - if (amount === 0n) { - if (!hasTransactionActivity(baseTx)) { - return txInfoWithError("Nothing to do for now", context.estimatedMaturity); - } - - return await finalizeTransaction( - baseTx, - context.estimatedMaturity, - context.system.feeRate, - walletConfig, + const result = await walletConfig.sdk.buildConversionTransaction( + ccc.Transaction.default(), + walletConfig.cccClient, + { + direction: isCkb2Udt ? "ckb-to-ickb" : "ickb-to-ckb", + amount, + lock: walletConfig.primaryLock, + context, + }, + ); + if (!result.ok) { + return txInfoWithError( + conversionFailureMessage(result.reason), + result.estimatedMaturity, ); } - return await (isCkb2Udt - ? buildCkbToIckbPreview(baseTx, context, amount, walletConfig) - : buildIckbToCkbPreview(baseTx, context, amount, walletConfig)); + return await finalizeTransaction( + result.tx, + result.estimatedMaturity, + context.system.feeRate, + walletConfig, + result.conversionNotice, + ); } catch (error) { return txInfoWithError(errorMessageOf(error), context.estimatedMaturity); } } -async function buildBaseTransaction( - context: TransactionContext, - walletConfig: WalletConfig, - withdrawalRequestDeposits: IckbDepositCell[] = [], -): Promise { - 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( - baseTx: ccc.Transaction, - context: TransactionContext, - amount: bigint, - walletConfig: WalletConfig, -): Promise { - const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, context.system.exchangeRatio); - const depositQuotient = depositCapacity === 0n ? 0n : amount / depositCapacity; - const maxDeposits = - depositQuotient > BigInt(MAX_DIRECT_DEPOSITS) - ? MAX_DIRECT_DEPOSITS - : Number(depositQuotient); - - return findBestAttempt(maxDeposits, async (depositCount) => { - try { - let tx = baseTx.clone(); - let estimatedMaturity = context.estimatedMaturity; - - if (depositCount > 0) { - tx = await walletConfig.managers.logic.deposit( - tx, - depositCount, - depositCapacity, - walletConfig.primaryLock, - walletConfig.cccClient, - ); - } - - const remainder = amount - depositCapacity * BigInt(depositCount); - if (remainder > 0n) { - const amounts = { ckbValue: remainder, udtValue: 0n }; - const estimate = IckbSdk.estimate(true, amounts, context.system); - if (estimate.maturity === undefined) { - return txInfoWithError( - "Amount too small to exceed the minimum match and fee threshold", - estimatedMaturity, - ); - } - - estimatedMaturity = maxMaturity(estimatedMaturity, estimate.maturity); - tx = await walletConfig.sdk.request( - tx, - walletConfig.primaryLock, - estimate.info, - amounts, - ); - } - - return await finalizeTransaction( - tx, - estimatedMaturity, - context.system.feeRate, - walletConfig, - ); - } catch (error) { - return txInfoWithError(errorMessageOf(error), context.estimatedMaturity); - } - }); -} - -async function buildIckbToCkbPreview( - baseTx: ccc.Transaction, - context: TransactionContext, - amount: bigint, - walletConfig: WalletConfig, -): Promise { - const deposits = await collect( - walletConfig.managers.logic.findDeposits(walletConfig.cccClient, { - onChain: true, - tip: context.system.tip, - }), - ); - - const candidates = deposits - .filter((deposit) => deposit.isReady) - .sort((left, right) => compareBigInt( - left.maturity.toUnix(context.system.tip), - right.maturity.toUnix(context.system.tip), - )); - - return findBestAttempt( - Math.min(candidates.length, MAX_WITHDRAWAL_REQUESTS), - async (withdrawalCount) => { - try { - let tx = baseTx.clone(); - let estimatedMaturity = context.estimatedMaturity; - let remainder = amount; - - if (withdrawalCount > 0) { - const selectedDeposits = selectExactCountReadyDepositsUnderAmount( - candidates, - withdrawalCount, - remainder, - ); - if (selectedDeposits.length !== withdrawalCount) { - return txInfoWithError( - "Not enough ready deposits to convert now", - estimatedMaturity, - ); - } - - tx = await buildBaseTransaction(context, walletConfig, selectedDeposits); - - remainder -= sum(0n, ...selectedDeposits.map((deposit) => deposit.udtValue)); - for (const deposit of selectedDeposits) { - estimatedMaturity = maxMaturity( - estimatedMaturity, - deposit.maturity.toUnix(context.system.tip), - ); - } - } - - if (remainder > 0n) { - const amounts = { ckbValue: 0n, udtValue: remainder }; - const estimate = IckbSdk.estimate(false, amounts, context.system); - if (estimate.maturity === undefined) { - return txInfoWithError( - "Amount too small to exceed the minimum match and fee threshold", - estimatedMaturity, - ); - } - - estimatedMaturity = maxMaturity(estimatedMaturity, estimate.maturity); - tx = await walletConfig.sdk.request( - tx, - walletConfig.primaryLock, - estimate.info, - amounts, - ); - } - - return await finalizeTransaction( - tx, - estimatedMaturity, - context.system.feeRate, - walletConfig, - ); - } catch (error) { - return txInfoWithError(errorMessageOf(error), context.estimatedMaturity); - } - }, - ); -} - async function finalizeTransaction( tx: ccc.Transaction, estimatedMaturity: bigint, feeRate: ccc.Num, walletConfig: WalletConfig, + conversionNotice?: TxInfo["conversionNotice"], ): Promise { tx = await walletConfig.sdk.completeTransaction(tx, { signer: walletConfig.signer, @@ -256,35 +81,7 @@ async function finalizeTransaction( error: "", fee: await tx.getFee(walletConfig.cccClient), estimatedMaturity, - }); -} - -async function findBestAttempt( - maxQuantity: number, - build: (quantity: number) => Promise, -): Promise { - let lastError: TxInfo | undefined; - for (let quantity = maxQuantity; quantity >= 0; quantity -= 1) { - const attempt = await build(quantity); - if (attempt.error === "") { - return attempt; - } - - lastError = attempt; - } - - return lastError ?? txInfoWithError("Nothing to do for now", 0n); -} - -export function selectExactCountReadyDepositsUnderAmount( - deposits: IckbDepositCell[], - wanted: number, - amount: bigint, -): IckbDepositCell[] { - return selectBoundedUdtSubset(deposits, amount, { - candidateLimit: MAX_WITHDRAWAL_REQUESTS, - minCount: wanted, - maxCount: wanted, + ...(conversionNotice ? { conversionNotice } : {}), }); } @@ -296,18 +93,19 @@ function txInfoWithError(error: string, estimatedMaturity: bigint): TxInfo { }); } -function compareBigInt(left: bigint, right: bigint): number { - if (left < right) { - return -1; - } - - if (left > right) { - return 1; +function conversionFailureMessage(reason: ConversionTransactionFailureReason): string { + switch (reason) { + case "amount-negative": + return "Amount must be positive"; + case "insufficient-ckb": + return "Not enough CKB"; + case "insufficient-ickb": + return "Not enough iCKB"; + case "amount-too-small": + return "Amount too small to exceed the minimum match and fee threshold"; + case "not-enough-ready-deposits": + return "Not enough ready deposits to convert now"; + case "nothing-to-do": + return "Nothing to do for now"; } - - return 0; -} - -function maxMaturity(left: bigint, right: bigint): bigint { - return left > right ? left : right; } diff --git a/apps/interface/src/utils.test.ts b/apps/interface/src/utils.test.ts new file mode 100644 index 0000000..2124059 --- /dev/null +++ b/apps/interface/src/utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { parseWalletChain } from "./utils.ts"; + +describe("parseWalletChain", () => { + it("parses supported wallet-chain identifiers", () => { + expect(parseWalletChain("JoyID_mainnet")).toEqual({ + walletName: "JoyID", + chain: "mainnet", + }); + expect(parseWalletChain("JoyID_testnet")).toEqual({ + walletName: "JoyID", + chain: "testnet", + }); + expect(parseWalletChain("Wallet_With_Underscores_testnet")).toEqual({ + walletName: "Wallet_With_Underscores", + chain: "testnet", + }); + }); + + it("rejects missing or unsupported chains instead of defaulting", () => { + expect(() => parseWalletChain("JoyID")).toThrow("Unsupported wallet chain: JoyID"); + expect(() => parseWalletChain("JoyID_devnet")).toThrow("Unsupported wallet chain: JoyID_devnet"); + expect(() => parseWalletChain("JoyID_testnet_extra")).toThrow( + "Unsupported wallet chain: JoyID_testnet_extra", + ); + }); +}); diff --git a/apps/interface/src/utils.ts b/apps/interface/src/utils.ts index 8058659..ddfb763 100644 --- a/apps/interface/src/utils.ts +++ b/apps/interface/src/utils.ts @@ -1,7 +1,5 @@ import { ccc } from "@ckb-ccc/ccc"; import type { QueryClient } from "@tanstack/react-query"; -import type { IckbUdt, LogicManager, OwnedOwnerManager } from "@ickb/core"; -import type { OrderManager } from "@ickb/order"; import type { IckbSdk } from "@ickb/sdk"; export interface RootConfig { @@ -9,12 +7,13 @@ export interface RootConfig { cccClient: ccc.Client; queryClient: QueryClient; sdk: IckbSdk; - managers: { - ickbUdt: IckbUdt; - logic: LogicManager; - ownedOwner: OwnedOwnerManager; - order: OrderManager; - }; +} + +type SupportedChain = RootConfig["chain"]; + +interface WalletChainParts { + walletName: string; + chain: SupportedChain; } export interface WalletConfig extends RootConfig { @@ -29,6 +28,13 @@ export type TxInfo = Readonly<{ error: string; fee: bigint; estimatedMaturity: bigint; + conversionNotice?: { + kind: "dust-ickb-to-ckb" | "maturity-unavailable"; + inputIckb: bigint; + outputCkb: bigint; + incentiveCkb: bigint; + maturityEstimateUnavailable: boolean; + }; }>; export const txInfoPadding: TxInfo = Object.freeze({ @@ -43,6 +49,21 @@ export const CKB = ccc.fixedPointFrom(1); // reservedCKB are reserved for state rent in conversions export const reservedCKB = 600n * CKB; +export function parseWalletChain(walletChain: string): WalletChainParts { + const separatorIndex = walletChain.lastIndexOf("_"); + if (separatorIndex <= 0 || separatorIndex === walletChain.length - 1) { + throw new Error(`Unsupported wallet chain: ${walletChain}`); + } + + const walletName = walletChain.slice(0, separatorIndex); + const chain = walletChain.slice(separatorIndex + 1); + if (chain !== "mainnet" && chain !== "testnet") { + throw new Error(`Unsupported wallet chain: ${walletChain}`); + } + + return { walletName, chain }; +} + export function symbol2Direction(symbol: string): boolean { return symbol !== "I"; } diff --git a/apps/interface/vite.config.ts b/apps/interface/vite.config.ts index ecbc54f..7129c7b 100644 --- a/apps/interface/vite.config.ts +++ b/apps/interface/vite.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ "@ickb/core": fileURLToPath( new URL("../../packages/core/dist/index.js", import.meta.url), ), + "@ickb/dao": fileURLToPath( + new URL("../../packages/dao/dist/index.js", import.meta.url), + ), "@ickb/order": fileURLToPath( new URL("../../packages/order/dist/index.js", import.meta.url), ), @@ -35,6 +38,7 @@ export default defineConfig({ include: [/\.[jt]sx?$/u], exclude: [ /\/packages\/core\/src\//u, + /\/packages\/dao\/src\//u, /\/packages\/order\/src\//u, /\/packages\/sdk\/src\//u, /\/packages\/utils\/src\//u, @@ -46,7 +50,7 @@ export default defineConfig({ basicSsl(), ], optimizeDeps: { - exclude: ["@ickb/core", "@ickb/order", "@ickb/sdk", "@ickb/utils"], + exclude: ["@ickb/core", "@ickb/dao", "@ickb/order", "@ickb/sdk", "@ickb/utils"], }, build: { commonjsOptions: { diff --git a/apps/interface/vitest.config.mts b/apps/interface/vitest.config.mts index dc6a587..58bacf3 100644 --- a/apps/interface/vitest.config.mts +++ b/apps/interface/vitest.config.mts @@ -1,6 +1,26 @@ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@ickb/core": fileURLToPath( + new URL("../../packages/core/src/index.ts", import.meta.url), + ), + "@ickb/dao": fileURLToPath( + new URL("../../packages/dao/src/index.ts", import.meta.url), + ), + "@ickb/order": fileURLToPath( + new URL("../../packages/order/src/index.ts", import.meta.url), + ), + "@ickb/sdk": fileURLToPath( + new URL("../../packages/sdk/src/index.ts", import.meta.url), + ), + "@ickb/utils": fileURLToPath( + new URL("../../packages/utils/src/index.ts", import.meta.url), + ), + }, + }, test: { include: ["src/**/*.test.ts"], coverage: { diff --git a/apps/sampler/.gitignore b/apps/sampler/.gitignore new file mode 100644 index 0000000..ccfe3d2 --- /dev/null +++ b/apps/sampler/.gitignore @@ -0,0 +1 @@ +rate.csv diff --git a/apps/sampler/README.md b/apps/sampler/README.md index d38c95b..2b42915 100644 --- a/apps/sampler/README.md +++ b/apps/sampler/README.md @@ -4,33 +4,21 @@ An utility to help sampling iCKB rate across time. ## Run the sampler on mainnet -1. Download this repo in a folder of your choice: +From a plain checkout, follow the root [Local CCC Workflow](../../README.md#local-ccc-workflow) first so `forks/ccc/repo` is materialized. If you are working against patched local CCC packages, rerun `pnpm forks:ccc` or keep `pnpm forks:ccc --watch` running. The app build commands below then build the runtime workspace package closure they import. -```bash -git clone https://github.com/ickb/stack.git -``` - -2. Enter into the repo folder: - -```bash -cd stack/apps/sampler -``` - -3. Install dependencies: +From the repo root: ```bash pnpm install +pnpm --filter ./apps/sampler build +pnpm --filter ./apps/sampler start ``` -4. Build project: +Or from `apps/sampler` inside the monorepo workspace: ```bash +pnpm install pnpm build -``` - -5. Start the sampler utility: - -```bash pnpm start ``` diff --git a/apps/sampler/package.json b/apps/sampler/package.json index f6be753..cf95443 100644 --- a/apps/sampler/package.json +++ b/apps/sampler/package.json @@ -31,7 +31,7 @@ "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "tsc", + "build": "pnpm --filter @ickb/utils --filter @ickb/dao --filter @ickb/core build && tsc -p tsconfig.build.json", "lint": "eslint ./src", "clean": "rm -fr dist", "clean:deep": "rm -fr dist node_modules", @@ -46,6 +46,7 @@ "provenance": true }, "devDependencies": { + "@ickb/testkit": "workspace:*", "@types/node": "catalog:" }, "dependencies": { diff --git a/apps/sampler/src/index.test.ts b/apps/sampler/src/index.test.ts new file mode 100644 index 0000000..474b1fa --- /dev/null +++ b/apps/sampler/src/index.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { ccc } from "@ckb-ccc/core"; +import { headerLike } from "@ickb/testkit"; +import { main, samples } from "./index.js"; + +describe("sampler module", () => { + it("can be imported without running the main loop", () => { + expect(typeof main).toBe("function"); + }); + + it("samples each covered UTC year", () => { + expect(samples(0n, 1n, 1).map((date) => date.toISOString())).toEqual([ + "1970-01-01T00:00:00.000Z", + ]); + }); + + it("logs sampled rows from an injected client", async () => { + const genesis = sampleHeader(0n, "2024-09-12T00:00:00.000Z"); + const launch = sampleHeader(1n, "2024-09-12T15:13:19.574Z"); + const tip = sampleHeader(2n, "2024-09-13T00:00:00.000Z"); + const lines: string[] = []; + + await main({ + client: sampleClient(new Map([ + [0, genesis], + [1, launch], + [2, tip], + ]), tip), + log: (line) => { + lines.push(line); + }, + samplesPerYear: 1, + }); + + expect(lines.map((line) => line.split(", ").slice(0, 2))).toEqual([ + ["BlockNumber", "Date"], + ["0", "2024-09-12T00:00:00.000Z"], + ["1", "2024-09-12T15:13:19.574Z"], + ["2", "2024-09-13T00:00:00.000Z"], + ]); + expect(lines.at(-2)?.endsWith(", iCKB Launch")).toBe(true); + expect(lines.at(-1)?.endsWith(", Tip")).toBe(true); + }); + + it("uses a non-overflowing search bound for current chain heights", async () => { + const genesis = sampleHeader(0n, "2024-09-12T00:00:00.000Z"); + const tip = sampleHeader(1_500_000_000n, "2024-09-13T00:00:00.000Z"); + const requests: bigint[] = []; + + await main({ + client: { + getHeaderByNumber: async (blockNumber) => { + requests.push(BigInt(blockNumber)); + await Promise.resolve(); + return BigInt(blockNumber) === 0n ? genesis : tip; + }, + getTipHeader: async () => { + await Promise.resolve(); + return tip; + }, + }, + log: () => {}, + samplesPerYear: 1, + }); + + expect(requests.some((blockNumber) => blockNumber > 0n)).toBe(true); + }); + + it("throws when the selected sample header is missing", async () => { + const genesis = sampleHeader(0n, "2024-09-12T00:00:00.000Z"); + const tip = sampleHeader(2n, "2024-09-13T00:00:00.000Z"); + + await expect(main({ + client: sampleClient(new Map([ + [0, genesis], + [2, tip], + ]), tip), + log: () => {}, + samplesPerYear: 1, + })).rejects.toThrow("Header not found"); + }); +}); + +function sampleHeader(number: bigint, isoTimestamp: string): ccc.ClientBlockHeader { + return headerLike({ + number, + timestamp: BigInt(Date.parse(isoTimestamp)), + }); +} + +function sampleClient( + headers: Map, + tip: ccc.ClientBlockHeader, +): Pick { + return { + getHeaderByNumber: async (blockNumber): Promise => { + await Promise.resolve(); + return headers.get(Number(blockNumber)); + }, + getTipHeader: async (): Promise => { + await Promise.resolve(); + return tip; + }, + }; +} diff --git a/apps/sampler/src/index.ts b/apps/sampler/src/index.ts index 88db2f2..53d5599 100644 --- a/apps/sampler/src/index.ts +++ b/apps/sampler/src/index.ts @@ -27,6 +27,18 @@ import { ccc } from "@ckb-ccc/core"; import { convert } from "@ickb/core"; import { asyncBinarySearch } from "@ickb/utils"; +import { pathToFileURL } from "node:url"; + +interface SamplerClient { + getHeaderByNumber: ccc.Client["getHeaderByNumber"]; + getTipHeader: ccc.Client["getTipHeader"]; +} + +interface MainOptions { + client?: SamplerClient; + log?: (line: string) => void; + samplesPerYear?: number; +} /** * Main program that orchestrates sampling and logging. @@ -47,9 +59,12 @@ import { asyncBinarySearch } from "@ickb/utils"; * * @public */ -export async function main(): Promise { +export async function main(options: MainOptions = {}): Promise { // Create a public mainnet client (network I/O happens on method calls). - const client = new ccc.ClientPublicMainnet(); + const client = options.client ?? new ccc.ClientPublicMainnet(); + const log = options.log ?? ((line: string): void => { + console.log(line); + }); // Fetch genesis header (block 0). If absent, abort early. const genesis = await client.getHeaderByNumber(0); @@ -60,23 +75,13 @@ export async function main(): Promise { // Fetch tip header to bound our searches. const tip = await client.getTipHeader(); - // Compute an upper bound `n` for the binary search using the bit-length - // of the tip number. This yields a power-of-two >= tip.number. - const n = 1 << tip.number.toString(2).length; + const n = Math.pow(2, tip.number.toString(2).length); - // Generate date samples between genesis and tip (timestamps are bigints in ms). - // The samples(...) helper returns Date instances; attach optional notes here. - const dates = samples(genesis.timestamp, tip.timestamp, 4).map( - (d) => [d, ""] as [Date, string], - ); - // Insert a named event sample (kept as an example of adding special dates). - dates.push([new Date("2024-09-12T15:13:19.574Z"), "iCKB Launch"]); - // Ensure chronological order across all samples (safety). - dates.sort((a, b) => a[0].getTime() - b[0].getTime()); + const dates = sampleTargets(genesis.timestamp, tip.timestamp, options.samplesPerYear); // Emit CSV header and the genesis row. - console.log(["BlockNumber", "Date", "Value", "Note"].join(", ")); - logRow(genesis, "Genesis"); + log(["BlockNumber", "Date", "Value", "Note"].join(", ")); + logRow(genesis, "Genesis", log); // For each sample date, find the earliest block whose timestamp is >= date. for (const [date, note] of dates) { @@ -102,10 +107,22 @@ export async function main(): Promise { throw new Error("Header not found"); } - logRow(header, note); + logRow(header, note, log); } } +function sampleTargets( + startMs: bigint, + endMs: bigint, + n = 4, +): [Date, string][] { + const dates = samples(startMs, endMs, n).map((d) => [d, ""] as [Date, string]); + dates.push([new Date("2024-09-12T15:13:19.574Z"), "iCKB Launch"]); + dates.push([new Date(Number(endMs)), "Tip"]); + dates.sort((a, b) => a[0].getTime() - b[0].getTime()); + return dates; +} + /** * Log a CSV row for a header. * @@ -120,13 +137,17 @@ export async function main(): Promise { * * @internal */ -function logRow(header: ccc.ClientBlockHeader, note: string): void { +function logRow( + header: ccc.ClientBlockHeader, + note: string, + log: (line: string) => void, +): void { // Compute ISO timestamp from header timestamp (milliseconds). const date = new Date(Number(header.timestamp)); // Convert the header's monetary value to a fixed-point representation. const val = convert(false, ccc.One, header); // Emit CSV row: blockNumber, ISO date, formatted value, note. - console.log( + log( [ String(header.number), date.toISOString(), @@ -187,6 +208,7 @@ export function samples(startMs: bigint, endMs: bigint, n: number): Date[] { return out; } -await main(); - -process.exit(0); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); + process.exit(0); +} diff --git a/apps/sampler/tsconfig.build.json b/apps/sampler/tsconfig.build.json new file mode 100644 index 0000000..998f832 --- /dev/null +++ b/apps/sampler/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false, + "sourceRoot": "", + "sourceMap": false + }, + "exclude": ["src/**/*.test.ts"] +} diff --git a/apps/tester/.gitignore b/apps/tester/.gitignore index b732cd1..6913ede 100644 --- a/apps/tester/.gitignore +++ b/apps/tester/.gitignore @@ -1 +1,2 @@ -log_*.json +env/ +log_*.json diff --git a/apps/tester/README.md b/apps/tester/README.md index 63e9813..9d6b205 100644 --- a/apps/tester/README.md +++ b/apps/tester/README.md @@ -1,6 +1,6 @@ # iCKB Tester -The tester is now CCC-native. It cancels the tester's own active orders, then places randomized iCKB limit orders against the live testnet exchange ratio using the shared `@ickb/sdk`, `@ickb/core`, and `@ickb/order` packages. +The tester is now CCC-native. It waits while its own fresh matchable orders are still live, then cancels stale active orders and places randomized iCKB limit orders against the selected chain exchange ratio using the shared `@ickb/sdk`, `@ickb/core`, and `@ickb/order` packages. ## Environment @@ -25,6 +25,8 @@ Current network support: ## Run +From a plain checkout, follow the root [Local CCC Workflow](../../README.md#local-ccc-workflow) first so `forks/ccc/repo` is materialized. If you are working against patched local CCC packages, rerun `pnpm forks:ccc` or keep `pnpm forks:ccc --watch` running. The app build commands below then build the runtime workspace package closure they import. + ```bash pnpm install pnpm --filter ./apps/tester build @@ -47,7 +49,7 @@ pnpm run start `CHAIN` selects `env/${CHAIN}/.env`, which must contain the remaining runtime variables such as `TESTER_PRIVATE_KEY` and `TESTER_SLEEP_INTERVAL`. -The start script keeps the existing JSON log format and writes one log file per run. +The start script writes one newline-delimited JSON log stream per run. Each loop appends one JSON object to the log file. Balance, amount, and fee values are decimal strings so bigint values do not lose precision. Confirmation timeouts are logged with the broadcast hash and stop the loop with exit code `2` so a wrapper does not immediately send conflicting replacement work. ## Licensing diff --git a/apps/tester/package.json b/apps/tester/package.json index 57ec53e..993cbe0 100644 --- a/apps/tester/package.json +++ b/apps/tester/package.json @@ -31,11 +31,11 @@ "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "tsc", + "build": "pnpm --filter @ickb/utils --filter @ickb/dao --filter @ickb/core --filter @ickb/order --filter @ickb/sdk --filter @ickb/node-utils build && pnpm clean && tsc -p tsconfig.build.json", "lint": "eslint ./src", "clean": "rm -fr dist", "clean:deep": "rm -fr dist node_modules", - "start": "[ -n \"$CHAIN\" ] || { echo 'CHAIN not set (testnet|mainnet)' >&2; exit 1; } && node --env-file=env/${CHAIN}/.env dist/index.js | tee log_${CHAIN}_$(date +%F_%H-%M-%S).json" + "start": "[ -n \"$CHAIN\" ] || { echo 'CHAIN not set (testnet|mainnet)' >&2; exit 1; }; bash -o pipefail -c 'node --env-file=\"env/${CHAIN}/.env\" dist/index.js | tee \"log_${CHAIN}_$(date +%F_%H-%M-%S).json\"'" }, "files": [ "dist", @@ -46,12 +46,14 @@ "provenance": true }, "devDependencies": { + "@ickb/testkit": "workspace:*", "@types/node": "catalog:" }, "dependencies": { "@ckb-ccc/core": "catalog:", "@ickb/core": "workspace:*", "@ickb/order": "workspace:*", + "@ickb/node-utils": "workspace:*", "@ickb/sdk": "workspace:*" } } diff --git a/apps/tester/src/freshMatchableOrderSkip.ts b/apps/tester/src/freshMatchableOrderSkip.ts new file mode 100644 index 0000000..7fde3fd --- /dev/null +++ b/apps/tester/src/freshMatchableOrderSkip.ts @@ -0,0 +1,54 @@ +import { ccc } from "@ckb-ccc/core"; +import { type OrderGroup } from "@ickb/order"; +import { type Runtime } from "./runtime.js"; + +const MAX_ELAPSED_BLOCKS = 100800n; + +type FreshMatchableOrderSkip = + | { + reason: "matchable-order-transaction-missing"; + txHash: ccc.Hex; + } + | { + reason: "fresh-matchable-order"; + txHash: ccc.Hex; + blockNumber: bigint; + tipNumber: bigint; + maxElapsedBlocks: bigint; + }; + +export async function freshMatchableOrderSkip( + runtime: Runtime, + orders: OrderGroup[], + tip: ccc.ClientBlockHeader, +): Promise { + const tx2BlockNumber = new Map(); + + for (const group of orders) { + if (!group.order.isMatchable()) { + continue; + } + + const txHash = group.order.cell.outPoint.txHash; + let blockNumber = tx2BlockNumber.get(txHash); + if (blockNumber === undefined) { + const tx = await runtime.client.getTransaction(txHash); + if (tx?.blockNumber === undefined) { + return { reason: "matchable-order-transaction-missing", txHash }; + } + + blockNumber = tx.blockNumber; + tx2BlockNumber.set(txHash, blockNumber); + } + + if (blockNumber + MAX_ELAPSED_BLOCKS >= tip.number) { + return { + reason: "fresh-matchable-order", + txHash, + blockNumber, + tipNumber: tip.number, + maxElapsedBlocks: MAX_ELAPSED_BLOCKS, + }; + } + } +} diff --git a/apps/tester/src/index.test.ts b/apps/tester/src/index.test.ts index a31f753..2757081 100644 --- a/apps/tester/src/index.test.ts +++ b/apps/tester/src/index.test.ts @@ -1,17 +1,80 @@ import { describe, expect, it } from "vitest"; -import { parseSleepInterval } from "./index.js"; - -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", - ); - } +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, headerLike, script } from "@ickb/testkit"; +import { freshMatchableOrderSkip } from "./freshMatchableOrderSkip.js"; + +describe("freshMatchableOrderSkip", () => { + it("explains skips caused by unavailable transaction lookup", async () => { + const txHash = byte32FromByte("11"); + const runtime = { + client: { + getTransaction: (): Promise => Promise.resolve(undefined), + }, + }; + + await expect(freshMatchableOrderSkip( + runtime as never, + [matchableOrder(txHash)], + headerLike({ number: 200000n }), + )).resolves.toEqual({ + reason: "matchable-order-transaction-missing", + txHash, + }); }); - it("returns milliseconds for valid second intervals", () => { - expect(parseSleepInterval("1", "TESTER_SLEEP_INTERVAL")).toBe(1000); - expect(parseSleepInterval("2.5", "TESTER_SLEEP_INTERVAL")).toBe(2500); + it("explains skips caused by fresh matchable orders", async () => { + const txHash = byte32FromByte("22"); + const runtime = { + client: { + getTransaction: (): Promise<{ blockNumber: bigint }> => Promise.resolve({ blockNumber: 100000n }), + }, + }; + + await expect(freshMatchableOrderSkip( + runtime as never, + [matchableOrder(txHash)], + headerLike({ number: 200000n }), + )).resolves.toEqual({ + reason: "fresh-matchable-order", + txHash, + blockNumber: 100000n, + tipNumber: 200000n, + maxElapsedBlocks: 100800n, + }); + }); + + it("does not skip stale or non-matchable orders", async () => { + const runtime = { + client: { + getTransaction: (): Promise<{ blockNumber: bigint }> => Promise.resolve({ blockNumber: 100000n }), + }, + }; + + await expect(freshMatchableOrderSkip( + runtime as never, + [matchableOrder(byte32FromByte("33")), nonMatchableOrder(byte32FromByte("44"))], + headerLike({ number: 200801n }), + )).resolves.toBeUndefined(); }); }); + +function matchableOrder(txHash: ccc.Hex): never { + return order(txHash, true); +} + +function nonMatchableOrder(txHash: ccc.Hex): never { + return order(txHash, false); +} + +function order(txHash: ccc.Hex, isMatchable: boolean): never { + return { + order: { + isMatchable: () => isMatchable, + cell: ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { capacity: 0n, lock: script("55") }, + outputData: "0x", + }), + }, + } as never; +} diff --git a/apps/tester/src/index.ts b/apps/tester/src/index.ts index e83a5ea..866b6af 100644 --- a/apps/tester/src/index.ts +++ b/apps/tester/src/index.ts @@ -1,21 +1,31 @@ import { ccc } from "@ckb-ccc/core"; import { ICKB_DEPOSIT_CAP, convert } from "@ickb/core"; import { IckbSdk, getConfig, sendAndWaitForCommit } from "@ickb/sdk"; -import { type OrderGroup } from "@ickb/order"; +import { + createPublicClient, + formatCkb, + handleLoopError, + logExecution, + parseSleepInterval, + parseSupportedChain, + signerAccountLocks, + sleep, +} from "@ickb/node-utils"; import { pathToFileURL } from "node:url"; -import { buildTransaction, readTesterState, type Runtime } from "./runtime.js"; - +import { + buildTransaction, + readTesterState, + type Runtime, +} from "./runtime.js"; +import { freshMatchableOrderSkip } from "./freshMatchableOrderSkip.js"; const CKB = ccc.fixedPointFrom(1); const CKB_RESERVE = 2000n * CKB; const MIN_POST_TX_CKB = 1000n * CKB; const MIN_TOTAL_CAPITAL_DIVISOR = 20n; const TESTER_FEE = 100n; const TESTER_FEE_BASE = 100000n; -const MAX_ELAPSED_BLOCKS = 100800n; const RANDOM_SCALE = 1000000n; -type SupportedChain = "mainnet" | "testnet"; - async function main(): Promise { const { CHAIN, RPC_URL, TESTER_PRIVATE_KEY, TESTER_SLEEP_INTERVAL } = process.env; @@ -30,21 +40,21 @@ async function main(): Promise { "TESTER_SLEEP_INTERVAL", ); - const chain = parseChain(CHAIN); - const client = createClient(chain, RPC_URL); + const chain = parseSupportedChain(CHAIN, "CHAIN"); + const client = createPublicClient(chain, RPC_URL); const config = getConfig(chain); const signer = new ccc.SignerCkbPrivateKey(client, TESTER_PRIVATE_KEY); - const primaryLock = (await signer.getRecommendedAddressObj()).script; + const recommendedAddress = await signer.getRecommendedAddressObj(); + const primaryLock = recommendedAddress.script; const runtime: Runtime = { client, signer, sdk: IckbSdk.fromConfig(config), primaryLock, - accountLocks: dedupeScripts( - (await signer.getAddressObjs()).map(({ script }) => script), - ), + accountLocks: await signerAccountLocks(signer, primaryLock), }; + let stopAfterLog = false; for (;;) { await sleep(2 * Math.random() * sleepInterval); @@ -54,7 +64,14 @@ async function main(): Promise { try { const state = await readTesterState(runtime); - if (await hasFreshMatchableOrders(runtime, state.userOrders, state.system.tip)) { + const skip = await freshMatchableOrderSkip( + runtime, + state.userOrders, + state.system.tip, + ); + if (skip) { + executionLog.skip = skip; + logExecution(executionLog, startTime); continue; } @@ -65,18 +82,18 @@ async function main(): Promise { executionLog.balance = { CKB: { - total: fmtCkb(state.availableCkbBalance), - available: fmtCkb(state.availableCkbBalance), - unavailable: fmtCkb(0n), + total: formatCkb(state.availableCkbBalance), + available: formatCkb(state.availableCkbBalance), + unavailable: formatCkb(0n), }, ICKB: { - total: fmtCkb(state.availableIckbBalance), - available: fmtCkb(state.availableIckbBalance), - unavailable: fmtCkb(0n), + total: formatCkb(state.availableIckbBalance), + available: formatCkb(state.availableIckbBalance), + unavailable: formatCkb(0n), }, totalEquivalent: { - CKB: fmtCkb(totalEquivalentCkb), - ICKB: fmtCkb( + CKB: formatCkb(totalEquivalentCkb), + ICKB: formatCkb( convert(true, state.availableCkbBalance, state.system.tip) + state.availableIckbBalance, ), @@ -110,9 +127,11 @@ async function main(): Promise { if (totalEquivalentCkb < depositCapacity / MIN_TOTAL_CAPITAL_DIVISOR) { executionLog.error = "Not enough funds to continue testing, shutting down..."; - console.log(JSON.stringify(executionLog, replacer, " ")); + logExecution(executionLog, startTime); return; } + executionLog.skip = { reason: "sampled-amount-too-small" }; + logExecution(executionLog, startTime); continue; } @@ -124,6 +143,8 @@ async function main(): Promise { feeBase: TESTER_FEE_BASE, }); if (estimate.convertedAmount <= 0n) { + executionLog.skip = { reason: "estimated-conversion-too-small" }; + logExecution(executionLog, startTime); continue; } @@ -136,129 +157,35 @@ async function main(): Promise { executionLog.actions = { newOrder: isCkb2Udt ? { - giveCkb: fmtCkb(ckbAmount), - takeIckb: fmtCkb(estimate.convertedAmount), - fee: fmtCkb(estimate.ckbFee), + giveCkb: formatCkb(ckbAmount), + takeIckb: formatCkb(estimate.convertedAmount), + fee: formatCkb(estimate.ckbFee), } : { - giveIckb: fmtCkb(udtAmount), - takeCkb: fmtCkb(estimate.convertedAmount), - fee: fmtCkb(estimate.ckbFee), + giveIckb: formatCkb(udtAmount), + takeCkb: formatCkb(estimate.convertedAmount), + fee: formatCkb(estimate.ckbFee), }, cancelledOrders: state.userOrders.filter((group) => group.order.isMatchable()) .length, }; executionLog.txFee = { - fee: fmtCkb(await tx.getFee(runtime.client)), + fee: formatCkb(await tx.getFee(runtime.client)), feeRate: state.system.feeRate, }; - executionLog.txHash = await sendAndWaitForCommit(runtime, tx); + executionLog.txHash = await sendAndWaitForCommit(runtime, tx, { + onSent: (txHash) => { + executionLog.txHash = txHash; + }, + }); } catch (e) { - executionLog.error = errorToLog(e); - } - executionLog.ElapsedSeconds = Math.round( - (new Date().getTime() - startTime.getTime()) / 1000, - ); - console.log(JSON.stringify(executionLog, replacer, " ")); - } -} - -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 seconds * 1000; -} - -async function hasFreshMatchableOrders( - runtime: Runtime, - orders: OrderGroup[], - tip: ccc.ClientBlockHeader, -): Promise { - const tx2BlockNumber = new Map(); - - for (const group of orders) { - if (!group.order.isMatchable()) { - continue; + stopAfterLog = handleLoopError(executionLog, e); } - - const txHash = group.order.cell.outPoint.txHash; - let blockNumber = tx2BlockNumber.get(txHash); - if (blockNumber === undefined) { - const tx = await runtime.client.getTransaction(txHash); - if (!tx?.blockNumber) { - return true; - } - - blockNumber = tx.blockNumber; - tx2BlockNumber.set(txHash, blockNumber); - } - - if (blockNumber + MAX_ELAPSED_BLOCKS >= tip.number) { - return true; + logExecution(executionLog, startTime); + if (stopAfterLog) { + return; } } - - return false; -} - -function createClient(chain: SupportedChain, rpcUrl: string | undefined): ccc.Client { - const config = rpcUrl ? { url: rpcUrl } : undefined; - return chain === "mainnet" - ? new ccc.ClientPublicMainnet(config) - : new ccc.ClientPublicTestnet(config); -} - -function parseChain(chain: string): SupportedChain { - if (chain === "mainnet" || chain === "testnet") { - return chain; - } - - throw new Error("Invalid env CHAIN: " + chain); -} - -function dedupeScripts(scripts: ccc.Script[]): ccc.Script[] { - const seen = new Set(); - const unique: ccc.Script[] = []; - - for (const script of scripts) { - const key = script.toHex(); - if (seen.has(key)) { - continue; - } - seen.add(key); - unique.push(script); - } - - return unique; -} - -function fmtCkb(balance: bigint): number { - return Number(balance) / Number(CKB); -} - -function replacer(_: unknown, value: unknown): unknown { - return typeof value === "bigint" ? Number(value) : value; -} - -function errorToLog(error: unknown): unknown { - if (error instanceof Object && "stack" in error) { - return { - name: "name" in error ? error.name : undefined, - message: - "message" in error && typeof error.message === "string" - ? error.message - : "Unknown error", - stack: error.stack ?? "", - }; - } - - return error ?? "Empty Error"; } function min(left: bigint, right: bigint): bigint { @@ -277,12 +204,6 @@ function randomScaled(): bigint { return BigInt(Math.floor(Math.random() * Number(RANDOM_SCALE))); } -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - 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 index 16e1279..6c1799c 100644 --- a/apps/tester/src/runtime.test.ts +++ b/apps/tester/src/runtime.test.ts @@ -1,4 +1,5 @@ import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, script } from "@ickb/testkit"; import { describe, expect, it, vi } from "vitest"; import { buildTransaction, @@ -7,21 +8,6 @@ import { 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 }, @@ -30,6 +16,38 @@ function cell(capacity: bigint, lock: ccc.Script, outputData = "0x"): ccc.Cell { }); } +function buildBaseTransactionMock(calls: string[]): ReturnType< + typeof vi.fn +> { + return vi.fn().mockImplementation((txLike) => + recordTxStep("base", calls, txLike) + ); +} + +function requestMock(calls: string[]): ReturnType> { + return vi.fn().mockImplementation((txLike) => + recordTxStep("request", calls, txLike) + ); +} + +function completeTransactionMock(calls: string[]): ReturnType< + typeof vi.fn +> { + return vi.fn().mockImplementation((txLike) => + recordTxStep("complete", calls, txLike) + ); +} + +async function recordTxStep( + label: string, + calls: string[], + txLike: ccc.TransactionLike, +): Promise { + calls.push(label); + await Promise.resolve(); + return ccc.Transaction.from(txLike); +} + describe("readTesterState", () => { it("includes receipts and ready withdrawals in the actionable state", async () => { const plainLock = script("11"); @@ -57,21 +75,19 @@ describe("readTesterState", () => { client: {} as ccc.Client, signer: {} as ccc.SignerCkbPrivateKey, sdk: { - getL1State: async () => { + getL1AccountState: 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], + account: { + capacityCells: [plainCell], + nativeUdtCells: [], + nativeUdtCapacity: 7n, + nativeUdtBalance: 11n, + receipts: [receipt], + withdrawalGroups: [readyWithdrawal, pendingWithdrawal], + }, }; }, } as unknown as Runtime["sdk"], @@ -94,27 +110,9 @@ describe("readTesterState", () => { 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 buildBaseTransaction = buildBaseTransactionMock(calls); + const request = requestMock(calls); + const completeTransaction = completeTransactionMock(calls); const receipts = [{ id: "receipt" }]; const readyWithdrawals = [{ id: "withdrawal" }]; const state: TesterState = { diff --git a/apps/tester/src/runtime.ts b/apps/tester/src/runtime.ts index 4479f17..8761f7d 100644 --- a/apps/tester/src/runtime.ts +++ b/apps/tester/src/runtime.ts @@ -25,15 +25,10 @@ export interface TesterState { } export async function readTesterState(runtime: Runtime): Promise { - const { system, user } = await runtime.sdk.getL1State( + const { system, user, account } = await runtime.sdk.getL1AccountState( runtime.client, runtime.accountLocks, ); - const account = await runtime.sdk.getAccountState( - runtime.client, - runtime.accountLocks, - system.tip, - ); const projection = projectAccountAvailability(account, user.orders, { collectedOrdersAvailable: true, diff --git a/apps/tester/tsconfig.build.json b/apps/tester/tsconfig.build.json new file mode 100644 index 0000000..998f832 --- /dev/null +++ b/apps/tester/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false, + "sourceRoot": "", + "sourceMap": false + }, + "exclude": ["src/**/*.test.ts"] +} diff --git a/apps/tester/vitest.config.mts b/apps/tester/vitest.config.mts index dc6a587..ccef59b 100644 --- a/apps/tester/vitest.config.mts +++ b/apps/tester/vitest.config.mts @@ -1,6 +1,14 @@ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@ickb/node-utils": fileURLToPath( + new URL("../../packages/node-utils/src/index.ts", import.meta.url), + ), + }, + }, test: { include: ["src/**/*.test.ts"], coverage: { diff --git a/package.json b/package.json index 01e5a5c..fc350c9 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.3", + "@ickb/testkit": "workspace:*", "@vitest/coverage-v8": "3.2.4", "eslint": "^9.39.3", "eslint-plugin-react-compiler": "19.1.0-rc.2", diff --git a/packages/node-utils/.gitignore b/packages/node-utils/.gitignore new file mode 100644 index 0000000..2d0c064 --- /dev/null +++ b/packages/node-utils/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +coverage/ diff --git a/packages/node-utils/README.md b/packages/node-utils/README.md new file mode 100644 index 0000000..7425ec5 --- /dev/null +++ b/packages/node-utils/README.md @@ -0,0 +1,11 @@ +# iCKB/Node Utils + +Private workspace utilities for Node-based iCKB apps. + +`@ickb/node-utils` owns process and operator glue for Node-based iCKB apps such as `apps/tester`: environment parsing, public RPC client construction, signer account-lock collection, sleep loops, CKB log formatting, JSON-safe error/log serialization, elapsed-loop logging, and broadcast-timeout stop handling. + +This package is intentionally private and should not be used by the browser interface. Cross-runtime transaction lifecycle helpers, such as `sendAndWaitForCommit(...)`, stay in `@ickb/sdk`. + +## Licensing + +Released under the [MIT License](https://github.com/ickb/stack/tree/master/LICENSE). diff --git a/packages/node-utils/package.json b/packages/node-utils/package.json new file mode 100644 index 0000000..30fa3c3 --- /dev/null +++ b/packages/node-utils/package.json @@ -0,0 +1,48 @@ +{ + "name": "@ickb/node-utils", + "version": "1001.0.0", + "private": true, + "description": "Shared Node utilities for iCKB apps", + "keywords": [ + "ickb", + "ccc", + "ckb", + "blockchain" + ], + "author": "phroi", + "license": "MIT", + "homepage": "https://ickb.org", + "repository": { + "type": "git", + "url": "https://github.com/ickb/stack" + }, + "bugs": { + "url": "https://github.com/ickb/stack/issues" + }, + "sideEffects": false, + "type": "module", + "main": "dist/index.js", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "test": "NODE_OPTIONS='--disable-warning=DEP0040' vitest", + "test:ci": "NODE_OPTIONS='--disable-warning=DEP0040' vitest run", + "build": "tsc", + "lint": "eslint ./src", + "clean": "rm -fr dist", + "clean:deep": "rm -fr dist node_modules" + }, + "dependencies": { + "@ckb-ccc/core": "catalog:", + "@ickb/utils": "workspace:*" + }, + "devDependencies": { + "@ickb/testkit": "workspace:*", + "@types/node": "catalog:" + } +} diff --git a/packages/node-utils/src/index.test.ts b/packages/node-utils/src/index.test.ts new file mode 100644 index 0000000..9a49ac0 --- /dev/null +++ b/packages/node-utils/src/index.test.ts @@ -0,0 +1,147 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, script } from "@ickb/testkit"; +import process from "node:process"; +import { describe, expect, it, vi } from "vitest"; +import { + createPublicClient, + formatCkb, + handleLoopError, + logExecution, + parseSleepInterval, + parseSupportedChain, + signerAccountLocks, +} from "./index.js"; + +describe("node utilities", () => { + it("formats CKB values without losing bigint precision", () => { + const whole = 123456789012345678901234567890n; + + expect(formatCkb(whole * 100000000n + 12345670n)).toBe( + `${whole.toString()}.1234567`, + ); + expect(formatCkb(-100000000n - 1n)).toBe("-1.00000001"); + }); + + it("parses supported chain names from env values", () => { + expect(parseSupportedChain("mainnet", "CHAIN")).toBe("mainnet"); + expect(parseSupportedChain("testnet", "CHAIN")).toBe("testnet"); + }); + + it("rejects missing and unsupported chain env values", () => { + expect(() => parseSupportedChain(undefined, "CHAIN")).toThrow( + "Invalid env CHAIN: Empty", + ); + expect(() => parseSupportedChain("devnet", "CHAIN")).toThrow( + "Invalid env CHAIN: devnet", + ); + }); + + it("parses positive sleep intervals as milliseconds", () => { + expect(parseSleepInterval("1", "SLEEP_INTERVAL")).toBe(1000); + expect(parseSleepInterval("2.5", "SLEEP_INTERVAL")).toBe(2500); + }); + + it("rejects missing and sub-second sleep intervals", () => { + for (const value of [undefined, "", "abc", "NaN", "Infinity", "0", "0.5"]) { + expect(() => parseSleepInterval(value, "SLEEP_INTERVAL")).toThrow( + "Invalid env SLEEP_INTERVAL", + ); + } + }); + + it("keeps the primary signer lock first and deduplicates account locks", async () => { + const primaryLock = script("11"); + const otherLock = script("22"); + const signer = { + getAddressObjs: async () => { + await Promise.resolve(); + return [{ script: otherLock }, { script: primaryLock }]; + }, + } as ccc.Signer; + + await expect(signerAccountLocks(signer, primaryLock)).resolves.toEqual([ + primaryLock, + otherLock, + ]); + }); + + it("creates network-specific public clients and forwards custom RPC URLs", () => { + const mainnet = createPublicClient("mainnet", "https://mainnet.example"); + const testnet = createPublicClient("testnet", undefined); + + expect(mainnet).toBeInstanceOf(ccc.ClientPublicMainnet); + expect(testnet).toBeInstanceOf(ccc.ClientPublicTestnet); + expect(mainnet.addressPrefix).toBe("ckb"); + expect(testnet.addressPrefix).toBe("ckt"); + expect((mainnet as ccc.ClientPublicMainnet).url).toBe( + "https://mainnet.example", + ); + }); + + it("serializes error-like values for JSON logs", () => { + const executionLog: Record = {}; + + expect(handleLoopError(executionLog, new Error("failed"))).toBe(false); + expect(executionLog.error).toMatchObject({ + name: "Error", + message: "failed", + }); + expect(executionLog.error).toHaveProperty("stack"); + }); + + it("stops after broadcast confirmation timeouts", () => { + expect(handleLoopError({}, transactionError(true))).toBe(true); + expect(process.exitCode).toBe(2); + process.exitCode = undefined; + + expect(handleLoopError({}, transactionError(false))).toBe(false); + expect(handleLoopError({}, new Error("failed"))).toBe(false); + }); + + it("records timeout errors, preserves broadcast hash, and sets exit code 2", () => { + const txHash = byte32FromByte("33"); + const executionLog: Record = { txHash }; + + expect(handleLoopError(executionLog, transactionError(true, txHash))).toBe(true); + expect(process.exitCode).toBe(2); + expect(executionLog.txHash).toBe(txHash); + expect(executionLog.error).toMatchObject({ + name: "TransactionConfirmationError", + message: "Transaction confirmation timed out", + txHash, + status: "sent", + }); + + process.exitCode = undefined; + }); + + it("logs one JSON entry with elapsed seconds", () => { + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const now = vi.spyOn(Date, "now").mockReturnValue(2500); + const executionLog: Record = { + amount: 9007199254740993n, + txHash: byte32FromByte("44"), + }; + + logExecution(executionLog, new Date(1000)); + + expect(stdoutWrite).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(stdoutWrite.mock.calls[0]?.[0]))).toMatchObject({ + amount: "9007199254740993", + txHash: byte32FromByte("44"), + ElapsedSeconds: 2, + }); + + now.mockRestore(); + stdoutWrite.mockRestore(); + }); +}); + +function transactionError(isTimeout: boolean, txHash = byte32FromByte("11")): Error { + return Object.assign(new Error("Transaction confirmation timed out"), { + name: "TransactionConfirmationError", + txHash, + status: isTimeout ? "sent" : "rejected", + isTimeout, + }); +} diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts new file mode 100644 index 0000000..fbab41d --- /dev/null +++ b/packages/node-utils/src/index.ts @@ -0,0 +1,124 @@ +import { ccc } from "@ckb-ccc/core"; +import { unique } from "@ickb/utils"; +import process from "node:process"; +import { setTimeout } from "node:timers"; + +const CKB = 100000000n; + +const STOP_EXIT_CODE = 2; + +export type SupportedChain = "mainnet" | "testnet"; + +export function formatCkb(balance: bigint): string { + const sign = balance < 0n ? "-" : ""; + const absolute = balance < 0n ? -balance : balance; + const whole = absolute / CKB; + const fraction = absolute % CKB; + + if (fraction === 0n) { + return sign + whole.toString(); + } + + return `${sign}${whole.toString()}.${fraction.toString().padStart(8, "0").replace(/0+$/u, "")}`; +} + +function jsonLogReplacer(_: unknown, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} + +export function parseSupportedChain( + chain: string | undefined, + envName: string, +): SupportedChain { + if (chain === "mainnet" || chain === "testnet") { + return chain; + } + + throw new Error("Invalid env " + envName + ": " + (chain || "Empty")); +} + +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 seconds * 1000; +} + +export function createPublicClient( + chain: SupportedChain, + rpcUrl: string | undefined, +): ccc.Client { + const config = rpcUrl ? { url: rpcUrl } : undefined; + return chain === "mainnet" + ? new ccc.ClientPublicMainnet(config) + : new ccc.ClientPublicTestnet(config); +} + +export async function signerAccountLocks( + signer: ccc.Signer, + primaryLock: ccc.Script, +): Promise { + return [...unique([ + primaryLock, + ...(await signer.getAddressObjs()).map(({ script }) => script), + ])]; +} + +function errorToLog(error: unknown): unknown { + if (error instanceof Object && "stack" in error) { + const stack = error.stack ?? ""; + return { + name: "name" in error ? error.name : undefined, + message: + "message" in error && typeof error.message === "string" + ? error.message + : "Unknown error", + txHash: "txHash" in error ? error.txHash : undefined, + status: "status" in error ? error.status : undefined, + stack, + }; + } + + return error ?? "Empty Error"; +} + +function shouldStopAfterError(error: unknown): boolean { + return error instanceof Error && + error.name === "TransactionConfirmationError" && + "isTimeout" in error && + error.isTimeout === true; +} + +export function handleLoopError( + executionLog: Record, + error: unknown, +): boolean { + executionLog.error = errorToLog(error); + if (shouldStopAfterError(error)) { + process.exitCode = STOP_EXIT_CODE; + return true; + } + + return false; +} + +export function logExecution( + executionLog: Record, + startTime: Date, +): void { + executionLog.ElapsedSeconds = Math.round( + (Date.now() - startTime.getTime()) / 1000, + ); + process.stdout.write(`${JSON.stringify(executionLog, jsonLogReplacer, " ")}\n`); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/packages/node-utils/tsconfig.json b/packages/node-utils/tsconfig.json new file mode 100644 index 0000000..1172216 --- /dev/null +++ b/packages/node-utils/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist", + "sourceRoot": "../src", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/node-utils/vitest.config.mts b/packages/node-utils/vitest.config.mts new file mode 100644 index 0000000..8fb6f2d --- /dev/null +++ b/packages/node-utils/vitest.config.mts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c83682..97a54dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@eslint/js': specifier: ^9.39.3 version: 9.39.4 + '@ickb/testkit': + specifier: workspace:* + version: link:packages/testkit '@vitest/coverage-v8': specifier: 3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) @@ -80,6 +83,9 @@ importers: '@ickb/core': specifier: workspace:* version: link:../../packages/core + '@ickb/dao': + specifier: workspace:* + version: link:../../packages/dao '@ickb/order': specifier: workspace:* version: link:../../packages/order @@ -102,6 +108,9 @@ importers: '@babel/preset-react': specifier: ^7.27.1 version: 7.28.5(@babel/core@7.29.0) + '@ickb/testkit': + specifier: workspace:* + version: link:../../packages/testkit '@tailwindcss/vite': specifier: ^4.1.14 version: 4.2.4(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)) @@ -145,6 +154,9 @@ importers: specifier: workspace:* version: link:../../packages/utils devDependencies: + '@ickb/testkit': + specifier: workspace:* + version: link:../../packages/testkit '@types/node': specifier: 'catalog:' version: 24.12.2 @@ -157,6 +169,9 @@ importers: '@ickb/core': specifier: workspace:* version: link:../../packages/core + '@ickb/node-utils': + specifier: workspace:* + version: link:../../packages/node-utils '@ickb/order': specifier: workspace:* version: link:../../packages/order @@ -164,6 +179,9 @@ importers: specifier: workspace:* version: link:../../packages/sdk devDependencies: + '@ickb/testkit': + specifier: workspace:* + version: link:../../packages/testkit '@types/node': specifier: 'catalog:' version: 24.12.2 @@ -1090,6 +1108,22 @@ importers: specifier: workspace:* version: link:../testkit + packages/node-utils: + dependencies: + '@ckb-ccc/core': + specifier: workspace:* + version: link:../../forks/ccc/repo/packages/core + '@ickb/utils': + specifier: workspace:* + version: link:../utils + devDependencies: + '@ickb/testkit': + specifier: workspace:* + version: link:../testkit + '@types/node': + specifier: 'catalog:' + version: 24.12.2 + packages/order: dependencies: '@ckb-ccc/core': From 9ceea571f8663069d9f103d66f4a66c2b93698ce Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 13:36:14 +0000 Subject: [PATCH 2/3] fix(node-utils): keep execution logs line-delimited --- packages/node-utils/src/index.test.ts | 13 +++++++++++-- packages/node-utils/src/index.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/node-utils/src/index.test.ts b/packages/node-utils/src/index.test.ts index 9a49ac0..ce495d3 100644 --- a/packages/node-utils/src/index.test.ts +++ b/packages/node-utils/src/index.test.ts @@ -51,11 +51,12 @@ describe("node utilities", () => { it("keeps the primary signer lock first and deduplicates account locks", async () => { const primaryLock = script("11"); + const primaryLockCopy = ccc.Script.from(primaryLock); const otherLock = script("22"); const signer = { getAddressObjs: async () => { await Promise.resolve(); - return [{ script: otherLock }, { script: primaryLock }]; + return [{ script: otherLock }, { script: primaryLockCopy }]; }, } as ccc.Signer; @@ -126,7 +127,15 @@ describe("node utilities", () => { logExecution(executionLog, new Date(1000)); expect(stdoutWrite).toHaveBeenCalledTimes(1); - expect(JSON.parse(String(stdoutWrite.mock.calls[0]?.[0]))).toMatchObject({ + const logLine = String(stdoutWrite.mock.calls[0]?.[0]); + expect(logLine).toBe( + JSON.stringify({ + amount: "9007199254740993", + txHash: byte32FromByte("44"), + ElapsedSeconds: 2, + }) + "\n", + ); + expect(JSON.parse(logLine)).toMatchObject({ amount: "9007199254740993", txHash: byte32FromByte("44"), ElapsedSeconds: 2, diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts index fbab41d..088d140 100644 --- a/packages/node-utils/src/index.ts +++ b/packages/node-utils/src/index.ts @@ -114,7 +114,7 @@ export function logExecution( executionLog.ElapsedSeconds = Math.round( (Date.now() - startTime.getTime()) / 1000, ); - process.stdout.write(`${JSON.stringify(executionLog, jsonLogReplacer, " ")}\n`); + process.stdout.write(`${JSON.stringify(executionLog, jsonLogReplacer)}\n`); } export function sleep(ms: number): Promise { From 459c96213fc8368df7606aead9875f9fd6beb367 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 12 May 2026 14:11:11 +0000 Subject: [PATCH 3/3] fix(sampler): guard number-safe search bounds --- apps/sampler/src/index.test.ts | 11 +++++++++++ apps/sampler/src/index.ts | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/sampler/src/index.test.ts b/apps/sampler/src/index.test.ts index 474b1fa..ae5c70f 100644 --- a/apps/sampler/src/index.test.ts +++ b/apps/sampler/src/index.test.ts @@ -66,6 +66,17 @@ describe("sampler module", () => { expect(requests.some((blockNumber) => blockNumber > 0n)).toBe(true); }); + it("rejects tip heights beyond the number-safe search range", async () => { + const genesis = sampleHeader(0n, "2024-09-12T00:00:00.000Z"); + const tip = sampleHeader(2n ** 52n, "2024-09-13T00:00:00.000Z"); + + await expect(main({ + client: sampleClient(new Map([[0, genesis]]), tip), + log: () => {}, + samplesPerYear: 1, + })).rejects.toThrow("Tip block number exceeds sampler search range"); + }); + it("throws when the selected sample header is missing", async () => { const genesis = sampleHeader(0n, "2024-09-12T00:00:00.000Z"); const tip = sampleHeader(2n, "2024-09-13T00:00:00.000Z"); diff --git a/apps/sampler/src/index.ts b/apps/sampler/src/index.ts index 53d5599..06abf47 100644 --- a/apps/sampler/src/index.ts +++ b/apps/sampler/src/index.ts @@ -75,7 +75,11 @@ export async function main(options: MainOptions = {}): Promise { // Fetch tip header to bound our searches. const tip = await client.getTipHeader(); - const n = Math.pow(2, tip.number.toString(2).length); + const searchBound = 1n << BigInt(tip.number.toString(2).length); + if (searchBound > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("Tip block number exceeds sampler search range"); + } + const n = Number(searchBound); const dates = sampleTargets(genesis.timestamp, tip.timestamp, options.samplesPerYear);