From ec13b909f02507b56d51e1783a6d3ba999ba04e2 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:48:31 +0000 Subject: [PATCH 1/8] fix(node-utils): classify wrapped fetch failures --- packages/node-utils/src/retryable.ts | 19 ++++++++++++------- packages/node-utils/test/retryable.ts | 7 +++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/node-utils/src/retryable.ts b/packages/node-utils/src/retryable.ts index e933676..f5d4c1c 100644 --- a/packages/node-utils/src/retryable.ts +++ b/packages/node-utils/src/retryable.ts @@ -7,14 +7,19 @@ export const FETCH_FAILED_MESSAGE = "fetch failed"; * Returns true for transient fetch transport failures surfaced by the RPC client. */ export function isRetryableRpcTransportError(error: unknown): boolean { - if (error instanceof TypeError && error.message === FETCH_FAILED_MESSAGE) { - return true; + let current = error; + const seen = new Set(); + while (typeof current === "object" && current !== null) { + if (isFetchFailedTypeErrorCause(current)) { + return true; + } + if (!(current instanceof Error) || seen.has(current) || !("cause" in current)) { + return false; + } + seen.add(current); + current = current.cause; } - if (!(error instanceof Error) || error.message !== FETCH_FAILED_MESSAGE) { - return false; - } - const cause = "cause" in error ? error.cause : undefined; - return isFetchFailedTypeErrorCause(cause); + return false; } /** diff --git a/packages/node-utils/test/retryable.ts b/packages/node-utils/test/retryable.ts index 9164e59..d1225fe 100644 --- a/packages/node-utils/test/retryable.ts +++ b/packages/node-utils/test/retryable.ts @@ -22,6 +22,13 @@ describe("retryable error classifiers", () => { new Error(FETCH_FAILED_MESSAGE, { cause: new TypeError(FETCH_FAILED_MESSAGE) }), ), ).toBe(true); + expect( + importedIsRetryableRpcTransportError( + new Error("Failed to load transaction header for txHash 0x11 at 0x1100000000", { + cause: new TypeError(FETCH_FAILED_MESSAGE), + }), + ), + ).toBe(true); expect(importedIsRetryableRpcTransportError(new Error(FETCH_FAILED_MESSAGE))).toBe( false, ); From d7f6ebf2093334db4c4cb11c029973304b763725 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:49:01 +0000 Subject: [PATCH 2/8] refactor(bot): split runtime and tooling --- README.md | 5 +- apps/bot/README.md | 250 ++-- apps/bot/docs/current_rebalancing_policy.md | 130 +- apps/bot/package.json | 35 +- apps/bot/src/index.test.ts | 868 ------------ apps/bot/src/index.ts | 405 ++---- apps/bot/src/observability.test.ts | 693 ---------- apps/bot/src/observability.ts | 355 ----- apps/bot/src/policy.test.ts | 1216 ----------------- apps/bot/src/policy.ts | 479 ------- apps/bot/src/runtime.ts | 573 -------- apps/bot/src/withdrawal_selection.ts | 808 ----------- apps/bot/test/index.ts | 145 ++ apps/bot/tsconfig.build.json | 9 - apps/bot/tsconfig.json | 8 +- apps/bot/vitest.config.mts | 4 +- package.json | 10 +- packages/bot/package.json | 51 + packages/bot/src/bot/failure.ts | 227 +++ packages/bot/src/bot/loop.ts | 331 +++++ packages/bot/src/bot/state.ts | 65 + packages/bot/src/index.ts | 22 + packages/bot/src/observability/artifacts.ts | 226 +++ packages/bot/src/observability/error.ts | 147 ++ packages/bot/src/observability/events.ts | 330 +++++ packages/bot/src/observability/lifecycle.ts | 142 ++ packages/bot/src/observability/logValue.ts | 62 + packages/bot/src/observability/rbf.ts | 15 + packages/bot/src/policy.ts | 80 ++ packages/bot/src/policy/constants.ts | 6 + packages/bot/src/policy/ring.ts | 220 +++ packages/bot/src/policy/types.ts | 102 ++ packages/bot/src/policy/withdrawal.ts | 229 ++++ packages/bot/src/runtime/audit.ts | 130 ++ packages/bot/src/runtime/decision.ts | 153 +++ packages/bot/src/runtime/support.ts | 173 +++ packages/bot/src/runtime/transaction.ts | 344 +++++ packages/bot/src/runtime/types.ts | 261 ++++ packages/bot/test/bot/config.ts | 77 ++ packages/bot/test/bot/failure.ts | 187 +++ packages/bot/test/bot/fixtures/bot.ts | 299 ++++ packages/bot/test/bot/loop.ts | 539 ++++++++ packages/bot/test/bot/outputBoundary.ts | 104 ++ packages/bot/test/bot/state.ts | 169 +++ packages/bot/test/observability/artifacts.ts | 264 ++++ packages/bot/test/observability/decision.ts | 237 ++++ packages/bot/test/observability/error.ts | 281 ++++ packages/bot/test/observability/events.ts | 346 +++++ .../observability/fixtures/observability.ts | 193 +++ packages/bot/test/observability/lifecycle.ts | 294 ++++ packages/bot/test/policy/balance.ts | 450 ++++++ packages/bot/test/policy/fixtures/policy.ts | 145 ++ packages/bot/test/policy/ringBootstrap.ts | 246 ++++ packages/bot/test/policy/ringReserve.ts | 171 +++ packages/bot/test/policy/ringWithdrawal.ts | 157 +++ packages/bot/test/policy/withdrawalLimits.ts | 196 +++ .../bot/test/policy/withdrawalRecovery.ts | 106 ++ .../bot/test/policy/withdrawalSelection.ts | 217 +++ packages/bot/test/runtime/support.ts | 33 + packages/bot/test/transactions/deposit.ts | 204 +++ packages/bot/test/transactions/match.ts | 411 ++++++ packages/bot/test/transactions/reserve.ts | 245 ++++ packages/bot/test/transactions/withdrawal.ts | 208 +++ packages/bot/tsconfig.json | 8 + packages/bot/vitest.config.mts | 11 + packages/node-utils/package.json | 8 +- pnpm-lock.yaml | 41 +- scripts/bot/collect-incident.ts | 9 + scripts/bot/incident/args.ts | 150 ++ scripts/bot/incident/bundle.ts | 85 ++ scripts/bot/incident/index.ts | 65 + scripts/bot/incident/io/filesystem.ts | 298 ++++ scripts/bot/incident/io/version.ts | 70 + scripts/bot/incident/model/constants.ts | 12 + scripts/bot/incident/model/paths.ts | 77 ++ scripts/bot/incident/model/text.ts | 48 + scripts/bot/incident/model/types.ts | 244 ++++ scripts/bot/incident/source/artifacts.ts | 261 ++++ scripts/bot/incident/source/collection.ts | 59 + scripts/bot/incident/source/discovery.ts | 93 ++ scripts/bot/incident/source/filter.ts | 228 ++++ scripts/bot/incident/source/summary.ts | 397 ++++++ scripts/bot/launcher.ts | 14 + scripts/bot/launcher/args.ts | 98 ++ scripts/bot/launcher/index.ts | 55 + scripts/bot/launcher/io.ts | 92 ++ scripts/bot/launcher/logs.ts | 198 +++ scripts/bot/launcher/paths.ts | 48 + scripts/bot/launcher/runtime/constants.ts | 13 + scripts/bot/launcher/runtime/execute.ts | 64 + scripts/bot/launcher/runtime/process.ts | 102 ++ scripts/bot/launcher/runtime/records.ts | 95 ++ scripts/bot/launcher/runtime/spawn.ts | 39 + scripts/bot/launcher/runtime/support.ts | 23 + scripts/bot/launcher/runtime/types.ts | 230 ++++ scripts/bot/launcher/storage/filesystem.ts | 135 ++ scripts/bot/launcher/storage/metadata.ts | 24 + scripts/bot/launcher/storage/prepare.ts | 60 + scripts/bot/launcher/storage/retention.ts | 150 ++ scripts/ickb-bot-collect-incident.mjs | 822 ----------- scripts/ickb-bot-collect-incident.test.mjs | 574 -------- scripts/ickb-bot-launcher.mjs | 455 ------ scripts/ickb-bot-launcher.test.mjs | 393 ------ scripts/ickb-bot-systemd-credential.sh | 24 +- scripts/ickb-bot-systemd-credential.test.mjs | 68 - scripts/ickb-bot-systemd-install.sh | 88 +- scripts/ickb-bot-systemd-install.test.mjs | 96 -- scripts/ickb-bot-systemd-update.sh | 153 +-- scripts/ickb-bot-systemd-update.test.mjs | 270 ---- scripts/run-node-tests.ts | 51 + scripts/test/bot/incident/artifacts.ts | 244 ++++ scripts/test/bot/incident/core.ts | 381 ++++++ scripts/test/bot/incident/paths.ts | 133 ++ scripts/test/bot/incident/support.ts | 477 +++++++ scripts/test/bot/launcher/failures.ts | 277 ++++ scripts/test/bot/launcher/logs.ts | 417 ++++++ scripts/test/bot/launcher/support.ts | 237 ++++ scripts/test/bot/systemd-credential.ts | 99 ++ scripts/test/bot/systemd-install.ts | 215 +++ scripts/test/bot/systemd-update.ts | 428 ++++++ scripts/tsconfig.json | 8 + 121 files changed, 16165 insertions(+), 8437 deletions(-) delete mode 100644 apps/bot/src/index.test.ts delete mode 100644 apps/bot/src/observability.test.ts delete mode 100644 apps/bot/src/observability.ts delete mode 100644 apps/bot/src/policy.test.ts delete mode 100644 apps/bot/src/policy.ts delete mode 100644 apps/bot/src/runtime.ts delete mode 100644 apps/bot/src/withdrawal_selection.ts create mode 100644 apps/bot/test/index.ts delete mode 100644 apps/bot/tsconfig.build.json create mode 100644 packages/bot/package.json create mode 100644 packages/bot/src/bot/failure.ts create mode 100644 packages/bot/src/bot/loop.ts create mode 100644 packages/bot/src/bot/state.ts create mode 100644 packages/bot/src/index.ts create mode 100644 packages/bot/src/observability/artifacts.ts create mode 100644 packages/bot/src/observability/error.ts create mode 100644 packages/bot/src/observability/events.ts create mode 100644 packages/bot/src/observability/lifecycle.ts create mode 100644 packages/bot/src/observability/logValue.ts create mode 100644 packages/bot/src/observability/rbf.ts create mode 100644 packages/bot/src/policy.ts create mode 100644 packages/bot/src/policy/constants.ts create mode 100644 packages/bot/src/policy/ring.ts create mode 100644 packages/bot/src/policy/types.ts create mode 100644 packages/bot/src/policy/withdrawal.ts create mode 100644 packages/bot/src/runtime/audit.ts create mode 100644 packages/bot/src/runtime/decision.ts create mode 100644 packages/bot/src/runtime/support.ts create mode 100644 packages/bot/src/runtime/transaction.ts create mode 100644 packages/bot/src/runtime/types.ts create mode 100644 packages/bot/test/bot/config.ts create mode 100644 packages/bot/test/bot/failure.ts create mode 100644 packages/bot/test/bot/fixtures/bot.ts create mode 100644 packages/bot/test/bot/loop.ts create mode 100644 packages/bot/test/bot/outputBoundary.ts create mode 100644 packages/bot/test/bot/state.ts create mode 100644 packages/bot/test/observability/artifacts.ts create mode 100644 packages/bot/test/observability/decision.ts create mode 100644 packages/bot/test/observability/error.ts create mode 100644 packages/bot/test/observability/events.ts create mode 100644 packages/bot/test/observability/fixtures/observability.ts create mode 100644 packages/bot/test/observability/lifecycle.ts create mode 100644 packages/bot/test/policy/balance.ts create mode 100644 packages/bot/test/policy/fixtures/policy.ts create mode 100644 packages/bot/test/policy/ringBootstrap.ts create mode 100644 packages/bot/test/policy/ringReserve.ts create mode 100644 packages/bot/test/policy/ringWithdrawal.ts create mode 100644 packages/bot/test/policy/withdrawalLimits.ts create mode 100644 packages/bot/test/policy/withdrawalRecovery.ts create mode 100644 packages/bot/test/policy/withdrawalSelection.ts create mode 100644 packages/bot/test/runtime/support.ts create mode 100644 packages/bot/test/transactions/deposit.ts create mode 100644 packages/bot/test/transactions/match.ts create mode 100644 packages/bot/test/transactions/reserve.ts create mode 100644 packages/bot/test/transactions/withdrawal.ts create mode 100644 packages/bot/tsconfig.json create mode 100644 packages/bot/vitest.config.mts create mode 100644 scripts/bot/collect-incident.ts create mode 100644 scripts/bot/incident/args.ts create mode 100644 scripts/bot/incident/bundle.ts create mode 100644 scripts/bot/incident/index.ts create mode 100644 scripts/bot/incident/io/filesystem.ts create mode 100644 scripts/bot/incident/io/version.ts create mode 100644 scripts/bot/incident/model/constants.ts create mode 100644 scripts/bot/incident/model/paths.ts create mode 100644 scripts/bot/incident/model/text.ts create mode 100644 scripts/bot/incident/model/types.ts create mode 100644 scripts/bot/incident/source/artifacts.ts create mode 100644 scripts/bot/incident/source/collection.ts create mode 100644 scripts/bot/incident/source/discovery.ts create mode 100644 scripts/bot/incident/source/filter.ts create mode 100644 scripts/bot/incident/source/summary.ts create mode 100644 scripts/bot/launcher.ts create mode 100644 scripts/bot/launcher/args.ts create mode 100644 scripts/bot/launcher/index.ts create mode 100644 scripts/bot/launcher/io.ts create mode 100644 scripts/bot/launcher/logs.ts create mode 100644 scripts/bot/launcher/paths.ts create mode 100644 scripts/bot/launcher/runtime/constants.ts create mode 100644 scripts/bot/launcher/runtime/execute.ts create mode 100644 scripts/bot/launcher/runtime/process.ts create mode 100644 scripts/bot/launcher/runtime/records.ts create mode 100644 scripts/bot/launcher/runtime/spawn.ts create mode 100644 scripts/bot/launcher/runtime/support.ts create mode 100644 scripts/bot/launcher/runtime/types.ts create mode 100644 scripts/bot/launcher/storage/filesystem.ts create mode 100644 scripts/bot/launcher/storage/metadata.ts create mode 100644 scripts/bot/launcher/storage/prepare.ts create mode 100644 scripts/bot/launcher/storage/retention.ts delete mode 100644 scripts/ickb-bot-collect-incident.mjs delete mode 100644 scripts/ickb-bot-collect-incident.test.mjs delete mode 100644 scripts/ickb-bot-launcher.mjs delete mode 100644 scripts/ickb-bot-launcher.test.mjs delete mode 100644 scripts/ickb-bot-systemd-credential.test.mjs delete mode 100644 scripts/ickb-bot-systemd-install.test.mjs delete mode 100644 scripts/ickb-bot-systemd-update.test.mjs create mode 100644 scripts/run-node-tests.ts create mode 100644 scripts/test/bot/incident/artifacts.ts create mode 100644 scripts/test/bot/incident/core.ts create mode 100644 scripts/test/bot/incident/paths.ts create mode 100644 scripts/test/bot/incident/support.ts create mode 100644 scripts/test/bot/launcher/failures.ts create mode 100644 scripts/test/bot/launcher/logs.ts create mode 100644 scripts/test/bot/launcher/support.ts create mode 100644 scripts/test/bot/systemd-credential.ts create mode 100644 scripts/test/bot/systemd-install.ts create mode 100644 scripts/test/bot/systemd-update.ts create mode 100644 scripts/tsconfig.json diff --git a/README.md b/README.md index 9bee59a..23f8e32 100644 --- a/README.md +++ b/README.md @@ -26,19 +26,20 @@ Current stack flows assume user-owned cells are protected by locks whose signatu Apps: -- `apps/bot`: Node order-fulfillment and rebalance bot for matching profitable orders, collecting owned orders, completing receipts and withdrawals, and rebalancing pool exposure. +- `apps/bot`: Private Node CLI adapter for the bot runtime package. - `apps/interface`: Browser interface for CCC wallet connection, conversion previews, transaction completion, signing, sending, and confirmation. - `apps/sampler`: Mainnet sampling utility that writes historical iCKB exchange-rate CSV output. - `apps/supervisor`: Deterministic live testnet supervisor for bounded bot/tester stress cycles, ignored artifacts, and incident bundles. - `apps/tester`: Node simulator that creates random conversion orders to exercise the order and conversion flows. -The Node app packages (`@ickb/bot`, `@ickb/sampler`, and `@ickb/tester`) publish their built entrypoints for distribution, but the supported reusable API surface lives in the packages below. `@ickb/interface` is a deployable browser app package and does not expose a library entrypoint. +The Node app packages (`@ickb/sampler` and `@ickb/tester`) publish their built entrypoints for distribution, while `apps/bot` runs source through its private `@ickb/bot-cli` package. `@ickb/interface` is a deployable browser app package and does not expose a library entrypoint. Packages: - `packages/core`: iCKB protocol primitives, cells, UDT conversion helpers, and low-level transaction builders. - `packages/dao`: Nervos DAO cell classification, readiness, deposit, request, and withdrawal helpers. - `packages/node-utils`: Private Node app utilities for env parsing, RPC client setup, signer locks, sleeps, and JSON logs. +- `packages/bot`: Private bot runtime, policy, observability, and transaction planning package. - `packages/order`: UDT limit-order entities, grouping, matching, minting, melting, and deployed-script confusion mitigation. - `packages/sdk`: Stack-level SDK that composes core, DAO, and order packages into account state, conversion planning, completion, sending, and confirmation helpers. - `packages/testkit`: Private test helpers and fixtures for workspace tests. diff --git a/apps/bot/README.md b/apps/bot/README.md index 564541f..a22b77c 100644 --- a/apps/bot/README.md +++ b/apps/bot/README.md @@ -4,6 +4,8 @@ The bot is CCC-native. It reads market state from `@ickb/sdk`, matches profitabl The bot minimizes excess iCKB holdings so more liquidity stays available in CKB during iCKB-to-CKB redemption pressure. +Order directions in logs and diagnostics are the on-chain order owner's direction, not the bot's inventory direction. When the bot matches `ckb-to-ickb`, it spends iCKB and receives CKB. When it matches `ickb-to-ckb`, it spends CKB and receives iCKB. Matcher allowance steps therefore follow the asset the bot spends; at an unbalanced rate such as `1 BTC = 100000 USD`, the same value step is `1000 USD` on the USD-spending side and `0.01 BTC` on the BTC-spending side. + ## Docs - [Current Bot Rebalancing Policy](docs/current_rebalancing_policy.md) @@ -13,7 +15,14 @@ The bot minimizes excess iCKB holdings so more liquidity stays available in CKB The bot reads one strict JSON config file named by `BOT_CONFIG_FILE`: ```json -{"chain":"testnet","privateKey":"0x...","rpcUrl":"http://127.0.0.1:8114/","sleepIntervalSeconds":60,"maxIterations":1,"maxRetryableAttempts":10} +{ + "chain": "testnet", + "privateKey": "0x...", + "rpcUrl": "http://127.0.0.1:8114/", + "sleepIntervalSeconds": 60, + "maxIterations": 1, + "maxRetryableAttempts": 10 +} ``` The JSON config accepts exactly `chain`, `privateKey`, optional `rpcUrl`, `sleepIntervalSeconds`, optional `maxIterations`, and optional `maxRetryableAttempts`. Omit `rpcUrl` to let CCC use its default public endpoint for the selected chain. Unknown keys, wrong types, empty/non-HTTP(S) RPC URLs, whitespace/control characters in `rpcUrl`, and non-canonical private keys are rejected. The private key must be exactly lowercase `0x` plus 64 lowercase hex characters, with no newline, spaces, tabs, or comments. Local config files under `config/` are ignored by git. @@ -24,10 +33,16 @@ For local testnet live supervision, keep funded identities in external environme export ICKB_TESTNET_BOT_PRIVATE_KEY='0x...' export ICKB_TESTNET_TESTER_PRIVATE_KEY='0x...' # Optional: export ICKB_TESTNET_RPC_URL='https://...' -# Optional: export ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS=10 +# Optional: export ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS=10 to cap all generated configs pnpm live:config-from-env -- --force ``` +The helper writes bounded `config/bot-testnet.json` and `config/tester-testnet.json` for supervisor/tester runs, plus unbounded `config/bot-live-testnet.json` for a production-like long-running bot. Bounded configs default to `maxRetryableAttempts: 10`; the live config omits `maxRetryableAttempts` unless `ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS` is set intentionally. Use the live config with the source-owned launcher when the goal is continuous matching: + +```bash +BOT_CONFIG_FILE=config/bot-live-testnet.json node --experimental-default-type=module scripts/bot/launcher.ts --no-child-tee +``` + Current network support: - `"chain":"testnet"` @@ -35,11 +50,12 @@ Current network support: ## Run -From the repo root: +From a plain checkout, run `pnpm install` from the repo root. CCC is resolved as a normal package dependency, and the app itself runs from TypeScript source under Node 22.19+. + +From the repo root for an ad hoc foreground bot run: ```bash pnpm install -pnpm --filter ./apps/bot build mkdir -p config $EDITOR config/bot-testnet.json export BOT_CONFIG_FILE="$(pwd)/config/bot-testnet.json" @@ -50,7 +66,6 @@ Or from `apps/bot`: ```bash pnpm install -pnpm build mkdir -p ../../config $EDITOR ../../config/bot-testnet.json export BOT_CONFIG_FILE="$(pwd)/../../config/bot-testnet.json" @@ -63,7 +78,7 @@ The start script writes NDJSON logs to stdout and tees one log file per run. Bal Every bot observability record is one JSON object on stdout with `version`, `app: "bot"`, `chain`, `runId`, `iterationId`, ISO `timestamp`, and `type`. Execution-log records also remain on stdout, and structured bot records can be selected with `app == "bot"`. -The stable event contract is the bot NDJSON object stream, not a particular file path. Production launchers may route stdout to journald, files, or another operator-owned log root; consumers should depend on records with `app: "bot"` and `bot.*` event types, not supervisor/tester output, launcher metadata, rotation layout, incident bundles, `/var/log`, or validation log directories. +The stable event contract is the bot NDJSON object stream, not a particular file path. The source-owned production launcher keeps bot logs under the repo-root `log/` tree by default and records the current event file in `launches.ndjson`. Consumers should depend on records with `app: "bot"` and `bot.*` event types, not supervisor/tester output, launcher metadata, slot layout, incident bundles, `/var/log`, or validation log directories. Stable event types: @@ -81,94 +96,92 @@ Stable event types: - `bot.transaction.failed` - `bot.iteration.failed` -`bot.chain.preflight` emits public chain identity evidence before signing starts: whether a custom RPC URL was configured, expected chain identity, observed genesis hash/address prefix/tip, and match booleans. It does not print the RPC URL because configured URLs may contain credentials. No-action iterations emit `bot.decision.skipped` with `reason` and evidence. Build-time skip reasons `no_actions`, `match_value_not_above_fee`, and `post_tx_ckb_reserve` include a `decision` transcript. Reserve skips report zero committed `actions` and keep attempted action counts under `decision.skip.attemptedActions` because the transaction was not broadcast. The pre-build safety skip `capital_below_minimum` exits with code `2` and includes zero `actions`, `deficit`, and `state` evidence instead of a `decision` transcript because match, rebalance, fee, and transaction shape were not evaluated. `bot.iteration.failed` includes an `error` summary plus `retryable` and `terminal` booleans from the bot retry policy. Rebalance decisions include normalized `reason`; no-op reasons remain policy-owned strings such as `insufficient_output_slots`, `low_ickb_ckb_reserve_unavailable`, `target_ickb_not_exceeded`, and `no_ready_withdrawal_selection`. +`bot.chain.preflight` emits public chain identity evidence before signing starts: whether a custom RPC URL was configured, expected chain identity, observed genesis hash/address prefix/tip, and match booleans. It does not print the RPC URL because configured URLs may contain credentials. No-action iterations emit `bot.decision.skipped` with `reason` and evidence. Build-time skip reasons `no_actions`, `match_value_not_above_fee`, and `post_tx_ckb_reserve` include a `decision` transcript. Reserve skips report zero committed `actions`, keep attempted action counts under `decision.skip.attemptedActions`, and keep reserve arithmetic under `decision.audit.reserveCheck` because the transaction was not broadcast. Bot reserve arithmetic is projected available CKB, not actual plain-cell accounting; withdrawal requests with non-negative match CKB delta are staged CKB recovery actions and bypass the immediate reserve skip. The pre-build safety skip `capital_below_minimum` exits with code `2` and includes zero `actions`, `deficit`, and `state` evidence instead of a `decision` transcript because match, rebalance, fee, and transaction shape were not evaluated. `bot.iteration.failed` includes an `error` summary plus `retryable` and `terminal` booleans from the bot retry policy. Rebalance decisions include normalized `reason`; no-op reasons remain policy-owned strings such as `insufficient_output_slots`, `low_ickb_ckb_reserve_unavailable`, `no_withdrawable_ickb`, `no_ring_surplus_ready_deposits`, `ring_surplus_withdrawal_over_budget`, and `no_ready_withdrawal_selection`, while action reasons include `low_ickb_balance`, `ring_inventory`, `excess_ickb_balance`, and `reserve_recovery`. -The decision transcript groups evidence under `chainTip`, `balances`, `orders`, `withdrawals`, `poolDeposits`, `match`, `rebalance`, `actions`, `fee`, `transactionShape`, `exchangeRatio`, and `depositCapacity`. `balances` includes available, unavailable, total, equivalent, minimum-capital, and spendable CKB evidence. `match.reason` normalizes the matching outcome, while `match.diagnostics` carries public allowance, mining fee, direction counts, candidate counts, positive-gain counts, and rejection counts. `rebalance` carries kind, reason, projected balances, output slots, pool/deposit/withdrawal counts, and policy diagnostics. `fee.feeRate` is included on state and decision events. +The decision transcript groups evidence under `chainTip`, `balances`, `orders`, `withdrawals`, `poolDeposits`, `match`, `rebalance`, `audit`, `actions`, `fee`, `transactionShape`, `exchangeRatio`, and `depositCapacity`. `balances` includes available, unavailable, total, equivalent, minimum-capital, spendable CKB, and matchable CKB evidence. `match.reason` normalizes the matching outcome, while `match.diagnostics` carries public allowance, mining fee, direction counts, candidate counts, positive-gain counts, and rejection counts. Runtime derives the post-match useful CKB and iCKB floors from those diagnostics before evaluating refill and recovery. `rebalance` carries kind, reason, projected balances, output slots, and pool/deposit/withdrawal counts. Ring diagnostics are compact inline on `bot.rebalance.evaluated`; repeated full segment detail is stored as a content-addressed artifact under `log/bot/artifacts//ringSegments/sha256-.json` and referenced by `rebalance.diagnostics.ring.segmentsRef`. If artifact writing is unavailable, the event falls back to inline full diagnostics. Final `bot.decision.skipped` and `bot.transaction.built` decision transcripts keep compact ring evidence under `audit.selectedRing`. `audit` carries compact operator checks for reserve arithmetic, rebalance CKB costs, and selected ring segment shape so reserve and ring decisions can be reviewed without reimplementing the policy. `fee.feeRate` is included on state and decision events. Transaction events summarize action counts, fee, fee rate, tx hash, phase, outcome, confirmation status, check count, elapsed time, retryable/terminal policy, and transaction shape counts. Error summaries preserve non-secret enumerable error fields. This keeps CKB/CCC send rejection evidence such as `code`, `data`, `outPoint`, `currentFee`, and `leastFee` visible in `bot.transaction.failed` and `bot.iteration.failed`. Non-secret debugging data may be logged when useful, including raw transactions, witnesses, public config fields, noncredentialed RPC identity evidence, scripts, cells, hashes, counts, and summaries. -Observed testnet full-node send rejection signatures for generic stale-state races are: in-pool same-input conflict can return `code:-1111` with `data:"RBFRejected(...)"` and CCC fields `currentFee`/`leastFee`; a post-commit spent input returns `code:-301` with `data:"Resolve(Unknown(OutPoint(...)))"` and CCC field `outPoint`; resending the same tx returns `code:-1107` with `data:"Duplicated(Byte32(...))"` and CCC field `txHash`. CKB source also has a `Resolve(Dead(OutPoint(...)))` path for some pool conflicts. Treat these as retry candidates only when the bot discards the transaction and rebuilds from fresh state, not by blindly resending the same transaction. +Observed testnet full-node send rejection signatures for generic stale-state races are: in-pool same-input conflict can return `code:-1111` with `data:"RBFRejected(...)"` and CCC fields `currentFee`/`leastFee`; a post-commit spent input returns `code:-301` with `data:"Resolve(Unknown(OutPoint(...)))"` and CCC field `outPoint`; resending the same tx returns `code:-1107` with `data:"Duplicated(Byte32(...))"` and CCC field `txHash`. CKB source also has a `Resolve(Dead(OutPoint(...)))` path for some pool conflicts. CCC JSON-RPC response id mismatch errors such as `Id mismatched, got null, expected 319` are also retry candidates because the bot discards the failed state read and rebuilds from fresh state. Treat these as retry candidates only when the bot discards the transaction or read state and rebuilds from fresh state, not by blindly resending the same transaction. -JSON `"maxIterations":1` makes `pnpm --filter ./apps/bot start` exit with code `0` after one terminal iteration when that terminal outcome is a skipped decision, committed transaction, non-safety transaction failure, or non-safety iteration failure. Retryable iteration failures do not count toward `maxIterations`; set `maxRetryableAttempts` to stop repeated fresh-state retries with exit code `2` after that many consecutive retryable failures. Safety stops still keep their nonzero behavior: low capital and confirmation timeouts after broadcast exit with code `2`. Omitting `maxIterations` keeps the default infinite loop; omitting `maxRetryableAttempts` leaves retryable attempts unbounded. +JSON `"maxIterations":1` makes `pnpm --filter ./apps/bot start` exit with code `0` after one terminal iteration when that terminal outcome is a skipped decision, committed transaction, or non-safety transaction failure. Nonretryable deterministic iteration failures exit with code `1`. Retryable iteration failures do not count toward `maxIterations`; set `maxRetryableAttempts` to stop repeated fresh-state retries with exit code `2` after that many consecutive retryable failures. Safety stops still keep their nonzero behavior: low capital and confirmation timeouts after broadcast exit with code `2`. Omitting `maxIterations` keeps the default infinite loop; omitting `maxRetryableAttempts` leaves retryable attempts unbounded. Structured events should contain evidence needed to understand bot behavior. The bot must not print its configured private key to events, execution logs, errors, stdout, or stderr. Private keys are for signing only: logger, event, and error helpers must not receive private keys, secret contexts, masking callbacks, redaction parameters, or guard inputs. Tests use a configured canary private key from outside the production path and verify produced output cannot reveal it, even unlabeled or nested in arbitrary text. Secrets, credentialed RPC URLs, tokens, passwords, API keys, and secret-bearing config/env dumps must not be logged or passed to logging, redaction, masking, or guard helpers. Bot-only log queries, using the production event file or any saved bot stdout NDJSON stream: ```bash -LOG_DIR=/opt/ickb-stack-testnet/log/bot/testnet -jq -c 'select(.app == "bot")' "$LOG_DIR/bot.events.ndjson" -jq -r 'select(.app == "bot") | .type' "$LOG_DIR/bot.events.ndjson" | sort | uniq -c -jq -c 'select(.app == "bot" and .type == "bot.chain.preflight") | {timestamp, chain, rpcConfigured, expected, observed, matches}' "$LOG_DIR/bot.events.ndjson" -jq -c 'select(.app == "bot" and .type == "bot.decision.skipped") | {timestamp, chain, runId, iterationId, reason, actions, deficit, state, skip: .decision.skip}' "$LOG_DIR/bot.events.ndjson" -jq -c 'select(.app == "bot" and .type == "bot.match.evaluated") | {timestamp, iterationId, reason: .match.reason, orders, diagnostics: .match.diagnostics}' "$LOG_DIR/bot.events.ndjson" -jq -c 'select(.app == "bot" and .type == "bot.rebalance.evaluated") | {timestamp, iterationId, rebalance, poolDeposits}' "$LOG_DIR/bot.events.ndjson" -jq -c 'select(.app == "bot" and (.type == "bot.transaction.failed" or .type == "bot.iteration.failed")) | {timestamp, chain, runId, iterationId, type, phase, outcome, retryable, terminal, retryableAttempts, maxRetryableAttempts, retryBudgetExhausted, txHash, status, checks, elapsedMs, error}' "$LOG_DIR/bot.events.ndjson" +LOG_DIR=/opt/ickb-stack-testnet/log/bot +EVENT_FILE=$(jq -r 'select(.type == "launcher.started") | .logFiles.events' "$LOG_DIR/launches.ndjson" | tail -n 1) +jq -c 'select(.app == "bot")' "$EVENT_FILE" +jq -r 'select(.app == "bot") | .type' "$EVENT_FILE" | sort | uniq -c +jq -c 'select(.app == "bot" and .type == "bot.chain.preflight") | {timestamp, chain, rpcConfigured, expected, observed, matches}' "$EVENT_FILE" +jq -c 'select(.app == "bot" and .type == "bot.decision.skipped") | {timestamp, chain, runId, iterationId, reason, actions, deficit, state, skip: .decision.skip}' "$EVENT_FILE" +jq -c 'select(.app == "bot" and .type == "bot.match.evaluated") | {timestamp, iterationId, reason: .match.reason, orders, diagnostics: .match.diagnostics}' "$EVENT_FILE" +jq -c 'select(.app == "bot" and .type == "bot.rebalance.evaluated") | {timestamp, iterationId, rebalance, poolDeposits}' "$EVENT_FILE" +jq -c 'select(.app == "bot" and (.type == "bot.decision.skipped" or .type == "bot.transaction.built")) | {timestamp, iterationId, reason, actions, reserve: .decision.audit.reserveCheck, ring: .decision.audit.selectedRing}' "$EVENT_FILE" +jq -c 'select(.app == "bot" and (.type == "bot.transaction.failed" or .type == "bot.iteration.failed")) | {timestamp, chain, runId, iterationId, type, phase, outcome, retryable, terminal, retryableAttempts, maxRetryableAttempts, retryBudgetExhausted, txHash, status, checks, elapsedMs, error}' "$EVENT_FILE" jq -c 'select(.type == "launcher.child.exited") | {timestamp, status, signal, elapsedMs, logRoot, logDir, command}' "$LOG_DIR/launches.ndjson" ``` ## Ubuntu systemd Deployment -For unattended Ubuntu 24.04 deployments, run testnet and mainnet as separate systemd services with separate users, deploy directories, encrypted JSON config credentials, and bot-only file logs. The production units execute the built app through `scripts/ickb-bot-launcher.mjs`, which owns only process and file plumbing: it starts `/usr/bin/node apps/bot/dist/index.js`, writes bot stdout byte-for-byte to `bot.events.ndjson`, writes child stderr byte-for-byte to `bot.stderr.log`, writes launch metadata to `launches.ndjson`, and tees stdout/stderr to journald as a fallback. Launcher metadata records the executable basename and argument count, but not raw child argument values or environment. The package `start` script is for local JSON-config runs and tees app-local log files. +For unattended Ubuntu 24.04 deployments, run testnet and mainnet as separate systemd services with separate users, deploy directories, encrypted JSON credentials, and bot-only logs. The generated units run the bot from source, not `dist`: -The launcher resolves the log root in this order: explicit `--log-root`, runtime `ICKB_BOT_LOG_ROOT`, then `/log`. The systemd install script bakes an explicit `--log-root` into generated units only when `ICKB_BOT_LOG_ROOT` is set during install; relative values are resolved against that network's deploy directory. Without an explicit configured root, testnet defaults to `/opt/ickb-stack-testnet/log` and mainnet defaults to `/opt/ickb-stack-mainnet/log`. +```text +/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts --no-child-tee +``` -The launcher refuses empty paths, log directories outside the resolved log root, symlinked log roots or parent directories, and symlinked log files. It creates `bot.events.ndjson`, `bot.stderr.log`, and `launches.ndjson` with mode `0600` for service-user writes. +`apps/bot` is the CLI workspace for the private `packages/bot` runtime. Production runs Stack source and resolves CCC from installed package dependencies. Production log layout: ```text -/bot/testnet/bot.events.ndjson -/bot/testnet/bot.stderr.log -/bot/testnet/launches.ndjson -/bot/mainnet/bot.events.ndjson -/bot/mainnet/bot.stderr.log -/bot/mainnet/launches.ndjson +/log/bot/bot.events.slot-00.ndjson +/log/bot/bot.stderr.slot-00.log +/log/bot/artifacts/slot-00/ringSegments/sha256-.json +/log/bot/launches.ndjson ``` -These are production bot-only logs. They are separate from local live validation supervisor artifacts such as `logs/live-supervisor/...`. +The launcher keeps 16 fixed run slots, `slot-00` through `slot-15`. Each new launcher run truncates the selected event/stderr slot and resets that slot's artifact directory. `launches.ndjson` is append-only metadata and records `logFiles.events`, `logFiles.stderr`, `logFiles.artifacts`, `logSlot`, and retention settings for every run. These are production bot-only logs. They are separate from local live validation supervisor artifacts such as `log/live-supervisor/...` and `log/validation/...`. -This layout keeps the workflow simple while avoiding accidental cross-network updates: +Default deployment layout: ```text /opt/ickb-stack-testnet /opt/ickb-stack-mainnet -/opt/ickb-stack-testnet/log/bot/testnet/ -/opt/ickb-stack-mainnet/log/bot/mainnet/ +/opt/ickb-stack-testnet/log/bot/ +/opt/ickb-stack-mainnet/log/bot/ /etc/ickb/credentials/ickb-bot-testnet-config.cred /etc/ickb/credentials/ickb-bot-mainnet-config.cred /etc/systemd/system/ickb-bot-testnet.service /etc/systemd/system/ickb-bot-mainnet.service ``` -From a deployed checkout on the VM, install service users, deploy directories, and unit files: +Install each service user, deploy directory, log directory, and unit file from that network's deployed checkout on the VM: ```bash -sudo scripts/ickb-bot-systemd-install.sh all -``` - -To use one explicit log root for both networks, set it while installing or regenerating the units: +sudo -u ickb-bot-testnet git clone /opt/ickb-stack-testnet +cd /opt/ickb-stack-testnet +sudo scripts/ickb-bot-systemd-install.sh testnet -```bash -sudo ICKB_BOT_LOG_ROOT=/path/to/ickb-log-root scripts/ickb-bot-systemd-install.sh all +sudo -u ickb-bot-mainnet git clone /opt/ickb-stack-mainnet +cd /opt/ickb-stack-mainnet +sudo scripts/ickb-bot-systemd-install.sh mainnet ``` -Populate and build each deploy directory before starting services. Clone or copy the same repo revision into both directories, then build as the matching service user: +Install runtime dependencies and check source as the matching service user: ```bash -sudo -u ickb-bot-testnet git clone /opt/ickb-stack-testnet -sudo -u ickb-bot-mainnet git clone /opt/ickb-stack-mainnet sudo -u ickb-bot-testnet pnpm -C /opt/ickb-stack-testnet bot:install +sudo -u ickb-bot-testnet pnpm -C /opt/ickb-stack-testnet bot:check sudo -u ickb-bot-mainnet pnpm -C /opt/ickb-stack-mainnet bot:install -sudo -u ickb-bot-testnet pnpm -C /opt/ickb-stack-testnet bot:build -sudo -u ickb-bot-mainnet pnpm -C /opt/ickb-stack-mainnet bot:build +sudo -u ickb-bot-mainnet pnpm -C /opt/ickb-stack-mainnet bot:check ``` -The install step resolves CCC through normal package dependencies from the lockfile; no separate CCC fork build is required. +The install script uses the current checkout as that network's `WorkingDirectory` and log root. Run it separately from the testnet and mainnet checkouts. The update script expects each deploy directory to be a clean git checkout. -If `/opt/ickb-stack-testnet` or `/opt/ickb-stack-mainnet` already exists from the install script, clone into a temporary path and move the checkout into place, or initialize the existing directory with your normal deployment tooling. The update script expects each deploy directory to be a clean git checkout. - -Create encrypted config credentials on the VM. The tested Ubuntu 24.04 VM has `systemd-creds` and no TPM device, so host-key credentials are the compatible unattended option. If a future VM exposes a TPM, replace `--with-key=host` with the TPM-backed mode selected for that host. The helper prompts for the private key, optional RPC URL, sleep interval, optional max iterations, and max retryable attempts, validates the same strict JSON schema that the app reads, and encrypts that JSON as one systemd credential. +Create encrypted config credentials on the VM. The helper prompts for the private key, optional RPC URL, sleep interval, optional max iterations, and optional max retryable attempts. Leaving the retryable-attempt prompt empty keeps retryable attempts unbounded. The helper validates the bot JSON config and encrypts it as one systemd credential. Private keys and credentialed RPC URLs must stay inside the encrypted credential and must not appear in logs, unit text, environment dumps, incident bundles, or diagnostic output. ```bash sudo systemd-creds setup @@ -176,68 +189,6 @@ sudo scripts/ickb-bot-systemd-credential.sh testnet sudo scripts/ickb-bot-systemd-credential.sh mainnet ``` -The install script writes `/etc/systemd/system/ickb-bot-testnet.service` equivalent to: - -```ini -[Unit] -Description=iCKB bot testnet -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=ickb-bot-testnet -Group=ickb-bot-testnet -WorkingDirectory=/opt/ickb-stack-testnet -Environment=BOT_CONFIG_FILE=%d/ickb-bot-testnet-config.json -LoadCredentialEncrypted=ickb-bot-testnet-config.json:/etc/ickb/credentials/ickb-bot-testnet-config.cred -ExecStart=/usr/bin/node scripts/ickb-bot-launcher.mjs --network testnet -- /usr/bin/node apps/bot/dist/index.js -Restart=on-failure -RestartSec=10 -RestartPreventExitStatus=2 -LimitCORE=0 -NoNewPrivileges=true -PrivateTmp=true -ProtectProc=invisible -ProtectSystem=strict -ReadWritePaths=/opt/ickb-stack-testnet/log -ProtectHome=true - -[Install] -WantedBy=multi-user.target -``` - -It writes `/etc/systemd/system/ickb-bot-mainnet.service` equivalent to: - -```ini -[Unit] -Description=iCKB bot mainnet -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=ickb-bot-mainnet -Group=ickb-bot-mainnet -WorkingDirectory=/opt/ickb-stack-mainnet -Environment=BOT_CONFIG_FILE=%d/ickb-bot-mainnet-config.json -LoadCredentialEncrypted=ickb-bot-mainnet-config.json:/etc/ickb/credentials/ickb-bot-mainnet-config.cred -ExecStart=/usr/bin/node scripts/ickb-bot-launcher.mjs --network mainnet -- /usr/bin/node apps/bot/dist/index.js -Restart=on-failure -RestartSec=10 -RestartPreventExitStatus=2 -LimitCORE=0 -NoNewPrivileges=true -PrivateTmp=true -ProtectProc=invisible -ProtectSystem=strict -ReadWritePaths=/opt/ickb-stack-mainnet/log -ProtectHome=true - -[Install] -WantedBy=multi-user.target -``` - Validate and start the units: ```bash @@ -254,14 +205,15 @@ sudo systemctl status ickb-bot-testnet.service sudo systemctl status ickb-bot-mainnet.service sudo journalctl -u ickb-bot-testnet.service -f sudo journalctl -u ickb-bot-mainnet.service -f -sudo tail -f /opt/ickb-stack-testnet/log/bot/testnet/bot.events.ndjson -sudo tail -f /opt/ickb-stack-mainnet/log/bot/mainnet/bot.events.ndjson -jq -c 'select(.type == "launcher.child.exited")' /opt/ickb-stack-testnet/log/bot/testnet/launches.ndjson +LOG_DIR=/opt/ickb-stack-testnet/log/bot +sudo tail -f "$(jq -r 'select(.type == "launcher.started") | .logFiles.events' "$LOG_DIR/launches.ndjson" | tail -n 1)" sudo systemctl restart ickb-bot-testnet.service sudo systemctl restart ickb-bot-mainnet.service ``` -Update testnet first, then mainnet after the same revision is validated. Regenerate the units with `scripts/ickb-bot-systemd-install.sh` before updating when the documented unit shape changes. The update script refuses stale units that are missing the production launcher wiring or `LimitCORE=0`, then pulls, installs, and builds before restarting the service, so a failed build leaves the currently running bot alone. +Generated systemd units use the checkout-local `/log` root. The launcher also accepts `--log-root` or `ICKB_BOT_LOG_ROOT` for explicit non-systemd workflows, but production units intentionally avoid those overrides so logs, artifacts, validation output, and incident bundles stay under the repo-root `log/` tree. With `--no-child-tee`, systemd/journald gets launcher lifecycle output only; bot stdout/stderr are written byte-for-byte to the current slot files. Launcher metadata records only the executable basename and argument count, not raw child arguments or environment. Set `ICKB_BOT_LOG_STORAGE_QUOTA_BYTES` while generating units to enable best-effort pruning of inactive slot files and artifact directories by storage quota. + +Update testnet first, then mainnet after the same revision is validated. Regenerate the units with `scripts/ickb-bot-systemd-install.sh` before updating when the unit shape changes. The update script refuses stale units and dirty deploy checkouts, pulls with `--ff-only`, runs `bot:install`, type-checks source with `bot:check`, then restarts the service. ```bash sudo scripts/ickb-bot-systemd-update.sh testnet @@ -270,92 +222,58 @@ sudo scripts/ickb-bot-systemd-update.sh mainnet Use `scripts/ickb-bot-systemd-update.sh mainnet` only after the same revision has been validated on testnet. -Exit code `2` is an intentional safety stop, including low capital and transaction confirmation timeout after broadcast. `RestartPreventExitStatus=2` keeps systemd from relaunching immediately. Before restarting, inspect `launches.ndjson` for the child exit record, `bot.events.ndjson` for the terminal bot event, `bot.stderr.log` for runtime errors, and journald for launcher fallback output: +Exit code `2` is an intentional safety stop, including low capital and transaction confirmation timeout after broadcast. `RestartPreventExitStatus=2` keeps systemd from relaunching immediately. Before restarting, inspect `launches.ndjson` for the child exit record, the current event slot for the terminal bot event, the current stderr slot for runtime errors, referenced artifacts for full diagnostics, and journald for launcher lifecycle output: ```bash -LOG_DIR=/opt/ickb-stack-testnet/log/bot/testnet +LOG_DIR=/opt/ickb-stack-testnet/log/bot +EVENT_FILE=$(jq -r 'select(.type == "launcher.started") | .logFiles.events' "$LOG_DIR/launches.ndjson" | tail -n 1) jq -c 'select(.type == "launcher.child.exited")' "$LOG_DIR/launches.ndjson" -jq -c 'select(.app == "bot" and (.terminal == true or .type == "bot.decision.skipped" or .type == "bot.transaction.failed" or .type == "bot.iteration.failed"))' "$LOG_DIR/bot.events.ndjson" +jq -c 'select(.app == "bot" and (.terminal == true or .type == "bot.decision.skipped" or .type == "bot.transaction.failed" or .type == "bot.iteration.failed"))' "$EVENT_FILE" sudo journalctl -u ickb-bot-testnet.service -n 200 --no-pager ``` -The generated units set `LimitCORE=0`, so crash diagnosis should use bot logs, launcher exit records, stderr, journald, and the bundled unit text rather than expecting a core file. +The generated units set `LimitCORE=0`, so crash diagnosis should use bot logs, launcher exit records, stderr, journald, and the bundled systemd unit properties rather than expecting a core file. ### Incident Bundles -Use `scripts/ickb-bot-collect-incident.mjs` before restarting after exit code `2` or any unexpected production behavior. The collector reads only bot production sources: `bot.events.ndjson`, `bot.stderr.log`, `launches.ndjson`, version metadata, and optional systemd status/journal/unit text. It keeps those sources separated and writes a restricted incident directory under the selected bot log directory: +Use `scripts/bot/collect-incident.ts` before restarting after exit code `2` or any unexpected production behavior. The collector reads only bot production sources: event slot files, stderr slot files, legacy flat files when present, `launches.ndjson`, referenced bot artifacts, and version metadata. It keeps those sources separated and writes a restricted incident directory under the selected bot log directory: ```text -/bot//incidents// +/bot/incidents// README.txt - bot.events.ndjson - bot.stderr.log + bot.events.slot-00.ndjson + bot.stderr.slot-00.log + artifacts/slot-00/ringSegments/sha256-.json launches.ndjson summary.json version.json - systemd.status.txt # when systemd output is available - systemd.journal.txt # when systemd output is available - systemd.unit.txt # when systemd output is available ``` -The log root resolves the same way as the launcher: explicit `--log-root`, then runtime `ICKB_BOT_LOG_ROOT`, then `/log`. `--network testnet|mainnet` selects `/bot//`. `--log-dir ` may be used instead of `--network` only when the resolved path stays inside the resolved log root, which is useful for copied logs or a custom contained bot log directory. The collector refuses empty paths, paths outside the resolved log root, symlinked log directories, symlinked incident parents, and symlinked source log files. +The log root resolves the same way as the launcher: explicit `--log-root`, then runtime `ICKB_BOT_LOG_ROOT`, then `/log`. Production systemd units use `/log`, so the default selected bot log directory is `/bot`. `--log-dir ` may be used for copied logs or a custom contained bot log directory when the resolved path stays inside the resolved log root. The collector refuses empty paths, paths outside the resolved log root, symlinked log directories, symlinked incident parents, symlinked source log files, and symlinked artifact path components. Referenced artifacts must be under `artifacts/`, use `sha256-.json` filenames, and match the referenced hash before they are bundled; missing or mismatched artifact refs are reported in `summary.json`. Examples from the deployed checkout: ```bash -sudo -u ickb-bot-testnet node scripts/ickb-bot-collect-incident.mjs --network testnet --since 2h --until now -sudo -u ickb-bot-mainnet node scripts/ickb-bot-collect-incident.mjs --network mainnet --since 2026-05-25T10:00:00Z --until 2026-05-25T11:00:00Z -sudo -u ickb-bot-testnet node scripts/ickb-bot-collect-incident.mjs --log-root /path/to/ickb-log-root --network testnet --since 30m --until now -sudo -u ickb-bot-testnet ICKB_BOT_LOG_ROOT=/path/to/ickb-log-root node scripts/ickb-bot-collect-incident.mjs --log-dir /path/to/ickb-log-root/bot/testnet --since 30m --until now +sudo -u ickb-bot-testnet node --experimental-default-type=module scripts/bot/collect-incident.ts --since 2h --until now +sudo -u ickb-bot-mainnet node --experimental-default-type=module scripts/bot/collect-incident.ts --since 2026-05-25T10:00:00Z --until 2026-05-25T11:00:00Z +sudo -u ickb-bot-testnet node --experimental-default-type=module scripts/bot/collect-incident.ts --log-root log --since 30m --until now ``` -If you do not want systemd status, journal, or unit text in the bundle, add `--no-systemd`. The collector does not include runtime config files or environment dumps because they can contain private keys, credentialed RPC URLs, tokens, passwords, or API keys. Selected source logs and systemd output are bundled as public producer-owned evidence; if a private key or other secret reaches those sources, fix the producer that wrote it before sharing or archiving the bundle. +The collector does not include runtime config files, environment dumps, systemd output, or raw unit text because they can contain private keys, credentialed RPC URLs, tokens, passwords, or API keys. Selected source logs are bundled as public producer-owned evidence; if a private key or other secret reaches those sources, fix the producer that wrote it before sharing or archiving the bundle. -Inspect `summary.json` first. It includes selected source files, malformed/undated/out-of-window line counts, first/last timestamps, event counts by type, transaction hashes by outcome, skip/failure reasons, launcher exit codes, systemd capture results, package version, git commit, Node version, and the collector script version. `bot.stderr.log` is raw child stderr, so undated stack-trace lines after an in-window timestamped stderr line are kept with that timestamped line; when stderr has no timestamps at all, the collector includes the last 200 non-empty stderr lines and marks that in `summary.json`. For exit code `2`, review the `launcher.child.exited` record, terminal bot events (`bot.decision.skipped`, `bot.transaction.failed`, `bot.iteration.failed`, or records with `terminal:true`), stderr, and journald before deciding whether the restart is safe. +Inspect `summary.json` first. It includes selected source files, malformed/undated/out-of-window line counts, first/last timestamps, event counts by type, transaction hashes by outcome, skip/failure reasons, launcher exit codes, artifact refs included/missing/mismatched, package version, git commit, Node version, and the collector script version. Bot stderr files are raw child stderr, so undated stack-trace lines after an in-window timestamped stderr line are kept with that timestamped line; when stderr has no timestamps at all, the collector includes the last 200 non-empty stderr lines and marks that in `summary.json`. For exit code `2`, review the `launcher.child.exited` record, terminal bot events (`bot.decision.skipped`, `bot.transaction.failed`, `bot.iteration.failed`, or records with `terminal:true`), stderr, and referenced artifacts before deciding whether the restart is safe. The collector writes the incident directory directly and prints a portable compression command instead of assuming `tar`, `gzip`, or `zstd` are present. On a host with `tar` and gzip, run the printed command or equivalently: ```bash -tar -czf /opt/ickb-stack-testnet/log/bot/testnet/incidents/.tar.gz -C /opt/ickb-stack-testnet/log/bot/testnet/incidents +tar -czf /opt/ickb-stack-testnet/log/bot/incidents/.tar.gz -C /opt/ickb-stack-testnet/log/bot/incidents ``` Retain incident bundles long enough to cover your operational review and postmortem window, then remove them with the same sensitivity as production logs. A practical default is to keep testnet bundles for 14 days and mainnet bundles for 30 days, matching the rotation examples below unless an active incident review requires longer retention. -### Log Rotation - -The launcher keeps the three log files open for the lifetime of the service and does not implement a reopen signal. Use `copytruncate` if you want rotation without restarting the bot. This can lose a small write window during copy/truncate, but it preserves continuous systemd supervision. If you require exact handoff instead, restart the service after rotation and treat the restart as an operational event. - -Default-root logrotate example for testnet: +### Retention -```text -/opt/ickb-stack-testnet/log/bot/testnet/bot.events.ndjson -/opt/ickb-stack-testnet/log/bot/testnet/bot.stderr.log -/opt/ickb-stack-testnet/log/bot/testnet/launches.ndjson { - daily - rotate 14 - missingok - notifempty - compress - copytruncate - su ickb-bot-testnet ickb-bot-testnet -} -``` - -Default-root logrotate example for mainnet: - -```text -/opt/ickb-stack-mainnet/log/bot/mainnet/bot.events.ndjson -/opt/ickb-stack-mainnet/log/bot/mainnet/bot.stderr.log -/opt/ickb-stack-mainnet/log/bot/mainnet/launches.ndjson { - daily - rotate 30 - missingok - notifempty - compress - copytruncate - su ickb-bot-mainnet ickb-bot-mainnet -} -``` +The launcher owns run-slot retention, so logrotate is not required for bot event and stderr files. It keeps 16 slots by default and can additionally prune inactive slot files and artifact directories when `ICKB_BOT_LOG_STORAGE_QUOTA_BYTES` is configured before systemd unit generation or `--log-storage-quota-bytes` is passed to the launcher. Quota pruning is best-effort: the current run's open files and artifact directory are preserved, and a long current run can grow beyond the configured quota until the next launcher run. ## Notes diff --git a/apps/bot/docs/current_rebalancing_policy.md b/apps/bot/docs/current_rebalancing_policy.md index 600a276..a2e71f2 100644 --- a/apps/bot/docs/current_rebalancing_policy.md +++ b/apps/bot/docs/current_rebalancing_policy.md @@ -1,6 +1,6 @@ # Current Bot Rebalancing Policy -This document describes the behavior implemented by `apps/bot/src/index.ts`, `apps/bot/src/runtime.ts`, and `apps/bot/src/policy.ts`. +This document describes the behavior implemented by the `packages/bot/src/runtime` modules, `packages/bot/src/policy.ts`, and the `apps/bot/src/index.ts` CLI adapter. ## Goal @@ -12,30 +12,22 @@ The bot exits when its total CKB-equivalent capital is less than or equal to `21 The runtime reads system and account state through `@ickb/sdk`, then derives the balances and pool slices used by `planRebalance(...)`. -- `accountLocks`: all signer address locks, deduplicated by full script bytes. -- `system`: live exchange ratio, tip header, fee rate, and market order pool from `sdk.getL1State(...)`. -- `userOrders`: the bot's order groups from `sdk.getL1State(...)`. -- `account`: spendable capacity, native iCKB, receipts, and withdrawals from `sdk.getAccountState(...)`. +- `system`: live exchange ratio, tip header, fee rate, market order pool, and configured pool deposit snapshot from `sdk.getL1AccountState(...)`. +- `userOrders`: the bot's order groups from `sdk.getL1AccountState(...)`. - `marketOrders`: system order-pool entries not already owned by the bot. -- `readyPoolDeposits`: pool deposits that are ready now. -- `nearReadyPoolDeposits`: not-ready pool deposits from the end of the current ready window until, but not including, one hour later. -- `futurePoolDeposits`: not-ready pool deposits after that near-ready hour. +- `poolDeposits`: configured public pool deposit snapshot used for ring coverage. +- `readyPoolDeposits`: ready pool deposits used as withdrawal candidates. - `availableCkbBalance` and `availableIckbBalance`: account balances projected with collected orders available. - `unavailableCkbBalance`: CKB pending in not-ready withdrawals. - `depositCapacity`: CKB required for one standard 100,000 iCKB deposit at the live exchange ratio. - `minCkbBalance`: shutdown threshold set to `21 / 20 * depositCapacity`. -The public pool scan uses the SDK L1 state snapshot and the default CCC cell-query page size unless callers pass `cellPageSize`. Rebalance decisions use the collected pool deposits from that snapshot. - -`nearReadyPoolDeposits` only ranks ready-window withdrawal choices. Fresh deposits are scored against `futurePoolDeposits`, not against the near-ready hour. +The public pool scan uses the SDK L1 state snapshot, the bot's configured pool lock-up window, and the default CCC cell-query page size unless callers pass `cellPageSize`. Ring coverage uses that whole `poolDeposits` snapshot. Ready deposits are only the withdrawal-candidate view; they do not create separate preservation anchors. ## Constants -- `CKB_RESERVE = 1000 CKB`: CKB left aside when creating a direct refill or future inventory. -- `MIN_ICKB_BALANCE = 2000 iCKB`: below this value, the bot prioritizes a direct deposit refill. -- `TARGET_ICKB_BALANCE = 120000 iCKB`: above this value, the bot may request ready withdrawals. -- `NEAR_READY_LOOKAHEAD_MS = 1 hour`: exclusive horizon used to compute ready-bucket refill tie-breaks. -- `READY_POOL_BUCKET_SPAN_MS = 15 minutes`: maturity bucket width for ready deposit selection. +- `CKB_RESERVE = 1000 CKB`: hard available-CKB floor for match allowance and the projected post-transaction reserve guard. CKB-consuming matches also keep the fixed fee headroom out of allowance so ordinary matching does not predictably build below reserve after fees. Direct-deposit gates require the same fixed fee headroom. There is no soft CKB reserve above it. +- `MATCH_STEP_DIVISOR = 100`: matcher allowance step is `depositCapacity / 100` in CKB, converted to iCKB for CKB-to-iCKB matching. - `MAX_WITHDRAWAL_REQUESTS = 30`: maximum deposits requested for withdrawal by one rebalance action. - `BEST_FIT_SEARCH_CANDIDATES = 30`: bounded top-ranked horizon for exact subset selection. @@ -45,90 +37,80 @@ One direct deposit or withdrawal request uses two output slots. The bot computes `planRebalance(...)` returns one of three actions: `none`, `deposit`, or `withdraw`. -1. If fewer than two output slots remain, return `none`. -2. If `ickbBalance < MIN_ICKB_BALANCE`, return one `deposit` only when `ckbBalance >= depositCapacity + CKB_RESERVE`; otherwise return `none`. -3. If future seeding gates pass, return one direct `deposit`. -4. Compute `excessIckb = ickbBalance - TARGET_ICKB_BALANCE`. -5. If `excessIckb <= 0`, return `none`. -6. Try at most one ready-only non-standard cleanup withdrawal. -7. Select ordinary ready deposits for withdrawal using the ready-window rules below. -8. If no withdrawal candidate satisfies the rules, return `none`. +1. Let order matching spend first: iCKB allowance is the full iCKB balance, and CKB allowance is `max(0, availableCkb - CKB_RESERVE - direct deposit fee headroom)`. +2. Derive useful post-match floors from `OrderManager.bestMatch(...)` diagnostics. +3. If fewer than two output slots remain after matching, return `none`. +4. If post-match iCKB is below the useful CKB-to-iCKB floor, return one same-transaction direct `deposit` when CKB can fund `directDepositCapacity + CKB_RESERVE + direct deposit fee headroom`. +5. If the current full-pool ring bucket is under-covered and the same creation gates pass, return one direct `deposit`. +6. If post-match CKB is below `CKB_RESERVE + useful UDT-to-CKB floor`, or iCKB refill is needed but cannot be funded, try reserve recovery withdrawal. +7. If iCKB refill is still needed after reserve recovery fails, return `none`. +8. If iCKB exceeds the useful withdrawal floor, select ready ring-surplus deposits for ordinary withdrawal. +9. If no candidate satisfies the ring rules, return `none`. Runtime transaction construction applies the chosen action after order matching. For `withdraw`, the withdrawal request is passed into `sdk.buildBaseTransaction(...)`. For `deposit`, `logic.deposit(...)` adds the fresh deposit. The bot completes iCKB UDT balance, CKB capacity, fees, and the DAO output-limit check through `sdk.completeTransaction(...)` before signing. -## Future Inventory +The final CKB reserve guard uses projected `availableCkbBalance + match.ckbDelta - rebalance costs - fee`. It blocks transactions that would end below `CKB_RESERVE`, except withdrawal requests with non-negative match CKB delta. Withdrawal requests spend CKB now to restore CKB later, including ordinary `excess_ickb_balance`; they must not hide an unrelated CKB-spending match below reserve. Pending CKB from withdrawal requests still is not liquid in the current loop. + +The direct-deposit fee headroom is a fixed prebuild margin. Exact fee remains a runtime completion concern because it depends on selected inputs, change, and witness size. -Future inventory actions use a fixed 180-epoch ring model around the coarse fresh-deposit target `tip.epoch.add([180, 0, 1]).toUnix(tip)`. This target is only a candidate region for a future deposit, not an exact post-inclusion maturity prediction. +## Ring Bucket Seeding + +Ring bucket seeding uses a fixed 180-epoch ring model over the configured live iCKB pool snapshot. The target segment is the segment containing `tip.epoch`; this is a policy bucket, not an exact post-inclusion maturity prediction. The ring model is: -- ring length: `tip.epoch.add([180, 0, 1]).toUnix(tip) - tip.epoch.toUnix(tip)` -- origin: absolute unix `0` modulo the ring length -- segment count: `2^(ceil(log2(futureDepositCount)))` -- segment index: `floor(((maturityUnix mod ringLength) * segmentCount) / ringLength)` +- ring length: `180` epochs +- origin: epoch `0` modulo the 180-epoch ring +- segment count: `2^(ceil(log2(poolDepositCount)))` +- segment index: `floor(((maturityEpoch mod 180 epochs) * segmentCount) / 180 epochs)` - segment density: `segmentUdtValue / segmentLength` -- average density: `totalFutureUdt / ringLength` +- average density: `totalPoolUdt / ringLength` -The target segment is under-covered when `targetDensity < 0.5 * averageDensity`. If total future `udtValue` is zero, density-based seeding does not run. +Because all segments are equal width, the implementation checks under-coverage as `2 * targetSegmentUdtValue * segmentCount < totalPoolUdt`. If total pool `udtValue` is zero, density-based seeding does not run. -Future seeding requires all future-inventory creation gates: +Ring seeding requires all creation gates: -- `ickbBalance > MIN_ICKB_BALANCE` -- `ckbBalance >= depositCapacity + CKB_RESERVE` -- `ickbBalance + ICKB_DEPOSIT_CAP <= TARGET_ICKB_BALANCE` +- `ickbBalance >= useful CKB-to-iCKB floor` +- `ckbBalance >= directDepositCapacity + CKB_RESERVE + direct deposit fee headroom` -Then the topology rules apply: +Then the current ring bucket rule applies: -- `0` future deposits: return one direct `deposit`. -- `1` future deposit: return `none`. -- `2` future deposits: seed only when both deposits land in the same `Q = 2` segment and the target segment is under-covered. -- `3+` future deposits: seed when the target segment is under-covered. +- `0` pool deposits: return one direct `deposit`. +- Non-empty pool: seed when the current target segment is under-covered. -Public future pool shape may veto or choose whether the already-budgeted direct deposit targets the first future segment policy path, but it cannot create any withdrawal request, same-transaction rotation, retry widening, or persistent state. This is the non-amplification invariant: public pool state is negative-only for removals. A known-code attacker can crowd, drain, dust, or stale-shape public future deposits, but those shapes can only block or admit the bot's independently budgeted direct deposit; they cannot make the bot remove future liquidity. +Public pool shape may admit or block direct ring seeding. If a seed is needed but `ckbBalance < directDepositCapacity + CKB_RESERVE + direct deposit fee headroom`, the bot does not use that ring need to withdraw. Public state still cannot create a future withdrawal, same-transaction rotation, retry widening, or persistent state. A known-code attacker can crowd, drain, dust, or stale-shape public deposits, but those shapes can only admit a direct deposit or block ring seeding. Far-future withdrawal, same-transaction future rotation, retry widening, and persistence are disabled. -## Non-Standard Cleanup +## Excess Withdrawals -Non-standard cleanup is a narrow ready-only withdrawal path for crowded-bucket extras whose iCKB value is larger than one standard deposit. It runs only after output slots and `excessIckb` are known and only when no deposit action has already been selected. +Ordinary excess withdrawal is independent of ring seeding. It runs only after deposit and reserve-recovery paths decline, and only when available iCKB is above the useful iCKB withdrawal floor. -The bot admits at most one cleanup candidate per rebalance. The candidate must come from `readyPoolDeposits`, be a withdrawable extra rather than a singleton or protected crowded anchor, have `deposit.udtValue > ICKB_DEPOSIT_CAP`, and leave `ickbBalance - deposit.udtValue >= TARGET_ICKB_BALANCE`. Cleanup also pins the protected anchor from the same ready bucket as a `cell_dep`; if that anchor is spent before inclusion, the cleanup transaction fails instead of consuming the extra as the new live anchor. +Normal candidates come only from `readyPoolDeposits` that are ring surplus in the configured live pool snapshot. Ring anchors for selected surplus deposits are passed as required live deposits. -The value-positive predicate is intentionally the implementation predicate from `@ickb/core`: iCKB value discounts only amounts above `ICKB_DEPOSIT_CAP`, so cleanup starts with `deposit.udtValue > ICKB_DEPOSIT_CAP`. Under-cap and cap-sized dust are ignored. +When ordinary excess withdrawal does not build, the policy-owned no-op reason distinguishes the cause: `no_ready_withdrawal_selection` for no ready withdrawal selection at all, `no_ring_surplus_ready_deposits` when ready deposits exist but all are ring anchors, and `ring_surplus_withdrawal_over_budget` when ring-surplus ready deposits exist but none fit the withdrawable iCKB budget. -Cleanup does not inspect `nearReadyPoolDeposits` or `futurePoolDeposits`, does not persist observations, does not widen retries, and does not couple a withdrawal to a same-transaction deposit. It classifies ready buckets without near-ready refill, so public near-ready state cannot steer cleanup. Pending CKB from the withdrawal is not treated as liquid CKB for future seeding until the normal send loop observes it in account state after chain processing. +Pending CKB from an excess withdrawal request is not treated as liquid until a later loop reads it from account state. -The `ickbBalance` used for cleanup is the post-match liquid iCKB passed to `planRebalance(...)`. Positive-gain matched orders are already selected before rebalancing and are treated as current transaction liquidity; public pool candidates still cannot enlarge the cleanup budget. +## Reserve Recovery -Cleanup is not a standard redeposit policy. The bot may later create standard deposits only through the ordinary deposit gates, in a later exclusive rebalance action. +Reserve recovery is bot-only anchor breaking. It runs when post-match CKB is below `CKB_RESERVE + useful UDT-to-CKB floor`, or when iCKB refill is needed but CKB cannot fund a direct deposit. -Attack assumption: a known-code attacker can add near-ready, future, under-cap, cap-sized, or over-cap public deposits. Only a ready over-cap extra that preserves the target liquid iCKB floor can be removed, and only one per loop. Public non-ready state cannot unlock cleanup, protected-anchor consumption, or same-transaction rotation. This is the cleanup non-amplification invariant. +The useful floor is derived from matcher diagnostics. It is a recovery trigger after matching has spent freely, not a soft reserve withheld from matching. -## Ready Withdrawals +This useful CKB floor is the urgency signal that permits reserve recovery to break ring anchors before the hard reserve is breached. Without that signal, anchors remain protected by normal withdrawal policy. -Ready withdrawals run only when `ickbBalance > TARGET_ICKB_BALANCE` and no deposit action has already been selected. +Once reserve recovery is triggered, the selector first tries ring surplus, then may choose any ready deposit within the available iCKB and output-slot caps. This may break ring anchors because restoring CKB matching capability takes priority once the bot is below the useful CKB floor or cannot fund the required iCKB refill. -The selector groups ready deposits into 15-minute maturity buckets. - -- A bucket with one ready deposit is a singleton anchor. -- A bucket with multiple ready deposits is crowded. -- In each crowded bucket, the protected deposit is the largest `udtValue` deposit. With equal values, the runtime keeps the latest deposit because ready deposits are sorted by maturity before selection. -- The other deposits in crowded buckets are withdrawable extras. -- Crowded buckets rank by withdrawable extra value first, then by near-ready refill in the following hour, then by earlier bucket. -- Singleton buckets rank by near-ready refill first, then by earlier bucket. - -Candidate selection calls `selectReadyDeposits(...)`, which compares a bounded best-fit search over the top 30 ranked candidates against a greedy scan over the full candidate list. The selected set is the higher-value valid subset under the amount and count limits. Ties keep the earlier candidate order. +## Ready Withdrawals -Singleton anchors are spendable only when `excessIckb >= ICKB_DEPOSIT_CAP`. +Ready withdrawals run only when no deposit action has already been selected. They are labeled `reserve_recovery` when they restore matching capability and `excess_ickb_balance` otherwise. -The ordinary ready withdrawal flow is: +The normal selector filters ready deposits through configured-pool ring surplus. A deposit is ring surplus when its ring segment still has an anchor after removing it. The chosen anchor for selected surplus is passed as a required live deposit, so stale inclusion fails instead of silently consuming the last live representative. -1. Try crowded-bucket extras under `excessIckb`. -2. If extras were selected and singleton consumption is unlocked, top up from singleton buckets with remaining amount and withdrawal slots. -3. If no extras were selected and singleton consumption is locked, try all non-singleton ready deposits. -4. If singleton consumption is unlocked, try singleton buckets, then all ready deposits. +Candidate selection calls `selectReadyWithdrawalDeposits(...)`, which compares a bounded best-fit search over the top 30 ranked candidates against a greedy scan over the full candidate list. The selected set is the higher-value valid subset under the amount and count limits. Ties keep the earlier candidate order. -When an ordinary withdrawal selects a crowded-bucket extra, the transaction also pins that bucket's protected deposit as a `cell_dep`. If the protected deposit is spent before inclusion, the withdrawal transaction fails instead of succeeding against stale bucket classification. This is only an inclusion-time liveness check: it does not reserve public protected deposits after the bot transaction commits, and it cannot stop a later same-block or later transaction from spending a public protected deposit. +Normal ready withdrawals never spend ring anchors. The only path that can break ring anchors is reserve recovery above. Withdrawal count is capped by `min(MAX_WITHDRAWAL_REQUESTS, floor(outputSlots / 2))`. @@ -140,11 +122,11 @@ The bot validates JSON `sleepIntervalSeconds` as a finite number of seconds grea The bot does not try to: -- globally optimize the full 180-epoch pool +- globally optimize the full 180-epoch pool outside the configured snapshot - predict the exact inclusion maturity of a pending fresh deposit - withdraw far-future deposits or rotate future sources in the same transaction as a fresh deposit -- create future inventory when reserve, minimum iCKB, target-band, or output-slot gates fail -- persist future-pool observations or retry-widen across loops -- treat pending CKB from cleanup withdrawals as liquid before account state reports it -- encode or publish a pool snapshot summary +- create ring inventory when the hard CKB reserve, useful iCKB floor, or output-slot gates fail +- persist ring observations or retry-widen across loops +- treat pending CKB from withdrawal requests as liquid before account state reports it +- publish a consensus or API pool snapshot; operator diagnostics may include compact pool summaries and content-addressed artifacts under `log/` - coordinate with other bots beyond the current visible chain state diff --git a/apps/bot/package.json b/apps/bot/package.json index f7ea2fe..146207a 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -1,5 +1,5 @@ { - "name": "@ickb/bot", + "name": "@ickb/bot-cli", "version": "1001.0.0", "description": "CCC-native iCKB order fulfillment and rebalance bot", "keywords": [ @@ -18,43 +18,26 @@ "bugs": { "url": "https://github.com/ickb/stack/issues" }, - "sideEffects": false, "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } + "private": true, + "engines": { + "node": ">=22.19.0" }, "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "pnpm clean && tsc -p tsconfig.build.json", - "lint": "eslint ./src", - "clean": "rm -fr dist", - "clean:deep": "rm -fr dist node_modules", - "start": "[ -n \"$BOT_CONFIG_FILE\" ] || { echo 'BOT_CONFIG_FILE not set' >&2; exit 1; }; bash -o pipefail -c 'node dist/index.js | tee \"log_$(date +%F_%H-%M-%S).json\"'", + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 apps/bot/src apps/bot/test", + "start": "[ -n \"$BOT_CONFIG_FILE\" ] || { echo 'BOT_CONFIG_FILE not set' >&2; exit 1; }; bash -o pipefail -c 'node src/index.ts | tee \"log_$(date +%F_%H-%M-%S).json\"'", "start:loop": "while true; do pnpm start; status=$?; [ \"$status\" -ne 0 ] || exit 0; [ \"$status\" -ne 2 ] || exit 2; sleep 10; done" }, - "files": [ - "docs", - "dist" - ], - "publishConfig": { - "access": "public", - "provenance": true - }, "devDependencies": { + "@ickb/testkit": "workspace:*", "@types/node": "catalog:" }, "dependencies": { "@ckb-ccc/core": "catalog:", - "@ickb/core": "workspace:*", + "@ickb/bot": "workspace:*", "@ickb/node-utils": "workspace:*", - "@ickb/order": "workspace:*", - "@ickb/sdk": "workspace:*", - "@ickb/utils": "workspace:*" + "@ickb/sdk": "workspace:*" } } diff --git a/apps/bot/src/index.test.ts b/apps/bot/src/index.test.ts deleted file mode 100644 index dfea5c3..0000000 --- a/apps/bot/src/index.test.ts +++ /dev/null @@ -1,868 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { type IckbDepositCell } from "@ickb/core"; -import { handleLoopError, logExecution } from "@ickb/node-utils"; -import { OrderManager } from "@ickb/order"; -import { type IckbSdk } from "@ickb/sdk"; -import { hash, headerLike, script } from "@ickb/testkit"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { CKB_RESERVE, POOL_MAX_LOCK_UP, POOL_MIN_LOCK_UP, TARGET_ICKB_BALANCE } from "./policy.js"; -import { - completeTerminalIteration, - isRetryableBotError, - iterationFailureEventFields, - readBotState, - readBotRuntimeConfig, - reachedMaxRetryableAttempts, -} from "./index.js"; -import { BotEventEmitter, transactionLifecycleEvents } from "./observability.js"; -import { buildTransaction } from "./runtime.js"; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -function readyDeposit( - byte: string, - udtValue: bigint, - maturityUnix: bigint, - options: { isReady?: boolean } = {}, -): IckbDepositCell { - return { - cell: ccc.Cell.from({ - outPoint: { txHash: hash(byte), index: 0n }, - cellOutput: { - capacity: 0n, - lock: script("22"), - }, - outputData: "0x", - }), - isReady: options.isReady ?? true, - udtValue, - maturity: { - toUnix: (): bigint => maturityUnix, - }, - } as unknown as IckbDepositCell; -} - -function botState(overrides: Record): Record { - return { - accountLocks: [], - capacityCells: [], - marketOrders: [], - availableCkbBalance: 0n, - availableIckbBalance: 0n, - unavailableCkbBalance: 0n, - totalCkbBalance: 0n, - depositCapacity: 100n, - minCkbBalance: 0n, - readyPoolDeposits: [], - nearReadyPoolDeposits: [], - futurePoolDeposits: [], - userOrders: [], - receipts: [], - readyWithdrawals: [], - notReadyWithdrawals: [], - system: { - feeRate: 1n, - exchangeRatio: { ckbScale: 1n, udtScale: 1n }, - tip: headerLike(), - }, - ...overrides, - }; -} - -function botRuntime(overrides: { - sdk?: Partial<{ - buildBaseTransaction: IckbSdk["buildBaseTransaction"]; - completeTransaction: IckbSdk["completeTransaction"]; - getL1AccountState: IckbSdk["getL1AccountState"]; - assertCurrentTip: IckbSdk["assertCurrentTip"]; - }>; - primaryLock?: ccc.Script; - managers?: Record; -} = {}): Record { - const client: ccc.Client = {}; - const signer: ccc.SignerCkbPrivateKey = { - getAddressObjs: () => Promise.resolve([]), - } as never; - - return { - client, - signer, - managers: { - order: { - addMatch: (txLike: ccc.TransactionLike): ccc.Transaction => - ccc.Transaction.from(txLike), - }, - logic: { - deposit: (txLike: ccc.TransactionLike): Promise => - Promise.resolve(ccc.Transaction.from(txLike)), - }, - ...(overrides.managers ?? {}), - }, - sdk: { - buildBaseTransaction: async ( - txLike: ccc.TransactionLike, - ): Promise => { - await Promise.resolve(); - return ccc.Transaction.from(txLike); - }, - completeTransaction: async ( - txLike: ccc.TransactionLike, - ): Promise => { - await Promise.resolve(); - return ccc.Transaction.from(txLike); - }, - getL1AccountState: async (): Promise => { - await Promise.resolve(); - return { - system: { - tip: headerLike(), - exchangeRatio: { ckbScale: 1n, udtScale: 1n }, - orderPool: [], - feeRate: 1n, - poolDeposits: { deposits: [], readyDeposits: [], id: "empty" }, - ckbAvailable: 0n, - ckbMaturing: [], - }, - user: { orders: [] }, - account: { - capacityCells: [], - nativeUdtCells: [], - nativeUdtCapacity: 0n, - nativeUdtBalance: 0n, - receipts: [], - withdrawalGroups: [], - }, - }; - }, - assertCurrentTip: async (): Promise => { - await Promise.resolve(); - }, - ...overrides.sdk, - }, - primaryLock: overrides.primaryLock ?? script("11"), - }; -} - -describe("readBotState", () => { - it("partitions SDK pool deposits without a second pool scan", async () => { - const tip = headerLike({ - number: 10n, - epoch: [0n, 0n, 1n], - timestamp: "0x0", - }); - const readyWindowEnd = POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip); - const ready = readyDeposit("33", 1n, 1n, { isReady: true }); - const tooEarly = readyDeposit("34", 2n, readyWindowEnd - 1n, { isReady: false }); - const nearReady = readyDeposit("35", 3n, readyWindowEnd + 1n, { isReady: false }); - const future = readyDeposit("36", 4n, readyWindowEnd + 60n * 60n * 1000n, { isReady: false }); - const getL1AccountState = vi.fn(); - getL1AccountState.mockResolvedValue({ - system: { - tip, - exchangeRatio: { ckbScale: 1n, udtScale: 1n }, - orderPool: [], - feeRate: 1n, - poolDeposits: { - deposits: [ready, tooEarly, nearReady, future], - readyDeposits: [ready], - id: "pool", - }, - ckbAvailable: 0n, - ckbMaturing: [], - }, - user: { orders: [] }, - account: { - capacityCells: [], - nativeUdtCells: [], - nativeUdtCapacity: 0n, - nativeUdtBalance: 0n, - receipts: [], - withdrawalGroups: [], - }, - }); - const assertCurrentTip = vi.fn(); - const findDeposits = vi.fn(async function* (): AsyncGenerator { - await Promise.resolve(); - yield* [] as IckbDepositCell[]; - }); - const runtime = botRuntime({ - sdk: { - getL1AccountState, - assertCurrentTip, - }, - managers: { - logic: { - findDeposits, - }, - }, - }); - - const state = await readBotState(runtime as never); - - expect(getL1AccountState).toHaveBeenCalledTimes(1); - expect(getL1AccountState.mock.calls[0]?.[2]).toMatchObject({ - poolDeposits: { - minLockUp: POOL_MIN_LOCK_UP, - maxLockUp: POOL_MAX_LOCK_UP, - }, - }); - expect(findDeposits).not.toHaveBeenCalled(); - expect(assertCurrentTip).not.toHaveBeenCalled(); - expect(state.readyPoolDeposits).toEqual([ready]); - expect(state.nearReadyPoolDeposits).toEqual([nearReady]); - expect(state.futurePoolDeposits).toEqual([future]); - }); - - it("fails closed when L1 account state omits the pool deposit snapshot", async () => { - const runtime = botRuntime({ - sdk: { - getL1AccountState: async (): Promise => { - await Promise.resolve(); - return { - system: { - tip: headerLike(), - exchangeRatio: { ckbScale: 1n, udtScale: 1n }, - orderPool: [], - feeRate: 1n, - ckbAvailable: 0n, - ckbMaturing: [], - }, - user: { orders: [] }, - account: { - capacityCells: [], - nativeUdtCells: [], - nativeUdtCapacity: 0n, - nativeUdtBalance: 0n, - receipts: [], - withdrawalGroups: [], - }, - }; - }, - }, - }); - - await expect(readBotState(runtime as never)).rejects.toThrow( - "L1 account state is missing pool deposit snapshot", - ); - }); -}); - -describe("completeTerminalIteration", () => { - it("counts only terminal iterations toward bounded runs", () => { - expect(completeTerminalIteration(0, 1)).toEqual({ - completedIterations: 1, - shouldStop: true, - }); - expect(completeTerminalIteration(0, 2)).toEqual({ - completedIterations: 1, - shouldStop: false, - }); - expect(completeTerminalIteration(999, undefined)).toEqual({ - completedIterations: 1000, - shouldStop: false, - }); - }); -}); - -describe("reachedMaxRetryableAttempts", () => { - it("uses a separate retryable-attempt budget", () => { - expect(reachedMaxRetryableAttempts(1, 1)).toBe(true); - expect(reachedMaxRetryableAttempts(1, 2)).toBe(false); - expect(reachedMaxRetryableAttempts(999, undefined)).toBe(false); - }); -}); - -describe("bot retryable iteration failures", () => { - it("treats transport and CKB state-race failures as retryable", () => { - expect(isRetryableBotError(new Error("L1 state scan crossed chain tip; retry with a fresh state"))).toBe(true); - expect(isRetryableBotError(new TypeError("fetch failed"))).toBe(true); - expect(isRetryableBotError(new Error("fetch failed", { cause: new TypeError("fetch failed") }))).toBe(true); - expect(isRetryableBotError(Object.assign(new Error("Client request error PoolRejectedRBF"), { - code: -1111, - data: "RBFRejected(\"Tx's current fee is 11795, expect it to >= 12326 to replace old txs\")", - }))).toBe(true); - expect(isRetryableBotError(Object.assign(new Error("Client request error TransactionFailedToResolve"), { - code: -301, - data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, - }))).toBe(true); - expect(isRetryableBotError({ - code: -301, - data: `Resolve(Dead(OutPoint(0x${"11".repeat(32)}00000000)))`, - })).toBe(true); - expect(isRetryableBotError(Object.assign(new Error("Client request error PoolRejectedDuplicatedTransaction"), { - code: -1107, - data: `Duplicated(Byte32(0x${"22".repeat(32)}))`, - }))).toBe(true); - expect(isRetryableBotError(new Error("fetch failed"))).toBe(false); - expect(isRetryableBotError({ code: -301, data: "Resolve(InvalidHeader(Byte32(0x...)))" })).toBe(false); - expect(isRetryableBotError(new Error("deterministic build failure"))).toBe(false); - }); - - it("emits retryability metadata from the same retry decision", () => { - expect(iterationFailureEventFields(new TypeError("fetch failed"))).toMatchObject({ - retryable: true, - terminal: false, - error: { name: "TypeError", message: "fetch failed" }, - }); - expect(iterationFailureEventFields(new TypeError("fetch failed")).error).not.toHaveProperty("stack"); - - const stateRaceFailure = iterationFailureEventFields(Object.assign(new Error("Client request error TransactionFailedToResolve"), { - code: -301, - data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, - })); - expect(stateRaceFailure).toMatchObject({ retryable: true, terminal: false }); - expect(stateRaceFailure.error).not.toHaveProperty("stack"); - - const exhaustedFailure = iterationFailureEventFields(new TypeError("fetch failed"), { - retryableAttempts: 3, - maxRetryableAttempts: 3, - }); - expect(exhaustedFailure).toMatchObject({ - retryable: true, - terminal: true, - retryableAttempts: 3, - maxRetryableAttempts: 3, - retryBudgetExhausted: true, - }); - expect(exhaustedFailure.error).not.toHaveProperty("stack"); - - expect(iterationFailureEventFields(new TypeError("fetch failed"), { - retryableAttempts: 3, - maxRetryableAttempts: undefined, - })).toMatchObject({ - retryable: true, - terminal: false, - retryableAttempts: 3, - retryBudgetExhausted: false, - }); - - const terminalFailure = iterationFailureEventFields(new Error("deterministic build failure")); - expect(terminalFailure).toMatchObject({ - retryable: false, - terminal: true, - error: { name: "Error", message: "deterministic build failure" }, - }); - expect(terminalFailure.error).toHaveProperty("stack"); - }); -}); - -describe("bot private key output boundary", () => { - it("does not leak the configured canary key across representative crash outputs", async () => { - const privateKey = `0x${"42".repeat(32)}`; - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-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: 60, - maxIterations: 1, - }), { mode: 0o600 }); - const runtimeConfig = await readBotRuntimeConfig({ BOT_CONFIG_FILE: configPath }); - const emitter = new BotEventEmitter({ - chain: runtimeConfig.chain, - runId: "run-canary-test", - write: (event): void => { - output.push(JSON.stringify(event)); - }, - }); - emitter.emit(0, "bot.run.started", { - runtime: { - maxIterations: runtimeConfig.maxIterations, - maxRetryableAttempts: runtimeConfig.maxRetryableAttempts, - bounded: runtimeConfig.maxIterations !== undefined, - sleepIntervalMs: runtimeConfig.sleepIntervalMs, - rpcConfigured: runtimeConfig.rpcUrl !== undefined, - }, - }); - emitter.emit(0, "bot.chain.preflight", { - rpcConfigured: runtimeConfig.rpcUrl !== undefined, - expected: { chain: "testnet", genesisHash: hash("11"), addressPrefix: "ckt" }, - observed: { - genesisHash: hash("11"), - addressPrefix: "ckt", - tip: { hash: hash("22"), number: 1n, timestamp: 2n }, - }, - matches: { genesisHash: true, addressPrefix: true }, - }); - const executionLog: Record = { startTime: "fixture" }; - handleLoopError(executionLog, new Error("deterministic crash")); - logExecution(executionLog, new Date()); - emitter.emit(1, "bot.iteration.failed", iterationFailureEventFields(new TypeError("fetch failed"))); - for (const lifecycle of transactionLifecycleEvents({ - type: "pre_broadcast_failed", - elapsedMs: 1, - error: new TypeError("fetch failed"), - }, isRetryableBotError)) { - emitter.emit(1, lifecycle.type, lifecycle.fields); - } - - const capturedCrashLog = output.join("\n"); - - expect(runtimeConfig.privateKey).toBe(privateKey); - expect(capturedCrashLog).not.toContain(privateKey); - } finally { - stdoutWrite.mockRestore(); - await rm(dir, { recursive: true, force: true }); - } - }); -}); - -describe("readBotRuntimeConfig", () => { - it("requires a JSON config file", async () => { - await expect(readBotRuntimeConfig({})).rejects.toThrow("Empty env BOT_CONFIG_FILE"); - }); - - it("reads JSON config files", async () => { - const privateKey = `0x${"11".repeat(32)}`; - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-config-")); - try { - const configPath = join(dir, "config.json"); - await writeFile(configPath, JSON.stringify({ - chain: "testnet", - privateKey, - rpcUrl: "http://127.0.0.1:8114/", - sleepIntervalSeconds: 60, - maxIterations: 1, - maxRetryableAttempts: 3, - }), { mode: 0o600 }); - - await expect(readBotRuntimeConfig({ BOT_CONFIG_FILE: configPath })).resolves.toEqual({ - chain: "testnet", - privateKey, - rpcUrl: "http://127.0.0.1:8114/", - sleepIntervalMs: 60000, - maxIterations: 1, - maxRetryableAttempts: 3, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it("reads JSON config files that omit custom RPC URLs", async () => { - const privateKey = `0x${"11".repeat(32)}`; - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-config-")); - try { - const configPath = join(dir, "config.json"); - await writeFile(configPath, JSON.stringify({ - chain: "testnet", - privateKey, - sleepIntervalSeconds: 60, - maxIterations: 1, - }), { mode: 0o600 }); - - await expect(readBotRuntimeConfig({ BOT_CONFIG_FILE: configPath })).resolves.toEqual({ - chain: "testnet", - privateKey, - rpcUrl: undefined, - sleepIntervalMs: 60000, - maxIterations: 1, - maxRetryableAttempts: undefined, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); - -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(CKB_RESERVE + 100n, 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, - managers: { - logic: { - deposit: async (txLike: ccc.TransactionLike): Promise => { - await Promise.resolve(); - return ccc.Transaction.from(txLike); - }, - }, - }, - 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: CKB_RESERVE + 100n, - availableIckbBalance: TARGET_ICKB_BALANCE, - totalCkbBalance: CKB_RESERVE + 100n, - }); - - const result = await buildTransaction(runtime as never, state as never); - - expect(result).toMatchObject({ - kind: "skipped", - reason: "post_tx_ckb_reserve", - decision: { - balances: { - spendableCkb: 100n, - }, - skip: { - reason: "post_tx_ckb_reserve", - reserve: ccc.fixedPointFrom(1000), - }, - }, - }); - if (result.kind !== "skipped" || result.decision.skip?.postTxCkbBalance === undefined || result.decision.skip.deficit === undefined) { - throw new Error("Expected reserve skip details"); - } - expect(result.decision.skip.deficit).toBe( - ccc.fixedPointFrom(1000) - result.decision.skip.postTxCkbBalance, - ); - }); - - it("allows CKB-replenishing transactions even when plain CKB remains below reserve", async () => { - const lock = script("11"); - 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.addOutput({ capacity: ccc.fixedPointFrom(500), lock }); - return tx; - }, - }, - }); - const state = botState({ - accountLocks: [lock], - capacityCells: [], - readyWithdrawals: [{}], - availableCkbBalance: ccc.fixedPointFrom(2000), - availableIckbBalance: TARGET_ICKB_BALANCE, - totalCkbBalance: ccc.fixedPointFrom(2000), - }); - - const result = await buildTransaction(runtime as never, state as never); - - expect(result).toMatchObject({ - kind: "built", - actions: { withdrawals: 1 }, - }); - expect(result.decision.skip).toBeUndefined(); - }); - - it("allows withdrawal requests to spend reserve CKB for recovery state rent", async () => { - const lock = script("11"); - const rent = capacityCell(100n, lock, "80"); - vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ - ckbDelta: 0n, - 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: rent.outPoint })); - return tx; - }, - }, - }); - const first = readyDeposit("79", 4n, 20n * 60n * 1000n); - const protectedAnchor = readyDeposit("7a", 6n, 25n * 60n * 1000n); - const third = readyDeposit("7b", 5n, 40n * 60n * 1000n); - const state = botState({ - accountLocks: [lock], - capacityCells: [rent], - availableCkbBalance: 0n, - availableIckbBalance: TARGET_ICKB_BALANCE + 9n, - totalCkbBalance: 0n, - depositCapacity: 1000n, - readyPoolDeposits: [first, protectedAnchor, third], - }); - - const result = await buildTransaction(runtime as never, state as never); - - expect(result).toMatchObject({ - kind: "built", - actions: { withdrawalRequests: 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, - udtDelta: 0n, - partials: [{} as never], - }); - vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); - - 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, - }); - - await expect(buildTransaction(runtime as never, state as never)).resolves.toMatchObject({ - kind: "skipped", - reason: "match_value_not_above_fee", - decision: { - skip: { - reason: "match_value_not_above_fee", - fee: 1n, - matchValue: 1n, - }, - }, - }); - }); - - it("uses the repo exchange-ratio scale when checking match-only profitability", async () => { - vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ - ckbDelta: -2n, - udtDelta: 2n, - partials: [{} as never], - }); - vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); - - 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, - exchangeRatio: { ckbScale: 3n, udtScale: 5n }, - tip: headerLike(), - }, - }); - - await expect(buildTransaction(runtime as never, state as never)).resolves.toMatchObject({ - kind: "built", - actions: { matchedOrders: 1 }, - }); - }); - - it("labels built match and deposit-rebalance decisions", async () => { - vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ - ckbDelta: 0n, - udtDelta: 1n, - partials: [{} as never], - }); - vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); - - const lock = script("11"); - const runtime = botRuntime({ primaryLock: lock }); - const state = botState({ - accountLocks: [lock], - capacityCells: [capacityCell(ccc.fixedPointFrom(2000), lock, "69")], - marketOrders: [{}], - availableCkbBalance: ccc.fixedPointFrom(2000), - availableIckbBalance: 0n, - depositCapacity: 100n, - totalCkbBalance: ccc.fixedPointFrom(2000), - }); - - await expect(buildTransaction(runtime as never, state as never)).resolves.toMatchObject({ - kind: "built", - decision: { - match: { reason: "matched" }, - rebalance: { kind: "deposit", reason: "low_ickb_balance" }, - }, - }); - }); - - it("returns a structured no-action skip with rebalance reason", async () => { - vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ - ckbDelta: 0n, - udtDelta: 0n, - partials: [], - diagnostics: { - orderCount: 1, - allowance: { ckbValue: 0n, udtValue: 0n }, - ckbAllowanceStep: 1n, - udtAllowanceStep: 1n, - ckbMiningFee: 1n, - directions: { - ckbToUdt: { matchableCount: 0 }, - udtToCkb: { matchableCount: 1, minAllowance: 1n, maxMatch: 2n }, - }, - candidates: { - total: 2, - viable: 1, - positiveGain: 0, - rejected: { - maxPartials: 0, - duplicateOrder: 0, - insufficientCkbAllowance: 1, - insufficientUdtAllowance: 0, - nonPositiveGain: 1, - }, - bestGain: 0n, - }, - }, - }); - - const completeTransaction = vi.fn(); - const runtime = botRuntime({ sdk: { completeTransaction } }); - const state = botState({ - marketOrders: [{}], - availableCkbBalance: 0n, - availableIckbBalance: TARGET_ICKB_BALANCE, - }); - - await expect(buildTransaction(runtime as never, state as never)).resolves.toMatchObject({ - kind: "skipped", - reason: "no_actions", - decision: { - rebalance: { kind: "none", reason: "target_ickb_not_exceeded" }, - match: { - reason: "no_positive_gain", - diagnostics: { - orderCount: 1, - directions: { - udtToCkb: { matchableCount: 1, minAllowance: 1n, maxMatch: 2n }, - }, - candidates: { - rejected: { - insufficientCkbAllowance: 1, - nonPositiveGain: 1, - }, - }, - }, - }, - actions: { - collectedOrders: 0, - completedDeposits: 0, - matchedOrders: 0, - deposits: 0, - withdrawalRequests: 0, - withdrawals: 0, - }, - skip: { reason: "no_actions" }, - }, - }); - expect(completeTransaction).not.toHaveBeenCalled(); - }); - - it("passes required live deposits to SDK base transaction construction", async () => { - vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ - ckbDelta: 0n, - udtDelta: 0n, - partials: [], - }); - - const first = readyDeposit("11", 4n, 20n * 60n * 1000n); - const protectedAnchor = readyDeposit("12", 6n, 25n * 60n * 1000n); - const third = readyDeposit("13", 5n, 40n * 60n * 1000n); - const calls: string[] = []; - const buildBaseTransaction = vi.fn(); - buildBaseTransaction.mockImplementation( - async (txLike: ccc.TransactionLike): Promise => { - calls.push("base"); - await Promise.resolve(); - return ccc.Transaction.from(txLike); - }, - ); - const completeTransaction = vi.fn( - async (txLike: ccc.TransactionLike): Promise => { - calls.push("complete"); - await Promise.resolve(); - expect(calls).toEqual(["base", "complete"]); - const tx = ccc.Transaction.from(txLike); - expect(tx.cellDeps).toEqual([]); - return tx; - }, - ); - const runtime = botRuntime({ - sdk: { buildBaseTransaction, completeTransaction }, - 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, - readyPoolDeposits: [first, protectedAnchor, third], - }); - - const result = await buildTransaction(runtime as never, state as never); - - expect(result.kind).toBe("built"); - if (result.kind !== "built") { - throw new Error("Expected built transaction"); - } - expect(result.actions.withdrawalRequests).toBe(1); - expect(buildBaseTransaction.mock.calls[0]?.[2]).toMatchObject({ - withdrawalRequest: { - deposits: [first], - requiredLiveDeposits: [protectedAnchor], - }, - }); - expect(completeTransaction).toHaveBeenCalledTimes(1); - expect(calls).toEqual(["base", "complete"]); - expect(result.tx.cellDeps).toEqual([]); - }); -}); - -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 53a6a70..02d6457 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,54 +1,77 @@ import { ccc } from "@ckb-ccc/core"; -import { pathToFileURL } from "node:url"; -import { ICKB_DEPOSIT_CAP, convert } from "@ickb/core"; -import { - getConfig, - IckbSdk, - projectAccountAvailability, - sendAndWaitForCommit, -} from "@ickb/sdk"; -import { - createPublicClient, - formatCkb, - handleLoopError, - isRetryableCkbStateRaceError, - isRetryableRpcTransportError, - logExecution, - randomSleepIntervalMs, - readRuntimeConfigEnv, - reachedMaxIterations, - signerAccountLocks, - sleep, - STOP_EXIT_CODE, - type RuntimeConfig, - verifyChainPreflight, -} from "@ickb/node-utils"; -import { - buildTransaction, - summarizeBotState, - type BotState, - type Runtime, -} from "./runtime.js"; -import { - partitionBotPoolDeposits, - POOL_MAX_LOCK_UP, - POOL_MIN_LOCK_UP, -} from "./policy.js"; import { BotEventEmitter, createRunId, - emitDecisionEvents, - errorSummary, - lowCapitalSkipDecision, - transactionLifecycleEvents, - transactionSummary, -} from "./observability.js"; + readBotRuntimeConfig, + runBotLoop, + type BotLoopContext, + type Runtime, +} from "@ickb/bot"; +import { createPublicClient, verifyChainPreflight } from "@ickb/node-utils"; +import { getConfig, IckbSdk } from "@ickb/sdk"; +import { pathToFileURL } from "node:url"; + +type BotRuntimeConfig = Awaited>; +type IckbConfig = ReturnType; + +export interface BotCliDependencies { + createEvents: (context: { + artifactRefPrefix?: string; + artifactRoot?: string; + chain: BotRuntimeConfig["chain"]; + runId: string; + }) => BotEventEmitter; + createPublicClient: typeof createPublicClient; + createRunId: typeof createRunId; + createSdk: (config: IckbConfig) => IckbSdk; + getConfig: typeof getConfig; + readBotRuntimeConfig: typeof readBotRuntimeConfig; + runBotLoop: typeof runBotLoop; + verifyChainPreflight: typeof verifyChainPreflight; +} + +const defaultDependencies: BotCliDependencies = { + createEvents: (context) => new BotEventEmitter(context), + createPublicClient, + createRunId, + createSdk: (config) => IckbSdk.fromConfig(config), + getConfig, + readBotRuntimeConfig, + runBotLoop, + verifyChainPreflight, +}; + +export async function runBotCli( + env: NodeJS.ProcessEnv = process.env, + dependencies: Partial = {}, +): Promise { + const resolved = { ...defaultDependencies, ...dependencies }; + const context = await initializeBot(env, resolved); + await resolved.runBotLoop(context); +} -async function main(): Promise { - const runtimeConfig = await readBotRuntimeConfig(process.env); - const { chain, privateKey, rpcUrl, sleepIntervalMs, maxIterations, maxRetryableAttempts } = runtimeConfig; - const runId = createRunId(); - const events = new BotEventEmitter({ chain, runId }); +export async function initializeBot( + env: NodeJS.ProcessEnv, + dependencies: BotCliDependencies = defaultDependencies, +): Promise { + const runtimeConfig = await dependencies.readBotRuntimeConfig(env); + const { + chain, + privateKey, + rpcUrl, + sleepIntervalMs, + maxIterations, + maxRetryableAttempts, + } = runtimeConfig; + const runId = dependencies.createRunId(); + const artifactRoot = env["BOT_ARTIFACT_ROOT"]; + const artifactRefPrefix = env["BOT_ARTIFACT_REF_PREFIX"]; + const events = dependencies.createEvents({ + chain, + runId, + ...(artifactRoot === undefined ? {} : { artifactRoot }), + ...(artifactRefPrefix === undefined ? {} : { artifactRefPrefix }), + }); events.emit(0, "bot.run.started", { maxIterations, bounded: maxIterations !== undefined, @@ -60,16 +83,20 @@ async function main(): Promise { rpcConfigured: rpcUrl !== undefined, }, }); - const client = createPublicClient(chain, rpcUrl); - const preflight = await verifyChainPreflight(client, chain); + const client = dependencies.createPublicClient(chain, rpcUrl); + const preflight = await dependencies.verifyChainPreflight(client, chain); events.emit(0, "bot.chain.preflight", { rpcConfigured: rpcUrl !== undefined, expected: preflight.expected, observed: preflight.observed, matches: preflight.matches, }); - const config = getConfig(chain); + const config = dependencies.getConfig(chain); const { managers } = config; + // BEFORE EDITING, STOP AND PROVE, LOCAL SAFETY IS NOT ENOUGH: + // - OWNER: secret purpose boundary. + // - INVARIANT: private keys pass only to signer construction and signing. + // - FAILURE MODE: passing keys to logs, errors, telemetry, redaction, masking, or test hooks leaks signing authority. const signer = new ccc.SignerCkbPrivateKey(client, privateKey); const recommendedAddress = await signer.getRecommendedAddressObj(); const primaryLock = recommendedAddress.script; @@ -77,283 +104,25 @@ async function main(): Promise { chain, client, signer, - sdk: IckbSdk.fromConfig(config), + sdk: dependencies.createSdk(config), managers, primaryLock, }; - let stopAfterLog = false; - let completedIterations = 0; - let retryableAttempts = 0; - let iterationId = 0; - for (;;) { - const executionLog: Record = {}; - const startTime = new Date(); - let stopAfterIteration = false; - let retryableAttempt = false; - executionLog.startTime = startTime.toLocaleString(); - iterationId += 1; - events.emit(iterationId, "bot.iteration.started"); - - try { - const state = await readBotState(runtime); - const stateDecision = summarizeBotState(state); - events.emit(iterationId, "bot.state.read", { - chainTip: stateDecision.chainTip, - balances: stateDecision.balances, - orders: stateDecision.orders, - withdrawals: stateDecision.withdrawals, - poolDeposits: stateDecision.poolDeposits, - exchangeRatio: stateDecision.exchangeRatio, - depositCapacity: stateDecision.depositCapacity, - fee: stateDecision.fee, - }); - - executionLog.balance = { - CKB: { - total: fmtCkb(state.totalCkbBalance), - available: fmtCkb(state.availableCkbBalance), - unavailable: fmtCkb(state.unavailableCkbBalance), - }, - ICKB: { - total: fmtCkb(state.availableIckbBalance), - available: fmtCkb(state.availableIckbBalance), - unavailable: fmtCkb(0n), - }, - totalEquivalent: { - CKB: fmtCkb(stateDecision.balances.totalEquivalentCkb), - ICKB: fmtCkb(stateDecision.balances.totalEquivalentIckb), - }, - }; - executionLog.ratio = state.system.exchangeRatio; - - if (stateDecision.balances.totalEquivalentCkb <= state.minCkbBalance) { - const skip = lowCapitalSkipDecision(stateDecision); - events.emit(iterationId, "bot.decision.skipped", skip); - executionLog.error = - "The bot must have more than " + - fmtCkb(state.minCkbBalance) + - " CKB worth of capital to be able to operate, shutting down..."; - process.exitCode = STOP_EXIT_CODE; - logExecution(executionLog, startTime); - return; - } - const result = await buildTransaction(runtime, state); - emitDecisionEvents(events, iterationId, result); - if (result.kind === "skipped") { - executionLog.actions = result.actions; - } else { - executionLog.actions = result.actions; - const fee = result.tx.estimateFee(state.system.feeRate); - executionLog.txFee = { - fee: fmtCkb(fee), - feeRate: state.system.feeRate, - }; - executionLog.txHash = await sendAndWaitForCommit(runtime, result.tx, { - onSent: (txHash) => { - executionLog.txHash = txHash; - }, - onLifecycle: (event) => { - for (const lifecycle of transactionLifecycleEvents(event, isRetryableBotError)) { - events.emit(iterationId, lifecycle.type, { - ...lifecycle.fields, - ...(event.type === "broadcasted" - ? { transaction: transactionSummary(result.tx, fee, state.system.feeRate) } - : {}), - }); - } - }, - }); - } - } catch (error) { - const retryable = isRetryableBotError(error); - if (retryable) { - retryableAttempt = true; - retryableAttempts += 1; - } - const failure = retryable - ? iterationFailureEventFields(error, { - retryableAttempts, - maxRetryableAttempts, - }) - : iterationFailureEventFields(error); - events.emit(iterationId, "bot.iteration.failed", failure); - if (failure.retryable) { - executionLog.error = failure.error; - if (failure.retryBudgetExhausted) { - executionLog.error = { - message: "Retryable bot error budget exhausted", - attempts: retryableAttempts, - maxRetryableAttempts, - lastError: failure.error, - }; - process.exitCode = STOP_EXIT_CODE; - stopAfterLog = true; - } - } else { - stopAfterLog = handleLoopError(executionLog, error); - } - } - - if (!retryableAttempt) { - retryableAttempts = 0; - const completion = completeTerminalIteration(completedIterations, maxIterations); - completedIterations = completion.completedIterations; - stopAfterIteration = completion.shouldStop; - } - - logExecution(executionLog, startTime); - if (stopAfterLog) { - return; - } - if (stopAfterIteration) { - return; - } - await sleep(randomSleepIntervalMs(sleepIntervalMs)); - } -} - -export async function readBotRuntimeConfig(env: NodeJS.ProcessEnv): Promise { - return readRuntimeConfigEnv(env.BOT_CONFIG_FILE, "BOT_CONFIG_FILE"); -} - -export function completeTerminalIteration( - completedIterations: number, - maxIterations: number | undefined, -): { completedIterations: number; shouldStop: boolean } { - const nextCompletedIterations = completedIterations + 1; - return { - completedIterations: nextCompletedIterations, - shouldStop: reachedMaxIterations(nextCompletedIterations, maxIterations), - }; -} - -export function reachedMaxRetryableAttempts( - retryableAttempts: number, - maxRetryableAttempts: number | undefined, -): boolean { - return maxRetryableAttempts !== undefined && retryableAttempts >= maxRetryableAttempts; -} - -export function iterationFailureEventFields(error: unknown): { - error: Record | string; - retryable: boolean; - terminal: boolean; - retryableAttempts?: number; - maxRetryableAttempts?: number; - retryBudgetExhausted?: boolean; -}; -export function iterationFailureEventFields( - error: unknown, - retryBudget: { retryableAttempts: number; maxRetryableAttempts: number | undefined }, -): { - error: Record | string; - retryable: boolean; - terminal: boolean; - retryableAttempts: number; - maxRetryableAttempts?: number; - retryBudgetExhausted: boolean; -}; -export function iterationFailureEventFields( - error: unknown, - retryBudget?: { retryableAttempts: number; maxRetryableAttempts: number | undefined }, -): { - error: Record | string; - retryable: boolean; - terminal: boolean; - retryableAttempts?: number; - maxRetryableAttempts?: number; - retryBudgetExhausted?: boolean; -} { - const retryable = isRetryableBotError(error); - const retryBudgetExhausted = retryable && retryBudget !== undefined && - reachedMaxRetryableAttempts( - retryBudget.retryableAttempts, - retryBudget.maxRetryableAttempts, - ); return { - error: errorSummary(error, { includeStack: !retryable }), - retryable, - terminal: !retryable || retryBudgetExhausted, - ...(retryBudget === undefined ? {} : { - retryableAttempts: retryBudget.retryableAttempts, - ...(retryBudget.maxRetryableAttempts === undefined ? {} : { maxRetryableAttempts: retryBudget.maxRetryableAttempts }), - retryBudgetExhausted, - }), - }; -} - -export async function readBotState(runtime: Runtime): Promise { - const accountLocks = await signerAccountLocks(runtime.signer, runtime.primaryLock); - const { system, user, account } = await runtime.sdk.getL1AccountState( - runtime.client, - accountLocks, - { - poolDeposits: { - minLockUp: POOL_MIN_LOCK_UP, - maxLockUp: POOL_MAX_LOCK_UP, - }, - }, - ); - if (!system.poolDeposits) { - throw new Error("L1 account state is missing pool deposit snapshot"); - } - const poolDeposits = partitionBotPoolDeposits( - system.poolDeposits.deposits, - system.tip, - ); - - const projection = projectAccountAvailability(account, user.orders, { - collectedOrdersAvailable: true, - }); - const ownedOrderKeys = new Set( - user.orders.map((group) => outPointKey(group.order.cell.outPoint)), - ); - const marketOrders = system.orderPool.filter( - (order) => !ownedOrderKeys.has(outPointKey(order.cell.outPoint)), - ); - - const availableCkbBalance = projection.ckbAvailable; - const availableIckbBalance = projection.ickbAvailable; - const unavailableCkbBalance = projection.ckbPending; - const totalCkbBalance = availableCkbBalance + unavailableCkbBalance; - const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, system.exchangeRatio); - - return { - accountLocks, - capacityCells: account.capacityCells, - system, - userOrders: user.orders, - marketOrders, - receipts: account.receipts, - readyWithdrawals: projection.readyWithdrawals, - notReadyWithdrawals: projection.pendingWithdrawals, - readyPoolDeposits: poolDeposits.ready, - nearReadyPoolDeposits: poolDeposits.nearReady, - futurePoolDeposits: poolDeposits.future, - availableCkbBalance, - availableIckbBalance, - unavailableCkbBalance, - totalCkbBalance, - depositCapacity, - minCkbBalance: (21n * depositCapacity) / 20n, + events, + runtime, + sleepIntervalMs, + maxIterations, + maxRetryableAttempts, }; } -function outPointKey(outPoint: ccc.OutPoint): string { - return ccc.hexFrom(outPoint.toBytes()); -} - -const fmtCkb = formatCkb; - -export function isRetryableBotError(error: unknown): boolean { - return (error instanceof Error && ( - error.message === "L1 state scan crossed chain tip; retry with a fresh state" || - isRetryableRpcTransportError(error) - )) || isRetryableCkbStateRaceError(error); +function exitProcess(code: NodeJS.Process["exitCode"]): never { + process.exit(Number(code)); } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); - process.exit(process.exitCode ?? 0); +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + await runBotCli(); + exitProcess(process.exitCode ?? 0); } diff --git a/apps/bot/src/observability.test.ts b/apps/bot/src/observability.test.ts deleted file mode 100644 index 2ec0c3d..0000000 --- a/apps/bot/src/observability.test.ts +++ /dev/null @@ -1,693 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { describe, expect, it } from "vitest"; -import { - BotEventEmitter, - emitDecisionEvents, - errorSummary, - lowCapitalSkipDecision, - transactionSummary, - transactionLifecycleEvents, -} from "./observability.js"; -import { - type BotActions, - type BotDecisionTranscript, - type BuildTransactionResult, -} from "./runtime.js"; - -const noActions: BotActions = { - collectedOrders: 0, - completedDeposits: 0, - matchedOrders: 0, - deposits: 0, - withdrawalRequests: 0, - withdrawals: 0, -}; - -describe("bot observability", () => { - it("keeps stack traces by default but can summarize retryable errors", () => { - const error = new Error("scan raced chain tip"); - const summary = errorSummary(error); - - expect(summary).toMatchObject({ - name: "Error", - message: "scan raced chain tip", - }); - expect(typeof summary).toBe("object"); - expect(summary).not.toBeNull(); - expect((summary as Record)["stack"]).toContain("scan raced chain tip"); - expect(errorSummary(error, { includeStack: false })).toEqual({ - name: "Error", - message: "scan raced chain tip", - }); - }); - - it("preserves public CKB RPC error fields from Error objects", () => { - const rbfError = Object.assign(new Error("Client request error PoolRejectedRBF"), { - code: -1111, - data: "RBFRejected(\"Tx's current fee is 11795, expect it to >= 12326 to replace old txs\")", - currentFee: 11795n, - leastFee: 12326n, - }); - const resolveError = Object.assign(new Error("Client request error TransactionFailedToResolve"), { - code: -301, - data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, - outPoint: { - txHash: `0x${"11".repeat(32)}`, - index: 0n, - }, - }); - - expect(errorSummary(rbfError, { includeStack: false })).toEqual({ - name: "Error", - message: "Client request error PoolRejectedRBF", - code: -1111, - data: "RBFRejected(\"Tx's current fee is 11795, expect it to >= 12326 to replace old txs\")", - currentFee: "11795", - leastFee: "12326", - }); - expect(errorSummary(resolveError, { includeStack: false })).toEqual({ - name: "Error", - message: "Client request error TransactionFailedToResolve", - code: -301, - data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, - outPoint: { - txHash: `0x${"11".repeat(32)}`, - index: "0", - }, - }); - }); - - it("emits one structured JSON-compatible event", () => { - const written: unknown[] = []; - const emitter = new BotEventEmitter({ - chain: "testnet", - runId: "run-1", - write: (event): void => { - written.push(event); - }, - }); - - const event = emitter.emit(7, "bot.decision.skipped", { - reason: "no_actions", - amount: 9007199254740993n, - witnesses: ["0x" + "11".repeat(80)], - witness: "0x" + "22".repeat(80), - output_data: "0x" + "33".repeat(80), - transactionShape: { witnesses: 1 }, - txHash: `0x${"44".repeat(32)}`, - tx: { inputs: [], outputs: [], witnesses: [] }, - lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" }, - cell: { cellOutput: { lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" } } }, - env: "testnet", - environment: { BOT_CONFIG_FILE: "/run/credentials/config.json" }, - config: { chain: "testnet" }, - }); - - expect(written).toHaveLength(1); - expect(event).toBe(written[0]); - expect(written[0]).toMatchObject({ - version: 1, - app: "bot", - chain: "testnet", - runId: "run-1", - iterationId: 7, - type: "bot.decision.skipped", - reason: "no_actions", - amount: "9007199254740993", - witnesses: ["0x" + "11".repeat(80)], - witness: "0x" + "22".repeat(80), - output_data: "0x" + "33".repeat(80), - transactionShape: { witnesses: 1 }, - tx: { inputs: [], outputs: [], witnesses: [] }, - lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" }, - cell: { cellOutput: { lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" } } }, - env: "testnet", - environment: { BOT_CONFIG_FILE: "/run/credentials/config.json" }, - config: { chain: "testnet" }, - }); - expect((written[0] as Record).txHash).toBe(`0x${"44".repeat(32)}`); - expect(typeof (written[0] as { timestamp: unknown }).timestamp).toBe("string"); - }); - - it("emits functions and circular values as JSON-safe fields", () => { - const written: unknown[] = []; - const emitter = new BotEventEmitter({ - chain: "testnet", - runId: "run-1", - write: (event): void => { - written.push(event); - }, - }); - const circular: Record = { label: "root" }; - circular.self = circular; - - emitter.emit(7, "bot.decision.skipped", { - evidence: { - toJSON: (): Record => ({ ignored: "custom serializer" }), - circular, - observedAt: new Date("2026-01-02T03:04:05.006Z"), - invalidAt: new Date(Number.NaN), - }, - }); - - expect(written[0]).toMatchObject({ - evidence: { - toJSON: "[Unsupported log value]", - circular: { label: "root", self: "[Circular]" }, - observedAt: "2026-01-02T03:04:05.006Z", - invalidAt: null, - }, - }); - }); - - it("maps terminal lifecycle callbacks into confirmation and failure events", () => { - expect(transactionLifecycleEvents({ - type: "timeout_after_broadcast", - txHash: "0x01", - status: "unknown", - checks: 3, - elapsedMs: 20, - })).toEqual([ - { - type: "bot.transaction.confirmation", - fields: { - txHash: "0x01", - phase: "confirmation", - status: "unknown", - checks: 3, - elapsedMs: 20, - outcome: "timeout_after_broadcast", - retryable: false, - terminal: true, - }, - }, - { - type: "bot.transaction.failed", - fields: { - txHash: "0x01", - phase: "confirmation", - status: "unknown", - checks: 3, - elapsedMs: 20, - outcome: "timeout_after_broadcast", - retryable: false, - terminal: true, - }, - }, - ]); - }); - - it("summarizes transaction shape with the decision shape fields", () => { - const tx = { - inputs: [{}, {}], - outputs: [{}], - cellDeps: [{}, {}, {}], - headerDeps: [{}], - witnesses: [{}, {}], - } as unknown as ccc.Transaction; - - expect(transactionSummary(tx, 4n, 5n)).toEqual({ - fee: 4n, - feeRate: 5n, - shape: { - inputs: 2, - outputs: 1, - cellDeps: 3, - headerDeps: 1, - witnesses: 2, - }, - }); - }); - - it("preserves public chain tip block metadata", () => { - const written: unknown[] = []; - const emitter = new BotEventEmitter({ - chain: "testnet", - runId: "run-1", - write: (event): void => { - written.push(event); - }, - }); - const blockHash = `0x${"11".repeat(32)}`; - - emitter.emit(1, "bot.state.read", { - chainTip: { - blockNumber: 123n, - blockHash, - timestamp: 456n, - }, - }); - - expect(written[0]).toMatchObject({ - chainTip: { - blockNumber: "123", - blockHash, - timestamp: "456", - }, - }); - }); - - it("keeps stable event identity when payload fields collide", () => { - const written: unknown[] = []; - const emitter = new BotEventEmitter({ - chain: "testnet", - runId: "run-1", - write: (event): void => { - written.push(event); - }, - }); - - emitter.emit(7, "bot.decision.skipped", { - version: 999, - app: "wrong", - chain: "mainnet", - runId: "wrong", - iterationId: 999, - timestamp: "not-iso", - type: "wrong", - }); - - expect(written[0]).toMatchObject({ - version: 1, - app: "bot", - chain: "testnet", - runId: "run-1", - iterationId: 7, - type: "bot.decision.skipped", - }); - expect((written[0] as { timestamp: string }).timestamp).not.toBe("not-iso"); - }); - - it("emits public chain preflight evidence", () => { - const written: unknown[] = []; - const emitter = new BotEventEmitter({ - chain: "testnet", - runId: "run-1", - write: (event): void => { - written.push(event); - }, - }); - const genesisHash = `0x${"11".repeat(32)}`; - const tipHash = `0x${"22".repeat(32)}`; - - emitter.emit(0, "bot.chain.preflight", { - rpcConfigured: true, - expected: { - chain: "testnet", - networkName: "ckb_testnet", - genesisHash, - genesisMessage: "aggron-v4", - genesisSource: "test fixture", - addressPrefix: "ckt", - }, - observed: { - genesisHash, - addressPrefix: "ckt", - tip: { - hash: tipHash, - number: 123n, - timestamp: 456n, - }, - }, - matches: { - genesisHash: true, - addressPrefix: true, - }, - }); - - expect(written[0]).toMatchObject({ - type: "bot.chain.preflight", - rpcConfigured: true, - expected: { chain: "testnet", genesisHash, addressPrefix: "ckt" }, - observed: { - genesisHash, - addressPrefix: "ckt", - tip: { hash: tipHash, number: "123", timestamp: "456" }, - }, - matches: { genesisHash: true, addressPrefix: true }, - }); - }); - - it("emits run context without private key material", () => { - const written: unknown[] = []; - const emitter = new BotEventEmitter({ - chain: "testnet", - runId: "run-1", - write: (event): void => { - written.push(event); - }, - }); - - emitter.emit(0, "bot.run.started", { - maxIterations: 1, - bounded: true, - runtime: { - maxIterations: 1, - bounded: true, - sleepIntervalMs: 60000, - rpcConfigured: true, - }, - config: { chain: "testnet" }, - rpcHost: "testnet.example", - }); - - expect(written[0]).toMatchObject({ - type: "bot.run.started", - maxIterations: 1, - bounded: true, - runtime: { - maxIterations: 1, - bounded: true, - sleepIntervalMs: 60000, - rpcConfigured: true, - }, - config: { chain: "testnet" }, - rpcHost: "testnet.example", - }); - }); - - it("preserves transaction-shaped error messages in structured error summaries", () => { - const error = new Error("failed with witness 0x" + "22".repeat(80)); - - const summary = errorSummary(error) as Record; - - expect(summary.name).toBe("Error"); - expect(summary.message).toBe("failed with witness 0x" + "22".repeat(80)); - expect(summary.stack).toContain("failed with witness"); - }); - - it("preserves serialized transaction-shaped error messages", () => { - const error = new Error(`failed {"witnesses":["0x${"22".repeat(80)}"],"inputs":[]}`); - - const summary = errorSummary(error) as Record; - - expect(summary.message).toBe(`failed {"witnesses":["0x${"22".repeat(80)}"],"inputs":[]}`); - }); - - it("preserves nested error causes in structured error summaries", () => { - const cause = new Error("inner public failure"); - const error = new Error("outer public failure", { cause }); - - const summary = errorSummary(error) as Record; - - expect(summary.cause).toMatchObject({ - name: "Error", - message: "inner public failure", - }); - }); - - it("tracks circular references in custom Error properties", () => { - const details: Record = {}; - const error = Object.assign(new Error("outer public failure"), { - details, - }); - error.details.self = error; - - const summary = errorSummary(error, { includeStack: false }) as Record; - - expect(summary).toEqual({ - name: "Error", - message: "outer public failure", - details: { self: "[Circular]" }, - }); - }); - - it("preserves public RPC debugging data in structured object error summaries", () => { - const rpcUrl = "https://testnet.example/rpc/path"; - - const summary = errorSummary({ - message: `object ${rpcUrl}`, - rpcUrl, - amount: 9007199254740993n, - }) as Record; - const serialized = JSON.stringify(summary); - - expect(serialized).toContain(rpcUrl); - expect((summary.details as Record).rpcUrl).toBe(rpcUrl); - }); - - it("preserves public nested object error fields", () => { - const rpcUrl = "https://testnet.example/rpc/path"; - - const summary = errorSummary({ - message: `failed ${rpcUrl}`, - nested: { - publicReason: "visible evidence", - }, - }) as Record; - const details = summary.details as Record; - const nested = details.nested as Record; - - expect(details.message).toBe(`failed ${rpcUrl}`); - expect(nested.publicReason).toBe("visible evidence"); - }); - - it("uses the caller-owned retry classifier for pre-broadcast failures", () => { - const transportEvents = transactionLifecycleEvents({ - type: "pre_broadcast_failed", - elapsedMs: 12, - error: new TypeError("fetch failed"), - }, () => true); - const rbfEvents = transactionLifecycleEvents({ - type: "pre_broadcast_failed", - elapsedMs: 12, - error: Object.assign(new Error("Client request error PoolRejectedRBF"), { - code: -1111, - data: "RBFRejected(\"Tx's current fee is 11795, expect it to >= 12326 to replace old txs\")", - currentFee: 11795n, - leastFee: 12326n, - }), - }, () => true); - const terminalEvents = transactionLifecycleEvents({ - type: "pre_broadcast_failed", - elapsedMs: 12, - error: new Error("deterministic failure"), - }, () => false); - - expect(transportEvents).toMatchObject([{ - type: "bot.transaction.failed", - fields: { - phase: "pre_broadcast", - outcome: "pre_broadcast_failed", - retryable: true, - terminal: false, - }, - }]); - expect(transportEvents[0]?.fields.error).not.toHaveProperty("stack"); - expect(rbfEvents).toMatchObject([{ - type: "bot.transaction.failed", - fields: { - phase: "pre_broadcast", - outcome: "pre_broadcast_failed", - retryable: true, - terminal: false, - error: { - code: -1111, - data: "RBFRejected(\"Tx's current fee is 11795, expect it to >= 12326 to replace old txs\")", - currentFee: "11795", - leastFee: "12326", - }, - }, - }]); - expect(rbfEvents[0]?.fields.error).not.toHaveProperty("stack"); - expect(terminalEvents).toMatchObject([{ - type: "bot.transaction.failed", - fields: { - phase: "pre_broadcast", - outcome: "pre_broadcast_failed", - retryable: false, - terminal: true, - }, - }]); - expect(terminalEvents[0]?.fields.error).toHaveProperty("stack"); - }); - - it("summarizes thrown objects with JSON-safe debugging details preserved", () => { - const summary = errorSummary({ - code: "RPC_FAILURE", - amount: 9007199254740993n, - message: "failed with public evidence", - lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" }, - cell: { cellOutput: { lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" } } }, - witnesses: ["0x" + "22".repeat(80)], - signedTx: "0x" + "33".repeat(80), - env: { BOT_CONFIG_FILE: "/run/credentials/config.json" }, - config: { chain: "testnet" }, - }) as Record; - - expect(summary).toEqual({ - message: "Non-Error object", - details: { - code: "RPC_FAILURE", - amount: "9007199254740993", - message: "failed with public evidence", - lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" }, - cell: { cellOutput: { lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" } } }, - witnesses: ["0x" + "22".repeat(80)], - signedTx: "0x" + "33".repeat(80), - env: { BOT_CONFIG_FILE: "/run/credentials/config.json" }, - config: { chain: "testnet" }, - }, - }); - }); - - it("preserves transaction-shaped details from nested object causes", () => { - const summary = errorSummary(new Error("outer", { - cause: { - message: "inner public evidence", - inputs: [{}], - outputsData: ["0x" + "22".repeat(80)], - cellDeps: [{}], - headerDeps: [{}], - }, - })) as Record; - - expect(summary.cause).toEqual({ - message: "Non-Error object", - details: { - message: "inner public evidence", - inputs: [{}], - outputsData: ["0x" + "22".repeat(80)], - cellDeps: [{}], - headerDeps: [{}], - }, - }); - }); - - it("emits a structured decision event for no-action skips", () => { - const events: unknown[] = []; - const emitter = new BotEventEmitter({ - chain: "testnet", - runId: "run-1", - write: (event): void => { - events.push(event); - }, - }); - const actions = noActions; - const decision: BotDecisionTranscript = { - chainTip: { - blockNumber: 1n, - blockHash: `0x${"11".repeat(32)}`, - timestamp: 2n, - epoch: { integer: 3n, numerator: 0n, denominator: 1n }, - }, - balances: { - availableCkb: 0n, - unavailableCkb: 0n, - totalCkb: 0n, - availableIckb: 0n, - totalEquivalentCkb: 0n, - totalEquivalentIckb: 0n, - minimumCkbCapital: 0n, - spendableCkb: 0n, - }, - orders: { marketCount: 0, userCount: 0, receiptCount: 0 }, - withdrawals: { readyCount: 0, pendingCount: 0 }, - poolDeposits: { readyCount: 0, nearReadyCount: 0, futureCount: 0 }, - match: { reason: "no_market_orders", partialCount: 0, ckbDelta: 0n, udtDelta: 0n }, - rebalance: { - kind: "none", - reason: "target_ickb_not_exceeded", - diagnostics: { - futurePool: { - futureDepositCount: 2, - canCreateFutureInventory: true, - ringLength: 16n, - segmentCount: 2, - targetSegmentIndex: 0, - targetSegmentLength: 8n, - targetSegmentUdtValue: 2n, - totalFutureUdt: 2n, - anchorsShareOneSegment: true, - segments: [ - { index: 0, length: 8n, depositCount: 2, udtValue: 2n, isTarget: true }, - { index: 1, length: 8n, depositCount: 0, udtValue: 0n, isTarget: false }, - ], - }, - }, - outputSlots: 58, - projectedAvailableCkb: 0n, - projectedAvailableIckb: 0n, - }, - actions, - fee: { feeRate: 1n }, - transactionShape: { inputs: 0, outputs: 0, cellDeps: 0, headerDeps: 0, witnesses: 0 }, - exchangeRatio: { ckbScale: 1n, udtScale: 1n }, - depositCapacity: 0n, - skip: { reason: "no_actions" }, - }; - const result: BuildTransactionResult = { - kind: "skipped", - reason: "no_actions", - actions, - decision, - }; - - emitDecisionEvents(emitter, 1, result); - - expect(events).toHaveLength(3); - expect(events).toMatchObject([ - { type: "bot.match.evaluated" }, - { type: "bot.rebalance.evaluated" }, - { - type: "bot.decision.skipped", - reason: "no_actions", - actions, - decision: { - rebalance: { - kind: "none", - reason: "target_ickb_not_exceeded", - diagnostics: { - futurePool: { - futureDepositCount: 2, - segments: [ - { index: 0, length: "8", depositCount: 2, udtValue: "2", isTarget: true }, - { index: 1, length: "8", depositCount: 0, udtValue: "0", isTarget: false }, - ], - }, - }, - }, - skip: { reason: "no_actions" }, - }, - }, - ]); - }); - - it("keeps low-capital safety skips state-only", () => { - const state = { - chainTip: { - blockNumber: 1n, - blockHash: `0x${"11".repeat(32)}`, - timestamp: 2n, - epoch: { integer: 3n, numerator: 0n, denominator: 1n }, - }, - balances: { - availableCkb: 0n, - unavailableCkb: 0n, - totalCkb: 0n, - availableIckb: 0n, - totalEquivalentCkb: 0n, - totalEquivalentIckb: 0n, - minimumCkbCapital: 1n, - spendableCkb: 0n, - }, - orders: { marketCount: 0, userCount: 0, receiptCount: 0 }, - withdrawals: { readyCount: 0, pendingCount: 0 }, - poolDeposits: { readyCount: 0, nearReadyCount: 0, futureCount: 0 }, - exchangeRatio: { ckbScale: 1n, udtScale: 1n }, - depositCapacity: 0n, - fee: { feeRate: 1n }, - }; - - const skip = lowCapitalSkipDecision(state); - - expect(skip).toEqual({ - reason: "capital_below_minimum", - actions: noActions, - state, - deficit: 1n, - }); - expect(skip).not.toHaveProperty("decision"); - }); - -}); diff --git a/apps/bot/src/observability.ts b/apps/bot/src/observability.ts deleted file mode 100644 index 0eea392..0000000 --- a/apps/bot/src/observability.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { type ccc } from "@ckb-ccc/core"; -import { - jsonLogReplacer, - writeJsonLine, - type SupportedChain, -} from "@ickb/node-utils"; -import { type SendAndWaitForCommitEvent } from "@ickb/sdk"; -import { - type BotActions, - type BotDecisionSkipReason, - type BotStateSummary, - type BuildTransactionResult, - transactionShape, -} from "./runtime.js"; - -const BOT_EVENT_VERSION = 1; - -export type BotEventType = - | "bot.run.started" - | "bot.chain.preflight" - | "bot.iteration.started" - | "bot.state.read" - | "bot.match.evaluated" - | "bot.rebalance.evaluated" - | "bot.decision.skipped" - | "bot.transaction.built" - | "bot.transaction.sent" - | "bot.transaction.confirmation" - | "bot.transaction.committed" - | "bot.transaction.failed" - | "bot.iteration.failed"; - -export interface BotEventIdentity { - version: typeof BOT_EVENT_VERSION; - app: "bot"; - chain: SupportedChain; - runId: string; - iterationId: number; - timestamp: string; - type: BotEventType; -} - -export type BotEvent = BotEventIdentity & Record; - -export class BotEventEmitter { - constructor( - private readonly context: { - chain: SupportedChain; - runId: string; - write?: (event: BotEvent) => void; - }, - ) {} - - emit( - iterationId: number, - type: BotEventType, - fields: Record = {}, - ): BotEvent { - const event: BotEvent = { - ...jsonSafeEventFields(fields), - version: BOT_EVENT_VERSION, - app: "bot", - chain: this.context.chain, - runId: this.context.runId, - iterationId, - timestamp: new Date().toISOString(), - type, - }; - (this.context.write ?? writeJsonLine)(event); - return event; - } -} - -export function createRunId(): string { - return `${new Date().toISOString()}-${process.pid.toString(36)}`; -} - -export function emitDecisionEvents( - emitter: BotEventEmitter, - iterationId: number, - result: BuildTransactionResult, -): void { - const { decision } = result; - emitter.emit(iterationId, "bot.match.evaluated", { - match: decision.match, - orders: decision.orders, - }); - emitter.emit(iterationId, "bot.rebalance.evaluated", { - rebalance: decision.rebalance, - poolDeposits: decision.poolDeposits, - }); - - if (result.kind === "skipped") { - emitter.emit(iterationId, "bot.decision.skipped", { - reason: result.reason, - actions: result.actions, - decision, - }); - return; - } - - emitter.emit(iterationId, "bot.transaction.built", { - actions: result.actions, - fee: decision.fee, - transactionShape: decision.transactionShape, - decision, - }); -} - -export function transactionSummary( - tx: ccc.Transaction, - fee: bigint, - feeRate: ccc.Num, -): Record { - return { - fee, - feeRate, - shape: transactionShape(tx), - }; -} - -export function transactionLifecycleEvents( - event: SendAndWaitForCommitEvent, - isRetryableError: (error: unknown) => boolean = (): boolean => false, -): Array<{ - type: "bot.transaction.sent" | "bot.transaction.confirmation" | "bot.transaction.committed" | "bot.transaction.failed"; - fields: Record; -}> { - switch (event.type) { - case "broadcasted": - return [{ - type: "bot.transaction.sent", - fields: { - txHash: event.txHash, - phase: "broadcast", - outcome: "broadcasted", - elapsedMs: event.elapsedMs, - }, - }]; - case "committed": - return [ - { - type: "bot.transaction.confirmation", - fields: { - ...confirmationFields(event), - outcome: "committed", - retryable: false, - terminal: true, - }, - }, - { - type: "bot.transaction.committed", - fields: { ...confirmationFields(event), outcome: "committed" }, - }, - ]; - case "timeout_after_broadcast": - case "post_broadcast_unresolved": - return [ - { - type: "bot.transaction.confirmation", - fields: { - ...confirmationFields(event), - outcome: event.type, - retryable: false, - terminal: true, - }, - }, - { - type: "bot.transaction.failed", - fields: { - ...confirmationFields(event), - outcome: event.type, - retryable: false, - terminal: true, - }, - }, - ]; - case "terminal_rejection": - return [ - { - type: "bot.transaction.confirmation", - fields: { - ...confirmationFields(event), - outcome: "terminal_rejection", - retryable: false, - terminal: true, - }, - }, - { - type: "bot.transaction.failed", - fields: { - ...confirmationFields(event), - outcome: "terminal_rejection", - retryable: false, - terminal: true, - }, - }, - ]; - case "pre_broadcast_failed": - { - const retryable = isRetryableError(event.error); - return [{ - type: "bot.transaction.failed", - fields: { - phase: "pre_broadcast", - outcome: "pre_broadcast_failed", - elapsedMs: event.elapsedMs, - error: errorSummary(event.error, { includeStack: !retryable }), - retryable, - terminal: !retryable, - }, - }]; - } - } -} - -export function lowCapitalSkipDecision( - summary: BotStateSummary, -): { - reason: BotDecisionSkipReason; - actions: BotActions; - state: BotStateSummary; - deficit: bigint; -} { - const deficit = summary.balances.minimumCkbCapital - summary.balances.totalEquivalentCkb; - return { - reason: "capital_below_minimum", - actions: { - collectedOrders: 0, - completedDeposits: 0, - matchedOrders: 0, - deposits: 0, - withdrawalRequests: 0, - withdrawals: 0, - }, - state: summary, - deficit: deficit > 0n ? deficit : 0n, - }; -} - -export function errorSummary( - error: unknown, - options: { includeStack?: boolean } = {}, -): Record | string { - return summarizeError(error, new Set(), options.includeStack ?? true); -} - -function summarizeError( - error: unknown, - seen: Set, - includeStack: boolean, -): Record | string { - if (error instanceof Error) { - if (seen.has(error)) { - return { message: "Circular error reference" }; - } - seen.add(error); - - return { - ...errorOwnProperties(error, seen), - name: error.name, - message: logValue(error.message, seen), - ...(includeStack && error.stack !== undefined ? { stack: logValue(error.stack, seen) } : {}), - ...("txHash" in error ? { txHash: logValue(error.txHash, seen) } : {}), - ...("status" in error ? { status: logValue(error.status, seen) } : {}), - ...("isTimeout" in error ? { isTimeout: logValue(error.isTimeout, seen) } : {}), - ...("cause" in error ? { cause: summarizeError(error.cause, seen, includeStack) } : {}), - }; - } - - if (typeof error === "object" && error !== null) { - return { - message: "Non-Error object", - details: logValue(error, seen), - }; - } - - if (typeof error === "string") { - return logValue(error, seen) as string; - } - - if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") { - return error.toString(); - } - - return "Empty Error"; -} - -const ERROR_BUILTIN_KEYS = new Set(["name", "message", "stack", "cause"]); - -function errorOwnProperties(error: Error, seen: Set): Record { - const properties: Record = {}; - for (const [key, value] of Object.entries(error)) { - if (!ERROR_BUILTIN_KEYS.has(key)) { - properties[key] = value; - } - } - // CCC/CKB RPC errors carry retry evidence such as code, data, outPoint, - // currentFee, and leastFee as enumerable Error fields. - return logValue(properties, seen) as Record; -} - -function confirmationFields(event: Extract< - SendAndWaitForCommitEvent, - { txHash: ccc.Hex; checks: number } ->): Record { - return { - phase: "confirmation", - txHash: event.txHash, - status: event.status, - checks: event.checks, - elapsedMs: event.elapsedMs, - ...("error" in event ? { error: errorSummary(event.error) } : {}), - }; -} - -function jsonSafeEventFields(fields: Record): Record { - return JSON.parse(JSON.stringify(logValue(fields, new Set()), jsonLogReplacer)) as Record; -} - -const UNSUPPORTED_LOG_VALUE = "[Unsupported log value]"; - -function logValue(value: unknown, seen: Set): unknown { - if (typeof value === "string") { - return value; - } - if (typeof value === "bigint") { - return value.toString(); - } - if (typeof value === "function") { - return UNSUPPORTED_LOG_VALUE; - } - if (typeof value !== "object" || value === null) { - return value; - } - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? null : value.toISOString(); - } - if (seen.has(value)) { - return "[Circular]"; - } - seen.add(value); - try { - if (Array.isArray(value)) { - return value.map((entry) => logValue(entry, seen)); - } - const logged: Record = {}; - for (const [key, entry] of Object.entries(value)) { - logged[key] = logValue(entry, seen); - } - return logged; - } finally { - seen.delete(value); - } -} diff --git a/apps/bot/src/policy.test.ts b/apps/bot/src/policy.test.ts deleted file mode 100644 index 35e7f20..0000000 --- a/apps/bot/src/policy.test.ts +++ /dev/null @@ -1,1216 +0,0 @@ -import { ICKB_DEPOSIT_CAP } from "@ickb/core"; -import { describe, expect, it } from "vitest"; -import { - CKB, - CKB_RESERVE, - MIN_ICKB_BALANCE, - NEAR_READY_LOOKAHEAD_MS, - partitionPoolDeposits, - planRebalance, - TARGET_ICKB_BALANCE, -} from "./policy.js"; - -const FUTURE_RING_LENGTH_MS = 16n; - -const TIP = { - epoch: { - toUnix: (): bigint => 0n, - add: (): { toUnix: () => bigint } => ({ - toUnix: (): bigint => FUTURE_RING_LENGTH_MS, - }), - }, -} as never; - -function readyDeposit( - udtValue: bigint, - maturityUnix: bigint, -): { - udtValue: bigint; - maturity: { toUnix: () => bigint }; -} { - return { - udtValue, - maturity: { - toUnix: (): bigint => maturityUnix, - }, - }; -} - -function futureDeposit( - maturityUnix: bigint, - udtValue = ICKB_DEPOSIT_CAP, - options?: { - isReady?: boolean; - }, -): { - udtValue: bigint; - maturity: { toUnix: () => bigint }; - isReady?: boolean; -} { - return { - ...readyDeposit(udtValue, maturityUnix), - isReady: options?.isReady, - }; -} - -const NO_NEAR_READY: never[] = []; -const NO_FUTURE: never[] = []; - -describe("partitionPoolDeposits", () => { - it("keeps not-ready deposits before the near-ready window out of future coverage", () => { - const ready = futureDeposit(2n, ICKB_DEPOSIT_CAP, { isReady: true }); - const notReadyBeforeWindow = futureDeposit(8n); - const nearReady = futureDeposit(16n); - const future = futureDeposit(16n + NEAR_READY_LOOKAHEAD_MS); - - expect( - partitionPoolDeposits( - [future, nearReady, notReadyBeforeWindow, ready] as never[], - TIP, - FUTURE_RING_LENGTH_MS, - ), - ).toEqual({ - ready: [ready], - nearReady: [nearReady], - future: [future], - }); - }); -}); - -describe("planRebalance", () => { - it("does nothing when fewer than two output slots remain", () => { - expect( - planRebalance({ - outputSlots: 1, - tip: TIP, - ickbBalance: 0n, - ckbBalance: 2000n * 100000000n, - depositCapacity: 1000n * 100000000n, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toEqual({ kind: "none", reason: "insufficient_output_slots" }); - }); - - it("requests one deposit when iCKB is too low and CKB reserve is available", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: 0n, - ckbBalance: 2000n * 100000000n, - depositCapacity: 1000n * 100000000n, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "deposit", quantity: 1 }); - }); - - it("requests one deposit when CKB is exactly at the reserve boundary", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: 0n, - ckbBalance: 1000n * CKB + CKB_RESERVE, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "deposit", quantity: 1 }); - }); - - it("does nothing when iCKB is too low but the CKB reserve is unavailable", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: 0n, - ckbBalance: 1999n * 100000000n, - depositCapacity: 1000n * 100000000n, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toEqual({ kind: "none", reason: "low_ickb_ckb_reserve_unavailable" }); - }); - - it("does not deposit when iCKB is exactly at the minimum balance", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: MIN_ICKB_BALANCE, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none", reason: "target_ickb_not_exceeded" }); - }); - - it("seeds one future deposit when no future anchors exist", () => { - const plan = planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: [futureDeposit(105n * 60n * 1000n)] as never[], - futurePoolDeposits: NO_FUTURE, - }); - - expect(plan).toMatchObject({ kind: "deposit", quantity: 1 }); - expect(plan.diagnostics?.futurePool).toMatchObject({ - futureDepositCount: 0, - canCreateFutureInventory: true, - shouldBootstrapFirstAnchor: true, - segmentCount: 1, - segments: [ - { index: 0, depositCount: 0, udtValue: 0n, isTarget: true }, - ], - }); - }); - - it("does not seed when a lone future anchor must be preserved", () => { - const loneAnchor = futureDeposit(9n); - - const plan = planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [loneAnchor] as never[], - }); - - expect(plan).toMatchObject({ kind: "none" }); - expect(plan.diagnostics?.futurePool).toMatchObject({ - futureDepositCount: 1, - canCreateFutureInventory: true, - shouldBootstrapFirstAnchor: false, - segmentCount: 1, - segments: [ - { index: 0, depositCount: 1, udtValue: ICKB_DEPOSIT_CAP, isTarget: true }, - ], - }); - }); - - it("seeds, without withdrawing, when two future anchors crowd the same adaptive segment", () => { - const firstDuplicate = futureDeposit(9n); - const secondDuplicate = futureDeposit(10n); - - const plan = planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [firstDuplicate, secondDuplicate] as never[], - }); - - expect(plan).toMatchObject({ kind: "deposit", quantity: 1 }); - expect(plan.diagnostics?.futurePool).toMatchObject({ - futureDepositCount: 2, - canCreateFutureInventory: true, - ringLength: FUTURE_RING_LENGTH_MS, - segmentCount: 2, - targetSegmentIndex: 0, - totalFutureUdt: 2n * ICKB_DEPOSIT_CAP, - anchorsShareOneSegment: true, - segments: [ - { index: 0, depositCount: 0, udtValue: 0n, isTarget: true }, - { index: 1, depositCount: 2, udtValue: 2n * ICKB_DEPOSIT_CAP, isTarget: false }, - ], - }); - }); - - it("does not seed when two future anchors already span both adaptive segments", () => { - const plan = planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(1n), futureDeposit(9n)] as never[], - }); - - expect(plan).toMatchObject({ kind: "none" }); - expect(plan.diagnostics?.futurePool).toMatchObject({ - futureDepositCount: 2, - segmentCount: 2, - targetSegmentIndex: 0, - targetSegmentUdtValue: ICKB_DEPOSIT_CAP, - anchorsShareOneSegment: false, - segments: [ - { index: 0, depositCount: 1, udtValue: ICKB_DEPOSIT_CAP, isTarget: true }, - { index: 1, depositCount: 1, udtValue: ICKB_DEPOSIT_CAP, isTarget: false }, - ], - }); - }); - - it("seeds when the coarse target segment is under-covered by udt per meter", () => { - const plan = planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(5n), futureDeposit(9n), futureDeposit(13n)] as never[], - }); - - expect(plan).toMatchObject({ kind: "deposit", quantity: 1 }); - expect(plan.diagnostics?.futurePool).toMatchObject({ - futureDepositCount: 3, - segmentCount: 4, - targetSegmentIndex: 0, - targetSegmentUdtValue: 0n, - anchorsShareOneSegment: false, - segments: [ - { index: 0, depositCount: 0, udtValue: 0n, isTarget: true }, - { index: 1, depositCount: 1, udtValue: ICKB_DEPOSIT_CAP, isTarget: false }, - { index: 2, depositCount: 1, udtValue: ICKB_DEPOSIT_CAP, isTarget: false }, - { index: 3, depositCount: 1, udtValue: ICKB_DEPOSIT_CAP, isTarget: false }, - ], - }); - }); - - it("does not seed when the coarse target segment meets the density threshold", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [ - futureDeposit(1n, 1n), - futureDeposit(5n, 3n), - futureDeposit(9n, 4n), - ] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not seed from zero-total future coverage", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(9n, 0n), futureDeposit(10n, 0n)] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not seed future shaping when the reserve gate fails", () => { - const plan = planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }); - - expect(plan).toMatchObject({ kind: "none" }); - expect(plan.diagnostics?.futurePool).toMatchObject({ - futureDepositCount: 0, - canCreateFutureInventory: false, - shouldBootstrapFirstAnchor: false, - segmentCount: 1, - segments: [ - { index: 0, depositCount: 0, udtValue: 0n, isTarget: true }, - ], - }); - }); - - it("does not seed when one more deposit would cross the target band", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP + 1n, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not treat sparse next-hour near-ready as future-segmentation coverage", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: [futureDeposit(105n * 60n * 1000n)] as never[], - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "deposit", quantity: 1 }); - }); - - it("does not withdraw from a duplicate dense future segment to fill an empty target segment", () => { - const firstDuplicate = futureDeposit(5n); - const secondDuplicate = futureDeposit(6n); - const otherSegment = futureDeposit(9n); - - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [firstDuplicate, secondDuplicate, otherSegment] as never[], - }), - ).toMatchObject({ kind: "deposit", quantity: 1 }); - }); - - it("keeps direct seeding when future crowding has only deposit output slots", () => { - expect( - planRebalance({ - outputSlots: 3, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)] as never[], - }), - ).toMatchObject({ kind: "deposit", quantity: 1 }); - }); - - it("does not withdraw stale ready-shaped entries from the future pool", () => { - const readyDuplicate = futureDeposit(5n, ICKB_DEPOSIT_CAP, { isReady: true }); - const pendingDuplicate = futureDeposit(6n); - const secondPendingDuplicate = futureDeposit(7n); - const otherSegment = futureDeposit(9n); - - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [readyDuplicate, pendingDuplicate, secondPendingDuplicate, otherSegment] as never[], - }), - ).toMatchObject({ kind: "deposit", quantity: 1 }); - }); - - it("does not treat ready entries as future anchors", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [ - futureDeposit(5n, ICKB_DEPOSIT_CAP, { isReady: true }), - futureDeposit(6n, ICKB_DEPOSIT_CAP, { isReady: true }), - futureDeposit(9n), - ] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("keeps direct seeding when removing a future source would leave the source segment below the preservation floor", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [ - futureDeposit(5n, 50n), - futureDeposit(6n, 50n), - futureDeposit(9n, 200n), - futureDeposit(13n, 200n), - ] as never[], - }), - ).toMatchObject({ kind: "deposit", quantity: 1 }); - }); - - it("keeps direct seeding when density improvement would be too small", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [ - futureDeposit(1n, 10n), - futureDeposit(4n), - futureDeposit(4n), - futureDeposit(4n), - futureDeposit(5n), - futureDeposit(5n), - futureDeposit(9n, 10n), - ] as never[], - }), - ).toMatchObject({ kind: "deposit", quantity: 1 }); - }); - - it("returns none, not a withdrawal, when dust crowds the future pool without deposit budget", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(5n, 1n), futureDeposit(6n, 1n), futureDeposit(9n)] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not reserve withdrawals when public drain leaves the future target under-covered", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(5n), futureDeposit(9n, 1n), futureDeposit(13n)] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not expand future horizon or remove a farther source", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n), futureDeposit(10_000n)] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("returns none when a raced future-removal source disappears", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(9n)] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not treat pending future withdrawal value as liquid CKB for future seeding", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: [futureDeposit(105n * 60n * 1000n, 10n * ICKB_DEPOSIT_CAP)] as never[], - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does nothing instead of withdrawing for cosmetic future smoothing", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [ - futureDeposit(1n), - futureDeposit(4n), - futureDeposit(8n), - futureDeposit(12n), - ] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not seed or withdraw future inventory when the reserve gate fails", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not seed or withdraw future inventory when one more deposit would cross the target band", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP + 1n, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("requests withdrawals when iCKB is above the target band and a crowded-bucket fit exists", () => { - const first = readyDeposit(4n, 20n * 60n * 1000n); - const second = readyDeposit(6n, 25n * 60n * 1000n); - const third = readyDeposit(5n, 40n * 60n * 1000n); - - const plan = planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 9n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [first, second, third] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }); - - expect(plan).toMatchObject({ - kind: "withdraw", - deposits: [first], - requiredLiveDeposits: [second], - }); - }); - - it("prefers thinning crowded ready buckets before isolated deposits", () => { - const singleton = readyDeposit(5n, 0n); - const crowdedEarly = readyDeposit(4n, 20n * 60n * 1000n); - const crowdedLate = readyDeposit(4n, 25n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 5n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [singleton, crowdedEarly, crowdedLate] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ - kind: "withdraw", - deposits: [crowdedEarly], - requiredLiveDeposits: [crowdedLate], - }); - }); - - it("pins protected crowded anchors for ordinary extra withdrawals", () => { - const protectedAnchor = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); - const extra = readyDeposit(ICKB_DEPOSIT_CAP - CKB, 25n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP - CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [extra, protectedAnchor] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ - kind: "withdraw", - deposits: [extra], - requiredLiveDeposits: [protectedAnchor], - }); - }); - - it("still prefers a crowded-bucket fit before touching singleton anchors", () => { - const singleton = readyDeposit(5n, 0n); - const crowdedProtected = readyDeposit(6n, 20n * 60n * 1000n); - const crowdedExtra = readyDeposit(5n, 25n * 60n * 1000n); - - const plan = planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 5n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [crowdedExtra, crowdedProtected, singleton] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }); - - expect(plan).toMatchObject({ kind: "withdraw" }); - expect(plan.kind === "withdraw" ? plan.deposits.map((deposit) => deposit.udtValue) : []).toEqual([crowdedExtra.udtValue]); - }); - - it("protects isolated singleton anchors while moderate excess can still thin a crowded bucket", () => { - const singleton = readyDeposit(5n, 0n); - const crowdedLarge = readyDeposit(9n, 20n * 60n * 1000n); - const crowdedSmall = readyDeposit(4n, 25n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 9n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [singleton, crowdedLarge, crowdedSmall] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ - kind: "withdraw", - deposits: [crowdedSmall], - requiredLiveDeposits: [crowdedLarge], - }); - }); - - it("spends singleton anchors again once excess reaches one deposit step above target", () => { - const singleton = readyDeposit(5n, 0n); - const crowdedLarge = readyDeposit(9n, 20n * 60n * 1000n); - const crowdedSmall = readyDeposit(4n, 25n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 9n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [singleton, crowdedLarge, crowdedSmall] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ - kind: "withdraw", - deposits: [singleton, crowdedSmall], - requiredLiveDeposits: [crowdedLarge], - }); - }); - - it("ranks crowded buckets by overfull value before smaller crowded buckets", () => { - const lowExtra = readyDeposit(3n, 20n * 60n * 1000n); - const lowProtected = readyDeposit(5n, 25n * 60n * 1000n); - const highExtra = readyDeposit(4n, 40n * 60n * 1000n); - const highProtected = readyDeposit(5n, 44n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 4n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [lowExtra, lowProtected, highExtra, highProtected] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ - kind: "withdraw", - deposits: [highExtra], - requiredLiveDeposits: [highProtected], - }); - }); - - it("leaves one deposit in a crowded ready bucket when that already reduces excess", () => { - const first = readyDeposit(3n, 20n * 60n * 1000n); - const last = readyDeposit(3n, 25n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 6n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [first, last] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ - kind: "withdraw", - deposits: [first], - requiredLiveDeposits: [last], - }); - }); - - it("keeps the latest deposit when crowded bucket values tie", () => { - const earlier = readyDeposit(3n, 20n * 60n * 1000n); - const later = readyDeposit(3n, 25n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 3n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [earlier, later] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ - kind: "withdraw", - deposits: [earlier], - requiredLiveDeposits: [later], - }); - }); - - it("keeps singleton anchors when only they remain under moderate excess", () => { - const earlierSingleton = readyDeposit(5n, 20n * 60n * 1000n); - const laterSingleton = readyDeposit(5n, 45n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 5n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [earlierSingleton, laterSingleton] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("uses near-ready refill as a tie-break for equal singleton choices once anchors unlock", () => { - const earlierSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); - const laterSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); - const nearReadyRefill = readyDeposit(4n, 105n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [earlierSingleton, laterSingleton] as never[], - nearReadyDeposits: [nearReadyRefill] as never[], - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "withdraw", deposits: [laterSingleton] }); - }); - - it("uses near-ready refill as a tie-break for equal crowded-bucket extras", () => { - const firstExtra = readyDeposit(4n, 20n * 60n * 1000n); - const firstProtected = readyDeposit(5n, 25n * 60n * 1000n); - const secondExtra = readyDeposit(4n, 40n * 60n * 1000n); - const secondProtected = readyDeposit(5n, 44n * 60n * 1000n); - const nearReadyRefill = readyDeposit(3n, 105n * 60n * 1000n); - - const plan = planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 4n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [firstExtra, firstProtected, secondExtra, secondProtected] as never[], - nearReadyDeposits: [nearReadyRefill] as never[], - futurePoolDeposits: NO_FUTURE, - }); - - expect(plan).toMatchObject({ kind: "withdraw" }); - expect(plan.kind === "withdraw" ? plan.deposits.map((deposit) => deposit.udtValue) : []).toEqual([secondExtra.udtValue]); - }); - - it("ignores near-ready refill exactly at the lookahead cutoff", () => { - const earlierSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); - const laterSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); - const atCutoff = readyDeposit(4n, 120n * 60n * 1000n); - - const plan = planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [earlierSingleton, laterSingleton] as never[], - nearReadyDeposits: [atCutoff] as never[], - futurePoolDeposits: NO_FUTURE, - }); - - expect(plan).toMatchObject({ kind: "withdraw" }); - expect(plan.kind === "withdraw" ? plan.deposits.map((deposit) => deposit.udtValue) : []).toEqual([earlierSingleton.udtValue]); - }); - - it("ignores near-ready refill just outside the one-hour lookahead", () => { - const earlierSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); - const laterSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); - const outsideLookahead = readyDeposit(4n, 121n * 60n * 1000n); - - const plan = planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [earlierSingleton, laterSingleton] as never[], - nearReadyDeposits: [outsideLookahead] as never[], - futurePoolDeposits: NO_FUTURE, - }); - - expect(plan).toMatchObject({ kind: "withdraw" }); - expect(plan.kind === "withdraw" ? plan.deposits.map((deposit) => deposit.udtValue) : []).toEqual([earlierSingleton.udtValue]); - }); - - it("returns none for tiny excess with a near-cap crowded extra", () => { - const singleton = readyDeposit(5n, 0n); - const crowdedEarly = readyDeposit(ICKB_DEPOSIT_CAP - 1n, 20n * 60n * 1000n); - const crowdedLate = readyDeposit(ICKB_DEPOSIT_CAP, 25n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 1n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [singleton, crowdedEarly, crowdedLate] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("returns none when neither extras nor singletons fit", () => { - const singleton = readyDeposit(6n, 0n); - const crowdedProtected = readyDeposit(6n, 20n * 60n * 1000n); - const crowdedExtra = readyDeposit(7n, 25n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 5n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [crowdedExtra, crowdedProtected, singleton] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none", reason: "no_ready_withdrawal_selection" }); - }); - - it("limits withdrawal requests by the available output slots", () => { - const first = readyDeposit(3n, 0n); - const second = readyDeposit(3n, 20n * 60n * 1000n); - const third = readyDeposit(3n, 40n * 60n * 1000n); - - const plan = planRebalance({ - outputSlots: 5, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 10n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [first, second, third] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }); - - expect(plan).toMatchObject({ - kind: "withdraw", - deposits: [first, second], - }); - }); - - it("caps withdrawal requests at thirty deposits", () => { - const readyDeposits = Array.from( - { length: 31 }, - (_, index) => readyDeposit(1n, BigInt(index) * 20n * 60n * 1000n), - ) as never[]; - - const plan = planRebalance({ - outputSlots: 100, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 100n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits, - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }); - - expect(plan).toMatchObject({ kind: "withdraw" }); - expect(plan.kind === "withdraw" ? plan.deposits : []).toHaveLength(30); - }); - - it("does nothing when iCKB is above target but a full withdrawal would cut below the buffer", () => { - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 3n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [readyDeposit(ICKB_DEPOSIT_CAP + 4n, 0n)] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("cleanup withdraws one over-cap extra and pins its protected anchor", () => { - const first = futureDeposit(20n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB, { isReady: true }); - const second = futureDeposit(25n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB, { isReady: true }); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [first, second] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ - kind: "withdraw", - deposits: [first], - requiredLiveDeposits: [second], - }); - }); - - it("does not clean up an exact-cap ready deposit", () => { - const capBait = futureDeposit(0n, ICKB_DEPOSIT_CAP, { isReady: true }); - - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 1n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [capBait] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not let cleanup consume a protected crowded ready anchor", () => { - const tinyExtra = futureDeposit(20n * 60n * 1000n, 1n, { isReady: true }); - const protectedAnchor = futureDeposit(25n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB, { isReady: true }); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [tinyExtra, protectedAnchor] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ - kind: "withdraw", - deposits: [tinyExtra], - requiredLiveDeposits: [protectedAnchor], - }); - }); - - it("ignores under-cap non-standard bait for cleanup", () => { - const dustBait = futureDeposit(0n, ICKB_DEPOSIT_CAP - 1n, { isReady: true }); - - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + 1n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [dustBait] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not clean up near-ready or future non-standard deposits", () => { - const nearReady = futureDeposit(105n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB); - const future = futureDeposit(10n, ICKB_DEPOSIT_CAP + CKB); - - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [], - nearReadyDeposits: [nearReady] as never[], - futurePoolDeposits: [future] as never[], - }), - ).toMatchObject({ kind: "none" }); - }); - - it("requests one ready singleton once anchors unlock and a full withdrawal keeps the target buffer", () => { - const deposit = readyDeposit(ICKB_DEPOSIT_CAP + CKB, 0n); - - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [deposit] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "withdraw", deposits: [deposit] }); - }); - - it("does not request a full withdrawal that would cut below the target buffer", () => { - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [readyDeposit(ICKB_DEPOSIT_CAP + 2n * CKB, 0n)] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not let near-ready refill unlock cleanup below the target floor", () => { - const unsafeReady = futureDeposit(0n, ICKB_DEPOSIT_CAP + 2n * CKB, { isReady: true }); - const hugeNearReady = readyDeposit(10n * ICKB_DEPOSIT_CAP, 105n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [unsafeReady] as never[], - nearReadyDeposits: [hugeNearReady] as never[], - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("keeps deposit action exclusive when future seeding gates pass", () => { - const cleanupReady = futureDeposit(0n, ICKB_DEPOSIT_CAP + CKB, { isReady: true }); - - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, - ckbBalance: 2000n * CKB, - depositCapacity: 1000n * CKB, - readyDeposits: [cleanupReady] as never[], - nearReadyDeposits: NO_NEAR_READY, - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "deposit", quantity: 1 }); - }); - - it("does not treat near-ready refill as current liquidity budget", () => { - const unsafeReady = readyDeposit(ICKB_DEPOSIT_CAP + 2n * CKB, 0n); - const hugeNearReady = readyDeposit(10n * ICKB_DEPOSIT_CAP, 105n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 4, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + CKB, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [unsafeReady] as never[], - nearReadyDeposits: [hugeNearReady] as never[], - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); - - it("does not let fake near-ready refill unlock singleton anchors before the excess gate", () => { - const earlierSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); - const laterSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); - const fakeRefill = readyDeposit(10n * ICKB_DEPOSIT_CAP, 105n * 60n * 1000n); - - expect( - planRebalance({ - outputSlots: 6, - tip: TIP, - ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP - 1n, - ckbBalance: 0n, - depositCapacity: 1000n, - readyDeposits: [earlierSingleton, laterSingleton] as never[], - nearReadyDeposits: [fakeRefill] as never[], - futurePoolDeposits: NO_FUTURE, - }), - ).toMatchObject({ kind: "none" }); - }); -}); diff --git a/apps/bot/src/policy.ts b/apps/bot/src/policy.ts deleted file mode 100644 index 44f9d19..0000000 --- a/apps/bot/src/policy.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { ICKB_DEPOSIT_CAP, type IckbDepositCell } from "@ickb/core"; -import { - selectReadyWithdrawalDeposits, - selectReadyWithdrawalCleanupDeposit, -} from "./withdrawal_selection.ts"; -import { compareBigInt } from "@ickb/utils"; - -export const CKB = ccc.fixedPointFrom(1); -export const CKB_RESERVE = 1000n * CKB; -export const MIN_ICKB_BALANCE = 2000n * CKB; -export const TARGET_ICKB_BALANCE = ICKB_DEPOSIT_CAP + 20000n * CKB; -export const NEAR_READY_LOOKAHEAD_MS = 60n * 60n * 1000n; -export const POOL_MIN_LOCK_UP = ccc.Epoch.from([0n, 1n, 16n]); -export const POOL_MAX_LOCK_UP = ccc.Epoch.from([0n, 4n, 16n]); - -const OUTPUTS_PER_REBALANCE_ACTION = 2; -const MAX_WITHDRAWAL_REQUESTS = 30; -const SINGLETON_ANCHOR_OVERRIDE_EXCESS = ICKB_DEPOSIT_CAP; -const FRESH_DEPOSIT_TARGET_EPOCH_OFFSET: [bigint, bigint, bigint] = [180n, 0n, 1n]; -const FUTURE_SEGMENT_UNDERCOVERAGE_RATIO_DENOMINATOR = 2n; - -export type RebalancePlan = - | { kind: "none"; reason: RebalanceNoopReason; diagnostics?: RebalanceDiagnostics } - | { kind: "deposit"; quantity: 1; diagnostics?: RebalanceDiagnostics } - | { - kind: "withdraw"; - deposits: IckbDepositCell[]; - requiredLiveDeposits?: IckbDepositCell[]; - diagnostics?: RebalanceDiagnostics; - }; - -export type RebalanceNoopReason = - | "insufficient_output_slots" - | "low_ickb_ckb_reserve_unavailable" - | "target_ickb_not_exceeded" - | "no_ready_withdrawal_selection"; - -export interface RebalanceDiagnostics { - futurePool?: FuturePoolDiagnostics; -} - -export interface FuturePoolDiagnostics { - futureDepositCount: number; - canCreateFutureInventory: boolean; - shouldBootstrapFirstAnchor: boolean; - ringLength: bigint; - segmentCount: number; - targetSegmentIndex: number; - targetSegmentLength: bigint; - targetSegmentUdtValue: bigint; - totalFutureUdt: bigint; - anchorsShareOneSegment: boolean; - segments: FuturePoolSegmentDiagnostics[]; -} - -export interface FuturePoolSegmentDiagnostics { - index: number; - length: bigint; - depositCount: number; - udtValue: bigint; - isTarget: boolean; -} - -export function partitionPoolDeposits( - deposits: readonly IckbDepositCell[], - tip: ccc.ClientBlockHeader, - readyWindowEnd: bigint, -): { - ready: IckbDepositCell[]; - nearReady: IckbDepositCell[]; - future: IckbDepositCell[]; -} { - const ready: IckbDepositCell[] = []; - const nearReady: IckbDepositCell[] = []; - const future: IckbDepositCell[] = []; - const nearReadyCutoff = readyWindowEnd + NEAR_READY_LOOKAHEAD_MS; - - for (const deposit of deposits) { - const maturityUnix = deposit.maturity.toUnix(tip); - if (deposit.isReady) { - ready.push(deposit); - continue; - } - - if (maturityUnix < readyWindowEnd) { - continue; - } - - if (maturityUnix < nearReadyCutoff) { - nearReady.push(deposit); - continue; - } - - future.push(deposit); - } - - ready.sort(compareDepositsByMaturity(tip)); - nearReady.sort(compareDepositsByMaturity(tip)); - future.sort(compareDepositsByMaturity(tip)); - - return { ready, nearReady, future }; -} - -export function partitionBotPoolDeposits( - deposits: readonly IckbDepositCell[], - tip: ccc.ClientBlockHeader, -): ReturnType { - return partitionPoolDeposits( - deposits, - tip, - POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip), - ); -} - -export function planRebalance(options: { - outputSlots: number; - tip: ccc.ClientBlockHeader; - ickbBalance: bigint; - ckbBalance: bigint; - depositCapacity: bigint; - readyDeposits: readonly IckbDepositCell[]; - nearReadyDeposits: readonly IckbDepositCell[]; - futurePoolDeposits: readonly IckbDepositCell[]; -}): RebalancePlan { - const { - outputSlots, - tip, - ickbBalance, - ckbBalance, - depositCapacity, - readyDeposits, - nearReadyDeposits, - futurePoolDeposits, - } = - options; - - if (outputSlots < OUTPUTS_PER_REBALANCE_ACTION) { - return { kind: "none", reason: "insufficient_output_slots" }; - } - - if (ickbBalance < MIN_ICKB_BALANCE) { - if (ckbBalance >= depositCapacity + CKB_RESERVE) { - return { kind: "deposit", quantity: 1 }; - } - return { kind: "none", reason: "low_ickb_ckb_reserve_unavailable" }; - } - - const futureSegment = evaluateFutureSegment( - futurePoolDeposits, - tip, - ickbBalance, - ckbBalance, - depositCapacity, - ); - if (futureSegment.shouldSeed) { - return { - kind: "deposit", - quantity: 1, - ...diagnosticsField(futureSegment.diagnostics), - }; - } - - const excessIckb = ickbBalance - TARGET_ICKB_BALANCE; - if (excessIckb <= 0n) { - return { - kind: "none", - reason: "target_ickb_not_exceeded", - ...diagnosticsField(futureSegment.diagnostics), - }; - } - - const withdrawalLimit = Math.min( - MAX_WITHDRAWAL_REQUESTS, - Math.floor(outputSlots / OUTPUTS_PER_REBALANCE_ACTION), - ); - const cleanup = selectNonStandardCleanupDeposit( - readyDeposits, - tip, - ickbBalance, - ); - if (cleanup) { - return { - kind: "withdraw", - deposits: [cleanup.deposit], - requiredLiveDeposits: [cleanup.requiredLiveDeposit], - ...diagnosticsField(futureSegment.diagnostics), - }; - } - - const selection = selectPoolRebalancingDeposits( - readyDeposits, - nearReadyDeposits, - tip, - excessIckb, - withdrawalLimit, - ); - if (selection.deposits.length > 0) { - return { - kind: "withdraw", - deposits: selection.deposits, - ...diagnosticsField(futureSegment.diagnostics), - ...(selection.requiredLiveDeposits.length > 0 - ? { requiredLiveDeposits: selection.requiredLiveDeposits } - : {}), - }; - } - return { - kind: "none", - reason: "no_ready_withdrawal_selection", - ...diagnosticsField(futureSegment.diagnostics), - }; -} - -function selectPoolRebalancingDeposits( - readyDeposits: readonly IckbDepositCell[], - nearReadyDeposits: readonly IckbDepositCell[], - tip: ccc.ClientBlockHeader, - maxAmount: bigint, - limit: number, -): { - deposits: IckbDepositCell[]; - requiredLiveDeposits: IckbDepositCell[]; -} { - if (maxAmount <= 0n || limit <= 0 || readyDeposits.length === 0) { - return { deposits: [], requiredLiveDeposits: [] }; - } - - return selectReadyWithdrawalDeposits({ - readyDeposits, - nearReadyDeposits, - tip, - maxAmount, - maxCount: limit, - preserveSingletons: !canSpendSingletonAnchors(maxAmount), - }); -} - -function evaluateFutureSegment( - futurePoolDeposits: readonly IckbDepositCell[], - tip: ccc.ClientBlockHeader, - ickbBalance: bigint, - ckbBalance: bigint, - depositCapacity: bigint, -): { shouldSeed: boolean; diagnostics?: RebalanceDiagnostics } { - const futureDeposits = futurePoolDeposits.filter((deposit) => !deposit.isReady); - const canCreate = canCreateFutureInventory(ickbBalance, ckbBalance, depositCapacity); - const futureLayout = analyzeFutureSegments(futureDeposits, tip); - const diagnostics = futureDiagnostics(futureLayout, futureDeposits.length, canCreate); - - if (!canCreate) { - return { shouldSeed: false, diagnostics }; - } - - if (futureDeposits.length === 0) { - return { shouldSeed: true, diagnostics }; - } - - if (futureDeposits.length === 1) { - return { shouldSeed: false, diagnostics }; - } - - if (futureDeposits.length === 2 && !futureLayout.anchorsShareOneSegment) { - return { shouldSeed: false, diagnostics }; - } - - if (futureLayout.totalFutureUdt <= 0n) { - return { shouldSeed: false, diagnostics }; - } - - return { - shouldSeed: isUnderCoveredFutureSegment( - futureLayout.targetSegment.udtValue, - futureLayout.targetSegment.length, - futureLayout.totalFutureUdt, - futureLayout.ringLength, - ), - diagnostics, - }; -} - -function canCreateFutureInventory( - ickbBalance: bigint, - ckbBalance: bigint, - depositCapacity: bigint, -): boolean { - return ( - ickbBalance > MIN_ICKB_BALANCE && - ckbBalance >= depositCapacity + CKB_RESERVE && - ickbBalance + ICKB_DEPOSIT_CAP <= TARGET_ICKB_BALANCE - ); -} - -function selectNonStandardCleanupDeposit( - readyDeposits: readonly IckbDepositCell[], - tip: ccc.ClientBlockHeader, - ickbBalance: bigint, -): ReturnType { - return selectReadyWithdrawalCleanupDeposit({ - readyDeposits, - tip, - minAmountExclusive: ICKB_DEPOSIT_CAP, - maxAmount: ickbBalance - TARGET_ICKB_BALANCE, - }); -} - -function diagnosticsField( - diagnostics: RebalanceDiagnostics | undefined, -): { diagnostics?: RebalanceDiagnostics } { - return diagnostics === undefined ? {} : { diagnostics }; -} - -function futureDiagnostics( - layout: FutureLayout, - futureDepositCount: number, - canCreateFutureInventory: boolean, -): RebalanceDiagnostics { - return { - futurePool: { - futureDepositCount, - canCreateFutureInventory, - shouldBootstrapFirstAnchor: canCreateFutureInventory && futureDepositCount === 0, - ringLength: layout.ringLength, - segmentCount: layout.segmentCount, - targetSegmentIndex: layout.targetSegmentIndex, - targetSegmentLength: layout.targetSegment.length, - targetSegmentUdtValue: layout.targetSegment.udtValue, - totalFutureUdt: layout.totalFutureUdt, - anchorsShareOneSegment: layout.anchorsShareOneSegment, - segments: layout.segments.map((segment) => ({ - index: segment.index, - length: segment.length, - depositCount: segment.deposits.length, - udtValue: segment.udtValue, - isTarget: segment.index === layout.targetSegmentIndex, - })), - }, - }; -} - -function canSpendSingletonAnchors(excessIckb: bigint): boolean { - return excessIckb >= SINGLETON_ANCHOR_OVERRIDE_EXCESS; -} - -// Phase 1 future shaping keeps the current direct-deposit transaction shape, -// but it now uses the historical 180-epoch ring in the smallest honest live -// form: ringLength = tip+180 epochs - tip, origin = absolute unix 0 modulo that -// ring, Q = 2^(ceil(log2(anchorCount))) for 2+ future anchors, and wraparound = -// floor((maturityUnix mod ringLength) * Q / ringLength). Low-count base cases -// stay explicit: 0 bootstraps the first anchor, 1 preserves the lone anchor, -// and 2 only shape if both anchors crowd the same Q=2 segment. -function futureRingLengthForTip(tip: ccc.ClientBlockHeader): bigint { - return tip.epoch.add(FRESH_DEPOSIT_TARGET_EPOCH_OFFSET).toUnix(tip) - - tip.epoch.toUnix(tip); -} - -function analyzeFutureSegments( - futurePoolDeposits: readonly IckbDepositCell[], - tip: ccc.ClientBlockHeader, -): FutureLayout { - const ringLength = futureRingLengthForTip(tip); - const segmentCount = nextPowerOfTwo(futurePoolDeposits.length); - const targetSegmentIndex = futureSegmentIndexForUnix( - tip.epoch.add(FRESH_DEPOSIT_TARGET_EPOCH_OFFSET).toUnix(tip), - ringLength, - segmentCount, - ); - const segments = Array.from({ length: segmentCount }, (_, index) => ({ - index, - length: - futureSegmentBoundary(index + 1, ringLength, segmentCount) - - futureSegmentBoundary(index, ringLength, segmentCount), - deposits: [] as IckbDepositCell[], - udtValue: 0n, - } satisfies FutureSegment)); - - let totalFutureUdt = 0n; - let firstSegmentIndex: number | undefined; - let anchorsShareOneSegment = true; - - for (const deposit of futurePoolDeposits) { - totalFutureUdt += deposit.udtValue; - - const segmentIndex = futureSegmentIndexForUnix( - deposit.maturity.toUnix(tip), - ringLength, - segmentCount, - ); - const segment = segments[segmentIndex]; - if (!segment) { - throw new Error("Expected future segment to exist"); - } - segment.deposits.push(deposit); - segment.udtValue += deposit.udtValue; - - if (firstSegmentIndex === undefined) { - firstSegmentIndex = segmentIndex; - continue; - } - - if (segmentIndex !== firstSegmentIndex) { - anchorsShareOneSegment = false; - } - } - - const targetSegment = segments[targetSegmentIndex]; - if (!targetSegment) { - throw new Error("Expected target future segment to exist"); - } - - return { - ringLength, - segmentCount, - targetSegmentIndex, - targetSegment, - totalFutureUdt, - anchorsShareOneSegment, - segments, - }; -} - -function nextPowerOfTwo(value: number): number { - let power = 1; - while (power < value) { - power *= 2; - } - return power; -} - -function futureSegmentBoundary( - segmentIndex: number, - ringLength: bigint, - segmentCount: number, -): bigint { - return (ringLength * BigInt(segmentIndex)) / BigInt(segmentCount); -} - -function futureSegmentIndexForUnix( - maturityUnix: bigint, - ringLength: bigint, - segmentCount: number, -): number { - const wrappedMaturity = ((maturityUnix % ringLength) + ringLength) % ringLength; - return Number((wrappedMaturity * BigInt(segmentCount)) / ringLength); -} - -function isUnderCoveredFutureSegment( - segmentUdtValue: bigint, - segmentLength: bigint, - totalFutureUdt: bigint, - ringLength: bigint, -): boolean { - return ( - FUTURE_SEGMENT_UNDERCOVERAGE_RATIO_DENOMINATOR * segmentUdtValue * ringLength < - totalFutureUdt * segmentLength - ); -} - -function compareDepositsByMaturity(tip: ccc.ClientBlockHeader) { - return (left: IckbDepositCell, right: IckbDepositCell): number => - compareBigInt(left.maturity.toUnix(tip), right.maturity.toUnix(tip)); -} - -interface FutureLayout { - ringLength: bigint; - segmentCount: number; - targetSegmentIndex: number; - targetSegment: FutureSegment; - totalFutureUdt: bigint; - anchorsShareOneSegment: boolean; - segments: FutureSegment[]; -} - -interface FutureSegment { - index: number; - length: bigint; - deposits: IckbDepositCell[]; - udtValue: bigint; -} diff --git a/apps/bot/src/runtime.ts b/apps/bot/src/runtime.ts deleted file mode 100644 index e780f36..0000000 --- a/apps/bot/src/runtime.ts +++ /dev/null @@ -1,573 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { - convert, - type IckbDepositCell, - type ReceiptCell, - type WithdrawalGroup, -} from "@ickb/core"; -import { - OrderManager, - type Match, - type MatchDiagnostics, - type OrderCell, - type OrderGroup, -} from "@ickb/order"; -import { type getConfig, type IckbSdk, type SystemState } from "@ickb/sdk"; -import { accountPlainCkbBalance, postTransactionAccountPlainCkbBalance, type SupportedChain } from "@ickb/node-utils"; -import { - CKB_RESERVE, - planRebalance, - type RebalanceNoopReason, - type RebalancePlan, -} from "./policy.js"; - -const MATCH_STEP_DIVISOR = 100n; -const MAX_OUTPUTS_BEFORE_CHANGE = 58; - -export interface Runtime { - chain: SupportedChain; - client: ccc.Client; - signer: ccc.SignerCkbPrivateKey; - sdk: IckbSdk; - managers: ReturnType["managers"]; - primaryLock: ccc.Script; -} - -export interface BotState { - accountLocks: ccc.Script[]; - capacityCells: ccc.Cell[]; - system: SystemState; - userOrders: OrderGroup[]; - marketOrders: OrderCell[]; - receipts: ReceiptCell[]; - readyWithdrawals: WithdrawalGroup[]; - notReadyWithdrawals: WithdrawalGroup[]; - readyPoolDeposits: IckbDepositCell[]; - nearReadyPoolDeposits: IckbDepositCell[]; - futurePoolDeposits: IckbDepositCell[]; - availableCkbBalance: bigint; - availableIckbBalance: bigint; - unavailableCkbBalance: bigint; - totalCkbBalance: bigint; - depositCapacity: bigint; - minCkbBalance: bigint; -} - -export interface BotActions { - collectedOrders: number; - completedDeposits: number; - matchedOrders: number; - deposits: number; - withdrawalRequests: number; - withdrawals: number; -} - -export type BuildTransactionSkipReason = - | "no_actions" - | "match_value_not_above_fee" - | "post_tx_ckb_reserve"; - -export type BotDecisionSkipReason = - | BuildTransactionSkipReason - | "capital_below_minimum"; - -export type BotMatchReason = - | "matched" - | "no_market_orders" - | "no_matchable_orders" - | "insufficient_allowance" - | "no_viable_candidates" - | "max_partials" - | "no_positive_gain"; - -export type BotRebalanceReason = - | RebalanceNoopReason - | "low_ickb_balance" - | "future_pool_inventory" - | "excess_ickb_balance"; - -export type BuildTransactionResult = - | { - kind: "built"; - tx: ccc.Transaction; - actions: BotActions; - decision: BotDecisionTranscript; - } - | { - kind: "skipped"; - reason: BuildTransactionSkipReason; - actions: BotActions; - decision: BotDecisionTranscript; - }; - -export interface BotDecisionTranscript { - chainTip: { - blockNumber: bigint; - blockHash: ccc.Hex; - timestamp: bigint; - epoch: { - integer: bigint; - numerator: bigint; - denominator: bigint; - }; - }; - balances: { - availableCkb: bigint; - unavailableCkb: bigint; - totalCkb: bigint; - availableIckb: bigint; - totalEquivalentCkb: bigint; - totalEquivalentIckb: bigint; - minimumCkbCapital: bigint; - spendableCkb: bigint; - }; - orders: { - marketCount: number; - userCount: number; - receiptCount: number; - }; - withdrawals: { - readyCount: number; - pendingCount: number; - }; - poolDeposits: { - readyCount: number; - nearReadyCount: number; - futureCount: number; - }; - match: { - reason: BotMatchReason; - partialCount: number; - ckbDelta: bigint; - udtDelta: bigint; - value?: bigint; - diagnostics?: MatchDiagnostics; - }; - rebalance: { - kind: RebalancePlan["kind"]; - reason: BotRebalanceReason; - depositQuantity?: number; - withdrawalRequestCount?: number; - requiredLiveDepositCount?: number; - diagnostics?: RebalancePlan["diagnostics"]; - outputSlots: number; - projectedAvailableCkb: bigint; - projectedAvailableIckb: bigint; - }; - actions: BotActions; - fee: { - feeRate: ccc.Num; - estimated?: bigint; - }; - transactionShape: { - inputs: number; - outputs: number; - cellDeps: number; - headerDeps: number; - witnesses: number; - }; - exchangeRatio: { - ckbScale: bigint; - udtScale: bigint; - }; - depositCapacity: bigint; - skip?: { - reason: BotDecisionSkipReason; - fee?: bigint; - matchValue?: bigint; - postTxCkbBalance?: bigint; - reserve?: bigint; - deficit?: bigint; - attemptedActions?: BotActions; - }; -} - -export type BotStateSummary = Pick< - BotDecisionTranscript, - | "chainTip" - | "balances" - | "orders" - | "withdrawals" - | "poolDeposits" - | "exchangeRatio" - | "depositCapacity" - | "fee" ->; - -export type { SupportedChain }; - -export async function buildTransaction( - runtime: Runtime, - state: BotState, -): Promise { - const match = OrderManager.bestMatch( - state.marketOrders, - { - ckbValue: spendableCkb(state.availableCkbBalance), - udtValue: state.availableIckbBalance, - }, - state.system.exchangeRatio, - { - feeRate: state.system.feeRate, - ckbAllowanceStep: maxBigInt(1n, state.depositCapacity / MATCH_STEP_DIVISOR), - maxPartials: MAX_OUTPUTS_BEFORE_CHANGE, - }, - ); - let tx = ccc.Transaction.default(); - if (match.partials.length > 0) { - tx = runtime.managers.order.addMatch(tx, match); - } - - const outputSlots = Math.max(0, MAX_OUTPUTS_BEFORE_CHANGE - tx.outputs.length); - const rebalance = planRebalance({ - outputSlots, - tip: state.system.tip, - ickbBalance: state.availableIckbBalance + match.udtDelta, - ckbBalance: state.availableCkbBalance + match.ckbDelta, - depositCapacity: state.depositCapacity, - readyDeposits: state.readyPoolDeposits, - nearReadyDeposits: state.nearReadyPoolDeposits, - futurePoolDeposits: state.futurePoolDeposits, - }); - tx = await runtime.sdk.buildBaseTransaction(tx, runtime.client, { - withdrawalRequest: - rebalance.kind === "withdraw" - ? { - deposits: rebalance.deposits, - requiredLiveDeposits: rebalance.requiredLiveDeposits, - lock: runtime.primaryLock, - } - : undefined, - orders: state.userOrders, - receipts: state.receipts, - readyWithdrawals: state.readyWithdrawals, - }); - if (rebalance.kind === "deposit") { - tx = await runtime.managers.logic.deposit( - tx, - rebalance.quantity, - state.depositCapacity, - runtime.primaryLock, - runtime.client, - ); - } - - const actions: BotActions = { - collectedOrders: state.userOrders.length, - completedDeposits: state.receipts.length, - matchedOrders: match.partials.length, - deposits: - rebalance.kind === "deposit" ? rebalance.quantity : 0, - withdrawalRequests: - rebalance.kind === "withdraw" ? rebalance.deposits.length : 0, - withdrawals: state.readyWithdrawals.length, - }; - const actionCount = actionTotal(actions); - let decision = buildDecisionTranscript({ - state, - match, - rebalance, - outputSlots, - actions, - tx, - }); - if (actionCount === 0) { - return skippedResult("no_actions", actions, decision); - } - - tx = await runtime.sdk.completeTransaction(tx, { - signer: runtime.signer, - client: runtime.client, - feeRate: state.system.feeRate, - }); - const fee = tx.estimateFee(state.system.feeRate); - const preTxCkbBalance = accountPlainCkbBalance(state.capacityCells, state.accountLocks); - const postTxCkbBalance = postTransactionPlainCkbBalance(tx, state); - decision = buildDecisionTranscript({ - state, - match, - rebalance, - outputSlots, - actions, - tx, - }); - decision = { ...decision, fee: { ...decision.fee, estimated: fee } }; - const preTxDeficit = maxBigInt(0n, CKB_RESERVE - preTxCkbBalance); - const postTxDeficit = maxBigInt(0n, CKB_RESERVE - postTxCkbBalance); - // The reserve is actual owned plain CKB, not projected CKB availability. Ready - // withdrawals and collectable orders can make projected CKB look healthy while - // the account still lacks plain cells for state rent and future fees. - // - // Most actions must not create or worsen a plain-CKB reserve deficit. The one - // intentional exception is an iCKB withdrawal request: it spends plain CKB for - // DAO withdrawal-cell rent now so the bot can recover CKB in a later completion - // transaction. Do not let CKB-spending matches or deposits hide behind that - // recovery exception. - const spendsReserveForWithdrawalRequest = actions.withdrawalRequests > 0 && - actions.deposits === 0 && - match.ckbDelta >= 0n; - if (postTxDeficit > preTxDeficit && !spendsReserveForWithdrawalRequest) { - return skippedResult("post_tx_ckb_reserve", emptyActions(), { - ...decision, - actions: emptyActions(), - }, { - attemptedActions: actions, - postTxCkbBalance, - reserve: CKB_RESERVE, - deficit: postTxDeficit, - }); - } - - if (isMatchOnly(actions)) { - const matchValue = - match.ckbDelta * state.system.exchangeRatio.ckbScale + - match.udtDelta * state.system.exchangeRatio.udtScale; - decision = { - ...decision, - match: { ...decision.match, value: matchValue }, - }; - if (matchValue <= fee * state.system.exchangeRatio.ckbScale) { - return skippedResult("match_value_not_above_fee", actions, decision, { - fee, - matchValue, - }); - } - } - - return { kind: "built", tx, actions, decision }; -} - -export function summarizeBotState(state: BotState): BotStateSummary { - return { - chainTip: { - blockNumber: state.system.tip.number, - blockHash: state.system.tip.hash, - timestamp: state.system.tip.timestamp, - epoch: { - integer: state.system.tip.epoch.integer, - numerator: state.system.tip.epoch.numerator, - denominator: state.system.tip.epoch.denominator, - }, - }, - balances: { - availableCkb: state.availableCkbBalance, - unavailableCkb: state.unavailableCkbBalance, - totalCkb: state.totalCkbBalance, - availableIckb: state.availableIckbBalance, - totalEquivalentCkb: state.totalCkbBalance + - convert(false, state.availableIckbBalance, state.system.exchangeRatio), - totalEquivalentIckb: convert(true, state.totalCkbBalance, state.system.exchangeRatio) + - state.availableIckbBalance, - minimumCkbCapital: state.minCkbBalance, - spendableCkb: spendableCkb(state.availableCkbBalance), - }, - orders: { - marketCount: state.marketOrders.length, - userCount: state.userOrders.length, - receiptCount: state.receipts.length, - }, - withdrawals: { - readyCount: state.readyWithdrawals.length, - pendingCount: state.notReadyWithdrawals.length, - }, - poolDeposits: { - readyCount: state.readyPoolDeposits.length, - nearReadyCount: state.nearReadyPoolDeposits.length, - futureCount: state.futurePoolDeposits.length, - }, - exchangeRatio: { - ckbScale: state.system.exchangeRatio.ckbScale, - udtScale: state.system.exchangeRatio.udtScale, - }, - depositCapacity: state.depositCapacity, - fee: { - feeRate: state.system.feeRate, - }, - }; -} - -function isMatchOnly(actions: { - collectedOrders: number; - completedDeposits: number; - matchedOrders: number; - deposits: number; - withdrawalRequests: number; - withdrawals: number; -}): boolean { - return ( - actions.matchedOrders > 0 && - actions.collectedOrders === 0 && - actions.completedDeposits === 0 && - actions.deposits === 0 && - actions.withdrawalRequests === 0 && - actions.withdrawals === 0 - ); -} - -function buildDecisionTranscript({ - state, - match, - rebalance, - outputSlots, - actions, - tx, -}: { - state: BotState; - match: Pick; - rebalance: RebalancePlan; - outputSlots: number; - actions: BotActions; - tx: ccc.Transaction; -}): BotDecisionTranscript { - const summary = summarizeBotState(state); - return { - ...summary, - match: { - reason: matchReason(match, state), - partialCount: match.partials.length, - ckbDelta: match.ckbDelta, - udtDelta: match.udtDelta, - ...(match.diagnostics === undefined ? {} : { diagnostics: match.diagnostics }), - }, - rebalance: rebalanceSummary(rebalance, outputSlots, state, match), - actions, - fee: { - feeRate: state.system.feeRate, - }, - transactionShape: transactionShape(tx), - }; -} - -function rebalanceSummary( - rebalance: RebalancePlan, - outputSlots: number, - state: BotState, - match: { ckbDelta: bigint; udtDelta: bigint }, -): BotDecisionTranscript["rebalance"] { - return { - kind: rebalance.kind, - reason: rebalanceReason(rebalance), - ...(rebalance.kind === "deposit" ? { depositQuantity: rebalance.quantity } : {}), - ...(rebalance.kind === "withdraw" - ? { - withdrawalRequestCount: rebalance.deposits.length, - requiredLiveDepositCount: rebalance.requiredLiveDeposits?.length ?? 0, - } - : {}), - ...(rebalance.diagnostics === undefined ? {} : { diagnostics: rebalance.diagnostics }), - outputSlots, - projectedAvailableCkb: state.availableCkbBalance + match.ckbDelta, - projectedAvailableIckb: state.availableIckbBalance + match.udtDelta, - }; -} - -function matchReason( - match: Pick, - state: BotState, -): BotMatchReason { - if (match.partials.length > 0) { - return "matched"; - } - if (state.marketOrders.length === 0) { - return "no_market_orders"; - } - - const diagnostics = match.diagnostics; - if (diagnostics === undefined) { - return "no_viable_candidates"; - } - if ( - diagnostics.directions.ckbToUdt.matchableCount === 0 && - diagnostics.directions.udtToCkb.matchableCount === 0 - ) { - return "no_matchable_orders"; - } - if (diagnostics.candidates.viable === 0) { - return diagnostics.candidates.rejected.insufficientCkbAllowance > 0 || - diagnostics.candidates.rejected.insufficientUdtAllowance > 0 - ? "insufficient_allowance" - : "no_viable_candidates"; - } - if (diagnostics.candidates.rejected.maxPartials > 0 && diagnostics.candidates.positiveGain === 0) { - return "max_partials"; - } - if (diagnostics.candidates.positiveGain === 0) { - return "no_positive_gain"; - } - return "no_viable_candidates"; -} - -function rebalanceReason(rebalance: RebalancePlan): BotRebalanceReason { - switch (rebalance.kind) { - case "none": - return rebalance.reason; - case "deposit": - return rebalance.diagnostics === undefined - ? "low_ickb_balance" - : "future_pool_inventory"; - case "withdraw": - return "excess_ickb_balance"; - } -} - -export function transactionShape(tx: ccc.Transaction): BotDecisionTranscript["transactionShape"] { - return { - inputs: tx.inputs.length, - outputs: tx.outputs.length, - cellDeps: tx.cellDeps.length, - headerDeps: tx.headerDeps.length, - witnesses: tx.witnesses.length, - }; -} - -export function postTransactionPlainCkbBalance(tx: ccc.Transaction, state: BotState): bigint { - return postTransactionAccountPlainCkbBalance(tx, state.capacityCells, state.accountLocks); -} - -function skippedResult( - reason: BuildTransactionSkipReason, - actions: BotActions, - decision: BotDecisionTranscript, - details?: { fee?: bigint; matchValue?: bigint; postTxCkbBalance?: bigint; reserve?: bigint; deficit?: bigint; attemptedActions?: BotActions }, -): BuildTransactionResult { - return { - kind: "skipped", - reason, - actions, - decision: { - ...decision, - skip: { - reason, - ...details, - }, - }, - }; -} - -function emptyActions(): BotActions { - return { - collectedOrders: 0, - completedDeposits: 0, - matchedOrders: 0, - deposits: 0, - withdrawalRequests: 0, - withdrawals: 0, - }; -} - -function actionTotal(actions: BotActions): number { - return actions.collectedOrders + - actions.completedDeposits + - actions.matchedOrders + - actions.deposits + - actions.withdrawalRequests + - actions.withdrawals; -} - -function spendableCkb(availableCkbBalance: bigint): bigint { - return maxBigInt(0n, availableCkbBalance - CKB_RESERVE); -} - -function maxBigInt(left: bigint, right: bigint): bigint { - return left > right ? left : right; -} diff --git a/apps/bot/src/withdrawal_selection.ts b/apps/bot/src/withdrawal_selection.ts deleted file mode 100644 index c5f4c25..0000000 --- a/apps/bot/src/withdrawal_selection.ts +++ /dev/null @@ -1,808 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { type IckbDepositCell } from "@ickb/core"; -import { compareBigInt } from "@ickb/utils"; - -const READY_POOL_BUCKET_SPAN_MS = 15n * 60n * 1000n; -const NEAR_READY_LOOKAHEAD_MS = 60n * 60n * 1000n; -const NEAR_READY_BUCKET_LOOKAHEAD = NEAR_READY_LOOKAHEAD_MS / READY_POOL_BUCKET_SPAN_MS; -const BEST_FIT_SEARCH_CANDIDATES = 30; -const DEFAULT_MAX_WITHDRAWAL_REQUESTS = 30; - -export interface ReadyWithdrawalSelection { - deposits: IckbDepositCell[]; - requiredLiveDeposits: IckbDepositCell[]; -} - -export interface ReadyWithdrawalCleanupSelection { - deposit: IckbDepositCell; - requiredLiveDeposit: IckbDepositCell; -} - -export interface ReadyWithdrawalSelectionOptions { - readyDeposits: readonly IckbDepositCell[]; - nearReadyDeposits?: readonly IckbDepositCell[]; - tip: ccc.ClientBlockHeader; - maxAmount: bigint; - minCount?: number; - maxCount?: number; - preserveSingletons?: boolean; -} - -export interface ReadyWithdrawalCleanupSelectionOptions { - readyDeposits: readonly IckbDepositCell[]; - tip: ccc.ClientBlockHeader; - minAmountExclusive?: bigint; - maxAmount?: bigint; -} - -type ScoredReadyWithdrawalSelectionOptions = ReadyWithdrawalSelectionOptions & { - score?: (deposit: IckbDepositCell) => bigint; -}; - -type ExactReadyWithdrawalSelectionOptions = Omit< - ReadyWithdrawalSelectionOptions, - "minCount" | "maxCount" -> & { - count: number; -}; - -type ScoredExactReadyWithdrawalSelectionOptions = ExactReadyWithdrawalSelectionOptions & { - score?: (deposit: IckbDepositCell) => bigint; -}; - -export function selectReadyWithdrawalDeposits( - options: ReadyWithdrawalSelectionOptions, -): ReadyWithdrawalSelection { - return selectReadyWithdrawalDepositsWithScore(options); -} - -function selectReadyWithdrawalDepositsWithScore( - options: ScoredReadyWithdrawalSelectionOptions, -): ReadyWithdrawalSelection { - const { - tip, - maxAmount, - readyDeposits, - nearReadyDeposits = [], - minCount = 1, - maxCount = DEFAULT_MAX_WITHDRAWAL_REQUESTS, - preserveSingletons = true, - score, - } = options; - const requiredCount = Math.max(1, minCount); - if ( - maxAmount <= 0n || - maxCount <= 0 || - requiredCount > maxCount || - readyDeposits.length === 0 - ) { - return { deposits: [], requiredLiveDeposits: [] }; - } - - const { extras, singletons, anchorsByExtra } = classifyReadyDeposits( - readyDeposits, - nearReadyDeposits, - tip, - ); - const selectedExtras = selectReadyDeposits(extras, maxAmount, { - maxCount, - minCount: preserveSingletons ? requiredCount : 1, - score, - }); - if (selectedExtras.length > 0) { - if (preserveSingletons) { - return selectionWithRequiredAnchors(selectedExtras, anchorsByExtra); - } - - const remainingAmount = maxAmount - sumUdtValue(selectedExtras); - const remainingCount = maxCount - selectedExtras.length; - const remainingRequiredCount = Math.max(1, requiredCount - selectedExtras.length); - const selectedSingletons = selectReadyDeposits( - singletons, - remainingAmount, - { - maxCount: remainingCount, - minCount: remainingRequiredCount, - score, - }, - ); - if (selectedSingletons.length === 0 && selectedExtras.length >= requiredCount) { - return selectionWithRequiredAnchors(selectedExtras, anchorsByExtra); - } - - const selected = new Set([ - ...selectedExtras, - ...selectedSingletons, - ]); - const selectedDeposits = sortByMaturity(readyDeposits, tip).filter((deposit) => - selected.has(deposit) - ); - if (selectedDeposits.length >= requiredCount) { - return selectionWithRequiredAnchors(selectedDeposits, anchorsByExtra); - } - } - - if (preserveSingletons) { - return { deposits: [], requiredLiveDeposits: [] }; - } - - const selectedSingletons = selectReadyDeposits(singletons, maxAmount, { - maxCount, - minCount: requiredCount, - score, - }); - if (selectedSingletons.length > 0) { - return { deposits: selectedSingletons, requiredLiveDeposits: [] }; - } - - return selectionWithRequiredAnchors( - selectReadyDeposits(sortByMaturity(readyDeposits, tip), maxAmount, { - maxCount, - minCount: requiredCount, - score, - }), - anchorsByExtra, - ); -} - -function selectReadyWithdrawalCandidateWithScore( - options: ScoredExactReadyWithdrawalSelectionOptions, -): ReadyWithdrawalSelection | undefined { - const { count, ...selectionOptions } = options; - const selection = selectReadyWithdrawalDepositsWithScore({ - ...selectionOptions, - minCount: count, - maxCount: count, - }); - - return selection.deposits.length === count ? selection : undefined; -} - -export function selectExactReadyWithdrawalDepositCandidates( - options: ExactReadyWithdrawalSelectionOptions & { - score: (deposit: IckbDepositCell) => bigint; - maturityBucket: (deposit: IckbDepositCell) => bigint; - }, -): ReadyWithdrawalSelection[] { - const selections: ReadyWithdrawalSelection[] = []; - const seen = new Set(); - const indexByDeposit = new Map( - options.readyDeposits.map((deposit, index) => [deposit, index] as const), - ); - const addSelection = (selection: ReadyWithdrawalSelection | undefined): void => { - if (selection === undefined) { - return; - } - - const key = selectionKey(selection.deposits, indexByDeposit); - if (seen.has(key)) { - return; - } - - seen.add(key); - selections.push(selection); - }; - - for (const bucket of uniqueBuckets(options.readyDeposits, options.maturityBucket)) { - const readyDeposits = options.readyDeposits.filter( - (deposit) => options.maturityBucket(deposit) <= bucket, - ); - const baseOptions = { - readyDeposits, - tip: options.tip, - maxAmount: options.maxAmount, - count: options.count, - preserveSingletons: options.preserveSingletons, - }; - addSelection(selectReadyWithdrawalCandidateWithScore({ - ...baseOptions, - score: options.score, - })); - addSelection(selectReadyWithdrawalCandidateWithScore(baseOptions)); - } - - return selections; -} - -export function selectReadyWithdrawalCleanupDeposit( - options: ReadyWithdrawalCleanupSelectionOptions, -): ReadyWithdrawalCleanupSelection | undefined { - const { - readyDeposits, - tip, - minAmountExclusive = 0n, - maxAmount, - } = options; - if (readyDeposits.length === 0 || maxAmount !== undefined && maxAmount <= 0n) { - return undefined; - } - - const { readyExtras } = classifyReadyDeposits(readyDeposits, [], tip); - const cleanup = readyExtras.find( - ({ deposit }) => - deposit.udtValue > minAmountExclusive && - (maxAmount === undefined || deposit.udtValue <= maxAmount), - ); - - return cleanup === undefined - ? undefined - : { deposit: cleanup.deposit, requiredLiveDeposit: cleanup.anchor }; -} - -function selectReadyDeposits( - deposits: readonly T[], - maxAmount: bigint, - options: { - minCount?: number; - maxCount?: number; - score?: (deposit: T) => bigint; - } = {}, -): T[] { - const { minCount = 1, maxCount = DEFAULT_MAX_WITHDRAWAL_REQUESTS, score } = options; - const requiredCount = Math.max(1, minCount); - if ( - maxAmount <= 0n || - maxCount <= 0 || - requiredCount > maxCount || - deposits.length === 0 - ) { - return []; - } - - const bestFit = selectBoundedReadyDepositSubset(deposits, maxAmount, { - candidateLimit: BEST_FIT_SEARCH_CANDIDATES, - minCount: requiredCount, - maxCount, - ...(score ? { score } : {}), - }); - const greedy = selectGreedyDeposits( - deposits, - maxAmount, - maxCount, - requiredCount, - score, - ); - - return pickBetterSelection(deposits, bestFit, greedy, score); -} - -function selectBoundedReadyDepositSubset( - items: readonly T[], - maxAmount: bigint, - options: { - candidateLimit: number; - minCount: number; - maxCount: number; - score?: (item: T) => bigint; - }, -): T[] { - const { candidateLimit, minCount, maxCount } = options; - const scoreOf = options.score ?? ((item: T): bigint => item.udtValue); - const boundedItems = items.slice(0, candidateLimit); - const effectiveMaxCount = Math.min(maxCount, boundedItems.length); - if ( - maxAmount <= 0n || - minCount < 0 || - effectiveMaxCount < minCount || - boundedItems.length === 0 - ) { - return []; - } - - interface PartialSelection { - mask: number; - total: bigint; - score: bigint; - } - - const split = Math.floor(boundedItems.length / 2); - const firstHalf = boundedItems.slice(0, split); - const secondHalf = boundedItems.slice(split); - assertBitmaskSearchSize(firstHalf.length); - assertBitmaskSearchSize(secondHalf.length); - - const enumerate = (half: readonly T[]): PartialSelection[][] => { - const groups = Array.from( - { length: half.length + 1 }, - () => [] as PartialSelection[], - ); - - const search = ( - index: number, - mask: number, - count: number, - total: bigint, - score: bigint, - ): void => { - if (index === half.length) { - const group = groups[count]; - if (group === undefined) { - throw new Error("Bounded subset search count exceeded group bounds"); - } - group.push({ mask, total, score }); - return; - } - - search(index + 1, mask, count, total, score); - - const item = half[index]; - if (item === undefined) { - return; - } - search( - index + 1, - mask | (1 << index), - count + 1, - total + item.udtValue, - score + scoreOf(item), - ); - }; - - search(0, 0, 0, 0n, 0n); - return groups; - }; - - const firstByCount = enumerate(firstHalf); - const secondByCount = enumerate(secondHalf).map((selections) => - prepareSelections(selections, secondHalf.length) - ); - - let best: - | { - firstMask: number; - secondMask: number; - total: bigint; - score: bigint; - } - | undefined; - - for (let firstCount = 0; firstCount <= effectiveMaxCount; firstCount += 1) { - const firstSelections = firstByCount[firstCount] ?? []; - for (const first of firstSelections) { - if (first.total > maxAmount) { - continue; - } - - const minSecondCount = Math.max(0, minCount - firstCount); - const maxSecondCount = effectiveMaxCount - firstCount; - for (let secondCount = minSecondCount; secondCount <= maxSecondCount; secondCount += 1) { - const secondSelections = secondByCount[secondCount] ?? []; - const second = findBestAtOrBelow(secondSelections, maxAmount - first.total); - if (!second) { - continue; - } - - const total = first.total + second.total; - const candidate = { - firstMask: first.mask, - secondMask: second.mask, - total, - score: first.score + second.score, - }; - if (!best || isBetterSelection(candidate, best, firstHalf.length, secondHalf.length)) { - best = candidate; - } - } - } - } - - if (!best) { - return []; - } - - return selectByMasks(firstHalf, best.firstMask).concat( - selectByMasks(secondHalf, best.secondMask), - ); -} - -function assertBitmaskSearchSize(length: number): void { - if (length > 16) { - throw new Error("Bounded subset search supports at most 16 items per half"); - } -} - -function prepareSelections( - selections: { mask: number; total: bigint; score: bigint }[], - length: number, -): { total: bigint; selection: { mask: number; total: bigint; score: bigint } }[] { - selections.sort((left, right) => { - const totalCompare = compareBigInt(left.total, right.total); - if (totalCompare !== 0) { - return totalCompare; - } - - return compareMask(left.mask, right.mask, length); - }); - - const prepared: { total: bigint; selection: { mask: number; total: bigint; score: bigint } }[] = []; - let best: { mask: number; total: bigint; score: bigint } | undefined; - for (const selection of selections) { - if (!best || isBetterPartialSelection(selection, best, length)) { - best = selection; - } - prepared.push({ total: selection.total, selection: best }); - } - - return prepared; -} - -function findBestAtOrBelow( - items: readonly { total: bigint; selection: { mask: number; total: bigint; score: bigint } }[], - limit: bigint, -): { mask: number; total: bigint; score: bigint } | undefined { - let low = 0; - let high = items.length - 1; - let bestIndex = -1; - - while (low <= high) { - const mid = Math.floor((low + high) / 2); - const item = items[mid]; - if (item === undefined) { - break; - } - - if (item.total <= limit) { - bestIndex = mid; - low = mid + 1; - } else { - high = mid - 1; - } - } - - if (bestIndex < 0) { - return undefined; - } - - const item = items[bestIndex]; - if (item === undefined) { - throw new Error("Bounded subset search best index exceeded selection bounds"); - } - return item.selection; -} - -function isBetterPartialSelection( - left: { mask: number; total: bigint; score: bigint }, - right: { mask: number; total: bigint; score: bigint }, - length: number, -): boolean { - if (left.score !== right.score) { - return left.score > right.score; - } - - if (left.total !== right.total) { - return left.total > right.total; - } - - return compareMask(left.mask, right.mask, length) < 0; -} - -function isBetterSelection( - left: { firstMask: number; secondMask: number; total: bigint; score: bigint }, - right: { firstMask: number; secondMask: number; total: bigint; score: bigint }, - firstLength: number, - secondLength: number, -): boolean { - if (left.score !== right.score) { - return left.score > right.score; - } - - if (left.total !== right.total) { - return left.total > right.total; - } - - const firstCompare = compareMask(left.firstMask, right.firstMask, firstLength); - return firstCompare < 0 || - (firstCompare === 0 && compareMask(left.secondMask, right.secondMask, secondLength) < 0); -} - -function selectByMasks(items: readonly T[], mask: number): T[] { - const selected: T[] = []; - for (let i = 0; i < items.length; i += 1) { - if ((mask & (1 << i)) !== 0) { - const item = items[i]; - if (item !== undefined) { - selected.push(item); - } - } - } - return selected; -} - -function compareMask(left: number, right: number, length: number): number { - for (let i = 0; i < length; i += 1) { - const leftHas = (left & (1 << i)) !== 0; - const rightHas = (right & (1 << i)) !== 0; - if (leftHas === rightHas) { - continue; - } - - return leftHas ? -1 : 1; - } - - return 0; -} - -function classifyReadyDeposits( - readyDeposits: readonly IckbDepositCell[], - nearReadyDeposits: readonly IckbDepositCell[], - tip: ccc.ClientBlockHeader, -): { - extras: IckbDepositCell[]; - singletons: IckbDepositCell[]; - anchorsByExtra: Map; - readyExtras: ReadyExtra[]; -} { - const readyBuckets = new Map(); - const nearReadyBucketValues = new Map(); - - for (const deposit of sortByMaturity(readyDeposits, tip)) { - const key = deposit.maturity.toUnix(tip) / READY_POOL_BUCKET_SPAN_MS; - const bucket = readyBuckets.get(key); - if (bucket) { - bucket.push(deposit); - continue; - } - readyBuckets.set(key, [deposit]); - } - - for (const deposit of sortByMaturity(nearReadyDeposits, tip)) { - const key = deposit.maturity.toUnix(tip) / READY_POOL_BUCKET_SPAN_MS; - nearReadyBucketValues.set( - key, - (nearReadyBucketValues.get(key) ?? 0n) + deposit.udtValue, - ); - } - - const crowdedBuckets: ReadyBucket[] = []; - const singletonBuckets: ReadyBucket[] = []; - for (const [key, deposits] of readyBuckets) { - const protectedDeposit = selectProtectedBucketDeposit(deposits); - const totalValue = sumUdtValue(deposits); - const bucket = { - key, - deposits, - protectedDeposit, - extraValue: totalValue - protectedDeposit.udtValue, - futureRefillValue: futureRefillValueForBucket(key, nearReadyBucketValues), - } satisfies ReadyBucket; - - if (deposits.length === 1) { - singletonBuckets.push(bucket); - } else { - crowdedBuckets.push(bucket); - } - } - - crowdedBuckets.sort(compareCrowdedBuckets); - singletonBuckets.sort(compareSingletonBuckets); - - const readyExtras = crowdedBuckets.flatMap((bucket) => - bucket.deposits - .filter((deposit) => deposit !== bucket.protectedDeposit) - .map((deposit) => ({ deposit, anchor: bucket.protectedDeposit })) - ); - - return { - extras: readyExtras.map(({ deposit }) => deposit), - singletons: singletonBuckets.flatMap((bucket) => bucket.deposits), - anchorsByExtra: new Map(readyExtras.map(({ deposit, anchor }) => [deposit, anchor])), - readyExtras, - }; -} - -function selectProtectedBucketDeposit( - deposits: readonly IckbDepositCell[], -): IckbDepositCell { - let protectedDeposit = deposits[0]; - if (!protectedDeposit) { - throw new Error("Expected at least one deposit in bucket"); - } - - for (let index = 1; index < deposits.length; index += 1) { - const deposit = deposits[index]; - if (!deposit) { - throw new Error("Expected bucket deposit to exist"); - } - if (deposit.udtValue >= protectedDeposit.udtValue) { - protectedDeposit = deposit; - } - } - - return protectedDeposit; -} - -function selectionWithRequiredAnchors( - deposits: IckbDepositCell[], - anchorsByExtra: ReadonlyMap, -): ReadyWithdrawalSelection { - const requiredLiveDeposits: IckbDepositCell[] = []; - const seen = new Set(deposits); - for (const deposit of deposits) { - const anchor = anchorsByExtra.get(deposit); - if (!anchor || seen.has(anchor)) { - continue; - } - seen.add(anchor); - requiredLiveDeposits.push(anchor); - } - - return { deposits, requiredLiveDeposits }; -} - -function selectGreedyDeposits( - deposits: readonly T[], - maxAmount: bigint, - maxCount: number, - minCount: number, - score?: (deposit: T) => bigint, -): T[] { - const selected: T[] = []; - const candidates = score - ? [...deposits].sort((left, right) => compareBigInt(score(right), score(left))) - : deposits; - let cumulative = 0n; - - for (const deposit of candidates) { - if (selected.length >= maxCount) { - break; - } - - if (cumulative + deposit.udtValue > maxAmount) { - continue; - } - - cumulative += deposit.udtValue; - selected.push(deposit); - } - - return selected.length >= minCount ? selected : []; -} - -function pickBetterSelection( - deposits: readonly T[], - left: T[], - right: T[], - score?: (deposit: T) => bigint, -): T[] { - if (left.length === 0) { - return right; - } - - if (right.length === 0) { - return left; - } - - if (score) { - const leftScore = sumScore(left, score); - const rightScore = sumScore(right, score); - if (leftScore > rightScore) { - return left; - } - - if (rightScore > leftScore) { - return right; - } - } - - const leftTotal = sumUdtValue(left); - const rightTotal = sumUdtValue(right); - if (leftTotal > rightTotal) { - return left; - } - - if (rightTotal > leftTotal) { - return right; - } - - return compareSelectionOrder(deposits, left, right) <= 0 ? left : right; -} - -function sumScore(deposits: readonly T[], score: (deposit: T) => bigint): bigint { - let total = 0n; - for (const deposit of deposits) { - total += score(deposit); - } - return total; -} - -function uniqueBuckets(items: readonly T[], bucket: (item: T) => bigint): bigint[] { - return [...new Set(items.map(bucket))].sort(compareBigInt); -} - -function selectionKey(items: readonly T[], indexByItem: ReadonlyMap): string { - return items - .map((item) => String(indexByItem.get(item))) - .sort() - .join(","); -} - -function compareSelectionOrder( - deposits: readonly T[], - left: readonly T[], - right: readonly T[], -): number { - const leftSet = new Set(left); - const rightSet = new Set(right); - - for (const deposit of deposits) { - const inLeft = leftSet.has(deposit); - const inRight = rightSet.has(deposit); - if (inLeft === inRight) { - continue; - } - - return inLeft ? -1 : 1; - } - - return 0; -} - -function futureRefillValueForBucket( - bucketKey: bigint, - nearReadyBucketValues: ReadonlyMap, -): bigint { - let total = 0n; - for (let offset = 1n; offset <= NEAR_READY_BUCKET_LOOKAHEAD; offset += 1n) { - total += nearReadyBucketValues.get(bucketKey + offset) ?? 0n; - } - return total; -} - -function sortByMaturity( - deposits: readonly T[], - tip: ccc.ClientBlockHeader, -): T[] { - return [...deposits].sort((left, right) => - compareBigInt(left.maturity.toUnix(tip), right.maturity.toUnix(tip)) - ); -} - -function sumUdtValue(deposits: readonly { udtValue: bigint }[]): bigint { - let total = 0n; - for (const deposit of deposits) { - total += deposit.udtValue; - } - return total; -} - -function compareCrowdedBuckets(left: ReadyBucket, right: ReadyBucket): number { - const extraCompare = compareBigInt(right.extraValue, left.extraValue); - if (extraCompare !== 0) { - return extraCompare; - } - - const refillCompare = compareBigInt( - right.futureRefillValue, - left.futureRefillValue, - ); - if (refillCompare !== 0) { - return refillCompare; - } - - return compareBigInt(left.key, right.key); -} - -function compareSingletonBuckets(left: ReadyBucket, right: ReadyBucket): number { - const refillCompare = compareBigInt( - right.futureRefillValue, - left.futureRefillValue, - ); - if (refillCompare !== 0) { - return refillCompare; - } - - return compareBigInt(left.key, right.key); -} - -interface ReadyBucket { - key: bigint; - deposits: IckbDepositCell[]; - protectedDeposit: IckbDepositCell; - extraValue: bigint; - futureRefillValue: bigint; -} - -interface ReadyExtra { - deposit: IckbDepositCell; - anchor: IckbDepositCell; -} diff --git a/apps/bot/test/index.ts b/apps/bot/test/index.ts new file mode 100644 index 0000000..35fdbbe --- /dev/null +++ b/apps/bot/test/index.ts @@ -0,0 +1,145 @@ +import type { ccc } from "@ckb-ccc/core"; +import { BotEventEmitter, type BotLoopContext } from "@ickb/bot"; +import type { ChainPreflightEvidence } from "@ickb/node-utils"; +import { getConfig, IckbSdk } from "@ickb/sdk"; +import { StubClient } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + initializeBot, + runBotCli, + type BotCliDependencies, +} from "../src/index.ts"; + +const privateKey = `0x${"11".repeat(32)}` as const; +const testnetGenesisHash = `0x${"aa".repeat(32)}` as const; +const testnetPreflightExpected = { + addressPrefix: "ckt", + chain: "testnet", + genesisHash: testnetGenesisHash, + genesisMessage: "aggron-v4", + genesisSource: "test", + networkName: "ckb_testnet", +} as const satisfies ChainPreflightEvidence["expected"]; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("bot CLI runtime wiring", () => { + it("initializes runtime wiring from config and emits startup events", async () => { + const events: unknown[] = []; + const client = new StubClient(); + const config = getConfig("testnet"); + const dependencies = botDependencies({ client, config, events }); + + const context = await initializeBot( + { + BOT_ARTIFACT_REF_PREFIX: "artifacts/slot-00", + BOT_ARTIFACT_ROOT: "log/bot/artifacts/slot-00", + }, + dependencies, + ); + + expect(context.sleepIntervalMs).toBe(60_000); + expect(context.maxIterations).toBe(1); + expect(context.maxRetryableAttempts).toBe(2); + expect(context.runtime.client).toBe(client); + expect(context.runtime.managers).toBe(config.managers); + expect(events).toMatchObject([ + { type: "bot.run.started", runId: "run-1", bounded: true }, + { type: "bot.chain.preflight", runId: "run-1", rpcConfigured: true }, + ]); + }); + + it("runs the loop with the initialized context", async () => { + const runBotLoop = vi.fn(async (): Promise => { + await Promise.resolve(); + }); + const dependencies = botDependencies({ runBotLoop }); + + await runBotCli({}, dependencies); + + expect(runBotLoop).toHaveBeenCalledTimes(1); + }); + + it("uses the default event and SDK factories", async () => { + const config = getConfig("testnet"); + const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const dependencies: Partial = botDependencies({ + config, + runBotLoop: async (context): Promise => { + expect(context.events).toBeInstanceOf(BotEventEmitter); + expect(context.runtime.sdk).toBeInstanceOf(IckbSdk); + expect(context.runtime.managers).toBe(config.managers); + await Promise.resolve(); + }, + }); + delete dependencies.createEvents; + delete dependencies.createSdk; + + await runBotCli({}, dependencies); + + expect(write).toHaveBeenCalledWith( + expect.stringContaining('"type":"bot.run.started"'), + ); + }); +}); + +function botDependencies({ + client = new StubClient(), + config = getConfig("testnet"), + events = [], + runBotLoop = vi.fn(async (): Promise => { + await Promise.resolve(); + }), +}: { + client?: ccc.Client; + config?: ReturnType; + events?: unknown[]; + runBotLoop?: (context: BotLoopContext) => Promise; +} = {}): BotCliDependencies { + return { + createEvents: (context) => + new BotEventEmitter({ + ...context, + write: (event): void => { + events.push(event); + }, + }), + createPublicClient: () => client, + createRunId: () => "run-1", + createSdk: (sdkConfig) => IckbSdk.fromConfig(sdkConfig), + getConfig: () => config, + readBotRuntimeConfig: async (): ReturnType< + BotCliDependencies["readBotRuntimeConfig"] + > => { + await Promise.resolve(); + return { + chain: "testnet", + maxIterations: 1, + maxRetryableAttempts: 2, + privateKey, + rpcUrl: "https://testnet.example", + sleepIntervalMs: 60_000, + }; + }, + runBotLoop, + verifyChainPreflight: async (): Promise => { + await Promise.resolve(); + return chainPreflightEvidence(); + }, + }; +} + +function chainPreflightEvidence(): ChainPreflightEvidence { + return { + chain: testnetPreflightExpected.chain, + expected: testnetPreflightExpected, + matches: { addressPrefix: true, genesisHash: true }, + observed: { + addressPrefix: testnetPreflightExpected.addressPrefix, + genesisHash: testnetGenesisHash, + tip: { hash: `0x${"bb".repeat(32)}`, number: 1n, timestamp: 2n }, + }, + }; +} diff --git a/apps/bot/tsconfig.build.json b/apps/bot/tsconfig.build.json deleted file mode 100644 index 998f832..0000000 --- a/apps/bot/tsconfig.build.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "declarationMap": false, - "sourceRoot": "", - "sourceMap": false - }, - "exclude": ["src/**/*.test.ts"] -} diff --git a/apps/bot/tsconfig.json b/apps/bot/tsconfig.json index 80da643..34b5438 100644 --- a/apps/bot/tsconfig.json +++ b/apps/bot/tsconfig.json @@ -1,10 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": false, - "rootDir": "src", - "outDir": "dist", - "sourceRoot": "../src" + "noEmit": true, + "types": ["node"] }, - "include": ["src"], + "include": ["src", "test"] } diff --git a/apps/bot/vitest.config.mts b/apps/bot/vitest.config.mts index dc6a587..e5f423a 100644 --- a/apps/bot/vitest.config.mts +++ b/apps/bot/vitest.config.mts @@ -2,9 +2,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + include: ["{test,tests}/*.{ts,tsx}"], coverage: { - include: ["src/**/*.ts"], + include: ["src/**/*.{ts,tsx}"], }, }, }); diff --git a/package.json b/package.json index bfee454..4aea78c 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,16 @@ "build": "pnpm -r --filter !./apps/** --filter '!./forks/**' build", "build:all": "pnpm build && pnpm -r --workspace-concurrency=1 --filter './apps/*' build", "audit": "pnpm audit", - "bot:install": "CI=true pnpm --filter @ickb/bot... install --frozen-lockfile", - "bot:build": "pnpm --filter @ickb/bot... build", + "bot:install": "CI=true pnpm --filter @ickb/bot-cli... install --frozen-lockfile", + "bot:check": "pnpm --filter @ickb/bot-cli --filter @ickb/bot exec tsgo --noEmit -p tsconfig.json && pnpm bot:scripts:check", + "bot:scripts:check": "tsgo --noEmit -p scripts/tsconfig.json", "check": "CI=true pnpm check:base", "check:base": "pnpm clean:deep && pnpm install && pnpm audit && pnpm madge && pnpm lint && pnpm build:all && pnpm test:ci", "check:fresh": "rm -f pnpm-lock.yaml && pnpm check", "madge": "madge --circular --extensions ts,tsx --ts-config ./tsconfig.json --exclude '^forks/' packages apps", "test": "vitest", - "test:ci": "vitest run && node --test scripts/*.test.mjs", - "lint": "pnpm -r --filter '!./forks/**' lint", + "test:ci": "vitest run && node --test scripts/*.test.mjs && node scripts/run-node-tests.ts", + "lint": "pnpm -r --filter '!./forks/**' lint && pnpm bot:scripts:check", "clean": "rm -fr dist packages/*/dist apps/*/dist", "clean:deep": "pnpm clean && rm -fr node_modules packages/*/node_modules apps/*/node_modules", "live:generate-config": "node scripts/ickb-generate-config.mjs", @@ -31,6 +32,7 @@ "@eslint/js": "^9.39.3", "@ickb/testkit": "workspace:*", "@microsoft/api-extractor": "^7.58.9", + "@types/node": "catalog:", "@typescript/native-preview": "latest", "@vitest/coverage-v8": "4.1.8", "eslint": "^9.39.3", diff --git a/packages/bot/package.json b/packages/bot/package.json new file mode 100644 index 0000000..ef834a5 --- /dev/null +++ b/packages/bot/package.json @@ -0,0 +1,51 @@ +{ + "name": "@ickb/bot", + "version": "1001.0.0", + "description": "Private iCKB bot core and harness", + "keywords": [ + "ickb", + "ccc", + "ckb", + "blockchain" + ], + "author": "phroi", + "license": "MIT", + "homepage": "https://ickb.org", + "repository": { + "type": "git", + "url": "https://github.com/ickb/stack" + }, + "bugs": { + "url": "https://github.com/ickb/stack/issues" + }, + "sideEffects": false, + "type": "module", + "private": true, + "engines": { + "node": ">=22.19.0" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "test": "vitest", + "test:ci": "vitest run", + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/bot/src packages/bot/test" + }, + "dependencies": { + "@ckb-ccc/core": "catalog:", + "@ickb/core": "workspace:*", + "@ickb/node-utils": "workspace:*", + "@ickb/order": "workspace:*", + "@ickb/sdk": "workspace:*" + }, + "devDependencies": { + "@ickb/testkit": "workspace:*", + "@types/node": "catalog:" + } +} diff --git a/packages/bot/src/bot/failure.ts b/packages/bot/src/bot/failure.ts new file mode 100644 index 0000000..d4b6b6c --- /dev/null +++ b/packages/bot/src/bot/failure.ts @@ -0,0 +1,227 @@ +import { + handleLoopError, + isRetryableCkbStateRaceError, + isRetryableRpcResponseShapeError, + isRetryableRpcTransportError, + STOP_EXIT_CODE, + type RuntimeConfig, +} from "@ickb/node-utils"; +import { errorSummary } from "../observability/error.ts"; +import { isRbfRejectedReason } from "../observability/rbf.ts"; + +interface TransactionConfirmationErrorLike extends Error { + status: unknown; + isTimeout: unknown; + reason: unknown; +} + +export interface FailureHandlingResult { + retryableAttempt: boolean; + retryableAttempts: number; + stopAfterLog: boolean; +} + +interface IterationFailureFields extends Record { + error: Record | string; + retryable: boolean; + terminal: boolean; + retryBudgetExhausted?: boolean; +} + +interface FailureHandlingContext { + events: { + emit: ( + iterationId: number, + type: "bot.iteration.failed", + fields?: Record, + ) => unknown; + }; + maxRetryableAttempts: RuntimeConfig["maxRetryableAttempts"]; +} + +export function handleIterationFailure({ + context, + iterationId, + executionLog, + error, + retryableAttempts, +}: { + context: FailureHandlingContext; + iterationId: number; + executionLog: Record; + error: unknown; + retryableAttempts: number; +}): FailureHandlingResult { + const retryable = isRetryableBotError(error); + const nextRetryableAttempts = retryable ? retryableAttempts + 1 : retryableAttempts; + const failure = retryable + ? iterationFailureEventFields(error, { + retryableAttempts: nextRetryableAttempts, + maxRetryableAttempts: context.maxRetryableAttempts, + }) + : iterationFailureEventFields(error); + context.events.emit(iterationId, "bot.iteration.failed", failure); + return failure.retryable + ? handleRetryableFailure( + executionLog, + failure, + nextRetryableAttempts, + context.maxRetryableAttempts, + ) + : handleNonRetryableFailure(executionLog, error, failure, nextRetryableAttempts); +} + +/** + * Builds public failure fields for bot loop events. + */ +export function iterationFailureEventFields(error: unknown): { + error: Record | string; + retryable: boolean; + terminal: boolean; + retryableAttempts?: number; + maxRetryableAttempts?: number; + retryBudgetExhausted?: boolean; +}; +export function iterationFailureEventFields( + error: unknown, + retryBudget: { + retryableAttempts: number; + maxRetryableAttempts: number | undefined; + }, +): { + error: Record | string; + retryable: boolean; + terminal: boolean; + retryableAttempts: number; + maxRetryableAttempts?: number; + retryBudgetExhausted: boolean; +}; +export function iterationFailureEventFields( + error: unknown, + retryBudget?: { + retryableAttempts: number; + maxRetryableAttempts: number | undefined; + }, +): { + error: Record | string; + retryable: boolean; + terminal: boolean; + retryableAttempts?: number; + maxRetryableAttempts?: number; + retryBudgetExhausted?: boolean; +} { + const retryable = isRetryableBotError(error); + const retryBudgetExhausted = + retryable && + retryBudget !== undefined && + reachedMaxRetryableAttempts( + retryBudget.retryableAttempts, + retryBudget.maxRetryableAttempts, + ); + return { + error: errorSummary(error, { includeStack: !retryable }), + retryable, + terminal: !retryable || retryBudgetExhausted, + ...(retryBudget === undefined + ? {} + : { + retryableAttempts: retryBudget.retryableAttempts, + ...(retryBudget.maxRetryableAttempts === undefined + ? {} + : { maxRetryableAttempts: retryBudget.maxRetryableAttempts }), + retryBudgetExhausted, + }), + }; +} + +function handleRetryableFailure( + executionLog: Record, + failure: IterationFailureFields, + retryableAttempts: number, + maxRetryableAttempts: RuntimeConfig["maxRetryableAttempts"], +): FailureHandlingResult { + Object.assign(executionLog, { error: failure.error }); + if (failure.retryBudgetExhausted === true) { + Object.assign(executionLog, { + error: { + message: "Retryable bot error budget exhausted", + attempts: retryableAttempts, + maxRetryableAttempts, + lastError: failure.error, + }, + }); + process.exitCode = STOP_EXIT_CODE; + return { retryableAttempt: true, retryableAttempts, stopAfterLog: true }; + } + return { retryableAttempt: true, retryableAttempts, stopAfterLog: false }; +} + +function handleNonRetryableFailure( + executionLog: Record, + error: unknown, + failure: IterationFailureFields, + retryableAttempts: number, +): FailureHandlingResult { + let stopAfterLog = handleLoopError(executionLog, error); + const exitCode = nonRetryableTerminalFailureExitCode(failure); + if (!stopAfterLog && exitCode !== undefined) { + process.exitCode = exitCode; + stopAfterLog = true; + } + return { retryableAttempt: false, retryableAttempts, stopAfterLog }; +} + +/** + * Maps a terminal non-retryable failure to the process failure exit code. + */ +export function nonRetryableTerminalFailureExitCode(failure: { + retryable: boolean; + terminal: boolean; +}): 1 | undefined { + return !failure.retryable && failure.terminal ? 1 : undefined; +} + +/** + * Reports whether retryable failures have consumed the configured retry budget. + */ +export function reachedMaxRetryableAttempts( + retryableAttempts: number, + maxRetryableAttempts: number | undefined, +): boolean { + return maxRetryableAttempts !== undefined && retryableAttempts >= maxRetryableAttempts; +} + +/** + * Identifies transient bot failures that can be retried without consuming a terminal iteration. + */ +export function isRetryableBotError(error: unknown): boolean { + return ( + (error instanceof Error && + (error.message === "L1 state scan crossed chain tip; retry with a fresh state" || + isRetryableRpcResponseShapeError(error) || + isRetryableRpcTransportError(error) || + isRetryableRbfConfirmationError(error))) || + isRetryableCkbStateRaceError(error) + ); +} + +function isRetryableRbfConfirmationError(error: Error): boolean { + return ( + isTransactionConfirmationErrorLike(error) && + error.status === "rejected" && + error.isTimeout === false && + typeof error.reason === "string" && + isRbfRejectedReason(error.reason) + ); +} + +function isTransactionConfirmationErrorLike( + error: Error, +): error is TransactionConfirmationErrorLike { + return ( + error.name === "TransactionConfirmationError" && + "status" in error && + "isTimeout" in error && + "reason" in error + ); +} diff --git a/packages/bot/src/bot/loop.ts b/packages/bot/src/bot/loop.ts new file mode 100644 index 0000000..9a2f859 --- /dev/null +++ b/packages/bot/src/bot/loop.ts @@ -0,0 +1,331 @@ +import type { ccc } from "@ckb-ccc/core"; +import { + formatCkb, + logExecution, + randomSleepIntervalMs, + reachedMaxIterations, + sleep, + STOP_EXIT_CODE, +} from "@ickb/node-utils"; +import { sendAndWaitForCommit } from "@ickb/sdk"; +import { + emitDecisionEvents, + lowCapitalSkipDecision, + transactionSummary, + type BotEventEmitter, +} from "../observability/events.ts"; +import { transactionLifecycleEvents } from "../observability/lifecycle.ts"; +import { summarizeBotState } from "../runtime/support.ts"; +import { buildTransaction } from "../runtime/transaction.ts"; +import type { BotState, BuildTransactionResult, Runtime } from "../runtime/types.ts"; +import { handleIterationFailure, isRetryableBotError } from "./failure.ts"; +import { readBotState } from "./state.ts"; + +type ExecutionLog = Record & { + startTime?: string; + balance?: unknown; + ratio?: unknown; + error?: unknown; + actions?: unknown; + txFee?: unknown; + txHash?: unknown; +}; + +type BuiltTransactionResult = Extract; +type BotStateDecision = ReturnType; +type IterationWorkStatus = "completed" | "stopped"; + +interface BotIterationResult { + countsAsTerminalIteration: boolean; + retryableAttempts: number; + shouldStop: boolean; +} + +export interface BotLoopOperations { + buildTransaction: typeof buildTransaction; + logExecution: typeof logExecution; + readBotState: typeof readBotState; + sendAndWaitForCommit: typeof sendAndWaitForCommit; + sleep: typeof sleep; + sleepInterval: typeof randomSleepIntervalMs; +} + +export interface BotLoopContext { + /** Event emitter scoped to this bot run. */ + events: BotEventEmitter; + + /** Runtime clients, signer, SDK, managers, and primary lock. */ + runtime: Runtime; + + /** Delay between loop iterations. */ + sleepIntervalMs: number; + + /** Optional maximum completed loop iterations. */ + maxIterations: number | undefined; + + /** Optional maximum retryable failures before stopping. */ + maxRetryableAttempts: number | undefined; + + /** Optional loop-owned effect overrides. Production callers use the defaults. */ + operations?: Partial; +} + +const defaultBotLoopOperations: BotLoopOperations = { + buildTransaction, + logExecution, + readBotState, + sendAndWaitForCommit, + sleep, + sleepInterval: randomSleepIntervalMs, +}; + +export async function runBotLoop(context: BotLoopContext): Promise { + const operations = { ...defaultBotLoopOperations, ...context.operations }; + let completedIterations = 0; + let retryableAttempts = 0; + let iterationId = 0; + for (;;) { + iterationId += 1; + const result = await runBotIteration( + context, + operations, + iterationId, + retryableAttempts, + ); + retryableAttempts = result.retryableAttempts; + if (result.countsAsTerminalIteration) { + // Retryable failures do not consume bounded iterations; successful and + // terminal non-retryable attempts reset the retry budget. + retryableAttempts = 0; + const completion = completeTerminalIteration( + completedIterations, + context.maxIterations, + ); + completedIterations = completion.completedIterations; + if (result.shouldStop || completion.shouldStop) { + return; + } + } + + if (result.shouldStop) { + return; + } + await operations.sleep(operations.sleepInterval(context.sleepIntervalMs)); + } +} + +async function runBotIteration( + context: BotLoopContext, + operations: BotLoopOperations, + iterationId: number, + retryableAttempts: number, +): Promise { + const executionLog: ExecutionLog = {}; + const startTime = new Date(); + executionLog.startTime = startTime.toLocaleString(); + context.events.emit(iterationId, "bot.iteration.started"); + + try { + const status = await executeBotWork(context, operations, iterationId, executionLog); + operations.logExecution(executionLog, startTime); + return { + countsAsTerminalIteration: status === "completed", + retryableAttempts, + shouldStop: status === "stopped", + }; + } catch (error) { + const failure = handleIterationFailure({ + context, + iterationId, + executionLog, + error, + retryableAttempts, + }); + operations.logExecution(executionLog, startTime); + return { + countsAsTerminalIteration: !failure.retryableAttempt, + retryableAttempts: failure.retryableAttempts, + shouldStop: failure.stopAfterLog, + }; + } +} + +async function executeBotWork( + context: BotLoopContext, + operations: BotLoopOperations, + iterationId: number, + executionLog: ExecutionLog, +): Promise { + const state = await operations.readBotState(context.runtime); + const stateDecision = summarizeBotState(state); + emitBotStateRead(context.events, iterationId, stateDecision); + recordExecutionState(executionLog, state, stateDecision); + + if (stateDecision.balances.totalEquivalentCkb <= state.minCkbBalance) { + stopForLowCapital({ + events: context.events, + iterationId, + executionLog, + state, + stateDecision, + }); + return "stopped"; + } + + const result = await operations.buildTransaction(context.runtime, state); + await emitDecisionEvents(context.events, iterationId, result); + Object.assign(executionLog, { actions: result.actions }); + if (result.kind === "built") { + await sendBuiltTransaction({ + context, + operations, + iterationId, + state, + result, + executionLog, + }); + } + return "completed"; +} + +function emitBotStateRead( + events: BotEventEmitter, + iterationId: number, + stateDecision: BotStateDecision, +): void { + events.emit(iterationId, "bot.state.read", { + chainTip: stateDecision.chainTip, + balances: stateDecision.balances, + orders: stateDecision.orders, + withdrawals: stateDecision.withdrawals, + poolDeposits: stateDecision.poolDeposits, + exchangeRatio: stateDecision.exchangeRatio, + depositCapacity: stateDecision.depositCapacity, + fee: stateDecision.fee, + }); +} + +function recordExecutionState( + executionLog: ExecutionLog, + state: BotState, + stateDecision: BotStateDecision, +): void { + Object.assign(executionLog, { + balance: { + CKB: { + total: formatCkb(state.totalCkbBalance), + available: formatCkb(state.availableCkbBalance), + unavailable: formatCkb(state.unavailableCkbBalance), + }, + ICKB: { + total: formatCkb(state.availableIckbBalance), + available: formatCkb(state.availableIckbBalance), + unavailable: formatCkb(0n), + }, + totalEquivalent: { + CKB: formatCkb(stateDecision.balances.totalEquivalentCkb), + ICKB: formatCkb(stateDecision.balances.totalEquivalentIckb), + }, + }, + ratio: state.system.exchangeRatio, + }); +} + +function stopForLowCapital({ + events, + iterationId, + executionLog, + state, + stateDecision, +}: { + events: BotEventEmitter; + iterationId: number; + executionLog: ExecutionLog; + state: BotState; + stateDecision: BotStateDecision; +}): void { + const skip = lowCapitalSkipDecision(stateDecision); + events.emit(iterationId, "bot.decision.skipped", skip); + Object.assign(executionLog, { + error: `The bot must have more than ${formatCkb( + state.minCkbBalance, + )} CKB worth of capital to be able to operate, shutting down...`, + }); + process.exitCode = STOP_EXIT_CODE; +} + +async function sendBuiltTransaction({ + context, + operations, + iterationId, + state, + result, + executionLog, +}: { + context: BotLoopContext; + operations: BotLoopOperations; + iterationId: number; + state: BotState; + result: BuiltTransactionResult; + executionLog: ExecutionLog; +}): Promise { + const fee = result.tx.estimateFee(state.system.feeRate); + Object.assign(executionLog, { + txFee: { + fee: formatCkb(fee), + feeRate: state.system.feeRate, + }, + }); + const txHash = await operations.sendAndWaitForCommit(context.runtime, result.tx, { + onSent: (sentTxHash) => { + Object.assign(executionLog, { txHash: sentTxHash }); + }, + onLifecycle: emitTransactionLifecycle({ + events: context.events, + iterationId, + tx: result.tx, + fee, + feeRate: state.system.feeRate, + }), + }); + Object.assign(executionLog, { txHash }); +} + +function emitTransactionLifecycle({ + events, + iterationId, + tx, + fee, + feeRate, +}: { + events: BotEventEmitter; + iterationId: number; + tx: ccc.Transaction; + fee: bigint; + feeRate: ccc.Num; +}): (event: Parameters[0]) => void { + return (event) => { + for (const lifecycle of transactionLifecycleEvents(event, isRetryableBotError)) { + events.emit(iterationId, lifecycle.type, { + ...lifecycle.fields, + ...(event.type === "broadcasted" + ? { transaction: transactionSummary(tx, fee, feeRate) } + : {}), + }); + } + }; +} + +/** + * Counts one terminal loop iteration and reports whether the configured iteration limit has been reached. + */ +export function completeTerminalIteration( + completedIterations: number, + maxIterations: number | undefined, +): { completedIterations: number; shouldStop: boolean } { + const nextCompletedIterations = completedIterations + 1; + return { + completedIterations: nextCompletedIterations, + shouldStop: reachedMaxIterations(nextCompletedIterations, maxIterations), + }; +} diff --git a/packages/bot/src/bot/state.ts b/packages/bot/src/bot/state.ts new file mode 100644 index 0000000..7681248 --- /dev/null +++ b/packages/bot/src/bot/state.ts @@ -0,0 +1,65 @@ +import { ccc } from "@ckb-ccc/core"; +import { convert, ICKB_DEPOSIT_CAP } from "@ickb/core"; +import { signerAccountLocks } from "@ickb/node-utils"; +import { projectAccountAvailability } from "@ickb/sdk"; +import { POOL_MAX_LOCK_UP, POOL_MIN_LOCK_UP } from "../policy.ts"; +import type { BotState, Runtime } from "../runtime/types.ts"; + +/** + * Reads bot-owned account state and public market state for one planning attempt. + * + * @remarks Own orders are excluded from the market side, and pool deposits must + * come from the same L1 snapshot used for account projection. + */ +export async function readBotState(runtime: Runtime): Promise { + const accountLocks = await signerAccountLocks(runtime.signer, runtime.primaryLock); + const { system, user, account } = await runtime.sdk.getL1AccountState( + runtime.client, + accountLocks, + { + poolDeposits: { + minLockUp: POOL_MIN_LOCK_UP, + maxLockUp: POOL_MAX_LOCK_UP, + }, + }, + ); + if (system.poolDeposits === undefined) { + throw new Error("L1 account state is missing pool deposit snapshot"); + } + const projection = projectAccountAvailability(account, user.orders, { + collectedOrdersAvailable: true, + }); + const ownedOrderKeys = new Set( + user.orders.map((group) => outPointKey(group.order.cell.outPoint)), + ); + const marketOrders = system.orderPool.filter( + (order) => !ownedOrderKeys.has(outPointKey(order.cell.outPoint)), + ); + + const availableCkbBalance = projection.ckbAvailable; + const availableIckbBalance = projection.ickbAvailable; + const unavailableCkbBalance = projection.ckbPending; + const totalCkbBalance = availableCkbBalance + unavailableCkbBalance; + const depositCapacity = convert(false, ICKB_DEPOSIT_CAP, system.exchangeRatio); + + return { + system, + userOrders: user.orders, + marketOrders, + receipts: account.receipts, + readyWithdrawals: projection.readyWithdrawals, + notReadyWithdrawals: projection.pendingWithdrawals, + poolDeposits: system.poolDeposits.deposits, + readyPoolDeposits: system.poolDeposits.readyDeposits, + availableCkbBalance, + availableIckbBalance, + unavailableCkbBalance, + totalCkbBalance, + depositCapacity, + minCkbBalance: (21n * depositCapacity) / 20n, + }; +} + +function outPointKey(outPoint: ccc.OutPoint): string { + return ccc.hexFrom(outPoint.toBytes()); +} diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts new file mode 100644 index 0000000..efb095d --- /dev/null +++ b/packages/bot/src/index.ts @@ -0,0 +1,22 @@ +import { readRuntimeConfigEnv, type RuntimeConfig } from "@ickb/node-utils"; + +export { + isRetryableBotError, + iterationFailureEventFields, + nonRetryableTerminalFailureExitCode, + reachedMaxRetryableAttempts, +} from "./bot/failure.ts"; +export { completeTerminalIteration, runBotLoop } from "./bot/loop.ts"; +export type { BotLoopContext, BotLoopOperations } from "./bot/loop.ts"; +export { readBotState } from "./bot/state.ts"; +export { BotEventEmitter, createRunId } from "./observability/events.ts"; +export type { Runtime } from "./runtime/types.ts"; + +/** + * Reads bot runtime config from `BOT_CONFIG_FILE`. + */ +export async function readBotRuntimeConfig( + env: NodeJS.ProcessEnv, +): Promise { + return readRuntimeConfigEnv(env["BOT_CONFIG_FILE"], "BOT_CONFIG_FILE"); +} diff --git a/packages/bot/src/observability/artifacts.ts b/packages/bot/src/observability/artifacts.ts new file mode 100644 index 0000000..6354816 --- /dev/null +++ b/packages/bot/src/observability/artifacts.ts @@ -0,0 +1,226 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import fsPromises, { type FileHandle } from "node:fs/promises"; +import pathModule from "node:path"; +import type { RingDiagnostics, RingSegmentDiagnostics } from "../policy/types.ts"; +import { logValue } from "./logValue.ts"; + +const BOT_ARTIFACT_VERSION = 1; +const noFollow = constants.O_NOFOLLOW; +const readArtifactFlags = constants.O_RDONLY | noFollow; +const writeNewArtifactFlags = + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow; + +/** Content-addressed reference to a bot artifact emitted outside the event line. */ +export interface BotArtifactRef { + /** Artifact kind, for example `bot.ringSegments`. */ + kind: string; + + /** Content hash of the canonical artifact payload. */ + hash: string; + + /** Public reference path built from the configured artifact prefix. */ + path: string; +} + +export async function writeBotArtifact(options: { + artifactRefPrefix: string; + artifactRoot: string; + kind: string; + payload: Record; +}): Promise { + const text = `${canonicalJson({ + version: BOT_ARTIFACT_VERSION, + kind: options.kind, + ...options.payload, + })}\n`; + const hash = createHash("sha256").update(text).digest("hex"); + const fileName = `sha256-${hash}.json`; + const kindDir = artifactKindDirectory(options.kind); + const outputDir = pathModule.join(options.artifactRoot, kindDir); + await ensureArtifactDirectory(outputDir); + const path = pathModule.join(outputDir, fileName); + await writeContentAddressedArtifact(path, text, hash); + return { + kind: options.kind, + hash: `sha256:${hash}`, + path: `${options.artifactRefPrefix}/${kindDir}/${fileName}`, + }; +} + +export function ringSegmentsArtifact(ring: RingDiagnostics): Record { + return { + poolDepositCount: ring.poolDepositCount, + ringLength: ring.ringLength, + segmentCount: ring.segmentCount, + totalPoolUdt: ring.totalPoolUdt, + depositsShareOneSegment: ring.depositsShareOneSegment, + segments: ring.segments.map(publicRingSegment), + }; +} + +function publicRingSegment({ + index, + depositCount, + udtValue, + protectedDepositCount, + protectedUdtValue, + protectedOutPoints, + surplusDepositCount, + surplusUdtValue, + surplusOutPoints, +}: RingSegmentDiagnostics): Omit { + return { + index, + depositCount, + udtValue, + protectedDepositCount, + protectedUdtValue, + protectedOutPoints, + surplusDepositCount, + surplusUdtValue, + surplusOutPoints, + }; +} + +function canonicalJson(value: unknown): string { + const logged = logValue(value, new Set()); + return JSON.stringify(logged, sortedJsonKeys(logged)); +} + +function sortedJsonKeys(value: unknown): string[] { + const keys = new Set(); + collectJsonKeys(value, keys); + return [...keys].toSorted((left, right) => left.localeCompare(right)); +} + +function collectJsonKeys(value: unknown, keys: Set): void { + if (Array.isArray(value)) { + for (const entry of value) { + collectJsonKeys(entry, keys); + } + return; + } + if (typeof value !== "object" || value === null) { + return; + } + for (const [key, entry] of Object.entries(value)) { + keys.add(key); + collectJsonKeys(entry, keys); + } +} + +function artifactKindDirectory(kind: string): string { + const scopedKind = kind.startsWith("bot.") ? kind.slice(4) : kind; + return scopedKind.replaceAll(/[^\w-]/gu, "-"); +} + +async function ensureArtifactDirectory(outputDir: string): Promise { + await assertNoSymlinkedArtifactPath(outputDir); + await fsPromises.mkdir(outputDir, { recursive: true, mode: 0o700 }); + await assertNoSymlinkedArtifactPath(outputDir); +} + +async function writeContentAddressedArtifact( + path: string, + text: string, + hash: string, +): Promise { + const tempPath = `${path}.tmp-${process.pid.toString()}-${Date.now().toString()}-${randomUUID()}`; + let handle: FileHandle | undefined; + let tempCreated = false; + let linkingFinalPath = false; + let writeSucceeded = false; + try { + handle = await fsPromises.open(tempPath, writeNewArtifactFlags, 0o600); + tempCreated = true; + await handle.writeFile(text, "utf8"); + await handle.chmod(0o600); + await handle.close(); + handle = undefined; + linkingFinalPath = true; + await fsPromises.link(tempPath, path); + writeSucceeded = true; + } catch (error) { + if (linkingFinalPath && isErrno(error, "EEXIST")) { + await verifyExistingArtifact(path, hash); + writeSucceeded = true; + } else { + throw error; + } + } finally { + await closeArtifactHandle(handle); + if (tempCreated) { + await removeTemporaryArtifact(tempPath, writeSucceeded); + } + } +} + +async function removeTemporaryArtifact( + tempPath: string, + writeSucceeded: boolean, +): Promise { + try { + await fsPromises.rm(tempPath, { force: true }); + } catch (error) { + if (writeSucceeded) { + throw error; + } + } +} + +async function verifyExistingArtifact(path: string, expectedHash: string): Promise { + let handle: FileHandle | undefined; + try { + handle = await fsPromises.open(path, readArtifactFlags); + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error(`Artifact path is not a regular file: ${path}`); + } + const text = await handle.readFile("utf8"); + const actualHash = createHash("sha256").update(text).digest("hex"); + if (actualHash !== expectedHash) { + throw new Error(`Existing artifact does not match its content hash: ${path}`); + } + } finally { + await closeArtifactHandle(handle); + } +} + +async function assertNoSymlinkedArtifactPath(path: string): Promise { + const parsed = pathModule.parse(path); + let current = parsed.root; + const parts = pathModule + .relative(parsed.root, path) + .split(pathModule.sep) + .filter(Boolean); + for (const part of parts) { + current = pathModule.join(current, part); + try { + const stat = await fsPromises.lstat(current); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked artifact path: ${current}`); + } + } catch (error) { + if (isErrno(error, "ENOENT")) { + return; + } + throw error; + } + } +} + +async function closeArtifactHandle(handle: FileHandle | undefined): Promise { + if (handle === undefined) { + return; + } + try { + await handle.close(); + } catch { + // Preserve the write outcome when close fails after the write attempt. + } +} + +function isErrno(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/packages/bot/src/observability/error.ts b/packages/bot/src/observability/error.ts new file mode 100644 index 0000000..bced93c --- /dev/null +++ b/packages/bot/src/observability/error.ts @@ -0,0 +1,147 @@ +import { logValue } from "./logValue.ts"; + +const ERROR_CAUSE_KEY = "cause"; +const ERROR_EXTRA_KEYS = ["txHash", "status", "isTimeout"] as const; +const ERROR_BUILTIN_KEYS = new Set(["name", "message", "stack", ERROR_CAUSE_KEY]); + +/** + * Converts arbitrary thrown values into JSON-safe failure evidence. + * + * @remarks This function records values it is given; callers must keep signing + * material and other secrets out of error objects before they reach logging. + */ +export function errorSummary( + error: unknown, + options: { includeStack?: boolean } = {}, +): Record | string { + return summarizeError(error, new Set(), options.includeStack !== false); +} + +function summarizeError( + error: unknown, + seen: Set, + includeStack: boolean, +): Record | string { + return error instanceof Error + ? summarizeNativeError(error, seen, includeStack, summarizeError) + : summarizeThrownValue(error, seen); +} + +function summarizeNativeError( + error: Error, + seen: Set, + includeStack: boolean, + summarize: ( + error: unknown, + seen: Set, + includeStack: boolean, + ) => Record | string, +): Record { + const circular = trackNativeError(error, seen); + if (circular !== undefined) { + return circular; + } + return { + ...nativeErrorFields(error, seen), + ...(includeStack && error.stack !== undefined + ? { stack: logValue(error.stack, seen) } + : {}), + ...(ERROR_CAUSE_KEY in error + ? { cause: summarizeNativeCause(error.cause, seen, includeStack, summarize) } + : {}), + }; +} + +function summarizeNativeCause( + cause: unknown, + seen: Set, + includeStack: boolean, + summarize: ( + error: unknown, + seen: Set, + includeStack: boolean, + ) => Record | string, +): Record | string { + return summarize(cause, seen, includeStack); +} + +function summarizeThrownValue( + error: unknown, + seen: Set, +): Record | string { + let summary: Record | string; + if (typeof error === "object" && error !== null) { + summary = { + message: "Non-Error object", + details: logValue(error, seen), + }; + } else if (typeof error === "string") { + summary = error; + } else if (isStringifiedThrownValue(error)) { + summary = error.toString(); + } else { + summary = "Empty Error"; + } + return summary; +} + +function trackNativeError( + error: Error, + seen: Set, +): Record | undefined { + if (seen.has(error)) { + return { message: "Circular error reference" }; + } + seen.add(error); + return undefined; +} + +function nativeErrorFields(error: Error, seen: Set): Record { + return { + ...errorOwnProperties(error, seen), + name: error.name, + message: logValue(error.message, seen), + ...errorExtraFields(error, seen), + }; +} + +function errorOwnProperties(error: Error, seen: Set): Record { + const properties: Record = {}; + for (const [key, value] of Object.entries(error)) { + if (!ERROR_BUILTIN_KEYS.has(key)) { + properties[key] = value; + } + } + // CCC/CKB RPC errors carry retry evidence such as code, data, outPoint, + // currentFee, and leastFee as enumerable Error fields. + return logRecord(properties, seen); +} + +function errorExtraFields(error: Error, seen: Set): Record { + const fields: Record = {}; + const entries = Object.entries(error); + for (const key of ERROR_EXTRA_KEYS) { + const entry = entries.find(([entryKey]) => entryKey === key); + if (entry !== undefined) { + fields[key] = logValue(entry[1], seen); + } + } + return fields; +} + +function logRecord( + value: Record, + seen: Set, +): Record { + const logged: Record = {}; + for (const [key, entry] of Object.entries(value)) { + logged[key] = logValue(entry, seen); + } + return logged; +} + +function isStringifiedThrownValue(error: unknown): error is number | boolean | bigint { + return ( + typeof error === "number" || typeof error === "boolean" || typeof error === "bigint" + ); +} diff --git a/packages/bot/src/observability/events.ts b/packages/bot/src/observability/events.ts new file mode 100644 index 0000000..36bef90 --- /dev/null +++ b/packages/bot/src/observability/events.ts @@ -0,0 +1,330 @@ +import type { ccc } from "@ckb-ccc/core"; +import { jsonLogReplacer, writeJsonLine, type SupportedChain } from "@ickb/node-utils"; +import type { RingDiagnostics, RingSegmentDiagnostics } from "../policy/types.ts"; +import { transactionShape } from "../runtime/support.ts"; +import type { + BotActions, + BotDecisionSkipReason, + BotDecisionTranscript, + BotStateSummary, + BuildTransactionResult, +} from "../runtime/types.ts"; +import { + ringSegmentsArtifact, + writeBotArtifact, + type BotArtifactRef, +} from "./artifacts.ts"; +import { logValue } from "./logValue.ts"; + +const BOT_EVENT_VERSION = 1; + +export type BotEventType = + | "bot.run.started" + | "bot.chain.preflight" + | "bot.iteration.started" + | "bot.state.read" + | "bot.match.evaluated" + | "bot.rebalance.evaluated" + | "bot.decision.skipped" + | "bot.transaction.built" + | "bot.transaction.sent" + | "bot.transaction.confirmation" + | "bot.transaction.committed" + | "bot.transaction.failed" + | "bot.iteration.failed"; + +interface BotEventIdentity { + version: typeof BOT_EVENT_VERSION; + app: "bot"; + chain: SupportedChain; + runId: string; + iterationId: number; + timestamp: string; + type: BotEventType; +} + +export type BotEvent = BotEventIdentity & Record; +export type { BotArtifactRef } from "./artifacts.ts"; + +/** + * Emits versioned bot events as JSON-safe records. + */ +export class BotEventEmitter { + private readonly context: { + chain: SupportedChain; + artifactRefPrefix?: string; + artifactRoot?: string; + runId: string; + write?: (event: BotEvent) => void; + }; + + constructor(context: { + chain: SupportedChain; + artifactRefPrefix?: string; + artifactRoot?: string; + runId: string; + write?: (event: BotEvent) => void; + }) { + this.context = context; + } + + public emit( + iterationId: number, + type: BotEventType, + fields: Record = {}, + ): BotEvent { + const event: BotEvent = { + ...jsonSafeEventFields(fields), + version: BOT_EVENT_VERSION, + app: "bot", + chain: this.context.chain, + runId: this.context.runId, + iterationId, + timestamp: new Date().toISOString(), + type, + }; + (this.context.write ?? writeJsonLine)(event); + return event; + } + + /** + * Writes a content-addressed artifact and returns its public reference. + * + * @returns `undefined` when artifact output is not configured. + */ + public async writeArtifact( + kind: string, + payload: Record, + ): Promise { + const { artifactRoot, artifactRefPrefix } = this.context; + if (artifactRoot === undefined || artifactRefPrefix === undefined) { + return undefined; + } + return writeBotArtifact({ + artifactRefPrefix, + artifactRoot, + kind, + payload, + }); + } +} + +export function createRunId(): string { + return `${new Date().toISOString()}-${process.pid.toString(36)}`; +} + +/** + * Emits the public decision events for a build result. + * + * @remarks Full ring diagnostics are kept in evaluation events but stripped + * from the embedded final decision transcript to keep terminal events bounded. + */ +export async function emitDecisionEvents( + emitter: BotEventEmitter, + iterationId: number, + result: BuildTransactionResult, +): Promise { + const { decision } = result; + const finalDecision = finalDecisionTranscript(decision); + emitter.emit(iterationId, "bot.match.evaluated", { + match: decision.match, + orders: decision.orders, + }); + const rebalance = await artifactedRebalance(decision.rebalance, emitter); + emitter.emit(iterationId, "bot.rebalance.evaluated", { + rebalance, + poolDeposits: decision.poolDeposits, + }); + + if (result.kind === "skipped") { + emitter.emit(iterationId, "bot.decision.skipped", { + reason: result.reason, + actions: result.actions, + decision: finalDecision, + }); + return; + } + + emitter.emit(iterationId, "bot.transaction.built", { + actions: result.actions, + fee: decision.fee, + transactionShape: decision.transactionShape, + decision: finalDecision, + }); +} + +export function transactionSummary( + tx: ccc.Transaction, + fee: bigint, + feeRate: ccc.Num, +): Record { + return { + fee, + feeRate, + shape: transactionShape(tx), + }; +} + +export function lowCapitalSkipDecision(summary: BotStateSummary): { + reason: BotDecisionSkipReason; + actions: BotActions; + state: BotStateSummary; + deficit: bigint; +} { + return { + reason: "capital_below_minimum", + actions: emptyActions(), + state: summary, + deficit: summary.balances.minimumCkbCapital - summary.balances.totalEquivalentCkb, + }; +} + +function finalDecisionTranscript(decision: BotDecisionTranscript): BotDecisionTranscript { + if (decision.rebalance.diagnostics === undefined) { + return decision; + } + const rebalance = { ...decision.rebalance }; + delete rebalance.diagnostics; + return { + ...decision, + rebalance, + }; +} + +async function artifactedRebalance( + rebalance: BotDecisionTranscript["rebalance"], + emitter: BotEventEmitter, +): Promise> { + const ring = rebalance.diagnostics?.ring; + if (ring === undefined || ring.segments.length === 0) { + return { ...rebalance }; + } + let segmentsRef: BotArtifactRef | undefined; + try { + segmentsRef = await emitter.writeArtifact("bot.ringSegments", { + ring: ringSegmentsArtifact(ring), + }); + } catch (error) { + return { + ...rebalance, + diagnostics: { + ...rebalance.diagnostics, + ring: { + ...ring, + artifactWriteFailed: publicErrorMessage(error), + }, + }, + }; + } + if (segmentsRef === undefined) { + return { ...rebalance }; + } + return { + ...rebalance, + diagnostics: { + ...rebalance.diagnostics, + ring: compactRingDiagnostics(ring, segmentsRef), + }, + }; +} + +function compactRingDiagnostics( + ring: RingDiagnostics, + segmentsRef: BotArtifactRef, +): Omit & Record { + const stats = ringSegmentStats(ring.segments); + return { + poolDepositCount: ring.poolDepositCount, + canCreateRingInventory: ring.canCreateRingInventory, + shouldBootstrapRing: ring.shouldBootstrapRing, + ringLength: ring.ringLength, + segmentCount: ring.segmentCount, + targetSegmentIndex: ring.targetSegmentIndex, + targetSegmentUdtValue: ring.targetSegmentUdtValue, + totalPoolUdt: ring.totalPoolUdt, + depositsShareOneSegment: ring.depositsShareOneSegment, + emptySegmentCount: stats.emptySegmentCount, + nonemptySegmentCount: ring.segments.length - stats.emptySegmentCount, + protectedDepositCount: stats.protectedDepositCount, + protectedUdtValue: stats.protectedUdtValue, + surplusDepositCount: stats.surplusDepositCount, + surplusUdtValue: stats.surplusUdtValue, + heaviestSegmentIndex: stats.heaviest.index, + heaviestSegmentDepositCount: stats.heaviest.depositCount, + heaviestSegmentUdtValue: stats.heaviest.udtValue, + segmentsRef, + }; +} + +function ringSegmentStats(segments: RingSegmentDiagnostics[]): { + emptySegmentCount: number; + heaviest: RingSegmentDiagnostics; + protectedDepositCount: number; + protectedUdtValue: bigint; + surplusDepositCount: number; + surplusUdtValue: bigint; +} { + let emptySegmentCount = 0; + const heaviest = segments.reduce( + (best, segment) => (segment.udtValue > best.udtValue ? segment : best), + { + index: 0, + depositCount: 0, + udtValue: -1n, + isTarget: false, + protectedDepositCount: 0, + protectedUdtValue: 0n, + protectedOutPoints: [], + surplusDepositCount: 0, + surplusUdtValue: 0n, + surplusOutPoints: [], + }, + ); + let protectedDepositCount = 0; + let protectedUdtValue = 0n; + let surplusDepositCount = 0; + let surplusUdtValue = 0n; + for (const segment of segments) { + emptySegmentCount += segment.depositCount === 0 ? 1 : 0; + protectedDepositCount += segment.protectedDepositCount; + protectedUdtValue += segment.protectedUdtValue; + surplusDepositCount += segment.surplusDepositCount; + surplusUdtValue += segment.surplusUdtValue; + } + return { + emptySegmentCount, + heaviest, + protectedDepositCount, + protectedUdtValue, + surplusDepositCount, + surplusUdtValue, + }; +} + +function emptyActions(): BotActions { + return { + collectedOrders: 0, + completedDeposits: 0, + matchedOrders: 0, + deposits: 0, + withdrawalRequests: 0, + withdrawals: 0, + }; +} + +function jsonSafeEventFields(fields: Record): Record { + return parsedRecord( + JSON.parse(JSON.stringify(logValue(fields, new Set()), jsonLogReplacer)), + ); +} + +function publicErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown artifact write error"; +} + +function parsedRecord(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + return Object.fromEntries(Object.entries(value)); +} diff --git a/packages/bot/src/observability/lifecycle.ts b/packages/bot/src/observability/lifecycle.ts new file mode 100644 index 0000000..d81cf3a --- /dev/null +++ b/packages/bot/src/observability/lifecycle.ts @@ -0,0 +1,142 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { SendAndWaitForCommitEvent } from "@ickb/sdk"; +import { errorSummary } from "./error.ts"; +import { isRbfRejectedReason } from "./rbf.ts"; + +const BOT_TRANSACTION_CONFIRMATION = "bot.transaction.confirmation"; +const BOT_TRANSACTION_FAILED = "bot.transaction.failed"; + +interface TransactionLifecycleRecord { + type: + | "bot.transaction.sent" + | "bot.transaction.confirmation" + | "bot.transaction.committed" + | "bot.transaction.failed"; + fields: Record; +} + +type ConfirmationLifecycleEvent = Extract< + SendAndWaitForCommitEvent, + { txHash: ccc.Hex; checks: number } +>; + +/** + * Converts SDK transaction lifecycle events into bot event records. + */ +export function transactionLifecycleEvents( + event: SendAndWaitForCommitEvent, + isRetryableError: (error: unknown) => boolean = (): boolean => false, +): TransactionLifecycleRecord[] { + switch (event.type) { + case "broadcasted": + return [broadcastedLifecycleEvent(event)]; + case "committed": + return committedLifecycleEvents(event); + case "timeout_after_broadcast": + case "post_broadcast_unresolved": + return terminalFailureLifecycleEvents(event, event.type); + case "terminal_rejection": + return terminalFailureLifecycleEvents(event, "terminal_rejection"); + case "pre_broadcast_failed": + return [preBroadcastFailedLifecycleEvent(event, isRetryableError)]; + } +} + +function broadcastedLifecycleEvent( + event: Extract, +): TransactionLifecycleRecord { + return { + type: "bot.transaction.sent", + fields: { + txHash: event.txHash, + phase: "broadcast", + outcome: "broadcasted", + elapsedMs: event.elapsedMs, + }, + }; +} + +function committedLifecycleEvents( + event: ConfirmationLifecycleEvent, +): TransactionLifecycleRecord[] { + return [ + { + type: BOT_TRANSACTION_CONFIRMATION, + fields: terminalConfirmationFields(event, "committed"), + }, + { + type: "bot.transaction.committed", + fields: { ...confirmationFields(event), outcome: "committed" }, + }, + ]; +} + +function terminalFailureLifecycleEvents( + event: ConfirmationLifecycleEvent, + outcome: string, +): TransactionLifecycleRecord[] { + return [ + { + type: BOT_TRANSACTION_CONFIRMATION, + fields: terminalConfirmationFields(event, outcome), + }, + { + type: BOT_TRANSACTION_FAILED, + fields: terminalConfirmationFields(event, outcome), + }, + ]; +} + +function preBroadcastFailedLifecycleEvent( + event: Extract, + isRetryableError: (error: unknown) => boolean, +): TransactionLifecycleRecord { + const retryable = isRetryableError(event.error); + return { + type: BOT_TRANSACTION_FAILED, + fields: { + phase: "pre_broadcast", + outcome: "pre_broadcast_failed", + elapsedMs: event.elapsedMs, + error: errorSummary(event.error, { includeStack: !retryable }), + retryable, + terminal: !retryable, + }, + }; +} + +function terminalConfirmationFields( + event: ConfirmationLifecycleEvent, + outcome: string, +): Record { + const retryable = isRbfRejectedConfirmation(event); + return { + ...confirmationFields(event), + outcome, + retryable, + terminal: !retryable, + }; +} + +function isRbfRejectedConfirmation(event: ConfirmationLifecycleEvent): boolean { + return ( + event.status === "rejected" && + "reason" in event && + typeof event.reason === "string" && + isRbfRejectedReason(event.reason) + ); +} + +function confirmationFields( + event: Extract, +): Record { + return { + phase: "confirmation", + txHash: event.txHash, + status: event.status, + ...("reason" in event ? { reason: event.reason } : {}), + checks: event.checks, + elapsedMs: event.elapsedMs, + ...("error" in event ? { error: errorSummary(event.error) } : {}), + }; +} diff --git a/packages/bot/src/observability/logValue.ts b/packages/bot/src/observability/logValue.ts new file mode 100644 index 0000000..003d19e --- /dev/null +++ b/packages/bot/src/observability/logValue.ts @@ -0,0 +1,62 @@ +const UNSUPPORTED_LOG_VALUE = "[Unsupported log value]"; + +type ImmediateLogValue = + { handled: true; value: LogValue } | { handled: false; value: object }; +export type LogValue = + | string + | number + | boolean + | bigint + | symbol + | null + | undefined + | Record + | unknown[]; + +export function logValue(value: unknown, seen: Set): LogValue { + const immediate = immediateLogValue(value); + let logged: LogValue; + if (immediate.handled) { + logged = immediate.value; + } else if (immediate.value instanceof Date) { + logged = Number.isNaN(immediate.value.getTime()) + ? null + : immediate.value.toISOString(); + } else if (seen.has(immediate.value)) { + logged = "[Circular]"; + } else { + seen.add(immediate.value); + try { + logged = Array.isArray(immediate.value) + ? immediate.value.map((entry) => logValue(entry, seen)) + : Object.fromEntries( + Object.entries(immediate.value).map(([key, entry]) => [ + key, + logValue(entry, seen), + ]), + ); + } finally { + seen.delete(immediate.value); + } + } + return logged; +} + +function immediateLogValue(value: unknown): ImmediateLogValue { + if (typeof value === "bigint") { + return { handled: true, value: value.toString() }; + } + if (typeof value === "function") { + return { handled: true, value: UNSUPPORTED_LOG_VALUE }; + } + if (isImmediateLogPrimitive(value)) { + return { handled: true, value }; + } + return { handled: false, value }; +} + +function isImmediateLogPrimitive( + value: unknown, +): value is string | number | boolean | symbol | null | undefined { + return typeof value !== "object" || value === null; +} diff --git a/packages/bot/src/observability/rbf.ts b/packages/bot/src/observability/rbf.ts new file mode 100644 index 0000000..25cfc5f --- /dev/null +++ b/packages/bot/src/observability/rbf.ts @@ -0,0 +1,15 @@ +export function isRbfRejectedReason(reason: string): boolean { + try { + return parsedReasonIsRbfRejected(JSON.parse(reason)); + } catch { + return false; + } +} + +function parsedReasonIsRbfRejected(value: unknown): boolean { + return isRecord(value) && value["type"] === "RBFRejected"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/bot/src/policy.ts b/packages/bot/src/policy.ts new file mode 100644 index 0000000..71b97a1 --- /dev/null +++ b/packages/bot/src/policy.ts @@ -0,0 +1,80 @@ +import { CKB_RESERVE } from "./policy/constants.ts"; +import { canFundDirectDeposit, evaluateRingCoverage } from "./policy/ring.ts"; +import type { PlanRebalanceOptions, RebalancePlan } from "./policy/types.ts"; +import { planRebalanceWithdrawal } from "./policy/withdrawal.ts"; + +export { + CKB, + CKB_RESERVE, + POOL_MAX_LOCK_UP, + POOL_MIN_LOCK_UP, +} from "./policy/constants.ts"; +export type { + PlanRebalanceOptions, + RebalanceDiagnostics, + RebalancePlan, + RingSegmentDiagnostics, +} from "./policy/types.ts"; + +/** + * Chooses at most one deposit or withdrawal-request action for bot inventory and reserve policy. + * + * @remarks Reserve recovery can withdraw any ready deposit when ring-surplus + * selection cannot recover CKB; normal excess withdrawals preserve live ring + * anchors. + */ +export function planRebalance(options: PlanRebalanceOptions): RebalancePlan { + const { + outputSlots, + tip, + ickbBalance, + ckbBalance, + directDepositCapacity, + directDepositFeeHeadroom = 0n, + ickbRefillThreshold = 0n, + ckbRecoveryThreshold = CKB_RESERVE, + poolDeposits, + readyDeposits, + } = options; + + if (outputSlots < 2) { + return { kind: "none", reason: "insufficient_output_slots" }; + } + + const needsIckbRefill = ickbBalance < ickbRefillThreshold; + if ( + needsIckbRefill && + canFundDirectDeposit(ckbBalance, directDepositCapacity, directDepositFeeHeadroom) + ) { + return { kind: "deposit", reason: "low_ickb_balance", quantity: 1 }; + } + + const ringCoverage = evaluateRingCoverage({ + poolDeposits, + tip, + ickbBalance, + ckbBalance, + directDepositCapacity, + directDepositFeeHeadroom, + ickbRefillThreshold, + }); + if (ringCoverage.canSeed) { + return { + kind: "deposit", + reason: "ring_inventory", + quantity: 1, + diagnostics: ringCoverage.diagnostics, + }; + } + return planRebalanceWithdrawal({ + outputSlots, + tip, + ickbBalance, + ckbBalance, + ickbRefillThreshold, + ckbRecoveryThreshold, + poolDeposits, + readyDeposits, + diagnostics: ringCoverage.diagnostics, + }); +} diff --git a/packages/bot/src/policy/constants.ts b/packages/bot/src/policy/constants.ts new file mode 100644 index 0000000..489f658 --- /dev/null +++ b/packages/bot/src/policy/constants.ts @@ -0,0 +1,6 @@ +import { ccc } from "@ckb-ccc/core"; + +export const CKB = ccc.fixedPointFrom(1); +export const CKB_RESERVE = 1000n * CKB; +export const POOL_MIN_LOCK_UP = ccc.Epoch.from([0n, 1n, 16n]); +export const POOL_MAX_LOCK_UP = ccc.Epoch.from([0n, 4n, 16n]); diff --git a/packages/bot/src/policy/ring.ts b/packages/bot/src/policy/ring.ts new file mode 100644 index 0000000..ea09b2b --- /dev/null +++ b/packages/bot/src/policy/ring.ts @@ -0,0 +1,220 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell } from "@ickb/core"; +import { ringSegmentAnchor, ringSegments, ringTargetSegmentIndex } from "@ickb/sdk"; +import { CKB_RESERVE } from "./constants.ts"; +import type { RebalanceDiagnostics, RingSegmentDiagnostics } from "./types.ts"; + +const RING_LENGTH_EPOCHS = 180n; +const RING_SEGMENT_UNDERCOVERAGE_RATIO_DENOMINATOR = 2n; + +/** + * Evaluates whether the public pool ring needs a seed deposit in the target segment. + * + * @remarks Ring coverage uses the full public pool snapshot, not just ready + * deposits, so seeding and surplus decisions preserve the DAO-cycle inventory shape. + */ +export function evaluateRingCoverage({ + poolDeposits, + tip, + ickbBalance, + ckbBalance, + directDepositCapacity, + directDepositFeeHeadroom, + ickbRefillThreshold, +}: { + poolDeposits: readonly IckbDepositCell[]; + tip: ccc.ClientBlockHeader; + ickbBalance: bigint; + ckbBalance: bigint; + directDepositCapacity: bigint; + directDepositFeeHeadroom: bigint; + ickbRefillThreshold: bigint; +}): { + canSeed: boolean; + diagnostics: RebalanceDiagnostics; +} { + const canCreate = canCreateRingInventory({ + ickbBalance, + ckbBalance, + directDepositCapacity, + directDepositFeeHeadroom, + ickbRefillThreshold, + }); + const layout = analyzeRingSegments(poolDeposits, tip); + const diagnostics = ringDiagnostics(layout, poolDeposits.length, canCreate); + const result = ( + needsSeed: boolean, + ): { canSeed: boolean; diagnostics: RebalanceDiagnostics } => ({ + canSeed: needsSeed && canCreate, + diagnostics, + }); + + if (poolDeposits.length === 0) { + return result(true); + } + if (layout.totalPoolUdt <= 0n) { + return result(false); + } + return result( + isUnderCoveredRingSegment( + layout.targetSegment.udtValue, + layout.segmentCount, + layout.totalPoolUdt, + ), + ); +} + +function canCreateRingInventory({ + ickbBalance, + ckbBalance, + directDepositCapacity, + directDepositFeeHeadroom, + ickbRefillThreshold, +}: { + ickbBalance: bigint; + ckbBalance: bigint; + directDepositCapacity: bigint; + directDepositFeeHeadroom: bigint; + ickbRefillThreshold: bigint; +}): boolean { + return ( + ickbBalance >= ickbRefillThreshold && + canFundDirectDeposit(ckbBalance, directDepositCapacity, directDepositFeeHeadroom) + ); +} + +/** Returns true when available CKB can fund one direct deposit while preserving reserve. */ +export function canFundDirectDeposit( + ckbBalance: bigint, + directDepositCapacity: bigint, + directDepositFeeHeadroom: bigint, +): boolean { + return ckbBalance >= directDepositCapacity + directDepositFeeHeadroom + CKB_RESERVE; +} + +// BEFORE EDITING, STOP AND PROVE, LOCAL SAFETY IS NOT ENOUGH: +// - OWNER: shared SDK ring surplus filter. +// - INVARIANT: bot rebalancing follows full-pool ring buckets, while 15-minute ready buckets only rank already-valid candidates. +// - FAILURE MODE: local ready-bucket protection can block ring surplus withdrawals and leave the bot unable to recover CKB reserve. +function ringDiagnostics( + layout: RingLayout, + poolDepositCount: number, + canCreateRingInventoryResult: boolean, +): RebalanceDiagnostics { + return { + ring: { + poolDepositCount, + canCreateRingInventory: canCreateRingInventoryResult, + shouldBootstrapRing: canCreateRingInventoryResult && poolDepositCount === 0, + ringLength: layout.ringLength, + segmentCount: layout.segmentCount, + targetSegmentIndex: layout.targetSegmentIndex, + targetSegmentUdtValue: layout.targetSegment.udtValue, + totalPoolUdt: layout.totalPoolUdt, + depositsShareOneSegment: layout.depositsShareOneSegment, + segments: layout.segments.map((segment) => + ringSegmentDiagnostics(segment, layout.targetSegmentIndex), + ), + }, + }; +} + +function ringSegmentDiagnostics( + segment: RingPolicySegment, + targetSegmentIndex: number, +): RingSegmentDiagnostics { + const protectedDeposit = ringSegmentAnchor(segment.deposits); + const protectedKey = + protectedDeposit === undefined ? undefined : depositOutPoint(protectedDeposit); + const surplusDeposits = segment.deposits.filter( + (deposit) => depositOutPoint(deposit) !== protectedKey, + ); + const protectedUdtValue = protectedDeposit?.udtValue ?? 0n; + const surplusUdtValue = surplusDeposits.reduce( + (total, deposit) => total + deposit.udtValue, + 0n, + ); + + return { + index: segment.index, + depositCount: segment.deposits.length, + udtValue: segment.udtValue, + isTarget: segment.index === targetSegmentIndex, + protectedDepositCount: protectedDeposit === undefined ? 0 : 1, + protectedUdtValue, + protectedOutPoints: protectedKey === undefined ? [] : [protectedKey], + surplusDepositCount: surplusDeposits.length, + surplusUdtValue, + surplusOutPoints: surplusDeposits.map(depositOutPoint), + }; +} + +function depositOutPoint(deposit: IckbDepositCell): string { + return deposit.cell.outPoint.toHex(); +} + +function analyzeRingSegments( + poolDeposits: readonly IckbDepositCell[], + tip: ccc.ClientBlockHeader, +): RingLayout { + const segments = ringSegments(poolDeposits); + const segmentCount = segments.length; + const targetSegmentIndex = ringTargetSegmentIndex(tip, segmentCount); + let totalUdt = 0n; + let firstSegmentIndex: number | undefined; + let depositsShareOneSegment = true; + + for (const segment of segments) { + totalUdt += segment.udtValue; + if (segment.deposits.length > 0) { + if (firstSegmentIndex === undefined) { + firstSegmentIndex = segment.index; + } else { + depositsShareOneSegment = false; + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- ringTargetSegmentIndex returns 0 <= index < segmentCount, and ringSegments always returns at least one segment. + const targetSegment = segments[targetSegmentIndex]!; + return { + ringLength: RING_LENGTH_EPOCHS, + segmentCount, + targetSegmentIndex, + targetSegment, + totalPoolUdt: totalUdt, + depositsShareOneSegment, + segments, + }; +} + +function isUnderCoveredRingSegment( + segmentUdtValue: bigint, + segmentCount: number, + totalRingUdt: bigint, +): boolean { + // The target segment is under-covered when it holds less than half of its + // equal-share iCKB coverage across the current ring partition. + return ( + RING_SEGMENT_UNDERCOVERAGE_RATIO_DENOMINATOR * + segmentUdtValue * + BigInt(segmentCount) < + totalRingUdt + ); +} + +interface RingLayout { + ringLength: bigint; + segmentCount: number; + targetSegmentIndex: number; + targetSegment: RingPolicySegment; + totalPoolUdt: bigint; + depositsShareOneSegment: boolean; + segments: RingPolicySegment[]; +} + +interface RingPolicySegment { + index: number; + deposits: IckbDepositCell[]; + udtValue: bigint; +} diff --git a/packages/bot/src/policy/types.ts b/packages/bot/src/policy/types.ts new file mode 100644 index 0000000..6b59ec2 --- /dev/null +++ b/packages/bot/src/policy/types.ts @@ -0,0 +1,102 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell } from "@ickb/core"; + +/** Inputs for choosing the bot's post-match rebalance action. */ +export interface PlanRebalanceOptions { + /** Remaining output capacity after planned match/collect actions. */ + outputSlots: number; + + /** Sampled tip used for maturity and ring calculations. */ + tip: ccc.ClientBlockHeader; + + /** Post-match available iCKB balance. */ + ickbBalance: bigint; + + /** Post-match available CKB balance. */ + ckbBalance: bigint; + + /** Capacity required for one direct deposit. */ + directDepositCapacity: bigint; + + /** Extra CKB headroom reserved for direct-deposit fees. */ + directDepositFeeHeadroom?: bigint; + + /** Minimum iCKB balance before the bot may spend iCKB on withdrawals. */ + ickbRefillThreshold?: bigint; + + /** CKB threshold below which reserve recovery may spend iCKB. */ + ckbRecoveryThreshold?: bigint; + + /** Full public pool snapshot used for ring coverage and anchor decisions. */ + poolDeposits: readonly IckbDepositCell[]; + + /** Ready deposits available for withdrawal selection. */ + readyDeposits: readonly IckbDepositCell[]; +} + +/** + * Rebalance action chosen after match planning reserves output slots. + */ +export type RebalancePlan = + | { + kind: "none"; + reason: RebalanceNoopReason; + diagnostics?: RebalanceDiagnostics; + } + | { + kind: "deposit"; + reason: RebalanceDepositReason; + quantity: 1; + diagnostics?: RebalanceDiagnostics; + } + | { + kind: "withdraw"; + reason: RebalanceWithdrawReason; + deposits: IckbDepositCell[]; + requiredLiveDeposits?: IckbDepositCell[]; + diagnostics?: RebalanceDiagnostics; + }; + +type RebalanceDepositReason = "low_ickb_balance" | "ring_inventory"; +export type RebalanceWithdrawReason = "excess_ickb_balance" | "reserve_recovery"; + +export type RebalanceNoopReason = + | "insufficient_output_slots" + | "low_ickb_ckb_reserve_unavailable" + | "no_withdrawable_ickb" + | "no_ring_surplus_ready_deposits" + | "ring_surplus_withdrawal_over_budget" + | "no_ready_withdrawal_selection"; + +export interface RebalanceDiagnostics { + ring?: RingDiagnostics; +} + +/** + * Public diagnostic snapshot for ring-shaped pool inventory decisions. + */ +export interface RingDiagnostics { + poolDepositCount: number; + canCreateRingInventory: boolean; + shouldBootstrapRing: boolean; + ringLength: bigint; + segmentCount: number; + targetSegmentIndex: number; + targetSegmentUdtValue: bigint; + totalPoolUdt: bigint; + depositsShareOneSegment: boolean; + segments: RingSegmentDiagnostics[]; +} + +export interface RingSegmentDiagnostics { + index: number; + depositCount: number; + udtValue: bigint; + isTarget: boolean; + protectedDepositCount: number; + protectedUdtValue: bigint; + protectedOutPoints: string[]; + surplusDepositCount: number; + surplusUdtValue: bigint; + surplusOutPoints: string[]; +} diff --git a/packages/bot/src/policy/withdrawal.ts b/packages/bot/src/policy/withdrawal.ts new file mode 100644 index 0000000..becff5e --- /dev/null +++ b/packages/bot/src/policy/withdrawal.ts @@ -0,0 +1,229 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell } from "@ickb/core"; +import { + ringRequiredLiveDepositFor, + ringSurplusDepositFilter, + selectReadyWithdrawalDeposits, +} from "@ickb/sdk"; +import type { + RebalanceDiagnostics, + RebalanceNoopReason, + RebalancePlan, + RebalanceWithdrawReason, +} from "./types.ts"; + +const OUTPUTS_PER_REBALANCE_ACTION = 2; +const MAX_WITHDRAWAL_REQUESTS = 30; + +/** + * Plans an iCKB withdrawal rebalance after matching and output-slot reservation. + * + * @remarks Ordinary excess withdrawals preserve live ring anchors. Reserve + * recovery first tries the same ring-safe path, then may use any ready deposit + * because restoring CKB now is the owning invariant. + */ +export function planRebalanceWithdrawal(options: { + outputSlots: number; + tip: ccc.ClientBlockHeader; + ickbBalance: bigint; + ckbBalance: bigint; + ickbRefillThreshold: bigint; + ckbRecoveryThreshold: bigint; + poolDeposits: readonly IckbDepositCell[]; + readyDeposits: readonly IckbDepositCell[]; + diagnostics: RebalanceDiagnostics; +}): RebalancePlan { + const { + outputSlots, + tip, + ickbBalance, + ckbBalance, + ickbRefillThreshold, + ckbRecoveryThreshold, + poolDeposits, + readyDeposits, + diagnostics, + } = options; + const withdrawalLimit = Math.min( + MAX_WITHDRAWAL_REQUESTS, + Math.floor(outputSlots / OUTPUTS_PER_REBALANCE_ACTION), + ); + if (ckbBalance < ckbRecoveryThreshold || ickbBalance < ickbRefillThreshold) { + const recovery = planReserveRecovery({ + withdrawalLimit, + tip, + excessIckb: ickbBalance, + poolDeposits, + readyDeposits, + diagnostics, + }); + if (recovery.kind === "withdraw") { + return recovery; + } + } + + if (ickbBalance < ickbRefillThreshold) { + return noRebalancePlan("low_ickb_ckb_reserve_unavailable", diagnostics); + } + + const withdrawableIckb = ickbBalance - ickbRefillThreshold; + if (withdrawableIckb <= 0n) { + return noRebalancePlan("no_withdrawable_ickb", diagnostics); + } + + return planExcessIckbWithdrawal({ + readyDeposits, + tip, + withdrawableIckb, + withdrawalLimit, + poolDeposits, + diagnostics, + }); +} + +function planReserveRecovery(options: { + withdrawalLimit: number; + tip: ccc.ClientBlockHeader; + excessIckb: bigint; + poolDeposits: readonly IckbDepositCell[]; + readyDeposits: readonly IckbDepositCell[]; + diagnostics: RebalanceDiagnostics; +}): RebalancePlan { + const { withdrawalLimit, tip, excessIckb, poolDeposits, readyDeposits, diagnostics } = + options; + const selection = selectPoolRebalancingDeposits({ + readyDeposits, + tip, + maxAmount: excessIckb, + limit: withdrawalLimit, + ringSurplus: ringSurplusDepositFilter(poolDeposits), + requiredLiveDepositFor: ringRequiredLiveDepositFor(poolDeposits), + }); + if (selection.deposits.length > 0) { + return withdrawPlan("reserve_recovery", selection, diagnostics); + } + + // Reserve recovery is allowed to fall back to any ready deposit because its + // goal is restoring CKB now; normal excess withdrawals keep ring anchors live. + const reserveRecovery = selectPoolRebalancingDeposits({ + readyDeposits, + tip, + maxAmount: excessIckb, + limit: withdrawalLimit, + ringSurplus: () => true, + requiredLiveDepositFor: undefined, + }); + if (reserveRecovery.deposits.length > 0) { + return withdrawPlan("reserve_recovery", reserveRecovery, diagnostics); + } + + return { + kind: "none", + reason: "no_ready_withdrawal_selection", + diagnostics, + }; +} + +function planExcessIckbWithdrawal({ + readyDeposits, + tip, + withdrawableIckb, + withdrawalLimit, + poolDeposits, + diagnostics, +}: { + readyDeposits: readonly IckbDepositCell[]; + tip: ccc.ClientBlockHeader; + withdrawableIckb: bigint; + withdrawalLimit: number; + poolDeposits: readonly IckbDepositCell[]; + diagnostics: RebalanceDiagnostics; +}): RebalancePlan { + const ringSurplus = ringSurplusDepositFilter(poolDeposits); + const selection = selectPoolRebalancingDeposits({ + readyDeposits, + tip, + maxAmount: withdrawableIckb, + limit: withdrawalLimit, + ringSurplus, + requiredLiveDepositFor: ringRequiredLiveDepositFor(poolDeposits), + }); + if (selection.deposits.length > 0) { + return withdrawPlan("excess_ickb_balance", selection, diagnostics); + } + return noRebalancePlan( + noExcessWithdrawalReason(readyDeposits, ringSurplus), + diagnostics, + ); +} + +function noExcessWithdrawalReason( + readyDeposits: readonly IckbDepositCell[], + ringSurplus: (deposit: IckbDepositCell) => boolean, +): RebalanceNoopReason { + if (readyDeposits.length === 0) { + return "no_ready_withdrawal_selection"; + } + return readyDeposits.some(ringSurplus) + ? "ring_surplus_withdrawal_over_budget" + : "no_ring_surplus_ready_deposits"; +} + +function noRebalancePlan( + reason: RebalanceNoopReason, + diagnostics: RebalanceDiagnostics, +): RebalancePlan { + return { + kind: "none", + reason, + diagnostics, + }; +} + +function withdrawPlan( + reason: RebalanceWithdrawReason, + selection: { + deposits: IckbDepositCell[]; + requiredLiveDeposits: IckbDepositCell[]; + }, + diagnostics: RebalanceDiagnostics, +): RebalancePlan { + return { + kind: "withdraw", + reason, + deposits: selection.deposits, + diagnostics, + ...(selection.requiredLiveDeposits.length > 0 + ? { requiredLiveDeposits: selection.requiredLiveDeposits } + : {}), + }; +} + +function selectPoolRebalancingDeposits({ + readyDeposits, + tip, + maxAmount, + limit, + ringSurplus, + requiredLiveDepositFor, +}: { + readyDeposits: readonly IckbDepositCell[]; + tip: ccc.ClientBlockHeader; + maxAmount: bigint; + limit: number; + ringSurplus: (deposit: IckbDepositCell) => boolean; + requiredLiveDepositFor: + ((deposit: IckbDepositCell) => IckbDepositCell | undefined) | undefined; +}): { + deposits: IckbDepositCell[]; + requiredLiveDeposits: IckbDepositCell[]; +} { + return selectReadyWithdrawalDeposits({ + readyDeposits, + tip, + maxAmount, + maxCount: limit, + canSelectDeposit: ringSurplus, + ...(requiredLiveDepositFor === undefined ? {} : { requiredLiveDepositFor }), + }); +} diff --git a/packages/bot/src/runtime/audit.ts b/packages/bot/src/runtime/audit.ts new file mode 100644 index 0000000..ecaa43e --- /dev/null +++ b/packages/bot/src/runtime/audit.ts @@ -0,0 +1,130 @@ +import { ccc } from "@ckb-ccc/core"; +import { receiptPhase2Capacity } from "@ickb/core"; +import type { RebalancePlan, RingSegmentDiagnostics } from "../policy.ts"; +import { CKB_RESERVE } from "../policy/constants.ts"; +import { DIRECT_DEPOSIT_FEE_HEADROOM, maxBigInt } from "./support.ts"; +import type { BotDecisionTranscript, BotState, Runtime } from "./types.ts"; + +const OWNED_OWNER_TYPE_BYTES = 33; +const OWNER_DATA_BYTES = 4; + +export function auditSummary({ + runtime, + state, + match, + rebalance, + fee, +}: { + runtime: Runtime; + state: BotState; + match: { ckbDelta: bigint; udtDelta: bigint }; + rebalance: RebalancePlan; + fee?: bigint; +}): BotDecisionTranscript["audit"] { + const directCost = directDepositCost(runtime, state, rebalance); + const withdrawalCost = withdrawalRequestCost(runtime, rebalance); + const estimatedFee = fee ?? 0n; + // BEFORE EDITING, STOP AND PROVE, LOCAL SAFETY IS NOT ENOUGH: + // - OWNER: bot available-CKB reserve policy. + // - INVARIANT: reserve is based on projected available CKB, not actual plain-cell accounting. + // - FAILURE MODE: plain-cell audits can block withdrawal requests that spend rent now to restore CKB later. + const projectedPostTransactionCkb = + state.availableCkbBalance + + match.ckbDelta - + directCost - + withdrawalCost - + estimatedFee; + return { + reserveCheck: { + availableCkb: state.availableCkbBalance, + matchCkbDelta: match.ckbDelta, + rebalanceCkbCost: directCost + withdrawalCost, + directDepositCost: directCost, + withdrawalRequestCost: withdrawalCost, + ...(fee === undefined ? {} : { estimatedFee: fee }), + projectedPostTransactionCkb, + reserve: CKB_RESERVE, + deficit: maxBigInt(0n, CKB_RESERVE - projectedPostTransactionCkb), + recoveryException: rebalance.kind === "withdraw" && match.ckbDelta >= 0n, + }, + rebalanceCosts: { + directDepositCapacity: + state.depositCapacity + receiptPhase2Capacity(runtime.primaryLock), + directDepositFeeHeadroom: DIRECT_DEPOSIT_FEE_HEADROOM, + directDepositCost: directCost, + withdrawalRequestCost: withdrawalCost, + }, + ...selectedRingAudit(rebalance), + }; +} + +function directDepositCost( + runtime: Runtime, + state: BotState, + rebalance: RebalancePlan, +): bigint { + return rebalance.kind === "deposit" + ? state.depositCapacity + receiptPhase2Capacity(runtime.primaryLock) + : 0n; +} + +function withdrawalRequestCost(runtime: Runtime, rebalance: RebalancePlan): bigint { + if (rebalance.kind !== "withdraw") { + return 0n; + } + // Owner cells contain 8 capacity bytes, the account lock, a 33-byte type + // script, and 4 bytes of owner data. + const ownerCapacity = + BigInt( + 8 + runtime.primaryLock.occupiedSize + OWNED_OWNER_TYPE_BYTES + OWNER_DATA_BYTES, + ) * ccc.One; + return BigInt(rebalance.deposits.length) * ownerCapacity; +} + +function selectedRingAudit( + rebalance: RebalancePlan, +): Pick { + const ring = rebalance.diagnostics?.ring; + if (ring === undefined) { + return {}; + } + const segmentStats = selectedRingSegmentStats(ring.segments); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- policy ring diagnostics are built from ringSegments, so the target index points at an existing segment. + const targetSegment = ring.segments[ring.targetSegmentIndex]!; + return { + selectedRing: { + targetSegmentIndex: ring.targetSegmentIndex, + targetDepositCount: targetSegment.depositCount, + targetUdtValue: ring.targetSegmentUdtValue, + totalPoolUdt: ring.totalPoolUdt, + emptySegmentCount: segmentStats.emptySegmentCount, + nonemptySegmentCount: ring.segments.length - segmentStats.emptySegmentCount, + heaviestSegmentIndex: segmentStats.heaviest.index, + heaviestSegmentDepositCount: segmentStats.heaviest.depositCount, + heaviestSegmentUdtValue: segmentStats.heaviest.udtValue, + canCreateRingInventory: ring.canCreateRingInventory, + shouldBootstrapRing: ring.shouldBootstrapRing, + }, + }; +} + +function selectedRingSegmentStats(segments: RingSegmentDiagnostics[]): { + heaviest: RingSegmentDiagnostics; + emptySegmentCount: number; +} { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- policy ring diagnostics are built from ringSegments, which always returns at least one segment. + let heaviest = segments[0]!; + let emptySegmentCount = 0; + for (const segment of segments) { + emptySegmentCount += segment.depositCount === 0 ? 1 : 0; + heaviest = heavierRingSegment(heaviest, segment); + } + return { heaviest, emptySegmentCount }; +} + +function heavierRingSegment( + current: RingSegmentDiagnostics, + segment: RingSegmentDiagnostics, +): RingSegmentDiagnostics { + return segment.udtValue > current.udtValue ? segment : current; +} diff --git a/packages/bot/src/runtime/decision.ts b/packages/bot/src/runtime/decision.ts new file mode 100644 index 0000000..3cf16a4 --- /dev/null +++ b/packages/bot/src/runtime/decision.ts @@ -0,0 +1,153 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { Match, MatchDiagnostics } from "@ickb/order"; +import type { RebalancePlan } from "../policy.ts"; +import { auditSummary } from "./audit.ts"; +import { summarizeBotState, transactionShape } from "./support.ts"; +import type { + BotActions, + BotDecisionTranscript, + BotMatchReason, + BotState, + Runtime, +} from "./types.ts"; + +export function buildDecisionTranscript({ + runtime, + state, + match, + rebalance, + outputSlots, + actions, + tx, +}: { + runtime: Runtime; + state: BotState; + match: Pick; + rebalance: RebalancePlan; + outputSlots: number; + actions: BotActions; + tx: ccc.Transaction; +}): BotDecisionTranscript { + const summary = summarizeBotState(state); + return { + ...summary, + match: { + reason: matchReason(match, state), + partialCount: match.partials.length, + ckbDelta: match.ckbDelta, + udtDelta: match.udtDelta, + ...(match.partials.length === 0 + ? {} + : { + matchedOrderOutPoints: matchedOrderOutPoints(match.partials), + matchedOrderMasterOutPoints: matchedOrderMasterOutPoints(match.partials), + }), + ...(match.diagnostics === undefined ? {} : { diagnostics: match.diagnostics }), + }, + rebalance: rebalanceSummary(rebalance, outputSlots, state, match), + audit: auditSummary({ runtime, state, match, rebalance }), + actions, + fee: { + feeRate: state.system.feeRate, + }, + transactionShape: transactionShape(tx), + }; +} + +function matchedOrderOutPoints( + partials: Match["partials"], +): Array<{ txHash: ccc.Hex; index: string }> { + return partials.map((partial) => ({ + txHash: partial.order.cell.outPoint.txHash, + index: String(partial.order.cell.outPoint.index), + })); +} + +function matchedOrderMasterOutPoints( + partials: Match["partials"], +): Array<{ txHash: ccc.Hex; index: string }> { + return partials.map((partial) => { + const master = partial.order.getMaster(); + return { + txHash: master.txHash, + index: String(master.index), + }; + }); +} + +function rebalanceSummary( + rebalance: RebalancePlan, + outputSlots: number, + state: BotState, + match: { ckbDelta: bigint; udtDelta: bigint }, +): BotDecisionTranscript["rebalance"] { + return { + kind: rebalance.kind, + reason: rebalance.reason, + ...(rebalance.kind === "deposit" ? { depositQuantity: rebalance.quantity } : {}), + ...(rebalance.kind === "withdraw" + ? { + withdrawalRequestCount: rebalance.deposits.length, + requiredLiveDepositCount: rebalance.requiredLiveDeposits?.length ?? 0, + } + : {}), + ...(rebalance.diagnostics === undefined + ? {} + : { diagnostics: rebalance.diagnostics }), + outputSlots, + projectedAvailableCkb: state.availableCkbBalance + match.ckbDelta, + projectedAvailableIckb: state.availableIckbBalance + match.udtDelta, + }; +} + +function matchReason( + match: Pick, + state: BotState, +): BotMatchReason { + if (match.partials.length > 0) { + return "matched"; + } + if (state.marketOrders.length === 0) { + return "no_market_orders"; + } + + const diagnostics = match.diagnostics; + if (diagnostics === undefined) { + return "no_viable_candidates"; + } + if ( + diagnostics.directions.ckbToUdt.matchableCount === 0 && + diagnostics.directions.udtToCkb.matchableCount === 0 + ) { + return "no_matchable_orders"; + } + if (diagnostics.candidates.viable === 0) { + return noViableCandidateReason(diagnostics); + } + return viableCandidateMissReason(diagnostics); +} + +function noViableCandidateReason(diagnostics: MatchDiagnostics): BotMatchReason { + return hasInsufficientAllowanceRejection(diagnostics) + ? "insufficient_allowance" + : "no_viable_candidates"; +} + +function viableCandidateMissReason(diagnostics: MatchDiagnostics): BotMatchReason { + if (diagnostics.candidates.positiveGain > 0) { + return "no_viable_candidates"; + } + if (diagnostics.candidates.rejected.maxPartials > 0) { + return "max_partials"; + } + return hasInsufficientAllowanceRejection(diagnostics) + ? "insufficient_allowance" + : "no_positive_gain"; +} + +function hasInsufficientAllowanceRejection(diagnostics: MatchDiagnostics): boolean { + return ( + diagnostics.candidates.rejected.insufficientCkbAllowance > 0 || + diagnostics.candidates.rejected.insufficientUdtAllowance > 0 + ); +} diff --git a/packages/bot/src/runtime/support.ts b/packages/bot/src/runtime/support.ts new file mode 100644 index 0000000..b67162b --- /dev/null +++ b/packages/bot/src/runtime/support.ts @@ -0,0 +1,173 @@ +import { ccc } from "@ckb-ccc/core"; +import { convert } from "@ickb/core"; +import type { Match, MatchDiagnostics } from "@ickb/order"; +import type { RebalancePlan } from "../policy.ts"; +import { CKB_RESERVE } from "../policy/constants.ts"; +import type { + BotActions, + BotDecisionTranscript, + BotState, + BotStateSummary, +} from "./types.ts"; + +export const MATCH_STEP_DIVISOR = 100n; +export const MAX_OUTPUTS_BEFORE_CHANGE = 58; +export const DIRECT_DEPOSIT_FEE_HEADROOM = ccc.fixedPointFrom(1); + +/** + * Builds the stable public state summary emitted with bot decisions. + */ +export function summarizeBotState(state: BotState): BotStateSummary { + return { + chainTip: { + blockNumber: state.system.tip.number, + blockHash: state.system.tip.hash, + timestamp: state.system.tip.timestamp, + epoch: { + integer: state.system.tip.epoch.integer, + numerator: state.system.tip.epoch.numerator, + denominator: state.system.tip.epoch.denominator, + }, + }, + balances: { + availableCkb: state.availableCkbBalance, + unavailableCkb: state.unavailableCkbBalance, + totalCkb: state.totalCkbBalance, + availableIckb: state.availableIckbBalance, + totalEquivalentCkb: + state.totalCkbBalance + + convert(false, state.availableIckbBalance, state.system.exchangeRatio), + totalEquivalentIckb: + convert(true, state.totalCkbBalance, state.system.exchangeRatio) + + state.availableIckbBalance, + minimumCkbCapital: state.minCkbBalance, + spendableCkb: spendableCkb(state.availableCkbBalance), + matchableCkb: matchableCkb(state.availableCkbBalance), + }, + orders: { + marketCount: state.marketOrders.length, + userCount: state.userOrders.length, + receiptCount: state.receipts.length, + }, + withdrawals: { + readyCount: state.readyWithdrawals.length, + pendingCount: state.notReadyWithdrawals.length, + }, + poolDeposits: { + totalCount: state.poolDeposits.length, + readyCount: state.readyPoolDeposits.length, + }, + exchangeRatio: { + ckbScale: state.system.exchangeRatio.ckbScale, + udtScale: state.system.exchangeRatio.udtScale, + }, + depositCapacity: state.depositCapacity, + fee: { + feeRate: state.system.feeRate, + }, + }; +} + +export function emptyActions(): BotActions { + return { + collectedOrders: 0, + completedDeposits: 0, + matchedOrders: 0, + deposits: 0, + withdrawalRequests: 0, + withdrawals: 0, + }; +} + +export function actionTotal(actions: BotActions): number { + return ( + actions.collectedOrders + + actions.completedDeposits + + actions.matchedOrders + + actions.deposits + + actions.withdrawalRequests + + actions.withdrawals + ); +} + +export function actionsForState( + state: BotState, + match: Match, + rebalance: RebalancePlan, +): BotActions { + return { + collectedOrders: state.userOrders.length, + completedDeposits: state.receipts.length, + matchedOrders: match.partials.length, + deposits: rebalance.kind === "deposit" ? rebalance.quantity : 0, + withdrawalRequests: rebalance.kind === "withdraw" ? rebalance.deposits.length : 0, + withdrawals: state.readyWithdrawals.length, + }; +} + +export function isMatchOnly(actions: BotActions): boolean { + return ( + actions.matchedOrders > 0 && + actions.collectedOrders === 0 && + actions.completedDeposits === 0 && + actions.deposits === 0 && + actions.withdrawalRequests === 0 && + actions.withdrawals === 0 + ); +} + +/** + * Counts transaction sections for decision logs without exposing transaction contents. + */ +export function transactionShape( + tx: ccc.Transaction, +): BotDecisionTranscript["transactionShape"] { + return { + inputs: tx.inputs.length, + outputs: tx.outputs.length, + cellDeps: tx.cellDeps.length, + headerDeps: tx.headerDeps.length, + witnesses: tx.witnesses.length, + }; +} + +export function usefulMatchFloors(diagnostics: MatchDiagnostics | undefined): { + ckb: bigint; + ickb: bigint; +} { + if (diagnostics === undefined) { + return { ckb: 0n, ickb: 0n }; + } + return { + ckb: usefulDirectionFloor( + diagnostics.ckbAllowanceStep, + diagnostics.directions.udtToCkb, + ), + ickb: usefulDirectionFloor( + diagnostics.udtAllowanceStep, + diagnostics.directions.ckbToUdt, + ), + }; +} + +function usefulDirectionFloor( + allowanceStep: bigint, + direction: MatchDiagnostics["directions"]["ckbToUdt"], +): bigint { + if (direction.matchableCount === 0) { + return 0n; + } + return maxBigInt(allowanceStep, direction.minAllowance ?? 0n); +} + +export function matchableCkb(availableCkbBalance: bigint): bigint { + return maxBigInt(0n, spendableCkb(availableCkbBalance) - DIRECT_DEPOSIT_FEE_HEADROOM); +} + +function spendableCkb(availableCkbBalance: bigint): bigint { + return maxBigInt(0n, availableCkbBalance - CKB_RESERVE); +} + +export function maxBigInt(left: bigint, right: bigint): bigint { + return left > right ? left : right; +} diff --git a/packages/bot/src/runtime/transaction.ts b/packages/bot/src/runtime/transaction.ts new file mode 100644 index 0000000..090c161 --- /dev/null +++ b/packages/bot/src/runtime/transaction.ts @@ -0,0 +1,344 @@ +import { ccc } from "@ckb-ccc/core"; +import { receiptPhase2Capacity } from "@ickb/core"; +import { OrderManager, type Match } from "@ickb/order"; +import { planRebalance } from "../policy.ts"; +import { auditSummary } from "./audit.ts"; +import { buildDecisionTranscript } from "./decision.ts"; +import { + actionsForState, + actionTotal, + DIRECT_DEPOSIT_FEE_HEADROOM, + emptyActions, + isMatchOnly, + MATCH_STEP_DIVISOR, + matchableCkb, + MAX_OUTPUTS_BEFORE_CHANGE, + maxBigInt, + usefulMatchFloors, +} from "./support.ts"; +import type { + BotActions, + BotDecisionTranscript, + BotState, + BuildTransactionResult, + BuildTransactionSkipReason, + CandidateTransaction, + Runtime, +} from "./types.ts"; + +type CompletedDecisionTranscript = BotDecisionTranscript & { + fee: BotDecisionTranscript["fee"] & { estimated: bigint }; +}; + +/** + * Plans the bot transaction for the current state, then applies fee and reserve gates. + * + * @remarks Matching is evaluated before rebalancing. Rebalance planning receives + * only the output slots left by the match candidate so the final transaction has + * room for fee/change completion. + */ +export async function buildTransaction( + runtime: Runtime, + state: BotState, +): Promise { + const { match, candidate, outputSlots } = await prepareCandidateTransaction( + runtime, + state, + ); + const actionCount = actionTotal(candidate.actions); + if (actionCount === 0) { + return skippedResult( + "no_actions", + candidate.actions, + decisionForCandidate({ runtime, state, match, candidate, outputSlots }), + ); + } + if (candidate.tx.outputs.length > MAX_OUTPUTS_BEFORE_CHANGE) { + return skippedResult( + "output_limit", + emptyActions(), + { + ...decisionForCandidate({ runtime, state, match, candidate, outputSlots }), + actions: emptyActions(), + }, + { + attemptedActions: candidate.actions, + }, + ); + } + + const { tx, decision } = await completeCandidateTransaction({ + runtime, + state, + match, + candidate, + outputSlots, + }); + const reserveCheck = decision.audit.reserveCheck; + if ( + !reserveCheck.recoveryException && + reserveCheck.projectedPostTransactionCkb < reserveCheck.reserve + ) { + return skippedResult( + "post_tx_ckb_reserve", + emptyActions(), + { + ...decision, + actions: emptyActions(), + }, + { + attemptedActions: candidate.actions, + }, + ); + } + + if (isMatchOnly(candidate.actions)) { + return matchOnlyResult({ + state, + match, + fee: decision.fee.estimated, + candidate, + decision, + tx, + }); + } + + return { kind: "built", tx, actions: candidate.actions, decision }; +} + +async function prepareCandidateTransaction( + runtime: Runtime, + state: BotState, +): Promise<{ match: Match; candidate: CandidateTransaction; outputSlots: number }> { + // Match allowance scales with current deposit capacity, which keeps small + // partial matches from consuming output slots without meaningful inventory gain. + const ckbAllowanceStep = maxBigInt(1n, state.depositCapacity / MATCH_STEP_DIVISOR); + const match = OrderManager.bestMatch( + state.marketOrders, + { + ckbValue: matchableCkb(state.availableCkbBalance), + udtValue: state.availableIckbBalance, + }, + state.system.exchangeRatio, + { + feeRate: state.system.feeRate, + ckbAllowanceStep, + maxPartials: MAX_OUTPUTS_BEFORE_CHANGE, + }, + ); + const usefulFloors = usefulMatchFloors(match.diagnostics); + let tx = ccc.Transaction.default(); + if (match.partials.length > 0) { + tx = runtime.managers.order.addMatch(tx, match); + } + + const outputSlots = Math.max(0, MAX_OUTPUTS_BEFORE_CHANGE - tx.outputs.length); + const rebalance = planRebalance({ + outputSlots, + tip: state.system.tip, + ickbBalance: state.availableIckbBalance + match.udtDelta, + ckbBalance: state.availableCkbBalance + match.ckbDelta, + directDepositCapacity: + state.depositCapacity + receiptPhase2Capacity(runtime.primaryLock), + directDepositFeeHeadroom: DIRECT_DEPOSIT_FEE_HEADROOM, + ickbRefillThreshold: usefulFloors.ickb, + ckbRecoveryThreshold: reserveRecoveryThreshold(usefulFloors.ckb), + poolDeposits: state.poolDeposits, + readyDeposits: state.readyPoolDeposits, + }); + const candidate = await buildCandidateTransaction({ + runtime, + state, + match, + rebalance, + tx, + }); + return { match, candidate, outputSlots }; +} + +async function completeCandidateTransaction({ + runtime, + state, + match, + candidate, + outputSlots, +}: { + runtime: Runtime; + state: BotState; + match: Match; + candidate: CandidateTransaction; + outputSlots: number; +}): Promise<{ + tx: ccc.Transaction; + decision: CompletedDecisionTranscript; +}> { + const tx = await runtime.sdk.completeTransaction(candidate.tx, { + signer: runtime.signer, + client: runtime.client, + feeRate: state.system.feeRate, + }); + const fee = tx.estimateFee(state.system.feeRate); + const audit = auditSummary({ + runtime, + state, + match, + rebalance: candidate.rebalance, + fee, + }); + const decision = buildDecisionTranscript({ + runtime, + state, + match, + rebalance: candidate.rebalance, + outputSlots, + actions: candidate.actions, + tx, + }); + return { + tx, + decision: { ...decision, audit, fee: { ...decision.fee, estimated: fee } }, + }; +} + +function matchOnlyResult({ + state, + match, + fee, + candidate, + decision, + tx, +}: { + state: BotState; + match: Match; + fee: bigint; + candidate: CandidateTransaction; + decision: BotDecisionTranscript; + tx: ccc.Transaction; +}): BuildTransactionResult { + const matchValue = + match.ckbDelta * state.system.exchangeRatio.ckbScale + + match.udtDelta * state.system.exchangeRatio.udtScale; + const valuedDecision = { + ...decision, + match: { ...decision.match, value: matchValue }, + }; + // Pure matches must beat the fee because no collection or rebalance action + // justifies sending an otherwise value-neutral transaction. + if (matchValue <= fee * state.system.exchangeRatio.ckbScale) { + return skippedResult( + "match_value_not_above_fee", + emptyActions(), + { + ...valuedDecision, + actions: emptyActions(), + }, + { + fee, + matchValue, + attemptedActions: candidate.actions, + }, + ); + } + return { + kind: "built", + tx, + actions: candidate.actions, + decision: valuedDecision, + }; +} + +async function buildCandidateTransaction({ + runtime, + state, + match, + rebalance, + tx: matchTx, +}: { + runtime: Runtime; + state: BotState; + match: Match; + rebalance: CandidateTransaction["rebalance"]; + tx: ccc.Transaction; +}): Promise { + let tx = await runtime.sdk.buildBaseTransaction(matchTx, runtime.client, { + withdrawalRequest: + rebalance.kind === "withdraw" + ? { + deposits: rebalance.deposits, + requiredLiveDeposits: rebalance.requiredLiveDeposits, + lock: runtime.primaryLock, + } + : undefined, + orders: state.userOrders, + receipts: state.receipts, + readyWithdrawals: state.readyWithdrawals, + }); + if (rebalance.kind === "deposit") { + tx = await runtime.managers.logic.deposit( + tx, + rebalance.quantity, + state.depositCapacity, + runtime.primaryLock, + runtime.client, + ); + } + + const actions = actionsForState(state, match, rebalance); + return { + tx, + actions, + rebalance, + }; +} + +function decisionForCandidate({ + runtime, + state, + match, + candidate, + outputSlots, +}: { + runtime: Runtime; + state: BotState; + match: Match; + candidate: CandidateTransaction; + outputSlots: number; +}): BotDecisionTranscript { + return buildDecisionTranscript({ + runtime, + state, + match, + rebalance: candidate.rebalance, + outputSlots, + actions: candidate.actions, + tx: candidate.tx, + }); +} + +function reserveRecoveryThreshold(usefulCkbFloor: bigint): bigint { + return maxBigInt(0n, usefulCkbFloor) + 1000n * ccc.fixedPointFrom(1); +} + +function skippedResult( + reason: BuildTransactionSkipReason, + actions: BotActions, + decision: BotDecisionTranscript, + details?: { + fee?: bigint; + matchValue?: bigint; + attemptedActions?: BotActions; + }, +): BuildTransactionResult { + return { + kind: "skipped", + reason, + actions, + decision: { + ...decision, + skip: { + reason, + ...details, + }, + }, + }; +} diff --git a/packages/bot/src/runtime/types.ts b/packages/bot/src/runtime/types.ts new file mode 100644 index 0000000..59b9236 --- /dev/null +++ b/packages/bot/src/runtime/types.ts @@ -0,0 +1,261 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell, ReceiptCell, WithdrawalGroup } from "@ickb/core"; +import type { SupportedChain } from "@ickb/node-utils"; +import type { MatchDiagnostics, OrderCell, OrderGroup } from "@ickb/order"; +import type { getConfig, IckbSdk, SystemState } from "@ickb/sdk"; +import type { RebalanceDiagnostics, RebalancePlan } from "../policy.ts"; + +/** Runtime dependencies used by each bot loop iteration. */ +export interface Runtime { + /** Configured public chain. */ + chain: SupportedChain; + + /** CCC client for the configured chain. */ + client: ccc.Client; + + /** Private-key signer; signing is its only secret-bearing purpose. */ + signer: ccc.SignerCkbPrivateKey; + + /** SDK instance for state scans and transaction completion. */ + sdk: IckbSdk; + + /** Lower-level managers from the selected deployment config. */ + managers: ReturnType["managers"]; + + /** Primary lock controlled by the signer. */ + primaryLock: ccc.Script; +} + +/** Snapshot of bot-owned and public state used for one planning attempt. */ +export interface BotState { + /** Sampled public L1 state. */ + system: SystemState; + + /** User-owned order groups. */ + userOrders: OrderGroup[]; + + /** Public market orders eligible for matching. */ + marketOrders: OrderCell[]; + + /** User receipt cells ready for deposit completion. */ + receipts: ReceiptCell[]; + + /** User withdrawal groups ready for withdrawal completion. */ + readyWithdrawals: WithdrawalGroup[]; + + /** User withdrawal groups that are not ready yet. */ + notReadyWithdrawals: WithdrawalGroup[]; + + /** Full public pool deposit snapshot. */ + poolDeposits: IckbDepositCell[]; + + /** Ready public pool deposits available as withdrawal candidates. */ + readyPoolDeposits: IckbDepositCell[]; + + /** Spendable CKB after projected availability rules. */ + availableCkbBalance: bigint; + + /** Spendable iCKB after projected availability rules. */ + availableIckbBalance: bigint; + + /** CKB represented by pending or otherwise unavailable paths. */ + unavailableCkbBalance: bigint; + + /** Total projected CKB balance. */ + totalCkbBalance: bigint; + + /** Capacity used for one direct deposit output. */ + depositCapacity: bigint; + + /** Minimum CKB-equivalent capital required before the bot acts. */ + minCkbBalance: bigint; +} + +/** Counts of actions selected for a candidate transaction. */ +export interface BotActions { + collectedOrders: number; + completedDeposits: number; + matchedOrders: number; + deposits: number; + withdrawalRequests: number; + withdrawals: number; +} + +export type BuildTransactionSkipReason = + "no_actions" | "match_value_not_above_fee" | "output_limit" | "post_tx_ckb_reserve"; + +export type BotDecisionSkipReason = BuildTransactionSkipReason | "capital_below_minimum"; + +export type BotMatchReason = + | "matched" + | "no_market_orders" + | "no_matchable_orders" + | "insufficient_allowance" + | "no_viable_candidates" + | "max_partials" + | "no_positive_gain"; + +type BotRebalanceReason = RebalancePlan["reason"]; + +/** + * Bot transaction-build outcome with the decision transcript used for logs and events. + */ +export type BuildTransactionResult = + | { + kind: "built"; + tx: ccc.Transaction; + actions: BotActions; + decision: BotDecisionTranscript; + } + | { + kind: "skipped"; + reason: BuildTransactionSkipReason; + actions: BotActions; + decision: BotDecisionTranscript; + }; + +export interface CandidateTransaction { + tx: ccc.Transaction; + actions: BotActions; + rebalance: RebalancePlan; +} + +/** + * Structured evidence for one bot planning attempt. + */ +export interface BotDecisionTranscript { + chainTip: { + blockNumber: bigint; + blockHash: ccc.Hex; + timestamp: bigint; + epoch: { + integer: bigint; + numerator: bigint; + denominator: bigint; + }; + }; + balances: { + availableCkb: bigint; + unavailableCkb: bigint; + totalCkb: bigint; + availableIckb: bigint; + totalEquivalentCkb: bigint; + totalEquivalentIckb: bigint; + minimumCkbCapital: bigint; + spendableCkb: bigint; + matchableCkb: bigint; + }; + orders: { + marketCount: number; + userCount: number; + receiptCount: number; + }; + withdrawals: { + readyCount: number; + pendingCount: number; + }; + poolDeposits: { + totalCount: number; + readyCount: number; + }; + match: { + reason: BotMatchReason; + partialCount: number; + ckbDelta: bigint; + udtDelta: bigint; + matchedOrderOutPoints?: Array<{ txHash: ccc.Hex; index: string }>; + matchedOrderMasterOutPoints?: Array<{ txHash: ccc.Hex; index: string }>; + value?: bigint; + diagnostics?: MatchDiagnostics; + }; + rebalance: { + kind: RebalancePlan["kind"]; + reason: BotRebalanceReason; + depositQuantity?: number; + withdrawalRequestCount?: number; + requiredLiveDepositCount?: number; + diagnostics?: RebalanceDiagnostics; + outputSlots: number; + projectedAvailableCkb: bigint; + projectedAvailableIckb: bigint; + }; + audit: { + /** Reserve projection based on selected actions, not plain-cell accounting. */ + reserveCheck: { + /** CKB available before applying selected match and rebalance actions. */ + availableCkb: bigint; + /** CKB delta contributed by order matching. */ + matchCkbDelta: bigint; + /** CKB cost of the selected rebalance action. */ + rebalanceCkbCost: bigint; + /** Direct deposit capacity component of the rebalance cost. */ + directDepositCost: bigint; + /** Withdrawal request capacity component of the rebalance cost. */ + withdrawalRequestCost: bigint; + /** Optional estimated fee applied to the projection. */ + estimatedFee?: bigint; + /** Projected available CKB after the candidate transaction. */ + projectedPostTransactionCkb: bigint; + /** Required CKB reserve floor. */ + reserve: bigint; + /** Positive shortfall below reserve after projection. */ + deficit: bigint; + /** True when a withdrawal rebalance with non-negative match CKB delta may cross the immediate reserve. */ + recoveryException: boolean; + }; + rebalanceCosts: { + directDepositCapacity: bigint; + directDepositFeeHeadroom: bigint; + directDepositCost: bigint; + withdrawalRequestCost: bigint; + }; + selectedRing?: { + targetSegmentIndex: number; + targetDepositCount: number; + targetUdtValue: bigint; + totalPoolUdt: bigint; + emptySegmentCount: number; + nonemptySegmentCount: number; + heaviestSegmentIndex: number; + heaviestSegmentDepositCount: number; + heaviestSegmentUdtValue: bigint; + canCreateRingInventory: boolean; + shouldBootstrapRing: boolean; + }; + }; + actions: BotActions; + fee: { + feeRate: ccc.Num; + estimated?: bigint; + }; + transactionShape: { + inputs: number; + outputs: number; + cellDeps: number; + headerDeps: number; + witnesses: number; + }; + exchangeRatio: { + ckbScale: bigint; + udtScale: bigint; + }; + depositCapacity: bigint; + skip?: { + reason: BotDecisionSkipReason; + fee?: bigint; + matchValue?: bigint; + attemptedActions?: BotActions; + }; +} + +export type BotStateSummary = Pick< + BotDecisionTranscript, + | "chainTip" + | "balances" + | "orders" + | "withdrawals" + | "poolDeposits" + | "exchangeRatio" + | "depositCapacity" + | "fee" +>; diff --git a/packages/bot/test/bot/config.ts b/packages/bot/test/bot/config.ts new file mode 100644 index 0000000..149ff29 --- /dev/null +++ b/packages/bot/test/bot/config.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { readBotRuntimeConfig } from "../../src/index.ts"; + +const CONFIG_FILE_NAME = "config.json"; + +describe("readBotRuntimeConfig", () => { + it("requires a JSON config file", async () => { + await expect(readBotRuntimeConfig({})).rejects.toThrow("Empty env BOT_CONFIG_FILE"); + }); + + it("reads JSON config files", async () => { + const privateKey = `0x${"11".repeat(32)}`; + const dir = await mkdtemp(path.join(tmpdir(), "ickb-bot-config-")); + try { + const configPath = path.join(dir, CONFIG_FILE_NAME); + await writeFile( + configPath, + JSON.stringify({ + chain: "testnet", + privateKey, + rpcUrl: "http://127.0.0.1:8114/", + sleepIntervalSeconds: 60, + maxIterations: 1, + maxRetryableAttempts: 3, + }), + { mode: 0o600 }, + ); + + await expect( + readBotRuntimeConfig({ BOT_CONFIG_FILE: configPath }), + ).resolves.toEqual({ + chain: "testnet", + privateKey, + rpcUrl: "http://127.0.0.1:8114/", + sleepIntervalMs: 60000, + maxIterations: 1, + maxRetryableAttempts: 3, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("reads JSON config files that omit custom RPC URLs", async () => { + const privateKey = `0x${"11".repeat(32)}`; + const dir = await mkdtemp(path.join(tmpdir(), "ickb-bot-config-")); + try { + const configPath = path.join(dir, CONFIG_FILE_NAME); + await writeFile( + configPath, + JSON.stringify({ + chain: "testnet", + privateKey, + sleepIntervalSeconds: 60, + maxIterations: 1, + }), + { mode: 0o600 }, + ); + + await expect( + readBotRuntimeConfig({ BOT_CONFIG_FILE: configPath }), + ).resolves.toEqual({ + chain: "testnet", + privateKey, + rpcUrl: undefined, + sleepIntervalMs: 60000, + maxIterations: 1, + maxRetryableAttempts: undefined, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/bot/test/bot/failure.ts b/packages/bot/test/bot/failure.ts new file mode 100644 index 0000000..27a8916 --- /dev/null +++ b/packages/bot/test/bot/failure.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { + isRetryableBotError, + iterationFailureEventFields, + nonRetryableTerminalFailureExitCode, +} from "../../src/index.ts"; + +const FETCH_FAILED = "fetch failed"; +const DETERMINISTIC_BUILD_FAILURE = "deterministic build failure"; +const TRANSACTION_CONFIRMATION_ERROR = "TransactionConfirmationError"; +const REJECTED_STATUS = "rejected"; +const TX_HASH = `0x${"11".repeat(32)}`; +const RBF_REJECTED_REASON = JSON.stringify({ + type: "RBFRejected", + description: `RBF rejected: replaced by tx Byte32(0x${"22".repeat(32)})`, +}); + +describe("bot iteration failure metadata", () => { + it("treats transport and CKB state-race failures as retryable", () => { + expect( + isRetryableBotError( + new Error("L1 state scan crossed chain tip; retry with a fresh state"), + ), + ).toBe(true); + expect(isRetryableBotError(new TypeError(FETCH_FAILED))).toBe(true); + expect( + isRetryableBotError( + new Error(FETCH_FAILED, { cause: new TypeError(FETCH_FAILED) }), + ), + ).toBe(true); + expect(isRetryableBotError(wrappedTransactionHeaderFetchFailure())).toBe(true); + expect(isRetryableBotError(new Error("Id mismatched, got null, expected 319"))).toBe( + true, + ); + expect( + isRetryableBotError( + new SyntaxError("Unexpected token '<', \"= 12326 to replace old txs")', + }), + ), + ).toBe(true); + expect( + isRetryableBotError( + Object.assign(new Error("Client request error TransactionFailedToResolve"), { + code: -301, + data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, + }), + ), + ).toBe(true); + expect( + isRetryableBotError({ + code: -301, + data: `Resolve(Dead(OutPoint(0x${"11".repeat(32)}00000000)))`, + }), + ).toBe(true); + expect( + isRetryableBotError( + Object.assign( + new Error("Client request error PoolRejectedDuplicatedTransaction"), + { + code: -1107, + data: `Duplicated(Byte32(0x${"22".repeat(32)}))`, + }, + ), + ), + ).toBe(true); + expect(isRetryableBotError(new Error(FETCH_FAILED))).toBe(false); + expect( + isRetryableBotError({ code: -301, data: "Resolve(InvalidHeader(Byte32(0x...)))" }), + ).toBe(false); + expect(isRetryableBotError(new Error(DETERMINISTIC_BUILD_FAILURE))).toBe(false); + }); + + it("treats post-broadcast RBF confirmation rejection as retryable", () => { + expect(isRetryableBotError(rbfConfirmationError())).toBe(true); + expect( + isRetryableBotError( + confirmationError({ reason: "Resolve failed Dead(OutPoint(...))" }), + ), + ).toBe(false); + expect(isRetryableBotError(confirmationError({ reason: undefined }))).toBe(false); + }); +}); + +describe("bot retryable iteration failures", () => { + it("emits retryability metadata from the same retry decision", () => { + expect(iterationFailureEventFields(new TypeError(FETCH_FAILED))).toMatchObject({ + retryable: true, + terminal: false, + error: { name: "TypeError", message: FETCH_FAILED }, + }); + expect( + iterationFailureEventFields(new TypeError(FETCH_FAILED)).error, + ).not.toHaveProperty("stack"); + + const responseShapeFailure = iterationFailureEventFields( + new Error("Id mismatched, got null, expected 319"), + ); + expect(responseShapeFailure).toMatchObject({ retryable: true, terminal: false }); + expect(responseShapeFailure.error).not.toHaveProperty("stack"); + + const wrappedTransportFailure = iterationFailureEventFields( + wrappedTransactionHeaderFetchFailure(), + ); + expect(wrappedTransportFailure).toMatchObject({ retryable: true, terminal: false }); + expect(wrappedTransportFailure.error).not.toHaveProperty("stack"); + + const exhaustedFailure = iterationFailureEventFields(new TypeError(FETCH_FAILED), { + retryableAttempts: 3, + maxRetryableAttempts: 3, + }); + expect(exhaustedFailure).toMatchObject({ + retryable: true, + terminal: true, + retryableAttempts: 3, + maxRetryableAttempts: 3, + retryBudgetExhausted: true, + }); + expect(exhaustedFailure.error).not.toHaveProperty("stack"); + + expect( + iterationFailureEventFields(new TypeError(FETCH_FAILED), { + retryableAttempts: 3, + maxRetryableAttempts: undefined, + }), + ).toMatchObject({ retryable: true, terminal: false, retryableAttempts: 3 }); + + const terminalFailure = iterationFailureEventFields( + new Error(DETERMINISTIC_BUILD_FAILURE), + ); + expect(terminalFailure).toMatchObject({ + retryable: false, + terminal: true, + error: { name: "Error", message: DETERMINISTIC_BUILD_FAILURE }, + }); + expect(terminalFailure.error).toHaveProperty("stack"); + expect(nonRetryableTerminalFailureExitCode(terminalFailure)).toBe(1); + expect(nonRetryableTerminalFailureExitCode(exhaustedFailure)).toBeUndefined(); + }); + + it("emits retryable metadata for post-broadcast RBF confirmation rejection", () => { + const rbfConfirmationFailure = iterationFailureEventFields(rbfConfirmationError()); + + expect(rbfConfirmationFailure).toMatchObject({ + retryable: true, + terminal: false, + error: { + name: TRANSACTION_CONFIRMATION_ERROR, + txHash: TX_HASH, + status: REJECTED_STATUS, + isTimeout: false, + reason: RBF_REJECTED_REASON, + }, + }); + expect(rbfConfirmationFailure.error).not.toHaveProperty("stack"); + }); +}); + +function rbfConfirmationError(): Error { + return confirmationError({ reason: RBF_REJECTED_REASON }); +} + +function wrappedTransactionHeaderFetchFailure(): Error { + return new Error( + `Failed to load transaction header for txHash ${TX_HASH} at ${TX_HASH}00000000`, + { + cause: new TypeError(FETCH_FAILED), + }, + ); +} + +function confirmationError({ reason }: { reason: string | undefined }): Error { + const error = Object.assign(new Error("Transaction ended with status: rejected"), { + txHash: TX_HASH, + status: REJECTED_STATUS, + isTimeout: false, + reason, + }); + Object.defineProperty(error, "name", { value: TRANSACTION_CONFIRMATION_ERROR }); + return error; +} diff --git a/packages/bot/test/bot/fixtures/bot.ts b/packages/bot/test/bot/fixtures/bot.ts new file mode 100644 index 0000000..dd649a2 --- /dev/null +++ b/packages/bot/test/bot/fixtures/bot.ts @@ -0,0 +1,299 @@ +import { ccc } from "@ckb-ccc/core"; +import { + OwnerCell, + ReceiptData, + WithdrawalGroup, + type IckbDepositCell, +} from "@ickb/core"; +import { OrderCell, OrderData, Ratio, type OrderManager } from "@ickb/order"; +import { getConfig, IckbSdk } from "@ickb/sdk"; +import { byte32FromByte, headerLike, outPoint, script } from "@ickb/testkit"; +import type { BotState, Runtime } from "../../../src/runtime/types.ts"; + +type TestWithdrawalRequestCell = ConstructorParameters[0]; + +export interface BotRuntimeOptions { + sdk?: Partial<{ + buildBaseTransaction: IckbSdk["buildBaseTransaction"]; + completeTransaction: IckbSdk["completeTransaction"]; + getL1AccountState: IckbSdk["getL1AccountState"]; + assertCurrentTip: IckbSdk["assertCurrentTip"]; + }>; + primaryLock?: ccc.Script; + managers?: { + dao?: Partial; + ickbUdt?: Partial; + order?: Partial; + ownedOwner?: Partial; + logic?: Partial; + }; +} + +export const hash = byte32FromByte; +export const TARGET_ICKB_BALANCE = ccc.fixedPointFrom(120000); +export const NO_DEPOSITS: IckbDepositCell[] = []; + +export function readyDeposit( + byte: string, + udtValue: bigint, + maturityUnix: bigint, + options: { isReady?: boolean } = {}, +): IckbDepositCell { + const minute = 60n * 1000n; + const ringEpoch = maturityUnix % minute === 0n ? maturityUnix / minute : maturityUnix; + const deposit: TestDepositCell = { + cell: ccc.Cell.from({ + outPoint: { txHash: hash(byte), index: 0n }, + cellOutput: { + capacity: 0n, + lock: script("22"), + }, + outputData: "0x", + }), + isReady: options.isReady ?? true, + ckbValue: udtValue, + udtValue, + maturity: new TestEpoch(ringEpoch, 0n, 1n, maturityUnix), + }; + if (isDepositFixture(deposit)) { + return deposit; + } + throw new Error("Invalid deposit fixture"); +} + +export function testMatch( + byte: string, +): ReturnType["partials"][number] { + return { order: testOrderCell(byte), ckbOut: 0n, udtOut: 0n }; +} + +export function testWithdrawal(byte: string): WithdrawalGroup { + const cell = ccc.Cell.from({ + outPoint: outPoint(byte), + cellOutput: { capacity: 0n, lock: script("91") }, + outputData: "0x0000000000000000", + }); + const header = { header: headerLike(), txHash: hash(byte) }; + const owned: TestWithdrawalRequestCell = { + cell, + headers: [header, header], + interests: 0n, + maturity: new TestEpoch(0n, 0n, 1n, 0n), + isReady: true, + isDeposit: false, + ckbValue: 0n, + udtValue: 0n, + }; + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: outPoint("90"), + cellOutput: { capacity: 0n, lock: script("90") }, + outputData: ReceiptData.encode({ + depositQuantity: 0n, + depositAmount: 0n, + }), + }), + ); + return new WithdrawalGroup(owned, owner); +} + +export function botRuntime(overrides: BotRuntimeOptions = {}): Runtime { + const client = new ccc.ClientPublicTestnet({ + url: "https://example.invalid", + }); + const signer = Object.assign( + new ccc.SignerCkbPrivateKey(client, `0x${"11".repeat(32)}`), + { + getAddressObjs: async () => { + await Promise.resolve(); + return []; + }, + }, + ); + const config = getConfig("testnet"); + + return { + chain: "testnet", + client, + signer, + managers: botManagers(config, overrides.managers), + sdk: botSdk(config, overrides.sdk), + primaryLock: overrides.primaryLock ?? script("11"), + }; +} + +export function botState(overrides: Partial): BotState { + const state: BotState = { + marketOrders: [], + availableCkbBalance: 0n, + availableIckbBalance: 0n, + unavailableCkbBalance: 0n, + totalCkbBalance: 0n, + depositCapacity: 100n, + minCkbBalance: 0n, + readyPoolDeposits: [], + userOrders: [], + receipts: [], + readyWithdrawals: [], + notReadyWithdrawals: [], + poolDeposits: [], + system: { + feeRate: 1n, + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + tip: headerLike(), + orderPool: [], + ckbAvailable: 0n, + ckbMaturing: [], + }, + ...overrides, + }; + if (overrides.poolDeposits === undefined) { + state.poolDeposits = [...state.readyPoolDeposits]; + } + return state; +} + +function botManagers( + config: ReturnType, + overrides: BotRuntimeOptions["managers"], +): Runtime["managers"] { + return { + dao: Object.assign(config.managers.dao, overrides?.dao), + ickbUdt: Object.assign(config.managers.ickbUdt, overrides?.ickbUdt), + order: Object.assign( + config.managers.order, + { + addMatch: (txLike: ccc.TransactionLike): ccc.Transaction => + ccc.Transaction.from(txLike), + }, + overrides?.order, + ), + ownedOwner: Object.assign( + config.managers.ownedOwner, + { script: script("33") }, + overrides?.ownedOwner, + ), + logic: Object.assign( + config.managers.logic, + { + deposit: async (txLike: ccc.TransactionLike): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + }, + overrides?.logic, + ), + }; +} + +function botSdk( + config: ReturnType, + overrides: BotRuntimeOptions["sdk"], +): IckbSdk { + return Object.assign(IckbSdk.fromConfig(config), { + buildBaseTransaction: async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + completeTransaction: async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + getL1AccountState: async (): ReturnType => { + await Promise.resolve(); + return emptyL1AccountState(); + }, + assertCurrentTip: async (): Promise => { + await Promise.resolve(); + }, + ...overrides, + }); +} + +function testOrderCell(byte: string): OrderCell { + return new OrderCell( + ccc.Cell.from({ + outPoint: outPoint(byte), + cellOutput: { capacity: 0n, lock: script("55") }, + outputData: "0x", + }), + OrderData.from({ + udtValue: 0n, + master: { + type: "relative", + value: { distance: 1n, padding: new Uint8Array(32) }, + }, + info: { + ckbToUdt: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + udtToCkb: Ratio.empty(), + ckbMinMatchLog: 0, + }, + }), + 0n, + 0n, + 0n, + undefined, + ); +} + +function emptyL1AccountState(): Awaited> { + return { + system: { + tip: headerLike(), + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + orderPool: [], + feeRate: 1n, + poolDeposits: { deposits: [], readyDeposits: [], id: "empty" }, + ckbAvailable: 0n, + ckbMaturing: [], + }, + user: { orders: [] }, + account: { + capacityCells: [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + }; +} + +class TestEpoch extends ccc.Epoch { + private readonly unix: bigint; + + constructor(integer: bigint, numerator: bigint, denominator: bigint, unix: bigint) { + super(integer, numerator, denominator); + this.unix = unix; + } + + public override add(epoch: ccc.EpochLike): ccc.Epoch { + const added = super.add(epoch); + return new TestEpoch( + added.integer, + added.numerator, + added.denominator, + this.unix + 16n, + ); + } + + public override toUnix(): bigint { + return this.unix; + } +} + +interface TestDepositCell { + cell: ccc.Cell; + isReady: boolean; + ckbValue: bigint; + udtValue: bigint; + maturity: ccc.Epoch; +} + +function isDepositFixture(value: unknown): value is IckbDepositCell { + return typeof value === "object" && value !== null && "cell" in value; +} diff --git a/packages/bot/test/bot/loop.ts b/packages/bot/test/bot/loop.ts new file mode 100644 index 0000000..130bd8b --- /dev/null +++ b/packages/bot/test/bot/loop.ts @@ -0,0 +1,539 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BotEventEmitter, + completeTerminalIteration, + reachedMaxRetryableAttempts, + runBotLoop, + type BotLoopContext, + type BotLoopOperations, +} from "../../src/index.ts"; +import type { BuildTransactionResult } from "../../src/runtime/types.ts"; +import { + noActionDecisionTranscript, + noActions, + record, +} from "../observability/fixtures/observability.ts"; +import { botRuntime, botState } from "./fixtures/bot.ts"; + +const FETCH_FAILED = "fetch failed"; +const DETERMINISTIC_BUILD_FAILURE = "deterministic build failure"; +const CONFIRMATION_TIMED_OUT = "confirmation timed out"; +const TRANSACTION_CONFIRMATION_ERROR = "TransactionConfirmationError"; +const REJECTED_STATUS = "rejected"; +const BOT_ITERATION_STARTED = "bot.iteration.started"; +const BOT_STATE_READ = "bot.state.read"; +const BOT_ITERATION_FAILED = "bot.iteration.failed"; +const TX_HASH_AB = `0x${"ab".repeat(32)}` as const; +const TX_HASH_FF = `0x${"ff".repeat(32)}` as const; +const TIMEOUT_TX_HASH = `0x${"99".repeat(32)}` as const; +const RBF_REJECTED_REASON = JSON.stringify({ + type: "RBFRejected", + description: `RBF rejected: replaced by tx Byte32(0x${"cd".repeat(32)})`, +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +describe("completeTerminalIteration", () => { + it("counts only terminal iterations toward bounded runs", () => { + expect(completeTerminalIteration(0, 1)).toEqual({ + completedIterations: 1, + shouldStop: true, + }); + expect(completeTerminalIteration(0, 2)).toEqual({ + completedIterations: 1, + shouldStop: false, + }); + expect(completeTerminalIteration(999, undefined)).toEqual({ + completedIterations: 1000, + shouldStop: false, + }); + }); +}); + +describe("reachedMaxRetryableAttempts", () => { + it("uses a separate retryable-attempt budget", () => { + expect(reachedMaxRetryableAttempts(1, 1)).toBe(true); + expect(reachedMaxRetryableAttempts(1, 2)).toBe(false); + expect(reachedMaxRetryableAttempts(999, undefined)).toBe(false); + }); +}); + +it("stops before building when total capital is below the minimum", async () => { + const { context, events, logs, operations } = loopHarness({ + readBotState: async () => { + await Promise.resolve(); + return botState({ minCkbBalance: 1n }); + }, + }); + + await runBotLoop(context); + + expect(operations.buildTransaction).not.toHaveBeenCalled(); + expect(operations.sleep).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + expect(eventTypes(events)).toEqual([ + BOT_ITERATION_STARTED, + BOT_STATE_READ, + "bot.decision.skipped", + ]); + expect(events[2]).toMatchObject({ + reason: "capital_below_minimum", + deficit: "1", + actions: noActions, + }); + expect(logs).toHaveLength(1); + expect(logs[0]).toMatchObject({ + error: + "The bot must have more than 0.00000001 CKB worth of capital to be able to operate, shutting down...", + }); +}); + +it("low-capital stops do not consume bounded completed iterations", async () => { + const { context, operations } = loopHarness({ + maxIterations: 1, + readBotState: async () => { + await Promise.resolve(); + return botState({ minCkbBalance: 1n }); + }, + }); + + await runBotLoop(context); + + expect(operations.sleep).not.toHaveBeenCalled(); + expect(operations.buildTransaction).not.toHaveBeenCalled(); +}); + +it("emits skipped decisions as completed bounded iterations", async () => { + const result = skippedResult(); + const { context, events, logs, operations } = loopHarness({ + buildTransaction: async () => { + await Promise.resolve(); + return result; + }, + }); + + await runBotLoop(context); + + expect(operations.buildTransaction).toHaveBeenCalledTimes(1); + expect(operations.sendAndWaitForCommit).not.toHaveBeenCalled(); + expect(operations.sleep).not.toHaveBeenCalled(); + expect(eventTypes(events)).toEqual([ + BOT_ITERATION_STARTED, + BOT_STATE_READ, + "bot.match.evaluated", + "bot.rebalance.evaluated", + "bot.decision.skipped", + ]); + expect(logs[0]).toMatchObject({ actions: noActions }); +}); + +it("sleeps between unbounded completed iterations", async () => { + const results = [skippedResult(), skippedResult()]; + const { context, operations } = loopHarness({ + maxIterations: undefined, + buildTransaction: async () => { + await Promise.resolve(); + const result = results.shift(); + if (result === undefined) { + return skippedResult(); + } + return result; + }, + sleep: async () => { + await Promise.resolve(); + context.maxIterations = 2; + }, + sleepInterval: asyncSleepInterval(123), + }); + + await runBotLoop(context); + + expect(operations.sleepInterval).toHaveBeenCalledWith(100); + expect(operations.sleep).toHaveBeenCalledWith(123); + expect(operations.buildTransaction).toHaveBeenCalledTimes(2); +}); + +it("sends built transactions and emits public lifecycle events", async () => { + const tx = ccc.Transaction.from({ + outputs: [{ capacity: 0n, lock: emptyScript("22") }], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(7n); + const { context, events, logs, operations } = loopHarness({ + buildTransaction: async () => { + await Promise.resolve(); + return builtResult(tx); + }, + sendAndWaitForCommit: async (_runtime, _tx, options) => { + await Promise.resolve(); + if (options === undefined) { + throw new Error("Expected send options"); + } + options.onSent?.(TX_HASH_AB); + options.onLifecycle?.({ + type: "broadcasted", + txHash: TX_HASH_AB, + elapsedMs: 1, + }); + options.onLifecycle?.({ + type: "committed", + txHash: TX_HASH_AB, + status: "committed", + checks: 1, + elapsedMs: 2, + }); + return TX_HASH_AB; + }, + }); + + await runBotLoop(context); + + expect(operations.sendAndWaitForCommit).toHaveBeenCalledTimes(1); + expect(eventTypes(events)).toEqual([ + BOT_ITERATION_STARTED, + BOT_STATE_READ, + "bot.match.evaluated", + "bot.rebalance.evaluated", + "bot.transaction.built", + "bot.transaction.sent", + "bot.transaction.confirmation", + "bot.transaction.committed", + ]); + expect(events[5]).toMatchObject({ + txHash: TX_HASH_AB, + transaction: { + fee: "7", + feeRate: "1", + shape: { inputs: 0, outputs: 1, cellDeps: 0, headerDeps: 0, witnesses: 0 }, + }, + }); + expect(logs[0]).toMatchObject({ + txFee: { fee: "0.00000007", feeRate: 1n }, + txHash: TX_HASH_AB, + }); + expect(operations.readBotState).toHaveBeenCalledTimes(1); +}); + +it("retries retryable failures without consuming bounded iterations", async () => { + const attempts: Array = [ + new TypeError(FETCH_FAILED), + new TypeError(FETCH_FAILED), + skippedResult(), + ]; + const { context, events, logs, operations } = loopHarness({ + maxIterations: 1, + maxRetryableAttempts: 3, + buildTransaction: async () => { + await Promise.resolve(); + const next = attempts.shift(); + if (next instanceof Error) { + throw next; + } + if (next === undefined) { + throw new Error("Expected loop attempt fixture"); + } + return next; + }, + }); + + await runBotLoop(context); + + expect(operations.buildTransaction).toHaveBeenCalledTimes(3); + expect(operations.sleep).toHaveBeenCalledTimes(2); + expect(logs).toHaveLength(3); + expect( + events.filter((event) => record(event, "event")["type"] === BOT_ITERATION_FAILED), + ).toMatchObject([ + { + retryable: true, + terminal: false, + retryableAttempts: 1, + maxRetryableAttempts: 3, + retryBudgetExhausted: false, + }, + { + retryable: true, + terminal: false, + retryableAttempts: 2, + maxRetryableAttempts: 3, + retryBudgetExhausted: false, + }, + ]); +}); + +it("stops after logging exhausted retryable failures", async () => { + const { context, events, logs, operations } = loopHarness({ + maxRetryableAttempts: 1, + buildTransaction: async () => { + await Promise.resolve(); + throw new TypeError(FETCH_FAILED); + }, + }); + + await runBotLoop(context); + + expect(operations.buildTransaction).toHaveBeenCalledTimes(1); + expect(operations.sleep).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + expect(events.at(-1)).toMatchObject({ + type: BOT_ITERATION_FAILED, + retryable: true, + terminal: true, + retryableAttempts: 1, + maxRetryableAttempts: 1, + retryBudgetExhausted: true, + }); + expect(logs[0]).toMatchObject({ + error: { + message: "Retryable bot error budget exhausted", + attempts: 1, + maxRetryableAttempts: 1, + lastError: { name: "TypeError", message: FETCH_FAILED }, + }, + }); +}); + +it("stops on non-retryable failures and records terminal metadata", async () => { + const { context, events, logs, operations } = loopHarness({ + buildTransaction: async () => { + await Promise.resolve(); + throw new Error(DETERMINISTIC_BUILD_FAILURE); + }, + }); + + await runBotLoop(context); + + expect(operations.buildTransaction).toHaveBeenCalledTimes(1); + expect(operations.sleep).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(events.at(-1)).toMatchObject({ + type: BOT_ITERATION_FAILED, + retryable: false, + terminal: true, + }); + expect(logs[0]).toMatchObject({ + error: { name: "Error", message: DETERMINISTIC_BUILD_FAILURE }, + }); +}); + +it("retries post-broadcast RBF confirmation rejections", async () => { + const tx = ccc.Transaction.from({ + outputs: [{ capacity: 0n, lock: emptyScript("33") }], + }); + const { context, events, logs, operations } = loopHarness({ + maxIterations: 1, + buildTransaction: async () => { + await Promise.resolve(); + return builtResult(tx); + }, + sendAndWaitForCommit: vi + .fn() + .mockRejectedValueOnce(rbfConfirmationError()) + .mockResolvedValueOnce(TX_HASH_FF), + }); + + await runBotLoop(context); + + expect(operations.buildTransaction).toHaveBeenCalledTimes(2); + expect(operations.sendAndWaitForCommit).toHaveBeenCalledTimes(2); + expect(process.exitCode).toBeUndefined(); + expect( + events.find((event) => record(event, "event")["type"] === BOT_ITERATION_FAILED), + ).toMatchObject({ + retryable: true, + terminal: false, + error: { + name: TRANSACTION_CONFIRMATION_ERROR, + txHash: TX_HASH_AB, + status: REJECTED_STATUS, + isTimeout: false, + reason: RBF_REJECTED_REASON, + }, + }); + expect(logs[0]).toMatchObject({ + error: { + name: "TransactionConfirmationError", + txHash: TX_HASH_AB, + status: REJECTED_STATUS, + isTimeout: false, + reason: RBF_REJECTED_REASON, + }, + }); + expect(logs[1]).toMatchObject({ txHash: TX_HASH_FF }); +}); + +it("preserves stop-worthy loop error exit codes", async () => { + const timeout = Object.assign(new Error(CONFIRMATION_TIMED_OUT), { + isTimeout: true, + txHash: TIMEOUT_TX_HASH, + status: "pending", + }); + Object.defineProperty(timeout, "name", { value: "TransactionConfirmationError" }); + const { context, events, logs } = loopHarness({ + buildTransaction: async () => { + await Promise.resolve(); + throw timeout; + }, + }); + + await runBotLoop(context); + + expect(process.exitCode).toBe(2); + expect(events.at(-1)).toMatchObject({ + type: BOT_ITERATION_FAILED, + retryable: false, + terminal: true, + error: { + name: "TransactionConfirmationError", + message: CONFIRMATION_TIMED_OUT, + txHash: TIMEOUT_TX_HASH, + status: "pending", + isTimeout: true, + }, + }); + expect(logs[0]).toMatchObject({ + error: { + name: "TransactionConfirmationError", + message: CONFIRMATION_TIMED_OUT, + txHash: TIMEOUT_TX_HASH, + status: "pending", + isTimeout: true, + }, + }); +}); + +function eventTypes(events: unknown[]): string[] { + return Array.from(events, (event) => String(record(event, "event")["type"])); +} + +const defaultBuildTransaction: BotLoopOperations["buildTransaction"] = async () => { + await Promise.resolve(); + return skippedResult(); +}; + +function captureExecutionLog( + logs: Array>, +): BotLoopOperations["logExecution"] { + return (executionLog): void => { + logs.push({ ...executionLog }); + }; +} + +const defaultReadBotState: BotLoopOperations["readBotState"] = async () => { + await Promise.resolve(); + return botState({ + availableCkbBalance: 1n, + totalCkbBalance: 1n, + minCkbBalance: 0n, + }); +}; + +const defaultSendAndWaitForCommit: BotLoopOperations["sendAndWaitForCommit"] = + async () => { + await Promise.resolve(); + return TX_HASH_FF; + }; + +const defaultSleep: BotLoopOperations["sleep"] = async () => { + await Promise.resolve(); +}; + +const defaultSleepInterval: BotLoopOperations["sleepInterval"] = () => 0; + +function loopHarness( + overrides: Partial & { + maxIterations?: number; + maxRetryableAttempts?: number; + } = {}, +): { + context: BotLoopContext; + events: unknown[]; + logs: Array>; + operations: BotLoopOperations; +} { + const { maxIterations, maxRetryableAttempts, ...operationOverrides } = overrides; + const events: unknown[] = []; + const logs: Array> = []; + const runtime = botRuntime(); + const operations: BotLoopOperations = { + buildTransaction: vi.fn( + operationOverrides.buildTransaction ?? defaultBuildTransaction, + ), + logExecution: vi.fn(operationOverrides.logExecution ?? captureExecutionLog(logs)), + readBotState: vi.fn(operationOverrides.readBotState ?? defaultReadBotState), + sendAndWaitForCommit: vi.fn( + operationOverrides.sendAndWaitForCommit ?? defaultSendAndWaitForCommit, + ), + sleep: vi.fn(operationOverrides.sleep ?? defaultSleep), + sleepInterval: vi.fn(operationOverrides.sleepInterval ?? defaultSleepInterval), + }; + return { + context: { + events: new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + events.push(event); + }, + }), + runtime, + sleepIntervalMs: 100, + maxIterations: "maxIterations" in overrides ? maxIterations : 1, + maxRetryableAttempts, + operations, + }, + events, + logs, + operations, + }; +} + +function rbfConfirmationError(): Error { + const error = Object.assign(new Error("Transaction ended with status: rejected"), { + txHash: TX_HASH_AB, + status: REJECTED_STATUS, + isTimeout: false, + reason: RBF_REJECTED_REASON, + }); + Object.defineProperty(error, "name", { value: TRANSACTION_CONFIRMATION_ERROR }); + return error; +} + +function asyncSleepInterval(value: number): BotLoopOperations["sleepInterval"] { + return () => value; +} + +function skippedResult(): BuildTransactionResult { + return { + kind: "skipped", + reason: "no_actions", + actions: noActions, + decision: noActionDecisionTranscript(), + }; +} + +function builtResult(tx: ccc.Transaction): BuildTransactionResult { + return { + kind: "built", + tx, + actions: { ...noActions, completedDeposits: 1 }, + decision: { + ...noActionDecisionTranscript(), + actions: { ...noActions, completedDeposits: 1 }, + transactionShape: { + inputs: tx.inputs.length, + outputs: tx.outputs.length, + cellDeps: tx.cellDeps.length, + headerDeps: tx.headerDeps.length, + witnesses: tx.witnesses.length, + }, + }, + }; +} + +function emptyScript(byte: string): ccc.ScriptLike { + return { codeHash: `0x${byte.repeat(32)}`, hashType: "type", args: "0x" }; +} diff --git a/packages/bot/test/bot/outputBoundary.ts b/packages/bot/test/bot/outputBoundary.ts new file mode 100644 index 0000000..b789158 --- /dev/null +++ b/packages/bot/test/bot/outputBoundary.ts @@ -0,0 +1,104 @@ +import { handleLoopError, logExecution } from "@ickb/node-utils"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + isRetryableBotError, + iterationFailureEventFields, + readBotRuntimeConfig, +} from "../../src/index.ts"; +import { BotEventEmitter } from "../../src/observability/events.ts"; +import { transactionLifecycleEvents } from "../../src/observability/lifecycle.ts"; +import { hash } from "./fixtures/bot.ts"; + +type BotRuntimeConfig = Awaited>; + +const CONFIG_FILE_NAME = "config.json"; +const FETCH_FAILED = "fetch failed"; + +describe("bot private key output boundary", () => { + it("does not leak the configured canary key across representative crash outputs", async () => { + const privateKey = `0x${"42".repeat(32)}`; + const dir = await mkdtemp(path.join(tmpdir(), "ickb-bot-private-key-boundary-")); + const output: string[] = []; + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + try { + const runtimeConfig = await readPrivateKeyBoundaryRuntimeConfig(dir, privateKey); + emitRepresentativeCrashOutputs(runtimeConfig, output); + + expect(runtimeConfig.privateKey).toBe(privateKey); + expect(output.join("\n")).not.toContain(privateKey); + } finally { + stdoutWrite.mockRestore(); + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +async function readPrivateKeyBoundaryRuntimeConfig( + dir: string, + privateKey: string, +): Promise { + const configPath = path.join(dir, CONFIG_FILE_NAME); + await writeFile( + configPath, + JSON.stringify({ + chain: "testnet", + privateKey, + sleepIntervalSeconds: 60, + maxIterations: 1, + }), + { mode: 0o600 }, + ); + return readBotRuntimeConfig({ BOT_CONFIG_FILE: configPath }); +} + +function emitRepresentativeCrashOutputs( + runtimeConfig: BotRuntimeConfig, + output: string[], +): void { + const emitter = new BotEventEmitter({ + chain: runtimeConfig.chain, + runId: "run-canary-test", + write: (event): void => { + output.push(JSON.stringify(event)); + }, + }); + emitter.emit(0, "bot.run.started", { + runtime: { + maxIterations: runtimeConfig.maxIterations, + maxRetryableAttempts: runtimeConfig.maxRetryableAttempts, + bounded: runtimeConfig.maxIterations !== undefined, + sleepIntervalMs: runtimeConfig.sleepIntervalMs, + rpcConfigured: runtimeConfig.rpcUrl !== undefined, + }, + }); + emitter.emit(0, "bot.chain.preflight", { + rpcConfigured: runtimeConfig.rpcUrl !== undefined, + expected: { chain: "testnet", genesisHash: hash("11"), addressPrefix: "ckt" }, + observed: { + genesisHash: hash("11"), + addressPrefix: "ckt", + tip: { hash: hash("22"), number: 1n, timestamp: 2n }, + }, + matches: { genesisHash: true, addressPrefix: true }, + }); + const executionLog: Record = { startTime: "fixture" }; + handleLoopError(executionLog, new Error("deterministic crash")); + logExecution(executionLog, new Date()); + emitter.emit( + 1, + "bot.iteration.failed", + iterationFailureEventFields(new TypeError(FETCH_FAILED)), + ); + for (const lifecycle of transactionLifecycleEvents( + { type: "pre_broadcast_failed", elapsedMs: 1, error: new TypeError(FETCH_FAILED) }, + isRetryableBotError, + )) { + emitter.emit(1, lifecycle.type, lifecycle.fields); + } +} diff --git a/packages/bot/test/bot/state.ts b/packages/bot/test/bot/state.ts new file mode 100644 index 0000000..b6fc552 --- /dev/null +++ b/packages/bot/test/bot/state.ts @@ -0,0 +1,169 @@ +import { ccc } from "@ckb-ccc/core"; +import type { IckbDepositCell } from "@ickb/core"; +import { MasterCell, OrderGroup, Ratio } from "@ickb/order"; +import type { IckbSdk } from "@ickb/sdk"; +import { headerLike, script } from "@ickb/testkit"; +import { describe, expect, it, vi } from "vitest"; +import { readBotState } from "../../src/index.ts"; +import { POOL_MAX_LOCK_UP, POOL_MIN_LOCK_UP } from "../../src/policy.ts"; +import { botRuntime, NO_DEPOSITS, readyDeposit, testMatch } from "./fixtures/bot.ts"; + +describe("readBotState pool snapshot validation", () => { + it("partitions SDK pool deposits without a second pool scan", async () => { + const tip = headerLike({ number: 10n, epoch: [0n, 0n, 1n], timestamp: "0x0" }); + const readyWindowEnd = POOL_MAX_LOCK_UP.add(tip.epoch).toUnix(tip); + const ready = readyDeposit("33", 1n, 1n, { isReady: true }); + const tooEarly = readyDeposit("34", 2n, readyWindowEnd - 1n, { isReady: false }); + const nearReady = readyDeposit("35", 3n, readyWindowEnd + 1n, { + isReady: false, + }); + const future = readyDeposit("36", 4n, readyWindowEnd + 60n * 60n * 1000n, { + isReady: false, + }); + const getL1AccountState = vi.fn(); + getL1AccountState.mockResolvedValue({ + system: { + tip, + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + orderPool: [], + feeRate: 1n, + poolDeposits: { + deposits: [ready, tooEarly, nearReady, future], + readyDeposits: [ready], + id: "pool", + }, + ckbAvailable: 0n, + ckbMaturing: [], + }, + user: { orders: [] }, + account: { + capacityCells: [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + }); + const assertCurrentTip = vi.fn(); + const findDeposits = vi.fn(async function* (): AsyncGenerator { + await Promise.resolve(); + yield* NO_DEPOSITS; + }); + const runtime = botRuntime({ + sdk: { getL1AccountState, assertCurrentTip }, + managers: { logic: { findDeposits } }, + }); + + const state = await readBotState(runtime); + + expect(getL1AccountState).toHaveBeenCalledTimes(1); + expect(getL1AccountState.mock.calls[0]?.[2]).toMatchObject({ + poolDeposits: { minLockUp: POOL_MIN_LOCK_UP, maxLockUp: POOL_MAX_LOCK_UP }, + }); + expect(findDeposits).not.toHaveBeenCalled(); + expect(assertCurrentTip).not.toHaveBeenCalled(); + expect(state.readyPoolDeposits).toEqual([ready]); + expect(state.poolDeposits).toEqual([ready, tooEarly, nearReady, future]); + }); +}); + +describe("readBotState", () => { + it("excludes own orders from the market and projects account availability", async () => { + const ownOrder = testOrderGroup("46", 7n); + const marketOrder = testMatch("47").order; + const capacityCell = ccc.Cell.from({ + outPoint: { txHash: `0x${"44".repeat(32)}`, index: 0n }, + cellOutput: { capacity: 5n, lock: script("44") }, + outputData: "0x", + }); + const nativeUdtCell = ccc.Cell.from({ + outPoint: { txHash: `0x${"48".repeat(32)}`, index: 0n }, + cellOutput: { capacity: 0n, lock: script("44") }, + outputData: ccc.numLeToBytes(11n, 16), + }); + const getL1AccountState = vi.fn(); + getL1AccountState.mockResolvedValue({ + system: { + tip: headerLike(), + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + orderPool: [ownOrder.order, marketOrder], + feeRate: 1n, + poolDeposits: { deposits: [], readyDeposits: [], id: "pool" }, + ckbAvailable: 0n, + ckbMaturing: [], + }, + user: { orders: [ownOrder] }, + account: { + capacityCells: [capacityCell], + nativeUdtCells: [nativeUdtCell], + nativeUdtCapacity: 0n, + nativeUdtBalance: 11n, + receipts: [], + withdrawalGroups: [], + }, + }); + + const state = await readBotState(botRuntime({ sdk: { getL1AccountState } })); + + expect(state.userOrders).toEqual([ownOrder]); + expect(state.marketOrders).toEqual([marketOrder]); + expect(state.availableCkbBalance).toBe( + capacityCell.cellOutput.capacity + ownOrder.ckbValue, + ); + expect(state.availableIckbBalance).toBe(11n + ownOrder.udtValue); + expect(state.unavailableCkbBalance).toBe(0n); + expect(state.totalCkbBalance).toBe( + state.availableCkbBalance + state.unavailableCkbBalance, + ); + }); + + it("fails closed when L1 account state omits the pool deposit snapshot", async () => { + const runtime = botRuntime({ + sdk: { + getL1AccountState: async (): ReturnType => { + await Promise.resolve(); + return { + system: { + tip: headerLike(), + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + orderPool: [], + feeRate: 1n, + ckbAvailable: 0n, + ckbMaturing: [], + }, + user: { orders: [] }, + account: { + capacityCells: [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + }; + }, + }, + }); + + await expect(readBotState(runtime)).rejects.toThrow( + "L1 account state is missing pool deposit snapshot", + ); + }); +}); + +function testOrderGroup(byte: string, ckbValue: bigint): OrderGroup { + const order = testMatch(byte).order; + order.cell.cellOutput.capacity = ckbValue; + return new OrderGroup( + new MasterCell( + ccc.Cell.from({ + outPoint: { txHash: `0x${"45".repeat(32)}`, index: 0n }, + cellOutput: { capacity: 0n, lock: script("45") }, + outputData: "0x", + }), + ), + order, + order, + ); +} diff --git a/packages/bot/test/observability/artifacts.ts b/packages/bot/test/observability/artifacts.ts new file mode 100644 index 0000000..4baac6e --- /dev/null +++ b/packages/bot/test/observability/artifacts.ts @@ -0,0 +1,264 @@ +import { createHash } from "node:crypto"; +import fsPromises, { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import * as artifacts from "../../src/observability/artifacts.ts"; +import { BotEventEmitter } from "../../src/observability/events.ts"; +import { BOT_OBSERVABILITY_SUITE } from "./fixtures/observability.ts"; + +const artifactPrefix = "artifacts/slot-00"; +const artifactKind = "bot.ringSegments"; +const artifactDirectory = "ringSegments"; +const artifactPayload = { ring: { totalPoolUdt: 1n, segments: [] } }; +const artifactTempPrefix = "ickb-bot-artifact-"; + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("writes content-addressed artifacts with hash filenames", async () => { + const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); + try { + await expectContentAddressedArtifact(artifactRoot); + } finally { + await rm(artifactRoot, { force: true, recursive: true }); + } + }); + + it("normalizes unscoped artifact kinds into safe directories", async () => { + const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); + try { + const ref = await writeArtifact(artifactRoot, "ring/segments.v2", artifactPayload); + + expect(ref.path).toMatch( + /^artifacts\/slot-00\/ring-segments-v2\/sha256-[\da-f]+\.json$/u, + ); + } finally { + await rm(artifactRoot, { force: true, recursive: true }); + } + }); + + it("canonicalizes nested array artifact payloads", async () => { + const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); + try { + const ref = await writeArtifact(artifactRoot, artifactKind, { + ring: { rows: [[{ totalPoolUdt: 1n }]] }, + }); + const hash = ref.hash.slice("sha256:".length); + + await expect(readArtifactFile(artifactFilePath(artifactRoot, hash))).resolves.toBe( + '{"kind":"bot.ringSegments","ring":{"rows":[[{"totalPoolUdt":"1"}]]},"version":1}\n', + ); + } finally { + await rm(artifactRoot, { force: true, recursive: true }); + } + }); +}); + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("verifies existing artifact content before reusing a hash path", async () => { + const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); + try { + await expectExistingArtifactVerification(artifactRoot); + } finally { + await rm(artifactRoot, { force: true, recursive: true }); + } + }); + + it("refuses symlinked artifact directories", async () => { + const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); + try { + await expectSymlinkedArtifactRefusal(artifactRoot); + } finally { + await rm(artifactRoot, { force: true, recursive: true }); + } + }); +}); + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("refuses non-file artifacts at existing content-addressed paths", async () => { + const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); + try { + const emitter = artifactEmitter(artifactRoot); + const first = await emitter.writeArtifact(artifactKind, artifactPayload); + const hash = first?.hash.slice("sha256:".length) ?? ""; + const artifactPath = artifactFilePath(artifactRoot, hash); + await removeArtifactFile(artifactPath); + await makeArtifactDirectory(artifactPath); + + await expect(emitter.writeArtifact(artifactKind, artifactPayload)).rejects.toThrow( + /not a regular file/u, + ); + } finally { + await rm(artifactRoot, { force: true, recursive: true }); + } + }); + + it("preserves successful artifact writes when temp cleanup fails", async () => { + const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); + const rmSpy = vi + .spyOn(fsPromises, "rm") + .mockRejectedValueOnce(new Error("cleanup failed")); + try { + await expect( + writeArtifact(artifactRoot, artifactKind, artifactPayload), + ).rejects.toThrow(/cleanup failed/u); + } finally { + rmSpy.mockRestore(); + await rm(artifactRoot, { force: true, recursive: true }); + } + }); + + it("preserves earlier artifact errors when temp cleanup also fails", async () => { + const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); + const linkSpy = vi + .spyOn(fsPromises, "link") + .mockRejectedValueOnce(new Error("link failed")); + const rmSpy = vi + .spyOn(fsPromises, "rm") + .mockRejectedValueOnce(new Error("cleanup failed")); + try { + await expect( + writeArtifact(artifactRoot, artifactKind, artifactPayload), + ).rejects.toThrow(/link failed/u); + } finally { + linkSpy.mockRestore(); + rmSpy.mockRestore(); + await rm(artifactRoot, { force: true, recursive: true }); + } + }); + + it("does not clean up a temp artifact when temp creation fails", async () => { + const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); + const openSpy = vi + .spyOn(fsPromises, "open") + .mockRejectedValueOnce(new Error("open failed")); + const rmSpy = vi.spyOn(fsPromises, "rm"); + try { + await expect( + writeArtifact(artifactRoot, artifactKind, artifactPayload), + ).rejects.toThrow(/open failed/u); + expect(rmSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + rmSpy.mockRestore(); + await rm(artifactRoot, { force: true, recursive: true }); + } + }); +}); + +async function writeArtifact( + artifactRoot: string, + kind: string, + payload: Record, +): Promise { + return artifacts.writeBotArtifact({ + artifactRefPrefix: artifactPrefix, + artifactRoot, + kind, + payload, + }); +} + +async function expectContentAddressedArtifact(artifactRoot: string): Promise { + const emitter = artifactEmitter(artifactRoot); + const ref = await emitter.writeArtifact(artifactKind, artifactPayload); + + if (ref === undefined) { + throw new Error("expected artifact ref"); + } + const hash = ref.hash.slice("sha256:".length); + expect(ref).toMatchObject({ + kind: artifactKind, + hash: `sha256:${hash}`, + path: `${artifactPrefix}/${artifactDirectory}/sha256-${hash}.json`, + }); + const artifactPath = artifactFilePath(artifactRoot, hash); + const text = await readArtifactFile(artifactPath); + expect(ref.hash).toBe(sha256Ref(text)); + expect(JSON.parse(text)).toEqual({ + kind: artifactKind, + ring: { segments: [], totalPoolUdt: "1" }, + version: 1, + }); + expect((await statArtifactFile(artifactPath)).mode & 0o777).toBe(0o600); +} + +async function expectExistingArtifactVerification(artifactRoot: string): Promise { + const emitter = artifactEmitter(artifactRoot); + const first = await emitter.writeArtifact(artifactKind, artifactPayload); + const second = await emitter.writeArtifact(artifactKind, artifactPayload); + expect(second).toEqual(first); + + const hash = first?.hash.slice("sha256:".length) ?? ""; + await writeArtifactFile(artifactFilePath(artifactRoot, hash), "wrong content\n"); + await expect(emitter.writeArtifact(artifactKind, artifactPayload)).rejects.toThrow( + /content hash/u, + ); + await expect(artifactTempFiles(artifactRoot)).resolves.toEqual([]); +} + +async function expectSymlinkedArtifactRefusal(artifactRoot: string): Promise { + await makeArtifactDirectory(path.join(artifactRoot, "outside")); + await makeArtifactSymlink( + path.join(artifactRoot, "outside"), + path.join(artifactRoot, artifactDirectory), + ); + await expect( + artifactEmitter(artifactRoot).writeArtifact(artifactKind, { ring: { segments: [] } }), + ).rejects.toThrow(/symlinked artifact path/u); +} + +function artifactEmitter(artifactRoot: string): BotEventEmitter { + return new BotEventEmitter({ + artifactRefPrefix: artifactPrefix, + artifactRoot, + chain: "testnet", + runId: "run-1", + }); +} + +function artifactFilePath(artifactRoot: string, hash: string): string { + return path.join(artifactRoot, artifactDirectory, `sha256-${hash}.json`); +} + +async function readArtifactFile(artifactPath: string): Promise { + return readFile(artifactPath, "utf8"); +} + +async function statArtifactFile(artifactPath: string): Promise<{ mode: number }> { + return stat(artifactPath); +} + +async function writeArtifactFile(artifactPath: string, text: string): Promise { + await writeFile(artifactPath, text, { mode: 0o600 }); +} + +async function removeArtifactFile(artifactPath: string): Promise { + await unlink(artifactPath); +} + +async function artifactTempFiles(artifactRoot: string): Promise { + const names = await readdir(path.join(artifactRoot, artifactDirectory)); + return names.filter((name) => name.includes(".tmp-")); +} + +async function makeArtifactDirectory(directory: string): Promise { + await mkdir(directory); +} + +async function makeArtifactSymlink(target: string, destination: string): Promise { + await symlink(target, destination, "dir"); +} + +function sha256Ref(text: string): string { + return `sha256:${createHash("sha256").update(text).digest("hex")}`; +} diff --git a/packages/bot/test/observability/decision.ts b/packages/bot/test/observability/decision.ts new file mode 100644 index 0000000..8938b55 --- /dev/null +++ b/packages/bot/test/observability/decision.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { + BotEventEmitter, + emitDecisionEvents, + lowCapitalSkipDecision, + type BotArtifactRef, +} from "../../src/observability/events.ts"; +import type { BotStateSummary } from "../../src/runtime/types.ts"; +import { + BOT_OBSERVABILITY_SUITE, + NO_ACTION_SKIP_RESULT, + noActions, + record, +} from "./fixtures/observability.ts"; + +const RING_SEGMENTS_ARTIFACT_PATH = "artifacts/ringSegments/sha256-abc.json"; +const REBALANCE_EVENT = "rebalance event"; + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("emits compact rebalance events with full ring artifacts", async () => { + const { artifacts, emitter, events } = artifactCapturingEmitter(); + + await emitDecisionEvents(emitter, 1, NO_ACTION_SKIP_RESULT); + + expectCompactRingEvents(events); + expectRingArtifact(artifacts); + const decision = record(record(events[2], "decision event")["decision"], "decision"); + expect(decision["rebalance"]).not.toHaveProperty("diagnostics"); + }); + + it("keeps inline ring diagnostics when artifact output is disabled", async () => { + const events: unknown[] = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + events.push(event); + }, + }); + + await emitDecisionEvents(emitter, 1, NO_ACTION_SKIP_RESULT); + + const rebalance = record( + record(events[1], REBALANCE_EVENT)["rebalance"], + "rebalance", + ); + const ring = record(record(rebalance["diagnostics"], "diagnostics")["ring"], "ring"); + expect(ring["segments"]).toHaveLength(2); + expect(ring).not.toHaveProperty("segmentsRef"); + }); + + it("records public artifact write failures in ring diagnostics", async () => { + const { emitter, events } = artifactCapturingEmitter(); + emitter.writeArtifact = async (): Promise => { + await Promise.resolve(); + throw new Error("disk full"); + }; + + await emitDecisionEvents(emitter, 1, NO_ACTION_SKIP_RESULT); + + const rebalance = record( + record(events[1], REBALANCE_EVENT)["rebalance"], + "rebalance", + ); + const ring = record(record(rebalance["diagnostics"], "diagnostics")["ring"], "ring"); + expect(ring["artifactWriteFailed"]).toBe("disk full"); + expect(ring["segments"]).toHaveLength(2); + }); + + it("records unknown artifact write failures without leaking thrown values", async () => { + const { emitter, events } = artifactCapturingEmitter(); + emitter.writeArtifact = async (): Promise => { + await new Promise((_resolve, reject) => { + Reflect.apply(reject, undefined, ["private-ish raw thrown value"]); + }); + throw new Error("unreachable"); + }; + + await emitDecisionEvents(emitter, 1, NO_ACTION_SKIP_RESULT); + + const rebalance = record( + record(events[1], REBALANCE_EVENT)["rebalance"], + "rebalance", + ); + const ring = record(record(rebalance["diagnostics"], "diagnostics")["ring"], "ring"); + expect(ring["artifactWriteFailed"]).toBe("Unknown artifact write error"); + expect(JSON.stringify(ring)).not.toContain("private-ish raw thrown value"); + }); +}); + +function artifactCapturingEmitter(): { + artifacts: Array<{ kind: string; payload: Record }>; + emitter: BotEventEmitter; + events: unknown[]; +} { + const events: unknown[] = []; + const artifacts: Array<{ kind: string; payload: Record }> = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + events.push(event); + }, + }); + emitter.writeArtifact = async (kind, payload): Promise => { + await Promise.resolve(); + artifacts.push({ kind, payload }); + return { + kind, + hash: "sha256:abc", + path: RING_SEGMENTS_ARTIFACT_PATH, + }; + }; + return { artifacts, emitter, events }; +} + +function expectCompactRingEvents(events: unknown[]): void { + expect(events).toHaveLength(3); + expect(events).toMatchObject([ + { type: "bot.match.evaluated" }, + { + type: "bot.rebalance.evaluated", + rebalance: { + diagnostics: { + ring: { + poolDepositCount: 2, + emptySegmentCount: 1, + nonemptySegmentCount: 1, + protectedDepositCount: 1, + protectedUdtValue: "2", + surplusDepositCount: 1, + surplusUdtValue: "0", + heaviestSegmentIndex: 0, + segmentsRef: { + kind: "bot.ringSegments", + hash: "sha256:abc", + path: RING_SEGMENTS_ARTIFACT_PATH, + }, + }, + }, + }, + }, + { + type: "bot.decision.skipped", + reason: "no_actions", + actions: noActions, + decision: { + rebalance: { kind: "none", reason: "no_withdrawable_ickb" }, + skip: { reason: "no_actions" }, + }, + }, + ]); + const rebalanceEvent = record(events[1], REBALANCE_EVENT); + const rebalance = record(rebalanceEvent["rebalance"], "rebalance"); + const diagnostics = record(rebalance["diagnostics"], "diagnostics"); + const ring = record(diagnostics["ring"], "ring"); + expect(ring).not.toHaveProperty("segments"); +} + +function expectRingArtifact( + artifacts: Array<{ kind: string; payload: Record }>, +): void { + expect(artifacts).toMatchObject([ + { + kind: "bot.ringSegments", + payload: { + ring: { + segmentCount: 2, + segments: [ + { + index: 0, + depositCount: 2, + udtValue: 2n, + protectedDepositCount: 1, + protectedUdtValue: 2n, + protectedOutPoints: ["0xprotected"], + surplusDepositCount: 1, + surplusUdtValue: 0n, + surplusOutPoints: ["0xsurplus"], + }, + { + index: 1, + depositCount: 0, + udtValue: 0n, + protectedDepositCount: 0, + protectedUdtValue: 0n, + protectedOutPoints: [], + surplusDepositCount: 0, + surplusUdtValue: 0n, + surplusOutPoints: [], + }, + ], + }, + }, + }, + ]); +} + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("keeps low-capital safety skips state-only", () => { + const state: BotStateSummary = { + chainTip: { + blockNumber: 1n, + blockHash: `0x${"11".repeat(32)}`, + timestamp: 2n, + epoch: { integer: 3n, numerator: 0n, denominator: 1n }, + }, + balances: { + availableCkb: 0n, + unavailableCkb: 0n, + totalCkb: 0n, + availableIckb: 0n, + totalEquivalentCkb: 0n, + totalEquivalentIckb: 0n, + minimumCkbCapital: 1n, + spendableCkb: 0n, + matchableCkb: 0n, + }, + orders: { marketCount: 0, userCount: 0, receiptCount: 0 }, + withdrawals: { readyCount: 0, pendingCount: 0 }, + poolDeposits: { totalCount: 0, readyCount: 0 }, + exchangeRatio: { ckbScale: 1n, udtScale: 1n }, + depositCapacity: 0n, + fee: { feeRate: 1n }, + }; + + const skip = lowCapitalSkipDecision(state); + + expect(skip).toEqual({ + reason: "capital_below_minimum", + actions: noActions, + state, + deficit: 1n, + }); + expect(skip).not.toHaveProperty("decision"); + }); +}); diff --git a/packages/bot/test/observability/error.ts b/packages/bot/test/observability/error.ts new file mode 100644 index 0000000..d604653 --- /dev/null +++ b/packages/bot/test/observability/error.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; +import { errorSummary } from "../../src/observability/error.ts"; +import { + BOT_OBSERVABILITY_SUITE, + CREDENTIAL_CONFIG_FILE, + OUTER_PUBLIC_FAILURE, + POOL_REJECTED_RBF_MESSAGE, + RBF_REJECTED_DATA, + SCAN_RACED_CHAIN_TIP, + nestedRecord, + record, +} from "./fixtures/observability.ts"; + +const ENUMERABLE_ERROR_MESSAGE = "enumerable message"; + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("keeps stack traces by default but can summarize retryable errors", () => { + const error = new Error(SCAN_RACED_CHAIN_TIP); + const summary = errorSummary(error); + + expect(summary).toMatchObject({ + name: "Error", + message: SCAN_RACED_CHAIN_TIP, + }); + expect(typeof summary).toBe("object"); + expect(summary).not.toBeNull(); + expect(record(summary, "summary")["stack"]).toContain(SCAN_RACED_CHAIN_TIP); + expect(errorSummary(error, { includeStack: false })).toEqual({ + name: "Error", + message: SCAN_RACED_CHAIN_TIP, + }); + }); + + it("preserves public CKB RPC error fields from Error objects", () => { + const rbfError = Object.assign(new Error(POOL_REJECTED_RBF_MESSAGE), { + code: -1111, + data: RBF_REJECTED_DATA, + currentFee: 11795n, + leastFee: 12326n, + }); + const resolveError = Object.assign( + new Error("Client request error TransactionFailedToResolve"), + { + code: -301, + data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, + outPoint: { + txHash: `0x${"11".repeat(32)}`, + index: 0n, + }, + }, + ); + + expect(errorSummary(rbfError, { includeStack: false })).toEqual({ + name: "Error", + message: POOL_REJECTED_RBF_MESSAGE, + code: -1111, + data: RBF_REJECTED_DATA, + currentFee: "11795", + leastFee: "12326", + }); + expect(errorSummary(resolveError, { includeStack: false })).toEqual({ + name: "Error", + message: "Client request error TransactionFailedToResolve", + code: -301, + data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, + outPoint: { + txHash: `0x${"11".repeat(32)}`, + index: "0", + }, + }); + }); +}); + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("preserves transaction-shaped error messages in structured error summaries", () => { + const error = new Error(`failed with witness 0x${"22".repeat(80)}`); + + const summary = record(errorSummary(error), "summary"); + + expect(summary["name"]).toBe("Error"); + expect(summary["message"]).toBe(`failed with witness 0x${"22".repeat(80)}`); + expect(summary["stack"]).toContain("failed with witness"); + }); + + it("preserves serialized transaction-shaped error messages", () => { + const error = new Error(`failed {"witnesses":["0x${"22".repeat(80)}"],"inputs":[]}`); + + const summary = record(errorSummary(error), "summary"); + + expect(summary["message"]).toBe( + `failed {"witnesses":["0x${"22".repeat(80)}"],"inputs":[]}`, + ); + }); + + it("preserves nested error causes in structured error summaries", () => { + const cause = new Error("inner public failure"); + const error = new Error(OUTER_PUBLIC_FAILURE, { cause }); + + const summary = record(errorSummary(error), "summary"); + + expect(summary["cause"]).toMatchObject({ + name: "Error", + message: "inner public failure", + }); + }); + + it("tracks circular references in custom Error properties", () => { + const details: Record = {}; + const error = Object.assign(new Error(OUTER_PUBLIC_FAILURE), { details }); + error.details["self"] = error; + + const summary = record(errorSummary(error, { includeStack: false }), "summary"); + + expect(summary).toEqual({ + name: "Error", + message: OUTER_PUBLIC_FAILURE, + details: { self: "[Circular]" }, + }); + }); + + it("tracks circular native error causes", () => { + const error = new Error(OUTER_PUBLIC_FAILURE); + Object.defineProperty(error, "cause", { value: error }); + + expect(errorSummary(error, { includeStack: false })).toEqual({ + name: "Error", + message: OUTER_PUBLIC_FAILURE, + cause: { message: "Circular error reference" }, + }); + }); + + it("filters enumerable built-in Error fields from own property details", () => { + const error = new Error("outer"); + Object.defineProperty(error, "message", { + enumerable: true, + value: ENUMERABLE_ERROR_MESSAGE, + }); + Object.assign(error, { code: "PUBLIC_CODE" }); + + expect(errorSummary(error, { includeStack: false })).toEqual({ + name: "Error", + message: ENUMERABLE_ERROR_MESSAGE, + code: "PUBLIC_CODE", + }); + }); + + it("omits enumerable Error properties when all own properties are built in", () => { + const error = new Error("outer"); + Object.defineProperty(error, "message", { + enumerable: true, + value: ENUMERABLE_ERROR_MESSAGE, + }); + + expect(errorSummary(error, { includeStack: false })).toEqual({ + name: "Error", + message: ENUMERABLE_ERROR_MESSAGE, + }); + }); +}); + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("preserves public RPC debugging data in structured object error summaries", () => { + const rpcUrl = "https://testnet.example/rpc/path"; + + const summary = record( + errorSummary({ + message: `object ${rpcUrl}`, + rpcUrl, + amount: 9007199254740993n, + }), + "summary", + ); + const serialized = JSON.stringify(summary); + + expect(serialized).toContain(rpcUrl); + expect(nestedRecord(summary, "details")["rpcUrl"]).toBe(rpcUrl); + }); + + it("preserves public nested object error fields", () => { + const rpcUrl = "https://testnet.example/rpc/path"; + + const summary = record( + errorSummary({ + message: `failed ${rpcUrl}`, + nested: { publicReason: "visible evidence" }, + }), + "summary", + ); + const details = nestedRecord(summary, "details"); + const nested = nestedRecord(details, "nested"); + + expect(details["message"]).toBe(`failed ${rpcUrl}`); + expect(nested["publicReason"]).toBe("visible evidence"); + }); +}); + +it("summarizes thrown primitives without inventing structured fields", () => { + expect(errorSummary("plain failure")).toBe("plain failure"); + expect(errorSummary(404)).toBe("404"); + expect(errorSummary(false)).toBe("false"); + expect(errorSummary(null)).toBe("Empty Error"); +}); + +it("summarizes thrown objects with JSON-safe debugging details preserved", () => { + const summary = record( + errorSummary({ + code: "RPC_FAILURE", + amount: 9007199254740993n, + message: "failed with public evidence", + lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" }, + cell: { + cellOutput: { lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" } }, + }, + witnesses: [`0x${"22".repeat(80)}`], + signedTx: `0x${"33".repeat(80)}`, + env: { BOT_CONFIG_FILE: CREDENTIAL_CONFIG_FILE }, + config: { chain: "testnet" }, + }), + "summary", + ); + + expect(summary).toMatchObject({ + message: "Non-Error object", + details: { + code: "RPC_FAILURE", + amount: "9007199254740993", + message: "failed with public evidence", + witnesses: [`0x${"22".repeat(80)}`], + }, + }); +}); + +it("preserves transaction-shaped details from nested object causes", () => { + const summary = record( + errorSummary( + new Error("outer", { + cause: { + message: "inner public evidence", + inputs: [{}], + outputsData: [`0x${"22".repeat(80)}`], + cellDeps: [{}], + headerDeps: [{}], + }, + }), + ), + "summary", + ); + + expect(summary["cause"]).toEqual({ + message: "Non-Error object", + details: { + message: "inner public evidence", + inputs: [{}], + outputsData: [`0x${"22".repeat(80)}`], + cellDeps: [{}], + headerDeps: [{}], + }, + }); +}); + +it("preserves enumerable tx status fields from public error objects", () => { + const summary = record( + errorSummary( + Object.assign(new Error("confirmation failed"), { + txHash: `0x${"44".repeat(32)}`, + status: "pending", + isTimeout: true, + }), + { includeStack: false }, + ), + "summary", + ); + + expect(summary).toEqual({ + name: "Error", + message: "confirmation failed", + txHash: `0x${"44".repeat(32)}`, + status: "pending", + isTimeout: true, + }); +}); diff --git a/packages/bot/test/observability/events.ts b/packages/bot/test/observability/events.ts new file mode 100644 index 0000000..7c8eab9 --- /dev/null +++ b/packages/bot/test/observability/events.ts @@ -0,0 +1,346 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it, vi } from "vitest"; +import { + BotEventEmitter, + createRunId, + transactionSummary, +} from "../../src/observability/events.ts"; +import { + BOT_DECISION_SKIPPED, + BOT_OBSERVABILITY_SUITE, + CREDENTIAL_CONFIG_FILE, + emptyCellDep, + emptyInput, + emptyScript, + record, +} from "./fixtures/observability.ts"; + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("emits one structured JSON-compatible event", () => { + const written: unknown[] = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + written.push(event); + }, + }); + + const event = emitter.emit(7, BOT_DECISION_SKIPPED, { + reason: "no_actions", + amount: 9007199254740993n, + witnesses: [`0x${"11".repeat(80)}`], + witness: `0x${"22".repeat(80)}`, + output_data: `0x${"33".repeat(80)}`, + transactionShape: { witnesses: 1 }, + txHash: `0x${"44".repeat(32)}`, + tx: { inputs: [], outputs: [], witnesses: [] }, + lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" }, + cell: { + cellOutput: { + lock: { codeHash: "0xabc", hashType: "type", args: "0xdef" }, + }, + }, + env: "testnet", + environment: { BOT_CONFIG_FILE: CREDENTIAL_CONFIG_FILE }, + config: { chain: "testnet" }, + }); + + expect(written).toHaveLength(1); + expect(event).toBe(written[0]); + expect(written[0]).toMatchObject({ + version: 1, + app: "bot", + chain: "testnet", + runId: "run-1", + iterationId: 7, + type: BOT_DECISION_SKIPPED, + reason: "no_actions", + amount: "9007199254740993", + witnesses: [`0x${"11".repeat(80)}`], + environment: { BOT_CONFIG_FILE: CREDENTIAL_CONFIG_FILE }, + }); + const writtenEvent = record(written[0], "written event"); + expect(writtenEvent["txHash"]).toBe(`0x${"44".repeat(32)}`); + expect(typeof writtenEvent["timestamp"]).toBe("string"); + }); +}); + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("emits functions and circular values as JSON-safe fields", () => { + const written: unknown[] = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + written.push(event); + }, + }); + const circular: Record = { label: "root" }; + circular["self"] = circular; + + emitter.emit(7, BOT_DECISION_SKIPPED, { + evidence: { + toJSON: (): Record => ({ ignored: "custom serializer" }), + circular, + observedAt: new Date("2026-01-02T03:04:05.006Z"), + invalidAt: new Date(NaN), + }, + }); + + expect(written[0]).toMatchObject({ + evidence: { + toJSON: "[Unsupported log value]", + circular: { label: "root", self: "[Circular]" }, + observedAt: "2026-01-02T03:04:05.006Z", + invalidAt: null, + }, + }); + }); +}); + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("writes JSON lines to stdout by default", () => { + const output: string[] = []; + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + try { + new BotEventEmitter({ chain: "testnet", runId: "run-1" }).emit( + 7, + BOT_DECISION_SKIPPED, + { amount: 1n }, + ); + } finally { + stdoutWrite.mockRestore(); + } + + expect(output).toHaveLength(1); + expect(JSON.parse(output[0] ?? "{}")).toMatchObject({ + amount: "1", + type: BOT_DECISION_SKIPPED, + }); + }); + + it("creates run ids with timestamp and process evidence", () => { + expect(createRunId()).toMatch(/^\d{4}-\d\d-\d\dT.*Z-[\da-z]+$/u); + }); +}); + +describe(BOT_OBSERVABILITY_SUITE, () => { + it("summarizes transaction shape with the decision shape fields", () => { + const tx = ccc.Transaction.from({ + inputs: [emptyInput("11"), emptyInput("12")], + outputs: [{ capacity: 0n, lock: emptyScript("21") }], + cellDeps: [emptyCellDep("31"), emptyCellDep("32"), emptyCellDep("33")], + headerDeps: [`0x${"41".repeat(32)}`], + witnesses: ["0x", "0x"], + }); + + expect(transactionSummary(tx, 4n, 5n)).toEqual({ + fee: 4n, + feeRate: 5n, + shape: { + inputs: 2, + outputs: 1, + cellDeps: 3, + headerDeps: 1, + witnesses: 2, + }, + }); + }); + + it("preserves public chain tip block metadata", () => { + const written: unknown[] = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + written.push(event); + }, + }); + const blockHash = `0x${"11".repeat(32)}`; + + emitter.emit(1, "bot.state.read", { + chainTip: { + blockNumber: 123n, + blockHash, + timestamp: 456n, + }, + }); + + expect(written[0]).toMatchObject({ + chainTip: { + blockNumber: "123", + blockHash, + timestamp: "456", + }, + }); + }); +}); + +it("keeps stable event identity when payload fields collide", () => { + const written: unknown[] = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + written.push(event); + }, + }); + + emitter.emit(7, BOT_DECISION_SKIPPED, { + version: 999, + app: "wrong", + chain: "mainnet", + runId: "wrong", + iterationId: 999, + timestamp: "not-iso", + type: "wrong", + }); + + expect(written[0]).toMatchObject({ + version: 1, + app: "bot", + chain: "testnet", + runId: "run-1", + iterationId: 7, + type: BOT_DECISION_SKIPPED, + }); + expect(record(written[0], "written event")["timestamp"]).not.toBe("not-iso"); +}); + +it("ignores custom serializers before adding event identity", () => { + const written: unknown[] = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + written.push(event); + }, + }); + const value = { + toJSON: (): string => "not a record", + hidden: "ignored", + }; + + emitter.emit(7, BOT_DECISION_SKIPPED, value); + + expect(written[0]).toMatchObject({ + version: 1, + app: "bot", + chain: "testnet", + runId: "run-1", + iterationId: 7, + type: BOT_DECISION_SKIPPED, + }); + expect(written[0]).toMatchObject({ + toJSON: "[Unsupported log value]", + hidden: "ignored", + }); +}); + +it("treats non-record payloads from JS callers as empty fields", () => { + const written: unknown[] = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + written.push(event); + }, + }); + + emitMalformedFields(emitter, 7, "not a record"); + emitMalformedFields(emitter, 8, []); + + expect(written).toMatchObject([ + { iterationId: 7, type: BOT_DECISION_SKIPPED }, + { iterationId: 8, type: BOT_DECISION_SKIPPED }, + ]); +}); + +it("emits public chain preflight evidence", () => { + const written: unknown[] = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + written.push(event); + }, + }); + const genesisHash = `0x${"11".repeat(32)}`; + const tipHash = `0x${"22".repeat(32)}`; + + emitter.emit(0, "bot.chain.preflight", { + rpcConfigured: true, + expected: { chain: "testnet", genesisHash, addressPrefix: "ckt" }, + observed: { + genesisHash, + addressPrefix: "ckt", + tip: { hash: tipHash, number: 123n, timestamp: 456n }, + }, + matches: { genesisHash: true, addressPrefix: true }, + }); + + expect(written[0]).toMatchObject({ + type: "bot.chain.preflight", + rpcConfigured: true, + expected: { chain: "testnet", genesisHash, addressPrefix: "ckt" }, + observed: { + genesisHash, + addressPrefix: "ckt", + tip: { hash: tipHash, number: "123", timestamp: "456" }, + }, + matches: { genesisHash: true, addressPrefix: true }, + }); +}); +it("emits run context without private key material", () => { + const written: unknown[] = []; + const emitter = new BotEventEmitter({ + chain: "testnet", + runId: "run-1", + write: (event): void => { + written.push(event); + }, + }); + + emitter.emit(0, "bot.run.started", { + maxIterations: 1, + bounded: true, + runtime: { + maxIterations: 1, + bounded: true, + sleepIntervalMs: 60000, + rpcConfigured: true, + }, + config: { chain: "testnet" }, + rpcHost: "testnet.example", + }); + + expect(written[0]).toMatchObject({ + type: "bot.run.started", + maxIterations: 1, + bounded: true, + runtime: { + maxIterations: 1, + bounded: true, + sleepIntervalMs: 60000, + rpcConfigured: true, + }, + config: { chain: "testnet" }, + rpcHost: "testnet.example", + }); +}); + +function emitMalformedFields( + emitter: BotEventEmitter, + iterationId: number, + fields: unknown, +): void { + Reflect.apply(emitter.emit.bind(emitter), emitter, [ + iterationId, + BOT_DECISION_SKIPPED, + fields, + ]); +} diff --git a/packages/bot/test/observability/fixtures/observability.ts b/packages/bot/test/observability/fixtures/observability.ts new file mode 100644 index 0000000..b73608d --- /dev/null +++ b/packages/bot/test/observability/fixtures/observability.ts @@ -0,0 +1,193 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { BotActions, BuildTransactionResult } from "../../../src/runtime/types.ts"; + +export const BOT_OBSERVABILITY_SUITE = "bot observability"; +export const SCAN_RACED_CHAIN_TIP = "scan raced chain tip"; +export const POOL_REJECTED_RBF_MESSAGE = "Client request error PoolRejectedRBF"; +export const RBF_REJECTED_DATA = + 'RBFRejected("Tx\'s current fee is 11795, expect it to >= 12326 to replace old txs")'; +export const BOT_DECISION_SKIPPED = "bot.decision.skipped"; +export const CREDENTIAL_CONFIG_FILE = "/run/credentials/config.json"; +export const RESOLVE_FAILED_DEAD = "Resolve failed Dead(OutPoint(...))"; +export const BOT_TRANSACTION_FAILED = "bot.transaction.failed"; +export const OUTER_PUBLIC_FAILURE = "outer public failure"; + +export const noActions: BotActions = { + collectedOrders: 0, + completedDeposits: 0, + matchedOrders: 0, + deposits: 0, + withdrawalRequests: 0, + withdrawals: 0, +}; + +export function nestedRecord( + source: Record, + key: string, +): Record { + return record(source[key], key); +} + +export function record(value: unknown, label: string): Record { + if (isRecord(value)) { + return value; + } + throw new Error(`Expected record: ${label}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function emptyScript(byte: string): ccc.ScriptLike { + return { codeHash: `0x${byte.repeat(32)}`, hashType: "type", args: "0x" }; +} + +export function emptyInput(byte: string): ccc.CellInputLike { + return { previousOutput: { txHash: `0x${byte.repeat(32)}`, index: 0n } }; +} + +export function emptyCellDep(byte: string): ccc.CellDepLike { + return { + outPoint: { txHash: `0x${byte.repeat(32)}`, index: 0n }, + depType: "code", + }; +} + +export function noActionDecisionTranscript(): BuildTransactionResult["decision"] { + return { + chainTip: { + blockNumber: 1n, + blockHash: `0x${"11".repeat(32)}`, + timestamp: 2n, + epoch: { integer: 3n, numerator: 0n, denominator: 1n }, + }, + balances: { + availableCkb: 0n, + unavailableCkb: 0n, + totalCkb: 0n, + availableIckb: 0n, + totalEquivalentCkb: 0n, + totalEquivalentIckb: 0n, + minimumCkbCapital: 0n, + spendableCkb: 0n, + matchableCkb: 0n, + }, + orders: { marketCount: 0, userCount: 0, receiptCount: 0 }, + withdrawals: { readyCount: 0, pendingCount: 0 }, + poolDeposits: { totalCount: 0, readyCount: 0 }, + match: { + reason: "no_market_orders", + partialCount: 0, + ckbDelta: 0n, + udtDelta: 0n, + }, + rebalance: { + kind: "none", + reason: "no_withdrawable_ickb", + outputSlots: 58, + projectedAvailableCkb: 0n, + projectedAvailableIckb: 0n, + }, + audit: { + reserveCheck: { + availableCkb: 0n, + matchCkbDelta: 0n, + rebalanceCkbCost: 0n, + directDepositCost: 0n, + withdrawalRequestCost: 0n, + projectedPostTransactionCkb: 0n, + reserve: 0n, + deficit: 0n, + recoveryException: false, + }, + rebalanceCosts: { + directDepositCapacity: 0n, + directDepositFeeHeadroom: 0n, + directDepositCost: 0n, + withdrawalRequestCost: 0n, + }, + }, + actions: noActions, + fee: { feeRate: 1n }, + transactionShape: { + inputs: 0, + outputs: 0, + cellDeps: 0, + headerDeps: 0, + witnesses: 0, + }, + exchangeRatio: { ckbScale: 1n, udtScale: 1n }, + depositCapacity: 0n, + skip: { reason: "no_actions" }, + }; +} + +const noActionSkipDecision = noActionDecisionTranscript(); + +export const NO_ACTION_SKIP_RESULT: BuildTransactionResult = { + kind: "skipped", + reason: "no_actions", + actions: noActions, + decision: { + ...noActionSkipDecision, + rebalance: { + ...noActionSkipDecision.rebalance, + diagnostics: { + ring: { + poolDepositCount: 2, + canCreateRingInventory: true, + shouldBootstrapRing: false, + ringLength: 16n, + segmentCount: 2, + targetSegmentIndex: 0, + targetSegmentUdtValue: 2n, + totalPoolUdt: 2n, + depositsShareOneSegment: true, + segments: [ + { + index: 0, + depositCount: 2, + udtValue: 2n, + isTarget: true, + protectedDepositCount: 1, + protectedUdtValue: 2n, + protectedOutPoints: ["0xprotected"], + surplusDepositCount: 1, + surplusUdtValue: 0n, + surplusOutPoints: ["0xsurplus"], + }, + { + index: 1, + depositCount: 0, + udtValue: 0n, + isTarget: false, + protectedDepositCount: 0, + protectedUdtValue: 0n, + protectedOutPoints: [], + surplusDepositCount: 0, + surplusUdtValue: 0n, + surplusOutPoints: [], + }, + ], + }, + }, + }, + audit: { + ...noActionSkipDecision.audit, + selectedRing: { + targetSegmentIndex: 0, + targetDepositCount: 2, + targetUdtValue: 2n, + totalPoolUdt: 2n, + emptySegmentCount: 1, + nonemptySegmentCount: 1, + heaviestSegmentIndex: 0, + heaviestSegmentDepositCount: 2, + heaviestSegmentUdtValue: 2n, + canCreateRingInventory: true, + shouldBootstrapRing: false, + }, + }, + }, +}; diff --git a/packages/bot/test/observability/lifecycle.ts b/packages/bot/test/observability/lifecycle.ts new file mode 100644 index 0000000..6d63082 --- /dev/null +++ b/packages/bot/test/observability/lifecycle.ts @@ -0,0 +1,294 @@ +import { expect, it } from "vitest"; +import { transactionLifecycleEvents } from "../../src/observability/lifecycle.ts"; +import { + BOT_TRANSACTION_FAILED, + POOL_REJECTED_RBF_MESSAGE, + RBF_REJECTED_DATA, + RESOLVE_FAILED_DEAD, +} from "./fixtures/observability.ts"; + +const BOT_TRANSACTION_CONFIRMATION = "bot.transaction.confirmation"; +const PHASE_CONFIRMATION = "confirmation"; +const POST_BROADCAST_FAILURE = "post-broadcast failure"; +const POST_BROADCAST_UNRESOLVED = "post_broadcast_unresolved"; +const RBF_REJECTED_CONFIRMATION_REASON = JSON.stringify({ + type: "RBFRejected", + description: `RBF rejected: replaced by tx Byte32(0x${"22".repeat(32)})`, +}); + +it("maps terminal lifecycle callbacks into confirmation and failure events", () => { + expect( + transactionLifecycleEvents({ + type: "terminal_rejection", + txHash: "0x00", + status: "rejected", + reason: RESOLVE_FAILED_DEAD, + checks: 1, + elapsedMs: 10, + }), + ).toEqual([ + { + type: BOT_TRANSACTION_CONFIRMATION, + fields: { + txHash: "0x00", + phase: PHASE_CONFIRMATION, + status: "rejected", + reason: RESOLVE_FAILED_DEAD, + checks: 1, + elapsedMs: 10, + outcome: "terminal_rejection", + retryable: false, + terminal: true, + }, + }, + { + type: BOT_TRANSACTION_FAILED, + fields: { + txHash: "0x00", + phase: PHASE_CONFIRMATION, + status: "rejected", + reason: RESOLVE_FAILED_DEAD, + checks: 1, + elapsedMs: 10, + outcome: "terminal_rejection", + retryable: false, + terminal: true, + }, + }, + ]); + + expect( + transactionLifecycleEvents({ + type: "timeout_after_broadcast", + txHash: "0x01", + status: "unknown", + checks: 3, + elapsedMs: 20, + }), + ).toMatchObject([ + { + type: BOT_TRANSACTION_CONFIRMATION, + fields: { outcome: "timeout_after_broadcast" }, + }, + { type: BOT_TRANSACTION_FAILED, fields: { outcome: "timeout_after_broadcast" } }, + ]); +}); + +it("preserves post-broadcast failure errors in lifecycle events", () => { + const unresolvedError = new Error(POST_BROADCAST_FAILURE); + + expect( + transactionLifecycleEvents({ + type: POST_BROADCAST_UNRESOLVED, + txHash: "0x04", + status: "pending", + checks: 4, + elapsedMs: 30, + error: unresolvedError, + }), + ).toMatchObject([ + { + type: BOT_TRANSACTION_CONFIRMATION, + fields: { + outcome: POST_BROADCAST_UNRESOLVED, + error: { message: POST_BROADCAST_FAILURE }, + }, + }, + { + type: BOT_TRANSACTION_FAILED, + fields: { + outcome: POST_BROADCAST_UNRESOLVED, + error: { message: POST_BROADCAST_FAILURE }, + }, + }, + ]); +}); + +it("marks RBF confirmation rejection lifecycle events as retryable", () => { + expect( + transactionLifecycleEvents({ + type: "terminal_rejection", + txHash: "0x05", + status: "rejected", + reason: RBF_REJECTED_CONFIRMATION_REASON, + checks: 2, + elapsedMs: 11_019, + }), + ).toMatchObject([ + { + type: BOT_TRANSACTION_CONFIRMATION, + fields: { + phase: PHASE_CONFIRMATION, + txHash: "0x05", + status: "rejected", + reason: RBF_REJECTED_CONFIRMATION_REASON, + outcome: "terminal_rejection", + retryable: true, + terminal: false, + }, + }, + { + type: BOT_TRANSACTION_FAILED, + fields: { + phase: PHASE_CONFIRMATION, + txHash: "0x05", + status: "rejected", + reason: RBF_REJECTED_CONFIRMATION_REASON, + outcome: "terminal_rejection", + retryable: true, + terminal: false, + }, + }, + ]); +}); + +it("maps broadcast and commit lifecycle callbacks into public events", () => { + expect( + transactionLifecycleEvents({ + type: "broadcasted", + txHash: "0x02", + elapsedMs: 4, + }), + ).toEqual([ + { + type: "bot.transaction.sent", + fields: { + txHash: "0x02", + phase: "broadcast", + outcome: "broadcasted", + elapsedMs: 4, + }, + }, + ]); + + expect( + transactionLifecycleEvents({ + type: "committed", + txHash: "0x03", + status: "committed", + checks: 2, + elapsedMs: 6, + }), + ).toEqual([ + { + type: BOT_TRANSACTION_CONFIRMATION, + fields: { + phase: PHASE_CONFIRMATION, + txHash: "0x03", + status: "committed", + checks: 2, + elapsedMs: 6, + outcome: "committed", + retryable: false, + terminal: true, + }, + }, + { + type: "bot.transaction.committed", + fields: { + phase: PHASE_CONFIRMATION, + txHash: "0x03", + status: "committed", + checks: 2, + elapsedMs: 6, + outcome: "committed", + }, + }, + ]); +}); + +it("uses the caller-owned retry classifier for pre-broadcast failures", () => { + const transportEvents = transactionLifecycleEvents( + { + type: "pre_broadcast_failed", + elapsedMs: 12, + error: new TypeError("fetch failed"), + }, + () => true, + ); + const rbfEvents = transactionLifecycleEvents( + { + type: "pre_broadcast_failed", + elapsedMs: 12, + error: Object.assign(new Error(POOL_REJECTED_RBF_MESSAGE), { + code: -1111, + data: RBF_REJECTED_DATA, + currentFee: 11795n, + leastFee: 12326n, + }), + }, + () => true, + ); + const terminalEvents = transactionLifecycleEvents( + { + type: "pre_broadcast_failed", + elapsedMs: 12, + error: new Error("deterministic failure"), + }, + () => false, + ); + + expect(transportEvents).toMatchObject([ + { + type: BOT_TRANSACTION_FAILED, + fields: { + phase: "pre_broadcast", + outcome: "pre_broadcast_failed", + retryable: true, + terminal: false, + }, + }, + ]); + expect(transportEvents[0]?.fields["error"]).not.toHaveProperty("stack"); + expect(rbfEvents).toMatchObject([ + { + type: BOT_TRANSACTION_FAILED, + fields: { + phase: "pre_broadcast", + outcome: "pre_broadcast_failed", + retryable: true, + terminal: false, + error: { + code: -1111, + data: RBF_REJECTED_DATA, + currentFee: "11795", + leastFee: "12326", + }, + }, + }, + ]); + expect(rbfEvents[0]?.fields["error"]).not.toHaveProperty("stack"); + expect(terminalEvents).toMatchObject([ + { + type: BOT_TRANSACTION_FAILED, + fields: { + phase: "pre_broadcast", + outcome: "pre_broadcast_failed", + retryable: false, + terminal: true, + }, + }, + ]); + expect(terminalEvents[0]?.fields["error"]).toHaveProperty("stack"); +}); + +it("treats pre-broadcast failures as terminal without a retry classifier", () => { + const events = transactionLifecycleEvents({ + type: "pre_broadcast_failed", + elapsedMs: 12, + error: new TypeError("fetch failed"), + }); + + expect(events).toMatchObject([ + { + type: BOT_TRANSACTION_FAILED, + fields: { + phase: "pre_broadcast", + outcome: "pre_broadcast_failed", + retryable: false, + terminal: true, + }, + }, + ]); + expect(events[0]?.fields["error"]).toHaveProperty("stack"); +}); diff --git a/packages/bot/test/policy/balance.ts b/packages/bot/test/policy/balance.ts new file mode 100644 index 0000000..21f58fb --- /dev/null +++ b/packages/bot/test/policy/balance.ts @@ -0,0 +1,450 @@ +import { describe, expect, it } from "vitest"; +import { + CKB, + CKB_RESERVE, + ICKB_DEPOSIT_CAP, + NO_POOL_REST, + PLAN_REBALANCE_SUITE, + TARGET_ICKB_BALANCE, + TIP, + futureDeposit, + planRebalance, + readyDeposit, +} from "./fixtures/policy.ts"; + +describe(PLAN_REBALANCE_SUITE, () => { + it("does nothing when fewer than two output slots remain", () => { + expect( + planRebalance({ + outputSlots: 1, + tip: TIP, + ickbBalance: 0n, + ckbBalance: 2000n * 100000000n, + depositCapacity: 1000n * 100000000n, + ickbRefillThreshold: 1n, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }), + ).toEqual({ kind: "none", reason: "insufficient_output_slots" }); + }); + + it("requests one deposit when iCKB is too low and CKB reserve is available", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: 0n, + ckbBalance: 2000n * 100000000n, + depositCapacity: 1000n * 100000000n, + ickbRefillThreshold: 1n, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "deposit", reason: "low_ickb_balance", quantity: 1 }); + }); + + it("does not deposit at the reserve boundary when fee headroom is required", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: 0n, + ckbBalance: 1000n * CKB + CKB_RESERVE, + depositCapacity: 1000n * CKB, + directDepositFeeHeadroom: 1n, + ickbRefillThreshold: 1n, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none", reason: "low_ickb_ckb_reserve_unavailable" }); + }); + + it("requests one deposit when CKB covers reserve and fee headroom", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: 0n, + ckbBalance: 1000n * CKB + CKB_RESERVE + 1n, + depositCapacity: 1000n * CKB, + directDepositFeeHeadroom: 1n, + ickbRefillThreshold: 1n, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "deposit", reason: "low_ickb_balance", quantity: 1 }); + }); + + it("does nothing when iCKB is too low but the CKB reserve is unavailable", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: 0n, + ckbBalance: 1999n * 100000000n, + depositCapacity: 1000n * 100000000n, + ickbRefillThreshold: 1n, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none", reason: "low_ickb_ckb_reserve_unavailable" }); + }); +}); + +describe(`${PLAN_REBALANCE_SUITE} iCKB refill`, () => { + it("seeds ring inventory when iCKB is at the refill floor", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: 100n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + ickbRefillThreshold: 100n, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "deposit", reason: "ring_inventory", quantity: 1 }); + }); + + it("refills iCKB below the useful match floor", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: 99n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + ickbRefillThreshold: 100n, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "deposit", reason: "low_ickb_balance", quantity: 1 }); + }); + + it("does not refill iCKB at the useful match floor", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: 100n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + ickbRefillThreshold: 100n, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "deposit", reason: "ring_inventory", quantity: 1 }); + }); +}); + +describe(`${PLAN_REBALANCE_SUITE} near-ready deposits`, () => { + it("treats sparse next-hour near-ready deposits as ring coverage", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsNearReady: [futureDeposit(105n * 60n * 1000n)], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not treat pending future withdrawal value as liquid CKB for ring seeding", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsNearReady: [ + futureDeposit(105n * 60n * 1000n, 10n * ICKB_DEPOSIT_CAP), + ], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not let near-ready refill unlock ring anchors", () => { + const earlierSparseReady = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const laterSparseReady = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const poolNearReadyRefill = readyDeposit(4n, 105n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [earlierSparseReady, laterSparseReady], + poolDepositsNearReady: [poolNearReadyRefill], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none", reason: "no_ring_surplus_ready_deposits" }); + }); +}); + +describe(`${PLAN_REBALANCE_SUITE} near-ready ring selection`, () => { + it("uses near-ready deposits only through full-pool ring policy", () => { + const firstExtra = readyDeposit(4n, 20n * 60n * 1000n); + const firstProtected = readyDeposit(5n, 25n * 60n * 1000n); + const secondExtra = readyDeposit(4n, 40n * 60n * 1000n); + const secondProtected = readyDeposit(5n, 44n * 60n * 1000n); + const poolNearReadyRefill = readyDeposit(3n, 105n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 4n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [firstExtra, firstProtected, secondExtra, secondProtected], + poolDepositsNearReady: [poolNearReadyRefill], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ kind: "withdraw", deposits: [secondExtra] }); + }); + + it("ignores near-ready refill exactly at the lookahead cutoff", () => { + const earlierSparseReady = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const laterSparseReady = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const atCutoff = readyDeposit(4n, 120n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [earlierSparseReady, laterSparseReady], + poolDepositsNearReady: [atCutoff], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ + kind: "none", + reason: "no_ring_surplus_ready_deposits", + }); + }); + + it("ignores near-ready refill just outside the one-hour lookahead", () => { + const earlierSparseReady = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const laterSparseReady = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const outsideLookahead = readyDeposit(4n, 121n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [earlierSparseReady, laterSparseReady], + poolDepositsNearReady: [outsideLookahead], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ + kind: "none", + reason: "no_ring_surplus_ready_deposits", + }); + }); +}); + +describe(`${PLAN_REBALANCE_SUITE} cleanup bait`, () => { + it("does not clean up an exact-cap ready deposit", () => { + const capBait = futureDeposit(0n, ICKB_DEPOSIT_CAP, { isReady: true }); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 1n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [capBait], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not withdraw the only over-cap ring anchor", () => { + const tinyExtra = futureDeposit(20n * 60n * 1000n, 1n, { isReady: true }); + const protectedAnchor = futureDeposit(25n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB, { + isReady: true, + }); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [tinyExtra, protectedAnchor], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("ignores under-cap non-standard bait for cleanup", () => { + const dustBait = futureDeposit(0n, ICKB_DEPOSIT_CAP - 1n, { isReady: true }); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 1n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [dustBait], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not clean up near-ready or future non-standard deposits", () => { + const poolNearReady = futureDeposit(105n * 60n * 1000n, ICKB_DEPOSIT_CAP + CKB); + const future = futureDeposit(10n, ICKB_DEPOSIT_CAP + CKB); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [], + poolDepositsNearReady: [poolNearReady], + poolDepositsRest: [future], + }), + ).toMatchObject({ kind: "none" }); + }); +}); + +describe(`${PLAN_REBALANCE_SUITE} cleanup withdrawal floor`, () => { + it("does not request one ready ring anchor as normal withdrawal", () => { + const deposit = readyDeposit(ICKB_DEPOSIT_CAP + CKB, 0n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [deposit], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none", reason: "no_ring_surplus_ready_deposits" }); + }); + + it("does not request a full withdrawal that would cut below the withdrawal floor", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [readyDeposit(ICKB_DEPOSIT_CAP + 2n * CKB, 0n)], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not let near-ready refill unlock cleanup below the withdrawal floor", () => { + const unsafeReady = futureDeposit(0n, ICKB_DEPOSIT_CAP + 2n * CKB, { + isReady: true, + }); + const hugeNearReady = readyDeposit(10n * ICKB_DEPOSIT_CAP, 105n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + CKB, + ckbBalance: CKB_RESERVE, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [unsafeReady], + poolDepositsNearReady: [hugeNearReady], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); +}); + +describe(`${PLAN_REBALANCE_SUITE} cleanup near-ready isolation`, () => { + it("does not clean up the only ring anchor", () => { + const cleanupReady = futureDeposit(0n, ICKB_DEPOSIT_CAP + CKB, { isReady: true }); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: CKB_RESERVE, + depositCapacity: 1000n * CKB, + readyDeposits: [cleanupReady], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not treat near-ready refill as current liquidity budget", () => { + const unsafeReady = readyDeposit(ICKB_DEPOSIT_CAP + 2n * CKB, 0n); + const hugeNearReady = readyDeposit(10n * ICKB_DEPOSIT_CAP, 105n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + CKB, + ckbBalance: CKB_RESERVE, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [unsafeReady], + poolDepositsNearReady: [hugeNearReady], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not let fake near-ready refill unlock oversized ring anchors", () => { + const earlierSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const laterSingleton = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const fakeRefill = readyDeposit(10n * ICKB_DEPOSIT_CAP, 105n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP - 1n, + ckbBalance: CKB_RESERVE, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [earlierSingleton, laterSingleton], + poolDepositsNearReady: [fakeRefill], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); +}); diff --git a/packages/bot/test/policy/fixtures/policy.ts b/packages/bot/test/policy/fixtures/policy.ts new file mode 100644 index 0000000..f5f7135 --- /dev/null +++ b/packages/bot/test/policy/fixtures/policy.ts @@ -0,0 +1,145 @@ +import { ccc } from "@ckb-ccc/core"; +import { ICKB_DEPOSIT_CAP, type IckbDepositCell } from "@ickb/core"; +import { headerLike } from "@ickb/testkit"; +import { + CKB, + CKB_RESERVE, + planRebalance as planRebalanceImpl, +} from "../../../src/policy.ts"; + +export { CKB, CKB_RESERVE, ICKB_DEPOSIT_CAP }; + +export const TARGET_ICKB_BALANCE = ICKB_DEPOSIT_CAP + 20000n * CKB; +const READY_WINDOW_END_MS = 16n; +export const RING_LENGTH_EPOCHS = 180n; +export const PLAN_REBALANCE_SUITE = "planRebalance"; +export const TIP = headerLike({ epoch: [0n, 0n, 1n], timestamp: 0n }); +export const NO_POOL_REST: IckbDepositCell[] = []; + +let nextDepositKey = 0; + +export function readyDeposit( + udtValue: bigint, + maturityUnix: bigint, + key = `ready-${String(nextDepositKey++)}`, +): IckbDepositCell { + const minute = 60n * 1000n; + const ringEpoch = maturityUnix % minute === 0n ? maturityUnix / minute : maturityUnix; + return depositCell({ + key, + isReady: true, + udtValue, + maturity: new TestEpoch(ringEpoch, 0n, 1n, maturityUnix), + }); +} + +export function futureDeposit( + maturityUnix: bigint, + udtValue = ICKB_DEPOSIT_CAP, + options?: { isReady?: boolean; key?: string }, +): IckbDepositCell { + const minute = 60n * 1000n; + const scaledEpoch = + maturityUnix < minute + ? maturityUnix * RING_LENGTH_EPOCHS + : (maturityUnix / minute) * READY_WINDOW_END_MS; + const denominator = maturityUnix < minute ? READY_WINDOW_END_MS : 1n; + return depositCell({ + key: options?.key ?? `future-${String(nextDepositKey++)}`, + udtValue, + maturity: new TestEpoch( + scaledEpoch / denominator, + scaledEpoch % denominator, + denominator, + maturityUnix, + ), + isReady: options?.isReady ?? false, + }); +} + +type PlanRebalanceOptions = Parameters[0]; +type TestPlanRebalanceOptions = Omit< + PlanRebalanceOptions, + "poolDeposits" | "directDepositCapacity" +> & { + depositCapacity: bigint; + directDepositCapacity?: bigint; + poolDepositsRest: PlanRebalanceOptions["poolDeposits"]; + poolDepositsNearReady?: PlanRebalanceOptions["poolDeposits"]; + poolDeposits?: PlanRebalanceOptions["poolDeposits"]; +}; + +export function planRebalance( + options: TestPlanRebalanceOptions, +): ReturnType { + const { + depositCapacity, + poolDepositsRest, + poolDepositsNearReady = [], + poolDeposits, + ...rebalanceOptions + } = options; + return planRebalanceImpl({ + ...rebalanceOptions, + directDepositCapacity: options.directDepositCapacity ?? depositCapacity, + poolDeposits: poolDeposits ?? [ + ...options.readyDeposits, + ...poolDepositsNearReady, + ...poolDepositsRest, + ], + }); +} + +function depositCell(options: { + key: string; + udtValue: bigint; + maturity: ccc.Epoch; + isReady: boolean; +}): IckbDepositCell { + const deposit: TestDepositCell = { + cell: { outPoint: { toHex: (): string => options.key } }, + isReady: options.isReady, + maturity: options.maturity, + ckbValue: options.udtValue, + udtValue: options.udtValue, + }; + if (isDepositFixture(deposit)) { + return deposit; + } + throw new Error("Invalid deposit fixture"); +} + +class TestEpoch extends ccc.Epoch { + public readonly unix: bigint; + + constructor(integer: bigint, numerator: bigint, denominator: bigint, unix: bigint) { + super(integer, numerator, denominator); + this.unix = unix; + } + + public override add(epoch: ccc.EpochLike): ccc.Epoch { + const added = super.add(epoch); + return new TestEpoch( + added.integer, + added.numerator, + added.denominator, + READY_WINDOW_END_MS, + ); + } + + public override toUnix(): bigint { + return this.unix; + } +} + +interface TestDepositCell { + cell: { outPoint: { toHex: () => string } }; + isReady: boolean; + maturity: ccc.Epoch; + ckbValue: bigint; + udtValue: bigint; +} + +function isDepositFixture(value: TestDepositCell): value is IckbDepositCell { + return typeof value.cell.outPoint.toHex() === "string"; +} diff --git a/packages/bot/test/policy/ringBootstrap.ts b/packages/bot/test/policy/ringBootstrap.ts new file mode 100644 index 0000000..ff46b26 --- /dev/null +++ b/packages/bot/test/policy/ringBootstrap.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; +import { + CKB, + CKB_RESERVE, + ICKB_DEPOSIT_CAP, + NO_POOL_REST, + PLAN_REBALANCE_SUITE, + RING_LENGTH_EPOCHS, + TARGET_ICKB_BALANCE, + TIP, + futureDeposit, + planRebalance, +} from "./fixtures/policy.ts"; + +describe(PLAN_REBALANCE_SUITE, () => { + it("seeds one future deposit when no future anchors exist", () => { + const plan = planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ kind: "deposit", reason: "ring_inventory" }); + expect(plan.diagnostics?.ring).toMatchObject({ + poolDepositCount: 0, + canCreateRingInventory: true, + shouldBootstrapRing: true, + segmentCount: 1, + segments: [{ index: 0, depositCount: 0, udtValue: 0n, isTarget: true }], + }); + }); + + it("seeds ring inventory even when liquid iCKB is above the withdrawal floor", () => { + const plan = planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ kind: "deposit", reason: "ring_inventory" }); + expect(plan.diagnostics?.ring).toMatchObject({ + poolDepositCount: 0, + canCreateRingInventory: true, + shouldBootstrapRing: true, + }); + }); + + it("carries ring diagnostics on ring-inventory deposits", () => { + const plan = planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: 100n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + ickbRefillThreshold: 100n, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ kind: "deposit", reason: "ring_inventory" }); + expect(plan.diagnostics?.ring).toMatchObject({ + poolDepositCount: 0, + shouldBootstrapRing: true, + segments: [{ isTarget: true }], + }); + }); + + it("does not seed when a lone ring deposit must be preserved", () => { + const loneDeposit = futureDeposit(9n); + + const plan = planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [loneDeposit], + }); + + expect(plan).toMatchObject({ kind: "none" }); + expect(plan.diagnostics?.ring).toMatchObject({ + poolDepositCount: 1, + canCreateRingInventory: true, + shouldBootstrapRing: false, + segmentCount: 1, + segments: [ + { index: 0, depositCount: 1, udtValue: ICKB_DEPOSIT_CAP, isTarget: true }, + ], + }); + }); +}); + +describe(PLAN_REBALANCE_SUITE, () => { + it("seeds, without withdrawing, when two future deposits crowd the same adaptive segment", () => { + const firstDuplicate = futureDeposit(9n); + const secondDuplicate = futureDeposit(10n); + + const plan = planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [firstDuplicate, secondDuplicate], + }); + + expect(plan).toMatchObject({ kind: "deposit", quantity: 1 }); + expect(plan.diagnostics?.ring).toMatchObject({ + poolDepositCount: 2, + canCreateRingInventory: true, + ringLength: RING_LENGTH_EPOCHS, + segmentCount: 2, + targetSegmentIndex: 0, + totalPoolUdt: 2n * ICKB_DEPOSIT_CAP, + depositsShareOneSegment: true, + }); + }); + + it("does not seed when two future deposits already span both adaptive segments", () => { + const plan = planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [futureDeposit(1n), futureDeposit(9n)], + }); + + expect(plan).toMatchObject({ kind: "none" }); + expect(plan.diagnostics?.ring).toMatchObject({ + poolDepositCount: 2, + segmentCount: 2, + targetSegmentIndex: 0, + targetSegmentUdtValue: ICKB_DEPOSIT_CAP, + depositsShareOneSegment: false, + }); + }); +}); + +describe(PLAN_REBALANCE_SUITE, () => { + it("seeds when the coarse target segment is under-covered by udt per meter", () => { + const plan = planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [futureDeposit(5n), futureDeposit(9n), futureDeposit(13n)], + }); + + expect(plan).toMatchObject({ kind: "deposit", quantity: 1 }); + expect(plan.diagnostics?.ring).toMatchObject({ + poolDepositCount: 3, + segmentCount: 4, + targetSegmentIndex: 0, + targetSegmentUdtValue: 0n, + depositsShareOneSegment: false, + }); + }); + + it("seeds an empty target segment in high-count pools", () => { + const plan = planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: Array.from({ length: 181 }, () => futureDeposit(1n)), + }); + + expect(plan).toMatchObject({ kind: "deposit", reason: "ring_inventory" }); + expect(plan.diagnostics?.ring).toMatchObject({ + poolDepositCount: 181, + ringLength: RING_LENGTH_EPOCHS, + segmentCount: 256, + targetSegmentIndex: 0, + targetSegmentUdtValue: 0n, + }); + }); +}); + +describe(PLAN_REBALANCE_SUITE, () => { + it("does not seed when the coarse target segment meets the density threshold", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [ + futureDeposit(1n, 1n), + futureDeposit(5n, 3n), + futureDeposit(9n, 4n), + ], + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not seed from zero-total future coverage", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [futureDeposit(9n, 0n), futureDeposit(10n, 0n)], + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not seed future shaping when the reserve gate fails", () => { + const plan = planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ kind: "none" }); + expect(plan.diagnostics?.ring).toMatchObject({ + poolDepositCount: 0, + canCreateRingInventory: false, + shouldBootstrapRing: false, + }); + }); +}); diff --git a/packages/bot/test/policy/ringReserve.ts b/packages/bot/test/policy/ringReserve.ts new file mode 100644 index 0000000..603c4c3 --- /dev/null +++ b/packages/bot/test/policy/ringReserve.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { + CKB, + CKB_RESERVE, + ICKB_DEPOSIT_CAP, + NO_POOL_REST, + PLAN_REBALANCE_SUITE, + TARGET_ICKB_BALANCE, + TIP, + futureDeposit, + planRebalance, +} from "./fixtures/policy.ts"; + +describe(PLAN_REBALANCE_SUITE, () => { + it("seeds ring inventory when one more deposit would cross the withdrawal floor", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP + 1n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "deposit", reason: "ring_inventory", quantity: 1 }); + }); + + it("keeps direct seeding when future crowding has only deposit output slots", () => { + expect( + planRebalance({ + outputSlots: 3, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)], + }), + ).toMatchObject({ kind: "deposit", quantity: 1 }); + }); + + it("keeps direct seeding when removing a future source would leave the source segment below the preservation floor", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [ + futureDeposit(5n, 50n), + futureDeposit(6n, 50n), + futureDeposit(9n, 200n), + futureDeposit(13n, 200n), + ], + }), + ).toMatchObject({ kind: "deposit", quantity: 1 }); + }); + + it("keeps direct seeding when density improvement would be too small", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [ + futureDeposit(1n, 10n), + futureDeposit(4n), + futureDeposit(4n), + futureDeposit(4n), + futureDeposit(5n), + futureDeposit(5n), + futureDeposit(9n, 10n), + ], + }), + ).toMatchObject({ kind: "deposit", quantity: 1 }); + }); +}); + +describe(PLAN_REBALANCE_SUITE, () => { + it("returns none, not a withdrawal, when dust crowds the future pool without deposit budget", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [ + futureDeposit(5n, 1n), + futureDeposit(6n, 1n), + futureDeposit(9n), + ], + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does not reserve withdrawals when public drain leaves the future target under-covered", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [futureDeposit(5n), futureDeposit(9n, 1n), futureDeposit(13n)], + }), + ).toMatchObject({ kind: "none" }); + }); +}); + +describe(PLAN_REBALANCE_SUITE, () => { + it("does not expand future horizon or remove a farther source", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [ + futureDeposit(5n), + futureDeposit(6n), + futureDeposit(9n), + futureDeposit(10000n), + ], + }), + ).toMatchObject({ kind: "none" }); + }); + + it("returns none when a raced future-removal source disappears", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [futureDeposit(9n)], + }), + ).toMatchObject({ kind: "none" }); + }); + + it("does nothing instead of withdrawing for cosmetic future smoothing", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [ + futureDeposit(1n), + futureDeposit(4n), + futureDeposit(8n), + futureDeposit(12n), + ], + }), + ).toMatchObject({ kind: "none" }); + }); +}); diff --git a/packages/bot/test/policy/ringWithdrawal.ts b/packages/bot/test/policy/ringWithdrawal.ts new file mode 100644 index 0000000..cbe28f5 --- /dev/null +++ b/packages/bot/test/policy/ringWithdrawal.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + CKB, + CKB_RESERVE, + ICKB_DEPOSIT_CAP, + NO_POOL_REST, + PLAN_REBALANCE_SUITE, + TARGET_ICKB_BALANCE, + TIP, + futureDeposit, + planRebalance, + readyDeposit, +} from "./fixtures/policy.ts"; + +describe(PLAN_REBALANCE_SUITE, () => { + it("uses ring surplus, not sparse ready buckets, for ordinary excess", () => { + const extra = readyDeposit(4n, 20n * 60n * 1000n); + const ringAnchor = readyDeposit(6n, 25n * 60n * 1000n); + const sparseReady = readyDeposit(5n, 40n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 9n, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [extra, ringAnchor, sparseReady], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ + kind: "withdraw", + reason: "excess_ickb_balance", + deposits: [extra, sparseReady], + requiredLiveDeposits: [ringAnchor], + }); + expect(plan.diagnostics?.ring).toMatchObject({ + canCreateRingInventory: false, + shouldBootstrapRing: false, + }); + }); + + it("keeps ring surplus selection independent of sparse ready buckets", () => { + const extra = readyDeposit(4n, 20n * 60n * 1000n); + const ringAnchor = readyDeposit(6n, 25n * 60n * 1000n); + const sparseReady = readyDeposit(5n, 40n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 9n, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [extra, ringAnchor, sparseReady], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ + kind: "withdraw", + reason: "excess_ickb_balance", + deposits: [extra, sparseReady], + requiredLiveDeposits: [ringAnchor], + }); + }); + + it("does not spend the only ring anchor for excess withdrawal", () => { + const ringAnchor = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [ringAnchor], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none", reason: "no_ring_surplus_ready_deposits" }); + }); + + it("does not withdraw for excess withdrawal below the withdrawal floor", () => { + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [ + readyDeposit(4n, 20n * 60n * 1000n), + readyDeposit(6n, 25n * 60n * 1000n), + ], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none", reason: "no_withdrawable_ickb" }); + }); +}); + +describe(PLAN_REBALANCE_SUITE, () => { + it("does not withdraw from a duplicate dense future segment to fill an empty target segment", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)], + }), + ).toMatchObject({ kind: "deposit", quantity: 1 }); + }); + + it("does not withdraw stale ready-shaped entries from the future pool", () => { + const readyDuplicate = futureDeposit(5n, ICKB_DEPOSIT_CAP, { isReady: true }); + const pendingDuplicate = futureDeposit(6n); + const secondPendingDuplicate = futureDeposit(7n); + const otherSegment = futureDeposit(9n); + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [ + readyDuplicate, + pendingDuplicate, + secondPendingDuplicate, + otherSegment, + ], + }), + ).toMatchObject({ kind: "deposit", quantity: 1 }); + }); + + it("uses the full ring instead of future-only anchors", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [ + futureDeposit(5n, ICKB_DEPOSIT_CAP, { isReady: true }), + futureDeposit(6n, ICKB_DEPOSIT_CAP, { isReady: true }), + futureDeposit(9n), + ], + }), + ).toMatchObject({ kind: "deposit", quantity: 1 }); + }); +}); diff --git a/packages/bot/test/policy/withdrawalLimits.ts b/packages/bot/test/policy/withdrawalLimits.ts new file mode 100644 index 0000000..225139c --- /dev/null +++ b/packages/bot/test/policy/withdrawalLimits.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { planRebalanceWithdrawal } from "../../src/policy/withdrawal.ts"; +import { + CKB, + ICKB_DEPOSIT_CAP, + NO_POOL_REST, + PLAN_REBALANCE_SUITE, + TARGET_ICKB_BALANCE, + TIP, + planRebalance, + readyDeposit, +} from "./fixtures/policy.ts"; + +describe(PLAN_REBALANCE_SUITE, () => { + it("returns none for tiny excess with a near-cap crowded extra", () => { + const sparseReady = readyDeposit(5n, 0n); + const crowdedEarly = readyDeposit(ICKB_DEPOSIT_CAP - 1n, 20n * 60n * 1000n); + const crowdedLate = readyDeposit(ICKB_DEPOSIT_CAP, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 1n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [sparseReady, crowdedEarly, crowdedLate], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("returns none when neither extras nor sparse ready deposits fit", () => { + const sparseReady = readyDeposit(6n, 0n); + const crowdedProtected = readyDeposit(6n, 20n * 60n * 1000n); + const crowdedExtra = readyDeposit(7n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 5n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [crowdedExtra, crowdedProtected, sparseReady], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "none", + reason: "ring_surplus_withdrawal_over_budget", + }); + }); + + it("limits withdrawal requests by the available output slots", () => { + const first = readyDeposit(3n, 0n); + const second = readyDeposit(3n, 20n * 60n * 1000n); + const third = readyDeposit(3n, 40n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 5, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 10n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [first, second, third], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ kind: "withdraw", deposits: [second, third] }); + }); + + it("caps withdrawal requests at thirty deposits", () => { + const readyDeposits = Array.from({ length: 31 }, () => readyDeposit(1n, 0n)); + + const plan = planRebalance({ + outputSlots: 100, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 100n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + readyDeposits, + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ kind: "withdraw" }); + expect(plan.kind === "withdraw" ? plan.deposits : []).toHaveLength(30); + }); +}); + +describe("planRebalanceWithdrawal diagnostics", () => { + it("carries diagnostics on withdrawal and no-op plans", () => { + const diagnostics = ringDiagnostics(); + const anchor = readyDeposit(1n, 0n); + const surplus = readyDeposit(1n, 0n); + + expect( + planRebalanceWithdrawal({ + outputSlots: 2, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 2n, + ckbBalance: 2000n * CKB, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + ckbRecoveryThreshold: 1000n * CKB, + poolDeposits: [anchor, surplus], + readyDeposits: [anchor, surplus], + diagnostics, + }), + ).toMatchObject({ kind: "withdraw", diagnostics }); + + expect( + planRebalanceWithdrawal({ + outputSlots: 2, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE, + ckbBalance: 2000n * CKB, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + ckbRecoveryThreshold: 1000n * CKB, + poolDeposits: [], + readyDeposits: [], + diagnostics, + }), + ).toEqual({ kind: "none", reason: "no_withdrawable_ickb", diagnostics }); + }); +}); + +function ringDiagnostics(): Parameters[0]["diagnostics"] { + return { + ring: { + poolDepositCount: 0, + canCreateRingInventory: false, + shouldBootstrapRing: false, + ringLength: 180n, + segmentCount: 1, + targetSegmentIndex: 0, + targetSegmentUdtValue: 0n, + totalPoolUdt: 0n, + depositsShareOneSegment: true, + segments: [ + { + index: 0, + depositCount: 0, + udtValue: 0n, + isTarget: true, + protectedDepositCount: 0, + protectedUdtValue: 0n, + protectedOutPoints: [], + surplusDepositCount: 0, + surplusUdtValue: 0n, + surplusOutPoints: [], + }, + ], + }, + }; +} + +describe(PLAN_REBALANCE_SUITE, () => { + it("does nothing when iCKB is above the withdrawal floor but ring surplus would cut below the buffer", () => { + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 3n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [readyDeposit(ICKB_DEPOSIT_CAP + 4n, 0n)], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ kind: "none" }); + }); + + it("withdraws one over-cap ring surplus and pins its ring anchor", () => { + const first = readyDeposit(ICKB_DEPOSIT_CAP + CKB, 20n * 60n * 1000n); + const second = readyDeposit(ICKB_DEPOSIT_CAP + CKB, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [first, second], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "withdraw", + deposits: [second], + requiredLiveDeposits: [first], + }); + }); +}); diff --git a/packages/bot/test/policy/withdrawalRecovery.ts b/packages/bot/test/policy/withdrawalRecovery.ts new file mode 100644 index 0000000..0e8c8aa --- /dev/null +++ b/packages/bot/test/policy/withdrawalRecovery.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + CKB, + CKB_RESERVE, + ICKB_DEPOSIT_CAP, + NO_POOL_REST, + PLAN_REBALANCE_SUITE, + TARGET_ICKB_BALANCE, + TIP, + futureDeposit, + planRebalance, + readyDeposit, +} from "./fixtures/policy.ts"; + +describe(PLAN_REBALANCE_SUITE, () => { + it("breaks ring anchors below the useful CKB recovery threshold", () => { + const anchor = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const otherSegment = futureDeposit(120n * 60n * 1000n, 4n * ICKB_DEPOSIT_CAP); + const threshold = CKB_RESERVE + 100n; + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP, + ckbBalance: threshold - 1n, + depositCapacity: 1000n * CKB, + ckbRecoveryThreshold: threshold, + readyDeposits: [anchor], + poolDepositsRest: [otherSegment], + }), + ).toMatchObject({ kind: "withdraw", reason: "reserve_recovery", deposits: [anchor] }); + }); + + it("does not break ring anchors at the useful CKB recovery threshold", () => { + const anchor = readyDeposit(ICKB_DEPOSIT_CAP, 45n * 60n * 1000n); + const otherSegment = futureDeposit(120n * 60n * 1000n, 4n * ICKB_DEPOSIT_CAP); + const threshold = CKB_RESERVE + 100n; + + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP, + ckbBalance: threshold, + depositCapacity: 1000n * CKB, + ckbRecoveryThreshold: threshold, + readyDeposits: [anchor], + poolDepositsRest: [otherSegment], + }), + ).toMatchObject({ kind: "none", reason: "no_ring_surplus_ready_deposits" }); + }); + + it("requests ring-surplus withdrawals when iCKB is above the withdrawal floor", () => { + const first = readyDeposit(4n, 20n * 60n * 1000n); + const second = readyDeposit(6n, 25n * 60n * 1000n); + const third = readyDeposit(5n, 40n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 9n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [first, second, third], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ + kind: "withdraw", + deposits: [first, third], + requiredLiveDeposits: [second], + }); + }); +}); + +describe(PLAN_REBALANCE_SUITE, () => { + it("does not seed or withdraw ring inventory when the reserve gate fails", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP, + ckbBalance: 1000n * CKB + CKB_RESERVE - 1n, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)], + }), + ).toMatchObject({ kind: "none" }); + }); + + it("seeds ring inventory even when one more deposit would cross the withdrawal floor", () => { + expect( + planRebalance({ + outputSlots: 4, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE - ICKB_DEPOSIT_CAP + 1n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n * CKB, + readyDeposits: [], + poolDepositsRest: [futureDeposit(5n), futureDeposit(6n), futureDeposit(9n)], + }), + ).toMatchObject({ kind: "deposit", quantity: 1 }); + }); +}); diff --git a/packages/bot/test/policy/withdrawalSelection.ts b/packages/bot/test/policy/withdrawalSelection.ts new file mode 100644 index 0000000..18fd528 --- /dev/null +++ b/packages/bot/test/policy/withdrawalSelection.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { + CKB, + ICKB_DEPOSIT_CAP, + NO_POOL_REST, + PLAN_REBALANCE_SUITE, + TARGET_ICKB_BALANCE, + TIP, + planRebalance, + readyDeposit, +} from "./fixtures/policy.ts"; + +describe(PLAN_REBALANCE_SUITE, () => { + it("does not prefer crowded ready buckets over ring surplus", () => { + const sparseReady = readyDeposit(5n, 0n); + const crowdedEarly = readyDeposit(4n, 20n * 60n * 1000n); + const crowdedLate = readyDeposit(4n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 5n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [sparseReady, crowdedEarly, crowdedLate], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "withdraw", + deposits: [crowdedEarly], + requiredLiveDeposits: [sparseReady], + }); + }); + + it("pins required ring deposits for ordinary extra withdrawals", () => { + const ringAnchor = readyDeposit(ICKB_DEPOSIT_CAP, 20n * 60n * 1000n); + const extra = readyDeposit(ICKB_DEPOSIT_CAP - CKB, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP - CKB, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [extra, ringAnchor], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "withdraw", + deposits: [extra], + requiredLiveDeposits: [ringAnchor], + }); + }); + + it("uses the earliest ring surplus fit", () => { + const sparseReady = readyDeposit(5n, 0n); + const crowdedProtected = readyDeposit(6n, 20n * 60n * 1000n); + const crowdedExtra = readyDeposit(5n, 25n * 60n * 1000n); + + const plan = planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 5n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [crowdedExtra, crowdedProtected, sparseReady], + poolDepositsRest: NO_POOL_REST, + }); + + expect(plan).toMatchObject({ kind: "withdraw", deposits: [sparseReady] }); + }); +}); + +describe(PLAN_REBALANCE_SUITE, () => { + it("uses ring surplus while moderate excess can still thin a crowded bucket", () => { + const sparseReady = readyDeposit(5n, 0n); + const crowdedLarge = readyDeposit(9n, 20n * 60n * 1000n); + const crowdedSmall = readyDeposit(4n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 9n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [sparseReady, crowdedLarge, crowdedSmall], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "withdraw", + deposits: [sparseReady, crowdedSmall], + requiredLiveDeposits: [crowdedLarge], + }); + }); + + it("can select sparse ready deposits when ring surplus permits it", () => { + const sparseReady = readyDeposit(5n, 0n); + const crowdedLarge = readyDeposit(9n, 20n * 60n * 1000n); + const crowdedSmall = readyDeposit(4n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + ICKB_DEPOSIT_CAP + 9n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [sparseReady, crowdedLarge, crowdedSmall], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "withdraw", + deposits: [sparseReady, crowdedSmall], + requiredLiveDeposits: [crowdedLarge], + }); + }); + + it("pins only ring anchors for selected surplus", () => { + const lowExtra = readyDeposit(3n, 20n * 60n * 1000n); + const lowProtected = readyDeposit(5n, 25n * 60n * 1000n); + const highExtra = readyDeposit(4n, 40n * 60n * 1000n); + const highProtected = readyDeposit(5n, 44n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 4n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [lowExtra, lowProtected, highExtra, highProtected], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "withdraw", + deposits: [highExtra], + requiredLiveDeposits: [lowProtected], + }); + }); +}); + +describe(PLAN_REBALANCE_SUITE, () => { + it("leaves one deposit in a crowded ready bucket when that already reduces excess", () => { + const first = readyDeposit(3n, 20n * 60n * 1000n); + const last = readyDeposit(3n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 6n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [first, last], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "withdraw", + deposits: [last], + requiredLiveDeposits: [first], + }); + }); + + it("keeps the latest deposit when crowded bucket values tie", () => { + const earlier = readyDeposit(3n, 20n * 60n * 1000n); + const later = readyDeposit(3n, 25n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 3n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [earlier, later], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "withdraw", + deposits: [later], + requiredLiveDeposits: [earlier], + }); + }); + + it("withdraws sparse ready buckets when they are ring surplus", () => { + const earlierSparseReady = readyDeposit(5n, 20n * 60n * 1000n); + const laterSparseReady = readyDeposit(5n, 45n * 60n * 1000n); + + expect( + planRebalance({ + outputSlots: 6, + tip: TIP, + ickbBalance: TARGET_ICKB_BALANCE + 5n, + ckbBalance: 2000n * CKB, + depositCapacity: 1000n, + ickbRefillThreshold: TARGET_ICKB_BALANCE, + readyDeposits: [earlierSparseReady, laterSparseReady], + poolDepositsRest: NO_POOL_REST, + }), + ).toMatchObject({ + kind: "withdraw", + deposits: [laterSparseReady], + requiredLiveDeposits: [earlierSparseReady], + }); + }); +}); diff --git a/packages/bot/test/runtime/support.ts b/packages/bot/test/runtime/support.ts new file mode 100644 index 0000000..c70262f --- /dev/null +++ b/packages/bot/test/runtime/support.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { usefulMatchFloors } from "../../src/runtime/support.ts"; + +describe("usefulMatchFloors", () => { + it("uses allowance steps when matchable directions have no lower bound", () => { + expect( + usefulMatchFloors({ + orderCount: 1, + allowance: { ckbValue: 0n, udtValue: 0n }, + ckbAllowanceStep: 3n, + udtAllowanceStep: 5n, + ckbMiningFee: 1n, + directions: { + ckbToUdt: { matchableCount: 1 }, + udtToCkb: { matchableCount: 1 }, + }, + candidates: { + total: 1, + viable: 0, + positiveGain: 0, + rejected: { + maxPartials: 0, + duplicateOrder: 0, + insufficientCkbAllowance: 0, + insufficientUdtAllowance: 0, + nonPositiveGain: 0, + }, + bestGain: 0n, + }, + }), + ).toEqual({ ckb: 3n, ickb: 5n }); + }); +}); diff --git a/packages/bot/test/transactions/deposit.ts b/packages/bot/test/transactions/deposit.ts new file mode 100644 index 0000000..1262c79 --- /dev/null +++ b/packages/bot/test/transactions/deposit.ts @@ -0,0 +1,204 @@ +import { ccc } from "@ckb-ccc/core"; +import { receiptPhase2Capacity } from "@ickb/core"; +import { OrderManager } from "@ickb/order"; +import { asyncPassthroughTransaction, script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CKB_RESERVE } from "../../src/policy.ts"; +import { buildTransaction } from "../../src/runtime/transaction.ts"; +import { + botRuntime, + botState, + TARGET_ICKB_BALANCE, + testMatch, +} from "../bot/fixtures/bot.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildTransaction direct deposit seeding", () => { + it("labels direct ring seeding decisions", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + + const lock = script("45"); + const deposit = vi.fn(asyncPassthroughTransaction); + const runtime = botRuntime({ primaryLock: lock, managers: { logic: { deposit } } }); + const state = botState({ + availableCkbBalance: ccc.fixedPointFrom(3000), + availableIckbBalance: TARGET_ICKB_BALANCE + CKB_RESERVE, + depositCapacity: ccc.fixedPointFrom(1000), + totalCkbBalance: ccc.fixedPointFrom(3000), + }); + + await expect(buildTransaction(runtime, state)).resolves.toMatchObject({ + kind: "built", + actions: { deposits: 1 }, + decision: { + audit: { + reserveCheck: { + directDepositCost: ccc.fixedPointFrom(1000) + receiptPhase2Capacity(lock), + estimatedFee: 1n, + }, + rebalanceCosts: { + directDepositCapacity: ccc.fixedPointFrom(1000) + receiptPhase2Capacity(lock), + directDepositFeeHeadroom: ccc.fixedPointFrom(1), + }, + selectedRing: { + targetDepositCount: 0, + canCreateRingInventory: true, + shouldBootstrapRing: true, + }, + }, + rebalance: { kind: "deposit", reason: "ring_inventory" }, + }, + }); + expect(deposit).toHaveBeenCalledTimes(1); + }); +}); + +describe("buildTransaction low iCKB direct deposit refill", () => { + it("refills iCKB when post-match balance is below the useful UDT floor", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + diagnostics: matchDiagnostics({ + ckbValue: ccc.fixedPointFrom(2000), + udtValue: 99n, + }), + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const deposit = vi.fn(asyncPassthroughTransaction); + + await expect( + buildTransaction( + botRuntime({ managers: { logic: { deposit } } }), + botState({ + availableCkbBalance: ccc.fixedPointFrom(3000), + availableIckbBalance: 99n, + depositCapacity: ccc.fixedPointFrom(1000), + totalCkbBalance: ccc.fixedPointFrom(3000), + marketOrders: [testMatch("6c").order], + }), + ), + ).resolves.toMatchObject({ + kind: "built", + actions: { deposits: 1 }, + decision: { rebalance: { kind: "deposit", reason: "low_ickb_balance" } }, + }); + expect(deposit).toHaveBeenCalledTimes(1); + }); + + it("skips direct iCKB refill when only capacity and reserve are available", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + diagnostics: matchDiagnostics({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 99n, + }), + }); + const deposit = vi.fn(asyncPassthroughTransaction); + const completeTransaction = vi.fn(); + + await expect( + buildTransaction( + botRuntime({ managers: { logic: { deposit } }, sdk: { completeTransaction } }), + botState({ + availableCkbBalance: ccc.fixedPointFrom(1000) + CKB_RESERVE, + availableIckbBalance: 99n, + depositCapacity: ccc.fixedPointFrom(1000), + totalCkbBalance: ccc.fixedPointFrom(1000) + CKB_RESERVE, + marketOrders: [testMatch("6d").order], + }), + ), + ).resolves.toMatchObject({ + kind: "skipped", + reason: "no_actions", + decision: { + rebalance: { kind: "none", reason: "low_ickb_ckb_reserve_unavailable" }, + }, + }); + expect(deposit).not.toHaveBeenCalled(); + expect(completeTransaction).not.toHaveBeenCalled(); + }); +}); + +describe("buildTransaction post-match direct deposit refill", () => { + it("refills iCKB in the same transaction when a match depletes it below the useful UDT floor", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 1n, + udtDelta: -60n, + partials: [testMatch("6e")], + diagnostics: matchDiagnostics({ + ckbValue: ccc.fixedPointFrom(2000), + udtValue: 150n, + positiveGain: 1, + }), + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const deposit = vi.fn(asyncPassthroughTransaction); + + await expect( + buildTransaction( + botRuntime({ managers: { logic: { deposit } } }), + botState({ + availableCkbBalance: ccc.fixedPointFrom(3000), + availableIckbBalance: 150n, + depositCapacity: ccc.fixedPointFrom(1000), + totalCkbBalance: ccc.fixedPointFrom(3000), + marketOrders: [testMatch("6f").order], + }), + ), + ).resolves.toMatchObject({ + kind: "built", + actions: { matchedOrders: 1, deposits: 1 }, + decision: { + match: { reason: "matched" }, + rebalance: { kind: "deposit", reason: "low_ickb_balance" }, + }, + }); + expect(deposit).toHaveBeenCalledTimes(1); + }); +}); + +function matchDiagnostics({ + ckbValue, + udtValue, + positiveGain = 0, +}: { + ckbValue: bigint; + udtValue: bigint; + positiveGain?: number; +}): ReturnType["diagnostics"] { + return { + orderCount: 1, + allowance: { ckbValue, udtValue }, + ckbAllowanceStep: 100n, + udtAllowanceStep: 100n, + ckbMiningFee: 1n, + directions: { + ckbToUdt: { matchableCount: 1, minAllowance: 100n, maxMatch: 1000n }, + udtToCkb: { matchableCount: 0 }, + }, + candidates: { + total: 1, + viable: 1, + positiveGain, + rejected: { + maxPartials: 0, + duplicateOrder: 0, + insufficientCkbAllowance: 0, + insufficientUdtAllowance: positiveGain === 0 ? 1 : 0, + nonPositiveGain: 0, + }, + bestGain: BigInt(positiveGain), + }, + }; +} diff --git a/packages/bot/test/transactions/match.ts b/packages/bot/test/transactions/match.ts new file mode 100644 index 0000000..c232d88 --- /dev/null +++ b/packages/bot/test/transactions/match.ts @@ -0,0 +1,411 @@ +import { ccc } from "@ckb-ccc/core"; +import { OrderManager, Ratio, type MatchDiagnostics } from "@ickb/order"; +import { headerLike, script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + CKB_RESERVE, + type RebalanceDiagnostics, + type RebalancePlan, + type RingSegmentDiagnostics, +} from "../../src/policy.ts"; +import { buildDecisionTranscript } from "../../src/runtime/decision.ts"; +import { MAX_OUTPUTS_BEFORE_CHANGE } from "../../src/runtime/support.ts"; +import { buildTransaction } from "../../src/runtime/transaction.ts"; +import type { + BotActions, + BotDecisionTranscript, + BotMatchReason, +} from "../../src/runtime/types.ts"; +import { + botRuntime, + botState, + hash, + readyDeposit, + TARGET_ICKB_BALANCE, + testMatch, +} from "../bot/fixtures/bot.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildTransaction match allowance reserve", () => { + it("preserves the bot available CKB reserve when matching orders", async () => { + const bestMatch = vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + + await buildTransaction( + botRuntime(), + botState({ + availableCkbBalance: ccc.fixedPointFrom(5000), + availableIckbBalance: TARGET_ICKB_BALANCE, + }), + ); + + expect(bestMatch.mock.calls[0]?.[1]).toMatchObject({ + ckbValue: ccc.fixedPointFrom(3999), + }); + }); + + it("keeps fee headroom out of CKB-consuming match allowance", async () => { + const bestMatch = vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + + await buildTransaction( + botRuntime(), + botState({ + availableCkbBalance: CKB_RESERVE + ccc.fixedPointFrom(2), + availableIckbBalance: TARGET_ICKB_BALANCE, + }), + ); + + expect(bestMatch.mock.calls[0]?.[1]).toMatchObject({ + ckbValue: ccc.fixedPointFrom(1), + }); + }); +}); + +describe("buildTransaction match-only fee profitability", () => { + it("skips match-only transactions when the completed fee consumes the match value", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 1n, + udtDelta: 0n, + partials: [testMatch("67")], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + + const state = botState({ + marketOrders: [testMatch("68").order], + availableCkbBalance: CKB_RESERVE, + availableIckbBalance: TARGET_ICKB_BALANCE, + totalCkbBalance: CKB_RESERVE, + }); + + const result = await buildTransaction( + botRuntime({ primaryLock: script("11") }), + state, + ); + + expect(result).toMatchObject({ + kind: "skipped", + reason: "match_value_not_above_fee", + actions: { matchedOrders: 0 }, + decision: { + actions: { matchedOrders: 0 }, + skip: { + reason: "match_value_not_above_fee", + fee: 1n, + matchValue: 1n, + attemptedActions: { matchedOrders: 1 }, + }, + }, + }); + }); + + it("uses the repo exchange-ratio scale when checking match-only profitability", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: -2n, + udtDelta: 2n, + partials: [testMatch("70")], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + + const state = botState({ + marketOrders: [testMatch("71").order], + availableCkbBalance: CKB_RESERVE + 3n, + availableIckbBalance: TARGET_ICKB_BALANCE, + totalCkbBalance: CKB_RESERVE + 3n, + system: { + feeRate: 1n, + exchangeRatio: Ratio.from({ ckbScale: 3n, udtScale: 5n }), + orderPool: [], + ckbAvailable: 0n, + ckbMaturing: [], + tip: headerLike(), + }, + }); + + await expect( + buildTransaction(botRuntime({ primaryLock: script("11") }), state), + ).resolves.toMatchObject({ + kind: "built", + actions: { matchedOrders: 1 }, + }); + }); +}); + +describe("buildTransaction match decision labels", () => { + it("labels built match and deposit-rebalance decisions", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 1n, + partials: [testMatch("69")], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + + const state = botState({ + marketOrders: [testMatch("6a").order], + availableCkbBalance: ccc.fixedPointFrom(2000), + availableIckbBalance: 0n, + depositCapacity: 100n, + totalCkbBalance: ccc.fixedPointFrom(2000), + }); + + await expect( + buildTransaction(botRuntime({ primaryLock: script("11") }), state), + ).resolves.toMatchObject({ + kind: "built", + decision: { + match: { + reason: "matched", + matchedOrderOutPoints: [{ txHash: hash("69"), index: "0" }], + matchedOrderMasterOutPoints: [{ txHash: hash("69"), index: "1" }], + }, + rebalance: { kind: "deposit", reason: "ring_inventory" }, + }, + }); + }); +}); + +describe("buildTransaction output boundary", () => { + it("skips candidates whose actual pre-change outputs exceed the output limit", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + const buildBaseTransaction = vi.fn(async (txLike: ccc.TransactionLike) => { + await Promise.resolve(); + const tx = ccc.Transaction.from(txLike); + for (let index = 0; index <= MAX_OUTPUTS_BEFORE_CHANGE; index += 1) { + tx.addOutput({ capacity: 0n, lock: script("44") }, "0x"); + } + return tx; + }); + const completeTransaction = vi.fn(async (txLike: ccc.TransactionLike) => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }); + + const result = await buildTransaction( + botRuntime({ sdk: { buildBaseTransaction, completeTransaction } }), + botState({ + availableCkbBalance: ccc.fixedPointFrom(2000), + availableIckbBalance: 0n, + depositCapacity: 100n, + totalCkbBalance: ccc.fixedPointFrom(2000), + }), + ); + + expect(result).toMatchObject({ + kind: "skipped", + reason: "output_limit", + actions: { deposits: 0 }, + decision: { + skip: { + reason: "output_limit", + attemptedActions: { deposits: 1 }, + }, + }, + }); + expect(completeTransaction).not.toHaveBeenCalled(); + }); +}); + +describe("buildDecisionTranscript match miss labels", () => { + it.each([ + ["no_matchable_orders", matchDiagnostics({ ckbMatchable: 0, udtMatchable: 0 })], + [ + "insufficient_allowance", + matchDiagnostics({ viable: 0, insufficientCkbAllowance: 1 }), + ], + ["no_viable_candidates", matchDiagnostics({ viable: 0 })], + ["no_viable_candidates", undefined], + ["no_viable_candidates", matchDiagnostics({ viable: 1, positiveGain: 1 })], + ["max_partials", matchDiagnostics({ viable: 1, maxPartials: 1 })], + [ + "insufficient_allowance", + matchDiagnostics({ viable: 1, insufficientUdtAllowance: 1 }), + ], + ["no_positive_gain", matchDiagnostics({ viable: 1 })], + ] satisfies Array<[BotMatchReason, MatchDiagnostics | undefined]>)( + "labels %s", + (reason, diagnostics) => { + expect( + buildDecisionTranscript({ + runtime: botRuntime(), + state: botState({ marketOrders: [testMatch("72").order] }), + match: { ckbDelta: 0n, udtDelta: 0n, partials: [], diagnostics }, + rebalance: { kind: "none", reason: "no_withdrawable_ickb" }, + outputSlots: 58, + actions: { + collectedOrders: 0, + completedDeposits: 0, + matchedOrders: 0, + deposits: 0, + withdrawalRequests: 0, + withdrawals: 0, + }, + tx: ccc.Transaction.default(), + }).match.reason, + ).toBe(reason); + }, + ); +}); + +describe("buildDecisionTranscript withdrawal summaries", () => { + it("records required live deposits for withdrawal rebalances", () => { + const selected = readyDeposit("73", 1n, 20n * 60n * 1000n); + const requiredLive = readyDeposit("74", 1n, 25n * 60n * 1000n); + + expect( + transcriptForRebalance( + { + kind: "withdraw", + reason: "excess_ickb_balance", + deposits: [selected], + requiredLiveDeposits: [requiredLive], + }, + { withdrawalRequests: 1 }, + ).rebalance, + ).toMatchObject({ + withdrawalRequestCount: 1, + requiredLiveDepositCount: 1, + }); + }); + + it("defaults required live deposits to zero", () => { + const selected = readyDeposit("75", 1n, 20n * 60n * 1000n); + + expect( + transcriptForRebalance( + { + kind: "withdraw", + reason: "excess_ickb_balance", + deposits: [selected], + }, + { withdrawalRequests: 1 }, + ).rebalance, + ).toMatchObject({ requiredLiveDepositCount: 0 }); + }); +}); + +describe("buildDecisionTranscript selected ring summaries", () => { + it("summarizes the heaviest selected ring segment", () => { + expect( + transcriptForRebalance({ + kind: "none", + reason: "no_withdrawable_ickb", + diagnostics: ringDiagnostics(), + }).audit.selectedRing, + ).toMatchObject({ + targetDepositCount: 0, + heaviestSegmentIndex: 1, + heaviestSegmentDepositCount: 2, + heaviestSegmentUdtValue: 4n, + }); + }); +}); + +function transcriptForRebalance( + rebalance: RebalancePlan, + actionOverrides: Partial = {}, +): BotDecisionTranscript { + return buildDecisionTranscript({ + runtime: botRuntime(), + state: botState({}), + match: { ckbDelta: 0n, udtDelta: 0n, partials: [] }, + rebalance, + outputSlots: 58, + actions: { + collectedOrders: 0, + completedDeposits: 0, + matchedOrders: 0, + deposits: 0, + withdrawalRequests: 0, + withdrawals: 0, + ...actionOverrides, + }, + tx: ccc.Transaction.default(), + }); +} + +function ringDiagnostics(): RebalanceDiagnostics { + return { + ring: { + poolDepositCount: 2, + canCreateRingInventory: true, + shouldBootstrapRing: false, + ringLength: 180n, + segmentCount: 2, + targetSegmentIndex: 0, + targetSegmentUdtValue: 0n, + totalPoolUdt: 4n, + depositsShareOneSegment: false, + segments: [ + ringSegment({ index: 0, depositCount: 0, udtValue: 0n, isTarget: true }), + ringSegment({ index: 1, depositCount: 2, udtValue: 4n, isTarget: false }), + ], + }, + }; +} + +function ringSegment( + segment: Pick< + RingSegmentDiagnostics, + "depositCount" | "index" | "isTarget" | "udtValue" + >, +): RingSegmentDiagnostics { + return { + ...segment, + protectedDepositCount: 0, + protectedUdtValue: 0n, + protectedOutPoints: [], + surplusDepositCount: segment.depositCount, + surplusUdtValue: segment.udtValue, + surplusOutPoints: [], + }; +} + +function matchDiagnostics( + overrides: Partial<{ + ckbMatchable: number; + udtMatchable: number; + viable: number; + positiveGain: number; + maxPartials: number; + insufficientCkbAllowance: number; + insufficientUdtAllowance: number; + }> = {}, +): MatchDiagnostics { + return { + orderCount: 1, + allowance: { ckbValue: 0n, udtValue: 0n }, + ckbAllowanceStep: 1n, + udtAllowanceStep: 1n, + ckbMiningFee: 1n, + directions: { + ckbToUdt: { matchableCount: overrides.ckbMatchable ?? 1 }, + udtToCkb: { matchableCount: overrides.udtMatchable ?? 1 }, + }, + candidates: { + total: 1, + viable: overrides.viable ?? 0, + positiveGain: overrides.positiveGain ?? 0, + rejected: { + maxPartials: overrides.maxPartials ?? 0, + duplicateOrder: 0, + insufficientCkbAllowance: overrides.insufficientCkbAllowance ?? 0, + insufficientUdtAllowance: overrides.insufficientUdtAllowance ?? 0, + nonPositiveGain: 0, + }, + bestGain: 0n, + }, + }; +} diff --git a/packages/bot/test/transactions/reserve.ts b/packages/bot/test/transactions/reserve.ts new file mode 100644 index 0000000..c97ca32 --- /dev/null +++ b/packages/bot/test/transactions/reserve.ts @@ -0,0 +1,245 @@ +import { ccc } from "@ckb-ccc/core"; +import { receiptPhase2Capacity } from "@ickb/core"; +import { OrderManager } from "@ickb/order"; +import { script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CKB_RESERVE } from "../../src/policy.ts"; +import { buildTransaction } from "../../src/runtime/transaction.ts"; +import { + botRuntime, + botState, + readyDeposit, + TARGET_ICKB_BALANCE, + testMatch, + testWithdrawal, +} from "../bot/fixtures/bot.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildTransaction reserve violation skip", () => { + it("skips built transactions that would violate the bot available CKB reserve", async () => { + const lock = script("11"); + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: -1n, + udtDelta: 0n, + partials: [testMatch("60")], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const runtime = botRuntime({ + primaryLock: lock, + managers: { + logic: { + deposit: async (txLike: ccc.TransactionLike): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + }, + }, + sdk: { + completeTransaction: async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + }, + }); + const state = botState({ + marketOrders: [testMatch("61").order], + availableCkbBalance: CKB_RESERVE + 1n, + availableIckbBalance: TARGET_ICKB_BALANCE, + totalCkbBalance: CKB_RESERVE + 1n, + }); + + const result = await buildTransaction(runtime, state); + + expect(result).toMatchObject({ + kind: "skipped", + reason: "post_tx_ckb_reserve", + decision: { + balances: { spendableCkb: 1n, matchableCkb: 0n }, + skip: { reason: "post_tx_ckb_reserve" }, + audit: { + reserveCheck: { + availableCkb: CKB_RESERVE + 1n, + matchCkbDelta: -1n, + estimatedFee: 1n, + reserve: CKB_RESERVE, + recoveryException: false, + }, + }, + }, + }); + expect(result.decision.audit.reserveCheck.deficit).toBe( + CKB_RESERVE - result.decision.audit.reserveCheck.projectedPostTransactionCkb, + ); + }); +}); + +describe("buildTransaction CKB reserve recovery", () => { + it("allows CKB-replenishing transactions even when available CKB remains below reserve", async () => { + const lock = script("11"); + 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.addOutput({ capacity: ccc.fixedPointFrom(500), lock }); + return tx; + }, + }, + }); + const state = botState({ + readyWithdrawals: [testWithdrawal("62")], + availableCkbBalance: ccc.fixedPointFrom(2000), + availableIckbBalance: TARGET_ICKB_BALANCE, + totalCkbBalance: ccc.fixedPointFrom(2000), + }); + + const result = await buildTransaction(runtime, state); + + expect(result).toMatchObject({ kind: "built", actions: { withdrawals: 1 } }); + expect(result.decision.skip).toBeUndefined(); + }); +}); + +describe("buildTransaction direct deposit audit", () => { + it("records direct deposit reserve costs", async () => { + const lock = script("18"); + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const state = botState({ + availableCkbBalance: ccc.fixedPointFrom(2000), + availableIckbBalance: 0n, + depositCapacity: 100n, + totalCkbBalance: ccc.fixedPointFrom(2000), + }); + + const result = await buildTransaction(botRuntime({ primaryLock: lock }), state); + + expect(result).toMatchObject({ + kind: "built", + actions: { deposits: 1 }, + decision: { + rebalance: { kind: "deposit" }, + audit: { + reserveCheck: { + directDepositCost: state.depositCapacity + receiptPhase2Capacity(lock), + withdrawalRequestCost: 0n, + }, + }, + }, + }); + }); +}); + +describe("buildTransaction reserve violation with withdrawals", () => { + it("skips withdrawal requests mixed with CKB-spending matches that violate reserve", async () => { + const lock = script("19"); + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: -1n, + udtDelta: 1n, + partials: [testMatch("63")], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const runtime = botRuntime({ + primaryLock: lock, + sdk: { + completeTransaction: async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + }, + }); + const state = botState({ + marketOrders: [testMatch("64").order], + availableCkbBalance: CKB_RESERVE, + availableIckbBalance: TARGET_ICKB_BALANCE + 9n, + totalCkbBalance: CKB_RESERVE, + depositCapacity: ccc.fixedPointFrom(1000), + readyPoolDeposits: [ + readyDeposit("1b", 4n, 20n * 60n * 1000n), + readyDeposit("1c", 6n, 25n * 60n * 1000n), + readyDeposit("1d", 5n, 40n * 60n * 1000n), + ], + }); + + const result = await buildTransaction(runtime, state); + + expect(result).toMatchObject({ + kind: "skipped", + reason: "post_tx_ckb_reserve", + actions: { matchedOrders: 0, withdrawalRequests: 0 }, + decision: { + skip: { + reason: "post_tx_ckb_reserve", + attemptedActions: { matchedOrders: 1, withdrawalRequests: 2 }, + }, + }, + }); + expect(result.decision.audit.reserveCheck.recoveryException).toBe(false); + }); +}); + +describe("buildTransaction reserve recovery with withdrawals", () => { + it("allows reserve-recovery withdrawal requests mixed with CKB-replenishing matches", async () => { + const lock = script("1e"); + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 1n, + udtDelta: -1n, + partials: [testMatch("65")], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const state = botState({ + marketOrders: [testMatch("66").order], + availableCkbBalance: CKB_RESERVE - 50n, + availableIckbBalance: TARGET_ICKB_BALANCE + 9n, + totalCkbBalance: CKB_RESERVE - 50n, + depositCapacity: ccc.fixedPointFrom(1000), + readyPoolDeposits: [ + readyDeposit("20", 4n, 20n * 60n * 1000n), + readyDeposit("21", 6n, 25n * 60n * 1000n), + readyDeposit("22", 5n, 40n * 60n * 1000n), + ], + }); + + const result = await buildTransaction( + botRuntime({ + primaryLock: lock, + sdk: { + completeTransaction: async ( + txLike: ccc.TransactionLike, + ): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + }, + }), + state, + ); + + expect(result).toMatchObject({ + kind: "built", + actions: { matchedOrders: 1, withdrawalRequests: 2 }, + decision: { rebalance: { kind: "withdraw", reason: "reserve_recovery" } }, + }); + expect(result.decision.skip).toBeUndefined(); + }); +}); diff --git a/packages/bot/test/transactions/withdrawal.ts b/packages/bot/test/transactions/withdrawal.ts new file mode 100644 index 0000000..31a3c41 --- /dev/null +++ b/packages/bot/test/transactions/withdrawal.ts @@ -0,0 +1,208 @@ +import { ccc } from "@ckb-ccc/core"; +import { OrderManager, Ratio } from "@ickb/order"; +import type { IckbSdk } from "@ickb/sdk"; +import { headerLike, script } from "@ickb/testkit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CKB_RESERVE } from "../../src/policy.ts"; +import { buildTransaction } from "../../src/runtime/transaction.ts"; +import { + botRuntime, + botState, + hash, + readyDeposit, + TARGET_ICKB_BALANCE, +} from "../bot/fixtures/bot.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildTransaction withdrawal reserve staging", () => { + it("allows withdrawal requests from an available CKB reserve deficit", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const state = botState({ + availableCkbBalance: 0n, + availableIckbBalance: TARGET_ICKB_BALANCE + 9n, + totalCkbBalance: 0n, + depositCapacity: 1000n, + readyPoolDeposits: [ + readyDeposit("82", 4n, 20n * 60n * 1000n), + readyDeposit("83", 6n, 25n * 60n * 1000n), + readyDeposit("84", 5n, 40n * 60n * 1000n), + ], + }); + + const result = await buildTransaction( + botRuntime({ primaryLock: script("11") }), + state, + ); + + expect(result).toMatchObject({ kind: "built", actions: { withdrawalRequests: 2 } }); + expect(result.decision.skip).toBeUndefined(); + }); +}); + +describe("buildTransaction excess withdrawal reserve crossing", () => { + it("allows excess withdrawal requests that cross below the available CKB reserve", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + vi.spyOn(ccc.Transaction.prototype, "estimateFee").mockReturnValue(1n); + const withdrawal = readyDeposit("86", ccc.fixedPointFrom(1000), 20n * 60n * 1000n); + const protectedAnchor = readyDeposit( + "8a", + ccc.fixedPointFrom(1001), + 25n * 60n * 1000n, + ); + const futureFirst = readyDeposit("87", ccc.fixedPointFrom(1000), 9n, { + isReady: false, + }); + const futureSecond = readyDeposit("88", ccc.fixedPointFrom(1000), 10n, { + isReady: false, + }); + const state = botState({ + availableCkbBalance: CKB_RESERVE + 50n, + availableIckbBalance: TARGET_ICKB_BALANCE + ccc.fixedPointFrom(1000), + totalCkbBalance: CKB_RESERVE + 50n, + depositCapacity: ccc.fixedPointFrom(1000), + readyPoolDeposits: [withdrawal, protectedAnchor], + poolDeposits: [withdrawal, protectedAnchor, futureFirst, futureSecond], + system: systemState("89"), + }); + + const result = await buildTransaction( + botRuntime({ primaryLock: script("11") }), + state, + ); + + expect(result).toMatchObject({ + kind: "built", + actions: { withdrawalRequests: 2 }, + decision: { rebalance: { kind: "withdraw", reason: "excess_ickb_balance" } }, + }); + expect(result.decision.audit.reserveCheck.recoveryException).toBe(true); + expect(result.decision.skip).toBeUndefined(); + }); +}); + +describe("buildTransaction withdrawal required live deposits", () => { + it("passes required live deposits to SDK base transaction construction", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + const first = readyDeposit("11", 4n, 20n * 60n * 1000n); + const protectedAnchor = readyDeposit("12", 6n, 25n * 60n * 1000n); + const third = readyDeposit("13", 5n, 40n * 60n * 1000n); + const calls: string[] = []; + const buildBaseTransaction = vi.fn(); + buildBaseTransaction.mockImplementation( + async (txLike: ccc.TransactionLike): Promise => { + await Promise.resolve(); + calls.push("base"); + return ccc.Transaction.from(txLike); + }, + ); + const completeTransaction = vi.fn( + async (txLike: ccc.TransactionLike): Promise => { + await Promise.resolve(); + calls.push("complete"); + expect(calls).toEqual(["base", "complete"]); + return ccc.Transaction.from(txLike); + }, + ); + + const result = await buildTransaction( + botRuntime({ + sdk: { buildBaseTransaction, completeTransaction }, + primaryLock: script("44"), + }), + botState({ + availableIckbBalance: TARGET_ICKB_BALANCE + 9n, + depositCapacity: 1000n, + readyPoolDeposits: [first, protectedAnchor, third], + }), + ); + + expect(result.kind).toBe("built"); + expect(result.actions.withdrawalRequests).toBe(2); + expect(buildBaseTransaction.mock.calls[0]?.[2]).toMatchObject({ + withdrawalRequest: { + deposits: [first, third], + requiredLiveDeposits: [protectedAnchor], + }, + }); + expect(completeTransaction).toHaveBeenCalledTimes(1); + expect(calls).toEqual(["base", "complete"]); + }); +}); + +describe("buildTransaction excess withdrawal ready deposit selection", () => { + it("labels excess withdrawals and passes only ready deposits", async () => { + vi.spyOn(OrderManager, "bestMatch").mockReturnValue({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + const extra = readyDeposit("14", 4n, 20n * 60n * 1000n); + const protectedAnchor = readyDeposit("15", 6n, 25n * 60n * 1000n); + const futureFirst = readyDeposit("16", 100n, 9n, { isReady: false }); + const futureSecond = readyDeposit("17", 100n, 10n, { isReady: false }); + const buildBaseTransaction = vi.fn(); + buildBaseTransaction.mockImplementation( + async (txLike: ccc.TransactionLike): Promise => { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + }, + ); + + const result = await buildTransaction( + botRuntime({ sdk: { buildBaseTransaction }, primaryLock: script("46") }), + botState({ + availableCkbBalance: ccc.fixedPointFrom(1999), + availableIckbBalance: TARGET_ICKB_BALANCE + 9n, + depositCapacity: ccc.fixedPointFrom(1000), + readyPoolDeposits: [extra, protectedAnchor], + poolDeposits: [extra, protectedAnchor, futureFirst, futureSecond], + system: systemState("18"), + }), + ); + + expect(result).toMatchObject({ + kind: "built", + actions: { withdrawalRequests: 2 }, + decision: { rebalance: { kind: "withdraw", reason: "excess_ickb_balance" } }, + }); + const withdrawalRequest = buildBaseTransaction.mock.calls[0]?.[2]?.withdrawalRequest; + expect(withdrawalRequest).toMatchObject({ + deposits: [extra, protectedAnchor], + requiredLiveDeposits: [futureFirst], + }); + expect(withdrawalRequest?.deposits).not.toContain(futureFirst); + expect(withdrawalRequest?.deposits).not.toContain(futureSecond); + }); +}); + +function systemState(hashByte: string): Parameters[0]["system"] { + return { + feeRate: 1n, + exchangeRatio: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + orderPool: [], + ckbAvailable: 0n, + ckbMaturing: [], + tip: headerLike({ + number: 3n, + hash: hash(hashByte), + timestamp: 0n, + epoch: [0n, 0n, 1n], + }), + }; +} diff --git a/packages/bot/tsconfig.json b/packages/bot/tsconfig.json new file mode 100644 index 0000000..34b5438 --- /dev/null +++ b/packages/bot/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/bot/vitest.config.mts b/packages/bot/vitest.config.mts new file mode 100644 index 0000000..6bfb4b3 --- /dev/null +++ b/packages/bot/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["{test,tests}/**/*.{ts,tsx}"], + exclude: ["{test,tests}/**/fixtures/**"], + coverage: { + include: ["src/**/*.{ts,tsx}"], + }, + }, +}); diff --git a/packages/node-utils/package.json b/packages/node-utils/package.json index bb8bcb2..65dc2cb 100644 --- a/packages/node-utils/package.json +++ b/packages/node-utils/package.json @@ -24,20 +24,18 @@ "engines": { "node": ">=22.19.0" }, - "main": "dist/index.js", + "main": "src/index.ts", "types": "src/index.ts", "exports": { ".": { "types": "./src/index.ts", - "import": "./dist/index.js" + "import": "./src/index.ts" } }, "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "rm -fr dist && tsgo -p tsconfig.build.json && node ../../scripts/tooling/build/rewrite-dts-imports.ts dist", - "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/node-utils/src packages/node-utils/test", - "clean": "rm -fr dist" + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/node-utils/src packages/node-utils/test" }, "dependencies": { "@ckb-ccc/core": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index feddda5..9fc645b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@microsoft/api-extractor': specifier: ^7.58.9 version: 7.58.9(@types/node@24.12.2) + '@types/node': + specifier: 'catalog:' + version: 24.12.2 '@typescript/native-preview': specifier: latest version: 7.0.0-dev.20260704.1 @@ -78,22 +81,19 @@ importers: '@ckb-ccc/core': specifier: 'catalog:' version: 1.14.0(typescript@5.9.3)(zod@3.25.76) - '@ickb/core': + '@ickb/bot': specifier: workspace:* - version: link:../../packages/core + version: link:../../packages/bot '@ickb/node-utils': specifier: workspace:* version: link:../../packages/node-utils - '@ickb/order': - specifier: workspace:* - version: link:../../packages/order '@ickb/sdk': specifier: workspace:* version: link:../../packages/sdk - '@ickb/utils': - specifier: workspace:* - version: link:../../packages/utils devDependencies: + '@ickb/testkit': + specifier: workspace:* + version: link:../../packages/testkit '@types/node': specifier: 'catalog:' version: 24.12.2 @@ -215,6 +215,31 @@ importers: specifier: 'catalog:' version: 24.12.2 + packages/bot: + dependencies: + '@ckb-ccc/core': + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) + '@ickb/core': + specifier: workspace:* + version: link:../core + '@ickb/node-utils': + specifier: workspace:* + version: link:../node-utils + '@ickb/order': + specifier: workspace:* + version: link:../order + '@ickb/sdk': + specifier: workspace:* + version: link:../sdk + devDependencies: + '@ickb/testkit': + specifier: workspace:* + version: link:../testkit + '@types/node': + specifier: 'catalog:' + version: 24.12.2 + packages/core: dependencies: '@ckb-ccc/core': diff --git a/scripts/bot/collect-incident.ts b/scripts/bot/collect-incident.ts new file mode 100644 index 0000000..f511f54 --- /dev/null +++ b/scripts/bot/collect-incident.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { pathToFileURL } from "node:url"; + +import { main } from "./incident/index.ts"; + +const entrypoint = process.argv[1]; +if (entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href) { + process.exit(await main(process.argv.slice(2))); +} diff --git a/scripts/bot/incident/args.ts b/scripts/bot/incident/args.ts new file mode 100644 index 0000000..52a1d20 --- /dev/null +++ b/scripts/bot/incident/args.ts @@ -0,0 +1,150 @@ +import { isAsciiDigits } from "./model/text.ts"; +import type { CollectorRunArgs, ParsedCollectorArgs } from "./model/types.ts"; + +type RelativeTimeUnit = "d" | "h" | "m" | "ms" | "s"; + +interface ParsedOption { + args: Partial; + index: number; +} + +const relativeTimeMultipliers: Record = { + d: 86_400_000, + h: 3_600_000, + m: 60_000, + ms: 1, + s: 1_000, +}; +const relativeTimeUnits: readonly RelativeTimeUnit[] = ["ms", "s", "m", "h", "d"]; + +export function usage(): string { + return [ + "Usage: node --experimental-default-type=module scripts/bot/collect-incident.ts [--log-root PATH] [--log-dir PATH] --since --until ", + "Relative times use the current time, for example --since 2h --until now.", + ].join("\n"); +} + +export function parseArgs(argv: readonly string[]): ParsedCollectorArgs { + let args: Partial = {}; + let index = 0; + while (index < argv.length) { + const arg = argv[index] ?? ""; + if (arg === "--") { + index += 1; + continue; + } + if (arg === "-h" || arg === "--help") { + return { help: true }; + } + const parsed = parseOption(argv, index, arg, args); + args = parsed.args; + index = parsed.index + 1; + } + + if (args.since === undefined) { + throw new Error("Missing required --since"); + } + if (args.until === undefined) { + throw new Error("Missing required --until"); + } + + return omitUndefinedOptions({ + logDir: args.logDir, + logRoot: args.logRoot, + since: args.since, + until: args.until, + }); +} + +export function parseTimeBound(value: string, now: Date): Date { + if (value === "now") { + return new Date(now); + } + + const relativeOffsetMs = parseRelativeOffsetMs(value); + if (relativeOffsetMs !== null) { + const timestamp = now.getTime() - relativeOffsetMs; + if (!Number.isFinite(timestamp)) { + throw new TypeError(`Invalid time bound: ${value}`); + } + return new Date(timestamp); + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new TypeError(`Invalid time bound: ${value}`); + } + return parsed; +} + +function omitUndefinedOptions(args: CollectorRunArgs): CollectorRunArgs { + return { + ...(args.logDir === undefined ? {} : { logDir: args.logDir }), + ...(args.logRoot === undefined ? {} : { logRoot: args.logRoot }), + since: args.since, + until: args.until, + }; +} + +function parseOption( + argv: readonly string[], + index: number, + option: string, + args: Partial, +): ParsedOption { + switch (option) { + case "--log-root": { + const value = requireValue(argv, index + 1, option); + return { args: { ...args, logRoot: value }, index: index + 1 }; + } + case "--log-dir": { + const value = requireValue(argv, index + 1, option); + return { args: { ...args, logDir: value }, index: index + 1 }; + } + case "--since": { + const value = requireValue(argv, index + 1, option); + return { args: { ...args, since: value }, index: index + 1 }; + } + case "--until": { + const value = requireValue(argv, index + 1, option); + return { args: { ...args, until: value }, index: index + 1 }; + } + default: { + throw new Error(`Unknown option: ${option}`); + } + } +} + +function requireValue(argv: readonly string[], index: number, option: string): string { + const value = argv[index]; + if (value === undefined || value === "" || value.startsWith("--")) { + throw new Error(`Missing value for ${option}`); + } + return value; +} + +function parseRelativeOffsetMs(value: string): number | null { + const relativeValue = stripOptionalAgo(value); + const unit = relativeTimeUnits.find((candidate) => relativeValue.endsWith(candidate)); + if (unit === undefined) { + return null; + } + const amountText = relativeValue.slice(0, -unit.length); + if (amountText === "" || !isAsciiDigits(amountText)) { + return null; + } + const amount = Number(amountText); + if (!Number.isSafeInteger(amount)) { + return null; + } + return amount * relativeTimeMultipliers[unit]; +} + +function stripOptionalAgo(value: string): string { + if (!value.endsWith("ago")) { + return value; + } + const beforeAgo = value.slice(0, -"ago".length); + const stripped = beforeAgo.trimEnd(); + return stripped.length < beforeAgo.length ? stripped : value; +} diff --git a/scripts/bot/incident/bundle.ts b/scripts/bot/incident/bundle.ts new file mode 100644 index 0000000..5e991e6 --- /dev/null +++ b/scripts/bot/incident/bundle.ts @@ -0,0 +1,85 @@ +import pathModule from "node:path"; + +import { + assertRealDirectory, + prepareIncidentDirectory, + writeBundleOutputs, +} from "./io/filesystem.ts"; +import { buildVersionMetadata } from "./io/version.ts"; +import { logDirectoryLabel } from "./model/constants.ts"; +import type { + CollectIncidentResult, + IncidentDependencies, + IncidentPaths, + SourceWindow, +} from "./model/types.ts"; +import { addReferencedArtifacts } from "./source/artifacts.ts"; +import { collectSources, incidentParent } from "./source/collection.ts"; +import { buildCompleteSummary, createSummary, incidentReadme } from "./source/summary.ts"; + +const { dirname } = pathModule; + +export async function collectIncidentBundle({ + createdAt, + dependencies, + paths, + root, + window, +}: { + createdAt: Date; + dependencies: IncidentDependencies; + paths: IncidentPaths; + root: string; + window: SourceWindow; +}): Promise { + await assertRealDirectory(paths.logRoot, "log root", dependencies); + await assertRealDirectory(paths.logDir, logDirectoryLabel, dependencies); + + const summary = createSummary({ + createdAt, + logDir: paths.logDir, + logRoot: paths.logRoot, + logRootSource: paths.logRootSource, + since: window.since, + until: window.until, + }); + const collected = await collectSources({ + dependencies, + outputs: new Map(), + paths, + summary, + window, + }); + + const version = await buildVersionMetadata(root, dependencies); + collected.outputs.set("version.json", `${JSON.stringify(version, null, 2)}\n`); + + const artifactSummary = await addReferencedArtifacts( + paths, + collected.summary, + collected.outputs, + dependencies, + ); + + const completeSummary = buildCompleteSummary({ + createdAt, + incidentParent: incidentParent(paths.logDir), + summary: artifactSummary, + }); + collected.outputs.set("README.txt", incidentReadme(completeSummary)); + collected.outputs.set("summary.json", `${JSON.stringify(completeSummary, null, 2)}\n`); + + await prepareIncidentDirectory( + paths.logDir, + dirname(completeSummary.incidentDir), + completeSummary.incidentDir, + dependencies, + ); + await writeBundleOutputs(completeSummary.incidentDir, collected.outputs, dependencies); + + return { + incidentDir: completeSummary.incidentDir, + incidentId: completeSummary.incidentId, + summary: completeSummary, + }; +} diff --git a/scripts/bot/incident/index.ts b/scripts/bot/incident/index.ts new file mode 100644 index 0000000..37a52f4 --- /dev/null +++ b/scripts/bot/incident/index.ts @@ -0,0 +1,65 @@ +import { fileURLToPath } from "node:url"; + +import { parseArgs, parseTimeBound, usage } from "./args.ts"; +import { collectIncidentBundle } from "./bundle.ts"; +import { resolveIncidentPaths } from "./model/paths.ts"; +import { publicErrorMessage } from "./model/text.ts"; +import type { + CollectIncidentOptions, + CollectIncidentResult, + WritableLike, +} from "./model/types.ts"; + +const rootDir = fileURLToPath(new URL("../../..", import.meta.url)); + +export async function collectIncident({ + argv = process.argv.slice(2), + envLogRoot = process.env["ICKB_BOT_LOG_ROOT"], + now = (): Date => new Date(), + root = rootDir, + dependencies = {}, +}: CollectIncidentOptions = {}): Promise { + const parsed = parseArgs(argv); + if (parsed.help === true) { + return { help: usage() }; + } + + const createdAt = now(); + const since = parseTimeBound(parsed.since, createdAt); + const until = parseTimeBound(parsed.until, createdAt); + if (since.getTime() > until.getTime()) { + throw new Error("--since must be before or equal to --until"); + } + + const paths = resolveIncidentPaths({ parsed, envLogRoot, root }); + return collectIncidentBundle({ + createdAt, + dependencies, + paths, + root, + window: { since, until }, + }); +} + +export async function main( + argv: string[], + io: { stderr?: WritableLike; stdout?: WritableLike } = {}, +): Promise { + const stdout = io.stdout ?? process.stdout; + const stderr = io.stderr ?? process.stderr; + try { + const result = await collectIncident({ argv }); + if (result.help !== undefined) { + stdout.write(`${result.help}\n`); + return 0; + } + stdout.write(`Incident bundle directory: ${result.incidentDir}\n`); + stdout.write(`Compression command: ${result.summary.compression.command}\n`); + return 0; + } catch (error) { + stderr.write( + `Incident collection failed: ${publicErrorMessage(error)}\n${usage()}\n`, + ); + return 1; + } +} diff --git a/scripts/bot/incident/io/filesystem.ts b/scripts/bot/incident/io/filesystem.ts new file mode 100644 index 0000000..91ddd09 --- /dev/null +++ b/scripts/bot/incident/io/filesystem.ts @@ -0,0 +1,298 @@ +import { constants } from "node:fs"; +import { + lstat as fsLstat, + mkdir as fsMkdir, + open as fsOpen, + readdir as fsReaddir, + readFile as fsReadFile, + realpath as fsRealpath, + type FileHandle, +} from "node:fs/promises"; +import pathModule from "node:path"; + +import { logDirectoryLabel } from "../model/constants.ts"; +import { ignoreError, isErrorCode, publicErrorMessage } from "../model/text.ts"; +import type { + DirentLike, + IncidentDependencies, + IncidentFileHandle, + SourceStream, + StatLike, +} from "../model/types.ts"; + +type LineHandler = (line: string, lineNumber: number) => void; + +const { dirname, join, parse, relative, sep } = pathModule; +const noFollow = constants.O_NOFOLLOW; +const readOnlyNoFollow = constants.O_RDONLY | noFollow; +const writeNewFileFlags = + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow; + +export async function processSourceLines( + filePath: string, + label: string, + dependencies: IncidentDependencies, + onLine: LineHandler, +): Promise { + const handle = await openSourceHandle(filePath, label, dependencies); + if (handle === null) { + return false; + } + + try { + const stream = sourceStream(handle); + let lineNumber = 0; + let pending = ""; + const decoder = new TextDecoder("utf-8"); + for await (const text of streamTextChunks(stream, decoder)) { + pending += text; + const parts = pending.split("\n"); + pending = parts.pop() ?? ""; + for (const part of parts) { + lineNumber += 1; + onLine(`${part}\n`, lineNumber); + } + } + pending += decoder.decode(); + if (pending !== "") { + onLine(pending, lineNumber + 1); + } + return true; + } finally { + await ignoreError(handle.close()); + } +} + +export async function openSourceHandle( + path: string, + label: string, + dependencies: IncidentDependencies, +): Promise { + let stat: StatLike; + try { + stat = await (dependencies.lstat ?? safeLstat)(path); + } catch (error) { + if (isErrorCode(error, "ENOENT")) { + return null; + } + throw error; + } + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked source log file: ${path}`); + } + if (!stat.isFile()) { + throw new Error(`Source log is not a regular file: ${path}`); + } + + let handle: (FileHandle | IncidentFileHandle) | undefined; + try { + handle = await (dependencies.open ?? safeOpen)(path, readOnlyNoFollow); + const opened = await handle.stat(); + if (!opened.isFile()) { + throw new Error(`Source log is not a regular file: ${path}`); + } + return handle; + } catch (error) { + await ignoreError(handle?.close()); + if (isErrorCode(error, "ELOOP")) { + throw new Error(`Refusing symlinked source log file: ${path}`, { + cause: error, + }); + } + throw new Error(`Unable to read ${label}: ${publicErrorMessage(error)}`, { + cause: error, + }); + } +} + +export async function writeBundleOutputs( + incidentDir: string, + outputs: Map, + dependencies: IncidentDependencies, +): Promise { + for (const [name, text] of [...outputs].toSorted(([left], [right]) => + left.localeCompare(right), + )) { + await writeBundleFile(join(incidentDir, name), text, dependencies); + } +} + +export async function prepareIncidentDirectory( + logDir: string, + incidentParent: string, + incidentDir: string, + dependencies: IncidentDependencies, +): Promise { + await assertRealDirectory(logDir, logDirectoryLabel, dependencies); + await ensureDirectChildDirectory( + incidentParent, + "incident directory parent", + dependencies, + ); + await assertRealDirectory(incidentParent, "incident directory parent", dependencies); + await (dependencies.mkdir ?? safeMkdir)(incidentDir, { mode: 0o700 }); + await assertRealDirectory(incidentDir, "incident directory", dependencies); +} + +export async function assertRealDirectory( + filePath: string, + label: string, + dependencies: IncidentDependencies, +): Promise { + if (filePath === "") { + throw new Error(`Empty ${label} path`); + } + await assertNoSymlinkedPathComponents(filePath, label, dependencies); + const stat = await (dependencies.lstat ?? safeLstat)(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked ${label}: ${filePath}`); + } + if (!stat.isDirectory()) { + throw new Error(`${label} is not a directory: ${filePath}`); + } + const resolved = await (dependencies.realpath ?? safeRealpath)(filePath); + if (resolved !== filePath) { + throw new Error(`Resolved ${label} crosses a symlink: ${filePath}`); + } +} + +export async function assertNoSymlinkedPathComponents( + filePath: string, + label: string, + dependencies: IncidentDependencies, +): Promise { + const parsed = parse(filePath); + let current = parsed.root; + const parts = relative(parsed.root, filePath).split(sep).filter(Boolean); + for (const part of parts) { + current = join(current, part); + let stat: StatLike; + try { + stat = await (dependencies.lstat ?? safeLstat)(current); + } catch (error) { + if (isErrorCode(error, "ENOENT")) { + return; + } + throw error; + } + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked ${label} path: ${current}`); + } + } +} + +export async function readHandleText( + handle: IncidentFileHandle, + label: string, +): Promise { + if (handle.readFile === undefined) { + throw new Error(`Unable to read ${label}: source handle is not readable`); + } + const text = await handle.readFile("utf8"); + return typeof text === "string" ? text : Buffer.from(text).toString("utf8"); +} + +export async function safeLstat(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Callers prove collector-managed paths before filesystem use. + return fsLstat(filePath); +} + +export async function safeRealpath(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This is the symlink proof for the already resolved collector path. + return fsRealpath(filePath); +} + +export async function safeReadFile( + filePath: string, + encoding: BufferEncoding, +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Reads target package metadata selected by the collector root. + return fsReadFile(filePath, encoding); +} + +export const safeReaddir: ( + directory: string, + options: { withFileTypes: true }, +) => Promise = async (directory, options) => { + // eslint-disable-next-line sonarjs/prefer-immediate-return, security/detect-non-literal-fs-filename -- Async wrapper is required by the local promise rule; directory reads stay under checked collector log paths. + const entries = await fsReaddir(directory, options); + return entries; +}; + +async function ensureDirectChildDirectory( + filePath: string, + label: string, + dependencies: IncidentDependencies, +): Promise { + try { + await (dependencies.mkdir ?? safeMkdir)(filePath, { mode: 0o700 }); + } catch (error) { + if (!isErrorCode(error, "EEXIST")) { + throw error; + } + } + const stat = await (dependencies.lstat ?? safeLstat)(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked ${label}: ${filePath}`); + } + if (!stat.isDirectory()) { + throw new Error(`${label} is not a directory: ${filePath}`); + } +} + +async function writeBundleFile( + filePath: string, + text: string, + dependencies: IncidentDependencies, +): Promise { + await (dependencies.mkdir ?? safeMkdir)(dirname(filePath), { + recursive: true, + mode: 0o700, + }); + const handle = await (dependencies.open ?? safeOpen)( + filePath, + writeNewFileFlags, + 0o600, + ); + try { + if (handle.writeFile === undefined || handle.chmod === undefined) { + throw new Error(`Unable to write incident bundle file: ${filePath}`); + } + await handle.writeFile(text, "utf8"); + await handle.chmod(0o600); + } finally { + await ignoreError(handle.close()); + } +} + +function sourceStream(handle: IncidentFileHandle): SourceStream { + // eslint-disable-next-line no-restricted-syntax -- FileHandle readableWebStream is async-iterable in Node, but the lib type is narrower here. + return handle.readableWebStream() as SourceStream; +} + +async function* streamTextChunks( + stream: SourceStream, + decoder: InstanceType, +): AsyncGenerator { + for await (const chunk of stream) { + yield decoder.decode(chunk, { stream: true }); + } +} + +async function safeOpen( + filePath: string, + flags: number, + mode?: number, +): Promise { + // eslint-disable-next-line sonarjs/prefer-immediate-return, security/detect-non-literal-fs-filename -- Async wrapper is required by the local promise rule; opens checked collector files with no-follow flags and post-open file validation. + const handle = await fsOpen(filePath, flags, mode); + return handle; +} + +async function safeMkdir( + directory: string, + options?: { mode?: number; recursive?: boolean }, +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Callers build paths from resolved collector roots and checked path parts. + await fsMkdir(directory, options); +} diff --git a/scripts/bot/incident/io/version.ts b/scripts/bot/incident/io/version.ts new file mode 100644 index 0000000..fb71382 --- /dev/null +++ b/scripts/bot/incident/io/version.ts @@ -0,0 +1,70 @@ +import { spawnSync } from "node:child_process"; +import pathModule from "node:path"; + +import { scriptVersion } from "../model/constants.ts"; +import { isRecord } from "../model/text.ts"; +import type { IncidentDependencies, VersionMetadata } from "../model/types.ts"; +import { safeReadFile } from "./filesystem.ts"; + +const { join } = pathModule; + +export async function buildVersionMetadata( + root: string, + dependencies: IncidentDependencies, +): Promise { + const [rootPackage, botPackage] = await Promise.all([ + readPackage(join(root, "package.json")), + readPackage(join(root, "apps/bot/package.json")), + ]); + return { + script: { + name: "collect-incident.ts", + version: scriptVersion, + }, + nodeVersion: process.version, + package: { + packageManager: rootPackage?.["packageManager"] ?? null, + private: rootPackage?.["private"] === true, + }, + botPackage: + botPackage === null + ? null + : { + name: typeof botPackage["name"] === "string" ? botPackage["name"] : null, + version: + typeof botPackage["version"] === "string" ? botPackage["version"] : null, + }, + gitCommit: readGitCommit(root, dependencies), + }; +} + +async function readPackage(path: string): Promise | null> { + try { + const text = await safeReadFile(path, "utf8"); + const parsed: unknown = JSON.parse(text); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function readGitCommit(root: string, dependencies: IncidentDependencies): string | null { + try { + const spawn = dependencies.spawnSync ?? spawnSync; + const result = spawn("git", ["-C", root, "rev-parse", "HEAD"], { + encoding: "utf8", + timeout: 5_000, + }); + if (result.error !== undefined || result.status !== 0) { + return null; + } + const stdout = + typeof result.stdout === "string" + ? result.stdout + : Buffer.from(result.stdout).toString("utf8"); + const commit = stdout.trim(); + return commit === "" ? null : commit; + } catch { + return null; + } +} diff --git a/scripts/bot/incident/model/constants.ts b/scripts/bot/incident/model/constants.ts new file mode 100644 index 0000000..6df3899 --- /dev/null +++ b/scripts/bot/incident/model/constants.ts @@ -0,0 +1,12 @@ +import type { SourceFile } from "./types.ts"; + +export const botEventsSourceName = "bot.events.ndjson"; +export const botStderrSourceName = "bot.stderr.log"; +export const launchesSourceName = "launches.ndjson"; +export const logDirectoryLabel = "log directory"; +export const scriptVersion = 1; +export const stderrUndatedLineLimit = 200; + +export const baseSourceFiles: readonly SourceFile[] = [ + { kind: "launches", name: launchesSourceName, output: launchesSourceName }, +]; diff --git a/scripts/bot/incident/model/paths.ts b/scripts/bot/incident/model/paths.ts new file mode 100644 index 0000000..b0638c4 --- /dev/null +++ b/scripts/bot/incident/model/paths.ts @@ -0,0 +1,77 @@ +import pathModule from "node:path"; + +import { logDirectoryLabel } from "./constants.ts"; +import type { CollectorRunArgs, IncidentPaths } from "./types.ts"; + +const { isAbsolute, join, relative, resolve, sep } = pathModule; + +export function resolveIncidentPaths({ + parsed, + envLogRoot, + root, +}: { + envLogRoot?: string; + parsed: CollectorRunArgs; + root: string; +}): IncidentPaths { + const logRootSource = logRootSourceLabel(parsed.logRoot, envLogRoot); + const logRoot = resolveConfiguredPath( + parsed.logRoot ?? envLogRoot ?? "log", + root, + "log root", + ); + const logDir = + parsed.logDir === undefined + ? join(logRoot, "bot") + : resolveConfiguredPath(parsed.logDir, root, logDirectoryLabel); + assertContained(logRoot, logDir, logDirectoryLabel); + return { + logDir, + logRoot, + logRootSource, + }; +} + +export function relationshipEscapesRoot(relationship: string): boolean { + return ( + relationship === "" || + relationship === ".." || + relationship.startsWith(`..${sep}`) || + relationship.includes("\0") || + isAbsolute(relationship) + ); +} + +export function shellQuote(value: string): string { + const escaped = value.replaceAll("'", String.raw`'\''`); + return `'${escaped}'`; +} + +function logRootSourceLabel( + cliLogRoot: string | undefined, + envLogRoot: string | undefined, +): string { + if (cliLogRoot !== undefined) { + return "cli"; + } + return envLogRoot === undefined ? "default:log" : "env:ICKB_BOT_LOG_ROOT"; +} + +function resolveConfiguredPath(value: string, root: string, label: string): string { + if (value === "") { + throw new Error(`Empty ${label} path`); + } + return isAbsolute(value) ? resolve(value) : resolve(root, value); +} + +function assertContained(root: string, candidate: string, label: string): void { + const relationship = relative(root, candidate); + const escapesRoot = + relationship === ".." || + relationship.startsWith(`..${sep}`) || + isAbsolute(relationship); + + if (escapesRoot) { + throw new Error(`${label} must stay inside the resolved log root`); + } +} diff --git a/scripts/bot/incident/model/text.ts b/scripts/bot/incident/model/text.ts new file mode 100644 index 0000000..1d57acb --- /dev/null +++ b/scripts/bot/incident/model/text.ts @@ -0,0 +1,48 @@ +export function stripLineEnding(line: string): string { + const withoutNewline = line.endsWith("\n") ? line.slice(0, -1) : line; + return withoutNewline.endsWith("\r") ? withoutNewline.slice(0, -1) : withoutNewline; +} + +export function scanAsciiDigits(line: string, start: number, limit: number): number { + let end = start; + while (end < line.length && end < start + limit && isAsciiDigit(line[end])) { + end += 1; + } + return end; +} + +export function isAsciiDigits(value: string): boolean { + if (value === "") { + return false; + } + for (const character of value) { + if (!isAsciiDigit(character)) { + return false; + } + } + return true; +} + +export function isAsciiDigit(value: string | undefined): boolean { + return value !== undefined && value >= "0" && value <= "9"; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +export function publicErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown incident collector error"; +} + +export async function ignoreError(promise: Promise | undefined): Promise { + try { + await promise; + } catch { + // Best-effort cleanup must preserve the original collector error. + } +} diff --git a/scripts/bot/incident/model/types.ts b/scripts/bot/incident/model/types.ts new file mode 100644 index 0000000..4fc8812 --- /dev/null +++ b/scripts/bot/incident/model/types.ts @@ -0,0 +1,244 @@ +import type { FileHandle } from "node:fs/promises"; + +export interface CollectorRunArgs { + help?: false; + logDir?: string; + logRoot?: string; + since: string; + until: string; +} + +export type ParsedCollectorArgs = CollectorRunArgs | { help: true }; + +export interface CollectIncidentOptions { + argv?: string[]; + dependencies?: IncidentDependencies; + envLogRoot?: string; + now?: () => Date; + root?: string; +} + +export type CollectIncidentResult = + | { help: string; incidentDir?: undefined; incidentId?: undefined; summary?: undefined } + | { + help?: undefined; + incidentDir: string; + incidentId: string; + summary: CompleteIncidentSummary; + }; + +export interface IncidentPaths { + logDir: string; + logRoot: string; + logRootSource: string; +} + +export type SourceKind = "botEvents" | "launches" | "stderr"; + +export interface SourceFile { + kind: SourceKind; + name: string; + output: string; +} + +export interface SourceWindow { + since: Date; + until: Date; +} + +export interface SourceStats { + emptyLines: number; + firstTimestamp: string | null; + lastTimestamp: string | null; + malformedLines: number; + outsideWindowLines: number; + selectedLines: number; + selectedUndatedLines: number; + timestampedLines: number; + totalLines: number; + undatedLines: number; + undatedTailIncluded: boolean; + undatedTailLimit: number | null; +} + +export type SourceSummary = + | { included: false; path: string; reason: "missing" } + | ({ included: true; output: string; path: string } & SourceStats); + +export interface FilteredSource { + stats: SourceStats; + text: string; +} + +export type JsonFilteredSource = FilteredSource & { summary: IncidentSummary }; + +export interface JsonSourceFilterOptions { + dependencies: IncidentDependencies; + filePath: string; + kind: Exclude; + sourceName: string; + summary: IncidentSummary; + window: SourceWindow; +} + +export interface StderrSourceFilterOptions { + dependencies: IncidentDependencies; + filePath: string; + sourceName: string; + window: SourceWindow; +} + +export interface ReferencedArtifactOptions { + artifact: ArtifactRef; + dependencies: IncidentDependencies; + outputs: Map; + paths: IncidentPaths; + summary: IncidentSummary; +} + +export interface SourceCollectionOptions { + dependencies: IncidentDependencies; + outputs: Map; + paths: IncidentPaths; + summary: IncidentSummary; + window: SourceWindow; +} + +export interface SourceCollectionResult { + outputs: Map; + summary: IncidentSummary; +} + +export interface SourceFileSummary { + name: string; + output: string; + path: string; + selectedLines: number; +} + +export type CountMap = Record; +export type GroupedTextMap = Record; + +export interface TimestampSummary { + firstTimestamp: string | null; + lastTimestamp: string | null; +} + +export type BotEventsSummary = TimestampSummary & { + countsByType: CountMap; + failureReasons: CountMap; + skipReasons: CountMap; + txHashesByOutcome: GroupedTextMap; +}; + +export type LaunchesSummary = TimestampSummary & { + countsByType: CountMap; + exitCodes: CountMap; + signals: CountMap; +}; + +export interface ArtifactRef { + hash: string; + kind: string; + path: string; +} + +export type ArtifactMismatch = ArtifactRef & { + actualHash: string; +}; + +export interface CompressionSummary { + command: string; + created: false; + reason: string; +} + +export interface IncidentSummary { + artifacts: { + included: ArtifactRef[]; + mismatched: ArtifactMismatch[]; + missing: ArtifactRef[]; + }; + botEvents: BotEventsSummary; + compression?: CompressionSummary; + createdAt: string; + incidentDir?: string; + incidentId?: string; + launches: LaunchesSummary; + logDir: string; + logRoot: string; + logRootSource: string; + scriptVersion: number; + sourceFiles: SourceFileSummary[]; + sources: Record; + stderr: TimestampSummary; + version: number; + window: { + since: string; + until: string; + }; +} + +export type CompleteIncidentSummary = IncidentSummary & { + compression: CompressionSummary; + incidentDir: string; + incidentId: string; +}; + +export interface StatLike { + isDirectory: () => boolean; + isFile: () => boolean; + isSymbolicLink: () => boolean; +} + +export interface DirentLike { + isFile: () => boolean; + name: string; +} + +export type IncidentFileHandle = Pick & { + chmod?: FileHandle["chmod"]; + readFile?: FileHandle["readFile"]; + stat: () => Promise; + writeFile?: FileHandle["writeFile"]; +}; + +export type DecoderInput = Exclude< + Parameters["decode"]>[0], + undefined +>; +export type SourceStream = AsyncIterable; + +export interface IncidentDependencies { + lstat?: (path: string) => Promise; + mkdir?: ( + path: string, + options?: { mode?: number; recursive?: boolean }, + ) => Promise; + open?: (path: string, flags: number, mode?: number) => Promise; + readdir?: (path: string, options: { withFileTypes: true }) => Promise; + realpath?: (path: string) => Promise; + spawnSync?: ( + command: string, + args: string[], + options: { encoding: "utf8"; timeout: number }, + ) => { error?: Error; status: number | null; stdout: string }; +} + +export interface VersionMetadata { + botPackage: null | { name: string | null; version: string | null }; + gitCommit: string | null; + nodeVersion: string; + package: { + packageManager: unknown; + private: boolean; + }; + script: { + name: string; + version: number; + }; +} + +export interface WritableLike { + write: (chunk: string) => unknown; +} diff --git a/scripts/bot/incident/source/artifacts.ts b/scripts/bot/incident/source/artifacts.ts new file mode 100644 index 0000000..2336c5c --- /dev/null +++ b/scripts/bot/incident/source/artifacts.ts @@ -0,0 +1,261 @@ +import { createHash } from "node:crypto"; +import pathModule from "node:path"; + +import { + assertNoSymlinkedPathComponents, + openSourceHandle, + readHandleText, + safeLstat, + safeRealpath, +} from "../io/filesystem.ts"; +import { relationshipEscapesRoot } from "../model/paths.ts"; +import { ignoreError, isAsciiDigit, isErrorCode, isRecord } from "../model/text.ts"; +import type { + ArtifactRef, + IncidentDependencies, + IncidentPaths, + IncidentSummary, + ReferencedArtifactOptions, + StatLike, +} from "../model/types.ts"; + +const { join, relative } = pathModule; + +export async function addReferencedArtifacts( + paths: IncidentPaths, + summary: IncidentSummary, + outputs: Map, + dependencies: IncidentDependencies, +): Promise { + let artifactSummary = summary; + for (const artifact of summary.artifacts.included) { + artifactSummary = await addReferencedArtifact({ + artifact, + dependencies, + outputs, + paths, + summary: artifactSummary, + }); + } + return artifactSummary; +} + +export function collectArtifactRefs( + record: unknown, + summary: IncidentSummary, +): IncidentSummary { + return { + ...summary, + artifacts: { + ...summary.artifacts, + included: appendUniqueArtifactRefs( + summary.artifacts.included, + artifactRefsFrom(record), + ), + }, + }; +} + +async function addReferencedArtifact({ + artifact, + dependencies, + outputs, + paths, + summary, +}: ReferencedArtifactOptions): Promise { + const sourcePath = join(paths.logDir, artifact.path); + const found = await proveArtifactSourcePath( + paths.logDir, + sourcePath, + artifact.path, + dependencies, + ); + if (!found) { + return appendMissingArtifact(summary, artifact); + } + + const handle = await openSourceHandle(sourcePath, artifact.path, dependencies); + if (handle === null) { + return appendMissingArtifact(summary, artifact); + } + + try { + const text = await readHandleText(handle, artifact.path); + const actualHash = sha256TextRef(text); + if (actualHash !== artifact.hash) { + return appendMismatchedArtifact(summary, artifact, actualHash); + } + outputs.set(artifact.path, text.endsWith("\n") ? text : `${text}\n`); + return summary; + } finally { + await ignoreError(handle.close()); + } +} + +async function proveArtifactSourcePath( + logDir: string, + sourcePath: string, + label: string, + dependencies: IncidentDependencies, +): Promise { + await assertNoSymlinkedPathComponents(sourcePath, `artifact ${label}`, dependencies); + const stat = await optionalLstat(sourcePath, dependencies); + if (stat === null) { + return false; + } + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked artifact path: ${sourcePath}`); + } + if (!stat.isFile()) { + throw new Error(`Artifact path is not a regular file: ${sourcePath}`); + } + const [realLogDir, realSourcePath] = await realArtifactPaths( + logDir, + sourcePath, + dependencies, + ); + const relationship = relative(realLogDir, realSourcePath); + if (relationshipEscapesRoot(relationship)) { + throw new Error(`Artifact path escapes the log directory: ${label}`); + } + return true; +} + +async function optionalLstat( + path: string, + dependencies: IncidentDependencies, +): Promise { + try { + return await (dependencies.lstat ?? safeLstat)(path); + } catch (error) { + if (isErrorCode(error, "ENOENT")) { + return null; + } + throw error; + } +} + +async function realArtifactPaths( + logDir: string, + sourcePath: string, + dependencies: IncidentDependencies, +): Promise<[string, string]> { + const realpath = dependencies.realpath ?? safeRealpath; + return Promise.all([realpath(logDir), realpath(sourcePath)]); +} + +function appendMissingArtifact( + summary: IncidentSummary, + artifact: ArtifactRef, +): IncidentSummary { + return { + ...summary, + artifacts: { + ...summary.artifacts, + missing: [...summary.artifacts.missing, artifact], + }, + }; +} + +function appendMismatchedArtifact( + summary: IncidentSummary, + artifact: ArtifactRef, + actualHash: string, +): IncidentSummary { + return { + ...summary, + artifacts: { + ...summary.artifacts, + mismatched: [...summary.artifacts.mismatched, { ...artifact, actualHash }], + }, + }; +} + +function sha256TextRef(text: string): string { + return `sha256:${createHash("sha256").update(text).digest("hex")}`; +} + +function appendUniqueArtifactRefs( + existing: readonly ArtifactRef[], + refs: readonly ArtifactRef[], +): ArtifactRef[] { + let included = [...existing]; + for (const ref of refs) { + if (included.some((candidate) => sameArtifactRef(candidate, ref))) { + continue; + } + included = [...included, ref]; + } + return included; +} + +function sameArtifactRef(left: ArtifactRef, right: ArtifactRef): boolean { + return left.kind === right.kind && left.hash === right.hash && left.path === right.path; +} + +function artifactRefsFrom(value: unknown): ArtifactRef[] { + const refs: ArtifactRef[] = []; + const visit = (candidate: unknown): void => { + if (Array.isArray(candidate)) { + for (const item of candidate) { + visit(item); + } + return; + } + if (!isRecord(candidate)) { + return; + } + if (isArtifactRef(candidate)) { + refs.push({ + hash: candidate.hash, + kind: candidate.kind, + path: candidate.path, + }); + } + for (const entry of Object.values(candidate)) { + visit(entry); + } + }; + visit(value); + return refs; +} + +function isArtifactRef( + value: Record, +): value is Record & ArtifactRef { + return ( + typeof value["kind"] === "string" && + value["kind"].startsWith("bot.") && + typeof value["hash"] === "string" && + isSha256Ref(value["hash"]) && + typeof value["path"] === "string" && + isContainedArtifactPath(value["path"]) + ); +} + +function isSha256Ref(value: string): boolean { + const prefix = "sha256:"; + const hex = value.slice(prefix.length); + return value.startsWith(prefix) && hex.length === 64 && isLowerHex(hex); +} + +function isLowerHex(value: string): boolean { + for (const character of value) { + if (!isLowerHexDigit(character)) { + return false; + } + } + return true; +} + +function isLowerHexDigit(value: string): boolean { + return isAsciiDigit(value) || (value >= "a" && value <= "f"); +} + +function isContainedArtifactPath(path: string): boolean { + if (!path.startsWith("artifacts/") || path.includes("\0")) { + return false; + } + const parts = path.split("/"); + return parts.every((part) => part !== "" && part !== "." && part !== ".."); +} diff --git a/scripts/bot/incident/source/collection.ts b/scripts/bot/incident/source/collection.ts new file mode 100644 index 0000000..1db66fa --- /dev/null +++ b/scripts/bot/incident/source/collection.ts @@ -0,0 +1,59 @@ +import pathModule from "node:path"; + +import type { SourceCollectionOptions, SourceCollectionResult } from "../model/types.ts"; +import { discoverSourceFiles, sourcePath } from "./discovery.ts"; +import { filterJsonSource, filterStderrSource } from "./filter.ts"; +import { appendMissingSource, appendSourceResult } from "./summary.ts"; + +const { join } = pathModule; + +export async function collectSources({ + dependencies, + outputs, + paths, + summary, + window, +}: SourceCollectionOptions): Promise { + let collectedSummary = summary; + for (const source of await discoverSourceFiles(paths.logDir, dependencies)) { + const filePath = sourcePath(paths.logDir, source); + if (source.kind === "stderr") { + const result = await filterStderrSource({ + dependencies, + filePath, + sourceName: source.name, + window, + }); + if (result === null) { + collectedSummary = appendMissingSource(collectedSummary, source.output, filePath); + continue; + } + + collectedSummary = appendSourceResult(collectedSummary, source, filePath, result); + outputs.set(source.output, result.text); + continue; + } + + const result = await filterJsonSource({ + dependencies, + filePath, + kind: source.kind, + sourceName: source.name, + summary: collectedSummary, + window, + }); + if (result === null) { + collectedSummary = appendMissingSource(collectedSummary, source.output, filePath); + continue; + } + + collectedSummary = appendSourceResult(result.summary, source, filePath, result); + outputs.set(source.output, result.text); + } + + return { outputs, summary: collectedSummary }; +} + +export function incidentParent(logDir: string): string { + return join(logDir, "incidents"); +} diff --git a/scripts/bot/incident/source/discovery.ts b/scripts/bot/incident/source/discovery.ts new file mode 100644 index 0000000..47a9854 --- /dev/null +++ b/scripts/bot/incident/source/discovery.ts @@ -0,0 +1,93 @@ +import pathModule from "node:path"; + +import { safeReaddir } from "../io/filesystem.ts"; +import { + baseSourceFiles, + botEventsSourceName, + botStderrSourceName, +} from "../model/constants.ts"; +import { isAsciiDigits, isErrorCode } from "../model/text.ts"; +import type { IncidentDependencies, SourceFile } from "../model/types.ts"; + +const { join } = pathModule; + +export async function discoverSourceFiles( + logDir: string, + dependencies: IncidentDependencies, +): Promise { + const names = await sourceFileNames(logDir, dependencies); + const eventNames = names.filter(isBotEventsSourceName).toSorted(nameCompare); + const stderrNames = names.filter(isBotStderrSourceName).toSorted(nameCompare); + return [ + ...(eventNames.length === 0 + ? [ + { + kind: "botEvents", + name: botEventsSourceName, + output: botEventsSourceName, + } satisfies SourceFile, + ] + : eventNames.map((name) => ({ + kind: "botEvents", + name, + output: name, + }))), + ...(stderrNames.length === 0 + ? [ + { + kind: "stderr", + name: botStderrSourceName, + output: botStderrSourceName, + } satisfies SourceFile, + ] + : stderrNames.map((name) => ({ kind: "stderr", name, output: name }))), + ...baseSourceFiles, + ]; +} + +async function sourceFileNames( + logDir: string, + dependencies: IncidentDependencies, +): Promise { + try { + const entries = await (dependencies.readdir ?? safeReaddir)(logDir, { + withFileTypes: true, + }); + return entries.filter((entry) => entry.isFile()).map((entry) => entry.name); + } catch (error) { + if (isErrorCode(error, "ENOENT")) { + return []; + } + throw error; + } +} + +function isBotEventsSourceName(name: string): boolean { + return ( + name === botEventsSourceName || isSlottedSourceName(name, "bot.events.", ".ndjson") + ); +} + +function isBotStderrSourceName(name: string): boolean { + return name === botStderrSourceName || isSlottedSourceName(name, "bot.stderr.", ".log"); +} + +function isSlottedSourceName(name: string, prefix: string, suffix: string): boolean { + if (!name.startsWith(prefix) || !name.endsWith(suffix)) { + return false; + } + const slot = name.slice(prefix.length, -suffix.length); + return ( + slot.length === "slot-00".length && + slot.startsWith("slot-") && + isAsciiDigits(slot.slice("slot-".length)) + ); +} + +function nameCompare(left: string, right: string): number { + return left.localeCompare(right); +} + +export function sourcePath(logDir: string, source: SourceFile): string { + return join(logDir, source.name); +} diff --git a/scripts/bot/incident/source/filter.ts b/scripts/bot/incident/source/filter.ts new file mode 100644 index 0000000..a78fc0e --- /dev/null +++ b/scripts/bot/incident/source/filter.ts @@ -0,0 +1,228 @@ +import { processSourceLines } from "../io/filesystem.ts"; +import { stderrUndatedLineLimit } from "../model/constants.ts"; +import { + isAsciiDigits, + isRecord, + scanAsciiDigits, + stripLineEnding, +} from "../model/text.ts"; +import type { + FilteredSource, + IncidentSummary, + JsonFilteredSource, + JsonSourceFilterOptions, + SourceKind, + StderrSourceFilterOptions, +} from "../model/types.ts"; +import { collectArtifactRefs } from "./artifacts.ts"; +import { + appendSelectedStderrLine, + appendSelectedUndatedStderrLine, + emptySourceStats, + rememberUndatedTail, + summarizeBotEvent, + summarizeLaunch, + timestampInWindow, + updateSourceStatsTimestamps, +} from "./summary.ts"; + +export async function filterJsonSource({ + dependencies, + filePath, + kind, + sourceName, + summary, + window, +}: JsonSourceFilterOptions): Promise { + const selected: string[] = []; + const stats = emptySourceStats(); + let updatedSummary = summary; + + const found = await processSourceLines(filePath, sourceName, dependencies, (line) => { + if (line.trim() === "") { + stats.emptyLines += 1; + return; + } + stats.totalLines += 1; + + let record: unknown; + try { + record = JSON.parse(stripLineEnding(line)); + } catch { + stats.malformedLines += 1; + return; + } + + const timestamp = parseRecordTimestamp( + isRecord(record) ? record["timestamp"] : undefined, + ); + if (timestamp === null) { + stats.undatedLines += 1; + return; + } + if (!timestampInWindow(timestamp, window)) { + stats.outsideWindowLines += 1; + return; + } + + selected.push(line.endsWith("\n") ? line : `${line}\n`); + stats.selectedLines += 1; + Object.assign(stats, updateSourceStatsTimestamps(stats, timestamp)); + updatedSummary = summarizeJsonRecord(record, updatedSummary, timestamp, kind); + }); + if (!found) { + return null; + } + + return { stats, summary: updatedSummary, text: selected.join("") }; +} + +export async function filterStderrSource({ + dependencies, + filePath, + sourceName, + window, +}: StderrSourceFilterOptions): Promise { + const selected: string[] = []; + const stats = emptySourceStats(); + const undatedTail: Array<{ line: string }> = []; + let selectedSinceLastTimestamp = false; + + const found = await processSourceLines(filePath, sourceName, dependencies, (line) => { + if (line.trim() === "") { + stats.emptyLines += 1; + return; + } + stats.totalLines += 1; + const timestamp = parseTextTimestamp(line); + if (timestamp === null) { + stats.undatedLines += 1; + rememberUndatedTail(undatedTail, { line }, stderrUndatedLineLimit); + Object.assign( + stats, + selectedSinceLastTimestamp + ? appendSelectedUndatedStderrLine(selected, stats, line) + : stats, + ); + return; + } + stats.timestampedLines += 1; + selectedSinceLastTimestamp = false; + if (!timestampInWindow(timestamp, window)) { + stats.outsideWindowLines += 1; + return; + } + + Object.assign(stats, appendSelectedStderrLine(selected, stats, line)); + selectedSinceLastTimestamp = true; + Object.assign(stats, updateSourceStatsTimestamps(stats, timestamp)); + }); + if (!found) { + return null; + } + + if (selected.length === 0 && stats.undatedLines > 0 && stats.timestampedLines === 0) { + for (const { line } of undatedTail) { + Object.assign(stats, appendSelectedStderrLine(selected, stats, line)); + } + stats.selectedUndatedLines = undatedTail.length; + stats.undatedTailIncluded = true; + stats.undatedTailLimit = stderrUndatedLineLimit; + } + + return { stats, text: selected.join("") }; +} + +function summarizeJsonRecord( + record: unknown, + summary: IncidentSummary, + timestamp: Date, + kind: Exclude, +): IncidentSummary { + return kind === "botEvents" + ? collectArtifactRefs(record, summarizeBotEvent(record, summary, timestamp)) + : summarizeLaunch(record, summary, timestamp); +} + +function parseRecordTimestamp(timestamp: unknown): Date | null { + if (typeof timestamp !== "string") { + return null; + } + const parsed = new Date(timestamp); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function parseTextTimestamp(line: string): Date | null { + for (let index = 0; index < line.length; index += 1) { + const timestamp = timestampTextAt(line, index); + if (timestamp === null) { + continue; + } + const parsed = new Date(timestamp); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + return null; +} + +function timestampTextAt(line: string, start: number): string | null { + if (!hasIsoTimestampBase(line, start)) { + return null; + } + const fractionEnd = timestampFractionEnd(line, start + 19); + if (fractionEnd === null) { + return null; + } + const zoneLength = timestampZoneLength(line, fractionEnd); + return zoneLength === 0 ? null : line.slice(start, fractionEnd + zoneLength); +} + +function hasIsoTimestampBase(line: string, start: number): boolean { + if (start + 19 > line.length) { + return false; + } + const delimiters: ReadonlyArray = [ + [4, "-"], + [7, "-"], + [10, "T"], + [13, ":"], + [16, ":"], + ]; + const digitRanges: ReadonlyArray = [ + [0, 4], + [5, 7], + [8, 10], + [11, 13], + [14, 16], + [17, 19], + ]; + return ( + delimiters.every(([offset, delimiter]) => line[start + offset] === delimiter) && + digitRanges.every(([from, until]) => + isAsciiDigits(line.slice(start + from, start + until)), + ) + ); +} + +function timestampFractionEnd(line: string, start: number): number | null { + if (line[start] !== ".") { + return start; + } + const end = scanAsciiDigits(line, start + 1, 9); + return end === start + 1 ? null : end; +} + +function timestampZoneLength(line: string, start: number): number { + if (line[start] === "Z") { + return 1; + } + if (line[start] !== "+" && line[start] !== "-") { + return 0; + } + return isAsciiDigits(line.slice(start + 1, start + 3)) && + line[start + 3] === ":" && + isAsciiDigits(line.slice(start + 4, start + 6)) + ? 6 + : 0; +} diff --git a/scripts/bot/incident/source/summary.ts b/scripts/bot/incident/source/summary.ts new file mode 100644 index 0000000..c053d73 --- /dev/null +++ b/scripts/bot/incident/source/summary.ts @@ -0,0 +1,397 @@ +import { randomBytes } from "node:crypto"; +import path from "node:path"; + +import { scriptVersion } from "../model/constants.ts"; +import { shellQuote } from "../model/paths.ts"; +import { isRecord } from "../model/text.ts"; +import type { + BotEventsSummary, + CompleteIncidentSummary, + CountMap, + FilteredSource, + GroupedTextMap, + IncidentSummary, + LaunchesSummary, + SourceFile, + SourceStats, + SourceWindow, + TimestampSummary, +} from "../model/types.ts"; + +export function createSummary({ + createdAt, + logDir, + logRoot, + logRootSource, + since, + until, +}: { + createdAt: Date; + logDir: string; + logRoot: string; + logRootSource: string; + since: Date; + until: Date; +}): IncidentSummary { + return { + version: 1, + scriptVersion, + createdAt: createdAt.toISOString(), + window: { + since: since.toISOString(), + until: until.toISOString(), + }, + logRoot, + logRootSource, + logDir, + sourceFiles: [], + sources: {}, + artifacts: { + included: [], + missing: [], + mismatched: [], + }, + botEvents: { + countsByType: {}, + failureReasons: {}, + firstTimestamp: null, + lastTimestamp: null, + skipReasons: {}, + txHashesByOutcome: {}, + }, + launches: { + countsByType: {}, + exitCodes: {}, + firstTimestamp: null, + lastTimestamp: null, + signals: {}, + }, + stderr: { + firstTimestamp: null, + lastTimestamp: null, + }, + }; +} + +export function buildCompleteSummary({ + createdAt, + incidentParent, + summary, +}: { + createdAt: Date; + incidentParent: string; + summary: IncidentSummary; +}): CompleteIncidentSummary { + const incidentId = buildIncidentId(createdAt); + const incidentDir = path.join(incidentParent, incidentId); + return { + ...summary, + incidentDir, + incidentId, + compression: { + created: false, + command: compressionCommand(incidentParent, incidentId), + reason: "The collector avoids assuming tar/gzip/zstd binaries are present.", + }, + }; +} + +export function incidentReadme(summary: CompleteIncidentSummary): string { + return [ + `Incident: ${summary.incidentId}`, + `Window: ${summary.window.since} to ${summary.window.until}`, + `Log directory: ${summary.logDir}`, + "", + "Files are source-separated: bot event logs, bot stderr logs, and launches.ndjson are never merged.", + "summary.json contains event counts, transaction outcomes, skip/failure reasons, exit codes, and source inclusion stats.", + "version.json contains collector, package, Node, and git metadata. Config files and environment dumps are intentionally not included.", + "", + `Compression command: ${summary.compression.command}`, + "", + ].join("\n"); +} + +export function appendMissingSource( + summary: IncidentSummary, + output: string, + sourcePath: string, +): IncidentSummary { + return { + ...summary, + sources: { + ...summary.sources, + [output]: { + included: false, + path: sourcePath, + reason: "missing", + }, + }, + }; +} + +export function appendSourceResult( + summary: IncidentSummary, + source: SourceFile, + sourcePath: string, + result: FilteredSource, +): IncidentSummary { + return { + ...summary, + sourceFiles: [ + ...summary.sourceFiles, + { + name: source.name, + output: source.output, + path: sourcePath, + selectedLines: result.stats.selectedLines, + }, + ], + sources: { + ...summary.sources, + [source.output]: { + included: true, + output: source.output, + path: sourcePath, + ...result.stats, + }, + }, + stderr: + source.kind === "stderr" + ? { + firstTimestamp: result.stats.firstTimestamp, + lastTimestamp: result.stats.lastTimestamp, + } + : summary.stderr, + }; +} + +export function emptySourceStats(): SourceStats { + return { + emptyLines: 0, + firstTimestamp: null, + lastTimestamp: null, + malformedLines: 0, + outsideWindowLines: 0, + selectedLines: 0, + selectedUndatedLines: 0, + timestampedLines: 0, + totalLines: 0, + undatedTailIncluded: false, + undatedTailLimit: null, + undatedLines: 0, + }; +} + +export function rememberUndatedTail( + tail: Array<{ line: string }>, + entry: { line: string }, + limit: number, +): void { + tail.push(entry); + if (tail.length > limit) { + tail.shift(); + } +} + +export function appendSelectedStderrLine( + selected: string[], + stats: SourceStats, + line: string, +): SourceStats { + selected.push(line.endsWith("\n") ? line : `${line}\n`); + return { ...stats, selectedLines: stats.selectedLines + 1 }; +} + +export function appendSelectedUndatedStderrLine( + selected: string[], + stats: SourceStats, + line: string, +): SourceStats { + return { + ...appendSelectedStderrLine(selected, stats, line), + selectedUndatedLines: stats.selectedUndatedLines + 1, + }; +} + +export function timestampInWindow( + timestamp: Date, + { since, until }: SourceWindow, +): boolean { + const value = timestamp.getTime(); + return since.getTime() <= value && value <= until.getTime(); +} + +export function updateSourceStatsTimestamps( + stats: SourceStats, + timestamp: Date, +): SourceStats { + return { ...stats, ...updatedTimestampSummary(stats, timestamp) }; +} + +export function summarizeBotEvent( + record: unknown, + summary: IncidentSummary, + timestamp: Date, +): IncidentSummary { + if (!isRecord(record)) { + return summary; + } + if ( + record["app"] !== "bot" || + typeof record["type"] !== "string" || + !record["type"].startsWith("bot.") + ) { + return summary; + } + + let botEvents: BotEventsSummary = { + ...summary.botEvents, + ...updatedTimestampSummary(summary.botEvents, timestamp), + countsByType: increment(summary.botEvents.countsByType, record["type"]), + }; + if (typeof record["outcome"] === "string" && typeof record["txHash"] === "string") { + botEvents = { + ...botEvents, + txHashesByOutcome: addGroupedUnique( + botEvents.txHashesByOutcome, + record["outcome"], + record["txHash"], + ), + }; + } + if (record["type"] === "bot.decision.skipped") { + botEvents = { + ...botEvents, + skipReasons: increment(botEvents.skipReasons, stringReason(record["reason"])), + }; + } + if ( + record["type"] === "bot.transaction.failed" || + record["type"] === "bot.iteration.failed" + ) { + botEvents = { + ...botEvents, + failureReasons: increment(botEvents.failureReasons, failureReason(record)), + }; + } + return { ...summary, botEvents }; +} + +export function summarizeLaunch( + record: unknown, + summary: IncidentSummary, + timestamp: Date, +): IncidentSummary { + if (!isRecord(record)) { + return summary; + } + if (record["app"] !== "bot-launcher" || typeof record["type"] !== "string") { + return summary; + } + + let launches: LaunchesSummary = { + ...summary.launches, + ...updatedTimestampSummary(summary.launches, timestamp), + countsByType: increment(summary.launches.countsByType, record["type"]), + }; + const status = summaryValueKey(record["status"]); + if (status !== null) { + launches = { ...launches, exitCodes: increment(launches.exitCodes, status) }; + } + const signal = summaryValueKey(record["signal"]); + if (signal !== null) { + launches = { ...launches, signals: increment(launches.signals, signal) }; + } + return { ...summary, launches }; +} + +function compressionCommand(incidentParent: string, incidentId: string): string { + const archivePath = path.join(incidentParent, `${incidentId}.tar.gz`); + return `tar -czf ${shellQuote(archivePath)} -C ${shellQuote(incidentParent)} ${shellQuote(incidentId)}`; +} + +function buildIncidentId(createdAt: Date): string { + const stamp = createdAt.toISOString().replaceAll(/[-:.]/gu, ""); + return `${stamp}-${process.pid.toString(36)}-${randomBytes(3).toString("hex")}`; +} + +function updatedTimestampSummary( + summary: TimestampSummary, + timestamp: Date, +): TimestampSummary { + const iso = timestamp.toISOString(); + return { + firstTimestamp: + summary.firstTimestamp === null || iso < summary.firstTimestamp + ? iso + : summary.firstTimestamp, + lastTimestamp: + summary.lastTimestamp === null || iso > summary.lastTimestamp + ? iso + : summary.lastTimestamp, + }; +} + +function summaryValueKey(value: unknown): string | null { + if (value === undefined || value === null) { + return null; + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + const json = JSON.stringify(value); + return typeof json === "string" ? json : null; +} + +function increment(counts: CountMap, key: string): CountMap { + return { ...counts, [key]: (counts[key] ?? 0) + 1 }; +} + +function addGroupedUnique( + groups: GroupedTextMap, + key: string, + value: string, +): GroupedTextMap { + const existing = groups[key] ?? []; + if (existing.includes(value)) { + return groups; + } + return { ...groups, [key]: [...existing, value] }; +} + +function stringReason(value: unknown): string { + return typeof value === "string" && value !== "" ? value : ""; +} + +function failureReason(record: Record): string { + if ( + record["type"] === "bot.iteration.failed" && + record["retryBudgetExhausted"] === true + ) { + return "retry_budget_exhausted"; + } + const error = record["error"]; + return ( + firstNonEmptyString([ + record["outcome"], + record["reason"], + error, + isRecord(error) ? error["message"] : undefined, + isRecord(error) ? error["name"] : undefined, + ]) ?? "" + ); +} + +function firstNonEmptyString(values: readonly unknown[]): string | null { + for (const value of values) { + if (typeof value === "string" && value !== "") { + return value; + } + } + return null; +} diff --git a/scripts/bot/launcher.ts b/scripts/bot/launcher.ts new file mode 100644 index 0000000..502e1de --- /dev/null +++ b/scripts/bot/launcher.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { pathToFileURL } from "node:url"; + +import { runBotLauncher } from "./launcher/index.ts"; + +const entrypoint = process.argv[1]; +if (entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href) { + const result = await runBotLauncher(); + if (result.signal !== undefined) { + process.kill(process.pid, result.signal); + } else { + process.exit(result.status); + } +} diff --git a/scripts/bot/launcher/args.ts b/scripts/bot/launcher/args.ts new file mode 100644 index 0000000..93ce314 --- /dev/null +++ b/scripts/bot/launcher/args.ts @@ -0,0 +1,98 @@ +import type { ParsedLauncherArgs } from "./runtime/types.ts"; + +export function usage(): string { + return `Usage: node --experimental-default-type=module scripts/bot/launcher.ts [--log-root PATH] [--log-dir PATH] [--log-storage-quota-bytes N] [--no-child-tee] [-- [args...]]\n`; +} + +export function parseArgs(argv: readonly string[]): ParsedLauncherArgs { + if (argv.includes("-h") || argv.includes("--help")) { + return { help: true }; + } + + const separator = argv.indexOf("--"); + const command = separator === -1 ? [] : argv.slice(separator + 1); + + if (separator !== -1 && (command.length === 0 || command[0] === "")) { + throw new Error("Missing child command after --"); + } + + const options = separator === -1 ? argv : argv.slice(0, separator); + const parsed = parseLauncherOptions(options); + + return { + ...parsed, + command: command[0], + commandArgs: command.slice(1), + }; +} + +export function parseOptionalPositiveSafeInteger( + value: string | undefined, + label: string, +): number | undefined { + return value === undefined ? undefined : parsePositiveSafeInteger(value, label); +} + +function parseLauncherOptions( + options: readonly string[], +): Omit, "command" | "commandArgs"> { + let logRoot: string | undefined; + let logDir: string | undefined; + let logStorageQuotaBytes: number | undefined; + let teeChildOutput = true; + + for (let index = 0; index < options.length; index += 1) { + const option = options[index]; + switch (option) { + case undefined: { + throw new Error("Unknown option: "); + } + case "--log-root": { + logRoot = requireValue(options, index, option); + index += 1; + break; + } + case "--log-dir": { + logDir = requireValue(options, index, option); + index += 1; + break; + } + case "--log-storage-quota-bytes": { + logStorageQuotaBytes = parsePositiveSafeInteger( + requireValue(options, index, option), + option, + ); + index += 1; + break; + } + case "--no-child-tee": { + teeChildOutput = false; + break; + } + default: { + throw new Error(`Unknown option: ${option}`); + } + } + } + + return { logDir, logRoot, logStorageQuotaBytes, teeChildOutput }; +} + +function requireValue(argv: readonly string[], index: number, option: string): string { + const value = argv[index + 1]; + if (value === undefined || value === "" || value.startsWith("--")) { + throw new Error(`Missing value for ${option}`); + } + return value; +} + +function parsePositiveSafeInteger(value: string, label: string): number { + if (!/^[1-9]\d*$/u.test(value)) { + throw new Error(`Invalid ${label}: expected a positive integer`); + } + const parsed = BigInt(value); + if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`Invalid ${label}: expected a safe integer`); + } + return Number(parsed); +} diff --git a/scripts/bot/launcher/index.ts b/scripts/bot/launcher/index.ts new file mode 100644 index 0000000..46347e2 --- /dev/null +++ b/scripts/bot/launcher/index.ts @@ -0,0 +1,55 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { parseArgs, usage } from "./args.ts"; +import { runParsedBotLauncher } from "./runtime/execute.ts"; +import { publicErrorMessage } from "./runtime/support.ts"; +import type { + LauncherContext, + LauncherResult, + ParsedLauncherArgs, + RunBotLauncherOptions, +} from "./runtime/types.ts"; + +const rootDir = fileURLToPath(new URL("../../..", import.meta.url)); + +export { copyBytes } from "./io.ts"; +export { LogSink, selectRunLogs } from "./logs.ts"; +export { resolveLauncherPaths } from "./paths.ts"; +export { parseArgs, usage }; + +export async function runBotLauncher({ + argv = process.argv.slice(2), + env = process.env, + now = (): Date => new Date(), + root = rootDir, + spawnProcess = spawn, + stderr = process.stderr, + stdout = process.stdout, +}: RunBotLauncherOptions = {}): Promise { + const context = { env, now, root, spawnProcess, stderr, stdout }; + const startTime = Date.now(); + const parsed = parseLauncherInvocation(argv, stdout, stderr); + if (parsed === undefined || parsed.help === true) { + return parsed === undefined ? { status: 1 } : { status: 0 }; + } + + return runParsedBotLauncher(parsed, context, startTime); +} + +function parseLauncherInvocation( + argv: readonly string[], + stdout: LauncherContext["stdout"], + stderr: LauncherContext["stderr"], +): ParsedLauncherArgs | undefined { + try { + const parsed = parseArgs(argv); + if (parsed.help === true) { + stdout.write(usage()); + } + return parsed; + } catch (error) { + stderr.write(`ickb-bot-launcher: ${publicErrorMessage(error)}\n${usage()}`); + return undefined; + } +} diff --git a/scripts/bot/launcher/io.ts b/scripts/bot/launcher/io.ts new file mode 100644 index 0000000..47a1e74 --- /dev/null +++ b/scripts/bot/launcher/io.ts @@ -0,0 +1,92 @@ +import type { Readable } from "node:stream"; + +import { toError } from "./runtime/support.ts"; +import type { CopyChunkInput, LogSinkLike, OutputStream } from "./runtime/types.ts"; + +export async function copyBytes( + readable: Readable | null, + fileSink: Pick, + tee?: OutputStream, +): Promise { + if (readable === null) { + return; + } + + let pending = Promise.resolve(); + await new Promise((resolve, reject) => { + readable.on("data", (chunk: string | Uint8Array) => { + readable.pause(); + pending = copyChunk({ chunk, fileSink, pending, readable, reject, tee }); + }); + readable.once("end", () => { + void settlePendingCopy(pending, resolve, reject); + }); + readable.once("error", reject); + }); +} + +export async function settleCopies(...copies: Array>): Promise { + const results = await Promise.allSettled(copies); + const failed = results.find((result) => result.status === "rejected"); + return failed?.reason; +} + +async function copyChunk({ + chunk, + fileSink, + pending, + readable, + reject, + tee, +}: CopyChunkInput): Promise { + try { + await pending; + await fileSink.write(chunk); + if (tee !== undefined) { + await writeToStream(tee, chunk); + } + readable.resume(); + } catch (error) { + reject(error); + readable.destroy(toError(error)); + } +} + +async function settlePendingCopy( + pending: Promise, + resolve: () => void, + reject: (reason?: unknown) => void, +): Promise { + try { + await pending; + resolve(); + } catch (error) { + reject(error); + } +} + +async function writeToStream( + stream: OutputStream, + chunk: string | Uint8Array, +): Promise { + await new Promise((resolve) => { + let settled = false; + const finish = (): void => { + if (settled) { + return; + } + settled = true; + setImmediate(() => { + stream.off?.("error", finish); + resolve(); + }); + }; + + stream.once?.("error", finish); + try { + stream.write(chunk, finish); + } catch { + finish(); + } + }); +} diff --git a/scripts/bot/launcher/logs.ts b/scripts/bot/launcher/logs.ts new file mode 100644 index 0000000..31da925 --- /dev/null +++ b/scripts/bot/launcher/logs.ts @@ -0,0 +1,198 @@ +import path from "node:path"; + +import { + appendLogFileFlags, + launchLogFileName, + launcherStartedType, + runLogSlotCount, + truncateLogFileFlags, +} from "./runtime/constants.ts"; +import { ignoreError, isErrorCode, isRecord } from "./runtime/support.ts"; +import type { + HistoricalLaunchRecord, + LogSinkHandle, + LogSinkLike, + LogSinks, + RunLogs, +} from "./runtime/types.ts"; +import { safeLstat, safeOpen, safeReadFile } from "./storage/filesystem.ts"; + +export async function selectRunLogs(logDir: string): Promise { + const launches = path.join(logDir, launchLogFileName); + const index = await nextRunLogSlot(launches); + const slotName = formatRunLogSlot(index); + return { + artifactRefPrefix: `artifacts/${slotName}`, + slot: { index, count: runLogSlotCount, name: slotName }, + logFiles: { + artifacts: path.join(logDir, "artifacts", slotName), + events: path.join(logDir, `bot.events.${slotName}.ndjson`), + launches, + stderr: path.join(logDir, `bot.stderr.${slotName}.log`), + }, + }; +} + +export class LogSink implements LogSinkLike { + private readonly handle: LogSinkHandle; + private pending: Promise = Promise.resolve(); + + constructor(handle: LogSinkHandle) { + this.handle = handle; + } + + public async write(chunk: string | Uint8Array): Promise { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const previous = this.pending; + this.pending = appendAfter(previous, this.handle, data); + await this.pending; + } + + public async writeLine(record: unknown): Promise { + await this.write(`${JSON.stringify(record)}\n`); + } + + public async close(): Promise { + try { + await this.pending; + } finally { + await this.handle.close(); + } + } +} + +export async function openLogSinks(runLogs: RunLogs): Promise { + return { + events: await openLogSink(runLogs.logFiles.events, { truncate: true }), + launches: await openLogSink(runLogs.logFiles.launches), + stderr: await openLogSink(runLogs.logFiles.stderr, { truncate: true }), + }; +} + +export async function closeSinks(sinks: LogSinks): Promise { + await Promise.all([sinks.events.close(), sinks.launches.close(), sinks.stderr.close()]); +} + +async function nextRunLogSlot(launchesPath: string): Promise { + const latest = await latestRunLogSlot(launchesPath); + return latest === undefined ? 0 : (latest + 1) % runLogSlotCount; +} + +async function latestRunLogSlot(filePath: string): Promise { + const text = await readExistingLogFile(filePath); + if (text === undefined) { + return undefined; + } + + for (const line of text.trimEnd().split(/\r?\n/u).toReversed()) { + const index = parseLaunchSlotIndex(line); + if (index !== undefined) { + return index; + } + } + return undefined; +} + +async function readExistingLogFile(filePath: string): Promise { + try { + const stat = await safeLstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked log file path: ${filePath}`); + } + if (!stat.isFile()) { + throw new Error(`Log file path is not a regular file: ${filePath}`); + } + return await safeReadFile(filePath, "utf8"); + } catch (error) { + if (isErrorCode(error, "ENOENT")) { + return undefined; + } + throw error; + } +} + +function parseLaunchSlotIndex(line: string): number | undefined { + if (line.trim() === "") { + return undefined; + } + try { + const record = parseHistoricalLaunchRecord(line); + return validLaunchSlotIndex(record); + } catch { + return undefined; + } +} + +function parseHistoricalLaunchRecord(line: string): HistoricalLaunchRecord { + const parsed: unknown = JSON.parse(line); + return isRecord(parsed) ? parsed : {}; +} + +function validLaunchSlotIndex(record: HistoricalLaunchRecord): number | undefined { + const { logSlot } = record; + const index = logSlot?.index; + if ( + record.type === launcherStartedType && + Number.isSafeInteger(index) && + typeof index === "number" && + logSlot?.count === runLogSlotCount && + index >= 0 && + index < runLogSlotCount + ) { + return index; + } + return undefined; +} + +function formatRunLogSlot(index: number): string { + return `slot-${String(index).padStart(2, "0")}`; +} + +async function openLogSink( + filePath: string, + { truncate = false } = {}, +): Promise { + await assertLogFileTarget(filePath); + + const handle = await safeOpen( + filePath, + truncate ? truncateLogFileFlags : appendLogFileFlags, + 0o600, + ); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error(`Log file path is not a regular file: ${filePath}`); + } + await handle.chmod(0o600); + return new LogSink(handle); + } catch (error) { + await ignoreError(handle.close()); + throw error; + } +} + +async function assertLogFileTarget(filePath: string): Promise { + try { + const stat = await safeLstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked log file path: ${filePath}`); + } + if (!stat.isFile()) { + throw new Error(`Log file path is not a regular file: ${filePath}`); + } + } catch (error) { + if (!isErrorCode(error, "ENOENT")) { + throw error; + } + } +} + +async function appendAfter( + previous: Promise, + handle: LogSinkHandle, + data: Buffer, +): Promise { + await previous; + await handle.appendFile(data); +} diff --git a/scripts/bot/launcher/paths.ts b/scripts/bot/launcher/paths.ts new file mode 100644 index 0000000..1b29a63 --- /dev/null +++ b/scripts/bot/launcher/paths.ts @@ -0,0 +1,48 @@ +import path from "node:path"; + +import { defaultLogRoot, logDirectoryLabel } from "./runtime/constants.ts"; +import type { LauncherPathsOptions } from "./runtime/types.ts"; + +export function resolveLauncherPaths({ + cliLogRoot, + envLogRoot, + logDir, + root, +}: LauncherPathsOptions): { logDir: string; logRoot: string } { + const logRoot = resolveConfiguredPath( + cliLogRoot ?? envLogRoot ?? defaultLogRoot, + root, + "log root", + ); + const resolvedLogDir = + logDir === undefined + ? path.join(logRoot, "bot") + : resolveConfiguredPath(logDir, root, logDirectoryLabel); + + assertContained(logRoot, resolvedLogDir, logDirectoryLabel); + return { logDir: resolvedLogDir, logRoot }; +} + +function resolveConfiguredPath(value: string, root: string, label: string): string { + if (value === "") { + throw new Error(`Empty ${label} path`); + } + return path.isAbsolute(value) ? path.resolve(value) : path.resolve(root, value); +} + +function assertContained(root: string, candidate: string, label: string): void { + const relationship = path.relative(root, candidate); + if (isContainedRelationship(relationship)) { + return; + } + throw new Error(`${label} must stay inside the resolved log root`); +} + +function isContainedRelationship(relationship: string): boolean { + return ( + relationship === "" || + (relationship !== ".." && + !relationship.startsWith(`..${path.sep}`) && + !path.isAbsolute(relationship)) + ); +} diff --git a/scripts/bot/launcher/runtime/constants.ts b/scripts/bot/launcher/runtime/constants.ts new file mode 100644 index 0000000..d13bb03 --- /dev/null +++ b/scripts/bot/launcher/runtime/constants.ts @@ -0,0 +1,13 @@ +import { constants } from "node:fs"; + +export const appendLogFileFlags = + constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW; +export const truncateLogFileFlags = + constants.O_TRUNC | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW; +export const botSourceCommand = "apps/bot/src/index.ts"; +export const defaultLogRoot = "log"; +export const launchLogFileName = "launches.ndjson"; +export const launcherStartedType = "launcher.started"; +export const logDirectoryLabel = "log directory"; +export const runLogSlotCount = 16; +export const signalNames: readonly NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; diff --git a/scripts/bot/launcher/runtime/execute.ts b/scripts/bot/launcher/runtime/execute.ts new file mode 100644 index 0000000..4621359 --- /dev/null +++ b/scripts/bot/launcher/runtime/execute.ts @@ -0,0 +1,64 @@ +import { copyBytes, settleCopies } from "../io.ts"; +import { closeSinks } from "../logs.ts"; +import { prepareLaunchConfig } from "../storage/prepare.ts"; +import { childResultToLauncherResult, failLaunch } from "./process.ts"; +import { exitLaunchRecord, startLaunchRecord } from "./records.ts"; +import { spawnLaunch } from "./spawn.ts"; +import type { + LauncherContext, + LauncherResult, + ParsedLauncherArgs, + PreparedLaunch, + PreparedLaunchConfig, +} from "./types.ts"; + +export async function runParsedBotLauncher( + parsed: Exclude, + context: LauncherContext, + startTime: number, +): Promise { + let launch: PreparedLaunch | undefined; + let config: PreparedLaunchConfig | undefined; + + try { + config = await prepareLaunchConfig(parsed, context); + launch = spawnLaunch(parsed, context, config); + await config.sinks.launches.writeLine(startLaunchRecord(parsed, launch, context.now)); + + const stdoutCopy = copyBytes( + launch.child.stdout, + config.sinks.events, + parsed.teeChildOutput ? context.stdout : undefined, + ); + const stderrCopy = copyBytes( + launch.child.stderr, + config.sinks.stderr, + parsed.teeChildOutput ? context.stderr : undefined, + ); + const childResult = await launch.childResultPromise; + const copyResult = await settleCopies(stdoutCopy, stderrCopy); + await config.sinks.launches.writeLine( + exitLaunchRecord({ + childResult, + copyResult, + elapsedMs: Date.now() - startTime, + launch, + now: context.now, + parsed, + }), + ); + + await closeSinks(config.sinks); + config = undefined; + launch.removeSignalHandlers(); + return childResultToLauncherResult(childResult, copyResult, context.stderr); + } catch (error) { + return failLaunch({ + child: launch?.child, + error, + removeSignalHandlers: launch?.removeSignalHandlers, + sinks: config?.sinks, + stderr: context.stderr, + }); + } +} diff --git a/scripts/bot/launcher/runtime/process.ts b/scripts/bot/launcher/runtime/process.ts new file mode 100644 index 0000000..22ce3d1 --- /dev/null +++ b/scripts/bot/launcher/runtime/process.ts @@ -0,0 +1,102 @@ +import path from "node:path"; + +import { closeSinks } from "../logs.ts"; +import { signalNames } from "./constants.ts"; +import { ignoreError, publicErrorMessage } from "./support.ts"; +import type { + ChildLike, + ChildResult, + FailLaunchInput, + LauncherResult, + OutputStream, + SafeCommandShape, +} from "./types.ts"; + +export async function waitForChild(child: ChildLike): Promise { + return new Promise((resolve) => { + let settled = false; + const settle = (result: ChildResult): void => { + if (settled) { + return; + } + settled = true; + resolve(result); + }; + child.once("error", (error: Error) => { + settle({ error, signal: null, status: 1 }); + }); + child.once("close", (status, signal) => { + settle({ signal, status }); + }); + }); +} + +export function forwardSignalsTo(child: ChildLike): () => void { + const handlers: Array<[NodeJS.Signals, () => void]> = signalNames.map((signal) => { + const handler = (): void => { + if (child.exitCode === null && !child.killed) { + child.kill(signal); + } + }; + process.once(signal, handler); + return [signal, handler]; + }); + return (): void => { + for (const [signal, handler] of handlers) { + process.off(signal, handler); + } + }; +} + +export function safeCommandShape( + command: string, + argumentCount: number, +): SafeCommandShape { + return { + argumentCount, + arguments: Array.from({ length: argumentCount }, (_, index) => ({ + index, + value: "", + })), + executable: path.basename(command), + }; +} + +export function childResultToLauncherResult( + childResult: ChildResult, + copyResult: unknown, + stderr: OutputStream, +): LauncherResult { + if (copyResult !== undefined) { + stderr.write(`ickb-bot-launcher: ${publicErrorMessage(copyResult)}\n`); + return { status: 1 }; + } + if (childResult.error !== undefined) { + stderr.write( + `ickb-bot-launcher: Failed to spawn child process: ${publicErrorMessage(childResult.error)}\n`, + ); + return { status: 1 }; + } + if (childResult.signal !== null) { + return { signal: childResult.signal }; + } + return { status: childResult.status ?? 1 }; +} + +export async function failLaunch({ + child, + error, + removeSignalHandlers, + sinks, + stderr, +}: FailLaunchInput): Promise { + removeSignalHandlers?.(); + if (child?.exitCode === null && !child.killed) { + child.kill("SIGTERM"); + } + if (sinks !== undefined) { + await ignoreError(closeSinks(sinks)); + } + stderr.write(`ickb-bot-launcher: ${publicErrorMessage(error)}\n`); + return { status: 1 }; +} diff --git a/scripts/bot/launcher/runtime/records.ts b/scripts/bot/launcher/runtime/records.ts new file mode 100644 index 0000000..d11bef8 --- /dev/null +++ b/scripts/bot/launcher/runtime/records.ts @@ -0,0 +1,95 @@ +import { launcherStartedType } from "./constants.ts"; +import type { + ExitLaunchRecordInput, + LaunchRecordInput, + LaunchRecordShape, + ParsedLauncherArgs, + PreparedLaunch, +} from "./types.ts"; + +export function startLaunchRecord( + parsed: Exclude, + launch: PreparedLaunch, + now: () => Date, +): LaunchRecordShape { + return launchRecord({ + child: launch.child, + childCommand: launch.childCommand, + elapsedMs: 0, + now, + packageInfo: launch.packageInfo, + parsed, + paths: launch.paths, + root: launch.root, + runLogs: launch.runLogs, + signal: null, + status: null, + storageQuotaBytes: launch.storageQuotaBytes, + type: launcherStartedType, + }); +} + +export function exitLaunchRecord({ + childResult, + copyResult, + elapsedMs, + launch, + now, + parsed, +}: ExitLaunchRecordInput): LaunchRecordShape { + return launchRecord({ + child: launch.child, + childCommand: launch.childCommand, + elapsedMs, + now, + packageInfo: launch.packageInfo, + parsed, + paths: launch.paths, + root: launch.root, + runLogs: launch.runLogs, + signal: childResult.signal, + status: childResult.status, + storageQuotaBytes: launch.storageQuotaBytes, + type: copyResult === undefined ? "launcher.child.exited" : "launcher.io.failed", + }); +} + +function launchRecord({ + child, + childCommand, + elapsedMs, + packageInfo, + parsed, + paths, + root, + runLogs, + storageQuotaBytes, + now, + signal, + status, + type, +}: LaunchRecordInput): LaunchRecordShape { + return { + app: "bot-launcher", + childPid: child.pid ?? null, + command: childCommand, + elapsedMs, + logDir: paths.logDir, + logFiles: runLogs.logFiles, + logRoot: paths.logRoot, + logRetention: { + storageQuotaBytes: storageQuotaBytes ?? null, + }, + logSlot: runLogs.slot, + nodeVersion: process.version, + package: packageInfo, + pid: process.pid, + repoRoot: root, + signal, + status, + teeChildOutput: parsed.teeChildOutput, + timestamp: now().toISOString(), + type, + version: 1, + }; +} diff --git a/scripts/bot/launcher/runtime/spawn.ts b/scripts/bot/launcher/runtime/spawn.ts new file mode 100644 index 0000000..7243198 --- /dev/null +++ b/scripts/bot/launcher/runtime/spawn.ts @@ -0,0 +1,39 @@ +import { botSourceCommand } from "./constants.ts"; +import { forwardSignalsTo, safeCommandShape, waitForChild } from "./process.ts"; +import type { + LauncherContext, + ParsedLauncherArgs, + PreparedLaunch, + PreparedLaunchConfig, +} from "./types.ts"; + +export function spawnLaunch( + parsed: Exclude, + context: LauncherContext, + config: PreparedLaunchConfig, +): PreparedLaunch { + const command = parsed.command ?? process.execPath; + const commandArgs = + parsed.command === undefined ? [botSourceCommand] : parsed.commandArgs; + const child = context.spawnProcess(command, commandArgs, { + cwd: context.root, + env: { + ...context.env, + BOT_ARTIFACT_REF_PREFIX: config.runLogs.artifactRefPrefix, + BOT_ARTIFACT_ROOT: config.runLogs.logFiles.artifacts, + }, + stdio: ["inherit", "pipe", "pipe"], + }); + + return { + child, + childCommand: safeCommandShape(command, commandArgs.length), + childResultPromise: waitForChild(child), + packageInfo: config.packageInfo, + paths: config.paths, + removeSignalHandlers: forwardSignalsTo(child), + root: config.root, + runLogs: config.runLogs, + storageQuotaBytes: config.storageQuotaBytes, + }; +} diff --git a/scripts/bot/launcher/runtime/support.ts b/scripts/bot/launcher/runtime/support.ts new file mode 100644 index 0000000..adc2948 --- /dev/null +++ b/scripts/bot/launcher/runtime/support.ts @@ -0,0 +1,23 @@ +export function publicErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown launcher error"; +} + +export function isErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +export async function ignoreError(promise: Promise): Promise { + try { + await promise; + } catch { + // Best-effort cleanup must preserve the original launcher error. + } +} + +export function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/scripts/bot/launcher/runtime/types.ts b/scripts/bot/launcher/runtime/types.ts new file mode 100644 index 0000000..db57277 --- /dev/null +++ b/scripts/bot/launcher/runtime/types.ts @@ -0,0 +1,230 @@ +import type { SpawnOptions } from "node:child_process"; +import type { FileHandle } from "node:fs/promises"; +import type { Readable } from "node:stream"; + +export type ParsedLauncherArgs = + | { help: true } + | { + command?: string; + commandArgs: string[]; + help?: false; + logDir?: string; + logRoot?: string; + logStorageQuotaBytes?: number; + teeChildOutput: boolean; + }; + +export interface LauncherPathsOptions { + cliLogRoot?: string; + envLogRoot?: string; + logDir?: string; + root: string; +} + +export type LauncherResult = + { signal?: undefined; status: number } | { signal: NodeJS.Signals; status?: undefined }; + +export interface ChildResult { + error?: Error; + signal: NodeJS.Signals | null; + status: number | null; +} + +export interface ChildLike { + exitCode: number | null; + killed: boolean; + kill: (signal?: NodeJS.Signals | number) => boolean | undefined; + once: { + ( + event: "close", + listener: (status: number | null, signal: NodeJS.Signals | null) => void, + ): ChildLike; + (event: "error", listener: (error: Error) => void): ChildLike; + }; + pid?: number; + stderr: Readable | null; + stdout: Readable | null; +} + +export type SpawnProcess = ( + command: string, + args: string[], + options: SpawnOptions, +) => ChildLike; + +export interface OutputStream { + off?: (event: "error", listener: (error?: Error | null) => void) => OutputStream; + once?: (event: "error", listener: (error?: Error | null) => void) => OutputStream; + write: ( + chunk: string | Uint8Array, + callback?: (error?: Error | null) => void, + ) => boolean | undefined; +} + +export interface RunBotLauncherOptions { + argv?: string[]; + env?: NodeJS.ProcessEnv; + now?: () => Date; + root?: string; + spawnProcess?: SpawnProcess; + stderr?: OutputStream; + stdout?: OutputStream; +} + +export interface LogFiles { + artifacts: string; + events: string; + launches: string; + stderr: string; +} + +export interface RunLogSlot { + count: number; + index: number; + name: string; +} + +export interface RunLogs { + artifactRefPrefix: string; + logFiles: LogFiles; + slot: RunLogSlot; +} + +export interface LogSinkLike { + close: () => Promise; + write: (chunk: string | Uint8Array) => Promise; + writeLine: (record: unknown) => Promise; +} + +export interface LogSinks { + events: LogSinkLike; + launches: LogSinkLike; + stderr: LogSinkLike; +} + +export interface ManagedRunItem { + kind: "directory" | "file"; + path: string; +} + +export interface ManagedRunGroup { + items: ManagedRunItem[]; + mtimeMs: number; + size: number; + slotName: string; +} + +export type LogSinkHandle = Pick; + +export interface LauncherContext { + env: NodeJS.ProcessEnv; + now: () => Date; + root: string; + spawnProcess: SpawnProcess; + stderr: OutputStream; + stdout: OutputStream; +} + +export interface PreparedLaunch { + child: ChildLike; + childCommand: SafeCommandShape; + childResultPromise: Promise; + packageInfo: PackageInfo | null; + paths: { logDir: string; logRoot: string }; + removeSignalHandlers: () => void; + root: string; + runLogs: RunLogs; + storageQuotaBytes?: number; +} + +export interface PreparedLaunchConfig { + packageInfo: PackageInfo | null; + paths: { logDir: string; logRoot: string }; + root: string; + runLogs: RunLogs; + sinks: LogSinks; + storageQuotaBytes?: number; +} + +export interface SafeCommandShape { + argumentCount: number; + arguments: Array<{ index: number; value: string }>; + executable: string; +} + +export interface LaunchRecordInput { + child: ChildLike; + childCommand: SafeCommandShape; + elapsedMs: number; + now: () => Date; + packageInfo: PackageInfo | null; + parsed: Exclude; + paths: { logDir: string; logRoot: string }; + root: string; + runLogs: RunLogs; + signal: NodeJS.Signals | null; + status: number | null; + storageQuotaBytes?: number; + type: string; +} + +export interface LaunchRecordShape { + app: "bot-launcher"; + childPid: number | null; + command: SafeCommandShape; + elapsedMs: number; + logDir: string; + logFiles: LogFiles; + logRoot: string; + logRetention: { storageQuotaBytes: number | null }; + logSlot: RunLogSlot; + nodeVersion: string; + package: PackageInfo | null; + pid: number; + repoRoot: string; + signal: NodeJS.Signals | null; + status: number | null; + teeChildOutput: boolean; + timestamp: string; + type: string; + version: 1; +} + +export interface ExitLaunchRecordInput { + childResult: ChildResult; + copyResult: unknown; + elapsedMs: number; + launch: PreparedLaunch; + now: () => Date; + parsed: Exclude; +} + +export interface FailLaunchInput { + child?: ChildLike; + error: unknown; + removeSignalHandlers?: () => void; + sinks?: LogSinks; + stderr: OutputStream; +} + +export interface CopyChunkInput { + chunk: string | Uint8Array; + fileSink: Pick; + pending: Promise; + readable: Readable; + reject: (reason?: unknown) => void; + tee?: OutputStream; +} + +export interface HistoricalLaunchRecord { + logSlot?: { + count?: unknown; + index?: unknown; + }; + type?: unknown; +} + +export interface PackageInfo { + name: string | null; + version: string | null; +} diff --git a/scripts/bot/launcher/storage/filesystem.ts b/scripts/bot/launcher/storage/filesystem.ts new file mode 100644 index 0000000..8a5f7c4 --- /dev/null +++ b/scripts/bot/launcher/storage/filesystem.ts @@ -0,0 +1,135 @@ +import type { Dirent, Stats } from "node:fs"; +import { + lstat as fsLstat, + mkdir as fsMkdir, + open as fsOpen, + readdir as fsReaddir, + readFile as fsReadFile, + realpath as fsRealpath, + rm as fsRm, + type FileHandle, +} from "node:fs/promises"; +import path from "node:path"; + +import { logDirectoryLabel } from "../runtime/constants.ts"; +import { isErrorCode } from "../runtime/support.ts"; + +export async function prepareLogPaths(paths: { + logDir: string; + logRoot: string; +}): Promise { + await prepareLogDirectory(paths.logRoot); + await prepareLogDirectory(paths.logDir); + await proveResolvedPath(paths.logRoot, "log root"); + await proveResolvedPath(paths.logDir, logDirectoryLabel); +} + +export async function prepareLogDirectory(directory: string): Promise { + const parsed = path.parse(directory); + let current = parsed.root; + await assertDirectory(current); + + const parts = path.relative(parsed.root, directory).split(path.sep).filter(Boolean); + for (const part of parts) { + current = path.join(current, part); + await ensureLogDirectoryPart(current); + } +} + +export async function resetArtifactDirectory(filePath: string): Promise { + try { + const stat = await safeLstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked artifact directory path: ${filePath}`); + } + if (!stat.isDirectory()) { + throw new Error(`Artifact path is not a directory: ${filePath}`); + } + await safeRm(filePath, { force: true, recursive: true }); + } catch (error) { + if (!isErrorCode(error, "ENOENT")) { + throw error; + } + } + await safeMkdir(filePath, { recursive: true, mode: 0o700 }); + await assertDirectory(filePath); +} + +export async function safeLstat(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Callers prove launcher-managed paths before filesystem use. + return fsLstat(filePath); +} + +export async function safeMkdir( + directory: string, + options: Parameters[1], +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Callers build paths from the resolved log root and checked path parts. + await fsMkdir(directory, options); +} + +export async function safeRealpath(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This is the symlink proof for the already resolved launcher path. + return fsRealpath(filePath); +} + +export async function safeRm( + filePath: string, + options: Parameters[1], +): Promise { + await fsRm(filePath, options); +} + +export async function safeReaddir( + directory: string, + options: { withFileTypes: true }, +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Directory reads stay under checked launcher log/artifact paths. + return fsReaddir(directory, options); +} + +export async function safeReadFile( + filePath: string, + encoding: BufferEncoding, +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Reads target checked launcher log files or package metadata. + return fsReadFile(filePath, encoding); +} + +export async function safeOpen( + filePath: string, + flags: number, + mode: number, +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Opens checked log files with no-follow flags and post-open file validation. + return fsOpen(filePath, flags, mode); +} + +async function ensureLogDirectoryPart(current: string): Promise { + try { + await assertDirectory(current); + } catch (error) { + if (!isErrorCode(error, "ENOENT")) { + throw error; + } + await safeMkdir(current, { mode: 0o700 }); + await assertDirectory(current); + } +} + +async function assertDirectory(filePath: string): Promise { + const stat = await safeLstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked log directory path: ${filePath}`); + } + if (!stat.isDirectory()) { + throw new Error(`Log directory path is not a directory: ${filePath}`); + } +} + +async function proveResolvedPath(filePath: string, label: string): Promise { + const real = await safeRealpath(filePath); + if (real !== filePath) { + throw new Error(`Resolved ${label} crosses a symlink`); + } +} diff --git a/scripts/bot/launcher/storage/metadata.ts b/scripts/bot/launcher/storage/metadata.ts new file mode 100644 index 0000000..08b1cf9 --- /dev/null +++ b/scripts/bot/launcher/storage/metadata.ts @@ -0,0 +1,24 @@ +import path from "node:path"; + +import type { PackageInfo } from "../runtime/types.ts"; +import { safeReadFile } from "./filesystem.ts"; + +export async function readBotPackageInfo(root: string): Promise { + try { + const text = await safeReadFile(path.join(root, "apps/bot/package.json"), "utf8"); + const parsed: unknown = JSON.parse(text); + if (!isPackageJson(parsed)) { + return { name: null, version: null }; + } + return { + name: typeof parsed.name === "string" ? parsed.name : null, + version: typeof parsed.version === "string" ? parsed.version : null, + }; + } catch { + return null; + } +} + +function isPackageJson(value: unknown): value is { name?: unknown; version?: unknown } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/scripts/bot/launcher/storage/prepare.ts b/scripts/bot/launcher/storage/prepare.ts new file mode 100644 index 0000000..8595f0c --- /dev/null +++ b/scripts/bot/launcher/storage/prepare.ts @@ -0,0 +1,60 @@ +import path from "node:path"; + +import { parseOptionalPositiveSafeInteger } from "../args.ts"; +import { closeSinks, openLogSinks, selectRunLogs } from "../logs.ts"; +import { resolveLauncherPaths } from "../paths.ts"; +import { ignoreError } from "../runtime/support.ts"; +import type { + LauncherContext, + ParsedLauncherArgs, + PreparedLaunchConfig, +} from "../runtime/types.ts"; +import { + prepareLogDirectory, + prepareLogPaths, + resetArtifactDirectory, +} from "./filesystem.ts"; +import { readBotPackageInfo } from "./metadata.ts"; +import { applyStorageQuota } from "./retention.ts"; + +export async function prepareLaunchConfig( + parsed: Exclude, + context: LauncherContext, +): Promise { + const paths = resolveLauncherPaths({ + cliLogRoot: parsed.logRoot, + envLogRoot: context.env["ICKB_BOT_LOG_ROOT"], + logDir: parsed.logDir, + root: context.root, + }); + await prepareLogPaths(paths); + + const storageQuotaBytes = + parsed.logStorageQuotaBytes ?? + parseOptionalPositiveSafeInteger( + context.env["ICKB_BOT_LOG_STORAGE_QUOTA_BYTES"], + "ICKB_BOT_LOG_STORAGE_QUOTA_BYTES", + ); + await prepareLogDirectory(path.join(paths.logDir, "artifacts")); + const runLogs = await selectRunLogs(paths.logDir); + await resetArtifactDirectory(runLogs.logFiles.artifacts); + const sinks = await openLogSinks(runLogs); + try { + if (storageQuotaBytes !== undefined) { + await applyStorageQuota(paths.logDir, runLogs.slot.name, storageQuotaBytes); + } + + const packageInfo = await readBotPackageInfo(context.root); + return { + packageInfo, + paths, + root: context.root, + runLogs, + sinks, + storageQuotaBytes, + }; + } catch (error) { + await ignoreError(closeSinks(sinks)); + throw error; + } +} diff --git a/scripts/bot/launcher/storage/retention.ts b/scripts/bot/launcher/storage/retention.ts new file mode 100644 index 0000000..aacde42 --- /dev/null +++ b/scripts/bot/launcher/storage/retention.ts @@ -0,0 +1,150 @@ +import path from "node:path"; + +import { launchLogFileName } from "../runtime/constants.ts"; +import { isErrorCode } from "../runtime/support.ts"; +import type { ManagedRunGroup } from "../runtime/types.ts"; +import { safeLstat, safeReaddir, safeRm } from "./filesystem.ts"; + +export async function applyStorageQuota( + logDir: string, + currentSlotName: string, + quotaBytes: number, +): Promise { + const groups = await managedRunGroups(logDir, currentSlotName); + const launchesSize = await fileSize(path.join(logDir, launchLogFileName)); + let total = launchesSize + groups.reduce((sum, group) => sum + group.size, 0); + for (const group of groups + .filter((candidate) => candidate.slotName !== currentSlotName) + .toSorted((left, right) => left.mtimeMs - right.mtimeMs)) { + if (total <= quotaBytes) { + break; + } + for (const item of group.items) { + await safeRm(item.path, { force: true, recursive: item.kind === "directory" }); + } + total -= group.size; + } +} + +async function managedRunGroups( + logDir: string, + currentSlotName: string, +): Promise { + const groups = new Map(); + const entries = await readOptionalDirectory(logDir); + for (const entry of entries) { + if (entry.isFile()) { + await addManagedFileGroup(groups, logDir, entry.name); + } + } + + await addArtifactGroups(groups, logDir); + ensureCurrentRunGroup(groups, currentSlotName); + return [...groups.values()]; +} + +async function readOptionalDirectory( + directory: string, +): Promise>> { + try { + return await safeReaddir(directory, { withFileTypes: true }); + } catch (error) { + if (isErrorCode(error, "ENOENT")) { + return []; + } + throw error; + } +} + +async function addManagedFileGroup( + groups: Map, + logDir: string, + entryName: string, +): Promise { + const slotName = /^bot\.(?:events|stderr)\.(slot-\d{2})\.(?:ndjson|log)$/u.exec( + entryName, + )?.[1]; + if (slotName !== undefined) { + await addManagedRunItem(groups, slotName, path.join(logDir, entryName)); + } +} + +async function addArtifactGroups( + groups: Map, + logDir: string, +): Promise { + const artifactDir = path.join(logDir, "artifacts"); + const artifactEntries = await readOptionalDirectory(artifactDir); + for (const entry of artifactEntries) { + if (/^slot-\d{2}$/u.test(entry.name)) { + await addManagedRunItem(groups, entry.name, path.join(artifactDir, entry.name)); + } + } +} + +async function addManagedRunItem( + groups: Map, + slotName: string, + filePath: string, +): Promise { + const stat = await safeLstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked managed log path: ${filePath}`); + } + const isDirectory = stat.isDirectory(); + const size = isDirectory ? await directorySize(filePath) : stat.size; + const group = groups.get(slotName) ?? { + items: [], + mtimeMs: 0, + size: 0, + slotName, + }; + group.items.push({ kind: isDirectory ? "directory" : "file", path: filePath }); + group.mtimeMs = Math.max(group.mtimeMs, stat.mtimeMs); + group.size += size; + groups.set(slotName, group); +} + +function ensureCurrentRunGroup( + groups: Map, + currentSlotName: string, +): void { + if (groups.has(currentSlotName)) { + return; + } + groups.set(currentSlotName, { + items: [], + mtimeMs: 0, + size: 0, + slotName: currentSlotName, + }); +} + +async function fileSize(filePath: string): Promise { + try { + const stat = await safeLstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked managed log path: ${filePath}`); + } + return stat.isFile() ? stat.size : 0; + } catch (error) { + if (isErrorCode(error, "ENOENT")) { + return 0; + } + throw error; + } +} + +async function directorySize(directory: string): Promise { + const entries = await safeReaddir(directory, { withFileTypes: true }); + let total = 0; + for (const entry of entries) { + const child = path.join(directory, entry.name); + const stat = await safeLstat(child); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked managed log path: ${child}`); + } + total += stat.isDirectory() ? await directorySize(child) : stat.size; + } + return total; +} diff --git a/scripts/ickb-bot-collect-incident.mjs b/scripts/ickb-bot-collect-incident.mjs deleted file mode 100644 index ca0b4d1..0000000 --- a/scripts/ickb-bot-collect-incident.mjs +++ /dev/null @@ -1,822 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { constants } from "node:fs"; -import { lstat, mkdir, open, readFile, realpath } from "node:fs/promises"; -import { basename, join, parse, relative, resolve, sep } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { resolveLauncherPaths } from "./ickb-bot-launcher.mjs"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const supportedNetworks = new Set(["testnet", "mainnet"]); -const sourceFiles = [ - { kind: "botEvents", name: "bot.events.ndjson" }, - { kind: "stderr", name: "bot.stderr.log" }, - { kind: "launches", name: "launches.ndjson" }, -]; -const scriptVersion = 1; -const stderrUndatedLineLimit = 200; -const noFollow = constants.O_NOFOLLOW ?? 0; -const readOnlyNoFollow = constants.O_RDONLY | noFollow; -const writeNewFileFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow; - -export function usage() { - return [ - "Usage: node scripts/ickb-bot-collect-incident.mjs [--log-root PATH] (--network testnet|mainnet | --log-dir PATH) --since --until [--no-systemd]", - "Relative times use the current time, for example --since 2h --until now.", - ].join("\n"); -} - -export function parseArgs(argv) { - const args = { includeSystemd: true }; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--") { - continue; - } - if (arg === "-h" || arg === "--help") { - return { help: true }; - } - if (arg === "--log-root") { - args.logRoot = requireValue(argv, ++index, arg); - continue; - } - if (arg === "--network") { - args.network = requireValue(argv, ++index, arg); - continue; - } - if (arg === "--log-dir") { - args.logDir = requireValue(argv, ++index, arg); - continue; - } - if (arg === "--since") { - args.since = requireValue(argv, ++index, arg); - continue; - } - if (arg === "--until") { - args.until = requireValue(argv, ++index, arg); - continue; - } - if (arg === "--no-systemd") { - args.includeSystemd = false; - continue; - } - throw new Error(`Unknown option: ${arg}`); - } - - if (args.network !== undefined && !supportedNetworks.has(args.network)) { - throw new Error("Invalid network; expected testnet or mainnet"); - } - if ((args.network === undefined) === (args.logDir === undefined)) { - throw new Error("Specify exactly one of --network or --log-dir"); - } - if (args.since === undefined) { - throw new Error("Missing required --since"); - } - if (args.until === undefined) { - throw new Error("Missing required --until"); - } - - return args; -} - -export function parseTimeBound(value, now) { - if (value === "now") { - return new Date(now.getTime()); - } - - const relativeMatch = /^(\d+)(ms|s|m|h|d)(?:\s+ago)?$/u.exec(value); - if (relativeMatch !== null) { - const amount = Number(relativeMatch[1]); - const unit = relativeMatch[2]; - if (!Number.isSafeInteger(amount)) { - throw new Error(`Invalid time bound: ${value}`); - } - const multipliers = { d: 86_400_000, h: 3_600_000, m: 60_000, ms: 1, s: 1_000 }; - const timestamp = now.getTime() - amount * multipliers[unit]; - if (!Number.isFinite(timestamp)) { - throw new Error(`Invalid time bound: ${value}`); - } - return new Date(timestamp); - } - - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - throw new Error(`Invalid time bound: ${value}`); - } - return parsed; -} - -export async function collectIncident({ - argv = process.argv.slice(2), - envLogRoot = process.env.ICKB_BOT_LOG_ROOT, - now = () => new Date(), - root = rootDir, - dependencies = {}, -} = {}) { - const parsed = parseArgs(argv); - if (parsed.help) { - return { help: usage() }; - } - - const createdAt = now(); - const since = parseTimeBound(parsed.since, createdAt); - const until = parseTimeBound(parsed.until, createdAt); - if (since.getTime() > until.getTime()) { - throw new Error("--since must be before or equal to --until"); - } - - const paths = resolveIncidentPaths({ parsed, envLogRoot, root }); - await assertRealDirectory(paths.logRoot, "log root", dependencies); - await assertRealDirectory(paths.logDir, "log directory", dependencies); - - const summary = createSummary({ - createdAt, - logDir: paths.logDir, - logRoot: paths.logRoot, - logRootSource: paths.logRootSource, - network: paths.network, - since, - until, - }); - const outputs = new Map(); - - for (const source of sourceFiles) { - const sourcePath = join(paths.logDir, source.name); - const result = source.kind === "stderr" - ? await filterStderrSource(sourcePath, source.name, since, until, dependencies) - : await filterJsonSource(sourcePath, source.name, since, until, summary, source.kind, dependencies); - if (result === null) { - summary.sources[source.name] = { included: false, path: sourcePath, reason: "missing" }; - continue; - } - - summary.sources[source.name] = { - included: true, - output: source.name, - path: sourcePath, - ...result.stats, - }; - if (source.kind === "stderr") { - summary.stderr.firstTimestamp = result.stats.firstTimestamp; - summary.stderr.lastTimestamp = result.stats.lastTimestamp; - } - summary.sourceFiles.push({ - name: source.name, - output: source.name, - path: sourcePath, - selectedLines: result.stats.selectedLines, - }); - outputs.set(source.name, result.text); - } - - const version = await buildVersionMetadata(root, dependencies); - outputs.set("version.json", `${JSON.stringify(version, null, 2)}\n`); - - if (parsed.includeSystemd) { - const systemd = captureSystemd(paths.network, since, until, dependencies); - summary.systemd = systemd.summary; - for (const [name, text] of systemd.outputs) { - outputs.set(name, text); - } - } else { - summary.systemd = { included: false, reason: "disabled by --no-systemd" }; - } - - const incidentId = buildIncidentId(createdAt, paths.network); - const incidentParent = join(paths.logDir, "incidents"); - const incidentDir = join(incidentParent, incidentId); - summary.incidentId = incidentId; - summary.incidentDir = incidentDir; - summary.compression = { - created: false, - command: `tar -czf ${shellQuote(join(incidentParent, `${incidentId}.tar.gz`))} -C ${shellQuote(incidentParent)} ${shellQuote(incidentId)}`, - reason: "The collector avoids assuming tar/gzip/zstd binaries are present.", - }; - outputs.set("README.txt", incidentReadme(summary)); - outputs.set("summary.json", `${JSON.stringify(summary, null, 2)}\n`); - - await prepareIncidentDirectory(paths.logDir, incidentParent, incidentDir, dependencies); - for (const [name, text] of [...outputs].sort(([left], [right]) => left.localeCompare(right))) { - await writeBundleFile(join(incidentDir, name), text, dependencies); - } - - return { incidentDir, incidentId, summary }; -} - -export async function main(argv, io = {}) { - const stdout = io.stdout ?? process.stdout; - const stderr = io.stderr ?? process.stderr; - try { - const result = await collectIncident({ argv }); - if (result.help !== undefined) { - stdout.write(`${result.help}\n`); - return 0; - } - stdout.write(`Incident bundle directory: ${result.incidentDir}\n`); - stdout.write(`Compression command: ${result.summary.compression.command}\n`); - return 0; - } catch (error) { - stderr.write(`Incident collection failed: ${publicErrorMessage(error)}\n${usage()}\n`); - return 1; - } -} - -function requireValue(argv, index, option) { - const value = argv[index]; - if (value === undefined || value === "" || value.startsWith("--")) { - throw new Error(`Missing value for ${option}`); - } - return value; -} - -function resolveIncidentPaths({ parsed, envLogRoot, root }) { - const logRootSource = parsed.logRoot !== undefined - ? "cli" - : envLogRoot !== undefined - ? "env:ICKB_BOT_LOG_ROOT" - : "default:log"; - const paths = resolveLauncherPaths({ - cliLogRoot: parsed.logRoot, - envLogRoot, - logDir: parsed.logDir, - network: parsed.network, - root, - }); - return { - ...paths, - logRootSource, - network: parsed.network ?? inferNetwork(paths.logDir), - }; -} - -function inferNetwork(logDir) { - const name = basename(logDir); - return supportedNetworks.has(name) ? name : null; -} - -function createSummary({ createdAt, logDir, logRoot, logRootSource, network, since, until }) { - return { - version: 1, - scriptVersion, - createdAt: createdAt.toISOString(), - window: { - since: since.toISOString(), - until: until.toISOString(), - }, - logRoot, - logRootSource, - logDir, - network, - sourceFiles: [], - sources: {}, - botEvents: { - countsByType: {}, - failureReasons: {}, - firstTimestamp: null, - lastTimestamp: null, - skipReasons: {}, - txHashesByOutcome: {}, - }, - launches: { - countsByType: {}, - exitCodes: {}, - firstTimestamp: null, - lastTimestamp: null, - signals: {}, - }, - stderr: { - firstTimestamp: null, - lastTimestamp: null, - }, - }; -} - -async function openSourceHandle(path, label, dependencies) { - let stat; - try { - stat = await (dependencies.lstat ?? lstat)(path); - } catch (error) { - if (error?.code === "ENOENT") { - return null; - } - throw error; - } - if (stat.isSymbolicLink()) { - throw new Error(`Refusing symlinked source log file: ${path}`); - } - if (!stat.isFile()) { - throw new Error(`Source log is not a regular file: ${path}`); - } - - let handle; - try { - handle = await (dependencies.open ?? open)(path, readOnlyNoFollow); - const opened = await handle.stat(); - if (!opened.isFile()) { - throw new Error(`Source log is not a regular file: ${path}`); - } - return handle; - } catch (error) { - await handle?.close().catch(() => undefined); - if (error?.code === "ELOOP") { - throw new Error(`Refusing symlinked source log file: ${path}`); - } - throw new Error(`Unable to read ${label}: ${publicErrorMessage(error)}`); - } -} - -async function processSourceLines(path, label, dependencies, onLine) { - const handle = await openSourceHandle(path, label, dependencies); - if (handle === null) { - return false; - } - - try { - let lineNumber = 0; - let pending = ""; - const decoder = new TextDecoder("utf-8"); - for await (const chunk of handle.readableWebStream({ type: "bytes" })) { - pending += decoder.decode(chunk, { stream: true }); - const parts = pending.split("\n"); - pending = parts.pop() ?? ""; - for (const part of parts) { - lineNumber += 1; - onLine(`${part}\n`, lineNumber); - } - } - pending += decoder.decode(); - if (pending !== "") { - onLine(pending, lineNumber + 1); - } - return true; - } finally { - await handle.close().catch(() => undefined); - } -} - -async function filterJsonSource(path, sourceName, since, until, summary, kind, dependencies) { - const selected = []; - const stats = emptySourceStats(); - - const found = await processSourceLines(path, sourceName, dependencies, (line, lineNumber) => { - if (line.trim() === "") { - stats.emptyLines += 1; - return; - } - stats.totalLines += 1; - - let record; - try { - record = JSON.parse(stripLineEnding(line)); - } catch { - stats.malformedLines += 1; - return; - } - - const timestamp = parseRecordTimestamp(record?.timestamp); - if (timestamp === null) { - stats.undatedLines += 1; - return; - } - if (!timestampInWindow(timestamp, since, until)) { - stats.outsideWindowLines += 1; - return; - } - - selected.push(line.endsWith("\n") ? line : `${line}\n`); - stats.selectedLines += 1; - updateStatsTimestamps(stats, timestamp); - if (kind === "botEvents") { - summarizeBotEvent(record, summary, timestamp); - } else { - summarizeLaunch(record, summary, timestamp); - } - }); - if (!found) { - return null; - } - - return { stats, text: selected.join("") }; -} - -async function filterStderrSource(path, sourceName, since, until, dependencies) { - const selected = []; - const stats = emptySourceStats(); - const undatedTail = []; - let selectedSinceLastTimestamp = false; - - const found = await processSourceLines(path, sourceName, dependencies, (line, lineNumber) => { - if (line.trim() === "") { - stats.emptyLines += 1; - return; - } - stats.totalLines += 1; - const timestamp = parseTextTimestamp(line); - if (timestamp === null) { - stats.undatedLines += 1; - rememberUndatedTail(undatedTail, { line }, stderrUndatedLineLimit); - if (selectedSinceLastTimestamp) { - appendSelectedStderrLine(selected, stats, line); - stats.selectedUndatedLines += 1; - } - return; - } - stats.timestampedLines += 1; - selectedSinceLastTimestamp = false; - if (!timestampInWindow(timestamp, since, until)) { - stats.outsideWindowLines += 1; - return; - } - - appendSelectedStderrLine(selected, stats, line); - selectedSinceLastTimestamp = true; - updateStatsTimestamps(stats, timestamp); - }); - if (!found) { - return null; - } - - if (selected.length === 0 && stats.undatedLines > 0 && stats.timestampedLines === 0) { - for (const { line } of undatedTail) { - appendSelectedStderrLine(selected, stats, line); - } - stats.selectedUndatedLines = undatedTail.length; - stats.undatedTailIncluded = true; - stats.undatedTailLimit = stderrUndatedLineLimit; - } - - return { stats, text: selected.join("") }; -} - -function emptySourceStats() { - return { - emptyLines: 0, - firstTimestamp: null, - lastTimestamp: null, - malformedLines: 0, - outsideWindowLines: 0, - selectedLines: 0, - selectedUndatedLines: 0, - timestampedLines: 0, - totalLines: 0, - undatedTailIncluded: false, - undatedTailLimit: null, - undatedLines: 0, - }; -} - -function rememberUndatedTail(tail, entry, limit) { - tail.push(entry); - if (tail.length > limit) { - tail.shift(); - } -} - -function appendSelectedStderrLine(selected, stats, line) { - selected.push(line.endsWith("\n") ? line : `${line}\n`); - stats.selectedLines += 1; -} - -function stripLineEnding(line) { - const withoutNewline = line.endsWith("\n") ? line.slice(0, -1) : line; - return withoutNewline.endsWith("\r") ? withoutNewline.slice(0, -1) : withoutNewline; -} - -function parseRecordTimestamp(timestamp) { - if (typeof timestamp !== "string") { - return null; - } - const parsed = new Date(timestamp); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -function parseTextTimestamp(line) { - const match = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})/u.exec(line); - if (match === null) { - return null; - } - const parsed = new Date(match[0]); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -function timestampInWindow(timestamp, since, until) { - const value = timestamp.getTime(); - return since.getTime() <= value && value <= until.getTime(); -} - -function updateStatsTimestamps(stats, timestamp) { - const iso = timestamp.toISOString(); - if (stats.firstTimestamp === null || iso < stats.firstTimestamp) { - stats.firstTimestamp = iso; - } - if (stats.lastTimestamp === null || iso > stats.lastTimestamp) { - stats.lastTimestamp = iso; - } -} - -function summarizeBotEvent(record, summary, timestamp) { - if (record?.app !== "bot" || typeof record.type !== "string" || !record.type.startsWith("bot.")) { - return; - } - - increment(summary.botEvents.countsByType, record.type); - updateSummaryTimestamps(summary.botEvents, timestamp); - if (typeof record.outcome === "string" && typeof record.txHash === "string") { - addGroupedUnique(summary.botEvents.txHashesByOutcome, record.outcome, record.txHash); - } - if (record.type === "bot.decision.skipped") { - increment(summary.botEvents.skipReasons, stringReason(record.reason)); - } - if (record.type === "bot.transaction.failed" || record.type === "bot.iteration.failed") { - increment(summary.botEvents.failureReasons, failureReason(record)); - } -} - -function summarizeLaunch(record, summary, timestamp) { - if (record?.app !== "bot-launcher" || typeof record.type !== "string") { - return; - } - - increment(summary.launches.countsByType, record.type); - updateSummaryTimestamps(summary.launches, timestamp); - if (record.status !== undefined && record.status !== null) { - increment(summary.launches.exitCodes, String(record.status)); - } - if (record.signal !== undefined && record.signal !== null) { - increment(summary.launches.signals, String(record.signal)); - } -} - -function updateSummaryTimestamps(summary, timestamp) { - const iso = timestamp.toISOString(); - if (summary.firstTimestamp === null || iso < summary.firstTimestamp) { - summary.firstTimestamp = iso; - } - if (summary.lastTimestamp === null || iso > summary.lastTimestamp) { - summary.lastTimestamp = iso; - } -} - -function increment(counts, key) { - counts[key] = (counts[key] ?? 0) + 1; -} - -function addGroupedUnique(groups, key, value) { - groups[key] ??= []; - if (!groups[key].includes(value)) { - groups[key].push(value); - } -} - -function stringReason(value) { - return typeof value === "string" && value !== "" ? value : ""; -} - -function failureReason(record) { - if (record.type === "bot.iteration.failed" && record.retryBudgetExhausted === true) { - return "retry_budget_exhausted"; - } - if (typeof record.outcome === "string" && record.outcome !== "") { - return record.outcome; - } - if (typeof record.reason === "string" && record.reason !== "") { - return record.reason; - } - if (typeof record.error === "string" && record.error !== "") { - return record.error; - } - if (typeof record.error === "object" && record.error !== null) { - if (typeof record.error.message === "string" && record.error.message !== "") { - return record.error.message; - } - if (typeof record.error.name === "string" && record.error.name !== "") { - return record.error.name; - } - } - return ""; -} - -async function buildVersionMetadata(root, dependencies) { - const [rootPackage, botPackage] = await Promise.all([ - readPackage(join(root, "package.json")), - readPackage(join(root, "apps/bot/package.json")), - ]); - return { - script: { - name: "ickb-bot-collect-incident.mjs", - version: scriptVersion, - }, - nodeVersion: process.version, - package: { - packageManager: rootPackage?.packageManager ?? null, - private: rootPackage?.private === true, - }, - botPackage: botPackage === null - ? null - : { - name: typeof botPackage.name === "string" ? botPackage.name : null, - version: typeof botPackage.version === "string" ? botPackage.version : null, - }, - gitCommit: readGitCommit(root, dependencies), - }; -} - -async function readPackage(path) { - try { - return JSON.parse(await readFile(path, "utf8")); - } catch { - return null; - } -} - -function readGitCommit(root, dependencies) { - try { - const spawn = dependencies.spawnSync ?? spawnSync; - const result = spawn("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8", timeout: 5_000 }); - if (result.error !== undefined || result.status !== 0) { - return null; - } - const commit = result.stdout.trim(); - return commit === "" ? null : commit; - } catch { - return null; - } -} - -function captureSystemd(network, since, until, dependencies) { - if (!supportedNetworks.has(network)) { - return { - outputs: new Map(), - summary: { included: false, reason: "network unavailable for systemd unit derivation" }, - }; - } - - const unit = `ickb-bot-${network}.service`; - const commands = [ - { args: ["status", unit, "--no-pager", "--lines=0"], file: "systemd.status.txt", name: "systemctl status" }, - { args: ["cat", unit, "--no-pager"], file: "systemd.unit.txt", name: "systemctl cat" }, - { - args: [ - "-u", - unit, - "--since", - since.toISOString(), - "--until", - until.toISOString(), - "-n", - "200", - "--no-pager", - "--output", - "short-iso", - ], - command: "journalctl", - file: "systemd.journal.txt", - note: "last 200 entries inside the requested time window", - name: "journalctl", - }, - ]; - const outputs = new Map(); - const results = []; - const spawn = dependencies.spawnSync ?? spawnSync; - - for (const command of commands) { - const executable = command.command ?? "systemctl"; - const result = spawn(executable, command.args, { - encoding: "utf8", - maxBuffer: 1_000_000, - timeout: 5_000, - }); - const text = [result.stdout, result.stderr].filter(Boolean).join(result.stdout && result.stderr ? "\n" : ""); - results.push({ - command: executable, - args: command.args, - file: command.file, - name: command.name, - note: command.note ?? null, - signal: result.signal ?? null, - status: result.status ?? null, - error: result.error === undefined ? null : publicErrorMessage(result.error), - captured: text !== "", - }); - if (text !== "") { - outputs.set(command.file, text.endsWith("\n") ? text : `${text}\n`); - } - } - - return { - outputs, - summary: { - included: outputs.size > 0, - unit, - results, - }, - }; -} - -function buildIncidentId(createdAt, network) { - const stamp = createdAt.toISOString().replace(/[-:.]/gu, ""); - return `${stamp}-${network ?? "custom"}-${process.pid.toString(36)}-${randomBytes(3).toString("hex")}`; -} - -function shellQuote(value) { - return `'${value.replace(/'/gu, `'"'"'`)}'`; -} - -function incidentReadme(summary) { - return [ - `Incident: ${summary.incidentId}`, - `Window: ${summary.window.since} to ${summary.window.until}`, - `Log directory: ${summary.logDir}`, - "", - "Files are source-separated: bot.events.ndjson, bot.stderr.log, and launches.ndjson are never merged.", - "summary.json contains event counts, transaction outcomes, skip/failure reasons, exit codes, and source inclusion stats.", - "version.json contains collector, package, Node, and git metadata. Config files and environment dumps are intentionally not included.", - "", - `Compression command: ${summary.compression.command}`, - "", - ].join("\n"); -} - -async function prepareIncidentDirectory(logDir, incidentParent, incidentDir, dependencies) { - await assertRealDirectory(logDir, "log directory", dependencies); - await ensureDirectChildDirectory(incidentParent, "incident directory parent", dependencies); - await assertRealDirectory(incidentParent, "incident directory parent", dependencies); - await (dependencies.mkdir ?? mkdir)(incidentDir, { mode: 0o700 }); - await assertRealDirectory(incidentDir, "incident directory", dependencies); -} - -async function ensureDirectChildDirectory(path, label, dependencies) { - try { - await (dependencies.mkdir ?? mkdir)(path, { mode: 0o700 }); - } catch (error) { - if (error?.code !== "EEXIST") { - throw error; - } - } - const stat = await (dependencies.lstat ?? lstat)(path); - if (stat.isSymbolicLink()) { - throw new Error(`Refusing symlinked ${label}: ${path}`); - } - if (!stat.isDirectory()) { - throw new Error(`${label} is not a directory: ${path}`); - } -} - -async function assertRealDirectory(path, label, dependencies) { - if (path === "") { - throw new Error(`Empty ${label} path`); - } - await assertNoSymlinkedPathComponents(path, label, dependencies); - const stat = await (dependencies.lstat ?? lstat)(path); - if (stat.isSymbolicLink()) { - throw new Error(`Refusing symlinked ${label}: ${path}`); - } - if (!stat.isDirectory()) { - throw new Error(`${label} is not a directory: ${path}`); - } - const resolved = await (dependencies.realpath ?? realpath)(path); - if (resolved !== path) { - throw new Error(`Resolved ${label} crosses a symlink: ${path}`); - } -} - -async function assertNoSymlinkedPathComponents(path, label, dependencies) { - const parsed = parse(path); - let current = parsed.root; - const parts = relative(parsed.root, path).split(sep).filter(Boolean); - for (const part of parts) { - current = join(current, part); - let stat; - try { - stat = await (dependencies.lstat ?? lstat)(current); - } catch (error) { - if (error?.code === "ENOENT") { - return; - } - throw error; - } - if (stat.isSymbolicLink()) { - throw new Error(`Refusing symlinked ${label} path: ${current}`); - } - } -} - -async function writeBundleFile(path, text, dependencies) { - const handle = await (dependencies.open ?? open)(path, writeNewFileFlags, 0o600); - try { - await handle.writeFile(text, "utf8"); - await handle.chmod(0o600); - } finally { - await handle.close().catch(() => undefined); - } -} - -function publicErrorMessage(error) { - return error instanceof Error ? error.message : "Unknown incident collector error"; -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - process.exit(await main(process.argv.slice(2))); -} diff --git a/scripts/ickb-bot-collect-incident.test.mjs b/scripts/ickb-bot-collect-incident.test.mjs deleted file mode 100644 index 1cd0a9e..0000000 --- a/scripts/ickb-bot-collect-incident.test.mjs +++ /dev/null @@ -1,574 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdir, mkdtemp, open, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -import { collectIncident, parseArgs, parseTimeBound } from "./ickb-bot-collect-incident.mjs"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const collector = join(rootDir, "scripts", "ickb-bot-collect-incident.mjs"); -const canaryPrivateKey = `0x${"42".repeat(32)}`; - -test("parses collector arguments and relative time bounds", () => { - assert.deepEqual(parseArgs([ - "--log-root", - "var/logs", - "--network", - "testnet", - "--since", - "2h", - "--until", - "now", - "--no-systemd", - ]), { - includeSystemd: false, - logRoot: "var/logs", - network: "testnet", - since: "2h", - until: "now", - }); - - const now = new Date("2026-05-25T12:00:00.000Z"); - assert.equal(parseTimeBound("now", now).toISOString(), "2026-05-25T12:00:00.000Z"); - assert.equal(parseTimeBound("2h", now).toISOString(), "2026-05-25T10:00:00.000Z"); - assert.equal(parseTimeBound("2026-05-25T11:00:00Z", now).toISOString(), "2026-05-25T11:00:00.000Z"); - assert.throws(() => parseArgs(["--network", "devnet", "--since", "now", "--until", "now"]), /Invalid network/u); - assert.throws(() => parseArgs(["--network", "testnet", "--log-dir", "log/bot/testnet", "--since", "now", "--until", "now"]), /exactly one/u); - assert.throws(() => parseArgs(["--network", "testnet", "--until", "now"]), /Missing required --since/u); -}); - -test("collects a time-bounded source-separated incident bundle with summary counts", async () => { - const dir = await tempDir(); - try { - const logDir = await writeFixtureLogs(dir, { - events: [ - botEvent("2026-05-25T09:59:59.000Z", "bot.transaction.sent", { outcome: "broadcasted", txHash: txHash("01") }), - botEvent("2026-05-25T10:00:00.000Z", "bot.run.started", { bounded: true }), - "not-json\n", - JSON.stringify({ app: "execution", timestamp: "2026-05-25T10:05:00.000Z", message: "non-bot stdout" }) + "\n", - botEvent("2026-05-25T10:10:00.000Z", "bot.decision.skipped", { reason: "no_actions" }), - botEvent("2026-05-25T10:20:00.000Z", "bot.transaction.sent", { outcome: "broadcasted", txHash: txHash("02") }), - botEvent("2026-05-25T10:21:00.000Z", "bot.transaction.failed", { outcome: "timeout_after_broadcast", txHash: txHash("02") }), - botEvent("2026-05-25T11:00:00.000Z", "bot.iteration.failed", { error: { message: "fetch failed" }, retryable: true, terminal: false }), - botEvent("2026-05-25T10:59:59.500Z", "bot.iteration.failed", { - error: { message: "fetch failed" }, - retryable: true, - terminal: true, - retryableAttempts: 3, - maxRetryableAttempts: 3, - retryBudgetExhausted: true, - }), - botEvent("2026-05-25T11:00:01.000Z", "bot.transaction.committed", { outcome: "committed", txHash: txHash("03") }), - ], - launches: [ - launch("2026-05-25T09:00:00.000Z", "launcher.started", { status: null }), - launch("2026-05-25T10:00:00.000Z", "launcher.started", { status: null }), - launch("2026-05-25T10:30:00.000Z", "launcher.child.exited", { status: 2 }), - "{bad-json\n", - launch("2026-05-25T11:00:01.000Z", "launcher.child.exited", { status: 0 }), - ], - stderr: [ - "2026-05-25T09:00:00.000Z before window\n", - "2026-05-25T10:15:00.000Z runtime warning\n", - " at worker (bot.js:10:1)\n", - "undated warning\n", - "2026-05-25T11:00:01.000Z after window\n", - "outside continuation\n", - ], - }); - - const result = await collectIncident({ - argv: [ - "--log-root", - dir, - "--network", - "testnet", - "--since", - "2026-05-25T10:00:00.000Z", - "--until", - "2026-05-25T11:00:00.000Z", - "--no-systemd", - ], - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }); - - assert.match(result.incidentDir, /\/incidents\/20260525T120000000Z-testnet-/u); - assert.equal(await mode(result.incidentDir), 0o700); - assert.equal(await mode(join(result.incidentDir, "summary.json")), 0o600); - assert.equal(await readFile(join(result.incidentDir, "bot.stderr.log"), "utf8"), [ - "2026-05-25T10:15:00.000Z runtime warning\n", - " at worker (bot.js:10:1)\n", - "undated warning\n", - ].join("")); - - const eventBundle = await readFile(join(result.incidentDir, "bot.events.ndjson"), "utf8"); - assert.match(eventBundle, /bot\.run\.started/u); - assert.match(eventBundle, /non-bot stdout/u); - assert.doesNotMatch(eventBundle, /09:59:59|11:00:01|not-json/u); - - const summary = JSON.parse(await readFile(join(result.incidentDir, "summary.json"), "utf8")); - assert.equal(summary.logDir, logDir); - assert.deepEqual(summary.botEvents.countsByType, { - "bot.decision.skipped": 1, - "bot.iteration.failed": 2, - "bot.run.started": 1, - "bot.transaction.failed": 1, - "bot.transaction.sent": 1, - }); - assert.deepEqual(summary.botEvents.txHashesByOutcome, { - broadcasted: [txHash("02")], - timeout_after_broadcast: [txHash("02")], - }); - assert.deepEqual(summary.botEvents.skipReasons, { no_actions: 1 }); - assert.deepEqual(summary.botEvents.failureReasons, { "fetch failed": 1, retry_budget_exhausted: 1, timeout_after_broadcast: 1 }); - assert.deepEqual(summary.launches.exitCodes, { 2: 1 }); - assert.equal(summary.sources["bot.events.ndjson"].malformedLines, 1); - assert.equal(summary.sources["bot.events.ndjson"].selectedLines, 7); - assert.equal(summary.sources["bot.stderr.log"].undatedLines, 3); - assert.equal(summary.sources["bot.stderr.log"].selectedUndatedLines, 2); - assert.equal(summary.systemd.included, false); - assert.match(summary.compression.command, /tar -czf/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("includes a bounded tail when stderr has no timestamps", async () => { - const dir = await tempDir(); - try { - await writeFixtureLogs(dir, { - events: [botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")], - launches: [], - stderr: Array.from({ length: 205 }, (_, index) => `stack line ${index.toString()}\n`), - }); - - const result = await collectIncident({ - argv: commonArgs(dir), - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }); - - const stderr = await readFile(join(result.incidentDir, "bot.stderr.log"), "utf8"); - assert.doesNotMatch(stderr, /^stack line 0$/mu); - assert.match(stderr, /^stack line 5$/mu); - assert.match(stderr, /^stack line 204$/mu); - - const summary = JSON.parse(await readFile(join(result.incidentDir, "summary.json"), "utf8")); - assert.equal(summary.sources["bot.stderr.log"].selectedLines, 200); - assert.equal(summary.sources["bot.stderr.log"].selectedUndatedLines, 200); - assert.equal(summary.sources["bot.stderr.log"].undatedLines, 205); - assert.equal(summary.sources["bot.stderr.log"].undatedTailIncluded, true); - assert.equal(summary.sources["bot.stderr.log"].undatedTailLimit, 200); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("decodes source log UTF-8 across chunk boundaries", async () => { - const dir = await tempDir(); - try { - const marker = "snowman ☃"; - const logDir = await writeFixtureLogs(dir, { - events: [botEvent("2026-05-25T10:00:00.000Z", "bot.run.started", { marker })], - launches: [], - stderr: [], - }); - const eventPath = join(logDir, "bot.events.ndjson"); - const bytes = Buffer.from(await readFile(eventPath, "utf8")); - const splitAt = bytes.indexOf(Buffer.from("☃")) + 1; - - const result = await collectIncident({ - argv: commonArgs(dir), - dependencies: { - open(path, flags, mode) { - if (path === eventPath && typeof flags === "number") { - return splitReadHandle(path, [bytes.subarray(0, splitAt), bytes.subarray(splitAt)]); - } - return open(path, flags, mode); - }, - }, - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }); - - const events = await readFile(join(result.incidentDir, "bot.events.ndjson"), "utf8"); - assert.match(events, new RegExp(marker, "u")); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("does not use undated stderr tail fallback when timestamped stderr is outside the window", async () => { - const dir = await tempDir(); - try { - await writeFixtureLogs(dir, { - events: [botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")], - launches: [], - stderr: [ - "2026-05-25T09:00:00.000Z before window\n", - "outside continuation\n", - ], - }); - - const result = await collectIncident({ - argv: commonArgs(dir), - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }); - - assert.equal(await readFile(join(result.incidentDir, "bot.stderr.log"), "utf8"), ""); - const summary = JSON.parse(await readFile(join(result.incidentDir, "summary.json"), "utf8")); - assert.equal(summary.sources["bot.stderr.log"].timestampedLines, 1); - assert.equal(summary.sources["bot.stderr.log"].undatedLines, 1); - assert.equal(summary.sources["bot.stderr.log"].selectedLines, 0); - assert.equal(summary.sources["bot.stderr.log"].undatedTailIncluded, false); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("accepts explicit contained log directory and environment log root", async () => { - const dir = await tempDir(); - try { - const logDir = await writeFixtureLogs(dir, { - events: [botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")], - launches: [launch("2026-05-25T10:00:01.000Z", "launcher.child.exited", { status: 0 })], - stderr: ["2026-05-25T10:00:02.000Z ok\n"], - }); - - const result = await collectIncident({ - argv: [ - "--log-dir", - logDir, - "--since", - "2026-05-25T10:00:00.000Z", - "--until", - "2026-05-25T10:00:02.000Z", - "--no-systemd", - ], - envLogRoot: dir, - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }); - const summary = JSON.parse(await readFile(join(result.incidentDir, "summary.json"), "utf8")); - assert.equal(summary.logRoot, dir); - assert.equal(summary.logRootSource, "env:ICKB_BOT_LOG_ROOT"); - assert.equal(summary.logDir, logDir); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("refuses log directories outside the resolved log root", async () => { - const dir = await tempDir(); - try { - await assert.rejects(() => collectIncident({ - argv: [ - "--log-root", - join(dir, "root"), - "--log-dir", - join(dir, "outside"), - "--since", - "2026-05-25T10:00:00.000Z", - "--until", - "2026-05-25T11:00:00.000Z", - "--no-systemd", - ], - root: rootDir, - }), /inside the resolved log root/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("refuses symlinked log roots and log directories", async () => { - const dir = await tempDir(); - try { - await writeFixtureLogs(dir, { - events: [botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")], - launches: [], - stderr: [], - }); - const linkedRoot = join(dir, "linked-root"); - await symlink(dir, linkedRoot, "dir"); - await assert.rejects(() => collectIncident({ - argv: commonArgs(linkedRoot), - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }), /symlinked log root path/u); - - const linkedLogDir = join(dir, "linked-log-dir"); - await symlink(join(dir, "bot", "testnet"), linkedLogDir, "dir"); - await assert.rejects(() => collectIncident({ - argv: [ - "--log-root", - dir, - "--log-dir", - linkedLogDir, - "--since", - "2026-05-25T10:00:00.000Z", - "--until", - "2026-05-25T11:00:00.000Z", - "--no-systemd", - ], - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }), /symlinked log directory path/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("refuses symlinked source logs and incident directory parents", async () => { - const dir = await tempDir(); - try { - const logDir = await writeFixtureLogs(dir, { - events: [botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")], - launches: [], - stderr: [], - }); - await rm(join(logDir, "bot.events.ndjson")); - await writeFile(join(dir, "target-events"), botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")); - await symlink(join(dir, "target-events"), join(logDir, "bot.events.ndjson")); - - await assert.rejects(() => collectIncident({ - argv: commonArgs(dir), - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }), /symlinked source log file/u); - - await rm(join(logDir, "bot.events.ndjson")); - await writeFile(join(logDir, "bot.events.ndjson"), botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")); - await symlink(join(dir, "target-incidents"), join(logDir, "incidents"), "dir"); - - await assert.rejects(() => collectIncident({ - argv: commonArgs(dir), - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }), /symlinked incident directory parent/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("includes selected producer log text without masking bundles", async () => { - const dir = await tempDir(); - try { - await writeFixtureLogs(dir, { - events: [ - botEvent("2026-05-25T10:00:00.000Z", "bot.run.started", { - error: "public diagnostic", - }), - ], - launches: [], - stderr: [], - }); - - const result = await collectIncident({ - argv: commonArgs(dir), - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }); - const events = await readFile(join(result.incidentDir, "bot.events.ndjson"), "utf8"); - assert.match(events, /public diagnostic/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("produced bundles do not contain configured canary secrets", async () => { - const dir = await tempDir(); - try { - await writeFixtureLogs(dir, { - events: [botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")], - launches: [launch("2026-05-25T10:00:00.000Z", "launcher.started")], - stderr: ["2026-05-25T10:00:00.000Z public diagnostic\n"], - }); - - const result = spawnSync(process.execPath, [collector, ...commonArgs(dir)], { - cwd: rootDir, - encoding: "utf8", - env: { - ...process.env, - ICKB_TESTNET_BOT_PRIVATE_KEY: canaryPrivateKey, - }, - }); - assert.equal(result.status, 0, result.stderr); - assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, new RegExp(canaryPrivateKey, "u")); - const incidentDir = /^Incident bundle directory: (.+)$/mu.exec(result.stdout)?.[1]; - assert.ok(incidentDir); - - const produced = [ - "bot.events.ndjson", - "bot.stderr.log", - "launches.ndjson", - "README.txt", - "summary.json", - "version.json", - ]; - const bundleText = (await Promise.all( - produced.map((name) => readFile(join(incidentDir, name), "utf8")), - )).join("\n"); - assert.doesNotMatch(bundleText, new RegExp(canaryPrivateKey, "u")); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("captures systemd metadata into separate files when commands are available", async () => { - const dir = await tempDir(); - try { - await writeFixtureLogs(dir, { - events: [botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")], - launches: [], - stderr: [], - }); - const calls = []; - const result = await collectIncident({ - argv: [ - "--log-root", - dir, - "--network", - "testnet", - "--since", - "2026-05-25T10:00:00.000Z", - "--until", - "2026-05-25T10:01:00.000Z", - ], - dependencies: { - spawnSync(command, args) { - calls.push([command, args]); - return { signal: null, status: 0, stderr: "", stdout: `${command} ${args.join(" ")}\n` }; - }, - }, - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }); - - assert.deepEqual(calls.map(([command]) => command), ["git", "systemctl", "systemctl", "journalctl"]); - assert.deepEqual(calls[1], ["systemctl", ["status", "ickb-bot-testnet.service", "--no-pager", "--lines=0"]]); - assert.match(await readFile(join(result.incidentDir, "systemd.status.txt"), "utf8"), /ickb-bot-testnet\.service/u); - const summary = JSON.parse(await readFile(join(result.incidentDir, "summary.json"), "utf8")); - assert.equal(summary.systemd.included, true); - assert.equal(summary.systemd.unit, "ickb-bot-testnet.service"); - assert.equal(summary.systemd.results.find((entry) => entry.file === "systemd.journal.txt").note, "last 200 entries inside the requested time window"); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("keeps collecting incidents when git metadata lookup fails", async () => { - const dir = await tempDir(); - try { - await writeFixtureLogs(dir, { - events: [botEvent("2026-05-25T10:00:00.000Z", "bot.run.started")], - launches: [], - stderr: [], - }); - - const result = await collectIncident({ - argv: commonArgs(dir), - dependencies: { - spawnSync() { - throw new Error("git unavailable"); - }, - }, - now: () => new Date("2026-05-25T12:00:00.000Z"), - root: rootDir, - }); - - const version = JSON.parse(await readFile(join(result.incidentDir, "version.json"), "utf8")); - assert.equal(version.gitCommit, null); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -async function tempDir() { - return mkdtemp(join(tmpdir(), "ickb-bot-incident-")); -} - -async function writeFixtureLogs(root, { events, launches, stderr }) { - const logDir = join(root, "bot", "testnet"); - await mkdirp(logDir); - await writeFile(join(logDir, "bot.events.ndjson"), events.join(""), { mode: 0o600 }); - await writeFile(join(logDir, "launches.ndjson"), launches.join(""), { mode: 0o600 }); - await writeFile(join(logDir, "bot.stderr.log"), stderr.join(""), { mode: 0o600 }); - return logDir; -} - -async function mkdirp(dir) { - await mkdir(dir, { recursive: true, mode: 0o700 }); -} - -function botEvent(timestamp, type, fields = {}) { - return `${JSON.stringify({ - version: 1, - app: "bot", - chain: "testnet", - runId: "run-fixture", - iterationId: 1, - timestamp, - type, - ...fields, - })}\n`; -} - -function launch(timestamp, type, fields = {}) { - return `${JSON.stringify({ - version: 1, - app: "bot-launcher", - timestamp, - type, - status: null, - signal: null, - ...fields, - })}\n`; -} - -function txHash(byte) { - return `0x${byte.repeat(32)}`; -} - -function commonArgs(logRoot) { - return [ - "--log-root", - resolve(logRoot), - "--network", - "testnet", - "--since", - "2026-05-25T10:00:00.000Z", - "--until", - "2026-05-25T11:00:00.000Z", - "--no-systemd", - ]; -} - -async function mode(path) { - return (await stat(path)).mode & 0o777; -} - -async function splitReadHandle(path, chunks) { - return { - close() { - return Promise.resolve(); - }, - readableWebStream() { - return new ReadableStream({ - start(controller) { - for (const chunk of chunks) { - controller.enqueue(chunk); - } - controller.close(); - }, - }); - }, - stat() { - return stat(path); - }, - }; -} diff --git a/scripts/ickb-bot-launcher.mjs b/scripts/ickb-bot-launcher.mjs deleted file mode 100644 index 97c4f16..0000000 --- a/scripts/ickb-bot-launcher.mjs +++ /dev/null @@ -1,455 +0,0 @@ -#!/usr/bin/env node -import { spawn } from "node:child_process"; -import { constants } from "node:fs"; -import { lstat, mkdir, open, readFile, realpath } from "node:fs/promises"; -import { basename, isAbsolute, join, parse, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const supportedNetworks = new Set(["testnet", "mainnet"]); -const noFollow = constants.O_NOFOLLOW ?? 0; -const logFileFlags = constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | noFollow; -const signalNames = ["SIGINT", "SIGTERM", "SIGHUP"]; - -export function usage() { - return `Usage: ickb-bot-launcher.mjs [--log-root PATH] (--network testnet|mainnet | --log-dir PATH) -- [args...]\n`; -} - -export function parseArgs(argv) { - if (argv.includes("-h") || argv.includes("--help")) { - return { help: true }; - } - - const separator = argv.indexOf("--"); - if (separator === -1) { - throw new Error("Missing -- before command"); - } - - const options = argv.slice(0, separator); - const command = argv.slice(separator + 1); - let logRoot; - let network; - let logDir; - - for (let index = 0; index < options.length; index += 1) { - const option = options[index]; - if (option === "--log-root") { - logRoot = requireValue(options, index, option); - index += 1; - } else if (option === "--network") { - network = requireValue(options, index, option); - index += 1; - } else if (option === "--log-dir") { - logDir = requireValue(options, index, option); - index += 1; - } else { - throw new Error(`Unknown option: ${option}`); - } - } - - if (command.length === 0 || command[0] === "") { - throw new Error("Missing child command"); - } - if (network !== undefined && !supportedNetworks.has(network)) { - throw new Error("Invalid network; expected testnet or mainnet"); - } - if ((network === undefined) === (logDir === undefined)) { - throw new Error("Specify exactly one of --network or --log-dir"); - } - - return { - command: command[0], - commandArgs: command.slice(1), - logDir, - logRoot, - network, - }; -} - -export function resolveLauncherPaths({ cliLogRoot, envLogRoot, logDir, network, root }) { - const logRoot = resolveConfiguredPath(cliLogRoot ?? envLogRoot ?? "log", root, "log root"); - const resolvedLogDir = logDir === undefined - ? join(logRoot, "bot", network) - : resolveConfiguredPath(logDir, root, "log directory"); - - assertContained(logRoot, resolvedLogDir, "log directory"); - return { logDir: resolvedLogDir, logRoot }; -} - -export async function runBotLauncher({ - argv = process.argv.slice(2), - env = process.env, - now = () => new Date(), - root = rootDir, - spawnProcess = spawn, - stderr = process.stderr, - stdout = process.stdout, -} = {}) { - let parsed; - try { - parsed = parseArgs(argv); - } catch (error) { - stderr.write(`ickb-bot-launcher: ${publicErrorMessage(error)}\n${usage()}`); - return { status: 1 }; - } - - if (parsed.help) { - stdout.write(usage()); - return { status: 0 }; - } - - const startTime = Date.now(); - let sinks; - let child; - let removeSignalHandlers = () => undefined; - - try { - const paths = resolveLauncherPaths({ - cliLogRoot: parsed.logRoot, - envLogRoot: env.ICKB_BOT_LOG_ROOT, - logDir: parsed.logDir, - network: parsed.network, - root, - }); - await prepareLogDirectory(paths.logRoot); - await prepareLogDirectory(paths.logDir); - await proveResolvedPath(paths.logRoot, "log root"); - await proveResolvedPath(paths.logDir, "log directory"); - - sinks = await openLogSinks(paths.logDir); - const packageInfo = await readBotPackageInfo(root); - const childCommand = safeCommandShape(parsed.command, parsed.commandArgs.length); - child = spawnProcess(parsed.command, parsed.commandArgs, { - cwd: root, - env, - stdio: ["inherit", "pipe", "pipe"], - }); - const childResultPromise = waitForChild(child); - - removeSignalHandlers = forwardSignalsTo(child); - - await sinks.launches.writeLine({ - app: "bot-launcher", - childPid: child.pid ?? null, - command: childCommand, - elapsedMs: 0, - logDir: paths.logDir, - logRoot: paths.logRoot, - network: parsed.network ?? null, - nodeVersion: process.version, - package: packageInfo, - pid: process.pid, - repoRoot: root, - signal: null, - status: null, - timestamp: now().toISOString(), - type: "launcher.started", - version: 1, - }); - - const stdoutCopy = copyBytes(child.stdout, sinks.events, stdout); - const stderrCopy = copyBytes(child.stderr, sinks.stderr, stderr); - const childResult = await childResultPromise; - const copyResult = await settleCopies(stdoutCopy, stderrCopy); - const elapsedMs = Date.now() - startTime; - - await sinks.launches.writeLine({ - app: "bot-launcher", - childPid: child.pid ?? null, - command: childCommand, - elapsedMs, - logDir: paths.logDir, - logRoot: paths.logRoot, - network: parsed.network ?? null, - nodeVersion: process.version, - package: packageInfo, - pid: process.pid, - signal: childResult.signal, - status: childResult.status, - timestamp: now().toISOString(), - type: copyResult === undefined ? "launcher.child.exited" : "launcher.io.failed", - version: 1, - }); - - await closeSinks(sinks); - sinks = undefined; - removeSignalHandlers(); - - if (copyResult !== undefined) { - stderr.write(`ickb-bot-launcher: ${publicErrorMessage(copyResult)}\n`); - return { status: 1 }; - } - if (childResult.error !== undefined) { - stderr.write(`ickb-bot-launcher: Failed to spawn child process: ${publicErrorMessage(childResult.error)}\n`); - return { status: 1 }; - } - if (childResult.signal !== null) { - return { signal: childResult.signal }; - } - return { status: childResult.status ?? 1 }; - } catch (error) { - removeSignalHandlers(); - if (child !== undefined && child.exitCode === null && !child.killed) { - child.kill("SIGTERM"); - } - if (sinks !== undefined) { - await closeSinks(sinks).catch(() => undefined); - } - stderr.write(`ickb-bot-launcher: ${publicErrorMessage(error)}\n`); - return { status: 1 }; - } -} - -function requireValue(argv, index, option) { - const value = argv[index + 1]; - if (value === undefined || value === "" || value.startsWith("--")) { - throw new Error(`Missing value for ${option}`); - } - return value; -} - -function resolveConfiguredPath(value, root, label) { - if (value === "") { - throw new Error(`Empty ${label} path`); - } - return isAbsolute(value) ? resolve(value) : resolve(root, value); -} - -function assertContained(root, candidate, label) { - const relationship = relative(root, candidate); - if (relationship === "" || (relationship !== ".." && !relationship.startsWith(`..${sep}`) && !isAbsolute(relationship))) { - return; - } - throw new Error(`${label} must stay inside the resolved log root`); -} - -async function prepareLogDirectory(directory) { - const parsed = parse(directory); - let current = parsed.root; - await assertDirectory(current); - - const parts = relative(parsed.root, directory).split(sep).filter(Boolean); - for (const part of parts) { - current = join(current, part); - try { - await assertDirectory(current); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - await mkdir(current, { mode: 0o700 }); - await assertDirectory(current); - } - } -} - -async function assertDirectory(path) { - const stat = await lstat(path); - if (stat.isSymbolicLink()) { - throw new Error(`Refusing symlinked log directory path: ${path}`); - } - if (!stat.isDirectory()) { - throw new Error(`Log directory path is not a directory: ${path}`); - } -} - -async function proveResolvedPath(path, label) { - const real = await realpath(path); - if (real !== path) { - throw new Error(`Resolved ${label} crosses a symlink`); - } -} - -async function openLogSinks(logDir) { - return { - events: await openLogSink(join(logDir, "bot.events.ndjson")), - launches: await openLogSink(join(logDir, "launches.ndjson")), - stderr: await openLogSink(join(logDir, "bot.stderr.log")), - }; -} - -async function openLogSink(path) { - try { - const stat = await lstat(path); - if (stat.isSymbolicLink()) { - throw new Error(`Refusing symlinked log file path: ${path}`); - } - if (!stat.isFile()) { - throw new Error(`Log file path is not a regular file: ${path}`); - } - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - - const handle = await open(path, logFileFlags, 0o600); - try { - const stat = await handle.stat(); - if (!stat.isFile()) { - throw new Error(`Log file path is not a regular file: ${path}`); - } - await handle.chmod(0o600); - return new LogSink(handle); - } catch (error) { - await handle.close().catch(() => undefined); - throw error; - } -} - -export class LogSink { - #pending = Promise.resolve(); - - constructor(handle) { - this.handle = handle; - } - - write(chunk) { - const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); - this.#pending = this.#pending.then(() => this.handle.appendFile(data)); - return this.#pending; - } - - writeLine(record) { - return this.write(`${JSON.stringify(record)}\n`); - } - - async close() { - try { - await this.#pending; - } finally { - await this.handle.close(); - } - } -} - -export function copyBytes(readable, fileSink, tee) { - if (readable === null) { - return Promise.resolve(); - } - - let pending = Promise.resolve(); - return new Promise((resolvePromise, rejectPromise) => { - readable.on("data", (chunk) => { - readable.pause(); - pending = pending - .then(async () => { - await fileSink.write(chunk); - await writeToStream(tee, chunk); - }) - .then( - () => readable.resume(), - (error) => { - rejectPromise(error); - readable.destroy(error); - }, - ); - }); - readable.once("end", () => pending.then(resolvePromise, rejectPromise)); - readable.once("error", rejectPromise); - }); -} - -function writeToStream(stream, chunk) { - return new Promise((resolvePromise) => { - let settled = false; - const finish = () => { - if (!settled) { - settled = true; - setImmediate(() => { - stream.off("error", finish); - resolvePromise(); - }); - } - }; - - stream.once("error", finish); - try { - stream.write(chunk, finish); - } catch { - finish(); - } - }); -} - -function waitForChild(child) { - return new Promise((resolvePromise) => { - let settled = false; - const settle = (result) => { - if (!settled) { - settled = true; - resolvePromise(result); - } - }; - child.once("error", (error) => settle({ error, signal: null, status: 1 })); - child.once("close", (status, signal) => settle({ signal, status })); - }); -} - -async function settleCopies(...copies) { - const results = await Promise.allSettled(copies); - const failed = results.find((result) => result.status === "rejected"); - return failed?.reason; -} - -function forwardSignalsTo(child) { - const handlers = signalNames.map((signal) => { - const handler = () => { - if (child.exitCode === null && !child.killed) { - child.kill(signal); - } - }; - process.once(signal, handler); - return [signal, handler]; - }); - return () => { - for (const [signal, handler] of handlers) { - process.off(signal, handler); - } - }; -} - -function safeCommandShape(command, argumentCount) { - return { - argumentCount, - arguments: Array.from({ length: argumentCount }, (_, index) => ({ - index, - value: "", - })), - executable: basename(command), - }; -} - -async function readBotPackageInfo(root) { - try { - const text = await readFile(join(root, "apps/bot/package.json"), "utf8"); - const parsed = JSON.parse(text); - return { - name: typeof parsed.name === "string" ? parsed.name : null, - version: typeof parsed.version === "string" ? parsed.version : null, - }; - } catch { - return null; - } -} - -async function closeSinks(sinks) { - await Promise.all([ - sinks.events.close(), - sinks.launches.close(), - sinks.stderr.close(), - ]); -} - -function publicErrorMessage(error) { - return error instanceof Error ? error.message : "Unknown launcher error"; -} - -if (fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "")) { - const result = await runBotLauncher(); - if (result.signal !== undefined) { - process.kill(process.pid, result.signal); - } else { - process.exit(result.status); - } -} diff --git a/scripts/ickb-bot-launcher.test.mjs b/scripts/ickb-bot-launcher.test.mjs deleted file mode 100644 index ee968d3..0000000 --- a/scripts/ickb-bot-launcher.test.mjs +++ /dev/null @@ -1,393 +0,0 @@ -import assert from "node:assert/strict"; -import { EventEmitter } from "node:events"; -import { spawnSync } from "node:child_process"; -import { PassThrough, Writable } from "node:stream"; -import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -import { LogSink, copyBytes, parseArgs, resolveLauncherPaths, runBotLauncher } from "./ickb-bot-launcher.mjs"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const launcher = join(rootDir, "scripts", "ickb-bot-launcher.mjs"); -const canaryPrivateKey = `0x${"42".repeat(32)}`; - -test("parses launcher arguments", () => { - assert.deepEqual(parseArgs([ - "--log-root", - "var/bot-log", - "--network", - "testnet", - "--", - process.execPath, - "apps/bot/dist/index.js", - ]), { - command: process.execPath, - commandArgs: ["apps/bot/dist/index.js"], - logDir: undefined, - logRoot: "var/bot-log", - network: "testnet", - }); - - assert.throws(() => parseArgs(["--network", "devnet", "--", process.execPath]), /Invalid network/u); - assert.throws(() => parseArgs(["--log-root", "--network", "testnet", "--", process.execPath]), /Missing value for --log-root/u); - assert.throws(() => parseArgs(["--network", "testnet", "--log-dir", "log/bot/testnet", "--", process.execPath]), /exactly one/u); - assert.throws(() => parseArgs(["--network", "testnet"]), /Missing --/u); -}); - -test("resolves explicit log root before runtime env and keeps log dirs contained", () => { - const root = resolve("/tmp/ickb-stack"); - assert.deepEqual(resolveLauncherPaths({ - cliLogRoot: "cli-log", - envLogRoot: "env-log", - network: "mainnet", - root, - }), { - logDir: join(root, "cli-log", "bot", "mainnet"), - logRoot: join(root, "cli-log"), - }); - - assert.deepEqual(resolveLauncherPaths({ - envLogRoot: "env-log", - network: "testnet", - root, - }), { - logDir: join(root, "env-log", "bot", "testnet"), - logRoot: join(root, "env-log"), - }); - - assert.throws(() => resolveLauncherPaths({ - cliLogRoot: join(root, "log"), - logDir: join(root, "outside"), - root, - }), /inside the resolved log root/u); -}); - -test("writes stdout, stderr, and launch metadata to separate append-only files", async () => { - const dir = await tempDir(); - try { - const first = runLauncher(dir, ["--network", "testnet"], [ - "-e", - "process.stdout.write('event-1\\n'); process.stderr.write('stderr-1\\n');", - ]); - assert.equal(first.status, 0, first.stderr); - assert.equal(first.stdout, "event-1\n"); - assert.equal(first.stderr, "stderr-1\n"); - - const second = runLauncher(dir, ["--network", "testnet"], [ - "-e", - "process.stdout.write('event-2\\n'); process.stderr.write('stderr-2\\n');", - ]); - assert.equal(second.status, 0, second.stderr); - - const logDir = join(dir, "bot", "testnet"); - assert.equal(await readFile(join(logDir, "bot.events.ndjson"), "utf8"), "event-1\nevent-2\n"); - assert.equal(await readFile(join(logDir, "bot.stderr.log"), "utf8"), "stderr-1\nstderr-2\n"); - assert.equal((await stat(join(logDir, "bot.events.ndjson"))).mode & 0o777, 0o600); - assert.equal((await stat(join(logDir, "bot.stderr.log"))).mode & 0o777, 0o600); - assert.equal((await stat(join(logDir, "launches.ndjson"))).mode & 0o777, 0o600); - - const launches = await readLaunches(logDir); - assert.equal(launches.length, 4); - assert.equal(launches[0].type, "launcher.started"); - assert.equal(launches[0].network, "testnet"); - assert.equal(launches[0].status, null); - assert.equal(launches[0].signal, null); - assert.equal(launches[0].elapsedMs, 0); - assert.equal(launches[0].command.executable, basenameOfNode()); - assert.equal(launches[0].command.argumentCount, 2); - assert.deepEqual(launches[0].command.arguments, [ - { index: 0, value: "" }, - { index: 1, value: "" }, - ]); - assert.equal(launches[1].type, "launcher.child.exited"); - assert.equal(launches[1].status, 0); - assert.equal(launches[1].signal, null); - assert.equal(launches[1].logRoot, dir); - assert.equal(launches[1].logDir, logDir); - assert.equal(launches[1].package.name, "@ickb/bot"); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("copies stdout and stderr bytes without rewriting", async () => { - const dir = await tempDir(); - try { - const result = runLauncherBuffer(dir, ["--network", "testnet"], [ - "-e", - "process.stdout.write(Buffer.from([0, 255, 10])); process.stderr.write(Buffer.from([1, 254, 10]));", - ]); - assert.equal(result.status, 0, result.stderr.toString("utf8")); - assert.deepEqual(result.stdout, Buffer.from([0, 255, 10])); - assert.deepEqual(result.stderr, Buffer.from([1, 254, 10])); - - const logDir = join(dir, "bot", "testnet"); - assert.deepEqual(await readFile(join(logDir, "bot.events.ndjson")), Buffer.from([0, 255, 10])); - assert.deepEqual(await readFile(join(logDir, "bot.stderr.log")), Buffer.from([1, 254, 10])); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("refuses log directories that escape the resolved root", async () => { - const dir = await tempDir(); - try { - const result = runLauncher(dir, ["--log-dir", join(dir, "..", "escaped")], ["-e", ""]); - assert.equal(result.status, 1); - assert.match(result.stderr, /inside the resolved log root/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("refuses symlinks in the log root, log path parents, and log files", async () => { - const dir = await tempDir(); - try { - const symlinkRoot = join(dir, "root-link"); - await symlink(dir, symlinkRoot, "dir"); - const symlinkRootResult = runLauncher(symlinkRoot, ["--network", "testnet"], ["-e", ""]); - assert.equal(symlinkRootResult.status, 1); - assert.match(symlinkRootResult.stderr, /symlink/u); - - const botParent = join(dir, "bot"); - await symlink(join(dir, "target"), botParent, "dir"); - const symlinkParentResult = runLauncher(dir, ["--network", "testnet"], ["-e", ""]); - assert.equal(symlinkParentResult.status, 1); - assert.match(symlinkParentResult.stderr, /symlink/u); - await rm(botParent, { force: true, recursive: true }); - - const good = runLauncher(dir, ["--network", "testnet"], ["-e", ""]); - assert.equal(good.status, 0, good.stderr); - const eventPath = join(dir, "bot", "testnet", "bot.events.ndjson"); - await rm(eventPath); - await writeFile(join(dir, "target-events"), ""); - await symlink(join(dir, "target-events"), eventPath); - const symlinkFileResult = runLauncher(dir, ["--network", "testnet"], ["-e", ""]); - assert.equal(symlinkFileResult.status, 1); - assert.match(symlinkFileResult.stderr, /symlink/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("preserves child exit code 2 for systemd RestartPreventExitStatus", async () => { - const dir = await tempDir(); - try { - const result = runLauncher(dir, ["--network", "mainnet"], ["-e", "process.exit(2);"]); - assert.equal(result.status, 2); - - const launches = await readLaunches(join(dir, "bot", "mainnet")); - assert.equal(launches.at(-1).status, 2); - assert.equal(launches.at(-1).signal, null); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("tee failures do not override child exit semantics or file logs", async () => { - const dir = await tempDir(); - try { - const failingTee = new Writable({ - write(_chunk, _encoding, callback) { - callback(new Error("journald pipe closed")); - }, - }); - const result = await runBotLauncher({ - argv: [ - "--log-root", - dir, - "--network", - "testnet", - "--", - process.execPath, - "-e", - "process.stdout.write('event\\n'); process.stderr.write('stderr\\n'); process.exit(2);", - ], - root: rootDir, - stderr: failingTee, - stdout: failingTee, - }); - - assert.deepEqual(result, { status: 2 }); - const logDir = join(dir, "bot", "testnet"); - assert.equal(await readFile(join(logDir, "bot.events.ndjson"), "utf8"), "event\n"); - assert.equal(await readFile(join(logDir, "bot.stderr.log"), "utf8"), "stderr\n"); - const launches = await readLaunches(logDir); - assert.equal(launches.at(-1).status, 2); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("reports asynchronous child spawn errors", async () => { - const dir = await tempDir(); - try { - let stderr = ""; - const result = await runBotLauncher({ - argv: ["--log-root", dir, "--network", "testnet", "--", "missing-binary"], - root: rootDir, - spawnProcess() { - const child = new EventEmitter(); - child.exitCode = null; - child.killed = false; - child.kill = () => { - child.killed = true; - }; - child.stderr = null; - child.stdout = null; - process.nextTick(() => child.emit("error", new Error("spawn ENOENT"))); - return child; - }, - stderr: { - write(chunk) { - stderr += chunk; - }, - }, - }); - - assert.deepEqual(result, { status: 1 }); - assert.match(stderr, /Failed to spawn child process: spawn ENOENT/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("copy failures destroy the readable stream", async () => { - const readable = new PassThrough(); - const failure = new Error("disk full"); - const copy = copyBytes(readable, { - write() { - return Promise.reject(failure); - }, - }, new Writable({ - write(_chunk, _encoding, callback) { - callback(); - }, - })); - - readable.write("event\n"); - - await assert.rejects(copy, /disk full/u); - assert.equal(readable.destroyed, true); -}); - -test("copy failures reject even when stream destroy does not emit error", async () => { - const readable = new PassThrough(); - const failure = new Error("disk full"); - readable.destroy = () => readable; - const copy = copyBytes(readable, { - write() { - return Promise.reject(failure); - }, - }, new Writable({ - write(_chunk, _encoding, callback) { - callback(); - }, - })); - - readable.write("event\n"); - - await assert.rejects(copy, /disk full/u); -}); - -test("log sinks close file handles after pending write failures", async () => { - let closed = false; - const sink = new LogSink({ - appendFile() { - return Promise.reject(new Error("disk full")); - }, - close() { - closed = true; - return Promise.resolve(); - }, - }); - - const write = sink.write("event\n"); - - await assert.rejects(write, /disk full/u); - await assert.rejects(sink.close(), /disk full/u); - assert.equal(closed, true); -}); - -test("preserves child signal termination", async () => { - const dir = await tempDir(); - try { - const result = runLauncher(dir, ["--network", "testnet"], ["-e", "process.kill(process.pid, 'SIGTERM');"]); - assert.equal(result.signal, "SIGTERM"); - - const launches = await readLaunches(join(dir, "bot", "testnet")); - assert.equal(launches.at(-1).status, null); - assert.equal(launches.at(-1).signal, "SIGTERM"); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("launcher metadata does not expose configured canary secrets", async () => { - const dir = await tempDir(); - try { - const result = runLauncher(dir, ["--network", "testnet"], ["-e", "process.exit(0);", canaryPrivateKey], { - ICKB_TESTNET_BOT_PRIVATE_KEY: canaryPrivateKey, - }); - assert.equal(result.status, 0, result.stderr); - - const logDir = join(dir, "bot", "testnet"); - const produced = [ - result.stdout, - result.stderr, - await readFile(join(logDir, "bot.events.ndjson"), "utf8"), - await readFile(join(logDir, "bot.stderr.log"), "utf8"), - await readFile(join(logDir, "launches.ndjson"), "utf8"), - ].join("\n"); - assert.doesNotMatch(produced, new RegExp(canaryPrivateKey, "u")); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -async function tempDir() { - return mkdtemp(join(tmpdir(), "ickb-bot-launcher-")); -} - -function runLauncher(logRoot, launcherArgs, childArgs, extraEnv = {}) { - return spawnSync(process.execPath, [ - launcher, - "--log-root", - logRoot, - ...launcherArgs, - "--", - process.execPath, - ...childArgs, - ], { - cwd: rootDir, - encoding: "utf8", - env: { - ...process.env, - ...extraEnv, - }, - }); -} - -function runLauncherBuffer(logRoot, launcherArgs, childArgs) { - return spawnSync(process.execPath, [ - launcher, - "--log-root", - logRoot, - ...launcherArgs, - "--", - process.execPath, - ...childArgs, - ], { cwd: rootDir }); -} - -async function readLaunches(logDir) { - const text = await readFile(join(logDir, "launches.ndjson"), "utf8"); - return text.trim().split("\n").map((line) => JSON.parse(line)); -} - -function basenameOfNode() { - return process.execPath.split(/[\\/]/u).at(-1); -} diff --git a/scripts/ickb-bot-systemd-credential.sh b/scripts/ickb-bot-systemd-credential.sh index 5330549..58a8edd 100755 --- a/scripts/ickb-bot-systemd-credential.sh +++ b/scripts/ickb-bot-systemd-credential.sh @@ -5,6 +5,15 @@ usage() { printf 'Usage: %s [--force]\n' "${0##*/}" >&2 } +require_node_22_19() { + local node_bin=$1 + local context=$2 + "${node_bin}" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' || { + printf 'Node.js >=22.19.0 is required %s. Found: %s\n' "${context}" "$("${node_bin}" --version)" >&2 + exit 1 + } +} + require_root() { if [[ ${EUID} -ne 0 ]]; then printf 'Run this script as root, for example with sudo.\n' >&2 @@ -24,7 +33,7 @@ const expectedChain = process.argv[2]; const text = readFileSync(0, "utf8"); let config; try { - const { parseRuntimeConfig } = await import(pathToFileURL(`${repoRoot}/packages/node-utils/dist/index.js`).href); + const { parseRuntimeConfig } = await import(pathToFileURL(`${repoRoot}/packages/node-utils/src/index.ts`).href); parseRuntimeConfig(text, "BOT_CONFIG_FILE"); config = JSON.parse(text); } catch { @@ -34,7 +43,7 @@ if (config.chain !== expectedChain) fail(); process.stdout.write(JSON.stringify(config)); })(); function fail() { - process.stderr.write("Invalid bot config: expected exact JSON with matching chain, privateKey, optional rpcUrl, sleepIntervalSeconds, optional maxIterations, and optional maxRetryableAttempts. Build @ickb/node-utils before running this helper.\n"); + process.stderr.write("Invalid bot config: expected exact JSON with matching chain, privateKey, optional rpcUrl, sleepIntervalSeconds, optional maxIterations, and optional maxRetryableAttempts.\n"); process.exit(1); } ' "${repo_root}" "${expected_chain}" @@ -75,14 +84,15 @@ main() { exit 1 } command -v node >/dev/null || { - printf 'node is required to validate the config before encrypting.\n' >&2 + printf 'node >=22.19.0 is required to validate the config before encrypting.\n' >&2 exit 1 } + require_node_22_19 "$(command -v node)" "to validate TypeScript source configs" if [[ ! -e /var/lib/systemd/credential.secret ]]; then systemd-creds setup fi - if [[ ! -r "${repo_root}/packages/node-utils/dist/index.js" ]]; then - printf 'Build @ickb/node-utils before creating credentials, for example with pnpm bot:build.\n' >&2 + if [[ ! -r "${repo_root}/packages/node-utils/src/index.ts" ]]; then + printf 'Missing @ickb/node-utils source in %s.\n' "${repo_root}" >&2 exit 1 fi @@ -111,9 +121,9 @@ main() { read -r -p "iCKB ${network} bot sleep interval seconds [60]: " sleep_interval sleep_interval=${sleep_interval:-60} read -r -p "iCKB ${network} bot max iterations [empty for unbounded]: " max_iterations - retryable_prompt="iCKB ${network} bot max retryable attempts [10]: " + retryable_prompt="iCKB ${network} bot max retryable attempts [empty for unbounded]: " read -r -p "${retryable_prompt}" max_retryable_attempts - printf '%s\0%s\0%s\0%s\0%s\0%s' "${network}" "${private_key}" "${rpc_url}" "${sleep_interval}" "${max_iterations}" "${max_retryable_attempts:-10}" | + printf '%s\0%s\0%s\0%s\0%s\0%s' "${network}" "${private_key}" "${rpc_url}" "${sleep_interval}" "${max_iterations}" "${max_retryable_attempts}" | node -e ' const input = require("node:fs").readFileSync(0).toString("utf8").split("\0"); const [chain, privateKey, rpcUrl, sleepIntervalSeconds, maxIterations, maxRetryableAttempts] = input; diff --git a/scripts/ickb-bot-systemd-credential.test.mjs b/scripts/ickb-bot-systemd-credential.test.mjs deleted file mode 100644 index c95fd08..0000000 --- a/scripts/ickb-bot-systemd-credential.test.mjs +++ /dev/null @@ -1,68 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const script = join(rootDir, "scripts", "ickb-bot-systemd-credential.sh"); -const privateKey = `0x${"11".repeat(32)}`; - -test("credential helper validation uses the shared runtime parser", () => { - const config = JSON.stringify({ - chain: "testnet", - privateKey, - rpcUrl: "http://127.0.0.1:8114/", - sleepIntervalSeconds: 60, - maxRetryableAttempts: 10, - }); - - const valid = validateConfig("testnet", config); - assert.equal(valid.status, 0, valid.stderr); - assert.equal(valid.stdout, config); - - const defaultRpcConfig = JSON.stringify({ - chain: "testnet", - privateKey, - sleepIntervalSeconds: 60, - maxRetryableAttempts: 10, - }); - const validDefaultRpc = validateConfig("testnet", defaultRpcConfig); - assert.equal(validDefaultRpc.status, 0, validDefaultRpc.stderr); - assert.equal(validDefaultRpc.stdout, defaultRpcConfig); - - const wrongChain = validateConfig("mainnet", config); - assert.equal(wrongChain.status, 1); - assert.match(wrongChain.stderr, /Invalid bot config/u); - - const invalidKey = validateConfig("testnet", JSON.stringify({ - chain: "testnet", - privateKey: `${privateKey}\n`, - rpcUrl: "http://127.0.0.1:8114/", - sleepIntervalSeconds: 60, - })); - assert.equal(invalidKey.status, 1); - assert.doesNotMatch(invalidKey.stderr, /0x11/u); -}); - -test("credential helper does not echo RPC URL input", async () => { - const text = await readFile(script, "utf8"); - - assert.doesNotMatch(text, /systemd-ask-password --echo=yes/u); -}); - -test("credential helper prompts for retryable-attempt budget", async () => { - const text = await readFile(script, "utf8"); - - assert.match(text, /max retryable attempts/u); - assert.match(text, /maxRetryableAttempts/u); -}); - -function validateConfig(network, input) { - return spawnSync( - "bash", - ["-c", `source "$1"; validate_config "$2" "$3"`, "bash", script, network, rootDir], - { cwd: rootDir, input, encoding: "utf8" }, - ); -} diff --git a/scripts/ickb-bot-systemd-install.sh b/scripts/ickb-bot-systemd-install.sh index d0c20e6..bf6f79d 100755 --- a/scripts/ickb-bot-systemd-install.sh +++ b/scripts/ickb-bot-systemd-install.sh @@ -2,8 +2,18 @@ set -euo pipefail usage() { - printf 'Usage: %s [testnet|mainnet|all]\n' "${0##*/}" >&2 - printf 'Set ICKB_BOT_LOG_ROOT to bake an explicit launcher --log-root into generated units. Relative paths resolve from each deploy directory.\n' >&2 + printf 'Usage: %s \n' "${0##*/}" >&2 + printf 'Run from the checkout to use as that service deployment. Generated units keep production bot logs under /log.\n' >&2 + printf 'Set ICKB_BOT_LOG_STORAGE_QUOTA_BYTES to enable best-effort pruning of inactive per-run bot logs and artifacts.\n' >&2 +} + +require_node_22_19() { + local node_bin=$1 + local context=$2 + "${node_bin}" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' || { + printf 'Node.js >=22.19.0 is required %s. Found: %s\n' "${context}" "$("${node_bin}" --version)" >&2 + exit 1 + } } require_root() { @@ -15,49 +25,30 @@ require_root() { require_runtime() { if [[ ! -x /usr/bin/node ]]; then - printf '/usr/bin/node is required because generated units use that path. Install Node.js >=22 there or adjust the unit after install.\n' >&2 + printf '/usr/bin/node is required because generated units use that path. Install Node.js >=22.19.0 there or adjust the unit after install.\n' >&2 exit 1 fi - /usr/bin/node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' || { - printf 'Node.js >=22 is required at /usr/bin/node. Found: %s\n' "$(/usr/bin/node --version)" >&2 - exit 1 - } + require_node_22_19 /usr/bin/node "at /usr/bin/node" command -v node >/dev/null || { - printf 'node is required. Install Node.js >=22 before installing units.\n' >&2 + printf 'node is required. Install Node.js >=22.19.0 before installing units.\n' >&2 exit 1 } + require_node_22_19 "$(command -v node)" "on PATH" command -v pnpm >/dev/null || { printf 'pnpm is required for deploy updates.\n' >&2 exit 1 } } -resolve_log_root_path() { - local deploy_dir=$1 - local configured_log_root=$2 - - node -e ' -const path = require("node:path"); -const deployDir = process.argv[1]; -const configuredLogRoot = process.argv[2]; -const resolved = path.isAbsolute(configuredLogRoot) - ? path.resolve(configuredLogRoot) - : path.resolve(deployDir, configuredLogRoot); -process.stdout.write(resolved); -' "${deploy_dir}" "${configured_log_root}" -} - -require_systemd_safe_path_arg() { - local value=$1 +require_systemd_safe_positive_integer() { + local name=$1 + local value=$2 node -e ' const value = process.argv[1] ?? ""; -const disallowed = new Set([String.fromCharCode(34), String.fromCharCode(39), "\\", "$", "%", ";", String.fromCharCode(96)]); -if (value === "" || [...value].some((char) => char.charCodeAt(0) <= 32 || disallowed.has(char))) { - process.exit(1); -} +process.exit(/^[1-9][0-9]*$/.test(value) && BigInt(value) <= BigInt(Number.MAX_SAFE_INTEGER) ? 0 : 1); ' "${value}" || { - printf 'ICKB_BOT_LOG_ROOT must be a non-empty systemd-safe path without whitespace, quotes, backslashes, semicolons, $, or %%.\n' >&2 + printf '%s must be a positive safe integer.\n' "${name}" >&2 exit 1 } } @@ -118,20 +109,19 @@ function fail(message) { install_network() { local network=$1 + local deploy_dir=$2 local user="ickb-bot-${network}" - local deploy_dir="/opt/ickb-stack-${network}" local credential_name="ickb-bot-${network}-config.json" local credential="/etc/ickb/credentials/ickb-bot-${network}-config.cred" local service="ickb-bot-${network}.service" local unit_path="/etc/systemd/system/${service}" - local configured_log_root=${ICKB_BOT_LOG_ROOT:-} - local launcher_log_root_args= + local configured_log_quota=${ICKB_BOT_LOG_STORAGE_QUOTA_BYTES:-} + local launcher_quota_environment= local log_root_path="${deploy_dir}/log" - if [[ -n ${configured_log_root} ]]; then - require_systemd_safe_path_arg "${configured_log_root}" - log_root_path=$(resolve_log_root_path "${deploy_dir}" "${configured_log_root}") - launcher_log_root_args="--log-root ${log_root_path} " + if [[ -n ${configured_log_quota} ]]; then + require_systemd_safe_positive_integer ICKB_BOT_LOG_STORAGE_QUOTA_BYTES "${configured_log_quota}" + launcher_quota_environment=" ICKB_BOT_LOG_STORAGE_QUOTA_BYTES=${configured_log_quota}" fi if ! id -u "${user}" >/dev/null 2>&1; then @@ -144,8 +134,7 @@ install_network() { safe_install_directory "${deploy_dir}" 755 "${user_id}" "${group_id}" safe_install_directory "${log_root_path}" 755 0 0 - safe_install_directory "${log_root_path}/bot" 755 0 0 - safe_install_directory "${log_root_path}/bot/${network}" 700 "${user_id}" "${group_id}" + safe_install_directory "${log_root_path}/bot" 700 "${user_id}" "${group_id}" install -d -m 700 /etc/ickb/credentials cat >"${unit_path}" <, build apps/bot, then enable the services.\n' + printf 'Next: create credentials, install dependencies, type-check source, then enable the services.\n' } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/scripts/ickb-bot-systemd-install.test.mjs b/scripts/ickb-bot-systemd-install.test.mjs deleted file mode 100644 index 9ee07e0..0000000 --- a/scripts/ickb-bot-systemd-install.test.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdtemp, readFile, rm, stat, symlink } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const installScript = join(rootDir, "scripts", "ickb-bot-systemd-install.sh"); - -test("systemd install units run the built bot through the file-log launcher", async () => { - const text = await readFile(installScript, "utf8"); - - assert.match(text, /ExecStart=\/usr\/bin\/node scripts\/ickb-bot-launcher\.mjs .*--network \$\{network\} -- \/usr\/bin\/node apps\/bot\/dist\/index\.js/u); - assert.doesNotMatch(text, /ExecStart=\/usr\/bin\/node apps\/bot\/dist\/index\.js/u); - assert.match(text, /Environment=BOT_CONFIG_FILE=%d\/\$\{credential_name\}/u); - assert.match(text, /LoadCredentialEncrypted=\$\{credential_name\}:\$\{credential\}/u); - assert.match(text, /RestartPreventExitStatus=2/u); - assert.match(text, /LimitCORE=0/u); - assert.match(text, /ProtectSystem=strict/u); - assert.match(text, /ReadWritePaths=\$\{log_root_path\}/u); -}); - -test("systemd install script avoids environment-specific hardcoded log roots", async () => { - const text = await readFile(installScript, "utf8"); - - assert.match(text, /log_root_path="\$\{deploy_dir\}\/log"/u); - assert.doesNotMatch(text, /\/var\/log/u); - assert.doesNotMatch(text, /logs\/live-supervisor/u); -}); - -test("systemd install script resolves configured log roots like the launcher", () => { - const absolute = resolveLogRoot("/opt/ickb-stack-testnet", "/srv/ickb/logs"); - assert.equal(absolute.status, 0, absolute.stderr); - assert.equal(absolute.stdout, "/srv/ickb/logs"); - - const relative = resolveLogRoot("/opt/ickb-stack-testnet", "runtime-log"); - assert.equal(relative.status, 0, relative.stderr); - assert.equal(relative.stdout, "/opt/ickb-stack-testnet/runtime-log"); -}); - -test("systemd install script refuses unsafe log-root unit arguments", () => { - const valid = validatePathArg("/srv/ickb-logs"); - assert.equal(valid.status, 0, valid.stderr); - - for (const value of ["", "/srv/ickb logs", "/srv/ickb;logs", "/srv/ickb%logs", "/srv/ickb$logs", "/srv/ickb\\logs", "/srv/ickb`logs"]) { - const invalid = validatePathArg(value); - assert.equal(invalid.status, 1, value); - assert.match(invalid.stderr, /ICKB_BOT_LOG_ROOT/u); - } -}); - -test("systemd install script creates log dirs without following symlinks", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-install-")); - try { - const uid = String(process.getuid?.() ?? 0); - const gid = String(process.getgid?.() ?? 0); - const createdPath = join(dir, "log", "bot", "testnet"); - const created = safeInstallDirectory(createdPath, "700", uid, gid); - assert.equal(created.status, 0, created.stderr); - assert.equal((await stat(createdPath)).mode & 0o777, 0o700); - - const linkPath = join(dir, "link"); - await symlink(dir, linkPath, "dir"); - const refused = safeInstallDirectory(join(linkPath, "bot"), "755", uid, gid); - assert.equal(refused.status, 1); - assert.match(refused.stderr, /Refusing symlinked directory path/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -function resolveLogRoot(deployDir, logRoot) { - return spawnSync( - "bash", - ["-c", "source \"$1\"; resolve_log_root_path \"$2\" \"$3\"", "bash", installScript, deployDir, logRoot], - { cwd: rootDir, encoding: "utf8" }, - ); -} - -function validatePathArg(value) { - return spawnSync( - "bash", - ["-c", "source \"$1\"; require_systemd_safe_path_arg \"$2\"", "bash", installScript, value], - { cwd: rootDir, encoding: "utf8" }, - ); -} - -function safeInstallDirectory(path, mode, uid, gid) { - return spawnSync( - "bash", - ["-c", "source \"$1\"; safe_install_directory \"$2\" \"$3\" \"$4\" \"$5\"", "bash", installScript, path, mode, uid, gid], - { cwd: rootDir, encoding: "utf8" }, - ); -} diff --git a/scripts/ickb-bot-systemd-update.sh b/scripts/ickb-bot-systemd-update.sh index 678ce31..ef05a15 100755 --- a/scripts/ickb-bot-systemd-update.sh +++ b/scripts/ickb-bot-systemd-update.sh @@ -5,6 +5,15 @@ usage() { printf 'Usage: %s \n' "${0##*/}" >&2 } +require_node_22_19() { + local node_bin=$1 + local context=$2 + "${node_bin}" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' || { + printf 'Node.js >=22.19.0 is required %s. Found: %s\n' "${context}" "$("${node_bin}" --version)" >&2 + exit 1 + } +} + require_root() { if [[ ${EUID} -ne 0 ]]; then printf 'Run this script as root, for example with sudo.\n' >&2 @@ -14,13 +23,10 @@ require_root() { require_runtime() { if [[ ! -x /usr/bin/node ]]; then - printf '/usr/bin/node is required because generated units use that path. Install Node.js >=22 there or adjust the unit before updating.\n' >&2 + printf '/usr/bin/node is required because generated units use that path. Install Node.js >=22.19.0 there or adjust the unit before updating.\n' >&2 exit 1 fi - /usr/bin/node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' || { - printf 'Node.js >=22 is required at /usr/bin/node. Found: %s\n' "$(/usr/bin/node --version)" >&2 - exit 1 - } + require_node_22_19 /usr/bin/node "at /usr/bin/node" command -v pnpm >/dev/null || { printf 'pnpm is required before updating.\n' >&2 exit 1 @@ -70,6 +76,7 @@ require_clean_worktree() { require_launcher_unit() { local unit_path=$1 local network=$2 + local deploy_dir=$3 if [[ ! -r ${unit_path} ]]; then printf 'Service unit %s is missing or unreadable. Run scripts/ickb-bot-systemd-install.sh %s first.\n' "${unit_path}" "${network}" >&2 @@ -80,25 +87,23 @@ require_launcher_unit() { unit_text=$(<"${unit_path}") local credential_name="ickb-bot-${network}-config.json" local credential="/etc/ickb/credentials/ickb-bot-${network}-config.cred" - local log_root - if ! log_root=$(unit_launcher_log_root "${unit_text}" "${network}"); then - printf 'Service unit %s is not wired for production launcher file logging and core-dump hardening. Run scripts/ickb-bot-systemd-install.sh %s before updating.\n' "${unit_path}" "${network}" >&2 - exit 1 - fi - if ! unit_has_directive "${unit_text}" "Environment" "BOT_CONFIG_FILE=%d/${credential_name}" || - ! unit_has_directive "${unit_text}" "LoadCredentialEncrypted" "${credential_name}:${credential}" || - ! unit_has_directive "${unit_text}" "RestartPreventExitStatus" "2" || - ! unit_has_directive "${unit_text}" "LimitCORE" "0" || - ! unit_has_directive "${unit_text}" "ReadWritePaths" "${log_root}"; then + local log_root="${deploy_dir}/log" + if ! service_has_bot_environment "${unit_text}" "${credential_name}" || + ! service_has_line "${unit_text}" "LoadCredentialEncrypted=${credential_name}:${credential}" || + ! service_has_line "${unit_text}" "ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts --no-child-tee" || + ! service_has_line "${unit_text}" "RestartPreventExitStatus=2" || + ! service_has_line "${unit_text}" "LimitCORE=0" || + ! service_has_line "${unit_text}" "RestartSec=60" || + ! service_has_line "${unit_text}" "ReadWritePaths=${log_root}"; then printf 'Service unit %s is not wired for production launcher file logging and core-dump hardening. Run scripts/ickb-bot-systemd-install.sh %s before updating.\n' "${unit_path}" "${network}" >&2 exit 1 fi } -unit_has_directive() { - local unit_text=$1 - local key=$2 - local expected=$3 +unit_working_directory() { + local unit_path=$1 + local unit_text + unit_text=$(<"${unit_path}") local line local value local in_service=0 @@ -111,78 +116,53 @@ unit_has_directive() { continue fi [[ ${in_service} -eq 1 ]] || continue - if value=$(unit_directive_value "${line}" "${key}") && unit_value_matches "${key}" "${value}" "${expected}"; then + if [[ ${line} == WorkingDirectory=* ]]; then + value=${line#WorkingDirectory=} + printf '%s\n' "${value}" return 0 fi done <<<"${unit_text}" return 1 } -unit_directive_value() { - local line=$1 - local expected_key=$2 - if [[ ${line} != *=* ]]; then - return 1 - fi - - local key=${line%%=*} - local value=${line#*=} - key=$(trim_unit_field "${key}") - value=$(trim_unit_field "${value}") - if [[ ${key} != "${expected_key}" ]]; then - return 1 +require_unit_working_directory() { + local unit_path=$1 + local network=$2 + local deploy_dir + if ! deploy_dir=$(unit_working_directory "${unit_path}") || [[ -z ${deploy_dir} || ${deploy_dir} != /* ]]; then + printf 'Service unit %s has no absolute WorkingDirectory. Run scripts/ickb-bot-systemd-install.sh %s from the deploy checkout before updating.\n' "${unit_path}" "${network}" >&2 + exit 1 fi - printf '%s\n' "${value}" -} - -trim_unit_field() { - local value=$1 - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s\n' "${value}" + printf '%s\n' "${deploy_dir}" } -unit_value_matches() { - local key=$1 - local value=$2 - local expected=$3 - - [[ ${value} == "${expected}" ]] && return 0 - case "${key}" in - Environment|ReadWritePaths) unit_value_contains_token "${value}" "${expected}" ;; - *) return 1 ;; - esac -} - -unit_value_contains_token() { - local value=$1 +service_has_line() { + local unit_text=$1 local expected=$2 - local token - local quoted - local -a tokens - - read -r -a tokens <<<"${value}" - for token in "${tokens[@]}"; do - if [[ ${token} == '"'*'"' && ${#token} -ge 2 ]]; then - quoted=${token#'"'} - token=${quoted%'"'} + local line + local in_service=0 + + while IFS= read -r line || [[ -n ${line} ]]; do + line=${line%$'\r'} + [[ ${line} =~ ^[[:space:]]*($|#|\;) ]] && continue + if [[ ${line} =~ ^[[:space:]]*\[(.*)\][[:space:]]*$ ]]; then + [[ ${BASH_REMATCH[1]} == Service ]] && in_service=1 || in_service=0 + continue + fi + [[ ${in_service} -eq 1 ]] || continue + if [[ ${line} == "${expected}" ]]; then + return 0 fi - [[ ${token} == "${expected}" ]] && return 0 - done + done <<<"${unit_text}" return 1 } -unit_launcher_log_root() { +service_has_bot_environment() { local unit_text=$1 - local network=$2 - local default_log_root="/opt/ickb-stack-${network}/log" - local prefix="ExecStart=/usr/bin/node scripts/ickb-bot-launcher.mjs " - local suffix="--network ${network} -- /usr/bin/node apps/bot/dist/index.js" - local with_log_root_prefix="${prefix}--log-root " - local suffix_with_separator=" ${suffix}" + local credential_name=$2 local line - local exec_start local in_service=0 + local quota while IFS= read -r line || [[ -n ${line} ]]; do line=${line%$'\r'} @@ -192,22 +172,12 @@ unit_launcher_log_root() { continue fi [[ ${in_service} -eq 1 ]] || continue - if ! exec_start=$(unit_directive_value "${line}" "ExecStart"); then - continue - fi - if [[ ${exec_start} == "${prefix#ExecStart=}${suffix}" ]]; then - printf '%s\n' "${default_log_root}" + if [[ ${line} == "Environment=BOT_CONFIG_FILE=%d/${credential_name}" ]]; then return 0 fi - if [[ ${exec_start} == "${with_log_root_prefix#ExecStart=}"*"${suffix_with_separator}" ]]; then - local rest=${exec_start#"${with_log_root_prefix#ExecStart=}"} - local log_root_length=$(( ${#rest} - ${#suffix_with_separator} )) - local log_root=${rest:0:log_root_length} - if [[ -n ${log_root} && ${log_root} == /* && ${log_root} != *[[:space:]]* ]]; then - printf '%s\n' "${log_root}" - return 0 - fi - return 1 + if [[ ${line} == "Environment=BOT_CONFIG_FILE=%d/${credential_name} ICKB_BOT_LOG_STORAGE_QUOTA_BYTES="* ]]; then + quota=${line#"Environment=BOT_CONFIG_FILE=%d/${credential_name} ICKB_BOT_LOG_STORAGE_QUOTA_BYTES="} + [[ ${quota} =~ ^[1-9][0-9]*$ ]] && return 0 fi done <<<"${unit_text}" return 1 @@ -231,21 +201,22 @@ main() { esac local user="ickb-bot-${network}" - local deploy_dir="/opt/ickb-stack-${network}" local service="ickb-bot-${network}.service" local unit_path="/etc/systemd/system/${service}" + local deploy_dir local pnpm_bin pnpm_bin=$(command -v pnpm) local user_home user_home=$(service_user_home "${user}") + deploy_dir=$(require_unit_working_directory "${unit_path}" "${network}") + require_launcher_unit "${unit_path}" "${network}" "${deploy_dir}" run_as_service_user "${user}" "${user_home}" git -C "${deploy_dir}" rev-parse --is-inside-work-tree >/dev/null require_clean_worktree "${user}" "${user_home}" "${deploy_dir}" - require_launcher_unit "${unit_path}" "${network}" run_as_service_user "${user}" "${user_home}" git -C "${deploy_dir}" pull --ff-only run_as_service_user "${user}" "${user_home}" "${pnpm_bin}" -C "${deploy_dir}" bot:install - run_as_service_user "${user}" "${user_home}" "${pnpm_bin}" -C "${deploy_dir}" bot:build + run_as_service_user "${user}" "${user_home}" "${pnpm_bin}" -C "${deploy_dir}" bot:check systemctl restart "${service}" systemctl --no-pager --full status "${service}" } diff --git a/scripts/ickb-bot-systemd-update.test.mjs b/scripts/ickb-bot-systemd-update.test.mjs deleted file mode 100644 index 90704f6..0000000 --- a/scripts/ickb-bot-systemd-update.test.mjs +++ /dev/null @@ -1,270 +0,0 @@ -import assert from "node:assert/strict"; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const updateScript = join(rootDir, "scripts", "ickb-bot-systemd-update.sh"); - -test("systemd update accepts launcher-wired units", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: true })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 0, result.stderr); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update accepts launcher-wired units with explicit log roots", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: true, logRoot: "/srv/ickb/log" })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 0, result.stderr); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update accepts spaces around service directive separators", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: true, separator: " = " })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 0, result.stderr); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update reads unit files without trailing newlines", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: true }).replace(/\n$/u, "")); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 0, result.stderr); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update accepts expected values inside multi-value directives", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ - network: "testnet", - launcher: true, - extraEnvironment: "NODE_ENV=production", - extraReadWritePath: "/var/tmp", - })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 0, result.stderr); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update refuses stale direct-exec units", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: false })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 1); - assert.match(result.stderr, /production launcher file logging/u); - assert.match(result.stderr, /ickb-bot-systemd-install\.sh testnet/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update refuses launcher units without core-dump hardening", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: true, limitCore: false })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 1); - assert.match(result.stderr, /core-dump hardening/u); - assert.match(result.stderr, /ickb-bot-systemd-install\.sh testnet/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update refuses launcher units with mismatched writable log roots", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: true, logRoot: "/srv/ickb/log", readWritePath: "/tmp" })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 1); - assert.match(result.stderr, /production launcher file logging/u); - assert.match(result.stderr, /ickb-bot-systemd-install\.sh testnet/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update refuses malformed launcher log-root arguments", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: true, logRoot: "relative-log" })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 1); - assert.match(result.stderr, /production launcher file logging/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update ignores commented launcher directives", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: false, commentedSpoof: true })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 1); - assert.match(result.stderr, /production launcher file logging/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update ignores launcher directives outside the Service section", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const unitPath = join(dir, "ickb-bot-testnet.service"); - await writeFile(unitPath, unitText({ network: "testnet", launcher: false, inactiveSectionSpoof: true })); - - const result = requireLauncherUnit(unitPath, "testnet"); - assert.equal(result.status, 1); - assert.match(result.stderr, /production launcher file logging/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -test("systemd update checks unit wiring before mutating deploy checkout", async () => { - const text = await readFile(updateScript, "utf8"); - const guardIndex = text.indexOf('require_launcher_unit "${unit_path}" "${network}"'); - const pullIndex = text.indexOf('git -C "${deploy_dir}" pull --ff-only'); - - assert.notEqual(guardIndex, -1); - assert.notEqual(pullIndex, -1); - assert.ok(guardIndex < pullIndex); -}); - -test("systemd update refuses untracked files before pulling", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-update-")); - try { - const fakeBin = join(dir, "bin"); - const logPath = join(dir, "git.log"); - await mkdir(fakeBin, { recursive: true }); - await writeFile(join(fakeBin, "runuser"), `#!/usr/bin/env bash -shift 3 -env_args=() -while [[ $# -gt 0 && $1 == *=* ]]; do - env_args+=("$1") - shift -done -exec env "\${env_args[@]}" "$@" -`); - await chmod(join(fakeBin, "runuser"), 0o755); - await writeFile(join(fakeBin, "git"), `#!/usr/bin/env bash -printf '%s\\n' "$*" >> ${JSON.stringify(logPath)} -if [[ $* == *' status --porcelain' ]]; then - printf '?? untracked.txt\\n' -fi -`); - await chmod(join(fakeBin, "git"), 0o755); - - const result = spawnSync( - "bash", - ["-c", "source \"$1\"; PATH=\"$2:$PATH\"; run_as_service_user() { command runuser -u \"$1\" -- env HOME=\"$2\" USER=\"$1\" LOGNAME=\"$1\" SHELL=/bin/bash \"${@:3}\"; }; require_clean_worktree ickb-bot-testnet /home/ickb /deploy", "bash", updateScript, fakeBin], - { cwd: rootDir, encoding: "utf8" }, - ); - - assert.equal(result.status, 1); - assert.match(result.stderr, /local changes or untracked files/u); - assert.doesNotMatch(await readFile(logPath, "utf8"), /pull --ff-only/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } -}); - -function requireLauncherUnit(unitPath, network) { - return spawnSync( - "bash", - ["-c", "source \"$1\"; require_launcher_unit \"$2\" \"$3\"", "bash", updateScript, unitPath, network], - { cwd: rootDir, encoding: "utf8" }, - ); -} - -function unitText({ - network, - launcher, - limitCore = true, - commentedSpoof = false, - inactiveSectionSpoof = false, - logRoot, - readWritePath, - extraEnvironment, - extraReadWritePath, - separator = "=", -}) { - const credentialName = `ickb-bot-${network}-config.json`; - const credential = `/etc/ickb/credentials/ickb-bot-${network}-config.cred`; - const launcherLogRoot = logRoot === undefined ? "" : `--log-root ${logRoot} `; - const execStart = launcher - ? `ExecStart${separator}/usr/bin/node scripts/ickb-bot-launcher.mjs ${launcherLogRoot}--network ${network} -- /usr/bin/node apps/bot/dist/index.js` - : `ExecStart${separator}/usr/bin/node apps/bot/dist/index.js`; - const writablePath = readWritePath ?? logRoot ?? `/opt/ickb-stack-${network}/log`; - const environmentValue = [`BOT_CONFIG_FILE=%d/${credentialName}`, extraEnvironment].filter(Boolean).join(" "); - const readWritePaths = [writablePath, extraReadWritePath].filter(Boolean).join(" "); - - const comments = commentedSpoof - ? `# ExecStart=/usr/bin/node scripts/ickb-bot-launcher.mjs --network ${network} -- /usr/bin/node apps/bot/dist/index.js -# ReadWritePaths=/opt/ickb-stack-${network}/log -` - : ""; - const inactive = inactiveSectionSpoof - ? `[Unit] -ExecStart=/usr/bin/node scripts/ickb-bot-launcher.mjs --network ${network} -- /usr/bin/node apps/bot/dist/index.js -ReadWritePaths=/opt/ickb-stack-${network}/log -[Install] -WantedBy=multi-user.target -` - : ""; - - return `${inactive}[Service] -${comments} -Environment${separator}${environmentValue} -LoadCredentialEncrypted${separator}${credentialName}:${credential} -${execStart} -RestartPreventExitStatus${separator}2 -${limitCore ? `LimitCORE${separator}0\n` : ""} -ReadWritePaths${separator}${readWritePaths} -`; -} diff --git a/scripts/run-node-tests.ts b/scripts/run-node-tests.ts new file mode 100644 index 0000000..64a4c82 --- /dev/null +++ b/scripts/run-node-tests.ts @@ -0,0 +1,51 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, type Dirent } from "node:fs"; +import pathModule from "node:path"; +import process from "node:process"; + +const testRoot = "scripts/test"; +const testFiles = recursiveFiles(testRoot, (name) => name.endsWith(".ts")); + +if (testFiles.length === 0) { + console.error("No Node script tests found."); + process.exitCode = 1; +} else { + const result = spawnSync(process.execPath, ["--test", ...testFiles], { + stdio: "inherit", + }); + + if (result.error !== undefined) { + console.error(result.error.message); + process.exitCode = 1; + } else { + process.exitCode = result.status ?? 1; + } +} + +function recursiveFiles( + directory: string, + predicate: (name: string) => boolean, +): string[] { + if (!pathExists(directory)) { + return []; + } + return readDirectory(directory) + .toSorted((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry) => { + const entryPath = pathModule.join(directory, entry.name); + if (entry.isDirectory()) { + return recursiveFiles(entryPath, predicate); + } + return entry.isFile() && predicate(entry.name) ? [entryPath] : []; + }); +} + +function pathExists(filePath: string): boolean { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Test discovery only scans the fixed scripts/test directory. + return existsSync(filePath); +} + +function readDirectory(filePath: string): Dirent[] { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Test discovery only scans the fixed scripts/test directory. + return readdirSync(filePath, { encoding: "utf8", withFileTypes: true }); +} diff --git a/scripts/test/bot/incident/artifacts.ts b/scripts/test/bot/incident/artifacts.ts new file mode 100644 index 0000000..8aaf4ea --- /dev/null +++ b/scripts/test/bot/incident/artifacts.ts @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + artifactsDirectory, + botEvent, + botRebalanceEvaluated, + canaryPrivateKey, + collectBundle, + collectIncident, + commonArgs, + fixtureDate, + join, + linkSymbolic, + mkdirp, + readIncidentSummary, + readText, + referencedArtifactEvents, + referencedArtifactFixture, + ringSegmentsArtifactPath, + ringSegmentsDirectory, + ringSegmentsKind, + rm, + rootDir, + sha256Ref, + slot00Directory, + tempDir, + windowSince, + writeFixtureLogs, + writeText, +} from "./support.ts"; + +void test("copies referenced bot diagnostic artifacts into incident bundles", async () => { + const dir = await tempDir(); + try { + const artifactText = '{"kind":"bot.ringSegments"}\n'; + const artifact = referencedArtifactFixture(artifactText); + const logDir = await writeFixtureLogs(dir, { + events: referencedArtifactEvents(artifact), + launches: [], + stderr: [], + }); + const artifactPath = join( + logDir, + artifactsDirectory, + slot00Directory, + ringSegmentsDirectory, + artifact.artifactFileName, + ); + await mkdirp( + join(logDir, artifactsDirectory, slot00Directory, ringSegmentsDirectory), + ); + await writeText(artifactPath, artifactText, { mode: 0o600 }); + + const result = await collectBundle({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }); + + assert.equal( + await readText( + join( + result.incidentDir, + artifactsDirectory, + slot00Directory, + ringSegmentsDirectory, + artifact.artifactFileName, + ), + ), + artifactText, + ); + const summary = await readIncidentSummary(result.incidentDir); + assert.equal(summary.artifacts.included.length, 2); + assert.deepEqual(summary.artifacts.missing, [ + { + hash: `sha256:${"b".repeat(64)}`, + kind: ringSegmentsKind, + path: ringSegmentsArtifactPath( + "sha256-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json", + ), + }, + ]); + assert.deepEqual(summary.artifacts.mismatched, []); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("refuses referenced artifacts through symlinked parents", async () => { + const dir = await tempDir(); + try { + const artifactText = `${canaryPrivateKey}\n`; + const artifactHash = sha256Ref(artifactText); + const artifactFileName = `sha256-${artifactHash.slice("sha256:".length)}.json`; + const logDir = await writeFixtureLogs(dir, { + events: [ + botEvent(windowSince, botRebalanceEvaluated, { + rebalance: { + diagnostics: { + ring: { + segmentsRef: { + kind: ringSegmentsKind, + hash: artifactHash, + path: ringSegmentsArtifactPath(artifactFileName), + }, + }, + }, + }, + }), + ], + launches: [], + stderr: [], + }); + const outside = join(dir, "outside-artifacts"); + await mkdirp(outside); + await writeText(join(outside, artifactFileName), artifactText, { mode: 0o600 }); + await mkdirp(join(logDir, artifactsDirectory, slot00Directory)); + await linkSymbolic( + outside, + join(logDir, artifactsDirectory, slot00Directory, ringSegmentsDirectory), + "dir", + ); + + await assert.rejects( + async () => + collectIncident({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }), + /symlinked artifact/u, + ); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("reports mismatched referenced artifact hashes without bundling them", async () => { + const dir = await tempDir(); + try { + const expectedText = '{"kind":"bot.ringSegments","expected":true}\n'; + const actualText = '{"kind":"bot.ringSegments","actual":true}\n'; + const expectedHash = sha256Ref(expectedText); + const artifactFileName = `sha256-${expectedHash.slice("sha256:".length)}.json`; + const artifactRef = { + kind: ringSegmentsKind, + hash: expectedHash, + path: ringSegmentsArtifactPath(artifactFileName), + }; + const logDir = await writeFixtureLogs(dir, { + events: [ + botEvent(windowSince, botRebalanceEvaluated, { + rebalance: { diagnostics: { ring: { segmentsRef: artifactRef } } }, + }), + ], + launches: [], + stderr: [], + }); + await mkdirp( + join(logDir, artifactsDirectory, slot00Directory, ringSegmentsDirectory), + ); + await writeText( + join( + logDir, + artifactsDirectory, + slot00Directory, + ringSegmentsDirectory, + artifactFileName, + ), + actualText, + { mode: 0o600 }, + ); + + const result = await collectBundle({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }); + + await assert.rejects( + async () => readText(join(result.incidentDir, artifactRef.path)), + /ENOENT/u, + ); + const summary = await readIncidentSummary(result.incidentDir); + assert.deepEqual(summary.artifacts.missing, []); + assert.deepEqual(summary.artifacts.mismatched, [ + { ...artifactRef, actualHash: sha256Ref(actualText) }, + ]); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("reports conflicting artifact hashes that reuse one path", async () => { + const dir = await tempDir(); + try { + const artifactText = '{"kind":"bot.ringSegments","actual":true}\n'; + const artifactHash = sha256Ref(artifactText); + const wrongHash = `sha256:${"c".repeat(64)}`; + const artifactFileName = `sha256-${artifactHash.slice("sha256:".length)}.json`; + const artifactPath = ringSegmentsArtifactPath(artifactFileName); + const logDir = await writeFixtureLogs(dir, { + events: [ + botEvent(windowSince, botRebalanceEvaluated, { + rebalance: { + diagnostics: { + ring: { + refs: [ + { kind: ringSegmentsKind, hash: artifactHash, path: artifactPath }, + { kind: ringSegmentsKind, hash: wrongHash, path: artifactPath }, + ], + }, + }, + }, + }), + ], + launches: [], + stderr: [], + }); + await mkdirp( + join(logDir, artifactsDirectory, slot00Directory, ringSegmentsDirectory), + ); + await writeText(join(logDir, artifactPath), artifactText, { mode: 0o600 }); + + const result = await collectBundle({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }); + + const summary = await readIncidentSummary(result.incidentDir); + assert.equal(summary.artifacts.included.length, 2); + assert.deepEqual(summary.artifacts.mismatched, [ + { + kind: ringSegmentsKind, + hash: wrongHash, + path: artifactPath, + actualHash: artifactHash, + }, + ]); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); diff --git a/scripts/test/bot/incident/core.ts b/scripts/test/bot/incident/core.ts new file mode 100644 index 0000000..c727b11 --- /dev/null +++ b/scripts/test/bot/incident/core.ts @@ -0,0 +1,381 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertWindowedBundleOutputs, + assertWindowedBundleSummary, + botDecisionSkipped, + botEvent, + botEventsNdjson, + botRunStarted, + botStderrLog, + canaryPrivateKey, + collectBundle, + collector, + commonArgs, + fixtureDate, + fixtureNow, + join, + launch, + launcherStarted, + launchesNdjson, + logDirOption, + logRootOption, + mode, + moduleDefaultFlag, + openPath, + parseArgs, + parseTimeBound, + readIncidentSummary, + readText, + readVersionJson, + rm, + rootDir, + sinceOption, + slot00EventsNdjson, + slot00StderrLog, + slot01EventsNdjson, + slot01StderrLog, + sourceSummary, + spawnSync, + splitReadHandle, + summaryJson, + tempDir, + untilOption, + versionJson, + windowSince, + windowUntil, + windowedFixtureEvents, + windowedFixtureLaunches, + windowedFixtureStderr, + writeFixtureLogs, + writeFixtureSlotLogs, +} from "./support.ts"; + +void test("parses collector arguments and relative time bounds", () => { + assert.deepEqual( + parseArgs([logRootOption, "var/logs", sinceOption, "2h", untilOption, "now"]), + { + logRoot: "var/logs", + since: "2h", + until: "now", + }, + ); + + const now = new Date(fixtureNow); + assert.equal(parseTimeBound("now", now).toISOString(), fixtureNow); + assert.equal(parseTimeBound("2h", now).toISOString(), windowSince); + assert.equal(parseTimeBound("2026-05-25T11:00:00Z", now).toISOString(), windowUntil); + assert.throws( + () => parseArgs([logRootOption, "var/logs", untilOption, "now"]), + /Missing required --since/u, + ); +}); + +void test("collects a time-bounded source-separated incident bundle with summary counts", async () => { + const dir = await tempDir(); + try { + const logDir = await writeFixtureLogs(dir, { + events: windowedFixtureEvents(), + launches: windowedFixtureLaunches(), + stderr: windowedFixtureStderr(), + }); + + const result = await collectBundle({ + argv: [logRootOption, dir, sinceOption, windowSince, untilOption, windowUntil], + now: fixtureDate, + root: rootDir, + }); + + assert.match(result.incidentDir, /\/incidents\/20260525T120000000Z-/u); + assert.equal(await mode(result.incidentDir), 0o700); + assert.equal(await mode(join(result.incidentDir, summaryJson)), 0o600); + await assertWindowedBundleOutputs(result.incidentDir); + await assertWindowedBundleSummary(result.incidentDir, logDir); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("includes a bounded tail when stderr has no timestamps", async () => { + const dir = await tempDir(); + try { + await writeFixtureLogs(dir, { + events: [botEvent(windowSince, botRunStarted)], + launches: [], + stderr: Array.from( + { length: 205 }, + (_, index) => `stack line ${index.toString()}\n`, + ), + }); + + const result = await collectBundle({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }); + + const stderr = await readText(join(result.incidentDir, botStderrLog)); + assert.doesNotMatch(stderr, /^stack line 0$/mu); + assert.match(stderr, /^stack line 5$/mu); + assert.match(stderr, /^stack line 204$/mu); + + const summary = await readIncidentSummary(result.incidentDir); + assert.equal(sourceSummary(summary, botStderrLog).selectedLines, 200); + assert.equal(sourceSummary(summary, botStderrLog).selectedUndatedLines, 200); + assert.equal(sourceSummary(summary, botStderrLog).undatedLines, 205); + assert.equal(sourceSummary(summary, botStderrLog).undatedTailIncluded, true); + assert.equal(sourceSummary(summary, botStderrLog).undatedTailLimit, 200); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("decodes source log UTF-8 across chunk boundaries", async () => { + const dir = await tempDir(); + try { + const marker = "snowman ☃"; + const logDir = await writeFixtureLogs(dir, { + events: [botEvent(windowSince, botRunStarted, { marker })], + launches: [], + stderr: [], + }); + const eventPath = join(logDir, botEventsNdjson); + const bytes = Buffer.from(await readText(eventPath)); + const splitAt = bytes.indexOf(Buffer.from("☃")) + 1; + + const result = await collectBundle({ + argv: commonArgs(dir), + dependencies: { + async open(filePath: string, flags: number, fileMode?: number) { + if (filePath === eventPath && typeof flags === "number") { + return splitReadHandle(filePath, [ + bytes.subarray(0, splitAt), + bytes.subarray(splitAt), + ]); + } + return openPath(filePath, flags, fileMode); + }, + }, + now: fixtureDate, + root: rootDir, + }); + + const events = await readText(join(result.incidentDir, botEventsNdjson)); + assert.ok(events.includes(marker)); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("collects ring-slot bot launcher log files", async () => { + const dir = await tempDir(); + try { + const logDir = await writeFixtureSlotLogs(dir, { + eventSlots: { + [slot00EventsNdjson]: [botEvent(windowSince, botRunStarted)], + [slot01EventsNdjson]: [ + botEvent("2026-05-25T10:10:00.000Z", botDecisionSkipped, { + reason: "no_actions", + }), + ], + }, + launches: [ + launch(windowSince, launcherStarted, { + logFiles: { + events: join(dir, "bot", slot01EventsNdjson), + launches: join(dir, "bot", launchesNdjson), + stderr: join(dir, "bot", slot01StderrLog), + }, + logSlot: { index: 1, count: 16 }, + }), + ], + stderrSlots: { + [slot00StderrLog]: ["2026-05-25T10:05:00.000Z slot zero warning\n"], + [slot01StderrLog]: ["2026-05-25T10:15:00.000Z slot one warning\n"], + }, + }); + + const result = await collectBundle({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }); + + assert.equal(logDir, join(dir, "bot")); + assert.match( + await readText(join(result.incidentDir, slot00EventsNdjson)), + /bot\.run\.started/u, + ); + assert.match( + await readText(join(result.incidentDir, slot01EventsNdjson)), + /bot\.decision\.skipped/u, + ); + assert.match( + await readText(join(result.incidentDir, slot01StderrLog)), + /slot one warning/u, + ); + const summary = await readIncidentSummary(result.incidentDir); + assert.equal(sourceSummary(summary, slot00EventsNdjson).included, true); + assert.equal(sourceSummary(summary, slot01EventsNdjson).included, true); + assert.equal(sourceSummary(summary, slot00StderrLog).included, true); + assert.equal(sourceSummary(summary, slot01StderrLog).included, true); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("does not use undated stderr tail fallback when timestamped stderr is outside the window", async () => { + const dir = await tempDir(); + try { + await writeFixtureLogs(dir, { + events: [botEvent(windowSince, botRunStarted)], + launches: [], + stderr: ["2026-05-25T09:00:00.000Z before window\n", "outside continuation\n"], + }); + + const result = await collectBundle({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }); + + assert.equal(await readText(join(result.incidentDir, botStderrLog)), ""); + const summary = await readIncidentSummary(result.incidentDir); + assert.equal(sourceSummary(summary, botStderrLog).timestampedLines, 1); + assert.equal(sourceSummary(summary, botStderrLog).undatedLines, 1); + assert.equal(sourceSummary(summary, botStderrLog).selectedLines, 0); + assert.equal(sourceSummary(summary, botStderrLog).undatedTailIncluded, false); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("accepts explicit contained log directory and environment log root", async () => { + const dir = await tempDir(); + try { + const logDir = await writeFixtureLogs(dir, { + events: [botEvent(windowSince, botRunStarted)], + launches: [ + launch("2026-05-25T10:00:01.000Z", "launcher.child.exited", { + status: 0, + }), + ], + stderr: ["2026-05-25T10:00:02.000Z ok\n"], + }); + + const result = await collectBundle({ + argv: [ + logDirOption, + logDir, + sinceOption, + windowSince, + untilOption, + "2026-05-25T10:00:02.000Z", + ], + envLogRoot: dir, + now: fixtureDate, + root: rootDir, + }); + const summary = await readIncidentSummary(result.incidentDir); + assert.equal(summary.logRoot, dir); + assert.equal(summary.logRootSource, "env:ICKB_BOT_LOG_ROOT"); + assert.equal(summary.logDir, logDir); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("includes selected producer log text without masking bundles", async () => { + const dir = await tempDir(); + try { + await writeFixtureLogs(dir, { + events: [ + botEvent(windowSince, botRunStarted, { + error: "public diagnostic", + }), + ], + launches: [], + stderr: [], + }); + + const result = await collectBundle({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }); + const events = await readText(join(result.incidentDir, botEventsNdjson)); + assert.match(events, /public diagnostic/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("produced bundles do not contain configured canary secrets", async () => { + const dir = await tempDir(); + try { + await writeFixtureLogs(dir, { + events: [botEvent(windowSince, botRunStarted)], + launches: [launch(windowSince, launcherStarted)], + stderr: [`${windowSince} public diagnostic\n`], + }); + + const result = spawnSync( + process.execPath, + [moduleDefaultFlag, collector, ...commonArgs(dir)], + { + cwd: rootDir, + encoding: "utf8", + env: { + ...process.env, + ICKB_TESTNET_BOT_PRIVATE_KEY: canaryPrivateKey, + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(`${result.stdout}\n${result.stderr}`.includes(canaryPrivateKey), false); + const incidentDir = /^Incident bundle directory: (.+)$/mu.exec(result.stdout)?.[1]; + assert.ok(incidentDir !== undefined && incidentDir !== ""); + + const produced = [ + botEventsNdjson, + botStderrLog, + launchesNdjson, + "README.txt", + summaryJson, + versionJson, + ]; + const bundleText = ( + await Promise.all(produced.map(async (name) => readText(join(incidentDir, name)))) + ).join("\n"); + assert.equal(bundleText.includes(canaryPrivateKey), false); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("keeps collecting incidents when git metadata lookup fails", async () => { + const dir = await tempDir(); + try { + await writeFixtureLogs(dir, { + events: [botEvent(windowSince, botRunStarted)], + launches: [], + stderr: [], + }); + + const result = await collectBundle({ + argv: commonArgs(dir), + dependencies: { + spawnSync() { + throw new Error("git unavailable"); + }, + }, + now: fixtureDate, + root: rootDir, + }); + + const version = await readVersionJson(result.incidentDir); + assert.equal(version.gitCommit, null); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); diff --git a/scripts/test/bot/incident/paths.ts b/scripts/test/bot/incident/paths.ts new file mode 100644 index 0000000..5a5095f --- /dev/null +++ b/scripts/test/bot/incident/paths.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + botEvent, + botEventsNdjson, + botRunStarted, + collectIncident, + commonArgs, + fixtureDate, + join, + linkSymbolic, + logDirOption, + logRootOption, + rm, + rootDir, + sinceOption, + tempDir, + untilOption, + windowSince, + windowUntil, + writeFixtureLogs, + writeText, +} from "./support.ts"; + +void test("refuses log directories outside the resolved log root", async () => { + const dir = await tempDir(); + try { + await assert.rejects( + async () => + collectIncident({ + argv: [ + logRootOption, + join(dir, "root"), + logDirOption, + join(dir, "outside"), + sinceOption, + windowSince, + untilOption, + windowUntil, + ], + root: rootDir, + }), + /inside the resolved log root/u, + ); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("refuses symlinked log roots and log directories", async () => { + const dir = await tempDir(); + try { + await writeFixtureLogs(dir, { + events: [botEvent(windowSince, botRunStarted)], + launches: [], + stderr: [], + }); + const linkedRoot = join(dir, "linked-root"); + await linkSymbolic(dir, linkedRoot, "dir"); + await assert.rejects( + async () => + collectIncident({ + argv: commonArgs(linkedRoot), + now: fixtureDate, + root: rootDir, + }), + /symlinked log root path/u, + ); + + const linkedLogDir = join(dir, "linked-log-dir"); + await linkSymbolic(join(dir, "bot"), linkedLogDir, "dir"); + await assert.rejects( + async () => + collectIncident({ + argv: [ + logRootOption, + dir, + logDirOption, + linkedLogDir, + sinceOption, + windowSince, + untilOption, + windowUntil, + ], + now: fixtureDate, + root: rootDir, + }), + /symlinked log directory path/u, + ); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("refuses symlinked source logs and incident directory parents", async () => { + const dir = await tempDir(); + try { + const logDir = await writeFixtureLogs(dir, { + events: [botEvent(windowSince, botRunStarted)], + launches: [], + stderr: [], + }); + await rm(join(logDir, botEventsNdjson)); + await writeText(join(dir, "target-events"), botEvent(windowSince, botRunStarted)); + await linkSymbolic(join(dir, "target-events"), join(logDir, botEventsNdjson)); + + await assert.rejects( + async () => + collectIncident({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }), + /symlinked source log file/u, + ); + + await rm(join(logDir, botEventsNdjson)); + await writeText(join(logDir, botEventsNdjson), botEvent(windowSince, botRunStarted)); + await linkSymbolic(join(dir, "target-incidents"), join(logDir, "incidents"), "dir"); + + await assert.rejects( + async () => + collectIncident({ + argv: commonArgs(dir), + now: fixtureDate, + root: rootDir, + }), + /symlinked incident directory parent/u, + ); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); diff --git a/scripts/test/bot/incident/support.ts b/scripts/test/bot/incident/support.ts new file mode 100644 index 0000000..e013a3c --- /dev/null +++ b/scripts/test/bot/incident/support.ts @@ -0,0 +1,477 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import type { Stats } from "node:fs"; +import { + mkdir as fsMkdir, + open as fsOpen, + readFile as fsReadFile, + stat as fsStat, + symlink as fsSymlink, + writeFile as fsWriteFile, + mkdtemp, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseArgs, parseTimeBound } from "../../../bot/incident/args.ts"; +import { collectIncident } from "../../../bot/incident/index.ts"; + +export type CollectorOptions = Parameters[0]; +export type CollectorResult = Awaited>; +export type BundleResult = Extract; +export type TestFileHandle = Pick>, "close"> & { + readableWebStream: () => ReadableStream; + stat: () => Promise; +}; +export interface FixtureLogs { + events: string[]; + launches: string[]; + stderr: string[]; +} +export interface FixtureSlotLogs { + eventSlots: Record; + launches: string[]; + stderrSlots: Record; +} +export type JsonFields = Record; +export interface ArtifactSummary { + included: unknown[]; + mismatched: unknown[]; + missing: unknown[]; +} +export interface BotEventSummary { + countsByType: Record; + failureReasons: Record; + skipReasons: Record; + txHashesByOutcome: Record; +} +export interface CompressionSummary { + command: string; +} +export interface IncidentSummary { + artifacts: ArtifactSummary; + botEvents: BotEventSummary; + compression: CompressionSummary; + launches: { exitCodes: Record }; + logDir?: string; + logRoot?: string; + logRootSource?: string; + sources: Record; +} +export interface SourceSummary { + included?: boolean; + malformedLines?: number; + selectedLines?: number; + selectedUndatedLines?: number; + timestampedLines?: number; + undatedLines?: number; + undatedTailIncluded?: boolean; + undatedTailLimit?: number; +} +export interface VersionJson { + gitCommit: string | null; +} +export interface ReferencedArtifactFixture { + artifactFileName: string; + artifactHash: string; + artifactPath: string; +} + +export const { join, resolve } = path; +export const rootDir = fileURLToPath(new URL("../../../..", import.meta.url)); +export const collector = join(rootDir, "scripts", "bot", "collect-incident.ts"); +export const moduleDefaultFlag = "--experimental-default-type=module"; +export const canaryPrivateKey = `0x${"42".repeat(32)}`; +export const logRootOption = "--log-root"; +export const logDirOption = "--log-dir"; +export const sinceOption = "--since"; +export const untilOption = "--until"; +export const fixtureNow = "2026-05-25T12:00:00.000Z"; +export const windowSince = "2026-05-25T10:00:00.000Z"; +export const windowUntil = "2026-05-25T11:00:00.000Z"; +export const launcherStarted = "launcher.started"; +export const launcherChildExited = "launcher.child.exited"; +export const botRunStarted = "bot.run.started"; +export const botDecisionSkipped = "bot.decision.skipped"; +export const botRebalanceEvaluated = "bot.rebalance.evaluated"; +export const botEventsNdjson = "bot.events.ndjson"; +export const botStderrLog = "bot.stderr.log"; +export const slot00EventsNdjson = "bot.events.slot-00.ndjson"; +export const slot01EventsNdjson = "bot.events.slot-01.ndjson"; +export const slot00StderrLog = "bot.stderr.slot-00.log"; +export const slot01StderrLog = "bot.stderr.slot-01.log"; +export const launchesNdjson = "launches.ndjson"; +export const summaryJson = "summary.json"; +export const versionJson = "version.json"; +export const artifactsDirectory = "artifacts"; +export const slot00Directory = "slot-00"; +export const ringSegmentsDirectory = "ringSegments"; +export const ringSegmentsKind = "bot.ringSegments"; +export async function tempDir(): Promise { + return mkdtemp(join(tmpdir(), "ickb-bot-incident-")); +} + +export async function collectBundle(options: CollectorOptions): Promise { + const result = await collectIncident(options); + assert.ok(result.incidentDir !== undefined && result.incidentDir !== ""); + return result; +} + +export async function assertWindowedBundleOutputs(incidentDir: string): Promise { + assert.equal( + await readText(join(incidentDir, botStderrLog)), + [ + "2026-05-25T10:15:00.000Z runtime warning\n", + " at worker (bot.js:10:1)\n", + "undated warning\n", + ].join(""), + ); + + const eventBundle = await readText(join(incidentDir, botEventsNdjson)); + assert.match(eventBundle, /bot\.run\.started/u); + assert.match(eventBundle, /non-bot stdout/u); + assert.doesNotMatch(eventBundle, /09:59:59|11:00:01|not-json/u); +} + +export function windowedFixtureEvents(): string[] { + return [ + botEvent("2026-05-25T09:59:59.000Z", "bot.transaction.sent", { + outcome: "broadcasted", + txHash: txHash("01"), + }), + botEvent(windowSince, botRunStarted, { bounded: true }), + "not-json\n", + `${JSON.stringify({ + app: "execution", + timestamp: "2026-05-25T10:05:00.000Z", + message: "non-bot stdout", + })}\n`, + botEvent("2026-05-25T10:10:00.000Z", botDecisionSkipped, { + reason: "no_actions", + }), + botEvent("2026-05-25T10:20:00.000Z", "bot.transaction.sent", { + outcome: "broadcasted", + txHash: txHash("02"), + }), + botEvent("2026-05-25T10:21:00.000Z", "bot.transaction.failed", { + outcome: "timeout_after_broadcast", + txHash: txHash("02"), + }), + botEvent(windowUntil, "bot.iteration.failed", { + error: { message: "fetch failed" }, + retryable: true, + terminal: false, + }), + botEvent("2026-05-25T10:59:59.500Z", "bot.iteration.failed", { + error: { message: "fetch failed" }, + retryable: true, + terminal: true, + retryableAttempts: 3, + maxRetryableAttempts: 3, + retryBudgetExhausted: true, + }), + botEvent("2026-05-25T11:00:01.000Z", "bot.transaction.committed", { + outcome: "committed", + txHash: txHash("03"), + }), + ]; +} + +export function windowedFixtureLaunches(): string[] { + return [ + launch("2026-05-25T09:00:00.000Z", launcherStarted, { status: null }), + launch(windowSince, launcherStarted, { status: null }), + launch("2026-05-25T10:30:00.000Z", launcherChildExited, { status: 2 }), + "{bad-json\n", + launch("2026-05-25T11:00:01.000Z", launcherChildExited, { status: 0 }), + ]; +} + +export function windowedFixtureStderr(): string[] { + return [ + "2026-05-25T09:00:00.000Z before window\n", + "2026-05-25T10:15:00.000Z runtime warning\n", + " at worker (bot.js:10:1)\n", + "undated warning\n", + "2026-05-25T11:00:01.000Z after window\n", + "outside continuation\n", + ]; +} + +export function referencedArtifactFixture( + artifactText: string, +): ReferencedArtifactFixture { + const artifactHash = sha256Ref(artifactText); + const artifactFileName = `sha256-${artifactHash.slice("sha256:".length)}.json`; + return { + artifactFileName, + artifactHash, + artifactPath: ringSegmentsArtifactPath(artifactFileName), + }; +} + +export function referencedArtifactEvents(artifact: ReferencedArtifactFixture): string[] { + return [ + artifactEvent(windowSince, artifact.artifactHash, artifact.artifactPath), + artifactEvent( + "2026-05-25T10:01:00.000Z", + `sha256:${"b".repeat(64)}`, + ringSegmentsArtifactPath( + "sha256-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json", + ), + ), + ]; +} + +export function artifactEvent( + timestamp: string, + hash: string, + artifactPath: string, +): string { + return botEvent(timestamp, botRebalanceEvaluated, { + rebalance: { + diagnostics: { + ring: { + segmentsRef: { + kind: ringSegmentsKind, + hash, + path: artifactPath, + }, + }, + }, + }, + }); +} + +export async function assertWindowedBundleSummary( + incidentDir: string, + logDir: string, +): Promise { + const summary = await readIncidentSummary(incidentDir); + assert.equal(summary.logDir, logDir); + assert.deepEqual(summary.botEvents.countsByType, { + [botDecisionSkipped]: 1, + "bot.iteration.failed": 2, + [botRunStarted]: 1, + "bot.transaction.failed": 1, + "bot.transaction.sent": 1, + }); + assert.deepEqual(summary.botEvents.txHashesByOutcome, { + broadcasted: [txHash("02")], + timeout_after_broadcast: [txHash("02")], + }); + assert.deepEqual(summary.botEvents.skipReasons, { no_actions: 1 }); + assert.deepEqual(summary.botEvents.failureReasons, { + "fetch failed": 1, + retry_budget_exhausted: 1, + timeout_after_broadcast: 1, + }); + assert.deepEqual(summary.launches.exitCodes, { 2: 1 }); + assert.equal(sourceSummary(summary, botEventsNdjson).malformedLines, 1); + assert.equal(sourceSummary(summary, botEventsNdjson).selectedLines, 7); + assert.equal(sourceSummary(summary, botStderrLog).undatedLines, 3); + assert.equal(sourceSummary(summary, botStderrLog).selectedUndatedLines, 2); + assert.match(summary.compression.command, /tar -czf/u); +} + +export async function writeFixtureLogs( + root: string, + { events, launches, stderr }: FixtureLogs, +): Promise { + const logDir = join(root, "bot"); + await mkdirp(logDir); + await writeText(join(logDir, botEventsNdjson), events.join(""), { + mode: 0o600, + }); + await writeText(join(logDir, launchesNdjson), launches.join(""), { + mode: 0o600, + }); + await writeText(join(logDir, botStderrLog), stderr.join(""), { + mode: 0o600, + }); + return logDir; +} + +export async function writeFixtureSlotLogs( + root: string, + { eventSlots, launches, stderrSlots }: FixtureSlotLogs, +): Promise { + const logDir = join(root, "bot"); + await mkdirp(logDir); + for (const [name, lines] of Object.entries(eventSlots)) { + await writeText(join(logDir, name), lines.join(""), { mode: 0o600 }); + } + for (const [name, lines] of Object.entries(stderrSlots)) { + await writeText(join(logDir, name), lines.join(""), { mode: 0o600 }); + } + await writeText(join(logDir, launchesNdjson), launches.join(""), { + mode: 0o600, + }); + return logDir; +} + +export async function mkdirp(dir: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Collector tests create directories inside temp fixtures only. + await fsMkdir(dir, { recursive: true, mode: 0o700 }); +} + +export function botEvent( + timestamp: string, + type: string, + fields: JsonFields = {}, +): string { + return `${JSON.stringify({ + version: 1, + app: "bot", + chain: "testnet", + runId: "run-fixture", + iterationId: 1, + timestamp, + type, + ...fields, + })}\n`; +} + +export function launch(timestamp: string, type: string, fields: JsonFields = {}): string { + return `${JSON.stringify({ + version: 1, + app: "bot-launcher", + timestamp, + type, + status: null, + signal: null, + ...fields, + })}\n`; +} + +export function txHash(byte: string): string { + return `0x${byte.repeat(32)}`; +} + +export function sha256Ref(text: string): string { + return `sha256:${createHash("sha256").update(text).digest("hex")}`; +} + +export function commonArgs(logRoot: string): string[] { + return [ + logRootOption, + resolve(logRoot), + sinceOption, + windowSince, + untilOption, + windowUntil, + ]; +} + +export function fixtureDate(): Date { + return new Date(fixtureNow); +} + +export async function mode(filePath: string): Promise { + return (await statPath(filePath)).mode & 0o777; +} + +export async function openPath( + filePath: string, + flags: number, + fileMode?: number, +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- fsOpen returns Node's FileHandle, which structurally satisfies the collector dependency handle used by this test. + return fsOpen(filePath, flags, fileMode); +} + +export async function readIncidentSummary(incidentDir: string): Promise { + return parseIncidentSummary(await readText(join(incidentDir, summaryJson))); +} + +export async function readText(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Collector tests read repo-local scripts or their own temp fixture outputs. + return fsReadFile(filePath, "utf8"); +} + +export async function readVersionJson(incidentDir: string): Promise { + return parseVersionJson(await readText(join(incidentDir, versionJson))); +} + +export async function statPath(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Collector tests stat paths inside their own temp fixture directories. + return fsStat(filePath); +} + +export async function writeText( + filePath: string, + data: string, + options?: Parameters[2], +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Collector tests write files inside their own temp fixture directories. + await fsWriteFile(filePath, data, options); +} + +export async function linkSymbolic( + target: string, + linkPath: string, + type?: "dir" | "file" | "junction", +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Symlink tests intentionally create links inside their temp directory. + await fsSymlink(target, linkPath, type); +} + +export function splitReadHandle(filePath: string, chunks: Uint8Array[]): TestFileHandle { + return { + async close(): Promise { + await Promise.resolve(); + }, + readableWebStream(): ReadableStream { + return new ReadableStream({ + start(controller): void { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + }, + async stat(): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Split-read fixture stats the collector temp log file path. + return fsStat(filePath); + }, + }; +} + +export function parseIncidentSummary(text: string): IncidentSummary { + const value: unknown = JSON.parse(text); + if (isIncidentSummary(value)) { + return value; + } + throw new Error("Expected incident summary JSON"); +} + +export function parseVersionJson(text: string): VersionJson { + const value: unknown = JSON.parse(text); + if (isVersionJson(value)) { + return value; + } + throw new Error("Expected version JSON"); +} + +export function isIncidentSummary(value: unknown): value is IncidentSummary { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isVersionJson(value: unknown): value is VersionJson { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function ringSegmentsArtifactPath(fileName: string): string { + return `${artifactsDirectory}/${slot00Directory}/${ringSegmentsDirectory}/${fileName}`; +} + +export function sourceSummary(summary: IncidentSummary, name: string): SourceSummary { + const source = summary.sources[name]; + assert.ok(source !== undefined); + return source; +} +export { collectIncident, parseArgs, parseTimeBound, rm, spawnSync }; diff --git a/scripts/test/bot/launcher/failures.ts b/scripts/test/bot/launcher/failures.ts new file mode 100644 index 0000000..39c6e83 --- /dev/null +++ b/scripts/test/bot/launcher/failures.ts @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + LogSink, + PassThrough, + Writable, + assertLauncherOutputHidesCanary, + canaryPrivateKey, + childFixture, + copyBytes, + eventsSlot00File, + eventsSlot01File, + join, + lastLaunch, + linkSymbolic, + logRootOption, + readLaunches, + readText, + rm, + rootDir, + runBotLauncher, + runLauncher, + stderrSlot00File, + tempDir, + writeText, +} from "./support.ts"; + +void test("refuses log directories that escape the resolved root", async () => { + const dir = await tempDir(); + try { + const result = runLauncher( + dir, + ["--log-dir", join(dir, "..", "escaped")], + ["-e", ""], + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /inside the resolved log root/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("refuses symlinks in the log root, log path parents, and log files", async () => { + const dir = await tempDir(); + try { + const symlinkRoot = join(dir, "root-link"); + await linkSymbolic(dir, symlinkRoot, "dir"); + const symlinkRootResult = runLauncher(symlinkRoot, [], ["-e", ""]); + assert.equal(symlinkRootResult.status, 1); + assert.match(symlinkRootResult.stderr, /symlink/u); + + const botParent = join(dir, "bot"); + await linkSymbolic(join(dir, "target"), botParent, "dir"); + const symlinkParentResult = runLauncher(dir, [], ["-e", ""]); + assert.equal(symlinkParentResult.status, 1); + assert.match(symlinkParentResult.stderr, /symlink/u); + await rm(botParent, { force: true, recursive: true }); + + const good = runLauncher(dir, [], ["-e", ""]); + assert.equal(good.status, 0, good.stderr); + const eventPath = join(dir, "bot", eventsSlot01File); + await writeText(eventPath, ""); + await rm(eventPath); + await writeText(join(dir, "target-events"), ""); + await linkSymbolic(join(dir, "target-events"), eventPath); + const symlinkFileResult = runLauncher(dir, [], ["-e", ""]); + assert.equal(symlinkFileResult.status, 1); + assert.match(symlinkFileResult.stderr, /symlink/u); + + await rm(eventPath, { force: true, recursive: true }); + await rm(join(dir, "bot", "artifacts"), { force: true, recursive: true }); + await linkSymbolic( + join(dir, "target-artifacts"), + join(dir, "bot", "artifacts"), + "dir", + ); + const symlinkArtifactParentResult = runLauncher(dir, [], ["-e", ""]); + assert.equal(symlinkArtifactParentResult.status, 1); + assert.match(symlinkArtifactParentResult.stderr, /symlink/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("preserves child exit code 2 for systemd RestartPreventExitStatus", async () => { + const dir = await tempDir(); + try { + const result = runLauncher(dir, [], ["-e", "process.exit(2);"]); + assert.equal(result.status, 2); + + const launches = await readLaunches(join(dir, "bot")); + const childExit = lastLaunch(launches); + assert.equal(childExit.status, 2); + assert.equal(childExit.signal, null); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("tee failures do not override child exit semantics or file logs", async () => { + const dir = await tempDir(); + try { + const failingTee = new Writable({ + write(_chunk, _encoding, callback): void { + callback(new Error("journald pipe closed")); + }, + }); + const result = await runBotLauncher({ + argv: [ + logRootOption, + dir, + "--", + process.execPath, + "-e", + String.raw`process.stdout.write('event\n'); process.stderr.write('stderr\n'); process.exit(2);`, + ], + root: rootDir, + stderr: failingTee, + stdout: failingTee, + }); + + assert.deepEqual(result, { status: 2 }); + const logDir = join(dir, "bot"); + assert.equal(await readText(join(logDir, eventsSlot00File)), "event\n"); + assert.equal(await readText(join(logDir, stderrSlot00File)), "stderr\n"); + const launches = await readLaunches(logDir); + assert.equal(lastLaunch(launches).status, 2); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("reports asynchronous child spawn errors", async () => { + const dir = await tempDir(); + try { + let stderr = ""; + const result = await runBotLauncher({ + argv: [logRootOption, dir, "--", "missing-binary"], + root: rootDir, + spawnProcess() { + const child = childFixture(); + queueMicrotask((): void => { + child.emit("error", new Error("spawn ENOENT")); + }); + return child; + }, + stderr: { + write(chunk: string | Uint8Array): boolean { + stderr += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }, + }, + }); + + assert.deepEqual(result, { status: 1 }); + assert.match(stderr, /Failed to spawn child process: spawn ENOENT/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("copy failures destroy the readable stream", async () => { + const readable = new PassThrough(); + const failure = new Error("disk full"); + const copy = copyBytes( + readable, + { + async write(): Promise { + await Promise.resolve(); + throw failure; + }, + }, + new Writable({ + write(_chunk, _encoding, callback): void { + callback(); + }, + }), + ); + + readable.write("event\n"); + + await assert.rejects(copy, /disk full/u); + assert.equal(readable.destroyed, true); +}); + +void test("copy failures reject even when stream destroy does not emit error", async () => { + const readable = new PassThrough(); + const failure = new Error("disk full"); + readable.destroy = (): PassThrough => readable; + const copy = copyBytes( + readable, + { + async write(): Promise { + await Promise.resolve(); + throw failure; + }, + }, + new Writable({ + write(_chunk, _encoding, callback): void { + callback(); + }, + }), + ); + + readable.write("event\n"); + + await assert.rejects(copy, /disk full/u); +}); + +void test("log sinks close file handles after pending write failures", async () => { + let closed = false; + const sink = new LogSink({ + async appendFile(): Promise { + await Promise.resolve(); + throw new Error("disk full"); + }, + async close(): Promise { + await Promise.resolve(); + closed = true; + }, + }); + + const write = sink.write("event\n"); + + await assert.rejects(write, /disk full/u); + await assert.rejects(sink.close(), /disk full/u); + assert.equal(closed, true); +}); + +void test("preserves child signal termination", async () => { + const dir = await tempDir(); + try { + const result = runLauncher(dir, [], ["-e", "process.kill(process.pid, 'SIGTERM');"]); + assert.equal(result.signal, "SIGTERM"); + + const launches = await readLaunches(join(dir, "bot")); + const childExit = lastLaunch(launches); + assert.equal(childExit.status, null); + assert.equal(childExit.signal, "SIGTERM"); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("launcher metadata does not expose configured canary secrets", async () => { + const dir = await tempDir(); + try { + const result = runLauncher(dir, [], ["-e", "process.exit(0);", canaryPrivateKey], { + ICKB_TESTNET_BOT_PRIVATE_KEY: canaryPrivateKey, + }); + assert.equal(result.status, 0, result.stderr); + + await assertLauncherOutputHidesCanary(dir, result.stdout, result.stderr); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("launcher passes child environment without logging canary secrets", async () => { + const dir = await tempDir(); + try { + const result = runLauncher( + dir, + [], + ["-e", "process.exit(process.env.IKCB_CANARY_CHILD_ENV === undefined ? 7 : 0);"], + { + IKCB_CANARY_CHILD_ENV: canaryPrivateKey, + }, + ); + assert.equal(result.status, 0, result.stderr); + + await assertLauncherOutputHidesCanary(dir, result.stdout, result.stderr); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); diff --git a/scripts/test/bot/launcher/logs.ts b/scripts/test/bot/launcher/logs.ts new file mode 100644 index 0000000..9838166 --- /dev/null +++ b/scripts/test/bot/launcher/logs.ts @@ -0,0 +1,417 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + type SpawnedCommand, + artifactRefSlot00, + basenameOfNode, + botSourceCommand, + childFixture, + eventsSlot00File, + eventsSlot01File, + join, + launchAt, + launchesFile, + logRootOption, + logStorageQuotaOption, + makeDirectory, + parseArgs, + pathMode, + readBytes, + readLaunches, + readText, + resolve, + resolveLauncherPaths, + rm, + rootDir, + runBotLauncher, + runLauncher, + runLauncherBuffer, + selectRunLogs, + statPath, + stderrSlot00File, + tempDir, + writeText, +} from "./support.ts"; + +void test("parses launcher arguments", () => { + assert.deepEqual( + parseArgs([logRootOption, "var/bot-log", "--", process.execPath, botSourceCommand]), + { + command: process.execPath, + commandArgs: [botSourceCommand], + logDir: undefined, + logRoot: "var/bot-log", + logStorageQuotaBytes: undefined, + teeChildOutput: true, + }, + ); + assert.deepEqual(parseArgs([]), { + command: undefined, + commandArgs: [], + logDir: undefined, + logRoot: undefined, + logStorageQuotaBytes: undefined, + teeChildOutput: true, + }); + assert.deepEqual(parseArgs([logStorageQuotaOption, "123", "--no-child-tee"]), { + command: undefined, + commandArgs: [], + logDir: undefined, + logRoot: undefined, + logStorageQuotaBytes: 123, + teeChildOutput: false, + }); + + assert.throws( + () => parseArgs([logRootOption, "--", process.execPath]), + /Missing value for --log-root/u, + ); + assert.throws(() => parseArgs([logStorageQuotaOption, "0"]), /positive integer/u); +}); + +void test("resolves explicit log root before runtime env and keeps log dirs contained", () => { + // eslint-disable-next-line sonarjs/publicly-writable-directories -- This test only resolves path strings and never accesses /tmp. + const root = resolve("/tmp/ickb-stack"); + assert.deepEqual( + resolveLauncherPaths({ + cliLogRoot: "cli-log", + envLogRoot: "env-log", + root, + }), + { + logDir: join(root, "cli-log", "bot"), + logRoot: join(root, "cli-log"), + }, + ); + + assert.deepEqual( + resolveLauncherPaths({ + envLogRoot: "env-log", + root, + }), + { + logDir: join(root, "env-log", "bot"), + logRoot: join(root, "env-log"), + }, + ); + + assert.throws( + () => + resolveLauncherPaths({ + cliLogRoot: join(root, "log"), + logDir: join(root, "outside"), + root, + }), + /inside the resolved log root/u, + ); +}); + +void test("writes stdout, stderr, and launch metadata to separate append-only files", async () => { + const dir = await tempDir(); + try { + const first = runLauncher( + dir, + [], + [ + "-e", + String.raw`process.stdout.write('event-1\n'); process.stderr.write('stderr-1\n');`, + ], + ); + assert.equal(first.status, 0, first.stderr); + assert.equal(first.stdout, "event-1\n"); + assert.equal(first.stderr, "stderr-1\n"); + + const second = runLauncher( + dir, + [], + [ + "-e", + String.raw`process.stdout.write('event-2\n'); process.stderr.write('stderr-2\n');`, + ], + ); + assert.equal(second.status, 0, second.stderr); + + const logDir = join(dir, "bot"); + assert.equal(await readText(join(logDir, eventsSlot00File)), "event-1\n"); + assert.equal(await readText(join(logDir, stderrSlot00File)), "stderr-1\n"); + assert.equal(await readText(join(logDir, eventsSlot01File)), "event-2\n"); + assert.equal(await readText(join(logDir, "bot.stderr.slot-01.log")), "stderr-2\n"); + assert.equal(await pathMode(join(logDir, eventsSlot00File)), 0o600); + assert.equal(await pathMode(join(logDir, stderrSlot00File)), 0o600); + assert.equal(await pathMode(join(logDir, launchesFile)), 0o600); + + const launches = await readLaunches(logDir); + assert.equal(launches.length, 4); + const firstLaunch = launchAt(launches, 0); + const childExit = launchAt(launches, 1); + const secondLaunch = launchAt(launches, 2); + assert.equal(firstLaunch.type, "launcher.started"); + assert.equal("network" in firstLaunch, false); + assert.deepEqual(firstLaunch.logSlot, { index: 0, count: 16, name: "slot-00" }); + assert.deepEqual(firstLaunch.logFiles, { + artifacts: join(logDir, "artifacts", "slot-00"), + events: join(logDir, eventsSlot00File), + launches: join(logDir, launchesFile), + stderr: join(logDir, stderrSlot00File), + }); + assert.equal(firstLaunch.status, null); + assert.equal(firstLaunch.signal, null); + assert.equal(firstLaunch.elapsedMs, 0); + const firstCommand = firstLaunch.command; + assert.ok(firstCommand !== undefined); + assert.equal(firstCommand.executable, basenameOfNode()); + assert.equal(firstCommand.argumentCount, 2); + assert.deepEqual(firstCommand.arguments, [ + { index: 0, value: "" }, + { index: 1, value: "" }, + ]); + assert.equal(childExit.type, "launcher.child.exited"); + assert.equal(childExit.status, 0); + assert.equal(childExit.signal, null); + assert.equal(childExit.logRoot, dir); + assert.equal(childExit.logDir, logDir); + assert.equal(childExit.package?.name, "@ickb/bot-cli"); + assert.deepEqual(secondLaunch.logSlot, { index: 1, count: 16, name: "slot-01" }); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("copies stdout and stderr bytes without rewriting", async () => { + const dir = await tempDir(); + try { + const result = runLauncherBuffer( + dir, + [], + [ + "-e", + "process.stdout.write(Buffer.from([0, 255, 10])); process.stderr.write(Buffer.from([1, 254, 10]));", + ], + ); + assert.equal(result.status, 0, result.stderr.toString("utf8")); + assert.deepEqual(result.stdout, Buffer.from([0, 255, 10])); + assert.deepEqual(result.stderr, Buffer.from([1, 254, 10])); + + const logDir = join(dir, "bot"); + assert.deepEqual( + await readBytes(join(logDir, eventsSlot00File)), + Buffer.from([0, 255, 10]), + ); + assert.deepEqual( + await readBytes(join(logDir, stderrSlot00File)), + Buffer.from([1, 254, 10]), + ); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("reuses fixed run log slots by truncating the selected slot", async () => { + const dir = await tempDir(); + try { + for (let index = 0; index < 17; index += 1) { + const result = runLauncher( + dir, + [], + [ + "-e", + String.raw`process.stdout.write('event-${String(index)}\n'); process.stderr.write('stderr-${String(index)}\n');`, + ], + ); + assert.equal(result.status, 0, result.stderr); + } + + const logDir = join(dir, "bot"); + assert.equal(await readText(join(logDir, eventsSlot00File)), "event-16\n"); + assert.equal(await readText(join(logDir, stderrSlot00File)), "stderr-16\n"); + assert.equal(await readText(join(logDir, eventsSlot01File)), "event-1\n"); + assert.deepEqual((await selectRunLogs(logDir)).slot, { + index: 1, + count: 16, + name: "slot-01", + }); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("can keep child output out of systemd tee while writing files", async () => { + const dir = await tempDir(); + try { + const result = runLauncher( + dir, + ["--no-child-tee"], + [ + "-e", + String.raw`process.stdout.write('event\n'); process.stderr.write('stderr\n');`, + ], + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); + + const logDir = join(dir, "bot"); + assert.equal(await readText(join(logDir, eventsSlot00File)), "event\n"); + assert.equal(await readText(join(logDir, stderrSlot00File)), "stderr\n"); + const launches = await readLaunches(logDir); + assert.equal(launchAt(launches, 0).teeChildOutput, false); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("uses the source bot as the default child command", async () => { + const dir = await tempDir(); + try { + let spawned: SpawnedCommand | undefined; + const result = await runBotLauncher({ + argv: [logRootOption, dir], + root: rootDir, + spawnProcess(command, args, options) { + spawned = { args, command, options }; + const child = childFixture({ pid: 1234 }); + queueMicrotask((): void => { + child.emit("close", 0, null); + }); + return child; + }, + }); + + assert.deepEqual(result, { status: 0 }); + assert.ok(spawned !== undefined); + assert.equal(spawned.command, process.execPath); + assert.deepEqual(spawned.args, [botSourceCommand]); + assert.equal(spawned.options.cwd, rootDir); + assert.ok(spawned.options.env !== undefined); + assert.equal( + spawned.options.env["BOT_ARTIFACT_ROOT"], + join(dir, "bot", "artifacts", "slot-00"), + ); + assert.equal(spawned.options.env["BOT_ARTIFACT_REF_PREFIX"], artifactRefSlot00); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("passes per-slot artifact root to child", async () => { + const dir = await tempDir(); + try { + const result = runLauncher( + dir, + [], + [ + "-e", + `process.stdout.write(\`\${process.env.BOT_ARTIFACT_REF_PREFIX} \${process.env.BOT_ARTIFACT_ROOT.endsWith('/${artifactRefSlot00}')}\\n\`);`, + ], + ); + assert.equal(result.status, 0, result.stderr); + + const logDir = join(dir, "bot"); + assert.equal( + await readText(join(logDir, eventsSlot00File)), + `${artifactRefSlot00} true\n`, + ); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("resets artifact directories when a run slot is reused", async () => { + const dir = await tempDir(); + try { + const first = runLauncher(dir, [], ["-e", ""]); + assert.equal(first.status, 0, first.stderr); + const staleArtifactDir = join(dir, "bot", "artifacts", "slot-00", "ringSegments"); + const staleArtifactPath = join(staleArtifactDir, `sha256-${"a".repeat(64)}.json`); + await makeDirectory(staleArtifactDir, { recursive: true }); + await writeText(staleArtifactPath, "stale\n"); + + for (let index = 1; index < 17; index += 1) { + const result = runLauncher(dir, [], ["-e", ""]); + assert.equal(result.status, 0, result.stderr); + } + + await assert.rejects(async () => readText(staleArtifactPath), /ENOENT/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("storage quota prunes inactive run slots and artifact dirs", async () => { + const dir = await tempDir(); + try { + for (let index = 0; index < 3; index += 1) { + const result = runLauncher( + dir, + [logStorageQuotaOption, "900"], + ["-e", String.raw`process.stdout.write('${"x".repeat(500)}\n');`], + ); + assert.equal(result.status, 0, result.stderr); + } + + const logDir = join(dir, "bot"); + await assert.rejects(async () => readText(join(logDir, eventsSlot00File)), /ENOENT/u); + assert.equal( + await readText(join(logDir, "bot.events.slot-02.ndjson")), + `${"x".repeat(500)}\n`, + ); + assert.equal((await statPath(join(logDir, launchesFile))).isFile(), true); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("storage quota ignores stale bytes in the slot selected for truncation", async () => { + const dir = await tempDir(); + try { + const logDir = join(dir, "bot"); + await makeDirectory(logDir, { recursive: true }); + await writeText( + join(logDir, launchesFile), + `${JSON.stringify({ + app: "bot-launcher", + type: "launcher.started", + logSlot: { index: 15, count: 16, name: "slot-15" }, + })}\n`, + ); + await writeText(join(logDir, eventsSlot00File), `${"x".repeat(1_000)}\n`); + await writeText(join(logDir, stderrSlot00File), `${"x".repeat(1_000)}\n`); + await writeText(join(logDir, eventsSlot01File), "keep\n"); + + const result = runLauncher( + dir, + [logStorageQuotaOption, "500"], + ["-e", String.raw`process.stdout.write('current\n');`], + ); + assert.equal(result.status, 0, result.stderr); + + assert.equal(await readText(join(logDir, eventsSlot00File)), "current\n"); + assert.equal(await readText(join(logDir, eventsSlot01File)), "keep\n"); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("storage quota can be configured from the launcher environment", async () => { + const dir = await tempDir(); + try { + for (let index = 0; index < 3; index += 1) { + const result = runLauncher( + dir, + [], + ["-e", String.raw`process.stdout.write('${"x".repeat(500)}\n');`], + { ICKB_BOT_LOG_STORAGE_QUOTA_BYTES: "900" }, + ); + assert.equal(result.status, 0, result.stderr); + } + + const logDir = join(dir, "bot"); + await assert.rejects(async () => readText(join(logDir, eventsSlot00File)), /ENOENT/u); + assert.equal( + await readText(join(logDir, "bot.events.slot-02.ndjson")), + `${"x".repeat(500)}\n`, + ); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); diff --git a/scripts/test/bot/launcher/support.ts b/scripts/test/bot/launcher/support.ts new file mode 100644 index 0000000..57efedc --- /dev/null +++ b/scripts/test/bot/launcher/support.ts @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import { spawnSync, type SpawnOptions, type SpawnSyncReturns } from "node:child_process"; +import { EventEmitter } from "node:events"; +import type { Stats } from "node:fs"; +import { + mkdir as fsMkdir, + readFile as fsReadFile, + stat as fsStat, + symlink as fsSymlink, + writeFile as fsWriteFile, + mkdtemp, + rm, +} from "node:fs/promises"; +import path from "node:path"; +import { PassThrough, Writable, type Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +import { + copyBytes, + LogSink, + parseArgs, + resolveLauncherPaths, + runBotLauncher, + selectRunLogs, +} from "../../../bot/launcher/index.ts"; +export { + copyBytes, + LogSink, + parseArgs, + PassThrough, + resolveLauncherPaths, + runBotLauncher, + selectRunLogs, + Writable, +}; + +export const rootDir = fileURLToPath(new URL("../../../..", import.meta.url)); +export const { join, resolve } = path; +export const launcher = join(rootDir, "scripts", "bot", "launcher.ts"); +export const moduleDefaultFlag = "--experimental-default-type=module"; +export const canaryPrivateKey = `0x${"42".repeat(32)}`; +export const logRootOption = "--log-root"; +export const logStorageQuotaOption = "--log-storage-quota-bytes"; +export const botSourceCommand = "apps/bot/src/index.ts"; +export const eventsSlot00File = "bot.events.slot-00.ndjson"; +export const stderrSlot00File = "bot.stderr.slot-00.log"; +export const eventsSlot01File = "bot.events.slot-01.ndjson"; +export const launchesFile = "launches.ndjson"; +export const artifactRefSlot00 = "artifacts/slot-00"; + +export interface SpawnedCommand { + args: string[]; + command: string; + options: SpawnOptions; +} + +export interface LaunchRecord extends Record { + command?: { argumentCount?: unknown; arguments?: unknown; executable?: unknown }; + elapsedMs?: unknown; + logDir?: unknown; + logFiles?: unknown; + logRoot?: unknown; + logSlot?: unknown; + package?: { name?: unknown }; + signal?: unknown; + status?: unknown; + teeChildOutput?: unknown; + type?: unknown; +} + +// eslint-disable-next-line unicorn/prefer-event-target -- runBotLauncher models Node child-process EventEmitter semantics. +export class FixtureChild extends EventEmitter { + public exitCode: number | null = null; + public killed = false; + public readonly pid?: number; + public readonly stderr: Readable | null = null; + public readonly stdout: Readable | null = null; + + constructor(pid?: number) { + super(); + this.pid = pid; + } + + public kill(): boolean { + this.killed = true; + return true; + } +} +export async function assertLauncherOutputHidesCanary( + dir: string, + stdout: string | Buffer | null, + stderr: string | Buffer | null, +): Promise { + const logDir = join(dir, "bot"); + const produced = [ + stdout, + stderr, + await readText(join(logDir, eventsSlot00File)), + await readText(join(logDir, stderrSlot00File)), + await readText(join(logDir, launchesFile)), + ].join("\n"); + // eslint-disable-next-line security/detect-non-literal-regexp -- The canary is a fixed test secret used only to assert output redaction. + assert.doesNotMatch(produced, new RegExp(canaryPrivateKey, "u")); +} + +export async function tempDir(): Promise { + return mkdtemp(join("/tmp", "ickb-bot-launcher-")); +} + +export function childFixture({ pid }: { pid?: number } = {}): FixtureChild { + return new FixtureChild(pid); +} + +export function runLauncher( + logRoot: string, + launcherArgs: string[], + childArgs: string[], + extraEnv: NodeJS.ProcessEnv = {}, +): SpawnSyncReturns { + return spawnSync( + process.execPath, + [ + moduleDefaultFlag, + launcher, + logRootOption, + logRoot, + ...launcherArgs, + "--", + process.execPath, + ...childArgs, + ], + { + cwd: rootDir, + encoding: "utf8", + env: { + ...process.env, + ...extraEnv, + }, + }, + ); +} + +export function runLauncherBuffer( + logRoot: string, + launcherArgs: string[], + childArgs: string[], +): SpawnSyncReturns { + return spawnSync( + process.execPath, + [ + moduleDefaultFlag, + launcher, + logRootOption, + logRoot, + ...launcherArgs, + "--", + process.execPath, + ...childArgs, + ], + { cwd: rootDir }, + ); +} + +export async function readLaunches(logDir: string): Promise { + const text = await readText(join(logDir, launchesFile)); + return text.trim().split("\n").map(parseLaunchRecord); +} + +export async function makeDirectory( + directory: string, + options: { recursive: true }, +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Test paths are temp dirs or repo-local launcher fixture paths. + await fsMkdir(directory, options); +} + +export async function readBytes(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Test paths are temp dirs or repo-local launcher fixture paths. + return fsReadFile(filePath); +} + +export async function readText(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Test paths are temp dirs or repo-local launcher fixture paths. + return fsReadFile(filePath, "utf8"); +} + +export async function statPath(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Test paths are temp dirs or repo-local launcher fixture paths. + return fsStat(filePath); +} + +export async function pathMode(filePath: string): Promise { + return (await statPath(filePath)).mode & 0o777; +} + +export async function writeText(filePath: string, data: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Test paths are temp dirs or repo-local launcher fixture paths. + await fsWriteFile(filePath, data); +} + +export async function linkSymbolic( + target: string, + linkPath: string, + type?: "dir" | "file" | "junction", +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Symlink tests intentionally create links inside their temp directory. + await fsSymlink(target, linkPath, type); +} + +export function basenameOfNode(): string | undefined { + return process.execPath.split(/[\\/]/u).at(-1); +} + +export function parseLaunchRecord(line: string): LaunchRecord { + const parsed: unknown = JSON.parse(line); + if (isRecord(parsed)) { + return parsed; + } + throw new Error("Expected launcher record"); +} + +export function launchAt(launches: LaunchRecord[], index: number): LaunchRecord { + const launch = launches[index]; + assert.ok(launch !== undefined); + return launch; +} + +export function lastLaunch(launches: LaunchRecord[]): LaunchRecord { + const launch = launches.at(-1); + assert.ok(launch !== undefined); + return launch; +} + +export function isRecord(value: unknown): value is LaunchRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +export { rm }; diff --git a/scripts/test/bot/systemd-credential.ts b/scripts/test/bot/systemd-credential.ts new file mode 100644 index 0000000..46bbe70 --- /dev/null +++ b/scripts/test/bot/systemd-credential.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { readFile as fsReadFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const { join } = path; +const rootDir = fileURLToPath(new URL("../../..", import.meta.url)); +const script = join(rootDir, "scripts", "ickb-bot-systemd-credential.sh"); +const bashPath = "/usr/bin/bash"; +const privateKey = `0x${"11".repeat(32)}`; + +void test("credential helper requires Node 22.19 for source config validation", async () => { + const text = await readScript(); + + assert.match(text, /Node\.js >=22\.19\.0/u); + assert.match(text, /minor >= 19/u); +}); + +void test("credential helper validation uses the shared runtime parser", () => { + const config = JSON.stringify({ + chain: "testnet", + privateKey, + rpcUrl: "http://127.0.0.1:8114/", + sleepIntervalSeconds: 60, + maxRetryableAttempts: 10, + }); + + const valid = validateConfig("testnet", config); + assert.equal(valid.status, 0, valid.stderr); + assert.equal(valid.stdout, config); + + const defaultRpcConfig = JSON.stringify({ + chain: "testnet", + privateKey, + sleepIntervalSeconds: 60, + maxRetryableAttempts: 10, + }); + const validDefaultRpc = validateConfig("testnet", defaultRpcConfig); + assert.equal(validDefaultRpc.status, 0, validDefaultRpc.stderr); + assert.equal(validDefaultRpc.stdout, defaultRpcConfig); + + const unboundedRetryConfig = JSON.stringify({ + chain: "testnet", + privateKey, + sleepIntervalSeconds: 60, + }); + const validUnboundedRetry = validateConfig("testnet", unboundedRetryConfig); + assert.equal(validUnboundedRetry.status, 0, validUnboundedRetry.stderr); + assert.equal(validUnboundedRetry.stdout, unboundedRetryConfig); + + const wrongChain = validateConfig("mainnet", config); + assert.equal(wrongChain.status, 1); + assert.match(wrongChain.stderr, /Invalid bot config/u); + + const invalidKey = validateConfig( + "testnet", + JSON.stringify({ + chain: "testnet", + privateKey: `${privateKey}\n`, + rpcUrl: "http://127.0.0.1:8114/", + sleepIntervalSeconds: 60, + }), + ); + assert.equal(invalidKey.status, 1); + assert.doesNotMatch(invalidKey.stderr, /0x11/u); +}); + +void test("credential helper does not echo RPC URL input", async () => { + const text = await readScript(); + + assert.doesNotMatch(text, /systemd-ask-password --echo=yes/u); +}); + +void test("credential helper prompts for retryable-attempt budget", async () => { + const text = await readScript(); + + assert.match(text, /max retryable attempts/u); + assert.match(text, /empty for unbounded/u); + assert.match(text, /maxRetryableAttempts/u); +}); + +async function readScript(): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test reads the fixed credential helper script under the repository root. + return fsReadFile(script, "utf8"); +} + +function validateConfig(network: string, input: string): SpawnSyncReturns { + return spawnSync( + bashPath, + ["-c", `source "$1"; validate_config "$2" "$3"`, "bash", script, network, rootDir], + { + cwd: rootDir, + input, + encoding: "utf8", + }, + ); +} diff --git a/scripts/test/bot/systemd-install.ts b/scripts/test/bot/systemd-install.ts new file mode 100644 index 0000000..df18779 --- /dev/null +++ b/scripts/test/bot/systemd-install.ts @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import type { Stats } from "node:fs"; +import { + chmod as fsChmod, + readFile as fsReadFile, + stat as fsStat, + symlink as fsSymlink, + writeFile as fsWriteFile, + mkdtemp, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const { join } = path; +const rootDir = fileURLToPath(new URL("../../..", import.meta.url)); +const installScript = join(rootDir, "scripts", "ickb-bot-systemd-install.sh"); +const bashPath = "/usr/bin/bash"; + +void test("systemd install units run the source bot through the file-log launcher", async () => { + const text = await readInstallScript(); + + assert.match( + text, + /ExecStart=\/usr\/bin\/node --experimental-default-type=module scripts\/bot\/launcher\.ts --no-child-tee/u, + ); + assert.doesNotMatch(text, /ExecStart=\/usr\/bin\/node apps\/bot\/src\/index\.ts/u); + assert.match(text, /Environment=BOT_CONFIG_FILE=%d\/\$\{credential_name\}/u); + assert.match(text, /LoadCredentialEncrypted=\$\{credential_name\}:\$\{credential\}/u); + assert.match(text, /RestartSec=60/u); + assert.match(text, /RestartPreventExitStatus=2/u); + assert.match(text, /LimitCORE=0/u); + assert.match(text, /ProtectSystem=strict/u); + assert.match(text, /ReadWritePaths=\$\{log_root_path\}/u); +}); + +void test("systemd install script requires Node 22.19 for source units", async () => { + const text = await readInstallScript(); + + assert.match(text, /Node\.js >=22\.19\.0/u); + assert.match(text, /minor >= 19/u); + assert.doesNotMatch(text, /Node\.js >=22 is required/u); +}); + +void test("systemd install node version helper rejects early Node 22", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-node-version-")); + try { + const fakeNode = join(dir, "node"); + await writeText( + fakeNode, + `#!/usr/bin/env bash +version=\${FAKE_NODE_VERSION:?} +if [[ \${1:-} == --version ]]; then + printf 'v%s\\n' "\${version}" + exit 0 +fi +IFS=. read -r major minor _ <<<"\${version}" +if (( major > 22 || (major == 22 && minor >= 19) )); then + exit 0 +fi +exit 1 +`, + ); + await chmodPath(fakeNode, 0o755); + + assert.equal(requireNodeVersion(fakeNode, "22.18.0").status, 1); + assert.equal(requireNodeVersion(fakeNode, "22.19.0").status, 0); + assert.equal(requireNodeVersion(fakeNode, "23.0.0").status, 0); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd install script uses the invoking checkout as deployment root", async () => { + const text = await readInstallScript(); + + assert.match(text, /deploy_dir=\$\(pwd -P\)/u); + assert.match(text, /log_root_path="\$\{deploy_dir\}\/log"/u); + assert.doesNotMatch(text, /\/opt\/ickb-stack-/u); + assert.doesNotMatch(text, /\/var\/log/u); + assert.doesNotMatch(text, /logs\/live-supervisor/u); +}); + +void test("systemd install script keeps generated units on the checkout-local log root", async () => { + const text = await readInstallScript(); + + assert.match(text, /log_root_path="\$\{deploy_dir\}\/log"/u); + assert.match( + text, + /ExecStart=\/usr\/bin\/node --experimental-default-type=module scripts\/bot\/launcher\.ts --no-child-tee/u, + ); + assert.doesNotMatch(text, /--network \$\{network\}/u); + assert.doesNotMatch(text, /ICKB_BOT_LOG_ROOT/u); + assert.doesNotMatch(text, /--log-root \$\{log_root_path\}/u); +}); + +void test("systemd install script validates optional log storage quota", () => { + assert.equal( + validatePositiveInteger("ICKB_BOT_LOG_STORAGE_QUOTA_BYTES", "1000").status, + 0, + ); + for (const value of ["", "0", "abc", "9007199254740993"]) { + const invalid = validatePositiveInteger("ICKB_BOT_LOG_STORAGE_QUOTA_BYTES", value); + assert.equal(invalid.status, 1, value); + assert.match(invalid.stderr, /positive safe integer/u); + } +}); + +void test("systemd install script creates log dirs without following symlinks", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-install-")); + try { + const uid = String(process.getuid?.() ?? 0); + const gid = String(process.getgid?.() ?? 0); + const createdPath = join(dir, "log", "bot"); + const created = safeInstallDirectory(createdPath, "700", uid, gid); + assert.equal(created.status, 0, created.stderr); + assert.equal(await pathMode(createdPath), 0o700); + + const linkPath = join(dir, "link"); + await linkSymbolic(dir, linkPath, "dir"); + const refused = safeInstallDirectory(join(linkPath, "bot"), "755", uid, gid); + assert.equal(refused.status, 1); + assert.match(refused.stderr, /Refusing symlinked directory path/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +async function readInstallScript(): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test reads the fixed systemd install helper script under the repository root. + return fsReadFile(installScript, "utf8"); +} + +async function chmodPath(filePath: string, mode: number): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test modifies files inside its own temporary fixture directory. + await fsChmod(filePath, mode); +} + +async function linkSymbolic( + target: string, + linkPath: string, + type: "dir" | "file" | "junction", +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Symlink tests intentionally create links inside their temp directory. + await fsSymlink(target, linkPath, type); +} + +async function statPath(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test stats paths inside its own temporary fixture directory. + return fsStat(filePath); +} + +async function pathMode(filePath: string): Promise { + return (await statPath(filePath)).mode & 0o777; +} + +async function writeText(filePath: string, data: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test writes files inside its own temporary fixture directory. + await fsWriteFile(filePath, data); +} + +function validatePositiveInteger(name: string, value: string): SpawnSyncReturns { + return spawnSync( + bashPath, + [ + "-c", + 'source "$1"; require_systemd_safe_positive_integer "$2" "$3"', + "bash", + installScript, + name, + value, + ], + { + cwd: rootDir, + encoding: "utf8", + }, + ); +} + +function requireNodeVersion(nodePath: string, version: string): SpawnSyncReturns { + return spawnSync( + bashPath, + ["-c", 'source "$1"; require_node_22_19 "$2" test', "bash", installScript, nodePath], + { + cwd: rootDir, + encoding: "utf8", + env: { ...process.env, FAKE_NODE_VERSION: version }, + }, + ); +} + +function safeInstallDirectory( + directory: string, + mode: string, + uid: string, + gid: string, +): SpawnSyncReturns { + return spawnSync( + bashPath, + [ + "-c", + 'source "$1"; safe_install_directory "$2" "$3" "$4" "$5"', + "bash", + installScript, + directory, + mode, + uid, + gid, + ], + { cwd: rootDir, encoding: "utf8" }, + ); +} diff --git a/scripts/test/bot/systemd-update.ts b/scripts/test/bot/systemd-update.ts new file mode 100644 index 0000000..f1efead --- /dev/null +++ b/scripts/test/bot/systemd-update.ts @@ -0,0 +1,428 @@ +import assert from "node:assert/strict"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { + chmod as fsChmod, + mkdir as fsMkdir, + readFile as fsReadFile, + writeFile as fsWriteFile, + mkdtemp, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const { join } = path; +const rootDir = fileURLToPath(new URL("../../..", import.meta.url)); +const updateScript = join(rootDir, "scripts", "ickb-bot-systemd-update.sh"); +const bashPath = "/usr/bin/bash"; +const updateTempPrefix = "ickb-bot-systemd-update-"; +const testnetUnitFile = "ickb-bot-testnet.service"; +const testnet = "testnet"; +const productionLauncherFileLogging = /production launcher file logging/u; +const shellExpansionStart = "$".concat("{"); + +void test("systemd update script requires Node 22.19 for source units", async () => { + const text = await readUpdateScript(); + + assert.match(text, /Node\.js >=22\.19\.0/u); + assert.match(text, /minor >= 19/u); + assert.doesNotMatch(text, /Node\.js >=22 is required/u); +}); + +void test("systemd update accepts launcher-wired units", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText(unitPath, unitText({ network: testnet, launcher: true })); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 0, result.stderr); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update refuses launcher-wired units with explicit log roots", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText( + unitPath, + unitText({ + network: testnet, + launcher: true, + logRoot: "/srv/ickb/log", + }), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 1); + assert.match(result.stderr, productionLauncherFileLogging); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update reads unit files without trailing newlines", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText( + unitPath, + unitText({ network: testnet, launcher: true }).replace(/\n$/u, ""), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 0, result.stderr); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update accepts generated units with log storage quotas", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText( + unitPath, + unitText({ + network: testnet, + launcher: true, + extraEnvironment: "ICKB_BOT_LOG_STORAGE_QUOTA_BYTES=1000", + }), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 0, result.stderr); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update refuses launcher units that tee child output to journald", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText( + unitPath, + unitText({ network: testnet, launcher: true, staleLauncherChild: true }), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 1); + assert.match(result.stderr, productionLauncherFileLogging); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update refuses environment log-root overrides", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText( + unitPath, + unitText({ + network: testnet, + launcher: true, + extraEnvironment: "ICKB_BOT_LOG_ROOT=/tmp/other-log", + }), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 1); + assert.match(result.stderr, productionLauncherFileLogging); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update refuses stale direct-exec units", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText(unitPath, unitText({ network: testnet, launcher: false })); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 1); + assert.match(result.stderr, productionLauncherFileLogging); + assert.match(result.stderr, /ickb-bot-systemd-install\.sh testnet/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update refuses launcher units without core-dump hardening", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText( + unitPath, + unitText({ network: testnet, launcher: true, limitCore: false }), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 1); + assert.match(result.stderr, /core-dump hardening/u); + assert.match(result.stderr, /ickb-bot-systemd-install\.sh testnet/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update refuses launcher units with mismatched writable log roots", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText( + unitPath, + unitText({ + network: testnet, + launcher: true, + logRoot: "/srv/ickb/log", + readWritePath: "/tmp", + }), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 1); + assert.match(result.stderr, productionLauncherFileLogging); + assert.match(result.stderr, /ickb-bot-systemd-install\.sh testnet/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update refuses launcher log-root arguments", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText( + unitPath, + unitText({ network: testnet, launcher: true, logRoot: "relative-log" }), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 1); + assert.match(result.stderr, productionLauncherFileLogging); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update ignores commented launcher directives", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, testnetUnitFile); + await writeText( + unitPath, + unitText({ network: testnet, launcher: false, commentedSpoof: true }), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 1); + assert.match(result.stderr, productionLauncherFileLogging); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update ignores launcher directives outside the Service section", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const unitPath = join(dir, "ickb-bot-testnet.service"); + await writeText( + unitPath, + unitText({ + network: testnet, + launcher: false, + inactiveSectionSpoof: true, + }), + ); + + const result = requireLauncherUnit(unitPath, testnet); + assert.equal(result.status, 1); + assert.match(result.stderr, productionLauncherFileLogging); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +void test("systemd update checks unit wiring before mutating deploy checkout", async () => { + const text = await readUpdateScript(); + const guardIndex = text.indexOf( + `require_launcher_unit "${shellExpansionStart}unit_path}" "${shellExpansionStart}network}" "${shellExpansionStart}deploy_dir}"`, + ); + const pullIndex = text.indexOf( + `git -C "${shellExpansionStart}deploy_dir}" pull --ff-only`, + ); + + assert.notEqual(guardIndex, -1); + assert.notEqual(pullIndex, -1); + assert.ok(guardIndex < pullIndex); +}); + +void test("systemd update refuses untracked files before pulling", async () => { + const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + try { + const fakeBin = join(dir, "bin"); + const logPath = join(dir, "git.log"); + await makeDirectory(fakeBin, { recursive: true }); + await writeText( + join(fakeBin, "runuser"), + `#!/usr/bin/env bash +shift 3 +env_args=() +while [[ $# -gt 0 && $1 == *=* ]]; do + env_args+=("$1") + shift +done +exec env "${shellExpansionStart}env_args[@]}" "$@" +`, + ); + await chmodPath(join(fakeBin, "runuser"), 0o755); + await writeText( + join(fakeBin, "git"), + String.raw`#!/usr/bin/env bash +printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +if [[ $* == *' status --porcelain' ]]; then + printf '?? untracked.txt\n' +fi +`, + ); + await chmodPath(join(fakeBin, "git"), 0o755); + + const result = spawnSync( + bashPath, + [ + "-c", + `source "$1"; PATH="$2:$PATH"; run_as_service_user() { command runuser -u "$1" -- env HOME="$2" USER="$1" LOGNAME="$1" SHELL=/bin/bash "${shellExpansionStart}@:3}"; }; require_clean_worktree ickb-bot-testnet /home/ickb /deploy`, + "bash", + updateScript, + fakeBin, + ], + { cwd: rootDir, encoding: "utf8" }, + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /local changes or untracked files/u); + assert.doesNotMatch(await readText(logPath), /pull --ff-only/u); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +interface UnitTextOptions { + commentedSpoof?: boolean; + extraEnvironment?: string; + inactiveSectionSpoof?: boolean; + launcher: boolean; + limitCore?: boolean; + logRoot?: string; + network: string; + readWritePath?: string; + staleLauncherChild?: boolean; +} + +async function readUpdateScript(): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test reads the fixed systemd update helper script under the repository root. + return fsReadFile(updateScript, "utf8"); +} + +async function chmodPath(filePath: string, mode: number): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test modifies files inside its own temporary fixture directory. + await fsChmod(filePath, mode); +} + +async function makeDirectory( + directory: string, + options: { recursive: true }, +): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test creates directories inside its own temporary fixture directory. + await fsMkdir(directory, options); +} + +async function readText(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test reads files inside its own temporary fixture directory. + return fsReadFile(filePath, "utf8"); +} + +async function writeText(filePath: string, data: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test writes files inside its own temporary fixture directory. + await fsWriteFile(filePath, data); +} + +function requireLauncherUnit( + unitPath: string, + network: string, +): SpawnSyncReturns { + const deployDir = `/opt/ickb-${network}`; + return spawnSync( + bashPath, + [ + "-c", + 'source "$1"; require_launcher_unit "$2" "$3" "$4"', + "bash", + updateScript, + unitPath, + network, + deployDir, + ], + { cwd: rootDir, encoding: "utf8" }, + ); +} + +function unitText({ + network, + launcher, + limitCore = true, + commentedSpoof = false, + inactiveSectionSpoof = false, + logRoot, + readWritePath, + extraEnvironment, + staleLauncherChild = false, +}: UnitTextOptions): string { + const credentialName = `ickb-bot-${network}-config.json`; + const credential = `/etc/ickb/credentials/ickb-bot-${network}-config.cred`; + const launcherLogRoot = logRoot === undefined ? "" : `--log-root ${logRoot} `; + let execStart = `ExecStart=/usr/bin/node apps/bot/src/index.ts`; + if (launcher && staleLauncherChild) { + execStart = `ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts ${launcherLogRoot}-- /usr/bin/node apps/bot/src/index.ts`; + } else if (launcher) { + execStart = `ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts ${launcherLogRoot}--no-child-tee`; + } + const workingDirectory = `/opt/ickb-${network}`; + const writablePath = readWritePath ?? logRoot ?? `${workingDirectory}/log`; + const environmentValue = [`BOT_CONFIG_FILE=%d/${credentialName}`, extraEnvironment] + .filter(Boolean) + .join(" "); + + const comments = commentedSpoof + ? `# ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts -- /usr/bin/node apps/bot/src/index.ts +# ReadWritePaths=${workingDirectory}/log +` + : ""; + const inactive = inactiveSectionSpoof + ? `[Unit] +ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts -- /usr/bin/node apps/bot/src/index.ts +ReadWritePaths=${workingDirectory}/log +[Install] +WantedBy=multi-user.target +` + : ""; + + return `${inactive}[Service] +${comments} +WorkingDirectory=${workingDirectory} +Environment=${environmentValue} +LoadCredentialEncrypted=${credentialName}:${credential} +${execStart} +RestartPreventExitStatus=2 +RestartSec=60 +${limitCore ? `LimitCORE=0\n` : ""} +ReadWritePaths=${writablePath} +`; +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..ceadf91 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["bot/**/*.ts", "test/bot/**/*.ts", "run-node-tests.ts"] +} From 187f893756bb6283a7fdb7df8c08192775d332e5 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:27:44 +0000 Subject: [PATCH 3/8] fix(packages): carry forward lower-level fixes --- packages/node-utils/src/account.ts | 24 ++++++++++++------ packages/node-utils/test/account.ts | 21 ++++++++++++++++ packages/node-utils/tsconfig.build.json | 13 ---------- packages/sdk/src/client/sdk_base.ts | 4 +-- packages/sdk/src/client/sdk_error.ts | 16 +++++------- packages/sdk/src/estimate/sdk_maturity.ts | 14 +++-------- packages/sdk/src/index.ts | 2 +- packages/sdk/src/send/send_and_wait_error.ts | 7 ++++++ .../withdrawal/withdrawal_best_fit_support.ts | 7 ++---- .../src/withdrawal/withdrawal_ring_core.ts | 7 ++---- .../src/withdrawal/withdrawal_selection.ts | 17 ++++++------- packages/sdk/test/constants.ts | 14 ++++++----- packages/sdk/test/index.ts | 18 +++++++++++++ packages/sdk/test/sdk_error.ts | 25 ------------------- .../withdrawal/withdrawal_best_fit_support.ts | 3 --- packages/testkit/src/index.ts | 5 ---- 16 files changed, 93 insertions(+), 104 deletions(-) delete mode 100644 packages/node-utils/tsconfig.build.json delete mode 100644 packages/sdk/test/sdk_error.ts diff --git a/packages/node-utils/src/account.ts b/packages/node-utils/src/account.ts index c95e34d..9ec6e4c 100644 --- a/packages/node-utils/src/account.ts +++ b/packages/node-utils/src/account.ts @@ -39,6 +39,12 @@ export function postTransactionAccountPlainCkbBalance( capacityCells: readonly ccc.Cell[], accountLocks: readonly ccc.Script[], ): bigint { + if (tx.outputs.length !== tx.outputsData.length) { + throw new Error( + `Malformed transaction: outputs count ${String(tx.outputs.length)} differs from outputsData count ${String(tx.outputsData.length)}`, + ); + } + const accountLockHexes = new Set(accountLocks.map((lock) => lock.toHex())); const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); const unspentCapacity = capacityCells.reduce( @@ -48,18 +54,20 @@ export function postTransactionAccountPlainCkbBalance( : total + plainCapacity(cell.cellOutput, cell.outputData, accountLockHexes), 0n, ); - const outputCapacity = tx.outputs.reduce( - (total, output, index) => - total + plainCapacity(output, tx.outputsData[index], accountLockHexes), - 0n, - ); + const outputCapacity = tx.outputs.reduce((total, output, index) => { + const outputData = tx.outputsData[index]; + if (outputData === undefined) { + throw new Error("Malformed transaction: missing output data"); + } + return total + plainCapacity(output, outputData, accountLockHexes); + }, 0n); return unspentCapacity + outputCapacity; } function plainCapacity( output: ccc.CellOutput, - outputData: string | undefined, + outputData: string, accountLockHexes: Set, ): bigint { return isAccountPlainCapacityOutput(output, outputData, accountLockHexes) @@ -69,12 +77,12 @@ function plainCapacity( function isAccountPlainCapacityOutput( output: ccc.CellOutput, - outputData: string | undefined, + outputData: string, accountLockHexes: Set, ): boolean { return ( output.type === undefined && - (outputData ?? "0x") === "0x" && + outputData === "0x" && accountLockHexes.has(output.lock.toHex()) ); } diff --git a/packages/node-utils/test/account.ts b/packages/node-utils/test/account.ts index 9ac1a60..87822a4 100644 --- a/packages/node-utils/test/account.ts +++ b/packages/node-utils/test/account.ts @@ -58,6 +58,27 @@ describe("account locks and balances", () => { postTransactionAccountPlainCkbBalance(tx, [spent, unspent, typed, data], [lock]), ).toBe(ccc.fixedPointFrom(2300)); }); + + it("rejects malformed transaction outputs without matching output data", () => { + const { lock, unspent } = accountCellFixture(); + const tx = ccc.Transaction.default(); + tx.outputs.push(ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(300), lock })); + + expect(() => postTransactionAccountPlainCkbBalance(tx, [unspent], [lock])).toThrow( + "Malformed transaction: outputs count 1 differs from outputsData count 0", + ); + }); + + it("rejects output data holes even when output data count matches", () => { + const { lock, unspent } = accountCellFixture(); + const tx = ccc.Transaction.default(); + tx.outputs.push(ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(300), lock })); + tx.outputsData.length = tx.outputs.length; + + expect(() => postTransactionAccountPlainCkbBalance(tx, [unspent], [lock])).toThrow( + "Malformed transaction: missing output data", + ); + }); }); function accountCellFixture(): { diff --git a/packages/node-utils/tsconfig.build.json b/packages/node-utils/tsconfig.build.json deleted file mode 100644 index c23b9fe..0000000 --- a/packages/node-utils/tsconfig.build.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "removeComments": false, - "rewriteRelativeImportExtensions": true, - "rootDir": "src", - "outDir": "dist", - "sourceRoot": "../src" - }, - "include": ["src"], - "exclude": ["test"] -} diff --git a/packages/sdk/src/client/sdk_base.ts b/packages/sdk/src/client/sdk_base.ts index 018e919..0c95767 100644 --- a/packages/sdk/src/client/sdk_base.ts +++ b/packages/sdk/src/client/sdk_base.ts @@ -16,9 +16,7 @@ import type { * * @public */ -export class IckbSdkBase { - protected constructor() {} - +export abstract class IckbSdkBase { /** * Completes iCKB/xUDT inputs and transaction fees for a partial transaction. * diff --git a/packages/sdk/src/client/sdk_error.ts b/packages/sdk/src/client/sdk_error.ts index 8a0d918..ed8f121 100644 --- a/packages/sdk/src/client/sdk_error.ts +++ b/packages/sdk/src/client/sdk_error.ts @@ -22,21 +22,17 @@ function errorMessage(error: unknown): string { } try { - return JSON.stringify(error, stringifyErrorValue); + return JSON.stringify(error, stringifyBigInt); } catch { return String(error); } } -function stringifyErrorValue( - this: Record | unknown[], - key: string, - value: unknown, -): unknown { - const original = Array.isArray(this) ? this[Number(key)] : this[key]; - if (original instanceof Date) { - return Number.isNaN(original.getTime()) ? null : original.toISOString(); - } +type JsonReplacerInput = string | number | boolean | bigint | null | object | undefined; +function stringifyBigInt( + _key: string, + value: JsonReplacerInput, +): Exclude | string { return typeof value === "bigint" ? value.toString() : value; } diff --git a/packages/sdk/src/estimate/sdk_maturity.ts b/packages/sdk/src/estimate/sdk_maturity.ts index 442f99b..1c499b6 100644 --- a/packages/sdk/src/estimate/sdk_maturity.ts +++ b/packages/sdk/src/estimate/sdk_maturity.ts @@ -107,19 +107,13 @@ function firstCkbMaturityAtOrAbove( ckbNeeded: bigint, ): bigint | undefined { const index = binarySearch(ckbMaturing.length, (n) => { - const entry = ckbMaturing[n]; - if (entry === undefined) { - throw new Error(`CKB maturity entry ${String(n)} is missing`); - } - return entry.ckbCumulative >= ckbNeeded; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- binarySearch probes 0 <= n < ckbMaturing.length. + return ckbMaturing[n]!.ckbCumulative >= ckbNeeded; }); if (index >= ckbMaturing.length) { return undefined; } - const entry = ckbMaturing[index]; - if (entry === undefined) { - throw new Error(`CKB maturity entry ${String(index)} is missing`); - } - return entry.maturity; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index is checked against ckbMaturing.length above. + return ckbMaturing[index]!.maturity; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 209ffb7..dc0292b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -11,11 +11,11 @@ export { IckbSdkBase, IckbSdkConversion, IckbSdkL1, + TransactionConfirmationError, estimateMaturityFeeThreshold, projectAccountAvailability, projectConversionTransactionContext, sendAndWaitForCommit, - TransactionConfirmationError, } from "./sdk.ts"; export type { AccountAvailabilityProjection, diff --git a/packages/sdk/src/send/send_and_wait_error.ts b/packages/sdk/src/send/send_and_wait_error.ts index 9bc3de8..5b3698f 100644 --- a/packages/sdk/src/send/send_and_wait_error.ts +++ b/packages/sdk/src/send/send_and_wait_error.ts @@ -6,11 +6,18 @@ import type { ccc } from "@ckb-ccc/core"; * @public */ export class TransactionConfirmationError extends Error { + /** Hash of the transaction whose confirmation failed. */ public readonly txHash: ccc.Hex; + /** Last known node status for the transaction, when available. */ public readonly status: string | undefined; + /** True when confirmation polling exhausted its check budget. */ public readonly isTimeout: boolean; + /** Node-provided rejection or failure reason, when available. */ public readonly reason: string | undefined; + /** + * Creates a confirmation error carrying the transaction hash and last known status. + */ constructor( ...[message, options, txHash, status, isTimeout, reason]: [ message: string, diff --git a/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts b/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts index 20ddda8..86bf3c4 100644 --- a/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts +++ b/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts @@ -85,11 +85,8 @@ export function selectByMasks(items: readonly T[], mask: number): T[] { const selected: T[] = []; for (let i = 0; i < items.length; i += 1) { if ((mask & (1 << i)) !== 0) { - const item = items[i]; - if (item === undefined) { - throw new Error(`Selection item ${String(i)} is missing`); - } - selected.push(item); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- masks are generated from the same dense item array. + selected.push(items[i]!); } } return selected; diff --git a/packages/sdk/src/withdrawal/withdrawal_ring_core.ts b/packages/sdk/src/withdrawal/withdrawal_ring_core.ts index 1d10fca..6404ced 100644 --- a/packages/sdk/src/withdrawal/withdrawal_ring_core.ts +++ b/packages/sdk/src/withdrawal/withdrawal_ring_core.ts @@ -48,11 +48,8 @@ export function ringSegments( items: readonly T[], indexByItem: ReadonlyMap, ): string { - return items - .map((item) => { - const index = indexByItem.get(item); - if (index === undefined) { - throw new Error("Selection item index is missing"); - } - return String(index); - }) - .toSorted((left, right) => left.localeCompare(right)) - .join(","); + return ( + items + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- selections are built from the indexed readyDeposits array. + .map((item) => String(indexByItem.get(item)!)) + .toSorted((left, right) => left.localeCompare(right)) + .join(",") + ); } diff --git a/packages/sdk/test/constants.ts b/packages/sdk/test/constants.ts index 5ae0c5d..b0238a9 100644 --- a/packages/sdk/test/constants.ts +++ b/packages/sdk/test/constants.ts @@ -100,18 +100,20 @@ describe("getConfig defaults", () => { const malformedConfig = { udt: { script: script("11"), - codeOutPoint: undefined, + codeOutPoint: outPoint("22"), cellDeps: [dep], }, logic: { - script: script("22"), + script: script("33"), codeOutPoint: outPoint("33"), cellDeps: [dep], }, - ownedOwner: { script: script("44"), cellDeps: [dep] }, - order: { script: script("55"), cellDeps: [dep] }, - dao: { script: script("66"), cellDeps: [dep] }, - } as unknown as Parameters[0]; + ownedOwner: { script: script("55"), cellDeps: [dep] }, + order: { script: script("66"), cellDeps: [dep] }, + dao: { script: script("77"), cellDeps: [dep] }, + }; + + Reflect.deleteProperty(malformedConfig.udt, "codeOutPoint"); expect(() => getConfig(malformedConfig)).toThrow( "custom config missing xUDT code outPoint", diff --git a/packages/sdk/test/index.ts b/packages/sdk/test/index.ts index 7d60d0f..f7a25c6 100644 --- a/packages/sdk/test/index.ts +++ b/packages/sdk/test/index.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { errorOf } from "../src/client/sdk_error.ts"; import * as sdk from "../src/index.ts"; import { nativeUdtCell, @@ -28,4 +29,21 @@ describe("sdk package barrel", () => { ickbAvailable: 7n, }); }); + + it("preserves transparent error causes and JSON-safe messages", () => { + const message = "plain failure"; + const thrown = { + amount: 42n, + validDate: new Date("2026-01-02T03:04:05.000Z"), + invalidDate: new Date(NaN), + }; + const error = errorOf(thrown); + + expect(errorOf(message).message).toBe(message); + expect(errorOf(message).cause).toBe(message); + expect(error.message).toBe( + '{"amount":"42","validDate":"2026-01-02T03:04:05.000Z","invalidDate":null}', + ); + expect(error.cause).toBe(thrown); + }); }); diff --git a/packages/sdk/test/sdk_error.ts b/packages/sdk/test/sdk_error.ts deleted file mode 100644 index 0813408..0000000 --- a/packages/sdk/test/sdk_error.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { errorOf } from "../src/client/sdk_error.ts"; - -describe("errorOf", () => { - it("preserves thrown strings as error messages", () => { - const error = errorOf("plain failure"); - - expect(error.message).toBe("plain failure"); - expect(error.cause).toBe("plain failure"); - }); - - it("serializes bigint and date values in object errors", () => { - const thrown = { - amount: 42n, - validDate: new Date("2026-01-02T03:04:05.000Z"), - invalidDate: new Date(Number.NaN), - }; - const error = errorOf(thrown); - - expect(error.message).toBe( - '{"amount":"42","validDate":"2026-01-02T03:04:05.000Z","invalidDate":null}', - ); - expect(error.cause).toBe(thrown); - }); -}); diff --git a/packages/sdk/test/withdrawal/withdrawal_best_fit_support.ts b/packages/sdk/test/withdrawal/withdrawal_best_fit_support.ts index c89e5d3..6a4ec3c 100644 --- a/packages/sdk/test/withdrawal/withdrawal_best_fit_support.ts +++ b/packages/sdk/test/withdrawal/withdrawal_best_fit_support.ts @@ -81,9 +81,6 @@ describe("withdrawal best-fit concrete selection support", () => { const deposits = [a, b, c]; expect(selectByMasks(deposits, 0b101)).toEqual([a, c]); - expect(() => selectByMasks([a, undefined, c], 0b111)).toThrow( - "Selection item 1 is missing", - ); expect(pickBetterSelection(deposits, [b], [a], (deposit) => deposit.score)).toEqual([ b, ]); diff --git a/packages/testkit/src/index.ts b/packages/testkit/src/index.ts index ada8d28..040212b 100644 --- a/packages/testkit/src/index.ts +++ b/packages/testkit/src/index.ts @@ -9,11 +9,6 @@ import { byte32FromByte } from "./bytes.ts"; export { byte32FromByte } from "./bytes.ts"; -/** Creates a 32-byte hash fixture by repeating one byte. */ -export function hash(hexByte: string): `0x${string}` { - return byte32FromByte(hexByte); -} - type ClientMethod = Extract< ccc.Client[K], (...args: never[]) => unknown From 3dedb2a10d4fd50bdefb09609dc42b479afd3a61 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:28:16 +0000 Subject: [PATCH 4/8] fix(bot): address review feedback --- apps/bot/README.md | 10 +++++----- packages/bot/src/observability/error.ts | 7 +++---- scripts/bot/incident/args.ts | 8 ++++---- scripts/bot/launcher/args.ts | 2 +- scripts/ickb-bot-systemd-install.sh | 2 +- scripts/ickb-bot-systemd-update.sh | 2 +- scripts/test/bot/incident/core.ts | 3 +-- scripts/test/bot/incident/support.ts | 1 - scripts/test/bot/launcher/support.ts | 3 --- scripts/test/bot/systemd-install.ts | 4 ++-- scripts/test/bot/systemd-update.ts | 8 ++++---- 11 files changed, 22 insertions(+), 28 deletions(-) diff --git a/apps/bot/README.md b/apps/bot/README.md index a22b77c..6695102 100644 --- a/apps/bot/README.md +++ b/apps/bot/README.md @@ -40,7 +40,7 @@ pnpm live:config-from-env -- --force The helper writes bounded `config/bot-testnet.json` and `config/tester-testnet.json` for supervisor/tester runs, plus unbounded `config/bot-live-testnet.json` for a production-like long-running bot. Bounded configs default to `maxRetryableAttempts: 10`; the live config omits `maxRetryableAttempts` unless `ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS` is set intentionally. Use the live config with the source-owned launcher when the goal is continuous matching: ```bash -BOT_CONFIG_FILE=config/bot-live-testnet.json node --experimental-default-type=module scripts/bot/launcher.ts --no-child-tee +BOT_CONFIG_FILE=config/bot-live-testnet.json node scripts/bot/launcher.ts --no-child-tee ``` Current network support: @@ -129,7 +129,7 @@ jq -c 'select(.type == "launcher.child.exited") | {timestamp, status, signal, el For unattended Ubuntu 24.04 deployments, run testnet and mainnet as separate systemd services with separate users, deploy directories, encrypted JSON credentials, and bot-only logs. The generated units run the bot from source, not `dist`: ```text -/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts --no-child-tee +/usr/bin/node scripts/bot/launcher.ts --no-child-tee ``` `apps/bot` is the CLI workspace for the private `packages/bot` runtime. Production runs Stack source and resolves CCC from installed package dependencies. @@ -254,9 +254,9 @@ The log root resolves the same way as the launcher: explicit `--log-root`, then Examples from the deployed checkout: ```bash -sudo -u ickb-bot-testnet node --experimental-default-type=module scripts/bot/collect-incident.ts --since 2h --until now -sudo -u ickb-bot-mainnet node --experimental-default-type=module scripts/bot/collect-incident.ts --since 2026-05-25T10:00:00Z --until 2026-05-25T11:00:00Z -sudo -u ickb-bot-testnet node --experimental-default-type=module scripts/bot/collect-incident.ts --log-root log --since 30m --until now +sudo -u ickb-bot-testnet node scripts/bot/collect-incident.ts --since 2h --until now +sudo -u ickb-bot-mainnet node scripts/bot/collect-incident.ts --since 2026-05-25T10:00:00Z --until 2026-05-25T11:00:00Z +sudo -u ickb-bot-testnet node scripts/bot/collect-incident.ts --log-root log --since 30m --until now ``` The collector does not include runtime config files, environment dumps, systemd output, or raw unit text because they can contain private keys, credentialed RPC URLs, tokens, passwords, or API keys. Selected source logs are bundled as public producer-owned evidence; if a private key or other secret reaches those sources, fix the producer that wrote it before sharing or archiving the bundle. diff --git a/packages/bot/src/observability/error.ts b/packages/bot/src/observability/error.ts index bced93c..71ffb1f 100644 --- a/packages/bot/src/observability/error.ts +++ b/packages/bot/src/observability/error.ts @@ -119,11 +119,10 @@ function errorOwnProperties(error: Error, seen: Set): Record): Record { const fields: Record = {}; - const entries = Object.entries(error); + const values = error as Record; for (const key of ERROR_EXTRA_KEYS) { - const entry = entries.find(([entryKey]) => entryKey === key); - if (entry !== undefined) { - fields[key] = logValue(entry[1], seen); + if (key in error) { + fields[key] = logValue(values[key], seen); } } return fields; diff --git a/scripts/bot/incident/args.ts b/scripts/bot/incident/args.ts index 52a1d20..e53cfba 100644 --- a/scripts/bot/incident/args.ts +++ b/scripts/bot/incident/args.ts @@ -19,7 +19,7 @@ const relativeTimeUnits: readonly RelativeTimeUnit[] = ["ms", "s", "m", "h", "d" export function usage(): string { return [ - "Usage: node --experimental-default-type=module scripts/bot/collect-incident.ts [--log-root PATH] [--log-dir PATH] --since --until ", + "Usage: node scripts/bot/collect-incident.ts [--log-root PATH] [--log-dir PATH] --since --until ", "Relative times use the current time, for example --since 2h --until now.", ].join("\n"); } @@ -133,11 +133,11 @@ function parseRelativeOffsetMs(value: string): number | null { if (amountText === "" || !isAsciiDigits(amountText)) { return null; } - const amount = Number(amountText); - if (!Number.isSafeInteger(amount)) { + const amount = BigInt(amountText); + if (amount > BigInt(Number.MAX_SAFE_INTEGER)) { return null; } - return amount * relativeTimeMultipliers[unit]; + return Number(amount) * relativeTimeMultipliers[unit]; } function stripOptionalAgo(value: string): string { diff --git a/scripts/bot/launcher/args.ts b/scripts/bot/launcher/args.ts index 93ce314..98b0175 100644 --- a/scripts/bot/launcher/args.ts +++ b/scripts/bot/launcher/args.ts @@ -1,7 +1,7 @@ import type { ParsedLauncherArgs } from "./runtime/types.ts"; export function usage(): string { - return `Usage: node --experimental-default-type=module scripts/bot/launcher.ts [--log-root PATH] [--log-dir PATH] [--log-storage-quota-bytes N] [--no-child-tee] [-- [args...]]\n`; + return `Usage: node scripts/bot/launcher.ts [--log-root PATH] [--log-dir PATH] [--log-storage-quota-bytes N] [--no-child-tee] [-- [args...]]\n`; } export function parseArgs(argv: readonly string[]): ParsedLauncherArgs { diff --git a/scripts/ickb-bot-systemd-install.sh b/scripts/ickb-bot-systemd-install.sh index bf6f79d..cdbeb33 100755 --- a/scripts/ickb-bot-systemd-install.sh +++ b/scripts/ickb-bot-systemd-install.sh @@ -150,7 +150,7 @@ Group=${user} WorkingDirectory=${deploy_dir} Environment=BOT_CONFIG_FILE=%d/${credential_name}${launcher_quota_environment} LoadCredentialEncrypted=${credential_name}:${credential} -ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts --no-child-tee +ExecStart=/usr/bin/node scripts/bot/launcher.ts --no-child-tee Restart=on-failure RestartSec=60 RestartPreventExitStatus=2 diff --git a/scripts/ickb-bot-systemd-update.sh b/scripts/ickb-bot-systemd-update.sh index ef05a15..f59bdc3 100755 --- a/scripts/ickb-bot-systemd-update.sh +++ b/scripts/ickb-bot-systemd-update.sh @@ -90,7 +90,7 @@ require_launcher_unit() { local log_root="${deploy_dir}/log" if ! service_has_bot_environment "${unit_text}" "${credential_name}" || ! service_has_line "${unit_text}" "LoadCredentialEncrypted=${credential_name}:${credential}" || - ! service_has_line "${unit_text}" "ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts --no-child-tee" || + ! service_has_line "${unit_text}" "ExecStart=/usr/bin/node scripts/bot/launcher.ts --no-child-tee" || ! service_has_line "${unit_text}" "RestartPreventExitStatus=2" || ! service_has_line "${unit_text}" "LimitCORE=0" || ! service_has_line "${unit_text}" "RestartSec=60" || diff --git a/scripts/test/bot/incident/core.ts b/scripts/test/bot/incident/core.ts index c727b11..0452737 100644 --- a/scripts/test/bot/incident/core.ts +++ b/scripts/test/bot/incident/core.ts @@ -21,7 +21,6 @@ import { logDirOption, logRootOption, mode, - moduleDefaultFlag, openPath, parseArgs, parseTimeBound, @@ -321,7 +320,7 @@ void test("produced bundles do not contain configured canary secrets", async () const result = spawnSync( process.execPath, - [moduleDefaultFlag, collector, ...commonArgs(dir)], + [collector, ...commonArgs(dir)], { cwd: rootDir, encoding: "utf8", diff --git a/scripts/test/bot/incident/support.ts b/scripts/test/bot/incident/support.ts index e013a3c..079872e 100644 --- a/scripts/test/bot/incident/support.ts +++ b/scripts/test/bot/incident/support.ts @@ -83,7 +83,6 @@ export interface ReferencedArtifactFixture { export const { join, resolve } = path; export const rootDir = fileURLToPath(new URL("../../../..", import.meta.url)); export const collector = join(rootDir, "scripts", "bot", "collect-incident.ts"); -export const moduleDefaultFlag = "--experimental-default-type=module"; export const canaryPrivateKey = `0x${"42".repeat(32)}`; export const logRootOption = "--log-root"; export const logDirOption = "--log-dir"; diff --git a/scripts/test/bot/launcher/support.ts b/scripts/test/bot/launcher/support.ts index 57efedc..56502e1 100644 --- a/scripts/test/bot/launcher/support.ts +++ b/scripts/test/bot/launcher/support.ts @@ -37,7 +37,6 @@ export { export const rootDir = fileURLToPath(new URL("../../../..", import.meta.url)); export const { join, resolve } = path; export const launcher = join(rootDir, "scripts", "bot", "launcher.ts"); -export const moduleDefaultFlag = "--experimental-default-type=module"; export const canaryPrivateKey = `0x${"42".repeat(32)}`; export const logRootOption = "--log-root"; export const logStorageQuotaOption = "--log-storage-quota-bytes"; @@ -120,7 +119,6 @@ export function runLauncher( return spawnSync( process.execPath, [ - moduleDefaultFlag, launcher, logRootOption, logRoot, @@ -148,7 +146,6 @@ export function runLauncherBuffer( return spawnSync( process.execPath, [ - moduleDefaultFlag, launcher, logRootOption, logRoot, diff --git a/scripts/test/bot/systemd-install.ts b/scripts/test/bot/systemd-install.ts index df18779..2341e71 100644 --- a/scripts/test/bot/systemd-install.ts +++ b/scripts/test/bot/systemd-install.ts @@ -25,7 +25,7 @@ void test("systemd install units run the source bot through the file-log launche assert.match( text, - /ExecStart=\/usr\/bin\/node --experimental-default-type=module scripts\/bot\/launcher\.ts --no-child-tee/u, + /ExecStart=\/usr\/bin\/node scripts\/bot\/launcher\.ts --no-child-tee/u, ); assert.doesNotMatch(text, /ExecStart=\/usr\/bin\/node apps\/bot\/src\/index\.ts/u); assert.match(text, /Environment=BOT_CONFIG_FILE=%d\/\$\{credential_name\}/u); @@ -90,7 +90,7 @@ void test("systemd install script keeps generated units on the checkout-local lo assert.match(text, /log_root_path="\$\{deploy_dir\}\/log"/u); assert.match( text, - /ExecStart=\/usr\/bin\/node --experimental-default-type=module scripts\/bot\/launcher\.ts --no-child-tee/u, + /ExecStart=\/usr\/bin\/node scripts\/bot\/launcher\.ts --no-child-tee/u, ); assert.doesNotMatch(text, /--network \$\{network\}/u); assert.doesNotMatch(text, /ICKB_BOT_LOG_ROOT/u); diff --git a/scripts/test/bot/systemd-update.ts b/scripts/test/bot/systemd-update.ts index f1efead..8ae3b02 100644 --- a/scripts/test/bot/systemd-update.ts +++ b/scripts/test/bot/systemd-update.ts @@ -390,9 +390,9 @@ function unitText({ const launcherLogRoot = logRoot === undefined ? "" : `--log-root ${logRoot} `; let execStart = `ExecStart=/usr/bin/node apps/bot/src/index.ts`; if (launcher && staleLauncherChild) { - execStart = `ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts ${launcherLogRoot}-- /usr/bin/node apps/bot/src/index.ts`; + execStart = `ExecStart=/usr/bin/node scripts/bot/launcher.ts ${launcherLogRoot}-- /usr/bin/node apps/bot/src/index.ts`; } else if (launcher) { - execStart = `ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts ${launcherLogRoot}--no-child-tee`; + execStart = `ExecStart=/usr/bin/node scripts/bot/launcher.ts ${launcherLogRoot}--no-child-tee`; } const workingDirectory = `/opt/ickb-${network}`; const writablePath = readWritePath ?? logRoot ?? `${workingDirectory}/log`; @@ -401,13 +401,13 @@ function unitText({ .join(" "); const comments = commentedSpoof - ? `# ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts -- /usr/bin/node apps/bot/src/index.ts + ? `# ExecStart=/usr/bin/node scripts/bot/launcher.ts -- /usr/bin/node apps/bot/src/index.ts # ReadWritePaths=${workingDirectory}/log ` : ""; const inactive = inactiveSectionSpoof ? `[Unit] -ExecStart=/usr/bin/node --experimental-default-type=module scripts/bot/launcher.ts -- /usr/bin/node apps/bot/src/index.ts +ExecStart=/usr/bin/node scripts/bot/launcher.ts -- /usr/bin/node apps/bot/src/index.ts ReadWritePaths=${workingDirectory}/log [Install] WantedBy=multi-user.target From ba2ad88a4a9896070b12eaeb295299669b6c4d49 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:50:04 +0000 Subject: [PATCH 5/8] fix(bot): harden cleanup paths --- packages/bot/src/observability/artifacts.ts | 16 +--- packages/bot/test/observability/artifacts.ts | 5 +- scripts/bot/incident/source/filter.ts | 84 ++------------------ scripts/bot/launcher/storage/retention.ts | 12 ++- 4 files changed, 25 insertions(+), 92 deletions(-) diff --git a/packages/bot/src/observability/artifacts.ts b/packages/bot/src/observability/artifacts.ts index 6354816..ce80e34 100644 --- a/packages/bot/src/observability/artifacts.ts +++ b/packages/bot/src/observability/artifacts.ts @@ -130,7 +130,6 @@ async function writeContentAddressedArtifact( let handle: FileHandle | undefined; let tempCreated = false; let linkingFinalPath = false; - let writeSucceeded = false; try { handle = await fsPromises.open(tempPath, writeNewArtifactFlags, 0o600); tempCreated = true; @@ -140,32 +139,25 @@ async function writeContentAddressedArtifact( handle = undefined; linkingFinalPath = true; await fsPromises.link(tempPath, path); - writeSucceeded = true; } catch (error) { if (linkingFinalPath && isErrno(error, "EEXIST")) { await verifyExistingArtifact(path, hash); - writeSucceeded = true; } else { throw error; } } finally { await closeArtifactHandle(handle); if (tempCreated) { - await removeTemporaryArtifact(tempPath, writeSucceeded); + await removeTemporaryArtifact(tempPath); } } } -async function removeTemporaryArtifact( - tempPath: string, - writeSucceeded: boolean, -): Promise { +async function removeTemporaryArtifact(tempPath: string): Promise { try { await fsPromises.rm(tempPath, { force: true }); - } catch (error) { - if (writeSucceeded) { - throw error; - } + } catch { + // Temp cleanup is best-effort; write or verification errors remain authoritative. } } diff --git a/packages/bot/test/observability/artifacts.ts b/packages/bot/test/observability/artifacts.ts index 4baac6e..f23ac31 100644 --- a/packages/bot/test/observability/artifacts.ts +++ b/packages/bot/test/observability/artifacts.ts @@ -108,9 +108,8 @@ describe(BOT_OBSERVABILITY_SUITE, () => { .spyOn(fsPromises, "rm") .mockRejectedValueOnce(new Error("cleanup failed")); try { - await expect( - writeArtifact(artifactRoot, artifactKind, artifactPayload), - ).rejects.toThrow(/cleanup failed/u); + await expect(writeArtifact(artifactRoot, artifactKind, artifactPayload)).resolves + .toMatchObject({ kind: artifactKind }); } finally { rmSpy.mockRestore(); await rm(artifactRoot, { force: true, recursive: true }); diff --git a/scripts/bot/incident/source/filter.ts b/scripts/bot/incident/source/filter.ts index a78fc0e..666e6ac 100644 --- a/scripts/bot/incident/source/filter.ts +++ b/scripts/bot/incident/source/filter.ts @@ -1,11 +1,6 @@ import { processSourceLines } from "../io/filesystem.ts"; import { stderrUndatedLineLimit } from "../model/constants.ts"; -import { - isAsciiDigits, - isRecord, - scanAsciiDigits, - stripLineEnding, -} from "../model/text.ts"; +import { isRecord, stripLineEnding } from "../model/text.ts"; import type { FilteredSource, IncidentSummary, @@ -26,6 +21,9 @@ import { updateSourceStatsTimestamps, } from "./summary.ts"; +const ISO_TIMESTAMP_TEXT = + /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})/u; + export async function filterJsonSource({ dependencies, filePath, @@ -153,76 +151,10 @@ function parseRecordTimestamp(timestamp: unknown): Date | null { } function parseTextTimestamp(line: string): Date | null { - for (let index = 0; index < line.length; index += 1) { - const timestamp = timestampTextAt(line, index); - if (timestamp === null) { - continue; - } - const parsed = new Date(timestamp); - if (!Number.isNaN(parsed.getTime())) { - return parsed; - } - } - return null; -} - -function timestampTextAt(line: string, start: number): string | null { - if (!hasIsoTimestampBase(line, start)) { + const match = ISO_TIMESTAMP_TEXT.exec(line); + if (match === null) { return null; } - const fractionEnd = timestampFractionEnd(line, start + 19); - if (fractionEnd === null) { - return null; - } - const zoneLength = timestampZoneLength(line, fractionEnd); - return zoneLength === 0 ? null : line.slice(start, fractionEnd + zoneLength); -} - -function hasIsoTimestampBase(line: string, start: number): boolean { - if (start + 19 > line.length) { - return false; - } - const delimiters: ReadonlyArray = [ - [4, "-"], - [7, "-"], - [10, "T"], - [13, ":"], - [16, ":"], - ]; - const digitRanges: ReadonlyArray = [ - [0, 4], - [5, 7], - [8, 10], - [11, 13], - [14, 16], - [17, 19], - ]; - return ( - delimiters.every(([offset, delimiter]) => line[start + offset] === delimiter) && - digitRanges.every(([from, until]) => - isAsciiDigits(line.slice(start + from, start + until)), - ) - ); -} - -function timestampFractionEnd(line: string, start: number): number | null { - if (line[start] !== ".") { - return start; - } - const end = scanAsciiDigits(line, start + 1, 9); - return end === start + 1 ? null : end; -} - -function timestampZoneLength(line: string, start: number): number { - if (line[start] === "Z") { - return 1; - } - if (line[start] !== "+" && line[start] !== "-") { - return 0; - } - return isAsciiDigits(line.slice(start + 1, start + 3)) && - line[start + 3] === ":" && - isAsciiDigits(line.slice(start + 4, start + 6)) - ? 6 - : 0; + const parsed = new Date(match[0]); + return Number.isNaN(parsed.getTime()) ? null : parsed; } diff --git a/scripts/bot/launcher/storage/retention.ts b/scripts/bot/launcher/storage/retention.ts index aacde42..a3d25c3 100644 --- a/scripts/bot/launcher/storage/retention.ts +++ b/scripts/bot/launcher/storage/retention.ts @@ -20,12 +20,22 @@ export async function applyStorageQuota( break; } for (const item of group.items) { - await safeRm(item.path, { force: true, recursive: item.kind === "directory" }); + await pruneManagedRunItem(item); } total -= group.size; } } +async function pruneManagedRunItem( + item: ManagedRunGroup["items"][number], +): Promise { + try { + await safeRm(item.path, { force: true, recursive: item.kind === "directory" }); + } catch { + // Quota pruning is best-effort; failed cleanup must not block launcher startup. + } +} + async function managedRunGroups( logDir: string, currentSlotName: string, From f0f9e76d235fac40778b711b90d75ea52db1212b Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:05:32 +0000 Subject: [PATCH 6/8] refactor(bot): move systemd helpers to node --- scripts/bot/systemd-credential.ts | 232 +++++++++++++++++++ scripts/bot/systemd-install.ts | 221 ++++++++++++++++++ scripts/bot/systemd-support.ts | 178 +++++++++++++++ scripts/bot/systemd-update.ts | 301 +++++++++++++++++++++++++ scripts/ickb-bot-systemd-credential.sh | 159 +------------ scripts/ickb-bot-systemd-install.sh | 205 +---------------- scripts/ickb-bot-systemd-update.sh | 230 +------------------ scripts/test/bot/systemd-credential.ts | 86 ++++--- scripts/test/bot/systemd-install.ts | 187 ++++++++------- scripts/test/bot/systemd-update.ts | 283 ++++++++++------------- 10 files changed, 1190 insertions(+), 892 deletions(-) create mode 100644 scripts/bot/systemd-credential.ts create mode 100644 scripts/bot/systemd-install.ts create mode 100644 scripts/bot/systemd-support.ts create mode 100644 scripts/bot/systemd-update.ts diff --git a/scripts/bot/systemd-credential.ts b/scripts/bot/systemd-credential.ts new file mode 100644 index 0000000..54d3d6f --- /dev/null +++ b/scripts/bot/systemd-credential.ts @@ -0,0 +1,232 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { copyFile, mkdir, rm } from "node:fs/promises"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stderr as promptOutput } from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +import { parseRuntimeConfig } from "../../packages/node-utils/src/index.ts"; +import { + type Network, + type OutputStream, + isNetwork, + isRecord, + publicErrorMessage, + requireCommand, + requireNode22_19, + requireRoot, + requireSuccessfulCommand, +} from "./systemd-support.ts"; +import { credentialConfigName, credentialPath } from "./systemd-install.ts"; + +export interface BotSystemdCredentialOptions { + argv?: string[]; + repoRoot?: string; + stdout?: OutputStream; +} + +export interface ParsedCredentialArgs { + force: boolean; + network: Network; +} + +const invalidConfigMessage = + "Invalid bot config: expected exact JSON with matching chain, privateKey, optional rpcUrl, sleepIntervalSeconds, optional maxIterations, and optional maxRetryableAttempts."; +const credentialDirectory = "/etc/ickb/credentials"; +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); + +export function usage(scriptName = "ickb-bot-systemd-credential.sh"): string { + return `Usage: ${scriptName} [--force]`; +} + +export function parseCredentialArgs(argv: readonly string[]): ParsedCredentialArgs | "help" { + const [network, force, extra] = argv; + if (network === "-h" || network === "--help") { + return "help"; + } + if (extra !== undefined || network === undefined || !isNetwork(network)) { + throw new Error(usage()); + } + if (force !== undefined && force !== "--force") { + throw new Error(usage()); + } + return { force: force === "--force", network }; +} + +export async function runSystemdCredential({ + argv = process.argv.slice(2), + repoRoot: root = repoRoot, + stdout = process.stdout, +}: BotSystemdCredentialOptions = {}): Promise { + requireRoot(); + const parsed = parseCredentialArgs(argv); + if (parsed === "help") { + stdout.write(`${usage()}\n`); + return; + } + + requireCredentialRuntime(root); + if (!fs.existsSync("/var/lib/systemd/credential.secret")) { + requireSuccessfulCommand("systemd-creds", ["setup"], { stdio: "inherit" }); + } + + const credential = credentialPath(parsed.network); + if (fs.existsSync(credential) && !parsed.force) { + throw new Error(`${credential} already exists; rerun with --force to rotate it.`); + } + + await mkdir(credentialDirectory, { mode: 0o700, recursive: true }); + fs.chmodSync(credentialDirectory, 0o700); + const tmp = await makeTemporaryCredentialPath(parsed.network); + try { + const config = await promptCredentialConfig(parsed.network); + const validated = validateCredentialConfig(parsed.network, config); + encryptCredential(credentialConfigName(parsed.network), validated, tmp); + await copyFile(tmp, credential); + fs.chmodSync(credential, 0o600); + const decrypted = decryptCredential(credentialConfigName(parsed.network), credential); + validateCredentialConfig(parsed.network, decrypted); + stdout.write(`Wrote encrypted credential ${credential}\n`); + } finally { + await rm(path.dirname(tmp), { force: true, recursive: true }); + } +} + +export async function main( + argv: string[] = process.argv.slice(2), + io: { stderr?: OutputStream; stdout?: OutputStream } = {}, +): Promise { + const stderr = io.stderr ?? process.stderr; + try { + await runSystemdCredential({ argv, stdout: io.stdout }); + return 0; + } catch (error) { + stderr.write(`${publicErrorMessage(error)}\n`); + return 1; + } +} + +export function validateCredentialConfig( + expectedChain: Network, + text: string, +): string { + try { + parseRuntimeConfig(text, "BOT_CONFIG_FILE"); + const config: unknown = JSON.parse(text); + if (!isRecord(config) || config["chain"] !== expectedChain) { + throw new Error("chain mismatch"); + } + return JSON.stringify(config); + } catch { + throw new Error(invalidConfigMessage); + } +} + +function requireCredentialRuntime(root: string): void { + requireCommand("systemd-creds", "systemd-creds is required."); + requireCommand("systemd-ask-password", "systemd-ask-password is required."); + const node = requireCommand( + "node", + "node >=22.19.0 is required to validate the config before encrypting.", + ); + requireNode22_19(node, "to validate TypeScript source configs"); + if (!fs.existsSync(path.join(root, "packages/node-utils/src/index.ts"))) { + throw new Error(`Missing @ickb/node-utils source in ${root}.`); + } +} + +async function promptCredentialConfig(network: Network): Promise { + const privateKey = askPassword(`iCKB ${network} bot private key:`); + const rpcUrl = askPassword(`iCKB ${network} RPC URL [empty for CCC default]:`); + const readline = createInterface({ input, output: promptOutput }); + try { + const sleepIntervalSeconds = await questionDefault( + readline, + `iCKB ${network} bot sleep interval seconds [60]: `, + "60", + ); + const maxIterations = await readline.question( + `iCKB ${network} bot max iterations [empty for unbounded]: `, + ); + const maxRetryableAttempts = await readline.question( + `iCKB ${network} bot max retryable attempts [empty for unbounded]: `, + ); + return JSON.stringify( + configFromPrompt({ + maxIterations, + maxRetryableAttempts, + network, + privateKey, + rpcUrl, + sleepIntervalSeconds, + }), + ); + } finally { + readline.close(); + } +} + +function configFromPrompt(inputConfig: { + maxIterations: string; + maxRetryableAttempts: string; + network: Network; + privateKey: string; + rpcUrl: string; + sleepIntervalSeconds: string; +}): Record { + const config: Record = { + chain: inputConfig.network, + privateKey: inputConfig.privateKey, + sleepIntervalSeconds: Number(inputConfig.sleepIntervalSeconds), + }; + if (inputConfig.rpcUrl !== "") { + config["rpcUrl"] = inputConfig.rpcUrl; + } + if (inputConfig.maxIterations !== "") { + config["maxIterations"] = Number(inputConfig.maxIterations); + } + if (inputConfig.maxRetryableAttempts !== "") { + config["maxRetryableAttempts"] = Number(inputConfig.maxRetryableAttempts); + } + return config; +} + +function askPassword(prompt: string): string { + const result = requireSuccessfulCommand("systemd-ask-password", ["-n", prompt]); + return result.stdout.replace(/[\r\n]+$/u, ""); +} + +async function questionDefault( + readline: ReturnType, + prompt: string, + defaultValue: string, +): Promise { + const answer = await readline.question(prompt); + return answer === "" ? defaultValue : answer; +} + +function encryptCredential(name: string, text: string, outputPath: string): void { + requireSuccessfulCommand( + "systemd-creds", + ["encrypt", "--with-key=host", `--name=${name}`, "-", outputPath], + { input: text }, + ); +} + +function decryptCredential(name: string, credential: string): string { + return requireSuccessfulCommand("systemd-creds", ["decrypt", `--name=${name}`, credential]) + .stdout; +} + +async function makeTemporaryCredentialPath(network: Network): Promise { + const directory = await fs.promises.mkdtemp( + path.join(credentialDirectory, `.bot-${network}.`), + ); + return path.join(directory, "credential.tmp"); +} + +const entrypoint = process.argv[1]; +if (entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href) { + process.exit(await main()); +} diff --git a/scripts/bot/systemd-install.ts b/scripts/bot/systemd-install.ts new file mode 100644 index 0000000..a88159b --- /dev/null +++ b/scripts/bot/systemd-install.ts @@ -0,0 +1,221 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + type Network, + type OutputStream, + parseNetworkArg, + parsePositiveSafeInteger, + publicErrorMessage, + requireCommand, + requireNode22_19, + requireRoot, + requireSuccessfulCommand, + runCommand, + safeInstallDirectory, +} from "./systemd-support.ts"; + +export interface BotSystemdInstallOptions { + argv?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + stdout?: OutputStream; +} + +export interface BotServiceUnitOptions { + deployDir: string; + logStorageQuotaBytes?: number; + network: Network; +} + +export function usage(scriptName = "ickb-bot-systemd-install.sh"): string { + return [ + `Usage: ${scriptName} `, + "Run from the checkout to use as that service deployment. Generated units keep production bot logs under /log.", + "Set ICKB_BOT_LOG_STORAGE_QUOTA_BYTES to enable best-effort pruning of inactive per-run bot logs and artifacts.", + ].join("\n"); +} + +export function botServiceUnitText({ + deployDir, + logStorageQuotaBytes, + network, +}: BotServiceUnitOptions): string { + const user = serviceUser(network); + const credentialName = credentialConfigName(network); + const credential = credentialPath(network); + const logRootPath = path.join(deployDir, "log"); + const quotaEnvironment = + logStorageQuotaBytes === undefined + ? "" + : ` ICKB_BOT_LOG_STORAGE_QUOTA_BYTES=${logStorageQuotaBytes.toString()}`; + return `[Unit] +Description=iCKB bot ${network} +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=${user} +Group=${user} +WorkingDirectory=${deployDir} +Environment=BOT_CONFIG_FILE=%d/${credentialName}${quotaEnvironment} +LoadCredentialEncrypted=${credentialName}:${credential} +ExecStart=/usr/bin/node scripts/bot/launcher.ts --no-child-tee +Restart=on-failure +RestartSec=60 +RestartPreventExitStatus=2 +LimitCORE=0 +NoNewPrivileges=true +PrivateTmp=true +ProtectProc=invisible +ProtectSystem=strict +ReadWritePaths=${logRootPath} +ProtectHome=true + +[Install] +WantedBy=multi-user.target +`; +} + +export function runSystemdInstall({ + argv = process.argv.slice(2), + cwd = process.cwd(), + env = process.env, + stdout = process.stdout, +}: BotSystemdInstallOptions = {}): void { + requireRoot(); + requireInstallRuntime(); + const network = parseNetworkArg(argv, usage()); + if (network === "help") { + stdout.write(`${usage()}\n`); + return; + } + + const deployDir = fs.realpathSync(cwd); + installNetwork(network, deployDir, env, stdout); + requireSuccessfulCommand("systemctl", ["daemon-reload"], { stdio: "inherit" }); + stdout.write( + "Next: create credentials, install dependencies, type-check source, then enable the services.\n", + ); +} + +export function main( + argv: string[] = process.argv.slice(2), + io: { stderr?: OutputStream; stdout?: OutputStream } = {}, +): number { + const stderr = io.stderr ?? process.stderr; + try { + runSystemdInstall({ argv, stdout: io.stdout }); + return 0; + } catch (error) { + stderr.write(`${publicErrorMessage(error)}\n`); + return 1; + } +} + +export function installNetwork( + network: Network, + deployDir: string, + env: NodeJS.ProcessEnv = process.env, + stdout: OutputStream = process.stdout, +): void { + const user = serviceUser(network); + const logStorageQuotaBytes = parseLogStorageQuota(env["ICKB_BOT_LOG_STORAGE_QUOTA_BYTES"]); + ensureServiceUser(user); + const userId = readUserId(user, "-u"); + const groupId = readUserId(user, "-g"); + const logRootPath = path.join(deployDir, "log"); + + safeInstallDirectory(deployDir, 0o755, userId, groupId); + safeInstallDirectory(logRootPath, 0o755, 0, 0); + safeInstallDirectory(path.join(logRootPath, "bot"), 0o700, userId, groupId); + safeInstallDirectory(credentialDirectory, 0o700, 0, 0); + + const unitPath = `/etc/systemd/system/${serviceName(network)}`; + fs.writeFileSync( + unitPath, + botServiceUnitText({ deployDir, logStorageQuotaBytes, network }), + "utf8", + ); + fs.chmodSync(unitPath, 0o644); + stdout.write( + `Installed ${serviceName(network)} for user ${user} in ${deployDir} with logs under ${logRootPath}/bot\n`, + ); +} + +export function serviceName(network: Network): string { + return `ickb-bot-${network}.service`; +} + +export function serviceUser(network: Network): string { + return `ickb-bot-${network}`; +} + +export function credentialConfigName(network: Network): string { + return `ickb-bot-${network}-config.json`; +} + +export function credentialPath(network: Network): string { + return `${credentialDirectory}/ickb-bot-${network}-config.cred`; +} + +const credentialDirectory = "/etc/ickb/credentials"; + +function requireInstallRuntime(): void { + if (!isExecutableFile("/usr/bin/node")) { + throw new Error( + "/usr/bin/node is required because generated units use that path. Install Node.js >=22.19.0 there or adjust the unit after install.", + ); + } + requireNode22_19("/usr/bin/node", "at /usr/bin/node"); + const pathNode = requireCommand( + "node", + "node is required. Install Node.js >=22.19.0 before installing units.", + ); + requireNode22_19(pathNode, "on PATH"); + requireCommand("pnpm", "pnpm is required for deploy updates."); +} + +function parseLogStorageQuota(value: string | undefined): number | undefined { + return value === undefined || value === "" + ? undefined + : parsePositiveSafeInteger("ICKB_BOT_LOG_STORAGE_QUOTA_BYTES", value); +} + +function ensureServiceUser(user: string): void { + const existing = runCommand("id", ["-u", user], { stdio: "ignore" }); + if (existing.status === 0) { + return; + } + requireSuccessfulCommand( + "useradd", + ["--system", "--create-home", "--user-group", "--shell", "/usr/sbin/nologin", user], + { stdio: "inherit" }, + ); +} + +function readUserId(user: string, flag: "-g" | "-u"): number { + const result = requireSuccessfulCommand("id", [flag, user]); + const parsed = Number(result.stdout.trim()); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`Invalid id output for ${user}`); + } + return parsed; +} + +function isExecutableFile(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +const entrypoint = process.argv[1]; +if (entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href) { + process.exit(main()); +} diff --git a/scripts/bot/systemd-support.ts b/scripts/bot/systemd-support.ts new file mode 100644 index 0000000..dd6dc05 --- /dev/null +++ b/scripts/bot/systemd-support.ts @@ -0,0 +1,178 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; + +export type Network = "mainnet" | "testnet"; + +export interface OutputStream { + write: (chunk: string) => boolean | undefined; +} + +export interface RunCommandOptions { + input?: string; + stdio?: "ignore" | "inherit" | "pipe"; +} + +const nodeVersionCheck = + 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)'; + +export function isNetwork(value: string): value is Network { + return value === "testnet" || value === "mainnet"; +} + +export function parseNetworkArg(argv: readonly string[], usage: string): Network | "help" { + const [network, extra] = argv; + if (network === "-h" || network === "--help") { + return "help"; + } + if (extra !== undefined || network === undefined || !isNetwork(network)) { + throw new Error(usage); + } + return network; +} + +export function requireRoot(euid = process.geteuid?.()): void { + if (euid !== 0) { + throw new Error("Run this script as root, for example with sudo."); + } +} + +export function requireNode22_19(nodePath: string, context: string): void { + const check = runCommand(nodePath, ["-e", nodeVersionCheck], { stdio: "ignore" }); + if (check.status === 0) { + return; + } + + const version = runCommand(nodePath, ["--version"]); + const found = version.status === 0 ? version.stdout.trim() : "unknown"; + throw new Error(`Node.js >=22.19.0 is required ${context}. Found: ${found}`); +} + +export function requireCommand(command: string, message: string): string { + const resolved = findCommand(command); + if (resolved === null) { + throw new Error(message); + } + return resolved; +} + +export function findCommand(command: string, pathText = process.env.PATH): string | null { + if (path.isAbsolute(command)) { + return isExecutable(command) ? command : null; + } + for (const directory of (pathText ?? "").split(path.delimiter)) { + const candidate = path.join(directory === "" ? "." : directory, command); + if (isExecutable(candidate)) { + return candidate; + } + } + return null; +} + +export function parsePositiveSafeInteger(name: string, value: string): number { + if (!/^[1-9]\d*$/u.test(value)) { + throw new Error(`${name} must be a positive safe integer.`); + } + const parsed = BigInt(value); + if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`${name} must be a positive safe integer.`); + } + return Number(parsed); +} + +export function safeInstallDirectory( + target: string, + mode: number, + uid: number, + gid: number, +): void { + if (!path.isAbsolute(target) || !Number.isInteger(mode) || !isUid(uid) || !isUid(gid)) { + throw new Error(`Invalid directory install arguments: ${target}`); + } + + const parsed = path.parse(target); + let current = parsed.root; + assertDirectory(current); + for (const part of path.relative(parsed.root, target).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + try { + assertDirectory(current); + } catch (error) { + if (!isErrorCode(error, "ENOENT")) { + throw error; + } + fs.mkdirSync(current, { mode }); + assertDirectory(current); + } + } + fs.chmodSync(target, mode); + fs.chownSync(target, uid, gid); +} + +export function runCommand( + command: string, + args: readonly string[], + options: RunCommandOptions = {}, +): SpawnSyncReturns { + const result = spawnSync(command, [...args], { + encoding: "utf8", + input: options.input, + stdio: options.stdio, + }); + if (result.error !== undefined) { + throw result.error; + } + return result; +} + +export function requireSuccessfulCommand( + command: string, + args: readonly string[], + options: RunCommandOptions = {}, +): SpawnSyncReturns { + const result = runCommand(command, args, options); + if (result.status !== 0) { + const detail = stderrText(result) || `${command} exited with status ${String(result.status)}`; + throw new Error(detail); + } + return result; +} + +export function publicErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown systemd helper error"; +} + +export function isErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertDirectory(candidate: string): void { + const stat = fs.lstatSync(candidate); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked directory path: ${candidate}`); + } + if (!stat.isDirectory()) { + throw new Error(`Directory path is not a directory: ${candidate}`); + } +} + +function isExecutable(candidate: string): boolean { + try { + fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function isUid(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} + +function stderrText(result: SpawnSyncReturns): string { + return typeof result.stderr === "string" ? result.stderr.trim() : ""; +} diff --git a/scripts/bot/systemd-update.ts b/scripts/bot/systemd-update.ts new file mode 100644 index 0000000..ffb0b51 --- /dev/null +++ b/scripts/bot/systemd-update.ts @@ -0,0 +1,301 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { SpawnSyncReturns } from "node:child_process"; + +import { + type Network, + type OutputStream, + parseNetworkArg, + publicErrorMessage, + requireCommand, + requireNode22_19, + requireRoot, + requireSuccessfulCommand, + runCommand, +} from "./systemd-support.ts"; +import { credentialConfigName, credentialPath, serviceName, serviceUser } from "./systemd-install.ts"; + +export interface BotSystemdUpdateOptions { + argv?: string[]; +} + +export interface RequireCleanWorktreeOptions { + deployDir: string; + runAsServiceUser?: RunAsServiceUser; + user: string; + userHome: string; +} + +export type RunAsServiceUser = ( + user: string, + userHome: string, + command: string, + args: readonly string[], + options?: { stdio?: "ignore" | "inherit" | "pipe" }, +) => SpawnSyncReturns; + +export function usage(scriptName = "ickb-bot-systemd-update.sh"): string { + return `Usage: ${scriptName} `; +} + +export function runSystemdUpdate({ + argv = process.argv.slice(2), +}: BotSystemdUpdateOptions = {}): void { + requireRoot(); + requireUpdateRuntime(); + const network = parseNetworkArg(argv, usage()); + if (network === "help") { + process.stdout.write(`${usage()}\n`); + return; + } + updateNetwork(network); +} + +export function main( + argv: string[] = process.argv.slice(2), + io: { stderr?: OutputStream } = {}, +): number { + const stderr = io.stderr ?? process.stderr; + try { + runSystemdUpdate({ argv }); + return 0; + } catch (error) { + stderr.write(`${publicErrorMessage(error)}\n`); + return 1; + } +} + +export function updateNetwork(network: Network): void { + const user = serviceUser(network); + const service = serviceName(network); + const unitPath = `/etc/systemd/system/${service}`; + const pnpmBin = requireCommand("pnpm", "pnpm is required before updating."); + const userHome = serviceUserHome(user); + const deployDir = requireUnitWorkingDirectory(unitPath, network); + + requireLauncherUnit(unitPath, network, deployDir); + requireServiceCommand(user, userHome, "git", [ + "-C", + deployDir, + "rev-parse", + "--is-inside-work-tree", + ], { stdio: "ignore" }); + requireCleanWorktree({ deployDir, user, userHome }); + + requireServiceCommand(user, userHome, "git", ["-C", deployDir, "pull", "--ff-only"], { + stdio: "inherit", + }); + requireServiceCommand(user, userHome, pnpmBin, ["-C", deployDir, "bot:install"], { + stdio: "inherit", + }); + requireServiceCommand(user, userHome, pnpmBin, ["-C", deployDir, "bot:check"], { + stdio: "inherit", + }); + requireSuccessfulCommand("systemctl", ["restart", service], { stdio: "inherit" }); + requireSuccessfulCommand("systemctl", ["--no-pager", "--full", "status", service], { + stdio: "inherit", + }); +} + +export function serviceUserHome(user: string): string { + const result = runCommand("getent", ["passwd", user]); + if (result.status !== 0) { + throw new Error(`User ${user} does not exist. Run bot:install first.`); + } + const userHome = result.stdout.split(":")[5] ?? ""; + if (userHome === "") { + throw new Error(`User ${user} has no home directory.`); + } + return userHome; +} + +export function runAsServiceUser( + user: string, + userHome: string, + command: string, + args: readonly string[], + options: { stdio?: "ignore" | "inherit" | "pipe" } = {}, +): SpawnSyncReturns { + return runCommand( + "runuser", + [ + "-u", + user, + "--", + "env", + `HOME=${userHome}`, + `USER=${user}`, + `LOGNAME=${user}`, + "SHELL=/bin/bash", + command, + ...args, + ], + { stdio: options.stdio }, + ); +} + +export function requireServiceCommand( + user: string, + userHome: string, + command: string, + args: readonly string[], + options: { stdio?: "ignore" | "inherit" | "pipe" } = {}, +): SpawnSyncReturns { + const result = runAsServiceUser(user, userHome, command, args, options); + if (result.status !== 0) { + throw new Error( + stderrText(result) || `${command} exited with status ${String(result.status)}`, + ); + } + return result; +} + +export function requireCleanWorktree({ + deployDir, + runAsServiceUser: run = runAsServiceUser, + user, + userHome, +}: RequireCleanWorktreeOptions): void { + const status = run(user, userHome, "git", ["-C", deployDir, "status", "--porcelain"]); + if (status.status !== 0) { + throw new Error(stderrText(status) || "Unable to inspect deploy checkout status."); + } + if (status.stdout !== "") { + throw new Error( + `Deploy checkout ${deployDir} has local changes or untracked files; refusing to update.`, + ); + } +} + +export function requireLauncherUnit( + unitPath: string, + network: Network, + deployDir: string, +): void { + if (!isReadableFile(unitPath)) { + throw new Error( + `Service unit ${unitPath} is missing or unreadable. Run scripts/ickb-bot-systemd-install.sh ${network} first.`, + ); + } + + const unitText = fs.readFileSync(unitPath, "utf8"); + const credentialName = credentialConfigName(network); + const credential = credentialPath(network); + const logRoot = path.join(deployDir, "log"); + if ( + !serviceHasBotEnvironment(unitText, credentialName) || + !serviceHasLine(unitText, `LoadCredentialEncrypted=${credentialName}:${credential}`) || + !serviceHasLine(unitText, "ExecStart=/usr/bin/node scripts/bot/launcher.ts --no-child-tee") || + !serviceHasLine(unitText, "RestartPreventExitStatus=2") || + !serviceHasLine(unitText, "LimitCORE=0") || + !serviceHasLine(unitText, "RestartSec=60") || + !serviceHasLine(unitText, `ReadWritePaths=${logRoot}`) + ) { + throw new Error( + `Service unit ${unitPath} is not wired for production launcher file logging and core-dump hardening. Run scripts/ickb-bot-systemd-install.sh ${network} before updating.`, + ); + } +} + +export function requireUnitWorkingDirectory(unitPath: string, network: Network): string { + const deployDir = unitWorkingDirectory(fs.readFileSync(unitPath, "utf8")); + if (deployDir === null || !path.isAbsolute(deployDir)) { + throw new Error( + `Service unit ${unitPath} has no absolute WorkingDirectory. Run scripts/ickb-bot-systemd-install.sh ${network} from the deploy checkout before updating.`, + ); + } + return deployDir; +} + +export function unitWorkingDirectory(unitText: string): string | null { + for (const line of serviceSectionLines(unitText)) { + if (line.startsWith("WorkingDirectory=")) { + return line.slice("WorkingDirectory=".length); + } + } + return null; +} + +export function serviceHasLine(unitText: string, expected: string): boolean { + return serviceSectionLines(unitText).includes(expected); +} + +export function serviceHasBotEnvironment( + unitText: string, + credentialName: string, +): boolean { + const base = `Environment=BOT_CONFIG_FILE=%d/${credentialName}`; + for (const line of serviceSectionLines(unitText)) { + if (line === base) { + return true; + } + if (!line.startsWith(`${base} ICKB_BOT_LOG_STORAGE_QUOTA_BYTES=`)) { + continue; + } + const quota = line.slice(`${base} ICKB_BOT_LOG_STORAGE_QUOTA_BYTES=`.length); + if (/^[1-9]\d*$/u.test(quota)) { + return true; + } + } + return false; +} + +export function serviceSectionLines(unitText: string): string[] { + const lines: string[] = []; + let inService = false; + for (const rawLine of unitText.split("\n")) { + const line = rawLine.replace(/\r$/u, "").trim(); + if (line === "" || line.startsWith("#") || line.startsWith(";")) { + continue; + } + const section = /^\[(.*)\]$/u.exec(line); + if (section !== null) { + inService = section[1] === "Service"; + continue; + } + if (inService) { + lines.push(line); + } + } + return lines; +} + +function requireUpdateRuntime(): void { + if (!isExecutableFile("/usr/bin/node")) { + throw new Error( + "/usr/bin/node is required because generated units use that path. Install Node.js >=22.19.0 there or adjust the unit before updating.", + ); + } + requireNode22_19("/usr/bin/node", "at /usr/bin/node"); + requireCommand("pnpm", "pnpm is required before updating."); + requireCommand("git", "git is required before updating."); +} + +function isReadableFile(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +function isExecutableFile(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function stderrText(result: SpawnSyncReturns): string { + return typeof result.stderr === "string" ? result.stderr.trim() : ""; +} + +const entrypoint = process.argv[1]; +if (entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href) { + process.exit(main()); +} diff --git a/scripts/ickb-bot-systemd-credential.sh b/scripts/ickb-bot-systemd-credential.sh index 58a8edd..6989904 100755 --- a/scripts/ickb-bot-systemd-credential.sh +++ b/scripts/ickb-bot-systemd-credential.sh @@ -1,155 +1,4 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - printf 'Usage: %s [--force]\n' "${0##*/}" >&2 -} - -require_node_22_19() { - local node_bin=$1 - local context=$2 - "${node_bin}" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' || { - printf 'Node.js >=22.19.0 is required %s. Found: %s\n' "${context}" "$("${node_bin}" --version)" >&2 - exit 1 - } -} - -require_root() { - if [[ ${EUID} -ne 0 ]]; then - printf 'Run this script as root, for example with sudo.\n' >&2 - exit 1 - fi -} - -validate_config() { - local expected_chain=$1 - local repo_root=$2 - node -e ' -(async () => { -const { readFileSync } = require("node:fs"); -const { pathToFileURL } = require("node:url"); -const repoRoot = process.argv[1]; -const expectedChain = process.argv[2]; -const text = readFileSync(0, "utf8"); -let config; -try { - const { parseRuntimeConfig } = await import(pathToFileURL(`${repoRoot}/packages/node-utils/src/index.ts`).href); - parseRuntimeConfig(text, "BOT_CONFIG_FILE"); - config = JSON.parse(text); -} catch { - fail(); -} -if (config.chain !== expectedChain) fail(); -process.stdout.write(JSON.stringify(config)); -})(); -function fail() { - process.stderr.write("Invalid bot config: expected exact JSON with matching chain, privateKey, optional rpcUrl, sleepIntervalSeconds, optional maxIterations, and optional maxRetryableAttempts.\n"); - process.exit(1); -} -' "${repo_root}" "${expected_chain}" -} - -main() { - require_root - - local script_dir - local repo_root - script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) - repo_root=$(cd -- "${script_dir}/.." && pwd) - - local network=${1:-} - local force=${2:-} - case "${network}" in - testnet|mainnet) ;; - -h|--help) - usage - exit 0 - ;; - *) - usage - exit 1 - ;; - esac - if [[ -n "${force}" && "${force}" != "--force" ]]; then - usage - exit 1 - fi - - command -v systemd-creds >/dev/null || { - printf 'systemd-creds is required.\n' >&2 - exit 1 - } - command -v systemd-ask-password >/dev/null || { - printf 'systemd-ask-password is required.\n' >&2 - exit 1 - } - command -v node >/dev/null || { - printf 'node >=22.19.0 is required to validate the config before encrypting.\n' >&2 - exit 1 - } - require_node_22_19 "$(command -v node)" "to validate TypeScript source configs" - if [[ ! -e /var/lib/systemd/credential.secret ]]; then - systemd-creds setup - fi - if [[ ! -r "${repo_root}/packages/node-utils/src/index.ts" ]]; then - printf 'Missing @ickb/node-utils source in %s.\n' "${repo_root}" >&2 - exit 1 - fi - - local credential_dir=/etc/ickb/credentials - local credential_name="ickb-bot-${network}-config.json" - local credential="${credential_dir}/ickb-bot-${network}-config.cred" - if [[ -e "${credential}" && "${force}" != "--force" ]]; then - printf '%s already exists; rerun with --force to rotate it.\n' "${credential}" >&2 - exit 1 - fi - - install -d -m 700 "${credential_dir}" - local tmp - tmp=$(mktemp "${credential_dir}/.bot-${network}.XXXXXX") - trap 'rm -f "${tmp}"' EXIT - - umask 077 - local private_key - local rpc_url - local sleep_interval - local max_iterations - local retryable_prompt - local max_retryable_attempts - private_key=$(systemd-ask-password -n "iCKB ${network} bot private key:") - rpc_url=$(systemd-ask-password -n "iCKB ${network} RPC URL [empty for CCC default]:") - read -r -p "iCKB ${network} bot sleep interval seconds [60]: " sleep_interval - sleep_interval=${sleep_interval:-60} - read -r -p "iCKB ${network} bot max iterations [empty for unbounded]: " max_iterations - retryable_prompt="iCKB ${network} bot max retryable attempts [empty for unbounded]: " - read -r -p "${retryable_prompt}" max_retryable_attempts - printf '%s\0%s\0%s\0%s\0%s\0%s' "${network}" "${private_key}" "${rpc_url}" "${sleep_interval}" "${max_iterations}" "${max_retryable_attempts}" | - node -e ' -const input = require("node:fs").readFileSync(0).toString("utf8").split("\0"); -const [chain, privateKey, rpcUrl, sleepIntervalSeconds, maxIterations, maxRetryableAttempts] = input; -const config = { - chain, - privateKey, - sleepIntervalSeconds: Number(sleepIntervalSeconds), -}; -if (rpcUrl !== "") { - config.rpcUrl = rpcUrl; -} -if (maxIterations !== "") { - config.maxIterations = Number(maxIterations); -} -if (maxRetryableAttempts !== "") { - config.maxRetryableAttempts = Number(maxRetryableAttempts); -} -process.stdout.write(JSON.stringify(config)); -' | - validate_config "${network}" "${repo_root}" | - systemd-creds encrypt --with-key=host --name="${credential_name}" - "${tmp}" - install -m 600 "${tmp}" "${credential}" - systemd-creds decrypt --name="${credential_name}" "${credential}" | validate_config "${network}" "${repo_root}" >/dev/null - printf 'Wrote encrypted credential %s\n' "${credential}" -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi +#!/usr/bin/env sh +set -eu +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec node "${script_dir}/bot/systemd-credential.ts" "$@" diff --git a/scripts/ickb-bot-systemd-install.sh b/scripts/ickb-bot-systemd-install.sh index cdbeb33..3075c48 100755 --- a/scripts/ickb-bot-systemd-install.sh +++ b/scripts/ickb-bot-systemd-install.sh @@ -1,201 +1,4 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - printf 'Usage: %s \n' "${0##*/}" >&2 - printf 'Run from the checkout to use as that service deployment. Generated units keep production bot logs under /log.\n' >&2 - printf 'Set ICKB_BOT_LOG_STORAGE_QUOTA_BYTES to enable best-effort pruning of inactive per-run bot logs and artifacts.\n' >&2 -} - -require_node_22_19() { - local node_bin=$1 - local context=$2 - "${node_bin}" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' || { - printf 'Node.js >=22.19.0 is required %s. Found: %s\n' "${context}" "$("${node_bin}" --version)" >&2 - exit 1 - } -} - -require_root() { - if [[ ${EUID} -ne 0 ]]; then - printf 'Run this script as root, for example with sudo.\n' >&2 - exit 1 - fi -} - -require_runtime() { - if [[ ! -x /usr/bin/node ]]; then - printf '/usr/bin/node is required because generated units use that path. Install Node.js >=22.19.0 there or adjust the unit after install.\n' >&2 - exit 1 - fi - require_node_22_19 /usr/bin/node "at /usr/bin/node" - command -v node >/dev/null || { - printf 'node is required. Install Node.js >=22.19.0 before installing units.\n' >&2 - exit 1 - } - require_node_22_19 "$(command -v node)" "on PATH" - command -v pnpm >/dev/null || { - printf 'pnpm is required for deploy updates.\n' >&2 - exit 1 - } -} - -require_systemd_safe_positive_integer() { - local name=$1 - local value=$2 - - node -e ' -const value = process.argv[1] ?? ""; -process.exit(/^[1-9][0-9]*$/.test(value) && BigInt(value) <= BigInt(Number.MAX_SAFE_INTEGER) ? 0 : 1); -' "${value}" || { - printf '%s must be a positive safe integer.\n' "${name}" >&2 - exit 1 - } -} - -safe_install_directory() { - local path=$1 - local mode=$2 - local uid=$3 - local gid=$4 - - node -e ' -const fs = require("node:fs"); -const path = require("node:path"); - -const target = process.argv[1]; -const mode = Number.parseInt(process.argv[2], 8); -const uid = Number(process.argv[3]); -const gid = Number(process.argv[4]); - -if (!path.isAbsolute(target) || !Number.isInteger(mode) || !Number.isInteger(uid) || !Number.isInteger(gid)) { - fail(`Invalid directory install arguments: ${target}`); -} - -const parsed = path.parse(target); -let current = parsed.root; -assertDirectory(current); - -for (const part of path.relative(parsed.root, target).split(path.sep).filter(Boolean)) { - current = path.join(current, part); - try { - assertDirectory(current); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - fs.mkdirSync(current, { mode }); - assertDirectory(current); - } -} - -fs.chmodSync(target, mode); -fs.chownSync(target, uid, gid); - -function assertDirectory(candidate) { - const stat = fs.lstatSync(candidate); - if (stat.isSymbolicLink()) { - fail(`Refusing symlinked directory path: ${candidate}`); - } - if (!stat.isDirectory()) { - fail(`Directory path is not a directory: ${candidate}`); - } -} - -function fail(message) { - process.stderr.write(`${message}\n`); - process.exit(1); -} -' "${path}" "${mode}" "${uid}" "${gid}" -} - -install_network() { - local network=$1 - local deploy_dir=$2 - local user="ickb-bot-${network}" - local credential_name="ickb-bot-${network}-config.json" - local credential="/etc/ickb/credentials/ickb-bot-${network}-config.cred" - local service="ickb-bot-${network}.service" - local unit_path="/etc/systemd/system/${service}" - local configured_log_quota=${ICKB_BOT_LOG_STORAGE_QUOTA_BYTES:-} - local launcher_quota_environment= - local log_root_path="${deploy_dir}/log" - - if [[ -n ${configured_log_quota} ]]; then - require_systemd_safe_positive_integer ICKB_BOT_LOG_STORAGE_QUOTA_BYTES "${configured_log_quota}" - launcher_quota_environment=" ICKB_BOT_LOG_STORAGE_QUOTA_BYTES=${configured_log_quota}" - fi - - if ! id -u "${user}" >/dev/null 2>&1; then - useradd --system --create-home --user-group --shell /usr/sbin/nologin "${user}" - fi - local user_id - local group_id - user_id=$(id -u "${user}") - group_id=$(id -g "${user}") - - safe_install_directory "${deploy_dir}" 755 "${user_id}" "${group_id}" - safe_install_directory "${log_root_path}" 755 0 0 - safe_install_directory "${log_root_path}/bot" 700 "${user_id}" "${group_id}" - install -d -m 700 /etc/ickb/credentials - - cat >"${unit_path}" <\n' "${0##*/}" >&2 -} - -require_node_22_19() { - local node_bin=$1 - local context=$2 - "${node_bin}" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' || { - printf 'Node.js >=22.19.0 is required %s. Found: %s\n' "${context}" "$("${node_bin}" --version)" >&2 - exit 1 - } -} - -require_root() { - if [[ ${EUID} -ne 0 ]]; then - printf 'Run this script as root, for example with sudo.\n' >&2 - exit 1 - fi -} - -require_runtime() { - if [[ ! -x /usr/bin/node ]]; then - printf '/usr/bin/node is required because generated units use that path. Install Node.js >=22.19.0 there or adjust the unit before updating.\n' >&2 - exit 1 - fi - require_node_22_19 /usr/bin/node "at /usr/bin/node" - command -v pnpm >/dev/null || { - printf 'pnpm is required before updating.\n' >&2 - exit 1 - } - command -v git >/dev/null || { - printf 'git is required before updating.\n' >&2 - exit 1 - } -} - -service_user_home() { - local user=$1 - local passwd_entry - local user_home - - passwd_entry=$(getent passwd "${user}") || { - printf 'User %s does not exist. Run bot:install first.\n' "${user}" >&2 - exit 1 - } - IFS=: read -r _ _ _ _ _ user_home _ <<<"${passwd_entry}" - if [[ -z ${user_home} ]]; then - printf 'User %s has no home directory.\n' "${user}" >&2 - exit 1 - fi - printf '%s\n' "${user_home}" -} - -run_as_service_user() { - local user=$1 - local user_home=$2 - shift 2 - - runuser -u "${user}" -- env HOME="${user_home}" USER="${user}" LOGNAME="${user}" SHELL=/bin/bash "$@" -} - -require_clean_worktree() { - local user=$1 - local user_home=$2 - local deploy_dir=$3 - - if [[ -n $(run_as_service_user "${user}" "${user_home}" git -C "${deploy_dir}" status --porcelain) ]]; then - printf 'Deploy checkout %s has local changes or untracked files; refusing to update.\n' "${deploy_dir}" >&2 - exit 1 - fi -} - -require_launcher_unit() { - local unit_path=$1 - local network=$2 - local deploy_dir=$3 - - if [[ ! -r ${unit_path} ]]; then - printf 'Service unit %s is missing or unreadable. Run scripts/ickb-bot-systemd-install.sh %s first.\n' "${unit_path}" "${network}" >&2 - exit 1 - fi - - local unit_text - unit_text=$(<"${unit_path}") - local credential_name="ickb-bot-${network}-config.json" - local credential="/etc/ickb/credentials/ickb-bot-${network}-config.cred" - local log_root="${deploy_dir}/log" - if ! service_has_bot_environment "${unit_text}" "${credential_name}" || - ! service_has_line "${unit_text}" "LoadCredentialEncrypted=${credential_name}:${credential}" || - ! service_has_line "${unit_text}" "ExecStart=/usr/bin/node scripts/bot/launcher.ts --no-child-tee" || - ! service_has_line "${unit_text}" "RestartPreventExitStatus=2" || - ! service_has_line "${unit_text}" "LimitCORE=0" || - ! service_has_line "${unit_text}" "RestartSec=60" || - ! service_has_line "${unit_text}" "ReadWritePaths=${log_root}"; then - printf 'Service unit %s is not wired for production launcher file logging and core-dump hardening. Run scripts/ickb-bot-systemd-install.sh %s before updating.\n' "${unit_path}" "${network}" >&2 - exit 1 - fi -} - -unit_working_directory() { - local unit_path=$1 - local unit_text - unit_text=$(<"${unit_path}") - local line - local value - local in_service=0 - - while IFS= read -r line || [[ -n ${line} ]]; do - line=${line%$'\r'} - [[ ${line} =~ ^[[:space:]]*($|#|\;) ]] && continue - if [[ ${line} =~ ^[[:space:]]*\[(.*)\][[:space:]]*$ ]]; then - [[ ${BASH_REMATCH[1]} == Service ]] && in_service=1 || in_service=0 - continue - fi - [[ ${in_service} -eq 1 ]] || continue - if [[ ${line} == WorkingDirectory=* ]]; then - value=${line#WorkingDirectory=} - printf '%s\n' "${value}" - return 0 - fi - done <<<"${unit_text}" - return 1 -} - -require_unit_working_directory() { - local unit_path=$1 - local network=$2 - local deploy_dir - if ! deploy_dir=$(unit_working_directory "${unit_path}") || [[ -z ${deploy_dir} || ${deploy_dir} != /* ]]; then - printf 'Service unit %s has no absolute WorkingDirectory. Run scripts/ickb-bot-systemd-install.sh %s from the deploy checkout before updating.\n' "${unit_path}" "${network}" >&2 - exit 1 - fi - printf '%s\n' "${deploy_dir}" -} - -service_has_line() { - local unit_text=$1 - local expected=$2 - local line - local in_service=0 - - while IFS= read -r line || [[ -n ${line} ]]; do - line=${line%$'\r'} - [[ ${line} =~ ^[[:space:]]*($|#|\;) ]] && continue - if [[ ${line} =~ ^[[:space:]]*\[(.*)\][[:space:]]*$ ]]; then - [[ ${BASH_REMATCH[1]} == Service ]] && in_service=1 || in_service=0 - continue - fi - [[ ${in_service} -eq 1 ]] || continue - if [[ ${line} == "${expected}" ]]; then - return 0 - fi - done <<<"${unit_text}" - return 1 -} - -service_has_bot_environment() { - local unit_text=$1 - local credential_name=$2 - local line - local in_service=0 - local quota - - while IFS= read -r line || [[ -n ${line} ]]; do - line=${line%$'\r'} - [[ ${line} =~ ^[[:space:]]*($|#|\;) ]] && continue - if [[ ${line} =~ ^[[:space:]]*\[(.*)\][[:space:]]*$ ]]; then - [[ ${BASH_REMATCH[1]} == Service ]] && in_service=1 || in_service=0 - continue - fi - [[ ${in_service} -eq 1 ]] || continue - if [[ ${line} == "Environment=BOT_CONFIG_FILE=%d/${credential_name}" ]]; then - return 0 - fi - if [[ ${line} == "Environment=BOT_CONFIG_FILE=%d/${credential_name} ICKB_BOT_LOG_STORAGE_QUOTA_BYTES="* ]]; then - quota=${line#"Environment=BOT_CONFIG_FILE=%d/${credential_name} ICKB_BOT_LOG_STORAGE_QUOTA_BYTES="} - [[ ${quota} =~ ^[1-9][0-9]*$ ]] && return 0 - fi - done <<<"${unit_text}" - return 1 -} - -main() { - require_root - require_runtime - - local network=${1:-} - case "${network}" in - testnet|mainnet) ;; - -h|--help) - usage - exit 0 - ;; - *) - usage - exit 1 - ;; - esac - - local user="ickb-bot-${network}" - local service="ickb-bot-${network}.service" - local unit_path="/etc/systemd/system/${service}" - local deploy_dir - local pnpm_bin - pnpm_bin=$(command -v pnpm) - local user_home - - user_home=$(service_user_home "${user}") - deploy_dir=$(require_unit_working_directory "${unit_path}" "${network}") - require_launcher_unit "${unit_path}" "${network}" "${deploy_dir}" - run_as_service_user "${user}" "${user_home}" git -C "${deploy_dir}" rev-parse --is-inside-work-tree >/dev/null - require_clean_worktree "${user}" "${user_home}" "${deploy_dir}" - - run_as_service_user "${user}" "${user_home}" git -C "${deploy_dir}" pull --ff-only - run_as_service_user "${user}" "${user_home}" "${pnpm_bin}" -C "${deploy_dir}" bot:install - run_as_service_user "${user}" "${user_home}" "${pnpm_bin}" -C "${deploy_dir}" bot:check - systemctl restart "${service}" - systemctl --no-pager --full status "${service}" -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi +#!/usr/bin/env sh +set -eu +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec node "${script_dir}/bot/systemd-update.ts" "$@" diff --git a/scripts/test/bot/systemd-credential.ts b/scripts/test/bot/systemd-credential.ts index 46bbe70..12cfd07 100644 --- a/scripts/test/bot/systemd-credential.ts +++ b/scripts/test/bot/systemd-credential.ts @@ -1,21 +1,27 @@ import assert from "node:assert/strict"; -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { readFile as fsReadFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -const { join } = path; +import { validateCredentialConfig } from "../../bot/systemd-credential.ts"; + const rootDir = fileURLToPath(new URL("../../..", import.meta.url)); -const script = join(rootDir, "scripts", "ickb-bot-systemd-credential.sh"); -const bashPath = "/usr/bin/bash"; +const wrapper = joinPath(rootDir, "scripts", "ickb-bot-systemd-credential.sh"); +const entrypoint = joinPath(rootDir, "scripts", "bot", "systemd-credential.ts"); const privateKey = `0x${"11".repeat(32)}`; +void test("credential helper wrapper delegates to the Node-owned entrypoint", async () => { + const text = await readText(wrapper); + + assert.match(text, /bot\/systemd-credential\.ts/u); + assert.doesNotMatch(text, /validate_config/u); +}); + void test("credential helper requires Node 22.19 for source config validation", async () => { - const text = await readScript(); + const text = await readText(entrypoint); - assert.match(text, /Node\.js >=22\.19\.0/u); - assert.match(text, /minor >= 19/u); + assert.match(text, /requireNode22_19/u); }); void test("credential helper validation uses the shared runtime parser", () => { @@ -27,9 +33,7 @@ void test("credential helper validation uses the shared runtime parser", () => { maxRetryableAttempts: 10, }); - const valid = validateConfig("testnet", config); - assert.equal(valid.status, 0, valid.stderr); - assert.equal(valid.stdout, config); + assert.equal(validateCredentialConfig("testnet", config), config); const defaultRpcConfig = JSON.stringify({ chain: "testnet", @@ -37,63 +41,53 @@ void test("credential helper validation uses the shared runtime parser", () => { sleepIntervalSeconds: 60, maxRetryableAttempts: 10, }); - const validDefaultRpc = validateConfig("testnet", defaultRpcConfig); - assert.equal(validDefaultRpc.status, 0, validDefaultRpc.stderr); - assert.equal(validDefaultRpc.stdout, defaultRpcConfig); + assert.equal(validateCredentialConfig("testnet", defaultRpcConfig), defaultRpcConfig); const unboundedRetryConfig = JSON.stringify({ chain: "testnet", privateKey, sleepIntervalSeconds: 60, }); - const validUnboundedRetry = validateConfig("testnet", unboundedRetryConfig); - assert.equal(validUnboundedRetry.status, 0, validUnboundedRetry.stderr); - assert.equal(validUnboundedRetry.stdout, unboundedRetryConfig); - - const wrongChain = validateConfig("mainnet", config); - assert.equal(wrongChain.status, 1); - assert.match(wrongChain.stderr, /Invalid bot config/u); - - const invalidKey = validateConfig( - "testnet", - JSON.stringify({ - chain: "testnet", - privateKey: `${privateKey}\n`, - rpcUrl: "http://127.0.0.1:8114/", - sleepIntervalSeconds: 60, - }), + assert.equal(validateCredentialConfig("testnet", unboundedRetryConfig), unboundedRetryConfig); + + assert.throws(() => validateCredentialConfig("mainnet", config), /Invalid bot config/u); + + assert.throws( + () => + validateCredentialConfig( + "testnet", + JSON.stringify({ + chain: "testnet", + privateKey: `${privateKey}\n`, + rpcUrl: "http://127.0.0.1:8114/", + sleepIntervalSeconds: 60, + }), + ), + (error: unknown) => + error instanceof Error && + /Invalid bot config/u.test(error.message) && + !/0x11/u.test(error.message), ); - assert.equal(invalidKey.status, 1); - assert.doesNotMatch(invalidKey.stderr, /0x11/u); }); void test("credential helper does not echo RPC URL input", async () => { - const text = await readScript(); + const text = await readText(entrypoint); assert.doesNotMatch(text, /systemd-ask-password --echo=yes/u); }); void test("credential helper prompts for retryable-attempt budget", async () => { - const text = await readScript(); + const text = await readText(entrypoint); assert.match(text, /max retryable attempts/u); assert.match(text, /empty for unbounded/u); assert.match(text, /maxRetryableAttempts/u); }); -async function readScript(): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test reads the fixed credential helper script under the repository root. - return fsReadFile(script, "utf8"); +async function readText(filePath: string): Promise { + return fsReadFile(filePath, "utf8"); } -function validateConfig(network: string, input: string): SpawnSyncReturns { - return spawnSync( - bashPath, - ["-c", `source "$1"; validate_config "$2" "$3"`, "bash", script, network, rootDir], - { - cwd: rootDir, - input, - encoding: "utf8", - }, - ); +function joinPath(...segments: string[]): string { + return path.join(...segments); } diff --git a/scripts/test/bot/systemd-install.ts b/scripts/test/bot/systemd-install.ts index 2341e71..aa27620 100644 --- a/scripts/test/bot/systemd-install.ts +++ b/scripts/test/bot/systemd-install.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import type { Stats } from "node:fs"; import { chmod as fsChmod, @@ -15,46 +14,68 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -const { join } = path; +import { botServiceUnitText } from "../../bot/systemd-install.ts"; +import { + parsePositiveSafeInteger, + requireNode22_19, + safeInstallDirectory, +} from "../../bot/systemd-support.ts"; + const rootDir = fileURLToPath(new URL("../../..", import.meta.url)); -const installScript = join(rootDir, "scripts", "ickb-bot-systemd-install.sh"); -const bashPath = "/usr/bin/bash"; +const installWrapper = joinPath(rootDir, "scripts", "ickb-bot-systemd-install.sh"); +const installEntrypoint = joinPath(rootDir, "scripts", "bot", "systemd-install.ts"); -void test("systemd install units run the source bot through the file-log launcher", async () => { - const text = await readInstallScript(); +void test("systemd install wrapper delegates to the Node-owned entrypoint", async () => { + const text = await readText(installWrapper); + + assert.match(text, /bot\/systemd-install\.ts/u); + assert.doesNotMatch(text, /require_systemd_safe_positive_integer/u); +}); + +void test("systemd install units run the source bot through the file-log launcher", () => { + const text = botServiceUnitText({ + deployDir: "/srv/ickb-stack", + logStorageQuotaBytes: 1000, + network: "testnet", + }); assert.match( text, /ExecStart=\/usr\/bin\/node scripts\/bot\/launcher\.ts --no-child-tee/u, ); assert.doesNotMatch(text, /ExecStart=\/usr\/bin\/node apps\/bot\/src\/index\.ts/u); - assert.match(text, /Environment=BOT_CONFIG_FILE=%d\/\$\{credential_name\}/u); - assert.match(text, /LoadCredentialEncrypted=\$\{credential_name\}:\$\{credential\}/u); + assert.match( + text, + /Environment=BOT_CONFIG_FILE=%d\/ickb-bot-testnet-config\.json ICKB_BOT_LOG_STORAGE_QUOTA_BYTES=1000/u, + ); + assert.match( + text, + /LoadCredentialEncrypted=ickb-bot-testnet-config\.json:\/etc\/ickb\/credentials\/ickb-bot-testnet-config\.cred/u, + ); assert.match(text, /RestartSec=60/u); assert.match(text, /RestartPreventExitStatus=2/u); assert.match(text, /LimitCORE=0/u); assert.match(text, /ProtectSystem=strict/u); - assert.match(text, /ReadWritePaths=\$\{log_root_path\}/u); + assert.match(text, /ReadWritePaths=\/srv\/ickb-stack\/log/u); }); void test("systemd install script requires Node 22.19 for source units", async () => { - const text = await readInstallScript(); + const text = await readText(installEntrypoint); - assert.match(text, /Node\.js >=22\.19\.0/u); - assert.match(text, /minor >= 19/u); + assert.match(text, /requireNode22_19/u); assert.doesNotMatch(text, /Node\.js >=22 is required/u); }); void test("systemd install node version helper rejects early Node 22", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-node-version-")); + const dir = await mkdtemp(joinPath(tmpdir(), "ickb-node-version-")); try { - const fakeNode = join(dir, "node"); + const fakeNode = joinPath(dir, "node"); await writeText( fakeNode, `#!/usr/bin/env bash version=\${FAKE_NODE_VERSION:?} if [[ \${1:-} == --version ]]; then - printf 'v%s\\n' "\${version}" + printf 'v%s\n' "\${version}" exit 0 fi IFS=. read -r major minor _ <<<"\${version}" @@ -66,28 +87,34 @@ exit 1 ); await chmodPath(fakeNode, 0o755); - assert.equal(requireNodeVersion(fakeNode, "22.18.0").status, 1); - assert.equal(requireNodeVersion(fakeNode, "22.19.0").status, 0); - assert.equal(requireNodeVersion(fakeNode, "23.0.0").status, 0); + assert.throws(() => { + requireNodeVersion(fakeNode, "22.18.0"); + }, /v22\.18\.0/u); + assert.doesNotThrow(() => { + requireNodeVersion(fakeNode, "22.19.0"); + }); + assert.doesNotThrow(() => { + requireNodeVersion(fakeNode, "23.0.0"); + }); } finally { await rm(dir, { force: true, recursive: true }); } }); -void test("systemd install script uses the invoking checkout as deployment root", async () => { - const text = await readInstallScript(); +void test("systemd install units use the invoking checkout as deployment root", () => { + const text = botServiceUnitText({ deployDir: "/srv/deploy", network: "mainnet" }); - assert.match(text, /deploy_dir=\$\(pwd -P\)/u); - assert.match(text, /log_root_path="\$\{deploy_dir\}\/log"/u); + assert.match(text, /WorkingDirectory=\/srv\/deploy/u); + assert.match(text, /ReadWritePaths=\/srv\/deploy\/log/u); assert.doesNotMatch(text, /\/opt\/ickb-stack-/u); assert.doesNotMatch(text, /\/var\/log/u); assert.doesNotMatch(text, /logs\/live-supervisor/u); }); -void test("systemd install script keeps generated units on the checkout-local log root", async () => { - const text = await readInstallScript(); +void test("systemd install script keeps generated units on the checkout-local log root", () => { + const text = botServiceUnitText({ deployDir: "/srv/deploy", network: "testnet" }); - assert.match(text, /log_root_path="\$\{deploy_dir\}\/log"/u); + assert.match(text, /ReadWritePaths=\/srv\/deploy\/log/u); assert.match( text, /ExecStart=\/usr\/bin\/node scripts\/bot\/launcher\.ts --no-child-tee/u, @@ -98,44 +125,57 @@ void test("systemd install script keeps generated units on the checkout-local lo }); void test("systemd install script validates optional log storage quota", () => { - assert.equal( - validatePositiveInteger("ICKB_BOT_LOG_STORAGE_QUOTA_BYTES", "1000").status, - 0, - ); + assert.equal(parsePositiveSafeInteger("ICKB_BOT_LOG_STORAGE_QUOTA_BYTES", "1000"), 1000); for (const value of ["", "0", "abc", "9007199254740993"]) { - const invalid = validatePositiveInteger("ICKB_BOT_LOG_STORAGE_QUOTA_BYTES", value); - assert.equal(invalid.status, 1, value); - assert.match(invalid.stderr, /positive safe integer/u); + assert.throws( + () => parsePositiveSafeInteger("ICKB_BOT_LOG_STORAGE_QUOTA_BYTES", value), + /positive safe integer/u, + value, + ); } }); void test("systemd install script creates log dirs without following symlinks", async () => { - const dir = await mkdtemp(join(tmpdir(), "ickb-bot-systemd-install-")); + const dir = await mkdtemp(joinPath(tmpdir(), "ickb-bot-systemd-install-")); try { - const uid = String(process.getuid?.() ?? 0); - const gid = String(process.getgid?.() ?? 0); - const createdPath = join(dir, "log", "bot"); - const created = safeInstallDirectory(createdPath, "700", uid, gid); - assert.equal(created.status, 0, created.stderr); + const uid = process.getuid?.() ?? 0; + const gid = process.getgid?.() ?? 0; + const createdPath = joinPath(dir, "log", "bot"); + safeInstallDirectory(createdPath, 0o700, uid, gid); assert.equal(await pathMode(createdPath), 0o700); - const linkPath = join(dir, "link"); + const linkPath = joinPath(dir, "link"); await linkSymbolic(dir, linkPath, "dir"); - const refused = safeInstallDirectory(join(linkPath, "bot"), "755", uid, gid); - assert.equal(refused.status, 1); - assert.match(refused.stderr, /Refusing symlinked directory path/u); + assert.throws( + () => { + safeInstallDirectory(joinPath(linkPath, "bot"), 0o755, uid, gid); + }, + /Refusing symlinked directory path/u, + ); } finally { await rm(dir, { force: true, recursive: true }); } }); -async function readInstallScript(): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test reads the fixed systemd install helper script under the repository root. - return fsReadFile(installScript, "utf8"); +function requireNodeVersion(nodePath: string, version: string): void { + const previousVersion = process.env["FAKE_NODE_VERSION"]; + process.env["FAKE_NODE_VERSION"] = version; + try { + requireNode22_19(nodePath, "test"); + } finally { + if (previousVersion === undefined) { + delete process.env["FAKE_NODE_VERSION"]; + } else { + process.env["FAKE_NODE_VERSION"] = previousVersion; + } + } +} + +async function readText(filePath: string): Promise { + return fsReadFile(filePath, "utf8"); } async function chmodPath(filePath: string, mode: number): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test modifies files inside its own temporary fixture directory. await fsChmod(filePath, mode); } @@ -144,12 +184,10 @@ async function linkSymbolic( linkPath: string, type: "dir" | "file" | "junction", ): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- Symlink tests intentionally create links inside their temp directory. await fsSymlink(target, linkPath, type); } async function statPath(filePath: string): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test stats paths inside its own temporary fixture directory. return fsStat(filePath); } @@ -158,58 +196,9 @@ async function pathMode(filePath: string): Promise { } async function writeText(filePath: string, data: string): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test writes files inside its own temporary fixture directory. await fsWriteFile(filePath, data); } -function validatePositiveInteger(name: string, value: string): SpawnSyncReturns { - return spawnSync( - bashPath, - [ - "-c", - 'source "$1"; require_systemd_safe_positive_integer "$2" "$3"', - "bash", - installScript, - name, - value, - ], - { - cwd: rootDir, - encoding: "utf8", - }, - ); -} - -function requireNodeVersion(nodePath: string, version: string): SpawnSyncReturns { - return spawnSync( - bashPath, - ["-c", 'source "$1"; require_node_22_19 "$2" test', "bash", installScript, nodePath], - { - cwd: rootDir, - encoding: "utf8", - env: { ...process.env, FAKE_NODE_VERSION: version }, - }, - ); -} - -function safeInstallDirectory( - directory: string, - mode: string, - uid: string, - gid: string, -): SpawnSyncReturns { - return spawnSync( - bashPath, - [ - "-c", - 'source "$1"; safe_install_directory "$2" "$3" "$4" "$5"', - "bash", - installScript, - directory, - mode, - uid, - gid, - ], - { cwd: rootDir, encoding: "utf8" }, - ); +function joinPath(...segments: string[]): string { + return path.join(...segments); } diff --git a/scripts/test/bot/systemd-update.ts b/scripts/test/bot/systemd-update.ts index 8ae3b02..194684c 100644 --- a/scripts/test/bot/systemd-update.ts +++ b/scripts/test/bot/systemd-update.ts @@ -1,8 +1,6 @@ import assert from "node:assert/strict"; -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import type { SpawnSyncReturns } from "node:child_process"; import { - chmod as fsChmod, - mkdir as fsMkdir, readFile as fsReadFile, writeFile as fsWriteFile, mkdtemp, @@ -13,41 +11,52 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -const { join } = path; +import { + requireCleanWorktree, + requireLauncherUnit, + type RunAsServiceUser, +} from "../../bot/systemd-update.ts"; + const rootDir = fileURLToPath(new URL("../../..", import.meta.url)); -const updateScript = join(rootDir, "scripts", "ickb-bot-systemd-update.sh"); -const bashPath = "/usr/bin/bash"; +const updateWrapper = joinPath(rootDir, "scripts", "ickb-bot-systemd-update.sh"); +const updateEntrypoint = joinPath(rootDir, "scripts", "bot", "systemd-update.ts"); const updateTempPrefix = "ickb-bot-systemd-update-"; const testnetUnitFile = "ickb-bot-testnet.service"; const testnet = "testnet"; const productionLauncherFileLogging = /production launcher file logging/u; -const shellExpansionStart = "$".concat("{"); + +void test("systemd update wrapper delegates to the Node-owned entrypoint", async () => { + const text = await readText(updateWrapper); + + assert.match(text, /bot\/systemd-update\.ts/u); + assert.doesNotMatch(text, /require_launcher_unit/u); +}); void test("systemd update script requires Node 22.19 for source units", async () => { - const text = await readUpdateScript(); + const text = await readText(updateEntrypoint); - assert.match(text, /Node\.js >=22\.19\.0/u); - assert.match(text, /minor >= 19/u); + assert.match(text, /requireNode22_19/u); assert.doesNotMatch(text, /Node\.js >=22 is required/u); }); void test("systemd update accepts launcher-wired units", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText(unitPath, unitText({ network: testnet, launcher: true })); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 0, result.stderr); + assert.doesNotThrow(() => { + checkLauncherUnit(unitPath, testnet); + }); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update refuses launcher-wired units with explicit log roots", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText( unitPath, unitText({ @@ -57,34 +66,35 @@ void test("systemd update refuses launcher-wired units with explicit log roots", }), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 1); - assert.match(result.stderr, productionLauncherFileLogging); + assert.throws(() => { + checkLauncherUnit(unitPath, testnet); + }, productionLauncherFileLogging); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update reads unit files without trailing newlines", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText( unitPath, unitText({ network: testnet, launcher: true }).replace(/\n$/u, ""), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 0, result.stderr); + assert.doesNotThrow(() => { + checkLauncherUnit(unitPath, testnet); + }); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update accepts generated units with log storage quotas", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText( unitPath, unitText({ @@ -94,34 +104,35 @@ void test("systemd update accepts generated units with log storage quotas", asyn }), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 0, result.stderr); + assert.doesNotThrow(() => { + checkLauncherUnit(unitPath, testnet); + }); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update refuses launcher units that tee child output to journald", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText( unitPath, unitText({ network: testnet, launcher: true, staleLauncherChild: true }), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 1); - assert.match(result.stderr, productionLauncherFileLogging); + assert.throws(() => { + checkLauncherUnit(unitPath, testnet); + }, productionLauncherFileLogging); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update refuses environment log-root overrides", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText( unitPath, unitText({ @@ -131,51 +142,49 @@ void test("systemd update refuses environment log-root overrides", async () => { }), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 1); - assert.match(result.stderr, productionLauncherFileLogging); + assert.throws(() => { + checkLauncherUnit(unitPath, testnet); + }, productionLauncherFileLogging); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update refuses stale direct-exec units", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText(unitPath, unitText({ network: testnet, launcher: false })); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 1); - assert.match(result.stderr, productionLauncherFileLogging); - assert.match(result.stderr, /ickb-bot-systemd-install\.sh testnet/u); + assert.throws(() => { + checkLauncherUnit(unitPath, testnet); + }, /ickb-bot-systemd-install\.sh testnet/u); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update refuses launcher units without core-dump hardening", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText( unitPath, unitText({ network: testnet, launcher: true, limitCore: false }), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 1); - assert.match(result.stderr, /core-dump hardening/u); - assert.match(result.stderr, /ickb-bot-systemd-install\.sh testnet/u); + assert.throws(() => { + checkLauncherUnit(unitPath, testnet); + }, /core-dump hardening/u); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update refuses launcher units with mismatched writable log roots", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText( unitPath, unitText({ @@ -186,53 +195,52 @@ void test("systemd update refuses launcher units with mismatched writable log ro }), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 1); - assert.match(result.stderr, productionLauncherFileLogging); - assert.match(result.stderr, /ickb-bot-systemd-install\.sh testnet/u); + assert.throws(() => { + checkLauncherUnit(unitPath, testnet); + }, productionLauncherFileLogging); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update refuses launcher log-root arguments", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText( unitPath, unitText({ network: testnet, launcher: true, logRoot: "relative-log" }), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 1); - assert.match(result.stderr, productionLauncherFileLogging); + assert.throws(() => { + checkLauncherUnit(unitPath, testnet); + }, productionLauncherFileLogging); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update ignores commented launcher directives", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, testnetUnitFile); + const unitPath = joinPath(dir, testnetUnitFile); await writeText( unitPath, unitText({ network: testnet, launcher: false, commentedSpoof: true }), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 1); - assert.match(result.stderr, productionLauncherFileLogging); + assert.throws(() => { + checkLauncherUnit(unitPath, testnet); + }, productionLauncherFileLogging); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update ignores launcher directives outside the Service section", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); + const dir = await mkdtemp(joinPath(tmpdir(), updateTempPrefix)); try { - const unitPath = join(dir, "ickb-bot-testnet.service"); + const unitPath = joinPath(dir, "ickb-bot-testnet.service"); await writeText( unitPath, unitText({ @@ -242,76 +250,43 @@ void test("systemd update ignores launcher directives outside the Service sectio }), ); - const result = requireLauncherUnit(unitPath, testnet); - assert.equal(result.status, 1); - assert.match(result.stderr, productionLauncherFileLogging); + assert.throws(() => { + checkLauncherUnit(unitPath, testnet); + }, productionLauncherFileLogging); } finally { await rm(dir, { force: true, recursive: true }); } }); void test("systemd update checks unit wiring before mutating deploy checkout", async () => { - const text = await readUpdateScript(); - const guardIndex = text.indexOf( - `require_launcher_unit "${shellExpansionStart}unit_path}" "${shellExpansionStart}network}" "${shellExpansionStart}deploy_dir}"`, - ); - const pullIndex = text.indexOf( - `git -C "${shellExpansionStart}deploy_dir}" pull --ff-only`, - ); + const text = await readText(updateEntrypoint); + const guardIndex = text.indexOf("requireLauncherUnit(unitPath, network, deployDir);"); + const pullIndex = text.indexOf('"pull", "--ff-only"'); assert.notEqual(guardIndex, -1); assert.notEqual(pullIndex, -1); assert.ok(guardIndex < pullIndex); }); -void test("systemd update refuses untracked files before pulling", async () => { - const dir = await mkdtemp(join(tmpdir(), updateTempPrefix)); - try { - const fakeBin = join(dir, "bin"); - const logPath = join(dir, "git.log"); - await makeDirectory(fakeBin, { recursive: true }); - await writeText( - join(fakeBin, "runuser"), - `#!/usr/bin/env bash -shift 3 -env_args=() -while [[ $# -gt 0 && $1 == *=* ]]; do - env_args+=("$1") - shift -done -exec env "${shellExpansionStart}env_args[@]}" "$@" -`, - ); - await chmodPath(join(fakeBin, "runuser"), 0o755); - await writeText( - join(fakeBin, "git"), - String.raw`#!/usr/bin/env bash -printf '%s\n' "$*" >> ${JSON.stringify(logPath)} -if [[ $* == *' status --porcelain' ]]; then - printf '?? untracked.txt\n' -fi -`, - ); - await chmodPath(join(fakeBin, "git"), 0o755); - - const result = spawnSync( - bashPath, - [ - "-c", - `source "$1"; PATH="$2:$PATH"; run_as_service_user() { command runuser -u "$1" -- env HOME="$2" USER="$1" LOGNAME="$1" SHELL=/bin/bash "${shellExpansionStart}@:3}"; }; require_clean_worktree ickb-bot-testnet /home/ickb /deploy`, - "bash", - updateScript, - fakeBin, - ], - { cwd: rootDir, encoding: "utf8" }, - ); - - assert.equal(result.status, 1); - assert.match(result.stderr, /local changes or untracked files/u); - assert.doesNotMatch(await readText(logPath), /pull --ff-only/u); - } finally { - await rm(dir, { force: true, recursive: true }); - } +void test("systemd update refuses untracked files before pulling", () => { + const commands: string[] = []; + const runAsServiceUser: RunAsServiceUser = (_user, _userHome, command, args) => { + commands.push([command, ...args].join(" ")); + return spawnResult("?? untracked.txt\n"); + }; + + assert.throws( + () => { + requireCleanWorktree({ + deployDir: "/deploy", + runAsServiceUser, + user: "ickb-bot-testnet", + userHome: "/home/ickb", + }); + }, + /local changes or untracked files/u, + ); + assert.doesNotMatch(commands.join("\n"), /pull --ff-only/u); }); interface UnitTextOptions { @@ -326,52 +301,27 @@ interface UnitTextOptions { staleLauncherChild?: boolean; } -async function readUpdateScript(): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test reads the fixed systemd update helper script under the repository root. - return fsReadFile(updateScript, "utf8"); -} - -async function chmodPath(filePath: string, mode: number): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test modifies files inside its own temporary fixture directory. - await fsChmod(filePath, mode); -} - -async function makeDirectory( - directory: string, - options: { recursive: true }, -): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test creates directories inside its own temporary fixture directory. - await fsMkdir(directory, options); -} - async function readText(filePath: string): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test reads files inside its own temporary fixture directory. return fsReadFile(filePath, "utf8"); } async function writeText(filePath: string, data: string): Promise { - // eslint-disable-next-line security/detect-non-literal-fs-filename -- This test writes files inside its own temporary fixture directory. await fsWriteFile(filePath, data); } -function requireLauncherUnit( - unitPath: string, - network: string, -): SpawnSyncReturns { - const deployDir = `/opt/ickb-${network}`; - return spawnSync( - bashPath, - [ - "-c", - 'source "$1"; require_launcher_unit "$2" "$3" "$4"', - "bash", - updateScript, - unitPath, - network, - deployDir, - ], - { cwd: rootDir, encoding: "utf8" }, - ); +function checkLauncherUnit(unitPath: string, network: typeof testnet): void { + requireLauncherUnit(unitPath, network, `/opt/ickb-${network}`); +} + +function spawnResult(stdout: string, status = 0): SpawnSyncReturns { + return { + output: [null, stdout, ""], + pid: 0, + signal: null, + status, + stderr: "", + stdout, + }; } function unitText({ @@ -422,7 +372,10 @@ LoadCredentialEncrypted=${credentialName}:${credential} ${execStart} RestartPreventExitStatus=2 RestartSec=60 -${limitCore ? `LimitCORE=0\n` : ""} -ReadWritePaths=${writablePath} +${limitCore ? `LimitCORE=0\n` : ""}ReadWritePaths=${writablePath} `; } + +function joinPath(...segments: string[]): string { + return path.join(...segments); +} From 18764fc70f16bcfee9e62cd42a6a366e8ad15847 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:10:05 +0000 Subject: [PATCH 7/8] fix(sdk): restore invariant checks --- packages/sdk/src/estimate/sdk_maturity.ts | 14 ++++++++++---- packages/sdk/src/withdrawal/withdrawal_best_fit.ts | 13 +++++++++---- .../src/withdrawal/withdrawal_best_fit_support.ts | 13 +++++++++---- .../sdk/src/withdrawal/withdrawal_ring_core.ts | 7 +++++-- .../sdk/src/withdrawal/withdrawal_selection.ts | 9 +++++++-- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/sdk/src/estimate/sdk_maturity.ts b/packages/sdk/src/estimate/sdk_maturity.ts index 1c499b6..fca0f15 100644 --- a/packages/sdk/src/estimate/sdk_maturity.ts +++ b/packages/sdk/src/estimate/sdk_maturity.ts @@ -107,13 +107,19 @@ function firstCkbMaturityAtOrAbove( ckbNeeded: bigint, ): bigint | undefined { const index = binarySearch(ckbMaturing.length, (n) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- binarySearch probes 0 <= n < ckbMaturing.length. - return ckbMaturing[n]!.ckbCumulative >= ckbNeeded; + const maturity = ckbMaturing[n]; + if (maturity === undefined) { + throw new Error(`Missing CKB maturity at binary-search index ${String(n)}`); + } + return maturity.ckbCumulative >= ckbNeeded; }); if (index >= ckbMaturing.length) { return undefined; } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index is checked against ckbMaturing.length above. - return ckbMaturing[index]!.maturity; + const maturity = ckbMaturing[index]; + if (maturity === undefined) { + throw new Error(`Missing CKB maturity at selected index ${String(index)}`); + } + return maturity.maturity; } diff --git a/packages/sdk/src/withdrawal/withdrawal_best_fit.ts b/packages/sdk/src/withdrawal/withdrawal_best_fit.ts index b696448..94cd0fb 100644 --- a/packages/sdk/src/withdrawal/withdrawal_best_fit.ts +++ b/packages/sdk/src/withdrawal/withdrawal_best_fit.ts @@ -122,14 +122,19 @@ function enumeratePartialSelections( ] ): void => { if (index === items.length) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- count ranges from 0 to items.length. - groups[count]!.push({ mask, total, score }); + const group = groups[count]; + if (group === undefined) { + throw new Error(`Partial selection group ${String(count)} is missing`); + } + group.push({ mask, total, score }); return; } search(index + 1, mask, count, total, score); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index is checked against items.length above. - const item = items[index]!; + const item = items[index]; + if (item === undefined) { + throw new Error(`Withdrawal item ${String(index)} is missing`); + } search( index + 1, mask | (1 << index), diff --git a/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts b/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts index 86bf3c4..f45beb6 100644 --- a/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts +++ b/packages/sdk/src/withdrawal/withdrawal_best_fit_support.ts @@ -40,8 +40,10 @@ export function findBestAtOrBelow( while (low <= high) { const mid = Math.floor((low + high) / 2); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mid is inside the current binary-search bounds. - const item = items[mid]!; + const item = items[mid]; + if (item === undefined) { + throw new Error(`Prepared selection ${String(mid)} is missing`); + } if (item.total <= limit) { best = item.selection; @@ -85,8 +87,11 @@ export function selectByMasks(items: readonly T[], mask: number): T[] { const selected: T[] = []; for (let i = 0; i < items.length; i += 1) { if ((mask & (1 << i)) !== 0) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- masks are generated from the same dense item array. - selected.push(items[i]!); + const item = items[i]; + if (item === undefined) { + throw new Error(`Selection mask referenced missing item at index ${String(i)}`); + } + selected.push(item); } } return selected; diff --git a/packages/sdk/src/withdrawal/withdrawal_ring_core.ts b/packages/sdk/src/withdrawal/withdrawal_ring_core.ts index 6404ced..d1cfc19 100644 --- a/packages/sdk/src/withdrawal/withdrawal_ring_core.ts +++ b/packages/sdk/src/withdrawal/withdrawal_ring_core.ts @@ -48,8 +48,11 @@ export function ringSegments( ): string { return ( items - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- selections are built from the indexed readyDeposits array. - .map((item) => String(indexByItem.get(item)!)) + .map((item) => { + const index = indexByItem.get(item); + if (index === undefined) { + throw new Error("Selection item is missing from the ready deposit index"); + } + return String(index); + }) .toSorted((left, right) => left.localeCompare(right)) .join(",") ); From e72286b77ae467b6c3c128d4ff1907b133f4c2ec Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:26:51 +0000 Subject: [PATCH 8/8] fix(bot): canonicalize nested artifacts --- packages/bot/src/observability/artifacts.ts | 26 +++++++------------- packages/bot/test/observability/artifacts.ts | 7 ++++-- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/bot/src/observability/artifacts.ts b/packages/bot/src/observability/artifacts.ts index ce80e34..4fbf725 100644 --- a/packages/bot/src/observability/artifacts.ts +++ b/packages/bot/src/observability/artifacts.ts @@ -85,29 +85,21 @@ function publicRingSegment({ function canonicalJson(value: unknown): string { const logged = logValue(value, new Set()); - return JSON.stringify(logged, sortedJsonKeys(logged)); + return JSON.stringify(sortJsonValue(logged)); } -function sortedJsonKeys(value: unknown): string[] { - const keys = new Set(); - collectJsonKeys(value, keys); - return [...keys].toSorted((left, right) => left.localeCompare(right)); -} - -function collectJsonKeys(value: unknown, keys: Set): void { +function sortJsonValue(value: unknown): unknown { if (Array.isArray(value)) { - for (const entry of value) { - collectJsonKeys(entry, keys); - } - return; + return value.map(sortJsonValue); } if (typeof value !== "object" || value === null) { - return; - } - for (const [key, entry] of Object.entries(value)) { - keys.add(key); - collectJsonKeys(entry, keys); + return value; } + return Object.fromEntries( + Object.entries(value) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJsonValue(entry)]), + ); } function artifactKindDirectory(kind: string): string { diff --git a/packages/bot/test/observability/artifacts.ts b/packages/bot/test/observability/artifacts.ts index f23ac31..1a43a99 100644 --- a/packages/bot/test/observability/artifacts.ts +++ b/packages/bot/test/observability/artifacts.ts @@ -50,12 +50,15 @@ describe(BOT_OBSERVABILITY_SUITE, () => { const artifactRoot = await mkdtemp(path.join(tmpdir(), artifactTempPrefix)); try { const ref = await writeArtifact(artifactRoot, artifactKind, { - ring: { rows: [[{ totalPoolUdt: 1n }]] }, + ring: { + summary: { z: 2, a: 1 }, + rows: [[{ totalPoolUdt: 1n, nested: { z: 2, a: 1 } }]], + }, }); const hash = ref.hash.slice("sha256:".length); await expect(readArtifactFile(artifactFilePath(artifactRoot, hash))).resolves.toBe( - '{"kind":"bot.ringSegments","ring":{"rows":[[{"totalPoolUdt":"1"}]]},"version":1}\n', + '{"kind":"bot.ringSegments","ring":{"rows":[[{"nested":{"a":1,"z":2},"totalPoolUdt":"1"}]],"summary":{"a":1,"z":2}},"version":1}\n', ); } finally { await rm(artifactRoot, { force: true, recursive: true });