diff --git a/.changeset/tasty-walls-appear.md b/.changeset/tasty-walls-appear.md new file mode 100644 index 0000000..293777b --- /dev/null +++ b/.changeset/tasty-walls-appear.md @@ -0,0 +1,5 @@ +--- +"@offckb/cli": minor +--- + +Refactor UDT CLI support: reuse `balance` and `transfer` commands for CKB and UDT queries, and add `offckb udt issue` / `offckb udt destroy` subcommands. diff --git a/src/cli.ts b/src/cli.ts index d4ffd03..2fe5d98 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { startNode, stopNode } from './cmd/node'; import { accounts } from './cmd/accounts'; import { clean } from './cmd/clean'; @@ -8,6 +8,7 @@ import { DepositOptions, deposit } from './cmd/deposit'; import { DeployOptions, deploy } from './cmd/deploy'; import { TransferOptions, transfer } from './cmd/transfer'; import { BalanceOption, balanceOf } from './cmd/balance'; +import { udtIssue, udtDestroy, UdtIssueOption, UdtDestroyOption } from './cmd/udt'; import { createScriptProject, CreateScriptProjectOptions } from './cmd/create'; import { Config, ConfigItem } from './cmd/config'; import { devnetConfig } from './cmd/devnet-config'; @@ -137,13 +138,15 @@ program }); program - .command('transfer [toAddress] [amountInCKB]') - .description('Transfer CKB tokens to address, only devnet and testnet') + .command('transfer [toAddress] [amount]') + .description('Transfer CKB or UDT tokens to address, only devnet and testnet') .option('--network ', 'Specify the network to transfer to', 'devnet') - .option('--privkey ', 'Specify the private key to transfer CKB') + .option('--privkey ', 'Specify the private key to transfer') + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) + .option('--udt-type-args ', 'Specify the UDT type script args to transfer UDT') .option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain') - .action(async (toAddress: string, amountInCKB: string, options: TransferOptions) => { - return transfer(toAddress, amountInCKB, options); + .action(async (toAddress: string, amount: string, options: TransferOptions) => { + return transfer(toAddress, amount, options); }); program @@ -158,12 +161,40 @@ program program .command('balance [toAddress]') - .description('Check account balance, only devnet and testnet') + .description('Check account balance (CKB + detected SUDT/xUDT), only devnet and testnet') .option('--network ', 'Specify the network to check', 'devnet') + .addOption(new Option('--udt-kind ', 'Filter by UDT kind').choices(['sudt', 'xudt'])) + .option('--udt-type-args ', 'Filter by UDT type script args') + .option('--no-udt', 'Skip UDT balance scan') .action(async (toAddress: string, options: BalanceOption) => { return balanceOf(toAddress, options); }); +const udtCommand = program.command('udt').description('UDT token commands'); + +udtCommand + .command('issue ') + .description('Issue new UDT tokens, only devnet and testnet') + .option('--network ', 'Specify the network', 'devnet') + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) + .option('--type-args ', 'Specify the UDT type script args (xudt only; defaults to signer lock hash)') + .option('--to ', 'Specify the receiver address (defaults to signer)') + .option('--privkey ', 'Specify the private key to issue UDT') + .action(async (amount: string, options: UdtIssueOption) => { + return udtIssue(amount, options); + }); + +udtCommand + .command('destroy ') + .description('Destroy UDT tokens, only devnet and testnet') + .option('--network ', 'Specify the network', 'devnet') + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) + .requiredOption('--type-args ', 'Specify the UDT type script args') + .option('--privkey ', 'Specify the private key to destroy UDT') + .action(async (amount: string, options: UdtDestroyOption) => { + return udtDestroy(amount, options); + }); + program .command('debugger') .description('Port of the raw CKB Standalone Debugger') diff --git a/src/cmd/balance.ts b/src/cmd/balance.ts index 5e11d3c..a036a09 100644 --- a/src/cmd/balance.ts +++ b/src/cmd/balance.ts @@ -1,9 +1,13 @@ -import { CKB } from '../sdk/ckb'; +import { CKB, UdtBalanceInfo } from '../sdk/ckb'; import { validateNetworkOpt } from '../util/validator'; -import { NetworkOption, Network } from '../type/base'; +import { NetworkOption, Network, UdtKind } from '../type/base'; import { logger } from '../util/logger'; -export interface BalanceOption extends NetworkOption {} +export interface BalanceOption extends NetworkOption { + udtKind?: UdtKind; + udtTypeArgs?: string; + udt?: boolean; +} export async function balanceOf(address: string, opt: BalanceOption = { network: Network.devnet }) { const network = opt.network; @@ -11,7 +15,32 @@ export async function balanceOf(address: string, opt: BalanceOption = { network: const ckb = new CKB({ network }); - const balanceInCKB = await ckb.balance(address); - logger.info(`Balance: ${balanceInCKB} CKB`); + const [balanceInCKB, udtBalances] = await Promise.all([ + ckb.balance(address), + opt.udt !== false ? ckb.detectUdtBalances(address) : Promise.resolve([]), + ]); + logger.info(`CKB: ${balanceInCKB}`); + + const filtered = filterUdtBalances(udtBalances, opt); + + if (filtered.length > 0) { + logger.info('UDT:'); + for (const udt of filtered) { + logger.info(` ${udt.kind} (args=${udt.args}): ${udt.balance}`); + } + } + process.exit(0); } + +function filterUdtBalances(balances: UdtBalanceInfo[], opt: BalanceOption): UdtBalanceInfo[] { + if (!opt.udtKind && !opt.udtTypeArgs) { + return balances; + } + + return balances.filter((udt) => { + const kindMatch = opt.udtKind ? udt.kind === opt.udtKind : true; + const argsMatch = opt.udtTypeArgs ? udt.args === opt.udtTypeArgs : true; + return kindMatch && argsMatch; + }); +} diff --git a/src/cmd/transfer.ts b/src/cmd/transfer.ts index bea4688..1518eb3 100644 --- a/src/cmd/transfer.ts +++ b/src/cmd/transfer.ts @@ -1,18 +1,15 @@ import { CKB } from '../sdk/ckb'; -import { NetworkOption, Network } from '../type/base'; -import { buildTestnetTxLink } from '../util/link'; -import { validateNetworkOpt } from '../util/validator'; -import { logger } from '../util/logger'; +import { NetworkOption, Network, UdtKind } from '../type/base'; +import { logTxSuccess } from '../util/link'; +import { validateNetworkOpt, validateUdtKind, validateUdtTypeArgs } from '../util/validator'; export interface TransferOptions extends NetworkOption { privkey?: string | null; + udtKind?: UdtKind; + udtTypeArgs?: string; } -export async function transfer( - toAddress: string, - amountInCKB: string, - opt: TransferOptions = { network: Network.devnet }, -) { +export async function transfer(toAddress: string, amount: string, opt: TransferOptions = { network: Network.devnet }) { const network = opt.network; validateNetworkOpt(network); @@ -23,15 +20,27 @@ export async function transfer( const privateKey = opt.privkey; const ckb = new CKB({ network }); + if (opt.udtTypeArgs) { + const kind = opt.udtKind ?? 'sudt'; + validateUdtKind(kind); + const udtTypeArgs = validateUdtTypeArgs(kind, opt.udtTypeArgs); + const udtType = await ckb.buildUdtTypeScript(kind, udtTypeArgs); + const txHash = await ckb.udtTransfer({ + toAddress, + amount, + privateKey, + udtType, + kind, + }); + + logTxSuccess(network, txHash, 'transfer UDT'); + return; + } + const txHash = await ckb.transfer({ toAddress, - amountInCKB, + amountInCKB: amount, privateKey, }); - if (network === 'testnet') { - logger.info(`Successfully transfer, check ${buildTestnetTxLink(txHash)} for details.`); - return; - } - - logger.info('Successfully transfer, txHash:', txHash); + logTxSuccess(network, txHash, 'transfer'); } diff --git a/src/cmd/udt.ts b/src/cmd/udt.ts new file mode 100644 index 0000000..4d7ea23 --- /dev/null +++ b/src/cmd/udt.ts @@ -0,0 +1,64 @@ +import { CKB } from '../sdk/ckb'; +import { NetworkOption, Network, UdtKind } from '../type/base'; +import { logTxSuccess } from '../util/link'; +import { validateNetworkOpt, validateUdtKind, validateUdtTypeArgs } from '../util/validator'; + +export interface UdtIssueOption extends NetworkOption { + udtKind: UdtKind; + typeArgs?: string; + to?: string; + privkey: string; +} + +export interface UdtDestroyOption extends NetworkOption { + udtKind: UdtKind; + typeArgs: string; + privkey: string; +} + +export async function udtIssue( + amount: string, + opt: UdtIssueOption = { network: Network.devnet, udtKind: 'sudt', privkey: '' }, +) { + const network = opt.network; + validateNetworkOpt(network); + validateUdtKind(opt.udtKind); + + if (!opt.privkey) { + throw new Error('--privkey is required!'); + } + + const ckb = new CKB({ network }); + const txHash = await ckb.udtIssue({ + privateKey: opt.privkey, + kind: opt.udtKind, + amount, + typeArgs: opt.typeArgs ? validateUdtTypeArgs(opt.udtKind, opt.typeArgs) : undefined, + toAddress: opt.to, + }); + + logTxSuccess(network, txHash, 'issued UDT'); +} + +export async function udtDestroy( + amount: string, + opt: UdtDestroyOption = { network: Network.devnet, udtKind: 'sudt', typeArgs: '', privkey: '' }, +) { + const network = opt.network; + validateNetworkOpt(network); + validateUdtKind(opt.udtKind); + + if (!opt.privkey) { + throw new Error('--privkey is required!'); + } + + const ckb = new CKB({ network }); + const txHash = await ckb.udtDestroy({ + privateKey: opt.privkey, + kind: opt.udtKind, + amount, + typeArgs: validateUdtTypeArgs(opt.udtKind, opt.typeArgs), + }); + + logTxSuccess(network, txHash, 'destroyed UDT'); +} diff --git a/src/sdk/ckb.ts b/src/sdk/ckb.ts index 1c07cdc..328a185 100644 --- a/src/sdk/ckb.ts +++ b/src/sdk/ckb.ts @@ -2,13 +2,26 @@ // to replace lumos with ccc import { ccc, ClientPublicMainnet, ClientPublicTestnet, OutPointLike, Script } from '@ckb-ccc/core'; -import { isValidNetworkString, normalizePrivKey } from '../util/validator'; +import { isValidNetworkString, normalizePrivKey, validateUdtAmount, validateUdtTypeArgs } from '../util/validator'; import { networks } from './network'; -import { buildCCCDevnetKnownScripts } from '../scripts/private'; +import { buildCCCDevnetKnownScripts, getDevnetSystemScriptsFromListHashes } from '../scripts/private'; +import { MAINNET_SYSTEM_SCRIPTS, TESTNET_SYSTEM_SCRIPTS } from '../scripts/public'; +import { SystemScriptsRecord } from '../scripts/type'; import { Migration } from '../deploy/migration'; -import { Network, HexNumber, HexString } from '../type/base'; +import { Network, HexNumber, HexString, UdtKind } from '../type/base'; + +export { UdtKind } from '../type/base'; import { logger } from '../util/logger'; +const DEFAULT_UDT_SCAN_MAX_CELLS = 1000; +const DEFAULT_UDT_DESTROY_MAX_INPUT_CELLS = 100; + +interface UdtScriptInfo { + codeHash: HexString; + hashType: string; + cellDeps: ccc.CellDepInfoLike[]; +} + export class CKBProps { network?: Network; feeRate?: number; @@ -31,6 +44,45 @@ export interface TransferOption { export type TransferAllOption = Pick; +export interface UdtTransferOption { + privateKey: HexString; + toAddress: string; + amount: HexNumber; + udtType: ccc.Script; + kind: UdtKind; +} + +export interface UdtIssueOption { + privateKey: HexString; + kind: UdtKind; + amount: HexNumber; + typeArgs?: HexString; + toAddress?: string; +} + +export interface UdtDestroyOption { + privateKey: HexString; + kind: UdtKind; + typeArgs: HexString; + amount: HexNumber; +} + +export interface UdtBalanceInfo { + kind: UdtKind; + codeHash: HexString; + hashType: string; + args: HexString; + balance: string; +} + +function readUdtBalance(outputData: string | ccc.HexLike): bigint | null { + try { + return BigInt(ccc.udtBalanceFrom(outputData)); + } catch { + return null; + } +} + export class CKB { public network: Network; public feeRate: number; @@ -165,6 +217,296 @@ export class CKB { return txHash; } + private getSystemScripts(): SystemScriptsRecord { + if (this.network === Network.mainnet) { + return MAINNET_SYSTEM_SCRIPTS; + } + if (this.network === Network.testnet) { + return TESTNET_SYSTEM_SCRIPTS; + } + const scripts = getDevnetSystemScriptsFromListHashes(); + if (!scripts) { + throw new Error(`Failed to load devnet system scripts`); + } + return scripts; + } + + async buildUdtTypeScript(kind: UdtKind, args: HexString): Promise { + if (kind === 'xudt') { + return ccc.Script.fromKnownScript(this.client, ccc.KnownScript.XUdt, args); + } + + const scriptInfo = await this.getUdtScriptInfo(kind); + return ccc.Script.from({ + codeHash: scriptInfo.codeHash, + hashType: scriptInfo.hashType, + args, + }); + } + + private async getUdtScriptInfo(kind: UdtKind): Promise { + if (kind === 'xudt') { + return this.client.getKnownScript(ccc.KnownScript.XUdt); + } + + const systemScripts = this.getSystemScripts(); + const sudtScript = systemScripts.sudt?.script; + if (!sudtScript) { + throw new Error(`SUDT script not found on ${this.network}`); + } + return sudtScript; + } + + async detectUdtBalances( + address: string, + { maxCells = DEFAULT_UDT_SCAN_MAX_CELLS }: { maxCells?: number } = {}, + ): Promise { + const lock = (await ccc.Address.fromString(address, this.client)).script; + + const sudtScriptInfo = await this.getUdtScriptInfo('sudt').catch(() => null); + const xudtScriptInfo = await this.getUdtScriptInfo('xudt').catch(() => null); + + const balances = new Map< + string, + { kind: UdtKind; codeHash: HexString; hashType: string; args: HexString; balance: bigint } + >(); + + let scanned = 0; + + const scan = async (scriptInfo: UdtScriptInfo, kind: UdtKind) => { + for await (const cell of this.client.findCells( + { + script: { + codeHash: scriptInfo.codeHash, + hashType: scriptInfo.hashType, + args: '0x', + }, + scriptType: 'type', + scriptSearchMode: 'prefix', + filter: { script: lock }, + withData: true, + }, + 'asc', + )) { + if (scanned >= maxCells) { + logger.warn(`UDT balance scan stopped after ${maxCells} cells; balances may be incomplete`); + break; + } + scanned++; + + const type = cell.cellOutput.type; + if (!type) { + continue; + } + + const cellBalance = readUdtBalance(cell.outputData); + if (cellBalance == null) { + logger.debug(`Skipping corrupted UDT cell ${cell.outPoint?.txHash}:${cell.outPoint?.index}`); + continue; + } + + const key = `${kind}:${type.codeHash}:${type.hashType}:${type.args}`; + const entry = balances.get(key); + if (entry) { + entry.balance += cellBalance; + } else { + balances.set(key, { + kind, + codeHash: type.codeHash as HexString, + hashType: String(type.hashType), + args: type.args as HexString, + balance: cellBalance, + }); + } + } + }; + + if (sudtScriptInfo) { + await scan(sudtScriptInfo, 'sudt'); + } + if (xudtScriptInfo) { + await scan(xudtScriptInfo, 'xudt'); + } + + return Array.from(balances.values()).map((item) => ({ + ...item, + balance: item.balance.toString(), + })); + } + + async udtBalance( + address: string, + udtType: ccc.Script, + { maxCells = DEFAULT_UDT_SCAN_MAX_CELLS }: { maxCells?: number } = {}, + ): Promise { + const lock = (await ccc.Address.fromString(address, this.client)).script; + let balance = BigInt(0); + let scanned = 0; + for await (const cell of this.client.findCellsByLock(lock, udtType, true)) { + scanned++; + if (scanned > maxCells) { + logger.warn(`UDT balance scan stopped after ${maxCells} cells; balances may be incomplete`); + break; + } + const cellBalance = readUdtBalance(cell.outputData); + if (cellBalance != null) { + balance += cellBalance; + } + } + return balance.toString(); + } + + async udtTransfer({ privateKey, toAddress, amount, udtType, kind }: UdtTransferOption): Promise { + const signer = this.buildSigner(privateKey); + const to = await ccc.Address.fromString(toAddress, this.client); + const amountBigInt = validateUdtAmount(amount); + + const outputsData = [ccc.hexFrom(ccc.numToBytes(amountBigInt, 16))]; + const tx = ccc.Transaction.from({ + outputs: [ + { + lock: to.script, + type: udtType, + capacity: ccc.fixedPointFrom(0), + }, + ], + outputsData, + }); + + const scriptInfo = await this.getUdtScriptInfo(kind); + tx.addCellDeps(scriptInfo.cellDeps.map((dep) => dep.cellDep)); + + await tx.completeInputsByUdt(signer, udtType); + + const inputsUdtBalance = await tx.getInputsUdtBalance(this.client, udtType); + const outputsUdtBalance = tx.getOutputsUdtBalance(udtType); + const changeAmount = inputsUdtBalance - outputsUdtBalance; + if (changeAmount > BigInt(0)) { + const from = await signer.getAddressObjSecp256k1(); + tx.outputs.push( + ccc.CellOutput.from( + { + lock: from.script, + type: udtType, + capacity: ccc.fixedPointFrom(0), + }, + ccc.hexFrom(ccc.numToBytes(changeAmount, 16)), + ), + ); + tx.outputsData.push(ccc.hexFrom(ccc.numToBytes(changeAmount, 16))); + } + + await tx.completeFeeBy(signer, this.feeRate); + const txHash = await signer.sendTransaction(tx); + return txHash; + } + + async udtIssue({ privateKey, kind, amount, typeArgs, toAddress }: UdtIssueOption): Promise { + const signer = this.buildSigner(privateKey); + const signerAddress = await signer.getAddressObjSecp256k1(); + const to = toAddress ? await ccc.Address.fromString(toAddress, this.client) : signerAddress; + const amountBigInt = validateUdtAmount(amount); + + let resolvedTypeArgs: HexString; + if (kind === 'sudt') { + if (typeArgs) { + logger.warn('SUDT type args are derived from the issuer lock hash; --type-args is ignored'); + } + const issuerLockHash = signerAddress.script.hash(); + resolvedTypeArgs = ('0x' + issuerLockHash.slice(2, 42)) as HexString; + } else { + if (typeArgs) { + resolvedTypeArgs = validateUdtTypeArgs(kind, typeArgs); + } else { + const issuerLockHash = signerAddress.script.hash(); + resolvedTypeArgs = issuerLockHash as HexString; + } + } + + const udtType = await this.buildUdtTypeScript(kind, resolvedTypeArgs); + + const outputsData = [ccc.hexFrom(ccc.numToBytes(amountBigInt, 16))]; + const tx = ccc.Transaction.from({ + outputs: [ + { + lock: to.script, + type: udtType, + capacity: ccc.fixedPointFrom(0), + }, + ], + outputsData, + }); + + const scriptInfo = await this.getUdtScriptInfo(kind); + tx.addCellDeps(scriptInfo.cellDeps.map((dep) => dep.cellDep)); + + await tx.completeInputsByCapacity(signer); + await tx.completeFeeBy(signer, this.feeRate); + const txHash = await signer.sendTransaction(tx); + return txHash; + } + + async udtDestroy( + { privateKey, kind, typeArgs, amount }: UdtDestroyOption, + { maxInputCells = DEFAULT_UDT_DESTROY_MAX_INPUT_CELLS }: { maxInputCells?: number } = {}, + ): Promise { + const signer = this.buildSigner(privateKey); + const from = await signer.getAddressObjSecp256k1(); + const validatedTypeArgs = validateUdtTypeArgs(kind, typeArgs); + const udtType = await this.buildUdtTypeScript(kind, validatedTypeArgs); + const destroyAmount = validateUdtAmount(amount); + + const cells: ccc.Cell[] = []; + let totalBalance = BigInt(0); + for await (const cell of this.client.findCellsByLock(from.script, udtType, true)) { + if (cells.length >= maxInputCells) { + throw new Error(`Too many UDT cells to destroy (limit: ${maxInputCells}); split into smaller operations`); + } + const cellBalance = readUdtBalance(cell.outputData); + if (cellBalance == null) { + continue; + } + cells.push(cell); + totalBalance += cellBalance; + } + + if (totalBalance < destroyAmount) { + throw new Error(`Insufficient UDT balance: ${totalBalance} < ${destroyAmount}`); + } + + if (destroyAmount === totalBalance) { + throw new Error( + 'Destroying the entire UDT balance may be rejected by the UDT script. Leave at least 1 token or use a smaller amount.', + ); + } + + const tx = ccc.Transaction.from({}); + for (const cell of cells) { + tx.addInput({ previousOutput: cell.outPoint }); + } + + const remaining = totalBalance - destroyAmount; + tx.addOutput( + ccc.CellOutput.from( + { + lock: from.script, + type: udtType, + capacity: ccc.fixedPointFrom(0), + }, + ccc.hexFrom(ccc.numToBytes(remaining, 16)), + ), + ccc.hexFrom(ccc.numToBytes(remaining, 16)), + ); + + const scriptInfo = await this.getUdtScriptInfo(kind); + tx.addCellDeps(scriptInfo.cellDeps.map((dep) => dep.cellDep)); + + await tx.completeInputsByCapacity(signer); + await tx.completeFeeBy(signer, this.feeRate); + const txHash = await signer.sendTransaction(tx); + return txHash; + } + async deployScript(scriptBinBytes: Uint8Array, privateKey: string): Promise { const signer = this.buildSigner(privateKey); const signerSecp256k1Address = await signer.getAddressObjSecp256k1(); diff --git a/src/type/base.ts b/src/type/base.ts index e3fd0ac..2eb7d40 100644 --- a/src/type/base.ts +++ b/src/type/base.ts @@ -13,3 +13,5 @@ export type H256 = string; export type HexString = string; export type HexNumber = string; + +export type UdtKind = 'sudt' | 'xudt'; diff --git a/src/util/link.ts b/src/util/link.ts index 5de75d4..5f21ca6 100644 --- a/src/util/link.ts +++ b/src/util/link.ts @@ -1,3 +1,14 @@ +import { logger } from './logger'; +import { Network } from '../type/base'; + export function buildTestnetTxLink(txHash: string) { return `https://pudge.explorer.nervos.org/transaction/${txHash}`; } + +export function logTxSuccess(network: Network, txHash: string, action: string) { + if (network === 'testnet') { + logger.info(`Successfully ${action}, check ${buildTestnetTxLink(txHash)} for details.`); + } else { + logger.info(`Successfully ${action}, txHash:`, txHash); + } +} diff --git a/src/util/validator.ts b/src/util/validator.ts index 53e9fe7..fa0021e 100644 --- a/src/util/validator.ts +++ b/src/util/validator.ts @@ -1,6 +1,6 @@ import path from 'path'; import fs from 'fs'; -import { Network } from '../type/base'; +import { Network, HexString, UdtKind } from '../type/base'; import { logger } from './logger'; export function validateTypescriptWorkspace() { @@ -113,3 +113,50 @@ export function normalizePrivKey(privKey: string): string { // Return the formally strictly padded ckb format `0x` string return '0x' + key; } + +export function isValidUdtKind(kind: string): kind is UdtKind { + return kind === 'sudt' || kind === 'xudt'; +} + +export function validateUdtKind(kind?: string): asserts kind is UdtKind { + if (!kind) { + throw new Error('UDT kind is required'); + } + if (!isValidUdtKind(kind)) { + throw new Error(`invalid UDT kind "${kind}", must be "sudt" or "xudt"`); + } +} + +const U128_MAX = (BigInt(1) << BigInt(128)) - BigInt(1); + +export function validateUdtAmount(amount: string): bigint { + if (!/^\d+$/.test(amount)) { + throw new Error(`invalid UDT amount "${amount}", must be a non-negative decimal integer`); + } + const value = BigInt(amount); + if (value > U128_MAX) { + throw new Error(`UDT amount exceeds 128-bit max: ${amount}`); + } + return value; +} + +const HEX_REGEX = /^0x[0-9a-fA-F]*$/; + +export function validateHexString(value: string, name: string): HexString { + if (!value || !HEX_REGEX.test(value)) { + throw new Error(`invalid ${name} "${value}", must be a hex string starting with 0x`); + } + return value as HexString; +} + +export function validateUdtTypeArgs(kind: UdtKind, typeArgs: string): HexString { + const hex = validateHexString(typeArgs, 'type args'); + const byteLength = (hex.length - 2) / 2; + if (kind === 'sudt' && byteLength !== 20) { + throw new Error(`invalid SUDT type args length: expected 20 bytes, got ${byteLength}`); + } + if (kind === 'xudt' && byteLength !== 32) { + throw new Error(`invalid xUDT type args length: expected 32 bytes, got ${byteLength}`); + } + return hex; +} diff --git a/tests/sdk/ckb.udt.test.ts b/tests/sdk/ckb.udt.test.ts new file mode 100644 index 0000000..f36f24c --- /dev/null +++ b/tests/sdk/ckb.udt.test.ts @@ -0,0 +1,194 @@ +import { CKB, UdtKind } from '../../src/sdk/ckb'; +import { Network } from '../../src/type/base'; +import { logger } from '../../src/util/logger'; + +jest.mock('../../src/sdk/network', () => ({ + networks: { + devnet: { rpc_url: 'http://localhost:8114', proxy_rpc_url: 'http://localhost:8114' }, + testnet: { rpc_url: 'http://testnet', proxy_rpc_url: 'http://testnet' }, + mainnet: { rpc_url: 'http://mainnet', proxy_rpc_url: 'http://mainnet' }, + }, +})); + +jest.mock('../../src/scripts/private', () => ({ + buildCCCDevnetKnownScripts: jest.fn(() => ({})), + getDevnetSystemScriptsFromListHashes: jest.fn(() => ({ + sudt: { + script: { + codeHash: '0x' + 'c3'.repeat(32), + hashType: 'type', + cellDeps: [{ cellDep: { outPoint: { txHash: '0x' + 'aa'.repeat(32), index: 0 }, depType: 'depGroup' } }], + }, + }, + })), +})); + +const mockKnownScript = jest.fn(); +const mockFindCellsByLock = jest.fn(); +const mockFindCells = jest.fn(); +const mockClient = { + getKnownScript: mockKnownScript, + findCellsByLock: mockFindCellsByLock, + findCells: mockFindCells, +}; + +jest.mock('@ckb-ccc/core', () => { + return { + ccc: { + ClientPublicTestnet: jest.fn(() => mockClient), + ClientPublicMainnet: jest.fn(() => mockClient), + Address: { + fromString: jest.fn(async (address: string) => ({ + script: { + codeHash: '0x' + '00'.repeat(32), + hashType: 'type', + args: address.startsWith('ckt1') ? '0x' + '11'.repeat(20) : '0x' + '22'.repeat(20), + }, + })), + }, + Script: { + fromKnownScript: jest.fn(async (_client: unknown, script: unknown, args: string) => ({ + codeHash: script === 'XUdt' ? '0x' + 'dd'.repeat(32) : '0x' + 'cc'.repeat(32), + hashType: 'type', + args, + })), + from: jest.fn((script: unknown) => script), + }, + KnownScript: { XUdt: 'XUdt', TypeId: 'TypeId' }, + udtBalanceFrom: jest.fn((data: string) => { + if (!data || data === '0x') return 0n; + if (data === '0xbad') throw new Error('corrupted'); + return BigInt(data); + }), + fixedPointFrom: jest.fn((value: string | number) => BigInt(value) * BigInt(10 ** 8)), + numToBytes: jest.fn((value: bigint, bytes: number) => value.toString(16).padStart(bytes * 2, '0')), + hexFrom: jest.fn((value: string) => '0x' + value), + Transaction: { + from: jest.fn(() => ({ + outputs: [], + outputsData: [], + inputs: [], + cellDeps: [], + addCellDeps: jest.fn(), + addInput: jest.fn(), + addOutput: jest.fn(), + completeInputsByUdt: jest.fn(), + completeInputsByCapacity: jest.fn(), + completeFeeBy: jest.fn(), + getInputsUdtBalance: jest.fn().mockResolvedValue(0n), + getOutputsUdtBalance: jest.fn().mockReturnValue(0n), + })), + }, + CellOutput: { + from: jest.fn((output: unknown) => output), + }, + SignerCkbPrivateKey: jest.fn(() => ({ + getAddressObjSecp256k1: jest.fn().mockResolvedValue({ + script: { hash: () => '0x' + '00'.repeat(32) }, + }), + sendTransaction: jest.fn().mockResolvedValue('0xtxhash'), + })), + }, + }; +}); + +function createCKB(network: Network = Network.devnet) { + return new CKB({ network, isEnableProxyRpc: false }); +} + +describe('CKB SDK UDT helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockKnownScript.mockResolvedValue({ + codeHash: '0x' + 'dd'.repeat(32), + hashType: 'type', + cellDeps: [{ cellDep: { outPoint: { txHash: '0x' + 'aa'.repeat(32), index: 0 }, depType: 'depGroup' } }], + }); + }); + + describe('buildUdtTypeScript', () => { + it('should build xUDT type script from known script', async () => { + const ckb = createCKB(); + const type = await ckb.buildUdtTypeScript('xudt', '0x' + '12'.repeat(32)); + expect(type.codeHash).toBe('0x' + 'dd'.repeat(32)); + expect(type.args).toBe('0x' + '12'.repeat(32)); + }); + + it('should build SUDT type script from system scripts', async () => { + const ckb = createCKB(); + const type = await ckb.buildUdtTypeScript('sudt', '0x' + '12'.repeat(20)); + expect(type.codeHash).toBe('0x' + 'c3'.repeat(32)); + expect(type.args).toBe('0x' + '12'.repeat(20)); + }); + }); + + describe('detectUdtBalances', () => { + beforeEach(() => { + mockFindCells.mockImplementation(async function* (searchKey: { script: { codeHash: string } }) { + const isSudt = searchKey.script.codeHash === '0x' + 'c3'.repeat(32); + if (isSudt) { + yield makeCell('sudt', '0xabcd', '100'); + yield makeCell('sudt', '0xabcd', '200'); + } else { + yield makeCell('xudt', '0xbeef', '50'); + } + }); + }); + + it('should aggregate UDT balances by kind and args', async () => { + const ckb = createCKB(); + + const balances = await ckb.detectUdtBalances('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9'); + + expect(balances).toHaveLength(2); + expect(balances.find((b) => b.kind === 'sudt' && b.args === '0xabcd')?.balance).toBe('300'); + expect(balances.find((b) => b.kind === 'xudt' && b.args === '0xbeef')?.balance).toBe('50'); + }); + + it('should skip corrupted UDT cells instead of failing', async () => { + mockFindCells.mockImplementation(async function* (searchKey: { script: { codeHash: string } }) { + const isSudt = searchKey.script.codeHash === '0x' + 'c3'.repeat(32); + if (!isSudt) { + return; + } + yield makeCell('sudt', '0xabcd', '100'); + yield makeCell('sudt', '0xabcd', '0xbad'); + yield makeCell('sudt', '0xabcd', '200'); + }); + + const ckb = createCKB(); + const balances = await ckb.detectUdtBalances('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9'); + + expect(balances).toHaveLength(1); + expect(balances[0].balance).toBe('300'); + }); + }); + + describe('udtIssue type args handling', () => { + it('should warn when SUDT issue receives a user type args', async () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + const ckb = createCKB(); + + await ckb.udtIssue({ + privateKey: '0x' + '11'.repeat(32), + kind: 'sudt', + amount: '100', + typeArgs: '0x' + '12'.repeat(20), + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('--type-args is ignored')); + warnSpy.mockRestore(); + }); + }); +}); + +function makeCell(kind: UdtKind, args: string, balance: string) { + const codeHash = kind === 'sudt' ? '0x' + 'c3'.repeat(32) : '0x' + 'dd'.repeat(32); + return { + outPoint: { txHash: '0x' + '00'.repeat(32), index: 0 }, + cellOutput: { + type: { codeHash, hashType: 'type', args }, + }, + outputData: balance, + }; +} diff --git a/tests/udt.test.ts b/tests/udt.test.ts new file mode 100644 index 0000000..0005012 --- /dev/null +++ b/tests/udt.test.ts @@ -0,0 +1,193 @@ +import { Network } from '../src/type/base'; +import { balanceOf } from '../src/cmd/balance'; +import { transfer } from '../src/cmd/transfer'; +import { udtIssue, udtDestroy } from '../src/cmd/udt'; +import { CKB } from '../src/sdk/ckb'; +import { logger } from '../src/util/logger'; + +const mockTypeArgs = '0x' + 'ab'.repeat(20); +const mockUdtType = { codeHash: '0x1234', hashType: 'type', args: mockTypeArgs }; + +jest.mock('../src/sdk/ckb', () => { + return { + CKB: jest.fn().mockImplementation(() => ({ + balance: jest.fn().mockResolvedValue('1234.5678'), + transfer: jest.fn().mockResolvedValue('0xtxhash'), + buildUdtTypeScript: jest.fn().mockResolvedValue(mockUdtType), + detectUdtBalances: jest.fn().mockResolvedValue([ + { + kind: 'sudt', + codeHash: '0x1234', + hashType: 'type', + args: mockTypeArgs, + balance: '1000', + }, + ]), + udtTransfer: jest.fn().mockResolvedValue('0xtxhash'), + udtIssue: jest.fn().mockResolvedValue('0xissuehash'), + udtDestroy: jest.fn().mockResolvedValue('0xdestroyhash'), + })), + }; +}); + +jest.mock('../src/util/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + success: jest.fn(), + debug: jest.fn(), + }, +})); + +function mockProcessExit() { + return jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); +} + +describe('balance command', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should print CKB and detected UDT balances by default', async () => { + const exitSpy = mockProcessExit(); + await balanceOf('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { + network: Network.devnet, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(ckbInstance.balance).toHaveBeenCalled(); + expect(ckbInstance.detectUdtBalances).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('CKB: 1234.5678'); + expect(logger.info).toHaveBeenCalledWith('UDT:'); + expect(logger.info).toHaveBeenCalledWith(` sudt (args=${mockTypeArgs}): 1000`); + expect(exitSpy).toHaveBeenCalledWith(0); + exitSpy.mockRestore(); + }); + + it('should filter UDT balances by kind and type args', async () => { + const exitSpy = mockProcessExit(); + await balanceOf('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { + network: Network.devnet, + udtKind: 'sudt', + udtTypeArgs: mockTypeArgs, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(ckbInstance.detectUdtBalances).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(` sudt (args=${mockTypeArgs}): 1000`); + expect(exitSpy).toHaveBeenCalledWith(0); + exitSpy.mockRestore(); + }); + + it('should skip UDT scan with --no-udt', async () => { + const exitSpy = mockProcessExit(); + await balanceOf('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { + network: Network.devnet, + udt: false, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(ckbInstance.balance).toHaveBeenCalled(); + expect(ckbInstance.detectUdtBalances).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + exitSpy.mockRestore(); + }); +}); + +describe('transfer command', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should transfer CKB by default', async () => { + await transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { + network: Network.devnet, + privkey: '0x1234567812345678123456781234567812345678123456781234567812345678', + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(ckbInstance.transfer).toHaveBeenCalled(); + expect(ckbInstance.udtTransfer).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Successfully transfer, txHash:', '0xtxhash'); + }); + + it('should transfer UDT when --udt-type-args is provided', async () => { + await transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { + network: Network.devnet, + privkey: '0x1234567812345678123456781234567812345678123456781234567812345678', + udtTypeArgs: mockTypeArgs, + udtKind: 'sudt', + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(ckbInstance.buildUdtTypeScript).toHaveBeenCalledWith('sudt', mockTypeArgs); + expect(ckbInstance.udtTransfer).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Successfully transfer UDT, txHash:', '0xtxhash'); + }); + + it('should throw when privkey is missing for UDT transfer', async () => { + await expect( + transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { + network: Network.devnet, + udtTypeArgs: '0xabcd', + }), + ).rejects.toThrow('--privkey is required!'); + }); +}); + +describe('udt command', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('udtIssue', () => { + it('should throw when privkey is missing', async () => { + await expect( + udtIssue('100', { + network: Network.devnet, + udtKind: 'sudt', + privkey: '', + }), + ).rejects.toThrow('--privkey is required!'); + }); + + it('should issue UDT with privkey', async () => { + await udtIssue('100', { + network: Network.devnet, + udtKind: 'sudt', + privkey: '0x1234567812345678123456781234567812345678123456781234567812345678', + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(ckbInstance.udtIssue).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Successfully issued UDT, txHash:', '0xissuehash'); + }); + }); + + describe('udtDestroy', () => { + it('should throw when privkey is missing', async () => { + await expect( + udtDestroy('100', { + network: Network.devnet, + udtKind: 'sudt', + typeArgs: mockTypeArgs, + privkey: '', + }), + ).rejects.toThrow('--privkey is required!'); + }); + + it('should destroy UDT with privkey', async () => { + await udtDestroy('100', { + network: Network.devnet, + udtKind: 'sudt', + typeArgs: mockTypeArgs, + privkey: '0x1234567812345678123456781234567812345678123456781234567812345678', + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(ckbInstance.udtDestroy).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Successfully destroyed UDT, txHash:', '0xdestroyhash'); + }); + }); +}); diff --git a/tests/validator.test.ts b/tests/validator.test.ts index 9b57b26..f216139 100644 --- a/tests/validator.test.ts +++ b/tests/validator.test.ts @@ -1,4 +1,10 @@ -import { normalizePrivKey } from '../src/util/validator'; +import { + normalizePrivKey, + isValidUdtKind, + validateUdtKind, + validateUdtAmount, + validateUdtTypeArgs, +} from '../src/util/validator'; describe('normalizePrivKey', () => { const validHex64 = '1234567812345678123456781234567812345678123456781234567812345678'; @@ -44,3 +50,76 @@ describe('normalizePrivKey', () => { expect(() => normalizePrivKey(longKey)).toThrow('Invalid private key length'); }); }); + +describe('UDT validation helpers', () => { + describe('isValidUdtKind', () => { + it('should accept sudt and xudt', () => { + expect(isValidUdtKind('sudt')).toBe(true); + expect(isValidUdtKind('xudt')).toBe(true); + }); + + it('should reject other strings', () => { + expect(isValidUdtKind('')).toBe(false); + expect(isValidUdtKind('SUDT')).toBe(false); + expect(isValidUdtKind('unknown')).toBe(false); + }); + }); + + describe('validateUdtKind', () => { + it('should accept sudt and xudt', () => { + expect(() => validateUdtKind('sudt')).not.toThrow(); + expect(() => validateUdtKind('xudt')).not.toThrow(); + }); + + it('should reject invalid kinds', () => { + expect(() => validateUdtKind('')).toThrow('UDT kind is required'); + expect(() => validateUdtKind('SUDT')).toThrow('invalid UDT kind'); + }); + }); + + describe('validateUdtAmount', () => { + it('should accept non-negative decimal integers', () => { + expect(validateUdtAmount('0')).toBe(0n); + expect(validateUdtAmount('1')).toBe(1n); + expect(validateUdtAmount('123456789012345678901234567890')).toBe(123456789012345678901234567890n); + }); + + it('should reject negative, decimal, hex, scientific and empty values', () => { + expect(() => validateUdtAmount('-1')).toThrow('invalid UDT amount'); + expect(() => validateUdtAmount('1.5')).toThrow('invalid UDT amount'); + expect(() => validateUdtAmount('0x10')).toThrow('invalid UDT amount'); + expect(() => validateUdtAmount('1e10')).toThrow('invalid UDT amount'); + expect(() => validateUdtAmount('')).toThrow('invalid UDT amount'); + expect(() => validateUdtAmount('abc')).toThrow('invalid UDT amount'); + }); + + it('should reject amounts exceeding u128 max', () => { + const u128Max = (BigInt(1) << BigInt(128)) - BigInt(1); + expect(validateUdtAmount(u128Max.toString())).toBe(u128Max); + expect(() => validateUdtAmount((u128Max + BigInt(1)).toString())).toThrow('exceeds 128-bit max'); + }); + }); + + describe('validateUdtTypeArgs', () => { + it('should accept valid SUDT type args', () => { + const args = '0x' + '12'.repeat(20); + expect(validateUdtTypeArgs('sudt', args)).toBe(args); + }); + + it('should accept valid xUDT type args', () => { + const args = '0x' + '12'.repeat(32); + expect(validateUdtTypeArgs('xudt', args)).toBe(args); + }); + + it('should reject invalid hex', () => { + expect(() => validateUdtTypeArgs('sudt', 'not-hex')).toThrow('invalid type args'); + expect(() => validateUdtTypeArgs('sudt', '')).toThrow('invalid type args'); + }); + + it('should reject wrong lengths', () => { + expect(() => validateUdtTypeArgs('sudt', '0x' + '12'.repeat(19))).toThrow('invalid SUDT type args length'); + expect(() => validateUdtTypeArgs('sudt', '0x' + '12'.repeat(32))).toThrow('invalid SUDT type args length'); + expect(() => validateUdtTypeArgs('xudt', '0x' + '12'.repeat(31))).toThrow('invalid xUDT type args length'); + }); + }); +});