diff --git a/packages/demo/package.json b/packages/demo/package.json
index d4c1ce1a..44c89c69 100644
--- a/packages/demo/package.json
+++ b/packages/demo/package.json
@@ -28,6 +28,7 @@
"@ckb-ccc/connector-react": "workspace:*",
"@ckb-ccc/lumos-patches": "workspace:*",
"@ckb-ccc/ssri": "workspace:*",
+ "@ckb-ccc/type-id": "workspace:*",
"@ckb-ccc/udt": "workspace:*",
"@ckb-lumos/ckb-indexer": "0.24.0-next.2",
"@ckb-lumos/common-scripts": "0.24.0-next.2",
diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/deployComponents.tsx b/packages/demo/src/app/connected/(tools)/DeployScript/deployComponents.tsx
new file mode 100644
index 00000000..ed9b804e
--- /dev/null
+++ b/packages/demo/src/app/connected/(tools)/DeployScript/deployComponents.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import { Button } from "@/src/components/Button";
+import { Message } from "@/src/components/Message";
+import { useGetExplorerLink } from "@/src/utils";
+import { ccc } from "@ckb-ccc/connector-react";
+import { FileCode, X } from "lucide-react";
+import type { DeployResult } from "./deployLogic";
+
+function formatCellCreationDate(timestampMs: number): string {
+ try {
+ return new Date(timestampMs).toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch {
+ return "";
+ }
+}
+
+export function TypeIdCellListItem({
+ cell,
+ index,
+ onSelect,
+ isSelected,
+ creationTimestamp,
+}: {
+ cell: ccc.Cell;
+ index: number;
+ onSelect: () => void;
+ isSelected: boolean;
+ creationTimestamp?: number;
+}) {
+ const outPoint = `${cell.outPoint.txHash}:${cell.outPoint.index}`;
+
+ return (
+
+
+
+ #{index + 1}
+ Type ID
+
+
+ Occupied / Capacity: {" "}
+ {ccc.fixedPointToString(ccc.fixedPointFrom(cell.occupiedSize))} /{" "}
+ {ccc.fixedPointToString(cell.cellOutput.capacity)} CKB
+
+
+
+
+ Out Point: {outPoint}
+
+
+ {creationTimestamp == null
+ ? "Unavailable"
+ : formatCellCreationDate(creationTimestamp)}
+
+
+
+ );
+}
+
+export function CellFoundSection({
+ foundCell,
+ onClear,
+}: {
+ foundCell: ccc.Cell;
+ onClear: () => void;
+}) {
+ const { explorerTransaction } = useGetExplorerLink();
+ const typeScript = foundCell.cellOutput.type;
+ const typeId = typeScript?.args;
+
+ return (
+
+
+
+
+ Out Point: {" "}
+ {explorerTransaction(
+ foundCell.outPoint.txHash,
+ `${foundCell.outPoint.txHash}:${foundCell.outPoint.index}`,
+ )}
+
+
+ Occupied / Capacity: {" "}
+ {ccc.fixedPointToString(ccc.fixedPointFrom(foundCell.occupiedSize))} /{" "}
+ {ccc.fixedPointToString(foundCell.cellOutput.capacity)} CKB
+
+
+ Data Hash: {" "}
+
+ {ccc.hashCkb(foundCell.outputData ?? "0x")}
+
+
+ {typeScript && (
+
+ Type Hash: {" "}
+ {typeScript.hash()}
+
+ )}
+ {typeId && typeId !== "0x" && (
+
+ Type ID: {" "}
+ {typeId}
+
+ )}
+
+
+ );
+}
+
+export function DeploymentResultSection({
+ result,
+}: {
+ result: DeployResult & {
+ immutable: boolean;
+ action: "deployed" | "updated";
+ };
+}) {
+ const { explorerTransaction } = useGetExplorerLink();
+ const outPoint = `${result.txHash}:0`;
+
+ return (
+
+
+
+ Out Point: {" "}
+ {explorerTransaction(result.txHash, outPoint)}
+
+
+ Type ID: {" "}
+ {result.typeId}
+
+
+ Data Hash: {" "}
+ {result.dataHash}
+
+ {result.immutable && (
+
+ This cell is immutable and can never be updated.
+
+ )}
+
+
+ );
+}
+
+export function BurnButton({
+ onClick,
+ disabled,
+}: {
+ onClick: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ Burn
+
+ );
+}
diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/deployLogic.ts b/packages/demo/src/app/connected/(tools)/DeployScript/deployLogic.ts
new file mode 100644
index 00000000..79f85408
--- /dev/null
+++ b/packages/demo/src/app/connected/(tools)/DeployScript/deployLogic.ts
@@ -0,0 +1,81 @@
+import { readFileAsBytes } from "@/src/app/utils/(tools)/FileUpload/page";
+import { ccc } from "@ckb-ccc/connector-react";
+import { createTypeId, transferTypeId } from "@ckb-ccc/type-id";
+import { ReactNode } from "react";
+import { createImmutableLock } from "./helpers";
+
+export type Logger = (...args: ReactNode[]) => void;
+export type DeployResult = {
+ txHash: string;
+ typeId: string;
+ dataHash: string;
+};
+
+export async function runDeploy(
+ signer: ccc.Signer,
+ file: File,
+ immutable: boolean,
+ foundCell: ccc.Cell | null,
+ log: Logger,
+): Promise {
+ const fileBytes = (await readFileAsBytes(file)) as ccc.Bytes;
+
+ let tx: ccc.Transaction;
+ let typeIdArgsValue: string;
+
+ if (foundCell) {
+ const typeId = foundCell.cellOutput.type?.args;
+ if (!typeId) {
+ throw new Error("Selected cell does not have a Type ID");
+ }
+ log("Updating existing Type ID cell...");
+
+ ({ tx } = await transferTypeId({
+ client: signer.client,
+ id: typeId,
+ receiver: immutable ? createImmutableLock() : foundCell.cellOutput.lock,
+ data: fileBytes,
+ }));
+ typeIdArgsValue = typeId;
+ } else {
+ log("Building transaction...");
+ const created = await createTypeId({
+ signer,
+ data: fileBytes,
+ receiver: immutable ? createImmutableLock() : undefined,
+ });
+ tx = created.tx;
+ typeIdArgsValue = created.id;
+ log("Type ID created:", typeIdArgsValue);
+ }
+
+ await tx.completeFeeBy(signer);
+ log("Sending transaction...");
+ const txHash = await signer.sendTransaction(tx);
+ log("Transaction sent:", txHash);
+ return {
+ txHash,
+ typeId: typeIdArgsValue,
+ dataHash: ccc.hashCkb(fileBytes),
+ };
+}
+
+/** Burn the selected type_id cell: consume it and send capacity back to the lock (no type script). */
+export async function runBurn(
+ signer: ccc.Signer,
+ foundCell: ccc.Cell,
+ log: Logger,
+): Promise {
+ const { lock } = foundCell.cellOutput;
+ const tx = ccc.Transaction.from({
+ inputs: [{ previousOutput: foundCell.outPoint }],
+ outputs: [{ lock, capacity: ccc.Zero }],
+ outputsData: ["0x"],
+ });
+ await tx.addCellDepsOfKnownScripts(signer.client, ccc.KnownScript.TypeId);
+ await tx.completeFeeChangeToOutput(signer, 0);
+ log("Sending burn transaction...");
+ const txHash = await signer.sendTransaction(tx);
+ log("Transaction sent:", txHash);
+ return txHash;
+}
diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/helpers.ts b/packages/demo/src/app/connected/(tools)/DeployScript/helpers.ts
new file mode 100644
index 00000000..86d94dec
--- /dev/null
+++ b/packages/demo/src/app/connected/(tools)/DeployScript/helpers.ts
@@ -0,0 +1,16 @@
+import { ccc } from "@ckb-ccc/connector-react";
+
+/** Normalize Type ID args (strip 0x, trim). */
+export function normalizeTypeIdArgs(args: string): string {
+ const s = (args || "").trim();
+ return s.startsWith("0x") ? s.slice(2) : s;
+}
+
+/** Create an unspendable lock script for immutable cells. */
+export function createImmutableLock(): ccc.Script {
+ return ccc.Script.from({
+ codeHash: `0x${"00".repeat(32)}`,
+ hashType: "data",
+ args: "0x",
+ });
+}
diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/page.tsx b/packages/demo/src/app/connected/(tools)/DeployScript/page.tsx
new file mode 100644
index 00000000..31abcc68
--- /dev/null
+++ b/packages/demo/src/app/connected/(tools)/DeployScript/page.tsx
@@ -0,0 +1,345 @@
+"use client";
+
+import FileUploadArea from "@/src/app/utils/(tools)/FileUpload/page";
+import { Button } from "@/src/components/Button";
+import { ButtonsPanel } from "@/src/components/ButtonsPanel";
+import { Message } from "@/src/components/Message";
+import { useApp } from "@/src/context";
+import { useGetExplorerLink } from "@/src/utils";
+import { ccc } from "@ckb-ccc/connector-react";
+import { RefreshCw } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ BurnButton,
+ CellFoundSection,
+ DeploymentResultSection,
+ TypeIdCellListItem,
+} from "./deployComponents";
+import { runBurn, runDeploy, type DeployResult } from "./deployLogic";
+import { createImmutableLock } from "./helpers";
+import { useDeployScript } from "./useDeployScript";
+
+export default function DeployScript() {
+ const { createSender } = useApp();
+ const { log, error } = createSender("Deploy Script");
+ const { explorerTransaction } = useGetExplorerLink();
+
+ const [file, setFile] = useState(null);
+ const [immutable, setImmutable] = useState(false);
+ const [operation, setOperation] = useState<
+ "deploy" | "update" | "burn" | null
+ >(null);
+ const [lastDeployment, setLastDeployment] = useState<
+ | (DeployResult & { immutable: boolean; action: "deployed" | "updated" })
+ | null
+ >(null);
+ const [newCellOccupiedSizes, setNewCellOccupiedSizes] = useState<{
+ signer: ccc.Signer;
+ ownedBaseSize: number | null;
+ immutableBaseSize: number | null;
+ } | null>(null);
+ const fileInputRef = useRef(null);
+ const refreshTimersRef = useRef[]>([]);
+
+ const {
+ signer,
+ typeIdArgs,
+ typeIdCells,
+ cellCreationTimestamps,
+ isScanningCells,
+ isLoadingMoreCells,
+ hasMoreTypeIdCells,
+ cellScanError,
+ foundCell,
+ handleSelectTypeIdCell,
+ clearSelection,
+ normalizeTypeIdArgs,
+ refreshTypeIdCells,
+ loadMoreTypeIdCells,
+ } = useDeployScript();
+
+ const isDeploying = operation !== null;
+
+ const refreshCellsAfterTransaction = useCallback(() => {
+ refreshTimersRef.current.forEach(clearTimeout);
+ refreshTypeIdCells();
+ refreshTimersRef.current = [
+ setTimeout(refreshTypeIdCells, 1500),
+ setTimeout(refreshTypeIdCells, 4000),
+ ];
+ }, [refreshTypeIdCells]);
+
+ useEffect(() => () => refreshTimersRef.current.forEach(clearTimeout), []);
+
+ useEffect(() => {
+ if (!signer) return;
+ let cancelled = false;
+
+ (async () => {
+ try {
+ const [{ script: lock }, type] = await Promise.all([
+ signer.getRecommendedAddressObj(),
+ ccc.Script.fromKnownScript(
+ signer.client,
+ ccc.KnownScript.TypeId,
+ "00".repeat(32),
+ ),
+ ]);
+ const ownedBaseSize = ccc.CellOutput.from({ lock, type }).occupiedSize;
+ const immutableBaseSize = ccc.CellOutput.from({
+ lock: createImmutableLock(),
+ type,
+ }).occupiedSize;
+ if (!cancelled) {
+ setNewCellOccupiedSizes({
+ signer,
+ ownedBaseSize,
+ immutableBaseSize,
+ });
+ }
+ } catch {
+ if (!cancelled) {
+ setNewCellOccupiedSizes({
+ signer,
+ ownedBaseSize: null,
+ immutableBaseSize: null,
+ });
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [signer]);
+
+ const handleBurn = useCallback(async () => {
+ if (!signer || !foundCell) return;
+ setOperation("burn");
+ setLastDeployment(null);
+ try {
+ const txHash = await runBurn(signer, foundCell, log);
+ if (!txHash) return;
+ log("Transaction sent:", explorerTransaction(txHash));
+ await signer.client.waitTransaction(txHash);
+ log("Transaction committed:", explorerTransaction(txHash));
+ clearSelection();
+ refreshCellsAfterTransaction();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ error("Burn failed:", msg);
+ } finally {
+ setOperation(null);
+ }
+ }, [
+ signer,
+ foundCell,
+ log,
+ error,
+ explorerTransaction,
+ clearSelection,
+ refreshCellsAfterTransaction,
+ ]);
+
+ const handleDeploy = useCallback(async () => {
+ if (!signer) {
+ error("Please connect a wallet first");
+ return;
+ }
+ if (!file) {
+ error("Please select a file to deploy");
+ return;
+ }
+
+ const action = foundCell ? "updated" : "deployed";
+ setOperation(foundCell ? "update" : "deploy");
+ setLastDeployment(null);
+ try {
+ log("Reading file...");
+ const result = await runDeploy(signer, file, immutable, foundCell, log);
+
+ const { txHash } = result;
+
+ log("Transaction sent:", explorerTransaction(txHash));
+ await signer.client.waitTransaction(txHash);
+ log("Transaction committed:", explorerTransaction(txHash));
+ setLastDeployment({ ...result, immutable, action });
+ if (foundCell) clearSelection();
+ refreshCellsAfterTransaction();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ error("Deployment failed:", msg);
+ } finally {
+ setOperation(null);
+ }
+ }, [
+ signer,
+ file,
+ immutable,
+ foundCell,
+ log,
+ error,
+ explorerTransaction,
+ clearSelection,
+ refreshCellsAfterTransaction,
+ ]);
+
+ const normalizedInput = normalizeTypeIdArgs(typeIdArgs);
+ const baseOccupiedSize = (() => {
+ if (foundCell) {
+ if (!immutable) return foundCell.cellOutput.occupiedSize;
+ return ccc.CellOutput.from({
+ lock: createImmutableLock(),
+ type: foundCell.cellOutput.type,
+ }).occupiedSize;
+ }
+ if (newCellOccupiedSizes && newCellOccupiedSizes.signer === signer) {
+ return immutable
+ ? newCellOccupiedSizes.immutableBaseSize
+ : newCellOccupiedSizes.ownedBaseSize;
+ }
+ return undefined;
+ })();
+ const toOccupy = !file
+ ? undefined
+ : baseOccupiedSize === null
+ ? "Unavailable"
+ : baseOccupiedSize === undefined
+ ? signer
+ ? "Calculating..."
+ : "Connect wallet to calculate"
+ : `${ccc.fixedPointToString(
+ ccc.fixedPointFrom(baseOccupiedSize + file.size),
+ )} CKB`;
+ const deployButtonLabel =
+ operation === "update"
+ ? "Updating..."
+ : operation === "deploy"
+ ? "Deploying..."
+ : !file
+ ? "Select File"
+ : typeIdArgs
+ ? "Update"
+ : "Deploy";
+
+ return (
+
+
+ Upload a file to deploy it as a CKB cell with Type ID trait. The file
+ will be stored on-chain and can be referenced by its Type ID. Select an
+ existing Type ID cell below to update it, or leave all cells unselected
+ to create a new one.
+
+
+
setImmutable((value) => !value)}
+ >
+ {foundCell && (
+
+ )}
+
+
+ {lastDeployment &&
}
+
+ {cellScanError && (
+
+
+
{cellScanError}
+
+ Retry
+
+
+
+ )}
+
+
+
+
+ Update Existing Cell
+
+
+
+ Refresh
+
+
+
+ {!isScanningCells && !cellScanError && typeIdCells.length === 0 && (
+
+ No existing Type ID cells found.
+
+ )}
+
+ {typeIdCells.length > 0 && (
+ <>
+
+
+ {typeIdCells.map((cell, index) => {
+ const cellNorm = normalizeTypeIdArgs(
+ cell.cellOutput.type?.args || "",
+ );
+ const isSelected =
+ cellNorm === normalizedInput && normalizedInput !== "";
+
+ return (
+ handleSelectTypeIdCell(cell)}
+ isSelected={isSelected}
+ creationTimestamp={
+ cellCreationTimestamps[
+ ccc.hexFrom(cell.outPoint.toBytes())
+ ]
+ }
+ />
+ );
+ })}
+
+
+ {hasMoreTypeIdCells && (
+
+
+ {isLoadingMoreCells ? "Loading..." : "Load More"}
+
+
+ )}
+ >
+ )}
+
+
+
+ fileInputRef.current?.click()}
+ disabled={isDeploying}
+ >
+ {deployButtonLabel}
+
+ {typeIdArgs && (
+
+ )}
+
+
+ );
+}
diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/useDeployScript.ts b/packages/demo/src/app/connected/(tools)/DeployScript/useDeployScript.ts
new file mode 100644
index 00000000..ebaa48c1
--- /dev/null
+++ b/packages/demo/src/app/connected/(tools)/DeployScript/useDeployScript.ts
@@ -0,0 +1,231 @@
+"use client";
+
+import { useApp } from "@/src/context";
+import { ccc } from "@ckb-ccc/connector-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { normalizeTypeIdArgs } from "./helpers";
+
+const TYPE_ID_PAGE_SIZE = 8;
+
+async function takeCells(
+ iterator: AsyncGenerator,
+ count: number,
+): Promise {
+ const cells: ccc.Cell[] = [];
+ while (cells.length < count) {
+ const next = await iterator.next();
+ if (next.done) break;
+ cells.push(next.value);
+ }
+ return cells;
+}
+
+async function prepareCellPage(client: ccc.Client, cells: ccc.Cell[]) {
+ const metadata = await Promise.all(
+ cells.map(async (cell) => {
+ const key = ccc.hexFrom(cell.outPoint.toBytes());
+ try {
+ const res = await client.getCellWithHeader(cell.outPoint);
+ if (!res?.header) return { key };
+ return {
+ key,
+ blockNumber: res.header.number,
+ timestamp: Number(res.header.timestamp),
+ };
+ } catch {
+ return { key };
+ }
+ }),
+ );
+ const metadataByKey = new Map(metadata.map((item) => [item.key, item]));
+ const timestamps: Record = {};
+
+ for (const item of metadata) {
+ if (item.timestamp != null) timestamps[item.key] = item.timestamp;
+ }
+
+ return {
+ cells: [...cells].sort((a, b) => {
+ const aBlock = metadataByKey.get(
+ ccc.hexFrom(a.outPoint.toBytes()),
+ )?.blockNumber;
+ const bBlock = metadataByKey.get(
+ ccc.hexFrom(b.outPoint.toBytes()),
+ )?.blockNumber;
+ if (aBlock == null && bBlock == null) return 0;
+ if (aBlock == null) return 1;
+ if (bBlock == null) return -1;
+ if (aBlock === bBlock) return 0;
+ return bBlock > aBlock ? 1 : -1;
+ }),
+ timestamps,
+ };
+}
+
+export function useDeployScript() {
+ const { signer } = useApp();
+
+ const [typeIdArgs, setTypeIdArgs] = useState("");
+ const [typeIdCells, setTypeIdCells] = useState([]);
+ const [isScanningCells, setIsScanningCells] = useState(false);
+ const [isLoadingMoreCells, setIsLoadingMoreCells] = useState(false);
+ const [hasMoreTypeIdCells, setHasMoreTypeIdCells] = useState(false);
+ const [bufferedTypeIdCell, setBufferedTypeIdCell] = useState(
+ null,
+ );
+ const [cellScanError, setCellScanError] = useState("");
+ const [foundCell, setFoundCell] = useState(null);
+ const [cellCreationTimestamps, setCellCreationTimestamps] = useState<
+ Record
+ >({});
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
+ const scanGenerationRef = useRef(0);
+ const activeSignerRef = useRef(undefined);
+ const cellIteratorRef = useRef | null>(null);
+
+ // Scan Type ID cells (runs on signer change or force refresh)
+ const refreshTypeIdCells = useCallback(() => {
+ setRefreshTrigger((t) => t + 1);
+ }, []);
+
+ useEffect(() => {
+ const generation = ++scanGenerationRef.current;
+ let cancelled = false;
+ let iterator: AsyncGenerator | null = null;
+ (async () => {
+ await Promise.resolve();
+ if (cancelled) return;
+
+ const signerChanged = activeSignerRef.current !== signer;
+ activeSignerRef.current = signer;
+ setIsLoadingMoreCells(false);
+ setIsScanningCells(false);
+ setCellScanError("");
+
+ if (signerChanged) {
+ setTypeIdCells([]);
+ setBufferedTypeIdCell(null);
+ setHasMoreTypeIdCells(false);
+ setCellCreationTimestamps({});
+ setTypeIdArgs("");
+ setFoundCell(null);
+ }
+
+ if (!signer) return;
+ setIsScanningCells(true);
+ try {
+ const { script: lock } = await signer.getRecommendedAddressObj();
+ const typeIdScript = await ccc.Script.fromKnownScript(
+ signer.client,
+ ccc.KnownScript.TypeId,
+ "",
+ );
+ iterator = signer.client.findCells(
+ {
+ script: typeIdScript,
+ scriptType: "type",
+ scriptSearchMode: "prefix",
+ withData: true,
+ filter: { script: lock },
+ },
+ "desc",
+ TYPE_ID_PAGE_SIZE + 1,
+ );
+ cellIteratorRef.current = iterator;
+ const cells = await takeCells(iterator, TYPE_ID_PAGE_SIZE + 1);
+ const page = await prepareCellPage(
+ signer.client,
+ cells.slice(0, TYPE_ID_PAGE_SIZE),
+ );
+ if (cancelled || scanGenerationRef.current !== generation) return;
+ setCellCreationTimestamps(page.timestamps);
+ setTypeIdCells(page.cells);
+ setBufferedTypeIdCell(cells[TYPE_ID_PAGE_SIZE] ?? null);
+ setHasMoreTypeIdCells(cells.length > TYPE_ID_PAGE_SIZE);
+ } catch (err) {
+ if (cancelled || scanGenerationRef.current !== generation) return;
+ const msg = err instanceof Error ? err.message : String(err);
+ setCellScanError(`Failed to load Type ID cells: ${msg}`);
+ } finally {
+ if (!cancelled && scanGenerationRef.current === generation) {
+ setIsScanningCells(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ if (cellIteratorRef.current === iterator) {
+ cellIteratorRef.current = null;
+ }
+ if (iterator) void iterator.return(undefined);
+ };
+ }, [signer, refreshTrigger]);
+
+ const loadMoreTypeIdCells = useCallback(async () => {
+ if (
+ !signer ||
+ !cellIteratorRef.current ||
+ !bufferedTypeIdCell ||
+ !hasMoreTypeIdCells ||
+ isLoadingMoreCells
+ ) {
+ return;
+ }
+
+ setIsLoadingMoreCells(true);
+ setCellScanError("");
+ const generation = scanGenerationRef.current;
+ const iterator = cellIteratorRef.current;
+ try {
+ const cells = await takeCells(iterator, TYPE_ID_PAGE_SIZE);
+ const candidates = [bufferedTypeIdCell, ...cells];
+ const page = await prepareCellPage(
+ signer.client,
+ candidates.slice(0, TYPE_ID_PAGE_SIZE),
+ );
+ if (scanGenerationRef.current !== generation) return;
+ setTypeIdCells((current) => [...current, ...page.cells]);
+ setCellCreationTimestamps((current) => ({
+ ...current,
+ ...page.timestamps,
+ }));
+ setBufferedTypeIdCell(candidates[TYPE_ID_PAGE_SIZE] ?? null);
+ setHasMoreTypeIdCells(candidates.length > TYPE_ID_PAGE_SIZE);
+ } catch (err) {
+ if (scanGenerationRef.current !== generation) return;
+ const msg = err instanceof Error ? err.message : String(err);
+ setCellScanError(`Failed to load more Type ID cells: ${msg}`);
+ } finally {
+ if (scanGenerationRef.current === generation) {
+ setIsLoadingMoreCells(false);
+ }
+ }
+ }, [signer, bufferedTypeIdCell, hasMoreTypeIdCells, isLoadingMoreCells]);
+
+ const handleSelectTypeIdCell = useCallback((cell: ccc.Cell) => {
+ setTypeIdArgs(cell.cellOutput.type?.args || "");
+ setFoundCell(cell);
+ }, []);
+
+ const clearSelection = useCallback(() => {
+ setTypeIdArgs("");
+ setFoundCell(null);
+ }, []);
+
+ return {
+ signer,
+ typeIdArgs,
+ typeIdCells,
+ cellCreationTimestamps,
+ isScanningCells,
+ isLoadingMoreCells,
+ hasMoreTypeIdCells,
+ cellScanError,
+ foundCell,
+ handleSelectTypeIdCell,
+ clearSelection,
+ normalizeTypeIdArgs,
+ refreshTypeIdCells,
+ loadMoreTypeIdCells,
+ };
+}
diff --git a/packages/demo/src/app/connected/page.tsx b/packages/demo/src/app/connected/page.tsx
index fd3facab..a3ee824e 100644
--- a/packages/demo/src/app/connected/page.tsx
+++ b/packages/demo/src/app/connected/page.tsx
@@ -46,6 +46,7 @@ const TABS: [ReactNode, string, keyof typeof icons, string][] = [
"text-cyan-600",
],
["Nervos DAO", "/connected/NervosDao", "Vault", "text-pink-500"],
+ ["Deploy Script", "/connected/DeployScript", "Upload", "text-purple-500"],
["Dep Group", "/utils/DepGroup", "Boxes", "text-amber-500"],
["SSRI", "/connected/SSRI", "Pill", "text-blue-500"],
["Hash", "/utils/Hash", "Barcode", "text-violet-500"],
diff --git a/packages/demo/src/app/utils/(tools)/FileUpload/page.tsx b/packages/demo/src/app/utils/(tools)/FileUpload/page.tsx
new file mode 100644
index 00000000..f32e8192
--- /dev/null
+++ b/packages/demo/src/app/utils/(tools)/FileUpload/page.tsx
@@ -0,0 +1,221 @@
+"use client";
+
+import { ccc } from "@ckb-ccc/connector-react";
+import { ArrowLeftRight, Upload, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+export async function readFileAsBytes(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ if (e.target?.result instanceof ArrayBuffer) {
+ resolve(new Uint8Array(e.target.result));
+ } else {
+ reject(new Error("Failed to read file"));
+ }
+ };
+ reader.onerror = () => reject(new Error("Failed to read file"));
+ reader.readAsArrayBuffer(file);
+ });
+}
+
+function FileDataHash({ file }: { file: File }) {
+ const [dataHash, setDataHash] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ readFileAsBytes(file)
+ .then((bytes) => {
+ if (!cancelled) setDataHash(ccc.hashCkb(bytes));
+ })
+ .catch(() => {
+ if (!cancelled) setDataHash("Unavailable");
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [file]);
+
+ return (
+
+ Data Hash: {" "}
+
+ {dataHash ?? "Calculating..."}
+
+
+ );
+}
+
+export default function FileUploadArea({
+ file,
+ onFileChange,
+ fileInputRef: externalFileInputRef,
+ toOccupy,
+ immutable = false,
+ onImmutableChange,
+ children,
+}: {
+ file: File | null;
+ onFileChange: (file: File | null) => void;
+ fileInputRef?: React.RefObject;
+ toOccupy?: string;
+ immutable?: boolean;
+ onImmutableChange?: () => void;
+ children?: React.ReactNode;
+}) {
+ const [isDragging, setIsDragging] = useState(false);
+ const internalFileInputRef = useRef(null);
+ const fileInputRef = externalFileInputRef ?? internalFileInputRef;
+
+ const handleFileSelect = (selectedFile: File) => {
+ onFileChange(selectedFile);
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ const droppedFile = e.dataTransfer.files[0];
+ if (droppedFile) {
+ handleFileSelect(droppedFile);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (file || (e.key !== "Enter" && e.key !== " ")) return;
+ e.preventDefault();
+ fileInputRef.current?.click();
+ };
+
+ const handleFileInputChange = (e: React.ChangeEvent) => {
+ const selectedFile = e.target.files?.[0];
+ if (selectedFile) {
+ handleFileSelect(selectedFile);
+ }
+ };
+
+ const handleClearFile = () => {
+ onFileChange(null);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+
+ return (
+
+ {children && (
+
+ {children}
+
+ )}
+
+
e.stopPropagation()}
+ onChange={handleFileInputChange}
+ />
+
+ {!file ? (
+
fileInputRef.current?.click()}
+ onKeyDown={handleKeyDown}
+ role="button"
+ tabIndex={0}
+ aria-label="Select file"
+ >
+
+
+
+ Drag and drop a file here, or click to select
+
+
+
+ ) : (
+
+
+
+
fileInputRef.current?.click()}
+ className="mb-2 flex max-w-full items-center gap-2 text-left text-gray-800 transition-colors hover:text-purple-500"
+ aria-label={`Replace ${file.name}`}
+ title="Replace file"
+ >
+
+
+ {file.name}
+
+
+
+
+
+ To Occupy: {" "}
+ {toOccupy ?? "Calculating..."}
+
+
+ {immutable && (
+
+ This cell will become immutable and can never be updated.
+
+ )}
+
+
+
+
+
+
+ {onImmutableChange && (
+
+
+ Options:
+
+
+ Immutable
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/packages/demo/src/components/Message.tsx b/packages/demo/src/components/Message.tsx
index 34092172..35429aa8 100644
--- a/packages/demo/src/components/Message.tsx
+++ b/packages/demo/src/components/Message.tsx
@@ -7,6 +7,7 @@ export interface MessageProps {
type?: "error" | "warning" | "info" | "success";
lines?: number;
className?: string;
+ expandable?: boolean;
}
export function Message({
@@ -15,8 +16,9 @@ export function Message({
type = "info",
lines,
className = "",
+ expandable = true,
}: MessageProps) {
- const [isExpanded, setIsExpanded] = useState(false);
+ const [isExpanded, setIsExpanded] = useState(!expandable);
let colorClass = "";
let bgColorClass = "";
@@ -41,10 +43,12 @@ export function Message({
break;
}
+ const showFull = expandable ? isExpanded : true;
+
return (
setIsExpanded(!isExpanded)}
- className={`my-2 flex cursor-pointer flex-col items-start rounded-md p-4 ${bgColorClass} ${className}`}
+ onClick={expandable ? () => setIsExpanded(!isExpanded) : undefined}
+ className={`my-2 flex flex-col items-start rounded-md p-4 ${bgColorClass} ${className} ${expandable ? "cursor-pointer" : ""}`}
>
{title ? (
@@ -57,18 +61,18 @@ export function Message({
) : undefined}
);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a974eb29..37d9f76f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -329,6 +329,9 @@ importers:
'@ckb-ccc/ssri':
specifier: workspace:*
version: link:../ssri
+ '@ckb-ccc/type-id':
+ specifier: workspace:*
+ version: link:../type-id
'@ckb-ccc/udt':
specifier: workspace:*
version: link:../udt