From bf0f8d8ca011e627821445a10bc38519510e5b9d Mon Sep 17 00:00:00 2001 From: Phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 7 Jan 2026 07:35:53 +0100 Subject: [PATCH 01/45] feat(core): transform `Epoch` into a class and add utilities (#314) --- .changeset/crazy-hairs-greet.md | 5 + .changeset/fair-items-shout.md | 6 + packages/core/src/ckb/epoch.test.ts | 241 +++++++++ packages/core/src/ckb/epoch.ts | 488 ++++++++++++++++++ packages/core/src/ckb/index.ts | 1 + packages/core/src/ckb/transaction.ts | 96 +--- packages/core/src/client/clientTypes.ts | 3 +- .../core/src/client/jsonRpc/transformers.ts | 4 +- packages/core/src/molecule/codec.ts | 17 + packages/core/src/molecule/entity.ts | 12 +- packages/core/src/utils/index.ts | 21 +- 11 files changed, 818 insertions(+), 76 deletions(-) create mode 100644 .changeset/crazy-hairs-greet.md create mode 100644 .changeset/fair-items-shout.md create mode 100644 packages/core/src/ckb/epoch.test.ts create mode 100644 packages/core/src/ckb/epoch.ts diff --git a/.changeset/crazy-hairs-greet.md b/.changeset/crazy-hairs-greet.md new file mode 100644 index 000000000..5ba3e63e3 --- /dev/null +++ b/.changeset/crazy-hairs-greet.md @@ -0,0 +1,5 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(Epoch): transform `Epoch` into a class and add utilities diff --git a/.changeset/fair-items-shout.md b/.changeset/fair-items-shout.md new file mode 100644 index 000000000..e0da4d468 --- /dev/null +++ b/.changeset/fair-items-shout.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): `mol.padding` for padding codec + \ No newline at end of file diff --git a/packages/core/src/ckb/epoch.test.ts b/packages/core/src/ckb/epoch.test.ts new file mode 100644 index 000000000..e969db408 --- /dev/null +++ b/packages/core/src/ckb/epoch.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from "vitest"; +import type { ClientBlockHeader } from "../client/index.js"; +import { Epoch, epochFrom, epochFromHex, epochToHex } from "./epoch"; + +describe("Epoch", () => { + it("constructs from tuple and object via from()", () => { + const a = Epoch.from([1n, 2n, 3n]); + expect(a.integer).toBe(1n); + expect(a.numerator).toBe(2n); + expect(a.denominator).toBe(3n); + + const b = Epoch.from({ integer: 4n, numerator: 5n, denominator: 6n }); + expect(b.integer).toBe(4n); + expect(b.numerator).toBe(5n); + expect(b.denominator).toBe(6n); + + const c = new Epoch(7n, 8n, 9n); + expect(Epoch.from(c)).toBe(c); + }); + + it("packs and unpacks numeric layout (toNum/fromNum) and hex conversion", () => { + const e = new Epoch(0x010203n, 0x0405n, 0x0607n); // use values within bit widths + const packed1 = e.toNum(); + // integer in lower 24 bits, numerator next 16, denominator next 16 + expect(packed1 & 0xffffffn).toBe(0x010203n); + expect((packed1 >> 24n) & 0xffffn).toBe(0x0405n); + expect((packed1 >> 40n) & 0xffffn).toBe(0x0607n); + + const hex = e.toPackedHex(); + expect(typeof hex).toBe("string"); + expect(hex.startsWith("0x")).toBe(true); + + // round-trip + const decoded = Epoch.fromNum(packed1); + expect(decoded.integer).toBe(e.integer); + expect(decoded.numerator).toBe(e.numerator); + expect(decoded.denominator).toBe(e.denominator); + }); + + it("throws when packing negative components with toNum", () => { + const e = new Epoch(-1n, 0n, 1n); + expect(() => e.toNum()).toThrow(); + + const e2 = new Epoch(0n, -1n, 1n); + expect(() => e2.toNum()).toThrow(); + + const e3 = new Epoch(0n, 0n, -1n); + expect(() => e3.toNum()).toThrow(); + }); + + it("throws when packing components too big with toNum", () => { + const e = new Epoch(1n << 24n, 1n, 1n); // integer = 16777215 (24-bit limit + 1) + expect(() => e.toNum()).toThrow(); + + const e2 = new Epoch(1n, 1n << 16n, 1n); // numerator = 65536 (16-bit limit + 1) + expect(() => e2.toNum()).toThrow(); + + const e3 = new Epoch(1n, 1n, 1n << 16n); // denominator = 65536 (16-bit limit + 1) + expect(() => e3.toNum()).toThrow(); + }); + + it("normalizeBase fixes zero or negative denominators", () => { + const a = new Epoch(1n, 2n, 0n).normalizeBase(); + expect(a.denominator).toBe(1n); + expect(a.numerator).toBe(0n); + + const b = new Epoch(1n, 2n, -3n).normalizeBase(); + expect(b.denominator).toBe(3n); + expect(b.numerator).toBe(-2n); + }); + + it("normalizeCanonical reduces fractions and carries/borrows correctly", () => { + // reduction by gcd: 2/4 -> 1/2 + const a = new Epoch(1n, 2n, 4n).normalizeCanonical(); + expect(a.integer).toBe(1n); + expect(a.numerator).toBe(1n); + expect(a.denominator).toBe(2n); + + // carry: 5/2 -> +2 integer, remainder 1/2 + const b = new Epoch(0n, 5n, 2n).normalizeCanonical(); + expect(b.integer).toBe(2n); + expect(b.numerator).toBe(1n); + expect(b.denominator).toBe(2n); + + // borrow when numerator negative + const c = new Epoch(5n, -1n, 2n).normalizeCanonical(); + // -1/2 borrowed: integer 4, numerator becomes 1/2 + expect(c.integer).toBe(4n); + expect(c.numerator).toBe(1n); + expect(c.denominator).toBe(2n); + }); + + it("clone returns a deep copy", () => { + const e = new Epoch(1n, 1n, 1n); + const c = e.clone(); + expect(c).not.toBe(e); + expect(c.integer).toBe(e.integer); + expect(c.numerator).toBe(e.numerator); + expect(c.denominator).toBe(e.denominator); + }); + + it("Genesis and OneNervosDaoCycle helpers", () => { + const g = Epoch.Genesis; + expect(g.integer).toBe(0n); + expect(g.numerator).toBe(0n); + expect(g.denominator).toBe(0n); + + const o = Epoch.OneNervosDaoCycle; + expect(o.integer).toBe(180n); + expect(o.numerator).toBe(0n); + expect(o.denominator).toBe(1n); + }); + + it("comparison operations and compare()", () => { + const a = new Epoch(1n, 0n, 1n); + const b = new Epoch(1n, 1n, 2n); + const c = new Epoch(2n, 0n, 1n); + + expect(a.compare(b)).toBe(-1); + expect(b.compare(a)).toBe(1); + expect(a.compare(a)).toBe(0); + + expect(a.lt(b)).toBe(true); + expect(b.le(b)).toBe(true); + expect(b.eq(new Epoch(1n, 2n, 4n))).toBe(true); // 1 + 1/2 == 1 + 2/4 + expect(c.gt(b)).toBe(true); + expect(c.ge(b)).toBe(true); + }); + + it("add and sub arithmetic with differing denominators", () => { + const a = new Epoch(1n, 1n, 2n); // 1.5 + const b = new Epoch(2n, 1n, 3n); // 2 + 1/3 + const s = a.add(b); + // compute expected: whole = 3, fractional = 1/2 + 1/3 = 5/6 -> 3 + 5/6 + expect(s.integer).toBe(3n); + expect(s.numerator).toBe(5n); + expect(s.denominator).toBe(6n); + + const sub = s.sub(new Epoch(1n, 5n, 6n)); + expect(sub.integer).toBe(2n); + expect(sub.numerator).toBe(0n); + expect(sub.denominator).toBe(1n); + }); + + it("toUnix estimates timestamp using a reference header", () => { + const refEpoch = new Epoch(1n, 0n, 1n); + // Provide a minimal shaped header for toUnix without using `any`. + const refHeader = { + epoch: refEpoch, + timestamp: 1000n, + }; + + // target epoch is 2 + 1/2 + const target = new Epoch(2n, 1n, 2n); + const delta = target.sub(refEpoch); // should be 1 + 1/2 + + // Test default behavior (4 hours) + const expectedDefault = + refHeader.timestamp + + DEFAULT_EPOCH_IN_MILLISECONDS * delta.integer + + (DEFAULT_EPOCH_IN_MILLISECONDS * delta.numerator) / delta.denominator; + + expect(target.toUnix(refHeader)).toBe(expectedDefault); + + // Test custom epoch duration (10 minutes) + const customEpochMs = 10n * 60n * 1000n; + const expectedCustom = + refHeader.timestamp + + customEpochMs * delta.integer + + (customEpochMs * delta.numerator) / delta.denominator; + + expect(target.toUnix(refHeader, customEpochMs)).toBe(expectedCustom); + }); + + it("toUnix accepts full ClientBlockHeader", () => { + const refEpoch = new Epoch(1n, 0n, 1n); + // Simulate a full ClientBlockHeader object + const fullHeader: ClientBlockHeader = { + epoch: refEpoch, + timestamp: 1000n, + compactTarget: 0n, + hash: "0x1234567890abcdef", + number: 100n, + parentHash: "0xabcdef1234567890", + version: 0n, + nonce: 0n, + dao: { c: 0n, ar: 0n, s: 0n, u: 0n }, + extraHash: "0x0000000000000000", + proposalsHash: "0x0000000000000000", + transactionsRoot: "0x0000000000000000", + }; + + const target = new Epoch(2n, 1n, 2n); + const delta = target.sub(fullHeader.epoch); + const expected = + fullHeader.timestamp + + DEFAULT_EPOCH_IN_MILLISECONDS * delta.integer + + (DEFAULT_EPOCH_IN_MILLISECONDS * delta.numerator) / delta.denominator; + + // Full ClientBlockHeader should work due to structural typing + expect(target.toUnix(fullHeader)).toBe(expected); + }); + + it("toUnix accepts object literal with exact required properties", () => { + const target = new Epoch(2n, 1n, 2n); + const minimalRef = { + epoch: new Epoch(1n, 0n, 1n), + timestamp: 1000n, + }; + + const delta = target.sub(minimalRef.epoch); + const expected = + minimalRef.timestamp + + DEFAULT_EPOCH_IN_MILLISECONDS * delta.integer + + (DEFAULT_EPOCH_IN_MILLISECONDS * delta.numerator) / delta.denominator; + + expect(target.toUnix(minimalRef)).toBe(expected); + }); + + it("deprecated helpers epochFrom / epochFromHex / epochToHex", () => { + const e = new Epoch(3n, 4n, 5n); + expect(epochFrom(e)).toBe(e); + + const hex = epochToHex(e); + expect(typeof hex).toBe("string"); + expect(hex.startsWith("0x")).toBe(true); + + const decoded = epochFromHex(hex); + expect(decoded.integer).toBe(e.integer); + expect(decoded.numerator).toBe(e.numerator); + expect(decoded.denominator).toBe(e.denominator); + }); +}); + +/** + * DEFAULT_EPOCH_IN_MILLISECONDS + * + * Constant duration of a single standard ideal epoch expressed in milliseconds. + * Defined as 4 hours = 4 * 60 * 60 * 1000 ms. + */ +const DEFAULT_EPOCH_IN_MILLISECONDS = 4n * 60n * 60n * 1000n; diff --git a/packages/core/src/ckb/epoch.ts b/packages/core/src/ckb/epoch.ts new file mode 100644 index 000000000..36185a056 --- /dev/null +++ b/packages/core/src/ckb/epoch.ts @@ -0,0 +1,488 @@ +import type { ClientBlockHeader } from "../client/clientTypes.js"; +import { Zero } from "../fixedPoint/index.js"; +import { type Hex, type HexLike } from "../hex/index.js"; +import { mol } from "../molecule/index.js"; +import { numFrom, NumLike, numToHex, type Num } from "../num/index.js"; +import { gcd } from "../utils/index.js"; + +/** + * EpochLike + * + * Union type that represents any allowed input shapes that can be converted + * into an Epoch instance. + * + * Accepted shapes: + * - Tuple: [integer, numerator, denominator] where each element is NumLike + * - Object: { integer, numerator, denominator } where each field is NumLike + * - Packed numeric form: Num (bigint) or Hex (RPC-style packed hex) + * + * Notes: + * - When constructing an Epoch from a Num or Hex the packed numeric representation + * encodes integer (24 bits), numerator (16 bits) and denominator (16 bits). + * - Use Epoch.from() to convert any EpochLike into an Epoch instance. + * + * @example + * // From tuple + * Epoch.from([1n, 0n, 1n]); + */ +export type EpochLike = + | [NumLike, NumLike, NumLike] + | { + integer: NumLike; + numerator: NumLike; + denominator: NumLike; + } + | Num + | Hex; + +/** + * Epoch + * + * Represents a blockchain epoch consisting of a whole integer part and an + * optional fractional part represented as numerator/denominator. + * + * Behavior highlights: + * - Internally stores values as Num (bigint). + * - Provides normalization routines to canonicalize the fractional part: + * - normalizeBase(): fixes zero/negative denominators + * - normalizeCanonical(): reduces fraction, borrows/carries whole units + * - Supports arithmetic (add/sub), comparison and conversion utilities. + * + * @example + * const e = new Epoch(1n, 1n, 2n); // 1 + 1/2 + * + * @remarks + * This class is primarily a thin value-object; operations return new Epoch instances. + */ +@mol.codec( + mol.struct({ + padding: mol.padding(1), + denominator: mol.uint(2), + numerator: mol.uint(2), + integer: mol.uint(3), + }), +) +export class Epoch extends mol.Entity.Base() { + /** + * Construct a new Epoch instance. + * + * @param integer - Whole epoch units (Num/bigint) + * @param numerator - Fractional numerator (Num). + * @param denominator - Fractional denominator (Num). + */ + public constructor( + public readonly integer: Num, + public readonly numerator: Num, + public readonly denominator: Num, + ) { + super(); + } + + /** + * Normalize simpler base invariants: + * - If denominator === 0, set denominator to 1 and numerator to 0 for arithmetic convenience. + * - If denominator is negative flip signs of numerator and denominator to keep denominator positive. + * + * This is a minimal correction used before arithmetic or canonical normalization. + * + * @returns New Epoch with denominator corrected (but fraction not reduced). + */ + normalizeBase(): Epoch { + if (this.denominator === Zero) { + return new Epoch(this.integer, Zero, numFrom(1)); + } + + if (this.denominator < Zero) { + return new Epoch(this.integer, -this.numerator, -this.denominator); + } + + return this; + } + + /** + * Perform full canonical normalization of the epoch value. + * + * Steps: + * 1. Apply base normalization (normalizeBase). + * 2. If numerator is negative, borrow whole denominator(s) from the integer part + * so numerator becomes non-negative. This ensures 0 <= numerator < denominator whenever possible. + * 3. Reduce numerator/denominator by their greatest common divisor (gcd). + * 4. Carry any whole units from the reduced numerator into the integer part. + * 5. Ensure numerator is the strict remainder (numerator < denominator). + * + * @returns Canonicalized Epoch with a non-negative, reduced fractional part and integer adjusted accordingly. + */ + normalizeCanonical(): Epoch { + let { integer, numerator, denominator } = this.normalizeBase(); + + // If numerator is negative, borrow enough whole denominators from integer so numerator >= 0. + if (numerator < Zero) { + // n is the minimal non-negative integer such that numerator + n * denominator >= 0 + const n = (-numerator + denominator - 1n) / denominator; + integer -= n; + numerator += denominator * n; + } + + // Reduce the fractional part to lowest terms to keep canonical form and avoid unnecessarily large multiples. + const g = gcd(numerator, denominator); + numerator /= g; + denominator /= g; + + // Move any full units contained in the fraction into integer (e.g., 5/2 => +2 integer, remainder 1/2). + integer += numerator / denominator; + + // Remainder numerator after removing whole units; ensures numerator < denominator. + numerator %= denominator; + + return new Epoch(integer, numerator, denominator); + } + + /** + * Backwards-compatible array-style index 0 referencing the whole epoch integer. + * + * @returns integer portion (Num) + * @deprecated Use `.integer` property instead. + */ + get 0(): Num { + return this.integer; + } + + /** + * Backwards-compatible array-style index 1 referencing the epoch fractional numerator. + * + * @returns numerator portion (Num) + * @deprecated Use `.numerator` property instead. + */ + get 1(): Num { + return this.numerator; + } + + /** + * Backwards-compatible array-style index 2 referencing the epoch fractional denominator. + * + * @returns denominator portion (Num) + * @deprecated Use `.denominator` property instead. + */ + get 2(): Num { + return this.denominator; + } + + /** + * Convert this Epoch into its RPC-style packed numeric representation (Num). + * + * Packing layout (little-endian style fields): + * - integer: lower 24 bits + * - numerator: next 16 bits + * - denominator: next 16 bits + * + * Throws if any component is negative since packed representation assumes non-negative components. + * + * @throws {Error} If integer, numerator or denominator are negative. + * @throws {Error} If integer, numerator or denominator overflow the packing limits. + * @returns Packed numeric representation (Num) suitable for RPC packing. + */ + toNum(): Num { + if ( + this.integer < Zero || + this.numerator < Zero || + this.denominator < Zero + ) { + throw Error("Negative values in Epoch to Num conversion"); + } + + if ( + this.integer >= numFrom("0x1000000") || // 24-bit limit + this.numerator >= numFrom("0x10000") || // 16-bit limit + this.denominator >= numFrom("0x10000") // 16-bit limit + ) { + throw Error( + "Integer must be < 2^24, numerator and denominator must be < 2^16", + ); + } + + return ( + this.integer + + (this.numerator << numFrom(24)) + + (this.denominator << numFrom(40)) + ); + } + + /** + * Convert epoch to hex string representation of the RPC-style packed numeric form. + * + * Returns the same representation used by CKB RPC responses where the + * packed numeric bytes may be trimmed of leading zeros, see {@link numToHex} + * + * @returns Hex string corresponding to the packed epoch. + */ + toPackedHex(): Hex { + return numToHex(this.toNum()); + } + + /** + * Construct an Epoch by unpacking a RPC-style packed numeric form. + * + * @param v - NumLike packed epoch (like Num and Hex) + * @returns Epoch whose integer, numerator and denominator are extracted from the packed layout. + */ + static fromNum(v: NumLike): Epoch { + const num = numFrom(v); + + return new Epoch( + num & numFrom("0xffffff"), + (num >> numFrom(24)) & numFrom("0xffff"), + (num >> numFrom(40)) & numFrom("0xffff"), + ); + } + + /** + * Create an Epoch from an EpochLike value. + * + * Accepts: + * - an Epoch instance (returned as-is) + * - an array [integer, numerator, denominator] where each element is NumLike + * - an object { integer, numerator, denominator } where each field is NumLike + * - a packed numeric-like value handled by fromNum + * + * All numeric-like inputs are converted with numFrom() to produce internal Num values. + * + * @param e - Value convertible to Epoch + * @returns Epoch instance + */ + static override from(e: EpochLike): Epoch { + if (e instanceof Epoch) { + return e; + } + + if (Array.isArray(e)) { + return new Epoch(numFrom(e[0]), numFrom(e[1]), numFrom(e[2])); + } + + if (typeof e === "object") { + return new Epoch( + numFrom(e.integer), + numFrom(e.numerator), + numFrom(e.denominator), + ); + } + + return Epoch.fromNum(e); + } + + /** + * Return a deep copy of this Epoch. + * + * @returns New Epoch instance with identical components. + */ + override clone(): Epoch { + return new Epoch(this.integer, this.numerator, this.denominator); + } + + /** + * Return the genesis epoch. + * + * Note: for historical reasons the genesis epoch is represented with all-zero + * fields, no other epoch instance should use a zero denominator. + * + * @returns Epoch with integer = 0, numerator = 0, denominator = 0. + */ + static get Genesis(): Epoch { + return new Epoch(Zero, Zero, Zero); + } + + /** + * Return an Epoch representing one Nervos DAO cycle (180 epochs exactly). + * + * @returns Epoch equal to 180 with denominator set to 1 to represent an exact whole unit. + */ + static get OneNervosDaoCycle(): Epoch { + return new Epoch(numFrom(180), Zero, numFrom(1)); + } + + /** + * Compare this epoch to another EpochLike. + * + * The comparison computes scaled integer values so fractions are compared without precision loss: + * scaled = (integer * denominator + numerator) * other.denominator + * + * Special-case: identical object references return equality immediately. + * + * @param other - Epoch-like value to compare against. + * @returns 1 if this > other, 0 if equal, -1 if this < other. + * + * @example + * epochA.compare(epochB); // -1|0|1 + */ + compare(other: EpochLike): 1 | 0 | -1 { + if (this === other) { + return 0; + } + + const t = this.normalizeBase(); + const o = Epoch.from(other).normalizeBase(); + + // Compute scaled representations to compare fractions without floating-point arithmetic. + const a = (t.integer * t.denominator + t.numerator) * o.denominator; + const b = (o.integer * o.denominator + o.numerator) * t.denominator; + + return a > b ? 1 : a < b ? -1 : 0; + } + + /** + * Check whether this epoch is less than another EpochLike. + * + * @param other - EpochLike to compare against. + * @returns true if this < other. + */ + lt(other: EpochLike): boolean { + return this.compare(other) < 0; + } + + /** + * Check whether this epoch is less than or equal to another EpochLike. + * + * @param other - EpochLike to compare against. + * @returns true if this <= other. + */ + le(other: EpochLike): boolean { + return this.compare(other) <= 0; + } + + /** + * Check whether this epoch equals another EpochLike. + * + * @param other - EpochLike to compare against. + * @returns true if equal. + */ + eq(other: EpochLike): boolean { + return this.compare(other) === 0; + } + + /** + * Check whether this epoch is greater than or equal to another EpochLike. + * + * @param other - EpochLike to compare against. + * @returns true if this >= other. + */ + ge(other: EpochLike): boolean { + return this.compare(other) >= 0; + } + + /** + * Check whether this epoch is greater than another EpochLike. + * + * @param other - EpochLike to compare against. + * @returns true if this > other. + */ + gt(other: EpochLike): boolean { + return this.compare(other) > 0; + } + + /** + * Add another EpochLike to this epoch and return the normalized result. + * + * Rules and edge-cases: + * - Whole parts are added directly; fractional parts are aligned to a common denominator and added. + * - Final result is canonicalized to reduce the fraction and carry any overflow to the integer part. + * + * @param other - Epoch-like value to add. + * @returns Normalized Epoch representing the sum. + */ + add(other: EpochLike): Epoch { + const t = this.normalizeBase(); + const o = Epoch.from(other).normalizeBase(); + + // Sum whole integer parts. + const integer = t.integer + o.integer; + let numerator: Num; + let denominator: Num; + + // Align denominators if they differ; use multiplication to obtain a common denominator. + if (t.denominator !== o.denominator) { + // Numerators & Denominators are generally small (<= 2000n); multiplication produces a safe common denominator. + numerator = t.numerator * o.denominator + o.numerator * t.denominator; + denominator = t.denominator * o.denominator; + } else { + numerator = t.numerator + o.numerator; + denominator = t.denominator; + } + + // Normalize to reduce fraction and carry whole units into integer. + return new Epoch(integer, numerator, denominator).normalizeCanonical(); + } + + /** + * Subtract an EpochLike from this epoch and return the normalized result. + * + * Implementation notes: + * - Delegates to add by negating the other epoch's integer and numerator while preserving denominator. + * - normalizeCanonical will handle negative numerators by borrowing from integer as necessary. + * + * @param other - Epoch-like value to subtract. + * @returns Normalized Epoch representing this - other. + */ + sub(other: EpochLike): Epoch { + const { integer, numerator, denominator } = Epoch.from(other); + return this.add(new Epoch(-integer, -numerator, denominator)); + } + + /** + * Convert this epoch to an estimated Unix timestamp in milliseconds using a reference header. + * + * Note: This is an estimation that assumes a constant epoch duration. + * + * @param reference - Object providing `epoch` (Epoch) and `timestamp` (Num) fields, such as a ClientBlockHeader. + * @param epochInMilliseconds - Duration of a single epoch in milliseconds. Defaults to 4 hours. + * @returns Estimated Unix timestamp in milliseconds as bigint. + */ + toUnix( + reference: Pick, + epochInMilliseconds: Num = numFrom(4 * 60 * 60 * 1000), + ): bigint { + // Compute relative epoch difference against the reference header. + const { integer, numerator, denominator } = this.sub(reference.epoch); + + // Add whole epoch duration and fractional epoch duration to the reference timestamp. + return ( + reference.timestamp + + epochInMilliseconds * integer + + (epochInMilliseconds * numerator) / denominator + ); + } +} + +/** + * epochFrom + * + * @deprecated prefer using Epoch.from() directly. + * + * @param epochLike - Epoch-like value to convert. + * @returns Epoch instance corresponding to the input. + */ +export function epochFrom(epochLike: EpochLike): Epoch { + return Epoch.from(epochLike); +} + +/** + * epochFromHex + * + * @deprecated use Epoch.fromNum() with numeric input instead. + * + * @param hex - Hex-like or numeric-like value encoding a packed epoch. + * @returns Decoded Epoch instance. + */ +export function epochFromHex(hex: HexLike): Epoch { + return Epoch.fromNum(hex); +} + +/** + * epochToHex + * + * @deprecated use Epoch.from(epochLike).toPackedHex() instead. + * + * @param epochLike - Value convertible to an Epoch (object, tuple or Epoch). + * @returns Hex string representing the packed epoch encoding. + */ +export function epochToHex(epochLike: EpochLike): Hex { + return Epoch.from(epochLike).toPackedHex(); +} diff --git a/packages/core/src/ckb/index.ts b/packages/core/src/ckb/index.ts index 7d20b37d0..198c09299 100644 --- a/packages/core/src/ckb/index.ts +++ b/packages/core/src/ckb/index.ts @@ -1,3 +1,4 @@ +export * from "./epoch.js"; export * from "./hash.js"; export * from "./script.js"; export * from "./transaction.js"; diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 9a5920bd9..afb901285 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -21,6 +21,7 @@ import { } from "../num/index.js"; import type { Signer } from "../signer/index.js"; import { apply, reduceAsync } from "../utils/index.js"; +import { Epoch } from "./epoch.js"; import { Script, ScriptLike, ScriptOpt } from "./script.js"; import { DEP_TYPE_TO_NUM, NUM_TO_DEP_TYPE } from "./transaction.advanced.js"; import { @@ -647,45 +648,6 @@ export class Cell extends CellAny { } } -/** - * @public - */ -export type EpochLike = [NumLike, NumLike, NumLike]; -/** - * @public - */ -export type Epoch = [Num, Num, Num]; -/** - * @public - */ -export function epochFrom(epochLike: EpochLike): Epoch { - return [numFrom(epochLike[0]), numFrom(epochLike[1]), numFrom(epochLike[2])]; -} -/** - * @public - */ -export function epochFromHex(hex: HexLike): Epoch { - const num = numFrom(hexFrom(hex)); - - return [ - num & numFrom("0xffffff"), - (num >> numFrom(24)) & numFrom("0xffff"), - (num >> numFrom(40)) & numFrom("0xffff"), - ]; -} -/** - * @public - */ -export function epochToHex(epochLike: EpochLike): Hex { - const epoch = epochFrom(epochLike); - - return numToHex( - numFrom(epoch[0]) + - (numFrom(epoch[1]) << numFrom(24)) + - (numFrom(epoch[2]) << numFrom(40)), - ); -} - /** * @public */ @@ -1887,7 +1849,7 @@ export class Transaction extends mol.Entity.Base< return reduceAsync( this.inputs, async (acc, input) => acc + (await input.getExtraCapacity(client)), - numFrom(0), + Zero, ); } @@ -1903,16 +1865,13 @@ export class Transaction extends mol.Entity.Base< return acc + capacity; }, - numFrom(0), + Zero, )) + (await this.getInputsCapacityExtra(client)) ); } getOutputsCapacity(): Num { - return this.outputs.reduce( - (acc, { capacity }) => acc + capacity, - numFrom(0), - ); + return this.outputs.reduce((acc, { capacity }) => acc + capacity, Zero); } async getInputsUdtBalance(client: Client, type: ScriptLike): Promise { @@ -1926,7 +1885,7 @@ export class Transaction extends mol.Entity.Base< return acc + udtBalanceFrom(outputData); }, - numFrom(0), + Zero, ); } @@ -1937,7 +1896,7 @@ export class Transaction extends mol.Entity.Base< } return acc + udtBalanceFrom(this.outputsData[i]); - }, numFrom(0)); + }, Zero); } async completeInputs( @@ -2059,7 +2018,7 @@ export class Transaction extends mol.Entity.Base< ): Promise { const expectedBalance = this.getOutputsUdtBalance(type) + numFrom(balanceTweak ?? 0); - if (expectedBalance === numFrom(0)) { + if (expectedBalance === Zero) { return 0; } @@ -2073,7 +2032,7 @@ export class Transaction extends mol.Entity.Base< return [balanceAcc + udtBalanceFrom(outputData), countAcc + 1]; }, - [numFrom(0), 0], + [Zero, 0], ); if ( @@ -2512,12 +2471,12 @@ export function calcDaoProfit( * * @param depositHeader - The block header when the DAO deposit was made. * @param withdrawHeader - The block header when the DAO withdrawal was initiated. - * @returns The epoch when the withdrawal can be claimed, represented as [number, index, length]. + * @returns The epoch when the withdrawal can be claimed, represented as an Epoch instance. * * @example * ```typescript - * const claimEpoch = calcDaoClaimEpoch(depositHeader, withdrawHeader); - * console.log(`Can claim at epoch: ${claimEpoch[0]}, index: ${claimEpoch[1]}, length: ${claimEpoch[2]}`); + * const epoch = calcDaoClaimEpoch(depositHeader, withdrawHeader); + * console.log(`Can claim at epoch: ${epoch.integer}, numerator: ${epoch.numerator}, denominator: ${epoch.denominator}`); * ``` * * @remarks @@ -2531,26 +2490,23 @@ export function calcDaoClaimEpoch( depositHeader: ClientBlockHeaderLike, withdrawHeader: ClientBlockHeaderLike, ): Epoch { - const depositEpoch = ClientBlockHeader.from(depositHeader).epoch; - const withdrawEpoch = ClientBlockHeader.from(withdrawHeader).epoch; - const intDiff = withdrawEpoch[0] - depositEpoch[0]; - // deposit[1] withdraw[1] - // ---------- <= ----------- - // deposit[2] withdraw[2] + const deposit = ClientBlockHeader.from(depositHeader).epoch.normalizeBase(); + const withdraw = ClientBlockHeader.from(withdrawHeader).epoch.normalizeBase(); + + const fullCycle = numFrom(180); + const partialCycle = (withdraw.integer - deposit.integer) % fullCycle; + let withdrawInteger = withdraw.integer; if ( - intDiff % numFrom(180) !== numFrom(0) || - depositEpoch[1] * withdrawEpoch[2] <= depositEpoch[2] * withdrawEpoch[1] + partialCycle !== Zero || + // deposit.numerator withdraw.numerator + // --------------------- <= ---------------------- + // deposit.denominator withdraw.denominator + deposit.numerator * withdraw.denominator <= + withdraw.numerator * deposit.denominator ) { - return [ - depositEpoch[0] + (intDiff / numFrom(180) + numFrom(1)) * numFrom(180), - depositEpoch[1], - depositEpoch[2], - ]; + // Need to wait for the next cycle + withdrawInteger += -partialCycle + fullCycle; } - return [ - depositEpoch[0] + (intDiff / numFrom(180)) * numFrom(180), - depositEpoch[1], - depositEpoch[2], - ]; + return new Epoch(withdrawInteger, deposit.numerator, deposit.denominator); } diff --git a/packages/core/src/client/clientTypes.ts b/packages/core/src/client/clientTypes.ts index f7331e8d2..ec57a1a12 100644 --- a/packages/core/src/client/clientTypes.ts +++ b/packages/core/src/client/clientTypes.ts @@ -12,7 +12,6 @@ import { ScriptLike, Transaction, TransactionLike, - epochFrom, hashTypeFrom, } from "../ckb/index.js"; import { Hex, HexLike, hexFrom } from "../hex/index.js"; @@ -398,7 +397,7 @@ export class ClientBlockHeader { s: numFrom(headerLike.dao.s), u: numFrom(headerLike.dao.u), }, - epochFrom(headerLike.epoch), + Epoch.from(headerLike.epoch), hexFrom(headerLike.extraHash), hexFrom(headerLike.hash), numFrom(headerLike.nonce), diff --git a/packages/core/src/client/jsonRpc/transformers.ts b/packages/core/src/client/jsonRpc/transformers.ts index ded06bd80..a571fa412 100644 --- a/packages/core/src/client/jsonRpc/transformers.ts +++ b/packages/core/src/client/jsonRpc/transformers.ts @@ -9,6 +9,7 @@ import { CellOutputLike, DepType, DepTypeLike, + Epoch, HashType, HashTypeLike, OutPoint, @@ -18,7 +19,6 @@ import { Transaction, TransactionLike, depTypeFrom, - epochFromHex, hashTypeFrom, } from "../../ckb/index.js"; import { Hex, HexLike, hexFrom } from "../../hex/index.js"; @@ -217,7 +217,7 @@ export class JsonRpcTransformers { s: numLeFromBytes(dao.slice(16, 24)), u: numLeFromBytes(dao.slice(24, 32)), }, - epoch: epochFromHex(header.epoch), + epoch: Epoch.fromNum(header.epoch), extraHash: header.extra_hash, hash: header.hash, nonce: numFrom(header.nonce), diff --git a/packages/core/src/molecule/codec.ts b/packages/core/src/molecule/codec.ts index 4e80d7a1d..70af71faa 100644 --- a/packages/core/src/molecule/codec.ts +++ b/packages/core/src/molecule/codec.ts @@ -718,3 +718,20 @@ export function uintNumber( outMap: (num) => Number(num), }); } + +/** + * Create a codec for padding bytes. + * The padding bytes are zero-filled when encoding and ignored when decoding. + * @param byteLength The length of the padding in bytes. + */ +export function padding( + byteLength: number, +): Codec { + return Codec.from({ + byteLength, + encode: () => { + return new Uint8Array(byteLength); + }, + decode: () => {}, + }); +} diff --git a/packages/core/src/molecule/entity.ts b/packages/core/src/molecule/entity.ts index 5e1771e8c..67d91b83c 100644 --- a/packages/core/src/molecule/entity.ts +++ b/packages/core/src/molecule/entity.ts @@ -1,6 +1,6 @@ import { Bytes, bytesEq, BytesLike } from "../bytes/index.js"; import { hashCkb } from "../hasher/index.js"; -import { Hex } from "../hex/index.js"; +import { Hex, hexFrom } from "../hex/index.js"; import { Constructor } from "../utils/index.js"; import { Codec } from "./codec.js"; @@ -126,6 +126,15 @@ export abstract class Entity { hash(): Hex { return hashCkb(this.toBytes()); } + + /** + * Convert the entity to a full-byte untrimmed Hex representation + * @public + * @returns The entity full-byte untrimmed hexadecimal representation + */ + toHex(): Hex { + return hexFrom(this.toBytes()); + } } return Impl; @@ -133,6 +142,7 @@ export abstract class Entity { abstract toBytes(): Bytes; abstract hash(): Hex; + abstract toHex(): Hex; abstract clone(): Entity; } diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index e9e73364b..f75bdb8ab 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,4 +1,5 @@ -import { NumLike, numFrom, numToHex } from "../num/index.js"; +import { Zero } from "../fixedPoint/index.js"; +import { NumLike, numFrom, numToHex, type Num } from "../num/index.js"; /** * A type safe way to apply a transformer on a value if it's not empty. @@ -196,3 +197,21 @@ export function stringify(val: unknown) { return value; }); } + +/** + * Calculate the greatest common divisor (GCD) of two NumLike values using the Euclidean algorithm. + * + * @param a - First operand. + * @param b - Second operand. + * @returns GCD(a, b) as a Num. + */ +export function gcd(a: NumLike, b: NumLike): Num { + a = numFrom(a); + b = numFrom(b); + a = a < Zero ? -a : a; + b = b < Zero ? -b : b; + while (b !== Zero) { + [a, b] = [b, a % b]; + } + return a; +} From 9f7ecb6ab8db9c6866dad029f2888e1e5cfcbe7d Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Thu, 8 Jan 2026 20:18:18 +0800 Subject: [PATCH 02/45] feat(core): auto complete cell capacity if it's not enough --- .changeset/weak-adults-rhyme.md | 6 +++ packages/core/src/ckb/transaction.test.ts | 55 ++++++++++------------- packages/core/src/ckb/transaction.ts | 8 ++-- packages/core/src/client/client.ts | 2 +- 4 files changed, 36 insertions(+), 35 deletions(-) create mode 100644 .changeset/weak-adults-rhyme.md diff --git a/.changeset/weak-adults-rhyme.md b/.changeset/weak-adults-rhyme.md new file mode 100644 index 000000000..54dca531b --- /dev/null +++ b/.changeset/weak-adults-rhyme.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): auto complete cell capacity if it's not enough + \ No newline at end of file diff --git a/packages/core/src/ckb/transaction.test.ts b/packages/core/src/ckb/transaction.test.ts index 6271517d4..c6a3a9d46 100644 --- a/packages/core/src/ckb/transaction.test.ts +++ b/packages/core/src/ckb/transaction.test.ts @@ -385,6 +385,9 @@ describe("Transaction", () => { { previousOutput: mockCapacityCells[0].outPoint, }, + { + previousOutput: mockCapacityCells[1].outPoint, + }, ], outputs: [ { @@ -647,22 +650,13 @@ describe("Transaction", () => { describe("Automatic Capacity Completion", () => { describe("CellOutput.from", () => { - it("should use explicit capacity when provided", () => { - const cellOutput = ccc.CellOutput.from({ - capacity: 1000n, - lock, - }); - - expect(cellOutput.capacity).toBe(1000n); - }); - it("should not modify capacity when data is not provided", () => { const cellOutput = ccc.CellOutput.from({ - capacity: 0n, + capacity: 100n, lock, }); - expect(cellOutput.capacity).toBe(0n); + expect(cellOutput.capacity).toBe(100n); }); it("should calculate capacity automatically when capacity is 0", () => { @@ -679,6 +673,20 @@ describe("Transaction", () => { expect(cellOutput.capacity).toBe(ccc.fixedPointFrom(expectedCapacity)); }); + it("should calculate capacity automatically when capacity is less than min requirement", () => { + const outputData = "0x1234"; // 2 bytes + const cellOutput = ccc.CellOutput.from( + { + capacity: 1000n, + lock, + }, + outputData, + ); + + const expectedCapacity = cellOutput.occupiedSize + 2; // occupiedSize + outputData length + expect(cellOutput.capacity).toBe(ccc.fixedPointFrom(expectedCapacity)); + }); + it("should calculate capacity automatically when capacity is omitted", () => { const outputData = "0x5678"; // 2 bytes const cellOutput = ccc.CellOutput.from( @@ -732,9 +740,9 @@ describe("Transaction", () => { expect(cellOutput.capacity).toBe(ccc.fixedPointFrom(expectedCapacity)); }); - it("should not auto-calculate when capacity is explicitly provided even with outputData", () => { + it("should not auto-calculate when capacity is enough even with outputData", () => { const outputData = "0x1234"; // 2 bytes - const explicitCapacity = 5000n; + const explicitCapacity = ccc.fixedPointFrom(100); const cellOutput = ccc.CellOutput.from( { capacity: explicitCapacity, @@ -745,21 +753,6 @@ describe("Transaction", () => { expect(cellOutput.capacity).toBe(explicitCapacity); }); - - it("should handle the overloaded signature correctly", () => { - // Test the overloaded signature where capacity is omitted and outputData is required - const outputData = "0xabcd"; - const cellOutput = ccc.CellOutput.from( - { - lock, - type, - }, - outputData, - ); - - const expectedCapacity = cellOutput.occupiedSize + 2; - expect(cellOutput.capacity).toBe(ccc.fixedPointFrom(expectedCapacity)); - }); }); describe("Transaction.from", () => { @@ -798,7 +791,7 @@ describe("Transaction", () => { it("should handle mixed explicit and automatic capacity calculation", () => { const outputsData = ["0x12", "0x3456"]; - const explicitCapacity = 5000n; + const explicitCapacity = ccc.fixedPointFrom(100); const tx = ccc.Transaction.from({ outputs: [ { @@ -951,7 +944,7 @@ describe("Transaction", () => { it("should add output with explicit capacity", () => { const tx = ccc.Transaction.default(); const outputData = "0x12"; - const explicitCapacity = 10000n; + const explicitCapacity = ccc.fixedPointFrom(100); tx.addOutput( { @@ -1142,7 +1135,7 @@ describe("Transaction", () => { it("should calculate capacityFree correctly", () => { const outputData = "0x1234"; - const explicitCapacity = 1000n; + const explicitCapacity = ccc.fixedPointFrom(100); const cell = ccc.Cell.from({ outPoint: { txHash: "0x" + "0".repeat(64), diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index d6e8ae3f7..ad1d9d4d6 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -16,6 +16,7 @@ import { NumLike, numFrom, numFromBytes, + numMax, numToBytes, numToHex, } from "../num/index.js"; @@ -274,9 +275,10 @@ export class CellOutput extends mol.Entity.Base() { ); })(); - if (output.capacity === Zero && outputData != null) { - output.capacity = fixedPointFrom( - output.occupiedSize + bytesFrom(outputData).length, + if (outputData != null) { + output.capacity = numMax( + output.capacity, + fixedPointFrom(output.occupiedSize + bytesFrom(outputData).length), ); } diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 8c0cec7a6..f8d8d5e2a 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -369,7 +369,7 @@ export abstract class Client { async findSingletonCellByType( type: ScriptLike, - withData = false, + withData = true, ): Promise { for await (const cell of this.findCellsByType( type, From a5268909ea9d61c4e2f5187a43e2318327b27cae Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Tue, 16 Dec 2025 21:49:19 +0800 Subject: [PATCH 03/45] feat(core): add known script did ckb --- .changeset/poor-days-guess.md | 6 +++++ .../client/clientPublicMainnet.advanced.ts | 23 +++++++++++++++++++ .../client/clientPublicTestnet.advanced.ts | 23 +++++++++++++++++++ packages/core/src/client/knownScript.ts | 1 + 4 files changed, 53 insertions(+) create mode 100644 .changeset/poor-days-guess.md diff --git a/.changeset/poor-days-guess.md b/.changeset/poor-days-guess.md new file mode 100644 index 000000000..d6269460c --- /dev/null +++ b/.changeset/poor-days-guess.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): add known script did ckb + \ No newline at end of file diff --git a/packages/core/src/client/clientPublicMainnet.advanced.ts b/packages/core/src/client/clientPublicMainnet.advanced.ts index 979b0955c..bdcec4cc1 100644 --- a/packages/core/src/client/clientPublicMainnet.advanced.ts +++ b/packages/core/src/client/clientPublicMainnet.advanced.ts @@ -321,6 +321,29 @@ export const MAINNET_SCRIPTS: Record = }, ], }, + [KnownScript.DidCkb]: { + codeHash: + "0x4a06164dc34dccade5afe3e847a97b6db743e79f5477fa3295acf02849c5984a", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0xe2f74c56cdc610d2b9fe898a96a80118845f5278605d7f9ad535dad69ae015bf", + index: 0, + }, + depType: "code", + }, + type: { + codeHash: + "0x00000000000000000000000000000000000000000000000000545950455f4944", + args: "0x55573ef6d78e3ca75170ff476176732309a8b31efe94320a954ded3d75c2cb18", + hashType: "type", + }, + }, + ], + }, [KnownScript.AlwaysSuccess]: { codeHash: "0x3b521cc4b552f109d092d8cc468a8048acb53c5952dbe769d2b2f9cf6e47f7f1", diff --git a/packages/core/src/client/clientPublicTestnet.advanced.ts b/packages/core/src/client/clientPublicTestnet.advanced.ts index be811c7eb..9453d2523 100644 --- a/packages/core/src/client/clientPublicTestnet.advanced.ts +++ b/packages/core/src/client/clientPublicTestnet.advanced.ts @@ -333,6 +333,29 @@ export const TESTNET_SCRIPTS: Record = }, ], }, + [KnownScript.DidCkb]: { + codeHash: + "0x510150477b10d6ab551a509b71265f3164e9fd4137fcb5a4322f49f03092c7c5", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0x0e7a830e2d5ebd05cd45a55f93f94559edea0ef1237b7233f49f7facfb3d6a6c", + index: 0, + }, + depType: "code", + }, + type: { + codeHash: + "0x00000000000000000000000000000000000000000000000000545950455f4944", + args: "0x3c27695173b888ed44ddf36f901789014384ad6c05a9137f3db9a0779c141c35", + hashType: "type", + }, + }, + ], + }, [KnownScript.AlwaysSuccess]: { codeHash: "0x3b521cc4b552f109d092d8cc468a8048acb53c5952dbe769d2b2f9cf6e47f7f1", diff --git a/packages/core/src/client/knownScript.ts b/packages/core/src/client/knownScript.ts index 2171b90b4..90a1546fe 100644 --- a/packages/core/src/client/knownScript.ts +++ b/packages/core/src/client/knownScript.ts @@ -15,6 +15,7 @@ export enum KnownScript { OmniLock = "OmniLock", NostrLock = "NostrLock", UniqueType = "UniqueType", + DidCkb = "DidCkb", // ckb-proxy-locks https://github.com/ckb-devrel/ckb-proxy-locks AlwaysSuccess = "AlwaysSuccess", From 1148a5c403cde985fb4ba713ccfa0c163d287174 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Thu, 8 Jan 2026 18:43:49 +0800 Subject: [PATCH 04/45] feat(core): extract a universal `Codec` from `mol.Codec` --- .changeset/calm-doors-poke.md | 6 + packages/core/src/barrel.ts | 1 + packages/core/src/ckb/epoch.ts | 13 +- packages/core/src/ckb/script.ts | 7 +- packages/core/src/ckb/transaction.ts | 37 ++-- packages/core/src/codec/codec.ts | 108 ++++++++++ .../core/src/{molecule => codec}/entity.ts | 12 +- packages/core/src/codec/index.ts | 3 + packages/core/src/codec/predefined.ts | 145 ++++++++++++++ packages/core/src/molecule/barrel.ts | 11 +- packages/core/src/molecule/codec.ts | 188 +++--------------- packages/core/src/molecule/predefined.ts | 113 ++++++----- packages/spore/src/cobuild/index.ts | 20 +- packages/spore/src/codec/cluster.ts | 4 +- packages/spore/src/codec/spore.ts | 2 +- packages/spore/src/spore/advanced.ts | 4 +- 16 files changed, 417 insertions(+), 257 deletions(-) create mode 100644 .changeset/calm-doors-poke.md create mode 100644 packages/core/src/codec/codec.ts rename packages/core/src/{molecule => codec}/entity.ts (94%) create mode 100644 packages/core/src/codec/index.ts create mode 100644 packages/core/src/codec/predefined.ts diff --git a/.changeset/calm-doors-poke.md b/.changeset/calm-doors-poke.md new file mode 100644 index 000000000..9809e521f --- /dev/null +++ b/.changeset/calm-doors-poke.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): extract a universal `Codec` from `mol.Codec` + \ No newline at end of file diff --git a/packages/core/src/barrel.ts b/packages/core/src/barrel.ts index a92855072..85d942322 100644 --- a/packages/core/src/barrel.ts +++ b/packages/core/src/barrel.ts @@ -2,6 +2,7 @@ export * from "./address/index.js"; export * from "./bytes/index.js"; export * from "./ckb/index.js"; export * from "./client/index.js"; +export * from "./codec/index.js"; export * from "./fixedPoint/index.js"; export * from "./hasher/index.js"; export * from "./hex/index.js"; diff --git a/packages/core/src/ckb/epoch.ts b/packages/core/src/ckb/epoch.ts index 36185a056..8ee254ad5 100644 --- a/packages/core/src/ckb/epoch.ts +++ b/packages/core/src/ckb/epoch.ts @@ -1,4 +1,5 @@ import type { ClientBlockHeader } from "../client/clientTypes.js"; +import { codec, codecPadding, codecUint, Entity } from "../codec/index.js"; import { Zero } from "../fixedPoint/index.js"; import { type Hex, type HexLike } from "../hex/index.js"; import { mol } from "../molecule/index.js"; @@ -54,15 +55,15 @@ export type EpochLike = * @remarks * This class is primarily a thin value-object; operations return new Epoch instances. */ -@mol.codec( +@codec( mol.struct({ - padding: mol.padding(1), - denominator: mol.uint(2), - numerator: mol.uint(2), - integer: mol.uint(3), + padding: codecPadding(1), + denominator: codecUint(2), + numerator: codecUint(2), + integer: codecUint(3), }), ) -export class Epoch extends mol.Entity.Base() { +export class Epoch extends Entity.Base() { /** * Construct a new Epoch instance. * diff --git a/packages/core/src/ckb/script.ts b/packages/core/src/ckb/script.ts index 1de3c7ac0..21405c8a5 100644 --- a/packages/core/src/ckb/script.ts +++ b/packages/core/src/ckb/script.ts @@ -1,6 +1,7 @@ import { Bytes, BytesLike, bytesFrom } from "../bytes/index.js"; import type { Client } from "../client/index.js"; import { KnownScript } from "../client/knownScript.js"; +import { Codec, Entity, codec } from "../codec/index.js"; import { Hex, HexLike, hexFrom } from "../hex/index.js"; import { mol } from "../molecule/index.js"; import { @@ -9,7 +10,7 @@ import { NUM_TO_HASH_TYPE, } from "./script.advanced.js"; -export const HashTypeCodec: mol.Codec = mol.Codec.from({ +export const HashTypeCodec: Codec = Codec.from({ byteLength: 1, encode: hashTypeToBytes, decode: hashTypeFromBytes, @@ -108,14 +109,14 @@ export type ScriptLike = { /** * @public */ -@mol.codec( +@codec( mol.table({ codeHash: mol.Byte32, hashType: HashTypeCodec, args: mol.Bytes, }), ) -export class Script extends mol.Entity.Base() { +export class Script extends Entity.Base() { /** * Creates an instance of Script. * diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index ad1d9d4d6..2cb60e906 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -7,6 +7,7 @@ import { type ClientBlockHeaderLike, } from "../client/index.js"; import { KnownScript } from "../client/knownScript.js"; +import { Codec, Entity, codec } from "../codec/index.js"; import { Zero, fixedPointFrom } from "../fixedPoint/index.js"; import { Hasher, HasherCkb, hashCkb } from "../hasher/index.js"; import { Hex, HexLike, hexFrom } from "../hex/index.js"; @@ -31,7 +32,7 @@ import { } from "./transactionErrors.js"; import type { LumosTransactionSkeletonType } from "./transactionLumos.js"; -export const DepTypeCodec: mol.Codec = mol.Codec.from({ +export const DepTypeCodec: Codec = Codec.from({ byteLength: 1, encode: depTypeToBytes, decode: depTypeFromBytes, @@ -126,13 +127,13 @@ export type OutPointLike = { /** * @public */ -@mol.codec( +@codec( mol.struct({ txHash: mol.Byte32, index: mol.Uint32, }), ) -export class OutPoint extends mol.Entity.Base() { +export class OutPoint extends Entity.Base() { /** * Creates an instance of OutPoint. * @@ -207,14 +208,14 @@ export type CellOutputLike = { /** * @public */ -@mol.codec( +@codec( mol.table({ capacity: mol.Uint64, lock: Script, type: ScriptOpt, }), ) -export class CellOutput extends mol.Entity.Base() { +export class CellOutput extends Entity.Base() { /** * Creates an instance of CellOutput. * @@ -666,10 +667,10 @@ export type SinceLike = /** * @public */ -@mol.codec( +@codec( mol.Uint64.mapIn((encodable: SinceLike) => Since.from(encodable).toNum()), ) -export class Since extends mol.Entity.Base() { +export class Since extends Entity.Base() { /** * Creates an instance of Since. * @@ -787,7 +788,7 @@ export type CellInputLike = ( /** * @public */ -@mol.codec( +@codec( mol .struct({ since: Since, @@ -795,7 +796,7 @@ export type CellInputLike = ( }) .mapIn((encodable: CellInputLike) => CellInput.from(encodable)), ) -export class CellInput extends mol.Entity.Base() { +export class CellInput extends Entity.Base() { /** * Creates an instance of CellInput. * @@ -924,13 +925,13 @@ export type CellDepLike = { /** * @public */ -@mol.codec( +@codec( mol.struct({ outPoint: OutPoint, depType: DepTypeCodec, }), ) -export class CellDep extends mol.Entity.Base() { +export class CellDep extends Entity.Base() { /** * Creates an instance of CellDep. * @@ -998,17 +999,14 @@ export type WitnessArgsLike = { /** * @public */ -@mol.codec( +@codec( mol.table({ lock: mol.BytesOpt, inputType: mol.BytesOpt, outputType: mol.BytesOpt, }), ) -export class WitnessArgs extends mol.Entity.Base< - WitnessArgsLike, - WitnessArgs ->() { +export class WitnessArgs extends Entity.Base() { /** * Creates an instance of WitnessArgs. * @@ -1091,7 +1089,7 @@ export type TransactionLike = { /** * @public */ -@mol.codec( +@codec( mol .table({ raw: RawTransaction, @@ -1106,10 +1104,7 @@ export type TransactionLike = { }) .mapOut((tx) => Transaction.from({ ...tx.raw, witnesses: tx.witnesses })), ) -export class Transaction extends mol.Entity.Base< - TransactionLike, - Transaction ->() { +export class Transaction extends Entity.Base() { /** * Creates an instance of Transaction. * diff --git a/packages/core/src/codec/codec.ts b/packages/core/src/codec/codec.ts new file mode 100644 index 000000000..77a9c4e65 --- /dev/null +++ b/packages/core/src/codec/codec.ts @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { Bytes, bytesFrom, BytesLike } from "../bytes/index.js"; + +export type CodecLike = { + readonly encode: (encodable: Encodable) => Bytes; + readonly decode: ( + decodable: BytesLike, + config?: { isExtraFieldIgnored?: boolean }, + ) => Decoded; + readonly byteLength?: number; +}; +export class Codec { + constructor( + public readonly encode: (encodable: Encodable) => Bytes, + public readonly decode: ( + decodable: BytesLike, + config?: { isExtraFieldIgnored?: boolean }, // This is equivalent to "compatible" in the Rust implementation of Molecule. + ) => Decoded, + public readonly byteLength?: number, // if provided, treat codec as fixed length + ) {} + + encodeOr(encodable: Encodable, fallback: T): Bytes | T { + try { + return this.encode(encodable); + } catch (_) { + return fallback; + } + } + + decodeOr( + decodable: BytesLike, + fallback: T, + config?: { isExtraFieldIgnored?: boolean }, // This is equivalent to "compatible" in the Rust implementation of Molecule. + ) { + try { + return this.decode(decodable, config); + } catch (_) { + return fallback; + } + } + + static from({ + encode, + decode, + byteLength, + }: CodecLike): Codec { + return new Codec( + (encodable: Encodable) => { + const encoded = encode(encodable); + if (byteLength !== undefined && encoded.byteLength !== byteLength) { + throw new Error( + `Codec.encode: expected byte length ${byteLength}, got ${encoded.byteLength}`, + ); + } + return encoded; + }, + (decodable, config) => { + const decodableBytes = bytesFrom(decodable); + if ( + byteLength !== undefined && + decodableBytes.byteLength !== byteLength + ) { + throw new Error( + `Codec.decode: expected byte length ${byteLength}, got ${decodableBytes.byteLength}`, + ); + } + return decode(decodable, config); + }, + byteLength, + ); + } + + map({ + inMap, + outMap, + }: { + inMap?: (encodable: NewEncodable) => Encodable; + outMap?: (decoded: Decoded) => NewDecoded; + }): Codec { + return new Codec( + (encodable) => + this.encode((inMap ? inMap(encodable) : encodable) as Encodable), + (buffer, config) => + (outMap + ? outMap(this.decode(buffer, config)) + : this.decode(buffer, config)) as NewDecoded, + this.byteLength, + ); + } + + mapIn( + map: (encodable: NewEncodable) => Encodable, + ): Codec { + return this.map({ inMap: map }); + } + + mapOut( + map: (decoded: Decoded) => NewDecoded, + ): Codec { + return this.map({ outMap: map }); + } +} + +export type EncodableType> = + T extends CodecLike ? Encodable : never; +export type DecodedType> = + T extends CodecLike ? Decoded : never; diff --git a/packages/core/src/molecule/entity.ts b/packages/core/src/codec/entity.ts similarity index 94% rename from packages/core/src/molecule/entity.ts rename to packages/core/src/codec/entity.ts index 4486aa5ef..d62a7a539 100644 --- a/packages/core/src/molecule/entity.ts +++ b/packages/core/src/codec/entity.ts @@ -32,7 +32,7 @@ export abstract class Entity { */ static encode(_: SubTypeLike): Bytes { throw new Error( - "encode not implemented, use @ccc.mol.codec to decorate your type", + "encode not implemented, use @ccc.codec to decorate your type", ); } /** @@ -45,7 +45,7 @@ export abstract class Entity { */ static decode(_: BytesLike): SubType { throw new Error( - "decode not implemented, use @ccc.mol.codec to decorate your type", + "decode not implemented, use @ccc.codec to decorate your type", ); } @@ -59,7 +59,7 @@ export abstract class Entity { */ static fromBytes(_bytes: BytesLike): SubType { throw new Error( - "fromBytes not implemented, use @ccc.mol.codec to decorate your type", + "fromBytes not implemented, use @ccc.codec to decorate your type", ); } @@ -156,14 +156,14 @@ export abstract class Entity { * A class decorator to add methods implementation on the {@link Entity.Base} class * @example * ```typescript - * @mol.codec( + * @codec( * mol.table({ * codeHash: mol.Byte32, * hashType: HashTypeCodec, * args: mol.Bytes, * }), * ) - * export class Script extends mol.Entity.Base() { + * export class Script extends Entity.Base() { * from(scriptLike: ScriptLike): Script {} * } * ``` @@ -186,7 +186,7 @@ export function codec< Constructor.byteLength = codec.byteLength; if (Constructor.encode === undefined) { Constructor.encode = function (encodable: TypeLike) { - return codec.encode(encodable); + return codec.encode(Constructor.from(encodable)); }; } if (Constructor.decode === undefined) { diff --git a/packages/core/src/codec/index.ts b/packages/core/src/codec/index.ts new file mode 100644 index 000000000..7688d8753 --- /dev/null +++ b/packages/core/src/codec/index.ts @@ -0,0 +1,3 @@ +export * from "./codec.js"; +export * from "./entity.js"; +export * from "./predefined.js"; diff --git a/packages/core/src/codec/predefined.ts b/packages/core/src/codec/predefined.ts new file mode 100644 index 000000000..b14b90dac --- /dev/null +++ b/packages/core/src/codec/predefined.ts @@ -0,0 +1,145 @@ +import { Bytes, bytesFrom, BytesLike } from "../bytes/index.js"; +import { Hex, hexFrom, HexLike } from "../hex/index.js"; +import { + Num, + numBeFromBytes, + numBeToBytes, + numFromBytes, + NumLike, + numToBytes, +} from "../num/index.js"; +import { Codec } from "./codec.js"; + +/** + * Create a codec to deal with fixed LE or BE bytes. + * @param byteLength + * @param littleEndian + */ +export function codecUint( + byteLength: number, + littleEndian = false, +): Codec { + return Codec.from({ + byteLength, + encode: (numLike) => { + if (littleEndian) { + return numToBytes(numLike, byteLength); + } else { + return numBeToBytes(numLike, byteLength); + } + }, + decode: (buffer) => { + if (littleEndian) { + return numFromBytes(buffer); + } else { + return numBeFromBytes(buffer); + } + }, + }); +} + +/** + * Create a codec to deal with fixed LE or BE bytes. + * @param byteLength + * @param littleEndian + */ +export function codecUintNumber( + byteLength: number, + littleEndian = false, +): Codec { + if (byteLength > 4) { + throw new Error("uintNumber: byteLength must be less than or equal to 4"); + } + return codecUint(byteLength, littleEndian).map({ + outMap: (num) => Number(num), + }); +} + +/** + * Create a codec for padding bytes. + * The padding bytes are zero-filled when encoding and ignored when decoding. + * @param byteLength The length of the padding in bytes. + */ +export function codecPadding( + byteLength: number, +): Codec { + return Codec.from({ + byteLength, + encode: () => { + return new Uint8Array(byteLength); + }, + decode: () => {}, + }); +} + +export const CodecRaw: Codec = Codec.from({ + encode: (value) => bytesFrom(value), + decode: (buffer) => bytesFrom(buffer), +}); + +export const CodecBytes: Codec = Codec.from({ + encode: (value) => bytesFrom(value), + decode: (buffer) => hexFrom(buffer), +}); + +export const CodecUint8 = codecUintNumber(1, true); + +export const CodecUint16LE = codecUintNumber(2, true); +export const CodecUint16BE = codecUintNumber(2); +export const CodecUint16 = CodecUint16LE; + +export const CodecUint32LE = codecUintNumber(4, true); +export const CodecUint32BE = codecUintNumber(4); +export const CodecUint32 = CodecUint32LE; + +export const CodecUint64LE = codecUint(8, true); +export const CodecUint64BE = codecUint(8); +export const CodecUint64 = CodecUint64LE; + +export const CodecUint128LE = codecUint(16, true); +export const CodecUint128BE = codecUint(16); +export const CodecUint128 = CodecUint128LE; + +export const CodecUint256LE = codecUint(32, true); +export const CodecUint256BE = codecUint(32); +export const CodecUint256 = CodecUint256LE; + +export const CodecUint512LE = codecUint(64, true); +export const CodecUint512BE = codecUint(64); +export const CodecUint512 = CodecUint512LE; + +export const CodecBool: Codec = Codec.from({ + byteLength: 1, + encode: (value) => bytesFrom(value ? [1] : [0]), + decode: (buffer) => bytesFrom(buffer)[0] !== 0, +}); + +export const CodecByte: Codec = Codec.from({ + byteLength: 1, + encode: (value) => bytesFrom(value), + decode: (buffer) => hexFrom(buffer), +}); + +export const CodecByte4: Codec = Codec.from({ + byteLength: 4, + encode: (value) => bytesFrom(value), + decode: (buffer) => hexFrom(buffer), +}); + +export const CodecByte8: Codec = Codec.from({ + byteLength: 8, + encode: (value) => bytesFrom(value), + decode: (buffer) => hexFrom(buffer), +}); + +export const CodecByte16: Codec = Codec.from({ + byteLength: 16, + encode: (value) => bytesFrom(value), + decode: (buffer) => hexFrom(buffer), +}); + +export const CodecByte32: Codec = Codec.from({ + byteLength: 32, + encode: (value) => bytesFrom(value), + decode: (buffer) => hexFrom(buffer), +}); diff --git a/packages/core/src/molecule/barrel.ts b/packages/core/src/molecule/barrel.ts index 7688d8753..98d88a3c3 100644 --- a/packages/core/src/molecule/barrel.ts +++ b/packages/core/src/molecule/barrel.ts @@ -1,3 +1,12 @@ +export { + /** + * @deprecated Use ccc.Entity instead + */ + Entity, + /** + * @deprecated Use ccc.codec instead + */ + codec, +} from "../codec/entity.js"; export * from "./codec.js"; -export * from "./entity.js"; export * from "./predefined.js"; diff --git a/packages/core/src/molecule/codec.ts b/packages/core/src/molecule/codec.ts index 70af71faa..ff09cab9a 100644 --- a/packages/core/src/molecule/codec.ts +++ b/packages/core/src/molecule/codec.ts @@ -1,105 +1,45 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - Bytes, bytesConcat, bytesConcatTo, bytesFrom, BytesLike, } from "../bytes/index.js"; import { - Num, - numBeFromBytes, - numBeToBytes, - numFromBytes, - NumLike, - numToBytes, -} from "../num/index.js"; - -export type CodecLike = { - readonly encode: (encodable: Encodable) => Bytes; - readonly decode: ( - decodable: BytesLike, - config?: { isExtraFieldIgnored?: boolean }, - ) => Decoded; - readonly byteLength?: number; -}; -export class Codec { - constructor( - public readonly encode: (encodable: Encodable) => Bytes, - public readonly decode: ( - decodable: BytesLike, - config?: { isExtraFieldIgnored?: boolean }, // This is equivalent to "compatible" in the Rust implementation of Molecule. - ) => Decoded, - public readonly byteLength?: number, // if provided, treat codec as fixed length - ) {} - - static from({ - encode, - decode, - byteLength, - }: CodecLike): Codec { - return new Codec( - (encodable: Encodable) => { - const encoded = encode(encodable); - if (byteLength !== undefined && encoded.byteLength !== byteLength) { - throw new Error( - `Codec.encode: expected byte length ${byteLength}, got ${encoded.byteLength}`, - ); - } - return encoded; - }, - (decodable, config) => { - const decodableBytes = bytesFrom(decodable); - if ( - byteLength !== undefined && - decodableBytes.byteLength !== byteLength - ) { - throw new Error( - `Codec.decode: expected byte length ${byteLength}, got ${decodableBytes.byteLength}`, - ); - } - return decode(decodable, config); - }, - byteLength, - ); - } - - map({ - inMap, - outMap, - }: { - inMap?: (encodable: NewEncodable) => Encodable; - outMap?: (decoded: Decoded) => NewDecoded; - }): Codec { - return new Codec( - (encodable) => - this.encode((inMap ? inMap(encodable) : encodable) as Encodable), - (buffer, config) => - (outMap - ? outMap(this.decode(buffer, config)) - : this.decode(buffer, config)) as NewDecoded, - this.byteLength, - ); - } - - mapIn( - map: (encodable: NewEncodable) => Encodable, - ): Codec { - return this.map({ inMap: map }); - } - - mapOut( - map: (decoded: Decoded) => NewDecoded, - ): Codec { - return this.map({ outMap: map }); - } -} - -export type EncodableType> = - T extends CodecLike ? Encodable : never; -export type DecodedType> = - T extends CodecLike ? Decoded : never; + Codec, + CodecLike, + DecodedType, + EncodableType, +} from "../codec/index.js"; +import { numFromBytes, NumLike, numToBytes } from "../num/index.js"; + +export { + /** + * @deprecated Use ccc.Codec instead + */ + Codec, + /** + * @deprecated Use ccc.CodecLike instead + */ + CodecLike, + /** + * @deprecated Use ccc.DecodedType instead + */ + DecodedType, + /** + * @deprecated Use ccc.EncodableType instead + */ + EncodableType, + /** + * @deprecated Use ccc.codecUint instead + */ + codecUint as uint, + /** + * @deprecated Use ccc.codecUintNumber instead + */ + codecUintNumber as uintNumber, +} from "../codec/index.js"; function uint32To(numLike: NumLike) { return numToBytes(numLike, 4); @@ -673,65 +613,3 @@ export function array( }, }); } - -/** - * Create a codec to deal with fixed LE or BE bytes. - * @param byteLength - * @param littleEndian - */ -export function uint( - byteLength: number, - littleEndian = false, -): Codec { - return Codec.from({ - byteLength, - encode: (numLike) => { - if (littleEndian) { - return numToBytes(numLike, byteLength); - } else { - return numBeToBytes(numLike, byteLength); - } - }, - decode: (buffer) => { - if (littleEndian) { - return numFromBytes(buffer); - } else { - return numBeFromBytes(buffer); - } - }, - }); -} - -/** - * Create a codec to deal with fixed LE or BE bytes. - * @param byteLength - * @param littleEndian - */ -export function uintNumber( - byteLength: number, - littleEndian = false, -): Codec { - if (byteLength > 4) { - throw new Error("uintNumber: byteLength must be less than or equal to 4"); - } - return uint(byteLength, littleEndian).map({ - outMap: (num) => Number(num), - }); -} - -/** - * Create a codec for padding bytes. - * The padding bytes are zero-filled when encoding and ignored when decoding. - * @param byteLength The length of the padding in bytes. - */ -export function padding( - byteLength: number, -): Codec { - return Codec.from({ - byteLength, - encode: () => { - return new Uint8Array(byteLength); - }, - decode: () => {}, - }); -} diff --git a/packages/core/src/molecule/predefined.ts b/packages/core/src/molecule/predefined.ts index 176da41ca..7e13c3ff8 100644 --- a/packages/core/src/molecule/predefined.ts +++ b/packages/core/src/molecule/predefined.ts @@ -1,91 +1,104 @@ import { bytesFrom, bytesTo } from "../bytes/index.js"; -import { Hex, hexFrom, HexLike } from "../hex/index.js"; -import { byteVec, Codec, option, uint, uintNumber, vector } from "./codec.js"; +import { Codec, CodecBytes } from "../codec/index.js"; +import { Hex, HexLike } from "../hex/index.js"; +import { byteVec, option, vector } from "./codec.js"; + +import { + CodecBool as Bool, + CodecByte as Byte, + CodecByte16 as Byte16, + CodecByte32 as Byte32, + CodecByte4 as Byte4, + CodecByte8 as Byte8, + CodecUint128 as Uint128, + CodecUint128BE as Uint128BE, + CodecUint128LE as Uint128LE, + CodecUint16 as Uint16, + CodecUint16BE as Uint16BE, + CodecUint16LE as Uint16LE, + CodecUint256 as Uint256, + CodecUint256BE as Uint256BE, + CodecUint256LE as Uint256LE, + CodecUint32 as Uint32, + CodecUint32BE as Uint32BE, + CodecUint32LE as Uint32LE, + CodecUint512 as Uint512, + CodecUint512BE as Uint512BE, + CodecUint512LE as Uint512LE, + CodecUint64 as Uint64, + CodecUint64BE as Uint64BE, + CodecUint64LE as Uint64LE, + CodecUint8 as Uint8, +} from "../codec/index.js"; + +export { + Bool, + Byte, + Byte16, + Byte32, + Byte4, + Byte8, + Uint128, + Uint128BE, + Uint128LE, + Uint16, + Uint16BE, + Uint16LE, + Uint256, + Uint256BE, + Uint256LE, + Uint32, + Uint32BE, + Uint32LE, + Uint512, + Uint512BE, + Uint512LE, + Uint64, + Uint64BE, + Uint64LE, + Uint8, +}; -export const Uint8 = uintNumber(1, true); export const Uint8Opt = option(Uint8); export const Uint8Vec = vector(Uint8); -export const Uint16LE = uintNumber(2, true); -export const Uint16BE = uintNumber(2); -export const Uint16 = Uint16LE; export const Uint16Opt = option(Uint16); export const Uint16Vec = vector(Uint16); -export const Uint32LE = uintNumber(4, true); -export const Uint32BE = uintNumber(4); -export const Uint32 = Uint32LE; export const Uint32Opt = option(Uint32); export const Uint32Vec = vector(Uint32); -export const Uint64LE = uint(8, true); -export const Uint64BE = uint(8); -export const Uint64 = Uint64LE; export const Uint64Opt = option(Uint64); export const Uint64Vec = vector(Uint64); -export const Uint128LE = uint(16, true); -export const Uint128BE = uint(16); -export const Uint128 = Uint128LE; export const Uint128Opt = option(Uint128); export const Uint128Vec = vector(Uint128); -export const Uint256LE = uint(32, true); -export const Uint256BE = uint(32); -export const Uint256 = Uint256LE; export const Uint256Opt = option(Uint256); export const Uint256Vec = vector(Uint256); -export const Uint512LE = uint(64, true); -export const Uint512BE = uint(64); -export const Uint512 = Uint512LE; export const Uint512Opt = option(Uint512); export const Uint512Vec = vector(Uint512); -export const Bytes: Codec = byteVec({ - encode: (value) => bytesFrom(value), - decode: (buffer) => hexFrom(buffer), -}); +export const Bytes: Codec = byteVec(CodecBytes); export const BytesOpt = option(Bytes); export const BytesVec = vector(Bytes); -export const Bool: Codec = Codec.from({ - byteLength: 1, - encode: (value) => bytesFrom(value ? [1] : [0]), - decode: (buffer) => bytesFrom(buffer)[0] !== 0, -}); export const BoolOpt = option(Bool); export const BoolVec = vector(Bool); -export const Byte4: Codec = Codec.from({ - byteLength: 4, - encode: (value) => bytesFrom(value), - decode: (buffer) => hexFrom(buffer), -}); +export const ByteOpt = option(Byte); +export const ByteVec = vector(Byte); + export const Byte4Opt = option(Byte4); export const Byte4Vec = vector(Byte4); -export const Byte8: Codec = Codec.from({ - byteLength: 8, - encode: (value) => bytesFrom(value), - decode: (buffer) => hexFrom(buffer), -}); export const Byte8Opt = option(Byte8); export const Byte8Vec = vector(Byte8); -export const Byte16: Codec = Codec.from({ - byteLength: 16, - encode: (value) => bytesFrom(value), - decode: (buffer) => hexFrom(buffer), -}); export const Byte16Opt = option(Byte16); export const Byte16Vec = vector(Byte16); -export const Byte32: Codec = Codec.from({ - byteLength: 32, - encode: (value) => bytesFrom(value), - decode: (buffer) => hexFrom(buffer), -}); export const Byte32Opt = option(Byte32); export const Byte32Vec = vector(Byte32); diff --git a/packages/spore/src/cobuild/index.ts b/packages/spore/src/cobuild/index.ts index c1c5b52b8..4437f56d2 100644 --- a/packages/spore/src/cobuild/index.ts +++ b/packages/spore/src/cobuild/index.ts @@ -1,4 +1,4 @@ -import { ccc, mol } from "@ckb-ccc/core"; +import { ccc } from "@ckb-ccc/core"; import { Action, ActionVec, @@ -11,7 +11,7 @@ export function assembleCreateSporeAction( sporeOutput: ccc.CellOutputLike, sporeData: ccc.BytesLike, scriptInfoHash: ccc.HexLike = DEFAULT_COBUILD_INFO_HASH, -): mol.EncodableType { +): ccc.EncodableType { if (!sporeOutput.type) { throw new Error("Spore cell must have a type script"); } @@ -39,7 +39,7 @@ export function assembleTransferSporeAction( sporeInput: ccc.CellOutputLike, sporeOutput: ccc.CellOutputLike, scriptInfoHash: ccc.HexLike = DEFAULT_COBUILD_INFO_HASH, -): mol.EncodableType { +): ccc.EncodableType { if (!sporeInput.type || !sporeOutput.type) { throw new Error("Spore cell must have a type script"); } @@ -70,7 +70,7 @@ export function assembleTransferSporeAction( export function assembleMeltSporeAction( sporeInput: ccc.CellOutputLike, scriptInfoHash: ccc.HexLike = DEFAULT_COBUILD_INFO_HASH, -): mol.EncodableType { +): ccc.EncodableType { if (!sporeInput.type) { throw new Error("Spore cell must have a type script"); } @@ -97,7 +97,7 @@ export function assembleCreateClusterAction( clusterOutput: ccc.CellOutputLike, clusterData: ccc.BytesLike, scriptInfoHash: ccc.HexLike = DEFAULT_COBUILD_INFO_HASH, -): mol.EncodableType { +): ccc.EncodableType { if (!clusterOutput.type) { throw new Error("Cluster cell must have a type script"); } @@ -125,7 +125,7 @@ export function assembleTransferClusterAction( clusterInput: ccc.CellOutputLike, clusterOutput: ccc.CellOutputLike, scriptInfoHash: ccc.HexLike = DEFAULT_COBUILD_INFO_HASH, -): mol.EncodableType { +): ccc.EncodableType { if (!clusterInput.type || !clusterOutput.type) { throw new Error("Cluster cell must have a type script"); } @@ -155,7 +155,7 @@ export function assembleTransferClusterAction( export async function prepareSporeTransaction( signer: ccc.Signer, txLike: ccc.TransactionLike, - actions: mol.EncodableType, + actions: ccc.EncodableType, ): Promise { let tx = ccc.Transaction.from(txLike); @@ -171,7 +171,7 @@ export async function prepareSporeTransaction( export function unpackCommonCobuildProof( data: ccc.HexLike, -): mol.EncodableType | undefined { +): ccc.EncodableType | undefined { try { return WitnessLayout.decode(ccc.bytesFrom(data)); } catch { @@ -181,7 +181,7 @@ export function unpackCommonCobuildProof( export function extractCobuildActionsFromTx( tx: ccc.Transaction, -): mol.EncodableType { +): ccc.EncodableType { if (tx.witnesses.length === 0) { return []; } @@ -202,7 +202,7 @@ export function extractCobuildActionsFromTx( export function injectCobuild( tx: ccc.Transaction, - actions: mol.EncodableType, + actions: ccc.EncodableType, ): void { tx.setWitnessAt( Math.max(tx.witnesses.length, tx.inputs.length), diff --git a/packages/spore/src/codec/cluster.ts b/packages/spore/src/codec/cluster.ts index e8f7e6367..cc9dc7b44 100644 --- a/packages/spore/src/codec/cluster.ts +++ b/packages/spore/src/codec/cluster.ts @@ -5,7 +5,7 @@ export interface ClusterDataV1View { description: string; } -export const ClusterDataV1: mol.Codec = mol.table({ +export const ClusterDataV1: ccc.Codec = mol.table({ name: mol.String, description: mol.String, }); @@ -16,7 +16,7 @@ export interface ClusterDataV2View { mutantId?: ccc.HexLike; } -export const ClusterDataV2: mol.Codec = mol.table({ +export const ClusterDataV2: ccc.Codec = mol.table({ name: mol.String, description: mol.String, mutantId: mol.BytesOpt, diff --git a/packages/spore/src/codec/spore.ts b/packages/spore/src/codec/spore.ts index 4f41555cc..4f1ebef32 100644 --- a/packages/spore/src/codec/spore.ts +++ b/packages/spore/src/codec/spore.ts @@ -6,7 +6,7 @@ export interface SporeDataView { clusterId?: ccc.HexLike; } -export const SporeData: mol.Codec = mol.table({ +export const SporeData: ccc.Codec = mol.table({ contentType: mol.String, content: mol.Bytes, clusterId: mol.BytesOpt, diff --git a/packages/spore/src/spore/advanced.ts b/packages/spore/src/spore/advanced.ts index 23aa84aa1..e83c0dc84 100644 --- a/packages/spore/src/spore/advanced.ts +++ b/packages/spore/src/spore/advanced.ts @@ -1,4 +1,4 @@ -import { ccc, mol } from "@ckb-ccc/core"; +import { ccc } from "@ckb-ccc/core"; import { assembleTransferClusterAction } from "../advanced.js"; import { assertCluster } from "../cluster/index.js"; import { Action, SporeDataView } from "../codec/index.js"; @@ -9,7 +9,7 @@ export async function prepareCluster( data: SporeDataView, clusterMode?: "lockProxy" | "clusterCell" | "skip", scriptInfoHash?: ccc.HexLike, -): Promise | undefined> { +): Promise | undefined> { // skip if the spore is not belong to a cluster if (!data.clusterId || clusterMode === "skip") { return; From 3bd51300d9602482dd781752f618f6cfd642675c Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Wed, 7 Jan 2026 00:00:37 +0800 Subject: [PATCH 05/45] feat(type-id): add type-id package for Type ID operations --- .changeset/fifty-parks-dress.md | 7 + packages/shell/package.json | 1 + packages/shell/src/advancedBarrel.ts | 1 + packages/shell/src/barrel.ts | 1 + packages/type-id/.npmignore | 21 + packages/type-id/.prettierignore | 15 + packages/type-id/README.md | 41 ++ packages/type-id/eslint.config.mjs | 62 ++ .../misc/basedirs/dist.commonjs/package.json | 3 + .../type-id/misc/basedirs/dist/package.json | 3 + packages/type-id/package.json | 62 ++ packages/type-id/prettier.config.cjs | 11 + packages/type-id/src/advanced.ts | 2 + packages/type-id/src/advancedBarrel.test.ts | 493 ++++++++++++++++ packages/type-id/src/advancedBarrel.ts | 229 ++++++++ packages/type-id/src/barrel.ts | 12 + packages/type-id/src/index.ts | 2 + packages/type-id/tsconfig.base.json | 22 + packages/type-id/tsconfig.commonjs.json | 8 + packages/type-id/tsconfig.json | 8 + packages/type-id/tsdown.config.mts | 36 ++ packages/type-id/typedoc.json | 6 + packages/type-id/vitest.config.mts | 10 + pnpm-lock.yaml | 553 +++++++++++++++--- typedoc.config.mjs | 1 + vitest.config.mts | 4 +- 26 files changed, 1528 insertions(+), 86 deletions(-) create mode 100644 .changeset/fifty-parks-dress.md create mode 100644 packages/type-id/.npmignore create mode 100644 packages/type-id/.prettierignore create mode 100644 packages/type-id/README.md create mode 100644 packages/type-id/eslint.config.mjs create mode 100644 packages/type-id/misc/basedirs/dist.commonjs/package.json create mode 100644 packages/type-id/misc/basedirs/dist/package.json create mode 100644 packages/type-id/package.json create mode 100644 packages/type-id/prettier.config.cjs create mode 100644 packages/type-id/src/advanced.ts create mode 100644 packages/type-id/src/advancedBarrel.test.ts create mode 100644 packages/type-id/src/advancedBarrel.ts create mode 100644 packages/type-id/src/barrel.ts create mode 100644 packages/type-id/src/index.ts create mode 100644 packages/type-id/tsconfig.base.json create mode 100644 packages/type-id/tsconfig.commonjs.json create mode 100644 packages/type-id/tsconfig.json create mode 100644 packages/type-id/tsdown.config.mts create mode 100644 packages/type-id/typedoc.json create mode 100644 packages/type-id/vitest.config.mts diff --git a/.changeset/fifty-parks-dress.md b/.changeset/fifty-parks-dress.md new file mode 100644 index 000000000..384359158 --- /dev/null +++ b/.changeset/fifty-parks-dress.md @@ -0,0 +1,7 @@ +--- +"@ckb-ccc/shell": minor +"@ckb-ccc/type-id": patch +--- + +feat(type-id): add type-id package for Type ID operations + diff --git a/packages/shell/package.json b/packages/shell/package.json index d5aed9bbb..0f7efafca 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -59,6 +59,7 @@ "@ckb-ccc/core": "workspace:*", "@ckb-ccc/spore": "workspace:*", "@ckb-ccc/ssri": "workspace:*", + "@ckb-ccc/type-id": "workspace:*", "@ckb-ccc/udt": "workspace:*" }, "packageManager": "pnpm@10.8.1" diff --git a/packages/shell/src/advancedBarrel.ts b/packages/shell/src/advancedBarrel.ts index ec239348c..f2a60e5b7 100644 --- a/packages/shell/src/advancedBarrel.ts +++ b/packages/shell/src/advancedBarrel.ts @@ -1,2 +1,3 @@ export * from "@ckb-ccc/core/advancedBarrel"; export { sporeA } from "@ckb-ccc/spore/advanced"; +export { typeIdA } from "@ckb-ccc/type-id/advanced"; diff --git a/packages/shell/src/barrel.ts b/packages/shell/src/barrel.ts index 9cca15d65..3c7752a39 100644 --- a/packages/shell/src/barrel.ts +++ b/packages/shell/src/barrel.ts @@ -1,4 +1,5 @@ export * from "@ckb-ccc/core/barrel"; export { spore } from "@ckb-ccc/spore"; export { ssri } from "@ckb-ccc/ssri"; +export { typeId } from "@ckb-ccc/type-id"; export { udt } from "@ckb-ccc/udt"; diff --git a/packages/type-id/.npmignore b/packages/type-id/.npmignore new file mode 100644 index 000000000..7a88408aa --- /dev/null +++ b/packages/type-id/.npmignore @@ -0,0 +1,21 @@ +node_modules/ +misc/ + +*test.js +*test.ts +*test.d.ts +*test.d.ts.map +*spec.js +*spec.ts +*spec.d.ts +*spec.d.ts.map + +tsconfig.json +tsconfig.*.json +eslint.config.mjs +.prettierrc +.prettierignore + +tsconfig.tsbuildinfo +tsconfig.*.tsbuildinfo +.github/ diff --git a/packages/type-id/.prettierignore b/packages/type-id/.prettierignore new file mode 100644 index 000000000..aef5d239c --- /dev/null +++ b/packages/type-id/.prettierignore @@ -0,0 +1,15 @@ +node_modules/ + +dist/ +dist.commonjs/ + +.npmignore +.prettierrc +tsconfig.json +eslint.config.mjs +prettier.config.* + +tsconfig.tsbuildinfo +.github/ + +CHANGELOG.md diff --git a/packages/type-id/README.md b/packages/type-id/README.md new file mode 100644 index 000000000..a569c8126 --- /dev/null +++ b/packages/type-id/README.md @@ -0,0 +1,41 @@ +

+ + Logo + +

+ +

+ CCC's Support for Type ID +

+ +

+ NPM Version + GitHub commit activity + GitHub last commit + GitHub branch check runs + Playground + App + Docs +

+ +

+ CCC - CKBers' Codebase is a one-stop solution for your CKB JS/TS ecosystem development. +
+ Empower yourself with CCC to discover the unlimited potential of CKB. +
+ Interoperate with wallets from different chain ecosystems. +
+ Fully enabling CKB's Turing completeness and cryptographic freedom power. +

+ +

+ Read more about CCC on our website or GitHub Repo. +

diff --git a/packages/type-id/eslint.config.mjs b/packages/type-id/eslint.config.mjs new file mode 100644 index 000000000..b6132c277 --- /dev/null +++ b/packages/type-id/eslint.config.mjs @@ -0,0 +1,62 @@ +// @ts-check + +import eslint from "@eslint/js"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import tseslint from "typescript-eslint"; + +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +export default [ + ...tseslint.config({ + files: ["**/*.ts"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + args: "all", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + "@typescript-eslint/unbound-method": ["error", { ignoreStatic: true }], + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/only-throw-error": [ + "error", + { + allowThrowingAny: true, + allowThrowingUnknown: true, + allowRethrowing: true, + }, + ], + "@typescript-eslint/prefer-promise-reject-errors": [ + "error", + { + allowThrowingAny: true, + allowThrowingUnknown: true, + }, + ], + "no-empty": "off", + "prefer-const": [ + "error", + { ignoreReadBeforeAssign: true, destructuring: "all" }, + ], + }, + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: dirname(fileURLToPath(import.meta.url)), + }, + }, + }), + eslintPluginPrettierRecommended, +]; diff --git a/packages/type-id/misc/basedirs/dist.commonjs/package.json b/packages/type-id/misc/basedirs/dist.commonjs/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/packages/type-id/misc/basedirs/dist.commonjs/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/packages/type-id/misc/basedirs/dist/package.json b/packages/type-id/misc/basedirs/dist/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/packages/type-id/misc/basedirs/dist/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/type-id/package.json b/packages/type-id/package.json new file mode 100644 index 000000000..dcdf82b3b --- /dev/null +++ b/packages/type-id/package.json @@ -0,0 +1,62 @@ +{ + "name": "@ckb-ccc/type-id", + "version": "0.0.0", + "description": "CCC - CKBer's Codebase. CCC's support for Type ID", + "author": "Hanssen0 ", + "license": "MIT", + "private": false, + "homepage": "https://github.com/ckb-devrel/ccc", + "repository": { + "type": "git", + "url": "git://github.com/ckb-devrel/ccc.git" + }, + "main": "./dist.commonjs/index.js", + "module": "./dist/index.mjs", + "exports": { + ".": { + "require": "./dist.commonjs/index.js", + "import": "./dist/index.mjs" + }, + "./advanced": { + "require": "./dist.commonjs/advanced.js", + "import": "./dist/advanced.mjs" + }, + "./advancedBarrel": { + "require": "./dist.commonjs/advancedBarrel.js", + "import": "./dist/advancedBarrel.mjs" + }, + "./barrel": { + "require": "./dist.commonjs/barrel.js", + "import": "./dist/barrel.mjs" + }, + "./package.json": "./package.json" + }, + "scripts": { + "test": "vitest", + "test:ci": "vitest run", + "build": "tsdown", + "lint": "eslint ./src", + "format": "prettier --write . && eslint --fix ./src" + }, + "devDependencies": { + "@eslint/js": "^9.34.0", + "@types/node": "^24.3.0", + "eslint": "^9.34.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "prettier": "^3.6.2", + "prettier-plugin-organize-imports": "^4.2.0", + "tsdown": "0.19.0-beta.3", + "typescript": "^5.9.2", + "typescript-eslint": "^8.41.0", + "vitest": "^3.2.4" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@ckb-ccc/core": "workspace:*" + }, + "packageManager": "pnpm@10.8.1", + "types": "./dist.commonjs/index.d.ts" +} diff --git a/packages/type-id/prettier.config.cjs b/packages/type-id/prettier.config.cjs new file mode 100644 index 000000000..5e1810363 --- /dev/null +++ b/packages/type-id/prettier.config.cjs @@ -0,0 +1,11 @@ +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: [require.resolve("prettier-plugin-organize-imports")], +}; + +module.exports = config; diff --git a/packages/type-id/src/advanced.ts b/packages/type-id/src/advanced.ts new file mode 100644 index 000000000..fa30ed271 --- /dev/null +++ b/packages/type-id/src/advanced.ts @@ -0,0 +1,2 @@ +export * from "./advancedBarrel.js"; +export * as typeIdA from "./advancedBarrel.js"; diff --git a/packages/type-id/src/advancedBarrel.test.ts b/packages/type-id/src/advancedBarrel.test.ts new file mode 100644 index 000000000..d7fad2376 --- /dev/null +++ b/packages/type-id/src/advancedBarrel.test.ts @@ -0,0 +1,493 @@ +import { ccc } from "@ckb-ccc/core"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { buildTypeIdOperations } from "./advancedBarrel.js"; + +describe("type-id", () => { + let client: ccc.Client; + let signer: ccc.Signer; + + const typeIdScript = { + codeHash: + "0x00000000000000000000000000000000000000000000000000545950455f4944", + hashType: "type" as const, + args: "0x", + }; + const typeIdCellDep = { + outPoint: { + txHash: + "0x1111111111111111111111111111111111111111111111111111111111111111", + index: 0, + }, + depType: "code" as const, + }; + + beforeEach(() => { + client = { + getKnownScript: vi.fn(), + getCellDeps: vi.fn(), + findSingletonCellByType: vi.fn(), + } as unknown as ccc.Client; + + signer = { + client, + getRecommendedAddressObj: vi.fn(), + findCells: vi.fn(), + } as unknown as ccc.Signer; + + (client.getKnownScript as Mock).mockResolvedValue({ + ...typeIdScript, + cellDeps: [{ cellDep: typeIdCellDep }], + }); + + (client.getCellDeps as Mock).mockImplementation( + async (deps: ccc.CellDepInfoLike[]) => + deps.map((d) => ccc.CellDep.from(d.cellDep)), + ); + + (signer.getRecommendedAddressObj as Mock).mockResolvedValue({ + script: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x1234", + }), + }); + }); + + describe("buildTypeIdOperations with custom options", () => { + it("should use custom codec", async () => { + const customCodec = { + encode: (data: number) => ccc.numLeToBytes(data, 4), + decode: (bytes: ccc.BytesLike) => Number(ccc.numLeFromBytes(bytes)), + }; + + const { create } = buildTypeIdOperations({ + getScriptInfo: async () => ({ + ...typeIdScript, + cellDeps: [{ cellDep: typeIdCellDep }], + }), + codec: customCodec, + }); + + const inputCell = ccc.Cell.from({ + outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(1000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + }, + outputData: "0x", + }); + (signer.findCells as Mock).mockImplementation(async function* () { + yield inputCell; + }); + + const { tx } = await create({ + signer, + data: 123456, + }); + + expect(tx.outputsData[0]).toBe(ccc.hexFrom(ccc.numLeToBytes(123456, 4))); + }); + + it("should use custom calculateTypeId", async () => { + const customId = "0x" + "9".repeat(64); + const calculateTypeId = vi.fn().mockResolvedValue(customId); + + const { create } = buildTypeIdOperations({ + getScriptInfo: async () => ({ + ...typeIdScript, + cellDeps: [{ cellDep: typeIdCellDep }], + }), + calculateTypeId, + }); + + const inputCell = ccc.Cell.from({ + outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(1000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + }, + outputData: "0x", + }); + (signer.findCells as Mock).mockImplementation(async function* () { + yield inputCell; + }); + + const { id, tx } = await create({ + signer, + data: "0x", + }); + + expect(calculateTypeId).toHaveBeenCalled(); + expect(id).toBe(customId); + expect(tx.outputs[0].type?.args).toBe(customId); + }); + + it("should use custom addCellDeps", async () => { + const customDep = ccc.CellDep.from({ + outPoint: { txHash: "0x" + "a".repeat(64), index: 0 }, + depType: "code", + }); + const addCellDeps = vi + .fn() + .mockImplementation(async (_: ccc.Client, tx: ccc.Transaction) => { + tx.addCellDeps(customDep); + return tx; + }); + + const { create } = buildTypeIdOperations({ + getScriptInfo: async () => ({ + ...typeIdScript, + cellDeps: [{ cellDep: typeIdCellDep }], + }), + addCellDeps, + }); + + const inputCell = ccc.Cell.from({ + outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(1000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + }, + outputData: "0x", + }); + (signer.findCells as Mock).mockImplementation(async function* () { + yield inputCell; + }); + + const { tx } = await create({ + signer, + data: "0x", + }); + + expect(addCellDeps).toHaveBeenCalled(); + expect(tx.cellDeps).toContainEqual(customDep); + }); + }); + + describe("Type ID Operations", () => { + const { create, transfer, destroy } = buildTypeIdOperations({ + getScriptInfo: async () => ({ + ...typeIdScript, + cellDeps: [{ cellDep: typeIdCellDep }], + }), + }); + + describe("create", () => { + it("should create a transaction with correct type id", async () => { + const inputCell = ccc.Cell.from({ + outPoint: { + txHash: "0x" + "2".repeat(64), + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(1000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + }, + outputData: "0x", + }); + + (signer.findCells as Mock).mockImplementation(async function* () { + yield inputCell; + }); + + const data = "0x1234"; + const { tx, id, index } = await create({ + signer, + data, + }); + + expect(tx.inputs.length).toBe(1); + expect(tx.inputs[0].previousOutput).toEqual(inputCell.outPoint); + + expect(tx.outputs.length).toBe(1); + expect(index).toBe(0); + + const expectedId = ccc.hashTypeId(tx.inputs[0], 0); + expect(id).toBe(expectedId); + + const output = tx.outputs[0]; + expect(output.type).toBeDefined(); + expect(output.type?.codeHash).toBe(typeIdScript.codeHash); + expect(output.type?.hashType).toBe(typeIdScript.hashType); + expect(output.type?.args).toBe(id); + expect(tx.outputsData[0]).toBe(ccc.hexFrom(data)); + + expect(tx.cellDeps.length).toBeGreaterThan(0); + expect(tx.cellDeps[0].outPoint).toEqual( + ccc.OutPoint.from(typeIdCellDep.outPoint), + ); + }); + + it(" should append to existing tx", async () => { + const existingTx = ccc.Transaction.from({ + headerDeps: ["0x" + "e".repeat(64)], + }); + + const inputCell = ccc.Cell.from({ + outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(1000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + }, + outputData: "0x", + }); + (signer.findCells as Mock).mockImplementation(async function* () { + yield inputCell; + }); + + const { tx } = await create({ + signer, + data: "0x", + tx: existingTx, + }); + + expect(tx.headerDeps).toContain("0x" + "e".repeat(64)); + }); + }); + + it("should accept explicit receiver", async () => { + const receiver = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xffee", + }); + + const inputCell = ccc.Cell.from({ + outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(1000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + }, + outputData: "0x", + }); + (signer.findCells as Mock).mockImplementation(async function* () { + yield inputCell; + }); + + const { tx } = await create({ + signer, + data: "0x", + receiver, + }); + + expect(tx.outputs[0].lock).toEqual(receiver); + }); + + describe("transferTypeId", () => { + it("should transfer type id cell to new receiver", async () => { + const id = "0x" + "3".repeat(64); + const receiver = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xabcd", + }); + + const typeScript = ccc.Script.from({ + ...typeIdScript, + args: id, + }); + + const existingCell = ccc.Cell.from({ + outPoint: { + txHash: "0x" + "4".repeat(64), + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(2000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + type: typeScript, + }, + outputData: "0x5678", + }); + + (client.findSingletonCellByType as Mock).mockResolvedValue( + existingCell, + ); + + const newData = "0x9999"; + const { tx, inIndex, outIndex } = await transfer({ + client, + id, + receiver, + data: newData, + }); + + expect(tx.inputs[inIndex].previousOutput).toEqual( + existingCell.outPoint, + ); + + const output = tx.outputs[outIndex]; + expect(output.lock).toEqual(receiver); + expect(output.type).toEqual(typeScript); + expect(tx.outputsData[outIndex]).toBe(ccc.hexFrom(newData)); + }); + + it("transfer should preserve data if not provided", async () => { + const id = "0x" + "3".repeat(64); + const receiver = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xabcd", + }); + const typeScript = ccc.Script.from({ ...typeIdScript, args: id }); + const existingCell = ccc.Cell.from({ + outPoint: { txHash: "0x" + "4".repeat(64), index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(2000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + type: typeScript, + }, + outputData: "0x123456", + }); + + (client.findSingletonCellByType as Mock).mockResolvedValue( + existingCell, + ); + + const { tx, outIndex } = await transfer({ + client, + id, + receiver, + }); + + expect(tx.outputsData[outIndex]).toBe("0x123456"); + }); + + it("should transfer type id cell with data transformer", async () => { + const id = "0x" + "3".repeat(64); + const receiver = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xabcd", + }); + + const typeScript = ccc.Script.from({ + ...typeIdScript, + args: id, + }); + + const existingCell = ccc.Cell.from({ + outPoint: { + txHash: "0x" + "4".repeat(64), + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(2000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + type: typeScript, + }, + outputData: "0x1234", + }); + + (client.findSingletonCellByType as Mock).mockResolvedValue( + existingCell, + ); + + const { tx, outIndex } = await transfer({ + client, + id, + receiver, + data: (c, d) => ccc.bytesConcat(c.outputData, d ?? "0x", "0x5678"), + }); + + const output = tx.outputs[outIndex]; + expect(output.lock).toEqual(receiver); + expect(output.type).toEqual(typeScript); + expect(tx.outputsData[outIndex]).toBe(ccc.hexFrom("0x123412345678")); + }); + + it("should throw error if type id cell not found", async () => { + (client.findSingletonCellByType as Mock).mockResolvedValue(undefined); + + await expect( + transfer({ + client, + id: "0x" + "0".repeat(64), + receiver: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + }), + ).rejects.toThrow("Type ID"); + }); + }); + + describe("destroyTypeId", () => { + it("should consume type id cell without creating new one", async () => { + const id = "0x" + "5".repeat(64); + const typeScript = ccc.Script.from({ + ...typeIdScript, + args: id, + }); + + const existingCell = ccc.Cell.from({ + outPoint: { + txHash: "0x" + "6".repeat(64), + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(3000), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + type: typeScript, + }, + outputData: "0x", + }); + + (client.findSingletonCellByType as Mock).mockResolvedValue( + existingCell, + ); + + const { tx, index } = await destroy({ + client, + id, + }); + + expect(tx.inputs[index].previousOutput).toEqual(existingCell.outPoint); + + const hasTypeOutput = tx.outputs.some((o) => o.type?.eq(typeScript)); + expect(hasTypeOutput).toBe(false); + }); + }); + }); +}); diff --git a/packages/type-id/src/advancedBarrel.ts b/packages/type-id/src/advancedBarrel.ts new file mode 100644 index 000000000..29a171d4c --- /dev/null +++ b/packages/type-id/src/advancedBarrel.ts @@ -0,0 +1,229 @@ +import { ccc } from "@ckb-ccc/core"; + +/** + * Build Type ID operations. + * + * @param props The properties to build the operations. + * @param props.getScriptInfo Function to get the script info. + * @param props.calculateTypeId Function to calculate the Type ID. + * @param props.addCellDeps Function to add cell dependencies. + */ + +export function buildTypeIdOperations< + Encodable = ccc.BytesLike, + Decoded = ccc.Bytes, +>(props: { + getScriptInfo: (client: ccc.Client) => Promise; + codec?: ccc.CodecLike | null; + calculateTypeId?: + | ((client: ccc.Client, tx: ccc.Transaction) => Promise) + | null; + addCellDeps?: + | (( + client: ccc.Client, + tx: ccc.Transaction, + scriptInfo: ccc.ScriptInfo, + ) => Promise) + | null; +}) { + async function getScriptInfo(client: ccc.Client): Promise { + return ccc.ScriptInfo.from(await props.getScriptInfo(client)); + } + + const codec = ( + props.codec ? ccc.Codec.from(props.codec) : ccc.CodecRaw + ) as ccc.Codec; + + function getTypeScript(scriptInfo: ccc.ScriptInfo, args: ccc.HexLike) { + return ccc.Script.from({ + ...scriptInfo, + args, + }); + } + + async function addCellDeps( + client: ccc.Client, + tx: ccc.Transaction, + scriptInfo: ccc.ScriptInfo, + ): Promise { + if (props.addCellDeps) { + return ccc.Transaction.from( + await props.addCellDeps(client, tx, scriptInfo), + ); + } + + tx.addCellDeps(...(await client.getCellDeps(scriptInfo.cellDeps))); + return tx; + } + + async function calculateTypeId( + client: ccc.Client, + tx: ccc.Transaction, + ): Promise { + if (props.calculateTypeId) { + return ccc.hexFrom(await props.calculateTypeId(client, tx)); + } + + return ccc.hashTypeId(tx.inputs[0], tx.outputs.length); + } + + return { + /** + * Create a Type ID cell. + * + * @param props The arguments for creating the cell. + * @param props.signer The signer to sign the transaction. + * @param props.receiver The receiver script (optional). + * @param props.data The output data. + * @param props.tx The transaction skeleton (optional). + */ + async create( + this: void, + props: { + signer: ccc.Signer; + data: Encodable; + receiver?: ccc.ScriptLike | null; + tx?: ccc.TransactionLike | null; + }, + ): Promise<{ + tx: ccc.Transaction; + id: ccc.Hex; + index: number; + }> { + const { signer, receiver, data, tx: txLike } = props; + const tx = ccc.Transaction.from(txLike ?? {}); + + await tx.completeInputsAtLeastOne(signer); + const id = await calculateTypeId(signer.client, tx); + + const scriptInfo = await getScriptInfo(signer.client); + const len = tx.addOutput({ + cellOutput: { + type: getTypeScript(scriptInfo, id), + lock: receiver + ? ccc.Script.from(receiver) + : (await signer.getRecommendedAddressObj()).script, + }, + outputData: codec.encode(data), + }); + + return { + tx: await addCellDeps(signer.client, tx, scriptInfo), + id, + index: len - 1, + }; + }, + + /** + * Transfer a Type ID cell. + * + * @param props The arguments for transferring the cell. + * @param props.client The client to communicate with CKB. + * @param props.id The Type ID to transfer. + * @param props.receiver The new receiver script. + * @param props.tx The transaction skeleton (optional). + * @param props.data The new output data or a transformer to update the data (optional). + */ + async transfer( + this: void, + props: { + client: ccc.Client; + id: ccc.HexLike; + receiver: ccc.ScriptLike; + tx?: ccc.TransactionLike | null; + data?: + | Encodable + | ((cell: ccc.Cell, data?: Decoded) => Encodable | Promise) + | null; + }, + ): Promise<{ + tx: ccc.Transaction; + inIndex: number; + outIndex: number; + }> { + const { client, id, receiver, tx: txLike, data } = props; + const tx = ccc.Transaction.from(txLike ?? {}); + + const scriptInfo = await getScriptInfo(client); + const type = getTypeScript(scriptInfo, id); + const inCell = await client.findSingletonCellByType(type); + if (!inCell) { + throw new Error(`Type ID ${ccc.stringify(type)} not found`); + } + + const outputData = await (async () => { + if (!data) { + return inCell.outputData; + } + + if (typeof data === "function") { + return codec.encode( + await ( + data as ( + cell: ccc.Cell, + data?: Decoded, + ) => Encodable | Promise + )(inCell, codec.decodeOr(inCell.outputData, undefined)), + ); + } + + return codec.encode(data); + })(); + + const outCell = ccc.CellAny.from({ + ...inCell, + cellOutput: { + ...inCell.cellOutput, + lock: ccc.Script.from(receiver), + }, + outputData, + }); + + const inLen = tx.addInput(inCell); + const outLen = tx.addOutput(outCell); + + return { + tx: await addCellDeps(client, tx, scriptInfo), + inIndex: inLen - 1, + outIndex: outLen - 1, + }; + }, + + /** + * Destroy a Type ID cell. + * + * @param props The arguments for destroying the cell. + * @param props.client The client to communicate with CKB. + * @param props.id The Type ID to destroy. + * @param props.tx The transaction skeleton (optional). + */ + async destroy( + this: void, + props: { + client: ccc.Client; + id: ccc.HexLike; + tx?: ccc.TransactionLike | null; + }, + ): Promise<{ + tx: ccc.Transaction; + index: number; + }> { + const { client, id, tx: txLike } = props; + const tx = ccc.Transaction.from(txLike ?? {}); + + const scriptInfo = await getScriptInfo(client); + const type = getTypeScript(scriptInfo, id); + const cell = await client.findSingletonCellByType(type); + if (!cell) { + throw new Error(`Type ID ${ccc.stringify(type)} not found`); + } + + const len = tx.addInput(cell); + + return { + tx: await addCellDeps(client, tx, scriptInfo), + index: len - 1, + }; + }, + }; +} diff --git a/packages/type-id/src/barrel.ts b/packages/type-id/src/barrel.ts new file mode 100644 index 000000000..f948bf58e --- /dev/null +++ b/packages/type-id/src/barrel.ts @@ -0,0 +1,12 @@ +import { ccc } from "@ckb-ccc/core"; +import { buildTypeIdOperations } from "./advancedBarrel"; + +export const { + create: createTypeId, + transfer: transferTypeId, + destroy: destroyTypeId, +} = buildTypeIdOperations({ + async getScriptInfo(client: ccc.Client) { + return client.getKnownScript(ccc.KnownScript.TypeId); + }, +}); diff --git a/packages/type-id/src/index.ts b/packages/type-id/src/index.ts new file mode 100644 index 000000000..6a528c1af --- /dev/null +++ b/packages/type-id/src/index.ts @@ -0,0 +1,2 @@ +export * from "./barrel.js"; +export * as typeId from "./barrel.js"; diff --git a/packages/type-id/tsconfig.base.json b/packages/type-id/tsconfig.base.json new file mode 100644 index 000000000..7e5ac952b --- /dev/null +++ b/packages/type-id/tsconfig.base.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2020", + "incremental": true, + "allowJs": true, + "importHelpers": false, + "declaration": true, + "declarationMap": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "esModuleInterop": true, + "strict": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/packages/type-id/tsconfig.commonjs.json b/packages/type-id/tsconfig.commonjs.json new file mode 100644 index 000000000..76a25e98b --- /dev/null +++ b/packages/type-id/tsconfig.commonjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist.commonjs" + } +} diff --git a/packages/type-id/tsconfig.json b/packages/type-id/tsconfig.json new file mode 100644 index 000000000..df22faeca --- /dev/null +++ b/packages/type-id/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + } +} diff --git a/packages/type-id/tsdown.config.mts b/packages/type-id/tsdown.config.mts new file mode 100644 index 000000000..9f79d88fe --- /dev/null +++ b/packages/type-id/tsdown.config.mts @@ -0,0 +1,36 @@ +import { defineConfig } from "tsdown"; + +const common = { + minify: true, + dts: true, + platform: "neutral" as const, + exports: true, +}; + +export default defineConfig( + ( + [ + { + entry: { + index: "src/index.ts", + barrel: "src/barrel.ts", + advanced: "src/advanced.ts", + advancedBarrel: "src/advancedBarrel.ts", + }, + format: "esm", + copy: "./misc/basedirs/dist/*", + }, + { + entry: { + index: "src/index.ts", + barrel: "src/barrel.ts", + advanced: "src/advanced.ts", + advancedBarrel: "src/advancedBarrel.ts", + }, + format: "cjs", + outDir: "dist.commonjs", + copy: "./misc/basedirs/dist.commonjs/*", + }, + ] as const + ).map((c) => ({ ...c, ...common })), +); diff --git a/packages/type-id/typedoc.json b/packages/type-id/typedoc.json new file mode 100644 index 000000000..2eb611e3e --- /dev/null +++ b/packages/type-id/typedoc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["./src/index.ts", "./src/advanced.ts"], + "extends": ["../../typedoc.base.json"], + "name": "@ckb-ccc type-id" +} diff --git a/packages/type-id/vitest.config.mts b/packages/type-id/vitest.config.mts new file mode 100644 index 000000000..dc6a58785 --- /dev/null +++ b/packages/type-id/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + coverage: { + include: ["src/**/*.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d52d483a7..7a7acb140 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,7 +378,7 @@ importers: version: 9.34.0(jiti@2.5.1) eslint-config-next: specifier: 16.0.10 - version: 16.0.10(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) + version: 16.0.10(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@9.34.0(jiti@2.5.1)) @@ -965,6 +965,9 @@ importers: '@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 @@ -1122,6 +1125,46 @@ importers: specifier: ^8.41.0 version: 8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) + packages/type-id: + dependencies: + '@ckb-ccc/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@eslint/js': + specifier: ^9.34.0 + version: 9.34.0 + '@types/node': + specifier: ^24.3.0 + version: 24.3.0 + eslint: + specifier: ^9.34.0 + version: 9.34.0(jiti@2.5.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.34.0(jiti@2.5.1)) + eslint-plugin-prettier: + specifier: ^5.5.4 + version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1))(prettier@3.6.2) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + prettier-plugin-organize-imports: + specifier: ^4.2.0 + version: 4.2.0(prettier@3.6.2)(typescript@5.9.2) + tsdown: + specifier: 0.19.0-beta.3 + version: 0.19.0-beta.3(synckit@0.11.11)(typescript@5.9.2) + typescript: + specifier: ^5.9.2 + version: 5.9.2 + typescript-eslint: + specifier: ^8.41.0 + version: 8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) + packages/udt: dependencies: '@ckb-ccc/core': @@ -1408,6 +1451,10 @@ packages: resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -1483,6 +1530,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -1504,6 +1555,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1': resolution: {integrity: sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==} engines: {node: '>=6.9.0'} @@ -2043,6 +2099,10 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -2629,12 +2689,18 @@ packages: '@emnapi/core@1.4.5': resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} '@emnapi/wasi-threads@1.0.4': resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.25.9': resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} engines: {node: '>=18'} @@ -3414,6 +3480,9 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@nervina-labs/dob-render@0.2.5': resolution: {integrity: sha512-PZ5hcoTYfbdyI51xlHdK1j9VYTd6L1oYxEv9CzpYuu0AtkOqSNmrbn2FkI4noUJl+tqxqWSsJRju7JJiOApTpg==} peerDependencies: @@ -3612,6 +3681,9 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@oxc-project/types@0.106.0': + resolution: {integrity: sha512-QdsH3rZq480VnOHSHgPYOhjL8O8LBdcnSjM408BpPCCUc0JYYZPG9Gafl9i3OcGk/7137o+gweb4cCv3WAUykg==} + '@paralleldrive/cuid2@2.2.2': resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} @@ -3638,6 +3710,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@react-aria/focus@3.21.1': resolution: {integrity: sha512-hmH1IhHlcQ2lSIxmki1biWzMbGgnhdxJUM0MFfzc71Rv6YAzhlx4kX3GYn4VNcjCeb6cdPv4RZ5vunV4kgMZYQ==} peerDependencies: @@ -3675,6 +3750,86 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@rolldown/binding-android-arm64@1.0.0-beta.58': + resolution: {integrity: sha512-mWj5eE4Qc8TbPdGGaaLvBb9XfDPvE1EmZkJQgiGKwchkWH4oAJcRAKMTw7ZHnb1L+t7Ah41sBkAecaIsuUgsug==} + 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==} + 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==} + 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-linux-arm-gnueabihf@1.0.0-beta.58': + resolution: {integrity: sha512-tYeXprDOrEgVHUbPXH6MPso4cM/c6RTkmJNICMQlYdki4hGMh92aj3yU6CKs+4X5gfG0yj5kVUw/L4M685SYag==} + 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==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@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] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.58': + resolution: {integrity: sha512-urzJX0HrXxIh0FfxwWRjfPCMeInU9qsImLQxHBgLp5ivji1EEUnOfux8KxPPnRQthJyneBrN2LeqUix9DYrNaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.58': + resolution: {integrity: sha512-7ijfVK3GISnXIwq/1FZo+KyAUJjL3kWPJ7rViAL6MWeEBhEgRzJ0yEd9I8N9aut8Y8ab+EKFJyRNMWZuUBwQ0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@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-wasm32-wasi@1.0.0-beta.58': + resolution: {integrity: sha512-6SZk7zMgv+y3wFFQ9qE5P9NnRHcRsptL1ypmudD26PDY+PvFCvfHRkJNfclWnvacVGxjowr7JOL3a9fd1wWhUw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58': + resolution: {integrity: sha512-sFqfYPnBZ6xBhMkadB7UD0yjEDRvs7ipR3nCggblN+N4ODCXY6qhg/bKL39+W+dgQybL7ErD4EGERVbW9DAWvg==} + 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/pluginutils@1.0.0-beta.58': + resolution: {integrity: sha512-qWhDs6yFGR5xDfdrwiSa3CWGIHxD597uGE/A9xGqytBjANvh4rLCTTkq7szhMV4+Ygh+PMS90KVJ8xWG/TkX4w==} + '@rollup/rollup-android-arm-eabi@4.49.0': resolution: {integrity: sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==} cpu: [arm] @@ -4065,6 +4220,9 @@ packages: '@tybys/wasm-util@0.10.0': resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4799,6 +4957,10 @@ packages: resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} engines: {node: '>=14'} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -4871,6 +5033,10 @@ 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-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -4999,6 +5165,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -5721,6 +5890,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -5840,6 +6012,15 @@ packages: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} + dts-resolver@2.1.3: + resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} + engines: {node: '>=20.19.0'} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -5882,6 +6063,10 @@ packages: emoticon@4.1.0: resolution: {integrity: sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -6517,6 +6702,9 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-slugger@1.5.0: resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} @@ -6707,6 +6895,9 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hookable@6.0.1: + resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -6843,6 +7034,10 @@ packages: engines: {node: '>=8'} hasBin: true + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} + engines: {node: '>=20.19.0'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -8161,6 +8356,9 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -8967,6 +9165,9 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -9267,6 +9468,30 @@ packages: engines: {node: 20 || >=22} hasBin: true + rolldown-plugin-dts@0.20.0: + resolution: {integrity: sha512-cLAY1kN2ilTYMfZcFlGWbXnu6Nb+8uwUBsi+Mjbh4uIx7IN8uMOmJ7RxrrRgPsO4H7eSz3E+JwGoL1gyugiyUA==} + engines: {node: '>=20.19.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 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + 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 + rollup@4.49.0: resolution: {integrity: sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -9837,6 +10062,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} @@ -9966,6 +10195,31 @@ 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'} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@vitejs/devtools': '*' + publint: ^0.3.0 + typescript: ^5.0.0 + unplugin-lightningcss: ^0.4.0 + unplugin-unused: ^0.5.0 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + typescript: + optional: true + unplugin-lightningcss: + optional: true + unplugin-unused: + optional: true + tslib@2.3.1: resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} @@ -10117,6 +10371,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + unconfig-core@7.4.2: + resolution: {integrity: sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg==} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -10189,6 +10446,16 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unrun@0.2.22: + resolution: {integrity: sha512-vlQce4gTLNyCZxGylEQXGG+fSrrEFWiM/L8aghtp+t6j8xXh+lmsBtQJknG7ZSvv7P+/MRgbQtHWHBWk981uTg==} + 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'} @@ -10830,6 +11097,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.30 jsesc: 3.1.0 + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.28.2 @@ -10933,6 +11208,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helper-wrap-function@7.28.3': @@ -10959,6 +11236,10 @@ snapshots: dependencies: '@babel/types': 7.28.2 + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1(@babel/core@7.28.3)': dependencies: '@babel/core': 7.28.3 @@ -11626,6 +11907,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} '@bcoe/v8-coverage@1.0.2': {} @@ -12962,6 +13248,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 @@ -12972,6 +13264,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.25.9': optional: true @@ -13833,6 +14130,13 @@ snapshots: '@tybys/wasm-util': 0.10.0 optional: true + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@nervina-labs/dob-render@0.2.5(satori@0.10.14)': dependencies: satori: 0.10.14 @@ -14035,6 +14339,8 @@ snapshots: dependencies: consola: 3.4.2 + '@oxc-project/types@0.106.0': {} + '@paralleldrive/cuid2@2.2.2': dependencies: '@noble/hashes': 1.8.0 @@ -14058,6 +14364,10 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + '@react-aria/focus@3.21.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@react-aria/interactions': 3.25.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -14107,6 +14417,49 @@ snapshots: dependencies: react: 19.2.3 + '@rolldown/binding-android-arm64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.58': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.58': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.58': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.58': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.58': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.58': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.58': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.58': {} + '@rollup/rollup-android-arm-eabi@4.49.0': optional: true @@ -14476,6 +14829,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.3 @@ -15343,6 +15701,8 @@ snapshots: ansis@4.1.0: {} + ansis@4.2.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -15439,6 +15799,11 @@ snapshots: assertion-error@2.0.1: {} + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.28.5 + pathe: 2.0.3 + ast-types-flow@0.0.8: {} ast-v8-to-istanbul@0.3.4: @@ -15594,6 +15959,8 @@ snapshots: binary-extensions@2.3.0: {} + birpc@4.0.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -16374,6 +16741,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + delayed-stream@1.0.0: {} depd@1.1.2: {} @@ -16486,6 +16855,8 @@ snapshots: dotenv@8.6.0: {} + dts-resolver@2.1.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -16524,6 +16895,8 @@ snapshots: emoticon@4.1.0: {} + empathic@2.0.0: {} + encodeurl@1.0.2: {} encodeurl@2.0.0: {} @@ -16713,28 +17086,8 @@ snapshots: '@next/eslint-plugin-next': 16.0.10 eslint: 9.34.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.5.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.34.0(jiti@2.5.1)) - eslint-plugin-react: 7.37.5(eslint@9.34.0(jiti@2.5.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.34.0(jiti@2.5.1)) - globals: 16.4.0 - typescript-eslint: 8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) - optionalDependencies: - typescript: 5.9.2 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - - eslint-config-next@16.0.10(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2): - dependencies: - '@next/eslint-plugin-next': 16.0.10 - eslint: 9.34.0(jiti@2.5.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.34.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.34.0(jiti@2.5.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.34.0(jiti@2.5.1)) @@ -16769,7 +17122,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -16780,48 +17133,22 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.5.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 - eslint: 9.34.0(jiti@2.5.1) - get-tsconfig: 4.10.1 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.14 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.5.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.2) eslint: 9.34.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.5.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.34.0(jiti@2.5.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -16832,7 +17159,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.34.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -16850,35 +17177,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.34.0(jiti@2.5.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.34.0(jiti@2.5.1)): dependencies: aria-query: 5.3.2 @@ -17564,6 +17862,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + github-slugger@1.5.0: {} glob-parent@5.1.2: @@ -17857,6 +18159,8 @@ snapshots: dependencies: react-is: 16.13.1 + hookable@6.0.1: {} + hosted-git-info@2.8.9: {} hpack.js@2.1.6: @@ -18002,6 +18306,8 @@ snapshots: pkg-dir: 4.2.0 resolve-cwd: 3.0.0 + import-without-cache@0.2.5: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -19697,6 +20003,8 @@ snapshots: obuf@1.1.2: {} + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -20476,6 +20784,8 @@ snapshots: quansync@0.2.11: {} + quansync@1.0.0: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -20866,6 +21176,41 @@ snapshots: glob: 11.0.3 package-json-from-dist: 1.0.1 + rolldown-plugin-dts@0.20.0(rolldown@1.0.0-beta.58)(typescript@5.9.2): + dependencies: + '@babel/generator': 7.28.5 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + ast-kit: 2.2.0 + birpc: 4.0.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.13.0 + obug: 2.1.1 + rolldown: 1.0.0-beta.58 + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.0.0-beta.58: + 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 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.58 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.58 + rollup@4.49.0: dependencies: '@types/estree': 1.0.8 @@ -21606,6 +21951,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.14: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -21726,6 +22073,33 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tsdown@0.19.0-beta.3(synckit@0.11.11)(typescript@5.9.2): + dependencies: + ansis: 4.2.0 + cac: 6.7.14 + defu: 6.1.4 + empathic: 2.0.0 + hookable: 6.0.1 + import-without-cache: 0.2.5 + obug: 2.1.1 + picomatch: 4.0.3 + rolldown: 1.0.0-beta.58 + rolldown-plugin-dts: 0.20.0(rolldown@1.0.0-beta.58)(typescript@5.9.2) + semver: 7.7.3 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + unconfig-core: 7.4.2 + unrun: 0.2.22(synckit@0.11.11) + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + tslib@2.3.1: {} tslib@2.7.0: {} @@ -21882,6 +22256,11 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + unconfig-core@7.4.2: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + uncrypto@0.1.3: {} undici-types@6.19.8: {} @@ -21977,6 +22356,12 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + unrun@0.2.22(synckit@0.11.11): + dependencies: + rolldown: 1.0.0-beta.58 + optionalDependencies: + synckit: 0.11.11 + untildify@4.0.0: {} update-browserslist-db@1.1.3(browserslist@4.25.3): diff --git a/typedoc.config.mjs b/typedoc.config.mjs index 515df9321..428dfce67 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -4,6 +4,7 @@ const config = { name: "CCC Docs", entryPoints: [ "packages/core", + "packages/type-id", "packages/ssri", "packages/udt", "packages/spore", diff --git a/vitest.config.mts b/vitest.config.mts index 7af3d4a84..5d03d85e3 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -2,9 +2,9 @@ import { defineConfig, coverageConfigDefaults } from "vitest/config"; export default defineConfig({ test: { - projects: ["packages/core"], + projects: ["packages/core", "packages/type-id"], coverage: { - include: ["packages/core"], + include: ["packages/core", "packages/type-id"], exclude: [ "**/dist/**", "**/dist.commonjs/**", From 03667865d1bc6d091d9144d39f6b434abe4ce18b Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Thu, 8 Jan 2026 19:08:27 +0800 Subject: [PATCH 06/45] feat(did-ckb): add did-ckb package for basic did operations --- .changeset/fruity-drinks-kick.md | 7 + packages/did-ckb/.npmignore | 21 +++ packages/did-ckb/.prettierignore | 15 ++ packages/did-ckb/README.md | 41 +++++ packages/did-ckb/eslint.config.mjs | 62 ++++++++ .../misc/basedirs/dist.commonjs/package.json | 3 + .../did-ckb/misc/basedirs/dist/package.json | 3 + packages/did-ckb/package.json | 56 +++++++ packages/did-ckb/prettier.config.cjs | 11 ++ packages/did-ckb/src/barrel.ts | 2 + packages/did-ckb/src/codec.ts | 146 ++++++++++++++++++ packages/did-ckb/src/didCkb.ts | 89 +++++++++++ packages/did-ckb/src/index.ts | 2 + packages/did-ckb/tsconfig.base.json | 22 +++ packages/did-ckb/tsconfig.commonjs.json | 8 + packages/did-ckb/tsconfig.json | 8 + packages/did-ckb/tsdown.config.mts | 33 ++++ packages/did-ckb/typedoc.json | 6 + packages/did-ckb/vitest.config.mts | 10 ++ packages/examples/package.json | 4 +- packages/examples/src/createDid.ts | 20 +++ packages/examples/src/createDidWithLocalId.ts | 63 ++++++++ packages/examples/src/destroyDid.ts | 31 ++++ packages/examples/src/transferDid.ts | 47 ++++++ packages/shell/package.json | 1 + packages/shell/src/barrel.ts | 1 + pnpm-lock.yaml | 84 +++++++++- typedoc.config.mjs | 1 + vitest.config.mts | 6 +- 29 files changed, 795 insertions(+), 8 deletions(-) create mode 100644 .changeset/fruity-drinks-kick.md create mode 100644 packages/did-ckb/.npmignore create mode 100644 packages/did-ckb/.prettierignore create mode 100644 packages/did-ckb/README.md create mode 100644 packages/did-ckb/eslint.config.mjs create mode 100644 packages/did-ckb/misc/basedirs/dist.commonjs/package.json create mode 100644 packages/did-ckb/misc/basedirs/dist/package.json create mode 100644 packages/did-ckb/package.json create mode 100644 packages/did-ckb/prettier.config.cjs create mode 100644 packages/did-ckb/src/barrel.ts create mode 100644 packages/did-ckb/src/codec.ts create mode 100644 packages/did-ckb/src/didCkb.ts create mode 100644 packages/did-ckb/src/index.ts create mode 100644 packages/did-ckb/tsconfig.base.json create mode 100644 packages/did-ckb/tsconfig.commonjs.json create mode 100644 packages/did-ckb/tsconfig.json create mode 100644 packages/did-ckb/tsdown.config.mts create mode 100644 packages/did-ckb/typedoc.json create mode 100644 packages/did-ckb/vitest.config.mts create mode 100644 packages/examples/src/createDid.ts create mode 100644 packages/examples/src/createDidWithLocalId.ts create mode 100644 packages/examples/src/destroyDid.ts create mode 100644 packages/examples/src/transferDid.ts diff --git a/.changeset/fruity-drinks-kick.md b/.changeset/fruity-drinks-kick.md new file mode 100644 index 000000000..abb409ec7 --- /dev/null +++ b/.changeset/fruity-drinks-kick.md @@ -0,0 +1,7 @@ +--- +"@ckb-ccc/shell": minor +"@ckb-ccc/did-ckb": patch +--- + +feat(did-ckb): add did-ckb package for basic did operations + \ No newline at end of file diff --git a/packages/did-ckb/.npmignore b/packages/did-ckb/.npmignore new file mode 100644 index 000000000..7a88408aa --- /dev/null +++ b/packages/did-ckb/.npmignore @@ -0,0 +1,21 @@ +node_modules/ +misc/ + +*test.js +*test.ts +*test.d.ts +*test.d.ts.map +*spec.js +*spec.ts +*spec.d.ts +*spec.d.ts.map + +tsconfig.json +tsconfig.*.json +eslint.config.mjs +.prettierrc +.prettierignore + +tsconfig.tsbuildinfo +tsconfig.*.tsbuildinfo +.github/ diff --git a/packages/did-ckb/.prettierignore b/packages/did-ckb/.prettierignore new file mode 100644 index 000000000..aef5d239c --- /dev/null +++ b/packages/did-ckb/.prettierignore @@ -0,0 +1,15 @@ +node_modules/ + +dist/ +dist.commonjs/ + +.npmignore +.prettierrc +tsconfig.json +eslint.config.mjs +prettier.config.* + +tsconfig.tsbuildinfo +.github/ + +CHANGELOG.md diff --git a/packages/did-ckb/README.md b/packages/did-ckb/README.md new file mode 100644 index 000000000..1423faaf1 --- /dev/null +++ b/packages/did-ckb/README.md @@ -0,0 +1,41 @@ +

+ + Logo + +

+ +

+ CCC's Support for DID CKB +

+ +

+ NPM Version + GitHub commit activity + GitHub last commit + GitHub branch check runs + Playground + App + Docs +

+ +

+ CCC - CKBers' Codebase is a one-stop solution for your CKB JS/TS ecosystem development. +
+ Empower yourself with CCC to discover the unlimited potential of CKB. +
+ Interoperate with wallets from different chain ecosystems. +
+ Fully enabling CKB's Turing completeness and cryptographic freedom power. +

+ +

+ Read more about CCC on our website or GitHub Repo. +

diff --git a/packages/did-ckb/eslint.config.mjs b/packages/did-ckb/eslint.config.mjs new file mode 100644 index 000000000..b6132c277 --- /dev/null +++ b/packages/did-ckb/eslint.config.mjs @@ -0,0 +1,62 @@ +// @ts-check + +import eslint from "@eslint/js"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import tseslint from "typescript-eslint"; + +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +export default [ + ...tseslint.config({ + files: ["**/*.ts"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + args: "all", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + "@typescript-eslint/unbound-method": ["error", { ignoreStatic: true }], + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/only-throw-error": [ + "error", + { + allowThrowingAny: true, + allowThrowingUnknown: true, + allowRethrowing: true, + }, + ], + "@typescript-eslint/prefer-promise-reject-errors": [ + "error", + { + allowThrowingAny: true, + allowThrowingUnknown: true, + }, + ], + "no-empty": "off", + "prefer-const": [ + "error", + { ignoreReadBeforeAssign: true, destructuring: "all" }, + ], + }, + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: dirname(fileURLToPath(import.meta.url)), + }, + }, + }), + eslintPluginPrettierRecommended, +]; diff --git a/packages/did-ckb/misc/basedirs/dist.commonjs/package.json b/packages/did-ckb/misc/basedirs/dist.commonjs/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/packages/did-ckb/misc/basedirs/dist.commonjs/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/packages/did-ckb/misc/basedirs/dist/package.json b/packages/did-ckb/misc/basedirs/dist/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/packages/did-ckb/misc/basedirs/dist/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/did-ckb/package.json b/packages/did-ckb/package.json new file mode 100644 index 000000000..e61651208 --- /dev/null +++ b/packages/did-ckb/package.json @@ -0,0 +1,56 @@ +{ + "name": "@ckb-ccc/did-ckb", + "version": "0.0.0", + "description": "CCC - CKBer's Codebase. CCC's support for DID on CKB", + "author": "Hanssen0 ", + "license": "MIT", + "private": false, + "homepage": "https://github.com/ckb-devrel/ccc", + "repository": { + "type": "git", + "url": "git://github.com/ckb-devrel/ccc.git" + }, + "main": "./dist.commonjs/index.js", + "module": "./dist/index.mjs", + "exports": { + ".": { + "require": "./dist.commonjs/index.js", + "import": "./dist/index.mjs" + }, + "./barrel": { + "require": "./dist.commonjs/barrel.js", + "import": "./dist/barrel.mjs" + }, + "./package.json": "./package.json" + }, + "scripts": { + "test": "vitest", + "test:ci": "vitest run", + "build": "tsdown", + "lint": "eslint ./src", + "format": "prettier --write . && eslint --fix ./src" + }, + "devDependencies": { + "@eslint/js": "^9.34.0", + "@types/node": "^24.3.0", + "eslint": "^9.34.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "prettier": "^3.6.2", + "prettier-plugin-organize-imports": "^4.2.0", + "tsdown": "0.19.0-beta.3", + "typescript": "^5.9.2", + "typescript-eslint": "^8.41.0", + "vitest": "^3.2.4" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@ckb-ccc/core": "workspace:*", + "@ckb-ccc/type-id": "workspace:*", + "@ipld/dag-cbor": "^9.2.5" + }, + "packageManager": "pnpm@10.8.1", + "types": "./dist.commonjs/index.d.ts" +} diff --git a/packages/did-ckb/prettier.config.cjs b/packages/did-ckb/prettier.config.cjs new file mode 100644 index 000000000..5e1810363 --- /dev/null +++ b/packages/did-ckb/prettier.config.cjs @@ -0,0 +1,11 @@ +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: [require.resolve("prettier-plugin-organize-imports")], +}; + +module.exports = config; diff --git a/packages/did-ckb/src/barrel.ts b/packages/did-ckb/src/barrel.ts new file mode 100644 index 000000000..6112c76ce --- /dev/null +++ b/packages/did-ckb/src/barrel.ts @@ -0,0 +1,2 @@ +export * from "./codec.js"; +export * from "./didCkb.js"; diff --git a/packages/did-ckb/src/codec.ts b/packages/did-ckb/src/codec.ts new file mode 100644 index 000000000..c89ba8e57 --- /dev/null +++ b/packages/did-ckb/src/codec.ts @@ -0,0 +1,146 @@ +import { ccc } from "@ckb-ccc/core"; +import { decode as cborDecode, encode as cborEncode } from "@ipld/dag-cbor"; + +export type DidCkbDataV1Like = { + document: unknown; + localId?: string | null; +}; +@ccc.codec( + ccc.mol + .table({ + document: ccc.mol.Bytes, + localId: ccc.mol.StringOpt, + }) + .map({ + inMap: (data: DidCkbDataV1Like) => ({ + ...data, + document: ccc.hexFrom(cborEncode(data.document)), + }), + outMap: (data) => ({ + ...data, + document: cborDecode(ccc.bytesFrom(data.document)), + }), + }), +) +export class DidCkbDataV1 extends ccc.Entity.Base< + DidCkbDataV1Like, + DidCkbDataV1 +>() { + constructor( + public document: unknown, + public localId?: string, + ) { + super(); + } + + static from(data: DidCkbDataV1Like): DidCkbDataV1 { + if (data instanceof DidCkbDataV1) { + return data; + } + + return new DidCkbDataV1(data.document, data.localId ?? undefined); + } +} + +export type DidCkbDataLike = { + type?: "v1" | null; + value: DidCkbDataV1Like; +}; +@ccc.codec( + ccc.mol.union({ + v1: DidCkbDataV1, + }), +) +export class DidCkbData extends ccc.Entity.Base() { + constructor( + public type: "v1", + public value: DidCkbDataV1, + ) { + super(); + } + + static from(data: DidCkbDataLike): DidCkbData { + if (data instanceof DidCkbData) { + return data; + } + return new DidCkbData(data.type ?? "v1", DidCkbDataV1.from(data.value)); + } + + static fromV1( + data: DidCkbDataV1Like, + ): DidCkbData & { type: "v1"; value: DidCkbDataV1 } { + return new DidCkbData("v1", DidCkbDataV1.from(data)); + } +} + +export type PlcAuthorizationLike = { + history: object[]; + sig: ccc.HexLike; + rotationKeyIndices: ccc.NumLike[]; +}; +@ccc.codec( + ccc.mol + .table({ + history: ccc.mol.BytesVec, + sig: ccc.mol.Bytes, + rotationKeyIndices: ccc.mol.Uint8Vec, + }) + + .map({ + inMap: (data: PlcAuthorizationLike) => ({ + ...data, + history: data.history.map((h) => ccc.hexFrom(cborEncode(h))), + }), + outMap: (data) => ({ + ...data, + history: data.history.map((h) => cborDecode(ccc.bytesFrom(h))), + }), + }), +) +export class PlcAuthorization extends ccc.Entity.Base< + PlcAuthorizationLike, + PlcAuthorization +>() { + constructor( + public history: object[], + public sig: ccc.Hex, + public rotationKeyIndices: ccc.Num[], + ) { + super(); + } + + static from(data: PlcAuthorizationLike): PlcAuthorization { + if (data instanceof PlcAuthorization) { + return data; + } + return new PlcAuthorization( + data.history, + ccc.hexFrom(data.sig), + data.rotationKeyIndices.map(ccc.numFrom), + ); + } +} + +export type DidCkbWitnessLike = { + localIdAuthorization: PlcAuthorizationLike; +}; +@ccc.codec( + ccc.mol.table({ + localIdAuthorization: PlcAuthorization, + }), +) +export class DidCkbWitness extends ccc.Entity.Base< + DidCkbWitnessLike, + DidCkbWitness +>() { + constructor(public localIdAuthorization: PlcAuthorization) { + super(); + } + + static from(data: DidCkbWitnessLike): DidCkbWitness { + if (data instanceof DidCkbWitness) { + return data; + } + return new DidCkbWitness(PlcAuthorization.from(data.localIdAuthorization)); + } +} diff --git a/packages/did-ckb/src/didCkb.ts b/packages/did-ckb/src/didCkb.ts new file mode 100644 index 000000000..0b3ede189 --- /dev/null +++ b/packages/did-ckb/src/didCkb.ts @@ -0,0 +1,89 @@ +import { ccc } from "@ckb-ccc/core"; +import { typeIdA } from "@ckb-ccc/type-id/advanced"; +import { DidCkbData, DidCkbDataLike } from "./codec"; + +const OPERATIONS = typeIdA.buildTypeIdOperations({ + async getScriptInfo(client: ccc.Client): Promise { + return client.getKnownScript(ccc.KnownScript.DidCkb); + }, + codec: DidCkbData, + async calculateTypeId( + _: ccc.Client, + tx: ccc.Transaction, + ): Promise { + return ccc + .bytesFrom(ccc.hashTypeId(tx.inputs[0], tx.outputs.length)) + .slice(0, 20); + }, +}); + +/** + * Create a DID CKB cell. + * + * @param props The arguments for creating the cell. + * @param props.signer The signer to sign the transaction. + * @param props.receiver The receiver script (optional). + * @param props.data The output data. + * @param props.tx The transaction skeleton (optional). + */ +export function createDidCkb(props: { + signer: ccc.Signer; + data: DidCkbDataLike; + receiver?: ccc.ScriptLike | null; + tx?: ccc.TransactionLike | null; +}): Promise<{ + tx: ccc.Transaction; + id: ccc.Hex; + index: number; +}> { + return OPERATIONS.create(props); +} + +/** + * Transfer a DID CKB cell. + * + * @param props The arguments for transferring the cell. + * @param props.client The client to communicate with CKB. + * @param props.id The Type ID to transfer. + * @param props.receiver The new receiver script. + * @param props.tx The transaction skeleton (optional). + * @param props.data The new output data or a transformer to update the data (optional). + */ +export async function transferDidCkb(props: { + client: ccc.Client; + id: ccc.HexLike; + receiver: ccc.ScriptLike; + tx?: ccc.TransactionLike | null; + data?: + | DidCkbDataLike + | (( + cell: ccc.Cell, + data?: DidCkbData, + ) => DidCkbDataLike | Promise) + | null; +}): Promise<{ + tx: ccc.Transaction; + inIndex: number; + outIndex: number; +}> { + return OPERATIONS.transfer(props); +} + +/** + * Destroy a DID CKB cell. + * + * @param props The arguments for destroying the cell. + * @param props.client The client to communicate with CKB. + * @param props.id The Type ID to destroy. + * @param props.tx The transaction skeleton (optional). + */ +export async function destroyDidCkb(props: { + client: ccc.Client; + id: ccc.HexLike; + tx?: ccc.TransactionLike | null; +}): Promise<{ + tx: ccc.Transaction; + index: number; +}> { + return OPERATIONS.destroy(props); +} diff --git a/packages/did-ckb/src/index.ts b/packages/did-ckb/src/index.ts new file mode 100644 index 000000000..580265645 --- /dev/null +++ b/packages/did-ckb/src/index.ts @@ -0,0 +1,2 @@ +export * from "./barrel.js"; +export * as didCkb from "./barrel.js"; diff --git a/packages/did-ckb/tsconfig.base.json b/packages/did-ckb/tsconfig.base.json new file mode 100644 index 000000000..7e5ac952b --- /dev/null +++ b/packages/did-ckb/tsconfig.base.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2020", + "incremental": true, + "allowJs": true, + "importHelpers": false, + "declaration": true, + "declarationMap": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "esModuleInterop": true, + "strict": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/packages/did-ckb/tsconfig.commonjs.json b/packages/did-ckb/tsconfig.commonjs.json new file mode 100644 index 000000000..76a25e98b --- /dev/null +++ b/packages/did-ckb/tsconfig.commonjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist.commonjs" + } +} diff --git a/packages/did-ckb/tsconfig.json b/packages/did-ckb/tsconfig.json new file mode 100644 index 000000000..df22faeca --- /dev/null +++ b/packages/did-ckb/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + } +} diff --git a/packages/did-ckb/tsdown.config.mts b/packages/did-ckb/tsdown.config.mts new file mode 100644 index 000000000..d952699a0 --- /dev/null +++ b/packages/did-ckb/tsdown.config.mts @@ -0,0 +1,33 @@ +import { defineConfig } from "tsdown"; + +const common = { + minify: true, + dts: true, + platform: "neutral" as const, + exports: true, +}; + +export default defineConfig( + ( + [ + { + entry: { + index: "src/index.ts", + barrel: "src/barrel.ts", + }, + format: "esm", + copy: "./misc/basedirs/dist/*", + }, + { + entry: { + index: "src/index.ts", + barrel: "src/barrel.ts", + }, + noExternal: ["@ipld/dag-cbor"] as string[], + format: "cjs", + outDir: "dist.commonjs", + copy: "./misc/basedirs/dist.commonjs/*", + }, + ] as const + ).map((c) => ({ ...c, ...common })), +); diff --git a/packages/did-ckb/typedoc.json b/packages/did-ckb/typedoc.json new file mode 100644 index 000000000..5cdb8befe --- /dev/null +++ b/packages/did-ckb/typedoc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["./src/index.ts"], + "extends": ["../../typedoc.base.json"], + "name": "@ckb-ccc did-ckb" +} diff --git a/packages/did-ckb/vitest.config.mts b/packages/did-ckb/vitest.config.mts new file mode 100644 index 000000000..dc6a58785 --- /dev/null +++ b/packages/did-ckb/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + coverage: { + include: ["src/**/*.ts"], + }, + }, +}); diff --git a/packages/examples/package.json b/packages/examples/package.json index 756bf2d1d..bac19e45d 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -31,7 +31,9 @@ }, "dependencies": { "@ckb-ccc/ccc": "workspace:*", - "@ckb-ccc/playground": "file:src/playground" + "@ckb-ccc/playground": "file:src/playground", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" }, "packageManager": "pnpm@10.8.1" } diff --git a/packages/examples/src/createDid.ts b/packages/examples/src/createDid.ts new file mode 100644 index 000000000..2d01a6866 --- /dev/null +++ b/packages/examples/src/createDid.ts @@ -0,0 +1,20 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { render, signer } from "@ckb-ccc/playground"; + +// Construct create did tx +const { tx } = await ccc.didCkb.createDidCkb({ + signer, + data: { value: { document: {} } }, +}); + +// Complete missing parts: Fill inputs +await tx.completeInputsByCapacity(signer); +await render(tx); + +// Complete missing parts: Pay fee +await tx.completeFeeBy(signer); +await render(tx); + +// Sign and send the transaction +const txHash = await signer.sendTransaction(tx); +console.log(`Transaction ${txHash} sent`); diff --git a/packages/examples/src/createDidWithLocalId.ts b/packages/examples/src/createDidWithLocalId.ts new file mode 100644 index 000000000..957e4b087 --- /dev/null +++ b/packages/examples/src/createDidWithLocalId.ts @@ -0,0 +1,63 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { render, signer } from "@ckb-ccc/playground"; +import { secp256k1 } from "@noble/curves/secp256k1"; +import { sha256 } from "@noble/hashes/sha2"; + +// From https://github.com/bluesky-social/atproto/blob/main/packages/crypto +function plcSign(key: ccc.BytesLike, msg: ccc.BytesLike): ccc.Bytes { + const msgHash = sha256(ccc.bytesFrom(msg)); + const sig = secp256k1.sign(msgHash, ccc.bytesFrom(key), { lowS: true }); + return sig.toBytes("compact"); +} + +// Construct create did tx +const { tx } = await ccc.didCkb.createDidCkb({ + signer, + data: { + value: { document: {}, localId: "did:plc:yunkr6vorfgzmvzeoofbkhq5" }, + }, +}); + +// Complete missing parts: Fill inputs +await tx.completeInputsByCapacity(signer); +await render(tx); + +// Complete missing parts: Pay fee +await tx.completeFeeBy(signer); +await render(tx); + +// Authorize the transaction with the rotation key +const rotationKey = + "0x806d1925698097c64bc70f629e25b91b48a15eee4e492bb239402cee85356a10"; +const witness = ccc.didCkb.DidCkbWitness.from({ + localIdAuthorization: { + history: [ + { + type: "plc_operation", + verificationMethods: { + atproto: "did:key:zQ3shn3qejTEyEiokszFc4MWEqbdAwyj2XR1oS2AuXKvEBTuN", + }, + rotationKeys: [ + "did:key:zQ3shqtXEdagupBhLzL2vFUACfdVjDEvciip79uY8iHBuu7FD", + "did:key:zDnaefn5fMKvoZ1n4vyxJ9npjWE5P3D8GkM9zNqaGbLqdDrtX", + ], + alsoKnownAs: ["at://alice.example.com"], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: "https://example.com", + }, + }, + prev: null, + sig: "2ySrMKwAQ8j_7HlJlNdE9kXFXG6VAGzy0s4P5O12UuMQqUgDHlAe3PQza5zWxIi6TC9K3K8ghmypfhDyJm8LuQ", + }, + ], + rotationKeyIndices: [0n, 0n], + sig: plcSign(rotationKey, tx.hash()), + }, +}); +tx.setWitnessArgsAt(0, ccc.WitnessArgs.from({ outputType: witness.toBytes() })); + +// Sign and send the transaction +const txHash = await signer.sendTransaction(tx); +console.log(`Transaction ${txHash} sent`); diff --git a/packages/examples/src/destroyDid.ts b/packages/examples/src/destroyDid.ts new file mode 100644 index 000000000..55f38b189 --- /dev/null +++ b/packages/examples/src/destroyDid.ts @@ -0,0 +1,31 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { render, signer } from "@ckb-ccc/playground"; + +// === Create a did first === +// Check https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/createDid.ts for the full example +const { tx: createTx, id } = await ccc.didCkb.createDidCkb({ + signer, + data: { + value: { document: {} }, + }, +}); +await createTx.completeFeeBy(signer); +await render(createTx); +const createTxHash = await signer.sendTransaction(createTx); +console.log(`Transaction ${createTxHash} sent`); +// === Create a did first === + +// Construct destroy did tx +const { tx } = await ccc.didCkb.destroyDidCkb({ client: signer.client, id }); + +// Complete missing parts: Fill inputs +await tx.completeInputsByCapacity(signer); +await render(tx); + +// Complete missing parts: Pay fee +await tx.completeFeeBy(signer); +await render(tx); + +// Sign and send the transaction +const txHash = await signer.sendTransaction(tx); +console.log(`Transaction ${txHash} sent`); diff --git a/packages/examples/src/transferDid.ts b/packages/examples/src/transferDid.ts new file mode 100644 index 000000000..90af8e3c6 --- /dev/null +++ b/packages/examples/src/transferDid.ts @@ -0,0 +1,47 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { render, signer } from "@ckb-ccc/playground"; + +// === Create a did first === +// Check https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/createDid.ts for the full example +const { tx: createTx, id } = await ccc.didCkb.createDidCkb({ + signer, + data: { + value: { document: {} }, + }, +}); +await createTx.completeFeeBy(signer); +await render(createTx); +const createTxHash = await signer.sendTransaction(createTx); +console.log(`Transaction ${createTxHash} sent`); +// === Create a did first === + +// The receiver is the signer itself on mainnet +const receiver = (await signer.getRecommendedAddressObj()).script; +console.log(receiver); + +// Construct transfer did tx +const { tx } = await ccc.didCkb.transferDidCkb({ + client: signer.client, + id, + receiver, + data: (_, data) => { + if (!data) { + throw Error("Unknown error"); + } + + (data.value.document as Record)["foo"] = "bar"; + return data; + }, +}); + +// Complete missing parts: Fill inputs +await tx.completeInputsByCapacity(signer); +await render(tx); + +// Complete missing parts: Pay fee +await tx.completeFeeBy(signer); +await render(tx); + +// Sign and send the transaction +const txHash = await signer.sendTransaction(tx); +console.log(`Transaction ${txHash} sent`); diff --git a/packages/shell/package.json b/packages/shell/package.json index 0f7efafca..f20cbdf2c 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -57,6 +57,7 @@ }, "dependencies": { "@ckb-ccc/core": "workspace:*", + "@ckb-ccc/did-ckb": "workspace:*", "@ckb-ccc/spore": "workspace:*", "@ckb-ccc/ssri": "workspace:*", "@ckb-ccc/type-id": "workspace:*", diff --git a/packages/shell/src/barrel.ts b/packages/shell/src/barrel.ts index 3c7752a39..e9d7adb7a 100644 --- a/packages/shell/src/barrel.ts +++ b/packages/shell/src/barrel.ts @@ -1,4 +1,5 @@ export * from "@ckb-ccc/core/barrel"; +export { didCkb } from "@ckb-ccc/did-ckb"; export { spore } from "@ckb-ccc/spore"; export { ssri } from "@ckb-ccc/ssri"; export { typeId } from "@ckb-ccc/type-id"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a7acb140..33d5adee6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,52 @@ importers: specifier: ^5.9.2 version: 5.9.2 + 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.5 + devDependencies: + '@eslint/js': + specifier: ^9.34.0 + version: 9.34.0 + '@types/node': + specifier: ^24.3.0 + version: 24.3.0 + eslint: + specifier: ^9.34.0 + version: 9.34.0(jiti@2.5.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.34.0(jiti@2.5.1)) + eslint-plugin-prettier: + specifier: ^5.5.4 + version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1))(prettier@3.6.2) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + prettier-plugin-organize-imports: + specifier: ^4.2.0 + version: 4.2.0(prettier@3.6.2)(typescript@5.9.2) + tsdown: + specifier: 0.19.0-beta.3 + version: 0.19.0-beta.3(synckit@0.11.11)(typescript@5.9.2) + typescript: + specifier: ^5.9.2 + version: 5.9.2 + typescript-eslint: + specifier: ^8.41.0 + version: 8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) + packages/docs: dependencies: '@docusaurus/core': @@ -486,6 +532,12 @@ importers: '@ckb-ccc/playground': specifier: file:src/playground version: playground@file:packages/examples/src/playground + '@noble/curves': + specifier: ^1.9.7 + version: 1.9.7 + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 devDependencies: '@eslint/js': specifier: ^9.34.0 @@ -959,6 +1011,9 @@ importers: '@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 @@ -3253,6 +3308,10 @@ packages: '@types/node': optional: true + '@ipld/dag-cbor@9.2.5': + resolution: {integrity: sha512-84wSr4jv30biui7endhobYhXBQzQE4c/wdoWlFrKcfiwH+ofaPg8fwsM8okX9cOzkkrsAsNdDyH3ou+kiLquwQ==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -5317,6 +5376,10 @@ packages: caniuse-lite@1.0.30001737: resolution: {integrity: sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==} + cborg@4.3.2: + resolution: {integrity: sha512-l+QzebEAG0vb09YKkaOrMi2zmm80UNjmbvocMIeW5hO7JOXWdrQ/H49yOKfYX0MBgrj/KWgatBnEgRXyNyKD+A==} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -8199,6 +8262,9 @@ packages: resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} hasBin: true + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -13739,6 +13805,11 @@ snapshots: optionalDependencies: '@types/node': 24.3.0 + '@ipld/dag-cbor@9.2.5': + dependencies: + cborg: 4.3.2 + multiformats: 13.4.2 + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -16163,6 +16234,8 @@ snapshots: caniuse-lite@1.0.30001737: {} + cborg@4.3.2: {} + ccount@2.0.1: {} chai@5.3.3: @@ -16757,8 +16830,7 @@ snapshots: detect-libc@2.0.4: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -17130,7 +17202,7 @@ snapshots: get-tsconfig: 4.10.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) @@ -19086,7 +19158,7 @@ snapshots: lightningcss@1.30.1: dependencies: - detect-libc: 2.0.4 + detect-libc: 2.1.2 optionalDependencies: lightningcss-darwin-arm64: 1.30.1 lightningcss-darwin-x64: 1.30.1 @@ -19856,6 +19928,8 @@ snapshots: dns-packet: 5.6.1 thunky: 1.1.0 + multiformats@13.4.2: {} + mute-stream@2.0.0: {} nanoassert@2.0.0: {} @@ -22109,7 +22183,7 @@ snapshots: tsx@4.20.5: dependencies: esbuild: 0.25.9 - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3 diff --git a/typedoc.config.mjs b/typedoc.config.mjs index 428dfce67..35f05db43 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -8,6 +8,7 @@ const config = { "packages/ssri", "packages/udt", "packages/spore", + "packages/did-ckb", "packages/shell", "packages/ccc", "packages/connector", diff --git a/vitest.config.mts b/vitest.config.mts index 5d03d85e3..9b64c1358 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,10 +1,12 @@ import { defineConfig, coverageConfigDefaults } from "vitest/config"; +const packages = ["packages/core", "packages/did-ckb", "packages/type-id"]; + export default defineConfig({ test: { - projects: ["packages/core", "packages/type-id"], + projects: packages, coverage: { - include: ["packages/core", "packages/type-id"], + include: packages, exclude: [ "**/dist/**", "**/dist.commonjs/**", From b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sat, 10 Jan 2026 05:31:18 +0800 Subject: [PATCH 07/45] fix(core): circular dependency due to btc.verify --- .changeset/bumpy-apes-burn.md | 6 ++++++ packages/core/src/signer/signer/index.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/bumpy-apes-burn.md diff --git a/.changeset/bumpy-apes-burn.md b/.changeset/bumpy-apes-burn.md new file mode 100644 index 000000000..a6427ce8f --- /dev/null +++ b/.changeset/bumpy-apes-burn.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +fix(core): circular dependency due to btc.verify + \ No newline at end of file diff --git a/packages/core/src/signer/signer/index.ts b/packages/core/src/signer/signer/index.ts index 1522b3353..a2d0504b7 100644 --- a/packages/core/src/signer/signer/index.ts +++ b/packages/core/src/signer/signer/index.ts @@ -10,7 +10,7 @@ import { } from "../../client/index.js"; import { Hex } from "../../hex/index.js"; import { Num } from "../../num/index.js"; -import { verifyMessageBtcEcdsa } from "../btc/index.js"; +import { verifyMessageBtcEcdsa } from "../btc/verify.js"; import { verifyMessageCkbSecp256k1 } from "../ckb/verifyCkbSecp256k1.js"; import { verifyMessageJoyId } from "../ckb/verifyJoyId.js"; import { verifyMessageDogeEcdsa } from "../doge/verify.js"; From 68b71478312916f30c2e28a66cebca84e6114437 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:36:27 +0000 Subject: [PATCH 08/45] chore(release): bump packages version --- .changeset/bumpy-apes-burn.md | 6 ------ packages/ccc/CHANGELOG.md | 14 ++++++++++++++ packages/ccc/package.json | 2 +- packages/ckb-ccc/CHANGELOG.md | 6 ++++++ packages/ckb-ccc/package.json | 2 +- packages/connector-react/CHANGELOG.md | 6 ++++++ packages/connector-react/package.json | 2 +- packages/connector/CHANGELOG.md | 6 ++++++ packages/connector/package.json | 2 +- packages/core/CHANGELOG.md | 7 +++++++ packages/core/package.json | 2 +- packages/eip6963/CHANGELOG.md | 6 ++++++ packages/eip6963/package.json | 2 +- packages/joy-id/CHANGELOG.md | 6 ++++++ packages/joy-id/package.json | 2 +- packages/lumos-patches/CHANGELOG.md | 6 ++++++ packages/lumos-patches/package.json | 2 +- packages/nip07/CHANGELOG.md | 6 ++++++ packages/nip07/package.json | 2 +- packages/okx/CHANGELOG.md | 8 ++++++++ packages/okx/package.json | 2 +- packages/rei/CHANGELOG.md | 6 ++++++ packages/rei/package.json | 2 +- packages/shell/CHANGELOG.md | 9 +++++++++ packages/shell/package.json | 2 +- packages/spore/CHANGELOG.md | 6 ++++++ packages/spore/package.json | 2 +- packages/ssri/CHANGELOG.md | 6 ++++++ packages/ssri/package.json | 2 +- packages/udt/CHANGELOG.md | 7 +++++++ packages/udt/package.json | 2 +- packages/uni-sat/CHANGELOG.md | 6 ++++++ packages/uni-sat/package.json | 2 +- packages/utxo-global/CHANGELOG.md | 6 ++++++ packages/utxo-global/package.json | 2 +- packages/xverse/CHANGELOG.md | 6 ++++++ packages/xverse/package.json | 2 +- 37 files changed, 141 insertions(+), 24 deletions(-) delete mode 100644 .changeset/bumpy-apes-burn.md diff --git a/.changeset/bumpy-apes-burn.md b/.changeset/bumpy-apes-burn.md deleted file mode 100644 index a6427ce8f..000000000 --- a/.changeset/bumpy-apes-burn.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@ckb-ccc/core": patch ---- - -fix(core): circular dependency due to btc.verify - \ No newline at end of file diff --git a/packages/ccc/CHANGELOG.md b/packages/ccc/CHANGELOG.md index 3a11e04b0..ea1376989 100644 --- a/packages/ccc/CHANGELOG.md +++ b/packages/ccc/CHANGELOG.md @@ -1,5 +1,19 @@ # @ckb-ccc/ccc +## 1.1.24 +### Patch Changes + +- Updated dependencies []: + - @ckb-ccc/eip6963@1.0.31 + - @ckb-ccc/joy-id@1.0.31 + - @ckb-ccc/nip07@1.0.31 + - @ckb-ccc/okx@1.0.31 + - @ckb-ccc/rei@1.0.31 + - @ckb-ccc/shell@1.1.24 + - @ckb-ccc/uni-sat@1.0.31 + - @ckb-ccc/utxo-global@1.0.31 + - @ckb-ccc/xverse@1.0.31 + ## 1.1.23 ### Patch Changes diff --git a/packages/ccc/package.json b/packages/ccc/package.json index 24230193a..39d08c63e 100644 --- a/packages/ccc/package.json +++ b/packages/ccc/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/ccc", - "version": "1.1.23", + "version": "1.1.24", "description": "CCC - CKBer's Codebase. Common Chains Connector.", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/ckb-ccc/CHANGELOG.md b/packages/ckb-ccc/CHANGELOG.md index 44b96af02..a11f847a6 100644 --- a/packages/ckb-ccc/CHANGELOG.md +++ b/packages/ckb-ccc/CHANGELOG.md @@ -1,5 +1,11 @@ # ckb-ccc +## 1.0.32 +### Patch Changes + +- Updated dependencies []: + - @ckb-ccc/ccc@1.1.24 + ## 1.0.31 ### Patch Changes diff --git a/packages/ckb-ccc/package.json b/packages/ckb-ccc/package.json index e4f3efdad..445f16317 100644 --- a/packages/ckb-ccc/package.json +++ b/packages/ckb-ccc/package.json @@ -1,6 +1,6 @@ { "name": "ckb-ccc", - "version": "1.0.31", + "version": "1.0.32", "description": "CCC - CKBer's Codebase. Common Chains Connector.", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/connector-react/CHANGELOG.md b/packages/connector-react/CHANGELOG.md index 778ccab9f..41b3ee8a9 100644 --- a/packages/connector-react/CHANGELOG.md +++ b/packages/connector-react/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/connector-react +## 1.0.33 +### Patch Changes + +- Updated dependencies []: + - @ckb-ccc/connector@1.0.32 + ## 1.0.32 ### Patch Changes diff --git a/packages/connector-react/package.json b/packages/connector-react/package.json index f1f639d2f..541131c74 100644 --- a/packages/connector-react/package.json +++ b/packages/connector-react/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/connector-react", - "version": "1.0.32", + "version": "1.0.33", "description": "CCC - CKBer's Codebase. Common Chains Connector UI Component for React", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/connector/CHANGELOG.md b/packages/connector/CHANGELOG.md index 488182c9c..1e0ef5d2a 100644 --- a/packages/connector/CHANGELOG.md +++ b/packages/connector/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/connector +## 1.0.32 +### Patch Changes + +- Updated dependencies []: + - @ckb-ccc/ccc@1.1.24 + ## 1.0.31 ### Patch Changes diff --git a/packages/connector/package.json b/packages/connector/package.json index 53eaa4cca..5ed95676f 100644 --- a/packages/connector/package.json +++ b/packages/connector/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/connector", - "version": "1.0.31", + "version": "1.0.32", "description": "CCC - CKBer's Codebase. Common Chains Connector UI", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index dfac2f057..590342d9e 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,12 @@ # @ckb-ccc/core +## 1.12.4 +### Patch Changes + + + +- [#350](https://github.com/ckb-devrel/ccc/pull/350) [`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1) Thanks [@Hanssen0](https://github.com/Hanssen0)! - fix(core): circular dependency due to btc.verify + ## 1.12.3 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 5930cc344..56c3b8cd4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/core", - "version": "1.12.3", + "version": "1.12.4", "description": "Core of CCC - CKBer's Codebase", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/eip6963/CHANGELOG.md b/packages/eip6963/CHANGELOG.md index fcfa2c184..fe1856022 100644 --- a/packages/eip6963/CHANGELOG.md +++ b/packages/eip6963/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/eip6963 +## 1.0.31 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 1.0.30 ### Patch Changes diff --git a/packages/eip6963/package.json b/packages/eip6963/package.json index a660f3a1e..a9229617f 100644 --- a/packages/eip6963/package.json +++ b/packages/eip6963/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/eip6963", - "version": "1.0.30", + "version": "1.0.31", "description": "CCC - CKBer's Codebase. Common Chains Connector's support for EIP6963", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/joy-id/CHANGELOG.md b/packages/joy-id/CHANGELOG.md index b06fb2839..6304cec9d 100644 --- a/packages/joy-id/CHANGELOG.md +++ b/packages/joy-id/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/joy-id +## 1.0.31 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 1.0.30 ### Patch Changes diff --git a/packages/joy-id/package.json b/packages/joy-id/package.json index c142b7b7e..17448e71d 100644 --- a/packages/joy-id/package.json +++ b/packages/joy-id/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/joy-id", - "version": "1.0.30", + "version": "1.0.31", "description": "Connector's support for JoyID", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/lumos-patches/CHANGELOG.md b/packages/lumos-patches/CHANGELOG.md index 795e19e22..671524a24 100644 --- a/packages/lumos-patches/CHANGELOG.md +++ b/packages/lumos-patches/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/lumos-patches +## 1.0.31 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 1.0.30 ### Patch Changes diff --git a/packages/lumos-patches/package.json b/packages/lumos-patches/package.json index 127a8df4a..10de70a18 100644 --- a/packages/lumos-patches/package.json +++ b/packages/lumos-patches/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/lumos-patches", - "version": "1.0.30", + "version": "1.0.31", "description": "Patches for using Lumos with CCC", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/nip07/CHANGELOG.md b/packages/nip07/CHANGELOG.md index 05f9ffe97..ad881d7ad 100644 --- a/packages/nip07/CHANGELOG.md +++ b/packages/nip07/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/nip07 +## 1.0.31 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 1.0.30 ### Patch Changes diff --git a/packages/nip07/package.json b/packages/nip07/package.json index 06c668380..2bc0cf79b 100644 --- a/packages/nip07/package.json +++ b/packages/nip07/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/nip07", - "version": "1.0.30", + "version": "1.0.31", "description": "CCC - CKBer's Codebase. Common Chains Connector's support for NIP07", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/okx/CHANGELOG.md b/packages/okx/CHANGELOG.md index 91d339220..df59b7225 100644 --- a/packages/okx/CHANGELOG.md +++ b/packages/okx/CHANGELOG.md @@ -1,5 +1,13 @@ # @ckb-ccc/okx +## 1.0.31 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + - @ckb-ccc/nip07@1.0.31 + - @ckb-ccc/uni-sat@1.0.31 + ## 1.0.30 ### Patch Changes diff --git a/packages/okx/package.json b/packages/okx/package.json index e4de5c9a4..f3de9b5f1 100644 --- a/packages/okx/package.json +++ b/packages/okx/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/okx", - "version": "1.0.30", + "version": "1.0.31", "description": "CCC - CKBer's Codebase. Common Chains Connector's support for OKX", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/rei/CHANGELOG.md b/packages/rei/CHANGELOG.md index 0c34da249..751f6c04f 100644 --- a/packages/rei/CHANGELOG.md +++ b/packages/rei/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/rei +## 1.0.31 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 1.0.30 ### Patch Changes diff --git a/packages/rei/package.json b/packages/rei/package.json index 9b719bb7d..2b6573093 100644 --- a/packages/rei/package.json +++ b/packages/rei/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/rei", - "version": "1.0.30", + "version": "1.0.31", "description": "CCC - CKBer's Codebase. Common Chains Connector's support for Rei", "license": "MIT", "private": false, diff --git a/packages/shell/CHANGELOG.md b/packages/shell/CHANGELOG.md index 981658cb5..6ae040a14 100644 --- a/packages/shell/CHANGELOG.md +++ b/packages/shell/CHANGELOG.md @@ -1,5 +1,14 @@ # @ckb-ccc/shell +## 1.1.24 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + - @ckb-ccc/spore@1.5.16 + - @ckb-ccc/ssri@0.2.21 + - @ckb-ccc/udt@0.1.23 + ## 1.1.23 ### Patch Changes diff --git a/packages/shell/package.json b/packages/shell/package.json index d5aed9bbb..751f8a344 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/shell", - "version": "1.1.23", + "version": "1.1.24", "description": "Backend Shell of CCC - CKBer's Codebase. Common Chains Connector.", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/spore/CHANGELOG.md b/packages/spore/CHANGELOG.md index 044537da4..f66235af1 100644 --- a/packages/spore/CHANGELOG.md +++ b/packages/spore/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/spore +## 1.5.16 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 1.5.15 ### Patch Changes diff --git a/packages/spore/package.json b/packages/spore/package.json index 974ad76dc..d28818168 100644 --- a/packages/spore/package.json +++ b/packages/spore/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/spore", - "version": "1.5.15", + "version": "1.5.16", "description": "CCC - CKBer's Codebase. Common Chains Connector's support for Spore protocol", "author": "ashuralyk ", "license": "MIT", diff --git a/packages/ssri/CHANGELOG.md b/packages/ssri/CHANGELOG.md index 4d7a96444..33b45f3e6 100644 --- a/packages/ssri/CHANGELOG.md +++ b/packages/ssri/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/ssri +## 0.2.21 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 0.2.20 ### Patch Changes diff --git a/packages/ssri/package.json b/packages/ssri/package.json index 610c37ed1..951cf14d4 100644 --- a/packages/ssri/package.json +++ b/packages/ssri/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/ssri", - "version": "0.2.20", + "version": "0.2.21", "description": "SSRI", "author": "Alive24 ", "license": "MIT", diff --git a/packages/udt/CHANGELOG.md b/packages/udt/CHANGELOG.md index 47b54f837..749c96c2f 100644 --- a/packages/udt/CHANGELOG.md +++ b/packages/udt/CHANGELOG.md @@ -1,5 +1,12 @@ # @ckb-ccc/udt +## 0.1.23 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + - @ckb-ccc/ssri@0.2.21 + ## 0.1.22 ### Patch Changes diff --git a/packages/udt/package.json b/packages/udt/package.json index 5bcfa1b4b..8aa9778da 100644 --- a/packages/udt/package.json +++ b/packages/udt/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/udt", - "version": "0.1.22", + "version": "0.1.23", "description": "UDT", "author": "Alive24 ", "license": "MIT", diff --git a/packages/uni-sat/CHANGELOG.md b/packages/uni-sat/CHANGELOG.md index 440253c5d..593160e1c 100644 --- a/packages/uni-sat/CHANGELOG.md +++ b/packages/uni-sat/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/uni-sat +## 1.0.31 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 1.0.30 ### Patch Changes diff --git a/packages/uni-sat/package.json b/packages/uni-sat/package.json index ccece3532..26f73d490 100644 --- a/packages/uni-sat/package.json +++ b/packages/uni-sat/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/uni-sat", - "version": "1.0.30", + "version": "1.0.31", "description": "CCC - CKBer's Codebase. Common Chains Connector's support for UniSat", "author": "Hanssen0 ", "license": "MIT", diff --git a/packages/utxo-global/CHANGELOG.md b/packages/utxo-global/CHANGELOG.md index ff47c5b69..dda717b97 100644 --- a/packages/utxo-global/CHANGELOG.md +++ b/packages/utxo-global/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/utxo-global +## 1.0.31 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 1.0.30 ### Patch Changes diff --git a/packages/utxo-global/package.json b/packages/utxo-global/package.json index 3f7eb39c1..35137300b 100644 --- a/packages/utxo-global/package.json +++ b/packages/utxo-global/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/utxo-global", - "version": "1.0.30", + "version": "1.0.31", "description": "Common Chains Connector's support for UTXO Global", "author": "Trong Dinh ", "license": "MIT", diff --git a/packages/xverse/CHANGELOG.md b/packages/xverse/CHANGELOG.md index a0101e509..c059d1d42 100644 --- a/packages/xverse/CHANGELOG.md +++ b/packages/xverse/CHANGELOG.md @@ -1,5 +1,11 @@ # @ckb-ccc/xverse +## 1.0.31 +### Patch Changes + +- Updated dependencies [[`b4aa99f`](https://github.com/ckb-devrel/ccc/commit/b4aa99f1b87c1d14117a15fa1fcac6f9e60b43c1)]: + - @ckb-ccc/core@1.12.4 + ## 1.0.30 ### Patch Changes diff --git a/packages/xverse/package.json b/packages/xverse/package.json index 821cc5db8..21055c506 100644 --- a/packages/xverse/package.json +++ b/packages/xverse/package.json @@ -1,6 +1,6 @@ { "name": "@ckb-ccc/xverse", - "version": "1.0.30", + "version": "1.0.31", "description": "CCC - CKBer's Codebase. Common Chains Connector's support for Xverse", "author": "Hanssen0 ", "license": "MIT", From 7a047a6d9444e1293ebb781f813d4dc11c7a67b2 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Fri, 13 Jun 2025 19:40:24 +0800 Subject: [PATCH 09/45] feat(core): optional `shouldAddInputs` for `Transaction.completeFee` --- .changeset/curvy-baboons-sip.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/curvy-baboons-sip.md diff --git a/.changeset/curvy-baboons-sip.md b/.changeset/curvy-baboons-sip.md new file mode 100644 index 000000000..9171ff9a1 --- /dev/null +++ b/.changeset/curvy-baboons-sip.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): optional `shouldAddInputs` for `Transaction.completeFee` + \ No newline at end of file From 9d130601fcc4638d28433e1c54e1e3409cfebebe Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Fri, 13 Jun 2025 22:41:54 +0800 Subject: [PATCH 10/45] fix(core): `Transaction.clone` should clone inputs' cache --- .changeset/weak-otters-dance.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/weak-otters-dance.md diff --git a/.changeset/weak-otters-dance.md b/.changeset/weak-otters-dance.md new file mode 100644 index 000000000..af924ed4f --- /dev/null +++ b/.changeset/weak-otters-dance.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +fix(core): `Transaction.clone` should clone inputs' cache + \ No newline at end of file From c533eea41d4fda627400c030bc97cc4387ba3eca Mon Sep 17 00:00:00 2001 From: Phroi <90913182+phroi@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:40:44 +0700 Subject: [PATCH 11/45] Simplify MapLru, while improving Complexity (#244) * Simplify MapLru, while improving Complexity Closes #243 * Apply suggestions from code %2 Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Allow nullable Values * Switch from non-null assertion to type assertion * Create six-steaks-grab.md --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Hanssen --- .changeset/six-steaks-grab.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/six-steaks-grab.md diff --git a/.changeset/six-steaks-grab.md b/.changeset/six-steaks-grab.md new file mode 100644 index 000000000..9b7432cfd --- /dev/null +++ b/.changeset/six-steaks-grab.md @@ -0,0 +1,5 @@ +--- +"@ckb-ccc/core": patch +--- + +Simplify MapLru, while improving Complexity From c1b2be69f1540d848a6b92d03042dd0aeaa0eed4 Mon Sep 17 00:00:00 2001 From: fgh_ssh Date: Tue, 15 Jul 2025 14:10:36 -0500 Subject: [PATCH 12/45] feat: add RGB++ known scripts (RgbppLock, BtcTimeLock) --- .../client/clientPublicMainnet.advanced.ts | 68 ++++++++ .../client/clientPublicTestnet.advanced.ts | 163 ++++++++++++++++++ packages/core/src/client/knownScript.ts | 5 + 3 files changed, 236 insertions(+) diff --git a/packages/core/src/client/clientPublicMainnet.advanced.ts b/packages/core/src/client/clientPublicMainnet.advanced.ts index bdcec4cc1..86506e309 100644 --- a/packages/core/src/client/clientPublicMainnet.advanced.ts +++ b/packages/core/src/client/clientPublicMainnet.advanced.ts @@ -480,4 +480,72 @@ export const MAINNET_SCRIPTS: Record = }, ], }, + [KnownScript.RgbppLock]: { + codeHash: + "0xbc6c568a1a0d0a09f6844dc9d74ddb4343c32143ff25f727c59edf4fb72d6936", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0x04c5c3e69f1aa6ee27fb9de3d15a81704e387ab3b453965adbe0b6ca343c6f41", + index: 0, + }, + depType: "code", + }, + type: { + codeHash: + "0x00000000000000000000000000000000000000000000000000545950455f4944", + hashType: "type", + args: "0x68ad3d9e0bb9ea841a5d1fcd600137bd3f45401e759e353121f26cd0d981452f", + }, + }, + // Rgbpp lock config cell dep + { + cellDep: { + outPoint: { + txHash: + "0x04c5c3e69f1aa6ee27fb9de3d15a81704e387ab3b453965adbe0b6ca343c6f41", + index: 1, + }, + depType: "code", + }, + }, + ], + }, + [KnownScript.BtcTimeLock]: { + codeHash: + "0x70d64497a075bd651e98ac030455ea200637ee325a12ad08aff03f1a117e5a62", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0x6257bf4297ee75fcebe2654d8c5f8d93bc9fc1b3dc62b8cef54ffe166162e996", + index: 0, + }, + depType: "code", + }, + type: { + codeHash: + "0x00000000000000000000000000000000000000000000000000545950455f4944", + hashType: "type", + args: "0x44b8253ae18e913a2845b0d548eaf6b3ba1099ed26835888932a754194028a8a", + }, + }, + // btc time lock config cell dep + { + cellDep: { + outPoint: { + txHash: + "0x6257bf4297ee75fcebe2654d8c5f8d93bc9fc1b3dc62b8cef54ffe166162e996", + index: 1, + }, + depType: "code", + }, + }, + ], + }, }); diff --git a/packages/core/src/client/clientPublicTestnet.advanced.ts b/packages/core/src/client/clientPublicTestnet.advanced.ts index 9453d2523..3b22a4c3d 100644 --- a/packages/core/src/client/clientPublicTestnet.advanced.ts +++ b/packages/core/src/client/clientPublicTestnet.advanced.ts @@ -492,4 +492,167 @@ export const TESTNET_SCRIPTS: Record = }, ], }, + [KnownScript.RgbppLock]: { + codeHash: + "0x61ca7a4796a4eb19ca4f0d065cb9b10ddcf002f10f7cbb810c706cb6bb5c3248", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0xf1de59e973b85791ec32debbba08dff80c63197e895eb95d67fc1e9f6b413e00", + index: 0, + }, + depType: "code", + }, + type: { + codeHash: + "0x00000000000000000000000000000000000000000000000000545950455f4944", + hashType: "type", + args: "0xa3bc8441df149def76cfe15fec7b1e51d949548bc27fb7a75e9d4b3ef1c12c7f", + }, + }, + // Rgbpp lock config cell dep for Bitcoin Testnet3 + { + cellDep: { + outPoint: { + txHash: + "0xf1de59e973b85791ec32debbba08dff80c63197e895eb95d67fc1e9f6b413e00", + index: 1, + }, + depType: "code", + }, + }, + ], + }, + [KnownScript.BtcTimeLock]: { + codeHash: + "0x00cdf8fab0f8ac638758ebf5ea5e4052b1d71e8a77b9f43139718621f6849326", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0xde0f87878a97500f549418e5d46d2f7704c565a262aa17036c9c1c13ad638529", + index: 0, + }, + depType: "code", + }, + type: { + codeHash: + "0x00000000000000000000000000000000000000000000000000545950455f4944", + hashType: "type", + args: "0xc9828585e6dd2afacb9e6e8ca7deb0975121aabee5c7983178a45509ffaec984", + }, + }, + // btc time lock config cell dep for Bitcoin Testnet3 + { + cellDep: { + outPoint: { + txHash: + "0xde0f87878a97500f549418e5d46d2f7704c565a262aa17036c9c1c13ad638529", + index: 1, + }, + depType: "code", + }, + }, + ], + }, }); + +/** + * Bitcoin Signet specific script overrides for testnet + * + * Contains script configurations that differ when using Bitcoin Signet + * instead of Bitcoin Testnet3. Only RgbppLock and BtcTimeLock are affected. + * + * @example + * ```typescript + * import { ClientPublicTestnet } from "@ckb-ccc/core"; + * import { TESTNET_SCRIPTS, TESTNET_SCRIPTS_BTC_SIGNET_OVERRIDES } from "@ckb-ccc/core/advanced"; + * + * // Use Bitcoin Testnet3 scripts (default) + * const testnet3Client = new ClientPublicTestnet(); + * + * // Use Bitcoin Signet scripts by merging overrides + * const signetClient = new ClientPublicTestnet({ + * scripts: { + * ...TESTNET_SCRIPTS, + * ...TESTNET_SCRIPTS_BTC_SIGNET_OVERRIDES + * } + * }); + */ +export const TESTNET_SCRIPTS_BTC_SIGNET_OVERRIDES: Partial< + Record +> = Object.freeze({ + [KnownScript.RgbppLock]: { + codeHash: + "0xd07598deec7ce7b5665310386b4abd06a6d48843e953c5cc2112ad0d5a220364", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0x61efdeddbaa0bb4132c0eb174b3e8002ff5ec430f61ba46f30768d683c516eec", + index: 0, + }, + depType: "code", + }, + type: { + codeHash: + "0x00000000000000000000000000000000000000000000000000545950455f4944", + hashType: "type", + args: "0xb69fe766ce3b7014a2a78ad1fe688d82f1679325805371d2856c3b8d18ebfa5a", + }, + }, + // Rgbpp lock config cell dep for Bitcoin Signet + { + cellDep: { + outPoint: { + txHash: + "0x61efdeddbaa0bb4132c0eb174b3e8002ff5ec430f61ba46f30768d683c516eec", + index: 1, + }, + depType: "code", + }, + }, + ], + }, + [KnownScript.BtcTimeLock]: { + codeHash: + "0x80a09eca26d77cea1f5a69471c59481be7404febf40ee90f886c36a948385b55", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0x5364b3535965e9eac9a35dd7af8e9e45a61d30a16e115923c032f80b28783e21", + index: 0, + }, + depType: "code", + }, + type: { + codeHash: + "0x00000000000000000000000000000000000000000000000000545950455f4944", + hashType: "type", + args: "0x32fc8c70a6451a1439fd91e214bba093f9cdd9276bc4ab223430dab5940aff92", + }, + }, + // btc time lock config cell dep for Bitcoin Signet + { + cellDep: { + outPoint: { + txHash: + "0x5364b3535965e9eac9a35dd7af8e9e45a61d30a16e115923c032f80b28783e21", + index: 1, + }, + depType: "code", + }, + }, + ], + }, +}); diff --git a/packages/core/src/client/knownScript.ts b/packages/core/src/client/knownScript.ts index 90a1546fe..66d1c91c5 100644 --- a/packages/core/src/client/knownScript.ts +++ b/packages/core/src/client/knownScript.ts @@ -26,4 +26,9 @@ export enum KnownScript { TypeBurnLock = "TypeBurnLock", EasyToDiscoverType = "EasyToDiscoverType", TimeLock = "TimeLock", + + // RGB++ related scripts (default using Bitcoin Testnet3) + // For Bitcoin Signet, use TESTNET_SCRIPTS_BTC_SIGNET_OVERRIDES from @ckb-ccc/core/advanced + RgbppLock = "RgbppLock", + BtcTimeLock = "BtcTimeLock", } From e95f84d95915ac5ed4d545d6a0b741481ac60b8e Mon Sep 17 00:00:00 2001 From: Phroi <90913182+phroi@users.noreply.github.com> Date: Mon, 11 Aug 2025 05:34:39 +0700 Subject: [PATCH 13/45] Improve performance of Script & OutPoint eq (#248) * Improve performance of Script & OutPoint eq %143 * Fix typedoc copy-paste mishap Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Dedupe strings in an interleaved way %27 * Revert manual interning --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/ckb/transaction.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 2cb60e906..6aecdc470 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -299,6 +299,20 @@ export class CellOutput extends Entity.Base() { clone(): CellOutput { return new CellOutput(this.capacity, this.lock.clone(), this.type?.clone()); } + + /** + * Clone a CellOutput. + * + * @returns A cloned CellOutput instance. + * + * @example + * ```typescript + * const cellOutput1 = cellOutput0.clone(); + * ``` + */ + clone(): CellOutput { + return new CellOutput(this.capacity, this.lock.clone(), this.type?.clone()); + } } export const CellOutputVec = mol.vector(CellOutput); From 542cd11b28150aafb8dc235db3e1264134c67fe9 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Thu, 14 Aug 2025 20:28:08 +0800 Subject: [PATCH 14/45] fix(core): avoid circular dependency --- .changeset/clean-chefs-roll.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/clean-chefs-roll.md diff --git a/.changeset/clean-chefs-roll.md b/.changeset/clean-chefs-roll.md new file mode 100644 index 000000000..7e5f0c8fa --- /dev/null +++ b/.changeset/clean-chefs-roll.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +fix(core): avoid circular dependency + \ No newline at end of file From c2a9df937727a481d97eb14ba8f22a7dbe12f631 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sat, 21 Jun 2025 05:40:50 +0800 Subject: [PATCH 15/45] feat(core): `Signer.findCellsOnChain` --- .changeset/clean-shoes-thank.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/clean-shoes-thank.md diff --git a/.changeset/clean-shoes-thank.md b/.changeset/clean-shoes-thank.md new file mode 100644 index 000000000..00b6d9101 --- /dev/null +++ b/.changeset/clean-shoes-thank.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): `Signer.findCellsOnChain` + \ No newline at end of file From 9aaa25e114c6250237fbf3044ad870456f0fba43 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sun, 15 Jun 2025 01:01:37 +0800 Subject: [PATCH 16/45] feat(core): auto capacity completion --- .changeset/green-news-behave.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/green-news-behave.md diff --git a/.changeset/green-news-behave.md b/.changeset/green-news-behave.md new file mode 100644 index 000000000..8dc7156eb --- /dev/null +++ b/.changeset/green-news-behave.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): auto capacity completion + \ No newline at end of file From ef9c75a0eeefaf053dbd6969736dda3339ec5d8a Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sun, 15 Jun 2025 03:12:25 +0800 Subject: [PATCH 17/45] perf(core): optimize Transaction.completeFee --- .changeset/empty-shrimps-buy.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/empty-shrimps-buy.md diff --git a/.changeset/empty-shrimps-buy.md b/.changeset/empty-shrimps-buy.md new file mode 100644 index 000000000..15ccfd8a4 --- /dev/null +++ b/.changeset/empty-shrimps-buy.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +perf(core): optimize Transaction.completeFee + \ No newline at end of file From c1ae914fa45f9e740ac8308030616be5d51bff74 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sun, 17 Aug 2025 03:14:53 +0800 Subject: [PATCH 18/45] feat(udt): `Udt.complete*` methods --- .changeset/plenty-ads-rush.md | 6 + .changeset/shy-horses-agree.md | 6 + packages/core/src/ckb/transaction.ts | 12 + packages/core/src/ckb/transactionErrors.ts | 3 + packages/ssri/src/executor.ts | 8 + packages/udt/package.json | 2 +- packages/udt/src/udt/index.test.ts | 1001 +++++++++++++++ packages/udt/src/udt/index.ts | 1295 +++++++++++++++++++- packages/udt/src/udtPausable/index.ts | 6 +- packages/udt/vitest.config.ts | 10 + 10 files changed, 2290 insertions(+), 59 deletions(-) create mode 100644 .changeset/plenty-ads-rush.md create mode 100644 .changeset/shy-horses-agree.md create mode 100644 packages/udt/src/udt/index.test.ts create mode 100644 packages/udt/vitest.config.ts diff --git a/.changeset/plenty-ads-rush.md b/.changeset/plenty-ads-rush.md new file mode 100644 index 000000000..8db5049cd --- /dev/null +++ b/.changeset/plenty-ads-rush.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/ssri": minor +--- + +feat(ssri): `ExecutorResponse.mapAsync` + \ No newline at end of file diff --git a/.changeset/shy-horses-agree.md b/.changeset/shy-horses-agree.md new file mode 100644 index 000000000..f42efc551 --- /dev/null +++ b/.changeset/shy-horses-agree.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/udt": minor +--- + +feat(udt): `Udt.complete*` methods + diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 6aecdc470..006853076 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -1888,6 +1888,12 @@ export class Transaction extends Entity.Base() { return this.outputs.reduce((acc, { capacity }) => acc + capacity, Zero); } + /** + * @deprecated Use `Udt.getInputsBalance` from `@ckb-ccc/udt` instead + * @param client + * @param type + * @returns + */ async getInputsUdtBalance(client: Client, type: ScriptLike): Promise { return reduceAsync( this.inputs, @@ -1903,6 +1909,11 @@ export class Transaction extends Entity.Base() { ); } + /** + * @deprecated Use `Udt.getOutputsBalance` from `@ckb-ccc/udt` instead + * @param type + * @returns + */ getOutputsUdtBalance(type: ScriptLike): Num { return this.outputs.reduce((acc, output, i) => { if (!output.type?.eq(type)) { @@ -2020,6 +2031,7 @@ export class Transaction extends Entity.Base() { * This method succeeds only if enough balance is collected. * * It will try to collect at least two inputs, even when the first input already contains enough balance, to avoid extra occupation fees introduced by the change cell. An edge case: If the first cell has the same amount as the output, a new cell is not needed. + * @deprecated Use `Udt.completeInputsByBalance` from `@ckb-ccc/udt` instead * @param from - The signer to complete the inputs. * @param type - The type script of the UDT. * @param balanceTweak - The tweak of the balance. diff --git a/packages/core/src/ckb/transactionErrors.ts b/packages/core/src/ckb/transactionErrors.ts index 09c1cb745..26057669a 100644 --- a/packages/core/src/ckb/transactionErrors.ts +++ b/packages/core/src/ckb/transactionErrors.ts @@ -22,6 +22,9 @@ export class ErrorTransactionInsufficientCapacity extends Error { } } +/** + * @deprecated Use `ErrorUdtInsufficientCoin` from `@ckb-ccc/udt` instead. + */ export class ErrorTransactionInsufficientCoin extends Error { public readonly amount: Num; public readonly type: Script; diff --git a/packages/ssri/src/executor.ts b/packages/ssri/src/executor.ts index d4548b8bd..b7ae02d44 100644 --- a/packages/ssri/src/executor.ts +++ b/packages/ssri/src/executor.ts @@ -63,6 +63,14 @@ export class ExecutorResponse { throw new ExecutorErrorDecode(JSON.stringify(err)); } } + + async mapAsync(fn: (res: T) => Promise): Promise> { + try { + return new ExecutorResponse(await fn(this.res), this.cellDeps); + } catch (err) { + throw new ExecutorErrorDecode(JSON.stringify(err)); + } + } } /** diff --git a/packages/udt/package.json b/packages/udt/package.json index 8aa9778da..2e98f0038 100644 --- a/packages/udt/package.json +++ b/packages/udt/package.json @@ -23,7 +23,7 @@ } }, "scripts": { - "test": "jest", + "test": "vitest", "build": "rimraf ./dist && rimraf ./dist.commonjs && tsc && tsc --project tsconfig.commonjs.json && copyfiles -u 2 misc/basedirs/**/* .", "lint": "eslint ./src", "format": "prettier --write . && eslint --fix ./src" diff --git a/packages/udt/src/udt/index.test.ts b/packages/udt/src/udt/index.test.ts new file mode 100644 index 000000000..00433e894 --- /dev/null +++ b/packages/udt/src/udt/index.test.ts @@ -0,0 +1,1001 @@ +import { ccc } from "@ckb-ccc/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Udt } from "./index.js"; + +let client: ccc.Client; +let signer: ccc.Signer; +let lock: ccc.Script; +let type: ccc.Script; +let udt: Udt; + +beforeEach(async () => { + client = new ccc.ClientPublicTestnet(); + signer = new ccc.SignerCkbPublicKey( + client, + "0x026f3255791f578cc5e38783b6f2d87d4709697b797def6bf7b3b9af4120e2bfd9", + ); + lock = (await signer.getRecommendedAddressObj()).script; + + type = await ccc.Script.fromKnownScript( + client, + ccc.KnownScript.XUdt, + "0xf8f94a13dfe1b87c10312fb9678ab5276eefbe1e0b2c62b4841b1f393494eff2", + ); + + // Create UDT instance + udt = new Udt( + { + txHash: + "0x4e2e832e0b1e7b5994681b621b00c1e65f577ee4b440ef95fa07db9bb3d50269", + index: 0, + }, + type, + ); +}); + +describe("Udt", () => { + describe("completeInputsByBalance", () => { + // Mock cells with 100 UDT each (10 cells total = 1000 UDT) + let mockUdtCells: ccc.Cell[]; + + beforeEach(async () => { + // Create mock cells after type is initialized + mockUdtCells = Array.from({ length: 10 }, (_, i) => + ccc.Cell.from({ + outPoint: { + txHash: `0x${"0".repeat(63)}${i.toString(16)}`, + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(142), + lock, + type, + }, + outputData: ccc.numLeToBytes(100, 16), // 100 UDT tokens + }), + ); + }); + + beforeEach(() => { + // Mock the findCells method to return our mock UDT cells + vi.spyOn(signer, "findCells").mockImplementation( + async function* (filter) { + if (filter.script && ccc.Script.from(filter.script).eq(type)) { + for (const cell of mockUdtCells) { + yield cell; + } + } + }, + ); + + // Mock client.getCell to return the cell data for inputs + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + const cell = mockUdtCells.find((c) => c.outPoint.eq(outPoint)); + return cell; + }); + }); + + it("should return 0 when no UDT balance is needed", async () => { + const tx = ccc.Transaction.from({ + outputs: [], + }); + + const { addedCount } = await udt.completeInputsByBalance(tx, signer); + expect(addedCount).toBe(0); + }); + + it("should collect exactly the required UDT balance", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + lock, + type, + }, + ], + outputsData: [ccc.numLeToBytes(150, 16)], // Need 150 UDT + }); + + const { addedCount } = await udt.completeInputsByBalance(tx, signer); + + // Should add 2 cells (200 UDT total) to have at least 2 inputs + expect(addedCount).toBe(2); + expect(tx.inputs.length).toBe(2); + + // Verify the inputs are UDT cells + const inputBalance = await udt.getInputsBalance(tx, client); + expect(inputBalance).toBe(ccc.numFrom(200)); + }); + + it("should collect exactly one cell when amount matches exactly", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + lock, + type, + }, + ], + outputsData: [ccc.numLeToBytes(100, 16)], // Need exactly 100 UDT + }); + + const { addedCount } = await udt.completeInputsByBalance(tx, signer); + + // Should add only 1 cell since it matches exactly + expect(addedCount).toBe(1); + expect(tx.inputs.length).toBe(1); + + const inputBalance = await udt.getInputsBalance(tx, client); + expect(inputBalance).toBe(ccc.numFrom(100)); + }); + + it("should handle balanceTweak parameter", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + lock, + type, + }, + ], + outputsData: [ccc.numLeToBytes(100, 16)], // Need 100 UDT + }); + + // Add 50 extra UDT requirement via balanceTweak + const { addedCount } = await udt.completeInputsByBalance(tx, signer, 50); + + // Should add 2 cells to cover 150 UDT total requirement + expect(addedCount).toBe(2); + expect(tx.inputs.length).toBe(2); + + const inputBalance = await udt.getInputsBalance(tx, client); + expect(inputBalance).toBe(ccc.numFrom(200)); + }); + + it("should return 0 when existing inputs already satisfy the requirement", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockUdtCells[0].outPoint, + }, + { + previousOutput: mockUdtCells[1].outPoint, + }, + ], + outputs: [ + { + lock, + type, + }, + ], + outputsData: [ccc.numLeToBytes(150, 16)], // Need 150 UDT, already have 200 + }); + + const { addedCount } = await udt.completeInputsByBalance(tx, signer); + + // Should not add any inputs since we already have enough + expect(addedCount).toBe(0); + expect(tx.inputs.length).toBe(2); + }); + + it("should throw error when insufficient UDT balance available", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + lock, + type, + }, + ], + outputsData: [ccc.numLeToBytes(1500, 16)], // Need 1500 UDT, only have 1000 available + }); + + await expect(udt.completeInputsByBalance(tx, signer)).rejects.toThrow( + "Insufficient coin, need 500 extra coin", + ); + }); + + it("should handle multiple UDT outputs correctly", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + lock, + type, + }, + { + lock, + type, + }, + ], + outputsData: [ + ccc.numLeToBytes(100, 16), // First output: 100 UDT + ccc.numLeToBytes(150, 16), // Second output: 150 UDT + ], // Total: 250 UDT needed + }); + + const { addedCount } = await udt.completeInputsByBalance(tx, signer); + + // Should add 3 cells to cover 250 UDT requirement (300 UDT total) + expect(addedCount).toBe(3); + expect(tx.inputs.length).toBe(3); + + const inputBalance = await udt.getInputsBalance(tx, client); + expect(inputBalance).toBe(ccc.numFrom(300)); + + const outputBalance = await udt.getOutputsBalance(tx, client); + expect(outputBalance).toBe(ccc.numFrom(250)); + }); + + it("should skip cells that are already used as inputs", async () => { + // Pre-add one of the mock cells as input + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockUdtCells[0].outPoint, + }, + ], + outputs: [ + { + lock, + type, + }, + ], + outputsData: [ccc.numLeToBytes(150, 16)], // Need 150 UDT, already have 100 + }); + + const { addedCount } = await udt.completeInputsByBalance(tx, signer); + + // Should add 1 more cell (since we already have 1 input with 100 UDT) + expect(addedCount).toBe(1); + expect(tx.inputs.length).toBe(2); + + const inputBalance = await udt.getInputsBalance(tx, client); + expect(inputBalance).toBe(ccc.numFrom(200)); + }); + + it("should add one cell when user needs less than one cell", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + lock, + type, + }, + ], + outputsData: [ccc.numLeToBytes(50, 16)], // Need only 50 UDT (less than one cell) + }); + + const { addedCount } = await udt.completeInputsByBalance(tx, signer); + + // UDT completeInputsByBalance adds minimum inputs needed + expect(addedCount).toBe(1); + expect(tx.inputs.length).toBe(1); + + const inputBalance = await udt.getInputsBalance(tx, client); + expect(inputBalance).toBe(ccc.numFrom(100)); + }); + }); + + describe("completeInputsAll", () => { + // Mock cells with 100 UDT each (5 cells total = 500 UDT) + let mockUdtCells: ccc.Cell[]; + + beforeEach(async () => { + // Create mock cells after type is initialized + mockUdtCells = Array.from({ length: 5 }, (_, i) => + ccc.Cell.from({ + outPoint: { + txHash: `0x${"a".repeat(63)}${i.toString(16)}`, + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(142 + i * 10), // Varying capacity: 142, 152, 162, 172, 182 + lock, + type, + }, + outputData: ccc.numLeToBytes(100, 16), // 100 UDT tokens each + }), + ); + }); + + beforeEach(() => { + // Mock the findCells method to return our mock UDT cells + vi.spyOn(signer, "findCells").mockImplementation( + async function* (filter) { + if (filter.script && ccc.Script.from(filter.script).eq(type)) { + for (const cell of mockUdtCells) { + yield cell; + } + } + }, + ); + + // Mock client.getCell to return the cell data for inputs + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + const cell = mockUdtCells.find((c) => c.outPoint.eq(outPoint)); + return cell; + }); + }); + + it("should add all available UDT cells to empty transaction", async () => { + const tx = ccc.Transaction.from({ + outputs: [], + }); + + const { tx: completedTx, addedCount } = await udt.completeInputsAll( + tx, + signer, + ); + + // Should add all 5 available UDT cells + expect(addedCount).toBe(5); + expect(completedTx.inputs.length).toBe(5); + + // Verify total UDT balance is 500 (5 cells × 100 UDT each) + const inputBalance = await udt.getInputsBalance(completedTx, client); + expect(inputBalance).toBe(ccc.numFrom(500)); + + // Verify all cells were added by checking outpoints + const addedOutpoints = completedTx.inputs.map( + (input) => input.previousOutput, + ); + for (const cell of mockUdtCells) { + expect(addedOutpoints.some((op) => op.eq(cell.outPoint))).toBe(true); + } + }); + + it("should add all available UDT cells to transaction with outputs", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { lock, type }, + { lock, type }, + ], + outputsData: [ + ccc.numLeToBytes(150, 16), // 150 UDT + ccc.numLeToBytes(200, 16), // 200 UDT + ], // Total: 350 UDT needed + }); + + const { tx: completedTx, addedCount } = await udt.completeInputsAll( + tx, + signer, + ); + + // Should add all 5 available UDT cells regardless of output requirements + expect(addedCount).toBe(5); + expect(completedTx.inputs.length).toBe(5); + + // Verify total UDT balance is 500 (all available) + const inputBalance = await udt.getInputsBalance(completedTx, client); + expect(inputBalance).toBe(ccc.numFrom(500)); + + // Verify output balance is still 350 + const outputBalance = await udt.getOutputsBalance(completedTx, client); + expect(outputBalance).toBe(ccc.numFrom(350)); + + // Should have 150 UDT excess balance (500 - 350) + const balanceBurned = await udt.getBalanceBurned(completedTx, client); + expect(balanceBurned).toBe(ccc.numFrom(150)); + }); + + it("should skip cells already used as inputs", async () => { + // Pre-add 2 of the mock cells as inputs + const tx = ccc.Transaction.from({ + inputs: [ + { previousOutput: mockUdtCells[0].outPoint }, + { previousOutput: mockUdtCells[1].outPoint }, + ], + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(100, 16)], + }); + + const { tx: completedTx, addedCount } = await udt.completeInputsAll( + tx, + signer, + ); + + // Should add the remaining 3 cells (cells 2, 3, 4) + expect(addedCount).toBe(3); + expect(completedTx.inputs.length).toBe(5); // 2 existing + 3 added + + // Verify total UDT balance is still 500 (all 5 cells) + const inputBalance = await udt.getInputsBalance(completedTx, client); + expect(inputBalance).toBe(ccc.numFrom(500)); + }); + + it("should return 0 when all UDT cells are already used as inputs", async () => { + // Pre-add all mock cells as inputs + const tx = ccc.Transaction.from({ + inputs: mockUdtCells.map((cell) => ({ previousOutput: cell.outPoint })), + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(100, 16)], + }); + + const { tx: completedTx, addedCount } = await udt.completeInputsAll( + tx, + signer, + ); + + // Should not add any new inputs + expect(addedCount).toBe(0); + expect(completedTx.inputs.length).toBe(5); // Same as before + + // Verify total UDT balance is still 500 + const inputBalance = await udt.getInputsBalance(completedTx, client); + expect(inputBalance).toBe(ccc.numFrom(500)); + }); + + it("should handle transaction with no UDT outputs", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { lock }, // Non-UDT output + ], + outputsData: ["0x"], + }); + + const { tx: completedTx, addedCount } = await udt.completeInputsAll( + tx, + signer, + ); + + // Should add all 5 UDT cells even though no UDT outputs + expect(addedCount).toBe(5); + expect(completedTx.inputs.length).toBe(5); + + // All 500 UDT will be "burned" since no UDT outputs + const balanceBurned = await udt.getBalanceBurned(completedTx, client); + expect(balanceBurned).toBe(ccc.numFrom(500)); + }); + + it("should work with mixed input types", async () => { + // Create a non-UDT cell + const nonUdtCell = ccc.Cell.from({ + outPoint: { txHash: "0x" + "f".repeat(64), index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(1000), + lock, + // No type script + }, + outputData: "0x", + }); + + // Pre-add the non-UDT cell as input + const tx = ccc.Transaction.from({ + inputs: [{ previousOutput: nonUdtCell.outPoint }], + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(100, 16)], + }); + + // Mock getCell to handle both UDT and non-UDT cells + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + const outPointObj = ccc.OutPoint.from(outPoint); + if (outPointObj.eq(nonUdtCell.outPoint)) { + return nonUdtCell; + } + return mockUdtCells.find((c) => c.outPoint.eq(outPointObj)); + }); + + const { tx: completedTx, addedCount } = await udt.completeInputsAll( + tx, + signer, + ); + + // Should add all 5 UDT cells + expect(addedCount).toBe(5); + expect(completedTx.inputs.length).toBe(6); // 1 non-UDT + 5 UDT + + // Verify only UDT balance is counted + const inputBalance = await udt.getInputsBalance(completedTx, client); + expect(inputBalance).toBe(ccc.numFrom(500)); + }); + + it("should handle empty cell collection gracefully", async () => { + // Mock findCells to return no cells + vi.spyOn(signer, "findCells").mockImplementation(async function* () { + // Return no cells + }); + + const tx = ccc.Transaction.from({ + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(100, 16)], + }); + + const { tx: completedTx, addedCount } = await udt.completeInputsAll( + tx, + signer, + ); + + // Should not add any inputs + expect(addedCount).toBe(0); + expect(completedTx.inputs.length).toBe(0); + + // UDT balance should be 0 + const inputBalance = await udt.getInputsBalance(completedTx, client); + expect(inputBalance).toBe(ccc.numFrom(0)); + }); + }); + + describe("getInputsBalance", () => { + it("should calculate total UDT balance from inputs", async () => { + const mockCells = [ + ccc.Cell.from({ + outPoint: { txHash: "0x" + "0".repeat(64), index: 0 }, + cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, + outputData: ccc.numLeToBytes(100, 16), // 100 UDT + }), + ccc.Cell.from({ + outPoint: { txHash: "0x" + "1".repeat(64), index: 0 }, + cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, + outputData: ccc.numLeToBytes(200, 16), // 200 UDT + }), + ]; + + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + return mockCells.find((c) => c.outPoint.eq(outPoint)); + }); + + const tx = ccc.Transaction.from({ + inputs: [ + { previousOutput: mockCells[0].outPoint }, + { previousOutput: mockCells[1].outPoint }, + ], + }); + + const balance = await udt.getInputsBalance(tx, client); + expect(balance).toBe(ccc.numFrom(300)); // 100 + 200 + }); + + it("should ignore inputs without matching type script", async () => { + const mockCells = [ + ccc.Cell.from({ + outPoint: { txHash: "0x" + "0".repeat(64), index: 0 }, + cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, + outputData: ccc.numLeToBytes(100, 16), // 100 UDT + }), + ccc.Cell.from({ + outPoint: { txHash: "0x" + "1".repeat(64), index: 0 }, + cellOutput: { capacity: ccc.fixedPointFrom(142), lock }, // No type script + outputData: "0x", + }), + ]; + + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + return mockCells.find((c) => c.outPoint.eq(outPoint)); + }); + + const tx = ccc.Transaction.from({ + inputs: [ + { previousOutput: mockCells[0].outPoint }, + { previousOutput: mockCells[1].outPoint }, + ], + }); + + const balance = await udt.getInputsBalance(tx, client); + expect(balance).toBe(ccc.numFrom(100)); // Only the UDT cell + }); + }); + + describe("getOutputsBalance", () => { + it("should calculate total UDT balance from outputs", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { lock, type }, + { lock, type }, + { lock }, // No type script + ], + outputsData: [ + ccc.numLeToBytes(100, 16), // 100 UDT + ccc.numLeToBytes(200, 16), // 200 UDT + "0x", // Not UDT + ], + }); + + const balance = await udt.getOutputsBalance(tx, client); + expect(balance).toBe(ccc.numFrom(300)); // 100 + 200, ignoring non-UDT output + }); + + it("should return 0 when no UDT outputs", async () => { + const tx = ccc.Transaction.from({ + outputs: [{ lock }], // No type script + outputsData: ["0x"], + }); + + const balance = await udt.getOutputsBalance(tx, client); + expect(balance).toBe(ccc.numFrom(0)); + }); + }); + + describe("completeChangeToLock", () => { + let mockUdtCells: ccc.Cell[]; + + beforeEach(() => { + mockUdtCells = Array.from({ length: 5 }, (_, i) => + ccc.Cell.from({ + outPoint: { + txHash: `0x${"0".repeat(63)}${i.toString(16)}`, + index: 0, + }, + cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, + outputData: ccc.numLeToBytes(100, 16), // 100 UDT each + }), + ); + + vi.spyOn(signer, "findCells").mockImplementation( + async function* (filter) { + if (filter.script && ccc.Script.from(filter.script).eq(type)) { + for (const cell of mockUdtCells) { + yield cell; + } + } + }, + ); + + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + return mockUdtCells.find((c) => c.outPoint.eq(outPoint)); + }); + }); + + it("should add change output when there's excess UDT balance", async () => { + const changeLock = ccc.Script.from({ + codeHash: "0x" + "9".repeat(64), + hashType: "type", + args: "0x1234", + }); + + const tx = ccc.Transaction.from({ + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(150, 16)], // Need 150 UDT + }); + + const completedTx = await udt.completeChangeToLock( + tx, + signer, + changeLock, + ); + + // Should have original output + change output + expect(completedTx.outputs.length).toBe(2); + expect(completedTx.outputs[1].lock.eq(changeLock)).toBe(true); + expect(completedTx.outputs[1].type?.eq(type)).toBe(true); + + // Change should be 50 UDT (200 input - 150 output) + const changeAmount = ccc.udtBalanceFrom(completedTx.outputsData[1]); + expect(changeAmount).toBe(ccc.numFrom(50)); + }); + + it("should not add change when no excess balance", async () => { + const changeLock = ccc.Script.from({ + codeHash: "0x" + "9".repeat(64), + hashType: "type", + args: "0x1234", + }); + + const tx = ccc.Transaction.from({ + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(200, 16)], // Need exactly 200 UDT + }); + + const completedTx = await udt.completeChangeToLock( + tx, + signer, + changeLock, + ); + + // Should only have original output + expect(completedTx.outputs.length).toBe(1); + }); + }); + + describe("completeBy", () => { + it("should use signer's recommended address for change", async () => { + const mockUdtCells = Array.from({ length: 3 }, (_, i) => + ccc.Cell.from({ + outPoint: { + txHash: `0x${"0".repeat(63)}${i.toString(16)}`, + index: 0, + }, + cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, + outputData: ccc.numLeToBytes(100, 16), + }), + ); + + vi.spyOn(signer, "findCells").mockImplementation( + async function* (filter) { + if (filter.script && ccc.Script.from(filter.script).eq(type)) { + for (const cell of mockUdtCells) { + yield cell; + } + } + }, + ); + + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + return mockUdtCells.find((c) => c.outPoint.eq(outPoint)); + }); + + const tx = ccc.Transaction.from({ + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(150, 16)], + }); + + const completedTx = await udt.completeBy(tx, signer); + + // Should have change output with signer's lock + expect(completedTx.outputs.length).toBe(2); + expect(completedTx.outputs[1].lock.eq(lock)).toBe(true); // Same as signer's lock + }); + }); + + describe("complete method with capacity handling", () => { + let mockUdtCells: ccc.Cell[]; + + beforeEach(() => { + // Create mock cells with different capacity values + mockUdtCells = [ + // Cell 0: 100 UDT, 142 CKB capacity (minimum for UDT cell) + ccc.Cell.from({ + outPoint: { txHash: "0x" + "0".repeat(64), index: 0 }, + cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, + outputData: ccc.numLeToBytes(100, 16), + }), + // Cell 1: 100 UDT, 200 CKB capacity (extra capacity) + ccc.Cell.from({ + outPoint: { txHash: "0x" + "1".repeat(64), index: 0 }, + cellOutput: { capacity: ccc.fixedPointFrom(200), lock, type }, + outputData: ccc.numLeToBytes(100, 16), + }), + // Cell 2: 100 UDT, 300 CKB capacity (more extra capacity) + ccc.Cell.from({ + outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, + cellOutput: { capacity: ccc.fixedPointFrom(300), lock, type }, + outputData: ccc.numLeToBytes(100, 16), + }), + ]; + + vi.spyOn(signer, "findCells").mockImplementation( + async function* (filter) { + if (filter.script && ccc.Script.from(filter.script).eq(type)) { + for (const cell of mockUdtCells) { + yield cell; + } + } + }, + ); + + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + return mockUdtCells.find((c) => c.outPoint.eq(outPoint)); + }); + }); + + it("should add extra UDT cells when change output requires additional capacity", async () => { + const changeLock = ccc.Script.from({ + codeHash: "0x" + "9".repeat(64), + hashType: "type", + args: "0x1234", + }); + + // Create a transaction that needs 50 UDT (less than one cell) + const tx = ccc.Transaction.from({ + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(50, 16)], + }); + + const completedTx = await udt.completeChangeToLock( + tx, + signer, + changeLock, + ); + + // Should have original output + change output + expect(completedTx.outputs.length).toBe(2); + + // Verify inputs were added to cover both UDT balance and capacity requirements + expect(completedTx.inputs.length).toBe(2); + + // Check that change output has correct UDT balance (should be input - 50) + const changeAmount = ccc.udtBalanceFrom(completedTx.outputsData[1]); + const inputBalance = await udt.getInputsBalance(completedTx, client); + expect(changeAmount).toBe(inputBalance - ccc.numFrom(50)); + + // Verify change output has correct type script + expect(completedTx.outputs[1].type?.eq(type)).toBe(true); + expect(completedTx.outputs[1].lock.eq(changeLock)).toBe(true); + + // Key assertion: verify that capacity is sufficient (positive fee) + const fee = await completedTx.getFee(client); + expect(fee).toBeGreaterThanOrEqual(ccc.Zero); + }); + + it("should handle capacity tweak parameter in completeInputsByBalance", async () => { + const tx = ccc.Transaction.from({ + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(50, 16)], // Need 50 UDT + }); + + // Add extra capacity requirement via capacityTweak that's reasonable + const extraCapacityNeeded = ccc.fixedPointFrom(1000); // Reasonable capacity requirement + const { addedCount } = await udt.completeInputsByBalance( + tx, + signer, + ccc.Zero, // No extra UDT balance needed + extraCapacityNeeded, // Extra capacity needed + ); + + // Should add cells to cover the capacity requirement + expect(addedCount).toBeGreaterThan(2); + + // Should have added at least one cell with capacity + expect(await udt.getInputsBalance(tx, client)).toBeGreaterThan(ccc.Zero); + }); + + it("should handle the two-phase capacity completion in complete method", async () => { + const changeLock = ccc.Script.from({ + codeHash: "0x" + "9".repeat(64), + hashType: "type", + args: "0x1234", + }); + + // Create a transaction that will need change + const tx = ccc.Transaction.from({ + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(50, 16)], // Need 50 UDT, will have 50 UDT change + }); + + // Track the calls to completeInputsByBalance to verify two-phase completion + const completeInputsByBalanceSpy = vi.spyOn( + udt, + "completeInputsByBalance", + ); + + const completedTx = await udt.completeChangeToLock( + tx, + signer, + changeLock, + ); + + // Should have called completeInputsByBalance twice: + // 1. First call: initial UDT balance completion + // 2. Second call: with extraCapacity for change output + expect(completeInputsByBalanceSpy).toHaveBeenCalledTimes(2); + + // Verify the second call included extraCapacity parameter + const secondCall = completeInputsByBalanceSpy.mock.calls[1]; + expect(secondCall[2]).toBe(ccc.Zero); // balanceTweak should be 0 + expect(secondCall[3]).toBeGreaterThan(ccc.Zero); // capacityTweak should be > 0 (change output capacity) + + // Should have change output + expect(completedTx.outputs.length).toBe(2); + const changeAmount = ccc.udtBalanceFrom(completedTx.outputsData[1]); + expect(changeAmount).toBe( + (await udt.getInputsBalance(completedTx, client)) - ccc.numFrom(50), + ); // 100 input - 50 output = 50 change + + completeInputsByBalanceSpy.mockRestore(); + }); + + it("should handle completeChangeToOutput correctly", async () => { + // Create a transaction with an existing UDT output that will receive change + const tx = ccc.Transaction.from({ + outputs: [ + { lock, type }, // This will be the change output + ], + outputsData: [ + ccc.numLeToBytes(50, 16), // Initial amount in change output + ], + }); + + const completedTx = await udt.completeChangeToOutput(tx, signer, 0); // Use first output as change + + // Should have added inputs + expect(completedTx.inputs.length).toBeGreaterThan(0); + + // The first output should now contain the original amount plus any excess from inputs + const changeAmount = ccc.udtBalanceFrom(completedTx.outputsData[0]); + const inputBalance = await udt.getInputsBalance(completedTx, client); + + // Change output should have: original amount + excess from inputs + // Since we only have one output, all input balance should go to it + expect(changeAmount).toBe(inputBalance); + expect(changeAmount).toBeGreaterThan(ccc.numFrom(50)); // More than the original amount + }); + + it("should throw error when change output is not a UDT cell", async () => { + const tx = ccc.Transaction.from({ + outputs: [{ lock }], // No type script - not a UDT cell + outputsData: ["0x"], + }); + + await expect(udt.completeChangeToOutput(tx, signer, 0)).rejects.toThrow( + "Change output must be a UDT cell", + ); + }); + + it("should handle insufficient capacity gracefully", async () => { + // Mock to return cells with very low capacity + const lowCapacityCells = [ + ccc.Cell.from({ + outPoint: { txHash: "0x" + "0".repeat(64), index: 0 }, + cellOutput: { capacity: ccc.fixedPointFrom(61), lock, type }, // Very low capacity + outputData: ccc.numLeToBytes(100, 16), + }), + ]; + + vi.spyOn(signer, "findCells").mockImplementation( + async function* (filter) { + if (filter.script && ccc.Script.from(filter.script).eq(type)) { + for (const cell of lowCapacityCells) { + yield cell; + } + } + }, + ); + + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + return lowCapacityCells.find((c) => c.outPoint.eq(outPoint)); + }); + + const changeLock = ccc.Script.from({ + codeHash: "0x" + "9".repeat(64), + hashType: "type", + args: "0x1234", + }); + + const tx = ccc.Transaction.from({ + outputs: [{ lock, type }], + outputsData: [ccc.numLeToBytes(50, 16)], + }); + + // Should still complete successfully even with capacity constraints + // The UDT logic should focus on UDT balance completion + const completedTx = await udt.completeChangeToLock( + tx, + signer, + changeLock, + ); + + expect(completedTx.inputs.length).toBe(1); + expect(completedTx.outputs.length).toBe(2); // Original + change + + expect(await completedTx.getFee(client)).toBeLessThan(0n); + }); + + it("should handle capacity calculation when transaction has non-UDT inputs with high capacity", async () => { + // Create a non-UDT cell with very high capacity + const nonUdtCell = ccc.Cell.from({ + outPoint: { txHash: "0x" + "f".repeat(64), index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(10000), // Very high capacity (100 CKB) + lock, + // No type script - this is a regular CKB cell + }, + outputData: "0x", // Empty data + }); + + // Create a transaction that already has the non-UDT input + const tx = ccc.Transaction.from({ + inputs: [ + { previousOutput: nonUdtCell.outPoint }, // Pre-existing non-UDT input + ], + outputs: [ + { lock, type }, // UDT output requiring 50 UDT + ], + outputsData: [ + ccc.numLeToBytes(50, 16), // Need 50 UDT + ], + }); + + // Mock getCell to return both UDT and non-UDT cells + vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { + const outPointObj = ccc.OutPoint.from(outPoint); + if (outPointObj.eq(nonUdtCell.outPoint)) { + return nonUdtCell; + } + return mockUdtCells.find((c) => c.outPoint.eq(outPointObj)); + }); + + const resultTx = await udt.completeBy(tx, signer); + + // Should add exactly 2 UDT cell to satisfy the 50 UDT requirement & extra occupation from the change cell + expect(resultTx.inputs.length).toBe(3); // 1 non-UDT + 2 UDT + + // Verify UDT balance is satisfied + const inputBalance = await udt.getInputsBalance(resultTx, client); + expect(inputBalance).toBe(ccc.numFrom(200)); + }); + }); +}); diff --git a/packages/udt/src/udt/index.ts b/packages/udt/src/udt/index.ts index 3df9501ea..13350e8f8 100644 --- a/packages/udt/src/udt/index.ts +++ b/packages/udt/src/udt/index.ts @@ -1,6 +1,194 @@ import { ccc } from "@ckb-ccc/core"; import { ssri } from "@ckb-ccc/ssri"; +/** + * Error thrown when there are insufficient UDT coins to complete a transaction. + * This error provides detailed information about the shortfall, including the + * exact amount needed, the UDT type script, and an optional custom reason. + * + * @public + * @category Error + * @category UDT + * + * @example + * ```typescript + * // This error is typically thrown automatically by UDT methods + * try { + * await udt.completeInputsByBalance(tx, signer); + * } catch (error) { + * if (error instanceof ErrorUdtInsufficientCoin) { + * console.log(`Error: ${error.message}`); + * console.log(`Shortfall: ${error.amount} UDT tokens`); + * console.log(`UDT type script: ${error.type.toHex()}`); + * } + * } + * ``` + */ +export class ErrorUdtInsufficientCoin extends Error { + /** + * The amount of UDT coins that are insufficient (shortfall amount). + * This represents how many more UDT tokens are needed to complete the operation. + */ + public readonly amount: ccc.Num; + + /** + * The type script of the UDT that has insufficient balance. + * This identifies which specific UDT token is lacking sufficient funds. + */ + public readonly type: ccc.Script; + + /** + * Creates a new ErrorUdtInsufficientCoin instance. + * + * @param info - Configuration object for the error + * @param info.amount - The amount of UDT coins that are insufficient (shortfall amount) + * @param info.type - The type script of the UDT that has insufficient balance + * @param info.reason - Optional custom reason message. If not provided, a default message will be generated + * + * @example + * ```typescript + * // Manual creation (typically not needed as the error is thrown automatically) + * const error = new ErrorUdtInsufficientCoin({ + * amount: ccc.numFrom(1000), + * type: udtScript, + * reason: "Custom insufficient balance message" + * }); + * + * // More commonly, catch the error when it's thrown by UDT methods + * try { + * const result = await udt.completeInputsByBalance(tx, signer); + * } catch (error) { + * if (error instanceof ErrorUdtInsufficientCoin) { + * // Handle the insufficient balance error + * console.error(`Insufficient UDT: need ${error.amount} more tokens`); + * } + * } + * ``` + * + * @remarks + * The error message format depends on whether a custom reason is provided: + * - With custom reason: "Insufficient coin, {custom reason}" + * - Without custom reason: "Insufficient coin, need {amount} extra coin" + */ + constructor(info: { + amount: ccc.NumLike; + type: ccc.ScriptLike; + reason?: string; + }) { + const amount = ccc.numFrom(info.amount); + const type = ccc.Script.from(info.type); + super(`Insufficient coin, ${info.reason ?? `need ${amount} extra coin`}`); + this.amount = amount; + this.type = type; + } +} + +/** + * Configuration object type for UDT instances. + * This type defines the optional configuration parameters that can be passed + * when creating a UDT instance to customize its behavior. + * + * @public + * @category Configuration + * @category UDT + */ +export type UdtConfigLike = { + /** + * Optional SSRI executor instance for advanced UDT operations. + * When provided, enables SSRI-compliant features like metadata queries + * and advanced transfer operations. + */ + executor?: ssri.Executor | null; + + /** + * Optional custom search filter for finding UDT cells. + * If not provided, a default filter will be created that matches + * cells with the UDT's type script and valid output data length. + */ + filter?: ccc.ClientIndexerSearchKeyFilterLike | null; +}; + +/** + * Configuration class for UDT instances. + * This class provides a structured way to handle UDT configuration parameters + * and includes factory methods for creating instances from configuration-like objects. + * + * @public + * @category Configuration + * @category UDT + * + * @example + * ```typescript + * // Create configuration with executor + * const config = new UdtConfig(ssriExecutor); + * + * // Create configuration with both executor and filter + * const config = new UdtConfig( + * ssriExecutor, + * ccc.ClientIndexerSearchKeyFilter.from({ + * script: udtScript, + * outputDataLenRange: [16, 32] + * }) + * ); + * + * // Create from configuration-like object + * const config = UdtConfig.from({ + * executor: ssriExecutor, + * filter: { script: udtScript, outputDataLenRange: [16, "0xffffffff"] } + * }); + * ``` + */ +export class UdtConfig { + /** + * Creates a new UdtConfig instance. + * + * @param executor - Optional SSRI executor for advanced UDT operations + * @param filter - Optional search filter for finding UDT cells + */ + constructor( + public readonly executor?: ssri.Executor, + public readonly filter?: ccc.ClientIndexerSearchKeyFilter, + ) {} + + /** + * Creates a UdtConfig instance from a configuration-like object. + * This factory method provides a convenient way to create UdtConfig instances + * from plain objects, automatically converting filter-like objects to proper + * ClientIndexerSearchKeyFilter instances. + * + * @param configLike - Configuration-like object containing executor and/or filter + * @returns A new UdtConfig instance with the specified configuration + * + * @example + * ```typescript + * // Create from object with executor only + * const config = UdtConfig.from({ executor: ssriExecutor }); + * + * // Create from object with filter only + * const config = UdtConfig.from({ + * filter: { + * script: udtScript, + * outputDataLenRange: [16, "0xffffffff"] + * } + * }); + * + * // Create from object with both + * const config = UdtConfig.from({ + * executor: ssriExecutor, + * filter: { script: udtScript, outputDataLenRange: [16, 32] } + * }); + * ``` + */ + static from(configLike: UdtConfigLike) { + return new UdtConfig( + configLike.executor ?? undefined, + configLike.filter + ? ccc.ClientIndexerSearchKeyFilter.from(configLike.filter) + : undefined, + ); + } +} + /** * Represents a User Defined Token (UDT) script compliant with the SSRI protocol. * @@ -13,35 +201,144 @@ import { ssri } from "@ckb-ccc/ssri"; * @category Token */ export class Udt extends ssri.Trait { + /** + * The type script that uniquely identifies this UDT token. + * This script is used to distinguish UDT cells from other cell types and + * to identify which cells belong to this specific UDT token. + * + * @remarks + * The script contains: + * - `codeHash`: Hash of the UDT script code + * - `hashType`: How the code hash should be interpreted ("type" or "data") + * - `args`: Arguments that make this UDT unique (often contains token-specific data) + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * console.log(`UDT script hash: ${udt.script.hash()}`); + * console.log(`UDT args: ${udt.script.args}`); + * + * // Check if a cell belongs to this UDT + * const isUdt = udt.isUdt(cell); + * ``` + */ public readonly script: ccc.Script; + /** + * The search filter used to find UDT cells controlled by signers. + * This filter is automatically configured to match cells with this UDT's type script + * and appropriate output data length (minimum 16 bytes for UDT balance storage). + * + * @remarks + * The filter includes: + * - `script`: Set to this UDT's type script + * - `outputDataLenRange`: [16, "0xffffffff"] to ensure valid UDT cells + * + * This filter is used internally by methods like: + * - `calculateInfo()` and `calculateBalance()` for scanning all UDT cells + * - `completeInputs()` and related methods for finding suitable input cells + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * + * // The filter is used internally, but you can access it if needed + * console.log(`Filter script: ${udt.filter.script?.hash()}`); + * console.log(`Output data range: ${udt.filter.outputDataLenRange}`); + * + * // Manually find cells using the same filter + * for await (const cell of signer.findCells(udt.filter)) { + * console.log(`Found UDT cell with balance: ${ccc.udtBalanceFrom(cell.outputData)}`); + * } + * ``` + */ + public readonly filter: ccc.ClientIndexerSearchKeyFilter; + /** * Constructs a new UDT (User Defined Token) script instance. - * By default it is a SSRI-compliant UDT. By providing `xudtType`, it is compatible with the legacy xUDT. + * By default it is a SSRI-compliant UDT. This class supports both SSRI-compliant UDTs and legacy sUDT/xUDT standard tokens. + * + * @param code - The script code cell outpoint of the UDT. This points to the cell containing the UDT script code + * @param script - The type script of the UDT that uniquely identifies this token + * @param config - Optional configuration object for advanced settings + * @param config.executor - The SSRI executor instance for advanced UDT operations. If provided, enables SSRI-compliant features + * @param config.filter - Custom search filter for finding UDT cells. If not provided, a default filter will be created * - * @param executor - The SSRI executor instance. - * @param code - The script code cell of the UDT. - * @param script - The type script of the UDT. * @example * ```typescript - * const udt = new Udt(executor, code, script); + * // Basic UDT instance + * const udt = new Udt( + * { txHash: "0x...", index: 0 }, // code outpoint + * { codeHash: "0x...", hashType: "type", args: "0x..." } // type script + * ); + * + * // UDT with SSRI executor for advanced features + * const ssriUdt = new Udt( + * codeOutPoint, + * typeScript, + * { executor: ssriExecutor } + * ); + * + * // UDT with custom filter (advanced usage) + * const customUdt = new Udt( + * codeOutPoint, + * typeScript, + * { + * filter: { + * script: typeScript, + * outputDataLenRange: [16, 32], // Only cells with 16-32 bytes output data + * } + * } + * ); * ``` + * + * @remarks + * **Default Filter Behavior:** + * If no custom filter is provided, a default filter is created with: + * - `script`: Set to the provided UDT type script + * - `outputDataLenRange`: [16, "0xffffffff"] to match valid UDT cells + * + * **SSRI Compliance:** + * When an executor is provided, the UDT instance can use SSRI-compliant features like: + * - Advanced transfer operations + * - Metadata queries (name, symbol, decimals, icon) + * - Custom UDT logic execution + * + * **Legacy Support:** + * Even without an executor, the UDT class supports basic operations for legacy sUDT/xUDT tokens. */ constructor( code: ccc.OutPointLike, script: ccc.ScriptLike, - config?: { - executor?: ssri.Executor | null; - } | null, + config?: UdtConfigLike | null, ) { super(code, config?.executor); this.script = ccc.Script.from(script); + this.filter = ccc.ClientIndexerSearchKeyFilter.from( + config?.filter ?? { + script: this.script, + outputDataLenRange: [16, "0xffffffff"], + }, + ); } /** * Retrieves the human-readable name of the User Defined Token. + * This method queries the UDT script to get the token's display name, + * which is typically used in user interfaces and wallets. * - * @returns A promise resolving to the token's name. + * @param context - Optional script execution context for additional parameters + * @returns A promise resolving to an ExecutorResponse containing the token's name, + * or undefined if the name is not available or the script doesn't support this method + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const nameResponse = await udt.name(); + * if (nameResponse.res) { + * console.log(`Token name: ${nameResponse.res}`); + * } + * ``` */ async name( context?: ssri.ContextScript, @@ -60,8 +357,22 @@ export class Udt extends ssri.Trait { } /** - * Retrieves the symbol of the UDT. - * @returns The symbol of the UDT. + * Retrieves the symbol (ticker) of the User Defined Token. + * The symbol is typically a short abbreviation used to identify the token, + * similar to stock ticker symbols (e.g., "BTC", "ETH", "USDT"). + * + * @param context - Optional script execution context for additional parameters + * @returns A promise resolving to an ExecutorResponse containing the token's symbol, + * or undefined if the symbol is not available or the script doesn't support this method + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const symbolResponse = await udt.symbol(); + * if (symbolResponse.res) { + * console.log(`Token symbol: ${symbolResponse.res}`); + * } + * ``` */ async symbol( context?: ssri.ContextScript, @@ -85,8 +396,24 @@ export class Udt extends ssri.Trait { } /** - * Retrieves the decimals of the UDT. - * @returns The decimals of the UDT. + * Retrieves the number of decimal places for the User Defined Token. + * This value determines how the token amount should be displayed and interpreted. + * For example, if decimals is 8, then a balance of 100000000 represents 1.0 tokens. + * + * @param context - Optional script execution context for additional parameters + * @returns A promise resolving to an ExecutorResponse containing the number of decimals, + * or undefined if decimals are not specified or the script doesn't support this method + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const decimalsResponse = await udt.decimals(); + * if (decimalsResponse.res !== undefined) { + * console.log(`Token decimals: ${decimalsResponse.res}`); + * // Convert raw amount to human-readable format + * const humanReadable = rawAmount / (10 ** Number(decimalsResponse.res)); + * } + * ``` */ async decimals( context?: ssri.ContextScript, @@ -110,8 +437,25 @@ export class Udt extends ssri.Trait { } /** - * Retrieves the icon of the UDT - * @returns The icon of the UDT. + * Retrieves the icon URL or data URI for the User Defined Token. + * This can be used to display a visual representation of the token in user interfaces. + * The returned value may be a URL pointing to an image file or a data URI containing + * the image data directly. + * + * @param context - Optional script execution context for additional parameters + * @returns A promise resolving to an ExecutorResponse containing the icon URL/data, + * or undefined if no icon is available or the script doesn't support this method + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const iconResponse = await udt.icon(); + * if (iconResponse.res) { + * // Use the icon in UI + * const imgElement = document.createElement('img'); + * imgElement.src = iconResponse.res; + * } + * ``` */ async icon( context?: ssri.ContextScript, @@ -129,13 +473,67 @@ export class Udt extends ssri.Trait { return ssri.ExecutorResponse.new(undefined); } + /** + * Adds the UDT script code as a cell dependency to the transaction. + * This method ensures that the transaction includes the necessary cell dependency + * for the UDT script code, which is required for any transaction that uses this UDT. + * + * @param txLike - The transaction to add the cell dependency to + * @returns A new transaction with the UDT code cell dependency added + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * + * // Create a basic transaction + * let tx = ccc.Transaction.from({ + * outputs: [{ lock: recipientLock, type: udt.script }], + * outputsData: [ccc.numLeToBytes(100, 16)] + * }); + * + * // Add UDT code dependency + * tx = udt.addCellDeps(tx); + * + * // Now the transaction can be completed and sent + * await tx.completeInputsByCapacity(signer); + * await tx.completeFeeBy(signer); + * ``` + * + * @remarks + * **When to Use:** + * - When manually constructing transactions that involve UDT cells + * - Before sending any transaction that creates or consumes UDT cells + * - This is automatically called by methods like `transfer()` and `mint()` + * + * **Cell Dependency Details:** + * - Adds the UDT script code outpoint as a "code" type dependency + * - This allows the transaction to reference and execute the UDT script + * - Required for script validation during transaction processing + * + * **Note:** Most high-level UDT methods automatically add this dependency, + * so manual usage is typically only needed for custom transaction construction. + */ + addCellDeps(txLike: ccc.TransactionLike): ccc.Transaction { + const tx = ccc.Transaction.from(txLike); + tx.addCellDeps({ + outPoint: this.code, + depType: "code", + }); + return tx; + } + /** * Transfers UDT to specified addresses. - * @param tx - Transfer on the basis of an existing transaction to achieve combined actions. If not provided, a new transaction will be created. - * @param transfers - The array of transfers. - * @param transfers.to - The receiver of token. - * @param transfers.amount - The amount of token to the receiver. - * @returns The transaction result. + * This method creates a transaction that transfers UDT tokens to one or more recipients. + * It can build upon an existing transaction to achieve combined actions. + * + * @param signer - The signer that will authorize and potentially pay for the transaction + * @param transfers - Array of transfer operations to perform + * @param transfers.to - The lock script of the recipient who will receive the tokens + * @param transfers.amount - The amount of tokens to transfer to this recipient (in smallest unit) + * @param tx - Optional existing transaction to build upon. If not provided, a new transaction will be created + * @returns A promise resolving to an ExecutorResponse containing the transaction with transfer operations + * * @tag Mutation - This method represents a mutation of the onchain state and will return a transaction object. * @example * ```typescript @@ -154,12 +552,12 @@ export class Udt extends ssri.Trait { * }, * ); * - * const { res: tx } = await udtTrait.transfer( + * const { res: tx } = await udt.transfer( * signer, * [{ to, amount: 100 }], * ); * - * const completedTx = udt.completeUdtBy(tx, signer); + * const completedTx = await udt.completeBy(tx, signer); * await completedTx.completeInputsByCapacity(signer); * await completedTx.completeFeeBy(signer); * const transferTxHash = await signer.sendTransaction(completedTx); @@ -208,21 +606,47 @@ export class Udt extends ssri.Trait { } resTx = ssri.ExecutorResponse.new(transfer); } - resTx.res.addCellDeps({ - outPoint: this.code, - depType: "code", - }); - return resTx; + + return resTx.map((tx) => this.addCellDeps(tx)); } /** - * Mints new tokens to specified addresses. See the example in `transfer` as they are similar. - * @param tx - Optional existing transaction to build upon - * @param mints - Array of mints - * @param mints.to - receiver of token - * @param mints.amount - amount to the receiver - * @returns The transaction containing the mint operation + * Mints new tokens to specified addresses. + * This method creates new UDT tokens and assigns them to the specified recipients. + * The minting operation requires appropriate permissions and may be restricted + * based on the UDT's implementation. + * + * @param signer - The signer that will authorize and potentially pay for the transaction + * @param mints - Array of mint operations to perform + * @param mints.to - The lock script of the recipient who will receive the minted tokens + * @param mints.amount - The amount of tokens to mint for this recipient (in smallest unit) + * @param tx - Optional existing transaction to build upon. If not provided, a new transaction will be created + * @returns A promise resolving to an ExecutorResponse containing the transaction with mint operations + * * @tag Mutation - This method represents a mutation of the onchain state + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const { script: recipientLock } = await ccc.Address.fromString(recipientAddress, signer.client); + * + * const mintResponse = await udt.mint( + * signer, + * [ + * { to: recipientLock, amount: ccc.fixedPointFrom(1000) }, // Mint 1000 tokens + * { to: anotherLock, amount: ccc.fixedPointFrom(500) } // Mint 500 tokens + * ] + * ); + * + * // Complete the transaction + * const tx = mintResponse.res; + * await tx.completeInputsByCapacity(signer); + * await tx.completeFeeBy(signer, changeLock); + * + * const txHash = await signer.sendTransaction(tx); + * ``` + * + * @throws May throw if the signer doesn't have minting permissions or if the UDT doesn't support minting */ async mint( signer: ccc.Signer, @@ -267,40 +691,803 @@ export class Udt extends ssri.Trait { } resTx = ssri.ExecutorResponse.new(mint); } - resTx.res.addCellDeps({ - outPoint: this.code, - depType: "code", + + return resTx.map((tx) => this.addCellDeps(tx)); + } + + /** + * Checks if a cell is a valid UDT cell for this token. + * A valid UDT cell must have this UDT's type script and contain at least 16 bytes of output data + * (the minimum required for storing the UDT balance as a 128-bit little-endian integer). + * + * @param cellOutputLike - The cell output to check + * @param outputData - The output data of the cell + * @returns True if the cell is a valid UDT cell for this token, false otherwise + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const cellOutput = { lock: someLock, type: udt.script }; + * const outputData = ccc.numLeToBytes(1000, 16); // 1000 UDT balance + * + * const isValid = udt.isUdt({ cellOutput, outputData }); + * console.log(`Is valid UDT cell: ${isValid}`); // true + * ``` + * + * @remarks + * The method checks two conditions: + * 1. The cell's type script matches this UDT's script + * 2. The output data is at least 16 bytes long (required for UDT balance storage) + */ + isUdt(cell: { cellOutput: ccc.CellOutputLike; outputData: ccc.HexLike }) { + return ( + (ccc.CellOutput.from(cell.cellOutput).type?.eq(this.script) ?? false) && + ccc.bytesFrom(cell.outputData).length >= 16 + ); + } + + /** + * Retrieves comprehensive information about UDT inputs in a transaction. + * This method analyzes all input cells and returns detailed statistics including + * total UDT balance, total capacity occupied, and the number of UDT cells. + * + * @param txLike - The transaction to analyze + * @param client - The client to fetch input cell data + * @returns A promise resolving to an object containing: + * - balance: Total UDT balance from all input cells + * - capacity: Total capacity occupied by all UDT input cells + * - count: Number of UDT input cells + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const tx = ccc.Transaction.from(existingTransaction); + * + * const inputsInfo = await udt.getInputsInfo(tx, client); + * console.log(`UDT inputs: ${inputsInfo.count} cells`); + * console.log(`Total UDT balance: ${inputsInfo.balance}`); + * console.log(`Total capacity: ${inputsInfo.capacity}`); + * ``` + * + * @remarks + * This method provides more comprehensive information than `getInputsBalance`, + * making it useful for transaction analysis, fee calculation, and UI display. + * Only cells with this UDT's type script are included in the statistics. + */ + async getInputsInfo( + txLike: ccc.TransactionLike, + client: ccc.Client, + ): Promise<{ + balance: ccc.Num; + capacity: ccc.Num; + count: number; + }> { + const tx = ccc.Transaction.from(txLike); + const [balance, capacity, count] = await ccc.reduceAsync( + tx.inputs, + async (acc, input) => { + const { cellOutput, outputData } = await input.getCell(client); + if (!this.isUdt({ cellOutput, outputData })) { + return acc; + } + + return [ + acc[0] + ccc.udtBalanceFrom(outputData), + acc[1] + cellOutput.capacity, + acc[2] + 1, + ]; + }, + [ccc.Zero, ccc.Zero, 0], + ); + + return { + balance, + capacity, + count, + }; + } + + /** + * Calculates the total UDT balance from all inputs in a transaction. + * This method examines each input cell and sums up the UDT amounts + * for cells that have this UDT's type script. + * + * @param txLike - The transaction to analyze + * @param client - The client to fetch input cell data + * @returns A promise resolving to the total UDT balance from all inputs + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const tx = ccc.Transaction.from(existingTransaction); + * + * const inputBalance = await udt.getInputsBalance(tx, client); + * console.log(`Total UDT input balance: ${inputBalance}`); + * ``` + * + * @remarks + * This method only counts inputs that have the same type script as this UDT instance. + * Inputs without a type script or with different type scripts are ignored. + */ + async getInputsBalance( + txLike: ccc.TransactionLike, + client: ccc.Client, + ): Promise { + return (await this.getInputsInfo(txLike, client)).balance; + } + + /** + * Retrieves comprehensive information about UDT outputs in a transaction. + * This method analyzes all output cells and returns detailed statistics including + * total UDT balance, total capacity occupied, and the number of UDT cells. + * + * @param txLike - The transaction to analyze + * @param _client - The client parameter (unused for outputs since data is already available) + * @returns A promise resolving to an object containing: + * - balance: Total UDT balance from all output cells + * - capacity: Total capacity occupied by all UDT output cells + * - count: Number of UDT output cells + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const tx = ccc.Transaction.from({ + * outputs: [ + * { lock: recipientLock, type: udt.script }, + * { lock: changeLock, type: udt.script } + * ], + * outputsData: [ + * ccc.numLeToBytes(1000, 16), // 1000 UDT to recipient + * ccc.numLeToBytes(500, 16) // 500 UDT as change + * ] + * }); + * + * const outputsInfo = await udt.getOutputsInfo(tx, client); + * console.log(`UDT outputs: ${outputsInfo.count} cells`); + * console.log(`Total UDT balance: ${outputsInfo.balance}`); // 1500 + * console.log(`Total capacity: ${outputsInfo.capacity}`); + * ``` + * + * @remarks + * This method provides more comprehensive information than `getOutputsBalance`, + * making it useful for transaction validation, analysis, and UI display. + * Only cells with this UDT's type script are included in the statistics. + * This is an async method for consistency with `getInputsInfo`, though it doesn't + * actually need to fetch data since output information is already available. + */ + async getOutputsInfo( + txLike: ccc.TransactionLike, + _client: ccc.Client, + ): Promise<{ + balance: ccc.Num; + capacity: ccc.Num; + count: number; + }> { + const tx = ccc.Transaction.from(txLike); + const [balance, capacity, count] = tx.outputs.reduce( + (acc, output, i) => { + if ( + !this.isUdt({ cellOutput: output, outputData: tx.outputsData[i] }) + ) { + return acc; + } + + return [ + acc[0] + ccc.udtBalanceFrom(tx.outputsData[i]), + acc[1] + output.capacity, + acc[2] + 1, + ]; + }, + [ccc.Zero, ccc.Zero, 0], + ); + + return { + balance, + capacity, + count, + }; + } + + /** + * Calculates the total UDT balance from all outputs in a transaction. + * This method examines each output cell and sums up the UDT amounts + * for cells that have this UDT's type script. + * + * @param txLike - The transaction to analyze + * @param client - The client parameter (passed to getOutputsInfo for consistency) + * @returns A promise resolving to the total UDT balance from all outputs + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const tx = ccc.Transaction.from({ + * outputs: [ + * { lock: recipientLock, type: udt.script }, + * { lock: changeLock, type: udt.script } + * ], + * outputsData: [ + * ccc.numLeToBytes(1000, 16), // 1000 UDT to recipient + * ccc.numLeToBytes(500, 16) // 500 UDT as change + * ] + * }); + * + * const outputBalance = await udt.getOutputsBalance(tx, client); + * console.log(`Total UDT output balance: ${outputBalance}`); // 1500 + * ``` + * + * @remarks + * This method only counts outputs that have the same type script as this UDT instance. + * Outputs without a type script or with different type scripts are ignored. + * This method is a convenience wrapper around `getOutputsInfo` that returns only the balance. + */ + async getOutputsBalance( + txLike: ccc.TransactionLike, + client: ccc.Client, + ): Promise { + return (await this.getOutputsInfo(txLike, client)).balance; + } + + /** + * Calculates the net UDT balance that would be burned (destroyed) in a transaction. + * This is the difference between the total UDT balance in inputs and outputs. + * A positive value indicates UDT tokens are being burned, while a negative value + * indicates more UDT is being created than consumed (which may require minting permissions). + * + * @param txLike - The transaction to analyze + * @param client - The client to fetch input cell data + * @returns A promise resolving to the net UDT balance burned (inputs - outputs) + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * const tx = ccc.Transaction.from(existingTransaction); + * + * const burned = await udt.getBalanceBurned(tx, client); + * if (burned > 0) { + * console.log(`${burned} UDT tokens will be burned`); + * } else if (burned < 0) { + * console.log(`${-burned} UDT tokens will be created`); + * } else { + * console.log('UDT balance is conserved'); + * } + * ``` + * + * @remarks + * This method is useful for: + * - Validating transaction balance conservation + * - Calculating how much UDT is being destroyed in burn operations + * - Detecting minting operations (negative burned balance) + * - Ensuring sufficient UDT inputs are provided for transfers + */ + async getBalanceBurned( + txLike: ccc.TransactionLike, + client: ccc.Client, + ): Promise { + const tx = ccc.Transaction.from(txLike); + return ( + (await this.getInputsBalance(tx, client)) - + (await this.getOutputsBalance(tx, client)) + ); + } + + /** + * Low-level method to complete UDT inputs for a transaction using a custom accumulator function. + * This method provides maximum flexibility for input selection by allowing custom logic + * through the accumulator function. It's primarily used internally by other completion methods. + * + * @template T - The type of the accumulator value + * @param txLike - The transaction to complete with UDT inputs + * @param from - The signer that will provide UDT inputs + * @param accumulator - Function that determines when to stop adding inputs based on accumulated state + * @param init - Initial value for the accumulator + * @returns A promise resolving to an object containing: + * - tx: The transaction with added inputs + * - addedCount: Number of inputs that were added + * - accumulated: Final accumulator value (undefined if target was reached) + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * + * // Custom accumulator to track both balance and capacity + * const result = await udt.completeInputs( + * tx, + * signer, + * ([balanceAcc, capacityAcc], cell) => { + * const balance = ccc.udtBalanceFrom(cell.outputData); + * const newBalance = balanceAcc + balance; + * const newCapacity = capacityAcc + cell.cellOutput.capacity; + * + * // Stop when we have enough balance and capacity + * return newBalance >= requiredBalance && newCapacity >= requiredCapacity + * ? undefined // Stop adding inputs + * : [newBalance, newCapacity]; // Continue with updated accumulator + * }, + * [ccc.Zero, ccc.Zero] // Initial [balance, capacity] + * ); + * ``` + * + * @remarks + * This is a low-level method that most users won't need to call directly. + * Use `completeInputsByBalance` for typical UDT input completion needs. + * The accumulator function should return `undefined` to stop adding inputs, + * or return an updated accumulator value to continue. + */ + async completeInputs( + txLike: ccc.TransactionLike, + from: ccc.Signer, + accumulator: ( + acc: T, + v: ccc.Cell, + i: number, + array: ccc.Cell[], + ) => Promise | T | undefined, + init: T, + ): Promise<{ + tx: ccc.Transaction; + addedCount: number; + accumulated?: T; + }> { + const tx = ccc.Transaction.from(txLike); + const res = await tx.completeInputs(from, this.filter, accumulator, init); + + return { + ...res, + tx, + }; + } + + /** + * Completes UDT inputs for a transaction to satisfy both UDT balance and capacity requirements. + * This method implements intelligent input selection that considers both UDT token balance + * and cell capacity constraints, optimizing for minimal cell usage while meeting all requirements. + * It uses sophisticated balance calculations and early exit optimizations for efficiency. + * + * @param txLike - The transaction to complete with UDT inputs + * @param from - The signer that will provide UDT inputs + * @param balanceTweak - Optional additional UDT balance requirement beyond outputs (default: 0) + * @param capacityTweak - Optional additional CKB capacity requirement beyond outputs (default: 0) + * @returns A promise resolving to an object containing: + * - tx: The modified transaction with added UDT inputs + * - addedCount: Number of UDT input cells that were added + * + * @throws {ErrorUdtInsufficientCoin} When there are insufficient UDT cells to cover the required balance + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * + * // Basic usage: add inputs to cover UDT outputs + * const tx = ccc.Transaction.from({ + * outputs: [{ lock: recipientLock, type: udt.script }], + * outputsData: [ccc.numLeToBytes(1000, 16)] + * }); + * + * const { tx: completedTx, addedCount } = await udt.completeInputsByBalance(tx, signer); + * console.log(`Added ${addedCount} UDT inputs to cover 1000 UDT requirement`); + * + * // Advanced usage: with balance and capacity tweaks + * const { tx: advancedTx, addedCount: advancedCount } = await udt.completeInputsByBalance( + * tx, + * signer, + * ccc.numFrom(100), // Extra 100 UDT balance needed + * ccc.fixedPointFrom(5000) // Extra 5000 capacity needed + * ); + * ``` + * + * @remarks + * This method implements sophisticated dual-constraint input selection with the following logic: + * + * **Balance Calculations:** + * - UDT balance deficit: `inputBalance - outputBalance - balanceTweak` + * - Capacity balance with fee optimization: `min(inputCapacity - outputCapacity, estimatedFee) - capacityTweak` + * - The capacity calculation tries to avoid extra occupation by UDT cells and compress UDT state + * + * **Early Exit Optimization:** + * - Returns immediately with `addedCount: 0` if both balance and capacity constraints are satisfied + * - Avoids unnecessary input addition when existing inputs are sufficient + * + * **Smart Input Selection:** + * - Uses accumulator pattern to track both UDT balance and capacity during selection + * - Continues adding inputs until both constraints are satisfied: `balanceAcc >= 0 && capacityAcc >= 0` + * - Prioritizes providing sufficient capacity through UDT cells to avoid extra non-UDT inputs + * + * **Error Handling:** + * - Throws `ErrorUdtInsufficientCoin` with exact shortfall amount if insufficient UDT balance + * - Only throws error if UDT balance cannot be satisfied (capacity issues don't cause errors) + */ + async completeInputsByBalance( + txLike: ccc.TransactionLike, + from: ccc.Signer, + balanceTweak?: ccc.NumLike, + capacityTweak?: ccc.NumLike, + ): Promise<{ + addedCount: number; + tx: ccc.Transaction; + }> { + const tx = ccc.Transaction.from(txLike); + const { balance: inBalance, capacity: inCapacity } = + await this.getInputsInfo(tx, from.client); + const { balance: outBalance, capacity: outCapacity } = + await this.getOutputsInfo(tx, from.client); + + const balanceBurned = + inBalance - outBalance - ccc.numFrom(balanceTweak ?? 0); + // Try to avoid extra occupation by UDT and also try to compress UDT state + const capacityBurned = + ccc.numMin(inCapacity - outCapacity, await tx.getFee(from.client)) - + ccc.numFrom(capacityTweak ?? 0); + + if (balanceBurned >= ccc.Zero && capacityBurned >= ccc.Zero) { + return { addedCount: 0, tx }; + } + + const { + tx: txRes, + addedCount, + accumulated, + } = await this.completeInputs( + tx, + from, + ([balanceAcc, capacityAcc], { cellOutput: { capacity }, outputData }) => { + const balance = ccc.udtBalanceFrom(outputData); + const balanceBurned = balanceAcc + balance; + const capacityBurned = capacityAcc + capacity; + + // Try to provide enough capacity with UDT cells to avoid extra occupation + return balanceBurned >= ccc.Zero && capacityBurned >= ccc.Zero + ? undefined + : [balanceBurned, capacityBurned]; + }, + [balanceBurned, capacityBurned], + ); + + if (accumulated === undefined || accumulated[0] >= ccc.Zero) { + return { tx: txRes, addedCount }; + } + + throw new ErrorUdtInsufficientCoin({ + amount: -accumulated[0], + type: this.script, }); - return resTx; } - async completeChangeToLock( + /** + * Adds ALL available UDT cells from the signer as inputs to the transaction. + * Unlike `completeInputsByBalance` which adds only the minimum required inputs, + * this method collects every available UDT cell that the signer controls, + * regardless of the transaction's actual UDT requirements. + * + * @param txLike - The transaction to add UDT inputs to + * @param from - The signer that will provide all available UDT inputs + * @returns A promise resolving to an object containing: + * - tx: The transaction with all available UDT inputs added + * - addedCount: Number of UDT input cells that were added + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * + * // Create a transaction (can be empty or have existing outputs) + * const tx = ccc.Transaction.from({ + * outputs: [{ lock: recipientLock, type: udt.script }], + * outputsData: [ccc.numLeToBytes(100, 16)] // Send 100 UDT + * }); + * + * // Add ALL available UDT cells as inputs + * const { tx: completedTx, addedCount } = await udt.completeInputsAll(tx, signer); + * console.log(`Added ${addedCount} UDT cells as inputs`); + * + * // The transaction now contains all UDT cells the signer controls + * const totalInputBalance = await udt.getInputsBalance(completedTx, client); + * console.log(`Total UDT input balance: ${totalInputBalance}`); + * ``` + * + * @remarks + * **Use Cases:** + * - **UDT Consolidation**: Combining multiple small UDT cells into fewer larger ones + * - **Complete Balance Transfer**: Moving all UDT tokens from one address to another + * - **Wallet Cleanup**: Reducing the number of UDT cells for better wallet performance + * - **Batch Operations**: When you need to process all UDT holdings at once + * + * **Important Considerations:** + * - This method will likely create a large excess balance that needs to be handled with change outputs + * - The resulting transaction may be large and expensive due to many inputs + * - Use `completeInputsByBalance` instead if you only need specific amounts + * - Always handle the excess balance with appropriate change outputs after calling this method + * + * **Behavior:** + * - Adds every UDT cell that the signer controls and that isn't already used in the transaction + * - The accumulator tracks total capacity of added cells (used internally for optimization) + * - Does not stop until all available UDT cells are added + * - Skips cells that are already present as inputs in the transaction + */ + async completeInputsAll( + txLike: ccc.TransactionLike, + from: ccc.Signer, + ): Promise<{ + addedCount: number; + tx: ccc.Transaction; + }> { + const tx = ccc.Transaction.from(txLike); + + return this.completeInputs( + tx, + from, + (acc, { cellOutput: { capacity } }) => acc + capacity, + ccc.Zero, + ); + } + + /** + * Completes a UDT transaction by adding inputs and handling change with a custom change function. + * This is a low-level method that provides maximum flexibility for handling UDT transaction completion. + * The change function is called to handle excess UDT balance and can return the capacity cost of the change. + * + * @param txLike - The transaction to complete + * @param signer - The signer that will provide UDT inputs + * @param change - Function to handle excess UDT balance. Called with (tx, balance, shouldModify) + * where shouldModify indicates if the function should actually modify the transaction + * @param options - Optional configuration + * @param options.shouldAddInputs - Whether to automatically add inputs. Defaults to true + * @returns A promise resolving to the completed transaction + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * + * const completedTx = await udt.complete( + * tx, + * signer, + * (tx, balance, shouldModify) => { + * if (shouldModify && balance > 0) { + * // Add change output + * const changeData = ccc.numLeToBytes(balance, 16); + * tx.addOutput({ lock: changeLock, type: udt.script }, changeData); + * return ccc.CellOutput.from({ lock: changeLock, type: udt.script }, changeData).capacity; + * } + * return 0; + * } + * ); + * ``` + * + * @remarks + * The change function is called twice: + * 1. First with shouldModify=false to calculate capacity requirements + * 2. Then with shouldModify=true to actually modify the transaction + * This two-phase approach ensures proper input selection considering capacity requirements. + */ + async complete( txLike: ccc.TransactionLike, signer: ccc.Signer, - change: ccc.ScriptLike, + change: ( + tx: ccc.Transaction, + balance: ccc.Num, + shouldModify: boolean, + ) => Promise | ccc.NumLike, + options?: { shouldAddInputs?: boolean }, + ): Promise { + let tx = this.addCellDeps(ccc.Transaction.from(txLike)); + + /* === Figure out the balance to change === */ + if (options?.shouldAddInputs ?? true) { + tx = (await this.completeInputsByBalance(tx, signer)).tx; + } + + const balanceBurned = await this.getBalanceBurned(tx, signer.client); + + if (balanceBurned < ccc.Zero) { + throw new ErrorUdtInsufficientCoin({ + amount: -balanceBurned, + type: this.script, + }); + } else if (balanceBurned === ccc.Zero) { + return tx; + } + /* === Some balance need to change === */ + + if (!(options?.shouldAddInputs ?? true)) { + await Promise.resolve(change(tx, balanceBurned, true)); + return tx; + } + + // Different with `Transaction.completeFee`, we don't need the modified tx to track updated fee + // So one attempt should be enough + const extraCapacity = ccc.numFrom( + await Promise.resolve(change(tx, balanceBurned, false)), + ); // Extra capacity introduced by change cell + tx = ( + await this.completeInputsByBalance(tx, signer, ccc.Zero, extraCapacity) + ).tx; + + const balanceToChange = await this.getBalanceBurned(tx, signer.client); + await Promise.resolve(change(tx, balanceToChange, true)); + + return tx; + } + + /** + * Completes a UDT transaction by adding change to an existing output at the specified index. + * This method modifies an existing UDT output in the transaction to include any excess + * UDT balance as change, rather than creating a new change output. + * + * @param txLike - The transaction to complete + * @param signer - The signer that will provide UDT inputs + * @param indexLike - The index of the output to modify with change balance + * @param options - Optional configuration + * @param options.shouldAddInputs - Whether to automatically add inputs. Defaults to true + * @returns A promise resolving to the completed transaction + * + * @throws {Error} When the specified output is not a valid UDT cell + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * + * // Create transaction with a UDT output that will receive change + * const tx = ccc.Transaction.from({ + * outputs: [ + * { lock: recipientLock, type: udt.script }, + * { lock: changeLock, type: udt.script } // This will receive change + * ], + * outputsData: [ + * ccc.numLeToBytes(1000, 16), // Send 1000 UDT + * ccc.numLeToBytes(0, 16) // Change output starts with 0 + * ] + * }); + * + * // Complete with change going to output index 1 + * const completedTx = await udt.completeChangeToOutput(tx, signer, 1); + * // Output 1 now contains the excess UDT balance + * ``` + * + * @remarks + * This method is useful when you want to consolidate change into an existing output + * rather than creating a new output, which can save on transaction size and fees. + * The specified output must already be a valid UDT cell with this UDT's type script. + */ + async completeChangeToOutput( + txLike: ccc.TransactionLike, + signer: ccc.Signer, + indexLike: ccc.NumLike, + options?: { shouldAddInputs?: boolean }, ) { const tx = ccc.Transaction.from(txLike); + const index = Number(ccc.numFrom(indexLike)); + const outputData = ccc.bytesFrom(tx.outputsData[index]); - await tx.completeInputsByUdt(signer, this.script); - const balanceDiff = - (await tx.getInputsUdtBalance(signer.client, this.script)) - - tx.getOutputsUdtBalance(this.script); - if (balanceDiff > ccc.Zero) { - tx.addOutput( - { - lock: change, - type: this.script, - }, - ccc.numLeToBytes(balanceDiff, 16), - ); + if (!this.isUdt({ cellOutput: tx.outputs[index], outputData })) { + throw new Error("Change output must be a UDT cell"); } - return tx; + return this.complete( + tx, + signer, + (tx, balance, shouldModify) => { + if (shouldModify) { + const balanceData = ccc.numLeToBytes( + ccc.udtBalanceFrom(outputData) + balance, + 16, + ); + + tx.outputsData[index] = ccc.hexFrom( + ccc.bytesConcatTo([], balanceData, outputData.slice(16)), + ); + } + + return 0; + }, + options, + ); + } + + /** + * Completes a UDT transaction by adding necessary inputs and handling change. + * This method automatically adds UDT inputs to cover the required output amounts + * and creates a change output if there's excess UDT balance. + * + * @param tx - The transaction to complete, containing UDT outputs + * @param signer - The signer that will provide UDT inputs + * @param changeLike - The lock script where any excess UDT balance should be sent as change + * @param options - Optional configuration for the completion process + * @param options.shouldAddInputs - Whether to automatically add inputs. Defaults to true + * @returns A promise resolving to the completed transaction with inputs and change output added + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * + * // Create a transaction with UDT outputs + * const tx = ccc.Transaction.from({ + * outputs: [ + * { lock: recipientLock, type: udt.script } + * ], + * outputsData: [ccc.numLeToBytes(1000, 16)] // Send 1000 UDT + * }); + * + * // Complete with change going to sender's address + * const { script: changeLock } = await signer.getRecommendedAddressObj(); + * const completedTx = await udt.completeChangeToLock(tx, signer, changeLock); + * + * // The transaction now has: + * // - Sufficient UDT inputs to cover the 1000 UDT output + * // - A change output if there was excess UDT balance + * ``` + * + * @remarks + * This method performs the following operations: + * 1. Adds UDT inputs using `completeInputsByBalance` + * 2. Calculates the difference between input and output UDT balances + * 3. Creates a change output if there's excess UDT balance + */ + async completeChangeToLock( + tx: ccc.TransactionLike, + signer: ccc.Signer, + changeLike: ccc.ScriptLike, + options?: { shouldAddInputs?: boolean }, + ) { + const change = ccc.Script.from(changeLike); + + return this.complete( + tx, + signer, + (tx, balance, shouldModify) => { + const balanceData = ccc.numLeToBytes(balance, 16); + const changeOutput = ccc.CellOutput.from( + { lock: change, type: this.script }, + balanceData, + ); + if (shouldModify) { + tx.addOutput(changeOutput, balanceData); + } + + return changeOutput.capacity; + }, + options, + ); } - async completeBy(tx: ccc.TransactionLike, from: ccc.Signer) { + /** + * Completes a UDT transaction using the signer's recommended address for change. + * This is a convenience method that automatically uses the signer's recommended + * address as the change destination, making it easier to complete UDT transactions + * without manually specifying a change address. + * + * @param tx - The transaction to complete, containing UDT outputs + * @param from - The signer that will provide UDT inputs and receive change + * @param options - Optional configuration for the completion process + * @param options.shouldAddInputs - Whether to automatically add inputs. Defaults to true + * @returns A promise resolving to the completed transaction with inputs and change output added + * + * @example + * ```typescript + * const udt = new Udt(codeOutPoint, scriptConfig); + * + * // Create a transfer transaction + * const transferResponse = await udt.transfer( + * signer, + * [{ to: recipientLock, amount: 1000 }] + * ); + * + * // Complete the transaction (change will go to signer's address) + * const completedTx = await udt.completeBy(transferResponse.res, signer); + * + * // Add capacity inputs and fee + * await completedTx.completeInputsByCapacity(signer); + * await completedTx.completeFeeBy(signer, changeLock); + * + * const txHash = await signer.sendTransaction(completedTx); + * ``` + * + * @see {@link completeChangeToLock} for more control over the change destination + */ + async completeBy( + tx: ccc.TransactionLike, + from: ccc.Signer, + options?: { shouldAddInputs?: boolean }, + ) { const { script } = await from.getRecommendedAddressObj(); - return this.completeChangeToLock(tx, from, script); + return this.completeChangeToLock(tx, from, script, options); } } diff --git a/packages/udt/src/udtPausable/index.ts b/packages/udt/src/udtPausable/index.ts index 84215cce3..46fb7dda5 100644 --- a/packages/udt/src/udtPausable/index.ts +++ b/packages/udt/src/udtPausable/index.ts @@ -1,6 +1,6 @@ import { ccc } from "@ckb-ccc/core"; import { ssri } from "@ckb-ccc/ssri"; -import { Udt } from "../udt/index.js"; +import { Udt, UdtConfigLike } from "../udt/index.js"; /** * Represents a UDT (User Defined Token) with pausable functionality. @@ -11,9 +11,7 @@ export class UdtPausable extends Udt { constructor( code: ccc.OutPointLike, script: ccc.ScriptLike, - config: { - executor: ssri.Executor; - }, + config: UdtConfigLike & { executor: ssri.Executor }, ) { super(code, script, config); } diff --git a/packages/udt/vitest.config.ts b/packages/udt/vitest.config.ts new file mode 100644 index 000000000..dc6a58785 --- /dev/null +++ b/packages/udt/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + coverage: { + include: ["src/**/*.ts"], + }, + }, +}); From b6afda4e75b75fac1d818ce29bc56c343d89164e Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sun, 17 Aug 2025 21:23:02 +0800 Subject: [PATCH 19/45] feat(core): multiple scripts for `SignerCkbScriptReadonly` --- .changeset/ten-ties-kiss.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/ten-ties-kiss.md diff --git a/.changeset/ten-ties-kiss.md b/.changeset/ten-ties-kiss.md new file mode 100644 index 000000000..163263fc4 --- /dev/null +++ b/.changeset/ten-ties-kiss.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): multiple scripts for `SignerCkbScriptReadonly` + \ No newline at end of file From 1112030e5ada16570818648d37d7eec191ef6c5c Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sun, 17 Aug 2025 06:18:12 +0800 Subject: [PATCH 20/45] feat(core): add `CellAny` It's definitely a mistake to name `CellOnChain` `Cell`, but there is nothing we can do with that right now. To avoid more duplicate code, `CellAny` was added to represent a cell that's on-chain or off-chain. --- .changeset/salty-apples-check.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/salty-apples-check.md diff --git a/.changeset/salty-apples-check.md b/.changeset/salty-apples-check.md new file mode 100644 index 000000000..4e6c19b57 --- /dev/null +++ b/.changeset/salty-apples-check.md @@ -0,0 +1,9 @@ +--- +"@ckb-ccc/core": minor +"@ckb-ccc/ssri": patch +--- + +feat(core): add `CellAny` + +It's definitely a mistake to name `CellOnChain` `Cell`, but there is nothing we can do with that right now. To avoid more duplicate code, `CellAny` was added to represent a cell that's on-chain or off-chain. + From 5af2cb771cef6fb5a1a57f2897d9eacfa1b85df7 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Mon, 18 Aug 2025 01:39:28 +0800 Subject: [PATCH 21/45] feat(core): `reduce` and `reduceAsync` for `Iterable` --- .changeset/tangy-memes-sit.md | 6 + packages/core/src/utils/index.test.ts | 178 ++++++++++++++++++++++++++ packages/core/src/utils/index.ts | 120 +++++++++++++---- 3 files changed, 278 insertions(+), 26 deletions(-) create mode 100644 .changeset/tangy-memes-sit.md create mode 100644 packages/core/src/utils/index.test.ts diff --git a/.changeset/tangy-memes-sit.md b/.changeset/tangy-memes-sit.md new file mode 100644 index 000000000..1ee2f07d5 --- /dev/null +++ b/.changeset/tangy-memes-sit.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": major +--- + +feat(core): `reduce` and `reduceAsync` for `Iterable` + diff --git a/packages/core/src/utils/index.test.ts b/packages/core/src/utils/index.test.ts new file mode 100644 index 000000000..34dd53162 --- /dev/null +++ b/packages/core/src/utils/index.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { reduce, reduceAsync } from "./index.js"; + +// Helper to create an async iterable for testing +async function* createAsyncIterable(items: T[]): AsyncIterable { + for (const item of items) { + // Simulate a small delay for each item + await new Promise((resolve) => setTimeout(resolve, 1)); + yield item; + } +} + +describe("reduce", () => { + it("should reduce an array of numbers to their sum", () => { + const values = [1, 2, 3, 4]; + const result = reduce(values, (acc, val) => acc + val); + expect(result).toBe(10); + }); + + it("should reduce with a given initial value", () => { + const values = [1, 2, 3, 4]; + const result = reduce(values, (acc, val) => acc + val, 10); + expect(result).toBe(20); + }); + + it("should handle different accumulator and value types", () => { + const values = ["a", "bb", "ccc"]; + const result = reduce(values, (acc, val) => acc + val.length, 0); + expect(result).toBe(6); + }); + + it("should return the initial value for an empty array", () => { + const values: number[] = []; + const result = reduce(values, (acc, val) => acc + val, 100); + expect(result).toBe(100); + }); + + it("should throw a TypeError for an empty array with no initial value", () => { + const values: number[] = []; + expect(() => reduce(values, (acc, val) => acc + val)).toThrow( + "Reduce of empty iterator with no initial value", + ); + }); + + it("should keep the previous result if accumulator returns null or undefined", () => { + const values = [1, 2, 3, 4]; + const result = reduce( + values, + (acc, val) => { + // Only add odd numbers + return val % 2 !== 0 ? acc + val : null; + }, + 0, + ); + // 0+1=1, 1 (ignore 2), 1+3=4, 4 (ignore 4) + expect(result).toBe(4); + }); + + it("should work with other iterables like Set", () => { + const values = new Set([1, 2, 3, 4]); + const result = reduce(values, (acc, val) => acc * val, 1); + expect(result).toBe(24); + }); + + it("should pass correct index to the accumulator", () => { + const values = ["a", "b", "c"]; + const indicesWithInit: number[] = []; + reduce( + values, + (_acc, _val, i) => { + indicesWithInit.push(i); + }, + "", + ); + expect(indicesWithInit).toEqual([0, 1, 2]); + + const indicesWithoutInit: number[] = []; + reduce(values, (_acc, _val, i) => { + indicesWithoutInit.push(i); + }); + // First call is for the second element, so index is 1 + expect(indicesWithoutInit).toEqual([1, 2]); + }); +}); + +describe("reduceAsync", () => { + it("should work with a sync iterable and sync accumulator", async () => { + const values = [1, 2, 3, 4]; + const result = await reduceAsync(values, (acc, val) => acc + val); + expect(result).toBe(10); + }); + + it("should work with a sync iterable and async accumulator", async () => { + const values = [1, 2, 3, 4]; + const result = await reduceAsync( + values, + async (acc, val) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return acc + val; + }, + 0, + ); + expect(result).toBe(10); + }); + + it("should work with an async iterable and sync accumulator", async () => { + const values = createAsyncIterable([1, 2, 3, 4]); + const result = await reduceAsync(values, (acc, val) => acc + val, 0); + expect(result).toBe(10); + }); + + it("should work with an async iterable and async accumulator", async () => { + const values = createAsyncIterable([1, 2, 3, 4]); + const result = await reduceAsync( + values, + async (acc, val) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return acc + val; + }, + 0, + ); + expect(result).toBe(10); + }); + + it("should work with a promise as an initial value", async () => { + const values = [1, 2, 3, 4]; + const init = Promise.resolve(10); + const result = await reduceAsync(values, (acc, val) => acc + val, init); + expect(result).toBe(20); + }); + + it("should throw a TypeError for an empty iterable with no initial value", async () => { + const values: number[] = []; + await expect(reduceAsync(values, (acc, val) => acc + val)).rejects.toThrow( + "Reduce of empty iterator with no initial value", + ); + }); + + it("should return the initial value for an empty async iterable", async () => { + const values = createAsyncIterable([]); + const result = await reduceAsync(values, (acc, val) => acc + val, 100); + expect(result).toBe(100); + }); + + it("should keep previous result if async accumulator returns null", async () => { + const values = createAsyncIterable([1, 2, 3, 4]); + const result = await reduceAsync( + values, + async (acc, val) => { + return val % 2 !== 0 ? acc + val : Promise.resolve(null); + }, + 0, + ); + expect(result).toBe(4); + }); + + it("should pass correct index to the accumulator", async () => { + const values = ["a", "b", "c"]; + const indicesWithInit: number[] = []; + await reduceAsync( + values, + (acc, _val, i) => { + indicesWithInit.push(i); + return acc; + }, + "", + ); + expect(indicesWithInit).toEqual([0, 1, 2]); + + const indicesWithoutInit: number[] = []; + await reduceAsync(values, (acc, _val, i) => { + indicesWithoutInit.push(i); + return acc; + }); + // First call is for the second element, so index is 1 + expect(indicesWithoutInit).toEqual([1, 2]); + }); +}); diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index f75bdb8ab..6abad2eec 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -100,73 +100,141 @@ export function apply( } /** - * Similar to Array.reduce, but the accumulator can returns Promise. + * Similar to Array.reduce, but works on any iterable. * @public * - * @param values - The array to be reduced. - * @param accumulator - A callback to be called for each value. If it returns null, the previous result will be kept. + * @param values - The iterable to be reduced. + * @param accumulator - A callback to be called for each value. If it returns null or undefined, the previous result will be kept. + * @returns The accumulated result. + */ +export function reduce( + values: Iterable, + accumulator: (a: T, b: T, i: number) => T | undefined | null | void, +): T; +/** + * Similar to Array.reduce, but works on any iterable. + * @public + * + * @param values - The iterable to be reduced. + * @param accumulator - A callback to be called for each value. If it returns null or undefined, the previous result will be kept. + * @param init - The initial value. + * @returns The accumulated result. + */ +export function reduce( + values: Iterable, + accumulator: (a: T, b: V, i: number) => T | undefined | null | void, + init: T, +): T; +/** + * Similar to Array.reduce, but works on any iterable. + * @public + * + * @param values - The iterable to be reduced. + * @param accumulator - A callback to be called for each value. If it returns null or undefined, the previous result will be kept. + * @param init - The initial value. + * @returns The accumulated result. + */ +export function reduce( + values: Iterable | Iterable, + accumulator: (a: T, b: T | V, i: number) => T | undefined | null | void, + init?: T, +): T { + const hasInit = arguments.length > 2; + + let acc: T = init as T; // The compiler thinks `acc` isn't assigned without this. Since `T` might be nullable, we should not use non-null assertion here. + let i = 0; + + for (const value of values) { + if (!hasInit && i === 0) { + acc = value as T; + i++; + continue; + } + + acc = accumulator(acc, value, i) ?? acc; + i++; + } + + if (!hasInit && i === 0) { + throw new TypeError("Reduce of empty iterator with no initial value"); + } + + return acc; +} + +/** + * Similar to Array.reduce, but works on async iterables and the accumulator can return a Promise. + * @public + * + * @param values - The iterable or async iterable to be reduced. + * @param accumulator - A callback to be called for each value. If it returns null or undefined, the previous result will be kept. * @returns The accumulated result. */ export async function reduceAsync( - values: T[], + values: Iterable | AsyncIterable, accumulator: ( a: T, b: T, + i: number, ) => Promise | T | undefined | null | void, ): Promise; /** - * Similar to Array.reduce, but the accumulator can returns Promise. + * Similar to Array.reduce, but works on async iterables and the accumulator can return a Promise. * @public * - * @param values - The array to be reduced. - * @param accumulator - A callback to be called for each value. If it returns null, the previous result will be kept. + * @param values - The iterable or async iterable to be reduced. + * @param accumulator - A callback to be called for each value. If it returns null or undefined, the previous result will be kept. * @param init - The initial value. * @returns The accumulated result. */ export async function reduceAsync( - values: V[], + values: Iterable | AsyncIterable, accumulator: ( a: T, b: V, i: number, - values: V[], ) => Promise | T | undefined | null | void, init: T | Promise, ): Promise; /** - * Similar to Array.reduce, but the accumulator can returns Promise. + * Similar to Array.reduce, but works on async iterables and the accumulator can return a Promise. * @public * - * @param values - The array to be reduced. - * @param accumulator - A callback to be called for each value. If it returns null, the previous result will be kept. + * @param values - The iterable or async iterable to be reduced. + * @param accumulator - A callback to be called for each value. If it returns null or undefined, the previous result will be kept. * @param init - The initial value. * @returns The accumulated result. */ export async function reduceAsync( - values: (V | T)[], + values: Iterable | AsyncIterable | Iterable | AsyncIterable, accumulator: ( a: T, b: T | V, i: number, - values: (V | T)[], ) => Promise | T | undefined | null | void, init?: T | Promise, ): Promise { - if (init === undefined) { - if (values.length === 0) { - throw new TypeError("Reduce of empty array with no initial value"); + const hasInit = arguments.length > 2; + + let acc: T = (await Promise.resolve(init)) as T; // The compiler thinks `acc` isn't assigned without this. Since `T` might be nullable, we should not use non-null assertion here. + let i = 0; + + for await (const value of values) { + if (!hasInit && i === 0) { + acc = value as T; + i++; + continue; } - init = values[0] as T; - values = values.slice(1); + + acc = (await accumulator(acc, value, i)) ?? acc; + i++; + } + + if (!hasInit && i === 0) { + throw new TypeError("Reduce of empty iterator with no initial value"); } - return values.reduce( - (current: Promise, b: T | V, i, array) => - current.then((v) => - Promise.resolve(accumulator(v, b, i, array)).then((r) => r ?? v), - ), - Promise.resolve(init), - ); + return acc; } export function sleep(ms: NumLike) { From e63733976bd1cf1d2cf05cbf2b49d67dfc7da60e Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Thu, 21 Aug 2025 08:13:21 +0800 Subject: [PATCH 22/45] fix: `prettier-vscode` doesn't work with `import` --- config/prettier.config.mjs | 13 +++++++++++++ packages/ccc/prettier.config.mjs | 13 +++++++++++++ packages/ckb-ccc/prettier.config.mjs | 13 +++++++++++++ packages/connector-react/prettier.config.mjs | 13 +++++++++++++ packages/connector/prettier.config.mjs | 13 +++++++++++++ packages/core/prettier.config.mjs | 13 +++++++++++++ packages/demo/prettier.config.mjs | 13 +++++++++++++ packages/eip6963/prettier.config.mjs | 13 +++++++++++++ packages/examples/prettier.config.mjs | 13 +++++++++++++ packages/faucet/prettier.config.mjs | 13 +++++++++++++ packages/joy-id/prettier.config.mjs | 13 +++++++++++++ packages/lumos-patches/prettier.config.mjs | 13 +++++++++++++ packages/nip07/prettier.config.mjs | 13 +++++++++++++ packages/okx/prettier.config.mjs | 13 +++++++++++++ packages/playground/prettier.config.mjs | 13 +++++++++++++ packages/rei/prettier.config.mjs | 13 +++++++++++++ packages/shell/prettier.config.mjs | 13 +++++++++++++ packages/spore/prettier.config.mjs | 13 +++++++++++++ packages/ssri/prettier.config.mjs | 13 +++++++++++++ packages/udt/prettier.config.mjs | 13 +++++++++++++ packages/uni-sat/prettier.config.mjs | 13 +++++++++++++ packages/utxo-global/prettier.config.mjs | 13 +++++++++++++ packages/xverse/prettier.config.mjs | 13 +++++++++++++ 23 files changed, 299 insertions(+) create mode 100644 config/prettier.config.mjs create mode 100644 packages/ccc/prettier.config.mjs create mode 100644 packages/ckb-ccc/prettier.config.mjs create mode 100644 packages/connector-react/prettier.config.mjs create mode 100644 packages/connector/prettier.config.mjs create mode 100644 packages/core/prettier.config.mjs create mode 100644 packages/demo/prettier.config.mjs create mode 100644 packages/eip6963/prettier.config.mjs create mode 100644 packages/examples/prettier.config.mjs create mode 100644 packages/faucet/prettier.config.mjs create mode 100644 packages/joy-id/prettier.config.mjs create mode 100644 packages/lumos-patches/prettier.config.mjs create mode 100644 packages/nip07/prettier.config.mjs create mode 100644 packages/okx/prettier.config.mjs create mode 100644 packages/playground/prettier.config.mjs create mode 100644 packages/rei/prettier.config.mjs create mode 100644 packages/shell/prettier.config.mjs create mode 100644 packages/spore/prettier.config.mjs create mode 100644 packages/ssri/prettier.config.mjs create mode 100644 packages/udt/prettier.config.mjs create mode 100644 packages/uni-sat/prettier.config.mjs create mode 100644 packages/utxo-global/prettier.config.mjs create mode 100644 packages/xverse/prettier.config.mjs diff --git a/config/prettier.config.mjs b/config/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/config/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/ccc/prettier.config.mjs b/packages/ccc/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/ccc/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/ckb-ccc/prettier.config.mjs b/packages/ckb-ccc/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/ckb-ccc/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/connector-react/prettier.config.mjs b/packages/connector-react/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/connector-react/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/connector/prettier.config.mjs b/packages/connector/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/connector/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/core/prettier.config.mjs b/packages/core/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/core/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/demo/prettier.config.mjs b/packages/demo/prettier.config.mjs new file mode 100644 index 000000000..edf37446c --- /dev/null +++ b/packages/demo/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"], +}; + +export default config; diff --git a/packages/eip6963/prettier.config.mjs b/packages/eip6963/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/eip6963/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/examples/prettier.config.mjs b/packages/examples/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/examples/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/faucet/prettier.config.mjs b/packages/faucet/prettier.config.mjs new file mode 100644 index 000000000..7be7ba0bc --- /dev/null +++ b/packages/faucet/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; diff --git a/packages/joy-id/prettier.config.mjs b/packages/joy-id/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/joy-id/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/lumos-patches/prettier.config.mjs b/packages/lumos-patches/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/lumos-patches/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/nip07/prettier.config.mjs b/packages/nip07/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/nip07/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/okx/prettier.config.mjs b/packages/okx/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/okx/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/playground/prettier.config.mjs b/packages/playground/prettier.config.mjs new file mode 100644 index 000000000..edf37446c --- /dev/null +++ b/packages/playground/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"], +}; + +export default config; diff --git a/packages/rei/prettier.config.mjs b/packages/rei/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/rei/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/shell/prettier.config.mjs b/packages/shell/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/shell/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/spore/prettier.config.mjs b/packages/spore/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/spore/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/ssri/prettier.config.mjs b/packages/ssri/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/ssri/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/udt/prettier.config.mjs b/packages/udt/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/udt/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/uni-sat/prettier.config.mjs b/packages/uni-sat/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/uni-sat/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/utxo-global/prettier.config.mjs b/packages/utxo-global/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/utxo-global/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file diff --git a/packages/xverse/prettier.config.mjs b/packages/xverse/prettier.config.mjs new file mode 100644 index 000000000..af1401cbb --- /dev/null +++ b/packages/xverse/prettier.config.mjs @@ -0,0 +1,13 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: ["prettier-plugin-organize-imports"], +}; + +export default config; \ No newline at end of file From 285772c933a0719758802a8c6e8c41c8bacd746a Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Thu, 21 Aug 2025 05:03:05 +0800 Subject: [PATCH 23/45] fix(core)!: `getFeeRateStatistics` may returns `null` on devnet --- .changeset/sixty-games-scream.md | 5 +++++ packages/core/src/client/client.ts | 4 ++-- packages/core/src/client/jsonRpc/client.ts | 6 +++--- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .changeset/sixty-games-scream.md diff --git a/.changeset/sixty-games-scream.md b/.changeset/sixty-games-scream.md new file mode 100644 index 000000000..e419f74a7 --- /dev/null +++ b/.changeset/sixty-games-scream.md @@ -0,0 +1,5 @@ +--- +"@ckb-ccc/core": major +--- + +fix(core)!: `getFeeRateStatistics` may returns `null` on devnet diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index f8d8d5e2a..70f504324 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -54,13 +54,13 @@ export abstract class Client { abstract getFeeRateStatistics( blockRange?: NumLike, - ): Promise<{ mean: Num; median: Num }>; + ): Promise<{ mean?: Num; median?: Num }>; async getFeeRate( blockRange?: NumLike, options?: { maxFeeRate?: NumLike }, ): Promise { const feeRate = numMax( - (await this.getFeeRateStatistics(blockRange)).median, + (await this.getFeeRateStatistics(blockRange)).median ?? Zero, DEFAULT_MIN_FEE_RATE, ); diff --git a/packages/core/src/client/jsonRpc/client.ts b/packages/core/src/client/jsonRpc/client.ts index 55790e919..3b1d837b4 100644 --- a/packages/core/src/client/jsonRpc/client.ts +++ b/packages/core/src/client/jsonRpc/client.ts @@ -131,9 +131,9 @@ export abstract class ClientJsonRpc extends Client { getFeeRateStatistics = this.buildSender( "get_fee_rate_statistics", [(n: NumLike) => apply(numFrom, n)], - ({ mean, median }: { mean: NumLike; median: NumLike }) => ({ - mean: numFrom(mean), - median: numFrom(median), + (res: { mean: NumLike; median: NumLike } | null | undefined) => ({ + mean: apply(numFrom, res?.mean), + median: apply(numFrom, res?.median), }), ) as Client["getFeeRateStatistics"]; From 25aa5b9d9a5f76f280a3c5ba2bf3afaef2b2ac5a Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sun, 17 Aug 2025 21:28:50 +0800 Subject: [PATCH 24/45] feat(core): default `Signer.prepareTransaction` --- .changeset/old-eagles-bake.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/old-eagles-bake.md diff --git a/.changeset/old-eagles-bake.md b/.changeset/old-eagles-bake.md new file mode 100644 index 000000000..70b9e8d47 --- /dev/null +++ b/.changeset/old-eagles-bake.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +feat(core): default `Signer.prepareTransaction` + \ No newline at end of file From fe2d8be4ca2239755c349223fdb09de1beca0f9f Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Tue, 9 Sep 2025 18:36:58 +0800 Subject: [PATCH 25/45] Revert "feat(udt): `Udt.complete*` methods" This reverts commit 439380360a053c511c5ef741b3afeca77da9096a. It will be moved to a new branch `feat/udt` --- .changeset/plenty-ads-rush.md | 6 - .changeset/shy-horses-agree.md | 6 - packages/core/src/ckb/transaction.ts | 12 - packages/core/src/ckb/transactionErrors.ts | 3 - packages/ssri/src/executor.ts | 8 - packages/udt/package.json | 2 +- packages/udt/src/udt/index.test.ts | 1001 --------------- packages/udt/src/udt/index.ts | 1295 +------------------- packages/udt/src/udtPausable/index.ts | 6 +- packages/udt/vitest.config.ts | 10 - 10 files changed, 59 insertions(+), 2290 deletions(-) delete mode 100644 .changeset/plenty-ads-rush.md delete mode 100644 .changeset/shy-horses-agree.md delete mode 100644 packages/udt/src/udt/index.test.ts delete mode 100644 packages/udt/vitest.config.ts diff --git a/.changeset/plenty-ads-rush.md b/.changeset/plenty-ads-rush.md deleted file mode 100644 index 8db5049cd..000000000 --- a/.changeset/plenty-ads-rush.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@ckb-ccc/ssri": minor ---- - -feat(ssri): `ExecutorResponse.mapAsync` - \ No newline at end of file diff --git a/.changeset/shy-horses-agree.md b/.changeset/shy-horses-agree.md deleted file mode 100644 index f42efc551..000000000 --- a/.changeset/shy-horses-agree.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@ckb-ccc/udt": minor ---- - -feat(udt): `Udt.complete*` methods - diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 006853076..6aecdc470 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -1888,12 +1888,6 @@ export class Transaction extends Entity.Base() { return this.outputs.reduce((acc, { capacity }) => acc + capacity, Zero); } - /** - * @deprecated Use `Udt.getInputsBalance` from `@ckb-ccc/udt` instead - * @param client - * @param type - * @returns - */ async getInputsUdtBalance(client: Client, type: ScriptLike): Promise { return reduceAsync( this.inputs, @@ -1909,11 +1903,6 @@ export class Transaction extends Entity.Base() { ); } - /** - * @deprecated Use `Udt.getOutputsBalance` from `@ckb-ccc/udt` instead - * @param type - * @returns - */ getOutputsUdtBalance(type: ScriptLike): Num { return this.outputs.reduce((acc, output, i) => { if (!output.type?.eq(type)) { @@ -2031,7 +2020,6 @@ export class Transaction extends Entity.Base() { * This method succeeds only if enough balance is collected. * * It will try to collect at least two inputs, even when the first input already contains enough balance, to avoid extra occupation fees introduced by the change cell. An edge case: If the first cell has the same amount as the output, a new cell is not needed. - * @deprecated Use `Udt.completeInputsByBalance` from `@ckb-ccc/udt` instead * @param from - The signer to complete the inputs. * @param type - The type script of the UDT. * @param balanceTweak - The tweak of the balance. diff --git a/packages/core/src/ckb/transactionErrors.ts b/packages/core/src/ckb/transactionErrors.ts index 26057669a..09c1cb745 100644 --- a/packages/core/src/ckb/transactionErrors.ts +++ b/packages/core/src/ckb/transactionErrors.ts @@ -22,9 +22,6 @@ export class ErrorTransactionInsufficientCapacity extends Error { } } -/** - * @deprecated Use `ErrorUdtInsufficientCoin` from `@ckb-ccc/udt` instead. - */ export class ErrorTransactionInsufficientCoin extends Error { public readonly amount: Num; public readonly type: Script; diff --git a/packages/ssri/src/executor.ts b/packages/ssri/src/executor.ts index b7ae02d44..d4548b8bd 100644 --- a/packages/ssri/src/executor.ts +++ b/packages/ssri/src/executor.ts @@ -63,14 +63,6 @@ export class ExecutorResponse { throw new ExecutorErrorDecode(JSON.stringify(err)); } } - - async mapAsync(fn: (res: T) => Promise): Promise> { - try { - return new ExecutorResponse(await fn(this.res), this.cellDeps); - } catch (err) { - throw new ExecutorErrorDecode(JSON.stringify(err)); - } - } } /** diff --git a/packages/udt/package.json b/packages/udt/package.json index 2e98f0038..8aa9778da 100644 --- a/packages/udt/package.json +++ b/packages/udt/package.json @@ -23,7 +23,7 @@ } }, "scripts": { - "test": "vitest", + "test": "jest", "build": "rimraf ./dist && rimraf ./dist.commonjs && tsc && tsc --project tsconfig.commonjs.json && copyfiles -u 2 misc/basedirs/**/* .", "lint": "eslint ./src", "format": "prettier --write . && eslint --fix ./src" diff --git a/packages/udt/src/udt/index.test.ts b/packages/udt/src/udt/index.test.ts deleted file mode 100644 index 00433e894..000000000 --- a/packages/udt/src/udt/index.test.ts +++ /dev/null @@ -1,1001 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { Udt } from "./index.js"; - -let client: ccc.Client; -let signer: ccc.Signer; -let lock: ccc.Script; -let type: ccc.Script; -let udt: Udt; - -beforeEach(async () => { - client = new ccc.ClientPublicTestnet(); - signer = new ccc.SignerCkbPublicKey( - client, - "0x026f3255791f578cc5e38783b6f2d87d4709697b797def6bf7b3b9af4120e2bfd9", - ); - lock = (await signer.getRecommendedAddressObj()).script; - - type = await ccc.Script.fromKnownScript( - client, - ccc.KnownScript.XUdt, - "0xf8f94a13dfe1b87c10312fb9678ab5276eefbe1e0b2c62b4841b1f393494eff2", - ); - - // Create UDT instance - udt = new Udt( - { - txHash: - "0x4e2e832e0b1e7b5994681b621b00c1e65f577ee4b440ef95fa07db9bb3d50269", - index: 0, - }, - type, - ); -}); - -describe("Udt", () => { - describe("completeInputsByBalance", () => { - // Mock cells with 100 UDT each (10 cells total = 1000 UDT) - let mockUdtCells: ccc.Cell[]; - - beforeEach(async () => { - // Create mock cells after type is initialized - mockUdtCells = Array.from({ length: 10 }, (_, i) => - ccc.Cell.from({ - outPoint: { - txHash: `0x${"0".repeat(63)}${i.toString(16)}`, - index: 0, - }, - cellOutput: { - capacity: ccc.fixedPointFrom(142), - lock, - type, - }, - outputData: ccc.numLeToBytes(100, 16), // 100 UDT tokens - }), - ); - }); - - beforeEach(() => { - // Mock the findCells method to return our mock UDT cells - vi.spyOn(signer, "findCells").mockImplementation( - async function* (filter) { - if (filter.script && ccc.Script.from(filter.script).eq(type)) { - for (const cell of mockUdtCells) { - yield cell; - } - } - }, - ); - - // Mock client.getCell to return the cell data for inputs - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - const cell = mockUdtCells.find((c) => c.outPoint.eq(outPoint)); - return cell; - }); - }); - - it("should return 0 when no UDT balance is needed", async () => { - const tx = ccc.Transaction.from({ - outputs: [], - }); - - const { addedCount } = await udt.completeInputsByBalance(tx, signer); - expect(addedCount).toBe(0); - }); - - it("should collect exactly the required UDT balance", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { - lock, - type, - }, - ], - outputsData: [ccc.numLeToBytes(150, 16)], // Need 150 UDT - }); - - const { addedCount } = await udt.completeInputsByBalance(tx, signer); - - // Should add 2 cells (200 UDT total) to have at least 2 inputs - expect(addedCount).toBe(2); - expect(tx.inputs.length).toBe(2); - - // Verify the inputs are UDT cells - const inputBalance = await udt.getInputsBalance(tx, client); - expect(inputBalance).toBe(ccc.numFrom(200)); - }); - - it("should collect exactly one cell when amount matches exactly", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { - lock, - type, - }, - ], - outputsData: [ccc.numLeToBytes(100, 16)], // Need exactly 100 UDT - }); - - const { addedCount } = await udt.completeInputsByBalance(tx, signer); - - // Should add only 1 cell since it matches exactly - expect(addedCount).toBe(1); - expect(tx.inputs.length).toBe(1); - - const inputBalance = await udt.getInputsBalance(tx, client); - expect(inputBalance).toBe(ccc.numFrom(100)); - }); - - it("should handle balanceTweak parameter", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { - lock, - type, - }, - ], - outputsData: [ccc.numLeToBytes(100, 16)], // Need 100 UDT - }); - - // Add 50 extra UDT requirement via balanceTweak - const { addedCount } = await udt.completeInputsByBalance(tx, signer, 50); - - // Should add 2 cells to cover 150 UDT total requirement - expect(addedCount).toBe(2); - expect(tx.inputs.length).toBe(2); - - const inputBalance = await udt.getInputsBalance(tx, client); - expect(inputBalance).toBe(ccc.numFrom(200)); - }); - - it("should return 0 when existing inputs already satisfy the requirement", async () => { - const tx = ccc.Transaction.from({ - inputs: [ - { - previousOutput: mockUdtCells[0].outPoint, - }, - { - previousOutput: mockUdtCells[1].outPoint, - }, - ], - outputs: [ - { - lock, - type, - }, - ], - outputsData: [ccc.numLeToBytes(150, 16)], // Need 150 UDT, already have 200 - }); - - const { addedCount } = await udt.completeInputsByBalance(tx, signer); - - // Should not add any inputs since we already have enough - expect(addedCount).toBe(0); - expect(tx.inputs.length).toBe(2); - }); - - it("should throw error when insufficient UDT balance available", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { - lock, - type, - }, - ], - outputsData: [ccc.numLeToBytes(1500, 16)], // Need 1500 UDT, only have 1000 available - }); - - await expect(udt.completeInputsByBalance(tx, signer)).rejects.toThrow( - "Insufficient coin, need 500 extra coin", - ); - }); - - it("should handle multiple UDT outputs correctly", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { - lock, - type, - }, - { - lock, - type, - }, - ], - outputsData: [ - ccc.numLeToBytes(100, 16), // First output: 100 UDT - ccc.numLeToBytes(150, 16), // Second output: 150 UDT - ], // Total: 250 UDT needed - }); - - const { addedCount } = await udt.completeInputsByBalance(tx, signer); - - // Should add 3 cells to cover 250 UDT requirement (300 UDT total) - expect(addedCount).toBe(3); - expect(tx.inputs.length).toBe(3); - - const inputBalance = await udt.getInputsBalance(tx, client); - expect(inputBalance).toBe(ccc.numFrom(300)); - - const outputBalance = await udt.getOutputsBalance(tx, client); - expect(outputBalance).toBe(ccc.numFrom(250)); - }); - - it("should skip cells that are already used as inputs", async () => { - // Pre-add one of the mock cells as input - const tx = ccc.Transaction.from({ - inputs: [ - { - previousOutput: mockUdtCells[0].outPoint, - }, - ], - outputs: [ - { - lock, - type, - }, - ], - outputsData: [ccc.numLeToBytes(150, 16)], // Need 150 UDT, already have 100 - }); - - const { addedCount } = await udt.completeInputsByBalance(tx, signer); - - // Should add 1 more cell (since we already have 1 input with 100 UDT) - expect(addedCount).toBe(1); - expect(tx.inputs.length).toBe(2); - - const inputBalance = await udt.getInputsBalance(tx, client); - expect(inputBalance).toBe(ccc.numFrom(200)); - }); - - it("should add one cell when user needs less than one cell", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { - lock, - type, - }, - ], - outputsData: [ccc.numLeToBytes(50, 16)], // Need only 50 UDT (less than one cell) - }); - - const { addedCount } = await udt.completeInputsByBalance(tx, signer); - - // UDT completeInputsByBalance adds minimum inputs needed - expect(addedCount).toBe(1); - expect(tx.inputs.length).toBe(1); - - const inputBalance = await udt.getInputsBalance(tx, client); - expect(inputBalance).toBe(ccc.numFrom(100)); - }); - }); - - describe("completeInputsAll", () => { - // Mock cells with 100 UDT each (5 cells total = 500 UDT) - let mockUdtCells: ccc.Cell[]; - - beforeEach(async () => { - // Create mock cells after type is initialized - mockUdtCells = Array.from({ length: 5 }, (_, i) => - ccc.Cell.from({ - outPoint: { - txHash: `0x${"a".repeat(63)}${i.toString(16)}`, - index: 0, - }, - cellOutput: { - capacity: ccc.fixedPointFrom(142 + i * 10), // Varying capacity: 142, 152, 162, 172, 182 - lock, - type, - }, - outputData: ccc.numLeToBytes(100, 16), // 100 UDT tokens each - }), - ); - }); - - beforeEach(() => { - // Mock the findCells method to return our mock UDT cells - vi.spyOn(signer, "findCells").mockImplementation( - async function* (filter) { - if (filter.script && ccc.Script.from(filter.script).eq(type)) { - for (const cell of mockUdtCells) { - yield cell; - } - } - }, - ); - - // Mock client.getCell to return the cell data for inputs - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - const cell = mockUdtCells.find((c) => c.outPoint.eq(outPoint)); - return cell; - }); - }); - - it("should add all available UDT cells to empty transaction", async () => { - const tx = ccc.Transaction.from({ - outputs: [], - }); - - const { tx: completedTx, addedCount } = await udt.completeInputsAll( - tx, - signer, - ); - - // Should add all 5 available UDT cells - expect(addedCount).toBe(5); - expect(completedTx.inputs.length).toBe(5); - - // Verify total UDT balance is 500 (5 cells × 100 UDT each) - const inputBalance = await udt.getInputsBalance(completedTx, client); - expect(inputBalance).toBe(ccc.numFrom(500)); - - // Verify all cells were added by checking outpoints - const addedOutpoints = completedTx.inputs.map( - (input) => input.previousOutput, - ); - for (const cell of mockUdtCells) { - expect(addedOutpoints.some((op) => op.eq(cell.outPoint))).toBe(true); - } - }); - - it("should add all available UDT cells to transaction with outputs", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { lock, type }, - { lock, type }, - ], - outputsData: [ - ccc.numLeToBytes(150, 16), // 150 UDT - ccc.numLeToBytes(200, 16), // 200 UDT - ], // Total: 350 UDT needed - }); - - const { tx: completedTx, addedCount } = await udt.completeInputsAll( - tx, - signer, - ); - - // Should add all 5 available UDT cells regardless of output requirements - expect(addedCount).toBe(5); - expect(completedTx.inputs.length).toBe(5); - - // Verify total UDT balance is 500 (all available) - const inputBalance = await udt.getInputsBalance(completedTx, client); - expect(inputBalance).toBe(ccc.numFrom(500)); - - // Verify output balance is still 350 - const outputBalance = await udt.getOutputsBalance(completedTx, client); - expect(outputBalance).toBe(ccc.numFrom(350)); - - // Should have 150 UDT excess balance (500 - 350) - const balanceBurned = await udt.getBalanceBurned(completedTx, client); - expect(balanceBurned).toBe(ccc.numFrom(150)); - }); - - it("should skip cells already used as inputs", async () => { - // Pre-add 2 of the mock cells as inputs - const tx = ccc.Transaction.from({ - inputs: [ - { previousOutput: mockUdtCells[0].outPoint }, - { previousOutput: mockUdtCells[1].outPoint }, - ], - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(100, 16)], - }); - - const { tx: completedTx, addedCount } = await udt.completeInputsAll( - tx, - signer, - ); - - // Should add the remaining 3 cells (cells 2, 3, 4) - expect(addedCount).toBe(3); - expect(completedTx.inputs.length).toBe(5); // 2 existing + 3 added - - // Verify total UDT balance is still 500 (all 5 cells) - const inputBalance = await udt.getInputsBalance(completedTx, client); - expect(inputBalance).toBe(ccc.numFrom(500)); - }); - - it("should return 0 when all UDT cells are already used as inputs", async () => { - // Pre-add all mock cells as inputs - const tx = ccc.Transaction.from({ - inputs: mockUdtCells.map((cell) => ({ previousOutput: cell.outPoint })), - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(100, 16)], - }); - - const { tx: completedTx, addedCount } = await udt.completeInputsAll( - tx, - signer, - ); - - // Should not add any new inputs - expect(addedCount).toBe(0); - expect(completedTx.inputs.length).toBe(5); // Same as before - - // Verify total UDT balance is still 500 - const inputBalance = await udt.getInputsBalance(completedTx, client); - expect(inputBalance).toBe(ccc.numFrom(500)); - }); - - it("should handle transaction with no UDT outputs", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { lock }, // Non-UDT output - ], - outputsData: ["0x"], - }); - - const { tx: completedTx, addedCount } = await udt.completeInputsAll( - tx, - signer, - ); - - // Should add all 5 UDT cells even though no UDT outputs - expect(addedCount).toBe(5); - expect(completedTx.inputs.length).toBe(5); - - // All 500 UDT will be "burned" since no UDT outputs - const balanceBurned = await udt.getBalanceBurned(completedTx, client); - expect(balanceBurned).toBe(ccc.numFrom(500)); - }); - - it("should work with mixed input types", async () => { - // Create a non-UDT cell - const nonUdtCell = ccc.Cell.from({ - outPoint: { txHash: "0x" + "f".repeat(64), index: 0 }, - cellOutput: { - capacity: ccc.fixedPointFrom(1000), - lock, - // No type script - }, - outputData: "0x", - }); - - // Pre-add the non-UDT cell as input - const tx = ccc.Transaction.from({ - inputs: [{ previousOutput: nonUdtCell.outPoint }], - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(100, 16)], - }); - - // Mock getCell to handle both UDT and non-UDT cells - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - const outPointObj = ccc.OutPoint.from(outPoint); - if (outPointObj.eq(nonUdtCell.outPoint)) { - return nonUdtCell; - } - return mockUdtCells.find((c) => c.outPoint.eq(outPointObj)); - }); - - const { tx: completedTx, addedCount } = await udt.completeInputsAll( - tx, - signer, - ); - - // Should add all 5 UDT cells - expect(addedCount).toBe(5); - expect(completedTx.inputs.length).toBe(6); // 1 non-UDT + 5 UDT - - // Verify only UDT balance is counted - const inputBalance = await udt.getInputsBalance(completedTx, client); - expect(inputBalance).toBe(ccc.numFrom(500)); - }); - - it("should handle empty cell collection gracefully", async () => { - // Mock findCells to return no cells - vi.spyOn(signer, "findCells").mockImplementation(async function* () { - // Return no cells - }); - - const tx = ccc.Transaction.from({ - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(100, 16)], - }); - - const { tx: completedTx, addedCount } = await udt.completeInputsAll( - tx, - signer, - ); - - // Should not add any inputs - expect(addedCount).toBe(0); - expect(completedTx.inputs.length).toBe(0); - - // UDT balance should be 0 - const inputBalance = await udt.getInputsBalance(completedTx, client); - expect(inputBalance).toBe(ccc.numFrom(0)); - }); - }); - - describe("getInputsBalance", () => { - it("should calculate total UDT balance from inputs", async () => { - const mockCells = [ - ccc.Cell.from({ - outPoint: { txHash: "0x" + "0".repeat(64), index: 0 }, - cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, - outputData: ccc.numLeToBytes(100, 16), // 100 UDT - }), - ccc.Cell.from({ - outPoint: { txHash: "0x" + "1".repeat(64), index: 0 }, - cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, - outputData: ccc.numLeToBytes(200, 16), // 200 UDT - }), - ]; - - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - return mockCells.find((c) => c.outPoint.eq(outPoint)); - }); - - const tx = ccc.Transaction.from({ - inputs: [ - { previousOutput: mockCells[0].outPoint }, - { previousOutput: mockCells[1].outPoint }, - ], - }); - - const balance = await udt.getInputsBalance(tx, client); - expect(balance).toBe(ccc.numFrom(300)); // 100 + 200 - }); - - it("should ignore inputs without matching type script", async () => { - const mockCells = [ - ccc.Cell.from({ - outPoint: { txHash: "0x" + "0".repeat(64), index: 0 }, - cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, - outputData: ccc.numLeToBytes(100, 16), // 100 UDT - }), - ccc.Cell.from({ - outPoint: { txHash: "0x" + "1".repeat(64), index: 0 }, - cellOutput: { capacity: ccc.fixedPointFrom(142), lock }, // No type script - outputData: "0x", - }), - ]; - - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - return mockCells.find((c) => c.outPoint.eq(outPoint)); - }); - - const tx = ccc.Transaction.from({ - inputs: [ - { previousOutput: mockCells[0].outPoint }, - { previousOutput: mockCells[1].outPoint }, - ], - }); - - const balance = await udt.getInputsBalance(tx, client); - expect(balance).toBe(ccc.numFrom(100)); // Only the UDT cell - }); - }); - - describe("getOutputsBalance", () => { - it("should calculate total UDT balance from outputs", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { lock, type }, - { lock, type }, - { lock }, // No type script - ], - outputsData: [ - ccc.numLeToBytes(100, 16), // 100 UDT - ccc.numLeToBytes(200, 16), // 200 UDT - "0x", // Not UDT - ], - }); - - const balance = await udt.getOutputsBalance(tx, client); - expect(balance).toBe(ccc.numFrom(300)); // 100 + 200, ignoring non-UDT output - }); - - it("should return 0 when no UDT outputs", async () => { - const tx = ccc.Transaction.from({ - outputs: [{ lock }], // No type script - outputsData: ["0x"], - }); - - const balance = await udt.getOutputsBalance(tx, client); - expect(balance).toBe(ccc.numFrom(0)); - }); - }); - - describe("completeChangeToLock", () => { - let mockUdtCells: ccc.Cell[]; - - beforeEach(() => { - mockUdtCells = Array.from({ length: 5 }, (_, i) => - ccc.Cell.from({ - outPoint: { - txHash: `0x${"0".repeat(63)}${i.toString(16)}`, - index: 0, - }, - cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, - outputData: ccc.numLeToBytes(100, 16), // 100 UDT each - }), - ); - - vi.spyOn(signer, "findCells").mockImplementation( - async function* (filter) { - if (filter.script && ccc.Script.from(filter.script).eq(type)) { - for (const cell of mockUdtCells) { - yield cell; - } - } - }, - ); - - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - return mockUdtCells.find((c) => c.outPoint.eq(outPoint)); - }); - }); - - it("should add change output when there's excess UDT balance", async () => { - const changeLock = ccc.Script.from({ - codeHash: "0x" + "9".repeat(64), - hashType: "type", - args: "0x1234", - }); - - const tx = ccc.Transaction.from({ - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(150, 16)], // Need 150 UDT - }); - - const completedTx = await udt.completeChangeToLock( - tx, - signer, - changeLock, - ); - - // Should have original output + change output - expect(completedTx.outputs.length).toBe(2); - expect(completedTx.outputs[1].lock.eq(changeLock)).toBe(true); - expect(completedTx.outputs[1].type?.eq(type)).toBe(true); - - // Change should be 50 UDT (200 input - 150 output) - const changeAmount = ccc.udtBalanceFrom(completedTx.outputsData[1]); - expect(changeAmount).toBe(ccc.numFrom(50)); - }); - - it("should not add change when no excess balance", async () => { - const changeLock = ccc.Script.from({ - codeHash: "0x" + "9".repeat(64), - hashType: "type", - args: "0x1234", - }); - - const tx = ccc.Transaction.from({ - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(200, 16)], // Need exactly 200 UDT - }); - - const completedTx = await udt.completeChangeToLock( - tx, - signer, - changeLock, - ); - - // Should only have original output - expect(completedTx.outputs.length).toBe(1); - }); - }); - - describe("completeBy", () => { - it("should use signer's recommended address for change", async () => { - const mockUdtCells = Array.from({ length: 3 }, (_, i) => - ccc.Cell.from({ - outPoint: { - txHash: `0x${"0".repeat(63)}${i.toString(16)}`, - index: 0, - }, - cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, - outputData: ccc.numLeToBytes(100, 16), - }), - ); - - vi.spyOn(signer, "findCells").mockImplementation( - async function* (filter) { - if (filter.script && ccc.Script.from(filter.script).eq(type)) { - for (const cell of mockUdtCells) { - yield cell; - } - } - }, - ); - - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - return mockUdtCells.find((c) => c.outPoint.eq(outPoint)); - }); - - const tx = ccc.Transaction.from({ - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(150, 16)], - }); - - const completedTx = await udt.completeBy(tx, signer); - - // Should have change output with signer's lock - expect(completedTx.outputs.length).toBe(2); - expect(completedTx.outputs[1].lock.eq(lock)).toBe(true); // Same as signer's lock - }); - }); - - describe("complete method with capacity handling", () => { - let mockUdtCells: ccc.Cell[]; - - beforeEach(() => { - // Create mock cells with different capacity values - mockUdtCells = [ - // Cell 0: 100 UDT, 142 CKB capacity (minimum for UDT cell) - ccc.Cell.from({ - outPoint: { txHash: "0x" + "0".repeat(64), index: 0 }, - cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type }, - outputData: ccc.numLeToBytes(100, 16), - }), - // Cell 1: 100 UDT, 200 CKB capacity (extra capacity) - ccc.Cell.from({ - outPoint: { txHash: "0x" + "1".repeat(64), index: 0 }, - cellOutput: { capacity: ccc.fixedPointFrom(200), lock, type }, - outputData: ccc.numLeToBytes(100, 16), - }), - // Cell 2: 100 UDT, 300 CKB capacity (more extra capacity) - ccc.Cell.from({ - outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, - cellOutput: { capacity: ccc.fixedPointFrom(300), lock, type }, - outputData: ccc.numLeToBytes(100, 16), - }), - ]; - - vi.spyOn(signer, "findCells").mockImplementation( - async function* (filter) { - if (filter.script && ccc.Script.from(filter.script).eq(type)) { - for (const cell of mockUdtCells) { - yield cell; - } - } - }, - ); - - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - return mockUdtCells.find((c) => c.outPoint.eq(outPoint)); - }); - }); - - it("should add extra UDT cells when change output requires additional capacity", async () => { - const changeLock = ccc.Script.from({ - codeHash: "0x" + "9".repeat(64), - hashType: "type", - args: "0x1234", - }); - - // Create a transaction that needs 50 UDT (less than one cell) - const tx = ccc.Transaction.from({ - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(50, 16)], - }); - - const completedTx = await udt.completeChangeToLock( - tx, - signer, - changeLock, - ); - - // Should have original output + change output - expect(completedTx.outputs.length).toBe(2); - - // Verify inputs were added to cover both UDT balance and capacity requirements - expect(completedTx.inputs.length).toBe(2); - - // Check that change output has correct UDT balance (should be input - 50) - const changeAmount = ccc.udtBalanceFrom(completedTx.outputsData[1]); - const inputBalance = await udt.getInputsBalance(completedTx, client); - expect(changeAmount).toBe(inputBalance - ccc.numFrom(50)); - - // Verify change output has correct type script - expect(completedTx.outputs[1].type?.eq(type)).toBe(true); - expect(completedTx.outputs[1].lock.eq(changeLock)).toBe(true); - - // Key assertion: verify that capacity is sufficient (positive fee) - const fee = await completedTx.getFee(client); - expect(fee).toBeGreaterThanOrEqual(ccc.Zero); - }); - - it("should handle capacity tweak parameter in completeInputsByBalance", async () => { - const tx = ccc.Transaction.from({ - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(50, 16)], // Need 50 UDT - }); - - // Add extra capacity requirement via capacityTweak that's reasonable - const extraCapacityNeeded = ccc.fixedPointFrom(1000); // Reasonable capacity requirement - const { addedCount } = await udt.completeInputsByBalance( - tx, - signer, - ccc.Zero, // No extra UDT balance needed - extraCapacityNeeded, // Extra capacity needed - ); - - // Should add cells to cover the capacity requirement - expect(addedCount).toBeGreaterThan(2); - - // Should have added at least one cell with capacity - expect(await udt.getInputsBalance(tx, client)).toBeGreaterThan(ccc.Zero); - }); - - it("should handle the two-phase capacity completion in complete method", async () => { - const changeLock = ccc.Script.from({ - codeHash: "0x" + "9".repeat(64), - hashType: "type", - args: "0x1234", - }); - - // Create a transaction that will need change - const tx = ccc.Transaction.from({ - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(50, 16)], // Need 50 UDT, will have 50 UDT change - }); - - // Track the calls to completeInputsByBalance to verify two-phase completion - const completeInputsByBalanceSpy = vi.spyOn( - udt, - "completeInputsByBalance", - ); - - const completedTx = await udt.completeChangeToLock( - tx, - signer, - changeLock, - ); - - // Should have called completeInputsByBalance twice: - // 1. First call: initial UDT balance completion - // 2. Second call: with extraCapacity for change output - expect(completeInputsByBalanceSpy).toHaveBeenCalledTimes(2); - - // Verify the second call included extraCapacity parameter - const secondCall = completeInputsByBalanceSpy.mock.calls[1]; - expect(secondCall[2]).toBe(ccc.Zero); // balanceTweak should be 0 - expect(secondCall[3]).toBeGreaterThan(ccc.Zero); // capacityTweak should be > 0 (change output capacity) - - // Should have change output - expect(completedTx.outputs.length).toBe(2); - const changeAmount = ccc.udtBalanceFrom(completedTx.outputsData[1]); - expect(changeAmount).toBe( - (await udt.getInputsBalance(completedTx, client)) - ccc.numFrom(50), - ); // 100 input - 50 output = 50 change - - completeInputsByBalanceSpy.mockRestore(); - }); - - it("should handle completeChangeToOutput correctly", async () => { - // Create a transaction with an existing UDT output that will receive change - const tx = ccc.Transaction.from({ - outputs: [ - { lock, type }, // This will be the change output - ], - outputsData: [ - ccc.numLeToBytes(50, 16), // Initial amount in change output - ], - }); - - const completedTx = await udt.completeChangeToOutput(tx, signer, 0); // Use first output as change - - // Should have added inputs - expect(completedTx.inputs.length).toBeGreaterThan(0); - - // The first output should now contain the original amount plus any excess from inputs - const changeAmount = ccc.udtBalanceFrom(completedTx.outputsData[0]); - const inputBalance = await udt.getInputsBalance(completedTx, client); - - // Change output should have: original amount + excess from inputs - // Since we only have one output, all input balance should go to it - expect(changeAmount).toBe(inputBalance); - expect(changeAmount).toBeGreaterThan(ccc.numFrom(50)); // More than the original amount - }); - - it("should throw error when change output is not a UDT cell", async () => { - const tx = ccc.Transaction.from({ - outputs: [{ lock }], // No type script - not a UDT cell - outputsData: ["0x"], - }); - - await expect(udt.completeChangeToOutput(tx, signer, 0)).rejects.toThrow( - "Change output must be a UDT cell", - ); - }); - - it("should handle insufficient capacity gracefully", async () => { - // Mock to return cells with very low capacity - const lowCapacityCells = [ - ccc.Cell.from({ - outPoint: { txHash: "0x" + "0".repeat(64), index: 0 }, - cellOutput: { capacity: ccc.fixedPointFrom(61), lock, type }, // Very low capacity - outputData: ccc.numLeToBytes(100, 16), - }), - ]; - - vi.spyOn(signer, "findCells").mockImplementation( - async function* (filter) { - if (filter.script && ccc.Script.from(filter.script).eq(type)) { - for (const cell of lowCapacityCells) { - yield cell; - } - } - }, - ); - - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - return lowCapacityCells.find((c) => c.outPoint.eq(outPoint)); - }); - - const changeLock = ccc.Script.from({ - codeHash: "0x" + "9".repeat(64), - hashType: "type", - args: "0x1234", - }); - - const tx = ccc.Transaction.from({ - outputs: [{ lock, type }], - outputsData: [ccc.numLeToBytes(50, 16)], - }); - - // Should still complete successfully even with capacity constraints - // The UDT logic should focus on UDT balance completion - const completedTx = await udt.completeChangeToLock( - tx, - signer, - changeLock, - ); - - expect(completedTx.inputs.length).toBe(1); - expect(completedTx.outputs.length).toBe(2); // Original + change - - expect(await completedTx.getFee(client)).toBeLessThan(0n); - }); - - it("should handle capacity calculation when transaction has non-UDT inputs with high capacity", async () => { - // Create a non-UDT cell with very high capacity - const nonUdtCell = ccc.Cell.from({ - outPoint: { txHash: "0x" + "f".repeat(64), index: 0 }, - cellOutput: { - capacity: ccc.fixedPointFrom(10000), // Very high capacity (100 CKB) - lock, - // No type script - this is a regular CKB cell - }, - outputData: "0x", // Empty data - }); - - // Create a transaction that already has the non-UDT input - const tx = ccc.Transaction.from({ - inputs: [ - { previousOutput: nonUdtCell.outPoint }, // Pre-existing non-UDT input - ], - outputs: [ - { lock, type }, // UDT output requiring 50 UDT - ], - outputsData: [ - ccc.numLeToBytes(50, 16), // Need 50 UDT - ], - }); - - // Mock getCell to return both UDT and non-UDT cells - vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { - const outPointObj = ccc.OutPoint.from(outPoint); - if (outPointObj.eq(nonUdtCell.outPoint)) { - return nonUdtCell; - } - return mockUdtCells.find((c) => c.outPoint.eq(outPointObj)); - }); - - const resultTx = await udt.completeBy(tx, signer); - - // Should add exactly 2 UDT cell to satisfy the 50 UDT requirement & extra occupation from the change cell - expect(resultTx.inputs.length).toBe(3); // 1 non-UDT + 2 UDT - - // Verify UDT balance is satisfied - const inputBalance = await udt.getInputsBalance(resultTx, client); - expect(inputBalance).toBe(ccc.numFrom(200)); - }); - }); -}); diff --git a/packages/udt/src/udt/index.ts b/packages/udt/src/udt/index.ts index 13350e8f8..3df9501ea 100644 --- a/packages/udt/src/udt/index.ts +++ b/packages/udt/src/udt/index.ts @@ -1,194 +1,6 @@ import { ccc } from "@ckb-ccc/core"; import { ssri } from "@ckb-ccc/ssri"; -/** - * Error thrown when there are insufficient UDT coins to complete a transaction. - * This error provides detailed information about the shortfall, including the - * exact amount needed, the UDT type script, and an optional custom reason. - * - * @public - * @category Error - * @category UDT - * - * @example - * ```typescript - * // This error is typically thrown automatically by UDT methods - * try { - * await udt.completeInputsByBalance(tx, signer); - * } catch (error) { - * if (error instanceof ErrorUdtInsufficientCoin) { - * console.log(`Error: ${error.message}`); - * console.log(`Shortfall: ${error.amount} UDT tokens`); - * console.log(`UDT type script: ${error.type.toHex()}`); - * } - * } - * ``` - */ -export class ErrorUdtInsufficientCoin extends Error { - /** - * The amount of UDT coins that are insufficient (shortfall amount). - * This represents how many more UDT tokens are needed to complete the operation. - */ - public readonly amount: ccc.Num; - - /** - * The type script of the UDT that has insufficient balance. - * This identifies which specific UDT token is lacking sufficient funds. - */ - public readonly type: ccc.Script; - - /** - * Creates a new ErrorUdtInsufficientCoin instance. - * - * @param info - Configuration object for the error - * @param info.amount - The amount of UDT coins that are insufficient (shortfall amount) - * @param info.type - The type script of the UDT that has insufficient balance - * @param info.reason - Optional custom reason message. If not provided, a default message will be generated - * - * @example - * ```typescript - * // Manual creation (typically not needed as the error is thrown automatically) - * const error = new ErrorUdtInsufficientCoin({ - * amount: ccc.numFrom(1000), - * type: udtScript, - * reason: "Custom insufficient balance message" - * }); - * - * // More commonly, catch the error when it's thrown by UDT methods - * try { - * const result = await udt.completeInputsByBalance(tx, signer); - * } catch (error) { - * if (error instanceof ErrorUdtInsufficientCoin) { - * // Handle the insufficient balance error - * console.error(`Insufficient UDT: need ${error.amount} more tokens`); - * } - * } - * ``` - * - * @remarks - * The error message format depends on whether a custom reason is provided: - * - With custom reason: "Insufficient coin, {custom reason}" - * - Without custom reason: "Insufficient coin, need {amount} extra coin" - */ - constructor(info: { - amount: ccc.NumLike; - type: ccc.ScriptLike; - reason?: string; - }) { - const amount = ccc.numFrom(info.amount); - const type = ccc.Script.from(info.type); - super(`Insufficient coin, ${info.reason ?? `need ${amount} extra coin`}`); - this.amount = amount; - this.type = type; - } -} - -/** - * Configuration object type for UDT instances. - * This type defines the optional configuration parameters that can be passed - * when creating a UDT instance to customize its behavior. - * - * @public - * @category Configuration - * @category UDT - */ -export type UdtConfigLike = { - /** - * Optional SSRI executor instance for advanced UDT operations. - * When provided, enables SSRI-compliant features like metadata queries - * and advanced transfer operations. - */ - executor?: ssri.Executor | null; - - /** - * Optional custom search filter for finding UDT cells. - * If not provided, a default filter will be created that matches - * cells with the UDT's type script and valid output data length. - */ - filter?: ccc.ClientIndexerSearchKeyFilterLike | null; -}; - -/** - * Configuration class for UDT instances. - * This class provides a structured way to handle UDT configuration parameters - * and includes factory methods for creating instances from configuration-like objects. - * - * @public - * @category Configuration - * @category UDT - * - * @example - * ```typescript - * // Create configuration with executor - * const config = new UdtConfig(ssriExecutor); - * - * // Create configuration with both executor and filter - * const config = new UdtConfig( - * ssriExecutor, - * ccc.ClientIndexerSearchKeyFilter.from({ - * script: udtScript, - * outputDataLenRange: [16, 32] - * }) - * ); - * - * // Create from configuration-like object - * const config = UdtConfig.from({ - * executor: ssriExecutor, - * filter: { script: udtScript, outputDataLenRange: [16, "0xffffffff"] } - * }); - * ``` - */ -export class UdtConfig { - /** - * Creates a new UdtConfig instance. - * - * @param executor - Optional SSRI executor for advanced UDT operations - * @param filter - Optional search filter for finding UDT cells - */ - constructor( - public readonly executor?: ssri.Executor, - public readonly filter?: ccc.ClientIndexerSearchKeyFilter, - ) {} - - /** - * Creates a UdtConfig instance from a configuration-like object. - * This factory method provides a convenient way to create UdtConfig instances - * from plain objects, automatically converting filter-like objects to proper - * ClientIndexerSearchKeyFilter instances. - * - * @param configLike - Configuration-like object containing executor and/or filter - * @returns A new UdtConfig instance with the specified configuration - * - * @example - * ```typescript - * // Create from object with executor only - * const config = UdtConfig.from({ executor: ssriExecutor }); - * - * // Create from object with filter only - * const config = UdtConfig.from({ - * filter: { - * script: udtScript, - * outputDataLenRange: [16, "0xffffffff"] - * } - * }); - * - * // Create from object with both - * const config = UdtConfig.from({ - * executor: ssriExecutor, - * filter: { script: udtScript, outputDataLenRange: [16, 32] } - * }); - * ``` - */ - static from(configLike: UdtConfigLike) { - return new UdtConfig( - configLike.executor ?? undefined, - configLike.filter - ? ccc.ClientIndexerSearchKeyFilter.from(configLike.filter) - : undefined, - ); - } -} - /** * Represents a User Defined Token (UDT) script compliant with the SSRI protocol. * @@ -201,144 +13,35 @@ export class UdtConfig { * @category Token */ export class Udt extends ssri.Trait { - /** - * The type script that uniquely identifies this UDT token. - * This script is used to distinguish UDT cells from other cell types and - * to identify which cells belong to this specific UDT token. - * - * @remarks - * The script contains: - * - `codeHash`: Hash of the UDT script code - * - `hashType`: How the code hash should be interpreted ("type" or "data") - * - `args`: Arguments that make this UDT unique (often contains token-specific data) - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * console.log(`UDT script hash: ${udt.script.hash()}`); - * console.log(`UDT args: ${udt.script.args}`); - * - * // Check if a cell belongs to this UDT - * const isUdt = udt.isUdt(cell); - * ``` - */ public readonly script: ccc.Script; - /** - * The search filter used to find UDT cells controlled by signers. - * This filter is automatically configured to match cells with this UDT's type script - * and appropriate output data length (minimum 16 bytes for UDT balance storage). - * - * @remarks - * The filter includes: - * - `script`: Set to this UDT's type script - * - `outputDataLenRange`: [16, "0xffffffff"] to ensure valid UDT cells - * - * This filter is used internally by methods like: - * - `calculateInfo()` and `calculateBalance()` for scanning all UDT cells - * - `completeInputs()` and related methods for finding suitable input cells - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * - * // The filter is used internally, but you can access it if needed - * console.log(`Filter script: ${udt.filter.script?.hash()}`); - * console.log(`Output data range: ${udt.filter.outputDataLenRange}`); - * - * // Manually find cells using the same filter - * for await (const cell of signer.findCells(udt.filter)) { - * console.log(`Found UDT cell with balance: ${ccc.udtBalanceFrom(cell.outputData)}`); - * } - * ``` - */ - public readonly filter: ccc.ClientIndexerSearchKeyFilter; - /** * Constructs a new UDT (User Defined Token) script instance. - * By default it is a SSRI-compliant UDT. This class supports both SSRI-compliant UDTs and legacy sUDT/xUDT standard tokens. - * - * @param code - The script code cell outpoint of the UDT. This points to the cell containing the UDT script code - * @param script - The type script of the UDT that uniquely identifies this token - * @param config - Optional configuration object for advanced settings - * @param config.executor - The SSRI executor instance for advanced UDT operations. If provided, enables SSRI-compliant features - * @param config.filter - Custom search filter for finding UDT cells. If not provided, a default filter will be created + * By default it is a SSRI-compliant UDT. By providing `xudtType`, it is compatible with the legacy xUDT. * + * @param executor - The SSRI executor instance. + * @param code - The script code cell of the UDT. + * @param script - The type script of the UDT. * @example * ```typescript - * // Basic UDT instance - * const udt = new Udt( - * { txHash: "0x...", index: 0 }, // code outpoint - * { codeHash: "0x...", hashType: "type", args: "0x..." } // type script - * ); - * - * // UDT with SSRI executor for advanced features - * const ssriUdt = new Udt( - * codeOutPoint, - * typeScript, - * { executor: ssriExecutor } - * ); - * - * // UDT with custom filter (advanced usage) - * const customUdt = new Udt( - * codeOutPoint, - * typeScript, - * { - * filter: { - * script: typeScript, - * outputDataLenRange: [16, 32], // Only cells with 16-32 bytes output data - * } - * } - * ); + * const udt = new Udt(executor, code, script); * ``` - * - * @remarks - * **Default Filter Behavior:** - * If no custom filter is provided, a default filter is created with: - * - `script`: Set to the provided UDT type script - * - `outputDataLenRange`: [16, "0xffffffff"] to match valid UDT cells - * - * **SSRI Compliance:** - * When an executor is provided, the UDT instance can use SSRI-compliant features like: - * - Advanced transfer operations - * - Metadata queries (name, symbol, decimals, icon) - * - Custom UDT logic execution - * - * **Legacy Support:** - * Even without an executor, the UDT class supports basic operations for legacy sUDT/xUDT tokens. */ constructor( code: ccc.OutPointLike, script: ccc.ScriptLike, - config?: UdtConfigLike | null, + config?: { + executor?: ssri.Executor | null; + } | null, ) { super(code, config?.executor); this.script = ccc.Script.from(script); - this.filter = ccc.ClientIndexerSearchKeyFilter.from( - config?.filter ?? { - script: this.script, - outputDataLenRange: [16, "0xffffffff"], - }, - ); } /** * Retrieves the human-readable name of the User Defined Token. - * This method queries the UDT script to get the token's display name, - * which is typically used in user interfaces and wallets. * - * @param context - Optional script execution context for additional parameters - * @returns A promise resolving to an ExecutorResponse containing the token's name, - * or undefined if the name is not available or the script doesn't support this method - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const nameResponse = await udt.name(); - * if (nameResponse.res) { - * console.log(`Token name: ${nameResponse.res}`); - * } - * ``` + * @returns A promise resolving to the token's name. */ async name( context?: ssri.ContextScript, @@ -357,22 +60,8 @@ export class Udt extends ssri.Trait { } /** - * Retrieves the symbol (ticker) of the User Defined Token. - * The symbol is typically a short abbreviation used to identify the token, - * similar to stock ticker symbols (e.g., "BTC", "ETH", "USDT"). - * - * @param context - Optional script execution context for additional parameters - * @returns A promise resolving to an ExecutorResponse containing the token's symbol, - * or undefined if the symbol is not available or the script doesn't support this method - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const symbolResponse = await udt.symbol(); - * if (symbolResponse.res) { - * console.log(`Token symbol: ${symbolResponse.res}`); - * } - * ``` + * Retrieves the symbol of the UDT. + * @returns The symbol of the UDT. */ async symbol( context?: ssri.ContextScript, @@ -396,24 +85,8 @@ export class Udt extends ssri.Trait { } /** - * Retrieves the number of decimal places for the User Defined Token. - * This value determines how the token amount should be displayed and interpreted. - * For example, if decimals is 8, then a balance of 100000000 represents 1.0 tokens. - * - * @param context - Optional script execution context for additional parameters - * @returns A promise resolving to an ExecutorResponse containing the number of decimals, - * or undefined if decimals are not specified or the script doesn't support this method - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const decimalsResponse = await udt.decimals(); - * if (decimalsResponse.res !== undefined) { - * console.log(`Token decimals: ${decimalsResponse.res}`); - * // Convert raw amount to human-readable format - * const humanReadable = rawAmount / (10 ** Number(decimalsResponse.res)); - * } - * ``` + * Retrieves the decimals of the UDT. + * @returns The decimals of the UDT. */ async decimals( context?: ssri.ContextScript, @@ -437,25 +110,8 @@ export class Udt extends ssri.Trait { } /** - * Retrieves the icon URL or data URI for the User Defined Token. - * This can be used to display a visual representation of the token in user interfaces. - * The returned value may be a URL pointing to an image file or a data URI containing - * the image data directly. - * - * @param context - Optional script execution context for additional parameters - * @returns A promise resolving to an ExecutorResponse containing the icon URL/data, - * or undefined if no icon is available or the script doesn't support this method - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const iconResponse = await udt.icon(); - * if (iconResponse.res) { - * // Use the icon in UI - * const imgElement = document.createElement('img'); - * imgElement.src = iconResponse.res; - * } - * ``` + * Retrieves the icon of the UDT + * @returns The icon of the UDT. */ async icon( context?: ssri.ContextScript, @@ -473,67 +129,13 @@ export class Udt extends ssri.Trait { return ssri.ExecutorResponse.new(undefined); } - /** - * Adds the UDT script code as a cell dependency to the transaction. - * This method ensures that the transaction includes the necessary cell dependency - * for the UDT script code, which is required for any transaction that uses this UDT. - * - * @param txLike - The transaction to add the cell dependency to - * @returns A new transaction with the UDT code cell dependency added - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * - * // Create a basic transaction - * let tx = ccc.Transaction.from({ - * outputs: [{ lock: recipientLock, type: udt.script }], - * outputsData: [ccc.numLeToBytes(100, 16)] - * }); - * - * // Add UDT code dependency - * tx = udt.addCellDeps(tx); - * - * // Now the transaction can be completed and sent - * await tx.completeInputsByCapacity(signer); - * await tx.completeFeeBy(signer); - * ``` - * - * @remarks - * **When to Use:** - * - When manually constructing transactions that involve UDT cells - * - Before sending any transaction that creates or consumes UDT cells - * - This is automatically called by methods like `transfer()` and `mint()` - * - * **Cell Dependency Details:** - * - Adds the UDT script code outpoint as a "code" type dependency - * - This allows the transaction to reference and execute the UDT script - * - Required for script validation during transaction processing - * - * **Note:** Most high-level UDT methods automatically add this dependency, - * so manual usage is typically only needed for custom transaction construction. - */ - addCellDeps(txLike: ccc.TransactionLike): ccc.Transaction { - const tx = ccc.Transaction.from(txLike); - tx.addCellDeps({ - outPoint: this.code, - depType: "code", - }); - return tx; - } - /** * Transfers UDT to specified addresses. - * This method creates a transaction that transfers UDT tokens to one or more recipients. - * It can build upon an existing transaction to achieve combined actions. - * - * @param signer - The signer that will authorize and potentially pay for the transaction - * @param transfers - Array of transfer operations to perform - * @param transfers.to - The lock script of the recipient who will receive the tokens - * @param transfers.amount - The amount of tokens to transfer to this recipient (in smallest unit) - * @param tx - Optional existing transaction to build upon. If not provided, a new transaction will be created - * @returns A promise resolving to an ExecutorResponse containing the transaction with transfer operations - * + * @param tx - Transfer on the basis of an existing transaction to achieve combined actions. If not provided, a new transaction will be created. + * @param transfers - The array of transfers. + * @param transfers.to - The receiver of token. + * @param transfers.amount - The amount of token to the receiver. + * @returns The transaction result. * @tag Mutation - This method represents a mutation of the onchain state and will return a transaction object. * @example * ```typescript @@ -552,12 +154,12 @@ export class Udt extends ssri.Trait { * }, * ); * - * const { res: tx } = await udt.transfer( + * const { res: tx } = await udtTrait.transfer( * signer, * [{ to, amount: 100 }], * ); * - * const completedTx = await udt.completeBy(tx, signer); + * const completedTx = udt.completeUdtBy(tx, signer); * await completedTx.completeInputsByCapacity(signer); * await completedTx.completeFeeBy(signer); * const transferTxHash = await signer.sendTransaction(completedTx); @@ -606,47 +208,21 @@ export class Udt extends ssri.Trait { } resTx = ssri.ExecutorResponse.new(transfer); } - - return resTx.map((tx) => this.addCellDeps(tx)); + resTx.res.addCellDeps({ + outPoint: this.code, + depType: "code", + }); + return resTx; } /** - * Mints new tokens to specified addresses. - * This method creates new UDT tokens and assigns them to the specified recipients. - * The minting operation requires appropriate permissions and may be restricted - * based on the UDT's implementation. - * - * @param signer - The signer that will authorize and potentially pay for the transaction - * @param mints - Array of mint operations to perform - * @param mints.to - The lock script of the recipient who will receive the minted tokens - * @param mints.amount - The amount of tokens to mint for this recipient (in smallest unit) - * @param tx - Optional existing transaction to build upon. If not provided, a new transaction will be created - * @returns A promise resolving to an ExecutorResponse containing the transaction with mint operations - * + * Mints new tokens to specified addresses. See the example in `transfer` as they are similar. + * @param tx - Optional existing transaction to build upon + * @param mints - Array of mints + * @param mints.to - receiver of token + * @param mints.amount - amount to the receiver + * @returns The transaction containing the mint operation * @tag Mutation - This method represents a mutation of the onchain state - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const { script: recipientLock } = await ccc.Address.fromString(recipientAddress, signer.client); - * - * const mintResponse = await udt.mint( - * signer, - * [ - * { to: recipientLock, amount: ccc.fixedPointFrom(1000) }, // Mint 1000 tokens - * { to: anotherLock, amount: ccc.fixedPointFrom(500) } // Mint 500 tokens - * ] - * ); - * - * // Complete the transaction - * const tx = mintResponse.res; - * await tx.completeInputsByCapacity(signer); - * await tx.completeFeeBy(signer, changeLock); - * - * const txHash = await signer.sendTransaction(tx); - * ``` - * - * @throws May throw if the signer doesn't have minting permissions or if the UDT doesn't support minting */ async mint( signer: ccc.Signer, @@ -691,803 +267,40 @@ export class Udt extends ssri.Trait { } resTx = ssri.ExecutorResponse.new(mint); } - - return resTx.map((tx) => this.addCellDeps(tx)); - } - - /** - * Checks if a cell is a valid UDT cell for this token. - * A valid UDT cell must have this UDT's type script and contain at least 16 bytes of output data - * (the minimum required for storing the UDT balance as a 128-bit little-endian integer). - * - * @param cellOutputLike - The cell output to check - * @param outputData - The output data of the cell - * @returns True if the cell is a valid UDT cell for this token, false otherwise - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const cellOutput = { lock: someLock, type: udt.script }; - * const outputData = ccc.numLeToBytes(1000, 16); // 1000 UDT balance - * - * const isValid = udt.isUdt({ cellOutput, outputData }); - * console.log(`Is valid UDT cell: ${isValid}`); // true - * ``` - * - * @remarks - * The method checks two conditions: - * 1. The cell's type script matches this UDT's script - * 2. The output data is at least 16 bytes long (required for UDT balance storage) - */ - isUdt(cell: { cellOutput: ccc.CellOutputLike; outputData: ccc.HexLike }) { - return ( - (ccc.CellOutput.from(cell.cellOutput).type?.eq(this.script) ?? false) && - ccc.bytesFrom(cell.outputData).length >= 16 - ); - } - - /** - * Retrieves comprehensive information about UDT inputs in a transaction. - * This method analyzes all input cells and returns detailed statistics including - * total UDT balance, total capacity occupied, and the number of UDT cells. - * - * @param txLike - The transaction to analyze - * @param client - The client to fetch input cell data - * @returns A promise resolving to an object containing: - * - balance: Total UDT balance from all input cells - * - capacity: Total capacity occupied by all UDT input cells - * - count: Number of UDT input cells - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const tx = ccc.Transaction.from(existingTransaction); - * - * const inputsInfo = await udt.getInputsInfo(tx, client); - * console.log(`UDT inputs: ${inputsInfo.count} cells`); - * console.log(`Total UDT balance: ${inputsInfo.balance}`); - * console.log(`Total capacity: ${inputsInfo.capacity}`); - * ``` - * - * @remarks - * This method provides more comprehensive information than `getInputsBalance`, - * making it useful for transaction analysis, fee calculation, and UI display. - * Only cells with this UDT's type script are included in the statistics. - */ - async getInputsInfo( - txLike: ccc.TransactionLike, - client: ccc.Client, - ): Promise<{ - balance: ccc.Num; - capacity: ccc.Num; - count: number; - }> { - const tx = ccc.Transaction.from(txLike); - const [balance, capacity, count] = await ccc.reduceAsync( - tx.inputs, - async (acc, input) => { - const { cellOutput, outputData } = await input.getCell(client); - if (!this.isUdt({ cellOutput, outputData })) { - return acc; - } - - return [ - acc[0] + ccc.udtBalanceFrom(outputData), - acc[1] + cellOutput.capacity, - acc[2] + 1, - ]; - }, - [ccc.Zero, ccc.Zero, 0], - ); - - return { - balance, - capacity, - count, - }; - } - - /** - * Calculates the total UDT balance from all inputs in a transaction. - * This method examines each input cell and sums up the UDT amounts - * for cells that have this UDT's type script. - * - * @param txLike - The transaction to analyze - * @param client - The client to fetch input cell data - * @returns A promise resolving to the total UDT balance from all inputs - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const tx = ccc.Transaction.from(existingTransaction); - * - * const inputBalance = await udt.getInputsBalance(tx, client); - * console.log(`Total UDT input balance: ${inputBalance}`); - * ``` - * - * @remarks - * This method only counts inputs that have the same type script as this UDT instance. - * Inputs without a type script or with different type scripts are ignored. - */ - async getInputsBalance( - txLike: ccc.TransactionLike, - client: ccc.Client, - ): Promise { - return (await this.getInputsInfo(txLike, client)).balance; - } - - /** - * Retrieves comprehensive information about UDT outputs in a transaction. - * This method analyzes all output cells and returns detailed statistics including - * total UDT balance, total capacity occupied, and the number of UDT cells. - * - * @param txLike - The transaction to analyze - * @param _client - The client parameter (unused for outputs since data is already available) - * @returns A promise resolving to an object containing: - * - balance: Total UDT balance from all output cells - * - capacity: Total capacity occupied by all UDT output cells - * - count: Number of UDT output cells - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const tx = ccc.Transaction.from({ - * outputs: [ - * { lock: recipientLock, type: udt.script }, - * { lock: changeLock, type: udt.script } - * ], - * outputsData: [ - * ccc.numLeToBytes(1000, 16), // 1000 UDT to recipient - * ccc.numLeToBytes(500, 16) // 500 UDT as change - * ] - * }); - * - * const outputsInfo = await udt.getOutputsInfo(tx, client); - * console.log(`UDT outputs: ${outputsInfo.count} cells`); - * console.log(`Total UDT balance: ${outputsInfo.balance}`); // 1500 - * console.log(`Total capacity: ${outputsInfo.capacity}`); - * ``` - * - * @remarks - * This method provides more comprehensive information than `getOutputsBalance`, - * making it useful for transaction validation, analysis, and UI display. - * Only cells with this UDT's type script are included in the statistics. - * This is an async method for consistency with `getInputsInfo`, though it doesn't - * actually need to fetch data since output information is already available. - */ - async getOutputsInfo( - txLike: ccc.TransactionLike, - _client: ccc.Client, - ): Promise<{ - balance: ccc.Num; - capacity: ccc.Num; - count: number; - }> { - const tx = ccc.Transaction.from(txLike); - const [balance, capacity, count] = tx.outputs.reduce( - (acc, output, i) => { - if ( - !this.isUdt({ cellOutput: output, outputData: tx.outputsData[i] }) - ) { - return acc; - } - - return [ - acc[0] + ccc.udtBalanceFrom(tx.outputsData[i]), - acc[1] + output.capacity, - acc[2] + 1, - ]; - }, - [ccc.Zero, ccc.Zero, 0], - ); - - return { - balance, - capacity, - count, - }; - } - - /** - * Calculates the total UDT balance from all outputs in a transaction. - * This method examines each output cell and sums up the UDT amounts - * for cells that have this UDT's type script. - * - * @param txLike - The transaction to analyze - * @param client - The client parameter (passed to getOutputsInfo for consistency) - * @returns A promise resolving to the total UDT balance from all outputs - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const tx = ccc.Transaction.from({ - * outputs: [ - * { lock: recipientLock, type: udt.script }, - * { lock: changeLock, type: udt.script } - * ], - * outputsData: [ - * ccc.numLeToBytes(1000, 16), // 1000 UDT to recipient - * ccc.numLeToBytes(500, 16) // 500 UDT as change - * ] - * }); - * - * const outputBalance = await udt.getOutputsBalance(tx, client); - * console.log(`Total UDT output balance: ${outputBalance}`); // 1500 - * ``` - * - * @remarks - * This method only counts outputs that have the same type script as this UDT instance. - * Outputs without a type script or with different type scripts are ignored. - * This method is a convenience wrapper around `getOutputsInfo` that returns only the balance. - */ - async getOutputsBalance( - txLike: ccc.TransactionLike, - client: ccc.Client, - ): Promise { - return (await this.getOutputsInfo(txLike, client)).balance; - } - - /** - * Calculates the net UDT balance that would be burned (destroyed) in a transaction. - * This is the difference between the total UDT balance in inputs and outputs. - * A positive value indicates UDT tokens are being burned, while a negative value - * indicates more UDT is being created than consumed (which may require minting permissions). - * - * @param txLike - The transaction to analyze - * @param client - The client to fetch input cell data - * @returns A promise resolving to the net UDT balance burned (inputs - outputs) - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * const tx = ccc.Transaction.from(existingTransaction); - * - * const burned = await udt.getBalanceBurned(tx, client); - * if (burned > 0) { - * console.log(`${burned} UDT tokens will be burned`); - * } else if (burned < 0) { - * console.log(`${-burned} UDT tokens will be created`); - * } else { - * console.log('UDT balance is conserved'); - * } - * ``` - * - * @remarks - * This method is useful for: - * - Validating transaction balance conservation - * - Calculating how much UDT is being destroyed in burn operations - * - Detecting minting operations (negative burned balance) - * - Ensuring sufficient UDT inputs are provided for transfers - */ - async getBalanceBurned( - txLike: ccc.TransactionLike, - client: ccc.Client, - ): Promise { - const tx = ccc.Transaction.from(txLike); - return ( - (await this.getInputsBalance(tx, client)) - - (await this.getOutputsBalance(tx, client)) - ); - } - - /** - * Low-level method to complete UDT inputs for a transaction using a custom accumulator function. - * This method provides maximum flexibility for input selection by allowing custom logic - * through the accumulator function. It's primarily used internally by other completion methods. - * - * @template T - The type of the accumulator value - * @param txLike - The transaction to complete with UDT inputs - * @param from - The signer that will provide UDT inputs - * @param accumulator - Function that determines when to stop adding inputs based on accumulated state - * @param init - Initial value for the accumulator - * @returns A promise resolving to an object containing: - * - tx: The transaction with added inputs - * - addedCount: Number of inputs that were added - * - accumulated: Final accumulator value (undefined if target was reached) - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * - * // Custom accumulator to track both balance and capacity - * const result = await udt.completeInputs( - * tx, - * signer, - * ([balanceAcc, capacityAcc], cell) => { - * const balance = ccc.udtBalanceFrom(cell.outputData); - * const newBalance = balanceAcc + balance; - * const newCapacity = capacityAcc + cell.cellOutput.capacity; - * - * // Stop when we have enough balance and capacity - * return newBalance >= requiredBalance && newCapacity >= requiredCapacity - * ? undefined // Stop adding inputs - * : [newBalance, newCapacity]; // Continue with updated accumulator - * }, - * [ccc.Zero, ccc.Zero] // Initial [balance, capacity] - * ); - * ``` - * - * @remarks - * This is a low-level method that most users won't need to call directly. - * Use `completeInputsByBalance` for typical UDT input completion needs. - * The accumulator function should return `undefined` to stop adding inputs, - * or return an updated accumulator value to continue. - */ - async completeInputs( - txLike: ccc.TransactionLike, - from: ccc.Signer, - accumulator: ( - acc: T, - v: ccc.Cell, - i: number, - array: ccc.Cell[], - ) => Promise | T | undefined, - init: T, - ): Promise<{ - tx: ccc.Transaction; - addedCount: number; - accumulated?: T; - }> { - const tx = ccc.Transaction.from(txLike); - const res = await tx.completeInputs(from, this.filter, accumulator, init); - - return { - ...res, - tx, - }; - } - - /** - * Completes UDT inputs for a transaction to satisfy both UDT balance and capacity requirements. - * This method implements intelligent input selection that considers both UDT token balance - * and cell capacity constraints, optimizing for minimal cell usage while meeting all requirements. - * It uses sophisticated balance calculations and early exit optimizations for efficiency. - * - * @param txLike - The transaction to complete with UDT inputs - * @param from - The signer that will provide UDT inputs - * @param balanceTweak - Optional additional UDT balance requirement beyond outputs (default: 0) - * @param capacityTweak - Optional additional CKB capacity requirement beyond outputs (default: 0) - * @returns A promise resolving to an object containing: - * - tx: The modified transaction with added UDT inputs - * - addedCount: Number of UDT input cells that were added - * - * @throws {ErrorUdtInsufficientCoin} When there are insufficient UDT cells to cover the required balance - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * - * // Basic usage: add inputs to cover UDT outputs - * const tx = ccc.Transaction.from({ - * outputs: [{ lock: recipientLock, type: udt.script }], - * outputsData: [ccc.numLeToBytes(1000, 16)] - * }); - * - * const { tx: completedTx, addedCount } = await udt.completeInputsByBalance(tx, signer); - * console.log(`Added ${addedCount} UDT inputs to cover 1000 UDT requirement`); - * - * // Advanced usage: with balance and capacity tweaks - * const { tx: advancedTx, addedCount: advancedCount } = await udt.completeInputsByBalance( - * tx, - * signer, - * ccc.numFrom(100), // Extra 100 UDT balance needed - * ccc.fixedPointFrom(5000) // Extra 5000 capacity needed - * ); - * ``` - * - * @remarks - * This method implements sophisticated dual-constraint input selection with the following logic: - * - * **Balance Calculations:** - * - UDT balance deficit: `inputBalance - outputBalance - balanceTweak` - * - Capacity balance with fee optimization: `min(inputCapacity - outputCapacity, estimatedFee) - capacityTweak` - * - The capacity calculation tries to avoid extra occupation by UDT cells and compress UDT state - * - * **Early Exit Optimization:** - * - Returns immediately with `addedCount: 0` if both balance and capacity constraints are satisfied - * - Avoids unnecessary input addition when existing inputs are sufficient - * - * **Smart Input Selection:** - * - Uses accumulator pattern to track both UDT balance and capacity during selection - * - Continues adding inputs until both constraints are satisfied: `balanceAcc >= 0 && capacityAcc >= 0` - * - Prioritizes providing sufficient capacity through UDT cells to avoid extra non-UDT inputs - * - * **Error Handling:** - * - Throws `ErrorUdtInsufficientCoin` with exact shortfall amount if insufficient UDT balance - * - Only throws error if UDT balance cannot be satisfied (capacity issues don't cause errors) - */ - async completeInputsByBalance( - txLike: ccc.TransactionLike, - from: ccc.Signer, - balanceTweak?: ccc.NumLike, - capacityTweak?: ccc.NumLike, - ): Promise<{ - addedCount: number; - tx: ccc.Transaction; - }> { - const tx = ccc.Transaction.from(txLike); - const { balance: inBalance, capacity: inCapacity } = - await this.getInputsInfo(tx, from.client); - const { balance: outBalance, capacity: outCapacity } = - await this.getOutputsInfo(tx, from.client); - - const balanceBurned = - inBalance - outBalance - ccc.numFrom(balanceTweak ?? 0); - // Try to avoid extra occupation by UDT and also try to compress UDT state - const capacityBurned = - ccc.numMin(inCapacity - outCapacity, await tx.getFee(from.client)) - - ccc.numFrom(capacityTweak ?? 0); - - if (balanceBurned >= ccc.Zero && capacityBurned >= ccc.Zero) { - return { addedCount: 0, tx }; - } - - const { - tx: txRes, - addedCount, - accumulated, - } = await this.completeInputs( - tx, - from, - ([balanceAcc, capacityAcc], { cellOutput: { capacity }, outputData }) => { - const balance = ccc.udtBalanceFrom(outputData); - const balanceBurned = balanceAcc + balance; - const capacityBurned = capacityAcc + capacity; - - // Try to provide enough capacity with UDT cells to avoid extra occupation - return balanceBurned >= ccc.Zero && capacityBurned >= ccc.Zero - ? undefined - : [balanceBurned, capacityBurned]; - }, - [balanceBurned, capacityBurned], - ); - - if (accumulated === undefined || accumulated[0] >= ccc.Zero) { - return { tx: txRes, addedCount }; - } - - throw new ErrorUdtInsufficientCoin({ - amount: -accumulated[0], - type: this.script, + resTx.res.addCellDeps({ + outPoint: this.code, + depType: "code", }); + return resTx; } - /** - * Adds ALL available UDT cells from the signer as inputs to the transaction. - * Unlike `completeInputsByBalance` which adds only the minimum required inputs, - * this method collects every available UDT cell that the signer controls, - * regardless of the transaction's actual UDT requirements. - * - * @param txLike - The transaction to add UDT inputs to - * @param from - The signer that will provide all available UDT inputs - * @returns A promise resolving to an object containing: - * - tx: The transaction with all available UDT inputs added - * - addedCount: Number of UDT input cells that were added - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * - * // Create a transaction (can be empty or have existing outputs) - * const tx = ccc.Transaction.from({ - * outputs: [{ lock: recipientLock, type: udt.script }], - * outputsData: [ccc.numLeToBytes(100, 16)] // Send 100 UDT - * }); - * - * // Add ALL available UDT cells as inputs - * const { tx: completedTx, addedCount } = await udt.completeInputsAll(tx, signer); - * console.log(`Added ${addedCount} UDT cells as inputs`); - * - * // The transaction now contains all UDT cells the signer controls - * const totalInputBalance = await udt.getInputsBalance(completedTx, client); - * console.log(`Total UDT input balance: ${totalInputBalance}`); - * ``` - * - * @remarks - * **Use Cases:** - * - **UDT Consolidation**: Combining multiple small UDT cells into fewer larger ones - * - **Complete Balance Transfer**: Moving all UDT tokens from one address to another - * - **Wallet Cleanup**: Reducing the number of UDT cells for better wallet performance - * - **Batch Operations**: When you need to process all UDT holdings at once - * - * **Important Considerations:** - * - This method will likely create a large excess balance that needs to be handled with change outputs - * - The resulting transaction may be large and expensive due to many inputs - * - Use `completeInputsByBalance` instead if you only need specific amounts - * - Always handle the excess balance with appropriate change outputs after calling this method - * - * **Behavior:** - * - Adds every UDT cell that the signer controls and that isn't already used in the transaction - * - The accumulator tracks total capacity of added cells (used internally for optimization) - * - Does not stop until all available UDT cells are added - * - Skips cells that are already present as inputs in the transaction - */ - async completeInputsAll( - txLike: ccc.TransactionLike, - from: ccc.Signer, - ): Promise<{ - addedCount: number; - tx: ccc.Transaction; - }> { - const tx = ccc.Transaction.from(txLike); - - return this.completeInputs( - tx, - from, - (acc, { cellOutput: { capacity } }) => acc + capacity, - ccc.Zero, - ); - } - - /** - * Completes a UDT transaction by adding inputs and handling change with a custom change function. - * This is a low-level method that provides maximum flexibility for handling UDT transaction completion. - * The change function is called to handle excess UDT balance and can return the capacity cost of the change. - * - * @param txLike - The transaction to complete - * @param signer - The signer that will provide UDT inputs - * @param change - Function to handle excess UDT balance. Called with (tx, balance, shouldModify) - * where shouldModify indicates if the function should actually modify the transaction - * @param options - Optional configuration - * @param options.shouldAddInputs - Whether to automatically add inputs. Defaults to true - * @returns A promise resolving to the completed transaction - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * - * const completedTx = await udt.complete( - * tx, - * signer, - * (tx, balance, shouldModify) => { - * if (shouldModify && balance > 0) { - * // Add change output - * const changeData = ccc.numLeToBytes(balance, 16); - * tx.addOutput({ lock: changeLock, type: udt.script }, changeData); - * return ccc.CellOutput.from({ lock: changeLock, type: udt.script }, changeData).capacity; - * } - * return 0; - * } - * ); - * ``` - * - * @remarks - * The change function is called twice: - * 1. First with shouldModify=false to calculate capacity requirements - * 2. Then with shouldModify=true to actually modify the transaction - * This two-phase approach ensures proper input selection considering capacity requirements. - */ - async complete( - txLike: ccc.TransactionLike, - signer: ccc.Signer, - change: ( - tx: ccc.Transaction, - balance: ccc.Num, - shouldModify: boolean, - ) => Promise | ccc.NumLike, - options?: { shouldAddInputs?: boolean }, - ): Promise { - let tx = this.addCellDeps(ccc.Transaction.from(txLike)); - - /* === Figure out the balance to change === */ - if (options?.shouldAddInputs ?? true) { - tx = (await this.completeInputsByBalance(tx, signer)).tx; - } - - const balanceBurned = await this.getBalanceBurned(tx, signer.client); - - if (balanceBurned < ccc.Zero) { - throw new ErrorUdtInsufficientCoin({ - amount: -balanceBurned, - type: this.script, - }); - } else if (balanceBurned === ccc.Zero) { - return tx; - } - /* === Some balance need to change === */ - - if (!(options?.shouldAddInputs ?? true)) { - await Promise.resolve(change(tx, balanceBurned, true)); - return tx; - } - - // Different with `Transaction.completeFee`, we don't need the modified tx to track updated fee - // So one attempt should be enough - const extraCapacity = ccc.numFrom( - await Promise.resolve(change(tx, balanceBurned, false)), - ); // Extra capacity introduced by change cell - tx = ( - await this.completeInputsByBalance(tx, signer, ccc.Zero, extraCapacity) - ).tx; - - const balanceToChange = await this.getBalanceBurned(tx, signer.client); - await Promise.resolve(change(tx, balanceToChange, true)); - - return tx; - } - - /** - * Completes a UDT transaction by adding change to an existing output at the specified index. - * This method modifies an existing UDT output in the transaction to include any excess - * UDT balance as change, rather than creating a new change output. - * - * @param txLike - The transaction to complete - * @param signer - The signer that will provide UDT inputs - * @param indexLike - The index of the output to modify with change balance - * @param options - Optional configuration - * @param options.shouldAddInputs - Whether to automatically add inputs. Defaults to true - * @returns A promise resolving to the completed transaction - * - * @throws {Error} When the specified output is not a valid UDT cell - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * - * // Create transaction with a UDT output that will receive change - * const tx = ccc.Transaction.from({ - * outputs: [ - * { lock: recipientLock, type: udt.script }, - * { lock: changeLock, type: udt.script } // This will receive change - * ], - * outputsData: [ - * ccc.numLeToBytes(1000, 16), // Send 1000 UDT - * ccc.numLeToBytes(0, 16) // Change output starts with 0 - * ] - * }); - * - * // Complete with change going to output index 1 - * const completedTx = await udt.completeChangeToOutput(tx, signer, 1); - * // Output 1 now contains the excess UDT balance - * ``` - * - * @remarks - * This method is useful when you want to consolidate change into an existing output - * rather than creating a new output, which can save on transaction size and fees. - * The specified output must already be a valid UDT cell with this UDT's type script. - */ - async completeChangeToOutput( + async completeChangeToLock( txLike: ccc.TransactionLike, signer: ccc.Signer, - indexLike: ccc.NumLike, - options?: { shouldAddInputs?: boolean }, + change: ccc.ScriptLike, ) { const tx = ccc.Transaction.from(txLike); - const index = Number(ccc.numFrom(indexLike)); - const outputData = ccc.bytesFrom(tx.outputsData[index]); - if (!this.isUdt({ cellOutput: tx.outputs[index], outputData })) { - throw new Error("Change output must be a UDT cell"); + await tx.completeInputsByUdt(signer, this.script); + const balanceDiff = + (await tx.getInputsUdtBalance(signer.client, this.script)) - + tx.getOutputsUdtBalance(this.script); + if (balanceDiff > ccc.Zero) { + tx.addOutput( + { + lock: change, + type: this.script, + }, + ccc.numLeToBytes(balanceDiff, 16), + ); } - return this.complete( - tx, - signer, - (tx, balance, shouldModify) => { - if (shouldModify) { - const balanceData = ccc.numLeToBytes( - ccc.udtBalanceFrom(outputData) + balance, - 16, - ); - - tx.outputsData[index] = ccc.hexFrom( - ccc.bytesConcatTo([], balanceData, outputData.slice(16)), - ); - } - - return 0; - }, - options, - ); - } - - /** - * Completes a UDT transaction by adding necessary inputs and handling change. - * This method automatically adds UDT inputs to cover the required output amounts - * and creates a change output if there's excess UDT balance. - * - * @param tx - The transaction to complete, containing UDT outputs - * @param signer - The signer that will provide UDT inputs - * @param changeLike - The lock script where any excess UDT balance should be sent as change - * @param options - Optional configuration for the completion process - * @param options.shouldAddInputs - Whether to automatically add inputs. Defaults to true - * @returns A promise resolving to the completed transaction with inputs and change output added - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * - * // Create a transaction with UDT outputs - * const tx = ccc.Transaction.from({ - * outputs: [ - * { lock: recipientLock, type: udt.script } - * ], - * outputsData: [ccc.numLeToBytes(1000, 16)] // Send 1000 UDT - * }); - * - * // Complete with change going to sender's address - * const { script: changeLock } = await signer.getRecommendedAddressObj(); - * const completedTx = await udt.completeChangeToLock(tx, signer, changeLock); - * - * // The transaction now has: - * // - Sufficient UDT inputs to cover the 1000 UDT output - * // - A change output if there was excess UDT balance - * ``` - * - * @remarks - * This method performs the following operations: - * 1. Adds UDT inputs using `completeInputsByBalance` - * 2. Calculates the difference between input and output UDT balances - * 3. Creates a change output if there's excess UDT balance - */ - async completeChangeToLock( - tx: ccc.TransactionLike, - signer: ccc.Signer, - changeLike: ccc.ScriptLike, - options?: { shouldAddInputs?: boolean }, - ) { - const change = ccc.Script.from(changeLike); - - return this.complete( - tx, - signer, - (tx, balance, shouldModify) => { - const balanceData = ccc.numLeToBytes(balance, 16); - const changeOutput = ccc.CellOutput.from( - { lock: change, type: this.script }, - balanceData, - ); - if (shouldModify) { - tx.addOutput(changeOutput, balanceData); - } - - return changeOutput.capacity; - }, - options, - ); + return tx; } - /** - * Completes a UDT transaction using the signer's recommended address for change. - * This is a convenience method that automatically uses the signer's recommended - * address as the change destination, making it easier to complete UDT transactions - * without manually specifying a change address. - * - * @param tx - The transaction to complete, containing UDT outputs - * @param from - The signer that will provide UDT inputs and receive change - * @param options - Optional configuration for the completion process - * @param options.shouldAddInputs - Whether to automatically add inputs. Defaults to true - * @returns A promise resolving to the completed transaction with inputs and change output added - * - * @example - * ```typescript - * const udt = new Udt(codeOutPoint, scriptConfig); - * - * // Create a transfer transaction - * const transferResponse = await udt.transfer( - * signer, - * [{ to: recipientLock, amount: 1000 }] - * ); - * - * // Complete the transaction (change will go to signer's address) - * const completedTx = await udt.completeBy(transferResponse.res, signer); - * - * // Add capacity inputs and fee - * await completedTx.completeInputsByCapacity(signer); - * await completedTx.completeFeeBy(signer, changeLock); - * - * const txHash = await signer.sendTransaction(completedTx); - * ``` - * - * @see {@link completeChangeToLock} for more control over the change destination - */ - async completeBy( - tx: ccc.TransactionLike, - from: ccc.Signer, - options?: { shouldAddInputs?: boolean }, - ) { + async completeBy(tx: ccc.TransactionLike, from: ccc.Signer) { const { script } = await from.getRecommendedAddressObj(); - return this.completeChangeToLock(tx, from, script, options); + return this.completeChangeToLock(tx, from, script); } } diff --git a/packages/udt/src/udtPausable/index.ts b/packages/udt/src/udtPausable/index.ts index 46fb7dda5..84215cce3 100644 --- a/packages/udt/src/udtPausable/index.ts +++ b/packages/udt/src/udtPausable/index.ts @@ -1,6 +1,6 @@ import { ccc } from "@ckb-ccc/core"; import { ssri } from "@ckb-ccc/ssri"; -import { Udt, UdtConfigLike } from "../udt/index.js"; +import { Udt } from "../udt/index.js"; /** * Represents a UDT (User Defined Token) with pausable functionality. @@ -11,7 +11,9 @@ export class UdtPausable extends Udt { constructor( code: ccc.OutPointLike, script: ccc.ScriptLike, - config: UdtConfigLike & { executor: ssri.Executor }, + config: { + executor: ssri.Executor; + }, ) { super(code, script, config); } diff --git a/packages/udt/vitest.config.ts b/packages/udt/vitest.config.ts deleted file mode 100644 index dc6a58785..000000000 --- a/packages/udt/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.test.ts"], - coverage: { - include: ["src/**/*.ts"], - }, - }, -}); From 31d6bf1793af85f78268c3962b196c2eae4761d4 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Tue, 9 Sep 2025 18:38:40 +0800 Subject: [PATCH 26/45] Revert "feat: add RGB++ known scripts (RgbppLock, BtcTimeLock)" This reverts commit 507c6e8330eda1ee621d41b533e05b42e3d90356. This information is outdated --- .../client/clientPublicMainnet.advanced.ts | 68 -------- .../client/clientPublicTestnet.advanced.ts | 163 ------------------ packages/core/src/client/knownScript.ts | 5 - 3 files changed, 236 deletions(-) diff --git a/packages/core/src/client/clientPublicMainnet.advanced.ts b/packages/core/src/client/clientPublicMainnet.advanced.ts index 86506e309..bdcec4cc1 100644 --- a/packages/core/src/client/clientPublicMainnet.advanced.ts +++ b/packages/core/src/client/clientPublicMainnet.advanced.ts @@ -480,72 +480,4 @@ export const MAINNET_SCRIPTS: Record = }, ], }, - [KnownScript.RgbppLock]: { - codeHash: - "0xbc6c568a1a0d0a09f6844dc9d74ddb4343c32143ff25f727c59edf4fb72d6936", - hashType: "type", - cellDeps: [ - { - cellDep: { - outPoint: { - txHash: - "0x04c5c3e69f1aa6ee27fb9de3d15a81704e387ab3b453965adbe0b6ca343c6f41", - index: 0, - }, - depType: "code", - }, - type: { - codeHash: - "0x00000000000000000000000000000000000000000000000000545950455f4944", - hashType: "type", - args: "0x68ad3d9e0bb9ea841a5d1fcd600137bd3f45401e759e353121f26cd0d981452f", - }, - }, - // Rgbpp lock config cell dep - { - cellDep: { - outPoint: { - txHash: - "0x04c5c3e69f1aa6ee27fb9de3d15a81704e387ab3b453965adbe0b6ca343c6f41", - index: 1, - }, - depType: "code", - }, - }, - ], - }, - [KnownScript.BtcTimeLock]: { - codeHash: - "0x70d64497a075bd651e98ac030455ea200637ee325a12ad08aff03f1a117e5a62", - hashType: "type", - cellDeps: [ - { - cellDep: { - outPoint: { - txHash: - "0x6257bf4297ee75fcebe2654d8c5f8d93bc9fc1b3dc62b8cef54ffe166162e996", - index: 0, - }, - depType: "code", - }, - type: { - codeHash: - "0x00000000000000000000000000000000000000000000000000545950455f4944", - hashType: "type", - args: "0x44b8253ae18e913a2845b0d548eaf6b3ba1099ed26835888932a754194028a8a", - }, - }, - // btc time lock config cell dep - { - cellDep: { - outPoint: { - txHash: - "0x6257bf4297ee75fcebe2654d8c5f8d93bc9fc1b3dc62b8cef54ffe166162e996", - index: 1, - }, - depType: "code", - }, - }, - ], - }, }); diff --git a/packages/core/src/client/clientPublicTestnet.advanced.ts b/packages/core/src/client/clientPublicTestnet.advanced.ts index 3b22a4c3d..9453d2523 100644 --- a/packages/core/src/client/clientPublicTestnet.advanced.ts +++ b/packages/core/src/client/clientPublicTestnet.advanced.ts @@ -492,167 +492,4 @@ export const TESTNET_SCRIPTS: Record = }, ], }, - [KnownScript.RgbppLock]: { - codeHash: - "0x61ca7a4796a4eb19ca4f0d065cb9b10ddcf002f10f7cbb810c706cb6bb5c3248", - hashType: "type", - cellDeps: [ - { - cellDep: { - outPoint: { - txHash: - "0xf1de59e973b85791ec32debbba08dff80c63197e895eb95d67fc1e9f6b413e00", - index: 0, - }, - depType: "code", - }, - type: { - codeHash: - "0x00000000000000000000000000000000000000000000000000545950455f4944", - hashType: "type", - args: "0xa3bc8441df149def76cfe15fec7b1e51d949548bc27fb7a75e9d4b3ef1c12c7f", - }, - }, - // Rgbpp lock config cell dep for Bitcoin Testnet3 - { - cellDep: { - outPoint: { - txHash: - "0xf1de59e973b85791ec32debbba08dff80c63197e895eb95d67fc1e9f6b413e00", - index: 1, - }, - depType: "code", - }, - }, - ], - }, - [KnownScript.BtcTimeLock]: { - codeHash: - "0x00cdf8fab0f8ac638758ebf5ea5e4052b1d71e8a77b9f43139718621f6849326", - hashType: "type", - cellDeps: [ - { - cellDep: { - outPoint: { - txHash: - "0xde0f87878a97500f549418e5d46d2f7704c565a262aa17036c9c1c13ad638529", - index: 0, - }, - depType: "code", - }, - type: { - codeHash: - "0x00000000000000000000000000000000000000000000000000545950455f4944", - hashType: "type", - args: "0xc9828585e6dd2afacb9e6e8ca7deb0975121aabee5c7983178a45509ffaec984", - }, - }, - // btc time lock config cell dep for Bitcoin Testnet3 - { - cellDep: { - outPoint: { - txHash: - "0xde0f87878a97500f549418e5d46d2f7704c565a262aa17036c9c1c13ad638529", - index: 1, - }, - depType: "code", - }, - }, - ], - }, }); - -/** - * Bitcoin Signet specific script overrides for testnet - * - * Contains script configurations that differ when using Bitcoin Signet - * instead of Bitcoin Testnet3. Only RgbppLock and BtcTimeLock are affected. - * - * @example - * ```typescript - * import { ClientPublicTestnet } from "@ckb-ccc/core"; - * import { TESTNET_SCRIPTS, TESTNET_SCRIPTS_BTC_SIGNET_OVERRIDES } from "@ckb-ccc/core/advanced"; - * - * // Use Bitcoin Testnet3 scripts (default) - * const testnet3Client = new ClientPublicTestnet(); - * - * // Use Bitcoin Signet scripts by merging overrides - * const signetClient = new ClientPublicTestnet({ - * scripts: { - * ...TESTNET_SCRIPTS, - * ...TESTNET_SCRIPTS_BTC_SIGNET_OVERRIDES - * } - * }); - */ -export const TESTNET_SCRIPTS_BTC_SIGNET_OVERRIDES: Partial< - Record -> = Object.freeze({ - [KnownScript.RgbppLock]: { - codeHash: - "0xd07598deec7ce7b5665310386b4abd06a6d48843e953c5cc2112ad0d5a220364", - hashType: "type", - cellDeps: [ - { - cellDep: { - outPoint: { - txHash: - "0x61efdeddbaa0bb4132c0eb174b3e8002ff5ec430f61ba46f30768d683c516eec", - index: 0, - }, - depType: "code", - }, - type: { - codeHash: - "0x00000000000000000000000000000000000000000000000000545950455f4944", - hashType: "type", - args: "0xb69fe766ce3b7014a2a78ad1fe688d82f1679325805371d2856c3b8d18ebfa5a", - }, - }, - // Rgbpp lock config cell dep for Bitcoin Signet - { - cellDep: { - outPoint: { - txHash: - "0x61efdeddbaa0bb4132c0eb174b3e8002ff5ec430f61ba46f30768d683c516eec", - index: 1, - }, - depType: "code", - }, - }, - ], - }, - [KnownScript.BtcTimeLock]: { - codeHash: - "0x80a09eca26d77cea1f5a69471c59481be7404febf40ee90f886c36a948385b55", - hashType: "type", - cellDeps: [ - { - cellDep: { - outPoint: { - txHash: - "0x5364b3535965e9eac9a35dd7af8e9e45a61d30a16e115923c032f80b28783e21", - index: 0, - }, - depType: "code", - }, - type: { - codeHash: - "0x00000000000000000000000000000000000000000000000000545950455f4944", - hashType: "type", - args: "0x32fc8c70a6451a1439fd91e214bba093f9cdd9276bc4ab223430dab5940aff92", - }, - }, - // btc time lock config cell dep for Bitcoin Signet - { - cellDep: { - outPoint: { - txHash: - "0x5364b3535965e9eac9a35dd7af8e9e45a61d30a16e115923c032f80b28783e21", - index: 1, - }, - depType: "code", - }, - }, - ], - }, -}); diff --git a/packages/core/src/client/knownScript.ts b/packages/core/src/client/knownScript.ts index 66d1c91c5..90a1546fe 100644 --- a/packages/core/src/client/knownScript.ts +++ b/packages/core/src/client/knownScript.ts @@ -26,9 +26,4 @@ export enum KnownScript { TypeBurnLock = "TypeBurnLock", EasyToDiscoverType = "EasyToDiscoverType", TimeLock = "TimeLock", - - // RGB++ related scripts (default using Bitcoin Testnet3) - // For Bitcoin Signet, use TESTNET_SCRIPTS_BTC_SIGNET_OVERRIDES from @ckb-ccc/core/advanced - RgbppLock = "RgbppLock", - BtcTimeLock = "BtcTimeLock", } From 8610c7e4cba6e7bc594bfd2e0606c5923692323a Mon Sep 17 00:00:00 2001 From: Phroi <90913182+phroi@users.noreply.github.com> Date: Tue, 16 Sep 2025 04:52:46 +0300 Subject: [PATCH 27/45] feat(core): improve hex utils (#268) * feat(core): `hexFrom` passthru normalized hex and `numToHex` enforce hex normalization * feat(core): improve `isHex` * feat(core): improve `isHex` parameter --- .changeset/shiny-ants-say.md | 5 +++++ packages/core/src/hex/index.ts | 34 ++++++++++++++++++++++++++++++++-- packages/core/src/num/index.ts | 18 +++++++++++++++--- 3 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 .changeset/shiny-ants-say.md diff --git a/.changeset/shiny-ants-say.md b/.changeset/shiny-ants-say.md new file mode 100644 index 000000000..51df28401 --- /dev/null +++ b/.changeset/shiny-ants-say.md @@ -0,0 +1,5 @@ +--- +"@ckb-ccc/core": patch +--- + +`hexFrom` passthru normalized hex and `numToHex` enforce hex normalization \ No newline at end of file diff --git a/packages/core/src/hex/index.ts b/packages/core/src/hex/index.ts index 41df43d76..88a32d489 100644 --- a/packages/core/src/hex/index.ts +++ b/packages/core/src/hex/index.ts @@ -13,8 +13,33 @@ export type Hex = `0x${string}`; export type HexLike = BytesLike; /** - * Converts a HexLike value to a Hex string. - * @public + * Determines whether a given value is a properly formatted hexadecimal string (ccc.Hex). + * + * A valid hexadecimal string: + * - Has at least two characters. + * - Starts with "0x". + * - Has an even length. + * - Contains only characters representing digits (0-9) or lowercase letters (a-f) after the "0x" prefix. + * + * @param v - The value to validate as a hexadecimal (ccc.Hex) string. + * @returns True if the string is a valid hex string, false otherwise. + */ +export function isHex(v: unknown): v is Hex { + if (!(typeof v === "string" && v.length % 2 === 0 && v.startsWith("0x"))) { + return false; + } + + for (let i = 2; i < v.length; i++) { + const c = v.charAt(i); + if (!(("0" <= c && c <= "9") || ("a" <= c && c <= "f"))) { + return false; + } + } + return true; +} + +/** + * Returns the hexadecimal representation of the given value. * * @param hex - The value to convert, which can be a string, Uint8Array, ArrayBuffer, or number array. * @returns A Hex string representing the value. @@ -26,5 +51,10 @@ export type HexLike = BytesLike; * ``` */ export function hexFrom(hex: HexLike): Hex { + // Passthru an already normalized hex. V8 optimization: maintain existing hidden string fields. + if (isHex(hex)) { + return hex; + } + return `0x${bytesTo(bytesFrom(hex), "hex")}`; } diff --git a/packages/core/src/num/index.ts b/packages/core/src/num/index.ts index 8e1905a52..cf2367447 100644 --- a/packages/core/src/num/index.ts +++ b/packages/core/src/num/index.ts @@ -1,4 +1,5 @@ import { Bytes, BytesLike, bytesConcat, bytesFrom } from "../bytes/index.js"; +import { Zero } from "../fixedPoint/index.js"; import { Hex, HexLike, hexFrom } from "../hex/index.js"; /** @@ -90,11 +91,16 @@ export function numFrom(val: NumLike): Num { } /** - * Converts a NumLike value to a hexadecimal string. + * Convert a NumLike value into a canonical Hex, so prefixed with `0x` and + * containing an even number of lowercase hex digits (full-byte representation). + * * @public * * @param val - The value to convert, which can be a string, number, bigint, or HexLike. - * @returns A Hex string representing the numeric value. + * @returns A Hex string representing the provided value, prefixed with `0x` and + * containing an even number of lowercase hex digits. + * + * @throws {Error} If the normalized numeric value is negative. * * @example * ```typescript @@ -102,7 +108,13 @@ export function numFrom(val: NumLike): Num { * ``` */ export function numToHex(val: NumLike): Hex { - return `0x${numFrom(val).toString(16)}`; + const v = numFrom(val); + if (v < Zero) { + throw new Error("value must be non-negative"); + } + const h = v.toString(16); + // ensure even length (full bytes) + return h.length % 2 === 0 ? `0x${h}` : `0x0${h}`; } /** From a3621a366af6fd5e14f6ff5ecf8e9f204b35df78 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Thu, 18 Sep 2025 22:14:38 +0800 Subject: [PATCH 28/45] fix(core): Invalid Uint64 0x00: with redundant leading zeros. --- .changeset/wise-news-admire.md | 6 ++++++ packages/core/src/num/index.ts | 17 +++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 .changeset/wise-news-admire.md diff --git a/.changeset/wise-news-admire.md b/.changeset/wise-news-admire.md new file mode 100644 index 000000000..5607ae979 --- /dev/null +++ b/.changeset/wise-news-admire.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +fix(core): Invalid Uint64 0x00: with redundant leading zeros. + \ No newline at end of file diff --git a/packages/core/src/num/index.ts b/packages/core/src/num/index.ts index cf2367447..034455c11 100644 --- a/packages/core/src/num/index.ts +++ b/packages/core/src/num/index.ts @@ -91,20 +91,23 @@ export function numFrom(val: NumLike): Num { } /** - * Convert a NumLike value into a canonical Hex, so prefixed with `0x` and - * containing an even number of lowercase hex digits (full-byte representation). + * Converts a {@link NumLike} value into its hexadecimal string representation, prefixed with `0x`. + * + * @remarks + * This function returns the direct hexadecimal representation of the number, which may have an odd number of digits. + * For a full-byte representation (an even number of hex digits), consider using {@link numToBytes}, {@link numLeToBytes}, or {@link numBeToBytes} and then converting the resulting byte array to a hex string. * * @public * * @param val - The value to convert, which can be a string, number, bigint, or HexLike. - * @returns A Hex string representing the provided value, prefixed with `0x` and - * containing an even number of lowercase hex digits. + * @returns A Hex string representing the number. * * @throws {Error} If the normalized numeric value is negative. * * @example * ```typescript - * const hex = numToHex(12345); // Outputs "0x3039" + * const hex = numToHex(4660); // "0x1234" + * const oddLengthHex = numToHex(10); // "0xa" * ``` */ export function numToHex(val: NumLike): Hex { @@ -112,9 +115,7 @@ export function numToHex(val: NumLike): Hex { if (v < Zero) { throw new Error("value must be non-negative"); } - const h = v.toString(16); - // ensure even length (full bytes) - return h.length % 2 === 0 ? `0x${h}` : `0x0${h}`; + return `0x${v.toString(16)}`; } /** From 117e79c769396b0c1f6d325f091319d907c08f4e Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Wed, 17 Sep 2025 20:58:57 +0800 Subject: [PATCH 29/45] feat(joy-id): address info in identity --- .changeset/late-vans-juggle.md | 7 +++ packages/core/src/signer/ckb/verifyJoyId.ts | 50 ++++++++++++++++----- packages/joy-id/src/ckb/index.ts | 1 + 3 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 .changeset/late-vans-juggle.md diff --git a/.changeset/late-vans-juggle.md b/.changeset/late-vans-juggle.md new file mode 100644 index 000000000..4f88298b8 --- /dev/null +++ b/.changeset/late-vans-juggle.md @@ -0,0 +1,7 @@ +--- +"@ckb-ccc/core": major +"@ckb-ccc/joy-id": minor +--- + +feat(joy-id): address info in identity + \ No newline at end of file diff --git a/packages/core/src/signer/ckb/verifyJoyId.ts b/packages/core/src/signer/ckb/verifyJoyId.ts index eb9089946..44eece343 100644 --- a/packages/core/src/signer/ckb/verifyJoyId.ts +++ b/packages/core/src/signer/ckb/verifyJoyId.ts @@ -1,27 +1,55 @@ -import { verifySignature } from "@joyid/ckb"; +import { + CredentialKeyType, + SigningAlg, + verifyCredential, + verifySignature, +} from "@joyid/ckb"; import { BytesLike } from "../../bytes/index.js"; import { hexFrom } from "../../hex/index.js"; /** * @public */ -export function verifyMessageJoyId( +export async function verifyMessageJoyId( message: string | BytesLike, signature: string, identity: string, ): Promise { const challenge = typeof message === "string" ? message : hexFrom(message).slice(2); - const { publicKey, keyType } = JSON.parse(identity) as { + const { address, publicKey, keyType } = JSON.parse(identity) as { + address: string; publicKey: string; - keyType: string; + keyType: CredentialKeyType; }; + const signatureObj = JSON.parse(signature) as { + alg: SigningAlg; + signature: string; + message: string; + }; + + if ( + !(await verifySignature({ + challenge, + pubkey: publicKey, + keyType, + ...signatureObj, + })) + ) { + return false; + } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - return verifySignature({ - challenge, - pubkey: publicKey, - keyType, - ...JSON.parse(signature), - }); + // I sincerely hope one day we can get rid of the centralized registry + const registry = address.startsWith("ckb") + ? "https://api.joy.id/api/v1/" + : "https://api.testnet.joyid.dev/api/v1/"; + return verifyCredential( + { + pubkey: publicKey, + address, + keyType, + alg: signatureObj.alg, + }, + registry, + ); } diff --git a/packages/joy-id/src/ckb/index.ts b/packages/joy-id/src/ckb/index.ts index 2fa0851e9..289e21d99 100644 --- a/packages/joy-id/src/ckb/index.ts +++ b/packages/joy-id/src/ckb/index.ts @@ -148,6 +148,7 @@ export class CkbSigner extends ccc.Signer { async getIdentity(): Promise { const connection = await this.assertConnection(); return JSON.stringify({ + address: connection.address, keyType: connection.keyType, publicKey: connection.publicKey.slice(2), }); From 3bc893dbe3a6a458f2071b02ec42ea48df8eb2ba Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Thu, 18 Sep 2025 19:28:47 +0800 Subject: [PATCH 30/45] feat(core): `Signer.fromSignature` --- .changeset/fifty-planes-fetch.md | 6 ++ packages/core/src/signer/index.ts | 17 +++++ packages/core/src/signer/signer/index.ts | 11 ++++ .../core/src/signer/signerFromSignature.ts | 65 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 .changeset/fifty-planes-fetch.md create mode 100644 packages/core/src/signer/signerFromSignature.ts diff --git a/.changeset/fifty-planes-fetch.md b/.changeset/fifty-planes-fetch.md new file mode 100644 index 000000000..bcd19d3f6 --- /dev/null +++ b/.changeset/fifty-planes-fetch.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): `Signer.fromSignature` + \ No newline at end of file diff --git a/packages/core/src/signer/index.ts b/packages/core/src/signer/index.ts index 350b487e8..052248f15 100644 --- a/packages/core/src/signer/index.ts +++ b/packages/core/src/signer/index.ts @@ -5,3 +5,20 @@ export * from "./dummy/index.js"; export * from "./evm/index.js"; export * from "./nostr/index.js"; export * from "./signer/index.js"; +export * from "./signerFromSignature.js"; + +import { BytesLike } from "../bytes/index.js"; +import { Client } from "../client/index.js"; +import { Signer as BaseSigner, Signature } from "./signer/index.js"; +import { signerFromSignature } from "./signerFromSignature.js"; + +export abstract class Signer extends BaseSigner { + static fromSignature( + client: Client, + signature: Signature, + message?: string | BytesLike | null, + ...addresses: (string | string[])[] + ): Promise { + return signerFromSignature(client, signature, message, ...addresses); + } +} diff --git a/packages/core/src/signer/signer/index.ts b/packages/core/src/signer/signer/index.ts index a2d0504b7..04dd84642 100644 --- a/packages/core/src/signer/signer/index.ts +++ b/packages/core/src/signer/signer/index.ts @@ -157,6 +157,17 @@ export abstract class Signer { } } + static async fromSignature( + _client: Client, + _signature: Signature, + _message?: string | BytesLike | null, + ..._addresses: (string | string[])[] + ): Promise { + throw Error( + "Signer.fromSignature should be override to avoid circular references", + ); + } + /** * Connects to the signer. * diff --git a/packages/core/src/signer/signerFromSignature.ts b/packages/core/src/signer/signerFromSignature.ts new file mode 100644 index 000000000..7f296f827 --- /dev/null +++ b/packages/core/src/signer/signerFromSignature.ts @@ -0,0 +1,65 @@ +import { Address } from "../address/index.js"; +import { BytesLike } from "../bytes/index.js"; +import { Client } from "../client/index.js"; +import { SignerBtcPublicKeyReadonly } from "./btc/index.js"; +import { SignerCkbPublicKey, SignerCkbScriptReadonly } from "./ckb/index.js"; +import { SignerDogeAddressReadonly } from "./doge/index.js"; +import { SignerEvmAddressReadonly } from "./evm/index.js"; +import { SignerNostrPublicKeyReadonly } from "./nostr/index.js"; +import { Signature, Signer, SignerSignType } from "./signer/index.js"; + +/** + * Creates a signer from a signature. + * + * @param client - The client instance. + * @param signature - The signature to create the signer from. + * @param message - The message that was signed. + * @param addresses - The addresses to check against the signer. + * @returns The signer if the signature is valid and the addresses match, otherwise undefined. + * @throws Error if the signature sign type is unknown. + */ +export async function signerFromSignature( + client: Client, + signature: Signature, + message?: string | BytesLike | null, + ...addresses: (string | string[])[] +): Promise { + if ( + message != undefined && + !(await Signer.verifyMessage(message, signature)) + ) { + return; + } + + const signer = await (async () => { + switch (signature.signType) { + case SignerSignType.EvmPersonal: + return new SignerEvmAddressReadonly(client, signature.identity); + case SignerSignType.BtcEcdsa: + return new SignerBtcPublicKeyReadonly(client, "", signature.identity); + case SignerSignType.JoyId: { + const { address } = JSON.parse(signature.identity) as { + address: string; + }; + return new SignerCkbScriptReadonly( + client, + (await Address.fromString(address, client)).script, + ); + } + case SignerSignType.NostrEvent: + return new SignerNostrPublicKeyReadonly(client, signature.identity); + case SignerSignType.CkbSecp256k1: + return new SignerCkbPublicKey(client, signature.identity); + case SignerSignType.DogeEcdsa: + return new SignerDogeAddressReadonly(client, signature.identity); + case SignerSignType.Unknown: + throw new Error("Unknown signer sign type"); + } + })(); + const signerAddresses = await signer.getAddresses(); + if (!addresses.flat().every((addr) => signerAddresses.includes(addr))) { + return; + } + + return signer; +} From 117266495f15097e18016dcd3746618fae54ca49 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 10 Dec 2025 00:11:23 -0500 Subject: [PATCH 31/45] feat(btc): add PSBT signing and broadcasting support --- packages/core/src/signer/btc/index.ts | 1 + packages/core/src/signer/btc/psbt.ts | 56 ++++++ packages/core/src/signer/btc/signerBtc.ts | 50 +++++ .../signer/btc/signerBtcPublicKeyReadonly.ts | 9 + packages/joy-id/src/btc/index.ts | 72 ++++++++ packages/okx/src/advancedBarrel.ts | 17 +- packages/okx/src/btc/index.ts | 27 +++ packages/uni-sat/src/advancedBarrel.ts | 15 ++ packages/uni-sat/src/signer.ts | 26 +++ packages/utxo-global/src/btc/index.ts | 25 +++ packages/xverse/package.json | 1 + packages/xverse/src/signer.ts | 171 ++++++++++++++++++ pnpm-lock.yaml | 59 ++++++ 13 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/signer/btc/psbt.ts diff --git a/packages/core/src/signer/btc/index.ts b/packages/core/src/signer/btc/index.ts index d0aa15884..694ff7250 100644 --- a/packages/core/src/signer/btc/index.ts +++ b/packages/core/src/signer/btc/index.ts @@ -1,3 +1,4 @@ +export * from "./psbt.js"; export * from "./signerBtc.js"; export * from "./signerBtcPublicKeyReadonly.js"; export * from "./verify.js"; diff --git a/packages/core/src/signer/btc/psbt.ts b/packages/core/src/signer/btc/psbt.ts new file mode 100644 index 000000000..77ac646f7 --- /dev/null +++ b/packages/core/src/signer/btc/psbt.ts @@ -0,0 +1,56 @@ +/** + * Options for signing a PSBT (Partially Signed Bitcoin Transaction) + */ +export type SignPsbtOptions = { + /** + * Whether to finalize the PSBT after signing. + * Default is true. + */ + autoFinalized?: boolean; + /** + * Array of inputs to sign + */ + toSignInputs?: ToSignInput[]; +}; + +/** + * Specification for an input to sign in a PSBT. + * Must specify at least one of: address or pubkey. + */ +export type ToSignInput = { + /** + * Which input to sign (index in the PSBT inputs array) + */ + index: number; + /** + * (Optional) Sighash types to use for signing. + */ + sighashTypes?: number[]; + /** + * (Optional) When signing and unlocking Taproot addresses, the tweakSigner is used by default + * for signature generation. Setting this to true allows for signing with the original private key. + * Default value is false. + */ + disableTweakSigner?: boolean; +} & ( + | { + /** + * The address whose corresponding private key to use for signing. + */ + address: string; + /** + * The public key whose corresponding private key to use for signing. + */ + publicKey?: string; + } + | { + /** + * The address whose corresponding private key to use for signing. + */ + address?: string; + /** + * The public key whose corresponding private key to use for signing. + */ + publicKey: string; + } +); diff --git a/packages/core/src/signer/btc/signerBtc.ts b/packages/core/src/signer/btc/signerBtc.ts index 64112a74a..6f31946cd 100644 --- a/packages/core/src/signer/btc/signerBtc.ts +++ b/packages/core/src/signer/btc/signerBtc.ts @@ -5,6 +5,7 @@ import { KnownScript } from "../../client/index.js"; import { HexLike, hexFrom } from "../../hex/index.js"; import { numToBytes } from "../../num/index.js"; import { Signer, SignerSignType, SignerType } from "../signer/index.js"; +import { SignPsbtOptions } from "./psbt.js"; import { btcEcdsaPublicKeyHash } from "./verify.js"; /** @@ -22,6 +23,32 @@ export abstract class SignerBtc extends Signer { return SignerSignType.BtcEcdsa; } + /** + * Whether the wallet supports a single call to sign + broadcast (combined flow). + * Default false; override in implementations like Xverse/JoyID. + */ + get supportsSingleCallSignAndBroadcast(): boolean { + return false; + } + + /** + * Sign and broadcast a PSBT in one call when supported, otherwise falls back + * to sign then push. Prefer this over manual sign+push to avoid double popups. + */ + async signAndPushPsbt( + psbtHex: string, + options?: SignPsbtOptions, + ): Promise { + if (this.supportsSingleCallSignAndBroadcast) { + // Wallet handles sign+broadcast internally (e.g., Xverse/JoyID) + return this.pushPsbt(psbtHex, options); + } + + // Split-mode wallets: sign first, then broadcast + const signedPsbt = await this.signPsbt(psbtHex, options); + return this.pushPsbt(signedPsbt, options); + } + /** * Gets the Bitcoin account associated with the signer. * @@ -123,4 +150,27 @@ export abstract class SignerBtc extends Signer { tx.setWitnessArgsAt(info.position, witness); return tx; } + + /** + * Signs a Partially Signed Bitcoin Transaction (PSBT). + * + * @param psbtHex - The hex string of PSBT to sign + * @param options - Options for signing the PSBT + * @returns A promise that resolves to the signed PSBT hex string + */ + abstract signPsbt( + psbtHex: string, + options?: SignPsbtOptions, + ): Promise; + + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of signed PSBT to broadcast + * @returns A promise that resolves to the transaction ID + */ + abstract pushPsbt( + psbtHex: string, + options?: SignPsbtOptions, + ): Promise; } diff --git a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts index 50096db7e..f5293a952 100644 --- a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts +++ b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts @@ -1,5 +1,6 @@ import { Client } from "../../client/index.js"; import { Hex, HexLike, hexFrom } from "../../hex/index.js"; +import { SignPsbtOptions } from "./psbt.js"; import { SignerBtc } from "./signerBtc.js"; /** @@ -70,4 +71,12 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc { async getBtcPublicKey(): Promise { return this.publicKey; } + + async signPsbt(_: string, __?: SignPsbtOptions): Promise { + throw new Error("Read-only signer does not support signPsbt"); + } + + async pushPsbt(_: string, __?: SignPsbtOptions): Promise { + throw new Error("Read-only signer does not support pushPsbt"); + } } diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index 4a1e92fad..885528659 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -60,6 +60,10 @@ export class BitcoinSigner extends ccc.SignerBtc { super(client); } + get supportsSingleCallSignAndBroadcast(): boolean { + return true; + } + /** * Gets the configuration for JoyID. * @returns The configuration object. @@ -198,4 +202,72 @@ export class BitcoinSigner extends ccc.SignerBtc { ); return signature; } + + /** + * Signs a PSBT using JoyID wallet. + * + * @param psbtHex - The hex string of PSBT to sign + * @returns A promise that resolves to the signed PSBT hex string + */ + async signPsbt( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise { + const { address } = await this.assertConnection(); + + const config = this.getConfig(); + const { tx: signedPsbtHex } = await createPopup( + buildJoyIDURL( + { + ...config, + tx: psbtHex, + options, + signerAddress: address, + autoFinalized: options?.autoFinalized ?? true, + }, + "popup", + "/sign-psbt", + ), + { ...config, type: DappRequestType.SignPsbt }, + ); + + return signedPsbtHex; + } + + /** + * Signs and broadcasts a PSBT to the Bitcoin network using JoyID wallet. + * + * This method combines both signing and broadcasting in a single operation. + * + * @param psbtHex - The hex string of PSBT to sign and broadcast + * @returns A promise that resolves to the transaction ID + * + * @remarks + * Use this method directly for sign+broadcast operations to avoid double popups. + * While calling signPsbt() then pushPsbt() will still work, it triggers two popups and requires double signing. + */ + async pushPsbt( + psbtHex: string, + _options?: ccc.SignPsbtOptions, + ): Promise { + const { address } = await this.assertConnection(); + + const config = this.getConfig(); + const { tx: txid } = await createPopup( + buildJoyIDURL( + { + ...config, + tx: psbtHex, + signerAddress: address, + autoFinalized: true, // sendPsbt always finalizes + isSend: true, + }, + "popup", + "/sign-psbt", + ), + { ...config, type: DappRequestType.SignPsbt }, // Use SignPsbt type for both operations + ); + + return txid; + } } diff --git a/packages/okx/src/advancedBarrel.ts b/packages/okx/src/advancedBarrel.ts index 4704b662f..bdd6b3e04 100644 --- a/packages/okx/src/advancedBarrel.ts +++ b/packages/okx/src/advancedBarrel.ts @@ -2,8 +2,21 @@ import { Nip07A } from "@ckb-ccc/nip07/advanced"; import { UniSatA } from "@ckb-ccc/uni-sat/advanced"; export interface BitcoinProvider - extends Pick, - Partial> { + extends Pick< + UniSatA.Provider, + "on" | "removeListener" | "signMessage" | "signPsbt" | "pushPsbt" + >, + Partial< + Omit< + UniSatA.Provider, + | "on" + | "removeListener" + | "signMessage" + | "signPsbt" + | "pushPsbt" + | "pushTx" + > + > { connect?(): Promise<{ address: string; publicKey: string; diff --git a/packages/okx/src/btc/index.ts b/packages/okx/src/btc/index.ts index c35b9a480..7e3b20b65 100644 --- a/packages/okx/src/btc/index.ts +++ b/packages/okx/src/btc/index.ts @@ -176,4 +176,31 @@ export class BitcoinSigner extends ccc.SignerBtc { return this.provider.signMessage(challenge, "ecdsa"); } + + /** + * Signs a PSBT using OKX wallet. + * + * @param psbtHex - The hex string of PSBT to sign + * @param options - Options for signing the PSBT + * @returns A promise that resolves to the signed PSBT hex string + */ + async signPsbt( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise { + return this.provider.signPsbt(psbtHex, options); + } + + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of signed PSBT to broadcast + * @returns A promise that resolves to the transaction ID + */ + async pushPsbt( + psbtHex: string, + _options?: ccc.SignPsbtOptions, + ): Promise { + return this.provider.pushPsbt(psbtHex); + } } diff --git a/packages/uni-sat/src/advancedBarrel.ts b/packages/uni-sat/src/advancedBarrel.ts index e6ae56b58..6a97061cf 100644 --- a/packages/uni-sat/src/advancedBarrel.ts +++ b/packages/uni-sat/src/advancedBarrel.ts @@ -1,7 +1,22 @@ +import { ccc } from "@ckb-ccc/core"; + /** * Interface representing a provider for interacting with accounts and signing messages. */ export interface Provider { + /** + * Signs a PSBT using UniSat wallet. + * + * @param psbtHex - The hex string of PSBT to sign + * @param options - Options for signing the PSBT + * @returns A promise that resolves to the signed PSBT hex string + */ + signPsbt(psbtHex: string, options?: ccc.SignPsbtOptions): Promise; + + pushPsbt(psbtHex: string): Promise; + + pushTx(tx: { rawtx: string }): Promise; + /** * Requests user accounts. * @returns A promise that resolves to an array of account addresses. diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index 653bba8ee..bf28c7de5 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -150,4 +150,30 @@ export class Signer extends ccc.SignerBtc { return this.provider.signMessage(challenge, "ecdsa"); } + + /** + * Signs a PSBT using UniSat wallet. + * + * @param psbtHex - The hex string of PSBT to sign + * @returns A promise that resolves to the signed PSBT hex string + */ + async signPsbt( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise { + return this.provider.signPsbt(psbtHex, options); + } + + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of signed PSBT to broadcast + * @returns A promise that resolves to the transaction ID + */ + async pushPsbt( + psbtHex: string, + _options?: ccc.SignPsbtOptions, + ): Promise { + return this.provider.pushPsbt(psbtHex); + } } diff --git a/packages/utxo-global/src/btc/index.ts b/packages/utxo-global/src/btc/index.ts index 57e73594d..5cede753c 100644 --- a/packages/utxo-global/src/btc/index.ts +++ b/packages/utxo-global/src/btc/index.ts @@ -127,4 +127,29 @@ export class SignerBtc extends ccc.SignerBtc { this.accountCache ?? (await this.getBtcAccount()), ); } + + /** + * Signs a PSBT using UTXO Global wallet. + * + * @param psbtHex - The hex string of PSBT to sign + * @param options - Options for signing the PSBT + * @returns A promise that resolves to the signed PSBT hex string + */ + async signPsbt( + _psbtHex: string, + _options?: ccc.SignPsbtOptions, + ): Promise { + throw new Error("UTXO Global PSBT signing not implemented yet"); + } + + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of signed PSBT to broadcast + * @returns A promise that resolves to the transaction ID + * @todo Implement PSBT broadcasting with UTXO Global + */ + async pushPsbt(_: string, __?: ccc.SignPsbtOptions): Promise { + throw new Error("UTXO Global PSBT broadcasting not implemented yet"); + } } diff --git a/packages/xverse/package.json b/packages/xverse/package.json index 21055c506..360f87354 100644 --- a/packages/xverse/package.json +++ b/packages/xverse/package.json @@ -57,6 +57,7 @@ }, "dependencies": { "@ckb-ccc/core": "workspace:*", + "bitcoinjs-lib": "^7.0.0", "valibot": "^1.1.0" }, "packageManager": "pnpm@10.8.1" diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index af243a11a..5507e4006 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -1,4 +1,5 @@ import { ccc } from "@ckb-ccc/core"; +import { Psbt } from "bitcoinjs-lib"; import * as v from "valibot"; import { Address, @@ -65,6 +66,10 @@ export class Signer extends ccc.SignerBtc { super(client); } + get supportsSingleCallSignAndBroadcast(): boolean { + return true; + } + async assertAddress(): Promise
{ this.addressCache = this.addressCache ?? @@ -169,4 +174,170 @@ export class Signer extends ccc.SignerBtc { ) ).signature; } + + /** + * Build default toSignInputs for all unsigned inputs + */ + private buildDefaultToSignInputs( + psbtHex: string, + address: string, + ): ccc.ToSignInput[] { + const toSignInputs: ccc.ToSignInput[] = []; + + try { + const psbt = Psbt.fromHex(psbtHex); + + // Collect all unsigned inputs + psbt.data.inputs.forEach((input, index) => { + const isSigned = + input.finalScriptSig || + input.finalScriptWitness || + input.tapKeySig || + (input.partialSig && input.partialSig.length > 0) || + (input.tapScriptSig && input.tapScriptSig.length > 0); + + if (!isSigned) { + toSignInputs.push({ index, address } as ccc.ToSignInput); + } + }); + + // If no unsigned inputs found, assume we need to sign all inputs + if (toSignInputs.length === 0) { + for (let i = 0; i < psbt.data.inputs.length; i++) { + toSignInputs.push({ index: i, address } as ccc.ToSignInput); + } + } + } catch (error) { + // Fallback: if PSBT parsing fails, assume single input at index 0 + console.warn("Failed to parse PSBT, assuming single input:", error); + toSignInputs.push({ index: 0, address } as ccc.ToSignInput); + } + + return toSignInputs; + } + + private async prepareSignPsbtParams( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise<{ + psbtBase64: string; + signInputs: Record; + }> { + let toSignInputs = options?.toSignInputs; + if (!toSignInputs || !toSignInputs.length) { + const address = await this.getBtcAccount(); + toSignInputs = this.buildDefaultToSignInputs(psbtHex, address); + } + + const psbtBytes = ccc.bytesFrom(psbtHex); + const psbtBase64 = ccc.bytesTo(psbtBytes, "base64"); + + const signInputs = toSignInputs.reduce( + (acc, input) => { + if (!input.address) { + throw new Error( + "Xverse only supports signing with address. Please provide 'address' in toSignInputs.", + ); + } + if (acc[input.address]) { + acc[input.address].push(input.index); + } else { + acc[input.address] = [input.index]; + } + return acc; + }, + {} as Record, + ); + + return { psbtBase64, signInputs }; + } + + /** + * Signs a PSBT using Xverse wallet. + * + * @param psbtHex - The hex string of PSBT to sign + * @param options - Options for signing the PSBT + * @returns A promise that resolves to the signed PSBT hex string + * + * @remarks + * Xverse accepts: + * - psbt: A string representing the PSBT to sign, encoded in base64 + * - signInputs: A Record where: + * - keys are the addresses to use for signing + * - values are the indexes of the inputs to sign with each address + * + * Xverse returns: + * - psbt: The base64 encoded signed PSBT + * + * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt + */ + async signPsbt( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise { + const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( + psbtHex, + options, + ); + + const signedPsbtBase64 = ( + await checkResponse( + this.provider.request("signPsbt", { + psbt: psbtBase64, + signInputs, + broadcast: false, + }), + ) + ).psbt; + + const signedPsbtBytes = ccc.bytesFrom(signedPsbtBase64, "base64"); + return ccc.hexFrom(signedPsbtBytes).slice(2); + } + + /** + * Signs and broadcasts a PSBT using Xverse wallet (single popup). + * + * @param psbtHex - The hex string of PSBT to sign and broadcast + * @param options - Options for signing the PSBT + * @returns A promise that resolves to SignPsbtResult: + * - psbt: base64 encoded signed PSBT + * - txid: transaction id (only when broadcast succeeds) + * + * @remarks + * Xverse accepts: + * - psbt: base64 encoded PSBT + * - signInputs: Record input indexes to sign + * - broadcast: set to true to broadcast + * + * @remarks + * Use this method directly for sign+broadcast operations to avoid double popups. + * While calling signPsbt() then pushPsbt() will still work, it triggers two popups and requires double signing. + * + * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt + */ + async pushPsbt( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise { + const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( + psbtHex, + options, + ); + + const result = await checkResponse( + this.provider.request("signPsbt", { + psbt: psbtBase64, + // Build signInputs: Record + // Multiple inputs with the same address should be grouped together + signInputs, + broadcast: true, + }), + ); + + if (!result.txid) { + throw new Error("Failed to broadcast PSBT"); + } + + return result.txid; + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33d5adee6..67b164b5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1342,6 +1342,9 @@ importers: '@ckb-ccc/core': specifier: workspace:* version: link:../core + bitcoinjs-lib: + specifier: ^7.0.0 + version: 7.0.0(typescript@5.9.2) valibot: specifier: ^1.1.0 version: 1.1.0(typescript@5.9.2) @@ -5224,9 +5227,17 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bip174@3.0.0: + resolution: {integrity: sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==} + engines: {node: '>=18.0.0'} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + bitcoinjs-lib@7.0.0: + resolution: {integrity: sha512-2W6dGXFd1KG3Bs90Bzb5+ViCeSKNIYkCUWZ4cvUzUgwnneiNNZ6Sk8twGNcjlesmxC0JyLc/958QycfpvXLg7A==} + engines: {node: '>=18.0.0'} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -10433,6 +10444,14 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + uint8array-tools@0.0.8: + resolution: {integrity: sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==} + engines: {node: '>=14.0.0'} + + uint8array-tools@0.0.9: + resolution: {integrity: sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==} + engines: {node: '>=14.0.0'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -10579,6 +10598,14 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@0.38.0: + resolution: {integrity: sha512-RCJa0fetnzp+h+KN9BdgYOgtsMAG9bfoJ9JSjIhFHobKWVWyzM3jjaeNTdpFK9tQtf3q1sguXeERJ/LcmdFE7w==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + valibot@1.1.0: resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} peerDependencies: @@ -10597,6 +10624,9 @@ packages: value-equal@1.0.1: resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} + varuint-bitcoin@2.0.0: + resolution: {integrity: sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -16030,8 +16060,25 @@ snapshots: binary-extensions@2.3.0: {} + bip174@3.0.0: + dependencies: + uint8array-tools: 0.0.9 + varuint-bitcoin: 2.0.0 + birpc@4.0.0: {} + bitcoinjs-lib@7.0.0(typescript@5.9.2): + dependencies: + '@noble/hashes': 1.8.0 + bech32: 2.0.0 + bip174: 3.0.0 + bs58check: 4.0.0(patch_hash=0848a2e3956f24abf1dd8620cba2a3f468393e489185d9536ad109f7e5712d26) + uint8array-tools: 0.0.9 + valibot: 0.38.0(typescript@5.9.2) + varuint-bitcoin: 2.0.0 + transitivePeerDependencies: + - typescript + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -22323,6 +22370,10 @@ snapshots: uint8array-extras@1.5.0: {} + uint8array-tools@0.0.8: {} + + uint8array-tools@0.0.9: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -22496,6 +22547,10 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@0.38.0(typescript@5.9.2): + optionalDependencies: + typescript: 5.9.2 + valibot@1.1.0(typescript@5.9.2): optionalDependencies: typescript: 5.9.2 @@ -22509,6 +22564,10 @@ snapshots: value-equal@1.0.1: {} + varuint-bitcoin@2.0.0: + dependencies: + uint8array-tools: 0.0.8 + vary@1.1.2: {} vfile-location@5.0.3: From 5d29445b4632aa6dbea6d0be1e6a857765698620 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 10 Dec 2025 00:44:08 -0500 Subject: [PATCH 32/45] fix(btc): improve PSBT signing safety and error handling --- packages/core/src/signer/btc/signerBtc.ts | 11 ++++++++--- packages/xverse/src/signer.ts | 18 +++++------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/core/src/signer/btc/signerBtc.ts b/packages/core/src/signer/btc/signerBtc.ts index 6f31946cd..6dd8ba4c6 100644 --- a/packages/core/src/signer/btc/signerBtc.ts +++ b/packages/core/src/signer/btc/signerBtc.ts @@ -164,10 +164,15 @@ export abstract class SignerBtc extends Signer { ): Promise; /** - * Broadcasts a signed PSBT to the Bitcoin network. + * Pushes a PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of signed PSBT to broadcast - * @returns A promise that resolves to the transaction ID + * For wallets that support a single call for signing and broadcasting (where `supportsSingleCallSignAndBroadcast` is true), + * this method takes an **unsigned** PSBT, signs it, and broadcasts it. + * For other wallets, this method takes a **signed** PSBT and only broadcasts it. + * + * @param psbtHex - The hex string of the PSBT to push. Can be signed or unsigned depending on the wallet's capabilities. + * @param options - Options for signing the PSBT. Only used by wallets that perform signing in this step. + * @returns A promise that resolves to the transaction ID. */ abstract pushPsbt( psbtHex: string, diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index 5507e4006..f28d798eb 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -201,16 +201,12 @@ export class Signer extends ccc.SignerBtc { } }); - // If no unsigned inputs found, assume we need to sign all inputs - if (toSignInputs.length === 0) { - for (let i = 0; i < psbt.data.inputs.length; i++) { - toSignInputs.push({ index: i, address } as ccc.ToSignInput); - } - } + // If no unsigned inputs found, the PSBT is already fully signed + // Let the wallet handle this case (likely a no-op or error) } catch (error) { - // Fallback: if PSBT parsing fails, assume single input at index 0 - console.warn("Failed to parse PSBT, assuming single input:", error); - toSignInputs.push({ index: 0, address } as ccc.ToSignInput); + throw new Error( + `Failed to parse PSBT hex. Please provide toSignInputs explicitly in options. Original error: ${String(error)}`, + ); } return toSignInputs; @@ -309,10 +305,6 @@ export class Signer extends ccc.SignerBtc { * - signInputs: Record input indexes to sign * - broadcast: set to true to broadcast * - * @remarks - * Use this method directly for sign+broadcast operations to avoid double popups. - * While calling signPsbt() then pushPsbt() will still work, it triggers two popups and requires double signing. - * * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt */ async pushPsbt( From 9ed4d2cbf094b3acfc6288316c79a9adfb974e6c Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 10 Dec 2025 01:10:20 -0500 Subject: [PATCH 33/45] chore(btc): improve PSBT documentation and remove redundant code --- packages/uni-sat/src/advancedBarrel.ts | 8 ++++++-- packages/uni-sat/src/signer.ts | 1 + packages/xverse/src/signer.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/uni-sat/src/advancedBarrel.ts b/packages/uni-sat/src/advancedBarrel.ts index 6a97061cf..8ff7e0c04 100644 --- a/packages/uni-sat/src/advancedBarrel.ts +++ b/packages/uni-sat/src/advancedBarrel.ts @@ -13,10 +13,14 @@ export interface Provider { */ signPsbt(psbtHex: string, options?: ccc.SignPsbtOptions): Promise; + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of the signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID. + */ pushPsbt(psbtHex: string): Promise; - pushTx(tx: { rawtx: string }): Promise; - /** * Requests user accounts. * @returns A promise that resolves to an array of account addresses. diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index bf28c7de5..1c4461c49 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -155,6 +155,7 @@ export class Signer extends ccc.SignerBtc { * Signs a PSBT using UniSat wallet. * * @param psbtHex - The hex string of PSBT to sign + * @param options - Options for signing the PSBT * @returns A promise that resolves to the signed PSBT hex string */ async signPsbt( diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index f28d798eb..34d10c239 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -197,7 +197,7 @@ export class Signer extends ccc.SignerBtc { (input.tapScriptSig && input.tapScriptSig.length > 0); if (!isSigned) { - toSignInputs.push({ index, address } as ccc.ToSignInput); + toSignInputs.push({ index, address }); } }); From b908083e87ca1c33081e6a8cd8a1f80164ba9ee9 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 10 Dec 2025 01:28:05 -0500 Subject: [PATCH 34/45] refactor(xverse): use bytesTo instead of hexFrom+slice for PSBT conversion --- packages/xverse/src/signer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index 34d10c239..11344c12f 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -287,7 +287,7 @@ export class Signer extends ccc.SignerBtc { ).psbt; const signedPsbtBytes = ccc.bytesFrom(signedPsbtBase64, "base64"); - return ccc.hexFrom(signedPsbtBytes).slice(2); + return ccc.bytesTo(signedPsbtBytes, "hex"); // no leading "0x" } /** From fa4ad05135a3d1b9117668a8a14540092139a6fe Mon Sep 17 00:00:00 2001 From: fgh Date: Thu, 8 Jan 2026 00:06:19 -0500 Subject: [PATCH 35/45] fix(joy-id): pass options parameter to buildJoyIDURL in pushPsbt --- packages/joy-id/src/btc/index.ts | 3 ++- packages/xverse/src/signer.ts | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index 885528659..0cf634a79 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -248,7 +248,7 @@ export class BitcoinSigner extends ccc.SignerBtc { */ async pushPsbt( psbtHex: string, - _options?: ccc.SignPsbtOptions, + options?: ccc.SignPsbtOptions, ): Promise { const { address } = await this.assertConnection(); @@ -258,6 +258,7 @@ export class BitcoinSigner extends ccc.SignerBtc { { ...config, tx: psbtHex, + options, signerAddress: address, autoFinalized: true, // sendPsbt always finalizes isSend: true, diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index 11344c12f..c793d6a19 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -319,8 +319,6 @@ export class Signer extends ccc.SignerBtc { const result = await checkResponse( this.provider.request("signPsbt", { psbt: psbtBase64, - // Build signInputs: Record - // Multiple inputs with the same address should be grouped together signInputs, broadcast: true, }), From 20d6985a7f254cde5bf68dfe09d2a50b41ffea6e Mon Sep 17 00:00:00 2001 From: fgh Date: Tue, 13 Jan 2026 13:16:04 -0500 Subject: [PATCH 36/45] refactor(SignerBtc): standardize BTC signer methods and types --- packages/core/src/signer/btc/signerBtc.ts | 58 ++++++++----------- .../signer/btc/signerBtcPublicKeyReadonly.ts | 6 +- packages/joy-id/src/btc/index.ts | 39 +++++++------ packages/okx/src/btc/index.ts | 18 +++--- packages/uni-sat/src/signer.ts | 18 +++--- packages/utxo-global/src/btc/index.ts | 15 +++-- packages/xverse/src/signer.ts | 58 +++++++++---------- 7 files changed, 100 insertions(+), 112 deletions(-) diff --git a/packages/core/src/signer/btc/signerBtc.ts b/packages/core/src/signer/btc/signerBtc.ts index 6dd8ba4c6..30c090c6d 100644 --- a/packages/core/src/signer/btc/signerBtc.ts +++ b/packages/core/src/signer/btc/signerBtc.ts @@ -24,29 +24,19 @@ export abstract class SignerBtc extends Signer { } /** - * Whether the wallet supports a single call to sign + broadcast (combined flow). - * Default false; override in implementations like Xverse/JoyID. - */ - get supportsSingleCallSignAndBroadcast(): boolean { - return false; - } - - /** - * Sign and broadcast a PSBT in one call when supported, otherwise falls back - * to sign then push. Prefer this over manual sign+push to avoid double popups. + * Sign and broadcast a PSBT. + * + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign and broadcast. + * @param options - Options for signing the PSBT. + * @returns A promise that resolves to the transaction ID (non-0x prefixed hex). */ - async signAndPushPsbt( - psbtHex: string, + async signAndBroadcastPsbt( + psbtHex: HexLike, options?: SignPsbtOptions, ): Promise { - if (this.supportsSingleCallSignAndBroadcast) { - // Wallet handles sign+broadcast internally (e.g., Xverse/JoyID) - return this.pushPsbt(psbtHex, options); - } - - // Split-mode wallets: sign first, then broadcast - const signedPsbt = await this.signPsbt(psbtHex, options); - return this.pushPsbt(signedPsbt, options); + // ccc.hexFrom adds 0x prefix, but BTC expects non-0x + const signedPsbt = await this.signPsbt(hexFrom(psbtHex).slice(2), options); + return this.broadcastPsbt(signedPsbt, options); } /** @@ -154,28 +144,26 @@ export abstract class SignerBtc extends Signer { /** * Signs a Partially Signed Bitcoin Transaction (PSBT). * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ abstract signPsbt( - psbtHex: string, + psbtHex: HexLike, options?: SignPsbtOptions, ): Promise; /** - * Pushes a PSBT to the Bitcoin network. - * - * For wallets that support a single call for signing and broadcasting (where `supportsSingleCallSignAndBroadcast` is true), - * this method takes an **unsigned** PSBT, signs it, and broadcasts it. - * For other wallets, this method takes a **signed** PSBT and only broadcasts it. + * Broadcasts a PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of the PSBT to push. Can be signed or unsigned depending on the wallet's capabilities. - * @param options - Options for signing the PSBT. Only used by wallets that perform signing in this step. - * @returns A promise that resolves to the transaction ID. + * @param psbtHex - The hex string (without 0x prefix) of the PSBT to broadcast. + * @param options - Options for broadcasting the PSBT. + * @returns A promise that resolves to the transaction ID (without 0x prefix). */ - abstract pushPsbt( - psbtHex: string, - options?: SignPsbtOptions, - ): Promise; + async broadcastPsbt( + _psbtHex: HexLike, + _options?: SignPsbtOptions, + ): Promise { + throw new Error("Not implemented"); + } } diff --git a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts index f5293a952..e35be4f9d 100644 --- a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts +++ b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts @@ -72,11 +72,11 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc { return this.publicKey; } - async signPsbt(_: string, __?: SignPsbtOptions): Promise { + async signPsbt(_: HexLike, __?: SignPsbtOptions): Promise { throw new Error("Read-only signer does not support signPsbt"); } - async pushPsbt(_: string, __?: SignPsbtOptions): Promise { - throw new Error("Read-only signer does not support pushPsbt"); + async broadcastPsbt(_: HexLike, __?: SignPsbtOptions): Promise { + throw new Error("Read-only signer does not support broadcastPsbt"); } } diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index 0cf634a79..8d5686fc2 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -60,10 +60,6 @@ export class BitcoinSigner extends ccc.SignerBtc { super(client); } - get supportsSingleCallSignAndBroadcast(): boolean { - return true; - } - /** * Gets the configuration for JoyID. * @returns The configuration object. @@ -206,11 +202,11 @@ export class BitcoinSigner extends ccc.SignerBtc { /** * Signs a PSBT using JoyID wallet. * - * @param psbtHex - The hex string of PSBT to sign - * @returns A promise that resolves to the signed PSBT hex string + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ async signPsbt( - psbtHex: string, + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { const { address } = await this.assertConnection(); @@ -220,7 +216,7 @@ export class BitcoinSigner extends ccc.SignerBtc { buildJoyIDURL( { ...config, - tx: psbtHex, + tx: ccc.hexFrom(psbtHex).slice(2), options, signerAddress: address, autoFinalized: options?.autoFinalized ?? true, @@ -235,29 +231,34 @@ export class BitcoinSigner extends ccc.SignerBtc { } /** - * Signs and broadcasts a PSBT to the Bitcoin network using JoyID wallet. - * - * This method combines both signing and broadcasting in a single operation. - * - * @param psbtHex - The hex string of PSBT to sign and broadcast - * @returns A promise that resolves to the transaction ID + * Broadcasts a PSBT to the Bitcoin network. * * @remarks - * Use this method directly for sign+broadcast operations to avoid double popups. - * While calling signPsbt() then pushPsbt() will still work, it triggers two popups and requires double signing. + * JoyID does not support broadcasting a signed PSBT directly. + * It only supports "Sign and Broadcast" as a single atomic operation via `signAndBroadcastPsbt`. */ - async pushPsbt( - psbtHex: string, + async broadcastPsbt( + _psbtHex: ccc.HexLike, + _options?: ccc.SignPsbtOptions, + ): Promise { + throw new Error( + "JoyID does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", + ); + } + + async signAndBroadcastPsbt( + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { const { address } = await this.assertConnection(); const config = this.getConfig(); + // ccc.hexFrom adds 0x prefix, but BTC expects non-0x const { tx: txid } = await createPopup( buildJoyIDURL( { ...config, - tx: psbtHex, + tx: ccc.hexFrom(psbtHex).slice(2), options, signerAddress: address, autoFinalized: true, // sendPsbt always finalizes diff --git a/packages/okx/src/btc/index.ts b/packages/okx/src/btc/index.ts index 7e3b20b65..f9ff02512 100644 --- a/packages/okx/src/btc/index.ts +++ b/packages/okx/src/btc/index.ts @@ -180,27 +180,27 @@ export class BitcoinSigner extends ccc.SignerBtc { /** * Signs a PSBT using OKX wallet. * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ async signPsbt( - psbtHex: string, + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { - return this.provider.signPsbt(psbtHex, options); + return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); } /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of signed PSBT to broadcast - * @returns A promise that resolves to the transaction ID + * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID (without 0x prefix) */ - async pushPsbt( - psbtHex: string, + async broadcastPsbt( + psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, ): Promise { - return this.provider.pushPsbt(psbtHex); + return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); } } diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index 1c4461c49..4eba70d5d 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -154,27 +154,27 @@ export class Signer extends ccc.SignerBtc { /** * Signs a PSBT using UniSat wallet. * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ async signPsbt( - psbtHex: string, + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { - return this.provider.signPsbt(psbtHex, options); + return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); } /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of signed PSBT to broadcast - * @returns A promise that resolves to the transaction ID + * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID (without 0x prefix) */ - async pushPsbt( - psbtHex: string, + async broadcastPsbt( + psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, ): Promise { - return this.provider.pushPsbt(psbtHex); + return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); } } diff --git a/packages/utxo-global/src/btc/index.ts b/packages/utxo-global/src/btc/index.ts index 5cede753c..bfd74c499 100644 --- a/packages/utxo-global/src/btc/index.ts +++ b/packages/utxo-global/src/btc/index.ts @@ -131,12 +131,12 @@ export class SignerBtc extends ccc.SignerBtc { /** * Signs a PSBT using UTXO Global wallet. * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ async signPsbt( - _psbtHex: string, + _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, ): Promise { throw new Error("UTXO Global PSBT signing not implemented yet"); @@ -145,11 +145,14 @@ export class SignerBtc extends ccc.SignerBtc { /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of signed PSBT to broadcast - * @returns A promise that resolves to the transaction ID + * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID (without 0x prefix) * @todo Implement PSBT broadcasting with UTXO Global */ - async pushPsbt(_: string, __?: ccc.SignPsbtOptions): Promise { + async broadcastPsbt( + _: ccc.HexLike, + __?: ccc.SignPsbtOptions, + ): Promise { throw new Error("UTXO Global PSBT broadcasting not implemented yet"); } } diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index c793d6a19..f04df272d 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -66,10 +66,6 @@ export class Signer extends ccc.SignerBtc { super(client); } - get supportsSingleCallSignAndBroadcast(): boolean { - return true; - } - async assertAddress(): Promise
{ this.addressCache = this.addressCache ?? @@ -179,15 +175,14 @@ export class Signer extends ccc.SignerBtc { * Build default toSignInputs for all unsigned inputs */ private buildDefaultToSignInputs( - psbtHex: string, + psbtHex: ccc.Hex, address: string, ): ccc.ToSignInput[] { const toSignInputs: ccc.ToSignInput[] = []; try { - const psbt = Psbt.fromHex(psbtHex); - // Collect all unsigned inputs + const psbt = Psbt.fromHex(psbtHex.slice(2)); psbt.data.inputs.forEach((input, index) => { const isSigned = input.finalScriptSig || @@ -204,8 +199,10 @@ export class Signer extends ccc.SignerBtc { // If no unsigned inputs found, the PSBT is already fully signed // Let the wallet handle this case (likely a no-op or error) } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); throw new Error( - `Failed to parse PSBT hex. Please provide toSignInputs explicitly in options. Original error: ${String(error)}`, + `Failed to parse PSBT hex. Please provide toSignInputs explicitly in options. Original error: ${errorMessage}`, ); } @@ -213,7 +210,7 @@ export class Signer extends ccc.SignerBtc { } private async prepareSignPsbtParams( - psbtHex: string, + psbtHex: ccc.Hex, options?: ccc.SignPsbtOptions, ): Promise<{ psbtBase64: string; @@ -225,8 +222,7 @@ export class Signer extends ccc.SignerBtc { toSignInputs = this.buildDefaultToSignInputs(psbtHex, address); } - const psbtBytes = ccc.bytesFrom(psbtHex); - const psbtBase64 = ccc.bytesTo(psbtBytes, "base64"); + const psbtBase64 = ccc.bytesTo(psbtHex, "base64"); const signInputs = toSignInputs.reduce( (acc, input) => { @@ -251,9 +247,9 @@ export class Signer extends ccc.SignerBtc { /** * Signs a PSBT using Xverse wallet. * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) * * @remarks * Xverse accepts: @@ -268,11 +264,11 @@ export class Signer extends ccc.SignerBtc { * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt */ async signPsbt( - psbtHex: string, + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( - psbtHex, + ccc.hexFrom(psbtHex), options, ); @@ -291,28 +287,28 @@ export class Signer extends ccc.SignerBtc { } /** - * Signs and broadcasts a PSBT using Xverse wallet (single popup). - * - * @param psbtHex - The hex string of PSBT to sign and broadcast - * @param options - Options for signing the PSBT - * @returns A promise that resolves to SignPsbtResult: - * - psbt: base64 encoded signed PSBT - * - txid: transaction id (only when broadcast succeeds) + * Broadcasts a PSBT to the Bitcoin network. * * @remarks - * Xverse accepts: - * - psbt: base64 encoded PSBT - * - signInputs: Record input indexes to sign - * - broadcast: set to true to broadcast - * - * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt + * Xverse does not support broadcasting a signed PSBT directly. + * It only supports "Sign and Broadcast" as a single atomic operation via `signAndBroadcastPsbt`. */ - async pushPsbt( - psbtHex: string, + async broadcastPsbt( + _psbtHex: ccc.HexLike, + _options?: ccc.SignPsbtOptions, + ): Promise { + throw new Error( + "Xverse does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", + ); + } + + async signAndBroadcastPsbt( + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { + // ccc.hexFrom adds 0x prefix, but BTC expects non-0x const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( - psbtHex, + ccc.hexFrom(psbtHex), options, ); From 1a0f6addc5c29477e2d1c3df96fe189618917d8b Mon Sep 17 00:00:00 2001 From: fgh Date: Tue, 13 Jan 2026 13:38:38 -0500 Subject: [PATCH 37/45] refactor: improve parameter naming in PSBT methods --- .../core/src/signer/btc/signerBtcPublicKeyReadonly.ts | 10 ++++++++-- packages/utxo-global/src/btc/index.ts | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts index e35be4f9d..5f2d7a729 100644 --- a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts +++ b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts @@ -72,11 +72,17 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc { return this.publicKey; } - async signPsbt(_: HexLike, __?: SignPsbtOptions): Promise { + async signPsbt( + _psbtHex: HexLike, + _options?: SignPsbtOptions, + ): Promise { throw new Error("Read-only signer does not support signPsbt"); } - async broadcastPsbt(_: HexLike, __?: SignPsbtOptions): Promise { + async broadcastPsbt( + _psbtHex: HexLike, + _options?: SignPsbtOptions, + ): Promise { throw new Error("Read-only signer does not support broadcastPsbt"); } } diff --git a/packages/utxo-global/src/btc/index.ts b/packages/utxo-global/src/btc/index.ts index bfd74c499..7c285453c 100644 --- a/packages/utxo-global/src/btc/index.ts +++ b/packages/utxo-global/src/btc/index.ts @@ -150,8 +150,8 @@ export class SignerBtc extends ccc.SignerBtc { * @todo Implement PSBT broadcasting with UTXO Global */ async broadcastPsbt( - _: ccc.HexLike, - __?: ccc.SignPsbtOptions, + _psbtHex: ccc.HexLike, + _options?: ccc.SignPsbtOptions, ): Promise { throw new Error("UTXO Global PSBT broadcasting not implemented yet"); } From 6e796bc80867ea8a3c9f8984126e3db8d422229c Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 14 Jan 2026 04:41:13 -0500 Subject: [PATCH 38/45] refactor(SignerBtc): standardize PSBT method signatures and return types --- packages/core/src/signer/btc/signerBtc.ts | 26 ++++++++----------- .../signer/btc/signerBtcPublicKeyReadonly.ts | 7 ++--- packages/joy-id/src/btc/index.ts | 14 +++++----- packages/okx/src/btc/index.ts | 15 ++++++----- packages/uni-sat/src/signer.ts | 15 ++++++----- packages/utxo-global/src/btc/index.ts | 12 ++++----- packages/xverse/src/signer.ts | 15 +++++------ 7 files changed, 51 insertions(+), 53 deletions(-) diff --git a/packages/core/src/signer/btc/signerBtc.ts b/packages/core/src/signer/btc/signerBtc.ts index 30c090c6d..b450feefb 100644 --- a/packages/core/src/signer/btc/signerBtc.ts +++ b/packages/core/src/signer/btc/signerBtc.ts @@ -2,7 +2,7 @@ import { Address } from "../../address/index.js"; import { bytesConcat, bytesFrom } from "../../bytes/index.js"; import { Transaction, TransactionLike, WitnessArgs } from "../../ckb/index.js"; import { KnownScript } from "../../client/index.js"; -import { HexLike, hexFrom } from "../../hex/index.js"; +import { Hex, HexLike, hexFrom } from "../../hex/index.js"; import { numToBytes } from "../../num/index.js"; import { Signer, SignerSignType, SignerType } from "../signer/index.js"; import { SignPsbtOptions } from "./psbt.js"; @@ -26,16 +26,15 @@ export abstract class SignerBtc extends Signer { /** * Sign and broadcast a PSBT. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign and broadcast. + * @param psbtHex - The hex string of PSBT to sign and broadcast. * @param options - Options for signing the PSBT. - * @returns A promise that resolves to the transaction ID (non-0x prefixed hex). + * @returns A promise that resolves to the transaction ID as a Hex string. */ async signAndBroadcastPsbt( psbtHex: HexLike, options?: SignPsbtOptions, - ): Promise { - // ccc.hexFrom adds 0x prefix, but BTC expects non-0x - const signedPsbt = await this.signPsbt(hexFrom(psbtHex).slice(2), options); + ): Promise { + const signedPsbt = await this.signPsbt(psbtHex, options); return this.broadcastPsbt(signedPsbt, options); } @@ -144,26 +143,23 @@ export abstract class SignerBtc extends Signer { /** * Signs a Partially Signed Bitcoin Transaction (PSBT). * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @param psbtHex - The hex string of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string. */ - abstract signPsbt( - psbtHex: HexLike, - options?: SignPsbtOptions, - ): Promise; + abstract signPsbt(psbtHex: HexLike, options?: SignPsbtOptions): Promise; /** * Broadcasts a PSBT to the Bitcoin network. * - * @param psbtHex - The hex string (without 0x prefix) of the PSBT to broadcast. + * @param psbtHex - The hex string of the PSBT to broadcast. * @param options - Options for broadcasting the PSBT. - * @returns A promise that resolves to the transaction ID (without 0x prefix). + * @returns A promise that resolves to the transaction ID as a Hex string. */ async broadcastPsbt( _psbtHex: HexLike, _options?: SignPsbtOptions, - ): Promise { + ): Promise { throw new Error("Not implemented"); } } diff --git a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts index 5f2d7a729..1b2a403d6 100644 --- a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts +++ b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts @@ -72,17 +72,14 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc { return this.publicKey; } - async signPsbt( - _psbtHex: HexLike, - _options?: SignPsbtOptions, - ): Promise { + async signPsbt(_psbtHex: HexLike, _options?: SignPsbtOptions): Promise { throw new Error("Read-only signer does not support signPsbt"); } async broadcastPsbt( _psbtHex: HexLike, _options?: SignPsbtOptions, - ): Promise { + ): Promise { throw new Error("Read-only signer does not support broadcastPsbt"); } } diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index 8d5686fc2..b4efe236b 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -202,13 +202,13 @@ export class BitcoinSigner extends ccc.SignerBtc { /** * Signs a PSBT using JoyID wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. - * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) + * @param psbtHex - The hex string of PSBT to sign. + * @returns A promise that resolves to the signed PSBT as a Hex string. */ async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { const { address } = await this.assertConnection(); const config = this.getConfig(); @@ -227,7 +227,7 @@ export class BitcoinSigner extends ccc.SignerBtc { { ...config, type: DappRequestType.SignPsbt }, ); - return signedPsbtHex; + return ccc.hexFrom(signedPsbtHex); } /** @@ -240,7 +240,7 @@ export class BitcoinSigner extends ccc.SignerBtc { async broadcastPsbt( _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { throw new Error( "JoyID does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", ); @@ -249,7 +249,7 @@ export class BitcoinSigner extends ccc.SignerBtc { async signAndBroadcastPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { const { address } = await this.assertConnection(); const config = this.getConfig(); @@ -270,6 +270,6 @@ export class BitcoinSigner extends ccc.SignerBtc { { ...config, type: DappRequestType.SignPsbt }, // Use SignPsbt type for both operations ); - return txid; + return ccc.hexFrom(txid); } } diff --git a/packages/okx/src/btc/index.ts b/packages/okx/src/btc/index.ts index f9ff02512..3497e79fd 100644 --- a/packages/okx/src/btc/index.ts +++ b/packages/okx/src/btc/index.ts @@ -180,15 +180,17 @@ export class BitcoinSigner extends ccc.SignerBtc { /** * Signs a PSBT using OKX wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @param psbtHex - The hex string of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string */ async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { - return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); + ): Promise { + return ccc.hexFrom( + await this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options), + ); } /** @@ -200,7 +202,8 @@ export class BitcoinSigner extends ccc.SignerBtc { async broadcastPsbt( psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { - return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); + ): Promise { + const txid = await this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); + return ccc.hexFrom(txid); } } diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index 4eba70d5d..333683f58 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -154,15 +154,17 @@ export class Signer extends ccc.SignerBtc { /** * Signs a PSBT using UniSat wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @param psbtHex - The hex string of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string */ async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { - return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); + ): Promise { + return ccc.hexFrom( + await this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options), + ); } /** @@ -174,7 +176,8 @@ export class Signer extends ccc.SignerBtc { async broadcastPsbt( psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { - return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); + ): Promise { + const txid = await this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); + return ccc.hexFrom(txid); } } diff --git a/packages/utxo-global/src/btc/index.ts b/packages/utxo-global/src/btc/index.ts index 7c285453c..7aee417cd 100644 --- a/packages/utxo-global/src/btc/index.ts +++ b/packages/utxo-global/src/btc/index.ts @@ -131,28 +131,28 @@ export class SignerBtc extends ccc.SignerBtc { /** * Signs a PSBT using UTXO Global wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @param psbtHex - The hex string of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string */ async signPsbt( _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { throw new Error("UTXO Global PSBT signing not implemented yet"); } /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. - * @returns A promise that resolves to the transaction ID (without 0x prefix) + * @param psbtHex - The hex string of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID as a Hex string * @todo Implement PSBT broadcasting with UTXO Global */ async broadcastPsbt( _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { throw new Error("UTXO Global PSBT broadcasting not implemented yet"); } } diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index f04df272d..441fdc0cc 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -247,9 +247,9 @@ export class Signer extends ccc.SignerBtc { /** * Signs a PSBT using Xverse wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @param psbtHex - The hex string of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string * * @remarks * Xverse accepts: @@ -266,7 +266,7 @@ export class Signer extends ccc.SignerBtc { async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( ccc.hexFrom(psbtHex), options, @@ -282,8 +282,7 @@ export class Signer extends ccc.SignerBtc { ) ).psbt; - const signedPsbtBytes = ccc.bytesFrom(signedPsbtBase64, "base64"); - return ccc.bytesTo(signedPsbtBytes, "hex"); // no leading "0x" + return ccc.hexFrom(ccc.bytesFrom(signedPsbtBase64, "base64")); } /** @@ -296,7 +295,7 @@ export class Signer extends ccc.SignerBtc { async broadcastPsbt( _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { throw new Error( "Xverse does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", ); @@ -305,7 +304,7 @@ export class Signer extends ccc.SignerBtc { async signAndBroadcastPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { // ccc.hexFrom adds 0x prefix, but BTC expects non-0x const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( ccc.hexFrom(psbtHex), @@ -324,6 +323,6 @@ export class Signer extends ccc.SignerBtc { throw new Error("Failed to broadcast PSBT"); } - return result.txid; + return ccc.hexFrom(result.txid); } } From f69183f4c6b0413141868dd130cd18014d25b72e Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 21 Jan 2026 01:36:40 -0500 Subject: [PATCH 39/45] refactor(core): improve SignerBtc PSBT types with Like pattern --- packages/core/src/signer/btc/psbt.ts | 46 +++++++++++++++++-- packages/core/src/signer/btc/signerBtc.ts | 19 ++++---- .../signer/btc/signerBtcPublicKeyReadonly.ts | 9 ++-- packages/joy-id/src/btc/index.ts | 14 +++--- packages/okx/src/btc/index.ts | 4 +- packages/uni-sat/src/advancedBarrel.ts | 2 +- packages/uni-sat/src/signer.ts | 4 +- packages/utxo-global/src/btc/index.ts | 4 +- packages/xverse/src/signer.ts | 33 ++++++------- 9 files changed, 89 insertions(+), 46 deletions(-) diff --git a/packages/core/src/signer/btc/psbt.ts b/packages/core/src/signer/btc/psbt.ts index 77ac646f7..69dd6f595 100644 --- a/packages/core/src/signer/btc/psbt.ts +++ b/packages/core/src/signer/btc/psbt.ts @@ -1,7 +1,9 @@ +import { HexLike, hexFrom } from "../../hex/index.js"; + /** * Options for signing a PSBT (Partially Signed Bitcoin Transaction) */ -export type SignPsbtOptions = { +export type SignPsbtOptionsLike = { /** * Whether to finalize the PSBT after signing. * Default is true. @@ -10,14 +12,28 @@ export type SignPsbtOptions = { /** * Array of inputs to sign */ - toSignInputs?: ToSignInput[]; + inputsToSign?: InputToSignLike[]; }; +export class SignPsbtOptions { + constructor( + public autoFinalized: boolean, + public inputsToSign: InputToSign[], + ) {} + + static from(options?: SignPsbtOptionsLike): SignPsbtOptions { + return new SignPsbtOptions( + options?.autoFinalized ?? true, + options?.inputsToSign?.map((i) => InputToSign.from(i)) ?? [], + ); + } +} + /** * Specification for an input to sign in a PSBT. * Must specify at least one of: address or pubkey. */ -export type ToSignInput = { +export type InputToSignLike = { /** * Which input to sign (index in the PSBT inputs array) */ @@ -41,7 +57,7 @@ export type ToSignInput = { /** * The public key whose corresponding private key to use for signing. */ - publicKey?: string; + publicKey?: HexLike; } | { /** @@ -51,6 +67,26 @@ export type ToSignInput = { /** * The public key whose corresponding private key to use for signing. */ - publicKey: string; + publicKey: HexLike; } ); + +export class InputToSign { + constructor( + public index: number, + public sighashTypes?: number[], + public disableTweakSigner?: boolean, + public address?: string, + public publicKey?: string, + ) {} + + static from(input: InputToSignLike): InputToSign { + return new InputToSign( + input.index, + input.sighashTypes, + input.disableTweakSigner, + input.address, + input.publicKey ? hexFrom(input.publicKey).slice(2) : undefined, + ); + } +} diff --git a/packages/core/src/signer/btc/signerBtc.ts b/packages/core/src/signer/btc/signerBtc.ts index b450feefb..4c0d389ad 100644 --- a/packages/core/src/signer/btc/signerBtc.ts +++ b/packages/core/src/signer/btc/signerBtc.ts @@ -5,7 +5,7 @@ import { KnownScript } from "../../client/index.js"; import { Hex, HexLike, hexFrom } from "../../hex/index.js"; import { numToBytes } from "../../num/index.js"; import { Signer, SignerSignType, SignerType } from "../signer/index.js"; -import { SignPsbtOptions } from "./psbt.js"; +import { SignPsbtOptionsLike } from "./psbt.js"; import { btcEcdsaPublicKeyHash } from "./verify.js"; /** @@ -32,7 +32,7 @@ export abstract class SignerBtc extends Signer { */ async signAndBroadcastPsbt( psbtHex: HexLike, - options?: SignPsbtOptions, + options?: SignPsbtOptionsLike, ): Promise { const signedPsbt = await this.signPsbt(psbtHex, options); return this.broadcastPsbt(signedPsbt, options); @@ -147,7 +147,10 @@ export abstract class SignerBtc extends Signer { * @param options - Options for signing the PSBT * @returns A promise that resolves to the signed PSBT as a Hex string. */ - abstract signPsbt(psbtHex: HexLike, options?: SignPsbtOptions): Promise; + abstract signPsbt( + psbtHex: HexLike, + options?: SignPsbtOptionsLike, + ): Promise; /** * Broadcasts a PSBT to the Bitcoin network. @@ -156,10 +159,8 @@ export abstract class SignerBtc extends Signer { * @param options - Options for broadcasting the PSBT. * @returns A promise that resolves to the transaction ID as a Hex string. */ - async broadcastPsbt( - _psbtHex: HexLike, - _options?: SignPsbtOptions, - ): Promise { - throw new Error("Not implemented"); - } + abstract broadcastPsbt( + psbtHex: HexLike, + options?: SignPsbtOptionsLike, + ): Promise; } diff --git a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts index 1b2a403d6..25af50b6f 100644 --- a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts +++ b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts @@ -1,6 +1,6 @@ import { Client } from "../../client/index.js"; import { Hex, HexLike, hexFrom } from "../../hex/index.js"; -import { SignPsbtOptions } from "./psbt.js"; +import { SignPsbtOptionsLike } from "./psbt.js"; import { SignerBtc } from "./signerBtc.js"; /** @@ -72,13 +72,16 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc { return this.publicKey; } - async signPsbt(_psbtHex: HexLike, _options?: SignPsbtOptions): Promise { + async signPsbt( + _psbtHex: HexLike, + _options?: SignPsbtOptionsLike, + ): Promise { throw new Error("Read-only signer does not support signPsbt"); } async broadcastPsbt( _psbtHex: HexLike, - _options?: SignPsbtOptions, + _options?: SignPsbtOptionsLike, ): Promise { throw new Error("Read-only signer does not support broadcastPsbt"); } diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index b4efe236b..629ea7dc8 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -207,9 +207,10 @@ export class BitcoinSigner extends ccc.SignerBtc { */ async signPsbt( psbtHex: ccc.HexLike, - options?: ccc.SignPsbtOptions, + options?: ccc.SignPsbtOptionsLike, ): Promise { const { address } = await this.assertConnection(); + const formattedOptions = ccc.SignPsbtOptions.from(options); const config = this.getConfig(); const { tx: signedPsbtHex } = await createPopup( @@ -217,9 +218,9 @@ export class BitcoinSigner extends ccc.SignerBtc { { ...config, tx: ccc.hexFrom(psbtHex).slice(2), - options, + options: formattedOptions, signerAddress: address, - autoFinalized: options?.autoFinalized ?? true, + autoFinalized: formattedOptions.autoFinalized, }, "popup", "/sign-psbt", @@ -239,7 +240,7 @@ export class BitcoinSigner extends ccc.SignerBtc { */ async broadcastPsbt( _psbtHex: ccc.HexLike, - _options?: ccc.SignPsbtOptions, + _options?: ccc.SignPsbtOptionsLike, ): Promise { throw new Error( "JoyID does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", @@ -248,9 +249,10 @@ export class BitcoinSigner extends ccc.SignerBtc { async signAndBroadcastPsbt( psbtHex: ccc.HexLike, - options?: ccc.SignPsbtOptions, + options?: ccc.SignPsbtOptionsLike, ): Promise { const { address } = await this.assertConnection(); + const formattedOptions = ccc.SignPsbtOptions.from(options); const config = this.getConfig(); // ccc.hexFrom adds 0x prefix, but BTC expects non-0x @@ -259,7 +261,7 @@ export class BitcoinSigner extends ccc.SignerBtc { { ...config, tx: ccc.hexFrom(psbtHex).slice(2), - options, + options: formattedOptions, signerAddress: address, autoFinalized: true, // sendPsbt always finalizes isSend: true, diff --git a/packages/okx/src/btc/index.ts b/packages/okx/src/btc/index.ts index 3497e79fd..4048fbf44 100644 --- a/packages/okx/src/btc/index.ts +++ b/packages/okx/src/btc/index.ts @@ -186,7 +186,7 @@ export class BitcoinSigner extends ccc.SignerBtc { */ async signPsbt( psbtHex: ccc.HexLike, - options?: ccc.SignPsbtOptions, + options?: ccc.SignPsbtOptionsLike, ): Promise { return ccc.hexFrom( await this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options), @@ -201,7 +201,7 @@ export class BitcoinSigner extends ccc.SignerBtc { */ async broadcastPsbt( psbtHex: ccc.HexLike, - _options?: ccc.SignPsbtOptions, + _options?: ccc.SignPsbtOptionsLike, ): Promise { const txid = await this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); return ccc.hexFrom(txid); diff --git a/packages/uni-sat/src/advancedBarrel.ts b/packages/uni-sat/src/advancedBarrel.ts index 8ff7e0c04..d40e8d860 100644 --- a/packages/uni-sat/src/advancedBarrel.ts +++ b/packages/uni-sat/src/advancedBarrel.ts @@ -11,7 +11,7 @@ export interface Provider { * @param options - Options for signing the PSBT * @returns A promise that resolves to the signed PSBT hex string */ - signPsbt(psbtHex: string, options?: ccc.SignPsbtOptions): Promise; + signPsbt(psbtHex: string, options?: ccc.SignPsbtOptionsLike): Promise; /** * Broadcasts a signed PSBT to the Bitcoin network. diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index 333683f58..ec2fc6e23 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -160,7 +160,7 @@ export class Signer extends ccc.SignerBtc { */ async signPsbt( psbtHex: ccc.HexLike, - options?: ccc.SignPsbtOptions, + options?: ccc.SignPsbtOptionsLike, ): Promise { return ccc.hexFrom( await this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options), @@ -175,7 +175,7 @@ export class Signer extends ccc.SignerBtc { */ async broadcastPsbt( psbtHex: ccc.HexLike, - _options?: ccc.SignPsbtOptions, + _options?: ccc.SignPsbtOptionsLike, ): Promise { const txid = await this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); return ccc.hexFrom(txid); diff --git a/packages/utxo-global/src/btc/index.ts b/packages/utxo-global/src/btc/index.ts index 7aee417cd..67771340b 100644 --- a/packages/utxo-global/src/btc/index.ts +++ b/packages/utxo-global/src/btc/index.ts @@ -137,7 +137,7 @@ export class SignerBtc extends ccc.SignerBtc { */ async signPsbt( _psbtHex: ccc.HexLike, - _options?: ccc.SignPsbtOptions, + _options?: ccc.SignPsbtOptionsLike, ): Promise { throw new Error("UTXO Global PSBT signing not implemented yet"); } @@ -151,7 +151,7 @@ export class SignerBtc extends ccc.SignerBtc { */ async broadcastPsbt( _psbtHex: ccc.HexLike, - _options?: ccc.SignPsbtOptions, + _options?: ccc.SignPsbtOptionsLike, ): Promise { throw new Error("UTXO Global PSBT broadcasting not implemented yet"); } diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index 441fdc0cc..2585ce771 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -172,13 +172,13 @@ export class Signer extends ccc.SignerBtc { } /** - * Build default toSignInputs for all unsigned inputs + * Build default inputsToSign for all unsigned inputs */ - private buildDefaultToSignInputs( + private buildDefaultinputsToSign( psbtHex: ccc.Hex, address: string, - ): ccc.ToSignInput[] { - const toSignInputs: ccc.ToSignInput[] = []; + ): ccc.InputToSignLike[] { + const inputsToSign: ccc.InputToSignLike[] = []; try { // Collect all unsigned inputs @@ -192,7 +192,7 @@ export class Signer extends ccc.SignerBtc { (input.tapScriptSig && input.tapScriptSig.length > 0); if (!isSigned) { - toSignInputs.push({ index, address }); + inputsToSign.push({ index, address }); } }); @@ -202,33 +202,34 @@ export class Signer extends ccc.SignerBtc { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error( - `Failed to parse PSBT hex. Please provide toSignInputs explicitly in options. Original error: ${errorMessage}`, + `Failed to parse PSBT hex. Please provide inputsToSign explicitly in options. Original error: ${errorMessage}`, ); } - return toSignInputs; + return inputsToSign; } private async prepareSignPsbtParams( psbtHex: ccc.Hex, - options?: ccc.SignPsbtOptions, + options?: ccc.SignPsbtOptionsLike, ): Promise<{ psbtBase64: string; signInputs: Record; }> { - let toSignInputs = options?.toSignInputs; - if (!toSignInputs || !toSignInputs.length) { + let inputsToSign = options?.inputsToSign; + + if (!inputsToSign || !inputsToSign.length) { const address = await this.getBtcAccount(); - toSignInputs = this.buildDefaultToSignInputs(psbtHex, address); + inputsToSign = this.buildDefaultinputsToSign(psbtHex, address); } const psbtBase64 = ccc.bytesTo(psbtHex, "base64"); - const signInputs = toSignInputs.reduce( + const signInputs = inputsToSign.reduce( (acc, input) => { if (!input.address) { throw new Error( - "Xverse only supports signing with address. Please provide 'address' in toSignInputs.", + "Xverse only supports signing with address. Please provide 'address' in inputsToSign.", ); } if (acc[input.address]) { @@ -265,7 +266,7 @@ export class Signer extends ccc.SignerBtc { */ async signPsbt( psbtHex: ccc.HexLike, - options?: ccc.SignPsbtOptions, + options?: ccc.SignPsbtOptionsLike, ): Promise { const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( ccc.hexFrom(psbtHex), @@ -294,7 +295,7 @@ export class Signer extends ccc.SignerBtc { */ async broadcastPsbt( _psbtHex: ccc.HexLike, - _options?: ccc.SignPsbtOptions, + _options?: ccc.SignPsbtOptionsLike, ): Promise { throw new Error( "Xverse does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", @@ -303,7 +304,7 @@ export class Signer extends ccc.SignerBtc { async signAndBroadcastPsbt( psbtHex: ccc.HexLike, - options?: ccc.SignPsbtOptions, + options?: ccc.SignPsbtOptionsLike, ): Promise { // ccc.hexFrom adds 0x prefix, but BTC expects non-0x const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( From 7dff3551a98afe89ca38da0d87077cb9d134f586 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 21 Jan 2026 02:03:48 -0500 Subject: [PATCH 40/45] docs: clarify broadcastPsbt documentation --- packages/okx/src/btc/index.ts | 4 ++-- packages/uni-sat/src/signer.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/okx/src/btc/index.ts b/packages/okx/src/btc/index.ts index 4048fbf44..3fe40bed9 100644 --- a/packages/okx/src/btc/index.ts +++ b/packages/okx/src/btc/index.ts @@ -196,8 +196,8 @@ export class BitcoinSigner extends ccc.SignerBtc { /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. - * @returns A promise that resolves to the transaction ID (without 0x prefix) + * @param psbtHex - The hex string of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID as a Hex string */ async broadcastPsbt( psbtHex: ccc.HexLike, diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index ec2fc6e23..938c0b89f 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -170,8 +170,8 @@ export class Signer extends ccc.SignerBtc { /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. - * @returns A promise that resolves to the transaction ID (without 0x prefix) + * @param psbtHex - The hex string of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID as a Hex string */ async broadcastPsbt( psbtHex: ccc.HexLike, From 0090d1239e1a2fd0311ef3233ba3bcb0fe2647ae Mon Sep 17 00:00:00 2001 From: fgh Date: Tue, 27 Jan 2026 23:59:31 -0500 Subject: [PATCH 41/45] feat(playground): add bitcoin support for PSBT operations --- packages/playground/package.json | 2 ++ packages/playground/src/app/execute/index.tsx | 10 ++++++++++ pnpm-lock.yaml | 15 ++++++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/playground/package.json b/packages/playground/package.json index 1959e46a0..6bffe4f4f 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -11,6 +11,7 @@ "format": "prettier --write ./src" }, "dependencies": { + "@bitcoinerlab/secp256k1": "^1.2.0", "@ckb-ccc/ccc": "workspace:*", "@ckb-ccc/connector-react": "workspace:*", "@monaco-editor/react": "^4.7.0", @@ -21,6 +22,7 @@ "@shikijs/monaco": "^3.12.0", "axios": "^1.11.0", "bech32": "^2.0.0", + "bitcoinjs-lib": "^7.0.0", "isomorphic-ws": "^5.0.0", "lucide-react": "^0.542.0", "monaco-editor": "^0.52.2", diff --git a/packages/playground/src/app/execute/index.tsx b/packages/playground/src/app/execute/index.tsx index cd081999c..39148462e 100644 --- a/packages/playground/src/app/execute/index.tsx +++ b/packages/playground/src/app/execute/index.tsx @@ -1,5 +1,15 @@ +import * as ecc from "@bitcoinerlab/secp256k1"; +import * as cccLib from "@ckb-ccc/ccc"; +import * as cccAdvancedLib from "@ckb-ccc/ccc/advanced"; import { ccc } from "@ckb-ccc/connector-react"; +import * as dobRenderLib from "@nervina-labs/dob-render"; +import * as bitcoin from "bitcoinjs-lib"; import * as React from "react"; + +// Initialize the ECC library for bitcoinjs-lib to support Schnorr signatures (Taproot). +// This must be done once globally before any PSBT operations. +bitcoin.initEccLib(ecc); + import ts from "typescript"; import { formatTimestamp } from "../utils"; import { vlqDecode } from "./vlq"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67b164b5d..8b8b02927 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -865,6 +865,9 @@ importers: packages/playground: dependencies: + '@bitcoinerlab/secp256k1': + specifier: ^1.2.0 + version: 1.2.0 '@ckb-ccc/ccc': specifier: workspace:* version: link:../ccc @@ -895,6 +898,9 @@ importers: bech32: specifier: ^2.0.0 version: 2.0.0 + bitcoinjs-lib: + specifier: ^7.0.0 + version: 7.0.0(typescript@5.9.2) isomorphic-ws: specifier: ^5.0.0 version: 5.0.0(ws@8.18.3) @@ -2168,6 +2174,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bitcoinerlab/secp256k1@1.2.0': + resolution: {integrity: sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==} + '@borewit/text-codec@0.1.1': resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} @@ -12012,6 +12021,10 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bitcoinerlab/secp256k1@1.2.0': + dependencies: + '@noble/curves': 1.9.7 + '@borewit/text-codec@0.1.1': {} '@changesets/apply-release-plan@7.0.12': @@ -18261,7 +18274,7 @@ snapshots: history@4.10.1: dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 loose-envify: 1.4.0 resolve-pathname: 3.0.0 tiny-invariant: 1.3.3 From 24a560cdf0c79d4996d42373f2179feab84c7dd1 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 28 Jan 2026 00:38:53 -0500 Subject: [PATCH 42/45] feat: add Bitcoin transfer example --- packages/docs/docs/code-examples.md | 7 +- packages/examples/src/playground/index.d.ts | 2 + packages/examples/src/transferBtc.ts | 104 ++++++++++++++++++ .../playground/src/app/components/Editor.tsx | 2 +- 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 packages/examples/src/transferBtc.ts diff --git a/packages/docs/docs/code-examples.md b/packages/docs/docs/code-examples.md index 850efdb55..735683820 100644 --- a/packages/docs/docs/code-examples.md +++ b/packages/docs/docs/code-examples.md @@ -28,4 +28,9 @@ That's it! The transaction is sent. - [Use all supported wallets in custom UI.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/customUiWithController.ts) - [Sign and verify any message.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/sign.ts) - [Transfer all native CKB token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferAll.ts) -- [Transfer UDT token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferUdt.ts) \ No newline at end of file +- [Transfer UDT token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferUdt.ts) + + +CCC also supports Bitcoin! You can now build Bitcoin transactions and sign them using supported Bitcoin wallets. + +- [Transfer Bitcoin.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferBtc.ts) \ No newline at end of file diff --git a/packages/examples/src/playground/index.d.ts b/packages/examples/src/playground/index.d.ts index fefc0e139..7a7873c29 100644 --- a/packages/examples/src/playground/index.d.ts +++ b/packages/examples/src/playground/index.d.ts @@ -1,5 +1,7 @@ import { ccc } from "@ckb-ccc/ccc"; +import * as bitcoinLib from "bitcoinjs-lib"; export function render(tx: ccc.Transaction): Promise; export const signer: ccc.Signer; export const client: ccc.Client; +export const bitcoin: typeof bitcoinLib; diff --git a/packages/examples/src/transferBtc.ts b/packages/examples/src/transferBtc.ts new file mode 100644 index 000000000..c7b3b76a5 --- /dev/null +++ b/packages/examples/src/transferBtc.ts @@ -0,0 +1,104 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { bitcoin, signer } from "@ckb-ccc/playground"; + +// Supported wallets: Unisat, JoyID, Xverse +// Check if the current signer is also a Bitcoin signer +if (!(signer instanceof ccc.SignerBtc)) { + throw new Error("Signer is not a Bitcoin signer"); +} + +// Only support testnet for safety +if (signer.client.addressPrefix !== "ckt") { + throw new Error("Only supported on testnet"); +} + +// Xverse has deprecated Testnet3 support, so we default to Signet. Make sure to switch to Signet in Xverse's network settings. +const isXverse = signer instanceof ccc.Xverse.Signer; +const btcTestnetName = isXverse ? "signet" : "testnet"; + +const btcAddress = await signer.getBtcAccount(); +// Fetch UTXOs from mempool.space API +const utxos = (await fetch( + `https://mempool.space/${btcTestnetName}/api/address/${btcAddress}/utxo`, +).then((res) => { + if (!res.ok) { + throw new Error(`Failed to fetch UTXOs: ${res.status} ${res.statusText}`); + } + return res.json(); +})) as { value: number; txid: string; vout: number }[]; + +const DUST_LIMIT = 546; +const FEE_SATS = 200; + +// Select a UTXO above the 546 sat dust threshold +const selectedUtxo = utxos.find((utxo) => utxo.value > DUST_LIMIT + FEE_SATS); +if (!selectedUtxo) { + throw new Error("No UTXO available"); +} + +// Fetch the full transaction to get the scriptpubkey +const btcTx = (await fetch( + `https://mempool.space/${btcTestnetName}/api/tx/${selectedUtxo.txid}`, +).then((res) => { + if (!res.ok) { + throw new Error( + `Failed to fetch transaction: ${res.status} ${res.statusText}`, + ); + } + return res.json(); +})) as { + vout: { + value: number; + scriptpubkey: string; + scriptpubkey_type: string; + }[]; +}; +const vout = btcTx.vout[selectedUtxo.vout]; + +if (!vout || !vout.scriptpubkey) { + throw new Error("Invalid vout data"); +} + +// Build PSBT with the selected UTXO as input +const psbt = new bitcoin.Psbt({ + network: isXverse ? bitcoin.networks.testnet : bitcoin.networks.testnet, +}); +const input: { + hash: string; + index: number; + witnessUtxo: { + script: Uint8Array; + value: bigint; + }; + tapInternalKey?: Uint8Array; +} = { + hash: selectedUtxo.txid, + index: selectedUtxo.vout, + witnessUtxo: { + script: ccc.bytesFrom(vout.scriptpubkey), + value: BigInt(vout.value), + }, +}; + +// Handle Taproot (P2TR) specific input fields +if ( + vout.scriptpubkey_type === "v1_p2tr" || + vout.scriptpubkey_type === "witness_v1_taproot" +) { + const publicKey = await signer.getBtcPublicKey(); + input.tapInternalKey = ccc.bytesFrom(ccc.hexFrom(publicKey)).slice(1); +} + +psbt.addInput(input); + +// Add a single output back to the same address minus a hardcoded 200 sat fee +psbt.addOutput({ + address: btcAddress, + value: BigInt(vout.value) - BigInt(FEE_SATS), +}); + +// Sign and broadcast the transaction +const txId = await signer.signAndBroadcastPsbt(psbt.toHex()); +console.log( + `View transaction: https://mempool.space/${btcTestnetName}/tx/${txId.slice(2)}`, +); diff --git a/packages/playground/src/app/components/Editor.tsx b/packages/playground/src/app/components/Editor.tsx index 4bd227fb7..fc1416217 100644 --- a/packages/playground/src/app/components/Editor.tsx +++ b/packages/playground/src/app/components/Editor.tsx @@ -157,7 +157,7 @@ export function Editor({ }); monaco.languages.typescript.typescriptDefaults.addExtraLib( - "import { ccc } from '@ckb-ccc/core'; export function render(...msgs: unknown[]): Promise; export const signer: ccc.Signer; export const client: ccc.Client;", + "import { ccc } from '@ckb-ccc/core'; import * as bitcoin from 'bitcoinjs-lib'; export { bitcoin }; export function render(...msgs: unknown[]): Promise; export const signer: ccc.Signer; export const client: ccc.Client;", "file:///node_modules/@ckb-ccc/playground/index.d.ts", ); monaco.languages.typescript.typescriptDefaults.addExtraLib( From d6a089757a7491c182abeb080f675be837cbf0d0 Mon Sep 17 00:00:00 2001 From: fgh Date: Fri, 30 Jan 2026 13:37:48 -0500 Subject: [PATCH 43/45] feat(psbt): PSBT publicKey as Hex, instance guards in from() --- packages/core/src/signer/btc/psbt.ts | 12 +++++++++--- packages/examples/src/transferBtc.ts | 3 +-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/core/src/signer/btc/psbt.ts b/packages/core/src/signer/btc/psbt.ts index 69dd6f595..abbc47cd0 100644 --- a/packages/core/src/signer/btc/psbt.ts +++ b/packages/core/src/signer/btc/psbt.ts @@ -1,4 +1,4 @@ -import { HexLike, hexFrom } from "../../hex/index.js"; +import { Hex, HexLike, hexFrom } from "../../hex/index.js"; /** * Options for signing a PSBT (Partially Signed Bitcoin Transaction) @@ -22,6 +22,9 @@ export class SignPsbtOptions { ) {} static from(options?: SignPsbtOptionsLike): SignPsbtOptions { + if (options instanceof SignPsbtOptions) { + return options; + } return new SignPsbtOptions( options?.autoFinalized ?? true, options?.inputsToSign?.map((i) => InputToSign.from(i)) ?? [], @@ -77,16 +80,19 @@ export class InputToSign { public sighashTypes?: number[], public disableTweakSigner?: boolean, public address?: string, - public publicKey?: string, + public publicKey?: Hex, ) {} static from(input: InputToSignLike): InputToSign { + if (input instanceof InputToSign) { + return input; + } return new InputToSign( input.index, input.sighashTypes, input.disableTweakSigner, input.address, - input.publicKey ? hexFrom(input.publicKey).slice(2) : undefined, + input.publicKey ? hexFrom(input.publicKey) : undefined, ); } } diff --git a/packages/examples/src/transferBtc.ts b/packages/examples/src/transferBtc.ts index c7b3b76a5..d40af0e60 100644 --- a/packages/examples/src/transferBtc.ts +++ b/packages/examples/src/transferBtc.ts @@ -85,8 +85,7 @@ if ( vout.scriptpubkey_type === "v1_p2tr" || vout.scriptpubkey_type === "witness_v1_taproot" ) { - const publicKey = await signer.getBtcPublicKey(); - input.tapInternalKey = ccc.bytesFrom(ccc.hexFrom(publicKey)).slice(1); + input.tapInternalKey = ccc.bytesFrom(await signer.getBtcPublicKey()).slice(1); } psbt.addInput(input); From 312bf755e38a36d5366936b15e20af340bc97aa7 Mon Sep 17 00:00:00 2001 From: fgh Date: Fri, 30 Jan 2026 14:48:24 -0500 Subject: [PATCH 44/45] fix: resolve errors after rebase --- packages/core/src/ckb/transaction.ts | 14 -------------- packages/playground/src/app/execute/index.tsx | 4 +--- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 6aecdc470..2cb60e906 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -299,20 +299,6 @@ export class CellOutput extends Entity.Base() { clone(): CellOutput { return new CellOutput(this.capacity, this.lock.clone(), this.type?.clone()); } - - /** - * Clone a CellOutput. - * - * @returns A cloned CellOutput instance. - * - * @example - * ```typescript - * const cellOutput1 = cellOutput0.clone(); - * ``` - */ - clone(): CellOutput { - return new CellOutput(this.capacity, this.lock.clone(), this.type?.clone()); - } } export const CellOutputVec = mol.vector(CellOutput); diff --git a/packages/playground/src/app/execute/index.tsx b/packages/playground/src/app/execute/index.tsx index 39148462e..f913442eb 100644 --- a/packages/playground/src/app/execute/index.tsx +++ b/packages/playground/src/app/execute/index.tsx @@ -1,8 +1,5 @@ import * as ecc from "@bitcoinerlab/secp256k1"; -import * as cccLib from "@ckb-ccc/ccc"; -import * as cccAdvancedLib from "@ckb-ccc/ccc/advanced"; import { ccc } from "@ckb-ccc/connector-react"; -import * as dobRenderLib from "@nervina-labs/dob-render"; import * as bitcoin from "bitcoinjs-lib"; import * as React from "react"; @@ -117,6 +114,7 @@ export async function execute( }, signer, client: signer.client, + bitcoin, }; } From 8be156d5fa05cdf450b92b556aa167e1826506fa Mon Sep 17 00:00:00 2001 From: fgh Date: Fri, 30 Jan 2026 15:02:15 -0500 Subject: [PATCH 45/45] chore: add changeset for psbt feature --- .changeset/cuddly-lands-build.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/cuddly-lands-build.md diff --git a/.changeset/cuddly-lands-build.md b/.changeset/cuddly-lands-build.md new file mode 100644 index 000000000..b3a48766e --- /dev/null +++ b/.changeset/cuddly-lands-build.md @@ -0,0 +1,13 @@ +--- +"@ckb-ccc/core": minor +"@ckb-ccc/joy-id": patch +"@ckb-ccc/okx": patch +"@ckb-ccc/uni-sat": patch +"@ckb-ccc/utxo-global": patch +"@ckb-ccc/xverse": patch +--- + +feat(core): add BTC PSBT signing support + +- Add `SignerBtc.signPsbt()`, `signAndBroadcastPsbt()`, and `broadcastPsbt()` for signing and broadcasting PSBTs +- Add `SignPsbtOptions` and `InputToSign` for configuring PSBT signing