From 92d3a255e594b7250c2af4d8022b1b8d6c4ad4b1 Mon Sep 17 00:00:00 2001
From: Hanssen0 <0@hanssen0.com>
Date: Mon, 29 Jun 2026 15:33:18 +0800
Subject: [PATCH 1/2] feat(coin): new coin package
---
.changeset/early-oranges-lick.md | 7 +
packages/coin/.npmignore | 21 +
packages/coin/.prettierignore | 15 +
packages/coin/README.md | 123 +
packages/coin/eslint.config.mjs | 62 +
.../misc/basedirs/dist.commonjs/package.json | 3 +
packages/coin/misc/basedirs/dist/package.json | 3 +
packages/coin/package.json | 56 +
packages/coin/prettier.config.cjs | 11 +
packages/coin/src/barrel.ts | 2 +
packages/coin/src/coBuild.ts | 76 +
packages/coin/src/coin/coin.test.ts | 2197 +++++++++++++++++
packages/coin/src/coin/coin.ts | 866 +++++++
packages/coin/src/coin/coinInfo.ts | 130 +
packages/coin/src/coin/error.ts | 83 +
packages/coin/src/coin/index.ts | 3 +
packages/coin/src/index.ts | 2 +
packages/coin/tsconfig.json | 25 +
packages/coin/tsdown.config.mts | 41 +
packages/coin/typedoc.json | 6 +
packages/coin/vitest.config.ts | 10 +
packages/shell/package.json | 1 +
packages/shell/src/barrel.ts | 1 +
pnpm-lock.yaml | 218 +-
typedoc.config.mjs | 1 +
vitest.config.mts | 8 +-
26 files changed, 3968 insertions(+), 3 deletions(-)
create mode 100644 .changeset/early-oranges-lick.md
create mode 100644 packages/coin/.npmignore
create mode 100644 packages/coin/.prettierignore
create mode 100644 packages/coin/README.md
create mode 100644 packages/coin/eslint.config.mjs
create mode 100644 packages/coin/misc/basedirs/dist.commonjs/package.json
create mode 100644 packages/coin/misc/basedirs/dist/package.json
create mode 100644 packages/coin/package.json
create mode 100644 packages/coin/prettier.config.cjs
create mode 100644 packages/coin/src/barrel.ts
create mode 100644 packages/coin/src/coBuild.ts
create mode 100644 packages/coin/src/coin/coin.test.ts
create mode 100644 packages/coin/src/coin/coin.ts
create mode 100644 packages/coin/src/coin/coinInfo.ts
create mode 100644 packages/coin/src/coin/error.ts
create mode 100644 packages/coin/src/coin/index.ts
create mode 100644 packages/coin/src/index.ts
create mode 100644 packages/coin/tsconfig.json
create mode 100644 packages/coin/tsdown.config.mts
create mode 100644 packages/coin/typedoc.json
create mode 100644 packages/coin/vitest.config.ts
diff --git a/.changeset/early-oranges-lick.md b/.changeset/early-oranges-lick.md
new file mode 100644
index 000000000..49eaaff79
--- /dev/null
+++ b/.changeset/early-oranges-lick.md
@@ -0,0 +1,7 @@
+---
+"@ckb-ccc/shell": minor
+"@ckb-ccc/coin": minor
+---
+
+feat(coin): new coin package
+
\ No newline at end of file
diff --git a/packages/coin/.npmignore b/packages/coin/.npmignore
new file mode 100644
index 000000000..7a88408aa
--- /dev/null
+++ b/packages/coin/.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/coin/.prettierignore b/packages/coin/.prettierignore
new file mode 100644
index 000000000..aef5d239c
--- /dev/null
+++ b/packages/coin/.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/coin/README.md b/packages/coin/README.md
new file mode 100644
index 000000000..61338097e
--- /dev/null
+++ b/packages/coin/README.md
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+ CCC's Support for Coin
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+## Quick Start
+
+`Coin` from `@ckb-ccc/coin` is a generic helper for on-chain fungible tokens identified by a CKB type script.
+
+### Instantiate
+
+```ts
+import { Coin } from "@ckb-ccc/coin";
+import { ccc } from "@ckb-ccc/core";
+
+// Classic instantiation with explicit script and cellDeps
+const coin = await Coin.new({
+ script: {
+ codeHash: "0x...",
+ hashType: "type",
+ args: "0x...",
+ },
+ client,
+ cellDeps: [{ outPoint: codeOutPoint, depType: "code" }],
+});
+
+// Instantiation via knownScript (e.g., sUDT)
+const sUdt = await Coin.new({
+ knownScript: ccc.KnownScript.SUdt,
+ script: {
+ args: ownerLock.hash(),
+ },
+ client,
+});
+```
+
+### Query balance
+
+```ts
+// Total balance across all cells of the signer
+const balance = await coin.calculateBalance(signer);
+console.log(`Balance: ${balance}`);
+
+// Full info: Balance + CKB capacity + cell count
+const info = await coin.calculateInfo(signer);
+console.log(`Balance: ${info.amount}, Cells: ${info.count}`);
+```
+
+### Send
+
+Build the transaction manually, then use `completeBy` to add Coin inputs and a change output:
+
+```ts
+const coin = await Coin.new({
+ knownScript: ccc.KnownScript.SUdt,
+ script: {
+ args: ownerLock.hash(),
+ },
+ client,
+});
+
+const { script: to } = await signer.getRecommendedAddressObj();
+
+// Build outputs
+const tx = await coin.transfer([{ to, amount: 1000n }]);
+
+// Add Coin inputs + change (change goes back to signer)
+const completedTx = await coin.completeBy(signer, tx);
+
+// Cover CKB capacity and fee
+await completedTx.completeInputsByCapacity(signer);
+await completedTx.completeFeeBy(signer);
+
+const txHash = await signer.sendTransaction(completedTx);
+```
+
+### Change to a specific address
+
+```ts
+const { script: changeLock } = await signer.getRecommendedAddressObj();
+const completedTx = await coin.completeChangeToLock(signer, changeLock, tx);
+```
+
+## Learn More?
+
+Check the [package documentation](https://docs.ckbccc.com/docs/packages/protocol-sdks/coin) and [API reference](https://api.ckbccc.com/classes/_ckb-ccc_coin.coin) for more details.
+
+
diff --git a/packages/coin/eslint.config.mjs b/packages/coin/eslint.config.mjs
new file mode 100644
index 000000000..b6132c277
--- /dev/null
+++ b/packages/coin/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/coin/misc/basedirs/dist.commonjs/package.json b/packages/coin/misc/basedirs/dist.commonjs/package.json
new file mode 100644
index 000000000..5bbefffba
--- /dev/null
+++ b/packages/coin/misc/basedirs/dist.commonjs/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "commonjs"
+}
diff --git a/packages/coin/misc/basedirs/dist/package.json b/packages/coin/misc/basedirs/dist/package.json
new file mode 100644
index 000000000..3dbc1ca59
--- /dev/null
+++ b/packages/coin/misc/basedirs/dist/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}
diff --git a/packages/coin/package.json b/packages/coin/package.json
new file mode 100644
index 000000000..0082f6061
--- /dev/null
+++ b/packages/coin/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "@ckb-ccc/coin",
+ "version": "0.0.0",
+ "description": "Coin",
+ "author": "Hanssen <0@hanssen0.com>",
+ "license": "MIT",
+ "private": false,
+ "homepage": "https://github.com/ckb-devrel/ccc",
+ "repository": {
+ "type": "git",
+ "url": "git://github.com/ckb-devrel/ccc.git"
+ },
+ "sideEffects": false,
+ "main": "./dist.commonjs/index.js",
+ "module": "./dist/index.mjs",
+ "exports": {
+ ".": {
+ "import": "./dist/index.mjs",
+ "require": "./dist.commonjs/index.js"
+ },
+ "./barrel": {
+ "import": "./dist/barrel.mjs",
+ "require": "./dist.commonjs/barrel.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "scripts": {
+ "test": "vitest",
+ "test:ci": "vitest run",
+ "build": "tsdown",
+ "lint": "tsc --noEmit && eslint ./src",
+ "format": "prettier --write . && eslint --fix ./src"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@types/node": "^26.0.0",
+ "eslint": "^10.5.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.6",
+ "prettier": "^3.8.4",
+ "prettier-plugin-organize-imports": "^4.3.0",
+ "tsdown": "^0.22.3",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.61.1",
+ "vitest": "^4.1.9"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "@ckb-ccc/co-build": "workspace:*",
+ "@ckb-ccc/core": "workspace:*"
+ },
+ "packageManager": "pnpm@11.8.0",
+ "types": "./dist.commonjs/index.d.ts"
+}
diff --git a/packages/coin/prettier.config.cjs b/packages/coin/prettier.config.cjs
new file mode 100644
index 000000000..5e1810363
--- /dev/null
+++ b/packages/coin/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/coin/src/barrel.ts b/packages/coin/src/barrel.ts
new file mode 100644
index 000000000..a519fe010
--- /dev/null
+++ b/packages/coin/src/barrel.ts
@@ -0,0 +1,2 @@
+export * from "./coBuild.js";
+export * from "./coin/index.js";
diff --git a/packages/coin/src/coBuild.ts b/packages/coin/src/coBuild.ts
new file mode 100644
index 000000000..06b6a9423
--- /dev/null
+++ b/packages/coin/src/coBuild.ts
@@ -0,0 +1,76 @@
+import { ccc, mol } from "@ckb-ccc/core";
+
+const MintActionCodec = mol.table({
+ amount: mol.Uint128,
+ to: ccc.ScriptOpt,
+});
+
+export type MintActionLike = ccc.EncodableType;
+
+@ccc.codec(MintActionCodec)
+export class MintAction extends ccc.Entity.Base() {
+ public amount: ccc.Num;
+ public to?: ccc.Script;
+
+ constructor({ amount, to }: ccc.DecodedType) {
+ super();
+
+ this.amount = amount;
+ this.to = to;
+ }
+}
+
+const BurnActionCodec = mol.table({
+ amount: mol.Uint128,
+});
+
+export type BurnActionLike = ccc.EncodableType;
+
+@ccc.codec(BurnActionCodec)
+export class BurnAction extends ccc.Entity.Base() {
+ public amount: ccc.Num;
+
+ constructor({ amount }: ccc.DecodedType) {
+ super();
+
+ this.amount = amount;
+ }
+}
+
+const TransferActionCodec = mol.table({
+ amount: mol.Uint128,
+ to: ccc.ScriptOpt,
+});
+
+export type TransferActionLike = ccc.EncodableType;
+
+@ccc.codec(TransferActionCodec)
+export class TransferAction extends ccc.Entity.Base<
+ TransferActionLike,
+ TransferAction
+>() {
+ public amount: ccc.Num;
+ public to?: ccc.Script;
+
+ constructor({ amount, to }: ccc.DecodedType) {
+ super();
+
+ this.amount = amount;
+ this.to = to;
+ }
+}
+
+const CoinActionCodec = mol.union({
+ Mint: MintAction,
+ Burn: BurnAction,
+ Transfer: TransferAction,
+});
+
+export type CoinActionLike = ccc.EncodableType;
+
+@ccc.codec(CoinActionCodec)
+export class CoinAction extends ccc.Entity.BaseUnion<
+ typeof CoinActionCodec,
+ CoinActionLike,
+ CoinAction
+>() {}
diff --git a/packages/coin/src/coin/coin.test.ts b/packages/coin/src/coin/coin.test.ts
new file mode 100644
index 000000000..7b00e83b0
--- /dev/null
+++ b/packages/coin/src/coin/coin.test.ts
@@ -0,0 +1,2197 @@
+import { ccc } from "@ckb-ccc/core";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CoinAction } from "../coBuild.js";
+import { Coin, CoinOptions } from "./coin.js";
+
+class CoinWithoutCapacityCompletion extends Coin {
+ protected override get shouldUseCoinsForCapacity(): boolean {
+ return false;
+ }
+
+ static override async new(
+ options: CoinOptions,
+ ): Promise {
+ return new CoinWithoutCapacityCompletion(
+ await this.resolveOptions(options),
+ );
+ }
+}
+
+let client: ccc.Client;
+let signer: ccc.Signer;
+let lock: ccc.Script;
+let type: ccc.Script;
+let coin: Coin;
+
+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 Coin instance
+ coin = await Coin.new({ script: type, client, cellDeps: [] });
+});
+
+describe("Coin", () => {
+ describe("transformerOnOutput", () => {
+ it("should transform the whole transaction and adopt a returned transaction", async () => {
+ const transformedIndexes: number[] = [];
+ const coinWithTransformer = await Coin.new({
+ script: type,
+ client,
+ cellDeps: [],
+ transformerOnOutput: (tx, outputIndex) => {
+ transformedIndexes.push(outputIndex);
+ return {
+ ...tx,
+ headerDeps: ["0x" + "1".repeat(64)],
+ };
+ },
+ });
+ const original = ccc.Transaction.from({ outputs: [{ lock }] });
+
+ const transformed = await coinWithTransformer.transfer(
+ [{ to: lock, amount: 100n }],
+ original,
+ );
+
+ expect(transformed).not.toBe(original);
+ expect(transformed.outputs.length).toBe(2);
+ expect(transformedIndexes).toEqual([1]);
+ expect(transformed.headerDeps).toEqual(["0x" + "1".repeat(64)]);
+ });
+ });
+
+ describe("completeInputsByAmount", () => {
+ // Mock Coins with amount 100 each (10 total, amount = 1000)
+ let mockCoins: ccc.Cell[];
+
+ beforeEach(async () => {
+ // Create mock Coins after type is initialized
+ mockCoins = 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), // amount: 100
+ }),
+ );
+ });
+
+ beforeEach(() => {
+ // Mock the findCells method to return our mock Coins
+ vi.spyOn(signer, "findCells").mockImplementation(
+ async function* (filter) {
+ if (filter.script && ccc.Script.from(filter.script).eq(type)) {
+ for (const cell of mockCoins) {
+ yield cell;
+ }
+ }
+ },
+ );
+
+ // Mock client.getCell to return the cell data for inputs
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ const cell = mockCoins.find((c) => c.outPoint.eq(outPoint));
+ return cell;
+ });
+ });
+
+ it("should return 0 when no Coin amount is needed", async () => {
+ const { addedCount, tx } = await coin.completeInputsByAmount(signer);
+ expect(addedCount).toBe(0);
+ expect(tx.outputs).toEqual([]);
+ });
+
+ it("should collect exactly the required Coin amount", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [
+ {
+ lock,
+ type,
+ },
+ ],
+ outputsData: [ccc.numLeToBytes(150, 16)], // Need amount of 150
+ });
+
+ const { addedCount } = await coin.completeInputsByAmount(signer, tx);
+
+ // Should add 2 Coins (total amount: 200) to have at least 2 inputs
+ expect(addedCount).toBe(2);
+ expect(tx.inputs.length).toBe(2);
+
+ // Verify the inputs are Coins
+ const inputAmount = await coin.getInputsAmount(tx);
+ expect(inputAmount).toBe(ccc.numFrom(200));
+ });
+
+ it("should not use Coins to supplement capacity when disabled", async () => {
+ const coinWithoutCapacityCompletion =
+ await CoinWithoutCapacityCompletion.new({
+ script: type,
+ client,
+ cellDeps: [],
+ });
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(50, 16)],
+ });
+
+ const { addedCount } =
+ await coinWithoutCapacityCompletion.completeInputsByAmount(
+ signer,
+ tx,
+ ccc.Zero,
+ ccc.fixedPointFrom(1000),
+ );
+
+ expect(addedCount).toBe(1);
+ expect(await coinWithoutCapacityCompletion.getInputsAmount(tx)).toBe(
+ ccc.numFrom(100),
+ );
+ });
+
+ 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 amount of exactly 100
+ });
+
+ const { addedCount } = await coin.completeInputsByAmount(signer, tx);
+
+ // Should add only 1 cell since it matches exactly
+ expect(addedCount).toBe(1);
+ expect(tx.inputs.length).toBe(1);
+
+ const inputAmount = await coin.getInputsAmount(tx);
+ expect(inputAmount).toBe(ccc.numFrom(100));
+ });
+
+ it("should handle amountTweak parameter", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [
+ {
+ lock,
+ type,
+ },
+ ],
+ outputsData: [ccc.numLeToBytes(100, 16)], // Need amount of 100
+ });
+
+ // Add 50 extra to amount requirement via amountTweak
+ const { addedCount } = await coin.completeInputsByAmount(signer, tx, 50);
+
+ // Should add 2 Coins to cover total amount requirement of 150
+ expect(addedCount).toBe(2);
+ expect(tx.inputs.length).toBe(2);
+
+ const inputAmount = await coin.getInputsAmount(tx);
+ expect(inputAmount).toBe(ccc.numFrom(200));
+ });
+
+ it("should return 0 when existing inputs already satisfy the requirement", async () => {
+ const tx = ccc.Transaction.from({
+ inputs: [
+ {
+ previousOutput: mockCoins[0].outPoint,
+ },
+ {
+ previousOutput: mockCoins[1].outPoint,
+ },
+ ],
+ outputs: [
+ {
+ lock,
+ type,
+ },
+ ],
+ outputsData: [ccc.numLeToBytes(150, 16)], // Need amount of 150, already have 200
+ });
+
+ const { addedCount } = await coin.completeInputsByAmount(signer, tx);
+
+ // 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 Coin amount available", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [
+ {
+ lock,
+ type,
+ },
+ ],
+ outputsData: [ccc.numLeToBytes(1500, 16)], // Need amount of 1500, only have 1000 available
+ });
+
+ await expect(coin.completeInputsByAmount(signer, tx)).rejects.toThrow(
+ "Insufficient coin, need 500 extra coin",
+ );
+ });
+
+ it("should handle multiple Coin outputs correctly", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [
+ {
+ lock,
+ type,
+ },
+ {
+ lock,
+ type,
+ },
+ ],
+ outputsData: [
+ ccc.numLeToBytes(100, 16), // First output: amount 100
+ ccc.numLeToBytes(150, 16), // Second output: amount 150
+ ], // Total amount needed: 250
+ });
+
+ const { addedCount } = await coin.completeInputsByAmount(signer, tx);
+
+ // Should add 3 Coins to cover amount requirement of 250 (total amount: 300)
+ expect(addedCount).toBe(3);
+ expect(tx.inputs.length).toBe(3);
+
+ const inputAmount = await coin.getInputsAmount(tx);
+ expect(inputAmount).toBe(ccc.numFrom(300));
+
+ const outputAmount = await coin.getOutputsAmount(tx);
+ expect(outputAmount).toBe(ccc.numFrom(250));
+ });
+
+ it("should skip Coins already used as inputs", async () => {
+ // Pre-add one of the mock Coins as input
+ const tx = ccc.Transaction.from({
+ inputs: [
+ {
+ previousOutput: mockCoins[0].outPoint,
+ },
+ ],
+ outputs: [
+ {
+ lock,
+ type,
+ },
+ ],
+ outputsData: [ccc.numLeToBytes(150, 16)], // Need amount of 150, already have 100
+ });
+
+ const { addedCount } = await coin.completeInputsByAmount(signer, tx);
+
+ // Should add 1 more Coin (since we already have 1 input with amount 100)
+ expect(addedCount).toBe(1);
+ expect(tx.inputs.length).toBe(2);
+
+ const inputAmount = await coin.getInputsAmount(tx);
+ expect(inputAmount).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 amount of 50 (less than one Coin)
+ });
+
+ const { addedCount } = await coin.completeInputsByAmount(signer, tx);
+
+ // Coin completeInputsByAmount adds minimum inputs needed
+ expect(addedCount).toBe(1);
+ expect(tx.inputs.length).toBe(1);
+
+ const inputAmount = await coin.getInputsAmount(tx);
+ expect(inputAmount).toBe(ccc.numFrom(100));
+ });
+ });
+
+ describe("completeInputsAll", () => {
+ // Mock Coins with amount 100 each (5 total, amount = 500)
+ let mockCoins: ccc.Cell[];
+
+ beforeEach(async () => {
+ // Create mock Coins after type is initialized
+ mockCoins = 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), // amount: 100 each
+ }),
+ );
+ });
+
+ beforeEach(() => {
+ // Mock the findCells method to return our mock Coins
+ vi.spyOn(signer, "findCells").mockImplementation(
+ async function* (filter) {
+ if (filter.script && ccc.Script.from(filter.script).eq(type)) {
+ for (const cell of mockCoins) {
+ yield cell;
+ }
+ }
+ },
+ );
+
+ // Mock client.getCell to return the cell data for inputs
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ const cell = mockCoins.find((c) => c.outPoint.eq(outPoint));
+ return cell;
+ });
+ });
+
+ it("should add all available Coins to empty transaction", async () => {
+ const { tx: completedTx, addedCount } =
+ await coin.completeInputsAll(signer);
+
+ // Should add all 5 available Coins
+ expect(addedCount).toBe(5);
+ expect(completedTx.inputs.length).toBe(5);
+
+ // Verify total Coin amount is 500 (5 Coins, amount 100 each)
+ const inputAmount = await coin.getInputsAmount(completedTx);
+ expect(inputAmount).toBe(ccc.numFrom(500));
+
+ // Verify all Coins were added by checking outpoints
+ const addedOutpoints = completedTx.inputs.map(
+ (input) => input.previousOutput,
+ );
+ for (const cell of mockCoins) {
+ expect(addedOutpoints.some((op) => op.eq(cell.outPoint))).toBe(true);
+ }
+ });
+
+ it("should add all available Coins to transaction with outputs", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [
+ { lock, type },
+ { lock, type },
+ ],
+ outputsData: [
+ ccc.numLeToBytes(150, 16), // amount: 150
+ ccc.numLeToBytes(200, 16), // amount: 200
+ ], // Total amount needed: 350
+ });
+
+ const { tx: completedTx, addedCount } = await coin.completeInputsAll(
+ signer,
+ tx,
+ );
+
+ // Should add all 5 available Coins regardless of output requirements
+ expect(addedCount).toBe(5);
+ expect(completedTx.inputs.length).toBe(5);
+
+ // Verify total Coin amount is 500 (all available)
+ const inputAmount = await coin.getInputsAmount(completedTx);
+ expect(inputAmount).toBe(ccc.numFrom(500));
+
+ // Verify output amount is still 350
+ const outputAmount = await coin.getOutputsAmount(completedTx);
+ expect(outputAmount).toBe(ccc.numFrom(350));
+
+ // Should have excess amount of 150 (500 - 350)
+ const amountBurned = await coin.getAmountBurned(completedTx);
+ expect(amountBurned).toBe(ccc.numFrom(150));
+ });
+
+ it("should skip Coins already used as inputs", async () => {
+ // Pre-add 2 of the mock Coins as inputs
+ const tx = ccc.Transaction.from({
+ inputs: [
+ { previousOutput: mockCoins[0].outPoint },
+ { previousOutput: mockCoins[1].outPoint },
+ ],
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)],
+ });
+
+ const { tx: completedTx, addedCount } = await coin.completeInputsAll(
+ signer,
+ tx,
+ );
+
+ // Should add the remaining 3 Coins (Coins 2, 3, 4)
+ expect(addedCount).toBe(3);
+ expect(completedTx.inputs.length).toBe(5); // 2 existing + 3 added
+
+ // Verify total Coin amount is still 500 (all 5 Coins)
+ const inputAmount = await coin.getInputsAmount(completedTx);
+ expect(inputAmount).toBe(ccc.numFrom(500));
+ });
+
+ it("should return 0 when all Coins are already used as inputs", async () => {
+ // Pre-add all mock Coins as inputs
+ const tx = ccc.Transaction.from({
+ inputs: mockCoins.map((cell) => ({ previousOutput: cell.outPoint })),
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)],
+ });
+
+ const { tx: completedTx, addedCount } = await coin.completeInputsAll(
+ signer,
+ tx,
+ );
+
+ // Should not add any new inputs
+ expect(addedCount).toBe(0);
+ expect(completedTx.inputs.length).toBe(5); // Same as before
+
+ // Verify total Coin amount is still 500
+ const inputAmount = await coin.getInputsAmount(completedTx);
+ expect(inputAmount).toBe(ccc.numFrom(500));
+ });
+
+ it("should handle transaction with no Coin outputs", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [
+ { lock }, // Non-Coin output
+ ],
+ outputsData: ["0x"],
+ });
+
+ const { tx: completedTx, addedCount } = await coin.completeInputsAll(
+ signer,
+ tx,
+ );
+
+ // Should add all 5 Coins even though no Coin outputs
+ expect(addedCount).toBe(5);
+ expect(completedTx.inputs.length).toBe(5);
+
+ // Total amount of 500 will be "burned" since no Coin outputs
+ const amountBurned = await coin.getAmountBurned(completedTx);
+ expect(amountBurned).toBe(ccc.numFrom(500));
+ });
+
+ it("should work with mixed input types", async () => {
+ // Create a non-Coin cell
+ const nonCoin = 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-Coin cell as input
+ const tx = ccc.Transaction.from({
+ inputs: [{ previousOutput: nonCoin.outPoint }],
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)],
+ });
+
+ // Mock getCell to handle both Coin and non-Coins
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ const outPointObj = ccc.OutPoint.from(outPoint);
+ if (outPointObj.eq(nonCoin.outPoint)) {
+ return nonCoin;
+ }
+ return mockCoins.find((c) => c.outPoint.eq(outPointObj));
+ });
+
+ const { tx: completedTx, addedCount } = await coin.completeInputsAll(
+ signer,
+ tx,
+ );
+
+ // Should add all 5 Coins
+ expect(addedCount).toBe(5);
+ expect(completedTx.inputs.length).toBe(6); // 1 non-Coin + 5 Coin
+
+ // Verify only Coin amount is counted
+ const inputAmount = await coin.getInputsAmount(completedTx);
+ expect(inputAmount).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 Coins
+ });
+
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)],
+ });
+
+ const { tx: completedTx, addedCount } = await coin.completeInputsAll(
+ signer,
+ tx,
+ );
+
+ // Should not add any inputs
+ expect(addedCount).toBe(0);
+ expect(completedTx.inputs.length).toBe(0);
+
+ // Coin amount should be 0
+ const inputAmount = await coin.getInputsAmount(completedTx);
+ expect(inputAmount).toBe(ccc.numFrom(0));
+ });
+ });
+
+ describe("getInputsAmount", () => {
+ it("should calculate total Coin amount 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), // amount: 100
+ }),
+ ccc.Cell.from({
+ outPoint: { txHash: "0x" + "1".repeat(64), index: 0 },
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: ccc.numLeToBytes(200, 16), // amount: 200
+ }),
+ ];
+
+ 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 amount = await coin.getInputsAmount(tx);
+ expect(amount).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), // amount: 100
+ }),
+ 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 amount = await coin.getInputsAmount(tx);
+ expect(amount).toBe(ccc.numFrom(100)); // Only the Coin
+ });
+ });
+
+ describe("getOutputsAmount", () => {
+ it("should calculate total Coin amount from outputs", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [
+ { lock, type },
+ { lock, type },
+ { lock }, // No type script
+ ],
+ outputsData: [
+ ccc.numLeToBytes(100, 16), // amount: 100
+ ccc.numLeToBytes(200, 16), // amount: 200
+ "0x", // Not Coin
+ ],
+ });
+
+ const amount = await coin.getOutputsAmount(tx);
+ expect(amount).toBe(ccc.numFrom(300)); // 100 + 200, ignoring non-Coin output
+ });
+
+ it("should return 0 when no Coin outputs", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock }], // No type script
+ outputsData: ["0x"],
+ });
+
+ const amount = await coin.getOutputsAmount(tx);
+ expect(amount).toBe(ccc.numFrom(0));
+ });
+ });
+
+ describe("completeChangeToLock", () => {
+ let mockCoins: ccc.Cell[];
+
+ beforeEach(() => {
+ mockCoins = 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), // amount: 100 each
+ }),
+ );
+
+ vi.spyOn(signer, "findCells").mockImplementation(
+ async function* (filter) {
+ if (filter.script && ccc.Script.from(filter.script).eq(type)) {
+ for (const cell of mockCoins) {
+ yield cell;
+ }
+ }
+ },
+ );
+
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ return mockCoins.find((c) => c.outPoint.eq(outPoint));
+ });
+ });
+
+ it("should add change output when there's excess Coin amount", 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 amount of 150
+ });
+
+ const completedTx = await coin.completeChangeToLock(
+ signer,
+ changeLock,
+ tx,
+ );
+
+ // 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 have amount of 50 (input 200 - output 150)
+ const changeAmount = await coin.amountFrom(completedTx.getOutput(1)!);
+ expect(changeAmount).toBe(ccc.numFrom(50));
+ });
+
+ it("returns the completed transaction directly", async () => {
+ const changeLock = ccc.Script.from({
+ codeHash: "0x" + "9".repeat(64),
+ hashType: "type",
+ args: "0x1234",
+ });
+
+ // Case 1: change output is created
+ const tx1 = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(150, 16)],
+ });
+ const res1 = await coin.completeChangeToLock(signer, changeLock, tx1);
+ expect(res1.outputs.length).toBe(2);
+ expect(res1.inputs.length).toBeGreaterThan(0);
+
+ // Case 2: no excess amount — no change output
+ const tx2 = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(200, 16)],
+ });
+ const res2 = await coin.completeChangeToLock(signer, changeLock, tx2);
+ expect(res2.outputs.length).toBe(1);
+ });
+
+ it("transformer: appends extra bytes whose count equals amount / 100", async () => {
+ const changeLock = ccc.Script.from({
+ codeHash: "0x" + "9".repeat(64),
+ hashType: "type",
+ args: "0x1234",
+ });
+
+ const coinWithTransformer = await Coin.new({
+ script: type,
+ client,
+ cellDeps: [],
+ transformerOnOutput: async (tx, outputIndex) => {
+ const cell = tx.getOutput(outputIndex)!;
+ const amount = await coin.amountFrom(cell);
+ const extraLen = Number(amount / 100n);
+ const extra = new Uint8Array(extraLen).fill(0xff);
+ tx.setOutput(outputIndex, {
+ cellOutput: cell.cellOutput,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(ccc.bytesFrom(cell.outputData), extra),
+ ),
+ });
+ return tx;
+ },
+ });
+
+ // output amount = 0, inputs will cover 100 (1 cell), change = 100 → extra = 1 byte
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(0, 16)],
+ });
+
+ const completedTx = await coinWithTransformer.completeChangeToLock(
+ signer,
+ changeLock,
+ tx,
+ );
+
+ // change output should have been added
+ expect(completedTx.outputs.length).toBe(2);
+
+ const changeAmount = await coin.amountFrom(completedTx.getOutput(1)!);
+ const changeData = ccc.bytesFrom(completedTx.outputsData[1]);
+ const expectedExtraLen = Number(changeAmount / 100n);
+
+ // outputData = 16 bytes amount + extra bytes
+ expect(changeData.length).toBe(16 + expectedExtraLen);
+ // all extra bytes are 0xff
+ for (let i = 16; i < changeData.length; i++) {
+ expect(changeData[i]).toBe(0xff);
+ }
+
+ // capacity must be enough to cover the enlarged data
+ const minCapacity = ccc.CellOutput.from(
+ completedTx.outputs[1].clone(),
+ completedTx.outputsData[1],
+ ).capacity;
+ expect(completedTx.outputs[1].capacity).toBeGreaterThanOrEqual(
+ minCapacity,
+ );
+ });
+
+ it("transformer: oversized capacity set by transformer is preserved, not shrunk", 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(0, 16)],
+ });
+
+ // transformer sets capacity to 1000 CKB, far above the minimum
+ const bigCapacity = ccc.fixedPointFrom(1000);
+
+ const coinWithTransformer = await Coin.new({
+ script: type,
+ client,
+ cellDeps: [],
+ transformerOnOutput: (tx, outputIndex) => {
+ const cell = tx.getOutput(outputIndex)!;
+ tx.setOutput(outputIndex, {
+ cellOutput: { ...cell.cellOutput, capacity: bigCapacity },
+ outputData: cell.outputData,
+ });
+ return tx;
+ },
+ });
+
+ const completedTx = await coinWithTransformer.completeChangeToLock(
+ signer,
+ changeLock,
+ tx,
+ );
+
+ expect(completedTx.outputs.length).toBe(2);
+ // capacity must not be reduced below what transformer set
+ expect(completedTx.outputs[1].capacity).toBeGreaterThanOrEqual(
+ bigCapacity,
+ );
+ });
+ });
+
+ describe("completeBy", () => {
+ it("should use signer's recommended address for change", async () => {
+ const mockCoins = 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 mockCoins) {
+ yield cell;
+ }
+ }
+ },
+ );
+
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ return mockCoins.find((c) => c.outPoint.eq(outPoint));
+ });
+
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(150, 16)],
+ });
+
+ const completedTx = await coin.completeBy(signer, tx);
+
+ // 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 mockCoins: ccc.Cell[];
+
+ beforeEach(() => {
+ // Create mock Coins with different capacity values
+ mockCoins = [
+ // Cell 0: amount 100, 142 CKB capacity (minimum)
+ 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: amount 100, 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: amount 100, 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 mockCoins) {
+ yield cell;
+ }
+ }
+ },
+ );
+
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ return mockCoins.find((c) => c.outPoint.eq(outPoint));
+ });
+ });
+
+ it("should add extra Coins 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 amount of 50 (less than one Coin)
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(50, 16)],
+ });
+
+ const completedTx = await coin.completeChangeToLock(
+ signer,
+ changeLock,
+ tx,
+ );
+
+ // Should have original output + change output
+ expect(completedTx.outputs.length).toBe(2);
+
+ // Verify inputs were added to cover both Coin amount and capacity requirements
+ expect(completedTx.inputs.length).toBe(2);
+
+ // Check that change output has correct Coin amount (should be input - 50)
+ const changeAmount = await coin.amountFrom(completedTx.getOutput(1)!);
+ const inputAmount = await coin.getInputsAmount(completedTx);
+ expect(changeAmount).toBe(inputAmount - ccc.numFrom(50));
+
+ // Verify change output has correct type script
+ 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 completeInputsByAmount", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(50, 16)], // Need amount of 50
+ });
+
+ // Add extra capacity requirement via capacityTweak that's reasonable
+ const extraCapacityNeeded = ccc.fixedPointFrom(1000); // Reasonable capacity requirement
+ const { addedCount } = await coin.completeInputsByAmount(
+ signer,
+ tx,
+ ccc.Zero, // No extra Coin amount needed
+ extraCapacityNeeded, // Extra capacity needed
+ );
+
+ // Should add Coins to cover the capacity requirement
+ expect(addedCount).toBeGreaterThan(2);
+
+ // Should have added at least one cell with capacity
+ expect(await coin.getInputsAmount(tx)).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 amount of 50, change will have amount of 50
+ });
+
+ // Track the calls to completeInputsByAmount to verify two-phase completion
+ const completeInputsSpy = vi.spyOn(coin, "completeInputsByAmount");
+
+ const completedTx = await coin.completeChangeToLock(
+ signer,
+ changeLock,
+ tx,
+ );
+
+ // Should have called completeInputsByAmount twice:
+ // 1. First call: initial Coin amount completion
+ // 2. Second call: with extraCapacity for change output
+ expect(completeInputsSpy).toHaveBeenCalledTimes(2);
+
+ // Verify the second call included extraCapacity parameter
+ const secondCall = completeInputsSpy.mock.calls[1];
+ expect(secondCall[2]).toBe(ccc.Zero); // amountTweak 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 = await coin.amountFrom(completedTx.getOutput(1)!);
+ expect(changeAmount).toBe(
+ (await coin.getInputsAmount(completedTx)) - ccc.numFrom(50),
+ ); // 100 input - 50 output = 50 change
+
+ completeInputsSpy.mockRestore();
+ });
+
+ it("should handle completeChangeToOutput correctly", async () => {
+ // Create a transaction with an existing Coin 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 coin.completeChangeToOutput(signer, 0, tx); // 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 = await coin.amountFrom(completedTx.getOutput(0)!);
+ const inputAmount = await coin.getInputsAmount(completedTx);
+
+ // Change output should have: original amount + excess from inputs
+ // Since we only have one output, all input amount should go to it
+ expect(changeAmount).toBe(inputAmount);
+ expect(changeAmount).toBeGreaterThan(ccc.numFrom(50)); // More than the original amount
+ });
+
+ it("should throw error when change output is not a Coin cell", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock }], // No type script - not a Coin
+ outputsData: ["0x"],
+ });
+
+ await expect(coin.completeChangeToOutput(signer, 0, tx)).rejects.toThrow(
+ "Change output must be a Coin",
+ );
+ });
+
+ it("should throw error when change output index does not exist", async () => {
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(50, 16)],
+ });
+
+ await expect(coin.completeChangeToOutput(signer, 5, tx)).rejects.toThrow(
+ "Output at index 5 does not exist",
+ );
+ });
+
+ it("completeChangeToOutput transformer: appends extra bytes whose count equals amount / 100", async () => {
+ // Use a coin with 200 CKB capacity so there is 58 CKB surplus after covering the
+ // 142 CKB minimum output. The transformer appends 1 extra byte (= 1 CKB delta).
+ // With the correct delta calculation the surplus covers the delta → only 1 input.
+ // With a buggy full-capacity tweak (142 CKB) the surplus would be insufficient and
+ // a second input would be pulled in unnecessarily.
+ const highCapCoin = ccc.Cell.from({
+ outPoint: { txHash: "0x" + "a".repeat(64), index: 0 },
+ cellOutput: { capacity: ccc.fixedPointFrom(200), lock, type },
+ outputData: ccc.numLeToBytes(100, 16),
+ });
+ vi.spyOn(signer, "findCells").mockImplementationOnce(async function* () {
+ yield highCapCoin;
+ });
+ vi.spyOn(client, "getCell").mockImplementationOnce(
+ async () => highCapCoin,
+ );
+
+ const coinWithTransformer = await Coin.new({
+ script: type,
+ client,
+ cellDeps: [],
+ transformerOnOutput: async (tx, outputIndex) => {
+ const cell = tx.getOutput(outputIndex)!;
+ const amount = await coin.amountFrom(cell);
+ const extra = new Uint8Array(Number(amount / 100n)).fill(0xee);
+ tx.setOutput(outputIndex, {
+ cellOutput: cell.cellOutput,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(ccc.bytesFrom(cell.outputData), extra),
+ ),
+ });
+ return tx;
+ },
+ });
+
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(0, 16)],
+ });
+
+ const completedTx = await coinWithTransformer.completeChangeToOutput(
+ signer,
+ 0,
+ tx,
+ );
+
+ const changeAmount = await coin.amountFrom(completedTx.getOutput(0)!);
+ const changeData = ccc.bytesFrom(completedTx.outputsData[0]);
+ const expectedExtraLen = Number(changeAmount / 100n);
+
+ expect(changeData.length).toBe(16 + expectedExtraLen);
+ for (let i = 16; i < changeData.length; i++) {
+ expect(changeData[i]).toBe(0xee);
+ }
+
+ const minCapacity = ccc.CellOutput.from(
+ completedTx.outputs[0].clone(),
+ completedTx.outputsData[0],
+ ).capacity;
+ expect(completedTx.outputs[0].capacity).toBeGreaterThanOrEqual(
+ minCapacity,
+ );
+
+ // The 200 CKB coin input provides 58 CKB surplus over the 142 CKB output minimum.
+ // The transformer's 1-byte increase is only 1 CKB delta, well within the surplus,
+ // so exactly 1 coin input should suffice.
+ expect(completedTx.inputs.length).toBe(1);
+ });
+
+ it("completeChangeToOutput: output capacity covers data after transformer enlarges it", async () => {
+ // Regression test: the change callback reads from the live tx on each call (not a
+ // stale closure capture), so the capacity delta is computed correctly even when
+ // CellOutput.from internally mutates the same CellOutput instance.
+ //
+ // A transformer that appends 100 bytes forces an ~1 CKB capacity increase.
+ // The final output's capacity must be >= the minimum required by the enlarged data.
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(0, 16)],
+ });
+
+ const coinWithTransformer = await Coin.new({
+ script: type,
+ client,
+ cellDeps: [],
+ transformerOnOutput: async (tx, outputIndex) => {
+ const cell = tx.getOutput(outputIndex)!;
+ tx.setOutput(outputIndex, {
+ cellOutput: cell.cellOutput,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(
+ ccc.bytesFrom(cell.outputData),
+ new Uint8Array(100).fill(0xab),
+ ),
+ ),
+ });
+ return tx;
+ },
+ });
+
+ const completedTx = await coinWithTransformer.completeChangeToOutput(
+ signer,
+ 0,
+ tx,
+ );
+
+ const minCapacity = ccc.CellOutput.from(
+ completedTx.outputs[0].clone(),
+ completedTx.outputsData[0],
+ ).capacity;
+
+ expect(completedTx.outputs[0].capacity).toBeGreaterThanOrEqual(
+ minCapacity,
+ );
+ // The appended 100 bytes must actually be present
+ expect(ccc.bytesFrom(completedTx.outputsData[0]).length).toBe(16 + 100);
+ });
+
+ it("should handle capacity calculation when transaction has non-Coin inputs with high capacity", async () => {
+ // Create a non-Coin cell with very high capacity
+ const nonCoin = 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-Coin input
+ const tx = ccc.Transaction.from({
+ inputs: [
+ { previousOutput: nonCoin.outPoint }, // Pre-existing non-Coin input
+ ],
+ outputs: [
+ { lock, type }, // Coin output with amount of 50
+ ],
+ outputsData: [
+ ccc.numLeToBytes(50, 16), // amount: 50
+ ],
+ });
+
+ // Mock getCell to return both Coin and non-Coins
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ const outPointObj = ccc.OutPoint.from(outPoint);
+ if (outPointObj.eq(nonCoin.outPoint)) {
+ return nonCoin;
+ }
+ return mockCoins.find((c) => c.outPoint.eq(outPointObj));
+ });
+
+ const resultTx = await coin.completeBy(signer, tx);
+
+ // Should add exactly 2 Coins to satisfy amount of 50 & extra occupation from the change output
+ expect(resultTx.inputs.length).toBe(3); // 1 non-Coin + 2 Coin
+
+ // Verify Coin amount is satisfied
+ const inputAmount = await coin.getInputsAmount(resultTx);
+ expect(inputAmount).toBe(ccc.numFrom(200));
+ });
+ });
+
+ describe("infoFrom", () => {
+ let validCoin1: ccc.CellAny;
+ let validCoin2: ccc.CellAny;
+ let nonCoin: ccc.CellAny;
+ let otherCoin: ccc.CellAny;
+
+ beforeEach(async () => {
+ validCoin1 = ccc.CellAny.from({
+ cellOutput: {
+ capacity: ccc.fixedPointFrom(142),
+ lock,
+ type,
+ },
+ outputData: ccc.numLeToBytes(100, 16), // amount: 100
+ });
+
+ validCoin2 = ccc.CellAny.from({
+ cellOutput: {
+ capacity: ccc.fixedPointFrom(200),
+ lock,
+ type,
+ },
+ outputData: ccc.numLeToBytes(250, 16), // amount: 250
+ });
+
+ nonCoin = ccc.CellAny.from({
+ cellOutput: {
+ capacity: ccc.fixedPointFrom(500),
+ lock,
+ },
+ outputData: "0x",
+ });
+
+ const otherCoinScript = await ccc.Script.fromKnownScript(
+ client,
+ ccc.KnownScript.XUdt,
+ "0x" + "1".repeat(64),
+ );
+ otherCoin = ccc.CellAny.from({
+ cellOutput: {
+ capacity: ccc.fixedPointFrom(142),
+ lock,
+ type: otherCoinScript,
+ },
+ outputData: ccc.numLeToBytes(1000, 16), // amount: 1000 (other Coin)
+ });
+ });
+
+ it("should return zero for an empty list", async () => {
+ const info = await coin.infoFrom([]);
+ expect(info.amount).toBe(ccc.Zero);
+ expect(info.capacity).toBe(ccc.Zero);
+ expect(info.count).toBe(0);
+ });
+
+ it("should correctly calculate info for a list of valid Coins", async () => {
+ const info = await coin.infoFrom([validCoin1, validCoin2]);
+ expect(info.amount).toBe(ccc.numFrom(350)); // 100 + 250
+ expect(info.capacity).toBe(ccc.fixedPointFrom(342)); // 142 + 200
+ expect(info.count).toBe(2);
+ });
+
+ it("should ignore non-Coins and Coins of other types", async () => {
+ const info = await coin.infoFrom([validCoin1, nonCoin, otherCoin]);
+ expect(info.amount).toBe(ccc.numFrom(100));
+ expect(info.capacity).toBe(ccc.fixedPointFrom(142));
+ expect(info.count).toBe(1);
+ });
+
+ it("should accept a single cell (not an array)", async () => {
+ const info = await coin.infoFrom(validCoin1);
+ expect(info.amount).toBe(ccc.numFrom(100));
+ expect(info.count).toBe(1);
+ });
+
+ it("should accept an async iterable", async () => {
+ async function* gen() {
+ yield validCoin1;
+ yield validCoin2;
+ }
+ const info = await coin.infoFrom(gen());
+ expect(info.amount).toBe(ccc.numFrom(350));
+ expect(info.count).toBe(2);
+ });
+
+ it("should accumulate onto an initial acc value", async () => {
+ const info = await coin.infoFrom([validCoin1], {
+ amount: ccc.numFrom(500),
+ capacity: ccc.fixedPointFrom(10),
+ count: 3,
+ });
+ expect(info.amount).toBe(ccc.numFrom(600)); // 500 + 100
+ expect(info.capacity).toBe(ccc.fixedPointFrom(152)); // 10 + 142
+ expect(info.count).toBe(4); // 3 + 1
+ });
+
+ it("should exclude a cell with correct type but fewer than 16 bytes of data", async () => {
+ const shortDataCell = ccc.CellAny.from({
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: "0x0102030405060708090a0b0c0d0e0f", // only 15 bytes
+ });
+ const info = await coin.infoFrom([shortDataCell]);
+ expect(info.count).toBe(0);
+ expect(info.amount).toBe(ccc.Zero);
+ });
+ });
+
+ describe("isCoin", () => {
+ it("should return true for a valid Coin cell", async () => {
+ const cell = ccc.CellAny.from({
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: ccc.numLeToBytes(100, 16),
+ });
+ expect(await coin.isCoin(cell)).toBe(true);
+ });
+
+ it("should return false when cell has no type script", async () => {
+ const cell = ccc.CellAny.from({
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock },
+ outputData: ccc.numLeToBytes(100, 16),
+ });
+ expect(await coin.isCoin(cell)).toBe(false);
+ });
+
+ it("should return false when type script does not match", async () => {
+ const otherType = ccc.Script.from({
+ codeHash: "0x" + "ab".repeat(32),
+ hashType: "type",
+ args: "0x",
+ });
+ const cell = ccc.CellAny.from({
+ cellOutput: {
+ capacity: ccc.fixedPointFrom(142),
+ lock,
+ type: otherType,
+ },
+ outputData: ccc.numLeToBytes(100, 16),
+ });
+ expect(await coin.isCoin(cell)).toBe(false);
+ });
+
+ it("should return false when outputData is fewer than 16 bytes", async () => {
+ const cell = ccc.CellAny.from({
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: "0x0102030405060708090a0b0c0d0e0f", // 15 bytes
+ });
+ expect(await coin.isCoin(cell)).toBe(false);
+ });
+ });
+
+ describe("amountFromUnsafe", () => {
+ it("should decode a 16-byte little-endian uint128", () => {
+ const data = ccc.numLeToBytes(12345n, 16);
+ expect(Coin.amountFromUnsafe(data)).toBe(ccc.numFrom(12345n));
+ });
+
+ it("should return 0 when data is fewer than 16 bytes", () => {
+ expect(Coin.amountFromUnsafe("0x010203")).toBe(ccc.Zero);
+ expect(Coin.amountFromUnsafe("0x")).toBe(ccc.Zero);
+ });
+
+ it("should use only the first 16 bytes when data is longer", () => {
+ // First 16 bytes encode 100, remaining bytes are arbitrary
+ const first16 = ccc.bytesFrom(ccc.numLeToBytes(100n, 16));
+ const extra = new Uint8Array([0xff, 0xff]);
+ const combined = ccc.hexFrom(new Uint8Array([...first16, ...extra]));
+ expect(Coin.amountFromUnsafe(combined)).toBe(ccc.numFrom(100n));
+ });
+ });
+
+ describe("getAmountBurned / getInfoBurned", () => {
+ it("should return positive value when inputs exceed outputs (tokens burned)", async () => {
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ return ccc.Cell.from({
+ outPoint: ccc.OutPoint.from(outPoint),
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: ccc.numLeToBytes(300, 16), // amount: 300
+ });
+ });
+
+ const tx = ccc.Transaction.from({
+ inputs: [
+ { previousOutput: { txHash: "0x" + "0".repeat(64), index: 0 } },
+ ],
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)], // amount: 100
+ });
+
+ const burned = await coin.getAmountBurned(tx);
+ expect(burned).toBe(ccc.numFrom(200)); // 300 - 100
+ });
+
+ it("should return 0 when inputs equal outputs (balanced)", async () => {
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ return ccc.Cell.from({
+ outPoint: ccc.OutPoint.from(outPoint),
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: ccc.numLeToBytes(100, 16),
+ });
+ });
+
+ const tx = ccc.Transaction.from({
+ inputs: [
+ { previousOutput: { txHash: "0x" + "0".repeat(64), index: 0 } },
+ ],
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)],
+ });
+
+ expect(await coin.getAmountBurned(tx)).toBe(ccc.Zero);
+ });
+
+ it("should return negative value when outputs exceed inputs (minting)", async () => {
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ return ccc.Cell.from({
+ outPoint: ccc.OutPoint.from(outPoint),
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: ccc.numLeToBytes(100, 16),
+ });
+ });
+
+ const tx = ccc.Transaction.from({
+ inputs: [
+ { previousOutput: { txHash: "0x" + "0".repeat(64), index: 0 } },
+ ],
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(500, 16)], // amount: 500 > 100
+ });
+
+ const burned = await coin.getAmountBurned(tx);
+ expect(burned).toBe(ccc.numFrom(-400n)); // 100 - 500
+ });
+
+ it("getInfoBurned should aggregate amount, capacity, and count", async () => {
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ return ccc.Cell.from({
+ outPoint: ccc.OutPoint.from(outPoint),
+ cellOutput: { capacity: ccc.fixedPointFrom(200), lock, type },
+ outputData: ccc.numLeToBytes(300, 16),
+ });
+ });
+
+ const tx = ccc.Transaction.from({
+ inputs: [
+ { previousOutput: { txHash: "0x" + "0".repeat(64), index: 0 } },
+ ],
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)],
+ });
+
+ const info = await coin.getInfoBurned(tx);
+ expect(info.amount).toBe(ccc.numFrom(200)); // 300 - 100
+ expect(info.capacity).toBe(
+ ccc.fixedPointFrom(200) - ccc.fixedPointFrom(142),
+ ); // input cap - output min cap
+ expect(info.count).toBe(0); // 1 input - 1 output
+ });
+ });
+
+ describe("calculateBalance", () => {
+ it("should return total balance from chain source by default", async () => {
+ vi.spyOn(signer, "findCellsOnChain").mockImplementation(
+ async function* () {
+ yield ccc.Cell.from({
+ outPoint: { txHash: "0x" + "0".repeat(64), index: 0 },
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: ccc.numLeToBytes(400, 16),
+ });
+ },
+ );
+
+ const balance = await coin.calculateBalance(signer);
+ expect(balance).toBe(ccc.numFrom(400));
+ });
+
+ it("should return total balance from local source when specified", async () => {
+ vi.spyOn(signer, "findCells").mockImplementation(async function* () {
+ yield ccc.Cell.from({
+ outPoint: { txHash: "0x" + "0".repeat(64), index: 0 },
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: ccc.numLeToBytes(250, 16),
+ });
+ });
+
+ const balance = await coin.calculateBalance(signer, { source: "local" });
+ expect(balance).toBe(ccc.numFrom(250));
+ });
+ });
+
+ describe("Coin factory", () => {
+ it("should reject an incomplete script without knownScript", async () => {
+ await expect(
+ Coin.new({
+ script: { args: "0x" },
+ client,
+ }),
+ ).rejects.toThrow(
+ "Either knownScript or script.codeHash and script.hashType must be provided for Coin",
+ );
+ });
+
+ it("should preserve a custom filter", async () => {
+ const customFilter = ccc.ClientIndexerSearchKeyFilter.from({
+ script: ccc.Script.from({ ...type, args: type.args + "00" }),
+ outputDataLenRange: [32, "0xffffffff"],
+ });
+ const customCoin = await Coin.new({
+ script: type,
+ client,
+ filter: customFilter,
+ cellDeps: [],
+ });
+ expect(customCoin.filter).toEqual(customFilter);
+ });
+
+ it("should use the exact type script and Coin data range in the default filter", async () => {
+ const defaultCoin = await Coin.new({
+ script: type,
+ client,
+ cellDeps: [],
+ });
+ const filterScript = ccc.Script.from(
+ (defaultCoin.filter as { script?: ccc.ScriptLike }).script!,
+ );
+ expect(filterScript.eq(type)).toBe(true);
+ const scriptLength = 33 + ccc.bytesFrom(type.args).length;
+ expect(defaultCoin.filter.scriptLenRange).toEqual([
+ ccc.numFrom(scriptLength),
+ ccc.numFrom(scriptLength + 1),
+ ]);
+ expect(defaultCoin.filter.outputDataLenRange).toEqual([
+ ccc.numFrom(16),
+ ccc.numFrom("0xffffffff"),
+ ]);
+ });
+
+ it("client getter returns the provided client", async () => {
+ const otherClient = new ccc.ClientPublicMainnet();
+ const customClientCoin = await Coin.new({
+ script: type,
+ client: otherClient,
+ cellDeps: [],
+ });
+ expect(customClientCoin.client).toBe(otherClient);
+ });
+
+ it("uses a signer supplied to calculateInfo", async () => {
+ const explicitSignerCoin = await Coin.new({
+ script: type,
+ client,
+ cellDeps: [],
+ });
+ vi.spyOn(signer, "findCellsOnChain").mockImplementation(
+ async function* () {},
+ );
+ await expect(
+ explicitSignerCoin.calculateInfo(signer),
+ ).resolves.toBeDefined();
+ vi.restoreAllMocks();
+ });
+ });
+
+ describe("getInputsAmount / getOutputsAmount edge cases", () => {
+ it("getInputsAmount should return 0 for an empty transaction", async () => {
+ const tx = ccc.Transaction.from({});
+ expect(await coin.getInputsAmount(tx)).toBe(ccc.Zero);
+ });
+
+ it("getOutputsAmount should return 0 for an empty transaction", async () => {
+ const tx = ccc.Transaction.from({});
+ expect(await coin.getOutputsAmount(tx)).toBe(ccc.Zero);
+ });
+ });
+
+ describe("completeInputsByAmount edge cases", () => {
+ beforeEach(() => {
+ const mockCoins = Array.from({ length: 3 }, (_, i) =>
+ ccc.Cell.from({
+ outPoint: {
+ txHash: `0x${"c".repeat(63)}${i.toString(16)}`,
+ index: 0,
+ },
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: ccc.numLeToBytes(100, 16), // amount: 100 each
+ }),
+ );
+
+ vi.spyOn(signer, "findCells").mockImplementation(
+ async function* (filter) {
+ if (filter.script && ccc.Script.from(filter.script).eq(type)) {
+ for (const cell of mockCoins) {
+ yield cell;
+ }
+ }
+ },
+ );
+
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ return mockCoins.find((c) => c.outPoint.eq(outPoint));
+ });
+ });
+
+ it("should add no inputs when amountTweak exactly cancels the output requirement", async () => {
+ // Output needs 100, but amountTweak is -100 (negative tweak zeroing requirement)
+ const tx = ccc.Transaction.from({
+ inputs: [
+ {
+ previousOutput: {
+ txHash: `0x${"c".repeat(63)}0`,
+ index: 0,
+ },
+ },
+ ],
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)],
+ });
+
+ // The negative tweak cancels the output requirement → no more inputs needed
+ const { addedCount } = await coin.completeInputsByAmount(
+ signer,
+ tx,
+ -100,
+ );
+ expect(addedCount).toBe(0);
+ });
+ });
+
+ describe("calculateInfo", () => {
+ let mockCoins: ccc.Cell[];
+
+ beforeEach(() => {
+ mockCoins = [
+ ccc.Cell.from({
+ outPoint: { txHash: "0x" + "a".repeat(64), index: 0 },
+ cellOutput: { capacity: ccc.fixedPointFrom(142), lock, type },
+ outputData: ccc.numLeToBytes(100, 16), // amount: 100
+ }),
+ ccc.Cell.from({
+ outPoint: { txHash: "0x" + "b".repeat(64), index: 0 },
+ cellOutput: { capacity: ccc.fixedPointFrom(200), lock, type },
+ outputData: ccc.numLeToBytes(250, 16), // amount: 250
+ }),
+ ];
+ });
+
+ it("should calculate info from local source", async () => {
+ const findCellsSpy = vi
+ .spyOn(signer, "findCells")
+ .mockImplementation(async function* () {
+ for (const cell of mockCoins) {
+ yield cell;
+ }
+ });
+
+ const info = await coin.calculateInfo(signer, { source: "local" });
+
+ expect(info.amount).toBe(ccc.numFrom(350));
+ expect(info.capacity).toBe(ccc.fixedPointFrom(342));
+ expect(info.count).toBe(2);
+ expect(findCellsSpy).toHaveBeenCalledWith(coin.filter);
+ });
+
+ it("should calculate info from chain source", async () => {
+ const findCellsOnChainSpy = vi
+ .spyOn(signer, "findCellsOnChain")
+ .mockImplementation(async function* () {
+ for (const cell of mockCoins) {
+ yield cell;
+ }
+ });
+
+ const info = await coin.calculateInfo(signer, { source: "chain" });
+
+ expect(info.amount).toBe(ccc.numFrom(350));
+ expect(info.capacity).toBe(ccc.fixedPointFrom(342));
+ expect(info.count).toBe(2);
+ expect(findCellsOnChainSpy).toHaveBeenCalledWith(coin.filter);
+ });
+ });
+
+ describe("KnownScript construction", () => {
+ it("should initialize with knownScript correctly", async () => {
+ const getKnownScriptSpy = vi.spyOn(client, "getKnownScript");
+
+ const coinKnown = await Coin.new({
+ knownScript: ccc.KnownScript.SUdt,
+ script: {
+ args: lock.hash(),
+ },
+ client,
+ });
+
+ const script = coinKnown.script;
+ const resolvedCellDeps = coinKnown.cellDeps;
+
+ expect(getKnownScriptSpy).toHaveBeenCalledWith(ccc.KnownScript.SUdt);
+
+ const expectedSUdtInfo = await client.getKnownScript(
+ ccc.KnownScript.SUdt,
+ );
+ expect(script.codeHash).toBe(expectedSUdtInfo.codeHash);
+ expect(script.hashType).toBe(expectedSUdtInfo.hashType);
+ expect(script.args).toBe(lock.hash());
+ expect(resolvedCellDeps.length).toBe(1);
+ expect(resolvedCellDeps[0].outPoint.txHash).toBe(
+ expectedSUdtInfo.cellDeps[0].cellDep.outPoint.txHash,
+ );
+ });
+
+ it("should append custom cellDeps after the knownScript's cellDeps", async () => {
+ const customCellDep = {
+ outPoint: {
+ txHash: "0x" + "c".repeat(64),
+ index: 0,
+ },
+ depType: "code" as const,
+ };
+
+ const coinKnown = await Coin.new({
+ knownScript: ccc.KnownScript.SUdt,
+ script: {
+ args: lock.hash(),
+ },
+ cellDeps: [customCellDep],
+ client,
+ });
+
+ const resolvedCellDeps = coinKnown.cellDeps;
+ const expectedSUdtInfo = await client.getKnownScript(
+ ccc.KnownScript.SUdt,
+ );
+
+ // Total cell deps should be 2 (knownScript's cell dep + our custom cell dep)
+ expect(resolvedCellDeps.length).toBe(2);
+ // The first one is knownScript's cell dep
+ expect(resolvedCellDeps[0].outPoint.txHash).toBe(
+ expectedSUdtInfo.cellDeps[0].cellDep.outPoint.txHash,
+ );
+ // The second one is our custom cell dep
+ expect(resolvedCellDeps[1].outPoint.txHash).toBe(
+ customCellDep.outPoint.txHash,
+ );
+ });
+
+ it("should prefer explicit script over knownScript shorthand", async () => {
+ const explicitScript = {
+ codeHash: ("0x" + "a".repeat(64)) as ccc.Hex,
+ hashType: "type" as const,
+ args: lock.hash(),
+ };
+ const customCellDep = {
+ outPoint: {
+ txHash: "0x" + "c".repeat(64),
+ index: 0,
+ },
+ depType: "code" as const,
+ };
+ const getKnownScriptSpy = vi.spyOn(client, "getKnownScript");
+
+ const coinKnown = await Coin.new({
+ knownScript: ccc.KnownScript.SUdt,
+ script: explicitScript,
+ cellDeps: [customCellDep],
+ client,
+ });
+
+ expect(coinKnown.script).toEqual(ccc.Script.from(explicitScript));
+ expect(coinKnown.cellDeps).toEqual([ccc.CellDep.from(customCellDep)]);
+ expect(getKnownScriptSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("transfer", () => {
+ it("should add outputs correctly", async () => {
+ const recipientLock1 = (await signer.getRecommendedAddressObj()).script;
+ const recipientSigner2 = new ccc.SignerCkbPublicKey(
+ client,
+ "0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ );
+ const recipientLock2 = (await recipientSigner2.getRecommendedAddressObj())
+ .script;
+
+ const tx = await coin.transfer([
+ { to: recipientLock1, amount: 100n },
+ { to: recipientLock2, amount: 200n },
+ ]);
+
+ // Verify outputs were added correctly
+ expect(tx.outputs.length).toBe(2);
+ expect(tx.outputs[0].lock.eq(recipientLock1)).toBe(true);
+ expect(tx.outputs[0].type?.eq(coin.script)).toBe(true);
+ expect(tx.outputs[1].lock.eq(recipientLock2)).toBe(true);
+ expect(tx.outputs[1].type?.eq(coin.script)).toBe(true);
+
+ expect(tx.outputsData.length).toBe(2);
+ expect(await coin.amountFrom(tx.getOutput(0)!)).toBe(100n);
+ expect(await coin.amountFrom(tx.getOutput(1)!)).toBe(200n);
+
+ const actions = coin.coBuild.findActions(tx, coin.script);
+ expect(actions.length).toBe(2);
+
+ const decodedTransfers = actions.map((action) =>
+ CoinAction.fromBytes(action.data).match({
+ Transfer: (transfer) => transfer,
+ _: () => {
+ throw new Error("Expected a Transfer CoinAction");
+ },
+ }),
+ );
+ expect(decodedTransfers[0].amount).toBe(100n);
+ expect(decodedTransfers[0].to?.eq(recipientLock1)).toBe(true);
+ expect(decodedTransfers[1].amount).toBe(200n);
+ expect(decodedTransfers[1].to?.eq(recipientLock2)).toBe(true);
+ });
+
+ it("should apply transformerOnOutput to transfer outputs", async () => {
+ const recipientLock = (await signer.getRecommendedAddressObj()).script;
+
+ const coinWithTransformer = await Coin.new({
+ script: type,
+ client,
+ cellDeps: [],
+ transformerOnOutput: async (tx, outputIndex) => {
+ const cell = tx.getOutput(outputIndex)!;
+ tx.setOutput(outputIndex, {
+ cellOutput: cell.cellOutput,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(
+ ccc.bytesFrom(cell.outputData),
+ new Uint8Array([0xaa, 0xbb]),
+ ),
+ ),
+ });
+ return tx;
+ },
+ });
+
+ const tx = await coinWithTransformer.transfer([
+ { to: recipientLock, amount: 100n },
+ ]);
+
+ expect(tx.outputs.length).toBe(1);
+ expect(tx.outputs[0].lock.eq(recipientLock)).toBe(true);
+ expect(tx.outputs[0].type?.eq(coinWithTransformer.script)).toBe(true);
+ expect(ccc.bytesFrom(tx.outputsData[0]).length).toBe(16 + 2);
+ expect(ccc.hexFrom(ccc.bytesFrom(tx.outputsData[0]).slice(16))).toBe(
+ "0xaabb",
+ );
+ });
+ });
+
+ describe("mint", () => {
+ it("should mint correctly", async () => {
+ const recipientLock = (await signer.getRecommendedAddressObj()).script;
+
+ const tx = await coin.mint([{ to: recipientLock, amount: 100n }]);
+
+ expect(tx.outputs.length).toBe(1);
+ expect(tx.outputs[0].lock.eq(recipientLock)).toBe(true);
+ expect(tx.outputs[0].type?.eq(coin.script)).toBe(true);
+ expect(await coin.amountFrom(tx.getOutput(0)!)).toBe(100n);
+ // Verify the public helper injects a correctly wrapped CoinAction.
+ const actions = coin.coBuild.findActions(tx, coin.script);
+ expect(actions.length).toBe(1);
+ CoinAction.fromBytes(actions[0].data).match({
+ Mint: (mint) => {
+ expect(mint.amount).toBe(100n);
+ expect(mint.to?.eq(recipientLock)).toBe(true);
+ },
+ _: () => {
+ throw new Error("Expected a Mint CoinAction");
+ },
+ });
+ expect(await coin.getIntendedAmountBurned(tx)).toBe(-100n);
+ });
+
+ it("should apply transformerOnOutput to mint output", async () => {
+ const coinWithTransformer = await Coin.new({
+ script: type,
+ client,
+ cellDeps: [],
+ transformerOnOutput: async (tx, outputIndex) => {
+ const cell = tx.getOutput(outputIndex)!;
+ tx.setOutput(outputIndex, {
+ cellOutput: cell.cellOutput,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(
+ ccc.bytesFrom(cell.outputData),
+ new Uint8Array([0x11, 0x22]),
+ ),
+ ),
+ });
+ return tx;
+ },
+ });
+ const recipientLock = (await signer.getRecommendedAddressObj()).script;
+
+ const tx = await coinWithTransformer.mint([
+ { to: recipientLock, amount: 100n },
+ ]);
+
+ expect(tx.outputs.length).toBe(1);
+ expect(ccc.bytesFrom(tx.outputsData[0]).length).toBe(16 + 2);
+ expect(ccc.hexFrom(ccc.bytesFrom(tx.outputsData[0]).slice(16))).toBe(
+ "0x1122",
+ );
+ });
+ });
+
+ describe("burn", () => {
+ it("should burn correctly", async () => {
+ const tx = await coin.burn(100n);
+
+ // Burn should not add outputs
+ expect(tx.outputs.length).toBe(0);
+
+ // Verify the public helper injects a correctly wrapped CoinAction.
+ const actions = coin.coBuild.findActions(tx, coin.script);
+ expect(actions.length).toBe(1);
+ CoinAction.fromBytes(actions[0].data).match({
+ Burn: (burn) => {
+ expect(burn.amount).toBe(100n);
+ },
+ _: () => {
+ throw new Error("Expected a Burn CoinAction");
+ },
+ });
+ expect(await coin.getIntendedAmountBurned(tx)).toBe(100n);
+ });
+ });
+
+ describe("CoBuild action completion", () => {
+ let mockCoins: ccc.Cell[];
+
+ beforeEach(async () => {
+ mockCoins = 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), // amount: 100
+ }),
+ );
+
+ vi.spyOn(signer, "findCells").mockImplementation(
+ async function* (filter) {
+ if (filter.script && ccc.Script.from(filter.script).eq(type)) {
+ for (const cell of mockCoins) {
+ yield cell;
+ }
+ }
+ },
+ );
+
+ vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => {
+ const cell = mockCoins.find((c) => c.outPoint.eq(outPoint));
+ return cell;
+ });
+ });
+
+ it("should calculate intended amount burned from mint and burn transactions", async () => {
+ const cobuild = coin.coBuild;
+
+ // Exercise the public helpers here: they are responsible for wrapping the
+ // variant payload in a CoinAction before handing it to CoBuild.
+ const txMint = await coin.mint([{ amount: 100, to: lock }]);
+ const minted = await coin.getIntendedAmountBurned(txMint);
+ expect(minted).toBe(ccc.numFrom(-100));
+
+ // CoinAction also permits a Mint payload without the optional `to` field.
+ const mintActionWithoutTo = CoinAction.from({
+ type: "Mint",
+ value: {
+ amount: 150,
+ },
+ });
+ const { tx: txMintWithoutTo } = await cobuild.appendActions(
+ mintActionWithoutTo,
+ ccc.Transaction.from({}),
+ );
+ const mintedWithoutTo =
+ await coin.getIntendedAmountBurned(txMintWithoutTo);
+ expect(mintedWithoutTo).toBe(ccc.numFrom(-150));
+
+ const txBurn = await coin.burn(50);
+ const burned = await coin.getIntendedAmountBurned(txBurn);
+ expect(burned).toBe(ccc.numFrom(50));
+
+ const txBoth = await coin.burn(50, txMint);
+ const both = await coin.getIntendedAmountBurned(txBoth);
+ expect(both).toBe(ccc.numFrom(-50)); // -100 + 50 = -50
+ });
+
+ it("should completeInputsByAmount with MintAction", async () => {
+ const cobuild = coin.coBuild;
+ const mintAction = CoinAction.from({
+ type: "Mint",
+ value: {
+ amount: 100,
+ to: lock,
+ },
+ });
+ const baseTx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)], // Need 100
+ });
+ const { tx } = await cobuild.appendActions(mintAction, baseTx);
+
+ // Since we mint 100 and need 100, addedCount should be 0 (no inputs needed)
+ // Pass a negative capacity tweak equal to outputs capacity to avoid sourcing inputs for capacity.
+ const { addedCount } = await coin.completeInputsByAmount(
+ signer,
+ tx,
+ ccc.Zero,
+ -tx.getOutputsCapacity(),
+ );
+ expect(addedCount).toBe(0);
+ expect(tx.inputs.length).toBe(0);
+ });
+
+ it("should completeInputsByAmount with BurnAction", async () => {
+ const cobuild = coin.coBuild;
+ const burnAction = CoinAction.from({
+ type: "Burn",
+ value: {
+ amount: 50,
+ },
+ });
+ const baseTx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)], // Need 100
+ });
+ const { tx } = await cobuild.appendActions(burnAction, baseTx);
+
+ // Need 100 output + 50 burn = 150 total.
+ // Since each mock coin is 100, we need 2 inputs (total 200).
+ const { addedCount } = await coin.completeInputsByAmount(signer, tx);
+ expect(addedCount).toBe(2);
+ expect(tx.inputs.length).toBe(2);
+
+ const inputAmount = await coin.getInputsAmount(tx);
+ expect(inputAmount).toBe(ccc.numFrom(200));
+ });
+
+ it("should completeInputsByAmount with both MintAction and BurnAction", async () => {
+ const cobuild = coin.coBuild;
+ const mintAction = CoinAction.from({
+ type: "Mint",
+ value: {
+ amount: 60,
+ },
+ });
+ const burnAction = CoinAction.from({
+ type: "Burn",
+ value: {
+ amount: 10,
+ },
+ });
+ const baseTx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)], // Need 100
+ });
+ // Net change requirement = 100 output + 10 burn - 60 mint = 50.
+ const { tx } = await cobuild.appendActions(
+ [mintAction, burnAction],
+ baseTx,
+ );
+
+ // Need 50 total. 1 mock coin (amount 100) is enough.
+ const { addedCount } = await coin.completeInputsByAmount(signer, tx);
+ expect(addedCount).toBe(1);
+ expect(tx.inputs.length).toBe(1);
+
+ const inputAmount = await coin.getInputsAmount(tx);
+ expect(inputAmount).toBe(ccc.numFrom(100));
+ });
+
+ it("should completeChangeToLock with MintAction and BurnAction", async () => {
+ const cobuild = coin.coBuild;
+ const mintAction = CoinAction.from({
+ type: "Mint",
+ value: {
+ amount: 80,
+ to: lock,
+ },
+ });
+ const burnAction = CoinAction.from({
+ type: "Burn",
+ value: {
+ amount: 30,
+ },
+ });
+ const baseTx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)], // Need 100
+ });
+ // Net requirement = 100 output + 30 burn - 80 mint = 50.
+ const { tx } = await cobuild.appendActions(
+ [mintAction, burnAction],
+ baseTx,
+ );
+
+ // Complete change
+ const completedTx = await coin.completeChangeToLock(signer, lock, tx);
+
+ // Outputs should now have:
+ // Index 0: Original output (amount 100)
+ // Index 1: Change output (should have 200 input - 50 net requirement = 150)
+ expect(completedTx.outputs.length).toBe(2);
+ const changeAmount = await coin.amountFrom(completedTx.getOutput(1)!);
+ expect(changeAmount).toBe(ccc.numFrom(150));
+ });
+
+ it("should complete the transaction with MintAction and BurnAction in complete", async () => {
+ const cobuild = coin.coBuild;
+ const mintAction = CoinAction.from({
+ type: "Mint",
+ value: {
+ amount: 80,
+ },
+ });
+ const burnAction = CoinAction.from({
+ type: "Burn",
+ value: {
+ amount: 30,
+ },
+ });
+ const baseTx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(100, 16)], // Need 100
+ });
+ const { tx } = await cobuild.appendActions(
+ [mintAction, burnAction],
+ baseTx,
+ );
+
+ // Complete using standard complete method
+ const completedTx = await coin.complete(
+ signer,
+ (t, amount) => {
+ t.addOutput({ lock, type }, ccc.numLeToBytes(amount, 16));
+ return t;
+ },
+ tx,
+ );
+
+ expect(completedTx.outputs.length).toBe(2);
+ const changeAmount = await coin.amountFrom(completedTx.getOutput(1)!);
+ expect(changeAmount).toBe(ccc.numFrom(150));
+ });
+ });
+});
diff --git a/packages/coin/src/coin/coin.ts b/packages/coin/src/coin/coin.ts
new file mode 100644
index 000000000..1cd6d4358
--- /dev/null
+++ b/packages/coin/src/coin/coin.ts
@@ -0,0 +1,866 @@
+import { coBuild } from "@ckb-ccc/co-build";
+import { ccc } from "@ckb-ccc/core";
+import { CoinAction } from "../coBuild.js";
+import { CoinInfo, CoinInfoLike } from "./coinInfo.js";
+import { ErrorCoinInsufficient } from "./error.js";
+
+/**
+ * Script configurations for {@link Coin}.
+ * @public
+ */
+export type CoinOptionsScript = {
+ knownScript?: ccc.KnownScript | null;
+ script: {
+ codeHash?: ccc.HexLike | null;
+ hashType?: ccc.HashTypeLike | null;
+ args: ccc.BytesLike;
+ };
+ cellDeps?: ccc.CellDepLike[] | null;
+};
+
+/** Post-processes a transaction after Coin outputs have been written. @public */
+export type CoinTransformerOnOutput = (
+ tx: ccc.Transaction,
+ outputIndex: number,
+) => ccc.TransactionLike | Promise;
+
+/**
+ * Common configurations shared by {@link Coin} implementations.
+ * @public
+ */
+export type CoinOptionsCommon = {
+ client: ccc.Client;
+ filter?: ccc.ClientIndexerSearchKeyFilterLike | null;
+ transformerOnOutput?: CoinTransformerOnOutput | null;
+ scriptInfo?: coBuild.ScriptInfoLike | null;
+};
+
+/**
+ * Options for creating a {@link Coin} instance.
+ * @public
+ */
+export type CoinOptions = CoinOptionsCommon & CoinOptionsScript;
+
+/**
+ * A generic on-chain Coin (fungible token) identified by a CKB type script.
+ *
+ * Provides helpers for querying balances and building/completing transactions.
+ * Asset identity is defined by the complete type script `(codeHash, hashType, args)` —
+ * only cells with an identical type script belong to the same Coin.
+ *
+ * @public
+ * @category Blockchain
+ * @category Token
+ */
+export class Coin {
+ /** Whether Coin inputs should also be used to cover missing CKB capacity. @internal */
+ protected get shouldUseCoinsForCapacity(): boolean {
+ return true;
+ }
+
+ /** Type script that identifies this Coin. @public */
+ public readonly script: ccc.Script;
+
+ /** Output transformer for Coin outputs. @public */
+ public readonly transformerOnOutput?: CoinTransformerOnOutput | null;
+
+ /** CoBuild instance for this Coin. @public */
+ public readonly coBuild: coBuild.CoBuild;
+
+ /** Client for network requests. @public */
+ public readonly client: ccc.Client;
+
+ /**
+ * Indexer search filter used to find Coin cells.
+ * Defaults to cells with this type script and `outputDataLenRange: [16, ∞)`.
+ *
+ * @public
+ */
+ public readonly filter: ccc.ClientIndexerSearchKeyFilter;
+
+ /** Cell deps required by the type script, added to every built transaction. @public */
+ public readonly cellDeps: ccc.CellDep[];
+
+ /** @internal */
+ protected constructor(
+ options: Awaited>,
+ ) {
+ this.script = options.script;
+ this.transformerOnOutput = options.transformerOnOutput;
+ this.coBuild = options.coBuild;
+ this.client = options.client;
+ this.filter = options.filter;
+ this.cellDeps = options.cellDeps;
+ }
+
+ /**
+ * @param options.knownScript - Optional known script standard (e.g., `ccc.KnownScript.SUdt`) to dynamically resolve `codeHash`, `hashType`, and `cellDeps`.
+ * Used only when `script.codeHash` or `script.hashType` is not provided.
+ * @param options.script - Type script that identifies this Coin asset. A complete script with `codeHash` and `hashType`
+ * takes priority over `knownScript`; otherwise `knownScript` is used as shorthand and only `args` is required.
+ * @param options.client - Client for network requests.
+ * @param options.filter - Custom indexer filter. Defaults to the exact Coin type
+ * script and an output data length of at least 16 bytes.
+ * @param options.cellDeps - Cell deps automatically added to every built transaction. If the known script shorthand is used, these custom cell deps are appended after the resolved default cell deps of the known script.
+ * @param options.transformerOnOutput - Optional callback to post-process the whole transaction after each generated
+ * Coin output is written. It receives the output's index in the input transaction. Completion may invoke it more
+ * than once on different transaction instances while estimating capacity, so it must be deterministic and free
+ * of external side effects.
+ *
+ * @example
+ * ```typescript
+ * const coin = await Coin.new({
+ * script: { codeHash: "0x...", hashType: "type", args: "0x..." },
+ * client,
+ * cellDeps: [{ outPoint: codeOutPoint, depType: "code" }],
+ * });
+ * ```
+ *
+ * @example
+ * ```typescript
+ * const coin = await Coin.new({
+ * knownScript: ccc.KnownScript.SUdt,
+ * script: { args: ownerLock.hash() },
+ * client,
+ * transformerOnOutput: async (tx, outputIndex) => {
+ * tx.addOutput(companionCell);
+ * return tx;
+ * },
+ * });
+ * ```
+ */
+ static async new(options: CoinOptions): Promise {
+ return new Coin(await Coin.resolveOptions(options));
+ }
+
+ /** Resolves asynchronous construction dependencies. @internal */
+ protected static async resolveOptions(options: CoinOptions) {
+ const {
+ knownScript,
+ script,
+ cellDeps,
+ client,
+ transformerOnOutput,
+ scriptInfo,
+ } = options;
+
+ if (
+ knownScript == null &&
+ (script.codeHash == null || script.hashType == null)
+ ) {
+ throw new Error(
+ "Either knownScript or script.codeHash and script.hashType must be provided for Coin",
+ );
+ }
+ let resolvedScript: ccc.ScriptLike;
+ let resolvedCellDeps: ccc.CellDepLike[];
+ if (script.codeHash != null && script.hashType != null) {
+ resolvedScript = {
+ codeHash: script.codeHash,
+ hashType: script.hashType,
+ args: script.args,
+ };
+ resolvedCellDeps = cellDeps ?? [];
+ } else {
+ const knownScriptInfo = await client.getKnownScript(
+ knownScript as ccc.KnownScript,
+ );
+ resolvedScript = {
+ codeHash: knownScriptInfo.codeHash,
+ hashType: knownScriptInfo.hashType,
+ args: script.args,
+ };
+ resolvedCellDeps = (
+ await client.getCellDeps(knownScriptInfo.cellDeps)
+ ).concat(cellDeps?.map(ccc.CellDep.from) ?? []);
+ }
+
+ const normalizedScript = ccc.Script.from(resolvedScript);
+ const normalizedCellDeps = resolvedCellDeps.map(ccc.CellDep.from);
+ // The indexer's script length is code_hash + hash_type + args, without
+ // Molecule's table/Bytes serialization overhead.
+ const scriptLength = 33 + ccc.bytesFrom(normalizedScript.args).length;
+ const normalizedFilter = ccc.ClientIndexerSearchKeyFilter.from(
+ options.filter ?? {
+ script: normalizedScript,
+ scriptLenRange: [scriptLength, scriptLength + 1],
+ outputDataLenRange: [16, "0xffffffff"],
+ },
+ );
+ const normalizedCoBuild = new coBuild.CoBuild(normalizedScript, scriptInfo);
+
+ return {
+ script: normalizedScript,
+ transformerOnOutput,
+ coBuild: normalizedCoBuild,
+ client,
+ filter: normalizedFilter,
+ cellDeps: normalizedCellDeps,
+ };
+ }
+
+ /**
+ * Reads the Coin amount from raw output data without verifying the type script.
+ * Returns `0` if the data is shorter than 16 bytes.
+ *
+ * ⚠️ The caller must ensure the data belongs to a valid Coin cell.
+ * For safe extraction from an arbitrary cell use `amountFrom`.
+ */
+ static amountFromUnsafe(outputData: ccc.HexLike): ccc.Num {
+ const data = ccc.bytesFrom(outputData).slice(0, 16);
+ return data.length < 16 ? ccc.Zero : ccc.numLeFromBytes(data);
+ }
+
+ /** Applies the configured output transformer to a transaction output. @public */
+ async transformOutput(
+ tx: ccc.Transaction,
+ outputIndex: number,
+ ): Promise {
+ if (!this.transformerOnOutput) {
+ return tx;
+ }
+
+ return ccc.Transaction.from(
+ await this.transformerOnOutput(tx, outputIndex),
+ );
+ }
+
+ /**
+ * Writes a Coin amount into a transaction output and applies the configured
+ * transformer for that output.
+ *
+ * The amount is encoded as a 16-byte little-endian integer at
+ * `outputData[0..16)`. Any bytes after the first 16 are preserved, which lets
+ * callers keep extension data attached to token cells.
+ *
+ * @param tx - Transaction containing the output.
+ * @param outputIndexLike - Index of the output to update.
+ * @param amount - Coin amount to write into `outputData[0..16)`.
+ * @returns The transformed transaction.
+ * @throws If the output does not exist.
+ * @public
+ */
+ async setAmount(
+ tx: ccc.Transaction,
+ outputIndexLike: ccc.NumLike,
+ amount: ccc.NumLike,
+ ): Promise {
+ const outputIndex = Number(ccc.numFrom(outputIndexLike));
+ const cell = tx.getOutput(outputIndex);
+ if (!cell) {
+ throw new Error(`Output at index ${outputIndex} does not exist`);
+ }
+ const normalizedAmount = ccc.numFrom(amount);
+
+ cell.outputData = ccc.hexFrom(
+ ccc.bytesConcat(
+ ccc.numLeToBytes(normalizedAmount, 16),
+ ccc.bytesFrom(cell.outputData).slice(16),
+ ),
+ );
+
+ tx.setOutput(outputIndex, cell);
+ return this.transformOutput(tx, outputIndex);
+ }
+
+ /**
+ * Aggregates Coin info (amount, capacity, count) from cells, skipping non-Coins.
+ * Accepts a single cell, a sync iterable, or an async iterable.
+ */
+ async infoFrom(
+ cells:
+ | ccc.CellAnyLike
+ | Iterable
+ | AsyncIterable,
+ acc?: CoinInfoLike | null,
+ ): Promise {
+ return ccc.reduceAsync(
+ cells,
+ async (acc, cellLike) => {
+ const cell = ccc.CellAny.from(cellLike);
+ if (!(await this.isCoin(cell))) {
+ return;
+ }
+
+ return acc.addAssign({
+ amount: Coin.amountFromUnsafe(cell.outputData),
+ capacity: cell.cellOutput.capacity,
+ count: 1,
+ });
+ },
+ CoinInfo.from(acc).clone(),
+ );
+ }
+
+ /** Convenience wrapper around `infoFrom` that returns only the amount. */
+ async amountFrom(
+ cells:
+ | ccc.CellAnyLike
+ | Iterable
+ | AsyncIterable,
+ acc?: ccc.NumLike | null,
+ ): Promise {
+ return (await this.infoFrom(cells, { amount: acc })).amount;
+ }
+
+ /**
+ * Scans all Coins owned by the signer and returns aggregated info.
+ *
+ * @param options.source - `"chain"` (default) queries on-chain state; `"local"` uses the
+ * local indexer cache which is faster but may be stale.
+ *
+ * ⚠️ Expensive — scales linearly with the number of Coin cells.
+ */
+ async calculateInfo(
+ signer: ccc.Signer,
+ options?: { source?: "chain" | "local" | null },
+ ): Promise {
+ const isFromLocal = (options?.source ?? "chain") === "local";
+ const filter = this.filter;
+ const cells = isFromLocal
+ ? signer.findCells(filter)
+ : signer.findCellsOnChain(filter);
+
+ return this.infoFrom(cells);
+ }
+
+ /**
+ * Convenience wrapper around `calculateInfo` that returns only the balance.
+ *
+ * ⚠️ Expensive — scans all Coin cells owned by the signer.
+ */
+ async calculateBalance(
+ signer: ccc.Signer,
+ options?: { source?: "chain" | "local" | null },
+ ): Promise {
+ return (await this.calculateInfo(signer, options)).amount;
+ }
+
+ /**
+ * Returns whether the cell is a valid Coin for this token.
+ * Subclasses may override this to apply additional validation rules.
+ */
+ async isCoin(cellLike: ccc.CellAnyLike): Promise {
+ const cell = ccc.CellAny.from(cellLike);
+ return (
+ (cell.cellOutput.type?.eq(this.script) ?? false) &&
+ ccc.bytesFrom(cell.outputData).length >= 16
+ );
+ }
+
+ /** Returns aggregated Coin info (amount, capacity, count) for all Coin inputs in the transaction. */
+ async getInputsInfo(txLike: ccc.TransactionLike): Promise {
+ const tx = ccc.Transaction.from(txLike);
+ const client = this.client;
+ return this.infoFrom(
+ (async function* () {
+ for (const input of tx.inputs) {
+ yield await input.getCell(client);
+ }
+ })(),
+ );
+ }
+
+ /** Convenience wrapper around `getInputsInfo` that returns only the amount. */
+ async getInputsAmount(txLike: ccc.TransactionLike): Promise {
+ return (await this.getInputsInfo(txLike)).amount;
+ }
+
+ /** Returns aggregated Coin info (amount, capacity, count) for all Coin outputs in the transaction. */
+ async getOutputsInfo(txLike: ccc.TransactionLike): Promise {
+ const tx = ccc.Transaction.from(txLike);
+ return this.infoFrom(Array.from(tx.outputCells));
+ }
+
+ /** Convenience wrapper around `getOutputsInfo` that returns only the amount. */
+ async getOutputsAmount(txLike: ccc.TransactionLike): Promise {
+ return (await this.getOutputsInfo(txLike)).amount;
+ }
+
+ /**
+ * Returns inputs minus outputs as a `CoinInfo`. Positive amount means tokens are burned;
+ * positive capacity means Coins provide surplus CKB.
+ */
+ async getInfoBurned(txLike: ccc.TransactionLike): Promise {
+ const tx = ccc.Transaction.from(txLike);
+ return (await this.getInputsInfo(tx)).sub(await this.getOutputsInfo(tx));
+ }
+
+ /** Convenience wrapper around `getInfoBurned` that returns only the amount (inputs − outputs). */
+ async getAmountBurned(txLike: ccc.TransactionLike): Promise {
+ return (await this.getInfoBurned(txLike)).amount;
+ }
+
+ async getIntendedAmountBurned(txLike: ccc.TransactionLike): Promise {
+ const tx = ccc.Transaction.from(txLike);
+
+ return this.coBuild.findActions(tx, this.script).reduce((acc, action) => {
+ try {
+ const coinAction = CoinAction.fromBytes(action.data, {
+ isExtraFieldIgnored: true,
+ });
+ acc += coinAction.match({
+ Mint: (mint) => -mint.amount,
+ Burn: (burn) => burn.amount,
+ _: () => ccc.Zero,
+ });
+ } catch (_) {}
+ return acc;
+ }, ccc.Zero);
+ }
+
+ /**
+ * Low-level input selector driven by a custom accumulator.
+ * For each candidate Coin cell the `accumulator` receives `(state, cell, coinInfo)` and
+ * returns the next state to keep going, or `undefined` to stop.
+ *
+ * @returns `accumulated` is `undefined` if the target was reached before all cells were visited.
+ *
+ * @example
+ * ```typescript
+ * // Collect inputs until amount reaches a target
+ * const { tx } = await coin.completeInputs(
+ * signer,
+ * (acc, _cell, coinInfo) => {
+ * const next = acc + coinInfo.amount;
+ * return next >= target ? undefined : next;
+ * },
+ * ccc.Zero,
+ * tx,
+ * );
+ * ```
+ */
+ async completeInputs(
+ signer: ccc.Signer,
+ accumulator: (
+ acc: T,
+ cell: ccc.Cell,
+ coinInfo: CoinInfo,
+ i: number,
+ cells: ccc.Cell[],
+ ) => Promise | T | undefined,
+ init: T,
+ txLike?: ccc.TransactionLike | null,
+ ): Promise<{
+ tx: ccc.Transaction;
+ addedCount: number;
+ accumulated?: T;
+ }> {
+ const tx = ccc.Transaction.from(txLike ?? {});
+ tx.addCellDeps(this.cellDeps);
+
+ const res = await tx.completeInputs(
+ signer,
+ this.filter,
+ async (acc, cell, i, cells) =>
+ accumulator(acc, cell, await this.infoFrom(cell), i, cells),
+ init,
+ );
+
+ return {
+ ...res,
+ tx,
+ };
+ }
+
+ /**
+ * Adds Coin inputs until the Coin amount gap is covered. When
+ * `shouldUseCoinsForCapacity` is enabled, it also attempts to cover the CKB capacity gap
+ * on a best-effort basis (capacity may still be negative if Coin inputs are exhausted
+ * before it is satisfied).
+ *
+ * @param amountTweak - Extra Coin amount to require beyond what outputs consume.
+ * @param capacityTweak - Extra CKB capacity to require beyond what outputs consume.
+ *
+ * @throws {ErrorCoinInsufficient} if the signer has insufficient Coin amount.
+ *
+ * @example
+ * ```typescript
+ * const { tx: completedTx } = await coin.completeInputsByAmount(signer);
+ * ```
+ */
+ async completeInputsByAmount(
+ signer: ccc.Signer,
+ txLike?: ccc.TransactionLike | null,
+ amountTweak?: ccc.NumLike | null,
+ capacityTweak?: ccc.NumLike | null,
+ ): Promise<{
+ addedCount: number;
+ tx: ccc.Transaction;
+ }> {
+ const tx = ccc.Transaction.from(txLike ?? {});
+ tx.addCellDeps(this.cellDeps);
+
+ const { amount: inAmount, capacity: inCapacity } =
+ await this.getInputsInfo(tx);
+ const { amount: outAmount, capacity: outCapacity } =
+ await this.getOutputsInfo(tx);
+
+ const amountExcess =
+ inAmount -
+ outAmount -
+ ccc.numFrom(amountTweak ?? 0) -
+ (await this.getIntendedAmountBurned(tx));
+ // Try to let Coin inputs absorb the tx fee so no extra CKB capacity cell is needed.
+ // Cap at the current fee: we never ask Coins to cover more than what the tx owes.
+ const capacityExcess = this.shouldUseCoinsForCapacity
+ ? ccc.numMin(inCapacity - outCapacity, await tx.getFee(this.client)) -
+ ccc.numFrom(capacityTweak ?? 0)
+ : ccc.Zero;
+
+ if (amountExcess >= ccc.Zero && capacityExcess >= ccc.Zero) {
+ return { addedCount: 0, tx };
+ }
+
+ const {
+ tx: txRes,
+ addedCount,
+ accumulated,
+ } = await this.completeInputs(
+ signer,
+ (acc, _cell, coinInfo) => {
+ const info = acc.add(coinInfo);
+
+ // Try to provide enough capacity with Coins to avoid extra occupation
+ return info.amount >= ccc.Zero && info.capacity >= ccc.Zero
+ ? undefined
+ : info;
+ },
+ CoinInfo.from({ amount: amountExcess, capacity: capacityExcess }),
+ tx,
+ );
+
+ if (accumulated === undefined || accumulated.amount >= ccc.Zero) {
+ return { tx: txRes, addedCount };
+ }
+
+ throw new ErrorCoinInsufficient({
+ amount: -accumulated.amount,
+ type: this.script,
+ });
+ }
+
+ /**
+ * Adds ALL available Coins from the signer as inputs. Useful for consolidation or full sweeps.
+ */
+ async completeInputsAll(
+ signer: ccc.Signer,
+ txLike?: ccc.TransactionLike | null,
+ ): Promise<{
+ addedCount: number;
+ tx: ccc.Transaction;
+ }> {
+ const tx = ccc.Transaction.from(txLike ?? {});
+
+ const { tx: txRes, addedCount } = await this.completeInputs(
+ signer,
+ (acc, _cell, coinInfo) => acc.addAssign(coinInfo),
+ CoinInfo.default(),
+ tx,
+ );
+
+ return { tx: txRes, addedCount };
+ }
+
+ /**
+ * Low-level completion primitive. Adds Coin inputs, then calls `change(tx, amount)` to
+ * write the change output.
+ *
+ * `complete` never manages CKB capacity on its own — it only opportunistically uses any
+ * capacity gap between inputs and outputs to merge Coin cells (reducing the number of
+ * Coin inputs added), on a best-effort basis. It does not guarantee `tx` has enough
+ * capacity afterwards; follow up with `tx.completeBy` (or similar) to complete capacity.
+ *
+ * @param change - Callback that receives the transaction and the excess Coin amount, writes
+ * the change output, and returns the resulting transaction. It must be side-effect-free
+ * beyond modifying `tx`, as it may be invoked more than once (e.g. speculatively on a clone)
+ * before being applied to the final transaction.
+ * @param options.shouldAddInputs - When `false`, skips input sourcing entirely; the caller
+ * is responsible for ensuring `tx` already has enough Coin inputs. Defaults to `true`.
+ *
+ * @example
+ * ```typescript
+ * const completedTx = await coin.complete(signer, async (tx, amount) => {
+ * const outputIndex = tx.addOutput({
+ * cellOutput: { lock: changeLock, type: coin.script },
+ * outputData: "0x",
+ * }) - 1;
+ * return coin.setAmount(tx, outputIndex, amount);
+ * }, tx);
+ * ```
+ */
+ async complete(
+ signer: ccc.Signer,
+ change: (
+ tx: ccc.Transaction,
+ amount: ccc.Num,
+ ) => ccc.TransactionLike | Promise,
+ txLike?: ccc.TransactionLike | null,
+ options?: { shouldAddInputs?: boolean | null },
+ ): Promise {
+ let tx = ccc.Transaction.from(txLike ?? {});
+ tx.addCellDeps(this.cellDeps);
+
+ /* === Figure out the amount to change === */
+ if (options?.shouldAddInputs ?? true) {
+ const res = await this.completeInputsByAmount(signer, tx);
+ tx = res.tx;
+ }
+
+ const amountExcess =
+ (await this.getAmountBurned(tx)) -
+ (await this.getIntendedAmountBurned(tx));
+
+ if (amountExcess < ccc.Zero) {
+ throw new ErrorCoinInsufficient({
+ amount: -amountExcess,
+ type: this.script,
+ });
+ } else if (amountExcess === ccc.Zero) {
+ // No change needed — inputs and outputs are perfectly balanced
+ return tx;
+ }
+ /* === Some amount need to change === */
+
+ if (!(options?.shouldAddInputs ?? true)) {
+ // Caller manages inputs manually; apply change with current amount as-is
+ return ccc.Transaction.from(await change(tx, amountExcess));
+ }
+
+ // Clone the transaction and apply change to measure the extra output capacity
+ // the change cell requires, then source inputs, and finally apply change to
+ // the real transaction with the correct final amount.
+ let cloned = tx.clone();
+ const capacityBefore = tx.getOutputsCapacity();
+ cloned = ccc.Transaction.from(await change(cloned, amountExcess));
+ const extraCapacity = cloned.getOutputsCapacity() - capacityBefore;
+
+ const res2 = await this.completeInputsByAmount(
+ signer,
+ tx,
+ ccc.Zero,
+ extraCapacity,
+ );
+ tx = res2.tx;
+
+ return ccc.Transaction.from(
+ await change(
+ tx,
+ (await this.getAmountBurned(tx)) -
+ (await this.getIntendedAmountBurned(tx)),
+ ),
+ );
+ }
+
+ /**
+ * Completes the transaction by writing the excess Coin amount into the existing output at
+ * `index`. The output must already be a valid Coin cell with this type script.
+ *
+ * @throws {Error} If the output at `index` does not exist or is not a valid Coin.
+ *
+ * @example
+ * ```typescript
+ * // Change goes into output 1 of the transaction
+ * const completedTx = await coin.completeChangeToOutput(signer, 1, tx);
+ * ```
+ */
+ async completeChangeToOutput(
+ signer: ccc.Signer,
+ indexLike: ccc.NumLike,
+ txLike?: ccc.TransactionLike | null,
+ options?: {
+ shouldAddInputs?: boolean | null;
+ },
+ ) {
+ const tx = ccc.Transaction.from(txLike ?? {});
+ const index = Number(ccc.numFrom(indexLike));
+
+ const cellOutput = tx.outputs[index];
+ if (!cellOutput) {
+ throw new Error(`Output at index ${index} does not exist`);
+ }
+
+ const output = ccc.CellAny.from({
+ cellOutput: cellOutput.clone(),
+ outputData: tx.outputsData[index],
+ });
+
+ if (!(await this.isCoin(output))) {
+ throw new Error("Change output must be a Coin");
+ }
+
+ return this.complete(
+ signer,
+ async (tx, amount) => {
+ tx.setOutput(index, output.clone());
+ return this.setAmount(tx, index, await this.amountFrom(output, amount));
+ },
+ tx,
+ options,
+ );
+ }
+
+ /**
+ * Completes the transaction by creating a new change output locked to `changeLike`.
+ *
+ * @example
+ * ```typescript
+ * const { script: changeLock } = await signer.getRecommendedAddressObj();
+ * const completedTx = await coin.completeChangeToLock(signer, changeLock, tx);
+ * ```
+ */
+ async completeChangeToLock(
+ signer: ccc.Signer,
+ changeLike: ccc.ScriptLike,
+ txLike?: ccc.TransactionLike | null,
+ options?: {
+ shouldAddInputs?: boolean | null;
+ },
+ ): Promise {
+ const change = ccc.Script.from(changeLike);
+
+ return this.complete(
+ signer,
+ async (tx, amount) => {
+ const outputIndex =
+ tx.addOutput({ lock: change, type: this.script }) - 1;
+ return this.setAmount(tx, outputIndex, amount);
+ },
+ txLike,
+ options,
+ );
+ }
+
+ /**
+ * Convenience wrapper around `completeChangeToLock` using the signer's recommended address.
+ *
+ * @example
+ * ```typescript
+ * const completedTx = await coin.completeBy(signer, tx);
+ * await completedTx.completeFeeBy(signer);
+ * await signer.sendTransaction(completedTx);
+ * ```
+ *
+ * @see {@link completeChangeToLock} for more control over the change destination.
+ */
+ async completeBy(
+ signer: ccc.Signer,
+ tx?: ccc.TransactionLike | null,
+ options?: {
+ shouldAddInputs?: boolean | null;
+ },
+ ) {
+ const { script } = await signer.getRecommendedAddressObj();
+
+ return this.completeChangeToLock(signer, script, tx, options);
+ }
+
+ /**
+ * Make the transaction perform transfer actions to the specified recipients.
+ *
+ * @returns The updated transaction with added transfer outputs and CoBuild actions.
+ *
+ * @example
+ * ```typescript
+ * const tx = await coin.transfer([
+ * { to: recipientLock, amount: 100n },
+ * ]);
+ * const completedTx = await coin.completeBy(signer, tx);
+ * await completedTx.completeFeeBy(signer);
+ * await signer.sendTransaction(completedTx);
+ * ```
+ */
+ async transfer(
+ transfers: {
+ to: ccc.ScriptLike;
+ amount: ccc.NumLike;
+ }[],
+ txLike?: ccc.TransactionLike | null,
+ ): Promise {
+ let tx = ccc.Transaction.from(txLike ?? {});
+
+ for (const { to, amount } of transfers) {
+ const outputIndex = tx.addOutput({ lock: to, type: this.script }) - 1;
+ tx = await this.setAmount(tx, outputIndex, amount);
+ }
+
+ const { tx: txWithActions } = await this.coBuild.appendActions(
+ transfers.map((transfer) =>
+ CoinAction.from({
+ type: "Transfer",
+ value: transfer,
+ }),
+ ),
+ tx,
+ );
+ return txWithActions;
+ }
+
+ /**
+ * Make the transaction perform mint actions to the specified recipients.
+ *
+ * @returns The updated transaction with added mint outputs and CoBuild actions.
+ *
+ * @example
+ * ```typescript
+ * const tx = await coin.mint([
+ * { to: recipientLock, amount: 100n },
+ * ]);
+ * const completedTx = await coin.completeBy(signer, tx);
+ * await completedTx.completeFeeBy(signer);
+ * await signer.sendTransaction(completedTx);
+ * ```
+ */
+ async mint(
+ mints: {
+ to: ccc.ScriptLike;
+ amount: ccc.NumLike;
+ }[],
+ txLike?: ccc.TransactionLike | null,
+ ): Promise {
+ let tx = ccc.Transaction.from(txLike ?? {});
+
+ for (const { to, amount } of mints) {
+ const outputIndex = tx.addOutput({ lock: to, type: this.script }) - 1;
+ tx = await this.setAmount(tx, outputIndex, amount);
+ }
+
+ const { tx: txWithActions } = await this.coBuild.appendActions(
+ mints.map((mint) =>
+ CoinAction.from({
+ type: "Mint",
+ value: mint,
+ }),
+ ),
+ tx,
+ );
+ return txWithActions;
+ }
+
+ /**
+ * Make the transaction perform burn actions for the specified amount.
+ *
+ * @returns The updated transaction with appended CoBuild actions.
+ *
+ * @example
+ * ```typescript
+ * const tx = await coin.burn(100n);
+ * const completedTx = await coin.completeBy(signer, tx);
+ * await completedTx.completeFeeBy(signer);
+ * await signer.sendTransaction(completedTx);
+ * ```
+ */
+ async burn(
+ amount: ccc.NumLike,
+ txLike?: ccc.TransactionLike | null,
+ ): Promise {
+ const { tx } = await this.coBuild.appendActions(
+ CoinAction.from({
+ type: "Burn",
+ value: { amount },
+ }),
+ txLike,
+ );
+ return tx;
+ }
+}
diff --git a/packages/coin/src/coin/coinInfo.ts b/packages/coin/src/coin/coinInfo.ts
new file mode 100644
index 000000000..a9ffbe4ca
--- /dev/null
+++ b/packages/coin/src/coin/coinInfo.ts
@@ -0,0 +1,130 @@
+import { ccc } from "@ckb-ccc/core";
+
+/**
+ * Represents a Coin information-like object.
+ * This is used as a flexible input for creating `CoinInfo` instances.
+ *
+ * @public
+ * @category Coin
+ */
+export type CoinInfoLike =
+ | {
+ /** The Coin amount. */
+ amount?: ccc.NumLike | null;
+ /** The total CKB capacity of the Coins. */
+ capacity?: ccc.NumLike | null;
+ /** The number of Coins. */
+ count?: number | null;
+ }
+ | undefined
+ | null;
+
+/**
+ * Represents aggregated information about a set of Coins.
+ * This class encapsulates the total amount, total CKB capacity, and the number of cells.
+ *
+ * @public
+ * @category Coin
+ */
+export class CoinInfo {
+ /**
+ * Creates an instance of CoinInfo.
+ *
+ * @param amount - The total Coin amount.
+ * @param capacity - The total CKB capacity of the Coins.
+ * @param count - The number of Coins.
+ */
+ constructor(
+ public amount: ccc.Num,
+ public capacity: ccc.Num,
+ public count: number,
+ ) {}
+
+ /**
+ * Creates a `CoinInfo` instance from a `CoinInfoLike` object.
+ *
+ * @param infoLike - A `CoinInfoLike` object or an instance of `CoinInfo`.
+ * @returns A new `CoinInfo` instance.
+ */
+ static from(infoLike?: CoinInfoLike) {
+ if (infoLike instanceof CoinInfo) {
+ return infoLike;
+ }
+
+ return new CoinInfo(
+ ccc.numFrom(infoLike?.amount ?? ccc.Zero),
+ ccc.numFrom(infoLike?.capacity ?? ccc.Zero),
+ infoLike?.count ?? 0,
+ );
+ }
+
+ /**
+ * Creates a default `CoinInfo` instance with all values set to zero.
+ * @returns A new `CoinInfo` instance with zero amount, capacity, and count.
+ */
+ static default() {
+ return CoinInfo.from();
+ }
+
+ /**
+ * Clones the `CoinInfo` instance.
+ * @returns A new `CoinInfo` instance that is a copy of the current one.
+ */
+ clone() {
+ return new CoinInfo(this.amount, this.capacity, this.count);
+ }
+
+ /**
+ * Adds the values from another `CoinInfoLike` object to this instance (in-place).
+ *
+ * @param infoLike - The `CoinInfoLike` object to add.
+ * @returns The current, modified `CoinInfo` instance.
+ */
+ addAssign(infoLike: CoinInfoLike) {
+ const info = CoinInfo.from(infoLike);
+
+ this.amount += info.amount;
+ this.capacity += info.capacity;
+ this.count += info.count;
+
+ return this;
+ }
+
+ /**
+ * Creates a new `CoinInfo` instance by adding the values from another `CoinInfoLike` object to the current one.
+ * This method is not in-place.
+ *
+ * @param infoLike - The `CoinInfoLike` object to add.
+ * @returns A new `CoinInfo` instance with the summed values.
+ */
+ add(infoLike: CoinInfoLike) {
+ return this.clone().addAssign(infoLike);
+ }
+
+ /**
+ * Subtracts the values from another `CoinInfoLike` object from this instance (in-place).
+ *
+ * @param infoLike - The `CoinInfoLike` object to subtract.
+ * @returns The current, modified `CoinInfo` instance.
+ */
+ subAssign(infoLike: CoinInfoLike) {
+ const info = CoinInfo.from(infoLike);
+
+ this.amount -= info.amount;
+ this.capacity -= info.capacity;
+ this.count -= info.count;
+
+ return this;
+ }
+
+ /**
+ * Creates a new `CoinInfo` instance by subtracting the values of another `CoinInfoLike` object from the current one.
+ * This method is not in-place.
+ *
+ * @param infoLike - The `CoinInfoLike` object to subtract.
+ * @returns A new `CoinInfo` instance with the subtracted values.
+ */
+ sub(infoLike: CoinInfoLike) {
+ return this.clone().subAssign(infoLike);
+ }
+}
diff --git a/packages/coin/src/coin/error.ts b/packages/coin/src/coin/error.ts
new file mode 100644
index 000000000..8cd4d7ad3
--- /dev/null
+++ b/packages/coin/src/coin/error.ts
@@ -0,0 +1,83 @@
+import { ccc } from "@ckb-ccc/core";
+
+/**
+ * Error thrown when there are insufficient Coin to complete a transaction.
+ * This error provides detailed information about the shortfall, including the
+ * exact amount needed, the Coin type script, and an optional custom reason.
+ *
+ * @public
+ * @category Error
+ * @category Coin
+ *
+ * @example
+ * ```typescript
+ * // This error is typically thrown automatically by Coin methods
+ * try {
+ * await coin.completeInputsByAmount(signer, tx);
+ * } catch (error) {
+ * if (error instanceof ErrorCoinInsufficient) {
+ * console.log(`Error: ${error.message}`);
+ * console.log(`Shortfall: ${error.amount} Coin tokens`);
+ * console.log(`Coin type script: ${error.type.toHex()}`);
+ * }
+ * }
+ * ```
+ */
+export class ErrorCoinInsufficient extends Error {
+ /**
+ * The amount of Coin that is insufficient (shortfall amount).
+ * This represents how many more Coin tokens are needed to complete the operation.
+ */
+ public readonly amount: ccc.Num;
+
+ /**
+ * The type script of the Coin that has insufficient amount.
+ * This identifies which specific Coin token is lacking sufficient funds.
+ */
+ public readonly type: ccc.Script;
+
+ /**
+ * Creates a new ErrorCoinInsufficient instance.
+ *
+ * @param info - Configuration object for the error
+ * @param info.amount - The amount of Coin that is insufficient (shortfall amount)
+ * @param info.type - The type script of the Coin that has insufficient amount
+ * @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 ErrorCoinInsufficient({
+ * amount: ccc.numFrom(1000),
+ * type: coinScript,
+ * reason: "Custom insufficient amount message"
+ * });
+ *
+ * // More commonly, catch the error when it's thrown by Coin methods
+ * try {
+ * const result = await coin.completeInputsByAmount(signer, tx);
+ * } catch (error) {
+ * if (error instanceof ErrorCoinInsufficient) {
+ * // Handle the insufficient amount error
+ * console.error(`Insufficient Coin: 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;
+ }
+}
diff --git a/packages/coin/src/coin/index.ts b/packages/coin/src/coin/index.ts
new file mode 100644
index 000000000..6bf939aae
--- /dev/null
+++ b/packages/coin/src/coin/index.ts
@@ -0,0 +1,3 @@
+export * from "./coin.js";
+export * from "./coinInfo.js";
+export * from "./error.js";
diff --git a/packages/coin/src/index.ts b/packages/coin/src/index.ts
new file mode 100644
index 000000000..a3b9432a5
--- /dev/null
+++ b/packages/coin/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./barrel.js";
+export * as coin from "./barrel.js";
diff --git a/packages/coin/tsconfig.json b/packages/coin/tsconfig.json
new file mode 100644
index 000000000..3399c6f67
--- /dev/null
+++ b/packages/coin/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "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,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "rootDir": "./src"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/coin/tsdown.config.mts b/packages/coin/tsdown.config.mts
new file mode 100644
index 000000000..9453d8b6d
--- /dev/null
+++ b/packages/coin/tsdown.config.mts
@@ -0,0 +1,41 @@
+import { defineConfig } from "tsdown";
+
+const common = {
+ minify: true,
+ dts: true,
+ platform: "neutral" as const,
+ sourcemap: true,
+ exports: true,
+};
+
+const entry = {
+ index: "src/index.ts",
+ barrel: "src/barrel.ts",
+} as const;
+
+const bundleDeps: string[] = [];
+
+export default defineConfig(
+ (
+ [
+ {
+ entry,
+ deps: {
+ onlyBundle: [] as string[],
+ },
+ format: "esm",
+ copy: "./misc/basedirs/dist/*",
+ },
+ {
+ entry,
+ deps: {
+ alwaysBundle: bundleDeps,
+ onlyBundle: bundleDeps,
+ },
+ format: "cjs",
+ outDir: "dist.commonjs",
+ copy: "./misc/basedirs/dist.commonjs/*",
+ },
+ ] as const
+ ).map((c) => ({ ...c, ...common })),
+);
diff --git a/packages/coin/typedoc.json b/packages/coin/typedoc.json
new file mode 100644
index 000000000..eacdf24a3
--- /dev/null
+++ b/packages/coin/typedoc.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://typedoc.org/schema.json",
+ "entryPoints": ["./src/index.ts"],
+ "extends": ["../../typedoc.base.json"],
+ "name": "@ckb-ccc coin"
+}
diff --git a/packages/coin/vitest.config.ts b/packages/coin/vitest.config.ts
new file mode 100644
index 000000000..dc6a58785
--- /dev/null
+++ b/packages/coin/vitest.config.ts
@@ -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/shell/package.json b/packages/shell/package.json
index 9b533184f..7a28654ae 100644
--- a/packages/shell/package.json
+++ b/packages/shell/package.json
@@ -56,6 +56,7 @@
"access": "public"
},
"dependencies": {
+ "@ckb-ccc/coin": "workspace:*",
"@ckb-ccc/core": "workspace:*",
"@ckb-ccc/co-build": "workspace:*",
"@ckb-ccc/did-ckb": "workspace:*",
diff --git a/packages/shell/src/barrel.ts b/packages/shell/src/barrel.ts
index 0be10ba5c..cbdb95d5f 100644
--- a/packages/shell/src/barrel.ts
+++ b/packages/shell/src/barrel.ts
@@ -1,4 +1,5 @@
export { coBuild } from "@ckb-ccc/co-build";
+export { coin } from "@ckb-ccc/coin";
export * from "@ckb-ccc/core/barrel";
export { didCkb } from "@ckb-ccc/did-ckb";
export { spore } from "@ckb-ccc/spore";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ccbe6df21..ed12ed857 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -175,7 +175,7 @@ importers:
version: 4.3.0(prettier@3.9.6)(typescript@6.0.3)
tsdown:
specifier: ^0.22.3
- version: 0.22.12(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))
+ version: 0.22.12(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.22(synckit@0.11.13))
typescript:
specifier: ^6.0.3
version: 6.0.3
@@ -186,6 +186,49 @@ importers:
specifier: ^4.1.9
version: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))
+ packages/coin:
+ dependencies:
+ '@ckb-ccc/co-build':
+ specifier: workspace:*
+ version: link:../co-build
+ '@ckb-ccc/core':
+ specifier: workspace:*
+ version: link:../core
+ devDependencies:
+ '@eslint/js':
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@10.7.0(jiti@2.7.0))
+ '@types/node':
+ specifier: ^26.0.0
+ version: 26.0.1
+ eslint:
+ specifier: ^10.5.0
+ version: 10.7.0(jiti@2.7.0)
+ eslint-config-prettier:
+ specifier: ^10.1.8
+ version: 10.1.8(eslint@10.7.0(jiti@2.7.0))
+ eslint-plugin-prettier:
+ specifier: ^5.5.6
+ version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.7.0(jiti@2.7.0)))(eslint@10.7.0(jiti@2.7.0))(prettier@3.9.6)
+ prettier:
+ specifier: ^3.8.4
+ version: 3.9.6
+ prettier-plugin-organize-imports:
+ specifier: ^4.3.0
+ version: 4.3.0(prettier@3.9.6)(typescript@6.0.3)
+ tsdown:
+ specifier: ^0.22.3
+ version: 0.22.12(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.22(synckit@0.11.13))
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+ typescript-eslint:
+ specifier: ^8.61.1
+ version: 8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
+ vitest:
+ specifier: ^4.1.9
+ version: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))
+
packages/connector:
dependencies:
'@ckb-ccc/ccc':
@@ -1100,6 +1143,9 @@ importers:
'@ckb-ccc/co-build':
specifier: workspace:*
version: link:../co-build
+ '@ckb-ccc/coin':
+ specifier: workspace:*
+ version: link:../coin
'@ckb-ccc/core':
specifier: workspace:*
version: link:../core
@@ -4283,6 +4329,14 @@ packages:
typescript:
optional: true
+ '@typescript-eslint/eslint-plugin@8.61.1':
+ resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.61.1
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/eslint-plugin@8.65.0':
resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4301,6 +4355,13 @@ packages:
typescript:
optional: true
+ '@typescript-eslint/parser@8.61.1':
+ resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/parser@8.65.0':
resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4308,6 +4369,12 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/project-service@8.61.1':
+ resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/project-service@8.65.0':
resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4318,10 +4385,20 @@ packages:
resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==}
engines: {node: ^16.0.0 || >=18.0.0}
+ '@typescript-eslint/scope-manager@8.61.1':
+ resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/scope-manager@8.65.0':
resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/tsconfig-utils@8.61.1':
+ resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/tsconfig-utils@8.65.0':
resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4338,6 +4415,13 @@ packages:
typescript:
optional: true
+ '@typescript-eslint/type-utils@8.61.1':
+ resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/type-utils@8.65.0':
resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4349,6 +4433,10 @@ packages:
resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==}
engines: {node: ^16.0.0 || >=18.0.0}
+ '@typescript-eslint/types@8.61.1':
+ resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/types@8.65.0':
resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4362,6 +4450,12 @@ packages:
typescript:
optional: true
+ '@typescript-eslint/typescript-estree@8.61.1':
+ resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/typescript-estree@8.65.0':
resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4374,6 +4468,13 @@ packages:
peerDependencies:
eslint: ^7.0.0 || ^8.0.0
+ '@typescript-eslint/utils@8.61.1':
+ resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/utils@8.65.0':
resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4385,6 +4486,10 @@ packages:
resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==}
engines: {node: ^16.0.0 || >=18.0.0}
+ '@typescript-eslint/visitor-keys@8.61.1':
+ resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/visitor-keys@8.65.0':
resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -9302,6 +9407,13 @@ packages:
peerDependencies:
typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x
+ typescript-eslint@8.61.1:
+ resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
typescript-eslint@8.65.0:
resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -12720,6 +12832,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.61.1
+ '@typescript-eslint/type-utils': 8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.61.1
+ eslint: 10.7.0(jiti@2.7.0)
+ ignore: 7.0.6
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@6.0.3)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -12765,6 +12893,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/parser@8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.61.1
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.61.1
+ debug: 4.4.3
+ eslint: 10.7.0(jiti@2.7.0)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))':
dependencies:
'@typescript-eslint/scope-manager': 8.65.0
@@ -12789,6 +12929,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/project-service@8.61.1(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.1
+ debug: 4.4.3
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/project-service@8.65.0(@typescript/typescript6@6.0.2)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.65.0(@typescript/typescript6@6.0.2)
@@ -12812,11 +12961,20 @@ snapshots:
'@typescript-eslint/types': 6.21.0
'@typescript-eslint/visitor-keys': 6.21.0
+ '@typescript-eslint/scope-manager@8.61.1':
+ dependencies:
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/visitor-keys': 8.61.1
+
'@typescript-eslint/scope-manager@8.65.0':
dependencies:
'@typescript-eslint/types': 8.65.0
'@typescript-eslint/visitor-keys': 8.65.0
+ '@typescript-eslint/tsconfig-utils@8.61.1(typescript@6.0.3)':
+ dependencies:
+ typescript: 6.0.3
+
'@typescript-eslint/tsconfig-utils@8.65.0(@typescript/typescript6@6.0.2)':
dependencies:
typescript: '@typescript/typescript6@6.0.2'
@@ -12837,6 +12995,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/type-utils@8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
+ debug: 4.4.3
+ eslint: 10.7.0(jiti@2.7.0)
+ ts-api-utils: 2.5.0(typescript@6.0.3)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/type-utils@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))':
dependencies:
'@typescript-eslint/types': 8.65.0
@@ -12863,6 +13033,8 @@ snapshots:
'@typescript-eslint/types@6.21.0': {}
+ '@typescript-eslint/types@8.61.1': {}
+
'@typescript-eslint/types@8.65.0': {}
'@typescript-eslint/typescript-estree@6.21.0(@typescript/typescript6@6.0.2)':
@@ -12880,6 +13052,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.61.1(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/visitor-keys': 8.61.1
+ debug: 4.4.3
+ minimatch: 10.2.5
+ semver: 7.8.5
+ tinyglobby: 0.2.17
+ ts-api-utils: 2.5.0(typescript@6.0.3)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/typescript-estree@8.65.0(@typescript/typescript6@6.0.2)':
dependencies:
'@typescript-eslint/project-service': 8.65.0(@typescript/typescript6@6.0.2)
@@ -12924,6 +13111,17 @@ snapshots:
- supports-color
- typescript
+ '@typescript-eslint/utils@8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0))
+ '@typescript-eslint/scope-manager': 8.61.1
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
+ eslint: 10.7.0(jiti@2.7.0)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/utils@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0))
@@ -12951,6 +13149,11 @@ snapshots:
'@typescript-eslint/types': 6.21.0
eslint-visitor-keys: 3.4.3
+ '@typescript-eslint/visitor-keys@8.61.1':
+ dependencies:
+ '@typescript-eslint/types': 8.61.1
+ eslint-visitor-keys: 5.0.1
+
'@typescript-eslint/visitor-keys@8.65.0':
dependencies:
'@typescript-eslint/types': 8.65.0
@@ -18626,7 +18829,7 @@ snapshots:
- oxc-resolver
- vue-tsc
- tsdown@0.22.12(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)):
+ tsdown@0.22.12(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.22(synckit@0.11.13)):
dependencies:
ansis: 4.3.1
cac: 7.0.0
@@ -18756,6 +18959,17 @@ snapshots:
typescript: '@typescript/typescript6@6.0.2'
yaml: 2.9.0
+ typescript-eslint@8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.7.0(jiti@2.7.0)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
typescript-eslint@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)):
dependencies:
'@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))
diff --git a/typedoc.config.mjs b/typedoc.config.mjs
index 35f05db43..56f4a2d05 100644
--- a/typedoc.config.mjs
+++ b/typedoc.config.mjs
@@ -6,6 +6,7 @@ const config = {
"packages/core",
"packages/type-id",
"packages/ssri",
+ "packages/coin",
"packages/udt",
"packages/spore",
"packages/did-ckb",
diff --git a/vitest.config.mts b/vitest.config.mts
index 31436983b..a565011f2 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -1,6 +1,12 @@
import { defineConfig, coverageConfigDefaults } from "vitest/config";
-const packages = ["packages/core", "packages/did-ckb", "packages/type-id", "packages/co-build"];
+const packages = [
+ "packages/core",
+ "packages/did-ckb",
+ "packages/type-id",
+ "packages/co-build",
+ "packages/coin",
+];
export default defineConfig({
test: {
From 3edf75a4784b46cfe466ad7e922e2fce13493919 Mon Sep 17 00:00:00 2001
From: Hanssen0 <0@hanssen0.com>
Date: Tue, 14 Jul 2026 01:51:52 +0800
Subject: [PATCH 2/2] feat(coin): xUDT support
---
packages/coin/README.md | 19 ++
packages/coin/src/barrel.ts | 1 +
packages/coin/src/xUdt/args.ts | 230 +++++++++++++++++++
packages/coin/src/xUdt/coinXUdt.ts | 148 ++++++++++++
packages/coin/src/xUdt/index.ts | 2 +
packages/coin/src/xUdt/xUdt.test.ts | 345 ++++++++++++++++++++++++++++
6 files changed, 745 insertions(+)
create mode 100644 packages/coin/src/xUdt/args.ts
create mode 100644 packages/coin/src/xUdt/coinXUdt.ts
create mode 100644 packages/coin/src/xUdt/index.ts
create mode 100644 packages/coin/src/xUdt/xUdt.test.ts
diff --git a/packages/coin/README.md b/packages/coin/README.md
index 61338097e..057a13703 100644
--- a/packages/coin/README.md
+++ b/packages/coin/README.md
@@ -65,6 +65,17 @@ const sUdt = await Coin.new({
},
client,
});
+
+// Instantiation of xUDT via CoinXUdt with structured args
+import { CoinXUdt } from "@ckb-ccc/coin";
+
+const xUdt = await CoinXUdt.new({
+ xUdtArgs: {
+ ownerScriptHash: ownerLock.hash(),
+ ownerModeOutputType: true,
+ },
+ client,
+});
```
### Query balance
@@ -114,6 +125,14 @@ const { script: changeLock } = await signer.getRecommendedAddressObj();
const completedTx = await coin.completeChangeToLock(signer, changeLock, tx);
```
+## Extensible UDT (xUDT) Support
+
+`CoinXUdt` extends `Coin` to provide specialized support for RFC 52 extensible UDT (xUDT).
+
+- **Args Encoding/Decoding**: Provides `CoinXUdtArgs` to correctly build and parse xUDT type script arguments, supporting owner-mode flags.
+- **Default Known Script**: When `knownScript` is omitted, `CoinXUdt` uses `ccc.KnownScript.XUdt`. A complete `script` with `codeHash` and `hashType` takes priority over this shorthand. Pass `script.args` to use existing xUDT args, or pass `xUdtArgs` to build args from `ownerScriptHash` and `flags`.
+- **Underlying Coin Compatibility**: Inherits all query, transfer, and transaction completion methods of the generic `Coin` class.
+
## Learn More?
Check the [package documentation](https://docs.ckbccc.com/docs/packages/protocol-sdks/coin) and [API reference](https://api.ckbccc.com/classes/_ckb-ccc_coin.coin) for more details.
diff --git a/packages/coin/src/barrel.ts b/packages/coin/src/barrel.ts
index a519fe010..423b04601 100644
--- a/packages/coin/src/barrel.ts
+++ b/packages/coin/src/barrel.ts
@@ -1,2 +1,3 @@
export * from "./coBuild.js";
export * from "./coin/index.js";
+export * from "./xUdt/index.js";
diff --git a/packages/coin/src/xUdt/args.ts b/packages/coin/src/xUdt/args.ts
new file mode 100644
index 000000000..298377d01
--- /dev/null
+++ b/packages/coin/src/xUdt/args.ts
@@ -0,0 +1,230 @@
+import { ccc } from "@ckb-ccc/core";
+
+const CoinXUdtExtensionScriptVecHashCodec = ccc.Codec.from<
+ ccc.HexLike,
+ ccc.Hex
+>({
+ byteLength: 20,
+ encode: ccc.bytesFrom,
+ decode: ccc.hexFrom,
+ from: ccc.hexFrom,
+});
+
+/** Molecule-compatible union codec for the extension part of xUDT args. */
+export const CoinXUdtExtensionCodec = ccc.mol.union(
+ {
+ Empty: ccc.codecPadding(0),
+ ScriptVec: ccc.ScriptVec,
+ ScriptVecHash: CoinXUdtExtensionScriptVecHashCodec,
+ },
+ {
+ Empty: 0,
+ ScriptVec: 1,
+ ScriptVecHash: 2,
+ },
+);
+
+/** Object-like representation of the extension part of xUDT args. */
+export type CoinXUdtExtensionLike = ccc.EncodableType<
+ typeof CoinXUdtExtensionCodec
+>;
+
+/**
+ * Extension configuration encoded by the lower 29 bits of xUDT flags.
+ *
+ * The variants map directly to RFC 52:
+ *
+ * - `Empty`: no extension data
+ * - `ScriptVec`: a molecule-serialized extension `ScriptVec`
+ * - `ScriptVecHash`: the 20-byte blake160 hash of a `ScriptVec`
+ *
+ * @public
+ */
+@ccc.codec(CoinXUdtExtensionCodec)
+export class CoinXUdtExtension extends ccc.Entity.BaseUnion<
+ typeof CoinXUdtExtensionCodec,
+ CoinXUdtExtensionLike,
+ CoinXUdtExtension
+>() {
+ /** Creates an extension value with no extension scripts. @public */
+ static empty(): CoinXUdtExtension {
+ return CoinXUdtExtension.from({ type: "Empty", value: undefined });
+ }
+
+ /** Creates an extension value containing a molecule `ScriptVec`. @public */
+ static fromScriptVec(scripts: ccc.ScriptLike[]): CoinXUdtExtension {
+ return CoinXUdtExtension.from({ type: "ScriptVec", value: scripts });
+ }
+
+ /** Creates an extension value containing a 20-byte ScriptVec hash. @public */
+ static fromScriptVecHash(hash: ccc.HexLike): CoinXUdtExtension {
+ return CoinXUdtExtension.from({ type: "ScriptVecHash", value: hash });
+ }
+}
+
+/** Object-like representation of xUDT type script args. @public */
+export type CoinXUdtArgsLike = {
+ /** The 32-byte hash of the owner script. */
+ ownerScriptHash: ccc.HexLike;
+
+ /** Disable input-lock owner-mode validation. Defaults to `false`. */
+ ownerModeInputLockDisabled?: boolean | null;
+
+ /** Enable owner-mode validation through an output type script. */
+ ownerModeOutputType?: boolean | null;
+
+ /** Enable owner-mode validation through an input type script. */
+ ownerModeInputType?: boolean | null;
+
+ /** Extension mode and its associated data. Defaults to `Empty`. */
+ extensionData?: CoinXUdtExtensionLike | null;
+
+ /**
+ * Whether to retain the 4-byte flags field when it would otherwise be zero.
+ * Defaults to `false`.
+ */
+ shouldKeepFlag?: boolean | null;
+};
+
+const CoinXUdtArgsCodec = ccc.Codec.from({
+ encode(value) {
+ const args = CoinXUdtArgs.from(value);
+ const extension = args.extensionData.toBytes();
+ const extensionFlags = Number(ccc.numLeFromBytes(extension.slice(0, 4)));
+ const flags =
+ extensionFlags |
+ (args.ownerModeInputLockDisabled ? 0x20000000 : 0) |
+ (args.ownerModeOutputType ? 0x40000000 : 0) |
+ (args.ownerModeInputType ? 0x80000000 : 0);
+
+ const ownerScriptHash = ccc.bytesFrom(args.ownerScriptHash);
+ if (flags === 0 && !args.shouldKeepFlag) {
+ return ownerScriptHash;
+ }
+
+ return ccc.bytesConcat(
+ ownerScriptHash,
+ ccc.numLeToBytes(flags, 4),
+ extension.slice(4),
+ );
+ },
+ decode(value) {
+ const argsBytes = ccc.bytesFrom(value);
+ if (argsBytes.length === 32) {
+ return {
+ ownerScriptHash: ccc.hexFrom(argsBytes),
+ ownerModeInputLockDisabled: false,
+ ownerModeOutputType: false,
+ ownerModeInputType: false,
+ extensionData: CoinXUdtExtension.empty(),
+ shouldKeepFlag: false,
+ };
+ }
+
+ if (argsBytes.length >= 36) {
+ const flags = Number(ccc.numLeFromBytes(argsBytes.slice(32, 36)));
+ const extension = CoinXUdtExtension.fromBytes(
+ ccc.bytesConcat(
+ ccc.numLeToBytes(flags & 0x1fffffff, 4),
+ argsBytes.slice(36),
+ ),
+ );
+
+ return {
+ ownerScriptHash: ccc.hexFrom(argsBytes.slice(0, 32)),
+ ownerModeInputLockDisabled: (flags & 0x20000000) !== 0,
+ ownerModeOutputType: (flags & 0x40000000) !== 0,
+ ownerModeInputType: (flags & 0x80000000) !== 0,
+ extensionData: extension,
+ shouldKeepFlag: flags === 0 && argsBytes.length === 36,
+ };
+ }
+
+ throw new Error(
+ `Invalid xUDT args length: expected 32 or at least 36 bytes, got ${argsBytes.length}`,
+ );
+ },
+ from(value: CoinXUdtArgsLike) {
+ return CoinXUdtArgs.from(value);
+ },
+});
+
+/**
+ * Encoded xUDT type script arguments.
+ *
+ * The owner-mode bits are exposed as boolean fields, while the lower extension
+ * flags and their associated data are represented by {@link CoinXUdtExtension}.
+ *
+ * @public
+ */
+@ccc.codec(CoinXUdtArgsCodec)
+export class CoinXUdtArgs extends ccc.Entity.Base<
+ CoinXUdtArgsLike,
+ CoinXUdtArgs
+>() {
+ /** The normalized 32-byte owner script hash. @public */
+ public ownerScriptHash: ccc.Hex;
+
+ /** Whether input-lock owner-mode validation is disabled. @public */
+ public ownerModeInputLockDisabled: boolean;
+
+ /** Whether output-type owner-mode validation is enabled. @public */
+ public ownerModeOutputType: boolean;
+
+ /** Whether input-type owner-mode validation is enabled. @public */
+ public ownerModeInputType: boolean;
+
+ /** The normalized extension union. @public */
+ public extensionData: CoinXUdtExtension;
+
+ /** Whether an otherwise-zero flags field should be retained. @public */
+ public shouldKeepFlag: boolean;
+
+ constructor({
+ ownerScriptHash,
+ ownerModeInputLockDisabled,
+ ownerModeOutputType,
+ ownerModeInputType,
+ extensionData,
+ shouldKeepFlag,
+ }: ccc.DecodedType) {
+ super();
+
+ this.ownerScriptHash = ownerScriptHash;
+ this.ownerModeInputLockDisabled = ownerModeInputLockDisabled;
+ this.ownerModeOutputType = ownerModeOutputType;
+ this.ownerModeInputType = ownerModeInputType;
+ this.extensionData = extensionData;
+ this.shouldKeepFlag = shouldKeepFlag;
+ }
+
+ /**
+ * Normalizes an object-like xUDT args value.
+ *
+ * @throws if `ownerScriptHash` is not exactly 32 bytes.
+ * @public
+ */
+ static from(value: CoinXUdtArgsLike): CoinXUdtArgs {
+ if (value instanceof CoinXUdtArgs) {
+ return value;
+ }
+
+ const ownerScriptHash = ccc.bytesFrom(value.ownerScriptHash);
+ if (ownerScriptHash.length !== 32) {
+ throw new Error(
+ `Invalid owner script hash length: expected 32 bytes, got ${ownerScriptHash.length}`,
+ );
+ }
+
+ return new CoinXUdtArgs({
+ ownerScriptHash: ccc.hexFrom(ownerScriptHash),
+ ownerModeInputLockDisabled: value.ownerModeInputLockDisabled ?? false,
+ ownerModeOutputType: value.ownerModeOutputType ?? false,
+ ownerModeInputType: value.ownerModeInputType ?? false,
+ extensionData: CoinXUdtExtension.from(
+ value.extensionData ?? { type: "Empty", value: undefined },
+ ),
+ shouldKeepFlag: value.shouldKeepFlag ?? false,
+ });
+ }
+}
diff --git a/packages/coin/src/xUdt/coinXUdt.ts b/packages/coin/src/xUdt/coinXUdt.ts
new file mode 100644
index 000000000..fb1b65e19
--- /dev/null
+++ b/packages/coin/src/xUdt/coinXUdt.ts
@@ -0,0 +1,148 @@
+import { ccc } from "@ckb-ccc/core";
+import {
+ Coin,
+ CoinOptions,
+ CoinOptionsCommon,
+ CoinOptionsScript,
+} from "../coin/coin.js";
+import { CoinXUdtArgs, CoinXUdtArgsLike } from "./args.js";
+
+/**
+ * Script configurations for {@link CoinXUdt}.
+ *
+ * `CoinXUdt` accepts either existing xUDT type args through `script.args`, or
+ * structured args through `xUdtArgs`. When both are provided, `xUdtArgs` is
+ * used to build the final `script.args`.
+ *
+ * A complete `script` with `codeHash` and `hashType` takes priority over
+ * `knownScript`. If the script is incomplete, `knownScript` is used as
+ * shorthand and defaults to {@link ccc.KnownScript.XUdt}.
+ *
+ * @public
+ */
+export type CoinXUdtOptionsScript = Omit & {
+ /**
+ * Optional xUDT type script fields.
+ *
+ * Provide `args` to reuse existing xUDT args. Provide `codeHash` and
+ * `hashType` to use an explicit deployment instead of the known xUDT script
+ * shorthand.
+ */
+ script?: {
+ codeHash?: ccc.HexLike | null;
+ hashType?: ccc.HashTypeLike | null;
+ args?: ccc.BytesLike | null;
+ } | null;
+
+ /**
+ * Structured xUDT args.
+ *
+ * When provided, this value is encoded with {@link CoinXUdtArgs} and
+ * overrides `script.args`.
+ */
+ xUdtArgs?: CoinXUdtArgsLike | null;
+};
+
+/**
+ * Options for creating a {@link CoinXUdt} instance.
+ *
+ * Requires either `xUdtArgs` or `script.args` so the xUDT token identity can be
+ * determined. A `client` is required for network access, following
+ * {@link Coin}'s construction rules.
+ *
+ * @public
+ */
+export type CoinXUdtOptions = CoinOptionsCommon & CoinXUdtOptionsScript;
+
+/**
+ * An extensible UDT (xUDT) Coin implementation.
+ *
+ * `CoinXUdt` is a small specialization of {@link Coin} that understands the
+ * xUDT type args layout from [RFC 52](https://github.com/nervosnetwork/rfcs/blob/master/rfcs/0052-extensible-udt/0052-extensible-udt.md).
+ * It keeps the generic balance, transfer, and transaction completion
+ * behavior from `Coin`, while exposing the parsed xUDT args via {@link CoinXUdt.args}.
+ *
+ * @example
+ * ```ts
+ * const xUdt = await CoinXUdt.new({
+ * xUdtArgs: {
+ * ownerScriptHash: ownerLock.hash(),
+ * ownerModeOutputType: true,
+ * },
+ * client,
+ * });
+ * ```
+ *
+ * @public
+ */
+export class CoinXUdt extends Coin {
+ /**
+ * Parsed and normalized xUDT type args.
+ *
+ * If `xUdtArgs` was provided, this is the normalized form of that value. If
+ * only `script.args` was provided, this is parsed from the existing args.
+ *
+ * @public
+ */
+ public readonly args: Readonly;
+
+ /** @internal */
+ protected constructor(
+ options: Awaited>,
+ ) {
+ super(options);
+ this.args = options.args;
+ }
+
+ /**
+ * Creates an xUDT coin helper.
+ *
+ * `xUdtArgs` takes priority over `script.args` for the final type script args.
+ * A complete `script` takes priority over `knownScript`; otherwise the known
+ * script shorthand defaults to {@link ccc.KnownScript.XUdt}.
+ *
+ * @throws if neither `xUdtArgs` nor `script.args` is provided.
+ * @throws if the selected args cannot be parsed as xUDT args.
+ * @public
+ */
+ static override async new(options: CoinXUdtOptions): Promise {
+ return new CoinXUdt(await CoinXUdt.resolveOptions(options));
+ }
+
+ /** Resolves xUDT args and asynchronous Coin dependencies. @internal */
+ protected static override async resolveOptions(options: CoinXUdtOptions) {
+ let args;
+ if (options.xUdtArgs != null) {
+ args = CoinXUdtArgs.from(options.xUdtArgs);
+ } else if (options.script?.args != null) {
+ args = CoinXUdtArgs.fromBytes(options.script.args);
+ } else {
+ throw new Error(
+ "Either xUdtArgs or script.args must be provided for CoinXUdt",
+ );
+ }
+
+ let coinOptions: CoinOptions;
+ if (options.script?.codeHash != null && options.script.hashType != null) {
+ coinOptions = {
+ ...options,
+ script: {
+ codeHash: options.script.codeHash,
+ hashType: options.script.hashType,
+ args: args.toBytes(),
+ },
+ };
+ } else {
+ coinOptions = {
+ ...options,
+ script: { args: args.toBytes() },
+ knownScript: options.knownScript ?? ccc.KnownScript.XUdt,
+ };
+ }
+
+ return {
+ ...(await super.resolveOptions(coinOptions)),
+ args,
+ };
+ }
+}
diff --git a/packages/coin/src/xUdt/index.ts b/packages/coin/src/xUdt/index.ts
new file mode 100644
index 000000000..6a36be1a5
--- /dev/null
+++ b/packages/coin/src/xUdt/index.ts
@@ -0,0 +1,2 @@
+export * from "./args.js";
+export * from "./coinXUdt.js";
diff --git a/packages/coin/src/xUdt/xUdt.test.ts b/packages/coin/src/xUdt/xUdt.test.ts
new file mode 100644
index 000000000..0a2fb937e
--- /dev/null
+++ b/packages/coin/src/xUdt/xUdt.test.ts
@@ -0,0 +1,345 @@
+import { ccc } from "@ckb-ccc/core";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CoinXUdtArgs } from "./args.js";
+import { CoinXUdt } from "./coinXUdt.js";
+
+describe("xUDT Utils", () => {
+ const hash32: ccc.Hex = ("0x" + "00".repeat(32)) as ccc.Hex;
+ const hash31 = "0x" + "00".repeat(31);
+ const extensionScript = {
+ codeHash: ("0x" + "11".repeat(32)) as ccc.Hex,
+ hashType: "data1" as const,
+ args: "0x1234",
+ };
+ const extensionScriptVec = ccc.hexFrom(
+ ccc.ScriptVec.encode([extensionScript]),
+ );
+ const extensionScriptVecHash = ("0x" + "22".repeat(20)) as ccc.Hex;
+
+ describe("CoinXUdtArgs.from", () => {
+ it("should generate args with only owner script hash (32 bytes)", () => {
+ const args = CoinXUdtArgs.from({ ownerScriptHash: hash32 });
+ expect(args.shouldKeepFlag).toBe(false);
+ expect(args.toHex()).toBe(hash32);
+ });
+
+ it("should keep zero flags when shouldKeepFlag is true", () => {
+ const args = CoinXUdtArgs.from({
+ ownerScriptHash: hash32,
+ shouldKeepFlag: true,
+ });
+ expect(args.shouldKeepFlag).toBe(true);
+ expect(args.toHex()).toBe(hash32 + "00000000");
+ });
+
+ it("should round-trip combined extension and owner-mode flags", () => {
+ const res = CoinXUdtArgs.from({
+ ownerScriptHash: hash32,
+ ownerModeInputLockDisabled: true,
+ ownerModeOutputType: true,
+ ownerModeInputType: true,
+ extensionData: { type: "ScriptVec", value: [extensionScript] },
+ }).toHex();
+ expect(res).toBe(hash32 + "010000e0" + extensionScriptVec.slice(2));
+
+ const parsed = CoinXUdtArgs.fromBytes(res);
+ expect(parsed.ownerModeInputLockDisabled).toBe(true);
+ expect(parsed.ownerModeOutputType).toBe(true);
+ expect(parsed.ownerModeInputType).toBe(true);
+ expect(parsed.extensionData.inner.type).toBe("ScriptVec");
+ });
+
+ it("should encode a 20-byte ScriptVec hash for extension mode 2", () => {
+ const res = CoinXUdtArgs.from({
+ ownerScriptHash: hash32,
+ extensionData: {
+ type: "ScriptVecHash",
+ value: extensionScriptVecHash,
+ },
+ }).toHex();
+ expect(res).toBe(hash32 + "02000000" + extensionScriptVecHash.slice(2));
+ });
+
+ it("should throw error if owner script hash is not 32 bytes", () => {
+ expect(() =>
+ CoinXUdtArgs.from({
+ ownerScriptHash: hash31,
+ }),
+ ).toThrow("Invalid owner script hash length");
+ });
+ });
+
+ describe("CoinXUdtArgs.fromBytes", () => {
+ it("should parse 32-byte args", () => {
+ const parsed = CoinXUdtArgs.fromBytes(hash32);
+ expect(parsed.ownerScriptHash).toBe(hash32);
+ expect(parsed.ownerModeInputLockDisabled).toBe(false);
+ expect(parsed.ownerModeOutputType).toBe(false);
+ expect(parsed.ownerModeInputType).toBe(false);
+ expect(parsed.extensionData.inner).toEqual({
+ type: "Empty",
+ value: undefined,
+ });
+ expect(parsed.shouldKeepFlag).toBe(false);
+ expect(parsed.toHex()).toBe(hash32);
+ });
+
+ it("should decode a molecule ScriptVec for extension mode 1", () => {
+ const rawArgs = hash32 + "01000000" + extensionScriptVec.slice(2);
+ const parsed = CoinXUdtArgs.fromBytes(rawArgs);
+ expect(parsed.ownerScriptHash).toBe(hash32);
+ expect(parsed.shouldKeepFlag).toBe(false);
+ expect(parsed.extensionData.inner).toEqual({
+ type: "ScriptVec",
+ value: [ccc.Script.from(extensionScript)],
+ });
+ expect(parsed.toHex()).toBe(rawArgs);
+ });
+
+ it("should decode a 20-byte ScriptVec hash for extension mode 2", () => {
+ const rawArgs = hash32 + "02000000" + extensionScriptVecHash.slice(2);
+ const parsed = CoinXUdtArgs.fromBytes(rawArgs);
+ expect(parsed.shouldKeepFlag).toBe(false);
+ expect(parsed.extensionData.inner).toEqual({
+ type: "ScriptVecHash",
+ value: extensionScriptVecHash,
+ });
+ expect(parsed.toHex()).toBe(rawArgs);
+ });
+
+ it("should preserve an explicitly encoded zero flags field", () => {
+ const rawArgs = hash32 + "00000000";
+ const parsed = CoinXUdtArgs.fromBytes(rawArgs);
+ expect(parsed.shouldKeepFlag).toBe(true);
+ expect(parsed.extensionData.inner.type).toBe("Empty");
+ expect(parsed.toHex()).toBe(rawArgs);
+ });
+
+ it("should throw if args length is invalid", () => {
+ expect(() => CoinXUdtArgs.fromBytes(hash32 + "00")).toThrow(
+ "Invalid xUDT args length",
+ );
+ });
+
+ it("should reject extension data that does not match its flags", () => {
+ expect(() =>
+ CoinXUdtArgs.fromBytes(hash32 + "00000000" + "11"),
+ ).toThrow();
+ expect(() =>
+ CoinXUdtArgs.fromBytes(hash32 + "01000000" + "11"),
+ ).toThrow();
+ expect(() => CoinXUdtArgs.fromBytes(hash32 + "02000000" + "11")).toThrow(
+ "expected byte length 20",
+ );
+ expect(() => CoinXUdtArgs.fromBytes(hash32 + "03000000")).toThrow(
+ "unknown union field",
+ );
+ });
+ });
+});
+
+describe("CoinXUdt", () => {
+ let client: ccc.Client;
+ let signer: ccc.Signer;
+ const hash32: ccc.Hex = ("0x" + "00".repeat(32)) as ccc.Hex;
+ const hash32_alt: ccc.Hex = ("0x" + "11".repeat(32)) as ccc.Hex;
+ const explicitScript = {
+ codeHash: hash32,
+ hashType: "type" as const,
+ args: hash32,
+ };
+
+ beforeEach(() => {
+ client = new ccc.ClientPublicTestnet();
+ signer = new ccc.SignerCkbPublicKey(
+ client,
+ "0x026f3255791f578cc5e38783b6f2d87d4709697b797def6bf7b3b9af4120e2bfd9",
+ );
+ });
+
+ it("should preserve extension data from script.args", async () => {
+ const extensionScript = {
+ codeHash: hash32_alt,
+ hashType: "data1" as const,
+ args: "0x112233",
+ };
+ const rawArgs =
+ hash32 +
+ "01000000" +
+ ccc.hexFrom(ccc.ScriptVec.encode([extensionScript])).slice(2);
+ const coin = await CoinXUdt.new({
+ script: {
+ codeHash: hash32,
+ hashType: "type",
+ args: rawArgs,
+ },
+ cellDeps: [],
+ client: signer.client,
+ });
+
+ expect(coin.args.extensionData.inner).toEqual({
+ type: "ScriptVec",
+ value: [ccc.Script.from(extensionScript)],
+ });
+ expect(coin.script.args).toBe(rawArgs);
+ });
+
+ it("should prioritize xUdtArgs over script.args if both are provided", async () => {
+ const coin = await CoinXUdt.new({
+ script: {
+ codeHash: hash32,
+ hashType: "type",
+ args: hash32_alt,
+ },
+ cellDeps: [],
+ xUdtArgs: {
+ ownerScriptHash: hash32,
+ ownerModeOutputType: true,
+ },
+ client: signer.client,
+ });
+
+ expect(coin.args.ownerScriptHash).toBe(hash32);
+ expect(coin.args.ownerModeOutputType).toBe(true);
+
+ const script = coin.script;
+ expect(script.args).toBe(hash32 + "00000040");
+ });
+
+ it("should expose args as readonly", async () => {
+ const inputArgs = CoinXUdtArgs.from({
+ ownerScriptHash: hash32,
+ ownerModeOutputType: true,
+ });
+ const coin = await CoinXUdt.new({
+ script: explicitScript,
+ cellDeps: [],
+ xUdtArgs: inputArgs,
+ client: signer.client,
+ });
+
+ expect(coin.args.ownerModeOutputType).toBe(true);
+ expect(coin.script.args).toBe(coin.args.toHex());
+
+ const assertArgsAreReadonly = () => {
+ // @ts-expect-error CoinXUdt exposes its args as a readonly snapshot.
+ coin.args.ownerModeOutputType = false;
+ };
+ void assertArgsAreReadonly;
+
+ const mutableArgs = coin.args.clone();
+ mutableArgs.ownerModeOutputType = false;
+ expect(mutableArgs.ownerModeOutputType).toBe(false);
+ });
+
+ it("should throw error if both xUdtArgs and script.args are missing", async () => {
+ await expect(
+ CoinXUdt.new({
+ client: signer.client,
+ }),
+ ).rejects.toThrow("Either xUdtArgs or script.args must be provided");
+ });
+
+ it("should default knownScript to XUdt and add cell deps", async () => {
+ const mockCellDep = ccc.CellDep.from({
+ outPoint: {
+ txHash: hash32,
+ index: 0,
+ },
+ depType: "code",
+ });
+
+ vi.spyOn(client, "getKnownScript").mockResolvedValue({
+ codeHash: hash32_alt,
+ hashType: "type",
+ cellDeps: [
+ {
+ cellDep: ccc.CellDep.from({
+ outPoint: {
+ txHash: hash32,
+ index: 0,
+ },
+ depType: "code",
+ }),
+ },
+ ],
+ });
+
+ vi.spyOn(client, "getCellDeps").mockResolvedValue([mockCellDep]);
+
+ const coin = await CoinXUdt.new({
+ xUdtArgs: {
+ ownerScriptHash: hash32,
+ },
+ client: signer.client,
+ });
+
+ const script = coin.script;
+ expect(script.codeHash).toBe(hash32_alt);
+ expect(script.hashType).toBe("type");
+
+ const cellDeps = coin.cellDeps;
+ expect(cellDeps.length).toBe(1);
+ expect(cellDeps[0]).toEqual(mockCellDep);
+ });
+
+ it("should not mix balances of xUDT with different owner-mode flags", async () => {
+ const lock = (await signer.getRecommendedAddressObj()).script;
+ const coinFlags0 = await CoinXUdt.new({
+ script: explicitScript,
+ cellDeps: [],
+ xUdtArgs: {
+ ownerScriptHash: hash32,
+ },
+ client: signer.client,
+ });
+
+ const coinFlagsOutputType = await CoinXUdt.new({
+ script: explicitScript,
+ cellDeps: [],
+ xUdtArgs: {
+ ownerScriptHash: hash32,
+ ownerModeOutputType: true,
+ },
+ client: signer.client,
+ });
+
+ const cellFlags0 = ccc.Cell.from({
+ outPoint: {
+ txHash: hash32,
+ index: 0,
+ },
+ cellOutput: {
+ capacity: ccc.fixedPointFrom(142),
+ lock,
+ type: coinFlags0.script,
+ },
+ outputData: ccc.numLeToBytes(100, 16),
+ });
+
+ const cellFlagsOutputType = ccc.Cell.from({
+ outPoint: {
+ txHash: hash32,
+ index: 1,
+ },
+ cellOutput: {
+ capacity: ccc.fixedPointFrom(142),
+ lock,
+ type: coinFlagsOutputType.script,
+ },
+ outputData: ccc.numLeToBytes(200, 16),
+ });
+
+ await client.cache.markUsable(cellFlags0, cellFlagsOutputType);
+ vi.spyOn(client, "findCellsOnChain").mockImplementation(
+ async function* () {},
+ );
+
+ expect(await coinFlags0.calculateBalance(signer, { source: "local" })).toBe(
+ ccc.numFrom(100),
+ );
+ expect(
+ await coinFlagsOutputType.calculateBalance(signer, { source: "local" }),
+ ).toBe(ccc.numFrom(200));
+ });
+});