From 23ab1d41d5a8eb49bc50c43245fe22843ca65208 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 8 Jul 2026 03:08:12 +0000 Subject: [PATCH] refactor(protocol): adopt core protocol libraries --- .npmrc | 1 - README.md | 55 +- apps/bot/README.md | 4 +- apps/interface/README.md | 6 +- apps/interface/src/transaction.test.ts | 5 - apps/interface/tsconfig.json | 4 +- apps/sampler/README.md | 2 +- apps/supervisor/README.md | 6 +- apps/supervisor/src/index.test.ts | 4 +- apps/supervisor/src/index.ts | 2 - apps/tester/README.md | 2 +- apps/tester/src/index.test.ts | 7 +- forks/.gitignore | 3 - forks/ccc/pin/HEAD | 1 - forks/ccc/pin/LOCAL_BASE | 1 - forks/ccc/pin/manifest | 5 - forks/ccc/pin/res-4.resolution | 5 - forks/config.json | 18 - package.json | 19 +- packages/core/README.md | 6 +- packages/core/api-extractor.json | 5 + packages/core/package.json | 28 +- packages/core/src/cells.test.ts | 241 -- packages/core/src/cells.ts | 200 +- packages/core/src/entities.ts | 108 +- packages/core/src/index.ts | 25 +- packages/core/src/logic.test.ts | 360 --- packages/core/src/logic.ts | 223 +- packages/core/src/owned_owner.test.ts | 877 ------ packages/core/src/owned_owner.ts | 436 +-- packages/core/src/udt.ts | 456 ++- packages/core/test/cells/cells.ts | 395 +++ .../core/test/cells/support/cells_support.ts | 116 + packages/core/test/index.ts | 11 + packages/core/test/logic/logic.ts | 284 ++ packages/core/test/logic/logic_receipts.ts | 275 ++ packages/core/test/logic/logic_validation.ts | 77 + .../core/test/logic/support/logic_support.ts | 53 + packages/core/test/owned_owner/owned_owner.ts | 153 + .../owned_owner_find_concurrency.ts | 246 ++ .../owned_owner/owned_owner_find_filters.ts | 265 ++ .../test/owned_owner/owned_owner_request.ts | 423 +++ .../owned_owner_request_anchors.ts | 72 + .../support/owned_owner_support.ts | 187 ++ packages/core/test/udt/udt_complete.ts | 335 +++ packages/core/test/udt/udt_overfund.ts | 175 ++ packages/core/tsconfig.build.json | 13 + packages/core/tsconfig.json | 8 +- packages/core/vitest.config.mts | 4 +- packages/dao/api-extractor.json | 5 + packages/dao/package.json | 25 +- packages/dao/src/cells.test.ts | 116 - packages/dao/src/cells.ts | 317 +- packages/dao/src/dao.test.ts | 704 ----- packages/dao/src/dao.ts | 435 +-- packages/dao/src/dao_output_limit.ts | 41 + packages/dao/src/index.ts | 19 +- packages/dao/src/transaction_shape.ts | 17 + packages/dao/test/cells.ts | 306 ++ packages/dao/test/dao.ts | 366 +++ .../deposit_readiness.ts} | 27 +- packages/dao/test/find_deposits.ts | 229 ++ packages/dao/test/find_withdrawal_requests.ts | 273 ++ packages/dao/test/index.ts | 11 + packages/dao/test/support/dao_support.ts | 93 + packages/dao/test/withdraw.ts | 200 ++ packages/dao/tsconfig.build.json | 13 + packages/dao/tsconfig.json | 8 +- packages/dao/vitest.config.mts | 4 +- packages/order/README.md | 12 +- packages/order/api-extractor.json | 5 + packages/order/package.json | 28 +- packages/order/src/entities.ts | 756 ----- packages/order/src/index.test.ts | 15 - packages/order/src/index.ts | 37 +- packages/order/src/io/order_io.ts | 25 + packages/order/src/io/order_scan.ts | 240 ++ packages/order/src/io/order_transaction.ts | 177 ++ packages/order/src/matching/match_types.ts | 84 + .../order/src/matching/order_conversion.ts | 211 ++ .../order/src/matching/order_match_context.ts | 127 + .../order/src/matching/order_match_search.ts | 301 ++ .../src/matching/order_match_sequence.ts | 77 + .../src/matching/order_match_uniqueness.ts | 18 + packages/order/src/matching/order_matcher.ts | 288 ++ packages/order/src/matching/order_matching.ts | 30 + packages/order/src/{ => model}/cells.ts | 378 ++- packages/order/src/model/entity_validity.ts | 8 + packages/order/src/model/info.ts | 157 + packages/order/src/model/master.ts | 44 + packages/order/src/model/order_data.ts | 100 + packages/order/src/model/ratio.ts | 169 ++ packages/order/src/model/relative.ts | 82 + packages/order/src/order.test.ts | 1897 ------------ packages/order/src/order.ts | 1220 +------- .../order/test/fixtures/order_constants.ts | 24 + packages/order/test/index.ts | 13 + .../order/test/matching/order_cell_resolve.ts | 129 + .../matching/order_manager_transaction.ts | 502 ++++ .../test/matching/order_matcher_best_match.ts | 429 +++ .../order/test/matching/order_matcher_caps.ts | 56 + .../test/matching/order_matcher_conversion.ts | 307 ++ .../matching/order_matcher_diagnostics.ts | 275 ++ .../matching/support/order_match_helpers.ts | 187 ++ .../matching/support/order_order_helpers.ts | 107 + packages/order/test/order_conversion_io.ts | 52 + packages/order/test/order_entities.ts | 313 ++ packages/order/test/order_ratio.ts | 96 + .../test/scan/order_find_orders_cache.ts | 385 +++ .../scan/order_find_orders_missing_master.ts | 43 + .../order/test/scan/order_find_orders_scan.ts | 228 ++ .../order/test/scan/order_resolve_group.ts | 97 + .../test/scan/support/order_scan_helpers.ts | 142 + packages/order/tsconfig.build.json | 13 + packages/order/tsconfig.json | 8 +- packages/order/vitest.config.mts | 4 +- packages/sdk/src/constants.test.ts | 1 - packages/sdk/src/sdk.test.ts | 29 +- packages/sdk/src/sdk.ts | 15 +- packages/utils/package.json | 4 +- packages/utils/src/codec.ts | 3 + packages/utils/src/utils.ts | 4 + packages/utils/test/codec.ts | 13 + packages/utils/test/utils.ts | 15 +- pnpm-lock.yaml | 2608 +++++------------ pnpm-workspace.yaml | 35 +- scripts/check-ccc-overrides.mjs | 98 - scripts/forks-bootstrap.mjs | 41 - scripts/forks-ccc-smoke.mjs | 45 - scripts/forks-ccc.mjs | 237 -- scripts/forks-ccc.test.mjs | 67 - scripts/ickb-bot-systemd-update.sh | 1 - scripts/ickb-live-preflight.mjs | 6 +- scripts/ickb-supervisor-dynamic-loop.test.mjs | 34 +- scripts/ickb-supervisor-loop.mjs | 3 +- scripts/ickb-supervisor-loop.test.mjs | 7 +- vitest.config.mts | 1 - 137 files changed, 12845 insertions(+), 9734 deletions(-) delete mode 100644 .npmrc delete mode 100644 forks/.gitignore delete mode 100644 forks/ccc/pin/HEAD delete mode 100644 forks/ccc/pin/LOCAL_BASE delete mode 100644 forks/ccc/pin/manifest delete mode 100644 forks/ccc/pin/res-4.resolution delete mode 100644 forks/config.json create mode 100644 packages/core/api-extractor.json delete mode 100644 packages/core/src/cells.test.ts delete mode 100644 packages/core/src/logic.test.ts delete mode 100644 packages/core/src/owned_owner.test.ts create mode 100644 packages/core/test/cells/cells.ts create mode 100644 packages/core/test/cells/support/cells_support.ts create mode 100644 packages/core/test/index.ts create mode 100644 packages/core/test/logic/logic.ts create mode 100644 packages/core/test/logic/logic_receipts.ts create mode 100644 packages/core/test/logic/logic_validation.ts create mode 100644 packages/core/test/logic/support/logic_support.ts create mode 100644 packages/core/test/owned_owner/owned_owner.ts create mode 100644 packages/core/test/owned_owner/owned_owner_find_concurrency.ts create mode 100644 packages/core/test/owned_owner/owned_owner_find_filters.ts create mode 100644 packages/core/test/owned_owner/owned_owner_request.ts create mode 100644 packages/core/test/owned_owner/owned_owner_request_anchors.ts create mode 100644 packages/core/test/owned_owner/support/owned_owner_support.ts create mode 100644 packages/core/test/udt/udt_complete.ts create mode 100644 packages/core/test/udt/udt_overfund.ts create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/dao/api-extractor.json delete mode 100644 packages/dao/src/cells.test.ts delete mode 100644 packages/dao/src/dao.test.ts create mode 100644 packages/dao/src/dao_output_limit.ts create mode 100644 packages/dao/src/transaction_shape.ts create mode 100644 packages/dao/test/cells.ts create mode 100644 packages/dao/test/dao.ts rename packages/dao/{src/deposit_readiness.test.ts => test/deposit_readiness.ts} (74%) create mode 100644 packages/dao/test/find_deposits.ts create mode 100644 packages/dao/test/find_withdrawal_requests.ts create mode 100644 packages/dao/test/index.ts create mode 100644 packages/dao/test/support/dao_support.ts create mode 100644 packages/dao/test/withdraw.ts create mode 100644 packages/dao/tsconfig.build.json create mode 100644 packages/order/api-extractor.json delete mode 100644 packages/order/src/entities.ts delete mode 100644 packages/order/src/index.test.ts create mode 100644 packages/order/src/io/order_io.ts create mode 100644 packages/order/src/io/order_scan.ts create mode 100644 packages/order/src/io/order_transaction.ts create mode 100644 packages/order/src/matching/match_types.ts create mode 100644 packages/order/src/matching/order_conversion.ts create mode 100644 packages/order/src/matching/order_match_context.ts create mode 100644 packages/order/src/matching/order_match_search.ts create mode 100644 packages/order/src/matching/order_match_sequence.ts create mode 100644 packages/order/src/matching/order_match_uniqueness.ts create mode 100644 packages/order/src/matching/order_matcher.ts create mode 100644 packages/order/src/matching/order_matching.ts rename packages/order/src/{ => model}/cells.ts (53%) create mode 100644 packages/order/src/model/entity_validity.ts create mode 100644 packages/order/src/model/info.ts create mode 100644 packages/order/src/model/master.ts create mode 100644 packages/order/src/model/order_data.ts create mode 100644 packages/order/src/model/ratio.ts create mode 100644 packages/order/src/model/relative.ts delete mode 100644 packages/order/src/order.test.ts create mode 100644 packages/order/test/fixtures/order_constants.ts create mode 100644 packages/order/test/index.ts create mode 100644 packages/order/test/matching/order_cell_resolve.ts create mode 100644 packages/order/test/matching/order_manager_transaction.ts create mode 100644 packages/order/test/matching/order_matcher_best_match.ts create mode 100644 packages/order/test/matching/order_matcher_caps.ts create mode 100644 packages/order/test/matching/order_matcher_conversion.ts create mode 100644 packages/order/test/matching/order_matcher_diagnostics.ts create mode 100644 packages/order/test/matching/support/order_match_helpers.ts create mode 100644 packages/order/test/matching/support/order_order_helpers.ts create mode 100644 packages/order/test/order_conversion_io.ts create mode 100644 packages/order/test/order_entities.ts create mode 100644 packages/order/test/order_ratio.ts create mode 100644 packages/order/test/scan/order_find_orders_cache.ts create mode 100644 packages/order/test/scan/order_find_orders_missing_master.ts create mode 100644 packages/order/test/scan/order_find_orders_scan.ts create mode 100644 packages/order/test/scan/order_resolve_group.ts create mode 100644 packages/order/test/scan/support/order_scan_helpers.ts create mode 100644 packages/order/tsconfig.build.json delete mode 100644 scripts/check-ccc-overrides.mjs delete mode 100644 scripts/forks-bootstrap.mjs delete mode 100644 scripts/forks-ccc-smoke.mjs delete mode 100644 scripts/forks-ccc.mjs delete mode 100644 scripts/forks-ccc.test.mjs diff --git a/.npmrc b/.npmrc deleted file mode 100644 index e5d2b0d..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -node-options=--disable-warning=DEP0040 diff --git a/README.md b/README.md index 91030ab..9bee59a 100644 --- a/README.md +++ b/README.md @@ -44,64 +44,17 @@ Packages: - `packages/testkit`: Private test helpers and fixtures for workspace tests. - `packages/utils`: Shared low-level utilities such as complete-scan enforcement, binary search, collection helpers, and bounded subset selection. -## Local CCC Workflow +## Dependencies -The shared CCC baseline lives in `forks/ccc/pin/` and materializes into `forks/ccc/repo/`. - -Prerequisites: `git` and `jq`. - -From a plain checkout: - -```bash -git clone git@github.com:ickb/stack.git && cd stack -pnpm forks:bootstrap -pnpm install -pnpm forks:ccc -pnpm check -``` +CCC packages are normal package dependencies resolved through `pnpm-workspace.yaml` catalog entries and `pnpm-lock.yaml`. From a plain checkout, run `pnpm install`; no local CCC fork, build step, or workspace alias is required. `pnpm check` is the validation gate. It always runs with `CI=true`. -`pnpm forks:ccc` computes the local CCC build surface from the stack's direct `@ckb-ccc/*` dependencies and their current CCC dependency closure, so it avoids rebuilding unrelated packages like `ckb-ccc`, `@ckb-ccc/connector`, `@ckb-ccc/connector-react`, and `@ckb-ccc/lumos-patches`. - -To inspect that current CCC surface without building anything: - -```bash -pnpm forks:ccc:plan -``` - -For machine-readable inspection, use `pnpm -s forks:ccc --json`. - -For active CCC work, keep built output fresh with: - -```bash -pnpm forks:ccc --watch -``` - -Watch mode keeps the ESM `dist/` output fresh for the closure's `tsc` packages, including `@ckb-ccc/spore`, and prebuilds `@ckb-ccc/did-ckb` plus `@ckb-ccc/type-id` once so `@ckb-ccc/shell` and `@ckb-ccc/ccc` keep resolving against built output. If you change either `tsdown` package, rerun `pnpm forks:ccc`. - -For quick consumer-context sanity checks after rebuilding CCC: - -```bash -pnpm forks:ccc:smoke -``` - -That smoke path verifies the current direct Stack import surface through real consumers: `@ckb-ccc/core` from `@ickb/utils`, `@ckb-ccc/udt` from `@ickb/core`, and `@ckb-ccc/ccc` from `apps/interface`. - -If you add a new direct `@ckb-ccc/*` dependency to any stack package, add the matching root override in `pnpm-workspace.yaml`. `pnpm check:ccc-overrides` enforces this. - -If you need to update or save the shared CCC baseline, use `forks/phroi_forker/repo/` directly. `forks/ccc/pin/manifest` is the source of truth for the shared upstream refs. - ## Live Testnet Supervisor -Build the local CCC fork, shared packages, live apps, and supervisor, provide ignored bounded configs, then run the supervisor from the repo root: +Provide ignored bounded configs, then run the supervisor from the repo root: ```bash -pnpm forks:ccc -pnpm --filter @ickb/utils --filter @ickb/dao --filter @ickb/core --filter @ickb/order --filter @ickb/sdk --filter @ickb/node-utils build -pnpm --filter ./apps/bot build -pnpm --filter ./apps/tester build -pnpm --filter @ickb/supervisor build pnpm live:supervisor ``` @@ -117,7 +70,7 @@ For repeated bounded invocations, keep loop-owned options before `--` and superv pnpm live:supervisor:loop --max-runs 1 -- --scenario standard-cycle --max-cycles 1 ``` -By default the loop prebuilds the local CCC fork plus bot, tester, and supervisor runtime before the first run. Use loop-owned `--skip-build` only when another wrapper has already built those artifacts. Use loop-owned `--child-timeout-seconds` to bound the outer supervisor child process when running long watches; keep it long enough for the whole supervisor invocation, including actor preflights and actor commands, so the supervisor remains alive to enforce its own `--command-timeout-seconds` process-group cleanup. +By default the loop prebuilds bot, tester, and supervisor runtime before the first run. Use loop-owned `--skip-build` only when another wrapper has already built those artifacts. Use loop-owned `--child-timeout-seconds` to bound the outer supervisor child process when running long watches; keep it long enough for the whole supervisor invocation, including actor preflights and actor commands, so the supervisor remains alive to enforce its own `--command-timeout-seconds` process-group cleanup. For continuous live matching, use the dynamic external loop. It reads only tester preflight balance summaries, chooses `all-ckb-limit-order` when `CKB.available >= 3001`, otherwise chooses `ickb-to-ckb-limit-order` with `--tester-fee 1 --tester-fee-base 1000` when `CKB.available >= 2100` and `ICKB.available >= 100`, otherwise leaves the tester scenario as `auto`, then runs bounded `scripts/ickb-supervisor-loop.mjs` chunks: diff --git a/apps/bot/README.md b/apps/bot/README.md index 0219539..564541f 100644 --- a/apps/bot/README.md +++ b/apps/bot/README.md @@ -160,12 +160,12 @@ 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-mainnet pnpm -C /opt/ickb-stack-mainnet bot:install -sudo -u ickb-bot-testnet pnpm -C /opt/ickb-stack-testnet bot:ccc -sudo -u ickb-bot-mainnet pnpm -C /opt/ickb-stack-mainnet bot:ccc 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 ``` +The install step resolves CCC through normal package dependencies from the lockfile; no separate CCC fork build is required. + 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. diff --git a/apps/interface/README.md b/apps/interface/README.md index 03d085b..08c49b1 100644 --- a/apps/interface/README.md +++ b/apps/interface/README.md @@ -16,14 +16,14 @@ git clone https://github.com/ickb/stack.git cd stack ``` -3. Install dependencies and materialize the local CCC workspace: +3. Install dependencies: ```bash -pnpm forks:bootstrap pnpm install -pnpm forks:ccc ``` +CCC packages are normal package dependencies resolved through the workspace catalog and lockfile; no local CCC fork, build step, or workspace alias is required. + 4. Start the interface dev server from the repo root: ```bash diff --git a/apps/interface/src/transaction.test.ts b/apps/interface/src/transaction.test.ts index e9bd857..86138eb 100644 --- a/apps/interface/src/transaction.test.ts +++ b/apps/interface/src/transaction.test.ts @@ -230,10 +230,6 @@ describe("buildTransactionPreview", () => { calls.push("fee"); return Promise.resolve([0, false]); }); - const daoLimit = vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockImplementation(() => { - calls.push("dao-limit"); - return Promise.resolve(false); - }); const completeTransaction = vi .fn() .mockImplementation(async (txLike) => { @@ -254,7 +250,6 @@ describe("buildTransactionPreview", () => { expect(completeTransaction).toHaveBeenCalledTimes(1); expect(completeFeeBy).not.toHaveBeenCalled(); - expect(daoLimit).not.toHaveBeenCalled(); expect(calls).toEqual(["sdk-complete"]); }); diff --git a/apps/interface/tsconfig.json b/apps/interface/tsconfig.json index d4aba32..36af31b 100644 --- a/apps/interface/tsconfig.json +++ b/apps/interface/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2024", "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, diff --git a/apps/sampler/README.md b/apps/sampler/README.md index 2b42915..cb80fac 100644 --- a/apps/sampler/README.md +++ b/apps/sampler/README.md @@ -4,7 +4,7 @@ An utility to help sampling iCKB rate across time. ## Run the sampler on mainnet -From a plain checkout, follow the root [Local CCC Workflow](../../README.md#local-ccc-workflow) first so `forks/ccc/repo` is materialized. If you are working against patched local CCC packages, rerun `pnpm forks:ccc` or keep `pnpm forks:ccc --watch` running. The app build commands below then build the runtime workspace package closure they import. +From a plain checkout, run `pnpm install`; CCC packages are normal package dependencies resolved through the workspace catalog and lockfile. The app build commands below then build the runtime workspace package closure they import. From the repo root: diff --git a/apps/supervisor/README.md b/apps/supervisor/README.md index f5fe2a4..f97885d 100644 --- a/apps/supervisor/README.md +++ b/apps/supervisor/README.md @@ -4,10 +4,10 @@ The supervisor is a deterministic operator app for funded testnet validation. It ## Run -Build the local CCC fork, shared packages, live apps, and supervisor first: +From a plain checkout, install dependencies and build shared packages, live apps, and supervisor first: ```bash -pnpm forks:ccc +pnpm install pnpm --filter @ickb/utils --filter @ickb/dao --filter @ickb/core --filter @ickb/order --filter @ickb/sdk --filter @ickb/node-utils build pnpm --filter ./apps/bot build pnpm --filter ./apps/tester build @@ -83,7 +83,7 @@ node scripts/ickb-supervisor-loop.mjs --max-runs 1 --stable-limit 2 --backoff-se Loop-owned options go before `--`; supervisor options go after `--`. If using `pnpm live:supervisor:loop`, keep loop-owned options before the first `--` so they are not passed through to the supervisor. The loop owns child run directories through `--out-root`, so do not pass supervisor `--out-dir` after `--`. The loop stops on supervisor nonzero exit, incident artifacts listed in `summary.json`, tx-creating outcomes or tx hashes for tx-creating outcomes, a new outcome after the first run, repeated no-progress signatures, or `--max-runs`. `-- --help` and `-- -h` are child help passthroughs: the delegated help is printed and the wrapper exits with the child status. -By default the loop prebuilds the local CCC fork plus bot, tester, and supervisor runtime before the first run. Use loop-owned `--skip-build` only when another wrapper has already built those artifacts. The external loop also has a loop-owned `--child-timeout-seconds` guard for the supervisor child process. Keep it long enough for the whole delegated supervisor run, including actor preflights and actor commands, not just one `--command-timeout-seconds` window. The dynamic loop defaults this guard to the supervisor-loop default so the supervisor keeps ownership of killing funded actor process groups on command timeout. +By default the loop prebuilds bot, tester, and supervisor runtime before the first run. Use loop-owned `--skip-build` only when another wrapper has already built those artifacts. The external loop also has a loop-owned `--child-timeout-seconds` guard for the supervisor child process. Keep it long enough for the whole delegated supervisor run, including actor preflights and actor commands, not just one `--command-timeout-seconds` window. The dynamic loop defaults this guard to the supervisor-loop default so the supervisor keeps ownership of killing funded actor process groups on command timeout. For continuous tester-bot matching, use `node scripts/ickb-supervisor-dynamic-loop.mjs` or `pnpm live:supervisor:dynamic-loop`. This remains outside `apps/supervisor`: it reads tester preflight balance summaries, chooses `all-ckb-limit-order` when `CKB.available >= 3001`, otherwise chooses `ickb-to-ckb-limit-order` with `--tester-fee 1 --tester-fee-base 1000` when `CKB.available >= 2100` and `ICKB.available >= 100`, otherwise leaves tester selection as `auto`, and delegates each bounded chunk to `scripts/ickb-supervisor-loop.mjs`. When `--target-outcome tester_fresh_order_skip` is passed through, supervisor auto-planning can choose `tester-fresh-skip-two-pass`; the dynamic loop itself only chooses fundable tester stimuli. The dynamic loop also treats `-- --help` and `-- -h` as child help passthroughs and exits with the delegated status. diff --git a/apps/supervisor/src/index.test.ts b/apps/supervisor/src/index.test.ts index 28c1b4a..ab7abc1 100644 --- a/apps/supervisor/src/index.test.ts +++ b/apps/supervisor/src/index.test.ts @@ -793,7 +793,7 @@ describe("supervisor CLI", () => { let createdOutputDirectory = false; await expect(supervise(args, plan, { - existsSync: (path) => !pathToString(path).endsWith("forks/ccc/repo/packages/udt/dist/index.js"), + existsSync: (path) => !pathToString(path).endsWith("packages/sdk/dist/index.js"), spawnCommand: (() => { spawned = true; return fakeChild(""); @@ -803,7 +803,7 @@ describe("supervisor CLI", () => { createdOutputDirectory = true; return Promise.resolve(undefined); }, - })).rejects.toThrow("Missing built CCC UDT: forks/ccc/repo/packages/udt/dist/index.js"); + })).rejects.toThrow("Missing built SDK package: packages/sdk/dist/index.js"); expect(spawned).toBe(false); expect(createdOutputDirectory).toBe(false); }); diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index cf0fa92..5c68fab 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -1005,8 +1005,6 @@ async function assertRealOutputDirectory(plan: SupervisorPlan, dependencies: Dep function assertBuiltRuntime(plan: SupervisorPlan, dependencies: Dependencies): void { const required = [ - ["CCC core", join(plan.rootDir, "forks/ccc/repo/packages/core/dist/index.js")], - ["CCC UDT", join(plan.rootDir, "forks/ccc/repo/packages/udt/dist/index.js")], ["utils package", join(plan.rootDir, "packages/utils/dist/index.js")], ["DAO package", join(plan.rootDir, "packages/dao/dist/index.js")], ["core package", join(plan.rootDir, "packages/core/dist/index.js")], diff --git a/apps/tester/README.md b/apps/tester/README.md index ba525f3..7595ae4 100644 --- a/apps/tester/README.md +++ b/apps/tester/README.md @@ -31,7 +31,7 @@ Current network support: ## Run -From a plain checkout, follow the root [Local CCC Workflow](../../README.md#local-ccc-workflow) first so `forks/ccc/repo` is materialized. If you are working against patched local CCC packages, rerun `pnpm forks:ccc` or keep `pnpm forks:ccc --watch` running. The app build commands below then build the runtime workspace package closure they import. +From a plain checkout, run `pnpm install`; CCC packages are normal package dependencies resolved through the workspace catalog and lockfile. The app build commands below then build the runtime workspace package closure they import. ```bash pnpm install diff --git a/apps/tester/src/index.test.ts b/apps/tester/src/index.test.ts index b15b6a3..358c2bc 100644 --- a/apps/tester/src/index.test.ts +++ b/apps/tester/src/index.test.ts @@ -376,7 +376,7 @@ describe("planTesterTransaction", () => { )).toBeUndefined(); }); - it("treats auto with capital but no actionable scenario as a nonterminal estimate skip", () => { + it("auto-selects random orders when near-reserve capital can fund actionable iCKB stimulus", () => { const liveNearReserveState = testerState({ availableCkbBalance: 229423868188n, availableIckbBalance: 147394003472899n, @@ -390,7 +390,10 @@ describe("planTesterTransaction", () => { undefined, 11845567055823n, () => 0, - )).toBeUndefined(); + )).toBe("random-order"); + }); + + it("formats auto with no actionable scenario as a nonterminal estimate skip", () => { expect(testerNoActionableAutoScenarioSkip()).toEqual({ reason: "estimated-conversion-too-small", requestedTesterScenario: "auto", diff --git a/forks/.gitignore b/forks/.gitignore deleted file mode 100644 index 3dee754..0000000 --- a/forks/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -*/repo/ -.stage/ -.lock/ diff --git a/forks/ccc/pin/HEAD b/forks/ccc/pin/HEAD deleted file mode 100644 index 8080bf0..0000000 --- a/forks/ccc/pin/HEAD +++ /dev/null @@ -1 +0,0 @@ -94a873e28d47685a6a875e40e0200a329a4f8a5e diff --git a/forks/ccc/pin/LOCAL_BASE b/forks/ccc/pin/LOCAL_BASE deleted file mode 100644 index 8080bf0..0000000 --- a/forks/ccc/pin/LOCAL_BASE +++ /dev/null @@ -1 +0,0 @@ -94a873e28d47685a6a875e40e0200a329a4f8a5e diff --git a/forks/ccc/pin/manifest b/forks/ccc/pin/manifest deleted file mode 100644 index f836c20..0000000 --- a/forks/ccc/pin/manifest +++ /dev/null @@ -1,5 +0,0 @@ -0dbd492fee83cfbb2e3a2881d6d4876f8e14769f master -0e17e8f713c678c9d417eec313271e3017ae6a68 359 -5761fe63fcb29ac810fab5e71063424692f65592 328 -6727ffe05f60e6bfb2060a565c19acb0fd0f375e releases/next -0ad2a5f6305d4964b00394bc8a6ed50136fdffa8 releases/udt diff --git a/forks/ccc/pin/res-4.resolution b/forks/ccc/pin/res-4.resolution deleted file mode 100644 index 9a70eaf..0000000 --- a/forks/ccc/pin/res-4.resolution +++ /dev/null @@ -1,5 +0,0 @@ ---- vitest.config.mts -CONFLICT ours=1 base=1 theirs=1 resolution=1 sha=944a4a3ae09aceaa620bd0783e575d8519671a30ea7db8e975d6cff49d8156d2 - projects: packages, -CONFLICT ours=1 base=1 theirs=1 resolution=1 sha=ac0f08d951dbb8bc8f2fe686d0a5ab8e6d8bb47b2e6bc0ce8d565fa4e44d5b5a - include: packages, diff --git a/forks/config.json b/forks/config.json deleted file mode 100644 index 536ede5..0000000 --- a/forks/config.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "ccc": { - "upstream": "https://github.com/ckb-devrel/ccc.git", - "fork": "git@github.com:phroi/ccc.git", - "mode": "managed", - "base_branch": "master", - "refs": [ - "359", - "328", - "releases/next", - "releases/udt" - ] - }, - "phroi_forker": { - "upstream": "https://github.com/phroi/forker.git", - "mode": "reference" - } -} diff --git a/package.json b/package.json index 289a117..bfee454 100644 --- a/package.json +++ b/package.json @@ -3,28 +3,19 @@ "type": "module", "scripts": { "build": "pnpm -r --filter !./apps/** --filter '!./forks/**' build", - "build:all": "pnpm -r --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... --filter @ckb-ccc/core... --filter @ckb-ccc/udt... install --frozen-lockfile", - "bot:ccc": "pnpm --dir forks/ccc/repo -r --filter @ckb-ccc/core... --filter @ckb-ccc/udt... run build", + "bot:install": "CI=true pnpm --filter @ickb/bot... install --frozen-lockfile", "bot:build": "pnpm --filter @ickb/bot... build", "check": "CI=true pnpm check:base", - "check:base": "pnpm clean:deep && pnpm check:ccc-overrides && pnpm forks:bootstrap && pnpm install && pnpm audit && pnpm forks:ccc && pnpm forks:ccc:smoke && pnpm madge && pnpm lint && pnpm build:all && pnpm test:ci", - "check:ccc-overrides": "node scripts/check-ccc-overrides.mjs", + "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 && pnpm test:ccc", - "test:ccc": "vitest run --root forks/ccc/repo --project @ckb-ccc/core && vitest run --root forks/ccc/repo/packages/udt -t 'infoFrom|isUdt'", + "test:ci": "vitest run && node --test scripts/*.test.mjs", "lint": "pnpm -r --filter '!./forks/**' lint", "clean": "rm -fr dist packages/*/dist apps/*/dist", - "clean:deep": "pnpm clean && rm -fr node_modules packages/*/node_modules apps/*/node_modules forks/ccc/repo/packages/*/tsconfig.tsbuildinfo", - "forks:bootstrap": "node scripts/forks-bootstrap.mjs", - "forks:ccc": "node scripts/forks-ccc.mjs", - "forks:ccc:build": "node scripts/forks-ccc.mjs", - "forks:ccc:plan": "node scripts/forks-ccc.mjs --plan", - "forks:ccc:smoke": "node scripts/forks-ccc-smoke.mjs", - "forks:ccc:watch": "node scripts/forks-ccc.mjs --watch", + "clean:deep": "pnpm clean && rm -fr node_modules packages/*/node_modules apps/*/node_modules", "live:generate-config": "node scripts/ickb-generate-config.mjs", "live:config-from-env": "node scripts/ickb-live-config-from-env.mjs", "live:preflight": "node scripts/ickb-live-preflight.mjs", diff --git a/packages/core/README.md b/packages/core/README.md index 93cc5b6..4e3a244 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -25,9 +25,13 @@ graph TD; If a caller will send the returned transaction, it still must: 1. Complete the transaction before send. -2. Prefer the shared stack path in `@ickb/sdk`: `sdk.completeTransaction(...)` or `completeIckbTransaction(...)`. +2. Prefer the shared stack path in `@ickb/sdk`: `sdk.completeTransaction(...)`. 3. Only use lower-level manual completion when the caller intentionally owns UDT completion, CCC-native fee/capacity completion, and the DAO output-limit check itself. +## Receipt Capacity + +`LogicManager.deposit(...)` sizes receipt cells for the next required phase transition. A receipt is created with `minXudtCell(actualLock) + minPlainCell(actualLock) + 1 CKB`, so a wallet that spent all other CKB can still complete the receipt into iCKB xUDT plus a plain capacity cell. + ## Epoch Semantic Versioning This repository follows [Epoch Semantic Versioning](https://antfu.me/posts/epoch-semver). In short ESV aims to provide a more nuanced and effective way to communicate software changes, allowing for better user understanding and smoother upgrades. diff --git a/packages/core/api-extractor.json b/packages/core/api-extractor.json new file mode 100644 index 0000000..d842b4d --- /dev/null +++ b/packages/core/api-extractor.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../api-extractor.base.json", + "mainEntryPointFilePath": "/dist/index.d.ts" +} diff --git a/packages/core/package.json b/packages/core/package.json index 0287718..28a50c6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,36 +13,43 @@ "homepage": "https://ickb.org", "repository": { "type": "git", - "url": "https://github.com/ickb/stack" + "url": "git+https://github.com/ickb/stack.git" }, "bugs": { "url": "https://github.com/ickb/stack/issues" }, "sideEffects": false, "type": "module", - "main": "dist/index.js", + "engines": { + "node": ">=22.19.0" + }, + "main": "src/index.ts", "types": "src/index.ts", "exports": { ".": { + "api-extractor": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, "types": "./src/index.ts", - "import": "./dist/index.js" + "import": "./src/index.ts" } }, "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "tsc", - "lint": "eslint ./src", - "clean": "rm -fr dist", - "clean:deep": "rm -fr dist node_modules" + "build": "rm -fr dist && tsgo -p tsconfig.build.json && node ../../scripts/tooling/build/rewrite-dts-imports.ts dist", + "lint:api": "api-extractor run --local", + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/core/src packages/core/test", + "clean": "rm -fr dist" }, "files": [ - "dist", - "src" + "dist" ], "publishConfig": { "access": "public", "provenance": true, + "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { @@ -55,8 +62,7 @@ "@ckb-ccc/core": "catalog:", "@ckb-ccc/udt": "catalog:", "@ickb/dao": "workspace:*", - "@ickb/utils": "workspace:*", - "tslib": "^2.8.1" + "@ickb/utils": "workspace:*" }, "devDependencies": { "@ickb/testkit": "workspace:*" diff --git a/packages/core/src/cells.test.ts b/packages/core/src/cells.test.ts deleted file mode 100644 index 528777d..0000000 --- a/packages/core/src/cells.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, headerLike as testHeaderLike, script } from "@ickb/testkit"; -import { describe, expect, it, vi } from "vitest"; -import { DaoManager } from "@ickb/dao"; -import { receiptCellFrom } from "./cells.js"; -import { ReceiptData } from "./entities.js"; -import { IckbUdt, ickbValue } from "./udt.js"; - -function headerLike(ar: bigint): ccc.ClientBlockHeader { - return testHeaderLike({ - dao: { c: 0n, ar, s: 0n, u: 0n }, - epoch: [1n, 0n, 1n], - number: 1n, - }); -} - -function clientWithHeader(header: ccc.ClientBlockHeader): ccc.Client { - return { - getTransactionWithHeader: () => Promise.resolve({ header }), - } as unknown as ccc.Client; -} - -function receiptOutputData( - depositQuantity: number, - depositAmount: ccc.FixedPoint, -): ccc.Hex { - return ccc.hexFrom([ - ...ReceiptData.from({ depositQuantity, depositAmount }).toBytes(), - 0xab, - 0xcd, - ]); -} - -function receiptCell(outputData: ccc.Hex, logic: ccc.Script): ccc.Cell { - return ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("11"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: script("22"), - type: logic, - }, - outputData, - }); -} - -describe("receipt prefix decoding", () => { - it("lets receiptCellFrom ignore trailing bytes", async () => { - const logic = script("33"); - const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); - const outputData = receiptOutputData(2, ccc.fixedPointFrom(100000)); - - const receipt = await receiptCellFrom({ - cell: receiptCell(outputData, logic), - client: clientWithHeader(header), - }); - - expect(receipt.ckbValue).toBe(ccc.fixedPointFrom(100082)); - expect(receipt.udtValue).toBe(ccc.fixedPointFrom(200000)); - }); - - it("lets IckbUdt.infoFrom value receipt prefixes with trailing bytes", async () => { - const logic = script("33"); - const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); - const outputData = receiptOutputData(3, ccc.fixedPointFrom(100000)); - const cell = receiptCell(outputData, logic); - const ickbUdt = new IckbUdt( - { txHash: byte32FromByte("44"), index: 0n }, - script("55"), - { txHash: byte32FromByte("66"), index: 0n }, - logic, - new DaoManager(script("77"), []), - ); - - const info = await ickbUdt.infoFrom(clientWithHeader(header), cell); - - expect(info.balance).toBe(ickbValue(ccc.fixedPointFrom(100000), header) * 3n); - expect(info.capacity).toBe(ccc.fixedPointFrom(100082)); - expect(info.count).toBe(1); - }); - - it("subtracts deposit value from UDT balance info", async () => { - const logic = script("33"); - const dao = script("44"); - const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); - const cell = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("88"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: logic, - type: dao, - }, - outputData: "0x0000000000000000", - }); - const ickbUdt = new IckbUdt( - { txHash: byte32FromByte("44"), index: 0n }, - script("55"), - { txHash: byte32FromByte("66"), index: 0n }, - logic, - new DaoManager(dao, []), - ); - - const info = await ickbUdt.infoFrom(clientWithHeader(header), cell); - - expect(info.balance).toBe(-ickbValue(cell.capacityFree, header)); - expect(info.capacity).toBe(ccc.fixedPointFrom(100082)); - expect(info.count).toBe(1); - }); - - it("fetches receipt and deposit headers concurrently", async () => { - const logic = script("33"); - const dao = script("44"); - const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); - const receipt = receiptCell( - receiptOutputData(2, ccc.fixedPointFrom(100000)), - logic, - ); - const deposit = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("88"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: logic, - type: dao, - }, - outputData: "0x0000000000000000", - }); - const ickbUdt = new IckbUdt( - { txHash: byte32FromByte("44"), index: 0n }, - script("55"), - { txHash: byte32FromByte("66"), index: 0n }, - logic, - new DaoManager(dao, []), - ); - let resolveReceipt!: (res: { header: ccc.ClientBlockHeader }) => void; - let resolveDeposit!: (res: { header: ccc.ClientBlockHeader }) => void; - const receiptFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveReceipt = resolve; - }); - const depositFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveDeposit = resolve; - }); - const requests: ccc.Hex[] = []; - const client = { - getTransactionWithHeader: async (txHash: ccc.Hex) => { - requests.push(txHash); - return txHash === receipt.outPoint.txHash ? receiptFetch : depositFetch; - }, - } as unknown as ccc.Client; - - const infoPromise = ickbUdt.infoFrom(client, [receipt, deposit]); - - await vi.waitFor(() => { - expect(requests).toEqual([ - receipt.outPoint.txHash, - deposit.outPoint.txHash, - ]); - }); - resolveDeposit({ header }); - await Promise.resolve(); - resolveReceipt({ header }); - - const info = await infoPromise; - - expect(info.balance).toBe( - ickbValue(ccc.fixedPointFrom(100000), header) * 2n - - ickbValue(deposit.capacityFree, header), - ); - expect(info.capacity).toBe( - receipt.cellOutput.capacity + deposit.cellOutput.capacity, - ); - expect(info.count).toBe(2); - }); - - it("deduplicates repeated transaction header lookups", async () => { - const logic = script("33"); - const dao = script("44"); - const txHash = byte32FromByte("88"); - const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); - const receipt = ccc.Cell.from({ - outPoint: { txHash, index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: script("22"), - type: logic, - }, - outputData: receiptOutputData(2, ccc.fixedPointFrom(100000)), - }); - const deposit = ccc.Cell.from({ - outPoint: { txHash, index: 1n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: logic, - type: dao, - }, - outputData: "0x0000000000000000", - }); - const ickbUdt = new IckbUdt( - { txHash: byte32FromByte("44"), index: 0n }, - script("55"), - { txHash: byte32FromByte("66"), index: 0n }, - logic, - new DaoManager(dao, []), - ); - let calls = 0; - const client = { - getTransactionWithHeader: async () => { - calls += 1; - await Promise.resolve(); - return { header }; - }, - } as unknown as ccc.Client; - - const info = await ickbUdt.infoFrom(client, [receipt, deposit]); - - expect(calls).toBe(1); - expect(info.balance).toBe( - ickbValue(ccc.fixedPointFrom(100000), header) * 2n - - ickbValue(deposit.capacityFree, header), - ); - }); - - it("adds xUDT and logic code deps explicitly", () => { - const logic = script("33"); - const xudtCode = { txHash: byte32FromByte("44"), index: 1n }; - const logicCode = { txHash: byte32FromByte("66"), index: 2n }; - const ickbUdt = new IckbUdt( - xudtCode, - script("55"), - logicCode, - logic, - new DaoManager(script("77"), []), - ); - - const tx = ickbUdt.addCellDeps(ccc.Transaction.default()); - - expect(tx.cellDeps).toHaveLength(2); - expect(tx.cellDeps[0]?.depType).toBe("code"); - expect(tx.cellDeps[0]?.outPoint.eq(ccc.OutPoint.from(xudtCode))).toBe(true); - expect(tx.cellDeps[1]?.depType).toBe("code"); - expect(tx.cellDeps[1]?.outPoint.eq(ccc.OutPoint.from(logicCode))).toBe(true); - }); -}); diff --git a/packages/core/src/cells.ts b/packages/core/src/cells.ts index 5429fa6..3626181 100644 --- a/packages/core/src/cells.ts +++ b/packages/core/src/cells.ts @@ -1,9 +1,17 @@ import { ccc } from "@ckb-ccc/core"; -import { type TransactionHeader, type ValueComponents } from "@ickb/utils"; -import { OwnerData, ReceiptData } from "./entities.js"; -import { ickbValue } from "./udt.js"; import type { DaoDepositCell, DaoWithdrawalRequestCell } from "@ickb/dao"; +import type { TransactionHeader, ValueComponents } from "@ickb/utils"; +import { OwnerData, ReceiptData } from "./entities.ts"; +import { ickbValue } from "./udt.ts"; +// Symbol marker keeps the runtime tag off the public shape except by this module. +const isIckbDepositSymbol = Symbol("isIckbDeposit"); + +/** + * Represents a DAO deposit cell with its iCKB value computed from the deposit header. + * + * @public + */ export interface IckbDepositCell extends DaoDepositCell { /** * A symbol property indicating that this cell is a Ickb Deposit Cell. @@ -12,10 +20,22 @@ export interface IckbDepositCell extends DaoDepositCell { [isIckbDepositSymbol]: true; } -// Symbol to represent the isIckbDeposit property of Ickb Deposit Cells -const isIckbDepositSymbol = Symbol("isIckbDeposit"); +/** + * Converts a DAO deposit cell into an iCKB deposit cell. + * + * @public + */ +export function ickbDepositCellFrom( + daoCell: DaoDepositCell, + logicScript: ccc.ScriptLike, +): IckbDepositCell { + const expectedLock = ccc.Script.from(logicScript); + if (!daoCell.cell.cellOutput.lock.eq(expectedLock)) { + throw new Error( + `DAO deposit ${daoCell.cell.outPoint.toHex()} lock does not match iCKB logic script`, + ); + } -export function ickbDepositCellFrom(daoCell: DaoDepositCell): IckbDepositCell { return { ...daoCell, udtValue: ickbValue(daoCell.cell.capacityFree, daoCell.headers[0].header), @@ -25,6 +45,8 @@ export function ickbDepositCellFrom(daoCell: DaoDepositCell): IckbDepositCell { /** * Represents a receipt cell containing the receipt for iCKB Deposits. + * + * @public */ export interface ReceiptCell extends ValueComponents { /** The cell associated with the receipt. */ @@ -34,19 +56,17 @@ export interface ReceiptCell extends ValueComponents { header: TransactionHeader; } -type TransactionWithHeader = Awaited< - ReturnType ->; +type TransactionWithHeader = Awaited>; -type ReceiptCellFromCache = { +interface ReceiptCellFromCache { + /** Reuses transaction-with-header reads across receipt conversions in one scan. */ transactionCache?: Map>; -}; +} /** - * Creates a ReceiptCell instance from the provided options. - * @param options - Options for creating a ReceiptCell. - * @returns A promise that resolves to a ReceiptCell instance. - * @throws ReceiptCellError if the cell is not found. + * Loads and decodes an iCKB receipt cell from a cell or out point. + * + * @remarks `transactionCache` is scoped to one coherent read batch; it does not refresh transaction headers. */ export async function receiptCellFrom( options: @@ -59,31 +79,48 @@ export async function receiptCellFrom( client: ccc.Client; } & ReceiptCellFromCache), ): Promise { - const cell = - "cell" in options - ? options.cell - : await options.client.getCell(options.outpoint); - if (!cell) { - throw new Error("Cell not found"); + let cell: ccc.Cell; + if ("cell" in options) { + cell = options.cell; + } else { + let loadedCell: ccc.Cell | undefined; + try { + loadedCell = await options.client.getCell(options.outpoint); + } catch (error) { + throw new Error(`Failed to load cell for out point ${options.outpoint.toHex()}`, { + cause: error, + }); + } + if (loadedCell === undefined) { + throw new Error(`Cell not found for out point ${options.outpoint.toHex()}`); + } + cell = loadedCell; } const txHash = cell.outPoint.txHash; let txWithHeaderPromise = options.transactionCache?.get(txHash); - if (!txWithHeaderPromise) { - txWithHeaderPromise = options.client.getTransactionWithHeader(txHash); + if (txWithHeaderPromise === undefined) { + txWithHeaderPromise = getReceiptTransactionWithHeader(options.client, cell.outPoint); options.transactionCache?.set(txHash, txWithHeaderPromise); } const txWithHeader = await txWithHeaderPromise; - if (!txWithHeader?.header) { - throw new Error("Header not found for txHash"); + if (txWithHeader?.header === undefined) { + throw new Error(`Header not found for txHash ${txHash} at ${cell.outPoint.toHex()}`); } const header: TransactionHeader = { header: txWithHeader.header, txHash, }; - const { depositQuantity, depositAmount } = ReceiptData.decodePrefix( - cell.outputData, - ); + let receipt: ReturnType; + try { + receipt = ReceiptData.decodePrefix(cell.outputData); + } catch (error) { + throw new Error( + `Invalid iCKB receipt payload at ${cell.outPoint.toHex()}: ${cell.outputData}`, + { cause: error }, + ); + } + const { depositQuantity, depositAmount } = receipt; return { cell, @@ -93,77 +130,106 @@ export async function receiptCellFrom( }; } +async function getReceiptTransactionWithHeader( + client: ccc.Client, + outPoint: ccc.OutPoint, +): Promise { + try { + return await client.getTransactionWithHeader(outPoint.txHash); + } catch (error) { + throw new Error( + `Failed to load transaction header for txHash ${outPoint.txHash} at ${outPoint.toHex()}`, + { cause: error }, + ); + } +} + /** - * Represents a WithdrawalGroup + * Pairs an owned DAO withdrawal request with the owner marker cell that points to it. * - * @property owned - The DAO withdrawal request cell associated with the group. - * @property owner - The owner cell associated with the group. + * @public */ export class WithdrawalGroup implements ValueComponents { - constructor( - public owned: DaoWithdrawalRequestCell, - public owner: OwnerCell, - ) {} + /** The decoded DAO withdrawal request controlled by this owner marker. */ + public owned: DaoWithdrawalRequestCell; + + /** The owner marker cell that references the owned withdrawal request. */ + public owner: OwnerCell; + + /** Creates a withdrawal group from a decoded request and its owner marker. */ + constructor(owned: DaoWithdrawalRequestCell, owner: OwnerCell) { + this.owned = owned; + this.owner = owner; + } /** - * Gets the CKB value of the group. - * - * @returns The total CKB amount as a `ccc.FixedPoint`, which is the sum of the CKB values of the owned cell and the owner cell. + * Returns the total CKB capacity in the owned withdrawal and owner marker cells. */ - get ckbValue(): ccc.FixedPoint { + public get ckbValue(): ccc.FixedPoint { return this.owned.ckbValue + this.owner.cell.cellOutput.capacity; } /** - * Gets the UDT value of the group. - * - * @returns The iCKB amount represented by the owned withdrawal request. + * Returns the iCKB amount represented by the owned withdrawal request. */ - get udtValue(): ccc.FixedPoint { + public get udtValue(): ccc.FixedPoint { return ickbValue(this.owned.cell.capacityFree, this.owned.headers[0].header); } } /** - * Represents a cell that contains ownership information. + * Wraps an owner marker cell that references an owned withdrawal request. + * + * @public */ export class OwnerCell implements ValueComponents { + /** The live owner marker cell whose output data points to the owned request. */ + public cell: ccc.Cell; + /** - * Creates an instance of OwnerCell. - * - * @param cell - The cell associated with the owner. + * Owner marker cells carry no UDT value. */ - constructor(public cell: ccc.Cell) {} + public readonly udtValue = 0n; /** - * Gets the CKB value of the cell. - * - * @returns The CKB amount as a `ccc.FixedPoint` taken from the cell's capacity. + * Creates an owner marker wrapper for a live cell. */ - get ckbValue(): ccc.FixedPoint { - return this.cell.cellOutput.capacity; + constructor(cell: ccc.Cell) { + this.cell = cell; } /** - * Gets the UDT value of the cell. - * - * For an OwnerCell, the UDT amount is always zero. - * - * @returns The UDT amount as a `ccc.FixedPoint` (0n). + * Returns the CKB capacity held by the owner marker cell. */ - readonly udtValue = 0n; + public get ckbValue(): ccc.FixedPoint { + return this.cell.cellOutput.capacity; + } /** - * Retrieves the out point of the owned cell based on the owner's distance. - * - * Decodes the prefix of the cell's output data to determine the distance from the owner - * and then calculates the new index for the out point. + * Returns the owned withdrawal request out point referenced by this owner marker. * - * @returns The out point of the owned cell as a `ccc.OutPoint`. + * @remarks + * Owner data stores a signed output-index distance. The owned cell is resolved + * on the same transaction hash as the owner marker cell. */ - getOwned(): ccc.OutPoint { + public getOwned(): ccc.OutPoint { const { txHash, index } = this.cell.outPoint; - const { ownedDistance } = OwnerData.decodePrefix(this.cell.outputData); - return new ccc.OutPoint(txHash, index + ownedDistance); + let ownerData: ReturnType; + try { + ownerData = OwnerData.decodePrefix(this.cell.outputData); + } catch (error) { + throw new Error( + `Invalid owner marker payload at ${this.cell.outPoint.toHex()}: ${this.cell.outputData}`, + { cause: error }, + ); + } + const { ownedDistance } = ownerData; + const ownedIndex = index + ownedDistance; + if (ownedIndex < 0n) { + throw new Error( + `Owner marker ${this.cell.outPoint.toHex()} points before output 0 with distance ${String(ownedDistance)}`, + ); + } + return new ccc.OutPoint(txHash, ownedIndex); } } diff --git a/packages/core/src/entities.ts b/packages/core/src/entities.ts index b2e68cf..f378c4d 100644 --- a/packages/core/src/entities.ts +++ b/packages/core/src/entities.ts @@ -3,6 +3,8 @@ import { CheckedInt32LE } from "@ickb/utils"; /** * Represents a permissive data structure of the owner data of the owned owner script. + * + * @public */ export interface OwnerDataLike { /** The signed distance between owner and owned cell in the mint transaction. */ @@ -12,21 +14,40 @@ export interface OwnerDataLike { /** * Represents the data structure to encode the owner data of the owned owner script. * - * @extends ccc.Entity.Base + * Backed by `ccc.Entity.Base`. + */ +const OwnerDataCodec = mol.struct({ + ownedDistance: CheckedInt32LE, +}); + +/** + * Base CCC entity class used to encode and decode owned-owner payloads. + * + * @public */ -@ccc.codec( - mol.struct({ - ownedDistance: CheckedInt32LE, - }), -) -export class OwnerData extends ccc.Entity.Base() { +export const OwnerBase = ccc.Entity.Base(); + +/** + * Encodes the owned-owner marker data that links an owner cell to its owned cell. + * + * @public + */ +export class OwnerData extends OwnerBase { + static { + ccc.codec(OwnerDataCodec)(this); + } + + /** Signed output-index distance from the owner marker to the owned cell. */ + public ownedDistance: ccc.Num; + /** * Creates an instance of OwnerData. * * @param ownedDistance - The signed distance between owner and owned cell in the mint transaction. */ - constructor(public ownedDistance: ccc.Num) { + constructor(ownedDistance: ccc.Num) { super(); + this.ownedDistance = ownedDistance; } /** @@ -35,7 +56,7 @@ export class OwnerData extends ccc.Entity.Base() { * @param data - The data to create the OwnerData instance from. * @returns An instance of OwnerData. */ - static override from(data: OwnerDataLike): OwnerData { + public static override from(data: OwnerDataLike): OwnerData { if (data instanceof OwnerData) { return data; } @@ -45,18 +66,21 @@ export class OwnerData extends ccc.Entity.Base() { } /** - * Decodes a prefix from the encoded data. + * Decodes the fixed owner-data prefix and ignores trailing cell payload bytes. * - * @param encoded - The encoded data to decode. - * @returns An instance of OwnerData. + * @remarks The owner data prefix is 4 bytes after the `0x` marker: a signed + * little-endian relative output-index distance to the owned cell. Later bytes + * belong to other protocol data and are intentionally tolerated here. */ - static decodePrefix(encoded: ccc.Hex): OwnerData { + public static decodePrefix(encoded: ccc.Hex): OwnerData { return OwnerData.decode(encoded.slice(0, 10)); } } /** * Represents a permissive data structure of the data structure for a receipt. + * + * @public */ export interface ReceiptDataLike { /** The quantity of deposits. */ @@ -68,29 +92,46 @@ export interface ReceiptDataLike { /** * Represents receipt data containing deposit information. * - * @extends ccc.Entity.Base + * Backed by `ccc.Entity.Base`. */ -@ccc.codec( - mol.struct({ - depositQuantity: mol.Uint32, - depositAmount: mol.Uint64, - }), -) -export class ReceiptData extends ccc.Entity.Base< - ReceiptDataLike, - ReceiptData ->() { +const ReceiptDataCodec = mol.struct({ + depositQuantity: mol.Uint32, + depositAmount: mol.Uint64, +}); + +/** + * Base CCC entity class used to encode and decode iCKB receipt payloads. + * + * @public + */ +export const ReceiptBase = ccc.Entity.Base(); + +/** + * Encodes the receipt payload for one or more identical iCKB deposits. + * + * @public + */ +export class ReceiptData extends ReceiptBase { + static { + ccc.codec(ReceiptDataCodec)(this); + } + + /** Number of identical deposits represented by this receipt. */ + public depositQuantity: ccc.Num; + + /** Free CKB capacity of each represented deposit before iCKB conversion. */ + public depositAmount: ccc.FixedPoint; + /** * Creates an instance of ReceiptData. * * @param depositQuantity - The quantity of deposits. * @param depositAmount - The unoccupied capacity of each tracked deposit. */ - constructor( - public depositQuantity: ccc.Num, - public depositAmount: ccc.FixedPoint, - ) { + constructor(depositQuantity: ccc.Num, depositAmount: ccc.FixedPoint) { super(); + this.depositQuantity = depositQuantity; + this.depositAmount = depositAmount; } /** @@ -99,7 +140,7 @@ export class ReceiptData extends ccc.Entity.Base< * @param data - The data to create the ReceiptData instance from. * @returns An instance of ReceiptData. */ - static override from(data: ReceiptDataLike): ReceiptData { + public static override from(data: ReceiptDataLike): ReceiptData { if (data instanceof ReceiptData) { return data; } @@ -111,7 +152,14 @@ export class ReceiptData extends ccc.Entity.Base< ); } - static decodePrefix(encoded: ccc.Hex): ReceiptData { + /** + * Decodes the fixed receipt-data prefix and ignores trailing cell payload bytes. + * + * @remarks The receipt data prefix is 12 bytes after the `0x` marker: 4 bytes + * for deposit quantity and 8 bytes for deposit amount. Later payload bytes + * belong to other protocol data and are intentionally tolerated here. + */ + public static decodePrefix(encoded: ccc.Hex): ReceiptData { return ReceiptData.decode(encoded.slice(0, 26)); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d5dafa..ea2a1e5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,11 +1,24 @@ +/** + * Core iCKB cells, scripts, and UDT completion helpers. + * + * @packageDocumentation + */ + export { OwnerCell, - receiptCellFrom, WithdrawalGroup, + ickbDepositCellFrom, type IckbDepositCell, type ReceiptCell, -} from "./cells.js"; -export * from "./entities.js"; -export * from "./logic.js"; -export * from "./owned_owner.js"; -export * from "./udt.js"; +} from "./cells.ts"; +export { + OwnerBase, + OwnerData, + ReceiptBase, + ReceiptData, + type OwnerDataLike, + type ReceiptDataLike, +} from "./entities.ts"; +export { LogicManager, receiptPhase2Capacity } from "./logic.ts"; +export { OwnedOwnerManager } from "./owned_owner.ts"; +export { ICKB_DEPOSIT_CAP, IckbUdt, convert, ickbExchangeRatio } from "./udt.ts"; diff --git a/packages/core/src/logic.test.ts b/packages/core/src/logic.test.ts deleted file mode 100644 index 3d831d6..0000000 --- a/packages/core/src/logic.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, headerLike, script } from "@ickb/testkit"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { DaoManager } from "@ickb/dao"; -import { collect } from "@ickb/utils"; -import { ReceiptData } from "./entities.js"; -import { LogicManager } from "./logic.js"; - -describe("LogicManager.deposit", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("encodes receipt amounts from deposit free capacity", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const logic = script("11"); - const dao = script("22"); - const user = script("33"); - const manager = new LogicManager(logic, [], new DaoManager(dao, [])); - - const tx = await manager.deposit( - ccc.Transaction.default(), - 2, - ccc.fixedPointFrom(100082), - user, - {} as ccc.Client, - ); - - expect(tx.outputs).toHaveLength(3); - expect(tx.outputs[0]?.capacity).toBe(ccc.fixedPointFrom(100082)); - expect(tx.outputs[1]?.capacity).toBe(ccc.fixedPointFrom(100082)); - - const receiptData = tx.outputsData[2]; - if (!receiptData) { - throw new Error("Expected receipt output data"); - } - - const receipt = ReceiptData.decode(receiptData); - expect(receipt.depositQuantity).toBe(2n); - expect(receipt.depositAmount).toBe(ccc.fixedPointFrom(100000)); - }); - - it("keeps the protocol minimum on unoccupied capacity", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); - - await expect( - manager.deposit( - ccc.Transaction.default(), - 1, - ccc.fixedPointFrom(1081), - script("33"), - {} as ccc.Client, - ), - ).rejects.toThrow( - "iCKB deposit minimum is 1000 CKB free capacity (1082 CKB total capacity)", - ); - }); - - it("keeps the protocol maximum on unoccupied capacity", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); - - await expect( - manager.deposit( - ccc.Transaction.default(), - 1, - ccc.fixedPointFrom(1000083), - script("33"), - {} as ccc.Client, - ), - ).rejects.toThrow( - "iCKB deposit maximum is 1000000 CKB free capacity (1000082 CKB total capacity)", - ); - }); - - it("rejects non-safe-integer deposit quantities before allocation", async () => { - const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); - - for (const quantity of [1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) { - await expect( - manager.deposit( - ccc.Transaction.default(), - quantity, - ccc.fixedPointFrom(1082), - script("33"), - {} as ccc.Client, - ), - ).rejects.toThrow("iCKB deposit quantity must be a safe integer"); - } - }); - - it("rejects deposit quantities that cannot fit in one DAO transaction", async () => { - const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); - - await expect( - manager.deposit( - ccc.Transaction.default(), - 64, - ccc.fixedPointFrom(1082), - script("33"), - {} as ccc.Client, - ), - ).rejects.toThrow("iCKB deposit quantity maximum is 63"); - }); - - it("filters receipts by exact lock and type while deduplicating locks", async () => { - const logic = script("11"); - const wantedLock = script("22"); - const otherLock = script("33"); - const receiptData = ReceiptData.from({ - depositQuantity: 1, - depositAmount: ccc.fixedPointFrom(100000), - }).toBytes(); - const validReceipt = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: wantedLock, - type: logic, - }, - outputData: receiptData, - }); - const wrongLock = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: otherLock, - type: logic, - }, - outputData: receiptData, - }); - const wrongType = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("66"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: wantedLock, - type: script("77"), - }, - outputData: receiptData, - }); - const header = ccc.ClientBlockHeader.from({ - compactTarget: 0n, - dao: { c: 0n, ar: 10000000000000000n, s: 0n, u: 0n }, - epoch: [1n, 0n, 1n], - extraHash: byte32FromByte("aa"), - hash: byte32FromByte("bb"), - nonce: 0n, - number: 1n, - parentHash: byte32FromByte("cc"), - proposalsHash: byte32FromByte("dd"), - timestamp: 0n, - transactionsRoot: byte32FromByte("ee"), - version: 0n, - }); - let calls = 0; - const client = { - findCells: async function* () { - await Promise.resolve(); - calls += 1; - yield validReceipt; - yield wrongLock; - yield wrongType; - }, - getTransactionWithHeader: async () => { - await Promise.resolve(); - return { header }; - }, - } as unknown as ccc.Client; - const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); - - const receipts = await collect( - manager.findReceipts(client, [wantedLock, wantedLock]), - ); - - expect(calls).toBe(1); - expect(receipts).toHaveLength(1); - expect(receipts[0]?.cell.outPoint.txHash).toBe(byte32FromByte("44")); - }); - - it("fetches receipt headers concurrently and yields scan order", async () => { - const logic = script("11"); - const wantedLock = script("22"); - const receiptData = ReceiptData.from({ - depositQuantity: 1, - depositAmount: ccc.fixedPointFrom(100000), - }).toBytes(); - const firstReceipt = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: wantedLock, - type: logic, - }, - outputData: receiptData, - }); - const secondReceipt = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: wantedLock, - type: logic, - }, - outputData: receiptData, - }); - const header = ccc.ClientBlockHeader.from({ - compactTarget: 0n, - dao: { c: 0n, ar: 10000000000000000n, s: 0n, u: 0n }, - epoch: [1n, 0n, 1n], - extraHash: byte32FromByte("aa"), - hash: byte32FromByte("bb"), - nonce: 0n, - number: 1n, - parentHash: byte32FromByte("cc"), - proposalsHash: byte32FromByte("dd"), - timestamp: 0n, - transactionsRoot: byte32FromByte("ee"), - version: 0n, - }); - let resolveFirst!: (res: { header: ccc.ClientBlockHeader }) => void; - let resolveSecond!: (res: { header: ccc.ClientBlockHeader }) => void; - const firstFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveFirst = resolve; - }); - const secondFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveSecond = resolve; - }); - const requests: ccc.Hex[] = []; - const client = { - findCells: async function* () { - await Promise.resolve(); - yield firstReceipt; - yield secondReceipt; - }, - getTransactionWithHeader: async (txHash: ccc.Hex) => { - requests.push(txHash); - return txHash === firstReceipt.outPoint.txHash ? firstFetch : secondFetch; - }, - } as unknown as ccc.Client; - const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); - - const receiptsPromise = collect(manager.findReceipts(client, [wantedLock])); - - await vi.waitFor(() => { - expect(requests).toEqual([ - firstReceipt.outPoint.txHash, - secondReceipt.outPoint.txHash, - ]); - }); - resolveSecond({ header }); - await Promise.resolve(); - resolveFirst({ header }); - - const receipts = await receiptsPromise; - - expect(receipts.map((receipt) => receipt.cell.outPoint.txHash)).toEqual([ - firstReceipt.outPoint.txHash, - secondReceipt.outPoint.txHash, - ]); - }); - - it("passes the cell page size to receipt scanning", async () => { - const logic = script("11"); - const wantedLock = script("22"); - const receiptData = ReceiptData.from({ - depositQuantity: 1, - depositAmount: ccc.fixedPointFrom(100000), - }).toBytes(); - const firstReceipt = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: wantedLock, - type: logic, - }, - outputData: receiptData, - }); - const secondReceipt = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: wantedLock, - type: logic, - }, - outputData: receiptData, - }); - let requestedPageSize = 0; - const client = { - findCells: async function* (_query: unknown, _order: unknown, pageSize: number) { - requestedPageSize = pageSize; - await Promise.resolve(); - yield firstReceipt; - yield secondReceipt; - }, - getTransactionWithHeader: async () => { - await Promise.resolve(); - return { header: headerLike() }; - }, - } as unknown as ccc.Client; - const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); - - const receipts = await collect(manager.findReceipts(client, [wantedLock], { pageSize: 1 })); - - expect(requestedPageSize).toBe(1); - expect(receipts.map((receipt) => receipt.cell.outPoint.txHash)).toEqual([ - firstReceipt.outPoint.txHash, - secondReceipt.outPoint.txHash, - ]); - }); - - it("reuses receipt transaction header requests across lock scans", async () => { - const logic = script("11"); - const firstLock = script("22"); - const secondLock = script("33"); - const txHash = byte32FromByte("44"); - const receiptData = ReceiptData.from({ - depositQuantity: 1, - depositAmount: ccc.fixedPointFrom(100000), - }).toBytes(); - const firstReceipt = ccc.Cell.from({ - outPoint: { txHash, index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: firstLock, - type: logic, - }, - outputData: receiptData, - }); - const secondReceipt = ccc.Cell.from({ - outPoint: { txHash, index: 1n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: secondLock, - type: logic, - }, - outputData: receiptData, - }); - let transactionCalls = 0; - const client = { - findCells: async function* (query: { script: ccc.Script }) { - await Promise.resolve(); - yield query.script.eq(firstLock) ? firstReceipt : secondReceipt; - }, - getTransactionWithHeader: async () => { - transactionCalls += 1; - await Promise.resolve(); - return { header: headerLike() }; - }, - } as unknown as ccc.Client; - const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); - - const receipts = await collect(manager.findReceipts(client, [firstLock, secondLock])); - - expect(transactionCalls).toBe(1); - expect(receipts.map((receipt) => receipt.cell.outPoint.index)).toEqual([0n, 1n]); - }); -}); diff --git a/packages/core/src/logic.ts b/packages/core/src/logic.ts index 14b4a29..b810323 100644 --- a/packages/core/src/logic.ts +++ b/packages/core/src/logic.ts @@ -11,16 +11,32 @@ import { ickbDepositCellFrom, type ReceiptCell, receiptCellFrom, -} from "./cells.js"; -import { ReceiptData } from "./entities.js"; +} from "./cells.ts"; +import { ReceiptData } from "./entities.ts"; +import { IckbUdt } from "./udt.ts"; const maxDepositQuantity = 63; +const receiptDataPrefixByteLength = 12; +// Receipts must carry enough capacity for phase 2 when the wallet has no other +// CKB: one iCKB xUDT cell, one plain capacity cell, and the fee reserve. +const phase2TxFeeReserve = ccc.One; /** * Manages logic related to deposits and receipts in the blockchain. * Implements the ScriptDeps interface. + * + * @public */ export class LogicManager implements ScriptDeps { + /** The iCKB Logic script used as receipt type and DAO deposit lock. */ + public readonly script: ccc.Script; + + /** Cell dependencies required to execute the iCKB Logic script. */ + public readonly cellDeps: ccc.CellDep[]; + + /** DAO helper used for deposit and withdrawal cell construction. */ + public readonly daoManager: DaoManager; + /** * Creates an instance of LogicManager. * @@ -28,11 +44,11 @@ export class LogicManager implements ScriptDeps { * @param cellDeps - The cell dependencies for the manager. * @param daoManager - The DAO manager for handling deposits and receipts. */ - constructor( - public readonly script: ccc.Script, - public readonly cellDeps: ccc.CellDep[], - public readonly daoManager: DaoManager, - ) {} + constructor(script: ccc.Script, cellDeps: ccc.CellDep[], daoManager: DaoManager) { + this.script = script; + this.cellDeps = cellDeps; + this.daoManager = daoManager; + } /** * Checks if the specified cell is an iCKB receipt. @@ -40,8 +56,11 @@ export class LogicManager implements ScriptDeps { * @param cell - The cell to check. * @returns True if the cell is a receipt, otherwise false. */ - isReceipt(cell: ccc.Cell): boolean { - return Boolean(cell.cellOutput.type?.eq(this.script)); + public isReceipt(cell: ccc.Cell): boolean { + return ( + cell.cellOutput.type?.eq(this.script) === true && + ccc.bytesFrom(cell.outputData).length >= receiptDataPrefixByteLength + ); } /** @@ -50,10 +69,8 @@ export class LogicManager implements ScriptDeps { * @param cell - The cell to check. * @returns True if the cell is a deposit, otherwise false. */ - isDeposit(cell: ccc.Cell): boolean { - return ( - this.daoManager.isDeposit(cell) && cell.cellOutput.lock.eq(this.script) - ); + public isDeposit(cell: ccc.Cell): boolean { + return this.daoManager.isDeposit(cell) && cell.cellOutput.lock.eq(this.script); } /** @@ -67,24 +84,24 @@ export class LogicManager implements ScriptDeps { * @remarks Caller must ensure UDT cellDeps are added to the transaction * (e.g., via ickbUdt.addCellDeps(tx)). */ - async deposit( - txLike: ccc.TransactionLike, - depositQuantity: number, - depositCapacity: ccc.FixedPoint, - lock: ccc.Script, - client: ccc.Client, + public async deposit( + ...[txLike, depositQuantity, depositCapacity, lock, client]: [ + txLike: ccc.TransactionLike, + depositQuantity: number, + depositCapacity: ccc.FixedPoint, + lock: ccc.Script, + client: ccc.Client, + ] ): Promise { let tx = ccc.Transaction.from(txLike); if (depositQuantity <= 0) { return tx; } if (!Number.isSafeInteger(depositQuantity)) { - throw new Error("iCKB deposit quantity must be a safe integer"); + throw new TypeError("iCKB deposit quantity must be a safe integer"); } if (depositQuantity > maxDepositQuantity) { - throw new Error( - `iCKB deposit quantity maximum is ${String(maxDepositQuantity)}`, - ); + throw new Error(`iCKB deposit quantity maximum is ${String(maxDepositQuantity)}`); } const depositCell = ccc.Cell.from({ @@ -115,16 +132,14 @@ export class LogicManager implements ScriptDeps { tx.addCellDeps(this.cellDeps); - const capacities = Array.from( - { length: depositQuantity }, - () => depositCapacity, - ); + const capacities = Array.from({ length: depositQuantity }, () => depositCapacity); tx = await this.daoManager.deposit(tx, capacities, this.script, client); // Receipts track the deposit's free capacity, not the full DAO cell capacity. tx.addOutput( { - lock: lock, + capacity: receiptPhase2Capacity(lock), + lock, type: this.script, }, ReceiptData.encode({ depositQuantity, depositAmount }), @@ -135,15 +150,16 @@ export class LogicManager implements ScriptDeps { } /** - * Completes a deposit transaction by transforming the receipts into iCKB UDTs. + * Adds receipt inputs and required header deps for iCKB deposit completion. * * @param txLike - The transaction to add the receipts to. * @param receipts - The receipts to add to the transaction. * - * @remarks Caller must ensure UDT cellDeps are added to the transaction - * (e.g., via ickbUdt.addCellDeps(tx)). + * @remarks This prepares the phase-2 inputs. UDT cell deps and xUDT balance + * completion remain caller-owned, for example through `ickbUdt.addCellDeps(tx)` + * and `ickbUdt.completeBy(...)`. */ - completeDeposit( + public completeDeposit( txLike: ccc.TransactionLike, receipts: ReceiptCell[], ): ccc.Transaction { @@ -152,15 +168,18 @@ export class LogicManager implements ScriptDeps { return tx; } - tx.addCellDeps(this.cellDeps); - + this.assertReceiptInputsUnspent(tx, receipts); + const headerHashes: ccc.Hex[] = []; for (const r of receipts) { + this.assertReceiptForCompletion(r); const hash = r.header.header.hash; - if (!tx.headerDeps.some((h) => h === hash)) { - tx.headerDeps.push(hash); + if (!headerHashes.includes(hash) && !tx.headerDeps.includes(hash)) { + headerHashes.push(hash); } } + tx.addCellDeps(this.cellDeps); + tx.headerDeps.push(...headerHashes); for (const { cell } of receipts) { tx.addInput(cell); } @@ -173,24 +192,10 @@ export class LogicManager implements ScriptDeps { * Receipt cells are identified by `this.script` (the receipt type script) * and must also pass `this.isReceipt(cell)`. * - * @param client - * A CKB client instance providing: - * - `findCells(query, order, pageSize)` for cached searches - * - `findCellsOnChain(query, order, pageSize)` for direct on-chain searches - * - * @param locks - * An array of lock scripts. Only cells whose `cellOutput.lock` exactly matches - * one of these scripts will be considered. - * - * @param options - * Optional parameters to control query behavior: - * - `onChain?: boolean` - * If `true`, uses `findCellsOnChain`. Otherwise, uses `findCells`. Default: `false`. - * - `pageSize?: number` - * Cell query page size per lock script. Defaults to `defaultCellPageSize` (400). - * - * @yields - * {@link ReceiptCell} objects for each valid receipt cell found. + * @param client - CKB client used for cached and direct on-chain searches. + * @param locks - Lock scripts whose exact matching receipt cells will be considered. + * @param options - Query options. `onChain` defaults to false and `pageSize` defaults to `defaultCellPageSize`. + * @returns An async generator yielding {@link ReceiptCell} objects for each valid receipt cell found. * * @remarks * - Deduplicates `locks` via `unique(locks)`. @@ -199,15 +204,15 @@ export class LogicManager implements ScriptDeps { * - Skips any cell that: * 1. Fails `this.isReceipt(cell)` * 2. Has a non-matching lock script - * - Converts each raw cell via `receiptCellFrom({ client, cell })` before yielding. + * - Converts each raw cell with a transaction cache shared across this scan batch. */ - async *findReceipts( + public async *findReceipts( client: ccc.Client, locks: ccc.Script[], options?: { /** * If true, fetch cells directly from the chain RPC. Otherwise, use cached results. - * @default false + * Defaults to false. */ onChain?: boolean; /** @@ -235,15 +240,20 @@ export class LogicManager implements ScriptDeps { "asc", ] as const; - const receiptCandidates = (await collectPagedScan( - (pageSize) => options?.onChain - ? client.findCellsOnChain(...findCellsArgs, pageSize) - : client.findCells(...findCellsArgs, pageSize), - { pageSize }, - )).filter((cell) => this.isReceipt(cell) && cell.cellOutput.lock.eq(lock)); + const receiptCandidates = ( + await collectPagedScan( + (scanPageSize) => + options?.onChain === true + ? client.findCellsOnChain(...findCellsArgs, scanPageSize) + : client.findCells(...findCellsArgs, scanPageSize), + { pageSize }, + ) + ).filter((cell) => this.isReceipt(cell) && cell.cellOutput.lock.eq(lock)); const receipts = await Promise.all( - receiptCandidates.map((cell) => receiptCellFrom({ client, cell, transactionCache })), + receiptCandidates.map(async (cell) => + receiptCellFrom({ client, cell, transactionCache }), + ), ); for (const receipt of receipts) { yield receipt; @@ -257,25 +267,9 @@ export class LogicManager implements ScriptDeps { * Wraps DAO deposit detection for the iCKB token by delegating * to `this.daoManager.findDeposits` and converting results. * - * @param client - * A CKB RPC client instance implementing: - * - `getTipHeader()` to fetch the latest block header - * - `findCells` / `findCellsOnChain` for cell queries - * - * @param options - * Optional parameters to control the search: - * - `tip?: ClientBlockHeader` - * Block header to use as reference for epoch/lock calculations. - * If omitted, `client.getTipHeader()` is called to obtain the latest header. - * - `onChain?: boolean` - * When `true`, forces direct on-chain queries via `findCellsOnChain`. - * Otherwise, uses cached results via `findCells`. Default: `false`. - * - `minLockUp?: ccc.Epoch` - * Minimum lock-up period in epochs. Defaults to manager’s configured minimum (~10 min). - * - `maxLockUp?: ccc.Epoch` - * Maximum lock-up period in epochs. Defaults to manager’s configured maximum (~3 days). - * - `pageSize?: number` - * Cell query page size. Defaults to `defaultCellPageSize` (400). + * @param client - CKB client used for tip/header reads and cell queries. + * @param options - Search options. `tip` controls epoch calculations, `onChain` uses direct RPC queries, and `pageSize` defaults to `defaultCellPageSize`. + * `minLockUp` and `maxLockUp` override the DAO helper windows. * * @returns * An async generator yielding `IckbDepositCell` objects, each representing @@ -288,7 +282,7 @@ export class LogicManager implements ScriptDeps { * raw DAO deposit cells locked under `this.script`. * - Converts each validated DAO deposit into an `IckbDepositCell` via `ickbDepositCellFrom`. */ - async *findDeposits( + public async *findDeposits( client: ccc.Client, options?: { tip?: ccc.ClientBlockHeader; @@ -298,18 +292,61 @@ export class LogicManager implements ScriptDeps { pageSize?: number; }, ): AsyncGenerator { - const tip = options?.tip - ? ccc.ClientBlockHeader.from(options.tip) - : await client.getTipHeader(); - for await (const deposit of this.daoManager.findDeposits( - client, - [this.script], - { ...options, tip }, - )) { + const tip = + options?.tip === undefined + ? await client.getTipHeader() + : ccc.ClientBlockHeader.from(options.tip); + for await (const deposit of this.daoManager.findDeposits(client, [this.script], { + ...options, + tip, + })) { if (!this.isDeposit(deposit.cell)) { continue; } - yield ickbDepositCellFrom(deposit); + yield ickbDepositCellFrom(deposit, this.script); + } + } + + private assertReceiptForCompletion(receipt: ReceiptCell): void { + const outPoint = receipt.cell.outPoint.toHex(); + if (!this.isReceipt(receipt.cell)) { + throw new Error(`Receipt ${outPoint} is not an iCKB receipt for this logic script`); + } + if (receipt.header.txHash !== receipt.cell.outPoint.txHash) { + throw new Error( + `Receipt ${outPoint} header txHash ${String(receipt.header.txHash)} does not match cell txHash ${receipt.cell.outPoint.txHash}`, + ); + } + } + + private assertReceiptInputsUnspent(tx: ccc.Transaction, receipts: ReceiptCell[]): void { + const spent = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); + const selected = new Set(); + for (const receipt of receipts) { + const outPoint = receipt.cell.outPoint.toHex(); + if (selected.has(outPoint)) { + throw new Error(`Receipt ${outPoint} is duplicated`); + } + selected.add(outPoint); + if (spent.has(outPoint)) { + throw new Error(`Receipt ${outPoint} is already being spent`); + } } } } + +/** + * Returns the CKB needed for the two phase-2 outputs created by one receipt. + * + * @remarks The value is sized with the actual user lock because lock args affect + * occupied capacity. It includes one plain output, one xUDT output, and the + * phase-2 fee reserve. + * + * @public + */ +export function receiptPhase2Capacity(lock: ccc.Script): ccc.FixedPoint { + // Capacity is measured with the actual user lock. Lock args are wallet-specific + // and can make both phase-2 outputs larger than protocol-only examples. + const plainCellCapacity = BigInt(8 + lock.occupiedSize) * ccc.One; + return plainCellCapacity + IckbUdt.minimumXudtCellCapacity(lock) + phase2TxFeeReserve; +} diff --git a/packages/core/src/owned_owner.test.ts b/packages/core/src/owned_owner.test.ts deleted file mode 100644 index 45c0580..0000000 --- a/packages/core/src/owned_owner.test.ts +++ /dev/null @@ -1,877 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, headerLike, script } from "@ickb/testkit"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { collect } from "@ickb/utils"; -import { DaoManager } from "@ickb/dao"; -import { OwnerData } from "./entities.js"; -import { OwnerCell, type IckbDepositCell } from "./cells.js"; -import { ickbValue } from "./udt.js"; -import { OwnedOwnerManager } from "./owned_owner.js"; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("OwnedOwnerManager.findWithdrawalGroups", () => { - it("decodes owner relative distances from prefixed data", () => { - const ownerCell = new OwnerCell( - ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: script("11"), - type: script("22"), - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }), - ); - - expect(ownerCell.getOwned().index).toBe(0n); - }); - - it("passes the cell page size to owner scanning", async () => { - const ownerLock = script("11"); - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const tip = headerLike(); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const firstOwner = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const secondOwner = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("66"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - let requestedPageSize = 0; - const client = { - findCells: async function* (_query: unknown, _order: unknown, pageSize: number) { - requestedPageSize = pageSize; - await Promise.resolve(); - yield firstOwner; - yield secondOwner; - }, - getCell: async () => { - await Promise.resolve(); - return undefined; - }, - } as unknown as ccc.Client; - - const groups = await collect(manager.findWithdrawalGroups(client, [ownerLock], { tip, pageSize: 1 })); - - expect(requestedPageSize).toBe(1); - expect(groups).toEqual([]); - }); - - it("skips owners whose referenced withdrawal is not locked by Owned Owner", async () => { - const ownerLock = script("11"); - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const outsiderLock = script("44"); - const tip = headerLike(); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const ownerCell = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const fakeOwned = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: outsiderLock, - type: daoScript, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - const client = { - getTipHeader: async () => { - await Promise.resolve(); - return tip; - }, - findCells: async function* () { - await Promise.resolve(); - yield ownerCell; - }, - getCell: async () => { - await Promise.resolve(); - return fakeOwned; - }, - getHeaderByNumber: async () => { - await Promise.resolve(); - return headerLike(); - }, - getTransactionWithHeader: async () => { - await Promise.resolve(); - return { header: tip }; - }, - } as unknown as ccc.Client; - - const groups = await collect( - manager.findWithdrawalGroups(client, [ownerLock], { tip }), - ); - - expect(groups).toEqual([]); - }); - - it("skips owner targets before DAO header decoding", async () => { - const ownerLock = script("11"); - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const tip = headerLike(); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const ownerCell = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("77"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const fakeOwned = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("77"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: script("44"), - type: daoScript, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - let headerLookups = 0; - const client = { - getTipHeader: async () => { - await Promise.resolve(); - return tip; - }, - findCells: async function* () { - await Promise.resolve(); - yield ownerCell; - }, - getCell: async () => { - await Promise.resolve(); - return fakeOwned; - }, - getHeaderByNumber: async () => { - headerLookups += 1; - await Promise.resolve(); - return headerLike(); - }, - getTransactionWithHeader: async () => { - headerLookups += 1; - await Promise.resolve(); - return { header: tip }; - }, - } as unknown as ccc.Client; - - const groups = await collect( - manager.findWithdrawalGroups(client, [ownerLock], { tip }), - ); - - expect(groups).toEqual([]); - expect(headerLookups).toBe(0); - }); - - it("skips referenced cells that are not DAO withdrawal requests", async () => { - const ownerLock = script("11"); - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const foreignType = script("44"); - const tip = headerLike(); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const firstOwner = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const secondOwner = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("66"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const deposit = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: ownedOwnerScript, - type: daoScript, - }, - outputData: "0x0000000000000000", - }); - const foreignCell = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("66"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: ownedOwnerScript, - type: foreignType, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - const referencedCells = new Map([ - [deposit.outPoint.toHex(), deposit], - [foreignCell.outPoint.toHex(), foreignCell], - ]); - const client = { - getTipHeader: async () => { - await Promise.resolve(); - return tip; - }, - findCells: async function* () { - await Promise.resolve(); - yield firstOwner; - yield secondOwner; - }, - getCell: async (outPoint: ccc.OutPoint) => { - await Promise.resolve(); - return referencedCells.get(outPoint.toHex()); - }, - getHeaderByNumber: async () => { - await Promise.resolve(); - return headerLike(); - }, - getTransactionWithHeader: async () => { - await Promise.resolve(); - return { header: tip }; - }, - } as unknown as ccc.Client; - - const groups = await collect( - manager.findWithdrawalGroups(client, [ownerLock], { tip }), - ); - - expect(groups).toEqual([]); - }); - - it("keeps withdrawal-group iCKB value from the referenced deposit header", async () => { - const ownerLock = script("11"); - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const tip = headerLike(); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const ownerCell = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("88"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const depositHeader = headerLike({ - epoch: [1n, 0n, 1n], - number: 1n, - }); - const withdrawalHeader = headerLike({ - hash: byte32FromByte("99"), - number: 2n, - }); - const owned = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("88"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: ownedOwnerScript, - type: daoScript, - }, - outputData: ccc.mol.Uint64LE.encode(depositHeader.number), - }); - const client = { - getTipHeader: async () => { - await Promise.resolve(); - return tip; - }, - findCells: async function* () { - await Promise.resolve(); - yield ownerCell; - }, - getCell: async () => { - await Promise.resolve(); - return owned; - }, - getHeaderByNumber: async () => { - await Promise.resolve(); - return depositHeader; - }, - getTransactionWithHeader: async () => { - await Promise.resolve(); - return { header: withdrawalHeader }; - }, - } as unknown as ccc.Client; - - const groups = await collect( - manager.findWithdrawalGroups(client, [ownerLock], { tip }), - ); - - expect(groups).toHaveLength(1); - expect(groups[0]?.udtValue).toBe(ickbValue(owned.capacityFree, depositHeader)); - }); - - it("fetches referenced owned cells concurrently and yields in owner scan order", async () => { - const ownerLock = script("11"); - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const tip = headerLike(); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const firstOwner = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("88"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const secondOwner = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("99"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const depositHeader = headerLike({ - epoch: [1n, 0n, 1n], - number: 1n, - }); - const withdrawalHeader = headerLike({ - hash: byte32FromByte("aa"), - number: 2n, - }); - const firstOwned = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("88"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: ownedOwnerScript, - type: daoScript, - }, - outputData: ccc.mol.Uint64LE.encode(depositHeader.number), - }); - const secondOwned = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("99"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: ownedOwnerScript, - type: daoScript, - }, - outputData: ccc.mol.Uint64LE.encode(depositHeader.number), - }); - let resolveFirst!: (cell: ccc.Cell | undefined) => void; - let resolveSecond!: (cell: ccc.Cell | undefined) => void; - const firstFetch = new Promise((resolve) => { - resolveFirst = resolve; - }); - const secondFetch = new Promise((resolve) => { - resolveSecond = resolve; - }); - const pending = new Map([ - [firstOwned.outPoint.toHex(), firstFetch], - [secondOwned.outPoint.toHex(), secondFetch], - ]); - const fetches: ccc.OutPoint[] = []; - const client = { - getTipHeader: async () => { - await Promise.resolve(); - return tip; - }, - findCells: async function* () { - await Promise.resolve(); - yield firstOwner; - yield secondOwner; - }, - getCell: async (outPoint: ccc.OutPoint) => { - fetches.push(outPoint); - const fetch = pending.get(outPoint.toHex()); - if (!fetch) { - throw new Error("Unexpected getCell out point"); - } - return fetch; - }, - getHeaderByNumber: async () => { - await Promise.resolve(); - return depositHeader; - }, - getTransactionWithHeader: async () => { - await Promise.resolve(); - return { header: withdrawalHeader }; - }, - } as unknown as ccc.Client; - - const groupsPromise = collect( - manager.findWithdrawalGroups(client, [ownerLock], { tip }), - ); - - await vi.waitFor(() => { - expect(fetches).toHaveLength(2); - }); - expect(fetches.map((outPoint) => outPoint.toHex())).toEqual([ - firstOwned.outPoint.toHex(), - secondOwned.outPoint.toHex(), - ]); - - resolveSecond(secondOwned); - await Promise.resolve(); - resolveFirst(firstOwned); - - const groups = await groupsPromise; - - expect(groups.map((group) => group.owner.cell.outPoint.toHex())).toEqual([ - firstOwner.outPoint.toHex(), - secondOwner.outPoint.toHex(), - ]); - }); - - it("decodes referenced withdrawals concurrently and yields in owner scan order", async () => { - const ownerLock = script("11"); - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const tip = headerLike(); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const firstOwner = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("88"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const secondOwner = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("99"), index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const depositHeader = headerLike({ - epoch: [1n, 0n, 1n], - number: 1n, - }); - const withdrawalHeader = headerLike({ - hash: byte32FromByte("aa"), - number: 2n, - }); - const firstOwned = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("88"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: ownedOwnerScript, - type: daoScript, - }, - outputData: ccc.mol.Uint64LE.encode(depositHeader.number), - }); - const secondOwned = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("99"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: ownedOwnerScript, - type: daoScript, - }, - outputData: ccc.mol.Uint64LE.encode(depositHeader.number), - }); - const referencedCells = new Map([ - [firstOwned.outPoint.toHex(), firstOwned], - [secondOwned.outPoint.toHex(), secondOwned], - ]); - const headerRequests: ccc.Hex[] = []; - let resolveFirst!: (res: { header: ccc.ClientBlockHeader }) => void; - let resolveSecond!: (res: { header: ccc.ClientBlockHeader }) => void; - const firstWithdrawalFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveFirst = resolve; - }); - const secondWithdrawalFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveSecond = resolve; - }); - const client = { - getTipHeader: async () => { - await Promise.resolve(); - return tip; - }, - findCells: async function* () { - await Promise.resolve(); - yield firstOwner; - yield secondOwner; - }, - getCell: async (outPoint: ccc.OutPoint) => { - await Promise.resolve(); - return referencedCells.get(outPoint.toHex()); - }, - getHeaderByNumber: async () => { - await Promise.resolve(); - return depositHeader; - }, - getTransactionWithHeader: async (txHash: ccc.Hex) => { - headerRequests.push(txHash); - return txHash === firstOwned.outPoint.txHash - ? firstWithdrawalFetch - : secondWithdrawalFetch; - }, - } as unknown as ccc.Client; - - const groupsPromise = collect( - manager.findWithdrawalGroups(client, [ownerLock], { tip }), - ); - - await vi.waitFor(() => { - expect(headerRequests).toEqual([ - firstOwned.outPoint.txHash, - secondOwned.outPoint.txHash, - ]); - }); - resolveSecond({ header: withdrawalHeader }); - await Promise.resolve(); - resolveFirst({ header: withdrawalHeader }); - - const groups = await groupsPromise; - - expect(groups.map((group) => group.owner.cell.outPoint.toHex())).toEqual([ - firstOwner.outPoint.toHex(), - secondOwner.outPoint.toHex(), - ]); - }); - - it("deduplicates referenced withdrawal header lookups during a scan", async () => { - const ownerLock = script("11"); - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const tip = headerLike(); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const txHash = byte32FromByte("88"); - const firstOwner = ccc.Cell.from({ - outPoint: { txHash, index: 1n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const secondOwner = ccc.Cell.from({ - outPoint: { txHash, index: 3n }, - cellOutput: { - capacity: 61n, - lock: ownerLock, - type: ownedOwnerScript, - }, - outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), - }); - const firstOwned = ccc.Cell.from({ - outPoint: { txHash, index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: ownedOwnerScript, - type: daoScript, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - const secondOwned = ccc.Cell.from({ - outPoint: { txHash, index: 2n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: ownedOwnerScript, - type: daoScript, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - const referencedCells = new Map([ - [firstOwned.outPoint.toHex(), firstOwned], - [secondOwned.outPoint.toHex(), secondOwned], - ]); - let headerCalls = 0; - let transactionCalls = 0; - const client = { - getTipHeader: async () => { - await Promise.resolve(); - return tip; - }, - findCells: async function* () { - await Promise.resolve(); - yield firstOwner; - yield secondOwner; - }, - getCell: async (outPoint: ccc.OutPoint) => { - await Promise.resolve(); - return referencedCells.get(outPoint.toHex()); - }, - getHeaderByNumber: async () => { - headerCalls += 1; - await Promise.resolve(); - return headerLike({ number: 1n }); - }, - getTransactionWithHeader: async () => { - transactionCalls += 1; - await Promise.resolve(); - return { header: headerLike({ number: 2n }) }; - }, - } as unknown as ccc.Client; - - const groups = await collect( - manager.findWithdrawalGroups(client, [ownerLock], { tip }), - ); - - expect(groups).toHaveLength(2); - expect(headerCalls).toBe(1); - expect(transactionCalls).toBe(1); - }); -}); - -describe("OwnedOwnerManager.requestWithdrawal", () => { - it("encodes owner distances from the actual withdrawal output indexes", async () => { - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const ownerLock = script("44"); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const depositHeader = headerLike({ number: 1n }); - const deposits = [ - depositCell("55", ownedOwnerScript, daoScript, depositHeader), - depositCell("66", ownedOwnerScript, daoScript, depositHeader), - ]; - const baseTx = ccc.Transaction.default(); - baseTx.addInput({ previousOutput: { txHash: byte32FromByte("77"), index: 0n } }); - baseTx.addOutput({ capacity: 1n, lock: ownerLock }, "0x"); - - const tx = await manager.requestWithdrawal( - baseTx, - deposits, - ownerLock, - clientForDepositHeader(depositHeader), - ); - - expect(tx.outputsData.slice(3)).toEqual([ - ccc.hexFrom(OwnerData.encode({ ownedDistance: -2n })), - ccc.hexFrom(OwnerData.encode({ ownedDistance: -2n })), - ]); - const ownerAOutput = tx.outputs[3]; - const ownerAData = tx.outputsData[3]; - const ownerBOutput = tx.outputs[4]; - const ownerBData = tx.outputsData[4]; - if (!ownerAOutput || ownerAData === undefined || !ownerBOutput || ownerBData === undefined) { - throw new Error("Expected owner outputs"); - } - const ownerA = new OwnerCell(ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("99"), index: 3n }, - cellOutput: ownerAOutput, - outputData: ownerAData, - })); - const ownerB = new OwnerCell(ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("99"), index: 4n }, - cellOutput: ownerBOutput, - outputData: ownerBData, - })); - expect(ownerA.getOwned().index).toBe(1n); - expect(ownerB.getOwned().index).toBe(2n); - }); - - it("adds required live deposit anchors as cell deps", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const ownerLock = script("44"); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const depositHeader = headerLike({ number: 1n }); - const requestedDeposit = depositCell("55", ownedOwnerScript, daoScript, depositHeader); - const requiredLiveDeposit = depositCell("66", ownedOwnerScript, daoScript, depositHeader); - - const tx = await manager.requestWithdrawal( - ccc.Transaction.default(), - [requestedDeposit], - ownerLock, - clientForDepositHeader(depositHeader), - { requiredLiveDeposits: [requiredLiveDeposit] }, - ); - - expect(tx.cellDeps).toContainEqual( - ccc.CellDep.from({ - outPoint: requiredLiveDeposit.cell.outPoint, - depType: "code", - }), - ); - }); - - it("rejects duplicated or already spent withdrawal deposits", async () => { - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const ownerLock = script("44"); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const depositHeader = headerLike({ number: 1n }); - const requestedDeposit = depositCell("55", ownedOwnerScript, daoScript, depositHeader); - const spentTx = ccc.Transaction.default(); - spentTx.addInput(requestedDeposit.cell); - - await expect( - manager.requestWithdrawal( - ccc.Transaction.default(), - [requestedDeposit, requestedDeposit], - ownerLock, - clientForDepositHeader(depositHeader), - ), - ).rejects.toThrow("Withdrawal deposit is duplicated"); - await expect( - manager.requestWithdrawal( - spentTx, - [requestedDeposit], - ownerLock, - clientForDepositHeader(depositHeader), - ), - ).rejects.toThrow("Withdrawal deposit is already being spent"); - }); - - it("rejects invalid required live deposit anchors", async () => { - const ownedOwnerScript = script("22"); - const daoScript = script("33"); - const ownerLock = script("44"); - const manager = new OwnedOwnerManager( - ownedOwnerScript, - [], - new DaoManager(daoScript, []), - ); - const depositHeader = headerLike({ number: 1n }); - const requestedDeposit = depositCell("55", ownedOwnerScript, daoScript, depositHeader); - const requiredLiveDeposit = depositCell("66", ownedOwnerScript, daoScript, depositHeader); - const notReadyLiveDeposit = { - ...requiredLiveDeposit, - isReady: false, - } as IckbDepositCell; - const spentTx = ccc.Transaction.default(); - spentTx.addInput(requiredLiveDeposit.cell); - - await expect( - manager.requestWithdrawal( - ccc.Transaction.default(), - [requestedDeposit], - ownerLock, - clientForDepositHeader(depositHeader), - { requiredLiveDeposits: [notReadyLiveDeposit] }, - ), - ).rejects.toThrow("Withdrawal live deposit anchor is not ready"); - await expect( - manager.requestWithdrawal( - ccc.Transaction.default(), - [requestedDeposit], - ownerLock, - clientForDepositHeader(depositHeader), - { requiredLiveDeposits: [requiredLiveDeposit, requiredLiveDeposit] }, - ), - ).rejects.toThrow("Withdrawal live deposit anchor is duplicated"); - await expect( - manager.requestWithdrawal( - spentTx, - [requestedDeposit], - ownerLock, - clientForDepositHeader(depositHeader), - { requiredLiveDeposits: [requiredLiveDeposit] }, - ), - ).rejects.toThrow("Withdrawal live deposit anchor is also being spent"); - await expect( - manager.requestWithdrawal( - ccc.Transaction.default(), - [requestedDeposit], - ownerLock, - clientForDepositHeader(depositHeader), - { requiredLiveDeposits: [requestedDeposit] }, - ), - ).rejects.toThrow("Withdrawal live deposit anchor is also being spent"); - }); -}); - -function depositCell( - txHashByte: string, - lock: ccc.Script, - dao: ccc.Script, - depositHeader: ccc.ClientBlockHeader, -): IckbDepositCell { - const cell = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte(txHashByte), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: dao, - }, - outputData: DaoManager.depositData(), - }); - return { - cell, - headers: [{ header: depositHeader }], - ckbValue: cell.cellOutput.capacity, - udtValue: 0n, - isDeposit: true, - isReady: true, - } as unknown as IckbDepositCell; -} - -function clientForDepositHeader(depositHeader: ccc.ClientBlockHeader): ccc.Client { - return { - getTransactionWithHeader: async () => { - await Promise.resolve(); - return { header: depositHeader }; - }, - } as unknown as ccc.Client; -} diff --git a/packages/core/src/owned_owner.ts b/packages/core/src/owned_owner.ts index 7dd47b5..b451ac4 100644 --- a/packages/core/src/owned_owner.ts +++ b/packages/core/src/owned_owner.ts @@ -1,158 +1,122 @@ import { ccc } from "@ckb-ccc/core"; +import { assertDaoOutputLimit, type DaoCellFromCache, type DaoManager } from "@ickb/dao"; import { collectPagedScan, defaultCellPageSize, unique, type ScriptDeps, } from "@ickb/utils"; -import { - assertDaoOutputLimit, - DaoManager, - type DaoCellFromCache, -} from "@ickb/dao"; -import { OwnerData } from "./entities.js"; -import { OwnerCell, WithdrawalGroup, type IckbDepositCell } from "./cells.js"; +import { OwnerCell, WithdrawalGroup, type IckbDepositCell } from "./cells.ts"; +import { OwnerData } from "./entities.ts"; /** - * Manages ownership and withdrawal operations for owned cells. - * Implements the ScriptDeps interface. + * Builds and finds Owned Owner withdrawal groups for an iCKB deployment. + * + * @public */ export class OwnedOwnerManager implements ScriptDeps { + /** The Owned Owner script used as owner marker type and withdrawal request lock. */ + public readonly script: ccc.Script; + + /** Cell dependencies required to execute the Owned Owner script. */ + public readonly cellDeps: ccc.CellDep[]; + + /** DAO helper used to build and decode the underlying withdrawal requests. */ + public readonly daoManager: DaoManager; + /** - * Creates an instance of OwnedOwnerManager. - * - * @param script - The script associated with the OwnedOwner script. - * @param cellDeps - The cell dependencies for the OwnedOwner script. - * @param daoManager - The DAO manager for handling withdrawal requests. + * Creates an Owned Owner manager for the script and DAO manager that belong to one deployment. */ - constructor( - public readonly script: ccc.Script, - public readonly cellDeps: ccc.CellDep[], - public readonly daoManager: DaoManager, - ) {} + constructor(script: ccc.Script, cellDeps: ccc.CellDep[], daoManager: DaoManager) { + this.script = script; + this.cellDeps = cellDeps; + this.daoManager = daoManager; + } /** - * Checks if the specified cell is an owner cell. - * - * @param cell - The cell to check against. - * @returns True if the cell is an owner cell, otherwise false. + * Returns true when the cell is an owner marker for this manager's script. */ - isOwner(cell: ccc.Cell): boolean { - return ( - Boolean(cell.cellOutput.type?.eq(this.script)) && - cell.outputData.length >= 10 - ); + public isOwner(cell: ccc.Cell): boolean { + if (cell.cellOutput.type?.eq(this.script) !== true || cell.outputData.length < 10) { + return false; + } + try { + new OwnerCell(cell).getOwned(); + return true; + } catch { + return false; + } } /** - * Checks if the specified cell is an owned cell and is a withdrawal request. - * - * @param cell - The cell to check against. - * @returns True if the cell is owned cell, otherwise false. + * Returns true when the cell is a DAO withdrawal request locked by this manager's script. */ - isOwned(cell: ccc.Cell): boolean { + public isOwned(cell: ccc.Cell): boolean { return ( - this.daoManager.isWithdrawalRequest(cell) && - cell.cellOutput.lock.eq(this.script) + this.daoManager.isWithdrawalRequest(cell) && cell.cellOutput.lock.eq(this.script) ); } /** - * Requests a withdrawal for the specified deposits. + * Adds DAO withdrawal request outputs and owner marker outputs for the selected deposits. * - * @param txLike - The transaction to add the withdrawal request to. - * @param deposits - The deposits to withdraw. - * @param lock - The lock script for the output. - * @param options - Optional parameters for the withdrawal request. - * @param options.isReadyOnly - Whether to only process ready deposits (default: false). - * @param options.requiredLiveDeposits - Live deposit anchors that must remain resolvable while requested deposits are spent. - * @returns void + * @param options - Withdrawal options. `isReadyOnly` skips deposits that are not ready. `requiredLiveDeposits` adds live deposit anchors as cell deps while requested deposits are spent. + * @returns The updated partial transaction. * - * @remarks Caller must ensure UDT cellDeps are added to the transaction - * (e.g., via ickbUdt.addCellDeps(tx)). + * @remarks Required live deposits are not spent; they anchor the withdrawal + * request as live cell deps. Duplicate anchors, duplicate deposits, and anchors + * that are also being spent throw. Anchor readiness is not required here. + * Caller must ensure UDT cellDeps are added to the transaction, for example + * via `ickbUdt.addCellDeps(tx)`. */ - async requestWithdrawal( - txLike: ccc.TransactionLike, - deposits: IckbDepositCell[], - lock: ccc.Script, - client: ccc.Client, - options?: { - isReadyOnly?: boolean; - requiredLiveDeposits?: IckbDepositCell[]; - }, + public async requestWithdrawal( + ...[txLike, deposits, lock, client, options]: [ + txLike: ccc.TransactionLike | ccc.Transaction, + deposits: IckbDepositCell[], + lock: ccc.Script, + client: ccc.Client, + options?: { + isReadyOnly?: boolean; + requiredLiveDeposits?: IckbDepositCell[]; + }, + ] ): Promise { let tx = ccc.Transaction.from(txLike); - const isReadyOnly = options?.isReadyOnly ?? false; - if (isReadyOnly) { - deposits = deposits.filter((d) => d.isReady); - } - if (deposits.length === 0) { + const selectedDeposits = + options?.isReadyOnly === true + ? deposits.filter((deposit) => deposit.isReady) + : deposits; + if (selectedDeposits.length === 0) { return tx; } - const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); - const requestedDepositOutPoints = new Set(); - for (const deposit of deposits) { - const outPoint = deposit.cell.outPoint.toHex(); - if (requestedDepositOutPoints.has(outPoint)) { - throw new Error("Withdrawal deposit is duplicated"); - } - requestedDepositOutPoints.add(outPoint); - if (spentOutPoints.has(outPoint)) { - throw new Error("Withdrawal deposit is already being spent"); - } - spentOutPoints.add(outPoint); - } - + const spentOutPoints = withdrawalSpentOutPoints(tx, selectedDeposits); const requiredLiveDeposits = options?.requiredLiveDeposits ?? []; - const requiredAnchorOutPoints = new Set(); - for (const deposit of requiredLiveDeposits) { - if (!deposit.isReady) { - throw new Error("Withdrawal live deposit anchor is not ready"); - } - const outPoint = deposit.cell.outPoint.toHex(); - if (requiredAnchorOutPoints.has(outPoint)) { - throw new Error("Withdrawal live deposit anchor is duplicated"); - } - requiredAnchorOutPoints.add(outPoint); - if (spentOutPoints.has(outPoint)) { - throw new Error("Withdrawal live deposit anchor is also being spent"); - } - } - const daoOptions = { isReadyOnly: false }; // non isReady deposits already filtered + assertRequiredLiveDepositsUnspent(requiredLiveDeposits, spentOutPoints); + // Readiness filtering, when requested, happened above; preserve the selected deposits here. + const daoOptions = { isReadyOnly: false }; const withdrawalOutputStart = tx.outputs.length; tx = await this.daoManager.requestWithdrawal( tx, - deposits, + selectedDeposits, this.script, client, daoOptions, ); - if (tx.outputs.length < withdrawalOutputStart + deposits.length) { - throw new Error("DAO withdrawal request did not add expected outputs"); - } + const withdrawalOutputs = withdrawalRequestOutputs( + tx, + withdrawalOutputStart, + selectedDeposits.length, + ); tx.addCellDeps(this.cellDeps); - - for (let index = 0; index < deposits.length; index += 1) { - const withdrawalOutput = tx.outputs[withdrawalOutputStart + index]; - if ( - !withdrawalOutput?.lock.eq(this.script) || - withdrawalOutput.type?.eq(this.daoManager.script) !== true - ) { - throw new Error("DAO withdrawal request output order changed"); - } - - const ownerOutputIndex = tx.outputs.length; - tx.addOutput( - { - lock: lock, - type: this.script, - }, - OwnerData.encode({ - ownedDistance: BigInt(withdrawalOutputStart + index) - BigInt(ownerOutputIndex), - }), - ); - } + addWithdrawalOwnerOutputs({ + tx, + withdrawalOutputs, + withdrawalOutputStart, + lock, + ownerScript: this.script, + daoScript: this.daoManager.script, + }); for (const deposit of requiredLiveDeposits) { tx.addCellDeps({ outPoint: deposit.cell.outPoint, depType: "code" }); @@ -163,19 +127,15 @@ export class OwnedOwnerManager implements ScriptDeps { } /** - * Completes the withdrawals of the specified withdrawal groups. + * Adds owned withdrawal requests and their owner markers as inputs. * - * @param txLike - The transaction to add the withdrawals to. - * @param withdrawalGroups - The withdrawal groups to process. - * @param options - Optional parameters for the withdrawal process. - * @param options.isReadyOnly - Whether to only process ready withdrawal groups (default: false). - * @returns void + * @returns The updated partial transaction. * - * @remarks Caller must ensure UDT cellDeps are added to the transaction - * (e.g., via ickbUdt.addCellDeps(tx)). + * @remarks Set `isReadyOnly` to spend only ready requests. Caller must ensure + * UDT cellDeps are added to the transaction (e.g., via ickbUdt.addCellDeps(tx)). */ - async withdraw( - txLike: ccc.TransactionLike, + public async withdraw( + txLike: ccc.TransactionLike | ccc.Transaction, withdrawalGroups: WithdrawalGroup[], client: ccc.Client, options?: { @@ -183,21 +143,24 @@ export class OwnedOwnerManager implements ScriptDeps { }, ): Promise { let tx = ccc.Transaction.from(txLike); - const isReadyOnly = options?.isReadyOnly ?? false; - if (isReadyOnly) { - withdrawalGroups = withdrawalGroups.filter((g) => g.owned.isReady); - } - if (withdrawalGroups.length === 0) { + const selectedWithdrawalGroups = + options?.isReadyOnly === true + ? withdrawalGroups.filter((group) => group.owned.isReady) + : withdrawalGroups; + if (selectedWithdrawalGroups.length === 0) { return tx; } + for (const group of selectedWithdrawalGroups) { + assertWithdrawalGroupLinked(group); + } tx.addCellDeps(this.cellDeps); - const requests = withdrawalGroups.map((g) => g.owned); + const requests = selectedWithdrawalGroups.map((group) => group.owned); tx = await this.daoManager.withdraw(tx, requests, client); - for (const { owner } of withdrawalGroups) { - tx.addInput(owner.cell); + for (const { owner } of selectedWithdrawalGroups) { + tx.addInput(cellInputLikeFrom(owner.cell)); } // assertDaoOutputLimit already called inside daoManager.withdraw; @@ -206,49 +169,12 @@ export class OwnedOwnerManager implements ScriptDeps { } /** - * Async generator that finds and yields withdrawal groups for the specified lock scripts. - * - * A "withdrawal group" pairs an owner cell with its corresponding DAO withdrawal cell. - * - * @param client - * A CKB client instance providing: - * - `findCells(query, order, pageSize)` for cached searches - * - `findCellsOnChain(query, order, pageSize)` for on-chain searches - * - `getTipHeader()` to fetch the latest block header - * - * @param locks - * An array of lock scripts. Only owner cells whose `cellOutput.lock` exactly - * matches one of these scripts will be considered. + * Finds owner marker cells for the given locks and yields valid owned withdrawal groups. * - * @param options - * Optional parameters to refine the search: - * - `tip?: ClientBlockHeader` - * Reference block header for epoch and block-number lookups. - * Defaults to `await client.getTipHeader()`. - * - `onChain?: boolean` - * If `true`, uses `findCellsOnChain`; otherwise, uses `findCells`. - * Default: `false`. - * - `pageSize?: number` - * Cell query page size per lock script. Defaults to `defaultCellPageSize` (400). - * - * @yields - * {@link WithdrawalGroup} objects, each containing: - * - the owner cell (`OwnerCell`) - * - the corresponding DAO withdrawal request cell - * - * @remarks - * - Deduplicates `locks` via `unique(locks)`. - * - Applies an RPC filter with: - * • `script: this.script` (DAO type script) - * - Skips any cell that: - * 1. Fails `this.isOwner(cell)` - * 2. Has a non-matching lock script - * - For each owner cell: - * 1. Construct an `OwnerCell` instance. - * 2. Fetch the referenced cell and skip it unless it is an Owned Owner withdrawal request. - * 3. Decode the validated withdrawal request and yield a `WithdrawalGroup`. + * @param options - Scan options. `tip` controls readiness calculations, `onChain` bypasses cached cell queries, and `pageSize` is per lock. + * @remarks Header and transaction caches are scoped to one lock scan batch so related DAO cell conversions share the same reads. */ - async *findWithdrawalGroups( + public async *findWithdrawalGroups( client: ccc.Client, locks: ccc.Script[], options?: { @@ -273,41 +199,167 @@ export class OwnedOwnerManager implements ScriptDeps { "asc", ] as const; - const ownerCandidates = (await collectPagedScan( - (pageSize) => options?.onChain - ? client.findCellsOnChain(...findCellsArgs, pageSize) - : client.findCells(...findCellsArgs, pageSize), - { pageSize }, - )) + const ownerCandidates = ( + await collectPagedScan( + (scanPageSize) => + options?.onChain === true + ? client.findCellsOnChain(...findCellsArgs, scanPageSize) + : client.findCells(...findCellsArgs, scanPageSize), + { pageSize }, + ) + ) .filter((cell) => this.isOwner(cell) && cell.cellOutput.lock.eq(lock)) .map((cell) => new OwnerCell(cell)); const ownedCells = await Promise.all( - ownerCandidates.map((owner) => client.getCell(owner.getOwned())), + ownerCandidates.map(async (owner) => client.getCell(owner.getOwned())), ); const headerCache: DaoCellFromCache["headerCache"] = new Map(); const transactionCache: DaoCellFromCache["transactionCache"] = new Map(); const withdrawalGroups = await Promise.all( - ownerCandidates.map(async (owner, index) => { - const ownedCell = ownedCells[index]; - if (!ownedCell || !this.isOwned(ownedCell)) { - return; - } - const owned = await this.daoManager.withdrawalRequestCellFrom( - ownedCell, - client, - { tip, headerCache, transactionCache }, - ); - return new WithdrawalGroup(owned, owner); - }), + ownerCandidates.map( + async (owner, index): Promise => { + const ownedCell = ownedCells[index]; + if (ownedCell === undefined || !this.isOwned(ownedCell)) { + return undefined; + } + const owned = await this.daoManager.withdrawalRequestCellFrom( + ownedCell, + client, + { + tip, + headerCache, + transactionCache, + }, + ); + return new WithdrawalGroup(owned, owner); + }, + ), ); for (const group of withdrawalGroups) { - if (group) { + if (group !== undefined) { yield group; } } } } } + +function withdrawalSpentOutPoints( + tx: ccc.Transaction, + deposits: IckbDepositCell[], +): Set { + const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); + const requestedDepositOutPoints = new Set(); + for (const deposit of deposits) { + const outPoint = deposit.cell.outPoint.toHex(); + if (requestedDepositOutPoints.has(outPoint)) { + throw new Error("Withdrawal deposit is duplicated"); + } + requestedDepositOutPoints.add(outPoint); + if (spentOutPoints.has(outPoint)) { + throw new Error("Withdrawal deposit is already being spent"); + } + spentOutPoints.add(outPoint); + } + return spentOutPoints; +} + +function assertRequiredLiveDepositsUnspent( + requiredLiveDeposits: IckbDepositCell[], + spentOutPoints: Set, +): void { + const requiredAnchorOutPoints = new Set(); + for (const deposit of requiredLiveDeposits) { + const outPoint = deposit.cell.outPoint.toHex(); + if (requiredAnchorOutPoints.has(outPoint)) { + throw new Error("Withdrawal live deposit anchor is duplicated"); + } + requiredAnchorOutPoints.add(outPoint); + if (spentOutPoints.has(outPoint)) { + throw new Error("Withdrawal live deposit anchor is also being spent"); + } + } +} + +function withdrawalRequestOutputs( + tx: ccc.Transaction, + withdrawalOutputStart: number, + depositCount: number, +): ccc.CellOutput[] { + const outputs = tx.outputs.slice( + withdrawalOutputStart, + withdrawalOutputStart + depositCount, + ); + if (outputs.length !== depositCount) { + throw new Error("DAO withdrawal request did not add expected outputs"); + } + return outputs; +} + +interface AddWithdrawalOwnerOutputsOptions { + tx: ccc.Transaction; + withdrawalOutputs: ccc.CellOutput[]; + withdrawalOutputStart: number; + lock: ccc.Script; + ownerScript: ccc.Script; + daoScript: ccc.Script; +} + +function addWithdrawalOwnerOutputs({ + tx, + withdrawalOutputs, + withdrawalOutputStart, + lock, + ownerScript, + daoScript, +}: AddWithdrawalOwnerOutputsOptions): void { + for (const [index, withdrawalOutput] of withdrawalOutputs.entries()) { + assertWithdrawalRequestOutput(withdrawalOutput, ownerScript, daoScript); + const ownerOutputIndex = tx.outputs.length; + tx.addOutput( + { lock, type: ownerScript }, + OwnerData.encode({ + // ownedDistance is negative because owner markers are appended after their withdrawal outputs. + ownedDistance: BigInt(withdrawalOutputStart + index) - BigInt(ownerOutputIndex), + }), + ); + } +} + +function assertWithdrawalRequestOutput( + withdrawalOutput: ccc.CellOutput, + ownerScript: ccc.Script, + daoScript: ccc.Script, +): void { + if ( + !withdrawalOutput.lock.eq(ownerScript) || + withdrawalOutput.type?.eq(daoScript) !== true + ) { + throw new Error("DAO withdrawal request output order changed"); + } +} + +function assertWithdrawalGroupLinked(group: WithdrawalGroup): void { + const ownedOutPoint = group.owned.cell.outPoint; + const linkedOutPoint = group.owner.getOwned(); + if (!linkedOutPoint.eq(ownedOutPoint)) { + throw new Error( + `Withdrawal owner ${group.owner.cell.outPoint.toHex()} points to ${linkedOutPoint.toHex()} but group owned cell is ${ownedOutPoint.toHex()}`, + ); + } +} + +function cellInputLikeFrom(cell: ccc.Cell): ccc.CellInputLike { + return { + outPoint: cell.outPoint, + cellOutput: { + capacity: cell.cellOutput.capacity, + lock: cell.cellOutput.lock, + ...(cell.cellOutput.type === undefined ? {} : { type: cell.cellOutput.type }), + }, + outputData: cell.outputData, + }; +} diff --git a/packages/core/src/udt.ts b/packages/core/src/udt.ts index 8e85ab3..844664d 100644 --- a/packages/core/src/udt.ts +++ b/packages/core/src/udt.ts @@ -1,26 +1,47 @@ import { ccc } from "@ckb-ccc/core"; import { udt } from "@ckb-ccc/udt"; -import { ReceiptData } from "./entities.js"; import type { DaoManager } from "@ickb/dao"; import type { ExchangeRatio } from "@ickb/utils"; +import { ReceiptData } from "./entities.ts"; + +const ickbXudtTypeOccupiedSize = 69; +const udtDataSize = 16; +const AR_0: ccc.Num = 10000000000000000n; // Base scale for CKB +const depositUsedCapacity = ccc.fixedPointFrom(82); // 82n CKB +const depositCapacityDelta = (depositUsedCapacity * AR_0) / ccc.fixedPointFrom(100000); /** - * IckbUdt extends CCC's Udt class to provide accurate multi-representation - * balance for iCKB tokens. The iCKB conservation law is: - * Input UDT + Input Receipts = Output UDT + Input Deposits + * Soft per-deposit iCKB value cap used before applying the excess discount. * - * `infoFrom` values three cell types: - * - xUDT cells: positive balance (standard UDT) - * - Receipt cells: positive balance (input only, valued via ickbValue) - * - Deposit cells: negative balance (input only, withdrawal reduces UDT supply) + * @public + */ +export const ICKB_DEPOSIT_CAP = ccc.fixedPointFrom(100000); // 100,000 iCKB + +const zeroInputContribution: IckbInputContribution = { + balance: ccc.Zero, + isXudt: false, +}; + +type TransactionWithHeader = Awaited>; + +/** + * IckbUdt extends CCC's Udt class with iCKB-aware completion. + * CCC UDT APIs account actual xUDT cells; iCKB inputs can also carry value + * through receipt and DAO deposit cells. * - * Output cells without outPoint are naturally excluded from receipt/deposit - * processing, since only input cells (resolved by CellInput.getCell()) have outPoint. + * @public */ export class IckbUdt extends udt.Udt { + /** Out point of the xUDT code cell used by this iCKB token. */ public readonly udtCode: ccc.OutPoint; + + /** Out point of the iCKB Logic code cell required by this token. */ public readonly logicCode: ccc.OutPoint; + + /** Logic script whose hash is embedded in this iCKB xUDT type script. */ public readonly logicScript: ccc.Script; + + /** DAO helper used to recognize iCKB DAO deposit inputs during completion. */ public readonly daoManager: DaoManager; /** @@ -33,11 +54,13 @@ export class IckbUdt extends udt.Udt { * @param daoManager - The DAO manager instance for deposit cell identification. */ constructor( - code: ccc.OutPointLike, - script: ccc.ScriptLike, - logicCode: ccc.OutPointLike, - logicScript: ccc.ScriptLike, - daoManager: DaoManager, + ...[code, script, logicCode, logicScript, daoManager]: [ + code: ccc.OutPointLike, + script: ccc.ScriptLike, + logicCode: ccc.OutPointLike, + logicScript: ccc.ScriptLike, + daoManager: DaoManager, + ] ) { super(code, script); this.udtCode = ccc.OutPoint.from(code); @@ -52,125 +75,71 @@ export class IckbUdt extends udt.Udt { * Concatenates the iCKB logic script hash with the fixed 4-byte little-endian * xUDT owner-mode flags postfix ("00000080") to form the UDT type script args. * - * @param udt - The raw xUDT script (codeHash and hashType reused). + * @param udtScript - The raw xUDT script (codeHash and hashType reused). * @param ickbLogic - The iCKB logic script (hash used for args). * @returns A new Script with the computed args. */ - static typeScriptFrom(udt: ccc.Script, ickbLogic: ccc.Script): ccc.Script { - const { codeHash, hashType } = udt; + public static typeScriptFrom(udtScript: ccc.Script, ickbLogic: ccc.Script): ccc.Script { + const { codeHash, hashType } = udtScript; return new ccc.Script( codeHash, hashType, - [ickbLogic.hash(), "00000080"].join("") as ccc.Hex, + ccc.hexFrom([ickbLogic.hash(), "00000080"].join("")), ); } /** - * Computes UDT balance info for iCKB's three cell representations. - * - * For each cell: - * - xUDT cell (type === this.script, data >= 16 bytes): adds positive balance - * - Receipt cell (type === logicScript, has outPoint): adds positive balance - * via ickbValue of deposit amount * quantity - * - Deposit cell (lock === logicScript, isDeposit, has outPoint): adds negative - * balance via ickbValue of free capacity (withdrawal reduces UDT supply) - * - * Cells without outPoint (output cells from getOutputsInfo) skip receipt/deposit - * processing -- correct by design since these only appear as inputs. - * - * @param client - CKB client for header fetches (receipt/deposit valuation). - * @param cells - Cell or array of cells to evaluate. - * @param acc - Optional accumulator for running totals. - * @returns UdtInfo with balance, capacity, and count. + * Minimum capacity for an iCKB xUDT cell locked by the supplied lock script. */ - override async infoFrom( - client: ccc.Client, - cells: ccc.CellAnyLike | ccc.CellAnyLike[], - acc?: udt.UdtInfoLike, - ): Promise { - const info = udt.UdtInfo.from(acc).clone(); - const headerByTx = new Map< - ccc.Hex, - Promise - >(); - const getTransactionHeader = async ( - txHash: ccc.Hex, - ): Promise => { - let headerPromise = headerByTx.get(txHash); - if (!headerPromise) { - headerPromise = client - .getTransactionWithHeader(txHash) - .then((res) => res?.header); - headerByTx.set(txHash, headerPromise); - } - const header = await headerPromise; - if (!header) { - throw new Error("Header not found for txHash"); - } - return header; - }; - - const deltas = await Promise.all( - [cells].flat().map(async (cellLike) => { - const cell = ccc.CellAny.from(cellLike); - - // Standard xUDT cell -- delegate to base class pattern - if (this.isUdt(cell)) { - return { - balance: udt.Udt.balanceFromUnsafe(cell.outputData), - capacity: cell.cellOutput.capacity, - count: 1, - }; - } - - // Receipt and deposit cells need outPoint for header fetch. - // Output cells (no outPoint) are skipped -- correct by design. - if (!cell.outPoint) { - return; - } - - const { type, lock } = cell.cellOutput; + public static minimumXudtCellCapacity(lock: ccc.Script): ccc.FixedPoint { + return ( + BigInt(8 + lock.occupiedSize + ickbXudtTypeOccupiedSize + udtDataSize) * ccc.One + ); + } - // Receipt cell: type === logicScript - if (type && this.logicScript.eq(type)) { - const header = await getTransactionHeader(cell.outPoint.txHash); + /** + * Returns true when a cell carries this iCKB xUDT type and enough UDT data. + */ + public isUdt(cellLike: ccc.CellAnyLike): boolean { + const cell = ccc.CellAny.from(cellLike); + return ( + cell.cellOutput.type?.eq(this.script) === true && + ccc.bytesFrom(cell.outputData).length >= udtDataSize + ); + } - const { depositQuantity, depositAmount } = - ReceiptData.decodePrefix(cell.outputData); - return { - balance: ickbValue(depositAmount, header) * - depositQuantity, - capacity: cell.cellOutput.capacity, - count: 1, - }; - } + /** + * Completes iCKB xUDT inputs and change. + * Existing receipt/deposit inputs are valued here, but the code that added + * them still owns protocol-specific cell deps and header deps. + */ + public override async completeChangeToLock( + txLike: ccc.TransactionLike, + signer: ccc.Signer, + changeLike: ccc.ScriptLike, + ): Promise { + const tx = this.addCellDeps(txLike); + let inputTally = await this.inputTallyFromTransaction(tx, signer.client); + const requiredBalance = this.requiredBalanceFromOutputs(tx); - // Deposit cell: lock === logicScript AND isDeposit - // Output cells are gated by the !cell.outPoint check above and never reach here. - if ( - this.logicScript.eq(lock) && - this.daoManager.isDeposit(cell) - ) { - const header = await getTransactionHeader(cell.outPoint.txHash); - - return { - balance: -ickbValue(cell.capacityFree, header), - capacity: cell.cellOutput.capacity, - count: 1, - }; - } + if (shouldCollectMoreInputs(inputTally, requiredBalance)) { + inputTally = await this.collectXudtInputs(tx, signer, inputTally, requiredBalance); + } - return; - }), - ); + addUdtChangeOutput(tx, changeLike, this.script, inputTally.balance - requiredBalance); - for (const delta of deltas) { - if (delta) { - info.addAssign(delta); - } - } + return tx; + } - return info; + /** + * Completes iCKB xUDT inputs and sends change to the signer's recommended lock. + */ + public override async completeBy( + txLike: ccc.TransactionLike, + signer: ccc.Signer, + ): Promise { + const { script } = await signer.getRecommendedAddressObj(); + return this.completeChangeToLock(txLike, signer, script); } /** @@ -183,22 +152,236 @@ export class IckbUdt extends udt.Udt { * @param txLike - The transaction to add cell deps to. * @returns The transaction with cell deps added. */ - override addCellDeps(txLike: ccc.TransactionLike): ccc.Transaction { + public addCellDeps(txLike: ccc.TransactionLike): ccc.Transaction { const tx = ccc.Transaction.from(txLike); - // xUDT code dep - tx.addCellDeps({ outPoint: this.udtCode, depType: "code" }); - // iCKB Logic code dep - tx.addCellDeps({ outPoint: this.logicCode, depType: "code" }); + addCodeDep(tx, this.udtCode); + addCodeDep(tx, this.logicCode); return tx; } + + /** Builds the initial tally from inputs already present in the transaction. */ + private async inputTallyFromTransaction( + tx: ccc.Transaction, + client: ccc.Client, + ): Promise { + const transactionCache = new Map>(); + const cells = await Promise.all( + tx.inputs.map(async (input) => { + try { + return await input.getCell(client); + } catch (error) { + throw new Error(`Failed to load input cell ${input.previousOutput.toHex()}`, { + cause: error, + }); + } + }), + ); + const contributions = await Promise.all( + cells.map(async (cell) => this.inputContribution(cell, client, transactionCache)), + ); + const tally = IckbInputTally.default(); + for (const contribution of contributions) { + tally.addAssign(contribution); + } + return tally; + } + + /** Adds xUDT inputs until the required balance and xUDT-count policy are met. */ + private async collectXudtInputs( + tx: ccc.Transaction, + signer: ccc.Signer, + inputTally: IckbInputTally, + requiredBalance: ccc.Num, + ): Promise { + const transactionCache = new Map>(); + const collectedTally = new IckbInputTally(inputTally.balance, inputTally.xudtCount); + let completedBalance = collectedTally.balance; + const { accumulated } = await tx.completeInputs( + signer, + { + script: this.script, + outputDataLenRange: [udtDataSize, ccc.numFrom("0xffffffff")], + }, + async (balance, cell) => { + collectedTally.balance = balance; + collectedTally.addAssign( + await this.inputContribution(cell, signer.client, transactionCache), + ); + completedBalance = collectedTally.balance; + return shouldCollectMoreInputs(collectedTally, requiredBalance) + ? collectedTally.balance + : undefined; + }, + collectedTally.balance, + ); + if (accumulated !== undefined && accumulated < requiredBalance) { + throw new Error( + `Insufficient iCKB, need ${String(requiredBalance - accumulated)} more`, + ); + } + collectedTally.balance = accumulated ?? completedBalance; + return collectedTally; + } + + /** Classifies an input cell once for iCKB completion accounting. */ + private async inputContribution( + cell: ccc.CellAny, + client: ccc.Client, + transactionCache: Map>, + ): Promise { + if (this.isUdt(cell)) { + return { + balance: ccc.udtBalanceFrom(cell.outputData), + isXudt: true, + }; + } + if (cell.outPoint === undefined) { + return zeroInputContribution; + } + + const { type, lock } = cell.cellOutput; + let amount: ccc.FixedPoint; + let quantity = 1n; + let sign = 1n; + if (type !== undefined && this.logicScript.eq(type)) { + let receipt: ReturnType; + try { + receipt = ReceiptData.decodePrefix(cell.outputData); + } catch (error) { + throw new Error( + `Invalid iCKB receipt payload at ${cell.outPoint.toHex()}: ${cell.outputData}`, + { cause: error }, + ); + } + amount = receipt.depositAmount; + quantity = receipt.depositQuantity; + } else if (this.logicScript.eq(lock) && this.daoManager.isDeposit(cell)) { + amount = cell.capacityFree; + sign = -1n; + } else { + return zeroInputContribution; + } + + const header = ( + await getCachedTransactionWithHeader(client, cell.outPoint, transactionCache) + )?.header; + if (header === undefined) { + throw new Error( + `Header not found for txHash ${cell.outPoint.txHash} at ${cell.outPoint.toHex()}`, + ); + } + + return { + balance: sign * ickbValue(amount, header) * quantity, + isXudt: false, + }; + } + + private requiredBalanceFromOutputs(tx: ccc.Transaction): ccc.Num { + return Array.from(tx.outputCells).reduce((required, cell) => { + return this.isUdt(cell) ? required + ccc.udtBalanceFrom(cell.outputData) : required; + }, ccc.Zero); + } +} + +async function getCachedTransactionWithHeader( + client: ccc.Client, + outPoint: ccc.OutPoint, + transactionCache: Map>, +): Promise { + const txHash = outPoint.txHash; + let promise = transactionCache.get(txHash); + if (promise === undefined) { + promise = getTransactionWithHeader(client, outPoint); + transactionCache.set(txHash, promise); + } + return promise; +} + +async function getTransactionWithHeader( + client: ccc.Client, + outPoint: ccc.OutPoint, +): Promise { + try { + return await client.getTransactionWithHeader(outPoint.txHash); + } catch (error) { + throw new Error( + `Failed to load transaction header for txHash ${outPoint.txHash} at ${outPoint.toHex()}`, + { cause: error }, + ); + } +} + +interface IckbInputContribution { + balance: ccc.Num; + isXudt: boolean; +} + +/** Tracks iCKB input value and actual xUDT input count separately. */ +class IckbInputTally { + public balance: ccc.Num; + public xudtCount: number; + + constructor(balance: ccc.Num, xudtCount: number) { + this.balance = balance; + this.xudtCount = xudtCount; + } + + public static default(): IckbInputTally { + return new IckbInputTally(ccc.Zero, 0); + } + + public addAssign(contribution: IckbInputContribution): void { + this.balance += contribution.balance; + if (contribution.isXudt) { + this.xudtCount += 1; + } + } +} + +/** Decides whether xUDT collection should continue for the current tally. */ +function shouldCollectMoreInputs( + inputTally: IckbInputTally, + requiredBalance: ccc.Num, +): boolean { + if (inputTally.balance < requiredBalance) { + return true; + } + if (inputTally.balance === requiredBalance) { + return false; + } + + // Match CCC's xUDT compression rule: one overfunding xUDT input should + // collect a second xUDT input. Receipt and deposit inputs do not count. + return inputTally.xudtCount === 1; +} + +function addUdtChangeOutput( + tx: ccc.Transaction, + lock: ccc.ScriptLike, + type: ccc.ScriptLike, + balance: ccc.Num, +): void { + if (balance <= ccc.Zero) { + return; + } + const balanceData = ccc.numLeToBytes(balance, 16); + tx.addOutput({ lock, type }, balanceData); +} + +function addCodeDep(tx: ccc.Transaction, outPoint: ccc.OutPoint): void { + if (tx.cellDeps.some((dep) => dep.depType === "code" && dep.outPoint.eq(outPoint))) { + return; + } + tx.addCellDeps({ outPoint, depType: "code" }); } /** - * Calculates the iCKB value based on the unoccupied CKB capacity and the block header. + * Calculates iCKB value for unoccupied CKB capacity at a deposit header. * - * @param ckbUnoccupiedCapacity - The unoccupied capacity in CKB. - * @param header - The block header used for conversion. - * @returns The calculated iCKB amount. + * @remarks + * Values above {@link ICKB_DEPOSIT_CAP} receive a 10% discount only on the + * excess amount. */ export function ickbValue( ckbUnoccupiedCapacity: ccc.FixedPoint, @@ -213,9 +396,6 @@ export function ickbValue( return ickbAmount; } -/** The maximum deposit cap for iCKB, set to 100,000 iCKB. */ -export const ICKB_DEPOSIT_CAP = ccc.fixedPointFrom(100000); // 100,000 iCKB - /** * Converts between CKB and iCKB based on the provided ratio. * @@ -227,6 +407,8 @@ export const ICKB_DEPOSIT_CAP = ccc.fixedPointFrom(100000); // 100,000 iCKB * @param accountDepositCapacity - A boolean indicating whether to account for deposit capacity * when using ccc.ClientBlockHeader (default: true). * @returns The converted amount in the target unit as a `ccc.FixedPoint`. + * + * @public */ export function convert( isCkb2Udt: boolean, @@ -234,12 +416,13 @@ export function convert( rate: ExchangeRatio | ccc.ClientBlockHeader, accountDepositCapacity = true, ): ccc.FixedPoint { - if ("dao" in rate) { - rate = ickbExchangeRatio(rate, accountDepositCapacity); + const ratio = "dao" in rate ? ickbExchangeRatio(rate, accountDepositCapacity) : rate; + if (ratio.ckbScale <= 0n || ratio.udtScale <= 0n) { + throw new Error("Exchange ratio scales must be positive"); } return isCkb2Udt - ? (amount * rate.ckbScale) / rate.udtScale - : (amount * rate.udtScale) / rate.ckbScale; + ? (amount * ratio.ckbScale) / ratio.udtScale + : (amount * ratio.udtScale) / ratio.ckbScale; } /** @@ -248,6 +431,8 @@ export function convert( * @param header - The block header used for calculating the exchange ratio. * @param accountDepositCapacity - A boolean indicating whether to account for the deposit capacity in the calculation. * @returns An object containing the CKB and UDT scales. + * + * @public */ export function ickbExchangeRatio( header: ccc.ClientBlockHeader, @@ -259,8 +444,3 @@ export function ickbExchangeRatio( udtScale: accountDepositCapacity ? AR_m + depositCapacityDelta : AR_m, }; } - -// Constants used in calculations -const AR_0: ccc.Num = 10000000000000000n; // Base scale for CKB -const depositUsedCapacity = ccc.fixedPointFrom(82); // 82n CKB -const depositCapacityDelta = (depositUsedCapacity * AR_0) / ICKB_DEPOSIT_CAP; // Delta for deposit capacity diff --git a/packages/core/test/cells/cells.ts b/packages/core/test/cells/cells.ts new file mode 100644 index 0000000..42cac73 --- /dev/null +++ b/packages/core/test/cells/cells.ts @@ -0,0 +1,395 @@ +import { ccc } from "@ckb-ccc/core"; +import { + DaoManager, + type DaoDepositCell, + type DaoWithdrawalRequestCell, +} from "@ickb/dao"; +import { describe, expect, it, vi } from "vitest"; +import { + ickbDepositCellFrom, + OwnerCell, + receiptCellFrom, + WithdrawalGroup, +} from "../../src/cells.ts"; +import { IckbUdt, ickbValue } from "../../src/udt.ts"; +import { + byte32FromByte, + clientWithHeader, + headerLike, + RECEIPT_PREFIX_DECODING_SUITE, + receiptCell, + receiptOutputData, + script, + signerWithCells, + StubClient, + transactionWithHeader, + xudtCell, +} from "./support/cells_support.ts"; + +describe(RECEIPT_PREFIX_DECODING_SUITE, () => { + registerReceiptCellFromPrefixTests(); + registerReceiptCellFromFailureTests(); + registerIckbDepositCellFromTests(); + registerCompleteByPrefixTests(); + registerConcurrentHeaderTests(); + registerRepeatedHeaderTests(); +}); + +function registerReceiptCellFromPrefixTests(): void { + it("lets receiptCellFrom ignore trailing bytes", async () => { + const logic = script("33"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const outputData = receiptOutputData(2, ccc.fixedPointFrom(100000)); + const cell = receiptCell(outputData, logic); + + const receipt = await receiptCellFrom({ + cell, + client: clientWithHeader(header), + }); + const receiptFromOutPoint = await receiptCellFrom({ + outpoint: cell.outPoint, + client: new StubClient({ + getCell: async (): ReturnType => { + await Promise.resolve(); + return cell; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(header); + }, + }), + }); + + expect(receipt.ckbValue).toBe(ccc.fixedPointFrom(100082)); + expect(receipt.udtValue).toBe(ccc.fixedPointFrom(200000)); + expect(receiptFromOutPoint.cell.outPoint.toHex()).toBe(cell.outPoint.toHex()); + }); + + it("rejects malformed receipt payloads with out point context", async () => { + const logic = script("33"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const cell = receiptCell("0x12", logic); + + await expect( + receiptCellFrom({ cell, client: clientWithHeader(header) }), + ).rejects.toThrow(`Invalid iCKB receipt payload at ${cell.outPoint.toHex()}: 0x12`); + }); + + it("exposes owner and withdrawal group CKB values", () => { + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("12"), index: 1n }, + cellOutput: { capacity: 61n, lock: script("22"), type: script("33") }, + outputData: "0x0000000000000000", + }), + ); + const owned = withdrawalRequestCell(header); + + expect(owner.ckbValue).toBe(owner.cell.cellOutput.capacity); + expect(new WithdrawalGroup(owned, owner).ckbValue).toBe( + owned.ckbValue + owner.cell.cellOutput.capacity, + ); + }); + + it("rejects owner markers that point before output zero", () => { + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("12"), index: 0n }, + cellOutput: { capacity: 61n, lock: script("22"), type: script("33") }, + outputData: "0xffffffff", + }), + ); + + expect(() => owner.getOwned()).toThrow( + `Owner marker ${owner.cell.outPoint.toHex()} points before output 0`, + ); + }); + + it("rejects malformed owner marker payloads with out point context", () => { + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("12"), index: 0n }, + cellOutput: { capacity: 61n, lock: script("22"), type: script("33") }, + outputData: "0x12", + }), + ); + + expect(() => owner.getOwned()).toThrow( + `Invalid owner marker payload at ${owner.cell.outPoint.toHex()}: 0x12`, + ); + }); +} + +function registerReceiptCellFromFailureTests(): void { + it("rejects missing receipt cells and transaction headers", async () => { + const logic = script("33"); + const cell = receiptCell(receiptOutputData(1, ccc.fixedPointFrom(100000)), logic); + const client = new StubClient({ + getCell: async (): ReturnType => { + await Promise.resolve(); + return undefined; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return undefined; + }, + }); + + await expect(receiptCellFrom({ outpoint: cell.outPoint, client })).rejects.toThrow( + `Cell not found for out point ${cell.outPoint.toHex()}`, + ); + await expect(receiptCellFrom({ cell, client })).rejects.toThrow( + `Header not found for txHash ${cell.outPoint.txHash} at ${cell.outPoint.toHex()}`, + ); + }); + + it("preserves receipt coordinates when cell and header reads fail", async () => { + const logic = script("33"); + const cell = receiptCell(receiptOutputData(1, ccc.fixedPointFrom(100000)), logic); + const cellError = new Error("cell rpc failed"); + const headerError = new Error("header rpc failed"); + const failedCellClient = new StubClient({ + getCell: async (): ReturnType => { + await Promise.resolve(); + throw cellError; + }, + }); + const failedHeaderClient = new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + throw headerError; + }, + }); + + await expect( + receiptCellFrom({ outpoint: cell.outPoint, client: failedCellClient }), + ).rejects.toMatchObject({ + message: `Failed to load cell for out point ${cell.outPoint.toHex()}`, + cause: cellError, + }); + await expect( + receiptCellFrom({ cell, client: failedHeaderClient }), + ).rejects.toMatchObject({ + message: `Failed to load transaction header for txHash ${cell.outPoint.txHash} at ${cell.outPoint.toHex()}`, + cause: headerError, + }); + }); +} + +function registerIckbDepositCellFromTests(): void { + it("brands only DAO deposits locked by the expected logic script", () => { + const logic = script("33"); + const dao = script("44"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const deposit = daoDepositCell(logic, dao, header); + const wrongLogicDeposit = daoDepositCell(script("55"), dao, header); + + expect(ickbDepositCellFrom(deposit, logic).udtValue).toBe( + ickbValue(deposit.cell.capacityFree, header), + ); + expect(() => ickbDepositCellFrom(wrongLogicDeposit, logic)).toThrow( + `DAO deposit ${wrongLogicDeposit.cell.outPoint.toHex()} lock does not match iCKB logic script`, + ); + }); +} + +function daoDepositCell( + lock: ccc.Script, + dao: ccc.Script, + header: ccc.ClientBlockHeader, +): DaoDepositCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("14"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock, type: dao }, + outputData: DaoManager.depositData(), + }); + return { + cell, + headers: [{ header }, { header }], + ckbValue: cell.cellOutput.capacity, + udtValue: 0n, + interests: 0n, + maturity: header.epoch, + isDeposit: true, + isReady: true, + }; +} + +function withdrawalRequestCell(header: ccc.ClientBlockHeader): DaoWithdrawalRequestCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("13"), index: 0n }, + cellOutput: { capacity: 100n, lock: script("22"), type: script("33") }, + outputData: ccc.mol.Uint64LE.encode(header.number), + }); + return { + cell, + headers: [{ header }, { header }], + ckbValue: 100n, + udtValue: 0n, + interests: 0n, + maturity: header.epoch, + isDeposit: false, + isReady: true, + }; +} + +function registerCompleteByPrefixTests(): void { + it("completeBy values receipt prefixes with trailing bytes", async () => { + const logic = script("33"); + const type = script("55"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const outputData = receiptOutputData(3, ccc.fixedPointFrom(100000)); + const cell = receiptCell(outputData, logic); + const ickbUdt = new IckbUdt( + { txHash: byte32FromByte("44"), index: 0n }, + type, + { txHash: byte32FromByte("66"), index: 0n }, + logic, + new DaoManager(script("77"), []), + ); + const tx = ccc.Transaction.from({ + outputs: [{ lock: script("22"), type }], + outputsData: [ + ccc.numLeToBytes(ickbValue(ccc.fixedPointFrom(100000), header) * 3n, 16), + ], + }); + tx.addInput(cell); + const signer = signerWithCells([], clientWithHeader(header)); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(1); + expect(completed.outputs).toHaveLength(1); + }); +} + +function registerConcurrentHeaderTests(): void { + it("fetches receipt and deposit headers concurrently", async () => { + const logic = script("33"); + const dao = script("44"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const receipt = receiptCell(receiptOutputData(2, ccc.fixedPointFrom(100000)), logic); + const deposit = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("88"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock: logic, type: dao }, + outputData: "0x0000000000000000", + }); + const type = script("55"); + const ickbUdt = new IckbUdt( + { txHash: byte32FromByte("44"), index: 0n }, + type, + { txHash: byte32FromByte("66"), index: 0n }, + logic, + new DaoManager(dao, []), + ); + const { promise: receiptFetch, resolve: resolveReceipt } = + Promise.withResolvers< + Awaited> + >(); + const { promise: depositFetch, resolve: resolveDeposit } = + Promise.withResolvers< + Awaited> + >(); + const requests: ccc.Hex[] = []; + const client = new StubClient({ + getTransactionWithHeader: async ( + txHash, + ): ReturnType => { + const hash = ccc.hexFrom(txHash); + requests.push(hash); + return hash === receipt.outPoint.txHash ? receiptFetch : depositFetch; + }, + }); + + const tx = ccc.Transaction.from({ + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(50n, 16)], + }); + tx.addInput(receipt); + tx.addInput(deposit); + const signer = signerWithCells( + [xudtCell(ickbValue(deposit.capacityFree, header) + 50n, type)], + client, + ); + + const completedPromise = ickbUdt.completeBy(tx, signer); + + await vi.waitFor(() => { + expect(requests).toEqual([receipt.outPoint.txHash, deposit.outPoint.txHash]); + }); + resolveDeposit(transactionWithHeader(header)); + await Promise.resolve(); + resolveReceipt(transactionWithHeader(header)); + + const completed = await completedPromise; + + expect(completed.inputs).toHaveLength(2); + }); +} + +function registerRepeatedHeaderTests(): void { + it("values repeated-header receipt and deposit inputs", async () => { + const logic = script("33"); + const dao = script("44"); + const txHash = byte32FromByte("88"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const receipt = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("22"), + type: logic, + }, + outputData: receiptOutputData(2, ccc.fixedPointFrom(100000)), + }); + const deposit = ccc.Cell.from({ + outPoint: { txHash, index: 1n }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock: logic, type: dao }, + outputData: "0x0000000000000000", + }); + const ickbUdt = new IckbUdt( + { txHash: byte32FromByte("44"), index: 0n }, + script("55"), + { txHash: byte32FromByte("66"), index: 0n }, + logic, + new DaoManager(dao, []), + ); + + const tx = ccc.Transaction.default(); + tx.addInput(receipt); + tx.addInput(deposit); + let headerRequests = 0; + const signer = signerWithCells( + [], + new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + headerRequests += 1; + await Promise.resolve(); + return transactionWithHeader(header); + }, + }), + ); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(headerRequests).toBe(1); + expect(completed.outputsData).toContain( + ccc.hexFrom( + ccc.numLeToBytes( + ickbValue(ccc.fixedPointFrom(100000), header) * 2n - + ickbValue(deposit.capacityFree, header), + 16, + ), + ), + ); + }); +} diff --git a/packages/core/test/cells/support/cells_support.ts b/packages/core/test/cells/support/cells_support.ts new file mode 100644 index 0000000..3bc1780 --- /dev/null +++ b/packages/core/test/cells/support/cells_support.ts @@ -0,0 +1,116 @@ +import { ccc } from "@ckb-ccc/core"; +import { + byte32FromByte, + script, + StubClient, + headerLike as testHeaderLike, + transactionWithHeader, +} from "@ickb/testkit"; +import { ReceiptData } from "../../../src/entities.ts"; + +export const RECEIPT_PREFIX_DECODING_SUITE = "receipt prefix decoding"; + +export function headerLike(ar: bigint): ccc.ClientBlockHeader { + return testHeaderLike({ + dao: { c: 0n, ar, s: 0n, u: 0n }, + epoch: [1n, 0n, 1n], + number: 1n, + }); +} + +export function clientWithHeader(header: ccc.ClientBlockHeader): ccc.Client { + return new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(header); + }, + }); +} + +export function receiptOutputData( + depositQuantity: number, + depositAmount: ccc.FixedPoint, +): ccc.Hex { + return ccc.hexFrom([ + ...ReceiptData.from({ depositQuantity, depositAmount }).toBytes(), + 0xab, + 0xcd, + ]); +} + +export function receiptCell(outputData: ccc.Hex, logic: ccc.Script): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("11"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("22"), + type: logic, + }, + outputData, + }); +} + +export function xudtCell( + balance: ccc.Num, + type: ccc.Script, + lock = script("22"), +): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("99"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100), + lock, + type, + }, + outputData: ccc.numLeToBytes(balance, 16), + }); +} + +export function signerWithCells(cells: ccc.Cell[], client: ccc.Client): ccc.Signer { + return new TestSigner(client, cells); +} + +class TestSigner extends ccc.Signer { + private readonly cells: ccc.Cell[]; + + constructor(client: ccc.Client, cells: ccc.Cell[]) { + super(client); + this.cells = cells; + } + + public override get type(): ccc.SignerType { + return ccc.SignerType.CKB; + } + + public override get signType(): ccc.SignerSignType { + return ccc.SignerSignType.CkbSecp256k1; + } + + public override async connect(): Promise { + await Promise.resolve(); + } + + public override async isConnected(): Promise { + await Promise.resolve(); + return true; + } + + public override async getInternalAddress(): Promise { + await Promise.resolve(); + return "ckt1test"; + } + + public override async getAddressObjs(): Promise { + await Promise.resolve(); + return [new ccc.Address(script("22"), "ckt")]; + } + + public override async *findCells(): AsyncGenerator { + await Promise.resolve(); + yield* this.cells; + } +} + +export { byte32FromByte, script, StubClient, transactionWithHeader }; diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts new file mode 100644 index 0000000..5c2ff50 --- /dev/null +++ b/packages/core/test/index.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import * as core from "../src/index.ts"; + +describe("core package barrel", () => { + it("converts values through the public index", () => { + const ratio = { ckbScale: 2n, udtScale: 3n }; + + expect(core.convert(true, 9n, ratio)).toBe(6n); + expect(core.convert(false, 6n, ratio)).toBe(9n); + }); +}); diff --git a/packages/core/test/logic/logic.ts b/packages/core/test/logic/logic.ts new file mode 100644 index 0000000..84db8d0 --- /dev/null +++ b/packages/core/test/logic/logic.ts @@ -0,0 +1,284 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager, type DaoDepositCell } from "@ickb/dao"; +import { collect } from "@ickb/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReceiptCell } from "../../src/cells.ts"; +import { ReceiptData } from "../../src/entities.ts"; +import { LogicManager } from "../../src/logic.ts"; +import { IckbUdt } from "../../src/udt.ts"; +import { + byte32FromByte, + headerLike, + LOGIC_MANAGER_DEPOSIT_SUITE, + receiptPhase2Capacity, + script, + StubClient, + testClient, +} from "./support/logic_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(LOGIC_MANAGER_DEPOSIT_SUITE, () => { + registerDepositEncodingTests(); + registerReceiptSizingTests(); + registerDepositRecognitionTests(); + registerDepositCompletionTests(); + registerDepositFindingTests(); +}); + +function registerDepositEncodingTests(): void { + it("encodes receipt amounts from deposit free capacity", async () => { + const logic = script("11"); + const dao = script("22"); + const user = script("33"); + const manager = new LogicManager(logic, [], new DaoManager(dao, [])); + + const tx = await manager.deposit( + ccc.Transaction.default(), + 2, + ccc.fixedPointFrom(100082), + user, + testClient(), + ); + + expect(tx.outputs).toHaveLength(3); + expect(tx.outputs[0]?.capacity).toBe(ccc.fixedPointFrom(100082)); + expect(tx.outputs[1]?.capacity).toBe(ccc.fixedPointFrom(100082)); + const receiptData = tx.outputsData[2]; + const receiptOutput = tx.outputs[2]; + if (receiptData === undefined || receiptOutput === undefined) { + throw new Error("Expected receipt output"); + } + const receipt = ReceiptData.decode(receiptData); + expect(receipt.depositQuantity).toBe(2n); + expect(receipt.depositAmount).toBe(ccc.fixedPointFrom(100000)); + expect(receiptOutput.capacity).toBe(receiptPhase2Capacity(user)); + }); + + it("leaves the transaction unchanged for non-positive deposit quantities", async () => { + const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); + const tx = ccc.Transaction.default(); + tx.addOutput({ capacity: 1n, lock: script("33") }, "0x"); + + await expect( + manager.deposit(tx, 0, ccc.fixedPointFrom(100082), script("33"), testClient()), + ).resolves.toEqual(tx); + }); +} + +function registerReceiptSizingTests(): void { + it("sizes receipt capacity from the actual user lock", async () => { + const logic = script("11"); + const dao = script("22"); + const user = script("33", `0x${"44".repeat(20)}`); + const manager = new LogicManager(logic, [], new DaoManager(dao, [])); + + const tx = await manager.deposit( + ccc.Transaction.default(), + 1, + ccc.fixedPointFrom(100082), + user, + testClient(), + ); + + expect(tx.outputs[1]?.capacity).toBe(receiptPhase2Capacity(user)); + }); + + it("sizes iCKB xUDT cells from the actual token script shape", () => { + const lock = script("33", `0x${"44".repeat(20)}`); + const logic = script("11"); + const xudtType = IckbUdt.typeScriptFrom(script("22"), logic); + const xudtCell = ccc.CellAny.from({ + cellOutput: { lock, type: xudtType }, + outputData: `0x${"00".repeat(16)}`, + }); + + expect(IckbUdt.minimumXudtCellCapacity(lock)).toBe( + BigInt(xudtCell.occupiedSize) * ccc.One, + ); + }); +} + +function registerDepositRecognitionTests(): void { + it("identifies DAO deposits locked by the logic script", () => { + const logic = script("11"); + const dao = script("22"); + const manager = new LogicManager(logic, [], new DaoManager(dao, [])); + const deposit = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock: logic, type: dao }, + outputData: DaoManager.depositData(), + }); + const wrongLock = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock: script("33"), type: dao }, + outputData: DaoManager.depositData(), + }); + + expect(manager.isDeposit(deposit)).toBe(true); + expect(manager.isDeposit(wrongLock)).toBe(false); + }); +} + +function registerDepositCompletionTests(): void { + it("adds receipt inputs and unique receipt header deps when completing deposits", () => { + const logic = script("11"); + const manager = new LogicManager( + logic, + [ + ccc.CellDep.from({ + outPoint: { txHash: byte32FromByte("22"), index: 0n }, + depType: "code", + }), + ], + new DaoManager(script("33"), []), + ); + const firstHeader = headerLike({ hash: byte32FromByte("44") }); + const secondHeader = headerLike({ hash: byte32FromByte("55") }); + const receipts = [ + receiptForCompletion("66", firstHeader, logic), + receiptForCompletion("77", firstHeader, logic), + receiptForCompletion("88", secondHeader, logic), + ]; + + const tx = manager.completeDeposit(ccc.Transaction.default(), receipts); + + expect(tx.cellDeps).toHaveLength(1); + expect(tx.headerDeps).toEqual([firstHeader.hash, secondHeader.hash]); + expect(tx.inputs.map((input) => input.previousOutput.toHex())).toEqual( + receipts.map((receipt) => receipt.cell.outPoint.toHex()), + ); + expect(manager.completeDeposit(tx, [])).toEqual(tx); + }); + + it("rejects structural receipts for another logic script or tx hash", () => { + const logic = script("11"); + const manager = new LogicManager(logic, [], new DaoManager(script("33"), [])); + const header = headerLike({ hash: byte32FromByte("44") }); + const wrongType = receiptForCompletion("66", header, script("77")); + const wrongTxHash = receiptForCompletion("88", header, logic); + wrongTxHash.header = { ...wrongTxHash.header, txHash: byte32FromByte("99") }; + + expect(() => manager.completeDeposit(ccc.Transaction.default(), [wrongType])).toThrow( + `Receipt ${wrongType.cell.outPoint.toHex()} is not an iCKB receipt for this logic script`, + ); + expect(() => + manager.completeDeposit(ccc.Transaction.default(), [wrongTxHash]), + ).toThrow( + `Receipt ${wrongTxHash.cell.outPoint.toHex()} header txHash ${String(wrongTxHash.header.txHash)} does not match cell txHash ${wrongTxHash.cell.outPoint.txHash}`, + ); + }); + + it("rejects duplicated or already-spent receipt inputs", () => { + const logic = script("11"); + const manager = new LogicManager(logic, [], new DaoManager(script("33"), [])); + const receipt = receiptForCompletion("66", headerLike(), logic); + const tx = ccc.Transaction.default(); + tx.addInput(receipt.cell); + + expect(() => + manager.completeDeposit(ccc.Transaction.default(), [receipt, receipt]), + ).toThrow(`Receipt ${receipt.cell.outPoint.toHex()} is duplicated`); + expect(() => manager.completeDeposit(tx, [receipt])).toThrow( + `Receipt ${receipt.cell.outPoint.toHex()} is already being spent`, + ); + }); +} + +function registerDepositFindingTests(): void { + it("finds only DAO deposits locked by the logic script", async () => { + const logic = script("11"); + const dao = script("22"); + const tip = headerLike({ hash: byte32FromByte("33"), number: 3n }); + const depositHeader = headerLike({ hash: byte32FromByte("44"), number: 1n }); + const goodDeposit = depositDaoCell("55", logic, dao, depositHeader); + const wrongLockDeposit = depositDaoCell("66", script("77"), dao, depositHeader); + let tipReads = 0; + const fakeDaoManager = new FindDepositsDaoManager(dao, [ + goodDeposit, + wrongLockDeposit, + ]); + const manager = new LogicManager(logic, [], fakeDaoManager); + const client = new StubClient({ + getTipHeader: async (): ReturnType => { + tipReads += 1; + await Promise.resolve(); + return tip; + }, + }); + + const deposits = await collect(manager.findDeposits(client)); + const explicitTipDeposits = await collect(manager.findDeposits(client, { tip })); + + expect(tipReads).toBe(1); + expect(deposits).toHaveLength(1); + expect(deposits[0]?.cell.outPoint.toHex()).toBe(goodDeposit.cell.outPoint.toHex()); + expect(explicitTipDeposits).toHaveLength(1); + expect(tipReads).toBe(1); + }); +} + +class FindDepositsDaoManager extends DaoManager { + private readonly deposits: DaoDepositCell[]; + + constructor(daoScript: ccc.Script, deposits: DaoDepositCell[]) { + super(daoScript, []); + this.deposits = deposits; + } + + public override isDeposit(cell: ccc.CellAny): boolean { + return cell.cellOutput.type?.eq(this.script) === true; + } + + public override async *findDeposits(): AsyncGenerator { + await Promise.resolve(); + yield* this.deposits; + } +} + +function receiptForCompletion( + txHashByte: string, + header: ccc.ClientBlockHeader, + logic: ccc.Script, +): ReceiptCell { + const cell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte(txHashByte), index: 0n }, + cellOutput: { capacity: 61n, lock: script("99"), type: logic }, + outputData: ReceiptData.from({ + depositQuantity: 1, + depositAmount: ccc.fixedPointFrom(100000), + }).toBytes(), + }); + return { + cell, + header: { header, txHash: cell.outPoint.txHash }, + ckbValue: cell.cellOutput.capacity, + udtValue: 1n, + }; +} + +function depositDaoCell( + txHashByte: string, + lock: ccc.Script, + dao: ccc.Script, + depositHeader: ccc.ClientBlockHeader, +): DaoDepositCell { + const txHash = byte32FromByte(txHashByte); + const cell = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock, type: dao }, + outputData: DaoManager.depositData(), + }); + return { + cell, + headers: [{ header: depositHeader, txHash }, { header: depositHeader }], + ckbValue: cell.cellOutput.capacity, + udtValue: 0n, + interests: 0n, + maturity: depositHeader.epoch, + isDeposit: true, + isReady: true, + }; +} diff --git a/packages/core/test/logic/logic_receipts.ts b/packages/core/test/logic/logic_receipts.ts new file mode 100644 index 0000000..3df79ff --- /dev/null +++ b/packages/core/test/logic/logic_receipts.ts @@ -0,0 +1,275 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager } from "@ickb/dao"; +import { collect } from "@ickb/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ReceiptData } from "../../src/entities.ts"; +import { LogicManager } from "../../src/logic.ts"; +import { + byte32FromByte, + headerLike, + LOGIC_MANAGER_DEPOSIT_SUITE, + noCellsOnChain, + receiptPair, + script, + StubClient, + transactionWithHeader, +} from "./support/logic_support.ts"; + +describe(LOGIC_MANAGER_DEPOSIT_SUITE, () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + registerReceiptFilteringTests(); + registerReceiptConcurrencyTests(); + registerReceiptPageSizeTests(); + registerReceiptHeaderCacheTests(); +}); + +function registerReceiptFilteringTests(): void { + it("filters receipts by exact lock and type while deduplicating locks", async () => { + const logic = script("11"); + const wantedLock = script("22"); + const otherLock = script("33"); + const receiptData = ReceiptData.from({ + depositQuantity: 1, + depositAmount: ccc.fixedPointFrom(100000), + }).toBytes(); + const validReceipt = receiptCell("44", logic, wantedLock, receiptData); + const wrongLock = receiptCell("55", logic, otherLock, receiptData); + const wrongType = receiptCell("66", script("77"), wantedLock, receiptData); + const shortData = receiptCell("99", logic, wantedLock, "0x00"); + let calls = 0; + const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); + const client = new StubClient({ + async *findCells(): ReturnType { + await Promise.resolve(); + calls += 1; + yield validReceipt; + yield wrongLock; + yield wrongType; + yield shortData; + }, + findCellsOnChain: noCellsOnChain, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(headerLike({ number: 1n, epoch: [1n, 0n, 1n] })); + }, + }); + + const receipts = await collect( + manager.findReceipts(client, [wantedLock, wantedLock]), + ); + + expect(calls).toBe(1); + expect(manager.isReceipt(shortData)).toBe(false); + expect(receipts).toHaveLength(1); + expect(receipts[0]?.cell.outPoint.txHash).toBe(byte32FromByte("44")); + }); +} + +function registerReceiptConcurrencyTests(): void { + it("fetches receipt headers concurrently and yields scan order", async () => { + const logic = script("11"); + const wantedLock = script("22"); + const [firstReceipt, secondReceipt] = receiptPair(logic, wantedLock); + const header = headerLike({ number: 1n, epoch: [1n, 0n, 1n] }); + const { promise: firstFetch, resolve: resolveFirst } = + Promise.withResolvers< + Awaited> + >(); + const { promise: secondFetch, resolve: resolveSecond } = + Promise.withResolvers< + Awaited> + >(); + const requests: ccc.Hex[] = []; + const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); + const client = new StubClient({ + async *findCells(): ReturnType { + await Promise.resolve(); + yield firstReceipt; + yield secondReceipt; + }, + findCellsOnChain: noCellsOnChain, + getTransactionWithHeader: async ( + txHash, + ): ReturnType => { + const hash = ccc.hexFrom(txHash); + requests.push(hash); + return hash === firstReceipt.outPoint.txHash ? firstFetch : secondFetch; + }, + }); + + const receiptsPromise = collect(manager.findReceipts(client, [wantedLock])); + + await vi.waitFor(() => { + expect(requests).toEqual([ + firstReceipt.outPoint.txHash, + secondReceipt.outPoint.txHash, + ]); + }); + resolveSecond(transactionWithHeader(header)); + await Promise.resolve(); + resolveFirst(transactionWithHeader(header)); + + const receipts = await receiptsPromise; + + expect(receipts.map((receipt) => receipt.cell.outPoint.txHash)).toEqual([ + firstReceipt.outPoint.txHash, + secondReceipt.outPoint.txHash, + ]); + }); +} + +function registerReceiptPageSizeTests(): void { + it("passes the cell page size to receipt scanning", async () => { + const logic = script("11"); + const wantedLock = script("22"); + const [firstReceipt, secondReceipt] = receiptPair(logic, wantedLock); + let requestedPageSize = 0; + const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); + const client = new StubClient({ + async *findCells( + _query, + _order, + pageSize = 10, + ): ReturnType { + requestedPageSize = pageSize; + await Promise.resolve(); + yield firstReceipt; + yield secondReceipt; + }, + findCellsOnChain: noCellsOnChain, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(headerLike()); + }, + }); + + const receipts = await collect( + manager.findReceipts(client, [wantedLock], { pageSize: 1 }), + ); + + expect(requestedPageSize).toBe(1); + expect(receipts.map((receipt) => receipt.cell.outPoint.txHash)).toEqual([ + firstReceipt.outPoint.txHash, + secondReceipt.outPoint.txHash, + ]); + }); + + it("scans receipts directly from chain when requested", async () => { + const logic = script("11"); + const wantedLock = script("22"); + const [receipt] = receiptPair(logic, wantedLock); + let onChainPageSize = 0; + const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); + const client = new StubClient({ + async *findCellsOnChain( + _query, + _order, + pageSize = 10, + ): ReturnType { + onChainPageSize = pageSize; + await Promise.resolve(); + yield receipt; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(headerLike()); + }, + }); + + const receipts = await collect( + manager.findReceipts(client, [wantedLock], { onChain: true, pageSize: 2 }), + ); + + expect(onChainPageSize).toBe(2); + expect(receipts).toHaveLength(1); + }); +} + +function registerReceiptHeaderCacheTests(): void { + it("reuses receipt transaction header requests across lock scans", async () => { + const logic = script("11"); + const firstLock = script("22"); + const secondLock = script("33"); + const txHash = byte32FromByte("44"); + const receiptData = ReceiptData.from({ + depositQuantity: 1, + depositAmount: ccc.fixedPointFrom(100000), + }).toBytes(); + const firstReceipt = receiptCellAt({ + txHash, + index: 0n, + logic, + lock: firstLock, + outputData: receiptData, + }); + const secondReceipt = receiptCellAt({ + txHash, + index: 1n, + logic, + lock: secondLock, + outputData: receiptData, + }); + let transactionCalls = 0; + const manager = new LogicManager(logic, [], new DaoManager(script("88"), [])); + const client = new StubClient({ + async *findCells(query): ReturnType { + await Promise.resolve(); + yield ccc.Script.from(query.script).eq(firstLock) ? firstReceipt : secondReceipt; + }, + findCellsOnChain: noCellsOnChain, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + transactionCalls += 1; + await Promise.resolve(); + return transactionWithHeader(headerLike()); + }, + }); + + const receipts = await collect(manager.findReceipts(client, [firstLock, secondLock])); + + expect(transactionCalls).toBe(1); + expect(receipts.map((receipt) => receipt.cell.outPoint.index)).toEqual([0n, 1n]); + }); +} + +function receiptCell( + txHashByte: string, + logic: ccc.Script, + lock: ccc.Script, + outputData: ccc.BytesLike, +): ccc.Cell { + return receiptCellAt({ + txHash: byte32FromByte(txHashByte), + index: 0n, + logic, + lock, + outputData, + }); +} + +function receiptCellAt(options: ReceiptCellAtOptions): ccc.Cell { + const { txHash, index, logic, lock, outputData } = options; + return ccc.Cell.from({ + outPoint: { txHash, index }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock, type: logic }, + outputData, + }); +} + +interface ReceiptCellAtOptions { + txHash: ccc.Hex; + index: bigint; + logic: ccc.Script; + lock: ccc.Script; + outputData: ccc.BytesLike; +} diff --git a/packages/core/test/logic/logic_validation.ts b/packages/core/test/logic/logic_validation.ts new file mode 100644 index 0000000..64fa22f --- /dev/null +++ b/packages/core/test/logic/logic_validation.ts @@ -0,0 +1,77 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager } from "@ickb/dao"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LogicManager } from "../../src/logic.ts"; +import { + LOGIC_MANAGER_DEPOSIT_SUITE, + script, + testClient, +} from "./support/logic_support.ts"; + +describe(LOGIC_MANAGER_DEPOSIT_SUITE, () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("keeps the protocol minimum on unoccupied capacity", async () => { + const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); + + await expect( + manager.deposit( + ccc.Transaction.default(), + 1, + ccc.fixedPointFrom(1081), + script("33"), + testClient(), + ), + ).rejects.toThrow( + "iCKB deposit minimum is 1000 CKB free capacity (1082 CKB total capacity)", + ); + }); + + it("keeps the protocol maximum on unoccupied capacity", async () => { + const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); + + await expect( + manager.deposit( + ccc.Transaction.default(), + 1, + ccc.fixedPointFrom(1000083), + script("33"), + testClient(), + ), + ).rejects.toThrow( + "iCKB deposit maximum is 1000000 CKB free capacity (1000082 CKB total capacity)", + ); + }); + + it("rejects non-safe-integer deposit quantities before allocation", async () => { + const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); + + for (const quantity of [1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + await expect( + manager.deposit( + ccc.Transaction.default(), + quantity, + ccc.fixedPointFrom(1082), + script("33"), + testClient(), + ), + ).rejects.toThrow("iCKB deposit quantity must be a safe integer"); + } + }); + + it("rejects deposit quantities that cannot fit in one DAO transaction", async () => { + const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), [])); + + await expect( + manager.deposit( + ccc.Transaction.default(), + 64, + ccc.fixedPointFrom(1082), + script("33"), + testClient(), + ), + ).rejects.toThrow("iCKB deposit quantity maximum is 63"); + }); +}); diff --git a/packages/core/test/logic/support/logic_support.ts b/packages/core/test/logic/support/logic_support.ts new file mode 100644 index 0000000..5b6045a --- /dev/null +++ b/packages/core/test/logic/support/logic_support.ts @@ -0,0 +1,53 @@ +import { ccc } from "@ckb-ccc/core"; +import { + byte32FromByte, + headerLike, + script, + StubClient, + transactionWithHeader, +} from "@ickb/testkit"; +import { ReceiptData } from "../../../src/entities.ts"; +import { IckbUdt } from "../../../src/udt.ts"; + +export const LOGIC_MANAGER_DEPOSIT_SUITE = "LogicManager.deposit"; + +export function testClient(): ccc.Client { + return new StubClient(); +} + +export function noCellsOnChain(): ReturnType { + return cellsOf([]); +} + +export function receiptPair(logic: ccc.Script, lock: ccc.Script): [ccc.Cell, ccc.Cell] { + return [receiptCell("44", logic, lock), receiptCell("55", logic, lock)]; +} + +export function receiptPhase2Capacity(lock: ccc.Script): ccc.FixedPoint { + const plainCellCapacity = + BigInt( + ccc.CellAny.from({ + cellOutput: { lock }, + outputData: "0x", + }).occupiedSize, + ) * ccc.One; + return plainCellCapacity + IckbUdt.minimumXudtCellCapacity(lock) + ccc.One; +} + +async function* cellsOf(cells: readonly ccc.Cell[]): AsyncGenerator { + await Promise.resolve(); + yield* cells; +} + +function receiptCell(txHashByte: string, logic: ccc.Script, lock: ccc.Script): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte(txHashByte), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock, type: logic }, + outputData: ReceiptData.from({ + depositQuantity: 1, + depositAmount: ccc.fixedPointFrom(100000), + }).toBytes(), + }); +} + +export { byte32FromByte, headerLike, script, StubClient, transactionWithHeader }; diff --git a/packages/core/test/owned_owner/owned_owner.ts b/packages/core/test/owned_owner/owned_owner.ts new file mode 100644 index 0000000..9a0a2e2 --- /dev/null +++ b/packages/core/test/owned_owner/owned_owner.ts @@ -0,0 +1,153 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager } from "@ickb/dao"; +import { collect } from "@ickb/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OwnerCell } from "../../src/cells.ts"; +import { OwnerData } from "../../src/entities.ts"; +import { OwnedOwnerManager } from "../../src/owned_owner.ts"; +import { + FIND_WITHDRAWAL_GROUPS_SUITE, + headerLike, + script, + StubClient, +} from "./support/owned_owner_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(FIND_WITHDRAWAL_GROUPS_SUITE, () => { + registerOwnerDecodingTests(); + registerOwnerPageSizeTests(); + registerOwnerFilterTests(); +}); + +function registerOwnerDecodingTests(): void { + it("decodes owner relative distances from prefixed data", () => { + const ownerCell = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: `0x${"55".repeat(32)}`, index: 1n }, + cellOutput: { capacity: 61n, lock: script("11"), type: script("22") }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }), + ); + + expect(ownerCell.getOwned().index).toBe(0n); + }); +} + +function registerOwnerPageSizeTests(): void { + it("passes the cell page size to owner scanning", async () => { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const tip = headerLike(); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const firstOwner = ownerCell("55", ownerLock, ownedOwnerScript); + const secondOwner = ownerCell("66", ownerLock, ownedOwnerScript); + let requestedPageSize = 0; + const client = new StubClient({ + async *findCells( + _query, + _order, + pageSize = 10, + ): ReturnType { + requestedPageSize = pageSize; + await Promise.resolve(); + yield firstOwner; + yield secondOwner; + }, + getCell: async (): ReturnType => { + await Promise.resolve(); + return undefined; + }, + }); + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip, pageSize: 1 }), + ); + + expect(requestedPageSize).toBe(1); + expect(groups).toEqual([]); + }); +} + +function registerOwnerFilterTests(): void { + it("filters owners by owner type, owner lock, and scan mode", async () => { + const ownerLock = script("11"); + const otherLock = script("12"); + const ownedOwnerScript = script("22"); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(script("33"), []), + ); + const matchingOwner = ownerCell("55", ownerLock, ownedOwnerScript); + const shortDataOwner = ccc.Cell.from({ + outPoint: { txHash: `0x${"66".repeat(32)}`, index: 1n }, + cellOutput: { capacity: 61n, lock: ownerLock, type: ownedOwnerScript }, + outputData: "0x00", + }); + const wrongLockOwner = ownerCell("77", otherLock, ownedOwnerScript); + const wrongTypeOwner = ownerCell("88", ownerLock, script("44")); + const impossibleOwner = ccc.Cell.from({ + outPoint: { txHash: `0x${"99".repeat(32)}`, index: 0n }, + cellOutput: { capacity: 61n, lock: ownerLock, type: ownedOwnerScript }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); + let onChainPageSize = 0; + const client = new StubClient({ + getTipHeader: async (): ReturnType => { + await Promise.resolve(); + return headerLike(); + }, + async *findCellsOnChain( + _query, + _order, + pageSize = 10, + ): ReturnType { + onChainPageSize = pageSize; + await Promise.resolve(); + yield matchingOwner; + yield shortDataOwner; + yield wrongLockOwner; + yield wrongTypeOwner; + yield impossibleOwner; + }, + getCell: async (): ReturnType => { + await Promise.resolve(); + return undefined; + }, + }); + + expect(manager.isOwner(matchingOwner)).toBe(true); + expect(manager.isOwner(shortDataOwner)).toBe(false); + expect(manager.isOwner(impossibleOwner)).toBe(false); + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock, ownerLock], { + onChain: true, + pageSize: 3, + }), + ); + + expect(onChainPageSize).toBe(3); + expect(groups).toEqual([]); + }); +} + +function ownerCell( + txHashByte: string, + ownerLock: ccc.Script, + ownedOwnerScript: ccc.Script, +): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: `0x${txHashByte.repeat(32)}`, index: 1n }, + cellOutput: { capacity: 61n, lock: ownerLock, type: ownedOwnerScript }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); +} diff --git a/packages/core/test/owned_owner/owned_owner_find_concurrency.ts b/packages/core/test/owned_owner/owned_owner_find_concurrency.ts new file mode 100644 index 0000000..ad7f194 --- /dev/null +++ b/packages/core/test/owned_owner/owned_owner_find_concurrency.ts @@ -0,0 +1,246 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager } from "@ickb/dao"; +import { collect } from "@ickb/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OwnedOwnerManager } from "../../src/owned_owner.ts"; +import { + FIND_WITHDRAWAL_GROUPS_SUITE, + headerLike, + ownedWithdrawalCell, + ownerMarkerCell, + script, + StubClient, + transactionWithHeader, + type TransactionWithHeader, + twoOwnerPendingPair, + twoOwnerWithdrawalFixture, +} from "./support/owned_owner_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(FIND_WITHDRAWAL_GROUPS_SUITE, () => { + registerReferencedCellConcurrencyTests(); + registerWithdrawalDecodeConcurrencyTests(); + registerHeaderDeduplicationTests(); +}); + +function registerReferencedCellConcurrencyTests(): void { + it("fetches referenced owned cells concurrently and yields in owner scan order", async () => { + const fixture = twoOwnerWithdrawalFixture(); + const { manager, ownerLock, tip, firstOwner, secondOwner, firstOwned, secondOwned } = + fixture; + const { promise: firstFetch, resolve: resolveFirst } = Promise.withResolvers< + ccc.Cell | undefined + >(); + const { promise: secondFetch, resolve: resolveSecond } = Promise.withResolvers< + ccc.Cell | undefined + >(); + const pending = new Map([ + [firstOwned.outPoint.toHex(), firstFetch], + [secondOwned.outPoint.toHex(), secondFetch], + ]); + const fetches: ccc.OutPoint[] = []; + const client = new StubClient({ + getTipHeader: async (): ReturnType => { + await Promise.resolve(); + return tip; + }, + async *findCells(): ReturnType { + await Promise.resolve(); + yield firstOwner; + yield secondOwner; + }, + getCell: async (outPoint): ReturnType => { + const normalized = ccc.OutPoint.from(outPoint); + fetches.push(normalized); + const fetch = pending.get(normalized.toHex()); + if (fetch === undefined) { + throw new Error("Unexpected getCell out point"); + } + return fetch; + }, + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return fixture.depositHeader; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(fixture.withdrawalHeader); + }, + }); + + const groupsPromise = collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + await vi.waitFor(() => { + expect(fetches).toHaveLength(2); + }); + expect(fetches.map((outPoint) => outPoint.toHex())).toEqual([ + firstOwned.outPoint.toHex(), + secondOwned.outPoint.toHex(), + ]); + resolveSecond(secondOwned); + await Promise.resolve(); + resolveFirst(firstOwned); + + const groups = await groupsPromise; + + expect(groups.map((group) => group.owner.cell.outPoint.toHex())).toEqual([ + firstOwner.outPoint.toHex(), + secondOwner.outPoint.toHex(), + ]); + }); +} + +function registerWithdrawalDecodeConcurrencyTests(): void { + it("decodes referenced withdrawals concurrently and yields in owner scan order", async () => { + const fixture = twoOwnerWithdrawalFixture(); + const { manager, ownerLock, tip, firstOwner, secondOwner, firstOwned, secondOwned } = + fixture; + const referencedCells = new Map([ + [firstOwned.outPoint.toHex(), firstOwned], + [secondOwned.outPoint.toHex(), secondOwned], + ]); + const headerRequests: ccc.Hex[] = []; + const { + first: firstWithdrawalFetch, + second: secondWithdrawalFetch, + resolveFirst, + resolveSecond, + } = twoOwnerPendingPair(); + const client = twoOwnerScanClient({ + tip, + firstOwner, + secondOwner, + referencedCells, + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return fixture.depositHeader; + }, + getTransactionWithHeader: async ( + txHash, + ): ReturnType => { + const hash = ccc.hexFrom(txHash); + headerRequests.push(hash); + return hash === firstOwned.outPoint.txHash + ? firstWithdrawalFetch + : secondWithdrawalFetch; + }, + }); + + const groupsPromise = collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + await vi.waitFor(() => { + expect(headerRequests).toEqual([ + firstOwned.outPoint.txHash, + secondOwned.outPoint.txHash, + ]); + }); + resolveSecond(transactionWithHeader(fixture.withdrawalHeader)); + await Promise.resolve(); + resolveFirst(transactionWithHeader(fixture.withdrawalHeader)); + + const groups = await groupsPromise; + + expect(groups.map((group) => group.owner.cell.outPoint.toHex())).toEqual([ + firstOwner.outPoint.toHex(), + secondOwner.outPoint.toHex(), + ]); + }); +} + +function registerHeaderDeduplicationTests(): void { + it("deduplicates referenced withdrawal header lookups during a scan", async () => { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const tip = headerLike(); + const firstOwner = ownerMarkerCell("88", 1n, ownerLock, ownedOwnerScript); + const secondOwner = ownerMarkerCell("88", 3n, ownerLock, ownedOwnerScript); + const firstOwned = ownedWithdrawalCell({ + txHashByte: "88", + index: 0n, + ownedOwnerScript, + daoScript, + depositHeaderNumber: 1n, + }); + const secondOwned = ownedWithdrawalCell({ + txHashByte: "88", + index: 2n, + ownedOwnerScript, + daoScript, + depositHeaderNumber: 1n, + }); + const referencedCells = new Map([ + [firstOwned.outPoint.toHex(), firstOwned], + [secondOwned.outPoint.toHex(), secondOwned], + ]); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + let headerCalls = 0; + let transactionCalls = 0; + const client = twoOwnerScanClient({ + tip, + firstOwner, + secondOwner, + referencedCells, + getHeaderByNumber: async (): ReturnType => { + headerCalls += 1; + await Promise.resolve(); + return headerLike({ number: 1n }); + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + transactionCalls += 1; + await Promise.resolve(); + return transactionWithHeader(headerLike({ number: 2n })); + }, + }); + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toHaveLength(2); + expect(headerCalls).toBe(1); + expect(transactionCalls).toBe(1); + }); +} + +function twoOwnerScanClient(options: { + tip: ccc.ClientBlockHeader; + firstOwner: ccc.Cell; + secondOwner: ccc.Cell; + referencedCells: Map; + getHeaderByNumber: ccc.Client["getHeaderByNumber"]; + getTransactionWithHeader: ccc.Client["getTransactionWithHeader"]; +}): ccc.Client { + return new StubClient({ + getTipHeader: async (): ReturnType => { + await Promise.resolve(); + return options.tip; + }, + async *findCells(): ReturnType { + await Promise.resolve(); + yield options.firstOwner; + yield options.secondOwner; + }, + getCell: async (outPoint): ReturnType => { + await Promise.resolve(); + return options.referencedCells.get(ccc.OutPoint.from(outPoint).toHex()); + }, + getHeaderByNumber: options.getHeaderByNumber, + getTransactionWithHeader: options.getTransactionWithHeader, + }); +} diff --git a/packages/core/test/owned_owner/owned_owner_find_filters.ts b/packages/core/test/owned_owner/owned_owner_find_filters.ts new file mode 100644 index 0000000..c2be22c --- /dev/null +++ b/packages/core/test/owned_owner/owned_owner_find_filters.ts @@ -0,0 +1,265 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager } from "@ickb/dao"; +import { collect } from "@ickb/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OwnedOwnerManager } from "../../src/owned_owner.ts"; +import { ickbValue } from "../../src/udt.ts"; +import { + byte32FromByte, + FIND_WITHDRAWAL_GROUPS_SUITE, + headerLike, + ownedWithdrawalCell, + ownerMarkerCell, + script, + StubClient, + transactionWithHeader, +} from "./support/owned_owner_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(FIND_WITHDRAWAL_GROUPS_SUITE, () => { + registerOwnerLockFilterTests(); + registerPreDecodeFilterTests(); + registerWithdrawalTypeFilterTests(); + registerWithdrawalValueTests(); +}); + +function registerOwnerLockFilterTests(): void { + it("skips owners whose referenced withdrawal is not locked by Owned Owner", async () => { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const tip = headerLike(); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const ownerCell = ownerMarkerCell("55", 1n, ownerLock, ownedOwnerScript); + const fakeOwned = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("44"), + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + const client = clientWithSingleOwned(tip, ownerCell, fakeOwned); + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toEqual([]); + }); +} + +function registerPreDecodeFilterTests(): void { + it("skips owner targets before DAO header decoding", async () => { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const tip = headerLike(); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const ownerCell = ownerMarkerCell("77", 1n, ownerLock, ownedOwnerScript); + const fakeOwned = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("77"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("44"), + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + let headerLookups = 0; + const client = new StubClient({ + getTipHeader: async (): ReturnType => { + await Promise.resolve(); + return tip; + }, + async *findCells(): ReturnType { + await Promise.resolve(); + yield ownerCell; + }, + getCell: async (): ReturnType => { + await Promise.resolve(); + return fakeOwned; + }, + getHeaderByNumber: async (): ReturnType => { + headerLookups += 1; + await Promise.resolve(); + return headerLike(); + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + headerLookups += 1; + await Promise.resolve(); + return transactionWithHeader(tip); + }, + }); + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toEqual([]); + expect(headerLookups).toBe(0); + }); +} + +function registerWithdrawalTypeFilterTests(): void { + it("skips referenced cells that are not DAO withdrawal requests", async () => { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const tip = headerLike(); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const firstOwner = ownerMarkerCell("55", 1n, ownerLock, ownedOwnerScript); + const secondOwner = ownerMarkerCell("66", 1n, ownerLock, ownedOwnerScript); + const deposit = referencedCell( + "55", + ownedOwnerScript, + daoScript, + DaoManager.depositData(), + ); + const foreignCell = referencedCell( + "66", + ownedOwnerScript, + script("44"), + ccc.mol.Uint64LE.encode(1n), + ); + const client = clientWithReferencedCells( + tip, + [firstOwner, secondOwner], + [deposit, foreignCell], + ); + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toEqual([]); + }); +} + +function registerWithdrawalValueTests(): void { + it("keeps withdrawal-group iCKB value from the referenced deposit header", async () => { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const tip = headerLike(); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const ownerCell = ownerMarkerCell("88", 1n, ownerLock, ownedOwnerScript); + const depositHeader = headerLike({ epoch: [1n, 0n, 1n], number: 1n }); + const withdrawalHeader = headerLike({ hash: byte32FromByte("99"), number: 2n }); + const owned = ownedWithdrawalCell({ + txHashByte: "88", + index: 0n, + ownedOwnerScript, + daoScript, + depositHeaderNumber: depositHeader.number, + }); + const client = new StubClient({ + getTipHeader: async (): ReturnType => { + await Promise.resolve(); + return tip; + }, + async *findCells(): ReturnType { + await Promise.resolve(); + yield ownerCell; + }, + getCell: async (): ReturnType => { + await Promise.resolve(); + return owned; + }, + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return depositHeader; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(withdrawalHeader); + }, + }); + + const groups = await collect( + manager.findWithdrawalGroups(client, [ownerLock], { tip }), + ); + + expect(groups).toHaveLength(1); + expect(groups[0]?.udtValue).toBe(ickbValue(owned.capacityFree, depositHeader)); + }); +} + +function clientWithSingleOwned( + tip: ccc.ClientBlockHeader, + owner: ccc.Cell, + owned: ccc.Cell, +): ccc.Client { + return clientWithReferencedCells(tip, [owner], [owned]); +} + +function clientWithReferencedCells( + tip: ccc.ClientBlockHeader, + owners: ccc.Cell[], + referenced: ccc.Cell[], +): ccc.Client { + const referencedCells = new Map( + referenced.map((cell) => [cell.outPoint.toHex(), cell]), + ); + return new StubClient({ + getTipHeader: async (): ReturnType => { + await Promise.resolve(); + return tip; + }, + async *findCells(): ReturnType { + await Promise.resolve(); + yield* owners; + }, + getCell: async (outPoint): ReturnType => { + await Promise.resolve(); + return referencedCells.get(ccc.OutPoint.from(outPoint).toHex()); + }, + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return headerLike(); + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(tip); + }, + }); +} + +function referencedCell( + txHashByte: string, + lock: ccc.Script, + type: ccc.Script, + outputData: ccc.BytesLike, +): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte(txHashByte), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock, type }, + outputData, + }); +} diff --git a/packages/core/test/owned_owner/owned_owner_request.ts b/packages/core/test/owned_owner/owned_owner_request.ts new file mode 100644 index 0000000..9a4113e --- /dev/null +++ b/packages/core/test/owned_owner/owned_owner_request.ts @@ -0,0 +1,423 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager, type DaoWithdrawalRequestCell } from "@ickb/dao"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OwnerCell, WithdrawalGroup } from "../../src/cells.ts"; +import { OwnerData } from "../../src/entities.ts"; +import { OwnedOwnerManager } from "../../src/owned_owner.ts"; +import { + byte32FromByte, + clientForDepositHeader, + depositCell, + headerLike, + REQUEST_WITHDRAWAL_SUITE, + requestWithdrawalFixture, + script, +} from "./support/owned_owner_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(REQUEST_WITHDRAWAL_SUITE, () => { + registerOwnerDistanceTests(); + registerLiveDepositAnchorTests(); + registerWithdrawalDepositValidationTests(); + registerWithdrawalInputTests(); + registerMalformedDaoManagerTests(); + registerWithdrawalRequestSelectionTests(); +}); + +function registerOwnerDistanceTests(): void { + it("encodes owner distances from the actual withdrawal output indexes", async () => { + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const ownerLock = script("44"); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new DaoManager(daoScript, []), + ); + const depositHeader = headerLike({ number: 1n }); + const deposits = [ + depositCell("55", ownedOwnerScript, daoScript, depositHeader), + depositCell("66", ownedOwnerScript, daoScript, depositHeader), + ]; + const baseTx = ccc.Transaction.default(); + baseTx.addInput({ previousOutput: { txHash: byte32FromByte("77"), index: 0n } }); + baseTx.addOutput({ capacity: 1n, lock: ownerLock }, "0x"); + + const tx = await manager.requestWithdrawal( + baseTx, + deposits, + ownerLock, + clientForDepositHeader(depositHeader), + ); + + expect(tx.outputsData.slice(3)).toEqual([ + ccc.hexFrom(OwnerData.encode({ ownedDistance: -2n })), + ccc.hexFrom(OwnerData.encode({ ownedDistance: -2n })), + ]); + const [, , , ownerAOutput, ownerBOutput] = tx.outputs; + const [, , , ownerAData, ownerBData] = tx.outputsData; + if ( + ownerAOutput === undefined || + ownerAData === undefined || + ownerBOutput === undefined || + ownerBData === undefined + ) { + throw new Error("Expected owner outputs"); + } + const ownerA = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("99"), index: 3n }, + cellOutput: ownerAOutput, + outputData: ownerAData, + }), + ); + const ownerB = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("99"), index: 4n }, + cellOutput: ownerBOutput, + outputData: ownerBData, + }), + ); + expect(ownerA.getOwned().index).toBe(1n); + expect(ownerB.getOwned().index).toBe(2n); + }); +} + +function registerLiveDepositAnchorTests(): void { + it("adds required live deposit anchors as cell deps", async () => { + const { manager, ownerLock, depositHeader, requestedDeposit, requiredLiveDeposit } = + requestWithdrawalFixture(); + + const tx = await manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { requiredLiveDeposits: [requiredLiveDeposit] }, + ); + + expect(tx.cellDeps).toContainEqual( + ccc.CellDep.from({ outPoint: requiredLiveDeposit.cell.outPoint, depType: "code" }), + ); + }); +} + +function registerWithdrawalDepositValidationTests(): void { + it("leaves transactions unchanged when no withdrawal groups are selected", async () => { + const { manager, depositHeader } = requestWithdrawalFixture(); + const baseTx = ccc.Transaction.default(); + const notReadyGroup = withdrawalGroupFixture({ + isReady: false, + daoScript: manager.daoManager.script, + ownedOwnerScript: manager.script, + depositHeader, + }); + + await expect( + manager.withdraw(baseTx, [notReadyGroup], clientForDepositHeader(depositHeader), { + isReadyOnly: true, + }), + ).resolves.toEqual(baseTx); + await expect( + manager.withdraw(baseTx, [], clientForDepositHeader(depositHeader)), + ).resolves.toEqual(baseTx); + }); +} + +function registerWithdrawalInputTests(): void { + registerSelectedWithdrawalInputTest(); + registerTypelessOwnerInputTest(); + registerMismatchedOwnerInputTest(); +} + +function registerSelectedWithdrawalInputTest(): void { + it("adds selected owned withdrawals and owner marker inputs", async () => { + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const ownerLock = script("44"); + const depositHeader = headerLike({ number: 1n }); + const manager = new OwnedOwnerManager( + ownedOwnerScript, + [], + new FastWithdrawDaoManager(daoScript, []), + ); + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("aa"), index: 1n }, + cellOutput: { capacity: 61n, lock: ownerLock, type: ownedOwnerScript }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }), + ); + const ownedCell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("aa"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }); + const owned = await manager.daoManager.withdrawalRequestCellFrom( + ownedCell, + clientForDepositHeader(depositHeader), + { tip: depositHeader }, + ); + + const tx = await manager.withdraw( + ccc.Transaction.default(), + [new WithdrawalGroup(owned, owner)], + clientForDepositHeader(depositHeader), + ); + + expect(tx.inputs.map((input) => input.previousOutput.toHex())).toContain( + owner.cell.outPoint.toHex(), + ); + }); +} + +function registerTypelessOwnerInputTest(): void { + it("adds owner marker inputs that have no type script", async () => { + const { manager, ownerLock, depositHeader } = requestWithdrawalFixture(); + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("ab"), index: 1n }, + cellOutput: { capacity: 61n, lock: ownerLock }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }), + ); + const owned = await manager.daoManager.withdrawalRequestCellFrom( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("ab"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: manager.script, + type: manager.daoManager.script, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }), + clientForDepositHeader(depositHeader), + { tip: depositHeader }, + ); + + const tx = await manager.withdraw( + ccc.Transaction.default(), + [new WithdrawalGroup(owned, owner)], + clientForDepositHeader(depositHeader), + ); + + expect(tx.inputs.map((input) => input.previousOutput.toHex())).toContain( + owner.cell.outPoint.toHex(), + ); + }); +} + +function registerMismatchedOwnerInputTest(): void { + it("rejects withdrawal groups whose owner points at a different owned cell", async () => { + const { manager, ownerLock, depositHeader } = requestWithdrawalFixture(); + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("ac"), index: 2n }, + cellOutput: { capacity: 61n, lock: ownerLock, type: manager.script }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }), + ); + const owned = await manager.daoManager.withdrawalRequestCellFrom( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("ac"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: manager.script, + type: manager.daoManager.script, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }), + clientForDepositHeader(depositHeader), + { tip: depositHeader }, + ); + + await expect( + manager.withdraw( + ccc.Transaction.default(), + [new WithdrawalGroup(owned, owner)], + clientForDepositHeader(depositHeader), + ), + ).rejects.toThrow( + `Withdrawal owner ${owner.cell.outPoint.toHex()} points to ${owner.getOwned().toHex()} but group owned cell is ${owned.cell.outPoint.toHex()}`, + ); + }); +} + +function registerMalformedDaoManagerTests(): void { + it("rejects DAO withdrawal managers that do not add request outputs", async () => { + const ownerLock = script("44"); + const depositHeader = headerLike({ number: 1n }); + const requestedDeposit = depositCell("55", script("22"), script("33"), depositHeader); + const manager = new OwnedOwnerManager( + script("22"), + [], + new NoRequestOutputDaoManager(script("33")), + ); + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + ), + ).rejects.toThrow("DAO withdrawal request did not add expected outputs"); + }); + + it("rejects DAO withdrawal managers that add malformed request outputs", async () => { + const ownerLock = script("44"); + const depositHeader = headerLike({ number: 1n }); + const ownerScript = script("22"); + const daoScript = script("33"); + const requestedDeposit = depositCell("55", ownerScript, daoScript, depositHeader); + const manager = new OwnedOwnerManager( + ownerScript, + [], + new MalformedRequestOutputDaoManager(daoScript), + ); + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + ), + ).rejects.toThrow("DAO withdrawal request output order changed"); + }); +} + +function registerWithdrawalRequestSelectionTests(): void { + it("filters not-ready deposits when requesting ready withdrawals only", async () => { + const { manager, ownerLock, depositHeader, requestedDeposit } = + requestWithdrawalFixture(); + const notReadyDeposit = { ...requestedDeposit, isReady: false }; + const tx = await manager.requestWithdrawal( + ccc.Transaction.default(), + [notReadyDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { isReadyOnly: true }, + ); + + expect(tx.outputs).toEqual([]); + }); + + it("rejects duplicated or already spent withdrawal deposits", async () => { + const { manager, ownerLock, depositHeader, requestedDeposit } = + requestWithdrawalFixture(); + const spentTx = ccc.Transaction.default(); + spentTx.addInput(requestedDeposit.cell); + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit, requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + ), + ).rejects.toThrow("Withdrawal deposit is duplicated"); + await expect( + manager.requestWithdrawal( + spentTx, + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + ), + ).rejects.toThrow("Withdrawal deposit is already being spent"); + }); +} + +function withdrawalGroupFixture({ + isReady, + daoScript, + ownedOwnerScript, + depositHeader, +}: WithdrawalGroupFixtureOptions): WithdrawalGroup { + const ownedCell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("ef"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }); + const owner = new OwnerCell( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("ef"), index: 1n }, + cellOutput: { capacity: 61n, lock: script("44"), type: ownedOwnerScript }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }), + ); + const owned: DaoWithdrawalRequestCell = { + cell: ownedCell, + headers: [ + { header: depositHeader }, + { header: depositHeader, txHash: ownedCell.outPoint.txHash }, + ], + ckbValue: ownedCell.cellOutput.capacity, + udtValue: 0n, + interests: 0n, + maturity: depositHeader.epoch, + isDeposit: false, + isReady, + }; + return new WithdrawalGroup(owned, owner); +} + +class NoRequestOutputDaoManager extends DaoManager { + constructor(daoScript: ccc.Script) { + super(daoScript, []); + } + + public override async requestWithdrawal( + txLike: ccc.TransactionLike, + ): Promise { + await Promise.resolve(); + return ccc.Transaction.from(txLike); + } +} + +class MalformedRequestOutputDaoManager extends DaoManager { + constructor(daoScript: ccc.Script) { + super(daoScript, []); + } + + public override async requestWithdrawal( + txLike: ccc.TransactionLike, + ): Promise { + await Promise.resolve(); + const tx = ccc.Transaction.from(txLike); + tx.addOutput({ capacity: 1n, lock: script("99"), type: this.script }, "0x"); + return tx; + } +} + +class FastWithdrawDaoManager extends DaoManager { + public override async withdraw( + txLike: ccc.TransactionLike | ccc.Transaction, + withdrawalRequests: DaoWithdrawalRequestCell[], + ): Promise { + await Promise.resolve(); + const tx = ccc.Transaction.from(txLike); + for (const withdrawalRequest of withdrawalRequests) { + tx.addInput(withdrawalRequest.cell); + } + return tx; + } +} + +interface WithdrawalGroupFixtureOptions { + isReady: boolean; + daoScript: ccc.Script; + ownedOwnerScript: ccc.Script; + depositHeader: ccc.ClientBlockHeader; +} diff --git a/packages/core/test/owned_owner/owned_owner_request_anchors.ts b/packages/core/test/owned_owner/owned_owner_request_anchors.ts new file mode 100644 index 0000000..a52809f --- /dev/null +++ b/packages/core/test/owned_owner/owned_owner_request_anchors.ts @@ -0,0 +1,72 @@ +import { ccc } from "@ckb-ccc/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { IckbDepositCell } from "../../src/cells.ts"; +import { + clientForDepositHeader, + REQUEST_WITHDRAWAL_SUITE, + requestWithdrawalFixture, +} from "./support/owned_owner_support.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe(REQUEST_WITHDRAWAL_SUITE, () => { + it("rejects duplicated or already spent required live deposit anchors", async () => { + const { manager, ownerLock, depositHeader, requestedDeposit, requiredLiveDeposit } = + requestWithdrawalFixture(); + const spentTx = ccc.Transaction.default(); + spentTx.addInput(requiredLiveDeposit.cell); + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { requiredLiveDeposits: [requiredLiveDeposit, requiredLiveDeposit] }, + ), + ).rejects.toThrow("Withdrawal live deposit anchor is duplicated"); + await expect( + manager.requestWithdrawal( + spentTx, + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { + requiredLiveDeposits: [requiredLiveDeposit], + }, + ), + ).rejects.toThrow("Withdrawal live deposit anchor is also being spent"); + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { requiredLiveDeposits: [requestedDeposit] }, + ), + ).rejects.toThrow("Withdrawal live deposit anchor is also being spent"); + }); + + it("allows not-ready required live deposit anchors", async () => { + const { manager, ownerLock, depositHeader, requestedDeposit, requiredLiveDeposit } = + requestWithdrawalFixture(); + const notReadyLiveDeposit: IckbDepositCell = { + ...requiredLiveDeposit, + isReady: false, + }; + + const tx = await manager.requestWithdrawal( + ccc.Transaction.default(), + [requestedDeposit], + ownerLock, + clientForDepositHeader(depositHeader), + { requiredLiveDeposits: [notReadyLiveDeposit] }, + ); + + expect(tx.cellDeps).toContainEqual( + ccc.CellDep.from({ outPoint: notReadyLiveDeposit.cell.outPoint, depType: "code" }), + ); + }); +}); diff --git a/packages/core/test/owned_owner/support/owned_owner_support.ts b/packages/core/test/owned_owner/support/owned_owner_support.ts new file mode 100644 index 0000000..89aac4b --- /dev/null +++ b/packages/core/test/owned_owner/support/owned_owner_support.ts @@ -0,0 +1,187 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager } from "@ickb/dao"; +import { + byte32FromByte, + headerLike, + script, + StubClient, + transactionWithHeader, + type TransactionWithHeader, +} from "@ickb/testkit"; +import { ickbDepositCellFrom, type IckbDepositCell } from "../../../src/cells.ts"; +import { OwnerData } from "../../../src/entities.ts"; +import { OwnedOwnerManager } from "../../../src/owned_owner.ts"; + +export const FIND_WITHDRAWAL_GROUPS_SUITE = "OwnedOwnerManager.findWithdrawalGroups"; +export const REQUEST_WITHDRAWAL_SUITE = "OwnedOwnerManager.requestWithdrawal"; + +export function twoOwnerWithdrawalFixture(): TwoOwnerWithdrawalFixture { + const ownerLock = script("11"); + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const depositHeader = headerLike({ epoch: [1n, 0n, 1n], number: 1n }); + return { + manager: new OwnedOwnerManager(ownedOwnerScript, [], new DaoManager(daoScript, [])), + ownerLock, + tip: headerLike(), + depositHeader, + withdrawalHeader: headerLike({ hash: byte32FromByte("aa"), number: 2n }), + firstOwner: ownerMarkerCell("88", 1n, ownerLock, ownedOwnerScript), + secondOwner: ownerMarkerCell("99", 1n, ownerLock, ownedOwnerScript), + firstOwned: ownedWithdrawalCell({ + txHashByte: "88", + index: 0n, + ownedOwnerScript, + daoScript, + depositHeaderNumber: depositHeader.number, + }), + secondOwned: ownedWithdrawalCell({ + txHashByte: "99", + index: 0n, + ownedOwnerScript, + daoScript, + depositHeaderNumber: depositHeader.number, + }), + }; +} + +export function twoOwnerPendingPair(): TwoOwnerPendingPair { + const { promise: first, resolve: resolveFirst } = Promise.withResolvers(); + const { promise: second, resolve: resolveSecond } = Promise.withResolvers(); + return { first, second, resolveFirst, resolveSecond }; +} + +export function requestWithdrawalFixture(): RequestWithdrawalFixture { + const ownedOwnerScript = script("22"); + const daoScript = script("33"); + const ownerLock = script("44"); + const depositHeader = headerLike({ number: 1n }); + return { + manager: new OwnedOwnerManager(ownedOwnerScript, [], new DaoManager(daoScript, [])), + ownerLock, + depositHeader, + requestedDeposit: depositCell("55", ownedOwnerScript, daoScript, depositHeader), + requiredLiveDeposit: depositCell("66", ownedOwnerScript, daoScript, depositHeader), + }; +} + +export function depositCell( + txHashByte: string, + lock: ccc.Script, + dao: ccc.Script, + depositHeader: ccc.ClientBlockHeader, +): IckbDepositCell { + const txHash = byte32FromByte(txHashByte); + const cell = ccc.Cell.from({ + outPoint: { txHash, index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock, + type: dao, + }, + outputData: DaoManager.depositData(), + }); + return ickbDepositCellFrom( + { + cell, + headers: [{ header: depositHeader, txHash }, { header: depositHeader }], + ckbValue: cell.cellOutput.capacity, + udtValue: 0n, + interests: 0n, + maturity: depositHeader.epoch, + isDeposit: true, + isReady: true, + }, + lock, + ); +} + +export function ownerMarkerCell( + txHashByte: string, + index: bigint, + ownerLock: ccc.Script, + ownedOwnerScript: ccc.Script, +): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte(txHashByte), index }, + cellOutput: { + capacity: 61n, + lock: ownerLock, + type: ownedOwnerScript, + }, + outputData: OwnerData.from({ ownedDistance: -1n }).toBytes(), + }); +} + +export function ownedWithdrawalCell({ + txHashByte, + index, + ownedOwnerScript, + daoScript, + depositHeaderNumber, +}: OwnedWithdrawalCellOptions): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte(txHashByte), index }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: ownedOwnerScript, + type: daoScript, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeaderNumber), + }); +} + +export function clientForDepositHeader(depositHeader: ccc.ClientBlockHeader): ccc.Client { + return new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(depositHeader); + }, + }); +} + +interface OwnedWithdrawalCellOptions { + txHashByte: string; + index: bigint; + ownedOwnerScript: ccc.Script; + daoScript: ccc.Script; + depositHeaderNumber: bigint; +} + +interface TwoOwnerWithdrawalFixture { + manager: OwnedOwnerManager; + ownerLock: ccc.Script; + tip: ccc.ClientBlockHeader; + depositHeader: ccc.ClientBlockHeader; + withdrawalHeader: ccc.ClientBlockHeader; + firstOwner: ccc.Cell; + secondOwner: ccc.Cell; + firstOwned: ccc.Cell; + secondOwned: ccc.Cell; +} + +interface RequestWithdrawalFixture { + manager: OwnedOwnerManager; + ownerLock: ccc.Script; + depositHeader: ccc.ClientBlockHeader; + requestedDeposit: IckbDepositCell; + requiredLiveDeposit: IckbDepositCell; +} + +interface TwoOwnerPendingPair { + first: Promise; + second: Promise; + resolveFirst: (value: T | PromiseLike) => void; + resolveSecond: (value: T | PromiseLike) => void; +} + +export { + byte32FromByte, + headerLike, + script, + StubClient, + transactionWithHeader, + type TransactionWithHeader, +}; diff --git a/packages/core/test/udt/udt_complete.ts b/packages/core/test/udt/udt_complete.ts new file mode 100644 index 0000000..64f1479 --- /dev/null +++ b/packages/core/test/udt/udt_complete.ts @@ -0,0 +1,335 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager } from "@ickb/dao"; +import { describe, expect, it } from "vitest"; +import { convert, ickbExchangeRatio, IckbUdt, ickbValue } from "../../src/udt.ts"; +import { + byte32FromByte, + clientWithHeader, + headerLike, + RECEIPT_PREFIX_DECODING_SUITE, + receiptCell, + receiptOutputData, + script, + signerWithCells, + StubClient, + xudtCell, +} from "../cells/support/cells_support.ts"; + +describe(RECEIPT_PREFIX_DECODING_SUITE, () => { + registerCompleteByCollectionTests(); + registerCompleteByErrorTests(); + registerCompleteByContextErrorTests(); + registerCompleteByProtocolInputTests(); +}); + +function registerCompleteByCollectionTests(): void { + it("completeBy ignores unrelated inputs and outputs", async () => { + const { ickbUdt, type } = testIckbUdt(); + const unrelatedInput = xudtCell(100n, script("aa")); + unrelatedInput.outPoint.index = 1n; + const tx = ccc.Transaction.from({ + inputs: [unrelatedInput], + outputs: [ + { lock: script("22"), type: script("bb") }, + { lock: script("22"), type }, + ], + outputsData: [ccc.numLeToBytes(900n, 16), ccc.numLeToBytes(100n, 16)], + }); + const signer = signerWithCells( + [xudtCell(100n, type)], + clientWithHeader(headerLike(1n)), + ); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(2); + expect(completed.outputs).toHaveLength(2); + }); + + it("completeBy does not add change or second input on exact xUDT match", async () => { + const { ickbUdt, type } = testIckbUdt(); + const tx = ccc.Transaction.from({ + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(100n, 16)], + }); + const signer = signerWithCells( + [xudtCell(100n, type), xudtCell(50n, type, script("23"))], + clientWithHeader(headerLike(1n)), + ); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(1); + expect(completed.outputs).toHaveLength(1); + }); + + it("completeBy changes existing two xUDT input surplus without collecting more", async () => { + const { ickbUdt, type } = testIckbUdt(); + const firstInput = xudtCell(80n, type); + const secondInput = xudtCell(50n, type, script("23")); + secondInput.outPoint.index = 1n; + const tx = ccc.Transaction.from({ + inputs: [firstInput, secondInput], + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(100n, 16)], + }); + const signer = signerWithCells( + [xudtCell(200n, type, script("24"))], + clientWithHeader(headerLike(1n)), + ); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(2); + expect(completed.outputsData).toContain(ccc.hexFrom(ccc.numLeToBytes(30n, 16))); + }); +} + +function registerCompleteByErrorTests(): void { + it("completeBy throws when iCKB balance is insufficient", async () => { + const { ickbUdt, type } = testIckbUdt(); + const tx = ccc.Transaction.from({ + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(100n, 16)], + }); + const signer = signerWithCells( + [xudtCell(40n, type)], + clientWithHeader(headerLike(1n)), + ); + + await expect(ickbUdt.completeBy(tx, signer)).rejects.toThrow( + "Insufficient iCKB, need 60 more", + ); + }); + + it("completeBy throws when protocol input headers are unavailable", async () => { + const { ickbUdt, receipt, tx } = protocolReceiptCompletionCase(); + const signer = signerWithCells( + [], + new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return undefined; + }, + }), + ); + + await expect(ickbUdt.completeBy(tx, signer)).rejects.toThrow( + `Header not found for txHash ${receipt.outPoint.txHash} at ${receipt.outPoint.toHex()}`, + ); + }); + + it("completeBy rejects malformed receipt inputs with out point context", async () => { + const { ickbUdt, logic, type } = testIckbUdt(); + const receipt = receiptCell("0x12", logic); + const tx = ccc.Transaction.from({ + inputs: [receipt], + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(1n, 16)], + }); + const signer = signerWithCells([], clientWithHeader(headerLike(1n))); + + await expect(ickbUdt.completeBy(tx, signer)).rejects.toThrow( + `Invalid iCKB receipt payload at ${receipt.outPoint.toHex()}: 0x12`, + ); + }); + + it("ignores protocol-shaped inputs without out points", async () => { + const { ickbUdt, logic } = testIckbUdt(); + const tx = ccc.Transaction.default(); + tx.inputs.push(new DetachedProtocolInput(logic)); + const signer = signerWithCells([], clientWithHeader(headerLike(1n))); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(1); + }); +} + +function registerCompleteByContextErrorTests(): void { + it("completeBy preserves protocol input out point when header reads fail", async () => { + const { ickbUdt, receipt, tx } = protocolReceiptCompletionCase(); + const headerError = new Error("header rpc failed"); + const signer = signerWithCells( + [], + new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + throw headerError; + }, + }), + ); + + await expect(ickbUdt.completeBy(tx, signer)).rejects.toMatchObject({ + message: `Failed to load transaction header for txHash ${receipt.outPoint.txHash} at ${receipt.outPoint.toHex()}`, + cause: headerError, + }); + }); + + it("completeBy preserves input out point when existing input loading fails", async () => { + const { ickbUdt, type } = testIckbUdt(); + const inputError = new Error("source cell missing"); + const missingOutPoint = ccc.OutPoint.from({ + txHash: byte32FromByte("ac"), + index: 2n, + }); + const tx = ccc.Transaction.from({ + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(1n, 16)], + }); + tx.inputs.push(new MissingInput(missingOutPoint, inputError)); + const signer = signerWithCells([], new StubClient()); + + await expect(ickbUdt.completeBy(tx, signer)).rejects.toMatchObject({ + message: `Failed to load input cell ${missingOutPoint.toHex()}`, + cause: inputError, + }); + }); +} + +function protocolReceiptCompletionCase(): { + ickbUdt: IckbUdt; + receipt: ccc.Cell; + tx: ccc.Transaction; +} { + const { ickbUdt, logic, type } = testIckbUdt(); + const receipt = receiptCell(receiptOutputData(1, 100n), logic); + return { + ickbUdt, + receipt, + tx: ccc.Transaction.from({ + inputs: [receipt], + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(50n, 16)], + }), + }; +} + +class DetachedProtocolInput extends ccc.CellInput { + private readonly logic: ccc.Script; + + constructor(logic: ccc.Script) { + super(new ccc.OutPoint(byte32FromByte("ab"), 0n), 0n); + this.logic = logic; + } + + public override async getCell(): Promise { + await Promise.resolve(); + const cell = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("ab"), index: 0n }, + cellOutput: { capacity: 61n, lock: script("22"), type: this.logic }, + outputData: receiptOutputData(1, 100n), + }); + Reflect.deleteProperty(cell, "outPoint"); + return cell; + } +} + +class MissingInput extends ccc.CellInput { + private readonly error: Error; + + constructor(outPoint: ccc.OutPoint, error: Error) { + super(outPoint, 0n); + this.error = error; + } + + public override async getCell(): Promise { + await Promise.resolve(); + throw this.error; + } +} + +function registerCompleteByProtocolInputTests(): void { + it("completeBy values existing receipt inputs before adding xUDT inputs", async () => { + const { ickbUdt, logic, type } = testIckbUdt(); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const receipt = receiptCell(receiptOutputData(1, 100n), logic); + const tx = ccc.Transaction.from({ + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(150n, 16)], + }); + tx.addInput(receipt); + const signer = signerWithCells([xudtCell(100n, type)], clientWithHeader(header)); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(2); + expect(completed.outputsData).toContain(ccc.hexFrom(ccc.numLeToBytes(50n, 16))); + expect(completed.cellDeps).toHaveLength(2); + }); + + it("completeBy accounts deposit inputs as negative iCKB", async () => { + const logic = script("33"); + const dao = script("44"); + const type = script("55"); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const deposit = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("88"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock: logic, type: dao }, + outputData: "0x0000000000000000", + }); + const ickbUdt = new IckbUdt( + { txHash: byte32FromByte("44"), index: 1n }, + type, + { txHash: byte32FromByte("66"), index: 2n }, + logic, + new DaoManager(dao, []), + ); + const tx = ccc.Transaction.from({ + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(50n, 16)], + }); + tx.addInput(deposit); + const signer = signerWithCells( + [xudtCell(ickbValue(deposit.capacityFree, header) + 50n, type)], + clientWithHeader(header), + ); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(2); + expect(completed.outputs).toHaveLength(1); + }); +} + +function testIckbUdt(): { ickbUdt: IckbUdt; logic: ccc.Script; type: ccc.Script } { + const logic = script("33"); + const type = script("55"); + return { + ickbUdt: new IckbUdt( + { txHash: byte32FromByte("44"), index: 1n }, + type, + { txHash: byte32FromByte("66"), index: 2n }, + logic, + new DaoManager(script("77"), []), + ), + logic, + type, + }; +} + +describe("iCKB conversion", () => { + it("converts from iCKB to CKB using explicit ratios and header ratios", () => { + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + + expect(convert(false, 6n, { ckbScale: 2n, udtScale: 3n })).toBe(9n); + expect(convert(true, ccc.fixedPointFrom(1000), header, false)).toBe( + ccc.fixedPointFrom(1000), + ); + expect(ickbExchangeRatio(header).udtScale).toBeGreaterThan(header.dao.ar); + expect(ickbExchangeRatio(header, false).udtScale).toBe(header.dao.ar); + }); + + it("rejects non-positive exchange ratio scales", () => { + expect(() => convert(true, 1n, { ckbScale: 0n, udtScale: 1n })).toThrow( + "Exchange ratio scales must be positive", + ); + expect(() => convert(false, 1n, { ckbScale: 1n, udtScale: -1n })).toThrow( + "Exchange ratio scales must be positive", + ); + }); +}); diff --git a/packages/core/test/udt/udt_overfund.ts b/packages/core/test/udt/udt_overfund.ts new file mode 100644 index 0000000..f61f487 --- /dev/null +++ b/packages/core/test/udt/udt_overfund.ts @@ -0,0 +1,175 @@ +import { ccc } from "@ckb-ccc/core"; +import { DaoManager } from "@ickb/dao"; +import { describe, expect, it } from "vitest"; +import { IckbUdt } from "../../src/udt.ts"; +import { + byte32FromByte, + clientWithHeader, + headerLike, + RECEIPT_PREFIX_DECODING_SUITE, + receiptCell, + receiptOutputData, + script, + signerWithCells, + StubClient, + xudtCell, +} from "../cells/support/cells_support.ts"; + +describe(RECEIPT_PREFIX_DECODING_SUITE, () => { + registerUdtCellDepTests(); + registerUdtDetectionTests(); + registerCompleteByHeaderErrorTests(); + registerOverfundingTests(); +}); + +function registerUdtCellDepTests(): void { + it("adds xUDT and logic code deps explicitly", () => { + const { ickbUdt, logicCode, xudtCode } = testIckbUdt(); + + const tx = ickbUdt.addCellDeps(ccc.Transaction.default()); + + expect(tx.cellDeps).toHaveLength(2); + expect(tx.cellDeps[0]?.depType).toBe("code"); + expect(tx.cellDeps[0]?.outPoint.eq(ccc.OutPoint.from(xudtCode))).toBe(true); + expect(tx.cellDeps[1]?.depType).toBe("code"); + expect(tx.cellDeps[1]?.outPoint.eq(ccc.OutPoint.from(logicCode))).toBe(true); + }); + + it("does not duplicate xUDT and logic code deps", () => { + const { ickbUdt } = testIckbUdt(); + + const tx = ickbUdt.addCellDeps(ickbUdt.addCellDeps(ccc.Transaction.default())); + + expect(tx.cellDeps).toHaveLength(2); + }); +} + +function registerUdtDetectionTests(): void { + it("identifies only xUDT cells with UDT data", () => { + const { ickbUdt, logic, type } = testIckbUdt(); + + expect(ickbUdt.isUdt(xudtCell(1n, type))).toBe(true); + expect(ickbUdt.isUdt(receiptCell(receiptOutputData(1, 1n), logic))).toBe(false); + expect( + ickbUdt.isUdt( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("aa"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(100), lock: script("22"), type }, + outputData: "0x", + }), + ), + ).toBe(false); + }); +} + +function registerCompleteByHeaderErrorTests(): void { + it("completeBy throws when receipt header is missing", async () => { + const { ickbUdt, logic } = testIckbUdt(); + const tx = ccc.Transaction.default(); + const receipt = receiptCell(receiptOutputData(1, 40n), logic); + tx.addInput(receipt); + const signer = signerWithCells( + [], + new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return undefined; + }, + }), + ); + + await expect(ickbUdt.completeBy(tx, signer)).rejects.toThrow( + `Header not found for txHash ${receipt.outPoint.txHash} at ${receipt.outPoint.toHex()}`, + ); + }); +} + +function registerOverfundingTests(): void { + it("completeBy collects a second xUDT input when the first overfunds", async () => { + const { ickbUdt, type } = testIckbUdt(); + const tx = ccc.Transaction.from({ + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(100n, 16)], + }); + const secondInput = xudtCell(30n, type, script("23")); + secondInput.outPoint.index = 1n; + const signer = signerWithCells( + [xudtCell(150n, type), secondInput], + clientWithHeader(ccc.ClientBlockHeader.from(headerLike(10000000000000000n))), + ); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(2); + expect(completed.outputsData).toContain(ccc.hexFrom(ccc.numLeToBytes(80n, 16))); + }); + + it("does not count receipt inputs as xUDT inputs for overfunding", async () => { + const { ickbUdt, logic, type } = testIckbUdt(); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const tx = ccc.Transaction.from({ + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(100n, 16)], + }); + tx.addInput(receiptCell(receiptOutputData(1, 40n), logic)); + const secondInput = xudtCell(5n, type, script("23")); + secondInput.outPoint.index = 1n; + const signer = signerWithCells( + [xudtCell(80n, type), secondInput], + clientWithHeader(header), + ); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(3); + expect(completed.outputsData).toContain(ccc.hexFrom(ccc.numLeToBytes(25n, 16))); + }); + + it("does not count receipt inputs toward existing xUDT overfunding", async () => { + const { ickbUdt, logic, type } = testIckbUdt(); + const header = ccc.ClientBlockHeader.from(headerLike(10000000000000000n)); + const existingXudt = xudtCell(80n, type); + const tx = ccc.Transaction.from({ + inputs: [existingXudt], + outputs: [{ lock: script("22"), type }], + outputsData: [ccc.numLeToBytes(100n, 16)], + }); + tx.addInput(receiptCell(receiptOutputData(1, 40n), logic)); + const secondInput = xudtCell(5n, type, script("23")); + secondInput.outPoint.index = 1n; + const signer = signerWithCells([secondInput], clientWithHeader(header)); + + const completed = await ickbUdt.completeBy(tx, signer); + + expect(completed.inputs).toHaveLength(3); + expect(completed.outputsData).toContain(ccc.hexFrom(ccc.numLeToBytes(25n, 16))); + }); +} + +function testIckbUdt(): { + ickbUdt: IckbUdt; + logic: ccc.Script; + logicCode: ccc.OutPointLike; + type: ccc.Script; + xudtCode: ccc.OutPointLike; +} { + const logic = script("33"); + const type = script("55"); + const xudtCode = { txHash: byte32FromByte("44"), index: 1n }; + const logicCode = { txHash: byte32FromByte("66"), index: 2n }; + return { + ickbUdt: new IckbUdt( + xudtCode, + type, + logicCode, + logic, + new DaoManager(script("77"), []), + ), + logic, + logicCode, + type, + xudtCode, + }; +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..ca89ac2 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "removeComments": false, + "rewriteRelativeImportExtensions": true, + "rootDir": "src", + "outDir": "dist", + "sourceRoot": "../src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*_test_support.ts"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 84a843d..c6a53fe 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,11 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": false, - "rootDir": "src", - "outDir": "dist", - "sourceRoot": "../src" + "noEmit": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src", "test"] } diff --git a/packages/core/vitest.config.mts b/packages/core/vitest.config.mts index dc6a587..5c760ca 100644 --- a/packages/core/vitest.config.mts +++ b/packages/core/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}", "test/{cells,logic,owned_owner,udt}/*.{ts,tsx}"], coverage: { - include: ["src/**/*.ts"], + include: ["src/**/*.{ts,tsx}"], }, }, }); diff --git a/packages/dao/api-extractor.json b/packages/dao/api-extractor.json new file mode 100644 index 0000000..d842b4d --- /dev/null +++ b/packages/dao/api-extractor.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../api-extractor.base.json", + "mainEntryPointFilePath": "/dist/index.d.ts" +} diff --git a/packages/dao/package.json b/packages/dao/package.json index 9131d71..0a8286c 100644 --- a/packages/dao/package.json +++ b/packages/dao/package.json @@ -13,36 +13,43 @@ "homepage": "https://ickb.org", "repository": { "type": "git", - "url": "https://github.com/ickb/stack" + "url": "git+https://github.com/ickb/stack.git" }, "bugs": { "url": "https://github.com/ickb/stack/issues" }, "sideEffects": false, "type": "module", - "main": "dist/index.js", + "engines": { + "node": ">=22.19.0" + }, + "main": "src/index.ts", "types": "src/index.ts", "exports": { ".": { + "api-extractor": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, "types": "./src/index.ts", - "import": "./dist/index.js" + "import": "./src/index.ts" } }, "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "tsc", - "lint": "eslint ./src", - "clean": "rm -fr dist", - "clean:deep": "rm -fr dist node_modules" + "build": "rm -fr dist && tsgo -p tsconfig.build.json && node ../../scripts/tooling/build/rewrite-dts-imports.ts dist", + "lint:api": "api-extractor run --local", + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/dao/src packages/dao/test", + "clean": "rm -fr dist" }, "files": [ - "dist", - "src" + "dist" ], "publishConfig": { "access": "public", "provenance": true, + "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { diff --git a/packages/dao/src/cells.test.ts b/packages/dao/src/cells.test.ts deleted file mode 100644 index 959707d..0000000 --- a/packages/dao/src/cells.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, headerLike as testHeaderLike, script } from "@ickb/testkit"; -import { describe, expect, it, vi } from "vitest"; -import { DaoManager } from "./dao.js"; - -function headerLike( - epoch: [bigint, bigint, bigint], - number: bigint, - timestamp = 0n, -): ccc.ClientBlockHeader { - return testHeaderLike({ - epoch, - number, - timestamp, - }); -} - -function withdrawalCell(): ccc.Cell { - return ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("11"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: script("22"), - type: script("33"), - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); -} - -function clientFor( - depositHeader: ccc.ClientBlockHeader, - withdrawHeader: ccc.ClientBlockHeader, -): ccc.Client { - return { - getHeaderByNumber: () => Promise.resolve(depositHeader), - getTransactionWithHeader: () => Promise.resolve({ header: withdrawHeader }), - } as unknown as ccc.Client; -} - -describe("daoCellFrom withdrawal readiness", () => { - it("marks withdrawal requests ready once the claim epoch is reached", async () => { - const manager = new DaoManager(script("33"), []); - const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); - const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); - const tip = ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)); - const claimEpoch = ccc.calcDaoClaimEpoch(depositHeader, withdrawHeader); - - const daoCell = await manager.withdrawalRequestCellFrom( - withdrawalCell(), - clientFor(depositHeader, withdrawHeader), - { tip }, - ); - - expect(daoCell.maturity.eq(claimEpoch)).toBe(true); - expect(daoCell.isReady).toBe(true); - }); - - it("keeps withdrawal requests pending before the claim epoch", async () => { - const manager = new DaoManager(script("33"), []); - const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); - const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); - const tip = ccc.ClientBlockHeader.from(headerLike([179n, 0n, 1n], 3n)); - - const daoCell = await manager.withdrawalRequestCellFrom( - withdrawalCell(), - clientFor(depositHeader, withdrawHeader), - { tip }, - ); - - expect(daoCell.isReady).toBe(false); - }); - - it("fetches withdrawal deposit and request headers concurrently", async () => { - const manager = new DaoManager(script("33"), []); - const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); - const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); - const tip = ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)); - const calls: string[] = []; - let resolveDeposit!: (header: ccc.ClientBlockHeader | undefined) => void; - let resolveWithdraw!: (res: { header: ccc.ClientBlockHeader } | undefined) => void; - const depositFetch = new Promise((resolve) => { - resolveDeposit = resolve; - }); - const withdrawFetch = new Promise<{ header: ccc.ClientBlockHeader } | undefined>((resolve) => { - resolveWithdraw = resolve; - }); - const client = { - getHeaderByNumber: async () => { - calls.push("deposit"); - return depositFetch; - }, - getTransactionWithHeader: async () => { - calls.push("withdraw"); - return withdrawFetch; - }, - } as unknown as ccc.Client; - - const daoCellPromise = manager.withdrawalRequestCellFrom( - withdrawalCell(), - client, - { tip }, - ); - - await vi.waitFor(() => { - expect(calls).toEqual(["deposit", "withdraw"]); - }); - resolveWithdraw({ header: withdrawHeader }); - await Promise.resolve(); - resolveDeposit(depositHeader); - - const daoCell = await daoCellPromise; - - expect(daoCell.headers[0].header.number).toBe(depositHeader.number); - expect(daoCell.headers[1].header.number).toBe(withdrawHeader.number); - }); -}); diff --git a/packages/dao/src/cells.ts b/packages/dao/src/cells.ts index e0ee05e..1ab38ef 100644 --- a/packages/dao/src/cells.ts +++ b/packages/dao/src/cells.ts @@ -1,7 +1,12 @@ import { ccc, mol } from "@ckb-ccc/core"; -import { type TransactionHeader, type ValueComponents } from "@ickb/utils"; +import type { TransactionHeader, ValueComponents } from "@ickb/utils"; -interface DaoCellBase extends ValueComponents { +/** + * Common decoded state for a DAO deposit or withdrawal request cell. + * + * @public + */ +export interface DaoCellBase extends ValueComponents { /** The DAO cell. */ cell: ccc.Cell; @@ -15,49 +20,120 @@ interface DaoCellBase extends ValueComponents { /** The interests accrued on the DAO cell. */ interests: ccc.Num; - /** The maturity epoch of the DAO cell. In case of deposit, it's calculated from tip plus minLockUp. */ + /** The DAO claim epoch for this cell, rolled to a later cycle when needed for deposit readiness. */ maturity: ccc.Epoch; /** - * Indicates the readiness to be consumed by a transaction. - * In case of deposit, it is true only when the renewal stays strictly inside the configured window: - * `tip + minLockUp < maturity < tip + maxLockUp`. - * while in case of withdrawal request, it indicates the readiness for withdrawal. + * Indicates whether this decoded cell is ready for the next transaction step. + * + * @remarks + * Deposits use the DAO claim epoch from the deposit and tip headers, rolling + * it forward by one DAO cycle when it is at or before `tip + minLockUp`, then + * requiring the result to stay before `tip + maxLockUp`. Withdrawal requests + * are ready when their maturity is at or before the tip epoch. */ isReady: boolean; } +/** + * Represents a live Nervos DAO deposit cell. + * + * @public + */ export interface DaoDepositCell extends DaoCellBase { + /** Discriminates this decoded DAO cell as a deposit. */ readonly isDeposit: true; } +/** + * Represents a live Nervos DAO withdrawal request cell. + * + * @public + */ export interface DaoWithdrawalRequestCell extends DaoCellBase { + /** Discriminates this decoded DAO cell as a withdrawal request. */ readonly isDeposit: false; } -type TransactionWithHeader = Awaited< +/** + * The default minimum lock-up period represented as an Epoch. + * + * Calculated from the tuple [0n, 1n, 24n]: + * - 0 whole epochs, + * - plus 1/24 of an epoch. + * + * On the target chain's nominal epoch cadence, 1/24 of an epoch is about 10 minutes. + */ +const defaultMinLockUp = ccc.Epoch.from([0n, 1n, 24n]); // 10 minutes + +/** + * The default maximum lock-up period represented as an Epoch. + * + * Calculated from the tuple [18n, 0n, 1n]: + * - 18 whole epochs, + * - plus 0/1 of an additional epoch. + * + * On the target chain's nominal epoch cadence, 18 epochs is about 3 days. + */ +const defaultMaxLockUp = ccc.Epoch.from([18n, 0n, 1n]); // 3 days + +/** + * Result shape returned by `ccc.Client.getTransactionWithHeader`. + * + * @public + */ +export type TransactionWithHeader = Awaited< ReturnType >; -export type DaoCellFromCache = { +/** + * Batch-scoped caches for DAO cell conversion reads. + * + * @public + */ +export interface DaoCellFromCache { + /** Reuses block-header reads by block number across DAO cell conversions in one batch. */ headerCache?: Map>; + /** Reuses transaction-with-header reads by transaction hash across DAO cell conversions in one batch. */ transactionCache?: Map>; -}; +} type DaoCell = DaoDepositCell | DaoWithdrawalRequestCell; -type DaoCellFromOptions = { +/** + * Options required to decode a DAO cell and calculate readiness. + * + * @public + */ +export type DaoCellFromOptions = { + /** Client used for transaction and header lookups. */ client: ccc.Client; + + /** Tip header used as the readiness freshness anchor. */ tip: ccc.ClientBlockHeader; + + /** Optional lower bound for deposit renewal readiness. */ minLockUp?: ccc.Epoch; + + /** Optional upper bound for deposit renewal readiness. */ maxLockUp?: ccc.Epoch; } & DaoCellFromCache; +/** + * Decodes a DAO deposit cell using the current tip as the newest header. + * + * @remarks Cache maps are scoped to one coherent conversion batch; callers should create a new batch for a freshness boundary. + */ export function daoCellFrom( cell: ccc.Cell, options: DaoCellFromOptions & { isDeposit: true }, ): Promise; +/** + * Decodes a DAO withdrawal request cell using its withdrawal transaction header as the newest header. + * + * @remarks Cache maps are scoped to one coherent conversion batch; callers should create a new batch for a freshness boundary. + */ export function daoCellFrom( cell: ccc.Cell, options: DaoCellFromOptions & { isDeposit: false }, @@ -67,67 +143,24 @@ export async function daoCellFrom( cell: ccc.Cell, options: DaoCellFromOptions & { isDeposit: boolean }, ): Promise { - const { isDeposit, tip } = options; - const txHash = cell.outPoint.txHash; - let oldest: TransactionHeader; - let withdrawalTxWithHeader: TransactionWithHeader | undefined; - if (!isDeposit) { - const depositBlockNumber = mol.Uint64LE.decode(cell.outputData); - const [header, txWithHeader] = await Promise.all([ - getCachedHeaderByNumber(options, depositBlockNumber), - getCachedTransactionWithHeader(options, txHash), - ]); - if (!header) { - throw new Error("Header not found for block number"); - } - oldest = { header }; - withdrawalTxWithHeader = txWithHeader; - } else { - const txWithHeader = - await getCachedTransactionWithHeader(options, txHash); - if (!txWithHeader?.header) { - throw new Error("Header not found for txHash"); - } - oldest = { header: txWithHeader.header, txHash }; - } - - let newest: TransactionHeader; - if (!isDeposit) { - const txWithHeader = withdrawalTxWithHeader; - if (!txWithHeader?.header) { - throw new Error("Header not found for txHash"); - } - newest = { header: txWithHeader.header, txHash }; - } else { - newest = { header: tip }; - } + const headers = options.isDeposit + ? await depositHeaders(cell, options) + : await withdrawalRequestHeaders(cell, options); + const [oldest, newest] = headers; - const interests = ccc.calcDaoProfit( - cell.capacityFree, - oldest.header, - newest.header, - ); + const interests = ccc.calcDaoProfit(cell.capacityFree, oldest.header, newest.header); let maturity = ccc.calcDaoClaimEpoch(oldest.header, newest.header); const minLockUp = options.minLockUp ?? defaultMinLockUp; const maxLockUp = options.maxLockUp ?? defaultMaxLockUp; - - // Deposit: ready only within the current/next usable DAO window. - // The boundaries are exclusive so callers do not race an exact-edge inclusion. - // WithdrawalRequest: ready once the claim epoch has been reached. - let isReady = isDeposit - ? maturity.compare(minLockUp.add(tip.epoch)) > 0 - : maturity.compare(tip.epoch) <= 0; - - if (isDeposit) { - // Deposit: maturity < tip.epoch + maxLockUp - if (!isReady) { - // This deposit is late for this cycle and it will be withdrawable in the next cycle - maturity = maturity.add([180n, 0n, 1n]); - // isReady = true; // Ready for next cycle - } - isReady = maxLockUp.add(tip.epoch).compare(maturity) > 0; - } + const readiness = daoCellReadiness({ + isDeposit: options.isDeposit, + maturity, + tip: options.tip, + minLockUp, + maxLockUp, + }); + maturity = readiness.maturity; const ckbValue = cell.cellOutput.capacity + interests; const udtValue = 0n; @@ -137,60 +170,150 @@ export async function daoCellFrom( headers: [oldest, newest], interests, maturity, - isReady, + isReady: readiness.isReady, ckbValue, udtValue, } satisfies DaoCellBase; - return isDeposit + return options.isDeposit ? { ...common, isDeposit: true } : { ...common, isDeposit: false }; } -function getCachedHeaderByNumber( +async function depositHeaders( + cell: ccc.Cell, + options: DaoCellFromOptions, +): Promise<[TransactionHeader, TransactionHeader]> { + const txHash = cell.outPoint.txHash; + let txWithHeader: Awaited>; + try { + txWithHeader = await getCachedTransactionWithHeader(options, txHash); + } catch (error) { + throw new Error( + `Failed to load transaction header for txHash ${txHash} at ${cell.outPoint.toHex()}`, + { cause: error }, + ); + } + if (txWithHeader?.header === undefined) { + throw new Error(`Header not found for txHash ${txHash} at ${cell.outPoint.toHex()}`); + } + return [{ header: txWithHeader.header, txHash }, { header: options.tip }]; +} + +async function withdrawalRequestHeaders( + cell: ccc.Cell, + options: DaoCellFromOptions, +): Promise<[TransactionHeader, TransactionHeader]> { + const txHash = cell.outPoint.txHash; + let depositBlockNumber: ccc.Num; + try { + depositBlockNumber = mol.Uint64LE.decode(cell.outputData); + } catch (error) { + throw new Error( + `Invalid DAO withdrawal request payload at ${cell.outPoint.toHex()}: ${cell.outputData}`, + { cause: error }, + ); + } + const depositHeaderPromise = getWithdrawalDepositHeader( + cell, + options, + depositBlockNumber, + ); + const txWithHeaderPromise = getWithdrawalTransactionWithHeader(cell, options, txHash); + const [depositHeader, txWithHeader] = await Promise.all([ + depositHeaderPromise, + txWithHeaderPromise, + ]); + if (depositHeader === undefined) { + throw new Error( + `Header not found for block number ${String(depositBlockNumber)} at ${cell.outPoint.toHex()}`, + ); + } + if (txWithHeader?.header === undefined) { + throw new Error(`Header not found for txHash ${txHash} at ${cell.outPoint.toHex()}`); + } + return [{ header: depositHeader }, { header: txWithHeader.header, txHash }]; +} + +async function getWithdrawalDepositHeader( + cell: ccc.Cell, + options: DaoCellFromOptions, + depositBlockNumber: ccc.Num, +): Promise { + try { + return await getCachedHeaderByNumber(options, depositBlockNumber); + } catch (error) { + throw new Error( + `Failed to load header for block number ${String(depositBlockNumber)} at ${cell.outPoint.toHex()}`, + { cause: error }, + ); + } +} + +async function getWithdrawalTransactionWithHeader( + cell: ccc.Cell, + options: DaoCellFromOptions, + txHash: ccc.Hex, +): Promise { + try { + return await getCachedTransactionWithHeader(options, txHash); + } catch (error) { + throw new Error( + `Failed to load transaction header for txHash ${txHash} at ${cell.outPoint.toHex()}`, + { cause: error }, + ); + } +} + +interface DaoCellReadinessOptions { + isDeposit: boolean; + maturity: ccc.Epoch; + tip: ccc.ClientBlockHeader; + minLockUp: ccc.Epoch; + maxLockUp: ccc.Epoch; +} + +function daoCellReadiness(options: DaoCellReadinessOptions): { + isReady: boolean; + maturity: ccc.Epoch; +} { + if (!options.isDeposit) { + return { + maturity: options.maturity, + isReady: options.maturity.compare(options.tip.epoch) <= 0, + }; + } + + let maturity = options.maturity; + if (maturity.compare(options.minLockUp.add(options.tip.epoch)) <= 0) { + maturity = maturity.add([180n, 0n, 1n]); + } + return { + maturity, + isReady: options.maxLockUp.add(options.tip.epoch).compare(maturity) > 0, + }; +} + +async function getCachedHeaderByNumber( options: DaoCellFromOptions, blockNumber: ccc.Num, ): Promise { let promise = options.headerCache?.get(blockNumber); - if (!promise) { + if (promise === undefined) { promise = options.client.getHeaderByNumber(blockNumber); options.headerCache?.set(blockNumber, promise); } return promise; } -function getCachedTransactionWithHeader( +async function getCachedTransactionWithHeader( options: DaoCellFromOptions, txHash: ccc.Hex, ): Promise { let promise = options.transactionCache?.get(txHash); - if (!promise) { + if (promise === undefined) { promise = options.client.getTransactionWithHeader(txHash); options.transactionCache?.set(txHash, promise); } return promise; } - -/** - * The default minimum lock-up period represented as an Epoch. - * - * Calculated from the tuple [0n, 1n, 24n]: - * - 0 whole epochs, - * - plus 1/24 of an epoch. - * - * Given each epoch represents 4 hours (14400000 milliseconds), - * then 1/24 of an epoch equals: (14400000 / 24) = 600000 milliseconds, i.e. 10 minutes. - */ -const defaultMinLockUp = ccc.Epoch.from([0n, 1n, 24n]); // 10 minutes - -/** - * The default maximum lock-up period represented as an Epoch. - * - * Calculated from the tuple [18n, 0n, 1n]: - * - 18 whole epochs, - * - plus 0/1 of an additional epoch. - * - * With each epoch lasting 4 hours, 18 epochs equal 18 * 4 hours = 72 hours, - * i.e. 3 days. - */ -const defaultMaxLockUp = ccc.Epoch.from([18n, 0n, 1n]); // 3 days diff --git a/packages/dao/src/dao.test.ts b/packages/dao/src/dao.test.ts deleted file mode 100644 index 2f9890a..0000000 --- a/packages/dao/src/dao.test.ts +++ /dev/null @@ -1,704 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, headerLike as testHeaderLike, script } from "@ickb/testkit"; -import { describe, expect, it, vi } from "vitest"; -import type { DaoDepositCell, DaoWithdrawalRequestCell } from "./cells.js"; -import { DaoManager, DaoOutputLimitError } from "./dao.js"; - -async function collect(inputs: AsyncIterable): Promise { - const result: T[] = []; - for await (const input of inputs) { - result.push(input); - } - return result; -} - -function headerLike(number: bigint): ccc.ClientBlockHeader { - return testHeaderLike({ - epoch: [1n, 0n, 1n], - number, - }); -} - -function headerWithHash(number: bigint, hashByte: string): ccc.ClientBlockHeader { - const header = headerLike(number); - return ccc.ClientBlockHeader.from({ - compactTarget: header.compactTarget, - dao: header.dao, - epoch: header.epoch, - extraHash: header.extraHash, - hash: byte32FromByte(hashByte), - nonce: header.nonce, - number: header.number, - parentHash: header.parentHash, - proposalsHash: header.proposalsHash, - timestamp: header.timestamp, - transactionsRoot: header.transactionsRoot, - version: header.version, - }); -} - -function depositCell( - manager: DaoManager, - options?: { - lock?: ccc.Script; - txHashByte?: string; - isReady?: boolean; - }, -): DaoDepositCell { - const depositHeader = headerLike(1n); - return { - cell: ccc.Cell.from({ - outPoint: { - txHash: byte32FromByte(options?.txHashByte ?? "22"), - index: 0n, - }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: options?.lock ?? script("33"), - type: manager.script, - }, - outputData: DaoManager.depositData(), - }), - isDeposit: true, - headers: [{ header: depositHeader }, { header: depositHeader }], - interests: 0n, - maturity: ccc.Epoch.from([1n, 0n, 1n]), - isReady: options?.isReady ?? true, - ckbValue: ccc.fixedPointFrom(100082), - udtValue: 0n, - }; -} - -describe("DaoManager.requestWithdrawal", () => { - it("throws a typed DAO output-limit error", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(true); - - const manager = new DaoManager(script("11"), []); - - await expect( - manager.requestWithdrawal( - ccc.Transaction.default(), - [depositCell(manager)], - script("44"), - {} as ccc.Client, - ), - ).rejects.toBeInstanceOf(DaoOutputLimitError); - }); - - it("always rejects withdrawal locks with different args size", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const manager = new DaoManager(script("11"), []); - const deposit = depositCell(manager, { lock: script("33", "0x1234") }); - - await expect( - manager.requestWithdrawal( - ccc.Transaction.default(), - [deposit], - script("44", "0x12"), - {} as ccc.Client, - ), - ).rejects.toThrow("Withdrawal request lock args has different size from deposit"); - }); - - it("keeps non-ready deposits unless isReadyOnly is set", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const manager = new DaoManager(script("11"), []); - const pending = depositCell(manager, { isReady: false, txHashByte: "22" }); - const ready = depositCell(manager, { isReady: true, txHashByte: "23" }); - - const tx = await manager.requestWithdrawal( - ccc.Transaction.default(), - [pending, ready], - script("44"), - {} as ccc.Client, - ); - - expect(tx.inputs).toHaveLength(2); - expect(tx.outputs).toHaveLength(2); - expect(tx.outputsData).toHaveLength(2); - }); - - it("filters non-ready deposits when isReadyOnly is set", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const manager = new DaoManager(script("11"), []); - const pending = depositCell(manager, { isReady: false, txHashByte: "22" }); - const ready = depositCell(manager, { isReady: true, txHashByte: "23" }); - - const tx = await manager.requestWithdrawal( - ccc.Transaction.default(), - [pending, ready], - script("44"), - {} as ccc.Client, - { isReadyOnly: true }, - ); - - expect(tx.inputs).toHaveLength(1); - expect(tx.outputs).toHaveLength(1); - expect(tx.inputs[0]?.previousOutput.txHash).toBe(ready.cell.outPoint.txHash); - }); - - it("requires matched input and output counts before appending requests", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const manager = new DaoManager(script("11"), []); - const tx = ccc.Transaction.default(); - tx.addOutput( - { - capacity: ccc.fixedPointFrom(1000), - lock: script("55"), - }, - "0x", - ); - - await expect( - manager.requestWithdrawal( - tx, - [depositCell(manager)], - script("44"), - {} as ccc.Client, - ), - ).rejects.toThrow("Transaction has different inputs and outputs lengths"); - }); -}); - -describe("DaoManager cell decoding ownership", () => { - it("rejects depositCellFrom on non-deposit cells", async () => { - const manager = new DaoManager(script("11"), []); - - await expect( - manager.depositCellFrom( - ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("22"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: script("33"), - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }), - {} as ccc.Client, - { tip: headerLike(2n) }, - ), - ).rejects.toThrow("Not a deposit"); - }); - - it("rejects withdrawalRequestCellFrom on non-withdrawal cells", async () => { - const manager = new DaoManager(script("11"), []); - - await expect( - manager.withdrawalRequestCellFrom( - depositCell(manager).cell, - {} as ccc.Client, - { - tip: headerLike(2n), - }, - ), - ).rejects.toThrow("Not a withdrawal request"); - }); -}); - -describe("DaoManager.findDeposits", () => { - it("passes the cell page size to deposit scanning", async () => { - const manager = new DaoManager(script("11"), []); - const lock = script("22"); - const firstDeposit = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("33"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: DaoManager.depositData(), - }); - const secondDeposit = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: DaoManager.depositData(), - }); - let requestedPageSize = 0; - const client = { - findCells: async function* (_query: unknown, _order: unknown, pageSize: number) { - requestedPageSize = pageSize; - await Promise.resolve(); - yield firstDeposit; - yield secondDeposit; - }, - getTransactionWithHeader: async () => { - await Promise.resolve(); - return { header: headerLike(1n) }; - }, - } as unknown as ccc.Client; - - const deposits = await collect(manager.findDeposits(client, [lock], { tip: headerLike(3n), pageSize: 1 })); - - expect(requestedPageSize).toBe(1); - expect(deposits.map((deposit) => deposit.cell.outPoint.txHash)).toEqual([ - firstDeposit.outPoint.txHash, - secondDeposit.outPoint.txHash, - ]); - }); - - it("decodes deposits concurrently and yields scan order", async () => { - const manager = new DaoManager(script("11"), []); - const lock = script("22"); - const firstDeposit = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("33"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: DaoManager.depositData(), - }); - const secondDeposit = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: DaoManager.depositData(), - }); - const tip = headerLike(3n); - const requests: ccc.Hex[] = []; - let resolveFirst!: (res: { header: ccc.ClientBlockHeader }) => void; - let resolveSecond!: (res: { header: ccc.ClientBlockHeader }) => void; - const firstFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveFirst = resolve; - }); - const secondFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveSecond = resolve; - }); - const client = { - findCells: async function* () { - await Promise.resolve(); - yield firstDeposit; - yield secondDeposit; - }, - getTransactionWithHeader: async (txHash: ccc.Hex) => { - requests.push(txHash); - return txHash === firstDeposit.outPoint.txHash ? firstFetch : secondFetch; - }, - } as unknown as ccc.Client; - - const depositsPromise = collect( - manager.findDeposits(client, [lock], { tip }), - ); - - await vi.waitFor(() => { - expect(requests).toEqual([ - firstDeposit.outPoint.txHash, - secondDeposit.outPoint.txHash, - ]); - }); - resolveSecond({ header: headerLike(1n) }); - await Promise.resolve(); - resolveFirst({ header: headerLike(1n) }); - - const deposits = await depositsPromise; - - expect(deposits.map((deposit) => deposit.cell.outPoint.txHash)).toEqual([ - firstDeposit.outPoint.txHash, - secondDeposit.outPoint.txHash, - ]); - }); - - it("deduplicates deposit transaction header requests during a scan", async () => { - const manager = new DaoManager(script("11"), []); - const lock = script("22"); - const txHash = byte32FromByte("33"); - const firstDeposit = ccc.Cell.from({ - outPoint: { txHash, index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: DaoManager.depositData(), - }); - const secondDeposit = ccc.Cell.from({ - outPoint: { txHash, index: 1n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: DaoManager.depositData(), - }); - let transactionCalls = 0; - const client = { - findCells: async function* () { - await Promise.resolve(); - yield firstDeposit; - yield secondDeposit; - }, - getTransactionWithHeader: async () => { - transactionCalls += 1; - await Promise.resolve(); - return { header: headerLike(1n) }; - }, - } as unknown as ccc.Client; - - const deposits = await collect(manager.findDeposits(client, [lock], { - tip: headerLike(3n), - })); - - expect(transactionCalls).toBe(1); - expect(deposits).toHaveLength(2); - }); - - it("reuses deposit transaction header requests across lock scans", async () => { - const manager = new DaoManager(script("11"), []); - const firstLock = script("22"); - const secondLock = script("33"); - const txHash = byte32FromByte("44"); - const firstDeposit = ccc.Cell.from({ - outPoint: { txHash, index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: firstLock, - type: manager.script, - }, - outputData: DaoManager.depositData(), - }); - const secondDeposit = ccc.Cell.from({ - outPoint: { txHash, index: 1n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: secondLock, - type: manager.script, - }, - outputData: DaoManager.depositData(), - }); - let transactionCalls = 0; - const client = { - findCells: async function* (query: { script: ccc.Script }) { - await Promise.resolve(); - yield query.script.eq(firstLock) ? firstDeposit : secondDeposit; - }, - getTransactionWithHeader: async () => { - transactionCalls += 1; - await Promise.resolve(); - return { header: headerLike(1n) }; - }, - } as unknown as ccc.Client; - - const deposits = await collect(manager.findDeposits(client, [firstLock, secondLock], { - tip: headerLike(3n), - })); - - expect(transactionCalls).toBe(1); - expect(deposits.map((deposit) => deposit.cell.outPoint.index)).toEqual([0n, 1n]); - }); -}); - -describe("DaoManager.findWithdrawalRequests", () => { - it("passes the cell page size to withdrawal request scanning", async () => { - const manager = new DaoManager(script("11"), []); - const lock = script("22"); - const firstWithdrawal = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - const secondWithdrawal = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("66"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - let requestedPageSize = 0; - const client = { - findCells: async function* (_query: unknown, _order: unknown, pageSize: number) { - requestedPageSize = pageSize; - await Promise.resolve(); - yield firstWithdrawal; - yield secondWithdrawal; - }, - getHeaderByNumber: async () => { - await Promise.resolve(); - return headerLike(1n); - }, - getTransactionWithHeader: async () => { - await Promise.resolve(); - return { header: headerLike(2n) }; - }, - } as unknown as ccc.Client; - - const withdrawals = await collect(manager.findWithdrawalRequests(client, [lock], { tip: headerLike(3n), pageSize: 1 })); - - expect(requestedPageSize).toBe(1); - expect(withdrawals.map((withdrawal) => withdrawal.cell.outPoint.txHash)).toEqual([ - firstWithdrawal.outPoint.txHash, - secondWithdrawal.outPoint.txHash, - ]); - }); - - it("decodes withdrawals concurrently and yields scan order", async () => { - const manager = new DaoManager(script("11"), []); - const lock = script("22"); - const firstWithdrawal = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("55"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - const secondWithdrawal = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("66"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - const tip = headerLike(3n); - const depositHeader = headerLike(1n); - const requests: ccc.Hex[] = []; - let resolveFirst!: (res: { header: ccc.ClientBlockHeader }) => void; - let resolveSecond!: (res: { header: ccc.ClientBlockHeader }) => void; - const firstFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveFirst = resolve; - }); - const secondFetch = new Promise<{ header: ccc.ClientBlockHeader }>((resolve) => { - resolveSecond = resolve; - }); - const client = { - findCells: async function* () { - await Promise.resolve(); - yield firstWithdrawal; - yield secondWithdrawal; - }, - getHeaderByNumber: async () => { - await Promise.resolve(); - return depositHeader; - }, - getTransactionWithHeader: async (txHash: ccc.Hex) => { - requests.push(txHash); - return txHash === firstWithdrawal.outPoint.txHash ? firstFetch : secondFetch; - }, - } as unknown as ccc.Client; - - const withdrawalsPromise = collect( - manager.findWithdrawalRequests(client, [lock], { tip }), - ); - - await vi.waitFor(() => { - expect(requests).toEqual([ - firstWithdrawal.outPoint.txHash, - secondWithdrawal.outPoint.txHash, - ]); - }); - resolveSecond({ header: headerLike(2n) }); - await Promise.resolve(); - resolveFirst({ header: headerLike(2n) }); - - const withdrawals = await withdrawalsPromise; - - expect(withdrawals.map((withdrawal) => withdrawal.cell.outPoint.txHash)).toEqual([ - firstWithdrawal.outPoint.txHash, - secondWithdrawal.outPoint.txHash, - ]); - }); - - it("deduplicates withdrawal transaction and deposit header requests during a scan", async () => { - const manager = new DaoManager(script("11"), []); - const lock = script("22"); - const txHash = byte32FromByte("55"); - const firstWithdrawal = ccc.Cell.from({ - outPoint: { txHash, index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - const secondWithdrawal = ccc.Cell.from({ - outPoint: { txHash, index: 1n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock, - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - let headerCalls = 0; - let transactionCalls = 0; - const client = { - findCells: async function* () { - await Promise.resolve(); - yield firstWithdrawal; - yield secondWithdrawal; - }, - getHeaderByNumber: async () => { - headerCalls += 1; - await Promise.resolve(); - return headerLike(1n); - }, - getTransactionWithHeader: async () => { - transactionCalls += 1; - await Promise.resolve(); - return { header: headerLike(2n) }; - }, - } as unknown as ccc.Client; - - const withdrawals = await collect( - manager.findWithdrawalRequests(client, [lock], { tip: headerLike(3n) }), - ); - - expect(headerCalls).toBe(1); - expect(transactionCalls).toBe(1); - expect(withdrawals).toHaveLength(2); - }); - - it("reuses withdrawal transaction and deposit header requests across lock scans", async () => { - const manager = new DaoManager(script("11"), []); - const firstLock = script("22"); - const secondLock = script("33"); - const txHash = byte32FromByte("55"); - const firstWithdrawal = ccc.Cell.from({ - outPoint: { txHash, index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: firstLock, - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - const secondWithdrawal = ccc.Cell.from({ - outPoint: { txHash, index: 1n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: secondLock, - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(1n), - }); - let headerCalls = 0; - let transactionCalls = 0; - const client = { - findCells: async function* (query: { script: ccc.Script }) { - await Promise.resolve(); - yield query.script.eq(firstLock) ? firstWithdrawal : secondWithdrawal; - }, - getHeaderByNumber: async () => { - headerCalls += 1; - await Promise.resolve(); - return headerLike(1n); - }, - getTransactionWithHeader: async () => { - transactionCalls += 1; - await Promise.resolve(); - return { header: headerLike(2n) }; - }, - } as unknown as ccc.Client; - - const withdrawals = await collect(manager.findWithdrawalRequests(client, [firstLock, secondLock], { - tip: headerLike(3n), - })); - - expect(headerCalls).toBe(1); - expect(transactionCalls).toBe(1); - expect(withdrawals.map((withdrawal) => withdrawal.cell.outPoint.index)).toEqual([0n, 1n]); - }); -}); - -describe("DaoManager.withdraw", () => { - it("writes since, header deps, and witness inputType for withdrawals", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const manager = new DaoManager(script("11"), []); - const depositHeader = headerLike(1n); - const withdrawHeader = headerWithHash(2n, "99"); - const withdrawal: DaoWithdrawalRequestCell = { - cell: ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("22"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: script("33", "0x1234"), - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(depositHeader.number), - }), - isDeposit: false, - headers: [{ header: depositHeader }, { header: withdrawHeader }], - interests: 0n, - maturity: ccc.Epoch.from([180n, 0n, 1n]), - isReady: true, - ckbValue: ccc.fixedPointFrom(100082), - udtValue: 0n, - }; - - const tx = await manager.withdraw( - ccc.Transaction.default(), - [withdrawal], - {} as ccc.Client, - ); - - expect(tx.headerDeps).toEqual([depositHeader.hash, withdrawHeader.hash]); - expect(tx.inputs).toHaveLength(1); - const since = tx.inputs[0]?.since; - if (since === undefined) { - throw new Error("Expected withdrawal input since"); - } - expect(ccc.Since.from(since).metric).toBe("epoch"); - expect(ccc.Since.from(since).value).toBe(withdrawal.maturity.toNum()); - expect(tx.getWitnessArgsAt(0)?.inputType).toBe( - ccc.hexFrom(ccc.numLeToBytes(0n, 8)), - ); - }); - - it("preserves an existing non-input witness by shifting it after the new input", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - - const manager = new DaoManager(script("11"), []); - const depositHeader = headerLike(1n); - const withdrawHeader = headerWithHash(2n, "99"); - const withdrawal: DaoWithdrawalRequestCell = { - cell: ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("22"), index: 0n }, - cellOutput: { - capacity: ccc.fixedPointFrom(100082), - lock: script("33", "0x1234"), - type: manager.script, - }, - outputData: ccc.mol.Uint64LE.encode(depositHeader.number), - }), - isDeposit: false, - headers: [{ header: depositHeader }, { header: withdrawHeader }], - interests: 0n, - maturity: ccc.Epoch.from([180n, 0n, 1n]), - isReady: true, - ckbValue: ccc.fixedPointFrom(100082), - udtValue: 0n, - }; - const tx = ccc.Transaction.default(); - const preservedWitness = ccc.WitnessArgs.from({ inputType: "0xab" }).toHex(); - tx.witnesses.push(preservedWitness); - - const updated = await manager.withdraw(tx, [withdrawal], {} as ccc.Client); - - expect(updated.getWitnessArgsAt(0)?.inputType).toBe( - ccc.hexFrom(ccc.numLeToBytes(0n, 8)), - ); - expect(updated.witnesses[1]).toBe(preservedWitness); - }); -}); diff --git a/packages/dao/src/dao.ts b/packages/dao/src/dao.ts index db26da0..8ed6343 100644 --- a/packages/dao/src/dao.ts +++ b/packages/dao/src/dao.ts @@ -10,102 +10,92 @@ import { type DaoCellFromCache, type DaoDepositCell, type DaoWithdrawalRequestCell, -} from "./cells.js"; +} from "./cells.ts"; +import { assertDaoOutputLimit } from "./dao_output_limit.ts"; +import { cellInputLikeFrom, cellOutputLikeFrom } from "./transaction_shape.ts"; -type DaoCellFromOptions = { +/** + * Options shared by DAO cell decoding and manager helpers. + * + * @public + */ +export type DaoCellFromOptions = { + /** Tip header used as the readiness freshness anchor. */ tip: ccc.ClientBlockHeader; + + /** Optional lower bound for deposit renewal readiness. */ minLockUp?: ccc.Epoch; + + /** Optional upper bound for deposit renewal readiness. */ maxLockUp?: ccc.Epoch; } & DaoCellFromCache; -export class DaoOutputLimitError extends Error { - constructor(outputCount: number) { - super( - `NervosDAO transaction has ${String(outputCount)} output cells, exceeding the limit of 64`, - ); - this.name = "DaoOutputLimitError"; - } -} - -export async function assertDaoOutputLimit( - txLike: ccc.TransactionLike, - client: ccc.Client, -): Promise { - const tx = ccc.Transaction.from(txLike); - if (await ccc.isDaoOutputLimitExceeded(tx, client)) { - throw new DaoOutputLimitError(tx.outputs.length); - } -} - /** - * Manage NervosDAO functionalities. + * Builds and finds Nervos DAO deposit and withdrawal transactions. + * + * @public */ export class DaoManager implements ScriptDeps { + /** The deployed Nervos DAO type script managed by this instance. */ + public readonly script: ccc.Script; + + /** Cell dependencies required to execute the DAO script. */ + public readonly cellDeps: ccc.CellDep[]; + /** - * Creates an instance of the DaoManager class. - * - * @param script - The script associated with the NervosDAO. - * @param cellDeps - An array of cell dependencies for the NervosDAO. + * Creates a DAO manager for one deployed DAO script and its cell deps. */ - constructor( - public readonly script: ccc.Script, - public readonly cellDeps: ccc.CellDep[], - ) {} + constructor(script: ccc.Script, cellDeps: ccc.CellDep[]) { + this.script = script; + this.cellDeps = cellDeps; + } /** - * Checks if a given cell is a deposit. - * - * @param cell - The cell to check. - * @returns True if the cell is a deposit, otherwise false. + * Returns true when the cell is a DAO deposit for this manager's script. */ - isDeposit(cell: ccc.CellAny): boolean { + public isDeposit(cell: ccc.CellAny): boolean { const { cellOutput: { type }, outputData, } = cell; - return ( - outputData === DaoManager.depositData() && type?.eq(this.script) === true - ); + return outputData === DaoManager.depositData() && type?.eq(this.script) === true; } /** - * Checks if a given cell is a withdrawal request. + * Returns true when the cell uses this DAO script and is not deposit-shaped. * - * @param cell - The cell to check. - * @returns True if the cell is a withdrawal request, otherwise false. + * @remarks + * This structural check does not decode or validate the withdrawal request's + * deposit block number payload. */ - isWithdrawalRequest(cell: ccc.Cell): boolean { + public isWithdrawalRequest(cell: ccc.Cell): boolean { const { cellOutput: { type }, outputData, } = cell; - return ( - outputData !== DaoManager.depositData() && type?.eq(this.script) === true - ); + return outputData !== DaoManager.depositData() && type?.eq(this.script) === true; } /** - * Returns the deposit data. - * - * @returns The deposit data as a hexadecimal string. + * Returns the canonical DAO deposit data payload. */ - static depositData(): ccc.Hex { + public static depositData(): ccc.Hex { return "0x0000000000000000"; } - async depositCellFrom( + /** + * Loads and decodes a DAO deposit cell from a cell or out point. + * + * @remarks Cache maps in `options` are reused only for this caller-provided conversion batch. + */ + public async depositCellFrom( cellLike: ccc.Cell | ccc.OutPoint, client: ccc.Client, options: DaoCellFromOptions, ): Promise { - const cell = cellLike instanceof ccc.OutPoint - ? await client.getCell(cellLike) - : cellLike; - if (!cell) { - throw new Error("Cell not found"); - } + const cell = await cellFromLike(cellLike, client); if (!this.isDeposit(cell)) { throw new Error("Not a deposit"); } @@ -113,17 +103,17 @@ export class DaoManager implements ScriptDeps { return daoCellFrom(cell, { ...options, client, isDeposit: true }); } - async withdrawalRequestCellFrom( + /** + * Loads and decodes a DAO withdrawal request cell from a cell or out point. + * + * @remarks Cache maps in `options` are reused only for this caller-provided conversion batch. + */ + public async withdrawalRequestCellFrom( cellLike: ccc.Cell | ccc.OutPoint, client: ccc.Client, options: DaoCellFromOptions, ): Promise { - const cell = cellLike instanceof ccc.OutPoint - ? await client.getCell(cellLike) - : cellLike; - if (!cell) { - throw new Error("Cell not found"); - } + const cell = await cellFromLike(cellLike, client); if (!this.isWithdrawalRequest(cell)) { throw new Error("Not a withdrawal request"); } @@ -132,16 +122,12 @@ export class DaoManager implements ScriptDeps { } /** - * Adds a deposit to a transaction. + * Adds DAO deposit outputs with the given capacities and lock script. * - * @param txLike - The transaction to which the deposit will be added. - * @param capacities - An array of capacities of the deposits to create. - * @param lock - The lock script for the outputs. - * @param client - The CKB client for DAO output limit validation. - * @returns The updated transaction. + * @returns The updated partial transaction. */ - async deposit( - txLike: ccc.TransactionLike, + public async deposit( + txLike: ccc.TransactionLike | ccc.Transaction, capacities: ccc.FixedPoint[], lock: ccc.Script, client: ccc.Client, @@ -169,33 +155,29 @@ export class DaoManager implements ScriptDeps { } /** - * Requests withdrawal from NervosDAO deposits. + * Adds DAO withdrawal request inputs and outputs for the selected deposits. * - * @param txLike - The transaction to which the withdrawal request will be added. - * @param deposits - An array of deposits to request the withdrawal from. - * @param lock - The lock script for the withdrawal request cells. - * @param options - Optional parameters for the withdrawal request. - * @param options.isReadyOnly - Whether to only process ready deposits (default: false). - * @returns void + * @param options - Set `isReadyOnly` to only process ready deposits. + * @returns The updated partial transaction. * @throws Error if the transaction has different input and output lengths. * @throws Error if the withdrawal request lock args have a different size from the deposit. - * @throws Error if the transaction or header of deposit is not found. + * @throws DaoOutputLimitError if the resulting transaction exceeds DAO output limits. */ - async requestWithdrawal( - txLike: ccc.TransactionLike, - deposits: DaoDepositCell[], - lock: ccc.Script, - client: ccc.Client, - options?: { - isReadyOnly?: boolean; - }, + public async requestWithdrawal( + ...[txLike, deposits, lock, client, options]: [ + txLike: ccc.TransactionLike | ccc.Transaction, + deposits: DaoDepositCell[], + lock: ccc.Script, + client: ccc.Client, + options?: { + isReadyOnly?: boolean; + }, + ] ): Promise { const tx = ccc.Transaction.from(txLike); - const isReadyOnly = options?.isReadyOnly ?? false; - if (isReadyOnly) { - deposits = deposits.filter((d) => d.isReady); - } - if (deposits.length === 0) { + const selectedDeposits = + options?.isReadyOnly === true ? deposits.filter((d) => d.isReady) : deposits; + if (selectedDeposits.length === 0) { return tx; } @@ -205,22 +187,26 @@ export class DaoManager implements ScriptDeps { ) { throw new Error("Transaction has different inputs and outputs lengths"); } + assertUniqueUnspentInputs( + tx, + selectedDeposits.map((deposit) => deposit.cell.outPoint), + "DAO deposit", + ); - for (const deposit of deposits) { + for (const deposit of selectedDeposits) { const { cell, headers } = deposit; + this.assertDepositReadyForWithdrawalRequest(deposit); if (cell.cellOutput.lock.args.length !== lock.args.length) { - throw new Error( - "Withdrawal request lock args has different size from deposit", - ); + throw new Error("Withdrawal request lock args has different size from deposit"); } tx.addCellDeps(this.cellDeps); const depositHeader = headers[0]; const depositHash = depositHeader.header.hash; - if (!tx.headerDeps.some((h) => h === depositHash)) { + if (!tx.headerDeps.includes(depositHash)) { tx.headerDeps.push(depositHash); } - tx.addInput(cell); + tx.addInput(cellInputLikeFrom(cell)); tx.addOutput( { capacity: cell.cellOutput.capacity, @@ -236,17 +222,15 @@ export class DaoManager implements ScriptDeps { } /** - * Withdraws funds from the NervosDAO based on the provided mature withdrawal requests. + * Adds DAO withdrawal request inputs with required header deps and witness input types. * - * @param txLike - The transaction to which the withdrawal will be added. - * @param withdrawalRequests - An array of withdrawal requests to process. - * @param options - Optional parameters for the withdrawal process. - * @param options.isReadyOnly - Whether to only process ready withdrawal requests (default: false). - * @returns void - * @throws Error if the withdrawal request is not valid. + * @param options - Set `isReadyOnly` to skip requests that are not ready yet. + * @returns The updated partial transaction. + * @throws Error if a withdrawal request witness input type is already in use. + * @throws DaoOutputLimitError if the resulting transaction exceeds DAO output limits. */ - async withdraw( - txLike: ccc.TransactionLike, + public async withdraw( + txLike: ccc.TransactionLike | ccc.Transaction, withdrawalRequests: DaoWithdrawalRequestCell[], client: ccc.Client, options?: { @@ -254,17 +238,23 @@ export class DaoManager implements ScriptDeps { }, ): Promise { const tx = ccc.Transaction.from(txLike); - const isReadyOnly = options?.isReadyOnly ?? false; - if (isReadyOnly) { - withdrawalRequests = withdrawalRequests.filter((d) => d.isReady); - } - if (withdrawalRequests.length === 0) { + const selectedWithdrawalRequests = + options?.isReadyOnly === true + ? withdrawalRequests.filter((d) => d.isReady) + : withdrawalRequests; + if (selectedWithdrawalRequests.length === 0) { return tx; } tx.addCellDeps(this.cellDeps); + assertUniqueUnspentInputs( + tx, + selectedWithdrawalRequests.map((request) => request.cell.outPoint), + "DAO withdrawal request", + ); - for (const withdrawalRequest of withdrawalRequests) { + for (const withdrawalRequest of selectedWithdrawalRequests) { + this.assertWithdrawalRequestReadyForWithdrawal(withdrawalRequest); const { cell: { outPoint, cellOutput, outputData }, headers, @@ -272,19 +262,17 @@ export class DaoManager implements ScriptDeps { } = withdrawalRequest; for (const th of headers) { const hash = th.header.hash; - if (!tx.headerDeps.some((h) => h === hash)) { + if (!tx.headerDeps.includes(hash)) { tx.headerDeps.push(hash); } } const depositHeader = headers[0]; - const headerIndex = tx.headerDeps.findIndex( - (h) => h === depositHeader.header.hash, - ); + const headerIndex = tx.headerDeps.indexOf(depositHeader.header.hash); const inputIndex = tx.addInput({ outPoint, - cellOutput, + cellOutput: cellOutputLikeFrom(cellOutput), outputData, since: { relative: "absolute", @@ -293,9 +281,8 @@ export class DaoManager implements ScriptDeps { }, }) - 1; - const witness = - tx.getWitnessArgsAt(inputIndex) ?? ccc.WitnessArgs.from({}); - if (witness.inputType) { + const witness = tx.getWitnessArgsAt(inputIndex) ?? ccc.WitnessArgs.from({}); + if ((witness.inputType ?? "") !== "") { throw new Error("Witnesses of withdrawal request already in use"); } witness.inputType = ccc.hexFrom(ccc.numLeToBytes(headerIndex, 8)); @@ -307,49 +294,13 @@ export class DaoManager implements ScriptDeps { } /** - * Async generator that finds and yields DAO deposit cells for the specified lock scripts. - * - * @param client - * A CKB client instance that implements: - * - `findCells(query, order, pageSize)` — cached searches - * - `findCellsOnChain(query, order, pageSize)` — direct on-chain searches - * - * @param locks - * An array of lock scripts. Only cells whose `cellOutput.lock` exactly matches - * one of these scripts will be considered. - * - * @param options - * Optional parameters to refine the search: - * - `tip?: ClientBlockHeader` - * Reference block header for epoch and block-number lookups. - * Defaults to `await client.getTipHeader()`. - * - `onChain?: boolean` - * If `true`, uses `findCellsOnChain`; otherwise, uses `findCells`. - * Default: `false`. - * - `minLockUp?: ccc.Epoch` - * Minimum lock-up period required (in epochs). - * Defaults to the manager’s configured minimum (≈10 minutes). - * - `maxLockUp?: ccc.Epoch` - * Maximum lock-up period allowed (in epochs). - * Defaults to the manager’s configured maximum (≈3 days). - * - `pageSize?: number` - * Cell query page size per lock script. Defaults to `defaultCellPageSize` (400). - * - * @yields - * {@link DaoDepositCell} objects representing deposit cells. + * Finds DAO deposit cells for the given locks. * - * @remarks - * - Deduplicates `locks` via `unique(locks)`. - * - Applies an RPC filter with: - * • `script: this.script` (DAO type script) - * • `outputData: DaoManager.depositData()` (deposit flag) - * • `outputDataSearchMode: "exact"` - * - Skips any cell that: - * 1. Fails `this.isDeposit(cell)` - * 2. Has a non-matching lock script - * - Each yielded `DaoDepositCell` is constructed via `depositCellFrom(...)`. + * @param options - Scan options. `tip` controls readiness calculations, `onChain` bypasses cached cell queries, and `pageSize` is per lock. + * `minLockUp` and `maxLockUp` override deposit readiness windows. + * @remarks The transaction cache is shared across the scan so deposit conversions reuse transaction-header reads. */ - async *findDeposits( + public async *findDeposits( client: ccc.Client, locks: ccc.Script[], options?: { @@ -380,20 +331,23 @@ export class DaoManager implements ScriptDeps { "asc", ] as const; - const depositCandidates = (await collectPagedScan( - (pageSize) => options?.onChain - ? client.findCellsOnChain(...findCellsArgs, pageSize) - : client.findCells(...findCellsArgs, pageSize), - { pageSize }, - )).filter((cell) => this.isDeposit(cell) && cell.cellOutput.lock.eq(lock)); + const depositCandidates = ( + await collectPagedScan( + (scanPageSize) => + options?.onChain === true + ? client.findCellsOnChain(...findCellsArgs, scanPageSize) + : client.findCells(...findCellsArgs, scanPageSize), + { pageSize }, + ) + ).filter((cell) => this.isDeposit(cell) && cell.cellOutput.lock.eq(lock)); const deposits = await Promise.all( - depositCandidates.map((cell) => + depositCandidates.map(async (cell) => this.depositCellFrom(cell, client, { - minLockUp: options?.minLockUp, - maxLockUp: options?.maxLockUp, tip, transactionCache, + ...(options?.minLockUp === undefined ? {} : { minLockUp: options.minLockUp }), + ...(options?.maxLockUp === undefined ? {} : { maxLockUp: options.maxLockUp }), }), ), ); @@ -404,42 +358,12 @@ export class DaoManager implements ScriptDeps { } /** - * Async generator that finds and yields DAO withdrawal‐request cells - * for the specified lock scripts. - * - * @param client - * A CKB client instance that implements: - * - `findCells(query, order, pageSize)` — cached searches - * - `findCellsOnChain(query, order, pageSize)` — direct on-chain searches - * - * @param locks - * An array of lock scripts. Only cells whose `cellOutput.lock` exactly matches - * one of these scripts will be considered. - * - * @param options - * Optional parameters to refine the search: - * - `tip?: ClientBlockHeader` - * Reference block header for epoch and block-number lookups. - * Defaults to `await client.getTipHeader()`. - * - `onChain?: boolean` - * If `true`, uses `findCellsOnChain`; otherwise, uses `findCells`. - * Default: `false`. - * - `pageSize?: number` - * Cell query page size per lock script. Defaults to `defaultCellPageSize` (400). - * - * @yields - * {@link DaoWithdrawalRequestCell} objects representing withdrawal request cells. + * Finds DAO withdrawal request cells for the given locks. * - * @remarks - * - Deduplicates `locks` via `unique(locks)`. - * - Applies an RPC filter with: - * • `script: this.script` (DAO type script) - * - Skips any cell that: - * 1. Fails `this.isWithdrawalRequest(cell)` - * 2. Has a non-matching lock script - * - Each yielded `DaoWithdrawalRequestCell` is constructed via `withdrawalRequestCellFrom(...)`. + * @param options - Scan options. `tip` controls readiness calculations, `onChain` bypasses cached cell queries, and `pageSize` is per lock. + * @remarks Header and transaction caches are shared across the scan so withdrawal conversions reuse DAO reads. */ - async *findWithdrawalRequests( + public async *findWithdrawalRequests( client: ccc.Client, locks: ccc.Script[], options?: { @@ -467,15 +391,18 @@ export class DaoManager implements ScriptDeps { "asc", ] as const; - const withdrawalCandidates = (await collectPagedScan( - (pageSize) => options?.onChain - ? client.findCellsOnChain(...findCellsArgs, pageSize) - : client.findCells(...findCellsArgs, pageSize), - { pageSize }, - )).filter((cell) => this.isWithdrawalRequest(cell) && cell.cellOutput.lock.eq(lock)); + const withdrawalCandidates = ( + await collectPagedScan( + (scanPageSize) => + options?.onChain === true + ? client.findCellsOnChain(...findCellsArgs, scanPageSize) + : client.findCells(...findCellsArgs, scanPageSize), + { pageSize }, + ) + ).filter((cell) => this.isWithdrawalRequest(cell) && cell.cellOutput.lock.eq(lock)); const withdrawals = await Promise.all( - withdrawalCandidates.map((cell) => + withdrawalCandidates.map(async (cell) => this.withdrawalRequestCellFrom(cell, client, { tip, headerCache, @@ -488,4 +415,88 @@ export class DaoManager implements ScriptDeps { } } } + + private assertDepositReadyForWithdrawalRequest(deposit: DaoDepositCell): void { + const outPoint = deposit.cell.outPoint.toHex(); + if (!this.isDeposit(deposit.cell)) { + throw new Error(`DAO deposit ${outPoint} does not match this DAO script`); + } + const depositTxHash = deposit.headers[0].txHash; + if (depositTxHash !== deposit.cell.outPoint.txHash) { + throw new Error( + `DAO deposit ${outPoint} header txHash ${String(depositTxHash)} does not match cell txHash ${deposit.cell.outPoint.txHash}`, + ); + } + } + + private assertWithdrawalRequestReadyForWithdrawal( + withdrawalRequest: DaoWithdrawalRequestCell, + ): void { + const outPoint = withdrawalRequest.cell.outPoint.toHex(); + if (!this.isWithdrawalRequest(withdrawalRequest.cell)) { + throw new Error( + `DAO withdrawal request ${outPoint} does not match this DAO script`, + ); + } + const requestTxHash = withdrawalRequest.headers[1].txHash; + if (requestTxHash !== withdrawalRequest.cell.outPoint.txHash) { + throw new Error( + `DAO withdrawal request ${outPoint} header txHash ${String(requestTxHash)} does not match cell txHash ${withdrawalRequest.cell.outPoint.txHash}`, + ); + } + let depositBlockNumber: ccc.Num; + try { + depositBlockNumber = mol.Uint64LE.decode(withdrawalRequest.cell.outputData); + } catch (error) { + throw new Error( + `Invalid DAO withdrawal request payload at ${outPoint}: ${withdrawalRequest.cell.outputData}`, + { cause: error }, + ); + } + if (depositBlockNumber !== withdrawalRequest.headers[0].header.number) { + throw new Error( + `DAO withdrawal request ${outPoint} deposit block ${String(depositBlockNumber)} does not match header block ${String(withdrawalRequest.headers[0].header.number)}`, + ); + } + } +} + +function assertUniqueUnspentInputs( + tx: ccc.Transaction, + outPoints: ccc.OutPoint[], + label: string, +): void { + const spent = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); + const selected = new Set(); + for (const outPoint of outPoints) { + const key = outPoint.toHex(); + if (selected.has(key)) { + throw new Error(`${label} ${key} is duplicated`); + } + selected.add(key); + if (spent.has(key)) { + throw new Error(`${label} ${key} is already being spent`); + } + } +} + +async function cellFromLike( + cellLike: ccc.Cell | ccc.OutPoint, + client: ccc.Client, +): Promise { + if (!(cellLike instanceof ccc.OutPoint)) { + return cellLike; + } + let cell: ccc.Cell | undefined; + try { + cell = await client.getCell(cellLike); + } catch (error) { + throw new Error(`Failed to load cell for out point ${cellLike.toHex()}`, { + cause: error, + }); + } + if (cell === undefined) { + throw new Error(`Cell not found for out point ${cellLike.toHex()}`); + } + return cell; } diff --git a/packages/dao/src/dao_output_limit.ts b/packages/dao/src/dao_output_limit.ts new file mode 100644 index 0000000..42cd204 --- /dev/null +++ b/packages/dao/src/dao_output_limit.ts @@ -0,0 +1,41 @@ +import { ccc } from "@ckb-ccc/core"; + +/** + * Maximum output count accepted by the Nervos DAO validator path. + * + * @public + */ +export const DAO_OUTPUT_LIMIT = 64; + +/** + * Throws when a transaction exceeds the Nervos DAO output limit. + * + * @public + */ +export async function assertDaoOutputLimit( + txLike: ccc.TransactionLike | ccc.Transaction, + client: ccc.Client, +): Promise { + const tx = ccc.Transaction.from(txLike); + if (await ccc.isDaoOutputLimitExceeded(tx, client)) { + throw new DaoOutputLimitError(tx.outputs.length); + } +} + +/** + * Error thrown when a DAO transaction exceeds the protocol output limit. + * + * @public + */ +export class DaoOutputLimitError extends Error { + /** + * Creates an output-limit error for a transaction with too many outputs. + */ + constructor(outputCount: number, options?: ErrorOptions) { + super( + `NervosDAO transaction has ${String(outputCount)} output cells, exceeding the limit of ${String(DAO_OUTPUT_LIMIT)}`, + options, + ); + this.name = "DaoOutputLimitError"; + } +} diff --git a/packages/dao/src/index.ts b/packages/dao/src/index.ts index fd72b69..8741ad7 100644 --- a/packages/dao/src/index.ts +++ b/packages/dao/src/index.ts @@ -1,6 +1,21 @@ +/** + * Nervos DAO cell decoding, scanning, and transaction builders for iCKB Stack. + * + * @packageDocumentation + */ + export type { + DaoCellBase, DaoCellFromCache, + DaoCellFromOptions, DaoDepositCell, DaoWithdrawalRequestCell, -} from "./cells.js"; -export * from "./dao.js"; + TransactionWithHeader, +} from "./cells.ts"; +export { DaoManager } from "./dao.ts"; +export type { DaoCellFromOptions as DaoManagerCellFromOptions } from "./dao.ts"; +export { + DAO_OUTPUT_LIMIT, + DaoOutputLimitError, + assertDaoOutputLimit, +} from "./dao_output_limit.ts"; diff --git a/packages/dao/src/transaction_shape.ts b/packages/dao/src/transaction_shape.ts new file mode 100644 index 0000000..4ff8d87 --- /dev/null +++ b/packages/dao/src/transaction_shape.ts @@ -0,0 +1,17 @@ +import type { ccc } from "@ckb-ccc/core"; + +export function cellInputLikeFrom(cell: ccc.Cell): ccc.CellInputLike { + return { + outPoint: cell.outPoint, + cellOutput: cellOutputLikeFrom(cell.cellOutput), + outputData: cell.outputData, + }; +} + +export function cellOutputLikeFrom(cellOutput: ccc.CellOutput): ccc.CellOutputLike { + return { + capacity: cellOutput.capacity, + lock: cellOutput.lock, + ...(cellOutput.type === undefined ? {} : { type: cellOutput.type }), + }; +} diff --git a/packages/dao/test/cells.ts b/packages/dao/test/cells.ts new file mode 100644 index 0000000..78d8a9c --- /dev/null +++ b/packages/dao/test/cells.ts @@ -0,0 +1,306 @@ +import { ccc } from "@ckb-ccc/core"; +import { + byte32FromByte, + script, + StubClient, + headerLike as testHeaderLike, +} from "@ickb/testkit"; +import { expect, it, vi } from "vitest"; +import { DaoManager } from "../src/dao.ts"; + +const DAO_CELL_WITHDRAWAL_READINESS_SUITE = "daoCellFrom withdrawal readiness"; +const FULL_WORKSPACE_TIMEOUT_MS = 20_000; + +function headerLike( + epoch: [bigint, bigint, bigint], + number: bigint, + timestamp = 0n, +): ccc.ClientBlockHeader { + return testHeaderLike({ + epoch, + number, + timestamp, + }); +} + +function withdrawalCell(): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("11"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("22"), + type: script("33"), + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); +} + +function clientFor( + depositHeader: ccc.ClientBlockHeader, + withdrawHeader: ccc.ClientBlockHeader, +): ccc.Client { + return new StubClient({ + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return depositHeader; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return { + transaction: ccc.ClientTransactionResponse.from({ + transaction: ccc.Transaction.default(), + status: "committed", + }), + header: withdrawHeader, + }; + }, + }); +} + +it(`${DAO_CELL_WITHDRAWAL_READINESS_SUITE} marks withdrawal requests ready once the claim epoch is reached`, async () => { + const manager = new DaoManager(script("33"), []); + const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); + const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); + const tip = ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)); + const claimEpoch = ccc.calcDaoClaimEpoch(depositHeader, withdrawHeader); + + const daoCell = await manager.withdrawalRequestCellFrom( + withdrawalCell(), + clientFor(depositHeader, withdrawHeader), + { tip }, + ); + + expect(daoCell.maturity.eq(claimEpoch)).toBe(true); + expect(daoCell.isReady).toBe(true); +}); + +it(`${DAO_CELL_WITHDRAWAL_READINESS_SUITE} keeps withdrawal requests pending before the claim epoch`, async () => { + const manager = new DaoManager(script("33"), []); + const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); + const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); + const tip = ccc.ClientBlockHeader.from(headerLike([179n, 0n, 1n], 3n)); + + const daoCell = await manager.withdrawalRequestCellFrom( + withdrawalCell(), + clientFor(depositHeader, withdrawHeader), + { tip }, + ); + + expect(daoCell.isReady).toBe(false); +}); + +it(`${DAO_CELL_WITHDRAWAL_READINESS_SUITE} rejects invalid withdrawal payloads`, async () => { + const manager = new DaoManager(script("33"), []); + const cell = withdrawalCell(); + cell.outputData = "0x12"; + + await expect( + manager.withdrawalRequestCellFrom(cell, new StubClient(), { + tip: ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)), + }), + ).rejects.toThrow("Invalid DAO withdrawal request payload"); +}); + +it(`${DAO_CELL_WITHDRAWAL_READINESS_SUITE} fetches withdrawal deposit and request headers concurrently`, async () => { + const manager = new DaoManager(script("33"), []); + const depositHeader = ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); + const withdrawHeader = ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)); + const tip = ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)); + const calls: string[] = []; + const { promise: depositFetch, resolve: resolveDeposit } = Promise.withResolvers< + ccc.ClientBlockHeader | undefined + >(); + const { promise: withdrawFetch, resolve: resolveWithdraw } = + Promise.withResolvers>>(); + const client = new StubClient({ + getHeaderByNumber: async (): ReturnType => { + calls.push("deposit"); + return depositFetch; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + calls.push("withdraw"); + return withdrawFetch; + }, + }); + + const daoCellPromise = manager.withdrawalRequestCellFrom(withdrawalCell(), client, { + tip, + }); + + await vi.waitFor(() => { + expect(calls).toEqual(["deposit", "withdraw"]); + }); + resolveWithdraw({ + transaction: ccc.ClientTransactionResponse.from({ + transaction: ccc.Transaction.default(), + status: "committed", + }), + header: withdrawHeader, + }); + await Promise.resolve(); + resolveDeposit(depositHeader); + + const daoCell = await daoCellPromise; + + expect(daoCell.headers[0].header.number).toBe(depositHeader.number); + expect(daoCell.headers[1].header.number).toBe(withdrawHeader.number); +}); + +it( + `${DAO_CELL_WITHDRAWAL_READINESS_SUITE} rejects cells when required headers are unavailable`, + async () => { + const manager = new DaoManager(script("33"), []); + const deposit = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("22"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("22"), + type: manager.script, + }, + outputData: DaoManager.depositData(), + }); + const missingTransactionClient = new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return undefined; + }, + }); + const missingDepositHeaderClient = new StubClient({ + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return undefined; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return { + transaction: ccc.ClientTransactionResponse.from({ + transaction: ccc.Transaction.default(), + status: "committed", + }), + header: ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)), + }; + }, + }); + const missingDepositHeaderCell = withdrawalCell(); + const missingRequestHeaderCell = withdrawalCell(); + + await expect( + manager.depositCellFrom(deposit, missingTransactionClient, { + tip: ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)), + }), + ).rejects.toThrow( + `Header not found for txHash ${deposit.outPoint.txHash} at ${deposit.outPoint.toHex()}`, + ); + await expect( + manager.withdrawalRequestCellFrom( + missingDepositHeaderCell, + missingDepositHeaderClient, + { + tip: ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)), + }, + ), + ).rejects.toThrow( + `Header not found for block number 1 at ${missingDepositHeaderCell.outPoint.toHex()}`, + ); + await expect( + manager.withdrawalRequestCellFrom( + missingRequestHeaderCell, + missingTransactionClient, + { + tip: ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)), + }, + ), + ).rejects.toThrow( + `Header not found for txHash ${missingRequestHeaderCell.outPoint.txHash} at ${missingRequestHeaderCell.outPoint.toHex()}`, + ); + }, + FULL_WORKSPACE_TIMEOUT_MS, +); + +it(`${DAO_CELL_WITHDRAWAL_READINESS_SUITE} preserves cell context when header reads fail`, async () => { + const manager = new DaoManager(script("33"), []); + const depositTransactionError = new Error("transaction rpc failed"); + const depositHeaderError = new Error("header rpc failed"); + const withdrawalTransactionError = new Error("transaction rpc failed"); + const deposit = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("22"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("22"), + type: manager.script, + }, + outputData: DaoManager.depositData(), + }); + const withdrawal = withdrawalCell(); + const failedTransactionClient = new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + throw depositTransactionError; + }, + }); + const failedDepositHeaderClient = new StubClient({ + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + throw depositHeaderError; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return { + transaction: ccc.ClientTransactionResponse.from({ + transaction: ccc.Transaction.default(), + status: "committed", + }), + header: ccc.ClientBlockHeader.from(headerLike([180n, 0n, 1n], 2n)), + }; + }, + }); + const failedWithdrawalTransactionClient = new StubClient({ + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)); + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + throw withdrawalTransactionError; + }, + }); + + await expect( + manager.depositCellFrom(deposit, failedTransactionClient, { + tip: ccc.ClientBlockHeader.from(headerLike([1n, 0n, 1n], 1n)), + }), + ).rejects.toMatchObject({ + message: `Failed to load transaction header for txHash ${deposit.outPoint.txHash} at ${deposit.outPoint.toHex()}`, + cause: depositTransactionError, + }); + await expect( + manager.withdrawalRequestCellFrom(withdrawal, failedDepositHeaderClient, { + tip: ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)), + }), + ).rejects.toMatchObject({ + message: `Failed to load header for block number 1 at ${withdrawal.outPoint.toHex()}`, + cause: depositHeaderError, + }); + await expect( + manager.withdrawalRequestCellFrom(withdrawal, failedWithdrawalTransactionClient, { + tip: ccc.ClientBlockHeader.from(headerLike([181n, 0n, 1n], 3n)), + }), + ).rejects.toMatchObject({ + message: `Failed to load transaction header for txHash ${withdrawal.outPoint.txHash} at ${withdrawal.outPoint.toHex()}`, + cause: withdrawalTransactionError, + }); +}); diff --git a/packages/dao/test/dao.ts b/packages/dao/test/dao.ts new file mode 100644 index 0000000..0ce52fe --- /dev/null +++ b/packages/dao/test/dao.ts @@ -0,0 +1,366 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { + assertDaoOutputLimit, + DAO_OUTPUT_LIMIT, + DaoManager, + DaoOutputLimitError, +} from "../src/index.ts"; +import { cellOutputLikeFrom } from "../src/transaction_shape.ts"; +import { + byte32FromByte, + client, + depositCell, + headerLike, + REQUEST_WITHDRAWAL_SUITE, + script, + StubClient, +} from "./support/dao_support.ts"; + +describe(REQUEST_WITHDRAWAL_SUITE, () => { + it("omits an output type when preserving a plain cell output", () => { + const lock = script("11"); + const output = ccc.CellOutput.from({ capacity: 42n, lock }); + + const like = cellOutputLikeFrom(output); + + expect(like).not.toHaveProperty("type"); + expect(like.capacity).toBe(42n); + expect(like.lock).toBe(output.lock); + }); + + it("adds DAO deposit outputs and leaves empty deposits unchanged", async () => { + const manager = new DaoManager(script("11"), [ + ccc.CellDep.from({ + outPoint: ccc.OutPoint.from({ txHash: byte32FromByte("aa"), index: 0n }), + depType: "code", + }), + ]); + const baseTx = ccc.Transaction.default(); + + await expect(manager.deposit(baseTx, [], script("22"), client())).resolves.toEqual( + baseTx, + ); + + const tx = await manager.deposit( + ccc.Transaction.default(), + [ccc.fixedPointFrom(100082)], + script("22"), + client(), + ); + + expect(tx.cellDeps).toHaveLength(1); + expect(tx.outputs).toHaveLength(1); + expect(tx.outputs[0]?.type?.eq(manager.script)).toBe(true); + expect(tx.outputsData).toEqual([DaoManager.depositData()]); + }); + + it("does not apply the DAO output limit to non-DAO transactions", async () => { + const tx = ccc.Transaction.default(); + for (let index = 0; index <= DAO_OUTPUT_LIMIT; index += 1) { + tx.addOutput({ capacity: 1n, lock: script("99") }, "0x"); + } + + await expect(assertDaoOutputLimit(tx, new StubClient())).resolves.toBeUndefined(); + }); + + it("throws a typed DAO output-limit error", async () => { + const testClient = new StubClient(); + const knownDaoScript = await testClient.getKnownScript(ccc.KnownScript.NervosDao); + const daoType = ccc.Script.from({ + codeHash: knownDaoScript.codeHash, + hashType: knownDaoScript.hashType, + args: "0x", + }); + const tx = ccc.Transaction.default(); + for (let index = 0; index <= DAO_OUTPUT_LIMIT; index += 1) { + tx.addOutput( + { capacity: 1n, lock: script("99"), type: index === 0 ? daoType : undefined }, + "0x", + ); + } + + await expect(assertDaoOutputLimit(tx, testClient)).rejects.toBeInstanceOf( + DaoOutputLimitError, + ); + }); + + it("always rejects withdrawal locks with different args size", async () => { + const manager = new DaoManager(script("11"), []); + const deposit = depositCell(manager, { lock: script("33", "0x1234") }); + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [deposit], + script("44", "0x12"), + client(), + ), + ).rejects.toThrow("Withdrawal request lock args has different size from deposit"); + }); +}); + +describe(REQUEST_WITHDRAWAL_SUITE, () => { + registerRequestWithdrawalSelectionTests(); + registerRequestWithdrawalValidationTests(); +}); + +function registerRequestWithdrawalSelectionTests(): void { + it("keeps non-ready deposits unless isReadyOnly is set", async () => { + const manager = new DaoManager(script("11"), []); + const pending = depositCell(manager, { isReady: false, txHashByte: "22" }); + const ready = depositCell(manager, { isReady: true, txHashByte: "23" }); + + const tx = await manager.requestWithdrawal( + ccc.Transaction.default(), + [pending, ready], + script("44"), + client(), + ); + + expect(tx.inputs).toHaveLength(2); + expect(tx.outputs).toHaveLength(2); + expect(tx.outputsData).toHaveLength(2); + }); + + it("filters non-ready deposits when isReadyOnly is set", async () => { + const manager = new DaoManager(script("11"), []); + const pending = depositCell(manager, { isReady: false, txHashByte: "22" }); + const ready = depositCell(manager, { isReady: true, txHashByte: "23" }); + + const tx = await manager.requestWithdrawal( + ccc.Transaction.default(), + [pending, ready], + script("44"), + client(), + { isReadyOnly: true }, + ); + + expect(tx.inputs).toHaveLength(1); + expect(tx.outputs).toHaveLength(1); + expect(tx.inputs[0]?.previousOutput.txHash).toBe(ready.cell.outPoint.txHash); + }); + + it("leaves the transaction unchanged when ready-only deposits are all pending", async () => { + const manager = new DaoManager(script("11"), []); + const baseTx = ccc.Transaction.default(); + + await expect( + manager.requestWithdrawal( + baseTx, + [depositCell(manager, { isReady: false })], + script("44"), + client(), + { isReadyOnly: true }, + ), + ).resolves.toEqual(baseTx); + }); + + it("does not duplicate existing deposit header deps", async () => { + const manager = new DaoManager(script("11"), []); + const deposit = depositCell(manager); + const tx = ccc.Transaction.default(); + tx.headerDeps.push(deposit.headers[0].header.hash); + + const updated = await manager.requestWithdrawal( + tx, + [deposit], + script("44"), + client(), + ); + + expect(updated.headerDeps).toEqual([deposit.headers[0].header.hash]); + }); + + it("requires matched input and output counts before appending requests", async () => { + const manager = new DaoManager(script("11"), []); + const tx = ccc.Transaction.default(); + tx.addOutput({ capacity: ccc.fixedPointFrom(1000), lock: script("55") }, "0x"); + + await expect( + manager.requestWithdrawal(tx, [depositCell(manager)], script("44"), client()), + ).rejects.toThrow("Transaction has different inputs and outputs lengths"); + }); +} + +function registerRequestWithdrawalValidationTests(): void { + it("rejects deposits whose DAO type script was erased", async () => { + const manager = new DaoManager(script("11"), []); + const deposit = depositCell(manager); + deposit.cell.cellOutput.type = undefined; + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [deposit], + script("44"), + client(), + ), + ).rejects.toThrow( + `DAO deposit ${deposit.cell.outPoint.toHex()} does not match this DAO script`, + ); + }); + + it("rejects deposits whose header tx hash does not match the cell", async () => { + const manager = new DaoManager(script("11"), []); + const deposit = depositCell(manager); + deposit.headers[0] = { ...deposit.headers[0], txHash: byte32FromByte("99") }; + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [deposit], + script("44"), + client(), + ), + ).rejects.toThrow("header txHash"); + }); + + it("rejects duplicated or already-spent deposit inputs", async () => { + const manager = new DaoManager(script("11"), []); + const deposit = depositCell(manager); + const tx = ccc.Transaction.default(); + tx.addInput(deposit.cell); + tx.addOutput( + { capacity: deposit.cell.cellOutput.capacity, lock: script("44") }, + "0x", + ); + + await expect( + manager.requestWithdrawal( + ccc.Transaction.default(), + [deposit, deposit], + script("44"), + client(), + ), + ).rejects.toThrow(`DAO deposit ${deposit.cell.outPoint.toHex()} is duplicated`); + await expect( + manager.requestWithdrawal(tx, [deposit], script("44"), client()), + ).rejects.toThrow( + `DAO deposit ${deposit.cell.outPoint.toHex()} is already being spent`, + ); + }); +} + +describe("DaoManager cell decoding ownership from out points", () => { + it("loads DAO cells from out points before decoding", async () => { + const manager = new DaoManager(script("11"), []); + const deposit = depositCell(manager).cell; + const withdrawal = ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("23"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("33"), + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); + const testClient = new StubClient({ + getCell: async (outPoint): ReturnType => { + await Promise.resolve(); + return ccc.OutPoint.from(outPoint).eq(deposit.outPoint) ? deposit : withdrawal; + }, + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return headerLike(1n); + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return { + transaction: ccc.ClientTransactionResponse.from({ + transaction: ccc.Transaction.default(), + status: "committed", + }), + header: headerLike(2n), + }; + }, + }); + + await expect( + manager.depositCellFrom(deposit.outPoint, testClient, { tip: headerLike(3n) }), + ).resolves.toMatchObject({ isDeposit: true }); + await expect( + manager.withdrawalRequestCellFrom(withdrawal.outPoint, testClient, { + tip: headerLike(3n), + }), + ).resolves.toMatchObject({ isDeposit: false }); + }); + + it("rejects missing cells loaded by out point", async () => { + const manager = new DaoManager(script("11"), []); + const missingOutPoint = ccc.OutPoint.from({ + txHash: byte32FromByte("22"), + index: 0n, + }); + const testClient = new StubClient({ + getCell: async (): ReturnType => { + await Promise.resolve(); + return undefined; + }, + }); + + await expect( + manager.depositCellFrom(missingOutPoint, testClient, { tip: headerLike(2n) }), + ).rejects.toThrow(`Cell not found for out point ${missingOutPoint.toHex()}`); + await expect( + manager.withdrawalRequestCellFrom(missingOutPoint, testClient, { + tip: headerLike(2n), + }), + ).rejects.toThrow(`Cell not found for out point ${missingOutPoint.toHex()}`); + }); +}); + +describe("DaoManager cell decoding ownership from out point failures", () => { + it("preserves out point context when cell loads fail", async () => { + const manager = new DaoManager(script("11"), []); + const cellError = new Error("cell rpc failed"); + const outPoint = ccc.OutPoint.from({ txHash: byte32FromByte("22"), index: 0n }); + const testClient = new StubClient({ + getCell: async (): ReturnType => { + await Promise.resolve(); + throw cellError; + }, + }); + + await expect( + manager.depositCellFrom(outPoint, testClient, { tip: headerLike(2n) }), + ).rejects.toMatchObject({ + message: `Failed to load cell for out point ${outPoint.toHex()}`, + cause: cellError, + }); + }); +}); + +describe("DaoManager cell decoding ownership rejection paths", () => { + it("rejects depositCellFrom on non-deposit cells", async () => { + const manager = new DaoManager(script("11"), []); + + await expect( + manager.depositCellFrom( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("22"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("33"), + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(1n), + }), + client(), + { tip: headerLike(2n) }, + ), + ).rejects.toThrow("Not a deposit"); + }); + + it("rejects withdrawalRequestCellFrom on non-withdrawal cells", async () => { + const manager = new DaoManager(script("11"), []); + + await expect( + manager.withdrawalRequestCellFrom(depositCell(manager).cell, client(), { + tip: headerLike(2n), + }), + ).rejects.toThrow("Not a withdrawal request"); + }); +}); diff --git a/packages/dao/src/deposit_readiness.test.ts b/packages/dao/test/deposit_readiness.ts similarity index 74% rename from packages/dao/src/deposit_readiness.test.ts rename to packages/dao/test/deposit_readiness.ts index ad5d095..8da3543 100644 --- a/packages/dao/src/deposit_readiness.test.ts +++ b/packages/dao/test/deposit_readiness.ts @@ -1,7 +1,9 @@ import { ccc } from "@ckb-ccc/core"; -import { headerLike, hash, script } from "@ickb/testkit"; +import { byte32FromByte, headerLike, script, StubClient } from "@ickb/testkit"; import { describe, expect, it } from "vitest"; -import { DaoManager } from "./dao.js"; +import { DaoManager } from "../src/dao.ts"; + +const hash = byte32FromByte; function depositCell(lock: ccc.Script, dao: ccc.Script): ccc.Cell { return ccc.Cell.from({ @@ -16,9 +18,20 @@ function depositCell(lock: ccc.Script, dao: ccc.Script): ccc.Cell { } function clientFor(depositHeader: ccc.ClientBlockHeader): ccc.Client { - return { - getTransactionWithHeader: () => Promise.resolve({ header: depositHeader }), - } as unknown as ccc.Client; + return new StubClient({ + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return { + transaction: ccc.ClientTransactionResponse.from({ + transaction: ccc.Transaction.default(), + status: "committed", + }), + header: depositHeader, + }; + }, + }); } describe("daoCellFrom deposit readiness boundaries", () => { @@ -40,7 +53,9 @@ describe("daoCellFrom deposit readiness boundaries", () => { ); expect(daoCell.isReady).toBe(false); - expect(daoCell.maturity.eq(ccc.calcDaoClaimEpoch(depositHeader, tip).add([180n, 0n, 1n]))).toBe(true); + expect( + daoCell.maturity.eq(ccc.calcDaoClaimEpoch(depositHeader, tip).add([180n, 0n, 1n])), + ).toBe(true); }); it("keeps deposits at the exact max boundary out of the ready window", async () => { diff --git a/packages/dao/test/find_deposits.ts b/packages/dao/test/find_deposits.ts new file mode 100644 index 0000000..dbe8fd9 --- /dev/null +++ b/packages/dao/test/find_deposits.ts @@ -0,0 +1,229 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it, vi } from "vitest"; +import { DaoManager } from "../src/index.ts"; +import { + byte32FromByte, + collect, + FIND_DEPOSITS_SUITE, + headerLike, + script, + StubClient, + transactionWithHeader, + type TransactionWithHeader, +} from "./support/dao_support.ts"; + +describe(`${FIND_DEPOSITS_SUITE} scan paging`, () => { + it("passes the cell page size to deposit scanning", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const [firstDeposit, secondDeposit] = depositCells(manager, lock, "33", "44"); + let requestedPageSize = 0; + const testClient = new StubClient({ + async *findCells( + _query, + _order, + pageSize = 10, + ): ReturnType { + requestedPageSize = pageSize; + await Promise.resolve(); + yield firstDeposit; + yield secondDeposit; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(headerLike(1n)); + }, + }); + + const deposits = await collect( + manager.findDeposits(testClient, [lock], { tip: headerLike(3n), pageSize: 1 }), + ); + + expect(requestedPageSize).toBe(1); + expect(deposits.map((deposit) => deposit.cell.outPoint.txHash)).toEqual([ + firstDeposit.outPoint.txHash, + secondDeposit.outPoint.txHash, + ]); + }); + + it("uses default tip and on-chain scans when requested", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const [deposit] = depositCells(manager, lock, "33", "44"); + let tipReads = 0; + let onChainPageSize = 0; + const testClient = new StubClient({ + getTipHeader: async (): ReturnType => { + tipReads += 1; + await Promise.resolve(); + return headerLike(3n); + }, + async *findCellsOnChain( + _query, + _order, + pageSize = 10, + ): ReturnType { + onChainPageSize = pageSize; + await Promise.resolve(); + yield deposit; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(headerLike(1n)); + }, + }); + + const deposits = await collect( + manager.findDeposits(testClient, [lock], { + onChain: true, + minLockUp: ccc.Epoch.from([0n, 0n, 1n]), + maxLockUp: ccc.Epoch.from([200n, 0n, 1n]), + }), + ); + + expect(tipReads).toBe(1); + expect(onChainPageSize).toBeGreaterThan(1); + expect(deposits).toHaveLength(1); + }); +}); + +describe(`${FIND_DEPOSITS_SUITE} concurrent decoding`, () => { + it("decodes deposits concurrently and yields scan order", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const [firstDeposit, secondDeposit] = depositCells(manager, lock, "33", "44"); + const requests: ccc.Hex[] = []; + const { promise: firstFetch, resolve: resolveFirst } = + Promise.withResolvers(); + const { promise: secondFetch, resolve: resolveSecond } = + Promise.withResolvers(); + const testClient = new StubClient({ + async *findCells(): ReturnType { + await Promise.resolve(); + yield firstDeposit; + yield secondDeposit; + }, + getTransactionWithHeader: async ( + txHash, + ): ReturnType => { + const hash = ccc.hexFrom(txHash); + requests.push(hash); + return hash === firstDeposit.outPoint.txHash ? firstFetch : secondFetch; + }, + }); + + const depositsPromise = collect( + manager.findDeposits(testClient, [lock], { tip: headerLike(3n) }), + ); + + await vi.waitFor(() => { + expect(requests).toEqual([ + firstDeposit.outPoint.txHash, + secondDeposit.outPoint.txHash, + ]); + }); + resolveSecond(transactionWithHeader(headerLike(1n))); + await Promise.resolve(); + resolveFirst(transactionWithHeader(headerLike(1n))); + + const deposits = await depositsPromise; + + expect(deposits.map((deposit) => deposit.cell.outPoint.txHash)).toEqual([ + firstDeposit.outPoint.txHash, + secondDeposit.outPoint.txHash, + ]); + }); +}); + +describe(`${FIND_DEPOSITS_SUITE} single-lock cache reuse`, () => { + it("deduplicates deposit transaction header requests during a scan", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const txHash = byte32FromByte("33"); + const firstDeposit = depositCell(manager, lock, txHash, 0n); + const secondDeposit = depositCell(manager, lock, txHash, 1n); + let transactionCalls = 0; + const testClient = new StubClient({ + async *findCells(): ReturnType { + await Promise.resolve(); + yield firstDeposit; + yield secondDeposit; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + transactionCalls += 1; + await Promise.resolve(); + return transactionWithHeader(headerLike(1n)); + }, + }); + + const deposits = await collect( + manager.findDeposits(testClient, [lock], { tip: headerLike(3n) }), + ); + + expect(transactionCalls).toBe(1); + expect(deposits).toHaveLength(2); + }); +}); + +describe(`${FIND_DEPOSITS_SUITE} multi-lock cache reuse`, () => { + it("reuses deposit transaction header requests across lock scans", async () => { + const manager = new DaoManager(script("11"), []); + const firstLock = script("22"); + const secondLock = script("33"); + const txHash = byte32FromByte("44"); + const firstDeposit = depositCell(manager, firstLock, txHash, 0n); + const secondDeposit = depositCell(manager, secondLock, txHash, 1n); + let transactionCalls = 0; + const testClient = new StubClient({ + async *findCells(query): ReturnType { + await Promise.resolve(); + yield ccc.Script.from(query.script).eq(firstLock) ? firstDeposit : secondDeposit; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + transactionCalls += 1; + await Promise.resolve(); + return transactionWithHeader(headerLike(1n)); + }, + }); + + const deposits = await collect( + manager.findDeposits(testClient, [firstLock, secondLock], { tip: headerLike(3n) }), + ); + + expect(transactionCalls).toBe(1); + expect(deposits.map((deposit) => deposit.cell.outPoint.index)).toEqual([0n, 1n]); + }); +}); + +function depositCells( + manager: DaoManager, + lock: ccc.Script, + firstByte: string, + secondByte: string, +): [ccc.Cell, ccc.Cell] { + return [ + depositCell(manager, lock, byte32FromByte(firstByte), 0n), + depositCell(manager, lock, byte32FromByte(secondByte), 0n), + ]; +} + +function depositCell( + manager: DaoManager, + lock: ccc.Script, + txHash: ccc.Hex, + index: bigint, +): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash, index }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock, type: manager.script }, + outputData: DaoManager.depositData(), + }); +} diff --git a/packages/dao/test/find_withdrawal_requests.ts b/packages/dao/test/find_withdrawal_requests.ts new file mode 100644 index 0000000..76b5f3d --- /dev/null +++ b/packages/dao/test/find_withdrawal_requests.ts @@ -0,0 +1,273 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it, vi } from "vitest"; +import { DaoManager } from "../src/index.ts"; +import { + byte32FromByte, + collect, + FIND_WITHDRAWAL_REQUESTS_SUITE, + headerLike, + script, + StubClient, + transactionWithHeader, + type TransactionWithHeader, +} from "./support/dao_support.ts"; + +describe(`${FIND_WITHDRAWAL_REQUESTS_SUITE} scan page size`, () => { + it("passes the cell page size to withdrawal request scanning", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const [firstWithdrawal, secondWithdrawal] = withdrawalCells( + manager, + lock, + "55", + "66", + ); + let requestedPageSize = 0; + const testClient = new StubClient({ + async *findCells( + _query, + _order, + pageSize = 10, + ): ReturnType { + requestedPageSize = pageSize; + await Promise.resolve(); + yield firstWithdrawal; + yield secondWithdrawal; + }, + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return headerLike(1n); + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(headerLike(2n)); + }, + }); + + const withdrawals = await collect( + manager.findWithdrawalRequests(testClient, [lock], { + tip: headerLike(3n), + pageSize: 1, + }), + ); + + expect(requestedPageSize).toBe(1); + expect(withdrawals.map((withdrawal) => withdrawal.cell.outPoint.txHash)).toEqual([ + firstWithdrawal.outPoint.txHash, + secondWithdrawal.outPoint.txHash, + ]); + }); +}); + +describe(`${FIND_WITHDRAWAL_REQUESTS_SUITE} on-chain scanning`, () => { + it("uses default tip and on-chain scans when requested", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const [withdrawal] = withdrawalCells(manager, lock, "55", "66"); + let tipReads = 0; + let onChainPageSize = 0; + const testClient = new StubClient({ + getTipHeader: async (): ReturnType => { + tipReads += 1; + await Promise.resolve(); + return headerLike(3n); + }, + async *findCellsOnChain( + _query, + _order, + pageSize = 10, + ): ReturnType { + onChainPageSize = pageSize; + await Promise.resolve(); + yield withdrawal; + }, + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return headerLike(1n); + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return transactionWithHeader(headerLike(2n)); + }, + }); + + const withdrawals = await collect( + manager.findWithdrawalRequests(testClient, [lock], { onChain: true }), + ); + + expect(tipReads).toBe(1); + expect(onChainPageSize).toBeGreaterThan(1); + expect(withdrawals).toHaveLength(1); + }); +}); + +describe(`${FIND_WITHDRAWAL_REQUESTS_SUITE} concurrent decoding`, () => { + it("decodes withdrawals concurrently and yields scan order", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const [firstWithdrawal, secondWithdrawal] = withdrawalCells( + manager, + lock, + "55", + "66", + ); + const requests: ccc.Hex[] = []; + const { promise: firstFetch, resolve: resolveFirst } = + Promise.withResolvers(); + const { promise: secondFetch, resolve: resolveSecond } = + Promise.withResolvers(); + const testClient = new StubClient({ + async *findCells(): ReturnType { + await Promise.resolve(); + yield firstWithdrawal; + yield secondWithdrawal; + }, + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return headerLike(1n); + }, + getTransactionWithHeader: async ( + txHash, + ): ReturnType => { + const hash = ccc.hexFrom(txHash); + requests.push(hash); + return hash === firstWithdrawal.outPoint.txHash ? firstFetch : secondFetch; + }, + }); + + const withdrawalsPromise = collect( + manager.findWithdrawalRequests(testClient, [lock], { tip: headerLike(3n) }), + ); + + await vi.waitFor(() => { + expect(requests).toEqual([ + firstWithdrawal.outPoint.txHash, + secondWithdrawal.outPoint.txHash, + ]); + }); + resolveSecond(transactionWithHeader(headerLike(2n))); + await Promise.resolve(); + resolveFirst(transactionWithHeader(headerLike(2n))); + + const withdrawals = await withdrawalsPromise; + + expect(withdrawals.map((withdrawal) => withdrawal.cell.outPoint.txHash)).toEqual([ + firstWithdrawal.outPoint.txHash, + secondWithdrawal.outPoint.txHash, + ]); + }); +}); + +describe(`${FIND_WITHDRAWAL_REQUESTS_SUITE} single-lock cache reuse`, () => { + it("deduplicates withdrawal transaction and deposit header requests during a scan", async () => { + const manager = new DaoManager(script("11"), []); + const lock = script("22"); + const txHash = byte32FromByte("55"); + const firstWithdrawal = withdrawalCell(manager, lock, txHash, 0n); + const secondWithdrawal = withdrawalCell(manager, lock, txHash, 1n); + let headerCalls = 0; + let transactionCalls = 0; + const testClient = new StubClient({ + async *findCells(): ReturnType { + await Promise.resolve(); + yield firstWithdrawal; + yield secondWithdrawal; + }, + getHeaderByNumber: async (): ReturnType => { + headerCalls += 1; + await Promise.resolve(); + return headerLike(1n); + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + transactionCalls += 1; + await Promise.resolve(); + return transactionWithHeader(headerLike(2n)); + }, + }); + + const withdrawals = await collect( + manager.findWithdrawalRequests(testClient, [lock], { tip: headerLike(3n) }), + ); + + expect(headerCalls).toBe(1); + expect(transactionCalls).toBe(1); + expect(withdrawals).toHaveLength(2); + }); +}); + +describe(`${FIND_WITHDRAWAL_REQUESTS_SUITE} multi-lock cache reuse`, () => { + it("reuses withdrawal transaction and deposit header requests across lock scans", async () => { + const manager = new DaoManager(script("11"), []); + const firstLock = script("22"); + const secondLock = script("33"); + const txHash = byte32FromByte("55"); + const firstWithdrawal = withdrawalCell(manager, firstLock, txHash, 0n); + const secondWithdrawal = withdrawalCell(manager, secondLock, txHash, 1n); + let headerCalls = 0; + let transactionCalls = 0; + const testClient = new StubClient({ + async *findCells(query): ReturnType { + await Promise.resolve(); + yield ccc.Script.from(query.script).eq(firstLock) + ? firstWithdrawal + : secondWithdrawal; + }, + getHeaderByNumber: async (): ReturnType => { + headerCalls += 1; + await Promise.resolve(); + return headerLike(1n); + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + transactionCalls += 1; + await Promise.resolve(); + return transactionWithHeader(headerLike(2n)); + }, + }); + + const withdrawals = await collect( + manager.findWithdrawalRequests(testClient, [firstLock, secondLock], { + tip: headerLike(3n), + }), + ); + + expect(headerCalls).toBe(1); + expect(transactionCalls).toBe(1); + expect(withdrawals.map((withdrawal) => withdrawal.cell.outPoint.index)).toEqual([ + 0n, + 1n, + ]); + }); +}); + +function withdrawalCells( + manager: DaoManager, + lock: ccc.Script, + firstByte: string, + secondByte: string, +): [ccc.Cell, ccc.Cell] { + return [ + withdrawalCell(manager, lock, byte32FromByte(firstByte), 0n), + withdrawalCell(manager, lock, byte32FromByte(secondByte), 0n), + ]; +} + +function withdrawalCell( + manager: DaoManager, + lock: ccc.Script, + txHash: ccc.Hex, + index: bigint, +): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash, index }, + cellOutput: { capacity: ccc.fixedPointFrom(100082), lock, type: manager.script }, + outputData: ccc.mol.Uint64LE.encode(1n), + }); +} diff --git a/packages/dao/test/index.ts b/packages/dao/test/index.ts new file mode 100644 index 0000000..230a1f1 --- /dev/null +++ b/packages/dao/test/index.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import * as dao from "../src/index.ts"; + +describe("dao package barrel", () => { + it("exposes DAO output-limit behavior through the public index", () => { + const error = new dao.DaoOutputLimitError(dao.DAO_OUTPUT_LIMIT + 1); + + expect(dao.DaoManager.depositData()).toBe("0x0000000000000000"); + expect(error.message).toContain("65 output cells"); + }); +}); diff --git a/packages/dao/test/support/dao_support.ts b/packages/dao/test/support/dao_support.ts new file mode 100644 index 0000000..d850ac8 --- /dev/null +++ b/packages/dao/test/support/dao_support.ts @@ -0,0 +1,93 @@ +import { ccc } from "@ckb-ccc/core"; +import { + byte32FromByte, + script, + StubClient, + headerLike as testHeaderLike, + transactionWithHeader, + type TransactionWithHeader, +} from "@ickb/testkit"; +import type { DaoDepositCell } from "../../src/cells.ts"; +import { DaoManager } from "../../src/dao.ts"; + +export const REQUEST_WITHDRAWAL_SUITE = "DaoManager.requestWithdrawal"; +export const FIND_DEPOSITS_SUITE = "DaoManager.findDeposits"; +export const FIND_WITHDRAWAL_REQUESTS_SUITE = "DaoManager.findWithdrawalRequests"; + +export async function collect(inputs: AsyncIterable): Promise { + const result: T[] = []; + for await (const input of inputs) { + result.push(input); + } + return result; +} + +export function headerWithHash(number: bigint, hashByte: string): ccc.ClientBlockHeader { + const header = headerLike(number); + return ccc.ClientBlockHeader.from({ + compactTarget: header.compactTarget, + dao: header.dao, + epoch: header.epoch, + extraHash: header.extraHash, + hash: byte32FromByte(hashByte), + nonce: header.nonce, + number: header.number, + parentHash: header.parentHash, + proposalsHash: header.proposalsHash, + timestamp: header.timestamp, + transactionsRoot: header.transactionsRoot, + version: header.version, + }); +} + +export function depositCell( + manager: DaoManager, + options?: { + lock?: ccc.Script; + txHashByte?: string; + isReady?: boolean; + }, +): DaoDepositCell { + const depositHeader = headerLike(1n); + const txHash = byte32FromByte(options?.txHashByte ?? "22"); + return { + cell: ccc.Cell.from({ + outPoint: { + txHash, + index: 0n, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: options?.lock ?? script("33"), + type: manager.script, + }, + outputData: DaoManager.depositData(), + }), + isDeposit: true, + headers: [{ header: depositHeader, txHash }, { header: depositHeader }], + interests: 0n, + maturity: ccc.Epoch.from([1n, 0n, 1n]), + isReady: options?.isReady ?? true, + ckbValue: ccc.fixedPointFrom(100082), + udtValue: 0n, + }; +} + +export function headerLike(number: bigint): ccc.ClientBlockHeader { + return testHeaderLike({ + epoch: [1n, 0n, 1n], + number, + }); +} + +export function client(): ccc.Client { + return new StubClient(); +} + +export { + byte32FromByte, + script, + StubClient, + transactionWithHeader, + type TransactionWithHeader, +}; diff --git a/packages/dao/test/withdraw.ts b/packages/dao/test/withdraw.ts new file mode 100644 index 0000000..aa46890 --- /dev/null +++ b/packages/dao/test/withdraw.ts @@ -0,0 +1,200 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import type { DaoWithdrawalRequestCell } from "../src/cells.ts"; +import { DaoManager } from "../src/index.ts"; +import { + byte32FromByte, + client, + headerLike, + headerWithHash, + script, +} from "./support/dao_support.ts"; + +describe("DaoManager.withdraw", () => { + registerWithdrawConstructionTests(); + registerWithdrawValidationTests(); +}); + +function registerWithdrawConstructionTests(): void { + it("writes since, header deps, and witness inputType for withdrawals", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal = withdrawalCell(manager, depositHeader, withdrawHeader); + + const tx = await manager.withdraw(ccc.Transaction.default(), [withdrawal], client()); + + expect(tx.headerDeps).toEqual([depositHeader.hash, withdrawHeader.hash]); + expect(tx.inputs).toHaveLength(1); + const since = tx.inputs[0]?.since; + if (since === undefined) { + throw new Error("Expected withdrawal input since"); + } + expect(ccc.Since.from(since).metric).toBe("epoch"); + expect(ccc.Since.from(since).value).toBe(withdrawal.maturity.toNum()); + expect(tx.getWitnessArgsAt(0)?.inputType).toBe(ccc.hexFrom(ccc.numLeToBytes(0n, 8))); + }); + + it("does not duplicate withdrawal header deps", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal = withdrawalCell(manager, depositHeader, withdrawHeader); + const tx = ccc.Transaction.default(); + tx.headerDeps.push(depositHeader.hash, withdrawHeader.hash); + + const updated = await manager.withdraw(tx, [withdrawal], client()); + + expect(updated.headerDeps).toEqual([depositHeader.hash, withdrawHeader.hash]); + }); + + it("preserves an existing non-input witness by shifting it after the new input", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal = withdrawalCell(manager, depositHeader, withdrawHeader); + const tx = ccc.Transaction.default(); + const preservedWitness = ccc.WitnessArgs.from({ inputType: "0xab" }).toHex(); + tx.witnesses.push(preservedWitness); + + const updated = await manager.withdraw(tx, [withdrawal], client()); + + expect(updated.getWitnessArgsAt(0)?.inputType).toBe( + ccc.hexFrom(ccc.numLeToBytes(0n, 8)), + ); + expect(updated.witnesses[1]).toBe(preservedWitness); + }); + + it("leaves transactions unchanged when ready-only withdrawals are pending", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const pending = { + ...withdrawalCell(manager, depositHeader, withdrawHeader), + isReady: false, + }; + const tx = ccc.Transaction.default(); + + await expect( + manager.withdraw(tx, [pending], client(), { isReadyOnly: true }), + ).resolves.toEqual(tx); + }); +} + +function registerWithdrawValidationTests(): void { + it("rejects withdrawal witnesses whose input type is already set", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal = withdrawalCell(manager, depositHeader, withdrawHeader); + const tx = ccc.Transaction.default(); + const addInput = tx.addInput.bind(tx); + tx.addInput = (input): number => { + const inputCount = addInput(input); + tx.setWitnessArgsAt(inputCount - 1, ccc.WitnessArgs.from({ inputType: "0xab" })); + return inputCount; + }; + + await expect(manager.withdraw(tx, [withdrawal], client())).rejects.toThrow( + "Witnesses of withdrawal request already in use", + ); + }); + + it("rejects withdrawal requests whose cell output has no DAO type script", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal = withdrawalCell(manager, depositHeader, withdrawHeader); + withdrawal.cell.cellOutput.type = undefined; + + await expect( + manager.withdraw(ccc.Transaction.default(), [withdrawal], client()), + ).rejects.toThrow( + `DAO withdrawal request ${withdrawal.cell.outPoint.toHex()} does not match this DAO script`, + ); + }); + + it("rejects withdrawal requests whose header tx hash does not match the cell", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal = withdrawalCell(manager, depositHeader, withdrawHeader); + withdrawal.headers[1] = { ...withdrawal.headers[1], txHash: byte32FromByte("44") }; + + await expect( + manager.withdraw(ccc.Transaction.default(), [withdrawal], client()), + ).rejects.toThrow("header txHash"); + }); + + it("rejects withdrawal requests whose payload deposit block does not match the deposit header", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal = withdrawalCell(manager, depositHeader, withdrawHeader); + withdrawal.cell.outputData = ccc.hexFrom(ccc.mol.Uint64LE.encode(9n)); + + await expect( + manager.withdraw(ccc.Transaction.default(), [withdrawal], client()), + ).rejects.toThrow("deposit block 9 does not match header block 1"); + }); + + it("rejects malformed withdrawal request payloads with out point context", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal = withdrawalCell(manager, depositHeader, withdrawHeader); + withdrawal.cell.outputData = "0x12"; + + await expect( + manager.withdraw(ccc.Transaction.default(), [withdrawal], client()), + ).rejects.toThrow( + `Invalid DAO withdrawal request payload at ${withdrawal.cell.outPoint.toHex()}: 0x12`, + ); + }); + + it("rejects duplicated or already-spent withdrawal request inputs", async () => { + const manager = new DaoManager(script("11"), []); + const depositHeader = headerLike(1n); + const withdrawHeader = headerWithHash(2n, "99"); + const withdrawal = withdrawalCell(manager, depositHeader, withdrawHeader); + const tx = ccc.Transaction.default(); + tx.addInput(withdrawal.cell); + + await expect( + manager.withdraw(ccc.Transaction.default(), [withdrawal, withdrawal], client()), + ).rejects.toThrow( + `DAO withdrawal request ${withdrawal.cell.outPoint.toHex()} is duplicated`, + ); + await expect(manager.withdraw(tx, [withdrawal], client())).rejects.toThrow( + `DAO withdrawal request ${withdrawal.cell.outPoint.toHex()} is already being spent`, + ); + }); +} + +function withdrawalCell( + manager: DaoManager, + depositHeader: ccc.ClientBlockHeader, + withdrawHeader: ccc.ClientBlockHeader, +): DaoWithdrawalRequestCell { + return { + cell: ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("22"), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(100082), + lock: script("33", "0x1234"), + type: manager.script, + }, + outputData: ccc.mol.Uint64LE.encode(depositHeader.number), + }), + isDeposit: false, + headers: [ + { header: depositHeader }, + { header: withdrawHeader, txHash: byte32FromByte("22") }, + ], + interests: 0n, + maturity: ccc.Epoch.from([180n, 0n, 1n]), + isReady: true, + ckbValue: ccc.fixedPointFrom(100082), + udtValue: 0n, + }; +} diff --git a/packages/dao/tsconfig.build.json b/packages/dao/tsconfig.build.json new file mode 100644 index 0000000..ca89ac2 --- /dev/null +++ b/packages/dao/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "removeComments": false, + "rewriteRelativeImportExtensions": true, + "rootDir": "src", + "outDir": "dist", + "sourceRoot": "../src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*_test_support.ts"] +} diff --git a/packages/dao/tsconfig.json b/packages/dao/tsconfig.json index 84a843d..c6a53fe 100644 --- a/packages/dao/tsconfig.json +++ b/packages/dao/tsconfig.json @@ -1,11 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": false, - "rootDir": "src", - "outDir": "dist", - "sourceRoot": "../src" + "noEmit": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src", "test"] } diff --git a/packages/dao/vitest.config.mts b/packages/dao/vitest.config.mts index dc6a587..e5f423a 100644 --- a/packages/dao/vitest.config.mts +++ b/packages/dao/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/packages/order/README.md b/packages/order/README.md index 6612bde..47f4ec3 100644 --- a/packages/order/README.md +++ b/packages/order/README.md @@ -22,9 +22,19 @@ graph TD; If a caller will send the returned transaction, it still must: 1. Complete the transaction before send. -2. Prefer the shared stack path in `@ickb/sdk`: `sdk.completeTransaction(...)` or `completeIckbTransaction(...)`. +2. Prefer the shared stack path in `@ickb/sdk`: `sdk.completeTransaction(...)`. 3. Only use lower-level manual completion when the caller intentionally owns UDT completion, CCC-native fee/capacity completion, and the DAO output-limit check itself. +## Match Direction + +Order directions are from the on-chain order owner's perspective. A `ckb-to-udt` order means the owner gives CKB and wants UDT, so the matcher spends UDT and receives CKB. A `udt-to-ckb` order means the owner gives UDT and wants CKB, so the matcher spends CKB and receives UDT. + +`OrderManager.convert(...)` is the quote boundary. It computes the rounded-up output amount from the midpoint ratio and fee, then encodes `Info` so a full fill preserves that quote under the matcher integer arithmetic while fitting the order script's Uint64 ratio fields. If no Uint64 ratio can preserve the quote, conversion fails instead of silently weakening the order. Mint orders with the same amounts used for the successful quote. + +Matching steps must be sized in the asset the matcher spends, not in the order owner's direction label. For an exchange rate like `1 BTC = 100000 USD`, a value step of `1000 USD` corresponds to `1000 USD` when the matcher spends USD, and `0.01 BTC` when the matcher spends BTC. Swapping those units either skips usable small matches or explodes the search into meaningless dust steps. + +Candidate viability is netted across both directions. A CKB-to-UDT match can increase the matcher's CKB allowance for a UDT-to-CKB match in the same transaction, and a UDT-to-CKB match can increase UDT allowance for a CKB-to-UDT match. Below-step probes must account for those cross-side proceeds while still reserving CKB fees. + ## Limit Order Confusion Boundary The deployed Limit Order script has a known confusion surface because CKB does not execute output locks at creation time. `@ickb/order` keeps the stack-side mitigation in `findOrders(...)`: it fetches the mint origin for each master, rejects candidates whose order script, UDT type, resolved master, or parameters differ from the origin, rejects candidates whose normalized value or directional progress is lower than the origin, then selects the best remaining candidate by progress/value. Ambiguous mint origins and ambiguous equal-score descendants are skipped instead of selected by indexer order. This is a best-effort stack-side heuristic for immutable deployed behavior, not proof that forged higher-progress descendants cannot exist. Consumers should use resolved `OrderGroup`s from `findOrders(...)` for matching and melting instead of hand-pairing order and master cells. diff --git a/packages/order/api-extractor.json b/packages/order/api-extractor.json new file mode 100644 index 0000000..d842b4d --- /dev/null +++ b/packages/order/api-extractor.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../api-extractor.base.json", + "mainEntryPointFilePath": "/dist/index.d.ts" +} diff --git a/packages/order/package.json b/packages/order/package.json index 355f61f..c8f4891 100644 --- a/packages/order/package.json +++ b/packages/order/package.json @@ -13,36 +13,43 @@ "homepage": "https://ickb.org", "repository": { "type": "git", - "url": "https://github.com/ickb/stack" + "url": "git+https://github.com/ickb/stack.git" }, "bugs": { "url": "https://github.com/ickb/stack/issues" }, "sideEffects": false, "type": "module", - "main": "dist/index.js", + "engines": { + "node": ">=22.19.0" + }, + "main": "src/index.ts", "types": "src/index.ts", "exports": { ".": { + "api-extractor": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, "types": "./src/index.ts", - "import": "./dist/index.js" + "import": "./src/index.ts" } }, "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "tsc", - "lint": "eslint ./src", - "clean": "rm -fr dist", - "clean:deep": "rm -fr dist node_modules" + "build": "rm -fr dist && tsgo -p tsconfig.build.json && node ../../scripts/tooling/build/rewrite-dts-imports.ts dist", + "lint:api": "api-extractor run --local", + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/order/src packages/order/test", + "clean": "rm -fr dist" }, "files": [ - "dist", - "src" + "dist" ], "publishConfig": { "access": "public", "provenance": true, + "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { @@ -53,8 +60,7 @@ }, "dependencies": { "@ckb-ccc/core": "catalog:", - "@ickb/utils": "workspace:*", - "tslib": "^2.8.1" + "@ickb/utils": "workspace:*" }, "devDependencies": { "@ickb/testkit": "workspace:*" diff --git a/packages/order/src/entities.ts b/packages/order/src/entities.ts deleted file mode 100644 index 2120a17..0000000 --- a/packages/order/src/entities.ts +++ /dev/null @@ -1,756 +0,0 @@ -import { ccc, mol } from "@ckb-ccc/core"; -import { CheckedInt32LE, compareBigInt, type ExchangeRatio } from "@ickb/utils"; - -const maxUint64 = (1n << 64n) - 1n; - -/** - * Represents a ratio of two scales, CKB and UDT, with validation and comparison methods. - * - * @class Ratio - * @extends {ccc.Entity.Base} - * @codec {mol.struct({ ckbScale: mol.Uint64, udtScale: mol.Uint64 })} - */ -@ccc.codec( - mol.struct({ - ckbScale: mol.Uint64, - udtScale: mol.Uint64, - }), -) -export class Ratio extends ccc.Entity.Base() { - /** - * Creates an instance of Ratio. - * - * @param {ccc.Num} ckbScale - The scale of CKB. - * @param {ccc.Num} udtScale - The scale of UDT. - */ - constructor( - public ckbScale: ccc.Num, - public udtScale: ccc.Num, - ) { - super(); - } - - /** - * Creates a Ratio instance from a ExchangeRatio object. - * - * @static - * @param {ExchangeRatio} ratio - The exchange ratio object to convert. - * @returns {Ratio} The created Ratio instance. - */ - static override from(ratio: ExchangeRatio): Ratio { - if (ratio instanceof Ratio) { - return ratio; - } - - const { ckbScale, udtScale } = ratio; - return new Ratio(ckbScale, udtScale); - } - - /** - * Validates the Ratio instance. - * - * @throws {Error} If the Ratio is not empty and not populated. - */ - validate(): void { - if (!this.isEmpty() && !this.isPopulated()) { - throw new Error("Ratio invalid: not empty, not populated"); - } - } - - /** - * Checks if the Ratio instance is valid. - * - * @returns {boolean} True if valid, otherwise false. - */ - isValid(): boolean { - try { - this.validate(); - return true; - } catch { - return false; - } - } - - /** - * Checks if the Ratio instance is empty. - * - * @returns {boolean} True if both scales are zero, otherwise false. - */ - isEmpty(): boolean { - return this.ckbScale === 0n && this.udtScale === 0n; - } - - /** - * Checks if the Ratio instance is populated. - * - * @returns {boolean} True if both scales are greater than zero, otherwise false. - */ - isPopulated(): boolean { - return this.ckbScale > 0n && this.udtScale > 0n; - } - - /** - * Creates an empty Ratio instance. - * - * @static - * @returns {Ratio} An empty Ratio instance. - */ - static empty(): Ratio { - return new Ratio(0n, 0n); - } - - /** - * Compares this Ratio instance with another Ratio instance, - * compare directly on ckbScale and inversely to udtScale. - * - * @param {Ratio} other - The other Ratio instance to compare against. - * @returns {number} A negative number if this is less than other, - * a positive number if this is greater than other, - * and zero if they are equal. - */ - compare(other: Ratio): number { - if (this.udtScale === other.udtScale) { - return compareBigInt(this.ckbScale, other.ckbScale); - } - - if (this.ckbScale === other.ckbScale) { - return compareBigInt(other.udtScale, this.udtScale); - } - - // Idea: o0.Ckb2Udt - o1.Ckb2Udt - // ~ o0.ckbScale / o0.udtScale - o1.ckbScale / o1.udtScale - // order equivalent to: - // ~ o0.ckbScale * o1.udtScale - o1.ckbScale * o0.udtScale - return compareBigInt( - this.ckbScale * other.udtScale, - other.ckbScale * this.udtScale, - ); - } - - /** - * Applies a fee to the current conversion ratio. - * - * This method adjusts the ratio by applying a fee relative to a base value. - * It modifies the scaling factors accordingly and reduces them by their greatest common divisor (GCD). - * If the resulting scaling factors exceed 64 bits, they are shifted to prevent potential overflow. - * - * @param isCkb2Udt - Indicates the conversion direction. - * - If true, the conversion is from CKB to UDT. - * - Otherwise, for UDT to CKB conversion, the scaling factors are swapped. - * @param fee - The fee to apply during conversion as a `ccc.Num`. - * Must be less than the provided feeBase. - * @param feeBase - The base reference for the fee calculation as a `ccc.Num`. - * Used to adjust the scaling factors and prevent oversized values. - * @returns A new Ratio instance with the adjusted scaling factors after applying the fee. - * - * @throws Error if fee is greater than or equal to feeBase. - */ - applyFee(isCkb2Udt: boolean, fee: ccc.Num, feeBase: ccc.Num): Ratio { - if (fee >= feeBase) { - throw new Error("Fee too big relative to feeBase"); - } - - if (fee === 0n) { - return this; - } - if (!this.isPopulated()) { - throw new Error("Invalid ExchangeRatio"); - } - - // Extract scaling factors from the current Ratio. - let { ckbScale: aScale, udtScale: bScale } = this; - - // For UDT to CKB conversion, swap the scaling factors. - if (!isCkb2Udt) { - [aScale, bScale] = [bScale, aScale]; - } - - // Adjust scales by applying the fee. - aScale *= feeBase - fee; - bScale *= feeBase; - - // Reduce the ratio by dividing by the greatest common divisor. - const g = ccc.gcd(aScale, bScale); - aScale /= g; - bScale /= g; - - if (aScale > maxUint64 || bScale > maxUint64) { - throw new Error("Ratio scale exceeds Uint64"); - } - - // Rebuild and return the adjusted ratio based on the conversion direction. - return Ratio.from({ - ckbScale: isCkb2Udt ? aScale : bScale, - udtScale: isCkb2Udt ? bScale : aScale, - }); - } - - /** - * Converts an amount between CKB and UDT based on the specified conversion direction and scaling factors. - * - * @param isCkb2Udt - If true, converts from CKB to UDT; if false, converts from UDT to CKB. - * @param amount - The amount to convert, represented as a `ccc.FixedPoint`. - * @param mustCeil - When true, applies a ceiling adjustment during conversion for rounding up; - * if false, applies a floor adjustment for rounding down. - * @returns The converted amount as a `ccc.FixedPoint` in the target unit. - * - * @throws Error if the ExchangeRatio instance is not properly populated. - * - * @remarks - * The conversion is achieved using the internal scaling factors: - * - `ckbScale` is used when converting from CKB. - * - `udtScale` is used when converting from UDT. - * - * If the conversion direction is from UDT to CKB, the scales are swapped. - * The adjustment is determined by the `mustCeil` flag: - * - If `mustCeil` is true, an adjustment of `(udtScale - 1n)` is applied to round up. - * - Otherwise, no adjustment (i.e., `0n`) is applied for rounding down. - */ - convert( - isCkb2Udt: boolean, - amount: ccc.FixedPoint, - mustCeil: boolean, - ): ccc.FixedPoint { - if (!this.isPopulated()) { - throw new Error("Invalid midpoint ExchangeRatio"); - } - - if (amount === 0n) { - return 0n; - } - - let { ckbScale: aScale, udtScale: bScale } = this; - if (!isCkb2Udt) { - // For UDT to CKB conversion, swap the scaling factors. - [aScale, bScale] = [bScale, aScale]; - } - - // Apply ceiling adjustment when necessary; otherwise, use floor adjustment. - return (amount * aScale + (mustCeil ? bScale - 1n : 0n)) / bScale; - } -} - -/** - * Represents a structure containing conversion ratios and a minimum match log value. - * - * @interface InfoLike - */ -export interface InfoLike { - /** - * The ratio for converting CKB to UDT. - * - * @type {ExchangeRatio} - */ - ckbToUdt: ExchangeRatio; - - /** - * The ratio for converting UDT to CKB. - * - * @type {ExchangeRatio} - */ - udtToCkb: ExchangeRatio; - - /** - * The minimum match log value for CKB. - * - * @type {ccc.FixedPointLike} - */ - ckbMinMatchLog: ccc.FixedPointLike; -} - -/** - * Represents conversion information between CKB and UDT, including validation and comparison methods. - * - * @class Info - * @extends {ccc.Entity.Base} - * @codec {mol.struct({ ckbToUdt: Ratio, udtToCkb: Ratio, ckbMinMatchLog: mol.Uint8 })} - */ -@ccc.codec( - mol.struct({ - ckbToUdt: Ratio, - udtToCkb: Ratio, - ckbMinMatchLog: mol.Uint8, - }), -) -export class Info extends ccc.Entity.Base() { - /** - * Creates an instance of Info. - * - * @param {Ratio} ckbToUdt - The ratio for converting CKB to UDT. - * @param {Ratio} udtToCkb - The ratio for converting UDT to CKB. - * @param {number} ckbMinMatchLog - The minimum match log value for CKB. - */ - constructor( - public ckbToUdt: Ratio, - public udtToCkb: Ratio, - public ckbMinMatchLog: number, - ) { - super(); - } - - /** - * Creates an Info instance from an InfoLike object. - * - * @static - * @param {InfoLike} info - The info-like object to convert. - * @returns {Info} The created Info instance. - */ - static override from(info: InfoLike): Info { - if (info instanceof Info) { - return info; - } - - const { ckbToUdt, udtToCkb, ckbMinMatchLog } = info; - return new Info( - Ratio.from(ckbToUdt), - Ratio.from(udtToCkb), - Number(ckbMinMatchLog), - ); - } - - /** - * Creates a new Info instance based on the provided parameters. - * - * @static - * @param {boolean} isCkb2Udt - Indicates if the conversion is from CKB to UDT. - * @param {ExchangeRatio} ratioLike - The ratio to use for conversion. - * @param {number} [ckbMinMatchLog] - The minimum match log value for CKB (Default: 33, about 86 CKB) - * @returns {Info} The created Info instance. - */ - static create( - isCkb2Udt: boolean, - ratioLike: ExchangeRatio, - ckbMinMatchLog = Info.ckbMinMatchLogDefault(), - ): Info { - return Info.from({ - ckbToUdt: isCkb2Udt ? ratioLike : Ratio.empty(), - udtToCkb: isCkb2Udt ? Ratio.empty() : ratioLike, - ckbMinMatchLog, - }); - } - - /** - * Validates the Info instance. - * - * @throws {Error} If the Info instance is invalid based on its properties. - */ - validate(): void { - if (this.ckbMinMatchLog < 0 || this.ckbMinMatchLog > 64) { - throw new Error("ckbMinMatchLog invalid"); - } - - if (this.ckbToUdt.isEmpty()) { - if (this.udtToCkb.isPopulated()) { - return; - } else { - throw new Error("ckbToUdt is Empty, but udtToCkb is not Populated"); - } - } - - if (this.udtToCkb.isEmpty()) { - if (this.ckbToUdt.isPopulated()) { - return; - } else { - throw new Error("udtToCkb is Empty, but ckbToUdt is not Populated"); - } - } - - if (!this.ckbToUdt.isPopulated() || !this.udtToCkb.isPopulated()) { - throw new Error("One ratio is invalid, so not Empty and not Populated"); - } - - // Check that if we convert from ckb to udt and then back from udt to ckb, it doesn't lose value. - if ( - this.ckbToUdt.ckbScale * this.udtToCkb.udtScale < - this.ckbToUdt.udtScale * this.udtToCkb.ckbScale - ) { - throw new Error("udtToCkb and ckbToUdt allow order value to be extracted"); - } - } - - /** - * Checks if the Info instance is valid. - * - * @returns {boolean} True if valid, otherwise false. - */ - isValid(): boolean { - try { - this.validate(); - return true; - } catch { - return false; - } - } - - /** - * Gets the minimum match value for CKB as a fixed point. - * - * @returns {ccc.FixedPoint} The minimum match value for CKB. - */ - getCkbMinMatch(): ccc.FixedPoint { - return 1n << BigInt(this.ckbMinMatchLog); - } - - /** - * Checks if the Info instance represents a CKB to UDT conversion or dual ratio. - * - * @returns {boolean} True if it is a CKB to UDT conversion, otherwise false. - */ - isCkb2Udt(): boolean { - return this.ckbToUdt.isPopulated(); - } - - /** - * Checks if the Info instance represents a UDT to CKB conversion or dual ratio. - * - * @returns {boolean} True if it is a UDT to CKB conversion, otherwise false. - */ - isUdt2Ckb(): boolean { - return this.udtToCkb.isPopulated(); - } - - /** - * Checks if the Info instance represents a dual ratio (both conversions). - * - * @returns {boolean} True if both conversions are populated, otherwise false. - */ - isDualRatio(): boolean { - return this.isCkb2Udt() && this.isUdt2Ckb(); - } - - /** - * Compares the CKB to UDT ratio of this Info instance with another Info instance. - * - * @param {Info} other - The other Info instance to compare against. - * @returns {number} A negative number if this is less than other, - * a positive number if this is greater than other, - * and zero if they are equal. - */ - ckb2UdtCompare(other: Info): number { - return this.ckbToUdt.compare(other.ckbToUdt); - } - - /** - * Compares the UDT to CKB ratio of this Info instance with another Info instance. - * - * @param {Info} other - The other Info instance to compare against. - * @returns {number} A negative number if this is less than other, - * a positive number if this is greater than other, - * and zero if they are equal. - */ - udt2CkbCompare(other: Info): number { - return other.udtToCkb.compare(this.udtToCkb); - } - - /** - * Provides the default minimum match log value for CKB, which is 86 CKB. - * - * @static - * @returns {number} The default minimum match log value. - */ - static ckbMinMatchLogDefault(): number { - return 33; // ~ 86 CKB - } -} - -/** - * Represents a structure containing padding and distance values. - * - * @interface RelativeLike - */ -export interface RelativeLike { - /** - * The padding value, represented as bytes. - * - * @type {ccc.BytesLike} - */ - padding: ccc.BytesLike; - - /** - * The distance value, represented as a number. - * - * @type {ccc.NumLike} - */ - distance: ccc.NumLike; -} - -/** - * Represents a relative structure with padding and distance, including validation methods. - * - * @class Relative - * @extends {ccc.Entity.Base} - * @codec {mol.struct({ padding: mol.Byte32, distance: CheckedInt32LE })} - */ -@ccc.codec( - mol.struct({ - padding: mol.Byte32, - distance: CheckedInt32LE, - }), -) -export class Relative extends ccc.Entity.Base() { - /** - * Creates an instance of Relative. - * - * @param {ccc.Bytes} padding - The padding value. - * @param {ccc.Num} distance - The distance value. - */ - constructor( - public padding: ccc.Bytes, - public distance: ccc.Num, - ) { - super(); - } - - /** - * Creates a Relative instance from a RelativeLike object. - * - * @static - * @param {RelativeLike} relative - The relative-like object to convert. - * @returns {Relative} The created Relative instance. - */ - static override from(relative: RelativeLike): Relative { - if (relative instanceof Relative) { - return relative; - } - - const { padding, distance } = relative; - return new Relative(ccc.bytesFrom(padding), ccc.numFrom(distance)); - } - - /** - * Creates a new Relative instance with default padding and the specified distance. - * - * @static - * @param {ccc.Num} distance - The distance value. - * @returns {Relative} The created Relative instance. - */ - static create(distance: ccc.Num): Relative { - return new Relative(Relative.padding(), distance); - } - - /** - * Provides the default padding value as a byte array of length 32. - * - * @static - * @returns {ccc.Bytes} The default padding value. - */ - static padding(): ccc.Bytes { - return new Uint8Array(32); - } - - /** - * Validates the Relative instance. - * - * @throws {Error} If the padding is not of length 32 or contains non-zero values. - */ - validate(): void { - if (this.padding.length !== 32 || this.padding.some((x) => x !== 0)) { - throw new Error("Relative master invalid, non standard padding"); - } - } - - /** - * Checks if the Relative instance is valid. - * - * @returns {boolean} True if valid, otherwise false. - */ - isValid(): boolean { - try { - this.validate(); - return true; - } catch { - return false; - } - } -} - -/** - * A union codec that can encode and decode either a Relative or an Absolute OutPoint. - * - * @constant MasterCodec - * @type {mol.UnionCodec<{ relative: Relative; absolute: ccc.OutPoint; }>} - */ -export const MasterCodec = mol.union({ - relative: Relative, - absolute: ccc.OutPoint, -}); - -/** - * Represents a type that can be encoded using the MasterCodec. - * - * @type {MasterLike} - */ -export type MasterLike = ccc.EncodableType; - -/** - * Represents a type that has been decoded using the MasterCodec. - * - * @type {Master} - */ -export type Master = ccc.DecodedType; - -/** - * Converts a MasterLike object to a Master object. - * - * @param {MasterLike} master - The master-like object to convert. - * @returns {Master} The converted Master object. - * @throws {Error} If the type is not "relative" or "absolute". - */ -function masterFrom(master: MasterLike): Master { - const { type, value } = master; - if (type === "relative") { - return { type, value: Relative.from(value) }; - } else { - return { type, value: ccc.OutPoint.from(value) }; - } -} -/** - * Validates a Master object, ensuring that it conforms to the expected structure - * based on its type (either "relative" or "absolute"). - * - * @param {Master} master - The Master object to validate. - * @throws {Error} If the Master object is of an invalid type or if the validation - * checks for the specific type fail. - */ -function masterValidate(master: Master): void { - const { type, value } = master; - if (type === "relative") { - value.validate(); - } else { - if (!/^0x[0-9a-f]{64}$/i.test(value.txHash) || value.index < 0) { - throw new Error("OutPoint invalid"); - } - } -} - -/** - * Represents a structure containing UDT amount, master information, and additional info. - * - * @interface OrderDataLike - */ -export interface OrderDataLike { - /** - * The amount of UDT (User Defined Token). - * - * @type {ccc.FixedPointLike} - */ - udtValue: ccc.FixedPointLike; - - /** - * The master information, which can be either relative or absolute. - * - * @type {MasterLike} - */ - master: MasterLike; - - /** - * Additional information related to the data. - * - * @type {InfoLike} - */ - info: InfoLike; -} - -/** - * Represents a data structure that includes UDT amount, master, and info, - * with validation and utility methods. - * - * @class Data - * @extends {ccc.Entity.Base} - * @codec {mol.struct({ udtValue: mol.Uint128, master: MasterCodec, info: Info })} - */ -@ccc.codec( - mol.struct({ - udtValue: mol.Uint128, - master: MasterCodec, - info: Info, - }), -) -export class OrderData extends ccc.Entity.Base() { - /** - * Creates an instance of OrderData. - * - * @param {ccc.FixedPoint} udtValue - The amount of UDT. - * @param {Master} master - The master information. - * @param {Info} info - The additional information. - */ - constructor( - public udtValue: ccc.FixedPoint, - public master: Master, - public info: Info, - ) { - super(); - } - - /** - * Creates a OrderData instance from a OrderDataLike object. - * - * @static - * @param {OrderDataLike} data - The data-like object to convert. - * @returns {OrderData} The created Data instance. - */ - static override from(data: OrderDataLike): OrderData { - if (data instanceof OrderData) { - return data; - } - - const { udtValue, master, info } = data; - return new OrderData( - ccc.numFrom(udtValue), - masterFrom(master), - Info.from(info), - ); - } - - /** - * Validates the Data instance. - * - * @throws {Error} If the UDT amount is negative or if the master or info are invalid. - */ - validate(): void { - if (this.udtValue < 0) { - throw new Error("udtValue invalid, negative"); - } - masterValidate(this.master); - this.info.validate(); - } - - /** - * Checks if the Data instance is valid. - * - * @returns {boolean} True if valid, otherwise false. - */ - isValid(): boolean { - try { - this.validate(); - return true; - } catch { - return false; - } - } - - /** - * Checks if the Data instance represents a mint operation. - * - * @returns {boolean} True if the master type is "relative", otherwise false. - */ - isMint(): boolean { - return this.master.type === "relative"; - } - - /** - * Gets the master OutPoint based on the current OutPoint. - * - * @param {ccc.OutPoint} current - The current OutPoint to use for calculation. - * @returns {ccc.OutPoint} The calculated master OutPoint. - * @throws {Error} If the master type is invalid. - */ - getMaster(current: ccc.OutPoint): ccc.OutPoint { - const { type, value } = this.master; - if (type === "relative") { - return new ccc.OutPoint(current.txHash, current.index + value.distance); - } else { - return value; - } - } -} diff --git a/packages/order/src/index.test.ts b/packages/order/src/index.test.ts deleted file mode 100644 index 1f6ef65..0000000 --- a/packages/order/src/index.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expectTypeOf, it } from "vitest"; -import type { OrderGroupSkipReason } from "./index.js"; - -describe("package root exports", () => { - it("exports the skipped order group callback reason", () => { - expectTypeOf().toEqualTypeOf< - | "missing-master" - | "missing-origin" - | "ambiguous-origin" - | "missing-order" - | "ambiguous-order" - | "invalid-group" - >(); - }); -}); diff --git a/packages/order/src/index.ts b/packages/order/src/index.ts index ef7b811..3a72aab 100644 --- a/packages/order/src/index.ts +++ b/packages/order/src/index.ts @@ -1,14 +1,25 @@ -export { MasterCell, OrderCell, OrderGroup } from "./cells.js"; +/** + * UDT limit-order entities, matching helpers, and transaction builders. + * + * @packageDocumentation + */ + export { - Info, - MasterCodec, - OrderData, - Ratio, - Relative, - type InfoLike, - type Master, - type MasterLike, - type OrderDataLike, - type RelativeLike, -} from "./entities.js"; -export { OrderManager, type Match, type MatchDiagnostics, type OrderGroupSkipReason } from "./order.js"; + MasterCell, + OrderCell, + OrderGroup, + type OrderCellConstructorArgs, +} from "./model/cells.ts"; +export { Info, InfoBase, type InfoLike } from "./model/info.ts"; +export { type Master, type MasterLike } from "./model/master.ts"; +export { OrderBase, OrderData, type OrderDataLike } from "./model/order_data.ts"; +export { Ratio, RatioBase } from "./model/ratio.ts"; +export { Relative, RelativeBase, type RelativeLike } from "./model/relative.ts"; +export { + OrderConversionRepresentabilityError, + OrderManager, + type Match, + type MatchDiagnostics, + type MatchDirectionDiagnostics, + type OrderGroupSkipReason, +} from "./order.ts"; diff --git a/packages/order/src/io/order_io.ts b/packages/order/src/io/order_io.ts new file mode 100644 index 0000000..789b54a --- /dev/null +++ b/packages/order/src/io/order_io.ts @@ -0,0 +1,25 @@ +import type { ccc } from "@ckb-ccc/core"; + +export function cellInputLike(cell: ccc.Cell): ccc.CellInputLike { + return { + outPoint: cell.outPoint, + cellOutput: cellOutputLike(cell.cellOutput), + outputData: cell.outputData, + }; +} + +export function cellOutputLike(output: ccc.CellOutput): ccc.CellOutputLike { + return { + capacity: output.capacity, + lock: output.lock, + type: output.type ?? null, + }; +} + +export function maxOrderOccupiedSize(orderPool: Array<{ cell: ccc.Cell }>): number { + let maxSize = 0; + for (const order of orderPool) { + maxSize = Math.max(maxSize, order.cell.occupiedSize); + } + return maxSize; +} diff --git a/packages/order/src/io/order_scan.ts b/packages/order/src/io/order_scan.ts new file mode 100644 index 0000000..2fd4a47 --- /dev/null +++ b/packages/order/src/io/order_scan.ts @@ -0,0 +1,240 @@ +import { ccc } from "@ckb-ccc/core"; +import { collectPagedScan } from "@ickb/utils"; +import { MasterCell, OrderCell, OrderGroup } from "../model/cells.ts"; +import { cellOutputLike } from "./order_io.ts"; + +/** + * Reason an observed order/master candidate could not form a valid group. + * + * @public + */ +export type OrderGroupSkipReason = + /** Order cell points to a master out point that was not found in the master scan. */ + | "missing-master" + /** Master creation transaction did not contain an origin order. */ + | "missing-origin" + /** Master creation transaction contained more than one matching origin order. */ + | "ambiguous-origin" + /** No descendant order was valid for the origin order. */ + | "missing-order" + /** More than one equally good descendant order was valid for the origin order. */ + | "ambiguous-order" + /** Master, origin, and resolved order failed final group validation. */ + | "invalid-group"; + +type FindOriginResult = + | { ok: true; origin: OrderCell } + | { ok: false; reason: "missing-origin" | "ambiguous-origin" }; + +type ResolveOrderGroupResult = + | { ok: true; group: OrderGroup } + | { + ok: false; + reason: Exclude; + }; + +type TransactionResponse = Awaited>; + +interface OriginOrderAtOptions { + transaction: NonNullable["transaction"]; + txHash: ccc.Hex; + index: bigint; + master: ccc.OutPoint; + isOrder: (cell: ccc.Cell) => boolean; +} + +interface FindSimpleOrdersOptions { + client: ccc.Client; + script: ccc.Script; + udtScript: ccc.Script; + onChain: boolean; + pageSize: number; +} + +/** Finds simple order cells before grouping them with masters. */ +export async function findSimpleOrders({ + client, + script, + udtScript, + onChain, + pageSize, +}: FindSimpleOrdersOptions): Promise { + const findCellsArgs = [ + { + script, + scriptType: "lock", + filter: { script: udtScript }, + scriptSearchMode: "exact", + withData: true, + }, + "asc", + ] as const; + const orders: OrderCell[] = []; + for (const cell of await collectPagedScan( + (requestPageSize) => + onChain + ? client.findCellsOnChain(...findCellsArgs, requestPageSize) + : client.findCells(...findCellsArgs, requestPageSize), + { pageSize }, + )) { + const order = OrderCell.tryFrom(cell); + if (order !== undefined && isOrderCell(cell, script, udtScript)) { + orders.push(order); + } + } + + return orders; +} + +/** Finds master cells for the order script. */ +export async function findAllMasters( + client: ccc.Client, + script: ccc.Script, + onChain: boolean, + pageSize: number, +): Promise { + const findCellsArgs = [ + { + script, + scriptType: "type", + scriptSearchMode: "exact", + withData: true, + }, + "asc", + ] as const; + const masters: MasterCell[] = []; + for (const cell of await collectPagedScan( + (requestPageSize) => + onChain + ? client.findCellsOnChain(...findCellsArgs, requestPageSize) + : client.findCells(...findCellsArgs, requestPageSize), + { pageSize }, + )) { + if (isMasterCell(cell, script)) { + masters.push(new MasterCell(cell)); + } + } + + return masters; +} + +/** Resolves one master and its descendant orders into a validated order group. */ +export async function resolveOrderGroup( + client: ccc.Client, + master: MasterCell, + orders: OrderCell[], + isOrder: (cell: ccc.Cell) => boolean, +): Promise { + const origin = await findOrigin(client, master.cell.outPoint, isOrder); + if (!origin.ok) { + return origin; + } + + const order = origin.origin.resolve(orders); + if (order === undefined) { + return { + ok: false, + reason: orders.some((candidate) => origin.origin.isValid(candidate)) + ? "ambiguous-order" + : "missing-order", + }; + } + + const group = OrderGroup.tryFrom(master, order, origin.origin); + return group === undefined + ? { ok: false, reason: "invalid-group" } + : { ok: true, group }; +} + +/** Checks the order-cell lock/type shape for one order deployment. */ +export function isOrderCell( + cell: ccc.Cell, + script: ccc.Script, + udtScript: ccc.Script, +): boolean { + return cell.cellOutput.lock.eq(script) && Boolean(cell.cellOutput.type?.eq(udtScript)); +} + +/** Checks the master-cell type shape for one order deployment. */ +export function isMasterCell(cell: ccc.Cell, script: ccc.Script): boolean { + return Boolean(cell.cellOutput.type?.eq(script)); +} + +async function findOrigin( + client: ccc.Client, + master: ccc.OutPoint, + isOrder: (cell: ccc.Cell) => boolean, +): Promise { + const { txHash, index: mIndex } = master; + const response = await cachedTransactionResponse(client, txHash); + if (response === undefined) { + return { ok: false, reason: "missing-origin" }; + } + + let origin: OrderCell | undefined; + for (let index = 0n; index < BigInt(response.transaction.outputs.length); index++) { + if (index === mIndex) { + continue; + } + + const candidate = originOrderAt({ + transaction: response.transaction, + txHash, + index, + master, + isOrder, + }); + if (candidate === undefined) { + continue; + } + if (origin !== undefined) { + return { ok: false, reason: "ambiguous-origin" }; + } + origin = candidate; + } + return origin === undefined + ? { ok: false, reason: "missing-origin" } + : { ok: true, origin }; +} + +async function cachedTransactionResponse( + client: ccc.Client, + txHash: ccc.Hex, +): Promise { + const cached = await client.cache.getTransactionResponse(txHash); + if (cached !== undefined) { + return cached; + } + + const response = await client.getTransaction(txHash); + if (response !== undefined) { + await client.cache.recordTransactionResponses(response); + } + return response; +} + +function originOrderAt({ + transaction, + txHash, + index, + master, + isOrder, +}: OriginOrderAtOptions): OrderCell | undefined { + const output = transaction.getOutput(index); + if (output === undefined) { + return undefined; + } + + const cell = ccc.Cell.from({ + cellOutput: cellOutputLike(output.cellOutput), + outputData: output.outputData, + outPoint: { txHash, index }, + }); + const order = OrderCell.tryFrom(cell); + return order !== undefined && + isOrder(cell) && + order.data.isMint() && + order.getMaster().eq(master) + ? order + : undefined; +} diff --git a/packages/order/src/io/order_transaction.ts b/packages/order/src/io/order_transaction.ts new file mode 100644 index 0000000..fe191af --- /dev/null +++ b/packages/order/src/io/order_transaction.ts @@ -0,0 +1,177 @@ +import { ccc } from "@ckb-ccc/core"; +import type { ValueComponents } from "@ickb/utils"; +import type { Match } from "../matching/match_types.ts"; +import { OrderCell, type OrderGroup } from "../model/cells.ts"; +import type { Info } from "../model/info.ts"; +import { OrderData } from "../model/order_data.ts"; +import { Relative } from "../model/relative.ts"; +import { cellInputLike } from "./order_io.ts"; +import { isMasterCell, isOrderCell } from "./order_scan.ts"; + +interface OrderTransactionContext { + script: ccc.Script; + cellDeps: ccc.CellDep[]; + udtScript: ccc.Script; +} + +interface MintOrderInput { + tx: ccc.Transaction; + lock: ccc.Script; + info: Info; + amounts: ValueComponents; +} + +export function mintOrder( + context: OrderTransactionContext, + { tx, lock, info, amounts }: MintOrderInput, +): ccc.Transaction { + const { script, cellDeps, udtScript } = context; + const { ckbValue, udtValue } = amounts; + const data = OrderData.from({ + udtValue, + master: { type: "relative", value: Relative.create(1n) }, + info, + }); + data.validate(); + if (ckbValue < 0n) { + throw new Error("ckbValue invalid, negative"); + } + + tx.addCellDeps(cellDeps); + const outputCount = tx.addOutput({ lock: script, type: udtScript }, data.toBytes()); + const orderOutput = tx.outputs[outputCount - 1]; + if (orderOutput === undefined) { + throw new Error("Failed to append order output"); + } + orderOutput.capacity += ckbValue; + tx.addOutput({ lock, type: script }); + return tx; +} + +export function addOrderMatch( + context: OrderTransactionContext, + tx: ccc.Transaction, + match: Match, +): ccc.Transaction { + const { cellDeps, script, udtScript } = context; + const partials = match.partials; + if (partials.length === 0) { + return tx; + } + const duplicateOutPoint = duplicatePartialOrderOutPoint(partials); + if (duplicateOutPoint !== undefined) { + throw new Error(`Match contains duplicate order cells: ${duplicateOutPoint}`); + } + + tx.addCellDeps(cellDeps); + for (const partial of partials) { + assertMatchPartial(context, partial); + const { order, ckbOut, udtOut } = partial; + tx.addInput(cellInputLike(order.cell)); + tx.addOutput( + { lock: script, type: udtScript, capacity: ckbOut }, + OrderData.from({ + udtValue: udtOut, + master: { type: "absolute", value: order.getMaster() }, + info: order.data.info, + }).toBytes(), + ); + } + return tx; +} + +export function meltOrderGroups( + context: OrderTransactionContext, + tx: ccc.Transaction, + groups: OrderGroup[], + options?: { isFulfilledOnly?: boolean }, +): ccc.Transaction { + const selectedGroups = + options?.isFulfilledOnly === true + ? groups.filter((g) => g.order.isFulfilled()) + : groups; + if (selectedGroups.length === 0) { + return tx; + } + for (const group of selectedGroups) { + assertOrderGroupForMelt(context, group); + } + assertMeltInputsUnspent(tx, selectedGroups); + tx.addCellDeps(context.cellDeps); + + for (const group of selectedGroups) { + tx.addInput(cellInputLike(group.order.cell)); + tx.addInput(cellInputLike(group.master.cell)); + } + return tx; +} + +type MatchPartial = Match["partials"][number]; + +function duplicatePartialOrderOutPoint(partials: Match["partials"]): string | undefined { + const outPoints = new Set(); + for (const partial of partials) { + const key = partial.order.cell.outPoint.toHex(); + if (outPoints.has(key)) { + return key; + } + outPoints.add(key); + } + return undefined; +} + +function assertMatchPartial( + context: OrderTransactionContext, + { order, ckbOut, udtOut }: MatchPartial, +): void { + const outPoint = order.cell.outPoint.toHex(); + if (!isOrderCell(order.cell, context.script, context.udtScript)) { + throw new Error(`Match order ${outPoint} does not match this order manager`); + } + if (ckbOut < 0n) { + throw new Error(`Match order ${outPoint} has negative CKB output`); + } + if (udtOut < 0n) { + throw new Error(`Match order ${outPoint} has negative UDT output`); + } + + OrderCell.mustFrom(order.cell); + if (ccc.hexFrom(order.data.toBytes()) !== order.cell.outputData) { + throw new Error(`Match order ${outPoint} does not match its cell data`); + } +} + +function assertOrderGroupForMelt( + context: OrderTransactionContext, + group: OrderGroup, +): void { + const orderOutPoint = group.order.cell.outPoint.toHex(); + const masterOutPoint = group.master.cell.outPoint.toHex(); + if (!isOrderCell(group.order.cell, context.script, context.udtScript)) { + throw new Error(`Melt order ${orderOutPoint} does not match this order manager`); + } + if (!isMasterCell(group.master.cell, context.script)) { + throw new Error(`Melt master ${masterOutPoint} does not match this order manager`); + } + group.validate(); +} + +function assertMeltInputsUnspent(tx: ccc.Transaction, groups: OrderGroup[]): void { + const spent = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); + const selected = new Set(); + for (const group of groups) { + for (const [label, outPoint] of [ + ["Melt order", group.order.cell.outPoint], + ["Melt master", group.master.cell.outPoint], + ] as const) { + const key = outPoint.toHex(); + if (selected.has(key)) { + throw new Error(`${label} ${key} is duplicated`); + } + selected.add(key); + if (spent.has(key)) { + throw new Error(`${label} ${key} is already being spent`); + } + } + } +} diff --git a/packages/order/src/matching/match_types.ts b/packages/order/src/matching/match_types.ts new file mode 100644 index 0000000..61e1d49 --- /dev/null +++ b/packages/order/src/matching/match_types.ts @@ -0,0 +1,84 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { ValueComponents } from "@ickb/utils"; +import type { OrderCell } from "../model/cells.ts"; + +/** + * Result of matching one or more orders against available allowance. + * + * @public + */ +export interface Match { + /** Net CKB change from the match from the matcher caller's perspective. */ + ckbDelta: bigint; + + /** Net UDT change from the match from the matcher caller's perspective. */ + udtDelta: bigint; + + /** Partial order outputs that must replace matched order inputs. */ + partials: Array<{ + /** Matched input order. */ + order: OrderCell; + + /** CKB capacity for the replacement partial order output. */ + ckbOut: ccc.FixedPoint; + + /** UDT amount for the replacement partial order output. */ + udtOut: ccc.FixedPoint; + }>; + + /** Optional diagnostics produced by best-match search. */ + diagnostics?: MatchDiagnostics; +} + +/** + * Search diagnostics for best-match selection. + * + * @public + */ +export interface MatchDiagnostics { + /** Number of orders inspected. */ + orderCount: number; + /** Original match allowance. */ + allowance: ValueComponents; + /** CKB allowance step used during search. */ + ckbAllowanceStep: ccc.FixedPoint; + /** UDT allowance step derived from the exchange rate. */ + udtAllowanceStep: ccc.FixedPoint; + /** CKB fee budget reserved per matched order. */ + ckbMiningFee: ccc.FixedPoint; + /** Optional maximum number of partial order outputs. */ + maxPartials?: number; + /** Per-direction matchability bounds. */ + directions: { + ckbToUdt: MatchDirectionDiagnostics; + udtToCkb: MatchDirectionDiagnostics; + }; + /** Candidate counts and rejection reasons from the search. */ + candidates: { + total: number; + viable: number; + positiveGain: number; + rejected: { + maxPartials: number; + duplicateOrder: number; + insufficientCkbAllowance: number; + insufficientUdtAllowance: number; + nonPositiveGain: number; + }; + bestGain: bigint; + }; +} + +/** + * Matchability bounds for one order direction. + * + * @public + */ +export interface MatchDirectionDiagnostics { + /** Number of orders matchable in this direction. */ + matchableCount: number; + /** Smallest required allowance among matchable orders. */ + minAllowance?: ccc.FixedPoint; + /** Largest possible match amount among matchable orders. */ + maxMatch?: ccc.FixedPoint; +} diff --git a/packages/order/src/matching/order_conversion.ts b/packages/order/src/matching/order_conversion.ts new file mode 100644 index 0000000..2631320 --- /dev/null +++ b/packages/order/src/matching/order_conversion.ts @@ -0,0 +1,211 @@ +import type { ccc } from "@ckb-ccc/core"; +import { Ratio } from "../model/ratio.ts"; + +const maxUint64 = (1n << 64n) - 1n; + +export function quotePreservingRatio( + inputAmount: ccc.FixedPoint, + quotedOutput: ccc.FixedPoint, + isCkb2Udt: boolean, +): Ratio { + if (inputAmount < 0n || quotedOutput < 0n) { + throw new Error("Order conversion amounts cannot be negative"); + } + if (inputAmount === 0n || quotedOutput === 0n) { + throw new OrderConversionRepresentabilityError( + "Order conversion quote must have positive input and output", + ); + } + + const { numerator, denominator } = greatestBoundedFractionAtMost( + quotedOutput, + inputAmount, + maxUint64, + ); + if (numerator <= 0n || denominator <= 0n) { + throw new OrderConversionRepresentabilityError(); + } + if ((quotedOutput - 1n) * denominator >= inputAmount * numerator) { + throw new OrderConversionRepresentabilityError(); + } + + return Ratio.from({ + ckbScale: isCkb2Udt ? numerator : denominator, + udtScale: isCkb2Udt ? denominator : numerator, + }); +} + +/** + * Error thrown when an exact quote cannot be represented by the order ratio format. + * + * @public + */ +export class OrderConversionRepresentabilityError extends Error { + /** Creates a representability error with the default public conversion message. */ + constructor( + message = "Order conversion quote cannot be represented as Uint64 ratio", + options?: ErrorOptions, + ) { + super(message, options); + this.name = "OrderConversionRepresentabilityError"; + } +} + +export function ceilDiv(numerator: bigint, denominator: bigint): bigint { + return (numerator + denominator - 1n) / denominator; +} + +function greatestBoundedFractionAtMost( + numerator: bigint, + denominator: bigint, + maxTerm: bigint, +): { numerator: bigint; denominator: bigint } { + const target = { numerator, denominator }; + let state = initialBoundedFractionState(numerator, denominator); + while (state.remainingDenominator !== 0n) { + const step = boundedFractionStep(state, target, maxTerm); + state = step.state; + if (step.done) { + break; + } + } + + return { numerator: state.bestNumerator, denominator: state.bestDenominator }; +} + +interface BoundedFractionState { + previousNumerator: bigint; + previousDenominator: bigint; + currentNumerator: bigint; + currentDenominator: bigint; + bestNumerator: bigint; + bestDenominator: bigint; + remainingNumerator: bigint; + remainingDenominator: bigint; +} + +interface BoundedFraction { + numerator: bigint; + denominator: bigint; +} + +function initialBoundedFractionState( + numerator: bigint, + denominator: bigint, +): BoundedFractionState { + return { + previousNumerator: 0n, + previousDenominator: 1n, + currentNumerator: 1n, + currentDenominator: 0n, + bestNumerator: 0n, + bestDenominator: 1n, + remainingNumerator: numerator, + remainingDenominator: denominator, + }; +} + +function boundedFractionStep( + state: BoundedFractionState, + target: BoundedFraction, + maxTerm: bigint, +): { state: BoundedFractionState; done: boolean } { + const quotient = state.remainingNumerator / state.remainingDenominator; + const nextNumerator = quotient * state.currentNumerator + state.previousNumerator; + const nextDenominator = quotient * state.currentDenominator + state.previousDenominator; + if (nextNumerator <= maxTerm && nextDenominator <= maxTerm) { + return { + state: advanceBoundedFractionState( + state, + { numerator: nextNumerator, denominator: nextDenominator }, + target, + ), + done: false, + }; + } + + return { + state: clampBoundedFractionState(state, quotient, target, maxTerm), + done: true, + }; +} + +function advanceBoundedFractionState( + state: BoundedFractionState, + next: BoundedFraction, + target: BoundedFraction, +): BoundedFractionState { + const best = betterBoundedFraction(state, next, target); + return { + previousNumerator: state.currentNumerator, + previousDenominator: state.currentDenominator, + currentNumerator: next.numerator, + currentDenominator: next.denominator, + bestNumerator: best.numerator, + bestDenominator: best.denominator, + remainingNumerator: state.remainingDenominator, + remainingDenominator: state.remainingNumerator % state.remainingDenominator, + }; +} + +function clampBoundedFractionState( + state: BoundedFractionState, + quotient: bigint, + target: BoundedFraction, + maxTerm: bigint, +): BoundedFractionState { + const termLimit = boundedFractionTermLimit(state, quotient, maxTerm); + if (termLimit <= 0n) { + return state; + } + + const candidateNumerator = termLimit * state.currentNumerator + state.previousNumerator; + const candidateDenominator = + termLimit * state.currentDenominator + state.previousDenominator; + const best = betterBoundedFraction( + state, + { numerator: candidateNumerator, denominator: candidateDenominator }, + target, + ); + return { + ...state, + bestNumerator: best.numerator, + bestDenominator: best.denominator, + }; +} + +function boundedFractionTermLimit( + state: BoundedFractionState, + quotient: bigint, + maxTerm: bigint, +): bigint { + let termLimit = quotient; + if (state.currentNumerator !== 0n) { + termLimit = minBigInt( + termLimit, + (maxTerm - state.previousNumerator) / state.currentNumerator, + ); + } + if (state.currentDenominator !== 0n) { + termLimit = minBigInt( + termLimit, + (maxTerm - state.previousDenominator) / state.currentDenominator, + ); + } + return termLimit; +} + +function betterBoundedFraction( + state: BoundedFractionState, + candidate: BoundedFraction, + target: BoundedFraction, +): BoundedFraction { + return candidate.numerator * target.denominator <= + candidate.denominator * target.numerator + ? candidate + : { numerator: state.bestNumerator, denominator: state.bestDenominator }; +} + +function minBigInt(left: bigint, right: bigint): bigint { + return left < right ? left : right; +} diff --git a/packages/order/src/matching/order_match_context.ts b/packages/order/src/matching/order_match_context.ts new file mode 100644 index 0000000..be768e0 --- /dev/null +++ b/packages/order/src/matching/order_match_context.ts @@ -0,0 +1,127 @@ +import { ccc } from "@ckb-ccc/core"; +import type { ExchangeRatio, ValueComponents } from "@ickb/utils"; +import type { OrderCell } from "../model/cells.ts"; +import type { MatchDiagnostics } from "./match_types.ts"; +import { ceilDiv } from "./order_conversion.ts"; +import { orderMatchers, summarizeMatchers } from "./order_match_sequence.ts"; +import type { OrderMatcher } from "./order_matcher.ts"; + +export interface BestMatchOptions { + feeRate?: ccc.Num; + ckbAllowanceStep?: ccc.FixedPoint; + maxPartials?: number; +} + +export interface BestMatchContext { + allowance: ValueComponents; + ckbAllowanceStep: ccc.FixedPoint; + ckbMiningFee: ccc.FixedPoint; + ckbScale: bigint; + ckbToUdtMatchers: OrderMatcher[]; + diagnostics: MatchDiagnostics; + maxPartials?: number; + udtAllowanceStep: ccc.FixedPoint; + udtScale: bigint; + udtToCkbMatchers: OrderMatcher[]; +} + +export function createBestMatchContext({ + orderPool, + allowance, + exchangeRate, + orderSize, + options, +}: { + orderPool: OrderCell[]; + allowance: ValueComponents; + exchangeRate: ExchangeRatio; + orderSize: number; + options: BestMatchOptions | undefined; +}): BestMatchContext { + const { ckbScale, udtScale } = checkedExchangeRate(exchangeRate); + const ckbAllowanceStep = checkedCkbAllowanceStep(options?.ckbAllowanceStep); + const ckbMiningFee = + (ccc.numFrom(36 + orderSize) * (options?.feeRate ?? 1000n) + 999n) / 1000n; + const udtAllowanceStep = ceilDiv(ckbAllowanceStep * ckbScale, udtScale); + const ckbToUdtMatchers = orderMatchers(orderPool, true, ckbMiningFee); + const udtToCkbMatchers = orderMatchers(orderPool, false, ckbMiningFee); + const diagnostics = bestMatchDiagnostics({ + allowance, + ckbAllowanceStep, + ckbMiningFee, + ckbToUdtMatchers, + maxPartials: options?.maxPartials, + orderCount: orderPool.length, + udtAllowanceStep, + udtToCkbMatchers, + }); + + return { + allowance, + ckbAllowanceStep, + ckbMiningFee, + ckbScale, + ckbToUdtMatchers, + diagnostics, + ...(options?.maxPartials === undefined ? {} : { maxPartials: options.maxPartials }), + udtAllowanceStep, + udtScale, + udtToCkbMatchers, + }; +} + +function checkedExchangeRate(exchangeRate: ExchangeRatio): { + ckbScale: bigint; + udtScale: bigint; +} { + const { ckbScale, udtScale } = exchangeRate; + if (ckbScale <= 0n || udtScale <= 0n) { + throw new Error("Exchange rate scales must be positive"); + } + return { ckbScale, udtScale }; +} + +function checkedCkbAllowanceStep(value?: ccc.FixedPoint): ccc.FixedPoint { + const ckbAllowanceStep = value ?? ccc.fixedPointFrom("1000"); + if (ckbAllowanceStep <= 0n) { + throw new Error("CKB allowance step must be positive"); + } + return ckbAllowanceStep; +} + +function bestMatchDiagnostics(options: { + allowance: ValueComponents; + ckbAllowanceStep: ccc.FixedPoint; + ckbMiningFee: ccc.FixedPoint; + ckbToUdtMatchers: OrderMatcher[]; + maxPartials?: number; + orderCount: number; + udtAllowanceStep: ccc.FixedPoint; + udtToCkbMatchers: OrderMatcher[]; +}): MatchDiagnostics { + return { + orderCount: options.orderCount, + allowance: options.allowance, + ckbAllowanceStep: options.ckbAllowanceStep, + udtAllowanceStep: options.udtAllowanceStep, + ckbMiningFee: options.ckbMiningFee, + ...(options.maxPartials === undefined ? {} : { maxPartials: options.maxPartials }), + directions: { + ckbToUdt: summarizeMatchers(options.ckbToUdtMatchers), + udtToCkb: summarizeMatchers(options.udtToCkbMatchers), + }, + candidates: { + total: 0, + viable: 0, + positiveGain: 0, + rejected: { + maxPartials: 0, + duplicateOrder: 0, + insufficientCkbAllowance: 0, + insufficientUdtAllowance: 0, + nonPositiveGain: 0, + }, + bestGain: 0n, + }, + }; +} diff --git a/packages/order/src/matching/order_match_search.ts b/packages/order/src/matching/order_match_search.ts new file mode 100644 index 0000000..dff5025 --- /dev/null +++ b/packages/order/src/matching/order_match_search.ts @@ -0,0 +1,301 @@ +import { BufferedGenerator } from "@ickb/utils"; +import type { Match } from "./match_types.ts"; +import type { BestMatchContext } from "./order_match_context.ts"; +import { sequentialMatches } from "./order_match_sequence.ts"; +import { + hasUniquePartialOrderOutPoints, + partialOutPointKeys, +} from "./order_match_uniqueness.ts"; +import type { OrderMatcher } from "./order_matcher.ts"; + +interface MatchSearchState { + advance: MatchAdvance; + best: MatchCandidate; +} + +interface MatchPair { + c2u: Match; + u2c: Match; +} + +interface MatchSearchPosition { + i: number; + j: number; +} + +interface MatchCandidateInput extends MatchPair { + next?: MatchSearchPosition; +} + +interface MatchBudgetExtensionInput extends MatchPair { + budgetKind: "ckb" | "udt"; + excluded: Set; +} + +interface MatchAdvance { + i: number; + j: number; + gain: bigint; +} + +interface MatchCandidate extends Match { + gain: bigint; +} + +export function searchBestMatch(context: BestMatchContext): Match { + const ckb2UdtMatches = new BufferedGenerator( + sequentialMatches(context.ckbToUdtMatchers, context.udtAllowanceStep), + 2, + ); + const udt2CkbMatches = new BufferedGenerator( + sequentialMatches(context.udtToCkbMatchers, context.ckbAllowanceStep), + 2, + ); + let state: MatchSearchState = { + advance: initialMatchAdvance(), + best: { ...emptyMatch(), gain: 0n }, + }; + + while (state.advance.i !== 0 || state.advance.j !== 0) { + ckb2UdtMatches.next(state.advance.i); + udt2CkbMatches.next(state.advance.j); + state = visitMatchFrontier( + context, + { ...state, advance: neutralMatchAdvance() }, + ckb2UdtMatches.buffer, + udt2CkbMatches.buffer, + ); + } + + const diagnostics = context.diagnostics; + diagnostics.candidates.bestGain = state.best.gain; + return { + ckbDelta: state.best.ckbDelta, + udtDelta: state.best.udtDelta, + partials: state.best.partials, + diagnostics: context.diagnostics, + }; +} + +function emptyMatch(): Match { + return { ckbDelta: 0n, udtDelta: 0n, partials: [] }; +} + +function initialMatchAdvance(): MatchAdvance { + return { i: -1, j: -1, gain: minimumGain() }; +} + +function neutralMatchAdvance(): MatchAdvance { + return { i: 0, j: 0, gain: minimumGain() }; +} + +function minimumGain(): bigint { + return -1n << 256n; +} + +function visitMatchFrontier( + context: BestMatchContext, + state: MatchSearchState, + ckb2UdtMatches: readonly Match[], + udt2CkbMatches: readonly Match[], +): MatchSearchState { + let nextState = state; + for (const [i, c2u] of ckb2UdtMatches.entries()) { + for (const [j, u2c] of udt2CkbMatches.entries()) { + nextState = visitMatchPair(context, nextState, { c2u, u2c }, { i, j }); + } + } + return nextState; +} + +function visitMatchPair( + context: BestMatchContext, + state: MatchSearchState, + pair: MatchPair, + next: MatchSearchPosition, +): MatchSearchState { + const { c2u, u2c } = pair; + const partials = c2u.partials.concat(u2c.partials); + let nextState = considerMatchCandidate(context, state, { ...pair, next }); + if (context.maxPartials !== undefined && partials.length >= context.maxPartials) { + return nextState; + } + + const excluded = partialOutPointKeys(partials); + nextState = considerBudgetExtension(context, nextState, { + ...pair, + excluded, + budgetKind: "ckb", + }); + return considerBudgetExtension(context, nextState, { + ...pair, + excluded, + budgetKind: "udt", + }); +} + +function considerBudgetExtension( + context: BestMatchContext, + state: MatchSearchState, + { c2u, u2c, excluded, budgetKind }: MatchBudgetExtensionInput, +): MatchSearchState { + const extension = + budgetKind === "ckb" + ? ckbBudgetExtension(context, u2c, c2u, excluded) + : udtBudgetExtension(context, c2u, u2c, excluded); + if (extension === undefined) { + return state; + } + return considerMatchCandidate( + context, + state, + budgetKind === "ckb" ? { c2u, u2c: extension } : { c2u: extension, u2c }, + ); +} + +function ckbBudgetExtension( + context: BestMatchContext, + u2c: Match, + c2u: Match, + excluded: Set, +): Match | undefined { + const partialCount = c2u.partials.length + u2c.partials.length; + const nextPartialFee = context.ckbMiningFee * BigInt(partialCount + 1); + const budget = + context.allowance.ckbValue + c2u.ckbDelta + u2c.ckbDelta - nextPartialFee; + return budget > 0n && budget < context.ckbAllowanceStep + ? extendMatch(u2c, context.udtToCkbMatchers, budget, excluded) + : undefined; +} + +function udtBudgetExtension( + context: BestMatchContext, + c2u: Match, + u2c: Match, + excluded: Set, +): Match | undefined { + const budget = context.allowance.udtValue + c2u.udtDelta + u2c.udtDelta; + return budget > 0n && budget < context.udtAllowanceStep + ? extendMatch(c2u, context.ckbToUdtMatchers, budget, excluded) + : undefined; +} + +function considerMatchCandidate( + context: BestMatchContext, + state: MatchSearchState, + { c2u, u2c, next }: MatchCandidateInput, +): MatchSearchState { + const candidate = matchCandidate(context, c2u, u2c); + const candidates = context.diagnostics.candidates; + candidates.total += 1; + if (!isViableMatchCandidate(context, candidate)) { + return state; + } + + candidates.viable += 1; + const advance = + next !== undefined && candidate.gain > state.advance.gain + ? { ...next, gain: candidate.gain } + : state.advance; + if (!isPositiveMatchCandidate(context, candidate)) { + return { ...state, advance }; + } + return candidate.gain > state.best.gain + ? { advance, best: candidate } + : { ...state, advance }; +} + +function matchCandidate( + context: BestMatchContext, + c2u: Match, + u2c: Match, +): MatchCandidate { + const ckbDelta = c2u.ckbDelta + u2c.ckbDelta; + const udtDelta = c2u.udtDelta + u2c.udtDelta; + const partials = c2u.partials.concat(u2c.partials); + const ckbFee = context.ckbMiningFee * BigInt(partials.length); + return { + ckbDelta, + udtDelta, + partials, + gain: (ckbDelta - ckbFee) * context.ckbScale + udtDelta * context.udtScale, + }; +} + +function isViableMatchCandidate( + context: BestMatchContext, + candidate: MatchCandidate, +): boolean { + if ( + context.maxPartials !== undefined && + candidate.partials.length > context.maxPartials + ) { + const rejected = context.diagnostics.candidates.rejected; + rejected.maxPartials += 1; + return false; + } + + const rejected = context.diagnostics.candidates.rejected; + if (!hasUniquePartialOrderOutPoints(candidate.partials)) { + rejected.duplicateOrder += 1; + return false; + } + + return hasCandidateAllowance(context, candidate); +} + +function hasCandidateAllowance( + context: BestMatchContext, + candidate: MatchCandidate, +): boolean { + const rejected = context.diagnostics.candidates.rejected; + const ckbFee = context.ckbMiningFee * BigInt(candidate.partials.length); + const ckbAllowance = context.allowance.ckbValue + candidate.ckbDelta - ckbFee; + const udtAllowance = context.allowance.udtValue + candidate.udtDelta; + if (ckbAllowance < 0n) { + rejected.insufficientCkbAllowance += 1; + } else if (udtAllowance < 0n) { + rejected.insufficientUdtAllowance += 1; + } + return ckbAllowance >= 0n && udtAllowance >= 0n; +} + +function isPositiveMatchCandidate( + context: BestMatchContext, + candidate: MatchCandidate, +): boolean { + if (candidate.partials.length === 0) { + return true; + } + + const candidates = context.diagnostics.candidates; + if (candidate.gain > 0n) { + candidates.positiveGain += 1; + return true; + } + candidates.rejected.nonPositiveGain += 1; + return false; +} + +function extendMatch( + base: Match, + matchers: OrderMatcher[], + allowance: bigint, + excludedOutPoints: Set, +): Match | undefined { + for (const matcher of matchers) { + if (excludedOutPoints.has(matcher.order.cell.outPoint.toHex())) { + continue; + } + const probe = matcher.match(allowance); + if (probe.partials.length === 0) { + continue; + } + return { + ckbDelta: base.ckbDelta + probe.ckbDelta, + udtDelta: base.udtDelta + probe.udtDelta, + partials: base.partials.concat(probe.partials), + }; + } + return undefined; +} diff --git a/packages/order/src/matching/order_match_sequence.ts b/packages/order/src/matching/order_match_sequence.ts new file mode 100644 index 0000000..0418d22 --- /dev/null +++ b/packages/order/src/matching/order_match_sequence.ts @@ -0,0 +1,77 @@ +import type { ccc } from "@ckb-ccc/core"; +import { compareBigInt } from "@ickb/utils"; +import type { OrderCell } from "../model/cells.ts"; +import type { Match, MatchDirectionDiagnostics } from "./match_types.ts"; +import { OrderMatcher } from "./order_matcher.ts"; + +export function orderMatchers( + orderPool: OrderCell[], + isCkb2Udt: boolean, + ckbMiningFee: ccc.FixedPoint, +): OrderMatcher[] { + return orderPool + .map((o) => OrderMatcher.from(o, isCkb2Udt, ckbMiningFee)) + .filter((matcher): matcher is OrderMatcher => matcher !== undefined) + .toSorted((a, b) => OrderMatcher.compareRealRatioDesc(a, b)); +} + +export function* sequentialMatches( + matchers: OrderMatcher[], + allowanceStep: ccc.FixedPoint, +): Generator { + if (allowanceStep <= 0n) { + throw new Error("Allowance step must be positive"); + } + let acc: Match = emptyMatch(); + let curr = acc; + yield curr; + + for (const matcher of matchers) { + const maxMatch = matcher.bMaxMatch; + const N = (maxMatch + allowanceStep - 1n) / allowanceStep; + const q = maxMatch / N; + const r = maxMatch % N; + let allowance = 0n; + + for (let i = 0n; i < N; i++) { + allowance += i < r ? q + 1n : q; + const m = matcher.match(allowance); + if (m.partials.length === 0) { + continue; + } + curr = { + ckbDelta: acc.ckbDelta + m.ckbDelta, + udtDelta: acc.udtDelta + m.udtDelta, + partials: acc.partials.concat(m.partials), + }; + yield curr; + } + acc = curr; + } +} + +export function summarizeMatchers(matchers: OrderMatcher[]): MatchDirectionDiagnostics { + const matchableCount = matchers.length; + let minAllowance: ccc.FixedPoint | undefined; + let maxMatch: ccc.FixedPoint | undefined; + for (const matcher of matchers) { + minAllowance = + minAllowance === undefined || compareBigInt(matcher.bMinMatch, minAllowance) < 0 + ? matcher.bMinMatch + : minAllowance; + maxMatch = + maxMatch === undefined || compareBigInt(matcher.bMaxMatch, maxMatch) > 0 + ? matcher.bMaxMatch + : maxMatch; + } + + return { + matchableCount, + ...(minAllowance === undefined ? {} : { minAllowance }), + ...(maxMatch === undefined ? {} : { maxMatch }), + }; +} + +function emptyMatch(): Match { + return { ckbDelta: 0n, udtDelta: 0n, partials: [] }; +} diff --git a/packages/order/src/matching/order_match_uniqueness.ts b/packages/order/src/matching/order_match_uniqueness.ts new file mode 100644 index 0000000..1d2b5b3 --- /dev/null +++ b/packages/order/src/matching/order_match_uniqueness.ts @@ -0,0 +1,18 @@ +import type { Match } from "./match_types.ts"; + +export function hasUniquePartialOrderOutPoints(partials: Match["partials"]): boolean { + const outPoints = new Set(); + for (const partial of partials) { + const key = partial.order.cell.outPoint.toHex(); + if (outPoints.has(key)) { + return false; + } + outPoints.add(key); + } + + return true; +} + +export function partialOutPointKeys(partials: Match["partials"]): Set { + return new Set(partials.map((partial) => partial.order.cell.outPoint.toHex())); +} diff --git a/packages/order/src/matching/order_matcher.ts b/packages/order/src/matching/order_matcher.ts new file mode 100644 index 0000000..5562015 --- /dev/null +++ b/packages/order/src/matching/order_matcher.ts @@ -0,0 +1,288 @@ +import type { ccc } from "@ckb-ccc/core"; +import { compareBigInt } from "@ickb/utils"; +import type { OrderCell } from "../model/cells.ts"; +import type { Match } from "./match_types.ts"; + +type OrderMatcherParameters = [ + aScale: ccc.Num, + bScale: ccc.Num, + aIn: ccc.FixedPoint, + bIn: ccc.FixedPoint, + aMin: ccc.FixedPoint, + bMinMatch: ccc.FixedPoint, + bMaxMatch: ccc.FixedPoint, + bMaxOut: ccc.FixedPoint, + realRatioNumerator: ccc.FixedPoint, + realRatioDenominator: ccc.FixedPoint, +]; + +type OrderMatcherConstructorArgs = [ + order: OrderCell, + isCkb2Udt: boolean, + ...parameters: OrderMatcherParameters, +]; + +type NonDecreasingArgs = [ + aScale: ccc.Num, + bScale: ccc.Num, + aIn: ccc.FixedPoint, + bIn: ccc.FixedPoint, + aOut: ccc.FixedPoint, +]; + +interface OrderMatcherValues { + aScale: ccc.Num; + bScale: ccc.Num; + aIn: ccc.FixedPoint; + bIn: ccc.FixedPoint; + aMin: ccc.FixedPoint; + bMinMatch: ccc.FixedPoint; + aMiningFee: ccc.FixedPoint; + bMiningFee: ccc.FixedPoint; +} + +export class OrderMatcher { + public readonly order: OrderCell; + public readonly isCkb2Udt: boolean; + public readonly aScale: ccc.Num; + public readonly bScale: ccc.Num; + public readonly aIn: ccc.FixedPoint; + public readonly bIn: ccc.FixedPoint; + public readonly aMin: ccc.FixedPoint; + public readonly bMinMatch: ccc.FixedPoint; + public readonly bMaxMatch: ccc.FixedPoint; + public readonly bMaxOut: ccc.FixedPoint; + public readonly realRatioNumerator: ccc.FixedPoint; + public readonly realRatioDenominator: ccc.FixedPoint; + + constructor( + ...[ + order, + isCkb2Udt, + aScale, + bScale, + aIn, + bIn, + aMin, + bMinMatch, + bMaxMatch, + bMaxOut, + realRatioNumerator, + realRatioDenominator, + ]: OrderMatcherConstructorArgs + ) { + assertOrderMatcherParameters({ + aScale, + bScale, + aIn, + bIn, + aMin, + bMinMatch, + bMaxMatch, + bMaxOut, + realRatioNumerator, + realRatioDenominator, + }); + this.order = order; + this.isCkb2Udt = isCkb2Udt; + this.aScale = aScale; + this.bScale = bScale; + this.aIn = aIn; + this.bIn = bIn; + this.aMin = aMin; + this.bMinMatch = bMinMatch; + this.bMaxMatch = bMaxMatch; + this.bMaxOut = bMaxOut; + this.realRatioNumerator = realRatioNumerator; + this.realRatioDenominator = realRatioDenominator; + } + + public static compareRealRatioDesc(left: OrderMatcher, right: OrderMatcher): number { + return compareBigInt( + right.realRatioNumerator * left.realRatioDenominator, + left.realRatioNumerator * right.realRatioDenominator, + ); + } + + public static from( + order: OrderCell, + isCkb2Udt: boolean, + ckbMiningFee: ccc.FixedPoint, + ): OrderMatcher | undefined { + const parameters = orderMatcherParameters(order, isCkb2Udt, ckbMiningFee); + return parameters === undefined + ? undefined + : new OrderMatcher(order, isCkb2Udt, ...parameters); + } + + public match(bAllowance: ccc.FixedPoint): Match { + if (bAllowance < this.bMinMatch) { + return { ckbDelta: 0n, udtDelta: 0n, partials: [] }; + } + + if (bAllowance >= this.bMaxMatch) { + return this.create(this.aMin, this.bMaxOut); + } + + const bOut = this.bIn + bAllowance; + const aOut = nonDecreasing(this.bScale, this.aScale, this.bIn, this.aIn, bOut); + + if ( + !this.isCkb2Udt && + this.aIn * this.aScale < aOut * this.aScale + this.bMinMatch * this.bScale + ) { + return { ckbDelta: 0n, udtDelta: 0n, partials: [] }; + } + + return this.create(aOut, bOut); + } + + public create(aOut: ccc.FixedPoint, bOut: ccc.FixedPoint): Match { + return this.isCkb2Udt + ? { + ckbDelta: this.aIn - aOut, + udtDelta: this.bIn - bOut, + partials: [{ order: this.order, ckbOut: aOut, udtOut: bOut }], + } + : { + ckbDelta: this.bIn - bOut, + udtDelta: this.aIn - aOut, + partials: [{ order: this.order, ckbOut: bOut, udtOut: aOut }], + }; + } + + public static nonDecreasing( + ...[aScale, bScale, aIn, bIn, aOut]: NonDecreasingArgs + ): ccc.FixedPoint { + return nonDecreasing(aScale, bScale, aIn, bIn, aOut); + } +} + +function orderMatcherParameters( + order: OrderCell, + isCkb2Udt: boolean, + ckbMiningFee: ccc.FixedPoint, +): OrderMatcherParameters | undefined { + const values = orderMatcherValues(order, isCkb2Udt, ckbMiningFee); + if (values === undefined) { + return undefined; + } + + const { aScale, bScale, aIn, bIn, aMin, bMinMatch, aMiningFee, bMiningFee } = values; + if (aIn <= aMin + aMiningFee || aScale <= 0n || bScale <= 0n) { + return undefined; + } + + const bMaxOut = nonDecreasing(aScale, bScale, aIn, bIn, aMin); + const bMaxMatch = bMaxOut - bIn; + const realRatioNumerator = aIn - aMin - aMiningFee; + const realRatioDenominator = bMaxMatch + bMiningFee; + if (realRatioNumerator <= 0n || realRatioDenominator <= 0n) { + return undefined; + } + + return [ + aScale, + bScale, + aIn, + bIn, + aMin, + minBigInt(bMinMatch, bMaxMatch), + bMaxMatch, + bMaxOut, + realRatioNumerator, + realRatioDenominator, + ]; +} + +function orderMatcherValues( + order: OrderCell, + isCkb2Udt: boolean, + ckbMiningFee: ccc.FixedPoint, +): OrderMatcherValues | undefined { + if (isCkb2Udt ? !order.isCkb2UdtMatchable() : !order.isUdt2CkbMatchable()) { + return undefined; + } + + if (isCkb2Udt) { + const { ckbScale: aScale, udtScale: bScale } = order.data.info.ckbToUdt; + return { + aScale, + bScale, + aIn: order.ckbValue, + bIn: order.udtValue, + aMin: order.cell.cellOutput.capacity - order.ckbUnoccupied, + bMinMatch: (order.data.info.getCkbMinMatch() * bScale + aScale - 1n) / aScale, + aMiningFee: ckbMiningFee, + bMiningFee: 0n, + }; + } + + const { ckbScale: bScale, udtScale: aScale } = order.data.info.udtToCkb; + return { + aScale, + bScale, + aIn: order.udtValue, + bIn: order.ckbValue, + aMin: 0n, + bMinMatch: order.data.info.getCkbMinMatch(), + aMiningFee: 0n, + bMiningFee: ckbMiningFee, + }; +} + +function nonDecreasing( + ...[aScale, bScale, aIn, bIn, aOut]: NonDecreasingArgs +): ccc.FixedPoint { + return (aScale * (aIn - aOut) + bScale * (bIn + 1n) - 1n) / bScale; +} + +function minBigInt(left: bigint, right: bigint): bigint { + return left < right ? left : right; +} + +function assertOrderMatcherParameters({ + aScale, + bScale, + aIn, + bIn, + aMin, + bMinMatch, + bMaxMatch, + bMaxOut, + realRatioNumerator, + realRatioDenominator, +}: { + aScale: ccc.Num; + bScale: ccc.Num; + aIn: ccc.FixedPoint; + bIn: ccc.FixedPoint; + aMin: ccc.FixedPoint; + bMinMatch: ccc.FixedPoint; + bMaxMatch: ccc.FixedPoint; + bMaxOut: ccc.FixedPoint; + realRatioNumerator: ccc.FixedPoint; + realRatioDenominator: ccc.FixedPoint; +}): void { + if (aScale <= 0n || bScale <= 0n) { + throw new Error("OrderMatcher scales must be positive"); + } + if (realRatioNumerator <= 0n || realRatioDenominator <= 0n) { + throw new Error("OrderMatcher real ratio terms must be positive"); + } + for (const [name, value] of [ + ["aIn", aIn], + ["bIn", bIn], + ["aMin", aMin], + ["bMinMatch", bMinMatch], + ["bMaxMatch", bMaxMatch], + ["bMaxOut", bMaxOut], + ] as const) { + if (value < 0n) { + throw new Error(`OrderMatcher ${name} must be non-negative`); + } + } + if (bMaxMatch < bMinMatch) { + throw new Error("OrderMatcher maximum match must be at least the minimum match"); + } +} diff --git a/packages/order/src/matching/order_matching.ts b/packages/order/src/matching/order_matching.ts new file mode 100644 index 0000000..5104da4 --- /dev/null +++ b/packages/order/src/matching/order_matching.ts @@ -0,0 +1,30 @@ +import type { ExchangeRatio, ValueComponents } from "@ickb/utils"; +import { maxOrderOccupiedSize } from "../io/order_io.ts"; +import type { OrderCell } from "../model/cells.ts"; +import type { Match } from "./match_types.ts"; +import { createBestMatchContext, type BestMatchOptions } from "./order_match_context.ts"; +import { searchBestMatch } from "./order_match_search.ts"; + +export type { + Match, + MatchDiagnostics, + MatchDirectionDiagnostics, +} from "./match_types.ts"; +export { orderMatchers, sequentialMatches } from "./order_match_sequence.ts"; +export { OrderMatcher } from "./order_matcher.ts"; + +export function bestMatch( + orderPool: OrderCell[], + allowance: ValueComponents, + exchangeRate: ExchangeRatio, + options?: BestMatchOptions, +): Match { + const orderSize = maxOrderOccupiedSize(orderPool); + if (orderSize === 0) { + return { ckbDelta: 0n, udtDelta: 0n, partials: [] }; + } + + return searchBestMatch( + createBestMatchContext({ orderPool, allowance, exchangeRate, orderSize, options }), + ); +} diff --git a/packages/order/src/cells.ts b/packages/order/src/model/cells.ts similarity index 53% rename from packages/order/src/cells.ts rename to packages/order/src/model/cells.ts index 29e0a4b..ac703dc 100644 --- a/packages/order/src/cells.ts +++ b/packages/order/src/model/cells.ts @@ -1,13 +1,43 @@ import { ccc } from "@ckb-ccc/core"; -import { OrderData } from "./entities.js"; import type { ValueComponents } from "@ickb/utils"; +import { OrderData } from "./order_data.ts"; + +/** + * Constructor tuple for the decoded cell, order payload, value totals, progress, and optional maturity estimate. + * + * @public + */ +export type OrderCellConstructorArgs = [ + cell: ccc.Cell, + data: OrderData, + ckbUnoccupied: ccc.FixedPoint, + absTotal: ccc.Num, + absProgress: ccc.Num, + maturity: bigint | undefined, +]; + /** * Represents a parsed order cell on the blockchain. * * Implements `ValueComponents` to expose the cell's raw data and its * value breakdown (CKB and UDT). + * + * @public */ export class OrderCell implements ValueComponents { + /** Raw live cell that carries the order lock and UDT type. */ + public cell: ccc.Cell; + /** Decoded order payload from `cell.outputData`. */ + public data: OrderData; + /** CKB capacity available for matching after occupied capacity is reserved. */ + public ckbUnoccupied: ccc.FixedPoint; + /** Absolute total order value in the order's comparison units. */ + public absTotal: ccc.Num; + /** Absolute matched progress in the order's comparison units. */ + public absProgress: ccc.Num; + /** Estimated completion maturity, `0n` for complete orders, or `undefined` when unavailable. */ + public maturity: bigint | undefined; + /** * Creates an instance of OrderCell. * @@ -16,26 +46,36 @@ export class OrderCell implements ValueComponents { * @param ckbUnoccupied - Amount of CKB in this cell not used in state rent. * @param absTotal - Absolute total value of this order (in base units). * @param absProgress - Absolute amount filled so far (in base units). - * @param maturity - Estimated completion time: - * - `bigint` Unix timestamp in milliseconds if scheduled, - * - `0n` if already completed, - * - `undefined` if no estimate is available. + * @param maturity - Estimated completion time supplied by higher-level state: + * - `bigint` Unix timestamp in milliseconds when estimated, + * - `0n` when already completed, + * - `undefined` when no estimate is available. Core parsing uses + * `undefined` for in-progress or dual orders and `0n` for completed directional orders. */ constructor( - public cell: ccc.Cell, - public data: OrderData, - public ckbUnoccupied: ccc.FixedPoint, - public absTotal: ccc.Num, - public absProgress: ccc.Num, - public maturity: bigint | undefined, - ) {} + ...[ + cell, + data, + ckbUnoccupied, + absTotal, + absProgress, + maturity, + ]: OrderCellConstructorArgs + ) { + this.cell = cell; + this.data = data; + this.ckbUnoccupied = ckbUnoccupied; + this.absTotal = absTotal; + this.absProgress = absProgress; + this.maturity = maturity; + } /** * Gets the order CKB amount. * * @returns The CKB amount as a `ccc.FixedPoint`. */ - get ckbValue(): ccc.FixedPoint { + public get ckbValue(): ccc.FixedPoint { return this.cell.cellOutput.capacity; } @@ -44,7 +84,7 @@ export class OrderCell implements ValueComponents { * * @returns The UDT amount as a `ccc.FixedPoint`. */ - get udtValue(): ccc.FixedPoint { + public get udtValue(): ccc.FixedPoint { return this.data.udtValue; } @@ -56,7 +96,7 @@ export class OrderCell implements ValueComponents { * @param cell - The raw chain cell to convert. * @returns An `OrderCell` instance or `undefined`. */ - static tryFrom(cell: ccc.Cell): OrderCell | undefined { + public static tryFrom(cell: ccc.Cell): OrderCell | undefined { try { return OrderCell.mustFrom(cell); } catch { @@ -73,7 +113,7 @@ export class OrderCell implements ValueComponents { * @returns A new `OrderCell` instance. * @throws When decoding or validation fails. */ - static mustFrom(cell: ccc.Cell): OrderCell { + public static mustFrom(cell: ccc.Cell): OrderCell { // Decode and validate the order payload const data = OrderData.decode(cell.outputData); data.validate(); @@ -93,35 +133,21 @@ export class OrderCell implements ValueComponents { ? ckbUnoccupied * udtToCkb.ckbScale + udtValue * udtToCkb.udtScale : 0n; - // Determine absolute total: single or average for dual-ratio - const absTotal = - ckb2UdtValue === 0n - ? udt2CkbValue - : udt2CkbValue === 0n - ? ckb2UdtValue - : // Take the average of the two values for dual ratio orders - (ckb2UdtValue * udtToCkb.ckbScale * udtToCkb.udtScale + - udt2CkbValue * ckbToUdt.ckbScale * ckbToUdt.udtScale) >> - 1n; - - // Compute progress: full for dual, else based on direction - const absProgress = isDualRatio - ? absTotal - : isCkb2Udt - ? udtValue * ckbToUdt.udtScale - : ckbUnoccupied * udtToCkb.ckbScale; + const absTotal = absoluteOrderTotal(ckb2UdtValue, udt2CkbValue, data.info); + const absProgress = absoluteOrderProgress({ + absTotal, + ckbUnoccupied, + isCkb2Udt, + isDualRatio, + udtScale: ckbToUdt.udtScale, + udtValue, + udtToCkbCkbScale: udtToCkb.ckbScale, + }); // Maturity: undefined if in-progress or dual; zero if complete const maturity = isDualRatio || absTotal !== absProgress ? undefined : 0n; - return new OrderCell( - cell, - data, - ckbUnoccupied, - absTotal, - absProgress, - maturity, - ); + return new OrderCell(cell, data, ckbUnoccupied, absTotal, absProgress, maturity); } /** @@ -129,31 +155,30 @@ export class OrderCell implements ValueComponents { * * @returns True if the order is dual ratio (liquidity provider), otherwise false. */ - isDualRatio(): boolean { + public isDualRatio(): boolean { return this.data.info.isDualRatio(); } /** - * Checks if the order can be matched as a CKB to UDT order. - * @returns True if the order is matchable as CKB to UDT, otherwise false. + * Checks whether the CKB-to-UDT side is enabled and has nonzero inventory. + * + * @remarks Executable-match checks for fee, minimum output, and ratio bounds happen in `OrderMatcher`. */ - isCkb2UdtMatchable(): boolean { + public isCkb2UdtMatchable(): boolean { return this.data.info.isCkb2Udt() && this.ckbUnoccupied > 0n; } /** - * Checks if the order can be matched as a UDT to CKB order. - * @returns True if the order is matchable as UDT to CKB, otherwise false. + * Checks whether the UDT-to-CKB side is enabled and has nonzero inventory. + * + * @remarks Executable-match checks for fee, minimum output, and ratio bounds happen in `OrderMatcher`. */ - isUdt2CkbMatchable(): boolean { + public isUdt2CkbMatchable(): boolean { return this.data.info.isUdt2Ckb() && this.data.udtValue > 0n; } - /** - * Checks if the order is matchable in any way. - * @returns True if the order is matchable, otherwise false. - */ - isMatchable(): boolean { + /** Returns true when either side is enabled and has nonzero inventory. */ + public isMatchable(): boolean { return this.isCkb2UdtMatchable() || this.isUdt2CkbMatchable(); } @@ -162,7 +187,7 @@ export class OrderCell implements ValueComponents { * * @returns True if the order is fulfilled (not matchable), otherwise false. */ - isFulfilled(): boolean { + public isFulfilled(): boolean { return !this.isMatchable(); } @@ -170,17 +195,19 @@ export class OrderCell implements ValueComponents { * Retrieves the master out point of the order. * @returns The master out point associated with the order. */ - getMaster(): ccc.OutPoint { + public getMaster(): ccc.OutPoint { return this.data.getMaster(this.cell.outPoint); } /** - * Countermeasure to Confusion Attack https://github.com/ickb/whitepaper/issues/19 - * Validates the order against a descendant order. - * @param descendant - The descendant order to validate against. - * @throws Will throw an error if validation fails. + * Validates a descendant order against this origin order. + * + * @remarks + * This confusion-attack guard requires the same order script, UDT type, + * master, and info, while forbidding lower total value or lower progress. + * See {@link https://github.com/ickb/whitepaper/issues/19}. */ - validate(descendant: OrderCell): void { + public validate(descendant: OrderCell): void { // Same cell, nothing to check if (this.cell.outPoint.eq(descendant.cell.outPoint)) { return; @@ -191,7 +218,7 @@ export class OrderCell implements ValueComponents { } const udt = this.cell.cellOutput.type; - if (!udt || !descendant.cell.cellOutput.type?.eq(udt)) { + if (udt === undefined || descendant.cell.cellOutput.type?.eq(udt) !== true) { throw new Error("UDT type is different"); } @@ -217,7 +244,7 @@ export class OrderCell implements ValueComponents { * @param descendant - The descendant order to validate. * @returns True if the descendant is valid, otherwise false. */ - isValid(descendant: OrderCell): boolean { + public isValid(descendant: OrderCell): boolean { try { this.validate(descendant); return true; @@ -227,75 +254,137 @@ export class OrderCell implements ValueComponents { } /** - * Countermeasure to Confusion Attack https://github.com/ickb/whitepaper/issues/19 - * Resolves the best descendant order from a list of descendants. - * @param descendants - The list of descendant orders to resolve. - * @returns The best matching descendant order or undefined if none is valid. + * Resolves the best valid descendant order. + * + * @remarks + * Resolution prefers greater progress, then a mint order over a non-mint order + * at equal progress. Equal-progress non-identical candidates with the same + * mint status are ambiguous and resolve to `undefined`. */ - resolve(descendants: OrderCell[]): OrderCell | undefined { - let best: OrderCell | undefined = undefined; - let isAmbiguous = false; + public resolve(descendants: OrderCell[]): OrderCell | undefined { + let resolution: OrderResolution | undefined; for (const descendant of descendants) { if (!this.isValid(descendant)) { continue; } - // Directional orders rank by irreversible progress. Dual-sided orders - // rank by value because absProgress === absTotal for that shape. - // At equal progress, prefer newly minted orders. - if (!best || best.absProgress < descendant.absProgress) { - best = descendant; - isAmbiguous = false; - continue; - } + resolution = betterOrderResolution(resolution, descendant); + } - if (best.absProgress !== descendant.absProgress) { - continue; - } + if (resolution?.isAmbiguous === true) { + return undefined; + } - if (best.cell.outPoint.eq(descendant.cell.outPoint)) { - continue; - } + return resolution?.order; + } +} - if (descendant.data.isMint() && !best.data.isMint()) { - best = descendant; - isAmbiguous = false; - continue; - } +interface OrderResolution { + isAmbiguous: boolean; + order: OrderCell; +} - if (!descendant.data.isMint() && best.data.isMint()) { - continue; - } +function absoluteOrderTotal( + ckb2UdtValue: bigint, + udt2CkbValue: bigint, + info: OrderData["info"], +): bigint { + if (ckb2UdtValue === 0n) { + return udt2CkbValue; + } + if (udt2CkbValue === 0n) { + return ckb2UdtValue; + } - if (best.absProgress === descendant.absProgress) { - isAmbiguous = true; - } - } + const { ckbToUdt, udtToCkb } = info; + return ( + (ckb2UdtValue * udtToCkb.ckbScale * udtToCkb.udtScale + + udt2CkbValue * ckbToUdt.ckbScale * ckbToUdt.udtScale) >> + 1n + ); +} - if (isAmbiguous) { - return undefined; - } +function absoluteOrderProgress(options: { + absTotal: bigint; + ckbUnoccupied: bigint; + isCkb2Udt: boolean; + isDualRatio: boolean; + udtScale: bigint; + udtToCkbCkbScale: bigint; + udtValue: bigint; +}): bigint { + if (options.isDualRatio) { + return options.absTotal; + } + if (options.isCkb2Udt) { + return options.udtValue * options.udtScale; + } + return options.ckbUnoccupied * options.udtToCkbCkbScale; +} - return best; +function betterOrderResolution( + current: OrderResolution | undefined, + candidate: OrderCell, +): OrderResolution { + if (current === undefined || current.order.absProgress < candidate.absProgress) { + return { isAmbiguous: false, order: candidate }; + } + if (current.order.absProgress !== candidate.absProgress) { + return current; } + if (current.order.cell.outPoint.eq(candidate.cell.outPoint)) { + return current; + } + if (candidate.data.isMint() && !current.order.data.isMint()) { + return { isAmbiguous: false, order: candidate }; + } + if (!candidate.data.isMint() && current.order.data.isMint()) { + return current; + } + return { ...current, isAmbiguous: true }; } /** * Represents a master cell + * + * @public */ export class MasterCell implements ValueComponents { + /** Raw live master cell that anchors an order group. */ + public cell: ccc.Cell; + + /** + * Gets the UDT value of the cell. + * + * For a Master Cell, the UDT amount is always zero. + * + * @returns The UDT amount as a `ccc.FixedPoint` (0n). + */ + public readonly udtValue = 0n; + /** * Creates an instance of MasterCell. * @param cell - The ccc.Cell instance to be wrapped by the MasterCell. */ - constructor(public cell: ccc.Cell) {} + constructor(cell: ccc.Cell) { + this.cell = cell; + } + + /** + * Gets the CKB value of the cell. + * + * @returns The total CKB amount as a `ccc.FixedPoint`. + */ + public get ckbValue(): ccc.FixedPoint { + return this.cell.cellOutput.capacity; + } /** * Creates a MasterCell instance from a cell-like object. * @param cellLike - An object that can be converted to a ccc.Cell. * @returns A new instance of MasterCell. */ - static from(cellLike: ccc.CellLike): MasterCell { + public static from(cellLike: ccc.CellLike): MasterCell { return new MasterCell(ccc.Cell.from(cellLike)); } @@ -304,8 +393,8 @@ export class MasterCell implements ValueComponents { * @param order - The OrderCell to validate against. * @throws Error if the order script is different or if the master is different. */ - validate(order: OrderCell): void { - if (!this.cell.cellOutput.type?.eq(order.cell.cellOutput.lock)) { + public validate(order: OrderCell): void { + if (this.cell.cellOutput.type?.eq(order.cell.cellOutput.lock) !== true) { throw new Error("Order script different"); } @@ -313,41 +402,50 @@ export class MasterCell implements ValueComponents { throw new Error("Master is different"); } } - - /** - * Gets the CKB value of the cell. - * - * @returns The total CKB amount as a `ccc.FixedPoint`. - */ - get ckbValue(): ccc.FixedPoint { - return this.cell.cellOutput.capacity; - } - - /** - * Gets the UDT value of the cell. - * - * For a Master Cell, the UDT amount is always zero. - * - * @returns The UDT amount as a `ccc.FixedPoint` (0n). - */ - readonly udtValue = 0n; } /** * Represents a group of orders associated with a master cell. + * + * @public */ export class OrderGroup implements ValueComponents { + /** Master cell that authorizes and anchors the current order. */ + public master: MasterCell; + /** Current resolved order cell. */ + public order: OrderCell; + /** Origin order used to validate descendant progress and identity. */ + public origin: OrderCell; + /** * Creates an instance of OrderGroup. * @param master - The master cell associated with the order group. * @param order - The order within the group. * @param origin - The original order associated with the group. */ - constructor( - public master: MasterCell, - public order: OrderCell, - public origin: OrderCell, - ) {} + constructor(master: MasterCell, order: OrderCell, origin: OrderCell) { + this.master = master; + this.order = order; + this.origin = origin; + } + + /** + * Gets the CKB value of the group. + * + * @returns The total CKB amount as a `ccc.FixedPoint`, which is the sum of the CKB values of the order cell and the master cell. + */ + public get ckbValue(): ccc.FixedPoint { + return this.order.cell.cellOutput.capacity + this.master.cell.cellOutput.capacity; + } + + /** + * Gets the UDT value of the group. + * + * @returns The UDT amount as a `ccc.FixedPoint`, derived from the order cell. + */ + public get udtValue(): ccc.FixedPoint { + return this.order.data.udtValue; + } /** * Tries to create an OrderGroup from the provided parameters. @@ -356,7 +454,7 @@ export class OrderGroup implements ValueComponents { * @param origin - The original order. * @returns An OrderGroup instance or undefined if creation fails. */ - static tryFrom( + public static tryFrom( master: MasterCell, order: OrderCell, origin: OrderCell, @@ -372,7 +470,7 @@ export class OrderGroup implements ValueComponents { * Validates the order group against its master and origin orders. * @throws Will throw an error if validation fails. */ - validate(): void { + public validate(): void { this.master.validate(this.order); this.origin.validate(this.order); } @@ -381,7 +479,7 @@ export class OrderGroup implements ValueComponents { * Checks if the order group is valid. * @returns True if the order group is valid, otherwise false. */ - isValid(): boolean { + public isValid(): boolean { try { this.validate(); return true; @@ -396,28 +494,8 @@ export class OrderGroup implements ValueComponents { * @param locks - The locks to check ownership against. * @returns True if the lock is the owner, otherwise false. */ - isOwner(...locks: ccc.Script[]): boolean { + public isOwner(...locks: ccc.Script[]): boolean { const lock = this.master.cell.cellOutput.lock; return locks.some((l) => lock.eq(l)); } - - /** - * Gets the CKB value of the group. - * - * @returns The total CKB amount as a `ccc.FixedPoint`, which is the sum of the CKB values of the order cell and the master cell. - */ - get ckbValue(): ccc.FixedPoint { - return ( - this.order.cell.cellOutput.capacity + this.master.cell.cellOutput.capacity - ); - } - - /** - * Gets the UDT value of the group. - * - * @returns The UDT amount as a `ccc.FixedPoint`, derived from the order cell. - */ - get udtValue(): ccc.FixedPoint { - return this.order.data.udtValue; - } } diff --git a/packages/order/src/model/entity_validity.ts b/packages/order/src/model/entity_validity.ts new file mode 100644 index 0000000..053c050 --- /dev/null +++ b/packages/order/src/model/entity_validity.ts @@ -0,0 +1,8 @@ +export function isValidEntity(entity: { validate: () => void }): boolean { + try { + entity.validate(); + return true; + } catch { + return false; + } +} diff --git a/packages/order/src/model/info.ts b/packages/order/src/model/info.ts new file mode 100644 index 0000000..0e366d8 --- /dev/null +++ b/packages/order/src/model/info.ts @@ -0,0 +1,157 @@ +import { ccc, mol } from "@ckb-ccc/core"; +import type { ExchangeRatio } from "@ickb/utils"; +import { isValidEntity } from "./entity_validity.ts"; +import { Ratio } from "./ratio.ts"; + +/** + * Wire shape for an order's directional ratios and minimum CKB match. + * + * @public + */ +export interface InfoLike { + /** Populated when the order can trade CKB for UDT; empty otherwise. */ + ckbToUdt: ExchangeRatio; + /** Populated when the order can trade UDT for CKB; empty otherwise. */ + udtToCkb: ExchangeRatio; + /** Base-2 exponent for the minimum CKB match amount. */ + ckbMinMatchLog: ccc.FixedPointLike; +} + +const InfoCodec = mol.struct({ + ckbToUdt: Ratio, + udtToCkb: Ratio, + ckbMinMatchLog: mol.Uint8, +}); + +/** + * CCC entity base for serializing and decoding order `Info` values. + * + * @public + */ +export const InfoBase = ccc.Entity.Base(); + +/** + * Order price and minimum-match metadata. + * + * @remarks + * Exactly one populated ratio describes a directional order. Two populated + * ratios describe a dual-ratio order. Validation rejects empty/invalid pairs and + * ratio pairs that allow value extraction. + * + * @public + */ +export class Info extends InfoBase { + static { + ccc.codec(InfoCodec)(this); + } + + /** Ratio for CKB-to-UDT matching, or empty when unavailable. */ + public ckbToUdt: Ratio; + /** Ratio for UDT-to-CKB matching, or empty when unavailable. */ + public udtToCkb: Ratio; + /** Base-2 exponent for the minimum CKB match amount. */ + public ckbMinMatchLog: number; + + /** Creates order info from normalized ratio objects. */ + constructor(ckbToUdt: Ratio, udtToCkb: Ratio, ckbMinMatchLog: number) { + super(); + this.ckbToUdt = ckbToUdt; + this.udtToCkb = udtToCkb; + this.ckbMinMatchLog = ckbMinMatchLog; + } + + /** Normalizes an `InfoLike` wire object or existing entity into `Info`. */ + public static override from(info: InfoLike): Info { + if (info instanceof Info) { + return info; + } + + const { ckbToUdt, udtToCkb, ckbMinMatchLog } = info; + return new Info(Ratio.from(ckbToUdt), Ratio.from(udtToCkb), Number(ckbMinMatchLog)); + } + + /** Creates directional order info from one ratio. */ + public static create( + isCkb2Udt: boolean, + ratioLike: ExchangeRatio, + ckbMinMatchLog = Info.ckbMinMatchLogDefault(), + ): Info { + return Info.from({ + ckbToUdt: isCkb2Udt ? ratioLike : Ratio.empty(), + udtToCkb: isCkb2Udt ? Ratio.empty() : ratioLike, + ckbMinMatchLog, + }); + } + + /** Throws when ratio pairing or minimum-match exponent is invalid. */ + public validate(): void { + if (this.ckbMinMatchLog < 0 || this.ckbMinMatchLog > 64) { + throw new Error("ckbMinMatchLog invalid"); + } + + if (this.ckbToUdt.isEmpty()) { + if (this.udtToCkb.isPopulated()) { + return; + } + throw new Error("ckbToUdt is Empty, but udtToCkb is not Populated"); + } + + if (this.udtToCkb.isEmpty()) { + if (this.ckbToUdt.isPopulated()) { + return; + } + throw new Error("udtToCkb is Empty, but ckbToUdt is not Populated"); + } + + if (!this.ckbToUdt.isPopulated() || !this.udtToCkb.isPopulated()) { + throw new Error("One ratio is invalid, so not Empty and not Populated"); + } + + if ( + this.ckbToUdt.ckbScale * this.udtToCkb.udtScale < + this.ckbToUdt.udtScale * this.udtToCkb.ckbScale + ) { + throw new Error("udtToCkb and ckbToUdt allow order value to be extracted"); + } + } + + /** Returns true when validation succeeds. */ + public isValid(): boolean { + return isValidEntity(this); + } + + /** Returns the minimum CKB match amount. */ + public getCkbMinMatch(): ccc.FixedPoint { + return 1n << BigInt(this.ckbMinMatchLog); + } + + /** Returns true when CKB-to-UDT matching is enabled. */ + public isCkb2Udt(): boolean { + return this.ckbToUdt.isPopulated(); + } + + /** Returns true when UDT-to-CKB matching is enabled. */ + public isUdt2Ckb(): boolean { + return this.udtToCkb.isPopulated(); + } + + /** Returns true when both directions are enabled. */ + public isDualRatio(): boolean { + return this.isCkb2Udt() && this.isUdt2Ckb(); + } + + /** Compares the CKB-to-UDT side against another order info. */ + public ckb2UdtCompare(other: Info): number { + return this.ckbToUdt.compare(other.ckbToUdt); + } + + /** Compares the UDT-to-CKB side against another order info. */ + public udt2CkbCompare(other: Info): number { + return other.udtToCkb.compare(this.udtToCkb); + } + + /** Returns the default minimum CKB match exponent for newly created orders. */ + public static ckbMinMatchLogDefault(): number { + return 33; + } +} diff --git a/packages/order/src/model/master.ts b/packages/order/src/model/master.ts new file mode 100644 index 0000000..4a136d0 --- /dev/null +++ b/packages/order/src/model/master.ts @@ -0,0 +1,44 @@ +import { ccc, mol } from "@ckb-ccc/core"; +import { Relative, type RelativeLike } from "./relative.ts"; + +const MasterCodec = mol.union({ + relative: Relative, + absolute: ccc.OutPoint, +}); + +/** + * Master pointer before normalization. + * + * @public + */ +export type MasterLike = + | { type: "relative"; value: RelativeLike } + | { type: "absolute"; value: ccc.OutPointLike }; + +/** + * Master pointer stored in order data. + * + * @public + */ +export type Master = + { type: "relative"; value: Relative } | { type: "absolute"; value: ccc.OutPoint }; + +/** Normalizes a master pointer into entity values. */ +export function masterFrom(master: MasterLike): Master { + const { type, value } = master; + return type === "relative" + ? { type, value: Relative.from(value) } + : { type, value: ccc.OutPoint.from(value) }; +} + +/** Validates a normalized master pointer. */ +export function masterValidate(master: Master): void { + const { type, value } = master; + if (type === "relative") { + value.validate(); + } else if (!/^0x[0-9a-f]{64}$/i.test(value.txHash) || value.index < 0) { + throw new Error("OutPoint invalid"); + } +} + +export { MasterCodec }; diff --git a/packages/order/src/model/order_data.ts b/packages/order/src/model/order_data.ts new file mode 100644 index 0000000..0bfb2b2 --- /dev/null +++ b/packages/order/src/model/order_data.ts @@ -0,0 +1,100 @@ +import { ccc, mol } from "@ckb-ccc/core"; +import { isValidEntity } from "./entity_validity.ts"; +import { Info, type InfoLike } from "./info.ts"; +import { + MasterCodec, + masterFrom, + masterValidate, + type Master, + type MasterLike, +} from "./master.ts"; + +/** + * Wire shape for order cell data. + * + * @public + */ +export interface OrderDataLike { + /** UDT amount held by the order cell. */ + udtValue: ccc.FixedPointLike; + /** Relative or absolute pointer to the master cell. */ + master: MasterLike; + /** Price and minimum-match metadata. */ + info: InfoLike; +} + +const OrderDataCodec = mol.struct({ + udtValue: mol.Uint128, + master: MasterCodec, + info: Info, +}); + +/** + * CCC entity base for serializing and decoding order cell payloads. + * + * @public + */ +export const OrderBase = ccc.Entity.Base(); + +/** + * Serialized order cell payload. + * + * @public + */ +export class OrderData extends OrderBase { + static { + ccc.codec(OrderDataCodec)(this); + } + + /** UDT amount held by the order cell. */ + public udtValue: ccc.FixedPoint; + /** Master-cell pointer. */ + public master: Master; + /** Price and minimum-match metadata. */ + public info: Info; + + /** Creates normalized order data. */ + constructor(udtValue: ccc.FixedPoint, master: Master, info: Info) { + super(); + this.udtValue = udtValue; + this.master = master; + this.info = info; + } + + /** Normalizes an `OrderDataLike` wire object or existing entity into `OrderData`. */ + public static override from(data: OrderDataLike): OrderData { + if (data instanceof OrderData) { + return data; + } + + const { udtValue, master, info } = data; + return new OrderData(ccc.numFrom(udtValue), masterFrom(master), Info.from(info)); + } + + /** Throws when the order payload is not internally valid. */ + public validate(): void { + if (this.udtValue < 0) { + throw new Error("udtValue invalid, negative"); + } + masterValidate(this.master); + this.info.validate(); + } + + /** Returns true when validation succeeds. */ + public isValid(): boolean { + return isValidEntity(this); + } + + /** Returns true when the master pointer is relative to the current output. */ + public isMint(): boolean { + return this.master.type === "relative"; + } + + /** Resolves the master out point relative to the current order out point. */ + public getMaster(current: ccc.OutPoint): ccc.OutPoint { + const { type, value } = this.master; + return type === "relative" + ? new ccc.OutPoint(current.txHash, current.index + value.distance) + : value; + } +} diff --git a/packages/order/src/model/ratio.ts b/packages/order/src/model/ratio.ts new file mode 100644 index 0000000..59bb88e --- /dev/null +++ b/packages/order/src/model/ratio.ts @@ -0,0 +1,169 @@ +import { ccc, mol } from "@ckb-ccc/core"; +import { compareBigInt, type ExchangeRatio } from "@ickb/utils"; +import { isValidEntity } from "./entity_validity.ts"; + +const maxUint64 = (1n << 64n) - 1n; + +const RatioCodec = mol.struct({ + ckbScale: mol.Uint64, + udtScale: mol.Uint64, +}); + +/** + * CCC entity base for serializing and decoding exchange ratio values. + * + * @public + */ +export const RatioBase = ccc.Entity.Base(); + +/** + * Serialized exchange ratio used by order info. + * + * @remarks + * A ratio is either empty (`0, 0`) or populated with both scales greater than + * zero. Mixed empty/populated values are invalid. + * + * @public + */ +export class Ratio extends RatioBase { + static { + ccc.codec(RatioCodec)(this); + } + + /** CKB-side scale. */ + public ckbScale: ccc.Num; + /** UDT-side scale. */ + public udtScale: ccc.Num; + + /** Creates a ratio from raw scales. */ + constructor(ckbScale: ccc.Num, udtScale: ccc.Num) { + super(); + this.ckbScale = ckbScale; + this.udtScale = udtScale; + } + + /** Normalizes an exchange ratio wire object or existing entity into `Ratio`. */ + public static override from(ratio: ExchangeRatio): Ratio { + if (ratio instanceof Ratio) { + return ratio; + } + + const { ckbScale, udtScale } = ratio; + return new Ratio(ckbScale, udtScale); + } + + /** Throws when the ratio is neither empty nor fully populated. */ + public validate(): void { + if (!this.isEmpty() && !this.isPopulated()) { + throw new Error("Ratio invalid: not empty, not populated"); + } + } + + /** Returns true when validation succeeds. */ + public isValid(): boolean { + return isValidEntity(this); + } + + /** Returns true for the sentinel empty ratio. */ + public isEmpty(): boolean { + return this.ckbScale === 0n && this.udtScale === 0n; + } + + /** Returns true when both scales are positive. */ + public isPopulated(): boolean { + return this.ckbScale > 0n && this.udtScale > 0n; + } + + /** Creates the sentinel empty ratio. */ + public static empty(): Ratio { + return new Ratio(0n, 0n); + } + + /** Compares two populated ratios by effective CKB-to-UDT price. */ + public compare(other: Ratio): number { + if (this.udtScale === other.udtScale) { + return compareBigInt(this.ckbScale, other.ckbScale); + } + + if (this.ckbScale === other.ckbScale) { + return compareBigInt(other.udtScale, this.udtScale); + } + + return compareBigInt(this.ckbScale * other.udtScale, other.ckbScale * this.udtScale); + } + + /** Returns a fee-adjusted ratio for one conversion direction. */ + public applyFee(isCkb2Udt: boolean, fee: ccc.Num, feeBase: ccc.Num): Ratio { + if (fee >= feeBase) { + throw new Error("Fee too big relative to feeBase"); + } + if (fee === 0n) { + return this; + } + const { aScale, bScale } = this.feeAdjustedScales(isCkb2Udt, fee, feeBase); + + if (aScale > maxUint64 || bScale > maxUint64) { + throw new Error("Ratio scale exceeds Uint64"); + } + + return Ratio.from({ + ckbScale: isCkb2Udt ? aScale : bScale, + udtScale: isCkb2Udt ? bScale : aScale, + }); + } + + /** Computes reduced direction-specific scales after applying a fee. */ + public feeAdjustedScales( + isCkb2Udt: boolean, + fee: ccc.Num, + feeBase: ccc.Num, + ): { aScale: ccc.Num; bScale: ccc.Num } { + if (fee < 0n) { + throw new Error("Fee cannot be negative"); + } + if (feeBase <= 0n) { + throw new Error("Fee base must be positive"); + } + if (fee >= feeBase) { + throw new Error("Fee too big relative to feeBase"); + } + if (!this.isPopulated()) { + throw new Error("Invalid ExchangeRatio"); + } + + let { ckbScale: aScale, udtScale: bScale } = this; + if (!isCkb2Udt) { + [aScale, bScale] = [bScale, aScale]; + } + + aScale *= feeBase - fee; + bScale *= feeBase; + const divisor = ccc.gcd(aScale, bScale); + return { + aScale: aScale / divisor, + bScale: bScale / divisor, + }; + } + + /** Converts an amount in the requested direction with optional ceiling. */ + public convert( + isCkb2Udt: boolean, + amount: ccc.FixedPoint, + mustCeil: boolean, + ): ccc.FixedPoint { + if (!this.isPopulated()) { + throw new Error("Invalid midpoint ExchangeRatio"); + } + + if (amount === 0n) { + return 0n; + } + + let { ckbScale: aScale, udtScale: bScale } = this; + if (!isCkb2Udt) { + [aScale, bScale] = [bScale, aScale]; + } + + return (amount * aScale + (mustCeil ? bScale - 1n : 0n)) / bScale; + } +} diff --git a/packages/order/src/model/relative.ts b/packages/order/src/model/relative.ts new file mode 100644 index 0000000..a6e0a0a --- /dev/null +++ b/packages/order/src/model/relative.ts @@ -0,0 +1,82 @@ +import { ccc, mol } from "@ckb-ccc/core"; +import { CheckedInt32LE } from "@ickb/utils"; +import { isValidEntity } from "./entity_validity.ts"; + +/** + * Wire shape for a relative master pointer. + * + * @public + */ +export interface RelativeLike { + /** Must be the 32-byte zero padding used by the standard encoding. */ + padding: ccc.BytesLike; + /** Signed output-index distance from the current order to its master. */ + distance: ccc.NumLike; +} + +const RelativeCodec = mol.struct({ + padding: mol.Byte32, + distance: CheckedInt32LE, +}); + +/** + * CCC entity base for serializing and decoding relative master pointers. + * + * @public + */ +export const RelativeBase = ccc.Entity.Base(); + +/** + * Relative pointer from an order output to its master output. + * + * @public + */ +export class Relative extends RelativeBase { + static { + ccc.codec(RelativeCodec)(this); + } + + /** Standard zero padding. */ + public padding: ccc.Bytes; + /** Signed output-index distance to the master output. */ + public distance: ccc.Num; + + /** Creates a normalized relative pointer. */ + constructor(padding: ccc.Bytes, distance: ccc.Num) { + super(); + this.padding = padding; + this.distance = distance; + } + + /** Normalizes a relative pointer wire object or existing entity into `Relative`. */ + public static override from(relative: RelativeLike): Relative { + if (relative instanceof Relative) { + return relative; + } + + const { padding, distance } = relative; + return new Relative(ccc.bytesFrom(padding), ccc.numFrom(distance)); + } + + /** Creates a relative pointer with standard zero padding. */ + public static create(distance: ccc.Num): Relative { + return new Relative(Relative.padding(), distance); + } + + /** Returns the standard 32-byte zero padding. */ + public static padding(): ccc.Bytes { + return new Uint8Array(32); + } + + /** Throws when padding is not the standard 32-byte zero value. */ + public validate(): void { + if (this.padding.length !== 32 || this.padding.some((x) => x !== 0)) { + throw new Error("Relative master invalid, non standard padding"); + } + } + + /** Returns true when validation succeeds. */ + public isValid(): boolean { + return isValidEntity(this); + } +} diff --git a/packages/order/src/order.test.ts b/packages/order/src/order.test.ts deleted file mode 100644 index b02c9f3..0000000 --- a/packages/order/src/order.test.ts +++ /dev/null @@ -1,1897 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, script } from "@ickb/testkit"; -import { defaultCellPageSize } from "@ickb/utils"; -import { describe, expect, it } from "vitest"; -import { OrderCell } from "./cells.js"; -import { Info, OrderData, Ratio, Relative } from "./entities.js"; -import { OrderManager, OrderMatcher, type Match, type OrderGroupSkipReason } from "./order.js"; - -describe("Ratio", () => { - it("compares ratios exactly beyond Number precision", () => { - const scale = 2n ** 60n; - const larger = Ratio.from({ ckbScale: scale + 1n, udtScale: scale }); - const smaller = Ratio.from({ ckbScale: scale, udtScale: scale }); - - expect(Number((scale + 1n) * scale - scale * scale)).toBe( - Number(scale), - ); - expect(larger.compare(smaller)).toBe(1); - expect(smaller.compare(larger)).toBe(-1); - }); - - it("rejects nonzero fee application to empty ratios", () => { - expect(() => Ratio.empty().applyFee(true, 1n, 2n)).toThrow( - "Invalid ExchangeRatio", - ); - }); - - it("rejects fee-adjusted ratios that do not fit Uint64 exactly", () => { - expect(() => - Ratio.from({ ckbScale: (2n ** 64n) + 1n, udtScale: 1n }).applyFee(true, 1n, 2n) - ).toThrow("Ratio scale exceeds Uint64"); - expect(() => - Ratio.from({ ckbScale: 2n ** 65n, udtScale: 1n }).applyFee(true, 1n, 2n) - ).toThrow("Ratio scale exceeds Uint64"); - }); - - it("keeps exactly representable fee-adjusted ratios", () => { - expect( - Ratio.from({ ckbScale: (1n << 64n) - 1n, udtScale: 1n }) - .applyFee(true, 1n, 2n), - ).toEqual(Ratio.from({ ckbScale: (1n << 64n) - 1n, udtScale: 2n })); - }); -}); - -describe("OrderMatcher", () => { - it("sorts effective ratios exactly beyond Number precision", () => { - const order = makeUdtToCkbOrder(); - const scale = 2n ** 60n; - const better = new OrderMatcher( - order, - true, - 1n, - 1n, - 0n, - 0n, - 0n, - 0n, - 0n, - 0n, - scale + 1n, - scale, - ); - const worse = new OrderMatcher( - order, - true, - 1n, - 1n, - 0n, - 0n, - 0n, - 0n, - 0n, - 0n, - scale, - scale, - ); - - expect(Number(scale + 1n) / Number(scale)).toBe(1); - expect(OrderMatcher.compareRealRatioDesc(better, worse)).toBeLessThan(0); - expect(OrderMatcher.compareRealRatioDesc(worse, better)).toBeGreaterThan(0); - }); - - it("reports UDT-to-CKB fee in CKB units", () => { - const result = OrderManager.convert( - false, - Ratio.from({ ckbScale: 2n, udtScale: 1n }), - { ckbValue: 0n, udtValue: 100n }, - { fee: 1n, feeBase: 10n }, - ); - - expect(result.convertedAmount).toBe(45n); - expect(result.ckbFee).toBe(5n); - }); - - it("uses udtToCkb scales for UDT-to-CKB orders", () => { - const order = makeUdtToCkbOrder(); - - const matcher = OrderMatcher.from(order, false, 0n); - - expect(matcher).toBeDefined(); - expect(OrderMatcher.from(order, true, 0n)).toBeUndefined(); - expect(matcher?.aScale).toBe(2n); - expect(matcher?.bScale).toBe(5n); - expect(matcher?.bMaxMatch).toBeGreaterThan(0n); - }); - - it("lets bestMatch consume UDT-to-CKB orders", () => { - const order = makeUdtToCkbOrder(); - - const match = OrderManager.bestMatch( - [order], - { - ckbValue: ccc.fixedPointFrom(200), - udtValue: 0n, - }, - { - ckbScale: 3n, - udtScale: 5n, - }, - { - feeRate: 0n, - ckbAllowanceStep: ccc.fixedPointFrom(1), - }, - ); - - expect(match.partials).toHaveLength(1); - expect(match.ckbDelta).toBeLessThan(0n); - expect(match.udtDelta).toBeGreaterThan(0n); - expect(match.diagnostics).toMatchObject({ - orderCount: 1, - directions: { - ckbToUdt: { matchableCount: 0 }, - udtToCkb: { matchableCount: 1 }, - }, - candidates: { - rejected: { nonPositiveGain: 0 }, - }, - }); - expect(match.diagnostics?.candidates.positiveGain).toBeGreaterThan(0); - }); - - it("respects a partial cap when selecting the best match", () => { - const orders = [ - makeUdtToCkbOrder({ - txHashByte: "10", - orderTxHashByte: "20", - }), - makeUdtToCkbOrder({ - txHashByte: "11", - orderTxHashByte: "21", - }), - ]; - - const uncapped = OrderManager.bestMatch( - orders, - { - ckbValue: ccc.fixedPointFrom(1000), - udtValue: 0n, - }, - { - ckbScale: 3n, - udtScale: 5n, - }, - { - feeRate: 0n, - ckbAllowanceStep: ccc.fixedPointFrom(1), - }, - ); - const capped = OrderManager.bestMatch( - orders, - { - ckbValue: ccc.fixedPointFrom(1000), - udtValue: 0n, - }, - { - ckbScale: 3n, - udtScale: 5n, - }, - { - feeRate: 0n, - ckbAllowanceStep: ccc.fixedPointFrom(1), - maxPartials: 1, - }, - ); - - expect(uncapped.partials).toHaveLength(2); - expect(capped.partials).toHaveLength(1); - expect(capped.ckbDelta).toBeLessThan(0n); - expect(capped.udtDelta).toBeGreaterThan(0n); - }); - - it("charges one mining fee unit per selected partial", () => { - const order = makeUdtToCkbOrder(); - - const match = OrderManager.bestMatch( - [order], - { - ckbValue: ccc.fixedPointFrom(60), - udtValue: 0n, - }, - { - ckbScale: 3n, - udtScale: 5n, - }, - { - feeRate: 1000n, - ckbAllowanceStep: ccc.fixedPointFrom(1), - }, - ); - - expect(match.partials).toHaveLength(1); - expect(match.ckbDelta).toBe(-ccc.fixedPointFrom(40)); - }); - - it("ignores matches whose estimated mining fee exceeds the value gain", () => { - const order = makeUdtToCkbOrder(); - - const match = OrderManager.bestMatch( - [order], - { - ckbValue: ccc.fixedPointFrom(1000), - udtValue: 0n, - }, - { - ckbScale: 3n, - udtScale: 5n, - }, - { - feeRate: ccc.fixedPointFrom(1000), - ckbAllowanceStep: ccc.fixedPointFrom(1), - }, - ); - - expect(match).toMatchObject({ - ckbDelta: 0n, - udtDelta: 0n, - partials: [], - diagnostics: { - orderCount: 1, - directions: { - ckbToUdt: { matchableCount: 0 }, - udtToCkb: { matchableCount: 1 }, - }, - candidates: { - bestGain: 0n, - positiveGain: 0, - }, - }, - }); - expect(match.diagnostics?.candidates.rejected.nonPositiveGain).toBeGreaterThan(0); - }); - - it("does not select non-positive candidates after rejecting the empty allowance", () => { - const order = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(200), - udtValue: 0n, - info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), - master: { - type: "absolute", - value: { - txHash: byte32FromByte("33"), - index: 1n, - }, - }, - outPoint: { - txHash: byte32FromByte("55"), - index: 0n, - }, - }); - - const match = OrderManager.bestMatch( - [order], - { - ckbValue: -1n, - udtValue: ccc.fixedPointFrom(1000), - }, - { - ckbScale: 1n, - udtScale: 1n, - }, - { - feeRate: 0n, - ckbAllowanceStep: ccc.fixedPointFrom(1), - }, - ); - - expect(match).toMatchObject({ - ckbDelta: 0n, - udtDelta: 0n, - partials: [], - diagnostics: { - candidates: { - bestGain: 0n, - positiveGain: 0, - }, - }, - }); - expect(match.diagnostics?.candidates.rejected.insufficientCkbAllowance).toBeGreaterThan(0); - expect(match.diagnostics?.candidates.rejected.nonPositiveGain).toBeGreaterThan(0); - }); - - it("reports one primary allowance rejection reason per candidate", () => { - const order = makeUdtToCkbOrder(); - - const match = OrderManager.bestMatch( - [order], - { - ckbValue: -ccc.fixedPointFrom(1000), - udtValue: -ccc.fixedPointFrom(1000), - }, - { - ckbScale: 3n, - udtScale: 5n, - }, - { - feeRate: 0n, - ckbAllowanceStep: ccc.fixedPointFrom(1), - }, - ); - - const rejected = match.diagnostics?.candidates.rejected; - expect(rejected?.insufficientCkbAllowance).toBeGreaterThan(0); - expect(rejected?.insufficientUdtAllowance).toBe(0); - }); - - it("does not use the same order cell in both match directions", () => { - const order = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: ccc.fixedPointFrom(50), - info: dualInfo(), - master: { - type: "absolute", - value: { - txHash: byte32FromByte("33"), - index: 1n, - }, - }, - outPoint: { - txHash: byte32FromByte("44"), - index: 0n, - }, - }); - - const match = OrderManager.bestMatch( - [order], - { - ckbValue: ccc.fixedPointFrom(50), - udtValue: ccc.fixedPointFrom(50), - }, - { - ckbScale: 2n, - udtScale: 1n, - }, - { - feeRate: 0n, - ckbAllowanceStep: ccc.fixedPointFrom(1), - }, - ); - - expect(match.partials.map((partial) => partial.order.cell.outPoint.toHex())).toEqual([ - order.cell.outPoint.toHex(), - ]); - }); - - it("rejects invalid best-match search parameters", () => { - const order = makeUdtToCkbOrder(); - const allowance = { - ckbValue: ccc.fixedPointFrom(50), - udtValue: ccc.fixedPointFrom(50), - }; - - expect(() => - OrderManager.bestMatch( - [order], - allowance, - { ckbScale: 0n, udtScale: 1n }, - { ckbAllowanceStep: ccc.fixedPointFrom(1) }, - ) - ).toThrow("Exchange rate scales must be positive"); - expect(() => - OrderManager.bestMatch( - [order], - allowance, - { ckbScale: 1n, udtScale: 0n }, - { ckbAllowanceStep: ccc.fixedPointFrom(1) }, - ) - ).toThrow("Exchange rate scales must be positive"); - expect(() => - OrderManager.bestMatch( - [order], - allowance, - { ckbScale: 1n, udtScale: 1n }, - { ckbAllowanceStep: 0n }, - ) - ).toThrow("CKB allowance step must be positive"); - }); - - it("uses the largest order size when estimating per-partial mining fees", () => { - const smallOrder = makeUdtToCkbOrder({ - txHashByte: "40", - orderTxHashByte: "50", - }); - const largeOrder = makeUdtToCkbOrder({ - txHashByte: "41", - orderTxHashByte: "51", - lockArgs: `0x${"00".repeat(100)}`, - }); - const allowance = { - ckbValue: ccc.fixedPointFrom(1000), - udtValue: 0n, - }; - const exchangeRate = { ckbScale: 3n, udtScale: 5n }; - const options = { - feeRate: 1000n, - ckbAllowanceStep: ccc.fixedPointFrom(1), - }; - - expect(largeOrder.cell.occupiedSize).toBeGreaterThan(smallOrder.cell.occupiedSize); - - expect(matchKey(OrderManager.bestMatch( - [smallOrder, largeOrder], - allowance, - exchangeRate, - options, - ))).toEqual(matchKey(exhaustiveSequentialBestMatch( - [smallOrder, largeOrder], - allowance, - exchangeRate, - options, - ))); - }); - - it("rejects UDT-to-CKB partials below the converted CKB minimum", () => { - const order = makeUdtToCkbOrder(); - const matcher = OrderMatcher.from(order, false, 0n); - - const belowMinimum = matcher?.match(1n); - const atMinimum = matcher?.match(3n); - - expect(belowMinimum?.partials).toHaveLength(0); - expect(atMinimum?.partials).toHaveLength(1); - expect(atMinimum?.partials[0]?.ckbOut).toBe(ccc.fixedPointFrom(200) + 3n); - expect(atMinimum?.partials[0]?.udtOut).toBe(ccc.fixedPointFrom(100) - 7n); - }); - - it("allows full consumption when the remaining CKB match is below the default minimum", () => { - const order = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(50), - udtValue: ccc.fixedPointFrom(50), - info: Info.create(false, { ckbScale: 1n, udtScale: 1n }), - master: { - type: "absolute", - value: { - txHash: byte32FromByte("33"), - index: 1n, - }, - }, - outPoint: { - txHash: byte32FromByte("45"), - index: 0n, - }, - }); - const matcher = OrderMatcher.from(order, false, 0n); - - expect(matcher).toBeDefined(); - if (!matcher) { - throw new Error("Expected order to be matchable"); - } - expect(matcher.bMaxMatch).toBeLessThan(1n << 33n); - expect(matcher.bMinMatch).toBe(matcher.bMaxMatch); - - const match = matcher.match(matcher.bMaxMatch); - - expect(match.partials).toHaveLength(1); - expect(match.partials[0]?.ckbOut).toBe(matcher.bMaxOut); - }); - - it("continues trying larger allowances after an allowance below the minimum", () => { - const order = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(200), - udtValue: 0n, - info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), - master: { - type: "absolute", - value: { - txHash: byte32FromByte("33"), - index: 1n, - }, - }, - outPoint: { - txHash: byte32FromByte("46"), - index: 0n, - }, - }); - const matcher = OrderMatcher.from(order, true, 0n); - - expect(matcher).toBeDefined(); - if (!matcher) { - throw new Error("Expected order to be matchable"); - } - expect(ccc.fixedPointFrom(50)).toBeLessThan(matcher.bMinMatch); - - const matches = Array.from( - OrderManager.sequentialMatcher( - [order], - true, - ccc.fixedPointFrom(50), - 0n, - ), - ); - - expect(matches.some((match) => match.partials.length === 1)).toBe(true); - }); - - it("matches an exhaustive cross-product on a bounded pool", () => { - const orders = [ - makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(90), - udtValue: ccc.fixedPointFrom(40), - info: dualInfo(), - master: { - type: "absolute", - value: { txHash: byte32FromByte("33"), index: 1n }, - }, - outPoint: { txHash: byte32FromByte("47"), index: 0n }, - }), - makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(60), - udtValue: ccc.fixedPointFrom(80), - info: dualInfo(), - master: { - type: "absolute", - value: { txHash: byte32FromByte("34"), index: 1n }, - }, - outPoint: { txHash: byte32FromByte("48"), index: 0n }, - }), - makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(30), - udtValue: ccc.fixedPointFrom(120), - info: dualInfo(), - master: { - type: "absolute", - value: { txHash: byte32FromByte("35"), index: 1n }, - }, - outPoint: { txHash: byte32FromByte("49"), index: 0n }, - }), - ]; - const allowance = { - ckbValue: ccc.fixedPointFrom(160), - udtValue: ccc.fixedPointFrom(120), - }; - const exchangeRate = { ckbScale: 1n, udtScale: 1n }; - const options = { - feeRate: 0n, - ckbAllowanceStep: ccc.fixedPointFrom(50), - maxPartials: 3, - }; - - expect(matchKey(OrderManager.bestMatch(orders, allowance, exchangeRate, options))) - .toEqual(matchKey(exhaustiveSequentialBestMatch( - orders, - allowance, - exchangeRate, - options, - ))); - }); -}); - -describe("OrderManager.addMatch", () => { - it("rejects duplicate partials for the same order cell", () => { - const manager = new OrderManager(script("11"), [], script("22")); - const order = makeUdtToCkbOrder(); - const partial = { order, ckbOut: order.ckbValue, udtOut: order.udtValue }; - - expect(() => - manager.addMatch(ccc.Transaction.default(), { - ckbDelta: 0n, - udtDelta: 0n, - partials: [partial, partial], - }) - ).toThrow("Match contains duplicate order cells"); - }); -}); - -describe("OrderManager.mint", () => { - it("creates an order output with the requested CKB value plus occupied capacity", () => { - const lock = script("11"); - const udt = script("22"); - const manager = new OrderManager(script("33"), [], udt); - - const tx = manager.mint( - ccc.Transaction.default(), - lock, - dualInfo(), - { ckbValue: ccc.fixedPointFrom(123), udtValue: ccc.fixedPointFrom(456) }, - ); - - expect(tx.outputs).toHaveLength(2); - const output = tx.getOutput(0); - if (output === undefined) { - throw new Error("Expected order output"); - } - expect(OrderCell.mustFrom(ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("ef"), index: 0n }, - cellOutput: output.cellOutput, - outputData: output.outputData, - })).ckbUnoccupied).toBe(ccc.fixedPointFrom(123)); - expect(tx.outputs[0]?.capacity).toBeGreaterThan(ccc.fixedPointFrom(123)); - expect(tx.outputs[0]?.lock.eq(manager.script)).toBe(true); - expect(tx.outputs[0]?.type?.eq(udt)).toBe(true); - expect(tx.outputs[1]?.lock.eq(lock)).toBe(true); - expect(tx.outputs[1]?.type?.eq(manager.script)).toBe(true); - }); -}); - -describe("OrderCell.resolve", () => { - it("prefers directional progress over a higher-value unprogressed candidate", () => { - const master = { - txHash: byte32FromByte("55"), - index: 10n, - }; - const info = directionalInfo(); - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - }); - const progressed = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(50), - udtValue: ccc.fixedPointFrom(50), - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("66"), index: 0n }, - }); - const forged = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(200), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("77"), index: 0n }, - }); - - expect(progressed.absProgress).toBeGreaterThan(forged.absProgress); - expect(forged.absTotal).toBeGreaterThan(progressed.absTotal); - expect(origin.resolve([forged, progressed])).toBe(progressed); - }); - - it("uses best value for dual-sided orders via absProgress === absTotal", () => { - const master = { - txHash: byte32FromByte("88"), - index: 10n, - }; - const info = dualInfo(); - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - }); - const lowerValue = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("99"), index: 0n }, - }); - const higherValue = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(60), - udtValue: ccc.fixedPointFrom(60), - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("aa"), index: 0n }, - }); - - expect(lowerValue.absProgress).toBe(lowerValue.absTotal); - expect(higherValue.absProgress).toBe(higherValue.absTotal); - expect(higherValue.absTotal).toBeGreaterThan(lowerValue.absTotal); - expect(origin.resolve([lowerValue, higherValue])).toBe(higherValue); - }); - - it("fails closed for ambiguous equal-progress non-mint candidates", () => { - const master = { - txHash: byte32FromByte("bb"), - index: 10n, - }; - const info = directionalInfo(); - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - }); - const nonMint = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("cc"), index: 0n }, - }); - const otherNonMint = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { - type: "absolute", - value: { - txHash: master.txHash, - index: master.index, - }, - }, - outPoint: { txHash: byte32FromByte("dd"), index: 0n }, - }); - - expect(origin.resolve([nonMint, otherNonMint])).toBeUndefined(); - expect(origin.resolve([otherNonMint, nonMint])).toBeUndefined(); - }); - - it("prefers a mint candidate over an equal-progress non-mint candidate", () => { - const master = { - txHash: byte32FromByte("bc"), - index: 10n, - }; - const info = directionalInfo(); - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - }); - const nonMint = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("ce"), index: 0n }, - }); - const mint = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { - type: "relative", - value: Relative.create(1n), - }, - outPoint: { txHash: master.txHash, index: 9n }, - }); - - expect(mint.getMaster().eq(origin.getMaster())).toBe(true); - expect(origin.resolve([nonMint, mint])).toBe(mint); - expect(origin.resolve([mint, nonMint])).toBe(mint); - }); - - it("does not treat duplicate same-outpoint candidates as ambiguous", () => { - const master = { - txHash: byte32FromByte("bd"), - index: 10n, - }; - const info = directionalInfo(); - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("44"), index: 0n }, - }); - const duplicate = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: master }, - outPoint: { txHash: byte32FromByte("cf"), index: 0n }, - }); - - expect(origin.resolve([duplicate, duplicate])).toBe(duplicate); - }); -}); - -describe("OrderManager.findOrders", () => { - it("passes the default page size to order scanning", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - let requestedPageSize = 0; - const client = { - findCellsOnChain: async function* (query: { scriptType: string }, _order: unknown, pageSize: number) { - await Promise.resolve(); - if (query.scriptType !== "lock") { - return; - } - requestedPageSize = pageSize; - yield* [] as ccc.Cell[]; - }, - } as unknown as ccc.Client; - - await expect(collectOrders(manager, client)).resolves.toEqual([]); - expect(requestedPageSize).toBe(defaultCellPageSize); - }); - - it("passes the default page size to master scanning", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - let requestedPageSize = 0; - const client = { - findCellsOnChain: async function* (query: { scriptType: string }, _order: unknown, pageSize: number) { - await Promise.resolve(); - if (query.scriptType !== "type") { - return; - } - requestedPageSize = pageSize; - yield* [] as ccc.Cell[]; - }, - } as unknown as ccc.Client; - - await expect(collectOrders(manager, client)).resolves.toEqual([]); - expect(requestedPageSize).toBe(defaultCellPageSize); - }); - - it("accepts exact page-size order and master scans", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - const master = ccc.OutPoint.from({ txHash: byte32FromByte("36"), index: 1n }); - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "relative", - value: Relative.create(1n), - }, - lock: orderScript, - outPoint: { txHash: master.txHash, index: 0n }, - }); - const order = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { type: "absolute", value: master }, - lock: orderScript, - outPoint: { txHash: byte32FromByte("37"), index: 0n }, - }); - const masterCell = ccc.Cell.from({ - outPoint: master, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - const tx = ccc.Transaction.default(); - tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); - tx.outputsData.push(origin.cell.outputData, masterCell.outputData); - const client = { - cache: new ccc.ClientCacheMemory(), - findCellsOnChain: async function* (query: { scriptType: string }) { - await Promise.resolve(); - if (query.scriptType === "lock") { - for (let index = 0; index < defaultCellPageSize; index += 1) { - yield index === 0 ? order.cell : ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("38"), index: BigInt(index) }, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: orderScript, - type: udtScript, - }, - outputData: "0x", - }); - } - return; - } - - for (let index = 0; index < defaultCellPageSize; index += 1) { - yield index === 0 ? masterCell : ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("39"), index: BigInt(index) }, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - } - }, - getTransaction: async (txHash: ccc.Hex) => { - await Promise.resolve(); - return txHash === master.txHash - ? ccc.ClientTransactionResponse.from({ - transaction: tx, - status: "committed", - }) - : undefined; - }, - } as unknown as ccc.Client; - - const groups = []; - for await (const group of manager.findOrders(client)) { - groups.push(group); - } - - expect(groups).toHaveLength(1); - }); - - it("findOrigin skips parseable non-mint origins in the master transaction", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - const originMaster = { txHash: byte32FromByte("55"), index: 2n }; - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); - const forgedOrigin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(200), - udtValue: 0n, - info: directionalInfo(), - master: { type: "absolute", value: originMaster }, - lock: orderScript, - outPoint: { txHash: originMaster.txHash, index: 1n }, - }); - const trueOrigin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "relative", - value: Relative.create(2n), - }, - outPoint: { txHash: originMaster.txHash, index: 0n }, - }); - const liveOrder = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { type: "absolute", value: originMaster }, - lock: orderScript, - outPoint: { txHash: byte32FromByte("56"), index: 0n }, - }); - const masterCell = ccc.Cell.from({ - outPoint: originMaster, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - const tx = ccc.Transaction.default(); - tx.outputs.push(trueOrigin.cell.cellOutput, forgedOrigin.cell.cellOutput, masterCell.cellOutput); - tx.outputsData.push(trueOrigin.cell.outputData, forgedOrigin.cell.outputData, masterCell.outputData); - const client = { - cache: new ccc.ClientCacheMemory(), - findCellsOnChain: async function* (query: { scriptType: string }) { - await Promise.resolve(); - if (query.scriptType === "lock") { - yield liveOrder.cell; - } else { - yield masterCell; - } - }, - getTransaction: async (txHash: ccc.Hex) => { - await Promise.resolve(); - return txHash === originMaster.txHash - ? ccc.ClientTransactionResponse.from({ - transaction: tx, - status: "committed", - }) - : undefined; - }, - } as unknown as ccc.Client; - - expect(trueOrigin.data.master.type).toBe("relative"); - if (trueOrigin.data.master.type !== "relative") { - throw new Error("Expected relative master"); - } - expect(trueOrigin.data.master.value.distance).toBe(2n); - expect(trueOrigin.getMaster().eq(originMaster)).toBe(true); - const groups = []; - for await (const group of manager.findOrders(client)) { - groups.push(group); - } - - expect(groups).toHaveLength(1); - expect(groups[0]?.origin.cell.outPoint.eq(trueOrigin.cell.outPoint)).toBe(true); - }); - - it("round-trips non-zero relative master distances", () => { - const encoded = OrderData.from({ - udtValue: 0n, - master: { - type: "relative", - value: Relative.create(2n), - }, - info: directionalInfo(), - }).toBytes(); - - const decoded = OrderData.decode(encoded); - - expect(decoded.master.type).toBe("relative"); - if (decoded.master.type !== "relative") { - throw new Error("Expected relative master"); - } - expect(decoded.master.value.distance).toBe(2n); - }); - - it("findOrigin requires a minted origin in the master transaction", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - const originMaster = { txHash: byte32FromByte("65"), index: 1n }; - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); - const fakeOrigin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { type: "absolute", value: originMaster }, - outPoint: { txHash: originMaster.txHash, index: 0n }, - }); - const liveOrder = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { type: "absolute", value: originMaster }, - lock: orderScript, - outPoint: { txHash: byte32FromByte("67"), index: 0n }, - }); - const masterCell = ccc.Cell.from({ - outPoint: originMaster, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - const tx = ccc.Transaction.default(); - tx.outputs.push(fakeOrigin.cell.cellOutput, masterCell.cellOutput); - tx.outputsData.push(fakeOrigin.cell.outputData, masterCell.outputData); - const client = { - cache: new ccc.ClientCacheMemory(), - findCellsOnChain: async function* (query: { scriptType: string }) { - await Promise.resolve(); - if (query.scriptType === "lock") { - yield liveOrder.cell; - } else { - yield masterCell; - } - }, - getTransaction: async (txHash: ccc.Hex) => { - await Promise.resolve(); - return txHash === originMaster.txHash - ? ccc.ClientTransactionResponse.from({ - transaction: tx, - status: "committed", - }) - : undefined; - }, - } as unknown as ccc.Client; - - const skippedReasons: OrderGroupSkipReason[] = []; - const groups = []; - for await (const group of manager.findOrders(client, { - onSkippedGroup: (reason) => skippedReasons.push(reason), - })) { - groups.push(group); - } - - expect(groups).toHaveLength(0); - expect(skippedReasons).toEqual(["missing-origin"]); - }); - - it("findOrigin fails closed for multiple minted origins in the master transaction", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - const originMaster = { txHash: byte32FromByte("66"), index: 2n }; - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); - const firstOrigin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "relative", - value: Relative.create(2n), - }, - lock: orderScript, - outPoint: { txHash: originMaster.txHash, index: 0n }, - }); - const secondOrigin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "relative", - value: Relative.create(1n), - }, - lock: orderScript, - outPoint: { txHash: originMaster.txHash, index: 1n }, - }); - const liveOrder = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { type: "absolute", value: originMaster }, - lock: orderScript, - outPoint: { txHash: byte32FromByte("68"), index: 0n }, - }); - const masterCell = ccc.Cell.from({ - outPoint: originMaster, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - const tx = ccc.Transaction.default(); - tx.outputs.push(firstOrigin.cell.cellOutput, secondOrigin.cell.cellOutput, masterCell.cellOutput); - tx.outputsData.push(firstOrigin.cell.outputData, secondOrigin.cell.outputData, masterCell.outputData); - const client = { - cache: new ccc.ClientCacheMemory(), - findCellsOnChain: async function* (query: { scriptType: string }) { - await Promise.resolve(); - if (query.scriptType === "lock") { - yield liveOrder.cell; - } else { - yield masterCell; - } - }, - getTransaction: async (txHash: ccc.Hex) => { - await Promise.resolve(); - return txHash === originMaster.txHash - ? ccc.ClientTransactionResponse.from({ - transaction: tx, - status: "committed", - }) - : undefined; - }, - } as unknown as ccc.Client; - - expect(firstOrigin.getMaster().eq(originMaster)).toBe(true); - expect(secondOrigin.getMaster().eq(originMaster)).toBe(true); - const skippedReasons: OrderGroupSkipReason[] = []; - const groups = []; - for await (const group of manager.findOrders(client, { - onSkippedGroup: (reason) => skippedReasons.push(reason), - })) { - groups.push(group); - } - - expect(groups).toHaveLength(0); - expect(skippedReasons).toEqual(["ambiguous-origin"]); - }); - - it("uses live queries by default", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - const originMaster = { txHash: byte32FromByte("77"), index: 1n }; - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "relative", - value: Relative.create(1n), - }, - lock: orderScript, - outPoint: { txHash: originMaster.txHash, index: 0n }, - }); - const masterCell = ccc.Cell.from({ - outPoint: originMaster, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - let cachedCalls = 0; - let onChainCalls = 0; - const tx = ccc.Transaction.default(); - tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); - tx.outputsData.push(origin.cell.outputData, masterCell.outputData); - const client = { - cache: new ccc.ClientCacheMemory(), - findCells: async function* () { - await Promise.resolve(); - cachedCalls += 1; - yield* [] as ccc.Cell[]; - }, - findCellsOnChain: async function* (query: { scriptType: string }) { - await Promise.resolve(); - onChainCalls += 1; - if (query.scriptType === "lock") { - yield origin.cell; - } else { - yield masterCell; - } - }, - getTransaction: async (txHash: ccc.Hex) => { - await Promise.resolve(); - return txHash === originMaster.txHash - ? ccc.ClientTransactionResponse.from({ - transaction: tx, - status: "committed", - }) - : undefined; - }, - } as unknown as ccc.Client; - - const groups = []; - for await (const group of manager.findOrders(client)) { - groups.push(group); - } - - expect(groups).toHaveLength(1); - expect(cachedCalls).toBe(0); - expect(onChainCalls).toBe(2); - }); - - it("caches master transaction lookups within findOrders", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - const txHash = byte32FromByte("77"); - const firstMaster = ccc.OutPoint.from({ txHash, index: 1n }); - const secondMaster = ccc.OutPoint.from({ txHash, index: 3n }); - const firstOrigin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "relative", - value: Relative.create(1n), - }, - lock: orderScript, - outPoint: { txHash, index: 0n }, - }); - const secondOrigin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "relative", - value: Relative.create(1n), - }, - lock: orderScript, - outPoint: { txHash, index: 2n }, - }); - const firstMasterCell = ccc.Cell.from({ - outPoint: firstMaster, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - const secondMasterCell = ccc.Cell.from({ - outPoint: secondMaster, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - const tx = ccc.Transaction.default(); - tx.outputs.push( - firstOrigin.cell.cellOutput, - firstMasterCell.cellOutput, - secondOrigin.cell.cellOutput, - secondMasterCell.cellOutput, - ); - tx.outputsData.push( - firstOrigin.cell.outputData, - firstMasterCell.outputData, - secondOrigin.cell.outputData, - secondMasterCell.outputData, - ); - const actualTxHash = tx.hash(); - firstMaster.txHash = actualTxHash; - secondMaster.txHash = actualTxHash; - firstOrigin.cell.outPoint.txHash = actualTxHash; - secondOrigin.cell.outPoint.txHash = actualTxHash; - firstMasterCell.outPoint.txHash = actualTxHash; - secondMasterCell.outPoint.txHash = actualTxHash; - const cache = new ccc.ClientCacheMemory(); - let getTransactionCalls = 0; - const client = { - cache, - findCellsOnChain: async function* (query: { scriptType: string }) { - await Promise.resolve(); - if (query.scriptType === "lock") { - yield firstOrigin.cell; - yield secondOrigin.cell; - } else { - yield firstMasterCell; - yield secondMasterCell; - } - }, - getTransaction: async (queriedTxHash: ccc.Hex) => { - getTransactionCalls += 1; - await Promise.resolve(); - return queriedTxHash === actualTxHash - ? ccc.ClientTransactionResponse.from({ - transaction: tx, - status: "committed", - }) - : undefined; - }, - } as unknown as ccc.Client; - - const groups = []; - for await (const group of manager.findOrders(client)) { - groups.push(group); - } - - expect(groups).toHaveLength(2); - expect(groups[0]?.master.cell.outPoint.eq(firstMaster)).toBe(true); - expect(groups[1]?.master.cell.outPoint.eq(secondMaster)).toBe(true); - expect(getTransactionCalls).toBe(1); - }); - - it("uses cached queries when onChain is false", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - const originMaster = { txHash: byte32FromByte("77"), index: 1n }; - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "relative", - value: Relative.create(1n), - }, - lock: orderScript, - outPoint: { txHash: originMaster.txHash, index: 0n }, - }); - const masterCell = ccc.Cell.from({ - outPoint: originMaster, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - let cachedCalls = 0; - let onChainCalls = 0; - const tx = ccc.Transaction.default(); - tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); - tx.outputsData.push(origin.cell.outputData, masterCell.outputData); - const client = { - cache: new ccc.ClientCacheMemory(), - findCells: async function* (query: { scriptType: string }) { - await Promise.resolve(); - cachedCalls += 1; - if (query.scriptType === "lock") { - yield origin.cell; - } else { - yield masterCell; - } - }, - findCellsOnChain: async function* () { - await Promise.resolve(); - onChainCalls += 1; - yield* [] as ccc.Cell[]; - }, - getTransaction: async (txHash: ccc.Hex) => { - await Promise.resolve(); - return txHash === originMaster.txHash - ? ccc.ClientTransactionResponse.from({ - transaction: tx, - status: "committed", - }) - : undefined; - }, - } as unknown as ccc.Client; - - const groups = []; - for await (const group of manager.findOrders(client, { onChain: false })) { - groups.push(group); - } - - expect(groups).toHaveLength(1); - expect(cachedCalls).toBe(2); - expect(onChainCalls).toBe(0); - }); - - it("skips groups with ambiguous same-score descendants", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - const originMaster = { txHash: byte32FromByte("87"), index: 1n }; - const info = directionalInfo(); - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { - type: "relative", - value: Relative.create(1n), - }, - lock: orderScript, - outPoint: { txHash: originMaster.txHash, index: 0n }, - }); - const liveOrder = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: originMaster }, - lock: orderScript, - outPoint: { txHash: byte32FromByte("97"), index: 0n }, - }); - const forgedOrder = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info, - master: { type: "absolute", value: originMaster }, - lock: orderScript, - outPoint: { txHash: byte32FromByte("98"), index: 0n }, - }); - const masterCell = ccc.Cell.from({ - outPoint: originMaster, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - const tx = ccc.Transaction.default(); - tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); - tx.outputsData.push(origin.cell.outputData, masterCell.outputData); - const client = { - cache: new ccc.ClientCacheMemory(), - findCellsOnChain: async function* (query: { scriptType: string }) { - await Promise.resolve(); - if (query.scriptType === "lock") { - yield liveOrder.cell; - yield forgedOrder.cell; - } else { - yield masterCell; - } - }, - getTransaction: async (txHash: ccc.Hex) => { - await Promise.resolve(); - return txHash === originMaster.txHash - ? ccc.ClientTransactionResponse.from({ - transaction: tx, - status: "committed", - }) - : undefined; - }, - } as unknown as ccc.Client; - - const skippedReasons: OrderGroupSkipReason[] = []; - const groups = []; - for await (const group of manager.findOrders(client, { - onSkippedGroup: (reason) => skippedReasons.push(reason), - })) { - groups.push(group); - } - - expect(groups).toHaveLength(0); - expect(skippedReasons).toEqual(["ambiguous-order"]); - }); - - it("uses live queries when onChain is requested", async () => { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const ownerLock = ccc.Script.from({ - codeHash: byte32FromByte("44"), - hashType: "type", - args: "0x", - }); - const manager = new OrderManager(orderScript, [], udtScript); - const originMaster = { txHash: byte32FromByte("88"), index: 1n }; - const origin = makeOrderCell({ - ckbUnoccupied: ccc.fixedPointFrom(100), - udtValue: 0n, - info: directionalInfo(), - master: { - type: "relative", - value: Relative.create(1n), - }, - lock: orderScript, - outPoint: { txHash: originMaster.txHash, index: 0n }, - }); - const masterCell = ccc.Cell.from({ - outPoint: originMaster, - cellOutput: { - capacity: ccc.fixedPointFrom(61), - lock: ownerLock, - type: orderScript, - }, - outputData: "0x", - }); - let cachedCalls = 0; - let onChainCalls = 0; - const tx = ccc.Transaction.default(); - tx.outputs.push(origin.cell.cellOutput, masterCell.cellOutput); - tx.outputsData.push(origin.cell.outputData, masterCell.outputData); - const client = { - cache: new ccc.ClientCacheMemory(), - findCells: async function* () { - await Promise.resolve(); - cachedCalls += 1; - yield* [] as ccc.Cell[]; - }, - findCellsOnChain: async function* (query: { scriptType: string }) { - await Promise.resolve(); - onChainCalls += 1; - if (query.scriptType === "lock") { - yield origin.cell; - } else { - yield masterCell; - } - }, - getTransaction: async (txHash: ccc.Hex) => { - await Promise.resolve(); - return txHash === originMaster.txHash - ? ccc.ClientTransactionResponse.from({ - transaction: tx, - status: "committed", - }) - : undefined; - }, - } as unknown as ccc.Client; - - const groups = []; - for await (const group of manager.findOrders(client, { onChain: true })) { - groups.push(group); - } - - expect(groups).toHaveLength(1); - expect(cachedCalls).toBe(0); - expect(onChainCalls).toBe(2); - }); -}); - -function makeUdtToCkbOrder(options?: { - txHashByte?: string; - orderTxHashByte?: string; - udtValue?: ccc.FixedPoint; - lockArgs?: ccc.Hex; -}): OrderCell { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: options?.lockArgs ?? "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - - return OrderCell.mustFrom( - ccc.Cell.from({ - outPoint: { - txHash: byte32FromByte(options?.orderTxHashByte ?? "44"), - index: 0n, - }, - cellOutput: { - capacity: ccc.fixedPointFrom(200), - lock: orderScript, - type: udtScript, - }, - outputData: OrderData.from({ - udtValue: options?.udtValue ?? ccc.fixedPointFrom(100), - master: { - type: "absolute", - value: { - txHash: byte32FromByte(options?.txHashByte ?? "33"), - index: 1n, - }, - }, - info: Info.from({ - ckbToUdt: Ratio.empty(), - udtToCkb: Ratio.from({ - ckbScale: 5n, - udtScale: 2n, - }), - ckbMinMatchLog: 0, - }), - }).toBytes(), - }), - ); -} - -function directionalInfo(): Info { - return Info.from({ - ckbToUdt: Ratio.from({ ckbScale: 1n, udtScale: 1n }), - udtToCkb: Ratio.empty(), - ckbMinMatchLog: 0, - }); -} - -function dualInfo(): Info { - const ratio = Ratio.from({ ckbScale: 1n, udtScale: 1n }); - return Info.from({ - ckbToUdt: ratio, - udtToCkb: ratio, - ckbMinMatchLog: 0, - }); -} - -function exhaustiveSequentialBestMatch( - orderPool: OrderCell[], - allowance: { ckbValue: bigint; udtValue: bigint }, - exchangeRate: { ckbScale: bigint; udtScale: bigint }, - options: { - feeRate: bigint; - ckbAllowanceStep: bigint; - maxPartials?: number; - }, -): Match { - const orderSize = orderPool.reduce( - (maxSize, order) => Math.max(maxSize, order.cell.occupiedSize), - 0, - ); - const ckbMiningFee = (ccc.numFrom(36 + orderSize) * options.feeRate + 999n) / 1000n; - const udtAllowanceStep = ( - options.ckbAllowanceStep * exchangeRate.ckbScale + exchangeRate.udtScale - 1n - ) / exchangeRate.udtScale; - let best: Match = { ckbDelta: 0n, udtDelta: 0n, partials: [] }; - let bestGain = 0n; - for (const c2u of OrderManager.sequentialMatcher( - orderPool, - true, - options.ckbAllowanceStep, - ckbMiningFee, - )) { - for (const u2c of OrderManager.sequentialMatcher( - orderPool, - false, - udtAllowanceStep, - ckbMiningFee, - )) { - const partials = c2u.partials.concat(u2c.partials); - if (options.maxPartials !== undefined && partials.length > options.maxPartials) { - continue; - } - if (!hasUniquePartialOrderOutPoints(partials)) { - continue; - } - - const ckbDelta = c2u.ckbDelta + u2c.ckbDelta; - const udtDelta = c2u.udtDelta + u2c.udtDelta; - const ckbFee = ckbMiningFee * BigInt(partials.length); - const ckbAllowance = allowance.ckbValue + ckbDelta - ckbFee; - const udtAllowance = allowance.udtValue + udtDelta; - const gain = (ckbDelta - ckbFee) * exchangeRate.ckbScale + udtDelta * exchangeRate.udtScale; - - if ( - ckbAllowance >= 0n && - udtAllowance >= 0n && - gain > bestGain - ) { - best = { ckbDelta, udtDelta, partials }; - bestGain = gain; - } - } - } - return best; -} - -function hasUniquePartialOrderOutPoints(partials: Match["partials"]): boolean { - const seen = new Set(); - for (const partial of partials) { - const key = partial.order.cell.outPoint.toHex(); - if (seen.has(key)) { - return false; - } - seen.add(key); - } - return true; -} - -function matchKey(match: Match): unknown { - return { - ckbDelta: match.ckbDelta, - udtDelta: match.udtDelta, - partials: match.partials.map((partial) => ({ - outPoint: partial.order.cell.outPoint.toHex(), - ckbOut: partial.ckbOut, - udtOut: partial.udtOut, - })), - }; -} - -async function collectOrders( - manager: OrderManager, - client: ccc.Client, -): Promise { - const groups = []; - for await (const group of manager.findOrders(client)) { - groups.push(group); - } - return groups; -} - -function makeOrderCell(options: { - ckbUnoccupied: ccc.FixedPoint; - udtValue: ccc.FixedPoint; - info: Info; - lock?: ccc.Script; - master: { - type: "relative"; - value: { - padding: Uint8Array; - distance: bigint; - }; - } | { - type: "absolute"; - value: { - txHash: `0x${string}`; - index: bigint; - }; - }; - outPoint: { - txHash: `0x${string}`; - index: bigint; - }; -}): OrderCell { - const orderScript = ccc.Script.from({ - codeHash: byte32FromByte("11"), - hashType: "type", - args: "0x", - }); - const udtScript = ccc.Script.from({ - codeHash: byte32FromByte("22"), - hashType: "type", - args: "0x", - }); - const lock = options.lock ?? orderScript; - const outputData = OrderData.from({ - udtValue: options.udtValue, - master: options.master, - info: options.info, - }).toBytes(); - const minimalCell = ccc.Cell.from({ - previousOutput: { - txHash: byte32FromByte("ff"), - index: 0n, - }, - cellOutput: { - lock, - type: udtScript, - }, - outputData, - }); - - return OrderCell.mustFrom( - ccc.Cell.from({ - outPoint: options.outPoint, - cellOutput: { - capacity: minimalCell.cellOutput.capacity + options.ckbUnoccupied, - lock, - type: udtScript, - }, - outputData, - }), - ); -} diff --git a/packages/order/src/order.ts b/packages/order/src/order.ts index b6dc04e..07b960b 100644 --- a/packages/order/src/order.ts +++ b/packages/order/src/order.ts @@ -1,118 +1,80 @@ import { ccc } from "@ckb-ccc/core"; import { - BufferedGenerator, - collectPagedScan, - compareBigInt, defaultCellPageSize, type ExchangeRatio, type ScriptDeps, type ValueComponents, } from "@ickb/utils"; -import { Info, OrderData, Ratio, Relative, type InfoLike } from "./entities.js"; -import { MasterCell, OrderCell, OrderGroup } from "./cells.js"; +import { + findAllMasters, + findSimpleOrders, + isMasterCell, + isOrderCell, + resolveOrderGroup, + type OrderGroupSkipReason, +} from "./io/order_scan.ts"; +import { addOrderMatch, meltOrderGroups, mintOrder } from "./io/order_transaction.ts"; +import { ceilDiv, quotePreservingRatio } from "./matching/order_conversion.ts"; +import { + OrderMatcher, + bestMatch, + orderMatchers, + sequentialMatches, + type Match, +} from "./matching/order_matching.ts"; +import type { OrderCell, OrderGroup } from "./model/cells.ts"; +import { Info, type InfoLike } from "./model/info.ts"; +import { Ratio } from "./model/ratio.ts"; + +export type { OrderGroupSkipReason } from "./io/order_scan.ts"; +export { OrderConversionRepresentabilityError } from "./matching/order_conversion.ts"; +export { OrderMatcher } from "./matching/order_matching.ts"; +export type { + Match, + MatchDiagnostics, + MatchDirectionDiagnostics, +} from "./matching/order_matching.ts"; /** - * Utilities for managing UDT orders on Nervos L1 such as minting, matching, and melting. + * Builds and scans iCKB Stack order cells for one order script deployment. + * + * @public */ export class OrderManager implements ScriptDeps { - /** - * Creates an instance of OrderManager. - * - * @param script - The order script. - * @param cellDeps - The cell dependencies for the order. - * @param udtScript - The UDT (User Defined Token) type script. - */ - constructor( - public readonly script: ccc.Script, - public readonly cellDeps: ccc.CellDep[], - public readonly udtScript: ccc.Script, - ) {} + /** Order lock script this manager scans and builds for. */ + public readonly script: ccc.Script; + /** Cell deps required to execute the order script. */ + public readonly cellDeps: ccc.CellDep[]; + /** UDT type script accepted by this order market. */ + public readonly udtScript: ccc.Script; /** - * Checks if the given cell is an order. - * - * A cell is considered an order if its lock script matches the order script of the manager and its type script - * equates to the UDT handler's script. - * - * @param cell - The cell to check. - * @returns True if the cell is an order; otherwise, false. + * Creates an order manager for one order script, its cell deps, and UDT type. */ - isOrder(cell: ccc.Cell): boolean { - return ( - cell.cellOutput.lock.eq(this.script) && - Boolean(cell.cellOutput.type?.eq(this.udtScript)) - ); + constructor(script: ccc.Script, cellDeps: ccc.CellDep[], udtScript: ccc.Script) { + this.script = script; + this.cellDeps = cellDeps; + this.udtScript = udtScript; } - /** - * Checks if the given cell is a master cell. - * - * A cell is recognized as a master cell if its type script matches the order script. - * - * @param cell - The cell to check. - * @returns True if the cell is a master cell; otherwise, false. - */ - isMaster(cell: ccc.Cell): boolean { - return Boolean(cell.cellOutput.type?.eq(this.script)); + /** Returns true when the cell is an order cell for this manager's scripts. */ + public isOrder(cell: ccc.Cell): boolean { + return isOrderCell(cell, this.script, this.udtScript); + } + + /** Returns true when the cell is a master cell for this manager's order script. */ + public isMaster(cell: ccc.Cell): boolean { + return isMasterCell(cell, this.script); } /** - * Previews the conversion between CKB and UDT. - * - * This method calculates a conversion preview using an exchange ratio midpoint. + * Computes the output-side amount, CKB fee, and order info for a new order. * - * Optionally, a fee may be applied that influences the effective conversion rate, - * resulting in additional fees or gains. The fee is applied by adjusting the ratio using the - * provided fee and feeBase. In practice, the fee is incorporated by multiplying the numerator by - * (feeBase - fee) and the denominator by feeBase. - * - * Effectively the computed fee scales the converted amount by (feeBase - fee) / feeBase, for example: - * - (100000 - 1000) / 100000 = 0.99 (1% fee). - * - (100000 - 300) / 100000 = 0.997 (0.3% fee). - * - (100000 - 1) / 100000 = 0.99999 (0.001% fee). - * - * This computation ensures that the fee is applied as a fixed percentage using the same - * integer arithmetic as the midpoint conversion ratio, with control over rounding adjustments. - * - * @param isCkb2Udt - Indicates if the conversion is from CKB to UDT: - * - If true, converts CKB to UDT. - * - Otherwise, converts UDT to CKB. - * @param midpoint - The exchange ratio used as the midpoint for conversion. - * It should contain both CKB and UDT scaling factors. - * @param amounts - An object of ValueComponents containing the CKB and UDT amounts. - * @param options - Optional conversion parameters. - * @param options.fee - The fee (as a ccc.Num) to apply during conversion. It represents the fee portion - * in integer terms (e.g., fee basis-points) and defaults to 0n (i.e., no fee). - * Internally, the fee is applied as a scaling factor: (feeBase – fee) / feeBase. - * @param options.feeBase - The base reference (as a ccc.Num) used for fee calculation. - * Defaults to 100000n if not provided. The feeBase defines the scaling factor - * in which fee is applied, ensuring fee is always a fixed percentage. - * @param options.ckbMinMatchLog - Optional minimum logarithmic matching threshold for CKB. - * This is used for further internal validation or matching criteria. - * Defaults to 33 (~86 CKB) if not provided. - * - * @returns An object with the following properties: - * - convertedAmount: The converted amount as a ccc.FixedPoint in the target unit. - * - ckbFee: The fee (or gain) in CKB, computed as a ccc.FixedPoint. - * - info: Additional conversion information as Info, to be used in OrderManager.mint. - * - * @example - * // Example usage previewing the conversion from CKB to iCKB UDT: - * const result = OrderManager.convert( - * true, // CKB to UDT - * ickbExchangeRatio(tipHeader), - * { - * ckbValue: ccc.FixedPointFrom("1000"), // 1000 CKB - * udtValue: 0n, - * }, - * { - * feeBase: 100000n, - * fee: 1n, // (100000 - 1) / 100000 = 0.99999 (i.e., a 0.001% fee is deducted). - * ckbMinMatchLog: 33 - * } - * ); + * @remarks + * The returned `Info` preserves the quoted amount after fee adjustment so the + * minted order records the executable limit price. */ - static convert( + public static convert( isCkb2Udt: boolean, midpoint: ExchangeRatio, amounts: ValueComponents, @@ -122,164 +84,63 @@ export class OrderManager implements ScriptDeps { ckbMinMatchLog?: number; }, ): { convertedAmount: ccc.FixedPoint; ckbFee: ccc.FixedPoint; info: Info } { - // Set fee and feeBase with fallback default values. const fee = options?.fee ?? 0n; const feeBase = options?.feeBase ?? 100000n; - - // Create a Ratio instance from the midpoint ratio. const base = Ratio.from(midpoint); - - // Apply the fee adjustment to the ratio. - const adjusted = base.applyFee(isCkb2Udt, fee, feeBase); - - // Select the input amount based on the conversion direction. const amount = isCkb2Udt ? amounts.ckbValue : amounts.udtValue; - - // Perform the conversion using the adjusted ratio. - const convertedAmount = adjusted.convert(isCkb2Udt, amount, true); + const { aScale, bScale } = base.feeAdjustedScales(isCkb2Udt, fee, feeBase); + const convertedAmount = ceilDiv(amount * aScale, bScale); let ckbFee = 0n; - // Calculate fee (or gain) based on the original midpoint rate. if (amount > 0n && fee !== 0n) { ckbFee = isCkb2Udt ? amount - base.convert(false, convertedAmount, false) : base.convert(false, amount, false) - convertedAmount; } - // Generate additional conversion info for further processing. - const info = Info.create(isCkb2Udt, adjusted, options?.ckbMinMatchLog); - + const info = Info.create( + isCkb2Udt, + quotePreservingRatio(amount, convertedAmount, isCkb2Udt), + options?.ckbMinMatchLog, + ); return { convertedAmount, ckbFee, info }; } /** - * Mints a new order cell and appends it to the transaction. - * - * The method performs the following: - * - Creates order data using the provided amounts and order information. - * - Adds required cell dependencies to the transaction. - * - Appends the order cell to the outputs with the UDT data and adjusts the CKB capacity. - * - Appends a corresponding master cell immediately after the order cell. + * Adds a new order cell and its master cell to a partial transaction. * - * @remarks Caller must ensure UDT cellDeps are added to the transaction (e.g., via CCC Udt balance completion). - * - * @param txLike - The transaction to which the order will be added. - * @param lock - The lock script for the master cell. - * @param info - The information related to the order, usually calculated using OrderManager.convert. - * @param amounts - The amounts for the order, including: - * @param amounts.ckbValue - The amount of CKB to allocate for the order (note: more CKB than expressed might be used). - * @param amounts.udtValue - The amount of UDT to allocate for the order. - * - * @returns void + * @remarks + * The order output is locked by the order script and typed by the configured + * UDT. The following master output is typed by the order script and locked by + * the caller-provided lock. */ - mint( + public mint( txLike: ccc.TransactionLike, lock: ccc.Script, info: InfoLike, amounts: ValueComponents, ): ccc.Transaction { - const tx = ccc.Transaction.from(txLike); - const { ckbValue, udtValue } = amounts; - const data = OrderData.from({ - udtValue, - master: { - type: "relative", - value: Relative.create(1n), // master is appended right after its order - }, - info, - }); - - tx.addCellDeps(this.cellDeps); - - // Append order cell to Outputs - const outputCount = tx.addOutput( - { - lock: this.script, - type: this.udtScript, - }, - data.toBytes(), - ); - const orderOutput = tx.outputs[outputCount - 1]; - if (orderOutput === undefined) { - throw new Error("Failed to append order output"); - } - orderOutput.capacity += ckbValue; - - // Append master cell to Outputs right after its order - tx.addOutput({ + return mintOrder(this, { + tx: ccc.Transaction.from(txLike), lock, - type: this.script, + info: Info.from(info), + amounts, }); - return tx; } /** - * Adds the match to the Transaction. - * - * Iterates over the partial matches (if any) and for each: - * - Adds the original order as an input. - * - Creates an updated output with adjusted CKB capacity and UDT data. + * Adds inputs and partial outputs for a chosen match. * - * @remarks Caller must ensure UDT cellDeps are added to the transaction (e.g., via CCC Udt balance completion). - * - * @param txLike - The transaction to which the matches will be added. - * @param match - The match object containing partial matches. + * @throws Error if the match repeats the same order out point. */ - addMatch(txLike: ccc.TransactionLike, match: Match): ccc.Transaction { - const tx = ccc.Transaction.from(txLike); - const partials = match.partials; - if (partials.length === 0) { - return tx; - } - if (!hasUniquePartialOrderOutPoints(partials)) { - throw new Error("Match contains duplicate order cells"); - } - - tx.addCellDeps(this.cellDeps); - - for (const { order, ckbOut, udtOut } of partials) { - tx.addInput(order.cell); - tx.addOutput( - { - lock: this.script, - type: this.udtScript, - capacity: ckbOut, - }, - OrderData.from({ - udtValue: udtOut, - master: { - type: "absolute", - value: order.getMaster(), - }, - info: order.data.info, - }).toBytes(), - ); - } - return tx; + public addMatch(txLike: ccc.TransactionLike, match: Match): ccc.Transaction { + return addOrderMatch(this, ccc.Transaction.from(txLike), match); } /** - * Matches the order with the specified parameters. - * - * Uses an OrderMatcher (if available) to compute the match based on a provided allowance. - * If no matcher is available, returns a match with zero deltas and no partials. - * - * @param order - The order cell to match against. - * @param isCkb2Udt - If true the match is in the CKB-to-UDT direction; otherwise UDT-to-CKB. - * @param allowance - The matching allowance as a fixed point number. - * - * @throws Will throw an error if the order is incompatible. - * - * @returns A Match object containing: - * • ckbDelta: net change in CKB value, - * • udtDelta: net change in UDT value, - * • partials: a list of partial matches. + * Matches one order against an allowance in the requested direction. */ - match( - order: OrderCell, - isCkb2Udt: boolean, - allowance: ccc.FixedPoint, - ): Match { + public match(order: OrderCell, isCkb2Udt: boolean, allowance: ccc.FixedPoint): Match { return ( OrderMatcher.from(order, isCkb2Udt, 0n)?.match(allowance) ?? { ckbDelta: 0n, @@ -290,272 +151,59 @@ export class OrderManager implements ScriptDeps { } /** - * Finds the best match for a given set of orders based on the current exchange rate. - * - * Evaluates pairs of matches (CKB-to-UDT and UDT-to-CKB) to determine the optimal combined match. - * The best match is chosen based on remaining allowances and overall gain. - * - * @param orderPool - The list of order cells to consider for matching. - * @param allowance - The allowance for CKB and UDT as a ValueComponents object. - * @param exchangeRate - The current exchange rate between CKB and UDT, including scaling factors. - * @param options - Optional parameters for matching: - * @param options.feeRate - Fee rate for the transaction (defaults to 1000n if not provided). - * @param options.ckbAllowanceStep - The step value for CKB allowance (defaults to 1000 CKB as fixed point). - * @param options.maxPartials - Maximum matched partial outputs to keep in the result. - * - * @returns A Match object containing the best combination of deltas, partial matches, and search diagnostics. + * Finds the best executable match for the supplied order pool and allowances. */ - static bestMatch( + public static bestMatch( orderPool: OrderCell[], allowance: ValueComponents, exchangeRate: ExchangeRatio, options?: { - feeRate?: ccc.Num; // Fee rate for the transaction + feeRate?: ccc.Num; ckbAllowanceStep?: ccc.FixedPoint; maxPartials?: number; }, ): Match { - const orderSize = maxOrderOccupiedSize(orderPool); - if (!orderSize) { - return { - ckbDelta: 0n, - udtDelta: 0n, - partials: [], - }; - } - - const { ckbScale, udtScale } = exchangeRate; - if (ckbScale <= 0n || udtScale <= 0n) { - throw new Error("Exchange rate scales must be positive"); - } - - // ckbAllowanceStep should be 1000 CKB if not provided - const ckbAllowanceStep = - options?.ckbAllowanceStep ?? ccc.fixedPointFrom("1000"); - if (ckbAllowanceStep <= 0n) { - throw new Error("CKB allowance step must be positive"); - } - - // Get fee rate or base fee rate if not provided - const feeRate = options?.feeRate ?? 1000n; - const ckbMiningFee = (ccc.numFrom(36 + orderSize) * feeRate + 999n) / 1000n; - - const maxPartials = options?.maxPartials; - const udtAllowanceStep = - (ckbAllowanceStep * ckbScale + udtScale - 1n) / udtScale; - const ckbToUdtMatchers = orderMatchers(orderPool, true, ckbMiningFee); - const udtToCkbMatchers = orderMatchers(orderPool, false, ckbMiningFee); - const diagnostics: MatchDiagnostics = { - orderCount: orderPool.length, - allowance, - ckbAllowanceStep, - udtAllowanceStep, - ckbMiningFee, - ...(maxPartials === undefined ? {} : { maxPartials }), - directions: { - ckbToUdt: summarizeMatchers(ckbToUdtMatchers), - udtToCkb: summarizeMatchers(udtToCkbMatchers), - }, - candidates: { - total: 0, - viable: 0, - positiveGain: 0, - rejected: { - maxPartials: 0, - duplicateOrder: 0, - insufficientCkbAllowance: 0, - insufficientUdtAllowance: 0, - nonPositiveGain: 0, - }, - bestGain: 0n, - }, - }; - - const ckb2UdtMatches = new BufferedGenerator( - sequentialMatches( - ckbToUdtMatchers, - ckbAllowanceStep, - ), - 2, - ); - const udt2CkbMatches = new BufferedGenerator( - sequentialMatches( - udtToCkbMatchers, - udtAllowanceStep, - ), - 2, - ); - - let best = { - ckbDelta: 0n, - udtDelta: 0n, - partials: [] as Match["partials"], - gain: 0n, - }; - let advance = { - i: -1, - j: -1, - gain: -1n << 256n, - }; - // advance.i/advance.j are offsets into the current two-entry frontiers; (0, 0) - // means the current frontier head is already optimal, so the search stops. - while (advance.i !== 0 || advance.j !== 0) { - ckb2UdtMatches.next(advance.i); - udt2CkbMatches.next(advance.j); - advance = { i: 0, j: 0, gain: -1n << 256n }; - - for (const [i, c2u] of ckb2UdtMatches.buffer.entries()) { - for (const [j, u2c] of udt2CkbMatches.buffer.entries()) { - const ckbDelta = c2u.ckbDelta + u2c.ckbDelta; - const udtDelta = c2u.udtDelta + u2c.udtDelta; - const partials = c2u.partials.concat(u2c.partials); - diagnostics.candidates.total += 1; - if (maxPartials !== undefined && partials.length > maxPartials) { - diagnostics.candidates.rejected.maxPartials += 1; - continue; - } - if (!hasUniquePartialOrderOutPoints(partials)) { - diagnostics.candidates.rejected.duplicateOrder += 1; - continue; - } - const ckbFee = ckbMiningFee * BigInt(partials.length); - const ckbAllowance = allowance.ckbValue + ckbDelta - ckbFee; - const udtAllowance = allowance.udtValue + udtDelta; - const gain = (ckbDelta - ckbFee) * ckbScale + udtDelta * udtScale; - if (ckbAllowance < 0n) { - diagnostics.candidates.rejected.insufficientCkbAllowance += 1; - } else if (udtAllowance < 0n) { - diagnostics.candidates.rejected.insufficientUdtAllowance += 1; - } - if (ckbAllowance < 0n || udtAllowance < 0n) { - continue; - } - diagnostics.candidates.viable += 1; - if (gain > advance.gain) { - advance = { i, j, gain }; - } - if (partials.length > 0) { - if (gain > 0n) { - diagnostics.candidates.positiveGain += 1; - } else { - diagnostics.candidates.rejected.nonPositiveGain += 1; - continue; - } - } - - if (gain > best.gain) { - best = { - ckbDelta, - udtDelta, - partials, - gain, - }; - } - } - } - } - - const { ckbDelta, udtDelta, partials } = best; - diagnostics.candidates.bestGain = best.gain; - return { - ckbDelta, - udtDelta, - partials, - diagnostics, - }; + return bestMatch(orderPool, allowance, exchangeRate, options); } /** - * Returns a generator that sequentially yields match objects for a given order pool. - * - * For each order, it uses an OrderMatcher (if available) to compute a match for increasing allowances, - * and yields the cumulative matched result. - * - * The matching for each order is performed in a sequential manner: - * 1. An array of matchers is created from the order pool, filtering out any undefined ones and sorting - * them in decreasing order by real match ratio (best gain per unit first). - * 2. A cumulative empty Match object is initialized and immediately yielded as the first match. - * 3. For each matcher, the algorithm performs a fair distribution of the matcher's `bMaxMatch` into - * partial matches. The distribution follows these rules: - * - Each partial match has at least N elements (where N is determined by dividing bMaxMatch by allowanceStep). - * - The number of partial matches is maximized. - * - The distribution is as fair as possible (i.e., partial match sizes differ by at most one sats). - * 4. The algorithm yields the cumulative match after processing each partial match; if a certain allowance is too low - * and does not produce any partial matches, the matcher is skipped. - * - * @param orderPool - The list of order cells to match. - * @param isCkb2Udt - A flag indicating the matching direction. If true, matching is done from CKB to UDT; otherwise, from UDT to CKB. - * @param allowanceStep - The step increment for the allowance represented as a fixed point number. - * @param ckbMiningFee - The CKB mining fee represented as a fixed point value. - * - * @yields A Match object representing the cumulative match up to the current matcher. + * Yields sequential matches for one direction using a fixed allowance step. */ - static *sequentialMatcher( + public static *sequentialMatcher( orderPool: OrderCell[], isCkb2Udt: boolean, allowanceStep: ccc.FixedPoint, ckbMiningFee: ccc.FixedPoint, ): Generator { - yield* sequentialMatches(orderMatchers(orderPool, isCkb2Udt, ckbMiningFee), allowanceStep); + yield* sequentialMatches( + orderMatchers(orderPool, isCkb2Udt, ckbMiningFee), + allowanceStep, + ); } /** - * Melts the specified order groups. - * - * For each order group, if the option is to only process fulfilled orders, it filters accordingly. - * Then, for every valid group, the master and order cells are added as inputs in the transaction. - * - * @param txLike - The transaction to which the groups will be added. - * @param groups - The array of OrderGroup instances to melt. - * @remarks Caller must ensure UDT cellDeps are added to the transaction (e.g., via CCC Udt balance completion). + * Adds order groups and their master cells as melt inputs. * - * @param options - Optional parameters: - * @param options.isFulfilledOnly - If true, only groups with fulfilled orders will be melted. - * - * @returns void + * @param options - Set `isFulfilledOnly` to skip groups whose current order is still matchable. */ - melt( + public melt( txLike: ccc.TransactionLike, groups: OrderGroup[], - options?: { - isFulfilledOnly?: boolean; - }, + options?: { isFulfilledOnly?: boolean }, ): ccc.Transaction { - const tx = ccc.Transaction.from(txLike); - const isFulfilledOnly = options?.isFulfilledOnly ?? false; - if (isFulfilledOnly) { - groups = groups.filter((g) => g.order.isFulfilled()); - } - if (groups.length === 0) { - return tx; - } - tx.addCellDeps(this.cellDeps); - - for (const group of groups) { - tx.addInput(group.order.cell); - tx.addInput(group.master.cell); - } - return tx; + return meltOrderGroups(this, ccc.Transaction.from(txLike), groups, options); } /** - * Finds orders associated with this OrderManager instance. + * Finds valid order groups by scanning order cells and master cells. * - * This async generator performs: - * 1. Fetch simple orders (lock-script cells matching order & UDT handler). - * 2. Fetch master cells (type-script cells matching order). - * 3. Group each simple order under its master cell; initiate origin lookup once. - * 4. For each group with orders and a resolved origin: - * - Resolve the best order via `origin.resolve(orders)`. - * - Construct an `OrderGroup` via `OrderGroup.tryFrom(...)`. - * - Yield the valid `OrderGroup`. - * - * @param client – Client to interact with the blockchain. - * @param options.onChain – Defaults to true. When false, use cached cell queries. - * @param options.pageSize – Cell query page size. Defaults to `defaultCellPageSize` (400). - * @yields OrderGroup instances combining master, order, and origin cells. + * @remarks + * `pageSize` is the per-scan RPC/indexer page size, not a total cap. The + * origin order lookup reads the client cache first, then fetches and records + * the transaction response when needed. `onSkippedGroup` reports unresolved or + * invalid groups without aborting the scan. */ - async *findOrders( + public async *findOrders( client: ccc.Client, options?: { onChain?: boolean; @@ -565,682 +213,44 @@ export class OrderManager implements ScriptDeps { ): AsyncGenerator { const onChain = options?.onChain ?? true; const pageSize = options?.pageSize ?? defaultCellPageSize; - - // Fetch simple orders & master cells in parallel const [simpleOrders, allMasters] = await Promise.all([ - this.findSimpleOrders(client, onChain, pageSize), - this.findAllMasters(client, onChain, pageSize), + findSimpleOrders({ + client, + script: this.script, + udtScript: this.udtScript, + onChain, + pageSize, + }), + findAllMasters(client, this.script, onChain, pageSize), ]); - - // Prepare a map of masterCellKey → { master, orders[] } const rawGroups = new Map( allMasters.map((master) => [ master.cell.outPoint.toHex(), - { - master, - orders: [] as OrderCell[], - }, + { master, orders: new Array() }, ]), ); - // Group simple orders by their master cell for (const order of simpleOrders) { - const master = order.getMaster(); - const key = master.toHex(); - const rawGroup = rawGroups.get(key); - - if (!rawGroup) { - // No matching master cell found + const rawGroup = rawGroups.get(order.getMaster().toHex()); + if (rawGroup === undefined) { options?.onSkippedGroup?.("missing-master"); continue; } - rawGroup.orders.push(order); } - // For each populated group, await origin, resolve the best order, and yield OrderGroup for (const { master, orders } of rawGroups.values()) { if (orders.length === 0) { continue; } - - const orderGroup = await this.resolveOrderGroup(client, master, orders); + const orderGroup = await resolveOrderGroup(client, master, orders, (cell) => + this.isOrder(cell), + ); if (!orderGroup.ok) { options?.onSkippedGroup?.(orderGroup.reason); continue; } - yield orderGroup.group; } } - - /** - * Finds simple orders on the blockchain. - * - * Queries cells whose lock script equals the order script and whose type script - * matches the UDT type script, returning only valid {@link OrderCell} instances. - * - * @param client – The client used to interact with the blockchain. - * @param onChain - When true, use live RPC queries; otherwise, use cached results. - * @param pageSize – Cell query page size. - * @returns Promise that resolves to an array of {@link OrderCell}. - */ - private async findSimpleOrders( - client: ccc.Client, - onChain: boolean, - pageSize: number, - ): Promise { - const findCellsArgs = [ - { - script: this.script, - scriptType: "lock", - filter: { - script: this.udtScript, - }, - scriptSearchMode: "exact", - withData: true, - }, - "asc", - ] as const; - - const orders: OrderCell[] = []; - for (const cell of await collectPagedScan( - (requestPageSize) => onChain - ? client.findCellsOnChain(...findCellsArgs, requestPageSize) - : client.findCells(...findCellsArgs, requestPageSize), - { pageSize }, - )) { - const order = OrderCell.tryFrom(cell); - if (!order || !this.isOrder(cell)) { - // Skip non-order cells or failed conversions - continue; - } - orders.push(order); - } - - return orders; - } - - /** - * Finds all master cells on the blockchain. - * - * Searches for cells whose type script exactly matches the order script, - * then wraps them as {@link MasterCell} instances. - * - * @param client – The client used to interact with the blockchain. - * @param onChain - When true, use live RPC queries; otherwise, use cached results. - * @param pageSize – Cell query page size. - * @returns Promise that resolves to an array of {@link MasterCell}. - */ - private async findAllMasters( - client: ccc.Client, - onChain: boolean, - pageSize: number, - ): Promise { - const findCellsArgs = [ - { - script: this.script, - scriptType: "type", - scriptSearchMode: "exact", - withData: true, - }, - "asc", - ] as const; - - const masters: MasterCell[] = []; - for (const cell of await collectPagedScan( - (requestPageSize) => onChain - ? client.findCellsOnChain(...findCellsArgs, requestPageSize) - : client.findCells(...findCellsArgs, requestPageSize), - { pageSize }, - )) { - if (!this.isMaster(cell)) { - // Skip cells that do not satisfy master criteria - continue; - } - masters.push(new MasterCell(cell)); - } - - return masters; - } - - /** - * Finds the mint origin order associated with a given master out point. - * - * The origin is historical transaction output data, not live cell state: it - * may already be spent by later matches while still anchoring the order group. - * - * @param client - The client used to interact with the blockchain. - * @param master - The master cell anchoring the order group. - * @param orders - Candidate live descendant orders for this master. - * - * @returns A promise that resolves to the validated group or a skip reason. - */ - private async resolveOrderGroup( - client: ccc.Client, - master: MasterCell, - orders: OrderCell[], - ): Promise { - const origin = await this.findOrigin(client, master.cell.outPoint); - if (!origin.ok) { - return origin; - } - - const order = origin.origin.resolve(orders); - if (!order) { - return { - ok: false, - reason: orders.some((candidate) => origin.origin.isValid(candidate)) - ? "ambiguous-order" - : "missing-order", - }; - } - - const group = OrderGroup.tryFrom(master, order, origin.origin); - if (!group) { - return { ok: false, reason: "invalid-group" }; - } - - return { ok: true, group }; - } - - private async findOrigin( - client: ccc.Client, - master: ccc.OutPoint, - ): Promise { - const { txHash, index: mIndex } = master; - let res = await client.cache.getTransactionResponse(txHash); - if (!res) { - res = await client.getTransaction(txHash); - if (res) { - await client.cache.recordTransactionResponses(res); - } - } - if (!res) { - return { ok: false, reason: "missing-origin" }; - } - - let origin: OrderCell | undefined; - for (let index = 0n; index < BigInt(res.transaction.outputs.length); index++) { - if (index === mIndex) { - continue; - } - - const output = res.transaction.getOutput(index); - if (!output) { - continue; - } - const cell = ccc.Cell.from({ - cellOutput: output.cellOutput, - outputData: output.outputData, - outPoint: { txHash, index }, - }); - const order = OrderCell.tryFrom(cell); - if ( - order && - this.isOrder(cell) && - order.data.isMint() && - order.getMaster().eq(master) - ) { - if (origin) { - return { ok: false, reason: "ambiguous-origin" }; - } - origin = order; - } - } - return origin - ? { ok: true, origin } - : { ok: false, reason: "missing-origin" }; - } -} - -type FindOriginResult = - | { ok: true; origin: OrderCell } - | { ok: false; reason: "missing-origin" | "ambiguous-origin" }; - -export type OrderGroupSkipReason = - | "missing-master" - | "missing-origin" - | "ambiguous-origin" - | "missing-order" - | "ambiguous-order" - | "invalid-group"; - -type ResolveOrderGroupResult = - | { ok: true; group: OrderGroup } - | { - ok: false; - reason: Exclude; - }; - -function hasUniquePartialOrderOutPoints(partials: Match["partials"]): boolean { - const outPoints = new Set(); - for (const partial of partials) { - const key = partial.order.cell.outPoint.toHex(); - if (outPoints.has(key)) { - return false; - } - outPoints.add(key); - } - - return true; -} - -function maxOrderOccupiedSize(orderPool: OrderCell[]): number { - let maxSize = 0; - for (const order of orderPool) { - maxSize = Math.max(maxSize, order.cell.occupiedSize); - } - return maxSize; -} - -function orderMatchers( - orderPool: OrderCell[], - isCkb2Udt: boolean, - ckbMiningFee: ccc.FixedPoint, -): OrderMatcher[] { - return orderPool - .map((o) => OrderMatcher.from(o, isCkb2Udt, ckbMiningFee)) - .filter((m) => m !== undefined) - .sort((a, b) => OrderMatcher.compareRealRatioDesc(a, b)); -} - -function* sequentialMatches( - matchers: OrderMatcher[], - allowanceStep: ccc.FixedPoint, -): Generator { - // Initialize an accumulator for the cumulative match. - let acc: Match = { - ckbDelta: 0n, - udtDelta: 0n, - partials: [], - }; - - let curr = acc; - yield curr; - - // Process each matcher in sequence. - for (const matcher of matchers) { - const maxMatch = matcher.bMaxMatch; - // Distribute maxMatch into partial matches according to a fair distribution policy: - // - Each partial match is of at least of allowanceStep size. - // - The number of partial matches is maximized. - // - The distribution is as fair as possible (i.e., partial match sizes differ by at most 1 sats). - // - // Here, N is defined as ceil(maxMatch / allowanceStep). - const N = (maxMatch + allowanceStep - 1n) / allowanceStep; - - // Determine the base quota (q) and remainder (r) for fair distribution. - // q = base units per partial match. - // r = the number of partial matches that will receive one extra unit. - const q = maxMatch / N; - const r = maxMatch % N; - - let allowance = 0n; - for (let i = 0n; i < N; i++) { - // For the first r partial matches, assign an extra unit (q + 1); for the rest, assign q. - allowance += i < r ? q + 1n : q; - - // Compute the match using the current allowance. - const m = matcher.match(allowance); - // If the current allowance is too low to yield any partial matches, - // try the next allowance for the same matcher. - if (m.partials.length === 0) { - continue; - } - // Update the cumulative match by aggregating the deltas and partials. - curr = { - ckbDelta: acc.ckbDelta + m.ckbDelta, - udtDelta: acc.udtDelta + m.udtDelta, - partials: acc.partials.concat(m.partials), - }; - // Yield the newly updated cumulative match. - yield curr; - } - // Update the accumulator with the current cumulative match for the next matcher. - acc = curr; - } -} - -function summarizeMatchers( - matchers: OrderMatcher[], -): MatchDirectionDiagnostics { - const matchableCount = matchers.length; - let minAllowance: ccc.FixedPoint | undefined; - let maxMatch: ccc.FixedPoint | undefined; - for (const matcher of matchers) { - minAllowance = minAllowance === undefined || compareBigInt(matcher.bMinMatch, minAllowance) < 0 - ? matcher.bMinMatch - : minAllowance; - maxMatch = maxMatch === undefined || compareBigInt(matcher.bMaxMatch, maxMatch) > 0 - ? matcher.bMaxMatch - : maxMatch; - } - - return { - matchableCount, - ...(minAllowance === undefined ? {} : { minAllowance }), - ...(maxMatch === undefined ? {} : { maxMatch }), - }; -} - -/** - * Represents a partial match result for an order. - */ -export interface Match { - /** - * The change in CKB for the matches from the matcher perspective. - */ - ckbDelta: bigint; - - /** - * The change in UDT for the matches from the matcher perspective. - */ - udtDelta: bigint; - - /** - * An array of match details. - * - * Each match includes the order cell involved in the match, - * the output amount of CKB, and the output amount of UDT. - */ - partials: { - /** - * The order cell involved in the match. - */ - order: OrderCell; - - /** - * The output amount of CKB. - */ - ckbOut: ccc.FixedPoint; - - /** - * The output amount of UDT. - */ - udtOut: ccc.FixedPoint; - }[]; - - /** Aggregate match-search diagnostics. It excludes order cells, scripts, and out points. */ - diagnostics?: MatchDiagnostics; -} - -export interface MatchDiagnostics { - orderCount: number; - allowance: ValueComponents; - ckbAllowanceStep: ccc.FixedPoint; - udtAllowanceStep: ccc.FixedPoint; - ckbMiningFee: ccc.FixedPoint; - maxPartials?: number; - directions: { - ckbToUdt: MatchDirectionDiagnostics; - udtToCkb: MatchDirectionDiagnostics; - }; - candidates: { - total: number; - viable: number; - positiveGain: number; - rejected: { - maxPartials: number; - duplicateOrder: number; - insufficientCkbAllowance: number; - insufficientUdtAllowance: number; - nonPositiveGain: number; - }; - bestGain: bigint; - }; -} - -export interface MatchDirectionDiagnostics { - matchableCount: number; - minAllowance?: ccc.FixedPoint; - maxMatch?: ccc.FixedPoint; -} - -/** - * OrderMatcher is responsible for computing match results for an order. - * - * It encapsulates all parameters and logic required to match an order based on a given allowance. - */ -export class OrderMatcher { - /** - * @param order - The order cell to match. - * @param isCkb2Udt - Indicates whether the matching direction is from CKB to UDT (true) or vice versa. - * @param aScale - Scaling factor for the primary asset (CKB when isCkb2Udt is true, otherwise UDT). - * @param bScale - Scaling factor for the secondary asset (UDT when isCkb2Udt is true, otherwise CKB). - * @param aIn - The input amount for asset A. - * @param bIn - The input amount for asset B. - * @param aMin - The minimum allowable output for asset A (e.g., minimum CKB after fee deduction). - * @param bMinMatch - The minimum matching amount for asset B. - * @param bMaxMatch - The maximum amount of asset B that can be matched. - * @param bMaxOut - The maximum output amount for asset B. - * @param realRatioNumerator - Numerator of the exact effective ratio used for sorting. - * @param realRatioDenominator - Denominator of the exact effective ratio used for sorting. - */ - constructor( - public readonly order: OrderCell, - public readonly isCkb2Udt: boolean, - public readonly aScale: ccc.Num, - public readonly bScale: ccc.Num, - public readonly aIn: ccc.FixedPoint, - public readonly bIn: ccc.FixedPoint, - public readonly aMin: ccc.FixedPoint, - public readonly bMinMatch: ccc.FixedPoint, - public readonly bMaxMatch: ccc.FixedPoint, - public readonly bMaxOut: ccc.FixedPoint, - public readonly realRatioNumerator: ccc.FixedPoint, - public readonly realRatioDenominator: ccc.FixedPoint, - ) {} - - static compareRealRatioDesc(left: OrderMatcher, right: OrderMatcher): number { - return compareBigInt( - right.realRatioNumerator * left.realRatioDenominator, - left.realRatioNumerator * right.realRatioDenominator, - ); - } - - /** - * Factory method to create an OrderMatcher instance from an order. - * - * The method determines necessary matching parameters based on the matching direction - * (CKB-to-UDT versus UDT-to-CKB) and calculates the maximum and minimum amounts - * allowed for a valid match. It returns undefined if the parameters are invalid. - * - * @param order - The order cell to match. - * @param isCkb2Udt - Indicates matching direction (true for CKB-to-UDT; false for UDT-to-CKB). - * @param ckbMiningFee - The CKB mining fee as a fixed point, applied to the appropriate asset. - * - * @returns An instance of OrderMatcher if matching is possible; otherwise, undefined. - */ - static from( - order: OrderCell, - isCkb2Udt: boolean, - ckbMiningFee: ccc.FixedPoint, - ): OrderMatcher | undefined { - if (isCkb2Udt ? !order.isCkb2UdtMatchable() : !order.isUdt2CkbMatchable()) { - return; - } - - let aScale: ccc.Num; - let bScale: ccc.Num; - let aIn: ccc.FixedPoint; - let bIn: ccc.FixedPoint; - let aMin: ccc.FixedPoint; - let bMinMatch: ccc.FixedPoint; - let aMiningFee: ccc.FixedPoint; - let bMiningFee: ccc.FixedPoint; - - if (isCkb2Udt) { - // When converting CKB to UDT, extract scaling factors accordingly. - ({ ckbScale: aScale, udtScale: bScale } = order.data.info.ckbToUdt); - [aIn, bIn] = [order.ckbValue, order.udtValue]; - // Calculate the minimal match for UDT based on the order info. - bMinMatch = - (order.data.info.getCkbMinMatch() * bScale + aScale - 1n) / aScale; - // aMin is determined by subtracting unoccupied capacity from the total capacity. - aMin = order.cell.cellOutput.capacity - order.ckbUnoccupied; - aMiningFee = ckbMiningFee; - bMiningFee = 0n; - } else { - // When converting UDT to CKB, swap the scale factors. - ({ ckbScale: bScale, udtScale: aScale } = order.data.info.udtToCkb); - [bIn, aIn] = [order.ckbValue, order.udtValue]; - bMinMatch = order.data.info.getCkbMinMatch(); - aMin = 0n; - aMiningFee = 0n; - bMiningFee = ckbMiningFee; - } - - // Validate that there is sufficient input beyond the minimum required. - if (aIn <= aMin + aMiningFee || aScale <= 0n || bScale <= 0n) { - return; - } - - // Calculate the maximum possible output for asset B, ensuring a non-decreasing property. - const bMaxOut = OrderMatcher.nonDecreasing(aScale, bScale, aIn, bIn, aMin); - const bMaxMatch = bMaxOut - bIn; - if (bMinMatch > bMaxMatch) { - bMinMatch = bMaxMatch; - } - - const realRatioNumerator = aIn - aMin - aMiningFee; - const realRatioDenominator = bMaxMatch + bMiningFee; - - if (realRatioNumerator <= 0n || realRatioDenominator <= 0n) { - return; - } - - return new OrderMatcher( - order, - isCkb2Udt, - aScale, - bScale, - aIn, - bIn, - aMin, - bMinMatch, - bMaxMatch, - bMaxOut, - realRatioNumerator, - realRatioDenominator, - ); - } - - /** - * Computes a match result for the provided allowance on asset B. - * - * If the provided allowance is too low to fulfill even a partial match, an empty match is returned. - * If the allowance meets or exceeds the maximum matchable amount, a complete match is returned. - * - * @param bAllowance - The allowance available for matching asset B. - * - * @returns A Match object containing delta values and the match details. - */ - match(bAllowance: ccc.FixedPoint): Match { - // Check if allowance is too low to even fulfill partially. - if (bAllowance < this.bMinMatch) { - return { - ckbDelta: 0n, - udtDelta: 0n, - partials: [], - }; - } - - // Check if allowance is sufficient for a complete match. - if (bAllowance >= this.bMaxMatch) { - return this.create(this.aMin, this.bMaxOut); - } - - // For partial matches, calculate output values. - const bOut = this.bIn + bAllowance; - const aOut = OrderMatcher.nonDecreasing( - this.bScale, - this.aScale, - this.bIn, - this.aIn, - bOut, - ); - - if ( - !this.isCkb2Udt && - this.aIn * this.aScale < aOut * this.aScale + this.bMinMatch * this.bScale - ) { - return { - ckbDelta: 0n, - udtDelta: 0n, - partials: [], - }; - } - - return this.create(aOut, bOut); - } - - /** - * Creates a Match result given the output amounts. - * - * Depending on the matching direction, it calculates the deltas: - * - For CKB-to-UDT: the change in CKB is aIn - aOut and in UDT is bIn - bOut. - * - For UDT-to-CKB: the change in CKB is bIn - bOut and in UDT is aIn - aOut. - * - * @param aOut - The computed output amount for asset A. - * @param bOut - The computed output amount for asset B. - * - * @returns A Match object representing the result. - */ - create(aOut: ccc.FixedPoint, bOut: ccc.FixedPoint): Match { - return this.isCkb2Udt - ? { - ckbDelta: this.aIn - aOut, - udtDelta: this.bIn - bOut, - partials: [ - { - order: this.order, - ckbOut: aOut, - udtOut: bOut, - }, - ], - } - : { - ckbDelta: this.bIn - bOut, - udtDelta: this.aIn - aOut, - partials: [ - { - order: this.order, - ckbOut: bOut, - udtOut: aOut, - }, - ], - }; - } - - /** - * Applies the limit order rule on non-decreasing value to calculate bOut. - * - * The formula finds the minimum bOut such that: - * aScale * aIn + bScale * bIn <= aScale * aOut + bScale * bOut - * - * Rearranging, we get: - * bOut = (aScale * (aIn - aOut) + bScale * bIn) / bScale - * - * Since integer division truncates, rounding is applied to guarantee an upper value: - * - * bOut = (aScale * (aIn - aOut) + bScale * (bIn + 1) - 1) / bScale - * - * @param aScale - The scaling factor for asset A. - * @param bScale - The scaling factor for asset B. - * @param aIn - The input amount for asset A. - * @param bIn - The input amount for asset B. - * @param aOut - The output amount for asset A. - * - * @returns The computed output amount for asset B ensuring the non-decreasing property. - */ - static nonDecreasing( - aScale: ccc.Num, - bScale: ccc.Num, - aIn: ccc.FixedPoint, - bIn: ccc.FixedPoint, - aOut: ccc.FixedPoint, - ): ccc.FixedPoint { - return (aScale * (aIn - aOut) + bScale * (bIn + 1n) - 1n) / bScale; - } } diff --git a/packages/order/test/fixtures/order_constants.ts b/packages/order/test/fixtures/order_constants.ts new file mode 100644 index 0000000..c33b0aa --- /dev/null +++ b/packages/order/test/fixtures/order_constants.ts @@ -0,0 +1,24 @@ +import type { ccc } from "@ckb-ccc/core"; +import type { StubClient } from "@ickb/testkit"; + +export const RATIO_SCALE_EXCEEDS_UINT64 = "Ratio scale exceeds Uint64"; +export const ORDER_MATCHER_SUITE = "OrderMatcher"; +export const ORDER_CELL_RESOLVE_SUITE = "OrderCell.resolve"; +export const ORDER_MANAGER_FIND_ORDERS_SUITE = "OrderManager.findOrders"; +export const NO_CELLS: readonly ccc.Cell[] = []; + +type StubClientOptions = NonNullable[0]>; +type FindCellsOnChainHandler = NonNullable; +export type FindCellsOnChainQuery = Parameters[0]; +export type FindCellsOnChainOrder = Parameters[1]; +export type FindCellsOnChainLimit = Parameters[2]; +export type FindCellsOnChainReturn = ReturnType; +export type GetTransactionHash = Parameters[0]; +export type GetTransactionReturn = ReturnType; + +export function mustPageSize(pageSize: FindCellsOnChainLimit): number { + if (pageSize === undefined) { + throw new Error("Expected page size"); + } + return pageSize; +} diff --git a/packages/order/test/index.ts b/packages/order/test/index.ts new file mode 100644 index 0000000..87212a9 --- /dev/null +++ b/packages/order/test/index.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import * as order from "../src/index.ts"; + +describe("order package barrel", () => { + it("creates order metadata through the public index", () => { + const ratio = order.Ratio.from({ ckbScale: 2n, udtScale: 3n }); + const info = order.Info.create(true, ratio, 4); + + expect(info.isCkb2Udt()).toBe(true); + expect(info.ckbToUdt.compare(ratio)).toBe(0); + expect(info.getCkbMinMatch()).toBe(16n); + }); +}); diff --git a/packages/order/test/matching/order_cell_resolve.ts b/packages/order/test/matching/order_cell_resolve.ts new file mode 100644 index 0000000..46e2ded --- /dev/null +++ b/packages/order/test/matching/order_cell_resolve.ts @@ -0,0 +1,129 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, script } from "@ickb/testkit"; +import { describe, expect, it } from "vitest"; +import { OrderCell } from "../../src/model/cells.ts"; +import { OrderManager } from "../../src/order.ts"; +import { ORDER_CELL_RESOLVE_SUITE } from "../fixtures/order_constants.ts"; +import { makeUdtToCkbOrder } from "./support/order_match_helpers.ts"; +import { + directionalInfo, + dualInfo, + makeOrderCell, +} from "./support/order_order_helpers.ts"; +describe("OrderManager.addMatch", () => { + it("rejects duplicate partials for the same order cell", () => { + const manager = new OrderManager(script("11"), [], script("22")); + const order = makeUdtToCkbOrder(); + const partial = { order, ckbOut: order.ckbValue, udtOut: order.udtValue }; + + expect(() => + manager.addMatch(ccc.Transaction.default(), { + ckbDelta: 0n, + udtDelta: 0n, + partials: [partial, partial], + }), + ).toThrow(`Match contains duplicate order cells: ${order.cell.outPoint.toHex()}`); + }); +}); + +describe("OrderManager.mint", () => { + it("creates an order output with the requested CKB value plus occupied capacity", () => { + const lock = script("11"); + const udt = script("22"); + const manager = new OrderManager(script("33"), [], udt); + + const tx = manager.mint(ccc.Transaction.default(), lock, dualInfo(), { + ckbValue: ccc.fixedPointFrom(123), + udtValue: ccc.fixedPointFrom(456), + }); + + expect(tx.outputs).toHaveLength(2); + const output = tx.getOutput(0); + if (output === undefined) { + throw new Error("Expected order output"); + } + expect( + OrderCell.mustFrom( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("ef"), index: 0n }, + cellOutput: output.cellOutput, + outputData: output.outputData, + }), + ).ckbUnoccupied, + ).toBe(ccc.fixedPointFrom(123)); + expect(tx.outputs[0]?.capacity).toBeGreaterThan(ccc.fixedPointFrom(123)); + expect(tx.outputs[0]?.lock.eq(manager.script)).toBe(true); + expect(tx.outputs[0]?.type?.eq(udt)).toBe(true); + expect(tx.outputs[1]?.lock.eq(lock)).toBe(true); + expect(tx.outputs[1]?.type?.eq(manager.script)).toBe(true); + }); +}); + +describe(ORDER_CELL_RESOLVE_SUITE, () => { + it("prefers directional progress over a higher-value unprogressed candidate", () => { + const master = { + txHash: byte32FromByte("55"), + index: 10n, + }; + const info = directionalInfo(); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + }); + const progressed = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(50), + udtValue: ccc.fixedPointFrom(50), + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("66"), index: 0n }, + }); + const forged = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(200), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("77"), index: 0n }, + }); + + expect(progressed.absProgress).toBeGreaterThan(forged.absProgress); + expect(forged.absTotal).toBeGreaterThan(progressed.absTotal); + expect(origin.resolve([forged, progressed])).toBe(progressed); + }); + + it("uses best value for dual-sided orders via absProgress === absTotal", () => { + const master = { + txHash: byte32FromByte("88"), + index: 10n, + }; + const info = dualInfo(); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + }); + const lowerValue = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("99"), index: 0n }, + }); + const higherValue = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(60), + udtValue: ccc.fixedPointFrom(60), + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("aa"), index: 0n }, + }); + + expect(lowerValue.absProgress).toBe(lowerValue.absTotal); + expect(higherValue.absProgress).toBe(higherValue.absTotal); + expect(higherValue.absTotal).toBeGreaterThan(lowerValue.absTotal); + expect(origin.resolve([lowerValue, higherValue])).toBe(higherValue); + }); +}); diff --git a/packages/order/test/matching/order_manager_transaction.ts b/packages/order/test/matching/order_manager_transaction.ts new file mode 100644 index 0000000..f0b75b2 --- /dev/null +++ b/packages/order/test/matching/order_manager_transaction.ts @@ -0,0 +1,502 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { MasterCell, OrderCell, OrderGroup } from "../../src/model/cells.ts"; +import { Info } from "../../src/model/info.ts"; +import { OrderData } from "../../src/model/order_data.ts"; +import { Ratio } from "../../src/model/ratio.ts"; +import { OrderManager, OrderMatcher } from "../../src/order.ts"; +import { ORDER_MATCHER_SUITE } from "../fixtures/order_constants.ts"; +import { + exhaustiveSequentialBestMatch, + makeUdtToCkbOrder, + matchKey, +} from "./support/order_match_helpers.ts"; +import { + byte32FromByte, + dualInfo, + makeOrderCell, +} from "./support/order_order_helpers.ts"; + +const ORDER_SCRIPT = script("11"); +const UDT_SCRIPT = script("22"); +const OWNER_LOCK = script("33"); +const WRONG_MANAGER_ERROR = "does not match this order manager"; + +describe(ORDER_MATCHER_SUITE, () => { + registerOrderMatcherMinimumTests(); + registerSequentialMatcherTests(); +}); + +function registerOrderMatcherMinimumTests(): void { + it("rejects UDT-to-CKB partials below the converted CKB minimum", () => { + const order = makeUdtToCkbOrder(); + const matcher = OrderMatcher.from(order, false, 0n); + + const belowMinimum = matcher?.match(1n); + const atMinimum = matcher?.match(3n); + + expect(belowMinimum?.partials).toHaveLength(0); + expect(atMinimum?.partials).toHaveLength(1); + expect(atMinimum?.partials[0]?.ckbOut).toBe(ccc.fixedPointFrom(200) + 3n); + expect(atMinimum?.partials[0]?.udtOut).toBe(ccc.fixedPointFrom(100) - 7n); + }); + + it("allows full consumption when the remaining CKB match is below the default minimum", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(50), + udtValue: ccc.fixedPointFrom(50), + info: Info.create(false, { ckbScale: 1n, udtScale: 1n }), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("45"), + index: 0n, + }, + }); + const matcher = OrderMatcher.from(order, false, 0n); + + if (matcher === undefined) { + throw new Error("Expected order to be matchable"); + } + expect(matcher.bMaxMatch).toBeLessThan(1n << 33n); + expect(matcher.bMinMatch).toBe(matcher.bMaxMatch); + + const match = matcher.match(matcher.bMaxMatch); + + expect(match.partials).toHaveLength(1); + expect(match.partials[0]?.ckbOut).toBe(matcher.bMaxOut); + }); +} + +function registerSequentialMatcherTests(): void { + it("continues trying larger allowances after an allowance below the minimum", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(200), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("46"), + index: 0n, + }, + }); + const matcher = OrderMatcher.from(order, true, 0n); + + if (matcher === undefined) { + throw new Error("Expected order to be matchable"); + } + expect(ccc.fixedPointFrom(50)).toBeLessThan(matcher.bMinMatch); + + const matches = Array.from( + OrderManager.sequentialMatcher([order], true, ccc.fixedPointFrom(50), 0n), + ); + + expect(matches.find((match) => match.partials.length === 1)?.partials).toHaveLength( + 1, + ); + }); + + it("rejects a zero sequential allowance step", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(200), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("47"), + index: 0n, + }, + }); + + expect(() => + Array.from(OrderManager.sequentialMatcher([order], true, 0n, 0n)), + ).toThrow("Allowance step must be positive"); + }); +} + +describe("OrderManager no-op transaction helpers", () => { + it("recognizes master cells and handles no-op matches and melts", () => { + const manager = new OrderManager(ORDER_SCRIPT, [], UDT_SCRIPT); + const master = masterCell(); + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 10n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + }); + + expect(manager.isMaster(master)).toBe(true); + expect(manager.isMaster(order.cell)).toBe(false); + expect( + manager.addMatch(ccc.Transaction.default(), { + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }).inputs, + ).toEqual([]); + expect(manager.match(order, false, 0n)).toEqual({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + }); + expect( + OrderManager.bestMatch( + [], + { ckbValue: 1n, udtValue: 1n }, + { ckbScale: 1n, udtScale: 1n }, + ), + ).toEqual({ ckbDelta: 0n, udtDelta: 0n, partials: [] }); + expect( + manager.melt(ccc.Transaction.default(), [], { isFulfilledOnly: true }).inputs, + ).toEqual([]); + }); +}); + +describe("OrderManager match and melt transaction helpers", () => { + registerMatchMeltSuccessTests(); + registerMintTransactionValidationTests(); + registerMatchPartialValidationTests(); + registerMeltGroupValidationTests(); + registerMatcherConstructorValidationTests(); +}); + +function registerMatchMeltSuccessTests(): void { + it("adds match partials and melt inputs for selected groups", () => { + const manager = new OrderManager( + ORDER_SCRIPT, + [ + ccc.CellDep.from({ + outPoint: { txHash: byte32FromByte("aa"), index: 0n }, + depType: "code", + }), + ], + UDT_SCRIPT, + ); + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 10n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + }); + const master = MasterCell.from(masterCell()); + const group = new OrderGroup(master, order, order); + + const matched = manager.addMatch(ccc.Transaction.default(), { + ckbDelta: 1n, + udtDelta: -1n, + partials: [{ order, ckbOut: order.ckbValue, udtOut: order.udtValue }], + }); + const melted = manager.melt(ccc.Transaction.default(), [group]); + const fulfilledOnly = manager.melt(ccc.Transaction.default(), [group], { + isFulfilledOnly: true, + }); + + expect(matched.cellDeps).toHaveLength(1); + expect(matched.inputs).toHaveLength(1); + expect(matched.outputs).toHaveLength(1); + expect(melted.inputs).toHaveLength(2); + expect(fulfilledOnly.inputs).toEqual([]); + }); +} + +function registerMintTransactionValidationTests(): void { + it("fails closed if mint output append cannot be observed", () => { + const manager = new OrderManager(ORDER_SCRIPT, [], UDT_SCRIPT); + + expect(() => { + manager.mint( + new UnobservableOutputTransaction(), + OWNER_LOCK, + Info.create(true, { ckbScale: 1n, udtScale: 1n }), + { + ckbValue: 1n, + udtValue: 1n, + }, + ); + }).toThrow("Failed to append order output"); + }); + + it("rejects invalid mint data before adding order outputs", () => { + const manager = new OrderManager(ORDER_SCRIPT, [], UDT_SCRIPT); + const info = Info.create(true, { ckbScale: 1n, udtScale: 1n }); + + expect(() => + manager.mint(ccc.Transaction.default(), OWNER_LOCK, info, { + ckbValue: -1n, + udtValue: 1n, + }), + ).toThrow("ckbValue invalid, negative"); + expect(() => + manager.mint(ccc.Transaction.default(), OWNER_LOCK, info, { + ckbValue: 1n, + udtValue: -1n, + }), + ).toThrow("udtValue invalid, negative"); + expect(() => + manager.mint( + ccc.Transaction.default(), + OWNER_LOCK, + Info.from({ + ckbToUdt: Ratio.empty(), + udtToCkb: Ratio.empty(), + ckbMinMatchLog: 0, + }), + { ckbValue: 1n, udtValue: 1n }, + ), + ).toThrow("ckbToUdt is Empty, but udtToCkb is not Populated"); + }); +} + +function registerMatchPartialValidationTests(): void { + it("rejects fabricated match partials", () => { + const manager = new OrderManager(ORDER_SCRIPT, [], UDT_SCRIPT); + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 10n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + }); + const foreign = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 10n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + lock: script("99"), + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + outPoint: { txHash: byte32FromByte("56"), index: 0n }, + }); + const mismatched = new OrderCell( + order.cell, + OrderData.from({ + udtValue: 11n, + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + info: order.data.info, + }), + order.ckbUnoccupied, + order.absTotal, + order.absProgress, + order.maturity, + ); + + expect(() => + manager.addMatch(ccc.Transaction.default(), { + ckbDelta: 0n, + udtDelta: 0n, + partials: [ + { order, ckbOut: order.ckbValue, udtOut: order.udtValue }, + { order, ckbOut: order.ckbValue, udtOut: order.udtValue }, + ], + }), + ).toThrow(`Match contains duplicate order cells: ${order.cell.outPoint.toHex()}`); + expect(() => + manager.addMatch(ccc.Transaction.default(), { + ckbDelta: 0n, + udtDelta: 0n, + partials: [ + { order: foreign, ckbOut: foreign.ckbValue, udtOut: foreign.udtValue }, + ], + }), + ).toThrow(WRONG_MANAGER_ERROR); + expect(() => + manager.addMatch(ccc.Transaction.default(), { + ckbDelta: 0n, + udtDelta: 0n, + partials: [{ order, ckbOut: -1n, udtOut: order.udtValue }], + }), + ).toThrow("negative CKB output"); + expect(() => + manager.addMatch(ccc.Transaction.default(), { + ckbDelta: 0n, + udtDelta: 0n, + partials: [{ order, ckbOut: order.ckbValue, udtOut: -1n }], + }), + ).toThrow("negative UDT output"); + expect(() => + manager.addMatch(ccc.Transaction.default(), { + ckbDelta: 0n, + udtDelta: 0n, + partials: [{ order: mismatched, ckbOut: order.ckbValue, udtOut: order.udtValue }], + }), + ).toThrow("does not match its cell data"); + }); +} + +function registerMeltGroupValidationTests(): void { + it("rejects melt groups from a different manager", () => { + const manager = new OrderManager(ORDER_SCRIPT, [], UDT_SCRIPT); + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + outPoint: { txHash: byte32FromByte("57"), index: 0n }, + }); + const foreignOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + lock: script("99"), + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + outPoint: { txHash: byte32FromByte("58"), index: 0n }, + }); + const master = MasterCell.from(masterCell()); + const foreignMaster = MasterCell.from( + ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("66"), index: 1n }, + cellOutput: { capacity: 61n, lock: OWNER_LOCK, type: script("99") }, + outputData: "0x", + }), + ); + const group = new OrderGroup(foreignMaster, order, order); + + expect(() => + manager.melt(ccc.Transaction.default(), [ + new OrderGroup(master, foreignOrder, foreignOrder), + ]), + ).toThrow(WRONG_MANAGER_ERROR); + expect(() => manager.melt(ccc.Transaction.default(), [group])).toThrow( + WRONG_MANAGER_ERROR, + ); + }); + + it("rejects duplicated or already-spent melt inputs", () => { + const manager = new OrderManager(ORDER_SCRIPT, [], UDT_SCRIPT); + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + outPoint: { txHash: byte32FromByte("5a"), index: 0n }, + }); + const master = MasterCell.from(masterCell()); + const group = new OrderGroup(master, order, order); + const tx = ccc.Transaction.default(); + tx.addInput(order.cell); + + expect(() => manager.melt(ccc.Transaction.default(), [group, group])).toThrow( + `Melt order ${order.cell.outPoint.toHex()} is duplicated`, + ); + expect(() => manager.melt(tx, [group])).toThrow( + `Melt order ${order.cell.outPoint.toHex()} is already being spent`, + ); + }); +} + +function registerMatcherConstructorValidationTests(): void { + it("rejects negative order matcher constructor values", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + outPoint: { txHash: byte32FromByte("59"), index: 0n }, + }); + const constructMatcher = (): OrderMatcher => + new OrderMatcher(order, true, 1n, 1n, -1n, 0n, 0n, 0n, 0n, 0n, 1n, 1n); + + expect(constructMatcher).toThrow("OrderMatcher aIn must be non-negative"); + }); +} + +describe(ORDER_MATCHER_SUITE, () => { + it("matches an exhaustive cross-product on a bounded pool", () => { + const orders = [ + makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(90), + udtValue: ccc.fixedPointFrom(40), + info: dualInfo(), + master: { + type: "absolute", + value: { txHash: byte32FromByte("33"), index: 1n }, + }, + outPoint: { txHash: byte32FromByte("47"), index: 0n }, + }), + makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(60), + udtValue: ccc.fixedPointFrom(80), + info: dualInfo(), + master: { + type: "absolute", + value: { txHash: byte32FromByte("34"), index: 1n }, + }, + outPoint: { txHash: byte32FromByte("48"), index: 0n }, + }), + makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(30), + udtValue: ccc.fixedPointFrom(120), + info: dualInfo(), + master: { + type: "absolute", + value: { txHash: byte32FromByte("35"), index: 1n }, + }, + outPoint: { txHash: byte32FromByte("49"), index: 0n }, + }), + ]; + const allowance = { + ckbValue: ccc.fixedPointFrom(160), + udtValue: ccc.fixedPointFrom(120), + }; + const exchangeRate = { ckbScale: 1n, udtScale: 1n }; + const options = { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(50), + maxPartials: 3, + }; + + expect( + matchKey(OrderManager.bestMatch(orders, allowance, exchangeRate, options)), + ).toEqual( + matchKey(exhaustiveSequentialBestMatch(orders, allowance, exchangeRate, options)), + ); + }); +}); + +function masterCell(): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("66"), index: 1n }, + cellOutput: { capacity: 61n, lock: OWNER_LOCK, type: ORDER_SCRIPT }, + outputData: "0x", + }); +} + +function script(byte: string): ccc.Script { + return ccc.Script.from({ + codeHash: byte32FromByte(byte), + hashType: "type", + args: "0x", + }); +} + +class UnobservableOutputTransaction extends ccc.Transaction { + constructor() { + super(0n, [], [], [], [], [], []); + } + + public override addOutput(_cellLike: ccc.CellAnyLike): number; + public override addOutput( + _outputLike: ccc.CellOutputLike, + _outputDataLike?: ccc.BytesLike | null, + ): number; + public override addOutput(): number { + return 0; + } +} diff --git a/packages/order/test/matching/order_matcher_best_match.ts b/packages/order/test/matching/order_matcher_best_match.ts new file mode 100644 index 0000000..3ca00aa --- /dev/null +++ b/packages/order/test/matching/order_matcher_best_match.ts @@ -0,0 +1,429 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte } from "@ickb/testkit"; +import { describe, expect, it } from "vitest"; +import type { BestMatchContext } from "../../src/matching/order_match_context.ts"; +import { searchBestMatch } from "../../src/matching/order_match_search.ts"; +import type { OrderCell } from "../../src/model/cells.ts"; +import { Info } from "../../src/model/info.ts"; +import { OrderManager, OrderMatcher, type Match } from "../../src/order.ts"; +import { ORDER_MATCHER_SUITE } from "../fixtures/order_constants.ts"; +import { makeOrderCell } from "./support/order_order_helpers.ts"; + +describe(ORDER_MATCHER_SUITE, () => { + it("steps CKB-to-UDT orders in the UDT the matcher spends", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100_000), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 200_000n }, 0), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("51"), + index: 0n, + }, + }); + const ckbStep = ccc.fixedPointFrom(1000); + const udtStep = ckbStep / 100_000n; + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: 0n, + udtValue: udtStep, + }, + { + ckbScale: 1n, + udtScale: 100_000n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ckbStep, + }, + ); + + expect(match.partials).toHaveLength(1); + expect(match.ckbDelta).toBeGreaterThan(0n); + expect(match.udtDelta).toBeLessThan(0n); + expect(-match.udtDelta).toBeLessThanOrEqual(udtStep); + expect(match.diagnostics?.ckbAllowanceStep).toBe(ckbStep); + expect(match.diagnostics?.udtAllowanceStep).toBe(udtStep); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("steps UDT-to-CKB orders in the CKB the matcher spends", () => { + const order = makeOrderCell({ + ckbUnoccupied: 0n, + udtValue: ccc.fixedPointFrom(100_000), + info: Info.create(false, { ckbScale: 200_000n, udtScale: 1n }, 0), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("52"), + index: 0n, + }, + }); + const ckbAllowance = ccc.fixedPointFrom(1) / 100n; + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: ckbAllowance, + udtValue: 0n, + }, + { + ckbScale: 100_000n, + udtScale: 1n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1000), + }, + ); + + expect(match.partials).toHaveLength(1); + expect(match.ckbDelta).toBeLessThan(0n); + expect(match.udtDelta).toBeGreaterThan(0n); + expect(-match.ckbDelta).toBeLessThanOrEqual(ckbAllowance); + expect(match.diagnostics?.ckbAllowanceStep).toBe(ccc.fixedPointFrom(1000)); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("leaves fee room when probing a below-step CKB allowance", () => { + const order = makeOrderCell({ + ckbUnoccupied: 0n, + udtValue: ccc.fixedPointFrom(100_000), + info: Info.create(false, { ckbScale: 200_000n, udtScale: 1n }, 0), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("53"), + index: 0n, + }, + }); + const ckbAllowance = ccc.fixedPointFrom(1) / 100n; + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: ckbAllowance, + udtValue: 0n, + }, + { + ckbScale: 100_000n, + udtScale: 1n, + }, + { + feeRate: 1000n, + ckbAllowanceStep: ccc.fixedPointFrom(1000), + }, + ); + + expect(match.partials).toHaveLength(1); + expect(-match.ckbDelta + (match.diagnostics?.ckbMiningFee ?? 0n)).toBeLessThanOrEqual( + ckbAllowance, + ); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("uses CKB gained from one side to probe a below-step match on the other side", () => { + const ckbToUdt = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(50), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 50n }, 0), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("54"), + index: 0n, + }, + }); + const udtToCkb = makeOrderCell({ + ckbUnoccupied: 0n, + udtValue: ccc.fixedPointFrom(100_000), + info: Info.create(false, { ckbScale: 3n, udtScale: 1n }, 0), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("34"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("55"), + index: 0n, + }, + }); + const initialUdt = ccc.fixedPointFrom(1); + + const match = OrderManager.bestMatch( + [ckbToUdt, udtToCkb], + { + ckbValue: 0n, + udtValue: initialUdt, + }, + { + ckbScale: 1n, + udtScale: 1n, + }, + { + feeRate: 1000n, + ckbAllowanceStep: ccc.fixedPointFrom(1000), + }, + ); + + const fee = match.diagnostics?.ckbMiningFee ?? 0n; + expect(match.partials.map((partial) => partial.order.cell.outPoint.toHex())).toEqual([ + ckbToUdt.cell.outPoint.toHex(), + udtToCkb.cell.outPoint.toHex(), + ]); + expect(match.ckbDelta - fee * BigInt(match.partials.length)).toBeGreaterThanOrEqual( + 0n, + ); + expect(initialUdt + match.udtDelta).toBeGreaterThanOrEqual(0n); + expect(match.udtDelta).toBeGreaterThan(0n); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("uses UDT gained from one side to probe a below-step match on the other side", () => { + const udtToCkb = makeOrderCell({ + ckbUnoccupied: 0n, + udtValue: ccc.fixedPointFrom(50), + info: Info.create(false, { ckbScale: 50n, udtScale: 1n }, 0), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("56"), + index: 0n, + }, + }); + const ckbToUdt = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100_000), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 3n }, 0), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("34"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("57"), + index: 0n, + }, + }); + const initialCkb = ccc.fixedPointFrom(1); + + const match = OrderManager.bestMatch( + [udtToCkb, ckbToUdt], + { + ckbValue: initialCkb, + udtValue: 0n, + }, + { + ckbScale: 1n, + udtScale: 1n, + }, + { + feeRate: 1000n, + ckbAllowanceStep: ccc.fixedPointFrom(1000), + }, + ); + + const fee = match.diagnostics?.ckbMiningFee ?? 0n; + expect(match.partials.map((partial) => partial.order.cell.outPoint.toHex())).toEqual([ + ckbToUdt.cell.outPoint.toHex(), + udtToCkb.cell.outPoint.toHex(), + ]); + expect( + initialCkb + match.ckbDelta - fee * BigInt(match.partials.length), + ).toBeGreaterThanOrEqual(0n); + expect(match.udtDelta).toBeGreaterThanOrEqual(0n); + expect(match.ckbDelta).toBeGreaterThan(0n); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("continues probing budget extensions after an empty first probe", () => { + const emptyProbe = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100_000), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }, 40), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("35"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("58"), + index: 0n, + }, + }); + const ckbToUdt = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100_000), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 3n }, 0), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("36"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("59"), + index: 0n, + }, + }); + const udtToCkb = makeOrderCell({ + ckbUnoccupied: 0n, + udtValue: ccc.fixedPointFrom(50), + info: Info.create(false, { ckbScale: 50n, udtScale: 1n }, 0), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("37"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("5a"), + index: 0n, + }, + }); + + const match = OrderManager.bestMatch( + [emptyProbe, ckbToUdt, udtToCkb], + { + ckbValue: ccc.fixedPointFrom(1), + udtValue: 0n, + }, + { + ckbScale: 1n, + udtScale: 1n, + }, + { + feeRate: 1000n, + ckbAllowanceStep: ccc.fixedPointFrom(1000), + }, + ); + + expect(match.partials.map((partial) => partial.order.cell.outPoint.toHex())).toEqual([ + ckbToUdt.cell.outPoint.toHex(), + udtToCkb.cell.outPoint.toHex(), + ]); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("skips empty budget-extension probes before trying the next matcher", () => { + const emptyProbe = budgetExtensionProbe("38", "5b"); + const filledProbe = budgetExtensionProbe("39", "5c"); + const emptyMatcher = budgetExtensionMatcher(emptyProbe); + const filledMatcher = budgetExtensionMatcher(filledProbe); + emptyMatcher.match = (): Match => ({ ckbDelta: 0n, udtDelta: 0n, partials: [] }); + filledMatcher.match = (): Match => ({ + ckbDelta: 2n, + udtDelta: -1n, + partials: [{ order: filledProbe, ckbOut: 1n, udtOut: 0n }], + }); + + const match = searchBestMatch(budgetExtensionContext(emptyMatcher, filledMatcher)); + + expect(match.partials.map((partial) => partial.order.cell.outPoint.toHex())).toEqual([ + filledProbe.cell.outPoint.toHex(), + ]); + }); +}); + +function budgetExtensionProbe(masterByte: string, outPointByte: string): OrderCell { + return makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(10), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }, 0), + master: { + type: "absolute", + value: { txHash: byte32FromByte(masterByte), index: 1n }, + }, + outPoint: { txHash: byte32FromByte(outPointByte), index: 0n }, + }); +} + +function budgetExtensionMatcher(order: OrderCell): OrderMatcher { + return new OrderMatcher(order, true, 1n, 1n, 0n, 0n, 0n, 0n, 1n, 0n, 1n, 1n); +} + +function budgetExtensionContext( + emptyMatcher: OrderMatcher, + filledMatcher: OrderMatcher, +): BestMatchContext { + return { + allowance: { ckbValue: 0n, udtValue: 1n }, + ckbAllowanceStep: 10n, + ckbMiningFee: 0n, + ckbScale: 1n, + ckbToUdtMatchers: [emptyMatcher, filledMatcher], + diagnostics: budgetExtensionDiagnostics(), + udtAllowanceStep: 10n, + udtScale: 1n, + udtToCkbMatchers: [], + }; +} + +function budgetExtensionDiagnostics(): BestMatchContext["diagnostics"] { + return { + orderCount: 2, + allowance: { ckbValue: 0n, udtValue: 1n }, + ckbAllowanceStep: 10n, + udtAllowanceStep: 10n, + ckbMiningFee: 0n, + directions: { + ckbToUdt: { matchableCount: 2 }, + udtToCkb: { matchableCount: 0 }, + }, + candidates: { + total: 0, + viable: 0, + positiveGain: 0, + rejected: { + maxPartials: 0, + duplicateOrder: 0, + insufficientCkbAllowance: 0, + insufficientUdtAllowance: 0, + nonPositiveGain: 0, + }, + bestGain: 0n, + }, + }; +} diff --git a/packages/order/test/matching/order_matcher_caps.ts b/packages/order/test/matching/order_matcher_caps.ts new file mode 100644 index 0000000..280fb95 --- /dev/null +++ b/packages/order/test/matching/order_matcher_caps.ts @@ -0,0 +1,56 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { OrderManager } from "../../src/order.ts"; +import { ORDER_MATCHER_SUITE } from "../fixtures/order_constants.ts"; +import { makeUdtToCkbOrder } from "./support/order_match_helpers.ts"; +describe(ORDER_MATCHER_SUITE, () => { + it("respects a partial cap when selecting the best match", () => { + const orders = [ + makeUdtToCkbOrder({ + txHashByte: "10", + orderTxHashByte: "20", + }), + makeUdtToCkbOrder({ + txHashByte: "11", + orderTxHashByte: "21", + }), + ]; + + const uncapped = OrderManager.bestMatch( + orders, + { + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 0n, + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + const capped = OrderManager.bestMatch( + orders, + { + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 0n, + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + maxPartials: 1, + }, + ); + + expect(uncapped.partials).toHaveLength(2); + expect(capped.partials).toHaveLength(1); + expect(capped.ckbDelta).toBeLessThan(0n); + expect(capped.udtDelta).toBeGreaterThan(0n); + }); +}); diff --git a/packages/order/test/matching/order_matcher_conversion.ts b/packages/order/test/matching/order_matcher_conversion.ts new file mode 100644 index 0000000..3c50bff --- /dev/null +++ b/packages/order/test/matching/order_matcher_conversion.ts @@ -0,0 +1,307 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { orderMatchers } from "../../src/matching/order_match_sequence.ts"; +import { Info } from "../../src/model/info.ts"; +import { Ratio } from "../../src/model/ratio.ts"; +import { + OrderConversionRepresentabilityError, + OrderManager, + OrderMatcher, +} from "../../src/order.ts"; +import { + ORDER_MATCHER_SUITE, + RATIO_SCALE_EXCEEDS_UINT64, +} from "../fixtures/order_constants.ts"; +import { + exactAdjustedConversion, + fullMatchOutput, + makeUdtToCkbOrder, +} from "./support/order_match_helpers.ts"; +import { byte32FromByte, makeOrderCell } from "./support/order_order_helpers.ts"; +describe(ORDER_MATCHER_SUITE, () => { + it("sorts effective ratios exactly beyond Number precision", () => { + const order = makeUdtToCkbOrder(); + const scale = 2n ** 60n; + const better = new OrderMatcher( + order, + true, + 1n, + 1n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + scale + 1n, + scale, + ); + const worse = new OrderMatcher( + order, + true, + 1n, + 1n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + scale, + scale, + ); + + expect(Number(scale + 1n) / Number(scale)).toBe(1); + expect(OrderMatcher.compareRealRatioDesc(better, worse)).toBeLessThan(0); + expect(OrderMatcher.compareRealRatioDesc(worse, better)).toBeGreaterThan(0); + }); + + it("drops unmatchable orders before returning sorted matchers", () => { + const validA = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { + type: "absolute", + value: { txHash: byte32FromByte("31"), index: 1n }, + }, + outPoint: { txHash: byte32FromByte("41"), index: 0n }, + }); + const invalidDirection = makeUdtToCkbOrder({ orderTxHashByte: "42" }); + const validB = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(50), + udtValue: 0n, + info: Info.create(true, { ckbScale: 2n, udtScale: 1n }), + master: { + type: "absolute", + value: { txHash: byte32FromByte("32"), index: 1n }, + }, + outPoint: { txHash: byte32FromByte("43"), index: 0n }, + }); + + const matchers = orderMatchers([validA, invalidDirection, validB], true, 0n); + + expect(matchers.map((matcher) => matcher.order.cell.outPoint.toHex())).not.toContain( + invalidDirection.cell.outPoint.toHex(), + ); + expect(matchers).toHaveLength(2); + const [firstMatcher, secondMatcher] = matchers; + if (firstMatcher === undefined || secondMatcher === undefined) { + throw new Error("Expected two matchers"); + } + expect( + OrderMatcher.compareRealRatioDesc(firstMatcher, secondMatcher), + ).toBeLessThanOrEqual(0); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("reports UDT-to-CKB fee in CKB units", () => { + const result = OrderManager.convert( + false, + Ratio.from({ ckbScale: 2n, udtScale: 1n }), + { ckbValue: 0n, udtValue: 100n }, + { fee: 1n, feeBase: 10n }, + ); + + expect(result.convertedAmount).toBe(45n); + expect(result.ckbFee).toBe(5n); + expect(fullMatchOutput(false, result.info, { ckbValue: 0n, udtValue: 100n })).toBe( + 45n, + ); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("encodes CKB-to-UDT quotes through a Uint64 ratio that preserves the full fill", () => { + const midpoint = Ratio.from({ + ckbScale: (1n << 64n) - 1n, + udtScale: (1n << 64n) - 2n, + }); + const amounts = { ckbValue: 1000000n, udtValue: 0n }; + const result = OrderManager.convert(true, midpoint, amounts, { + fee: 1n, + feeBase: 100000n, + }); + + expect(() => midpoint.applyFee(true, 1n, 100000n)).toThrow( + RATIO_SCALE_EXCEEDS_UINT64, + ); + expect(result.convertedAmount).toBe( + exactAdjustedConversion(true, midpoint, amounts.ckbValue, 1n, 100000n), + ); + expect(result.info.ckbToUdt.ckbScale).toBeLessThanOrEqual((1n << 64n) - 1n); + expect(result.info.ckbToUdt.udtScale).toBeLessThanOrEqual((1n << 64n) - 1n); + expect(fullMatchOutput(true, result.info, amounts)).toBe(result.convertedAmount); + }); + + it("encodes UDT-to-CKB quotes through a Uint64 ratio that preserves the full fill", () => { + const midpoint = Ratio.from({ + ckbScale: 10000000000000000n, + udtScale: 11850413696044750n, + }); + const amounts = { ckbValue: 0n, udtValue: 1000000n }; + const fee = 1185042n; + const feeBase = 1185043n; + const result = OrderManager.convert(false, midpoint, amounts, { + fee, + feeBase, + }); + + expect(() => midpoint.applyFee(false, fee, feeBase)).toThrow( + RATIO_SCALE_EXCEEDS_UINT64, + ); + expect(result.convertedAmount).toBe( + exactAdjustedConversion(false, midpoint, amounts.udtValue, fee, feeBase), + ); + expect(result.info.udtToCkb.ckbScale).toBeLessThanOrEqual((1n << 64n) - 1n); + expect(result.info.udtToCkb.udtScale).toBeLessThanOrEqual((1n << 64n) - 1n); + expect(fullMatchOutput(false, result.info, amounts)).toBe(result.convertedAmount); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("rejects zero-input quotes because they cannot mint a meaningful order ratio", () => { + expect(() => + OrderManager.convert( + true, + Ratio.from({ ckbScale: 1n, udtScale: 1n }), + { ckbValue: 0n, udtValue: 0n }, + { fee: 0n }, + ), + ).toThrow(OrderConversionRepresentabilityError); + }); + + it("rejects quotes whose preserving interval has no Uint64 ratio", () => { + expect(() => + OrderManager.convert( + true, + Ratio.from({ ckbScale: 1n, udtScale: 1n << 80n }), + { ckbValue: 2n ** 80n, udtValue: 0n }, + { fee: 0n }, + ), + ).toThrow(OrderConversionRepresentabilityError); + }); + + it("rejects preserving fractions whose terms cannot advance", () => { + expect(() => + OrderManager.convert( + true, + Ratio.from({ ckbScale: 1n, udtScale: 1n << 96n }), + { ckbValue: 1n << 96n, udtValue: 0n }, + { fee: 0n }, + ), + ).toThrow(OrderConversionRepresentabilityError); + }); + + it("rejects preserving fractions whose bounded clamp cannot advance", () => { + const maxUint64 = (1n << 64n) - 1n; + + expect(() => + OrderManager.convert( + true, + Ratio.from({ ckbScale: maxUint64 * 2n + 1n, udtScale: 2n }), + { ckbValue: 2n, udtValue: 0n }, + { fee: 0n }, + ), + ).toThrow(OrderConversionRepresentabilityError); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("uses udtToCkb scales for UDT-to-CKB orders", () => { + const order = makeUdtToCkbOrder(); + + const matcher = OrderMatcher.from(order, false, 0n); + + if (matcher === undefined) { + throw new Error("Expected UDT-to-CKB order to be matchable"); + } + expect(OrderMatcher.from(order, true, 0n)).toBeUndefined(); + expect(matcher.aScale).toBe(2n); + expect(matcher.bScale).toBe(5n); + expect(matcher.bMaxMatch).toBeGreaterThan(0n); + }); + + it("exposes the non-decreasing rounded output helper", () => { + expect(OrderMatcher.nonDecreasing(3n, 2n, 10n, 1n, 7n)).toBe(6n); + }); + + it("rejects invalid direct matcher construction", () => { + const order = makeUdtToCkbOrder(); + + expect( + () => new OrderMatcher(order, true, 0n, 1n, 0n, 0n, 0n, 0n, 0n, 0n, 1n, 1n), + ).toThrow("OrderMatcher scales must be positive"); + expect( + () => new OrderMatcher(order, true, 1n, 1n, 0n, 0n, 0n, 2n, 1n, 0n, 1n, 1n), + ).toThrow("OrderMatcher maximum match must be at least the minimum match"); + expect( + () => new OrderMatcher(order, true, 1n, 1n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 1n), + ).toThrow("OrderMatcher real ratio terms must be positive"); + }); + + it("rejects orders whose fee leaves no spendable input", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }, 0), + master: { + type: "absolute", + value: { + txHash: "0x3333333333333333333333333333333333333333333333333333333333333333", + index: 1n, + }, + }, + outPoint: { + txHash: "0x4545454545454545454545454545454545454545454545454545454545454545", + index: 0n, + }, + }); + + expect(OrderMatcher.from(order, true, ccc.fixedPointFrom(2))).toBeUndefined(); + }); + + it("rejects matcher parameters with a non-positive effective denominator", () => { + const order = makeUdtToCkbOrder(); + + expect(OrderMatcher.from(order, false, -ccc.fixedPointFrom(100))).toBeUndefined(); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("lets bestMatch consume UDT-to-CKB orders", () => { + const order = makeUdtToCkbOrder(); + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: ccc.fixedPointFrom(200), + udtValue: 0n, + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + + expect(match.partials).toHaveLength(1); + expect(match.ckbDelta).toBeLessThan(0n); + expect(match.udtDelta).toBeGreaterThan(0n); + expect(match.diagnostics).toMatchObject({ + orderCount: 1, + directions: { + ckbToUdt: { matchableCount: 0 }, + udtToCkb: { matchableCount: 1 }, + }, + candidates: { + rejected: { nonPositiveGain: 0 }, + }, + }); + expect(match.diagnostics?.candidates.positiveGain).toBeGreaterThan(0); + }); +}); diff --git a/packages/order/test/matching/order_matcher_diagnostics.ts b/packages/order/test/matching/order_matcher_diagnostics.ts new file mode 100644 index 0000000..4f0852b --- /dev/null +++ b/packages/order/test/matching/order_matcher_diagnostics.ts @@ -0,0 +1,275 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte } from "@ickb/testkit"; +import { describe, expect, it } from "vitest"; +import { Info } from "../../src/model/info.ts"; +import { OrderManager } from "../../src/order.ts"; +import { ORDER_MATCHER_SUITE } from "../fixtures/order_constants.ts"; +import { + exhaustiveSequentialBestMatch, + makeUdtToCkbOrder, + matchKey, +} from "./support/order_match_helpers.ts"; +import { dualInfo, makeOrderCell } from "./support/order_order_helpers.ts"; +describe(ORDER_MATCHER_SUITE, () => { + it("charges one mining fee unit per selected partial", () => { + const order = makeUdtToCkbOrder(); + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: ccc.fixedPointFrom(60), + udtValue: 0n, + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: 1000n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + + expect(match.partials).toHaveLength(1); + expect(match.ckbDelta).toBe(-ccc.fixedPointFrom(40)); + }); + + it("ignores matches whose estimated mining fee exceeds the value gain", () => { + const order = makeUdtToCkbOrder(); + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 0n, + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: ccc.fixedPointFrom(1000), + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + + expect(match).toMatchObject({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + diagnostics: { + orderCount: 1, + directions: { + ckbToUdt: { matchableCount: 0 }, + udtToCkb: { matchableCount: 1 }, + }, + candidates: { + bestGain: 0n, + positiveGain: 0, + }, + }, + }); + expect(match.diagnostics?.candidates.rejected.nonPositiveGain).toBeGreaterThan(0); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("does not select non-positive candidates after rejecting the empty allowance", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(200), + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("55"), + index: 0n, + }, + }); + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: -1n, + udtValue: ccc.fixedPointFrom(1000), + }, + { + ckbScale: 1n, + udtScale: 1n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + + expect(match).toMatchObject({ + ckbDelta: 0n, + udtDelta: 0n, + partials: [], + diagnostics: { + candidates: { + bestGain: 0n, + positiveGain: 0, + }, + }, + }); + expect( + match.diagnostics?.candidates.rejected.insufficientCkbAllowance, + ).toBeGreaterThan(0); + expect(match.diagnostics?.candidates.rejected.nonPositiveGain).toBeGreaterThan(0); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("reports one primary allowance rejection reason per candidate", () => { + const order = makeUdtToCkbOrder(); + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: -ccc.fixedPointFrom(1000), + udtValue: -ccc.fixedPointFrom(1000), + }, + { + ckbScale: 3n, + udtScale: 5n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + + const rejected = match.diagnostics?.candidates.rejected; + expect(rejected?.insufficientCkbAllowance).toBeGreaterThan(0); + expect(rejected?.insufficientUdtAllowance).toBe(0); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("does not use the same order cell in both match directions", () => { + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: ccc.fixedPointFrom(50), + info: dualInfo(), + master: { + type: "absolute", + value: { + txHash: byte32FromByte("33"), + index: 1n, + }, + }, + outPoint: { + txHash: byte32FromByte("44"), + index: 0n, + }, + }); + + const match = OrderManager.bestMatch( + [order], + { + ckbValue: ccc.fixedPointFrom(50), + udtValue: ccc.fixedPointFrom(50), + }, + { + ckbScale: 2n, + udtScale: 1n, + }, + { + feeRate: 0n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }, + ); + + expect(match.partials.map((partial) => partial.order.cell.outPoint.toHex())).toEqual([ + order.cell.outPoint.toHex(), + ]); + }); +}); + +describe(ORDER_MATCHER_SUITE, () => { + it("rejects invalid best-match search parameters", () => { + const order = makeUdtToCkbOrder(); + const allowance = { + ckbValue: ccc.fixedPointFrom(50), + udtValue: ccc.fixedPointFrom(50), + }; + + expect(() => + OrderManager.bestMatch( + [order], + allowance, + { ckbScale: 0n, udtScale: 1n }, + { ckbAllowanceStep: ccc.fixedPointFrom(1) }, + ), + ).toThrow("Exchange rate scales must be positive"); + expect(() => + OrderManager.bestMatch( + [order], + allowance, + { ckbScale: 1n, udtScale: 0n }, + { ckbAllowanceStep: ccc.fixedPointFrom(1) }, + ), + ).toThrow("Exchange rate scales must be positive"); + expect(() => + OrderManager.bestMatch( + [order], + allowance, + { ckbScale: 1n, udtScale: 1n }, + { ckbAllowanceStep: 0n }, + ), + ).toThrow("CKB allowance step must be positive"); + expect(() => + OrderManager.bestMatch([order], allowance, { ckbScale: 1n, udtScale: 1n }), + ).not.toThrow(); + }); + + it("uses the largest order size when estimating per-partial mining fees", () => { + const smallOrder = makeUdtToCkbOrder({ + txHashByte: "40", + orderTxHashByte: "50", + }); + const largeOrder = makeUdtToCkbOrder({ + txHashByte: "41", + orderTxHashByte: "51", + lockArgs: `0x${"00".repeat(100)}`, + }); + const allowance = { + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 0n, + }; + const exchangeRate = { ckbScale: 3n, udtScale: 5n }; + const options = { + feeRate: 1000n, + ckbAllowanceStep: ccc.fixedPointFrom(1), + }; + + expect(largeOrder.cell.occupiedSize).toBeGreaterThan(smallOrder.cell.occupiedSize); + + expect( + matchKey( + OrderManager.bestMatch( + [smallOrder, largeOrder], + allowance, + exchangeRate, + options, + ), + ), + ).toEqual( + matchKey( + exhaustiveSequentialBestMatch( + [smallOrder, largeOrder], + allowance, + exchangeRate, + options, + ), + ), + ); + }); +}); diff --git a/packages/order/test/matching/support/order_match_helpers.ts b/packages/order/test/matching/support/order_match_helpers.ts new file mode 100644 index 0000000..d0707e3 --- /dev/null +++ b/packages/order/test/matching/support/order_match_helpers.ts @@ -0,0 +1,187 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte } from "@ickb/testkit"; +import { OrderCell } from "../../../src/model/cells.ts"; +import { Info } from "../../../src/model/info.ts"; +import { OrderData } from "../../../src/model/order_data.ts"; +import { Ratio } from "../../../src/model/ratio.ts"; +import { OrderManager, OrderMatcher, type Match } from "../../../src/order.ts"; +import { makeOrderCell } from "./order_order_helpers.ts"; + +type ExactAdjustedConversionArgs = [ + isCkb2Udt: boolean, + ratio: Ratio, + amount: ccc.FixedPoint, + fee: bigint, + feeBase: bigint, +]; + +export function exactAdjustedConversion( + ...[isCkb2Udt, ratio, amount, fee, feeBase]: ExactAdjustedConversionArgs +): ccc.FixedPoint { + let { ckbScale: aScale, udtScale: bScale } = ratio; + if (!isCkb2Udt) { + [aScale, bScale] = [bScale, aScale]; + } + aScale *= feeBase - fee; + bScale *= feeBase; + const divisor = ccc.gcd(aScale, bScale); + aScale /= divisor; + bScale /= divisor; + return (amount * aScale + bScale - 1n) / bScale; +} + +export function fullMatchOutput( + isCkb2Udt: boolean, + info: Info, + amounts: { ckbValue: bigint; udtValue: bigint }, +): bigint { + const order = makeOrderCell({ + ckbUnoccupied: amounts.ckbValue, + udtValue: amounts.udtValue, + info, + master: { + type: "absolute", + value: { txHash: byte32FromByte("77"), index: 1n }, + }, + outPoint: { txHash: byte32FromByte("78"), index: 0n }, + }); + const matcher = OrderMatcher.from(order, isCkb2Udt, 0n); + if (matcher === undefined) { + throw new Error("Expected order matcher"); + } + return matcher.bMaxMatch; +} + +export function makeUdtToCkbOrder(options?: { + txHashByte?: string; + orderTxHashByte?: string; + udtValue?: ccc.FixedPoint; + lockArgs?: ccc.Hex; +}): OrderCell { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: options?.lockArgs ?? "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + + return OrderCell.mustFrom( + ccc.Cell.from({ + outPoint: { + txHash: byte32FromByte(options?.orderTxHashByte ?? "44"), + index: 0n, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(200), + lock: orderScript, + type: udtScript, + }, + outputData: OrderData.from({ + udtValue: options?.udtValue ?? ccc.fixedPointFrom(100), + master: { + type: "absolute", + value: { + txHash: byte32FromByte(options?.txHashByte ?? "33"), + index: 1n, + }, + }, + info: Info.from({ + ckbToUdt: Ratio.empty(), + udtToCkb: Ratio.from({ + ckbScale: 5n, + udtScale: 2n, + }), + ckbMinMatchLog: 0, + }), + }).toBytes(), + }), + ); +} +export function exhaustiveSequentialBestMatch( + orderPool: OrderCell[], + allowance: { ckbValue: bigint; udtValue: bigint }, + exchangeRate: { ckbScale: bigint; udtScale: bigint }, + options: { + feeRate: bigint; + ckbAllowanceStep: bigint; + maxPartials?: number; + }, +): Match { + const orderSize = orderPool.reduce( + (maxSize, order) => Math.max(maxSize, order.cell.occupiedSize), + 0, + ); + const ckbMiningFee = (ccc.numFrom(36 + orderSize) * options.feeRate + 999n) / 1000n; + const udtAllowanceStep = + (options.ckbAllowanceStep * exchangeRate.ckbScale + exchangeRate.udtScale - 1n) / + exchangeRate.udtScale; + let best: Match = { ckbDelta: 0n, udtDelta: 0n, partials: [] }; + let bestGain = 0n; + for (const c2u of OrderManager.sequentialMatcher( + orderPool, + true, + udtAllowanceStep, + ckbMiningFee, + )) { + for (const u2c of OrderManager.sequentialMatcher( + orderPool, + false, + options.ckbAllowanceStep, + ckbMiningFee, + )) { + const partials = c2u.partials.concat(u2c.partials); + if (options.maxPartials !== undefined && partials.length > options.maxPartials) { + continue; + } + if (!hasUniquePartialOrderOutPoints(partials)) { + continue; + } + + const ckbDelta = c2u.ckbDelta + u2c.ckbDelta; + const udtDelta = c2u.udtDelta + u2c.udtDelta; + const ckbFee = ckbMiningFee * BigInt(partials.length); + const ckbAllowance = allowance.ckbValue + ckbDelta - ckbFee; + const udtAllowance = allowance.udtValue + udtDelta; + const gain = + (ckbDelta - ckbFee) * exchangeRate.ckbScale + udtDelta * exchangeRate.udtScale; + + if (ckbAllowance >= 0n && udtAllowance >= 0n && gain > bestGain) { + best = { ckbDelta, udtDelta, partials }; + bestGain = gain; + } + } + } + return best; +} + +function hasUniquePartialOrderOutPoints(partials: Match["partials"]): boolean { + const seen = new Set(); + for (const partial of partials) { + const key = partial.order.cell.outPoint.toHex(); + if (seen.has(key)) { + return false; + } + seen.add(key); + } + return true; +} + +export function matchKey(match: Match): { + ckbDelta: bigint; + udtDelta: bigint; + partials: Array<{ outPoint: ccc.Hex; ckbOut: bigint; udtOut: bigint }>; +} { + return { + ckbDelta: match.ckbDelta, + udtDelta: match.udtDelta, + partials: match.partials.map((partial) => ({ + outPoint: partial.order.cell.outPoint.toHex(), + ckbOut: partial.ckbOut, + udtOut: partial.udtOut, + })), + }; +} diff --git a/packages/order/test/matching/support/order_order_helpers.ts b/packages/order/test/matching/support/order_order_helpers.ts new file mode 100644 index 0000000..032bfa9 --- /dev/null +++ b/packages/order/test/matching/support/order_order_helpers.ts @@ -0,0 +1,107 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte } from "@ickb/testkit"; +import { OrderCell } from "../../../src/model/cells.ts"; +import { Info } from "../../../src/model/info.ts"; +import { OrderData } from "../../../src/model/order_data.ts"; +import { Ratio } from "../../../src/model/ratio.ts"; + +export { byte32FromByte } from "@ickb/testkit"; + +export function directionalInfo(): Info { + return Info.from({ + ckbToUdt: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + udtToCkb: Ratio.empty(), + ckbMinMatchLog: 0, + }); +} + +export function dualInfo(): Info { + const ratio = Ratio.from({ ckbScale: 1n, udtScale: 1n }); + return Info.from({ + ckbToUdt: ratio, + udtToCkb: ratio, + ckbMinMatchLog: 0, + }); +} + +export function absoluteOrderCell(options: { + master: { txHash: `0x${string}`; index: bigint }; + outPointByte: string; + info: Info; + lock?: ccc.Script; +}): OrderCell { + return makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: options.info, + master: { type: "absolute", value: options.master }, + lock: options.lock, + outPoint: { txHash: byte32FromByte(options.outPointByte), index: 0n }, + }); +} + +export function makeOrderCell(options: { + ckbUnoccupied: ccc.FixedPoint; + udtValue: ccc.FixedPoint; + info: Info; + lock?: ccc.Script; + master: + | { + type: "relative"; + value: { + padding: Uint8Array; + distance: bigint; + }; + } + | { + type: "absolute"; + value: { + txHash: `0x${string}`; + index: bigint; + }; + }; + outPoint: { + txHash: `0x${string}`; + index: bigint; + }; +}): OrderCell { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const lock = options.lock ?? orderScript; + const outputData = OrderData.from({ + udtValue: options.udtValue, + master: options.master, + info: options.info, + }).toBytes(); + const minimalCell = ccc.Cell.from({ + previousOutput: { + txHash: byte32FromByte("ff"), + index: 0n, + }, + cellOutput: { + lock, + type: udtScript, + }, + outputData, + }); + + return OrderCell.mustFrom( + ccc.Cell.from({ + outPoint: options.outPoint, + cellOutput: { + capacity: minimalCell.cellOutput.capacity + options.ckbUnoccupied, + lock, + type: udtScript, + }, + outputData, + }), + ); +} diff --git a/packages/order/test/order_conversion_io.ts b/packages/order/test/order_conversion_io.ts new file mode 100644 index 0000000..65b3174 --- /dev/null +++ b/packages/order/test/order_conversion_io.ts @@ -0,0 +1,52 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, script } from "@ickb/testkit"; +import { describe, expect, it } from "vitest"; +import { cellInputLike, cellOutputLike } from "../src/io/order_io.ts"; +import { OrderConversionRepresentabilityError } from "../src/matching/order_conversion.ts"; +import { Info } from "../src/model/info.ts"; +import { OrderManager } from "../src/order.ts"; +import { makeOrderCell } from "./matching/support/order_order_helpers.ts"; + +describe("order conversion and I/O", () => { + it("converts order I/O shapes and rejects unrepresentable quotes", () => { + const cell = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 10n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 1n } }, + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + }).cell; + const ownerLock = script("33"); + + expect(cellInputLike(cell)).toEqual({ + outPoint: cell.outPoint, + cellOutput: cellOutputLike(cell.cellOutput), + outputData: cell.outputData, + }); + expect(cellOutputLike(cell.cellOutput).type).toEqual(cell.cellOutput.type); + expect( + cellOutputLike(ccc.CellOutput.from({ capacity: 1n, lock: ownerLock })).type, + ).toBeNull(); + expect(() => { + OrderManager.convert( + true, + { ckbScale: 1n, udtScale: 1n }, + { ckbValue: -1n, udtValue: 0n }, + ); + }).toThrow("Order conversion amounts cannot be negative"); + expect(() => { + OrderManager.convert( + true, + { ckbScale: 1n, udtScale: 1n }, + { ckbValue: 0n, udtValue: 0n }, + ); + }).toThrow(OrderConversionRepresentabilityError); + expect(() => { + OrderManager.convert( + true, + { ckbScale: 1n << 80n, udtScale: 1n }, + { ckbValue: 1n, udtValue: 0n }, + ); + }).toThrow(OrderConversionRepresentabilityError); + }); +}); diff --git a/packages/order/test/order_entities.ts b/packages/order/test/order_entities.ts new file mode 100644 index 0000000..6e3311a --- /dev/null +++ b/packages/order/test/order_entities.ts @@ -0,0 +1,313 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { MasterCell, OrderCell, OrderGroup } from "../src/model/cells.ts"; +import { Info } from "../src/model/info.ts"; +import { OrderData } from "../src/model/order_data.ts"; +import { Ratio } from "../src/model/ratio.ts"; +import { Relative } from "../src/model/relative.ts"; + +const ORDER_SCRIPT = script("11"); +const UDT_SCRIPT = script("22"); +const OWNER_LOCK = script("33"); + +describe("order entity validation", () => { + it("validates relative pointers and order data", () => { + const relative = Relative.create(1n); + const invalidRelative = Relative.from({ padding: new Uint8Array([1]), distance: 1n }); + const info = Info.create(true, { ckbScale: 1n, udtScale: 1n }); + const data = OrderData.from({ + udtValue: 10n, + master: { type: "relative", value: relative }, + info, + }); + + expect(relative.isValid()).toBe(true); + expect(invalidRelative.isValid()).toBe(false); + expect(data.isValid()).toBe(true); + expect(data.isMint()).toBe(true); + expect( + data.getMaster(ccc.OutPoint.from({ txHash: byte32("44"), index: 1n })).index, + ).toBe(2n); + expect(() => { + invalidRelative.validate(); + }).toThrow("Relative master invalid"); + expect(() => { + OrderData.from({ + udtValue: -1n, + master: { type: "relative", value: relative }, + info, + }).validate(); + }).toThrow("udtValue invalid"); + const invalidOutPoint = ccc.OutPoint.from({ txHash: byte32("77"), index: 0n }); + invalidOutPoint.index = -1n; + expect(() => { + new OrderData(1n, { type: "absolute", value: invalidOutPoint }, info).validate(); + }).toThrow("OutPoint invalid"); + }); + + it("validates order info combinations and comparisons", () => { + const ckbToUdt = Ratio.from({ ckbScale: 3n, udtScale: 1n }); + const udtToCkb = Ratio.from({ ckbScale: 3n, udtScale: 1n }); + const dual = Info.from({ ckbToUdt, udtToCkb, ckbMinMatchLog: 2 }); + + expect(dual.isValid()).toBe(true); + expect(dual.isDualRatio()).toBe(true); + expect(dual.getCkbMinMatch()).toBe(4n); + expect(dual.ckb2UdtCompare(Info.create(true, { ckbScale: 4n, udtScale: 1n }))).toBe( + -1, + ); + expect(dual.udt2CkbCompare(Info.create(false, { ckbScale: 2n, udtScale: 1n }))).toBe( + -1, + ); + expect(() => { + Info.from({ ckbToUdt, udtToCkb, ckbMinMatchLog: 65 }).validate(); + }).toThrow("ckbMinMatchLog invalid"); + expect(() => { + Info.from({ + ckbToUdt: Ratio.empty(), + udtToCkb: Ratio.empty(), + ckbMinMatchLog: 0, + }).validate(); + }).toThrow("ckbToUdt is Empty"); + expect(() => { + Info.from({ + ckbToUdt: Ratio.from({ ckbScale: 1n, udtScale: 0n }), + udtToCkb: Ratio.empty(), + ckbMinMatchLog: 0, + }).validate(); + }).toThrow("udtToCkb is Empty"); + expect(() => { + Info.from({ + ckbToUdt: Ratio.from({ ckbScale: 1n, udtScale: 0n }), + udtToCkb: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + ckbMinMatchLog: 0, + }).validate(); + }).toThrow("One ratio is invalid"); + expect(() => { + Info.from({ + ckbToUdt: Ratio.from({ ckbScale: 1n, udtScale: 10n }), + udtToCkb: Ratio.from({ ckbScale: 1n, udtScale: 1n }), + ckbMinMatchLog: 0, + }).validate(); + }).toThrow("allow order value to be extracted"); + }); +}); + +describe("order cells", () => { + it("exposes order and master values, matchability, and ownership", () => { + const order = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + }); + const fulfilled = orderCell({ + ckbValue: 0n, + udtValue: 0n, + info: Info.create(true, { ckbScale: 1n, udtScale: 1n }), + }); + const master = MasterCell.from(masterCell()); + const group = new OrderGroup(master, order, order); + + expect(order.ckbValue).toBe(order.cell.cellOutput.capacity); + expect(order.udtValue).toBe(10n); + expect(order.isDualRatio()).toBe(false); + expect(order.isMatchable()).toBe(true); + expect(order.isFulfilled()).toBe(false); + expect(fulfilled.isMatchable()).toBe(false); + expect(fulfilled.isFulfilled()).toBe(true); + expect(master.ckbValue).toBe(master.cell.cellOutput.capacity); + expect(group.ckbValue).toBe( + order.cell.cellOutput.capacity + master.cell.cellOutput.capacity, + ); + expect(group.udtValue).toBe(order.data.udtValue); + expect(group.isOwner(OWNER_LOCK)).toBe(true); + expect(group.isOwner(script("99"))).toBe(false); + }); +}); + +describe("order descendant identity", () => { + it("validates descendant identity invariants", () => { + const origin = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + outPointIndex: 0n, + }); + const same = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + outPointIndex: 0n, + }); + const wrongLock = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + lock: script("99"), + outPointIndex: 1n, + }); + const wrongType = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + udtScript: script("98"), + outPointIndex: 1n, + }); + const wrongMaster = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + masterIndex: 2n, + outPointIndex: 1n, + }); + + expect(() => { + origin.validate(same); + }).not.toThrow(); + expect(() => { + origin.validate(wrongLock); + }).toThrow("Order script different"); + expect(() => { + origin.validate(wrongType); + }).toThrow("UDT type is different"); + expect(() => { + origin.validate(wrongMaster); + }).toThrow("Master is different"); + expect(origin.isValid(wrongLock)).toBe(false); + expect(origin.resolve([wrongLock])).toBeUndefined(); + }); +}); + +describe("order descendant values", () => { + it("validates descendant value invariants", () => { + const origin = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + outPointIndex: 0n, + }); + const wrongInfo = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + info: Info.create(true, { ckbScale: 2n, udtScale: 1n }), + outPointIndex: 1n, + }); + const lowerTotal = orderCell({ + ckbValue: ccc.fixedPointFrom(500), + udtValue: 10n, + outPointIndex: 1n, + }); + const udtInfo = Info.create(false, { ckbScale: 1n, udtScale: 1n }); + const udtOrigin = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + info: udtInfo, + outPointIndex: 3n, + }); + const lowerProgress = orderCell({ + ckbValue: ccc.fixedPointFrom(500), + udtValue: ccc.fixedPointFrom(1000), + info: udtInfo, + outPointIndex: 4n, + }); + + expect(() => { + origin.validate(wrongInfo); + }).toThrow("Info is different"); + expect(() => { + origin.validate(lowerTotal); + }).toThrow("Total value is lower"); + expect(() => { + udtOrigin.validate(lowerProgress); + }).toThrow("Progress is lower"); + }); +}); + +describe("order descendant resolution", () => { + it("resolves to the better descendant", () => { + const origin = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + outPointIndex: 0n, + }); + const better = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: ccc.fixedPointFrom(20), + outPointIndex: 5n, + }); + const worse = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: ccc.fixedPointFrom(10), + outPointIndex: 6n, + }); + + expect(origin.resolve([better, worse])).toBe(better); + }); +}); + +describe("order groups", () => { + it("validates master cells and order groups", () => { + const origin = orderCell({ + ckbValue: ccc.fixedPointFrom(1000), + udtValue: 10n, + outPointIndex: 0n, + }); + + const master = MasterCell.from(masterCell()); + expect(OrderGroup.tryFrom(master, origin, origin)?.isValid()).toBe(true); + expect( + OrderGroup.tryFrom( + new MasterCell(masterCell({ type: script("99") })), + origin, + origin, + ), + ).toBeUndefined(); + expect(() => { + new MasterCell(masterCell({ type: script("99") })).validate(origin); + }).toThrow("Order script different"); + expect(() => { + new MasterCell(masterCell({ index: 2n })).validate(origin); + }).toThrow("Master is different"); + }); +}); + +function orderCell(options: { + ckbValue: bigint; + udtValue: bigint; + info?: Info; + lock?: ccc.Script; + udtScript?: ccc.Script; + masterIndex?: bigint; + outPointIndex?: bigint; +}): OrderCell { + const info = options.info ?? Info.create(true, { ckbScale: 1n, udtScale: 1n }); + const index = options.outPointIndex ?? 0n; + const masterIndex = options.masterIndex ?? 1n; + const cell = ccc.Cell.from({ + outPoint: { txHash: byte32("55"), index }, + cellOutput: { + capacity: options.ckbValue, + lock: options.lock ?? ORDER_SCRIPT, + type: options.udtScript ?? UDT_SCRIPT, + }, + outputData: OrderData.from({ + udtValue: options.udtValue, + master: { + type: "absolute", + value: { txHash: byte32("66"), index: masterIndex }, + }, + info, + }).toBytes(), + }); + return OrderCell.mustFrom(cell); +} + +function masterCell(options?: { index?: bigint; type?: ccc.Script }): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32("66"), index: options?.index ?? 1n }, + cellOutput: { capacity: 61n, lock: OWNER_LOCK, type: options?.type ?? ORDER_SCRIPT }, + outputData: "0x", + }); +} + +function script(byte: string): ccc.Script { + return ccc.Script.from({ codeHash: byte32(byte), hashType: "type", args: "0x" }); +} + +function byte32(byte: string): `0x${string}` { + return `0x${byte.repeat(32)}`; +} diff --git a/packages/order/test/order_ratio.ts b/packages/order/test/order_ratio.ts new file mode 100644 index 0000000..ad76960 --- /dev/null +++ b/packages/order/test/order_ratio.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { Ratio } from "../src/model/ratio.ts"; +import { OrderManager } from "../src/order.ts"; +import { RATIO_SCALE_EXCEEDS_UINT64 } from "./fixtures/order_constants.ts"; + +const FEE_TOO_BIG = "Fee too big relative to feeBase"; + +describe("Ratio", () => { + it("compares ratios exactly beyond Number precision", () => { + const scale = 2n ** 60n; + const larger = Ratio.from({ ckbScale: scale + 1n, udtScale: scale }); + const smaller = Ratio.from({ ckbScale: scale, udtScale: scale }); + + expect(Number((scale + 1n) * scale - scale * scale)).toBe(Number(scale)); + expect(larger.compare(smaller)).toBe(1); + expect(smaller.compare(larger)).toBe(-1); + }); + + it("rejects nonzero fee application to empty ratios", () => { + expect(() => Ratio.empty().applyFee(true, 1n, 2n)).toThrow("Invalid ExchangeRatio"); + }); + + it("keeps zero-fee application as the original ratio", () => { + const empty = Ratio.empty(); + + expect(empty.applyFee(true, 0n, 2n)).toBe(empty); + }); + + it("rejects fee-adjusted ratios that do not fit Uint64 exactly", () => { + expect(() => + Ratio.from({ ckbScale: 2n ** 64n + 1n, udtScale: 1n }).applyFee(true, 1n, 2n), + ).toThrow(RATIO_SCALE_EXCEEDS_UINT64); + expect(() => + Ratio.from({ ckbScale: 2n ** 65n, udtScale: 1n }).applyFee(true, 1n, 2n), + ).toThrow(RATIO_SCALE_EXCEEDS_UINT64); + }); + + it("rejects invalid fee policy before conversion", () => { + const ratio = Ratio.from({ ckbScale: 1n, udtScale: 1n }); + const amounts = { ckbValue: 1n, udtValue: 0n }; + + expect(() => + OrderManager.convert(true, ratio, amounts, { fee: 1n, feeBase: 1n }), + ).toThrow(FEE_TOO_BIG); + expect(() => + OrderManager.convert(true, ratio, amounts, { fee: 2n, feeBase: 1n }), + ).toThrow(FEE_TOO_BIG); + expect(() => + OrderManager.convert(true, ratio, amounts, { fee: 0n, feeBase: 0n }), + ).toThrow("Fee base must be positive"); + expect(() => + OrderManager.convert(true, ratio, amounts, { fee: -1n, feeBase: 1n }), + ).toThrow("Fee cannot be negative"); + }); + + it("keeps exactly representable fee-adjusted ratios", () => { + expect( + Ratio.from({ ckbScale: (1n << 64n) - 1n, udtScale: 1n }).applyFee(true, 1n, 2n), + ).toEqual(Ratio.from({ ckbScale: (1n << 64n) - 1n, udtScale: 2n })); + }); + + it("validates populated and invalid ratios", () => { + const populated = Ratio.from({ ckbScale: 1n, udtScale: 2n }); + const invalid = Ratio.from({ ckbScale: 1n, udtScale: 0n }); + + expect(populated.isValid()).toBe(true); + expect(invalid.isValid()).toBe(false); + expect(() => { + invalid.validate(); + }).toThrow("Ratio invalid"); + }); + + it("compares matching CKB scales and converts with direction and ceiling", () => { + const ratio = Ratio.from({ ckbScale: 2n, udtScale: 3n }); + + expect(Ratio.from({ ckbScale: 2n, udtScale: 4n }).compare(ratio)).toBe(-1); + expect(ratio.compare(Ratio.from({ ckbScale: 2n, udtScale: 4n }))).toBe(1); + expect(ratio.convert(true, 7n, false)).toBe(4n); + expect(ratio.convert(true, 7n, true)).toBe(5n); + expect(ratio.convert(false, 7n, false)).toBe(10n); + expect(() => Ratio.empty().convert(true, 1n, false)).toThrow( + "Invalid midpoint ExchangeRatio", + ); + }); + + it("covers reverse fee adjustment and zero conversion", () => { + const ratio = Ratio.from({ ckbScale: 2n, udtScale: 3n }); + + expect(ratio.applyFee(false, 1n, 2n)).toEqual( + Ratio.from({ ckbScale: 4n, udtScale: 3n }), + ); + expect(() => ratio.applyFee(true, 1n, 1n)).toThrow(FEE_TOO_BIG); + expect(ratio.convert(true, 0n, true)).toBe(0n); + expect(Ratio.from({ ckbScale: 3n, udtScale: 2n }).compare(ratio)).toBe(1); + }); +}); diff --git a/packages/order/test/scan/order_find_orders_cache.ts b/packages/order/test/scan/order_find_orders_cache.ts new file mode 100644 index 0000000..fab4851 --- /dev/null +++ b/packages/order/test/scan/order_find_orders_cache.ts @@ -0,0 +1,385 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, StubClient } from "@ickb/testkit"; +import { describe, expect, it } from "vitest"; +import { Info } from "../../src/model/info.ts"; +import { OrderData } from "../../src/model/order_data.ts"; +import { Relative } from "../../src/model/relative.ts"; +import { + ORDER_MANAGER_FIND_ORDERS_SUITE, + type FindCellsOnChainQuery, + type FindCellsOnChainReturn, + type GetTransactionReturn, +} from "../fixtures/order_constants.ts"; +import { + directionalInfo, + makeOrderCell, +} from "../matching/support/order_order_helpers.ts"; +import { + collectOrders, + collectSkippedOrders, + findOrdersFixture, + masterCell, + originLookupClient, + transactionResponse, + transactionWithOutputs, +} from "./support/order_scan_helpers.ts"; + +const MISSING_ORIGIN = "missing-origin"; + +describe(ORDER_MANAGER_FIND_ORDERS_SUITE, () => { + it("findOrigin skips parseable non-mint origins in the master transaction", async () => { + const { manager, orderScript, ownerLock } = findOrdersFixture(); + const originMaster = { txHash: byte32FromByte("55"), index: 2n }; + const forgedOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(200), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 1n }, + }); + const trueOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(2n), + }, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("56"), index: 0n }, + }); + const liveMaster = masterCell(originMaster, orderScript, ownerLock); + const tx = transactionWithOutputs([trueOrigin.cell, forgedOrigin.cell, liveMaster]); + const client = originLookupClient({ + liveOrder: liveOrder.cell, + liveMaster, + originMasterTxHash: originMaster.txHash, + originTransaction: tx, + }); + + expect(trueOrigin.data.master.type).toBe("relative"); + if (trueOrigin.data.master.type !== "relative") { + throw new Error("Expected relative master"); + } + expect(trueOrigin.data.master.value.distance).toBe(2n); + expect(trueOrigin.getMaster().eq(originMaster)).toBe(true); + const groups = await collectOrders(manager, client); + + expect(groups).toHaveLength(1); + expect(groups[0]?.origin.cell.outPoint.eq(trueOrigin.cell.outPoint)).toBe(true); + }); + + it("round-trips non-zero relative master distances", () => { + const encoded = OrderData.from({ + udtValue: 0n, + master: { + type: "relative", + value: Relative.create(2n), + }, + info: directionalInfo(), + }).toBytes(); + + const decoded = OrderData.decode(encoded); + + expect(decoded.master.type).toBe("relative"); + if (decoded.master.type !== "relative") { + throw new Error("Expected relative master"); + } + expect(decoded.master.value.distance).toBe(2n); + }); +}); + +describe(ORDER_MANAGER_FIND_ORDERS_SUITE, () => { + it("findOrigin requires a minted origin in the master transaction", async () => { + const { manager, orderScript, ownerLock } = findOrdersFixture(); + const originMaster = { txHash: byte32FromByte("65"), index: 1n }; + const fakeOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("67"), index: 0n }, + }); + const liveMaster = masterCell(originMaster, orderScript, ownerLock); + const tx = transactionWithOutputs([fakeOrigin.cell, liveMaster]); + const client = originLookupClient({ + liveOrder: liveOrder.cell, + liveMaster, + originMasterTxHash: originMaster.txHash, + originTransaction: tx, + }); + + const { groups, skippedReasons } = await collectSkippedOrders(manager, client); + + expect(groups).toHaveLength(0); + expect(skippedReasons).toEqual([MISSING_ORIGIN]); + }); + + it("uses cached origin transactions before fetching from the client", async () => { + const { manager, orderScript, ownerLock } = findOrdersFixture(); + const originMaster = { txHash: byte32FromByte("69"), index: 1n }; + const { origin, liveOrder } = linkedOriginAndOrder({ + originMaster, + orderScript, + liveOrderByte: "70", + }); + const liveMaster = masterCell(originMaster, orderScript, ownerLock); + const cachedResponse = transactionResponse( + transactionWithOutputs([origin.cell, liveMaster]), + ); + let fetched = false; + const client = new StubClient({ + cache: new TransactionResponseCache(originMaster.txHash, cachedResponse), + async *findCellsOnChain(query: FindCellsOnChainQuery): FindCellsOnChainReturn { + await Promise.resolve(); + if (query.scriptType === "lock") { + yield liveOrder.cell; + } else { + yield liveMaster; + } + }, + getTransaction: async (): GetTransactionReturn => { + fetched = true; + await Promise.resolve(); + return undefined; + }, + }); + + const groups = await collectOrders(manager, client); + + expect(groups).toHaveLength(1); + expect(fetched).toBe(false); + }); +}); + +class TransactionResponseCache extends ccc.ClientCacheMemory { + private readonly txHash: ccc.Hex; + private readonly response: ccc.ClientTransactionResponse; + + constructor(txHash: ccc.Hex, response: ccc.ClientTransactionResponse) { + super(); + this.txHash = txHash; + this.response = response; + } + + public override async getTransactionResponse( + txHash: ccc.HexLike, + ): Promise { + await Promise.resolve(); + return ccc.hexFrom(txHash) === this.txHash ? this.response.clone() : undefined; + } + + public override async recordTransactionResponses(): Promise { + await Promise.resolve(); + throw new Error("Should not record cached response"); + } +} + +describe(ORDER_MANAGER_FIND_ORDERS_SUITE, () => { + it("findOrigin fails closed for multiple minted origins in the master transaction", async () => { + const { manager, orderScript, ownerLock } = findOrdersFixture(); + const originMaster = { txHash: byte32FromByte("66"), index: 2n }; + const firstOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(2n), + }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const secondOrigin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 1n }, + }); + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("68"), index: 0n }, + }); + const liveMaster = masterCell(originMaster, orderScript, ownerLock); + const tx = transactionWithOutputs([firstOrigin.cell, secondOrigin.cell, liveMaster]); + const client = originLookupClient({ + liveOrder: liveOrder.cell, + liveMaster, + originMasterTxHash: originMaster.txHash, + originTransaction: tx, + }); + + expect(firstOrigin.getMaster().eq(originMaster)).toBe(true); + expect(secondOrigin.getMaster().eq(originMaster)).toBe(true); + const { groups, skippedReasons } = await collectSkippedOrders(manager, client); + + expect(groups).toHaveLength(0); + expect(skippedReasons).toEqual(["ambiguous-origin"]); + }); +}); + +describe(ORDER_MANAGER_FIND_ORDERS_SUITE, () => { + it("reports a missing origin when the master transaction cannot be loaded", async () => { + const { manager, orderScript, ownerLock } = findOrdersFixture(); + const originMaster = { txHash: byte32FromByte("61"), index: 1n }; + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("62"), index: 0n }, + }); + const liveMaster = masterCell(originMaster, orderScript, ownerLock); + const client = originLookupClient({ + liveOrder: liveOrder.cell, + liveMaster, + originMasterTxHash: byte32FromByte("ff"), + originTransaction: ccc.Transaction.default(), + }); + + const { groups, skippedReasons } = await collectSkippedOrders(manager, client); + + expect(groups).toHaveLength(0); + expect(skippedReasons).toEqual([MISSING_ORIGIN]); + }); + + it("reports ambiguous descendant orders separately from missing descendants", async () => { + const { manager, orderScript, ownerLock } = findOrdersFixture(); + const originMaster = { txHash: byte32FromByte("63"), index: 1n }; + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "relative", value: Relative.create(1n) }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const firstOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("64"), index: 0n }, + }); + const secondOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("64"), index: 1n }, + }); + const liveMaster = masterCell(originMaster, orderScript, ownerLock); + const tx = transactionWithOutputs([origin.cell, liveMaster]); + const client = new StubClient({ + cache: new ccc.ClientCacheMemory(), + async *findCellsOnChain(query: FindCellsOnChainQuery): FindCellsOnChainReturn { + await Promise.resolve(); + if (query.scriptType === "lock") { + yield firstOrder.cell; + yield secondOrder.cell; + } else { + yield liveMaster; + } + }, + getTransaction: async (): GetTransactionReturn => { + await Promise.resolve(); + return transactionResponse(tx); + }, + }); + + const { groups, skippedReasons } = await collectSkippedOrders(manager, client); + + expect(groups).toHaveLength(0); + expect(skippedReasons).toEqual(["ambiguous-order"]); + }); +}); + +describe(ORDER_MANAGER_FIND_ORDERS_SUITE, () => { + it("reports missing descendant orders when no scanned order validates", async () => { + const { manager, orderScript, ownerLock } = findOrdersFixture(); + const originMaster = { txHash: byte32FromByte("6c"), index: 1n }; + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "relative", value: Relative.create(1n) }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: Info.create(true, { ckbScale: 2n, udtScale: 1n }, 0), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("6d"), index: 0n }, + }); + const liveMaster = masterCell(originMaster, orderScript, ownerLock); + const client = originLookupClient({ + liveOrder: liveOrder.cell, + liveMaster, + originMasterTxHash: originMaster.txHash, + originTransaction: transactionWithOutputs([origin.cell, liveMaster]), + }); + + const { groups, skippedReasons } = await collectSkippedOrders(manager, client); + + expect(groups).toHaveLength(0); + expect(skippedReasons).toEqual(["missing-order"]); + }); +}); + +function linkedOriginAndOrder(options: { + originMaster: { txHash: `0x${string}`; index: bigint }; + orderScript: ccc.Script; + liveOrderByte: string; +}): { + origin: ReturnType; + liveOrder: ReturnType; +} { + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "relative", value: Relative.create(1n) }, + lock: options.orderScript, + outPoint: { txHash: options.originMaster.txHash, index: 0n }, + }); + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: options.originMaster }, + lock: options.orderScript, + outPoint: { txHash: byte32FromByte(options.liveOrderByte), index: 0n }, + }); + return { origin, liveOrder }; +} diff --git a/packages/order/test/scan/order_find_orders_missing_master.ts b/packages/order/test/scan/order_find_orders_missing_master.ts new file mode 100644 index 0000000..6c7d0a6 --- /dev/null +++ b/packages/order/test/scan/order_find_orders_missing_master.ts @@ -0,0 +1,43 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, StubClient } from "@ickb/testkit"; +import { describe, expect, it } from "vitest"; +import { + directionalInfo, + makeOrderCell, +} from "../matching/support/order_order_helpers.ts"; +import { + collectSkippedOrders, + findOrdersFixture, + transactionResponse, +} from "./support/order_scan_helpers.ts"; + +describe("OrderManager.findOrders missing master", () => { + it("reports order cells whose master was not found", async () => { + const { manager, orderScript } = findOrdersFixture(); + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(1000), + udtValue: 10n, + info: directionalInfo(), + lock: orderScript, + master: { type: "absolute", value: { txHash: byte32FromByte("66"), index: 9n } }, + outPoint: { txHash: byte32FromByte("55"), index: 0n }, + }); + const client = new StubClient({ + async *findCellsOnChain(query): ReturnType { + await Promise.resolve(); + if (query.scriptType === "lock") { + yield order.cell; + } + }, + getTransaction: async (): ReturnType => { + await Promise.resolve(); + return transactionResponse(ccc.Transaction.default()); + }, + }); + + const { groups, skippedReasons } = await collectSkippedOrders(manager, client); + + expect(groups).toEqual([]); + expect(skippedReasons).toEqual(["missing-master"]); + }); +}); diff --git a/packages/order/test/scan/order_find_orders_scan.ts b/packages/order/test/scan/order_find_orders_scan.ts new file mode 100644 index 0000000..9efd4b2 --- /dev/null +++ b/packages/order/test/scan/order_find_orders_scan.ts @@ -0,0 +1,228 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, StubClient } from "@ickb/testkit"; +import { defaultCellPageSize } from "@ickb/utils"; +import { describe, expect, it } from "vitest"; +import { Relative } from "../../src/model/relative.ts"; +import { OrderManager } from "../../src/order.ts"; +import { + mustPageSize, + NO_CELLS, + ORDER_CELL_RESOLVE_SUITE, + ORDER_MANAGER_FIND_ORDERS_SUITE, + type FindCellsOnChainLimit, + type FindCellsOnChainOrder, + type FindCellsOnChainQuery, + type FindCellsOnChainReturn, + type GetTransactionHash, + type GetTransactionReturn, +} from "../fixtures/order_constants.ts"; +import { + absoluteOrderCell, + directionalInfo, + makeOrderCell, +} from "../matching/support/order_order_helpers.ts"; +import { + collectOrders, + dummyCell, + findOrdersFixture, + masterCell, + transactionResponse, + transactionWithOutputs, +} from "./support/order_scan_helpers.ts"; +describe(ORDER_CELL_RESOLVE_SUITE, () => { + it("fails closed for ambiguous equal-progress non-mint candidates", () => { + const master = { + txHash: byte32FromByte("bb"), + index: 10n, + }; + const info = directionalInfo(); + const origin = absoluteOrderCell({ master, info, outPointByte: "44" }); + const nonMint = absoluteOrderCell({ master, info, outPointByte: "cc" }); + const otherNonMint = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { + type: "absolute", + value: { + txHash: master.txHash, + index: master.index, + }, + }, + outPoint: { txHash: byte32FromByte("dd"), index: 0n }, + }); + + expect(origin.resolve([nonMint, otherNonMint])).toBeUndefined(); + expect(origin.resolve([otherNonMint, nonMint])).toBeUndefined(); + }); + + it("prefers a mint candidate over an equal-progress non-mint candidate", () => { + const master = { + txHash: byte32FromByte("bc"), + index: 10n, + }; + const info = directionalInfo(); + const origin = absoluteOrderCell({ master, info, outPointByte: "44" }); + const nonMint = absoluteOrderCell({ master, info, outPointByte: "ce" }); + const mint = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { + type: "relative", + value: Relative.create(1n), + }, + outPoint: { txHash: master.txHash, index: 9n }, + }); + + expect(mint.getMaster().eq(origin.getMaster())).toBe(true); + expect(origin.resolve([nonMint, mint])).toBe(mint); + expect(origin.resolve([mint, nonMint])).toBe(mint); + }); +}); + +describe(ORDER_CELL_RESOLVE_SUITE, () => { + it("does not treat duplicate same-outpoint candidates as ambiguous", () => { + const master = { + txHash: byte32FromByte("bd"), + index: 10n, + }; + const info = directionalInfo(); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("44"), index: 0n }, + }); + const duplicate = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info, + master: { type: "absolute", value: master }, + outPoint: { txHash: byte32FromByte("cf"), index: 0n }, + }); + + expect(origin.resolve([duplicate, duplicate])).toBe(duplicate); + }); +}); + +describe(ORDER_MANAGER_FIND_ORDERS_SUITE, () => { + it("passes the default page size to order scanning", async () => { + await expectDefaultPageSizeScan("lock"); + }); + + it("passes the default page size to master scanning", async () => { + await expectDefaultPageSizeScan("type"); + }); +}); + +async function expectDefaultPageSizeScan( + scriptType: FindCellsOnChainQuery["scriptType"], +): Promise { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const manager = new OrderManager(orderScript, [], udtScript); + let requestedPageSize = 0; + const client = new StubClient({ + async *findCellsOnChain( + query: FindCellsOnChainQuery, + _order: FindCellsOnChainOrder, + pageSize: FindCellsOnChainLimit, + ): FindCellsOnChainReturn { + await Promise.resolve(); + if (query.scriptType !== scriptType) { + return; + } + requestedPageSize = mustPageSize(pageSize); + yield* NO_CELLS; + }, + }); + + await expect(collectOrders(manager, client)).resolves.toEqual([]); + expect(requestedPageSize).toBe(defaultCellPageSize); +} + +describe(ORDER_MANAGER_FIND_ORDERS_SUITE, () => { + it("accepts exact page-size order and master scans", async () => { + const { manager, orderScript, ownerLock, udtScript } = findOrdersFixture(); + const master = ccc.OutPoint.from({ + txHash: byte32FromByte("36"), + index: 1n, + }); + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { + type: "relative", + value: Relative.create(1n), + }, + lock: orderScript, + outPoint: { txHash: master.txHash, index: 0n }, + }); + const order = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: master }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("37"), index: 0n }, + }); + const liveMaster = masterCell(master, orderScript, ownerLock); + const tx = transactionWithOutputs([origin.cell, liveMaster]); + const client = new StubClient({ + cache: new ccc.ClientCacheMemory(), + async *findCellsOnChain(query: FindCellsOnChainQuery): FindCellsOnChainReturn { + await Promise.resolve(); + if (query.scriptType === "lock") { + for (let index = 0; index < defaultCellPageSize; index += 1) { + yield index === 0 ? order.cell : dummyCell("38", orderScript, udtScript); + } + return; + } + + for (let index = 0; index < defaultCellPageSize; index += 1) { + yield index === 0 ? liveMaster : dummyCell("39", ownerLock, orderScript); + } + }, + getTransaction: async (txHash: GetTransactionHash): GetTransactionReturn => { + await Promise.resolve(); + return txHash === master.txHash ? transactionResponse(tx) : undefined; + }, + }); + + const groups = await collectOrders(manager, client); + + expect(groups).toHaveLength(1); + }); + + it("uses cached scans when onChain is false", async () => { + const { manager, orderScript, ownerLock, udtScript } = findOrdersFixture(); + let cachedScans = 0; + const client = new StubClient({ + async *findCells(query: FindCellsOnChainQuery): FindCellsOnChainReturn { + cachedScans += 1; + await Promise.resolve(); + yield dummyCell( + query.scriptType === "lock" ? "71" : "72", + query.scriptType === "lock" ? orderScript : ownerLock, + udtScript, + ); + }, + }); + + await expect( + collectOrders(manager, client, { onChain: false, pageSize: 2 }), + ).resolves.toEqual([]); + expect(cachedScans).toBe(2); + }); +}); diff --git a/packages/order/test/scan/order_resolve_group.ts b/packages/order/test/scan/order_resolve_group.ts new file mode 100644 index 0000000..0677f9e --- /dev/null +++ b/packages/order/test/scan/order_resolve_group.ts @@ -0,0 +1,97 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, StubClient } from "@ickb/testkit"; +import { describe, expect, it } from "vitest"; +import { resolveOrderGroup } from "../../src/io/order_scan.ts"; +import { MasterCell } from "../../src/model/cells.ts"; +import { Relative } from "../../src/model/relative.ts"; +import type { GetTransactionReturn } from "../fixtures/order_constants.ts"; +import { + absoluteOrderCell, + directionalInfo, + makeOrderCell, +} from "../matching/support/order_order_helpers.ts"; +import { + findOrdersFixture, + masterCell, + transactionResponse, + transactionWithOutputs, +} from "./support/order_scan_helpers.ts"; + +const MISSING_ORIGIN = "missing-origin"; + +describe("resolveOrderGroup", () => { + it("reports invalid groups after resolving a descendant", async () => { + const { orderScript, ownerLock } = findOrdersFixture(); + const originMaster = { txHash: byte32FromByte("6a"), index: 1n }; + const origin = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "relative", value: Relative.create(1n) }, + lock: orderScript, + outPoint: { txHash: originMaster.txHash, index: 0n }, + }); + const liveOrder = absoluteOrderCell({ + master: originMaster, + outPointByte: "6b", + info: directionalInfo(), + lock: orderScript, + }); + const invalidMaster = new MasterCell( + masterCell( + originMaster, + ccc.Script.from({ + codeHash: orderScript.codeHash, + hashType: orderScript.hashType, + args: "0x01", + }), + ownerLock, + ), + ); + const tx = transactionWithOutputs([origin.cell, invalidMaster.cell]); + const client = new StubClient({ + cache: new ccc.ClientCacheMemory(), + getTransaction: async (): GetTransactionReturn => { + await Promise.resolve(); + return transactionResponse(tx); + }, + }); + + const result = await resolveOrderGroup(client, invalidMaster, [liveOrder], (cell) => + cell.cellOutput.lock.eq(orderScript), + ); + + expect(result).toEqual({ ok: false, reason: "invalid-group" }); + }); + + it("ignores missing transaction outputs while locating an origin", async () => { + const { orderScript, ownerLock } = findOrdersFixture(); + const originMaster = { txHash: byte32FromByte("6e"), index: 1n }; + const liveOrder = makeOrderCell({ + ckbUnoccupied: ccc.fixedPointFrom(100), + udtValue: 0n, + info: directionalInfo(), + master: { type: "absolute", value: originMaster }, + lock: orderScript, + outPoint: { txHash: byte32FromByte("6f"), index: 0n }, + }); + const master = new MasterCell(masterCell(originMaster, orderScript, ownerLock)); + const tx = transactionWithOutputs([liveOrder.cell]); + const response = transactionResponse(tx); + response.transaction.getOutput = (): ReturnType => + undefined; + const client = new StubClient({ + cache: new ccc.ClientCacheMemory(), + getTransaction: async (): GetTransactionReturn => { + await Promise.resolve(); + return response; + }, + }); + + const result = await resolveOrderGroup(client, master, [liveOrder], (cell) => + cell.cellOutput.lock.eq(orderScript), + ); + + expect(result).toEqual({ ok: false, reason: MISSING_ORIGIN }); + }); +}); diff --git a/packages/order/test/scan/support/order_scan_helpers.ts b/packages/order/test/scan/support/order_scan_helpers.ts new file mode 100644 index 0000000..e58c321 --- /dev/null +++ b/packages/order/test/scan/support/order_scan_helpers.ts @@ -0,0 +1,142 @@ +import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte, committedTransactionResponse, StubClient } from "@ickb/testkit"; +import type { OrderGroup } from "../../../src/model/cells.ts"; +import { OrderManager } from "../../../src/order.ts"; +import type { + FindCellsOnChainQuery, + FindCellsOnChainReturn, + GetTransactionHash, + GetTransactionReturn, +} from "../../fixtures/order_constants.ts"; + +type FindOrdersOptions = Parameters[1]; + +export async function collectOrders( + manager: OrderManager, + client: ccc.Client, + options?: FindOrdersOptions, +): Promise { + const groups: OrderGroup[] = []; + for await (const group of manager.findOrders(client, options)) { + groups.push(group); + } + return groups; +} + +export interface FindOrdersFixture { + manager: OrderManager; + orderScript: ccc.Script; + ownerLock: ccc.Script; + udtScript: ccc.Script; +} + +export function findOrdersFixture(): FindOrdersFixture { + const orderScript = ccc.Script.from({ + codeHash: byte32FromByte("11"), + hashType: "type", + args: "0x", + }); + const udtScript = ccc.Script.from({ + codeHash: byte32FromByte("22"), + hashType: "type", + args: "0x", + }); + const ownerLock = ccc.Script.from({ + codeHash: byte32FromByte("44"), + hashType: "type", + args: "0x", + }); + return { + manager: new OrderManager(orderScript, [], udtScript), + orderScript, + ownerLock, + udtScript, + }; +} + +export function masterCell( + outPoint: ccc.OutPointLike, + orderScript: ccc.Script, + ownerLock: ccc.Script, +): ccc.Cell { + return ccc.Cell.from({ + outPoint, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock: ownerLock, + type: orderScript, + }, + outputData: "0x", + }); +} + +export function originLookupClient({ + liveOrder, + liveMaster, + originMasterTxHash, + originTransaction, +}: { + liveOrder: ccc.Cell; + liveMaster: ccc.Cell; + originMasterTxHash: ccc.Hex; + originTransaction: ccc.Transaction; +}): ccc.Client { + return new StubClient({ + cache: new ccc.ClientCacheMemory(), + async *findCellsOnChain(query: FindCellsOnChainQuery): FindCellsOnChainReturn { + await Promise.resolve(); + if (query.scriptType === "lock") { + yield liveOrder; + } else { + yield liveMaster; + } + }, + getTransaction: async (txHash: GetTransactionHash): GetTransactionReturn => { + await Promise.resolve(); + return txHash === originMasterTxHash + ? transactionResponse(originTransaction) + : undefined; + }, + }); +} + +export function transactionResponse(tx: ccc.Transaction): ccc.ClientTransactionResponse { + return committedTransactionResponse(tx); +} + +export function transactionWithOutputs(cells: ccc.Cell[]): ccc.Transaction { + const tx = ccc.Transaction.default(); + for (const cell of cells) { + tx.outputs.push(cell.cellOutput); + tx.outputsData.push(cell.outputData); + } + return tx; +} + +export function dummyCell(byte: string, lock: ccc.Script, type: ccc.Script): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte(byte), index: 0n }, + cellOutput: { + capacity: ccc.fixedPointFrom(61), + lock, + type, + }, + outputData: "0x", + }); +} + +export async function collectSkippedOrders( + manager: OrderManager, + client: ccc.Client, +): Promise<{ groups: OrderGroup[]; skippedReasons: string[] }> { + const skippedReasons: string[] = []; + const groups: OrderGroup[] = []; + for await (const group of manager.findOrders(client, { + onSkippedGroup: (reason) => { + skippedReasons.push(reason); + }, + })) { + groups.push(group); + } + return { groups, skippedReasons }; +} diff --git a/packages/order/tsconfig.build.json b/packages/order/tsconfig.build.json new file mode 100644 index 0000000..fd5ea4e --- /dev/null +++ b/packages/order/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "removeComments": false, + "rewriteRelativeImportExtensions": true, + "rootDir": "src", + "outDir": "dist", + "sourceRoot": "../src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*_test_*.ts"] +} diff --git a/packages/order/tsconfig.json b/packages/order/tsconfig.json index 84a843d..c6a53fe 100644 --- a/packages/order/tsconfig.json +++ b/packages/order/tsconfig.json @@ -1,11 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": false, - "rootDir": "src", - "outDir": "dist", - "sourceRoot": "../src" + "noEmit": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src", "test"] } diff --git a/packages/order/vitest.config.mts b/packages/order/vitest.config.mts index dc6a587..24a187d 100644 --- a/packages/order/vitest.config.mts +++ b/packages/order/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}", "test/{matching,scan}/*.{ts,tsx}"], coverage: { - include: ["src/**/*.ts"], + include: ["src/**/*.{ts,tsx}"], }, }, }); diff --git a/packages/sdk/src/constants.test.ts b/packages/sdk/src/constants.test.ts index ae20300..0fe31bf 100644 --- a/packages/sdk/src/constants.test.ts +++ b/packages/sdk/src/constants.test.ts @@ -62,7 +62,6 @@ describe("getConfig", () => { 0, false, ]); - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); expect(sdk).toBeInstanceOf(IckbSdk); const completed = await sdk.completeTransaction(tx, { diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 01d0131..441f734 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -53,12 +53,10 @@ const tip = headerLike(0n); function fakeIckbUdt(udt = script("66")): { isUdt: (cell: ccc.Cell) => boolean; - infoFrom: () => Promise; completeBy: (txLike: ccc.TransactionLike) => Promise; } { return { isUdt: (cell: ccc.Cell): boolean => cell.cellOutput.type?.eq(udt) ?? false, - infoFrom: () => Promise.resolve({ capacity: 0n, balance: 0n, count: 0 } as never), completeBy: async (txLike: ccc.TransactionLike): Promise => { await Promise.resolve(); return ccc.Transaction.from(txLike); @@ -736,8 +734,6 @@ describe("IckbSdk.buildBaseTransaction", () => { }); it("combines real manager transaction effects", async () => { - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); - const botLock = script("11"); const logic = script("22"); const dao = script("33"); @@ -1794,7 +1790,7 @@ describe("IckbSdk.buildConversionTransaction", () => { }); describe("completeIckbTransaction", () => { - it("runs UDT, fee, DAO-limit in order", async () => { + it("runs UDT completion before fee completion", async () => { const calls: string[] = []; const signer = {} as ccc.Signer; const client = {} as ccc.Client; @@ -1809,10 +1805,6 @@ describe("completeIckbTransaction", () => { calls.push("fee"); return Promise.resolve([0, false]); }); - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockImplementation(() => { - calls.push("dao-limit"); - return Promise.resolve(false); - }); const completed = await completeIckbTransaction(tx, ickbUdt, { signer, @@ -1821,7 +1813,7 @@ describe("completeIckbTransaction", () => { }); expect(completed).toBeInstanceOf(ccc.Transaction); - expect(calls).toEqual(["udt", "fee", "dao-limit"]); + expect(calls).toEqual(["udt", "fee"]); }); it("uses the provided fee rate", async () => { @@ -1830,7 +1822,6 @@ describe("completeIckbTransaction", () => { const completeFeeBy = vi .spyOn(ccc.Transaction.prototype, "completeFeeBy") .mockResolvedValue([0, false]); - vi.spyOn(ccc, "isDaoOutputLimitExceeded").mockResolvedValue(false); await completeIckbTransaction(ccc.Transaction.default(), fakeIckbUdt(), { signer, @@ -2793,7 +2784,7 @@ describe("IckbSdk.getAccountState", () => { const udtCell = ccc.Cell.from({ outPoint: { txHash: hash("90"), index: 0n }, cellOutput: { capacity: 7n, lock: accountLock, type: udt }, - outputData: "0x01", + outputData: ccc.numLeToBytes(11n, 16), }); const capacityCell = ccc.Cell.from({ outPoint: { txHash: hash("91"), index: 0n }, @@ -2804,11 +2795,6 @@ describe("IckbSdk.getAccountState", () => { const logicManager = new LogicManager(script("22"), [], daoManager); const ownedOwnerManager = new OwnedOwnerManager(script("44"), [], daoManager); const ickbUdt = fakeIckbUdt(udt); - vi.spyOn(ickbUdt, "infoFrom").mockResolvedValue({ - capacity: 7n, - balance: 11n, - count: 1, - } as never); vi.spyOn(logicManager, "findReceipts").mockImplementation(() => once(receipt)); vi.spyOn(ownedOwnerManager, "findWithdrawalGroups").mockImplementation(() => once(withdrawal)); const sdk = new IckbSdk( @@ -2830,11 +2816,10 @@ describe("IckbSdk.getAccountState", () => { expect(state.capacityCells).toEqual([capacityCell]); expect(state.nativeUdtCells).toEqual([udtCell]); - expect(state.nativeUdtCapacity).toBe(7n); + expect(state.nativeUdtCapacity).toBe(udtCell.cellOutput.capacity); expect(state.nativeUdtBalance).toBe(11n); expect(state.receipts).toEqual([receipt]); expect(state.withdrawalGroups).toEqual([withdrawal]); - expect(ickbUdt.infoFrom).toHaveBeenCalledWith(client, [udtCell]); }); it("uses a custom account cell scan page size", async () => { @@ -2893,7 +2878,7 @@ function depositCell( }); return { cell, - headers: [{ header: depositHeader }, { header: tipHeader }], + headers: [{ header: depositHeader, txHash: cell.outPoint.txHash }, { header: tipHeader }], interests: 0n, maturity: ccc.Epoch.from([1n, 0n, 1n]), isReady: options?.isReady ?? false, @@ -2993,7 +2978,7 @@ function readyWithdrawalGroup(options: { }); const owner = new OwnerCell( ccc.Cell.from({ - outPoint: { txHash: hash("76"), index: 0n }, + outPoint: { txHash: ownedCell.outPoint.txHash, index: 1n }, cellOutput: { capacity: ccc.fixedPointFrom(61), lock: options.ownerLock, @@ -3006,7 +2991,7 @@ function readyWithdrawalGroup(options: { cell: ownedCell, headers: [ { header: options.depositHeader }, - { header: options.withdrawalHeader }, + { header: options.withdrawalHeader, txHash: ownedCell.outPoint.txHash }, ], interests: 0n, maturity: ccc.Epoch.from([1n, 0n, 1n]), diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 09a7c5c..8bbab22 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -194,7 +194,7 @@ export class TransactionConfirmationError extends Error { } } -type IckbUdtCompleter = Pick; +type IckbUdtCompleter = Pick; /** * Completes a stack-built partial transaction with the iCKB post-processing @@ -1073,16 +1073,19 @@ export class IckbSdk { ), ]); const nativeUdtCells = cells.filter((cell) => this.ickbUdt.isUdt(cell)); - const nativeUdtInfo = await this.ickbUdt.infoFrom( - client, - nativeUdtCells, + const nativeUdt = nativeUdtCells.reduce( + (acc, cell) => ({ + capacity: acc.capacity + cell.cellOutput.capacity, + balance: acc.balance + ccc.udtBalanceFrom(cell.outputData), + }), + { capacity: 0n, balance: 0n }, ); return { capacityCells: cells.filter(isPlainCapacityCell), nativeUdtCells, - nativeUdtCapacity: nativeUdtInfo.capacity, - nativeUdtBalance: nativeUdtInfo.balance, + nativeUdtCapacity: nativeUdt.capacity, + nativeUdtBalance: nativeUdt.balance, receipts, withdrawalGroups, }; diff --git a/packages/utils/package.json b/packages/utils/package.json index d8ee24c..74098fb 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -23,7 +23,7 @@ "engines": { "node": ">=22.19.0" }, - "main": "dist/index.js", + "main": "src/index.ts", "types": "src/index.ts", "exports": { ".": { @@ -32,7 +32,7 @@ "import": "./dist/index.js" }, "types": "./src/index.ts", - "import": "./dist/index.js" + "import": "./src/index.ts" } }, "scripts": { diff --git a/packages/utils/src/codec.ts b/packages/utils/src/codec.ts index 27d6332..f8542a3 100644 --- a/packages/utils/src/codec.ts +++ b/packages/utils/src/codec.ts @@ -14,6 +14,9 @@ export const CheckedInt32LE = ccc.Codec.from({ byteLength: 4, encode: (numLike) => { const num = Number(numLike); + if (!Number.isInteger(num)) { + throw new TypeError("NumLike must be a finite integer"); + } if (num < -2147483648 || num > 2147483647) { throw new Error("NumLike out of int32 bounds"); } diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index 4434eda..d0a71f0 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -28,6 +28,10 @@ export async function collectPagedScan( pageSize: number; }, ): Promise { + if (!Number.isSafeInteger(options.pageSize) || options.pageSize <= 0) { + throw new Error("pageSize must be a positive safe integer"); + } + const results: T[] = []; for await (const item of scan(options.pageSize)) { results.push(item); diff --git a/packages/utils/test/codec.ts b/packages/utils/test/codec.ts index 028d57f..acb3edf 100644 --- a/packages/utils/test/codec.ts +++ b/packages/utils/test/codec.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest"; import { CheckedInt32LE } from "../src/codec.ts"; describe("CheckedInt32LE", () => { + it("round-trips signed int32 bounds", () => { + expect(CheckedInt32LE.decode(CheckedInt32LE.encode(2147483647))).toBe(2147483647); + expect(CheckedInt32LE.decode(CheckedInt32LE.encode(-2147483648))).toBe(-2147483648); + }); + it("rejects values outside signed int32 bounds", () => { expect(() => CheckedInt32LE.encode(2147483648)).toThrow( "NumLike out of int32 bounds", @@ -11,6 +16,14 @@ describe("CheckedInt32LE", () => { ); }); + it("rejects non-finite and fractional values", () => { + for (const value of [NaN, Infinity, -Infinity, 1.5]) { + expect(() => CheckedInt32LE.encode(value)).toThrow( + "NumLike must be a finite integer", + ); + } + }); + it("decodes from the provided byte view offset", () => { const backing = new Uint8Array(8); backing.set([0xaa, 0xbb, 0xcc, 0xdd], 0); diff --git a/packages/utils/test/utils.ts b/packages/utils/test/utils.ts index b5c50dd..bfef55f 100644 --- a/packages/utils/test/utils.ts +++ b/packages/utils/test/utils.ts @@ -1,5 +1,5 @@ import { ccc } from "@ckb-ccc/core"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { asyncBinarySearch, binarySearch, @@ -67,6 +67,19 @@ describe("scan collection", () => { expect(seenPageSizes).toEqual([2]); }); + it("rejects invalid page sizes before creating the scan", async () => { + const scan = vi.fn((): AsyncIterable => { + throw new Error("scan factory should not be called"); + }); + + for (const pageSize of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + await expect(collectPagedScan(scan, { pageSize })).rejects.toThrow( + "pageSize must be a positive safe integer", + ); + } + expect(scan).not.toHaveBeenCalled(); + }); + it("collects async iterable values", async () => { await expect( collect( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5e9a88..feddda5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,15 @@ settings: catalogs: default: + '@ckb-ccc/ccc': + specifier: 1.2.0 + version: 1.2.0 + '@ckb-ccc/core': + specifier: 1.14.0 + version: 1.14.0 + '@ckb-ccc/udt': + specifier: 0.2.0 + version: 0.2.0 '@types/node': specifier: ^24.8.1 version: 24.12.2 @@ -19,9 +28,6 @@ overrides: vite@<=6.4.2: 6.4.3 vitest@<4.1.0: 4.1.8 ws@>=8.0.0 <8.21.0: 8.21.0 - '@ckb-ccc/ccc': workspace:* - '@ckb-ccc/core': workspace:* - '@ckb-ccc/udt': workspace:* importers: @@ -70,8 +76,8 @@ importers: apps/bot: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) '@ickb/core': specifier: workspace:* version: link:../../packages/core @@ -95,8 +101,8 @@ importers: apps/interface: dependencies: '@ckb-ccc/ccc': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/ccc + specifier: 'catalog:' + version: 1.2.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) '@ickb/core': specifier: workspace:* version: link:../../packages/core @@ -162,8 +168,8 @@ importers: apps/sampler: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) '@ickb/core': specifier: workspace:* version: link:../../packages/core @@ -187,8 +193,8 @@ importers: apps/tester: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) '@ickb/core': specifier: workspace:* version: link:../../packages/core @@ -209,910 +215,20 @@ importers: specifier: 'catalog:' version: 24.12.2 - forks/ccc/repo/packages/ccc: - dependencies: - '@ckb-ccc/eip6963': - specifier: workspace:* - version: link:../eip6963 - '@ckb-ccc/joy-id': - specifier: workspace:* - version: link:../joy-id - '@ckb-ccc/nip07': - specifier: workspace:* - version: link:../nip07 - '@ckb-ccc/okx': - specifier: workspace:* - version: link:../okx - '@ckb-ccc/rei': - specifier: workspace:* - version: link:../rei - '@ckb-ccc/shell': - specifier: workspace:* - version: link:../shell - '@ckb-ccc/uni-sat': - specifier: workspace:* - version: link:../uni-sat - '@ckb-ccc/utxo-global': - specifier: workspace:* - version: link:../utxo-global - '@ckb-ccc/xverse': - specifier: workspace:* - version: link:../xverse - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/ckb-ccc: - dependencies: - '@ckb-ccc/ccc': - specifier: workspace:* - version: link:../ccc - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/connector: - dependencies: - '@ckb-ccc/ccc': - specifier: workspace:* - version: link:../ccc - lit: - specifier: ^3.3.1 - version: 3.3.2 - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/connector-react: - dependencies: - '@ckb-ccc/connector': - specifier: workspace:* - version: link:../connector - '@lit/react': - specifier: ^1.0.8 - version: 1.0.8(@types/react@19.2.14) - react: - specifier: '>=16' - version: 19.2.5 - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - '@types/react': - specifier: ^19.2.7 - version: 19.2.14 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/core: - dependencies: - '@joyid/ckb': - specifier: ^1.1.2 - version: 1.1.4(typescript@5.9.3)(zod@3.25.76) - '@noble/ciphers': - specifier: ^0.5.3 - version: 0.5.3 - '@noble/curves': - specifier: ^1.9.7 - version: 1.9.7 - '@noble/hashes': - specifier: ^1.8.0 - version: 1.8.0 - bech32: - specifier: ^2.0.0 - version: 2.0.0 - bs58check: - specifier: ^4.0.0 - version: 4.0.0 - buffer: - specifier: ^6.0.3 - version: 6.0.3 - ethers: - specifier: ^6.15.0 - version: 6.16.0 - isomorphic-ws: - specifier: ^5.0.0 - version: 5.0.0(ws@8.21.0) - ws: - specifier: 8.21.0 - version: 8.21.0 - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - vitest: - specifier: 4.1.8 - version: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) - - forks/ccc/repo/packages/did-ckb: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - '@ckb-ccc/type-id': - specifier: workspace:* - version: link:../type-id - '@ipld/dag-cbor': - specifier: ^9.2.5 - version: 9.2.6 - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - '@types/node': - specifier: ^24.3.0 - version: 24.12.2 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - tsdown: - specifier: 0.19.0-beta.3 - version: 0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260704.1)(synckit@0.11.12)(typescript@5.9.3) - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - vitest: - specifier: 4.1.8 - version: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) - - forks/ccc/repo/packages/eip6963: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/joy-id: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - '@joyid/ckb': - specifier: ^1.1.2 - version: 1.1.4(typescript@5.9.3)(zod@3.25.76) - '@joyid/common': - specifier: ^0.2.1 - version: 0.2.2(typescript@5.9.3)(zod@3.25.76) - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/lumos-patches: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - '@ckb-lumos/base': - specifier: 0.24.0-next.2 - version: 0.24.0-next.2 - '@ckb-lumos/codec': - specifier: 0.24.0-next.2 - version: 0.24.0-next.2 - '@ckb-lumos/common-scripts': - specifier: 0.24.0-next.2 - version: 0.24.0-next.2 - '@ckb-lumos/config-manager': - specifier: 0.24.0-next.2 - version: 0.24.0-next.2 - '@ckb-lumos/helpers': - specifier: 0.24.0-next.2 - version: 0.24.0-next.2 - '@joyid/ckb': - specifier: ^1.1.2 - version: 1.1.4(typescript@5.9.3)(zod@3.25.76) - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/nip07: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/okx: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - '@ckb-ccc/nip07': - specifier: workspace:* - version: link:../nip07 - '@ckb-ccc/uni-sat': - specifier: workspace:* - version: link:../uni-sat - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/rei: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/shell: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - '@ckb-ccc/did-ckb': - specifier: workspace:* - version: link:../did-ckb - '@ckb-ccc/spore': - specifier: workspace:* - version: link:../spore - '@ckb-ccc/ssri': - specifier: workspace:* - version: link:../ssri - '@ckb-ccc/type-id': - specifier: workspace:* - version: link:../type-id - '@ckb-ccc/udt': - specifier: workspace:* - version: link:../udt - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/spore: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - axios: - specifier: ^1.11.0 - version: 1.16.0 - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - '@types/node': - specifier: ^24.3.0 - version: 24.12.2 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - dotenv: - specifier: ^17.2.1 - version: 17.4.2 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - vitest: - specifier: 4.1.8 - version: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) - - forks/ccc/repo/packages/ssri: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - '@types/node': - specifier: ^24.3.0 - version: 24.12.2 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/type-id: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - '@types/node': - specifier: ^24.3.0 - version: 24.12.2 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - tsdown: - specifier: 0.19.0-beta.3 - version: 0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260704.1)(synckit@0.11.12)(typescript@5.9.3) - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - vitest: - specifier: 4.1.8 - version: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) - - forks/ccc/repo/packages/udt: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - '@ckb-ccc/ssri': - specifier: workspace:* - version: link:../ssri - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - '@types/node': - specifier: ^24.3.0 - version: 24.12.2 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/uni-sat: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/utxo-global: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - forks/ccc/repo/packages/xverse: - dependencies: - '@ckb-ccc/core': - specifier: workspace:* - version: link:../core - bitcoinjs-lib: - specifier: ^7.0.0 - version: 7.0.1(typescript@5.9.3) - valibot: - specifier: ^1.1.0 - version: 1.3.1(typescript@5.9.3) - devDependencies: - '@eslint/js': - specifier: ^9.34.0 - version: 9.39.4 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - eslint: - specifier: ^9.34.0 - version: 9.39.4(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) - prettier: - specifier: ^3.6.2 - version: 3.8.3 - prettier-plugin-organize-imports: - specifier: ^4.2.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) - rimraf: - specifier: ^6.0.1 - version: 6.1.3 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.41.0 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - packages/core: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) '@ckb-ccc/udt': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/udt + specifier: 'catalog:' + version: 0.2.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) '@ickb/dao': specifier: workspace:* version: link:../dao '@ickb/utils': specifier: workspace:* version: link:../utils - tslib: - specifier: ^2.8.1 - version: 2.8.1 devDependencies: '@ickb/testkit': specifier: workspace:* @@ -1121,8 +237,8 @@ importers: packages/dao: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) '@ickb/utils': specifier: workspace:* version: link:../utils @@ -1134,8 +250,8 @@ importers: packages/node-utils: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) '@ickb/utils': specifier: workspace:* version: link:../utils @@ -1150,14 +266,11 @@ importers: packages/order: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) '@ickb/utils': specifier: workspace:* version: link:../utils - tslib: - specifier: ^2.8.1 - version: 2.8.1 devDependencies: '@ickb/testkit': specifier: workspace:* @@ -1166,8 +279,8 @@ importers: packages/sdk: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) '@ickb/core': specifier: workspace:* version: link:../core @@ -1188,19 +301,19 @@ importers: packages/testkit: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) packages/utils: dependencies: '@ckb-ccc/core': - specifier: workspace:* - version: link:../../forks/ccc/repo/packages/core + specifier: 'catalog:' + version: 1.14.0(typescript@5.9.3)(zod@3.25.76) packages: - '@adraffy/ens-normalize@1.10.1': - resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} @@ -1226,6 +339,10 @@ packages: resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -1292,6 +409,10 @@ packages: resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -1300,6 +421,10 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.2': + resolution: {integrity: sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -1322,6 +447,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@8.0.0': + resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + '@babel/plugin-proposal-private-methods@7.18.6': resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} engines: {node: '>=6.9.0'} @@ -1401,58 +531,74 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.0': + resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@ckb-lumos/base@0.24.0-next.2': - resolution: {integrity: sha512-VCXYpykJ+OhjbcCp3imwt9rk3Ie2T8jyCjSXExkoyKY+QT+EI856p37KBbBdO4r7gguVtADnJS+WLIZoNm5Bvw==} - engines: {node: '>=12.0.0'} + '@ckb-ccc/ccc@1.2.0': + resolution: {integrity: sha512-NCwVlQWl3rYSlXCCFqMuDkVBorlrqJO4xJ48Pitp5lVXN6vKO6rteUMy4zQvnjM2/u/iRSlWyn+ONh1k/LI/Ew==} - '@ckb-lumos/bi@0.24.0-next.2': - resolution: {integrity: sha512-KGRv2M0EsZw08qIjauew43VBoG8X6IPiPrW7Bn5nnrqWh0i+JE8ZC4fsS6kGmAhUMw3OcTzinU7cQ318CjaC3Q==} - engines: {node: '>=12.0.0'} + '@ckb-ccc/core@1.14.0': + resolution: {integrity: sha512-X0zicFdnHm7JJZBQml1waNDkZ+9L9xn7XglWExKPuG8DI8ZEuWpvHqdhmcn7ey4CRi5JdiRzfzgZqhoWBgd9+A==} - '@ckb-lumos/codec@0.24.0-next.2': - resolution: {integrity: sha512-yPrAwJyLJw4jf2GMMcJHRE2Z/I9ZD0e7VzzHA0rwTNaaXHpjZWdR5ffBqHGjNAobIrIlJlv9FVDLZTukO1RohA==} - engines: {node: '>=12.0.0'} + '@ckb-ccc/did-ckb@0.2.0': + resolution: {integrity: sha512-r7SQ9OAn1aH52GrVY+645oh1oIu9WtK02J60hiWAAUW8ar/VKCW2jFfdO+aMllpnSyI2xBZ7gq2pH552Eau8KA==} - '@ckb-lumos/common-scripts@0.24.0-next.2': - resolution: {integrity: sha512-wPaAHYWoxOuYLkkfRImtTxaElfMuL5mYA/L5nSCTCkMCncvt3loMjbYlBZY6IBMMclwobTfMPej5Bg5cUpHadA==} - engines: {node: '>=12.0.0'} + '@ckb-ccc/eip6963@1.1.0': + resolution: {integrity: sha512-I15SQPwzPW3dvAkxYPtDqCnuiYOmCdwrEd2Txk+A70bVv/92xSjtUDaRJslWPFSdjxKUCA5kYG0kksf4UJwnPQ==} - '@ckb-lumos/config-manager@0.24.0-next.2': - resolution: {integrity: sha512-/HL0fjFXFnTaJNRyZqHk6/MiioDWeB6bVcm6MO97E6tJVBxTJjIe/cvRjqohEAO3SRst9X81vh2WIB7lCIBjfA==} - engines: {node: '>=12.0.0'} + '@ckb-ccc/joy-id@1.1.0': + resolution: {integrity: sha512-UdcDsq3Or9c9bUK2WGt9EgZtI7FGAfES4tc0iNoCa/BFFxBcYGvcAb2Xzx49l8ms5RneM4/TiwI/Kggh1nXR5A==} - '@ckb-lumos/crypto@0.24.0-next.2': - resolution: {integrity: sha512-wJ1gE5ThZX15q45wRLX0oUoZ/qyE3jIIBnM7wuOWjAVEdp7LVRgNDoYHd7pFCYDH2j6rJwLNhJW41o1f7Aqqlg==} - engines: {node: '>=18.0.0'} + '@ckb-ccc/nip07@1.1.0': + resolution: {integrity: sha512-InydOakokrKQ0Qh+2kA+mrGleqrLqB7iFPR85bKub3mC2BBSAuaEUi87FJmKv+B3PIdFBtzBikU+Glx66VzJng==} - '@ckb-lumos/helpers@0.24.0-next.2': - resolution: {integrity: sha512-yHn1Ty1lyOTRKyEKGzH88eBE4iio7iDpoyejsWVZVk6Eetkv1XS/zNA2J5w7KVhjOzbNK7GUUU9v/3kzYWpG0A==} - engines: {node: '>=12.0.0'} + '@ckb-ccc/okx@1.1.0': + resolution: {integrity: sha512-VUyTukBntKJhQsX+ZjrsllTwFFD9I3h2QLz2JajJjoRvJbJoxCny11l9iFgzjwIap2TKdSXgohMC32dsfvN5sw==} - '@ckb-lumos/rpc@0.24.0-next.2': - resolution: {integrity: sha512-C/Vjvbh+GStxA/9vcj6oI1apZQRoydIvc8T1DR5Dh3F9K1q8NUSnJ43bLNOcwusdMWDkRNNS0jyoKirtLJy5Hg==} - engines: {node: '>=12.0.0'} + '@ckb-ccc/rei@1.1.0': + resolution: {integrity: sha512-g/BU4jHbT8/HWrHphv/W245JFy2tHJS3Qh13x4V1zCi4yZkfhcipiuwMhGTuQpvc5B8OfkeFj5Qkl9I0Wvs0yQ==} - '@ckb-lumos/toolkit@0.24.0-next.2': - resolution: {integrity: sha512-faqOZpj0H21vsqfQXfzRRQUEgF3vZ9i3PcqyF7hNbrNeR6VUcIzJL8QskhjFhBusxAvKf56QbX0/T06/PAgbfg==} - engines: {node: '>=12.0.0'} + '@ckb-ccc/shell@1.3.0': + resolution: {integrity: sha512-WXREgcv0qQ2zUYPKcoX8tkmdfmS1spDT2lRfdzlH/mXqPrFCtgu/+HYFx6XxtGeenFE4kAAbVB8emtoyXe0Q7w==} + + '@ckb-ccc/spore@1.6.0': + resolution: {integrity: sha512-lNdzmWrCVskIkd4ShKXS4WjW5W3OMTcyTTWwtAq5QPgYCYQLK2apZ1wntGBEoftPSYdteGfxL7BzVwb9OvBHzg==} + + '@ckb-ccc/ssri@0.3.0': + resolution: {integrity: sha512-NCLDCpEPpocQMogJGrwLuBiYEGQBTfr1ogu6Aq4mQv9BP4Jtj0vHeVFBge3ny7f+5sITEz3AkS1LuQ9tHKP2Mg==} + + '@ckb-ccc/type-id@0.1.0': + resolution: {integrity: sha512-IMlDuX+HnJvSl216kZF6sAUrNMtpxCuIO3DwHYMB96gqAyW5lLr/SX9Zz5cQ3Xb/tFpqugUqDil7kBN6bewsdg==} + + '@ckb-ccc/udt@0.2.0': + resolution: {integrity: sha512-AHi6e0r3v3SMQwZ/sV+73UcvhjYuooEnM5XHAlLrdREWR3THQHQqPkrrMwV40siSomjMFLzloK46sdzvJBbX0g==} + + '@ckb-ccc/uni-sat@1.1.0': + resolution: {integrity: sha512-Mq+S/FR9TIADrqbTz/upWurE3LXNtmhbPEyy+wpJRnkiVmE2Im/J1GllAuRRSvErtX/1vzvCTJiwj0dma7BMNw==} + + '@ckb-ccc/utxo-global@1.1.0': + resolution: {integrity: sha512-vyBF7yFxgsonTdiDRHxJ3kdUdHTrxbnqKYBRq7SF/MP3uBmJOEt/k80jOJ7md0ThRIN3YTYHG0B/eXWMlxitmQ==} + + '@ckb-ccc/xverse@1.1.0': + resolution: {integrity: sha512-P0lMBxTYUVnRxW9bMoLap/MTchcs5tNTfs9t2UIERXCzHFL+pyhmHm+jR3tWVGGHRaTuPzalJm9JMWLFnzZhkg==} '@dependents/detective-less@5.0.3': resolution: {integrity: sha512-v6oD9Ukp+N7V4n6p5I/+mM5fIohSfkrDSGlFm5w/pYmchvbk+sMIHsLxrFJ5Lnujewj1BzWL0K84d88lwZAMQA==} engines: {node: '>=18'} - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -1668,9 +814,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@ipld/dag-cbor@9.2.6': - resolution: {integrity: sha512-vZGJ84Em2jCVAS7td5gc08YTVN8/s4bTQxg4pU77PAXDAR/yLYOthOvkCu01fdl1lrZwz47RdUterxdkrs3p5A==} - engines: {node: '>=16.0.0', npm: '>=7.0.0'} + '@ipld/dag-cbor@10.0.1': + resolution: {integrity: sha512-nF07iiZPqduSXyMxc0jGANArRHFa9hjMQpQlgLOV2O/3xI1CNb5sXhvbmigbMiz5owSGi0Oq10VtauupMojuiw==} '@joyid/ckb@1.1.4': resolution: {integrity: sha512-8WqcfFd/kfttuaIf2/XsRcmQkEwYLUnZc9UZRcGSVX6GkwzpOLVY5S6Hq+fDXY82lQo9upJGjjudrtcPKYPx3g==} @@ -1694,17 +839,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lit-labs/ssr-dom-shim@1.5.1': - resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} - - '@lit/react@1.0.8': - resolution: {integrity: sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==} - peerDependencies: - '@types/react': 17 || 18 || 19 - - '@lit/reactive-element@2.1.2': - resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} - '@microsoft/api-extractor-model@7.33.8': resolution: {integrity: sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==} @@ -1718,8 +852,8 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -1730,15 +864,16 @@ packages: '@nervosnetwork/ckb-types@0.109.5': resolution: {integrity: sha512-5jQNjFw76YCd+Ppl+0RvBWzxwvWaKfWC5wjVFFdNAieX7xksCHfZFIeow8je7AF8uVypwe56WlLBlblxw9NBBQ==} - '@noble/ciphers@0.5.3': - resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} '@noble/curves@1.2.0': resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} - '@noble/curves@1.9.7': - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} '@noble/hashes@1.3.2': resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} @@ -1748,191 +883,107 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@oxc-project/types@0.106.0': - resolution: {integrity: sha512-QdsH3rZq480VnOHSHgPYOhjL8O8LBdcnSjM408BpPCCUc0JYYZPG9Gafl9i3OcGk/7137o+gweb4cCv3WAUykg==} - - '@oxc-project/types@0.127.0': - resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@rolldown/binding-android-arm64@1.0.0-beta.58': - resolution: {integrity: sha512-mWj5eE4Qc8TbPdGGaaLvBb9XfDPvE1EmZkJQgiGKwchkWH4oAJcRAKMTw7ZHnb1L+t7Ah41sBkAecaIsuUgsug==} + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-beta.58': - resolution: {integrity: sha512-wFxUymI/5R8bH8qZFYDfAxAN9CyISEIYke+95oZPiv6EWo88aa5rskjVcCpKA532R+klFmdqjbbaD56GNmTF4Q==} + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-beta.58': - resolution: {integrity: sha512-ybp3MkPj23VDV9PhtRwdU5qrGhlViWRV5BjKwO6epaSlUD5lW0WyY+roN3ZAzbma/9RrMTgZ/a/gtQq8YXOcqw==} + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.17': - resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-beta.58': - resolution: {integrity: sha512-Evxj3yh7FWvyklUYZa0qTVT9N2zX9TPDqGF056hl8hlCZ9/ndQ2xMv6uw9PD1VlLpukbsqL+/C6M0qwipL0QMg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': - resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.58': - resolution: {integrity: sha512-tYeXprDOrEgVHUbPXH6MPso4cM/c6RTkmJNICMQlYdki4hGMh92aj3yU6CKs+4X5gfG0yj5kVUw/L4M685SYag==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': - resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.58': - resolution: {integrity: sha512-N78vmZzP6zG967Ohr+MasCjmKtis0geZ1SOVmxrA0/bklTQSzH5kHEjW5Qn+i1taFno6GEre1E40v0wuWsNOQw==} + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.58': - resolution: {integrity: sha512-l+p4QVtG72C7wI2SIkNQw/KQtSjuYwS3rV6AKcWrRBF62ClsFUcif5vLaZIEbPrCXu5OFRXigXFJnxYsVVZqdQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.58': - resolution: {integrity: sha512-urzJX0HrXxIh0FfxwWRjfPCMeInU9qsImLQxHBgLp5ivji1EEUnOfux8KxPPnRQthJyneBrN2LeqUix9DYrNaQ==} + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.0-beta.58': - resolution: {integrity: sha512-7ijfVK3GISnXIwq/1FZo+KyAUJjL3kWPJ7rViAL6MWeEBhEgRzJ0yEd9I8N9aut8Y8ab+EKFJyRNMWZuUBwQ0A==} + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.0-beta.58': - resolution: {integrity: sha512-/m7sKZCS+cUULbzyJTIlv8JbjNohxbpAOA6cM+lgWgqVzPee3U6jpwydrib328JFN/gF9A99IZEnuGYqEDJdww==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.58': - resolution: {integrity: sha512-6SZk7zMgv+y3wFFQ9qE5P9NnRHcRsptL1ypmudD26PDY+PvFCvfHRkJNfclWnvacVGxjowr7JOL3a9fd1wWhUw==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': - resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58': - resolution: {integrity: sha512-sFqfYPnBZ6xBhMkadB7UD0yjEDRvs7ipR3nCggblN+N4ODCXY6qhg/bKL39+W+dgQybL7ErD4EGERVbW9DAWvg==} + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.58': - resolution: {integrity: sha512-AnFWJdAqB8+IDPcGrATYs67Kik/6tnndNJV2jGRmwlbeNiQQ8GhRJU8ETRlINfII0pqi9k4WWLnb00p1QCxw/Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1940,11 +991,8 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rolldown/pluginutils@1.0.0-beta.58': - resolution: {integrity: sha512-qWhDs6yFGR5xDfdrwiSa3CWGIHxD597uGE/A9xGqytBjANvh4rLCTTkq7szhMV4+Ygh+PMS90KVJ8xWG/TkX4w==} - - '@rolldown/pluginutils@1.0.0-rc.17': - resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@rollup/rollup-android-arm-eabi@4.60.2': resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} @@ -2235,8 +1283,8 @@ packages: resolution: {integrity: sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg==} engines: {node: '>=18'} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} @@ -2253,30 +1301,21 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/blake2b@2.1.3': - resolution: {integrity: sha512-MFCdX0MNxFBP/xEILO5Td0kv6nI7+Q2iRWZbTL/yzH2/eDVZS5Wd1LHdsmXClvsCyzqaZfHFzZaN6BUeUCfSDA==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/deep-freeze-strict@1.1.2': - resolution: {integrity: sha512-VvMETBojHvhX4f+ocYTySQlXMZfxKV3Jyb7iCWlWaC+exbedkv6Iv2bZZqI736qXjVguH6IH7bzwMBMfTT+zuQ==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/lodash.isequal@4.5.8': - resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} - - '@types/lodash@4.17.24': - resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} - '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} @@ -2294,12 +1333,6 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.59.1': resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2480,10 +1513,6 @@ packages: zod: optional: true - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2497,6 +1526,10 @@ packages: aes-js@4.0.0-beta.5: resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -2527,8 +1560,8 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansis@4.2.0: - resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} any-promise@1.3.0: @@ -2547,9 +1580,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-kit@2.2.0: - resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} - engines: {node: '>=20.19.0'} + ast-kit@3.0.0: + resolution: {integrity: sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==} + engines: {node: ^22.18.0 || >=24.11.0} ast-module-types@6.0.1: resolution: {integrity: sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA==} @@ -2561,16 +1594,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.16.0: - resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} - - b4a@1.8.1: - resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} - peerDependencies: - react-native-b4a: '*' - peerDependenciesMeta: - react-native-b4a: - optional: true + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} @@ -2582,9 +1607,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base-x@4.0.1: - resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} - base-x@5.0.1: resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} @@ -2613,12 +1635,6 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - blake2b-wasm@2.4.0: - resolution: {integrity: sha512-S1kwmW2ZhZFFFOghcx73+ZajEfKBqhP82JMssxtLVMxlaPea1p9uoLiUZ5WYyHn0KddwbLc+0vh4wR0KBNoT5w==} - - blake2b@2.1.4: - resolution: {integrity: sha512-AyBuuJNI64gIvwx13qiICz6H6hpmjvYS5DGkG6jbXMOT8Z3WUJ3V1X0FlhIoT1b/5JtHE3ki+xjtMvu1nn+t9A==} - bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} @@ -2637,9 +1653,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bs58@5.0.0: - resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} - bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} @@ -2652,9 +1665,9 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} @@ -2687,9 +1700,6 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -2722,16 +1732,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - copyfiles@2.4.1: - resolution: {integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==} - hasBin: true - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - cross-fetch@3.2.0: - resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} - cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} @@ -2755,9 +1755,6 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} - deep-freeze-strict@1.1.1: - resolution: {integrity: sha512-QemROZMM2IvhAcCFvahdX2Vbm4S/txeq5rFYU9fh4mQP79WTMW5c/HkQ2ICl1zuzcDZdPZ6zarDxQeQMsVYoNA==} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2827,13 +1824,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - - dts-resolver@2.1.3: - resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} - engines: {node: '>=20.19.0'} + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} peerDependencies: oxc-resolver: '>=11.0.0' peerDependenciesMeta: @@ -2850,11 +1843,8 @@ packages: elliptic@6.6.1: resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - empathic@2.0.0: - resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} enhanced-resolve@5.21.0: @@ -2902,26 +1892,6 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-config-prettier@10.1.8: - resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-plugin-prettier@5.5.5: - resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true - eslint-plugin-react-compiler@19.1.0-rc.2: resolution: {integrity: sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==} engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0} @@ -2996,14 +1966,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - ethers@6.16.0: - resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + ethers@6.17.0: + resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==} engines: {node: '>=14.0.0'} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3011,9 +1977,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3069,9 +2032,6 @@ packages: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3088,10 +2048,6 @@ packages: resolution: {integrity: sha512-7zShVYAYtMnj9S65CfN+hvpBCByfuB1OY8xID01nZEzXTZbx4YyysAfi+nMl95JSR6odt4q8TCj2W63KAoyVLQ==} engines: {node: '>=18'} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3103,21 +2059,14 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3172,6 +2121,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3183,9 +2136,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immutable@4.3.8: - resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==} - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3194,18 +2144,14 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} - import-without-cache@0.2.5: - resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} - engines: {node: '>=20.19.0'} + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3220,10 +2166,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -3248,12 +2190,6 @@ packages: resolution: {integrity: sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==} engines: {node: '>=10'} - isarray@0.0.1: - resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3287,10 +2223,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-xxhash@1.0.4: - resolution: {integrity: sha512-S/6Oo7ruxx5k8m4qlMnbpwQdJjRsvvfcIhIk1dA9c5y5GNhYHKYKu9krEK3QgBax6CxJuf4gRL2opgLkdzWIKg==} - engines: {node: '>=8.0.0'} - js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true @@ -3298,9 +2230,6 @@ packages: jsbi@3.1.3: resolution: {integrity: sha512-nBJqA0C6Qns+ZxurbEoIR56wyjiUszpNy70FHvxO5ervMoCbZVE3z3kxr5nKGhlxr/9MhKTSUBs7cAwwuf3g9w==} - jsbi@4.3.2: - resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3407,15 +2336,6 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lit-element@4.2.2: - resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} - - lit-html@3.3.2: - resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} - - lit@3.3.2: - resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} - locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3427,10 +2347,6 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - lru-cache@11.3.5: - resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3490,15 +2406,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - module-definition@6.0.2: resolution: {integrity: sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA==} engines: {node: '>=18'} @@ -3512,11 +2419,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - multiformats@13.4.2: - resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} - - nanoassert@2.0.0: - resolution: {integrity: sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA==} + multiformats@14.0.4: + resolution: {integrity: sha512-+SXItmOUWzT0kjlIfkZ4NawJaC9jW/mPlyn902V9Qgpc7YDwdEiwqbiZxTyvC0XWh1jZguXca3A/WQ+UOPRLUA==} nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} @@ -3542,14 +2446,12 @@ packages: resolution: {integrity: sha512-71kFFjYaSshDTA8/a2HiTYPLdASWjLJxUyJxGE+ffxU+KhxSBtM9kiLUX+R2yooFdSFKMFpi4n3PFtDy6qXv8A==} engines: {node: '>=18'} - noms@0.0.0: - resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} @@ -3571,9 +2473,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3586,10 +2485,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3597,10 +2492,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3634,32 +2525,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.1: - resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} - engines: {node: '>=6.0.0'} - - prettier-plugin-organize-imports@4.3.0: - resolution: {integrity: sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==} - peerDependencies: - prettier: '>=2.0' - typescript: '>=2.9' - vue-tsc: ^2.1.0 || 3 - peerDependenciesMeta: - vue-tsc: - optional: true - - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} - engines: {node: '>=14'} - hasBin: true - pretty-ms@7.0.1: resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} engines: {node: '>=10'} - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -3691,20 +2560,10 @@ packages: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} - readable-stream@1.0.34: - resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -3738,20 +2597,15 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - rimraf@6.1.3: - resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} - engines: {node: 20 || >=22} - hasBin: true - - rolldown-plugin-dts@0.20.0: - resolution: {integrity: sha512-cLAY1kN2ilTYMfZcFlGWbXnu6Nb+8uwUBsi+Mjbh4uIx7IN8uMOmJ7RxrrRgPsO4H7eSz3E+JwGoL1gyugiyUA==} - engines: {node: '>=20.19.0'} + rolldown-plugin-dts@0.26.0: + resolution: {integrity: sha512-e+kEPtUiDES0htk5iqkSeF4EzAV7R+vugGB44iPDuw1Kw9E+WyL1VG7PaV0IIjGHLiacztMBcMTyrr8ON9CT1Q==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: '@ts-macro/tsc': ^0.3.6 - '@typescript/native-preview': '>=7.0.0-dev.20250601.1' - rolldown: ^1.0.0-beta.57 - typescript: ^5.0.0 - vue-tsc: ~3.2.0 + '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 peerDependenciesMeta: '@ts-macro/tsc': optional: true @@ -3762,13 +2616,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.58: - resolution: {integrity: sha512-v1FCjMZCan7f+xGAHBi+mqiE4MlH7I+SXEHSQSJoMOGNNB2UYtvMiejsq9YuUOiZjNeUeV/a21nSFbrUR+4ZCQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - rolldown@1.0.0-rc.17: - resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + rolldown@1.1.4: + resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3797,6 +2646,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3835,13 +2689,6 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string_decoder@0.10.31: - resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -3882,10 +2729,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} - engines: {node: ^14.18.0 || >=16.0.0} - tailwindcss@4.2.4: resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} @@ -3893,9 +2736,6 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - through2@2.0.5: - resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3903,10 +2743,18 @@ packages: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -3932,30 +2780,39 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} - tsdown@0.19.0-beta.3: - resolution: {integrity: sha512-Ud75SBmTap0kDf9hs31yBBlU0iAV17gtZgTJlW6nG/e4J6wXPXwQtUXt/Fck4XSmHXXgSuYRwGrjF6AxTLwk+Q==} - engines: {node: '>=20.19.0'} + tsdown@0.22.3: + resolution: {integrity: sha512-louqbfA8Qf//B9jTTL0FPtXTNpjCWv1VPkbcmQMph2pTpzs+LnB1tbe4tDDRVpo2BjF5SgUXaTZe45SxB8pWHg==} + engines: {node: ^22.18.0 || >=24.11.0} hasBin: true peerDependencies: '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.3 + '@tsdown/exe': 0.22.3 '@vitejs/devtools': '*' - publint: ^0.3.0 - typescript: ^5.0.0 - unplugin-lightningcss: ^0.4.0 + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 unplugin-unused: ^0.5.0 + unrun: '*' peerDependenciesMeta: '@arethetypeswrong/core': optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true '@vitejs/devtools': optional: true publint: optional: true - typescript: + tsx: optional: true - unplugin-lightningcss: + typescript: optional: true unplugin-unused: optional: true + unrun: + optional: true tslib@2.3.1: resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} @@ -3963,9 +2820,6 @@ packages: tslib@2.7.0: resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4013,20 +2867,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unrun@0.2.37: - resolution: {integrity: sha512-AA7vDuYsgeSYVzJMm16UKA+aXFKhy7nFqW9z5l7q44K4ppFWZAMqYS58ePRZbugMLPH0fwwMzD5A8nP0avxwZQ==} - engines: {node: '>=20.19.0'} - hasBin: true - peerDependencies: - synckit: ^0.11.11 - peerDependenciesMeta: - synckit: - optional: true - - untildify@4.0.0: - resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} - engines: {node: '>=8'} - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -4039,8 +2879,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - valibot@1.3.1: - resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -4158,13 +2998,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -4177,25 +3010,9 @@ packages: utf-8-validate: optional: true - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} - - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} - engines: {node: '>=10'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4211,7 +3028,7 @@ packages: snapshots: - '@adraffy/ens-normalize@1.10.1': {} + '@adraffy/ens-normalize@1.11.1': {} '@babel/code-frame@7.29.0': dependencies: @@ -4263,6 +3080,15 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.0 + '@babel/types': 8.0.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.0 @@ -4348,10 +3174,14 @@ snapshots: '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.2': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helper-validator-option@7.29.7': {} @@ -4369,6 +3199,10 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/parser@8.0.0': + dependencies: + '@babel/types': 8.0.0 + '@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -4479,108 +3313,398 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.0': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.2 + '@bcoe/v8-coverage@1.0.2': {} - '@ckb-lumos/base@0.24.0-next.2': + '@ckb-ccc/ccc@1.2.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': + dependencies: + '@ckb-ccc/eip6963': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/joy-id': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/nip07': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/okx': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/rei': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/shell': 1.3.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/uni-sat': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/utxo-global': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/xverse': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - debug + - encoding + - oxc-resolver + - publint + - supports-color + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod + + '@ckb-ccc/core@1.14.0(typescript@5.9.3)(zod@3.25.76)': + dependencies: + '@joyid/ckb': 1.1.4(typescript@5.9.3)(zod@3.25.76) + '@noble/ciphers': 2.2.0 + '@noble/curves': 2.2.0 + '@noble/hashes': 2.2.0 + bech32: 2.0.0 + bs58check: 4.0.0 + buffer: 6.0.3 + ethers: 6.17.0 + isomorphic-ws: 5.0.0(ws@8.21.0) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + - zod + + '@ckb-ccc/did-ckb@0.2.0(typescript@5.9.3)(zod@3.25.76)': + dependencies: + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/type-id': 0.1.0(typescript@5.9.3)(zod@3.25.76) + '@ipld/dag-cbor': 10.0.1 + '@noble/curves': 2.2.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + - zod + + '@ckb-ccc/eip6963@1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': + dependencies: + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - encoding + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod + + '@ckb-ccc/joy-id@1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': + dependencies: + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + '@joyid/ckb': 1.1.4(typescript@5.9.3)(zod@3.25.76) + '@joyid/common': 0.2.2(typescript@5.9.3)(zod@3.25.76) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - encoding + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod + + '@ckb-ccc/nip07@1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': + dependencies: + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - encoding + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod + + '@ckb-ccc/okx@1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': + dependencies: + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/nip07': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/uni-sat': 1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - encoding + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod + + '@ckb-ccc/rei@1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': dependencies: - '@ckb-lumos/bi': 0.24.0-next.2 - '@ckb-lumos/codec': 0.24.0-next.2 - '@ckb-lumos/toolkit': 0.24.0-next.2 - '@types/blake2b': 2.1.3 - '@types/lodash.isequal': 4.5.8 - blake2b: 2.1.4 - js-xxhash: 1.0.4 + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) transitivePeerDependencies: - - react-native-b4a + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - encoding + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod - '@ckb-lumos/bi@0.24.0-next.2': + '@ckb-ccc/shell@1.3.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': dependencies: - jsbi: 4.3.2 + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/did-ckb': 0.2.0(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/spore': 1.6.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/ssri': 0.3.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/type-id': 0.1.0(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/udt': 0.2.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - debug + - encoding + - oxc-resolver + - publint + - supports-color + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod - '@ckb-lumos/codec@0.24.0-next.2': + '@ckb-ccc/spore@1.6.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': dependencies: - '@ckb-lumos/bi': 0.24.0-next.2 + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + axios: 1.18.1 + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - debug + - encoding + - oxc-resolver + - publint + - supports-color + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod - '@ckb-lumos/common-scripts@0.24.0-next.2': + '@ckb-ccc/ssri@0.3.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': dependencies: - '@ckb-lumos/base': 0.24.0-next.2 - '@ckb-lumos/bi': 0.24.0-next.2 - '@ckb-lumos/codec': 0.24.0-next.2 - '@ckb-lumos/config-manager': 0.24.0-next.2 - '@ckb-lumos/crypto': 0.24.0-next.2 - '@ckb-lumos/helpers': 0.24.0-next.2 - '@ckb-lumos/rpc': 0.24.0-next.2 - '@ckb-lumos/toolkit': 0.24.0-next.2 - bech32: 2.0.0 - bs58: 5.0.0 - immutable: 4.3.8 + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil - encoding - - react-native-b4a + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod - '@ckb-lumos/config-manager@0.24.0-next.2': + '@ckb-ccc/type-id@0.1.0(typescript@5.9.3)(zod@3.25.76)': dependencies: - '@ckb-lumos/base': 0.24.0-next.2 - '@ckb-lumos/bi': 0.24.0-next.2 - '@ckb-lumos/codec': 0.24.0-next.2 - '@ckb-lumos/rpc': 0.24.0-next.2 - '@types/deep-freeze-strict': 1.1.2 - deep-freeze-strict: 1.1.1 + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) transitivePeerDependencies: + - bufferutil - encoding - - react-native-b4a + - typescript + - utf-8-validate + - zod - '@ckb-lumos/crypto@0.24.0-next.2': + '@ckb-ccc/udt@0.2.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': dependencies: - '@noble/ciphers': 0.5.3 - '@noble/hashes': 1.8.0 + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + '@ckb-ccc/ssri': 0.3.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - encoding + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod - '@ckb-lumos/helpers@0.24.0-next.2': + '@ckb-ccc/uni-sat@1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': dependencies: - '@ckb-lumos/base': 0.24.0-next.2 - '@ckb-lumos/bi': 0.24.0-next.2 - '@ckb-lumos/codec': 0.24.0-next.2 - '@ckb-lumos/config-manager': 0.24.0-next.2 - '@ckb-lumos/toolkit': 0.24.0-next.2 - bech32: 2.0.0 - immutable: 4.3.8 + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil - encoding - - react-native-b4a + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod - '@ckb-lumos/rpc@0.24.0-next.2': + '@ckb-ccc/utxo-global@1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': dependencies: - '@ckb-lumos/base': 0.24.0-next.2 - '@ckb-lumos/bi': 0.24.0-next.2 - abort-controller: 3.0.0 - cross-fetch: 3.2.0 + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil - encoding - - react-native-b4a + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod - '@ckb-lumos/toolkit@0.24.0-next.2': + '@ckb-ccc/xverse@1.1.0(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3)(zod@3.25.76)': dependencies: - '@ckb-lumos/bi': 0.24.0-next.2 + '@ckb-ccc/core': 1.14.0(typescript@5.9.3)(zod@3.25.76) + bitcoinjs-lib: 7.0.1(typescript@5.9.3) + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3) + valibot: 1.4.2(typescript@5.9.3) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - encoding + - oxc-resolver + - publint + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - zod '@dependents/detective-less@5.0.3': dependencies: gonzales-pe: 4.3.0 node-source-walk: 7.0.2 - '@emnapi/core@1.10.0': + '@emnapi/core@1.11.1': dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.7.0 optional: true - '@emnapi/runtime@1.10.0': + '@emnapi/runtime@1.11.1': dependencies: - tslib: 2.8.1 + tslib: 2.7.0 optional: true - '@emnapi/wasi-threads@1.2.1': + '@emnapi/wasi-threads@1.2.2': dependencies: - tslib: 2.8.1 + tslib: 2.7.0 optional: true '@esbuild/aix-ppc64@0.25.12': @@ -4723,10 +3847,10 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@ipld/dag-cbor@9.2.6': + '@ipld/dag-cbor@10.0.1': dependencies: cborg: 5.1.1 - multiformats: 13.4.2 + multiformats: 14.0.4 '@joyid/ckb@1.1.4(typescript@5.9.3)(zod@3.25.76)': dependencies: @@ -4766,16 +3890,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lit-labs/ssr-dom-shim@1.5.1': {} - - '@lit/react@1.0.8(@types/react@19.2.14)': - dependencies: - '@types/react': 19.2.14 - - '@lit/reactive-element@2.1.2': - dependencies: - '@lit-labs/ssr-dom-shim': 1.5.1 - '@microsoft/api-extractor-model@7.33.8(@types/node@24.12.2)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -4811,11 +3925,11 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 optional: true '@nervosnetwork/ckb-sdk-utils@0.109.5': @@ -4828,128 +3942,80 @@ snapshots: '@nervosnetwork/ckb-types@0.109.5': {} - '@noble/ciphers@0.5.3': {} + '@noble/ciphers@2.2.0': {} '@noble/curves@1.2.0': dependencies: '@noble/hashes': 1.3.2 - '@noble/curves@1.9.7': + '@noble/curves@2.2.0': dependencies: - '@noble/hashes': 1.8.0 + '@noble/hashes': 2.2.0 '@noble/hashes@1.3.2': {} '@noble/hashes@1.8.0': {} - '@oxc-project/types@0.106.0': {} + '@noble/hashes@2.2.0': {} - '@oxc-project/types@0.127.0': {} - - '@pkgr/core@0.2.9': {} + '@oxc-project/types@0.138.0': {} '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 - '@rolldown/binding-android-arm64@1.0.0-beta.58': - optional: true - - '@rolldown/binding-android-arm64@1.0.0-rc.17': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-beta.58': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-beta.58': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-rc.17': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-beta.58': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.58': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.58': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + '@rolldown/binding-android-arm64@1.1.4': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.58': + '@rolldown/binding-darwin-arm64@1.1.4': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + '@rolldown/binding-darwin-x64@1.1.4': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + '@rolldown/binding-freebsd-x64@1.1.4': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.58': + '@rolldown/binding-linux-arm64-gnu@1.1.4': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-musl@1.1.4': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.58': + '@rolldown/binding-linux-ppc64-gnu@1.1.4': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-s390x-gnu@1.1.4': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.58': + '@rolldown/binding-linux-x64-gnu@1.1.4': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + '@rolldown/binding-linux-x64-musl@1.1.4': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@rolldown/binding-openharmony-arm64@1.1.4': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + '@rolldown/binding-wasm32-wasi@1.1.4': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58': - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.58': + '@rolldown/binding-win32-arm64-msvc@1.1.4': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-x64-msvc@1.1.4': optional: true '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rolldown/pluginutils@1.0.0-beta.58': {} - - '@rolldown/pluginutils@1.0.0-rc.17': {} + '@rolldown/pluginutils@1.0.1': {} '@rollup/rollup-android-arm-eabi@4.60.2': optional: true @@ -5157,9 +4223,9 @@ snapshots: '@ts-graphviz/ast': 2.0.7 '@ts-graphviz/common': 2.1.5 - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.3': dependencies: - tslib: 2.8.1 + tslib: 2.7.0 optional: true '@types/argparse@1.0.38': {} @@ -5185,8 +4251,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@types/blake2b@2.1.3': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -5194,17 +4258,11 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/deep-freeze-strict@1.1.2': {} - '@types/estree@1.0.8': {} - '@types/json-schema@7.0.15': {} - - '@types/lodash.isequal@4.5.8': - dependencies: - '@types/lodash': 4.17.24 + '@types/jsesc@2.5.1': {} - '@types/lodash@4.17.24': {} + '@types/json-schema@7.0.15': {} '@types/node@22.19.17': dependencies: @@ -5226,12 +4284,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/trusted-types@2.0.7': {} - - '@types/ws@8.18.1': - dependencies: - '@types/node': 24.12.2 - '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5463,10 +4515,6 @@ snapshots: optionalDependencies: zod: 3.25.76 - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -5475,6 +4523,12 @@ snapshots: aes-js@4.0.0-beta.5: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv-draft-04@1.0.0(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -5503,7 +4557,7 @@ snapshots: dependencies: color-convert: 2.0.1 - ansis@4.2.0: {} + ansis@4.3.1: {} any-promise@1.3.0: {} @@ -5517,9 +4571,10 @@ snapshots: assertion-error@2.0.1: {} - ast-kit@2.2.0: + ast-kit@3.0.0: dependencies: - '@babel/parser': 7.29.3 + '@babel/parser': 8.0.0 + estree-walker: 3.0.3 pathe: 2.0.3 ast-module-types@6.0.1: {} @@ -5532,15 +4587,15 @@ snapshots: asynckit@0.4.0: {} - axios@1.16.0: + axios@1.18.1: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug - - b4a@1.8.1: {} + - supports-color babel-plugin-react-compiler@1.0.0: dependencies: @@ -5550,8 +4605,6 @@ snapshots: balanced-match@4.0.4: {} - base-x@4.0.1: {} - base-x@5.0.1: {} base64-js@1.5.1: {} @@ -5574,7 +4627,7 @@ snapshots: bip174: 3.0.0 bs58check: 4.0.0 uint8array-tools: 0.0.9 - valibot: 1.3.1(typescript@5.9.3) + valibot: 1.4.2(typescript@5.9.3) varuint-bitcoin: 2.0.0 transitivePeerDependencies: - typescript @@ -5585,20 +4638,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - blake2b-wasm@2.4.0: - dependencies: - b4a: 1.8.1 - nanoassert: 2.0.0 - transitivePeerDependencies: - - react-native-b4a - - blake2b@2.1.4: - dependencies: - blake2b-wasm: 2.4.0 - nanoassert: 2.0.0 - transitivePeerDependencies: - - react-native-b4a - bn.js@4.12.3: {} brace-expansion@1.1.14: @@ -5620,10 +4659,6 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) - bs58@5.0.0: - dependencies: - base-x: 4.0.1 - bs58@6.0.0: dependencies: base-x: 5.0.1 @@ -5643,7 +4678,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - cac@6.7.14: {} + cac@7.0.0: {} call-bind-apply-helpers@1.0.2: dependencies: @@ -5669,12 +4704,6 @@ snapshots: cli-spinners@2.9.2: {} - cliui@7.0.4: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - clone@1.0.4: {} color-convert@2.0.1: @@ -5697,24 +4726,6 @@ snapshots: convert-source-map@2.0.0: {} - copyfiles@2.4.1: - dependencies: - glob: 7.2.3 - minimatch: 3.1.5 - mkdirp: 1.0.4 - noms: 0.0.0 - through2: 2.0.5 - untildify: 4.0.0 - yargs: 16.2.0 - - core-util-is@1.0.3: {} - - cross-fetch@3.2.0: - dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - cross-fetch@4.0.0: dependencies: node-fetch: 2.7.0 @@ -5735,8 +4746,6 @@ snapshots: deep-extend@0.6.0: {} - deep-freeze-strict@1.1.1: {} - deep-is@0.1.4: {} defaults@1.0.4: @@ -5816,9 +4825,7 @@ snapshots: diff@8.0.4: {} - dotenv@17.4.2: {} - - dts-resolver@2.1.3: {} + dts-resolver@3.0.0: {} dunder-proto@1.0.1: dependencies: @@ -5838,9 +4845,7 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - emoji-regex@8.0.0: {} - - empathic@2.0.0: {} + empathic@2.0.1: {} enhanced-resolve@5.21.0: dependencies: @@ -5907,19 +4912,6 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): - dependencies: - eslint: 9.39.4(jiti@2.6.1) - - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3): - dependencies: - eslint: 9.39.4(jiti@2.6.1) - prettier: 3.8.3 - prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 - optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react-compiler@19.1.0-rc.2(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/core': 7.29.7 @@ -6018,9 +5010,9 @@ snapshots: esutils@2.0.3: {} - ethers@6.16.0: + ethers@6.17.0: dependencies: - '@adraffy/ens-normalize': 1.10.1 + '@adraffy/ens-normalize': 1.11.1 '@noble/curves': 1.2.0 '@noble/hashes': 1.3.2 '@types/node': 22.7.5 @@ -6031,14 +5023,10 @@ snapshots: - bufferutil - utf-8-validate - event-target-shim@5.0.1: {} - expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -6095,8 +5083,6 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true @@ -6109,8 +5095,6 @@ snapshots: ast-module-types: 6.0.1 node-source-walk: 7.0.2 - get-caller-file@2.0.5: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6131,7 +5115,7 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-tsconfig@4.14.0: + get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 @@ -6139,21 +5123,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - globals@14.0.0: {} gonzales-pe@4.3.0: @@ -6201,14 +5170,19 @@ snapshots: html-escaper@2.0.2: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ieee754@1.2.1: {} ignore@5.3.2: {} ignore@7.0.5: {} - immutable@4.3.8: {} - import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -6216,15 +5190,10 @@ snapshots: import-lazy@4.0.0: {} - import-without-cache@0.2.5: {} + import-without-cache@0.4.0: {} imurmurhash@0.1.4: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} ini@1.3.8: {} @@ -6235,8 +5204,6 @@ snapshots: is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -6251,10 +5218,6 @@ snapshots: is-url-superb@4.0.0: {} - isarray@0.0.1: {} - - isarray@1.0.0: {} - isexe@2.0.0: {} isomorphic-ws@5.0.0(ws@8.21.0): @@ -6282,16 +5245,12 @@ snapshots: js-tokens@4.0.0: {} - js-xxhash@1.0.4: {} - js-yaml@4.2.0: dependencies: argparse: 2.0.1 jsbi@3.1.3: {} - jsbi@4.3.2: {} - jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -6368,22 +5327,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lit-element@4.2.2: - dependencies: - '@lit-labs/ssr-dom-shim': 1.5.1 - '@lit/reactive-element': 2.1.2 - lit-html: 3.3.2 - - lit-html@3.3.2: - dependencies: - '@types/trusted-types': 2.0.7 - - lit@3.3.2: - dependencies: - '@lit/reactive-element': 2.1.2 - lit-element: 4.2.2 - lit-html: 3.3.2 - locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -6395,8 +5338,6 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - lru-cache@11.3.5: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -6462,10 +5403,6 @@ snapshots: minimist@1.2.8: {} - minipass@7.1.3: {} - - mkdirp@1.0.4: {} - module-definition@6.0.2: dependencies: ast-module-types: 6.0.1 @@ -6479,9 +5416,7 @@ snapshots: ms@2.1.3: {} - multiformats@13.4.2: {} - - nanoassert@2.0.0: {} + multiformats@14.0.4: {} nanoid@3.3.12: {} @@ -6497,16 +5432,9 @@ snapshots: dependencies: '@babel/parser': 7.29.3 - noms@0.0.0: - dependencies: - inherits: 2.0.4 - readable-stream: 1.0.34 - obug@2.1.1: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 + obug@2.1.3: {} onetime@5.1.2: dependencies: @@ -6541,8 +5469,6 @@ snapshots: dependencies: p-limit: 3.1.0 - package-json-from-dist@1.0.1: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6551,17 +5477,10 @@ snapshots: path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-parse@1.0.7: {} - path-scurry@2.0.2: - dependencies: - lru-cache: 11.3.5 - minipass: 7.1.3 - pathe@2.0.3: {} picocolors@1.1.1: {} @@ -6605,23 +5524,10 @@ snapshots: prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.1: - dependencies: - fast-diff: 1.3.0 - - prettier-plugin-organize-imports@4.3.0(prettier@3.8.3)(typescript@5.9.3): - dependencies: - prettier: 3.8.3 - typescript: 5.9.3 - - prettier@3.8.3: {} - pretty-ms@7.0.1: dependencies: parse-ms: 2.1.0 - process-nextick-args@2.0.1: {} - proxy-from-env@2.1.0: {} punycode@2.3.1: {} @@ -6646,31 +5552,12 @@ snapshots: react@19.2.5: {} - readable-stream@1.0.34: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 0.0.1 - string_decoder: 0.10.31 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.1.1 util-deprecate: 1.0.2 - require-directory@2.1.1: {} - require-from-string@2.0.2: {} requirejs-config-file@4.0.0: @@ -6698,70 +5585,43 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - rimraf@6.1.3: + rolldown-plugin-dts@0.26.0(@typescript/native-preview@7.0.0-dev.20260704.1)(rolldown@1.1.4)(typescript@5.9.3): dependencies: - glob: 13.0.6 - package-json-from-dist: 1.0.1 - - rolldown-plugin-dts@0.20.0(@typescript/native-preview@7.0.0-dev.20260704.1)(rolldown@1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.9.3): - dependencies: - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - ast-kit: 2.2.0 + '@babel/generator': 8.0.0 + '@babel/helper-validator-identifier': 8.0.2 + '@babel/parser': 8.0.0 + ast-kit: 3.0.0 birpc: 4.0.0 - dts-resolver: 2.1.3 - get-tsconfig: 4.14.0 - obug: 2.1.1 - rolldown: 1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.3 + rolldown: 1.1.4 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260704.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): - dependencies: - '@oxc-project/types': 0.106.0 - '@rolldown/pluginutils': 1.0.0-beta.58 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.58 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.58 - '@rolldown/binding-darwin-x64': 1.0.0-beta.58 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.58 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.58 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.58 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.58 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.58 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.58 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.58 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.58 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.58 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - rolldown@1.0.0-rc.17: + rolldown@1.1.4: dependencies: - '@oxc-project/types': 0.127.0 - '@rolldown/pluginutils': 1.0.0-rc.17 + '@oxc-project/types': 0.138.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-x64': 1.0.0-rc.17 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + '@rolldown/binding-android-arm64': 1.1.4 + '@rolldown/binding-darwin-arm64': 1.1.4 + '@rolldown/binding-darwin-x64': 1.1.4 + '@rolldown/binding-freebsd-x64': 1.1.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 + '@rolldown/binding-linux-arm64-gnu': 1.1.4 + '@rolldown/binding-linux-arm64-musl': 1.1.4 + '@rolldown/binding-linux-ppc64-gnu': 1.1.4 + '@rolldown/binding-linux-s390x-gnu': 1.1.4 + '@rolldown/binding-linux-x64-gnu': 1.1.4 + '@rolldown/binding-linux-x64-musl': 1.1.4 + '@rolldown/binding-openharmony-arm64': 1.1.4 + '@rolldown/binding-wasm32-wasi': 1.1.4 + '@rolldown/binding-win32-arm64-msvc': 1.1.4 + '@rolldown/binding-win32-x64-msvc': 1.1.4 rollup@4.60.2: dependencies: @@ -6807,6 +5667,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6833,14 +5695,6 @@ snapshots: string-argv@0.3.2: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string_decoder@0.10.31: {} - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -6875,28 +5729,26 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.12: - dependencies: - '@pkgr/core': 0.2.9 - tailwindcss@4.2.4: {} tapable@2.3.3: {} - through2@2.0.5: - dependencies: - readable-stream: 2.3.8 - xtend: 4.0.2 - tinybench@2.9.0: {} tinyexec@1.1.2: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinyrainbow@3.1.0: {} tr46@0.0.3: {} @@ -6920,41 +5772,35 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260704.1)(synckit@0.11.12)(typescript@5.9.3): + tsdown@0.22.3(@typescript/native-preview@7.0.0-dev.20260704.1)(typescript@5.9.3): dependencies: - ansis: 4.2.0 - cac: 6.7.14 + ansis: 4.3.1 + cac: 7.0.0 defu: 6.1.7 - empathic: 2.0.0 + empathic: 2.0.1 hookable: 6.1.1 - import-without-cache: 0.2.5 - obug: 2.1.1 + import-without-cache: 0.4.0 + obug: 2.1.3 picomatch: 4.0.4 - rolldown: 1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - rolldown-plugin-dts: 0.20.0(@typescript/native-preview@7.0.0-dev.20260704.1)(rolldown@1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.9.3) - semver: 7.7.4 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 + rolldown: 1.1.4 + rolldown-plugin-dts: 0.26.0(@typescript/native-preview@7.0.0-dev.20260704.1)(rolldown@1.1.4)(typescript@5.9.3) + semver: 7.8.5 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 tree-kill: 1.2.2 unconfig-core: 7.5.0 - unrun: 0.2.37(synckit@0.11.12) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@ts-macro/tsc' - '@typescript/native-preview' - oxc-resolver - - synckit - vue-tsc tslib@2.3.1: {} tslib@2.7.0: {} - tslib@2.8.1: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -6993,14 +5839,6 @@ snapshots: universalify@2.0.1: {} - unrun@0.2.37(synckit@0.11.12): - dependencies: - rolldown: 1.0.0-rc.17 - optionalDependencies: - synckit: 0.11.12 - - untildify@4.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -7013,7 +5851,7 @@ snapshots: util-deprecate@1.0.2: {} - valibot@1.3.1(typescript@5.9.3): + valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -7101,34 +5939,10 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - ws@8.21.0: {} - xtend@4.0.2: {} - - y18n@5.0.8: {} - yallist@3.1.1: {} - yargs-parser@20.2.9: {} - - yargs@16.2.0: - dependencies: - cliui: 7.0.4 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 20.2.9 - yocto-queue@0.1.0: {} zod-validation-error@3.5.4(zod@3.25.76): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c17d473..11ae70f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,27 +1,6 @@ packages: - packages/* - apps/* - # Local CCC workspace when materialized via `pnpm forks:bootstrap`. - - forks/ccc/repo/packages/ccc - - forks/ccc/repo/packages/ckb-ccc - - forks/ccc/repo/packages/connector - - forks/ccc/repo/packages/connector-react - - forks/ccc/repo/packages/core - - forks/ccc/repo/packages/did-ckb - - forks/ccc/repo/packages/eip6963 - - forks/ccc/repo/packages/joy-id - - forks/ccc/repo/packages/lumos-patches - - forks/ccc/repo/packages/nip07 - - forks/ccc/repo/packages/okx - - forks/ccc/repo/packages/rei - - forks/ccc/repo/packages/shell - - forks/ccc/repo/packages/spore - - forks/ccc/repo/packages/ssri - - forks/ccc/repo/packages/type-id - - forks/ccc/repo/packages/udt - - forks/ccc/repo/packages/uni-sat - - forks/ccc/repo/packages/utxo-global - - forks/ccc/repo/packages/xverse auditConfig: ignoreCves: @@ -38,19 +17,11 @@ overrides: "vite@<=6.4.2": 6.4.3 "vitest@<4.1.0": 4.1.8 "ws@>=8.0.0 <8.21.0": 8.21.0 - # Keep published manifests on `catalog:` while forcing the materialized - # local CCC workspace to satisfy direct stack dependencies during installs. - # Update this list alongside any new direct `@ckb-ccc/*` dependency. - "@ckb-ccc/ccc": workspace:* - "@ckb-ccc/core": workspace:* - "@ckb-ccc/udt": workspace:* catalog: - # Catalog tracks published fallback versions; forks/config.json only owns the - # local clone refs that pnpm forks:bootstrap materializes under forks/ccc/repo. - "@ckb-ccc/ccc": ^1.1.21 - "@ckb-ccc/core": ^1.12.2 - "@ckb-ccc/udt": ^0.1.24 + "@ckb-ccc/ccc": 1.2.0 + "@ckb-ccc/core": 1.14.0 + "@ckb-ccc/udt": 0.2.0 "@types/node": ^24.8.1 minimumReleaseAge: 1440 diff --git a/scripts/check-ccc-overrides.mjs b/scripts/check-ccc-overrides.mjs deleted file mode 100644 index f3f2384..0000000 --- a/scripts/check-ccc-overrides.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const workspacePath = join(rootDir, "pnpm-workspace.yaml"); -const workspaceText = readFileSync(workspacePath, "utf8"); -const overrides = parseOverrides(workspaceText); -const directCccDeps = collectDirectCccDeps(rootDir); - -const problems = []; -for (const [name, consumers] of directCccDeps.entries()) { - const override = overrides.get(name); - if (override === undefined) { - problems.push({ - name, - reason: "missing root override", - consumers, - }); - continue; - } - if (override !== "workspace:*") { - problems.push({ - name, - reason: `override is ${override}, expected workspace:*`, - consumers, - }); - } -} - -if (problems.length > 0) { - console.error("Direct @ckb-ccc/* dependencies must be covered by root workspace overrides:"); - for (const problem of problems) { - console.error(`- ${problem.name}: ${problem.reason}`); - console.error(` consumers: ${problem.consumers.join(", ")}`); - } - process.exit(1); -} - -console.log( - `Verified ${directCccDeps.size} direct @ckb-ccc/* dependencies against root overrides.`, -); - -function collectDirectCccDeps(root) { - const manifests = [join(root, "package.json")]; - for (const group of ["packages", "apps"]) { - const groupDir = join(root, group); - for (const entry of readdirSync(groupDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const manifestPath = join(groupDir, entry.name, "package.json"); - if (!existsSync(manifestPath)) continue; - manifests.push(manifestPath); - } - } - - const deps = new Map(); - for (const manifestPath of manifests) { - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - const label = relativeLabel(root, manifestPath); - for (const field of [ - "dependencies", - "devDependencies", - "optionalDependencies", - "peerDependencies", - ]) { - for (const depName of Object.keys(manifest[field] ?? {})) { - if (!depName.startsWith("@ckb-ccc/")) continue; - const consumers = deps.get(depName) ?? []; - consumers.push(`${label} (${field})`); - deps.set(depName, consumers); - } - } - } - return deps; -} - -function parseOverrides(workspaceYaml) { - const overrides = new Map(); - let inOverrides = false; - for (const line of workspaceYaml.split(/\r?\n/u)) { - if (!inOverrides) { - if (line === "overrides:") inOverrides = true; - continue; - } - if (/^\S/u.test(line)) break; - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const match = line.match(/^\s{2}"([^"]+)":\s*(\S.*?)\s*$/u); - if (match) { - overrides.set(match[1], match[2]); - } - } - return overrides; -} - -function relativeLabel(root, filePath) { - return filePath.slice(root.length + 1, -"/package.json".length); -} diff --git a/scripts/forks-bootstrap.mjs b/scripts/forks-bootstrap.mjs deleted file mode 100644 index eca8741..0000000 --- a/scripts/forks-bootstrap.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const forksDir = join(rootDir, "forks"); -const configPath = join(forksDir, "config.json"); -const stageDir = join(forksDir, ".stage"); -const stagePrefix = join(stageDir, "bootstrap-phroi_forker."); - -const config = JSON.parse(readFileSync(configPath, "utf8")); -const upstream = config.phroi_forker?.upstream; - -if (typeof upstream !== "string" || upstream.length === 0) { - throw new Error("forks/config.json must define phroi_forker.upstream"); -} - -// Bootstrap from a temporary forker checkout so plain stack checkouts do not -// need an existing local tool clone before materializing forks/. -mkdirSync(stageDir, { recursive: true }); -const tempDir = mkdtempSync(stagePrefix); - -try { - const toolDir = join(tempDir, "repo"); - run("git", ["clone", "--filter=blob:none", "--depth", "1", upstream, toolDir]); - run("bash", [join(toolDir, "materialize-workspace.sh")]); -} finally { - rmSync(tempDir, { recursive: true, force: true }); -} - -function run(command, args) { - const result = spawnSync(command, args, { - cwd: rootDir, - stdio: "inherit", - }); - - if (result.status !== 0) { - process.exit(result.status ?? 1); - } -} diff --git a/scripts/forks-ccc-smoke.mjs b/scripts/forks-ccc-smoke.mjs deleted file mode 100644 index fee5eb9..0000000 --- a/scripts/forks-ccc-smoke.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const targets = [ - { - filter: "@ickb/utils", - script: - "const { ccc } = await import('@ckb-ccc/core'); if (!ccc) throw new Error('Missing ccc namespace export from @ckb-ccc/core'); const lock = ccc.Script.from({ codeHash: '0x' + '11'.repeat(32), hashType: 'type', args: '0x1234' }); if (!lock.eq({ codeHash: '0x' + '11'.repeat(32), hashType: 'type', args: '0x1234' })) throw new Error('Script equality rejected identical script'); if (lock.eq({ codeHash: '0x' + '11'.repeat(32), hashType: 'data1', args: '0x1234' })) throw new Error('Script equality ignored hashType'); const tx = ccc.Transaction.default(); tx.addOutput({ capacity: ccc.fixedPointFrom(100), lock }, '0x'); if (tx.outputs.length !== 1 || tx.outputsData[0] !== '0x') throw new Error('Transaction output construction failed'); if (!/^0x[0-9a-f]{64}$/.test(tx.hash())) throw new Error('Transaction hashing failed');", - }, - { - filter: "@ickb/core", - script: - "const { ccc } = await import('@ckb-ccc/core'); const { udt } = await import('@ckb-ccc/udt'); if (!udt) throw new Error('Missing udt namespace export from @ckb-ccc/udt'); const type = ccc.Script.from({ codeHash: '0x' + '22'.repeat(32), hashType: 'type', args: '0xab' }); const lock = ccc.Script.from({ codeHash: '0x' + '33'.repeat(32), hashType: 'type', args: '0x' }); const token = new udt.Udt({ txHash: '0x' + '44'.repeat(32), index: 0n }, type); const cell = ccc.Cell.from({ outPoint: { txHash: '0x' + '55'.repeat(32), index: 0n }, cellOutput: { capacity: ccc.fixedPointFrom(100), lock, type }, outputData: ccc.numLeToBytes(123n, 16) }); const info = await token.infoFrom({}, [cell]); if (info.balance !== 123n || info.count !== 1) throw new Error('UDT balance extraction failed');", - }, - { - filter: "interface", - script: - "const mod = await import('@ckb-ccc/ccc'); if (!('ccc' in mod) || !('JoyId' in mod) || !('Transaction' in mod)) throw new Error('Missing expected @ckb-ccc/ccc exports'); const lock = mod.ccc.Script.from({ codeHash: '0x' + '66'.repeat(32), hashType: 'type', args: '0x' }); const tx = mod.Transaction.default(); tx.addOutput({ capacity: mod.ccc.fixedPointFrom(61), lock }, '0x'); if (tx.outputs[0]?.capacity !== mod.ccc.fixedPointFrom(61)) throw new Error('@ckb-ccc/ccc transaction behavior failed');", - }, -]; - -for (const target of targets) { - // Run the import from the real consumer package so resolution matches - // the downstream path we want to validate, not the repo root. - const result = spawnSync( - "pnpm", - [ - "--filter", - target.filter, - "exec", - "node", - "--input-type=module", - "-e", - target.script, - ], - { - cwd: rootDir, - stdio: "inherit", - }, - ); - if (result.status !== 0) { - process.exit(result.status ?? 1); - } -} diff --git a/scripts/forks-ccc.mjs b/scripts/forks-ccc.mjs deleted file mode 100644 index 5aed84d..0000000 --- a/scripts/forks-ccc.mjs +++ /dev/null @@ -1,237 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const cccDir = join(rootDir, "forks", "ccc", "repo"); -const json = process.argv.includes("--json"); -const planOnly = json || process.argv.includes("--plan"); -const watch = process.argv.includes("--watch"); -const watchCommand = - "test -f misc/basedirs/dist/package.json && mkdir -p dist && cp misc/basedirs/dist/package.json dist/package.json; rm -rf dist.commonjs; exec tsc --watch --incremental false --preserveWatchOutput --rootDir src"; - -if (!existsSync(join(cccDir, "package.json"))) { - console.error("Missing forks/ccc/repo. Run pnpm forks:bootstrap first."); - process.exit(1); -} - -const directDeps = collectDirectCccDeps(rootDir); -const directRoots = [...directDeps.keys()].sort(); -if (directRoots.length === 0) { - console.error("No direct @ckb-ccc/* dependencies found in the stack workspace."); - process.exit(1); -} - -const cccPackages = collectCccPackages(cccDir); -const buildSurface = [...collectClosure(directRoots, directDeps, cccPackages)].sort(); -const watchSurface = buildSurface.filter((name) => isTscWatchPackage(cccPackages.get(name))); -const prebuildSurface = buildSurface.filter((name) => !watchSurface.includes(name)); -const plan = { - roots: directRoots, - buildSurface, - watchSurface, - prebuildSurface, -}; - -if (planOnly) { - if (json) { - console.log(JSON.stringify(plan, null, 2)); - } else { - printPlan(plan, { includeWatchSurface: true }); - } - process.exit(0); -} - -printPlan(plan, { includeWatchSurface: watch }); - -if (watch) { - if (prebuildSurface.length > 0) { - runPnpm([...packageFilters(prebuildSurface), "run", "build"]); - } - - runWatch(watchSurface); -} else { - runPnpm([...packageFilters(buildSurface), "run", "build"]); - cleanupTsBuildInfo(watchSurface, cccPackages); -} - -function printPlan(plan, { includeWatchSurface }) { - console.log(`CCC roots (${plan.roots.length}): ${plan.roots.join(", ")}`); - console.log( - `CCC build surface (${plan.buildSurface.length}): ${plan.buildSurface.join(", ")}`, - ); - if (!includeWatchSurface) return; - console.log( - `CCC watch prebuild-only surface (${plan.prebuildSurface.length}): ${plan.prebuildSurface.join(", ")}`, - ); - console.log( - `CCC watch surface (${plan.watchSurface.length}): ${plan.watchSurface.join(", ")}`, - ); -} - -function collectDirectCccDeps(root) { - const manifests = [join(root, "package.json")]; - for (const group of ["packages", "apps"]) { - const groupDir = join(root, group); - for (const entry of readdirSync(groupDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const manifestPath = join(groupDir, entry.name, "package.json"); - if (!existsSync(manifestPath)) continue; - manifests.push(manifestPath); - } - } - - const deps = new Map(); - for (const manifestPath of manifests) { - const manifest = readJson(manifestPath); - for (const field of [ - "dependencies", - "devDependencies", - "optionalDependencies", - "peerDependencies", - ]) { - for (const depName of Object.keys(manifest[field] ?? {})) { - if (!depName.startsWith("@ckb-ccc/")) continue; - const consumers = deps.get(depName) ?? []; - consumers.push(manifestPath); - deps.set(depName, consumers); - } - } - } - - return deps; -} - -function collectCccPackages(root) { - const packagesDir = join(root, "packages"); - const packages = new Map(); - for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const dir = join(packagesDir, entry.name); - const manifestPath = join(dir, "package.json"); - if (!existsSync(manifestPath)) continue; - const manifest = readJson(manifestPath); - packages.set(manifest.name, { - dir, - manifest, - buildScript: manifest.scripts?.build, - }); - } - return packages; -} - -function collectClosure(roots, directDeps, cccPackages) { - const closure = new Set(); - - const visit = (packageName, consumer) => { - if (closure.has(packageName)) return; - - const pkg = cccPackages.get(packageName); - if (!pkg) { - const from = consumer ? ` required by ${consumer}` : ""; - throw new Error(`Missing local CCC package for ${packageName}${from}.`); - } - - closure.add(packageName); - for (const depName of Object.keys(pkg.manifest.dependencies ?? {})) { - if (!depName.startsWith("@ckb-ccc/")) continue; - visit(depName, packageName); - } - }; - - for (const root of roots) { - visit(root, directDeps.get(root)?.[0]); - } - - return closure; -} - -function isTscWatchPackage(pkg) { - return Boolean(pkg?.buildScript?.includes("tsc") && !pkg.buildScript.includes("tsdown")); -} - -function packageFilters(packages) { - return [ - "--dir", - cccDir, - "-r", - ...packages.flatMap((name) => ["--filter", name]), - ]; -} - -function runPnpm(args) { - const result = spawnSync("pnpm", args, { - cwd: rootDir, - stdio: "inherit", - }); - if (result.status !== 0) { - process.exit(result.status ?? 1); - } -} - -function runWatch(packages) { - const child = spawn( - "pnpm", - [ - "--dir", - cccDir, - "-r", - "--parallel", - ...packages.flatMap((name) => ["--filter", name]), - "exec", - "sh", - "-c", - watchCommand, - ], - { - cwd: rootDir, - detached: true, - stdio: "inherit", - }, - ); - - const forwardSignal = (signal) => { - if (child.pid) { - try { - process.kill(-child.pid, signal); - } catch { - child.kill(signal); - } - } - }; - - process.on("SIGINT", () => forwardSignal("SIGINT")); - process.on("SIGTERM", () => forwardSignal("SIGTERM")); - child.on("error", (error) => { - console.error(error.message); - process.exit(1); - }); - child.on("exit", (code, signal) => { - cleanupTsBuildInfo(watchSurface, cccPackages); - process.exit(code ?? signalExitCode(signal)); - }); -} - -function readJson(filePath) { - return JSON.parse(readFileSync(filePath, "utf8")); -} - -function cleanupTsBuildInfo(packages, cccPackages) { - for (const name of packages) { - rmSync(join(cccPackages.get(name).dir, "tsconfig.tsbuildinfo"), { - force: true, - }); - } -} - -function signalExitCode(signal) { - switch (signal) { - case "SIGINT": - return 130; - case "SIGTERM": - return 143; - default: - return 1; - } -} diff --git a/scripts/forks-ccc.test.mjs b/scripts/forks-ccc.test.mjs deleted file mode 100644 index 729e58a..0000000 --- a/scripts/forks-ccc.test.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -const rootDir = fileURLToPath(new URL("..", import.meta.url)); -const cccDir = join(rootDir, "forks", "ccc", "repo"); - -test( - "forks-ccc --json reports the current stack-owned CCC surfaces", - { skip: !existsSync(join(cccDir, "package.json")) }, - () => { - const result = spawnSync("node", ["scripts/forks-ccc.mjs", "--json"], { - cwd: rootDir, - encoding: "utf8", - }); - - assert.equal(result.status, 0, result.stderr); - - const plan = JSON.parse(result.stdout); - assert.deepEqual(plan.roots, [ - "@ckb-ccc/ccc", - "@ckb-ccc/core", - "@ckb-ccc/udt", - ]); - assert.deepEqual(plan.buildSurface, [ - "@ckb-ccc/ccc", - "@ckb-ccc/core", - "@ckb-ccc/did-ckb", - "@ckb-ccc/eip6963", - "@ckb-ccc/joy-id", - "@ckb-ccc/nip07", - "@ckb-ccc/okx", - "@ckb-ccc/rei", - "@ckb-ccc/shell", - "@ckb-ccc/spore", - "@ckb-ccc/ssri", - "@ckb-ccc/type-id", - "@ckb-ccc/udt", - "@ckb-ccc/uni-sat", - "@ckb-ccc/utxo-global", - "@ckb-ccc/xverse", - ]); - assert.deepEqual(plan.watchSurface, [ - "@ckb-ccc/ccc", - "@ckb-ccc/core", - "@ckb-ccc/eip6963", - "@ckb-ccc/joy-id", - "@ckb-ccc/nip07", - "@ckb-ccc/okx", - "@ckb-ccc/rei", - "@ckb-ccc/shell", - "@ckb-ccc/spore", - "@ckb-ccc/ssri", - "@ckb-ccc/udt", - "@ckb-ccc/uni-sat", - "@ckb-ccc/utxo-global", - "@ckb-ccc/xverse", - ]); - assert.deepEqual(plan.prebuildSurface, [ - "@ckb-ccc/did-ckb", - "@ckb-ccc/type-id", - ]); - }, -); diff --git a/scripts/ickb-bot-systemd-update.sh b/scripts/ickb-bot-systemd-update.sh index f5c1b41..678ce31 100755 --- a/scripts/ickb-bot-systemd-update.sh +++ b/scripts/ickb-bot-systemd-update.sh @@ -245,7 +245,6 @@ main() { 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:ccc run_as_service_user "${user}" "${user_home}" "${pnpm_bin}" -C "${deploy_dir}" bot:build systemctl restart "${service}" systemctl --no-pager --full status "${service}" diff --git a/scripts/ickb-live-preflight.mjs b/scripts/ickb-live-preflight.mjs index 8d5ea0c..a5f01b8 100644 --- a/scripts/ickb-live-preflight.mjs +++ b/scripts/ickb-live-preflight.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { lstat, readFile, realpath } from "node:fs/promises"; +import { createRequire } from "node:module"; import { isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { defaultCheckIgnored } from "./ickb-live-config-git.mjs"; @@ -258,15 +259,16 @@ async function loadBuiltNodeUtils(root) { async function loadBuiltStack(root) { try { + const requireFromCore = createRequire(resolve(root, "packages/core/package.json")); const [sdk, core, cccModule] = await Promise.all([ import(pathToFileURL(join(root, "packages/sdk/dist/index.js")).href), import(pathToFileURL(join(root, "packages/core/dist/index.js")).href), - import(pathToFileURL(join(root, "forks/ccc/repo/packages/core/dist/index.js")).href), + import(pathToFileURL(requireFromCore.resolve("@ckb-ccc/core")).href), ]); return { sdk, core, ccc: cccModule.ccc }; } catch (cause) { throw new Error( - "Build required packages before running preflight, for example: pnpm --filter @ickb/node-utils --filter @ickb/sdk --filter @ickb/core build", + "Install dependencies and build required packages before running preflight, for example: pnpm --filter @ickb/node-utils --filter @ickb/sdk --filter @ickb/core build", { cause }, ); } diff --git a/scripts/ickb-supervisor-dynamic-loop.test.mjs b/scripts/ickb-supervisor-dynamic-loop.test.mjs index d0d00db..01394e7 100644 --- a/scripts/ickb-supervisor-dynamic-loop.test.mjs +++ b/scripts/ickb-supervisor-dynamic-loop.test.mjs @@ -138,20 +138,19 @@ test("dynamic supervisor loop creates a default validation session root", async assert.equal(exitCode, 3); assert.equal(writes.has("/repo/log/validation/dynamic-1700000000-4321/operator/launch.json"), true); assert.equal(appended.has("/repo/log/validation/dynamic-1700000000-4321/operator/events.ndjson"), true); - assert.deepEqual(commands.slice(0, 4).map((item) => item.args), [ - ["forks:ccc"], + assert.deepEqual(commands.slice(0, 3).map((item) => item.args), [ ["bot:build"], ["--filter", "@ickb/tester", "build"], ["--filter", "@ickb/supervisor", "build"], ]); - assert.deepEqual(commands[5].args.slice(0, 3), [ + assert.deepEqual(commands[4].args.slice(0, 3), [ "scripts/ickb-supervisor-loop.mjs", "--out-root", "log/validation/dynamic-1700000000-4321/chunks/chunk-0001", ]); - assert.equal(commands[5].args.includes("--skip-build"), true); + assert.equal(commands[4].args.includes("--skip-build"), true); + assert.equal(commands[3].options.env.NODE_OPTIONS, "--disable-warning=DEP0040"); assert.equal(commands[4].options.env.NODE_OPTIONS, "--disable-warning=DEP0040"); - assert.equal(commands[5].options.env.NODE_OPTIONS, "--disable-warning=DEP0040"); for (const command of commands) { assert.equal(command.options.env.PRIVATE_KEY, undefined); } @@ -301,19 +300,18 @@ test("dynamic supervisor loop runs selected bounded chunks", async () => { assert.equal(exitCode, 3); assert.equal(sleeps.length, 0); - assert.equal(commands.length, 6); - assert.deepEqual(commands.slice(0, 4).map((item) => item.args), [ - ["forks:ccc"], + assert.equal(commands.length, 5); + assert.deepEqual(commands.slice(0, 3).map((item) => item.args), [ ["bot:build"], ["--filter", "@ickb/tester", "build"], ["--filter", "@ickb/supervisor", "build"], ]); - assert.equal(commands[5].args.includes("all-ckb-limit-order"), true); - assert.deepEqual(commands[5].args.slice(0, 3), ["scripts/ickb-supervisor-loop.mjs", "--out-root", "log/validation/test-session/chunks/chunk-0001"]); - const separator = commands[5].args.indexOf("--"); - assert.equal(commands[5].args.slice(0, separator).includes("--out-root"), true); - assert.equal(commands[5].args.slice(0, separator).includes("--skip-build"), true); - assert.equal(commands[5].args.slice(separator + 1).includes("--target-outcome"), true); + assert.equal(commands[4].args.includes("all-ckb-limit-order"), true); + assert.deepEqual(commands[4].args.slice(0, 3), ["scripts/ickb-supervisor-loop.mjs", "--out-root", "log/validation/test-session/chunks/chunk-0001"]); + const separator = commands[4].args.indexOf("--"); + assert.equal(commands[4].args.slice(0, separator).includes("--out-root"), true); + assert.equal(commands[4].args.slice(0, separator).includes("--skip-build"), true); + assert.equal(commands[4].args.slice(separator + 1).includes("--target-outcome"), true); assert.match(output.text, /"type":"selected"/u); assert.match(output.text, /testerScenario":"all-ckb-limit-order"/u); }); @@ -385,7 +383,6 @@ test("dynamic supervisor loop refuses sessions created during prebuild", async ( assert.equal(exitCode, 1); assert.deepEqual(commands, [ - ["forks:ccc"], ["bot:build"], ["--filter", "@ickb/tester", "build"], ["--filter", "@ickb/supervisor", "build"], @@ -457,7 +454,7 @@ test("dynamic supervisor loop stops after inspection-worthy supervisor-loop reas }); assert.equal(exitCode, 0); - assert.equal(commands.filter((command) => isPrebuildCommand(command.args)).length, 4); + assert.equal(commands.filter((command) => isPrebuildCommand(command.args)).length, 3); assert.equal(commands.filter((command) => command.args[0] === "scripts/ickb-live-preflight.mjs").length, 1); assert.equal(commands.filter((command) => command.args[0] === "scripts/ickb-supervisor-loop.mjs").length, 1); assert.match(output.text, /"supervisorLoopStopReason":"tx_observed"/u); @@ -496,7 +493,7 @@ test("dynamic supervisor loop preserves supervisor-loop inspection-required stat }); assert.equal(exitCode, 3); - assert.equal(commands.filter((command) => isPrebuildCommand(command.args)).length, 4); + assert.equal(commands.filter((command) => isPrebuildCommand(command.args)).length, 3); assert.equal(commands.filter((command) => command.args[0] === "scripts/ickb-live-preflight.mjs").length, 1); assert.equal(commands.filter((command) => command.args[0] === "scripts/ickb-supervisor-loop.mjs").length, 1); assert.match(output.text, /"supervisorLoopStopReason":"max_runs"/u); @@ -698,8 +695,7 @@ function missingStat() { } function isPrebuildCommand(args) { - return args[0] === "forks:ccc" || - args[0] === "bot:build" || + return args[0] === "bot:build" || args.includes("@ickb/tester") || args.includes("@ickb/supervisor"); } diff --git a/scripts/ickb-supervisor-loop.mjs b/scripts/ickb-supervisor-loop.mjs index 2f7afed..511350d 100644 --- a/scripts/ickb-supervisor-loop.mjs +++ b/scripts/ickb-supervisor-loop.mjs @@ -18,7 +18,6 @@ const SUPERVISOR_OUTPUT_ROOT = "logs/live-supervisor"; export const INSPECTION_REQUIRED_EXIT_CODE = 3; const LOOP_OWNED_FLAGS = ["--out-root", "--max-runs", "--stable-limit", "--backoff-seconds", "--child-timeout-seconds", "--supervisor-script", "--skip-build"]; const PREBUILD_COMMANDS = [ - { target: "ccc", command: "pnpm", args: ["forks:ccc"] }, { target: "bot", command: "pnpm", args: ["bot:build"] }, { target: "tester", command: "pnpm", args: ["--filter", "@ickb/tester", "build"] }, { target: "supervisor", command: "pnpm", args: ["--filter", "@ickb/supervisor", "build"] }, @@ -96,7 +95,7 @@ export function usage() { ` --supervisor-script Default: ${DEFAULT_SUPERVISOR_SCRIPT}`, " --skip-build Do not rebuild local runtime packages before launching the supervisor", " -h, --help", - "Builds local CCC/bot/tester/supervisor runtime, then reads only each child run summary.json.", + "Builds local bot/tester/supervisor runtime, then reads only each child run summary.json.", "Loop options must appear before --; supervisor --out-dir is owned by the loop.", ].join("\n"); } diff --git a/scripts/ickb-supervisor-loop.test.mjs b/scripts/ickb-supervisor-loop.test.mjs index 9dd3bfd..816d0bf 100644 --- a/scripts/ickb-supervisor-loop.test.mjs +++ b/scripts/ickb-supervisor-loop.test.mjs @@ -336,7 +336,6 @@ test("supervisor loop prebuilds runtime before launching supervisor", async () = assert.equal(exitCode, INSPECTION_REQUIRED_EXIT_CODE); assert.deepEqual(commands.map((item) => [item.command, ...item.args]), [ - ["pnpm", "forks:ccc"], ["pnpm", "bot:build"], ["pnpm", "--filter", "@ickb/tester", "build"], ["pnpm", "--filter", "@ickb/supervisor", "build"], @@ -345,7 +344,7 @@ test("supervisor loop prebuilds runtime before launching supervisor", async () = for (const command of commands) { assert.equal(command.options.env.PRIVATE_KEY, undefined); } - for (const command of commands.slice(0, 4)) { + for (const command of commands.slice(0, 3)) { assert.equal(command.options.stdio, "ignore"); assert.equal(command.options.timeout, DEFAULT_PREBUILD_TIMEOUT_SECONDS_VALUE * 1000); assert.equal(command.options.killSignal, "SIGTERM"); @@ -379,8 +378,8 @@ test("supervisor loop reports prebuild failures without child output", async () assert.equal(exitCode, 1); assert.match(output.text, /loop prebuild_failed/u); - assert.match(output.text, /target=ccc/u); - assert.match(output.text, /command=pnpm_forks:ccc/u); + assert.match(output.text, /target=bot/u); + assert.match(output.text, /command=pnpm_bot:build/u); assert.doesNotMatch(output.text, /privateKey|0x1111|operator secret|0x2222/u); }); diff --git a/vitest.config.mts b/vitest.config.mts index d37526e..f7c039e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -2,7 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - passWithNoTests: true, projects: ["packages/*", "apps/bot", "apps/interface", "apps/sampler", "apps/supervisor", "apps/tester"], coverage: { include: ["packages/*", "apps/bot", "apps/interface", "apps/sampler", "apps/supervisor", "apps/tester"],