From 72bc6d6def6456f7e63b980ec0896bf18f2ba2e6 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 20 May 2026 04:00:43 +0000 Subject: [PATCH 1/5] feat(bot): bound live actor execution --- apps/bot/src/index.test.ts | 105 ++++++++++++++++++++++++++++- apps/bot/src/index.ts | 19 +++++- apps/bot/src/observability.test.ts | 55 +++++++++++++++ apps/bot/src/observability.ts | 50 +++++++++----- apps/bot/src/runtime.ts | 41 ++++++++++- 5 files changed, 243 insertions(+), 27 deletions(-) diff --git a/apps/bot/src/index.test.ts b/apps/bot/src/index.test.ts index f9ed446..6b080f7 100644 --- a/apps/bot/src/index.test.ts +++ b/apps/bot/src/index.test.ts @@ -10,7 +10,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TARGET_ICKB_BALANCE } from "./policy.js"; import { completeTerminalIteration, readBotRuntimeConfig } from "./index.js"; -import { buildTransaction, collectPoolDeposits } from "./runtime.js"; +import { buildTransaction, collectPoolDeposits, postTransactionPlainCkbBalance } from "./runtime.js"; afterEach(() => { vi.restoreAllMocks(); @@ -52,6 +52,7 @@ function readyDeposit( function botState(overrides: Record): Record { return { accountLocks: [], + capacityCells: [], marketOrders: [], availableCkbBalance: 0n, availableIckbBalance: 0n, @@ -93,6 +94,10 @@ function botRuntime(overrides: { addMatch: (txLike: ccc.TransactionLike): ccc.Transaction => ccc.Transaction.from(txLike), }, + logic: { + deposit: (txLike: ccc.TransactionLike): Promise => + Promise.resolve(ccc.Transaction.from(txLike)), + }, }, sdk: { buildBaseTransaction: async ( @@ -190,6 +195,60 @@ describe("readBotRuntimeConfig", () => { }); describe("buildTransaction", () => { + it("preserves the bot plain CKB reserve when matching orders", async () => { + const bestMatch = vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + + await buildTransaction(botRuntime() as never, botState({ + availableCkbBalance: ccc.fixedPointFrom(5000), + availableIckbBalance: TARGET_ICKB_BALANCE, + }) as never); + + expect(bestMatch.mock.calls[0]?.[1]).toMatchObject({ + ckbValue: ccc.fixedPointFrom(4000), + }); + }); + + it("skips built transactions that would violate the bot plain CKB reserve", async () => { + const lock = script("11"); + const spent = capacityCell(1000n, lock, "77"); + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 1n, + udtDelta: 0n, + partials: [{} as never], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const runtime = botRuntime({ + primaryLock: lock, + sdk: { + completeTransaction: async (txLike: ccc.TransactionLike): Promise => { + await Promise.resolve(); + const tx = ccc.Transaction.from(txLike); + tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); + tx.addOutput({ capacity: 1n, lock }); + return tx; + }, + }, + }); + const state = botState({ + accountLocks: [lock], + capacityCells: [spent], + marketOrders: [{}], + availableCkbBalance: ccc.fixedPointFrom(5000), + availableIckbBalance: TARGET_ICKB_BALANCE, + totalCkbBalance: ccc.fixedPointFrom(5000), + }); + + await expect(buildTransaction(runtime as never, state as never)).resolves.toMatchObject({ + kind: "skipped", + reason: "post_tx_ckb_reserve", + decision: { skip: { reason: "post_tx_ckb_reserve" } }, + }); + }); + it("skips match-only transactions when the completed fee consumes the match value", async () => { vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ ckbDelta: 1n, @@ -198,10 +257,14 @@ describe("buildTransaction", () => { }); vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); - const runtime = botRuntime(); + const lock = script("11"); + const runtime = botRuntime({ primaryLock: lock }); const state = botState({ + accountLocks: [lock], + capacityCells: [capacityCell(ccc.fixedPointFrom(2000), lock, "66")], marketOrders: [{}], availableCkbBalance: 100n, + availableIckbBalance: TARGET_ICKB_BALANCE, totalCkbBalance: 100n, }); @@ -226,10 +289,14 @@ describe("buildTransaction", () => { }); vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); - const runtime = botRuntime(); + const lock = script("11"); + const runtime = botRuntime({ primaryLock: lock }); const state = botState({ + accountLocks: [lock], + capacityCells: [capacityCell(ccc.fixedPointFrom(2000), lock, "67")], marketOrders: [{}], availableCkbBalance: 100n, + availableIckbBalance: TARGET_ICKB_BALANCE, totalCkbBalance: 100n, system: { feeRate: 1n, @@ -311,6 +378,8 @@ describe("buildTransaction", () => { primaryLock: script("44"), }); const state = botState({ + accountLocks: [script("44")], + capacityCells: [capacityCell(ccc.fixedPointFrom(2000), script("44"), "68")], marketOrders: [], availableIckbBalance: TARGET_ICKB_BALANCE + 9n, depositCapacity: 1000n, @@ -335,3 +404,33 @@ describe("buildTransaction", () => { expect(result.tx.cellDeps).toEqual([]); }); }); + +describe("postTransactionPlainCkbBalance", () => { + it("counts unspent account plain CKB plus account plain outputs", () => { + const lock = script("11"); + const otherLock = script("22"); + const spent = capacityCell(ccc.fixedPointFrom(1000), lock, "aa"); + const unspent = capacityCell(ccc.fixedPointFrom(2000), lock, "bb"); + const tx = ccc.Transaction.default(); + tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); + tx.outputs.push( + ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(300), lock }), + ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(500), lock, type: script("33") }), + ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(700), lock: otherLock }), + ); + tx.outputsData.push("0x", "0x", "0x"); + + expect(postTransactionPlainCkbBalance( + tx, + botState({ accountLocks: [lock], capacityCells: [spent, unspent] }) as never, + )).toBe(ccc.fixedPointFrom(2300)); + }); +}); + +function capacityCell(capacity: bigint, lock: ccc.Script, txByte: string): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: hash(txByte), index: 0n }, + cellOutput: { capacity, lock }, + outputData: "0x", + }); +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 82bbb2a..79dd48e 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -19,6 +19,7 @@ import { sleep, STOP_EXIT_CODE, type RuntimeConfig, + verifyChainPreflight, } from "@ickb/node-utils"; import { buildTransaction, @@ -40,6 +41,7 @@ import { async function main(): Promise { const runtimeConfig = await readBotRuntimeConfig(process.env); const { chain, privateKey, rpcUrl, sleepIntervalMs, maxIterations } = runtimeConfig; + const secrets = { privateKey, rpcUrl }; const runId = createRunId(); const events = new BotEventEmitter({ chain, runId }); events.emit(0, "bot.run.started", { @@ -47,6 +49,7 @@ async function main(): Promise { bounded: maxIterations !== undefined, }); const client = createPublicClient(chain, rpcUrl); + await verifyChainPreflight(client, chain); const config = getConfig(chain); const { managers } = config; const signer = new ccc.SignerCkbPrivateKey(client, privateKey); @@ -133,7 +136,7 @@ async function main(): Promise { executionLog.txHash = txHash; }, onLifecycle: (event) => { - for (const lifecycle of transactionLifecycleEvents(event)) { + for (const lifecycle of transactionLifecycleEvents(event, secrets)) { events.emit(iterationId, lifecycle.type, { ...lifecycle.fields, ...(event.type === "broadcasted" @@ -145,10 +148,14 @@ async function main(): Promise { }); } } catch (error) { - stopAfterLog = handleLoopError(executionLog, error); + stopAfterLog = handleLoopError(executionLog, error, secrets); events.emit(iterationId, "bot.iteration.failed", { - error: errorSummary(error), + error: errorSummary(error, secrets), }); + if (isRetryableBotError(error)) { + logExecution(executionLog, startTime); + continue; + } } const completion = completeTerminalIteration(completedIterations, maxIterations); @@ -211,6 +218,7 @@ async function readBotState(runtime: Runtime): Promise { return { accountLocks, + capacityCells: account.capacityCells, system, userOrders: user.orders, marketOrders, @@ -235,6 +243,11 @@ function outPointKey(outPoint: ccc.OutPoint): string { const fmtCkb = formatCkb; +function isRetryableBotError(error: unknown): boolean { + return error instanceof Error && error.message.includes("L1 state scan crossed chain tip"); +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); + process.exit(process.exitCode ?? 0); } diff --git a/apps/bot/src/observability.test.ts b/apps/bot/src/observability.test.ts index 7bb8771..010dda9 100644 --- a/apps/bot/src/observability.test.ts +++ b/apps/bot/src/observability.test.ts @@ -200,6 +200,61 @@ describe("bot observability", () => { }); }); + it("redacts runtime secrets in structured error summaries", () => { + const privateKey = `0x${"11".repeat(32)}`; + const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret"; + const cause = new Error(`inner ${privateKey} ${rpcUrl}`); + const error = new Error(`outer ${privateKey} ${rpcUrl}`, { cause }); + error.stack = `outer stack ${privateKey} ${rpcUrl}`; + cause.stack = `inner stack ${privateKey} ${rpcUrl}`; + + const summary = errorSummary(error, { privateKey, rpcUrl }) as Record; + const serialized = JSON.stringify(summary); + + expect(serialized).not.toContain(privateKey); + expect(serialized).not.toContain("user:pass"); + expect(serialized).not.toContain("secret"); + expect(serialized).toContain(""); + expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted"); + }); + + it("redacts runtime secrets in structured object error summaries", () => { + const privateKey = `0x${"11".repeat(32)}`; + const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret"; + + const summary = errorSummary({ + message: `object ${privateKey}`, + rpcUrl, + amount: 9007199254740993n, + }, { privateKey, rpcUrl }) as Record; + const serialized = JSON.stringify(summary); + + expect(serialized).not.toContain(privateKey); + expect(serialized).not.toContain("user:pass"); + expect(serialized).not.toContain("secret"); + expect(serialized).toContain(""); + expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted"); + }); + + it("redacts runtime secrets in transaction lifecycle errors", () => { + const privateKey = `0x${"11".repeat(32)}`; + const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret"; + const error = new Error(`lifecycle ${privateKey} ${rpcUrl}`); + + const events = transactionLifecycleEvents({ + type: "pre_broadcast_failed", + elapsedMs: 12, + error, + }, { privateKey, rpcUrl }); + const serialized = JSON.stringify(events); + + expect(serialized).not.toContain(privateKey); + expect(serialized).not.toContain("user:pass"); + expect(serialized).not.toContain("secret"); + expect(serialized).toContain(""); + expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted"); + }); + it("summarizes thrown objects with JSON-safe details", () => { const summary = errorSummary({ code: "RPC_FAILURE", diff --git a/apps/bot/src/observability.ts b/apps/bot/src/observability.ts index ddc9eb2..21cad4b 100644 --- a/apps/bot/src/observability.ts +++ b/apps/bot/src/observability.ts @@ -1,5 +1,11 @@ import { type ccc } from "@ckb-ccc/core"; -import { jsonLogReplacer, writeJsonLine, type SupportedChain } from "@ickb/node-utils"; +import { + jsonLogReplacer, + redactSecretText, + writeJsonLine, + type SecretRedactionContext, + type SupportedChain, +} from "@ickb/node-utils"; import { type SendAndWaitForCommitEvent } from "@ickb/sdk"; import { type BotActions, @@ -116,6 +122,7 @@ export function transactionSummary( export function transactionLifecycleEvents( event: SendAndWaitForCommitEvent, + secrets: SecretRedactionContext = {}, ): Array<{ type: "bot.transaction.sent" | "bot.transaction.confirmation" | "bot.transaction.committed" | "bot.transaction.failed"; fields: Record; @@ -130,11 +137,11 @@ export function transactionLifecycleEvents( return [ { type: "bot.transaction.confirmation", - fields: { ...confirmationFields(event), outcome: "committed" }, + fields: { ...confirmationFields(event, secrets), outcome: "committed" }, }, { type: "bot.transaction.committed", - fields: confirmationFields(event), + fields: confirmationFields(event, secrets), }, ]; case "timeout_after_broadcast": @@ -143,14 +150,14 @@ export function transactionLifecycleEvents( { type: "bot.transaction.confirmation", fields: { - ...confirmationFields(event), + ...confirmationFields(event, secrets), outcome: event.type, }, }, { type: "bot.transaction.failed", fields: { - ...confirmationFields(event), + ...confirmationFields(event, secrets), outcome: event.type, }, }, @@ -160,14 +167,14 @@ export function transactionLifecycleEvents( { type: "bot.transaction.confirmation", fields: { - ...confirmationFields(event), + ...confirmationFields(event, secrets), outcome: "terminal_rejection", }, }, { type: "bot.transaction.failed", fields: { - ...confirmationFields(event), + ...confirmationFields(event, secrets), outcome: "terminal_rejection", }, }, @@ -178,7 +185,7 @@ export function transactionLifecycleEvents( fields: { phase: "pre_broadcast", elapsedMs: event.elapsedMs, - error: errorSummary(event.error), + error: errorSummary(event.error, secrets), }, }]; } @@ -205,11 +212,18 @@ export function lowCapitalSkipDecision( }; } -export function errorSummary(error: unknown): Record | string { - return summarizeError(error, new Set()); +export function errorSummary( + error: unknown, + secrets: SecretRedactionContext = {}, +): Record | string { + return summarizeError(error, new Set(), secrets); } -function summarizeError(error: unknown, seen: Set): Record | string { +function summarizeError( + error: unknown, + seen: Set, + secrets: SecretRedactionContext, +): Record | string { if (error instanceof Error) { if (seen.has(error)) { return { message: "Circular error reference" }; @@ -218,12 +232,12 @@ function summarizeError(error: unknown, seen: Set): Record): Record): Record): Record): Record { +>, secrets: SecretRedactionContext = {}): Record { return { txHash: event.txHash, status: event.status, checks: event.checks, elapsedMs: event.elapsedMs, - ...("error" in event ? { error: errorSummary(event.error) } : {}), + ...("error" in event ? { error: errorSummary(event.error, secrets) } : {}), }; } diff --git a/apps/bot/src/runtime.ts b/apps/bot/src/runtime.ts index 7e5b403..77913fc 100644 --- a/apps/bot/src/runtime.ts +++ b/apps/bot/src/runtime.ts @@ -14,6 +14,7 @@ import { type getConfig, type IckbSdk, type SystemState } from "@ickb/sdk"; import { type SupportedChain } from "@ickb/node-utils"; import { collectCompleteScan, defaultFindCellsLimit } from "@ickb/utils"; import { + CKB_RESERVE, partitionPoolDeposits, planRebalance, type RebalanceNoopReason, @@ -36,6 +37,7 @@ export interface Runtime { export interface BotState { accountLocks: ccc.Script[]; + capacityCells: ccc.Cell[]; system: SystemState; userOrders: OrderGroup[]; marketOrders: OrderCell[]; @@ -64,7 +66,8 @@ export interface BotActions { export type BuildTransactionSkipReason = | "no_actions" - | "match_value_not_above_fee"; + | "match_value_not_above_fee" + | "post_tx_ckb_reserve"; export type BotDecisionSkipReason = | BuildTransactionSkipReason @@ -156,6 +159,8 @@ export interface BotDecisionTranscript { reason: BotDecisionSkipReason; fee?: bigint; matchValue?: bigint; + postTxCkbBalance?: bigint; + reserve?: bigint; }; } @@ -179,7 +184,7 @@ export async function buildTransaction( const match = OrderManager.bestMatch( state.marketOrders, { - ckbValue: state.availableCkbBalance, + ckbValue: spendableCkb(state.availableCkbBalance), udtValue: state.availableIckbBalance, }, state.system.exchangeRatio, @@ -257,6 +262,7 @@ export async function buildTransaction( feeRate: state.system.feeRate, }); const fee = tx.estimateFee(state.system.feeRate); + const postTxCkbBalance = postTransactionPlainCkbBalance(tx, state); decision = buildDecisionTranscript({ state, match, @@ -266,6 +272,12 @@ export async function buildTransaction( tx, }); decision = { ...decision, fee: { ...decision.fee, estimated: fee } }; + if (postTxCkbBalance < CKB_RESERVE) { + return skippedResult("post_tx_ckb_reserve", actions, decision, { + postTxCkbBalance, + reserve: CKB_RESERVE, + }); + } if (isMatchOnly(actions)) { const matchValue = @@ -440,11 +452,26 @@ export function transactionShape(tx: ccc.Transaction): BotDecisionTranscript["tr }; } +export function postTransactionPlainCkbBalance(tx: ccc.Transaction, state: BotState): bigint { + const accountLockHexes = new Set(state.accountLocks.map((lock) => lock.toHex())); + const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); + const unspentCapacity = state.capacityCells.reduce( + (total, cell) => spentOutPoints.has(cell.outPoint.toHex()) ? total : total + cell.cellOutput.capacity, + 0n, + ); + const outputCapacity = tx.outputs.reduce( + (total, output, index) => total + (isAccountPlainCapacityOutput(output, tx.outputsData[index], accountLockHexes) ? output.capacity : 0n), + 0n, + ); + + return unspentCapacity + outputCapacity; +} + function skippedResult( reason: BuildTransactionSkipReason, actions: BotActions, decision: BotDecisionTranscript, - details?: { fee?: bigint; matchValue?: bigint }, + details?: { fee?: bigint; matchValue?: bigint; postTxCkbBalance?: bigint; reserve?: bigint }, ): BuildTransactionResult { return { kind: "skipped", @@ -469,6 +496,14 @@ function actionTotal(actions: BotActions): number { actions.withdrawals; } +function spendableCkb(availableCkbBalance: bigint): bigint { + return maxBigInt(0n, availableCkbBalance - CKB_RESERVE); +} + +function isAccountPlainCapacityOutput(output: ccc.CellOutput, outputData: string | undefined, accountLockHexes: Set): boolean { + return output.type === undefined && (outputData ?? "0x") === "0x" && accountLockHexes.has(output.lock.toHex()); +} + function maxBigInt(left: bigint, right: bigint): bigint { return left > right ? left : right; } From f3b07b4d3fc439368ac330cb295bb3e80331fda5 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 20 May 2026 04:00:48 +0000 Subject: [PATCH 2/5] feat(tester): add live scenario controls --- apps/tester/README.md | 21 ++ apps/tester/src/index.test.ts | 336 ++++++++++++++++++++- apps/tester/src/index.ts | 502 ++++++++++++++++++++++++++++---- apps/tester/src/runtime.test.ts | 206 ++++++++++++- apps/tester/src/runtime.ts | 66 ++++- 5 files changed, 1054 insertions(+), 77 deletions(-) diff --git a/apps/tester/README.md b/apps/tester/README.md index d4279e6..9fdfe72 100644 --- a/apps/tester/README.md +++ b/apps/tester/README.md @@ -43,6 +43,27 @@ pnpm run start 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. +## Test Scenarios + +`TESTER_SCENARIO` selects the order-building path for live supervision. Omit it, or set `auto`, to choose randomly from the supported scenarios on each tester process start: + +- `random-order`: current randomized raw limit-order behavior. +- `sdk-conversion`: uses the SDK conversion transaction builder, then completes and sends the transaction. Its log `actions.conversion.kind` reports whether the SDK built a direct conversion, an order, or both. +- `extra-large-limit-order`: creates a raw CKB-to-iCKB limit order larger than one deposit-cap unit. It fails instead of silently downsizing if the account cannot preserve the tester CKB reserve. +- `multi-order-limit-orders`: creates any supported two-order raw limit-order transaction based on available balances. It prefers mixed direction when both sides can fund two orders, then CKB-to-iCKB, then iCKB-to-CKB. Its log records `actions.requestedTesterScenario` as `multi-order-limit-orders` and `actions.testerScenario` as the concrete multi-order type. +- `two-ckb-to-ickb-limit-orders`: creates two raw CKB-to-iCKB limit orders in one transaction. Its log uses `actions.newOrders` and `actions.orderCount` instead of singular `actions.newOrder`. +- `all-ckb-limit-order`: creates one raw CKB-to-iCKB limit order with all currently available CKB except the tester reserve. +- `ickb-to-ckb-limit-order`: creates one raw iCKB-to-CKB limit order with all currently available iCKB. Use this for iCKB withdrawal-through-LO coverage. +- `two-ickb-to-ckb-limit-orders`: creates two raw iCKB-to-CKB limit orders in one transaction. Its log uses `actions.newOrders` and `actions.orderCount`. +- `mixed-direction-limit-orders`: creates one raw CKB-to-iCKB order and one raw iCKB-to-CKB order in one transaction. Its log uses `actions.newOrders` and `actions.orderCount`. +- `all-ickb-limit-order`: compatibility alias for `ickb-to-ckb-limit-order`. +- `dust-ckb-conversion`: tries a one-shannon CKB-to-iCKB conversion with the normal tester order fee. +- `dust-ickb-conversion`: tries a one-shannon iCKB-to-CKB conversion with the normal tester order fee. + +These are tester-owned scenario controls. The supervisor may set `TESTER_SCENARIO`, but it must not mutate funded config files in place or force tx-bearing paths outside the tester runtime. + +Raw limit-order scenarios use `TESTER_FEE=1` and `TESTER_FEE_BASE=100000` by default, matching the normal 0.001% order fee. Set `TESTER_FEE` and `TESTER_FEE_BASE` to unsigned integers to exercise alternate raw order fees; `TESTER_FEE` must be less than `TESTER_FEE_BASE`, and `TESTER_FEE_BASE` is capped at `1000000`. The selected numerator/base are recorded on `actions.newOrder.feeNumerator` and `actions.newOrder.feeBase`, or on each `actions.newOrders[]` entry for multi-order scenarios. `sdk-conversion` keeps using SDK-owned fee defaults for any order remainder. + ## Licensing The license for this repository is the MIT License, see the [`LICENSE`](../../LICENSE). diff --git a/apps/tester/src/index.test.ts b/apps/tester/src/index.test.ts index b077275..3668c10 100644 --- a/apps/tester/src/index.test.ts +++ b/apps/tester/src/index.test.ts @@ -5,7 +5,18 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { freshMatchableOrderSkip } from "./freshMatchableOrderSkip.js"; -import { readTesterRuntimeConfig } from "./index.js"; +import { + isRetryableTesterError, + planTesterTransaction, + postTransactionPlainCkbBalance, + randomTesterScenario, + readTesterFeePolicy, + readTesterRuntimeConfig, + readTesterScenario, + resolveTesterScenario, + TesterTerminalError, +} from "./index.js"; +import { type TesterState } from "./runtime.js"; describe("readTesterRuntimeConfig", () => { it("requires a JSON config file", async () => { @@ -38,6 +49,276 @@ describe("readTesterRuntimeConfig", () => { }); }); +describe("readTesterScenario", () => { + it("defaults to random orders and accepts explicit tester scenarios", () => { + expect(randomTesterScenario(() => 0)).toBe("random-order"); + expect(randomTesterScenario(() => 0.99)).toBe("dust-ickb-conversion"); + expect(readTesterScenario({ TESTER_SCENARIO: "sdk-conversion" })).toBe("sdk-conversion"); + expect(readTesterScenario({ TESTER_SCENARIO: "extra-large-limit-order" })).toBe("extra-large-limit-order"); + expect(readTesterScenario({ TESTER_SCENARIO: "multi-order-limit-orders" })).toBe("multi-order-limit-orders"); + expect(readTesterScenario({ TESTER_SCENARIO: "two-ckb-to-ickb-limit-orders" })).toBe("two-ckb-to-ickb-limit-orders"); + expect(readTesterScenario({ TESTER_SCENARIO: "all-ckb-limit-order" })).toBe("all-ckb-limit-order"); + expect(readTesterScenario({ TESTER_SCENARIO: "all-ickb-limit-order" })).toBe("all-ickb-limit-order"); + expect(readTesterScenario({ TESTER_SCENARIO: "ickb-to-ckb-limit-order" })).toBe("ickb-to-ckb-limit-order"); + expect(readTesterScenario({ TESTER_SCENARIO: "two-ickb-to-ckb-limit-orders" })).toBe("two-ickb-to-ckb-limit-orders"); + expect(readTesterScenario({ TESTER_SCENARIO: "mixed-direction-limit-orders" })).toBe("mixed-direction-limit-orders"); + expect(readTesterScenario({ TESTER_SCENARIO: "dust-ckb-conversion" })).toBe("dust-ckb-conversion"); + expect(readTesterScenario({ TESTER_SCENARIO: "dust-ickb-conversion" })).toBe("dust-ickb-conversion"); + expect(() => readTesterScenario({ TESTER_SCENARIO: "interface-like" })).toThrow("Invalid env TESTER_SCENARIO"); + expect(() => readTesterScenario({ TESTER_SCENARIO: "unsafe" })).toThrow("Invalid env TESTER_SCENARIO"); + }); +}); + +describe("readTesterFeePolicy", () => { + it("defaults to the normal live order fee", () => { + expect(readTesterFeePolicy({})).toEqual({ fee: 1n, feeBase: 100000n }); + }); + + it("accepts bounded fee overrides", () => { + expect(readTesterFeePolicy({ TESTER_FEE: "0", TESTER_FEE_BASE: "100000" })).toEqual({ + fee: 0n, + feeBase: 100000n, + }); + expect(readTesterFeePolicy({ TESTER_FEE: "1000", TESTER_FEE_BASE: "100000" })).toEqual({ + fee: 1000n, + feeBase: 100000n, + }); + }); + + it("rejects malformed or unsafe fee overrides", () => { + expect(() => readTesterFeePolicy({ TESTER_FEE: "1.5" })).toThrow("Invalid env TESTER_FEE"); + expect(() => readTesterFeePolicy({ TESTER_FEE_BASE: "0" })).toThrow("Invalid env TESTER_FEE_BASE"); + expect(() => readTesterFeePolicy({ TESTER_FEE_BASE: "1000001" })).toThrow("Invalid env TESTER_FEE_BASE"); + expect(() => readTesterFeePolicy({ TESTER_FEE: "100000", TESTER_FEE_BASE: "100000" })).toThrow( + "TESTER_FEE must be less than TESTER_FEE_BASE", + ); + }); +}); + +describe("isRetryableTesterError", () => { + it("recognizes the live state-scan tip race", () => { + expect(isRetryableTesterError(new Error("L1 state scan crossed chain tip; retry with a fresh state"))).toBe(true); + expect(isRetryableTesterError(new Error("Not enough CKB"))).toBe(false); + }); +}); + +describe("planTesterTransaction", () => { + it("does not silently downsize extra-large limit orders", () => { + const depositCapacity = 1000n; + const state = testerState({ availableCkbBalance: 202000000000n }); + + expect(planTesterTransaction(state, depositCapacity, "extra-large-limit-order")).toEqual({ + direction: "ckb-to-ickb", + amount: 2000n, + ckbAmount: 2000n, + udtAmount: 0n, + orderCount: 1, + }); + }); + + it("fails extra-large limit orders when funding cannot preserve reserve", () => { + const depositCapacity = 1000n; + const state = testerState({ availableCkbBalance: 3000n }); + + expect(() => planTesterTransaction(state, depositCapacity, "extra-large-limit-order")).toThrow( + "Not enough CKB for extra-large limit order scenario", + ); + expect(() => planTesterTransaction(state, depositCapacity, "extra-large-limit-order")).toThrow( + TesterTerminalError, + ); + }); + + it("spends all available CKB except reserve for all-CKB limit orders", () => { + const state = testerState({ availableCkbBalance: ccc.fixedPointFrom(650000) }); + + expect(planTesterTransaction(state, 1000n, "all-ckb-limit-order")).toEqual({ + direction: "ckb-to-ickb", + amount: ccc.fixedPointFrom(647500), + ckbAmount: ccc.fixedPointFrom(647500), + udtAmount: 0n, + orderCount: 1, + }); + }); + + it("plans two CKB-to-iCKB limit orders with all available CKB except reserve", () => { + const state = testerState({ availableCkbBalance: ccc.fixedPointFrom(650000) }); + + expect(planTesterTransaction(state, 1000n, "two-ckb-to-ickb-limit-orders")).toEqual({ + direction: "ckb-to-ickb", + amount: ccc.fixedPointFrom(647500), + ckbAmount: ccc.fixedPointFrom(647500), + udtAmount: 0n, + orderCount: 2, + }); + }); + + it("fails two CKB-to-iCKB limit orders when funding cannot preserve reserve", () => { + const state = testerState({ availableCkbBalance: ccc.fixedPointFrom(2000) }); + + expect(() => planTesterTransaction(state, 1000n, "two-ckb-to-ickb-limit-orders")).toThrow( + "Not enough CKB for two CKB-to-iCKB limit orders scenario", + ); + expect(() => planTesterTransaction(state, 1000n, "two-ckb-to-ickb-limit-orders")).toThrow( + TesterTerminalError, + ); + }); + + it("fails all-CKB limit orders when funding cannot preserve reserve", () => { + const state = testerState({ availableCkbBalance: ccc.fixedPointFrom(2000) }); + + expect(() => planTesterTransaction(state, 1000n, "all-ckb-limit-order")).toThrow( + "Not enough CKB for all-CKB limit order scenario", + ); + expect(() => planTesterTransaction(state, 1000n, "all-ckb-limit-order")).toThrow( + TesterTerminalError, + ); + }); + + it("spends all available iCKB for iCKB-to-CKB limit orders", () => { + const state = testerState({ availableCkbBalance: 0n, availableIckbBalance: ccc.fixedPointFrom(123) }); + + expect(planTesterTransaction(state, 1000n, "all-ickb-limit-order")).toEqual({ + direction: "ickb-to-ckb", + amount: ccc.fixedPointFrom(123), + ckbAmount: 0n, + udtAmount: ccc.fixedPointFrom(123), + orderCount: 1, + }); + expect(planTesterTransaction(state, 1000n, "ickb-to-ckb-limit-order")).toEqual({ + direction: "ickb-to-ckb", + amount: ccc.fixedPointFrom(123), + ckbAmount: 0n, + udtAmount: ccc.fixedPointFrom(123), + orderCount: 1, + }); + }); + + it("plans two iCKB-to-CKB limit orders with all available iCKB", () => { + const state = testerState({ availableCkbBalance: 0n, availableIckbBalance: ccc.fixedPointFrom(123) }); + + expect(planTesterTransaction(state, 1000n, "two-ickb-to-ckb-limit-orders")).toEqual({ + direction: "ickb-to-ckb", + amount: ccc.fixedPointFrom(123), + ckbAmount: 0n, + udtAmount: ccc.fixedPointFrom(123), + orderCount: 2, + }); + }); + + it("fails two iCKB-to-CKB limit orders when funding cannot create two orders", () => { + const state = testerState({ availableCkbBalance: 0n, availableIckbBalance: 1n }); + + expect(() => planTesterTransaction(state, 1000n, "two-ickb-to-ckb-limit-orders")).toThrow( + "Not enough iCKB for two iCKB-to-CKB limit orders scenario", + ); + expect(() => planTesterTransaction(state, 1000n, "two-ickb-to-ckb-limit-orders")).toThrow( + TesterTerminalError, + ); + }); + + it("plans mixed-direction limit orders with available CKB and iCKB", () => { + const state = testerState({ + availableCkbBalance: ccc.fixedPointFrom(650000), + availableIckbBalance: ccc.fixedPointFrom(123), + }); + + expect(planTesterTransaction(state, 1000n, "mixed-direction-limit-orders")).toEqual({ + direction: "ckb-to-ickb", + amount: ccc.fixedPointFrom(647623), + ckbAmount: ccc.fixedPointFrom(647500), + udtAmount: ccc.fixedPointFrom(123), + orderCount: 2, + }); + }); + + it("fails mixed-direction limit orders when either side is unavailable", () => { + expect(() => planTesterTransaction( + testerState({ availableCkbBalance: ccc.fixedPointFrom(2000), availableIckbBalance: ccc.fixedPointFrom(123) }), + 1000n, + "mixed-direction-limit-orders", + )).toThrow("Not enough CKB for mixed-direction limit orders scenario"); + expect(() => planTesterTransaction( + testerState({ availableCkbBalance: ccc.fixedPointFrom(650000), availableIckbBalance: 0n }), + 1000n, + "mixed-direction-limit-orders", + )).toThrow("Not enough iCKB for mixed-direction limit orders scenario"); + }); + + it("creates dust conversion scenarios with normal fee handling", () => { + expect(planTesterTransaction( + testerState({ availableCkbBalance: ccc.fixedPointFrom(3000) }), + 1000n, + "dust-ckb-conversion", + )).toEqual({ direction: "ckb-to-ickb", amount: 1n, ckbAmount: 1n, udtAmount: 0n, orderCount: 1 }); + expect(planTesterTransaction( + testerState({ availableCkbBalance: 0n, availableIckbBalance: 10n }), + 1000n, + "dust-ickb-conversion", + )).toEqual({ direction: "ickb-to-ckb", amount: 1n, ckbAmount: 0n, udtAmount: 1n, orderCount: 1 }); + }); + + it("plans SDK conversion scenarios as full deposit-cap conversions", () => { + const ckbState = testerState({ availableCkbBalance: ccc.fixedPointFrom(3000) }); + expect(planTesterTransaction(ckbState, 1000n, "sdk-conversion")).toEqual({ + direction: "ckb-to-ickb", + amount: 1000n, + ckbAmount: 1000n, + udtAmount: 0n, + orderCount: 1, + }); + }); + + it("resolves generic multi-order scenarios to any funded multi-order type", () => { + expect(resolveTesterScenario(testerState({ + availableCkbBalance: ccc.fixedPointFrom(650000), + availableIckbBalance: ccc.fixedPointFrom(123), + }), "multi-order-limit-orders")).toBe("mixed-direction-limit-orders"); + expect(planTesterTransaction(testerState({ + availableCkbBalance: ccc.fixedPointFrom(650000), + availableIckbBalance: ccc.fixedPointFrom(123), + }), 1000n, "multi-order-limit-orders")).toMatchObject({ + direction: "ckb-to-ickb", + orderCount: 2, + }); + expect(resolveTesterScenario(testerState({ + availableCkbBalance: ccc.fixedPointFrom(650000), + availableIckbBalance: 0n, + }), "multi-order-limit-orders")).toBe("two-ckb-to-ickb-limit-orders"); + expect(resolveTesterScenario(testerState({ + availableCkbBalance: 0n, + availableIckbBalance: ccc.fixedPointFrom(123), + }), "multi-order-limit-orders")).toBe("two-ickb-to-ckb-limit-orders"); + expect(resolveTesterScenario(testerState({ + availableCkbBalance: ccc.fixedPointFrom(2500) + 1n, + availableIckbBalance: ccc.fixedPointFrom(123), + }), "multi-order-limit-orders")).toBe("mixed-direction-limit-orders"); + expect(() => resolveTesterScenario(testerState({ + availableCkbBalance: ccc.fixedPointFrom(2000), + availableIckbBalance: 1n, + }), "multi-order-limit-orders")).toThrow("Not enough funds for multi-order limit orders scenario"); + expect(resolveTesterScenario(testerState({ availableCkbBalance: 0n }), "sdk-conversion")).toBe("sdk-conversion"); + }); + + it("computes post-transaction plain CKB reserve from unspent inputs and account outputs", () => { + const lock = script("11"); + const otherLock = script("22"); + const spent = capacityCell(ccc.fixedPointFrom(1000), lock, "01"); + const unspent = capacityCell(ccc.fixedPointFrom(2000), lock, "02"); + const tx = ccc.Transaction.default(); + tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); + tx.addOutput({ capacity: ccc.fixedPointFrom(300), lock }); + tx.addOutput({ capacity: ccc.fixedPointFrom(500), lock, type: script("33") }); + tx.addOutput({ capacity: ccc.fixedPointFrom(700), lock: otherLock }); + tx.addOutput({ capacity: ccc.fixedPointFrom(900), lock }, "0x1234"); + + expect(postTransactionPlainCkbBalance( + tx, + testerState({ availableCkbBalance: ccc.fixedPointFrom(3000), capacityCells: [spent, unspent] }), + [lock], + )).toBe(ccc.fixedPointFrom(2300)); + }); +}); + describe("freshMatchableOrderSkip", () => { it("explains skips caused by unavailable transaction lookup", async () => { const txHash = byte32FromByte("11"); @@ -113,3 +394,56 @@ function order(txHash: ccc.Hex, isMatchable: boolean): never { }, } as never; } + +function testerState(values: { + availableCkbBalance: bigint; + availableIckbBalance?: bigint; + capacityCells?: ccc.Cell[]; +}): TesterState { + const availableIckbBalance = values.availableIckbBalance ?? 0n; + return { + system: { + exchangeRatio: { ckbScale: 1n, udtScale: 1n }, + feeRate: 1000n, + tip: headerLike({ timestamp: 0n }), + orderPool: [], + ckbAvailable: values.availableCkbBalance, + ckbMaturing: [], + } as TesterState["system"], + account: { + capacityCells: values.capacityCells ?? [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + userOrders: [], + conversionContext: { + system: { + exchangeRatio: { ckbScale: 1n, udtScale: 1n }, + feeRate: 1000n, + tip: headerLike({ timestamp: 0n }), + orderPool: [], + ckbAvailable: values.availableCkbBalance, + ckbMaturing: [], + } as TesterState["system"], + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: values.availableCkbBalance, + ickbAvailable: availableIckbBalance, + estimatedMaturity: 0n, + }, + availableCkbBalance: values.availableCkbBalance, + availableIckbBalance, + }; +} + +function capacityCell(capacity: bigint, lock: ccc.Script, txByte: string): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte(txByte), index: 0n }, + cellOutput: { capacity, lock }, + outputData: "0x", + }); +} diff --git a/apps/tester/src/index.ts b/apps/tester/src/index.ts index 688b011..22476a5 100644 --- a/apps/tester/src/index.ts +++ b/apps/tester/src/index.ts @@ -12,26 +12,96 @@ import { signerAccountLocks, sleep, type RuntimeConfig, + verifyChainPreflight, } from "@ickb/node-utils"; import { pathToFileURL } from "node:url"; import { + buildRawOrderTransaction, + buildSdkConversionTransaction, buildTransaction, readTesterState, type Runtime, + type RawOrderRequest, + type TesterState, } 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 ALL_CKB_LIMIT_ORDER_OVERHEAD = 500n * CKB; const MIN_TOTAL_CAPITAL_DIVISOR = 20n; -const TESTER_FEE = 100n; +const TESTER_FEE = 1n; const TESTER_FEE_BASE = 100000n; +const MAX_TESTER_FEE_BASE = 1000000n; const RANDOM_SCALE = 1000000n; +const TESTER_SCENARIOS = [ + "random-order", + "sdk-conversion", + "extra-large-limit-order", + "multi-order-limit-orders", + "two-ckb-to-ickb-limit-orders", + "all-ckb-limit-order", + "all-ickb-limit-order", + "ickb-to-ckb-limit-order", + "two-ickb-to-ckb-limit-orders", + "mixed-direction-limit-orders", + "dust-ckb-conversion", + "dust-ickb-conversion", +] as const; +export type TesterScenario = typeof TESTER_SCENARIOS[number]; +const MULTI_ORDER_SCENARIOS: TesterScenario[] = [ + "mixed-direction-limit-orders", + "two-ckb-to-ickb-limit-orders", + "two-ickb-to-ckb-limit-orders", +]; +type TesterDirection = "ckb-to-ickb" | "ickb-to-ckb"; +export type TesterFeePolicy = { + fee: bigint; + feeBase: bigint; +}; +const DEFAULT_TESTER_FEE_POLICY: TesterFeePolicy = { + fee: TESTER_FEE, + feeBase: TESTER_FEE_BASE, +}; +type PlannedOrderLog = { + giveCkb?: string; + takeIckb?: string; + giveIckb?: string; + takeCkb?: string; + fee: string; + feeNumerator: string; + feeBase: string; +}; +type TesterPlan = { + direction: TesterDirection; + amount: bigint; + ckbAmount: bigint; + udtAmount: bigint; + orderCount: number; +}; +type PlannedRawOrder = { + direction: TesterDirection; + amounts: { ckbValue: bigint; udtValue: bigint }; + amount: bigint; +}; +type EstimatedRawOrder = PlannedRawOrder & { + estimate: ReturnType; +}; + +export class TesterTerminalError extends Error { + constructor(message: string) { + super(message); + this.name = "TesterTerminalError"; + } +} async function main(): Promise { const { chain, privateKey, rpcUrl, sleepIntervalMs, maxIterations } = await readTesterRuntimeConfig(process.env); + const testerScenario = readTesterScenario(process.env); + const feePolicy = readTesterFeePolicy(process.env); + const secrets = { privateKey, rpcUrl }; const client = createPublicClient(chain, rpcUrl); + await verifyChainPreflight(client, chain); const config = getConfig(chain); const signer = new ccc.SignerCkbPrivateKey(client, privateKey); const recommendedAddress = await signer.getRecommendedAddressObj(); @@ -68,10 +138,10 @@ async function main(): Promise { continue; } - const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, state.system.tip); + const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, state.system.exchangeRatio); const totalEquivalentCkb = state.availableCkbBalance + - convert(false, state.availableIckbBalance, state.system.tip); + convert(false, state.availableIckbBalance, state.system.exchangeRatio); executionLog.balance = { CKB: { @@ -87,36 +157,18 @@ async function main(): Promise { totalEquivalent: { CKB: formatCkb(totalEquivalentCkb), ICKB: formatCkb( - convert(true, state.availableCkbBalance, state.system.tip) + + convert(true, state.availableCkbBalance, state.system.exchangeRatio) + state.availableIckbBalance, ), }, }; executionLog.ratio = state.system.exchangeRatio; - const ickbEquivalentBalance = convert( - true, - state.availableCkbBalance, - state.system.tip, - ); - const totalIckbBalance = ickbEquivalentBalance + state.availableIckbBalance; - const isCkb2Udt = - sampleRatio(totalIckbBalance) <= ickbEquivalentBalance; - - const ckbAmount = isCkb2Udt - ? min( - sampleRatio(depositCapacity), - state.availableCkbBalance - CKB_RESERVE, - ) - : 0n; - const udtAmount = isCkb2Udt - ? 0n - : min( - sampleRatio(ICKB_DEPOSIT_CAP), - state.availableIckbBalance, - ); - - if (ckbAmount <= 0n && udtAmount <= 0n) { + const effectiveTesterScenario = resolveTesterScenario(state, testerScenario, feePolicy); + const plan = planTesterTransaction(state, depositCapacity, effectiveTesterScenario); + const rawOrders = plannedRawOrders(plan, effectiveTesterScenario); + + if (rawOrders.length === 0) { if (totalEquivalentCkb < depositCapacity / MIN_TOTAL_CAPITAL_DIVISOR) { executionLog.error = "Not enough funds to continue testing, shutting down..."; @@ -130,14 +182,17 @@ async function main(): Promise { continue; } - const amounts = isCkb2Udt - ? { ckbValue: ckbAmount, udtValue: 0n } - : { ckbValue: 0n, udtValue: udtAmount }; - const estimate = IckbSdk.estimate(isCkb2Udt, amounts, state.system, { - fee: TESTER_FEE, - feeBase: TESTER_FEE_BASE, - }); - if (estimate.convertedAmount <= 0n) { + const effectiveFeePolicy = isSdkConversionScenario(effectiveTesterScenario) + ? DEFAULT_TESTER_FEE_POLICY + : feePolicy; + const estimatedOrders = rawOrders.map((order) => ({ + ...order, + estimate: IckbSdk.estimate(order.direction === "ckb-to-ickb", order.amounts, state.system, { + fee: effectiveFeePolicy.fee, + feeBase: effectiveFeePolicy.feeBase, + }), + })); + if (estimatedOrders.some((order) => order.estimate.convertedAmount <= 0n)) { executionLog.skip = { reason: "estimated-conversion-too-small" }; if (logTerminalIteration(executionLog, startTime, ++completedIterations, maxIterations)) { return; @@ -145,29 +200,47 @@ async function main(): Promise { continue; } - if (isCkb2Udt && state.availableCkbBalance - ckbAmount < MIN_POST_TX_CKB) { - throw new Error("Not enough CKB, less than 1000 CKB after the tx"); - } + const built = isSdkConversionScenario(effectiveTesterScenario) + ? await buildSdkConversionTransaction(runtime, state, plan.direction, plan.amount) + : { + tx: await buildPlannedRawOrderTransaction(runtime, state, estimatedOrders), + conversion: undefined, + }; + const { tx } = built; + const txFee = await tx.getFee(runtime.client); - const tx = await buildTransaction(runtime, state, amounts, estimate.info); + if (estimatedOrders.some((order) => order.direction === "ckb-to-ickb")) { + const postTxCkbBalance = postTransactionPlainCkbBalance(tx, state, runtime.accountLocks); + if (postTxCkbBalance < CKB_RESERVE) { + if (isExplicitCkbReserveScenario(effectiveTesterScenario)) { + throw new TesterTerminalError( + `Not enough CKB to preserve tester reserve after the tx: expected ${formatCkb(CKB_RESERVE)} CKB, got ${formatCkb(postTxCkbBalance)} CKB`, + ); + } + executionLog.skip = { + reason: "post-tx-ckb-reserve", + reserve: formatCkb(CKB_RESERVE), + postTxCkbBalance: formatCkb(postTxCkbBalance), + }; + if (logTerminalIteration(executionLog, startTime, ++completedIterations, maxIterations)) { + return; + } + continue; + } + } executionLog.actions = { - newOrder: isCkb2Udt - ? { - giveCkb: formatCkb(ckbAmount), - takeIckb: formatCkb(estimate.convertedAmount), - fee: formatCkb(estimate.ckbFee), - } - : { - giveIckb: formatCkb(udtAmount), - takeCkb: formatCkb(estimate.convertedAmount), - fee: formatCkb(estimate.ckbFee), - }, + ...(effectiveTesterScenario === testerScenario ? {} : { requestedTesterScenario: testerScenario }), + testerScenario: effectiveTesterScenario, + ...(built.conversion === undefined ? {} : { conversion: built.conversion }), + ...(built.conversion?.kind === "direct" || built.conversion?.kind === "collect-only" + ? {} + : orderEvidence(estimatedOrders, effectiveFeePolicy)), cancelledOrders: state.userOrders.filter((group) => group.order.isMatchable()) .length, }; executionLog.txFee = { - fee: formatCkb(await tx.getFee(runtime.client)), + fee: formatCkb(txFee), feeRate: state.system.feeRate, }; executionLog.txHash = await sendAndWaitForCommit(runtime, tx, { @@ -176,7 +249,14 @@ async function main(): Promise { }, }); } catch (e) { - stopAfterLog = handleLoopError(executionLog, e); + stopAfterLog = handleLoopError(executionLog, e, secrets); + if (e instanceof TesterTerminalError) { + process.exitCode = 1; + stopAfterLog = true; + } else if (isRetryableTesterError(e)) { + logExecution(executionLog, startTime); + continue; + } } if (stopAfterLog) { logExecution(executionLog, startTime); @@ -198,10 +278,292 @@ function logTerminalIteration( return reachedMaxIterations(completedIterations, maxIterations); } +function orderLog( + isCkb2Udt: boolean, + ckbAmount: bigint, + udtAmount: bigint, + convertedAmount: bigint, + ckbFee: bigint, + feePolicy: TesterFeePolicy, +): PlannedOrderLog { + const feeFields = feePolicyLog(feePolicy); + return isCkb2Udt + ? { + giveCkb: formatCkb(ckbAmount), + takeIckb: formatCkb(convertedAmount), + fee: formatCkb(ckbFee), + ...feeFields, + } + : { + giveIckb: formatCkb(udtAmount), + takeCkb: formatCkb(convertedAmount), + fee: formatCkb(ckbFee), + ...feeFields, + }; +} + export async function readTesterRuntimeConfig(env: NodeJS.ProcessEnv): Promise { return readRuntimeConfigEnv(env.TESTER_CONFIG_FILE, "TESTER_CONFIG_FILE"); } +export function readTesterScenario(env: NodeJS.ProcessEnv): TesterScenario { + const value = env.TESTER_SCENARIO ?? "auto"; + if (value === "auto") { + return randomTesterScenario(); + } + if (isTesterScenario(value)) { + return value; + } + throw new Error("Invalid env TESTER_SCENARIO"); +} + +export function readTesterFeePolicy(env: NodeJS.ProcessEnv): TesterFeePolicy { + const fee = readOptionalBigintEnv(env.TESTER_FEE, "TESTER_FEE") ?? DEFAULT_TESTER_FEE_POLICY.fee; + const feeBase = readOptionalBigintEnv(env.TESTER_FEE_BASE, "TESTER_FEE_BASE") ?? DEFAULT_TESTER_FEE_POLICY.feeBase; + if (fee < 0n) { + throw new Error("Invalid env TESTER_FEE: expected a non-negative integer"); + } + if (feeBase <= 0n) { + throw new Error("Invalid env TESTER_FEE_BASE: expected a positive integer"); + } + if (feeBase > MAX_TESTER_FEE_BASE) { + throw new Error(`Invalid env TESTER_FEE_BASE: expected at most ${MAX_TESTER_FEE_BASE.toString()}`); + } + if (fee >= feeBase) { + throw new Error("Invalid tester fee policy: TESTER_FEE must be less than TESTER_FEE_BASE"); + } + return { fee, feeBase }; +} + +export function randomTesterScenario(random: () => number = Math.random): TesterScenario { + const index = Math.floor(random() * TESTER_SCENARIOS.length); + return TESTER_SCENARIOS[index] ?? "random-order"; +} + +export function isRetryableTesterError(error: unknown): boolean { + return error instanceof Error && error.message.includes("L1 state scan crossed chain tip"); +} + +export function postTransactionPlainCkbBalance( + tx: ccc.Transaction, + state: TesterState, + accountLocks: ccc.Script[], +): bigint { + const accountLockHexes = new Set(accountLocks.map((lock) => lock.toHex())); + const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); + const unspentCapacity = state.account.capacityCells.reduce( + (total, cell) => spentOutPoints.has(cell.outPoint.toHex()) ? total : total + cell.cellOutput.capacity, + 0n, + ); + const outputCapacity = tx.outputs.reduce( + (total, output, index) => total + (isAccountPlainCapacityOutput(output, tx.outputsData[index], accountLockHexes) ? output.capacity : 0n), + 0n, + ); + + return unspentCapacity + outputCapacity; +} + +export function planTesterTransaction( + state: Pick, + depositCapacity: bigint, + scenario: TesterScenario, +): TesterPlan { + if (scenario === "multi-order-limit-orders") { + return planTesterTransaction(state, depositCapacity, resolveTesterScenario(state, scenario)); + } + if (scenario === "extra-large-limit-order") { + const ckbAmount = depositCapacity * 2n; + if (state.availableCkbBalance - CKB_RESERVE < ckbAmount) { + throw new TesterTerminalError("Not enough CKB for extra-large limit order scenario"); + } + return { direction: "ckb-to-ickb", amount: ckbAmount, ckbAmount, udtAmount: 0n, orderCount: 1 }; + } + if (scenario === "two-ckb-to-ickb-limit-orders") { + const ckbAmount = state.availableCkbBalance - CKB_RESERVE - ALL_CKB_LIMIT_ORDER_OVERHEAD; + if (ckbAmount < 2n) { + throw new TesterTerminalError("Not enough CKB for two CKB-to-iCKB limit orders scenario"); + } + return { direction: "ckb-to-ickb", amount: ckbAmount, ckbAmount, udtAmount: 0n, orderCount: 2 }; + } + if (scenario === "all-ckb-limit-order") { + const ckbAmount = state.availableCkbBalance - CKB_RESERVE - ALL_CKB_LIMIT_ORDER_OVERHEAD; + if (ckbAmount <= 0n) { + throw new TesterTerminalError("Not enough CKB for all-CKB limit order scenario"); + } + return { direction: "ckb-to-ickb", amount: ckbAmount, ckbAmount, udtAmount: 0n, orderCount: 1 }; + } + if (scenario === "all-ickb-limit-order" || scenario === "ickb-to-ckb-limit-order") { + const udtAmount = state.availableIckbBalance; + if (udtAmount <= 0n) { + throw new TesterTerminalError("Not enough iCKB for iCKB-to-CKB limit order scenario"); + } + return { direction: "ickb-to-ckb", amount: udtAmount, ckbAmount: 0n, udtAmount, orderCount: 1 }; + } + if (scenario === "two-ickb-to-ckb-limit-orders") { + const udtAmount = state.availableIckbBalance; + if (udtAmount < 2n) { + throw new TesterTerminalError("Not enough iCKB for two iCKB-to-CKB limit orders scenario"); + } + return { direction: "ickb-to-ckb", amount: udtAmount, ckbAmount: 0n, udtAmount, orderCount: 2 }; + } + if (scenario === "mixed-direction-limit-orders") { + const ckbAmount = state.availableCkbBalance - CKB_RESERVE - ALL_CKB_LIMIT_ORDER_OVERHEAD; + if (ckbAmount <= 0n) { + throw new TesterTerminalError("Not enough CKB for mixed-direction limit orders scenario"); + } + if (state.availableIckbBalance <= 0n) { + throw new TesterTerminalError("Not enough iCKB for mixed-direction limit orders scenario"); + } + return { + direction: "ckb-to-ickb", + amount: ckbAmount + state.availableIckbBalance, + ckbAmount, + udtAmount: state.availableIckbBalance, + orderCount: 2, + }; + } + if (scenario === "dust-ckb-conversion") { + if (state.availableCkbBalance - CKB_RESERVE < 1n) { + throw new TesterTerminalError("Not enough CKB for dust CKB conversion scenario"); + } + return { direction: "ckb-to-ickb", amount: 1n, ckbAmount: 1n, udtAmount: 0n, orderCount: 1 }; + } + if (scenario === "dust-ickb-conversion") { + if (state.availableIckbBalance < 1n) { + throw new TesterTerminalError("Not enough iCKB for dust iCKB conversion scenario"); + } + return { direction: "ickb-to-ckb", amount: 1n, ckbAmount: 0n, udtAmount: 1n, orderCount: 1 }; + } + + const ickbEquivalentBalance = convert( + true, + state.availableCkbBalance, + state.system.exchangeRatio, + ); + const totalIckbBalance = ickbEquivalentBalance + state.availableIckbBalance; + const isCkb2Udt = sampleRatio(totalIckbBalance) <= ickbEquivalentBalance; + const ckbAmount = isCkb2Udt + ? min( + isSdkConversionScenario(scenario) ? depositCapacity : sampleRatio(depositCapacity), + state.availableCkbBalance - CKB_RESERVE, + ) + : 0n; + const udtAmount = isCkb2Udt + ? 0n + : min( + isSdkConversionScenario(scenario) ? ICKB_DEPOSIT_CAP : sampleRatio(ICKB_DEPOSIT_CAP), + state.availableIckbBalance, + ); + + return { + direction: isCkb2Udt ? "ckb-to-ickb" : "ickb-to-ckb", + amount: isCkb2Udt ? ckbAmount : udtAmount, + ckbAmount, + udtAmount, + orderCount: 1, + }; +} + +export function resolveTesterScenario( + state: Pick, + scenario: TesterScenario, + feePolicy: TesterFeePolicy = DEFAULT_TESTER_FEE_POLICY, +): TesterScenario { + if (scenario !== "multi-order-limit-orders") { + return scenario; + } + const selected = MULTI_ORDER_SCENARIOS.find((candidate) => hasPositiveMultiOrderEstimates(state, candidate, feePolicy)); + if (selected !== undefined) { + return selected; + } + throw new TesterTerminalError("Not enough funds for multi-order limit orders scenario"); +} + +function hasPositiveMultiOrderEstimates( + state: Pick, + scenario: TesterScenario, + feePolicy: TesterFeePolicy, +): boolean { + try { + const plan = planTesterTransaction(state, 0n, scenario); + const orders = plannedRawOrders(plan, scenario); + return orders.length >= 2 && orders.every((order) => IckbSdk.estimate( + order.direction === "ckb-to-ickb", + order.amounts, + state.system, + { fee: feePolicy.fee, feeBase: feePolicy.feeBase }, + ).convertedAmount > 0n); + } catch (error) { + if (error instanceof TesterTerminalError) { + return false; + } + throw error; + } +} + +function plannedRawOrders(plan: TesterPlan, scenario: TesterScenario): PlannedRawOrder[] { + if (plan.amount <= 0n) { + return []; + } + if (isSdkConversionScenario(scenario)) { + return [{ direction: plan.direction, amounts: planAmounts(plan.direction, plan.amount), amount: plan.amount }]; + } + if (scenario === "mixed-direction-limit-orders") { + const orders: PlannedRawOrder[] = [ + { direction: "ckb-to-ickb", amounts: { ckbValue: plan.ckbAmount, udtValue: 0n }, amount: plan.ckbAmount }, + { direction: "ickb-to-ckb", amounts: { ckbValue: 0n, udtValue: plan.udtAmount }, amount: plan.udtAmount }, + ]; + return orders.filter((order) => order.amount > 0n); + } + if (plan.orderCount === 2) { + const firstAmount = plan.amount / 2n; + const secondAmount = plan.amount - firstAmount; + return [firstAmount, secondAmount] + .filter((amount) => amount > 0n) + .map((amount) => ({ direction: plan.direction, amounts: planAmounts(plan.direction, amount), amount })); + } + return [{ direction: plan.direction, amounts: planAmounts(plan.direction, plan.amount), amount: plan.amount }]; +} + +function planAmounts(direction: TesterDirection, amount: bigint): { ckbValue: bigint; udtValue: bigint } { + return direction === "ckb-to-ickb" + ? { ckbValue: amount, udtValue: 0n } + : { ckbValue: 0n, udtValue: amount }; +} + +function buildPlannedRawOrderTransaction( + runtime: Runtime, + state: TesterState, + orders: EstimatedRawOrder[], +): Promise { + if (orders.length === 1) { + const [order] = orders; + if (order !== undefined) { + return buildTransaction(runtime, state, order.amounts, order.estimate.info); + } + } + const rawOrders: RawOrderRequest[] = orders.map((order) => ({ + amounts: order.amounts, + info: order.estimate.info, + })); + return buildRawOrderTransaction(runtime, state, rawOrders); +} + +function orderEvidence(orders: EstimatedRawOrder[], feePolicy: TesterFeePolicy): Record { + const logs = orders.map((order) => orderLog( + order.direction === "ckb-to-ickb", + order.amounts.ckbValue, + order.amounts.udtValue, + order.estimate.convertedAmount, + order.estimate.ckbFee, + feePolicy, + )); + const [first] = logs; + return logs.length === 1 && first !== undefined + ? { newOrder: first } + : { newOrders: logs, orderCount: logs.length }; +} + function min(left: bigint, right: bigint): bigint { return left < right ? left : right; } @@ -218,6 +580,40 @@ function randomScaled(): bigint { return BigInt(Math.floor(Math.random() * Number(RANDOM_SCALE))); } +function isTesterScenario(value: string): value is TesterScenario { + return TESTER_SCENARIOS.includes(value as TesterScenario); +} + +function readOptionalBigintEnv(value: string | undefined, name: string): bigint | undefined { + if (value === undefined) { + return undefined; + } + if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) { + throw new Error(`Invalid env ${name}: expected an unsigned integer`); + } + return BigInt(value); +} + +function feePolicyLog(policy: TesterFeePolicy): Pick { + return { + feeNumerator: policy.fee.toString(), + feeBase: policy.feeBase.toString(), + }; +} + +function isExplicitCkbReserveScenario(scenario: TesterScenario): boolean { + return scenario === "all-ckb-limit-order" || scenario === "extra-large-limit-order" || scenario === "two-ckb-to-ickb-limit-orders" || scenario === "mixed-direction-limit-orders"; +} + +function isSdkConversionScenario(scenario: TesterScenario): boolean { + return scenario === "sdk-conversion"; +} + +function isAccountPlainCapacityOutput(output: ccc.CellOutput, outputData: string | undefined, accountLockHexes: Set): boolean { + return output.type === undefined && (outputData ?? "0x") === "0x" && accountLockHexes.has(output.lock.toHex()); +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); + process.exit(process.exitCode ?? 0); } diff --git a/apps/tester/src/runtime.test.ts b/apps/tester/src/runtime.test.ts index 6c1799c..0545562 100644 --- a/apps/tester/src/runtime.test.ts +++ b/apps/tester/src/runtime.test.ts @@ -2,6 +2,8 @@ import { ccc } from "@ckb-ccc/core"; import { byte32FromByte, script } from "@ickb/testkit"; import { describe, expect, it, vi } from "vitest"; import { + buildRawOrderTransaction, + buildSdkConversionTransaction, buildTransaction, readTesterState, type Runtime, @@ -38,6 +40,21 @@ function completeTransactionMock(calls: string[]): ReturnType< ); } +function buildConversionTransactionMock(calls: string[]): ReturnType< + typeof vi.fn +> { + return vi.fn().mockImplementation(async (txLike) => { + calls.push("conversion"); + await Promise.resolve(); + return { + ok: true, + tx: ccc.Transaction.from(txLike), + estimatedMaturity: 0n, + conversion: { kind: "order" }, + }; + }); +} + async function recordTxStep( label: string, calls: string[], @@ -70,7 +87,19 @@ describe("readTesterState", () => { }; const receipt = { ckbValue: 13n, udtValue: 17n }; const readyWithdrawal = { owned: { isReady: true }, ckbValue: 19n, udtValue: 0n }; - const pendingWithdrawal = { owned: { isReady: false }, ckbValue: 31n, udtValue: 0n }; + const pendingWithdrawal = { + owned: { isReady: false, maturity: { toUnix: (): bigint => 100n } }, + ckbValue: 31n, + udtValue: 0n, + }; + const account = { + capacityCells: [plainCell], + nativeUdtCells: [], + nativeUdtCapacity: 7n, + nativeUdtBalance: 11n, + receipts: [receipt], + withdrawalGroups: [readyWithdrawal, pendingWithdrawal], + }; const runtime: Runtime = { client: {} as ccc.Client, signer: {} as ccc.SignerCkbPrivateKey, @@ -80,14 +109,7 @@ describe("readTesterState", () => { return { system: { tip: { timestamp: 0n } } as TesterState["system"], user: { orders: [userOrder, pendingOrder] }, - account: { - capacityCells: [plainCell], - nativeUdtCells: [], - nativeUdtCapacity: 7n, - nativeUdtBalance: 11n, - receipts: [receipt], - withdrawalGroups: [readyWithdrawal, pendingWithdrawal], - }, + account, }; }, } as unknown as Runtime["sdk"], @@ -98,13 +120,57 @@ describe("readTesterState", () => { const state = await readTesterState(runtime); expect(state.userOrders).toEqual([userOrder, pendingOrder]); - expect(state.receipts).toEqual([receipt]); - expect(state.readyWithdrawals).toEqual([readyWithdrawal]); + expect(state.account).toBe(account); + expect(state.conversionContext).toEqual({ + system: { tip: { timestamp: 0n } }, + receipts: [receipt], + readyWithdrawals: [readyWithdrawal], + availableOrders: [userOrder, pendingOrder], + ckbAvailable: plainCell.cellOutput.capacity + 23n + 31n + 13n + 19n, + ickbAvailable: 11n + 29n + 37n + 17n, + estimatedMaturity: 100n, + }); expect(state.availableCkbBalance).toBe( plainCell.cellOutput.capacity + 23n + 31n + 13n + 19n, ); expect(state.availableIckbBalance).toBe(11n + 29n + 37n + 17n); }); + + it("budgets user orders as available because buildTransaction collects them", async () => { + const lock = script("11"); + const userOrder = { + ckbValue: 23n, + udtValue: 29n, + order: { + isDualRatio: (): boolean => false, + isMatchable: (): boolean => true, + }, + }; + const account = emptyAccountState(); + const runtime: Runtime = { + client: {} as ccc.Client, + signer: {} as ccc.SignerCkbPrivateKey, + sdk: { + getL1AccountState: async () => { + await Promise.resolve(); + return { + system: { tip: { timestamp: 0n } } as TesterState["system"], + user: { orders: [userOrder] }, + account, + }; + }, + } as unknown as Runtime["sdk"], + primaryLock: lock, + accountLocks: [lock], + }; + + const state = await readTesterState(runtime); + + expect(state.userOrders).toEqual([userOrder]); + expect(state.account).toBe(account); + expect(state.availableCkbBalance).toBe(userOrder.ckbValue); + expect(state.availableIckbBalance).toBe(userOrder.udtValue); + }); }); describe("buildTransaction", () => { @@ -117,9 +183,17 @@ describe("buildTransaction", () => { const readyWithdrawals = [{ id: "withdrawal" }]; const state: TesterState = { system: { feeRate: 42n } as TesterState["system"], + account: emptyAccountState(), userOrders: [{ id: "order" }] as unknown as TesterState["userOrders"], - receipts: receipts as unknown as TesterState["receipts"], - readyWithdrawals: readyWithdrawals as unknown as TesterState["readyWithdrawals"], + conversionContext: { + system: { feeRate: 42n } as TesterState["system"], + receipts: receipts as unknown as TesterState["conversionContext"]["receipts"], + readyWithdrawals: readyWithdrawals as unknown as TesterState["conversionContext"]["readyWithdrawals"], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, availableCkbBalance: 0n, availableIckbBalance: 0n, }; @@ -154,4 +228,110 @@ describe("buildTransaction", () => { }); expect(calls).toEqual(["base", "request", "complete"]); }); + + it("delegates SDK conversion planning to the SDK", async () => { + const calls: string[] = []; + const buildConversionTransaction = buildConversionTransactionMock(calls); + const completeTransaction = completeTransactionMock(calls); + const state: TesterState = { + system: { feeRate: 42n } as TesterState["system"], + account: emptyAccountState(), + userOrders: [], + conversionContext: { + system: { feeRate: 42n } as TesterState["system"], + receipts: [{ id: "context-receipt" }] as unknown as TesterState["conversionContext"]["receipts"], + readyWithdrawals: [{ id: "context-withdrawal" }] as unknown as TesterState["conversionContext"]["readyWithdrawals"], + availableOrders: [{ id: "context-order" }] as unknown as TesterState["conversionContext"]["availableOrders"], + ckbAvailable: 1000n, + ickbAvailable: 0n, + estimatedMaturity: 100n, + }, + availableCkbBalance: 1000n, + availableIckbBalance: 0n, + }; + const primaryLock = script("11"); + const runtime: Runtime = { + client: {} as ccc.Client, + signer: {} as ccc.SignerCkbPrivateKey, + sdk: { + buildConversionTransaction, + completeTransaction, + } as unknown as Runtime["sdk"], + primaryLock, + accountLocks: [], + }; + + const result = await buildSdkConversionTransaction(runtime, state, "ckb-to-ickb", 500n); + + expect(result.conversion).toEqual({ kind: "order" }); + expect(buildConversionTransaction.mock.calls[0]?.[2]).toMatchObject({ + direction: "ckb-to-ickb", + amount: 500n, + lock: primaryLock, + context: state.conversionContext, + }); + expect(completeTransaction.mock.calls[0]?.[1]).toEqual({ + signer: runtime.signer, + client: runtime.client, + feeRate: 42n, + }); + expect(calls).toEqual(["conversion", "complete"]); + }); + + it("builds multiple raw order requests in one base transaction", async () => { + const calls: string[] = []; + const buildBaseTransaction = buildBaseTransactionMock(calls); + const request = requestMock(calls); + const completeTransaction = completeTransactionMock(calls); + const state: TesterState = { + system: { feeRate: 42n } as TesterState["system"], + account: emptyAccountState(), + userOrders: [], + conversionContext: { + system: { feeRate: 42n } as TesterState["system"], + receipts: [], + readyWithdrawals: [], + availableOrders: [], + ckbAvailable: 0n, + ickbAvailable: 0n, + estimatedMaturity: 0n, + }, + availableCkbBalance: 0n, + availableIckbBalance: 0n, + }; + const runtime: Runtime = { + client: {} as ccc.Client, + signer: {} as ccc.SignerCkbPrivateKey, + sdk: { + buildBaseTransaction, + completeTransaction, + request, + } as unknown as Runtime["sdk"], + primaryLock: script("11"), + accountLocks: [], + }; + + await buildRawOrderTransaction(runtime, state, [ + { amounts: { ckbValue: 10n, udtValue: 0n }, info: { id: "first" } as Parameters[2] }, + { amounts: { ckbValue: 20n, udtValue: 0n }, info: { id: "second" } as Parameters[2] }, + ]); + + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls.map((call) => call[3])).toEqual([ + { ckbValue: 10n, udtValue: 0n }, + { ckbValue: 20n, udtValue: 0n }, + ]); + expect(calls).toEqual(["base", "request", "request", "complete"]); + }); }); + +function emptyAccountState(): TesterState["account"] { + return { + capacityCells: [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }; +} diff --git a/apps/tester/src/runtime.ts b/apps/tester/src/runtime.ts index 8761f7d..a2348d6 100644 --- a/apps/tester/src/runtime.ts +++ b/apps/tester/src/runtime.ts @@ -1,9 +1,12 @@ import { ccc } from "@ckb-ccc/core"; -import { type ReceiptCell, type WithdrawalGroup } from "@ickb/core"; import { type OrderGroup } from "@ickb/order"; import { IckbSdk, - projectAccountAvailability, + projectConversionTransactionContext, + type AccountState, + type ConversionTransactionContext, + type ConversionDirection, + type ConversionMetadata, type SystemState, } from "@ickb/sdk"; @@ -17,28 +20,33 @@ export interface Runtime { export interface TesterState { system: SystemState; + account: AccountState; userOrders: OrderGroup[]; - receipts: ReceiptCell[]; - readyWithdrawals: WithdrawalGroup[]; + conversionContext: ConversionTransactionContext; availableCkbBalance: bigint; availableIckbBalance: bigint; } +export interface RawOrderRequest { + amounts: { ckbValue: bigint; udtValue: bigint }; + info: Parameters[2]; +} + export async function readTesterState(runtime: Runtime): Promise { const { system, user, account } = await runtime.sdk.getL1AccountState( runtime.client, runtime.accountLocks, ); - const projection = projectAccountAvailability(account, user.orders, { + const { projection, context } = projectConversionTransactionContext(system, account, user.orders, { collectedOrdersAvailable: true, }); return { system, + account, userOrders: user.orders, - receipts: account.receipts, - readyWithdrawals: projection.readyWithdrawals, + conversionContext: context, availableCkbBalance: projection.ckbAvailable, availableIckbBalance: projection.ickbAvailable, }; @@ -49,21 +57,59 @@ export async function buildTransaction( state: TesterState, amounts: { ckbValue: bigint; udtValue: bigint }, info: Parameters[2], +): Promise { + return buildRawOrderTransaction(runtime, state, [{ amounts, info }]); +} + +export async function buildRawOrderTransaction( + runtime: Runtime, + state: TesterState, + orders: RawOrderRequest[], ): Promise { let tx = await runtime.sdk.buildBaseTransaction( ccc.Transaction.default(), runtime.client, { orders: state.userOrders, - receipts: state.receipts, - readyWithdrawals: state.readyWithdrawals, + receipts: state.conversionContext.receipts, + readyWithdrawals: state.conversionContext.readyWithdrawals, }, ); - tx = await runtime.sdk.request(tx, runtime.primaryLock, info, amounts); + for (const order of orders) { + tx = await runtime.sdk.request(tx, runtime.primaryLock, order.info, order.amounts); + } return runtime.sdk.completeTransaction(tx, { signer: runtime.signer, client: runtime.client, feeRate: state.system.feeRate, }); } + +export async function buildSdkConversionTransaction( + runtime: Runtime, + state: TesterState, + direction: ConversionDirection, + amount: bigint, +): Promise<{ tx: ccc.Transaction; conversion: ConversionMetadata }> { + const result = await runtime.sdk.buildConversionTransaction( + ccc.Transaction.default(), + runtime.client, + { + direction, + amount, + lock: runtime.primaryLock, + context: state.conversionContext, + }, + ); + if (!result.ok) { + throw new Error(`SDK conversion failed: ${result.reason}`); + } + + const tx = await runtime.sdk.completeTransaction(result.tx, { + signer: runtime.signer, + client: runtime.client, + feeRate: state.system.feeRate, + }); + return { tx, conversion: result.conversion }; +} From 75475b57ae052ac0745ffa68f0a3f498e0500243 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 20 May 2026 04:05:45 +0000 Subject: [PATCH 3/5] fix(tester): estimate logged fees locally --- apps/tester/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tester/src/index.ts b/apps/tester/src/index.ts index 22476a5..d861bb1 100644 --- a/apps/tester/src/index.ts +++ b/apps/tester/src/index.ts @@ -207,7 +207,7 @@ async function main(): Promise { conversion: undefined, }; const { tx } = built; - const txFee = await tx.getFee(runtime.client); + const txFee = tx.estimateFee(state.system.feeRate); if (estimatedOrders.some((order) => order.direction === "ckb-to-ickb")) { const postTxCkbBalance = postTransactionPlainCkbBalance(tx, state, runtime.accountLocks); From b9c1a6446099912167d05e048a10dc2c5037cfa6 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 20 May 2026 04:24:27 +0000 Subject: [PATCH 4/5] fix(bot): avoid reserve deadlock --- apps/bot/src/index.test.ts | 41 +++++++++++++++++++++++++++++++++++++- apps/bot/src/runtime.ts | 3 ++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/bot/src/index.test.ts b/apps/bot/src/index.test.ts index 6b080f7..4805e55 100644 --- a/apps/bot/src/index.test.ts +++ b/apps/bot/src/index.test.ts @@ -216,7 +216,7 @@ describe("buildTransaction", () => { const lock = script("11"); const spent = capacityCell(1000n, lock, "77"); vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ - ckbDelta: 1n, + ckbDelta: -1n, udtDelta: 0n, partials: [{} as never], }); @@ -249,6 +249,45 @@ describe("buildTransaction", () => { }); }); + it("allows CKB-replenishing transactions even when plain CKB remains below reserve", async () => { + const lock = script("11"); + const spent = capacityCell(1000n, lock, "78"); + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 1n, + udtDelta: 0n, + partials: [], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const runtime = botRuntime({ + primaryLock: lock, + sdk: { + completeTransaction: async (txLike: ccc.TransactionLike): Promise => { + await Promise.resolve(); + const tx = ccc.Transaction.from(txLike); + tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); + tx.addOutput({ capacity: 1n, lock }); + return tx; + }, + }, + }); + const state = botState({ + accountLocks: [lock], + capacityCells: [spent], + readyWithdrawals: [{}], + availableCkbBalance: 1000n, + availableIckbBalance: TARGET_ICKB_BALANCE, + totalCkbBalance: 1000n, + }); + + const result = await buildTransaction(runtime as never, state as never); + + expect(result).toMatchObject({ + kind: "built", + actions: { withdrawals: 1 }, + }); + expect(result.decision.skip).toBeUndefined(); + }); + it("skips match-only transactions when the completed fee consumes the match value", async () => { vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ ckbDelta: 1n, diff --git a/apps/bot/src/runtime.ts b/apps/bot/src/runtime.ts index 77913fc..bb6e8fb 100644 --- a/apps/bot/src/runtime.ts +++ b/apps/bot/src/runtime.ts @@ -272,7 +272,8 @@ export async function buildTransaction( tx, }); decision = { ...decision, fee: { ...decision.fee, estimated: fee } }; - if (postTxCkbBalance < CKB_RESERVE) { + const consumesCkbReserve = actions.deposits > 0 || match.ckbDelta < 0n; + if (postTxCkbBalance < CKB_RESERVE && consumesCkbReserve) { return skippedResult("post_tx_ckb_reserve", actions, decision, { postTxCkbBalance, reserve: CKB_RESERVE, From 02c3e1dc6c5eb4be6c8e7269c072a6e6fa1e3d20 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 20 May 2026 04:32:47 +0000 Subject: [PATCH 5/5] fix(actors): count only plain CKB reserve cells --- apps/bot/src/index.test.ts | 12 +++++++++++- apps/bot/src/runtime.ts | 6 +++++- apps/tester/src/index.test.ts | 12 +++++++++++- apps/tester/src/index.ts | 6 +++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/bot/src/index.test.ts b/apps/bot/src/index.test.ts index 4805e55..071fd90 100644 --- a/apps/bot/src/index.test.ts +++ b/apps/bot/src/index.test.ts @@ -450,6 +450,16 @@ describe("postTransactionPlainCkbBalance", () => { const otherLock = script("22"); const spent = capacityCell(ccc.fixedPointFrom(1000), lock, "aa"); const unspent = capacityCell(ccc.fixedPointFrom(2000), lock, "bb"); + const typed = ccc.Cell.from({ + outPoint: { txHash: hash("cc"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(4000), lock, type: script("33") }, + outputData: "0x", + }); + const data = ccc.Cell.from({ + outPoint: { txHash: hash("dd"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(8000), lock }, + outputData: "0x1234", + }); const tx = ccc.Transaction.default(); tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); tx.outputs.push( @@ -461,7 +471,7 @@ describe("postTransactionPlainCkbBalance", () => { expect(postTransactionPlainCkbBalance( tx, - botState({ accountLocks: [lock], capacityCells: [spent, unspent] }) as never, + botState({ accountLocks: [lock], capacityCells: [spent, unspent, typed, data] }) as never, )).toBe(ccc.fixedPointFrom(2300)); }); }); diff --git a/apps/bot/src/runtime.ts b/apps/bot/src/runtime.ts index bb6e8fb..a449769 100644 --- a/apps/bot/src/runtime.ts +++ b/apps/bot/src/runtime.ts @@ -457,7 +457,11 @@ export function postTransactionPlainCkbBalance(tx: ccc.Transaction, state: BotSt const accountLockHexes = new Set(state.accountLocks.map((lock) => lock.toHex())); const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); const unspentCapacity = state.capacityCells.reduce( - (total, cell) => spentOutPoints.has(cell.outPoint.toHex()) ? total : total + cell.cellOutput.capacity, + (total, cell) => + spentOutPoints.has(cell.outPoint.toHex()) || + !isAccountPlainCapacityOutput(cell.cellOutput, cell.outputData, accountLockHexes) + ? total + : total + cell.cellOutput.capacity, 0n, ); const outputCapacity = tx.outputs.reduce( diff --git a/apps/tester/src/index.test.ts b/apps/tester/src/index.test.ts index 3668c10..cb9ebdb 100644 --- a/apps/tester/src/index.test.ts +++ b/apps/tester/src/index.test.ts @@ -304,6 +304,16 @@ describe("planTesterTransaction", () => { const otherLock = script("22"); const spent = capacityCell(ccc.fixedPointFrom(1000), lock, "01"); const unspent = capacityCell(ccc.fixedPointFrom(2000), lock, "02"); + const typed = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("03"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(4000), lock, type: script("33") }, + outputData: "0x", + }); + const data = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("04"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(8000), lock }, + outputData: "0x1234", + }); const tx = ccc.Transaction.default(); tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); tx.addOutput({ capacity: ccc.fixedPointFrom(300), lock }); @@ -313,7 +323,7 @@ describe("planTesterTransaction", () => { expect(postTransactionPlainCkbBalance( tx, - testerState({ availableCkbBalance: ccc.fixedPointFrom(3000), capacityCells: [spent, unspent] }), + testerState({ availableCkbBalance: ccc.fixedPointFrom(3000), capacityCells: [spent, unspent, typed, data] }), [lock], )).toBe(ccc.fixedPointFrom(2300)); }); diff --git a/apps/tester/src/index.ts b/apps/tester/src/index.ts index d861bb1..265159b 100644 --- a/apps/tester/src/index.ts +++ b/apps/tester/src/index.ts @@ -352,7 +352,11 @@ export function postTransactionPlainCkbBalance( const accountLockHexes = new Set(accountLocks.map((lock) => lock.toHex())); const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); const unspentCapacity = state.account.capacityCells.reduce( - (total, cell) => spentOutPoints.has(cell.outPoint.toHex()) ? total : total + cell.cellOutput.capacity, + (total, cell) => + spentOutPoints.has(cell.outPoint.toHex()) || + !isAccountPlainCapacityOutput(cell.cellOutput, cell.outputData, accountLockHexes) + ? total + : total + cell.cellOutput.capacity, 0n, ); const outputCapacity = tx.outputs.reduce(