diff --git a/apps/interface/src/Form.tsx b/apps/interface/src/Form.tsx index f40e164..37d333f 100644 --- a/apps/interface/src/Form.tsx +++ b/apps/interface/src/Form.tsx @@ -3,8 +3,8 @@ import { IckbSdk, type SystemState } from "@ickb/sdk"; import { CKB, direction2Symbol, - reservedCKB, sanitizeAmountInput, + spendableCkb, symbol2Direction, toText, } from "./utils.ts"; @@ -41,8 +41,8 @@ export default function Form({ setRawText(direction2Symbol(!isCkb2Udt) + text); }; - const spendableCkb = ckbAvailable > reservedCKB ? ckbAvailable - reservedCKB : 0n; - const nativeCkb = spendableCkb < ckbNative ? spendableCkb : ckbNative; + const maxCkb = spendableCkb(ckbAvailable); + const nativeCkb = maxCkb < ckbNative ? maxCkb : ckbNative; let a = { name: "CKB", diff --git a/apps/interface/src/queries.ts b/apps/interface/src/queries.ts index 3b0d9ed..a239b1d 100644 --- a/apps/interface/src/queries.ts +++ b/apps/interface/src/queries.ts @@ -1,5 +1,5 @@ import { - projectAccountAvailability, + projectConversionTransactionContext, type SystemState, } from "@ickb/sdk"; import { @@ -88,7 +88,7 @@ export async function getL1State( walletConfig.accountLocks, ); const { system, user, account } = sdkState; - const projection = projectAccountAvailability(account, user.orders, { + const { projection, context: conversionContext } = projectConversionTransactionContext(system, account, user.orders, { collectedOrdersAvailable: true, }); const hasMatchable = user.orders.some((group) => group.order.isMatchable()); @@ -99,30 +99,14 @@ export async function getL1State( ickbBalance, ckbAvailable, ickbAvailable, - readyWithdrawals, pendingWithdrawals, - availableOrders, pendingOrders, } = projection; - const estimatedMaturity = [ - system.tip.timestamp, - ...pendingWithdrawals.map((group) => group.owned.maturity.toUnix(system.tip)), - ...pendingOrders - .map((group) => group.order.maturity) - .filter((maturity): maturity is bigint => maturity !== undefined), - ].reduce((best, maturity) => (best > maturity ? best : maturity)); - const txContext: TransactionContext = { - system, + ...conversionContext, capacityCells: account.capacityCells, nativeUdtCells: account.nativeUdtCells, - receipts: account.receipts, - readyWithdrawals, - availableOrders, - ckbAvailable, - ickbAvailable, - estimatedMaturity, }; return { diff --git a/apps/interface/src/transaction.test.ts b/apps/interface/src/transaction.test.ts index 861fc74..e9bd857 100644 --- a/apps/interface/src/transaction.test.ts +++ b/apps/interface/src/transaction.test.ts @@ -8,7 +8,7 @@ import { byte32FromByte } from "@ickb/testkit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildTransactionPreview } from "./transaction.ts"; import type { TransactionContext } from "./transaction.ts"; -import type { WalletConfig } from "./utils.ts"; +import { CKB, reservedCKB, type WalletConfig } from "./utils.ts"; type BuildConversionTransactionMock = ReturnType< typeof vi.fn @@ -134,6 +134,8 @@ describe("buildTransactionPreview", () => { .resolves.toMatchObject({ error: "Amount must be positive" }); await expect(buildTransactionPreview(context({ ckbAvailable: 1n }), true, 2n, config)) .resolves.toMatchObject({ error: "Not enough CKB" }); + await expect(buildTransactionPreview(context({ ckbAvailable: reservedCKB + CKB }), true, reservedCKB + CKB, config)) + .resolves.toMatchObject({ error: "Not enough CKB" }); await expect(buildTransactionPreview(context({ ickbAvailable: 1n }), false, 2n, config)) .resolves.toMatchObject({ error: "Not enough iCKB" }); expect(buildConversionTransaction).not.toHaveBeenCalled(); @@ -156,7 +158,7 @@ describe("buildTransactionPreview", () => { const completeTransaction = completeTransactionMock(); vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(42n); const txContext = context({ - ckbAvailable: 7n, + ckbAvailable: reservedCKB + 7n, system: { ...context().system, feeRate: 9n }, }); const config = walletConfigWith({ @@ -215,7 +217,7 @@ describe("buildTransactionPreview", () => { sdk: { buildConversionTransaction: buildConversionTransactionMock(failedPlan(reason, 77n)) }, }); - await expect(buildTransactionPreview(context({ ckbAvailable: 1n }), true, 1n, config)) + await expect(buildTransactionPreview(context({ ckbAvailable: reservedCKB + 1n }), true, 1n, config)) .resolves.toMatchObject({ error: message, estimatedMaturity: 77n }); } }); @@ -242,7 +244,7 @@ describe("buildTransactionPreview", () => { vi.spyOn(ccc.Transaction.prototype, "getFee").mockResolvedValue(1n); await buildTransactionPreview( - context({ ckbAvailable: 1n }), + context({ ckbAvailable: reservedCKB + 1n }), true, 1n, walletConfigWith({ @@ -264,7 +266,7 @@ describe("buildTransactionPreview", () => { .mockRejectedValue(new Error("planner failed")), }, }); - await expect(buildTransactionPreview(context({ ckbAvailable: 1n }), true, 1n, plannerFailure)) + await expect(buildTransactionPreview(context({ ckbAvailable: reservedCKB + 1n }), true, 1n, plannerFailure)) .resolves.toMatchObject({ error: "planner failed" }); const completionFailure = walletConfigWith({ @@ -274,7 +276,7 @@ describe("buildTransactionPreview", () => { .mockRejectedValue(new Error("completion failed")), }, }); - await expect(buildTransactionPreview(context({ ckbAvailable: 1n }), true, 1n, completionFailure)) + await expect(buildTransactionPreview(context({ ckbAvailable: reservedCKB + 1n }), true, 1n, completionFailure)) .resolves.toMatchObject({ error: "completion failed" }); }); }); diff --git a/apps/interface/src/transaction.ts b/apps/interface/src/transaction.ts index 1886e82..073f25e 100644 --- a/apps/interface/src/transaction.ts +++ b/apps/interface/src/transaction.ts @@ -5,6 +5,7 @@ import { } from "@ickb/sdk"; import { errorMessageOf, + spendableCkb, txInfoPadding, type TxInfo, type WalletConfig, @@ -26,7 +27,7 @@ export async function buildTransactionPreview( return txInfoWithError("Amount must be positive", context.estimatedMaturity); } - if (isCkb2Udt && amount > context.ckbAvailable) { + if (isCkb2Udt && amount > spendableCkb(context.ckbAvailable)) { return txInfoWithError("Not enough CKB", context.estimatedMaturity); } diff --git a/apps/interface/src/utils.ts b/apps/interface/src/utils.ts index ddfb763..fda5820 100644 --- a/apps/interface/src/utils.ts +++ b/apps/interface/src/utils.ts @@ -49,6 +49,10 @@ export const CKB = ccc.fixedPointFrom(1); // reservedCKB are reserved for state rent in conversions export const reservedCKB = 600n * CKB; +export function spendableCkb(ckbAvailable: bigint): bigint { + return ckbAvailable > reservedCKB ? ckbAvailable - reservedCKB : 0n; +} + export function parseWalletChain(walletChain: string): WalletChainParts { const separatorIndex = walletChain.lastIndexOf("_"); if (separatorIndex <= 0 || separatorIndex === walletChain.length - 1) { diff --git a/package.json b/package.json index 2797c10..0abf726 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "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", + "live:generate-config": "node scripts/ickb-generate-config.mjs", + "live:preflight": "node scripts/ickb-live-preflight.mjs", "coworker:ask": "opencode run --pure --agent plan" }, "engines": { diff --git a/packages/node-utils/src/index.test.ts b/packages/node-utils/src/index.test.ts index ca9bea4..da8f935 100644 --- a/packages/node-utils/src/index.test.ts +++ b/packages/node-utils/src/index.test.ts @@ -1,11 +1,13 @@ import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, script } from "@ickb/testkit"; +import { byte32FromByte, headerLike, script } from "@ickb/testkit"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import process from "node:process"; import { tmpdir } from "node:os"; import { describe, expect, it, vi } from "vitest"; import { + assertChainPreflight, + CHAIN_IDENTITIES, createPublicClient, formatCkb, handleLoopError, @@ -16,10 +18,15 @@ import { readRuntimeConfigEnv, parseSleepInterval, randomSleepIntervalMs, + readChainPreflight, + redactRpcUrl, + redactSecretText, reachedMaxIterations, signerAccountLocks, STOP_EXIT_CODE, + verifyChainPreflight, writeJsonLine, + type ChainPreflightClient, } from "./index.js"; describe("node utilities", () => { @@ -74,6 +81,7 @@ describe("node utilities", () => { it("parses private keys as exact 0x-prefixed lowercase hex", () => { const privateKey = `0x${"11".repeat(32)}`; + const secp256k1Order = "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"; expect(parsePrivateKey(privateKey, "BOT_CONFIG_FILE")).toBe(privateKey); for (const value of [ @@ -83,6 +91,9 @@ describe("node utilities", () => { ` 0x${"11".repeat(32)}`, `0x${"11".repeat(32)} `, `0x${"11".repeat(31)}`, + `0x${"00".repeat(32)}`, + secp256k1Order, + "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142", ]) { expect(() => parsePrivateKey(value, "BOT_CONFIG_FILE")).toThrow( "Invalid env BOT_CONFIG_FILE", @@ -148,6 +159,7 @@ describe("node utilities", () => { it("reads runtime JSON config from a file env source", async () => { const privateKey = `0x${"11".repeat(32)}`; const dir = await mkdtemp(join(tmpdir(), "ickb-runtime-config-")); + const originalInitCwd = process.env.INIT_CWD; try { const configPath = join(dir, "config.json"); await writeFile(configPath, JSON.stringify({ @@ -170,7 +182,20 @@ describe("node utilities", () => { await expect(readRuntimeConfigEnv(join(dir, "missing"), "BOT_CONFIG_FILE")).rejects.toThrow( "Invalid file from env BOT_CONFIG_FILE", ); + process.env.INIT_CWD = dir; + await expect(readRuntimeConfigEnv("config.json", "BOT_CONFIG_FILE")).resolves.toMatchObject({ + chain: "testnet", + privateKey, + }); + await expect(readRuntimeConfigEnv(resolve("config.json"), "BOT_CONFIG_FILE")).rejects.toThrow( + "Invalid file from env BOT_CONFIG_FILE", + ); } finally { + if (originalInitCwd === undefined) { + delete process.env.INIT_CWD; + } else { + process.env.INIT_CWD = originalInitCwd; + } await rm(dir, { recursive: true, force: true }); } }); @@ -205,6 +230,172 @@ describe("node utilities", () => { ); }); + it("pins official CKB chain identities for preflight checks", () => { + expect(CHAIN_IDENTITIES.mainnet).toMatchObject({ + chain: "mainnet", + networkName: "ckb", + genesisHash: "0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5", + genesisMessage: "lina 0x18e020f6b1237a3d06b75121f25a7efa0550e4b3f44f974822f471902424c104", + genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/mainnet.toml", + addressPrefix: "ckb", + }); + expect(CHAIN_IDENTITIES.testnet).toMatchObject({ + chain: "testnet", + networkName: "ckb_testnet", + genesisHash: "0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606", + genesisMessage: "aggron-v4", + genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/testnet.toml", + addressPrefix: "ckt", + }); + }); + + it("reads and verifies public chain identity evidence", async () => { + const client = preflightClient({ + addressPrefix: "ckt", + genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, + tipHash: byte32FromByte("22"), + tipNumber: 123n, + tipTimestamp: 456n, + url: "https://user:pass@testnet.example/rpc/path?token=secret&plain=value", + }); + + await expect(readChainPreflight(client, "testnet")).resolves.toEqual({ + chain: "testnet", + redactedRpcUrl: "https://redacted:redacted@testnet.example/...?token=redacted&plain=redacted", + expected: CHAIN_IDENTITIES.testnet, + observed: { + genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, + addressPrefix: "ckt", + tip: { + hash: byte32FromByte("22"), + number: 123n, + timestamp: 456n, + }, + }, + matches: { + genesisHash: true, + addressPrefix: true, + }, + }); + await expect(verifyChainPreflight(client, "testnet")).resolves.toMatchObject({ + chain: "testnet", + matches: { genesisHash: true, addressPrefix: true }, + }); + }); + + it("rejects mismatched public chain identity evidence", () => { + expect(() => assertChainPreflight({ + chain: "testnet", + redactedRpcUrl: "https://rpc.example/", + expected: CHAIN_IDENTITIES.testnet, + observed: { + genesisHash: CHAIN_IDENTITIES.mainnet.genesisHash, + addressPrefix: "ckb", + tip: { hash: byte32FromByte("22"), number: 1n, timestamp: 2n }, + }, + matches: { genesisHash: false, addressPrefix: false }, + })).toThrow( + "Invalid testnet RPC chain identity: genesis hash expected " + + CHAIN_IDENTITIES.testnet.genesisHash + + " observed " + + CHAIN_IDENTITIES.mainnet.genesisHash + + "; address prefix expected ckt observed ckb", + ); + }); + + it("redacts RPC URLs when chain preflight reads fail", async () => { + const client = preflightClient({ + addressPrefix: "ckt", + genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, + tipHash: byte32FromByte("22"), + tipNumber: 123n, + tipTimestamp: 456n, + url: "https://user:pass@testnet.example/rpc/path?token=secret", + }); + client.getHeaderByNumber = (): Promise => { + throw new Error("RPC failed: https://user:pass@testnet.example/rpc/path?token=secret user pass secret"); + }; + + await expect(verifyChainPreflight(client, "testnet")).rejects.toThrow( + "RPC failed: https://redacted:redacted@testnet.example/...?token=redacted", + ); + await expect(verifyChainPreflight(client, "testnet")).rejects.not.toThrow(/secret|user:pass/u); + await expect(verifyChainPreflight(client, "testnet")).rejects.not.toThrow(/\buser\b|\bpass\b/u); + }); + + it("redacts non-Error preflight failures without losing thrown values", async () => { + const client = preflightClient({ + addressPrefix: "ckt", + genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, + tipHash: byte32FromByte("22"), + tipNumber: 123n, + tipTimestamp: 456n, + url: "https://testnet.example/rpc/path?token=secret", + }); + client.getHeaderByNumber = (): Promise => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Covers defensive handling for non-Error RPC failures. + return Promise.reject({ + reason: "failed", + amount: 9007199254740993n, + token: "secret", + }); + }; + + await expect(verifyChainPreflight(client, "testnet")).rejects.toThrow( + '{"reason":"failed","amount":"9007199254740993"}', + ); + await expect(verifyChainPreflight(client, "testnet")).rejects.toMatchObject({ + cause: { + reason: "failed", + amount: "9007199254740993", + }, + }); + }); + + it("redacts credential-bearing RPC URLs", () => { + expect(redactRpcUrl("https://rpc.example/")).toBe("https://rpc.example/"); + expect(redactRpcUrl("https://rpc.example/path?token=abc&key=def")).toBe( + "https://rpc.example/...?token=redacted&key=redacted", + ); + expect(redactRpcUrl("not a url")).toBe(""); + }); + + it("redacts runtime secrets from text", () => { + const privateKey = `0x${"11".repeat(32)}`; + const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret"; + + expect(redactSecretText( + `failed for ${privateKey} via ${rpcUrl}`, + { privateKey, rpcUrl }, + )).toBe( + "failed for via https://redacted:redacted@testnet.example/...?token=redacted", + ); + expect(redactSecretText( + "fetch failed for https://testnet.example/rpc/path?token=secret auth user:pass", + { rpcUrl }, + )).toBe( + "fetch failed for https://testnet.example/rpc/path?token= auth " + + ":", + ); + expect(redactSecretText( + "fetch failed for token=a%2Fb decoded=a/b plain=value", + { rpcUrl: "https://testnet.example/rpc/path?token=a%2Fb&plain=value" }, + )).toBe( + "fetch failed for token= decoded= " + + "plain=", + ); + expect(redactSecretText("empty secrets stay intact", { privateKey: "", rpcUrl: "" })).toBe( + "empty secrets stay intact", + ); + expect(redactSecretText( + "fetch failed for https://%E0%A4%A@testnet.example/rpc?token=secret %E0%A4%A", + { rpcUrl: "https://%E0%A4%A@testnet.example/rpc?token=secret" }, + )).toBe( + "fetch failed for https://redacted@testnet.example/...?token=redacted " + + "", + ); + }); + it("serializes error-like values for JSON logs", () => { const executionLog: Record = {}; @@ -247,6 +438,80 @@ describe("node utilities", () => { process.exitCode = undefined; }); + it("redacts runtime secrets from loop errors", () => { + const privateKey = `0x${"11".repeat(32)}`; + const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret"; + const error = new Error(`failed for ${privateKey} via ${rpcUrl}`, { + cause: new Error(`nested ${privateKey} via ${rpcUrl}`), + }); + error.stack = `stack with ${privateKey} and ${rpcUrl}`; + (error.cause as Error).stack = `nested stack with ${privateKey} and ${rpcUrl}`; + const executionLog: Record = {}; + + expect(handleLoopError(executionLog, error, { privateKey, rpcUrl })).toBe(false); + const serialized = JSON.stringify(executionLog); + + expect(serialized).not.toContain(privateKey); + expect(serialized).not.toContain("user:pass"); + expect(serialized).not.toContain("secret"); + expect(serialized).toContain(""); + expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted"); + expect(executionLog.error).toMatchObject({ + cause: { + name: "Error", + message: "nested via " + + "https://redacted:redacted@testnet.example/...?token=redacted", + }, + }); + }); + + it("redacts runtime secrets from non-Error loop failures", () => { + const privateKey = `0x${"11".repeat(32)}`; + const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret"; + const executionLog: Record = {}; + const circular: Record = {}; + circular.self = circular; + + expect(handleLoopError(executionLog, { + message: `failed for ${privateKey} via ${rpcUrl}`, + privateKey, + rpcUrl, + amount: 9007199254740993n, + nested: { + private_key: privateKey, + rpc_url: rpcUrl, + password: "hunter2", + apiKey: "api-key-value", + accessToken: "secret-token", + api_secret: "secret-value", + message: `nested ${privateKey}`, + }, + circular, + }, { privateKey, rpcUrl })).toBe(false); + const serialized = JSON.stringify(executionLog); + + expect(serialized).not.toContain(privateKey); + expect(serialized).not.toContain("user:pass"); + expect(serialized).not.toContain("secret"); + expect(serialized).toContain(""); + expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted"); + expect(executionLog.error).toMatchObject({ + message: "failed for via " + + "https://redacted:redacted@testnet.example/...?token=redacted", + amount: "9007199254740993", + nested: { message: "nested " }, + circular: { self: "[Circular]" }, + }); + expect(executionLog.error).not.toHaveProperty("rpcUrl"); + expect(executionLog.error).not.toHaveProperty("privateKey"); + expect((executionLog.error as { nested?: unknown }).nested).not.toHaveProperty("rpc_url"); + expect((executionLog.error as { nested?: unknown }).nested).not.toHaveProperty("private_key"); + expect((executionLog.error as { nested?: unknown }).nested).not.toHaveProperty("password"); + expect((executionLog.error as { nested?: unknown }).nested).not.toHaveProperty("apiKey"); + expect((executionLog.error as { nested?: unknown }).nested).not.toHaveProperty("accessToken"); + expect((executionLog.error as { nested?: unknown }).nested).not.toHaveProperty("api_secret"); + }); + it("logs one JSON entry with elapsed seconds", () => { const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); const now = vi.spyOn(Date, "now").mockReturnValue(2500); @@ -348,3 +613,35 @@ function transactionError(isTimeout: boolean, txHash = byte32FromByte("11")): Er isTimeout, }); } + +function preflightClient({ + addressPrefix, + genesisHash, + tipHash, + tipNumber, + tipTimestamp, + url, +}: { + addressPrefix: string; + genesisHash: `0x${string}`; + tipHash: `0x${string}`; + tipNumber: bigint; + tipTimestamp: bigint; + url: string; +}): ChainPreflightClient { + return { + addressPrefix, + url, + getHeaderByNumber: async (blockNumber): Promise => { + await Promise.resolve(); + if (blockNumber !== 0n) { + return; + } + return headerLike({ hash: genesisHash, number: 0n }); + }, + getTipHeader: async (): Promise => { + await Promise.resolve(); + return headerLike({ hash: tipHash, number: tipNumber, timestamp: tipTimestamp }); + }, + }; +} diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts index 072a043..d01eda6 100644 --- a/packages/node-utils/src/index.ts +++ b/packages/node-utils/src/index.ts @@ -1,15 +1,261 @@ import { ccc } from "@ckb-ccc/core"; import { unique } from "@ickb/utils"; import { readFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; import process from "node:process"; import { setTimeout } from "node:timers"; const CKB = 100000000n; +const SECP256K1_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); export const STOP_EXIT_CODE = 2; export type SupportedChain = "mainnet" | "testnet"; +export interface ChainIdentity { + chain: SupportedChain; + networkName: string; + genesisHash: ccc.Hex; + genesisMessage: string; + genesisSource: string; + addressPrefix: "ckb" | "ckt"; +} + +export const CHAIN_IDENTITIES = { + mainnet: { + chain: "mainnet", + networkName: "ckb", + genesisHash: "0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5", + genesisMessage: "lina 0x18e020f6b1237a3d06b75121f25a7efa0550e4b3f44f974822f471902424c104", + genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/mainnet.toml", + addressPrefix: "ckb", + }, + testnet: { + chain: "testnet", + networkName: "ckb_testnet", + genesisHash: "0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606", + genesisMessage: "aggron-v4", + genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/testnet.toml", + addressPrefix: "ckt", + }, +} as const satisfies Record; + +export type ChainPreflightClient = Pick< + ccc.Client, + "addressPrefix" | "getHeaderByNumber" | "getTipHeader" | "url" +>; + +export interface ChainPreflightEvidence { + chain: SupportedChain; + redactedRpcUrl: string; + expected: ChainIdentity; + observed: { + genesisHash: ccc.Hex; + addressPrefix: string; + tip: { + hash: ccc.Hex; + number: bigint; + timestamp: bigint; + }; + }; + matches: { + genesisHash: boolean; + addressPrefix: boolean; + }; +} + +export function expectedChainIdentity(chain: SupportedChain): ChainIdentity { + return CHAIN_IDENTITIES[chain]; +} + +export async function readChainPreflight( + client: ChainPreflightClient, + chain: SupportedChain, +): Promise { + const expected = expectedChainIdentity(chain); + const [genesis, tip] = await Promise.all([ + client.getHeaderByNumber(0n), + client.getTipHeader(), + ]); + + if (genesis === undefined) { + throw new Error(`Missing ${chain} genesis header`); + } + + return { + chain, + redactedRpcUrl: redactRpcUrl(client.url), + expected, + observed: { + genesisHash: genesis.hash, + addressPrefix: client.addressPrefix, + tip: { + hash: tip.hash, + number: tip.number, + timestamp: tip.timestamp, + }, + }, + matches: { + genesisHash: genesis.hash === expected.genesisHash, + addressPrefix: client.addressPrefix === expected.addressPrefix, + }, + }; +} + +export function assertChainPreflight( + evidence: ChainPreflightEvidence, +): ChainPreflightEvidence { + const failures: string[] = []; + if (evidence.observed.genesisHash !== evidence.expected.genesisHash) { + failures.push( + `genesis hash expected ${evidence.expected.genesisHash} observed ${evidence.observed.genesisHash}`, + ); + } + if (evidence.observed.addressPrefix !== evidence.expected.addressPrefix) { + failures.push( + `address prefix expected ${evidence.expected.addressPrefix} observed ${evidence.observed.addressPrefix}`, + ); + } + if (failures.length > 0) { + throw new Error(`Invalid ${evidence.chain} RPC chain identity: ${failures.join("; ")}`); + } + + return evidence; +} + +export async function verifyChainPreflight( + client: ChainPreflightClient, + chain: SupportedChain, +): Promise { + try { + return assertChainPreflight(await readChainPreflight(client, chain)); + } catch (error) { + const secrets = { rpcUrl: client.url }; + throw new Error(redactRpcUrlInError(error, secrets), { + cause: errorToLogValue(error, secrets, new WeakSet()), + }); + } +} + +function redactRpcUrlInError(error: unknown, secrets: SecretRedactionContext): string { + const message = typeof error === "string" + ? error + : error instanceof Error + ? error.message + : stringifyErrorMessage(error, secrets); + return redactSecretText(message, secrets); +} + +export function redactSecretText(text: string, secrets: SecretRedactionContext = {}): string { + let redacted = text; + if (secrets.privateKey) { + redacted = redacted.split(secrets.privateKey).join(""); + } + if (secrets.rpcUrl) { + redacted = redacted.split(secrets.rpcUrl).join(secrets.redactedRpcUrl ?? redactRpcUrl(secrets.rpcUrl)); + redacted = redactRpcUrlSecrets(redacted, secrets.rpcUrl); + } + return redacted; +} + +function stringifyErrorMessage(error: unknown, secrets: SecretRedactionContext): string { + if (error === undefined || error === null) { + return "Unknown error"; + } + try { + return JSON.stringify(sanitizeLogValue(error, secrets, new WeakSet()), jsonLogReplacer); + } catch { + return "Unknown error"; + } +} + +function redactRpcUrlSecrets(text: string, rpcUrl: string): string { + let url: URL; + try { + url = new URL(rpcUrl); + } catch { + return text; + } + + const replacements = new Array<[string, string]>(); + if (url.username !== "") { + replacements.push([url.username, ""]); + replacements.push([safeDecodeURIComponent(url.username), ""]); + } + if (url.password !== "") { + replacements.push([url.password, ""]); + replacements.push([safeDecodeURIComponent(url.password), ""]); + } + for (const value of url.searchParams.values()) { + replacements.push([value, ""]); + } + for (const value of rawSearchParamValues(url.search)) { + replacements.push([value, ""]); + } + return replaceUrlSecrets(text, replacements); +} + +function rawSearchParamValues(search: string): string[] { + const query = search.startsWith("?") ? search.slice(1) : search; + if (query === "") { + return []; + } + return query.split("&").map((part) => { + const separator = part.indexOf("="); + return separator === -1 ? "" : part.slice(separator + 1); + }); +} + +function replaceUrlSecrets(text: string, replacements: Array<[string, string]>): string { + const unique = new Map(replacements.filter(([secret]) => secret !== "")); + if (unique.size === 0) { + return text; + } + const pattern = [...unique.keys()] + .sort((left, right) => right.length - left.length) + .map(escapeRegExp) + .join("|"); + return text.replace(new RegExp(pattern, "gu"), (match) => unique.get(match) ?? match); +} + +function safeDecodeURIComponent(text: string): string { + try { + return decodeURIComponent(text); + } catch { + return ""; + } +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +export function redactRpcUrl(rpcUrl: string): string { + let url: URL; + try { + url = new URL(rpcUrl); + } catch { + return ""; + } + + if (url.username !== "" || url.password !== "") { + url.username = "redacted"; + url.password = url.password === "" ? "" : "redacted"; + } + if (url.pathname !== "" && url.pathname !== "/") { + url.pathname = "/..."; + } + if (url.search !== "") { + const redactedParams = new URLSearchParams(); + for (const [key] of url.searchParams) { + redactedParams.append(key, "redacted"); + } + url.search = redactedParams.toString(); + } + + return url.toString(); +} + export function formatCkb(balance: bigint): string { const sign = balance < 0n ? "-" : ""; const absolute = balance < 0n ? -balance : balance; @@ -40,7 +286,10 @@ export function parseSleepInterval( export function parsePrivateKey(privateKey: string, envName: string): `0x${string}` { if (/^0x[0-9a-f]{64}$/u.test(privateKey)) { - return privateKey as `0x${string}`; + const value = BigInt(privateKey); + if (value > 0n && value < SECP256K1_ORDER) { + return privateKey as `0x${string}`; + } } throw new Error("Invalid env " + envName); @@ -54,6 +303,12 @@ export type RuntimeConfig = { maxIterations: number | undefined; }; +export interface SecretRedactionContext { + privateKey?: string; + rpcUrl?: string; + redactedRpcUrl?: string; +} + export function parseRpcUrl(rpcUrl: string, envName: string): string { for (let index = 0; index < rpcUrl.length; index += 1) { const code = rpcUrl.charCodeAt(index); @@ -163,7 +418,9 @@ export async function readRuntimeConfigEnv( } async function readFileEnv(fileEnvValue: string, fileEnvName: string): Promise { - const secretPath = fileEnvValue; + const secretPath = isAbsolute(fileEnvValue) + ? fileEnvValue + : resolve(process.env.INIT_CWD ?? process.cwd(), fileEnvValue); let fileSecret: string; try { fileSecret = await readFile(secretPath, "utf8"); @@ -196,24 +453,97 @@ export async function signerAccountLocks( ])]; } -function errorToLog(error: unknown): unknown { +function errorToLog(error: unknown, secrets: SecretRedactionContext = {}): unknown { + return errorToLogValue(error, secrets, new WeakSet()); +} + +function errorToLogValue( + error: unknown, + secrets: SecretRedactionContext, + seen: WeakSet, +): unknown { if (error instanceof Object && "stack" in error) { - const stack = error.stack ?? ""; - return { + if (seen.has(error)) { + return "[Circular]"; + } + seen.add(error); + const stack = redactSecretText(typeof error.stack === "string" ? error.stack : "", secrets); + const logged: Record = { name: "name" in error ? error.name : undefined, message: "message" in error && typeof error.message === "string" - ? error.message + ? redactSecretText(error.message, secrets) : "Unknown error", txHash: "txHash" in error ? error.txHash : undefined, status: "status" in error ? error.status : undefined, stack, }; + try { + if ("cause" in error) { + logged.cause = errorToLogValue((error as { cause?: unknown }).cause, secrets, seen); + } + return logged; + } finally { + seen.delete(error); + } + } + + if (typeof error === "object" && error !== null) { + return sanitizeLogValue(error, secrets, new WeakSet()); + } + + if (typeof error === "string") { + return redactSecretText(error, secrets); } return error ?? "Empty Error"; } +function sanitizeLogValue( + value: unknown, + secrets: SecretRedactionContext, + seen: WeakSet, +): unknown { + if (typeof value === "string") { + return redactSecretText(value, secrets); + } + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value !== "object" || value === null) { + return value; + } + if (value instanceof Object && "stack" in value) { + return errorToLogValue(value, secrets, seen); + } + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + try { + if (Array.isArray(value)) { + return value.map((entry) => sanitizeLogValue(entry, secrets, seen)); + } + const sanitized: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (isSensitiveLogKey(key)) { + continue; + } + sanitized[key] = sanitizeLogValue(entry, secrets, seen); + } + return sanitized; + } finally { + seen.delete(value); + } +} + +function isSensitiveLogKey(key: string): boolean { + const normalized = key.toLowerCase().replace(/[-_]/gu, ""); + return ["privatekey", "rpcurl", "apikey", "password", "token", "secret"].some( + (sensitiveKey) => normalized === sensitiveKey || normalized.endsWith(sensitiveKey), + ); +} + function shouldStopAfterError(error: unknown): boolean { return error instanceof Error && error.name === "TransactionConfirmationError" && @@ -224,8 +554,9 @@ function shouldStopAfterError(error: unknown): boolean { export function handleLoopError( executionLog: Record, error: unknown, + secrets: SecretRedactionContext = {}, ): boolean { - executionLog.error = errorToLog(error); + executionLog.error = errorToLog(error, secrets); if (shouldStopAfterError(error)) { process.exitCode = STOP_EXIT_CODE; return true; diff --git a/packages/order/src/order.test.ts b/packages/order/src/order.test.ts index c1c7044..b96c018 100644 --- a/packages/order/src/order.test.ts +++ b/packages/order/src/order.test.ts @@ -486,6 +486,37 @@ describe("OrderManager.addMatch", () => { }); }); +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 = { diff --git a/packages/order/src/order.ts b/packages/order/src/order.ts index 0c1f839..1d8921f 100644 --- a/packages/order/src/order.ts +++ b/packages/order/src/order.ts @@ -192,15 +192,18 @@ export class OrderManager implements ScriptDeps { tx.addCellDeps(this.cellDeps); // Append order cell to Outputs - const position = tx.addOutput( + const outputCount = tx.addOutput( { lock: this.script, type: this.udtScript, }, data.toBytes(), ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - tx.outputs[position]!.capacity += ckbValue; + 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({ diff --git a/packages/sdk/src/sdk.test.ts b/packages/sdk/src/sdk.test.ts index 337477d..5f3c627 100644 --- a/packages/sdk/src/sdk.test.ts +++ b/packages/sdk/src/sdk.test.ts @@ -30,6 +30,7 @@ import { IckbSdk, MAX_DIRECT_DEPOSITS, projectAccountAvailability, + projectConversionTransactionContext, sendAndWaitForCommit, TransactionConfirmationError, type SystemState, @@ -539,6 +540,77 @@ describe("projectAccountAvailability", () => { }); }); +describe("projectConversionTransactionContext", () => { + it("projects conversion context from account state and collected-order policy", () => { + const readyWithdrawal = { owned: { isReady: true }, ckbValue: 11n, udtValue: 0n } as WithdrawalGroup; + const pendingWithdrawal = { + owned: { isReady: false, maturity: { toUnix: (): bigint => 5000n } }, + ckbValue: 17n, + udtValue: 0n, + } as WithdrawalGroup; + const matchable = orderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + Object.defineProperty(matchable.order, "maturity", { value: 7000n }); + const receipt = { ckbValue: 41n, udtValue: 43n } as ReceiptCell; + const account = { + capacityCells: [{ cellOutput: { capacity: 3n } } as ccc.Cell], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 7n, + receipts: [receipt], + withdrawalGroups: [readyWithdrawal, pendingWithdrawal], + }; + const currentSystem = system({ tip: headerLike(0n, { timestamp: 1000n }) }); + + const { projection, context } = projectConversionTransactionContext( + currentSystem, + account, + [matchable], + { collectedOrdersAvailable: true }, + ); + + expect(projection.availableOrders).toEqual([matchable]); + expect(context).toEqual({ + system: currentSystem, + receipts: [receipt], + readyWithdrawals: [readyWithdrawal], + availableOrders: [matchable], + ckbAvailable: projection.ckbAvailable, + ickbAvailable: projection.ickbAvailable, + estimatedMaturity: 5000n, + }); + }); + + it("includes pending order maturity when collected orders are not budgeted", () => { + const matchable = orderGroup({ + ckbValue: 31n, + udtValue: 37n, + isDualRatio: false, + isMatchable: true, + }); + Object.defineProperty(matchable.order, "maturity", { value: 7000n }); + + const { context } = projectConversionTransactionContext( + system({ tip: headerLike(0n, { timestamp: 1000n }) }), + { + capacityCells: [], + nativeUdtCells: [], + nativeUdtCapacity: 0n, + nativeUdtBalance: 0n, + receipts: [], + withdrawalGroups: [], + }, + [matchable], + ); + + expect(context.estimatedMaturity).toBe(7000n); + }); +}); + describe("IckbSdk.buildBaseTransaction", () => { it("requests withdrawals before input-only base activity", async () => { const botLock = script("11"); @@ -2651,7 +2723,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { }); }); - it("fails closed when the chain tip changes during L1 state scanning", async () => { + it("fails closed when L1 state scanning crosses forward tip progress", async () => { const logic = script("22"); const dao = script("33"); const ownedOwner = script("44"); @@ -2681,7 +2753,101 @@ describe("IckbSdk.getL1State snapshot detection", () => { ); }); - it("fails closed when the chain tip changes during account state scanning", async () => { + it("fails closed when L1 state scanning crosses a reorg", async () => { + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const firstTip = headerLike(1n, { hash: hash("01") }); + const secondTip = headerLike(2n, { hash: hash("02") }); + const replacedFirstTip = headerLike(1n, { hash: hash("03") }); + const getTipHeader = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(secondTip); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [], + ); + const client = { + getTipHeader, + getHeaderByNumber: vi.fn().mockResolvedValue(replacedFirstTip), + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => none(), + } as unknown as ccc.Client; + + await expect(sdk.getL1State(client, [])).rejects.toThrow( + "L1 state scan crossed chain tip", + ); + }); + + it("fails closed when the chain tip is replaced during L1 state scanning", async () => { + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const firstTip = headerLike(1n, { hash: hash("01") }); + const replacementTip = headerLike(1n, { hash: hash("02") }); + const getTipHeader = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(replacementTip); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [], + ); + const client = { + getTipHeader, + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => none(), + } as unknown as ccc.Client; + + await expect(sdk.getL1State(client, [])).rejects.toThrow( + "L1 state scan crossed chain tip", + ); + }); + + it("fails closed when account state scanning crosses forward tip progress", async () => { + const accountLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const firstTip = headerLike(1n, { hash: hash("01") }); + const secondTip = headerLike(2n, { hash: hash("02") }); + const getTipHeader = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(secondTip); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [], + ); + const client = { + getTipHeader, + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => none(), + } as unknown as ccc.Client; + + await expect(sdk.getL1AccountState(client, [accountLock])).rejects.toThrow( + "L1 state scan crossed chain tip", + ); + }); + + it("fails closed when account state scanning crosses a reorg", async () => { const accountLock = script("11"); const logic = script("22"); const dao = script("33"); @@ -2690,6 +2856,7 @@ describe("IckbSdk.getL1State snapshot detection", () => { const udt = script("66"); const firstTip = headerLike(1n, { hash: hash("01") }); const secondTip = headerLike(2n, { hash: hash("02") }); + const replacedFirstTip = headerLike(1n, { hash: hash("03") }); const getTipHeader = vi .fn() .mockResolvedValueOnce(firstTip) @@ -2702,6 +2869,39 @@ describe("IckbSdk.getL1State snapshot detection", () => { new OrderManager(order, [], udt), [], ); + const client = { + getTipHeader, + getHeaderByNumber: vi.fn().mockResolvedValue(replacedFirstTip), + getFeeRate: () => Promise.resolve(1n), + findCellsOnChain: () => none(), + } as unknown as ccc.Client; + + await expect(sdk.getL1AccountState(client, [accountLock])).rejects.toThrow( + "L1 state scan crossed chain tip", + ); + }); + + it("fails closed when the chain tip is replaced during account state scanning", async () => { + const accountLock = script("11"); + const logic = script("22"); + const dao = script("33"); + const ownedOwner = script("44"); + const order = script("55"); + const udt = script("66"); + const firstTip = headerLike(1n, { hash: hash("01") }); + const replacementTip = headerLike(1n, { hash: hash("02") }); + const getTipHeader = vi + .fn() + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(firstTip) + .mockResolvedValueOnce(replacementTip); + const sdk = new IckbSdk( + fakeIckbUdt(udt), + new OwnedOwnerManager(ownedOwner, [], new DaoManager(dao, [])), + new LogicManager(logic, [], new DaoManager(dao, [])), + new OrderManager(order, [], udt), + [], + ); const client = { getTipHeader, getFeeRate: () => Promise.resolve(1n), diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index e2183cc..bafcca1 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -1323,7 +1323,7 @@ export class IckbSdk { tip: ccc.ClientBlockHeader, ): Promise { const currentTip = await client.getTipHeader(); - if (currentTip.hash !== tip.hash) { + if (currentTip.number !== tip.number || currentTip.hash !== tip.hash) { throw new Error("L1 state scan crossed chain tip; retry with a fresh state"); } } @@ -1605,6 +1605,40 @@ export interface AccountAvailabilityProjection { pendingOrders: OrderGroup[]; } +export interface ConversionTransactionContextProjection { + projection: AccountAvailabilityProjection; + context: ConversionTransactionContext; +} + +export function projectConversionTransactionContext( + system: SystemState, + account: AccountState, + userOrders: OrderGroup[], + options?: Parameters[2], +): ConversionTransactionContextProjection { + const projection = projectAccountAvailability(account, userOrders, options); + const estimatedMaturity = [ + system.tip.timestamp, + ...projection.pendingWithdrawals.map((group) => group.owned.maturity.toUnix(system.tip)), + ...projection.pendingOrders + .map((group) => group.order.maturity) + .filter((maturity): maturity is bigint => maturity !== undefined), + ].reduce((best, maturity) => (best > maturity ? best : maturity)); + + return { + projection, + context: { + system, + receipts: account.receipts, + readyWithdrawals: projection.readyWithdrawals, + availableOrders: projection.availableOrders, + ckbAvailable: projection.ckbAvailable, + ickbAvailable: projection.ickbAvailable, + estimatedMaturity, + }, + }; +} + export function projectAccountAvailability( account: AccountState, userOrders: OrderGroup[], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa4c35f..8683d82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,8 @@ catalogs: version: 24.12.2 overrides: + brace-expansion@>=5.0.0 <5.0.6: 5.0.6 + ws@>=8.0.0 <8.20.1: 8.20.1 '@ckb-ccc/ccc': workspace:* '@ckb-ccc/core': workspace:* '@ckb-ccc/udt': workspace:* @@ -395,10 +397,10 @@ importers: version: 6.16.0 isomorphic-ws: specifier: ^5.0.0 - version: 5.0.0(ws@8.20.0) + version: 5.0.0(ws@8.20.1) ws: - specifier: ^8.18.3 - version: 8.20.0 + specifier: 8.20.1 + version: 8.20.1 devDependencies: '@eslint/js': specifier: ^9.34.0 @@ -2469,8 +2471,8 @@ packages: brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} brorand@1.1.0: @@ -3108,7 +3110,7 @@ packages: isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: - ws: '*' + ws: 8.20.1 istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} @@ -4039,20 +4041,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.17.1: - resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5341,7 +5331,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -5773,7 +5763,7 @@ snapshots: '@types/node': 22.7.5 aes-js: 4.0.0-beta.5 tslib: 2.7.0 - ws: 8.17.1 + ws: 8.20.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -6004,9 +5994,9 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.20.0): + isomorphic-ws@5.0.0(ws@8.20.1): dependencies: - ws: 8.20.0 + ws: 8.20.1 istanbul-lib-coverage@3.2.2: {} @@ -6207,7 +6197,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.5: dependencies: @@ -6935,9 +6925,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.17.1: {} - - ws@8.20.0: {} + ws@8.20.1: {} xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ef6a610..0947c31 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,9 @@ auditConfig: - CVE-2025-14505 overrides: + # Security pins for vulnerable transitive ranges not yet lifted by upstreams. + "brace-expansion@>=5.0.0 <5.0.6": 5.0.6 + "ws@>=8.0.0 <8.20.1": 8.20.1 # 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. diff --git a/scripts/ickb-generate-config.mjs b/scripts/ickb-generate-config.mjs new file mode 100644 index 0000000..33fdcbf --- /dev/null +++ b/scripts/ickb-generate-config.mjs @@ -0,0 +1,336 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, realpath, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const rootDir = fileURLToPath(new URL("..", import.meta.url)); +const SECP256K1_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); +const DEFAULT_RPC_URLS = { + mainnet: "https://mainnet.ckb.dev/", + testnet: "https://testnet.ckb.dev/", +}; +const ROLE_PATTERN = /^[a-z](?:[a-z0-9_-]{0,30}[a-z0-9])?$/u; + +export function parseArgs(argv) { + const args = { + chain: "testnet", + role: "bot", + sleepIntervalSeconds: 1, + maxIterations: 1, + force: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + if (arg === "-h" || arg === "--help") { + args.help = true; + continue; + } + if (arg === "--chain") { + args.chain = parseChain(valueAfter(argv, ++index, arg)); + continue; + } + if (arg === "--role") { + args.role = parseRole(valueAfter(argv, ++index, arg)); + continue; + } + if (arg === "--out") { + args.out = valueAfter(argv, ++index, arg); + continue; + } + if (arg === "--rpc-url") { + args.rpcUrl = parseRpcUrl(valueAfter(argv, ++index, arg)); + continue; + } + if (arg === "--sleep-interval-seconds") { + args.sleepIntervalSeconds = parsePositiveInteger(valueAfter(argv, ++index, arg), arg); + continue; + } + if (arg === "--max-iterations") { + args.maxIterations = parsePositiveInteger(valueAfter(argv, ++index, arg), arg); + continue; + } + if (arg === "--no-max-iterations") { + args.maxIterations = undefined; + continue; + } + if (arg === "--force") { + args.force = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + args.rpcUrl ??= DEFAULT_RPC_URLS[args.chain]; + args.out ??= `config/${args.role}-${args.chain}.json`; + return args; +} + +export function usage() { + return [ + "Usage: node scripts/ickb-generate-config.mjs [--chain testnet|mainnet] [--role