From 054caad1a486af9be8fcdf147a2ea0ae6c90c7ad 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 | 125 ++
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 | 55 +
packages/coin/prettier.config.cjs | 11 +
packages/coin/src/barrel.ts | 1 +
packages/coin/src/coin/coin.test.ts | 1739 +++++++++++++++++
packages/coin/src/coin/coin.ts | 718 +++++++
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 | 241 ++-
typedoc.config.mjs | 1 +
vitest.config.mts | 8 +-
25 files changed, 3283 insertions(+), 29 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/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..42671f813
--- /dev/null
+++ b/packages/coin/README.md
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+ 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 = new Coin({
+ script: {
+ codeHash: "0x...",
+ hashType: "type",
+ args: "0x...",
+ },
+ client,
+ cellDeps: [{ outPoint: codeOutPoint, depType: "code" }],
+});
+
+// Instantiation via knownScript (e.g., sUDT)
+const sUdt = new Coin({
+ knownScript: ccc.KnownScript.SUdt,
+ script: {
+ args: ownerLock.hash(),
+ },
+ signer,
+});
+```
+
+### 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 = new Coin({
+ knownScript: ccc.KnownScript.SUdt,
+ script: {
+ args: ownerLock.hash(),
+ },
+ signer, // It's necessary to construct the Coin class with a signer to use complete* methods.
+});
+
+const { script: to } = await signer.getRecommendedAddressObj();
+
+// Build outputs
+const { tx } = await coin.transfer(ccc.Transaction.from({}), [
+ { to, amount: 1000n },
+]);
+
+// Add Coin inputs + change (change goes back to signer)
+const { tx: completedTx } = await coin.completeBy(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 { tx: completedTx } = await coin.completeChangeToLock(tx, changeLock);
+```
+
+## 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..4db26e288
--- /dev/null
+++ b/packages/coin/package.json
@@ -0,0 +1,55 @@
+{
+ "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/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..2dfaa0f76
--- /dev/null
+++ b/packages/coin/src/barrel.ts
@@ -0,0 +1 @@
+export * from "./coin/index.js";
diff --git a/packages/coin/src/coin/coin.test.ts b/packages/coin/src/coin/coin.test.ts
new file mode 100644
index 000000000..5b23073b8
--- /dev/null
+++ b/packages/coin/src/coin/coin.test.ts
@@ -0,0 +1,1739 @@
+import { ccc } from "@ckb-ccc/core";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Coin } from "./coin.js";
+
+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 = new Coin({ script: type, signer, cellDeps: [] });
+});
+
+describe("Coin", () => {
+ 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 tx = ccc.Transaction.from({
+ outputs: [],
+ });
+
+ const { addedCount } = await coin.completeInputsByAmount(tx);
+ expect(addedCount).toBe(0);
+ });
+
+ 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(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 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(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(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(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(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(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(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(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 = ccc.Transaction.from({
+ outputs: [],
+ });
+
+ const { tx: completedTx, addedCount } = await coin.completeInputsAll(tx);
+
+ // 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(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(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(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(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(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(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 { tx: completedTx } = await coin.completeChangeToLock(
+ tx,
+ changeLock,
+ );
+
+ // Should have original output + change output
+ expect(completedTx.outputs.length).toBe(2);
+ expect(completedTx.outputs[1].lock.eq(changeLock)).toBe(true);
+ expect(completedTx.outputs[1].type?.eq(type)).toBe(true);
+
+ // Change should have amount of 50 (input 200 - output 150)
+ const changeAmount = await coin.amountFrom(completedTx.getOutput(1)!);
+ expect(changeAmount).toBe(ccc.numFrom(50));
+ });
+
+ it("returns correct changeIndex, hasChanged, addedInputs", 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(tx1, changeLock);
+ expect(res1.hasChanged).toBe(true);
+ expect(res1.changeIndex).toBe(1); // appended after the existing output
+ expect(res1.addedInputs).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(tx2, changeLock);
+ expect(res2.hasChanged).toBe(false);
+ expect(res2.changeIndex).toBeUndefined();
+ });
+
+ 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 = new Coin({
+ script: type,
+ signer,
+ cellDeps: [],
+ outputTransformer: async (cell) => {
+ const amount = await coin.amountFrom(cell);
+ const extraLen = Number(amount / 100n);
+ const extra = new Uint8Array(extraLen).fill(0xff);
+ return {
+ ...cell,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(ccc.bytesFrom(cell.outputData), extra),
+ ),
+ };
+ },
+ });
+
+ // 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 { tx: completedTx } =
+ await coinWithTransformer.completeChangeToLock(tx, changeLock);
+
+ // 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],
+ 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 = new Coin({
+ script: type,
+ signer,
+ cellDeps: [],
+ outputTransformer: (cell) => ({
+ ...cell,
+ cellOutput: { ...cell.cellOutput, capacity: bigCapacity },
+ }),
+ });
+
+ const { tx: completedTx } =
+ await coinWithTransformer.completeChangeToLock(tx, changeLock);
+
+ 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 { tx: completedTx } = await coin.completeBy(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 { tx: completedTx } = await coin.completeChangeToLock(
+ tx,
+ changeLock,
+ );
+
+ // 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(
+ 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 { tx: completedTx } = await coin.completeChangeToLock(
+ tx,
+ changeLock,
+ );
+
+ // 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[1]).toBe(ccc.Zero); // amountTweak should be 0
+ expect(secondCall[2]).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 { tx: completedTx } = await coin.completeChangeToOutput(tx, 0); // Use first output as change
+
+ // Should have added inputs
+ expect(completedTx.inputs.length).toBeGreaterThan(0);
+
+ // The first output should now contain the original amount plus any excess from inputs
+ const changeAmount = 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(tx, 0)).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(tx, 5)).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 = new Coin({
+ script: type,
+ signer,
+ cellDeps: [],
+ outputTransformer: async (cell) => {
+ const amount = await coin.amountFrom(cell);
+ const extra = new Uint8Array(Number(amount / 100n)).fill(0xee);
+ return {
+ ...cell,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(ccc.bytesFrom(cell.outputData), extra),
+ ),
+ };
+ },
+ });
+
+ const tx = ccc.Transaction.from({
+ outputs: [{ lock, type }],
+ outputsData: [ccc.numLeToBytes(0, 16)],
+ });
+
+ const { tx: completedTx } =
+ await coinWithTransformer.completeChangeToOutput(tx, 0);
+
+ 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],
+ 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 = new Coin({
+ script: type,
+ signer,
+ cellDeps: [],
+ outputTransformer: async (cell) => ({
+ ...cell,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(
+ ccc.bytesFrom(cell.outputData),
+ new Uint8Array(100).fill(0xab),
+ ),
+ ),
+ }),
+ });
+
+ const { tx: completedTx } =
+ await coinWithTransformer.completeChangeToOutput(tx, 0);
+
+ const minCapacity = ccc.CellOutput.from(
+ completedTx.outputs[0],
+ 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 { tx: resultTx } = await coin.completeBy(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 constructor", () => {
+ it("should apply a custom filter instead of the default one", async () => {
+ const customFilter = ccc.ClientIndexerSearchKeyFilter.from({
+ script: type,
+ outputDataLenRange: [32, "0xffffffff"],
+ });
+ const customCoin = new Coin({
+ script: type,
+ signer,
+ filter: customFilter,
+ cellDeps: [],
+ });
+ expect(await customCoin.filter).toEqual(customFilter);
+ });
+
+ it("should use the default filter (type script + outputDataLenRange [16, max]) when no filter given", async () => {
+ const defaultCoin = new Coin({ script: type, signer, cellDeps: [] });
+ const filterScript = ccc.Script.from(
+ ((await defaultCoin.filter) as { script?: ccc.ScriptLike }).script!,
+ );
+ expect(filterScript.eq(type)).toBe(true);
+ });
+
+ it("client-only: client getter returns the provided client", () => {
+ const clientOnlyCoin = new Coin({ script: type, client, cellDeps: [] });
+ expect(clientOnlyCoin.client).toBe(client);
+ });
+
+ it("client-only: signer getter throws", () => {
+ const clientOnlyCoin = new Coin({ script: type, client, cellDeps: [] });
+ expect(() => clientOnlyCoin.signer).toThrow(
+ "Coin was constructed without a signer. Pass signer to use this method.",
+ );
+ });
+
+ it("signer-only: client getter returns signer.client", () => {
+ const signerOnlyCoin = new Coin({ script: type, signer, cellDeps: [] });
+ expect(signerOnlyCoin.client).toBe(signer.client);
+ });
+
+ it("client takes priority over signer for client getter", () => {
+ const otherClient = new ccc.ClientPublicMainnet();
+ const bothCoin = new Coin({
+ script: type,
+ signer,
+ client: otherClient,
+ cellDeps: [],
+ });
+ expect(bothCoin.client).toBe(otherClient);
+ });
+
+ it("client-only: completeBy throws with clear error", async () => {
+ const clientOnlyCoin = new Coin({ script: type, client, cellDeps: [] });
+ const tx = ccc.Transaction.from({});
+ await expect(clientOnlyCoin.completeBy(tx)).rejects.toThrow(
+ "Coin was constructed without a signer. Pass signer to use this method.",
+ );
+ });
+
+ it("client-only: calculateInfo throws when no signer passed", async () => {
+ const clientOnlyCoin = new Coin({ script: type, client, cellDeps: [] });
+ await expect(clientOnlyCoin.calculateInfo()).rejects.toThrow(
+ "Coin was constructed without a signer",
+ );
+ });
+
+ it("client-only: calculateInfo works when signer passed as argument", async () => {
+ const clientOnlyCoin = new Coin({ script: type, client, cellDeps: [] });
+ vi.spyOn(signer, "findCellsOnChain").mockImplementation(
+ async function* () {},
+ );
+ await expect(clientOnlyCoin.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)],
+ });
+
+ // Already have 1 input (amount 100) matching output exactly → no more needed
+ const { addedCount } = await coin.completeInputsByAmount(tx);
+ 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(await 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(await coin.filter);
+ });
+ });
+
+ describe("KnownScript construction", () => {
+ it("should initialize with knownScript correctly", async () => {
+ const getKnownScriptSpy = vi.spyOn(client, "getKnownScript");
+
+ const coinKnown = new Coin({
+ knownScript: ccc.KnownScript.SUdt,
+ script: {
+ args: lock.hash(),
+ },
+ client,
+ });
+
+ const script = await coinKnown.script;
+ const resolvedCellDeps = await 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 = new Coin({
+ knownScript: ccc.KnownScript.SUdt,
+ script: {
+ args: lock.hash(),
+ },
+ cellDeps: [customCellDep],
+ client,
+ });
+
+ const resolvedCellDeps = await 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,
+ );
+ });
+ });
+
+ describe("transfer", () => {
+ it("should add outputs correctly", async () => {
+ const recipientLock1 = (await signer.getRecommendedAddressObj()).script;
+ const recipientLock2 = (await signer.getRecommendedAddressObj()).script;
+
+ const { tx, outputIndexes } = await coin.transfer(
+ ccc.Transaction.from({}),
+ [
+ { 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(await coin.script)).toBe(true);
+ expect(tx.outputs[1].lock.eq(recipientLock2)).toBe(true);
+ expect(tx.outputs[1].type?.eq(await 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);
+
+ expect(outputIndexes).toEqual([0, 1]);
+ });
+
+ it("should apply outputTransformer to transfer outputs", async () => {
+ const recipientLock = (await signer.getRecommendedAddressObj()).script;
+
+ const coinWithTransformer = new Coin({
+ script: type,
+ signer,
+ cellDeps: [],
+ outputTransformer: async (cell) => {
+ return {
+ ...cell,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(
+ ccc.bytesFrom(cell.outputData),
+ new Uint8Array([0xaa, 0xbb]),
+ ),
+ ),
+ };
+ },
+ });
+
+ const { tx } = await coinWithTransformer.transfer(
+ ccc.Transaction.from({}),
+ [{ 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(await 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",
+ );
+ });
+ });
+});
diff --git a/packages/coin/src/coin/coin.ts b/packages/coin/src/coin/coin.ts
new file mode 100644
index 000000000..76149b799
--- /dev/null
+++ b/packages/coin/src/coin/coin.ts
@@ -0,0 +1,718 @@
+import { ccc } from "@ckb-ccc/core";
+import { CoinInfo, CoinInfoLike } from "./coinInfo.js";
+import { ErrorCoinInsufficient } from "./error.js";
+
+/**
+ * Return type for all `complete*` methods on {@link Coin}.
+ *
+ * @public
+ */
+export type CoinCompleteResponse = {
+ /** The completed transaction. */
+ tx: ccc.Transaction;
+ /** Number of Coin inputs added to the transaction. */
+ addedInputs: number;
+ /** Whether a change output was written. `false` when inputs and outputs were already balanced. */
+ hasChanged: boolean;
+ /**
+ * Index of the change output in `tx.outputs`.
+ * `undefined` when `hasChanged` is `false`.
+ */
+ changeIndex: number | undefined;
+};
+
+/**
+ * Options for creating a {@link Coin} instance.
+ * @public
+ */
+export type CoinOptions = {
+ filter?: ccc.ClientIndexerSearchKeyFilterLike | null;
+ outputTransformer?:
+ ((cell: ccc.CellAny) => ccc.CellAnyLike | Promise) | null;
+} & (
+ | {
+ script: ccc.ScriptLike;
+ cellDeps: ccc.CellDepLike[];
+ knownScript?: null;
+ }
+ | {
+ knownScript: ccc.KnownScript;
+ script: {
+ args: ccc.BytesLike;
+ };
+ cellDeps?: ccc.CellDepLike[] | null;
+ }
+) &
+ (
+ | {
+ client: ccc.Client;
+ signer?: null;
+ }
+ | {
+ client?: ccc.Client | null;
+ signer: ccc.Signer;
+ }
+ );
+
+/**
+ * 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 {
+ /** Type script that identifies this Coin. @public */
+ public readonly script: Promise;
+
+ private readonly _signer: ccc.Signer | undefined;
+
+ /** Output transformer for Coin outputs. @public */
+ public readonly outputTransformer?:
+ ((cell: ccc.CellAny) => ccc.CellAnyLike | Promise) | null;
+
+ /**
+ * Signer used for all input sourcing and transaction completion.
+ * @throws if the `Coin` was constructed without a `signer`.
+ * @public
+ */
+ get signer(): ccc.Signer {
+ if (!this._signer) {
+ throw new Error(
+ "Coin was constructed without a signer. Pass signer to use this method.",
+ );
+ }
+ return this._signer;
+ }
+
+ /** 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: Promise;
+
+ /** Cell deps required by the type script, added to every built transaction. @public */
+ public readonly cellDeps: Promise;
+
+ /**
+ * @param options.knownScript - Optional known script standard (e.g., `ccc.KnownScript.SUdt`) to dynamically resolve `codeHash`, `hashType`, and `cellDeps`.
+ * @param options.script - Type script that identifies this Coin asset. If `knownScript` is provided, only `args` is required.
+ * @param options.signer - Signer used for input sourcing and transaction completion.
+ * Required for `complete*` methods.
+ * @param options.client - Client for network requests. Takes priority over the signer's client if both are provided.
+ * At least one of `signer` or `client` must be supplied.
+ * @param options.filter - Custom indexer filter. Defaults to cells with this type script
+ * and `outputDataLenRange: [16, ∞)`.
+ * @param options.cellDeps - Cell deps automatically added to every built transaction. If `knownScript` is also provided, these custom cell deps are appended after the resolved default cell deps of the known script.
+ * @param options.outputTransformer - Optional callback to further modify generated Coin cells after the
+ * amount has been written into `outputData[0..16)`. Any capacity below the data minimum is raised automatically;
+ * capacity above the minimum is preserved as-is.
+ *
+ * @example
+ * ```typescript
+ * const coin = new Coin({
+ * script: { codeHash: "0x...", hashType: "type", args: "0x..." },
+ * signer,
+ * cellDeps: [{ outPoint: codeOutPoint, depType: "code" }],
+ * });
+ * ```
+ *
+ * @example
+ * ```typescript
+ * const coin = new Coin({
+ * knownScript: ccc.KnownScript.SUdt,
+ * script: { args: ownerLock.hash() },
+ * signer,
+ * outputTransformer: async (cell) => ({
+ * ...cell,
+ * outputData: ccc.hexFrom(ccc.bytesConcat(ccc.bytesFrom(cell.outputData), extraData)),
+ * }),
+ * });
+ * ```
+ */
+ constructor(options: CoinOptions) {
+ this.outputTransformer = options.outputTransformer;
+ this._signer = options.signer ?? undefined;
+ const client =
+ options.signer == undefined
+ ? options.client
+ : (options.client ?? options.signer.client);
+ this.client = client;
+
+ if (options.knownScript != null) {
+ const resolved = (async (): Promise<[ccc.Script, ccc.CellDep[]]> => {
+ const scriptInfo = await client.getKnownScript(options.knownScript);
+ return [
+ ccc.Script.from({
+ codeHash: scriptInfo.codeHash,
+ hashType: scriptInfo.hashType,
+ args: options.script.args,
+ }),
+ (await client.getCellDeps(scriptInfo.cellDeps)).concat(
+ options.cellDeps?.map(ccc.CellDep.from) ?? [],
+ ),
+ ];
+ })();
+
+ this.script = resolved.then(([script]) => script);
+ this.cellDeps = resolved.then(([_, cellDeps]) => cellDeps);
+ } else {
+ this.script = Promise.resolve(options.script).then(ccc.Script.from);
+ this.cellDeps = Promise.resolve(options.cellDeps).then((deps) =>
+ deps.map(ccc.CellDep.from),
+ );
+ }
+
+ const script = this.script;
+ this.filter = (async () => {
+ return ccc.ClientIndexerSearchKeyFilter.from(
+ options.filter ?? {
+ script: await script,
+ outputDataLenRange: [16, "0xffffffff"],
+ },
+ );
+ })();
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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 | null,
+ options?: { source?: "chain" | "local" | null },
+ ): Promise {
+ const s = signer ?? this.signer;
+ const isFromLocal = (options?.source ?? "chain") === "local";
+ const filter = await this.filter;
+ const cells = isFromLocal
+ ? s.findCells(filter)
+ : s.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 | null,
+ 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(await 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;
+ }
+
+ /**
+ * 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(
+ * tx,
+ * (acc, _cell, coinInfo) => {
+ * const next = acc + coinInfo.amount;
+ * return next >= target ? undefined : next;
+ * },
+ * ccc.Zero,
+ * );
+ * ```
+ */
+ async completeInputs(
+ txLike: ccc.TransactionLike,
+ accumulator: (
+ acc: T,
+ cell: ccc.Cell,
+ coinInfo: CoinInfo,
+ ) => Promise | T | undefined,
+ init: T,
+ ): Promise<{
+ tx: ccc.Transaction;
+ addedCount: number;
+ accumulated?: T;
+ }> {
+ const tx = ccc.Transaction.from(txLike);
+ tx.addCellDeps(await this.cellDeps);
+
+ const res = await tx.completeInputs(
+ this.signer,
+ await this.filter,
+ async (acc, cell) => accumulator(acc, cell, await this.infoFrom(cell)),
+ init,
+ );
+
+ return {
+ ...res,
+ tx,
+ };
+ }
+
+ /**
+ * Adds Coin inputs until the Coin amount gap is covered, also attempting 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(tx);
+ * ```
+ */
+ async completeInputsByAmount(
+ txLike: ccc.TransactionLike,
+ amountTweak?: ccc.NumLike | null,
+ capacityTweak?: ccc.NumLike | null,
+ ): Promise<{
+ addedCount: number;
+ tx: ccc.Transaction;
+ }> {
+ const tx = ccc.Transaction.from(txLike);
+ tx.addCellDeps(await this.cellDeps);
+
+ const { amount: inAmount, capacity: inCapacity } =
+ await this.getInputsInfo(tx);
+ const { amount: outAmount, capacity: outCapacity } =
+ await this.getOutputsInfo(tx);
+
+ const amountBurned = inAmount - outAmount - ccc.numFrom(amountTweak ?? 0);
+ // 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 capacityBurned =
+ ccc.numMin(inCapacity - outCapacity, await tx.getFee(this.client)) -
+ ccc.numFrom(capacityTweak ?? 0);
+
+ if (amountBurned >= ccc.Zero && capacityBurned >= ccc.Zero) {
+ return { addedCount: 0, tx };
+ }
+
+ const {
+ tx: txRes,
+ addedCount,
+ accumulated,
+ } = await this.completeInputs(
+ tx,
+ (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: amountBurned, capacity: capacityBurned }),
+ );
+
+ if (accumulated === undefined || accumulated.amount >= ccc.Zero) {
+ return { tx: txRes, addedCount };
+ }
+
+ throw new ErrorCoinInsufficient({
+ amount: -accumulated.amount,
+ type: await this.script,
+ });
+ }
+
+ /**
+ * Adds ALL available Coins from the signer as inputs. Useful for consolidation or full sweeps.
+ */
+ async completeInputsAll(txLike: ccc.TransactionLike): Promise<{
+ addedCount: number;
+ tx: ccc.Transaction;
+ }> {
+ const tx = ccc.Transaction.from(txLike);
+
+ const { tx: txRes, addedCount } = await this.completeInputs(
+ tx,
+ (acc, _cell, coinInfo) => acc.addAssign(coinInfo),
+ CoinInfo.default(),
+ );
+
+ 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, and
+ * should write the change output in place. 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 { tx: completedTx } = await coin.complete(tx, async (tx, amount) => {
+ * tx.addOutput({ lock: changeLock, type: await coin.script }, ccc.numLeToBytes(amount, 16));
+ * });
+ * ```
+ */
+ async complete(
+ txLike: ccc.TransactionLike,
+ change: (tx: ccc.Transaction, amount: ccc.Num) => Promise | void,
+ options?: { shouldAddInputs?: boolean | null },
+ ): Promise {
+ let tx = ccc.Transaction.from(txLike);
+ tx.addCellDeps(await this.cellDeps);
+ let addedInputs = 0;
+
+ /* === Figure out the amount to change === */
+ if (options?.shouldAddInputs ?? true) {
+ const res = await this.completeInputsByAmount(tx);
+ tx = res.tx;
+ addedInputs += res.addedCount;
+ }
+
+ const amountBurned = await this.getAmountBurned(tx);
+
+ if (amountBurned < ccc.Zero) {
+ throw new ErrorCoinInsufficient({
+ amount: -amountBurned,
+ type: await this.script,
+ });
+ } else if (amountBurned === ccc.Zero) {
+ // No change needed — inputs and outputs are perfectly balanced
+ return { tx, addedInputs, hasChanged: false, changeIndex: undefined };
+ }
+ /* === Some amount need to change === */
+
+ if (!(options?.shouldAddInputs ?? true)) {
+ // Caller manages inputs manually; apply change with current amount as-is
+ await Promise.resolve(change(tx, amountBurned));
+ return { tx, addedInputs, hasChanged: true, changeIndex: undefined };
+ }
+
+ // 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.
+ const cloned = tx.clone();
+ const capacityBefore = tx.getOutputsCapacity();
+ await Promise.resolve(change(cloned, amountBurned));
+ const extraCapacity = cloned.getOutputsCapacity() - capacityBefore;
+
+ const res2 = await this.completeInputsByAmount(tx, ccc.Zero, extraCapacity);
+ tx = res2.tx;
+ addedInputs += res2.addedCount;
+
+ await Promise.resolve(change(tx, await this.getAmountBurned(tx)));
+
+ return { tx, addedInputs, hasChanged: true, changeIndex: undefined };
+ }
+
+ /**
+ * 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 { tx: completedTx } = await coin.completeChangeToOutput(tx, 1);
+ * ```
+ */
+ async completeChangeToOutput(
+ txLike: ccc.TransactionLike,
+ indexLike: ccc.NumLike,
+ 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");
+ }
+
+ const result = await this.complete(
+ tx,
+ async (tx, amount) => {
+ const amountData = ccc.numLeToBytes(
+ await this.amountFrom(output, amount),
+ 16,
+ );
+
+ let cell = ccc.CellAny.from({
+ cellOutput: output.cellOutput.clone(),
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(
+ amountData,
+ ccc.bytesFrom(output.outputData).slice(16),
+ ),
+ ),
+ });
+
+ if (this.outputTransformer) {
+ cell = ccc.CellAny.from(await this.outputTransformer(cell));
+ }
+
+ tx.outputs[index] = cell.cellOutput;
+ tx.outputsData[index] = cell.outputData;
+ },
+ options,
+ );
+
+ return { ...result, changeIndex: result.hasChanged ? index : undefined };
+ }
+
+ /**
+ * Completes the transaction by creating a new change output locked to `changeLike`.
+ *
+ * @example
+ * ```typescript
+ * const { script: changeLock } = await coin.signer.getRecommendedAddressObj();
+ * const { tx: completedTx, changeIndex } = await coin.completeChangeToLock(tx, changeLock);
+ * ```
+ */
+ async completeChangeToLock(
+ txLike: ccc.TransactionLike,
+ changeLike: ccc.ScriptLike,
+ options?: {
+ shouldAddInputs?: boolean | null;
+ },
+ ): Promise {
+ const change = ccc.Script.from(changeLike);
+ let changeIndex: number | undefined;
+
+ const { tx, addedInputs, hasChanged } = await this.complete(
+ txLike,
+ async (tx, amount) => {
+ let cell = ccc.CellAny.from({
+ cellOutput: { lock: change, type: await this.script },
+ outputData: ccc.numLeToBytes(amount, 16),
+ });
+
+ if (this.outputTransformer) {
+ cell = ccc.CellAny.from(await this.outputTransformer(cell));
+ }
+
+ changeIndex = tx.outputs.length;
+ tx.addOutput(cell.cellOutput, cell.outputData);
+ },
+ options,
+ );
+
+ return { tx, addedInputs, hasChanged, changeIndex };
+ }
+
+ /**
+ * Convenience wrapper around `completeChangeToLock` using the signer's recommended address.
+ *
+ * @example
+ * ```typescript
+ * const { tx: completedTx } = await coin.completeBy(tx);
+ * await completedTx.completeInputsByCapacity(coin.signer);
+ * await completedTx.completeFeeBy(coin.signer);
+ * await coin.signer.sendTransaction(completedTx);
+ * ```
+ *
+ * @see {@link completeChangeToLock} for more control over the change destination.
+ */
+ async completeBy(
+ tx: ccc.TransactionLike,
+ options?: {
+ shouldAddInputs?: boolean | null;
+ },
+ ) {
+ const { script } = await this.signer.getRecommendedAddressObj();
+
+ return this.completeChangeToLock(tx, script, options);
+ }
+
+ /**
+ * Make the transaction perform transfer actions to the specified recipients.
+ *
+ * @returns An object containing:
+ * - `tx`: The updated transaction with added transfer outputs
+ * - `outputIndexes`: The indexes of the newly added Coin outputs in `tx.outputs`.
+ *
+ * @example
+ * ```typescript
+ * const { tx } = await coin.transfer(ccc.Transaction.from({}), [
+ * { to: recipientLock, amount: 100n },
+ * ]);
+ * const { tx: completedTx } = await coin.completeBy(tx);
+ * await completedTx.completeInputsByCapacity(coin.signer);
+ * await completedTx.completeFeeBy(coin.signer);
+ * await coin.signer.sendTransaction(completedTx);
+ * ```
+ */
+ async transfer(
+ txLike: ccc.TransactionLike,
+ transfers: {
+ to: ccc.ScriptLike;
+ amount: ccc.NumLike;
+ }[],
+ ): Promise<{
+ tx: ccc.Transaction;
+ outputIndexes: number[];
+ }> {
+ const tx = ccc.Transaction.from(txLike);
+ const outputIndexes = [];
+
+ for (const { to, amount } of transfers) {
+ let cell = ccc.CellAny.from({
+ cellOutput: { lock: to, type: await this.script },
+ outputData: ccc.numLeToBytes(ccc.numFrom(amount), 16),
+ });
+
+ if (this.outputTransformer) {
+ cell = ccc.CellAny.from(await this.outputTransformer(cell));
+ }
+
+ outputIndexes.push(tx.addOutput(cell.cellOutput, cell.outputData) - 1);
+ }
+
+ return {
+ tx,
+ outputIndexes,
+ };
+ }
+}
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..1a52d90f6
--- /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(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(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 cc69dbcb2..c8f1bd163 100644
--- a/packages/shell/package.json
+++ b/packages/shell/package.json
@@ -55,6 +55,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 0d80ce240..015850a1e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -177,6 +177,46 @@ importers:
specifier: ^4.1.9
version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
+ packages/coin:
+ dependencies:
+ '@ckb-ccc/core':
+ specifier: workspace:*
+ version: link:../core
+ devDependencies:
+ '@eslint/js':
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@10.6.0(jiti@2.7.0))
+ '@types/node':
+ specifier: ^26.0.0
+ version: 26.0.1
+ eslint:
+ specifier: ^10.5.0
+ version: 10.6.0(jiti@2.7.0)
+ eslint-config-prettier:
+ specifier: ^10.1.8
+ version: 10.1.8(eslint@10.6.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.6.0(jiti@2.7.0)))(eslint@10.6.0(jiti@2.7.0))(prettier@3.9.4)
+ prettier:
+ specifier: ^3.8.4
+ version: 3.9.4
+ prettier-plugin-organize-imports:
+ specifier: ^4.3.0
+ version: 4.3.0(prettier@3.9.4)(typescript@6.0.3)
+ tsdown:
+ specifier: ^0.22.3
+ version: 0.22.3(tsx@4.22.4)(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.6.0(jiti@2.7.0))(typescript@6.0.3)
+ vitest:
+ specifier: ^4.1.9
+ version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
+
packages/connector:
dependencies:
'@ckb-ccc/ccc':
@@ -1046,6 +1086,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
@@ -4199,6 +4242,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.62.1':
resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4217,6 +4268,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.62.1':
resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4224,6 +4282,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.62.1':
resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4234,10 +4298,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.62.1':
resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==}
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.62.1':
resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4254,6 +4328,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.62.1':
resolution: {integrity: sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4265,6 +4346,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.62.1':
resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4278,6 +4363,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.62.1':
resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4290,6 +4381,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.62.1':
resolution: {integrity: sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4301,6 +4399,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.62.1':
resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -5610,9 +5712,6 @@ packages:
resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==}
engines: {node: '>= 0.4'}
- es-module-lexer@2.1.0:
- resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
-
es-module-lexer@2.2.0:
resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==}
@@ -8300,10 +8399,6 @@ packages:
resolution: {integrity: sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==}
engines: {node: '>=20'}
- side-channel-list@1.0.0:
- resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
- engines: {node: '>= 0.4'}
-
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -8316,10 +8411,6 @@ packages:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
- side-channel@1.1.0:
- resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
- engines: {node: '>= 0.4'}
-
side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
@@ -8881,6 +8972,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.62.1:
resolution: {integrity: sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -12251,6 +12349,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.61.1(eslint@10.6.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.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.61.1
+ eslint: 10.6.0(jiti@2.7.0)
+ ignore: 7.0.5
+ 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.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -12280,6 +12394,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/parser@8.61.1(eslint@10.6.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.6.0(jiti@2.7.0)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.62.1
@@ -12292,6 +12418,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.62.1(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3)
@@ -12306,11 +12441,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.62.1':
dependencies:
'@typescript-eslint/types': 8.62.1
'@typescript-eslint/visitor-keys': 8.62.1
+ '@typescript-eslint/tsconfig-utils@8.61.1(typescript@6.0.3)':
+ dependencies:
+ typescript: 6.0.3
+
'@typescript-eslint/tsconfig-utils@8.62.1(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
@@ -12327,6 +12471,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/type-utils@8.61.1(eslint@10.6.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.6.0(jiti@2.7.0))(typescript@6.0.3)
+ debug: 4.4.3
+ eslint: 10.6.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.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.62.1
@@ -12341,6 +12497,8 @@ snapshots:
'@typescript-eslint/types@6.21.0': {}
+ '@typescript-eslint/types@8.61.1': {}
+
'@typescript-eslint/types@8.62.1': {}
'@typescript-eslint/typescript-estree@6.21.0(typescript@6.0.3)':
@@ -12358,6 +12516,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.62.1(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.62.1(typescript@6.0.3)
@@ -12387,6 +12560,17 @@ snapshots:
- supports-color
- typescript
+ '@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.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.6.0(jiti@2.7.0)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0))
@@ -12403,6 +12587,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.62.1':
dependencies:
'@typescript-eslint/types': 8.62.1
@@ -13794,8 +13983,6 @@ snapshots:
iterator.prototype: 1.1.5
safe-array-concat: 1.1.3
- es-module-lexer@2.1.0: {}
-
es-module-lexer@2.2.0: {}
es-object-atoms@1.1.1:
@@ -17381,11 +17568,6 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
- side-channel-list@1.0.0:
- dependencies:
- es-errors: 1.3.0
- object-inspect: 1.13.4
-
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
@@ -17406,14 +17588,6 @@ snapshots:
object-inspect: 1.13.4
side-channel-map: 1.0.1
- side-channel@1.1.0:
- dependencies:
- es-errors: 1.3.0
- object-inspect: 1.13.4
- side-channel-list: 1.0.0
- side-channel-map: 1.0.1
- side-channel-weakmap: 1.0.2
-
side-channel@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -17531,7 +17705,7 @@ snapshots:
internal-slot: 1.1.0
regexp.prototype.flags: 1.5.4
set-function-name: 2.0.2
- side-channel: 1.1.0
+ side-channel: 1.1.1
string.prototype.repeat@1.0.0:
dependencies:
@@ -17960,6 +18134,17 @@ snapshots:
typescript: 6.0.3
yaml: 2.9.0
+ typescript-eslint@8.61.1(eslint@10.6.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.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.61.1(eslint@10.6.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.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
typescript-eslint@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
@@ -18199,7 +18384,7 @@ snapshots:
'@vitest/snapshot': 4.1.9
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
- es-module-lexer: 2.1.0
+ es-module-lexer: 2.2.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.3
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 3df67652b9c477711d9b1fcaa62781b4c26fa308 Mon Sep 17 00:00:00 2001
From: Hanssen0 <0@hanssen0.com>
Date: Fri, 3 Jul 2026 20:29:11 +0800
Subject: [PATCH 2/2] feat(coin): record intents in CoBuild
---
packages/coin/package.json | 1 +
packages/coin/src/barrel.ts | 1 +
packages/coin/src/coBuild.ts | 76 +++++++
packages/coin/src/coin/coin.test.ts | 308 ++++++++++++++++++++++++++++
packages/coin/src/coin/coin.ts | 166 +++++++++++++--
pnpm-lock.yaml | 3 +
6 files changed, 540 insertions(+), 15 deletions(-)
create mode 100644 packages/coin/src/coBuild.ts
diff --git a/packages/coin/package.json b/packages/coin/package.json
index 4db26e288..0082f6061 100644
--- a/packages/coin/package.json
+++ b/packages/coin/package.json
@@ -48,6 +48,7 @@
"access": "public"
},
"dependencies": {
+ "@ckb-ccc/co-build": "workspace:*",
"@ckb-ccc/core": "workspace:*"
},
"packageManager": "pnpm@11.8.0",
diff --git a/packages/coin/src/barrel.ts b/packages/coin/src/barrel.ts
index 2dfaa0f76..a519fe010 100644
--- a/packages/coin/src/barrel.ts
+++ b/packages/coin/src/barrel.ts
@@ -1 +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
index 5b23073b8..deaa615cd 100644
--- a/packages/coin/src/coin/coin.test.ts
+++ b/packages/coin/src/coin/coin.test.ts
@@ -1,5 +1,6 @@
import { ccc } from "@ckb-ccc/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CoinAction } from "../coBuild.js";
import { Coin } from "./coin.js";
let client: ccc.Client;
@@ -1736,4 +1737,311 @@ describe("Coin", () => {
);
});
});
+
+ describe("mint", () => {
+ it("should mint correctly", async () => {
+ const recipientLock = (await signer.getRecommendedAddressObj()).script;
+
+ const { tx, outputIndexes } = await coin.mint(ccc.Transaction.from({}), [
+ { 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(await coin.script)).toBe(true);
+ expect(await coin.amountFrom(tx.getOutput(0)!)).toBe(100n);
+ expect(outputIndexes).toEqual([0]);
+
+ // Verify MintAction is added to CoBuild actions
+ const actions = (await coin.coBuild).findActions(tx, await coin.script);
+ expect(actions.length).toBe(1);
+ });
+
+ it("should apply outputTransformer to mint output", async () => {
+ const coinWithTransformer = new Coin({
+ script: type,
+ signer,
+ cellDeps: [],
+ outputTransformer: async (cell) => {
+ return {
+ ...cell,
+ outputData: ccc.hexFrom(
+ ccc.bytesConcat(
+ ccc.bytesFrom(cell.outputData),
+ new Uint8Array([0x11, 0x22]),
+ ),
+ ),
+ };
+ },
+ });
+ const recipientLock = (await signer.getRecommendedAddressObj()).script;
+
+ const { tx } = await coinWithTransformer.mint(ccc.Transaction.from({}), [
+ { 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(ccc.Transaction.from({}), 100n);
+
+ // Burn should not add outputs
+ expect(tx.outputs.length).toBe(0);
+
+ // Verify BurnAction is added to CoBuild actions
+ const actions = (await coin.coBuild).findActions(tx, await coin.script);
+ expect(actions.length).toBe(1);
+ });
+ });
+
+ 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 correctly for MintAction and BurnAction", async () => {
+ const cobuild = await coin.coBuild;
+
+ // 1. MintAction with to
+ const mintAction = CoinAction.from({
+ type: "Mint",
+ value: {
+ amount: 100,
+ to: lock,
+ },
+ });
+ const { tx: txMint } = await cobuild.appendActions(
+ ccc.Transaction.from({}),
+ mintAction,
+ );
+ const minted = await coin.getIntendedAmountBurned(txMint);
+ expect(minted).toBe(ccc.numFrom(-100));
+
+ // 2. MintAction without to
+ const mintActionWithoutTo = CoinAction.from({
+ type: "Mint",
+ value: {
+ amount: 150,
+ },
+ });
+ const { tx: txMintWithoutTo } = await cobuild.appendActions(
+ ccc.Transaction.from({}),
+ mintActionWithoutTo,
+ );
+ const mintedWithoutTo =
+ await coin.getIntendedAmountBurned(txMintWithoutTo);
+ expect(mintedWithoutTo).toBe(ccc.numFrom(-150));
+
+ // 3. BurnAction
+ const burnAction = CoinAction.from({
+ type: "Burn",
+ value: {
+ amount: 50,
+ },
+ });
+ const { tx: txBurn } = await cobuild.appendActions(
+ ccc.Transaction.from({}),
+ burnAction,
+ );
+ const burned = await coin.getIntendedAmountBurned(txBurn);
+ expect(burned).toBe(ccc.numFrom(50));
+
+ // 4. Both MintAction and BurnAction
+ const { tx: txBoth } = await cobuild.appendActions(
+ ccc.Transaction.from({}),
+ [mintAction, burnAction],
+ );
+ const both = await coin.getIntendedAmountBurned(txBoth);
+ expect(both).toBe(ccc.numFrom(-50)); // -100 + 50 = -50
+ });
+
+ it("should completeInputsByAmount with MintAction", async () => {
+ const cobuild = await 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(baseTx, mintAction);
+
+ // 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(
+ tx,
+ ccc.Zero,
+ -tx.getOutputsCapacity(),
+ );
+ expect(addedCount).toBe(0);
+ expect(tx.inputs.length).toBe(0);
+ });
+
+ it("should completeInputsByAmount with BurnAction", async () => {
+ const cobuild = await 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(baseTx, burnAction);
+
+ // Need 100 output + 50 burn = 150 total.
+ // Since each mock coin is 100, we need 2 inputs (total 200).
+ const { addedCount } = await coin.completeInputsByAmount(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 = await 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(baseTx, [
+ mintAction,
+ burnAction,
+ ]);
+
+ // Need 50 total. 1 mock coin (amount 100) is enough.
+ const { addedCount } = await coin.completeInputsByAmount(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 = await 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(baseTx, [
+ mintAction,
+ burnAction,
+ ]);
+
+ // Complete change
+ const { tx: completedTx } = await coin.completeChangeToLock(tx, lock);
+
+ // 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 = await 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(baseTx, [
+ mintAction,
+ burnAction,
+ ]);
+
+ // Complete using standard complete method
+ const { tx: completedTx } = await coin.complete(tx, (t, amount) => {
+ t.addOutput({ lock, type }, ccc.numLeToBytes(amount, 16));
+ });
+
+ 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
index 76149b799..5b04ed76f 100644
--- a/packages/coin/src/coin/coin.ts
+++ b/packages/coin/src/coin/coin.ts
@@ -1,4 +1,11 @@
+import { coBuild } from "@ckb-ccc/co-build";
import { ccc } from "@ckb-ccc/core";
+import {
+ BurnAction,
+ CoinAction,
+ MintAction,
+ TransferAction,
+} from "../coBuild.js";
import { CoinInfo, CoinInfoLike } from "./coinInfo.js";
import { ErrorCoinInsufficient } from "./error.js";
@@ -29,6 +36,7 @@ export type CoinOptions = {
filter?: ccc.ClientIndexerSearchKeyFilterLike | null;
outputTransformer?:
((cell: ccc.CellAny) => ccc.CellAnyLike | Promise) | null;
+ scriptInfo?: coBuild.ScriptInfoLike | null;
} & (
| {
script: ccc.ScriptLike;
@@ -75,6 +83,9 @@ export class Coin {
public readonly outputTransformer?:
((cell: ccc.CellAny) => ccc.CellAnyLike | Promise) | null;
+ /** CoBuild instance for this Coin. @public */
+ public readonly coBuild: Promise;
+
/**
* Signer used for all input sourcing and transaction completion.
* @throws if the `Coin` was constructed without a `signer`.
@@ -181,6 +192,10 @@ export class Coin {
},
);
})();
+
+ this.coBuild = Promise.all([this.script]).then(
+ ([script]) => new coBuild.CoBuild(script, options.scriptInfo),
+ );
}
/**
@@ -324,6 +339,26 @@ export class Coin {
return (await this.getInfoBurned(txLike)).amount;
}
+ async getIntendedAmountBurned(txLike: ccc.TransactionLike): Promise {
+ const tx = ccc.Transaction.from(txLike);
+
+ return (await this.coBuild)
+ .findActions(tx, await 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
@@ -404,14 +439,18 @@ export class Coin {
const { amount: outAmount, capacity: outCapacity } =
await this.getOutputsInfo(tx);
- const amountBurned = inAmount - outAmount - ccc.numFrom(amountTweak ?? 0);
+ 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 capacityBurned =
+ const capacityExcess =
ccc.numMin(inCapacity - outCapacity, await tx.getFee(this.client)) -
ccc.numFrom(capacityTweak ?? 0);
- if (amountBurned >= ccc.Zero && capacityBurned >= ccc.Zero) {
+ if (amountExcess >= ccc.Zero && capacityExcess >= ccc.Zero) {
return { addedCount: 0, tx };
}
@@ -429,7 +468,7 @@ export class Coin {
? undefined
: info;
},
- CoinInfo.from({ amount: amountBurned, capacity: capacityBurned }),
+ CoinInfo.from({ amount: amountExcess, capacity: capacityExcess }),
);
if (accumulated === undefined || accumulated.amount >= ccc.Zero) {
@@ -499,14 +538,16 @@ export class Coin {
addedInputs += res.addedCount;
}
- const amountBurned = await this.getAmountBurned(tx);
+ const amountExcess =
+ (await this.getAmountBurned(tx)) -
+ (await this.getIntendedAmountBurned(tx));
- if (amountBurned < ccc.Zero) {
+ if (amountExcess < ccc.Zero) {
throw new ErrorCoinInsufficient({
- amount: -amountBurned,
+ amount: -amountExcess,
type: await this.script,
});
- } else if (amountBurned === ccc.Zero) {
+ } else if (amountExcess === ccc.Zero) {
// No change needed — inputs and outputs are perfectly balanced
return { tx, addedInputs, hasChanged: false, changeIndex: undefined };
}
@@ -514,7 +555,7 @@ export class Coin {
if (!(options?.shouldAddInputs ?? true)) {
// Caller manages inputs manually; apply change with current amount as-is
- await Promise.resolve(change(tx, amountBurned));
+ await Promise.resolve(change(tx, amountExcess));
return { tx, addedInputs, hasChanged: true, changeIndex: undefined };
}
@@ -523,14 +564,20 @@ export class Coin {
// the real transaction with the correct final amount.
const cloned = tx.clone();
const capacityBefore = tx.getOutputsCapacity();
- await Promise.resolve(change(cloned, amountBurned));
+ await Promise.resolve(change(cloned, amountExcess));
const extraCapacity = cloned.getOutputsCapacity() - capacityBefore;
const res2 = await this.completeInputsByAmount(tx, ccc.Zero, extraCapacity);
tx = res2.tx;
addedInputs += res2.addedCount;
- await Promise.resolve(change(tx, await this.getAmountBurned(tx)));
+ await Promise.resolve(
+ change(
+ tx,
+ (await this.getAmountBurned(tx)) -
+ (await this.getIntendedAmountBurned(tx)),
+ ),
+ );
return { tx, addedInputs, hasChanged: true, changeIndex: undefined };
}
@@ -648,7 +695,6 @@ export class Coin {
* @example
* ```typescript
* const { tx: completedTx } = await coin.completeBy(tx);
- * await completedTx.completeInputsByCapacity(coin.signer);
* await completedTx.completeFeeBy(coin.signer);
* await coin.signer.sendTransaction(completedTx);
* ```
@@ -670,8 +716,9 @@ export class Coin {
* Make the transaction perform transfer actions to the specified recipients.
*
* @returns An object containing:
- * - `tx`: The updated transaction with added transfer outputs
+ * - `tx`: The updated transaction with added transfer outputs and CoBuild actions.
* - `outputIndexes`: The indexes of the newly added Coin outputs in `tx.outputs`.
+ * - `witnessIndex`: The index of the witness where CoBuild actions were appended.
*
* @example
* ```typescript
@@ -679,7 +726,6 @@ export class Coin {
* { to: recipientLock, amount: 100n },
* ]);
* const { tx: completedTx } = await coin.completeBy(tx);
- * await completedTx.completeInputsByCapacity(coin.signer);
* await completedTx.completeFeeBy(coin.signer);
* await coin.signer.sendTransaction(completedTx);
* ```
@@ -693,6 +739,7 @@ export class Coin {
): Promise<{
tx: ccc.Transaction;
outputIndexes: number[];
+ witnessIndex: number;
}> {
const tx = ccc.Transaction.from(txLike);
const outputIndexes = [];
@@ -711,8 +758,97 @@ export class Coin {
}
return {
- tx,
+ ...(await (
+ await this.coBuild
+ ).appendActions(
+ tx,
+ transfers.map((transfer) => TransferAction.from(transfer)),
+ )),
+ outputIndexes,
+ };
+ }
+
+ /**
+ * Make the transaction perform mint actions to the specified recipients.
+ *
+ * @returns An object containing:
+ * - `tx`: The updated transaction with added mint outputs and CoBuild actions.
+ * - `outputIndexes`: The indexes of the newly added Coin outputs in `tx.outputs`.
+ * - `witnessIndex`: The index of the witness where CoBuild actions were appended.
+ *
+ * @example
+ * ```typescript
+ * const { tx } = await coin.mint(ccc.Transaction.from({}), [
+ * { to: recipientLock, amount: 100n },
+ * ]);
+ * const { tx: completedTx } = await coin.completeBy(tx);
+ * await completedTx.completeFeeBy(coin.signer);
+ * await coin.signer.sendTransaction(completedTx);
+ * ```
+ */
+ async mint(
+ txLike: ccc.TransactionLike,
+ mints: {
+ to: ccc.ScriptLike;
+ amount: ccc.NumLike;
+ }[],
+ ): Promise<{
+ tx: ccc.Transaction;
+ outputIndexes: number[];
+ witnessIndex: number;
+ }> {
+ const tx = ccc.Transaction.from(txLike);
+ const outputIndexes = [];
+
+ for (const { to, amount } of mints) {
+ let cell = ccc.CellAny.from({
+ cellOutput: { lock: to, type: await this.script },
+ outputData: ccc.numLeToBytes(ccc.numFrom(amount), 16),
+ });
+
+ if (this.outputTransformer) {
+ cell = ccc.CellAny.from(await this.outputTransformer(cell));
+ }
+
+ outputIndexes.push(tx.addOutput(cell.cellOutput, cell.outputData) - 1);
+ }
+
+ return {
+ ...(await (
+ await this.coBuild
+ ).appendActions(
+ tx,
+ mints.map((mint) => MintAction.from(mint)),
+ )),
outputIndexes,
};
}
+
+ /**
+ * Make the transaction perform burn actions for the specified amount.
+ *
+ * @returns An object containing:
+ * - `tx`: The updated transaction with appended CoBuild actions.
+ * - `witnessIndex`: The index of the witness where CoBuild actions were appended.
+ *
+ * @example
+ * ```typescript
+ * const { tx } = await coin.burn(ccc.Transaction.from({}), 100n);
+ * const { tx: completedTx } = await coin.completeBy(tx);
+ * await completedTx.completeFeeBy(coin.signer);
+ * await coin.signer.sendTransaction(completedTx);
+ * ```
+ */
+ async burn(
+ txLike: ccc.TransactionLike,
+ amount: ccc.NumLike,
+ ): Promise<{
+ tx: ccc.Transaction;
+ witnessIndex: number;
+ }> {
+ return (await this.coBuild).appendActions(
+ txLike,
+ BurnAction.from({ amount }),
+ );
+ }
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 015850a1e..f780160ac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -179,6 +179,9 @@ importers:
packages/coin:
dependencies:
+ '@ckb-ccc/co-build':
+ specifier: workspace:*
+ version: link:../co-build
'@ckb-ccc/core':
specifier: workspace:*
version: link:../core