diff --git a/apps/tester/README.md b/apps/tester/README.md index 7f3e5337..536c5749 100644 --- a/apps/tester/README.md +++ b/apps/tester/README.md @@ -1,6 +1,6 @@ # iCKB Tester -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. +The tester is now CCC-native. It waits while its own freshly minted 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. ## Runtime Config @@ -45,7 +45,7 @@ The start script writes one newline-delimited JSON log stream per run. Each loop ## 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: +`TESTER_SCENARIO` selects the order-building path for live supervision. Omit it, or set `auto`, to read current balances first and then choose randomly from conservative funded scenarios on each tester iteration. `auto` does not choose the full-capital, multi-order, or dust stress scenarios; select those explicitly when that coverage is intended: - `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. @@ -54,6 +54,7 @@ The start script writes one newline-delimited JSON log stream per run. Each loop - `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. +- `bounded-ickb-to-ckb-limit-order`: creates one raw iCKB-to-CKB limit order capped at one deposit-cap unit. Use this when the goal is a fresh non-dust tester order, not locking the full iCKB balance. - `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`. - `dust-ckb-conversion`: tries a one-shannon CKB-to-iCKB conversion with the normal tester order fee. @@ -61,6 +62,8 @@ The start script writes one newline-delimited JSON log stream per run. Each loop 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. +Before broadcast, every completed tester transaction is checked against the plain-CKB reserve using actual account plain-capacity outputs after the transaction shape is complete. Non-reserve-stress scenarios skip with `reason: "post-tx-ckb-reserve"` when the transaction would leave less than the reserve; those skips include `skip.attemptedOrder`, `skip.attemptedOrders`, or `skip.attemptedConversion`, not committed `actions.newOrder`. Explicit CKB reserve stress scenarios fail terminally instead of silently weakening the stress target. Estimate skips use `reason: "estimated-conversion-too-small"` and include `skip.attemptedOrder` or `skip.attemptedOrders` so dust and fee-adjusted unbuildable stimulus can be diagnosed without treating the skip as a committed order. + 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 diff --git a/apps/tester/src/freshMatchableOrderSkip.ts b/apps/tester/src/freshMatchableOrderSkip.ts index 348a104e..2a2c2a4a 100644 --- a/apps/tester/src/freshMatchableOrderSkip.ts +++ b/apps/tester/src/freshMatchableOrderSkip.ts @@ -3,7 +3,7 @@ import { ickbExchangeRatio } from "@ickb/core"; import { OrderManager, type OrderGroup } from "@ickb/order"; import { type Runtime } from "./runtime.js"; -const MAX_ELAPSED_BLOCKS = 5400n; +const MAX_ELAPSED_BLOCKS = 180n; type FreshMatchableOrderSkip = | { @@ -27,6 +27,9 @@ export async function freshMatchableOrderSkip( const tx2BlockNumber = new Map(); for (const group of orders) { + if (!group.order.data.isMint()) { + continue; + } if (!isActionableOrder(group, tip, feeRate)) { continue; } diff --git a/apps/tester/src/index.test.ts b/apps/tester/src/index.test.ts index fb991ff0..02abdda9 100644 --- a/apps/tester/src/index.test.ts +++ b/apps/tester/src/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { ccc } from "@ckb-ccc/core"; +import { handleLoopError, logExecution } from "@ickb/node-utils"; import { OrderCell, OrderData, Ratio } from "@ickb/order"; import { byte32FromByte, headerLike, script } from "@ickb/testkit"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -7,6 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { freshMatchableOrderSkip } from "./freshMatchableOrderSkip.js"; import { + enforceTesterPlainCkbReserve, isRetryableTesterError, isUnrepresentableTesterEstimateError, planTesterTransaction, @@ -15,6 +17,9 @@ import { readTesterFeePolicy, readTesterRuntimeConfig, readTesterScenario, + testerAttemptedTransactionEvidence, + testerEstimatedTooSmallSkip, + testerExecutionActions, resolveTesterScenario, testerReserveSkip, TesterTerminalError, @@ -54,15 +59,18 @@ describe("readTesterRuntimeConfig", () => { }); describe("readTesterScenario", () => { - it("defaults to random orders and accepts explicit tester scenarios", () => { + it("keeps auto selection until balances are known and accepts explicit tester scenarios", () => { expect(randomTesterScenario(() => 0)).toBe("random-order"); expect(randomTesterScenario(() => 0.99)).toBe("dust-ickb-conversion"); + expect(readTesterScenario({})).toBe("auto"); + expect(readTesterScenario({ TESTER_SCENARIO: "auto" })).toBe("auto"); 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: "ickb-to-ckb-limit-order" })).toBe("ickb-to-ckb-limit-order"); + expect(readTesterScenario({ TESTER_SCENARIO: "bounded-ickb-to-ckb-limit-order" })).toBe("bounded-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"); @@ -72,6 +80,42 @@ describe("readTesterScenario", () => { }); }); +describe("tester private key output boundary", () => { + it("does not leak the configured canary key across representative crash output", async () => { + const privateKey = `0x${"42".repeat(32)}`; + const dir = await mkdtemp(join(tmpdir(), "ickb-tester-private-key-boundary-")); + const output: string[] = []; + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + try { + const configPath = join(dir, "config.json"); + await writeFile(configPath, JSON.stringify({ + chain: "testnet", + privateKey, + sleepIntervalSeconds: 10, + maxIterations: 1, + }), { mode: 0o600 }); + const runtimeConfig = await readTesterRuntimeConfig({ TESTER_CONFIG_FILE: configPath }); + const executionLog: Record = { + chain: runtimeConfig.chain, + maxIterations: runtimeConfig.maxIterations, + startTime: "fixture", + }; + + handleLoopError(executionLog, new Error("tester deterministic crash")); + logExecution(executionLog, new Date()); + + expect(runtimeConfig.privateKey).toBe(privateKey); + expect(output.join("\n")).not.toContain(privateKey); + } finally { + stdoutWrite.mockRestore(); + await rm(dir, { recursive: true, force: true }); + } + }); +}); + describe("readTesterFeePolicy", () => { it("defaults to the normal live order fee", () => { expect(readTesterFeePolicy({})).toEqual({ fee: 1n, feeBase: 100000n }); @@ -196,6 +240,23 @@ describe("planTesterTransaction", () => { }); }); + it("caps bounded iCKB-to-CKB limit orders at one deposit-cap unit", () => { + const state = testerState({ availableCkbBalance: ccc.fixedPointFrom(2500), availableIckbBalance: ccc.fixedPointFrom(123456) }); + + expect(planTesterTransaction(state, ccc.fixedPointFrom(1000), "bounded-ickb-to-ckb-limit-order")).toEqual({ + direction: "ickb-to-ckb", + amount: ccc.fixedPointFrom(100000), + ckbAmount: 0n, + udtAmount: ccc.fixedPointFrom(100000), + orderCount: 1, + }); + expect(planTesterTransaction( + testerState({ availableCkbBalance: ccc.fixedPointFrom(2500), availableIckbBalance: ccc.fixedPointFrom(123) }), + ccc.fixedPointFrom(1000), + "bounded-ickb-to-ckb-limit-order", + )).toMatchObject({ amount: ccc.fixedPointFrom(123), udtAmount: ccc.fixedPointFrom(123) }); + }); + it("plans two iCKB-to-CKB limit orders with all available iCKB", () => { const state = testerState({ availableCkbBalance: 0n, availableIckbBalance: ccc.fixedPointFrom(123) }); @@ -271,6 +332,49 @@ describe("planTesterTransaction", () => { }); }); + it("plans random orders from iCKB only when CKB is below reserve", () => { + const state = testerState({ + availableCkbBalance: ccc.fixedPointFrom(1999), + availableIckbBalance: ccc.fixedPointFrom(123), + }); + const random = vi.spyOn(Math, "random").mockReturnValue(0.999999); + try { + expect(planTesterTransaction(state, 1000n, "random-order")).toEqual({ + direction: "ickb-to-ckb", + amount: ccc.fixedPointFrom(123), + ckbAmount: 0n, + udtAmount: ccc.fixedPointFrom(123), + orderCount: 1, + }); + } finally { + random.mockRestore(); + } + }); + + it("does not auto-select unbuildable tiny SDK conversions before other funded auto choices", () => { + expect(resolveTesterScenario( + testerState({ availableCkbBalance: ccc.fixedPointFrom(2000) + 1n, availableIckbBalance: 0n }), + "auto", + undefined, + 1000n, + () => 0.99, + )).toBe("random-order"); + }); + + it("plans SDK conversions only in a buildable funded direction", () => { + expect(planTesterTransaction( + testerState({ availableCkbBalance: ccc.fixedPointFrom(2000) + 1n, availableIckbBalance: ccc.fixedPointFrom(123) }), + 1000n, + "sdk-conversion", + )).toEqual({ + direction: "ickb-to-ckb", + amount: ccc.fixedPointFrom(123), + ckbAmount: 0n, + udtAmount: ccc.fixedPointFrom(123), + orderCount: 1, + }); + }); + it("resolves generic multi-order scenarios to any funded multi-order type", () => { expect(resolveTesterScenario(testerState({ availableCkbBalance: ccc.fixedPointFrom(650000), @@ -302,6 +406,44 @@ describe("planTesterTransaction", () => { expect(resolveTesterScenario(testerState({ availableCkbBalance: 0n }), "sdk-conversion")).toBe("sdk-conversion"); }); + it("resolves auto only to scenarios funded by current balances", () => { + const ckbOnlyState = testerState({ availableCkbBalance: ccc.fixedPointFrom(3000), availableIckbBalance: 0n }); + expect(resolveTesterScenario(ckbOnlyState, "auto", undefined, 1000n, () => 0)).toBe("random-order"); + expect(resolveTesterScenario(ckbOnlyState, "auto", undefined, 1000n, () => 0.99)).toBe("sdk-conversion"); + + const ickbOnlyState = testerState({ availableCkbBalance: 0n, availableIckbBalance: ccc.fixedPointFrom(10) }); + expect(resolveTesterScenario(ickbOnlyState, "auto", undefined, 1000n, () => 0)).toBe("random-order"); + expect(resolveTesterScenario(ickbOnlyState, "auto", undefined, 1000n, () => 0.5)).toBe("sdk-conversion"); + expect(resolveTesterScenario(ickbOnlyState, "auto", undefined, 1000n, () => 0.99)).toBe("bounded-ickb-to-ckb-limit-order"); + + const mixedMultiOrderState = testerState({ + availableCkbBalance: ccc.fixedPointFrom(650000), + availableIckbBalance: ccc.fixedPointFrom(123), + }); + const autoSamples = [0, 0.2, 0.4, 0.6, 0.8, 0.99].map((sample) => resolveTesterScenario( + mixedMultiOrderState, + "auto", + undefined, + 1000n, + () => sample, + )); + expect(autoSamples).not.toContain("all-ckb-limit-order"); + expect(autoSamples).not.toContain("ickb-to-ckb-limit-order"); + expect(autoSamples).not.toContain("two-ckb-to-ickb-limit-orders"); + expect(autoSamples).not.toContain("two-ickb-to-ckb-limit-orders"); + expect(autoSamples).not.toContain("mixed-direction-limit-orders"); + expect(autoSamples).not.toContain("dust-ckb-conversion"); + expect(autoSamples).not.toContain("dust-ickb-conversion"); + + expect(() => resolveTesterScenario( + testerState({ availableCkbBalance: ccc.fixedPointFrom(2000), availableIckbBalance: 0n }), + "auto", + undefined, + 1000n, + () => 0, + )).toThrow("Not enough funds for auto tester scenario"); + }); + it("computes post-transaction plain CKB reserve from unspent inputs and account outputs", () => { const lock = script("11"); const otherLock = script("22"); @@ -339,6 +481,207 @@ describe("planTesterTransaction", () => { postTxCkbBalance: "0", }); }); + + it("reports total collected owned orders separately from matchable cancelled orders", () => { + const actions = testerExecutionActions( + "auto", + "ickb-to-ckb-limit-order", + undefined, + [estimatedOrder("ickb-to-ckb", ccc.fixedPointFrom(12), ccc.fixedPointFrom(10), ccc.fixedPointFrom(11), ccc.fixedPointFrom(1))], + { fee: 1n, feeBase: 1000n }, + testerState({ + availableCkbBalance: ccc.fixedPointFrom(100), + userOrders: [matchableOrder(byte32FromByte("10")), nonMatchableOrder(byte32FromByte("11"))], + }), + ); + + expect(actions).toEqual({ + requestedTesterScenario: "auto", + testerScenario: "ickb-to-ckb-limit-order", + newOrder: { + giveIckb: "11", + takeCkb: "10", + fee: "1", + feeNumerator: "1", + feeBase: "1000", + }, + collectedOrders: 2, + cancelledOrders: 1, + }); + }); + + it("keeps unbroadcast post-build reserve skips under attempted evidence", () => { + const evidence = testerAttemptedTransactionEvidence( + "auto", + "ickb-to-ckb-limit-order", + undefined, + { + attemptedOrder: { + giveIckb: "11", + takeCkb: "10", + fee: "1", + feeNumerator: "1", + feeBase: "1000", + }, + }, + ); + + expect(evidence).toEqual({ + requestedTesterScenario: "auto", + testerScenario: "ickb-to-ckb-limit-order", + attemptedOrder: { + giveIckb: "11", + takeCkb: "10", + fee: "1", + feeNumerator: "1", + feeBase: "1000", + }, + }); + expect(evidence).not.toHaveProperty("newOrder"); + expect(evidence).not.toHaveProperty("newOrders"); + }); + + it("does not claim exact SDK conversion order evidence", () => { + const actions = testerExecutionActions( + "sdk-conversion", + "sdk-conversion", + { kind: "order" }, + [estimatedOrder("ckb-to-ickb", ccc.fixedPointFrom(12), ccc.fixedPointFrom(12), ccc.fixedPointFrom(11), ccc.fixedPointFrom(1))], + { fee: 1n, feeBase: 1000n }, + testerState({ availableCkbBalance: ccc.fixedPointFrom(3000) }), + ); + + expect(actions).toEqual({ + testerScenario: "sdk-conversion", + conversion: { kind: "order" }, + collectedOrders: 0, + cancelledOrders: 0, + }); + }); + + it("keeps attempted order evidence available for unbuildable estimates", () => { + const skip = testerEstimatedTooSmallSkip( + "auto", + "dust-ckb-conversion", + [{ direction: "ckb-to-ickb", amount: 1n, amounts: { ckbValue: 1n, udtValue: 0n } }], + [], + { fee: 1n, feeBase: 100000n }, + ); + + expect(skip).toEqual({ + reason: "estimated-conversion-too-small", + requestedTesterScenario: "auto", + testerScenario: "dust-ckb-conversion", + attemptedOrder: { + giveCkb: "0.00000001", + feeNumerator: "1", + feeBase: "100000", + }, + }); + }); + + it("keeps converted attempted order evidence for zero estimates", () => { + const skip = testerEstimatedTooSmallSkip( + "dust-ickb-conversion", + "dust-ickb-conversion", + [{ direction: "ickb-to-ckb", amount: 1n, amounts: { ckbValue: 0n, udtValue: 1n } }], + [estimatedOrder("ickb-to-ckb", 1n, 0n, 1n, 0n)], + { fee: 1n, feeBase: 100000n }, + ); + + expect(skip).toEqual({ + reason: "estimated-conversion-too-small", + testerScenario: "dust-ickb-conversion", + attemptedOrder: { + giveIckb: "0.00000001", + takeCkb: "0", + fee: "0", + feeNumerator: "1", + feeBase: "100000", + }, + }); + }); + + it("checks the post-transaction CKB reserve for iCKB-to-CKB transactions", () => { + const lock = script("11"); + const spent = capacityCell(ccc.fixedPointFrom(1000), lock, "05"); + const tx = ccc.Transaction.default(); + tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); + tx.addOutput({ capacity: ccc.fixedPointFrom(100), lock }); + + expect(enforceTesterPlainCkbReserve( + tx, + testerState({ availableCkbBalance: ccc.fixedPointFrom(1000), capacityCells: [spent] }), + [lock], + "ickb-to-ckb-limit-order", + )).toEqual({ + reason: "post-tx-ckb-reserve", + reserve: "2000", + postTxCkbBalance: "100", + }); + }); + + it("allows below-reserve transactions that improve plain CKB", () => { + const lock = script("11"); + const tx = ccc.Transaction.default(); + tx.addOutput({ capacity: ccc.fixedPointFrom(100), lock }); + + expect(enforceTesterPlainCkbReserve( + tx, + testerState({ availableCkbBalance: 0n }), + [lock], + "ickb-to-ckb-limit-order", + )).toBeUndefined(); + }); + + it("allows below-reserve transactions that preserve plain CKB", () => { + const lock = script("11"); + const spent = capacityCell(ccc.fixedPointFrom(1000), lock, "06"); + const tx = ccc.Transaction.default(); + tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); + tx.addOutput({ capacity: ccc.fixedPointFrom(1000), lock }); + + expect(enforceTesterPlainCkbReserve( + tx, + testerState({ availableCkbBalance: ccc.fixedPointFrom(1000), capacityCells: [spent] }), + [lock], + "ickb-to-ckb-limit-order", + )).toBeUndefined(); + }); + + it("still blocks below-reserve transactions that drain plain CKB", () => { + const lock = script("11"); + const spent = capacityCell(ccc.fixedPointFrom(1000), lock, "07"); + const tx = ccc.Transaction.default(); + tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); + tx.addOutput({ capacity: ccc.fixedPointFrom(999), lock }); + + expect(enforceTesterPlainCkbReserve( + tx, + testerState({ availableCkbBalance: ccc.fixedPointFrom(1000), capacityCells: [spent] }), + [lock], + "ickb-to-ckb-limit-order", + )).toEqual({ + reason: "post-tx-ckb-reserve", + reserve: "2000", + postTxCkbBalance: "999", + }); + }); + + it("fails explicit CKB reserve stress scenarios when completed transactions violate reserve", () => { + const lock = script("11"); + const spent = capacityCell(ccc.fixedPointFrom(3000), lock, "08"); + const tx = ccc.Transaction.default(); + tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); + tx.addOutput({ capacity: ccc.fixedPointFrom(1999), lock }); + + expect(() => enforceTesterPlainCkbReserve( + tx, + testerState({ availableCkbBalance: ccc.fixedPointFrom(3000), capacityCells: [spent] }), + [lock], + "all-ckb-limit-order", + )).toThrow(TesterTerminalError); + }); }); describe("freshMatchableOrderSkip", () => { @@ -372,14 +715,14 @@ describe("freshMatchableOrderSkip", () => { await expect(freshMatchableOrderSkip( runtime as never, [matchableOrder(txHash)], - headerLike({ number: 105400n, epoch: ccc.Epoch.from([0n, 0n, 1n]) }), + headerLike({ number: 100180n, epoch: ccc.Epoch.from([0n, 0n, 1n]) }), 0n, )).resolves.toEqual({ reason: "fresh-matchable-order", txHash, blockNumber: 100000n, - tipNumber: 105400n, - maxElapsedBlocks: 5400n, + tipNumber: 100180n, + maxElapsedBlocks: 180n, }); }); @@ -393,7 +736,7 @@ describe("freshMatchableOrderSkip", () => { await expect(freshMatchableOrderSkip( runtime as never, [matchableOrder(byte32FromByte("33")), nonMatchableOrder(byte32FromByte("44"))], - headerLike({ number: 105401n, epoch: ccc.Epoch.from([0n, 0n, 1n]) }), + headerLike({ number: 100181n, epoch: ccc.Epoch.from([0n, 0n, 1n]) }), 0n, )).resolves.toBeUndefined(); }); @@ -408,7 +751,7 @@ describe("freshMatchableOrderSkip", () => { await expect(freshMatchableOrderSkip( runtime as never, [unmarketableOrder(byte32FromByte("45"))], - headerLike({ number: 105400n, epoch: ccc.Epoch.from([0n, 0n, 1n]) }), + headerLike({ number: 100180n, epoch: ccc.Epoch.from([0n, 0n, 1n]) }), 0n, )).resolves.toBeUndefined(); }); @@ -423,30 +766,62 @@ describe("freshMatchableOrderSkip", () => { await expect(freshMatchableOrderSkip( runtime as never, [matchableOrder(byte32FromByte("46"))], - headerLike({ number: 105400n, epoch: ccc.Epoch.from([0n, 0n, 1n]) }), + headerLike({ number: 100180n, epoch: ccc.Epoch.from([0n, 0n, 1n]) }), ccc.fixedPointFrom(1000), )).resolves.toBeUndefined(); expect(runtime.client.getTransaction).not.toHaveBeenCalled(); }); + + it("does not let fresh bot-updated descendants block new tester stimulus", async () => { + const descendantTxHash = byte32FromByte("47"); + const mintTxHash = byte32FromByte("48"); + const runtime = { + client: { + getTransaction: vi.fn<() => Promise<{ blockNumber: bigint }>>(() => Promise.resolve({ blockNumber: 100000n })), + }, + }; + + await expect(freshMatchableOrderSkip( + runtime as never, + [matchedDescendantOrder(descendantTxHash, mintTxHash)], + headerLike({ number: 100180n, epoch: ccc.Epoch.from([0n, 0n, 1n]) }), + 0n, + )).resolves.toBeUndefined(); + expect(runtime.client.getTransaction).not.toHaveBeenCalled(); + }); }); function matchableOrder(txHash: ccc.Hex): never { - return order(txHash, true); + return order(txHash, true, { type: "relative", value: { distance: 1n, padding: new Uint8Array(32) } }); +} + +function matchedDescendantOrder(txHash: ccc.Hex, masterTxHash: ccc.Hex): never { + return order(txHash, true, { type: "absolute", value: { txHash: masterTxHash, index: 1n } }); } function nonMatchableOrder(txHash: ccc.Hex): never { - return order(txHash, false); + return order(txHash, false, { type: "relative", value: { distance: 1n, padding: new Uint8Array(32) } }); } function unmarketableOrder(txHash: ccc.Hex): never { - return order(txHash, true, Ratio.from({ ckbScale: 20_000_000_000_000_000n, udtScale: 1n })); + return order( + txHash, + true, + { type: "relative", value: { distance: 1n, padding: new Uint8Array(32) } }, + Ratio.from({ ckbScale: 20_000_000_000_000_000n, udtScale: 1n }), + ); } -function order(txHash: ccc.Hex, isMatchable: boolean, ckbToUdt = Ratio.from({ ckbScale: 1n, udtScale: 2n })): never { +function order( + txHash: ccc.Hex, + isMatchable: boolean, + master: Parameters[0]["master"], + ckbToUdt = Ratio.from({ ckbScale: 1n, udtScale: 2n }), +): never { const udtScript = script("66"); const outputData = OrderData.from({ udtValue: 0n, - master: { type: "absolute", value: { txHash, index: 1n } }, + master, info: { ckbToUdt, udtToCkb: Ratio.empty(), ckbMinMatchLog: 0 }, }).toBytes(); const minimalCell = ccc.Cell.from({ @@ -467,10 +842,29 @@ function order(txHash: ccc.Hex, isMatchable: boolean, ckbToUdt = Ratio.from({ ck } as never; } +function estimatedOrder( + direction: "ckb-to-ickb" | "ickb-to-ckb", + amount: bigint, + ckbValue: bigint, + udtValue: bigint, + ckbFee: bigint, +): never { + return { + direction, + amount, + amounts: { ckbValue, udtValue }, + estimate: { + convertedAmount: direction === "ckb-to-ickb" ? udtValue : ckbValue, + ckbFee, + }, + } as never; +} + function testerState(values: { availableCkbBalance: bigint; availableIckbBalance?: bigint; capacityCells?: ccc.Cell[]; + userOrders?: never[]; }): TesterState { const availableIckbBalance = values.availableIckbBalance ?? 0n; return { @@ -490,7 +884,7 @@ function testerState(values: { receipts: [], withdrawalGroups: [], }, - userOrders: [], + userOrders: values.userOrders ?? [], conversionContext: { system: { exchangeRatio: { ckbScale: 1n, udtScale: 1n }, diff --git a/apps/tester/src/index.ts b/apps/tester/src/index.ts index 15c24b3f..99c40ae8 100644 --- a/apps/tester/src/index.ts +++ b/apps/tester/src/index.ts @@ -41,12 +41,19 @@ const TESTER_SCENARIOS = [ "two-ckb-to-ickb-limit-orders", "all-ckb-limit-order", "ickb-to-ckb-limit-order", + "bounded-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]; +export type TesterScenarioSelection = TesterScenario | "auto"; +const AUTO_TESTER_SCENARIOS: readonly TesterScenario[] = [ + "random-order", + "sdk-conversion", + "bounded-ickb-to-ckb-limit-order", +]; const MULTI_ORDER_SCENARIOS: TesterScenario[] = [ "mixed-direction-limit-orders", "two-ckb-to-ickb-limit-orders", @@ -66,7 +73,7 @@ type PlannedOrderLog = { takeIckb?: string; giveIckb?: string; takeCkb?: string; - fee: string; + fee?: string; feeNumerator: string; feeBase: string; }; @@ -85,6 +92,7 @@ type PlannedRawOrder = { type EstimatedRawOrder = PlannedRawOrder & { estimate: ReturnType; }; +export type TesterExecutionActions = Record; export class TesterTerminalError extends Error { constructor(message: string) { @@ -98,7 +106,6 @@ async function main(): Promise { 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); @@ -164,7 +171,7 @@ async function main(): Promise { }; executionLog.ratio = state.system.exchangeRatio; - const effectiveTesterScenario = resolveTesterScenario(state, testerScenario, feePolicy); + const effectiveTesterScenario = resolveTesterScenario(state, testerScenario, feePolicy, depositCapacity); const plan = planTesterTransaction(state, depositCapacity, effectiveTesterScenario); const rawOrders = plannedRawOrders(plan, effectiveTesterScenario); @@ -196,7 +203,13 @@ async function main(): Promise { estimatedOrders.push({ ...order, estimate }); } if (estimateUnavailable || estimatedOrders.some((order) => order.estimate.convertedAmount <= 0n)) { - executionLog.skip = { reason: "estimated-conversion-too-small" }; + executionLog.skip = testerEstimatedTooSmallSkip( + testerScenario, + effectiveTesterScenario, + rawOrders, + estimatedOrders, + effectiveFeePolicy, + ); if (logTerminalIteration(executionLog, startTime, ++completedIterations, maxIterations)) { return; } @@ -212,33 +225,31 @@ async function main(): Promise { const { tx } = built; const txFee = tx.estimateFee(state.system.feeRate); - if (estimatedOrders.some((order) => order.direction === "ckb-to-ickb")) { - const postTxCkbBalance = postTransactionPlainCkbBalance(tx, state, runtime.accountLocks); - const reserveSkip = testerReserveSkip(postTxCkbBalance); - if (reserveSkip !== undefined) { - 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 = reserveSkip; - if (logTerminalIteration(executionLog, startTime, ++completedIterations, maxIterations)) { - return; - } - continue; + const reserveSkip = enforceTesterPlainCkbReserve(tx, state, runtime.accountLocks, effectiveTesterScenario); + if (reserveSkip !== undefined) { + executionLog.skip = { + ...reserveSkip, + ...testerAttemptedTransactionEvidence( + testerScenario, + effectiveTesterScenario, + built.conversion, + attemptedOrderEvidence(rawOrders, estimatedOrders, effectiveFeePolicy), + ), + }; + if (logTerminalIteration(executionLog, startTime, ++completedIterations, maxIterations)) { + return; } + continue; } - executionLog.actions = { - ...(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.actions = testerExecutionActions( + testerScenario, + effectiveTesterScenario, + built.conversion, + estimatedOrders, + effectiveFeePolicy, + state, + ); executionLog.txFee = { fee: formatCkb(txFee), feeRate: state.system.feeRate, @@ -249,7 +260,7 @@ async function main(): Promise { }, }); } catch (e) { - stopAfterLog = handleLoopError(executionLog, e, secrets); + stopAfterLog = handleLoopError(executionLog, e, { rpcUrl }); if (e instanceof TesterTerminalError) { process.exitCode = 1; stopAfterLog = true; @@ -306,10 +317,10 @@ export async function readTesterRuntimeConfig(env: NodeJS.ProcessEnv): Promise number = Math.random): TesterScenario { - const index = Math.floor(random() * TESTER_SCENARIOS.length); - return TESTER_SCENARIOS[index] ?? "random-order"; +export function randomTesterScenario( + random: () => number = Math.random, + scenarios: readonly TesterScenario[] = TESTER_SCENARIOS, +): TesterScenario { + const index = Math.floor(random() * scenarios.length); + return scenarios[index] ?? "random-order"; } export function isRetryableTesterError(error: unknown): boolean { @@ -351,13 +365,9 @@ export function postTransactionPlainCkbBalance( ): 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()) || - !isAccountPlainCapacityOutput(cell.cellOutput, cell.outputData, accountLockHexes) - ? total - : total + cell.cellOutput.capacity, - 0n, + const unspentCapacity = plainCapacityBalance( + state.account.capacityCells.filter((cell) => !spentOutPoints.has(cell.outPoint.toHex())), + accountLockHexes, ); const outputCapacity = tx.outputs.reduce( (total, output, index) => total + (isAccountPlainCapacityOutput(output, tx.outputsData[index], accountLockHexes) ? output.capacity : 0n), @@ -378,6 +388,84 @@ export function testerReserveSkip(postTxCkbBalance: bigint): Record | undefined { + const accountLockHexes = new Set(accountLocks.map((lock) => lock.toHex())); + const preTxCkbBalance = plainCapacityBalance(state.account.capacityCells, accountLockHexes); + const postTxCkbBalance = postTransactionPlainCkbBalance(tx, state, accountLocks); + const reserveSkip = testerReserveSkip(postTxCkbBalance); + if (reserveSkip === undefined || postTxCkbBalance >= preTxCkbBalance) { + return undefined; + } + if (isExplicitCkbReserveScenario(scenario)) { + throw new TesterTerminalError( + `Not enough CKB to preserve tester reserve after the tx: expected ${formatCkb(CKB_RESERVE)} CKB, got ${formatCkb(postTxCkbBalance)} CKB`, + ); + } + return reserveSkip; +} + +function plainCapacityBalance(cells: readonly ccc.Cell[], accountLockHexes: ReadonlySet): bigint { + return cells.reduce( + (total, cell) => total + (isAccountPlainCapacityOutput(cell.cellOutput, cell.outputData, accountLockHexes) ? cell.cellOutput.capacity : 0n), + 0n, + ); +} + +export function testerExecutionActions( + requestedScenario: TesterScenarioSelection, + effectiveScenario: TesterScenario, + conversion: unknown, + estimatedOrders: EstimatedRawOrder[], + feePolicy: TesterFeePolicy, + state: Pick, +): TesterExecutionActions { + const conversionRecord = isRecord(conversion) ? conversion : undefined; + const collectedOrders = state.userOrders.length; + const cancelledOrders = state.userOrders.filter((group) => group.order.isMatchable()).length; + return { + ...(effectiveScenario === requestedScenario ? {} : { requestedTesterScenario: requestedScenario }), + testerScenario: effectiveScenario, + ...(conversionRecord === undefined ? {} : { conversion: conversionRecord }), + ...(conversionRecord === undefined ? orderEvidence(estimatedOrders, feePolicy) : {}), + collectedOrders, + cancelledOrders, + }; +} + +export function testerEstimatedTooSmallSkip( + requestedScenario: TesterScenarioSelection, + effectiveScenario: TesterScenario, + rawOrders: PlannedRawOrder[], + estimatedOrders: EstimatedRawOrder[], + feePolicy: TesterFeePolicy, +): Record { + return { + reason: "estimated-conversion-too-small", + ...(effectiveScenario === requestedScenario ? {} : { requestedTesterScenario: requestedScenario }), + testerScenario: effectiveScenario, + ...attemptedOrderEvidence(rawOrders, estimatedOrders, feePolicy), + }; +} + +export function testerAttemptedTransactionEvidence( + requestedScenario: TesterScenarioSelection, + effectiveScenario: TesterScenario, + conversion: unknown, + orderEvidence: Record, +): Record { + const conversionRecord = isRecord(conversion) ? conversion : undefined; + return { + ...(effectiveScenario === requestedScenario ? {} : { requestedTesterScenario: requestedScenario }), + testerScenario: effectiveScenario, + ...(conversionRecord === undefined ? orderEvidence : { attemptedConversion: conversionRecord }), + }; +} + export function planTesterTransaction( state: Pick, depositCapacity: bigint, @@ -386,6 +474,9 @@ export function planTesterTransaction( if (scenario === "multi-order-limit-orders") { return planTesterTransaction(state, depositCapacity, resolveTesterScenario(state, scenario)); } + if (scenario === "sdk-conversion") { + return planSdkConversionTransaction(state, depositCapacity); + } if (scenario === "extra-large-limit-order") { const ckbAmount = depositCapacity * 2n; if (state.availableCkbBalance - CKB_RESERVE < ckbAmount) { @@ -414,6 +505,13 @@ export function planTesterTransaction( } return { direction: "ickb-to-ckb", amount: udtAmount, ckbAmount: 0n, udtAmount, orderCount: 1 }; } + if (scenario === "bounded-ickb-to-ckb-limit-order") { + const udtAmount = min(ICKB_DEPOSIT_CAP, state.availableIckbBalance); + if (udtAmount <= 0n) { + throw new TesterTerminalError("Not enough iCKB for bounded 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) { @@ -450,9 +548,10 @@ export function planTesterTransaction( return { direction: "ickb-to-ckb", amount: 1n, ckbAmount: 0n, udtAmount: 1n, orderCount: 1 }; } + const spendableCkbBalance = max(0n, state.availableCkbBalance - CKB_RESERVE); const ickbEquivalentBalance = convert( true, - state.availableCkbBalance, + spendableCkbBalance, state.system.exchangeRatio, ); const totalIckbBalance = ickbEquivalentBalance + state.availableIckbBalance; @@ -460,7 +559,7 @@ export function planTesterTransaction( const ckbAmount = isCkb2Udt ? min( isSdkConversionScenario(scenario) ? depositCapacity : sampleRatio(depositCapacity), - state.availableCkbBalance - CKB_RESERVE, + spendableCkbBalance, ) : 0n; const udtAmount = isCkb2Udt @@ -481,9 +580,18 @@ export function planTesterTransaction( export function resolveTesterScenario( state: Pick, - scenario: TesterScenario, + scenario: TesterScenarioSelection, feePolicy: TesterFeePolicy = DEFAULT_TESTER_FEE_POLICY, + depositCapacity = 0n, + random: () => number = Math.random, ): TesterScenario { + if (scenario === "auto") { + const fundedScenarios = fundedTesterScenarios(state, depositCapacity, feePolicy, AUTO_TESTER_SCENARIOS); + if (fundedScenarios.length === 0) { + throw new TesterTerminalError("Not enough funds for auto tester scenario"); + } + return randomTesterScenario(random, fundedScenarios); + } if (scenario !== "multi-order-limit-orders") { return scenario; } @@ -494,6 +602,113 @@ export function resolveTesterScenario( throw new TesterTerminalError("Not enough funds for multi-order limit orders scenario"); } +function fundedTesterScenarios( + state: Pick, + depositCapacity: bigint, + feePolicy: TesterFeePolicy, + candidates: readonly TesterScenario[], +): TesterScenario[] { + return candidates.filter((scenario) => { + if (scenario === "random-order") { + return hasPositiveRandomOrderEstimate(state, depositCapacity, feePolicy); + } + if (scenario === "sdk-conversion") { + return hasBuildableSdkConversionEstimate(state, depositCapacity); + } + try { + const plan = planTesterTransaction(state, depositCapacity, scenario); + const orders = plannedRawOrders(plan, scenario); + return orders.length > 0 && orders.every((order) => { + const estimate = estimateRawOrder(order, state.system, feePolicy); + return estimate !== undefined && estimate.convertedAmount > 0n; + }); + } catch (error) { + if (error instanceof TesterTerminalError) { + return false; + } + throw error; + } + }); +} + +function hasBuildableSdkConversionEstimate( + state: Pick, + depositCapacity: bigint, +): boolean { + try { + planSdkConversionTransaction(state, depositCapacity); + return true; + } catch (error) { + if (error instanceof TesterTerminalError) { + return false; + } + throw error; + } +} + +function planSdkConversionTransaction( + state: Pick, + depositCapacity: bigint, +): TesterPlan { + const spendableCkbBalance = max(0n, state.availableCkbBalance - CKB_RESERVE); + const ckbPlan = buildableSdkConversionPlan(state, "ckb-to-ickb", min(depositCapacity, spendableCkbBalance), depositCapacity); + if (ckbPlan !== undefined) { + return ckbPlan; + } + + const udtPlan = buildableSdkConversionPlan(state, "ickb-to-ckb", min(ICKB_DEPOSIT_CAP, state.availableIckbBalance), depositCapacity); + if (udtPlan !== undefined) { + return udtPlan; + } + + throw new TesterTerminalError("Not enough funds for SDK conversion scenario"); +} + +function buildableSdkConversionPlan( + state: Pick, + direction: TesterDirection, + amount: bigint, + depositCapacity: bigint, +): TesterPlan | undefined { + if (amount <= 0n) { + return undefined; + } + const order: PlannedRawOrder = { direction, amounts: planAmounts(direction, amount), amount }; + const estimate = estimateRawOrder(order, state.system, DEFAULT_TESTER_FEE_POLICY); + if (estimate === undefined || estimate.convertedAmount <= 0n) { + return undefined; + } + const plan = { + direction, + amount, + ckbAmount: direction === "ckb-to-ickb" ? amount : 0n, + udtAmount: direction === "ickb-to-ckb" ? amount : 0n, + orderCount: 1, + }; + return isBuildableSdkConversionOrder(plan, order, estimate, state.system, depositCapacity) ? plan : undefined; +} + +function hasPositiveRandomOrderEstimate( + state: Pick, + depositCapacity: bigint, + feePolicy: TesterFeePolicy, +): boolean { + const spendableCkbBalance = max(0n, state.availableCkbBalance - CKB_RESERVE); + const orders: PlannedRawOrder[] = []; + if (spendableCkbBalance > 0n) { + const amount = min(depositCapacity, spendableCkbBalance); + orders.push({ direction: "ckb-to-ickb", amounts: { ckbValue: amount, udtValue: 0n }, amount }); + } + if (state.availableIckbBalance > 0n) { + const amount = min(ICKB_DEPOSIT_CAP, state.availableIckbBalance); + orders.push({ direction: "ickb-to-ckb", amounts: { ckbValue: 0n, udtValue: amount }, amount }); + } + return orders.some((order) => { + const estimate = estimateRawOrder(order, state.system, feePolicy); + return estimate !== undefined && estimate.convertedAmount > 0n; + }); +} + function hasPositiveMultiOrderEstimates( state: Pick, scenario: TesterScenario, @@ -584,6 +799,19 @@ export function isUnrepresentableTesterEstimateError(error: unknown): boolean { return error instanceof Error && error.message === "Ratio scale exceeds Uint64"; } +function isBuildableSdkConversionOrder( + plan: TesterPlan, + order: PlannedRawOrder, + estimate: ReturnType, + system: TesterState["system"], + depositCapacity: bigint, +): boolean { + if (order.direction === "ckb-to-ickb") { + return plan.amount >= depositCapacity || estimate.maturity !== undefined; + } + return IckbSdk.estimateIckbToCkbOrder(order.amounts, system) !== undefined; +} + function orderEvidence(orders: EstimatedRawOrder[], feePolicy: TesterFeePolicy): Record { const logs = orders.map((order) => orderLog( order.direction === "ckb-to-ickb", @@ -599,10 +827,51 @@ function orderEvidence(orders: EstimatedRawOrder[], feePolicy: TesterFeePolicy): : { newOrders: logs, orderCount: logs.length }; } +function attemptedOrderEvidence( + rawOrders: PlannedRawOrder[], + estimatedOrders: EstimatedRawOrder[], + feePolicy: TesterFeePolicy, +): Record { + const logs = rawOrders.map((order, index) => attemptedOrderLog(order, estimatedOrders[index], feePolicy)); + const [first] = logs; + return logs.length === 1 && first !== undefined + ? { attemptedOrder: first } + : { attemptedOrders: logs, attemptedOrderCount: logs.length }; +} + +function attemptedOrderLog( + order: PlannedRawOrder, + estimatedOrder: EstimatedRawOrder | undefined, + feePolicy: TesterFeePolicy, +): PlannedOrderLog { + if (estimatedOrder !== undefined) { + return orderLog( + estimatedOrder.direction === "ckb-to-ickb", + estimatedOrder.amounts.ckbValue, + estimatedOrder.amounts.udtValue, + estimatedOrder.estimate.convertedAmount, + estimatedOrder.estimate.ckbFee, + feePolicy, + ); + } + const feeFields = feePolicyLog(feePolicy); + return order.direction === "ckb-to-ickb" + ? { giveCkb: formatCkb(order.amounts.ckbValue), ...feeFields } + : { giveIckb: formatCkb(order.amounts.udtValue), ...feeFields }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function min(left: bigint, right: bigint): bigint { return left < right ? left : right; } +function max(left: bigint, right: bigint): bigint { + return left > right ? left : right; +} + function sampleRatio(amount: bigint): bigint { if (amount <= 0n) { return 0n; @@ -644,7 +913,7 @@ function isSdkConversionScenario(scenario: TesterScenario): boolean { return scenario === "sdk-conversion"; } -function isAccountPlainCapacityOutput(output: ccc.CellOutput, outputData: string | undefined, accountLockHexes: Set): boolean { +function isAccountPlainCapacityOutput(output: ccc.CellOutput, outputData: string | undefined, accountLockHexes: ReadonlySet): boolean { return output.type === undefined && (outputData ?? "0x") === "0x" && accountLockHexes.has(output.lock.toHex()); }