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 @@ +

+ + Logo + +

+ +

+ CCC's Support for Coin +

+ +

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

+ +

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

+ +## 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. + +

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

diff --git a/packages/coin/eslint.config.mjs b/packages/coin/eslint.config.mjs new file mode 100644 index 000000000..b6132c277 --- /dev/null +++ b/packages/coin/eslint.config.mjs @@ -0,0 +1,62 @@ +// @ts-check + +import eslint from "@eslint/js"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import tseslint from "typescript-eslint"; + +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +export default [ + ...tseslint.config({ + files: ["**/*.ts"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + args: "all", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + "@typescript-eslint/unbound-method": ["error", { ignoreStatic: true }], + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/only-throw-error": [ + "error", + { + allowThrowingAny: true, + allowThrowingUnknown: true, + allowRethrowing: true, + }, + ], + "@typescript-eslint/prefer-promise-reject-errors": [ + "error", + { + allowThrowingAny: true, + allowThrowingUnknown: true, + }, + ], + "no-empty": "off", + "prefer-const": [ + "error", + { ignoreReadBeforeAssign: true, destructuring: "all" }, + ], + }, + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: dirname(fileURLToPath(import.meta.url)), + }, + }, + }), + eslintPluginPrettierRecommended, +]; diff --git a/packages/coin/misc/basedirs/dist.commonjs/package.json b/packages/coin/misc/basedirs/dist.commonjs/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/packages/coin/misc/basedirs/dist.commonjs/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/packages/coin/misc/basedirs/dist/package.json b/packages/coin/misc/basedirs/dist/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/packages/coin/misc/basedirs/dist/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/coin/package.json b/packages/coin/package.json new file mode 100644 index 000000000..0082f6061 --- /dev/null +++ b/packages/coin/package.json @@ -0,0 +1,56 @@ +{ + "name": "@ckb-ccc/coin", + "version": "0.0.0", + "description": "Coin", + "author": "Hanssen <0@hanssen0.com>", + "license": "MIT", + "private": false, + "homepage": "https://github.com/ckb-devrel/ccc", + "repository": { + "type": "git", + "url": "git://github.com/ckb-devrel/ccc.git" + }, + "sideEffects": false, + "main": "./dist.commonjs/index.js", + "module": "./dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist.commonjs/index.js" + }, + "./barrel": { + "import": "./dist/barrel.mjs", + "require": "./dist.commonjs/barrel.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "test": "vitest", + "test:ci": "vitest run", + "build": "tsdown", + "lint": "tsc --noEmit && eslint ./src", + "format": "prettier --write . && eslint --fix ./src" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.0.0", + "eslint": "^10.5.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "prettier": "^3.8.4", + "prettier-plugin-organize-imports": "^4.3.0", + "tsdown": "^0.22.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.61.1", + "vitest": "^4.1.9" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@ckb-ccc/co-build": "workspace:*", + "@ckb-ccc/core": "workspace:*" + }, + "packageManager": "pnpm@11.8.0", + "types": "./dist.commonjs/index.d.ts" +} diff --git a/packages/coin/prettier.config.cjs b/packages/coin/prettier.config.cjs new file mode 100644 index 000000000..5e1810363 --- /dev/null +++ b/packages/coin/prettier.config.cjs @@ -0,0 +1,11 @@ +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: [require.resolve("prettier-plugin-organize-imports")], +}; + +module.exports = config; diff --git a/packages/coin/src/barrel.ts b/packages/coin/src/barrel.ts new file mode 100644 index 000000000..a519fe010 --- /dev/null +++ b/packages/coin/src/barrel.ts @@ -0,0 +1,2 @@ +export * from "./coBuild.js"; +export * from "./coin/index.js"; diff --git a/packages/coin/src/coBuild.ts b/packages/coin/src/coBuild.ts new file mode 100644 index 000000000..06b6a9423 --- /dev/null +++ b/packages/coin/src/coBuild.ts @@ -0,0 +1,76 @@ +import { ccc, mol } from "@ckb-ccc/core"; + +const MintActionCodec = mol.table({ + amount: mol.Uint128, + to: ccc.ScriptOpt, +}); + +export type MintActionLike = ccc.EncodableType; + +@ccc.codec(MintActionCodec) +export class MintAction extends ccc.Entity.Base() { + public amount: ccc.Num; + public to?: ccc.Script; + + constructor({ amount, to }: ccc.DecodedType) { + super(); + + this.amount = amount; + this.to = to; + } +} + +const BurnActionCodec = mol.table({ + amount: mol.Uint128, +}); + +export type BurnActionLike = ccc.EncodableType; + +@ccc.codec(BurnActionCodec) +export class BurnAction extends ccc.Entity.Base() { + public amount: ccc.Num; + + constructor({ amount }: ccc.DecodedType) { + super(); + + this.amount = amount; + } +} + +const TransferActionCodec = mol.table({ + amount: mol.Uint128, + to: ccc.ScriptOpt, +}); + +export type TransferActionLike = ccc.EncodableType; + +@ccc.codec(TransferActionCodec) +export class TransferAction extends ccc.Entity.Base< + TransferActionLike, + TransferAction +>() { + public amount: ccc.Num; + public to?: ccc.Script; + + constructor({ amount, to }: ccc.DecodedType) { + super(); + + this.amount = amount; + this.to = to; + } +} + +const CoinActionCodec = mol.union({ + Mint: MintAction, + Burn: BurnAction, + Transfer: TransferAction, +}); + +export type CoinActionLike = ccc.EncodableType; + +@ccc.codec(CoinActionCodec) +export class CoinAction extends ccc.Entity.BaseUnion< + typeof CoinActionCodec, + CoinActionLike, + CoinAction +>() {} diff --git a/packages/coin/src/coin/coin.test.ts b/packages/coin/src/coin/coin.test.ts new file mode 100644 index 000000000..deaa615cd --- /dev/null +++ b/packages/coin/src/coin/coin.test.ts @@ -0,0 +1,2047 @@ +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; +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", + ); + }); + }); + + 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 new file mode 100644 index 000000000..5b04ed76f --- /dev/null +++ b/packages/coin/src/coin/coin.ts @@ -0,0 +1,854 @@ +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"; + +/** + * 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; + scriptInfo?: coBuild.ScriptInfoLike | 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; + + /** 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`. + * @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"], + }, + ); + })(); + + this.coBuild = Promise.all([this.script]).then( + ([script]) => new coBuild.CoBuild(script, options.scriptInfo), + ); + } + + /** + * 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; + } + + 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 + * 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 amountExcess = + inAmount - + outAmount - + ccc.numFrom(amountTweak ?? 0) - + (await this.getIntendedAmountBurned(tx)); + // Try to let Coin inputs absorb the tx fee so no extra CKB capacity cell is needed. + // Cap at the current fee: we never ask Coins to cover more than what the tx owes. + const capacityExcess = + ccc.numMin(inCapacity - outCapacity, await tx.getFee(this.client)) - + ccc.numFrom(capacityTweak ?? 0); + + if (amountExcess >= ccc.Zero && capacityExcess >= 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: amountExcess, capacity: capacityExcess }), + ); + + 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 amountExcess = + (await this.getAmountBurned(tx)) - + (await this.getIntendedAmountBurned(tx)); + + if (amountExcess < ccc.Zero) { + throw new ErrorCoinInsufficient({ + amount: -amountExcess, + type: await this.script, + }); + } else if (amountExcess === 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, amountExcess)); + 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, 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 this.getIntendedAmountBurned(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.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 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.transfer(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 transfer( + txLike: ccc.TransactionLike, + transfers: { + 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 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 { + ...(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/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..f780160ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,49 @@ 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/co-build': + specifier: workspace:* + version: link:../co-build + '@ckb-ccc/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.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 +1089,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 +4245,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 +4271,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 +4285,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 +4301,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 +4331,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 +4349,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 +4366,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 +4384,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 +4402,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 +5715,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 +8402,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 +8414,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 +8975,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 +12352,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 +12397,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 +12421,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 +12444,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 +12474,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 +12500,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 +12519,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 +12563,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 +12590,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 +13986,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 +17571,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 +17591,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 +17708,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 +18137,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 +18387,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: {