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..d4f69290 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/DeployScript/page.tsx @@ -0,0 +1,586 @@ +"use client"; + +import { Button } from "@/src/components/Button"; +import { ButtonsPanel } from "@/src/components/ButtonsPanel"; +import { TextInput } from "@/src/components/Input"; +import { Message } from "@/src/components/Message"; +import { useApp } from "@/src/context"; +import { formatString, useGetExplorerLink } from "@/src/utils"; +import { ccc } from "@ckb-ccc/connector-react"; +import { Loader2, Upload, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +function formatDate(date: Date): string { + return date.toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +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 ConfirmationModal({ + isOpen, + message, + txHash, +}: { + isOpen: boolean; + message: string; + txHash?: string; +}) { + if (!isOpen) return null; + + return ( +
+
+
+ +
+

{message}

+ {txHash && ( +

{txHash}

+ )} +

+ Please wait for transaction confirmation... +

+
+
+
+
+ ); +} + +export default function DeployScript() { + const { signer, createSender } = useApp(); + const { log, error } = createSender("Deploy Script"); + + const { explorerTransaction, explorerAddress } = useGetExplorerLink(); + + const [file, setFile] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [isDeploying, setIsDeploying] = useState(false); + const [typeIdArgs, setTypeIdArgs] = useState(""); + const [isWaitingConfirmation, setIsWaitingConfirmation] = useState(false); + const [confirmationMessage, setConfirmationMessage] = useState(""); + const [confirmationTxHash, setConfirmationTxHash] = useState(""); + const [foundCell, setFoundCell] = useState(null); + const [foundCellAddress, setFoundCellAddress] = useState(""); + const [userAddress, setUserAddress] = useState(""); + const [isAddressMatch, setIsAddressMatch] = useState(null); + const [isCheckingCell, setIsCheckingCell] = useState(false); + const [cellCheckError, setCellCheckError] = useState(""); + const fileInputRef = useRef(null); + const lastCheckedTypeIdRef = useRef(""); + const isCheckingRef = useRef(false); + const { client } = ccc.useCcc(); + + // Get user's wallet address + useEffect(() => { + if (!signer) { + setUserAddress(""); + setIsAddressMatch(null); + return; + } + + signer.getRecommendedAddress().then((addr) => { + setUserAddress(addr); + }); + }, [signer]); + + // Compare addresses when both are available + useEffect(() => { + if (userAddress && foundCellAddress) { + setIsAddressMatch(userAddress === foundCellAddress); + } else { + setIsAddressMatch(null); + } + }, [userAddress, foundCellAddress]); + + const handleFileSelect = (selectedFile: File) => { + setFile(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 handleFileInputChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile) { + handleFileSelect(selectedFile); + } + }; + + const handleClearFile = () => { + setFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + // Automatically check Type ID cell when typeIdArgs changes + useEffect(() => { + // Normalize Type ID args for comparison + const normalizedTypeIdArgs = typeIdArgs.trim().startsWith("0x") + ? typeIdArgs.trim().slice(2) + : typeIdArgs.trim(); + + // Skip if already checked this value or currently checking + if ( + lastCheckedTypeIdRef.current === normalizedTypeIdArgs || + isCheckingRef.current + ) { + return; + } + + // If empty, just clear state + if (!typeIdArgs.trim()) { + lastCheckedTypeIdRef.current = ""; + setFoundCell(null); + setFoundCellAddress(""); + setIsAddressMatch(null); + setCellCheckError(""); + return; + } + + const checkTypeIdCell = async () => { + // Mark as checking to prevent concurrent checks + if (isCheckingRef.current) { + return; + } + isCheckingRef.current = true; + lastCheckedTypeIdRef.current = normalizedTypeIdArgs; + + // Validate length + try { + const typeIdBytes = ccc.bytesFrom(normalizedTypeIdArgs); + if (typeIdBytes.length !== 32) { + setFoundCell(null); + setFoundCellAddress(""); + setIsAddressMatch(null); + setCellCheckError( + "Type ID args must be 32 bytes (64 hex characters)", + ); + isCheckingRef.current = false; + return; + } + } catch { + setFoundCell(null); + setFoundCellAddress(""); + setIsAddressMatch(null); + setCellCheckError("Invalid Type ID args format"); + isCheckingRef.current = false; + return; + } + + setIsCheckingCell(true); + setCellCheckError(""); + + try { + const typeIdScript = await ccc.Script.fromKnownScript( + client, + ccc.KnownScript.TypeId, + normalizedTypeIdArgs, + ); + + const cell = await client.findSingletonCellByType(typeIdScript, true); + + if (cell) { + setFoundCell(cell); + const address = ccc.Address.fromScript( + cell.cellOutput.lock, + client, + ).toString(); + setFoundCellAddress(address); + setCellCheckError(""); + // Address comparison will be handled by useEffect + } else { + setFoundCell(null); + setFoundCellAddress(""); + setIsAddressMatch(null); + setCellCheckError("Type ID cell not found on-chain"); + } + } catch (err) { + setFoundCell(null); + setFoundCellAddress(""); + setIsAddressMatch(null); + const errorMessage = err instanceof Error ? err.message : String(err); + setCellCheckError(`Error checking Type ID: ${errorMessage}`); + } finally { + setIsCheckingCell(false); + isCheckingRef.current = false; + } + }; + + // Debounce the check + const timeoutId = setTimeout(checkTypeIdCell, 500); + return () => { + clearTimeout(timeoutId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [typeIdArgs, client]); + + const handleDeploy = async () => { + if (!signer) { + error("Please connect a wallet first"); + return; + } + + if (!file) { + error("Please select a file to deploy"); + return; + } + + setIsDeploying(true); + try { + log("Reading file..."); + const fileBytes = await readFileAsBytes(file); + + log("Building transaction..."); + const { script } = await signer.getRecommendedAddressObj(); + + let tx: ccc.Transaction; + let typeIdArgsValue: string; + + if (typeIdArgs.trim() !== "") { + // Update existing Type ID cell + if (!foundCell) { + error("Type ID cell not found. Please check the Type ID args."); + return; + } + + // Check if addresses match + if (isAddressMatch === false) { + error( + "Cannot update cell: The cell's lock address does not match your wallet address. You cannot unlock this cell.", + ); + return; + } + + // Normalize Type ID args - remove 0x prefix if present + const normalizedTypeIdArgs = typeIdArgs.trim().startsWith("0x") + ? typeIdArgs.trim().slice(2) + : typeIdArgs.trim(); + + log("Updating existing Type ID cell..."); + + // Create transaction to update the cell + tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: foundCell.outPoint, + }, + ], + outputs: [ + { + ...foundCell.cellOutput, + capacity: ccc.Zero, // Zero capacity means the cell will be replaced with a new one + }, + ], + outputsData: [fileBytes], + }); + + typeIdArgsValue = normalizedTypeIdArgs; + } else { + // Create new Type ID cell + tx = ccc.Transaction.from({ + outputs: [ + { + lock: script, + type: await ccc.Script.fromKnownScript( + signer.client, + ccc.KnownScript.TypeId, + "00".repeat(32), + ), + }, + ], + outputsData: [fileBytes], + }); + + // Complete inputs for capacity + await tx.completeInputsAddOne(signer); + + // Generate type_id from first input and output index + if (!tx.outputs[0].type) { + throw new Error("Unexpected disappeared output"); + } + tx.outputs[0].type.args = ccc.hashTypeId(tx.inputs[0], 0); + typeIdArgsValue = tx.outputs[0].type.args; + + log("Type ID created:", typeIdArgsValue); + } + + // Complete fees + await tx.completeFeeBy(signer); + + // Sign and send the transaction + log("Sending transaction..."); + const txHash = await signer.sendTransaction(tx); + log("Transaction sent:", explorerTransaction(txHash)); + + // Show blocking confirmation modal + setIsWaitingConfirmation(true); + setConfirmationMessage("Waiting for transaction confirmation..."); + setConfirmationTxHash(txHash); + + await signer.client.waitTransaction(txHash); + log("Transaction committed:", explorerTransaction(txHash)); + + // Close modal after confirmation + setIsWaitingConfirmation(false); + setConfirmationMessage(""); + setConfirmationTxHash(""); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + error("Deployment failed:", errorMessage); + setIsWaitingConfirmation(false); + setConfirmationMessage(""); + setConfirmationTxHash(""); + } finally { + setIsDeploying(false); + } + }; + + 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. Leave + Type ID args empty to create a new cell, or provide existing Type ID + args to update an existing cell. + + + + + {isCheckingCell && ( + +
+ + Searching for Type ID cell on-chain... +
+
+ )} + + {foundCell && !isCheckingCell && ( + <> + +
+

+ Transaction:{" "} + {explorerTransaction(foundCell.outPoint.txHash)} +

+

+ Index:{" "} + {foundCell.outPoint.index} +

+

+ Capacity:{" "} + {ccc.fixedPointToString(foundCell.cellOutput.capacity)} CKB +

+

+ Lock Address:{" "} + {explorerAddress( + foundCellAddress, + formatString(foundCellAddress, 8, 6), + )} +

+ {foundCell.outputData && ( +

+ Data Size:{" "} + {formatFileSize(ccc.bytesFrom(foundCell.outputData).length)} +

+ )} +
+
+ {isAddressMatch === false && ( + +
+

+ The cell's lock address does not match your wallet + address. You will not be able to unlock this cell to update + it. +

+

+ Cell Lock:{" "} + {explorerAddress( + foundCellAddress, + formatString(foundCellAddress, 8, 6), + )} +

+

+ Your Address:{" "} + {userAddress + ? explorerAddress( + userAddress, + formatString(userAddress, 8, 6), + ) + : "Not connected"} +

+

+ Deployment will fail because you cannot unlock this cell. +

+
+
+ )} + {isAddressMatch === true && ( + +
+ The cell's lock address matches your wallet address. You + can update this cell. +
+
+ )} + + )} + + {cellCheckError && !isCheckingCell && ( + + {cellCheckError} + + )} + +
+ + + {!file ? ( +
+ +
+

+ Drag and drop a file here, or click to select +

+

+ Select a file from your computer +

+
+ +
+ ) : ( +
+
+
+
+ +

+ {file.name} +

+
+
+

+ Size:{" "} + {formatFileSize(file.size)} +

+

+ Type:{" "} + {file.type || "Unknown"} +

+

+ Modified:{" "} + {formatDate(new Date(file.lastModified))} +

+
+
+ +
+ +
+ )} +
+ + + + +
+ + ); +} 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/components/Message.tsx b/packages/demo/src/components/Message.tsx index 34092172..1a521385 100644 --- a/packages/demo/src/components/Message.tsx +++ b/packages/demo/src/components/Message.tsx @@ -66,9 +66,9 @@ export function Message({ } } > -

+

{children} -

+
);